Linux 网络:序列化与反序列化
引言
写完 TCP 回显程序后,客户端已经能把一段字符串发给服务器。如果把需求改成"客户端输入两个整数和一个运算符,由服务器计算结果",问题就多了一层:服务器收到一串字节,怎样知道哪些字节表示左操作数,哪些表示右操作数,哪个字符表示运算?
客户端希望表达 10 + 20,可以发送 "10 + 20" 或 {"left":10,"right":20,"oper":"+"}。双方需要约定表示方法,再从字节恢复出可操作的数据。
网络计算器可以把对象、JSON、应用层报文和 TCP 收发串起来。
一、对象与字节序列
1. 序列化与反序列化的含义
序列化(Serialization)是把程序中的数据,按约定编码成可以存储或传输的字节序列;反序列化(Deserialization)则按同一套约定解析字节,恢复出具有相应含义的数据。
text
发送端:请求对象 → 序列化 → 字节序列
接收端:字节序列 → 反序列化 → 请求对象
恢复的是字段的值和含义,不要求保留原来的内存地址。遵守同一份协议的两个进程,可以使用不同编程语言。
序列化也不等于加密或压缩。把密码放进 JSON,密码仍然可读;把数字写成文本,报文还可能比二进制表示更长。保密和减小体积需要另外设计。
2. 直接发送对象内存的问题
下面的写法很容易让人觉得省事:
cpp
Request req{10, 20, '+'};
send(sockfd, &req, sizeof(req), 0);
但它把 C++ 的内存布局当成了网络协议,接收方必须承担一整套隐含条件。
| 问题 | 可能产生的结果 |
|---|---|
| 字节序不同 | 同一组整数字节被解释成不同数值 |
| 类型宽度、对齐方式不同 | 成员偏移和对象大小不一致 |
| 对象含指针或字符串 | 发送的可能是地址和内部管理信息 |
| 编译器或对象版本不同 | 接收方按旧布局读错字段 |
std::string 的对象大小不表示字符串正文长度,直接复制其内存无法通用地传递字符串。二进制协议应明确规定字段宽度、顺序和字节序,再逐字段编码。
二、网络计算器的数据约定
计算请求包含两个操作数和一个运算符;响应包含结果和状态码。本文约定输入为 32 位有符号整数,结果使用 64 位有符号整数。
cpp
#include <cstdint>
#include <memory>
#include <stdexcept>
#include <string>
#include <cerrno>
#include <sys/socket.h>
#include <jsoncpp/json/json.h>
struct Request {
std::int32_t left{};
std::int32_t right{};
char oper{};
};
struct Response {
std::int64_t result{};
int code{};
};
请求的 JSON 表示为:
json
{"left":10,"right":20,"oper":"+"}
响应的 JSON 表示为:
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。它与字符串 "+" 是两种不同的协议表示。
本文统一把运算符编码成单字符字符串:
cpp
root["oper"] = std::string(1, req.oper);
反序列化时也必须按字符串读取,检查长度为 1 后再取出字符。客户端和服务器需要同时采用这个约定。
四、JsonCpp 序列化
JsonCpp 是一个 C++ JSON 库。这里使用 Json::Value 描述 JSON 数据,通过 StreamWriterBuilder 和 writeString 生成文本;这些接口的用法可查阅 JsonCpp 官方示例。
先把公共的 JSON 输出过程封装起来:
cpp
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 语法,解析出的字段是否符合计算器协议。
cpp
bool ReadObject(const std::string& text, Json::Value& root) {
Json::CharReaderBuilder builder;
Json::CharReaderBuilder::strictMode(&builder.settings_);
builder["stackLimit"] = 64;
std::unique_ptr<Json::CharReader> 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. 恢复请求对象
cpp
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. 恢复响应对象
cpp
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:
text
{"left":10,"right":20,"oper":"+"}
{"left":30,"right":5,"oper":"-"}
一次接收可能只拿到第一份的前半段,也可能拿到第一份加第二份的一部分。不能把一次 recv 的返回结果直接当成一份完整 JSON。
| 接收到的内容 | 应用层处理 |
|---|---|
| 半条报文 | 保留已有字节,继续接收 |
| 一条完整报文 | 解析并处理 |
| 多条完整报文 | 循环解析,逐条处理 |
| 完整报文加半条报文 | 处理完整部分,保留剩余部分 |
这就是通常所说的拆包、粘包。它们体现了 TCP 的流式语义,并非数据损坏。增大接收数组、让两次发送之间 sleep,都不能代替应用层的消息边界规则。
七、长度报头与封装
这里沿用一种容易观察的应用层格式:
text
正文长度\r\nJSON正文\r\n
\r\n 表示两个实际字节:回车和换行。上面为了便于阅读使用了转义写法;在线路上发送的并非反斜杠、字母 r 等四个字符。
以这份不带空格和末尾换行的正文为例:
text
{"left":10,"right":20,"oper":"+"}
正文为 33 字节,完整报文的可读表示是:
text
33\r\n{"left":10,"right":20,"oper":"+"}\r\n
整帧长度是 2 + 2 + 33 + 2 = 39 字节。长度字段只表示 JSON 正文的字节数,不包括长度字段自身和两个分隔符。
cpp
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 正文添加了换行,该换行同样属于正文,应计入长度。尾部分隔符始终根据"正文起点 + 正文长度"定位,不能随意搜索某个换行就认定正文结束。
八、解包的三种结果
解包需要区分三种状态:得到完整报文、数据暂时不足、格式错误。后两种情况的处理方式不同,不能都返回一个空字符串。
cpp
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 的情况。
cpp
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。若需要小数,应同时修改字段约定、类型校验和计算方式。
一次请求到响应的转换顺序如下:
text
客户端:Request → JSON → 加报头 → send
↓
服务器:recv → 缓冲 → 解包 → JSON → Request
↓
Execute
↓
服务器:Response → JSON → 加报头 → send
↓
客户端:recv → 缓冲 → 解包 → JSON → Response
图中的"缓冲"属于每条连接。不同客户端的字节不能放进同一个接收字符串,长连接上的半条报文也不能在下一次循环前清空。
十、接入阻塞式 TCP 收发
1. 按返回值完成发送
序列化成功、封装完整,并不表示一次 send 一定发送完。发送方需要根据返回值推进偏移量:
cpp
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) return false;
offset += static_cast<std::size_t>(n);
}
return true;
}
这里处理信号中断 EINTR;MSG_NOSIGNAL 避免对已关闭连接写入时产生 SIGPIPE 信号,错误仍通过返回值报告。send 成功也不等于对端业务已经完成。Linux send 手册说明了这些语义。
这个函数用于阻塞套接字。若改成非阻塞模式,遇到 EAGAIN/EWOULDBLOCK 应保存尚未发送的数据,等待可写事件后继续。
2. 接收后持续解包
下面的 HandleConnection 接收一个已经由 accept 得到的阻塞套接字;调用方在它返回后负责 close(fd)。
cpp
void HandleConnection(int fd) {
std::string input;
char buffer[4096];
while (true) {
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) return;
input.append(buffer, static_cast<std::size_t>(n));
while (true) {
std::string body;
auto state = Unpack(input, body);
if (state == UnpackResult::More) break;
if (state == UnpackResult::Bad) return;
Request req;
if (!Deserialize(body, req)) return;
Response resp = Execute(req);
if (!SendAll(fd, Packet(Serialize(resp)))) return;
}
}
}
外层循环不断接收,内层循环处理缓冲区中的所有完整报文。只有拿到 Ready 后,才把正文交给 JSON 解析器。
append(buffer, n) 明确追加收到的 n 个字节,不依赖零结尾。recv 返回正数时表示获得数据,返回 0 表示对端有序关闭发送方向;若此时还有未完成报文,应把它视为截断数据。Linux recv 手册给出了返回值含义。
本例对非法帧、非法 JSON 或字段格式错误直接结束连接;对格式正确但业务不支持的运算,返回状态码。这使"请求根本无法解释"和"请求可解释但计算失败"有了明确区别。
十一、客户端的响应接收
客户端同样需要持久缓冲区。发送三条请求后,应记录待接收数量;每解析一条合法响应就减一,直到收齐。其间持续接收、追加和解包,不能只调用一次 recv。连接关闭或格式错误时,应报告失败。
如果客户端已把 10 + 20、8 / 0、7 % 3 三条请求完整发送,按本例服务端的顺序处理逻辑,预期得到的响应依次是:
text
{"result":30,"code":0}
{"result":0,"code":1}
{"result":1,"code":0}
三份 JSON 在线路上各自带长度报头,不能整体当成一份 JSON 对象。
以后若改成多个工作线程并发执行同一连接上的请求,响应可能按完成时间返回。此时应增加请求编号 request_id,让响应带回相同编号,并统一管理该连接的写入,防止不同线程把报文字节交错发送。
十二、协议实现的边界
长度字段来自对端。除了正文上限,还应限制每连接缓冲区、等待时长和待发送队列;客户端可能声明发送 1 MiB,却迟迟不补齐正文。
JSON 的整数范围也要统一。若某种语言的默认数字类型无法精确表示 64 位结果,需要使用相应的大整数解析方式,或把结果约定为十进制字符串。若改用二进制报头,则要另行规定报头字节序。
协议演进时,增加可选字段可以约定旧端忽略;修改字段类型却可能破坏兼容。需要版本信息时,必须把 version 写入报文并检查,仅在 C++ 对象里保存版本号不够。
服务端可能已完成计算,但响应未到客户端。计算器重复请求影响很小,转账、下单等业务则需要请求去重,保证重复提交不会反复执行同一操作。
结语
网络计算器的处理可以分成四个职责:序列化定义字段的字节表示,报文封装划分消息边界,套接字完成字节收发,业务层解释请求并计算结果。
这四个职责各自明确之后,recv 读到半条报文、一次收到多条消息、JSON 字段缺失、除数为零,就都有了对应的处理位置。后面扩展聊天室消息或其他网络服务时,仍可以沿用"收字节、解完整报文、恢复对象、执行业务"的流程。