学习 Protocol Buffers(protobuf)


Protocol buffers 协议缓冲区提供了一种语言中立、平台中立、可扩展的机制,用于以前向兼容和后向兼容的方式序列化结构化数据。它类似于JSON,只是它更小更快,并且生成本地语言绑定。

以上是关于 Protocol buffers 的官方介绍,本篇笔记用于整理它的语法和基本使用方式,本次学习基于 Golang 语言。

Quick Start

内容来自: Protocol Buffer Basics: Go,详细描述参考原文档。

下载 protoc 编译器

protoc 工具用于编译 .proto 文件

下载地址:https://github.com/protocolbuffers/protobuf/releases

# 下载
$ wget https://github.com/protocolbuffers/protobuf/releases/download/v21.9/protoc-21.9-linux-x86_64.zip

# 解压缩
$ unzip protoc-21.9-linux-x86_64.zip -d protoc
$ chmod 755 -R protoc
$ sudo cp protoc/bin/protoc /usr/bin
$ sudo cp -R protoc/include/* /usr/bin/include

查看版本

$ protoc --version
# libprotoc 3.21.9

下载 Go protocol buffers plugin

$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

需要确认 Go 的 bin 目录在 PATH 中,编辑 ~/.bashrc 文件,在底部添加上 Go bin 目录的路径

export PATH=$PATH:/home/opc/go/bin

重载配置使其生效

$ source ~/.bashrc

查看版本

$ protoc-gen-go --version
# protoc-gen-go v1.28.1

环境准备妥当,接下来编写一个简单的 Proto 文件,用于描述数据结构。

使用 Proto 文件生成数据结构

接下来定一个 proto 文件,编译文件生成目标程序文件,最终在程序中使用。

person.proto

syntax = "proto3"; 

package tutorial;

option go_package = "github.com/protocolbuffers/protobuf/examples/go/tutorialpb";

message Person {
  string name = 1;
  int32 id = 2;  // Unique ID number for this person.
  string email = 3;
}

编译 person.proto

protoc --go_out=. person.proto

此时可以看到当前目录出现一个 github.com 文件夹,这是根据 proto 文件中 go_package 字段指定的。在层层嵌套的文件夹下,person.pb.go 就是我们需用的程序文件。

person.pb.go (部分生成代码已省略,未粘贴到此处)

// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
//  protoc-gen-go v1.28.1
//  protoc        v3.21.9
// source: person.proto

package pb_struct

import (
    protoreflect "google.golang.org/protobuf/reflect/protoreflect"
    protoimpl "google.golang.org/protobuf/runtime/protoimpl"
    reflect "reflect"
    sync "sync"
)

const (
    // Verify that this generated code is sufficiently up-to-date.
    _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
    // Verify that runtime/protoimpl is sufficiently up-to-date.
    _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)

type Person struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Name  string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
    Id    int32  `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` // Unique ID number for this person.
    Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"`
}

func (x *Person) Reset() {
    *x = Person{}
    if protoimpl.UnsafeEnabled {
        mi := &file_person_proto_msgTypes[0]
        ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
        ms.StoreMessageInfo(mi)
    }
}

func (x *Person) String() string {
    return protoimpl.X.MessageStringOf(x)
}

func (*Person) ProtoMessage() {}

func (x *Person) ProtoReflect() protoreflect.Message {
    mi := &file_person_proto_msgTypes[0]
    if protoimpl.UnsafeEnabled && x != nil {
        ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
        if ms.LoadMessageInfo() == nil {
            ms.StoreMessageInfo(mi)
        }
        return ms
    }
    return mi.MessageOf(x)
}

// Deprecated: Use Person.ProtoReflect.Descriptor instead.
func (*Person) Descriptor() ([]byte, []int) {
    return file_person_proto_rawDescGZIP(), []int{0}
}

func (x *Person) GetName() string {
    if x != nil {
        return x.Name
    }
    return ""
}

func (x *Person) GetId() int32 {
    if x != nil {
        return x.Id
    }
    return 0
}

func (x *Person) GetEmail() string {
    if x != nil {
        return x.Email
    }
    return ""
}

生成的程序文件提供了一些结构和方法,例如: Person 结构体、GetEmail() 获取邮箱等。

引入文件后,我们可以使用这个结构体,跟我们自己定义的没什么不同,可以借助 json 序列化,也可以使用特有的 proto 序列化。

func TestPersonStruct(t *testing.T) {
    p := &pb.Person{
        Id:    1234,
        Name:  "DongDong",
        Email: "dong@example.com",
    }
    p.Name = "DongDong (QAQ)"

    // 获取属性的方法
    fmt.Println(p.GetEmail())

    b, err := json.Marshal(p)
    if err != nil {
        log.Fatal("json marshal failed!")
    }
    fmt.Println(b, len(b))

    b, err = proto.Marshal(p)
    if err != nil {
        log.Fatal("proto marshal failed!")
    }
    fmt.Println(b, len(b))
}

输出如下

[123 34 110 97 109 101 34 58 34 68 111 110 103 68 111 110 103 32 40 81 65 81 41 34 44 34 105 100 34 58 49 50 51 52 44 34 101 109 97 105 108 34 58 34 100 111 110 103 64 101 120 97 109 112 108 101 46 99 111 109 34 125] 62
[10 14 68 111 110 103 68 111 110 103 32 40 81 65 81 41 16 210 9 26 16 100 111 110 103 64 101 120 97 109 112 108 101 46 99 111 109] 37

通过以上结果,可以进一步感受到 Protobuf 结构的优势,编码后的数据量更少,对资源的消耗也就更小,这在即时通讯等领域相较于 JSON 和 XML 有很大的优势。

到目前为止,我们对 Protobuf 有了一些直观的了解,接下来更多的了解一些 Protocol Buffers 语法规则。

Protocol Buffers 语法规则

文档请参考:https://developers.google.com/protocol-buffers/docs/proto3

定义一个 Message 消息

首行的 proto3 如果未声明,则默认使用 proto2,需要注意的是其必须放在文件的首行,在它的上方不能有空行和注释。

syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

在这个例子中,所有的数据类型都是 scalar types(标量类型),两个整数一个字符串,同时也可以定义 composite types(复合类型),包含其它的数据类型。

分配的字段值

上例中数据被分配了 1, 2, 3 编号,这个编号用于识别二进制格式中的字段。1 ~ 15 的编号使用一个字节来编码,16 ~ 2047 使用两个字节,请为频繁使用的字段指定小编码,同时也应该预留一些小编号以备日后添加频繁使用的字段。

  • 编码的范围从 1 开始最大支持到 536,870,911 但是不能使用 19000 到 19999 范围内的编号。

指定字段规则

  • singular(单数的),如果未指定字段规则,那么它就是默认规则。
  • optional(可选择),类似于 singular,不同之处在于如果字段被明确的设置,那么他将被序列化,如果没有设置,获取它的值会返回默认值,但是序列化的时候不会包含这个字段。
  • repeated(重复的),数据可以被重复 0 次或多次,重复数据是有序的。

定义多个消息

在一个 .proto 文件可以定义多个 message 消息,这有助于定义重复的消息。

添加注释

.proto 文件中可以添加 C/C++ 风格的块注释 /* ... */ 和 行 // 注释。

保留字段

如果已经弃用的编号未来再次被使用,很可能引发兼容问题,为了避免这种情况,可以将其设置为保留字段,编译器在处理时会抛出异常。

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}
  • 需要注意在同一个 reserved 字段中不能将编号和字段名混合使用。

proto 文件会生成什么?

编译器会用我们选择的语言生成代码

C++(.h.cc)、Java(.java)、Kotlin(.kt)、Python(有一点不同)、Go(.pb.go)、Ruby(.rb)、Objective-C(pbobjc.hpbobjc.m)、C#(.cs)、Dart(.pb.dart

Python is a little different — the Python compiler generates a module with a static descriptor of each message type in your .proto, which is then used with a metaclass to create the necessary Python data access class at runtime.

标量值类型

例如:定义为 float 类型值,对应 golang 中的 float32double 类型对应 golang 中的 float64 类型。

各语言类型对应关系参考:https://developers.google.com/protocol-buffers/docs/proto3#scalar

默认值

当解析消息时,如果消息不包含特定的 singular 元素,则会将其赋值为类型的默认值,值得关注的 enums 枚举类型,它的默认值是枚举列表的首个元素,该值必须为 0。

重复字段的默认值一般为对应语言的空列表。

需要注意的是,标量字段一旦解析就无法确定其数值本身就是默认值还是没有设置值。另外如果标量字段设置为默认值,它不会被序列化到编码数据。

枚举类型

定义消息类型的时候,你可能想要消息内容是预先定义的列表中的一项,例如 “媒体”,可以分为 “文字”、“图像”、“音频”、“视频” 等,通过 enum 枚举类型可以很容易的实现。

示例:定义一个 “语料库”

enum Corpus {
  CORPUS_UNSPECIFIED = 0;
  CORPUS_UNIVERSAL = 1;
  CORPUS_WEB = 2;
  CORPUS_IMAGES = 3;
  CORPUS_LOCAL = 4;
  CORPUS_NEWS = 5;
  CORPUS_PRODUCTS = 6;
  CORPUS_VIDEO = 7;
}
message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  Corpus corpus = 4;
}
  • 枚举类型【必须】有以 0 为编号的元素起始,这是因为做默认值的时候会用到。另外也是 proto2 的默认行为。

枚举值支持别名

enum EnumAllowingAlias {
  option allow_alias = true;
  EAA_UNSPECIFIED = 0;
  EAA_STARTED = 1;
  EAA_RUNNING = 1;
  EAA_FINISHED = 2;
}
enum EnumNotAllowingAlias {
  ENAA_UNSPECIFIED = 0;
  ENAA_STARTED = 1;
  // ENAA_RUNNING = 1;  // Uncommenting this line will cause a compile error inside Google and a warning message outside.
  ENAA_FINISHED = 2;
}

使用其它 Message 类型

定义一个消息类型,然后在另一个消息中使用。

message SearchResponse {
  repeated Result results = 1;
}

message Result {
  string url = 1;
  string title = 2;
  repeated string snippets = 3;
}

嵌套类型

可以在 Message 消息中定义另一个消息并使用。

message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 1;
}

如果需要在其它消息中使用内置的 Result 消息,可以使用 . 获取。

message SomeOtherMessage {
  SearchResponse.Result result = 1;
}

可以多层嵌套

message Outer {                  // Level 0
  message MiddleAA {  // Level 1
    message Inner {   // Level 2
      int64 ival = 1;
      bool  booly = 2;
    }
  }
  message MiddleBB {  // Level 1
    message Inner {   // Level 2
      int32 ival = 1;
      bool  booly = 2;
    }
  }
}

更新消息类型

如果想要修改 .proto 文件,例如新增一个字段,不用担心,老版本的描述文件会兼容这个 “未知字段”,不会影响旧字段的读取。 需要注意的是删除字段,要确保编号不会被用到,可以将其字段改名新增一个前缀,或是借助于保留字段。

更多的字段类型说明参考:https://developers.google.com/protocol-buffers/docs/proto3#updating

未知字段

新二进制文件用旧解析器读取就会有解析器不认识的字段,以往解析器在处理未知字段时会忽略掉。 从 3.5 版本开始,未知字段在解析期间会保留,并包含在序列化输出中。

Any 和 Oneof

使用 Any 可以将 pb 结构序列化,在定义数据的时候可以不指定具体的 Message 消息类型。

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

以下是在 Golang 中使用 Any 类型的序列化/反序列化示例

import (
    anyPb "google.golang.org/protobuf/types/known/anypb"
 )

func TestAny(t *testing.T) {
    p := &pb.Person{
        Id:    1234,
        Name:  "DongDong",
        Email: "dong@example.com",
    }

    // 从 pb 结构创建 any 数据
    anyData, _ := anyPb.New(p)
    fmt.Println(anyData)

    // 反序列化
    p2 := &pb.Person{}
    if err := anyData.UnmarshalTo(p2); err != nil {
    }
    fmt.Println(p2.GetName())

    // 序列化
    p.Name = "DongDong (QAQ)"
    if err := anyData.MarshalFrom(p); err != nil {
    }
    fmt.Println(anyData)
}

OneOf 操作参考文章:Medium: Protobuf and Go: Handling Oneof Field type

And so on

暂不将所有文档内容都罗列至此,用到具体功能到文档结合搜索引擎查找示例即可。

Map 数据类型

https://developers.google.com/protocol-buffers/docs/proto3#maps

Packages

https://developers.google.com/protocol-buffers/docs/proto3#packages

Defining Services

https://developers.google.com/protocol-buffers/docs/proto3#services

JSON Mapping

https://developers.google.com/protocol-buffers/docs/proto3#json

Options

https://developers.google.com/protocol-buffers/docs/proto3#options

Generating Your Classes

https://developers.google.com/protocol-buffers/docs/proto3#generating

File location

建议将 .proto 文件放在项目根目录下的 proto 文件夹,而不是跟生成的代码文件存在一处。

https://developers.google.com/protocol-buffers/docs/proto3#location

本篇文章整理了 Quick Start 和官方文档对 Protocol Buffers 的简介,对于 Protobuf 有了初步的了解。 接下来,可能会继续实践 Protobuf 的使用及最佳实践,或是进而学习 gRPC

今天的学习就先到这里。