语言指南 (editions)

介绍如何在项目中使用 Protocol Buffers 语言的版本修订。

本指南介绍了如何使用协议缓冲区语言来构建协议缓冲区数据,包括 .proto 文件语法以及如何从 .proto 文件生成数据访问类。它涵盖了协议缓冲区语言的 2023 版2024 版。有关版本与 proto2 和 proto3 在概念上如何不同,请参阅 Protobuf 版本概述

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

有关 proto3 语法的信息,请参阅 Proto3 语言指南

这是一份参考指南——若想通过一个分步示例来了解本文档中描述的许多功能,请参阅你所选语言的教程

定义消息类型

首先,我们来看一个非常简单的例子。假设您想定义一个搜索请求消息格式,其中每个搜索请求都有一个查询字符串、您感兴趣的特定结果页面以及每页的结果数量。这是您用来定义消息类型的 .proto 文件。

edition = "2023";

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

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

指定字段类型

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

分配字段编号

你必须为消息定义中的每个字段赋予一个介于 1536,870,911 之间的编号,并遵守以下限制:

  • 给定的编号必须在该消息的所有字段中是唯一的
  • 字段编号 19,00019,999 为 Protocol Buffers 实现保留。如果你在消息中使用这些保留的字段编号,protocol buffer 编译器会报错。
  • 您不能使用任何之前保留的字段编号或已分配给扩展的任何字段编号。

一旦您的消息类型投入使用,这个编号就不能更改,因为它在消息的有线格式中标识该字段。“更改”字段编号等同于删除该字段并创建一个具有相同类型但编号不同的新字段。有关如何正确执行此操作,请参阅删除字段

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

您应该为最常设置的字段使用 1 到 15 的字段编号。较小的字段编号值在有线格式中占用更少的空间。例如,范围在 1 到 15 之间的字段编号需要一个字节进行编码。范围在 16 到 2047 之间的字段编号需要两个字节。您可以在Protocol Buffer 编码中找到更多相关信息。

重用字段编号的后果

重用字段编号会使解码线路格式的消息变得模棱两可。

protobuf 线路格式是精简的,没有提供检测使用一种定义编码的字段并用另一种定义解码的方法。

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

  • 开发者浪费时间调试
  • 解析/合并错误(最好的情况)
  • 个人身份信息/敏感个人身份信息泄露
  • 数据损坏

字段编号重用的常见原因

  • 重编号字段(有时为了实现字段编号顺序更美观)。重编号实际上是删除并重新添加所有涉及的字段,导致不兼容的线路格式变更。

  • 删除一个字段并且没有保留其编号以防止未来重用。

    • 由于多种原因,这对于扩展字段来说是一个非常容易犯的错误。扩展声明提供了一种保留扩展字段的机制。

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

指定字段基数

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

  • 单一 (Singular):

    一个 singular 字段没有显式基数标签。它有两种可能的状态

    • 该字段已设置,并包含一个被显式设置或从线路中解析出的值。它将被序列化到线路中。
    • 该字段未设置,并将返回默认值。它将不会被序列化到线路中。

    你可以检查该值是否被显式设置。

    已迁移到版本的 Proto3 隐式字段将使用设置为 IMPLICIT 值的 field_presence 功能集。

    已迁移到版本的 Proto2 required 字段也将使用 field_presence 功能,但设置为 LEGACY_REQUIRED

  • repeated:此字段类型在一个格式良好的消息中可以重复零次或多次。重复值的顺序将被保留。

  • map:这是一个键值对字段类型。有关此字段类型的更多信息,请参阅映射

重复字段默认打包

在 proto 版本中,标量数字类型的 repeated 字段默认使用 packed 编码。

你可以在 Protocol Buffer 编码中找到更多关于 packed 编码的信息。

格式良好的消息

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

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

添加更多消息类型

可以在单个 .proto 文件中定义多个消息类型。如果您正在定义多个相关的消息,这很有用——例如,如果您想定义与您的 SearchResponse 消息类型相对应的回复消息格式,您可以将其添加到同一个 .proto 文件中:

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

message SearchResponse {
 ...
}

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

添加注释

要为你的 .proto 文件添加注释:

  • 优先使用 C/C++/Java 风格的行尾注释 '//',放在 .proto 代码元素的前一行。

  • 也接受 C 风格的内联/多行注释 /* ... */

    • 使用多行注释时,推荐使用 '*' 作为边距行。
/**
 * SearchRequest represents a search query, with pagination options to
 * indicate which results to include in the response.
 */
message SearchRequest {
  string query = 1;

  // Which page number do we want?
  int32 page_number = 2;

  // Number of results to return per page.
  int32 results_per_page = 3;
}

删除字段

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

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

你也应该保留字段名称,以允许你的消息的 JSON 和 TextFormat 编码继续解析。

保留字段编号

如果您通过完全删除字段或将其注释掉来更新消息类型,未来的开发人员在对该类型进行自己的更新时可以重用该字段编号。这可能会导致严重问题,如重用字段编号的后果中所述。为确保这种情况不会发生,请将您删除的字段编号添加到 reserved 列表中。

如果未来的开发者试图使用这些保留的字段编号,protoc 编译器将生成错误消息。

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

保留的字段编号范围是包含性的(9 to 119, 10, 11 相同)。

保留字段名

之后重用旧字段名通常是安全的,除非在使用 TextProto 或 JSON 编码时,字段名会被序列化。为避免这种风险,你可以将被删除的字段名添加到 reserved 列表中。

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

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

请注意,你不能在同一个 reserved 语句中混合使用字段名和字段编号。

从你的 .proto 文件生成了什么?

当您对 .proto 文件运行protocol buffer 编译器时,编译器会以您选择的语言生成您需要的代码,用于处理您在文件中描述的消息类型,包括获取和设置字段值、将消息序列化到输出流以及从输入流解析消息。

  • 对于 C++,编译器会从每个 .proto 文件生成一个 .h 和一个 .cc 文件,其中为你文件中描述的每种消息类型都提供一个类。
  • 对于 Java,编译器会为每种消息类型生成一个包含一个类的 .java 文件,以及一个用于创建消息类实例的特殊 Builder 类。
  • 对于 Kotlin,除了 Java 生成的代码外,编译器还为每种消息类型生成一个 .kt 文件,其中包含一个改进的 Kotlin API。这包括一个简化消息实例创建的 DSL、一个可空字段访问器和一个复制函数。
  • Python 有点不同——Python 编译器会生成一个模块,其中包含您 .proto 文件中每种消息类型的静态描述符,然后该描述符与一个*元类 (metaclass)* 一起使用,以在运行时创建必要的 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 参考

标量值类型

一个标量消息字段可以有以下类型之一——该表显示了在 .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 类型
doubledoubledoublefloatfloat64Floatdoublefloatdoublef64
floatfloatfloatfloatfloat32Floatfloatfloatdoublef32
int32int32_tintintint32Fixnum 或 Bignum (根据需要)intintegerinti32
int64int64_tlongint/long[4]int64Bignumlonginteger/string[6]Int64i64
uint32uint32_tint[2]int/long[4]uint32Fixnum 或 Bignum (根据需要)uintintegerintu32
uint64uint64_tlong[2]int/long[4]uint64Bignumulonginteger/string[6]Int64u64
sint32int32_tintintint32Fixnum 或 Bignum (根据需要)intintegerinti32
sint64int64_tlongint/long[4]int64Bignumlonginteger/string[6]Int64i64
fixed32uint32_tint[2]int/long[4]uint32Fixnum 或 Bignum (根据需要)uintintegerintu32
fixed64uint64_tlong[2]int/long[4]uint64Bignumulonginteger/string[6]Int64u64
sfixed32int32_tintintint32Fixnum 或 Bignum (根据需要)intintegerinti32
sfixed64int64_tlongint/long[4]int64Bignumlonginteger/string[6]Int64i64
boolboolbooleanboolboolTrueClass/FalseClassboolbooleanboolbool
stringstringStringstr/unicode[5]stringString (UTF-8)stringstringStringProtoString
bytesstringByteStringstr (Python 2), bytes (Python 3)[]byteString (ASCII-8BIT)ByteStringstringListProtoBytes

[1] 为了确保在混合的 Java/Kotlin 代码库中的兼容性,Kotlin 即使对于无符号类型也使用 Java 对应的类型。

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

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

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

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

[6] 在 64 位机器上使用 Integer,在 32 位机器上使用 string。

你可以在 Protocol Buffer 编码中了解更多关于这些类型在序列化消息时的编码方式。

字段默认值

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

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

对于 repeated 字段,默认值是空的(通常是相应语言中的空列表)。

对于 map 字段,默认值是空的(通常是相应语言中的空 map)。

覆盖默认标量值

在 protobuf 版本中,您可以为奇异的非消息字段指定显式默认值。例如,假设您希望为 SearchRequest.result_per_page 字段提供默认值 10

int32 result_per_page = 3 [default = 10];

如果发送方未指定 result_per_page,接收方将观察到以下状态

  • result_per_page 字段不存在。也就是说,has_result_per_page()(存在器方法)方法将返回 false
  • result_per_page 的值(从“getter”返回)为 10

如果发送方确实发送了 result_per_page 的值,则忽略默认值 10,并从“getter”返回发送方的值。

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

对于 field_presence 功能设置为 IMPLICIT 的字段,不能指定显式默认值。

枚举

当您定义消息类型时,您可能希望它的某个字段只能是预定义列表中的值之一。例如,假设您想为每个 SearchRequest 添加一个 corpus 字段,其中 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;
}

枚举默认值

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

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

  • 零值需要作为第一个元素,以与 proto2 语义兼容,在 proto2 中,除非显式指定不同的值,否则第一个枚举值是默认值。
  • 必须有一个零值,以与 proto3 语义兼容,在 proto3 中,零值用作使用此枚举类型的所有隐式存在字段的默认值。

还建议这个第一个默认值除了“此值未指定”外,不具有任何语义含义。

SearchRequest.corpus 字段这样的枚举字段的默认值可以像这样显式覆盖

  Corpus corpus = 4 [default = CORPUS_UNIVERSAL];

如果一个枚举类型已使用 option features.enum_type = CLOSED; 从 proto2 迁移,则枚举中第一个值没有限制。不建议更改此类枚举的第一个值,因为它会更改使用该枚举类型但没有显式字段默认值的任何字段的默认值。

枚举值别名

您可以通过为不同的枚举常量赋予相同的值来定义别名。为此,您需要将 `allow_alias` 选项设置为 `true`。否则,当发现别名时,protocol buffer 编译器会生成一条警告消息。虽然所有别名值在序列化时都有效,但在反序列化时只使用第一个值。

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;  // Uncommenting this line will cause a warning message.
  ENAA_FINISHED = 2;
}

枚举常量

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

特定语言的枚举实现

当您对使用 `enum` 的 `.proto` 文件运行 protocol buffer 编译器时,生成的代码将为 Java、Kotlin 或 C++ 提供相应的 `enum`,或者为 Python 提供一个特殊的 `EnumDescriptor` 类,用于在运行时生成的类中创建一组带有整数值的符号常量。

在反序列化过程中,无法识别的枚举值将保留在消息中,尽管在反序列化消息时如何表示取决于语言。在支持范围外值的开放枚举类型(例如 C++ 和 Go)的语言中,未知枚举值简单地存储为其底层整数表示。在具有封闭枚举类型(例如 Java)的语言中,枚举中的一个 case 用于表示无法识别的值,并且底层整数可以通过特殊的访问器访问。在这两种情况下,如果消息被序列化,无法识别的值仍将与消息一起序列化。

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

保留值

如果您通过完全删除一个枚举条目或将其注释掉来更新一个枚举类型,未来的用户在对该类型进行自己的更新时可以重用该数值。如果他们以后加载同一 .proto 的旧实例,这可能会导致严重问题,包括数据损坏、隐私漏洞等。确保这种情况不发生的一种方法是指定您删除的条目的数值(和/或名称,名称也可能导致 JSON 序列化问题)是 reserved 的。如果任何未来的用户试图使用这些标识符,protocol buffer 编译器会报错。您可以使用 max 关键字指定您的保留数值范围一直到可能的最大值。

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

请注意,你不能在同一个 reserved 语句中混合使用字段名和数值。

使用其他消息类型

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

message SearchResponse {
  repeated Result results = 1;
}

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

导入定义

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

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

import "myproject/other_protos.proto";

从 2024 版开始,您还可以使用 import option 从其他 .proto 文件使用自定义选项定义。与常规导入不同,这只允许使用自定义选项定义,而不允许使用其他消息或枚举定义,以避免生成的代码中的依赖项。

import option "myproject/other_protos.proto";

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

请注意,公共导入功能在 Java、Kotlin、TypeScript、JavaScript、GCL 以及使用 protobuf 静态反射的 C++ 目标中不可用。

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

// new.proto
// All definitions are moved here
// old.proto
// This is the proto that all clients are importing.
import public "new.proto";
import "other.proto";
// client.proto
import "old.proto";
// You use definitions from old.proto and new.proto, but not other.proto

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

符号可见性

当被其他 protos 导入时,哪些符号可用或不可用,由 features.default_symbol_visibility 功能和 2024 版中添加的 exportlocal 关键字控制。

只有通过默认符号可见性或 export 关键字导出的符号才能被导入文件引用。

使用 proto2 和 proto3 消息类型

可以导入 proto2proto3 消息类型并在您的版本消息中使用它们,反之亦然。

嵌套类型

你可以在其他消息类型内部定义和使用消息类型,如下例所示——这里的 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 {       // Level 0
  message MiddleAA {  // Level 1
    message Inner {   // Level 2
      int64 ival = 1;
      bool  booly = 2;
    }
  }
  message MiddleBB {  // Level 1
    message Inner {   // Level 2
      int32 ival = 1;
      bool  booly = 2;
    }
  }
}

更新消息类型

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

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

二进制线路非安全变更

有线不安全 (Wire-unsafe) 的更改是指,如果您使用新的 schema 解析器来解析使用旧 schema 序列化的数据(或反之),将会导致中断的 schema 更改。只有在您知道数据的所有序列化器和反序列化器都使用新的 schema 时,才进行有线不安全的更改。

  • 更改任何现有字段的字段编号是不安全的。
    • 更改字段编号等同于删除该字段并添加一个具有相同类型的新字段。如果你想重编号一个字段,请参阅删除字段的说明。
  • 将字段移入一个现有的 oneof 是不安全的。

二进制线路安全变更

线路安全的变更是指完全安全地演进模式,而不会有数据丢失或新的解析失败的风险。

请注意,任何有线安全 (wire-safe) 的更改对于给定语言的应用代码来说都可能是破坏性更改。例如,向一个已有的枚举中添加一个值,对于任何对该枚举进行穷尽式 switch 的代码来说,都会导致编译中断。因此,Google 可能会避免在公共消息上进行这类更改:AIPs 中包含了关于哪些更改是安全的指导意见。

  • 添加新字段是安全的。
    • 如果您添加新字段,任何由使用您的“旧”消息格式的代码序列化的消息仍然可以被您的新生成代码解析。您应该记住这些元素的默认值,以便新代码可以正确地与旧代码生成的消息交互。同样,由您的新代码创建的消息可以被您的旧代码解析:旧的二进制文件在解析时会简单地忽略新字段。有关详细信息,请参阅未知字段部分。
  • 移除字段是安全的。
    • 在您更新的消息类型中,不得再次使用相同的字段编号。您可能需要重命名字段,例如添加前缀“OBSOLETE_”,或者将字段编号设为保留,这样您 .proto 的未来用户就不会意外地重用该编号。
  • 向枚举添加额外的值是安全的。
  • 将一个单一的显式存在字段或扩展变更为一个新的 oneof 的成员是安全的。
  • 将一个只包含一个字段的 oneof 更改为显式存在字段是安全的。
  • 将一个字段更改为具有相同编号和类型的扩展是安全的。

二进制线路兼容变更(有条件安全)

与有线安全 (Wire-safe) 的更改不同,有线兼容 (wire-compatible) 意味着相同的数据在给定更改前后都可以被解析。然而,在这种类型的更改下,数据的解析可能会是有损的。例如,将一个 int32 更改为 int64 是一个兼容的更改,但是如果写入了一个大于 INT32_MAX 的值,一个将其作为 int32 读取的客户端将丢弃该数字的高位比特。

只有在您仔细管理系统推出过程的情况下,才能对您的 schema 进行兼容性更改。例如,您可以将 int32 更改为 int64,但要确保在新的 schema 部署到所有端点之前,您继续只写入合法的 int32 值,然后在之后才开始写入更大的值。

如果你的模式是在组织外部发布的,通常不应该进行线路兼容的更改,因为你无法管理新模式的部署,从而无法知道何时使用不同范围的值是安全的。

  • int32uint32int64uint64bool 都是兼容的。
    • 如果从线路中解析出一个不适合相应类型的数字,你将得到与在 C++ 中将该数字强制转换为该类型相同的效果(例如,如果一个 64 位数字被作为 int32 读取,它将被截断为 32 位)。
  • sint32sint64 彼此兼容,但与其他整数类型兼容。
    • 如果写入的值在 INT_MIN 和 INT_MAX(含)之间,那么用任一类型解析都会得到相同的值。如果写入了一个超出该范围的 sint64 值,并被解析为 sint32,则 varint 会被截断为 32 位,然后进行 zigzag 解码(这将导致观察到不同的值)。
  • 只要字节是有效的 UTF-8,stringbytes 就是兼容的。
  • 如果字节包含消息的编码实例,则嵌入式消息与 bytes 兼容。
  • fixed32sfixed32 兼容,fixed64sfixed64 兼容。
  • 对于 stringbytes 和消息字段,singular 与 repeated 兼容。
    • 对于重复字段的序列化数据作为输入,期望该字段为 singular 的客户端,如果它是原始类型字段,将取最后一个输入值;如果它是消息类型字段,将合并所有输入元素。请注意,这对于数字类型(包括布尔值和枚举)通常是安全的。数字类型的重复字段默认以打包格式序列化,当期望的是 singular 字段时,将无法正确解析。
  • enumint32uint32int64uint64 兼容。
    • 请注意,当消息被反序列化时,客户端代码可能会以不同的方式处理它们:例如,未识别的 proto3 `enum` 值将保留在消息中,但当消息被反序列化时,其表示方式是依赖于语言的。
  • map<K, V> 和相应的 repeated 消息字段之间更改字段是二进制兼容的(有关消息布局和其他限制,请参阅下面的映射)。
    • 然而,更改的安全性取决于应用程序:在反序列化和重新序列化消息时,使用 `repeated` 字段定义的客户端将产生语义上相同的结果;但是,使用 `map` 字段定义的客户端可能会重新排序条目并丢弃具有重复键的条目。

未知字段

未知字段是格式良好的 protocol buffer 序列化数据,表示解析器无法识别的字段。例如,当一个旧的二进制文件解析一个由新的二进制文件发送的带有新字段的数据时,这些新字段在旧的二进制文件中就成为未知字段。

版本消息保留未知字段,并在解析和序列化输出中包含它们,这与 proto2 和 proto3 的行为一致。

保留未知字段

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

  • 将 proto 序列化为 JSON。
  • 遍历消息中的所有字段以填充一个新消息。

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

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

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

扩展

扩展是其容器消息之外定义的字段;通常在与容器消息的 .proto 文件分开的 .proto 文件中。

为什么要使用扩展?

使用扩展主要有两个原因

  • 容器消息的 .proto 文件将有更少的导入/依赖项。这可以提高构建时间,打破循环依赖,并促进松散耦合。扩展在这方面非常出色。
  • 允许系统以最小的依赖和协调将数据附加到容器消息。扩展不是一个很好的解决方案,因为字段编号空间有限以及重用字段编号的后果。如果您的用例需要大量扩展的极低协调性,请考虑使用 Any 消息类型

示例扩展

使用扩展是一个两步过程。首先,在您要扩展的消息(“容器”)中,您必须为扩展保留一个字段编号范围。然后,在单独的文件中,您定义扩展字段本身。

这是一个示例,展示了如何向通用 UserContent 消息添加猫咪视频的扩展。

步骤 1:在容器消息中保留一个扩展范围。

容器消息必须使用 extensions 关键字来保留一个字段编号范围供他人使用。最佳实践是也为计划添加的特定扩展添加一个 declaration。此声明充当前向声明,使开发人员更容易发现扩展并避免重用字段编号。

// media/user_content.proto
edition = "2023";

package media;

// A container for user-created content.
message UserContent {
  extensions 100 to 199 [
    declaration = {
      number: 126,
      full_name: ".kittens.kitten_videos",
      type: ".kittens.Video",
      repeated: true
    }
  ];
}

此声明指定将在其他地方定义的扩展的字段编号、完整名称、类型和基数。

步骤 2:在单独的文件中定义扩展。

扩展本身在另一个 .proto 文件中定义,该文件通常侧重于特定功能(如猫咪视频)。这避免了从通用容器到特定功能的依赖。

// kittens/video_ext.proto
edition = "2023";

import "media/user_content.proto"; // Imports the container message
import "kittens/video.proto";      // Imports the extension's message type

package kittens;

// This defines the extension field.
extend media.UserContent {
  repeated Video kitten_videos = 126;
}

extend 块将新的 kitten_videos 字段连接回 media.UserContent 消息,使用在容器中保留的字段编号 126

与具有相同字段编号、类型和基数的标准字段相比,扩展字段的 wire-format 编码没有区别。因此,只要字段编号、类型和基数保持不变,将标准字段移出其容器作为扩展或将扩展字段移入其容器消息作为标准字段是安全的。

然而,由于扩展是在容器消息之外定义的,因此不会生成专门的访问器来获取和设置特定的扩展字段。例如,protobuf 编译器不会生成 AddKittenVideos()GetKittenVideos() 访问器。相反,扩展是通过参数化函数访问的,例如:HasExtension()ClearExtension()GetExtension()MutableExtension()AddExtension()

在 C++ 中,它看起来像这样

UserContent user_content;
user_content.AddExtension(kittens::kitten_videos, new kittens::Video());
assert(1 == user_content.GetRepeatedExtension(kittens::kitten_videos).size());
user_content.GetRepeatedExtension(kittens::kitten_videos)[0];

定义扩展范围

如果您是容器消息的所有者,则需要为消息的扩展定义一个扩展范围。

分配给扩展字段的字段编号不能用于标准字段。

在定义扩展范围后,扩展它是安全的。一个好的默认值是分配 1000 个相对较小的数字,并使用扩展声明密集填充该空间

message ModernExtendableMessage {
  // All extensions in this range should use extension declarations.
  extensions 1000 to 2000 [verification = DECLARATION];
}

在实际扩展之前添加扩展声明范围时,应添加 verification = DECLARATION 以强制此新范围使用声明。一旦添加了实际声明,此占位符即可删除。

将现有扩展范围拆分为覆盖相同总范围的单独范围是安全的。这对于将旧版消息类型迁移到 扩展声明可能很有必要。例如,在迁移之前,范围可能定义为

message LegacyMessage {
  extensions 1000 to max;
}

迁移后(拆分范围)可以为

message LegacyMessage {
  // Legacy range that was using an unverified allocation scheme.
  extensions 1000 to 524999999 [verification = UNVERIFIED];
  // Current range that uses extension declarations.
  extensions 525000000 to max  [verification = DECLARATION];
}

增加起始字段编号或减少结束字段编号以移动或缩小扩展范围是不安全的。这些更改可能会使现有扩展无效。

对于在 proto 的大多数实例中填充的标准字段,首选使用字段编号 1 到 15。不建议将这些编号用于扩展。

如果您的编号约定可能涉及扩展具有非常大的字段编号,您可以使用 max 关键字指定您的扩展范围达到可能的最大字段编号

message Foo {
  extensions 1000 to max;
}

max 是 229 - 1,即 536,870,911。

选择扩展编号

扩展只是可以在其容器消息之外指定的字段。所有关于分配字段编号的相同规则也适用于扩展字段编号。重用字段编号的相同后果也适用于重用扩展字段编号。

如果容器消息使用 扩展声明,则选择唯一的扩展字段编号很简单。在定义新扩展时,从容器消息中定义的最高扩展范围中选择所有其他声明之上最低的字段编号。例如,如果容器消息定义如下

message Container {
  // Legacy range that was using an unverified allocation scheme
  extensions 1000 to 524999999;
  // Current range that uses extension declarations. (highest extension range)
  extensions 525000000 to max  [
    declaration = {
      number: 525000001,
      full_name: ".bar.baz_ext",
      type: ".bar.Baz"
    }
    // 525,000,002 is the lowest field number above all other declarations
  ];
}

Container 的下一个扩展应该添加一个编号为 525000002 的新声明。

未经验证的扩展编号分配(不推荐)

容器消息的所有者可以选择放弃扩展声明,而采用他们自己的未经验证的扩展编号分配策略。

未经验证的分配方案使用 protobuf 生态系统之外的机制来在选定的扩展范围内分配扩展字段编号。一个例子可能是使用单体仓库的提交编号。从 protobuf 编译器的角度来看,此系统是“未经验证的”,因为无法检查扩展是否使用了正确获取的扩展字段编号。

与扩展声明等经过验证的系统相比,未经验证的系统的好处是可以在不与容器消息所有者协调的情况下定义扩展。

未经验证的系统的缺点是 protobuf 编译器无法保护参与者免于重用扩展字段编号。

不推荐使用未经验证的扩展字段编号分配策略,因为重用字段编号的后果会影响消息的所有扩展者(而不仅仅是未遵循建议的开发人员)。如果您的用例需要极低的协调性,请考虑使用 Any 消息

未经验证的扩展字段编号分配策略仅限于 1 到 524,999,999 的范围。字段编号 525,000,000 及以上只能与扩展声明一起使用。

指定扩展类型

扩展可以是除 oneofmap 之外的任何字段类型。

嵌套扩展(不推荐)

您可以在另一个消息的范围内声明扩展

import "common/user_profile.proto";

package puppies;

message Photo {
  extend common.UserProfile {
    int32 likes_count = 111;
  }
  ...
}

在这种情况下,访问此扩展的 C++ 代码是

UserProfile user_profile;
user_profile.SetExtension(puppies::Photo::likes_count, 42);

换句话说,唯一的影响是 likes_countpuppies.Photo 的范围内定义。

这是一个常见的混淆源:声明嵌套在消息类型中的 extend意味着外部类型和扩展类型之间存在任何关系。特别是,前面的示例意味着 PhotoUserProfile 的某种子类。它只意味着符号 likes_countPhoto 的范围内声明;它只是一个静态成员。

一种常见的模式是将扩展定义在扩展字段类型的范围内——例如,这是一个 media.UserContent 的扩展,类型为 puppies.Photo,其中扩展作为 Photo 的一部分定义

import "media/user_content.proto";

package puppies;

message Photo {
  extend media.UserContent {
    Photo puppy_photo = 127;
  }
  ...
}

然而,没有要求将具有消息类型的扩展定义在该类型内部。您也可以使用标准定义模式

import "media/user_content.proto";

package puppies;

message Photo {
  ...
}

// This can even be in a different file.
extend media.UserContent {
  Photo puppy_photo = 127;
}

为了避免混淆,首选这种标准(文件级)语法。对于不熟悉扩展的用户,嵌套语法常常被误认为是子类化。

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()` 方法:

// Storing an arbitrary message type in Any.
NetworkErrorDetails details = ...;
ErrorStatus status;
status.add_details()->PackFrom(details);

// Reading an arbitrary message from Any.
ErrorStatus status = ...;
for (const google::protobuf::Any& detail : status.details()) {
  if (detail.Is<NetworkErrorDetails>()) {
    NetworkErrorDetails network_error;
    detail.UnpackTo(&network_error);
    ... processing network_error ...
  }
}

如果您希望将包含的消息限制为少量类型,并且在将新类型添加到列表之前需要权限,请考虑使用带扩展声明扩展,而不是 Any 消息类型。

Oneof

如果您的消息有许多单一字段,并且最多只能同时设置一个字段,您可以通过使用 oneof 功能来强制此行为并节省内存。

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

请注意,如果*设置了多个值,则由 proto 中的顺序决定的最后一个设置的值将覆盖所有先前的值*。

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

使用 Oneof

要在你的 .proto 文件中定义一个 oneof,你使用 oneof 关键字,后跟你 oneof 的名称,在本例中是 test_oneof

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

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

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

Oneof 特性

  • 设置一个 oneof 字段将自动清除 oneof 的所有其他成员。所以如果你设置了几个 oneof 字段,只有你设置的*最后一个*字段仍然有值。

    SampleMessage message;
    message.set_name("name");
    CHECK(message.has_name());
    // Calling mutable_sub_message() will clear the name field and will set
    // sub_message to a new instance of SubMessage with none of its fields set.
    message.mutable_sub_message();
    CHECK(!message.has_name());
    
  • 如果解析器在传输线上遇到同一个 oneof 的多个成员,则在解析后的消息中只使用看到的最后一个成员。在解析传输线上的数据时,从字节的开头开始,评估下一个值,并应用以下解析规则:

    • 首先,检查同一个 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");      // Will delete sub_message
    sub_message->set_...            // Crashes here
    
  • 同样在 C++ 中,如果你 Swap() 两个带有 oneofs 的消息,每个消息最终会得到对方的 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(msg2.has_name());
    

向后兼容性问题

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

标签重用问题

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

映射

如果你想在数据定义中创建关联映射,protocol buffers 提供了一种方便的快捷语法:

map<key_type, value_type> map_field = N;

...其中 `key_type` 可以是任何整数或字符串类型(即,除浮点类型和 `bytes` 之外的任何标量类型)。请注意,枚举和 proto 消息都不能作为 `key_type`。`value_type` 可以是除另一个 map 之外的任何类型。

因此,举例来说,如果你想创建一个项目映射,其中每个 Project 消息都与一个字符串键相关联,你可以这样定义它:

map<string, Project> projects = 3;

映射特性

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

生成的 map API 目前可用于所有支持的语言。你可以在相关的 API 参考中找到更多关于你所选语言的 map API 的信息。

向后兼容性

map 语法在线路上等同于以下内容,因此不支持 map 的 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 中,除非你在你的 .proto 文件中明确提供一个 option java_package,否则该包将用作 Java 包。
  • Python 中,package 指令被忽略,因为 Python 模块是根据它们在文件系统中的位置来组织的。
  • Go 中,`package` 指令被忽略,生成的 `.pb.go` 文件位于以相应 `go_proto_library` Bazel 规则命名的包中。对于开源项目,您**必须**提供一个 `go_package` 选项或设置 Bazel `-M` 标志。
  • Ruby 中,生成的类被包装在嵌套的 Ruby 命名空间内,并转换为所需的 Ruby 大写风格(首字母大写;如果第一个字符不是字母,则前缀为 `PB_`)。例如,`Open` 将位于 `Foo::Bar` 命名空间中。
  • PHP 中,除非您在 .proto 文件中明确提供了 `option php_namespace`,否则 package 在转换为 PascalCase 后被用作命名空间。例如,`Open` 将位于 `Foo\Bar` 命名空间中。
  • C# 中,除非您在 `.proto` 文件中明确提供 `option csharp_namespace`,否则 package 在转换为 PascalCase 后将用作命名空间。例如,`Open` 将位于 `Foo.Bar` 命名空间中。

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

包和名称解析

protocol buffer 语言中的类型名称解析方式类似于 C++:首先搜索最内层的作用域,然后是次内层,以此类推,每个包都被视为其父包的“内层”。前导的点“.”(例如,`.foo.bar.Baz`)表示从最外层作用域开始。

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

定义服务

如果您想将您的消息类型与 RPC(远程过程调用)系统一起使用,您可以在 .proto 文件中定义一个 RPC 服务接口,protocol buffer 编译器将以您选择的语言生成服务接口代码和存根 (stub)。因此,举例来说,如果您想定义一个 RPC 服务,其方法接受您的 SearchRequest 并返回一个 SearchResponse,您可以在您的 .proto 文件中这样定义:

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

与协议缓冲区一起使用的最直接的 RPC 系统是 gRPC:一个由 Google 开发的语言和平台中立的开源 RPC 系统。gRPC 与协议缓冲区配合得非常好,允许您使用特殊的协议缓冲区编译器插件直接从 .proto 文件生成相关的 RPC 代码。

如果您不想使用 gRPC,也可以将协议缓冲区与您自己的 RPC 实现一起使用。您可以在 Proto2 语言指南中找到更多信息。

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

JSON 映射

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

选项

.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 代码,此选项无效。此选项已在 2024 版中删除,并替换为 features.(pb.java).nest_in_file_class

    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_services, java_generic_services, py_generic_services (文件选项): 通用服务已被弃用。 protocol buffer 编译器是否应分别基于 C++、Java 和 Python 中的服务定义生成抽象服务代码。由于历史原因,这些默认为 `true`。然而,自 2.3.0 版本(2010年1月)起,RPC 实现更倾向于提供代码生成器插件来生成更特定于每个系统的代码,而不是依赖于“抽象”服务。

    // This file relies on plugins to generate service code.
    option cc_generic_services = false;
    option java_generic_services = false;
    option py_generic_services = false;
    
  • cc_enable_arenas (文件选项): 为 C++ 生成的代码启用 arena 分配

  • objc_class_prefix (文件选项): 设置 Objective-C 类前缀,该前缀会添加到此 .proto 生成的所有 Objective-C 类和枚举之前。没有默认值。您应该使用 3-5 个大写字符的前缀,正如 Apple 推荐的那样。请注意,所有 2 个字母的前缀都由 Apple 保留。

  • packed(字段选项):在 protobuf 版本中,此选项被锁定为 true。要使用非打包有线格式,您可以使用版本特性覆盖此选项。这提供了与 2.3.0 版本之前的解析器(很少需要)的兼容性,示例如下

    repeated int32 samples = 4 [features.repeated_field_encoding = EXPANDED];
    
  • deprecated (字段选项): 如果设置为 `true`,表示该字段已弃用,新代码不应使用。在大多数语言中,这没有实际效果。在 Java 中,它会变成一个 `@Deprecated` 注解。对于 C++,clang-tidy 将在使用已弃用字段时生成警告。将来,其他特定语言的代码生成器可能会在该字段的访问器上生成弃用注解,这反过来又会在编译试图使用该字段的代码时发出警告。如果该字段无人使用,并且您想阻止新用户使用它,请考虑用保留语句替换该字段声明。

    int32 old_field = 6 [deprecated = true];
    

枚举值选项

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

下面的例子展示了添加这些选项的语法:

import "google/protobuf/descriptor.proto";

extend google.protobuf.EnumValueOptions {
  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);

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

自定义选项

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

从 2024 版开始,使用 import option 导入自定义选项定义。请参阅 导入

选项保留

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

选项保留是一个高级功能,大多数用户不需要担心,但如果您想使用某些选项而不想为在二进制文件中保留它们付出代码大小的代价,它可能很有用。具有源保留的选项对于 `protoc` 和 `protoc` 插件仍然可见,因此代码生成器可以使用它们来自定义其行为。

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

extend google.protobuf.FileOptions {
  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` 来覆盖这一点。

选项目标

字段有一个 `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 {
  MyOptions file_options = 50000;
}

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

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

// OK: this field is allowed on file options
option (file_options).file_only_option = "abc";

message MyMessage {
  // OK: this field is allowed on both message and enum options
  option (message_options).message_and_enum_option = 42;
}

enum MyEnum {
  MY_ENUM_UNSPECIFIED = 0;
  // Error: file_only_option cannot be set on an enum.
  option (enum_options).file_only_option = "xyz";
}

生成你的类

要生成您需要使用的 Java、Kotlin、Python、C++、Go、Ruby、Objective-C 或 C# 代码来处理在 .proto 文件中定义的消息类型,您需要在 .proto 文件上运行 protocol buffer 编译器 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.proto` 和 `proto/lib2/data.proto`,这两个文件不能与 `-I=proto/lib1 -I=proto/lib2` 一起使用,因为 `import "data.proto"` 的含义将不明确。相反,应该使用 `-Iproto/`,全局名称将是 `lib1/data.proto` 和 `lib2/data.proto`。

如果您正在发布一个库,并且其他用户可能会直接使用您的消息,您应该在他们预期使用的路径中包含一个唯一的库名,以避免文件名冲突。如果您在一个项目中有多个目录,最好的做法是倾向于将一个 `-I` 设置为项目的顶层目录。

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

    为了方便起见,如果 `DST_DIR` 以 `.zip` 或 `.jar` 结尾,编译器会将输出写入一个具有给定名称的 ZIP 格式的存档文件中。`.jar` 输出也会被赋予一个 Java JAR 规范所要求的清单文件。请注意,如果输出存档已经存在,它将被覆盖。

  • 您必须提供一个或多个 .proto 文件作为输入。可以一次指定多个 .proto 文件。尽管文件是相对于当前目录命名的,但每个文件必须位于其中一个 IMPORT_PATH 中,以便编译器可以确定其规范名称。

文件位置

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

位置应与语言无关

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

这条规则的例外是当很明显 protos 只会在 Java 上下文中使用时,例如用于测试。

支持的平台

有关信息