《游戏引擎实现原理》17.3 版本化与迁移

存档格式会变:今天的 hp 明天可能改名 health。本节给 helio 内核的 schema 迁移框架:存档里带 $v 版本号,加载时沿迁移链逐级升到当前版本,缺环直接抛错、绝不静默降级。实跑一份 v1 存档升到 v3,字段从 {hp, name, gold} 变成 {health, name, mana, level}。

17.3 版本化与迁移

17.1 在 JSON 里写了两个元字段:$type 和 $v。$type 让解码器找到描述符,$v 则是这一节的伏笔——它是写这份存档时所用的 schema 版本。只要游戏还在更新,类型就会变:字段改名、字段新增、字段删除、字段类型变化。玩家手里那份三个月前的存档,必须还能在新版本里读出来。这一节给 helio 内核的迁移框架。

存档为什么会读不出来

先把问题钉死。假设《微光岛》早期版本里,存档槽是这样的:

{ "$type": "SaveSlot", "$v": 1, "hp": 80, "name": "Isla", "gold": 12 }

后来策划把 hp 统一改名为 health(和别处对齐),又加了法力值 mana;再后来 gold 被移出存档槽(改成由背包系统管理),并新增了 level。于是同一个类型出现了三个版本的字段表:

版本字段
v1hp、name、gold
v2health(原 hp)、name、gold、mana(新增,默认 50)
v3health、name、mana、level(新增,默认 1);gold 移除

如果加载器只认当前(v3)的字段表,读到 v1 存档时 health 缺失、gold 多余,直接崩或者读出空值。解法不是让加载器「猜」,而是为每一对相邻版本写一个迁移函数,加载时按 $v 逐级升上来。

迁移链:从 v1 逐级升到 v3

迁移框架只做三件事:登记「vN → vN+1」的迁移函数、沿链把数据升到目标版本、迁移后校验字段齐全。

// file: src/core/migration.ts
// tier: L1
// verify: npx vitest run src/core/migration.test.ts
//
// helio 内核:schema 版本迁移。
// 存档里带 $v,加载时沿迁移链逐级升到当前版本。

import type { TypeRegistry } from './registry.ts';
import { JSON_TYPE_KEY, JSON_VERSION_KEY } from './json-serde.ts';

export type MigrationStep = {
  readonly from: number;
  readonly to: number;
  readonly apply: (raw: Record<string, unknown>) => Record<string, unknown>;
};

export class MigrationRegistry {
  #steps = new Map<number, MigrationStep>();

  add(step: MigrationStep): void {
    if (this.#steps.has(step.from)) {
      throw new Error(`Duplicate migration from v${step.from}`);
    }
    this.#steps.set(step.from, step);
  }

  get(from: number): MigrationStep | undefined {
    return this.#steps.get(from);
  }
}

export type MigrationResult = {
  data: Record<string, unknown>;
  from: number;
  to: number;
  /** 实际走过的迁移链,如 ["1->2", "2->3"]。 */
  applied: string[];
};

/** 沿迁移链把 raw 升到 targetVersion;缺环即抛错,绝不静默降级。 */
export function migrate(
  mig: MigrationRegistry,
  raw: Record<string, unknown>,
  targetVersion: number,
): MigrationResult {
  const name = raw[JSON_TYPE_KEY];
  const from = raw[JSON_VERSION_KEY];
  if (typeof name !== 'string' || typeof from !== 'number') {
    throw new Error('Invalid save: missing $type/$v');
  }
  let version = from;
  let data = { ...raw };
  const applied: string[] = [];
  while (version < targetVersion) {
    const step = mig.get(version);
    if (!step) throw new Error(`No migration from v${version} for ${name}`);
    data = step.apply(data);
    applied.push(`${step.from}->${step.to}`);
    version = step.to;
  }
  data[JSON_VERSION_KEY] = version;
  return { data, from, to: version, applied };
}

/** 迁移后校验:描述符里每个字段都必须在数据里出现,缺一即抛错。 */
export function validateAgainst(
  reg: TypeRegistry,
  typeName: string,
  data: Record<string, unknown>,
): void {
  const desc = reg.require(typeName);
  for (const f of desc.fields) {
    if (!(f.name in data)) {
      throw new Error(`Migrated data missing field: ${f.name} (${typeName})`);
    }
  }
}

migrate 的循环是整段的核心:只要当前版本还低于目标版本,就找「从当前版本出发」的那一步迁移并应用,直到追平。它只升不降——读旧存档要的是「升到当前」,而不是「降级去兼容旧代码」。如果某一步缺失(比如跳版本发布、忘了写 v2→v3),mig.get 返回 undefined,直接抛 No migration from v2,绝不悄悄跳过。

迁移函数长什么样

框架是通用的,具体的迁移函数才是每个类型自己的事。上面那份 SaveSlot 的两步迁移写成函数表,就是一个完整的文件:

// file: src/core/save-migrations.ts
// tier: L1
// verify: npx vitest run src/core/migration.test.ts
//
// 《微光岛》存档槽 SaveSlot 的迁移函数表:v1 → v2 → v3。

import { MigrationRegistry } from './migration.ts';

export function buildSaveMigrations(): MigrationRegistry {
  const mig = new MigrationRegistry();

  // v1 → v2:hp 改名 health;新增 mana,默认 50。
  mig.add({
    from: 1,
    to: 2,
    apply: (raw) => ({
      $type: raw.$type,
      $v: 2,
      health: raw.hp,
      name: raw.name,
      gold: raw.gold,
      mana: 50,
    }),
  });

  // v2 → v3:移除 gold;新增 level,默认 1。
  mig.add({
    from: 2,
    to: 3,
    apply: (raw) => ({
      $type: raw.$type,
      $v: 3,
      health: raw.health,
      name: raw.name,
      mana: raw.mana,
      level: 1,
    }),
  });

  return mig;
}

每个迁移函数都返回一个全新的对象,显式列出目标版本的每一个字段。这看起来啰嗦,但它把「改了什么」写得明明白白。实际开发里 90% 的迁移都属于下面四类,照着套就行:

操作v1 → v2 的例子迁移函数里怎么写
改名hp → healthhealth: raw.hp
新增加 manamana: 50(给默认值)
删除去掉 gold不写这个 key 即可
改类型hp 从数变成对象在 apply 里做真正的换算

可运行产物:加载一份 v1 存档

把两个迁移函数装进 buildSaveMigrations(),喂一份 v1 存档,跑出迁移前后的字段差异。本机实跑 node src/core/migration-demo.ts:

// file: src/core/migration-demo.ts
// tier: L1
// verify: node src/core/migration-demo.ts
//
// 加载一份 v1 存档,迁移到 v3,打印迁移前后的字段差异。

import { migrate } from './migration.ts';
import { buildSaveMigrations } from './save-migrations.ts';

const mig = buildSaveMigrations();

const v1Save = { $type: 'SaveSlot', $v: 1, hp: 80, name: 'Isla', gold: 12 };
console.log('loaded (v1):', JSON.stringify(v1Save));

const result = migrate(mig, v1Save, 3);
console.log('applied    :', result.applied.join(' -> '));
console.log('migrated   :', JSON.stringify(result.data));
loaded (v1): {"$type":"SaveSlot","$v":1,"hp":80,"name":"Isla","gold":12}
applied    : 1->2 -> 2->3
migrated   : {"$type":"SaveSlot","$v":3,"health":80,"name":"Isla","mana":50,"level":1}

迁移前后的字段差异一眼可见:hp 被改名成 health(值 80 原样保留),mana 与 level 是新增的、取默认值 50 与 1,gold 在 v3 里被丢弃。applied 那一行 1->2 -> 2->3 是实际走过的迁移链——存档是 v1,就必须走两步;如果存档已经是 v2,这条链只会剩 2->3。

加载入口:把三步接起来

真实的加载入口按固定顺序走三步:解析 JSON → 迁移到当前版本 → 校验字段。顺序不能乱。

// snippet: 存档加载入口——解析、迁移、校验、解码
const CURRENT_VERSION = 3;
const raw = JSON.parse(saveText) as Record<string, unknown>; // { $type, $v, ... }
const { data, applied } = migrate(mig, raw, CURRENT_VERSION);
validateAgainst(reg, raw.$type as string, data);
const slot = deserializeFromJson(reg, data); // 17.1 的解码器
if (applied.length > 0) log.info(`save migrated: ${applied.join(' -> ')}`);

关键是先迁移、再解码。迁移函数工作在「原始 JSON 字段」上(raw.hp 还是数字),不依赖描述符;解码器才把字段搬进结构化对象。如果反过来先解码,deserializeFromJson 会因为 v1 缺 health 而填上零值,原始的 hp 就再也找不回来了。另外 applied 非空时打一条日志——迁移是「存档被悄悄改过」的现场,值得在日志里留痕。

边界用例:缺环与缺字段都必须失败

迁移最容易出的两类 bug,都被测试盯死:忘了写某一步迁移、迁移函数漏了一个字段。两者都必须在加载阶段抛错,而不是静默产出半成品。本机实跑 npx vitest run src/core/migration.test.ts 全部通过,其中两条边界用例如下。

// snippet: 迁移的边界用例——缺环、缺字段都必须抛错
it('缺少迁移环时抛错,绝不静默降级', () => {
  const mig = new MigrationRegistry();
  mig.add({ from: 1, to: 2, apply: (raw) => ({ ...raw, $v: 2 }) });
  expect(() => migrate(mig, v1Save, 3)).toThrow(/No migration from v2/);
});

it('缺字段的迁移结果无法通过校验', () => {
  expect(() => validateAgainst(reg, 'SaveSlot', { health: 1, name: 'x' }))
    .toThrow(/missing field: mana/);
});

第一条覆盖「跳版本发布」:登记了 v1→v2,却直接要求升到 v3,框架在 v2 处找不到下一步,抛错。第二条覆盖「迁移写漏字段」:校验器拿当前描述符逐个核对,发现 mana 不在数据里,抛错。把这两类错误挡在加载阶段,玩家看到的是「存档读不出来」的明确提示,而不是进游戏后某个数值突然变成 NaN。

迁移的规矩

迁移函数写起来简单,但有几条纪律必须守,否则迁移会变成 bug 的温床:

  • 迁移函数只依赖它声明的源版本。v1→v2 只管把 v1 的数据变成 v2 的数据,不要顺手做 v2→v3 的事。每一步单一职责,链才能自由组合。
  • 只升不降。玩家不会拿到「比游戏还新」的存档(除非跨设备),所以不需要降级路径;真遇到了,说明版本号被人为改过,应该报错而不是硬猜。
  • 新增字段一律给默认值。旧存档里没有 mana,迁移函数必须填一个合理的 50,而不是留 undefined——否则下游逻辑读到空值才崩,排查成本高得多。
  • 迁移后必须校验。validateAgainst 拿当前描述符逐字段核对,缺一个就抛错。它把「迁移函数写漏了字段」这种错误挡在加载阶段,而不是留到游戏跑到一半。
  • 改类型比改名字更贵。hp 改名只是搬值;若 hp 从整数变成「整数 + 最大值」的二元组,迁移函数就得做真正的换算。schema 设计时对这类改动要格外谨慎。
  • 版本号属于类型,不属于整份存档。每个类型各自带 $v(17.1 写在 JSON 头部),互不干扰;存档里有十个类型,就可能有十条独立的迁移链。
  • 迁移函数要是纯函数。给定同一份 v1 输入,任何时候都应产出同一份 v2 输出;不要在迁移里读当前时间、随机数或全局状态,否则同一份旧存档在不同时刻会迁移出不同结果。
  • 为每个版本留一份样本存档做回归。每加一步迁移,就把一份该版本的样本存档放进测试夹具,让 CI 每次都跑一遍「老存档能升到最新」——迁移的 bug 只会在很久以后才暴露,回归夹具是唯一能提前抓住它的东西。

到这里,反射这条线就走完了:16 章把类型信息抽成描述符,17.1 用它驱动 JSON 与二进制编码,17.2 处理对象引用与循环,17.3 让格式能随时间演进。零件齐了——下一章 18.1 内核装配:把前 17 章拼成引擎 会把内存、句柄、容器、ECS、并发、反射全部装进一个可运行的引擎,让《微光岛》里出现第一个能走动的方块。

阅读导航:上一节:17.2 引用、句柄与循环的处理 · 下一节:18.1 内核装配:把前 17 章拼成引擎 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径