API 最佳实践

一个面向未来的 API 出奇地难以正确实现。本文档中的建议进行权衡,以利于长期、无错的演进。

已更新以支持 proto3。欢迎提交补丁!

本文档是对Proto 最佳实践的补充。它并非 Java/C++/Go 及其他 API 的强制性规定。

如果你在代码评审中看到有 Proto 偏离了这些准则,请将作者指向本主题并帮助传播。

精确、简洁地文档化大多数字段和消息

很有可能你的 proto 将会被不了解你编写或修改它时思路的人继承和使用。请用对新的团队成员或对你的系统知之甚少的客户端有用的术语来文档化每个字段。

一些具体的例子

// Bad: Option to enable Foo
// Good: Configuration controlling the behavior of the Foo feature.
message FeatureFooConfig {
  // Bad: Sets whether the feature is enabled
  // Good: Required field indicating whether the Foo feature
  // is enabled for account_id.  Must be false if account_id's
  // FOO_OPTIN Gaia bit is not set.
  optional bool enabled;
}

// Bad: Foo object.
// Good: Client-facing representation of a Foo (what/foo) exposed in APIs.
message Foo {
  // Bad: Title of the foo.
  // Good: Indicates the user-supplied title of this Foo, with no
  // normalization or escaping.
  // An example title: "Picture of my cat in a box <3 <3 !!!"
  optional string title [(max_length) = 512];
}

// Bad: Foo config.
// Less-Bad: If the most useful comment is re-stating the name, better to omit
// the comment.
FooConfig foo_config = 3;

用尽可能少的文字文档化每个字段的约束、期望和解释。

你可以使用自定义 proto 注解。参见自定义选项来定义跨语言常量,例如上面例子中的 max_length。proto2 和 proto3 都支持。

随着时间的推移,接口的文档可能会越来越长。长度会损害清晰度。当文档确实不清楚时,请进行修改,但要从整体上看待,并力求简洁。

对网络传输和存储使用不同的消息

如果你向客户端暴露的顶层 proto 与你在磁盘上存储的 proto 相同,那你就麻烦了。随着时间的推移,越来越多的二进制文件将依赖于你的 API,从而使其更难更改。你会希望能够自由更改存储格式而不影响客户端。分层你的代码,以便模块处理客户端 proto、存储 proto 或转换。

为什么?你可能想更换底层存储系统。你可能想以不同的方式规范化或反规范化数据。你可能意识到你暴露给客户端的 proto 的某些部分适合存储在 RAM 中,而其他部分适合存储在磁盘上。

当涉及到嵌套在顶层请求或响应中一层或多层内的 proto 时,分离存储和网络传输 proto 的理由就没有那么充分了,这取决于你愿意将客户端与这些 proto 耦合的紧密程度。

维护转换层是有成本的,但一旦你有了客户端并必须进行首次存储更改,它很快就会带来回报。

你可能会想共享 proto,然后在“需要时”再分歧。由于分歧的成本很高,并且没有明确的地方放置内部字段,你的 API 将会积累客户端不理解或在你不知情的情况下开始依赖的字段。

通过从单独的 proto 文件开始,你的团队将知道在哪里添加内部字段而不会污染你的 API。早期,网络传输 proto 可以通过自动转换层(例如:字节复制或 proto 反射)与存储 proto 标签完全相同。Proto 注解也可以支持自动转换层。

以下是该规则的例外情况

  • 如果 proto 字段是常用类型之一,例如 google.typegoogle.protobuf,那么将该类型同时用作存储和 API 是可以接受的。

  • 如果你的服务对性能要求极高,那么牺牲灵活性来换取执行速度可能是值得的。如果你的服务没有百万 QPS 且延迟为毫秒级,你可能就不是例外情况。

  • 如果以下所有条件都为真

    • 你的服务就是存储系统
    • 你的系统不基于客户端的结构化数据做出决策
    • 你的系统只是根据客户端的请求进行存储、加载,并可能提供查询功能

    请注意,如果你正在实现日志系统或围绕通用存储系统的基于 proto 的包装器,那么你可能希望尽量使客户端的消息尽可能不透明地传输到你的存储后端,这样你就不会创建依赖关系网。考虑使用扩展或通过 Web 安全编码二进制 Proto 序列化,将不透明数据编码为字符串

对于变更操作,支持部分更新或仅追加更新,而非完全替换

不要创建只接受一个 FooUpdateFooRequest

如果客户端不保留未知字段,它们将不会拥有 GetFooResponse 的最新字段,从而导致往返过程中数据丢失。有些系统不保留未知字段。Proto2 和 proto3 实现会保留未知字段,除非应用程序明确丢弃未知字段。通常,公共 API 应在服务器端丢弃未知字段,以防止通过未知字段进行安全攻击。例如,垃圾未知字段可能导致服务器在未来开始使用它们作为新字段时失败。

如果没有文档,可选字段的处理是模糊不清的。UpdateFoo 会清除该字段吗?这会让你在客户端不知道该字段的情况下容易发生数据丢失。它不触碰该字段吗?那么客户端如何清除该字段?这两种情况都不好。

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

让你的客户端传递它想要修改的字段,并在更新请求中只包含这些字段。你的服务器不触碰其他字段,只更新掩码指定的字段。通常,你的掩码结构应该镜像响应 proto 的结构;也就是说,如果 Foo 包含 Bar,则 FooMask 包含 BarMask

解决方案 #2:公开更精细的、只更改单个部分的变更操作

例如,你可以不使用 UpdateEmployeeRequest,而是使用:PromoteEmployeeRequestSetEmployeePayRequestTransferEmployeeRequest 等。

自定义更新方法比非常灵活的更新方法更容易监控、审计和安全。它们也更容易实现和调用。但数量巨大的自定义方法可能会增加 API 的认知负担。

不要在顶层请求或响应 Proto 中包含原始类型

本文档其他地方描述的许多陷阱都可以通过此规则解决。例如

通过将 repeated 字段包装在消息中,可以告知客户端 repeated 字段在存储中未设置与在此特定调用中未填充之间的区别。

请求之间共享的常见请求选项自然遵循此规则。读写字段掩码也源于此。

你的顶层 proto 几乎总是应该是一个容器,包含可以独立增长的其他消息。

即使你今天只需要一个原始类型,将其包装在消息中也能为你提供一条明确的路径来扩展该类型,并在返回类似值的其他方法之间共享该类型。例如

message MultiplicationResponse {
  // Bad: What if you later want to return complex numbers and have an
  // AdditionResponse that returns the same multi-field type?
  optional double result;


  // Good: Other methods can share this type and it can grow as your
  // service adds new features (units, confidence intervals, etc.).
  optional NumericResult result;
}

message NumericResult {
  optional double real_value;
  optional double complex_value;
  optional UnitType units;
}

顶层原始类型的一个例外:不透明字符串(或字节),它们编码一个 proto,但只在服务器上构建和解析。Continuation token、版本信息 token 和 ID 都可以作为字符串返回,前提是该字符串实际上是一个结构化 proto 的编码。

对于现在只有两个状态,但将来可能增加更多状态的事物,切勿使用布尔类型

如果你正在为一个字段使用布尔类型,请确保该字段确实只描述了两种可能的状态(永久如此,而不仅仅是现在和近期)。通常,枚举、整数或消息的灵活性是值得的。

例如,在返回帖子流时,开发者可能需要根据 UX 的当前模型指示帖子是否应以两列渲染。尽管今天只需要一个布尔类型,但没有任何东西阻止 UX 在未来版本中引入两行帖子、三列帖子或四方帖子。

message GooglePlusPost {
  // Bad: Whether to render this post across two columns.
  optional bool big_post;

  // Good: Rendering hints for clients displaying this post.
  // Clients should use this to decide how prominently to render this
  // post. If absent, assume a default rendering.
  optional LayoutConfig layout_config;
}

message Photo {
  // Bad: True if it's a GIF.
  optional bool gif;

  // Good: File format of the referenced photo (for example, GIF, WebP, PNG).
  optional PhotoType type;
}

添加将概念混淆的状态到枚举中要谨慎。

如果一个状态给枚举引入了一个新的维度或意味着多种应用程序行为,你几乎肯定需要另一个字段。

很少使用整数字段作为 ID

将 int64 用作对象的标识符是很诱人的。但请选择字符串。

这允许你在需要时更改你的 ID 空间,并减少冲突的可能性。2^64 不像以前那么大了。

你也可以将结构化标识符编码为字符串,这鼓励客户端将其视为不透明的 blob。你仍然必须有一个支持该字符串的 proto,但你可以将 proto 序列化到字符串字段(编码为 web 安全的 Base64),这会从暴露给客户端的 API 中移除所有内部细节。在这种情况下,请遵循下面的准则。

message GetFooRequest {
  // Which Foo to fetch.
  optional string foo_id;
}

// Serialized and websafe-base64-encoded into the GetFooRequest.foo_id field.
message InternalFooRef {
  // Only one of these two is set. Foos that have already been
  // migrated use the spanner_foo_id and Foos still living in
  // Caribou Storage Server have a classic_foo_id.
  optional bytes spanner_foo_id;
  optional int64 classic_foo_id;
}

如果你一开始就使用自己的序列化方案来将 ID 表示为字符串,事情可能会很快变得奇怪。这就是为什么通常最好从支持你的字符串字段的内部 proto 开始。

不要在字符串中编码你期望客户端构造或解析的数据

这在网络传输上效率较低,Proto 的消费者需要做更多工作,并且对于阅读你的文档的人来说也很困惑。你的客户端还需要担心编码问题:列表是逗号分隔的吗?我是否正确地转义了这个不受信任的数据?数字是十进制的吗?最好让客户端发送实际的消息或原始类型。这在网络传输上更紧凑,对你的客户端也更清晰。

当你的服务拥有多种语言的客户端时,情况会变得尤其糟糕。现在每个客户端都必须选择正确的解析器或构建器,或者更糟的是自己编写一个。

更一般地说,选择正确的原始类型。请参阅Protocol Buffer 语言指南中的标量值类型表。

不要在前端 Proto 中返回 HTML

对于 JavaScript 客户端,将 HTML 或 JSON 在你的 API 字段中返回是很诱人的。这是将你的 API 与特定 UI 绑定的一个危险倾向。以下是三个具体的危险

  • 一个“拼凑的”非 Web 客户端最终会解析你的 HTML 或 JSON 来获取他们想要的数据,这会导致如果你更改格式时出现脆弱性,并且如果他们的解析不好时会存在漏洞。
  • 如果返回的 HTML 未经净化,你的 Web 客户端现在容易受到 XSS 攻击。
  • 你返回的标签和类期望特定的样式表和 DOM 结构。从一个版本到另一个版本,该结构会发生变化,你面临版本偏差的风险,即 JavaScript 客户端比服务器旧,并且服务器返回的 HTML 在旧客户端上无法正常渲染。对于经常发布的项目,这不是边缘情况。

除了初始页面加载之外,通常最好是返回数据,并使用客户端模板在客户端构建 HTML。

通过 Web 安全编码二进制 Proto 序列化,将不透明数据编码为字符串

如果你在客户端可见的字段中编码了不透明数据(continuation token、序列化 ID、版本信息等),请文档化说明客户端应将其视为不透明的 blob。对于这些字段,始终使用二进制 proto 序列化,绝不使用文本格式或你自己设计的任何东西。当你需要扩展不透明字段中编码的数据时,如果你尚未在使用 Protocol Buffer 序列化,你会发现自己不得不重新发明它。

定义一个内部 proto 来存放将放入不透明字段的字段(即使你只需要一个字段),将此内部 proto 序列化为字节,然后将结果进行 web 安全的 base-64 编码,再放入你的字符串字段中。

使用 proto 序列化的一个罕见例外:极少情况下,精心构建的替代格式所带来的紧凑性收益是值得的。

不要包含客户端不可能使用的字段

你暴露给客户端的 API 应该只用于描述如何与你的系统交互。在其中包含任何其他内容都会增加试图理解它的人的认知负担。

在响应 proto 中返回调试数据曾经是一种常见做法,但我们有更好的方法。RPC 响应扩展(也称为“侧通道”)允许你使用一个 proto 描述客户端接口,使用另一个 proto 描述调试界面。

类似地,在响应 proto 中返回实验名称曾经是为了方便日志记录——不成文的约定是客户端会在后续操作中将这些实验信息发送回来。实现同样目标的公认方法是在分析管道中进行日志联接。

一个例外

如果你需要连续的实时分析并且机器预算有限,运行日志联接可能会成本过高。在成本是决定性因素的情况下,提前对日志数据进行非规范化处理可能是一个优势。如果你需要将日志数据往返传回给你,将其作为不透明的 blob 发送给客户端,并文档化请求和响应字段。

注意:如果你需要在每个请求中返回或往返传输隐藏数据,你是在隐藏使用你的服务的真实成本,这同样不好。

很少在没有 Continuation Token 的情况下定义分页 API

message FooQuery {
  // Bad: If the data changes between the first query and second, each of
  // these strategies can cause you to miss results. In an eventually
  // consistent world (that is, storage backed by Bigtable), it's not uncommon
  // to have old data appear after the new data. Also, the offset- and
  // page-based approaches all assume a sort-order, taking away some
  // flexibility.
  optional int64 max_timestamp_ms;
  optional int32 result_offset;
  optional int32 page_number;
  optional int32 page_size;

  // Good: You've got flexibility! Return this in a FooQueryResponse and
  // have clients pass it back on the next query.
  optional string next_page_token;
}

分页 API 的最佳实践是使用一个不透明的 continuation token(称为 next_page_token),该 token 由你序列化的内部 proto 提供支持,然后进行 WebSafeBase64Escape (C++) 或 BaseEncoding.base64Url().encode (Java) 操作。该内部 proto 可以包含许多字段。重要的是它为你带来了灵活性,并且——如果你选择——可以为你的客户端带来结果的稳定性。

不要忘记将此 proto 的字段作为不可信输入进行验证(参见将不透明数据编码为字符串中的注意)。

message InternalPaginationToken {
  // Track which IDs have been seen so far. This gives perfect recall at the
  // expense of a larger continuation token--especially as the user pages
  // back.
  repeated FooRef seen_ids;

  // Similar to the seen_ids strategy, but puts the seen_ids in a Bloom filter
  // to save bytes and sacrifice some precision.
  optional bytes bloom_filter;

  // A reasonable first cut and it may work for longer. Having it embedded in
  // a continuation token lets you change it later without affecting clients.
  optional int64 max_timestamp_ms;
}
message Foo {
  // Bad: The price and currency of this Foo.
  optional int price;
  optional CurrencyType currency;

  // Better: Encapsulates the price and currency of this Foo.
  optional CurrencyAmount price;
}

只有高内聚的字段才应该被嵌套。如果字段确实相关,你通常会希望在服务器内部将它们一起传递。如果它们在同一个消息中定义,这将更容易。例如

CurrencyAmount calculateLocalTax(CurrencyAmount price, Location where)

如果你的变更列表引入了一个字段,但该字段以后可能相关的字段,请预先将其放入自己的消息中,以避免此问题

message Foo {
  // DEPRECATED! Use currency_amount.
  optional int price [deprecated = true];

  // The price and currency of this Foo.
  optional google.type.Money currency_amount;
}

嵌套消息的问题在于,虽然 CurrencyAmount 可能在你的 API 的其他地方是一个受欢迎的重用候选项,但 Foo.CurrencyAmount 可能不是。在最坏的情况下,Foo.CurrencyAmount 重用了,但 Foo 特定的字段却泄露了进去。

虽然松耦合在系统开发中通常被认为是最佳实践,但在设计 .proto 文件时,这种实践可能并不总是适用。在某些情况下,紧密耦合两个信息单元(通过将一个单元嵌套在另一个单元内部)可能是有意义的。例如,如果你正在创建一组目前看起来相当通用的字段,但预计将来会添加专门字段,嵌套该消息将阻止其他人从当前或其他的 .proto 文件中引用该消息。

message Photo {
  // Bad: It's likely PhotoMetadata will be reused outside the scope of Photo,
  // so it's probably a good idea not to nest it and make it easier to access.
  message PhotoMetadata {
    optional int32 width = 1;
    optional int32 height = 2;
  }
  optional PhotoMetadata metadata = 1;
}

message FooConfiguration {
  // Good: Reusing FooConfiguration.Rule outside the scope of FooConfiguration
  // tightly-couples it with likely unrelated components, nesting it dissuades
  // from doing that.
  message Rule {
    optional float multiplier = 1;
  }
  repeated Rule rules = 1;
}

在读取请求中包含字段读取掩码

// Recommended: use google.protobuf.FieldMask

// Alternative one:
message FooReadMask {
  optional bool return_field1;
  optional bool return_field2;
}

// Alternative two:
message BarReadMask {
  // Tag numbers of the fields in Bar to return.
  repeated int32 fields_to_return;
}

如果你使用推荐的 google.protobuf.FieldMask,你可以使用 FieldMaskUtil (Java/C++) 库自动过滤 proto。

读取掩码在客户端设置了明确的期望,使它们能够控制需要返回多少数据,并允许后端只获取客户端需要的数据。

可接受的替代方案是始终填充所有字段;也就是说,将请求视为存在一个隐式读取掩码,所有字段都设置为 true。随着 proto 的增长,这可能会变得代价高昂。

最糟糕的失败模式是存在一个隐式(未声明)的读取掩码,它根据填充消息的方法而变化。这种反模式会导致从响应 proto 构建本地缓存的客户端出现明显的数据丢失。

包含版本字段以实现一致读取

当客户端执行写入操作,然后读取同一对象时,他们期望能取回他们写入的内容——即使这种期望对于底层存储系统来说并不合理。

你的服务器将读取本地值,如果本地 version_info 小于预期的 version_info,它将从远程副本读取以查找最新值。通常 version_info 是一个编码为字符串的 proto,其中包含变更发生的机房以及提交的时间戳。

即使是由一致性存储支持的系统,也经常希望有一个 token 来触发更昂贵的读取一致性路径,而不是在每次读取时都产生这个成本。

对于返回相同数据类型的 RPC,使用一致的请求选项

一个失败模式的例子是某个服务的请求选项,其中每个 RPC 都返回相同的数据类型,但对于指定最大评论数、支持的嵌入类型列表等有单独的请求选项。

这种临时(ad hoc)处理方式的代价是增加了客户端理解如何填写每个请求的复杂性,并增加了服务器将 N 个请求选项转换为一个通用内部选项的复杂性。许多实际 Bug 都可以追溯到这个例子。

相反,创建一个单独的消息来容纳请求选项,并将其包含在每个顶层请求消息中。以下是一个更好的实践示例

message FooRequestOptions {
  // Field-level read mask of which fields to return. Only fields that
  // were requested will be returned in the response. Clients should only
  // ask for fields they need to help the backend optimize requests.
  optional FooReadMask read_mask;

  // Up to this many comments will be returned on each Foo in the response.
  // Comments that are marked as spam don't count towards the maximum
  // comments. By default, no comments are returned.
  optional int max_comments_to_return;

  // Foos that include embeds that are not on this supported types list will
  // have the embeds down-converted to an embed specified in this list. If no
  // supported types list is specified, no embeds will be returned. If an embed
  // can't be down-converted to one of the supplied supported types, no embed
  // will be returned. Clients are strongly encouraged to always include at
  // least the THING_V2 embed type from EmbedTypes.proto.
  repeated EmbedType embed_supported_types_list;
}

message GetFooRequest {
  // What Foo to read. If the viewer doesn't have access to the Foo or the
  // Foo has been deleted, the response will be empty but will succeed.
  optional string foo_id;

  // Clients are required to include this field. Server returns
  // INVALID_ARGUMENT if FooRequestOptions is left empty.
  optional FooRequestOptions params;
}

message ListFooRequest {
  // Which Foos to return. Searches have 100% recall, but more clauses
  // impact performance.
  optional FooQuery query;

  // Clients are required to include this field. The server returns
  // INVALID_ARGUMENT if FooRequestOptions is left empty.
  optional FooRequestOptions params;
}

批处理/多阶段请求

在可能的情况下,使变更操作是原子的。更重要的是,使变更操作是幂等的。部分失败的完整重试不应损坏/复制数据。

有时,由于性能原因,你需要一个封装多个操作的单个 RPC。部分失败时该怎么办?如果有些成功,有些失败,最好让客户端知道。

考虑将 RPC 标记为失败,并在 RPC status proto 中返回成功和失败的详细信息。

通常,你希望不了解你处理部分失败的客户端仍能正常运行,而了解的客户端能获得额外价值。

创建返回或操作少量数据的方法,并期望客户端通过批量处理多个此类请求来组合 UI

在单次往返中查询许多细致指定数据块的能力,允许通过让客户端组合所需内容来扩展 UX 选项范围,而无需更改服务器。

这对于前端和中间层服务器来说最为相关。

许多服务都公开了自己的批处理 API。

当替代方案是移动或 Web 上的串行往返时,进行一次性 RPC 调用

Web 或移动客户端需要执行两个相互依赖的查询的情况下,当前的最佳实践是创建一个新的 RPC 来避免客户端的往返。

对于移动端来说,将两个服务方法捆绑到一个新的方法中,几乎总是值得的,这可以节省客户端额外的往返成本。对于服务器到服务器的调用,情况可能不那么明确;这取决于你的服务对性能的敏感程度以及新方法带来的认知开销。

将 Repeated 字段定义为消息,而非标量或枚举

一个常见的演进是单个 repeated 字段需要变成多个相关的 repeated 字段。如果从 repeated 原始类型开始,你的选择是有限的——你要么创建并行的 repeated 字段,要么定义一个带有新消息的新 repeated 字段来存放值,并将客户端迁移到它。

如果从 repeated 消息开始,演进就变得微不足道。

// Describes a type of enhancement applied to a photo
enum EnhancementType {
  ENHANCEMENT_TYPE_UNSPECIFIED;
  RED_EYE_REDUCTION;
  SKIN_SOFTENING;
}

message PhotoEnhancement {
  optional EnhancementType type;
}

message PhotoEnhancementReply {
  // Good: PhotoEnhancement can grow to describe enhancements that require
  // more fields than just an enum.
  repeated PhotoEnhancement enhancements;

  // Bad: If we ever want to return parameters associated with the
  // enhancement, we'd have to introduce a parallel array (terrible) or
  // deprecate this field and introduce a repeated message.
  repeated EnhancementType enhancement_types;
}

想象一下以下功能请求:“我们需要知道哪些增强功能是由用户执行的,哪些是由系统自动应用的。”

如果 PhotoEnhancementReply 中的 enhancement 字段是标量或枚举类型,这将很难支持。

这同样适用于 map。如果 map 的值已经是消息类型,那么添加额外字段要容易得多,而无需从 map<string, string> 迁移到 map<string, MyProto>

一个例外

对延迟要求严格的应用程序会发现,原始类型的并行数组比消息的单个数组构建和删除速度更快;如果你使用 [packed=true](省略字段标签),它们在网络传输上也可能更小。分配固定数量的数组比分配 N 个消息的工作量更少。此外:在 Proto3 中,packed 编码是自动的;你不需要显式指定。

使用 Proto Map

Proto3引入Proto3 map之前,服务有时会使用带有标量字段的临时 KVPair 消息来暴露数据对。最终客户端会需要更深层次的结构,并会设计出需要以某种方式解析的键或值。参见不要在字符串中编码数据

因此,为值使用(可扩展的)消息类型是对简单设计的即时改进。

Map 已被回port到 proto2 的所有语言中,因此使用 map<scalar, **message**> 比为了相同的目的发明自己的 KVPair 要好1

如果你想表示其结构你事先不知道的任意数据,请使用google.protobuf.Any

优先考虑幂等性

在你上面的某个栈中,客户端可能存在重试逻辑。如果重试是变更操作,用户可能会感到意外。重复的评论、构建请求、编辑等对任何人都没有好处。

避免重复写入的一种简单方法是允许客户端指定一个客户端生成的请求 ID,你的服务器根据该 ID 进行去重(例如,内容的哈希或 UUID)。

注意你的服务名称,并使其全局唯一

服务名称(即 .proto 文件中 service 关键字后面的部分)被用于许多令人惊讶的地方,不仅仅是生成服务类名。这使得这个名称比人们想象的更重要。

棘手的是,这些工具隐式假设你的服务名称在网络中是唯一的。更糟糕的是,它们使用的服务名称是非限定的服务名称(例如,MyService),而不是限定的服务名称(例如,my_package.MyService)。

因此,即使你的服务名称定义在特定包内,采取措施防止服务名称冲突也是有意义的。例如,名为 Watcher 的服务很可能引起问题;像 MyProjectWatcher 这样的名称会更好。

限定请求和响应的大小

请求和响应的大小应该有所限制。我们建议将限制设置在 8 MiB 左右,并且 2 GiB 是许多 proto 实现会崩溃的硬限制。许多存储系统对消息大小有限制。

此外,无限制的消息

  • 会使客户端和服务器都变得臃肿,
  • 导致高且不可预测的延迟,
  • 通过依赖单个客户端和单个服务器之间的长连接来降低弹性。

以下是几种限制 API 中所有消息大小的方法

  • 定义返回有限大小消息的 RPC,其中每个 RPC 调用在逻辑上相互独立。
  • 定义操作单个对象的 RPC,而不是操作无限制的、客户端指定的对象列表。
  • 避免在 string、byte 或 repeated 字段中编码无限制的数据。
  • 定义一个长时间运行的操作。将结果存储在为可伸缩、并发读取设计的存储系统中。
  • 使用分页 API(参见很少在没有 Continuation Token 的情况下定义分页 API)。
  • 使用流式 RPC。

如果你正在开发 UI,另请参见创建返回或操作少量数据的方法

谨慎传播状态码

RPC 服务应在 RPC 边界处仔细检查错误,并向其调用者返回有意义的状态错误。

让我们通过一个简单的例子来说明这一点

考虑一个客户端调用 ProductService.GetProducts,该方法不接受任何参数。作为 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 应该在其实现的 ProductService RPC 处理程序中,在 RPC 边界处检查接收到的错误。它应该向用户返回有意义的错误:如果它从调用者那里接收到了无效参数,它应该返回 INVALID_ARGUMENT。如果下游某个服务接收到了无效参数,在将错误返回给调用者之前,它应该将 INVALID_ARGUMENT 转换为 INTERNAL

草率地传播状态错误会导致混乱,这 debugging 的成本非常高。更糟糕的是,它可能导致隐形中断,即每个服务都转发客户端错误而不会触发任何警报。

一般规则是:在 RPC 边界处,仔细检查错误,并向调用者返回有意义的状态错误,并带有适当的状态码。为了传达意义,每个 RPC 方法都应该文档化在何种情况下返回何种错误码。每个方法的实现应符合文档化的 API 契约。

为每个方法创建唯一的 Proto

为每个 RPC 方法创建一个唯一的请求和响应 proto。事后发现需要分歧顶层请求或响应的成本很高。这包括“空”响应;创建一个唯一的空响应 proto,而不是重用常用 Empty 消息类型

重用消息

要重用消息,创建共享的“领域”消息类型,以包含在多个请求和响应 proto 中。用这些类型来编写你的应用程序逻辑,而不是请求和响应类型。

这为你提供了独立演进方法请求/响应类型的灵活性,同时可以共享逻辑子单元的代码。

附录

返回 Repeated 字段

当 repeated 字段为空时,客户端无法判断该字段是服务器未填充,还是该字段的后端数据确实为空。换句话说,repeated 字段没有 hasFoo 方法。

将 repeated 字段包装在消息中是获取 hasFoo 方法的一种简单方式。

message FooList {
  repeated Foo foos;
}

解决这个问题的更全面的方法是使用字段读取掩码。如果请求了该字段,则空列表表示没有数据。如果未请求该字段,客户端应忽略响应中的该字段。

更新 Repeated 字段

更新 repeated 字段最糟糕的方法是强制客户端提供一个替换列表。强制客户端提供整个数组的危险有很多。不保留未知字段的客户端会导致数据丢失。并发写入会导致数据丢失。即使这些问题不存在,你的客户端也需要仔细阅读你的文档,才能知道服务器端如何解释该字段。空字段是表示服务器不会更新它,还是表示服务器将清除它?

解决方案 #1:使用 Repeated 更新掩码,允许客户端在写入时替换、删除或插入数组元素,而无需提供整个数组。

解决方案 #2:在请求 proto 中创建单独的 append、replace、delete 数组。

解决方案 #3:只允许追加或清除。你可以通过将 repeated 字段包装在消息中来实现。存在但为空的消息表示清除,否则,任何 repeated 元素都表示追加。

Repeated 字段中的顺序独立性

尽量避免普遍的顺序依赖。这增加了脆弱性。尤其糟糕的一种顺序依赖是并行数组。并行数组使客户端更难解释结果,并且在你的服务内部传递两个相关字段变得不自然。

message BatchEquationSolverResponse {
  // Bad: Solved values are returned in the order of the equations given in
  // the request.
  repeated double solved_values;
  // (Usually) Bad: Parallel array for solved_values.
  repeated double solved_complex_values;
}

// Good: A separate message that can grow to include more fields and be
// shared among other methods. No order dependence between request and
// response, no order dependence between multiple repeated fields.
message BatchEquationSolverResponse {
  // Deprecated, this will continue to be populated in responses until Q2
  // 2014, after which clients must move to using the solutions field below.
  repeated double solved_values [deprecated = true];

  // Good: Each equation in the request has a unique identifier that's
  // included in the EquationSolution below so that the solutions can be
  // correlated with the equations themselves. Equations are solved in
  // parallel and as the solutions are made they are added to this array.
  repeated EquationSolution solutions;
}

由于你的 Proto 在移动构建中而导致的功能泄露

Android 和 iOS 运行时都支持反射。为此,字段和消息的未过滤名称会以字符串形式嵌入到应用程序二进制文件(APK、IPA)中。

message Foo {
  // This will leak existence of Google Teleport project on Android and iOS
  optional FeatureStatus google_teleport_enabled;
}

几种缓解策略

  • Android 上的 ProGuard 混淆。截至 2014 年第三季度。iOS 没有混淆选项:一旦你在桌面上有 IPA 文件,通过 strings 命令管道处理它将揭示包含的 proto 的字段名。iOS Chrome 拆解
  • 精确地策划哪些字段发送到移动客户端。
  • 如果可以在可接受的时间范围内堵住这个漏洞不可行,请征得功能所有者的同意承担风险。

切勿以此为借口使用代号来混淆字段的含义。要么堵住漏洞,要么承担风险并获得批准。

性能优化

在某些情况下,你可以牺牲类型安全或清晰度来换取性能提升。例如,一个包含数百个字段(尤其是消息类型字段)的 proto 解析速度会比字段较少的 proto 慢。一个非常深层嵌套的消息仅从内存管理方面反序列化也会很慢。以下是团队用于加速反序列化的几种技术

  • 创建一个并行的、精简的 proto,它镜像较大的 proto,但只声明了部分标签。当你不需要所有字段时,使用它进行解析。添加测试以确保随着精简 proto 积累编号“空洞”,标签号继续匹配。
  • 使用 [lazy=true] 注解字段为“延迟解析”。
  • 将字段声明为 bytes 并文档化其类型。关注该字段的客户端可以手动解析它。这种方法的危险在于,没有人阻止有人将错误类型的消息放入 bytes 字段中。你绝不应该对写入任何日志的 proto 这样做,因为它阻止了对 proto 进行 PII 审查或出于策略或隐私原因进行清洗。

  1. 包含 map<k,v> 字段的 proto 有一个注意事项。不要将它们用作 MapReduce 中的 reduce 键。proto3 map 项的网络格式和迭代顺序是未指定的,这会导致 MapReduce 分片不一致。 ↩︎