C++中protobuffer的具体使用方法以及重要原理的实现
一、protobuffer的具体使用
对于基本的知识可以看我之前的文章。 那一片文章主要是知识点,这一片是实战。
1、头部
我们通过syntax 这个来指定版本号,如果不写的话就会默认为proto2,2这个版本是一个比较旧的版本。旧的版本写起来就比较繁琐。
syntax = "proto3"; // 默认是proto2
package tutorial; // package类似C++命名空间 比如: std::cout 这样
// 可以引用本地的,也可以引用include里面的,已经写好的proto文件是可以引用
import "google/protobuf/timestamp.proto"; //这里的是用来调用Timestamp 类型适合高精度时间戳的场合
2、优化
2.1、speed:这是默认的优化级别,他生成代码注重于解码和编码的速度,但是可能专用较多的内存和生成较大的二进制文件。
2.2、code_size:这个和speed正好相反,这个级别生成的代码较小,适合于对于代码大小有严格要求的场景,但是,他可能会牺牲一些性能。
2.3、lite_runtime:这个级别旨在生成尽可能小的运行库,同时保持合理的解码和编码性能。它特别适用于移动设备和资源受限的环境,因为减少代码大小可以减少应用的内存占用和加载时间。
option optimize_for = LITE_RUNTIME; //编译优化选项
option optimize_for = SPEED;
option optimize_for = CODE_SIZE;
3、生成位置
其中路径1为.proto所在的路径,路径2为.cc和.h生成的位置。将指定proto文件生成.pb.cc和.pb.h。
protoc -I=/路径1 --cpp_out=./路径2 /路径1/addressbook.proto
protoc -I=./ --cpp_out=./ addressbook.proto //生成到当前目录中
//将对应目录的所有proto文件生成.pb.cc和.pb.h
protoc -I=./ --cpp_out=./ *.proto
4、定制选项
当需要用到以下语言的时候,就可以定制特定语言的可选选项。
//下面是针对不同的语言进行的优化选项,或者是定制的选项
// [START java_declaration]
option java_package = "com.example.tutorial";
option java_outer_classname = "AddressBookProtos";
// [END java_declaration]
// [START csharp_declaration]
option csharp_namespace = "Google.Protobuf.Examples.AddressBook";
// [END csharp_declaration]
5、主体部分
我们通过创造通讯录的方法来进行实战。其中protobuffer的写法类似于C++中类的写法。我们来看看这里面都写了什么吧:
首先就是创建类,可以写上类的名字,接下来就是保留id号,可以保留以后想要使用的id号,这样就可以避免不小心使用掉这些数字。下面就是指定名称了,他们每个名称都要通过这些编号进行搜索。
当使用的是旧版本,我们需要在每一个名称前面进行加入关键字:singular,repeated,option。但是新版本就解决了这个小问题,可以不用全部加了,减轻负担,默认都是singular,但是特殊的需要加。
接下来就是enum的枚举类型了,比如我们想让用户写入的是我们指定的数据,那么就可以通过枚举来实现。
然后就是嵌套类型了,可以多种不同的类型进行嵌套,后面就是oneof的类型,主要是解决多个字段的中的某一个,当选择了其中一个之后别的别的就会自动进行删除操作,这样可以节省内存。
// 通讯录
// [START messages]
message Person { // message类似C++的class
reserved 8,15,9 to 11; //这里的reserved是保留字
reserved "foo","bar";
string name = 1; // 名字
int32 id = 2; // Unique ID number for this person. 每个人的唯一id
string email = 3;
enum PhoneType { // enum 枚举类型
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
string number = 1; // 字符串 电话号码
PhoneType type = 2; //
}
repeated PhoneNumber phones = 4; // 重复0~多个,一个人有多个电话 在前面加入repeated 后,这个就可以支持多个数据。
google.protobuf.Timestamp last_updated = 5; // import "google/protobuf/timestamp.proto" 这里是一个时间戳类型的值。
}
// Our address book file is just one of these.
message AddressBook {
repeated Person people = 1; // 电话簿有多人的电话
message Samplemessage
{
oneof test_oneof{
string name = 2;
submessage sub_message = 3;
}
}
// [END messages]
二、重要原理-编码原理
首先我们要理解变长编码(Varints:base128)和固定编码。举个例子:int32_t :0x12345678占用四字节,int32_t:0x12 占用四字节,这里都是占用的四字节,这里采用的就是定长编码,当使用变长编码,0x12345678占用四字节,而0x12 占用一字节。
原理:base128 使用每个字节的最高有效位作为标志位, 而剩余的 7 位以二进制补码的形式来存储数字值本身, 当最高有效位为 1 时, 代表其后还跟有字节, 当最高有效位为 0 时, 代表已经是该数字的最后的一个字节;
我们假设数字1的int类型占四个字节。那么他的标准整型存储的二进制应该是这样的:
可以看到只有最后一个字节存储了有效数值,前三个字节都是0。
为什么设计变⻓编码:普通的 int 数据类型, 无论其值的大小, 所占用的存储空间都是相等的,比如不管是0x12345678 还是0x12都占用4字节,那能否让0x12在表示的时候只占用1个字节呢?
是否可以根据数值的大小来动态地占用存储空间, 使得值比较小的数字占用较少的字节数, 值相对比较大的数字占用较多的字节数, 这即是变⻓整型编码的基本思想。
采用变⻓整型编码的数字, 其占用的字节数不是完全一致的, Varints 编码使用每个字节的最高有效位作为标志位, 而剩余的 7 位以二进制补码的形式来存储数字值本身, 当最高有效位为 1 时, 代表其后还跟有字节, 当最高有效位为 0 时, 代表已经是该数字的最后的一个字节。
如果采用Varints编码,那么二进制就是:
我们可以再举个例子:666 。从下面的编码解码过程可以看出, 可变⻓整型编码对于不同大小的数字, 其所占用的存储空间是不同的。
三、protobuffer的总结
1:Protobuf 采用 Varints 编码和 Zigzag 编码来编码数据, 其中 Varints 编码的思想是移除数字高位的 0, 用变⻓的二进制位来描述一个数字, 对于小数字, 其编码⻓度短, 可提高数据传输效率, 但由于它在每个字节的最高位额外采用了一个标志位来标记其后是否还跟有有效字节, 因此对于大的正数, 它会比使用普通的定⻓格式占用更多的空间, 另外对于负数, 直接采用 Varints编码将恒定占用 10 个字节, Zigzag 编码可将负数映射为无符号的正数, 然后采用 Varints 编码进行数据压缩, 在各种语言的 Protobuf 实现中, 对于 int32 类型的数据, Protobuf 都会转为 uint64 而后使用 Varints 编码来处理, 因此当字段可能为负数时,我们应使用 sint32 或 sint64, 这样 Protobuf 会按照 Zigzag 编码将数据变换后再采用 Varints 编码进行压缩, 从而缩短数据的二进制位数。
2:Protobuf 不是完全自描述的信息描述格式, 接收端需要有相应的解码器(即 proto 定义)才可解析数据格式, 序列化后的 Protobuf 数据不携带字段名, 只使用字段编号来标识一个字段, 因此更改 proto 的字段名不会影响数据解析(但这显然不是一种好的行为), 字段编号会被编码进二进制的消息结构中, 因此我们应尽可能地使用小字段编号。
3:Protobuf 是一种紧密的消息结构, 编码后字段之间没有间隔, 每个字段头由两部分组成: 字段编号和 wire type, 字段头可确定数据段的⻓度, 因此其字段之前无需加入间隔, 也无需引入特定的数据来标记字段末尾, 因此 Protobuf 的编码⻓度短, 传输效率高。
四、协议升级
当我们要将之前的protobuffer进行升级的时候,我们就会体会到他的优势了,我们需要满足一些规则:
1:不要修改之前字段的数据结构。
2:如果您添加新字段,则任何由代码使用“旧”消息格式序列化的消息仍然可以通过新生成的代码进行分析。您应该记住这些元素的默认值,以便新代码可以正确地与旧代码生成的消息进行交互。同样,由新代码创建的消息可以由旧代码解析:旧的二进制文件在解析时会简单地忽略新字段。
3:int32,uint32,int64,uint64 和 bool 全都兼容。这意味着您可以将字段从这些类型之一更改为另一个字段而不破坏向前或向后兼容性。如果一个数字从不适合相应类型的线路中解析出来,则会得到与在 C++ 中将该数字转换为该类型相同的效果(例如,如果将 64 位数字读为 int32,它将被截断为 32 位)。
4:enum 就数组而言,是可以与 int32,uint32,int64 和 uint64 兼容(请注意,如果它们不适合,值将被截断)。但是请注意,当消息反序列化时,客户端代码可能会以不同的方式对待它们:例如,未识别的 proto3 枚举类型将保留在消息中,但消息反序列化时如何表示是与语言相关的。(这点和语言相关,上面提到过了)Int 域始终只保留它们的值。
5:将单个值更改为新的成员是安全和二进制兼容的。如果您确定一次没有代码设置多个字段,则将多个字段移至新的字段可能是安全的。将任何字段移到现有字段中都是不安全的。(注意字段和值的区别,字段是 field,值是 value)。