17.1 反射驱动的序列化:JSON 与二进制
第 16 章做了一件吃力但值钱的事:把「一个类型有哪些字段、每个字段是什么种类」从代码里抽出来,变成一张可被程序读取的描述符表。描述符本身不是目的——它的第一个大客户就是序列化。这一节把那张表接上编码器:同一份字段表,既能驱动可读的 JSON,也能驱动紧凑的二进制。
本章与站内既有内容的分工
站内已经有两篇讲存档的文章,它们和本节的分工必须说清楚,否则你会觉得内容重复。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
| 游戏存档与序列化:存档数据结构、版本迁移、校验与云存档 | 存档系统的玩法侧设计:存什么、JSON/Binary/Protobuf 怎么选、校验与云同步 | 由类型描述符驱动的编码器实现,以及两种格式的真实字节数 |
| Defold 游戏存档与序列化:数据建模、加密校验与版本迁移 | 引擎子系统视角:sys.save / sys.load 与表结构约定 | 从零手写 JSON 与二进制两套编码,不依赖任何引擎 API |
一句话:既有文章回答「存档系统长什么样、该选哪种格式」,本节回答「这个序列化器本身怎么由类型信息驱动着跑起来」。格式选型的取舍请去看上面两篇;这里只写实现和实测。
描述符驱动的编码:同一张字段表,两种格式
编码器只需要字段的种类,不需要字段的具体值。于是 FieldDescriptor 的 kind 就成了一个调度键:JSON 走一条分支,二进制走另一条。下表是本节支持的六种值种类各自的落盘形式。
| kind | JSON 表示 | 二进制表示 | 定长字节 |
|---|---|---|---|
f32 | number | Float32(小端) | 4 |
i32 | number | Int32(小端) | 4 |
bool | boolean | Uint8(0 / 1) | 1 |
string | string | Uint16 长度 + UTF-8 字节 | 2 + n |
handle | number | Uint32(小端) | 4 |
ref / array | 交给对象图序列化器(17.2) | 同左 | 变长 |
handle 之所以能直接编码成 4 字节整数,是因为第 6 章就把句柄设计成了纯整数(6.2 slot map 与世代句柄
)。这也正是那句「句柄可以存进存档」的兑现——引用不能存,句柄能存。
可运行产物:注册表与两个序列化器
三个文件构成这条链路。第一个是序列化用的 schema 注册表:第 16 章的 16.2 类型注册与属性描述符
管的是编辑器视角——字段在紧凑记录里的字节偏移、读写访问器;而序列化需要的是另一套信息——字段的线上种类(string / ref / array 这些编辑器未必关心的)以及一个 schema 版本号。两者描述同一批类型,但用途不同,所以这里单独定义 registry.ts,它是序列化这条链的入口。
// file: src/core/registry.ts
// tier: L1
// verify: npx vitest run src/core/serializer.test.ts
//
// helio 内核:类型注册表。第 16 章的反射底座,序列化器的最小驱动。
/** 字段的运行时种类,序列化器据此选择编码方式。 */
export type FieldKind = 'f32' | 'i32' | 'bool' | 'string' | 'handle' | 'ref' | 'array';
/** 单个字段:名字 + 种类(ref / array 另带目标类型名)。 */
export type FieldDescriptor = {
readonly name: string;
readonly kind: FieldKind;
readonly type?: string;
};
/** 一个类型:名字 + schema 版本 + 字段表。 */
export type TypeDescriptor = {
readonly name: string;
readonly version: number;
readonly fields: readonly FieldDescriptor[];
};
/** 名字到描述符的映射。显式注册,无装饰器、无元数据。 */
export class TypeRegistry {
#types = new Map<string, TypeDescriptor>();
register(desc: TypeDescriptor): void {
if (this.#types.has(desc.name)) throw new Error(`Type already registered: ${desc.name}`);
this.#types.set(desc.name, desc);
}
get(name: string): TypeDescriptor | undefined {
return this.#types.get(name);
}
/** 取描述符;不存在直接抛错——序列化遇到未注册类型属于硬错误。 */
require(name: string): TypeDescriptor {
const d = this.#types.get(name);
if (!d) throw new Error(`Type not registered: ${name}`);
return d;
}
names(): string[] {
return [...this.#types.keys()];
}
}
JSON 编码器最短:遍历描述符的字段,把值原样塞进以字段名做 key 的对象,再加两个元字段 $type 与 $v($v 是 schema 版本,17.3 的迁移全靠它)。
// file: src/core/json-serde.ts
// tier: L1
// verify: npx vitest run src/core/serializer.test.ts
//
// helio 内核:JSON 序列化。字段名直接当 key,可读优先。
import type { FieldDescriptor, TypeRegistry } from './registry.ts';
export const JSON_TYPE_KEY = '$type';
export const JSON_VERSION_KEY = '$v';
/** 未写入的字段按种类给零值,保证反序列化后字段齐全。 */
export function defaultValue(f: FieldDescriptor): unknown {
switch (f.kind) {
case 'bool':
return false;
case 'string':
return '';
case 'f32':
case 'i32':
case 'handle':
return 0;
default:
return undefined;
}
}
export function serializeToJson(
reg: TypeRegistry,
typeName: string,
obj: Record<string, unknown>,
): Record<string, unknown> {
const desc = reg.require(typeName);
const out: Record<string, unknown> = {
[JSON_TYPE_KEY]: desc.name,
[JSON_VERSION_KEY]: desc.version,
};
for (const f of desc.fields) {
const v = obj[f.name];
if (v === undefined) continue;
if (f.kind === 'ref' || f.kind === 'array') {
throw new Error(`Field ${f.name}: ${f.kind} needs the graph serializer`);
}
out[f.name] = v;
}
return out;
}
export function deserializeFromJson(
reg: TypeRegistry,
json: Record<string, unknown>,
): Record<string, unknown> {
const name = json[JSON_TYPE_KEY];
if (typeof name !== 'string') throw new Error('Missing $type in JSON payload');
const desc = reg.require(name);
const obj: Record<string, unknown> = {};
for (const f of desc.fields) obj[f.name] = json[f.name] ?? defaultValue(f);
return obj;
}
二进制编码器手工操作 DataView:BinaryWriter 往一段会按需翻倍的 ArrayBuffer 里按字段种类写定长或变长字段,BinaryReader 按完全相同的顺序读回来。顺序即协议,所以读写两个 switch 必须一一对应。
// file: src/core/binary-serde.ts
// tier: L1
// verify: npx vitest run src/core/serializer.test.ts
//
// helio 内核:二进制序列化。手工写字段:f32/i32/handle 各 4 字节,bool 1 字节,string 变长。
import type { FieldDescriptor, TypeRegistry } from './registry.ts';
import { defaultValue } from './json-serde.ts';
export const BINARY_FORMAT_VERSION = 1;
class BinaryWriter {
#buf = new ArrayBuffer(64);
#view = new DataView(this.#buf);
#bytes = new Uint8Array(this.#buf);
#pos = 0;
#ensure(n: number): void {
if (this.#pos + n <= this.#buf.byteLength) return;
let cap = this.#buf.byteLength;
while (cap < this.#pos + n) cap *= 2;
const next = new ArrayBuffer(cap);
new Uint8Array(next).set(this.#bytes);
this.#buf = next;
this.#view = new DataView(next);
this.#bytes = new Uint8Array(next);
}
u8(v: number): void { this.#ensure(1); this.#view.setUint8(this.#pos, v); this.#pos += 1; }
u16(v: number): void { this.#ensure(2); this.#view.setUint16(this.#pos, v, true); this.#pos += 2; }
u32(v: number): void { this.#ensure(4); this.#view.setUint32(this.#pos, v >>> 0, true); this.#pos += 4; }
i32(v: number): void { this.#ensure(4); this.#view.setInt32(this.#pos, v | 0, true); this.#pos += 4; }
f32(v: number): void { this.#ensure(4); this.#view.setFloat32(this.#pos, v, true); this.#pos += 4; }
str(s: string): void {
const b = new TextEncoder().encode(s);
this.u16(b.length);
this.#ensure(b.length);
this.#bytes.set(b, this.#pos);
this.#pos += b.length;
}
finish(): Uint8Array { return this.#bytes.slice(0, this.#pos); }
}
class BinaryReader {
#view: DataView;
#bytes: Uint8Array;
#pos = 0;
constructor(bytes: Uint8Array) {
this.#bytes = bytes;
this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
u8(): number { const v = this.#view.getUint8(this.#pos); this.#pos += 1; return v; }
u16(): number { const v = this.#view.getUint16(this.#pos, true); this.#pos += 2; return v; }
u32(): number { const v = this.#view.getUint32(this.#pos, true); this.#pos += 4; return v; }
i32(): number { const v = this.#view.getInt32(this.#pos, true); this.#pos += 4; return v; }
f32(): number { const v = this.#view.getFloat32(this.#pos, true); this.#pos += 4; return v; }
str(): string {
const n = this.u16();
const s = new TextDecoder().decode(this.#bytes.subarray(this.#pos, this.#pos + n));
this.#pos += n;
return s;
}
}
function writeField(w: BinaryWriter, f: FieldDescriptor, v: unknown): void {
switch (f.kind) {
case 'f32': w.f32(v as number); break;
case 'i32': w.i32(v as number); break;
case 'handle': w.u32(v as number); break;
case 'bool': w.u8(v ? 1 : 0); break;
case 'string': w.str(v as string); break;
default: throw new Error(`Field ${f.name}: ${f.kind} needs the graph serializer`);
}
}
function readField(r: BinaryReader, f: FieldDescriptor): unknown {
switch (f.kind) {
case 'f32': return r.f32();
case 'i32': return r.i32();
case 'handle': return r.u32();
case 'bool': return r.u8() !== 0;
case 'string': return r.str();
default: throw new Error(`Field ${f.name}: ${f.kind} needs the graph serializer`);
}
}
export function serializeToBinary(
reg: TypeRegistry,
typeName: string,
obj: Record<string, unknown>,
): Uint8Array {
const desc = reg.require(typeName);
const w = new BinaryWriter();
w.u8(BINARY_FORMAT_VERSION);
w.str(desc.name);
w.u16(desc.version);
for (const f of desc.fields) writeField(w, f, obj[f.name] ?? defaultValue(f));
return w.finish();
}
export type BinaryDecodeResult = { schemaVersion: number; data: Record<string, unknown> };
export function deserializeFromBinary(
reg: TypeRegistry,
bytes: Uint8Array,
): BinaryDecodeResult {
const r = new BinaryReader(bytes);
const fmt = r.u8();
if (fmt !== BINARY_FORMAT_VERSION) throw new Error(`Unsupported binary format version: ${fmt}`);
const name = r.str();
const schemaVersion = r.u16();
const desc = reg.require(name);
const data: Record<string, unknown> = {};
for (const f of desc.fields) data[f.name] = readField(r, f);
return { schemaVersion, data };
}
实测:同一对象,JSON 93 字节、二进制 37 字节
注册一个 Player 类型,造一个具体对象,两种格式各写一遍,把字节数打出来。本机实跑 node src/core/serialize-size-demo.ts:
JSON : {"$type":"Player","$v":1,"x":12.5,"y":0,"z":-3.25,"health":100,"name":"Glimmer","alive":true}
JSON bytes : 93
BIN bytes : 37
BIN hex : 01 06 00 50 6c 61 79 65 72 01 00 00 00 48 41 00 00 00 00 00 00 50 c0 64 00 00 00 07 00 47 6c 69 6d 6d 65 72 01
ratio : 2.51x
round-trip : {"x":12.5,"y":0,"z":-3.25,"health":100,"name":"Glimmer","alive":true}
二进制那 37 个字节的构成可以逐段对上,每个字段都是定长的,只有字符串变长:
| 区段 | 内容 | 字节数 |
|---|---|---|
| 格式版本 | 01 | 1 |
| 类型名 | 06 00 + Player(6 字节) | 8 |
| schema 版本 | 01 00 | 2 |
| x / y / z | 三个 Float32,12.5 / 0 / -3.25 | 12 |
| health | Int32 = 100 | 4 |
| name | 07 00 + Glimmer(7 字节) | 9 |
| alive | Uint8 = 1 | 1 |
| 合计 | 37 |
同一个对象,JSON 93 字节、二进制 37 字节,比例 2.51 倍。这个倍数是本机相对量级,会随字段名长度和数值位数变化——字段名越长、数值越接近整数、对象越少,JSON 相对越大。真正稳定的结论是方向:二进制把「字段名」和「分隔符」这两类开销彻底省掉,只留值本身。JSON 里 "health": 这 9 个字符的键名,在二进制里只对应 0 字节。
常见坑
- 读写顺序必须严格对应。二进制没有字段名,读错一个字段的位置,后面全错位。所以
writeField与readField的switch要放在同一屏里对照着改。 - 忘了给小端参数。
DataView的setUint16/setFloat32默认大端,跨端读回会得到乱码;本实现统一传true走小端。 - 字符串长度用
u16是上限 65535 字节。名字、标签这类短文本够用;要存长文本得换成u32长度,否则静默截断。 f32存不下所有 double。12.5能精确表示,0.1不能;需要精确的金额、坐标种子请用i32定点(见 10.2 定点数 )。- 二进制里没写字段名,也就没有「自描述」。解码时必须先拿到
$type对应的描述符——这就是为什么二进制格式把类型名和 schema 版本写进了头部。
JSON 与二进制解决了「值怎么落盘」,但两种编码器都明确拒绝 ref / array:一旦字段指向另一个对象,就会出现同一个对象被写两遍、甚至互相指来指去写不完的问题。这是 17.2 引用、句柄与循环的处理
要解决的。
阅读导航:上一节:目录 · 下一节:17.2 引用、句柄与循环的处理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。