Skip to main content

Protocol Buffers

Protocol Buffers

基本机制

  1. 基于.proto文件生成代码(Code Generator),声明了在Go语言中的数据结构。
  2. 对其进行解析编译,把成型的Message进行编码(Encoder),形成二进制数据。
  3. 通过网络传输到达接收方,接收方对二进制数据进行解析并使用。

Google Protocol Buffers

(反)序列化对比

Speed for large data

可以看出,Protocol Buffers基本上是序列化及反序列化中效率最高的一个梯队。

类型映射

Protobuf Buffer中,类型与其它语言的映射:

.proto TypeNotesC++ TypePython TypeGo Type
doubledoublefloatfloat64
floatfloatfloatfloat32
int32使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用sint64替代int32intint32
uint32使用变长编码uint32int/longuint32
uint64使用变长编码uint64int/longuint64
sint32使用变长编码,这些编码在负值时比int32高效的多int32intint32
sint64使用变长编码,符号号的整型值,编码时比通常的int64高效int64intint64
fixed32总是4个字节,如果数值总是比228大的话,这个类型比uint32高效uint32intuint32
fixed64总是8个字节,如果数值总是比256大的话,这个类型比uint64高效uint64int/longuint64
sfixed32总是4个字节int32intint32
sfixed64总是8个字节int64int/longint64
boolboolboolbool
string一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本stringstr/unicodestring
bytes可能包含任意好的字节数据stringstr[]byte

Wire Type

Wire Type表示不同的数据类型和编码方式,它决定了如何在序列化和反序列化过程中对字段的数据进行编码和解码。

  1. 在二进制数据传输的时候需要指定分隔符(Tag),解析时需要对应的分割符才能解析出相应的字段。
  2. 不同的类型有不同的分隔编号。
  3. 识别一个类型是为了转成其它语言,而转成其它语言的时候必须要有一个标识告诉解码器数据的类型,最终转成目标语言对应的数据类型。
TypeMeaningUsed For
0Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum
164-bitfixed64, sfixed64, double
2Length-delimitedstring, bytes, embedded messages, packed repeated fields
3Start groupgroups (deprecated)
4End groupgroups (deprecated)
532-bitfixed32, sfixed32, float

序列化格式

JSON
{
"name": "Tom",
"age": 28,
"is_marriage": false,
}

如果要修改某个字段,先要解析,修改之后再进行字符串化:

var info = JSON.parse({
"name": "Tom",
"age": 28,
"is_marriage": false,
})

info.age = 26

var infoStr = JSON.stringify(info)
/*
{
"name": "Tom",
"age": 26,
"is_marriage": false,
}
*/

JSON是静态数据,所有的字符都要进行二进制转换后传输,所以体积相对较大。

XML
<name>Tom</name>
<age>28</age>
<is-marriage>false</is-marriage>

XML也是静态数据,数据会全部传递,因此体积也相对较大。

Protobuf

Protobuf是一门语言,称作IDL(Interface Description Language),即接口描述语言。

syntax="proto3";
option go_package="./;proto";

message MyInfo {
// 类型 字段名 字段编号
string name = 1;
int32 age = 2;
bool isMarriage = 3;
}

如上接口定义,编译后会变成:

type MyInfo struct {
// 字段名 类型 编号 字段名 兼容json
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Age int32 `protobuf:"varint,2,opt,name=age,proto3" json:"age,omitempty"`
IsMarriage bool `protobuf:"varint,3,opt,name=isMarriage,proto3" json:"isMarriage,omitempty"`
}

传输时的真正数据:

info := MyInfo {
Name: "Tom",
Age: 28,
IsMarriage: false
}
字段编号

字段编号,即fieldNumber,为了在传输数据过程中传递字段编号而不传递字段名。

// 以 string name = 1 为例
// 字段编号的二进制: 00000000 00000000 00000000 00000001 (4字节)
// 字段名的二进制: 01001110 01100001 01101101 01100101 (4字节)

字段编号和字段名最终都是要以二进制传输,但问题在于字段编号形式的二进制可优化,而字段名形式的很难优化。

那么会产生一个问题,如果不传字段名,通信时接收方是如何知道传递的字段名呢?

服务端和客户端维护同一个.proto文件,由于两者都是通过同一个.proto文件来转换的,因此字段名和字段编号的映射一致,只需通过字段编号找到对应的字段名即可。

优化分析
varint

varint,即Variable-Width Integer,可变长编码。

// age: 
// fieldNumber => 2
// => 00000000 00000000 00000000 00000010
// => 00000010

// value => 28
// => 00000000 00000000 00000000 00011100
// => 00011100

// sign bit => most significant bit => MSB
// value + fieldNumber
// 10011100 + 00000010

每一个字节二进制的首位是标志位(sign bit):

  1. 如果标志位是1,则当前一组的value-key还有下一个字节。
  2. 如果标志位是0,则当前字节是fieldNumber,本组结束。
  3. true会变成int321false会变成int320,因此bool类型同样适用于varint
Zigzag

Zigzag是一种编码,主要用来对负数进行编码。

// 以 -1 为例
// 2 * abs(负数) - 1 => 2 * abs(-1) - 1 => 1

如上,对-1编码后的结果是1,传输1的二进制即可。

使用sint类型即可激活Zigzag编码。

TLV

TLV是针对字符串编码的一种方式,字符串在编码过程中存在TLV模式。

字符串编码时保存Length的原因是要知道其Value有多少位。

/**
Name:
Tag Length Value => TLV编码

最终编码形式:
tag value field + tag length value + tag value field

Tag演算:
Tag => field << 3 | wireType
=> 1 << 3 | 2
=> 00000001 << 3 | 2
=> 00001000 | 00000010
=> 00001010

Tag Length(Value) Value(Field)
Name: 00001010 00001100 01010100 01101111 01101101
Age: 00010000 10011100 00000010
IsMarriage: 00011000 10000001 00000011
*/

PB文件分析

syntax="proto3";
option go_package="./;proto";

// struct
message MyInfoRequest {
string name = 1;
int32 age = 2;
bool isMarriage = 3;
}

// struct
message MyInfoResponse {
string name = 1;
int32 age = 2;
bool isMarriage = 3;
}

// interface
service MyInfo {
rpc getData (MyInfoRequest) returns (MyInfoResponse);
}

// potoc -I . info.proto --go_out=plugins=grpc:.

结构体

.proto中的message最终会编译成结构体,并添加额外的字段:

type MyInfoRequest struct {
// 消息反射, 当前请求的消息(数据)
state protoimpl.MessageState

// 编码后总长度
sizeCache protoimpl.SizeCache

// 未成功解析的字段会放入此结构
unknownFields protoimpl.UnknownFields

/**
对protobuf的描述:
protobuf:"bytes,1,opt,name=name,proto3"
=> type: bytes,
=> fieldNumber: 1,
=> opt,
=> fieldName: name, // 传输过程中字段的名称
=> syntax: proto3

对json的描述:
json:"name,omitempty"
=> fieldName: name,
=> option: omitempty(忽略空)
*/
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Age int32 `protobuf:"varint,2,opt,name=age,proto3" json:"age,omitempty"`
IsMarriage bool `protobuf:"varint,3,opt,name=isMarriage,proto3" json:"isMarriage,omitempty"`
}

omitempty

在对json的描述中,存在一个omitempty选项,该选项的作用是忽略结构体没有显示传入的字段,而不是使用该类型的默认值。

func main() {
infos := proto.MyInfoRequest{
Name: "Tom",
IsMarriage: true,
}

jd, _ := json.Marshal(infos)
// {"name":"Tom","isMarriage":true}
fmt.Println(string(jd))
}

此时会产生一个问题:如果一个字段的值本身就是其类型对应的默认值,比如int32的值为0,按照当前逻辑,会依然忽略该字段,这样就导致了数据的丢失。

很遗憾,目前为止,Protobuf层面还没有一个很好的办法解决此问题,但是可以间接地修改:

  1. 手动替换。对生成的.pb.go进行手动替换omitempty
  2. 编译命令修改。原理是,在生成.pb.go文件后,利用脚本批量替换。
protoc -I . info.proto --go_out=plugins=grpc:. && ls *.pb.go | xargs -n1 -IX bash -c 'sed s/,omitempty// X > X.tmp && mv X{.tmp,}'

操作方法

Reset

重置所有字段为对应类型的默认值:

func main() {
infos := proto.MyInfoRequest{
Name: "Tom",
IsMarriage: true,
}

info.Reset()
fmt.Println(info)
}
String

字符串化方法:

func main() {
infos := proto.MyInfoRequest{
Name: "Tom",
IsMarriage: true,
}

// name:"Tom" isMarriage:true
fmt.Println(info.String())
}
Getter

对外暴露的访问属性值的方法:

func (x *MyInfoRequest) GetName() string {}

func (x *MyInfoRequest) GetAge() int32 {}

func (x *MyInfoRequest) GetIsMarriage() bool {}

File Descriptor

可通过以下方法访问proto的文件描述信息:

fmt.Println(proto.File_info_proto)

fmt.Println(info.ProtoReflect().Descriptor())

除此之外,还有对应的字节流形式:

var file_info_proto_rawDesc = []byte{
0x0a, 0x0a, 0x69, 0x6e, 0x66, 0x6f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x55, 0x0a, 0x0d,
0x4d, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a,
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
0x65, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03,
0x61, 0x67, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x73, 0x4d, 0x61, 0x72, 0x72, 0x69, 0x61, 0x67,
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69, 0x73, 0x4d, 0x61, 0x72, 0x72, 0x69,
0x61, 0x67, 0x65, 0x22, 0x56, 0x0a, 0x0e, 0x4d, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x67, 0x65,
0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x69,
0x73, 0x4d, 0x61, 0x72, 0x72, 0x69, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52,
0x0a, 0x69, 0x73, 0x4d, 0x61, 0x72, 0x72, 0x69, 0x61, 0x67, 0x65, 0x32, 0x34, 0x0a, 0x06, 0x4d,
0x79, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2a, 0x0a, 0x07, 0x67, 0x65, 0x74, 0x44, 0x61, 0x74, 0x61,
0x12, 0x0e, 0x2e, 0x4d, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x0f, 0x2e, 0x4d, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x42, 0x0a, 0x5a, 0x08, 0x2e, 0x2f, 0x3b, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
}

grpc相关

客户端
// 客户端接口定义
type MyInfoClient interface {
GetData(ctx context.Context, in *MyInfoRequest, opts ...grpc.CallOption) (*MyInfoResponse, error)
}

// 客户端grpc连接结构体
type myInfoClient struct {
cc grpc.ClientConnInterface
}

// 新的客户端 返回的客户端连接需要实现接口定义的方法
func NewMyInfoClient(cc grpc.ClientConnInterface) MyInfoClient {
return &myInfoClient{cc}
}

// interface实现
func (c *myInfoClient) GetData(ctx context.Context, in *MyInfoRequest, opts ...grpc.CallOption) (*MyInfoResponse, error) {
// 响应
out := new(MyInfoResponse)
// 远程调用
err := c.cc.Invoke(ctx, "/MyInfo/getData", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
客户端流程
  1. 向服务端拨号。
  2. 实例化一gRPC客户端。
  3. 调用客户端方法。
  4. 取响应信息。
服务端
// 服务端接口定义
type MyInfoServer interface {
GetData(context.Context, *MyInfoRequest) (*MyInfoResponse, error)
}

// 注册服务
func RegisterMyInfoServer(s *grpc.Server, srv MyInfoServer) {
s.RegisterService(&_MyInfo_serviceDesc, srv)
}

// getData方法处理函数
func _MyInfo_GetData_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MyInfoRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MyInfoServer).GetData(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/MyInfo/GetData",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MyInfoServer).GetData(ctx, req.(*MyInfoRequest))
}
return interceptor(ctx, in, info, handler)
}

// 服务相关信息
var _MyInfo_serviceDesc = grpc.ServiceDesc{
// 服务名
ServiceName: "MyInfo",
// 处理类型
HandlerType: (*MyInfoServer)(nil),
// 远程调用对外的方法
Methods: []grpc.MethodDesc{
{
MethodName: "getData",
Handler: _MyInfo_GetData_Handler,
},
},
Streams: []grpc.StreamDesc{},
// 元数据
Metadata: "info.proto",
}
服务端流程
  1. 创建结构体。
  2. 实现接口方法。
  3. 创建gRPC服务。
  4. 注册当前的服务。
  5. 监听端口。
  6. 启动gRPC服务。

Protobuf语法

编译命令

// -I . => --proto_path="./"
// go_out => ./
protoc -I . info.proto --go_out=plugins=:.

// 新版本编译
// protoc -I . xxx.proto --go_out=:. --go-grpc_out=:.

package

// info.pb.go => ./ => package proto
option go_package = "./;proto"

// info.pb.go -> ./proto => package proto
option go_package = "/proto"

// info.pb.go => ./protobuf/info/v1 package v1
option go_package="protobuf/info/v1";

// info.pb.go => ../ package proto
option go_package="../;proto";

import

import可用来导入自定义.proto或内置.proto,方便.proto的共享:

syntax = "proto3";
option go_package = "./;proto";

// 导入自定义 proto
import "userBehavior.proto";

// 导入内置 proto
import "google/protobuf/empty.proto";

message ProductRequest {
int64 pid = 1;
}

message ProductResponse {
int64 pid = 1;
string product_name = 2;
float price = 3;
}

message ProductList {
repeated ProductResponse productList = 1;
}

service Product {
rpc GetProduct (ProductRequest) returns (ProductResponse);

// 使用内置proto必须从按包名查找
rpc GetProductList (google.protobuf.Empty) returns (ProductList);

// 自定义proto直接使用
rpc UpdateUserBehavior (BehaviorUpdate) returns (BehaviorFormat);
}

import导入的路径中是一个虚拟路径,不允许使用./、../等相对路径,而是以包为单位的。

通常情况下一个项目的.proto放在同一个文件目录,如果放在不同的目录下,导入时需要通过包的形式导入:

syntax = "proto3";
option go_package = "./;proto";

// 通过包名的形式导入
import "proto2/userBehavior.proto";

import "google/protobuf/empty.proto";

message ProductRequest {
int64 pid = 1;
}

message ProductResponse {
int64 pid = 1;
string product_name = 2;
float price = 3;
}

message ProductList {
repeated ProductResponse productList = 1;
}

service Product {
rpc GetProduct (ProductRequest) returns (ProductResponse);

// 使用内置proto必须从按包名查找
rpc GetProductList (google.protobuf.Empty) returns (ProductList);

// 自定义proto直接使用
rpc UpdateUserBehavior (BehaviorUpdate) returns (BehaviorFormat);
}

但在进行单个.proto文件编译时会报错,这是因为仍然找不到对应的文件,因此最佳实践是将所有的.pb.go文件编译到同一个目录下:

# 切换到proto目录的公共上级目录
cd ../

# 进行批量编译
protoc -I . ./**/*.proto --go_out=plugins=:./pb/

数据类型

  1. 使用repeated关键字来声明数组。
  2. map使用map<KeyValue, ValueType>的形式。
  3. 枚举使用enum关键字声明,必须为整数且从0开始。
  4. 日期、时间戳可使用内置的google/protobuf/Timestamp.proto
syntax = "proto3";
option go_package = "./;proto";

import "google/protobuf/Timestamp.proto";

// 枚举
enum CourseType {
GO = 0;
JAVA = 1;
RUST = 2;
}

message CourseInfo {
int32 cid = 1;
string cname = 2;
string teacher = 3;

// 时间戳
// 编译后类型为 timestamppb
google.protobuf.Timestamp time = 4;
}

message CourseRequest {
CourseType type = 1;
}

message CourseResponse {
int32 code = 1;
string msg = 2;

// 数组
repeated CourseInfo data = 3;

// map
map<string, string> extra = 4;

// 嵌套message
// 可在外界通过父级一层一层访问:CourseResponse.CourseStatics
message CourseStatics {
int32 scount = 5;
int32 ccount = 6;
}

// 多选一
oneof Subjet {
int32 Chinese = 7;
int32 Math = 8;
}
}

message Test {
CourseResponse.CourseStatics statics = 1;
// 编译后在goz中
// CourseResponse_CourseStatics
}

service Course {
rpc GetCourseList (CourseRequest) returns (CourseResponse);
}