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 字段是常见类型之一,例如 google.typegoogle.protobuf,则同时将其用作存储和 API 是可以接受的。

  • 如果您的服务对性能非常敏感,则可能值得用灵活性换取执行速度。如果您的服务没有数百万 QPS 和毫秒级延迟,那么您可能不是例外。

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

    • 您的服务存储系统
    • 您的系统不会根据客户端的结构化数据做出决策
    • 您的系统只是根据客户端的请求存储、加载和可能提供查询

    请注意,如果您正在实现类似于日志系统或围绕通用存储系统构建的基于 proto 的包装器,那么您可能希望您的客户端消息尽可能不透明地传输到您的存储后端,这样您就不会创建依赖关系网络。考虑使用扩展或 通过网络安全编码二进制 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 的编码。

永远不要将布尔值用于现在只有两种状态但将来可能会有更多状态的事物

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

例如,在返回帖子流时,开发人员可能需要根据来自 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。

通过网络安全编码二进制 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 的最佳实践是使用由内部 proto 支持的不透明延续标记(称为 next_page_token),您将对其进行序列化,然后进行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文件中的其他位置引用该消息。

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,则可以使用FieldMaskUtilJava/C++)库来自动过滤 proto。

读取掩码在客户端设置明确的期望,让他们控制想要获取的数据量,并允许后端仅获取客户端需要的数据。

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

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

包含版本字段以允许一致读取

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

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

即使是基于一致存储的系统,也通常希望使用令牌来触发更昂贵的读取一致路径,而不是在每次读取时都产生成本。

对返回相同数据类型的 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 来保护客户端免受往返的影响。

对于移动设备,通过在一个新的 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<string, string>迁移到map<string, MyProto>

一个例外

对于延迟敏感型应用,构建和删除原始类型并行数组的速度比构建和删除单个消息数组更快;如果使用[packed=true](省略字段标签),它们在网络传输中也可以更小。分配固定数量的数组比分配 N 个消息的工作量更少。额外的好处是:在Proto3中,打包是自动的;您无需显式指定它。

使用 Proto 映射

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

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

映射已回溯移植到所有语言的 proto2 中,因此使用 map<scalar, **message**> 比为相同目的发明自己的 KVPair 更好1

如果要表示任意数据(其结构事先未知),请使用google.protobuf.Any

优先使用幂等性

在您上层的某个堆栈中,客户端可能具有重试逻辑。如果重试是修改操作,用户可能会遇到意外情况。重复的评论、构建请求、编辑等对任何人都没有好处。

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

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

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

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

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

确保每个 RPC 指定并强制执行(宽松的)截止时间

默认情况下,RPC 没有超时。由于请求可能会占用仅在完成时才释放的后端资源,因此设置一个允许所有正常请求完成的默认截止时间是一种良好的防御措施。过去,不强制执行截止时间曾给主要服务造成严重问题。RPC 客户端仍然应该在传出的 RPC 上设置截止时间,并且在使用标准框架时通常会默认这样做。截止时间可能会并且通常会由附加到请求的较短截止时间覆盖。

设置 deadline 选项可以清晰地向您的客户端传达 RPC 截止时间,并且标准框架会尊重和强制执行该选项。

rpc Foo(FooRequest) returns (FooResponse) {
  option deadline = x; // there is no globally good default
}

选择截止时间值将尤其影响您的系统在负载下的行为。对于现有服务,在强制执行新的截止时间以避免破坏客户端之前,评估现有客户端行为至关重要(咨询 SRE)。在某些情况下,事后可能无法强制执行更短的截止时间。

绑定请求和响应大小

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

此外,无界消息

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

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

  • 定义返回有界消息的 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 消息类型

重用消息

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

这使您可以灵活地独立发展您的方法请求/响应类型,但共享逻辑子单元的代码。

附录

返回重复字段

当重复字段为空时,客户端无法分辨该字段是否只是未由服务器填充,或者该字段的备份数据是否确实为空。换句话说,重复字段没有 hasFoo 方法。

将重复字段包装在消息中是获得 hasFoo 方法的简单方法。

message FooList {
  repeated Foo foos;
}

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

更新重复字段

更新重复字段的最糟糕方法是强制客户端提供替换列表。强制客户端提供整个数组的危险有很多。不保留未知字段的客户端会导致数据丢失。并发写入会导致数据丢失。即使这些问题不适用,您的客户端也需要仔细阅读您的文档以了解服务器端如何解释该字段。空字段是否意味着服务器不会更新它,还是服务器会清除它?

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

修复方案 #2:在请求 proto 中创建单独的追加、替换、删除数组。

修复方案 #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]将字段注释为“延迟解析”。
  • 将字段声明为字节并记录其类型。关心解析该字段的客户端可以手动执行此操作。这种方法的危险在于,没有任何东西可以阻止某人将错误类型的消息放入字节字段中。您永远不应该对写入任何日志的 proto 执行此操作,因为它会阻止对 proto 进行 PII 审查或出于策略或隐私原因进行清理。

  1. 包含 map<k,v> 字段的 proto 的一个陷阱。不要将它们用作 MapReduce 中的归约键。Proto3 映射项的线格式和迭代顺序是未指定的,这会导致映射分片不一致。↩︎