修改 CloudProduct ImageCache 项目改为 插件

This commit is contained in:
chfei committed 2026-10-22 15:56:55 +08:00
1 parent 5ff52dea92
commit 292cb8638c
26 files changed
+417 -282

No files matched your search

@@ -13,6 +13,7 @@ using Microsoft.Win32;
using System.Configuration; using System.Configuration;
using System.Diagnostics; using System.Diagnostics;
using System.Reflection; using System.Reflection;
using MAX.Plugins;
namespace MAX.Framework.DependencyInjection namespace MAX.Framework.DependencyInjection
{ {
@@ -33,10 +34,13 @@ namespace MAX.Framework.DependencyInjection
/// </summary> /// </summary>
public static IServiceCollection AddStore(this IHostApplicationBuilder host) public static IServiceCollection AddStore(this IHostApplicationBuilder host)
{ {
host.AddLogger(); Debug.WriteLine("[Store] 开始注册应用服务");
RunDebugStep("Logger", "注册日志服务", () => host.AddLogger());
//host.Services.AddSingleton<ILoggerFacade>(NLogger.Instance);//注册log //host.Services.AddSingleton<ILoggerFacade>(NLogger.Instance);//注册log
IEnumerable<Assembly> assemblies = StoreAppTypeFinder.Current.GetAssemblies(host.Configuration["Plugins:Path"]).ToArray(); IEnumerable<Assembly> assemblies = StoreAppTypeFinder.Current.GetAssemblies(host.Configuration["Plugins:Path"]).ToArray();
foreach (var assembly in assemblies)
Debug.WriteLine($"[Assembly] 已发现:{assembly.FullName}");
// 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象 // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象
Dictionary<Type, object> instances = []; Dictionary<Type, object> instances = [];
return AddStoreCore(host, assemblies, instances); return AddStoreCore(host, assemblies, instances);
@@ -54,14 +58,16 @@ namespace MAX.Framework.DependencyInjection
IEnumerable<Assembly> assemblies, IEnumerable<Assembly> assemblies,
IDictionary<Type, object> instances) IDictionary<Type, object> 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 => var mvc = host.Services.AddControllers(options =>
{ {
options.Filters.Add<GlobalExceptionFilter>(); options.Filters.Add<GlobalExceptionFilter>();
@@ -90,7 +96,9 @@ namespace MAX.Framework.DependencyInjection
foreach (var assembly in assemblies) foreach (var assembly in assemblies)
{ {
mvc.AddApplicationPart(assembly); mvc.AddApplicationPart(assembly);
Debug.WriteLine($"[MVC] 已添加 ApplicationPart:{assembly.GetName().Name}");
} }
Debug.WriteLine($"[Store] 应用服务注册完成,共 {host.Services.Count} 个服务描述");
return host.Services; return host.Services;
} }
@@ -101,7 +109,6 @@ namespace MAX.Framework.DependencyInjection
{ {
foreach (Type type in GetConcreteTypes(assemblies)) foreach (Type type in GetConcreteTypes(assemblies))
{ {
Debug.WriteLine(type.Namespace);
Type? marker = DependencyMarkers.FirstOrDefault(m => m.IsAssignableFrom(type)); Type? marker = DependencyMarkers.FirstOrDefault(m => m.IsAssignableFrom(type));
if (marker is null) if (marker is null)
{ {
@@ -114,6 +121,7 @@ namespace MAX.Framework.DependencyInjection
// 注册为自身 // 注册为自身
services.Add(new ServiceDescriptor(type, type, lifetime)); services.Add(new ServiceDescriptor(type, type, lifetime));
Debug.WriteLine($"[DI] 注册自身:{type.FullName},Lifetime={lifetime}");
// 注册为实现的接口(排除标记接口与系统接口) // 注册为实现的接口(排除标记接口与系统接口)
foreach (Type iface in type.GetInterfaces()) foreach (Type iface in type.GetInterfaces())
@@ -124,6 +132,7 @@ namespace MAX.Framework.DependencyInjection
} }
services.Add(new ServiceDescriptor(iface, type, lifetime)); 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<Assembly> assemblies, IDictionary<Type, object> instances) private static void RunStartups(IServiceCollection services, IEnumerable<Assembly> assemblies, IDictionary<Type, object> instances)
{ {
IServiceRegistrar registrar = new ServiceRegistrarProxy(services); IServiceRegistrar registrar = new ServiceRegistrarProxy(services);
Startups = CreateInstances<IStartup>(assemblies, instances).OrderByDescending(s => s.Priority); Startups = CreateInstances<IStartup>(assemblies, instances).Where(s => s is not IPlugin).OrderByDescending(s => s.Priority);
foreach (IStartup startup in Startups) foreach (IStartup startup in Startups)
{ {
startup.Initialize(); RunDebugStep("Startup", $"初始化 {startup.GetType().FullName},Priority={startup.Priority}", startup.Initialize);
startup.Register(registrar); RunDebugStep("Startup", $"注册服务 {startup.GetType().FullName}", () => startup.Register(registrar));
} }
} }
@@ -148,12 +157,12 @@ namespace MAX.Framework.DependencyInjection
/// </summary> /// </summary>
private static void RunModules(IHostApplicationBuilder host, IEnumerable<Assembly> assemblies, IDictionary<Type, object> instances) private static void RunModules(IHostApplicationBuilder host, IEnumerable<Assembly> assemblies, IDictionary<Type, object> instances)
{ {
Modules = CreateInstances<IModule>(assemblies, instances).OrderByDescending(s => s.Priority); Modules = CreateInstances<IModule>(assemblies, instances).Where(m => m is not IPlugin).OrderByDescending(s => s.Priority);
foreach (IModule module in Modules) foreach (IModule module in Modules)
{ {
try try
{ {
module.AddModule(host); RunDebugStep("Module", $"注册 {module.GetType().FullName},Priority={module.Priority}", () => module.AddModule(host));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -175,6 +184,11 @@ namespace MAX.Framework.DependencyInjection
instance = Activator.CreateInstance(type, true) instance = Activator.CreateInstance(type, true)
?? throw new InvalidOperationException($"无法创建 {type.FullName} 的实例(需要有参数量为 0 的构造函数)。"); ?? throw new InvalidOperationException($"无法创建 {type.FullName} 的实例(需要有参数量为 0 的构造函数)。");
instances[type] = instance; instances[type] = instance;
Debug.WriteLine($"[Instance] 已创建:{type.FullName}");
}
else
{
Debug.WriteLine($"[Instance] 复用实例:{type.FullName}");
} }
yield return (T)instance; yield return (T)instance;
@@ -196,6 +210,9 @@ namespace MAX.Framework.DependencyInjection
} }
catch (ReflectionTypeLoadException ex) catch (ReflectionTypeLoadException ex)
{ {
Debug.WriteLine($"[Assembly] 部分类型加载失败:{assembly.FullName}");
foreach (var error in ex.LoaderExceptions)
Debug.WriteLine($"[Assembly] 类型加载错误:{error?.Message}");
return ex.Types.OfType<Type>(); return ex.Types.OfType<Type>();
} }
} }
@@ -209,6 +226,53 @@ namespace MAX.Framework.DependencyInjection
return StoreAppTypeFinder.Current.GetAssemblies(); 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<Assembly> assemblies, IDictionary<Type, object> instances)
{
var runtime = new PluginRuntime();
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var plugin in CreateInstances<IPlugin>(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<PluginContext>(plugin.Id, (provider, _) =>
{
// 只要上下文被解析,就让容器持有并管理全部插件配置的释放。
provider.GetRequiredService<PluginRuntime>();
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) private static bool IsSystemInterface(Type type)
{ {
return type.Namespace is null 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) public static IApplicationBuilder UseStore(this IApplicationBuilder app)
{ {
app.UseMiddleware<UnifyStatusCodeMiddleware>(); Debug.WriteLine("[Store] 开始装配应用管道");
RunDebugStep("Middleware", "注册统一状态码中间件", () => app.UseMiddleware<UnifyStatusCodeMiddleware>());
foreach (var (plugin, context) in app.ApplicationServices.GetRequiredService<PluginRuntime>().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) 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) foreach (IModule module in Modules)
{ {
module.UseModule(app); RunDebugStep("Module", $"装配管道 {module.GetType().FullName}", () => module.UseModule(app));
} }
Debug.WriteLine("[Store] 应用管道装配完成");
return app; return app;
} }
+2 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
@@ -26,6 +26,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\MAX.Core\MAX.Core.csproj" /> <ProjectReference Include="..\MAX.Core\MAX.Core.csproj" />
<ProjectReference Include="..\MAX.Plugins\MAX.Plugins.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace MAX.Plugins;
/// <summary>独立插件入口。实现类必须具有无参构造函数。</summary>
public interface IPlugin
{
string Id { get; }
int Priority { get; }
void ConfigureServices(IServiceCollection services, PluginContext context);
void Configure(IApplicationBuilder app, PluginContext context);
}
+3
View File
@@ -0,0 +1,3 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App" /></ItemGroup>
</Project>
+32
View File
@@ -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);
}
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace MAX.Plugins;
/// <summary>插件独立配置,不合并宿主或其他插件配置。</summary>
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();
}
}
+4
View File
@@ -21,6 +21,10 @@
foreach (var file in Directory.GetFiles(plugin, "*", SearchOption.AllDirectories)) foreach (var file in Directory.GetFiles(plugin, "*", SearchOption.AllDirectories))
{ {
var relative = file.Substring(plugin.TrimEnd(Path.DirectorySeparatorChar).Length + 1); 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)); var shared = Path.GetFullPath(Path.Combine(host, relative));
// Only compare files below the host, outside its plugin subtree. // Only compare files below the host, outside its plugin subtree.
if (!shared.StartsWith(host.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) if (!shared.StartsWith(host.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
@@ -1,55 +1,30 @@
using MAX.Plugins;
using Hangfire; using Hangfire;
using Hangfire.MySql; using Hangfire.MySql;
using HangfireBasicAuthenticationFilter; using HangfireBasicAuthenticationFilter;
using MAX.Core.Interfaces;
using MAX.Framework.Interfaces;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MySql.Data.MySqlClient; using MySql.Data.MySqlClient;
using Store.Modules.CloudProduct.Services; using Store.Modules.CloudProduct.Services;
namespace Store.Modules.CloudProduct namespace Store.Modules.CloudProduct
{ {
public class CloudProductModule : IModule public sealed class CloudProductPlugin : PluginBase
{ {
/// <summary> public override string Id => "CloudProduct";
/// 本模块自己的配置文件 protected override void RegisterServices(IServiceCollection services)
/// </summary> {
public const string ConfigurationFileName = "appsettings.CloudProduct.json"; var configuration = Context.Configuration;
RequireConfiguration("Search:Url");
public int Priority => IntPriority.Normal; RequireConfiguration("Search:IndexName");
RequireConfiguration("Search:FavoritesIndexName");
RequireConfiguration("RawData:Path");
/// <summary>
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
/// </summary>
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");
// ===== Hangfire 后台定时任务(商品分词同步) ===== // ===== Hangfire 后台定时任务(商品分词同步) =====
var hangfireConnection = configuration.GetValue<string>("CloudProductConnection") var hangfireConnection = configuration.GetValue<string>("CloudProductConnection")
?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。"); ?? throw new InvalidOperationException("缺少配置 CloudProductConnection,CloudProduct 模块的 Hangfire 无法启动。");
// AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量 // AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量
var connectionStringBuilder = new MySqlConnectionStringBuilder(hangfireConnection) var connectionStringBuilder = new MySqlConnectionStringBuilder(hangfireConnection)
@@ -64,17 +39,22 @@ namespace Store.Modules.CloudProduct
.UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" }))); .UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" })));
services.AddHangfireServer(); services.AddHangfireServer();
services.AddTransient<GoodsIndexJob>(); services.AddTransient<GoodsIndexJob>();
var connection = RequireConfiguration("CloudProductConnection");
services.AddKeyedSingleton<IFreeSql>("CloudProductConnection", (_, _) =>
new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connection)
.UseAdoConnectionPool(true).Build());
services.AddHostedService<CloudProductSearchStartup>();
} }
/// <summary> /// <summary>
/// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务 /// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
/// </summary> /// </summary>
public void UseModule(IApplicationBuilder app) protected override void ConfigureApplication(IApplicationBuilder app)
{ {
try try
{ {
var Configuration = app.ApplicationServices.GetService<IConfiguration>()!; var Configuration = Context.Configuration;
// Hangfire 后台任务看板(默认地址 /hangfire) // Hangfire 后台任务看板(默认地址 /hangfire)
app.UseHangfireDashboard("/hangfire", new DashboardOptions app.UseHangfireDashboard("/hangfire", new DashboardOptions
{ {
@@ -104,15 +84,6 @@ namespace Store.Modules.CloudProduct
} }
} }
/// <summary>
/// 校验模块配置项存在(值来自模块自己的 <see cref="ConfigurationFileName"/>) }
/// </summary> }
private static void RequireConfiguration(IConfiguration configuration, string key)
{
if (string.IsNullOrWhiteSpace(configuration.GetValue<string>(key)))
{
throw new InvalidOperationException($"缺少配置 {key},请检查模块配置文件 {ConfigurationFileName}。");
}
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Store.Modules.CloudProduct.Services;
/// <summary>查询搜索服务状态;网络不可用时记录错误,不阻止 HTTP 服务启动。</summary>
internal sealed class CloudProductSearchStartup(
IOpenSearchService search, ILogger<CloudProductSearchStartup> 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 搜索服务启动检查失败。");
}
}
}
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration; using MAX.Plugins;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Store.Modules.CloudProduct.Services namespace Store.Modules.CloudProduct.Services
@@ -13,10 +14,10 @@ namespace Store.Modules.CloudProduct.Services
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<GoodsIndexJob> _logger; private readonly ILogger<GoodsIndexJob> _logger;
public GoodsIndexJob(IServiceScopeFactory scopeFactory, IConfiguration configuration,ILogger<GoodsIndexJob> logger) public GoodsIndexJob(IServiceScopeFactory scopeFactory, [FromKeyedServices("CloudProduct")] PluginContext context,ILogger<GoodsIndexJob> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_configuration = configuration; _configuration = context.Configuration;
_logger = logger; _logger = logger;
} }
@@ -1,3 +1,5 @@
using MAX.Plugins;
using Microsoft.Extensions.DependencyInjection;
using MAX.Core.DependencyInjection; using MAX.Core.DependencyInjection;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Store.Modules.CloudProduct.Dtos; using Store.Modules.CloudProduct.Dtos;
@@ -24,12 +26,12 @@ namespace Store.Modules.CloudProduct.Services
private readonly IRawDataStorage _rawDataStorage; private readonly IRawDataStorage _rawDataStorage;
private readonly IConfiguration _configuration; 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; _repository = repository;
_searchService = searchService; _searchService = searchService;
_rawDataStorage = rawDataStorage; _rawDataStorage = rawDataStorage;
_configuration = configuration; _configuration = context.Configuration;
} }
public async Task<(List<GoodsEntity> List, long Total)> SearchGoodsAsync(GoodsSearchRequest request) public async Task<(List<GoodsEntity> List, long Total)> SearchGoodsAsync(GoodsSearchRequest request)
@@ -1,3 +1,5 @@
using MAX.Plugins;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics; using System.Diagnostics;
using MAX.Core.DependencyInjection; using MAX.Core.DependencyInjection;
using MAX.Framework.Commons; using MAX.Framework.Commons;
@@ -40,9 +42,9 @@ namespace Store.Modules.CloudProduct.Services
private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30);
private readonly ILogger<OpenSearchService> _logger; private readonly ILogger<OpenSearchService> _logger;
public OpenSearchService(IConfiguration configuration,ILogger<OpenSearchService> logger) public OpenSearchService([FromKeyedServices("CloudProduct")] PluginContext context,ILogger<OpenSearchService> logger)
{ {
var config = configuration.GetSection("Search"); var config = context.Configuration.GetSection("Search");
_nodeUrl = config.GetValue<string>("Url") ?? ""; _nodeUrl = config.GetValue<string>("Url") ?? "";
_indexName = config.GetValue<string>("IndexName") ?? ""; _indexName = config.GetValue<string>("IndexName") ?? "";
_client = CreateClient(); _client = CreateClient();
@@ -1,3 +1,5 @@
using MAX.Plugins;
using Microsoft.Extensions.DependencyInjection;
using MAX.Core.DependencyInjection; using MAX.Core.DependencyInjection;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using System.IO.Compression; using System.IO.Compression;
@@ -30,13 +32,13 @@ namespace Store.Modules.CloudProduct.Services
{ {
private readonly string _rootPath; private readonly string _rootPath;
public RawDataStorage(IConfiguration configuration) public RawDataStorage([FromKeyedServices("CloudProduct")] PluginContext context)
{ {
var configured = configuration.GetValue<string>("RawData:Path"); var configured = context.Configuration.GetValue<string>("RawData:Path");
if (string.IsNullOrWhiteSpace(configured)) if (string.IsNullOrWhiteSpace(configured))
configured = "RawData"; configured = "RawData";
_rootPath = Path.GetFullPath(configured); _rootPath = Path.GetFullPath(configured, context.DirectoryPath);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -1,3 +1,5 @@
using MAX.Plugins;
using Microsoft.Extensions.DependencyInjection;
using MAX.Core.DependencyInjection; using MAX.Core.DependencyInjection;
using MAX.Framework.Commons; using MAX.Framework.Commons;
using MAX.Framework.Dtos; using MAX.Framework.Dtos;
@@ -53,9 +55,9 @@ namespace Store.Modules.CloudProduct.Services
// 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长) // 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长)
private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30);
public UserFavoritesIndexService(IConfiguration configuration, ILogger<UserFavoritesIndexService> logger) public UserFavoritesIndexService([FromKeyedServices("CloudProduct")] PluginContext context, ILogger<UserFavoritesIndexService> logger)
{ {
var config = configuration.GetSection("Search"); var config = context.Configuration.GetSection("Search");
_nodeUrl = config.GetValue<string>("Url") ?? ""; _nodeUrl = config.GetValue<string>("Url") ?? "";
_indexName = config.GetValue<string>("FavoritesIndexName") ?? ""; _indexName = config.GetValue<string>("FavoritesIndexName") ?? "";
_logger = logger; _logger = logger;
-49
View File
@@ -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<IServiceProvider, IFreeSql> KJDFsqlFactory = r =>
{
var config = r.GetRequiredService<IConfiguration>();
var connection = config.GetValue<string>("CloudProductConnection");
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.MySql, connection)
.UseAdoConnectionPool(true)
.Build();
return fsql;
};
registrar.RegisterSingleton<IFreeSql>("CloudProductConnection", (s, k) =>
{
return KJDFsqlFactory(s);
});
}
public async void Start(IServiceProvider serviceProvider)
{
var openSearchService = serviceProvider.GetRequiredService<OpenSearchService>();
await openSearchService.GetStatusAsync();
}
}
}
@@ -10,16 +10,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" /> <ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
<ProjectReference Include="..\..\Core\MAX.Plugins\MAX.Plugins.csproj" />
</ItemGroup> </ItemGroup>
<!--<ItemGroup>
--><!-- 本模块自己的配置文件:源码里叫 appsettings.json,构建后复制到宿主输出目录时改名为
appsettings.CloudProduct.json,避免和宿主自己的 appsettings.json 互相覆盖。
由 builder.AddStoreHub() 自动并入宿主配置树(在 appsettings*.json 之后、环境变量之前)。 --><!--
<None Update="appsettings.json" Link="appsettings.CloudProduct.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>-->
<ItemGroup> <ItemGroup>
<None Update="appsettings.CloudProduct.json"> <None Update="appsettings*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None> </None>
+57 -51
View File
@@ -1,86 +1,92 @@
# 独立功能插件 # 独立功能插件
`Modules/` 放置商城业务模块,`Plugins/` 放置共用 API 宿主的独立功能。 `Modules/` 使用 IModule,存放商城业务模块;`Plugins/` 使用 IPlugin,存放共用 API 宿主的独立功能。
插件通过 IModule、IStartup 和依赖标记接口接入现有生命周期,控制器共用宿主 URL。 公共项目 `Core/MAX.Plugins` 不依赖商城业务或数据库库,提供:
## 发布和运行 - `IPlugin`:唯一 Id、Priority、服务注册和管道装配生命周期。
- `PluginBase`:保存插件上下文、配置校验、默认管道实现;派生类只需实现 RegisterServices,按需重写 ConfigureApplication。
- `PluginContext`:插件目录、宿主环境、隔离的 Configuration 和必需配置校验。
在仓库根目录执行生成命令: 宿主扫描加载插件,按 Priority 降序注册,显式注册 MVC Application Parts;IModule/IStartup 与 IPlugin 分开执行,禁止同一入口同时实现两套生命周期。
## 生成和部署
在仓库根目录执行,宿主无需引用具体插件:
```powershell ```powershell
dotnet build Store.API/Store.Api.csproj -c Release dotnet build Store.API/Store.Api.csproj -c Release
dotnet build Plugins/CloudProduct/Store.CloudProduct.csproj -c Release dotnet build Plugins/CloudProduct/Store.CloudProduct.csproj -c Release
dotnet build Plugins/Store.ImageCache/Store.ImageCache.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/`,不再附加配置名称、目标框架或运行时子目录。 API 输出到根目录 Bin;插件生成后自动复制到 Bin/Plugins/<项目名>/。生成过程按相对路径和 SHA-256 清理宿主已提供的重复文件;插件自己的 appsettings*.json 始终保留。
各插件生成后自动将自身完整输出(DLL、deps.json、配置、运行依赖及 runtimes 子目录)复制到 宿主后生成时也会去重;同名但内容不同的依赖保留,由加载器检查版本兼容性。
`Bin/Plugins/<项目名>/`。例如: Debug/Release 共用 Bin,部署前请统一使用 Release。整体拷贝 Bin 部署,需要兼容的 .NET/ASP.NET Core 运行时。
```text ```text
Bin/ Bin/
Store.Api.dll Store.Api.dll
Store.Api.deps.json MAX.Plugins.dll
Store.Api.runtimeconfig.json
appsettings.json appsettings.json
Plugins/ Plugins/
Store.CloudProduct/ Store.CloudProduct/
Store.CloudProduct.dll
Store.CloudProduct.deps.json
appsettings.json
Store.ImageCache/ Store.ImageCache/
Store.ImageCache.dll
Store.ImageCache.deps.json
appsettings.json
``` ```
生成全部所需项目后即可整体拷贝 `Bin/` 部署,目标机器需要兼容的 .NET/ASP.NET Core 运行时。 每个部署子目录只放一个插件入口。构建会清理重命名前的旧入口文件;其他过期依赖或旧目录需要自行清理。
API 不引用插件,也不会在单独生成 API 时触发插件构建;生成插件项目或生成包含插件的解决方案时会自动复制。 插件扫描配置 Plugins:Path 默认 Plugins,相对 AppContext.BaseDirectory,也支持绝对路径;可通过 Plugins__Path 覆盖。
未来在 Plugins 下新增项目会继承同目录的 Directory.Build.props/targets,无需逐个添加复制任务。 MSBuild 属性 StoreHostOutputPath 可覆盖生成目录,应使用以分隔符结尾的绝对路径。
插件仅启动时加载,更新后重启,不支持卸载、热更新、进程隔离或冲突依赖版本隔离。
Debug 与 Release 共用此目录;部署前应以相同配置重新生成宿主及所需插件。 ## 配置隔离
生成宿主或插件后,都会按相对路径和 SHA-256 清理与宿主完全相同的插件文件;同名但内容不同的文件保留。
宿主尚未生成时插件暂时保留完整依赖,之后生成宿主会自动去重。去重后的插件目录必须与匹配的宿主一起部署。
同一插件输出和部署子目录中的旧入口(其他名称的 .deps.json 及对应 DLL、PDB、runtimeconfig.json)会在生成时自动清理,避免重复加载重命名前的插件。每个部署子目录只放一个插件入口。
其他旧文件不会自动删除;插件部署目录更名、删除或移除依赖后,应清理对应旧目录再生成。
MSBuild 属性 `StoreHostOutputPath` 可覆盖此路径,覆盖时使用绝对目录并以目录分隔符结尾。
需要标准 publish 产物时也可以指定独立部署目录: 各插件只读取自身目录,按以下顺序覆盖:
```powershell 1. appsettings.json(必需)。
dotnet publish Store.API/Store.Api.csproj -c Release -o artifacts/server 2. appsettings.<EnvironmentName>.json(可选,例如 appsettings.Development.json)。
dotnet publish Plugins/CloudProduct/Store.CloudProduct.csproj -c Release -o artifacts/server/Plugins/Store.CloudProduct 3. 前缀 Plugins__<Id>__ 的环境变量,例如 Plugins__CloudProduct__Search__Url。
dotnet publish Plugins/Store.ImageCache/Store.ImageCache.csproj -c Release -o artifacts/server/Plugins/Store.ImageCache
宿主配置、其他插件配置不会并入插件配置,插件配置也不会修改宿主 IConfiguration。
配置在启动时加载,修改后重启。缺失文件、格式错误或缺失必需项时,错误包含插件身份及上下文信息。
原有配置键保持不变;原始数据相对路径改为相对于 CloudProduct 插件目录,绝对路径不变。
插件通过 Context.Configuration 读取配置。业务服务可使用按插件 Id 注册的上下文,或在插件注册时将配置绑定到专属 Options:
```csharp
public ExampleService([FromKeyedServices("CloudProduct")] PluginContext context)
{
var url = context.RequireConfiguration("Search:Url");
}
``` ```
宿主配置 `Plugins:Path` 默认为 `Plugins`,相对路径以宿主二进制目录为基准,也可使用绝对路径。 数据库、Hangfire、OpenSearch 保留在各自插件内;认证、日志等仍由宿主管理。
可以通过环境变量 `Plugins__Path` 覆盖。目录不存在或为空时不加载插件。 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:创建类库,引用共享框架,实现需要的模块/启动接口,启用 如需完整独立发布产物,可分别 dotnet publish -o 到自定义目录;自定义发布目录不执行 Bin 下的共享文件去重。
`EnableDynamicLoading`,并将配置设置为复制到输出和发布目录;生成后会自动复制到宿主扫描目录;也可以独立发布到其他扫描目录。
配置文件命名与配置节应避免和其他插件冲突。现有模块的配置合并顺序保持不变。
插件、宿主和共享契约必须使用兼容版本。所有程序集加载到默认上下文,同名程序集只加载一次; ## 验证
发布目录中同名依赖身份不一致会在加载时明确报错,不提供依赖版本隔离。
插件必需的程序集、类型或配置缺失时启动失败;未调用的第三方可选功能依赖可能在调用时才解析。
插件与宿主同进程运行,共用认证、容器和中间件。更新时替换完整发布目录并重启宿主;
不支持热加载、卸载和进程隔离。CloudProduct 仍需要其数据库、OpenSearch 和后台任务配置。
## 加载冒烟验证
验证程序不连接数据库或 OpenSearch;每个场景单独启动进程:
```powershell ```powershell
dotnet run --project Tests/PluginLoadingSmoke -- <插件根目录> dotnet run --project Tests/PluginLoadingSmoke -- configuration
dotnet run --project Tests/PluginLoadingSmoke -- <仅包含ImageCache的发布目录> image 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 -- - empty dotnet run --project Tests/PluginLoadingSmoke -- <缺依赖或版本冲突目录> failure
dotnet run --project Tests/PluginLoadingSmoke -- <缺依赖或包含版本冲突的目录> failure
``` ```
`-` 使用默认 Plugins 路径;默认路径下已部署插件时使用普通模式。 配置验证覆盖同名键隔离、环境文件和环境变量覆盖、必需文件和配置项缺失。
普通模式验证两个插件及控制器发现和程序集去重;`image` 额外验证真实框架注册流程中的配置、DI、IStartup 和 MVC。 加载验证覆盖两个插件的控制器、DI、数据库服务注册和业务服务构造,不连接数据库或启动后台任务。
将同一插件发布内容复制到另一子目录可验证重复程序集;缺失依赖和版本冲突应报告具体插件路径。 真实启动还需数据库和 OpenSearch;外部服务不可达应结合日志判断对应功能状态。
@@ -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<IFreeSql>("ImageCacheConnection", (_, _) =>
new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connection)
.UseAdoConnectionPool(true).Build());
}
}
-47
View File
@@ -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
{
/// <summary>
/// 本模块自己的配置文件
/// </summary>
public const string ConfigurationFileName = "appsettings.ImageCache.json";
public int Priority => IntPriority.Normal;
/// <summary>
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
/// </summary>
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);
}
}
/// <summary>
/// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
/// </summary>
public void UseModule(IApplicationBuilder app)
{
}
}
}
-45
View File
@@ -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<IServiceProvider, IFreeSql> KJDFsqlFactory = r =>
{
var config = r.GetRequiredService<IConfiguration>();
var connection = config.GetValue<string>("ImageCache:ConnectionString");
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.MySql, connection)
.UseAdoConnectionPool(true)
.Build();
return fsql;
};
registrar.RegisterSingleton<IFreeSql>("ImageCacheConnection", (s, k) =>
{
return KJDFsqlFactory(s);
});
}
public void Start(IServiceProvider serviceProvider)
{
}
}
}
@@ -9,9 +9,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" /> <ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
<ProjectReference Include="..\..\Core\MAX.Plugins\MAX.Plugins.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="appsettings.ImageCache.json"> <None Update="appsettings*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None> </None>
+1
View File
@@ -1,6 +1,7 @@
<Solution> <Solution>
<Folder Name="/Core/"> <Folder Name="/Core/">
<Project Path="Core/MAX.Core/MAX.Core.csproj" /> <Project Path="Core/MAX.Core/MAX.Core.csproj" />
<Project Path="Core/MAX.Plugins/MAX.Plugins.csproj" />
<Project Path="Core/MAX.Framework/MAX.Framework.csproj" /> <Project Path="Core/MAX.Framework/MAX.Framework.csproj" />
</Folder> </Folder>
<Folder Name="/Modules/"> <Folder Name="/Modules/">
@@ -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);
}
}
}
+28 -6
View File
@@ -1,3 +1,4 @@
using MAX.Plugins;
using System.Reflection; using System.Reflection;
using MAX.Framework.DependencyInjection; using MAX.Framework.DependencyInjection;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -5,6 +6,8 @@ using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers; 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. // 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 finderType = typeof(ServiceCollectionExtensions).Assembly.GetType("MAX.Framework.DependencyInjection.StoreAppTypeFinder", true)!;
var finder = finderType.GetProperty("Current")!.GetValue(null); var finder = finderType.GetProperty("Current")!.GetValue(null);
@@ -36,17 +39,36 @@ var feature = new ControllerFeature();
mvc.PartManager.PopulateFeature(feature); mvc.PartManager.PopulateFeature(feature);
if (!feature.Controllers.Any(t => t.Name == "ImageCacheController")) throw new Exception("ImageCache controller missing"); 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" && !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 host = Host.CreateApplicationBuilder();
var core = typeof(ServiceCollectionExtensions).GetMethod("AddStoreCore", BindingFlags.Static | BindingFlags.NonPublic)!; 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<Type, object>() }); core.Invoke(null, new object[] { host, plugins, new Dictionary<Type, object>() });
if (string.IsNullOrEmpty(host.Configuration["ImageCache:ConnectionString"])) throw new Exception("Plugin configuration missing"); using var built = host.Build();
var image = built.Services.GetRequiredKeyedService<PluginContext>("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<PluginContext>("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.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"); if (!host.Services.Any(d => d.ServiceKey is string key && key == "ImageCacheConnection")) throw new Exception("Plugin database registration missing");
var registeredMvc = host.Services.AddControllers(); var registeredMvc = built.Services.GetRequiredService<ApplicationPartManager>();
var registered = new ControllerFeature(); 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"); 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" : ""));