【.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()}"); });
现在代码就变得简单多了,是吧,才两行就完事了。
那能不能运行呢,当然能了。看。
怎么样,牛逼烘烘吧。
好了,老周的芹菜炒鱼蛋饭做好了,肚子饿了,开饭了。