- protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。
- protobuf(Protocol Buffers)是一款序列化编码框架,经常在一些RPC(远程调用)协议中出现。但其实protobuf可以理解成是一款序列化协议,和json、xml一样,使用该框架,需要在自己的结构上构建的数据。
- 而且protobuf序列化的体积比xml、json小得多,所以protobuf经常在一些网络框架中使用。
protobuf特点:
举一个登录信息网络传输的示例,熟悉一下如何使用protobuf将数据传输给服务端。
登录信息包括5个数据:
- user_name
- password
- online_status
- client_type
- client_version
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使用)。
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目前支持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 编码有以下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文件;相对这种场景下操作更复杂了。
详细内容参考https://www.jianshu.com/p/a24c88c0526a