Graven

为需要长期保存的数据而设计的二进制格式。

Graven 是一个零拷贝序列化格式及其 schema 语言的开放规范。消息直接在缓冲区中原地读取。格式已经冻结并有完整的规范,其一致性测试向量让任何语言的实现都能证明自己正确读取每一条消息。

受 FlatBuffers 和 Cap'n Proto 启发。我们从它们的思路出发,用基准测试衡量各自的取舍,并持续探索,最终得到了这个格式。 我们是如何走到这里的

格式 v1 已冻结。Rust 参考实现。MIT 许可证。

规范和技术文档为英文。

逐字节解读一条消息
namespace bank;

table Entry {
  account @0: u64;
  amount  @1: i64;
  memo    @2: string?;
}

    为比写入它的代码活得更久的数据而生

    业务数据只写一次,却要被读取很多年:被下一个版本读取,被稍后才升级的服务读取,被原团队早已离开之后的审计读取。Graven 正是围绕这一现实设计的。

    原地读取
    字段直接从字节中读取。字符串和字节切片借用自缓冲区,因此在访问某个字段之前,读取一条大记录没有任何开销。
    演进无需猜测
    lockfile 记录每一个布局。编译器会拒绝导致新旧代码不一致的修改,并在任何修改破坏旧版读取方之前要求显式确认。
    可以安全地打开不可信的字节
    一次 verify 在固定的深度和工作量限制下遍历整条消息。之后,已验证视图读取时无需再次检查,而格式错误的输入只会产生错误。
    清楚文件发生了什么
    可选的信封携带 schema id 和 CRC32C。因崩溃而中断的写入被报告为不完整;翻转的比特被报告为损坏。两者绝不会混淆。

    格式如何保持可信

    规范中的每一项承诺都由可运行的检查来保证。以下是检查的内容和方式。

    保证检查方式
    线上格式不会漂移 一份规范性文档(采用 RFC 2119 用语)和 42 个冻结的一致性测试向量,每个向量是一个 .bin及其预期结果。参考写入器必须逐字节复现规范向量。
    两种实现结果一致 在参考实现内部,一个以 schema 为数据驱动的独立解释器和生成的代码会读取每个向量及其每一个单字节变异。二者必须以完全相同的方式接受、拒绝和解码。每个新实现都以同样的向量来检验。
    恶意输入受到限制 verify 将深度限制为 64,总工作量限制为 max(len × 8, 1 MiB),因此循环和共享数据放大都会被拒绝。五个模糊测试目标在开启溢出检查的情况下运行了超过 8000 万个输入,没有发现任何问题。
    unsafe 快速路径是可靠的 在 Rust 实现中,唯一的 unsafe 代码是在 verify 之后跳过 UTF-8 重新校验。其不变量有书面说明,并由变异测试强制保证;运行时和生成的代码在 Miri 下运行,未报告任何未定义行为。
    Schema 修改不会悄悄破坏数据 编译器将每次修改与 lockfile 对比:15 条规则拒绝会误读旧数据的修改,2 条警告要求在 schema 中确认。一组版本链语料测试每一条规则。
    崩溃与损坏可以区分 信封的检查顺序是规范性的,因此被截断的尾部和损坏的记录在每个实现上都会产生不同且可预期的错误。

    Schema 修改由编译器审查

    修改字段类型或添加必填字段时,gravenc update 会拒绝,并指出规则和行号。改为添加带默认值的字段,新的 lock 就会写入。以下是真实输出。

    被拒绝

    table Entry {
      account  @0: u64;
      amount   @1: i32;
      memo     @2: string?;
      currency @3: u16;
    }
    $ gravenc update ledger.graven
    ledger.graven:5:3: field `Entry.amount` (@1): type changed
      from `i64` to `i32` (type-changed)
    ledger.graven:7:3: new field `Entry.currency` (@3) is
      required: every new reader would fail on old messages;
      use `u16?` or a default (new-required)

    被接受

    table Entry {
      account  @0: u64;
      amount   @1: i64;
      memo     @2: string?;
      currency @3: u16 = 986;
    }
    $ gravenc update ledger.graven
    ledger.graven.lock: written

    适用场景

    凡是记录只写一次、长期保存、并由不断变化的软件读取的地方,都适合使用 Graven。

    账本与事件日志
    将信封追加到文件中,永不改写。每条记录都带有自己的 schema id 和校验和,崩溃只会留下可检测的不完整尾部,报表可以直接扫描文件而无需反序列化。
    服务与 SDK 协议
    按 schema id 路由请求,在入口处验证一次,然后无拷贝地传递已验证视图。兼容性规则确保服务器升级后,旧版客户端仍能正常工作。
    归档与审计记录
    一个拥有公开一致性测试向量的冻结格式,十年后依然可以解码,必要时还可以换用另一种实现。
    设备与离线同步
    该格式不需要内存分配器,也不需要对齐;Rust 运行时支持 no_std,只有两个依赖,因此同样的记录可以来自收银终端和嵌入式设备,在离线时批量积累。
    缓存与存储值
    把记录存入键值存储,只读取需要的字段,直接从存储的字节中读取。
    插件边界
    与不可信的扩展(例如 WebAssembly 模块)交换缓冲区,由 verify保护宿主免受它们返回的任何数据的影响。

    实现

    规范本身就是产品,各个实现都以它为衡量标准。一个符合规范的实现必须以完全符合预期的结果读取全部 42 个向量(规范 §10)。

    语言状态包含内容
    Rust 参考实现。通过全部 v1 向量。 运行时(no_std)、带 lockfile 和兼容性检查的 gravenc 编译器、Rust 代码生成,以及一致性解释器。API 参考

    编写一个实现

    从布局算法(规范 §3.3)、读取规则(§4–§6)和 verify(§7)开始,然后运行一致性测试向量。每个向量是一个 .bin 加一个.json,后者给出解码后的值或确切的错误;每个无效向量只有一个缺陷,因此一次失败只指向一条规则。

    为其他语言生成代码

    schema 语言及其兼容性规则与编程语言无关。gravenc 写入的 lockfile已经记录了每张表的布局、schema id 和字段存在性,这正是其他语言的代码生成器生成读取器和写入器所需要的。

    使用 Rust 快速上手

    使用参考实现只需要 Rust 工具链。

    1. 安装编译器

      cargo install --path crates/graven-compiler
    2. 编写 schema 并创建 lockfile

      gravenc update schemas/bank.graven --init
    3. 在 build.rs 中生成代码

      graven_compiler::build::Build::new()
          .schema("schemas/bank.graven")
          .compile()
          .unwrap_or_else(|e| panic!("{e}"));
    4. 写入与读取

      let bytes = TransactionW::to_bytes(&mut b, |t| {
          t.id(42).currency(Currency::USD);
      })?;
      let tx = Transaction::from_bytes(&bytes)?;
      println!("{}", tx.id());

    快速上手指南和schema 语言参考(英文)涵盖完整的工作流程。