Linux 网络:序列化与反序列化

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 字段缺失、除数为零,就都有了对应的处理位置。后面扩展聊天室消息或其他网络服务时,仍可以沿用"收字节、解完整报文、恢复对象、执行业务"的流程。

相关推荐
RD_daoyi1 小时前
外贸独立站从0到1:平台选择 + 建站流程 + 新手避坑
大数据·服务器·网络·人工智能·搜索引擎·职场和发展
科技小登1 小时前
网页防篡改与 WAF 方案怎么选?内容拦截、篡改监测与自动恢复实战测评
网络·web安全·ddos
特立独行的猫a1 小时前
仓颉语言GUI开发实战:用仓颉写串口/网络二合一调试助手
网络·ui·gui·harmonyos·tauri·仓颉
Source.Liu1 小时前
【A11】服务器底层权限表笔记
运维·服务器·笔记
先知后行。1 小时前
gdb、asan、Valgrind
linux·运维·服务器
yyuuuzz2 小时前
云服务器部署 web 项目的进程常驻记录
运维·服务器·前端·阿里云·云计算·aws
持敬chijing2 小时前
白盒测试与代码审计-JS 数据逆向
javascript·网络·安全·web安全·网络安全·网络攻击模型·安全威胁分析