Protobuf笔记
部署
官网下载、解压
左为对C#的支持,右为把.proto脚本生成.cs的编译器。
生成dll
VS打开.sln
把4个dll拷贝到项目Plugins
.proto语法
syntax="proto3";//版本号 package XXX;//生成的命名空间 import "YYY.proto";//导入其他proto文件的类型 message Person{//自定义消息类 int64 id=1;//字段的唯一编号,不是字段的值!!!! string name=2; Gender gender=8; int32 age=3;//不适合表示负数。负数用sint32 float height=4; //还有sint32 sint64 uint32 uint64 fixed32 fixed64 sfixed32 sfixed64 bool isAdult=5; //bytes字节字符串类型 //optional可选赋值 repeated string tags=6;//列表 map<string,Person>relationships=7;//字典 reserved 1,2,8,3,4;//即使1字段被在删掉了,也不允许再声明 reserved id,name; 命名空间.HeartBeat heartBeat=10;//其他proto文件的message } enum Gender{ male=0; female=1; }生成.cs脚本
在protoc.exe文件夹打开,输入
./protoc.exe -I=.proto文件夹 --csharp_out="输出文件夹" .proto文件名如
.\protoc.exe -I=. --csharp_out=. UserPropsData.proto pause使用
序列化存文件
using Google.Protobuf; string path; Person p1=new Person(); //赋值 using(FileStream stream=File.Create(path+"/坑你.kk")){ p1.WriteTo(stream); }反序列化读文件
using Google.Protobuf; string path; Person p1=null using(FileStream stream=File.OpenRead(path+"/坑你.kk")){ p1=Person.Parser.ParseFrom(stream); }二进制序列化反序列化。内存流法
Person p1=new Person; byte[]bytes; using (MemoryStream stream=new MemoryStream()){ p1.WriteTo(stream); bytes=stream.ToArray(); } using(MemoryStream stream=new MemoryStream(bytes){ p1=Person.Parser.ParseFrom(stream); }二进制序列化反序列化。不用流。
Person p1=new Person(); byte[]bytes=p1.ToByteArray(); Person.Parser.ParseFrom(bytes);Protobuf数据类和自己写的C#数据类的对接(或者说从自己写的数据类到pb数据类的迁移)
如果之前用C#写了数据类,现在用protobuf序列化,C#数据类和protobuf数据类的互相转换对于复杂嵌套的数据类会非常麻烦。于是自然想到项目直接用protobuf数据类。此时面临问题:
- 项目里引用C#数据类已经非常多了,代码修改量很大;
如果能有一个渐进的方法,每次把一部分引用转移到protobuf数据类,也行。
项目到处在引用CS数据类,保存时把cs数据类转换成pb数据类,读取时相反。如果渐进,需要玩家数据管理器同时维持一个cs数据和pb数据?
- 玩家数据管理器同时维护一个cs数据类和一个pb数据类
- 把cs数据类转换成pb数据类的函数改成接受一个pb数据类(而不是在里面new),把cs数据类的一些成员搬运进pb数据类
- 改成直接用pb数据类的那些数据成员,在cs和pb转换函数里搬运它们的代码删除,把项目个部分对它们的应用改成对pb数据类里它们的引用
现在项目从玩家数据管理器引用的不止整数、字符串、bool这些简单数据类型,还有整个的嵌套数据类比如背包。
注意
- 生成的.cs文件的类会直接使用.proto的package定义的命名空间!如果想把protobuf生成类和自己的代码分开,就定义不同的命名空间;
- 对于import很多其他.proto的文件,在命令行添加参数可以连带把依赖项也生成了,但是如果懒得写命令行不如把message都写在一个.proto;
- repeated数据不能new,可以Add
规则详细拆解
syntax = "proto3";
message User {
int32 id = 1; // 变长int
string name = 2; // 长度前缀字符串
fixed32 age = 3; // 固定4字节int
}
以二进制数据 b'\x08\x01\x12\x04Alice\x18\x1e' 为例,对应 User 协议(id=1/name=2/age=3),逐字节拆解 Protobuf 字段边界识别逻辑,核心是 Tag-Length-Type(TLT)+ 数据绑定:
先明确 2 个基础规则
• Tag 编码:字段标签(如 1/2/3)和类型(如 int32=0、string=2、fixed32=5)拼接成 (tag << 3) | type,再用 Varint 编码存储;
• Varint 特征:字节最高位为 1 表示后续还有字节,为 0 表示当前是最后一字节。
逐字段拆解(按二进制顺序)
1. 字段 1:int32 id = 1(值为 1)
对应二进制片段:\x08\x01
• 第 1 字节 \x08(二进制 00001000):解析 Tag+Type
◦ 低 3 位 000 = 类型 0(VARINT,对应 int32);
◦ 高 5 位 00001 右移 3 位 = 字段标签 1;
• 第 2 字节 \x01(二进制 00000001):Varint 编码的数据,最高位为 0,直接解析为 1,该字段读取结束(共 2 字节)。
2. 字段 2:string name = 2(值为 "Alice")
对应二进制片段:\x12\x04Alice(Alice 是 ASCII 码 \x41\x6c\x69\x63\x65)
• 第 1 字节 \x12(二进制 00010010):解析 Tag+Type
◦ 低 3 位 010 = 类型 2(LENGTH_DELIMITED,长度前缀类型,含字符串/嵌套消息);
◦ 高 5 位 00010 右移 3 位 = 字段标签 2;
• 第 2 字节 \x04(二进制 00000100):长度前缀,标识后续数据占 4 字节;
• 第 3-6 字节 \x41\x6c\x69\x63\x65?不,长度前缀是 4,所以读后续 4 字节?哦不对,"Alice" 是 5 个字符,对应长度前缀应为 \x05,修正二进制片段为 \x12\x05Alice(原数据笔误,不影响逻辑):
◦ 按长度前缀 05 读取后续 5 字节,正好是 "Alice",该字段读取结束(共 1+1+5=7 字节)。
3. 字段 3:fixed32 age = 3(值为 30)
对应二进制片段:\x18\x1e?不,fixed32 是固定 4 字节,类型为 5(FIXED32),修正后二进制片段:\x1d\x1e\x00\x00\x00
• 第 1 字节 \x1d(二进制 00011101):解析 Tag+Type
◦ 低 3 位 101 = 类型 5(FIXED32,固定 4 字节);
◦ 高 5 位 00011 右移 3 位 = 字段标签 3;
• 后续 4 字节 \x1e\x00\x00\x00(小端序):直接读取 4 字节,转成十进制为 30,该字段读取结束(共 1+4=5 字节)。
核心结论
Protobuf 字段“读完”的判断完全由 协议定义+编码规则 自动完成:
• 固定类型(fixed32/fixed64):读满协议规定的字节数即结束;
• 变长类型(Varint):读到最高位为 0 的字节即结束;
• 长度前缀类型(string/嵌套):先读长度,再读对应长度的字节即结束。
需要我用工具帮你可视化任意 Protobuf 二进制数据的拆解过程,或对比不同类型字段(如嵌套消息、repeated 数组)的边界识别差异吗?
问题排查
存入的repeated数据读取出来Count是0
往repeated里加数据,字节数会变多,读取出来Count是0.经过研究是因为读取数据时把读出的protobuf数据类以参数传入,以为数据类是引用传递,在里面修改也会修改外面的对象。但其实好像是值传递,需要加out参数。
再简单总结一下,引用类型输入参数,仅限里面没有赋值new,只对原对象操作时输入能当输出,当里面执行他人的代码时,不知道里面作了什么操作,如果有赋值new,就不会改变外面的对象。
