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 中均支持。

随着时间的推移,接口的文档可能会越来越长。长度会降低清晰度。当文档确实不清楚时,请修复它,但要整体看待它,并力求简洁。

对 Wire 和存储使用不同的消息

如果您暴露给客户端的顶级 proto 与您存储在磁盘上的 proto 相同,您就会遇到麻烦。随着时间的推移,越来越多的二进制文件将依赖于您的 API,这使得更改变得更加困难。您需要自由地更改存储格式,而不会影响您的客户端。对您的代码进行分层,以便模块处理客户端 proto、存储 proto 或转换。

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

当涉及到嵌套在一个或多个级别的顶级请求或响应中的 proto 时,分离存储和 wire proto 的理由并不那么充分,这取决于您愿意将客户端与这些 proto 耦合到什么程度。

维护翻译层是有成本的,但一旦您拥有客户端并且必须进行首次存储更改,它很快就会得到回报。

您可能会试图共享 proto 并在“需要时”进行分离。由于分离的感知成本很高,并且没有明确的位置来放置内部字段,因此您的 API 将积累客户端要么不理解,要么在您不知情的情况下开始依赖的字段。

通过从单独的 proto 文件开始,您的团队将知道在哪里添加内部字段,而不会污染您的 API。在早期,wire 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:公开更多更改各个部分的狭义突变

例如,您可能拥有 PromoteEmployeeRequestSetEmployeePayRequestTransferEmployeeRequest 等,而不是 UpdateEmployeeRequest

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

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

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

通过将重复字段包装在消息中,可以告知客户端重复字段在存储中未设置与在此特定调用中未填充的情况。

请求之间共享的通用请求选项自然而然地遵循此规则。读取和写入字段掩码也遵循此规则。

您的顶级 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,但仅在服务器上构建和解析。延续令牌、版本信息令牌和 ID 都可以作为字符串返回,如果该字符串实际上是结构化 proto 的编码。

永远不要将布尔值用于现在只有两种状态,但以后可能会有更多状态的情况

如果您正在为字段使用布尔值,请确保该字段确实只描述两种可能的状态(在所有时间,而不仅仅是现在和不久的将来)。通常,枚举、int 或消息的灵活性最终会被证明是值得的。

例如,在返回帖子流时,开发人员可能需要根据 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 客户端,很容易在 API 的字段中返回 HTML 或 JSON。这是一种将您的 API 绑定到特定 UI 的滑坡。以下是三个具体的危险

  • 一个“简陋”的非 Web 客户端最终会解析您的 HTML 或 JSON 以获取他们想要的数据,如果您更改格式,这将导致脆弱性;如果他们的解析很糟糕,则会导致漏洞。
  • 如果该 HTML 从未经过清理就返回,您的 Web 客户端现在很容易受到 XSS 攻击。
  • 您返回的标签和类期望特定的样式表和 DOM 结构。从一个版本到另一个版本,该结构会发生变化,您可能会遇到版本偏差问题,即 JavaScript 客户端比服务器旧,并且服务器返回的 HTML 不再在旧客户端上正确呈现。对于经常发布的项目,这不是边缘情况。

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

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

如果您确实在客户端可见的字段中编码不透明数据(延续令牌、序列化 ID、版本信息等),请记录客户端应将其视为不透明 blob。始终使用二进制 proto 序列化,切勿使用文本格式或您自己为此类字段设计的格式。当您需要扩展不透明字段中编码的数据时,如果您尚未使用协议缓冲区序列化,您会发现自己重新发明了协议缓冲区序列化。

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

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

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

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

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

同样,在响应 proto 中返回实验名称曾经是一种日志记录便利——不成文的约定是客户端会在后续操作中将这些实验发送回去。实现相同目的的公认方法是在分析管道中进行日志连接。

一个例外

如果您需要连续的实时分析并且预算有限,运行日志连接可能会受到限制。在成本是决定性因素的情况下,提前反规范化日志数据可能是一种胜利。如果您需要将日志数据往返于您,请将其作为不透明的 blob 发送给客户端,并记录请求和响应字段。

注意:如果您需要在每个请求上返回或往返隐藏数据,那么您正在隐藏使用您的服务的真实成本,这也不是好事。

极少定义没有延续令牌的分页 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 的最佳实践是使用不透明的延续令牌(称为 next_page_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)

如果您的 CL 引入了一个字段,但该字段以后可能会有相关字段,请抢先将其放入自己的消息中,以避免这种情况

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 文件或其他 .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,其中包括突变发生的datacenter 和提交时间戳。

即使是一致性存储支持的系统,也通常需要一个令牌来触发更昂贵的读取一致性路径,而不是在每次读取时都产生成本。

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

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

临时处理此问题的成本是客户端的复杂性增加,因为他们需要弄清楚如何填写每个请求,并且服务器的复杂性增加,因为他们需要将 N 个请求选项转换为通用的内部选项。大量现实生活中的错误都可以追溯到这个例子。

相反,创建一个单独的消息来保存请求选项,并将该消息包含在每个顶级请求消息中。这是一个最佳实践示例

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 状态 proto 中返回成功和失败的详细信息。

一般来说,您希望不知道您处理部分失败的客户端仍然表现正确,并且知道的客户端获得额外的价值。

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

在一个往返中查询许多狭义指定的数据位的能力允许更广泛的 UX 选项,而无需服务器更改,因为它让客户端组合他们需要的内容。

这与前端和中间层服务器最相关。

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

当替代方案是在移动设备或 Web 上进行串行往返时,制作一次性 RPC

Web 或移动客户端需要进行两个查询,并且它们之间存在数据依赖关系的情况下,当前的最佳实践是创建一个新的 RPC,以保护客户端免受往返的影响。

对于移动设备,通过将两个服务方法捆绑到一个新方法中,几乎总是值得为您的客户端节省额外的往返成本。对于服务器到服务器的调用,情况可能不太清楚;这取决于您的服务对性能的敏感程度以及新方法引入的认知负担。

将重复字段设为消息,而不是标量或枚举

一个常见的演变是,单个重复字段需要变成多个相关的重复字段。如果您从重复的原始类型开始,您的选择是有限的——您可以创建并行的重复字段,或者定义一个包含值的新消息的新重复字段,并将客户端迁移到它。

如果您从重复消息开始,演变将变得很简单。

// 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 中的增强字段是标量或枚举,这将更难支持。

这也同样适用于 map。如果 map 值已经是消息,则向 map 值添加其他字段要容易得多,而不是必须从 map<string, string> 迁移到 map<string, MyProto>

一个例外

延迟关键型应用程序会发现,并行原始类型数组比单个消息数组更快地构造和删除;如果您使用 [packed=true](省略字段标签),它们在线上传输时也可能更小。分配固定数量的数组比分配 N 条消息的工作量更少。奖励:在 Proto3 中,打包是自动的;您不需要显式指定它。

使用 Proto Maps

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

因此,对值使用(可扩展的)消息类型是对朴素设计的直接改进。

Maps 已反向移植到所有语言的 proto2 中,因此使用 map<scalar, **message**> 比为此目的发明自己的 KVPair 更好1

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

首选幂等性

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

避免重复写入的一种简单方法是允许客户端指定客户端创建的请求 ID,您的服务器会对此进行重复数据删除(例如,内容哈希或 UUID)。

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

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

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

因此,即使服务名称是在特定包内定义的,采取措施防止服务名称上的命名冲突也是有意义的。例如,名为 Watcher 的服务很可能导致问题;类似 MyProjectWatcher 的名称会更好。

限制请求和响应大小

请求和响应大小应受到限制。我们建议限制在 8 MiB 左右,而 2 GiB 是许多 proto 实现会中断的硬性限制。许多存储系统对消息大小有限制。

此外,无界消息

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

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

  • 定义返回有界消息的 RPC,其中每个 RPC 调用在逻辑上独立于其他 RPC 调用。
  • 定义对单个对象进行操作的 RPC,而不是对无界的、客户端指定的对象列表进行操作的 RPC。
  • 避免在字符串、字节或重复字段中编码无界数据。
  • 定义长时间运行的操作。将结果存储在为可扩展的并发读取而设计的存储系统中。
  • 使用分页 API(参见极少情况下才定义不带延续令牌的分页 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 应该检查它在 RPC 边界接收到的错误;也就是说,它实现的 ProductService 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 中创建单独的 append、replace、delete 数组。

修复 #3:仅允许追加或清除。您可以通过将重复字段包装在消息中来实现此目的。存在但为空的消息表示清除,否则,任何重复元素都表示追加。

重复字段中的顺序独立性

尽量避免一般的顺序依赖性。这是一个额外的脆弱层。一种特别糟糕的顺序依赖性类型是并行数组。并行数组使客户端更难以解释结果,并且在您自己的服务内部传递两个相关字段是不自然的。

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 项的线路格式和迭代顺序未指定,这会导致 map 分片不一致。 ↩︎