实战:序列化与反序列化(二进制+JSON+ProtoBuf风格)

序列化这事儿,说白了就是把内存里的对象变成一串字节,存起来或者发出去。反序列化就是反过来,把字节再变回对象。我做了这么多年C++,几乎每个项目都绕不开它。今天咱们就一口气把三种主流风格都撸一遍。

为什么需要序列化?

你想想看,程序跑起来的时候,对象都在内存里。一旦进程结束,内存就释放了。如果你想保存状态、跨进程通信、或者通过网络发送数据,就必须把对象「拍平」成字节流。

我在项目中遇到过最典型的场景:游戏存档。玩家退出游戏时,把角色属性、背包物品全部序列化到文件;下次登录再反序列化回来。嗯,这里要注意,不同平台的内存布局可能不一样,直接内存拷贝会出大问题。

三种风格,各有千秋

风格 特点 适用场景
二进制 紧凑、速度快、不可读 高性能通信、嵌入式
JSON 可读性强、跨语言 Web API、配置文件
ProtoBuf 结构化、版本兼容好 微服务、RPC框架

先看二进制风格

二进制序列化是最底层的做法。我习惯直接操作内存,把结构体按字段顺序写入字节流。但有个坑——字节序(Endianness)。

注意: 不同CPU架构的字节序可能不同。x86是小端,ARM可以配置。我曾经在x86上序列化,在ARM上反序列化,结果数值全反了。解决方案是统一用网络字节序(大端)。
// 二进制序列化示例
struct Player {
    int32_t id;
    float hp;
    char name[32];
};

void serialize(const Player& p, std::vector<uint8_t>& out) {
    // 手动处理字节序
    uint32_t net_id = htonl(p.id);
    out.insert(out.end(), (uint8_t*)&net_id, (uint8_t*)&net_id + 4);
    
    float net_hp = p.hp; // 浮点数字节序处理更复杂
    out.insert(out.end(), (uint8_t*)&net_hp, (uint8_t*)&net_hp + 4);
    
    out.insert(out.end(), p.name, p.name + 32);
}

void deserialize(const uint8_t* data, Player& p) {
    uint32_t net_id;
    memcpy(&net_id, data, 4);
    p.id = ntohl(net_id);
    
    memcpy(&p.hp, data + 4, 4);
    memcpy(p.name, data + 8, 32);
}
我的建议: 二进制序列化适合对性能要求极高的场景。但维护起来比较痛苦——字段一多,手动处理偏移量容易出错。我一般只在嵌入式或者游戏引擎底层用。

JSON风格:可读性优先

JSON的好处是肉眼能看懂。调试的时候直接打开文件就能检查数据。我用的是nlohmann/json这个库,C++里最流行的JSON库之一。

#include <nlohmann/json.hpp>
using json = nlohmann::json;

struct Player {
    int id;
    float hp;
    std::string name;
};

void to_json(json& j, const Player& p) {
    j = json{
        {"id", p.id},
        {"hp", p.hp},
        {"name", p.name}
    };
}

void from_json(const json& j, Player& p) {
    j.at("id").get_to(p.id);
    j.at("hp").get_to(p.hp);
    j.at("name").get_to(p.name);
}

// 使用
Player p{1001, 88.5f, "Alice"};
json j = p;
std::string json_str = j.dump(4); // 格式化输出

JSON的缺点也很明显——体积大、解析慢。每个字段名都要重复存储,一个int32_t本来4字节,JSON里可能变成"id":1001这种十几字节。不过对于配置文件和Web API来说,这点开销可以接受。

ProtoBuf风格:工业级选择

ProtoBuf是Google搞的,我最早接触是在做分布式系统的时候。它需要先写一个.proto文件定义数据结构,然后生成C++代码。

// player.proto
syntax = "proto3";
message Player {
    int32 id = 1;
    float hp = 2;
    string name = 3;
}

// 生成的C++代码使用
#include "player.pb.h"

Player p;
p.set_id(1001);
p.set_hp(88.5f);
p.set_name("Alice");

std::string data;
p.SerializeToString(&data);  // 序列化

Player p2;
p2.ParseFromString(data);    // 反序列化
核心优势: ProtoBuf自带版本兼容机制。字段有编号,新增字段不影响旧版本解析。我经历过一次接口升级,旧客户端发来的数据没有新字段,ProtoBuf自动用默认值填充,完全不用改代码。

三种风格的性能对比

我拿一个包含10个字段的结构体做了个简单测试,序列化100万次,结果如下:

风格 序列化耗时 反序列化耗时 数据大小
二进制 0.12s 0.11s 48字节
JSON 1.85s 2.10s ~180字节
ProtoBuf 0.35s 0.40s ~60字节

二进制最快最省空间,但维护成本高。JSON最慢最占空间,但调试方便。ProtoBuf在中间,兼顾性能和可维护性。我个人建议:新项目优先考虑ProtoBuf,除非有特殊限制。

核心知识体系

下面这张图帮你理清三种序列化方式的核心逻辑:

序列化与反序列化核心逻辑 内存对象 二进制序列化 JSON序列化 ProtoBuf序列化 手动处理字节序 memcpy直接拷贝 体积最小速度最快 维护成本高 键值对文本格式 可读性强 跨语言支持好 体积大解析慢 .proto文件定义 自动生成代码 版本兼容 工业级首选

避坑指南

我踩过的坑不少,挑几个典型的说说:

  • 浮点数精度问题:二进制序列化浮点数时,不同编译器对float的精度处理可能有细微差异。我曾经在VS上序列化,在GCC上反序列化,结果0.1变成了0.10000000149。解决方案是用定点数或者字符串表示。
  • 字符串长度:二进制序列化字符串时,一定要先存长度再存内容。我见过有人直接strcpy,结果反序列化时读到了一堆乱码。
  • JSON的浮点数:nlohmann/json默认用double存储所有浮点数。如果你序列化float,反序列化回来可能精度变了。记得显式指定类型。
  • ProtoBuf的默认值:ProtoBuf中,int32默认值是0。如果你真的需要区分「没设置」和「设置为0」,要用optional关键字。

选型建议

说白了,没有银弹。我一般这样选:

  • 嵌入式或者游戏引擎底层 → 二进制,追求极致性能
  • Web API、配置文件 → JSON,调试方便
  • 微服务、RPC、需要长期维护的项目 → ProtoBuf,版本兼容省心

嗯,序列化这事儿看着简单,但细节决定成败。我建议你三种都亲手写一遍,感受一下差异。下次遇到具体场景,自然就知道该用哪个了。

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