【微服务架构】Protocol Buffer序列化原理解析

protobuf定义

Protocol Buffer是Google出品的数据传输协议,目前已经广泛用于客户端和服务器之间的数据交互

作用

通过将 结构化的数据 进行 串行化(序列化),从而实现 数据存储 / RPC 数据交换的功能

序列化: 将 数据结构或对象 转换成 二进制串 的过程
反序列化:将在序列化过程中所生成的二进制串 转换成 数据结构或者对象 的过程

在这里插入图片描述

特点

在这里插入图片描述

protobuffer 为什么高效

  • 序列化数据时,不序列化key的name,使用key的编号替代,减小数据
    例如定义如下数据结构:

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

    在这里插入图片描述

    上述数据在序列化时,query,page_number以及result_per_page的key不会参与,由编号1,2,3替代,这样在反序列的时候可以直接通过编号找到对应的key,这样做确实可以减小传输数据,但是编号一旦确定就不可更改

  • 没有赋值的key,不参与序列化
    序列化时只会对赋值的key进行序列化,没有赋值的不参与,在反序列化的时候直接给默认值即可

  • 可变长度编码
    可变长度编码,主要缩减整数占用字节实现,例如java中int占用4个字节,但是大多数情况下,我们使用的数字都比较小,使用1个字节就够了,这就是可变长度编码完成的事

  • TLV
    TLV全称为Tag_Length_Value,其中Tag表示后面数据的类型,Length不一定有,根据Tag的值确定,Value就是数据了,TLV表示数据时,减少分隔符的使用,更加紧凑
    在这里插入图片描述

http rpc对比

编(解)码层

HTTP/1.1

  • 序列化协议:JSON
    • 额外空间开销大,没有类型,开发时需要通过反射统一解决。
      在这里插入图片描述

RPC

  • 序列化协议:以 gRPC 为代表的 Protobuf,其他也类似
    • 序列化后的体积比 JSON 小 ⇒ 传输效率高
    • 序列化、反序列化速度快,开发时不需要通过反射 ⇒ 性能消耗低
    • IDL 描述语义比较清晰。

通信协议约定

基于 TCP 传输,都会有消息头和消息体,区别在于消息头

HTTP/1.1

  • 优点是灵活,可以自定义很多字段。
  • 缺点是包含许多为了适应浏览器的冗余字段,这些是内部服务用不到的。

RPC

  • 可定制化,自定义必要字段即可。
  • 可摒弃很多 HTTP Header 中的字段,比如各种浏览器行为。

网络传输层

  • HTTP/1.1
    • 建立一个 TCP 长连接,设置 keep-alive 长时间复用这个连接。
    • 框架中会引入成熟的网络库,给 HTTP 加连接池,保证不只有一个 TCP 连接可用。
  • RPC
    • 建立 TCP 连接池,框架也会引入成熟网络库来提高传输性能。
    • gRPC 基于 HTTP/2,拥有多路复用、优先级控制、头部压缩等优势。

序列化速度 & 反序列化速度快

Protocol Buffer 反序列化直接读取二进制字节数据流,反序列化就是 encode
的反过程,同样是一些二进制操作。

RPC 的优势和不足

优势

  • 相较于 HTTP/1.1,数据包更小、序列化更快,所以传输效率很高。
  • 基于 TCP 或 HTTP/2 的自定义 RPC 协议,网络传输性能比 HTTP/1.1 更快。
  • 适用于微服务架构,微服务集群下,每个微服务职责单一,有利于多团队的分工协作。

不足

  • RPC 协议本身无法解决微服务集群的问题,例如:服务发现、服务治理等,需要工具来保障服务的稳定性。
  • 调用方对服务端的 RPC 接口有强依赖关系,需要有自动化工具、版本管理工具来保证代码级别的强依赖关系。例如,stub 桩文件需要频繁更新,否则接口调用方式可能出错。

https://zhuanlan.zhihu.com/p/101783606

在这里插入图片描述
序列化 / 反序列化 属于 TCP/IP模型 应用层 和 OSI`模型 展示层的主要功能:

(序列化)把 应用层的对象 转换成 二进制串
(反序列化)把 二进制串 转换成 应用层的对象
所以, Protocol Buffer属于 TCP/IP模型的应用层 & OSI模型的展示层

pb二进制数据流 实现原理

Protocol Buffer 序列化采用 Varint、Zigzag 方法,压缩 int 型整数和带符号的整数。对浮点型数字不做压缩(这里可以进一步的压缩,Protocol Buffer 还有提升空间)。编码 .proto 文件,会对 option 和 repeated 字段进行检查,若 optional 或 repeated 字段没有被设置字段值,那么该字段在序列化时的数据中是完全不存在的,即不进行序列化(少编码一个字段)。

上面这两点做到了压缩数据,序列化工作量减少。

序列化的过程都是二进制的位移,速度非常快。数据都以 tag - length - value (或者 tag -value)的形式存在二进制数据流中。采用了 TLV 结构存储数据以后,也摆脱了 JSON 中的 {、}、;、这些分隔符,没有这些分隔符也算是再一次减少了一部分数据。

数据结构

Protocol Buffer的数据组成方式为TLV,数据结构图如下:

在这里插入图片描述

其中Length不一定有,依据Tag确定,例如int类型的数据就只有Tag-Value,string类型的数据就必须是Tag-Length-Value

数据类型

在这里插入图片描述

tag

Tag块包含两块内容:数据编号、数据类型,Tag的生成规则如下:

(field_number << 3) | wire_type

其中Tag块的后3位表示数据类型,其他位表示数据编号

00001010,

file_num = 0001 = 1
type = 010 = 2

type=2,则后面有Length

可变长度编码

Java中整数类型的长度都是确定的,如int类型的长度为4个字节,可表示的整数范围为-231——231-1,但是实际开发中用到的数字均比较小,会造成字节浪费,可变长度编码就能很好的解决这个问题,可变长度编码规则如下:

字节最高位表示数据是否结束,如果最高位为1,则表示后面的字节也是该数据的一部分, 如果最高位为0,则表示数据计算终止

举个例子:

在这里插入图片描述

其中第一个字节由于最高位为1,则后面的字节也是前面的数据的一部分,第二个字节最高位为0,则表示数据计算终止,由于Protocol Buffer是低位在前,整体的转换过程如下:
在这里插入图片描述

10000001 00000011 ——> 00000110000001
表示的10进制数为:2^0 + 2^7 + 2^8 = 385

通过上面的例子可以知道一个字节表示的数的范围0-128,上面介绍的Tag生成算法中由于后3位表示数据类型,所以Tag中1-15编号只占用1个字节,所以确保编号中1-15为常用的,减少数据大小

可变长度编码唯一的缺点就是当数很大的时候int32需要占用5个字节,但是从统计学角度来说,一般不会有这么大的数

案例分析

https://juejin.cn/post/6844903997292150791

总结:

  • 序列化的时候,不序列化key的name,只序列化key的编号
  • 序列化的时候,没有赋值的key,不参与序列化,反序列化的时候直接使用默认值填充
  • 可变长度编码,减小字节占用
  • TLV编码,去除没有的符号,使数据更加紧凑

Protocol Buffers 生成pb.go都有什么?

syntax = "proto3";

package proto;

service SearchService {
    rpc Search(SearchRequest) returns (SearchResponse) {}
}

message SearchRequest {
    string request = 1;
}

message SearchResponse {
    string response = 1;
}

$ protoc --go_out=plugins=grpc:. *.proto
执行完毕命令后,将得到一个 .pb.go 文件,文件内容如下:

type SearchRequest struct {
    Request              string   `protobuf:"bytes,1,opt,name=request" json:"request,omitempty"`
    XXX_NoUnkeyedLiteral struct{} `json:"-"`
    XXX_unrecognized     []byte   `json:"-"`
    XXX_sizecache        int32    `json:"-"`
}

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

func (m *SearchRequest) GetRequest() string {
    if m != nil {
        return m.Request
    }
    return ""
}

通过阅读这一部分代码,可以知道主要涉及如下方面:

  • 字段名称从小写下划线转换为大写驼峰模式(字段导出)
  • 生成一组 Getters 方法,能便于处理一些空指针取值的情况
  • ProtoMessage 方法实现 proto.Message 的接口
  • 生成 Rest 方法,便于将 Protobuf 结构体恢复为零值
  • Repeated 转换为切片
type SearchRequest struct {
    Request              string   `protobuf:"bytes,1,opt,name=request" json:"request,omitempty"`
}

func (*SearchRequest) Descriptor() ([]byte, []int) {
    return fileDescriptor_search_8b45f79ee13ff6a3, []int{0}
}

type SearchResponse struct {
    Response             string   `protobuf:"bytes,1,opt,name=response" json:"response,omitempty"`
}

func (*SearchResponse) Descriptor() ([]byte, []int) {
    return fileDescriptor_search_8b45f79ee13ff6a3, []int{1}
}

...

func init() { proto.RegisterFile("search.proto", fileDescriptor_search_8b45f79ee13ff6a3) }

var fileDescriptor_search_8b45f79ee13ff6a3 = []byte{
    // 131 bytes of a gzipped FileDescriptorProto
    0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x29, 0x4e, 0x4d, 0x2c,
    0x4a, 0xce, 0xd0, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x05, 0x53, 0x4a, 0x9a, 0x5c, 0xbc,
    0xc1, 0x60, 0xe1, 0xa0, 0xd4, 0xc2, 0xd2, 0xd4, 0xe2, 0x12, 0x21, 0x09, 0x2e, 0xf6, 0x22, 0x08,
    0x53, 0x82, 0x51, 0x81, 0x51, 0x83, 0x33, 0x08, 0xc6, 0x55, 0xd2, 0xe1, 0xe2, 0x83, 0x29, 0x2d,
    0x2e, 0xc8, 0xcf, 0x2b, 0x4e, 0x15, 0x92, 0xe2, 0xe2, 0x28, 0x82, 0xb2, 0xa1, 0x8a, 0xe1, 0x7c,
    0x23, 0x0f, 0x98, 0xc1, 0xc1, 0xa9, 0x45, 0x65, 0x99, 0xc9, 0xa9, 0x42, 0xe6, 0x5c, 0x6c, 0x10,
    0x01, 0x21, 0x11, 0x88, 0x13, 0xf4, 0x50, 0x2c, 0x96, 0x12, 0x45, 0x13, 0x85, 0x98, 0xa3, 0xc4,
    0x90, 0xc4, 0x06, 0x16, 0x37, 0x06, 0x04, 0x00, 0x00, 0xff, 0xff, 0xf3, 0xba, 0x74, 0x95, 0xc0,
    0x00, 0x00, 0x00,
}

而这一部分代码主要是围绕 fileDescriptor 进行,在这里 fileDescriptor_search_8b45f79ee13ff6a3 表示一个编译后的 proto 文件,而每一个方法都包含 Descriptor 方法,代表着这一个方法在 fileDescriptor 中具体的 Message Field

golang pb.go 为什么要生成 fileDescriptor?

在 Golang 中使用 Protocol Buffers (PB) 进行编解码时,使用的是 proto 编译器将 .proto 文件编译为对应语言的源代码。在 Golang 中,生成的源代码中会包含 pb.go 文件,它是由 proto 编译器生成的。

pb.go 文件中包含了与 .proto 文件对应的结构体、接口、函数等信息。而 fileDescriptor 是 pb.go 文件中的一部分,它是一个包含了 pb.go 中所有消息、服务、枚举等定义的描述符,它可以让程序在运行时动态地获取这些信息,以便进行消息的编解码、RPC 调用等操作。

具体来说,fileDescriptor 的生成过程包括以下几个步骤:

  • 将 .proto 文件解析为内存中的数据结构。
  • 根据数据结构生成描述符,包括文件描述符、消息描述符、服务描述符等。
  • 将描述符序列化为二进制格式,并嵌入到 pb.go 文件中。

因此,fileDescriptor 是在 pb.go 文件中嵌入的一个二进制数据块,它是由 proto 编译器自动生成的,用于提供消息编解码和 RPC 调用等运行时支持。

https://eddycjy.gitbook.io/golang/di-4-ke-grpc/client-and-server

其他

Go是如何实现protobuf的编解码的(2): 源码

https://cloud.tencent.com/developer/beta/article/1500958

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值