序列化与反序列化:二进制序列化设计、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_tint64_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

知识体系总览

下面这张图,是我对序列化这块知识体系的总结。你可以看到,不同的场景有不同的选择:

序列化方案选型决策树 序列化需求 需要跨语言/版本兼容 追求极致性能 需要人类可读 机器间通信 JSON (rapidjson) Protocol Buffers 需要快速开发 需要极致控制 自定义二进制方案 选择序列化方案时,先问自己三个问题:跨语言?性能?可读性?

从图上你可以看到,选型其实就是一个权衡过程。没有银弹,只有最适合当前场景的方案。

核心要点回顾

  • 二进制序列化:紧凑、快速,但需要自己处理字节序、对齐等问题
  • Protocol Buffers:跨语言、版本兼容,适合微服务架构
  • JSON:可读性好,但性能差,适合对外接口
  • 自定义方案:极致性能,但维护成本高,只在必要时使用

好了,这一章就到这里。序列化这块内容其实很深,后面我们还会在具体项目里反复用到。你先把这些基础概念理清楚,后面实战的时候就不会手忙脚乱了。


公众号:蓝海资料掘金营,微信deep3321