设计一个面向未来的 API 异常困难。本文档中的建议通过权衡取舍,以支持长期无缺陷的演进。

本文档是对 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 配置
// 次优方案:若最有用的注释仅是复述名称,不如省略注释
FooConfig foo_config = 3;

用最简练的语言描述每个字段的约束、期望和解释规则。

可使用自定义 proto 注解(如示例中的 max_length), 详见自定义选项。 支持 proto2 和 proto3。

接口文档会随时间增长,冗长将降低清晰度。 当文档确实不清晰时,应修复它, 但需整体审视并追求简洁。

区分传输层与存储层的消息结构

若客户端接口与磁盘存储使用相同的顶层 proto, 将引发隐患。随时间推移,更多二进制文件会依赖您的 API, 使其难以修改。您需要在不影响客户端的情况下自由变更存储格式。 通过分层代码,让模块分别处理客户端 proto、存储 proto 或转换逻辑。

为何? 您可能需要更换底层存储系统, 调整数据归一化/反归一化策略, 或发现部分客户端 proto 适合存入 RAM 而其他部分应落盘。

对于嵌套在请求/响应中的 proto, 分离存储层与传输层的必要性会降低, 这取决于您希望客户端与这些 proto 的耦合程度。

维护转换层虽有成本,但在拥有客户端 且需首次变更存储格式时,其价值会迅速显现。

若试图”需要时再分离”共享 proto, 由于分离成本高且内部字段无处存放, API 将积累客户端不理解或未经授权就依赖的字段。

通过独立的 proto 文件起步, 团队将明确添加内部字段的位置, 避免污染 API。早期可通过自动转换层 (如字节复制或 proto 反射)保持传输层 proto 完全一致。 Proto 注解也可驱动自动转换层。

例外情况

注意:若实现日志系统或通用存储的 proto 包装器, 应让客户端消息尽可能透明地进入存储后端, 避免创建依赖枢纽。可考虑使用扩展或 通过 Web 安全编码将二进制序列化数据编码为字符串

支持局部更新或仅追加更新,而非完全替换

避免创建仅接收 FooUpdateFooRequest

若客户端不保留未知字段,其 GetFooResponse 将缺少新字段, 导致往返数据丢失。部分系统不保留未知字段。 Proto2/proto3 实现会保留未知字段,除非显式丢弃。 通常公共 API 应在服务端丢弃未知字段以防止安全攻击 (例如垃圾未知字段可能在服务端未来将其用作新字段时引发故障)。

未明确文档化时,可选字段的处理存在歧义: UpdateFoo 会清空字段吗?当客户端未知该字段时会导致数据丢失。 不修改字段?客户端如何清空字段?两种方案均不理想。

解决方案1:使用更新字段掩码

客户端传递需修改的字段,服务端仅更新掩码指定字段。 掩码结构应与响应 proto 结构镜像: 若 Foo 包含 Bar,则 FooMask 应包含 BarMask

解决方案2:暴露更细粒度的原子操作

例如,用 PromoteEmployeeRequestSetEmployeePayRequestTransferEmployeeRequest 等替代 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 绑定。三大风险:

除初始页面加载外, 通常应在客户端返回数据并用客户端模板构建 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;
}

仅高内聚字段应嵌套。若字段确实相关, 在服务内部传递时会更便捷。例如:

CurrencyAmount calculateLocalTax(CurrencyAmount price, Location where)

若变更引入一个字段,但该字段后续可能有相关字段, 应预先放入独立消息以避免:

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;
}

缓解策略:

绝不可以此为借口用代号混淆字段含义。 要么堵漏,要么征得同意承担风险。

性能优化

某些情况下可牺牲类型安全或清晰度换取性能。 例如,含数百个字段(特别是消息类型字段)的 proto 解析速度慢于字段少的 proto。 深度嵌套的消息仅因内存管理就会降低反序列化速度。 团队曾用以下技术加速反序列化:

本文翻译自:2025-7-17 API Best Practices | Protocol Buffers Documentation


  1. map<k,v> 字段的 proto 的陷阱:勿在 MapReduce 中将其作为归约键。 Proto3 map 项的传输格式和迭代顺序未指定,导致不一致的分片结果。↩︎