因为 .NET Core 不像 .NET Framework 一样支持动态创建与卸载 AppDomain,所以一直都没有好的方法实现插件热加载,好消息是,.NET Core 从 3.0 开始支持了可回收程序集 (Collectible Assembly),我们可以创建一个可回收的 AssemblyLoadContext,用它来加载与卸载程序集。关于 AssemblyLoadContext 的介绍与实现原理可以参考 yoyofx 的文章 与 我的文章。
本文会通过一个 180 行左右的示例程序,介绍如何使用 .NET Core 3.0 的 AssemblyLoadContext 实现插件热加载,程序同时使用了 Roslyn 实现动态编译,最终效果是改动插件代码后可以自动更新到正在运行的程序当中,并且不会造成内存泄漏。
首先我们来看看完整源代码与文件夹结构,源代码分为两部分,一部分是宿主,负责编译与加载插件,另一部分则是插件,后面会对源代码的各个部分作出详细讲解。
文件夹结构:
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();
}
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;
_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;
}
}
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 持平),目前还没有正式发布。