Protobuf 的使用和了解

3.2 Protobuf 的使用和了解

3.2.1 安装

3.2.1.1 protoc 安装

在 gRPC 开发中,我们常常需要与 Protobuf 进行打交道,而在编写了.proto 文件后,我们会需要到一个编译器,那就是 protoc,protoc 是 Protobuf 的编译器,是用 C++ 所编写的,其主要功能是用于编译.proto 文件。

接下来我们进行 protoc 的安装,在命令行下执行安装命令(需要依赖一些库,可根据错误提示搜索并进行依赖库的安装):

$ wget https://github.com/google/protobuf/releases/download/v3.11.2/protobuf-all-3.11.2.zip
$ unzip protobuf-all-3.11.2.zip && cd protobuf-3.11.2/
$ ./configure
$ make
$ make install

检查是否安装成功,如下:

$ protoc --version

如果出现如下类似报错:

protoc: error while loading shared libraries: libprotobuf.so.15: cannot open shared object file: No such file or directory

则执行在命令行执行 ldconfig 命令后,再次运行即可成功。但这是为什么呢,为什么要执行这条命令才能够正常运行 protoc 命令呢,我们可以通过安装时的控制台输出的信息得知,Protocol Buffers Libraries 的默认安装路径在 /usr/local/lib 下,如下:

Libraries have been installed in:
   /usr/local/lib
...

实际上在安装了 protoc 后,我们同时安装了一个新的动态链接库,而 ldconfig 命令一般默认在系统启动时运行,所以在特定情况下会找不到这个新安装的 lib,因此我们要手动执行 ldconfig,让动态链接库为系统所共享,它是一个动态链接库管理命令,这就是 ldconfig 命令的作用。

3.2.1.2 protoc 插件安装

我们在上一步安装了 protoc 编译器,但是还是不够的,针对不同的语言,还需要不同的运行时的 protoc 插件,那么对应 Go 语言就是 protoc-gen-go 插件,接下来可以在命令行执行如下安装命令:

$ go get -u github.com/golang/protobuf/protoc-gen-go@v1.3.2

同时 protoc-gen-go 因为一直处于更新迭代的过程中,如果不锁定版本,随着时间的推移,很有可能会出现不兼容的情况(因为需要与 proto 软件包版本相匹配),这是非常麻烦的,因此我们也可以通过如下命令进行安装:

$ GIT_TAG="v1.3.2"
$ go get -d -u github.com/golang/protobuf/protoc-gen-go
$ git -C "$(go env GOPATH)"/src/github.com/golang/protobuf checkout $GIT_TAG
$ go install github.com/golang/protobuf/protoc-gen-go

将所编译安装的 Protoc Plugin 的可执行文件中移动到相应的 bin 目录下,例如:

$ mv $GOPATH/bin/protoc-gen-go /usr/local/go/bin/

这里的命令操作并非是绝对必须的,主要目的是将二进制文件 protoc-gen-go 移动到 bin 目录下,让其可以直接运行 protoc-gen-go 执行,只要达到这个效果就可以了。

3.2.2 初始化 Demo 项目

接下来我们初始化一个 gRPC 专用的 Demo 项目,用于演示后续的 gRPC 和 Protobuf 应用,执行下述命令:

$ mkdir -p $HOME/go-programming-tour-book/grpc-demo
$ cd $HOME/go-programming-tour-book/grpc-demo
$ go mod init github.com/go-programming-tour-book/grpc-demo

在初始化目录结构后,新建 server、client、proto 目录,便于后续的使用,最终目录结构如下:

grpc-demo
├── go.mod
├── client
├── proto
└── server

3.2.3 编译和生成 proto 文件

3.2.3.1 创建 proto 文件

我们在项目的 proto 目录下新建 helloworld.proto 文件,写入如下声明:

syntax = "proto3";

package helloworld;

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
    string name = 1;
}

message HelloReply {
    string message = 1;
}

3.2.3.2 生成 proto 文件

接下来我们在项目的根目录下,执行 protoc 的相关命令来生成对应的 pb.go 文件,如下:

$ protoc --go_out=plugins=grpc:. ./proto/*.proto 
  • –go_out:设置所生成 Go 代码输出的目录,该指令会加载 protoc-gen-go 插件达到生成 Go 代码的目的,生成的文件以 .pb.go 为文件后缀,在这里 “:”(冒号)号充当分隔符的作用,后跟命令所需要的参数集,在这里代表着要将所生成的 Go 代码输出到所指向 protoc 编译的当前目录。
  • plugins=plugin1+plugin2:指定要加载的子插件列表,我们定义的 proto 文件是涉及了 RPC 服务的,而默认是不会生成 RPC 代码的,因此需要在 go_out 中给出 plugins 参数传递给 protoc-gen-go,告诉编译器,请支持 RPC(这里指定了内置的 grpc 插件)。

在执行这条命令后,就会生成此 proto 文件的对应.pb.go 文件,如下:

helloworld.pb.go helloworld.proto

3.2.3.3 生成的.pb.go 文件

我们查看刚刚所生成的 helloworld.pb.go 文件,pb.go 文件是针对 proto 文件所生成的对应的 Go 语言代码,是我们实际在应用中将会引用到的文件,我们一起看看生成出来的文件提供了什么功能,代码如下:

type HelloRequest struct {
	Name                 string   `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
	...
}

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

在上述代码中,主要涉及针对 HelloRequest 类型,其包含了一组 Getters 方法,能够提供便捷的取值方式,并且处理了一些空指针取值的情况,还能够通过 Reset 方法来重置该参数。而该方法通过实现 ProtoMessage 方法,以此表示这是一个实现了 proto.Message 的接口。另外 HelloReply 类型也是类似的生成结果,因此不重复概述。

接下来我们看到.pb.go 文件的初始化方法,其中比较特殊的就是 fileDescriptor 的相关语句,如下:

func init() {
	proto.RegisterType((*HelloRequest)(nil), "helloworld.HelloRequest")
	proto.RegisterType((*HelloReply)(nil), "helloworld.HelloReply")
}

func init() { proto.RegisterFile("proto/helloworld.proto", fileDescriptor_4d53fe9c48eadaad) }

var fileDescriptor_4d53fe9c48eadaad = []byte{
	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0x2b, 0x28, 0xca, 0x2f,
	...
}

实际上我们所看到的 fileDescriptor_4d53fe9c48eadaad 表示的是一个经过编译后的 proto 文件,是对 proto 文件的整体描述,其包含了 proto 文件名、引用(import)内容、包(package)名、选项设置、所有定义的消息体(message)、所有定义的枚举(enum)、所有定义的服务( service)、所有定义的方法(rpc method)等等内容,可以认为就是整个 proto 文件的信息你都能够取到。

同时在我们的每一个 Message Type 中都包含了 Descriptor 方法,Descriptor 代指对一个消息体(message)定义的描述,而这一个方法则会在 fileDescriptor 中寻找属于自己 Message Field 所在的位置再进行返回,如下:

func (*HelloRequest) Descriptor() ([]byte, []int) {
	return fileDescriptor_4d53fe9c48eadaad, []int{0}
}

func (*HelloReply) Descriptor() ([]byte, []int) {
	return fileDescriptor_4d53fe9c48eadaad, []int{1}
}

接下来我们再往下看可以看到 GreeterClient 接口,因为 Protobuf 是客户端和服务端可共用一份.proto 文件的,因此除了存在数据描述的信息以外,还会存在客户端和服务端的相关内部调用的接口约束和调用方式的实现,在后续我们在多服务内部调用的时候会经常用到,如下:

type GreeterClient interface {
	SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}

type greeterClient struct {
	cc *grpc.ClientConn
}

func NewGreeterClient(cc *grpc.ClientConn) GreeterClient {
	return &greeterClient{cc}
}

func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
	out := new(HelloReply)
	err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

3.2.4 更多的类型支持

在前面的例子中,我们已经大致了解到 Protobuf 的基本使用和内部情况,而其本身也支持了很多的数据类型,在本节我们将挑选一些常用的类型进行讲解。

3.2.4.1 通用类型

在 Protobuf 中一共支持 double、float、int32、int64、uint32、uint64、sint32、sint64、fixed32、fixed64、sfixed32、sfixed64、bool、string、bytes 类型,例如一开始使用的是字符串类型,当然你也可以根据实际情况,修改成上述类型,例如:

message HelloRequest {
    bytes name = 1;
}

另外我们常常会遇到需要传递动态数组的情况,在 protobuf 中,我们可以使用 repeated 关键字,如果一个字段被声明为 repeated,那么该字段可以重复任意次(包括零次),重复值的顺序将保留在 protobuf 中,将重复字段视为动态大小的数组,如下:

message HelloRequest {
    repeated string name = 1;
}

3.2.4.2 嵌套类型

嵌套类型,也就是字面意思,在 message 消息体中,又嵌套了其它的 message 消息体,一共有两种模式,如下:

message HelloRequest {
    message World {
        string name = 1;
    }
    
    repeated World worlds = 1;
}

第一种是将 World 消息体定义在 HelloRequest 消息体中,也就是其归属在消息体 HelloRequest 下,若要调用则需要使用 HelloRequest.World 的方式,外部才能引用成功。

第二种是将 World 消息体定义在外部,一般比较推荐使用这种方式,清晰、方便,如下:

message World {
    string name = 1;
}

message HelloRequest {
    repeated World worlds = 1;
}

3.2.4.3 Oneof

如果你希望你的消息体可以包含多个字段,但前提条件是最多同时只允许设置一个字段,那么就可以使用 oneof 关键字来实现这个功能,如下:

message HelloRequest {
    oneof name {
        string nick_name = 1;
        string true_name = 2;
    }
}

3.2.4.4 Enum

枚举类型,限定你所传入的字段值必须是预定义的值列表之一,如下:

enum NameType {
    NickName = 0;
    TrueName = 1;
}

message HelloRequest {
    string name = 1;
    NameType nameType = 2;
}

3.2.4.5 Map

map 类型,需要设置键和值的类型,格式为 map<key_type, value_type> map_field = N;,示例如下:

message HelloRequest {
    map<string, string> names = 2;
}

3.2.5 小结

在本章节中,我们对 Protobuf 进行了更详细的使用和说明,我们可得知.proto 文件需要通过 Protobuf 的编译器 protoc 来编译后才能够使用,而在各个语言的具体插件实现中,protoc-gen-go 是 protoc 中针对 Go 语言的 protoc plugin,它们是相对隔离且解耦的,因此在未来我们也可以自己实现一个 protoc plugin,针对企业内部的定制化需求非常的方便。

另外在 Protobuf 的类型使用上,其支持大量的类型,我在上文中只列举了在应用开发中常见的,大家可以根据实际需求再进一步的了解。



本图书由 煎鱼 ©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。