把一些项目移动到插件中

This commit is contained in:
chfei committed 2026-10-22 15:12:56 +08:00
1 parent d0f9f00b76
commit 5ff52dea92
60 files changed
+410 -27

No files matched your search

@@ -36,7 +36,7 @@ namespace MAX.Framework.DependencyInjection
host.AddLogger(); host.AddLogger();
//host.Services.AddSingleton<ILoggerFacade>(NLogger.Instance);//注册log //host.Services.AddSingleton<ILoggerFacade>(NLogger.Instance);//注册log
IEnumerable<Assembly> assemblies = GetApplicationAssemblies(); IEnumerable<Assembly> assemblies = StoreAppTypeFinder.Current.GetAssemblies(host.Configuration["Plugins:Path"]).ToArray();
// 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象 // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象
Dictionary<Type, object> instances = []; Dictionary<Type, object> instances = [];
return AddStoreCore(host, assemblies, 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<GlobalExceptionFilter>(); options.Filters.Add<GlobalExceptionFilter>();
options.Filters.Add<UnifyResultFilter>(); options.Filters.Add<UnifyResultFilter>();
@@ -87,6 +87,10 @@ namespace MAX.Framework.DependencyInjection
}; };
}); });
foreach (var assembly in assemblies)
{
mvc.AddApplicationPart(assembly);
}
return host.Services; return host.Services;
} }
@@ -154,7 +158,7 @@ namespace MAX.Framework.DependencyInjection
catch (Exception ex) catch (Exception ex)
{ {
// 带上模块名,避免只看到一个没有上下文的异常 // 带上模块名,避免只看到一个没有上下文的异常
throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 AddModule 执行失败。", ex); throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 AddModule 执行失败:{ex.Message}", ex);
} }
} }
} }
@@ -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;
/// <summary>发现宿主程序集及独立发布的插件;入口过滤统一使用 Match。</summary>
internal sealed class StoreAppTypeFinder : AppTypeFinder
{ {
/// <summary> public static StoreAppTypeFinder Current { get; } = new();
/// 本应用的类型查找器:在 AppTypeFinder 默认前缀(MAX. / Nexus.)之外, private readonly Dictionary<string, AssemblyDependencyResolver> resolvers = new(StringComparer.OrdinalIgnoreCase);
/// 增加 StoreHub、KJD 前缀,用于发现宿主和各业务模块的程序集。 private readonly object gate = new();
/// </summary> private bool registered;
internal sealed class StoreAppTypeFinder : AppTypeFinder
{
public static StoreAppTypeFinder Current { get; } = new();
protected override List<string> AvailablePrefix() protected override List<string> AvailablePrefix() => ["Store"];
{
return ["Store"]; public override List<Assembly> GetAssemblies() => GetAssemblies(null);
}
} public List<Assembly> 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<Assembly>();
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;
}
} }
+2
View File
@@ -2,6 +2,8 @@
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build --> <!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
<PropertyGroup> <PropertyGroup>
<!-- API 和独立插件共用的部署目录,可通过 MSBuild 属性覆盖。 -->
<StoreHostOutputPath Condition="'$(StoreHostOutputPath)' == ''">$(MSBuildThisFileDirectory)Bin\</StoreHostOutputPath>
<DefaultTargetFramework>net10.0</DefaultTargetFramework> <DefaultTargetFramework>net10.0</DefaultTargetFramework>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework> <TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<LangVersion>12.0</LangVersion> <LangVersion>12.0</LangVersion>
+53
View File
@@ -0,0 +1,53 @@
<Project>
<UsingTask TaskName="FindHostDuplicatePluginFiles" TaskFactory="RoslynCodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)/Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<HostDirectory ParameterType="System.String" Required="true" />
<Duplicates ParameterType="Microsoft.Build.Framework.ITaskItem[]" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Using Namespace="System.Collections.Generic" />
<Using Namespace="System.Security.Cryptography" />
<Code Type="Fragment" Language="cs"><![CDATA[
var host = Path.GetFullPath(HostDirectory);
var plugins = Path.GetFullPath(Path.Combine(host, "Plugins"));
var duplicates = new List<Microsoft.Build.Framework.ITaskItem>();
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();
]]></Code>
</Task>
</UsingTask>
<!-- Run again after the host build, so plugin-first build order is supported. -->
<Target Name="RemoveHostDuplicatePluginFiles" AfterTargets="Build"
Condition="'$(MSBuildProjectName)' == 'Store.Api' and '$(DesignTimeBuild)' != 'true'">
<FindHostDuplicatePluginFiles HostDirectory="$(StoreHostOutputPath)">
<Output TaskParameter="Duplicates" ItemName="_HostDuplicatePluginFile" />
</FindHostDuplicatePluginFiles>
<Delete Files="@(_HostDuplicatePluginFile)" />
</Target>
</Project>
@@ -33,7 +33,7 @@ namespace Store.Modules.CloudProduct
if (!string.IsNullOrWhiteSpace(ConfigurationFileName)) 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); configuration.AddJsonFile(path, optional: false, reloadOnChange: true);
} }
File renamed without changes.
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<EnableDynamicLoading>true</EnableDynamicLoading>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
@@ -20,6 +21,7 @@
<ItemGroup> <ItemGroup>
<None Update="appsettings.CloudProduct.json"> <None Update="appsettings.CloudProduct.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+8
View File
@@ -0,0 +1,8 @@
<Project>
<Import Project="../Directory.Build.props" />
<PropertyGroup>
<!-- 插件生成时带齐运行依赖,复制到宿主后移除共享副本。 -->
<EnableDynamicLoading>true</EnableDynamicLoading>
<PluginDeploymentDirectory>$(StoreHostOutputPath)Plugins\$(MSBuildProjectName)\</PluginDeploymentDirectory>
</PropertyGroup>
</Project>
+30
View File
@@ -0,0 +1,30 @@
<Project>
<Import Project="../Directory.Build.targets" />
<Target Name="CopyPluginToHost" AfterTargets="Build"
Condition="'$(DesignTimeBuild)' != 'true' and '$(IsCrossTargetingBuild)' != 'true'">
<ItemGroup>
<!-- 一个部署子目录只容纳一个插件入口,清理重命名之前遗留的入口产物。 -->
<_ObsoletePluginDeps Include="$(PluginDeploymentDirectory)*.deps.json;$(TargetDir)*.deps.json"
Exclude="$(PluginDeploymentDirectory)$(TargetName).deps.json;$(TargetDir)$(TargetName).deps.json">
<EntryName>$([System.IO.Path]::GetFileNameWithoutExtension('%(Filename)'))</EntryName>
</_ObsoletePluginDeps>
<_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')" />
</ItemGroup>
<Delete Files="@(_ObsoletePluginEntry)" />
<ItemGroup>
<!-- 保留 runtimes 等子目录,包含入口、deps.json、配置及传递依赖。 -->
<_PluginDeploymentFile Include="$(TargetDir)**\*" Exclude="$(TargetDir)publish\**\*" />
</ItemGroup>
<Copy SourceFiles="@(_PluginDeploymentFile)"
DestinationFiles="@(_PluginDeploymentFile->'$(PluginDeploymentDirectory)%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<FindHostDuplicatePluginFiles HostDirectory="$(StoreHostOutputPath)">
<Output TaskParameter="Duplicates" ItemName="_PluginDuplicateFile" />
</FindHostDuplicatePluginFiles>
<Delete Files="@(_PluginDuplicateFile)" />
<Message Importance="high" Text="插件已复制到 $(PluginDeploymentDirectory)" />
</Target>
</Project>
+86
View File
@@ -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。
将同一插件发布内容复制到另一子目录可验证重复程序集;缺失依赖和版本冲突应报告具体插件路径。
@@ -29,7 +29,7 @@ namespace Store.Modules.CloudProduct
if (!string.IsNullOrWhiteSpace(ConfigurationFileName)) 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); configuration.AddJsonFile(path, optional: false, reloadOnChange: true);
} }
} }
File renamed without changes.
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -12,6 +13,7 @@
<ItemGroup> <ItemGroup>
<None Update="appsettings.ImageCache.json"> <None Update="appsettings.ImageCache.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None> </None>
</ItemGroup> </ItemGroup>
</Project> </Project>
+1 -1
View File
@@ -48,7 +48,7 @@ public partial class Program
app.MapOpenApi().WithDocumentPerVersion(); app.MapOpenApi().WithDocumentPerVersion();
app.MapScalarApiReference(options => app.MapScalarApiReference(options =>
{ {
options.DefaultHttpClient = new(ScalarTarget.CSharp, ScalarClient.HttpClient);
options.Title = "商铺管理API V1"; options.Title = "商铺管理API V1";
options.AddDocuments(new[] { "v1", "v2" }); options.AddDocuments(new[] { "v1", "v2" });
}); });
+4 -3
View File
@@ -1,6 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<OutputPath>$(StoreHostOutputPath)</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<SatelliteResourceLanguages>zh-Hans;zh-Hant;zh;en</SatelliteResourceLanguages> <SatelliteResourceLanguages>zh-Hans;zh-Hant;zh;en</SatelliteResourceLanguages>
@@ -20,8 +23,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Modules\CloudProduct\Store.CloudProduct.csproj" />
<ProjectReference Include="..\Modules\Store.ImageCache\Store.ImageCache.csproj" />
<ProjectReference Include="..\Modules\Store.Users\Store.Users.csproj" /> <ProjectReference Include="..\Modules\Store.Users\Store.Users.csproj" />
<ProjectReference Include="..\Store.Framework\Store.Framework.csproj" /> <ProjectReference Include="..\Store.Framework\Store.Framework.csproj" />
</ItemGroup> </ItemGroup>
+4 -1
View File
@@ -1,4 +1,4 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@@ -43,5 +43,8 @@
"ValidateAudience": true, "ValidateAudience": true,
"ValidateLifetime": true "ValidateLifetime": true
}, },
"Plugins": {
"Path": "Plugins"
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.13",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+11 -2
View File
@@ -4,15 +4,24 @@
<Project Path="Core/MAX.Framework/MAX.Framework.csproj" /> <Project Path="Core/MAX.Framework/MAX.Framework.csproj" />
</Folder> </Folder>
<Folder Name="/Modules/"> <Folder Name="/Modules/">
<Project Path="Modules/CloudProduct/Store.CloudProduct.csproj" />
<Project Path="Modules/Store.ImageCache/Store.ImageCache.csproj" Id="53cc9cf1-62f9-4980-bad9-958bb3314370" />
<Project Path="Modules/Store.Shops.Contracts/Store.Shops.Contracts.csproj" Id="7d313823-f86a-450a-be29-8f915fc4b5b3" /> <Project Path="Modules/Store.Shops.Contracts/Store.Shops.Contracts.csproj" Id="7d313823-f86a-450a-be29-8f915fc4b5b3" />
<Project Path="Modules/Store.Shops/Store.Shops.csproj" Id="b1c06d62-9cfb-4e87-b495-7077dd0c088d" /> <Project Path="Modules/Store.Shops/Store.Shops.csproj" Id="b1c06d62-9cfb-4e87-b495-7077dd0c088d" />
<Project Path="Modules/Store.Users.Contracts/Store.Users.Contracts.csproj" Id="d4a8177b-503c-4205-96ce-da0e01c855db" /> <Project Path="Modules/Store.Users.Contracts/Store.Users.Contracts.csproj" Id="d4a8177b-503c-4205-96ce-da0e01c855db" />
<Project Path="Modules/Store.Users/Store.Users.csproj" /> <Project Path="Modules/Store.Users/Store.Users.csproj" />
</Folder> </Folder>
<Folder Name="/Plugins/">
<Project Path="Plugins/CloudProduct/Store.CloudProduct.csproj" />
<Project Path="Plugins/Store.ImageCache/Store.ImageCache.csproj" />
</Folder>
<Folder Name="/Plugins/_SolutionItems/">
<File Path="Plugins/Directory.Build.props" />
<File Path="Plugins/Directory.Build.targets" />
</Folder>
<Folder Name="/_SolutionItems/"> <Folder Name="/_SolutionItems/">
<File Path="Directory.Build.props" /> <File Path="Directory.Build.props" />
<File Path="Directory.Build.targets" />
</Folder> </Folder>
<Project Path="Store.API/Store.Api.csproj" /> <Project Path="Store.API/Store.Api.csproj" />
<Project Path="Store.Framework/Store.Framework.csproj" /> <Project Path="Store.Framework/Store.Framework.csproj" />
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
<ItemGroup><ProjectReference Include="../../Store.API/Store.Api.csproj" /></ItemGroup>
</Project>
+52
View File
@@ -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<Assembly> assemblies;
try
{
assemblies = (List<Assembly>)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<Type, object>() });
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" : ""));