数据格式真正棘手的部分不是定义第一版,而是十年后还能不能读。Seph Gentle 提出的实验性格式 Schemaboi 借鉴了 HTML 与 HTTP 的长期适应能力:把自包含的 schema 直接嵌入文件头,让数据不必依赖某个外部定义服务器、包注册表或中央协调者才能被解释。
它瞄准的不只是常见的向前兼容与向后兼容,还包括“横向兼容”:两个团队从同一版本独立增加不同字段后,数据仍可交换、合并,并尽量避免信息丢失。
外置 Schema 为什么会成为长期依赖
许多序列化格式只在文件里保存 schema ID、版本号或类型名称,真正的定义位于代码仓库、注册中心或网络服务中。这在受控系统里很高效,但也制造了隐含依赖:
- 注册中心停机或被清理后,历史文件可能无法解释。
- 类型定义随应用升级,旧数据对应的精确版本可能已经丢失。
- 两个组织使用不同注册中心时,需要先协调编号和发布流程。
- 数据被归档、通过邮件传递或复制到离线环境后,schema 引用可能失效。
把 schema 放进文件头,相当于让文件携带自己的阅读说明。几十年后的读取程序即使不认识具体业务类型,仍有机会检查字段、基础类型和结构,而不是只看到一个失效的类型编号。
代价同样明确:每个文件会增加头部开销,解析器必须把嵌入的 schema 当作不可信输入,而且格式需要规范 schema 长度、递归深度和资源上限。
三个方向的兼容性
向后兼容关注“新程序读取旧数据”。例如新版增加了可选的 display_name 字段,新读取器遇到没有该字段的旧文件时,应采用默认值,而不是拒绝整个记录。
向前兼容关注“旧程序读取新数据”。旧读取器可能不理解新增字段,但仍应读取自己认识的部分。要避免数据在一次旧版本的读写后消失,未知字段最好能够原样保留。
横向兼容处理的是分支演进。假设支付团队增加 billing_region,风控团队同时增加 risk_score,两边没有提前申请同一个中央版本号。文件携带 schema 后,接收方可以看到两个扩展各自的定义,并在名称和类型不冲突时组合它们。
这不意味着所有冲突都能自动解决。如果两个分支用同一个字段名表达不同含义,或者把同一字段分别改成整数和字符串,格式只能暴露冲突,不能替业务团队决定语义。稳定的字段标识、明确的命名空间和“只新增、谨慎改义”的规则仍然重要。
一个可运行的自包含文件实验
下面不是 Schemaboi 的正式 API,而是一个可以直接运行的简化实验,用 JSON 模拟“文件头携带 schema”的思路。它展示三件事:旧读取器只消费已知字段、未知字段被保留,以及文件脱离外部 schema 服务后仍可自描述。
将代码保存为 demo.py 后运行 python demo.py:
import json
from pathlib import Path
MAGIC = "SCHEMA-DEMO-1"
def write_file(path, schema, record):
envelope = {
"magic": MAGIC,
"schema": schema,
"record": record,
}
Path(path).write_text(
json.dumps(envelope, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def read_file(path, known_fields):
envelope = json.loads(Path(path).read_text(encoding="utf-8"))
if envelope.get("magic") != MAGIC:
raise ValueError("unsupported file format")
schema = envelope["schema"]
record = envelope["record"]
declared = {field["name"]: field for field in schema["fields"]}
known = {key: value for key, value in record.items() if key in known_fields}
unknown = {key: value for key, value in record.items() if key not in known_fields}
for key in known:
if key not in declared:
raise ValueError(f"field {key!r} is missing from embedded schema")
return known, unknown, schema
schema_v2 = {
"type": "customer",
"fields": [
{"name": "id", "type": "integer", "required": True},
{"name": "email", "type": "string", "required": True},
{"name": "risk_score", "type": "number", "required": False},
],
}
write_file(
"customer.sdata",
schema_v2,
{"id": 42, "email": "dev@example.com", "risk_score": 0.18},
)
# 模拟只认识 v1 字段的旧程序。
known, unknown, embedded_schema = read_file(
"customer.sdata",
known_fields={"id", "email"},
)
print("旧程序可读取:", known)
print("应原样保留的未知字段:", unknown)
print("文件自带的类型:", embedded_schema["type"])
预期输出如下:
旧程序可读取: {'id': 42, 'email': 'dev@example.com'}
应原样保留的未知字段: {'risk_score': 0.18}
文件自带的类型: customer
真实的二进制实现可以把固定 magic、schema 长度、schema 字节和数据区依次编码,并用哈希或签名保护完整性。读取器还应先检查长度上限,再分配内存,不能直接相信文件头声明的大小。
落地时先约束演进规则
采用这类设计时,可以从归档、跨组织交换或离线数据开始,而不是立即替换数据库内部的高频编码。评估清单包括:
- 文件是否能在没有网络和原始应用代码的环境中解释。
- 新增字段是否默认可选,并有明确默认行为。
- 旧程序能否保留未知字段,避免读写一次就丢数据。
- 字段是否拥有稳定标识或命名空间,以支持分支合并。
- schema 解析是否限制长度、嵌套深度、字段数量和计算成本。
- schema 与数据是否一起接受校验、哈希、签名和权限检查。
Schemaboi 的核心价值不在于再发明一种字段描述语法,而在于重新划定数据的完整边界:如果解释一份数据所需的定义也属于数据,就应该让两者一起迁移、归档和验证。这个选择会增加少量存储与解析成本,却能减少多年之后最昂贵的一类故障,即“字节还在,但已经没人知道它们是什么意思”。