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

4.2.1 Protobuf 入門(mén)

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

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

syntax = "proto3";

package main;

message String {
	string value = 1;
}

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

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

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

然后通過(guò)以下命令生成相應(yīng)的 Go 代碼:

$ protoc --go_out=. hello.proto

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

這里只生成了一個(gè) 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)體中還會(huì)包含一些以 XXX_ 為名字前綴的成員,我們已經(jīng)隱藏了這些成員。同時(shí) String 類(lèi)型還自動(dòng)生成了一組方法,其中 ProtoMessage 方法表示這是一個(gè)實(shí)現(xiàn)了 proto.Message 接口的方法。此外 Protobuf 還為每個(gè)成員生成了一個(gè) Get 方法,Get 方法不僅可以處理空指針類(lèi)型,而且可以和 Protobuf 第二版的方法保持一致(第二版的自定義默認(rèn)值特性依賴(lài)這類(lèi)方法)。

基于新的 String 類(lèi)型,我們可以重新實(shí)現(xiàn) HelloService 服務(wù):

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 類(lèi)型表示。因?yàn)樾碌妮斎雲(yún)?shù)為結(jié)構(gòu)體類(lèi)型,因此改用指針類(lèi)型作為輸入?yún)?shù),函數(shù)的內(nèi)部代碼同時(shí)也做了相應(yīng)的調(diào)整。

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

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

下面更新 hello.proto 文件,通過(guò) Protobuf 來(lái)定義 HelloService 服務(wù):

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

但是重新生成的 Go 代碼并沒(méi)有發(fā)生變化。這是因?yàn)槭澜缟系?RPC 實(shí)現(xiàn)有千萬(wàn)種,protoc 編譯器并不知道該如何為 HelloService 服務(wù)生成代碼。

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

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

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

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

4.2.2 定制代碼生成插件

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

參考 gRPC 插件的代碼,可以發(fā)現(xiàn) generator.RegisterPlugin 函數(shù)可以用來(lái)注冊(cè)插件。插件是一個(gè) 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 語(yǔ)言的 Protobuf 實(shí)現(xiàn)的插件體系,和 protoc 插件的名字并無(wú)關(guān)系。然后 Init 函數(shù)是通過(guò) g 參數(shù)對(duì)插件進(jìn)行初始化,g 參數(shù)中包含 Proto 文件的所有信息。最后的 Generate 和 GenerateImports 方法用于生成主體代碼和對(duì)應(yīng)的導(dǎo)入包代碼。

因此我們可以設(shè)計(jì)一個(gè) netrpcPlugin 插件,用于為標(biāo)準(zhǔn)庫(kù)的 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)置了一個(gè)匿名的 *generator.Generator 成員,然后在 Init 初始化的時(shí)候用參數(shù) g 進(jìn)行初始化,因此插件是從 g 參數(shù)對(duì)象繼承了全部的公有方法。其中 GenerateImports 方法調(diào)用自定義的 genImportCode 函數(shù)生成導(dǎo)入代碼。Generate 方法調(diào)用自定義的 genServiceCode 方法生成每個(gè)服務(wù)的代碼。

目前,自定義的 genImportCode 和 genServiceCode 方法只是輸出一行簡(jiǎn)單的注釋?zhuān)?

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

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

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

因?yàn)?Go 語(yǔ)言的包只能靜態(tài)導(dǎo)入,我們無(wú)法向已經(jīng)安裝的 protoc-gen-go 添加我們新編寫(xiě)的插件。我們將重新克隆 protoc-gen-go 對(duì)應(yīng)的 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")
    }
}

為了避免對(duì) 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 自動(dòng)生成完整的 RPC 代碼

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

首先是自定義的 genImportCode 方法中生成導(dǎo)入包的代碼:

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

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

為此我們定義了一個(gè) ServiceSpec 類(lèi)型,用于描述服務(wù)的元信息:

type ServiceSpec struct {
    ServiceName string
    MethodList  []ServiceMethodSpec
}

type ServiceMethodSpec struct {
    MethodName     string
    InputTypeName  string
    OutputTypeName string
}

然后我們新建一個(gè) buildServiceSpec 方法用來(lái)解析每個(gè)服務(wù)的 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 類(lèi)型,完整描述了一個(gè)服務(wù)的所有信息。然后通過(guò) svc.GetName() 就可以獲取 Protobuf 文件中定義的服務(wù)的名字。Protobuf 文件中的名字轉(zhuǎn)為 Go 語(yǔ)言的名字后,需要通過(guò) generator.CamelCase 函數(shù)進(jìn)行一次轉(zhuǎn)換。類(lèi)似的,在 for 循環(huán)中我們通過(guò) m.GetName() 獲取方法的名字,然后再轉(zhuǎn)為 Go 語(yǔ)言中對(duì)應(yīng)的名字。比較復(fù)雜的是對(duì)輸入和輸出參數(shù)名字的解析:首先需要通過(guò) m.GetInputType() 獲取輸入?yún)?shù)的類(lèi)型,然后通過(guò) p.ObjectNamed 獲取類(lèi)型對(duì)應(yīng)的類(lèi)對(duì)象信息,最后獲取類(lèi)對(duì)象的名字。

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

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

為了便于維護(hù),我們基于 Go 語(yǔ)言的模板來(lái)生成服務(wù)代碼,其中 tmplService 是服務(wù)的模板。

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

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 是服務(wù)名字,同時(shí)還有一系列的方法相關(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}}
`

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



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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)