[Linux#55][网络协议] 序列化与反序列化 | TcpCalculate为例

[复制链接]
发表于 2026-10-4 17:40:00 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×

做一个基于 TCP 的计算器小工具时,最直白的方案是:客户端发一段可读的字符串,服务端解析出两个操作数和运算符,算出结果再回传。这个「把对象变成字符串发出去、再把字符串还原成对象」的过程,就是序列化与反序列化。本文以一段 Request 类的 Serialize() / DeSerialize() 实现为主线,把思路、写法和容易出错的地方讲清楚。

一、为什么要自己写序列化

网络层只能传字节流,不能直接传 C++ 对象。常见做法有三种:

  • 自定义文本协议:拼成 "1 + 2" 这样的可读字符串,人能看懂,调试方便(本文例子的做法);
  • 结构体二进制铺开:直接把内存按 memcpy 发过去,快但跨机器有大小端与对齐问题;
  • 成熟库:Protobuf、JSON、MessagePack,稳定但需要引入依赖与 IDL。

本文走第一种:小工具、对端也是自己写的,用文本协议最省事,出问题一眼能看出是哪里拼错了。

二、对象长什么样

请求里只需要三个字段:两个整数和一个运算符。用一个简单的结构体表示,连构造和序列化一起写:

class Request {
public:
    Request() {}
    Request(int x, int op, int y) : _x(x), _op(op), _y(y) {}

    bool Serialize();       // 序列化:把三个成员拼成字符串
    bool DeSerialize();     // 反序列化:把字符串拆回三个成员

    int _x = 0;
    int _op = 0;
    int _y = 0;
};

类里带 _x/_op/_y 三个成员,构造函数负责在对象创建时就赋好值,序列化时只管读取、反序列化时只管写入。

三、Serialize:拼出可读的算式字符串

序列化函数做的事很朴素:用字符串流 stringstream 把三个值按固定顺序写进去,中间用空格隔开。

bool Request::Serialize() {
    std::stringstream ss;
    ss << _x << " " << _op << " " << _y;
    _package = ss.str();
    return true;
}

调用方拿到之后,把这个字符串交给 TCP socket 发出去:

Request req(1, 2, 2);   // 假设 2 代表运算符 +
req.Serialize();
send(client_sock, req._package.c_str(), req._package.size(), 0);

也就是说 _package 是这条消息的输出结果,而 _x/_op/_y 是消息的来源,两者是同一个对象的两个视图。用 stringstream 而不是手拼 to_string,好处是类型安全、不用担心 int 拼接时产生多余字符。

四、DeSerialize:按空格切分再还原

服务端收到字符串后,要逆向还原成三个整数。这里利用「空格是天然分隔符」这个约定:从头找第一个空格、从尾找最后一个空格,两段之间的东西就是运算符,两侧分别是两个操作数。

bool Request:eSerialize() {
    std::string str = _package;
    std::size_t head = str.find(" ");
    if (head == std::string::npos) return false;

    std::size_t tail = str.rfind(" ");
    if (tail == std::string::npos || tail <= head) return false;

    int x = std::atoi(str.substr(0, head).c_str());
    int op = std::atoi(str.substr(head + 1, tail - head - 1).c_str());
    int y = std::atoi(str.substr(tail + 1).c_str());

    _x = x;
    _op = op;
    _y = y;
    return true;
}

为什么要先 find 再 rfind,还判断 tail <= head?因为这两种情况都说明字符串结构不合法:find 找不到说明根本没有分隔符;rfind 的位置小于等于 find,说明两个空格重叠成了同一个(比如输入只有一个空格、或前后有连续空格)。提前返回 false 比让它继续算下去安全得多。

解析顺序也是固定三拍:取左操作数 → 取中间运算符 → 取右操作数,最后再写回成员。这样即使中途返回 false,对象里已有的值不会被改坏。

五、atoi 的坑

atoi 用起来方便,但有两个必须知道的行为:

  • 不报错:遇到非法输入比如 "abc",返回 0;整个字符串超长溢出时行为未定义。它既不看长度也不抛异常,所以「解析失败」和「真的等于 0」在它眼里没区别。
  • 吃掉前面的空白: Lead 空格会被自动跳过,反过来说如果玩家输入畸形字符串,你会得到一个莫名其妙的 0。

所以正规项目里更推荐 std::stoi,它会抛出 invalid_argument / out_of_range,配合 try-catch 能明确区分「格式不对」和「数值真的很大」:

try {
    int x = std::stoi(str.substr(0, head));
} catch (const std::exception& e) {
    // 客户端发来非法数据,应答一个错误码即可
}

六、配套看一眼响应端

响应对象的结构同构,只是把成员换成结果:

class Response {
public:
    int _result = 0;
    int _code = 0;    // 0 表示成功,非 0 表示各类异常
};

用 _code 承载错误码,比直接发一段错误文本更好扩展:客户端按码分派处理,协议里多一个整数而已。典型错误码约定:1 除数不能为 0、2 输入非法、3 未知运算符、4 未知错误。

七、这套简单协议的实际风险

  • 没有长度字段:TCP 是流,贴着 send 发不代表对端一次能收全,必须用「包长 + 内容」或者分隔符 + 粘包处理来定界;
  • 没有版本与校验:协议演进后老客户端会解出新字段,加一个魔数与版本号能省很多麻烦;
  • 运算符用数字编码:{"+":1,"-":2,"*":3,"/":4} 这类映射表要在两端共享,写死在头文件里最省心;
  • 负数与多空格:输入 "-5 + 3" 时首部没有空格,find 仍能找到中间的空格,逻辑上可解,但如果对端连发多个空格,rfind 与 find 会重叠,需要先把连续空白折叠掉。

八、小结

这套实现的核心思路可以概括成三步:定约定(三个值空格分隔)→ 序列化时按约定拼字符串 → 反序列化时用 find/rfind 按约定拆回来。写得好的反序列化函数一定要对「结构不合法」提前返回 false,而不是假定输入永远是干净的;解析整数上,atoi 图省事但会掩盖错误,stoi + 异常才是稳妥选择。等这个玩具跑通了、要接入真实业务,再换成 Protobuf 或 JSON,改动通常只发生在这两个函数里。

qidao123.com企服评测-应用市场 编辑提示

回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表