第23章:序列化与网络协议设计
说到网络编程,绕不开的一个话题就是——数据怎么在网络上传输?
你想想看,两个进程要通信,一个在Linux上,一个在Windows上,一个用C语言,一个用Java。它们怎么理解对方发过来的数据?
这就是序列化要解决的问题。说白了,就是把内存里的结构体、对象,变成一串字节流,让对方能看懂、能还原。
我在做嵌入式网关项目时,就吃过这个亏。当时两个模块之间直接传结构体指针,结果换了个编译器,内存对齐方式变了,数据全乱套。从那以后,我再也不敢裸传结构体了。
23.1 TLV格式:最朴素的二进制协议
TLV,全称是Type-Length-Value。结构很简单:
- Type:数据类型标识,比如0x01表示温度,0x02表示湿度
- Length:Value部分的长度
- Value:实际数据
它的好处是自描述、可扩展。你收到一个TLV包,先读Type,再读Length,就知道后面跟了多少字节,不会读错。
我习惯用这种结构体来定义TLV头:
typedef struct {
uint16_t type; // 类型
uint16_t length; // 数据长度
uint8_t value[0]; // 柔性数组,C99特性
} tlv_t;
注意这里的柔性数组,它不占结构体空间,只用来做指针偏移计算。实际使用时,你需要自己管理内存:
tlv_t *create_tlv(uint16_t type, const void *data, uint16_t len) {
tlv_t *tlv = malloc(sizeof(tlv_t) + len);
if (!tlv) return NULL;
tlv->type = htons(type); // 网络字节序
tlv->length = htons(len);
memcpy(tlv->value, data, len);
return tlv;
}
TLV的缺点也很明显——没有类型检查。你发一个int32,对方如果按int16去读,数据就全歪了。所以TLV适合用在封闭系统里,双方约定好协议文档。
23.2 Protocol Buffers:Google的序列化方案
Protobuf是Google搞的,比TLV高级不少。它用.proto文件定义数据结构,然后生成代码,直接序列化/反序列化。
举个例子,定义一个消息:
syntax = "proto3";
message SensorData {
int32 temperature = 1;
int32 humidity = 2;
string device_id = 3;
}
然后你用protoc编译,生成C语言的.h和.c文件。在代码里这样用:
SensorData data;
sensor_data_init(&data, NULL);
data.temperature = 25;
data.humidity = 60;
data.device_id = "sensor_01";
uint8_t buffer[1024];
size_t len;
sensor_data_pack(&data, buffer, sizeof(buffer), &len);
// 现在buffer里就是序列化后的数据,可以直接send()
接收端反序列化:
SensorData recv_data;
sensor_data_unpack(&recv_data, buffer, len);
printf("温度: %d\n", recv_data.temperature);
Protobuf的好处是跨语言、跨平台,而且压缩率高。我做过一个测试,同样的数据,JSON要200字节,Protobuf只要60字节左右。
23.3 JSON序列化:人类可读的协议
JSON的好处不用多说——调试方便,人类能直接看懂。但代价是解析开销大,数据冗余多。
在C语言里,我常用cJSON这个库。它只有一个.c和一个.h,集成起来很方便。
序列化示例:
cJSON *root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "temperature", 25);
cJSON_AddNumberToObject(root, "humidity", 60);
cJSON_AddStringToObject(root, "device_id", "sensor_01");
char *json_str = cJSON_Print(root);
// json_str 就是 "{"temperature":25,"humidity":60,"device_id":"sensor_01"}"
send(sockfd, json_str, strlen(json_str), 0);
cJSON_Delete(root);
free(json_str);
反序列化:
cJSON *root = cJSON_Parse(recv_buffer);
if (!root) {
printf("JSON解析失败\n");
return;
}
int temp = cJSON_GetObjectItem(root, "temperature")->valueint;
cJSON_Delete(root);
JSON适合用在API接口、配置文件的场景。但如果是高频数据流,比如每秒几千条传感器数据,JSON的解析开销就太大了。
23.4 自定义协议设计:什么时候自己造轮子?
说实话,我不建议轻易自己设计协议。能用Protobuf就用Protobuf,能用JSON就用JSON。但有些场景,你不得不自己动手:
- 极端资源受限:比如8位单片机,RAM只有2KB,跑不了Protobuf
- 实时性要求极高:比如工业控制,要求微秒级解析
- 私有协议:不想让别人轻易解析你的数据
我设计自定义协议时,一般遵循这几个原则:
- 固定头部:前几个字节固定格式,比如魔数(2字节)+版本(1字节)+总长度(2字节)
- 校验机制:CRC16或CRC32,防止数据损坏
- 可扩展性:预留字段,方便以后加功能
- 对齐友好:字段按4字节对齐,避免CPU做非对齐访问
一个简单的自定义协议头部:
typedef struct {
uint16_t magic; // 魔数,比如0xABCD
uint8_t version; // 协议版本
uint8_t flags; // 标志位
uint32_t seq; // 序列号
uint16_t payload_len;// 负载长度
uint16_t crc; // CRC校验
} custom_header_t;
接收端解析时,先检查魔数,再校验CRC,然后根据payload_len读取后续数据。这样即使网络丢包或粘包,也能正确识别帧边界。
23.5 四种方案对比
| 方案 | 数据大小 | 解析速度 | 可读性 | 跨语言 | 适用场景 |
|---|---|---|---|---|---|
| TLV | 小 | 极快 | 差 | 需约定 | 嵌入式、低功耗 |
| Protobuf | 很小 | 快 | 差 | 好 | 微服务、RPC |
| JSON | 大 | 慢 | 好 | 好 | API、配置文件 |
| 自定义 | 最小 | 最快 | 差 | 需实现 | 极端场景 |
你看,没有银弹。每种方案都有自己的位置。我个人的建议是:
- 如果你在做物联网网关,设备端用TLV,云端用JSON
- 如果你在做微服务,统一用Protobuf
- 如果你在写一个简单的工具,JSON就够了
- 如果你在搞工控、军工,老老实实设计自定义协议,加上完善的校验和重传机制
23.6 知识体系结构图
下面这张图,帮你理清这四种方案的关系和选择路径:
嗯,这张图基本把四种方案的定位说清楚了。你从左上角开始,根据你的资源、性能、跨语言需求,一路往下走,就能找到合适的方案。
最后说一句:协议设计没有标准答案,但有一条铁律——先保证不出错,再追求效率。我见过太多人为了省几个字节,结果数据解析全乱套,得不偿失。
公众号:蓝海资料掘金营,微信deep3321