今天翻到一篇不错的技术分享,看完之后自己也琢磨了一下,把思路梳理记录下来。
Linux 网络:序列化与反序列化
引言
写完 TCP 回显程序后,客户端已经能把一段字符串发给服务器。如果把需求改成“客户端输入两个整数和一个运算符,由服务器计算结果”,问题就多了一层:服务器收到一串字节,怎样知道哪些字节表示左操作数,哪些表示右操作数,哪个字符表示运算?
客户端希望表达 10 + 20,也能发送 "10 + 20" 或 {"left":10,"right":20,"oper":"+"}。双方得约定表示方法,再从字节恢复出可操作的数据。
网络计算器也能把对象、JSON、应用层报文和 TCP 收发串起来。
一、对象与字节序列
1. 序列化与反序列化的含义
序列化(Serialization)是把程序中的数据,按约定编码成也能存储或传输的字节序列;反序列化(Deserialization)则按同一套约定解析字节,恢复出具有相应含义的数据。
发送端:请求对象 → 序列化 → 字节序列
接收端:字节序列 → 反序列化 → 请求对象
恢复的是字段的值和含义,不要求保留原来的内存地址。遵守同一份协议的两个进程,可以使用不同编程语言。
序列化也不等于加密或压缩。把密码放进 JSON,密码仍然可读;把数字写成文本,报文还可能比二进制表示更长。保密和减小体积得另外设计。
2. 直接发送对象内存的问题
下面的写法很容易让人觉得省事:
Request req{10, 20, '+'};
send(sockfd, &req, sizeof(req), 0);
但它把 C++ 的内存布局当成了网络协议,接收方务必承担一整套隐含条件。
问题可能产生的结果字节序不同同一组整数字节被解释成不同数值类型宽度、对齐方法不同成员偏移和对象大小不一致对象含指针或字符串发送的可能是地址和内部管理信息编译器或对象版本不同接收方按旧布局读错字段
std::string 的对象大小不表示字符串正文长度,直接复制其内存无法通用地传递字符串。二进制协议应明确规定字段宽度、顺序和字节序,再逐字段编码。
二、网络计算器的数据约定
计算请求包含两个操作数和一个运算符;响应包含结果和状态码。本文约定输入为 32 位有符号整数,结果使用 64 位有符号整数。
#include
#include
#include
#include
#include
#include
#include
struct Request {
std::int32_t left{};
std::int32_t right{};
char oper{};
};
struct Response {
std::int64_t result{};
int code{};
};
请求的 JSON 表示为:
{"left":10,"right":20,"oper":"+"}
响应的 JSON 表示为:
{"result":30,"code":0}
键名也是协议的一部分。C++ 成员名可以与 JSON 字段名不同,但映射务必一致,不能一端发送 "x",另一端读取 "left"。
状态码约定如下:
code含义0计算成功1除数为零2取模时右操作数为零3不支持的运算符
code != 0 时应提示错误;此时 result 保持为零,不作为正常结果。
三、JSON 的表达能力
JSON(JavaScript Object Notation,JavaScript 对象表示法)是一种文本数据交换格式,可以表示对象、数组、字符串、数字、布尔值和空值。对象的键是字符串,字符串使用双引号。跨系统交换 JSON 时应使用 UTF-8(Unicode 的一种字节编码方法)。这些基本规则见 JSON 标准 RFC 8259。
JSON 只定义表示方法,字段含义仍由应用约定。一个数字表示年龄、端口还是计算结果,得协议说明。
运算符的类型尤其容易混淆。C++ 的 '+' 是字符,JSON 没有单独的字符类型。若直接把 char 交给 JsonCpp 的整数重载,在常见 ASCII 环境中可能得到数字 43。它与字符串 "+" 是两种不同的协议表示。
本文统一把运算符编码成单字符字符串:
root["oper"] = std::string(1, req.oper);
反序列化时也务必按字符串读取,检查长度为 1 后再取出字符。客户端和服务器需要同时采用这个约定。
四、JsonCpp 序列化
JsonCpp 是一个 C++ JSON 库。这里使用 Json::Value 描述 JSON 数据,通过 StreamWriterBuilder 和 writeString 生成文本;这些接口的用法可查阅 JsonCpp 官方示例。
先把公共的 JSON 输出过程封装起来:
std::string WriteJson(const Json::Value& root) {
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
builder["commentStyle"] = "None";
return Json::writeString(builder, root);
}
std::string Serialize(const Request& req) {
Json::Value root;
root["left"] = Json::Int(req.left);
root["right"] = Json::Int(req.right);
root["oper"] = std::string(1, req.oper);
return WriteJson(root);
}
std::string Serialize(const Response& resp) {
Json::Value root;
root["result"] = Json::Int64(resp.result);
root["code"] = resp.code;
return WriteJson(root);
}
Json::Value 是程序内的 JSON 数据结构;WriteJson 返回的 std::string 才保存要传输的文本字节。两个阶段不能混为一谈。
关闭缩进可使输出紧凑。空格、换行和字段顺序可能随配置变化;报文长度应按最终生成的字符串计算。
五、反序列化与字段校验
1. 解析完整的 JSON 对象
反序列化至少包含两个判断:文本是否符合 JSON 语法,解析出的字段是否符合计算器协议。
bool ReadObject(const std::string& text, Json::Value& root) {
Json::CharReaderBuilder builder;
Json::CharReaderBuilder::strictMode(&builder.settings_);
builder["stackLimit"] = 64;
std::unique_ptr reader(
builder.newCharReader());
std::string errors;
try {
bool ok = reader->parse(
text.data(), text.data() + text.size(),
&root, &errors);
return ok && root.isObject();
} catch (const std::exception&) {
return false;
}
}
本例启用严格解析,拒绝注释、重复键和 JSON 后面的额外非空白东西,并限制解析递归深度。相关配置及超出深度时的异常行为见 CharReaderBuilder 文档。实际服务可以记录 errors,帮助定位格式错误。
传入的起止指针标明字符串范围,不依赖末尾的 '\0' 来确定输入长度。
2. 恢复请求对象
bool IsInteger(const Json::Value& value) {
return value.type() == Json::intValue ||
value.type() == Json::uintValue;
}
bool IsInt32(const Json::Value& value) {
return IsInteger(value) && value.isInt();
}
bool Deserialize(const std::string& text, Request& out) {
Json::Value root;
if (!ReadObject(text, root)) return false;
if (!IsInt32(root["left"]) ||
!IsInt32(root["right"]) ||
!root["oper"].isString()) {
return false;
}
std::string oper = root["oper"].asString();
if (oper.size() != 1) return false;
Request req{root["left"].asInt(),
root["right"].asInt(), oper[0]};
out = req;
return true;
}
只检查 parse 成功还不够。{"left":"abc"} 在语法上是合法 JSON,却不满足请求字段要求。缺少字段、字段为 null、整数超出范围,同样不能直接交给计算器。
这里显式检查整数类型,再检查 32 位范围;10.0 等浮点写法不纳入该协议。oper 必须是一个字节的字符串,不支持的运算符交给业务层返回状态码 3。
先恢复到临时对象,校验通过后再赋给 out,可以避免失败时留下“部分字段已经更新”的状态。
3. 恢复响应对象
bool Deserialize(const std::string& text, Response& out) {
Json::Value root;
if (!ReadObject(text, root)) return false;
if (!IsInteger(root["result"]) ||
!root["result"].isInt64() ||
!IsInt32(root["code"])) {
return false;
}
int code = root["code"].asInt();
if (code < 0 || code > 3) return false;
out = Response{root["result"].asInt64(), code};
return true;
}
六、TCP 字节流与消息边界
序列化解决了字段怎样表示,但接收端还需要知道一条消息的起点和终点。
TCP(Transmission Control Protocol,传输控制协议)提供字节流,不保留应用程序一次发送的消息边界。连续两次 send,接收端可能一次读到,也可能分多次读到。Linux TCP 手册明确说明了这一点。
例如,连续发送两份 JSON:
{"left":10,"right":20,"oper":"+"}
{"left":30,"right":5,"oper":"-"}
一次接收可能只拿到第一份的前半段,也可能拿到第一份加第二份的一部分。不能把一次 recv 的返回结果直接当成一份完整 JSON。
接收到的东西应用层处理半条报文保留已有字节,继续接收一条完整报文解析并处理多条完整报文循环解析,逐条处理完整报文加半条报文处理完整部分,保留剩余部分
这就是通常所说的拆包、粘包。它们体现了 TCP 的流式语义,并非数据损坏。增大接收数组、让两次发送之间 sleep,都不能代替应用层的消息边界规则。
七、长度报头与封装
这里沿用一种容易观察的应用层格式:
正文长度\r\nJSON正文\r\n
\r\n 表示两个实际字节:回车和换行。上面为了便于阅读使用了转义写法;在线路上发送的并非反斜杠、字母 r 等四个字符。
以这份不带空格和末尾换行的正文为例:
{"left":10,"right":20,"oper":"+"}
正文为 33 字节,完整报文的可读表示是:
33\r\n{"left":10,"right":20,"oper":"+"}\r\n
整帧长度是 2 + 2 + 33 + 2 = 39 字节。长度字段只表示 JSON 正文的字节数,不包括长度字段自身和两个分隔符。
constexpr std::size_t MaxBody = 1024 * 1024;
constexpr std::size_t MaxDigits = 7;
std::string Packet(const std::string& body) {
if (body.empty() || body.size() > MaxBody) {
throw std::length_error("invalid body size");
}
return std::to_string(body.size()) + "\r\n" +
body + "\r\n";
}
正文上限为 1 MiB,即 1,048,576 字节,其十进制长度最多 7 位。这个上限是本例的应用协议约定。
std::string::size() 在这里统计的是字节数。UTF-8 中文字数和字节数通常不同;若库把中文转成 \uXXXX 形式,字节数又会变化。因此长度必须在序列化做好后计算,接收端也要按字节提取正文。
如果写入器给 JSON 正文添加了换行,该换行同样属于正文,应计入长度。尾部分隔符始终根据“正文起点 + 正文长度”定位,不能随意搜索某个换行就认定正文收尾。
八、解包的三种结果
解包需要区分三种状态:得到完整报文、数据暂时不足、格式错误。后两种情况的处理方法不同,不能都返回一个空字符串。
enum class UnpackResult { Bad, More, Ready };
UnpackResult Unpack(std::string& input, std::string& body) {
const std::size_t pos = input.find("\r\n");
if (pos == std::string::npos) {
// 允许最后只有一个尚未配对的 '\r'。
return input.size() > MaxDigits + 1
? UnpackResult::Bad : UnpackResult::More;
}
if (pos == 0 || pos > MaxDigits) {
return UnpackResult::Bad;
}
std::size_t length = 0;
for (std::size_t i = 0; i < pos; ++i) {
char ch = input[i];
if (ch < '0' || ch > '9') {
return UnpackResult::Bad;
}
std::size_t digit = ch - '0';
if (length > (MaxBody - digit) / 10) {
return UnpackResult::Bad;
}
length = length * 10 + digit;
}
if (length == 0) return UnpackResult::Bad;
const std::size_t start = pos + 2;
const std::size_t end = start + length;
if (input.size() < end + 2) {
return UnpackResult::More;
}
if (input.compare(end, 2, "\r\n") != 0) {
return UnpackResult::Bad;
}
body = input.substr(start, length);
input.erase(0, end + 2);
return UnpackResult::Ready;
}
这段函数先找完整报头,再验证长度字段,最后按长度定位正文和尾部。对数字逐位累积,并在乘十之前检查上限,避免把非法负数、溢出或超长正文带入后续计算。
More 表示现有字节可能合法,只是还不完整,函数保留 input;Ready 才删除刚消费的一帧。Bad 表示协议格式不成立,本例由调用方收尾连接。
例如,第一次收到 33\r\n{"left":10, 时返回 More。第二次追加剩余字节后,才提取完整正文。如果缓冲区还剩下一帧,调用方立即继续解包。
固定宽度的二进制长度头、严格定义的逐行消息也能划分边界,前提是双方规则一致。
九、业务计算与协议处理
业务层只处理已经恢复的 Request。在进入算术表达式前,把两个操作数提升到 64 位,可以容纳两个 32 位整数的加、减、乘结果,也能处理最小 32 位整数除以 -1 的情况。
Response Execute(const Request& req) {
const std::int64_t x = req.left;
const std::int64_t y = req.right;
Response resp;
switch (req.oper) {
case '+': resp.result = x + y; break;
case '-': resp.result = x - y; break;
case '*': resp.result = x * y; break;
case '/':
if (y == 0) resp.code = 1;
else resp.result = x / y;
break;
case '%':
if (y == 0) resp.code = 2;
else resp.result = x % y;
break;
default: resp.code = 3; break;
}
return resp;
}
这是整数计算器,7 / 2 的结果为 3。若需要小数,应同时修改字段约定、类型校验和计算方式。
一次请求到响应的转换顺序如下:
客户端:Request → JSON → 加报头 → send
↓
服务器:recv → 缓冲 → 解包 → JSON → Request
↓
Execute
↓
服务器:Response → JSON → 加报头 → send
↓
客户端:recv → 缓冲 → 解包 → JSON → Response
图中的“缓冲”属于每条连接。不同客户端的字节不能放进同一个接收字符串,长连接上的半条报文也不能在下一次循环前清空。
十、接入阻塞式 TCP 收发
1. 按返回值做好发送
序列化成功、封装完整,并不表示一次 send 一定发送完。发送方需要根据返回值推进偏移量:
```
bool SendAll(int fd, const std::string& data) {
std::size_t offset = 0;
while (offset < data.size()) {
ssize_t n = send(fd, data.data() + offset,
data.size() - offset, MSG_NOSIGNAL);
if (n < 0 && errno == EINTR) continue;
if (n
就写这么多吧,内容比较基础,适合入门回顾。有补充的地方欢迎留言一起完善。
评论 (0)
暂无评论