我们每天都在和数据传输打交道:发消息、加载游戏、调用接口,本质都是把内存里的数据变成字节流发出去,再在另一端还原回来 —— 这个过程就是序列化与反序列化

如果把网络传输比作快递物流,序列化就是把零散的商品打包成标准包裹,反序列化就是收件后拆箱复原。通用方案(JSON、Protobuf)就像现成的快递盒,方便但不一定最适配;而应用层自定义序列化,就是亲手定制打包规则,做到极致性能、零依赖、完全可控。


一、先搞懂:序列化到底在解决什么问题

1. 核心定义

  • 序列化:将内存中的结构化对象(结构体、类对象),转换成一段连续的二进制字节流,方便网络传输、文件存储
  • 反序列化:将接收到的二进制字节流,重新还原成内存中可读写的结构化对象

简单说:内存对象 ↔ 字节流的双向转换。

2. 为什么要「自定义」?现成方案不香吗?

通用序列化方案各有局限,很多场景下必须自己写规则:

表格

方案 特点 不适用场景
JSON/XML 文本可读、兼容性好 体积大、解析慢,高性能 / 低带宽场景扛不住
Protobuf/FlatBuffers 高效、跨语言 需要依赖代码生成工具,嵌入式 / 极精简环境难移植
自定义序列化 零依赖、极致性能、协议完全可控 通用业务开发(成本高)

游戏服务器、嵌入式设备通信、高性能网关、私有协议开发,都是自定义序列化的高频场景。


二、自定义序列化的核心「打包规则」

设计一套可靠的自定义序列化协议,本质是约定好「打包拆箱的统一标准」,有 5 个核心要点必须考虑。

1. 协议骨架:包头 + 包体的经典结构

几乎所有二进制协议都遵循「固定包头 + 变长包体」的设计,就像快递必须先有面单(包头),再装货物(包体):

  • 包头:固定长度,存整个包的元信息
    • 总长度:解决 TCP 粘包问题,知道要读多少字节
    • 版本号:保证新旧版本协议兼容
    • 命令字:区分当前包是什么业务(登录 / 聊天 / 心跳)
    • 序列号:请求 - 响应匹配
  • 包体:真正的业务数据,长度由包头声明

2. 必踩的坑:字节序(大小端)统一

CPU 存储多字节数据有两种顺序:

  • 大端(网络字节序):高位字节存在低地址
  • 小端(主机字节序):低位字节存在低地址(x86/ARM 主流)

如果发送方小端、接收方按大端解析,数字会完全错乱,就像快递地址写反了顺序。行业约定:网络传输统一使用大端字节序,发送时转大端,接收时转回主机字节序。

3. 隐形陷阱:内存对齐与直接 memcpy

很多新手图省事,直接把结构体 memcpy 到缓冲区,这是严重错误:

  • 编译器为了 CPU 访问效率,会在结构体字段间自动插入填充字节(内存对齐)
  • 不同编译器、不同编译选项对齐规则可能不同
  • 包含 stringvector 等动态对象的结构体,对象里只存指针,直接拷贝毫无意义

正确做法:按字段顺序逐个手动写入缓冲区,跳过对齐空洞。

4. 兼容性设计:新旧版本怎么和平共处

协议不可能一成不变,必须考虑迭代:

  • 包头必须带版本号,接收方根据版本号解析不同字段
  • 新增字段放在包体末尾,旧版本忽略末尾多余字节
  • 采用 TLV(Type-Length-Value)格式,未知字段可以跳过

5. 变长数据处理

字符串、数组这类长度不固定的数据,统一遵循「先写长度,再写内容」的规则:

  • 先用 2/4 字节存数据长度
  • 再按长度写入实际内容

三、动手写:从零实现一个二进制序列化器(C++)

我们以一个用户信息结构体为例,完整实现序列化与反序列化,全程手动控制字节写入,零第三方依赖。

1. 先看错误示范:直接 memcpy 的坑

cpp

运行

#include <iostream>
#include <string>
#include <cstring>
using namespace std;

// 错误示范:绝对不要这么写
struct BadUser {
    int id;         // 4字节
    short age;      // 2字节
    string name;    // 只存指针,不是实际数据
};

int main() {
    BadUser u = {1001, 25, "zhangsan"};
    char buf[128] = {0};
    // 错误1:拷贝了对齐填充字节,实际结构体大小不是4+2+字符串长度
    // 错误2:string只拷贝了指针,指向的字符串内容根本没发出去
    memcpy(buf, &u, sizeof(u));
    return 0;
}

2. 正确实现:手动按字节打包

我们约定规则:

  • id:4 字节,大端存储
  • age:2 字节,大端存储
  • name:先存 2 字节长度(大端),再存字符串内容

cpp

运行

#include <iostream>
#include <string>
#include <cstring>
#include <arpa/inet.h> // 大小端转换函数
using namespace std;

// 业务结构体:内存里的对象
struct User {
    uint32_t id;
    uint16_t age;
    string name;
};

/**
 * @brief 序列化:把User对象写入缓冲区
 * @param user 输入的内存对象
 * @param buf 输出缓冲区
 * @param bufSize 缓冲区总大小,防止溢出
 * @return 实际写入的字节数,失败返回-1
 */
int serialize(const User& user, char* buf, int bufSize) {
    int offset = 0;

    // 1. 写入id:4字节,转网络大端
    uint32_t id_net = htonl(user.id);
    if (offset + sizeof(id_net) > bufSize) return -1;
    memcpy(buf + offset, &id_net, sizeof(id_net));
    offset += sizeof(id_net);

    // 2. 写入age:2字节,转网络大端
    uint16_t age_net = htons(user.age);
    if (offset + sizeof(age_net) > bufSize) return -1;
    memcpy(buf + offset, &age_net, sizeof(age_net));
    offset += sizeof(age_net);

    // 3. 写入name:先写长度(2字节),再写内容
    uint16_t name_len = user.name.size();
    uint16_t len_net = htons(name_len);
    if (offset + sizeof(len_net) + name_len > bufSize) return -1;
    memcpy(buf + offset, &len_net, sizeof(len_net));
    offset += sizeof(len_net);
    memcpy(buf + offset, user.name.c_str(), name_len);
    offset += name_len;

    return offset;
}

/**
 * @brief 反序列化:从字节流还原User对象
 * @param buf 输入的字节流
 * @param dataLen 字节流总长度
 * @param outUser 输出的内存对象
 * @return 成功返回0,失败返回-1
 */
int deserialize(const char* buf, int dataLen, User& outUser) {
    int offset = 0;

    // 1. 读取id
    if (offset + sizeof(uint32_t) > dataLen) return -1;
    uint32_t id_net;
    memcpy(&id_net, buf + offset, sizeof(id_net));
    outUser.id = ntohl(id_net);
    offset += sizeof(id_net);

    // 2. 读取age
    if (offset + sizeof(uint16_t) > dataLen) return -1;
    uint16_t age_net;
    memcpy(&age_net, buf + offset, sizeof(age_net));
    outUser.age = ntohs(age_net);
    offset += sizeof(age_net);

    // 3. 读取name:先读长度,再读内容
    if (offset + sizeof(uint16_t) > dataLen) return -1;
    uint16_t len_net;
    memcpy(&len_net, buf + offset, sizeof(len_net));
    uint16_t name_len = ntohs(len_net);
    offset += sizeof(len_net);

    if (offset + name_len > dataLen) return -1;
    outUser.name.assign(buf + offset, name_len);
    offset += name_len;

    return 0;
}

// 测试
int main() {
    User sendUser = {10086, 24, "custom_serialize"};
    char buf[256] = {0};

    // 序列化
    int len = serialize(sendUser, buf, sizeof(buf));
    cout << "序列化后字节数:" << len << endl;

    // 反序列化
    User recvUser;
    int ret = deserialize(buf, len, recvUser);
    if (ret == 0) {
        cout << "反序列化结果:" << endl;
        cout << "id: " << recvUser.id << endl;
        cout << "age: " << recvUser.age << endl;
        cout << "name: " << recvUser.name << endl;
    }
    return 0;
}

四、一图看懂:自定义序列化知识思维导图

plaintext

应用层自定义序列化知识体系
├─ 基础概念
│  ├─ 序列化:内存对象 → 二进制字节流
│  └─ 反序列化:二进制字节流 → 内存对象
├─ 核心设计
│  ├─ 协议结构:固定包头 + 变长包体
│  │   ├─ 包头:长度/版本号/命令字/序列号
│  │   └─ 包体:业务字段数据
│  ├─ 字节序:统一网络大端
│  │   ├─ 发送:主机序 → 网络序(htonl/htons)
│  │   └─ 接收:网络序 → 主机序(ntohl/ntohs)
│  ├─ 数据类型处理
│  │   ├─ 定长类型:按字节数直接写入
│  │   └─ 变长类型:先写长度,再写内容
│  └─ 兼容性设计
│      ├─ 包头带版本号
│      ├─ 新增字段放末尾
│      └─ TLV格式支持未知字段跳过
├─ 常见坑点
│  ├─ 直接memcpy结构体(对齐空洞+动态对象)
│  ├─ 字节序不统一
│  ├─ 无边界检查导致缓冲区溢出
│  └─ 无版本号导致协议迭代崩掉
├─ 主流实现格式
│  ├─ 定长二进制打包(简单业务)
│  ├─ TLV(Type-Length-Value)(灵活扩展)
│  └─ 变长编码(极致压缩体积)
└─ 进阶增强
   ├─ CRC校验:保证数据完整性
   ├─ 零拷贝序列化:减少内存拷贝
   ├─ 字段加密:敏感数据保护
   └─ 压缩算法:进一步减小包体

五、进阶:更灵活的 TLV 格式

如果你的协议需要频繁迭代、字段经常增减,推荐使用 TLV(Type-Length-Value) 格式:

  • Type:字段编号,代表这是什么字段
  • Length:字段数据的长度
  • Value:字段实际内容

接收方解析时,遇到认识的 Type 就处理,不认识的直接按 Length 跳过,完美实现向前兼容,是工业界私有协议最常用的设计之一。


六、避坑总结

  1. 绝对不要直接 memcpy 包含动态对象的结构体
  2. 网络传输必须统一字节序,永远不要假设两端都是小端
  3. 所有写入 / 读取操作都必须做边界检查,防止缓冲区溢出
  4. 协议第一版就要加版本号,不然后续迭代会非常痛苦
  5. 变长数据必须「长度在前,内容在后」,不要靠 \0 结尾判断字符串
谢谢
Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐