【.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()}");
                });

现在代码就变得简单多了,是吧,才两行就完事了。

 

那能不能运行呢,当然能了。看。

 

怎么样,牛逼烘烘吧。

好了,老周的芹菜炒鱼蛋饭做好了,肚子饿了,开饭了。

示例源代码下载

 

posted @ 2016-11-02 19:15  东邪独孤  阅读(9627)  评论(31编辑  收藏  举报