diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssembly.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssembly.cs index e07d47043..0a412721e 100644 --- a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssembly.cs +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityAssembly.cs @@ -81,6 +81,11 @@ public static IReadOnlyList DiscoverModules(IEnumerable /// 加载部署目录内符合能力包命名约定({前缀}.*.dll)的全部程序集(扫描数据源)。 + /// + /// 维护约束:按磁盘路径 Assembly.LoadFrom 加载——部署产物落地即被发现(identity 不在 + /// deps.json 的裸部署包同样命中);任一文件加载失败不再静默跳过,逐条计入问题清单后汇总抛 + /// (fail-fast,一次报全)。 + /// /// /// 部署程序集前缀(如 GameFrameX.Capability.Online;非空)。 /// 部署目录内可加载的能力包程序集集合。 @@ -92,18 +97,25 @@ public static IReadOnlyList LoadDeploymentAssemblies(string prefix) } var assemblies = new List(); + var problems = new List(); foreach (var path in Directory.GetFiles(AppContext.BaseDirectory, prefix.Trim() + ".*.dll")) { try { - assemblies.Add(Assembly.Load(AssemblyName.GetAssemblyName(path))); + assemblies.Add(Assembly.LoadFrom(path)); } - catch (Exception) + catch (Exception exception) { - // 解析失败的文件(原生库 / 损坏产物)跳过——扫描面只关心能力包程序集。 + // 命中命名约定的产物加载失败 = 部署残缺,静默跳过会造成能力缺席零告警——计入问题一次报全。 + problems.Add(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.DeploymentAssemblyLoadFailed), path, exception.Message)); } } + if (problems.Count > 0) + { + throw new CapabilityAssemblyException(problems); + } + return assemblies; } @@ -129,10 +141,10 @@ private static void CollectModuleTypes(Assembly assembly, List moduleTypes { types = assembly.GetTypes(); } - catch (ReflectionTypeLoadException) + catch (ReflectionTypeLoadException exception) { - // 无法完整加载类型的程序集跳过(如仅资源程序集或依赖缺失的动态引用)。 - return; + // 命中命名约定的能力包类型无法完整加载(如依赖缺失)= 部署残缺——带定位信息 fail-fast,不再静默跳过。 + throw new InvalidOperationException(string.Format(LocalizationService.GetString(Localization.Keys.Capability.Hosting.ModuleTypeScanFailed), assembly.GetName().Name, exception.LoaderExceptions == null ? 0 : exception.LoaderExceptions.Length)); } foreach (var type in types) diff --git a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityComposition.cs b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityComposition.cs index d748fcaf3..5edf4a747 100644 --- a/Capability/GameFrameX.Capability.Hosting.Core/CapabilityComposition.cs +++ b/Capability/GameFrameX.Capability.Hosting.Core/CapabilityComposition.cs @@ -92,16 +92,16 @@ public static CapabilityRuntime Compose(ICapabilityFamily family, CapabilityComp services.AddOverride(new CapabilityServiceRegistration(entry.ServiceType, string.Empty, entry.Factory, family.FamilyId, true)); } - var modules = CapabilityAssembly.DiscoverFromDeploymentDirectory(family.DeploymentAssemblyPrefix); - var builder = new CapabilityHostBuilder(services, eventBus, family, options); CapabilityHost host; try { + var modules = CapabilityAssembly.DiscoverFromDeploymentDirectory(family.DeploymentAssemblyPrefix); host = builder.Build(modules); } catch (CapabilityAssemblyException exception) { + // 部署目录发现期与装配期的汇总问题统一走这里:抛出前逐条打 Error(一次报全)。 CapabilityAssemblyLogRenderer.RenderFailures(exception.Problems, options.AssemblyLog); throw; } diff --git a/GameFrameX.Localization/Keys.Capability.cs b/GameFrameX.Localization/Keys.Capability.cs index bc3646a77..6cb2e37b8 100644 --- a/GameFrameX.Localization/Keys.Capability.cs +++ b/GameFrameX.Localization/Keys.Capability.cs @@ -63,6 +63,26 @@ public static class Hosting /// public const string DuplicateCapability = "Capability.Hosting.DuplicateCapability"; + /// + /// 部署目录能力程序集加载失败:{0}({1}) + /// + /// + /// 键名: Capability.Hosting.DeploymentAssemblyLoadFailed + /// 用途: 部署目录扫描按磁盘路径加载命中命名约定的产物失败(损坏 / 残缺 / 非托管) + /// 参数: {0} - 文件路径;{1} - 底层异常消息 + /// + public const string DeploymentAssemblyLoadFailed = "Capability.Hosting.DeploymentAssemblyLoadFailed"; + + /// + /// 能力程序集类型扫描失败:{0}({1} 个类型加载错误) + /// + /// + /// 键名: Capability.Hosting.ModuleTypeScanFailed + /// 用途: 能力包程序集 GetTypes 抛 ReflectionTypeLoadException(如依赖缺失) + /// 参数: {0} - 程序集名;{1} - 加载失败类型数 + /// + public const string ModuleTypeScanFailed = "Capability.Hosting.ModuleTypeScanFailed"; + /// /// 装配过滤器引用了未发现的能力:{0} /// diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx index 9d7148c5e..4f5b8bb6f 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.resx @@ -1388,6 +1388,12 @@ Duplicate capability registration for id: {0} + + Failed to load a deployment capability assembly matched by the naming convention: {0} ({1}) + + + Failed to scan capability assembly types: {0} ({1} type load failures) + Assembly filter references an unknown capability id: {0} diff --git a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx index 4e8762e4b..fd78e6588 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx @@ -1386,6 +1386,12 @@ 能力 Id 重复注册:{0} + + 部署目录能力程序集加载失败:{0}({1}) + + + 能力程序集类型扫描失败:{0}({1} 个类型加载错误) + 装配过滤器引用了未发现的能力:{0} diff --git a/Tests/GameFrameX.Tests/Online/CapabilityDeploymentDiscoveryTests.cs b/Tests/GameFrameX.Tests/Online/CapabilityDeploymentDiscoveryTests.cs new file mode 100644 index 000000000..106e1424e --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/CapabilityDeploymentDiscoveryTests.cs @@ -0,0 +1,190 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the PRC and relevant international regulations. +// 使用本项目须严格遵守相关法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录的 LICENSE 文件。 +// please refer to the LICENSE file at the root of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe the legal rights and interests of others as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project, +// 本组织与贡献者概不承担任何责任。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://www.cnb.cool/GameFrameX +// CNB Repository: https://www.cnb.cool/GameFrameX + +using System; +using System.IO; +using System.Reflection; +using System.Reflection.Emit; +using System.Threading.Tasks; +using GameFrameX.Capability.Hosting.Abstractions; +using GameFrameX.Capability.Hosting.Core; +using Xunit; + +namespace GameFrameX.Tests.Online; + +/// +/// 部署目录发现语义测试(C303 / GFX-1014):裸 drop dll(identity 不在 deps.json)按磁盘路径落地即被发现; +/// 命中命名约定的损坏产物与类型无法完整加载的能力包 fail-fast 一次报全,不再静默缺席。 +/// +/// 探测程序集用独立前缀(CapabilityDeploymentProbe*),与机制族前缀 GameFrameX.Tests 无 glob +/// 交集,避免污染并行运行的机制装配用例。 +/// +/// +[Collection(CapabilityRuntimeCollection.Name)] +public sealed class CapabilityDeploymentDiscoveryTests +{ + /// + /// 裸 drop dll(不在 deps.json 的 identity)落地即被发现:按磁盘路径加载并发现其中的能力模块。 + /// + [Fact] + public void LoadDeploymentAssemblies_BareDroppedAssembly_IsDiscovered() + { + var path = Path.Combine(AppContext.BaseDirectory, "CapabilityDeploymentProbe.Bare.dll"); + EmitSingleModuleAssembly(path, "CapabilityDeploymentProbe.Bare", "CapabilityDeploymentProbe.BareModule", "capability-deployment-probe-bare"); + try + { + var assemblies = CapabilityAssembly.LoadDeploymentAssemblies("CapabilityDeploymentProbe"); + var module = Assert.Single(CapabilityAssembly.DiscoverFromDeploymentDirectory("CapabilityDeploymentProbe")); + + Assert.Equal("CapabilityDeploymentProbe.Bare", Assert.Single(assemblies).GetName().Name); + Assert.Equal("capability-deployment-probe-bare", module.Manifest.Id); + } + finally + { + File.Delete(path); + } + } + + /// + /// 命中命名约定的损坏产物不再静默跳过:汇总抛 ,问题条目含文件路径。 + /// + [Fact] + public void LoadDeploymentAssemblies_CorruptArtifact_FailsFastWithProblems() + { + var path = Path.Combine(AppContext.BaseDirectory, "CapabilityDeploymentProbeCorrupt.Broken.dll"); + File.WriteAllBytes(path, new byte[] { 0x4D, 0x5A, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, }); + try + { + var exception = Assert.Throws(() => CapabilityAssembly.LoadDeploymentAssemblies("CapabilityDeploymentProbeCorrupt")); + + Assert.Contains(path, Assert.Single(exception.Problems)); + } + finally + { + File.Delete(path); + } + } + + /// + /// 类型无法完整加载的能力包(依赖缺失)不再静默跳过:带程序集名定位信息 fail-fast。 + /// + [Fact] + public void DiscoverModules_UnloadableTypes_FailsFastWithAssemblyName() + { + var path = Path.Combine(AppContext.BaseDirectory, "CapabilityDeploymentScanProbe.Broken.dll"); + EmitAssemblyWithUnresolvableReference(path, "CapabilityDeploymentScanProbe.Broken"); + try + { + var assembly = Assembly.LoadFrom(path); + + var exception = Assert.Throws(() => CapabilityAssembly.DiscoverModules(new[] { assembly, })); + Assert.Contains("CapabilityDeploymentScanProbe.Broken", exception.Message); + } + finally + { + File.Delete(path); + } + } + + /// + /// 发射一个实现 的最小能力模块程序集并保存到指定路径(identity 不进 deps.json, + /// 构造"裸 drop dll"部署形态)。 + /// + /// 保存路径。 + /// 程序集 identity 名。 + /// 模块实现类型全名。 + /// manifest 能力 Id。 + private static void EmitSingleModuleAssembly(string path, string assemblyIdentityName, string moduleTypeName, string manifestId) + { + var assemblyName = new AssemblyName(assemblyIdentityName); + var assemblyBuilder = new PersistedAssemblyBuilder(assemblyName, typeof(object).Assembly); + var moduleBuilder = assemblyBuilder.DefineDynamicModule(assemblyIdentityName); + var typeBuilder = moduleBuilder.DefineType(moduleTypeName, TypeAttributes.Public | TypeAttributes.Class, typeof(object), new[] { typeof(ICapabilityModule), }); + var manifestField = typeBuilder.DefineField("manifest", typeof(CapabilityManifest), FieldAttributes.Private); + + var constructorBuilder = typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var constructorGenerator = constructorBuilder.GetILGenerator(); + // Object 的构造器在 .NET 10 反射面不可取(GetConstructor 返回 null);Reflection.Emit 生成的类型不做 IL 校验,直接省略基类构造调用。 + constructorGenerator.Emit(OpCodes.Ldarg_0); + constructorGenerator.Emit(OpCodes.Ldstr, manifestId); + constructorGenerator.Emit(OpCodes.Ldstr, "1.0.0"); + constructorGenerator.Emit(OpCodes.Ldnull); + constructorGenerator.Emit(OpCodes.Ldnull); + constructorGenerator.Emit(OpCodes.Ldnull); + constructorGenerator.Emit(OpCodes.Ldnull); + constructorGenerator.Emit(OpCodes.Newobj, typeof(CapabilityManifest).GetConstructor(new[] { typeof(string), typeof(string), typeof(System.Collections.Generic.IReadOnlyList), typeof(System.Collections.Generic.IReadOnlyList), typeof(System.Collections.Generic.IReadOnlyList), typeof(Type), })); + constructorGenerator.Emit(OpCodes.Stfld, manifestField); + constructorGenerator.Emit(OpCodes.Ret); + + var manifestProperty = typeBuilder.DefineProperty("Manifest", PropertyAttributes.None, typeof(CapabilityManifest), null); + var manifestGetter = typeBuilder.DefineMethod("get_Manifest", MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.NewSlot | MethodAttributes.Virtual, typeof(CapabilityManifest), Type.EmptyTypes); + var manifestGetterGenerator = manifestGetter.GetILGenerator(); + manifestGetterGenerator.Emit(OpCodes.Ldarg_0); + manifestGetterGenerator.Emit(OpCodes.Ldfld, manifestField); + manifestGetterGenerator.Emit(OpCodes.Ret); + manifestProperty.SetGetMethod(manifestGetter); + typeBuilder.DefineMethodOverride(manifestGetter, typeof(ICapabilityModule).GetProperty("Manifest").GetGetMethod()); + + var registerMethod = typeBuilder.DefineMethod("Register", MethodAttributes.Public | MethodAttributes.NewSlot | MethodAttributes.Virtual, typeof(void), new[] { typeof(CapabilityRegistrationContext), }); + registerMethod.GetILGenerator().Emit(OpCodes.Ret); + typeBuilder.DefineMethodOverride(registerMethod, typeof(ICapabilityModule).GetMethod("Register")); + + var activateMethod = typeBuilder.DefineMethod("ActivateAsync", MethodAttributes.Public | MethodAttributes.NewSlot | MethodAttributes.Virtual, typeof(Task), new[] { typeof(CapabilityActivationContext), typeof(System.Threading.CancellationToken), }); + var activateGenerator = activateMethod.GetILGenerator(); + activateGenerator.Emit(OpCodes.Call, typeof(Task).GetProperty("CompletedTask").GetGetMethod()); + activateGenerator.Emit(OpCodes.Ret); + typeBuilder.DefineMethodOverride(activateMethod, typeof(ICapabilityModule).GetMethod("ActivateAsync")); + + var deactivateMethod = typeBuilder.DefineMethod("DeactivateAsync", MethodAttributes.Public | MethodAttributes.NewSlot | MethodAttributes.Virtual, typeof(Task), new[] { typeof(System.Threading.CancellationToken), }); + var deactivateGenerator = deactivateMethod.GetILGenerator(); + deactivateGenerator.Emit(OpCodes.Call, typeof(Task).GetProperty("CompletedTask").GetGetMethod()); + deactivateGenerator.Emit(OpCodes.Ret); + typeBuilder.DefineMethodOverride(deactivateMethod, typeof(ICapabilityModule).GetMethod("DeactivateAsync")); + + typeBuilder.CreateType(); + assemblyBuilder.Save(path); + } + + /// + /// 发射一个公共类型引用"未保存的瞬态程序集类型"的程序集:加载后 GetTypes 因依赖缺失抛 + /// (复现依赖残缺的能力包形态)。 + /// + /// 保存路径。 + /// 程序集 identity 名。 + private static void EmitAssemblyWithUnresolvableReference(string path, string assemblyIdentityName) + { + var transientAssembly = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("CapabilityDeploymentScanProbe.Missing"), AssemblyBuilderAccess.Run); + var transientModule = transientAssembly.DefineDynamicModule("CapabilityDeploymentScanProbe.Missing"); + var missingType = transientModule.DefineType("CapabilityDeploymentScanProbe.MissingCarrier", TypeAttributes.Public | TypeAttributes.Class); + + var assemblyBuilder = new PersistedAssemblyBuilder(new AssemblyName(assemblyIdentityName), typeof(object).Assembly); + var moduleBuilder = assemblyBuilder.DefineDynamicModule(assemblyIdentityName); + var typeBuilder = moduleBuilder.DefineType(assemblyIdentityName + ".DependentType", TypeAttributes.Public | TypeAttributes.Class, typeof(object), new[] { missingType, }); + typeBuilder.DefineDefaultConstructor(MethodAttributes.Public); + typeBuilder.CreateType(); + assemblyBuilder.Save(path); + } +}