news 2026/9/10 13:22:51

Anki 的 Protocol Buffers 工程实践:跨语言后端接口与存储格式的设计要点

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Anki 的 Protocol Buffers 工程实践:跨语言后端接口与存储格式的设计要点

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_columnsdeck_columnnotetype_columntags_columnguid_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 中decknotetype两个 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.Commonreserved 8 to 13;Deck.Normalreserved 12 to 15;。而Config消息里大量repeated float字段(learn_steps = 1relearn_steps = 2fsrs_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为若干消息附加serdestrum等派生 trait)。官方文档对 prost 有一些有用提示,但要查阅生成的代码,更好的方式是在anki/rslib目录下运行:

cargo doc --open --document-private-items

在生成的文档中进入pb模块,可以找到所有生成的 Rust 类型及其实现。

使用上有两条 Rust 侧的关键经验:

  1. 枚举字段的访问:给定枚举字段Foo foo = 1;message.foo在 Rust 中的类型是i32(prost 的默认生成方式),应改用访问器message.foo(),避免手动做i32Foo的转换。

  2. 大量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()而非裸i32Option处理用InvalidInputunwrap_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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 13:21:56

Web日志分析工具V0.32:异常检测与可视化优化

1. 项目概述希水涵Web日志分析工具V0.32版本是一款面向中小型网站运营者的轻量级日志分析解决方案。作为该系列的第157次迭代更新&#xff0c;本次版本在保持原有核心功能的基础上&#xff0c;重点优化了异常访问识别算法和可视化呈现方式。我在实际部署测试中发现&#xff0c;…

作者头像 李华
网站建设 2026/9/10 13:16:56

GIKT图神经网络实现轻量级知识追踪与习题推荐

简介&#xff1a;本资源是一套基于GIKT深度知识追踪模型的习题推荐系统完整实现&#xff0c;面向计算机、人工智能、教育技术等方向的本科生与研究生&#xff0c;适用于毕业设计、课程大作业及个性化学习系统开发实践。系统采用Flask构建后端服务&#xff0c;Vue实现响应式前端…

作者头像 李华
网站建设 2026/9/10 13:15:01

TVBoxOSC 完整指南:5 分钟把电视盒子变成家庭娱乐中心

TVBoxOSC 完整指南&#xff1a;5 分钟把电视盒子变成家庭娱乐中心 【免费下载链接】TVBoxOSC TVBoxOSC - 一个基于第三方项目的代码库&#xff0c;用于电视盒子的控制和管理。 项目地址: https://gitcode.com/GitHub_Trending/tv/TVBoxOSC 家里那台电视盒子&#xff0c;…

作者头像 李华