Go 生成代码指南 (Opaque)

精确描述了协议缓冲区编译器为任何给定协议定义生成的 Go 代码。

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

编译器调用

协议缓冲区编译器需要一个插件来生成 Go 代码。使用 Go 1.16 或更高版本通过运行以下命令安装它

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

这将在 $GOBIN 中安装 protoc-gen-go 二进制文件。设置 $GOBIN 环境变量以更改安装位置。它必须位于您的 $PATH 中,以便协议缓冲区编译器能够找到它。

当使用 go_out 标志调用时,协议缓冲区编译器会生成 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 时传递 go_opt 标志来提供 protoc-gen-go 特定的标志。可以传递多个 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 目录。如果需要,编译器会自动创建嵌套的输出子目录,但不会创建输出目录本身。

为了生成 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
版本 2023Open Struct API
版本 2024+Opaque API

您可以通过在 .proto 文件中设置 api_level 版本特性来选择 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 {}

协议缓冲区编译器会生成一个名为 Artist 的结构体。*Artist 实现了 proto.Message 接口。

proto提供了操作消息的函数,包括转换为二进制格式以及从二进制格式转换回来。

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

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

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

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

嵌套类型

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

message Artist {
  message Name {
  }
}

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

字段

协议缓冲区编译器为消息中定义的每个字段生成访问器方法(setter 和 getter)。

请注意,生成的 Go 访问器方法总是使用驼峰式命名,即使 .proto 文件中的字段名使用小写字母和下划线(应该如此)。大小写转换规则如下

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

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

单数标量字段 (proto2)

对于以下任一字段定义

optional int32 birth_year = 1;
required int32 birth_year = 1;

编译器生成以下访问器方法

func (m *Artist) GetBirthYear() int32 { ... }
func (m *Artist) SetBirthYear(v int32) { ... }
func (m *Artist) HasBirthYear() bool { ... }
func (m *Artist) ClearBirthYear() { ... }

访问器方法 GetBirthYear() 返回 birth_year 中的 int32 值,如果字段未设置,则返回默认值。如果未显式设置默认值,则使用该类型的零值(数字为 0,字符串为空字符串)。

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

单数标量字段 (proto3)

对于此字段定义

int32 birth_year = 1;
optional int32 first_active_year = 2;

编译器生成以下访问器方法

func (m *Artist) GetBirthYear() int32 { ... }
func (m *Artist) SetBirthYear(v int32) { ... }
// NOTE: No HasBirthYear() or ClearBirthYear() methods;
// proto3 fields only have presence when declared as optional:
// /programming-guides/field_presence.md

func (m *Artist) GetFirstActiveYear() int32 { ... }
func (m *Artist) SetFirstActiveYear(v int32) { ... }
func (m *Artist) HasFirstActiveYear() bool { ... }
func (m *Artist) ClearFirstActiveYear() { ... }

访问器方法 GetBirthYear() 返回 birth_year 中的 int32 值,如果字段未设置,则返回该类型的零值(数字为 0,字符串为空字符串)。

对于其他标量字段类型(包括 bool, bytesstring),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;
}

编译器将生成一个包含以下访问器方法的 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() 访问器方法也是安全的。这使得链式调用 getter 成为可能,而无需进行中间的 nil 检查

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

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

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

重复字段

对于重复字段,访问器方法使用切片类型。对于包含重复字段的此消息

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 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)

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

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() 并传递生成的消息,使用 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;
  // ...
}

协议缓冲区编译器会生成一个类型和一系列具有该类型的常量

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

协议缓冲区编译器还为枚举中的每个值生成一个常量。对于消息内部的枚举,常量以包含该枚举的消息名称开头

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;
}

协议缓冲区编译器将生成一个名为 E_Promo_idprotoreflect.ExtensionType 值。此值可用于 proto.GetExtensionproto.SetExtensionproto.HasExtensionproto.ClearExtension 函数,以访问消息中的扩展。GetExtension 函数和 SetExtension 函数分别返回和接受一个包含扩展值类型的 interface{} 值。

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

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

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

例如,给定以下定义

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

服务

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