From 0e1620c1f94f35301e5950457bfb486324ddc40a Mon Sep 17 00:00:00 2001 From: chfei Date: Fri, 18 Sep 2026 19:17:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HostBuilderExtensions.cs | 61 +++++++++++ .../DependencyInjection/ModuleRegistry.cs | 20 ++++ .../ServiceCollectionExtensions.cs | 100 ++++++++++++++---- Core/MAX.Framework/Interfaces/IModule.cs | 35 +++++- Core/MAX.Framework/Interfaces/ModuleBase.cs | 23 ++++ .../MAX.Framework/Interfaces/ModuleContext.cs | 46 ++++++++ Models/CloudProduct/CloudProductModule.cs | 89 ++++++++++++++++ Models/CloudProduct/Services/GoodsIndexJob.cs | 32 ++++++ Models/CloudProduct/Startup.cs | 19 ++-- .../Store.Modules.CloudProduct.csproj | 5 +- Store.API/Program.cs | 10 +- 11 files changed, 399 insertions(+), 41 deletions(-) create mode 100644 Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs create mode 100644 Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs create mode 100644 Core/MAX.Framework/Interfaces/ModuleBase.cs create mode 100644 Core/MAX.Framework/Interfaces/ModuleContext.cs create mode 100644 Models/CloudProduct/CloudProductModule.cs create mode 100644 Models/CloudProduct/Services/GoodsIndexJob.cs diff --git a/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs b/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs new file mode 100644 index 0000000..a24ef14 --- /dev/null +++ b/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs @@ -0,0 +1,61 @@ +using MAX.Framework.Interfaces; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace MAX.Framework.DependencyInjection +{ + /// + /// 宿主(Program.cs)入口:模块两阶段装配。 + /// + /// var builder = WebApplication.CreateBuilder(args); + /// builder.AddStoreHub(); // 阶段一:服务注册(自动执行所有 IModule.AddModule) + /// + /// var app = builder.Build(); + /// // ... 宿主自己的中间件、认证、端点 ... + /// app.UseStoreHubModules(); // 阶段二:管道装配(自动执行所有 IModule.UseModule) + /// app.Run(); + /// + /// + public static class HostBuilderExtensions + { + /// + /// 在 上注册应用框架服务: + /// 等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment), + /// 区别是会把配置与环境一起包装成 交给各模块。 + /// + public static WebApplicationBuilder AddStoreHub(this WebApplicationBuilder builder) + { + builder.Services.AddStoreHub(builder.Configuration, builder.Environment); + return builder; + } + + /// + /// 执行所有模块的管道阶段():注册中间件、看板、Map 端点等。 + /// 必须在 builder.Build() 之后、app.Run() 之前调用; + /// 建议放在 UseAuthorization() 之后、MapControllers() 之前。 + /// + public static WebApplication UseStoreHubModules(this WebApplication app) + { + // 宿主没调用过 AddStoreHub() 时容器里没有注册表,直接返回即可 + ModuleRegistry? registry = app.Services.GetService(); + if (registry is null) + { + return app; + } + + foreach (IModule module in registry.Modules) + { + try + { + module.UseModule(app); + } + catch (Exception ex) + { + throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 UseModule 执行失败。", ex); + } + } + + return app; + } + } +} diff --git a/Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs b/Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs new file mode 100644 index 0000000..25aef10 --- /dev/null +++ b/Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs @@ -0,0 +1,20 @@ +using MAX.Framework.Interfaces; + +namespace MAX.Framework.DependencyInjection +{ + /// + /// 模块注册表:保存 AddStoreHub() 阶段发现(并按优先级排序)的模块实例。 + /// 它被注册进容器,好让 app.UseStoreHubModules() 在 builder.Build() 之后拿到 + /// 「同一批实例」继续执行管道阶段,而不是重新反射创建一遍。 + /// + internal sealed class ModuleRegistry + { + public ModuleRegistry(IReadOnlyList modules) + { + Modules = modules; + } + + /// 按优先级降序排列的模块实例 + public IReadOnlyList Modules { get; } + } +} diff --git a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs index a4095d5..c8be9ad 100644 --- a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs +++ b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,10 +1,12 @@ -using MAX.Core.DependencyInjection; +using MAX.Core.DependencyInjection; using MAX.Core.Interfaces; using MAX.Framework.Commons; using MAX.Framework.Dtos; using MAX.Framework.Interfaces; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using System.Diagnostics; using System.Reflection; @@ -23,18 +25,49 @@ namespace MAX.Framework.DependencyInjection ]; /// - /// 注册应用服务: - /// - /// 自动注册所有实现 / / 的类型; - /// 发现所有 ,按 降序执行 Initialize + Register。 - /// + /// 兼容入口:只有服务集合,没有配置与环境(模块拿到的 是空配置)。 + /// 新代码请用 builder.AddStoreHub()。 /// public static IServiceCollection AddStoreHub(this IServiceCollection services) + { + return services.AddStoreHub(null, null); + } + + /// + /// 带配置的注册入口:builder.Services.AddStoreHub(builder.Configuration) + /// + public static IServiceCollection AddStoreHub(this IServiceCollection services, IConfiguration? configuration) + { + return services.AddStoreHub(configuration, null); + } + + /// + /// 注册应用服务: + /// + /// 自动注册所有实现 / / 的类型; + /// 发现所有 ,按 降序执行 Initialize + Register; + /// 发现所有 ,按 降序执行 , + /// 并把模块实例登记进容器,留给 app.UseStoreHubModules() 执行管道阶段。 + /// + /// + public static IServiceCollection AddStoreHub(this IServiceCollection services, IConfiguration? configuration, IHostEnvironment? environment) { IEnumerable assemblies = GetApplicationAssemblies(); + + // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象 + Dictionary instances = []; + RegisterDependencyMarkers(services, assemblies); - RunStartups(services, assemblies); - RunModules(services, assemblies); + RunStartups(services, assemblies, instances); + + ModuleContext context = new( + services, + new ServiceRegistrarProxy(services), + configuration ?? new ConfigurationBuilder().Build(), + environment); + + // 注册表必须在 Build() 之前进容器,UseStoreHubModules() 才拿得到 + services.AddSingleton(RunModules(assemblies, instances, context)); services.AddControllers(options => @@ -102,28 +135,55 @@ namespace MAX.Framework.DependencyInjection /// /// 发现所有 实现,按 降序执行 /// - private static void RunStartups(IServiceCollection services, IEnumerable assemblies) + private static void RunStartups(IServiceCollection services, IEnumerable assemblies, IDictionary instances) { IServiceRegistrar registrar = new ServiceRegistrarProxy(services); - foreach (IStartup startup in GetConcreteTypes(assemblies) - .Where(t => typeof(IStartup).IsAssignableFrom(t)) - .Select(t => (IStartup)Activator.CreateInstance(t, true)!) - .OrderByDescending(s => s.Priority)) + foreach (IStartup startup in CreateInstances(assemblies, instances).OrderByDescending(s => s.Priority)) { startup.Initialize(); startup.Register(registrar); } } - private static void RunModules(IServiceCollection services, IEnumerable assemblies) - { - IServiceRegistrar registrar = new ServiceRegistrarProxy(services); - foreach (IModule startup in GetConcreteTypes(assemblies) - .Where(t => typeof(IModule).IsAssignableFrom(t)) - .Select(t => (IModule)Activator.CreateInstance(t, true)!)) + /// + /// 发现所有 实现,按 降序执行服务注册阶段 + /// + private static ModuleRegistry RunModules(IEnumerable assemblies, IDictionary instances, ModuleContext context) + { + List modules = [.. CreateInstances(assemblies, instances).OrderByDescending(m => m.Priority)]; + + foreach (IModule module in modules) { - startup.AddModule(services); + try + { + module.AddModule(context); + } + catch (Exception ex) + { + // 带上模块名,避免只看到一个没有上下文的异常 + throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 AddModule 执行失败。", ex); + } + } + + return new ModuleRegistry(modules); + } + + /// + /// 反射创建(或复用)所有实现 的具体类型实例 + /// + private static IEnumerable CreateInstances(IEnumerable assemblies, IDictionary instances) where T : class + { + foreach (Type type in GetConcreteTypes(assemblies).Where(t => typeof(T).IsAssignableFrom(t))) + { + if (!instances.TryGetValue(type, out object? instance)) + { + instance = Activator.CreateInstance(type, true) + ?? throw new InvalidOperationException($"无法创建 {type.FullName} 的实例(需要有参数量为 0 的构造函数)。"); + instances[type] = instance; + } + + yield return (T)instance; } } diff --git a/Core/MAX.Framework/Interfaces/IModule.cs b/Core/MAX.Framework/Interfaces/IModule.cs index 2a14d72..a14214a 100644 --- a/Core/MAX.Framework/Interfaces/IModule.cs +++ b/Core/MAX.Framework/Interfaces/IModule.cs @@ -1,12 +1,37 @@ -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Generic; -using System.Text; +using Microsoft.AspNetCore.Builder; namespace MAX.Framework.Interfaces { + /// + /// 业务模块:把「服务注册」和「管道装配」两件事都交给模块自己, + /// 由宿主的两行代码统一驱动(模块不需要出现在 Program.cs 里): + /// + /// builder.AddStoreHub() → 自动发现所有模块并执行 (服务注册阶段,Build() 之前); + /// app.UseStoreHubModules() → 自动执行 (管道阶段,Build() 之后、Run() 之前)。 + /// + /// 模块实例由反射创建,因此实现类必须有无参构造函数。 + /// public interface IModule { - void AddModule(IServiceCollection services); + /// + /// 模块执行顺序(数值越大越先执行),默认 。 + /// 两个阶段都按这个顺序执行。 + /// + int Priority => MAX.Core.Interfaces.IntPriority.Normal; + + /// + /// 服务注册阶段(builder.Build() 之前):注册本模块的服务、选项、后台任务类型。 + /// 需要读配置时用 ,不要再去碰 builder。 + /// + void AddModule(ModuleContext context); + + /// + /// 管道装配阶段(builder.Build() 之后、app.Run() 之前): + /// 注册中间件(如 Hangfire 看板)、Map 端点、读取 解析服务、注册启动任务等。 + /// 不需要管道装配的模块可以不实现(默认空实现)。 + /// + void UseModule(WebApplication app) + { + } } } diff --git a/Core/MAX.Framework/Interfaces/ModuleBase.cs b/Core/MAX.Framework/Interfaces/ModuleBase.cs new file mode 100644 index 0000000..88a5e4b --- /dev/null +++ b/Core/MAX.Framework/Interfaces/ModuleBase.cs @@ -0,0 +1,23 @@ +using MAX.Core.Interfaces; +using Microsoft.AspNetCore.Builder; + +namespace MAX.Framework.Interfaces +{ + /// + /// 模块基类:给出 与 的默认实现, + /// 模块只需重写自己真正关心的阶段(只有服务注册时只写 )。 + /// + public abstract class ModuleBase : IModule + { + /// + public virtual int Priority => IntPriority.Normal; + + /// + public abstract void AddModule(ModuleContext context); + + /// + public virtual void UseModule(WebApplication app) + { + } + } +} diff --git a/Core/MAX.Framework/Interfaces/ModuleContext.cs b/Core/MAX.Framework/Interfaces/ModuleContext.cs new file mode 100644 index 0000000..da6bfa3 --- /dev/null +++ b/Core/MAX.Framework/Interfaces/ModuleContext.cs @@ -0,0 +1,46 @@ +using MAX.Core.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MAX.Framework.Interfaces +{ + /// + /// 模块上下文:模块在「服务注册阶段」能拿到的全部宿主信息。 + /// 之所以包成一个对象,是为了以后要再补宿主的其他东西(日志、配置节、HostApplicationBuilder 等)时不用改接口签名。 + /// + public sealed class ModuleContext + { + public ModuleContext( + IServiceCollection services, + IServiceRegistrar registrar, + IConfiguration configuration, + IHostEnvironment? environment = null) + { + Services = services; + Registrar = registrar; + Configuration = configuration; + Environment = environment; + } + + /// + /// 服务集合(Microsoft.Extensions.DependencyInjection),等价于宿主里的 builder.Services + /// + public IServiceCollection Services { get; } + + /// + /// 容器无关的注册器,等价于 里拿到的那个,便于以后换容器 + /// + public IServiceRegistrar Registrar { get; } + + /// + /// 应用配置,等价于宿主的 builder.Configuration(永不为 null;走旧的无参入口时是空配置) + /// + public IConfiguration Configuration { get; } + + /// + /// 宿主环境,等价于宿主的 builder.Environment;走旧的无参入口时为 null + /// + public IHostEnvironment? Environment { get; } + } +} diff --git a/Models/CloudProduct/CloudProductModule.cs b/Models/CloudProduct/CloudProductModule.cs new file mode 100644 index 0000000..0963d8d --- /dev/null +++ b/Models/CloudProduct/CloudProductModule.cs @@ -0,0 +1,89 @@ +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 MySql.Data.MySqlClient; +using Store.Modules.CloudProduct.Services; + +namespace Store.Modules.CloudProduct +{ + /// + /// CloudProduct 模块的模块化入口():Hangfire 后台任务(商品分词同步)+ Hangfire 看板。 + /// 与 (只做 IStartup 的 Initialize/Register)分开,是因为模块注册需要配置和 app: + /// + /// builder.AddStoreHub() 执行 (注册 Hangfire 服务,此时只能拿到容器和配置); + /// app.UseStoreHubModules() 执行 (挂看板中间件 + 注册定时任务,此时才有 app)。 + /// + /// + public class CloudProductModule : ModuleBase + { + public override int Priority => IntPriority.Normal; + + /// + /// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app + /// + public override void AddModule(ModuleContext context) + { + IServiceCollection services = context.Services; + IConfiguration configuration = context.Configuration; + + // ===== Hangfire 后台定时任务(商品分词同步) ===== + var hangfireConnection = configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。"); + + // AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量 + var connectionStringBuilder = new MySqlConnectionStringBuilder(hangfireConnection) + { + AllowUserVariables = true + }; + + services.AddHangfire(config => config + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" }))); + services.AddHangfireServer(); + services.AddTransient(); + } + + /// + /// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务 + /// + public override void UseModule(WebApplication app) + { + try + { + // Hangfire 后台任务看板(默认地址 /hangfire) + app.UseHangfireDashboard("/hangfire", new DashboardOptions + { + Authorization = new[] + { + new HangfireCustomBasicAuthenticationFilter(){ + User = app.Configuration.GetValue("Hangfire:Dashboard:User") ?? "admin", + Pass = app.Configuration.GetValue("Hangfire:Dashboard:Pass") ?? "1234560" + } + } + }); + + // 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引 + var cron = app.Configuration.GetValue("Indexing:Cron") ?? "*/5 * * * *"; + + // 用容器里的 IRecurringJobManager,而不是静态 RecurringJob: + // AddHangfire 只把 JobStorage 注册进容器(没有设置 JobStorage.Current),静态 API 在 Build() 之后调用会直接抛异常。 + app.Services.GetRequiredService().AddOrUpdate( + "goods-index-pending", + job => job.ProcessPendingAsync(), + cron); + } + catch (Exception ex) + { + // 数据库不可用/表未初始化时避免阻断应用启动(看板与定时任务都会连库),恢复后重启即可重新装配 + Console.WriteLine("[Hangfire] 看板/定时任务装配失败: " + ex.Message); + } + } + } +} diff --git a/Models/CloudProduct/Services/GoodsIndexJob.cs b/Models/CloudProduct/Services/GoodsIndexJob.cs new file mode 100644 index 0000000..9fbe971 --- /dev/null +++ b/Models/CloudProduct/Services/GoodsIndexJob.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Store.Modules.CloudProduct.Services +{ + /// + /// Hangfire 定时任务:把数据库中未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引。 + /// + public class GoodsIndexJob + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public GoodsIndexJob(IServiceScopeFactory scopeFactory, IConfiguration configuration,ILogger logger) + { + _scopeFactory = scopeFactory; + _configuration = configuration; + _logger = logger; + } + + public async Task ProcessPendingAsync() + { + var batchSize = _configuration.GetValue("Indexing:BatchSize") ?? 1000; + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var count = await service.IndexPendingGoodsAsync(batchSize); + _logger.LogInformation($"[Hangfire] 商品分词定时任务完成,本次同步 {count} 条"); + } + } +} \ No newline at end of file diff --git a/Models/CloudProduct/Startup.cs b/Models/CloudProduct/Startup.cs index 35aa28c..3eb8ccf 100644 --- a/Models/CloudProduct/Startup.cs +++ b/Models/CloudProduct/Startup.cs @@ -1,28 +1,23 @@ -using MAX.Core.DependencyInjection; +using MAX.Core.DependencyInjection; using MAX.Core.Interfaces; -using MAX.Framework.Interfaces; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; namespace Store.Modules.CloudProduct { - public class Startup : IStartup, IModule + /// + /// CloudProduct 模块的启动器():由 AddStoreHub() 自动发现, + /// 按 降序执行 + 。 + /// 需要配置或管道装配(app)的部分不在这里,见 。 + /// + public class Startup : IStartup { public int Priority => IntPriority.Normal; - public void AddModule(IServiceCollection services) - { - services.AddControllers(); - } - public void Initialize() { - } public void Register(IServiceRegistrar registrar) { - } } } diff --git a/Models/CloudProduct/Store.Modules.CloudProduct.csproj b/Models/CloudProduct/Store.Modules.CloudProduct.csproj index 834fc3d..4e98693 100644 --- a/Models/CloudProduct/Store.Modules.CloudProduct.csproj +++ b/Models/CloudProduct/Store.Modules.CloudProduct.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -12,6 +12,9 @@ + + + diff --git a/Store.API/Program.cs b/Store.API/Program.cs index 42f37d1..b716fe9 100644 --- a/Store.API/Program.cs +++ b/Store.API/Program.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using MAX.Framework.DependencyInjection; using Scalar.AspNetCore; @@ -8,8 +8,9 @@ public partial class Program { var builder = WebApplication.CreateBuilder(args); - // IOC 注册:自动注册 ISingletonDependency / IScopedDependency / ITransientDependency,并执行所有 IStartup(实现见 StoreHub.Framework) - builder.Services.AddStoreHub(); + // IOC 注册:自动注册 ISingletonDependency / IScopedDependency / ITransientDependency,并执行所有 IStartup / IModule.AddModule + // 模块服务注册阶段(等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment),额外把配置/环境交给模块) + builder.AddStoreHub(); builder.Services.AddFreeSqlDB(); // JWT 验证:注册 JwtBearer 认证 + 令牌服务 + OpenAPI Bearer 安全方案(配置节 Jwt) builder.Services.AddStoreHubJwt(builder.Configuration); @@ -62,6 +63,9 @@ public partial class Program app.UseAuthorization(); + // 模块管道装配阶段:执行所有 IModule.UseModule(Hangfire 看板、定时任务等),必须在 Run() 之前 + app.UseStoreHubModules(); + app.MapControllers(); app.Run();