《移动游戏产品的架构·网络、资源与热更新》2.3 协议契约测试:把兼容规则写成断言

兼容规则写在文档里,下一次热更就会被绕过。本节把消消与方舟的协议契约拆成 schema、黄金样本和 schema diff 三层断言,给出一个能同时校验两个产品样本的检查器,并说明哪些问题必须阻断发版、哪些只应记成警告,以及为什么旧版本样本必须留在 CI 里。

热更窗口里的旧客户端

《潮汐方舟》每周热更一次,平均 45MB;商店整包每 6 周才发一次。《潮汐消消》节奏慢一些,双周热更平均 12MB,整包每 8 周。两者的共同点是:热更只改资源和配置,代码与协议结构的变化要等整包,但服务端字段和配置语义却可能每周都在动。

一个常见的退化是这样发生的:服务端把 clientSeq 改名为 client_seq,本地测试全绿,因为新客户端已经同步改了;灰度上线后,旧客户端发来的 clientSeq 被当成未知字段忽略,服务端读到 undefined,把序号校验直接跳过。问题不会立刻崩溃,只会让重复提交在弱网下变多,等到数据团队发现对账差异,已经过去几天。

契约测试要做的,就是把「字段名不能随便改」「新增必需字段会断裂」这些口头规则变成 CI 里的一次失败。它不是集成测试,不需要真的连服务端;它检查的是消息的形状:字段在不在、类型对不对、边界是否越界、两个版本的 schema 是否还能互读。

契约测试测什么

我们把它分成三层,每层的输入和失败处理都不同。

层输入回答的问题失败处理
schema 校验单个产品的消息定义字段名、类型、必需性是否自洽阻断合并
黄金样本真实抓包脱敏后的样本集合现有样本是否仍能被当前 schema 解析阻断合并
schema diff上一个发布版本与当前版本这次改动会不会让旧客户端断裂断裂则阻断,新增可选则放行

三层里最容易缺的是第三层。前两层只能证明「当前样本能过」,不能证明「旧客户端还能过」。schema diff 看的不是样本,而是两个版本之间的结构变化:删了必需字段、改了字段类型、把可选字段提升为必需,都会被标成断裂。

黄金样本也不能只存新版本的样本。灰度期线上同时存在两代客户端,样本库必须同时保留两代的消息。否则测试永远只验证新客户端,旧客户端的路径在 CI 里根本不存在。

双案例的契约差异

两个产品的协议节奏不同,契约测试的严格程度也应该不同。

维度消消方舟
提交方式回合制提交,每次 600B–1.2KB战斗输入 20Hz、每条 48B;快照 10Hz、每条 350B
单局规模14–22 次提交8–12 分钟一场
弱网会话占比22%14%
重连后需补状态8%31%
契约关注点回合幂等、提交顺序、结算字段输入频率、快照字段、状态增量

消消的契约更像「事务」:一次提交要么完整生效,要么按 clientSeq 幂等丢弃,字段少、频率低,适合把校验写得更严。方舟的契约更像「流」:输入每秒 20 条,任何一条字段不兼容都会在几秒内放大成整场战斗的错位,因此对字段类型和边界的要求更高,对可选字段的容忍度反而要更低。

注意,严格不等于把所有未知字段都判失败。未知可选字段是次版本升级的正常产物,必须放行并记警告;只有未知的必需字段、类型改变和必需字段删除才应该阻断。把警告升级成错误,会让每次合法的次版本升级都卡在 CI,团队很快会学会绕过检查。

三类断言与实现

下面的检查器只做纯逻辑校验,不依赖网络、文件或第三方包。它导出两个产品的示例 schema、单样本校验、语料聚合、schema diff 和一行摘要。

// file: src/net/contract-check.ts
// tier: L1
// verify: node --test test/net/contract-check.test.ts

export type FieldType = "int" | "float" | "string" | "bool" | "int-array";

export interface FieldSpec {
  readonly name: string;
  readonly type: FieldType;
  readonly required: boolean;
  readonly min?: number;
  readonly max?: number;
  readonly maxLength?: number;
}

export interface MessageSchema {
  readonly name: string;
  readonly version: number;
  readonly fields: readonly FieldSpec[];
}

export interface CorpusSample {
  readonly schema: string;
  readonly value: Readonly<Record<string, unknown>>;
}

export type IssueCode =
  | "missing-field"
  | "type-mismatch"
  | "out-of-range"
  | "too-long"
  | "unknown-field"
  | "unknown-schema"
  | "removed-required"
  | "required-added"
  | "type-changed"
  | "schema-name";

export interface ContractIssue {
  readonly schema: string;
  readonly path: string;
  readonly code: IssueCode;
  readonly detail: string;
}

export interface ContractReport {
  readonly samples: number;
  readonly passed: number;
  readonly failed: number;
  readonly warnings: number;
  readonly issues: readonly ContractIssue[];
}

export const TIDE_MOVE_SCHEMA: MessageSchema = {
  name: "tide.move.submit",
  version: 3,
  fields: [
    { name: "matchId", type: "string", required: true, maxLength: 36 },
    { name: "turn", type: "int", required: true, min: 1 },
    { name: "moves", type: "int-array", required: true },
    { name: "clientSeq", type: "int", required: true, min: 0 },
    { name: "skinId", type: "string", required: false, maxLength: 32 },
  ],
};

export const ARK_INPUT_SCHEMA: MessageSchema = {
  name: "ark.battle.input",
  version: 5,
  fields: [
    { name: "seq", type: "int", required: true, min: 0 },
    { name: "tick", type: "int", required: true, min: 0 },
    { name: "moveX", type: "float", required: true, min: -1, max: 1 },
    { name: "moveY", type: "float", required: true, min: -1, max: 1 },
    { name: "buttons", type: "int", required: true, min: 0, max: 65535 },
    { name: "skillHint", type: "string", required: false, maxLength: 16 },
  ],
};

function typeMatches(type: FieldType, value: unknown): boolean {
  switch (type) {
    case "int":
      return typeof value === "number" && Number.isInteger(value);
    case "float":
      return typeof value === "number" && Number.isFinite(value);
    case "string":
      return typeof value === "string";
    case "bool":
      return typeof value === "boolean";
    case "int-array":
      return Array.isArray(value) && value.every((item) => Number.isInteger(item));
    default:
      return false;
  }
}

export function validateSample(
  schema: MessageSchema,
  sample: Readonly<Record<string, unknown>>,
): readonly ContractIssue[] {
  const issues: ContractIssue[] = [];
  const known = new Set(schema.fields.map((field) => field.name));

  for (const field of schema.fields) {
    const value = sample[field.name];
    if (value === undefined) {
      if (field.required) {
        issues.push({
          schema: schema.name,
          path: field.name,
          code: "missing-field",
          detail: `${field.name} is required`,
        });
      }
      continue;
    }
    if (!typeMatches(field.type, value)) {
      issues.push({
        schema: schema.name,
        path: field.name,
        code: "type-mismatch",
        detail: `${field.name} expects ${field.type}, got ${typeof value}`,
      });
      continue;
    }
    if (typeof value === "number") {
      if (field.min !== undefined && value < field.min) {
        issues.push({
          schema: schema.name,
          path: field.name,
          code: "out-of-range",
          detail: `${field.name}=${value} < min ${field.min}`,
        });
      }
      if (field.max !== undefined && value > field.max) {
        issues.push({
          schema: schema.name,
          path: field.name,
          code: "out-of-range",
          detail: `${field.name}=${value} > max ${field.max}`,
        });
      }
    }
    if (typeof value === "string" && field.maxLength !== undefined && value.length > field.maxLength) {
      issues.push({
        schema: schema.name,
        path: field.name,
        code: "too-long",
        detail: `${field.name} length ${value.length} > ${field.maxLength}`,
      });
    }
  }

  for (const key of Object.keys(sample)) {
    if (!known.has(key)) {
      issues.push({
        schema: schema.name,
        path: key,
        code: "unknown-field",
        detail: `${key} is not declared in ${schema.name} v${schema.version}`,
      });
    }
  }
  return issues;
}

export function checkCorpus(
  schemas: readonly MessageSchema[],
  samples: readonly CorpusSample[],
): ContractReport {
  const byName = new Map(schemas.map((schema) => [schema.name, schema]));
  const issues: ContractIssue[] = [];
  let passed = 0;
  let failed = 0;
  let warnings = 0;

  for (const sample of samples) {
    const schema = byName.get(sample.schema);
    if (schema === undefined) {
      issues.push({
        schema: sample.schema,
        path: "",
        code: "unknown-schema",
        detail: `no schema registered for ${sample.schema}`,
      });
      failed += 1;
      continue;
    }
    const sampleIssues = validateSample(schema, sample.value);
    issues.push(...sampleIssues);
    const errors = sampleIssues.filter((issue) => issue.code !== "unknown-field");
    warnings += sampleIssues.length - errors.length;
    if (errors.length > 0) {
      failed += 1;
    } else {
      passed += 1;
    }
  }

  return {
    samples: samples.length,
    passed,
    failed,
    warnings,
    issues,
  };
}

export function diffSchema(base: MessageSchema, next: MessageSchema): readonly ContractIssue[] {
  const issues: ContractIssue[] = [];
  if (base.name !== next.name) {
    issues.push({
      schema: next.name,
      path: "",
      code: "schema-name",
      detail: `${next.name} != ${base.name}`,
    });
    return issues;
  }
  const nextFields = new Map(next.fields.map((field) => [field.name, field]));
  const baseFields = new Map(base.fields.map((field) => [field.name, field]));

  for (const field of base.fields) {
    const candidate = nextFields.get(field.name);
    if (candidate === undefined) {
      if (field.required) {
        issues.push({
          schema: base.name,
          path: field.name,
          code: "removed-required",
          detail: `${field.name} was required and disappeared`,
        });
      }
      continue;
    }
    if (candidate.type !== field.type) {
      issues.push({
        schema: base.name,
        path: field.name,
        code: "type-changed",
        detail: `${field.name} ${field.type} -> ${candidate.type}`,
      });
    }
    if (!field.required && candidate.required) {
      issues.push({
        schema: base.name,
        path: field.name,
        code: "required-added",
        detail: `${field.name} became required`,
      });
    }
  }

  for (const field of next.fields) {
    if (!baseFields.has(field.name) && field.required) {
      issues.push({
        schema: base.name,
        path: field.name,
        code: "required-added",
        detail: `${field.name} is new and required`,
      });
    }
  }
  return issues;
}

export function describeReport(report: ContractReport): string {
  return `contract samples=${report.samples} passed=${report.passed} failed=${report.failed} warnings=${report.warnings}`;
}

validateSample 先按 schema 逐字段检查,再扫描样本里多出来的键。顺序很重要:先报缺失和类型错误,再报未知字段,排障时不会被一堆警告淹没。未知字段的 code 是 unknown-field,聚合时只计入 warnings,不影响 passed。

checkCorpus 接受多个产品的样本,按 schema 名路由。没有注册 schema 的样本直接失败,而不是静默跳过;这是为了防止某次重命名之后,整批样本因为名字对不上而被「零样本通过」。

diffSchema 只回答「这次改动是否断裂」,不关心样本。它检查三件事:必需字段是否消失、字段类型是否改变、可选字段是否被提升为必需。新增可选字段不产生问题,新增必需字段产生 required-added。这套规则和 2.1 的信封兼容判定是同一套语义,只是一个看消息形状,一个看二进制头。

灰度门禁怎么用

契约测试应该放在打包之前,而不是发布之后。流程可以是:开发提交 schema 改动 → 跑黄金样本 → 跑 schema diff → 断裂则阻止出包,警告则要求 reviewer 确认 → 通过后按渠道、机型档位和账号哈希尾号灰度。

灰度期间还要保留一份「旧版本样本」作为回归输入。方舟有 31% 的重连需要服务端补状态,这些补状态消息的字段往往比常规快照更晚被覆盖;消消只有 8%,但回合提交的幂等字段一旦改名,影响会直接体现在对账上。两个产品都应该把「旧客户端仍能解析」作为灰度放量条件,而不是只看崩溃率和下载成功率。

与相邻章节的关系

2.1 定义了信封和版本兼容的规则,2.2 定义了请求、响应与推送的状态机;本节把这些规则变成可以在 CI 里重复执行的断言。3.1 之后会进入连接状态机,那里关注的是「连接是否可用」,而本节关注的是「消息形状是否仍然可信」,两者不能互相替代。

小结

  • 契约测试分三层:schema 校验、黄金样本、schema diff;缺了 diff 就防不住旧客户端断裂。
  • 黄金样本必须同时保留新旧两代客户端,否则灰度期的旧路径在 CI 里不存在。
  • 未知可选字段是正常演进,记警告;未知必需字段、类型改变、必需字段删除才阻断。
  • 方舟的输入是 20Hz 的流,消消的提交是回合事务,两者的严格程度和关注点不同。
  • 契约测试放在打包之前,作为灰度放量的前置条件,而不是发布后的补丁。

阅读导航:上一节:2.2 请求响应、推送与超时语义 · 下一节:3.1 连接状态机:从连接到就绪 。

常见错误与边界

  1. 只测新客户端样本。灰度期旧客户端仍在线上,样本库里没有旧版本,等于没有测到真正的兼容风险。
  2. 把未知字段一律判失败。次版本新增可选字段是合法演进,全部阻断会让团队绕过契约检查。
  3. 只做 schema 校验,不做版本 diff。当前样本能过,不代表旧客户端能过;断裂往往发生在两个版本之间。
  4. 样本 schema 名对不上就跳过。静默跳过会让整批样本「零失败通过」,比直接报错更危险。
  5. 把契约测试放到发布之后。发布后才发现断裂,只能回滚或发新版本,成本远高于打包前阻断。
  6. 用字段名当稳定标识。字段名会在重构中改变,稳定的契约标识应该是 schema 名加字段语义。
  7. 把可选字段提升为必需却不升主版本。旧客户端不会发送这个字段,新服务端却要求它存在,直接断裂。
  8. 样本包含真实账号或支付信息。黄金样本必须脱敏,否则契约测试会成为数据泄露的入口。
  9. 边界:契约测试不验证业务正确性。它能证明字段形状合法,不能证明伤害公式、掉落概率或结算逻辑正确。
  10. 边界:契约测试不替代真机灰度。机型、弱网和渠道差异只能在灰度中观察,CI 里的绿色只代表消息形状可信。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查