马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
做一个基于 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企服评测-应用市场 编辑提示 |