找回密码
 立即注册
查看: 244|回复: 5

protobuf怎样传输复杂数据结构?(c++ 与 C#)

[复制链接]
发表于 2023-3-1 14:47 | 显示全部楼层 |阅读模式
我的目的是利用protobuf进行C++与C#之间的序列化与反序列化,基本的数据类型是可以实现的,但是例如C#中的dictionary不知该如何实现,各位有什么思路吗?

c++ 用map?
发表于 2023-3-1 14:54 | 显示全部楼层
1 说在前面

因为看到网上都是一些零零散散的 protobuf 相关介绍,加上笔者最近因为项目的原因深入剖析了一下 protobuf,所以想做一个系统的《精通 protobuf 原理》系列的分享:

  • 「精通 protobuf 原理之一:为什么要使用它以及如何使用」;
  • 「精通 protobuf 原理之二:编码原理剖析」;
  • 「精通 protobuf 原理之三:反射原理剖析」;
  • 「精通 protobuf 原理之四:RPC 原理剖析」;
  • 「精通 protobuf 原理之五:Arena 分配器原理剖析」。
  • 后续的待定……
本文是系列文章的第二篇,本文适合 protobuf 入门、进阶的开发者阅读,是一篇讲原理的文章,主要是介绍了如何正确使用protobuf 的特性,以比较大地发挥它的优势。阅读本文之后,开发者能够对protobuf编码原理有深入的理解,在日常开发中能够熟练运用。
本文基于protobuf 的3.17.3版本进行分析、proto3的语法、编码示例使用C++语言实现。
阅读本文大概需要十分钟左右。建议读者先阅读目录,先大概了解有哪些内容,然后在选择全部阅读还是选择性阅读,以提高阅读效率。
2 初识protobuf语法

protobuf 官方实现了一门语言,专门用来自定义数据结构。protoc是这门语言的编译工具,可编译生成指定编程语言(如C++、Java、Golang、Python、C# 等)的源代码,然后开发者可以轻松在这些语言中使用该源代码进行编程。
先从以下serialize.proto开始。
//协议版本
syntax = "proto3";

//命名空间
package mytest;

//依赖的其他 proto 源文件,
//在依赖的数据类型在其他 proto 源文件中定义的情况下,
//需要通过 import 导入其他 proto 源文件
import "google/protobuf/any.proto";

//message 是消息体,它就是一个结构体/类
message SubTest {
  int32              i32      =   1;
}

message Test {
//[数据类型]   [字段]       [field-number]
  int32              i32      =   1;
  int64              i64      =   2;
  uint32             u32      =   3;
  uint64             u64      =   4;
  sint32             si32     =   5;
  sint64             si64     =   6;
  fixed32            fx32     =   7;
  fixed64            fx64     =   8;
  sfixed32           sfx32    =   9;
  sfixed64           sfx64    =   10;
  bool               bl       =   11;
  float              f32      =   12;
  double             d64      =   13;
  string             str      =   14;
  bytes              bs       =   15;
  repeated int32     vec      =   16;
  map<int32, int32>  mp       =   17;
  SubTest            test     =   18;
  oneof object {
    float            obj_f32  =   19;
    string           obj_str  =   20;
  }
  google.protobuf.Any any     =   21;
}
3 基于protobuf的编程示例

$ tree ./test_serialize
./test_serialize
├── Makefile
└── test_serialize.cpptest_serialize.cpp源文件:
#include <cstdio>
#include <iostream>
#include "test.pb.h"

//输出十六进制编码
static void dump_hexstring(const std::string& tag, const std::string& data) {
  printf("%s:\n", tag.c_str());
  for (size_t i = 0; i < data.size(); ++i) {
    printf("%02x ", (unsigned char)data);
  }
  printf("\n\n");
}

static void test_1() {
  mytest::Test t1;
  t1.set_i32(300);
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
Makefile源文件:
CC = g++
CXXFLAGS = -std=c++11
TARGET = test_serialize
SOURCE = $(wildcard *.cpp)
OBJS = $(patsubst %.cpp, %.o, $(SOURCE))
INCLUDE = -I./
LIBS = -lproto -lprotobuf
LIBPATH = -L../proto

$(TARGET): $(OBJS)
    $(CC) $(CXXFLAGS) -o $@ $^ $(LIBPATH) $(LIBS)

%.o: %.c
    protoc -I=./ --cpp_out=./ ./echo.proto
    $(CC) $(CXXFLAGS) $(INCLUDE) -o $@ -c $^                                                                                                                                                                        

.PHONY:clean
clean:
    rm -f *.o $(TARGET)编译和执行:
$ make
$ ./test_serialize
==== test_1 ====:
08 ac 024 Protobuf 的数据类型

以下是一个protobuf 数据类型和其他编程语言的数据类型的映射关系表。
Protobuf Type说明C++ TypeJava TypePython Type[2]Go Type
float固定4个字节floatfloatfloatfloat32
double固定8个字节doubledoublefloatfloat64
int32varint编码int32intintint32
uint32varint编码uint32intint/longuint32
uint64varint编码uint64longint/longuint64
sint32zigzag 和 varint编码int32intintint32
sint64zigzag 和 varint编码int64longint/longint64
fixed32固定4个字节uint32intintuint32
fixed64固定8个字节uint64longint/longuint64
sfixed32固定4个字节int32intintint32
sfixed64固定8个字节int64longint/longint64
bool固定一个字节boolbooleanboolbool
stringLenth-Delimiteduint64Stringstr/unicodestring
bytesLenth-DelimitedstringByteStringstr[]byte
bytesLenth-DelimitedstringByteStringstr[]byte
这里读者先有个大概的印象,后面会详细介绍每个数据类型。
5 protobuf编码方式

在详细介绍protobuf 的数据类型之前,这里先了解一下protobuf的编码,在后面介绍每个数据类型的时候会用到这些知识点。
wire-type名称说明类型
0Varint可变长整形非ZigZag编码类型:int32, uint32, int64, uint64, bool, enum; ZigZag编码类型:sint32, sint64
164-bits固定8个字节大小fixed64, sfixed64, double
2Length-delimitedLength + Body方式string, bytes, embedding message, packed repeated fields
532-bits固定4个字节大小fixed32, sfixed32, float
注:wire_type 为3、4 的编码类型官方已经弃用,所以这里也不在介绍。
5.1 Varint

5.1.1 Varint 是什么

Varint 编码是一种可变长的编码方式,值越小的数字,使用越少的字节数表示。它的原理是通过减少表示数字的字节数从而实现数据体积压缩。
先理解几个概念,因为后面需要用到这些概念:

  • field-number:指的是 message 中的最后一列的数字;
protobuf   int32  i32  =  1; //其中 1 就是 field-number。

  • wire-type:编码类型,比如 Varint 的编码类型为 0「见以上“5 protobuf编码方式”的表格」;
  • msb:全称 most significant bit,指的是每个字节的最高位 (例如:0x80 的 二进制是 10000000,其最高位是 1,即msb 为 1)。
Varint 是怎样编码的?先了解一下 Tag 信息 和 Data 信息:

  • Tag 信息:主要存储 field-number 和 wire-type;
  • Data 信息:编码后的序列。
注:Varint 编码序列 = Tag信息  + Data信息。
5.1.2 Tag 信息

使用一个字节来表示  Tag 信息,高5 位表示field-number,低 3 位表示 wire-type。
[7] [6] [5] [4] [3] [2]  [1]  [0]
|<----- field ----->|<-- wire -->|
        number           type
注:这个使用一个字节并非编码后的一个字节,而是编码前的一个字节,编码后可能是两个字节。为什么呢?因为 计算好 Tag之后,还要经过 Varint 编码才是最终的编码,即 Tag = VarintEncode( field-number << 3 | wire_type)。
5.1.3 Data 信息

在 C++ 中,int 类型的编码是固定的,无论数值大小,都使用固定  4 个字节来存储。假如数值为 1,二进制为 00000000 00000000 00000000 00000001,其实有效值只有最后一个字节 00000001,前三个字节是浪费的。
如果使用 length + body 的方式编码呢?

  • length 能不能和 Tag 公用一个字节?整形最大 8 个字节,二进制 1000,所以需要占用 4 位,wire-type 不能再压缩了,field-number 压缩之后只剩下 1 bit (5 - 4  = 1),这限制了 message 中的字段数量,此方案不可行;
  • 使用单独字段表示length,那么编码之后为 00000001 00000001,第一个00000001 为 length,第二个为值,加上 Tag 一个字节,总共 3 个字节。
但是,Varint 编码可以做到总共可以只用 2 个字节来表示值  1。这是怎么做到的?
注:Varint的每个字节只有低7位存储数据,最高位(即msb)作为标志位,0 代表后面没有再跟字节了,1 代表后面的字节还是属于当前字段的,可以继续读一个字节,以此类推 ……。
1 的 Varint 编码的 data 为 00000001,即0(msb) + 0000001(低 7 位)。
下面详细分析该编码。
在 「基于 protobuf 的编程示例」一节的示例中,int32 类型的 i32 字段(field-number 为 1),其值为 300 的时候,编码结果为 08 ac 02,怎么得来的?
比如这个int32类型字段的值为300,那么序列化之后:
08 ac 02
|  |__|___ 值
|_________ 元数据 (field-number << 3 | wire-type) = (1 << 3 | 0) = 0x08

ac 02 是怎么得来的?

i32的值为 300
|__ 0x012c             //十六进制
|__ 00000001 00101100  //二进制
|__ 0000000100101100   //合并
|__ 00 0000010 0101100 //重新按7位一组切割
|__ 0000010 0101100    //高位全0的组省略
|__ 0101100 0000010    //逆序,因为使用小端字节序,
|__ 10101100 00000010  //每一组加上msb,除了最后一组是msb是0,其他的都为1
|__ ac 02               //十六进制
注: 计算机硬件有两种储存数据的方式:大端字节序(big endian)和小端字节序(little endian)。 举例来说,数值0x2211使用两个字节储存:高位字节是0x22,低位字节是0x11。 大端字节序:高位字节在前,低位字节在后,这是人类读写数值的方法,即以0x2211形式存储; 小端字节序:低位字节在前,高位字节在后,即以0x1122形式储存。
5.2 ZigZag 编码

5.2.1 ZigZag 编码解决了什么问题

先看下背景。假如 int32 类型的字段,其值为 -1 时,在内存中,因为使用了补码,所以存储为 ffffffff(4个字节),然后在 Varint 序列化的之前会强制转换成 int64 类型,这样其值会变为ffffffff ffffffff。转换成 Varint 编码时,会加上 Tag ,以及 msb ,总共是 10 个字节。我们希望其绝对值越小,编码之后使用越少的字节数表示,显然这里编码之后得到的结果和我们期望的结果相悖的,基于这样的原因,才引入了 ZigZag 编码,主要作用是对负数的压缩处理。
5.2.2 ZigZag如何编码

很简单,两个公式就搞定了,没有复杂的编码转换。
zigzag32(n) = (n << 1) ^ (n >> 31)  //对于 sint32
zigzag64(n) = (n << 1) ^ (n >> 63)  //对于 sint64一般情况下我们认为,使用较多的是小整数(确切地说应该是绝对值小的整数),那么较小的整数应使用更少的字节数来编码,ZigZag 编码正是如此,如下表格:
n十六进制zigzag(n)varint(zigzag(n))
000 00 00 0000 00 00 0000
-1ff ff ff ff00 00 00 0101
100 00 00 0100 00 00 0202
-2ff ff ff fe00 00 00 0303
200 00 00 0200 00 00 0404
............
21474836477f ff ff ffff ff ff feff ff ff fe
-214748364880 00 00 00ff ff ff ffff ff ff ff
5.3 Length-delimited

这种编码很容易理解,就是 length + body 的方式,使用一个 Varint 类型表示 length,然后 length 的后面接着 length 个字节的内容。
/* message {
*   string str = 14;
* } */
static void test_1() {
  mytest::Test t1;
  t1.set_str("string"); //field_number = 14, wire_type = 5 (varint)
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
编译和执行结果:
==== test_1 ====:
72 06 73 74 72 69 6e 67分析结果:
72 06 73 74 72 69 6e 67
|  |  s  t  r  i  n  g
|  |  |__|__|__|__|__|__ body 的 ASCII 码
|  |__ length = 6 = 0x06
|__ Tag (field-number << 3 | wire-type) = (14 << 3 | 5) = 114 = 0x726 如何选型

6.1 int家族:int32/uint32/sint32/int64/uint64/sint64

测试 - 1
static void test_1() {
  mytest::Test t1;
  t1.set_i32(1);  //field_number = 1, wire_type = 0 (varint)
  t1.set_i64(2);  //field_number = 2, wire_type = 0 (varint)
  t1.set_u32(1);  //field_number = 3, wire_type = 0 (varint)
  t1.set_u64(2);  //field_number = 4, wire_type = 0 (varint)
  t1.set_si32(1); //field_number = 5, wire_type = 0 (varint)
  t1.set_si64(2); //field_number = 6, wire_type = 0 (varint)
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
编译和执行结果:
==== test_1 ====:
08 01 10 02 18 01 20 02 28 02 30 04分析结果:
08 01  |__ i32 字段
10 02  |__ i64 字段
18 01  |__ u32 字段
20 02  |__ u64 字段
28 02  |__ si32 字段
30 04  |__ si64 字段测试 - 2
static void test_1() {
  mytest::Test t1;
  t1.set_i32(-1);  //field_number = 1, wire_type = 0 (varint)
  t1.set_i64(-2);  //field_number = 2, wire_type = 0 (varint)
  t1.set_u32(-1);  //field_number = 3, wire_type = 0 (varint)
  t1.set_u64(-2);  //field_number = 4, wire_type = 0 (varint)
  t1.set_si32(-1); //field_number = 5, wire_type = 0 (varint)
  t1.set_si64(-2); //field_number = 6, wire_type = 0 (varint)
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
编译和执行结果:
==== test_1 ====:
08 ff ff ff ff ff ff ff ff ff 01 10 fe ff ff ff ff ff ff ff ff 01 18 ff ff ff ff 0f 20 fe ff ff ff ff ff ff ff ff 01 28 01 30 03分析结果:
08 ff ff ff ff ff ff ff ff ff 01  |___ i32 字段
10 fe ff ff ff ff ff ff ff ff 01  |___ i64 字段
18 ff ff ff ff 0f                 |___ u32 字段
20 fe ff ff ff ff ff ff ff ff 01  |___ u64 字段
28 01                             |___ si32 字段
30 03                             |___ si64 字段如何选型?
从编码步骤来看:

  • int32/uint32/int64/uint64:直接进行 Varint 编码;
  • sint32/sint64:先进行 ZigZag 编号,然后再对前者结果进行 Varint 编码,多了一个步骤。
从编码结果字节数来看:

  • int32/int64:横向比较 int32 和 int64 编码结果一样,但是 int64 能够表示更大的数;
  • uint32/uint64:横向比较 uint32 和 uint64 编码结果一样,但是 uint64 能够表示更大的数;
  • sint32/sint64:横向比较 sint32 和 sint64 编码结果一样,但是 sint64 能够表示更大的数;
  • 纵向比较:正数的时候,编码都一样,反而 sint32 和 sint64 多了一个步骤(ZigZag编码),但是负数的情况 sint32 和 sint64 使用的字节数较少。
综上所述,这样选型:

  • 如果确定是正数:
  • 如果数值确定小于等于 UINT32_MAX,可以用 uint32;
  • 如果数值可能大于 UINT32_MAX,则可以用 uint64;
  • 虽然序列化后结果一样,但是考虑到前者可能在内存分配上会少一点,这里说“可能”,是因为还和内存对齐有关系)。
  • 如果可能是负数,其ZigZag编码之后确定是正数:
  • 如果ZigZag编码后的值确定小于等于 INT32_MAX 且大于等于 INT32_MIN,可以用 sint32;
  • 如果ZigZag编码后的值确定可能大于 INT32_MAX 或者 小于 INT32_MIN,则用 sint64;
  • 虽然序列化后结果一样,但是考虑到前者可能在内存分配上会少一点,这里说“可能”,是因为还和内存对齐有关系)。
注:到这里,如果是对 protobuf 比较了解的读者,可能已经发现,以上少考虑了一种情况。因为 Varint 编码后的每个字节只有低 7 位表示 数据(最高位是 msb),那样的话,4 个字节能够表示的最大数为 2^28 - 1(不考虑符号),8 个字节能够表示的最大数为 2^56 - 1(不考虑符号)。 C++ 中的 uint32_t  可以表示的最大数为  2^32-1,uint64_t 可以表示的最大数为 2^64 - 1,那岂不是说在值 大于 2^28 - 1 或者 2^56 - 1 的情况下,其 Protobuf 编码后字节数还比对应的 C++ 类型的字节数还多了? 在 「6.2 fixed32/sfixed32/fixed64/sfixed64」中就提供了解决方案。
6.2 fixed家族:fixed32/sfixed32/fixed64/sfixed64

fixed32/fixed64 分别对应 C++ 类型的 uint32_t 和 uint64_t,sfixed32/sfixed64 分别对应 C++ 类型的 int32_t 和 int64_t。没有经过任何编码,分别使用固定的 4 个字节 和 8 个字节表示该值。
sfixed32/sfixed64 也是分别使用了固定 4 个字节 和 8 个字节表示该值,只不过先经过 ZigZag 编码然后再存储。
综上所述,可以这样选型:

  • 如果确定是正数:
  • 如果数值确定小于等于 UINT32_MAX 且大于 2^28-1,可以用 fixed32(比如:这是一个表示时间戳的字段);
  • 如果数值确定可能大于 2^56-1,则可以用 fixed64(比如纯数字订单号:2021083011405200001,20210830(日期)+114052(时间)+ 00001(5 位系列号))
  • 如果可能是负数,其 ZigZag 编码后确定是正数:
  • 如果 ZigZag 编码后的值确定小于等于 INT32_MAX 且大于 2^28-1,可以用 sfixed32;
  • 如果 ZigZag 编码后的值确定可能大于 2^56-1,则可以用 sfixed64。
6.3 浮点家族:float/double

float 是使用固定 4 个字节来表示浮点数,double 是使用固定 8 个字节来表示浮点数。
有时候我们可以灵活一点,不一定需要用浮点数的类型类表示浮点数,比如有这样一个需求:使用一个字段来表示分数,满分一分,有效值扩展到小数点后 2 位小数(如:99.98分),如果使用 float 编码结果为 5 个字节(Tag 1个字节 + 固定 4 个字节)。
我们换一种思路,把分数转换成整型(如:9998),选型为 int32,那么使用的是 Varint 编码,最后的结果只需 3 个字节。
测试-1
/* message Test {
*   int32 i32 = 1;
*   float fl  = 12;
* }
* */
static void test_1() {
  mytest::Test t1;
  t1.set_i32(9998);     //field_number = 1, wire_type = 0 (varint)
  t1.set_fl(99.98);     //field_number = 12, wire_type = 5 (float)
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
编译和执行结果:
==== test_1 ====:
08 8e 4e 65 c3 f5 c7 42分析结果:
08 8e 4e        |__ i32
65 c3 f5 c7 42  |__ fldouble 也是按同样的思路分析,这里就不再细抠图。
6.4 字符串家族:string/bytes

string 和 bytes 都是字符串,使用了 Length-delimited 的编码方式,见「5.3 Length-delimited」。但是string会做 字符串编码检查,仅支持UTF-8编码或者7-bit ASCII编码的文本,而 bytes 可以是任意字符串。
6.5 序列:repeated

repeated 顾名思义,是重复这个字段,其主要是补充数组功能这块的空白,类似于 C++ 语言中的 vector。
repeated 使用了 Length-delimited 的编码方式,见「5.3 Length-delimited」。先看一下它的序列化模型:
[Tag] [Length] [Data-1][Data-2][Data-3]...[Data-n]测试 - 1
/* message Test {
*   repeated int32 vec = 16;
* } */
static void test_1() {
  mytest::Test t1;
  t1.add_vec(1);  //field_number = 16, wire_type = 2 (Length-Delimited)
  t1.add_vec(2);  //field_number = 16, wire_type = 2 (Length-Delimited)
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
编译和执行结果:
==== test_1 ====:
82 01 02 01 02分析结果:
82 01  |__ Tag
02     |__ Length
01 02  |__ 值 1 和 2测试 - 2
/* message Test {
*   repeated SubTest vec = 16;
* } */
static void test_1() {
  mytest::Test t1;
  t1.add_vec()->set_i32(1);//field_number = 16, wire_type = 2 (Length-Delimited)
  t1.add_vec()->set_i32(2);//field_number = 16, wire_type = 2 (Length-Delimited)
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
编译和执行结果:
==== test_1 ====:
82 01 02 08 01 82 01 02 08 02分析结果:
82 01 02 08 01 //vec[0]
82 01  |__ Tag
02     |__ Length
08 01  |__ Subtest值, 08-> Tag, 01 -> 值

82 01 02 08 02 //vec[1]
82 01  |__ Tag
02     |__ Length
08 02  |__ Subtest值, 08-> Tag, 02 -> 值这里笔者觉得很怪,为什么每个 repeated item 都要重复 Tag 和 Length 呢,这不是会增加无畏的字节码?
问题:不应该是这种方式编码后体积更小吗?为什么不用这种方法呢?
82 01 02 08 01 08 02
82 01  |__ Tag
02     |__ Length
08 01  |__ vec[0] SubTest值, 08 -> Tag, 01 -> 值
08 02  |__ vec[1] SubTest值, 08 -> Tag, 02 -> 值这是因为如果 repeated 类型是基础类型(比如 Varint) 时,会做 packed 优化(也就是压缩)。
综上所述
如果不是很必要,repeated 不要使用复杂的类型,就使用 Varint 的类型就可以了。
比如有这样一个需求,需要存储一个列表,列表的 item 包含两个字段,一个是 appid,一个是整形的 score。那么不建议使用这种:
message Item {
  int64 appid;
  int64 score;
}
message Test {
  repeated Item vec = 1;
}可以使用下面这种(这种序列化知乎包体会小很多,vec size 越大,小得越明显):
message Test {
  repeated int64 vec_appid = 1;
  repeated int64 vec_score = 2;
}6.6 嵌套:embedding message

embedding message,也就是 message 中某个字段的类型是一个 message 的类型,使用了 Length-delimited 编码方式。
测试 - 1
/* message SubTest {
*   int32 i32 = 1;
* }
* message Test {
*   SubTest test = 18; //embedding message
* } */
static void test_1() {
  mytest::Test t1;
  //field_number = 18, wire_type = 2 (Length-delimited)
  t1.mutable_test()->set_i32(1);
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
编译和执行结果:
==== test_1 ====:
92 01 02 08 01分析结果:
92 01   |__ Tag
02      |__ Length
08 01   |__ Data, SubTest值, 08 -> Tag, 01 -> 值6.7 映射:map

map 的底层实现是哈希表。类似 C++ 语言中的 unordered_map。
map 使用了  Length-delimited 编码方式。
测试-1
/* message Test {
*   map<int32, int32>  mp = 17;
* } */
static void test_1() {
  mytest::Test t1;
  //field_number = 17, wire_type = 2 (Length-Delimited)
  t1.mutable_mp()->insert({1, 10});
  t1.mutable_mp()->insert({2, 11});
  t1.mutable_mp()->insert({3, 12});
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}
编译和执行结果:
==== test_1 ====:
8a 01 04 08 01 10 0a 8a 01 04 08 02 10 0b 8a 01 04 08 03 10 0c分析结果:
8a 01  04 08 01 10 0a  |__ Key-Value-Group[0]
|__|   |  |__|  |__|______ Value (10-> Tag ①, 0a -> 值)
    |   |     |____________ Key   (08-> Tag ②, 01 -> 值)
    |   |__________________ Length
    |______________________ Tag

    ①:10 = 2 << 3 | 0 = 16 = 0x10
       (map的value field-number 固定为 2)
    ②:08 = 1 << 3 | 0 = 8 = 0x08
       (map的Key field-number 固定为 1)

8a 01  04 08 02 10 0b  |__ Key-Value-Group[1]
8a 01  04 08 03 10 0c  |__ Key-Value-Group[2]
注:值得注意的是每一组 key-value 都会带上  8a 01  这个 Tag,以及  04  这个 Length。
6.8 any

源文件:include/google/protobuf/any.proto
syntax = "proto3";

package google.protobuf;

option csharp_namespace = "Google.Protobuf.WellKnownTypes";
option go_package = "google.golang.org/protobuf/types/known/anypb";
option java_package = "com.google.protobuf";
option java_outer_classname = "AnyProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";

message Any {
  string type_url = 1;
  bytes value = 2;
}去掉注释之后,也就一个 message,type_url 用来存储类型描述信息,value 用来存储序列化( C++ 是SerializeToString 函数)之后的字符串。
/* message SubTest {
*   int32 i32 = 1;
* }
* message Test {
*   google.protobuf.Any any = 21;
* } */
static void test_1() {
  mytest::SubTest st1;
  st1.set_i32(1);
  mytest::Test t1;
  t1.mutable_any()->PackFrom(st1);  //field_number = 21, wire_type = 2 (Lenth-Delimited)
  //std::cout << t1.any().type_url() << std::endl;
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
编译和执行结果:
==== test_1 ====:
aa 01 28 0a 22 74 79 70 65 2e 67 6f 6f 67 6c 65 61 70 69 73 2e 63 6f 6d 2f 6d 79 74 65 73 74 2e 53 75 62 54 65 73 74 12 02 08 01分析结果:
aa 01           |__ Tag
28              |__ Length (40个字符)
0a 22 74 79 70 65 2e 67 6f 6f 67 6c 65 61 70 69 73 2e 63 6f 6d 2f 6d 79 74 65 73 74 2e 53 75 62 54 65 73 74  |__ 第一个字段(string type_url)
12 02 08 01     |__ 第二个字段(bytes value = 2)

第一个字段(string type_url):
0a              |__ Tag
22              |__ Length (34个字符)
74 79 70 65 2e 67 6f 6f 67 6c 65 61 70 69 73 2e 63 6f 6d 2f 6d 79 74 65 73 74 2e 53 75 62 54 65 73 74        |__ 字符串的ASCCII码(以下是对应的字符)
t  y  p  e  .  g  o  o  g  l  e  a  p  i  s  .  c  o  m  /  m  y  t  e  s  t  .  S  u  b  T  e  s  t

第二个字段(bytes value = 2):
12              |__ Tag
02              |__ Length
08 01           |__ mytest::SubTest的编码: 08 -> Tag, 01 -> 值
注:Any 使用到了reflecttion(反射)功能,C++ 编译链接时不能使用 -lprotobuf-lite,而要使用 -lprotobuf。相关介绍请见 「7 可选项 optimize_for」。
6.9 oneof

如果需求有一条包含许多字段的消息,并且最多同时设置一个字段,那么可以使用 oneof 特性来节省内存。
oneof 字段类似于常规字段,除了 oneof 共享内存的所有字段之外,最多可以同时设置一个字段。设置 oneof  的任何成员都会自动清除所有其他成员。可以使用 case() 或 WhichOneof()方法检查 oneof  中的哪个值被设置(如果有的话),具体取决于您选择的语言。
oneof 不能使用 repeated 字段。
测试-1
/* message Test {
*   oneof object {
*     float   one_fl  = 19;
*     string  one_str = 20;
*   }
* } */
static void test_1() {
  mytest::SubTest st1;
  st1.set_i32(1);
  mytest::Test t1;
  t1.set_one_fl(0.1);
  std::cout << "one_str:" << t1.one_str() << ", one_fl:" << t1.one_fl() << std::endl;
  t1.set_one_str("string");
  std::cout << "one_str:" << t1.one_str() << ", one_fl:" << t1.one_fl() << std::endl; // optimize_for = LITE_RUNTIME 时会 crash,因为 one_fl 被释放掉。
  std::string buf;
  t1.SerializeToString(&buf);
  dump_hexstring("==== test_1 ====", buf);
}

int main() {
  test_1();
  return 0;
}
编译和执行结果:
one_str:, one_fl:0.1
one_str:string, one_fl:0
==== test_1 ====:
a2 01 06 73 74 72 69 6e 67分析结果:
one_str:, one_fl:0.1
one_str:string, one_fl:0
| 设置 one_str 的时候,one_fl被清空了,
| 操作(触发内存分配如执行set_xxx或者mutable_xxxx函数)
| 任何一个成员的时候,会清空所有其他成员。
==== test_1 ====:
a2 01 06 73 74 72 69 6e 67
|__|  |  s  t  r  i  n  g  _____ 字符串
   |  |_________________________ Length
   |____________________________ Tag7 可选项 optimize_for

syntax = "proto3";
option optimize_for = SPEED; //SPEED(默认)、CODE_SIZE、LITE_RUNTIME7.1 SPEED

表示生成的代码运行效率高,但是由此生成的代码编译后会占用更多的空间。
7.2 CODE_SIZE

和SPEED恰恰相反,代码运行效率较低,但是由此生成的代码编译后会占用更少的空间,通常用于资源有限的平台,如移动设备。
7.3 LITE_RUNTIME

生成的代码执行效率高,同时生成代码编译后的所占用的空间也是非常少,但是它会缺少一些属性像 reflection(反射)功能。在 C++ 中依赖 libprotobuf-lite 库,而非 libprotobuf 库。
$ cd /usr/local/Cellar/protobuf/3.17.3/lib/
$ ls -lh *.a
-r--r--r--  1 baron  admin   835K Jun  8 22:15 libprotobuf-lite.a
-r--r--r--  1 baron  admin   4.1M Jun  8 22:15 libprotobuf.a
...
注:看 lite 库的体积大小仅仅 非lite库的 1/5 ~ 1/4 左右。
8 附录

8.1 补码编码

我们先来看一下三个概念:源码、反码、补码

  • 原码:最高位为符号位,剩余位表示绝对值;
  • 反码:除符号位外,对原码剩余位依次取反;
  • 补码:正数补码为其自身,负数补码为除符号位外对原码剩余位依次取反然后加1。
如果计算机存储正数时,存储的是原码:

  • 数字 0  的表示
正数0: [0000 0000]原
负数0: [1000 0000]原

  • 原码中还存在加法错误的问题
1 + (1) = [0000 0001]原 + [1000 0001]原 = [1000 0010]原 = 2如果存储的是补码呢?
正数0 = 负数0 = [0000 0000]补

1 + (1) = [0000 0001]补 + [1111 1111]补 = [0000 0000]补 = 0没错,计算机存储整数时采用的是补码。
此外,整数的补码有一些有趣的性质:

  • 左移 1 位(n << 1),无论正数还是负数,相当于乘以 2 ;对于正数,若大于MAX_INT/2(1076741823),则会发生溢出,导致左移1位后为负数
  • 右移 31 位(n >> 31),对于正数,则返回0x00000000;对于负数,则返回0xffffffff。
9 参考文献

Language Guide (proto3)  |  Protocol Buffers  |  Google Developer
测试相关源码位置:https://github.com/sullivan1205/proto_test_example
10 说在最后

以上就是系列文章第二篇的所有内容。通过本文,读者应该已经了解protobuf各个数据类型是如何编码的,从而可以推算出其编码效率,以及压缩率,为能够选择最优的数据类型提供可靠的参考。按计划下一篇将分享反射的原理。
感谢阅读,如果还想了解更多的内容,请在评论区留言。
欢迎学习交流,也欢迎指正。
发表于 2023-3-1 14:59 | 显示全部楼层
Protobuf是什么

Protobuf是Google开源的可以跨语言的数据序列化方式,可以在合适场景下替代JSON以提高性能,具有以下特点:

  • Google出品,意味着简单易用且高效
  • 跨语言,支持C++、Java、Python、Go ...
  • 编解码性能高,编码后体积小
  • API完善,提供与原生数据结构近乎相同的操作方法
适用于:

  • RPC数据传输
  • 数据存储
数据类型

为了压缩序列化后的数据大小,Protobuf提供了多种数据类型供选择。 以32位整数为例: 当数值为无符号数,且小于2^28^,选择uint32,若大于2^28^,则选择fixed32 当数值为有符号数,若数据出现负数频率较大,使用sint32,当数据有可能出现负数,但频率较低,则应该选择int32,在数值总是比较大时,应选择sfixed32。
.proto TypeNotesC++ Type
doubledouble
floatfloat
int32有符号,变长编码,编码负数时不够高效int32
int64有符号,变长编码,编码负数时不够高效int64
uint32无符号,变长编码uint32
uint64无符号,变长编码uint64
sint32有符号,变长编码,使用ZigZag优化,编码负数时更高效int32
sint64有符号,变长编码,使用ZigZag优化,编码负数时更高效int64
fixed32无符号,固定4字节编码,数据大于2^28^时,效率比uint32高uint32
fixed64无符号,固定8字节编码,数据大于2^56^时,效率比uint64高uint64
sfixed32有符号,固定4字节编码int32
sfixed64有符号,固定8字节编码int64
boolbool
string必须为UTF-8编码或者7bit ASCII字符串string
bytes字节数组string
消息类型

Message
message SearchRequest {
  required string query = 1;
  optional int32 page_number = 2;
  optional int32 result_per_page = 3;
  optional Corpus corpus = 4 [default = UNIVERSAL];
}最常用的消息定义方式如上所示,可以将SearchRequest理解为一个结构体,具有3个特定类型的字段,可对message进行序列化和反序列化操作。
枚举

enum Corpus {
  UNIVERSAL = 0;
  WEB = 1;
  IMAGES = 2;
  LOCAL = 3;
  NEWS = 4;
  PRODUCTS = 5;
  VIDEO = 6;
}除了message,枚举也是常用的类型,上方代码段中Corpus即是一个枚举类型,使用枚举的时候在message中定义optional Corpus corpus = 4 即可,例如message SearchRequest。
Maps
map<string, Project> projects = 1;map类型也是一种常用类型,key 可以是任何 int 或者 string 类型(除去 float、double 和 bytes),枚举值也不能作为 key,value可以是除去 map 以外的任何类型,即map不支持嵌套。
编码原理



一种常用的编码格式是:type-length-value,type用于标记数据类型,length标记需要value的长度,value为所要读取的数据。假设要读取一个文本文件,为了节省空间,没有任何换行符,从第一个字节开始,先读取固定长度的type值,然后读取固定长度的length值,最后读取length数量的字节作为value,再根据type对value进行解析,完成一个field的读取,接着重复这个过程直到文件最后一个字节。当type为特定类型时,length是可以去除的,例如type为int32,那length默认为8 bytes,直接读取8 bytes作为value即可。
protobuf中也采用了类似的编码结构,对数值类型舍去了length字段,type字段使用field_numer和wire_type替代。


type计算方法:(field_number << 3) | wire_type
field_numer为定义message字段时分配的编号,wire_type为protobuf协议定义好的数据类型,目前wire_type已经定义了以下6种类型:
TypeMeaningUsed For
0Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum
164-bitfixed64, sfixed64, double
2Length-delimitedstring, bytes, embedded messages, packed repeated fields
3Start groupgroups (deprecated)
4End groupgroups (deprecated)
532-bitfixed32, sfixed32, float
wire_type固定占用3 bit,也就是最多支持8种不同的type,整个type域最大可使用32 bit的空间,除去wire_type的3 bit,filed_num最大可为2^29 - 1。整个type域在存储的时候也会使用Varints编码进行压缩,所以当filed_num处于1-15范围内时,只需要占用1个bytes(01111000),当filed_num处于16 - 2047范围时,需要占用2个bytes(11111111 01111000)。所以对于高频使用的字段,应该设定较小的filed_num,可以使用reserved对需要的filed_num进行保留。
Varints编码

对于不包含length字段的编码格式,如何确定value的长度以对各个数据进行分割?一种方法是根据type类型确定value域长度,这种方法的问题在于会浪费一定的存储空间,例如存储数字1,也需要int32的类型,若增加type的数量,则存储type占用的空间也会相应增加;第二种方法则是protobuf采用的Varint类型。
varints是一种使用一个或多个字节表示整型数据的方法。其中数值本身越小,其所占用的字节数越少。要求在每个字节开头的bit 设置了 msb(most significant bit ),标识是否需要继续读取下一个字节,如果是1则继续读取,否则认为已经读取到最后一个字节,字节中的其余七位将用于存储数据本身, Varints编码存储对应的二进制补码。
看一个google官方的例子:
对于数字1,其编码后如下,由于1只需要1个byte即可存储下,所以不需要读取下一个字节,当前字节的数据已经是完整数据,所以字节的第一个bit(msb的位置)设置为了0,直接解析成数值1。
0000 0001对于十进制的int类型数值300,protobuf编码后生成下面两个字节数据:
10101100 00000010从左往右读取,第一个字节的msb=1,所以需要继续往后再读取一个字节,这时读取到的字节msb=0,则数据已经读取到最后一个字节,读取完毕,若第二个字节的msb依然为1,则继续往后读取,直到读取到msb=0的字节。之后对这两个字节的数据进行解析。
解析的第一步,去除每个字节的msb位,每个字节只剩下 7 bits:
1010 1100 0000 0010
→ 010 1100  000 0010之后对字节进行反转得到补码,还原成原码即可:
010 1100 0000 010
→ 0000010 0101100
→ 100101100 = 300Varints 的本质实际上是每个字节都牺牲一个 bit 位(msb)来表示是否已经结束,msb 实际上就起到了 length 的作用,正因为有了 msb(length),所以可以根据数字大小动态调整需要的字节数量。通过varints我们可以让小的数字用更少的字节表示,从而提高了空间利用和效率。
缺点:
varints会把数字表示的最大范围缩小,比如uint使用varints就只能表示2^28,这是因为极端情况使用了4个 bit记录msb。
其次,在一个变量频繁的大于2^28次方时,我们编码后也不会节省任何的空间,因此varints编码只适用于相对较小的数字的序列化。如果序列化的数字对象为负数,不会有任何空间的节省,因为符号位在最前会被视为很大的一个数。但是在面对较小的数字的时候,varints的效果是非常显著的。
ZigZag编码

问题:Varints编码希望以标志位能够节省掉高位的0,但是负数的最高位一定是1,不会节省空间
ZigZag 将有符号整数映射到无符号整数,然后再使用 Varints 编码,从结果来看可以看出其名字的由来: 0, -1, 1, -2, 2 编码后变为了 0, 1, 2, 3, 4. 负数和正数以绝对值不断增大的方式来回在数轴上跳跃穿插。
映射表示例如下:
Signed OriginalEncoded As
00
-11
12
-23
21474836474294967294
-21474836484294967295
sint32计算方法:
(n << 1) ^ (n >> 31)sint64计算方法:
(n << 1) ^ (n >> 63)zig zag编码过程如下:
假设使用int32存储正数1和负数1,其补码为:
1  -> 00000000 00000000 00000000 00000001
-1 -> 11111111 11111111 11111111 11111111第一步,先将符号位调整到最后
1  -> 0000000 00000000 00000000 00000001_0
-1 -> 1111111 11111111 11111111 11111111_1第二步,对于负数把除了符号位以外的位都反转过来,对于正数则不变(除了将符号位也放在最后),则可以得到映射的正数值
1  -> 0000000 00000000 00000000 00000001_0  -> 2
-1 -> 0000000 00000000 00000000 00000000_1  -> 1Varints将无用的字节省略掉,zig zag则解决了负数的问题,但是这二者在面对绝对值较大的数字时效率并不会提升,因此在应用时应当分清不同的场景,仅在序列化绝对值较小的数字时才会使用这两种编码方式。
对于非varints编码的数值类型(参考上文wire_type表格),例如double 、fixed64等,解析时根据数值类型读取固定大小的字节数即可。

Length-delimited
wire_type 类型为 2 的数据(string, bytes, embedded messages, packed repeated fields),是一种指定长度的编码方式,也就是上文所说的type - length - value的格式,type的编码方式是统一的,length采用varints编码方式,value则是根据length读取的特定数量的字节。
例如:
message Test2 {
  optional string b = 2;
}将 b 的值设置为 "testing" 后得到如下编码:
12 07 | 74 65 73 74 69 6e 67
74 65 73 74 69 6e 67  是“testing”的 UTF8 代码
12 -> 0001 0010,后三位 010 为 wire type = 2,0001 0010 右移三位为 0000 0010,即 field_num = 2。
length = 7,即后面有 7 个bytes的数据域 (length 采用 varints 编码方式)
[ packed=true ]
repeated 字段的编码结构为 Tag-Length-Value-Tag-Length-Value-Tag-Length-Value...,因为这些 Tag 都是相同的(同一字段),因此可以将这些字段的 Value 打包,即将编码结构变为 Tag-Length-Value-Value-Value…
proto2中需要配置开启,proto3默认为true。
常用API

基础操作
void set_prop(val)    // 字段赋值
const T& prop()        // 获取字段值
bool has_prop()        // 判断字段是否赋值,用于区分默认值和null
void clear_prop()      // 清除字段数据
T* mutable_prop()    // 返回字段指针,用于copy数据
T* add_prop()            // 向repeated field增加一个数据

序列化操作
bool SerializeToString(output)   //序列化message到给定的output字符串中
bool ParseFromString(string)     //从string中反序列化出messgae

message操作
void CopyFrom(Message from)    //使用另外一个message的值来覆盖本message
void MergeFrom(Message from) //相同字段会被覆盖,repeated字段会追加
void Clear()                                      // 将message中所有数据都清空,用于复用message
void  Swap(Message * message1, Message * message2)  // 交换两个message数据
void  SwapFields(Message * message1, Message * message2, std::vector< const FieldDescriptor * > & fields)        //交换两个message中指定的fields
bool TextFormat::Parser::ParseFromString(string, Message * output)  //protobuf支持从特定格式的明文字符串中解析出message,可用于配置加载
辅助类操作

string DebugString()     //将message转化成格式化的字符串,用于debug
size_t  SpaceUsedLong(const Message & message)  //获取message占用的空间大小
JSON转换

Status MessageToJsonString(const Message & message, string * output)   
Status JsonStringToMessage(StringPiece input, Message * message,  JsonParseOptions & options)  

代码示例,定义以下proto文件:
syntax = "proto2";

message Person {
    optional string name = 1;
    optional int32 id = 2;
    optional string email = 3;

    enum PhoneType {
        MOBILE = 0;
        HOME = 1;
        WORK = 2;
    }
   
    message PhoneNumber {
        optional string number = 1;
        optional PhoneType type = 2 [default = HOME];
    }
   
    repeated PhoneNumber phones = 4;
}

message AddressBook {
    repeated Person people = 1;
}

Person person;            //创建一个person对象
person.set_name("luke");  //赋值name
person.set_id(007);       //赋值id

Person::PhoneNumber *phone_number = person.add_phones();  //向repeated字段添加一条数据并赋值
phone_number->set_number("112");
phone_number->set_type(Person::MOBILE);

Person::PhoneNumber *phone_number1 = person.add_phones();  //向repeated字段添加一条数据并赋值
phone_number1->set_number("113");
phone_number1->set_type(Person::WORK);

cout << person.name() << endl;  // 获取字段数据,luke
cout << person.has_email() << endl;   //判断字段是否赋值,false
cout << person.has_id() << endl;      //判断字段是否赋值,true
cout << person.phones_size() << endl; //获取repeated字段数据数量,2

person.clear_id();  //清空一个字段

//遍历repeated字段
for (const auto &phone : person.phones()) {
cout << phone.number() << " " << phone.type() << endl;
  }

cout << person.DebugString();  //以格式化的形式打印出person里的信息,方便debug,输出如下:
/*
name: "luke"
phones {
number: "112"
type: MOBILE
}
phones {
number: "113"
type: WORK
}
*/


Person person1;
person1.mutable_phones()->CopyFrom(person.phones());  // copy person的repeated字段到person1
person1.CopyFrom(person);                             // copy整个person message

Person person2;
person2.Swap(&person1);  // 交换message信息

//将person中的信息进行序列化,序列化结果存储到serialize_str中
string serialize_str;
if (!person.SerializeToString(&serialize_str)) {
return -1;
  }

//将serialize_str中的信息反序列化到person3中
Person person3;
if (!person3.ParseFromString(serialize_str)) {
return -1;
  }

//message to json
string json_str;
google::protobuf::util::MessageToJsonString(person, &json_str);

proto3的新特性

syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}proto3的编译器支持两个版本的编译,如果要使用proto3,需显式声明syntax = "proto3",否则会按照proto2的语法进行编译。
proto3 去除了required字段,只保留repeated标记数组类型,其他所有字段均默认为optional类型,optional不再需要额外声明。
第一个理由是protobuf 希望做到添加/删除协议定义中的字段,同时仍然完全向前/向后兼容较新/较旧的二进制文件,但required字段破坏了这种兼容性(增加一个required字段,旧proto中没有,删除一个required字段,旧proto中要求必须存在)。第二个理由我认为是proto3进行了简化,作为一个序列化工具,对数据的正确性检查不是proto的工作(required / optional 的检查),proto只需要做好编解码就够了,数据检查应该交由程序实现。所以proto3中移除了required字段。
去除默认值选项
proto   optional int32 result_per_page = 3 [default = 10]; //proto3中已不可用
proto2中经常会使用[default=xxx]的形式定义字段默认值,proto3中已经去除此选项。proto3中,hasField方法不适用于基本数据类型字段(int、string等),未设置的初始字段具有语言定义的默认值,例如在C++中int类型的默认值为0,bool值为false,message字段的hasField方法仍然可用。这样带来了一个新的问题:如何区分是未赋值(null)还是赋值为0?一种方法是wrappers方案,由于proto3只对原始数据类型不生成 hasField方法,所以Google提供了wrappers.proto,定义了所有的基本数据类型。
message DoubleValue {
// The double value.
double value = 1;
}

import "google/protobuf/wrappers.proto";

message Account {
string name = 1;
google.protobuf.DoubleValue profit_rate = 2;
}例如上面的代码段,profit_rate字段为一个message类型,所以可以使用has方法判断是否存在,而实际上这个message里只定义了一个double字段。
移除了对group的支持
分组的功能完全可以用消息嵌套的方式来实现,并且更清晰
移除了对扩展的支持,新增了Any 类型
Any 消息类型可以让你使用消息作为嵌入类型而不必持有他们的.proto定义.
import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated Any details = 2;
}

// 在Any中存储任务消息类型
NetworkErrorDetails details = ...;
ErrorStatus status;
status.add_details()->PackFrom(details);

// 从Any中读取任意消息
ErrorStatus status = ...;
for (const Any& detail : status.details()) {
  if (detail.IsType<NetworkErrorDetails>()) {
    NetworkErrorDetails network_error;
    detail.UnpackTo(&network_error);
    ... processing network_error ...
  }
}repeated字段默认采用 packed 编码
在 proto2 中,需要明确使用 [packed=true] 来为字段指定比较紧凑的 packed 编码方式。
支持map(同样移植到proto2中)  
支持JSON序列化  

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2023-3-1 15:00 | 显示全部楼层
专注后台服务器开发,包括C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等
发表于 2023-3-1 15:07 | 显示全部楼层
基于proto2 ,的确没有map的原始支持。需要自己定义一个 message 保存 key和value,而且key和value如果不同的话,需要重复定义很多个key,value对象。
然后在使用repeated 声明kv对象。
如果确实有很多map对象需要传输,一种是更改你自己的设计。一种是使用thrift。或者使用proto3
其实在传输方法传输map与vector是一样 ,只不过解析的时候,需要转换 多一次而已。
建议从协议上设计去适配。
发表于 2023-3-1 15:07 | 显示全部楼层
谢邀,protobuf 3.0开始新增了这部分功能。详见:
Releases · google/protobuf · GitHub

目前用protobuf 2的话,我们都是unpack之后自己建立索引。比如:
xresloader/libresloader.h at master · xresloader/xresloader · GitHubxresloader/read_kind_sample.cpp at master · xresloader/xresloader · GitHub
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-24 04:05 , Processed in 0.099646 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表