.Net Core 到現在 .Net10 有一點小困擾,就是我有時候要更新的時候就是要先停下程式,當然先不考慮現在正在進行中的 Threads
情況下,之前 .Net framewrok 式可以直接替換的,最近在想主程式既然不能關閉,但是我可不可以模組化更新..
這次目標很單純 ,有三個專案
第一個 CShellCore ,這主要就是制定介面 ICShellModule、ICShellModuleContext、ICShellShell 全在這裡
第二個 CSCrypto ,可被抽換的模組,實作 ICShellModule 內部放一個 ToMd5(string) ,進行 Md5 之後回拋
第三個 CSMain ,主要就是實作 動態載入 CSCrypto.dll,呼叫後立刻卸載
接下來就是開始程式碼了
1. CShellCore Project - ICShellShell.cs
// 模組對外需要支援的三個生命週期
// OnLoad: 載入時會被呼叫,多半做一次性的準備
// OnStart: 模組開始運作,如果有背景任務可以在這裡啟動
// OnStop: 模組結束時的收尾,釋放資源或停止任務
public interface ICShellModule
{
Task OnLoad(ICShellModuleContext context);
Task OnStart();
Task OnStop();
}
// Shell 提供給模組的存取介面
// 未來如果有 DI 容器或設定資料,都可以透過這裡提供給模組
public interface ICShellModuleContext
{
T? GetService<T>();
}
// Shell 需要具備的基本操作
// LoadModules: 讀取並載入模組
// StartAsync: 啟動所有模組
// StopAsync: 停止並釋放所有模組
public interface ICShellShell
{
void LoadModules(string folderPath);
Task StartAsync();
Task StopAsync();
}
CShellCore Project - ModuleLoadContext.cs
// 自訂可卸載的 AssemblyLoadContext
// 用來載入每一次執行所需的 dll,並在結束後釋放
// Plugin 架構用這個來控制 dll 的生命週期
public class ModuleLoadContext : AssemblyLoadContext
{
// 透過 AssemblyDependencyResolver 來處理相依檔案的位置
// 避免手動找 dll
private AssemblyDependencyResolver _resolver;
// true = 可卸載,這樣才可以釋放 dll
// path 只用來初始化 resolver,不會鎖住原本的檔案
public ModuleLoadContext(string path) : base(true)
{
_resolver = new AssemblyDependencyResolver(path);
}
// 決定要怎麼載入相依的 assembly
// 如果 resolver 找得到,就直接從那個路徑載入
// 找不到就回傳 null 讓預設行為處理
protected override Assembly? Load(AssemblyName name)
{
var path = _resolver.ResolveAssemblyToPath(name);
return path != null ? LoadFromAssemblyPath(path) : null;
}
}詳細內容我就寫在註解裡面了
2. CSCrypto Project - CryptoModule.cs
引入 CShellCore 專案,然後實作 ICShellModule
using CShellCore;
using System.Security.Cryptography;
using System.Text;
namespace CSCrypto
{
// 模組範例,用來提供 MD5 加密
// 被 shell 動態載入,不適合在建構子做太多事
public class CryptoModule : ICShellModule
{
// 模組載入時呼叫。需要初始化可以放這裡
public Task OnLoad(ICShellModuleContext context) => Task.CompletedTask;
// 模組啟動時呼叫。若有背景處理可以在這裡啟動
public Task OnStart() => Task.CompletedTask;
// 模組結束時呼叫。用來清理需要釋放的資源
public Task OnStop() => Task.CompletedTask;
// 主要功能:把字串做成 MD5
// 示範用途,沒有做任何安全性強化
public string ToMd5(string input)
{
using var md5 = MD5.Create();
var bytes = Encoding.UTF8.GetBytes(input);
var hash = md5.ComputeHash(bytes);
// return Convert.ToHexString(hash) ;;
// 回傳十六進位字串,用來確認 dll 是否真的有被換掉
//等到執行期我在換成這個版本換成新的dll 放入
return Convert.ToHexString(hash) + "---更換過後DLL";
}
}
}
3. CSMain 主要就是呼叫,呼叫前得先實作 MyShell 是主程式(CSMain)與外掛 dll(CSCrypto)之間的控制器
負責載入、執行、釋放模組,並保護 CSMain 不會直接碰到 dll 內部的細節
MyShell.cs
using CShellCore;
using System.Reflection;
namespace MainConsole
{
public class MyShell : ICShellShell, ICShellModuleContext
{
// 記住 dll 的來源路徑
// 只作為讀取用,每次執行都會重新載入,不會常駐
private string? _sourcedllPath;
// 找到要載入的 dll 位置
// Shell 本身不處理模組內容,只記錄路徑
public void LoadModules(string folderPath)
{
Directory.CreateDirectory(folderPath);
_sourcedllPath = Directory.GetFiles(folderPath, "*.dll").FirstOrDefault();
}
// Shell 啟動與停止,目前沒有額外工作,保留彈性
public async Task StartAsync() => await Task.CompletedTask;
public async Task StopAsync() => await Task.CompletedTask;
// 目前沒有真正的 service provider,因此回傳預設值
public T? GetService<T>() => default;
// 每次 Execute 都會重新載入 dll
// 用 LoadFromStream,避免鎖檔案
// 執行完後馬上卸載,下一次會使用新版本 dll
public string Execute(string input)
{
if (_sourcedllPath == null)
return "(module not found)";
// 讀取 dll 的原始 bytes,不會造成檔案被鎖住
byte[] raw = File.ReadAllBytes(_sourcedllPath);
var alc = new ModuleLoadContext(_sourcedllPath);
// 以記憶體來源載入 dll,避免 LoadFromAssemblyPath 造成鎖檔
using var ms = new MemoryStream(raw);
var asm = alc.LoadFromStream(ms);
// 找到模組類型,必須實作 ICShellModule
var type = asm.GetTypes()
.First(t => typeof(ICShellModule).IsAssignableFrom(t) && !t.IsInterface);
var module = (ICShellModule)Activator.CreateInstance(type)!;
// 模組生命週期流程
module.OnLoad(this).Wait();
module.OnStart().Wait();
// 呼叫 ToMd5(或其他模組提供的方法)
var result = type.InvokeMember(
"ToMd5",
BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.Public,
null,
module,
new object[] { input });
module.OnStop().Wait();
// 卸載 ALC,釋放 dll
alc.Unload();
GC.Collect();
GC.WaitForPendingFinalizers();
return result?.ToString() ?? "";
}
}
}
接下來就是呼叫了
static async Task Main(string[] args)
{
Console.WriteLine("=== CSMain Shell 啟動 ===");
var shell = new MyShell();
var modules = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "modules");
shell.LoadModules(modules);
await shell.StartAsync();
while (true)
{
Console.Write("輸入字串(exit 離開):");
var input = Console.ReadLine();
if (input == "exit") break;
var output = shell.Execute(input ?? "");
Console.WriteLine($"結果:{output}");
// 保證 dll 無引用可以被覆蓋
GC.Collect();
GC.WaitForPendingFinalizers();
}
await shell.StopAsync();
}
在執行期的時候,我把 ToMd5 的 result 重新編譯一個版本尾部加上一些測試的贅字,之後打開來取代bin\Debug\net10.0\modules\CSCrypto.dll
結果:
=== CSMain Shell 啟動 ===
輸入字串(exit 離開):123
加密模組 Loaded.
加密模組 Started.
加密模組 Stopped.
結果:202CB962AC59075B964B07152D234B70
輸入字串(exit 離開):123
結果:202CB962AC59075B964B07152D234B70---更換過後DLL
輸入字串(exit 離開):
之後結果就改變了,這邊強調一下,為何我要每執行一次就釋放,原因是因為,如果不這樣做,你在取代 CSCrypto.dll 很容易被鎖住
當然如果你是高併發一直被執行,我想你也應該會被鎖住不能使用這邊我測試過很多次目前比較好的寫法
我看許多 open source 也都有做這方面的嘗試
https://github.com/sfmskywalker/cshells/tree/main
有興趣可以多看看,畢竟這處於實驗性質,不過真的蠻有趣的,這邊跟 GPT 周旋好幾次最後有的版本
中間很多都是無法執行期替換 dll ,中間不知道測試過多少次才成功,即使跟大套件差異可能很大
但是可以窺得其原理,之後有空再來做的更完善
--
本文原文首發於我的個人部落格:.NET 動態載入 DLL,可熱插拔
--
---
Yesterday I wrote down the code. I bet I could be your hero. I am a mighty little programmer.