protobuf原理以及实例(Varint编码)

protobuf定义

  • protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。
  • protobuf(Protocol Buffers)是一款序列化编码框架,经常在一些RPC(远程调用)协议中出现。但其实protobuf可以理解成是一款序列化协议,和json、xml一样,使用该框架,需要在自己的结构上构建的数据。
  • 而且protobuf序列化的体积比xml、json小得多,所以protobuf经常在一些网络框架中使用。

protobuf特点:

  • ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台。并且客户端和服务端还可以使用不同的语言编写。
  • 使用protobuf序列化数据传输速度快,比XML数据快数10倍。主要得益于他的编码方式。
  • 扩展性、兼容性好。更新数据结构,不影响和破坏原有的旧程序。

protobuf实例

举一个登录信息网络传输的示例,熟悉一下如何使用protobuf将数据传输给服务端。

登录信息包括5个数据:

  • user_name
  • password
  • online_status
  • client_type
  • client_version
  1. 编写proto文件如下,将传输的数据结构放在message 字段。message 是一个关键字,后面跟上自定义的消息名称,如IMLoginReq:
syntax = "proto3"; // 版本指定,包括proto2和proto3 版本
package IM.Login;	//IM::Login -> package IM.Login   类似于命名空间
import "IM.BaseDefine.proto";	// 引用文件 引用其他的proto文件

option optimize_for = LITE_RUNTIME;  //编译优化
//IMLoginReq:描述的一个类
message IMLoginReq{
	string user_name = 1;
	string password = 2;
	IM.BaseDefine.UserStatType online_status = 3;
	IM.BaseDefine.ClientType client_type = 4;
	string client_version = 5;
}

注意:每个字段的编号,需要按照顺序从1开始规则定义,最小编号是 1,最大的是 2^29 -1即536,870,911,其中 19000 到 19999不能使用(内定为Protocol Buffers使用)。

  1. 编写完proto文件后,通过protoc进行编译
protoc --cpp_out=. login.proto

此处编译结果会生成两个文件,分别是login.pb.h和login.pb.cc的文件。生成的文件中有一些接口之后需要使用到。需要将生成的文件复制到客户端和服务端各一份供接口调用。

3.客户端设置

#include 
#include 
#include 
#include 
#include "login.pb.h"

using namespace std;

int main() {
  // 创建一个msg对象
  // 设置登录信息
	IM::Login::IMLoginReq msg;
	msg.set_user_name("aries");
	msg.set_password("123456");	
	msg.set_online_status(IM::BaseDefine::USER_STATUS_ONLINE);
	msg.set_client_type(IM::BaseDefine::CLIENT_TYPE_WINDOWS);
	msg.set_client_version("1.0");

  // 将Person对象序列化为字节流
  string buffer;
  msg.SerializeToString(&buffer);

  // 创建socket并连接到服务器
  int sockfd = socket(AF_INET, SOCK_STREAM, 0);
  struct sockaddr_in servaddr;
  servaddr.sin_family = AF_INET;
  servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
  servaddr.sin_port = htons(8080);
  connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));

  // 发送字节流到服务器
  send(sockfd, buffer.c_str(), buffer.size(), 0);

  // 关闭socket
  close(sockfd);

  return 0;
}

注意:set_user_name、set_password这些函数接口是通过protoc自动生成在.cc和.h文件中。

其中msg.SerializeToString(&buffer);用于序列化msg数据。这里序列化数据后通过通信协议传输可以节省传输的带宽。

4.服务端设置

在服务端将接收到客户端发送的字节流,解析为msg对象。

#include 
#include 
#include 
#include 
#include "login.pb.h"

using namespace std;

int main() {
  // 创建socket并绑定到端口
  int listenfd = socket(AF_INET, SOCK_STREAM, 0);
  struct sockaddr_in servaddr;
  servaddr.sin_family = AF_INET;
  servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
  servaddr.sin_port = htons(8080);
  bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
  listen(listenfd, 1);

  // 接收客户端连接并接收数据
  struct sockaddr_in cliaddr;
  socklen_t clilen = sizeof(cliaddr);
  int connfd = accept(listenfd, (struct sockaddr*)&cliaddr, &clilen);
  char buffer[1024];
  int n = recv(connfd, buffer, sizeof(buffer), 0);

  // 将字节流解析为msg对象
  IM::Login::IMLoginReq msg;
  msg.ParseFromArray(buffer, n);

  // 打印Person对象
  cout << "Name: " << msg.user_name() << endl;
  cout << "password: " << msg.password() << endl;
	std::string client_version = msg.client_version();
	IM::BaseDefine::ClientType client_type = msg.client_type();
  // 关闭socket
  close(connfd);
  close(listenfd);

  return 0;
}

注意:msg.user_name、msg.password这些函数也是通过protoc编译器自动生成在.cc和.h文件中

示例中创建了一个socket并绑定到端口,然后接收客户端连接并接收数据。然后将接收到的字节流解析为msg对象,并打印出信息。

protobuf编码

protobuf目前支持6种编码类型

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited(长度分割) string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float

其中Varint 编码最常用,可以看到int,bool,enum这类数据都使用的Varint 编码。Start group和End group 已经放弃使用了。

Varint 编码

Varint 编码有以下3个特点:

  • 在每个字节开头的 bit 设置了 msb(most significant bit),标识是否需要继续读取下一个字节
  • 存储数字对应的二进制补码
  • 补码的低位排在前面

用一个例子来理解这些特点:

比如存储一个int32类型的数据

int32 num = 1;

1的二进制为0000 0000 0000 0001(32位),因此在内存中保存这个1需要消耗2个字节。

而使用Varint 编码保存这个1,则是0000 0001,只需要8个字节。这其中包含了一些编码规则。

再用一个例子,保存

int32 num = 500;

500的二进制0000 0001 1111 0100(32位)。使用Varint 编码保存为以下形式

1111 0100 0000 0011。看起来没有规律,但其实很简单,如下

将0000 0001 1111 0100 从右到左按照7位分成0000011和1110100(把最前面的0去掉)。然后把低位1110100放在前面,并且最前面增加一个标志位1,即11110100;高位放在后面,并且最前面增加一个标志位0,即00000011。把两个拼在一起即1111010000000011。这就是按照Varint 三个特点实现的编码规则。

标志位:表示是否已经结束(是否还需要读取下一个字节),为1则表示还需要继续读下一个字节;为0表示这就是最后一个字节,不需要再读写一个字节了。

protobuf就是通过Varint 这样的编码方式,来减少序列化后的字节,下面是测试10万次序列化的对比

protobuf原理以及实例(Varint编码)_第1张图片

Varint 由于标志位占用了一位,那如果一个值为0xff ff ff ff那需要多少个字节存储?
答:0xff ff ff ff需要分配32个bit,使用Varints 编码需要的字节数:
32/7=4.57, 就是需要5个字节存储。 从这里看得出来,如果>=28bit的整数不适合使用变长Varint 编码,如果整数都是32bit>= 变量 >28bit可以考虑使用fixed32, sfixed32等固定4字节的类型。

在日常使用情况下,大部分的数据都会小于28bit的,所以说实际场景protobuf的效率仍然很高。

为什么补码的低位排在前面

我们调用序列化时,最终会调用底层的WriteVarint32ToArray 函数,这是是 Varint 编码的特点。

inline uint8* CodedOutputStream::WriteVarint32ToArray(uint32 value, uint8* target) {
  // 0x80 -> 1000 0000
  // 大于 1000 0000 意味这进行 Varints 编码时至少需要两个字节
  // 如果 value < 0x80,则只需要一个字节,编码结果和原值一样,则没有循环直接返回
  // 如果至少需要两个字节
  while (value >= 0x80) {
    // 如果还有后续字节,则 value | 0x80 将 value 的最后字节的最高 bit 位设置为 1,并取后七位
    *target = static_cast<uint8>(value | 0x80);
    // 处理完七位,后移,继续处理下一个七位
    value >>= 7;
    // 指针加一,(数组后移一位)  相当于后移了8位 
    ++target;
  }
  // 跳出循环,则表示已无后续字节,但还有最后一个字节
  // 把最后一个字节放入数组
  *target = static_cast<uint8>(value);
  // 结束地址指向数组最后一个元素的末尾
  return target + 1;
}

从代码中可以看出来我们是从最低的7位开始处理的,通过移位指令一直处理到高位,直到剩余高位小于0x80,从代码逻辑可以看出这样的编码形式的优雅。

protobuf不能完全代替json,就像这个登录的例子一样,通过json的话只需要把数据的格式传给服务端就好了。而protobuf还需要将proto文件,还需要protoc编译出.cc、.h文件;相对这种场景下操作更复杂了。

详细内容参考https://www.jianshu.com/p/a24c88c0526a

你可能感兴趣的:(开发语言,rpc)