晚上好 世界

晚上好 世界

早安

本文翻译自 ProtoJSON Format | Protocol Buffers Documentation

Protobuf 支持规范的 JSON 编码格式,便于与不支持标准 Protobuf 二进制有线格式的系统共享数据。

ProtoJSON 格式不如 Protobuf 有线格式高效。转换器在编解码消息时会消耗更多 CPU 资源,且(除少数情况外)编码后的消息占用空间更大。此外,ProtoJSON 格式会将字段名和枚举值名写入编码消息,导致后续重命名这些标识符变得极其困难。删除字段更是破坏性变更,会引发解析错误。简言之,Google 坚持使用标准有线格式而非 ProtoJSON 格式有诸多充分理由。

下文表格按类型详细描述了编码规则。

解析与生成规则

将 JSON 编码数据解析为 Protocol Buffer 时:

  • 若值缺失或为 null,将被解释为对应字段的默认值
  • 单数字段允许多个值(通过重复键或等效 JSON 键),保留最后一个值(与二进制格式解析行为一致)
  • ⚠️ 注意:非所有解析器实现都符合此规范,部分实现可能拒绝重复键

从 Protocol Buffer 生成 JSON 编码数据时:

  • 默认值字段:若字段不支持存在性检测,默认省略该字段(实现方可提供选项强制输出)
  • 显式设置字段:支持存在性检测的字段(如带 optional 关键字的 proto3 字段、任意版本的 message 类型字段)始终输出,即使值为默认值
  • Proto3 隐式存在标量字段:仅当值非类型默认值时输出
  • 数值处理:若解析值超出目标类型范围,将按 C++ 强制转换规则处理(如 64 位整数转为 int32 时将被截断)

JSON 数据表示对照表

Protobuf 类型 JSON 类型 JSON 示例 说明
message object {"fooBar": v, "g": null, ...} • 字段名转换为小驼峰命名(lowerCamelCase)作为 JSON 键
• 若指定 json_name 字段选项,则使用该值作为键
• 解析器同时接受小驼峰名(或 json_name 值)和原始字段名
null 会被解释为字段默认值
• ⚠️ json_name 禁止设为 null(详见严格校验说明
enum string "FOO_BAR" • 使用 proto 定义的枚举值名
• 解析器同时接受枚举名和整数值
map<K,V> object {"k": v, ...} 所有键均转换为字符串
repeated V array [v, ...] null 等价于空列表 []
bool boolean true, false -
string string "Hello World!" -
bytes base64 string "YWJjMTIzIT8kKiYoKSctPUB+" • 输出:标准 base64 编码(含填充)
• 输入:接受标准/URL安全 base64(含/不含填充)
int32, fixed32, uint32 number 1, -10, 0 • 接受数字或字符串
• 空字符串无效
int64, fixed64, uint64 string "1", "-10" • 接受数字或字符串
• 空字符串无效
float, double number/string 1.1, "NaN", "-Infinity" • 接受数字或特殊字符串("NaN"/"Infinity"/"-Infinity")
• 支持科学计数法
• 空字符串无效
Any object {"@type": "url", "value": yyy} 含特殊 JSON 映射的值按固定结构转换,其他值转换后插入 "@type" 字段标识类型
Timestamp string "1972-01-01T10:00:20.021Z" • 输出:RFC 3339 格式(Z 标准化,含 0/3/6/9 位小数)
• 输入:接受非 "Z" 时区偏移
Duration string "1.000340012s" • 输出:含 0/3/6/9 位小数 + "s" 后缀
• 输入:接受任意精度纳秒值 + "s" 后缀
Struct object { ... } 任意 JSON 对象(参见 struct.proto
Wrapper types 对应基础类型 2, "foo", true, null 包装类型使用原始类型表示规则,但允许保留 null
FieldMask string "f.fooBar,h" 参见 field_mask.proto
ListValue array [foo, bar, ...] -
Value any 任意值 任意 JSON 值(详见 google.protobuf.Value
NullValue null null JSON null
Empty object {} 空 JSON 对象

JSON 选项 {#json-options}

符合规范的 Protobuf JSON 实现可提供以下选项:

始终输出无存在性字段

  • 默认行为:省略不支持存在性检测且为默认值的字段(如 proto3 隐式存在字段的 0 值、空字符串、空列表/映射)
  • 选项:可覆盖默认行为,强制输出含默认值的字段
  • ⚠️ 现状:截至 v25.x,C++/Java/Python 实现存在不一致性(影响 proto2 optional 但不影响 proto3 optional),后续版本将修复

忽略未知字段

  • 默认行为:拒绝未知字段
  • 选项:解析时忽略未知字段

使用 proto 字段名替代小驼峰命名

  • 默认行为:字段名转为小驼峰命名作为 JSON 键
  • 选项:直接使用 proto 字段名作为 JSON 键
  • ⚠️ 要求:解析器必须同时接受小驼峰名和原始字段名

枚举值输出为整数

  • 默认行为:输出枚举值名称
  • 选项:输出枚举值对应的数字

本文翻译自 Language Guide (proto 3) | Protocol Buffers Documentation

本指南介绍如何使用 Protocol Buffer 语言构建协议缓冲区数据,包括 .proto 文件语法以及如何从 .proto 文件生成数据访问类。它涵盖了 Protocol Buffers 语言的 proto3 版本。

有关 editions 语法的信息,请参阅 Protobuf Editions 语言指南

有关 proto2 语法的信息,请参阅 Proto2 语言指南

这是参考指南——如需查看使用本文档中描述的多种功能的逐步示例,请参阅您所选语言的教程

定义消息类型 {#simple}

首先看一个简单示例。假设您想定义一个搜索请求的消息格式,每个搜索请求包含查询字符串、结果页码和每页结果数。以下是用于定义消息类型的 .proto 文件:

syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 results_per_page = 3;
}
  • 文件第一行指定您正在使用 protobuf 语言规范的 proto3 版本

    • edition(或 proto2/proto3 的 syntax)必须是文件中第一个非空、非注释行
    • 如果未指定 editionsyntax,协议缓冲区编译器将假定您使用 proto2
  • SearchRequest 消息定义指定了三个字段(名称/值对),每个字段对应要包含在此类消息中的数据片段。每个字段都有名称和类型

指定字段类型 {#specifying-types}

在前面的示例中,所有字段都是标量类型:两个整数(page_numberresults_per_page)和一个字符串(query)。您也可以为字段指定枚举和复合类型(如其他消息类型)

分配字段编号 {#assigning}

您必须为消息定义中的每个字段分配 1536,870,911 之间的数字,并遵守以下限制:

  • 给定数字在该消息的所有字段中必须唯一
  • 字段编号 19,00019,999 保留给 Protocol Buffers 实现使用。如果您在消息中使用这些保留字段号,协议缓冲区编译器将报错
  • 不能使用任何先前保留的字段编号或已分配给扩展的字段编号

此编号在消息类型投入使用后无法更改,因为它在消息线格式中标识字段。"更改"字段编号等同于删除该字段并使用相同类型但新编号创建新字段。有关正确操作方法,请参阅删除字段

字段编号永远不应重用。切勿将字段编号从保留列表中取出以用于新字段定义。请参阅重用字段编号的后果

对于最常设置的字段,应使用字段编号 1 到 15。较低的字段编号值在线格式中占用更少空间。例如,范围 1 到 15 的字段编号编码时占用一个字节。范围 16 到 2047 的字段编号占用两个字节。您可以在协议缓冲区编码中了解更多信息

重用字段编号的后果 {#consequences}

重用字段编号会导致解码线格式消息时出现歧义

protobuf 线格式精简,不提供检测使用一个定义编码而使用另一个定义解码字段的方法

使用一个定义编码字段,然后用不同定义解码同一字段可能导致:

  • 开发人员调试时间损失
  • 解析/合并错误(最佳情况)
  • PII/SPII 泄露
  • 数据损坏

字段编号重用的常见原因:

  • 重新编号字段(有时为实现更美观的字段编号顺序)。重新编号实际上会删除并重新添加涉及的所有字段,导致不兼容的线格式更改
  • 删除字段但未保留编号以防止未来重用

字段编号限制为 29 位而非 32 位,因为三位用于指定字段的线格式。有关详细信息,请参阅编码主题

指定字段基数 {#field-labels}

消息字段可以是以下之一:

  • 单数(Singular):
    在 proto3 中,单数字段有两种类型:

    • optional:(推荐)optional 字段处于两种可能状态之一:

      • 字段已设置,包含显式设置或从线解析的值。它将序列化到线
      • 字段未设置,将返回默认值。不会序列化到线
        您可以检查值是否被显式设置
        为获得与 protobuf editions 和 proto2 的最大兼容性,推荐使用 optional 而非隐式字段
    • 隐式:(不推荐)隐式字段没有显式基数标签,行为如下:

      • 如果字段是消息类型,其行为类似于 optional 字段
      • 如果字段非消息类型,有两种状态:

        • 字段设置为显式设置或从线解析的非默认(非零)值。它将序列化到线
        • 字段设置为默认(零)值。不会序列化到线。实际上,您无法确定默认(零)值是设置、从线解析还是根本未提供。有关此主题的更多信息,请参阅字段存在性
  • repeated:此字段类型在格式良好的消息中可以重复零次或多次。重复值的顺序将被保留
  • map:这是键/值对字段类型。有关此字段类型的更多信息,请参阅映射

重复字段默认打包 {#use-packed}

在 proto3 中,标量数值类型的 repeated 字段默认使用 packed 编码

您可以在协议缓冲区编码中了解更多关于 packed 编码的信息

消息类型字段始终具有字段存在性 {#field-presence}

在 proto3 中,消息类型字段已具有字段存在性。因此,添加 optional 修饰符不会更改字段的字段存在性

以下代码示例中 Message2Message3 的定义为所有语言生成相同的代码,且在二进制、JSON 和 TextFormat 中的表示没有区别:

syntax="proto3";

package foo.bar;

message Message1 {}

message Message2 {
  Message1 foo = 1;
}

message Message3 {
  optional Message1 bar = 1;
}

格式良好的消息 {#well-formed}

术语"格式良好"(well-formed)应用于 protobuf 消息时,指序列化/反序列化的字节。protoc 解析器会验证给定的 proto 定义文件是否可解析

单数字段可以多次出现在线格式字节中。解析器将接受输入,但只有该字段的最后一个实例可通过生成的绑定访问。有关此主题的更多信息,请参阅最后胜出

添加更多消息类型 {#adding-types}

可以在单个 .proto 文件中定义多个消息类型。如果要定义多个相关消息(例如,如果想定义与 SearchResponse 消息类型对应的回复消息格式),可以将其添加到同一 .proto

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 results_per_page = 3;
}

message SearchResponse {
 ...
}

组合消息会导致臃肿 虽然可以在单个 .proto 文件中定义多种消息类型(如 message、enum 和 service),但当在单个文件中定义大量具有不同依赖关系的消息时,也可能导致依赖膨胀。建议每个 .proto 文件包含尽可能少的消息类型

添加注释 {#adding-comments}

.proto 文件添加注释:

  • 建议在 .proto 代码元素之前的行使用 C/C++/Java 行尾风格注释 '//'
  • 也接受 C 风格内联/多行注释 /* ... */

    • 使用多行注释时,建议使用 '*' 作为边距行
/**
 * SearchRequest 表示搜索查询,包含分页选项以指示响应中包含哪些结果
 */
message SearchRequest {
  string query = 1;

  // 需要哪一页?
  int32 page_number = 2;

  // 每页返回的结果数
  int32 results_per_page = 3;
}

删除字段 {#deleting}

如果操作不当,删除字段可能导致严重问题

当不再需要某个字段且客户端代码中所有引用已被删除时,可以从消息中删除字段定义。但是,您必须保留已删除字段的编号。如果不保留字段编号,开发人员将来可能会重用该编号

您还应保留字段名称以允许 JSON 和 TextFormat 编码的消息继续解析

保留字段编号 {#reserved-field-numbers}

如果通过完全删除字段或将其注释掉来更新消息类型,未来开发人员在更新类型时可以重用该数字值。如果稍后加载相同 .proto 的旧实例,可能导致严重问题,包括数据损坏、隐私漏洞等。防止这种情况的一种方法是将已删除条目的数字值(和/或名称,这也可能导致 JSON 序列化问题)指定为 reserved。如果任何未来用户尝试使用这些标识符,协议缓冲区编译器将报错

message Foo {
  reserved 2, 15, 9 to 11;
}

保留字段编号范围是包含的(9 to 11 等同于 9, 10, 11

保留字段名称 {#reserved-field-names}

稍后重用旧字段名称通常是安全的,除非使用 TextProto 或 JSON 编码(字段名称被序列化)。为避免此风险,可以将已删除字段名称添加到 reserved 列表

保留名称仅影响 protoc 编译器行为,不影响运行时行为(一个例外:TextProto 实现在解析时可能丢弃具有保留名称的未知字段(不引发像其他未知字段那样的错误),目前仅 C++ 和 Go 实现如此)。运行时 JSON 解析不受保留名称影响

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

注意不能在同一个 reserved 语句中混合字段名称和数字值

.proto 生成什么? {#generated}

.proto 上运行协议缓冲区编译器时,编译器会生成所选语言的代码,这些代码是处理文件中描述的消息类型所需的,包括获取和设置字段值、将消息序列化到输出流以及从输入流解析消息

  • 对于 C++,编译器从每个 .proto 生成 .h.cc 文件,并为文件中的每个消息类型生成一个类
  • 对于 Java,编译器生成一个 .java 文件,其中包含每个消息类型的类,以及用于创建消息类实例的特殊 Builder
  • 对于 Kotlin,除了 Java 生成的代码外,编译器为每个消息类型生成一个 .kt 文件,提供增强的 Kotlin API。包括简化创建消息实例的 DSL、可空字段访问器和复制函数
  • Python 略有不同——Python 编译器生成一个模块,其中包含 .proto 中每个消息类型的静态描述符,然后与元类结合使用,在运行时创建必要的 Python 数据访问类
  • 对于 Go,编译器生成一个 .pb.go 文件,其中包含文件中每个消息类型的类型
  • 对于 Ruby,编译器生成一个 .rb 文件,其中包含包含消息类型的 Ruby 模块
  • 对于 Objective-C,编译器从每个 .proto 生成 pbobjc.hpbobjc.m 文件,并为文件中的每个消息类型生成一个类
  • 对于 C#,编译器从每个 .proto 生成一个 .cs 文件,并为文件中的每个消息类型生成一个类
  • 对于 PHP,编译器为每个描述的消息类型生成一个 .php 消息文件,并为编译的每个 .proto 生成一个 .php 元数据文件。元数据文件用于将有效消息类型加载到描述符池中
  • 对于 Dart,编译器生成一个 .pb.dart 文件,其中包含文件中每个消息类型的类

您可以通过遵循所选语言的教程了解有关使用每种语言 API 的更多信息。有关更多 API 详细信息,请参阅相关 API 参考

标量值类型 {#scalar}

标量消息字段可以具有以下类型之一——下表显示了 .proto 文件中指定的类型,以及自动生成类中的相应类型:

Proto 类型 说明
double
float
int32 使用变长编码。编码负数效率低——如果字段可能有负值,请改用 sint32
int64 使用变长编码。编码负数效率低——如果字段可能有负值,请改用 sint64
uint32 使用变长编码
uint64 使用变长编码
sint32 使用变长编码。有符号整数值。比常规 int32 更高效地编码负数
sint64 使用变长编码。有符号整数值。比常规 int64 更高效地编码负数
fixed32 始终四字节。如果值常大于 228,比 uint32 更高效
fixed64 始终八字节。如果值常大于 256,比 uint64 更高效
sfixed32 始终四字节
sfixed64 始终八字节
bool
string 字符串必须始终包含 UTF-8 编码或 7 位 ASCII 文本,且长度不能超过 232
bytes 可包含任意字节序列,长度不超过 232
Proto 类型 C++ 类型 Java/Kotlin 类型[1] Python 类型[3] Go 类型 Ruby 类型 C# 类型 PHP 类型 Dart 类型 Rust 类型
double double double float float64 Float double float double f64
float float float float float32 Float float float double f32
int32 int32_t int int int32 Fixnum 或 Bignum(根据需要) int integer int i32
int64 int64_t long int/long[4] int64 Bignum long integer/string[6] Int64 i64
uint32 uint32_t int[2] int/long[4] uint32 Fixnum 或 Bignum(根据需要) uint integer int u32
uint64 uint64_t long[2] int/long[4] uint64 Bignum ulong integer/string[6] Int64 u64
sint32 int32_t int int int32 Fixnum 或 Bignum(根据需要) int integer int i32
sint64 int64_t long int/long[4] int64 Bignum long integer/string[6] Int64 i64
fixed32 uint32_t int[2] int/long[4] uint32 Fixnum 或 Bignum(根据需要) uint integer int u32
fixed64 uint64_t long[2] int/long[4] uint64 Bignum ulong integer/string[6] Int64 u64
sfixed32 int32_t int int int32 Fixnum 或 Bignum(根据需要) int integer int i32
sfixed64 int64_t long int/long[4] int64 Bignum long integer/string[6] Int64 i64
bool bool boolean bool bool TrueClass/FalseClass bool boolean bool bool
string std::string String str/unicode[5] string String (UTF-8) string string String ProtoString
bytes std::string ByteString str (Python 2), bytes (Python 3) []byte String (ASCII-8BIT) ByteString string List ProtoBytes

[1] Kotlin 使用 Java 中的相应类型(即使是无符号类型),以确保在混合 Java/Kotlin 代码库中的兼容性

[2] 在 Java 中,无符号 32 位和 64 位整数使用其有符号对应项表示,最高位仅存储在符号位中

[3] 在所有情况下,为字段设置值将执行类型检查以确保其有效

[4] 64 位或无符号 32 位整数在解码时始终表示为 long,但如果设置字段时给出 int,则可以是 int。在任何情况下,设置时值必须适合所表示的类型。参见 [2]

[5] Python 字符串在解码时表示为 unicode,但如果给出 ASCII 字符串,则可以是 str(可能会更改)

[6] 64 位机器上使用 integer,32 位机器上使用 string

序列化消息时,您可以在协议缓冲区编码中了解这些类型的编码方式

默认字段值 {#default}

解析消息时,如果编码的消息字节不包含特定字段,则在解析对象中访问该字段将返回该字段的默认值。默认值特定于类型:

  • 对于字符串,默认值为空字符串
  • 对于字节,默认值为空字节
  • 对于布尔值,默认值为 false
  • 对于数值类型,默认值为零
  • 对于消息字段,未设置字段。其确切值取决于语言。详情请参阅生成代码指南
  • 对于枚举,默认值是第一个定义的枚举值,该值必须为 0。参见枚举默认值

重复字段的默认值为空(通常是相应语言中的空列表)

映射字段的默认值为空(通常是相应语言中的空映射)

请注意,对于隐式存在的标量字段,一旦消息被解析,就无法判断该字段是显式设置为默认值(例如布尔值设置为 false)还是根本未设置:在定义消息类型时应牢记这一点。例如,如果不希望某些行为在默认情况下也发生,请不要使用布尔值在设置为 false 时触发该行为。另请注意,如果标量消息字段确实设置为其默认值,该值将不会在线序列化。如果浮点或双精度值设置为 +0,则不会序列化,但 -0 被视为不同并将被序列化

有关默认值在生成代码中如何工作的更多详细信息,请参阅所选语言的生成代码指南

枚举 {#enum}

定义消息类型时,可能希望其中一个字段仅具有预定义列表中的值。例如,假设您想为每个 SearchRequest 添加一个 corpus 字段,其中语料库可以是 UNIVERSALWEBIMAGESLOCALNEWSPRODUCTSVIDEO。您可以通过在消息定义中添加具有每个可能值常量的 enum 来非常简单地实现这一点

在以下示例中,我们添加了一个名为 Corpusenum,其中包含所有可能的值,以及一个类型为 Corpus 的字段:

enum Corpus {
  CORPUS_UNSPECIFIED = 0;
  CORPUS_UNIVERSAL = 1;
  CORPUS_WEB = 2;
  CORPUS_IMAGES = 3;
  CORPUS_LOCAL = 4;
  CORPUS_NEWS = 5;
  CORPUS_PRODUCTS = 6;
  CORPUS_VIDEO = 7;
}

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 results_per_page = 3;
  Corpus corpus = 4;
}

枚举默认值 {#enum-default}

SearchRequest.corpus 字段的默认值为 CORPUS_UNSPECIFIED,因为这是枚举中定义的第一个值

在 proto3 中,枚举定义中定义的第一个值必须为零,并且应命名为 ENUM_TYPE_NAME_UNSPECIFIEDENUM_TYPE_NAME_UNKNOWN。这是因为:

  • 必须有一个零值,以便我们可以使用 0 作为数值默认值
  • 零值需要是第一个元素,以与 proto2 语义兼容(其中第一个枚举值是默认值,除非显式指定了不同的值)

还建议此第一个默认值除"此值未指定"外没有其他语义含义

枚举值别名 {#enum-aliases}

可以通过为不同的枚举常量分配相同的值来定义别名。为此,需要将 allow_alias 选项设置为 true。否则,当发现别名时,协议缓冲区编译器会生成警告消息。虽然所有别名值对于序列化都有效,但反序列化时仅使用第一个值

enum EnumAllowingAlias {
  option allow_alias = true;
  EAA_UNSPECIFIED = 0;
  EAA_STARTED = 1;
  EAA_RUNNING = 1;
  EAA_FINISHED = 2;
}

enum EnumNotAllowingAlias {
  ENAA_UNSPECIFIED = 0;
  ENAA_STARTED = 1;
  // ENAA_RUNNING = 1;  // 取消注释此行将导致警告消息
  ENAA_FINISHED = 2;
}

枚举器常量必须在 32 位整数的范围内。由于 enum 值在线上使用变长编码,负值效率低下,因此不推荐使用。您可以在消息定义中定义 enum(如前面的示例所示),也可以在外部定义——这些 enum 可以在 .proto 文件中的任何消息定义中重用。您还可以使用在一个消息中声明的 enum 类型作为另一个消息中字段的类型,语法为 _MessageType_._EnumType_

在包含 enum.proto 上运行协议缓冲区编译器时,生成的代码将具有 Java、Kotlin 或 C++ 的相应 enum,或 Python 的特殊 EnumDescriptor 类,用于在运行时生成的类中创建一组具有整数值的符号常量

{{% alert title="重要" color="warning" %}}
生成的代码可能受语言特定限制(如一种语言的枚举器数量限制在数千个)。请查看您计划使用的语言的限制
{{% /alert %}}

反序列化期间,无法识别的枚举值将保留在消息中,但消息反序列化时如何表示取决于语言。在支持开放枚举类型(值超出指定符号范围)的语言(如 C++ 和 Go)中,未知枚举值仅存储为其基础整数表示。在具有封闭枚举类型的语言(如 Java)中,使用枚举中的 case 表示无法识别的值,并且可以使用特殊访问器访问基础整数。在任何情况下,如果消息被序列化,无法识别的值仍将随消息一起序列化

{{% alert title="重要" color="warning" %}}
有关枚举应如何工作与目前在不同语言中工作方式的对比信息,请参阅枚举行为
{{% /alert %}}

有关如何在应用程序中使用消息 enum 的更多信息,请参阅所选语言的生成代码指南

保留值 {#reserved}

如果通过完全删除枚举条目或将其注释掉来更新枚举类型,未来用户可以在更新类型时重用数字值。如果稍后加载相同 .proto 的旧实例,可能导致严重问题(包括数据损坏、隐私漏洞等)。确保不会发生这种情况的一种方法是指定已删除条目的数字值(和/或名称,这也可能导致 JSON 序列化问题)为 reserved。如果任何未来用户尝试使用这些标识符,协议缓冲区编译器将报错。您可以使用 max 关键字指定保留的数字值范围上限

enum Foo {
  reserved 2, 15, 9 to 11, 40 to max;
  reserved "FOO", "BAR";
}

注意不能在同一个 reserved 语句中混合字段名称和数字值

使用其他消息类型 {#other}

您可以使用其他消息类型作为字段类型。例如,假设您想在每个 SearchResponse 消息中包含 Result 消息——为此,您可以在同一 .proto 中定义 Result 消息类型,然后在 SearchResponse 中指定类型为 Result 的字段:

message SearchResponse {
  repeated Result results = 1;
}

message Result {
  string url = 1;
  string title = 2;
  repeated string snippets = 3;
}

导入定义 {#importing}

在前面的示例中,Result 消息类型与 SearchResponse 定义在同一文件中——如果要用作字段类型的消息类型已在另一个 .proto 文件中定义怎么办?

您可以通过导入来使用其他 .proto 文件中的定义。要导入另一个 .proto 的定义,请在文件顶部添加导入语句:

import "myproject/other_protos.proto";

默认情况下,只能使用直接导入的 .proto 文件中的定义。但是,有时可能需要将 .proto 文件移动到新位置。与其直接移动 .proto 文件并在单个更改中更新所有调用点,不如在旧位置放置一个占位符 .proto 文件,使用 import public 概念将所有导入转发到新位置

注意: Java 中可用的公共导入功能在移动整个 .proto 文件或使用 java_multiple_files = true 时最有效。在这些情况下,生成的名称保持稳定,无需更新代码中的引用。虽然在技术上可以在没有 java_multiple_files = true 的情况下移动 .proto 文件的子集,但这样做需要同时更新许多引用,因此可能无法显著简化迁移。Kotlin、TypeScript、JavaScript、GCL 或使用 protobuf 静态反射的 C++ 目标中不提供此功能

导入包含 import public 语句的 proto 的任何代码都可以传递性依赖 import public 依赖项。例如:

// new.proto
// 所有定义已移至此
// old.proto
// 所有客户端正在导入的 proto
import public "new.proto";
import "other.proto";
// client.proto
import "old.proto";
// 您使用 old.proto 和 new.proto 中的定义,但不使用 other.proto

协议编译器在协议编译器命令行使用 -I/--proto_path 标志指定的一组目录中搜索导入的文件。如果未给出标志,则在调用编译器的目录中查找。通常应将 --proto_path 标志设置为项目的根目录,并对所有导入使用完全限定名称

使用 proto2 消息类型 {#proto2}

可以导入 proto2 消息类型并在 proto3 消息中使用它们,反之亦然。但是,proto2 枚举不能直接在 proto3 语法中使用(如果导入的 proto2 消息使用它们则可以)

嵌套类型 {#nested}

您可以在其他消息类型内部定义和使用消息类型,如下例所示——这里 Result 消息在 SearchResponse 消息内部定义:

message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 1;
}

如果要在父消息类型之外重用此消息类型,请将其引用为 _Parent_._Type_

message SomeOtherMessage {
  SearchResponse.Result result = 1;
}

您可以随意深度嵌套消息。在下面的示例中,请注意两个名为 Inner 的嵌套类型完全独立,因为它们定义在不同的消息中:

message Outer {       // 级别 0
  message MiddleAA {  // 级别 1
    message Inner {   // 级别 2
      int64 ival = 1;
      bool  booly = 2;
    }
  }
  message MiddleBB {  // 级别 1
    message Inner {   // 级别 2
      int32 ival = 1;
      bool  booly = 2;
    }
  }
}

更新消息类型 {#updating}

如果现有消息类型不再满足您的所有需求(例如,您希望消息格式有一个额外的字段)——但仍希望使用旧格式创建的代码,不用担心!使用二进制线格式时,更新消息类型而不破坏任何现有代码非常简单

{{% alert title="注意" color="note" %}}
如果使用 JSON 或 proto 文本格式存储协议缓冲区消息,您可以在 proto 定义中进行的更改是不同的
{{% /alert %}}

检查 Proto 最佳实践和以下规则:

  • 不要更改任何现有字段的字段编号。"更改"字段编号等同于删除字段并使用相同类型添加新字段。如果要重新编号字段,请参阅删除字段的说明
  • 如果添加新字段,使用"旧"消息格式的代码序列化的任何消息仍可由新生成的代码解析。您应牢记这些元素的默认值,以便新代码可以正确与旧代码生成的消息交互。同样,新代码创建的消息可以由旧代码解析:旧二进制文件在解析时直接忽略新字段。有关详细信息,请参阅未知字段部分
  • 可以删除字段,只要在更新的消息类型中不再使用该字段编号。您可以重命名字段(例如添加前缀"OBSOLETE_"),或将字段编号保留,以便 .proto 的未来用户无法意外重用该编号
  • int32uint32int64uint64bool 都兼容——这意味着您可以将字段从其中一种类型更改为另一种,而不会破坏向前或向后兼容性。如果从线解析的数字不适合相应类型,您将获得与在 C++ 中将数字强制转换为该类型相同的效果(例如,如果 64 位数字作为 int32 读取,它将被截断为 32 位)
  • sint32sint64 彼此兼容,但兼容其他整数类型。如果写入的值在 INT_MIN 和 INT_MAX 之间(含),则使用任一类型解析时都是相同的值。如果 sint64 值在该范围之外写入并作为 sint32 解析,则变长编码被截断为 32 位,然后进行 zigzag 解码(这将导致观察到不同的值)
  • 只要字节是有效的 UTF-8,stringbytes 就兼容
  • 如果字节包含消息的编码实例,则嵌入消息与 bytes 兼容
  • fixed32sfixed32 兼容,fixed64sfixed64 兼容
  • 对于 stringbytes 和消息字段,单数与 repeated 兼容。给定重复字段的序列化数据作为输入,期望此字段为单数的客户端将获取最后一个输入值(如果是原始类型字段),或合并所有输入元素(如果是消息类型字段)。请注意,对于数值类型(包括布尔值和枚举),这通常不安全。数值类型的重复字段默认使用打包格式序列化,当期望单数字段时将无法正确解析
  • 枚举在线格式方面与 int32uint32int64uint64 兼容(注意如果值不适合将被截断)。但是,请注意客户端代码在消息反序列化时可能以不同方式处理它们:例如,无法识别的 proto3 enum 值将保留在消息中,但消息反序列化时如何表示取决于语言。整数字段总是保留其值
  • 将单个 optional 字段或扩展更改为 oneof 的成员在二进制上是兼容的,但对于某些语言(尤其是 Go),生成的代码 API 将以不兼容的方式更改。因此,Google 在其公共 API 中不进行此类更改,如 AIP-180 中所述。考虑到相同的源兼容性警告,如果您确定不会同时设置多个字段,将多个字段移动到新的 oneof 可能是安全的。将字段移动到现有的 oneof 是不安全的。同样,将单个字段 oneof 更改为 optional 字段或扩展是安全的
  • map<K, V> 和相应的 repeated 消息字段之间更改字段在二进制上是兼容的(有关消息布局和其他限制,请参见下面的映射)。但是,更改的安全性取决于应用程序:使用 repeated 字段定义反序列化和重新序列化消息时,客户端将产生语义相同的结果;但是,使用 map 字段定义的客户端可能会重新排序条目并删除具有重复键的条目

未知字段 {#unknowns}

未知字段是格式良好的协议缓冲区序列化数据,表示解析器无法识别的字段。例如,当旧二进制文件解析新二进制文件(具有新字段)发送的数据时,这些新字段在旧二进制文件中成为未知字段

Proto3 消息保留未知字段,并在解析和序列化输出中包含它们,这与 proto2 行为匹配

保留未知字段 {#retaining}

某些操作可能导致未知字段丢失。例如,如果执行以下操作之一,未知字段将丢失:

  • 将 proto 序列化为 JSON
  • 迭代消息中的所有字段以填充新消息

为避免丢失未知字段,请执行以下操作:

  • 使用二进制;避免使用文本格式进行数据交换
  • 使用面向消息的 API(如 CopyFrom()MergeFrom())复制数据,而不是逐字段复制

TextFormat 是一个特例。序列化为 TextFormat 会使用字段编号打印未知字段。但是,将 TextFormat 数据解析回二进制 proto 时,如果存在使用字段编号的条目,则会失败

Any {#any}

Any 消息类型允许您使用消息作为嵌入类型,而无需其 .proto 定义。Any 包含一个任意的序列化消息(作为 bytes)以及一个 URL(作为全局唯一标识符并解析为该消息的类型)。要使用 Any 类型,需要导入 google/protobuf/any.proto

import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

给定消息类型的默认类型 URL 是 type.googleapis.com/_packagename_._messagename_

不同的语言实现将支持运行时库帮助程序以类型安全的方式打包和解包 Any 值——例如,在 Java 中,Any 类型将具有特殊的 pack()unpack() 访问器,而在 C++ 中有 PackFrom()UnpackTo() 方法:

// 在 Any 中存储任意消息类型
NetworkErrorDetails details = ...;
ErrorStatus status;
status.add_details()->PackFrom(details);

// 从 Any 读取任意消息
ErrorStatus status = ...;
for (const google::protobuf::Any& detail : status.details()) {
  if (detail.Is<NetworkErrorDetails>()) {
    NetworkErrorDetails network_error;
    detail.UnpackTo(&network_error);
    ... 处理 network_error ...
  }
}

Oneof {#oneof}

如果消息中有许多单数字段,并且最多同时设置一个字段,则可以使用 oneof 功能强制执行此行为并节省内存

Oneof 字段类似于可选字段,只是 oneof 中的所有字段共享内存,并且最多可以同时设置一个字段。设置 oneof 的任何成员会自动清除所有其他成员。您可以使用特殊的 case()WhichOneof() 方法检查 oneof 中的哪个值(如果有)被设置(具体取决于所选语言)

请注意,如果设置了多个值,则按 proto 中的顺序最后设置的值将覆盖所有先前值

oneof 字段的字段编号在封闭消息中必须唯一

使用 Oneof {#using-oneof}

要在 .proto 中定义 oneof,请使用 oneof 关键字后跟您的 oneof 名称(本例中为 test_oneof):

message SampleMessage {
  oneof test_oneof {
    string name = 4;
    SubMessage sub_message = 9;
  }
}

然后将 oneof 字段添加到 oneof 定义中。可以添加除 map 字段和 repeated 字段外的任何类型的字段。如果需要向 oneof 添加重复字段,可以使用包含重复字段的消息

在生成的代码中,oneof 字段具有与常规字段相同的 getter 和 setter。您还会获得一个特殊方法来检查 oneof 中的哪个值(如果有)被设置。您可以在相关 API 参考中了解所选语言的 oneof API 的更多信息

Oneof 特性 {#oneof-features}

  • 设置 oneof 字段将自动清除 oneof 的所有其他成员。因此,如果设置多个 oneof 字段,只有最后设置的字段仍具有值

    SampleMessage message;
    message.set_name("name");
    CHECK_EQ(message.name(), "name");
    // 调用 mutable_sub_message() 将清除 name 字段并将 sub_message 设置为 SubMessage 的新实例(未设置任何字段)
    message.mutable_sub_message();
    CHECK(message.name().empty());
  • 如果解析器在线上遇到同一 oneof 的多个成员,则仅使用解析消息中看到的最后一个成员。从字节开头开始在线解析数据时,评估下一个值并应用以下解析规则:

    • 首先,检查是否当前设置了同一 oneof 中的不同字段,如果是则清除它
    • 然后应用内容,就像该字段不在 oneof 中一样:

      • 原始类型将覆盖任何已设置的值
      • 消息将合并到任何已设置的值中
  • oneof 不能是 repeated
  • 反射 API 适用于 oneof 字段
  • 如果将 oneof 字段设置为默认值(例如将 int32 oneof 字段设置为 0),将设置该 oneof 字段的"case",并且该值将在线序列化
  • 如果使用 C++,请确保代码不会导致内存崩溃。以下示例代码将崩溃,因为 sub_message 已通过调用 set_name() 方法删除

    SampleMessage message;
    SubMessage* sub_message = message.mutable_sub_message();
    message.set_name("name");      // 将删除 sub_message
    sub_message->set_...            // 在此处崩溃
  • 同样在 C++ 中,如果使用 oneof Swap() 两个消息,每条消息将以另一条消息的 oneof case 结束:在下面的示例中,msg1 将具有 sub_message,而 msg2 将具有 name

    SampleMessage msg1;
    msg1.set_name("name");
    SampleMessage msg2;
    msg2.mutable_sub_message();
    msg1.swap(&msg2);
    CHECK(msg1.has_sub_message());
    CHECK_EQ(msg2.name(), "name");

向后兼容性问题 {#backward}

添加或删除 oneof 字段时要小心。如果检查 oneof 的值返回 None/NOT_SET,可能意味着 oneof 尚未设置,或已设置为不同版本的 oneof 中的字段。无法区分这两种情况,因为无法知道线上的未知字段是否是 oneof 的成员

标签重用问题 {#reuse}

  • 将单数字段移入或移出 oneof:消息序列化和解析后,可能会丢失部分信息(某些字段将被清除)。但是,可以安全地将单个字段移动到 oneof 中,如果已知只有一个字段被设置,则可能可以移动多个字段。有关更多详细信息,请参阅更新消息类型
  • 删除 oneof 字段并重新添加:消息序列化和解析后,可能会清除当前设置的 oneof 字段
  • 拆分或合并 oneof:这与移动单数字段有类似问题

映射 {#maps}

如果要在数据定义中创建关联映射,Protocol Buffers 提供了方便的快捷语法:

map<key_type, value_type> map_field = N;

...其中 key_type 可以是任何整数或字符串类型(因此是除浮点类型和 bytes 外的任何标量类型)。请注意,枚举和 proto 消息对 key_type 均无效。value_type 可以是除另一个映射外的任何类型

因此,例如,如果要创建项目映射,其中每个 Project 消息与字符串键关联,可以这样定义:

map<string, Project> projects = 3;

映射特性 {#maps-features}

  • 映射字段不能是 repeated
  • 映射值的线格式顺序和迭代顺序未定义,因此不能依赖映射项按特定顺序排列
  • .proto 生成文本格式时,映射按键排序。数字键按数字排序
  • 从线解析或合并时,如果存在重复的映射键,则使用最后看到的键。从文本格式解析映射时,如果存在重复键,解析可能失败
  • 如果为映射字段提供键但没有值,则字段序列化时的行为取决于语言。在 C++、Java、Kotlin 和 Python 中,序列化该类型的默认值,而在其他语言中不序列化任何内容
  • 符号 FooEntry 不能与映射 foo 存在于同一作用域中,因为 FooEntry 已由映射实现使用

生成的映射 API 目前可用于所有支持的语言。您可以在相关 API 参考中了解所选语言的映射 API 的更多信息

向后兼容性 {#backwards}

映射语法在线上等同于以下内容,因此不支持映射的 Protocol Buffers 实现仍可以处理您的数据:

message MapFieldEntry {
  key_type key = 1;
  value_type value = 2;
}

repeated MapFieldEntry map_field = N;

支持映射的任何 Protocol Buffers 实现必须生成和接受可由先前定义接受的数据

包 {#packages}

可以向 .proto 文件添加可选的 package 说明符,以防止协议消息类型之间的名称冲突

package foo.bar;
message Open { ... }

然后可以在定义消息类型的字段时使用包说明符:

message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

包说明符影响生成代码的方式取决于所选语言:

  • C++ 中,生成的类包装在 C++ 命名空间中。例如,Open 将在命名空间 foo::bar
  • JavaKotlin 中,包用作 Java 包,除非在 .proto 文件中显式提供 option java_package
  • Python 中,package 指令被忽略,因为 Python 模块根据其在文件系统中的位置组织
  • Go 中,package 指令被忽略,生成的 .pb.go 文件位于与相应 go_proto_library Bazel 规则同名的包中。对于开源项目,您必须提供 go_package 选项或设置 Bazel -M 标志
  • Ruby 中,生成的类包装在嵌套的 Ruby 命名空间中,转换为所需的 Ruby 大写样式(首字母大写;如果第一个字符不是字母,则添加 PB_ 前缀)。例如,Open 将在命名空间 Foo::Bar
  • PHP 中,包在转换为 PascalCase 后用作命名空间,除非在 .proto 文件中显式提供 option php_namespace。例如,Open 将在命名空间 Foo\Bar
  • C# 中,包在转换为 PascalCase 后用作命名空间,除非在 .proto 文件中显式提供 option csharp_namespace。例如,Open 将在命名空间 Foo.Bar

请注意,即使 package 指令不直接影响生成的代码(例如在 Python 中),仍强烈建议为 .proto 文件指定包,否则可能导致描述符中的命名冲突,并使 proto 对其他语言不可移植

包和名称解析 {#name-resolution}

Protocol Buffer 语言中的类型名称解析类似于 C++:首先搜索最内层作用域,然后搜索次内层,依此类推,每个包被视为其父包的"内部"。前导 '.'(例如 .foo.bar.Baz)表示从最外层作用域开始

Protocol Buffer 编译器通过解析导入的 .proto 文件来解析所有类型名称。每种语言的代码生成器知道如何引用该语言中的每种类型,即使其作用域规则不同

定义服务 {#services}

如果要在 RPC(远程过程调用)系统中使用消息类型,可以在 .proto 文件中定义 RPC 服务接口,Protocol Buffer 编译器将生成所选语言的服务接口代码和存根。因此,例如,如果要定义具有一个方法的 RPC 服务(该方法接受 SearchRequest 并返回 SearchResponse),可以在 .proto 文件中定义如下:

service SearchService {
  rpc Search(SearchRequest) returns (SearchResponse);
}

与 Protocol Buffers 一起使用的最直接的 RPC 系统是 gRPC:Google 开发的与语言和平台无关的开源 RPC 系统。gRPC 与 Protocol Buffers 配合得特别好,允许您使用特殊的 Protocol Buffer 编译器插件直接从 .proto 文件生成相关的 RPC 代码

如果不想使用 gRPC,也可以将 Protocol Buffers 与您自己的 RPC 实现一起使用。您可以在 Proto2 语言指南中了解更多信息

还有一些正在进行的第三方项目为 Protocol Buffers 开发 RPC 实现。有关我们已知项目的链接列表,请参阅第三方附加组件 wiki 页面

JSON 映射 {#json}

标准的 protobuf 二进制线格式是使用 protobuf 的两个系统之间通信的首选序列化格式。对于与使用 JSON 而非 protobuf 线格式的系统通信,Protobuf 支持 JSON 中的规范编码

选项 {#options}

.proto 文件中的各个声明可以使用多个选项进行注释。选项不会更改声明的整体含义,但可能会影响其在特定上下文中的处理方式。可用选项的完整列表在 /google/protobuf/descriptor.proto 中定义

有些选项是文件级选项,意味着应写在顶级作用域中(不在任何消息、枚举或服务定义内)。有些选项是消息级选项,意味着应写在消息定义内。有些选项是字段级选项,意味着应写在字段定义内。选项也可以写在枚举类型、枚举值、oneof 字段、服务类型和服务方法上;但是,目前这些没有有用的选项

以下是一些最常用的选项:

  • java_package(文件选项):要用于生成的 Java/Kotlin 类的包。如果在 .proto 文件中未给出显式的 java_package 选项,则默认使用 proto 包(使用 .proto 文件中的"package"关键字指定)。但是,proto 包通常不适合作为 Java 包,因为 proto 包不应以反向域名开头。如果不生成 Java 或 Kotlin 代码,此选项无效

    option java_package = "com.example.foo";
  • java_outer_classname(文件选项):要生成的包装 Java 类的类名(以及文件名)。如果在 .proto 文件中未指定显式的 java_outer_classname,则类名将通过将 .proto 文件名转换为驼峰式大小写来构造(因此 foo_bar.proto 变为 FooBar.java)。如果禁用 java_multiple_files 选项,则为 .proto 文件生成的所有其他类/枚举等将作为嵌套类/枚举等生成此外部包装 Java 类内。如果不生成 Java 代码,此选项无效

    option java_outer_classname = "Ponycopter";
  • java_multiple_files(文件选项):如果为 false,则为此 .proto 文件仅生成一个 .java 文件,并且为顶级消息、服务和枚举生成的所有 Java 类/枚举等将嵌套在外部类内(参见 java_outer_classname)。如果为 true,则为顶级消息、服务和枚举生成的每个 Java 类/枚举等生成单独的 .java 文件,并且为此 .proto 文件生成的包装 Java 类将不包含任何嵌套类/枚举等。这是一个布尔选项,默认为 false。如果不生成 Java 代码,此选项无效

    option java_multiple_files = true;
  • optimize_for(文件选项):可设置为 SPEEDCODE_SIZELITE_RUNTIME。这会影响 C++ 和 Java 代码生成器(以及可能的第三方生成器),如下所示:

    • SPEED(默认):Protocol Buffer 编译器将生成用于序列化、解析和执行消息类型其他常见操作的代码。此代码高度优化
    • CODE_SIZE:Protocol Buffer 编译器将生成最少的类,并依赖基于共享反射的代码来实现序列化、解析和各种其他操作。因此,生成的代码将比 SPEED 小得多,但操作会更慢。类仍将实现与 SPEED 模式中完全相同的公共 API。此模式在包含大量 .proto 文件且不需要所有文件都极快的应用程序中最有用
    • LITE_RUNTIME:Protocol Buffer 编译器将生成仅依赖于"lite"运行时库(libprotobuf-lite 而非 libprotobuf)的类。lite 运行时比完整库小得多(大约小一个数量级),但省略了某些功能(如描述符和反射)。这对于在受限平台(如手机)上运行的应用程序特别有用。编译器仍将生成所有方法的快速实现(与 SPEED 模式一样)。生成的类在每个语言中仅实现 MessageLite 接口,该接口仅提供完整 Message 接口方法的子集
    option optimize_for = CODE_SIZE;
  • cc_generic_servicesjava_generic_servicespy_generic_services(文件选项):通用服务已弃用。 Protocol Buffer 编译器是否应根据 服务定义在 C++、Java 和 Python 中生成抽象服务代码。出于遗留原因,这些默认为 true。但是,自 2.3.0 版(2010 年 1 月)起,RPC 实现提供代码生成器插件为每个系统生成更具体的代码,而不是依赖"抽象"服务,这被认为是更可取的

    // 此文件依赖插件生成服务代码
    option cc_generic_services = false;
    option java_generic_services = false;
    option py_generic_services = false;
  • cc_enable_arenas(文件选项):为 C++ 生成的代码启用竞技场分配
  • objc_class_prefix(文件选项):设置 Objective-C 类前缀,该前缀将添加到此 .proto 生成的所有 Objective-C 类和枚举。无默认值。应使用 3-5 个大写字符作为前缀(如 Apple 推荐)。请注意,所有 2 个字母的前缀由 Apple 保留
  • packed(字段选项):在基本数值类型的重复字段上默认为 true,导致使用更紧凑的编码。要使用非打包线格式,可设置为 false。这提供了与 2.3.0 版之前解析器的兼容性(很少需要),如下例所示:

    repeated int32 samples = 4 [packed = false];
  • deprecated(字段选项):如果设置为 true,表示该字段已弃用,不应被新代码使用。在大多数语言中,这没有实际效果。在 Java 中,这成为 @Deprecated 注解。对于 C++,clang-tidy 将在使用弃用字段时生成警告。将来,其他特定于语言的代码生成器可能会在字段的访问器上生成弃用注解,这将在编译尝试使用该字段的代码时导致发出警告。如果该字段未被任何人使用,并且您希望防止新用户使用它,请考虑用保留语句替换字段声明

    int32 old_field = 6 [deprecated = true];

枚举值选项 {#enum-value-options}

支持枚举值选项。您可以使用 deprecated 选项指示不应再使用某个值。也可以使用扩展创建自定义选项

以下示例显示了添加这些选项的语法:

import "google/protobuf/descriptor.proto";

extend google.protobuf.EnumValueOptions {
  optional string string_name = 123456789;
}

enum Data {
  DATA_UNSPECIFIED = 0;
  DATA_SEARCH = 1 [deprecated = true];
  DATA_DISPLAY = 2 [
    (string_name) = "display_value"
  ];
}

读取 string_name 选项的 C++ 代码可能如下所示:

const absl::string_view foo = proto2::GetEnumDescriptor<Data>()
    ->FindValueByName("DATA_DISPLAY")->options().GetExtension(string_name);

有关如何将自定义选项应用于枚举值和字段,请参阅自定义选项

自定义选项 {#customoptions}

Protocol Buffers 还允许您定义和使用自己的选项。请注意,这是高级功能,大多数人不需要。如果确实认为需要创建自己的选项,请参阅 Proto2 语言指南了解详细信息。请注意,创建自定义选项使用扩展,在 proto3 中仅允许用于自定义选项

选项保留 {#option-retention}

选项具有保留概念,控制选项是否保留在生成的代码中。选项默认具有运行时保留,意味着它们保留在生成的代码中,因此在运行时在生成的描述符池中可见。但是,您可以设置 retention = RETENTION_SOURCE 以指定选项(或选项中的字段)在运行时不得保留。这称为源保留

选项保留是大多数用户无需担心的高级功能,但如果希望使用某些选项而无需支付在二进制文件中保留它们的代码大小成本,它可能很有用。具有源保留的选项对 protocprotoc 插件仍然可见,因此代码生成器可以使用它们来自定义其行为

可以直接在选项上设置保留,如下所示:

extend google.protobuf.FileOptions {
  optional int32 source_retention_option = 1234
      [retention = RETENTION_SOURCE];
}

也可以在普通字段上设置,在这种情况下,仅当该字段出现在选项内时才生效:

message OptionsMessage {
  int32 source_retention_field = 1 [retention = RETENTION_SOURCE];
}

如果需要,可以设置 retention = RETENTION_RUNTIME,但这无效,因为它是默认行为。当消息字段标记为 RETENTION_SOURCE 时,其整个内容将被丢弃;其内部的字段无法通过尝试设置 RETENTION_RUNTIME 来覆盖

{{% alert title="注意" color="note" %}}
截至 Protocol Buffers 22.0,对选项保留的支持仍在进行中,仅 C++ 和 Java 受支持。Go 从 1.29.0 开始支持。Python 支持已完成,但尚未包含在版本中
{{% /alert %}}

选项目标 {#option-targets}

字段具有 targets 选项,控制当用作选项时字段可能适用的实体类型。例如,如果字段具有 targets = TARGET_TYPE_MESSAGE,则该字段不能在枚举(或任何其他非消息实体)上的自定义选项中设置。Protoc 强制执行此操作,如果违反目标约束,将引发错误

乍一看,此功能似乎没有必要,因为每个自定义选项都是特定实体选项消息的扩展,这已经将选项限制在该实体上。但是,当您有应用于多种实体类型的共享选项消息,并且希望控制该消息中各个字段的用法时,选项目标很有用。例如:

message MyOptions {
  string file_only_option = 1 [targets = TARGET_TYPE_FILE];
  int32 message_and_enum_option = 2 [targets = TARGET_TYPE_MESSAGE,
                                     targets = TARGET_TYPE_ENUM];
}

extend google.protobuf.FileOptions {
  optional MyOptions file_options = 50000;
}

extend google.protobuf.MessageOptions {
  optional MyOptions message_options = 50000;
}

extend google.protobuf.EnumOptions {
  optional MyOptions enum_options = 50000;
}

// 正确:此字段允许在文件选项上
option (file_options).file_only_option = "abc";

message MyMessage {
  // 正确:此字段允许在消息和枚举选项上
  option (message_options).message_and_enum_option = 42;
}

enum MyEnum {
  MY_ENUM_UNSPECIFIED = 0;
  // 错误:file_only_option 不能在枚举上设置
  option (enum_options).file_only_option = "xyz";
}

生成类 {#generating}

要生成处理 .proto 文件中定义的消息类型所需的 Java、Kotlin、Python、C++、Go、Ruby、Objective-C 或 C# 代码,需要在 .proto 文件上运行协议缓冲区编译器 protoc。如果尚未安装编译器,请下载安装包并按照 README 中的说明操作。对于 Go,还需要为编译器安装特殊的代码生成器插件;您可以在 GitHub 上的 golang/protobuf 存储库中找到此插件和安装说明

协议编译器调用方式如下:

protoc --proto_path=IMPORT_PATH --cpp_out=DST_DIR --java_out=DST_DIR --python_out=DST_DIR --go_out=DST_DIR --ruby_out=DST_DIR --objc_out=DST_DIR --csharp_out=DST_DIR path/to/file.proto
  • IMPORT_PATH 指定解析 import 指令时查找 .proto 文件的目录。如果省略,则使用当前目录。可以通过多次传递 --proto_path 选项指定多个导入目录。-I=_IMPORT_PATH_ 可用作 --proto_path 的简短形式

注意: 文件路径相对于其 proto_path 在给定二进制文件中必须是全局唯一的。例如,如果有 proto/lib1/data.protoproto/lib2/data.proto,则不能将这两个文件与 -I=proto/lib1 -I=proto/lib2 一起使用,因为 import "data.proto" 的含义将不明确。而应使用 -Iproto/,全局名称将为 lib1/data.protolib2/data.proto

如果发布库且其他用户可能直接使用您的消息,应在路径中包含唯一的库名称(他们期望使用的路径下),以避免文件名冲突。如果一个项目中有多个目录,最佳实践是首选设置一个 -I 到项目的顶级目录

  • 您可以提供一个或多个输出指令

    作为额外便利,如果 DST_DIR.zip.jar 结尾,编译器将输出写入具有给定名称的单个 ZIP 格式存档文件。.jar 输出还将根据需要提供清单文件(如 Java JAR 规范所要求)。请注意,如果输出存档已存在,将被覆盖

  • 必须提供一个或多个 .proto 文件作为输入。可以同时指定多个 .proto 文件。虽然文件相对于当前目录命名,但每个文件必须位于 IMPORT_PATH 之一,以便编译器可以确定其规范名称

文件位置 {#location}

建议不要将 .proto 文件与其他语言源文件放在同一目录中。考虑在项目的根包下为 .proto 文件创建子包 proto

位置应与语言无关 {#location-language-agnostic}

使用 Java 代码时,将相关的 .proto 文件放在 Java 源相同的目录中很方便。但是,如果任何非 Java 代码使用相同的 protos,路径前缀将不再有意义。因此,通常将 protos 放在相关的与语言无关的目录中,例如 //myteam/mypackage

此规则的例外情况是明确 protos 仅在 Java 上下文中使用(例如用于测试)

支持的平台 {#platforms}

有关信息:


VS Code 的 Code Lens 是一项增强代码编辑体验的功能,它会在代码中直接嵌入动态的、可交互的上下文信息(如引用、测试状态、Git 变更等),帮助开发者快速获取关键信息而无需跳转界面。以下是详细说明:


核心功能

  1. 引用显示(References)

    • 在函数、类或变量上方显示被引用的次数(例如:• 12 references),点击后可查看所有引用位置。
    • 作用:快速了解代码被哪些部分依赖。
  2. 测试状态(Test)

    • 如果项目有测试框架(如 Jest、Mocha),Code Lens 会显示测试用例的运行状态(例如:▶ Run Test | ✓ Passed)。
    • 作用:直接运行或调试测试,无需切换文件。
  3. Git 历史(GitLens 扩展)

    • 通过安装 GitLens 扩展,Code Lens 会显示代码行的最近修改作者、时间和提交信息。
    • 作用:追踪代码变更历史,辅助协作。
  4. 实现与继承(Implementations)

    • 对于接口或抽象类,显示实现该接口的子类数量(例如:• 3 implementations)。

如何启用/关闭

  • 默认状态:Code Lens 默认开启,但部分功能(如 Git 历史)需安装扩展。
  • 手动配置
    在设置中搜索 editor.codeLens

    • "editor.codeLens": true/false(全局开关)。
    • 针对语言单独配置(如 "[python]": { "editor.codeLens": false })。

优点

  • 减少上下文切换:直接查看关键信息,无需跳转到其他面板。
  • 提升效率:快速运行测试、查看引用或 Git 记录。
  • 可定制性:通过扩展增强功能(如 GitLens、Test Adapters)。

相关拓展

  • 插件市场搜索 Lens

2025-06-08T13:26:07.png

Error Lens

2025-06-08T13:26:40.png

配置说明

Version Lens

2025-06-08T13:31:14.png

Hover Lens

2025-06-08T13:30:12.png


转载自 https://github.com/tower-rs/tower/blob/master/guides/building-a-middleware-from-scratch.md

《发明Service trait》一文中,我们深入探讨了Service的设计动机及其架构原理。虽然我们也动手实现过几个简易中间件,但当时采取了一些取巧方案。本指南将完整构建Tower现有的Timeout中间件,全程不采用任何捷径。

编写健壮的中间件需要运用比常规更底层的异步Rust技术。本指南旨在揭开这些核心概念与模式的神秘面纱,助你掌握中间件开发技能,甚至为Tower生态贡献代码!

准备工作

我们将构建的中间件是tower::timeout::Timeout。该组件会限定内部Service响应future的最大执行时长。若未能在指定时间内生成响应,则返回错误。这使得客户端可以重试请求或向用户报错,而非无限等待。

首先定义包含被包装服务和超时时长的Timeout结构体:

use std::time::Duration;

struct Timeout<S> {
    inner: S,
    timeout: Duration,
}

根据《发明Service trait》的指导,服务实现Clone trait至关重要——这允许将Service::call接收的&mut self转换为可移入响应future的独立所有权。因此我们为结构体添加#[derive(Clone)],同时一并实现Debug

#[derive(Debug, Clone)]
struct Timeout<S> {
    inner: S,
    timeout: Duration,
}

接着实现构造函数:

impl<S> Timeout<S> {
    pub fn new(inner: S, timeout: Duration) -> Self {
        Timeout { inner, timeout }
    }
}

注意我们遵循Rust API指南的建议,即便预期S会实现Service trait,此处也未添加任何约束。

现在进入关键环节:如何为Timeout<S>实现Service?先实现一个简单透传版本:

use tower::Service;
use std::task::{Context, Poll};

impl<S, Request> Service<Request> for Timeout<S>
where
    S: Service<Request>,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = S::Future;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        // 中间件不关注背压,只要内部服务就绪即可
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, request: Request) -> Self::Future {
        self.inner.call(request)
    }
}

在熟练编写中间件前,先搭建这样的代码骨架能显著降低实现难度。

要实现真正的超时控制,核心在于检测self.inner.call(request)返回的future执行是否超过self.timeout,若超时则终止并返回错误。

我们将采用以下方案:调用tokio::time::sleep获取超时future,然后通过select等待最先完成的future。虽然也可使用tokio::time::timeout,但sleep同样适用。

创建两个future的代码如下:

use tokio::time::sleep;

fn call(&mut self, request: Request) -> Self::Future {
    let response_future = self.inner.call(request);

    // 此变量类型为`tokio::time::Sleep`
    // 由于`self.timeout`实现`Copy` trait,无需显式克隆
    let sleep = tokio::time::sleep(self.timeout);

    // 此处应返回什么?
}

一种可能的返回类型是Pin<Box<dyn Future<...>>>。但为最小化Timeout的开销,我们希望能避免Box分配。设想一个包含数十层嵌套Service的调用栈,若每层都为请求分配新Box,将产生大量内存分配,进而影响性能1

响应future实现

为避免Box分配,我们选择自定义Future实现。首先创建名为ResponseFuture的结构体,需泛型化内部服务的响应future类型。这类似于用服务包装其他服务,但此处是用future包装其他future。

use tokio::time::Sleep;

pub struct ResponseFuture<F> {
    response_future: F,
    sleep: Sleep,
}

其中F对应self.inner.call(request)的类型。更新Service实现:

impl<S, Request> Service<Request> for Timeout<S>
where
    S: Service<Request>,
{
    type Response = S::Response;
    type Error = S::Error;

    // 使用新的`ResponseFuture`类型
    type Future = ResponseFuture<S::Future>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, request: Request) -> Self::Future {
        let response_future = self.inner.call(request);
        let sleep = tokio::time::sleep(self.timeout);

        // 通过包装内部服务的future创建响应future
        ResponseFuture {
            response_future,
            sleep,
        }
    }
}

这里的关键在于Rust future具有_惰性_特性,即除非被await或poll,否则不会执行任何操作。因此self.inner.call(request)会立即返回而不会实际处理请求。

接下来为ResponseFuture实现Future

use std::{pin::Pin, future::Future};

impl<F, Response, Error> Future for ResponseFuture<F>
where
    F: Future<Output = Result<Response, Error>>,
{
    type Output = Result<Response, Error>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // 此处如何实现?
    }
}

理想情况下我们期望实现以下逻辑:

  1. 首先poll self.response_future,若就绪则返回其响应或错误
  2. 否则poll self.sleep,若就绪则返回超时错误
  3. 若两者均未就绪则返回Poll::Pending

初步尝试可能如下:

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
    match self.response_future.poll(cx) {
        Poll::Ready(result) => return Poll::Ready(result),
        Poll::Pending => {}
    }

    todo!()
}

但这会导致如下错误:

error[E0599]: 在当前作用域中未找到类型参数`F`的`poll`方法
  --> src/lib.rs:56:29
   |
56 |         match self.response_future.poll(cx) {
   |                             ^^^^ 方法未找到
   |
   = 帮助: 只有类型参数受trait约束时才能使用trait中的项
帮助: 以下trait定义了`poll`项,可能需要为类型参数`F`添加约束:
   |
49 | impl<F: Future, Response, Error> Future for ResponseFuture<F>
   |      ^^^^^^^^^

虽然Rust的错误提示建议添加F: Future约束,但实际上我们已通过where F: Future<Output = Result<Response, E>>实现约束。真正的问题与[Pin]相关。

关于固定(Pinning)的完整讨论超出本指南范围。若对Pin不熟悉,推荐阅读Jon Gjengset的《Rust中Pinning的为什么、是什么和怎么做》

Rust试图告诉我们的是:需要Pin<&mut F>才能调用poll。当selfPin<&mut Self>时,通过self.response_future访问F无法正常工作。

我们需要"pin投影"——即从Pin<&mut Struct>转换到Pin<&mut Field>。通常这需要编写unsafe代码,但优秀的[pin-project] crate能安全处理这些底层细节。

使用pin-project时,我们用#[pin_project]标注结构体,并为需要固定访问的字段添加#[pin]

use pin_project::pin_project;

#[pin_project]
pub struct ResponseFuture<F> {
    #[pin]
    response_future: F,
    #[pin]
    sleep: Sleep,
}

impl<F, Response, Error> Future for ResponseFuture<F>
where
    F: Future<Output = Result<Response, Error>>,
{
    type Output = Result<Response, Error>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // 调用`#[pin_project]`生成的`project`魔法方法
        let this = self.project();

        // `project`返回`__ResponseFutureProjection`类型(可忽略具体类型)
        // 其字段与`ResponseFuture`匹配,并为标注`#[pin]`的字段维护pin

        // `this.response_future`现在是`Pin<&mut F>`
        let response_future: Pin<&mut F> = this.response_future;

        // `this.sleep`是`Pin<&mut Sleep>`
        let sleep: Pin<&mut Sleep> = this.sleep;

        // 若有未标注`#[pin]`的字段,则获得普通`&mut`引用(无`Pin`)

        // ...
    }
}

Rust中的固定机制虽然复杂难懂,但借助pin-project我们可以规避大部分复杂性。关键在于,即使不完全理解固定机制,也能编写Tower中间件!所以如果你对PinUnpin还存有疑惑,请放心使用pin-project!

注意在前述代码中,我们获得了Pin<&mut F>Pin<&mut Sleep>,这正是调用poll所需的:

impl<F, Response, Error> Future for ResponseFuture<F>
where
    F: Future<Output = Result<Response, Error>>,
{
    type Output = Result<Response, Error>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();

        // 首先检查响应future是否就绪
        match this.response_future.poll(cx) {
            Poll::Ready(result) => {
                // 内部服务已准备好响应或失败
                return Poll::Ready(result);
            }
            Poll::Pending => {
                // 尚未就绪...
            }
        }

        // 然后检查sleep是否就绪。若就绪则表示响应超时
        match this.sleep.poll(cx) {
            Poll::Ready(()) => {
                // 超时触发,但应返回什么错误?!
                todo!()
            }
            Poll::Pending => {
                // 仍有剩余时间...
            }
        }

        // 若两者均未就绪,则返回Pending
        Poll::Pending
    }
}

现在唯一剩下的问题是:当sleep先完成时,应该返回什么错误?

错误类型设计

当前我们承诺返回的泛型Error类型与内部服务的错误类型相同。但对该类型我们一无所知——它完全不透明且无法构造其值。

我们有三条路径可选:

  1. 返回装箱的错误特征对象,如Box<dyn std::error::Error + Send + Sync>
  2. 返回包含服务错误和超时错误的枚举类型
  3. 定义TimeoutError结构体,并要求泛型错误类型可通过TimeoutError: Into<Error>构造

虽然选项3看似最灵活,但要求使用自定义错误类型的用户手动实现From<TimeoutError> for MyError。当使用多个自带错误类型的中间件时,这种操作会变得繁琐。

选项2需要定义如下枚举:

enum TimeoutError<Error> {
    // 超时触发的变体
    Timeout(InnerTimeoutError),
    // 内部服务产生错误的变体
    Service(Error),
}

虽然表面上看能保留完整类型信息且可通过match精确处理错误,但存在三个问题:

  1. 实践中常会嵌套大量中间件,导致最终错误枚举异常庞大。类似BufferError<RateLimitError<TimeoutError<MyError>>>的类型很常见,对此类类型进行模式匹配(例如判断错误是否可重试)将非常繁琐
  2. 调整中间件顺序会改变最终错误类型,需要同步更新模式匹配
  3. 最终错误类型可能占用大量栈空间

因此我们选择选项1:将内部服务错误转换为装箱特征对象Box<dyn std::error::Error + Send + Sync>。这样可将多种错误类型统一处理,具有以下优势:

  1. 错误处理更健壮,调整中间件顺序不会改变最终错误类型
  2. 错误类型具有固定大小,不受中间件数量影响
  3. 提取错误时无需大型match,可使用error.downcast_ref::<Timeout>()

但也存在以下缺点:

  1. 使用动态转换后,编译器无法保证检查所有可能的错误类型
  2. 创建错误需要进行内存分配。实践中错误应属罕见,故影响有限

选择哪种方案取决于个人偏好。Tower最终采用的是装箱特征对象方案,原始讨论参见这里

对于Timeout中间件,我们需要创建实现std::error::Error的结构体,以便转换为Box<dyn std::error::Error + Send + Sync>。同时要求内部服务的错误类型实现Into<Box<dyn std::error::Error + Send + Sync>>。幸运的是大多数错误类型自动满足该条件,用户无需编写额外代码。根据标准库建议,我们使用Into而非From作为trait约束。

错误类型实现如下:

use std::fmt;

#[derive(Debug, Default)]
pub struct TimeoutError(());

impl fmt::Display for TimeoutError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.pad("request timed out")
    }
}

impl std::error::Error for TimeoutError {}

我们向 TimeoutError 添加了一个私有字段,这样Tower外部的用户就无法构造自己的TimeoutError 。他们只能通过我们的中间件获取。

Box<dyn std::error::Error + Send + Sync> 这个表达确实有些冗长,因此我们为它定义一个类型别名:

// 该类型在`tower`中定义为`tower::BoxError`
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;

现在future实现更新为:

impl<F, Response, Error> Future for ResponseFuture<F>
where
    F: Future<Output = Result<Response, Error>>,
    // 要求内部服务错误可转换为`BoxError`
    Error: Into<BoxError>,
{
    type Output = Result<
        Response,
        // `ResponseFuture`的错误类型现在是`BoxError`
        BoxError,
    >;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();

        match this.response_future.poll(cx) {
            Poll::Ready(result) => {
                // 使用`map_err`转换错误类型
                let result = result.map_err(Into::into);
                return Poll::Ready(result);
            }
            Poll::Pending => {}
        }

        match this.sleep.poll(cx) {
            Poll::Ready(()) => {
                // 构造并返回超时错误
                let error = Box::new(TimeoutError(()));
                return Poll::Ready(Err(error));
            }
            Poll::Pending => {}
        }

        Poll::Pending
    }
}

最后需要更新Service实现,同样使用BoxError

impl<S, Request> Service<Request> for Timeout<S>
where
    S: Service<Request>,
    // 与`ResponseFuture`的future实现相同约束
    S::Error: Into<BoxError>,
{
    type Response = S::Response;
    // `Timeout`的错误类型现在是`BoxError`
    type Error = BoxError;
    type Future = ResponseFuture<S::Future>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        // 此处也需要转换错误类型
        self.inner.poll_ready(cx).map_err(Into::into)
    }

    fn call(&mut self, request: Request) -> Self::Future {
        let response_future = self.inner.call(request);
        let sleep = tokio::time::sleep(self.timeout);

        ResponseFuture {
            response_future,
            sleep,
        }
    }
}

最终成果

至此我们已成功实现了与Tower现有版本完全一致的Timeout中间件!

完整实现如下:

use pin_project::pin_project;
use std::time::Duration;
use std::{
    fmt,
    future::Future,
    pin::Pin,
    task::{Context, Poll},
};
use tokio::time::Sleep;
use tower::Service;

#[derive(Debug, Clone)]
struct Timeout<S> {
    inner: S,
    timeout: Duration,
}

impl<S> Timeout<S> {
    fn new(inner: S, timeout: Duration) -> Self {
        Timeout { inner, timeout }
    }
}

impl<S, Request> Service<Request> for Timeout<S>
where
    S: Service<Request>,
    S::Error: Into<BoxError>,
{
    type Response = S::Response;
    type Error = BoxError;
    type Future = ResponseFuture<S::Future>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx).map_err(Into::into)
    }

    fn call(&mut self, request: Request) -> Self::Future {
        let response_future = self.inner.call(request);
        let sleep = tokio::time::sleep(self.timeout);

        ResponseFuture {
            response_future,
            sleep,
        }
    }
}

#[pin_project]
struct ResponseFuture<F> {
    #[pin]
    response_future: F,
    #[pin]
    sleep: Sleep,
}

impl<F, Response, Error> Future for ResponseFuture<F>
where
    F: Future<Output = Result<Response, Error>>,
    Error: Into<BoxError>,
{
    type Output = Result<Response, BoxError>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();

        match this.response_future.poll(cx) {
            Poll::Ready(result) => {
                let result = result.map_err(Into::into);
                return Poll::Ready(result);
            }
            Poll::Pending => {}
        }

        match this.sleep.poll(cx) {
            Poll::Ready(()) => {
                let error = Box::new(TimeoutError(()));
                return Poll::Ready(Err(error));
            }
            Poll::Pending => {}
        }

        Poll::Pending
    }
}

#[derive(Debug, Default)]
struct TimeoutError(());

impl fmt::Display for TimeoutError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.pad("request timed out")
    }
}

impl std::error::Error for TimeoutError {}

type BoxError = Box<dyn std::error::Error + Send + Sync>;

Tower中的完整实现参见这里

这种为包装其他Service的类型实现Service trait,并返回包装其他FutureFuture的模式,是大多数Tower中间件的工作方式。

其他典型示例包括:

掌握这些知识后,你应该已具备编写生产级中间件的能力。以下练习可供实践:

  • 使用tokio::time::timeout重新实现超时中间件
  • 实现类似Result::mapResult::map_errService适配器,通过用户提供的闭包转换请求、响应或错误
  • 实现ConcurrencyLimit(提示:需要PollSemaphore来实现poll_ready

如有疑问,欢迎加入Tokio Discord服务器#tower频道交流。


  1. Rust编译器团队计划增加"类型别名中的impl Trait"功能,将允许从call返回impl Future,但目前尚未实现。

文章转载并翻译自:https://heikoseeberger.de/2022/10/20/2022-10-21-tower-1/

第一讲:将服务视为函数

作者:Heiko Seeberger 2022年10月21日 标签:Rust, Tokio, Tower

当我第一次接触Tower库(Tokio技术栈的一部分)时,注意到它的标题行写着:

async fn(Request) -> Result<Response, Error>

文档中对此解释如下:

Tower提供了一个简单的核心抽象——Service特性,它表示一个异步函数,接收请求并返回响应或错误。这个抽象可用于建模客户端和服务器。

这立即让我这个经验丰富的Scala开发者想起了Finagle论文《Your Server as a Function》,其中定义"系统边界由称为服务的异步函数表示"。看到新项目利用已有知识总是件好事。

虽然Tower和Finagle服务共享相同的核心抽象——异步函数,但有两点不同。

首先,Tower不一定将服务解释为系统边界。相反,服务也可以"本地"组合,这更符合Finagle的"过滤器":

通用组件(如超时、速率限制和负载平衡)可以建模为包装某些内部服务并在调用内部服务前后应用附加行为的服务。这允许以协议无关、可组合的方式实现这些组件。通常,此类服务被称为中间件。

其次,Tower通过就绪概念丰富了异步函数的核心抽象,正如我们从Service特性的定义中看到的:

pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
    fn call(&mut self, req: Request) -> Self::Future;
}

其核心在于这些服务方法的调用契约如下:

  1. 必须先调用poll_ready(可能需要多次)直到返回Poll::Ready
  2. 然后才能调用call
  3. 如果不遵守上述规则,call可能会panic

在下一讲深入探讨调用服务的细节之前,我们先看一个简单的服务示例:

pub struct EchoRequest(String);
pub struct EchoResponse(String);
pub struct EchoService;

/// 为[EchoService]实现Tower的`Service`特性:
/// 总是就绪,永不失败,立即返回回显响应(即内容与请求相同的响应)
impl Service<EchoRequest> for EchoService {
    type Response = EchoResponse;
    type Error = Infallible; // 此服务永不失败
    type Future = Ready<Result<Self::Response, Self::Error>>; // 立即响应

    /// 总是返回`Poll::Ready`:此服务总是就绪
    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }

    /// 总是返回回显,即内容与请求相同的响应
    fn call(&mut self, req: EchoRequest) -> Self::Future {
        ready(Ok(EchoResponse(req.0)))
    }
}

由于此服务总是就绪的,因此如果之前没有调用poll_readycall也不需要panic。

完整代码可在GitHub的tower-experiments仓库找到。下一讲我们将探讨服务客户端。

第二讲:调用Tower服务

作者:Heiko Seeberger 2022年10月23日 标签:Rust, Tokio, Tower

在上一讲中我们了解到,Tower服务由poll_readycall两个方法组成,它们遵循这样的契约:调用者必须先持续调用poll_ready直到返回Poll::Ready,然后才能调用call,否则call可能会panic。

poll_ready的签名如下:

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;

要调用poll_ready,我们需要一个std::task::Context。如果你熟悉Future,会发现它的poll方法也有这样的参数。那么我们是否必须实现一个Future才能调用服务呢?

幸运的是不必,因为Tower已经提供了ServiceExt特性,其中包含一些辅助方法,可以按照契约要求调用poll_ready,甚至链式调用poll_readycall

首先我们来看ServiceExt::oneshot。根据文档说明,它会"消费此服务,在就绪时使用提供的请求调用一次":

let mut service = EchoService;
let response = service.oneshot("Hello, Tower!".into()).await?;
println!("Echo service responded with: {response}");

oneshot返回的Future在被轮询时首先调用poll_ready,当服务返回Poll::Ready时再调用call。当然,由于oneshot通过self消费了服务,所以不可能再次调用该服务。但这实际上是Tower提供的唯一能强制执行poll_readycall之间契约的方式。

接下来看看ServiceExt::ready,根据文档说明,它"在服务准备好接受请求时返回一个可变引用":

let mut service = EchoService;

let service = service.ready().await?;
let response = service.call("Hello, Tower!".into()).await?;
println!("Echo service responded with: {response}");

// 再次调用call前应该先调用ready!
// service.ready().await?;
let response = service.call("Hello again, Tower!".into()).await?;
println!("Echo service responded with: {response}");

ready返回的Future会返回服务的可变引用,然后可以用来调用它。如你所见,我们甚至可以不检查就绪状态就多次调用服务。当然这违反了契约,我们应该在每次调用call之前再次调用ready,即在上面的代码片段中应该取消第8行的注释。

最后还有ServiceExt::ready_oneshot。它像ready一样工作,但会消费服务,然后又将其返回:

let service = EchoService;

let mut service = service.ready_oneshot().await?;
let response = service.call("Hello, Tower!".into()).await?;
println!("Echo service responded with: {response}");

// 再次调用call前应该先调用ready_oneshot!
// let mut service = service.ready_oneshot().await?;
let response = service.call("Hello again, Tower!".into()).await?;
println!("Echo service responded with: {response}");

ready类似,我们可以不检查就绪状态就多次调用返回的服务。因此我认为这个方法命名不当,因为"oneshot"对我来说意味着最多只能调用一次call

现在你可能会问,这种总是先调用poll_ready再调用call的服务契约是否真的那么重要。对于无害的EchoService来说确实不重要,因为它总是就绪的,所以它的call方法永远不会panic。我们将在下一讲中看看其他表现不那么好的服务。完整代码照例可在GitHub的tower-experiments仓库找到。

第三讲:就绪状态

作者:Heiko Seeberger 2022年11月8日 标签:Rust, Tokio, Tower

在上一讲中,我们学习了如何调用Tower服务。特别讨论了poll_readycall之间的契约关系:调用者必须首先持续调用poll_ready直到返回Poll::Ready,才能调用call方法,否则call可能会触发panic。

当然,某些服务(例如第一讲中介绍的EchoService)始终处于就绪状态,因此无需调用poll_ready直接调用call也能正常工作。

但通常情况下,服务的内部状态或外部依赖(例如数据库连接)会影响其就绪状态。此外,将领域服务与中间件(如限流器或熔断器)组合时,自然也会影响就绪状态。

为了演示这一点,我们来看一个在"就绪"与"未就绪"状态间交替切换的服务:

/// 交替就绪服务,接收[AlternatingReadyRequest]并返回[AlternatingReadyResponse]
#[derive(Debug, Clone)]
pub struct AlternatingReadyService {
    ready: bool,
}

/// 为[AlternatingReadyService]实现Tower的`Service`特性:
/// 就绪状态在Pending和Ready间交替切换,调用永不失败且响应立即就绪
impl Service<AlternatingReadyRequest> for AlternatingReadyService {
    type Response = AlternatingReadyResponse;
    type Error = Infallible; // 该服务永不失败
    type Future = Ready<Result<Self::Response, Self::Error>>; // 响应立即就绪

    /// 交替返回`Poll::Pending`和`Poll::Ready`
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        if self.ready {
            // 唤醒任务确保执行器再次调用poll_ready
            cx.waker().wake_by_ref();
            self.ready = false;
            Poll::Pending
        } else {
            self.ready = true;
            Poll::Ready(Ok(()))
        }
    }

    /// 始终返回[AlternatingReadyResponse]
    /// 
    /// # Panics
    /// 若未先调用poll_ready就直接调用本方法会panic
    fn call(&mut self, _req: AlternatingReadyRequest) -> Self::Future {
        if !self.ready {
            panic!("服务未就绪,必须先调用poll_ready");
        }
        self.ready = false;
        ready(Ok(AlternatingReadyResponse))
    }
}

AlternatingReadyService通过ready字段跟踪就绪状态。poll_ready的实现逻辑是:若服务之前处于就绪状态,则将其设为未就绪并返回Poll::Pending;反之则设为就绪并返回Poll::Ready。同时,call方法会检查ready字段,若服务未就绪时调用将触发panic。

虽然这个实现是刻意设计的,但它很好地演示了poll_readycall之间契约的重要性:

let mut service = AlternatingReadyService::new();

let service = service.ready().await?;
let _response = service.call(AlternatingReadyRequest).await?;

// 再次调用call前应该先调用ready!
// service.ready().await?;
let _response = service.call(AlternatingReadyRequest).await?;

在这个例子中,第二次call调用会因为服务处于未就绪状态而panic(由于第一次ready调用后服务状态变为未就绪)。如果你想亲自验证,可以在GitHub的tower-experiments仓库查看完整代码。