《移动游戏产品的架构·网络、资源与热更新》2.1 消息信封、版本与兼容规则

方舟的客户端与服务端不可能同时升级。本节从灰度期的快照解析错位出发,把固定头、主次版本号、未知字段与必需字段的处理写成可执行规则,并给出一个固定头加模式名的二进制编解码实现,让旧客户端在新服务端面前只做可预期的降级,而不是在解析失败后才发现问题。

灰度期的一次快照错位

《潮汐方舟》每周做一次平均 45MB 的热更,商店整包版本每 6 周才发一次。这意味着任何一个周三的晚上,线上至少同时存在两代客户端:刚更新完的,以及还没更新、或者更新失败后继续游戏的。服务端不能假设所有人都已经是新版本。

一次典型的故障是这样发生的:服务端在战斗快照末尾追加了一个字段,客户端按旧模式解析,把新增的字节当成下一条消息的长度前缀。战斗快照以 10Hz 下发、每条 350B,几十秒内客户端就会连续读错,表现成血量跳变、单位瞬移,最后触发断线重连。更麻烦的是,方舟的会话时长中位数是 34 分钟,玩家往往已经打完半场,才意识到状态不对。

问题不在某个字段,而在「消息的边界和身份没有先于内容被确定」。信封就是为此存在的:它不解释业务,只回答四个问题——这条消息属于哪个模式、哪个版本、哪个序号、负载有多长。把这四个问题固定下来,业务字段才有机会谈兼容。

信封先于消息:固定头承担什么

信封采用固定的二进制头,后面跟模式名和负载。固定头只有 14 字节,字段少到可以在断点里一眼读完,但它承担了所有解析器都必须先知道的信息。

字段宽度作用缺失时的后果
magic2 字节标记这是本协议的数据把 TCP 粘包里的其它字节误当消息
major1 字节协议主版本无法判断能否解析
kind1 字节请求、响应、推送、快照不知道要不要回包
flags1 字节压缩、加密、分片等标记负载被当作明文
sequence4 字节连接内单调递增的消息号无法发现乱序或重复
schemaLen1 字节模式名的 UTF-8 长度无法切出模式名
payloadLen4 字节负载字节数无法确定消息边界

这里有一个刻意的取舍:模式名放在头之后、负载之前,而不是塞进固定头。模式名是变长的,放进固定头会让头变成变长结构,解析器第一步就要读长度,等于把「先读头」的规则破坏掉。把它放在固定头之后,解析顺序仍然是「定长头 → 变长模式名 → 负载」,每一步都能用上一步的结果做边界检查。

模式名同时承担了「消息类型」和「契约身份」两个角色。ark.battle.input 和 ark.battle.snapshot 是两个不同的模式,即使它们同属战斗域。用模式名而不是数字 opcode,是为了让日志、抓包和契约测试都能直接读出语义;代价是每条消息多几个字节,对 48B 的战斗输入来说可以接受,对 350B 的快照更不算负担。

主版本与次版本:两种升级不是一回事

版本号只有两个数字,但它们的语义必须提前写死,否则团队会在第一次紧急发版时把它们用坏。

改动版本动作旧客户端的结果
新增可选字段minor + 1忽略未知字段,继续工作
删除可选字段minor + 1不再收到该字段,使用默认值
新增必需字段major + 1拒绝解析,提示更新或走降级通道
删除必需字段major + 1旧客户端缺少输入,必须拒绝
字段类型改变major + 1字节解释不同,必须拒绝
只改字段语义、不改结构minor + 1,并双写过渡新旧客户端各读各的语义

规则看起来简单,真正难的是执行。最常见的事故是「新增了一个必需字段,但只升了 minor」,因为开发同学觉得「这个字段很重要,必须带上」。对旧客户端来说,重要与否没有意义,它只知道自己不认识这个字段,而发送方又声明它是必需的。此时唯一安全的动作是拒绝整条消息,而不是猜一个默认值继续跑。

反过来,删除字段也要看它是否必需。删除一个可选字段可以只在 minor 上升级,因为旧客户端本来就可能没收到它;删除一个必需字段则必须升 major,因为旧客户端仍在等待这个输入。兼容性不是看字段重不重要,而是看「旧解析器能否在不理解新数据的情况下仍然做出正确行为」。

兼容判定要给出方向,而不是布尔值

很多团队把兼容检查写成一个 isCompatible 布尔函数,然后在 CI 里打印一行绿字。这个信息量不够:同样是「不兼容」,旧客户端读新消息和新客户端读旧消息,处理方式完全不同。

我们的实现返回四个级别:identical 表示双方模式完全一致;forward 表示本地较旧、远端较新,旧解析器可以忽略未知的可选字段继续工作;backward 表示本地较新、远端较旧,新解析器要为缺失字段准备默认值;breaking 表示必须拒绝并走更新或降级通道。除了级别,结果还带 reasons 和 unknownFields,让 CI 能直接指出是哪个字段导致断裂。

判定顺序也有讲究。先比模式名,再比主版本;任何一个不同都直接 breaking,不需要继续比对字段。只有主版本相同,才进入次版本和字段集的比较。这样可以把「协议身份不同」和「同一协议内的演进」分开,错误信息也不会被一堆字段差异淹没。

对于 forward 的情况,未知字段必须被允许跳过。信封的 payloadLen 让解析器可以在不认识的字段上直接前进,而不是逐字段猜测。这也是为什么信封层不负责字段级解析:它只需要知道整条消息有多长,剩下的交给模式层。

编解码实现

下面的文件把上述规则落成可执行代码。它只做三件事:按固定布局编码、按固定布局解码并做边界检查、比较两个模式契约给出兼容级别。没有第三方依赖,也不依赖工程里的其它文件。

// file: src/net/message-envelope.ts
// tier: L1
// verify: node --test test/net/message-envelope.test.ts

export const ENVELOPE_MAGIC = 0x4d47;
export const PROTOCOL_MAJOR = 2;
export const HEADER_BYTES = 14;

export type MessageKind = "request" | "response" | "push" | "snapshot";

const KIND_TO_CODE: Record<MessageKind, number> = {
  request: 1,
  response: 2,
  push: 3,
  snapshot: 4,
};

const CODE_TO_KIND: Record<number, MessageKind> = {
  1: "request",
  2: "response",
  3: "push",
  4: "snapshot",
};

export type EnvelopeErrorCode =
  | "short"
  | "magic"
  | "version"
  | "kind"
  | "schema"
  | "length";

export class EnvelopeError extends Error {
  readonly code: EnvelopeErrorCode;

  constructor(code: EnvelopeErrorCode, message: string) {
    super(message);
    this.name = "EnvelopeError";
    this.code = code;
  }
}

export interface Envelope {
  readonly major: number;
  readonly kind: MessageKind;
  readonly flags: number;
  readonly sequence: number;
  readonly schema: string;
  readonly payload: Uint8Array;
}

export interface SchemaField {
  readonly name: string;
  readonly required: boolean;
  readonly sinceMinor: number;
}

export interface SchemaContract {
  readonly name: string;
  readonly major: number;
  readonly minor: number;
  readonly fields: readonly SchemaField[];
}

export type CompatibilityLevel =
  | "identical"
  | "backward"
  | "forward"
  | "breaking";

export interface Compatibility {
  readonly ok: boolean;
  readonly level: CompatibilityLevel;
  readonly reasons: readonly string[];
  readonly unknownFields: readonly string[];
}

const encoder = new TextEncoder();
const decoder = new TextDecoder();

function kindCode(kind: MessageKind): number {
  const code = KIND_TO_CODE[kind];
  if (code === undefined) {
    throw new EnvelopeError("kind", `unknown message kind: ${String(kind)}`);
  }
  return code;
}

function isMessageKind(value: number): value is MessageKind {
  return CODE_TO_KIND[value] !== undefined;
}

export function encodeEnvelope(envelope: Envelope): Uint8Array {
  if (!Number.isInteger(envelope.major) || envelope.major < 0 || envelope.major > 255) {
    throw new EnvelopeError("version", `major out of range: ${envelope.major}`);
  }
  const schemaBytes = encoder.encode(envelope.schema);
  if (schemaBytes.length === 0 || schemaBytes.length > 255) {
    throw new EnvelopeError("schema", `schema length out of range: ${schemaBytes.length}`);
  }
  if (!Number.isInteger(envelope.sequence) || envelope.sequence < 0) {
    throw new EnvelopeError("length", `sequence out of range: ${envelope.sequence}`);
  }
  const bodyBytes = HEADER_BYTES + schemaBytes.length + envelope.payload.length;
  const buffer = new Uint8Array(bodyBytes);
  const view = new DataView(buffer.buffer);

  view.setUint16(0, ENVELOPE_MAGIC, false);
  view.setUint8(2, envelope.major);
  view.setUint8(3, kindCode(envelope.kind));
  view.setUint8(4, envelope.flags & 0xff);
  view.setUint8(5, schemaBytes.length);
  view.setUint32(6, envelope.sequence, false);
  view.setUint32(10, envelope.payload.length, false);

  buffer.set(schemaBytes, HEADER_BYTES);
  buffer.set(envelope.payload, HEADER_BYTES + schemaBytes.length);
  return buffer;
}

export function decodeEnvelope(bytes: Uint8Array): Envelope {
  if (bytes.length < HEADER_BYTES) {
    throw new EnvelopeError("short", `buffer ${bytes.length} < ${HEADER_BYTES}`);
  }
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const magic = view.getUint16(0, false);
  if (magic !== ENVELOPE_MAGIC) {
    throw new EnvelopeError("magic", `bad magic: 0x${magic.toString(16)}`);
  }
  const major = view.getUint8(2);
  if (major !== PROTOCOL_MAJOR) {
    throw new EnvelopeError("version", `unsupported major: ${major}`);
  }
  const kindCodeValue = view.getUint8(3);
  if (!isMessageKind(kindCodeValue)) {
    throw new EnvelopeError("kind", `unknown kind code: ${kindCodeValue}`);
  }
  const flags = view.getUint8(4);
  const schemaLength = view.getUint8(5);
  if (schemaLength === 0) {
    throw new EnvelopeError("schema", "empty schema name");
  }
  const sequence = view.getUint32(6, false);
  const payloadLength = view.getUint32(10, false);
  const expected = HEADER_BYTES + schemaLength + payloadLength;
  if (bytes.length < expected) {
    throw new EnvelopeError("length", `buffer ${bytes.length} < ${expected}`);
  }
  const schema = decoder.decode(bytes.subarray(HEADER_BYTES, HEADER_BYTES + schemaLength));
  const payloadStart = HEADER_BYTES + schemaLength;
  const payload = bytes.slice(payloadStart, payloadStart + payloadLength);
  return {
    major,
    kind: CODE_TO_KIND[kindCodeValue] as MessageKind,
    flags,
    sequence,
    schema,
    payload,
  };
}

export function checkCompatibility(
  local: SchemaContract,
  remote: SchemaContract,
): Compatibility {
  const reasons: string[] = [];
  if (local.name !== remote.name) {
    reasons.push(`schema ${remote.name} != ${local.name}`);
  }
  if (local.major !== remote.major) {
    reasons.push(`major ${remote.major} != ${local.major}`);
  }
  if (reasons.length > 0) {
    return { ok: false, level: "breaking", reasons, unknownFields: [] };
  }

  const localFields = new Map(local.fields.map((field) => [field.name, field]));
  const remoteFields = new Map(remote.fields.map((field) => [field.name, field]));

  for (const field of local.fields) {
    if (field.required && !remoteFields.has(field.name)) {
      reasons.push(`remote drops required field ${field.name}`);
    }
  }

  const unknownFields: string[] = [];
  for (const field of remote.fields) {
    if (!localFields.has(field.name)) {
      unknownFields.push(field.name);
      if (field.required && field.sinceMinor > local.minor) {
        reasons.push(`remote requires unknown field ${field.name}`);
      }
    }
  }

  if (reasons.length > 0) {
    return { ok: false, level: "breaking", reasons, unknownFields };
  }
  if (local.minor === remote.minor && unknownFields.length === 0) {
    return { ok: true, level: "identical", reasons: [], unknownFields: [] };
  }
  if (remote.minor > local.minor) {
    return { ok: true, level: "forward", reasons: [], unknownFields };
  }
  return { ok: true, level: "backward", reasons: [], unknownFields };
}

export function describeEnvelope(envelope: Envelope): string {
  const flags = `0x${(envelope.flags & 0xff).toString(16).padStart(2, "0")}`;
  return `MG/${envelope.major} ${envelope.kind} seq=${envelope.sequence} schema=${envelope.schema} payload=${envelope.payload.length}B flags=${flags}`;
}

export function describeCompatibility(result: Compatibility): string {
  if (result.ok) {
    const unknown = result.unknownFields.length > 0
      ? ` unknown=${result.unknownFields.join(",")}`
      : "";
    return `compatible:${result.level}${unknown}`;
  }
  return `breaking: ${result.reasons.join("; ")}`;
}

编码函数的顺序是「先校验入参,再一次性分配缓冲区,最后按偏移写入」。这样不会出现「写到一半发现模式名超长」的部分写入问题。解码函数则相反:每一步都先检查剩余长度,再读取;任何越界都会抛出带错误码的 EnvelopeError,而不是返回一个半成品。

decodeEnvelope 里的 bytes.slice 会复制负载,而不是返回原缓冲区的视图。这是一个有意的选择:网络层通常会复用接收缓冲区,如果信封持有视图,下一帧数据到达时负载会被悄悄改写。复制的成本是每条消息一次内存分配,但对 350B 的快照和 48B 的输入来说,远小于一次错误的调试成本。

运行结果与边界

测试覆盖了正常往返、头部偏移、短包、坏 magic、错误主版本、未知 kind、截断负载、相同契约、可选字段前进兼容、必需未知字段断裂这十类情况。describeEnvelope 的输出形如:

MG/2 snapshot seq=1042 schema=ark.battle.snapshot payload=4B flags=0x00

这行输出在抓包和日志里比一串十六进制更有用:它同时给出了版本、类型、序号、模式名和负载长度。排障时先看模式名对不对,再看序号是否连续,最后才需要展开负载。

方舟的战斗输入是 20Hz、每条 48B,快照是 10Hz、每条 350B,上下行均值分别是 240B 和 1.4KB。信封的 14 字节固定头在输入消息上占比并不低,看起来不便宜,但它换来的是每条消息都能独立定位边界。相比之下,消消是回合制提交,每次 600B 到 1.2KB,单局 14 到 22 次提交,头部占比可以忽略;两类产品对信封开销的敏感度并不相同,不能用同一套阈值去要求。

边界也要写清楚。模式名长度用 1 字节,意味着上限 255 字节;负载长度用 4 字节,单条消息上限是 4GB,但实际会远低于这个值。信封不负责压缩和加密,flags 只表达「负载经过了什么处理」,具体算法由连接层协商。信封也不保证送达和顺序,sequence 只是让上层能发现乱序,重传与去重属于请求跟踪和连接状态机的职责。

与相邻章节的关系

2.2 会在这个信封之上讨论请求、响应与推送的语义:哪些消息需要回包、超时后如何重试、推送如何与请求区分。3.1 会把连接状态机和信封的 major 检查接起来,决定版本不匹配时是断开、降级还是提示更新。本节只解决「一条消息如何被安全地切开和识别」,不展开会话层的策略。

如果要把断线后的状态重建讲完整,可以对照客户端卷里关于恢复流程的讨论(断线恢复 ),但本节不依赖那部分结论。

小结

  • 信封先于业务:固定头回答版本、类型、序号和长度,业务字段才有兼容的空间。
  • 主版本表示结构断裂,次版本表示可忽略的演进;新增必需字段必须升主版本。
  • 兼容判定要返回方向(identical、forward、backward、breaking),而不是一个布尔值。
  • 未知字段必须能跳过,未知的必需字段必须拒绝;两者不能混为一谈。
  • 解码时先检查长度再读取,负载要复制而不是持有网络缓冲区视图。

阅读导航:上一节:1.3 mga-lab-net 的网络实验骨架与验收口径 · 下一节:2.2 请求响应、推送与超时语义 。

常见错误与边界

  1. 把版本号写进负载。解析器必须先解负载才能知道版本,而解负载又需要版本,形成循环;版本必须在固定头里。
  2. 每次发版都把主版本加一。主版本一旦膨胀,兼容判断就失去意义,所有旧客户端都会被判为断裂。
  3. 新增必需字段只升次版本。旧客户端不认识这个字段,却被告知必须理解,只能拒绝整条消息。
  4. 把未知字段当错误丢弃整包。这会让所有次版本升级都变成硬断裂,灰度期无法共存。
  5. 只在服务端做兼容判断。客户端同样需要判断自己能否解析服务端的消息,否则会在本地读出错误状态。
  6. 用 JSON 字段名充当协议身份。字段名会随着重构改变,模式名应该是稳定的契约标识。
  7. 忽略字节序和长度校验。跨平台客户端混用大小端,或者把粘包当成一条消息,都会得到看似随机的结果。
  8. 用全局自增数做请求 id。请求、推送和快照共用序号池时,重连后序号回绕会造成误匹配;请求 id 应由请求跟踪器单独分配。
  9. 边界:信封不负责压缩、加密和分片。这些属于连接层,flags 只记录处理结果,不定义算法。
  10. 边界:信封不保证送达、顺序和去重。它只让上层能发现乱序,真正的重传和幂等由请求跟踪器与业务状态机负责。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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