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