可能是以往的习惯,我希望生产环境的服务可以热更新。有人会说Docker,可我希望能更简单一些。所以一直关注asp.net core如何热更新
早前读过这文章,工作关系没有继续学习。今天遇到一个关键问题,还是这文章启发了我。
https://www.cnblogs.com/artech/p/dynamic-controllers.html
第一步,dll需要在使用后,依然可以被修改和替换。我们需要一个继承自AssemblyLoadContext的类


/// <summary>
/// 支持真正卸载的插件加载上下文
/// </summary>
public class CollectiblePluginLoadContext : AssemblyLoadContext
{
private readonly string _pluginPath;
private readonly string? _pluginDirectory;
public CollectiblePluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_pluginPath = pluginPath;
_pluginDirectory = Path.GetDirectoryName(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// 尝试从插件目录加载依赖项
if (!string.IsNullOrEmpty(_pluginDirectory))
{
var assemblyPath = Path.Combine(_pluginDirectory, assemblyName.Name + ".dll");
if (File.Exists(assemblyPath))
{
// 使用流加载避免锁定依赖DLL文件
using var fileStream = new FileStream(assemblyPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return LoadFromStream(fileStream);
}
}
// 如果在插件目录中找不到,则返回null,让默认上下文处理
return null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
// 尝试从插件目录加载非托管DLL
if (!string.IsNullOrEmpty(_pluginDirectory))
{
var unmanagedDllPath = Path.Combine(_pluginDirectory, unmanagedDllName + ".dll");
if (File.Exists(unmanagedDllPath))
{
// 对于非托管DLL,仍然需要使用路径加载
// 但可以在加载后立即关闭句柄以减少锁定
return LoadUnmanagedDllFromPath(unmanagedDllPath);
}
}
// 如果在插件目录中找不到,则返回零,让默认上下文处理
return IntPtr.Zero;
}
public Assembly LoadPluginAssembly()
{
// 使用流加载避免锁定DLL文件
using var fileStream = new FileStream(_pluginPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return LoadFromStream(fileStream);
}
}
CollectiblePluginLoadContext
第二步,ApplicationPartManager添加动态加载的Assembly。
一开始我以为加入前移除就能完整热加载
// 从应用部件管理器中移除程序集
var partToRemove = _partManager.ApplicationParts
.OfType<AssemblyPart>()
.FirstOrDefault(p => p.Assembly == assembly);
if (partToRemove != null)
{
_partManager.ApplicationParts.Remove(partToRemove);
}
// 将程序集添加到应用部件管理器
var assemblyPart = new AssemblyPart(assembly);
_partManager.ApplicationParts.Add(assemblyPart);
实际上不能,如一开头的文章说到的
...但是MVC默认情况下对提供的ActionDescriptor对象进行了缓存。
如果框架能够使用新的ActionDescriptor对象,需要告诉它当前应用提供的ActionDescriptor列表发生了改变,而这可以利用自定义的IActionDescriptorChangeProvider来实现。
为此我们定义了如下这个DynamicChangeTokenProvider类型,该类型实现了IActionDescriptorChangeProvider接口,并利用GetChangeToken方法返回IChangeToken对象通知
MVC框架当前的ActionDescriptor已经发生改变。从实现实现代码可以看出,当我们调用NotifyChanges方法的时候,状态改变通知会被发出去。
public class DynamicChangeTokenProvider : IActionDescriptorChangeProvider
{
private CancellationTokenSource _source;
private CancellationChangeToken _token;
public DynamicChangeTokenProvider()
{
_source = new CancellationTokenSource();
_token = new CancellationChangeToken(_source.Token);
}
public IChangeToken GetChangeToken() => _token;
public void NotifyChanges()
{
var old = Interlocked.Exchange(ref _source, new CancellationTokenSource());
_token = new CancellationChangeToken(_source.Token);
old.Cancel();
}
}
有了蒋金楠(大内老A)的上面的代码,事情就好办了。以下是我的Program.cs的主要代码
// 添加MVC服务以支持动态控制器
builder.Services.AddControllers();
builder.Services.AddSingleton<DynamicChangeTokenProvider>();
builder.Services.AddSingleton<IActionDescriptorChangeProvider>(provider => provider.GetRequiredService<DynamicChangeTokenProvider>());
var app = builder.Build();// 初始化改进的插件管理器(支持真正卸载)
var partManager = app.Services.GetRequiredService<ApplicationPartManager>();
var tokenProvider = app.Services.GetRequiredService<DynamicChangeTokenProvider>();
var improvedPluginManager = new ImprovedPluginManager(partManager, tokenProvider);
// 预加载已存在的插件
await improvedPluginManager.LoadAllPluginsAsync();
// 映射控制器路由
app.MapControllers();
// 默认根路径
app.MapGet("/", () => "Dynamic Controller Demo Running!");
// 重新加载所有插件端点
app.MapPost("/reload-plugins", async () =>
{
await improvedPluginManager.LoadAllPluginsAsync();
return "Plugins reloaded with true unloading";
});
// 获取已加载插件列表
app.MapGet("/loaded-plugins", () =>
{
return improvedPluginManager.GetLoadedPlugins();
});
好了。程序跑起来。主程序没有Controller的实现。程序提供的WebApi,由plugin目录中的dll所包含Controller决定。至此期待的url正常响应了。将新的dll拷贝到plugin目录,替换旧的,post一下
/reload-plugins
WebApi也被新的程序响应了。当然我们也可以监视一下plugin目录,有文件修改时自动加载。
附上ImprovedPluginManager.cs的源代码


1 public class ImprovedPluginManager
2 {
3 private readonly ApplicationPartManager _partManager;
4 private readonly Dictionary<string, (CollectiblePluginLoadContext context, Assembly assembly)> _loadedPlugins;
5 private readonly List<PluginInfo> _pluginInfos;
6 private readonly string _pluginsDirectory;
7 private readonly DynamicChangeTokenProvider _tokenProvider;
8
9 public ImprovedPluginManager(ApplicationPartManager partManager, DynamicChangeTokenProvider tokenProvider)
10 {
11 _tokenProvider = tokenProvider;
12 _partManager = partManager;
13 _loadedPlugins = new Dictionary<string, (CollectiblePluginLoadContext, Assembly)>();
14 _pluginInfos = new List<PluginInfo>();
15
16 // 设置插件目录
17 _pluginsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins");
18 if (!Directory.Exists(_pluginsDirectory))
19 {
20 Directory.CreateDirectory(_pluginsDirectory);
21 }
22 }
23
24 public async Task<bool> LoadPluginAsync(string pluginPath)
25 {
26 try
27 {
28 if (!File.Exists(pluginPath))
29 {
30 throw new FileNotFoundException($"Plugin file not found: {pluginPath}");
31 }
32
33 // 检查文件扩展名
34 if (!pluginPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
35 {
36 throw new ArgumentException("Plugin must be a .dll file");
37 }
38
39 // 创建可收集的加载上下文
40 var loadContext = new CollectiblePluginLoadContext(pluginPath);
41
42 // 加载程序集
43 var assembly = loadContext.LoadPluginAssembly();
44
45 // 获取所有控制器类型
46 var controllerTypes = assembly.GetTypes()
47 .Where(t => t.IsSubclassOf(typeof(ControllerBase)) && !t.IsAbstract)
48 .ToList();
49
50 if (!controllerTypes.Any())
51 {
52 throw new InvalidOperationException($"No controllers found in plugin: {pluginPath}");
53 }
54
55 // 检查是否已经加载了相同的程序集
56 if (_loadedPlugins.ContainsKey(assembly.FullName))
57 {
58 await UnloadPluginAsync(assembly.FullName);
59 }
60
61 // 将程序集添加到应用部件管理器
62 var assemblyPart = new AssemblyPart(assembly);
63 _partManager.ApplicationParts.Add(assemblyPart);
64
65 // 记录已加载的插件和上下文
66 _loadedPlugins[assembly.FullName] = (loadContext, assembly);
67
68 // 创建插件信息
69 var pluginInfo = new PluginInfo
70 {
71 Name = Path.GetFileNameWithoutExtension(pluginPath),
72 Version = assembly.GetName().Version?.ToString() ?? "Unknown",
73 Description = "Dynamic controller plugin",
74 FilePath = pluginPath,
75 LoadedAt = DateTime.Now,
76 ControllerTypes = controllerTypes.Select(t => t.Name).ToList()
77 };
78
79 _pluginInfos.Add(pluginInfo);
80
81 Console.WriteLine($"Successfully loaded plugin: {pluginPath}");
82 foreach (var controller in controllerTypes)
83 {
84 Console.WriteLine($" - Controller: {controller.Name}");
85 }
86
87 return true;
88 }
89 catch (Exception ex)
90 {
91 Console.WriteLine($"Failed to load plugin: {ex.Message}");
92 return false;
93 }
94 }
95
96 public async Task<bool> UnloadPluginAsync(string assemblyFullName)
97 {
98 try
99 {
100 if (!_loadedPlugins.ContainsKey(assemblyFullName))
101 {
102 return false;
103 }
104
105 var (loadContext, assembly) = _loadedPlugins[assemblyFullName];
106
107
108 // 从应用部件管理器中移除程序集
109 var partToRemove = _partManager.ApplicationParts
110 .OfType<AssemblyPart>()
111 .FirstOrDefault(p => p.Assembly == assembly);
112
113 if (partToRemove != null)
114 {
115 _partManager.ApplicationParts.Remove(partToRemove);
116 }
117
118 // 从已加载插件列表中移除
119 _loadedPlugins.Remove(assemblyFullName);
120
121 // 从插件信息列表中移除
122 var pluginInfo = _pluginInfos.FirstOrDefault(p => p.FilePath == assembly.Location);
123 if (pluginInfo != null)
124 {
125 _pluginInfos.Remove(pluginInfo);
126 }
127
128 // 卸载加载上下文
129 loadContext.Unload();
130
131 // 强制垃圾回收以释放程序集
132 GC.Collect();
133 GC.WaitForPendingFinalizers();
134
135 Console.WriteLine($"=== UNLOAD DIAGNOSTICS ===");
136 Console.WriteLine($"Successfully unloaded plugin: {assemblyFullName}");
137 Console.WriteLine($"Assembly location: {assembly.Location}");
138 Console.WriteLine($"Loaded plugins count after unload: {_loadedPlugins.Count}");
139 Console.WriteLine($"Plugin infos count after unload: {_pluginInfos.Count}");
140 Console.WriteLine($"Application parts count after unload: {_partManager.ApplicationParts.Count}");
141 Console.WriteLine("========================");
142 return true;
143 }
144 catch (Exception ex)
145 {
146 Console.WriteLine($"Failed to unload plugin: {ex.Message}");
147 return false;
148 }
149 }
150
151 public List<PluginInfo> GetLoadedPlugins()
152 {
153 return _pluginInfos.ToList();
154 }
155
156 public async Task<List<FileInfo>> ScanPluginFilesAsync()
157 {
158 var pluginDirInfo = new DirectoryInfo(_pluginsDirectory);
159 if (!pluginDirInfo.Exists)
160 {
161 return new List<FileInfo>();
162 }
163
164 var dllFiles = pluginDirInfo.GetFiles("*.dll", SearchOption.AllDirectories);
165 return dllFiles.ToList();
166 }
167
168 public async Task<bool> LoadAllPluginsAsync()
169 {
170 // 先卸载所有已加载的插件
171 var assembliesToUnload = _loadedPlugins.Keys.ToList();
172 foreach (var assemblyName in assembliesToUnload)
173 {
174 await UnloadPluginAsync(assemblyName);
175 }
176
177 var pluginFiles = await ScanPluginFilesAsync();
178 var successCount = 0;
179
180 foreach (var pluginFile in pluginFiles)
181 {
182 if (await LoadPluginAsync(pluginFile.FullName))
183 {
184 successCount++;
185 }
186 }
187 _tokenProvider.NotifyChanges();
188 Console.WriteLine($"Loaded {successCount} out of {pluginFiles.Count} plugins");
189 return successCount > 0;
190 }
191 }
ImprovedPluginManager
如有错误请指正。