5

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

 3 years ago
source link: https://www.cnblogs.com/tcjiaan/p/6023782.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

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

怎么样,牛逼烘烘吧。

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

示例源代码下载


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK