设计一个面向未来的 API 异常困难。本文档中的建议通过权衡取舍,以支持长期无缺陷的演进。
- 精准简洁地文档化字段和消息
- 区分传输层与存储层的消息结构
- 支持局部更新或仅追加更新,而非完全替换
- 避免在顶层请求/响应中包含原始类型
- 切勿用布尔值表示未来可能扩展的状态
- 避免使用整型字段作为 ID
- 勿在字符串中编码需客户端构造/解析的数据
- 通过 Web 安全编码将二进制序列化数据编码为字符串
- 勿包含客户端无需的字段
- 定义分页 API 时务必包含续传令牌
- 关联字段分组为消息,仅高内聚字段嵌套
- 在读取请求中包含字段读取掩码
- 包含版本字段以实现一致性读取
- 相同数据类型的 RPC 使用一致的请求选项
- 批处理/多阶段请求
- 创建返回/操作小数据块的方法,客户端通过批处理组合 UI
- 移动端/Web 需避免串行往返时,应创建一次性 RPC
- 重复字段使用消息而非标量或枚举
- 使用 Proto Map
- 优先保证幂等性
- 谨慎命名服务并确保全局唯一性
- 限制请求/响应大小
- 谨慎传播状态码
- 为每个方法创建专属 Proto
- 附录
本文档是对 Proto 最佳实践的补充, 并非 Java/C++/Go 等 API 的规范。
⚠️ 注意
这些准则并非绝对,许多情况都有例外。例如, 若编写性能关键的后端服务,可能需牺牲灵活性或安全性换取速度。 本文旨在帮助您理解权衡取舍,做出适合自身场景的决策。
精准简洁地文档化字段和消息
您的 proto 很可能被不了解原始设计思路的继承者使用。 请以新团队成员或系统知识有限的客户端视角, 为每个字段编写实用文档。
具体示例:
// 反面示例:启用 Foo 的选项
// 正面示例:控制 Foo 功能行为的配置
message FeatureFooConfig {
// 反面示例:设置功能是否启用
// 正面示例:必填字段,指示 Foo 功能是否对 account_id 启用。
// 若 account_id 未设置 FOO_OPTIN Gaia 位,必须为 false。
optional bool enabled;
}
// 反面示例:Foo 对象
// 正面示例:API 中暴露的 Foo (what/foo) 客户端表示
message Foo {
// 反面示例:Foo 的标题
// 正面示例:表示用户提供的 Foo 标题(未经标准化或转义)
// 示例标题:"Picture of my cat in a box <3 <3 !!!"
optional string title [(max_length) = 512];
}
// 反面示例:Foo 配置
// 次优方案:若最有用的注释仅是复述名称,不如省略注释
3; FooConfig foo_config =
用最简练的语言描述每个字段的约束、期望和解释规则。
可使用自定义 proto 注解(如示例中的 max_length
), 详见自定义选项。 支持 proto2 和 proto3。
接口文档会随时间增长,冗长将降低清晰度。 当文档确实不清晰时,应修复它, 但需整体审视并追求简洁。
区分传输层与存储层的消息结构
若客户端接口与磁盘存储使用相同的顶层 proto, 将引发隐患。随时间推移,更多二进制文件会依赖您的 API, 使其难以修改。您需要在不影响客户端的情况下自由变更存储格式。 通过分层代码,让模块分别处理客户端 proto、存储 proto 或转换逻辑。
为何? 您可能需要更换底层存储系统, 调整数据归一化/反归一化策略, 或发现部分客户端 proto 适合存入 RAM 而其他部分应落盘。
对于嵌套在请求/响应中的 proto, 分离存储层与传输层的必要性会降低, 这取决于您希望客户端与这些 proto 的耦合程度。
维护转换层虽有成本,但在拥有客户端 且需首次变更存储格式时,其价值会迅速显现。
若试图”需要时再分离”共享 proto, 由于分离成本高且内部字段无处存放, API 将积累客户端不理解或未经授权就依赖的字段。
通过独立的 proto 文件起步, 团队将明确添加内部字段的位置, 避免污染 API。早期可通过自动转换层 (如字节复制或 proto 反射)保持传输层 proto 完全一致。 Proto 注解也可驱动自动转换层。
例外情况:
- 若字段属于通用类型(如
google.type
或google.protobuf
), 可同时用于存储和 API - 服务对性能极度敏感时,可用灵活性换取执行速度。 若服务未达到毫秒级延迟的数百万 QPS,通常不属此类例外
- 同时满足以下条件:
- 您的服务即是存储系统
- 系统不基于客户端的结构化数据做决策
- 系统仅按客户端请求存储、加载或查询数据
注意:若实现日志系统或通用存储的 proto 包装器, 应让客户端消息尽可能透明地进入存储后端, 避免创建依赖枢纽。可考虑使用扩展或 通过 Web 安全编码将二进制序列化数据编码为字符串。
支持局部更新或仅追加更新,而非完全替换
避免创建仅接收 Foo
的 UpdateFooRequest
。
若客户端不保留未知字段,其 GetFooResponse
将缺少新字段, 导致往返数据丢失。部分系统不保留未知字段。 Proto2/proto3 实现会保留未知字段,除非显式丢弃。 通常公共 API 应在服务端丢弃未知字段以防止安全攻击 (例如垃圾未知字段可能在服务端未来将其用作新字段时引发故障)。
未明确文档化时,可选字段的处理存在歧义: UpdateFoo
会清空字段吗?当客户端未知该字段时会导致数据丢失。 不修改字段?客户端如何清空字段?两种方案均不理想。
解决方案1:使用更新字段掩码
客户端传递需修改的字段,服务端仅更新掩码指定字段。 掩码结构应与响应 proto 结构镜像: 若 Foo
包含 Bar
,则 FooMask
应包含 BarMask
。
解决方案2:暴露更细粒度的原子操作
例如,用 PromoteEmployeeRequest
、SetEmployeePayRequest
、 TransferEmployeeRequest
等替代 UpdateEmployeeRequest
。
定制更新方法比通用更新方法更易监控、审计和防护。 其实现和调用也更简单。但过多定制方法会增加 API 认知负担。
避免在顶层请求/响应中包含原始类型
本规则可规避文档中描述的诸多陷阱。
例如:通过消息包装重复字段, 可区分存储层的未设置状态与特定调用中的未填充状态。
共享的通用请求选项将自然遵循此规则, 读写字段掩码也由此衍生。
顶层 proto 应始终作为可独立扩展的其他消息的容器。
即使当前仅需单个原始类型,将其包装在消息中 也为后续扩展和类型共享提供清晰路径。例如:
message MultiplicationResponse {
// 反面示例:后续需返回复数时如何处理?
// 若 AdditionResponse 需返回相同多字段类型?
optional double result;
// 正面示例:其他方法可复用此类型,随服务功能扩展而演进
// (如添加单位、置信区间等)
optional NumericResult result;
}
message NumericResult {
optional double real_value;
optional double complex_value;
optional UnitType units;
}
例外:若字符串(或字节)实际编码 proto 且仅在服务端构建解析, 可将续传令牌、版本令牌和 ID 作为字符串返回, 但字符串必须是结构化 proto 的编码(遵循下文准则)。
切勿用布尔值表示未来可能扩展的状态
若字段确实仅描述两种状态(永久性而非短期),可使用布尔值。 通常枚举、整型或消息提供的灵活性更值得投入。
例如,返回帖子流时,开发者可能需根据当前 UX 原型 决定是否双栏渲染。即使当前仅需布尔值, 也无法阻止未来引入双行帖、三栏帖或四宫格帖。
message GooglePlusPost {
// 反面示例:是否跨双栏渲染
optional bool big_post;
// 正面示例:客户端渲染提示
// 客户端应据此决定渲染突出程度,缺省时使用默认渲染
optional LayoutConfig layout_config;
}
message Photo {
// 反面示例:是否为 GIF
optional bool gif;
// 正面示例:引用照片的文件格式(如 GIF/WebP/PNG)
optional PhotoType type;
}
谨慎添加枚举状态。若状态引入新维度或隐含多重行为, 几乎必然需要新字段。
避免使用整型字段作为 ID
用 int64 作为对象 ID 很诱人,但应优先选择字符串。
这便于后续变更 ID 空间并减少冲突概率。 2^64 在现代已非足够大。
也可将结构化 ID 编码为字符串,鼓励客户端视其为不透明数据。 此时仍需 proto 支撑字符串,但序列化为字符串字段 (如 Web-safe Base64 编码)可从客户端 API 剥离内部细节。 具体遵循下文准则。
message GetFooRequest {
// 指定获取的 Foo
optional string foo_id;
}
// 序列化并 websafe-base64 编码到 GetFooRequest.foo_id
message InternalFooRef {
// 仅设置其一。已迁移的 Foo 使用 spanner_foo_id,
// 未迁移的使用 classic_foo_id
optional bytes spanner_foo_id;
optional int64 classic_foo_id;
}
若自行设计 ID 字符串序列化方案,可能快速引发问题。 因此最好用内部 proto 支撑字符串字段。
勿在字符串中编码需客户端构造/解析的数据
网络传输效率低,增加 proto 消费者工作量, 且令文档读者困惑。客户端还需考虑编码细节: 列表是否逗号分隔?非信任数据是否正确转义?数字是否十进制? 更好的方案是让客户端发送实际消息或原始类型, 网络传输更紧凑,客户端更清晰。
当服务拥有多语言客户端时尤其严重, 每个客户端需选择正确解析器/构建器(或更糟——自行编写)。
通用原则:选择正确的原始类型。 参见 Protocol Buffer 语言指南中的标量类型表。
勿在前端 proto 中返回 HTML
对 JavaScript 客户端,在 API 字段中返回 HTML 或 JSON 很诱人, 但这易导致 API 与特定 UI 绑定。三大风险:
- 非 Web 客户端可能解析您的 HTML/JSON 获取数据, 若您变更格式会导致脆弱性,解析不当则引发漏洞
- 若 HTML 未净化返回,Web 客户端面临 XSS 攻击风险
- 返回的标签和类依赖特定样式表和 DOM 结构。 随版本迭代结构变更,可能引发版本偏差问题: 旧版客户端无法正确渲染新版服务返回的 HTML
除初始页面加载外, 通常应在客户端返回数据并用客户端模板构建 HTML。
通过 Web 安全编码将二进制序列化数据编码为字符串
若需在客户端可见字段编码不透明数据 (续传令牌、序列化 ID、版本信息等), 需文档化要求客户端视其为不透明数据。 始终使用二进制 proto 序列化,勿用文本格式或自定义方案。
定义内部 proto 保存字段(即使仅需一个字段), 序列化为字节后,用 Web-safe base64 编码到字符串字段。
罕见例外:若定制格式的紧凑性收益显著,可替代序列化方案。
勿包含客户端无需的字段
暴露给客户端的 API 应仅描述系统交互方式。 添加其他内容会增加理解成本。
过去常在响应 proto 返回调试数据,现有更佳方案: RPC 响应扩展(或称”旁路通道”)可分离客户端接口与调试接口。
类似地,返回实验名称曾是日志便利手段 ——隐含约定是客户端在后续操作中回传这些实验。 当前推荐方案是在分析管道中进行日志关联。
例外情况: 若需实时持续分析且机器资源紧张, 日志关联成本过高时可预先反规范化日志数据。 如需日志数据往返传递,可作为不透明数据发送给客户端, 并文档化请求/响应字段。
注意:若需在每个请求中返回或往返隐藏数据, 您掩盖了服务的真实使用成本,这同样不利。
定义分页 API 时务必包含续传令牌
message FooQuery {
// 反面示例:若两次查询间数据变更,以下策略均可能导致结果遗漏
// 在最终一致性系统(如 Bigtable 存储)中,新旧数据交错出现很常见
// 且偏移量/分页方案均假设排序规则,丧失灵活性
optional int64 max_timestamp_ms;
optional int32 result_offset;
optional int32 page_number;
optional int32 page_size;
// 正面示例:灵活性!在 FooQueryResponse 中返回,
// 客户端下次查询时回传
optional string next_page_token;
}
分页 API 最佳实践是使用不透明续传令牌(称 next_page_token), 由内部 proto 支撑(序列化后执行 WebSafeBase64Escape
(C++) 或 BaseEncoding.base64Url().encode
(Java))。内部 proto 可包含多字段, 关键是通过灵活性为客户端提供结果稳定性。
勿忘验证 proto 字段的不可信输入 (参见准则)。
message InternalPaginationToken {
// 追踪已见 ID:以续传令牌增大为代价实现完美召回
repeated FooRef seen_ids;
// 类似 seen_ids 策略,但用布隆过滤器节省字节(牺牲精度)
optional bytes bloom_filter;
// 合理初版方案。嵌入续传令牌可在不影响客户端的情况下变更
optional int64 max_timestamp_ms;
}
关联字段分组为消息,仅高内聚字段嵌套
message Foo {
// 反面示例:Foo 的价格和货币类型
optional int price;
optional CurrencyType currency;
// 更佳方案:封装 Foo 的价格和货币类型
optional CurrencyAmount price;
}
仅高内聚字段应嵌套。若字段确实相关, 在服务内部传递时会更便捷。例如:
calculateLocalTax(CurrencyAmount price, Location where) CurrencyAmount
若变更引入一个字段,但该字段后续可能有相关字段, 应预先放入独立消息以避免:
message Foo {
// 已弃用!使用 currency_amount
optional int price [deprecated = true];
// Foo 的价格和货币类型
optional google.type.Money currency_amount;
}
嵌套消息的问题是:CurrencyAmount
可能在 API 其他位置复用, 但 Foo.CurrencyAmount
可能不会。最坏情况是复用 Foo.CurrencyAmount
, 但 Foo
专属字段泄漏其中。
虽然松耦合 是系统开发的通用准则,但设计 .proto
文件时未必适用。 若两个信息单元高度相关(通过嵌套),可能有意义。 例如,若创建一组当前较通用的字段, 但预期未来添加专属字段,嵌套消息可阻止他人 在其他 .proto
文件中引用该消息。
message Photo {
// 反面示例:PhotoMetadata 可能在 Photo 范围外复用
// 因此不嵌套更易访问是优选
message PhotoMetadata {
optional int32 width = 1;
optional int32 height = 2;
}optional PhotoMetadata metadata = 1;
}
message FooConfiguration {
// 正面示例:在 FooConfiguration 外复用 Rule
// 会与无关组件紧耦合,嵌套可避免此问题
message Rule {
optional float multiplier = 1;
}repeated Rule rules = 1;
}
在读取请求中包含字段读取掩码
// 推荐方案:使用 google.protobuf.FieldMask
// 替代方案一:
message FooReadMask {
optional bool return_field1;
optional bool return_field2;
}
// 替代方案二:
message BarReadMask {
// 需返回的 Bar 字段标签号
repeated int32 fields_to_return;
}
若使用推荐的 google.protobuf.FieldMask
, 可用 FieldMaskUtil
(Java/C++) 库自动过滤 proto。
读取掩码为客户端设定期望,控制需返回的数据量, 并让后端仅获取必要数据。
可接受替代方案是始终填充所有字段(即隐式全 true 读取掩码)。 但随着 proto 增长,成本会上升。
最差模式是存在未声明的隐式读取掩码, 且根据填充方法不同而变化。此反模式会导致 通过响应 proto 构建本地缓存的客户端出现数据丢失。
包含版本字段以实现一致性读取
当客户端写入后立即读取同一对象时, 期望得到所写内容(即使底层存储系统不保证此行为)。
服务端读取本地值后,若本地 version_info 低于预期, 将从远端副本读取最新值。通常 version_info 是 字符串编码的 proto, 包含数据中心和提交时间戳。
即使由一致性存储支撑的系统, 也常需令牌触发高成本的一致性读取路径, 而非每次读取均承担此开销。
相同数据类型的 RPC 使用一致的请求选项
反面模式示例:某服务中每个返回相同数据类型的 RPC, 却有独立选项指定最大评论数、支持的嵌入类型列表等。
临时方案会增加客户端填写请求的复杂性, 及服务端转换 N 个选项为通用内部选项的复杂性。 现实中的大量 bug 可追溯至此。
应创建独立的消息容纳请求选项, 并包含在每个顶层请求消息中。改进方案:
message FooRequestOptions {
// 字段级读取掩码。仅返回请求的字段。
// 客户端应仅请求所需字段以帮助后端优化
optional FooReadMask read_mask;
// 响应中每个 Foo 返回的最大评论数(垃圾评论不计入)。
// 默认不返回评论
optional int max_comments_to_return;
// 包含非支持类型嵌入的 Foo,将降级转换为此列表中的类型。
// 未指定列表时不返回嵌入内容。若无法降级转换,则不返回嵌入。
// 强烈建议客户端始终包含 EmbedTypes.proto 中的 THING_V2 类型
repeated EmbedType embed_supported_types_list;
}
message GetFooRequest {
// 读取的 Foo。若查看者无权访问或 Foo 已删除,响应为空但成功
optional string foo_id;
// 客户端必须包含此字段。若 FooRequestOptions 为空,
// 服务端返回 INVALID_ARGUMENT
optional FooRequestOptions params;
}
message ListFooRequest {
// 返回的 Foo。搜索召回率 100%,但子句越多性能影响越大
optional FooQuery query;
// 客户端必须包含此字段。若 FooRequestOptions 为空,
// 服务端返回 INVALID_ARGUMENT
optional FooRequestOptions params;
}
批处理/多阶段请求
尽可能保证操作的原子性,更重要的是保证 幂等性。部分失败后的重试不应破坏/重复数据。
有时出于性能需单个 RPC 封装多个操作。 部分失败时如何处理?若部分成功部分失败, 最佳方案是让客户端知晓两者详情。
通常建议将 RPC 设为失败状态, 在 RPC 状态 proto 中返回成功和失败的详情。 理想情况是:未感知部分失败的客户端仍能正确处理, 感知的客户端可获取额外价值。
创建返回/操作小数据块的方法,客户端通过批处理组合 UI
通过单次往返查询多个细粒度数据的能力, 允许客户端组合所需内容,无需服务端变更即可支持更广的 UX 范围。
这对前端和中层服务器最相关。
许多服务提供自有批处理 API。
移动端/Web 需避免串行往返时,应创建一次性 RPC
当Web/移动端客户端需进行两次有数据依赖的查询时, 当前最佳实践是创建新 RPC 避免客户端额外往返。
对移动端,几乎总值得通过捆绑两个服务方法为单一新方法 节省往返开销。对服务间调用,情况可能不同, 取决于服务性能敏感度和新方法的认知开销。
重复字段使用消息而非标量或枚举
常见演进场景是单个重复字段需变为多个关联重复字段。 若以原始类型起步,选项有限:要么创建并行重复字段, 要么定义含值容器的新重复字段并迁移客户端。
若以重复消息起步,演进将变得简单。
// 描述照片增强类型
enum EnhancementType {
ENHANCEMENT_TYPE_UNSPECIFIED;
RED_EYE_REDUCTION;
SKIN_SOFTENING;
}
message PhotoEnhancement {
optional EnhancementType type;
}
message PhotoEnhancementReply {
// 正面示例:PhotoEnhancement 可扩展描述需更多字段的增强类型
repeated PhotoEnhancement enhancements;
// 反面示例:若需返回增强相关参数,
// 需引入并行数组(糟糕)或弃用此字段改用重复消息
repeated EnhancementType enhancement_types;
}
设想需求:“需区分用户执行的增强与系统自动应用的增强”。
若 PhotoEnhancementReply
中是标量或枚举,支持此需求将更困难。
此原则同样适用于 map。若 map 值已是消息类型, 添加字段远比从 map<string, string>
迁移到 map<string, MyProto>
简单。
例外: 延迟敏感型应用会发现原始类型的并行数组比消息数组 构建/删除更快。若使用 packed=true(省略字段标签), 网络传输也更小。Proto3 中自动打包。 分配固定数量数组比分配 N 个消息开销更小。
使用 Proto Map
在 Proto3 引入 Map 类型前, 服务常使用临时 KVPair 消息暴露数据。当客户端需要深层结构时, 最终需设计需解析的键/值(参见准则)。
因此,对值使用(可扩展的)消息类型是基础改进。
Map 已向后移植到 proto2,故使用 map<标量, **消息**>
优于自定义 KVPair1。
若需表示结构未知的任意数据, 请使用 google.protobuf.Any
。
优先保证幂等性
上层客户端可能包含重试逻辑。若重试的是变更操作, 用户可能遭遇意外:重复评论、构建请求、编辑等对任何人都不利。
避免重复写入的简单方案是允许客户端指定请求 ID(如内容哈希或 UUID), 服务端依此去重。
谨慎命名服务并确保全局唯一性
服务名称(即 .proto
文件中 service
关键字后的部分) 用途远超生成服务类名,使其重要性超乎预期。
棘手之处在于这些工具隐含假设服务名称在网络中唯一。 更糟的是它们使用非限定服务名(如 MyService
), 而非限定名(如 my_package.MyService
)。
因此,即使服务定义在特定包内, 也应采取措施防止服务名冲突。 例如 Watcher
可能引发问题, MyProjectWatcher
更佳。
限制请求/响应大小
请求/响应大小应有上限。建议约 8 MiB, 2 GiB 是多数 proto 实现的硬限制。 许多存储系统对消息大小有限制。
此外,无界消息会:
- 膨胀客户端和服务端
- 导致高且不可预测的延迟
- 依赖长连接降低弹性
限制消息大小的方案: - 定义返回有界消息的 RPC,每次调用逻辑独立 - 定义操作单个对象的 RPC,而非操作客户端指定的无界列表 - 避免在字符串、字节或重复字段编码无界数据 - 定义长时运行操作。将结果存入支持可扩展并发读取的存储系统 - 使用分页 API(参见准则) - 使用流式 RPC
若开发 UI,另见准则。
谨慎传播状态码
RPC 服务应在边界检查错误,并向调用方返回有意义的状态错误。
以简单示例说明:
假设客户端调用无参数的 ProductService.GetProducts
。 执行过程中,ProductService
获取所有产品, 并为每个产品调用 LocaleService.LocaliseNutritionFacts
。
digraph toy_example {
node [style=filled]
client [label="Client"];
product [label="ProductService"];
locale [label="LocaleService"];
client -> product [label="GetProducts"]
product -> locale [label="LocaliseNutritionFacts"]
}
若 ProductService
实现错误,可能向 LocaleService
发送错误参数导致 INVALID_ARGUMENT
。
若 ProductService
草率返回错误,客户端将收到 INVALID_ARGUMENT
(状态码跨 RPC 传播)。 但客户端未向 ProductService.GetProducts
传递任何参数! 该错误比无用更糟:将引发严重困惑!
正确做法:ProductService
应在 RPC 边界检查错误 (即其实现的 RPC 处理程序)。若自身收到无效参数, 应返回 INVALID_ARGUMENT
;若下游收到无效参数, 应先将 INVALID_ARGUMENT
转为 INTERNAL
再返回。
草率传播状态错误将导致调试成本高昂的困惑, 甚至因服务转发客户端错误却不触发警报, 引发不可见的中断。
通用规则:在 RPC 边界仔细检查错误, 向调用方返回带合适状态码的有意义错误。 为准确传达意图,每个 RPC 方法应文档化其返回的错误码及场景。 方法实现应符合文档约定的 API 契约。
为每个方法创建专属 Proto
为每个 RPC 方法创建唯一的请求/响应 proto。 后期发现需变更顶层请求/响应时成本高昂。 包括”空”响应:应创建专属空响应 proto, 而非复用知名 Empty 类型。
消息复用
复用消息时,创建共享”领域”消息类型供多个请求/响应 proto 包含。 应用逻辑应基于这些类型而非请求/响应类型编写。
这使您能独立演进方法请求/响应类型, 同时共享逻辑子单元的代码。
附录
返回重复字段
当重复字段为空时,客户端无法区分是服务端未填充, 还是底层数据确实为空(即重复字段无 hasFoo
方法)。
用消息包装重复字段是获得 hasFoo 方法的简单方案:
message FooList {
repeated Foo foos;
}
更系统的方案是使用字段读取掩码。 若字段被请求,空列表表示无数据; 若未请求,客户端应忽略响应中的该字段。
更新重复字段
最差方案是强制客户端提供完整替换列表。 此方案风险包括:不保留未知字段的客户端导致数据丢失; 并发写入导致数据丢失;即使无此问题, 客户端也需仔细阅读文档才能理解服务端解释逻辑 (空字段表示不更新还是清空?)。
解决方案1:使用允许客户端替换、删除或插入元素的重复更新掩码, 无需在写入时提供整个数组。
解决方案2:在请求 proto 中创建独立的追加、替换、删除数组。
解决方案3:仅允许追加或清空。 可通过消息包装重复字段实现: 若存在但为空的消息表示清空,否则重复元素表示追加。
重复字段的顺序无关性
尽量避免顺序依赖,这是额外的脆弱层。 最恶劣的顺序依赖是并行数组, 这会增加客户端解读结果的难度, 并使其在服务内部传递时变得不自然。
message BatchEquationSolverResponse {
// 反面示例:解值按请求方程顺序返回
repeated double solved_values;
// (通常)反面示例:solved_values 的并行数组
repeated double solved_complex_values;
}
// 正面示例:独立消息可扩展更多字段并被其他方法复用。
// 请求与响应间无顺序依赖,多重复字段间无顺序依赖
message BatchEquationSolverResponse {
// 已弃用,2014 年 Q2 前响应将继续填充此字段,
// 之后客户端必须改用下方的 solutions 字段
repeated double solved_values [deprecated = true];
// 正面示例:请求中的每个方程有唯一 ID,
// 包含在下方的 EquationSolution 中以便与解关联。
// 方程并行求解,解产生后加入此数组
repeated EquationSolution solutions;
}
因 Proto 进入移动构建导致特性泄露
Android/iOS 运行时均支持反射,为此会将字段和消息的原始名称 以字符串形式嵌入应用二进制文件(APK/IPA)。
message Foo {
// 此字段将在 Android/iOS 泄露 Google Teleport 项目存在
optional FeatureStatus google_teleport_enabled;
}
缓解策略:
- Android 使用 ProGuard 混淆(截至 2014 年 Q3)。 iOS 无可混淆选项:桌面端对 IPA 执行
strings
命令 将暴露 proto 字段名(参见 iOS Chrome 拆解) - 严格筛选发送至移动客户端的字段
- 若在可接受时限内无法堵漏,需征得特性负责人同意承担风险
绝不可以此为借口用代号混淆字段含义。 要么堵漏,要么征得同意承担风险。
性能优化
某些情况下可牺牲类型安全或清晰度换取性能。 例如,含数百个字段(特别是消息类型字段)的 proto 解析速度慢于字段少的 proto。 深度嵌套的消息仅因内存管理就会降低反序列化速度。 团队曾用以下技术加速反序列化:
- 创建精简的并行 proto,仅声明部分标签。 无需所有字段时用于解析。 添加测试确保标签号随精简 proto 产生编号”空洞”而保持匹配
- 用 [lazy=true] 标注字段
- 将字段声明为字节类型并文档化其类型。 需解析字段的客户端可手动操作。 此方案风险在于无法防止错误类型的消息放入字节字段。 若 proto 会写入日志,切勿使用此方案, 因其阻碍 PII 审查或策略/隐私擦除。
本文翻译自:2025-7-17 API Best Practices | Protocol Buffers Documentation
含
map<k,v>
字段的 proto 的陷阱:勿在 MapReduce 中将其作为归约键。 Proto3 map 项的传输格式和迭代顺序未指定,导致不一致的分片结果。↩︎