diff --git a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs index 308cb46..9c4788e 100644 --- a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs +++ b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ using Microsoft.Win32; using System.Configuration; using System.Diagnostics; using System.Reflection; +using MAX.Plugins; namespace MAX.Framework.DependencyInjection { @@ -33,10 +34,13 @@ namespace MAX.Framework.DependencyInjection /// public static IServiceCollection AddStore(this IHostApplicationBuilder host) { - host.AddLogger(); + Debug.WriteLine("[Store] 开始注册应用服务"); + RunDebugStep("Logger", "注册日志服务", () => host.AddLogger()); //host.Services.AddSingleton(NLogger.Instance);//注册log IEnumerable assemblies = StoreAppTypeFinder.Current.GetAssemblies(host.Configuration["Plugins:Path"]).ToArray(); + foreach (var assembly in assemblies) + Debug.WriteLine($"[Assembly] 已发现:{assembly.FullName}"); // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象 Dictionary instances = []; return AddStoreCore(host, assemblies, instances); @@ -54,14 +58,16 @@ namespace MAX.Framework.DependencyInjection IEnumerable assemblies, IDictionary instances) { - RegisterDependencyMarkers(host.Services, assemblies); + RunDebugStep("DI", "注册依赖标记服务", () => RegisterDependencyMarkers(host.Services, assemblies)); - RunModules(host, assemblies, instances); + RunDebugStep("Module", "注册全部模块", () => RunModules(host, assemblies, instances)); + RunDebugStep("Plugin", "注册全部插件", () => RunPlugins(host, assemblies, instances)); - RunStartups(host.Services, assemblies, instances); + RunDebugStep("Startup", "初始化并注册启动服务", () => RunStartups(host.Services, assemblies, instances)); + Debug.WriteLine("[MVC] 开始注册控制器和全局过滤器"); var mvc = host.Services.AddControllers(options => { options.Filters.Add(); @@ -90,7 +96,9 @@ namespace MAX.Framework.DependencyInjection foreach (var assembly in assemblies) { mvc.AddApplicationPart(assembly); + Debug.WriteLine($"[MVC] 已添加 ApplicationPart:{assembly.GetName().Name}"); } + Debug.WriteLine($"[Store] 应用服务注册完成,共 {host.Services.Count} 个服务描述"); return host.Services; } @@ -101,7 +109,6 @@ namespace MAX.Framework.DependencyInjection { foreach (Type type in GetConcreteTypes(assemblies)) { - Debug.WriteLine(type.Namespace); Type? marker = DependencyMarkers.FirstOrDefault(m => m.IsAssignableFrom(type)); if (marker is null) { @@ -114,6 +121,7 @@ namespace MAX.Framework.DependencyInjection // 注册为自身 services.Add(new ServiceDescriptor(type, type, lifetime)); + Debug.WriteLine($"[DI] 注册自身:{type.FullName},Lifetime={lifetime}"); // 注册为实现的接口(排除标记接口与系统接口) foreach (Type iface in type.GetInterfaces()) @@ -124,6 +132,7 @@ namespace MAX.Framework.DependencyInjection } services.Add(new ServiceDescriptor(iface, type, lifetime)); + Debug.WriteLine($"[DI] 注册接口:{iface.FullName} -> {type.FullName},Lifetime={lifetime}"); } } } @@ -134,11 +143,11 @@ namespace MAX.Framework.DependencyInjection private static void RunStartups(IServiceCollection services, IEnumerable assemblies, IDictionary instances) { IServiceRegistrar registrar = new ServiceRegistrarProxy(services); - Startups = CreateInstances(assemblies, instances).OrderByDescending(s => s.Priority); + Startups = CreateInstances(assemblies, instances).Where(s => s is not IPlugin).OrderByDescending(s => s.Priority); foreach (IStartup startup in Startups) { - startup.Initialize(); - startup.Register(registrar); + RunDebugStep("Startup", $"初始化 {startup.GetType().FullName},Priority={startup.Priority}", startup.Initialize); + RunDebugStep("Startup", $"注册服务 {startup.GetType().FullName}", () => startup.Register(registrar)); } } @@ -148,12 +157,12 @@ namespace MAX.Framework.DependencyInjection /// private static void RunModules(IHostApplicationBuilder host, IEnumerable assemblies, IDictionary instances) { - Modules = CreateInstances(assemblies, instances).OrderByDescending(s => s.Priority); + Modules = CreateInstances(assemblies, instances).Where(m => m is not IPlugin).OrderByDescending(s => s.Priority); foreach (IModule module in Modules) { try { - module.AddModule(host); + RunDebugStep("Module", $"注册 {module.GetType().FullName},Priority={module.Priority}", () => module.AddModule(host)); } catch (Exception ex) { @@ -175,6 +184,11 @@ namespace MAX.Framework.DependencyInjection instance = Activator.CreateInstance(type, true) ?? throw new InvalidOperationException($"无法创建 {type.FullName} 的实例(需要有参数量为 0 的构造函数)。"); instances[type] = instance; + Debug.WriteLine($"[Instance] 已创建:{type.FullName}"); + } + else + { + Debug.WriteLine($"[Instance] 复用实例:{type.FullName}"); } yield return (T)instance; @@ -196,6 +210,9 @@ namespace MAX.Framework.DependencyInjection } catch (ReflectionTypeLoadException ex) { + Debug.WriteLine($"[Assembly] 部分类型加载失败:{assembly.FullName}"); + foreach (var error in ex.LoaderExceptions) + Debug.WriteLine($"[Assembly] 类型加载错误:{error?.Message}"); return ex.Types.OfType(); } } @@ -209,6 +226,53 @@ namespace MAX.Framework.DependencyInjection return StoreAppTypeFinder.Current.GetAssemblies(); } + private sealed class PluginRuntime : IDisposable + { + public List<(IPlugin Plugin, PluginContext Context)> Entries { get; } = []; + public void Dispose() + { + foreach (var entry in Entries) entry.Context.Dispose(); + } + } + + private static void RunPlugins(IHostApplicationBuilder host, IEnumerable assemblies, IDictionary instances) + { + var runtime = new PluginRuntime(); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var plugin in CreateInstances(assemblies, instances).OrderByDescending(p => p.Priority)) + { + if (plugin is IModule || plugin is IStartup) + throw new InvalidOperationException($"插件 {plugin.GetType().FullName} 不能同时实现 IModule/IStartup。"); + if (string.IsNullOrWhiteSpace(plugin.Id) || !ids.Add(plugin.Id)) + throw new InvalidOperationException($"插件标识为空或重复:{plugin.Id}"); + try + { + var directory = Path.GetDirectoryName(plugin.GetType().Assembly.Location)!; + Debug.WriteLine($"[Plugin] 加载独立配置:{plugin.Id},目录={directory},环境={host.Environment.EnvironmentName}"); + var context = new PluginContext(plugin.Id, directory, host.Environment); + runtime.Entries.Add((plugin, context)); + host.Services.AddKeyedSingleton(plugin.Id, (provider, _) => + { + // 只要上下文被解析,就让容器持有并管理全部插件配置的释放。 + provider.GetRequiredService(); + return context; + }); + RunDebugStep("Plugin", $"注册 {plugin.Id} ({plugin.GetType().FullName}),Priority={plugin.Priority}", + () => plugin.ConfigureServices(host.Services, context)); + } + catch (Exception ex) + { + Debug.WriteLine($"[Plugin] 注册失败:{plugin.Id},{ex.Message}"); + throw new InvalidOperationException($"插件 {plugin.Id} ({plugin.GetType().Assembly.Location}) 注册失败:{ex.Message}", ex); + } + } + host.Services.AddSingleton(_ => runtime); + } + catch { runtime.Dispose(); throw; } + } + private static bool IsSystemInterface(Type type) { return type.Namespace is null @@ -217,18 +281,40 @@ namespace MAX.Framework.DependencyInjection } + private static void RunDebugStep(string category, string description, Action action) + { + Debug.WriteLine($"[{category}] 开始:{description}"); + try + { + action(); + Debug.WriteLine($"[{category}] 完成:{description}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[{category}] 失败:{description},{ex.Message}"); + throw; + } + } + public static IApplicationBuilder UseStore(this IApplicationBuilder app) { - app.UseMiddleware(); + Debug.WriteLine("[Store] 开始装配应用管道"); + RunDebugStep("Middleware", "注册统一状态码中间件", () => app.UseMiddleware()); + foreach (var (plugin, context) in app.ApplicationServices.GetRequiredService().Entries) + { + try { RunDebugStep("Plugin", $"装配管道 {plugin.Id}", () => plugin.Configure(app, context)); } + catch (Exception ex) { throw new InvalidOperationException($"插件 {plugin.Id} 管道装配失败:{ex.Message}", ex); } + } foreach (IStartup startup in Startups) { - startup.Start((app as IHost).Services); + RunDebugStep("Startup", $"执行启动回调 {startup.GetType().FullName}", () => startup.Start((app as IHost).Services)); } foreach (IModule module in Modules) { - module.UseModule(app); + RunDebugStep("Module", $"装配管道 {module.GetType().FullName}", () => module.UseModule(app)); } + Debug.WriteLine("[Store] 应用管道装配完成"); return app; } diff --git a/Core/MAX.Framework/MAX.Framework.csproj b/Core/MAX.Framework/MAX.Framework.csproj index 504d8c4..68c8513 100644 --- a/Core/MAX.Framework/MAX.Framework.csproj +++ b/Core/MAX.Framework/MAX.Framework.csproj @@ -1,4 +1,4 @@ - + enable @@ -26,6 +26,7 @@ + diff --git a/Core/MAX.Plugins/IPlugin.cs b/Core/MAX.Plugins/IPlugin.cs new file mode 100644 index 0000000..5e5d860 --- /dev/null +++ b/Core/MAX.Plugins/IPlugin.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace MAX.Plugins; + +/// 独立插件入口。实现类必须具有无参构造函数。 +public interface IPlugin +{ + string Id { get; } + int Priority { get; } + void ConfigureServices(IServiceCollection services, PluginContext context); + void Configure(IApplicationBuilder app, PluginContext context); +} diff --git a/Core/MAX.Plugins/MAX.Plugins.csproj b/Core/MAX.Plugins/MAX.Plugins.csproj new file mode 100644 index 0000000..b9c4e37 --- /dev/null +++ b/Core/MAX.Plugins/MAX.Plugins.csproj @@ -0,0 +1,3 @@ + + + diff --git a/Core/MAX.Plugins/PluginBase.cs b/Core/MAX.Plugins/PluginBase.cs new file mode 100644 index 0000000..ac86039 --- /dev/null +++ b/Core/MAX.Plugins/PluginBase.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace MAX.Plugins; + +public abstract class PluginBase : IPlugin +{ + public abstract string Id { get; } + public virtual int Priority => 0; + protected PluginContext Context { get; private set; } = null!; + + public void ConfigureServices(IServiceCollection services, PluginContext context) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(context); + if (!string.Equals(Id, context.Id, StringComparison.Ordinal)) + throw new ArgumentException("插件上下文标识不匹配。", nameof(context)); + if (Context is not null) throw new InvalidOperationException($"插件 {Id} 已注册。"); + Context = context; + RegisterServices(services); + } + + public void Configure(IApplicationBuilder app, PluginContext context) + { + if (!ReferenceEquals(Context, context)) + throw new InvalidOperationException($"插件 {Id} 尚未注册或上下文不匹配。"); + ConfigureApplication(app); + } + protected abstract void RegisterServices(IServiceCollection services); + protected virtual void ConfigureApplication(IApplicationBuilder app) { } + protected string RequireConfiguration(string key) => Context.RequireConfiguration(key); +} diff --git a/Core/MAX.Plugins/PluginContext.cs b/Core/MAX.Plugins/PluginContext.cs new file mode 100644 index 0000000..52e9b88 --- /dev/null +++ b/Core/MAX.Plugins/PluginContext.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace MAX.Plugins; + +/// 插件独立配置,不合并宿主或其他插件配置。 +public sealed class PluginContext : IDisposable +{ + private int disposed; + public string Id { get; } + public string DirectoryPath { get; } + public IHostEnvironment Environment { get; } + public IConfigurationRoot Configuration { get; } + + public PluginContext(string id, string directoryPath, IHostEnvironment environment) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentNullException.ThrowIfNull(environment); + Id = id; + DirectoryPath = Path.GetFullPath(directoryPath); + Environment = environment; + Configuration = new ConfigurationBuilder() + .SetBasePath(DirectoryPath) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddJsonFile($"appsettings.{environment.EnvironmentName}.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables($"Plugins__{id}__") + .Build(); + } + + public string RequireConfiguration(string key) => + !string.IsNullOrWhiteSpace(Configuration[key]) ? Configuration[key]! + : throw new InvalidOperationException($"插件 {Id} 缺少配置 {key},请检查 {Path.Combine(DirectoryPath, "appsettings.json")}。"); + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) == 0) + (Configuration as IDisposable)?.Dispose(); + } +} diff --git a/Directory.Build.targets b/Directory.Build.targets index d9edbd3..9c5baae 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -21,6 +21,10 @@ foreach (var file in Directory.GetFiles(plugin, "*", SearchOption.AllDirectories)) { var relative = file.Substring(plugin.TrimEnd(Path.DirectorySeparatorChar).Length + 1); + // Plugin settings are owned by the plugin even when identical to host settings. + if (Path.GetFileName(file).StartsWith("appsettings", StringComparison.OrdinalIgnoreCase) + && string.Equals(Path.GetExtension(file), ".json", StringComparison.OrdinalIgnoreCase)) + continue; var shared = Path.GetFullPath(Path.Combine(host, relative)); // Only compare files below the host, outside its plugin subtree. if (!shared.StartsWith(host.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) diff --git a/Plugins/CloudProduct/CloudProductModule.cs b/Plugins/CloudProduct/CloudProductPlugin.cs similarity index 54% rename from Plugins/CloudProduct/CloudProductModule.cs rename to Plugins/CloudProduct/CloudProductPlugin.cs index 1ec397c..7c4519a 100644 --- a/Plugins/CloudProduct/CloudProductModule.cs +++ b/Plugins/CloudProduct/CloudProductPlugin.cs @@ -1,55 +1,30 @@ +using MAX.Plugins; using Hangfire; using Hangfire.MySql; using HangfireBasicAuthenticationFilter; -using MAX.Core.Interfaces; -using MAX.Framework.Interfaces; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using MySql.Data.MySqlClient; using Store.Modules.CloudProduct.Services; namespace Store.Modules.CloudProduct { - public class CloudProductModule : IModule + public sealed class CloudProductPlugin : PluginBase { - /// - /// 本模块自己的配置文件 - /// - public const string ConfigurationFileName = "appsettings.CloudProduct.json"; - - public int Priority => IntPriority.Normal; - - - /// - /// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app - /// - public void AddModule(IHostApplicationBuilder host) - { - IServiceCollection services = host.Services; - IConfigurationManager configuration = host.Configuration; - - if (!string.IsNullOrWhiteSpace(ConfigurationFileName)) - { - string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(Path.GetDirectoryName(GetType().Assembly.Location)!, ConfigurationFileName); - configuration.AddJsonFile(path, optional: false, reloadOnChange: true); - } - - - - - //// 本模块的配置节(Search / RawData / Indexing / Delete)都在模块自己的配置文件里, - //// 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障 - RequireConfiguration(configuration, "Search:Url"); - RequireConfiguration(configuration, "Search:IndexName"); - RequireConfiguration(configuration, "Search:FavoritesIndexName"); - RequireConfiguration(configuration, "RawData:Path"); + public override string Id => "CloudProduct"; + protected override void RegisterServices(IServiceCollection services) + { + var configuration = Context.Configuration; + RequireConfiguration("Search:Url"); + RequireConfiguration("Search:IndexName"); + RequireConfiguration("Search:FavoritesIndexName"); + RequireConfiguration("RawData:Path"); // ===== Hangfire 后台定时任务(商品分词同步) ===== var hangfireConnection = configuration.GetValue("CloudProductConnection") - ?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。"); + ?? throw new InvalidOperationException("缺少配置 CloudProductConnection,CloudProduct 模块的 Hangfire 无法启动。"); // AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量 var connectionStringBuilder = new MySqlConnectionStringBuilder(hangfireConnection) @@ -64,17 +39,22 @@ namespace Store.Modules.CloudProduct .UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" }))); services.AddHangfireServer(); services.AddTransient(); + var connection = RequireConfiguration("CloudProductConnection"); + services.AddKeyedSingleton("CloudProductConnection", (_, _) => + new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connection) + .UseAdoConnectionPool(true).Build()); + services.AddHostedService(); } /// /// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务 /// - public void UseModule(IApplicationBuilder app) + protected override void ConfigureApplication(IApplicationBuilder app) { try { - var Configuration = app.ApplicationServices.GetService()!; + var Configuration = Context.Configuration; // Hangfire 后台任务看板(默认地址 /hangfire) app.UseHangfireDashboard("/hangfire", new DashboardOptions { @@ -104,15 +84,6 @@ namespace Store.Modules.CloudProduct } } - /// - /// 校验模块配置项存在(值来自模块自己的 ) - /// - private static void RequireConfiguration(IConfiguration configuration, string key) - { - if (string.IsNullOrWhiteSpace(configuration.GetValue(key))) - { - throw new InvalidOperationException($"缺少配置 {key},请检查模块配置文件 {ConfigurationFileName}。"); - } - } - } -} + + } +} \ No newline at end of file diff --git a/Plugins/CloudProduct/Services/CloudProductSearchStartup.cs b/Plugins/CloudProduct/Services/CloudProductSearchStartup.cs new file mode 100644 index 0000000..1cc6cbc --- /dev/null +++ b/Plugins/CloudProduct/Services/CloudProductSearchStartup.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Store.Modules.CloudProduct.Services; + +/// 查询搜索服务状态;网络不可用时记录错误,不阻止 HTTP 服务启动。 +internal sealed class CloudProductSearchStartup( + IOpenSearchService search, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await search.GetStatusAsync().WaitAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } + catch (Exception ex) + { + logger.LogWarning(ex, "CloudProduct 搜索服务启动检查失败。"); + } + } +} diff --git a/Plugins/CloudProduct/Services/GoodsIndexJob.cs b/Plugins/CloudProduct/Services/GoodsIndexJob.cs index 9fbe971..db49f87 100644 --- a/Plugins/CloudProduct/Services/GoodsIndexJob.cs +++ b/Plugins/CloudProduct/Services/GoodsIndexJob.cs @@ -1,5 +1,6 @@ -using Microsoft.Extensions.Configuration; +using MAX.Plugins; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Store.Modules.CloudProduct.Services @@ -13,10 +14,10 @@ namespace Store.Modules.CloudProduct.Services private readonly IConfiguration _configuration; private readonly ILogger _logger; - public GoodsIndexJob(IServiceScopeFactory scopeFactory, IConfiguration configuration,ILogger logger) + public GoodsIndexJob(IServiceScopeFactory scopeFactory, [FromKeyedServices("CloudProduct")] PluginContext context,ILogger logger) { _scopeFactory = scopeFactory; - _configuration = configuration; + _configuration = context.Configuration; _logger = logger; } diff --git a/Plugins/CloudProduct/Services/GoodsService.cs b/Plugins/CloudProduct/Services/GoodsService.cs index 895e643..b99a13c 100644 --- a/Plugins/CloudProduct/Services/GoodsService.cs +++ b/Plugins/CloudProduct/Services/GoodsService.cs @@ -1,3 +1,5 @@ +using MAX.Plugins; +using Microsoft.Extensions.DependencyInjection; using MAX.Core.DependencyInjection; using Microsoft.Extensions.Configuration; using Store.Modules.CloudProduct.Dtos; @@ -24,12 +26,12 @@ namespace Store.Modules.CloudProduct.Services private readonly IRawDataStorage _rawDataStorage; private readonly IConfiguration _configuration; - public GoodsService(GoodsRepository repository, IOpenSearchService searchService, IRawDataStorage rawDataStorage, IConfiguration configuration) + public GoodsService(GoodsRepository repository, IOpenSearchService searchService, IRawDataStorage rawDataStorage, [FromKeyedServices("CloudProduct")] PluginContext context) { _repository = repository; _searchService = searchService; _rawDataStorage = rawDataStorage; - _configuration = configuration; + _configuration = context.Configuration; } public async Task<(List List, long Total)> SearchGoodsAsync(GoodsSearchRequest request) diff --git a/Plugins/CloudProduct/Services/OpenSearchService.cs b/Plugins/CloudProduct/Services/OpenSearchService.cs index ead187c..6cb1a01 100644 --- a/Plugins/CloudProduct/Services/OpenSearchService.cs +++ b/Plugins/CloudProduct/Services/OpenSearchService.cs @@ -1,3 +1,5 @@ +using MAX.Plugins; +using Microsoft.Extensions.DependencyInjection; using System.Diagnostics; using MAX.Core.DependencyInjection; using MAX.Framework.Commons; @@ -40,9 +42,9 @@ namespace Store.Modules.CloudProduct.Services private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30); private readonly ILogger _logger; - public OpenSearchService(IConfiguration configuration,ILogger logger) + public OpenSearchService([FromKeyedServices("CloudProduct")] PluginContext context,ILogger logger) { - var config = configuration.GetSection("Search"); + var config = context.Configuration.GetSection("Search"); _nodeUrl = config.GetValue("Url") ?? ""; _indexName = config.GetValue("IndexName") ?? ""; _client = CreateClient(); diff --git a/Plugins/CloudProduct/Services/RawDataStorage.cs b/Plugins/CloudProduct/Services/RawDataStorage.cs index 0b953d6..3d9eb31 100644 --- a/Plugins/CloudProduct/Services/RawDataStorage.cs +++ b/Plugins/CloudProduct/Services/RawDataStorage.cs @@ -1,3 +1,5 @@ +using MAX.Plugins; +using Microsoft.Extensions.DependencyInjection; using MAX.Core.DependencyInjection; using Microsoft.Extensions.Configuration; using System.IO.Compression; @@ -30,13 +32,13 @@ namespace Store.Modules.CloudProduct.Services { private readonly string _rootPath; - public RawDataStorage(IConfiguration configuration) + public RawDataStorage([FromKeyedServices("CloudProduct")] PluginContext context) { - var configured = configuration.GetValue("RawData:Path"); + var configured = context.Configuration.GetValue("RawData:Path"); if (string.IsNullOrWhiteSpace(configured)) configured = "RawData"; - _rootPath = Path.GetFullPath(configured); + _rootPath = Path.GetFullPath(configured, context.DirectoryPath); } /// diff --git a/Plugins/CloudProduct/Services/UserFavoritesIndexService.cs b/Plugins/CloudProduct/Services/UserFavoritesIndexService.cs index af9c04c..06cc7c1 100644 --- a/Plugins/CloudProduct/Services/UserFavoritesIndexService.cs +++ b/Plugins/CloudProduct/Services/UserFavoritesIndexService.cs @@ -1,3 +1,5 @@ +using MAX.Plugins; +using Microsoft.Extensions.DependencyInjection; using MAX.Core.DependencyInjection; using MAX.Framework.Commons; using MAX.Framework.Dtos; @@ -53,9 +55,9 @@ namespace Store.Modules.CloudProduct.Services // 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长) private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30); - public UserFavoritesIndexService(IConfiguration configuration, ILogger logger) + public UserFavoritesIndexService([FromKeyedServices("CloudProduct")] PluginContext context, ILogger logger) { - var config = configuration.GetSection("Search"); + var config = context.Configuration.GetSection("Search"); _nodeUrl = config.GetValue("Url") ?? ""; _indexName = config.GetValue("FavoritesIndexName") ?? ""; _logger = logger; diff --git a/Plugins/CloudProduct/Startup.cs b/Plugins/CloudProduct/Startup.cs deleted file mode 100644 index 4697336..0000000 --- a/Plugins/CloudProduct/Startup.cs +++ /dev/null @@ -1,49 +0,0 @@ -using MAX.Core.DependencyInjection; -using MAX.Core.Interfaces; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Store.Modules.CloudProduct.Services; -using System.ComponentModel; - -namespace Store.Modules.CloudProduct -{ - - public class Startup : IStartup - { - public int Priority => IntPriority.Normal; - - public void Initialize() - { - } - - public void Register(IServiceRegistrar registrar) - { - - - - Func KJDFsqlFactory = r => - { - var config = r.GetRequiredService(); - var connection = config.GetValue("CloudProductConnection"); - IFreeSql fsql = new FreeSql.FreeSqlBuilder() - .UseConnectionString(FreeSql.DataType.MySql, connection) - .UseAdoConnectionPool(true) - .Build(); - return fsql; - }; - registrar.RegisterSingleton("CloudProductConnection", (s, k) => - { - return KJDFsqlFactory(s); - }); - } - - public async void Start(IServiceProvider serviceProvider) - { - var openSearchService = serviceProvider.GetRequiredService(); - await openSearchService.GetStatusAsync(); - } - - - } -} diff --git a/Plugins/CloudProduct/Store.CloudProduct.csproj b/Plugins/CloudProduct/Store.CloudProduct.csproj index 33e9675..cba849e 100644 --- a/Plugins/CloudProduct/Store.CloudProduct.csproj +++ b/Plugins/CloudProduct/Store.CloudProduct.csproj @@ -10,16 +10,12 @@ + - + - + PreserveNewest PreserveNewest diff --git a/Plugins/CloudProduct/appsettings.CloudProduct.json b/Plugins/CloudProduct/appsettings.json similarity index 100% rename from Plugins/CloudProduct/appsettings.CloudProduct.json rename to Plugins/CloudProduct/appsettings.json diff --git a/Plugins/README.md b/Plugins/README.md index 9fb831c..77da6cc 100644 --- a/Plugins/README.md +++ b/Plugins/README.md @@ -1,86 +1,92 @@ # 独立功能插件 -`Modules/` 放置商城业务模块,`Plugins/` 放置共用 API 宿主的独立功能。 -插件通过 IModule、IStartup 和依赖标记接口接入现有生命周期,控制器共用宿主 URL。 +`Modules/` 使用 IModule,存放商城业务模块;`Plugins/` 使用 IPlugin,存放共用 API 宿主的独立功能。 +公共项目 `Core/MAX.Plugins` 不依赖商城业务或数据库库,提供: -## 发布和运行 +- `IPlugin`:唯一 Id、Priority、服务注册和管道装配生命周期。 +- `PluginBase`:保存插件上下文、配置校验、默认管道实现;派生类只需实现 RegisterServices,按需重写 ConfigureApplication。 +- `PluginContext`:插件目录、宿主环境、隔离的 Configuration 和必需配置校验。 -在仓库根目录执行生成命令: +宿主扫描加载插件,按 Priority 降序注册,显式注册 MVC Application Parts;IModule/IStartup 与 IPlugin 分开执行,禁止同一入口同时实现两套生命周期。 + +## 生成和部署 + +在仓库根目录执行,宿主无需引用具体插件: ```powershell dotnet build Store.API/Store.Api.csproj -c Release dotnet build Plugins/CloudProduct/Store.CloudProduct.csproj -c Release dotnet build Plugins/Store.ImageCache/Store.ImageCache.csproj -c Release -dotnet Bin/Store.Api.dll +dotnet Bin/Store.Api.dll --contentRoot "$PWD/Bin" ``` -API 直接生成到仓库根目录 `Bin/`,不再附加配置名称、目标框架或运行时子目录。 -各插件生成后自动将自身完整输出(DLL、deps.json、配置、运行依赖及 runtimes 子目录)复制到 -`Bin/Plugins/<项目名>/`。例如: +API 输出到根目录 Bin;插件生成后自动复制到 Bin/Plugins/<项目名>/。生成过程按相对路径和 SHA-256 清理宿主已提供的重复文件;插件自己的 appsettings*.json 始终保留。 +宿主后生成时也会去重;同名但内容不同的依赖保留,由加载器检查版本兼容性。 +Debug/Release 共用 Bin,部署前请统一使用 Release。整体拷贝 Bin 部署,需要兼容的 .NET/ASP.NET Core 运行时。 ```text Bin/ Store.Api.dll - Store.Api.deps.json - Store.Api.runtimeconfig.json + MAX.Plugins.dll appsettings.json Plugins/ Store.CloudProduct/ + Store.CloudProduct.dll + Store.CloudProduct.deps.json + appsettings.json Store.ImageCache/ + Store.ImageCache.dll + Store.ImageCache.deps.json + appsettings.json ``` -生成全部所需项目后即可整体拷贝 `Bin/` 部署,目标机器需要兼容的 .NET/ASP.NET Core 运行时。 -API 不引用插件,也不会在单独生成 API 时触发插件构建;生成插件项目或生成包含插件的解决方案时会自动复制。 -未来在 Plugins 下新增项目会继承同目录的 Directory.Build.props/targets,无需逐个添加复制任务。 +每个部署子目录只放一个插件入口。构建会清理重命名前的旧入口文件;其他过期依赖或旧目录需要自行清理。 +插件扫描配置 Plugins:Path 默认 Plugins,相对 AppContext.BaseDirectory,也支持绝对路径;可通过 Plugins__Path 覆盖。 +MSBuild 属性 StoreHostOutputPath 可覆盖生成目录,应使用以分隔符结尾的绝对路径。 +插件仅启动时加载,更新后重启,不支持卸载、热更新、进程隔离或冲突依赖版本隔离。 -Debug 与 Release 共用此目录;部署前应以相同配置重新生成宿主及所需插件。 -生成宿主或插件后,都会按相对路径和 SHA-256 清理与宿主完全相同的插件文件;同名但内容不同的文件保留。 -宿主尚未生成时插件暂时保留完整依赖,之后生成宿主会自动去重。去重后的插件目录必须与匹配的宿主一起部署。 -同一插件输出和部署子目录中的旧入口(其他名称的 .deps.json 及对应 DLL、PDB、runtimeconfig.json)会在生成时自动清理,避免重复加载重命名前的插件。每个部署子目录只放一个插件入口。 -其他旧文件不会自动删除;插件部署目录更名、删除或移除依赖后,应清理对应旧目录再生成。 -MSBuild 属性 `StoreHostOutputPath` 可覆盖此路径,覆盖时使用绝对目录并以目录分隔符结尾。 +## 配置隔离 -需要标准 publish 产物时也可以指定独立部署目录: +各插件只读取自身目录,按以下顺序覆盖: -```powershell -dotnet publish Store.API/Store.Api.csproj -c Release -o artifacts/server -dotnet publish Plugins/CloudProduct/Store.CloudProduct.csproj -c Release -o artifacts/server/Plugins/Store.CloudProduct -dotnet publish Plugins/Store.ImageCache/Store.ImageCache.csproj -c Release -o artifacts/server/Plugins/Store.ImageCache +1. appsettings.json(必需)。 +2. appsettings..json(可选,例如 appsettings.Development.json)。 +3. 前缀 Plugins____ 的环境变量,例如 Plugins__CloudProduct__Search__Url。 + +宿主配置、其他插件配置不会并入插件配置,插件配置也不会修改宿主 IConfiguration。 +配置在启动时加载,修改后重启。缺失文件、格式错误或缺失必需项时,错误包含插件身份及上下文信息。 +原有配置键保持不变;原始数据相对路径改为相对于 CloudProduct 插件目录,绝对路径不变。 + +插件通过 Context.Configuration 读取配置。业务服务可使用按插件 Id 注册的上下文,或在插件注册时将配置绑定到专属 Options: + +```csharp +public ExampleService([FromKeyedServices("CloudProduct")] PluginContext context) +{ + var url = context.RequireConfiguration("Search:Url"); +} ``` -宿主配置 `Plugins:Path` 默认为 `Plugins`,相对路径以宿主二进制目录为基准,也可使用绝对路径。 -可以通过环境变量 `Plugins__Path` 覆盖。目录不存在或为空时不加载插件。 +数据库、Hangfire、OpenSearch 保留在各自插件内;认证、日志等仍由宿主管理。 +CloudProduct 的搜索启动检查使用 BackgroundService,网络错误记录日志,不阻断 HTTP 启动;Hangfire 仍按既有配置运行。 -每个插件子目录包含入口 DLL、同名 `.deps.json`、配置和宿主未提供的依赖;共享依赖由宿主提供。 -上面的标准 `dotnet publish -o` 命令仍生成完整独立发布内容,仅 `Bin/Plugins/` 自动执行宿主去重。 -加载器递归寻找 `.deps.json`,由 `StoreHubAppTypeFinder.cs` 中现有 Match/AvailablePrefix 过滤入口程序集。 -依赖解析不受入口名称过滤限制。两个现有插件从自身程序集所在目录读取 JSON 配置。 +## 新增插件 -## 新增与更新 +在 Plugins 下创建类库,引用 Core/MAX.Plugins 和需要的共享业务框架,提供有无参构造函数的 PluginBase 派生类以及唯一 Id。 +提供 appsettings.json,将 appsettings*.json 设置为复制到生成和发布目录。Plugins 下的公共构建配置自动携带依赖并复制到宿主目录。 +无需修改 Store.API;入口程序集名称需符合 StoreHubAppTypeFinder.cs 中的过滤规则。 -新增插件无需修改 Store.API:创建类库,引用共享框架,实现需要的模块/启动接口,启用 -`EnableDynamicLoading`,并将配置设置为复制到输出和发布目录;生成后会自动复制到宿主扫描目录;也可以独立发布到其他扫描目录。 -配置文件命名与配置节应避免和其他插件冲突。现有模块的配置合并顺序保持不变。 +如需完整独立发布产物,可分别 dotnet publish -o 到自定义目录;自定义发布目录不执行 Bin 下的共享文件去重。 -插件、宿主和共享契约必须使用兼容版本。所有程序集加载到默认上下文,同名程序集只加载一次; -发布目录中同名依赖身份不一致会在加载时明确报错,不提供依赖版本隔离。 -插件必需的程序集、类型或配置缺失时启动失败;未调用的第三方可选功能依赖可能在调用时才解析。 - -插件与宿主同进程运行,共用认证、容器和中间件。更新时替换完整发布目录并重启宿主; -不支持热加载、卸载和进程隔离。CloudProduct 仍需要其数据库、OpenSearch 和后台任务配置。 - -## 加载冒烟验证 - -验证程序不连接数据库或 OpenSearch;每个场景单独启动进程: +## 验证 ```powershell -dotnet run --project Tests/PluginLoadingSmoke -- <插件根目录> -dotnet run --project Tests/PluginLoadingSmoke -- <仅包含ImageCache的发布目录> image +dotnet run --project Tests/PluginLoadingSmoke -- configuration +dotnet run --project Tests/PluginLoadingSmoke -- "$PWD/Bin/Plugins" +dotnet run --project Tests/PluginLoadingSmoke -- "$PWD/Bin/Plugins/Store.ImageCache" image dotnet run --project Tests/PluginLoadingSmoke -- <空目录或不存在目录> empty -dotnet run --project Tests/PluginLoadingSmoke -- - empty -dotnet run --project Tests/PluginLoadingSmoke -- <缺依赖或包含版本冲突的目录> failure +dotnet run --project Tests/PluginLoadingSmoke -- <缺依赖或版本冲突目录> failure ``` -`-` 使用默认 Plugins 路径;默认路径下已部署插件时使用普通模式。 -普通模式验证两个插件及控制器发现和程序集去重;`image` 额外验证真实框架注册流程中的配置、DI、IStartup 和 MVC。 -将同一插件发布内容复制到另一子目录可验证重复程序集;缺失依赖和版本冲突应报告具体插件路径。 +配置验证覆盖同名键隔离、环境文件和环境变量覆盖、必需文件和配置项缺失。 +加载验证覆盖两个插件的控制器、DI、数据库服务注册和业务服务构造,不连接数据库或启动后台任务。 +真实启动还需数据库和 OpenSearch;外部服务不可达应结合日志判断对应功能状态。 diff --git a/Plugins/Store.ImageCache/ImageCachePlugin.cs b/Plugins/Store.ImageCache/ImageCachePlugin.cs new file mode 100644 index 0000000..ff2da69 --- /dev/null +++ b/Plugins/Store.ImageCache/ImageCachePlugin.cs @@ -0,0 +1,17 @@ +using MAX.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; + +namespace Store.ImageCache; + +public sealed class ImageCachePlugin : PluginBase +{ + public override string Id => "ImageCache"; + protected override void RegisterServices(IServiceCollection services) + { + var connection = RequireConfiguration("ImageCache:ConnectionString"); + services.AddKeyedSingleton("ImageCacheConnection", (_, _) => + new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connection) + .UseAdoConnectionPool(true).Build()); + } +} diff --git a/Plugins/Store.ImageCache/Module.cs b/Plugins/Store.ImageCache/Module.cs deleted file mode 100644 index b4f4f51..0000000 --- a/Plugins/Store.ImageCache/Module.cs +++ /dev/null @@ -1,47 +0,0 @@ - -using MAX.Core.Interfaces; -using MAX.Framework.Interfaces; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using MySql.Data.MySqlClient; - -namespace Store.Modules.CloudProduct -{ - public class Module : IModule - { - /// - /// 本模块自己的配置文件 - /// - public const string ConfigurationFileName = "appsettings.ImageCache.json"; - - public int Priority => IntPriority.Normal; - - - /// - /// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app - /// - public void AddModule(IHostApplicationBuilder host) - { - IServiceCollection services = host.Services; - IConfigurationManager configuration = host.Configuration; - - if (!string.IsNullOrWhiteSpace(ConfigurationFileName)) - { - string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(Path.GetDirectoryName(GetType().Assembly.Location)!, ConfigurationFileName); - configuration.AddJsonFile(path, optional: false, reloadOnChange: true); - } - } - - /// - /// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务 - /// - public void UseModule(IApplicationBuilder app) - { - - } - - - } -} diff --git a/Plugins/Store.ImageCache/Startup.cs b/Plugins/Store.ImageCache/Startup.cs deleted file mode 100644 index e90b47d..0000000 --- a/Plugins/Store.ImageCache/Startup.cs +++ /dev/null @@ -1,45 +0,0 @@ -using MAX.Core.DependencyInjection; -using MAX.Core.Interfaces; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.ComponentModel; - -namespace Store.Modules.CloudProduct -{ - - public class Startup : IStartup - { - public int Priority => IntPriority.Normal; - - public void Initialize() - { - } - - public void Register(IServiceRegistrar registrar) - { - - - - Func KJDFsqlFactory = r => - { - var config = r.GetRequiredService(); - var connection = config.GetValue("ImageCache:ConnectionString"); - IFreeSql fsql = new FreeSql.FreeSqlBuilder() - .UseConnectionString(FreeSql.DataType.MySql, connection) - .UseAdoConnectionPool(true) - .Build(); - return fsql; - }; - registrar.RegisterSingleton("ImageCacheConnection", (s, k) => - { - return KJDFsqlFactory(s); - }); - } - - public void Start(IServiceProvider serviceProvider) - { - - } - } -} diff --git a/Plugins/Store.ImageCache/Store.ImageCache.csproj b/Plugins/Store.ImageCache/Store.ImageCache.csproj index 15c12e7..9efb987 100644 --- a/Plugins/Store.ImageCache/Store.ImageCache.csproj +++ b/Plugins/Store.ImageCache/Store.ImageCache.csproj @@ -9,9 +9,10 @@ + - + PreserveNewest PreserveNewest diff --git a/Plugins/Store.ImageCache/appsettings.ImageCache.json b/Plugins/Store.ImageCache/appsettings.json similarity index 100% rename from Plugins/Store.ImageCache/appsettings.ImageCache.json rename to Plugins/Store.ImageCache/appsettings.json diff --git a/Store.Api.slnx b/Store.Api.slnx index 84fa754..01c9f28 100644 --- a/Store.Api.slnx +++ b/Store.Api.slnx @@ -1,6 +1,7 @@ + diff --git a/Tests/PluginLoadingSmoke/ConfigurationChecks.cs b/Tests/PluginLoadingSmoke/ConfigurationChecks.cs new file mode 100644 index 0000000..e57a14e --- /dev/null +++ b/Tests/PluginLoadingSmoke/ConfigurationChecks.cs @@ -0,0 +1,53 @@ +using MAX.Plugins; +using Microsoft.Extensions.Hosting; + +internal static class ConfigurationChecks +{ + public static void Run() + { + var tempRoot = Path.GetFullPath(Path.GetTempPath()); + var directory = Path.GetFullPath(Path.Combine(tempRoot, "StorePluginChecks-" + Guid.NewGuid().ToString("N"))); + Directory.CreateDirectory(directory); + try + { + var environment = Host.CreateApplicationBuilder().Environment; + environment.EnvironmentName = "PluginTest"; + var first = Path.Combine(directory, "First"); + var second = Path.Combine(directory, "Second"); + Directory.CreateDirectory(first); + Directory.CreateDirectory(second); + File.WriteAllText(Path.Combine(first, "appsettings.json"), """{"Shared":"first","Required":"present"}"""); + File.WriteAllText(Path.Combine(first, "appsettings.PluginTest.json"), """{"Shared":"environment"}"""); + File.WriteAllText(Path.Combine(second, "appsettings.json"), """{"Shared":"second"}"""); + using var a = new PluginContext("First", first, environment); + using var b = new PluginContext("Second", second, environment); + if (a.Configuration["Shared"] != "environment" || b.Configuration["Shared"] != "second") + throw new Exception("Configuration isolation/environment override failed"); + if (a.RequireConfiguration("Required") != "present") throw new Exception("Required setting missing"); + try { b.RequireConfiguration("Required"); throw new Exception("Expected missing key failure"); } + catch (InvalidOperationException ex) when (ex.Message.Contains("Second") && ex.Message.Contains("Required")) { } + const string envKey = "Plugins__First__Shared"; + var previous = System.Environment.GetEnvironmentVariable(envKey); + try + { + System.Environment.SetEnvironmentVariable(envKey, "specific-environment-variable"); + using var overridden = new PluginContext("First", first, environment); + using var untouched = new PluginContext("Second", second, environment); + if (overridden.Configuration["Shared"] != "specific-environment-variable" || untouched.Configuration["Shared"] != "second") + throw new Exception("Plugin environment variable isolation failed"); + } + finally { System.Environment.SetEnvironmentVariable(envKey, previous); } + var absent = Path.Combine(directory, "Absent"); + Directory.CreateDirectory(absent); + try { using var invalid = new PluginContext("Absent", absent, environment); throw new Exception("Expected missing file failure"); } + catch (FileNotFoundException) { } + Console.WriteLine("PASS configuration isolation, environment overrides, missing file/key validation"); + } + finally + { + if (!directory.StartsWith(tempRoot.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Unexpected fixture path"); + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/Tests/PluginLoadingSmoke/Program.cs b/Tests/PluginLoadingSmoke/Program.cs index bbfd05b..b83b031 100644 --- a/Tests/PluginLoadingSmoke/Program.cs +++ b/Tests/PluginLoadingSmoke/Program.cs @@ -1,3 +1,4 @@ +using MAX.Plugins; using System.Reflection; using MAX.Framework.DependencyInjection; using Microsoft.Extensions.DependencyInjection; @@ -5,6 +6,8 @@ using Microsoft.Extensions.Hosting; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Controllers; +if (args.ElementAtOrDefault(0) == "configuration") { ConfigurationChecks.Run(); return; } + // Run each scenario in a fresh process: default ALC intentionally cannot unload plugins. var finderType = typeof(ServiceCollectionExtensions).Assembly.GetType("MAX.Framework.DependencyInjection.StoreAppTypeFinder", true)!; var finder = finderType.GetProperty("Current")!.GetValue(null); @@ -36,17 +39,36 @@ var feature = new ControllerFeature(); mvc.PartManager.PopulateFeature(feature); if (!feature.Controllers.Any(t => t.Name == "ImageCacheController")) throw new Exception("ImageCache controller missing"); if (mode != "image" && !feature.Controllers.Any(t => t.Name == "GoodsController")) throw new Exception("Goods controller missing"); -if (mode == "image") +if (mode is "image" or "plugins") { var host = Host.CreateApplicationBuilder(); var core = typeof(ServiceCollectionExtensions).GetMethod("AddStoreCore", BindingFlags.Static | BindingFlags.NonPublic)!; + host.Configuration["Search:Url"] = "https://host.invalid"; + host.Configuration["ImageCache:ConnectionString"] = "host-only"; core.Invoke(null, new object[] { host, plugins, new Dictionary() }); - if (string.IsNullOrEmpty(host.Configuration["ImageCache:ConnectionString"])) throw new Exception("Plugin configuration missing"); + using var built = host.Build(); + var image = built.Services.GetRequiredKeyedService("ImageCache"); + if (image.Configuration["ImageCache:ConnectionString"] is null or "host-only") throw new Exception("Image config isolation failed"); + if (image.Configuration["Search:Url"] != null) throw new Exception("Host settings leaked into ImageCache"); + if (host.Configuration["ImageCache:ConnectionString"] != "host-only") throw new Exception("Plugin modified host configuration"); + if (mode == "plugins") + { + var cloud = built.Services.GetRequiredKeyedService("CloudProduct"); + if (cloud.Configuration["Search:Url"] is null or "https://host.invalid") throw new Exception("Cloud config isolation failed"); + if (host.Configuration["Search:Url"] != "https://host.invalid") throw new Exception("Cloud modified host configuration"); + foreach (var name in new[] { "OpenSearchService", "UserFavoritesIndexService", "RawDataStorage", "GoodsIndexJob", "GoodsService" }) + { + var type = plugins.SelectMany(p => p.GetTypes()).Single(t => t.Name == name); + using var scope = built.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService(type); + } + } + if (!host.Services.Any(d => d.ServiceType.Name == "ImageCacheService")) throw new Exception("Plugin DI registration missing"); - if (!host.Services.Any(d => d.ServiceKey is string key && key == "ImageCacheConnection")) throw new Exception("Startup registration missing"); - var registeredMvc = host.Services.AddControllers(); + if (!host.Services.Any(d => d.ServiceKey is string key && key == "ImageCacheConnection")) throw new Exception("Plugin database registration missing"); + var registeredMvc = built.Services.GetRequiredService(); var registered = new ControllerFeature(); - registeredMvc.PartManager.PopulateFeature(registered); + registeredMvc.PopulateFeature(registered); if (!registered.Controllers.Any(t => t.Name == "ImageCacheController")) throw new Exception("Core MVC registration missing"); } -Console.WriteLine($"PASS plugins={plugins.Length}, controllers={feature.Controllers.Count}; dependencies/types loaded" + (mode == "image" ? "; configuration/DI/Startup/MVC registered" : "")); +Console.WriteLine($"PASS plugins={plugins.Length}, controllers={feature.Controllers.Count}; dependencies/types loaded" + (mode is "image" or "plugins" ? "; isolated configuration/DI/plugin lifecycle/MVC registered" : ""));