使用 .NET Core 3.0 的 AssemblyLoadContext 实现插件热加载

一般情况下,一个 .NET 程序集加载到程序中以后,它的类型信息以及原生代码等数据会一直保留在内存中,.NET 运行时无法回收它们,如果我们要实现插件热加载 (例如 Razor 或 Aspx 模版的热更新) 则会造成内存泄漏。在以往,我们可以使用 .NET Framework 的 AppDomain 机制,或者使用解释器 (有一定的性能损失),或者在编译一定次数以后重启程序 (Asp.NET 的 numRecompilesBeforeAppRestart) 来避免内存泄漏。

因为 .NET Core 不像 .NET Framework 一样支持动态创建与卸载 AppDomain,所以一直都没有好的方法实现插件热加载,好消息是,.NET Core 从 3.0 开始支持了可回收程序集 (Collectible Assembly),我们可以创建一个可回收的 AssemblyLoadContext,用它来加载与卸载程序集。关于 AssemblyLoadContext 的介绍与实现原理可以参考 yoyofx 的文章 与 我的文章。

本文会通过一个 180 行左右的示例程序,介绍如何使用 .NET Core 3.0 的 AssemblyLoadContext 实现插件热加载,程序同时使用了 Roslyn 实现动态编译,最终效果是改动插件代码后可以自动更新到正在运行的程序当中,并且不会造成内存泄漏。

完整源代码与文件夹结构

首先我们来看看完整源代码与文件夹结构,源代码分为两部分,一部分是宿主,负责编译与加载插件,另一部分则是插件,后面会对源代码的各个部分作出详细讲解。

文件夹结构:

  • pluginexample (顶级文件夹)

    • Plugin.cs (插件的代码)

    • bin (保存插件编译结果的文件夹)

    • MyPlugin.dll (插件编译后的 DLL 文件)

    • Program.cs (宿主的代码)

    • host.csproj (宿主的项目文件)

    • host (宿主的项目)

    • guest (插件的代码文件夹)

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 _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 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)));
}

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 的内容:




Exe
netcoreapp3.0






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

我们可以看到程序自动更新并执行修改以后的代码,如果你有兴趣还可以测试插件代码语法错误时会出现什么。

源代码讲解

接下来是对宿主的源代码中各个部分的详细讲解:

IPlugin 接口

public interface IPlugin : IDisposable
{
string GetMessage();
}

这是插件项目需要的实现接口,宿主项目在编译插件后会寻找程序集中实现 IPlugin 的类型,创建这个类型的实例并且使用它,创建插件时会调用构造函数,卸载插件时会调用 Dispose 方法。如果你用过 .NET Framework 的 AppDomain 机制可能会想是否需要 Marshalling 处理,答案是不需要,.NET Core 的可回收程序集会加载到当前的 AppDomain 中,回收时需要依赖 GC 清理,好处是使用简单并且运行效率高,坏处是 GC 清理有延迟,只要有一个插件中类型的实例没有被回收则插件程序集使用的数据会一直残留,导致内存泄漏。

PluginController 类型

internal class PluginController : IPlugin
{
private List _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

PluginController 的构造函数

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 监听插件文件是否有改变。

PluginController.ListenFileChanges

private void ListenFileChanges()
{
Action 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 也可以做到)。

PluginController.UnloadPlugin

private void UnloadPlugin()
{
_instance?.Dispose();
_instance = null;
_context?.Unload();
_context = null;
}

这个方法会卸载已加载的插件,首先调用 IPlugin.Dispose 通知插件正在卸载,如果插件创建了新的线程可以在 Dispose 方法中停止线程避免泄漏,然后调用 AssemblyLoadContext.Unload 允许 .NET Core 运行时卸载这个上下文加载的程序集,程序集的数据会在 GC 检测到所有类型的实例都被回收后回收 (参考文章开头的链接)。

PluginController.CompilePlugin

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)));
}

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 的实现问题并且有可能在以后的版本修复。

PluginController.GetInstance

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 扫描对象时做一些额外的工作所以默认不启用。

PluginController.GetMessage

public string GetMessage()
{
return GetInstance().GetMessage();
}

这个方法是代理方法,会获取最新的插件实例并转发调用参数与结果,如果 IPlugin 有其他方法也可以像这个方法一样写。

PluginController.Dispose

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。

你可能感兴趣的:(使用 .NET Core 3.0 的 AssemblyLoadContext 实现插件热加载)