【.net 深呼吸】程序集的熱更新


當一個程序集被加載使用的時候,出於數據的完整性和安全性考慮,程序集文件(在99.9998%的情況下是.dll文件)會被鎖定,如果此時你想更新程序集(實際上是替換dll文件),是不可以操作的,這時你得把應用程序退出,替換文件后再啟動程序。

多數情況下這樣做是可行的,只是有時候,比如ASP.NET或一些需要一直運行的服務進程,重啟程序來更新好像不太好。

要是想對程序集進行熱更新,即在程序運行的同時替換文件,有一個大家很熟悉的方案——影像復制,如果你不熟悉.net,你肯定沒聽說過的。當然了,這個叫法也挺難聽的,沒辦法,只好這樣翻譯,原詞是 Shadow Copy ,Shadow是影子,陰影,影像的意思,那也只好這么翻譯了。不過,你不用擔心它很抽象很高端,其實,只要用心學,沒什么東西是攻不克的。

我用一句話來概括一下影子復制(也可以叫拷貝,但我不喜歡拷貝這個詞,很黃很暴力的感覺)——應用程序域在加載程序集時,會把程序集文件復制到另一個地方,再進行加載。這樣一來,當程序集文件被使用時,它鎖定的是復制后的文件,即原始文件我們可以放心地去替換了,等到合適的時間,把應用程序重新啟動一下,再次運行時,就會自動把最新的程序集復制到緩存的目錄下,然后執行最新版本的代碼。最好把這些代碼的調用放到一個新的應用程序域中執行,因為這樣的好處是不用重新啟動應用程序,而只要把某個應用程序域卸載掉再重新創建一個新的,就會自動加載最新的程序集了。而且,通常你都應該這么做的,創建一個應用程序域,在里面執行代碼,執行完了就把應用程序域卸掉,可以節約資源。

應用程序在運行的時候,默認會創建一個應用程序域的,說白了,一個進程中至少會有一個應用程序域,如果你把某段代碼放到一個新的應用程序域中執行,並且你希望執行完后,可以把結果傳回給主應用程序域,那就用老周以前寫過的方法,記得老周前面寫過的,想按引用傳遞對象,就從MarshalByRefObject類派生,想讓對象按值傳遞,就讓它支持序列化。

 

在創建新的應用程序域時,可以同時傳遞一個SetupInfo對象,這個對象有一個 ShadowCopyFiles 屬性,雖然它定義的類型是 string,但你千萬不要理解錯,不要把一個文件的路徑賦給它。老周以前就見到一位朋友理解錯了,它誤以為這個屬性是用來設置復制程序集文件的緩存路徑,結果代碼寫了老是不行。唉,這就是不看MSDN的下場。

不要亂來,設置復制程序集的緩存目錄是 CachePath 屬性,不是 ShadowCopyFiles 屬性。ShadowCopyFiles 屬性只能用兩個字符串的值,如果要啟用影像復制,就設置為 true,如果想禁用,就設置 false 或者干脆保持默認的null值。也就說,它是一個用字符串表示的 bool 值。

 

下面,我們用一個例子來表演一下,很精彩的。

首先,弄一個類庫項目,然后在里面寫一個全宇宙最簡單的類。

namespace TestLib
{
    public class Demo
    {
        public string Call()
        {
            return "Ver - 3";
        }
    }
}

 

而主啟動項目是一個控制台應用,這里,老周希望設置新應用程序域的 PrivateBinPath ,這個屬性可以設置一堆目錄,可以是相對路徑,其實應該是用相對路徑的,因為這個目錄不能亂設的,它必須是應用程序目錄的子目錄。如果是多個目錄,可以用英文的分號(;)來分隔。

ApplicationBase路徑指定的是應用程序,即.exe啟動的目錄,不管你創建多個新的應用程序域,這個目錄都必須指定為當前exe的啟動目錄。否則你試試看,不能運行的,因為應用程序域之間是隔離的,所以在新創建的應用程序域中也必須加載當前exe所在的程序集,這個程序集是必須的,因為它是主入口點。

而 PrivateBinPath 屬性所指定的路徑必須為應用程序目錄的子目錄,比如,我們的項目在Debug模式下,通常是把exe生成到 bin \ Debug目錄下的,所以,你可以在Debug目錄下創建一個子目錄,我這里創了一個,叫ExtDlls,隨后我會把要用到的dll文件放在這個目錄中,並設置 PrivateBinPath = "ExtDlls" ,這樣一來,就算項目不引用這個類庫項目,在運行階段它都會自動到這個 ExtDlls 目錄下去找,找到了就加載,要是找不到就會“呵呵”。

我這個類庫項目名叫 TestLib,為了讓它生成后能夠自動把最新的版本復制到 ExtDlls 目錄中,可以打開類庫項目的項目屬性窗口,切換到【生成事件】頁,並在“后期生成命令行”中輸入以下命令:

copy "$(TargetPath)" "$(SolutionDir)MyApp\bin\Debug\ExtDlls\"

這么一搞,每次我重新生成類庫項目后,就會自動把dll文件復制過去。

 

好,下面的重點放在主項目上,在代碼中,可以創建一個新的應用程序域,然后調用類庫中的代碼。

                AppDomainSetup setup = new AppDomainSetup();
                setup.ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
                setup.ApplicationName = "ExtFuncs";
                setup.PrivateBinPath = "ExtDlls";
                setup.ShadowCopyFiles = "true";
                AppDomain newDom = AppDomain.CreateDomain("hello", null, setup);

                newDom.DoCallBack(() =>
                {
                    Type t = Type.GetType("TestLib.Demo, TestLib");
                    // 獲取公共無參構造函數
                    ConstructorInfo costr = t.GetConstructor(new Type[] { });
                    // 調用構造函數,創建類型實例
                    object instance = costr.Invoke(new object[] { });
                    // 找到要調用的方法
                    MethodInfo m = t.GetMethod("Call", BindingFlags.Public | BindingFlags.Instance);
                    // 調用方法,得到返回值
                    object retval = m.Invoke(instance, new object[] { });
                    Console.WriteLine($"調用輸出:{retval}");
                    Console.WriteLine("\n===================================");

                    // 輸出引用程序集的路徑
                    var refAsses = AppDomain.CurrentDomain.GetAssemblies();
                    foreach (var ass in refAsses)
                    {
                        Console.WriteLine("名稱:"+ ass.GetName().Name);
                        Console.WriteLine("路徑:" + ass.Location);
                        Console.WriteLine();
                    }
                });
                AppDomain.Unload(newDom); //卸載應用程序域

 

實驗表明,ApplicationName 屬性的值可以隨便寫,但 ApplicationBase 屬性必須是當前應用程序所在目錄。

這里我用的是反射的方法來調用的,DoCallBack 方法允許在另一個應用程序域中執行代碼,代碼內容通過一個委托來關聯。

 

在反射調用完測試類庫后,我還用這段代碼來輸出新的應用程序域所引用的所有程序集的路徑。

                    var refAsses = AppDomain.CurrentDomain.GetAssemblies();
                    foreach (var ass in refAsses)
                    {
                        Console.WriteLine("名稱:"+ ass.GetName().Name);
                        Console.WriteLine("路徑:" + ass.Location);
                        Console.WriteLine();
                    }

由於這段代碼是在新的應用程序域中執行的,所以 CurrentDomain 屬性所指的是新創建的應用程序域,而不是進程運行時創建的默認域。

之所以要在反射之后輸出路徑是因為,應用程序域是動態加載程序集,即當你用到類庫中的類型時才會加載,如果不訪問類庫中的任何東西,是不會加載這個程序集的。

我為啥要輸出路徑呢,就是讓大伙能夠清楚地看到,TestLib 類庫已經被復制到另一個目錄中執行了。請看:

從這個圖你就看到,默認的緩存程序集的路徑是在你的用戶配置目錄下的 AppData \ Local \ assembly 下面。

 

可能你覺得這個默認的緩存路徑不好,能不能自定義啊?能,前面老周提了一下 CachePath 屬性,對,你給這個屬性分配一個路徑,緩存的程序集就會放到這個自定義路徑中了。比如,我在Debug目錄下新建一個 TempAss 目錄,用來存放臨時復制的程序集。

setup.CachePath = CACHE_PATH;

 

然后你再看它的路徑。

看,是不是變了?

 

現在,我們來驗證一下,是不是可以熱更新。

先運行exe,輸出Ver - 1 ,如圖。

 

好,保持exe運行着,不要關,然后修改一下類庫項目的代碼。

    public class Demo
    {
        public string Call()
        {
            return "Ver - 2";
        }
    }

把 1 改為 2。

 

重新生成一下類庫項目,它會自動復制到 ExtDlls 目錄。

現在在控制台窗口按除 Esc 以外的任意鍵,就會重新建一個應用程序域,並加載執行類庫代碼,因為我弄了個循環,只有遇到Esc鍵才會退出。

這時候,你看到,輸出的內容變了。

 

不用退出應用程序,就能實現程序集文件的替換了,這對於服務應用特別好使。

 

為了寫代碼有智能提示,如果我不想用反射呢,而是直接在VS中引用類庫項目呢,試試,引用之后,把所TestLib屬性中的“復制本地”改為false,因為 ExtDlls 目錄下已經有文件了,不必再復制了,在新的應用程序域中執行時,會自動搜索。

 

然后把DoCallBack 方法中的代碼改一下:

                newDom.DoCallBack(() =>
                {
                    TestLib.Demo dm = new TestLib.Demo();
                    Console.WriteLine($"輸出:{dm.Call()}");
                });

現在代碼就變得簡單多了,是吧,才兩行就完事了。

 

那能不能運行呢,當然能了。看。

 

怎么樣,牛逼烘烘吧。

好了,老周的芹菜炒魚蛋飯做好了,肚子餓了,開飯了。

示例源代碼下載

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM