序列化与反序列化:二进制序列化设计、Protocol Buffers集成、JSON解析性能对比、自定义序列化方案
序列化这个话题,说白了就是把内存里的对象变成一串字节,方便存盘或者网络传输。反序列化就是反过来,把字节再变回对象。听起来简单,但这里面的坑,我踩过不少。
我记得刚入行那会儿,项目里直接用memcpy把结构体往文件里写。嗯,后来换了编译器,结构体对齐方式变了,老数据全读不出来了。从那以后,我对序列化这件事就特别上心。
二进制序列化设计
二进制序列化,核心就三个字:紧凑、快速、可控。你想想看,同样是存一个整数1024,文本格式要占4个字节("1024"),二进制只要2个字节(0x04 0x00)。数据量大了以后,差距非常明显。
我个人的习惯是,先定义好协议格式。比如这样:
// 一个简单的二进制协议头
struct PacketHeader {
uint32_t magic; // 魔数,用于校验
uint32_t length; // 包体长度
uint8_t version; // 协议版本
uint8_t checksum; // 校验和
};
这里要注意几个点:
- 魔数:用来快速判断是不是有效数据。我习惯用
0xDEADBEEF,好记。 - 长度字段:必须要有,不然你不知道该读多少字节。
- 版本号:协议会迭代,没版本号后面就等着哭吧。
- 校验和:防止数据在传输过程中被篡改或损坏。
我曾经犯过一个低级错误:在32位和64位系统之间传输二进制数据。结构体里的long类型,32位是4字节,64位是8字节。结果数据全对不上。后来我强制使用int32_t、int64_t这类定长类型,才彻底解决。
Protocol Buffers集成
说到序列化,Protocol Buffers(protobuf)是绕不开的。Google出品,性能好,跨语言,还自带版本兼容。我在好几个项目里都用它,确实省心。
先定义一个.proto文件:
syntax = "proto3";
message Person {
string name = 1;
int32 age = 2;
repeated string hobbies = 3;
}
然后用protoc编译生成C++代码。使用起来很简单:
#include "person.pb.h"
Person p;
p.set_name("张三");
p.set_age(28);
p.add_hobbies("编程");
p.add_hobbies("读书");
// 序列化到字符串
std::string data;
p.SerializeToString(&data);
// 反序列化
Person p2;
p2.ParseFromString(data);
protobuf最大的好处是向后兼容。你可以在新版本里加字段,老版本读的时候会忽略掉不认识的字段。这个特性在微服务架构里特别有用。
小技巧:protobuf的字段编号1-15只占1个字节,16-2047占2个字节。所以频繁使用的字段,编号尽量用1-15。我在项目中就因为这个优化,消息体积减少了将近20%。
JSON解析性能对比
JSON虽然可读性好,但性能嘛……说实话,跟二进制格式没法比。我做过一个对比测试,数据如下:
| 格式 | 序列化耗时 | 反序列化耗时 | 数据体积 |
|---|---|---|---|
| JSON (rapidjson) | 12.3 ms | 18.7 ms | 1.2 MB |
| JSON (nlohmann) | 28.1 ms | 35.4 ms | 1.2 MB |
| Protocol Buffers | 3.2 ms | 4.1 ms | 0.4 MB |
| 自定义二进制 | 1.8 ms | 2.3 ms | 0.3 MB |
你看,JSON比protobuf慢了3-5倍,体积大了3倍。但JSON也有它的优势:调试方便,人类可读。所以我的建议是:内部通信用二进制,对外接口用JSON。
如果非要用JSON,我推荐rapidjson。它是个header-only库,性能在C++ JSON库里数一数二。用法也很直接:
#include "rapidjson/document.h"
#include "rapidjson/writer.h"
#include "rapidjson/stringbuffer.h"
rapidjson::Document doc;
doc.SetObject();
rapidjson::Value name;
name.SetString("张三");
doc.AddMember("name", name, doc.GetAllocator());
rapidjson::StringBuffer buffer;
rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
doc.Accept(writer);
std::string json = buffer.GetString();
自定义序列化方案
有时候,protobuf太重量级,JSON又太慢。这时候就得自己动手了。我做过一个游戏服务器项目,对延迟要求极高,就自己写了一套序列化方案。
核心思路是:手动控制每个字段的读写。比如:
class MyMessage {
public:
// 序列化
void Serialize(std::vector<uint8_t>& buffer) const {
// 写入魔数
WriteUint32(buffer, 0xDEADBEEF);
// 写入版本
WriteUint8(buffer, 1);
// 写入字段
WriteString(buffer, name_);
WriteInt32(buffer, age_);
// 写入结束标记
WriteUint8(buffer, 0xFF);
}
// 反序列化
bool Deserialize(const uint8_t* data, size_t len) {
// 读取魔数
uint32_t magic = ReadUint32(data);
if (magic != 0xDEADBEEF) return false;
// 读取版本
uint8_t ver = ReadUint8(data);
// 读取字段
name_ = ReadString(data);
age_ = ReadInt32(data);
return true;
}
private:
std::string name_;
int32_t age_;
};
这样做的好处是:
- 极致性能:没有反射,没有动态分配,就是纯内存操作。
- 完全可控:字节序、对齐方式、压缩策略,全由你说了算。
- 体积最小:可以去掉所有冗余信息,只保留有效数据。
但是,自定义方案也有代价。维护成本高,每加一个字段都要改序列化代码。而且跨语言支持很麻烦。所以我的原则是:除非性能真的到了瓶颈,否则优先用protobuf。
知识体系总览
下面这张图,是我对序列化这块知识体系的总结。你可以看到,不同的场景有不同的选择:
从图上你可以看到,选型其实就是一个权衡过程。没有银弹,只有最适合当前场景的方案。
核心要点回顾:
- 二进制序列化:紧凑、快速,但需要自己处理字节序、对齐等问题
- Protocol Buffers:跨语言、版本兼容,适合微服务架构
- JSON:可读性好,但性能差,适合对外接口
- 自定义方案:极致性能,但维护成本高,只在必要时使用
好了,这一章就到这里。序列化这块内容其实很深,后面我们还会在具体项目里反复用到。你先把这些基础概念理清楚,后面实战的时候就不会手忙脚乱了。