把一些项目移动到插件中
This commit is contained in:
1 parent
d0f9f00b76
commit
5ff52dea92
60 files changed
+405
-22
No files matched your search
@@ -36,7 +36,7 @@ namespace MAX.Framework.DependencyInjection
|
||||
host.AddLogger();
|
||||
//host.Services.AddSingleton<ILoggerFacade>(NLogger.Instance);//注册log
|
||||
|
||||
IEnumerable<Assembly> assemblies = GetApplicationAssemblies();
|
||||
IEnumerable<Assembly> assemblies = StoreAppTypeFinder.Current.GetAssemblies(host.Configuration["Plugins:Path"]).ToArray();
|
||||
// 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象
|
||||
Dictionary<Type, object> 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<UnifyResultFilter>();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,132 @@
|
||||
using MAX.Core.Reflections;
|
||||
using MAX.Core.Reflections;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace MAX.Framework.DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// 本应用的类型查找器:在 AppTypeFinder 默认前缀(MAX. / Nexus.)之外,
|
||||
/// 增加 StoreHub、KJD 前缀,用于发现宿主和各业务模块的程序集。
|
||||
/// </summary>
|
||||
namespace MAX.Framework.DependencyInjection;
|
||||
|
||||
/// <summary>发现宿主程序集及独立发布的插件;入口过滤统一使用 Match。</summary>
|
||||
internal sealed class StoreAppTypeFinder : AppTypeFinder
|
||||
{
|
||||
public static StoreAppTypeFinder Current { get; } = new();
|
||||
private readonly Dictionary<string, AssemblyDependencyResolver> resolvers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object gate = new();
|
||||
private bool registered;
|
||||
|
||||
protected override List<string> AvailablePrefix()
|
||||
protected override List<string> AvailablePrefix() => ["Store"];
|
||||
|
||||
public override List<Assembly> GetAssemblies() => GetAssemblies(null);
|
||||
|
||||
public List<Assembly> GetAssemblies(string? pluginPath)
|
||||
{
|
||||
return ["Store"];
|
||||
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,6 +2,8 @@
|
||||
|
||||
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
|
||||
<PropertyGroup>
|
||||
<!-- API 和独立插件共用的部署目录,可通过 MSBuild 属性覆盖。 -->
|
||||
<StoreHostOutputPath Condition="'$(StoreHostOutputPath)' == ''">$(MSBuildThisFileDirectory)Bin\</StoreHostOutputPath>
|
||||
<DefaultTargetFramework>net10.0</DefaultTargetFramework>
|
||||
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
|
||||
<LangVersion>12.0</LangVersion>
|
||||
|
||||
@@ -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>
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
@@ -20,6 +21,7 @@
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.CloudProduct.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
File renamed without changes.
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<Import Project="../Directory.Build.props" />
|
||||
<PropertyGroup>
|
||||
<!-- 插件生成时带齐运行依赖,复制到宿主后移除共享副本。 -->
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<PluginDeploymentDirectory>$(StoreHostOutputPath)Plugins\$(MSBuildProjectName)\</PluginDeploymentDirectory>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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>
|
||||
@@ -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。
|
||||
将同一插件发布内容复制到另一子目录可验证重复程序集;缺失依赖和版本冲突应报告具体插件路径。
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+3
-1
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
@@ -12,6 +13,7 @@
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.ImageCache.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
File renamed without changes.
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputPath>$(StoreHostOutputPath)</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SatelliteResourceLanguages>zh-Hans;zh-Hant;zh;en</SatelliteResourceLanguages>
|
||||
@@ -20,8 +23,6 @@
|
||||
</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="..\Store.Framework\Store.Framework.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
@@ -43,5 +43,8 @@
|
||||
"ValidateAudience": true,
|
||||
"ValidateLifetime": true
|
||||
},
|
||||
"Plugins": {
|
||||
"Path": "Plugins"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.13",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -4,15 +4,24 @@
|
||||
<Project Path="Core/MAX.Framework/MAX.Framework.csproj" />
|
||||
</Folder>
|
||||
<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/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/Store.Users.csproj" />
|
||||
</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/">
|
||||
<File Path="Directory.Build.props" />
|
||||
<File Path="Directory.Build.targets" />
|
||||
</Folder>
|
||||
<Project Path="Store.API/Store.Api.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>
|
||||
@@ -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" : ""));
|
||||
Reference in new issue
Block a user