Go 語言 Protobuf

2023-03-22 15:02 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-02-pb-intro.html


4.2 Protobuf

Protobuf 是 Protocol Buffers 的簡稱,它是 Google 公司開發(fā)的一種數(shù)據(jù)描述語言,并于 2008 年對外開源。Protobuf 剛開源時的定位類似于 XML、JSON 等數(shù)據(jù)描述語言,通過附帶工具生成代碼并實現(xiàn)將結(jié)構(gòu)化數(shù)據(jù)序列化的功能。但是我們更關(guān)注的是 Protobuf 作為接口規(guī)范的描述語言,可以作為設計安全的跨語言 PRC 接口的基礎工具。

4.2.1 Protobuf 入門

對于沒有用過 Protobuf 的讀者,建議先從官網(wǎng)了解下基本用法。這里我們嘗試將 Protobuf 和 RPC 結(jié)合在一起使用,通過 Protobuf 來最終保證 RPC 的接口規(guī)范和安全。Protobuf 中最基本的數(shù)據(jù)單元是 message,是類似 Go 語言中結(jié)構(gòu)體的存在。在 message 中可以嵌套 message 或其它的基礎數(shù)據(jù)類型的成員。

首先創(chuàng)建 hello.proto 文件,其中包裝 HelloService 服務中用到的字符串類型:

syntax = "proto3";

package main;

message String {
	string value = 1;
}

開頭的 syntax 語句表示采用 proto3 的語法。第三版的 Protobuf 對語言進行了提煉簡化,所有成員均采用類似 Go 語言中的零值初始化(不再支持自定義默認值),因此消息成員也不再需要支持 required 特性。然后 package 指令指明當前是 main 包(這樣可以和 Go 的包名保持一致,簡化例子代碼),當然用戶也可以針對不同的語言定制對應的包路徑和名稱。最后 message 關(guān)鍵字定義一個新的 String 類型,在最終生成的 Go 語言代碼中對應一個 String 結(jié)構(gòu)體。String 類型中只有一個字符串類型的 value 成員,該成員編碼時用 1 編號代替名字。

在 XML 或 JSON 等數(shù)據(jù)描述語言中,一般通過成員的名字來綁定對應的數(shù)據(jù)。但是 Protobuf 編碼卻是通過成員的唯一編號來綁定對應的數(shù)據(jù),因此 Protobuf 編碼后數(shù)據(jù)的體積會比較小,但是也非常不便于人類查閱。我們目前并不關(guān)注 Protobuf 的編碼技術(shù),最終生成的 Go 結(jié)構(gòu)體可以自由采用 JSON 或 gob 等編碼格式,因此大家可以暫時忽略 Protobuf 的成員編碼部分。

Protobuf 核心的工具集是 C++ 語言開發(fā)的,在官方的 protoc 編譯器中并不支持 Go 語言。要想基于上面的 hello.proto 文件生成相應的 Go 代碼,需要安裝相應的插件。首先是安裝官方的 protoc 工具,可以從 https://github.com/google/protobuf/releases 下載。然后是安裝針對 Go 語言的代碼生成插件,可以通過 go get github.com/golang/protobuf/protoc-gen-go 命令安裝。

然后通過以下命令生成相應的 Go 代碼:

$ protoc --go_out=. hello.proto

其中 go_out 參數(shù)告知 protoc 編譯器去加載對應的 protoc-gen-go 工具,然后通過該工具生成代碼,生成代碼放到當前目錄。最后是一系列要處理的 protobuf 文件的列表。

這里只生成了一個 hello.pb.go 文件,其中 String 結(jié)構(gòu)體內(nèi)容如下:

type String struct {
    Value string `protobuf:"bytes,1,opt,name=value" json:"value,omitempty"`
}

func (m *String) Reset()         { *m = String{} }
func (m *String) String() string { return proto.CompactTextString(m) }
func (*String) ProtoMessage()    {}
func (*String) Descriptor() ([]byte, []int) {
    return fileDescriptor_hello_069698f99dd8f029, []int{0}
}

func (m *String) GetValue() string {
    if m != nil {
        return m.Value
    }
    return ""
}

生成的結(jié)構(gòu)體中還會包含一些以 XXX_ 為名字前綴的成員,我們已經(jīng)隱藏了這些成員。同時 String 類型還自動生成了一組方法,其中 ProtoMessage 方法表示這是一個實現(xiàn)了 proto.Message 接口的方法。此外 Protobuf 還為每個成員生成了一個 Get 方法,Get 方法不僅可以處理空指針類型,而且可以和 Protobuf 第二版的方法保持一致(第二版的自定義默認值特性依賴這類方法)。

基于新的 String 類型,我們可以重新實現(xiàn) HelloService 服務:

type HelloService struct{}

func (p *HelloService) Hello(request *String, reply *String) error {
    reply.Value = "hello:" + request.GetValue()
    return nil
}

其中 Hello 方法的輸入?yún)?shù)和輸出的參數(shù)均改用 Protobuf 定義的 String 類型表示。因為新的輸入?yún)?shù)為結(jié)構(gòu)體類型,因此改用指針類型作為輸入?yún)?shù),函數(shù)的內(nèi)部代碼同時也做了相應的調(diào)整。

至此,我們初步實現(xiàn)了 Protobuf 和 RPC 組合工作。在啟動 RPC 服務時,我們依然可以選擇默認的 gob 或手工指定 json 編碼,甚至可以重新基于 protobuf 編碼實現(xiàn)一個插件。雖然做了這么多工作,但是似乎并沒有看到什么收益!

回顧第一章中更安全的 RPC 接口部分的內(nèi)容,當時我們花費了極大的力氣去給 RPC 服務增加安全的保障。最終得到的更安全的 RPC 接口的代碼本身就非常繁瑣的使用手工維護,同時全部安全相關(guān)的代碼只適用于 Go 語言環(huán)境!既然使用了 Protobuf 定義的輸入和輸出參數(shù),那么 RPC 服務接口是否也可以通過 Protobuf 定義呢?其實用 Protobuf 定義語言無關(guān)的 RPC 服務接口才是它真正的價值所在!

下面更新 hello.proto 文件,通過 Protobuf 來定義 HelloService 服務:

service HelloService {
	rpc Hello (String) returns (String);
}

但是重新生成的 Go 代碼并沒有發(fā)生變化。這是因為世界上的 RPC 實現(xiàn)有千萬種,protoc 編譯器并不知道該如何為 HelloService 服務生成代碼。

不過在 protoc-gen-go 內(nèi)部已經(jīng)集成了一個名字為 grpc 的插件,可以針對 gRPC 生成代碼:

$ protoc --go_out=plugins=grpc:. hello.proto

在生成的代碼中多了一些類似 HelloServiceServer、HelloServiceClient 的新類型。這些類型是為 gRPC 服務的,并不符合我們的 RPC 要求。

不過 gRPC 插件為我們提供了改進的思路,下面我們將探索如何為我們的 RPC 生成安全的代碼。

4.2.2 定制代碼生成插件

Protobuf 的 protoc 編譯器是通過插件機制實現(xiàn)對不同語言的支持。比如 protoc 命令出現(xiàn) --xxx_out 格式的參數(shù),那么 protoc 將首先查詢是否有內(nèi)置的 xxx 插件,如果沒有內(nèi)置的 xxx 插件那么將繼續(xù)查詢當前系統(tǒng)中是否存在 protoc-gen-xxx 命名的可執(zhí)行程序,最終通過查詢到的插件生成代碼。對于 Go 語言的 protoc-gen-go 插件來說,里面又實現(xiàn)了一層靜態(tài)插件系統(tǒng)。比如 protoc-gen-go 內(nèi)置了一個 gRPC 插件,用戶可以通過 --go_out=plugins=grpc 參數(shù)來生成 gRPC 相關(guān)代碼,否則只會針對 message 生成相關(guān)代碼。

參考 gRPC 插件的代碼,可以發(fā)現(xiàn) generator.RegisterPlugin 函數(shù)可以用來注冊插件。插件是一個 generator.Plugin 接口:

// A Plugin provides functionality to add to the output during
// Go code generation, such as to produce RPC stubs.
type Plugin interface {
    // Name identifies the plugin.
    Name() string
    // Init is called once after data structures are built but before
    // code generation begins.
    Init(g *Generator)
    // Generate produces the code generated by the plugin for this file,
    // except for the imports, by calling the generator's methods P, In,
    // and Out.
    Generate(file *FileDescriptor)
    // GenerateImports produces the import declarations for this file.
    // It is called after Generate.
    GenerateImports(file *FileDescriptor)
}

其中 Name 方法返回插件的名字,這是 Go 語言的 Protobuf 實現(xiàn)的插件體系,和 protoc 插件的名字并無關(guān)系。然后 Init 函數(shù)是通過 g 參數(shù)對插件進行初始化,g 參數(shù)中包含 Proto 文件的所有信息。最后的 Generate 和 GenerateImports 方法用于生成主體代碼和對應的導入包代碼。

因此我們可以設計一個 netrpcPlugin 插件,用于為標準庫的 RPC 框架生成代碼:

import (
    "github.com/golang/protobuf/protoc-gen-go/generator"
)

type netrpcPlugin struct{*generator.Generator}

func (p *netrpcPlugin) Name() string                { return "netrpc" }
func (p *netrpcPlugin) Init(g *generator.Generator) { p.Generator = g }

func (p *netrpcPlugin) GenerateImports(file *generator.FileDescriptor) {
    if len(file.Service) > 0 {
        p.genImportCode(file)
    }
}

func (p *netrpcPlugin) Generate(file *generator.FileDescriptor) {
    for _, svc := range file.Service {
        p.genServiceCode(svc)
    }
}

首先 Name 方法返回插件的名字。netrpcPlugin 插件內(nèi)置了一個匿名的 *generator.Generator 成員,然后在 Init 初始化的時候用參數(shù) g 進行初始化,因此插件是從 g 參數(shù)對象繼承了全部的公有方法。其中 GenerateImports 方法調(diào)用自定義的 genImportCode 函數(shù)生成導入代碼。Generate 方法調(diào)用自定義的 genServiceCode 方法生成每個服務的代碼。

目前,自定義的 genImportCode 和 genServiceCode 方法只是輸出一行簡單的注釋:

func (p *netrpcPlugin) genImportCode(file *generator.FileDescriptor) {
    p.P("http:// TODO: import code")
}

func (p *netrpcPlugin) genServiceCode(svc *descriptor.ServiceDescriptorProto) {
    p.P("http:// TODO: service code, Name =" + svc.GetName())
}

要使用該插件需要先通過 generator.RegisterPlugin 函數(shù)注冊插件,可以在 init 函數(shù)中完成:

func init() {
    generator.RegisterPlugin(new(netrpcPlugin))
}

因為 Go 語言的包只能靜態(tài)導入,我們無法向已經(jīng)安裝的 protoc-gen-go 添加我們新編寫的插件。我們將重新克隆 protoc-gen-go 對應的 main 函數(shù):

package main

import (
    "io/ioutil"
    "os"

    "github.com/golang/protobuf/proto"
    "github.com/golang/protobuf/protoc-gen-go/generator"
)

func main() {
    g := generator.New()

    data, err := ioutil.ReadAll(os.Stdin)
    if err != nil {
        g.Error(err, "reading input")
    }

    if err := proto.Unmarshal(data, g.Request); err != nil {
        g.Error(err, "parsing input proto")
    }

    if len(g.Request.FileToGenerate) == 0 {
        g.Fail("no files to generate")
    }

    g.CommandLineParameters(g.Request.GetParameter())

    // Create a wrapped version of the Descriptors and EnumDescriptors that
    // point to the file that defines them.
    g.WrapTypes()

    g.SetPackageNames()
    g.BuildTypeNameMap()

    g.GenerateAllFiles()

    // Send back the results.
    data, err = proto.Marshal(g.Response)
    if err != nil {
        g.Error(err, "failed to marshal output proto")
    }
    _, err = os.Stdout.Write(data)
    if err != nil {
        g.Error(err, "failed to write output proto")
    }
}

為了避免對 protoc-gen-go 插件造成干擾,我們將我們的可執(zhí)行程序命名為 protoc-gen-go-netrpc,表示包含了 netrpc 插件。然后用以下命令重新編譯 hello.proto 文件:

$ protoc --go-netrpc_out=plugins=netrpc:. hello.proto

其中 --go-netrpc_out 參數(shù)告知 protoc 編譯器加載名為 protoc-gen-go-netrpc 的插件,插件中的 plugins=netrpc 指示啟用內(nèi)部唯一的名為 netrpc 的 netrpcPlugin 插件。在新生成的 hello.pb.go 文件中將包含增加的注釋代碼。

至此,手工定制的 Protobuf 代碼生成插件終于可以工作了。

4.2.3 自動生成完整的 RPC 代碼

在前面的例子中我們已經(jīng)構(gòu)建了最小化的 netrpcPlugin 插件,并且通過克隆 protoc-gen-go 的主程序創(chuàng)建了新的 protoc-gen-go-netrpc 的插件程序?,F(xiàn)在開始繼續(xù)完善 netrpcPlugin 插件,最終目標是生成 RPC 安全接口。

首先是自定義的 genImportCode 方法中生成導入包的代碼:

func (p *netrpcPlugin) genImportCode(file *generator.FileDescriptor) {
    p.P(`import "net/rpc"`)
}

然后要在自定義的 genServiceCode 方法中為每個服務生成相關(guān)的代碼。分析可以發(fā)現(xiàn)每個服務最重要的是服務的名字,然后每個服務有一組方法。而對于服務定義的方法,最重要的是方法的名字,還有輸入?yún)?shù)和輸出參數(shù)類型的名字。

為此我們定義了一個 ServiceSpec 類型,用于描述服務的元信息:

type ServiceSpec struct {
    ServiceName string
    MethodList  []ServiceMethodSpec
}

type ServiceMethodSpec struct {
    MethodName     string
    InputTypeName  string
    OutputTypeName string
}

然后我們新建一個 buildServiceSpec 方法用來解析每個服務的 ServiceSpec 元信息:

func (p *netrpcPlugin) buildServiceSpec(
    svc *descriptor.ServiceDescriptorProto,
) *ServiceSpec {
    spec := &ServiceSpec{
        ServiceName: generator.CamelCase(svc.GetName()),
    }

    for _, m := range svc.Method {
        spec.MethodList = append(spec.MethodList, ServiceMethodSpec{
            MethodName:     generator.CamelCase(m.GetName()),
            InputTypeName:  p.TypeName(p.ObjectNamed(m.GetInputType())),
            OutputTypeName: p.TypeName(p.ObjectNamed(m.GetOutputType())),
        })
    }

    return spec
}

其中輸入?yún)?shù)是 *descriptor.ServiceDescriptorProto 類型,完整描述了一個服務的所有信息。然后通過 svc.GetName() 就可以獲取 Protobuf 文件中定義的服務的名字。Protobuf 文件中的名字轉(zhuǎn)為 Go 語言的名字后,需要通過 generator.CamelCase 函數(shù)進行一次轉(zhuǎn)換。類似的,在 for 循環(huán)中我們通過 m.GetName() 獲取方法的名字,然后再轉(zhuǎn)為 Go 語言中對應的名字。比較復雜的是對輸入和輸出參數(shù)名字的解析:首先需要通過 m.GetInputType() 獲取輸入?yún)?shù)的類型,然后通過 p.ObjectNamed 獲取類型對應的類對象信息,最后獲取類對象的名字。

然后我們就可以基于 buildServiceSpec 方法構(gòu)造的服務的元信息生成服務的代碼:

func (p *netrpcPlugin) genServiceCode(svc *descriptor.ServiceDescriptorProto) {
    spec := p.buildServiceSpec(svc)

    var buf bytes.Buffer
    t := template.Must(template.New("").Parse(tmplService))
    err := t.Execute(&buf, spec)
    if err != nil {
        log.Fatal(err)
    }

    p.P(buf.String())
}

為了便于維護,我們基于 Go 語言的模板來生成服務代碼,其中 tmplService 是服務的模板。

在編寫模板之前,我們先查看下我們期望生成的最終代碼大概是什么樣子:

type HelloServiceInterface interface {
    Hello(in String, out *String) error
}

func RegisterHelloService(srv *rpc.Server, x HelloService) error {
    if err := srv.RegisterName("HelloService", x); err != nil {
        return err
    }
    return nil
}

type HelloServiceClient struct {
    *rpc.Client
}

var _ HelloServiceInterface = (*HelloServiceClient)(nil)

func DialHelloService(network, address string) (*HelloServiceClient, error) {
    c, err := rpc.Dial(network, address)
    if err != nil {
        return nil, err
    }
    return &HelloServiceClient{Client: c}, nil
}

func (p *HelloServiceClient) Hello(in String, out *String) error {
    return p.Client.Call("HelloService.Hello", in, out)
}

其中 HelloService 是服務名字,同時還有一系列的方法相關(guān)的名字。

參考最終要生成的代碼可以構(gòu)建如下模板:

const tmplService = `
{{$root := .}}

type {{.ServiceName}}Interface interface {
    {{- range $_, $m := .MethodList}}
    {{$m.MethodName}}(*{{$m.InputTypeName}}, *{{$m.OutputTypeName}}) error
    {{- end}}
}

func Register{{.ServiceName}}(
    srv *rpc.Server, x {{.ServiceName}}Interface,
) error {
    if err := srv.RegisterName("{{.ServiceName}}", x); err != nil {
        return err
    }
    return nil
}

type {{.ServiceName}}Client struct {
    *rpc.Client
}

var _ {{.ServiceName}}Interface = (*{{.ServiceName}}Client)(nil)

func Dial{{.ServiceName}}(network, address string) (
    *{{.ServiceName}}Client, error,
) {
    c, err := rpc.Dial(network, address)
    if err != nil {
        return nil, err
    }
    return &{{.ServiceName}}Client{Client: c}, nil
}

{{range $_, $m := .MethodList}}
func (p *{{$root.ServiceName}}Client) {{$m.MethodName}}(
    in *{{$m.InputTypeName}}, out *{{$m.OutputTypeName}},
) error {
    return p.Client.Call("{{$root.ServiceName}}.{{$m.MethodName}}", in, out)
}
{{end}}
`

當 Protobuf 的插件定制工作完成后,每次 hello.proto 文件中 RPC 服務的變化都可以自動生成代碼。也可以通過更新插件的模板,調(diào)整或增加生成代碼的內(nèi)容。在掌握了定制 Protobuf 插件技術(shù)后,你將徹底擁有這個技術(shù)。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號