Skip to content

Magic Game Harness — 架构守门(Architecture Enforcement) ​

源码位置:Assets/Tests/GameFramework/EditMode/Architecture/ 2 个文件 / ~450 行 这是用代码守 spec 的"测试宪兵"——把架构约束从文档变成 CI 红线。


0. 为什么需要架构守门测试? ​

spec 第 8 节画了 13 个 AOT 程序集的依赖图。但纸上的图不会自己守住自己。

考虑这些"无意中的违规":

违规后果
有人给 Game.Core.Primitives 加了对 Game.Core.Diagnostics 的引用破坏"Primitives 零依赖"约束
有人给 Game.ModApi 加了对 VContainer 的引用Mod 作者需要编译 VContainer
有人把 IUnityMainThreadGuard 改成 publicSpec 明确说它必须 internal
有人给 ModApi 加了 class GameplayManager破坏"genre-neutral"约束
有人偷偷把 Game.Bootstrap 升级 VContainer 1.20锁版本测试立刻红

没有架构测试——这些违规要等"代码 review 时被发现"或"出 bug 后才被发现"。 有了架构测试——CI 立刻红,PR 不能合并。


1. 两个测试文件覆盖的 5 个维度 ​

测试文件行数维度
AssemblyDependencyTests.cs143① asmdef 依赖图、② acyclic 检测、③ 第三方包白名单、④ 包版本锁、⑤ SDK 分发包内容
PublicApiSurfaceTests.cs303⑥ public surface 类型白名单、⑦ service-locator 反模式检测、⑧ genre-neutral 检测、⑨ private 类型未泄漏、⑩ 构造器签名

加起来 10 个架构约束,全部用 NUnit Assert 表达。


2. 维度一:依赖方向(ExpectedReferences 白名单) ​

测试:Core_assembly_references_follow_the_approved_dependency_direction

csharp
static readonly Dictionary<string, string[]> ExpectedReferences = new Dictionary<string, string[]>
{
    ["Game.Core.Primitives"] = Array.Empty<string>(),
    ["Game.ModApi"] = new[] { "Game.Core.Primitives" },
    ["Game.Core.Diagnostics"] = new[] { "Game.Core.Primitives" },
    ["Game.Core.Context"] = new[] { "Game.Core.Primitives", "Game.ModApi" },
    ["Game.Bootstrap"] = new[]
    {
        "Game.Core.Primitives", "Game.ModApi", "Game.Core.Context", "Game.Core.Rules",
        "Game.Core.Networking", "Game.Core.Persistence", "Game.Core.HotUpdate", "Game.Core.Diagnostics", "VContainer",
    },
};

[Test]
public void Core_assembly_references_follow_the_approved_dependency_direction()
{
    var definitions = LoadFrameworkDefinitions();
    foreach (var expected in ExpectedReferences)
    {
        Assert.That(definitions, Contains.Key(expected.Key));
        Assert.That(definitions[expected.Key].references, Is.EqualTo(expected.Value), expected.Key);
    }

    foreach (var definition in definitions.Values.Where(value => value.name.StartsWith("Game.Core.", StringComparison.Ordinal)))
        Assert.That(definition.references, Does.Not.Contain("Game.Bootstrap"), definition.name);
}

两个断言:

(a) 期望引用必须完全等于白名单 ​

csharp
Assert.That(definitions[expected.Key].references, Is.EqualTo(expected.Value), expected.Key);
// ↑ 集合完全相同,不是子集!

Is.EqualTo 而不是 Is.SubsetOf——因为依赖方向是严格单向,多一个都不行。

(b) 所有 Game.Core.* 都不能引用 Game.Bootstrap ​

csharp
foreach (var definition in definitions.Values.Where(value => value.name.StartsWith("Game.Core.", StringComparison.Ordinal)))
    Assert.That(definition.references, Does.Not.Contain("Game.Bootstrap"), definition.name);

Bootstrap 是"叶子"——只能被外部引用,不能反过来依赖 Core。

2.1 如何加载 asmdef JSON? ​

csharp
static Dictionary<string, AsmdefData> LoadFrameworkDefinitions()
{
    var root = Path.GetFullPath(Path.Combine(Application.dataPath, "GameFramework"));
    return Directory.GetFiles(root, "*.asmdef", SearchOption.AllDirectories)
        .Select(path => JsonUtility.FromJson<AsmdefData>(File.ReadAllText(path)))
        .ToDictionary(value => value.name);
}

[Serializable]
sealed class AsmdefData
{
    public string name;
    public string[] references;
}

3 个步骤:

  1. 遍历 Assets/GameFramework/**/*.asmdef
  2. 用 JsonUtility.FromJson 解析——Unity 内置的 JSON 解析器,无需 Newtonsoft
  3. [Serializable] 标 AsmdefData——JsonUtility 只认 [Serializable] 字段

为什么不用 System.Text.Json? 因为它在 Unity 6 之前可能不可用,且需要 NuGet 包。JsonUtility 是 Unity 默认可用。


3. 维度二:依赖图必须 acyclic ​

测试:Project_assembly_graph_is_acyclic

csharp
[Test]
public void Project_assembly_graph_is_acyclic()
{
    var definitions = LoadFrameworkDefinitions();
    var states = new Dictionary<string, int>();
    foreach (var name in definitions.Keys) Visit(name, definitions, states);
}

static void Visit(string name, IReadOnlyDictionary<string, AsmdefData> definitions, IDictionary<string, int> states)
{
    if (states.TryGetValue(name, out var state))
    {
        if (state == 1) Assert.Fail($"Assembly dependency cycle detected at {name}.");
        if (state == 2) return;
    }
    states[name] = 1;        // ← 灰色(visiting)
    foreach (var dependency in definitions[name].references ?? Array.Empty<string>())
        if (definitions.ContainsKey(dependency)) Visit(dependency, definitions, states);
    states[name] = 2;        // ← 黑色(done)
}

3.1 三色 DFS 算法 ​

这是经典的三色 DFS 环检测算法:

颜色状态值
⚪ 白未访问不在 states 里
⚫ 灰正在访问(栈上)states[name] == 1
⚪ 黑访问完states[name] == 2

环检测:

  • DFS 走到一个灰色节点 → 这是 back edge → 环!
  • DFS 走到黑色节点 → 已访问,跳过
  • DFS 走到白色节点 → 继续深入

3.2 为什么不用现成的图算法库? ​

理由:

  • NUnit 跑在 Unity Test Runner 里——依赖要受控
  • 这个算法 12 行——加库是 overkill
  • 测试的代码也要被测试守门——Visit 函数自己也很简单,自己看自己即可

3.3 Assert.Fail vs throw ​

csharp
Assert.Fail($"Assembly dependency cycle detected at {name}.");

故意用 Assert.Fail——而不是 throw new Exception(...)——因为 NUnit 框架会捕获并报告(红 ✗),而裸抛异常可能不被识别为"测试失败"。


4. 维度三:第三方包隔离 ​

4.1 公共契约层不能依赖第三方包 ​

测试:Contract_assemblies_declare_no_third_party_async_or_container_dependency

csharp
[Test]
public void Contract_assemblies_declare_no_third_party_async_or_container_dependency()
{
    var definitions = LoadFrameworkDefinitions();
    var forbidden = new[] { "UniTask", "Cysharp", "VContainer", "Unity.Netcode", "Addressables", "HybridCLR" };

    foreach (var name in new[] { "Game.Core.Primitives", "Game.ModApi", "Game.Core.Diagnostics" })
    {
        Assert.That(definitions, Contains.Key(name));
        foreach (var reference in definitions[name].references ?? Array.Empty<string>())
            foreach (var fragment in forbidden)
                Assert.That(
                    reference,
                    Does.Not.Contain(fragment).IgnoreCase,
                    $"{name} must not reference {fragment}.");
    }
}

3 个 contract 程序集 × 6 个 forbidden 包 = 18 个断言。

IgnoreCase — "UniTask" 和 "unitask" 都拒掉。

4.2 Bootstrap 允许 VContainer,不允许 UniTask ​

测试:Bootstrap_declares_no_third_party_async_dependency

csharp
[Test]
public void Bootstrap_declares_no_third_party_async_dependency()
{
    var definitions = LoadFrameworkDefinitions();
    Assert.That(definitions, Contains.Key("Game.Bootstrap"));

    foreach (var reference in definitions["Game.Bootstrap"].references ?? Array.Empty<string>())
        foreach (var fragment in new[] { "UniTask", "Cysharp" })
            Assert.That(
                reference,
                Does.Not.Contain(fragment).IgnoreCase,
                $"Game.Bootstrap must not reference {fragment}.");
}

只查 UniTask 和 Cysharp(UniTask 的命名空间)——不查 VContainer,因为 Bootstrap 故意引用 VContainer。

为什么 Bootstrap 可以引 VContainer?因为 Bootstrap 是 private 实现层(不进 Mod SDK),它的依赖只影响主程序,不影响 Mod 作者。

4.3 注释解释了为什么 ​

csharp
/// <summary>
/// The contract assemblies must stay free of third-party async and container dependencies.
/// UniTask may be used later inside private implementation assemblies, but never here: a Mod
/// author must not have to compile against a specific UniTask version to implement the
/// public lifecycle contract.
/// </summary>

注释直接引用了 spec 第 1 节的目标——Mod 作者不应该被迫 import UniTask。这条规则不是作者偏好,是 spec 要求。


5. 维度四:包版本锁 ​

测试:Relevant_package_versions_are_unchanged

csharp
[Test]
public void Relevant_package_versions_are_unchanged()
{
    var packages = PackageInfo.GetAllRegisteredPackages().ToDictionary(package => package.name, package => package.version);
    Assert.That(packages["jp.hadashikick.vcontainer"], Is.EqualTo("1.19.0"));
    Assert.That(packages["com.unity.test-framework"], Is.EqualTo("1.6.0"));
    Assert.That(packages["com.unity.addressables"], Is.EqualTo("2.9.1"));
    Assert.That(packages["com.unity.netcode.gameobjects"], Is.EqualTo("2.13.1"));
    Assert.That(packages["com.unity.pipeline"], Is.EqualTo("0.5.0-exp.1"));
}

5 个包 × 1 个版本断言 = 5 个硬编码版本号。

为什么锁版本:

  • VContainer 1.19.0 → 1.20.0:可能引入 breaking change
  • Netcode 2.13.1 → 2.14.0:自定义消息系统可能变
  • HybridCLR:当前没引用,但若升级会破坏 AOT 兼容性

PackageInfo.GetAllRegisteredPackages() — Unity API,直接读 Unity PackageManager——比读 Packages/manifest.json 更可靠(manifest 可能脱节于实际安装)。

5.1 锁版本是双刃剑 ​

好处:

  • 防止"昨天还工作的代码今天坏了"
  • CI 红 → 知道是版本更新引入的回归

坏处:

  • 必须手动更新版本号(不能自动 bump)
  • 如果忽略测试,版本会被锁死,新版本修的 bug 进不来

正确做法:升级包时同步更新这个测试的期望值,并在 PR 描述里说明升级原因。


6. 维度五:SDK 分发边界 ​

测试:Distributed_sdk_contains_no_private_source_or_contract_binaries

csharp
[Test]
public void Distributed_sdk_contains_no_private_source_or_contract_binaries()
{
    var root = Path.GetFullPath(Path.Combine(Application.dataPath, "../Packages/com.mimizh.game-mod-sdk"));
    var sources = Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories);
    var forbiddenArtifacts = Directory.GetFiles(root, "*.*", SearchOption.AllDirectories)
        .Where(path => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));

    Assert.That(sources.Select(Path.GetFileName), Is.EquivalentTo(new[] { "AssemblyAnchor.cs", "AssemblyAnchor.cs" }));
    Assert.That(forbiddenArtifacts, Is.Empty);
    Assert.That(sources.Any(path => path.Contains("GameFramework")), Is.False);
}

6.1 3 个断言 ​

(a) SDK 里只有 AssemblyAnchor.cs ​

csharp
Assert.That(sources.Select(Path.GetFileName), Is.EquivalentTo(new[] { "AssemblyAnchor.cs", "AssemblyAnchor.cs" }));
//                                                                ↑ 注意:两个,因为 Templates~/ 或 Tests/ 都有同名文件

Is.EquivalentTo 而不是 Is.EqualTo——不要求顺序。

(b) 不能有 .dll 或 .xml ​

csharp
Assert.That(forbiddenArtifacts, Is.Empty);

禁止预先编译的 DLL 在 SDK 包里——因为 Mod 作者应该自己编译,SDK 提供的 DLL 会被作者覆盖。

(c) 不能含 GameFramework 路径 ​

csharp
Assert.That(sources.Any(path => path.Contains("GameFramework")), Is.False);

防止有人把 Assets/GameFramework 的源码意外拷到 SDK 包。

6.2 测试体现 spec 第 8.1 节 ​

spec 第 8.1 节说 SDK 必须不含:

  • Game.Core.Context
  • Game.Core.Rules
  • Game.Core.Networking
  • Game.Core.Persistence
  • Game.Core.HotUpdate
  • Game.Core.Diagnostics
  • Game.Bootstrap

当前实现方式:SDK 包里只有 AssemblyAnchor(空 anchor),任何额外的源码都会让 sources.Select(Path.GetFileName) 失败。

未来实现方式:SDK 包里会放 Game.Core.Primitives.dll、Game.ModApi.dll 和 XML doc——测试要相应更新(放过这两个 DLL)。


7. 维度六:API surface 白名单(Allowlist) ​

测试:Public_mod_api_signatures_use_only_BCL_and_public_contract_types

csharp
static readonly string[] ForbiddenFragments =
{
    "VContainer", "UnityEngine", "Cysharp", "UniTask", "Unity.Netcode",
    "Unity.Addressables", "HybridCLR",
    "Game.Core.Context", "Game.Core.Diagnostics", "Game.Bootstrap",
};

static readonly string[] AllowedAssemblies =
{
    "Game.Core.Primitives",
    "Game.ModApi",

    // The BCL, across the runtimes Unity may report for these types.
    "mscorlib", "System", "System.Core", "System.Private.CoreLib", "System.Runtime",
    "System.Collections", "System.Threading", "System.Threading.Tasks", "netstandard",
};

[Test]
public void Public_mod_api_signatures_use_only_BCL_and_public_contract_types()
{
    var assembly = typeof(IModule).Assembly;
    foreach (var type in assembly.GetExportedTypes())
    {
        AssertTypeIsAllowed(type);
        foreach (var memberType in GetPublicMemberTypes(type))
            AssertTypeIsAllowed(memberType);
    }
}

7.1 Allowlist vs Blacklist 的设计哲学 ​

csharp
/// <summary>
/// The only assemblies a public Mod API signature may draw types from. This is an allowlist
/// on purpose: a blacklist would need a new entry for every third-party package added to the
/// project, and would silently pass the first time one was missed.
/// </summary>
static readonly string[] AllowedAssemblies = { ... };

Allowlist 的好处:

  • 添加新第三方包时默认不允许("fail-safe")
  • 引入前必须显式加白
  • 每次 review 都看得见

Blacklist 的坏处:

  • 添加新包时默认允许
  • 如果忘了加黑名单,默默通过
  • 一旦泄漏,回滚需要重新加黑名单

AllowedAssemblies + ForbiddenFragments 双层防御——allowlist 是"白名单"(必须在这几个里),forbidden 是"已知坏人"(这些字符串绝对不能出现)。

7.2 遍历所有 public 类型 ​

csharp
foreach (var type in assembly.GetExportedTypes())
{
    AssertTypeIsAllowed(type);
    foreach (var memberType in GetPublicMemberTypes(type))
        AssertTypeIsAllowed(memberType);
}

static IEnumerable<Type> GetPublicMemberTypes(Type type)
{
    foreach (var constructor in type.GetConstructors())
        foreach (var parameter in constructor.GetParameters())
            yield return parameter.ParameterType;
    foreach (var property in type.GetProperties())
        yield return property.PropertyType;
    foreach (var method in type.GetMethods().Where(method => !method.IsSpecialName))
    {
        yield return method.ReturnType;
        foreach (var parameter in method.GetParameters())
            yield return parameter.ParameterType;
    }
}

3 个反射检查:

  • 构造器参数类型
  • 属性类型
  • 方法返回类型 + 参数类型

!method.IsSpecialName — 排除 op_Equality、get_Item 这种编译器生成的特殊方法(避免噪音)。

7.3 类型展开(数组、指针、泛型) ​

csharp
static void AssertTypeIsAllowed(Type type)
{
    if (type == null) return;
    if (type.IsByRef || type.IsPointer || type.IsArray)
    {
        AssertTypeIsAllowed(type.GetElementType());  // ← 递归剥开
        return;
    }
    if (type.IsGenericParameter) return;             // ← T 这种 generic param 不查
    if (type.IsGenericType)
    {
        foreach (var argument in type.GetGenericArguments())
            AssertTypeIsAllowed(argument);            // ← 泛型参数也要查
        type = type.GetGenericTypeDefinition();       // ← List<int> → List<>
    }

    var fullName = type.FullName ?? type.Name;
    var assembly = type.Assembly.GetName().Name;
    Assert.That(AllowedAssemblies, Contains.Item(assembly), $"...");
    foreach (var forbidden in ForbiddenFragments)
        Assert.That(fullName, Does.Not.Contain(forbidden), $"...");
}

5 个边界:

  1. null — void 等
  2. IsByRef / IsPointer / IsArray — 剥开 element type
  3. IsGenericParameter — T(开放泛型)
  4. IsGenericType — List<int> 要查 List<> 也要查 int
  5. FullName ?? Name — 有些 dynamic 类型没有 FullName

8. 维度七:service-locator 反模式检测 ​

测试:Module_context_is_not_an_unrestricted_service_locator

csharp
[Test]
public void Module_context_is_not_an_unrestricted_service_locator()
{
    var methods = typeof(IModuleContext).GetMethods();
    Assert.That(methods.Any(method => method.Name.StartsWith("Resolve", StringComparison.Ordinal)), Is.False);
    Assert.That(methods.Any(method => method.IsGenericMethod), Is.False);
}

2 个断言:

断言阻止的反模式
无方法名以 Resolve 开头Resolve<T>() service locator 模式
无泛型方法Resolve<T>(string key) 这种"按 key 查"反模式

为什么禁? spec 第 6.1 节"Stable contracts, replaceable implementations"——Mod 作者不应该自己解析服务,而是通过具体的能力注册接口。如果 IModuleContext 有 Resolve<T>,Mod 就会开始直接拿 Context Runtime 的内部服务,导致循环依赖。


9. 维度八:Genre-neutral 词表 ​

测试:Public_contract_vocabulary_is_genre_and_product_neutral

csharp
[Test]
public void Public_contract_vocabulary_is_genre_and_product_neutral()
{
    var forbidden = new[]
    {
        "Gameplay", "Survival", "Card", "Board", "Rpg", "Strategy", "World",
        "Inventory", "Recipe", "Building", "OfficialContent", "ProductModule",
    };
    foreach (var type in typeof(IModule).Assembly.GetExportedTypes())
    {
        foreach (var word in forbidden)
        {
            Assert.That(type.FullName, Does.Not.Contain(word).IgnoreCase, type.FullName);
            foreach (var member in type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
                Assert.That(member.Name, Does.Not.Contain(word).IgnoreCase, $"{type.FullName}.{member.Name}");
        }
    }
}

12 个 forbidden word × 所有 public type 全名 × 所有 public member 名字 = 几十个断言。

BindingFlags.DeclaredOnly — 只查直接声明的成员,不查继承的(避免噪音)。

12 个词的来源:

  • 类型:Gameplay、Survival、Card、Board、Rpg、Strategy、World、Inventory、Recipe、Building — 都是游戏类型词汇
  • 产品:OfficialContent、ProductModule — spec 禁止的内容

IgnoreCase — GamePlay 和 gameplay 都不允许。

9.1 为什么需要这个测试? ​

spec 第 1 节:

The framework deliverable intentionally contains no genre implementation, gameplay skeleton, official gameplay mod, or community mod.

Genre-neutral 是 spec 的核心要求——如果有人在 ModApi 里加了 IInventoryManager,spec 就破了。

测试用词表方式——简单暴力,但有效。


10. 维度九:Private 类型未泄漏 ​

10.1 SessionLifetime 必须 private ​

测试:Session_lifetime_and_cancellation_stay_private_to_bootstrap

csharp
[Test]
public void Session_lifetime_and_cancellation_stay_private_to_bootstrap()
{
    var bootstrap = typeof(Game.Bootstrap.ApplicationLifecycleCoordinator).Assembly;
    var lifetime = bootstrap.GetType("Game.Bootstrap.Session.ISessionLifetime");
    var implementation = bootstrap.GetType("Game.Bootstrap.Session.SessionLifetime");

    Assert.That(lifetime, Is.Not.Null, "The Session lifetime contract must exist.");
    Assert.That(implementation, Is.Not.Null);
    Assert.That(lifetime.IsPublic, Is.False, "The Session lifetime contract must stay private.");
    Assert.That(implementation.IsPublic, Is.False);

    Assert.That(
        typeof(IModule).Assembly.GetExportedTypes().Any(type => type.Name.Contains("SessionLifetime")),
        Is.False,
        "The Session lifetime must not be exported from the Mod API.");
    Assert.That(
        bootstrap.GetExportedTypes().Any(type => type.Name.Contains("SessionLifetime")),
        Is.False,
        "The Session lifetime must not be publicly exported at all.");
}

5 个断言:

断言守住什么
lifetime != null类型必须存在
lifetime.IsPublic == false不能变 public
implementation.IsPublic == false同上
ModApi 不导出含 SessionLifetime 的类型防泄漏到 Mod SDK
Bootstrap 不导出含 SessionLifetime 的类型防 private 变 public

双重检查:

  1. 存在性(Not.Null)
  2. 可见性(Is.Public == false)
  3. 跨程序集导出(GetExportedTypes)

10.2 UnityMainThreadGuard 必须 private ​

测试:Unity_main_thread_guard_stays_private_to_bootstrap

csharp
[Test]
public void Unity_main_thread_guard_stays_private_to_bootstrap()
{
    var bootstrap = typeof(Game.Bootstrap.ApplicationLifecycleCoordinator).Assembly;
    var contract = bootstrap.GetType("Game.Bootstrap.Threading.IUnityMainThreadGuard");
    var implementation = bootstrap.GetType("Game.Bootstrap.Threading.UnityMainThreadGuard");

    Assert.That(contract, Is.Not.Null);
    Assert.That(implementation, Is.Not.Null);
    Assert.That(contract.IsPublic, Is.False, "The guard contract must stay private to Game.Bootstrap.");
    Assert.That(implementation.IsPublic, Is.False, "The guard implementation must stay private to Game.Bootstrap.");
    Assert.That(
        contract.IsAssignableFrom(implementation),
        Is.True,
        "The implementation must satisfy the guard contract.");

    foreach (var assembly in new[] { typeof(IModule).Assembly, typeof(Game.Core.Primitives.SemanticVersion).Assembly, bootstrap })
        Assert.That(
            assembly.GetExportedTypes().Any(type => type.Name.Contains("MainThreadGuard")),
            Is.False,
            $"{assembly.GetName().Name} must not export the main-thread guard.");
}

新增:contract.IsAssignableFrom(implementation) — 确保实现满足接口(不能只是空 anchor)。


11. 维度十:构造器签名守门 ​

测试:Application_root_owns_an_explicit_session_shutdown_path

csharp
[Test]
public void Application_root_owns_an_explicit_session_shutdown_path()
{
    var coordinator = typeof(Game.Bootstrap.ApplicationLifecycleCoordinator);

    var shutdown = coordinator.GetMethod("ShutdownAsync");
    Assert.That(shutdown, Is.Not.Null);
    Assert.That(shutdown.ReturnType, Is.EqualTo(typeof(Task)));
    Assert.That(
        shutdown.GetParameters().Single().ParameterType,
        Is.EqualTo(typeof(System.Threading.CancellationToken)));

    Assert.That(
        typeof(IDisposable).IsAssignableFrom(coordinator),
        Is.True,
        "The App root must still provide the synchronous disposal fallback.");

    // The composition constructor is internal: it takes the private main-thread guard
    var constructor = coordinator
        .GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
        .Single();
    Assert.That(
        constructor.GetParameters().Any(parameter => parameter.ParameterType == typeof(Game.Bootstrap.Session.SessionFactory)),
        Is.True,
        "The App root must depend on the Session factory so it can stop the active Session.");
    Assert.That(
        coordinator.GetConstructors(),
        Is.Empty,
        "The application root must not be publicly constructible.");
}

6 个断言:

断言守住什么
ShutdownAsync 存在不能删掉
返回 Task不能换成 UniTask
单一参数 CancellationToken不能换成自定义类型
实现 IDisposable必须有同步 fallback
构造器参数含 SessionFactory必须能 stop active session
GetConstructors() (只看 public) 为空public 构造器不能存在(必须是 internal)

11.1 为什么"public 构造器为空"是断言? ​

csharp
Assert.That(
    coordinator.GetConstructors(),
    Is.Empty,
    "The application root must not be publicly constructible.");

GetConstructors() 不带 BindingFlags 时只返回 public 构造器。空 = 没有 public 构造器。

这强制 ApplicationLifecycleCoordinator 的构造器必须是 internal——这样 VContainer 才能通过 RegisterEntryPoint 构造它,但外部代码不能 new 它。

对比:

csharp
GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Single();
// ↑ 包含 internal,所以 Single() = 1
// ↑ 这是为了下一个断言(拿构造器参数)

GetConstructors()
// ↑ 只 public,所以 Empty()
// ↑ 验证没有 public 构造器

12. 测试密度与覆盖 ​

测试守门维度断言数量(估算)
Core_assembly_references_follow_the_approved_dependency_direction依赖方向~20
Project_assembly_graph_is_acyclicacyclic1(DFS 调用)
Contract_assemblies_declare_no_third_party_async_or_container_dependency第三方包~18
Bootstrap_declares_no_third_party_async_dependency第三方包~2
Relevant_package_versions_are_unchanged版本锁5
Distributed_sdk_contains_no_private_source_or_contract_binariesSDK 边界3
Public_mod_api_signatures_use_only_BCL_and_public_contract_typesAPI surface~50
Module_context_is_not_an_unrestricted_service_locator反模式2
Module_lifecycle_uses_standard_repeatable_Task_contractsAsync policy2
Public_contract_vocabulary_is_genre_and_product_neutralGenre-neutral~100
Public_contract_assemblies_do_not_depend_on_UniTaskAsync policy6
Session_lifetime_and_cancellation_stay_private_to_bootstrapPrivate leak5
Unity_main_thread_guard_stays_private_to_bootstrapPrivate leak8
Enforcing_the_main_thread_policy_introduced_no_UniTask_dependencyAsync policy8
Application_root_owns_an_explicit_session_shutdown_path构造器签名6

~240 个架构断言——用代码守住 spec 的每一行。


13. 这套测试可以学什么? ​

13.1 三种"读取约束"的方法 ​

约束来源读取方式测试方法
asmdef 依赖文件系统 + JSON 解析JsonUtility.FromJson<AsmdefData>
运行时包Unity PackageInfo APIPackageInfo.GetAllRegisteredPackages()
API surface.NET 反射Assembly.GetExportedTypes()
private 可见性.NET 反射Type.IsPublic
构造器签名.NET 反射GetConstructors() / GetParameters()
方法签名.NET 反射GetMethod / GetParameters
SDK 文件文件系统Directory.GetFiles
genre 词表字符串匹配Does.Not.Contain

13.2 测试断言的精确度 ​

csharp
// ❌ 太松:
Assert.That(references.Length, Is.GreaterThan(0));

// ✅ 太严(白名单完整匹配):
Assert.That(definitions[expected.Key].references, Is.EqualTo(expected.Value), expected.Key);

严格匹配(Is.EqualTo) 是架构测试的正确姿势——你必须精确知道依赖是什么,不能模糊。

13.3 自描述的失败消息 ​

csharp
Assert.That(
    reference,
    Does.Not.Contain(fragment).IgnoreCase,
    $"{name} must not reference {fragment}.");

第三参数是失败消息——失败时 NUnit 会显示:"Game.ModApi must not reference UniTask."——比默认的"Expected: not contain 'UniTask' but: ..."可读得多。

13.4 Allowlist vs Blacklist 的选择 ​

场景用 Allowlist用 Blacklist
API surface✅(默认不允许新包)❌
genre 词表❌(无限词汇)✅("Gameplay" 等明确禁止)
第三方包✅(contract 层:只允许 BCL + Primitives + ModApi)✅(Bootstrap:只禁止 UniTask)

原则:

  • 防止意外的依赖→ Allowlist(fail-safe)
  • 防止已知错误→ Blacklist(cheap second signal)

PublicApiSurfaceTests 用 allowlist + blacklist两层——这是好做法。


14. 我对架构守门测试的整体评价 ​

优点 ​

  1. 10 个维度全覆盖——依赖方向、版本、API surface、private 泄漏、反模式、genre、构造器、SDK 边界
  2. Allowlist 为主——fail-safe,新依赖默认禁止
  3. 失败消息自描述——grep 友好的错误消息
  4. 反射 + 白名单 + 黑名单三层——任何一处漏掉都被另一处抓住
  5. 测试代码本身就是文档——读完测试就知道 spec 怎么落到代码

局限与可改进点 ​

  1. AssemblyDependencyTests 只检查 5 个程序集——Rules/Networking/Persistence/HotUpdate 还都是空 anchor。等 Context Runtime 实现后这测试会自动覆盖。
  2. Genre_vocabulary 黑名单可能漏掉——比如加 Mmo 或 Fps 这种缩写,黑名单没列。需要每次新游戏类型时手动加。
  3. 测试依赖 Unity Editor API(Application.dataPath、PackageInfo)——不能纯 .NET runner 跑。
  4. Visit 函数的 cycle detection 不抛具体异常——只 Assert.Fail。可以换成 throw new InvalidOperationException 让 CI 抓得更明确。
  5. 没有"过期测试"提醒——比如 package 版本锁过期了,没人主动改测试就永远过期。

可借鉴的设计模式 ​

模式适用学习难度
三色 DFS 环检测任何"图必须无环"的约束(依赖、状态机、schema)🟢 简单
反射 + Allowlist 守 API任何 SDK / 公共包🟡 中等
Genre-neutral 词表任何"通用框架"项目🟢 简单
包版本锁任何依赖第三方库的项目🟢 简单
构造器签名守门任何"组合根"模式🟢 简单
Is.EqualTo 严格匹配任何"必须严格等于"的约束🟢 简单

15. 与 spec 对照 ​

spec 章节守门测试
8.1 Source Distribution BoundaryDistributed_sdk_contains_no_private_source_or_contract_binaries
8.2 Dependency DirectionCore_assembly_references_follow_the_approved_dependency_direction
11.1 Namespace Policy(genre-neutral)Public_contract_vocabulary_is_genre_and_product_neutral
11.2 Stable ContractsPublic_mod_api_signatures_use_only_BCL_and_public_contract_types
Async Policy (Task vs UniTask)Bootstrap_declares_no_third_party_async_dependency 等 3 个
Main-thread policyEnforcing_the_main_thread_policy_introduced_no_UniTask_dependency

16. 关键 takeaway ​

读完架构守门,最大的认知收获:

架构约束 = 文档约束 + 测试约束。文档约束是 spec,测试约束是 CI。两者必须同步。

具体到这个项目:

  • 10 个架构测试对应 spec 的 10 条架构规则
  • 每个测试有 doc comment 说明 spec 出处
  • 失败消息直接引用 spec 章节——出问题时能 grep 到

这套模式可以应用到:

  • 微服务架构(服务依赖图必须 acyclic)
  • Monorepo(包依赖方向)
  • 插件系统(host API 不能泄漏到插件)
  • SDK 设计(第三方包依赖必须守门)

17. 整套阅读路线图(5 篇笔记合集) ​

读完 5 篇笔记,你对 magic-game-harness-unity 应该有完整的理解:

#笔记核心内容
1项目总览13 个 AOT 程序集依赖图、设计原则、阅读路线
2Primitives 深度解读命名空间 ID、SemVer 2.0.0、Compat ID、Runtime ID、诊断原语
3Bootstrap 架构VContainer 装配、App/Session 状态机、主线程守门、诊断路由
4ModApi 契约11 个 public 类型、Module 加载契约、safe message
5架构守门(本篇)依赖方向、API surface、genre-neutral、第三方包隔离

学习的核心概念(贯穿 5 篇) ​

概念体现位置
readonly struct + 私有构造 + Parse/TryParsePrimitives 全部值对象
FNV-1a + Ordinal 比较跨进程稳定的 hash/比较
状态机 + memoization + 双层失败Session、ApplicationLifecycleCoordinator
锁持有时间最小化Session.RunStop / ApplicationLifecycleCoordinator.RunShutdown
Allowlist vs BlacklistAPI surface 守门
Defense in depthUnityMainThreadGuard 三层守门
构造时验证 + 默认值无效所有 readonly struct 的 IsValid
"safe caller-facing"错误模型ModuleOperationResult + ModuleOperationError
测试守门10 个架构测试 = 240 个断言

学习曲线建议 ​

阶段时间投入完成度
读完 5 篇笔记~3 小时100% 概念理解
把 Primitives 单独跑测试~1 小时验证理解
试着给 Primitives 加新 ID 类型~2 小时实践模式
给 Session 加一个新状态~3 小时实践状态机
试着让 Context Runtime 解析 capability~10 小时触及下一个 plan

参考链接 ​


系列完结。这 5 篇笔记覆盖了 magic-game-harness-unity 当前的所有实现(脚手架 + Primitives + Bootstrap + ModApi + 架构守门),让你在不打开 Unity 编辑器的情况下,对这个项目有完整的源码级理解。

Contributors

The avatar of contributor named as root root

Changelog

用 ♥ 撰写