ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

Anki 的 Protocol Buffers 工程实践:跨语言后端接口与存储格式的设计要点 Anki 的 Protocol Buffers 工程实践跨语言后端接口与存储格式的设计要点【免费下载链接】ankiAnki is a smart spaced repetition flashcard program项目地址: https://gitcode.com/GitHub_Trending/an/ankiProtocol 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与 GUIQt 与 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) nameWhichOneof(bar)返回被设置的字段名如果 oneof 中没有字段被设置则返回None。这在处理多种互斥的输入方式例如 CsvMetadata 中deck与notetype两个 oneof既可通过 ID 指定已有牌组也可通过列号映射还可通过名称新建时非常有用。向后兼容数据库消息需谨慎Protobuf 官方语言指南对向后兼容做了大量说明但 Anki 通常不用 Protobuf 在不同客户端之间通信因此类似打乱字段编号这类问题一般不是关注点。然而有一类消息例外——会存入数据库的消息例如Deck定义于 decks.proto。如果以不兼容的方式修改这类消息而协议版本不同的客户端尝试读取它们就可能引发严重问题。这类修改只有在 schema 升级的框架下进行才是安全的因为schema 11在Downgrade时对应的目标 schema不使用 Protobuf 消息。字段编号repeated 字段优先用 1–15Protobuf 的 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(ntidntid) 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 侧的调试与校验中尤其常用。TypeScriptprotobuf-esAnki 的 TypeScript 侧使用 protobuf-es 实现生成的绑定由 rslib/proto/typescript.rs 生成到out/ts/lib/generated最终经由postProto()将消息投递给后端。生成的方法形态为export async function getFieldNames(input: PlainMessageNotetypeId, options?: PostProtoOptions): PromiseStringList { 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时可能注意到的差异来源。RustprostRust 侧使用 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 中的类型是i32prost 的默认生成方式应改用访问器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–15reserved通常是为未来repeated字段预留低位编号Rust枚举字段用访问器message.foo()而非裸i32Option处理用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),仅供参考
返回列表