• golang工程——protobuf使用及原理


    相关文档
    源码:https://github.com/grpc/grpc-go
    官方文档:https://www.grpc.io/docs/what-is-grpc/introduction/
    protobuf编译器源码:https://github.com/protocolbuffers/protobuf
    proto3文档:https://protobuf.dev/programming-guides/proto3/

    protobuf使用

    protoc下载

    用于编译.proto文件,生成对应语言的模板文件

    #下载地址
    https://github.com/protocolbuffers/protobuf/releases/
    
    • 1
    • 2

    windows的话选择对应版本,下载解压后配置对应环境变量
    在这里插入图片描述

    protoc 插件安装

    go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28 go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
    
    • 1

    protoc 编译

    protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go- grpc_opt=paths=source_relative .\echo\echo.proto
    
    • 1
    • –proto_path 或者 -I :指定 import 路径,可以指定多个参数,编译时按顺序查找,不指定时默 认查找
      当前目录。(.proto 文件中也可以引入其他 .proto 文件,这里主要用于指定被引入文件的 位置)
    • –go_out :golang编译支持,指定输出文件路径;
    • –-go_opt :指定参数,比如 --go_opt=paths=source_relative 就是表明生成文件输出使用相对 路径。
    • path/to/file.proto :被编译的 .proto 文件放在最后面

    protobuf原理

    protobuf字段

    定义一个搜索相关的proto 消息,请求有 查询字符串,有分页页数,和每页的数量。例子如下

    message SearchRequest {
      string query = 1;  // 查询字符串
      optional int32 page_number = 2;  // 第几页
      optional int32 result_per_page = 3;  // 每页的结果数
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 每个消息应该有类型字段编号
    • optional: message 可以包含该字段零次或一次(不超过一次)。
    • repeated: 该字段可以在消息中重复任意多次(包括零)。其中重复值的顺序会被保留。在开发语言中就是数组和列表
    字段类型

    字段有很多数据类型,看个例子

    syntax = "proto3";
    option go_package = "protos/pbs";
    enum Status{
      Status1=0;
      Status2=1;
      Status3=2;
    }
    
    message Source{
      //金条
      int64 Gold  =1;
      //血条
      int64 Blood=2;
    }
    message Role {
      //id
      int64  Id =1; //有符号整型
      //姓名
      string Name=2;//字符串类型
      //属性
      map Attr=3; //map 类型
      //状态
      Status typ =4;   //枚举类型
      //是不是vip
      bool IsVip =5; //bool 类型
      //资源
      Source source=6;     //复合类型
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    字段编号
    • 每个字段有唯一的编号,proto编码的时候是不管字段名的,仅根据编号来确定是哪个字段
    • 消息被使用了,字段就不能改了,改了会造成数据错乱(常见坑),服务器和客户端很多bug,是proto buffer 文件更改,未使用更改后的协议导致。
    • 1 到 15 范围内的字段编号需要一个字节进行编码,编码结果将同时包含编号和类型
    • 16 到 2047 范围内的字段编号占用两个字节。因此,非常频繁出现的 message 元素保留字段编号 1 到 15。
    • 字段最小数字为1,最大字段数为2^29 - 1。(原因在编码原理那章讲解过,字段数字会作为key,key最后三位是类型
    • 19000 through 19999 (FieldDescriptor::kFirstReservedNumber through FieldDescriptor::kLastReservedNumber这些数字不能用,这些是保留字段,如果使用会编译器会报错
    syntax = "proto3";
    option go_package = "protos/pbs";
    
    message Role {
      int64  Id =19527;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    编译会报下面的错

    protoc --go_out=. ./*.proto
    intro.proto:5:14: Field numbers 19000 through 19999 are reserved for the protocol buffer library implementation.
    
    • 1
    • 2
    • 保留字段指 reserved 关键字指定的字段

    protobuf 数据类型

    变量类型
    protoYypenotesGO type
    double*float64
    float*float32
    int32可变长编码,负数编码效率低,要经过zigzag*int32
    int64可变长编码,负数编码效率低,要经过zigzag*int64
    uint32可变长编码*uint32
    uint64可变长编码*uint64
    sint32可变长编码,比int32效率高*int32
    sint64可变长编码,比int64效率高*int64
    fixed32总是4字节,如果经常比228大,那比uint32效率更高*uint32
    fixed64总是4字节,如果经常比256大,那比uint32效率更高*uint64
    sfixed32总是4字节*int32
    sfixed64总是8字节*int64
    bool*bool
    stringutf8或7-bit ASCII文本编码*string
    bytes任意序列字节[]byte
    • java中,无符号32 位和64位使用其有符号类型表示。最高位是符号位
    • 所有场景中,给字段赋值都会给类型检查确保它是有效的
    • 64 位或无符号 32 位整数在解码时始终表示为 long,但如果在设置字段时给出 int,则可以为int
    默认值

    解析消息的时候,编码的消息字段没有赋值,将会设置默认值

    • 字符串类型默认值是" "
    • bytes 默认值是空字节
    • bool 默认值是false
    • 数字类型默认是0
    • 枚举值默认是0,详情看下面枚举类型
    • 空列表在合适的语言会转换成合适的数据类型空列表
    枚举

    当我们想定义一个消息类型,只使用定义好的一系列值的一个,我们就可以使用枚举

    message SearchRequest {
      string query = 1;
      int32 page_number = 2;
      int32 result_per_page = 3;
      enum Corpus { //定义枚举
        UNIVERSAL = 0;
        WEB = 1;
        IMAGES = 2;
        LOCAL = 3;
        NEWS = 4;
        PRODUCTS = 5;
        VIDEO = 6;
      }
      Corpus corpus = 4; //使用枚举
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    注意

    枚举第一个字段必须是0,像上面UNIVERSAL = 0,而且不能省略,原因有两点:

    • 当该枚举类型字段没有赋值的时候,我们使用0这个定义作为默认值
    • 兼容proto2 第一个字段总是默认值

    如何给枚举定义别名? 当我们希望两个枚举值一样,但是变量名不一样的时候,我们可以添加allow_alias option,并设置值为true,类似于下面这个样式,要不然编译器会报错。

    message MyMessage1 {
      enum EnumAllowingAlias {
        option allow_alias = true;
        UNKNOWN = 0;
        STARTED = 1;
        RUNNING = 1;
      }
    }
    message MyMessage2 {
      enum EnumNotAllowingAlias {
        UNKNOWN = 0;
        STARTED = 1; //直接使用会报错,因为这两个值一样了
        // RUNNING = 1;  // Uncommenting this line will cause a compile error inside Google and a warning message outside.
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    枚举值范围是32位范围内的整数,这是因为是是varint encoding 编码的。对于没有定义的枚举值,在go 和c++中会识别成数字

    枚举保留字段

    枚举更新的安全性,官方新增了个枚举保留字段,使用方法如下

    enum Foo {
      reserved 2, 15, 9 to 11, 40 to max;
      reserved "FOO", "BAR";
    }
    
    • 1
    • 2
    • 3
    • 4

    如果删除枚举定义或者注释来更新枚举类型,将来用户可能不注意去重用该类型的值,如果以后proto buffer 版本更新了,再加载到旧版本,那么可能导致严重问题,包括数据损坏、隐私漏洞等。

    官方提供了保留字段,可以保留枚举字段名和枚举字段值,如下,使用保留的字段会报错,超过max 也会报错。

    例如下面这个例子

    enum TestType {
      Hello1=0;
      Hello2=1;
      Hello3=2;
      Hello4=3;
      FOO=4; # 使用保留字段
      BAR=5;
      foo=6;
      Bar=7;
    
      Foo1=8;
      Hello6=39;
      Hello6=40;
      Hello5=99; # 超过最大值
      reserved 2,3, 15, 9 to 11, 40 to max;
      reserved "FOO", "BAR";
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    消息嵌套和导入其他proto
    消息引用

    当使用其他消息的时候,如果在本文件,直接使用就可以了。Result代表自定义消息类型

    message SearchResponse {
      repeated Result results = 1;
    }
    
    message Result {
      string url = 1;
      string title = 2;
      repeated string snippets = 3;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    消息嵌套

    如何在消息里面再定义消息了?例子如下,在SearchResponse定义了个内部消息Result,然后直接引用就可以了。

    message SearchResponse {
      message Result {
        string url = 1;
        string title = 2;
        repeated string snippets = 3;
      }
      repeated Result results = 1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    如果其他消息引用消息内部的消息呢?语法为_Parent_.Type

    message SomeOtherMessage {
      SearchResponse.Result result = 1;
    }
    
    • 1
    • 2
    • 3
    导入其他文件proto

    在项目开发中,我们有这种需要,将相同的结构放在一个公共文件夹,将请求响应的业务消息放一个文件夹,然后请求响应的proto 会引用通文件夹。我们来写一个例子,文件结构如下。

    • bussiness 代表业务文件夹,里面存放业务逻辑
    • share 存放公共结构文件夹

    user_business.proto

    syntax = "proto3";
    option go_package = "protos/pbs";
    import "share/user.proto";
    //获取角色信息请求
    message GetUserRequest {
    
    }
    //获取角色信息响应
    message GetUserResponse {
      User user=1;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    user.proto

    syntax = "proto3";
    option go_package = "protos/pbs";
    
    //用户定义
    message User {
    	string Id=1;
    	string Name=2;
    	string Age=3;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    protoc --go_out=. ./business/*.proto ./share/*.proto
    
    • 1
    Any

    官方说作用是集成proto 没有定义的类型,其实可以理解为go 语言接口类型,可以存任何类型的值,但是跨语言只能通过字节流代表任意类型,所以any 内部实现包含字节流,和标识字节流唯一url

    用这个关键字,官方说要导入官方proto,类似下面,相信如果直接编译肯定会有坑,编译不过,因为没有any.proto这个文件

    import "google/protobuf/any.proto";
    
    message ErrorStatus {
      string message = 1;
      repeated google.protobuf.Any details = 2;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 去官方下载这个文件
    • 安装protobuf的时候有个proto目录,拷贝过来

    最后序列化出来的any 结构包含下面两个字段:

    TypeUrl string `protobuf:"bytes,1,opt,name=type_url,json=typeUrl,proto3" json:"type_url,omitempty"`
    // Must be a valid serialized protocol buffer of the above specified type.
    Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
    
    • 1
    • 2
    • 3
    • 一个是序列化成bytes 的属性value
    • 一个是标识这个属性全局唯一的标识TypeUrl
    Oneof

    如果在平时在一个消息有许多字段,但是最多设置一个字段,我们可以使用oneof 来执行并节省内存。

    Oneof 字段类似于常规字段,除了Oneof共享内存的所有字段之外,最多可以同时设置一个字段。设置Oneof 的任何成员都会自动清除所有其他成员。您可以使用case()或WhichOneof()方法检查Oneof 中的哪个值被设置(如果有的话),具体取决于选择的语言。

    syntax = "proto3";
    option go_package = "protos/pbs";
    
    message SubMessage {
      int32 Id=1;
      string Age2=2;
    
    }
    message SampleMessage {
      oneof test_oneof {
        string name = 4;
        SubMessage sub_message = 9;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    oneof 可以添加任何字段,除了repeated字段

    oneof功能
    • oneof 设置一个字段会清除其他字段,如果设置了几个字段,自会保留最后一个设置的字段,可以看到在go中是通过一个接口类型来做到oneof的,只能给这个字段赋值为定义的字段结构体
    package main
    
    import (
    	"fmt"
    	"grpcdemo/protobuf/any/protos/pbs"
    )
    
    func main()  {
    
    	p:=&pbs.SampleMessage{
    		TestOneof: &pbs.SampleMessage_Name{Name: "hello"},
    	}
    
    	fmt.Println(p)
    	fmt.Println(p.GetTestOneof())
    	p.TestOneof=&pbs.SampleMessage_SubMessage{SubMessage: &pbs.SubMessage{Id: 1}}
    	fmt.Println(p)
    	fmt.Println(p.GetTestOneof())
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • oneof 不能被repeated
    • 反射作用于oneof的字段

    兼容性问题

    添加或删除其中一个字段时要小心。如果检查 oneof 的值返回 None/NOT_SET,则可能意味着 oneof 尚未设置或已设置为 oneof 的另一个字段。这种情况是无法区分的,因为无法知道未知字段是否是 oneof 成员。

    标签重用问题

    • 将 optional 可选字段移入或移出 oneof:在序列化和解析 message 后,你可能会丢失一些信息(某些字段将被清除)。但是,你可以安全地将单个字段移动到新的 oneof 中,并且如果已知只有一个字段被设置,则可以移动多个字段。
    • 删除 oneof 字段并将其重新添加回去:在序列化和解析 message 后,这可能会清除当前设置的 oneof 字段。
    • 拆分或合并 oneof:这与移动常规的 optional 字段有类似的问题。
    maps

    在数据定义创建map,语法格式为

    map map_field = N;
    
    • 1

    例如,创建一个项目,key 是string,value 是Project

    map projects = 3;
    
    • 1

    PS:

    • map 类型不能加repeated,简单来说map 是不支持map 数组的
    • map是无序的,不能依赖map 的特定顺序

    总的来说,map 语法等价于下面的语法,所以protocol buffers 的实现在不支持map 的语言上也能处理数据

    message MapFieldEntry {
      key_type key = 1;
      value_type value = 2;
    }
    
    repeated MapFieldEntry map_field = N;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    packages

    package提供命名空间,防冲突

    package foo.bar;
    message Open { ... }
    
    • 1
    • 2

    在其他地方引用

    message Foo {
      ...
      foo.bar.Open open = 1;
      ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    options
    • 可用的选项列表在google/protobuf/descriptor.proto
    • 其它选项官方有,是其它语言相关的,这里就不细讲了,看官方文档Options
    • deprecated选项设为true 代表字段被废弃,在新代码不应该被使用,在大多数语言都没有实际的效果,在java 变成@Deprecated注解。 在未来,可能产生废弃的注解在方法字段的入口。并且将会引起警告当编译这个字段的时候。如果这个字段没人使用,可以将字段的声明改为保留字段,上面已经讲解
    int32 old_field = 6 [deprecated = true];
    
    • 1
    custom options

    proto buffer 提供大多人都不会使用的高级功能-自定义选项。

    由于选项是由 google/protobuf/descriptor.proto(如 FileOptions 或 FieldOptions)中定义的消息定义的,因此定义你自己的选项只需要扩展这些消息

    import "google/protobuf/descriptor.proto";
    
    extend google.protobuf.MessageOptions {
      optional string my_option = 51234;
    }
    
    message MyMessage {
      option (my_option) = "Hello world!";
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    获取选项

    package main
    
    import (
       "fmt"
       "grpcdemo/protobuf/any/protos/pbs"
    )
    
    func main()  {
    
       p:=&pbs.MyMessage{}
    
       fmt.Println(p.ProtoReflect().Descriptor().Options())
       //[my_option]:"Hello world!"
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    Protocol Buffers可以为每种类型提供选项

    import "google/protobuf/descriptor.proto";
    
    extend google.protobuf.FileOptions {
      optional string my_file_option = 50000;
    }
    extend google.protobuf.MessageOptions {
      optional int32 my_message_option = 50001;
    }
    extend google.protobuf.FieldOptions {
      optional float my_field_option = 50002;
    }
    extend google.protobuf.OneofOptions {
      optional int64 my_oneof_option = 50003;
    }
    extend google.protobuf.EnumOptions {
      optional bool my_enum_option = 50004;
    }
    extend google.protobuf.EnumValueOptions {
      optional uint32 my_enum_value_option = 50005;
    }
    extend google.protobuf.ServiceOptions {
      optional MyEnum my_service_option = 50006;
    }
    extend google.protobuf.MethodOptions {
      optional MyMessage my_method_option = 50007;
    }
    
    option (my_file_option) = "Hello world!";
    
    message MyMessage {
      option (my_message_option) = 1234;
    
      optional int32 foo = 1 [(my_field_option) = 4.5];
      optional string bar = 2;
      oneof qux {
        option (my_oneof_option) = 42;
    
        string quux = 3;
      }
    }
    
    enum MyEnum {
      option (my_enum_option) = true;
    
      FOO = 1 [(my_enum_value_option) = 321];
      BAR = 2;
    }
    
    message RequestType {}
    message ResponseType {}
    
    service MyService {
      option (my_service_option) = FOO;
    
      rpc MyMethod(RequestType) returns(ResponseType) {
        // Note:  my_method_option has type MyMessage.  We can set each field
        //   within it using a separate "option" line.
        option (my_method_option).foo = 567;
        option (my_method_option).bar = "Some string";
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61

    引用其他包的选项需要加上包名

    // foo.proto
    import "google/protobuf/descriptor.proto";
    package foo;
    extend google.protobuf.MessageOptions {
      optional string my_option = 51234;
    }
    // bar.proto
    import "foo.proto";
    package bar;
    message MyMessage {
      option (foo.my_option) = "Hello world!";
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 自定义选项是扩展名,必须分配字段号,像上面的例子一样。在上面的示例中,使用了 50000-99999 范围内的字段编号。这个字段范围供个人组织使用,所以可以内部用。
    • 在公共应用使用的话,要保持全球唯一数字,需要申请,申请地址为: protobuf global extension registry
    • 通常只需要一个扩展号,可以多个选项放在子消息中来实现一个扩展号声明多个选项
    message FooOptions {
      optional int32 opt1 = 1;
      optional string opt2 = 2;
    }
    
    extend google.protobuf.FieldOptions {
      optional FooOptions foo_options = 1234;
    }
    
    // usage:
    message Bar {
      optional int32 a = 1 [(foo_options).opt1 = 123, (foo_options).opt2 = "baz"];
      // alternative aggregate syntax (uses TextFormat):
      optional int32 b = 2 [(foo_options) = { opt1: 123 opt2: "baz" }];
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    每种选项类型(文件级别,消息级别,字段级别等)都有自己的数字空间,例如:可以使用相同的数字声明 FieldOptions 和 MessageOptions 的扩展名。

    编解码原理

    Base 128 Varints

    可变字节长度编码,用一个字节或者多个字节表示整数类型,更小的数占用更小的字节。

    编码的理念是:越小的数字花费越少的字节

    来看看下面的例子:

    00000000 00000000 00000000 00000001 //int32 
    
    • 1
    • 假设值为1 ,类型为int32 在网络传输,其实有效位就一个,其他的位都是无效的,Base 128 Varints的出现就是为了解决这个问题

    Base 128 Varints 原理

    Base128 Varints 采用的是小端序, 即数字的低位存放在高地址。

    比如数字 666, 其以标准的整型存储, 其二进制表示为

    在这里插入图片描述

    而采用 Varints 编码, 其二进制形式为

    在这里插入图片描述

    可以尝试来复原一下上面这个 Base128 Varints 编码的二进制串, 首先看最高有效位

    在这里插入图片描述

    接下来我们移除标识位, 由于 Base128 Varints 采用小端字节序, 因此数字的高位存放于低地址上

    在这里插入图片描述

    移除标志位并交换字节序, 便得到原本的数值 1010011010, 即数字 666

    在这里插入图片描述

    可变长整型编码对于不同大小的数字, 其所占用的存储空间是不同的, 编码思想与 CPU 的间接寻址原理相似, 都是用一比特来标识是否走到末尾, 但采用这种方式存储数字, 也有一个相对不好的点便是, 无法对一个序列的数值进行随机查找, 因为每个数字所占用的存储空间不是等长的, 因此若要获得序列中的第 N 个数字, 无法像等长存储那样在查找之前直接计算出 Offset, 只能从头开始顺序查找

    zigzag 编码

    Varints 编码的实质在于去掉数字开头的 0, 因此可缩短数字所占的存储字节数, 在上面的例子中, 我们只举例说明了正数的 Varints 编码, 但如果数字为负数, 则采用 Varints 编码会恒定占用 10 个字节, 原因在于负数的符号位为 1, 对于负数其从符号位开始的高位均为 1, 在 Protobuf 的具体实现中, 会将此视为一个很大的无符号数, 以 Go 语言的实现为例, 对于 int32 类型的 pb 字段, 对于如下定义的 proto

    syntax = "proto3";
    package pbTest;
    
    message Request {
        int32 a = 1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    Request 中包含类型为 int32 类型的字段, 当 a 为负数时, 其序列化之后将恒定占用 10 个字节, 我们可以使用如下的测试代码

    func main() {
        a := pbTest.Request{
            A: -5,
        }
        bytes, err := proto.Marshal(&a)
        if err != nil {
            fmt.Println(err)
            return
        }
        fmt.Println(fmt.Sprintf("%08b", bytes))
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    对于 int32 类型的数字 -5, 其序列化之后的二进制为

    在这里插入图片描述

    究其原因在于 Protobuf 的内部将 int32 类型的负数转换为 uint64 来处理, 转换后的 uint64 数值的高位全为 1, 相当于是一个 8 字节的很大的无符号数, 因此采用 Base128 Varints 编码后将恒定占用 10 个字节的空间, 可见 Varints 编码对于表示负数毫无优势, 甚至比普通的固定 32 位存储还要多占 4 个字节。Varints 编码的实质在于设法移除数字开头的 0 比特, 而对于负数, 由于其数字高位都是 1, 因此 Varints 编码在此场景下失效

    Zigzag 编码便是为了解决这个问题, Zigzag 编码的大致思想是首先对负数做一次变换, 将其映射为一个正数, 变换以后便可以使用 Varints 编码进行压缩, 这里关键的一点在于变换的算法, 首先算法必须是可逆的, 即可以根据变换后的值计算出原始值, 否则就无法解码, 同时要求变换算法要尽可能简单, 以避免影响 Protobuf 编码、解码的速度, 我们假设 n 是一个 32 位类型的数字, 则 Zigzag 编码的计算方式为

    (n << 1) ^ (n >> 31)

    在这里插入图片描述

    首先对其进行一次逻辑左移, 移位后空出的比特位由 0 填充

    在这里插入图片描述

    然后对原数字进行 15 次算术右移, 得到 16 位全为原符号位(即 1)的数字

    在这里插入图片描述

    然后对逻辑移位和算术移位的结果按位异或, 便得到最终的 Zigzag 编码

    在这里插入图片描述

    可以看到, 对负数使用 Zigzag 编码以后, 其高位的 1 全部变成了 0, 这样以来我们便可以使用 Varints 编码进行进一步地压缩, 再来看正数的情形, 对于 16 位的正数 5。后面就可以用varints编码了

    可以看到, 对负数使用 Zigzag 编码以后, 其高位的 1 全部变成了 0, 这样以来我们便可以使用 Varints 编码进行进一步地压缩, 再来看正数的情形, 对于 16 位的正数 5, 其在内存中的存储形式为

    在这里插入图片描述

    我们按照与负数相同的处理方法, 可以得到其 Zigzag 编码为

    在这里插入图片描述

    从上面的结果来看, 无论是正数还是负数, 经过 Zigzag 编码以后, 数字高位都是 0, 这样以来, 便可以进一步使用 Varints 编码进行数据压缩, 即 Zigzag 编码在 Protobuf 中并不单独使用, 而是配合 Varints 编码共同来进行数据压缩

    消息编码

    protocol buffer消息是由一些key-value 组成的,其中key 代表字段后面的数字,变量名和变量类型仅仅决定编码的最终截止位置。

    消息编码的时候,key 和value 都会被编码进字节流。当解码器解码时,需要跳过不能识别的字段,因为新添加字段不会对原来造成影响。每个key 由两部分组成,1个是定义在proto消息字段后面的数字,后面跟的是wire type (消息类型)。通过消息类型能够找到后面值的长度。

    可用的wire type

    在这里插入图片描述

    每个key 在消息流里面都是这样的结构,(field_number << 3) | wire_type,最后三位存储wire_type,直白来说,wire_type类似语言中的数据类型,标识存储数据的长度

    解码例子

    假设有下面这种消息类型Test1

    message Test1 {
      optional int32 a = 1;
    }
    
    • 1
    • 2
    • 3

    当我们定义上面的消息,并赋值a=150,我们将得到下面序列化结构,总共三个字节

    08 96 01
    
    • 1

    解码步骤

    1、数据流第一个数字是 varint key,这里是08,二进制数据为000 1000

    • 最后三位000是wire_type(0),右移三位得到000 1(1),所以知道了字段1和后面的值是varint 类型
    • 将96 01 通过上面的Base 128 Varints解码方法得到数字150
    96 01 = 1001 0110  0000 0001000 0001  ++  001 0110 (drop the msb and reverse the groups of 7 bits)10010110128 + 16 + 4 + 2 = 150
    
    • 1
    • 2
    • 3
    • 4
    Non-varint 数字
    • double and fixed64 用的wire type 是1,编译器解析时会认为是64位的块数据。直接取64位解析,没有varint 编解码过程。
    • float and fixed32使用wire type 5,告诉编译器是32位的数据
    • 该数字都被排成小端字节序了
    字符串编码

    字符串的wire_type 是2,代表值是可变的,长度会被编码进字节流里面。

    如下例子:

    message Test2 {
      optional string b = 2;
    }
    
    • 1
    • 2
    • 3

    将b 赋值为"testing" ,得到下面的结果

    12 07 [74 65 73 74 69 6e 67]
    
    • 1
    • key 是0x12,最后三位代表wire_type 结果为2(length-delimited),key 为2
    • []里面的内容是UTF8 的 “testing”
    0x120001 0010  (binary representation)00010 010  (regroup bits)
    → field_number = 2, wire_type = 2
    
    • 1
    • 2
    • 3
    • 4
    • 长度是07,代表后面的7个字节为字符串内容
    复合结构消息
    message Test1 {
      optional int32 a = 1;
    }
    message Test3 {
      optional Test1 c = 3;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    Test1’s a 字段依然是150:

     1a 03 08 96 01
    
    • 1
    • 后面08 96 01就不说了,前面解析过了
    • 1a 二进制为00011010,后三位代表wire_type 为2,前面代表key 为数字3。所以Test1结果被当作字符串对待了
    • 03 为长度,代表Test3里面内容长度为3 个字节
    optional and repoeated
    • 在proto2 里面,消息字段定义为repeated没有在后面加选项packed=true,编码的消息可能有零个或者多个key-value 键值对,这些键值对也不是连续的,可能中间插入了其他字段,意思是和其他字段交替出现。
    • 任何不是repeated字段在proto3 里面或者optional 字段在proto2,编码消息可能有也可能没有那个字段形成的key value键值对
    • 通常编码消息对于不是repeated字段永远不可能出现超过1个的键值对,解析器期望去处理这种情况。对于数字类型和字符串类型,如果同一个字段出现多次,解析器会使用最后看见的一个值。对于复合类型字段,解析器合并多个实例到同一个字段,就像Message::MergeFrom方法一样。同个嵌套类型,如果出现了多个键值对,解析器会采取合并策略。
    MyMessage message;
    message.ParseFromString(str1 + str2);
    
    • 1
    • 2

    和下面的结果是一样的

    MyMessage message, message2;
    message.ParseFromString(str1);
    message2.ParseFromString(str2);
    message.MergeFrom(message2);
    
    • 1
    • 2
    • 3
    • 4
    packed repoeated fields
    • proto3默认使用packed编码repeated数字字段
    • 这些函数类似于重复字段,但是编码方式不同,包含零元素的压缩重复字段不会出现在编码消息中,要不然,该字段的所有元素会打包到wire_type 为2 的键值对中。每个元素的编码方式于正常情况相同,只是前面没有键

    例如下面的类型

    message Test4 {
      repeated int32 d = 4 [packed=true];
    }
    
    • 1
    • 2
    • 3

    Test4 的repeated 有三个值,3、270、86942 。编码结果将会如下面所示

    22        // key (field number 4, wire type 2)
    06        // payload size (6 bytes)
    03        // first element (varint 3)
    8E 02     // second element (varint 270)
    9E A7 05  // third element (varint 86942)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 只有varint, 32-bit, or 64-bit wire types可以使用packed
    • 虽然通常情况下没有必要为编码repeated字段使用多个键值对,但是解析器也必须做这样的编码,每对包含完整的信息
    • Protocol buffer必须能解析编译为packed的字段跟没有使用packed一样。在兼容性上就可以向前向后兼容使用[packed=true]
    filed oder

    字段数字顺序可以任何顺序出现在proto里面。顺序对消息序列化没有任何影响。

    当消息被序列化时,是无法保证已知字段和未知字段被写入,序列化是一个实现细节,任何特定实现的细节在将来都会被改变,因此protocol buffer 必须能够解析字段在任何顺序。

    未知字段
    • 未知字段是protocol buffer无法识别的字段,通常发送在旧二进制文件去解析新二进制发送的数据时,这些新字段就是未知字段
    • 最初,proto3消息在解析期间总是丢弃未知字段,但在3.5版本中,将未知字段保存以匹配proto2行为。 在版本3.5及更高版本中,未知字段在解析期间保留并包含在序列化输出中。

    【侵权删】

  • 相关阅读:
    docker镜像相关
    鸿蒙(HarmonyOS)项目方舟框架(ArkUI)之NavDestination组件
    Linux系统编程(二)——Linux系统IO函数
    【毕业设计】基于单片机的家庭智能监控系统 - 物联网 stm32 嵌入式
    图片批处理工具 PhotoMill X直装 for mac
    如何安装Vue
    【每日一题】最短无序连续子数组
    Service和启动其他应用的功能和替换Fragment
    微服务网关之Zuul下
    Node.js v19,它来了。详解 6 大特性
  • 原文地址:https://blog.csdn.net/qq_43058348/article/details/133343426