Go 程式碼產生指南(不透明)

精確描述 protocol buffer 編譯器針對任何給定的協定定義所產生的 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 目錄。編譯器會在必要時自動建立巢狀輸出子目錄,但不會建立輸出目錄本身。

套件

為了產生 Go 程式碼,必須為每個 .proto 檔案提供 Go 套件的匯入路徑(包括那些被產生的 .proto 檔案遞移依賴的檔案)。有兩種方法可以指定 Go 匯入路徑

  • 透過在 .proto 檔案中宣告它,或
  • 透過在調用 protoc 時在命令列上宣告它。

我們建議在 .proto 檔案中宣告它,以便 .proto 檔案的 Go 套件可以與 .proto 檔案本身集中識別,並簡化調用 protoc 時傳遞的標記集。如果給定 .proto 檔案的 Go 匯入路徑由 .proto 檔案本身和命令列提供,則後者優先於前者。

Go 匯入路徑在 .proto 檔案中本地指定,方法是宣告一個 go_package 選項,其中包含 Go 套件的完整匯入路徑。範例用法

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 檔案時,必須產生哪些匯入語句。例如,如果 a.proto 匯入 b.proto,則產生的 a.pb.go 檔案需要匯入包含產生的 b.pb.go 檔案的 Go 套件(除非兩個檔案都在同一個套件中)。匯入路徑也用於建構輸出檔案名稱。有關詳細資訊,請參閱上面的「編譯器調用」章節。

Go 匯入路徑和 .proto 檔案中的 package 規範之間沒有關聯性。後者僅與 protobuf 命名空間相關,而前者僅與 Go 命名空間相關。此外,Go 匯入路徑和 .proto 匯入路徑之間也沒有關聯性。

API 層級

產生的程式碼可以使用開放結構 API 或不透明 API。請參閱 Go Protobuf:新的不透明 API 部落格文章以取得簡介。

根據您的 .proto 檔案使用的語法,以下將使用哪個 API

.proto 語法API 層級
proto2開放結構 API
proto3開放結構 API
版本 2023開放結構 API
版本 2024+不透明 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 層級,您需要先將該檔案遷移到版本。

訊息

給定一個簡單的訊息宣告

message Artist {}

protocol buffer 編譯器會產生一個名為 Artist 的 struct。*Artist 實作 proto.Message 介面。

proto 套件 提供了對訊息進行操作的函數,包括與二進制格式之間的轉換。

proto.Message 介面定義了一個 ProtoReflect 方法。此方法傳回一個 protoreflect.Message,它提供訊息的基於反射的視圖。

optimize_for 選項不會影響 Go 程式碼產生器的輸出。

當多個 goroutine 並行存取同一個訊息時,以下規則適用

  • 並行存取(讀取)欄位是安全的,但有一個例外
  • 修改同一個訊息中的不同欄位是安全的。
  • 並行修改欄位是不安全的。
  • 以任何方式並行修改訊息與 proto 套件 的函數(例如 proto.Marshalproto.Size)是不安全的。

巢狀類型

訊息可以在另一個訊息內部宣告。例如

message Artist {
  message Name {
  }
}

在這種情況下,編譯器會產生兩個 struct:ArtistArtist_Name

欄位

protocol buffer 編譯器會為訊息中定義的每個欄位產生存取器方法(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,字串為空字串)。

對於其他純量欄位類型(包括 boolbytesstring),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,字串為空字串)。

對於其他純量欄位類型(包括 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;
}

編譯器將產生一個具有以下存取器方法的 Go struct

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。

重複欄位

對於重複欄位,存取器方法使用 slice 類型。對於具有重複欄位的此訊息

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

編譯器會產生一個具有以下存取器方法的 Go struct

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 欄位都會產生使用類型 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 struct

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 struct

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() 並傳遞產生的 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 編譯器還會產生一個從整數值到字串名稱的 map 和一個從名稱到值的 map

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 是欄位訊息類型。

對於重複擴充欄位,擴充值類型是單數類型的 slice。

例如,給定以下定義

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。