一般情況下,一個 .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 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
我們可以看到程式自動更新并執行修改以后的代碼,如果你有興趣還可以測驗插件代碼語法錯誤時會出現什么,
源代碼講解
接下來是對宿主的源代碼中各個部分的詳細講解:
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<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,
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<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 也可以做到),
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)));
}
//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 的實作問題并且有可能在以后的版本修復,
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,
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/108771.html
標籤:.NET Core
