Anki 的 Protocol Buffers 工程实践:跨语言后端接口与存储格式的设计要点
【免费下载链接】ankiAnki is a smart spaced repetition flashcard program项目地址: https://gitcode.com/GitHub_Trending/an/anki
Protocol Buffers(下称 Protobuf)在 Anki 中承担双重职责:既用于 SQLite 数据库内部分数据的持久化存储格式,也用于 Python、Rust、TypeScript 三种语言之间的数据传递,充当类似"带 schema 的 JSON"的角色,但以紧凑的二进制字节形式进行序列化。本文以仓库内的 protobuf.md(源文件为 docs-site/developers/protobuf.mdx)为骨架,结合proto/与rslib/proto/中的实际定义与代码生成器,系统讲解 Anki 中 Protobuf 的命名约定、可选值陷阱、oneof、向后兼容、字段编号等通用规则,以及 Python / TypeScript / Rust 三种实现各自特有的使用细节。读完本文,你将掌握在 Anki 仓库中阅读、修改与生成 Protobuf 定义时所需的关键知识与常见坑位规避方法。
为什么 Anki 需要 Protobuf
在 Anki 的架构中,逻辑核心分为两大部分:后端库(rslib 与 pylib)与 GUI(Qt 与 TypeScript)。其中绝大部分后端逻辑位于 Rust 库 rslib 中,pylib 的调用会代理给 rslib 并返回结果;而 GUI 中的 Qt 代码(qt/aqt/)与 Web 代码(ts/)则需要与后端通信。三种语言之间如何类型安全地传递数据?Anki 的答案是 Protocol Buffers——正如 architecture.md 所述:"Anki uses Protocol Buffers to define backend methods, and the storage format of some items in a collection file. The definitions live inproto/anki/."
从 proto/README.md 的说明可以确认其作用边界:
Protobuf files defining the interface the frontend and backend components use to talk to each other, and how Anki stores some of the data inside its SQLite database. These files are used to generate Rust, Python and TypeScript bindings.
也就是说,proto/anki/下约 24 个.proto文件(如 backend.proto、decks.proto、import_export.proto 等)是唯一的事实来源,同时驱动三种语言的绑定生成:
- Rust:通过 prost 生成 rslib 内部使用的类型;
- Python:通过官方 Python 实现生成
*_pb2.py,供 pylib 使用; - TypeScript:通过 protobuf-es 生成
*_pb.ts,供前端 Web 代码使用。
值得注意的是,目前 Protobuf 不被视为公开 API:虽然部分 pylib 方法会直接把 Protobuf 对象暴露给调用方,但都会使用类型别名包装,调用方不应直接导入生成的_pb2.py文件。
通用规则(General Notes)
无论使用哪种语言实现,以下几条 Anki 内部的 Protobuf 约定都适用。它们源自开发实践中的经验教训,能帮你避免最常见的错误。
命名约定:跟随目标语言的惯用法
生成的代码遵循目标语言的命名规范,因此同一个字段在不同语言中的访问方式不同。以消息字段foo_bar为例:
- TypeScript 中访问
fooBar(驼峰命名); - Rust 中由消息
FooBar生成的命名空间则叫foo_bar(蛇形命名); - Python 生成代码同样遵循 Python 的命名习惯。
这意味着阅读跨语言代码时,不能期望字段名在三种语言中完全一致,理解这一映射关系是定位问题的前提。
可选值:默认值而非 null
在 Python 与 TypeScript 中,未显式设置的可选字段,读取时得到的是该类型的默认值,而不是None/null/undefined。这是最容易踩的坑。例如:
message Foo { optional string name = 1; optional int32 number = 2; }message = Foo() assert message.number == 0 assert message.name == ""在 Python 中,可以使用消息的HasField()方法判断字段是否被真正设置:
message = Foo(name="") assert message.HasField("name") assert not message.HasField("number")注意上例中name被显式设置为空字符串,因此HasField("name")为True;而number从未被设置,所以HasField("number")为False。可见"值等于默认值"与"字段未被设置"是两件完全不同的事。
TypeScript 的体验更不友好(protobuf-es 生成的字段同样会回落到默认值),因此 Anki 的实践是:在活跃使用的字段中尽量设计出能避开默认值歧义的取值方案。仓库中的典型例证是 CsvMetadata 消息:注释明确写道 "Column indices are 1-based to make working with them in TS easier, where unset numerical fields default to 0."——即列索引采用1 起始而非可选的 0 起始,以避免索引为0时与"未设置"混淆。消息内的MappedNotetype.field_columns、deck_column、notetype_column、tags_column、guid_column等字段的注释也都标注了 "One-based. 0 means n/a."。
Oneof:字段隐式可选
oneof 中的所有字段都是隐式可选的,因此上面"可选值"一节的坑对如下消息同样适用:
message Foo { oneof bar { string name = 1; int32 number = 2; } }除了HasField(),Python 中还可以用WhichOneof()获取当前被设置的具体字段名:
message = Foo(name="") assert message.WhichOneof("bar") == "name"WhichOneof("bar")返回被设置的字段名;如果 oneof 中没有字段被设置,则返回None。这在处理"多种互斥的输入方式"(例如 CsvMetadata 中deck与notetype两个 oneof:既可通过 ID 指定已有牌组,也可通过列号映射,还可通过名称新建)时非常有用。
向后兼容:数据库消息需谨慎
Protobuf 官方语言指南对向后兼容做了大量说明,但 Anki 通常不用 Protobuf 在不同客户端之间通信,因此类似"打乱字段编号"这类问题一般不是关注点。
然而,有一类消息例外——会存入数据库的消息,例如Deck(定义于 decks.proto)。如果以不兼容的方式修改这类消息,而协议版本不同的客户端尝试读取它们,就可能引发严重问题。这类修改只有在 schema 升级的框架下进行才是安全的,因为schema 11(在Downgrade时对应的目标 schema)不使用 Protobuf 消息。
字段编号:repeated 字段优先用 1–15
Protobuf 的 varint 编码中,字段编号大于 15 时需要额外的一个字节来编码,因此repeated字段最好分配 1 到 15 之间的编号。相应地,如果一条消息中出现了reserved字段,通常是为了给未来可能新增的repeated字段预留低位编号。
仓库中的实例随处可见,例如 deck_config.proto 中:
// consider saving remaining ones for fsrs param changes reserved 7 to 8;以及 decks.proto 中Deck.Common的reserved 8 to 13;与Deck.Normal的reserved 12 to 15;。而Config消息里大量repeated float字段(learn_steps = 1、relearn_steps = 2、fsrs_params_4 = 3等)也正落在低编号区间,与这一约定吻合。反过来,消息末尾的bytes other = 255;则是一个预留扩展位,用于存放各端特有的附加数据而不破坏主结构。
各语言实现特有问题
Anki 在不同语言中使用了不同的 Protobuf 实现,各自有各自的怪癖。以下分别说明。
Python:官方实现
Python 使用 Protobuf 官方 Python 实现,带有详尽的参考文档。在 Anki 中,生成的*_pb2.py文件由 rslib/proto/python.rs 中的生成器配合官方工具链产出(生成的 Python 绑定最终写入out/pylib/anki/_backend_generated.py,供 pylib/anki/_backend.py 使用)。从 python.rs 的示例可以直观看到生成方法的形态:
def get_field_names_raw(self, message: bytes) -> bytes: return self._run_command(7, 16, message) def get_field_names(self, ntid: int) -> Sequence[str]: message = anki.notetypes_pb2.NotetypeId(ntid=ntid) raw_bytes = self._run_command(7, 16, message.SerializeToString()) output = anki.generic_pb2.StringList() output.ParseFromString(raw_bytes) return output.vals即每个 RPC 方法都生成一个_raw版本(接收序列化后的bytes)与一个类型化版本(负责消息的构造与解析,并把返回消息中唯一字段的值直接解构返回)。由于 Anki 内部的 Python 绑定同时负责序列化与反序列化,HasField()/WhichOneof()在 Python 侧的调试与校验中尤其常用。
TypeScript:protobuf-es
Anki 的 TypeScript 侧使用 protobuf-es 实现,生成的绑定由 rslib/proto/typescript.rs 生成到out/ts/lib/generated,最终经由postProto()将消息投递给后端。生成的方法形态为:
export async function getFieldNames(input: PlainMessage<NotetypeId>, options?: PostProtoOptions): Promise<StringList> { return await postProto("getFieldNames", new NotetypeId(input), StringList, options, ...); }如通用规则所述,protobuf-es 中未设置的数值字段会回落到0,因此 Anki 采用 1-based 索引等设计来规避歧义(参见前文 CsvMetadata 的例子)。此外,typescript.rs 中通过检查返回类型是否为OpChanges及其嵌套层级,为每个方法标注了变更通知类型(None/OpChanges/OpChangesOnly/NestedOpChanges),前端据此决定刷新策略——这也是阅读ts/lib/generated时可能注意到的差异来源。
Rust:prost
Rust 侧使用 prost crate,绑定在构建时由 rslib/proto/rust.rs 通过prost_build::Config编译../../proto下的所有.proto文件生成(同时会输出 file descriptor set,并利用anki_proto_gen为若干消息附加serde、strum等派生 trait)。官方文档对 prost 有一些有用提示,但要查阅生成的代码,更好的方式是在anki/rslib目录下运行:
cargo doc --open --document-private-items在生成的文档中进入pb模块,可以找到所有生成的 Rust 类型及其实现。
使用上有两条 Rust 侧的关键经验:
枚举字段的访问:给定枚举字段
Foo foo = 1;,message.foo在 Rust 中的类型是i32(prost 的默认生成方式),应改用访问器message.foo(),避免手动做i32到Foo的转换。大量
Option的处理:Protobuf 并不保证 oneof 字段一定被设置,也不保证枚举字段一定包含合法变体,因此 Rust 代码中会涌现大量Option。由于 Anki 内部其他部分不会发送非法消息,处理这类情况时使用InvalidInput错误或unwrap_or_default()通常是恰当的。InvalidInput正是 backend.proto 中BackendError.Kind枚举的第一种错误类型(INVALID_INPUT = 0),此外该枚举还包含PROTO_ERROR,专门用于表示 Protobuf 层面的解析失败。
小结与实用建议
将上述要点浓缩为在 Anki 仓库中与 Protobuf 打交道时的检查清单:
| 主题 | 要点 |
|---|---|
| 命名 | 同一字段在 TS 中为驼峰(fooBar),在 Rust 中为蛇形(foo_bar) |
| 可选值 | Python/TS 中未设置的字段返回类型默认值,用HasField()判断是否真正设置 |
| oneof | 字段隐式可选;Python 中用WhichOneof()获取已设置的字段名 |
| 兼容性 | 入库消息(如Deck)的破坏性修改只能在 schema 升级中完成;schema 11 不含 Protobuf |
| 字段编号 | repeated字段优先用 1–15;reserved通常是为未来repeated字段预留低位编号 |
| Rust | 枚举字段用访问器message.foo()而非裸i32;Option处理用InvalidInput或unwrap_or_default() |
| 文档 | 在rslib下运行cargo doc --open --document-private-items,查阅pb模块 |
进一步阅读:仓库中的定义文件位于 proto/anki/,三语言生成器位于 rslib/proto/,架构层面 Protobuf 的角色说明见 architecture.md。
【免费下载链接】ankiAnki is a smart spaced repetition flashcard program项目地址: https://gitcode.com/GitHub_Trending/an/anki
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考