From 5ff52dea92be2aabb8f652942c32ac7019ec3c04 Mon Sep 17 00:00:00 2001 From: chfei Date: Thu, 22 Oct 2026 15:12:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=E4=B8=80=E4=BA=9B=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E5=88=B0=E6=8F=92=E4=BB=B6=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ServiceCollectionExtensions.cs | 10 +- .../StoreHubAppTypeFinder.cs | 142 ++++++++++++++++-- Directory.Build.props | 2 + Directory.Build.targets | 53 +++++++ .../CloudProduct/CloudProductModule.cs | 2 +- .../Controllers/GoodsController.cs | 0 .../Controllers/UserFavoritesController.cs | 0 .../Dtos/AddUserFavoriteRequest.cs | 0 .../Dtos/CreateUserFavoriteGroupRequest.cs | 0 .../CloudProduct/Dtos/GoodsImportItem.cs | 0 .../CloudProduct/Dtos/GoodsImportResult.cs | 0 .../CloudProduct/Dtos/GoodsSearchDocument.cs | 0 .../CloudProduct/Dtos/GoodsSearchRequest.cs | 0 .../CloudProduct/Dtos/OpenSearchStatusDto.cs | 0 .../Dtos/RemoveUserFavoritesRequest.cs | 0 .../CloudProduct/Dtos/UserFavoriteGroupDto.cs | 0 .../CloudProduct/Dtos/UserFavoriteItem.cs | 0 .../Dtos/UserFavoriteStatusRequest.cs | 0 .../Dtos/UserFavoritesSearchDocument.cs | 0 .../Dtos/UserFavoritesSearchRequest.cs | 0 .../CloudProduct/Entities/GoodsEntity.cs | 0 .../Entities/UserGoodsFavoriteEntity.cs | 0 .../Entities/UserGoodsFavoriteGroupEntity.cs | 0 .../CloudProduct/Enums/GoodsSearchSortBy.cs | 0 .../CloudProduct/Enums/PlatformType.cs | 0 .../CloudProduct/Enums/UserFavoritesSortBy.cs | 0 .../ImportConverters/GoodsConverter.cs | 0 .../ImportConverters/GoodsConverterFactory.cs | 0 .../ImportConverters/PddGoodsConverter.cs | 0 .../Repositorys/GoodsRepository.cs | 0 .../UserGoodsFavoriteGroupRepository.cs | 0 .../UserGoodsFavoriteRepository.cs | 0 .../CloudProduct/Services/GoodsIndexJob.cs | 0 .../CloudProduct/Services/GoodsService.cs | 0 .../Services/OpenSearchService.cs | 0 .../CloudProduct/Services/RawDataStorage.cs | 0 .../Services/UserFavoritesIndexService.cs | 0 .../Services/UserFavoritesService.cs | 0 {Modules => Plugins}/CloudProduct/Startup.cs | 0 .../CloudProduct/Store.CloudProduct.csproj | 2 + .../appsettings.CloudProduct.json | 0 Plugins/Directory.Build.props | 8 + Plugins/Directory.Build.targets | 30 ++++ Plugins/README.md | 86 +++++++++++ .../Controllers/ImageCacheController.cs | 0 .../Store.ImageCache/Dtos/ImageCacheDto.cs | 0 .../Entities/ImageCacheEntity.cs | 0 .../Mapper/ImageCacheMapper.cs | 0 .../Store.ImageCache/Module.cs | 2 +- .../Services/ImageCacheService.cs | 0 .../Store.ImageCache/Startup.cs | 0 .../Store.ImageCache/Store.ImageCache.csproj | 4 +- .../appsettings.ImageCache.json | 0 Store.API/Program.cs | 2 +- Store.API/Store.Api.csproj | 7 +- Store.API/appsettings.json | 5 +- Store.API/dotnet-tools.json | 13 ++ Store.Api.slnx | 13 +- .../PluginLoadingSmoke.csproj | 4 + Tests/PluginLoadingSmoke/Program.cs | 52 +++++++ 60 files changed, 410 insertions(+), 27 deletions(-) create mode 100644 Directory.Build.targets rename {Modules => Plugins}/CloudProduct/CloudProductModule.cs (97%) rename {Modules => Plugins}/CloudProduct/Controllers/GoodsController.cs (100%) rename {Modules => Plugins}/CloudProduct/Controllers/UserFavoritesController.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/AddUserFavoriteRequest.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/CreateUserFavoriteGroupRequest.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/GoodsImportItem.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/GoodsImportResult.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/GoodsSearchDocument.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/GoodsSearchRequest.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/OpenSearchStatusDto.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/RemoveUserFavoritesRequest.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/UserFavoriteGroupDto.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/UserFavoriteItem.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/UserFavoriteStatusRequest.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/UserFavoritesSearchDocument.cs (100%) rename {Modules => Plugins}/CloudProduct/Dtos/UserFavoritesSearchRequest.cs (100%) rename {Modules => Plugins}/CloudProduct/Entities/GoodsEntity.cs (100%) rename {Modules => Plugins}/CloudProduct/Entities/UserGoodsFavoriteEntity.cs (100%) rename {Modules => Plugins}/CloudProduct/Entities/UserGoodsFavoriteGroupEntity.cs (100%) rename {Modules => Plugins}/CloudProduct/Enums/GoodsSearchSortBy.cs (100%) rename {Modules => Plugins}/CloudProduct/Enums/PlatformType.cs (100%) rename {Modules => Plugins}/CloudProduct/Enums/UserFavoritesSortBy.cs (100%) rename {Modules => Plugins}/CloudProduct/ImportConverters/GoodsConverter.cs (100%) rename {Modules => Plugins}/CloudProduct/ImportConverters/GoodsConverterFactory.cs (100%) rename {Modules => Plugins}/CloudProduct/ImportConverters/PddGoodsConverter.cs (100%) rename {Modules => Plugins}/CloudProduct/Repositorys/GoodsRepository.cs (100%) rename {Modules => Plugins}/CloudProduct/Repositorys/UserGoodsFavoriteGroupRepository.cs (100%) rename {Modules => Plugins}/CloudProduct/Repositorys/UserGoodsFavoriteRepository.cs (100%) rename {Modules => Plugins}/CloudProduct/Services/GoodsIndexJob.cs (100%) rename {Modules => Plugins}/CloudProduct/Services/GoodsService.cs (100%) rename {Modules => Plugins}/CloudProduct/Services/OpenSearchService.cs (100%) rename {Modules => Plugins}/CloudProduct/Services/RawDataStorage.cs (100%) rename {Modules => Plugins}/CloudProduct/Services/UserFavoritesIndexService.cs (100%) rename {Modules => Plugins}/CloudProduct/Services/UserFavoritesService.cs (100%) rename {Modules => Plugins}/CloudProduct/Startup.cs (100%) rename {Modules => Plugins}/CloudProduct/Store.CloudProduct.csproj (91%) rename {Modules => Plugins}/CloudProduct/appsettings.CloudProduct.json (100%) create mode 100644 Plugins/Directory.Build.props create mode 100644 Plugins/Directory.Build.targets create mode 100644 Plugins/README.md rename {Modules => Plugins}/Store.ImageCache/Controllers/ImageCacheController.cs (100%) rename {Modules => Plugins}/Store.ImageCache/Dtos/ImageCacheDto.cs (100%) rename {Modules => Plugins}/Store.ImageCache/Entities/ImageCacheEntity.cs (100%) rename {Modules => Plugins}/Store.ImageCache/Mapper/ImageCacheMapper.cs (100%) rename {Modules => Plugins}/Store.ImageCache/Module.cs (92%) rename {Modules => Plugins}/Store.ImageCache/Services/ImageCacheService.cs (100%) rename {Modules => Plugins}/Store.ImageCache/Startup.cs (100%) rename {Modules => Plugins}/Store.ImageCache/Store.ImageCache.csproj (71%) rename {Modules => Plugins}/Store.ImageCache/appsettings.ImageCache.json (100%) create mode 100644 Store.API/dotnet-tools.json create mode 100644 Tests/PluginLoadingSmoke/PluginLoadingSmoke.csproj create mode 100644 Tests/PluginLoadingSmoke/Program.cs diff --git a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs index 6fa5a36..308cb46 100644 --- a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs +++ b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs @@ -36,7 +36,7 @@ namespace MAX.Framework.DependencyInjection host.AddLogger(); //host.Services.AddSingleton(NLogger.Instance);//注册log - IEnumerable assemblies = GetApplicationAssemblies(); + IEnumerable assemblies = StoreAppTypeFinder.Current.GetAssemblies(host.Configuration["Plugins:Path"]).ToArray(); // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象 Dictionary instances = []; return AddStoreCore(host, assemblies, instances); @@ -62,7 +62,7 @@ namespace MAX.Framework.DependencyInjection - host.Services.AddControllers(options => + var mvc = host.Services.AddControllers(options => { options.Filters.Add(); options.Filters.Add(); @@ -87,6 +87,10 @@ namespace MAX.Framework.DependencyInjection }; }); + foreach (var assembly in assemblies) + { + mvc.AddApplicationPart(assembly); + } return host.Services; } @@ -154,7 +158,7 @@ namespace MAX.Framework.DependencyInjection catch (Exception ex) { // 带上模块名,避免只看到一个没有上下文的异常 - throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 AddModule 执行失败。", ex); + throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 AddModule 执行失败:{ex.Message}", ex); } } } diff --git a/Core/MAX.Framework/DependencyInjection/StoreHubAppTypeFinder.cs b/Core/MAX.Framework/DependencyInjection/StoreHubAppTypeFinder.cs index a552937..8582a6b 100644 --- a/Core/MAX.Framework/DependencyInjection/StoreHubAppTypeFinder.cs +++ b/Core/MAX.Framework/DependencyInjection/StoreHubAppTypeFinder.cs @@ -1,18 +1,132 @@ -using MAX.Core.Reflections; +using MAX.Core.Reflections; +using System.Reflection; +using System.Runtime.Loader; -namespace MAX.Framework.DependencyInjection +namespace MAX.Framework.DependencyInjection; + +/// 发现宿主程序集及独立发布的插件;入口过滤统一使用 Match。 +internal sealed class StoreAppTypeFinder : AppTypeFinder { - /// - /// 本应用的类型查找器:在 AppTypeFinder 默认前缀(MAX. / Nexus.)之外, - /// 增加 StoreHub、KJD 前缀,用于发现宿主和各业务模块的程序集。 - /// - internal sealed class StoreAppTypeFinder : AppTypeFinder - { - public static StoreAppTypeFinder Current { get; } = new(); + public static StoreAppTypeFinder Current { get; } = new(); + private readonly Dictionary resolvers = new(StringComparer.OrdinalIgnoreCase); + private readonly object gate = new(); + private bool registered; - protected override List AvailablePrefix() - { - return ["Store"]; - } - } + protected override List AvailablePrefix() => ["Store"]; + + public override List GetAssemblies() => GetAssemblies(null); + + public List GetAssemblies(string? pluginPath) + { + lock (gate) + { + var path = Path.GetFullPath(string.IsNullOrWhiteSpace(pluginPath) ? "Plugins" : pluginPath, AppContext.BaseDirectory); + if (!Directory.Exists(path)) + return base.GetAssemblies(); + + // 每个独立发布目录由入口 .deps.json 标识,避免把共享框架副本当作插件。 + var entries = Directory.GetFiles(path, "*.deps.json", SearchOption.AllDirectories) + .Select(file => file[..^".deps.json".Length] + ".dll") + .Where(file => Match(Path.GetFileName(file))) + .OrderBy(file => file, StringComparer.OrdinalIgnoreCase).ToArray(); + foreach (var entry in entries) + { + try + { + if (!File.Exists(entry)) + throw new FileNotFoundException("插件入口 DLL 不存在。", entry); + resolvers.TryAdd(entry, new AssemblyDependencyResolver(entry)); + } + catch (Exception ex) + { + throw new InvalidOperationException($"无法初始化插件 {entry}。", ex); + } + } + if (!registered) + { + AssemblyLoadContext.Default.Resolving += Resolve; + AssemblyLoadContext.Default.ResolvingUnmanagedDll += ResolveNative; + registered = true; + } + + var plugins = new List(); + foreach (var entry in entries) + { + try + { + var assembly = Load(entry); + ValidateDependencies(assembly); + assembly.GetTypes(); // 插件类型加载失败必须在启动时暴露。 + plugins.Add(assembly); + } + catch (Exception ex) + { + var detail = ex is ReflectionTypeLoadException types + ? string.Join("; ", types.LoaderExceptions.Select(error => error?.Message)) : ex.Message; + throw new InvalidOperationException($"加载插件 {entry} 失败:{detail}", ex); + } + } + return base.GetAssemblies().Concat(plugins).DistinctBy(a => a.FullName).ToList(); + } + } + + private static Assembly Load(string path) + { + var name = AssemblyName.GetAssemblyName(path); + var existing = AssemblyLoadContext.Default.Assemblies.FirstOrDefault(a => + string.Equals(a.GetName().Name, name.Name, StringComparison.OrdinalIgnoreCase)); + if (existing is null) + return AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(path)); + if (!string.Equals(existing.FullName, name.FullName, StringComparison.OrdinalIgnoreCase)) + throw new FileLoadException($"程序集版本冲突:请求 {name.FullName},已加载 {existing.FullName}。", path); + return existing; + } + + private void ValidateDependencies(Assembly assembly) + { + foreach (var reference in assembly.GetReferencedAssemblies()) + { + // 宿主 .deps.json 优先;插件发布目录中的副本仍须满足相同身份。 + var candidate = ResolvePath(reference); + AssemblyLoadContext.Default.LoadFromAssemblyName(reference); + if (candidate is not null) + { + Load(candidate); + // 不强制遍历第三方库所有可选功能依赖;插件类型所需依赖由 GetTypes 校验。 + } + } + } + + private string? ResolvePath(AssemblyName name) + { + string? result = null; + foreach (var (entry, resolver) in resolvers) + { + var candidate = resolver.ResolveAssemblyToPath(name); + if (candidate is null) continue; + var identity = AssemblyName.GetAssemblyName(candidate); + if (result is not null && !string.Equals(AssemblyName.GetAssemblyName(result).FullName, identity.FullName, StringComparison.OrdinalIgnoreCase)) + throw new FileLoadException($"插件依赖冲突:{result} 与 {candidate}(插件 {entry})。"); + result ??= candidate; + } + return result; + } + + private Assembly? Resolve(AssemblyLoadContext context, AssemblyName name) + { + var path = ResolvePath(name); + return path is null ? null : Load(path); + } + + private IntPtr ResolveNative(Assembly assembly, string name) + { + // 优先使用请求程序集所属插件目录,避免原生库跨插件误匹配。 + foreach (var (entry, resolver) in resolvers.OrderByDescending(pair => + assembly.Location.StartsWith(Path.GetDirectoryName(pair.Key)! + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) + { + var path = resolver.ResolveUnmanagedDllToPath(name); + if (path is not null) return System.Runtime.InteropServices.NativeLibrary.Load(path); + } + return IntPtr.Zero; + } } diff --git a/Directory.Build.props b/Directory.Build.props index 9b7dfdf..252be46 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,6 +2,8 @@ + + $(MSBuildThisFileDirectory)Bin\ net10.0 $(DefaultTargetFramework) 12.0 diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..d9edbd3 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,53 @@ + + + + + + + + + + + + (); + if (Directory.Exists(plugins)) + { + foreach (var plugin in Directory.GetDirectories(plugins)) + { + foreach (var file in Directory.GetFiles(plugin, "*", SearchOption.AllDirectories)) + { + var relative = file.Substring(plugin.TrimEnd(Path.DirectorySeparatorChar).Length + 1); + var shared = Path.GetFullPath(Path.Combine(host, relative)); + // Only compare files below the host, outside its plugin subtree. + if (!shared.StartsWith(host.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || shared.StartsWith(plugins + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || !File.Exists(shared) || new FileInfo(file).Length != new FileInfo(shared).Length) + continue; + using (var hash = SHA256.Create()) + using (var source = File.OpenRead(file)) + using (var target = File.OpenRead(shared)) + { + if (Convert.ToBase64String(hash.ComputeHash(source)) == Convert.ToBase64String(hash.ComputeHash(target))) + duplicates.Add(new Microsoft.Build.Utilities.TaskItem(file)); + } + } + } + } + Duplicates = duplicates.ToArray(); + ]]> + + + + + + + + + + + diff --git a/Modules/CloudProduct/CloudProductModule.cs b/Plugins/CloudProduct/CloudProductModule.cs similarity index 97% rename from Modules/CloudProduct/CloudProductModule.cs rename to Plugins/CloudProduct/CloudProductModule.cs index 24ead13..1ec397c 100644 --- a/Modules/CloudProduct/CloudProductModule.cs +++ b/Plugins/CloudProduct/CloudProductModule.cs @@ -33,7 +33,7 @@ namespace Store.Modules.CloudProduct if (!string.IsNullOrWhiteSpace(ConfigurationFileName)) { - string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName); + string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(Path.GetDirectoryName(GetType().Assembly.Location)!, ConfigurationFileName); configuration.AddJsonFile(path, optional: false, reloadOnChange: true); } diff --git a/Modules/CloudProduct/Controllers/GoodsController.cs b/Plugins/CloudProduct/Controllers/GoodsController.cs similarity index 100% rename from Modules/CloudProduct/Controllers/GoodsController.cs rename to Plugins/CloudProduct/Controllers/GoodsController.cs diff --git a/Modules/CloudProduct/Controllers/UserFavoritesController.cs b/Plugins/CloudProduct/Controllers/UserFavoritesController.cs similarity index 100% rename from Modules/CloudProduct/Controllers/UserFavoritesController.cs rename to Plugins/CloudProduct/Controllers/UserFavoritesController.cs diff --git a/Modules/CloudProduct/Dtos/AddUserFavoriteRequest.cs b/Plugins/CloudProduct/Dtos/AddUserFavoriteRequest.cs similarity index 100% rename from Modules/CloudProduct/Dtos/AddUserFavoriteRequest.cs rename to Plugins/CloudProduct/Dtos/AddUserFavoriteRequest.cs diff --git a/Modules/CloudProduct/Dtos/CreateUserFavoriteGroupRequest.cs b/Plugins/CloudProduct/Dtos/CreateUserFavoriteGroupRequest.cs similarity index 100% rename from Modules/CloudProduct/Dtos/CreateUserFavoriteGroupRequest.cs rename to Plugins/CloudProduct/Dtos/CreateUserFavoriteGroupRequest.cs diff --git a/Modules/CloudProduct/Dtos/GoodsImportItem.cs b/Plugins/CloudProduct/Dtos/GoodsImportItem.cs similarity index 100% rename from Modules/CloudProduct/Dtos/GoodsImportItem.cs rename to Plugins/CloudProduct/Dtos/GoodsImportItem.cs diff --git a/Modules/CloudProduct/Dtos/GoodsImportResult.cs b/Plugins/CloudProduct/Dtos/GoodsImportResult.cs similarity index 100% rename from Modules/CloudProduct/Dtos/GoodsImportResult.cs rename to Plugins/CloudProduct/Dtos/GoodsImportResult.cs diff --git a/Modules/CloudProduct/Dtos/GoodsSearchDocument.cs b/Plugins/CloudProduct/Dtos/GoodsSearchDocument.cs similarity index 100% rename from Modules/CloudProduct/Dtos/GoodsSearchDocument.cs rename to Plugins/CloudProduct/Dtos/GoodsSearchDocument.cs diff --git a/Modules/CloudProduct/Dtos/GoodsSearchRequest.cs b/Plugins/CloudProduct/Dtos/GoodsSearchRequest.cs similarity index 100% rename from Modules/CloudProduct/Dtos/GoodsSearchRequest.cs rename to Plugins/CloudProduct/Dtos/GoodsSearchRequest.cs diff --git a/Modules/CloudProduct/Dtos/OpenSearchStatusDto.cs b/Plugins/CloudProduct/Dtos/OpenSearchStatusDto.cs similarity index 100% rename from Modules/CloudProduct/Dtos/OpenSearchStatusDto.cs rename to Plugins/CloudProduct/Dtos/OpenSearchStatusDto.cs diff --git a/Modules/CloudProduct/Dtos/RemoveUserFavoritesRequest.cs b/Plugins/CloudProduct/Dtos/RemoveUserFavoritesRequest.cs similarity index 100% rename from Modules/CloudProduct/Dtos/RemoveUserFavoritesRequest.cs rename to Plugins/CloudProduct/Dtos/RemoveUserFavoritesRequest.cs diff --git a/Modules/CloudProduct/Dtos/UserFavoriteGroupDto.cs b/Plugins/CloudProduct/Dtos/UserFavoriteGroupDto.cs similarity index 100% rename from Modules/CloudProduct/Dtos/UserFavoriteGroupDto.cs rename to Plugins/CloudProduct/Dtos/UserFavoriteGroupDto.cs diff --git a/Modules/CloudProduct/Dtos/UserFavoriteItem.cs b/Plugins/CloudProduct/Dtos/UserFavoriteItem.cs similarity index 100% rename from Modules/CloudProduct/Dtos/UserFavoriteItem.cs rename to Plugins/CloudProduct/Dtos/UserFavoriteItem.cs diff --git a/Modules/CloudProduct/Dtos/UserFavoriteStatusRequest.cs b/Plugins/CloudProduct/Dtos/UserFavoriteStatusRequest.cs similarity index 100% rename from Modules/CloudProduct/Dtos/UserFavoriteStatusRequest.cs rename to Plugins/CloudProduct/Dtos/UserFavoriteStatusRequest.cs diff --git a/Modules/CloudProduct/Dtos/UserFavoritesSearchDocument.cs b/Plugins/CloudProduct/Dtos/UserFavoritesSearchDocument.cs similarity index 100% rename from Modules/CloudProduct/Dtos/UserFavoritesSearchDocument.cs rename to Plugins/CloudProduct/Dtos/UserFavoritesSearchDocument.cs diff --git a/Modules/CloudProduct/Dtos/UserFavoritesSearchRequest.cs b/Plugins/CloudProduct/Dtos/UserFavoritesSearchRequest.cs similarity index 100% rename from Modules/CloudProduct/Dtos/UserFavoritesSearchRequest.cs rename to Plugins/CloudProduct/Dtos/UserFavoritesSearchRequest.cs diff --git a/Modules/CloudProduct/Entities/GoodsEntity.cs b/Plugins/CloudProduct/Entities/GoodsEntity.cs similarity index 100% rename from Modules/CloudProduct/Entities/GoodsEntity.cs rename to Plugins/CloudProduct/Entities/GoodsEntity.cs diff --git a/Modules/CloudProduct/Entities/UserGoodsFavoriteEntity.cs b/Plugins/CloudProduct/Entities/UserGoodsFavoriteEntity.cs similarity index 100% rename from Modules/CloudProduct/Entities/UserGoodsFavoriteEntity.cs rename to Plugins/CloudProduct/Entities/UserGoodsFavoriteEntity.cs diff --git a/Modules/CloudProduct/Entities/UserGoodsFavoriteGroupEntity.cs b/Plugins/CloudProduct/Entities/UserGoodsFavoriteGroupEntity.cs similarity index 100% rename from Modules/CloudProduct/Entities/UserGoodsFavoriteGroupEntity.cs rename to Plugins/CloudProduct/Entities/UserGoodsFavoriteGroupEntity.cs diff --git a/Modules/CloudProduct/Enums/GoodsSearchSortBy.cs b/Plugins/CloudProduct/Enums/GoodsSearchSortBy.cs similarity index 100% rename from Modules/CloudProduct/Enums/GoodsSearchSortBy.cs rename to Plugins/CloudProduct/Enums/GoodsSearchSortBy.cs diff --git a/Modules/CloudProduct/Enums/PlatformType.cs b/Plugins/CloudProduct/Enums/PlatformType.cs similarity index 100% rename from Modules/CloudProduct/Enums/PlatformType.cs rename to Plugins/CloudProduct/Enums/PlatformType.cs diff --git a/Modules/CloudProduct/Enums/UserFavoritesSortBy.cs b/Plugins/CloudProduct/Enums/UserFavoritesSortBy.cs similarity index 100% rename from Modules/CloudProduct/Enums/UserFavoritesSortBy.cs rename to Plugins/CloudProduct/Enums/UserFavoritesSortBy.cs diff --git a/Modules/CloudProduct/ImportConverters/GoodsConverter.cs b/Plugins/CloudProduct/ImportConverters/GoodsConverter.cs similarity index 100% rename from Modules/CloudProduct/ImportConverters/GoodsConverter.cs rename to Plugins/CloudProduct/ImportConverters/GoodsConverter.cs diff --git a/Modules/CloudProduct/ImportConverters/GoodsConverterFactory.cs b/Plugins/CloudProduct/ImportConverters/GoodsConverterFactory.cs similarity index 100% rename from Modules/CloudProduct/ImportConverters/GoodsConverterFactory.cs rename to Plugins/CloudProduct/ImportConverters/GoodsConverterFactory.cs diff --git a/Modules/CloudProduct/ImportConverters/PddGoodsConverter.cs b/Plugins/CloudProduct/ImportConverters/PddGoodsConverter.cs similarity index 100% rename from Modules/CloudProduct/ImportConverters/PddGoodsConverter.cs rename to Plugins/CloudProduct/ImportConverters/PddGoodsConverter.cs diff --git a/Modules/CloudProduct/Repositorys/GoodsRepository.cs b/Plugins/CloudProduct/Repositorys/GoodsRepository.cs similarity index 100% rename from Modules/CloudProduct/Repositorys/GoodsRepository.cs rename to Plugins/CloudProduct/Repositorys/GoodsRepository.cs diff --git a/Modules/CloudProduct/Repositorys/UserGoodsFavoriteGroupRepository.cs b/Plugins/CloudProduct/Repositorys/UserGoodsFavoriteGroupRepository.cs similarity index 100% rename from Modules/CloudProduct/Repositorys/UserGoodsFavoriteGroupRepository.cs rename to Plugins/CloudProduct/Repositorys/UserGoodsFavoriteGroupRepository.cs diff --git a/Modules/CloudProduct/Repositorys/UserGoodsFavoriteRepository.cs b/Plugins/CloudProduct/Repositorys/UserGoodsFavoriteRepository.cs similarity index 100% rename from Modules/CloudProduct/Repositorys/UserGoodsFavoriteRepository.cs rename to Plugins/CloudProduct/Repositorys/UserGoodsFavoriteRepository.cs diff --git a/Modules/CloudProduct/Services/GoodsIndexJob.cs b/Plugins/CloudProduct/Services/GoodsIndexJob.cs similarity index 100% rename from Modules/CloudProduct/Services/GoodsIndexJob.cs rename to Plugins/CloudProduct/Services/GoodsIndexJob.cs diff --git a/Modules/CloudProduct/Services/GoodsService.cs b/Plugins/CloudProduct/Services/GoodsService.cs similarity index 100% rename from Modules/CloudProduct/Services/GoodsService.cs rename to Plugins/CloudProduct/Services/GoodsService.cs diff --git a/Modules/CloudProduct/Services/OpenSearchService.cs b/Plugins/CloudProduct/Services/OpenSearchService.cs similarity index 100% rename from Modules/CloudProduct/Services/OpenSearchService.cs rename to Plugins/CloudProduct/Services/OpenSearchService.cs diff --git a/Modules/CloudProduct/Services/RawDataStorage.cs b/Plugins/CloudProduct/Services/RawDataStorage.cs similarity index 100% rename from Modules/CloudProduct/Services/RawDataStorage.cs rename to Plugins/CloudProduct/Services/RawDataStorage.cs diff --git a/Modules/CloudProduct/Services/UserFavoritesIndexService.cs b/Plugins/CloudProduct/Services/UserFavoritesIndexService.cs similarity index 100% rename from Modules/CloudProduct/Services/UserFavoritesIndexService.cs rename to Plugins/CloudProduct/Services/UserFavoritesIndexService.cs diff --git a/Modules/CloudProduct/Services/UserFavoritesService.cs b/Plugins/CloudProduct/Services/UserFavoritesService.cs similarity index 100% rename from Modules/CloudProduct/Services/UserFavoritesService.cs rename to Plugins/CloudProduct/Services/UserFavoritesService.cs diff --git a/Modules/CloudProduct/Startup.cs b/Plugins/CloudProduct/Startup.cs similarity index 100% rename from Modules/CloudProduct/Startup.cs rename to Plugins/CloudProduct/Startup.cs diff --git a/Modules/CloudProduct/Store.CloudProduct.csproj b/Plugins/CloudProduct/Store.CloudProduct.csproj similarity index 91% rename from Modules/CloudProduct/Store.CloudProduct.csproj rename to Plugins/CloudProduct/Store.CloudProduct.csproj index 453bd03..33e9675 100644 --- a/Modules/CloudProduct/Store.CloudProduct.csproj +++ b/Plugins/CloudProduct/Store.CloudProduct.csproj @@ -1,6 +1,7 @@ + true enable enable @@ -20,6 +21,7 @@ PreserveNewest + PreserveNewest diff --git a/Modules/CloudProduct/appsettings.CloudProduct.json b/Plugins/CloudProduct/appsettings.CloudProduct.json similarity index 100% rename from Modules/CloudProduct/appsettings.CloudProduct.json rename to Plugins/CloudProduct/appsettings.CloudProduct.json diff --git a/Plugins/Directory.Build.props b/Plugins/Directory.Build.props new file mode 100644 index 0000000..c98d92f --- /dev/null +++ b/Plugins/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + true + $(StoreHostOutputPath)Plugins\$(MSBuildProjectName)\ + + diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets new file mode 100644 index 0000000..5deb6ad --- /dev/null +++ b/Plugins/Directory.Build.targets @@ -0,0 +1,30 @@ + + + + + + <_ObsoletePluginDeps Include="$(PluginDeploymentDirectory)*.deps.json;$(TargetDir)*.deps.json" + Exclude="$(PluginDeploymentDirectory)$(TargetName).deps.json;$(TargetDir)$(TargetName).deps.json"> + $([System.IO.Path]::GetFileNameWithoutExtension('%(Filename)')) + + <_ObsoletePluginEntry Include="@(_ObsoletePluginDeps)" /> + <_ObsoletePluginEntry Include="@(_ObsoletePluginDeps->'%(RootDir)%(Directory)%(EntryName).dll')" /> + <_ObsoletePluginEntry Include="@(_ObsoletePluginDeps->'%(RootDir)%(Directory)%(EntryName).pdb')" /> + <_ObsoletePluginEntry Include="@(_ObsoletePluginDeps->'%(RootDir)%(Directory)%(EntryName).runtimeconfig.json')" /> + + + + + <_PluginDeploymentFile Include="$(TargetDir)**\*" Exclude="$(TargetDir)publish\**\*" /> + + + + + + + + + diff --git a/Plugins/README.md b/Plugins/README.md new file mode 100644 index 0000000..9fb831c --- /dev/null +++ b/Plugins/README.md @@ -0,0 +1,86 @@ +# 独立功能插件 + +`Modules/` 放置商城业务模块,`Plugins/` 放置共用 API 宿主的独立功能。 +插件通过 IModule、IStartup 和依赖标记接口接入现有生命周期,控制器共用宿主 URL。 + +## 发布和运行 + +在仓库根目录执行生成命令: + +```powershell +dotnet build Store.API/Store.Api.csproj -c Release +dotnet build Plugins/CloudProduct/Store.CloudProduct.csproj -c Release +dotnet build Plugins/Store.ImageCache/Store.ImageCache.csproj -c Release +dotnet Bin/Store.Api.dll +``` + +API 直接生成到仓库根目录 `Bin/`,不再附加配置名称、目标框架或运行时子目录。 +各插件生成后自动将自身完整输出(DLL、deps.json、配置、运行依赖及 runtimes 子目录)复制到 +`Bin/Plugins/<项目名>/`。例如: + +```text +Bin/ + Store.Api.dll + Store.Api.deps.json + Store.Api.runtimeconfig.json + appsettings.json + Plugins/ + Store.CloudProduct/ + Store.ImageCache/ +``` + +生成全部所需项目后即可整体拷贝 `Bin/` 部署,目标机器需要兼容的 .NET/ASP.NET Core 运行时。 +API 不引用插件,也不会在单独生成 API 时触发插件构建;生成插件项目或生成包含插件的解决方案时会自动复制。 +未来在 Plugins 下新增项目会继承同目录的 Directory.Build.props/targets,无需逐个添加复制任务。 + +Debug 与 Release 共用此目录;部署前应以相同配置重新生成宿主及所需插件。 +生成宿主或插件后,都会按相对路径和 SHA-256 清理与宿主完全相同的插件文件;同名但内容不同的文件保留。 +宿主尚未生成时插件暂时保留完整依赖,之后生成宿主会自动去重。去重后的插件目录必须与匹配的宿主一起部署。 +同一插件输出和部署子目录中的旧入口(其他名称的 .deps.json 及对应 DLL、PDB、runtimeconfig.json)会在生成时自动清理,避免重复加载重命名前的插件。每个部署子目录只放一个插件入口。 +其他旧文件不会自动删除;插件部署目录更名、删除或移除依赖后,应清理对应旧目录再生成。 +MSBuild 属性 `StoreHostOutputPath` 可覆盖此路径,覆盖时使用绝对目录并以目录分隔符结尾。 + +需要标准 publish 产物时也可以指定独立部署目录: + +```powershell +dotnet publish Store.API/Store.Api.csproj -c Release -o artifacts/server +dotnet publish Plugins/CloudProduct/Store.CloudProduct.csproj -c Release -o artifacts/server/Plugins/Store.CloudProduct +dotnet publish Plugins/Store.ImageCache/Store.ImageCache.csproj -c Release -o artifacts/server/Plugins/Store.ImageCache +``` + +宿主配置 `Plugins:Path` 默认为 `Plugins`,相对路径以宿主二进制目录为基准,也可使用绝对路径。 +可以通过环境变量 `Plugins__Path` 覆盖。目录不存在或为空时不加载插件。 + +每个插件子目录包含入口 DLL、同名 `.deps.json`、配置和宿主未提供的依赖;共享依赖由宿主提供。 +上面的标准 `dotnet publish -o` 命令仍生成完整独立发布内容,仅 `Bin/Plugins/` 自动执行宿主去重。 +加载器递归寻找 `.deps.json`,由 `StoreHubAppTypeFinder.cs` 中现有 Match/AvailablePrefix 过滤入口程序集。 +依赖解析不受入口名称过滤限制。两个现有插件从自身程序集所在目录读取 JSON 配置。 + +## 新增与更新 + +新增插件无需修改 Store.API:创建类库,引用共享框架,实现需要的模块/启动接口,启用 +`EnableDynamicLoading`,并将配置设置为复制到输出和发布目录;生成后会自动复制到宿主扫描目录;也可以独立发布到其他扫描目录。 +配置文件命名与配置节应避免和其他插件冲突。现有模块的配置合并顺序保持不变。 + +插件、宿主和共享契约必须使用兼容版本。所有程序集加载到默认上下文,同名程序集只加载一次; +发布目录中同名依赖身份不一致会在加载时明确报错,不提供依赖版本隔离。 +插件必需的程序集、类型或配置缺失时启动失败;未调用的第三方可选功能依赖可能在调用时才解析。 + +插件与宿主同进程运行,共用认证、容器和中间件。更新时替换完整发布目录并重启宿主; +不支持热加载、卸载和进程隔离。CloudProduct 仍需要其数据库、OpenSearch 和后台任务配置。 + +## 加载冒烟验证 + +验证程序不连接数据库或 OpenSearch;每个场景单独启动进程: + +```powershell +dotnet run --project Tests/PluginLoadingSmoke -- <插件根目录> +dotnet run --project Tests/PluginLoadingSmoke -- <仅包含ImageCache的发布目录> image +dotnet run --project Tests/PluginLoadingSmoke -- <空目录或不存在目录> empty +dotnet run --project Tests/PluginLoadingSmoke -- - empty +dotnet run --project Tests/PluginLoadingSmoke -- <缺依赖或包含版本冲突的目录> failure +``` + +`-` 使用默认 Plugins 路径;默认路径下已部署插件时使用普通模式。 +普通模式验证两个插件及控制器发现和程序集去重;`image` 额外验证真实框架注册流程中的配置、DI、IStartup 和 MVC。 +将同一插件发布内容复制到另一子目录可验证重复程序集;缺失依赖和版本冲突应报告具体插件路径。 diff --git a/Modules/Store.ImageCache/Controllers/ImageCacheController.cs b/Plugins/Store.ImageCache/Controllers/ImageCacheController.cs similarity index 100% rename from Modules/Store.ImageCache/Controllers/ImageCacheController.cs rename to Plugins/Store.ImageCache/Controllers/ImageCacheController.cs diff --git a/Modules/Store.ImageCache/Dtos/ImageCacheDto.cs b/Plugins/Store.ImageCache/Dtos/ImageCacheDto.cs similarity index 100% rename from Modules/Store.ImageCache/Dtos/ImageCacheDto.cs rename to Plugins/Store.ImageCache/Dtos/ImageCacheDto.cs diff --git a/Modules/Store.ImageCache/Entities/ImageCacheEntity.cs b/Plugins/Store.ImageCache/Entities/ImageCacheEntity.cs similarity index 100% rename from Modules/Store.ImageCache/Entities/ImageCacheEntity.cs rename to Plugins/Store.ImageCache/Entities/ImageCacheEntity.cs diff --git a/Modules/Store.ImageCache/Mapper/ImageCacheMapper.cs b/Plugins/Store.ImageCache/Mapper/ImageCacheMapper.cs similarity index 100% rename from Modules/Store.ImageCache/Mapper/ImageCacheMapper.cs rename to Plugins/Store.ImageCache/Mapper/ImageCacheMapper.cs diff --git a/Modules/Store.ImageCache/Module.cs b/Plugins/Store.ImageCache/Module.cs similarity index 92% rename from Modules/Store.ImageCache/Module.cs rename to Plugins/Store.ImageCache/Module.cs index 5c41215..b4f4f51 100644 --- a/Modules/Store.ImageCache/Module.cs +++ b/Plugins/Store.ImageCache/Module.cs @@ -29,7 +29,7 @@ namespace Store.Modules.CloudProduct if (!string.IsNullOrWhiteSpace(ConfigurationFileName)) { - string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName); + string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(Path.GetDirectoryName(GetType().Assembly.Location)!, ConfigurationFileName); configuration.AddJsonFile(path, optional: false, reloadOnChange: true); } } diff --git a/Modules/Store.ImageCache/Services/ImageCacheService.cs b/Plugins/Store.ImageCache/Services/ImageCacheService.cs similarity index 100% rename from Modules/Store.ImageCache/Services/ImageCacheService.cs rename to Plugins/Store.ImageCache/Services/ImageCacheService.cs diff --git a/Modules/Store.ImageCache/Startup.cs b/Plugins/Store.ImageCache/Startup.cs similarity index 100% rename from Modules/Store.ImageCache/Startup.cs rename to Plugins/Store.ImageCache/Startup.cs diff --git a/Modules/Store.ImageCache/Store.ImageCache.csproj b/Plugins/Store.ImageCache/Store.ImageCache.csproj similarity index 71% rename from Modules/Store.ImageCache/Store.ImageCache.csproj rename to Plugins/Store.ImageCache/Store.ImageCache.csproj index 559ce0a..15c12e7 100644 --- a/Modules/Store.ImageCache/Store.ImageCache.csproj +++ b/Plugins/Store.ImageCache/Store.ImageCache.csproj @@ -1,6 +1,7 @@ - + + true @@ -12,6 +13,7 @@ PreserveNewest + PreserveNewest diff --git a/Modules/Store.ImageCache/appsettings.ImageCache.json b/Plugins/Store.ImageCache/appsettings.ImageCache.json similarity index 100% rename from Modules/Store.ImageCache/appsettings.ImageCache.json rename to Plugins/Store.ImageCache/appsettings.ImageCache.json diff --git a/Store.API/Program.cs b/Store.API/Program.cs index 662eaea..d3ff631 100644 --- a/Store.API/Program.cs +++ b/Store.API/Program.cs @@ -48,7 +48,7 @@ public partial class Program app.MapOpenApi().WithDocumentPerVersion(); app.MapScalarApiReference(options => { - + options.DefaultHttpClient = new(ScalarTarget.CSharp, ScalarClient.HttpClient); options.Title = "商铺管理API V1"; options.AddDocuments(new[] { "v1", "v2" }); }); diff --git a/Store.API/Store.Api.csproj b/Store.API/Store.Api.csproj index 4091a9b..41e7f5c 100644 --- a/Store.API/Store.Api.csproj +++ b/Store.API/Store.Api.csproj @@ -1,6 +1,9 @@ - + + $(StoreHostOutputPath) + false + false enable enable zh-Hans;zh-Hant;zh;en @@ -20,8 +23,6 @@ - - diff --git a/Store.API/appsettings.json b/Store.API/appsettings.json index 82d3a32..d96f366 100644 --- a/Store.API/appsettings.json +++ b/Store.API/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "Logging": { "LogLevel": { "Default": "Information", @@ -43,5 +43,8 @@ "ValidateAudience": true, "ValidateLifetime": true }, + "Plugins": { + "Path": "Plugins" + }, "AllowedHosts": "*" } diff --git a/Store.API/dotnet-tools.json b/Store.API/dotnet-tools.json new file mode 100644 index 0000000..39007e0 --- /dev/null +++ b/Store.API/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.13", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/Store.Api.slnx b/Store.Api.slnx index 53d346d..84fa754 100644 --- a/Store.Api.slnx +++ b/Store.Api.slnx @@ -4,15 +4,24 @@ - - + + + + + + + + + + + diff --git a/Tests/PluginLoadingSmoke/PluginLoadingSmoke.csproj b/Tests/PluginLoadingSmoke/PluginLoadingSmoke.csproj new file mode 100644 index 0000000..cd69ea2 --- /dev/null +++ b/Tests/PluginLoadingSmoke/PluginLoadingSmoke.csproj @@ -0,0 +1,4 @@ + + Exe + + diff --git a/Tests/PluginLoadingSmoke/Program.cs b/Tests/PluginLoadingSmoke/Program.cs new file mode 100644 index 0000000..bbfd05b --- /dev/null +++ b/Tests/PluginLoadingSmoke/Program.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using MAX.Framework.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; + +// Run each scenario in a fresh process: default ALC intentionally cannot unload plugins. +var finderType = typeof(ServiceCollectionExtensions).Assembly.GetType("MAX.Framework.DependencyInjection.StoreAppTypeFinder", true)!; +var finder = finderType.GetProperty("Current")!.GetValue(null); +var method = finderType.GetMethod("GetAssemblies", new[] { typeof(string) })!; +var mode = args.ElementAtOrDefault(1) ?? "plugins"; +List assemblies; +try +{ + assemblies = (List)method.Invoke(finder, new object[] { args[0] == "-" ? null : args[0] })!; +} +catch (TargetInvocationException ex) when (mode == "failure" && ex.InnerException is InvalidOperationException) +{ + Console.WriteLine("PASS expected loading failure: " + ex.InnerException.Message); + return; +} +if (mode == "failure") throw new Exception("Expected loading failure"); +var plugins = assemblies.Where(a => a.GetName().Name is "Store.CloudProduct" or "Store.ImageCache").ToArray(); +if (mode == "empty") +{ + if (plugins.Length != 0) throw new Exception("Unexpected plugins"); + Console.WriteLine("PASS empty/missing directory"); + return; +} +if (plugins.Length != (mode == "image" ? 1 : 2)) throw new Exception("Unexpected plugin count"); +if (assemblies.Select(a => a.FullName).Distinct().Count() != assemblies.Count) throw new Exception("Duplicate assemblies"); +var mvc = new ServiceCollection().AddControllers(); +foreach (var plugin in plugins) mvc.AddApplicationPart(plugin); +var feature = new ControllerFeature(); +mvc.PartManager.PopulateFeature(feature); +if (!feature.Controllers.Any(t => t.Name == "ImageCacheController")) throw new Exception("ImageCache controller missing"); +if (mode != "image" && !feature.Controllers.Any(t => t.Name == "GoodsController")) throw new Exception("Goods controller missing"); +if (mode == "image") +{ + var host = Host.CreateApplicationBuilder(); + var core = typeof(ServiceCollectionExtensions).GetMethod("AddStoreCore", BindingFlags.Static | BindingFlags.NonPublic)!; + core.Invoke(null, new object[] { host, plugins, new Dictionary() }); + if (string.IsNullOrEmpty(host.Configuration["ImageCache:ConnectionString"])) throw new Exception("Plugin configuration 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"); + var registeredMvc = host.Services.AddControllers(); + var registered = new ControllerFeature(); + registeredMvc.PartManager.PopulateFeature(registered); + if (!registered.Controllers.Any(t => t.Name == "ImageCacheController")) throw new Exception("Core MVC registration missing"); +} +Console.WriteLine($"PASS plugins={plugins.Length}, controllers={feature.Controllers.Count}; dependencies/types loaded" + (mode == "image" ? "; configuration/DI/Startup/MVC registered" : ""));