通常状况下,一个 .NET 程序集加载到程序中之后,它的类型信息以及原生代码等数据会一直保留在内存中,.NET 运行时没法回收它们,若是咱们要实现插件热加载 (例如 Razor 或 Aspx 模版的热更新) 则会形成内存泄漏。在以往,咱们可使用 .NET Framework 的 AppDomain 机制,或者使用解释器 (有必定的性能损失),或者在编译必定次数之后重启程序 (Asp.NET 的 numRecompilesBeforeAppRestart) 来避免内存泄漏。html
由于 .NET Core 不像 .NET Framework 同样支持动态建立与卸载 AppDomain,因此一直都没有好的方法实现插件热加载,好消息是,.NET Core 从 3.0 开始支持了可回收程序集 (Collectible Assembly),咱们能够建立一个可回收的 AssemblyLoadContext,用它来加载与卸载程序集。关于 AssemblyLoadContext 的介绍与实现原理能够参考 yoyofx 的文章 与 个人文章。git
本文会经过一个 180 行左右的示例程序,介绍如何使用 .NET Core 3.0 的 AssemblyLoadContext 实现插件热加载,程序同时使用了 Roslyn 实现动态编译,最终效果是改动插件代码后能够自动更新到正在运行的程序当中,而且不会形成内存泄漏。github
首先咱们来看看完整源代码与文件夹结构,源代码分为两部分,一部分是宿主,负责编译与加载插件,另外一部分则是插件,后面会对源代码的各个部分做出详细讲解。web
文件夹结构:app
Program.cs 的内容:框架
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.Loader; using System.Threading; namespace Common { public interface IPlugin : IDisposable { string GetMessage(); } } namespace Host { using Common; internal class PluginController : IPlugin { private List<Assembly> _defaultAssemblies; private AssemblyLoadContext _context; private string _pluginName; private string _pluginDirectory; private volatile IPlugin _instance; private volatile bool _changed; private object _reloadLock; private FileSystemWatcher _watcher; public PluginController(string pluginName, string pluginDirectory) { _defaultAssemblies = AssemblyLoadContext.Default.Assemblies .Where(assembly => !assembly.IsDynamic) .ToList(); _pluginName = pluginName; _pluginDirectory = pluginDirectory; _reloadLock = new object(); ListenFileChanges(); } private void ListenFileChanges() { Action<string> onFileChanged = path => { if (Path.GetExtension(path).ToLower() == ".cs") _changed = true; }; _watcher = new FileSystemWatcher(); _watcher.Path = _pluginDirectory; _watcher.IncludeSubdirectories = true; _watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; _watcher.Changed += (sender, e) => onFileChanged(e.FullPath); _watcher.Created += (sender, e) => onFileChanged(e.FullPath); _watcher.Deleted += (sender, e) => onFileChanged(e.FullPath); _watcher.Renamed += (sender, e) => { onFileChanged(e.FullPath); onFileChanged(e.OldFullPath); }; _watcher.EnableRaisingEvents = true; } private void UnloadPlugin() { _instance?.Dispose(); _instance = null; _context?.Unload(); _context = null; } private Assembly CompilePlugin() { var binDirectory = Path.Combine(_pluginDirectory, "bin"); var dllPath = Path.Combine(binDirectory, $"{_pluginName}.dll"); if (!Directory.Exists(binDirectory)) Directory.CreateDirectory(binDirectory); if (File.Exists(dllPath)) { File.Delete($"{dllPath}.old"); File.Move(dllPath, $"{dllPath}.old"); } var sourceFiles = Directory.EnumerateFiles( _pluginDirectory, "*.cs", SearchOption.AllDirectories); var compilationOptions = new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Debug); var references = _defaultAssemblies .Select(assembly => assembly.Location) .Where(path => !string.IsNullOrEmpty(path) && File.Exists(path)) .Select(path => MetadataReference.CreateFromFile(path)) .ToList(); var syntaxTrees = sourceFiles .Select(p => CSharpSyntaxTree.ParseText(File.ReadAllText(p))) .ToList(); var compilation = CSharpCompilation.Create(_pluginName) .WithOptions(compilationOptions) .AddReferences(references) .AddSyntaxTrees(syntaxTrees); var emitResult = compilation.Emit(dllPath); if (!emitResult.Success) { throw new InvalidOperationException(string.Join("\r\n", emitResult.Diagnostics.Where(d => d.WarningLevel == 0))); } //return _context.LoadFromAssemblyPath(Path.GetFullPath(dllPath)); using (var stream = File.OpenRead(dllPath)) { var assembly = _context.LoadFromStream(stream); return assembly; } } private IPlugin GetInstance() { var instance = _instance; if (instance != null && !_changed) return instance; lock (_reloadLock) { instance = _instance; if (instance != null && !_changed) return instance; UnloadPlugin(); _context = new AssemblyLoadContext( name: $"Plugin-{_pluginName}", isCollectible: true); var assembly = CompilePlugin(); var pluginType = assembly.GetTypes() .First(t => typeof(IPlugin).IsAssignableFrom(t)); instance = (IPlugin)Activator.CreateInstance(pluginType); _instance = instance; _changed = false; } return instance; } public string GetMessage() { return GetInstance().GetMessage(); } public void Dispose() { UnloadPlugin(); _watcher?.Dispose(); _watcher = null; } } internal class Program { static void Main(string[] args) { using (var controller = new PluginController("MyPlugin", "../guest")) { bool keepRunning = true; Console.CancelKeyPress += (sender, e) => { e.Cancel = true; keepRunning = false; }; while (keepRunning) { try { Console.WriteLine(controller.GetMessage()); } catch (Exception ex) { Console.WriteLine($"{ex.GetType()}: {ex.Message}"); } Thread.Sleep(1000); } } } } }
host.csproj 的内容:编辑器
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>netcoreapp3.0</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.3.1" /> </ItemGroup> </Project>
Plugin.cs 的内容:函数
using System; using Common; namespace Guest { public class MyPlugin : IPlugin { public MyPlugin() { Console.WriteLine("MyPlugin loaded"); } public string GetMessage() { return "Hello 1"; } public void Dispose() { Console.WriteLine("MyPlugin unloaded"); } } }
进入 pluginexample/host
下运行 dotnet run
便可启动宿主程序,这时宿主程序会自动编译与加载插件,检测插件文件的变化并在变化时从新编译加载。你能够在运行后修改 pluginexample/guest/Plugin.cs
中的 Hello 1
为 Hello 2
,以后能够看到相似如下的输出:性能
MyPlugin loaded Hello 1 Hello 1 Hello 1 MyPlugin unloaded MyPlugin loaded Hello 2 Hello 2
咱们能够看到程序自动更新并执行修改之后的代码,若是你有兴趣还能够测试插件代码语法错误时会出现什么。测试
接下来是对宿主的源代码中各个部分的详细讲解:
public interface IPlugin : IDisposable { string GetMessage(); }
这是插件项目须要的实现接口,宿主项目在编译插件后会寻找程序集中实现 IPlugin 的类型,建立这个类型的实例而且使用它,建立插件时会调用构造函数,卸载插件时会调用 Dispose 方法。若是你用过 .NET Framework 的 AppDomain 机制可能会想是否须要 Marshalling 处理,答案是不须要,.NET Core 的可回收程序集会加载到当前的 AppDomain 中,回收时须要依赖 GC 清理,好处是使用简单而且运行效率高,坏处是 GC 清理有延迟,只要有一个插件中类型的实例没有被回收则插件程序集使用的数据会一直残留,致使内存泄漏。
internal class PluginController : IPlugin { private List<Assembly> _defaultAssemblies; private AssemblyLoadContext _context; private string _pluginName; private string _pluginDirectory; private volatile IPlugin _instance; private volatile bool _changed; private object _reloadLock; private FileSystemWatcher _watcher;
这是管理插件的代理类,在内部它负责编译与加载插件,而且把对 IPlugin 接口的方法调用转发到插件的实现中。类成员包括默认 AssemblyLoadContext 中的程序集列表 _defaultAssemblies
,用于加载插件的自定义 AssemblyLoadContext _context
,插件名称与文件夹,插件实现 _instance
,标记插件文件是否已改变的 _changed
,防止多个线程同时编译加载插件的 _reloadLock
,与监测插件文件变化的 _watcher
。
public PluginController(string pluginName, string pluginDirectory) { _defaultAssemblies = AssemblyLoadContext.Default.Assemblies .Where(assembly => !assembly.IsDynamic) .ToList(); _pluginName = pluginName; _pluginDirectory = pluginDirectory; _reloadLock = new object(); ListenFileChanges(); }
构造函数会从 AssemblyLoadContext.Default.Assemblies
中获取默认 AssemblyLoadContext 中的程序集列表,包括宿主程序集、System.Runtime 等,这个列表会在 Roslyn 编译插件时使用,表示插件编译时须要引用哪些程序集。以后还会调用 ListenFileChanges
监听插件文件是否有改变。
private void ListenFileChanges() { Action<string> onFileChanged = path => { if (Path.GetExtension(path).ToLower() == ".cs") _changed = true; }; _watcher = new FileSystemWatcher(); _watcher.Path = _pluginDirectory; _watcher.IncludeSubdirectories = true; _watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; _watcher.Changed += (sender, e) => onFileChanged(e.FullPath); _watcher.Created += (sender, e) => onFileChanged(e.FullPath); _watcher.Deleted += (sender, e) => onFileChanged(e.FullPath); _watcher.Renamed += (sender, e) => { onFileChanged(e.FullPath); onFileChanged(e.OldFullPath); }; _watcher.EnableRaisingEvents = true; }
这个方法建立了 FileSystemWatcher
,监听插件文件夹下的文件是否有改变,若是有改变而且改变的是 C# 源代码 (.cs 扩展名) 则设置 _changed
成员为 true,这个成员标记插件文件已改变,下次访问插件实例的时候会触发从新加载。
你可能会有疑问,为何不在文件改变后马上触发从新加载插件,一个缘由是部分文件编辑器的保存文件实现可能会致使改变的事件连续触发几回,延迟触发能够避免编译屡次,另外一个缘由是编译过程当中出现的异常能够传递到访问插件实例的线程中,方便除错与调试 (尽管使用 ExceptionDispatchInfo 也能够作到)。
private void UnloadPlugin() { _instance?.Dispose(); _instance = null; _context?.Unload(); _context = null; }
这个方法会卸载已加载的插件,首先调用 IPlugin.Dispose
通知插件正在卸载,若是插件建立了新的线程能够在 Dispose
方法中中止线程避免泄漏,而后调用 AssemblyLoadContext.Unload
容许 .NET Core 运行时卸载这个上下文加载的程序集,程序集的数据会在 GC 检测到全部类型的实例都被回收后回收 (参考文章开头的连接)。
private Assembly CompilePlugin() { var binDirectory = Path.Combine(_pluginDirectory, "bin"); var dllPath = Path.Combine(binDirectory, $"{_pluginName}.dll"); if (!Directory.Exists(binDirectory)) Directory.CreateDirectory(binDirectory); if (File.Exists(dllPath)) { File.Delete($"{dllPath}.old"); File.Move(dllPath, $"{dllPath}.old"); } var sourceFiles = Directory.EnumerateFiles( _pluginDirectory, "*.cs", SearchOption.AllDirectories); var compilationOptions = new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Debug); var references = _defaultAssemblies .Select(assembly => assembly.Location) .Where(path => !string.IsNullOrEmpty(path) && File.Exists(path)) .Select(path => MetadataReference.CreateFromFile(path)) .ToList(); var syntaxTrees = sourceFiles .Select(p => CSharpSyntaxTree.ParseText(File.ReadAllText(p))) .ToList(); var compilation = CSharpCompilation.Create(_pluginName) .WithOptions(compilationOptions) .AddReferences(references) .AddSyntaxTrees(syntaxTrees); var emitResult = compilation.Emit(dllPath); if (!emitResult.Success) { throw new InvalidOperationException(string.Join("\r\n", emitResult.Diagnostics.Where(d => d.WarningLevel == 0))); } //return _context.LoadFromAssemblyPath(Path.GetFullPath(dllPath)); using (var stream = File.OpenRead(dllPath)) { var assembly = _context.LoadFromStream(stream); return assembly; } }
这个方法会调用 Roslyn 编译插件代码到 DLL,并使用自定义的 AssemblyLoadContext 加载编译后的 DLL。首先它须要删除原有的 DLL 文件,由于卸载程序集有延迟,原有的 DLL 文件在 Windows 系统上极可能会删除失败并提示正在使用,因此须要先重命名并在下次删除。接下来它会查找插件文件夹下的全部 C# 源代码,用 CSharpSyntaxTree
解析它们,并用 CSharpCompilation
编译,编译时引用的程序集列表是构造函数中取得的默认 AssemblyLoadContext 中的程序集列表 (包括宿主程序集,这样插件代码才可使用 IPlugin 接口)。编译成功后会使用自定义的 AssemblyLoadContext 加载编译后的 DLL 以支持卸载。
这段代码中有两个须要注意的部分,第一个部分是 Roslyn 编译失败时不会抛出异常,编译后须要判断 emitResult.Success
并从 emitResult.Diagnostics
找到错误信息;第二个部分是加载插件程序集必须使用 AssemblyLoadContext.LoadFromStream
从内存数据加载,若是使用 AssemblyLoadContext.LoadFromAssemblyPath
那么下次从同一个路径加载时仍然会返回第一次加载的程序集,这多是 .NET Core 3.0 的实现问题而且有可能在之后的版本修复。
private IPlugin GetInstance() { var instance = _instance; if (instance != null && !_changed) return instance; lock (_reloadLock) { instance = _instance; if (instance != null && !_changed) return instance; UnloadPlugin(); _context = new AssemblyLoadContext( name: $"Plugin-{_pluginName}", isCollectible: true); var assembly = CompilePlugin(); var pluginType = assembly.GetTypes() .First(t => typeof(IPlugin).IsAssignableFrom(t)); instance = (IPlugin)Activator.CreateInstance(pluginType); _instance = instance; _changed = false; } return instance; }
这个方法是获取最新插件实例的方法,若是插件实例已建立而且文件没有改变,则返回已有的实例,不然卸载原有的插件、从新编译插件、加载并生成实例。注意 AssemblyLoadContext 类型在 netstandard (包括 2.1) 中是 abstract 类型,不能直接建立,只有 netcoreapp3.0 才能够直接建立 (目前也只有 .NET Core 3.0 支持这项机制),若是须要支持可回收则建立时须要设置 isCollectible 参数为 true,由于支持可回收会让 GC 扫描对象时作一些额外的工做因此默认不启用。
public string GetMessage() { return GetInstance().GetMessage(); }
这个方法是代理方法,会获取最新的插件实例并转发调用参数与结果,若是 IPlugin 有其余方法也能够像这个方法同样写。
public void Dispose() { UnloadPlugin(); _watcher?.Dispose(); _watcher = null; }
这个方法支持主动释放 PluginController,会卸载已加载的插件而且中止监听插件文件。由于 PluginController 没有直接管理非托管资源,而且 AssemblyLoadContext 的析构函数 会触发卸载,因此 PluginController 不须要提供析构函数。
static void Main(string[] args) { using (var controller = new PluginController("MyPlugin", "../guest")) { bool keepRunning = true; Console.CancelKeyPress += (sender, e) => { e.Cancel = true; keepRunning = false; }; while (keepRunning) { try { Console.WriteLine(controller.GetMessage()); } catch (Exception ex) { Console.WriteLine($"{ex.GetType()}: {ex.Message}"); } Thread.Sleep(1000); } } }
主函数建立了 PluginController 实例并指定了上述的 guest 文件夹为插件文件夹,以后每隔 1 秒调用一次 GetMessage 方法,这样插件代码改变的时候咱们能够从控制台输出中观察的到,若是插件代码包含语法错误则调用时会抛出异常,程序会继续运行并在下一次调用时从新尝试编译与加载。
本文的介绍就到此为止了,在本文中咱们看到了一个最简单的 .NET Core 3.0 插件热加载实现,这个实现仍然有不少须要改进的地方,例如如何管理多个插件、怎么在重启宿主程序后避免从新编译全部插件,编译的插件代码如何调试等,若是你有兴趣能够解决它们,作一个插件系统嵌入到你的项目中,或者写一个新的框架。
关于 ZKWeb,3.0 会使用了本文介绍的机制实现插件热加载,但由于我目前已经退出 IT 行业,全部开发都是业余空闲时间作的,因此基本上不会有很大的更新,ZKWeb 更多的会做为一个框架的实现参考。此外,我正在使用 C++ 编写 HTTP 框架 cpv-framework,主要着重性能 (吞吐量是 .NET Core 3.0 的两倍以上,与 actix-web 持平),目前尚未正式发布。
关于书籍,出版社约定 11 月但目前尚未让我看修改过的稿件 (尽管我问的时候会回答),因此很大可能会继续延期,抱歉让期待出版的同窗们久等了,书籍目前仍是基于 .NET Core 2.2 而不是 .NET Core 3.0。