【Linux 网络编程】手写序列化与反序列化:从 x op y 到能校验的文本协议

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位:接着应用层自定义协议与 JsonCpp 的内容,亲手实现一个不依赖 JSON 库的计算器正文协议,理解字段如何编码、切分、校验和恢复。

💡 学习目标:分清 Serialize 与 Encode;读懂 find、rfind 和 substr;识别 stoi 的部分转换与异常;实现失败时不污染对象的反序列化;验证半帧、多帧和非法输入。

📌 实现范围:C++17、32 位有符号操作数、单字节可见 ASCII 运算符。正文采用空格分隔,外层继续采用长度前缀。示例只演示协议与确定性的分片输入,不重复实现上一讲的 TCP 计算服务器。

前面借助 JsonCpp,我们可以把对象转换成 JSON,再把 JSON 还原成对象。库调用很短,却容易遮住一个重要问题:接收端究竟凭什么知道,哪些字符是第一个操作数,哪个字符是运算符,哪些字符属于第二个操作数?

现在暂时放下库,从最简单的 7 + 3 开始。只要能把这五个字节的来龙去脉说明白,序列化这件事就不再神秘。


文章目录

  • 一、先分清对象、正文与外层帧
  • 二、给手写协议定一份明确的契约
  • 三、Request 序列化:让字段按顺序落到字符串里
  • 四、Request 反序列化:逐字符拆开 x op y
  • 五、字符串变整数,为什么不能只调用 stoi
  • 六、把请求与响应实现成可复用的协议模块
  • 七、Encode 与 Decode:取完整帧,再解释字段
  • 八、条件编译、JSON 分支与工厂该怎样理解
  • 九、完整演示与编译运行
  • 十、用边界测试检查协议是否真的可靠
  • 十一、从教学文本协议走向更复杂的数据
  • 总结

一、先分清对象、正文与外层帧

1.1 序列化把什么变成了什么

内存中的请求可以写成:

cpp 复制代码
struct Request {
    std::int32_t x;
    std::int32_t y;
    char op;
};

业务上它表示"拿 x 和 y 做 op 指定的运算"。网络发送接口接收的是一段字节,不能直接把 C++ 对象的业务含义传给另一台机器。

序列化于是规定:先写 x 的十进制文本,再写一个空格,再写 op,再写一个空格,最后写 y。接收端按相同规则反向处理,恢复三个字段。

text 复制代码
Request{x=7, y=3, op='+'} → "7 + 3" → Request{x=7, y=3, op='+'}

这里没有把 int32_t 的内存字节原样搬过去。文本中的 7 是 ASCII 字符,负数包含减号,多位数占多个字符,因此也没有直接使用主机内存里的整数布局。

1.2 Serialize 与 Encode 各自负责一层

PDF 把这两类操作放在同一份文件中,但职责不同:

接口 输入 → 输出 回答的问题
Serialize Request → 7 + 3 字段按什么格式表达
Encode 7 + 3 → 5\r\n7 + 3\r\n 正文多长,完整消息在哪里结束
Decode 接收缓存 → 一条完整正文 当前是否已经有一条完整帧
Deserialize 完整正文 → Request 字段是否有效,怎样恢复对象

TCP 提供字节流。无论正文采用空格文本还是 JSON,应用都需要自己的消息边界规则。不能因为正文只有一行,就默认一次 recv 恰好得到一行。

1.3 不直接发送结构体的原因

send(fd, &req, sizeof(req), ...) 传递的是对象表示。成员布局、填充、整数的字节序,以及两端的类型约定都必须额外处理;带 std::string 的对象还包含进程内部的管理信息,更不能靠复制对象本体恢复字符串内容。

本文用文本协议绕开这些布局依赖,但它同样需要明确字符集、字段顺序、分隔符与数值范围。"变成字符串"只是实现方式,双方共同遵守的格式才是通信依据。


二、给手写协议定一份明确的契约

2.1 请求正文:两个空格,三个字段

本篇采用以下规则,它们比课堂演示更严格,属于整理时明确补充的约定:

项目 本文约定
请求格式 x SP op SP y,SP 是一个 ASCII 空格 0x20
x、y 十进制有符号 32 位整数,范围 -2147483648~2147483647
数字语法 可选负号,后面至少一位数字;拒绝正号、小数、空白和后缀
运算符 恰好一个可见 ASCII 字节,范围 0x21~0x7e
空格规则 正文中恰有两个空格,不接受首尾或额外空格
正文大小 本协议解析入口最多接受 64 字节

为什么运算符没有在解析层限制成 + - * / %?因为"是不是一个合法字段"和"计算器是否支持它"可以分别处理。7 ^ 3 结构合法,业务层可以返回"不支持的运算符"。空运算符、两个字符的 ** 或一个制表符,则在结构层直接拒绝。

这种分工要由协议确定,不能两端各自猜测。若你的业务只允许五种符号,也可以在契约中明确限制,并同步修改两端实现。

2.2 响应正文:结果与状态分开

响应采用 result SP code,两项均按 int32 范围解析,另外要求 code 非负。code=0 表示成功,非零表示业务失败;失败时 result 不作为有效计算结果使用。

text 复制代码
10 0    → 成功,结果为 10
0 1     → 业务失败,具体含义由状态码表规定

为了接回上一篇计算器,可以继续使用其状态表:0 成功、1 除零、2 取模零、3 不支持的操作、4 结果溢出、5 请求非法。本篇的反序列化只验证 code 的表示范围与非负条件,不执行算术,也不把状态表写死在文本切分函数中。

如果将来收到尚不认识的非零状态码,客户端仍按失败处理,可以显示数值供排查,不能把未知值当成成功。

2.3 文本可以有不同写法,对象仍然等价

本篇数字解析允许前导零与 -0:007 + -0 可以被解析。序列化统一输出 7 + 0,不会保留原始写法。

所以我们要求的往返性质是:合法对象序列化后再反序列化,字段值不变。 不要求任意合法输入文本经过解析和重写后,每个字节都保持原样。

如果签名或缓存键要求唯一表示,应把前导零、负零等规范化规则也写入协议。外层长度头沿用上一篇的规则,禁止多位头部以零开头;正文数字与长度头属于不同字段,规则不必相同。


三、Request 序列化:让字段按顺序落到字符串里

PDF 的手写分支很直观:

cpp 复制代码
*out = std::to_string(_data_x) + ProtSep + _oper
     + ProtSep + std::to_string(_data_y);

ProtSep 是字符串 " "。表达式左侧先构造出 std::string,随后把运算符作为一个字符追加进去;这与把字符本身转成数字不同。

3.1 把每一步展开来看

text 复制代码
std::to_string(7)  → "7"
追加 " "          → "7 "
追加 '+'          → "7 +"
追加 " "          → "7 + "
追加 to_string(3) → "7 + 3"

原对象的成员排列不决定网络格式。即使结构体把 x、y 放在相邻位置,我们依旧可以规定线上次序是 x、op、y。关键是发送与接收使用同一顺序。

3.2 负号与减法符号为什么不会混淆

-12 - -3 中有三个减号,但空格已经划出了字段位置:第一个字段 -12,中间字段 -,最后字段 -3。接收端按空格切分,不按减号切分,因此能分清数值符号与运算符。

反过来,-12--3 不符合本文格式。我们没有实现完整的数学表达式解析器,也不打算让接收端从任意表达式里猜出三个字段。

3.3 为什么序列化也可能返回失败

整数成员本身已经有类型约束,但 char op 仍可能是空格、换行或 NUL。输出前验证它是否符合契约,可以避免自己制造出对端无法解析的正文。

本文采用 bool + 输出引用 的形式:协议字段不合法时返回 false,并保留原输出字符串。字符串分配等运行时异常仍按 C++ 的异常机制传播;这个 bool 不承诺吸收所有系统资源错误。


四、Request 反序列化:逐字符拆开 x op y

4.1 先读懂 PDF 的 find 与 rfind

PDF 使用 find(ProtSep) 找第一个空格,再用 rfind(ProtSep) 找最后一个空格。对于合法的 7 + 3,下标关系如下。

text 复制代码
字符: 7   空格   +   空格   3
下标: 0    1    2    3    4
            ↑         ↑
           left      right

substr(pos, count) 的第二个参数是长度。因此三段分别是:

cpp 复制代码
in.substr(0, left);                         // "7"
in.substr(left + 1, right - (left + 1));     // "+"
in.substr(right + 1);                       // "3"

运算符区间可写成 [left + 1, right)。半开区间的好处是,长度正好等于结束位置减开始位置。若中间是 **,长度就为 2,不能塞进协议要求的一个字节。

4.2 找到空格,不等于格式已经正确

如果正文只有一个空格,find 和 rfind 会得到同一个位置。PDF 的两次 npos 判断都可能通过,却没有证明左右空格确实不同。对无符号位置做减法还可能产生回绕,后续切分不能代表期望的字段区间。

本文改成"找第一个空格,再从它后面找第二个空格,最后确认没有第三个空格"。每个检查都直接对应协议的一条规则。

cpp 复制代码
const auto left = in.find(' ');
// 先确认 left 存在,再计算 left + 1。
const auto right = in.find(' ', left + 1);
// 确认 right 存在后,拒绝 right 后面出现的第三个空格。

这里为了说明思路省略了分支,完整可编译版本见第六节。不要直接把这段缩写当成安全的解析函数。

4.3 字段数量与字段内容分开验证

7 + 3 包含三个空格,会因额外字段分隔被拒绝;7 3 恰有两个空格,但中间字段为空,会因运算符长度不符被拒绝。

7 + 的字段数量看起来正确,最后一个数字却是空串;7 + 3abc 的最后一项不再是纯数字。它们需要整数解析函数继续判断。

反序列化通常是一串逐渐收紧的检查:长度 → 分隔位置 → 字段数量 → 单字段语法 → 数值范围。任何一层失败,都不能把对象交给业务使用。


五、字符串变整数,为什么不能只调用 stoi

5.1 stoi 可以只转换一个合法前缀

cpp 复制代码
std::stoi("12abc"); // 可以得到 12

这不代表标准库出错了,而是它的接口允许识别数值前缀。需要检查整段文本时,应使用 idx 观察停止位置,并处理无法转换、超出范围的异常。C++ 标准草案:字符串数值转换

课堂示例直接调用 stoi,有利于观察主线;作为外部输入解析器时,则不能漏掉这层契约。例如 3junk 不应该悄悄变成 3。

5.2 from_chars 也需要看两个结果

本文改用 C++17 的整数 std::from_chars,同时检查 ec 和 ptr。ec 表示转换错误,ptr 表示停止位置;只有转换成功且停止位置恰好到达字段末尾,才说明完整字段被消费。C++ 标准草案:from_chars

cpp 复制代码
const auto r = std::from_chars(first, last, value);
if (r.ec != std::errc{} || r.ptr != last) return false;

为了把协议语法写得更直观,完整实现还会预先检查可选负号与十进制数字。这样 +3、制表符、小数点、内嵌 NUL 和字母后缀都会被明确拒绝。范围检查则交给转换到 std::int32_t 的操作完成,避免自行累积带符号整数时发生溢出。

5.3 失败时对象保持原值

PDF 先更新 x 和 y,随后才检查 op 的长度。如果 op 不合法,函数返回 false,但部分成员已经改变,调用者很难确定对象还能否使用。

本文把结果先写入临时对象 value。所有字段通过后才执行一次 out = value。失败时原对象保持不变,调用者只需依据返回值决定是否继续使用新请求。

这不是要求每个类都套一层复杂事务,而是让一个解析函数拥有清楚、容易验证的失败行为。


六、把请求与响应实现成可复用的协议模块

以下是完整的 TextProtocol.hpp。它不包含 JsonCpp,也不依赖 Socket;Request 与 Response 使用同名重载接口,调用时由对象类型选择对应格式。

cpp 复制代码
#pragma once
#include <charconv>
#include <cstdint>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>

namespace textproto {
inline constexpr std::size_t MaxText = 64;
struct Request { std::int32_t x = 0, y = 0; char op = '+'; };
struct Response { std::int32_t result = 0, code = 0; };

inline bool ParseInt32(std::string_view s, std::int32_t& out) {
    if (s.empty()) return false;
    const std::size_t begin = s.front() == '-' ? 1 : 0;
    if (begin == s.size()) return false;
    for (std::size_t i = begin; i < s.size(); ++i)
        if (s[i] < '0' || s[i] > '9') return false;
    std::int32_t value = 0;
    const auto r = std::from_chars(s.data(), s.data() + s.size(), value);
    if (r.ec != std::errc{} || r.ptr != s.data() + s.size()) return false;
    out = value;
    return true;
}

inline bool IsOperator(char c) {
    const auto u = static_cast<unsigned char>(c);
    return u >= 0x21 && u <= 0x7e; // One visible ASCII byte; excludes space.
}

inline bool Serialize(const Request& req, std::string& out) {
    if (!IsOperator(req.op)) return false;
    std::string value = std::to_string(req.x) + " " + req.op + " "
                      + std::to_string(req.y);
    out = std::move(value);
    return true;
}

inline bool Deserialize(std::string_view in, Request& out) {
    if (in.size() > MaxText) return false;
    const auto left = in.find(' ');
    if (left == std::string_view::npos) return false;
    const auto right = in.find(' ', left + 1);
    if (right == std::string_view::npos) return false;
    if (in.find(' ', right + 1) != std::string_view::npos) return false;
    const auto op = in.substr(left + 1, right - left - 1);
    if (op.size() != 1 || !IsOperator(op.front())) return false;
    Request value;
    if (!ParseInt32(in.substr(0, left), value.x) ||
        !ParseInt32(in.substr(right + 1), value.y)) return false;
    value.op = op.front();
    out = value; // Commit only after every field is valid.
    return true;
}

inline bool Serialize(const Response& resp, std::string& out) {
    if (resp.code < 0) return false;
    out = std::to_string(resp.result) + " " + std::to_string(resp.code);
    return true;
}

inline bool Deserialize(std::string_view in, Response& out) {
    if (in.size() > MaxText) return false;
    const auto pos = in.find(' ');
    if (pos == std::string_view::npos ||
        in.find(' ', pos + 1) != std::string_view::npos) return false;
    Response value;
    if (!ParseInt32(in.substr(0, pos), value.result) ||
        !ParseInt32(in.substr(pos + 1), value.code) || value.code < 0)
        return false;
    out = value;
    return true;
}
} // namespace textproto

6.1 string_view 为什么适合这里

std::string_view 只观察已有字符,不拥有底层内存。它的 substr 产生的仍是一个视图,能让我们按区间检查字段,而不必为每一段都构造新字符串。

本实现只在函数调用期间使用这些视图,返回的 Request 保存的是整数与字符,没有保存指向输入的引用。调用结束后修改或释放原字符串,不会影响已经解析出的字段。

相反,如果把某个字段视图存进长期对象,就必须保证原始字符串一直有效,且地址没有因重新分配而改变。是否复制字符串取决于生命周期,不能只看写法短不短。

6.2 为什么正文限制为 64 字节

合法 Request 的最长规范输出是两个 11 字节的 int32 最小值,加两处空格与一个运算符,共 25 字节。Response 的规范输出最多 22 字节:11 字节 result、一个空格、最多 10 字节的非负 code。

64 字节为允许前导零的输入留出余量,并给解析入口设置明确上限。它与外层 64 KiB 的通用正文上限不同:外层可以承载其他类型的正文,本协议只接受自己的小消息。

接回实际网络程序时,如果连接只服务这种文本协议,还可以进一步降低外层帧上限。单独限制单条正文,不能替代接收超时、总缓存上限或连接数控制。


七、Encode 与 Decode:取完整帧,再解释字段

7.1 以字节数计算长度

正文 7 + 3 包含 5 字节,完整帧为:

text 复制代码
5\r\n7 + 3\r\n

其中 \r\n 表示两个实际控制字节,不是反斜杠、r、反斜杠、n 四个可见字符。帧总长为 1 + 2 + 5 + 2 = 10 字节,长度值 5 只计正文。

这里协议内容都是 ASCII。若以后使用 UTF-8 中文字段,仍应按实际编码字节计算长度,不能用显示出来的汉字数量替代 std::string::size()。

7.2 接收端用三种结果表达当前进度

PDF 的 bool Decode 能表达"取到一帧"与"没取到",但不完整和损坏都可能表现成 false。正文整理沿用上一讲已经使用的三状态外层模块:

结果 缓存如何变化 调用者下一步
NeedMore 不消费 继续接收字节
Ready 只消费当前完整帧 解析正文,并继续检查缓存
Invalid 不消费 按连接策略报告错误并终止处理

下面给出完整 Frame.hpp,保持示例独立可编译。外层拒绝非法数字头、超长声明和错误尾分隔符,不把"字节数够了"当成格式校验完成。

cpp 复制代码
#pragma once
#include <cstddef>
#include <stdexcept>
#include <string>
#include <string_view>

namespace wire {
inline constexpr std::size_t MaxBody = 64 * 1024;
inline constexpr std::size_t MaxDigits = 5;
enum class State { NeedMore, Ready, Invalid };

inline std::string Encode(std::string_view body) {
    if (body.size() > MaxBody) throw std::length_error("body too large");
    std::string frame = std::to_string(body.size()) + "\r\n";
    frame.append(body.data(), body.size());
    frame += "\r\n";
    return frame;
}

inline State Decode(std::string& buffer, std::string& body) {
    const auto pos = buffer.find("\r\n");
    if (pos == std::string::npos) {
        // An unfinished header can contain digits and one final CR only.
        std::size_t digits = buffer.size();
        if (digits && buffer.back() == '\r') --digits;
        if (digits > MaxDigits) return State::Invalid;
        for (std::size_t i = 0; i < digits; ++i)
            if (buffer[i] < '0' || buffer[i] > '9') return State::Invalid;
        if (!buffer.empty() && buffer[0] == '\r') return State::Invalid;
        if (digits > 1 && buffer[0] == '0') return State::Invalid;
        return State::NeedMore;
    }
    if (pos == 0 || pos > MaxDigits) return State::Invalid;
    if (pos > 1 && buffer[0] == '0') return State::Invalid;
    std::size_t length = 0;
    for (std::size_t i = 0; i < pos; ++i) {
        const char c = buffer[i];
        if (c < '0' || c > '9') return State::Invalid;
        const auto digit = static_cast<std::size_t>(c - '0');
        if (length > (MaxBody - digit) / 10) return State::Invalid;
        length = length * 10 + digit;
    }
    const std::size_t body_begin = pos + 2;
    const std::size_t total = body_begin + length + 2;
    if (buffer.size() < total) return State::NeedMore;
    if (buffer.compare(body_begin + length, 2, "\r\n") != 0)
        return State::Invalid;
    body.assign(buffer.data() + body_begin, length);
    buffer.erase(0, total);
    return State::Ready;
}
} // namespace wire

7.3 持久缓存属于连接,不能每次读取都清空

假设两条帧连在一起到达:5\r\n7 + 3\r\n5\r\n9 - 2\r\n。Decode 第一次只取出第一条,第二次再取第二条。若只解析一次就继续阻塞读取,会把本来已经齐全的请求留在缓存里。

如果收到的只是 5\r\n7 +,应保留缓存等待后续的 3\r\n。不能提前把它交给 Deserialize,然后把字段不完整误报成业务请求错误。

实际调用 recv 时,要按返回的有效字节数追加,例如 buffer.append(data, n);不要假定网络字节以 NUL 结尾。连接关闭时,缓存还有半帧意味着输入被截断,也不应当成一条完整请求。

7.4 正文非法与帧非法采用不同策略

取到完整帧但正文是 7 + x,边界仍然可信。服务端可以返回"请求非法"的响应,随后继续处理下一帧。

长度头或尾分隔符已经损坏时,接收端未必知道下一条消息从哪里开始。本文建议终止当前连接,不盲目在后续数据中搜索一个像数字的片段来恢复同步。具体的断连与错误响应代码属于网络调用者,本篇演示遇错以退出码结束。


八、条件编译、JSON 分支与工厂该怎样理解

8.1 #ifdef 判断的是宏有没有定义

PDF 使用 #ifdef SelfDefine 选择手写分支,否则走 JSON 分支。它是编译时选择,一份生成的程序不会因为收到不同正文就自动切换分支。

cpp 复制代码
#define SelfDefine 0
#ifdef SelfDefine
// 仍然会进入这里,因为宏已定义;值为 0 不改变 #ifdef 的判断。
#endif

需要根据数值选择时可以用 #if SelfDefine,并明确默认配置。若同一个头文件在不同编译单元用不同宏配置产生了不同定义,还会带来定义不一致的问题,不能只保证"每个源文件单独能编译"。

本篇直接实现手写版本,避免为了切换格式额外依赖 JsonCpp。若以后保留双分支,构建配置必须在项目内统一。

8.2 两端格式一致,比两端语言一致更重要

客户端发送 7 + 3,服务器却按 JSON 解析,不会因为它们都使用 C++ 就自动兼容。即使外层都能正确 Decode,正文仍然不符合接收端的格式。

PDF 注释提到可增加 protocol_code。这是一个扩展方向:让双方先识别格式或版本,再选择相应解析器。但必须定义标识放在哪里、是否计入长度、有哪些合法值,以及未知值怎样处理。只在注释里写一个编号,不能实现运行时协议协商。

8.3 JSON 中的 char 与文本中的 char 不是同一层问题

手写表达式里,std::string + char 会追加字符。PDF 的 JSON 分支则把 _oper 直接赋给 Json::Value,再通过 asInt() 读回字符值,是另一套编码约定。

上一篇博客已经把 JSON 运算符明确为单字节字符串。这里不要只改接收端或发送端的一半:从"数值表示字符"改成"字符串表示字符"是正文契约变化,需要两端同步。文本版 + 是运算符字段的一个 ASCII 字节,无需经过 JSON 类型系统。

8.4 Factory 创建对象,不解析报文

PDF 的 Factory 通过 make_shared 建立 Request 与 Response,便于把创建入口集中起来。它不负责字段检查,也不会让一个非法报文自动变合法。

对于当前这类少量字段的值对象,直接写 Request req{7, 3, '+'}; 已经清楚。需要多种实现、统一初始化过程或共享所有权时,再考虑工厂与智能指针。不要把"创建了对象"与"恢复出了合法请求"混为一件事。


九、完整演示与编译运行

9.1 文件组织

text 复制代码
code/
├── TextProtocol.hpp  # 正文序列化与字段验证
├── Frame.hpp         # 长度前缀与增量解帧
├── demo.cpp          # 请求往返、逐字节输入、响应往返
└── protocol_test.cpp # 边界与往返测试

下面是 demo.cpp,与两个头文件放在同一目录即可编译。演示按字节追加输入,为的是确定性地观察拆包过程,不表示真实 TCP 每次一定返回一个字节。

cpp 复制代码
#include "Frame.hpp"
#include "TextProtocol.hpp"
#include <iostream>

int main() {
    textproto::Request source{7, 3, '+'};
    std::string body;
    if (!textproto::Serialize(source, body)) return 1;
    const std::string packet = wire::Encode(body);
    std::cout << "body: " << body << '\n';
    std::cout << "frame bytes: " << packet.size() << '\n';

    // Deterministic byte-by-byte input, independent of TCP read boundaries.
    std::string buffer, message;
    for (char c : packet) {
        buffer.push_back(c);
        for (;;) {
            const auto state = wire::Decode(buffer, message);
            if (state == wire::State::NeedMore) break;
            if (state == wire::State::Invalid) return 2;
            textproto::Request restored;
            if (!textproto::Deserialize(message, restored)) return 3;
            std::cout << "restored: " << restored.x << ' '
                      << restored.op << ' ' << restored.y << '\n';
        }
    }
    if (!buffer.empty()) return 4;

    // A response supplied by the business layer; no arithmetic here.
    textproto::Response response{10, 0}, restored_response;
    if (!textproto::Serialize(response, body) ||
        !textproto::Deserialize(body, restored_response)) return 5;
    std::cout << "response: " << restored_response.result << ' '
              << restored_response.code << '\n';
}

9.2 编译命令与预期输出

在 Linux、WSL 或支持 C++17 整数 from_chars 的编译环境中执行:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror -O2 demo.cpp -o demo
./demo

预期输出:

text 复制代码
body: 7 + 3
frame bytes: 10
restored: 7 + 3
response: 10 0

演示中的 Response 由代码直接提供,代表业务层已经给出结果;它没有悄悄在序列化函数中计算 7+3。要接回上一讲的服务端,应在恢复 Request 后调用业务计算器,再序列化 Response。

9.3 接回 Socket 程序时保持四步顺序

text 复制代码
收到字节 → Decode 取出完整正文 → Deserialize 恢复请求 → 业务处理
业务响应 → Serialize 生成正文 → Encode 封成帧 → 完整发送

完整发送仍需处理部分写入;网络读取仍需处理返回值和连接生命周期。换了正文格式,不会使这些 Socket 层问题消失。这里关注协议模块,网络收发可复用上一篇已介绍的实现。


十、用边界测试检查协议是否真的可靠

10.1 正常往返只是起点

7 + 3 成功,只能说明最顺利的路径可用。测试还要回答:负数是否正确?边界值有没有溢出?失败之后输出对象是否被部分更新?下一帧残片是否还在?

随文 protocol_test.cpp 直接调用正文中的 C++ 实现,包含以下检查:

层次 输入或场景 预期
正常请求 7 + 3、-12 - -3、int32 两端边界 恢复准确字段
数字规则 007 + -0 允许,重写为规范输出
数字错误 +7 + 3、7 + 3abc、超范围整数 拒绝
分隔错误 多空格、首尾空格、缺失空格、制表符 拒绝
运算符 7 ** 3、UTF-8 全角加号、NUL 拒绝
业务边界 7 ^ 3 结构通过,是否支持由业务层决定
响应 0 1、负 code、额外字段 成功恢复或按规则拒绝
失败原子性 第二个数字错误、运算符错误 原对象保持原值
外层帧 每个切分位置、逐字节、多帧、残片 精确消费与保留
帧损坏 非法长度、超大声明、错误尾部 返回 Invalid

另外使用固定种子的随机对象做往返验证,检查序列化与反序列化在大量取值上是否一致。它不能证明所有输入都正确,但能补充手选样例遗漏的数值组合。

10.2 执行测试

bash 复制代码
g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror -O2 protocol_test.cpp -o protocol_test
./protocol_test

测试通过时输出 all protocol checks passed。具体运行环境、断言数量、诊断参数与验证范围记录在 code/验证记录.md,不把预期输出冒充实测结果。

10.3 怎样区分"读得少"与"格式错"

外层只收到一半,应是 NeedMore;完整正文中的最后一个字段是 3abc,应是 Deserialize 失败。这两个情形处在不同层,也需要不同恢复方式。

应用如果把二者合成一个"解析失败",很容易要么过早断开正常的慢连接,要么在已经损坏的消息上无限等待。分层最大的价值之一,就是让每个错误有准确的位置与后续动作。


十一、从教学文本协议走向更复杂的数据

11.1 字段本身含空格怎么办

当前协议适合少量整数和单字节符号。若新增文件路径、聊天内容或用户姓名,字段本身可能包含空格,直接使用相同分隔规则就会有歧义。

可选做法包括为字段规定转义语法、为每个字段单独给出长度,或采用 JSON 等现成的结构化编码。选定方案后还需要处理转义错误、长度限制与嵌套深度,而不是不断追加几次 find。

11.2 增加字段意味着修改协议

把 x op y 改成 id x op y,旧接收端不会自然知道第一个数字已经变成请求编号。本文严格拒绝额外字段,可以较早发现这种不一致;要兼容不同版本,应显式设计版本或消息类型。

更换整数范围也是协议变化。字段类型、文本解析结果与业务计算范围要一起调整,不能只把某个成员从 int32 改成 int64,然后假定旧端仍能处理全部新值。

11.3 手写一次的真正收获

PDF 开篇强调,亲手实现是为了体会过程,实际工作已经有许多成熟方案。这段练习最有价值的部分,是学会提出具体问题:字段从哪里开始?长度算的是哪一层?数字是否完整消费?失败时对象处于什么状态?缓存里的下一条消息由谁处理?

以后即使用 JSON、Protobuf 或其他库,这些问题仍然存在,只是其中一些语法工作由库承担。应用仍需定义字段语义、业务约束与兼容策略。


总结

手写序列化的主线很清楚:先定义格式,再按格式生成字节;反序列化按同一契约定位字段,验证语法和范围,最后恢复对象。

本篇把正文处理与外层解帧分开:Decode 确认消息完整,Deserialize 确认字段合法,业务层判断能否执行。三者各自承担一部分责任,才能把"收到了一段字符串"变成"得到了一条可以处理的请求"。

从 7 + 3 这样的小例子出发,把下标、字节数、失败路径与对象状态逐一弄清楚,再回头看成熟序列化库,就更容易理解它替我们完成了哪些工作,又有哪些规则仍需要自己约定。


🌄 愿每一次阅读、编译与调试,都能让我们离清晰可靠的代码更近一步。如果这篇记录对你有帮助,欢迎点赞、收藏,也欢迎交流你的实现与问题。

相关推荐
润滑卫士1 小时前
油液多参数融合故障诊断:数据级融合与决策级融合方法
运维
李纲明2 小时前
WordPress 后台登录马上被踢回登录页:Cookie 域、HTTPS、盐值和页面缓存怎么查
运维·缓存·https·php·wordpress·cookie
todoitbo2 小时前
在 Ubuntu ARM64 上运行 Q1 V3.0 MuJoCo 模型:加载、步进和单执行器响应
linux·运维·ubuntu·ai·机器人·仿真教程·启元
AR-26710-3 小时前
Linux Day26——传输层与三次握手
linux·运维
POMAGTOR3 小时前
【磁吸式连接器选型工程解析:结构原理、应用场景与选型可靠性要点】
网络·嵌入式硬件
隔窗听雨眠4 小时前
PostgreSQL单表从Windows迁移到Linux:差异剖析、方案对比与全流程实战
linux·windows·postgresql
xinruoqianqiu4 小时前
记录Linux
linux
额额额对了4 小时前
嵌入式 Linux 实战:i.MX6ULL 上的 CAN 通信与摄像头采集
linux·运维·服务器
牛马鸡niumasi4 小时前
【Linux】进程替换
linux