热更窗口里的旧客户端
《潮汐方舟》每周热更一次,平均 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 连接状态机:从连接到就绪 。
常见错误与边界
- 只测新客户端样本。灰度期旧客户端仍在线上,样本库里没有旧版本,等于没有测到真正的兼容风险。
- 把未知字段一律判失败。次版本新增可选字段是合法演进,全部阻断会让团队绕过契约检查。
- 只做 schema 校验,不做版本 diff。当前样本能过,不代表旧客户端能过;断裂往往发生在两个版本之间。
- 样本 schema 名对不上就跳过。静默跳过会让整批样本「零失败通过」,比直接报错更危险。
- 把契约测试放到发布之后。发布后才发现断裂,只能回滚或发新版本,成本远高于打包前阻断。
- 用字段名当稳定标识。字段名会在重构中改变,稳定的契约标识应该是 schema 名加字段语义。
- 把可选字段提升为必需却不升主版本。旧客户端不会发送这个字段,新服务端却要求它存在,直接断裂。
- 样本包含真实账号或支付信息。黄金样本必须脱敏,否则契约测试会成为数据泄露的入口。
- 边界:契约测试不验证业务正确性。它能证明字段形状合法,不能证明伤害公式、掉落概率或结算逻辑正确。
- 边界:契约测试不替代真机灰度。机型、弱网和渠道差异只能在灰度中观察,CI 里的绿色只代表消息形状可信。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。