先问你一个很多刚接触网络编程的人都会愣住的问题:我们写程序时,数据是躺在 int x、char op 这种"变量"里的;可是数据要从 A 机器的进程,经过网线,送到 B 机器的进程里,它在网线上到底是什么形态?
答案是:只有一串排列好的字节。字节是这个世界上唯一能被 TCP/IP 传输的东西。int x 在内存里是 4 个字节,std::string 是一堆字节外加一个长度,结构体是一块连续内存——所有这些,最终都必须"摊平"成一个字节序列,才能塞进 socket 发出去。这个"把内存里的对象摊平成字节串"的动作,就叫序列化;反过来"把字节串还原成对象"的动作,叫反序列化。
本篇文章是《Linux 网络编程》里"协议设计与序列化"的加餐课。市面上序列化方案已经多如牛毛(Json、Protobuf、Mesgpack……),但我们偏要手写一遍。为什么?因为只有亲手写过一次序列化和反序列化,你才能真的理解那些成熟方案到底在替你解决什么问题,才能在被它们出现各种莫名 bug 时一眼看出根源。课件原仓库在 gitee.com/whb-helloworld/linux-plus-meal,这一节我们就把它的 serialize-and-deserialize 掰开揉碎。
读这篇文章之前,你应该已经会的
- socket 基础:
send/recv一次能发/收一段字节,知道 TCP 是"字节流"协议就够 90% 的推理了。 - C++ 基础:
std::string、std::shared_ptr、std::stoi、命名空间、#ifdef条件编译。这些在我们的 C++ 入门系列里都讲过了,需要时可以回头翻。 - 一点点结构体的概念:你知道
struct里能装几个不同类型的成员就行——因为序列化的核心工作中,有一个就是"结构化数据 ↔ 字符串"的转换。
如果你是带着这些走进来的,下面每一步你都能看懂为什么这么写。
为什么我们要手写一次序列化
我先给结论,再拆开讲:序列化与反序列化,本质就是对字符串(或者说字节串)的处理。做这件手写工作,不是为了让你在生产环境里无视 Json 和 Protobuf,而是为了让你"体会一遍过程"——用课件里那句话讲,"实际情况肯定会更复杂,但序列化与反序列化已经有很多现成的解决方案了。我们有了一次手写的经历就够了。"
为什么"经历一次就够了"这么重要?因为序列化的难点从来不在"怎么把 2 存成 '2'",而在于一串隐藏得很深、不亲手撞一次根本记不住的设计决策,我先把它们列成一张全景图,后面一节一节地填肉:
| 难点 | 一句话描述 | 手写时你会意识到什么 |
|---|---|---|
| 报文边界 | TCP 是字节流,没有天然的"一条消息"切割点 | 你会亲自体会"粘包"和"半包" |
| 字段编解码 | 结构体的多个字段怎么变成一个字符串 | 你会亲手写"解析/拼接"的分隔逻辑 |
| 字节序 | 同一个整数两头机器读出来可能相反 | 你会明白网络字节序为什么是大端 |
| 变长 vs 定长 | 数字、字符串在字节串里怎么表示最适合 | 你会对比"分隔符"和"长度前缀"两种思路 |
| 编解码对称 | 序列化怎么写的,反序列化必须严格倒着读 | 你会害怕"写了一边忘另一边"的 bug |
| 健壮性 | 拿到任何恶意的、残缺的、超长的数据都不能崩 | 你会理解"绝不信任网络"这句话 |
这六行,就是本课真正的知识点骨架。下面我们从最让人头疼的"报文边界"开工,因为它是网络编程里一上来就会撞上的真问题。
先认识"报文边界":粘包与半包
先讲一个非常真实、让无数新手抓狂的现象。
TCP 是一个字节流服务。这句话是理解一切的开端:你调用了一次 send(sockfd, buf, 100, 0) 打算发 100 个字节,你"以为"对方 recv 一次也会恰好收到这 100 个字节。不是的。TCP 自己会决定怎么切这堆字节:可能在发送端把两次 send 的数据拼在一起发出去,也可能因为网络拥塞把一个 recv 拆成几次才到达。接收端 recv 到的数据,和你发送端一次 send 的数据,在"边界"上没有一丁点对应关系。
由此引出两个我们必须应对的术语:
- 粘包:接收端一次
recv读到了不属于同一个逻辑消息的多个数据段。比如客户端连续发了两次send(两条消息),服务端一次recv全收进来了,两条消息挤在一个缓冲区里,这就是"粘"在一起了。 - 半包:接收端一次
recv只收到了某一条逻辑消息的一部分。比如一条消息本该是 50 字节,结果这次recv只到了 30 字节,还差 20 字节在路上的 TCP 缓冲区里排队,这条消息就只来了"一半"。
有一点必须澄清:粘包、半包并不是网络"出错"了,而是 TCP 字节流的天然属性,是水流没有"结"的必然结果。真正的责任在于应用层——应用层必须自己定一个"协议",规定清楚"一条消息从哪儿开始、到哪儿结束"。这就是报文边界(或者说消息定界)问题。
约定边界的三种经典思路
要在一个字节流里切出"一条消息",常用的手段有三种:
**思路一:定长协议。**所有消息都固定为同一个长度,比如一律 16 字节。接收方每收 16 字节就当成一条完整消息。优点是极其简单、没有任何解析成本;缺点是糟糕透顶,因为消息长度几乎不可能都凑成整数,要么浪费空间填充,要么塞不下。
**思路二:分隔符协议。**用某个特殊字符(比如 \r\n,或者空行)来"隔开"每条消息,就像电话号码用 - 分隔区号一样。优点是简单直观,广泛应用于 HTTP(以空行分隔请求头和请求体)这类场景。缺点是:你的消息正文里一旦出现了这个分隔符本身,就会造成歧义——必须想办法转义(escape),非常麻烦。
思路三:长度前缀协议。这是本课的主角,也是绝大多数二进制协议(包括 Protobuf、HTTP Content-Length、Redis 协议)的思路:消息前面先带上一个"这条消息有多长"的数字,接收方先读这个数字,知道长度后,再精确地取这么多字节当作一条完整消息。可以叫它"自带长度"协议,也可叫 LTV(Length-Type-Value)的简化版。
课件采用的约定是这样一行协议文档(语法上它并不属于标准协议,是我们"约定"出来的):
"len\r\nmessage\r\n"
翻译成人话就是:一条消息由三部分组成——一个十进制的长度数字 len,跟一个换行 \r\n,然后是真正内容 message,最后再跟一个 \r\n 收尾。其中 \r\n 是分隔符,不属于报文(消息正文)的一部分,是我们双方约定出来的"格式装饰"。
思考题一:为什么长度前缀协议不需要担心"消息正文里碰到分隔符"这个问题?
详解:不要把本课的长度前缀和"分隔符协议"混为一谈。在长度前缀协议里,\r\n 只出现在两个固定位置——紧跟在长度数字之后、紧跟在正文之后——我们不是用 \r\n 去遍历正文、找"下一个分隔符在哪儿"来切消息的,而是先用前面的 len 精确算出正文占多少字节,然后按字节数去取正文。正文里就算真的出现了 \r\n 这几个字符,它也只是被当作文正的普通字节,我们取正文时按"长度"取,根本不关心正文内容里有啥。所以不会被正文里的偶发字符干扰到切消息。而真正的分隔符协议(思路二)因为是靠"找分隔符"来定界,正文一旦出现该字符就必须转义,那才是它的大坑。这两者的差别,正是"凭什么我们要在消息头里放一个长度"的根本原因。
手写 Encode 与 Decode:长度前缀协议实战
边界问题讲清了,现在动手写代码。课件把"把我的一条消息变成带边界的字节串"和"从可能残留着碎片的缓冲区里精确剥出一条消息"这两个动作,分别封装成了 Encode 和 Decode 两个函数。我把它们贴出来,逐行讲透。
#include <string> // std::string
#include <iostream> // std::cout 调试;编译目标是链接时用到
// 板块常量:整个 Protocol 命名空间共用的"协议约定"
namespace Protocol
{
// 分隔单个字段用的分隔符(后面 Request/Response 的字段拆解会用到)
const std::string ProtSep = " ";
// 分隔"长度数字/正文"、标记"一条消息结束"用的换行
const std::string LineBreakSep = "\r\n";
// 编码:把一段正文 message 包成一个完整的、自带边界的"包" package
// 约定为 "长度\r\n正文\r\n"
std::string Encode(const std::string &message)
{
// 1) 先把正文长度转成十进制字符串,比如 5 变成 "5"
std::string len = std::to_string(message.size());
// 2) 按约定拼装:长度 + \r\n + 正文 + \r\n
std::string package = len + LineBreakSep + message + LineBreakSep;
return package; // 这就是一个可以在网络上发的"完整包"
}
}这个 Encode 相当直观:算长度、拼格式、返回。真正有技术含量的是 Decode,它的输入是"一个缓冲区",里面可能只有半条消息(半包),也可能装着不止一条消息(粘包),还可能混着半条+整条。它要做的事是:判断缓冲区里现在够不够一条完整消息;如果够,就精确剥出一条,并把剥掉的字节从缓冲区前部清掉,剩下的继续留待下次处理。
// 解码:从缓冲区 package 里尝试剥出"恰好一条"完整消息到 message
// 返回值 true 表示成功剥出一条;false 表示字节还不够形成完整一条
bool Decode(std::string &package, std::string *message)
{
// 步骤一:找第一个 \r\n,它前面的那个数字就是"这条消息的长度"
auto pos = package.find(LineBreakSep);
if (pos == std::string::npos)
return false; // 连 \r\n 都没找到:说明长度数字都还没到齐,属于"半包",先等数据
// 取到长度数字对应的字符串,比如 "5"
std::string lens = package.substr(0, pos);
// 把长度字符串转成真正的数字。注意:这里可能抛异常(后面健壮性一节会讲)
int messagelen = std::stoi(lens);
// 计算这一条完整消息总共占多少字节:
// = 长度数字的字节数(lens.size())
// + 第一个 \r\n(LineBreakSep.size())
// + 真正的正文(messagelen)
// + 收尾的 \r\n(LineBreakSep.size())
int total = lens.size() + messagelen + 2 * LineBreakSep.size();
// 步骤二:判断缓冲区里字节够不够 total。
// 如果 package.size() < total,说明正文还有一部分没到,也是"半包",
// 此时绝不能被砍掉,返回 false,等后续数据拼上来。
if (package.size() < total)
return false;
// 到这里,可以确定:缓冲区里至少有一个完整消息了。
// 正文位置从 pos + \r\n 之后开始,长度为 messagelen。
*message = package.substr(pos + LineBreakSep.size(), messagelen);
// 把已经消费掉的这条完整消息(共 total 字节)从缓冲区前部删掉,
// 剩下的(可能是另一条消息,或下一条消息的半包)留在 package 里给下次用。
package.erase(0, total);
return true; // 成功剥出一条
}
}为什么 Decode 要"剥出一条"而不是"一次全解完"
你可能会想:缓冲区里明明可能有两条完整消息,为什么 Decode 一次只剥一条?
这正是为了对抗"粘包"。真实程序里,服务端一个 recv 拿到一坨字节后,往往后面还不断有新的数据从网络涌进来。最自然、最不易错的用法是:每来一批数据就追加进 package,然后在一个 while 循环里反复调用 Decode,剥出一条就处理一条,直到返回 false(说明剥不出来了、剩余是不完整半包)为止。下一次再收到数据,又追加、再循环。这样一个循环一个循环,就把散落到各次 recv 的字节,在应用层重新"拼回"了一条条完整消息。
我们稍后在"完整可运行代码"一节会写一个这样的粘包/半包演示,你一看那个循环就全通了。
原版 Decode 的两个隐患,我们先记住
课件这份是教学演示,为了讲清楚"长度前缀"的思想,它刻意写得简洁,也因此埋了两个隐患。你现在先有个印象,等第 10 节"协议的健壮性"我们专门修它们:
std::stoi(lens)在长度字符串不是合法数字时(恶意报文可能发来一串乱码当作长度)会直接抛异常,程序可能崩溃;- 长度字段理论上可以是任意大,如果对方告诉你说"这条消息长 20 亿字节",我们的
Decode也会傻等——不做上限检查的话,缓冲区就永远填不满,形成变相的拒绝服务。
这两个坑,正是"协议健壮性"要解决的事情。先记住,回头处理。
思考题二:为什么 Decode 计算 total 时要加上 2 * LineBreakSep.size()?只加一次 \r\n 不行吗?
详解:观察协议约定 "len\r\nmessage\r\n",可以数出两个 \r\n:第一个紧跟在长度数字后面(把"长度"和"正文"隔开),第二个在正文结束后(标记整条消息结束)。total 是所有字节加起来的总长,一个都不能少:长度数字、第一个 \r\n、正文、第二个 \r\n。如果只加一个 \r\n 的 2 字节,total 就会比真实总长少 2 字节,导致 package.size() < total 的判断出错,可能把"还没到齐的半包"误判成"已经齐了",从而剥出一条不完整的消息;或者反过来,你剥出来的 package.erase(0, total) 会删少 2 字节,尾巴上残留半个 \r\n,把后面的边界全部带乱。所以这是个必须精确的算术:两个换行,一个是"头标结束",一个是"尾标结束",一个都不能漏。
StructSerializer:结构化数据的字段编解码
边界问题解决后,我们迎来第二个核心问题:结构化数据 ↔ 字节串。标题里的 StructSerializer,是我给"把一个 struct / 类对象序列化成字节串"在本文里的统称——它就是那个"专门负责把结构化对象摊平成字符串、再复原"的组件。这一节我们就亲手写一个。
回想我们课程的经典例子:客户端要往服务端发一个算术请求,它包含三个字段——两个操作数 x、y,一个运算符 op(可以是 + - * / %)。服务端算完后要回一个应答,包含"运算结果 result"和"状态码 code"两个字段。
字段本身,在 C++ 里是一个类(Request / Response)。要让它们能上网络,就必须给它们各写一对方法:Serialize(序列化:对象 → 字符串) 和 Deserialize(反序列化:字符串 → 对象)。我们把这一整套"把结构化对象变成字符串、再从字符串变回来"的操作,亲切地叫做 StructSerializer 思想的落地。
Request:一个算术请求的两种字段写法
先给它定义字段:
class Request
{
public:
Request() : _data_x(0), _data_y(0), _oper(0) {} // 默认构造:清零
Request(int x, int y, char op)
: _data_x(x), _data_y(y), _oper(op) {} // 带参构造
int GetX() { return _data_x; } // 取第一个操作数
int GetY() { return _data_y; } // 取第二个操作数
char GetOper() { return _oper; } // 取运算符
private:
int _data_x; // 第一个操作数
int _data_y; // 第二个操作数
char _oper; // 运算符 + - * / %
};序列化(对象 → 字符串)。我们自定义一套报文格式,用 ProtSep = " "(单个空格)把三个字段串起来,得到形如 "3 + 5" 的字符串:
// 序列化:把自己的字段拼成一个字符串,例如 "3 + 5"
bool Serialize(std::string *out)
{
// 用 to_string 把数字变成字符串,用 ProtSep(空格) 分隔各字段
*out = std::to_string(_data_x) + ProtSep // "3"
+ _oper + ProtSep // "+"
+ std::to_string(_data_y); // "5"
// 拼出来就是 "3 + 5"
return true;
}反序列化(字符串 → 对象),这是关键,也是最容易写错的地方。要把 "3 + 5" 解析回去,我们用一个"找特点"的技巧——find 找第一个空格、rfind 找最后一个空格:
// 反序列化:把形如 "x op y" 的字符串解析回自己的三个字段
bool Deserialize(std::string &in) // 入参:形如 "3 + 5"
{
// 1) 找第一个空格,它的左边就是第一个操作数
auto left = in.find(ProtSep); // 定位到 "3" 之后的空格
if (left == std::string::npos)
return false; // 连空格都没有:格式非法
// 2) 找最后一个空格,它的右边就是第二个操作数
auto right = in.rfind(ProtSep); // 定位到 "+" 之后的空格
if (right == std::string::npos)
return false; // 理论不会出现,防御性检查
// 3) 从左边截取第一个操作数,从右边截取第二个操作数
_data_x = std::stoi(in.substr(0, left)); // "3"
_data_y = std::stoi(in.substr(right + ProtSep.size())); // "5"
// 注意:right + ProtSep.size() 是"跳到最后一个空格之后"的位置
// 也就是第二个操作数的起点
// 4) 中间夹着的就是运算符,长度必须是 1
std::string oper = in.substr(left + ProtSep.size(), // 从第一个空格之后起
right - (left + ProtSep.size())); // 到最后一个空格前
if (oper.size() != 1)
return false; // 运算符不是单字符:格式非法
_oper = oper[0]; // 取出那个字符
return true;
}
};为什么要同时用 find 和 rfind 两头夹?因为格式是 x op y,三段的"边界"正好是中间那两个空格:第一个空格之前的左段是 x,最后一个空格之后的右段是 y,两空格之间夹的是 op。用 find 拿左边界、rfind 拿右边界,一前一后就把三段干干净净地切出来了。就算第一个操作数是负数(比如 "-3 + 5"),前缀的负号不会影响"找空格",照样正确。
Response:一个算术应答的序列化
应答对象简单得多,只有 result 和 code 两个整数,中间也就一个空格,直接用一次 find 就能切开:
class Response
{
public:
Response() : _result(0), _code(0) {} // 默认构造
Response(int result, int code) : _result(result), _code(code) {}
void SetResult(int res) { _result = res; } // 设置运算结果
void SetCode(int code) { _code = code; } // 设置状态码
int GetResult() { return _result; } // 取结果
int GetCode() { return _code; } // 取状态码
// 序列化:拼成 "_result _code",例如 "8 0"
bool Serialize(std::string *out)
{
*out = std::to_string(_result) + ProtSep + std::to_string(_code);
return true;
}
// 反序列化:用第一个空格切开前后两段
bool Deserialize(std::string &in) // 入参:形如 "8 0"
{
auto pos = in.find(ProtSep); // 找空格
if (pos == std::string::npos)
return false; // 格式非法
_result = std::stoi(in.substr(0, pos)); // "8"
_code = std::stoi(in.substr(pos + ProtSep.size())); // "0"
return true;
}
private:
int _result; // 运算结果
int _code; // 运算状态(0 表示成功,非 0 表示出错码)
};到这里,StructSerializer 的核心思想你已经亲手完成了:给每个结构化对象配一对 Serialize / Deserialize,各字段要么用分隔符串起来、要么按固定位置切开。 这就是"结构化数据的序列化和反序列化"的全部秘密。
一个隐蔽又常见的坑:负数与分隔符
请你现在就注意一个非常值得警惕的问题。我们的字段协议用的是"空格分隔、十进制文本"。十进制文本表示 int,有一个先天的边界情形——负数。假设我要发送 _data_x = -3,那么序列化结果是 "-3 + 5",反序列化时 find 找空格,截取 "-3",stoi 解析成 -3,一路都对,很好。
但请你跟着我再推一个更阴间的场景:字段本身是"可变长、且内容里包含分隔符"——比如一个 std::string name,里面存了一句 "hello world"(带空格)。如果我还用空格做分隔符去拼 "张三 hello world 18",反序列化时拿 find 切第一段,就根本不知道该在哪个空格切了。这是文本分隔方案的死穴:当字段内容可能撞上你选定的分隔符时,协议就开始崩。
这个坑逼我们认真思考一个更通用的思路——字段要么定长,要么必须自带长度。这正是下一节"变长与定长字段"的内容。你先把"字段内容不能撞分隔符"这条记在心里,它会反复出现在各种协议的踩坑现场。
思考题三:为什么 Request::Deserialize 里要用 find(首个空格)配 rfind(末个空格)这个"夹逼"写法,而不是自己也直接用一个 find 从一个空格切开?
详解:因为 Request 的报文格式是三段 x op y,中间有两个空格;而 Response 是两段 _result _code,只有一个空格。对于三段的格式,如果你只 find 一个空格就当分界,那 _oper 这个运算符要么没有地方放(只有 left 空格、后面全是 y),要么会和 y 混在一起。所以必须"夹"出中间那段唯一的、长度为 1 的运算符:第一个空格界定 x 的右边界、最后一个空格界定 y 的左边界,两者之间(left + ProtSep.size() 到 right)正好是运算符。这跟"找就找最关键的两个关节"是一回事——帧的起点和终点才是硬边界,中间的肉靠两端夹出来。
字节序:同一个整数的另一种长相
许多手写二进制协议的人,第一次"翻车"就栽在字节序上。它是一个纯网络编程隐藏很深、但必须交代清楚的知识点。严格说,它是"二进制定长整数字段"专属的问题,我们前两节用的"十进制文本"表示法其实天然绕开了它——但我必须在这里把它讲透,否则你一旦把字段改成二进制整型(比如为了省字节/更快),就会在不经意间踩进泥潭。
字节序(byte order,也叫端序、端字节序)指的是:一个超过 1 字节的数据类型(比如 int 占 4 字节),它的各字节在内存地址上的排列顺序,有"谁在前"之分。
-
大端(big-endian):把"高字节"(更重要的字节)放在低地址。可以理解成"大端先放高位",数字书写时的直觉顺序。
-
小端(little-endian):把"低字节"放在低地址。"小端先放低位",和人的书写习惯相反。
打个比方,"房间号在门牌最左边"是大端,"最左边是门牌号里最低的一位"是小端。你的 x86/ARM 电脑内存里绝大多数是小端;而 TCP/IP 网络字节序却规定为"大端"——这是互联网协议为了不同厂商的机器都能正确对读而做的统一约定。
实际后果是这样的:同样的整数 0x01020304,在小端机器内存里排成 04 03 02 01,在大端机器里排成 01 02 03 04。如果你的程序在 x86(小端)上用二进制的原始 4 字节把 int 直接写到网络里发出去,对面一台大端机器按它的方式读这 4 字节,读出来的数会和你想发的完全不同。这就是"同一个整数,另一台机器看到的是另一种长相"。
所以,二进制协议在发多字节数值之前,必须明文规定好字节序(一般统一成网络字节序/大端),并在发送端转换、接收端还原。Linux 提供了一组烂熟于心的转换函数:
htonl:Host TO Network Long(32 位,把主机字节序转成网络字节序);htons:Host TO Network Short(16 位);ntohl:Network TO Host Long(网络字节序转回主机字节序,接收端用);ntohs:Network TO Host Short。
在二进制定长字段协议里,标准姿势是"写的时候 htonl,读的时候 ntohl":
#include <arpa/inet.h> // 提供 htonl / ntohl
#include <cstdio>
int main()
{
// 发送方:以主机(小端)视角构造这个整数
int value = 0x01020304;
// 转成网络字节序(大端)后再发到网络
int net_value = htonl(value);
std::printf("主机序 0x%08X -> 网络序 0x%08X\n", value, net_value);
// 接收方:收到网络序字节后,转回主机序使用
int recv = ntohl(net_value);
std::printf("还原回主机的值 = 0x%08X\n", recv);
return 0;
}但好消息是:文本协议绕开了这个坑
你可能会追问:那我们这节课手写的 "len\r\nmessage\r\n" 和 "3 + 5" 这种,岂不是也搞字节序?答案是——不用。因为这节课我们用的是"十进制文本"(std::to_string / std::stoi),数字是以 ASCII 字符 '3'、'5' 的形式一个字节一个字节发送的,字符本身没有端序问题,任何机器读 '3' 都必然得到字符 '3'。十进制文本用"字节数多、体积大"的代价,换来了"彻底无视主机字节序差异"的省心。
而一旦你要贪图省字节、把字段改成二进制定长整型,字节序问题就立刻跳出来咬你——这就是我为什么必须提前给你打好这针。手写二进制协议时,"统一字节序(通常统一为网络字节序大端)+发送端 htonl + 接收端 ntohl"是个绕不开的铁律。
思考题四:设计一个二进制定长协议时,为什么要显式规定字节序?如果两端一个是 x86(小端)、一个是某大端架构,不统一会怎样?
详解:因为"字节序"不由程序作者说了算,而是由硬件决定的。x86/多数 ARM 默认小端,一些 MIPS/网络设备用大端。如果协议文档没规定字节序,程序员们在各自的机器上"用自己的字节序直接写 4 字节",两边都觉得自己没错,但到网上对不上——A 端小端写的 04 03 02 01,B 端大端读成顺序反转,数值面目全非,而且这种 bug 只在跨架构环境暴露,单机自测永远发现不了,最阴。解决办法是:协议统一约成"网络字节序(大端)",让每个实现端的发送/接收都套一层 htonl/ntohl 做制度性保障。这跟时区一样——各地方(主机)用各自本地时间,但约定一个"UTC"作为交换标准,交换时才换算。
变长字段与定长字段:两种表示法
前两节我们已经非正式地接触到了"变长"和"定长"这两个概念。这一节它们正式登场,因为它决定了一个协议怎么表示各个字段,是整个字段编解码的底层框架。
-
定长字段(fixed-length):不管值多大,都占用固定的字节数。比如
int一定占 4 字节、char一定占 1 字节。接收端不用看任何辅助信息,直接劈开对应长度就能取走。 -
变长字段(variable-length):字段内容的长度不是固定的。比如一个
std::string(可能长短不一),或者一个"不知道会有多大"的数。变长字段在字节串里必须自己告诉自己有多长(或依赖别的定界手段),否则接收端不知道它到哪儿结束。
变长字段的两种常见实现
做法 A:前置长度。 先写一个数字(这个数字本身通常是定长或带个长度前缀的),告诉接收端"接下来的字段有 N 个字节",然后再写内容。这跟我们整套协议的思路一致,也最常用。字符串序列化的标准姿势就是 [长度][字符内容]。
做法 B:依赖分隔或定界。 用特殊字符标识结束(如 C 字符串以 '\0' 结尾)。缺点就是前面反复警告过的"内容撞分隔符"问题。
而定长字段因为没有歧义、解析最快,常用于那些长度天生固定的东西(整数、布尔、固定 4 字节的 ID)。代价是一旦遇到"长度其实会变"的内容,你就不能硬定长,否则要么塞不下、要么浪费。
一个定长的大坑:结构体对齐(padding)
写二进制定长协议时,最防不胜防的一个坑是 struct 的内存对齐(padding / 填充)。看这个结构体:
struct A {
char c; // 1 字节
int i; // 4 字节
};你猜 sizeof(A) 是多少?直觉上应该是 1 + 4 = 5。但实际通常是 8!为什么?因为编译器为了让 int i 的地址对齐到 4 的倍数以便一次性读入,会在 c 后面"悄悄塞"3 个填充字节(padding byte)。这些填充字节内容是不定的、不保证任何值。
于是,如果你图省事,直接 write(fd, &a, sizeof(a)) 把结构体 raw 字节发给对方,踩两个雷:其一,对面收方如果也按 sizeof 读,两边编译器的对齐规则一致才算没事;其二,这段 padding 字节里可能夹着脏数据,反序列化时你不但多读、还可能读到垃圾值,甚至泄露堆栈内容(历史原因它是安全大忌)。这正是为什么手写二进制序列化从不建议"直接把 struct 整体 memcpy 发出去",而要一个个字段显式、规范地按协议顺序编解码的原因。
更稳妥、更专业的做法是显式控制布局:要么用 #pragma pack(push, 1) 关闭对齐(代价是可能降低访问速度、且依赖编译器扩展),要么干脆不用 raw struct,而是显式逐个字段序列化到字节缓冲并按协议规定顺序——我们本节和全文的写法都属于后者,这也是"手写一份可靠序列化器"的应有觉悟。
思考题五:既然变长字段那么麻烦,为什么不把所有字段都设计成定长,一劳永逸?
详解:定长能省事的前提是"每个字段的长度天生固定"。数据结构里真正"天生固定长度"的只有整数、短整型、布尔、固定 ID 这类;而字符串、变长数组、嵌套对象、一段文本,它们的长度本质是变化的。你要强行给一个本会写成 "hello world" 的字符串定个上限 64 字节,那么:短的会浪费 64 字节(网络带宽每一个字节都是钱),长的又存不下(报了错还得改协议)。所以现实协议都是"定长 + 变长"混合:定长整数独立发,变长内容一律"长度前缀 + 内容"。这不是为了炫技,而是因为数据天生就是这种形状,协议要忠实反映它。
编解码必须严格对称
这是全文最像"铁律"、却又最容易被新手打破的一条纪律。先给术语下一个精确的定义:
编解码对称(encoding/decoding symmetry)指的是:序列化(编码)时怎么把对象压成字节串,反序列化(解码)时就必须按完全相反的步骤、严格地倒着把它读回来——字段多少、先后顺序、每个字段的长度/终止方式,两边必须严丝合缝地一一对应。任何一处不对称,都会导致解析错位。
不对称的后果是灾难性的。举个最经典的例子:假设序列化时我们按 x → y → op 的顺序写(就像课件注释里那句 // _data_x _oper _data_y,实际顺序必须先定死),可反序列化时另一处代码却按 x → op → y 的顺序读。结果就是:对上一个字段、错位一个字段、再错位一个字段,直到整条消息语义全乱——更可怕的是它往往不报错,只是静默地算出完全错误的结果,让你查得头秃。
这就是为什么,业界有一个严苛的实践:"编码"和"解码"永远是一对必须一起维护、一起改、一起测试的函数,绝不各自为政。协议文档里白纸黑字写明每个字段的顺序与类型,代码里编码/解码紧挨着放并在注释里互相引用,都是为了让这枚硬币的两面永远咬合。
具体到 Request,我们的静态约定就是 _data_x _op _data_y 三个字段的原因:
- 序列化顺序:
_data_x(写 3)→_op(写 +)→_data_y(写 5); - 反序列化顺序:先取第一段当
x,再夹中间当op,再取最后一段当y。
这个顺序在 Serialize 和 Deserialize 两侧是"镜像对称"的:正着写什么、反着读什么。对称不是爱好,是协议能正常工作的前提。
思考题六:如果序列化按 x,y,op 写,反序列化却按 x,op,y 读,且三个值恰好是 x=1, y=2, op='+',你会得到什么错误结果?
详解:序列化产出 "1 + 2"。错误的反序列化若按"第一段→x、第二段→op、第三段→y"理解,就会把 1 给 x,把 + 解析进 op(op 本该是运算符,实际被第一个操作数占了),再把 2 误当 y、并试图把它当运算符取走。最终 x 可能还算对(1),但 y 会被赋成运算符的数值或解析失败,op 会被赋成 2 或报错——三个字段全部张冠李戴,而且整条消息的字节位置也可能因此偏移。这也从反面说明:顺序不定死、两侧不一致,序列化对象就变成了一锅随机数。 对称性不是"建议",是正确性的最低要求。
协议的健壮性:绝不信任网络来的数据
分界协议有了、字段编解码有了、字节序绕开了,现在来到一个专业网络程序员和非专业程序员分水岭的东西——协议健壮性(protocol robustness)。这一节专门补我们前面欠下的两个坑。
核心的指导思想就一句话:**网络上来的任何字节,都是不可信的。**因为网络那头的程序可能是坏 bug 的、可能是被黑的、可能是不同版本老代码混发的、也可能是恶意攻击者伪造的。你的解析代码必须做到:无论输入什么乱七八糟的字节,都不能崩溃,也不能无限挂起。
坑一:stoi 会抛异常
紧接前面的原版 Decode,std::stoi(lens) 这行,当 lens 不是"合法整数字符串"时会抛 std::invalid_argument(转换失败)或 std::out_of_range(数字超出 int 范围)。恶意/异常报文完全可能让长度字段变成 "abcd" 或一个天文数字,直接导致未捕获异常、程序崩溃。
修复手法:把数字转换包进 try/catch,解析失败就当"这条报文非法",安全地返回失败标记,而不是让异常炸掉整个进程。
坑二:长度字段不加上限,变成变相拒绝服务
我们改动后的 Decode 用 messagelen 去等待 total 那么长的数据。如果对方恶毒地说"这条消息长 2147483647 字节",我们的逻辑就会一直"天真地"等待——因为 package.size() < total 永远成立,缓冲区怎么也填不满,连接就被这样"吊着",什么都不干,这就是一种资源耗尽型拒绝服务。
修复手法:给长度设一个设备能承受的上限(比如 1MB),超过就直接判定非法丢弃。这是所有成熟协议(HTTP 的 Content-Length 也常常带上限、Protobuf 同样有单消息上限)都会做的事。
我把 Decode 的强化版贴出来,同时把"负数长度"、"空指针出参"这些边界也一网打尽:
bool Decode(std::string &package, std::string *message)
{
// 防御一:出参为空指针,直接失败
if (message == nullptr)
return false;
// 防御二:约定单条报文最大长度,防止恶意超大长度字段拖垮我们
const size_t MaxMessageLength = 1 * 1024 * 1024; // 1MB
// 防御三:找不到 \r\n,说明长度数字都没到齐,半包,等待
auto pos = package.find(LineBreakSep);
if (pos == std::string::npos)
return false;
std::string lens = package.substr(0, pos); // 长度数字文本
// 防御三:解析长度数字,失败或非法一律返回 false(绝不信任网络数据)
long messagelen = 0;
try {
messagelen = std::stol(lens); // 可能抛异常,必须接住
} catch (const std::exception & /*e*/) {
return false; // 长度字段非法:当垃圾报文丢弃
}
// 防御四:长度不能为负、不能超过上限
if (messagelen < 0 || (size_t)messagelen > MaxMessageLength)
return false;
// 计算这条完整消息总共多少字节(两个 \r\n 一个都不能少)
size_t total = lens.size()
+ LineBreakSep.size() // 长度数字后的 \r\n
+ (size_t)messagelen // 正文字节数
+ LineBreakSep.size(); // 正文后的 \r\n
// 防御五:缓冲区字节不够,半包,等待更多数据
if (package.size() < total)
return false;
// 剥出一条完整消息
*message = package.substr(pos + LineBreakSep.size(), (size_t)messagelen);
package.erase(0, total); // 消费掉这条
return true;
}注意这个健壮版 Decode 的基础结构没变,但每个可能"害人"的地方都被堵死了:空指针、非法长度文本、负长度、超大长度、半包。这就是把"绝不信任网络数据"落到代码里的样子。
思考题七:半包时 Decode 返回 false,那么缓冲区里已经收到的那几十个字节,要不要丢掉重来?
详解:绝对不能丢。这正是 Decode 返回 false 时什么都不删(没有 package.erase)的原因。那些字节是"这条完整消息的前半段",它们本身是合法数据,只是还没攒够。一旦你因为这次 false 就清空缓冲区,那前面收到的半条就被你亲手扔了,下一批数据来了也拼不成一条完整消息,整条消息的语义永远对不上。正确的做法是:false 就原样保留缓冲,等下一次新数据 append 进来再重新 Decode。这也验证了前面那句"每次处理一坨字节、循环剥完整包、剥不动就留着"的设计——半包不是错误,是待办的中间态。
引入成熟方案:Json 与 Protobuf 的取舍
讲到这里,你已经亲手把序列化的每一寸土地都踩过了。现在我们可以站在更高的位置,来看一眼为什么课件反复念叨"实际情况会复杂很多、而现成方案已有很多"。现成方案的代表,一个是轻量的 Json,一个是重量级的 Protobuf。
先用 Json 改写 Request / Response
Json(JavaScript Object Notation,对象表示法)是一种简单文本格式,用花括号、冒号、方括号把有名字的字段组织起来,人类可读、调试友好。课件里用了一个 C++ 库 jsoncpp(Linux 下 sudo apt install libjsoncpp-dev 即可安装,头文件在 <jsoncpp/json/json.h>)。用 Json 改写我们的两个对象的序列化:
#include <jsoncpp/json/json.h> // Jsoncpp 的 Json::Value 及相关读写器
#include <string> // std::string
#include <memory> // std::unique_ptr
#include <sstream> // std::ostringstream / std::istringstream
// Request 的 Json 版序列化:把三个字段装进一个 Json 对象
bool RequestToJson(int x, int y, char op, std::string *out)
{
Json::Value root; // 一个万能容器,能放键值对
root["datax"] = x; // 存成有名字的字段
root["datay"] = y;
root["oper"] = op; // 注意:char 会被当作整数存(见下)
// 用 StreamWriterBuilder 生成紧凑的 Json 文本(新版本库推荐用法)
Json::StreamWriterBuilder writer;
std::unique_ptr<Json::StreamWriter> jw(writer.newStreamWriter());
std::ostringstream oss; // 需要一个字符串缓冲当输出流
jw->write(root, &oss); // 写出 Json 文本
*out = oss.str(); // 形如: {"datax":3,"datay":5,"oper":43}
return true;
}
// Request 的 Json 版反序列化:从 Json 文本还原字段
bool JsonToRequest(const std::string &in, int *x, int *y, char *op)
{
Json::Value root;
Json::CharReaderBuilder reader; // 新版本库推荐的 Json 读取方式
std::string errs;
std::istringstream iss(in); // 把字符串包成输入流
bool ok = Json::parseFromStream(reader, iss, &root, &errs);
if (!ok)
return false; // Json 文本非法
*x = root["datax"].asInt(); // 取回字段
*y = root["datay"].asInt();
*op = (char)root["oper"].asInt(); // char 在 Json 里以整数存取,再转回字符
return true;
}这里藏着一个值得记录的细节:Json 里没有独立的 char 类型。 你把一个 char op = '+' 塞进 root["oper"],jsoncpp 会按整数把它存成 43('+' 的 ASCII 码),读回来要 (char)root["oper"].asInt() 转回去。所以课件反序列化里写 _oper = root["oper"].asInt(),正是这个原因——char 在 Json 世界被当作"整型"对待,这是初学者常常挠头的一点。
手写 vs Json vs Protobuf:一张取舍表
我把三条路线放在一起做最后的对比,PPT 也要求你心里有这个"取舍":
| 对比维度 | 手写协议(本课) | Json | Protobuf |
|---|---|---|---|
| 报文体积 | 较大(十进制文本) | 大(键名+引号) | 小(二进制+变长) |
| 传输效率 | 一般 | 一般 | 高 |
| 人类可读 | 灰可以 | 很好 | 不可读 |
| 实现成本 | 全手写、费劲 | 用库一调就完 | 写 .proto + 工具生成代码 |
| 需要定义字段顺序 | 必须自己背死 | 键名自描述,无需顺序 | 字段号自描述 |
| 出错面 | 全在自己手里 | 库保证解析、仍要写映射 | 生成代码确保对称 |
| 何时选它 | 学原理、极简场景、追求可控 | 调试友好、弱类型场景 | 高性能、跨语言、大规模通信 |
结论很清晰,也是课件想让你最终记住的:手写不是用来替代成熟方案的,它的价值在"体验过程"——你亲手把字段、长度、顺序、字节序、健壮性全部碰过一遍之后,你才能真正读得懂 Json/Protobuf 是怎么替你兜底、值得在哪些场景花代价上它们。当你的需求是"极简内部协议、想完全掌控、不介意体积"时,一个精心写好的手写长度前缀协议完全够用;当你要跨语言对接、又要高性能,就老老实实上 Protobuf/MessagePack 这类正规军。
Protobuf 是怎么解决我们刚才的坑的
顺带满足一下你的好奇心。Protobuf(Protocol Buffers,谷歌开发的序列化协议)之所以"正规军",是因为它从根上把编解码对称、字段自描述、健壮性都做成了"保证"而不是"自觉":
- 你写一份
.proto报文描述(如message CalcReq { int32 x = 1; int32 y = 2; string oper = 3; }),由编译器protoc自动生成一组具有SerializeToString/ParseFromString的 C++ 类,序列化和反序列化代码是同一套生成的,对称性由工具保证; - 字段用字段号 + 类型编码(TAG)标识,天然自描述,配 varint 变长整型(每字节低 7 位有效、最高位表示是否还有后继字节)压缩体积,就是 TLV(Tag-Length-Value)编码——你回头看,我们这节课的"长度前缀"思想正是它最原始的那一格。
看完这段你就明白:Protobuf 不是魔法,它就是把你手写时踩过的那些坑,用一套严谨的工具和编码规范替你制度化地解决了。
用工厂模式把协议封装起来
最后,课件还引入了一个小而美的工程实践——工厂模式(Factory Pattern),它是一种创建型设计模式:"把'创建对象'的职责集中到一个专门的类里",调用方不再直接 new Request(...),而是通过这个工厂类的接口去拿对象。好处是:创建逻辑(比如默认值、初始化)收敛到一处,日后想改创建方式(比如换成对象池、加统计)只用动工厂一个地方,调用方完全无感。
在本项目里,Request/Response 既有默认构造也有带参构造,直接靠 API 使用者去选也行;但课件用一个 Factory 把两套构造都收口成语义化的接口。我基于课件原意给出一个可运行版本:
#include <memory> // std::shared_ptr / std::make_shared
class Factory
{
public:
// 造一个"默认空"的 Request
std::shared_ptr<Request> BuildRequest()
{
return std::make_shared<Request>(); // 用默认构造
}
// 造一个"带参"的 Request
std::shared_ptr<Request> BuildRequest(int x, int y, char op)
{
return std::make_shared<Request>(x, y, op); // 用带参构造
}
// 造默认的 Response
std::shared_ptr<Response> BuildResponse()
{
return std::make_shared<Response>(); // 用默认构造
}
// 造带结果的 Response
std::shared_ptr<Response> BuildResponse(int result, int code)
{
return std::make_shared<Response>(result, code); // 用带参构造
}
};std::shared_ptr 是 C++11 的智能指针,用引用计数管理对象生命周期,对象会自动在没人用时释放,避免手动 new/delete 的泄漏烦恼。返回 shared_ptr 而不是裸指针,是工程上"资源谁都有份、谁都能安全持有"的推荐写法。
完整可运行代码
光说不练假把式。这一节我们把全文所有零件——协议常量、Encode/Decode(健壮版)、Request/Response(自带分隔符清晰易读的自定协议)、Factory——整合成一个单文件、可直接编译运行的完整程序,并演示粘包和半包两种真实的字节流场景。请把下面的代码存成一个 main.cpp,用 g++ -std=c++17 main.cpp -o a.out 编译(本文件不开 Json,不需要 jsoncpp)。
// main.cpp —— 手写序列化与反序列化:完整可运行演示
// 编译:g++ -std=c++17 main.cpp -o a.out && ./a.out
#include <string> // std::string
#include <memory> // std::shared_ptr / make_shared
#include <iostream> // std::cout / std::endl
#include <exception> // std::exception(stoi 抛异常捕获用)
namespace Protocol
{
// 协议的硬约定:字段间用空格,报文帧用 \r\n 做"头标/尾标"
const std::string ProtSep = " ";
const std::string LineBreakSep = "\r\n";
// 编码:把一段正文 message 包成自带长度的网包 "len\r\n正文\r\n"
std::string Encode(const std::string &message)
{
std::string len = std::to_string(message.size()); // 正文长度->文本
return len + LineBreakSep + message + LineBreakSep;
}
// 解码:从缓冲区 package 尝试剥出恰好一条完整消息
bool Decode(std::string &package, std::string *message)
{
if (message == nullptr) return false; // 出参空指针防御
const size_t MaxLen = 1 * 1024 * 1024; // 单条最大 1MB,防超级长度
auto pos = package.find(LineBreakSep); // 找长度数字结尾
if (pos == std::string::npos) return false; // 半包:长度都没到齐
std::string lens = package.substr(0, pos); // 长度数字文本
long n = 0;
try { n = std::stol(lens); } // 解析长度,可能抛异常
catch (const std::exception &) { return false; } // 非法长度:当垃圾丢
if (n < 0 || (size_t)n > MaxLen) return false; // 负长度 / 超上限:非法
size_t total = lens.size() + LineBreakSep.size() // 头标
+ (size_t)n + LineBreakSep.size(); // 正文 + 尾标
if (package.size() < total) return false; // 半包:正文没到齐
*message = package.substr(pos + LineBreakSep.size(), (size_t)n); // 取正文
package.erase(0, total); // 消费掉这条完整消息
return true;
}
class Request
{
public:
Request() : _data_x(0), _data_y(0), _oper(0) {}
Request(int x, int y, char op) : _data_x(x), _data_y(y), _oper(op) {}
int GetX() { return _data_x; }
int GetY() { return _data_y; }
char GetOper() { return _oper; }
// 序列化:拼成 "x op y"
bool Serialize(std::string *out)
{
*out = std::to_string(_data_x) + ProtSep
+ _oper + ProtSep
+ std::to_string(_data_y);
return true;
}
// 反序列化:解析 "x op y",用 find/rfind 夹出运算符
bool Deserialize(std::string &in)
{
auto left = in.find(ProtSep); // 第一个空格
if (left == std::string::npos) return false;
auto right = in.rfind(ProtSep); // 最后一个空格
if (right == std::string::npos) return false;
try {
_data_x = std::stoi(in.substr(0, left));
_data_y = std::stoi(in.substr(right + ProtSep.size()));
} catch (const std::exception &) { return false; }
std::string oper = in.substr(left + ProtSep.size(),
right - (left + ProtSep.size()));
if (oper.size() != 1) return false; // 运算符必须单字符
_oper = oper[0];
return true;
}
void Debug()
{
// 打出来验证解析是否正确
std::cout << " x=" << _data_x << " y=" << _data_y
<< " op=" << _oper << std::endl;
}
private:
int _data_x; // 操作数1
int _data_y; // 操作数2
char _oper; // 运算符 + - * / %
};
class Response
{
public:
Response() : _result(0), _code(0) {}
Response(int result, int code) : _result(result), _code(code) {}
void SetResult(int r) { _result = r; }
void SetCode(int c) { _code = c; }
int GetResult() { return _result; }
int GetCode() { return _code; }
// 序列化:拼成 "result code"
bool Serialize(std::string *out)
{
*out = std::to_string(_result) + ProtSep + std::to_string(_code);
return true;
}
// 反序列化:用第一个空格切开
bool Deserialize(std::string &in)
{
auto pos = in.find(ProtSep);
if (pos == std::string::npos) return false;
try {
_result = std::stoi(in.substr(0, pos));
_code = std::stoi(in.substr(pos + ProtSep.size()));
} catch (const std::exception &) { return false; }
return true;
}
void Debug()
{
std::cout << " result=" << _result << " code=" << _code << std::endl;
}
private:
int _result; // 运算结果
int _code; // 状态码,0=成功
};
class Factory
{
public:
// 创建空/带参的 Request
std::shared_ptr<Request> BuildRequest() { return std::make_shared<Request>(); }
std::shared_ptr<Request> BuildRequest(int x, int y, char op)
{ return std::make_shared<Request>(x, y, op); }
// 创建空/带参的 Response
std::shared_ptr<Response> BuildResponse() { return std::make_shared<Response>(); }
std::shared_ptr<Response> BuildResponse(int result, int code)
{ return std::make_shared<Response>(result, code); }
};
}
// 模拟一个"简单计算器":真正处理一次算术请求,返回一条应答
std::string DoCalc(int x, int y, char op, int *code)
{
int r = 0;
switch (op) {
case '+': r = x + y; break;
case '-': r = x - y; break;
case '*': r = x * y; break;
case '/': if (y == 0) { *code = 1; return Protocol::Encode("div by zero"); }
r = x / y; break;
case '%': if (y == 0) { *code = 2; return Protocol::Encode("mod by zero"); }
r = x % y; break;
default : *code = 3; return Protocol::Encode("bad op");
}
*code = 0;
Protocol::Response resp(r, *code); // 构造应答
std::string body;
resp.Serialize(&body); // 对象->字符串
return Protocol::Encode(body); // 再套上"长度+边界"
}
int main()
{
using namespace Protocol;
Factory factory; // 用工厂创建对象
// —— 场景演示:粘包 ——
// 构造两条独立消息,在"发送端"各自 Encode,但接收端把它们一次性塞进同一缓冲区:
// 这模拟一次 recv 拿到两个包(粘包)。
auto r1 = factory.BuildRequest(3, 5, '+');
auto r2 = factory.BuildRequest(12, 4, '/');
std::string b1, b2;
r1->Serialize(&b1);
r2->Serialize(&b2);
std::string wire = Encode(b1) + Encode(b2); // 两个完整包拼进一个缓冲区 = 粘包
std::cout << "【粘包】一次拿到两个完整包,字节流如下(已做可读化):\n"
<< " " << wire << std::endl
<< "字节总数: " << wire.size() << std::endl;
// 循环剥包:while 内部分裂直到剥不出来(剥完或只剩半包)
std::string msg;
while (Decode(wire, &msg)) {
Request req; // 一个空请求对象
req.Deserialize(msg); // 解析刚剥出的正文
std::cout << " 剥出一条 Request:";
req.Debug(); // 打印字段
}
std::cout << " 剩余未剥字节:" << wire.size() << "(应为0)\n\n";
// —— 场景演示:半包 ——
// 把一条完整网包硬切成两半,分成两次喂给 Decode,模拟一次 recv 只到一半。
auto r3 = factory.BuildRequest(9, 3, '*');
std::string b3;
r3->Serialize(&b3);
std::string whole = Encode(b3); // 完整包
std::string half1 = whole.substr(0, 6); // 前半段
std::string half2 = whole.substr(6); // 后半段(剩余)
std::cout << "【半包】把一条包切成两半依次投喂:\n";
std::string buf;
buf += half1; // 只喂前半段
std::cout << " 喂入前半段后 Decode 状态:";
std::string out;
bool got = Decode(buf, &out);
std::cout << (got ? "剥出(不该出现)" : "没剥出,正确等待半包") << std::endl;
buf += half2; // 补齐后半段
std::cout << " 补齐后半段后 Decode 状态:";
got = Decode(buf, &out);
if (got) {
Request req2;
req2.Deserialize(out);
std::cout << "剥出完整 Request:";
req2.Debug();
std::cout << " 剩余未剥字节:" << buf.size() << "(应为0)\n";
} else {
std::cout << "仍没剥出(异常)" << std::endl;
}
// —— 场景演示:完整往返 —— 客户端请求 → 服务端计算 → 服务端应答
std::cout << "\n【完整往返】服务端计算 3+5:" << std::endl;
int code = 0;
std::string replyWire = DoCalc(3, 5, '+', &code); // 服务端算完并编码成网包
std::string replyBody;
Decode(replyWire, &replyBody); // 剥出结果正文
Response resp;
resp.Deserialize(replyBody); // 解析结果字段
std::cout << " 结果包正文: \"" << replyBody << "\"\n";
resp.Debug(); // 打印 result / code
std::cout << "\n全部场景演示完毕。" << std::endl;
return 0;
}上面这个 main 就是本课完整可运行的主函数:它把 DoCalc(服务端计算并把结果编码成网包)、namespace Protocol(协议、序列化与工厂)和 main(三场景演示)收进同一个文件,用 g++ -std=c++17 main.cpp -o a.out && ./a.out 即可编译运行。运行时你会看到:粘包被精确剥成两条消息、半包能正确等待补齐、3+5 算出 8 且 code 为 0。
到这里,我们不只是"看懂了序列化"。我们从"报文边界(粘包/半包)"讲起,亲手写出了长度前缀协议的 Encode/Decode;又用 Request/Response 把"结构化数据的字段编解码"完整落地,理解了分隔符与夹逼切分;顺带弄清了字节序、变长与定长、以及"为什么不能把 struct 整个 memcpy 出去"。我们用"绝不信任网络数据"的信条给协议补上了健壮性,再用 Json、Protobuf 的对比看清了"手写一次"的真正价值——是让你读懂成熟方案在替你兜什么底,而不是让你在大型生产系统里重造轮子。
还记得这篇文章开头那张"六格难点表"吗?此刻你可以回看:报文边界、字段编解码、字节序、变长/定长、编解码对称、协议健壮性——每一个,我们都在上面的代码里亲手撞过、修过、验证过。这六块砖,就是"协议设计"这堵承重墙的地基。你以后再看 HTTP 的 Content-Length、Redis 的 $3\r\n...、以及 Protobuf 的 varint,都能一眼认出,哎呀,这就是我当年手写过的那个 len\r\nmessage\r\n 的升级版。这就是我们花这一整篇加餐课去手写一遍的全部价值所在。
如果本课的代码你能亲手敲一遍、把粘包循环和半包等待跑出来,这块地基就算真夯进你脑子里了。下一节课,我们回到 Linux 网络编程的主线,去看真正的 socket、bind、listen、accept——到时候,这些序列化好的包,就真的要迎来它们在网线上的第一次旅行了。
全文思考题汇总(附详解)
每题一句答案 + 关键推理,帮你复习。
-
思考题一(长度前缀为何不怕正文出现分隔符):因为切消息是靠"长度数字"精确按字节取,不靠找分隔符;正文里的
\r\n只是普通字节,不影响定界。真正的分隔符协议才需要转义。 -
思考题二(为何 total 加 2 个 \r\n):协议里有两个
\r\n——头标(长度后)和尾标(正文后),total必须数全,少一个会导致半包误判成完整、或 erase 删少 2 字节把边界带乱。 -
思考题三(find/rfind 夹逼):三段格式
x op y有两个空格,用首尾两个空格夹出中间唯一的单字符运算符,一个空格只能切两段、放不进运算符。 -
思考题四(字节序):字节序由硬件决定、不归作者管;协议不统一,两端跨架构就对出完全错误的值,且单机测不出来;所以协议统一"网络字节序大端 + htonl/ntohl"。
-
思考题五(为何不全定长):字符串/数组长度天生可变,强制定长要么浪费带宽要么塞不下;因此"定长独立发 + 变长用长度前缀"是忠于数据形状的设计。
-
思考题六(不对称后果):
"1 + 2"按错误顺序读会把生命周期全串位,字段张冠李戴、语义全乱且不一定报错——所以对称是正确性最低要求,编解码必须一起维护。 -
思考题七(半包是否丢):不能丢。
Decode返回 false 时不 erase,半包是"待办的中间态",要保留等下一批数据 append 后再解,丢了就永远拼不成完整消息。
这七问如果你都能不看详解答出来,说明你不仅"看过"了,而是真的"会"了。下一站,真正的 socket 编程正等着你。
还没有评论 — 第一条由你来留。