Go 生成代码指南 (Open)

描述 protocol buffer 编译器为任何给定的 protocol 定义所生成的 Go 代码。

proto2、proto3 和 editions 生成的代码之间的任何差异都会被高亮显示——请注意,这些差异存在于本文档中描述的生成代码中,而不是基础 API 中,基础 API 在两个版本中是相同的。在阅读本文档之前,您应该先阅读 proto2 语言指南proto3 语言指南editions 语言指南

编译器调用

Protocol Buffer 编译器需要一个插件来生成 Go 代码。请使用 Go 1.16 或更高版本运行以下命令来安装它:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

这将在 $GOBIN 中安装一个 protoc-gen-go 二进制文件。设置 $GOBIN 环境变量可以更改安装位置。它必须在您的 $PATH 中,以便 Protocol Buffer 编译器能够找到它。

当使用 go_out 标志调用 Protocol Buffer 编译器时,它会生成 Go 输出。go_out 标志的参数是您希望编译器写入 Go 输出的目录。编译器会为每个输入的 .proto 文件创建一个源文件。输出文件的名称是通过将 .proto 扩展名替换为 .pb.go 来创建的。

生成的 .pb.go 文件在输出目录中的位置取决于编译器标志。有几种输出模式:

  • 如果指定了 paths=import 标志,输出文件将被放置在以 Go 包的导入路径命名的目录中(例如,由 .proto 文件中的 go_package 选项提供的路径)。例如,一个输入文件 protos/buzz.proto,其 Go 导入路径为 example.com/project/protos/fizz,将生成一个位于 example.com/project/protos/fizz/buzz.pb.go 的输出文件。如果未指定 paths 标志,则这是默认的输出模式。
  • 如果指定了 module=$PREFIX 标志,输出文件将被放置在以 Go 包的导入路径命名的目录中(例如,由 .proto 文件中的 go_package 选项提供的路径),但会从输出文件名中移除指定的目录前缀。例如,一个输入文件 protos/buzz.proto,其 Go 导入路径为 example.com/project/protos/fizz,并指定 example.com/projectmodule 前缀,将生成一个位于 protos/fizz/buzz.pb.go 的输出文件。生成任何在模块路径之外的 Go 包都会导致错误。此模式对于将生成的文件直接输出到 Go 模块中非常有用。
  • 如果指定了 paths=source_relative 标志,输出文件将被放置在与输入文件相同的相对目录中。例如,一个输入文件 protos/buzz.proto 将生成一个位于 protos/buzz.pb.go 的输出文件。

特定于 protoc-gen-go 的标志是通过在调用 protoc 时传递一个 go_opt 标志来提供的。可以传递多个 go_opt 标志。例如,当运行:

protoc --proto_path=src --go_out=out --go_opt=paths=source_relative foo.proto bar/baz.proto

编译器将从 src 目录中读取输入文件 foo.protobar/baz.proto,并将输出文件 foo.pb.gobar/baz.pb.go 写入 out 目录。编译器会自动创建必要的嵌套输出子目录,但不会创建输出目录本身。

包(Packages)

为了生成 Go 代码,必须为每个 .proto 文件(包括被生成的 .proto 文件传递依赖的文件)提供 Go 包的导入路径。有两种方式可以指定 Go 导入路径:

  • .proto 文件中声明,或
  • 在调用 protoc 时通过命令行声明。

我们建议在 .proto 文件中声明,这样 .proto 文件的 Go 包可以与 .proto 文件本身集中标识,并简化调用 protoc 时传递的标志集。如果一个给定的 .proto 文件的 Go 导入路径同时在 .proto 文件和命令行中提供,那么后者将优先于前者。

Go 导入路径通过在 .proto 文件中声明一个带有 Go 包完整导入路径的 go_package 选项来本地指定。示例用法:

option go_package = "example.com/project/protos/fizz";

Go 导入路径可以在调用编译器时通过命令行指定,方法是传递一个或多个 M${PROTO_FILE}=${GO_IMPORT_PATH} 标志。示例用法:

protoc --proto_path=src \
  --go_opt=Mprotos/buzz.proto=example.com/project/protos/fizz \
  --go_opt=Mprotos/bar.proto=example.com/project/protos/foo \
  protos/buzz.proto protos/bar.proto

由于所有 .proto 文件到其 Go 导入路径的映射可能相当大,这种指定 Go 导入路径的方式通常由某个构建工具(例如 Bazel)执行,该工具可以控制整个依赖树。如果某个给定的 .proto 文件有重复的条目,则最后指定的条目优先。

对于 go_package 选项和 M 标志,其值都可以包含一个由分号与导入路径分隔的显式包名。例如:"example.com/protos/foo;package_name"。不鼓励这种用法,因为默认情况下包名会以合理的方式从导入路径派生。

当一个 .proto 文件导入另一个 .proto 文件时,导入路径用于确定必须生成哪些 import 语句。例如,如果 a.proto 导入 b.proto,那么生成的 a.pb.go 文件需要导入包含生成的 b.pb.go 文件的 Go 包(除非两个文件在同一个包中)。导入路径也用于构造输出文件名。详情请参阅上面的“编译器调用”部分。

Go 导入路径与 .proto 文件中的 package 说明符之间没有关联。后者仅与 protobuf 命名空间相关,而前者仅与 Go 命名空间相关。此外,Go 导入路径与 .proto 导入路径之间也没有关联。

API 级别

生成的代码要么使用 Open Struct API,要么使用 Opaque API。有关介绍,请参阅 Go Protobuf:新的 Opaque API 博文。

根据您的 .proto 文件使用的语法,将使用以下 API:

.proto 语法API 级别
proto2Open Struct API
proto3Open Struct API
edition 2023Open Struct API
edition 2024+Opaque API

您可以通过在 .proto 文件中设置 api_level editions 特性来选择 API。这可以按文件或按消息设置:

edition = "2023";

package log;

import "google/protobuf/go_features.proto";
option features.(pb.go).api_level = API_OPAQUE;

message LogEntry {  }

为方便起见,您还可以使用 protoc 命令行标志覆盖默认的 API 级别:

protoc […] --go_opt=default_api_level=API_HYBRID

要为特定文件 (而不是所有文件) 覆盖默认 API 级别,请使用 apilevelM 映射标志 (类似于用于导入路径的 M 标志):

protoc […] --go_opt=apilevelMhello.proto=API_HYBRID

命令行标志也适用于仍使用 proto2 或 proto3 语法的 .proto 文件,但如果您想在 .proto 文件中选择 API 级别,则需要先将该文件迁移到 editions。

消息

给定一个简单的消息声明:

message Artist {}

Protocol Buffer 编译器会生成一个名为 Artist 的结构体。一个 *Artist 实现了 proto.Message 接口。

proto提供了对消息进行操作的函数,包括与二进制格式的相互转换。

proto.Message 接口定义了一个 ProtoReflect 方法。此方法返回一个 protoreflect.Message,它提供了消息的基于反射的视图。

optimize_for 选项不影响 Go 代码生成器的输出。

当多个 goroutine 并发访问同一个消息时,以下规则适用:

  • 并发访问(读取)字段是安全的,但有一个例外:
    • 首次访问惰性字段(lazy field)是一次修改操作。
  • 修改同一消息中的不同字段是安全的。
  • 并发修改同一字段是不安全的。
  • 在并发执行 proto的函数(例如 proto.Marshalproto.Size)时,以任何方式修改消息都是不安全的。

嵌套类型

消息可以声明在另一个消息内部。例如:

message Artist {
  message Name {
  }
}

在这种情况下,编译器会生成两个结构体:ArtistArtist_Name

字段

Protocol Buffer 编译器会为消息中定义的每个字段生成一个结构体字段。该字段的具体性质取决于其类型以及它是单一(singular)、重复(repeated)、映射(map)还是 oneof 字段。

请注意,生成的 Go 字段名称总是使用驼峰命名法(camel-case),即使 .proto 文件中的字段名称使用带下划线的小写字母(这是推荐的做法)。大小写转换规则如下:

  1. 首字母大写以供导出。如果第一个字符是下划线,则会移除下划线并在前面加上大写字母 X。
  2. 如果内部的下划线后跟一个小写字母,则移除下划线,并将后面的字母大写。

因此,proto 字段 birth_year 在 Go 中变为 BirthYear,而 _birth_year_2 变为 XBirthYear_2

单一显式存在标量字段

对于字段定义:

int32 birth_year = 1;

编译器会生成一个带有 *int32 类型字段(名为 BirthYear)的结构体,以及一个访问器方法 GetBirthYear(),该方法返回 Artist 中的 int32 值,如果字段未设置,则返回默认值。如果未显式设置默认值,则使用该类型的零值(数字为 0,字符串为空字符串)。

对于其他标量字段类型(包括 boolbytesstring),*int32 会根据标量值类型表被替换为相应的 Go 类型。

单一隐式存在标量字段

对于此字段定义:

int32 birth_year = 1;

编译器会生成一个带有 int32 类型字段(名为 BirthYear)的结构体,以及一个访问器方法 GetBirthYear(),该方法返回 birth_year 中的 int32 值,如果字段未设置,则返回该类型的零值(数字为 0,字符串为空字符串)。

FirstActiveYear 结构体字段的类型将是 *int32,因为它被标记为 optional

对于其他标量字段类型(包括 boolbytesstring),int32 会根据标量值类型表被替换为相应的 Go 类型。在 proto 中未设置的值将表示为该类型的零值(数字为 0,字符串为空字符串)。

奇异消息字段

给定消息类型:

message Band {}

对于带有 Band 字段的消息:

// proto2
message Concert {
  optional Band headliner = 1;
  // The generated code is the same result if required instead of optional.
}

// proto3
message Concert {
  Band headliner = 1;
}

// editions
message Concer {
  Band headliner = 1;
}

编译器将生成一个 Go 结构体:

type Concert struct {
    Headliner *Band
}

消息字段可以设置为 nil,这意味着该字段未被设置,实际上是清除了该字段。这不等同于将值设置为消息结构体的“空”实例。

编译器还会生成一个辅助函数 func (m *Concert) GetHeadliner() *Band。如果 m 是 nil 或者 headliner 未设置,该函数将返回一个 nil*Band。这使得可以链式调用 get 方法而无需进行中间的 nil 检查:

var m *Concert // defaults to nil
log.Infof("GetFoundingYear() = %d (no panic!)", m.GetHeadliner().GetFoundingYear())

重复字段

每个重复字段都会在 Go 的结构体中生成一个 []T 类型的切片字段,其中 T 是字段的元素类型。对于这个带有重复字段的消息:

message Concert {
  // Best practice: use pluralized names for repeated fields:
  // /programming-guides/style#repeated-fields
  repeated Band support_acts = 1;
}

编译器会生成 Go 结构体:

type Concert struct {
    SupportActs []*Band
}

同样,对于字段定义 repeated bytes band_promo_images = 1;,编译器将生成一个带有 [][]byte 类型字段(名为 BandPromoImage)的 Go 结构体。对于一个重复的枚举,如 repeated MusicGenre genres = 2;,编译器会生成一个带有 []MusicGenre 类型字段(名为 Genre)的结构体。

以下示例展示了如何设置该字段:

concert := &Concert{
  SupportActs: []*Band{
    {}, // First element.
    {}, // Second element.
  },
}

要访问该字段,您可以执行以下操作:

support := concert.GetSupportActs() // support type is []*Band.
b1 := support[0] // b1 type is *Band, the first element in support_acts.

映射字段

每个 map 字段都会在结构体中生成一个类型为 map[TKey]TValue 的字段,其中 TKey 是字段的键类型,TValue 是字段的值类型。对于这个带有 map 字段的消息:

message MerchItem {}

message MerchBooth {
  // items maps from merchandise item name ("Signed T-Shirt") to
  // a MerchItem message with more details about the item.
  map<string, MerchItem> items = 1;
}

编译器会生成 Go 结构体:

type MerchBooth struct {
    Items map[string]*MerchItem
}

Oneof 字段

对于 oneof 字段,protobuf 编译器会生成一个接口类型为 isMessageName_MyField 的单一字段。它还会为 oneof 中的每个单一字段生成一个结构体。这些结构体都实现了这个 isMessageName_MyField 接口。

对于这个带有 oneof 字段的消息:

package account;
message Profile {
  oneof avatar {
    string image_url = 1;
    bytes image_data = 2;
  }
}

编译器会生成这些结构体:

type Profile struct {
    // Types that are valid to be assigned to Avatar:
    //  *Profile_ImageUrl
    //  *Profile_ImageData
    Avatar isProfile_Avatar `protobuf_oneof:"avatar"`
}

type Profile_ImageUrl struct {
        ImageUrl string
}
type Profile_ImageData struct {
        ImageData []byte
}

*Profile_ImageUrl*Profile_ImageData 都通过提供一个空的 isProfile_Avatar() 方法来实现 isProfile_Avatar 接口。

以下示例展示了如何设置该字段:

p1 := &account.Profile{
  Avatar: &account.Profile_ImageUrl{ImageUrl: "http://example.com/image.png"},
}

// imageData is []byte
imageData := getImageData()
p2 := &account.Profile{
  Avatar: &account.Profile_ImageData{ImageData: imageData},
}

要访问该字段,您可以使用类型断言(type switch)来处理不同的消息类型。

switch x := m.Avatar.(type) {
case *account.Profile_ImageUrl:
    // Load profile image based on URL
    // using x.ImageUrl
case *account.Profile_ImageData:
    // Load profile image based on bytes
    // using x.ImageData
case nil:
    // The field is not set.
default:
    return fmt.Errorf("Profile.Avatar has unexpected type %T", x)
}

编译器还会生成 get 方法 func (m *Profile) GetImageUrl() stringfunc (m *Profile) GetImageData() []byte。每个 get 函数返回该字段的值,如果未设置,则返回零值。

枚举

给定一个枚举,例如:

message Venue {
  enum Kind {
    KIND_UNSPECIFIED = 0;
    KIND_CONCERT_HALL = 1;
    KIND_STADIUM = 2;
    KIND_BAR = 3;
    KIND_OPEN_AIR_FESTIVAL = 4;
  }
  Kind kind = 1;
  // ...
}

Protocol Buffer 编译器会生成一个类型和一系列该类型的常量:

type Venue_Kind int32

const (
    Venue_KIND_UNSPECIFIED       Venue_Kind = 0
    Venue_KIND_CONCERT_HALL      Venue_Kind = 1
    Venue_KIND_STADIUM           Venue_Kind = 2
    Venue_KIND_BAR               Venue_Kind = 3
    Venue_KIND_OPEN_AIR_FESTIVAL Venue_Kind = 4
)

对于消息内的枚举(如上例),类型名称以消息名称开头:

type Venue_Kind int32

对于包级别的枚举:

enum Genre {
  GENRE_UNSPECIFIED = 0;
  GENRE_ROCK = 1;
  GENRE_INDIE = 2;
  GENRE_DRUM_AND_BASS = 3;
  // ...
}

Go 类型名称与 proto 枚举名称保持不变:

type Genre int32

这个类型有一个 String() 方法,可以返回给定值的名称。

Enum() 方法用给定值初始化新分配的内存,并返回相应的指针:

func (Genre) Enum() *Genre

Protocol Buffer 编译器会为枚举中的每个值生成一个常量。对于消息内的枚举,常量以其所属消息的名称开头:

const (
    Venue_KIND_UNSPECIFIED       Venue_Kind = 0
    Venue_KIND_CONCERT_HALL      Venue_Kind = 1
    Venue_KIND_STADIUM           Venue_Kind = 2
    Venue_KIND_BAR               Venue_Kind = 3
    Venue_KIND_OPEN_AIR_FESTIVAL Venue_Kind = 4
)

对于包级别的枚举,常量则以枚举名称开头:

const (
    Genre_GENRE_UNSPECIFIED   Genre = 0
    Genre_GENRE_ROCK          Genre = 1
    Genre_GENRE_INDIE         Genre = 2
    Genre_GENRE_DRUM_AND_BASS Genre = 3
)

protobuf 编译器还会生成一个从整数值到字符串名称的映射,以及一个从名称到值的映射:

var Genre_name = map[int32]string{
    0: "GENRE_UNSPECIFIED",
    1: "GENRE_ROCK",
    2: "GENRE_INDIE",
    3: "GENRE_DRUM_AND_BASS",
}
var Genre_value = map[string]int32{
    "GENRE_UNSPECIFIED":   0,
    "GENRE_ROCK":          1,
    "GENRE_INDIE":         2,
    "GENRE_DRUM_AND_BASS": 3,
}

请注意,.proto 语言允许多个枚举符号具有相同的数值。具有相同数值的符号是同义词。在 Go 中,它们的表示方式完全相同,即多个名称对应同一个数值。反向映射中,每个数值只对应一个条目,即在 .proto 文件中首次出现的那个名称。

扩展

给定一个扩展定义:

extend Concert {
  int32 promo_id = 123;
}

Protocol Buffer 编译器将生成一个名为 E_Promo_idprotoreflect.ExtensionType 值。该值可与 proto.GetExtensionproto.SetExtensionproto.HasExtensionproto.ClearExtension 函数一起使用,以访问消息中的扩展。GetExtension 函数和 SetExtension 函数分别返回和接受一个包含扩展值类型的 interface{} 值。

对于单一标量扩展字段,扩展值类型是标量值类型表中对应的 Go 类型。

对于单一嵌入式消息扩展字段,扩展值类型为 *M,其中 M 是字段的消息类型。

对于重复的扩展字段,扩展值类型是单一类型的切片。

例如,给定以下定义:

extend Concert {
  int32 singular_int32 = 1;
  repeated bytes repeated_strings = 2;
  Band singular_message = 3;
}

可以这样访问扩展值:

m := &somepb.Concert{}
proto.SetExtension(m, extpb.E_SingularInt32, int32(1))
proto.SetExtension(m, extpb.E_RepeatedString, []string{"a", "b", "c"})
proto.SetExtension(m, extpb.E_SingularMessage, &extpb.Band{})

v1 := proto.GetExtension(m, extpb.E_SingularInt32).(int32)
v2 := proto.GetExtension(m, extpb.E_RepeatedString).([][]byte)
v3 := proto.GetExtension(m, extpb.E_SingularMessage).(*extpb.Band)

扩展可以声明在另一种类型内部嵌套。例如,一种常见的模式是这样做:

message Promo {
  extend Concert {
    int32 promo_id = 124;
  }
}

在这种情况下,ExtensionType 值被命名为 E_Promo_Concert

服务(Services)

默认情况下,Go 代码生成器不会为服务生成输出。如果您启用了 gRPC 插件(请参阅 gRPC Go 快速入门指南),则会生成支持 gRPC 的代码。