5.3 跨模块契约与接口约定

状态归属解决了「谁有权写」,这一节解决「一个模块怎么请求另一个模块去写」:用 TypeScript 接口把每个模块的写状态、读状态、命令与事件写死,再用 node --test 断言「同一份状态被两个模块写」「写了不属于自己的状态」一定被检出,让模块契约变成可执行的边界。

上一节把「状态归谁」钉死了:每份状态只有一个写者。但真实项目里,模块之间总要互相请求——联盟想扣资源,得让城建去扣;任务想发奖励,得让对应模块去发。「谁能请求谁、以什么形式请求」如果不定下来,单写者原则就只是纸面上的。 这一节把模块之间的接口写成契约,并用测试断言它。

先划边界。站内的 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。做产品边界时以归属表为准,做接口实现时以契约为准,两边都要对得上。

契约是模块边界的法律条文

把三节串起来看,模块边界是三层递进的:

  1. 功能树(5.1)回答「功能归谁」——一个功能只有一个实现模块;
  2. 状态归属(5.2)回答「数据归谁」——一份状态只有一个写者;
  3. 跨模块契约(5.3)回答「谁可以请求谁」——一次跨模块改动只能走对方的命令或事件。

三层都写进配置、都接进 CI,模块边界才从「架构师脑子里的默契」变成「任何一次改动都要过的一道关」。这也正是这套方法与单纯的「画一张模块图」最大的区别:图会过期,断言不会。

契约一旦定下来,改动就有了流程:想加一个新命令,先改契约配置、跑一遍测试看有没有引入越界;想删一份状态,先确认没有别的模块还在 reads 它。这套流程和埋点口径的变更流程是同一个思路——把「约定」变成「可执行的门禁」,那是第 8 章 埋点口径 要展开的方法。

下一章我们离开「模块内部」,走到玩家面前:主界面怎么组织信息层级、一个操作要点几次才能完成、新手引导怎么把这一整棵树一点点交给玩家。

阅读导航:上一节:数据流与状态归属 · 下一节:主界面框架与信息层级 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引