diff --git a/Core/MAX.Core/Interfaces/IStartup.cs b/Core/MAX.Core/Interfaces/IStartup.cs index 0767a3f..e7e3882 100644 --- a/Core/MAX.Core/Interfaces/IStartup.cs +++ b/Core/MAX.Core/Interfaces/IStartup.cs @@ -1,4 +1,7 @@ using MAX.Core.DependencyInjection; +using System; +using System.ComponentModel; +using System.Threading.Tasks; namespace MAX.Core.Interfaces; @@ -7,12 +10,10 @@ namespace MAX.Core.Interfaces; /// public interface IStartup : IDependencyRegistrar { - /// - /// 优先级(优先级越高越先执行) - /// - int Priority { get; } /// /// 初始化 /// void Initialize(); + + void Start(IServiceProvider serviceProvider); } diff --git a/Core/MAX.Core/Startup.cs b/Core/MAX.Core/Startup.cs index 2f5cb1d..c510de9 100644 --- a/Core/MAX.Core/Startup.cs +++ b/Core/MAX.Core/Startup.cs @@ -25,6 +25,7 @@ using MAX.Core.DependencyInjection; using MAX.Core.Interfaces; +using System; namespace MAX.Core; @@ -42,4 +43,9 @@ public class Startup : IStartup // 示例:恢复 LazyServiceProvider 后,可在这里注册为单例 // registrar.RegisterSingleton(); } + + public void Start(IServiceProvider serviceProvider) + { + + } } diff --git a/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs b/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs index 70f2faf..69609a5 100644 --- a/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs +++ b/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs @@ -1,125 +1,123 @@ -using MAX.Framework.Interfaces; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.Json; -using Microsoft.Extensions.DependencyInjection; -using System.Reflection; +//using MAX.Framework.Interfaces; +//using Microsoft.AspNetCore.Builder; +//using Microsoft.Extensions.Configuration; +//using Microsoft.Extensions.Configuration.Json; +//using Microsoft.Extensions.DependencyInjection; +//using System.Reflection; -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 - { - /// - /// 在 上注册应用框架服务: - /// - /// 发现所有 ; - /// 把每个模块声明的 并入宿主配置树(宿主 appsettings.json 不需要放模块的配置); - /// 再执行 builder.Services.AddStoreHub(builder.Configuration, builder.Environment)。 - /// - /// - public static WebApplicationBuilder AddStoreHub(this WebApplicationBuilder builder) - { - IEnumerable assemblies = ServiceCollectionExtensions.GetApplicationAssemblies(); +//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 +// { +// /// +// /// 在 上注册应用框架服务: +// /// +// /// 发现所有 ; +// /// 把每个模块声明的 并入宿主配置树(宿主 appsettings.json 不需要放模块的配置); +// /// 再执行 builder.Services.AddStoreHub(builder.Configuration, builder.Environment)。 +// /// +// /// +// public static WebApplicationBuilder AddStore(this WebApplicationBuilder builder) +// { +// IEnumerable assemblies = ServiceCollectionExtensions.GetApplicationAssemblies(); - // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证后面两个阶段拿到的是同一个对象 - Dictionary instances = []; - ModuleRegistry modules = ServiceCollectionExtensions.DiscoverModules(assemblies, instances); +// // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证后面两个阶段拿到的是同一个对象 +// Dictionary instances = []; +// ModuleRegistry modules = ServiceCollectionExtensions.DiscoverModules(assemblies, instances); - // 必须在跑 AddModule 之前并入,模块注册时才读得到自己的配置节 - foreach (IModule module in modules.Modules) - { - AddModuleConfigurationFile(builder.Configuration, module); - } +// // 必须在跑 AddModule 之前并入,模块注册时才读得到自己的配置节 +// foreach (IModule module in modules.Modules) +// { +// AddModuleConfigurationFile(builder.Configuration, module); +// } - builder.Services.AddStoreHubCore(assemblies, modules, instances, builder.Configuration, builder.Environment); - return builder; - } +// builder.Services.AddStoreHubCore(assemblies, modules, instances, 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; - } +// /// +// /// 执行所有模块的管道阶段():注册中间件、看板、Map 端点等。 +// /// +// 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); - } - } +// foreach (IModule module in registry.Modules) +// { +// try +// { +// module.UseModule(app); +// } +// catch (Exception ex) +// { +// throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 UseModule 执行失败。", ex); +// } +// } - return app; - } +// return app; +// } - /// - /// 把模块自己的配置文件并进宿主配置:插在 appsettings*.json 之后、环境变量/命令行之前, - /// 这样模块配置能覆盖宿主 appsettings.json,同时仍然允许用环境变量/命令行覆盖它。 - /// - private static void AddModuleConfigurationFile(IConfigurationBuilder configuration, IModule module) - { - string? fileName = module.ConfigurationFile; - if (string.IsNullOrWhiteSpace(fileName)) - { - return; - } +// /// +// /// 把模块自己的配置文件并进宿主配置:插在 appsettings*.json 之后、环境变量/命令行之前, +// /// 这样模块配置能覆盖宿主 appsettings.json,同时仍然允许用环境变量/命令行覆盖它。 +// /// +// private static void AddModuleConfigurationFile(IConfigurationBuilder configuration, IModule module) +// { +// string? fileName = module.ConfigurationFile; +// if (string.IsNullOrWhiteSpace(fileName)) +// { +// return; +// } - string path = Path.IsPathRooted(fileName) - ? fileName - : Path.Combine(AppContext.BaseDirectory, fileName); +// string path = Path.IsPathRooted(fileName) +// ? fileName +// : Path.Combine(AppContext.BaseDirectory, fileName); - JsonConfigurationSource source = new() - { - Path = path, - Optional = true, // 文件不存在时不报错(模块没配就是没有) - ReloadOnChange = true // 允许改配置不重启(与 appsettings.json 行为一致) - }; - source.ResolveFileProvider(); +// JsonConfigurationSource source = new() +// { +// Path = path, +// Optional = true, // 文件不存在时不报错(模块没配就是没有) +// ReloadOnChange = true // 允许改配置不重启(与 appsettings.json 行为一致) +// }; +// source.ResolveFileProvider(); - IList sources = configuration.Sources; - int index = sources.Count; +// IList sources = configuration.Sources; +// int index = sources.Count; - // 找到最后一个 appsettings*.json,插到它后面 - for (int i = sources.Count - 1; i >= 0; i--) - { - if (sources[i] is FileConfigurationSource file - && Path.GetFileName(file.Path).StartsWith("appsettings", StringComparison.OrdinalIgnoreCase)) - { - index = i + 1; - break; - } - } +// // 找到最后一个 appsettings*.json,插到它后面 +// for (int i = sources.Count - 1; i >= 0; i--) +// { +// if (sources[i] is FileConfigurationSource file +// && Path.GetFileName(file.Path).StartsWith("appsettings", StringComparison.OrdinalIgnoreCase)) +// { +// index = i + 1; +// break; +// } +// } - sources.Insert(index, source); +// sources.Insert(index, source); - if (configuration is IConfigurationRoot root) - { - root.Reload(); - } - } - } -} +// if (configuration is IConfigurationRoot root) +// { +// root.Reload(); +// } +// } +// } +//} diff --git a/Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs b/Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs deleted file mode 100644 index 25aef10..0000000 --- a/Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs +++ /dev/null @@ -1,20 +0,0 @@ -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 cb16315..a5aa210 100644 --- a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs +++ b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs @@ -3,10 +3,13 @@ using MAX.Core.Interfaces; using MAX.Framework.Commons; using MAX.Framework.Dtos; using MAX.Framework.Interfaces; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Win32; +using System.Configuration; using System.Diagnostics; using System.Reflection; @@ -25,70 +28,37 @@ namespace MAX.Framework.DependencyInjection ]; /// - /// 兼容入口:只有服务集合,没有配置与环境(模块拿到的 是空配置), - /// 也拿不到 IConfigurationBuilder,所以模块自己的配置文件不会加载。新代码请用 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() 执行管道阶段。 - /// - /// 这个入口不加载模块自己的配置文件,需要模块配置请用 builder.AddStoreHub()。 - /// - public static IServiceCollection AddStoreHub(this IServiceCollection services, IConfiguration? configuration, IHostEnvironment? environment) + public static IServiceCollection AddStore(this IHostApplicationBuilder host) { IEnumerable assemblies = GetApplicationAssemblies(); - // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象 Dictionary instances = []; - ModuleRegistry modules = DiscoverModules(assemblies, instances); - - return services.AddStoreHubCore(assemblies, modules, instances, configuration, environment); + return AddStoreCore(host, assemblies, instances); } + + + + /// /// 注册应用服务的公共实现:宿主入口(builder.AddStoreHub())与 入口共用。 /// - internal static IServiceCollection AddStoreHubCore( - this IServiceCollection services, + internal static IServiceCollection AddStoreCore( + IHostApplicationBuilder host, IEnumerable assemblies, - ModuleRegistry modules, - IDictionary instances, - IConfiguration? configuration, - IHostEnvironment? environment) + IDictionary instances) { - RegisterDependencyMarkers(services, assemblies); - RunStartups(services, assemblies, instances); + RegisterDependencyMarkers(host.Services, assemblies); - ModuleContext context = new( - services, - new ServiceRegistrarProxy(services), - configuration ?? new ConfigurationBuilder().Build(), - environment); + RunModules(host, assemblies, instances); - RunModules(modules, context); - - // 注册表必须在 Build() 之前进容器,UseStoreHubModules() 才拿得到 - services.AddSingleton(modules); + RunStartups(host.Services, assemblies, instances); - services.AddControllers(options => + + host.Services.AddControllers(options => { options.Filters.Add(); options.Filters.Add(); @@ -113,7 +83,7 @@ namespace MAX.Framework.DependencyInjection }; }); - return services; + return host.Services; } /// @@ -149,39 +119,33 @@ namespace MAX.Framework.DependencyInjection } } } - + private static IEnumerable Startups = []; /// /// 发现所有 实现,按 降序执行 /// private static void RunStartups(IServiceCollection services, IEnumerable assemblies, IDictionary instances) { IServiceRegistrar registrar = new ServiceRegistrarProxy(services); - - foreach (IStartup startup in CreateInstances(assemblies, instances).OrderByDescending(s => s.Priority)) + Startups = CreateInstances(assemblies, instances).OrderByDescending(s => s.Priority); + foreach (IStartup startup in Startups) { startup.Initialize(); startup.Register(registrar); } } - /// - /// 发现所有 实现,只创建实例,不执行任何阶段 - /// - internal static ModuleRegistry DiscoverModules(IEnumerable assemblies, IDictionary instances) - { - return new ModuleRegistry([.. CreateInstances(assemblies, instances).OrderByDescending(m => m.Priority)]); - } - + private static IEnumerable Modules = []; /// /// 执行所有模块的服务注册阶段() /// - private static void RunModules(ModuleRegistry modules, ModuleContext context) + private static void RunModules(IHostApplicationBuilder host, IEnumerable assemblies, IDictionary instances) { - foreach (IModule module in modules.Modules) + Modules = CreateInstances(assemblies, instances).OrderByDescending(s => s.Priority); + foreach (IModule module in Modules) { try { - module.AddModule(context); + module.AddModule(host); } catch (Exception ex) { @@ -234,7 +198,7 @@ namespace MAX.Framework.DependencyInjection internal static IEnumerable GetApplicationAssemblies() { // 使用 AppTypeFinder:扫描应用输出目录 + 已加载程序集, - return StoreHubAppTypeFinder.Current.GetAssemblies(); + return StoreAppTypeFinder.Current.GetAssemblies(); } private static bool IsSystemInterface(Type type) @@ -244,5 +208,20 @@ namespace MAX.Framework.DependencyInjection || type.Namespace.StartsWith("Microsoft", StringComparison.Ordinal); } + + public static IApplicationBuilder UseStore(this IApplicationBuilder app) + { + foreach (IStartup startup in Startups) + { + startup.Start((app as IHost).Services); + } + + foreach (IModule module in Modules) + { + module.UseModule(app); + } + return app; + } + } } diff --git a/Core/MAX.Framework/DependencyInjection/StoreHubAppTypeFinder.cs b/Core/MAX.Framework/DependencyInjection/StoreHubAppTypeFinder.cs index f4f5085..a552937 100644 --- a/Core/MAX.Framework/DependencyInjection/StoreHubAppTypeFinder.cs +++ b/Core/MAX.Framework/DependencyInjection/StoreHubAppTypeFinder.cs @@ -6,9 +6,9 @@ namespace MAX.Framework.DependencyInjection /// 本应用的类型查找器:在 AppTypeFinder 默认前缀(MAX. / Nexus.)之外, /// 增加 StoreHub、KJD 前缀,用于发现宿主和各业务模块的程序集。 /// - internal sealed class StoreHubAppTypeFinder : AppTypeFinder + internal sealed class StoreAppTypeFinder : AppTypeFinder { - public static StoreHubAppTypeFinder Current { get; } = new(); + public static StoreAppTypeFinder Current { get; } = new(); protected override List AvailablePrefix() { diff --git a/Core/MAX.Framework/Interfaces/IModule.cs b/Core/MAX.Framework/Interfaces/IModule.cs index 0d02414..8f59880 100644 --- a/Core/MAX.Framework/Interfaces/IModule.cs +++ b/Core/MAX.Framework/Interfaces/IModule.cs @@ -1,47 +1,22 @@ +using MAX.Core.Interfaces; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; namespace MAX.Framework.Interfaces { /// - /// 业务模块:把「服务注册」和「管道装配」两件事都交给模块自己, - /// 由宿主的两行代码统一驱动(模块不需要出现在 Program.cs 里): - /// - /// builder.AddStoreHub() → 自动发现所有模块并执行 (服务注册阶段,Build() 之前); - /// app.UseStoreHubModules() → 自动执行 (管道阶段,Build() 之后、Run() 之前)。 - /// /// 模块实例由反射创建,因此实现类必须有无参构造函数。 /// - public interface IModule + public interface IModule : IPriority { /// - /// 模块执行顺序(数值越大越先执行),默认 。 - /// 两个阶段都按这个顺序执行。 + /// 添加Module /// - int Priority => MAX.Core.Interfaces.IntPriority.Normal; + void AddModule(IHostApplicationBuilder host); /// - /// 模块自己的配置文件(相对宿主输出目录 ), - /// 例如 "appsettings.CloudProduct.json";默认 null 表示该模块没有独立配置文件。 - /// 该文件由 builder.AddStoreHub() 自动并入宿主配置树(插在 appsettings*.json 之后、环境变量/命令行之前), - /// 因此模块内的服务照常注入 IConfiguration 就能读到自己的配置节,模块之间互不干扰。 - /// 注意:只有 builder.AddStoreHub() 入口能加载模块配置文件, - /// 直接用 builder.Services.AddStoreHub() 时拿不到 IConfigurationBuilder,模块配置不会生效。 + /// 管道装配阶段 /// - string? ConfigurationFile => null; - - /// - /// 服务注册阶段(builder.Build() 之前):注册本模块的服务、选项、后台任务类型。 - /// 需要读配置时用 ,不要再去碰 builder。 - /// - void AddModule(ModuleContext context); - - /// - /// 管道装配阶段(builder.Build() 之后、app.Run() 之前): - /// 注册中间件(如 Hangfire 看板)、Map 端点、读取 解析服务、注册启动任务等。 - /// 不需要管道装配的模块可以不实现(默认空实现)。 - /// - void UseModule(WebApplication app) - { - } + void UseModule(IApplicationBuilder app); } } diff --git a/Core/MAX.Framework/Interfaces/ModuleBase.cs b/Core/MAX.Framework/Interfaces/ModuleBase.cs deleted file mode 100644 index af0a9e1..0000000 --- a/Core/MAX.Framework/Interfaces/ModuleBase.cs +++ /dev/null @@ -1,26 +0,0 @@ -using MAX.Core.Interfaces; -using Microsoft.AspNetCore.Builder; - -namespace MAX.Framework.Interfaces -{ - /// - /// 模块基类:给出 与 的默认实现, - /// 模块只需重写自己真正关心的阶段(只有服务注册时只写 )。 - /// - public abstract class ModuleBase : IModule - { - /// - public virtual int Priority => IntPriority.Normal; - - /// - public virtual string? ConfigurationFile => null; - - /// - 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 deleted file mode 100644 index da6bfa3..0000000 --- a/Core/MAX.Framework/Interfaces/ModuleContext.cs +++ /dev/null @@ -1,46 +0,0 @@ -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/Core/MAX.Framework/Startup.cs b/Core/MAX.Framework/Startup.cs index 86f2677..6431681 100644 --- a/Core/MAX.Framework/Startup.cs +++ b/Core/MAX.Framework/Startup.cs @@ -19,6 +19,11 @@ namespace MAX.Framework { } + + public void Start(IServiceProvider serviceProvider) + { + + } } } diff --git a/Models/CloudProduct/CloudProductModule.cs b/Models/CloudProduct/CloudProductModule.cs index e0d7260..f71a3c3 100644 --- a/Models/CloudProduct/CloudProductModule.cs +++ b/Models/CloudProduct/CloudProductModule.cs @@ -6,8 +6,10 @@ 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; +using Microsoft.Extensions.Configuration; namespace Store.Modules.CloudProduct { @@ -19,7 +21,7 @@ namespace Store.Modules.CloudProduct /// app.UseStoreHubModules() 执行 (挂看板中间件 + 注册定时任务,此时才有 app)。 /// /// - public class CloudProductModule : ModuleBase + public class CloudProductModule : IModule { /// /// 本模块自己的配置文件(源码里是 Models/CloudProduct/appsettings.json, @@ -27,23 +29,31 @@ namespace Store.Modules.CloudProduct /// public const string ConfigurationFileName = "appsettings.CloudProduct.json"; - public override int Priority => IntPriority.Normal; + public int Priority => IntPriority.Normal; - public override string? ConfigurationFile => ConfigurationFileName; /// /// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app /// - public override void AddModule(ModuleContext context) + public void AddModule(IHostApplicationBuilder host) { - IServiceCollection services = context.Services; - IConfiguration configuration = context.Configuration; + IServiceCollection services = host.Services; + IConfigurationManager configuration = host.Configuration; - // 本模块的配置节(Search / RawData / Indexing / Delete)都在模块自己的配置文件里, - // 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障 + if (!string.IsNullOrWhiteSpace(ConfigurationFileName)) + { + string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName); + configuration.AddJsonFile(path, optional: false, reloadOnChange: true); + } + + + + + //// 本模块的配置节(Search / RawData / Indexing / Delete)都在模块自己的配置文件里, + //// 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障 RequireConfiguration(configuration, "Search:Url"); - RequireConfiguration(configuration, "Search:IndexName"); - RequireConfiguration(configuration, "RawData:Path"); + //RequireConfiguration(configuration, "Search:IndexName"); + //RequireConfiguration(configuration, "RawData:Path"); // ===== Hangfire 后台定时任务(商品分词同步) ===== var hangfireConnection = configuration.GetConnectionString("DefaultConnection") @@ -62,33 +72,35 @@ namespace Store.Modules.CloudProduct .UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" }))); services.AddHangfireServer(); services.AddTransient(); + } /// /// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务 /// - public override void UseModule(WebApplication app) + public void UseModule(IApplicationBuilder app) { try { + var Configuration = app.ApplicationServices.GetService()!; // 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" + User = Configuration.GetValue("Hangfire:Dashboard:User") ?? "admin", + Pass = Configuration.GetValue("Hangfire:Dashboard:Pass") ?? "1234560" } } }); // 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引 - var cron = app.Configuration.GetValue("Indexing:Cron") ?? "*/5 * * * *"; + var cron = Configuration.GetValue("Indexing:Cron") ?? "*/5 * * * *"; // 用容器里的 IRecurringJobManager,而不是静态 RecurringJob: // AddHangfire 只把 JobStorage 注册进容器(没有设置 JobStorage.Current),静态 API 在 Build() 之后调用会直接抛异常。 - app.Services.GetRequiredService().AddOrUpdate( + app.ApplicationServices.GetRequiredService().AddOrUpdate( "goods-index-pending", job => job.ProcessPendingAsync(), cron); diff --git a/Models/CloudProduct/Controllers/GoodsController.cs b/Models/CloudProduct/Controllers/GoodsController.cs index 1e88bb6..456babe 100644 --- a/Models/CloudProduct/Controllers/GoodsController.cs +++ b/Models/CloudProduct/Controllers/GoodsController.cs @@ -246,6 +246,65 @@ namespace Store.Modules.CloudProduct.Controllers return await _searchService.GetStatusAsync(); } + /// + /// 检查搜索索引是否存在。 + /// + [HttpGet] + public async Task IndexExists() + { + return await _searchService.IndexExistsAsync(); + } + + /// + /// 检查索引映射(分析器)是否符合当前服务要求。 + /// + [HttpGet] + public async Task IndexMappingHealthy() + { + return await _searchService.IsIndexMappingHealthyAsync(); + } + + /// + /// 创建搜索索引(含 IK 分词映射,已存在且映射正常时跳过)。 + /// + [HttpPost] + public async Task CreateIndex() + { + return await _searchService.CreateIndexAsync(); + } + + /// + /// 删除搜索索引。 + /// + [HttpPost] + public async Task DeleteIndex() + { + await _searchService.DeleteIndexAsync(); + } + + /// + /// 手动刷新搜索索引,使最新写入立即可搜索。 + /// + [HttpPost] + public async Task RefreshIndex() + { + await _searchService.RefreshIndexAsync(); + } + + /// + /// 手动新增或覆盖一条搜索文档。 + /// + [HttpPost] + public async Task AddSearchDocument([FromBody] GoodsSearchDocument document) + { + if (document == null || document.Id <= 0) + { + throw new AppException("请提供有效的搜索文档", ResultCode.ValidationError); + } + + await _searchService.AddDocumentAsync(document); + } + /// /// 读取指定商品详情:解压缩原始数据并返回 JSON。 /// diff --git a/Models/CloudProduct/Dtos/OpenSearchStatusDto.cs b/Models/CloudProduct/Dtos/OpenSearchStatusDto.cs index ea61c2c..b714bf8 100644 --- a/Models/CloudProduct/Dtos/OpenSearchStatusDto.cs +++ b/Models/CloudProduct/Dtos/OpenSearchStatusDto.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Store.Modules.CloudProduct.Dtos +namespace Store.Modules.CloudProduct.Dtos { /// /// OpenSearch 运行状态 @@ -24,18 +20,88 @@ namespace Store.Modules.CloudProduct.Dtos /// public bool Connected { get; set; } + /// + /// OpenSearch 服务及目标索引是否健康 + /// + public bool Healthy { get; set; } + + /// + /// 集群名称 + /// + public string? ClusterName { get; set; } + + /// + /// 集群状态(green、yellow 或 red) + /// + public string? ClusterStatus { get; set; } + + /// + /// 集群节点总数 + /// + public int NumberOfNodes { get; set; } + + /// + /// 数据节点数量 + /// + public int NumberOfDataNodes { get; set; } + + /// + /// 活跃主分片数量 + /// + public int ActivePrimaryShards { get; set; } + + /// + /// 活跃分片总数 + /// + public int ActiveShards { get; set; } + + /// + /// 正在迁移的分片数量 + /// + public int RelocatingShards { get; set; } + + /// + /// 正在初始化的分片数量 + /// + public int InitializingShards { get; set; } + + /// + /// 未分配的分片数量 + /// + public int UnassignedShards { get; set; } + /// /// 索引是否存在 /// public bool IndexExists { get; set; } /// - /// 文档总数(索引不存在时为 -1) + /// 索引映射是否符合当前服务要求 + /// + public bool MappingHealthy { get; set; } + + /// + /// 标题字段的索引分析器 + /// + public string? IndexAnalyzer { get; set; } + + /// + /// 标题字段的搜索分析器 + /// + public string? SearchAnalyzer { get; set; } + + /// + /// 文档总数(索引不存在或读取失败时为 -1) /// public long DocumentCount { get; set; } = -1; /// - /// 说明信息 + /// 状态检测耗时(毫秒) + /// + public long ElapsedMilliseconds { get; set; } + + /// + /// 状态说明或错误信息 /// public string? Message { get; set; } } diff --git a/Models/CloudProduct/Services/OpenSearchService.cs b/Models/CloudProduct/Services/OpenSearchService.cs index 9c8c7f9..ead187c 100644 --- a/Models/CloudProduct/Services/OpenSearchService.cs +++ b/Models/CloudProduct/Services/OpenSearchService.cs @@ -1,6 +1,8 @@ +using System.Diagnostics; using MAX.Core.DependencyInjection; using MAX.Framework.Commons; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using OpenSearch.Client; using OpenSearch.Net; using Store.Modules.CloudProduct.Dtos; @@ -19,6 +21,9 @@ namespace Store.Modules.CloudProduct.Services Task DeleteDocumentsAsync(IEnumerable ids); Task DeleteIndexAsync(); Task GetStatusAsync(); + Task IndexExistsAsync(); + Task IsIndexMappingHealthyAsync(); + Task RefreshIndexAsync(); Task RebuildIndexAsync(IEnumerable goodsList); Task<(long Total, List Documents)> SearchByTitleAsync(GoodsSearchRequest request, GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance); } @@ -33,13 +38,15 @@ namespace Store.Modules.CloudProduct.Services private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(5); // 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长,可挪到配置文件) private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30); + private readonly ILogger _logger; - public OpenSearchService(IConfiguration configuration) + public OpenSearchService(IConfiguration configuration,ILogger logger) { var config = configuration.GetSection("Search"); _nodeUrl = config.GetValue("Url") ?? ""; _indexName = config.GetValue("IndexName") ?? ""; _client = CreateClient(); + _logger = logger; } private IOpenSearchClient CreateClient() @@ -100,34 +107,38 @@ namespace Store.Modules.CloudProduct.Services } } - /// - /// 校验索引映射是否健康:title 字段必须使用 IK 分析器 - /// - private async Task IsIndexMappingHealthyAsync() + public async Task IsIndexMappingHealthyAsync() { - try - { - var getResponse = await _client.Indices.GetAsync(_indexName, d => d - .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); - if (!getResponse.IsValid || !getResponse.Indices.TryGetValue(_indexName, out var indexState)) - { - return false; - } + var mappingStatus = await GetIndexMappingStatusAsync(); + return mappingStatus.Healthy; + } - var mapping = indexState.Mappings; - if (mapping?.Properties == null - || !mapping.Properties.TryGetValue(new PropertyName("title"), out var property) - || property is not ITextProperty textProperty) - { - return false; - } - - return textProperty.Analyzer == "ik_max_word" && textProperty.SearchAnalyzer == "ik_smart"; - } - catch + private async Task<(bool Healthy, string? IndexAnalyzer, string? SearchAnalyzer)> GetIndexMappingStatusAsync() + { + var getResponse = await _client.Indices.GetAsync(_indexName, d => d + .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); + if (!getResponse.IsValid) { - return false; + throw new AppException("读取索引映射失败: " + getResponse.DebugInformation); } + if (!getResponse.Indices.TryGetValue(_indexName, out var indexState)) + { + return (false, null, null); + } + + var mapping = indexState.Mappings; + if (mapping?.Properties == null + || !mapping.Properties.TryGetValue(new PropertyName("title"), out var property) + || property is not ITextProperty textProperty) + { + return (false, null, null); + } + + var indexAnalyzer = textProperty.Analyzer; + var searchAnalyzer = textProperty.SearchAnalyzer; + var usesIk = indexAnalyzer == "ik_max_word" && searchAnalyzer == "ik_smart"; + var usesStandard = indexAnalyzer == "standard" && searchAnalyzer == "standard"; + return (usesIk || usesStandard, indexAnalyzer, searchAnalyzer); } private async Task TryCreateIndexAsync(bool useIk) @@ -173,6 +184,23 @@ namespace Store.Modules.CloudProduct.Services } } + public async Task IndexExistsAsync() + { + var response = await _client.Indices.ExistsAsync(_indexName, d => d + .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); + return response.Exists; + } + + public async Task RefreshIndexAsync() + { + var response = await _client.Indices.RefreshAsync(_indexName, d => d + .RequestConfiguration(r => r.RequestTimeout(AdminTimeout))); + if (!response.IsValid) + { + throw new AppException("刷新索引失败: " + response.DebugInformation); + } + } + /// /// ============ 3.1 批量写入商品搜索文档(存在则覆盖) ============ /// @@ -198,8 +226,7 @@ namespace Store.Modules.CloudProduct.Services } // 刷新索引,保证数据立即可搜索(仅用于演示,生产环境不建议频繁 Refresh) - await _client.Indices.RefreshAsync(_indexName, d => d - .RequestConfiguration(r => r.RequestTimeout(AdminTimeout))); + await RefreshIndexAsync(); } /// @@ -260,8 +287,7 @@ namespace Store.Modules.CloudProduct.Services throw new AppException("删除 OpenSearch 文档失败: " + response.DebugInformation); } - await _client.Indices.RefreshAsync(_indexName, d => d - .RequestConfiguration(r => r.RequestTimeout(AdminTimeout))); + await RefreshIndexAsync(); } /// @@ -279,8 +305,7 @@ namespace Store.Modules.CloudProduct.Services if (response.IsValid) { - await _client.Indices.RefreshAsync(_indexName, d => d - .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); + await RefreshIndexAsync(); } else { @@ -414,6 +439,7 @@ namespace Store.Modules.CloudProduct.Services /// public async Task GetStatusAsync() { + var stopwatch = Stopwatch.StartNew(); var status = new OpenSearchStatusDto { NodeUrl = _nodeUrl, @@ -428,28 +454,65 @@ namespace Store.Modules.CloudProduct.Services if (!ping.IsValid) { status.Message = ping.DebugInformation; + _logger.LogError(ping.DebugInformation); + return status; + } + _logger.LogInformation("OpenSerach 已连接"); + var clusterHealth = await _client.Cluster.HealthAsync(Indices.All, h => h + .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); + if (!clusterHealth.IsValid) + { + status.Message = clusterHealth.DebugInformation; return status; } - var exists = await _client.Indices.ExistsAsync(_indexName, d => d - .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); - status.IndexExists = exists.Exists; + status.ClusterName = clusterHealth.ClusterName; + status.ClusterStatus = clusterHealth.Status.ToString().ToLowerInvariant(); + status.NumberOfNodes = clusterHealth.NumberOfNodes; + status.NumberOfDataNodes = clusterHealth.NumberOfDataNodes; + status.ActivePrimaryShards = clusterHealth.ActivePrimaryShards; + status.ActiveShards = clusterHealth.ActiveShards; + status.RelocatingShards = clusterHealth.RelocatingShards; + status.InitializingShards = clusterHealth.InitializingShards; + status.UnassignedShards = clusterHealth.UnassignedShards; + status.IndexExists = await IndexExistsAsync(); - if (exists.Exists) + if (status.IndexExists) { + var mappingStatus = await GetIndexMappingStatusAsync(); + status.MappingHealthy = mappingStatus.Healthy; + status.IndexAnalyzer = mappingStatus.IndexAnalyzer; + status.SearchAnalyzer = mappingStatus.SearchAnalyzer; + var count = await _client.CountAsync(c => c .Index(_indexName) .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); status.DocumentCount = count.IsValid ? count.Count : -1; if (!count.IsValid) + { status.Message = count.DebugInformation; + } } + else + { + status.Message = $"索引 [{_indexName}] 不存在"; + } + + status.Healthy = status.IndexExists + && status.MappingHealthy + && status.ClusterStatus != "red" + && status.DocumentCount >= 0; } catch (Exception ex) { - status.Connected = false; + status.Healthy = false; status.Message = ex.Message; } + finally + { + stopwatch.Stop(); + status.ElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + } return status; } diff --git a/Models/CloudProduct/Startup.cs b/Models/CloudProduct/Startup.cs index 3eb8ccf..383d4dd 100644 --- a/Models/CloudProduct/Startup.cs +++ b/Models/CloudProduct/Startup.cs @@ -1,13 +1,14 @@ 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 { - /// - /// CloudProduct 模块的启动器():由 AddStoreHub() 自动发现, - /// 按 降序执行 + 。 - /// 需要配置或管道装配(app)的部分不在这里,见 。 - /// + public class Startup : IStartup { public int Priority => IntPriority.Normal; @@ -19,5 +20,13 @@ namespace Store.Modules.CloudProduct public void Register(IServiceRegistrar registrar) { } + + public async void Start(IServiceProvider serviceProvider) + { + var openSearchService = serviceProvider.GetRequiredService(); + await openSearchService.GetStatusAsync(); + } + + } } diff --git a/Store.API/Program.cs b/Store.API/Program.cs index b716fe9..30b61a1 100644 --- a/Store.API/Program.cs +++ b/Store.API/Program.cs @@ -10,17 +10,17 @@ public partial class Program // IOC 注册:自动注册 ISingletonDependency / IScopedDependency / ITransientDependency,并执行所有 IStartup / IModule.AddModule // 模块服务注册阶段(等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment),额外把配置/环境交给模块) - builder.AddStoreHub(); + builder.AddStore(); builder.Services.AddFreeSqlDB(); // JWT 验证:注册 JwtBearer 认证 + 令牌服务 + OpenAPI Bearer 安全方案(配置节 Jwt) builder.Services.AddStoreHubJwt(builder.Configuration); // Add services to the container. - builder.Services.Configure(options => - { - // 兼容迁移的同步控制器:允许同步读写请求/响应 IO(旧 asmx 行为) - options.AllowSynchronousIO = true; - }); + //builder.Services.Configure(options => + //{ + // // 兼容迁移的同步控制器:允许同步读写请求/响应 IO(旧 asmx 行为) + // options.AllowSynchronousIO = true; + //}); builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddApiVersioning(options => @@ -34,13 +34,9 @@ public partial class Program options.SubstituteApiVersionInUrl = true; }).AddOpenApi(); - #region DB - - - #endregion - - var app = builder.Build(); + WebApplication app = builder.Build(); + app.UseStore(); // 初始化 KJD 迁移的静态提供者(MysqlUtils 连接提供 / 业务配置读取) //StoreHub.KJDWebService.Commons.AdoProvider.SetServiceProvider(app.Services); //StoreHub.KJDWebService.Commons.KJDConfig.SetConfiguration(app.Configuration); @@ -63,9 +59,6 @@ public partial class Program app.UseAuthorization(); - // 模块管道装配阶段:执行所有 IModule.UseModule(Hangfire 看板、定时任务等),必须在 Run() 之前 - app.UseStoreHubModules(); - app.MapControllers(); app.Run(); diff --git a/Store.Framework/Startup.cs b/Store.Framework/Startup.cs index 489c502..34e101f 100644 --- a/Store.Framework/Startup.cs +++ b/Store.Framework/Startup.cs @@ -49,6 +49,11 @@ namespace Store.Framework return KJDFsqlFactory(s); }); } + + public void Start(IServiceProvider serviceProvider) + { + + } } }