许多数据格式在设计之初都很清爽:一份二进制数据配一份外部 Schema,读写双方使用同一套定义。但几年后,Schema 仓库迁移、版本号失配、团队各自扩展字段,旧文件便可能变成无法解释的字节。受 HTML 和 HTTP 长期兼容能力的启发,Seph Gentle 提出的实验性格式 Schemaboi 选择把自包含 Schema 直接写入文件头,让数据携带解释自身所需的信息。
这个思路关注的不只是传统的向前兼容和向后兼容,还包括“横向兼容”:不同团队或不同分支可以独立扩展格式,随后仍有机会交换、读取并保留彼此的数据,而不必让所有变更先经过一个中央 Schema 管理机构。
文件不再只是数据,而是一份可解释的数据包
传统 Schema 驱动格式通常包含三部分:数据文件、外部 Schema,以及定位正确 Schema 版本的机制。任何一环丢失,都可能让归档数据无法读取。
把 Schema 放进文件头后,一个文件可以在逻辑上组织为:
+------------+----------------+------------------+
| Magic/版本 | 内嵌 Schema | 编码后的数据 |
+------------+----------------+------------------+
读取器先解析一个稳定、尽量精简的引导头,再根据内嵌 Schema 解释后续数据。这样做带来几个直接结果:
- 归档文件不依赖某个长期在线的 Schema Registry。
- 通用工具可以检查未知版本的数据,而不必预先编译业务模型。
- 新增字段时,旧读取器可以跳过或保留自己不认识的内容。
- 旧文件仍携带当时的结构定义,新读取器可以据此补默认值或执行迁移。
这里的关键并非简单地“保存一个版本号”。版本号仍要求读取器在别处找到对应定义;自描述文件则把定义与数据绑定在一起。
三个方向的兼容性分别解决什么问题
向后兼容意味着新程序能读取旧文件。例如,新版用户记录增加 email 字段,读取旧文件时可以使用 Schema 声明的默认值。
向前兼容意味着旧程序面对新文件时不会立刻失败。它可以读取认识的字段,并跳过不认识的字段。若文件还会被旧程序修改并重新写出,读取器最好保留未知字段,否则一次无害的编辑就可能造成数据丢失。
横向兼容处理的是另一种现实:团队 A 增加 timezone,团队 B 同期增加 locale,双方并没有严格的发布先后关系。内嵌 Schema 允许通用读取器理解两种文件的结构。不过,要真正合并这些扩展,仍需稳定的字段标识、命名空间或冲突处理规则;自包含 Schema 并不会自动消除语义冲突。
因此,兼容性最好拆成三个层次判断:
- 可解析:读取器能定位并解码未知字段。
- 可保留:读取、修改、重写后,未知数据仍然存在。
- 可理解:双方对字段含义、单位和约束达成一致。
Schemaboi 的方向主要改善前两个层次。第三个层次依旧需要工程约定。
可以这样实践:构造一个最小自描述文件
下面是一个只使用 Python 标准库的演示格式。它不是 Schemaboi 的正式实现,而是根据“文件头内嵌自包含 Schema”这一思路构造的最小实验。示例使用 JSON 方便观察;生产格式通常还需要更紧凑的编码、长度限制、校验和与安全审计。
将以下内容保存为 self_describing.py,然后运行 python self_describing.py:
import json
import struct
from pathlib import Path
MAGIC = b"SBOI1"
MAX_HEADER = 1024 * 1024
def write_file(path, schema, values):
header = json.dumps(schema, separators=(",", ":")).encode("utf-8")
payload = json.dumps(values, separators=(",", ":")).encode("utf-8")
if len(header) > MAX_HEADER:
raise ValueError("schema header is too large")
Path(path).write_bytes(MAGIC + struct.pack(">I", len(header)) + header + payload)
def read_file(path):
raw = Path(path).read_bytes()
if raw[: len(MAGIC)] != MAGIC:
raise ValueError("unknown file format")
offset = len(MAGIC)
header_size = struct.unpack(">I", raw[offset : offset + 4])[0]
if header_size > MAX_HEADER:
raise ValueError("schema header is too large")
offset += 4
schema = json.loads(raw[offset : offset + header_size])
values = json.loads(raw[offset + header_size :])
return {"schema": schema, "values": values}
def project(document, expected_schema):
"""读取已知字段,同时返回必须原样保留的未知字段。"""
values = document["values"]
known = {
field["name"]: values.get(field["name"], field.get("default"))
for field in expected_schema["fields"]
}
known_names = {field["name"] for field in expected_schema["fields"]}
unknown = {key: value for key, value in values.items() if key not in known_names}
return known, unknown
schema_v1 = {
"name": "User",
"fields": [
{"name": "id", "type": "integer"},
{"name": "name", "type": "string", "default": ""},
],
}
schema_v2 = {
"name": "User",
"fields": schema_v1["fields"]
+ [{"name": "email", "type": "string", "default": ""}],
}
# 新读取器读取旧文件:缺失的新字段使用默认值。
write_file("user-v1.sboi", schema_v1, {"id": 7, "name": "Lin"})
old_document = read_file("user-v1.sboi")
print("new reader ->", project(old_document, schema_v2)[0])
# 旧读取器读取新文件:email 不认识,但仍被保留下来。
write_file(
"user-v2.sboi",
schema_v2,
{"id": 7, "name": "Lin", "email": "lin@example.com"},
)
new_document = read_file("user-v2.sboi")
known, unknown = project(new_document, schema_v1)
print("old reader ->", known)
print("preserved unknown fields ->", unknown)
# 重写时合并未知字段,避免旧程序擦除新数据。
known["name"] = "Lin Chen"
write_file("user-rewritten.sboi", new_document["schema"], {**known, **unknown})
print("rewritten ->", read_file("user-rewritten.sboi"))
预期输出中的关键部分是:新版读取器为旧文件补出空的 email,旧版读取器把 email 放入未知字段集合,并在重写时将它带回文件。后一个行为经常被忽略,却是无损向前兼容的重要条件。
自描述不等于零治理
把 Schema 嵌入文件会增加空间占用。对于大量小文件,可以考虑 Schema 压缩、文件级字典,或者在保持自包含能力的前提下复用同一文件中的定义。读取器还必须把 Schema 当作不可信输入:限制头部长度、嵌套深度、字段数量和内存分配,并拒绝递归或计算成本异常的定义。
采用这类设计时,可以用以下清单检查边界:
- 引导头是否足够稳定,旧读取器能否跳过未来新增的头部区段?
- 字段是否拥有稳定标识,而不只依赖容易重命名的展示名称?
- 未知字段在读写往返后是否逐字节或等价保留?
- 默认值、缺失值和显式
null是否有清晰区别? - Schema 是否支持长度、递归深度和资源消耗限制?
- 横向扩展发生冲突时,是否有命名空间和确定性的合并规则?
- 文件是否需要校验和、签名或来源验证,防止 Schema 与数据被替换?
Schemaboi 所代表的核心判断很务实:长期数据不能把“解释权”完全寄托在外部服务和组织记忆上。把 Schema 与数据一同保存,可以显著提高文件跨时间、跨版本和跨团队流动的能力。但要做到真正无损,读取器还必须尊重未知内容,格式也必须明确资源限制与语义冲突的处理方式。