上一节把「状态归谁」钉死了:每份状态只有一个写者。但真实项目里,模块之间总要互相请求——联盟想扣资源,得让城建去扣;任务想发奖励,得让对应模块去发。「谁能请求谁、以什么形式请求」如果不定下来,单写者原则就只是纸面上的。 这一节把模块之间的接口写成契约,并用测试断言它。
先划边界。站内的 SLG 技术架构专题 讲的是技术架构:前后端分层、服务端模块拆分、通信协议设计,明确写了「不讨论玩法数值」。本节讲的不是通信协议(那是技术侧的事),而是产品侧的接口约定——每个模块对外暴露哪些命令、哪些事件、读写哪些状态,用类型接口固定下来。协议是「消息怎么传输」,契约是「模块之间允许做什么」,两者层次不同。
契约是什么:写谁、读谁、暴露什么
一份模块契约至少写清四件事:
| 字段 | 含义 | 作用 |
|---|---|---|
writes | 这个模块写哪些状态 | 声明所有权,越界即违规 |
reads | 这个模块读哪些状态 | 声明依赖,用于算耦合度 |
commands | 对外暴露的命令(名字 + 入参 + 出参类型) | 别的模块只能通过它请求改动 |
events | 对外发出的事件(名字 + 载荷类型) | 别的模块订阅它感知变化 |
关键在于 writes 与上一节的归属表是同一份事实:契约里声明的 writes,必须恰好是归属表里 owner 为该模块的那些状态。两份配置对不上,就说明有人偷偷写了别人的状态。
把契约写成配置
下面这段是 D 层配置:8 个模块各一份契约。注意 city 写两份状态、event 读三家状态,和上一节的归属表完全一致。
{
"version": 1,
"note": "跨模块契约:每个模块声明它写哪些状态(writes)、读哪些状态(reads)、对外暴露哪些命令(commands)与事件(events)。writes 里每份状态的 owner 必须等于该模块;同一份状态不得出现在两个模块的 writes 里。",
"contracts": [
{ "module": "city", "interface": "CityContract",
"writes": ["city.resources", "city.buildings"], "reads": ["account.profile"],
"commands": [{ "name": "build", "in": "BuildInput", "out": "BuildResult" },
{ "name": "upgrade", "in": "UpgradeInput", "out": "UpgradeResult" }],
"events": [{ "name": "BuildingChanged", "payload": "BuildingChangedPayload" }] },
{ "module": "army", "interface": "ArmyContract",
"writes": ["army.troops"], "reads": ["city.resources", "map.tiles"],
"commands": [{ "name": "train", "in": "TrainInput", "out": "TrainResult" },
{ "name": "march", "in": "MarchInput", "out": "MarchResult" }],
"events": [{ "name": "TroopsChanged", "payload": "TroopsChangedPayload" }] },
{ "module": "map", "interface": "MapContract",
"writes": ["map.tiles"], "reads": ["army.troops"],
"commands": [{ "name": "occupy", "in": "OccupyInput", "out": "OccupyResult" },
{ "name": "gather", "in": "GatherInput", "out": "GatherResult" }],
"events": [{ "name": "TileChanged", "payload": "TileChangedPayload" }] },
{ "module": "alliance", "interface": "AllianceContract",
"writes": ["alliance.members"], "reads": ["map.tiles", "account.profile"],
"commands": [{ "name": "create", "in": "CreateInput", "out": "CreateResult" },
{ "name": "help", "in": "HelpInput", "out": "HelpResult" }],
"events": [{ "name": "MemberChanged", "payload": "MemberChangedPayload" }] },
{ "module": "quest", "interface": "QuestContract",
"writes": ["quest.progress"], "reads": ["event.season", "city.buildings"],
"commands": [{ "name": "claim", "in": "ClaimInput", "out": "ClaimResult" }],
"events": [{ "name": "QuestCompleted", "payload": "QuestCompletedPayload" }] },
{ "module": "shop", "interface": "ShopContract",
"writes": ["shop.wallet"], "reads": ["city.resources", "account.profile"],
"commands": [{ "name": "purchase", "in": "PurchaseInput", "out": "PurchaseResult" }],
"events": [{ "name": "Purchased", "payload": "PurchasedPayload" }] },
{ "module": "event", "interface": "EventContract",
"writes": ["event.season"], "reads": ["alliance.members", "shop.wallet", "quest.progress"],
"commands": [{ "name": "enterSeason", "in": "EnterSeasonInput", "out": "EnterSeasonResult" }],
"events": [{ "name": "SeasonAdvanced", "payload": "SeasonAdvancedPayload" }] },
{ "module": "account", "interface": "AccountContract",
"writes": ["account.profile"], "reads": [],
"commands": [{ "name": "login", "in": "LoginInput", "out": "LoginResult" },
{ "name": "sync", "in": "SyncInput", "out": "SyncResult" }],
"events": [] }
]
}
契约校验器
下面这段是 P 层原型代码:把契约配置与状态归属配置对撞,逐条检出越界。它把「同一份状态被两个模块写」单独拎成 DOUBLE_WRITER,把「写了归属别人的状态」拎成 WRITE_OUT_OF_BOUND,并把耦合度算成 fan-out / fan-in。
// file: proto/src/ch05_contract.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 跨模块契约校验(P 层):把「状态归属」与「模块契约」两份配置对撞,
// 检测「同一份状态被两个模块写」「写了不属于自己的状态」「读了不存在的状态」等越界。
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const HERE = dirname(fileURLToPath(import.meta.url));
export const CONFIG_DIR = join(HERE, "..", "..", "config");
export interface Command { name: string; in: string; out: string }
export interface EventDef { name: string; payload: string }
export interface ModuleContract {
module: string;
interface: string;
writes: string[];
reads: string[];
commands: Command[];
events: EventDef[];
}
export interface ContractConfig { version: number; contracts: ModuleContract[] }
export interface StateDef { id: string; name: string; owner: string; writers: string[]; readers: string[] }
export interface StateOwnership { version: number; modules: string[]; states: StateDef[] }
export interface Violation { code: string; target: string; detail: string }
export function loadConfig<T>(name: string): T {
return JSON.parse(readFileSync(join(CONFIG_DIR, `${name}.json`), "utf-8")) as T;
}
/** 契约校验:返回全部越界项,空数组即边界闭合。 */
export function validateContracts(cfg: ContractConfig, ownership: StateOwnership): Violation[] {
const out: Violation[] = [];
const owner = new Map<string, string>();
for (const s of ownership.states) owner.set(s.id, s.owner);
// 1) 模块唯一
const seen = new Set<string>();
for (const c of cfg.contracts) {
if (seen.has(c.module)) out.push({ code: "DUP_MODULE", target: c.module, detail: "同一模块出现多份契约" });
seen.add(c.module);
}
// 2) 同一份状态被两个模块写:跨契约统计写者
const writersOf = new Map<string, string[]>();
for (const c of cfg.contracts) {
for (const w of c.writes) {
const arr = writersOf.get(w) ?? [];
arr.push(c.module);
writersOf.set(w, arr);
}
}
for (const [state, writers] of writersOf) {
if (writers.length > 1) {
out.push({ code: "DOUBLE_WRITER", target: state, detail: `状态被 ${writers.join(" / ")} 同时写` });
}
}
// 3) 逐模块检查写/读的状态是否存在、写是否越界
for (const c of cfg.contracts) {
for (const w of c.writes) {
if (!owner.has(w)) {
out.push({ code: "UNKNOWN_STATE", target: `${c.module}:${w}`, detail: "写入了不存在的状态" });
} else if (owner.get(w) !== c.module) {
out.push({ code: "WRITE_OUT_OF_BOUND", target: `${c.module}:${w}`,
detail: `写了归属 ${owner.get(w)} 的状态` });
}
}
for (const r of c.reads) {
if (!owner.has(r)) out.push({ code: "UNKNOWN_STATE", target: `${c.module}:${r}`, detail: "读取了不存在的状态" });
}
// 4) 命令名在模块内唯一
const cmdSeen = new Set<string>();
for (const cmd of c.commands) {
if (cmdSeen.has(cmd.name)) out.push({ code: "DUP_COMMAND", target: `${c.module}:${cmd.name}`, detail: "命令名重复" });
cmdSeen.add(cmd.name);
}
}
return out;
}
export interface Coupling { module: string; fanOut: number; fanIn: number }
/** 耦合度:fanOut = 它读到的外部状态所归属的不同模块数;fanIn = 读到它拥有的状态的不同模块数。 */
export function coupling(contracts: ModuleContract[], ownership: StateOwnership): Coupling[] {
const owner = new Map<string, string>();
for (const s of ownership.states) owner.set(s.id, s.owner);
return contracts.map((c) => {
const fanOut = new Set<string>();
for (const r of c.reads) {
const o = owner.get(r);
if (o && o !== c.module) fanOut.add(o);
}
const fanIn = new Set<string>();
for (const other of contracts) {
if (other.module === c.module) continue;
for (const r of other.reads) {
if (owner.get(r) === c.module) fanIn.add(other.module);
}
}
return { module: c.module, fanOut: fanOut.size, fanIn: fanIn.size };
});
}
注意 writes 的越界检查(WRITE_OUT_OF_BOUND)和 DOUBLE_WRITER 是两条独立的检查:前者抓「A 模块写了 B 的状态」,后者抓「A、B 两个模块写了同一份状态」。一份状态被越界写时,两条往往会同时触发,但成因不同,分开报错才能定位到底是「写错了地方」还是「权限重叠」。
断言它
光有校验器不够,要让它成为边界,必须写成断言、接进 CI。下面这段是 P 层测试,用 node --test 跑。最关键的一条是第二个:往配置里注入一个越界写者,断言校验器一定把它抓出来。
// file: proto/test/ch05.test.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 跨模块契约的可断言验收:正常配置零违规;注入越界后能被精确检出。
import { test } from "node:test";
import assert from "node:assert/strict";
import {
loadConfig, validateContracts, coupling,
type ContractConfig, type StateOwnership, type ModuleContract,
} from "../src/ch05_contract.ts";
const ownership = loadConfig<StateOwnership>("ch05_state_ownership");
const contract = loadConfig<ContractConfig>("ch05_contract");
test("P:正式契约配置零违规(边界闭合)", () => {
const v = validateContracts(contract, ownership);
assert.deepEqual(v, []);
});
test("P:同一份状态被两个模块写能被检出", () => {
const evil: ModuleContract = {
module: "event", interface: "EventContract",
writes: ["event.season", "city.resources"], // city.resources 属 city,越界
reads: [], commands: [], events: [],
};
const v = validateContracts({ version: 1, contracts: [...contract.contracts, evil] }, ownership);
const codes = v.map((x) => x.code);
assert.ok(codes.includes("DOUBLE_WRITER"), `应检出 DOUBLE_WRITER,实得 ${codes}`);
assert.ok(codes.includes("WRITE_OUT_OF_BOUND"), `应检出 WRITE_OUT_OF_BOUND,实得 ${codes}`);
const dw = v.find((x) => x.code === "DOUBLE_WRITER")!;
assert.equal(dw.target, "city.resources");
assert.match(dw.detail, /city/);
assert.match(dw.detail, /event/);
});
test("P:写入不存在的状态被检出", () => {
const bad: ModuleContract = {
module: "shop", interface: "ShopContract",
writes: ["shop.wallet", "ghost.state"], reads: [], commands: [], events: [],
};
const v = validateContracts({ version: 1, contracts: [bad] }, ownership);
assert.ok(v.some((x) => x.code === "UNKNOWN_STATE" && x.target === "shop:ghost.state"));
});
test("P:读取不存在的状态被检出", () => {
const bad: ModuleContract = {
module: "quest", interface: "QuestContract",
writes: ["quest.progress"], reads: ["nope.state"], commands: [], events: [],
};
const v = validateContracts({ version: 1, contracts: [bad] }, ownership);
assert.ok(v.some((x) => x.code === "UNKNOWN_STATE" && x.target === "quest:nope.state"));
});
test("P:耦合度与归属表一致(fan-out / fan-in)", () => {
const c = coupling(contract.contracts, ownership);
const of = (m: string) => c.find((x) => x.module === m)!;
assert.equal(of("account").fanOut, 0); // 账号不读任何外部状态
assert.equal(of("account").fanIn, 3); // city / alliance / shop 都读账号档案
assert.equal(of("event").fanOut, 3); // 活动读 alliance / shop / quest
assert.equal(of("city").fanOut, 1); // 城建只读账号档案
assert.equal(of("city").fanIn, 3); // army / shop / quest 读城建状态
assert.equal(of("army").fanOut, 2); // 读 city.resources / map.tiles
});
test("P:校验完全确定(同输入两次结果逐位一致)", () => {
assert.deepEqual(validateContracts(contract, ownership), validateContracts(contract, ownership));
assert.deepEqual(coupling(contract.contracts, ownership), coupling(contract.contracts, ownership));
});
跑一遍:6 个测试全过
在本机跑 node --test 'proto/test/ch05.test.ts'(注意必须用 glob,node --test proto/test/ 不会发现 .ts 文件),结果是 pass 6 / fail 0:
✔ P:正式契约配置零违规(边界闭合)
✔ P:同一份状态被两个模块写能被检出
✔ P:写入不存在的状态被检出
✔ P:读取不存在的状态被检出
✔ P:耦合度与归属表一致(fan-out / fan-in)
✔ P:校验完全确定(同输入两次结果逐位一致)
第二个测试是整套契约的核心价值所在。它构造了一份坏契约——让 event 模块去写 city.resources(一份属于 city 的状态),然后断言校验器同时报出 DOUBLE_WRITER 与 WRITE_OUT_OF_BOUND。这意味着:只要有人试图让两个模块写同一份状态,CI 就会红。注意这里的耦合度和上一节基准算出的略有差异(这里 event 的 fan-out 是 3,基准里是 4),原因是两者读的配置不同——契约只声明了 reads 字段,而基准直接扫归属表的 readers。做产品边界时以归属表为准,做接口实现时以契约为准,两边都要对得上。
契约是模块边界的法律条文
把三节串起来看,模块边界是三层递进的:
- 功能树(5.1)回答「功能归谁」——一个功能只有一个实现模块;
- 状态归属(5.2)回答「数据归谁」——一份状态只有一个写者;
- 跨模块契约(5.3)回答「谁可以请求谁」——一次跨模块改动只能走对方的命令或事件。
三层都写进配置、都接进 CI,模块边界才从「架构师脑子里的默契」变成「任何一次改动都要过的一道关」。这也正是这套方法与单纯的「画一张模块图」最大的区别:图会过期,断言不会。
契约一旦定下来,改动就有了流程:想加一个新命令,先改契约配置、跑一遍测试看有没有引入越界;想删一份状态,先确认没有别的模块还在 reads 它。这套流程和埋点口径的变更流程是同一个思路——把「约定」变成「可执行的门禁」,那是第 8 章 埋点口径
要展开的方法。
下一章我们离开「模块内部」,走到玩家面前:主界面怎么组织信息层级、一个操作要点几次才能完成、新手引导怎么把这一整棵树一点点交给玩家。
阅读导航:上一节:数据流与状态归属 · 下一节:主界面框架与信息层级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。