diff --git a/Modules/Hosting/ApplicationHost.cs b/Modules/Hosting/ApplicationHost.cs index 25c2afa..e662f7e 100644 --- a/Modules/Hosting/ApplicationHost.cs +++ b/Modules/Hosting/ApplicationHost.cs @@ -29,6 +29,12 @@ public class ApplicationHost private string _apiName = null!; private string _apiVersion = null!; + /// + /// 插件加载器实例,持有所有插件 AssemblyLoadContext 的强引用, + /// 确保在服务注册期间 ALC 不会被 GC 提前标记为 unloading。 + /// + private PluginLoader _pluginLoader = null!; + /// /// 初始化应用程序主机 /// @@ -65,8 +71,8 @@ public ApplicationHost Build() // 6. 加载插件 var pluginLogger = new SerilogLoggerFactory(Log.Logger).CreateLogger("PluginLoader"); - var pluginLoader = new PluginLoader(_configuration, pluginLogger); - var plugins = pluginLoader.LoadPlugins(); + _pluginLoader = new PluginLoader(_configuration, pluginLogger); + var plugins = _pluginLoader.LoadPlugins(); // 7. 检查依赖 var dependencyChecker = new PluginDependencyChecker(pluginLogger); @@ -75,7 +81,7 @@ public ApplicationHost Build() // 8. 注册服务 // 将插件集合注入到 DI 容器(作为单例),插件实现可从容器中获取此集合 builder.Services.AddSingleton(plugins); - + var serviceRegistrar = new PluginServiceRegistrar(builder.Services, pluginLogger); serviceRegistrar.AddSwaggerServices(_apiName, _apiVersion); serviceRegistrar.RegisterPluginServices(plugins, _configuration); diff --git a/Modules/PluginManagement/PluginLoader.cs b/Modules/PluginManagement/PluginLoader.cs index 5edcc85..de296f3 100644 --- a/Modules/PluginManagement/PluginLoader.cs +++ b/Modules/PluginManagement/PluginLoader.cs @@ -14,6 +14,17 @@ public class PluginLoader private readonly IConfiguration _configuration; private readonly ILogger _logger; + /// + /// 跟踪所有已创建的 AssemblyLoadContext,确保在插件服务注册期间 ALC 保持 alive 状态, + /// 防止因失去强引用而被 GC 提前标记为 unloading。 + /// + private readonly List _loadContexts = new(); + + /// + /// 获取所有已创建的插件加载上下文列表。 + /// + public IReadOnlyList LoadContexts => _loadContexts.AsReadOnly(); + /// /// 初始化插件加载器 /// @@ -32,7 +43,7 @@ public PluginLoader(IConfiguration configuration, ILogger logger) public List LoadPlugins() { var loadedPlugins = new List(); - + // 获取插件目录路径 string pluginsPath = Path.Combine(AppContext.BaseDirectory, "plugins"); @@ -221,8 +232,13 @@ private List LoadPluginFromDirectory(string dirPath) { // 使用隔离加载上下文加载程序集,防止插件之间的依赖冲突 var loadContext = new PluginLoadContext(dllPath); + + // 将 ALC 添加到跟踪列表,确保其在服务注册阶段保持 alive, + // 不会被 GC 标记为 unloading(isCollectible: true 的 ALC 若无强引用会被回收) + _loadContexts.Add(loadContext); + var assembly = loadContext.LoadFromAssemblyPath(dllPath); - + // 从程序集中查找所有实现了 IApiPlugin 接口的类型 var pluginTypes = assembly.GetTypes() .Where(t => typeof(IApiPlugin).IsAssignableFrom(t) && !t.IsInterface); @@ -238,4 +254,24 @@ private List LoadPluginFromDirectory(string dirPath) return null; } + + /// + /// 卸载所有插件 AssemblyLoadContext,释放占用的资源。 + /// 应在完成所有需要 ALC 的操作(如服务注册)后调用。 + /// + public void UnloadAll() + { + foreach (var ctx in _loadContexts) + { + try + { + ctx.Unload(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to unload plugin assembly load context"); + } + } + _loadContexts.Clear(); + } } diff --git a/Modules/Services/ServiceRegistration.cs b/Modules/Services/ServiceRegistration.cs index 6f41f46..46ecc7a 100644 --- a/Modules/Services/ServiceRegistration.cs +++ b/Modules/Services/ServiceRegistration.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using System.Text.Json.Nodes; using sharwapi.Contracts.Core; +using System.IO; namespace sharwapi.Core.Modules.Services; @@ -47,7 +49,7 @@ private void RegisterPluginServices(IApiPlugin plugin, IConfiguration configurat { // 构建插件配置文件的完整路径 var configPath = Path.Combine(AppContext.BaseDirectory, "config", $"{plugin.Name}.json"); - + // 确保配置文件存在,如果不存在则从插件的默认配置生成 EnsurePluginConfigFile(plugin, configPath); @@ -84,50 +86,125 @@ private void EnsurePluginDataDirectory(IApiPlugin plugin) } /// - /// 确保插件配置文件存在 - /// 如果配置文件不存在,从插件的 DefaultConfig 生成默认配置 + /// 确保插件配置文件存在并保持与 DefaultConfig 同步 + /// 如果配置文件不存在,从插件的 DefaultConfig 生成默认配置; + /// 如果已存在,则递归合并 DefaultConfig 中缺失的键,保留用户已有的值。 /// private void EnsurePluginConfigFile(IApiPlugin plugin, string configPath) { - // 检查配置文件是否已存在 - if (!File.Exists(configPath)) + // 确保配置目录存在 + var configDir = Path.GetDirectoryName(configPath); + if (!string.IsNullOrEmpty(configDir) && !Directory.Exists(configDir)) + { + Directory.CreateDirectory(configDir); + } + + // 获取插件提供的默认配置 + // 注意:如果 DefaultConfig 抛出异常,属于插件自身的问题,此处不做 catch + var defaultConfig = plugin.DefaultConfig; + if (defaultConfig == null) return; + + var jsonOptions = new System.Text.Json.JsonSerializerOptions { - // 获取插件提供的默认配置 - var defaultConfig = plugin.DefaultConfig; + WriteIndented = true + }; - if (defaultConfig != null) + if (!File.Exists(configPath)) + { + // 配置文件不存在,直接写入 DefaultConfig + try { - try + var jsonString = System.Text.Json.JsonSerializer.Serialize(defaultConfig, jsonOptions); + if (TryWriteConfigWithTimeout(configPath, jsonString, TimeSpan.FromSeconds(10))) { - // 确保配置目录存在 - var configDir = Path.GetDirectoryName(configPath); - - if (!string.IsNullOrEmpty(configDir) && !Directory.Exists(configDir)) - { - Directory.CreateDirectory(configDir); - } + _logger.LogInformation("Generated default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); + } + else + { + _logger.LogError("Timed out while generating default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate default configuration for plugin {PluginName}", plugin.Name); + } + } + else + { + // 配置文件已存在,读取并与 DefaultConfig 递归合并缺失的键 + try + { + var existingJson = File.ReadAllText(configPath); + var existingNode = JsonNode.Parse(existingJson) as JsonObject; + var defaultJsonString = System.Text.Json.JsonSerializer.Serialize(defaultConfig); + var defaultNode = JsonNode.Parse(defaultJsonString) as JsonObject; - // 配置 JSON 序列化选项为缩进格式,便于阅读 - var jsonOptions = new System.Text.Json.JsonSerializerOptions - { - WriteIndented = true - }; + if (existingNode != null && defaultNode != null) + { + MergeMissingKeys(existingNode, defaultNode); + var mergedJson = existingNode.ToJsonString(jsonOptions); - // 将默认配置序列化为 JSON 并写入文件 - var jsonString = System.Text.Json.JsonSerializer.Serialize(defaultConfig, jsonOptions); - if (TryWriteConfigWithTimeout(configPath, jsonString, TimeSpan.FromSeconds(10))) + if (TryWriteConfigWithTimeout(configPath, mergedJson, TimeSpan.FromSeconds(10))) { - _logger.LogInformation("Generated default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); + _logger.LogInformation("Merged default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); } else { - _logger.LogError("Timed out while generating default configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); + _logger.LogError("Timed out while merging configuration for plugin {PluginName} at {ConfigPath}", plugin.Name, configPath); } } - catch (Exception ex) + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to merge configuration for plugin {PluginName}, keeping existing config file unchanged", plugin.Name); + } + } + } + + /// + /// 递归合并默认配置到现有配置中,仅补充缺失的键,不覆盖已有值也不删除多余键。 + /// + /// 现有配置的 JsonObject + /// 默认配置的 JsonObject + private static void MergeMissingKeys(JsonObject existing, JsonObject defaults) + { + // 构建现有配置键的大小写不敏感查找表 + // 用于检测 DefaultConfig 中的键在现有配置中是否存在但大小写不同 + var existingKeyMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in existing) + { + existingKeyMap[kvp.Key] = kvp.Key; + } + + foreach (var kvp in defaults) + { + if (kvp.Value == null) continue; + + if (existing.ContainsKey(kvp.Key)) + { + // 键存在且大小写完全匹配 + if (kvp.Value is JsonObject defaultObj && existing[kvp.Key] is JsonObject existingObj) + { + // 双方都是嵌套对象 → 递归合并 + MergeMissingKeys(existingObj, defaultObj); + } + // 其他情况(值类型、数组、混合类型等)→ 保留现有值,不做任何操作 + } + else if (existingKeyMap.ContainsKey(kvp.Key)) + { + // 存在大小写不同但实质相同的键 → 沿用现有键名 + var actualKey = existingKeyMap[kvp.Key]; + if (kvp.Value is JsonObject defaultObj && existing[actualKey] is JsonObject existingObj) { - _logger.LogError(ex, "Failed to generate default configuration for plugin {PluginName}", plugin.Name); + // 双方都是嵌套对象 → 递归合并到已有的键名下 + MergeMissingKeys(existingObj, defaultObj); } + // 值类型或类型不一致 → 保留现有值,不做任何操作 + } + else + { + // 键完全不存在 → 添加(使用 DefaultConfig 中的键名大小写) + existing[kvp.Key] = kvp.Value.DeepClone(); } } } @@ -140,7 +217,7 @@ private void EnsurePluginConfigFile(IApiPlugin plugin, string configPath) public void AddSwaggerServices(string apiName, string apiVersion) { _services.AddEndpointsApiExplorer(); - + _services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo