一般情况下,一个 .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 (顶级文件夹)
- host (宿主的项目)
- Program.cs (宿主的代码)
- host.csproj (宿主的项目文件)
- guest (插件的代码文件夹)
- Plugin.cs (插件的代码)
- bin (保存插件编译结果的文件夹)
- MyPlugin.dll (插件编译后的 DLL 文件)
- host (宿主的项目)
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 compilation