Go 生成代码指南 (Opaque)

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

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

编译器调用

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/project 指定为 module 前缀,将生成一个位于 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 并发访问同一个消息时,适用以下规则:

  • 并发访问(读取)字段是安全的,但有一个例外:
  • 在同一消息中修改不同的字段是安全的。
  • 并发修改一个字段是不安全的。
  • 以任何方式修改消息,同时并发使用 proto的函数,例如 proto.Marshalproto.Size,是不安全的。

嵌套类型

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

message Artist {
  message Name {
  }
}

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

字段

protocol buffer 编译器为消息中定义的每个字段生成访问器方法(setter 和 getter)。

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

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

因此,您可以使用 Go 中的 GetBirthYear() 方法访问 proto 字段 birth_year,使用 GetXBirthYear_2() 访问 _birth_year_2

单一字段

对于此字段定义:

// proto2 and proto3
message Artist {
  optional int32 birth_year = 1;
}

// editions
message Artist {
  int32 birth_year = 1 [features.field_presence = EXPLICIT];
}

编译器会生成一个具有以下访问器方法的 Go 结构体:

func (m *Artist) GetBirthYear() int32
func (m *Artist) SetBirthYear(v int32)

对于隐式存在性,getter 返回 birth_year 中的 int32 值,如果字段未设置,则返回该类型的零值(数字为 0,字符串为空字符串)。对于显式存在性,getter 返回 birth_year 中的 int32 值,如果字段未设置,则返回默认值。如果未显式设置默认值,则使用零值代替。

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

在具有显式存在性的字段中,您还可以使用这些方法:

func (m *Artist) HasBirthYear() bool
func (m *Artist) ClearBirthYear()

奇异消息字段

给定消息类型:

message Band {}

对于具有 Band 字段的消息:

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

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

编译器将生成一个具有以下访问器方法的 Go 结构体:

type Concert struct { ... }

func (m *Concert) GetHeadliner() *Band { ... }
func (m *Concert) SetHeadliner(v *Band) { ... }
func (m *Concert) HasHeadliner() bool { ... }
func (m *Concert) ClearHeadliner() { ... }

即使 m 为 nil,调用 GetHeadliner() 访问器方法也是安全的。这使得可以链式调用 get 方法而无需进行中间的 nil 检查:

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

如果字段未设置,getter 将返回字段的默认值。对于消息,默认值是 nil 指针。

与 getter 相反,setter 不会为您执行 nil 检查。因此,您不能在可能为 nil 的消息上安全地调用 setter。

重复字段

对于 repeated 字段,访问器方法使用切片类型。对于这个带有 repeated 字段的消息:

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

编译器会生成一个具有以下访问器方法的 Go 结构体:

type Concert struct { ... }

func (m *Concert) GetSupportActs() []*Band { ... }
func (m *Concert) SetSupportActs(v []*Band) { ... }

同样,对于字段定义 repeated bytes band_promo_images = 1;,编译器将生成使用 [][]byte 类型的访问器。对于 repeated 的枚举 repeated MusicGenre genres = 2;,编译器会生成使用 []MusicGenre 类型的访问器。

以下示例展示了如何使用构建器构造一个 Concert 消息。

concert := Concert_builder{
  SupportActs: []*Band{
    {}, // First element.
    {}, // Second element.
  },
}.Build()

或者,您也可以使用 setter:

concert := &Concert{}
concert.SetSupportActs([]*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 { ... }

func (m *MerchBooth) GetItems() map[string]*MerchItem { ... }
func (m *MerchBooth) SetItems(v map[string]*MerchItem) { ... }

Oneof 字段

对于 oneof 字段,protobuf 编译器会为 oneof 中的每个单一字段生成访问器。

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

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

编译器会生成一个具有以下访问器方法的 Go 结构体:

type Profile struct { ... }

func (m *Profile) WhichAvatar() case_Profile_Avatar { ... }
func (m *Profile) GetImageUrl() string { ... }
func (m *Profile) GetImageData() []byte { ... }

func (m *Profile) SetImageUrl(v string) { ... }
func (m *Profile) SetImageData(v []byte) { ... }

func (m *Profile) HasAvatar() bool { ... }
func (m *Profile) HasImageUrl() bool { ... }
func (m *Profile) HasImageData() bool { ... }

func (m *Profile) ClearAvatar() { ... }
func (m *Profile) ClearImageUrl() { ... }
func (m *Profile) ClearImageData() { ... }

以下示例展示了如何使用构建器设置字段:

p1 := accountpb.Profile_builder{
  ImageUrl: proto.String("https://example.com/image.png"),
}.Build()

……或者,等效地,使用 setter:

// imageData is []byte
imageData := getImageData()
p2 := &accountpb.Profile{}
p2.SetImageData(imageData)

要访问该字段,您可以在 WhichAvatar() 的结果上使用 switch 语句:

switch m.WhichAvatar() {
case accountpb.Profile_ImageUrl_case:
    // Load profile image based on URL
    // using m.GetImageUrl()

case accountpb.Profile_ImageData_case:
    // Load profile image based on bytes
    // using m.GetImageData()

case accountpb.Profile_Avatar_not_set_case:
    // The field is not set.

default:
    return fmt.Errorf("Profile.Avatar has an unexpected new oneof field %v", x)
}

构建器

构建器是在单个表达式中构造和初始化消息的便捷方式,尤其是在处理嵌套消息(如单元测试)时。

与其他语言(如 Java)中的构建器不同,Go protobuf 构建器不应在函数之间传递。相反,应立即调用 Build() 并传递生成的 proto 消息,然后使用 setter 修改字段。

枚举

给定一个枚举,例如:

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 文件中首先出现的那个。

扩展 (proto2)

给定一个扩展定义:

extend Concert {
  optional int32 promo_id = 123;
}

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

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

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

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

例如,给定以下定义:

extend Concert {
  optional int32 singular_int32 = 1;
  repeated bytes repeated_strings = 2;
  optional 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 {
    optional int32 promo_id = 124;
  }
}

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

服务(Services)

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