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
- 编写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使用)。
- 编写完proto文件后,通过protoc进行编译
protoc --cpp_out=. login.proto
此处编译结果会生成两个文件,分别是login.pb.h和login.pb.cc的文件。生成的文件中有一些接口之后需要使用到。需要将生成的文件复制到客户端和服务端各一份供接口调用。
3.客户端设置
#include <iostream> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #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 <iostream> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #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万次序列化的对比
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文件;相对这种场景下操作更复杂了。