使用 .NET Core 3.0 的 AssemblyLoadContext 實現插件熱載入

  • 2019 年 10 月 7 日
  • 筆記

一般情況下,一個 .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 文件)

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("rn",                      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 1Hello 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("rn",              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。