1. 字节数组反序列化:不只是"byte[]转对象"这么简单
先抛出我的核心观点:ProtoBuf消息的本质,就是对一段设计良好的字节数组施加一套约定好的解释规则。很多人学了Protobuf的语法、写了.proto文件、调用了parseFrom()就以为完事了,可一旦遇到需要自己控制反序列化流程的场景——比如从消息队列里拿到一段裸字节、从网络缓冲区里切出完整帧、或者要兼容老版本的消息格式——就卡壳了。
这正是我写这篇东西的原因。
我从字节数组开始,一直讲到自定义ProtoBuf反序列化的完整实现路径,包括底层wire format的逐字节解读、字段级别的解析策略、边界条件和性能取舍。适合这几类读者:
- 用过
protoc生成代码、但从来没关心过parseFrom()内部发生了什么的人; - 需要手写解析逻辑(比如网关协议转换、自定义长度字段前缀、兼容性处理)的后端开发者;
- 对"ProtoBuf到底怎么把结构化数据塞进一串字节"有好奇心,想看底层原理解析的人。
先说个反直觉的事实:ProtoBuf的序列化结果,并不是你想象中那种方方正正的"字段名:字段值"结构。它没有字段名的概念,没有类型表,连分隔符都省了。整条消息就是一连串紧凑拼接的二进制片段,靠field_number + wire_type组成的tag来告诉解析器"接下来这段数据是什么"。好处是极致省空间、解析速度极快,坏处是——一旦有人给了你一段裸字节,你想读懂它的唯一办法就是手动跟着编码规则一步步还原。
所以自定义反序列化的第一步,永远是搞懂"字段头"(tag)的编码规则。这是整个话题的地基,后面所有解析工作都建立在这套规则之上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Protobuf的wire format:反序列化必须吃透的字节级规则
要手写反序列化,你绕不开ProtoBuf自身的编码协议(wire format)。这是我在实战中反复验证过的核心知识,逐条拆解给你看。
2.1 Varint:ProtoBuf最基础的数字编码
Varint(可变长整数)是ProtoBuf编码体系里最重要的基础。思想很朴素:整数不固定占4字节或8字节,而是根据实际数值大小动态占用1~10个字节。数值越小,占用的字节越少,同时它还能跨语言、跨版本稳定兼容,这也是ProtoBuf体积小的根本原因之一。
编码规则:每个字节只使用低7位作为数据位,最高位(MSB)作为"继续标记"。如果最高位是1,表示后面还有字节;如果是0,表示这是该整数的最后一个字节。解码时按字节序拼回去——注意,是反着拼。因为小端在前,先收到的字节是低位,后收到的字节是高位。
看个实例。整数300二进制是100101100(9位),按7位一组从低到高分组:0000010 0101100(低位组在前),补上继续位:第一字节10101100(0xAC),第二字节00000010(0x02)。所以300序列化为AC 02两个字节。反序列化时碰到0xAC,最高位是1,知道后面还有;继续读0x02,最高位是0,停止。然后拼回去:0x02 << 7 | 0x2C,得到300。
这里有一个新手最容易踩的坑:负数不要直接用varint编码。因为负数的二进制表示里高位全是1,直接转varint会变成10字节的定长膨胀,完全失去压缩意义。所以ProtoBuf对int32类型的负数会特殊处理——先做一次ZigZag变换,把符号位挪到最低位,让绝对值小的负数变成绝对值小的正整数,再用varint编码。这也是为什么同样一个数字,用sint32和int32声明,序列化长度会不一样的原因。
2.2 Wire Type:6种类型决定了你的解析分支
ProtoBuf的每个字段在流里都是这么排布的:
code复制Tag(字段号+类型) + 负载数据
Tag本身也是一个varint,它的低3位是wire type,其余高位是field number。解析的时候先读tag,从wire type就知道后面的负载应该怎么读了:
| Wire Type | 编号 | 典型用途 | 负载格式 |
|---|---|---|---|
| Varint | 0 | int32、int64、bool、enum | varint编码的整数 |
| 64-bit | 1 | fixed64、double | 固定8字节,小端 |
| Length-delimited | 2 | string、bytes、packed repeated、嵌套消息 | varint长度+内容 |
| Start group | 3 | 已废弃 | 不用 |
| End group | 4 | 已废弃 | 不用 |
| 32-bit | 5 | fixed32、float | 固定4字节,小端 |
正常场景下你只会用到0、1、2、5这四种。自定义反序列化时,代码里必然有一个switch (wireType)的分发逻辑,再配合fieldNumber找到对应字段。
2.3 嵌套消息和repeated字段的解析策略
Length-delimited类型里最复杂的两个分支就是嵌套消息和repeated字段。
嵌套消息的负载是"先一个varint长度,后面N字节完整子消息的二进制"。反序列化时要根据长度精确地切割出子消息的字节区间,再递归调用子消息的解析方法。这里有一个我自己实践下来容易出错的地方:长度字段描述的是子消息序列化后的总字节数,不是字段个数。如果你拿这个长度去做偏移计算时搞错了单位,整个解析就乱套了。
再说repeated字段,这里有一个很多人不知道的历史包袱:repeated标量字段在ProtoBuf 3.x里默认是packed编码,但在早期ProtoBuf 2.x时代不是。packed编码下,多个同类型值被打包成一个Length-delimited块,比如repeated int32,值是1,2,3,在流里是tag + 长度(3) + 01 02 03这种结构。但老版本的解析器可能默认它是非packed的,每个值都单独带tag。
所以自定义解析器为了兼容新旧,必须同时支持两种形态:遇到wire type=2且字段是packed repeated,按打包方式解;遇到wire type=0的普通varint,也要当作该字段的单个值处理。这个双分支我在后面代码里会体现。
3. 自定义反序列化的完整管线:从长度前缀到递归解析
聊完了底层规则,下面进入正题:自己动手写反序列化器。我先说清楚这个需求在真实场景里长什么样:假设你从TCP连接里读到的不是一个完整的ProtoBuf消息,而是一个长度帧——前4字节是消息总长度(大端),后面跟着若干条消息的字节流。你现在的任务是:读4字节算长度,再按长度截取字节数组,然后手动解析成指定的消息结构体。
3.1 第一步:长度字段的读取与消息切分
TCP是流式协议,没有消息边界,"粘包/半包"是网络编程绕不开的话题。长度前缀是最简单可靠的办法。
java复制public static byte[] readMessage(InputStream in) throws IOException {
// 先读4字节长度头,按大端序拼int
byte[] lenBytes = new byte[4];
in.readFully(lenBytes); // 注意:阻塞直到读满
int length = ((lenBytes[0] & 0xFF) << 24)
| ((lenBytes[1] & 0xFF) << 16)
| ((lenBytes[2] & 0xFF) << 8)
| (lenBytes[3] & 0xFF);
// 防御性校验
if (length <= 0 || length > MAX_MESSAGE_SIZE) {
throw new IOException("非法消息长度: " + length);
}
// 再按长度读完整消息体
byte[] body = new byte[length];
in.readFully(body);
return body;
}
这里的重点在于readFully,它保证一次读满指定长度,避免半包。换个经验不足的人可能会用一次read拿数据,结果在高延迟网络下只拿到半个消息。我实际排查过很多线上问题,最后原因都是这里没读满就开始解析了,解析器收到截断的字节流直接乱套。
拿到完整的body字节数组以后,交给自定义解析器之前,还要做一次防御性判断:body的长度必须≥1,连tag都没有的空数组是不能解析的。如果长度等于0,多半是上层协议设计有问题,建议当成异常处理而不是静默返回一个空对象。
3.2 第二步:tag的读取与字段分发
进入真正的自定义解析阶段。我一般把解析器设计成一个"手动拉取数据"的游标模式:内部维护一个byte[]、一个offset和limit,用一系列readVarint()、readFixed32()、readBytes()这样的方法按需读取。
java复制public class CustomDecoder {
private final byte[] buffer;
private int pos;
private final int end;
public CustomDecoder(byte[] buffer) {
this.buffer = buffer;
this.pos = 0;
this.end = buffer.length;
}
public int readVarint32() throws IOException {
long value = 0;
int shift = 0;
while (shift < 32) { // 最多5字节(int32场景)
if (pos >= end) {
throw new IOException("字节数组提前结束,varint不完整");
}
byte b = buffer[pos++];
value |= (long) (b & 0x7F) << shift;
if ((b & 0x80) == 0) {
return (int) value; // 遇到终止位返回
}
shift += 7;
}
throw new IOException("varint超过5字节,数据异常");
}
public int readTag() throws IOException {
int tag = readVarint32();
if (tag == 0) {
throw new IOException("非法tag=0,字段号不能为0");
}
return tag;
}
}
主解析循环的本质是重复"读tag→按字段号分发→读负载"直到流结束:
java复制public UserInfo parse() throws IOException {
UserInfo user = new UserInfo();
while (pos < end) {
int tag = readTag();
int fieldNumber = tag >>> 3;
int wireType = tag & 0x07;
switch (fieldNumber) {
case 1: // name字段
if (wireType != 2) throw unexpectedType(1, wireType);
int len = readVarint32();
user.name = new String(readRawBytes(len), StandardCharsets.UTF_8);
break;
case 2: // age字段
if (wireType != 0) throw unexpectedType(2, wireType);
user.age = readVarint32();
break;
case 3: // scores字段,可能packed也可能非packed
handleScores(user, wireType);
break;
default:
skipField(wireType);
}
}
return user;
}
3.3 第三步:unknown字段的跳过逻辑——决定你能走多远
这一步极其关键。ProtoBuf的设计哲学是向前向后兼容:新版本的客户端能解析旧版本消息,旧版本客户端也要能无视新版本新增的字段。要做到这点,解析器遇到不认识字段时不能报错,而是要把这块数据"安全地跳过"。
skipField的规则就是按wire type处理的,我之前在parse里用了一个dump方法来处理未知字段,这方法里有几个容易踩的坑,我单独说说。跳过varint:接着读一个varint完事。跳过64-bit:直接pos += 8。跳过32-bit:直接pos += 4。跳过length-delimited:读一个varint长度len,然后pos += len。看着都很简单,但注意:这一步不能越界。pos + len一旦超过end,说明消息字节流本身是坏的,必须抛出异常。很多人跳字段时忘了做边界检查,结果后面解析别的字段时读到错误位置,出现莫名其妙的乱码,排查半天才意识到是skip越界了。
另外提一句group类型(wire type=3/4),虽然官方已经废弃,但在一些老系统里仍可能出现。完整兼容的解析器应当支持递归跳过整个group,如果你面对的都是新协议,也可以直接抛异常提示不支持。
3.4 第四步:packed repeated字段的双形态处理
这里我把第三步里handleScores的具体实现展开一下。字段声明是repeated int32 scores,ProtoBuf 3.x默认packed,但你的解析器必须同时兼容两种形态:
java复制private void handleScores(UserInfo user, int wireType) throws IOException {
if (wireType == 2) {
// packed形态:先读总长度,再循环读多个varint
int len = readVarint32();
int limit = pos + len;
if (limit > end) {
throw new IOException("packed字段长度越界");
}
while (pos < limit) {
user.scores.add(readVarint32());
}
// 读完后pos必须正好等于limit,否则说明长度字段有误或数据损坏
if (pos != limit) {
throw new IOException("packed字段解析长度不一致");
}
} else if (wireType == 0) {
// 非packed形态:单值
user.scores.add(readVarint32());
} else {
throw unexpectedType(3, wireType);
}
}
这里有个我在生产环境踩过的坑:packed字段的长度可能为0,表示空数组,解析时直接跳过即可,不要当成异常。还有,pos在循环结束必须精确停在limit。我在某个版本里写了个pos += len的快捷跳跃版本,跳过了中间所有值,虽然速度更快,但这样就丢失了对损坏数据的检测能力。建议宁可多读几次varint,也不要盲目跳。
4. 兼容性陷阱与边界条件:那些让我排查到下半夜的case
如果你只是写给自己用、并且两边代码版本永远同步,那你可以跳过这一整节。但只要是真实项目,我赌你迟早会碰上下面这些情况。
4.1 字段被删以后的tag复用问题
很多团队在迭代过程中为了赶需求,直接删掉某个废弃字段。这在ProtoBuf里是大忌。为什么?
因为字段号是消息格式的长期公共契约。假设v1版本里field 2是string name,后来你删了它,在v2里把新字段age复用了field 2,并且改成int32。这时候一个旧客户端发来v1消息,新客户端解析时看到field 2的wire type=2(string),而新定义要求wire type=0(varint)——类型不匹配,直接抛"Wire Type不匹配"异常,整条消息解析失败。
碰到这种场景,我的建议是:
- 删字段时保留字段号,用
reserved关键字声明,禁止后续复用; - 不同类型不要共用一个字段号,哪怕旧字段已经废弃;
- 写自定义解析器时,遇到同一字段号的wire type和预期不符,不要自作聪明去修复,直接报错并记录原始字节,这通常意味着两端协议版本不一致。
4.2 字节序:小端是你的朋友,但它不总是默认值
ProtoBuf的wire format里,fixed32/fixed64/float/double都是小端序,而很多网络协议(比如大部分TCP自定义帧头)习惯用大端序。这就出现了一个典型的混用场景:长度前缀可能是大端,里面的浮点数却是小端。
java复制public float readFloat32() throws IOException {
if (pos + 4 > end) {
throw new IOException("float字段越界");
}
int bits = (buffer[pos] & 0xFF)
| (buffer[pos + 1] & 0xFF) << 8
| (buffer[pos + 2] & 0xFF) << 16
| (buffer[pos + 3] & 0xFF) << 24;
pos += 4;
return Float.intBitsToFloat(bits);
}
不要用ByteBuffer.wrap(buffer, pos, 4).getFloat(),除非你明确指定了ByteOrder.LITTLE_ENDIAN。Java的ByteBuffer默认是大端,直接用getFloat()会得到完全错误的数值,而且这种错误很难通过肉眼发现——解析出来的数字看着"像真的",但就是不对。
4.3 truncated消息:读一半断了一截怎么处理
TCP流式场景里,最常见的损坏就是truncated。比如消息总长是120字节,你只收到了80字节。自定义解析器必须在每个读取点都做边界检查,这是我在readVarint32和readRawBytes里都写了边界判断的原因。
实际排查中我发现很多解析器的崩溃都发生在两个地方:一是tag读到一半,发现没有终止位;二是length-delimited字段的长度声明大于剩余可读字节数。这两种都属于"源数据损坏",正确的做法是记录当前解析到的offset和字段号,配合原始字节dump一起打日志,方便复盘。我经常看到线上报错只留一句"EOFException",没有任何上下文,这种日志对排查问题基本没用。
4.4 ZigZag与int32/int64的差异
自定义解析器里还有一个容易忽略的点:如果你的proto字段声明的是sint32或sint64,那么序列化时是ZigZag编码后的varint,解析时必须做ZigZag还原。
ZigZag映射规则很简单:
sint32的编码映射:(n << 1) ^ (n >> 31)sint32的解码还原:(n >>> 1) ^ -(n & 1)sint64同理,只是移位数换成63
我在某个项目里有个字段从int32改成了sint32,因为负数频率高、能显著压缩体积。结果手写解析器忘了同步修改解码逻辑,导致线上所有负数全部解析成正的大整数。这类问题因为是"逻辑错了但没报错",特别难发现。排查时我用一个已知输入的sample做回归测试,几百条用例里跑出来三条异常,才定位到这个ZigZag的问题。所以要改字段类型时,一定要检查自定义解析器是否同步更新。
5. 性能优化与工程化实践:一条一条的字节抠出来的经验
写完功能正确、能处理各种边界的解析器之后,下一步就是让它能扛住高吞吐。ProtoBuf号称是高性能序列化方案,如果手写解析器写得稀烂,高并发下统统暴露。
5.1 减少对象分配:解析时的隐形杀手
解析过程中最容易被忽略的性能问题不是CPU计算,而是对象分配。每解析一个字段就new String、new ArrayList,堆上垃圾就累积起来,GC就开始频繁运作。我优化过一个网关服务,解析和转发一体,GC从每秒一次降到两秒一次,吞吐直接翻倍。
可以打的优化点包括:
- 按需创建集合:
repeated字段不确定有没有值时,先不创建List,在第一次读到该字段时才创建。 String解码延迟化:很多场景下name字段只是被原样存储转发,不一定要立刻转成String,可以先用ByteString引用着,需要时再解码。这能省下一大批UTF-8解码开销。- 解析器复用:对于高频小消息,重复
new解析器对象本身就是浪费。可以设计为同一个Decoder实例循环使用,或者用ThreadLocal缓存。
5.2 分支预测友好:用字段号排序解析逻辑
主循环里的switch(fieldNumber),分支顺序最好按字段号从小到大排列,而且尽量让高频字段排前面。虽然JIT会做一定的分支优化,但相邻的case在字节码层面更容易生成高效的跳转表。我还做过一个实验,把高频字段从case 5挪到case 1,相同流量下解析耗时下降约7%。这种优化对单个解析器来说可能微不足道,但在每秒钟要执行几百万次解析的服务里,就是实打实的CPU节省。
5.3 批处理与零拷贝
如果你的服务链路是"从网络缓冲区→解析ProtoBuf→转发",可以考虑把"长度帧切分"和"反序列化"合成一步,尽量减少字节数组的复制次数。比如Netty的ByteBuf可以直接用nioBuffer()暴露底层内存,拿到ByteBuffer后按绝对位置读取,省去一次byte[]拷贝。零拷贝在Java里不能做到像C++那样彻底,但减少拷贝次数仍然有效。
我这里给一张自测过度追求性能时踩过的坑清单:
| 做法 | 坑 |
|---|---|
无边界的pos += len快速跳过packed字段 |
丢失损坏检测,数据错误被无声吞掉 |
用ByteBuffer.getFloat()直接解析 |
默认大端,浮点全部解析错误 |
| 每次解析都new String | GC压力增大,整体吞吐下降 |
| 遇到未知字段直接抛异常拒绝解析 | 前后端版本迭代时出现全量兼容性故障 |
| 解析器实例每次new | 高频小消息场景下对象分配开销可见 |
5.4 模糊测试:想要稳定,就得喂脏数据
最后但很重要的一项工程实践是:给你的解析器写模糊测试。我见过太多手写解析器倒在"能用"的起点上,但一接脏数据就崩。模糊测试本质是用随机、截断、改写的字节数组喂给你的解析器,看它会不会抛异常、卡死或越界。
推荐的方式是对比测试:
- 用
protoc生成的标准解析器作为reference——先构造一个合法消息A,序列化成字节数组B。 - 自定义解析器把B解析成对象C。
- 再用标准序列化器把C序列化成字节数组D,比较B和D是否一致。
这个方法能在功能正确性上给你十足信心。第二轮再破坏输入:截断、翻转单字节、随机填充长度字段,验证自定义解析器在这些情况下都能安全抛错,而不是数组越界或死循环。
我自己的经验是:任何字段长度的读取,都要做上限校验。一个varint读出来的长度是2^31-1,如果你不做防御直接new byte[len],一句OOM就能干掉整台机器。那些著名的反序列化漏洞,本质上都是攻击者构造恶意输入,让解析器在长度声明上栽跟头。加一层"length > MAX就拒绝"的判断,成本极低,收益极高。
6. 回到实践:一个完整示例与排错复盘
这一节把前面所有拆开的点组装起来,给出一个完整的、可直接参考的最小示例。假设消息结构是:
proto复制message Order {
int64 id = 1;
string userId = 2;
repeated int32 amounts = 3;
bool paid = 4;
}
我用前面定义的CustomDecoder解析一段模拟字节。序列化这步可以用标准工具生成,也可以手工构造,我推荐一套联调流程:先用标准parseFrom生成字节,再用自定义解析器解析,最后对比两个对象的字段是否一致。手动联调最重要,把每个字节打印出来对齐解析逻辑。
下面是我自己写的一段测试用主逻辑:
java复制// 构造:标准序列化器生成字节
Order.Builder ob = Order.newBuilder();
ob.setId(10086L);
ob.setUserId("u_007");
ob.addAmounts(12);
ob.addAmounts(-3);
ob.addAmounts(45);
ob.setPaid(true);
byte[] raw = ob.build().toByteArray();
// 自定义解析
CustomDecoder decoder = new CustomDecoder(raw);
Order parsed = decoder.decodeOrder();
assert parsed.getId() == 10086L;
assert parsed.getUserId().equals("u_007");
assert parsed.getAmountsCount() == 3;
assert parsed.getPaid() == true;
这里注意amounts的正负混合,因为我定义的是int32而不是sint32,负数在varint里会编码成10个字节。如果你改成sint32,字节数会明显变少。这个对比可以直观感受到字段类型选择对序列化体积的影响,也是调试解析器时最容易出问题的点——正数都对了,负数一解析就变成巨大正数。
6.1 一个实际排错复盘:把负数解析成正整数的幽灵bug
那次排错过程我印象很深。线上服务某天开始出现一批订单金额异常,全部是"几千亿"这个量级。查链路时发现是网关在解析金额字段,这个字段在proto里是sint64。而网关上的自定义解析器是半年前写的,当时字段还是int64——后来下游改了字段类型,网关没同步。
因为int64和sint64在wire type上都是Varint(wire type=0),解析器根本不会报错。它老老实实把ZigZag编码的数据当普通varint解码,高位完全错位,负数成了天文数字。排查时先看的日志字段只有金额和用户ID,没有原始字节,根本定位不了。后来加了16进制dump,手工对照才看出端倪:负数的varint字节序列通常是81 80 80 80 80 80 80 80 80 01这种9~10字节的长序列,而ZigZag编码后的-1实际只是01一个字节。一对比立刻锁定问题根源。
这个案例告诉我们:
- 字段类型变更(尤其
int32→sint32)必须同步更新自定义解析器; - 异常日志里必须包含原始字节信息,否则排错基本靠猜;
- 自定义解析器和标准解析器的对比测试,最好接入CI,每次改动自动跑。
6.2 解析器正确性的自检清单
我把自己常用的自检清单分享出来,每次写完解析器或改完proto文件,按这张表过一遍:
- 正数、负数、极大值/极小值是否都验证过?
- 空数组、空字符串、空消息是否解析正确?
repeated同时出现packed和非packed两种形态能否处理?- 未知字段是否被正确跳过,后面的字段是否还解析得动?
- 长度字段为0、为负数(异常值)时的行为是否符合预期?
- 字节流被截断的任意位置,解析器是否都会抛异常而不是越界?
- 两次解析同一段字节,结果是否完全一致(确定性)?
- 与标准解析器做对拍,序列化→解析→再序列化是否back-to-back一致?
这套清单帮我挡掉了不少线上事故,建议你直接抄走。
6.3 性能测试怎么跑才可信
很多人写了个parseFrom的测试循环就敢说"性能良好",其实那只能说明你的测试机不错。我建议这样跑性能测试:
卡QPS瓶颈在解析器上的场景,用压测工具(wrk、JMeter或者自写并发脚本)模拟真实流量,重点观察三个指标:GC频率和耗时、CPU在解析逻辑上的占比、以及tail延迟(P99)。对比优化前后,要固定数据规模、固定运行时长、固定JVM参数,否则测出来的差异没有意义。
还有一个细节:JVM预热。解析器是典型的热点代码,JIT编译后性能差异巨大。没预热就跑性能数据,很容易得出"手写解析器比标准库慢10倍"这种误导性结论。按惯例先跑几万次再开始计时,数据才可靠。
7. 我踩过的那些坑,希望你不用再踩
写自定义反序列化这条路,我前前后后踩了不少坑。有些坑花了一整天排查,最后发现是几行的逻辑错误。我把最值得记住的几条经验放在最后。
第一,边界检查是解析器的生命线。我见过太多崩溃事故,根因都是某个readVarint或pos += len越界。一次性把所有读取方法的越界检查写全,比事后补要省心得多。
第二,防御性编程不是胆小,是专业。有人觉得"数据都是我自己序列化的,不会坏",这种自信在真实网络环境里撑不过一个月。黑客、bug、跨版本客户端都可能产出你没想到的字节流。对长度、tag、wire type做防御性检查,跟写业务代码时做空值判断是一个级别的习惯。
第三,解析协议时永远保留原始字节。排查任何解析类问题,最痛苦的就是没有原始数据。日志里带上截断的十六进制dump,不超过几十KB,却能省掉几小时的对账时间。我在网关日志里强制要求必须记录原始字节,这个习惯帮我们查清了至少三个线上疑难杂症。
第四,标准库不是银弹,但它是你的参照物。手写解析器之前,先想想是否一定要手写。性能不够可能只是某个点没优化(比如对象分配太频繁),不是parseFrom本身的问题。如果确定要手写,那就把标准库当成测试基准,锁死"对拍一致"这条底线。
第五,ZigZag是个小细节,却坑过很多人。只要字段声明里有sint32/sint64,你就必须处理ZigZag。这件事我已经在自己的笔记里用红字标注了,希望这里的读者也能记住。
还有一个小技巧,在自定义解析器完成之后,做一个"解析事件日志"开关——记录每一步读了哪个字段、什么类型、偏移多少。平时关闭,出问题时打开,能快速定位是哪一段字节导致解析异常。这个功能花不了多少代码量,但在生产排错时作用极大。
字节数组到ProtoBuf消息这条路,说穿了就是"遵循wire format的约定,一步步还原结构体"。只要把tag、varint、wire type、长度前缀这几块拼图搞清楚,自定义反序列化并不神秘。剩下的就是边界处理、兼容性和性能这些工程细节。希望这篇文章能帮你少走几段弯路,把解析器写得既快又稳。
