数据的「快递打包」艺术:一文吃透应用层自定义序列化(附手写 C++ 实现)
我们每天都在和数据传输打交道:发消息、加载游戏、调用接口,本质都是把内存里的数据变成字节流发出去,再在另一端还原回来 —— 这个过程就是序列化与反序列化。
如果把网络传输比作快递物流,序列化就是把零散的商品打包成标准包裹,反序列化就是收件后拆箱复原。通用方案(JSON、Protobuf)就像现成的快递盒,方便但不一定最适配;而应用层自定义序列化,就是亲手定制打包规则,做到极致性能、零依赖、完全可控。
一、先搞懂:序列化到底在解决什么问题
1. 核心定义
- 序列化:将内存中的结构化对象(结构体、类对象),转换成一段连续的二进制字节流,方便网络传输、文件存储
- 反序列化:将接收到的二进制字节流,重新还原成内存中可读写的结构化对象
简单说:内存对象 ↔ 字节流的双向转换。
2. 为什么要「自定义」?现成方案不香吗?
通用序列化方案各有局限,很多场景下必须自己写规则:
表格
| 方案 | 特点 | 不适用场景 |
|---|---|---|
| JSON/XML | 文本可读、兼容性好 | 体积大、解析慢,高性能 / 低带宽场景扛不住 |
| Protobuf/FlatBuffers | 高效、跨语言 | 需要依赖代码生成工具,嵌入式 / 极精简环境难移植 |
| 自定义序列化 | 零依赖、极致性能、协议完全可控 | 通用业务开发(成本高) |
游戏服务器、嵌入式设备通信、高性能网关、私有协议开发,都是自定义序列化的高频场景。
二、自定义序列化的核心「打包规则」
设计一套可靠的自定义序列化协议,本质是约定好「打包拆箱的统一标准」,有 5 个核心要点必须考虑。
1. 协议骨架:包头 + 包体的经典结构
几乎所有二进制协议都遵循「固定包头 + 变长包体」的设计,就像快递必须先有面单(包头),再装货物(包体):
- 包头:固定长度,存整个包的元信息
- 总长度:解决 TCP 粘包问题,知道要读多少字节
- 版本号:保证新旧版本协议兼容
- 命令字:区分当前包是什么业务(登录 / 聊天 / 心跳)
- 序列号:请求 - 响应匹配
- 包体:真正的业务数据,长度由包头声明
2. 必踩的坑:字节序(大小端)统一
CPU 存储多字节数据有两种顺序:
- 大端(网络字节序):高位字节存在低地址
- 小端(主机字节序):低位字节存在低地址(x86/ARM 主流)
如果发送方小端、接收方按大端解析,数字会完全错乱,就像快递地址写反了顺序。行业约定:网络传输统一使用大端字节序,发送时转大端,接收时转回主机字节序。
3. 隐形陷阱:内存对齐与直接 memcpy
很多新手图省事,直接把结构体 memcpy 到缓冲区,这是严重错误:
- 编译器为了 CPU 访问效率,会在结构体字段间自动插入填充字节(内存对齐)
- 不同编译器、不同编译选项对齐规则可能不同
- 包含
string、vector等动态对象的结构体,对象里只存指针,直接拷贝毫无意义
正确做法:按字段顺序逐个手动写入缓冲区,跳过对齐空洞。
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 跳过,完美实现向前兼容,是工业界私有协议最常用的设计之一。
六、避坑总结
- 绝对不要直接 memcpy 包含动态对象的结构体
- 网络传输必须统一字节序,永远不要假设两端都是小端
- 所有写入 / 读取操作都必须做边界检查,防止缓冲区溢出
- 协议第一版就要加版本号,不然后续迭代会非常痛苦
- 变长数据必须「长度在前,内容在后」,不要靠
\0结尾判断字符串
更多推荐


所有评论(0)