上一篇我们把 socket 编程的套路摸了个遍:怎么建监听、怎么 accept、怎么往缓冲区里读、怎么把数据发出去。但不知道你有没有闪过一丝不安——我们发出去的东西,本质上就是一串"字节",甚至可以说是一串"字符"。收发双方是痛快了,可这串字节到底代表什么意思,谁也没认真约定过。
这一篇,我们就要解决这个"意思"的问题。这就是应用层协议的世界。
先说清楚我们到底站在哪一层。TCP/IP 四层模型里,链路层、网络层、传输层的活基本被操作系统和内核包办了;而我们程序员写的一个个真正解决实际问题、满足日常需求的网络程序,几乎全都工作在应用层。这一层没有哪条铁律规定"你必须怎么组织数据",全靠收发双方自己商量。商量的结果,就是协议。
应用层:我们写的网络程序都在这一层
在往下讲之前,把这一层的地基夯实一点。你写完一个网络服务器,跑起来之后它在网络栈里的位置大概是这样的:
- 物理层 + 链路层:把比特变成电信号/光信号在网线上传送,处理 MAC 地址、网卡收发。这份脏活由硬件和驱动完成;
- 网络层:负责数据包的路由和转发,IP 地址就在这一层,它的产物是"数据报";
- 传输层:端到端的可靠传输,TCP 就在这一层,它的产物是"段";
- 应用层:你写的代码。HTTP、FTP、DNS、你自定义的一切,都在这一层。
所以你在自己程序里 make 出来的"报文",内核只当它是传输层要搬运的普通字节流——它既不确定你的第一个字节是数字还是字母,也不关心你是要发一个加法运算还是要传一张图片。"这些字节该怎么解读",是应用层自己说了算的事。 这份自由既是机遇也是负担:没有人规定你必须怎么组织数据,所以一切都得你来自定义。
这也正是"再谈协议"的由头。
到底什么是协议
协议这个词,日常生活中也有。两国首脑见面要谈"协议",其实就是"双方商量好、都认可的约定"。网络协议也是这个意思,只不过约定的对象是"数据长什么样、怎么填、怎么解析"。
一句话:协议就是双方约定好的结构化数据。 只要一端发送时构造数据的规则,与另一端解析数据时使用的规则完全一致,那么这两端就共享同一个协议,数据就能被正确理解。
这就带出一个特别重要的推论:协议的定义是双向的、对称的,而且没有任何"官方指定答案"。 只要你能保证"发"和"收"对齐,你用任何格式都会被大家接受。下面我们就用一个具体的例子,把这句话在一次实际的收发里坐实。
再谈协议:一份约定的两种打开方式
来看一个最经典的入门场景——网络版计算器。我们要在一台主机上跑一个服务器,在另一台主机上跑一个客户端。客户端把两个加数发给服务器,服务器算好结果再发回来。就这么简单。
问题是:这两个数,用什么格式送给服务器?
约定方案一:人类可读的字符串
我们约定,客户端发送一个形如 "1+2" 的字符串,并且:
- 这个字符串里有两个操作数,都是整数;
- 两个数字之间有一个字符是运算符,本版本运算符只能是
+; - 数字和运算符之间没有空格;
- 为了防止"负数、多位数、运算符撞上"这类歧义,再多定几条细则……
这样服务器收到后,就逐字符解析这个字符串,把 "1+2" 拆成 1、+、2,算得 3,再按约定把 "3" 这个字符串发回去。
这个方案看起来没什么问题,但你能明显感到它"脆":规则全是文字约定,解析要靠一堆字符串处理代码,一旦出现 "+-1+-2" 这种边缘情况,规则立刻需要打补丁。更麻烦的是,当我们要传输的数据不是"两个整数加一个符号",而是"用户名、密码、头像、一组好友、登录时间"这种结构复杂的数据时,纯字符串方案的解析工作会指数级膨胀。
约定方案二:先定义结构体,再转成字符串
我们换个思路:不直接传字符串,而是先在程序里定义一个结构体,用来表示要交互的信息:
- 定义结构体(比如"请求"里有加数
x、加数y、运算符oper三个字段); - 发送数据时,把这个结构体按照一套规则"翻译"成一个字符串,再送给 socket;
- 接收数据时,再按照同一套规则把这个字符串"翻译"回结构体;
- 这个"翻译"过程,就是我们接下来要浓墨重彩讲的序列化和反序列化。
对比一下你就发现,方案一和方案二本质是同一件事的两种姿势:都是在"内存里的数据"和"线上传输的字节"之间做翻译,只不过方案一直接拿字符串打交道,方案二先用结构体建模、再翻译成字符串。无论采用哪种,只要保证一端发送时构造的数据,在另一端能被正确解析,就是一段好用的应用层协议。
而为了真正理解协议内部的这些门道,我们决定手把手把方案二完整实现一遍——从 Socket 封装,到结构体定义,到序列化/反序列化,再到最头疼的"粘包",一整套走下来,你对"协议到底是怎么设计的"就彻底通了。
序列化和反序列化:协议的心脏
在动手写代码前,得先把这两个词彻底讲清。它们是应用层协议里出镜率最高、却常被误会的一对概念。
序列化(Serialize)
序列化是指:把一个"进程内存里的结构化数据"(结构体、对象、类实例)转换成一串连续的、可以放到网络上传输、或者存到文件里的字节序列。形象点说,内存里的数据本来是一块有着明确字段布局的"立体模型",而网络只能传"一字排开的字节流",序列化就是把这个立体模型"压扁拍平"成一条线。
为什么要这样做?因为结构体里的字段在内存里是靠"偏移量 + 类型"来定位的,这种布局只有运行中的同一个程序才懂。而数据要在两台主机、甚至两种语言之间传输,对端不可能共享你的内存布局。所以我们必须先把它翻译成"纯粹由字节构成、不带任何内存假设"的形式,对端拿到后再按自己的规则重建。
反序列化(Deserialize)
反序列化就是序列化的逆操作:把网络上传来的那一串连续字节,按照约定的规则重建回对应类型的结构化数据。发送端的结构体对象,在接收端被"复活"成一个字段值相同的结构体对象。
打个比方:序列化像是把一卡车货先装箱贴上清单发出去,反序列化像收货方拿到箱子和清单、照着清单把货再一样样摆回仓库的原位置。 只有"装箱规则"和"摆货规则"完全一致,货才不会摆错位。这也是协议里"对称"二字的全部含义。
序列化的三种"打开方式"
既然要翻译,用什么格式翻译?本篇文章重点覆盖三种主流选择,各有适用场景,我们后面都会展开有完整代码的实现:
- 直接用 C 结构体翻译(二进制序列化):把结构体的内存字节原样发出去。速度快、体积小,但坑极多(字节对齐、端序),对跨语言/跨平台不友好,适合限定场景的网络内部使用;
- 用 JSON 翻译(文本序列化):把结构体翻译成
{"datax":1,"oper":37,"datay":2}这种带字段名的文本。用现成库(我们讲 jsoncpp)处理,跨语言友好、可读性强,是当前互联网应用层的主流; - 自描述方案:在报文里额外携带"这是什么类型、占多长"这样的元信息,让接收方拿到手就能自己理解这是一份什么样的数据,而不必被"固定死在第几种协议"。
这三个方案不是互斥的,成熟的协议往往组合使用。我们逐个实现。
重新理解 read / write / recv / send:TCP 为什么是全双工
在正式写代码之前,还有一个底层认知需要补齐,否则后面讲"为什么读不到完整报文"时会卡壳。这就是 socket 为什么会变成"全双工"。
回忆一下:TCP 连接建立后,你在任何一个 sockfd 上,既可以用 recv 读,也可以用 send 写,而且是"边读边写"互不阻塞。为什么同一个 socket 既能读又能写?因为在内核里,一个 TCP 连接同时拥有两个缓冲区:发送缓冲区(send buffer)和接收缓冲区(recv buffer),它们彼此独立。
send并不是直接把数据"怼"到对端,而是把数据拷贝进本端的发送缓冲区,之后"什么实际发出去、一次发多少、发错了怎么办、要不要重传",由 TCP 协议自己控制——这就是为什么 TCP 全名叫传输控制协议(Transmission Control Protocol)。对端收到后,先落进它自己那端的接收缓冲区;- 你的
recv也不是直接到对端去"取",而是从本端的接收缓冲区里拿数据。
因为"发"走发送缓冲区、"收"走接收缓冲区,两者是两块不同的内存、两套独立的队列,所以可以同时进行——这就是 TCP 全双工的底层原因:收发路径互不依赖,各自有独立的缓冲区。 这也顺带解释了为什么一个 sockfd 就同时兼了"读"和"写"两个角色:它背后挂着的,本来就是两套各自独立的内核数据结构。
而"两个缓冲区彼此独立"这个特性,又直接引出我们后面要花大篇幅解决的粘包问题:接收缓冲区的数据不是一条条"有界"的,而是源源不断堆在一起的连续字节流,你得自己负责"切分"。先记下这个伏笔。
开始实现:先看整体代码结构
好,理论和底层都铺垫完了,现在动手。下面是我们这一整套的工程文件结构:
Calculate.hpp Protocol.hpp Socket.hpp
Daemon.hpp Makefile
TcpServerMain.cc TcpClientMain.ccSocket.hpp:把 socket 的建、绑、听、连、收、发封装起来,屏蔽底层细节;Calculate.hpp:真正的"业务逻辑"——拿到两个加数和运算符,算出结果;Protocol.hpp:本篇文章的主角。定义 Request/Response 结构体、序列化/反序列化、以及解决粘包的 Encode/Decode;TcpServerMain.cc:服务器入口;TcpClientMain.cc:客户端入口。
为了把时间花在真正有干货的协议设计上,我们不打算写一堆"用户交互输入"的界面代码,直接让客户端循环发送、服务器循环响应,把通信链路跑通即可。
先看第一块:把 socket 包装成一个类,让我们后面写服务器和客户端时只调取干净的方法。
Socket 封装:用模板方法把底层细节藏起来
socket.hpp 的完整实现如下。整段代码严格遵守 C++11 及以上标准,可在 Linux 下直接编译。我用了设计模式里的模板方法模式:把"创建→绑定→监听"这类固定流程写进基类的非虚方法里,把"每一步具体怎么实现"留给子类去覆写虚函数。这样流程框架稳定,细节又可替换。
// socket.hpp
#pragma once // 防止头文件被重复包含
#include <iostream> // C++ 标准输入输出流(便于调试打印)
#include <string> // std::string
#include <cstring> // memset、strerror
#include <sys/types.h> // socket 相关的底层类型
#include <sys/socket.h> // socket/bind/listen/accept/connect/recv/send/close
#include <netinet/in.h> // struct sockaddr_in
#include <arpa/inet.h> // htons/htonl/ntohs/ntohl/inet_addr/inet_ntoa
#include <unistd.h> // close
// 把"socket 协议族地址结构"通用地转成 sockaddr* 的简化宏
// sockaddr_in 和 sockaddr 内存布局兼容,传递前需要强转类型
#define Convert(addrptr) ((struct sockaddr *)addrptr)
namespace Net_Work
{
const int defaultsockfd = -1; // 表示"无效 fd"的默认值
const int backlog = 5; // listen 的等待连接队列长度
enum { SocketError = 1, BindError, ListenError }; // 各类失败对应的退出码
// 基类:Socket 接口。定义"连招"式的流程骨架,具体动作交给子类实现
class Socket
{
public:
virtual ~Socket() {} // 虚析构,保证多态删除正确
// ---- 纯虚函数:由派生类实现的具体原子操作 ----
virtual void CreateSocketOrDie() = 0; // 创建一个 socket
virtual void BindSocketOrDie(uint16_t port) = 0; // 绑定端口
virtual void ListenSocketOrDie(int backlog) = 0; // 进入监听
virtual Socket *AcceptConnection(std::string *peerip, uint16_t *peerport) = 0; // 接受连接
virtual bool ConnectServer(std::string &serverip, uint16_t serverport) = 0; // 主动连接
virtual int GetSockFd() = 0; // 拿到底层 fd
virtual void SetSockFd(int sockfd) = 0; // 设置底层 fd
virtual void CloseSocket() = 0; // 关闭 socket
virtual bool Recv(std::string *buffer, int size) = 0; // 读取一次数据
virtual void Send(std::string &send_str) = 0; // 发送字符串
public:
// 模板方法:把"监听端"的三板斧串起来,子类只需各自实现
void BuildListenSocketMethod(uint16_t port, int backlog)
{
CreateSocketOrDie(); // 第 1 步:创建
BindSocketOrDie(port); // 第 2 步:绑定端口
ListenSocketOrDie(backlog); // 第 3 步:监听
}
// 模板方法:把"客户端"的建连两步串起来
bool BuildConnectSocketMethod(std::string &serverip, uint16_t serverport)
{
CreateSocketOrDie(); // 第 1 步:创建
return ConnectServer(serverip, serverport); // 第 2 步:连接
}
// 模板方法:accept 返回的已经是一个已建立的连接 fd,直接"接管"即可
void BuildNormalSocketMethod(int sockfd)
{
SetSockFd(sockfd);
}
};
// 派生类:基于 TCP 的具体实现
class TcpSocket : public Socket
{
public:
TcpSocket(int sockfd = defaultsockfd)
: _sockfd(sockfd)
{
}
~TcpSocket()
{
CloseSocket(); // 析构时顺手关掉 fd,防止泄漏
}
// 创建一个 TCP 流式 socket
void CreateSocketOrDie() override
{
_sockfd = ::socket(AF_INET, SOCK_STREAM, 0); // AF_INET:IPv4,SOCK_STREAM:可靠字节流
if (_sockfd < 0) // 创建失败
exit(SocketError);
}
// 绑定一个监听端口
void BindSocketOrDie(uint16_t port) override
{
struct sockaddr_in local; // 本地地址结构
memset(&local, 0, sizeof(local)); // 清零,避免脏数据
local.sin_family = AF_INET; // IPv4 协议族
local.sin_addr.s_addr = INADDR_ANY; // 绑定所有网卡地址
local.sin_port = htons(port); // 端口转成网络字节序
int n = ::bind(_sockfd, Convert(&local), sizeof(local));
if (n < 0) // 绑定失败
exit(BindError);
}
// 进入监听状态
void ListenSocketOrDie(int backlog) override
{
int n = ::listen(_sockfd, backlog); // backlog 指定连接队列长度
if (n < 0) // 监听失败
exit(ListenError);
}
// 接受一个连接,把对端 ip/port 填出来,并返回一个专门和它通信的新 socket
Socket *AcceptConnection(std::string *peerip, uint16_t *peerport) override
{
struct sockaddr_in peer; // 对端地址
socklen_t len = sizeof(peer); // 地址结构长度(输入输出参数)
int newsockfd = ::accept(_sockfd, Convert(&peer), &len);
if (newsockfd < 0)
return nullptr; // 失败返回空指针
*peerport = ntohs(peer.sin_port); // 对端端口转回主机字节序
*peerip = inet_ntoa(peer.sin_addr); // 对端 IP 转成可读字符串
Socket *s = new TcpSocket(newsockfd); // 用它的 fd 包一个新对象
return s;
}
// 作为客户端,主动连接服务器
bool ConnectServer(std::string &serverip, uint16_t serverport) override
{
struct sockaddr_in server; // 服务器地址结构
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET; // IPv4
server.sin_addr.s_addr = inet_addr(serverip.c_str()); // 文本 IP 转二进制
server.sin_port = htons(serverport); // 端口网络字节序
int n = ::connect(_sockfd, Convert(&server), sizeof(server));
return n == 0; // n==0 表示连接成功
}
int GetSockFd() override { return _sockfd; }
void SetSockFd(int sockfd) override { _sockfd = sockfd; }
void CloseSocket() override
{
if (_sockfd > defaultsockfd) // fd 有效才关闭
::close(_sockfd);
}
// 读取一次数据,追加到 buffer 尾部。关键点:可能要读多次才积累成完整报文
bool Recv(std::string *buffer, int size) override
{
char inbuffer[size]; // 临时缓冲区(VLA,需编译器支持)
ssize_t n = recv(_sockfd, inbuffer, size - 1, 0); // 读最多 size-1 字节
if (n > 0)
{
inbuffer[n] = 0; // 手动补字符串结束符
*buffer += inbuffer; // 故意"拼接"——留到协议层去切分
return true;
}
else
{
return false; // n==0 对端关闭;n<0 出错
}
}
void Send(std::string &send_str) override
{
// 简单的发送:这里不处理"一次没发完"的情况(完整版本可加循环发送)
send(_sockfd, send_str.c_str(), send_str.size(), 0);
}
private:
int _sockfd; // 底层的文件描述符
};
}我特意在 Recv 里写了句注释"故意拼接",因为它是理解后面一切的关键。recv 一次能读到多少,只取决于此刻接收缓冲区里堆了多少字节,跟"你逻辑上一个报文多长"毫无关系。所以 Recv 只负责"把这一次读到的原始字节存进一个累加字符串",至于里面是半个报文、一个报文还是十个报文,全交给协议层去切分。缓冲区累积的想法,就是下文粘包解法的伏笔。
补充说明:可变长数组(VLA)
上面 char inbuffer[size]; 里 size 是运行时变量,这叫变长数组。它在 C99 里是标准特性,在 C++ 里虽属编译器扩展、不保证所有编译器支持,但 g++ 在 Linux 上默认可用。更稳妥的写法是用 std::vector<char>,但课堂示例为了直观用了 VLA。如果你想追求最严格的可移植性,可以改成 std::string inbuffer(size, '\0');,效果等价且更地道——读者可以自己替换试试。
定制协议:定义我们的 Request 和 Response
业务层要交互的,本质上就是三种信息:客户端发出的请求、服务器返回的响应。我们给它们各定义一个结构体,这三个字段的摆放,就是协议最核心的骨架。
// Protocol.hpp 中的协议骨架(先看结构定义)
class Request // 客户端的请求
{
private:
int _data_x; // 第一个加数
int _data_y; // 第二个加数
char _oper; // 运算符:+ - * / %
// 报文的自描述字段:约定一次完整报文的格式为 "len\r\nx op y\r\n"
// 其中 \r\n 不属于报文的数据部分,只是我们约定的分隔符
};
class Response // 服务器的响应
{
private:
int _result; // 运算结果
int _code; // 运算状态码(0 表示成功,非 0 表示出错,如除零)
// 报文格式: "len\r\nresult code\r\n"
};注意我在注释里反复强调 \r\n:它不属于报文的数据部分,是我们额外约定的"分隔符" ——这是协议设计里非常重要的一种"元数据"思想:报文不但有"数据",还得有描述这份数据的"格式信息"(长度、类型、分隔标记)。这些信息帮接收端从没有边界的字节流里把一条条报文"抠"出来。
好了,骨架有了,接下来要给它填上两个关键能力:
- 在内存结构体 与 字符串(线上格式)之间互转 —— 这就是序列化/反序列化;
- 在"字符串"与"字节流中的完整报文"之间互转 —— 这就是解决粘包的编解码(Encode/Decode)。
而我们选的序列化方案,是在这套骨架里直接套用现成的成熟库——jsoncpp。上一节说的第一种方案(结构体二进制直发)我会在后面的进阶篇单独补一个完整实现。
Jsoncpp:成熟的序列化方案
在我们自制协议的很多细节之前,先认识一下选定的序列化工具——jsoncpp。
JSON(JavaScript Object Notation)是一种轻量级的、非常流行的数据交换格式,本身是一段人类能读懂的文本,形如 {"name":"joe","age":12}。jsoncpp 是处理 JSON 的一个开源 C++ 库:它能把你程序里的结构数据序列化成 JSON 字符串,也能把 JSON 字符串反序列化回 C++ 结构。它开源、轻量、在本主题的课堂环境里被广泛采用。
安装(二选一,取决于你的发行版):
# Ubuntu / Debian 系
sudo apt-get install libjsoncpp-dev
# CentOS / RHEL 系
sudo yum install jsoncpp-develLinux 下的头文件路径通常是 /usr/include/jsoncpp/json/json.h,所以代码里要用 #include <jsoncpp/json/json.h>(而不是 <json/json.h>)。编译时要链接它:
g++ -std=c++17 mycode.cc -o myapp -ljsoncpp极速上手:Json::Value 序列化
jsoncpp 的核心是 Json::Value。它是一个"动态的、能表示任何 JSON 值"的容器:往里面塞键值对、数组、嵌套对象都行。序列化就是把 Json::Value 变成字符串。常见写法有三种,各有用武之地:
// json_serialize_demo.cc —— 三种序列化写法对比
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h> // jsoncpp 统一头文件,路径见上文说明
int main()
{
Json::Value root; // 一个用于装 JSON 数据的容器
root["name"] = "joe"; // 塞入键值对:name -> "joe"
root["sex"] = "男"; // 中文也能存,JSON 底层是 UTF-8
root["age"] = 30; // 数字类型直接就能塞进去
// 写法 1:toStyledString()。输出带缩进和换行的"美化"格式,方便人读/调试
std::string styled = root.toStyledString();
std::cout << "=== toStyledString ===" << std::endl;
std::cout << styled;
// 写法 2:StreamWriter。自由度最高,可定制缩进/分隔符(这里演示基本用法)
Json::StreamWriterBuilder wbuilder; // 一个 Writer 工场
std::unique_ptr<Json::StreamWriter> writer(wbuilder.newStreamWriter()); // 创一个 Writer
std::ostringstream ss; // 把输出接到字符串流里
writer->write(root, &ss); // 写入流
std::cout << "=== StreamWriter ===" << std::endl;
std::cout << ss.str();
// 写法 3:FastWriter。速度最快,紧凑无缩进无多余换行,适合线上传输省带宽
Json::FastWriter fwriter;
std::string fast = fwriter.write(root);
std::cout << "=== FastWriter ===" << std::endl;
std::cout << fast;
return 0;
}运行结果大致是这样(顺序对应三种写法):
{
"age" : 30,
"name" : "joe",
"sex" : "男"
}
{ "age" : 30, "name" : "joe", "sex" : "男" }
{"age":30,"name":"joe","sex":"男"}可以看到 FastWriter 最紧凑。线上传输我们一般用 FastWriter(省字节),本地调试才用 toStyledString(好阅读)。 这正是序列化在"体积"和"可读性"之间的常见取舍。
反序列化:Json::Reader 把字符串变回结构
反向操作用 Json::Reader。它有两个本篇文章很关心的优点:一是解析能力好,二是解析失败时能给出详细的错误信息,方便定位"到底哪行 JSON 写坏了"。
// json_deserialize_demo.cc —— 反序列化示例
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
int main()
{
// 一段 JSON 字符串(注意字符串里的引号要转义)
std::string json_string = "{\"name\":\"张三\", \"age\":30, \"city\":\"北京\"}";
Json::Reader reader; // 解析器
Json::Value root; // 解析出来的值会放进这里
bool ok = reader.parse(json_string, root); // 核心:把字符串解析进 root
if (!ok)
{
// 解析失败:打印详细错误信息和出错位置,强烈建议每个 parse 都这样处理
std::cout << "Failed to parse JSON: "
<< reader.getFormattedErrorMessages() << std::endl;
return 1;
}
// 从 root 里按键取值,并用 asXxx() 转成希望的类型
std::string name = root["name"].asString();
int age = root["age"].asInt();
std::string city = root["city"].asString();
std::cout << "Name: " << name << std::endl;
std::cout << "Age: " << age << std::endl;
std::cout << "City: " << city << std::endl;
return 0;
}Json::Value 的常用"武器库"
实战中你会频繁用到这些 Json::Value 的能力,我按类别列出来便于查阅(详细方法清单藏在 jsoncpp 头文件里,这里只挑高频的):
类型判断(用于"保守起见先看看是什么类型"):isNull()、isInt()、isInt64()、isUInt()、isDouble()、isString()、isArray()、isObject()、isBool()。
取类型(asXxx 系列):asInt()、asInt64()、asUInt()、asUInt64()、asDouble()、asString()、asBool()。这些方法会把值尽力转成你要的类型;但若类型根本不兼容(比如拿字符串当整数转),行为取决于编译版本,所以先 isXxx 判断再 asXxx 取值更稳妥。
访问元素:root["key"] 通过键访问对象成员;root[i] 通过下标访问数组成员。注意一个隐蔽点:root["不存在的键"] 并不会报错,而是自动创建一个空值。所以如果你想区分"键存在但值是空"和"键根本不存在",需要用 root.isMember("key") 先判断。
数组操作:append(value) 往数组尾部加一个元素;size() 取数组/对象的元素个数;empty() 判断是否为空。
我们把 Request/Response 的序列化与反序列化用 jsoncpp 填上,这一节就算闭环了。
把 Request / Response 接入网络:完整 Protocol 设计
下面给出完整的 Protocol.hpp。它集齐了我们前面所有概念:结构体定义、序列化/反序列化(用 jsoncpp)、以及为了解决粘包而设计的 Encode/Decode。还用了一个简单的工厂来帮我们规范地创建 Request/Response 对象。
// Protocol.hpp —— 协议层的完整实现
#pragma once
#include <iostream> // 调试打印
#include <memory> // std::shared_ptr / make_shared
#include <string>
#include <cassert> // assert,防御式检查用
#include <jsoncpp/json/json.h> // jsoncpp 序列化库
namespace Protocol
{
// 分隔符约定:字段之间的空格、一行报文内部的换行分隔符
const std::string ProtSep = " ";
const std::string LineBreakSep = "\r\n"; // 注意是 CRLF 两个字符
// -----------------------------------------------------------
// 一、报文编解码:解决"粘包"
// 我们的线上报文格式约定为:
// "len\r\n" + 真正的数据 + "\r\n"
// 即:长度字段 + 分隔符 + 数据 + 结束分隔符
// \r\n 不属于报文的数据部分,只是我们约定的边界标记。
// -----------------------------------------------------------
// 编码:给一段"已经是字符串的报文数据"加上"长度前缀 + 分隔符",打包成可上线的报文
std::string Encode(const std::string &message)
{
std::string len = std::to_string(message.size()); // 用十进制文本表示长度
std::string package = len + LineBreakSep + message + LineBreakSep; // 拼装
return package;
}
// 解码:从"可能包含多段报文"的字节流里,尝试从头部切出一段完整报文。
// 若头部凑不齐一条完整报文则返回 false;切成功则返回 true 并把数据填进 message。
bool Decode(std::string &package, std::string *message)
{
// 第 1 步:找第一个 \r\n,它把"长度"和"数据"隔开
auto pos = package.find(LineBreakSep);
if (pos == std::string::npos) // 连第一个分隔符都没找到 => 数据还不全
return false;
// 第 2 步:长度字段 = 开头到第一个 \r\n 之间那段文本
std::string lens = package.substr(0, pos);
int messagelen = std::stoi(lens); // 把它转成整数(ASCII -> int)
// 第 3 步:算这条完整报文总共占多少字节
// = "长度数字本身的字符数" + 数据长度 + 2 个 \r\n 的大小
int total = lens.size() + messagelen + 2 * LineBreakSep.size();
// 第 4 步:完整性校验 —— 只有积累了够 total 个字节,才可能是一条完整报文
if ((int)package.size() < total)
return false;
// 第 5 步:此时 package 头部必定已经有一条完整报文,把它抠出来
*message = package.substr(pos + LineBreakSep.size(), messagelen);
// 第 6 步:把已消费的 total 字节从缓冲区头部删掉,剩下的留给下一次
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) {}
// 序列化:把结构体 -> JSON 字符串
bool Serialize(std::string *out)
{
Json::Value root;
root["datax"] = _data_x; // 塞入加数 x
root["datay"] = _data_y; // 塞入加数 y
root["oper"] = _oper; // 注意:char 会被 json 当作整型(存的是 ASCII 码)
Json::FastWriter writer; // 用最省带宽的写法
*out = writer.write(root); // 输出 JSON 字符串
return true;
}
// 反序列化:把 JSON 字符串 -> 结构体
bool Deserialize(std::string &in)
{
Json::Value root;
Json::Reader reader;
bool res = reader.parse(in, root); // 解析;失败时 res 为 false
if (res)
{
_data_x = root["datax"].asInt(); // 按键取回
_data_y = root["datay"].asInt();
_oper = root["oper"].asInt(); // 取回的是 ASCII 码,拷进 char
}
return res;
}
// 调试:打印结构体内容,方便观察反序列化是否成功
void Debug()
{
std::cout << "_data_x: " << _data_x << std::endl;
std::cout << "_data_y: " << _data_y << std::endl;
std::cout << "_oper(int): " << int(_oper) << std::endl;
}
int GetX() { return _data_x; }
int GetY() { return _data_y; }
char GetOper() { return _oper; }
private:
int _data_x; // 第一个加数
int _data_y; // 第二个加数
char _oper; // 运算符
};
// 服务器响应
class Response
{
public:
Response() : _result(0), _code(0) {}
Response(int result, int code) : _result(result), _code(code) {}
bool Serialize(std::string *out)
{
Json::Value root;
root["result"] = _result; // 运算结果
root["code"] = _code; // 状态码
Json::FastWriter writer;
*out = writer.write(root);
return true;
}
bool Deserialize(std::string &in)
{
Json::Value root;
Json::Reader reader;
bool res = reader.parse(in, root);
if (res)
{
_result = root["result"].asInt();
_code = root["code"].asInt();
}
return res;
}
void SetResult(int res) { _result = res; }
void SetCode(int code) { _code = code; }
int GetResult() { return _result; }
int GetCode() { return _code; }
private:
int _result; // 运算结果
int _code; // 运算状态(0 成功,非 0 出错)
};
// 简单的工厂:统一创建对象,未来替换实现时不用改调用方
class Factory
{
public:
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);
}
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);
}
};
}注意 Deserialize 里 _oper = root["oper"].asInt();。因为 json 把 char 存成整型 ASCII 码,我们取回来时也要当成整形拿,再截断给 char。这在单字节字符(ASCII)下是安全的。
到这里,一个能满足"完整服务器客户端"的协议层就算成型了。但请先别急着跳到服务器——上面那个 Decode 函数是全文最重要也最容易写错的函数。我们得把"为什么要切报文"这件事彻底讲透,否则你只会抄代码、不知道它在解决什么、更不知道它有什么边界没兜住。
字节流、粘包和拆包:为什么 read 一次读不完
现在回到那个困扰无数新手的问题:为什么我 read 一次可能读到的不是一份完整的请求?
要理解它,必须先牢牢记住一个事实:TCP 不是"消息协议",而是"流协议"。 一个 recv 能读出多少字节,只取决于接收缓冲区里此刻真正堆了多少字节;TCP 根本不知道、也不关心"你逻辑上一个请求该有多长"。于是当你连续发多条消息时,接收端缓冲区里的数据是这样堆的:
len\r\nx op y\r\nlen\r\nx op y\r\nlen\r\n x op y...它们像一条没有刻度的绳索一样连在一起,读的时候你会遭遇两种情况:
- 粘包:你一次
recv把两条甚至多条报文一起读出来了。比如一次读了len\r\nx op y\r\nlen\r\nx op y\r\n,里面其实藏着 2 条完整响应。如果你天真地以为"读一次 = 一条报文",那就解析错了; - 拆包(半包):你一次
recv连一条报文的头都没读全。比如上次只读到了"le,下次才读到"n\r\nx op y\r\n"。一条报文被"拦腰截断"散落在多次recv里。
所谓粘包/拆包问题,指的就是"流式字节没有边界,导致我们无法从任意两次 recv 的切点上干净地取出一整条报文"这个难题。
请注意一个严谨的表述:粘包/拆包不是 TCP 协议的"bug",而是"流式字节流没有边界"这个特性天然带来的结果。TCP 保证的是"你发的字节顺序、内容不丢、作为可靠字节流到达",它从没承诺"每次读写都是一对一的消息边界"。职责边界是:TCP 负责可靠搬运,应用层负责自己切分。想明白了这点,"为什么 Socket 类里 Recv 要故意把数据拼到一个字符串里"就水落石出了——我们要在应用层维护一个"积累缓冲区",等缓冲区积累到足够判定出一条完整报文时,再去切它。
为什么会出现粘包(从发送端看)
从发送端发力也能产生粘包。假设你连续 send("len\r\n1+2\r\n") 和 send("len\r\n3+4\r\n") 两条数据:
- 这两条数据可能被 TCP 出于效率考虑(Nagle 算法等)在发送缓冲区里"合并"成一批一起发出去,接收端一次
recv就同时拿到两条; - 也可能每条单独发,但接收端两次
recv之间时序有差别,导致读者端看到的是"上次的尾巴 + 这次的脑袋"混在一起。
无论成因如何,结果都一样:你无法依赖"一次 recv = 一条消息"。 所以协议层必须提供"把字节流切成一条条报文"的能力,也就是 Encode(切)与 Decode(攒着切)这哥俩要解决的事。
分隔方案的两条底层原则
不管用什么手段,任何 TCP 应用层协议的分包都要守住两条底线:
- 不丢数据:切到的每条报文,内容必须和发送端原始报文完全一致,不多一个字节、不少一个字节;
- 正确完整:切出来的每条报文必须自洽——不会把"上一条的尾巴"错认成"下一条的头"。
有了这两条质量标准,我们就能评估各种分包手段的优劣了。
粘包问题的三种解决:定长、分隔符、长度前缀
治理粘包,业界公认有三板斧:定长、分隔符、长度前缀。我们先宏观对比,再逐个用代码说话。
方案 A:定长报文
约定:客户端每份报文长度永远等于 N 字节。比如 N = 12,那么每个报文都是 12 字节。接收端只要盯着字节数,每攒够 12 字节就切出一条完整报文,天然不会粘包。
优点:实现最简单,接收端逻辑几乎是"数数"。
缺点非常致命:
- 不灵活:报文内容超过 N 就必须拆分或失真,内容很短时又白占 N 字节。N 定大了浪费带宽,定小了装不下大报文;
- 需要处理短内容:不到 N 字节时得用填充(比如补
\0)凑齐,接收端还要会剔除填充,徒增复杂度。
所以定长只在"报文格式极度固定"(比如固定宽度的模拟量采集)的场景有意义;在通用计算器这种内容长短不一的场景,它并不合适。
方案 B:分隔符
约定:在报文之间放一个唯一的特殊标记,比如 \n 或某个自定义字节 ;。接收端"从头扫描,遇到分隔符就切一条",也就是我们 Encode 里用 LineBreakSep(\r\n)干的事。
优点:实现直观、报文内容不限长度,HTTP/1.1 的报文头就是用 \r\n 分行、再用 \r\n\r\n 隔开头和体的。
缺点同样明显:
- "转义"难题:万一报文内容里本身就含有这个分隔符呢?比如数据恰好是 "
1\r\n2",接收端一扫描到\r\n就误以为数据结束了——报文被拦腰切断,后面的数据全乱套。解决方案是要引入"转义"(就像 C 字符串里用\\n表示换行一样),报文中出现分隔符时必须转成别的写法、接收端再还原;转义逻辑本身又带来新的解析开销和出错面; - 扫描成本:每次读数据都要线性扫描找分隔符,数据量大时开销不小。
在我们的计算器场景里,运算数据里几乎不会出现 \r\n,所以转义的需求不强烈,用分隔符是够用的。但你必须心里有数:一旦报文可能携带任意二进制内容,纯分隔符方案就必须配转义。
方案 C:长度前缀(本课件采用,推荐)
约定:在每份报文的头部,先放一个"报文数据长度"字段,接收端先读这个长度,再按长度读对应数量的字节。这正是我们 Decode 干的事:
- 先找分隔符拿到
len字段(这里是文本形式的长度); - 再据此算出
total,够了才切,不够就等。
这是目前工程上最主流、最稳妥的方案,也是 HTTP/HTTPS 的报文体、绝大多数 RPC 框架、以及你自己将来设计的协议最常用的手段。它的本质是"让报文自带边界信息"——发送端负责标注"这段读多长",接收端负责"按图索骥"。
一个关键的技术分类:文本长度 vs 二进制长度
你可能注意到,我们课件里的长度字段是十进制文本(std::to_string 生成,如 "13"),后面跟 \r\n 作为分隔。而很多二进制协议直接用固定宽度的二进制整数做长度(如 4 字节网络字节序的整数),后面直接跟数据、不再需要分隔符。两者对比:
- 文本形式长度 + 分隔符:人可读、好调试,长度字段本身可变长(
"3"和"13"都是合法的);代价是每报文多出分隔符、且要处理"度数字符串再转整数"的琐碎; - 二进制形式长度:长度固定(固定占用 N 字节),接收端直接按字节序读出整数,更紧凑、更快、也更适合紧约束的嵌入式/高性能场景;代价是肉眼不可读、并且必须处理网络字节序。
这两种各有好坏,取决于你的报文是偏"文本友好"还是偏"二进制高效"。我们这一篇的课堂实现走文本路线,而在后面"进阶:二进制协议"一节再补一种最纯粹的二进制长度前缀写法。
长度校验的边界
再看一眼我们的 Decode,它有一行 if ((int)package.size() < total) return false;,这行完整性校验极其关键,它守住了三件事:
- 防止"长度字段都被
\r\n切出来了、但实际数据字节还没攒够"时,你强行去substr取出越界的空数据; - 防止恶意/异常对端声明的长度值过大,导致你后续分配异常巨大缓冲区、或读进错误的字节;
- 保证
package.size()与total比较时把符号问题处理掉——这里我把size()强转成int再比较,就是担心无符号 size_t 与有符号 int 混用时出现谁也想不到的比较错误(后面"常见坑"会展开)。
还要说明一个提升点:Decode 每次只切出一条报文。当缓存里同时有 5 条报文时,你应该在一个循环里反复调用 Decode 直到它返回 false,才能把这一次 recv 积累的所有报文都消化掉——这一点我会在服务器处理逻辑里示范,这是"一次读取、切出多条"的标准姿势。
期望的报文格式与流式数据的处理
把前面所有片段拼回到一张图上,我们期望的"线上真容"是这样的:
服务端 / 客户端之间,每一条完整报文的格式:
"len\r\n" + "数据正文" + "\r\n"
↑ 长度字段 ↑ 报文内容(JSON) ↑ 结束分隔符
例如一条"1+2"请求的报文为:
"4\r\n{\"datax\":1,\"oper\":43,\"datay\":2}\r\n"其中 len=4 是指后面数据正文 {"datax":1,...} 的长度(这段 JSON 大约 30 来字节,4 只是占位示意,真实长度取决于生成的 JSON 串)。关键是:收到一坨不透明的缓冲区字节后,我们按"找 \r\n → 拿长度 → 校验 → 按期切分 → 删除已消费前缀"这套流程,就能一次次地挤出完整报文。
那么就引出整个协议层最后一块拼图:流式数据到底应该怎么处理? 答案是三步曲:
- 积累:把每次
recv到的原始字节+=进同一个字符串缓冲区(Recv已经做了); - 切分:反复调用
Decode,每次切出一条完整报文,切到缓冲区头部不足一条为止; - 处理:每切出一条,就对其中的 JSON 做反序列化,得到 Request/Response 对象,走业务逻辑。
这三步,我会在下面的服务器与客户端里落成真正的代码。
完整实现:服务器与客户端端到端跑通
业务计算部分先放一个 Calculate.hpp:
// Calculate.hpp —— 业务逻辑:真正的"服务器上帝视角"计算
#pragma once
#include <cstdint> // 不一定要用,这里只是示意习惯
namespace Calc
{
// 返回计算结果;出错时把错误码填到 code(0 表示成功)
int Compute(int x, int y, char oper, int *code)
{
switch (oper)
{
case '+':
*code = 0;
return x + y;
case '-':
*code = 0;
return x - y;
case '*':
*code = 0;
return x * y;
case '/':
if (y == 0) // 除零:没法算
{
*code = 1; // 错误码 1 表示除零
return 0;
}
*code = 0;
return x / y;
case '%':
if (y == 0) // 取模除零同理
{
*code = 1;
return 0;
}
*code = 0;
return x % y;
default:
*code = 2; // 错误码 2 表示非法运算符
return 0;
}
}
}然后是服务器入口 TcpServerMain.cc,它是"积累 → 切分 → 反序列化 → 算 → 序列化 → 编码 → 发送"这条流水线的完整示范:
// TcpServerMain.cc —— 服务器:监听,接受一个连接,循环处理请求
#include "Socket.hpp"
#include "Protocol.hpp"
#include "Calculate.hpp"
#include <iostream>
#include <string>
using namespace Net_Work;
int main()
{
// 1. 创建监听 socket
TcpSocket listen_sock;
uint16_t port = 8888; // 监听端口
listen_sock.BuildListenSocketMethod(port, backlog); // 创建 + 绑定 + 监听
std::cout << "server listening on port " << port << std::endl;
// 2. 接受一个客户端连接(单连接版。想支撑多个客户端,请用多路转接,这里不展开)
std::string peerip;
uint16_t peerport = 0;
// accept 返回的是一个已经能直接收发数据的新 socket
Socket *newsock = listen_sock.AcceptConnection(&peerip, &peerport);
if (newsock == nullptr)
{
std::cerr << "accept failed" << std::endl;
return 1;
}
std::cout << "client connected: " << peerip << ":" << peerport << std::endl;
// 3. 维护一个"积累缓冲区":解决"一次 recv 读不到完整报文"的核心
std::string recv_buffer;
// 4. 主循环:循环读 -> 切分 -> 处理
while (true)
{
// 4.1 读一次原始字节,追加进积累缓冲区
if (newsock->Recv(&recv_buffer, 1024) == false)
{
// Recv 返回 false:对端关闭或出错,结束连接
std::cout << "client closed or error, exit" << std::endl;
break;
}
// 4.2 反复 Decode:一次读到的可能在缓冲里积累了多条完整报文
std::string message;
while (Protocol::Decode(recv_buffer, &message))
{
// 4.2.1 反序列化:JSON 字符串 -> Request 对象
Protocol::Request req;
if (!req.Deserialize(message))
{
std::cerr << "bad request json, skip" << std::endl;
continue; // 对端发来坏数据,跳过这一条,不崩
}
// 4.2.2 业务计算
int code = 0;
int result = Calc::Compute(req.GetX(), req.GetY(), req.GetOper(), &code);
// 4.2.3 构造响应对象并序列化成 JSON 字符串
Protocol::Response resp(result, code);
std::string json;
resp.Serialize(&json);
// 4.2.4 编码:给 JSON 串加上"长度前缀 + 分隔符",变成可上线的一条报文
std::string package = Protocol::Encode(json);
// 4.2.5 发送回客户端
newsock->Send(package);
std::cout << "ans: " << req.GetX() << " " << req.GetOper()
<< " " << req.GetY() << " = " << result
<< " (code=" << code << ")" << std::endl;
}
}
// 5. 善后
newsock->CloseSocket();
listen_sock.CloseSocket();
return 0;
}客户端 TcpClientMain.cc,主动连接并发一连串请求,再同步收响应:
// TcpClientMain.cc —— 客户端:连接服务器,发送一批请求,同步收结果
#include "Socket.hpp"
#include "Protocol.hpp"
#include <iostream>
#include <string>
#include <unistd.h> // usleep
using namespace Net_Work;
int main()
{
// 1. 创建客户端 socket 并连上服务器
TcpSocket client;
std::string serverip = "127.0.0.1"; // 本地回环,可改成远程 IP
uint16_t port = 8888;
if (!client.BuildConnectSocketMethod(serverip, port))
{
std::cerr << "connect failed" << std::endl;
return 1;
}
std::cout << "connected to " << serverip << ":" << port << std::endl;
// 2. 构造一批要发送的 (x, y, op)
struct Task { int x; int y; char op; };
Task tasks[] = {
{1, 2, '+'},
{7, 3, '-'},
{6, 7, '*'},
{20, 4, '/'},
{9, 2, '%'},
{8, 0, '/'}, // 故意触发一次除零,看服务器的错误码
};
std::string recv_buffer; // 客户端侧的积累缓冲区,同样要处理粘包
// 3. 循环发送请求、回收响应
for (auto &t : tasks)
{
// 3.1 构造 Request 并序列化
Protocol::Request req(t.x, t.y, t.op);
std::string request_json;
req.Serialize(&request_json);
// 3.2 编码(加长度前缀 + 分隔符)
std::string package = Protocol::Encode(request_json);
client.Send(package);
// 3.3 读响应。注意:一次 Recv 可能读多/读少,必须靠 Decode 循环切
bool got = false;
while (!got)
{
if (client.Recv(&recv_buffer, 1024) == false)
{
std::cerr << "connection closed by server" << std::endl;
return 1;
}
std::string response_json;
while (Protocol::Decode(recv_buffer, &response_json))
{
Protocol::Response resp;
resp.Deserialize(response_json);
std::cout << t.x << " " << t.op << " " << t.y
<< " = " << resp.GetResult()
<< " (code=" << resp.GetCode() << ")" << std::endl;
got = true; // 本轮任务已得到响应
}
}
usleep(200000); // 稍微停顿看清结果
}
return 0;
}编译:
# 两个入口分别编译;都要链 jsoncpp
g++ -std=c++17 TcpServerMain.cc -o server -ljsoncpp
g++ -std=c++17 TcpClientMain.cc -o client -ljsoncpp
# 先开一个终端跑服务器,再开另一个终端跑客户端
./server
./client运行后你会看到类似这样的输出:
// 服务器
server listening on port 8888
client connected: 127.0.0.1:52345
ans: 1 + 2 = 3 (code=0)
ans: 7 - 3 = 4 (code=0)
ans: 6 * 7 = 42 (code=0)
ans: 20 / 4 = 5 (code=0)
ans: 9 % 2 = 1 (code=0)
ans: 8 / 0 = 0 (code=1)
// 客户端
connected to 127.0.0.1:8888
1 + 2 = 3 (code=0)
7 - 3 = 4 (code=0)
6 * 7 = 42 (code=0)
20 / 4 = 5 (code=0)
9 % 2 = 1 (code=0)
8 / 0 = 0 (code=1)注意 8 / 0 那次:结果被算成 0 但 code=1,客户端依然能正确读出"出错状态码",说明我们的错误通道是可靠的——这正是协议里"状态码字段"存在的意义:结果和状态分开传递,结果不可用时用状态码说明原因。
到这里,一个端到端、能解决粘包、能正确序列化/反序列化的自定义协议已经完整落地。但作为"力求详尽正确"的博客,我们还有几块更深的硬骨头必须啃:二进制协议(包头+长度+负载)、网络字节序、字节对齐、自描述方案。它们每一个都是真实项目里会把你绊倒、而课堂往往一笔带过的地方。下面进入进阶分区。
进阶一:定制并理解"包头 + 长度 + 负载"的二进制协议
前面我们用的是"文本长度 + \r\n 分隔符"这种偏文本的报文。但很多高性能、嵌入式、或对带宽敏感的协议,用的是纯粹的二进制报文,格式是纲领性的:
| 包头(Header) | 长度(Length) | 负载(Payload) |这里的头部长度字段是指:在报文最前面放一个固定宽度的字段(通常 2~4 字节,用网络字节序存储),它代表"负载区"有多少字节。接收端先读这个长度,再按长度读负载。
与文本版相比,二进制版有两个必须面对的新概念:网络字节序和字节对齐。我们逐个讲透。
网络字节序:整数在两根网线之间的"方言"
先认识一个事实:同样一个整数,在不同 CPU 平台上的内存排列不一样。
- 内存从低地址到高地址依次放的是"高位字节在前",那叫大端字节序(big-endian),人类读十六进制最习惯,网络协议普遍采用这种;
- 如果放的是"低位字节在前",叫小端字节序(little-endian),我们日常用的 x86/x64 电脑、手机(ARM 多数为小端)全是小端。
举例:整数 0x01020304,用 int(4 字节)表示时:
大端内存: 01 02 03 04 (高字节在前)
小端内存: 04 03 02 01 (低字节在前)如果一台小端主机不转换、直接把 0x01020304 按二进制发出去,对端(无论大小端)如果按同样的规则原样读它,会读成 0x04030201——数值整个颠倒,协议就废了。
所以网络字节序的规矩是:穿插在跨主机传输中的所有多字节数值(长度、端口、整数绝对值等),发送端一律转成大端(网络序)后再发,接收端收到后转回自己的本地序再用。 这就是 htonl(host-to-network long)和 ntohl(network-to-host long)这两兄弟的职责。
#include <arpa/inet.h>
uint32_t host_val = 1000; // 我本机(小端)里的值
uint32_t net_val = htonl(host_val); // 转成网络字节序(大端)再上网络
uint32_t back = ntohl(net_val); // 对端收到网络序后转回本机序
// back 一定等于 1000类似的还有 htons/ntohs(专用于 16 位的 uint16_t)。凡是"输出到网络上的多字节字段"都记得套一层转换,凡是"从网络收进来的多字节字段"都记得反转换,这是二进制网络编程的铁律。我们前文 Socket.hpp 里 htons(port)、ntohs(peer.sin_port)、htonl 正是这个道理。
字节对齐与填充:结构体直接发送为什么会"串位"
很多人想到"我要发一个二进制报文",第一反应是"把我定义的结构体用 send 直接发出去不就行了?"——这个想法方向没错,但直接裸发会遇到三个大坑,最典型的是字节对齐导致的填充。
C 编译器为了 CPU 读内存更高效,会在结构体成员之间按对齐规则插入填充字节(padding)。例如:
struct S {
char c; // 偏移 0
int i; // 编译器会把它对齐到 4 字节边界 => 偏移变成 4,中间空出 3 字节
char d; // 偏移 8
};
// sizeof(S) 可能是 12,而不是 1+4+1=6如果你把这个结构体原封不动发到对端,对端收到的是带了填充的 12 字节——如果对端用同一个结构体解析,看起来"能用";但问题在于填充字节是不可预期的:
- 它对齐结果依赖编译器、CPU、平台、编译选项,A 机器算出的结构体布局和 B 机器很可能不一致;
- 一旦你换编译器、换平台,
sizeof(S)变了、偏移更了,同一批字节在不同机器上就会解析出完全不同的字段——结构体直接发送瞬间变成"串位灾难"。
所以二进制序列化的第一条准则是:要么序列化时逐字段手动打包(自己决定字节顺序),要么给结构体去掉填充让它严格按 1 字节对齐。让结构体按 1 字节对齐的手段是 #pragma pack(1)(MSVC)或 __attribute__((packed))(GCC/Clang):
// 1 字节对齐:成员之间不再自动插填充,结构体按声明的顺序紧挨着排
#pragma pack(push, 1)
struct BinaryRequest {
int32_t x; // 第一个加数
int32_t y; // 第二个加数
char oper; // 运算符
};
#pragma pack(pop)
// 现在 sizeof(BinaryRequest) == 9 (4+4+1),可预期、可控即便如此,字段内部的端序问题依然在——x 是 int32_t,还是得 htonl/ntohl。二进制协议的标准姿势是"结构体按 1 字节对齐 + 每个整数字段走网络字节序",两者缺一不可。
一个完整的二进制协议实现
下面是一个"包头+长度+负载"的完整自洽示例:先按 1 字节对齐定义包头与负载结构体(长度字段用网络序),给出打包(serialize)与解包(deserialize)函数,并演示端序转换。这段代码不依赖 jsoncpp,可独立编译运行,用来自测。
// binary_protocol_demo.cc —— 二进制协议:包头+长度+负载 + 网络字节序 + 1字节对齐
#include <iostream>
#include <cstring> // memcpy
#include <cstdint> // uint8_t/uint32_t
#include <arpa/inet.h> // htonl/ntohl
#include <string>
// 1 字节对齐:去掉编译器的自动填充,让结构体按声明的顺序紧凑排布
#pragma pack(push, 1)
// 包头:固定 8 字节。magic 用来校验"这确实是我们的协议",len 是负载字节数
struct PacketHeader {
uint32_t magic; // 魔数,比如 0xCAFEBABE,用于快速识别是不是我们的报文
uint32_t len; // 负载区长度,用网络字节序存
};
// 负载:我们要传输的"请求"数据。打包/解包时对 x、y 做端序转换
struct BinaryRequest {
int32_t x; // 第一个加数
int32_t y; // 第二个加数
uint8_t oper; // 运算符的 ASCII 码
};
#pragma pack(pop)
// 把一个"包头 + 负载"结构体编码成一串二进制字节(负责加入网络字节序)
std::string Pack(const PacketHeader &hdr, const BinaryRequest &req)
{
// 组装包头:正确转端序
PacketHeader nh = hdr;
nh.magic = hdr.magic; // 魔数通常直接用常量,也可不转端序(见文末说明)
nh.len = htonl(hdr.len); // 长度转成网络字节序后输出
// 组装负载:把整数转成网络字节序
BinaryRequest nr = req;
nr.x = htonl(req.x);
nr.y = htonl(req.y);
// 按固定字节布局拷贝进一个字节串
std::string out;
out.resize(sizeof(PacketHeader) + sizeof(BinaryRequest));
std::memcpy(&out[0], &nh, sizeof(PacketHeader)); // 先放飞包头
std::memcpy(&out[sizeof(PacketHeader)], &nr, sizeof(BinaryRequest)); // 再放飞负载
return out;
}
// 还原:把一坨字节解成 (包头, 负载),并把端序转回主机序
void Unpack(const std::string &raw, PacketHeader *hdr, BinaryRequest *req)
{
// 先取包头
std::memcpy(hdr, &raw[0], sizeof(PacketHeader));
// 再取负载(包头后面接着)
std::memcpy(req, &raw[sizeof(PacketHeader)], sizeof(BinaryRequest));
// 把网络字节序还原回主机字节序
hdr->len = ntohl(hdr->len); // 长度还原
req->x = ntohl(req->x); // x 还原
req->y = ntohl(req->y); // y 还原
}
int main()
{
// 构造一份请求:6 + 7 = ?
BinaryRequest req;
req.x = 6;
req.y = 7;
req.oper = '+';
// 手工写一份对应的包头
PacketHeader hdr;
hdr.magic = 0xCAFEBABE; // 自定义魔数
hdr.len = sizeof(BinaryRequest); // 负载长度 = 9
// 打包成字节,模拟"发到网络"
std::string wire = Pack(hdr, req);
std::cout << "wire size = " << wire.size() << " (应= 8+9=" << (sizeof(PacketHeader)+sizeof(BinaryRequest)) << ")" << std::endl;
// 接收端解包
PacketHeader rh;
BinaryRequest rr;
Unpack(wire, &rh, &rr);
std::cout << "magic = " << std::hex << rh.magic << std::dec << std::endl;
std::cout << "len = " << rh.len << " (应= 9)" << std::endl;
std::cout << "x = " << rr.x << std::endl;
std::cout << "y = " << rr.y << std::endl;
std::cout << "oper = " << char(rr.oper) << std::endl;
return 0;
}我把 Unpack 里没有同时做"根据 len 判断这条报文是否完整"这一步——这里为了聚焦"打包/解包 + 端序"而省略;真实网络场景中,接收方必须先用 hdr.len 判断"已有字节是否 >= len",再决定能否解析,这正是我们 Decode 里 package.size() < total 那一条的二进制同款。读者可以结合上一节自行补上这段完整性判断(也是很好的练习)。
编译运行:
g++ -std=c++17 binary_protocol_demo.cc -o binproto
./binproto
# 期望输出:x=6 y=7 oper=+ len=9 wire size=17这段代码把"结构体(二进制)序列化"这条路示范得明明白白:1 字节对齐 + 字段网络字节序 + 长度前缀。相比 JSON 文本方案,它更紧凑(17 字节 vs JSON 的几十上百字节)、更快,代价是肉眼不可读、跨语言要小心端序与对齐、并且改动字段就要同步两端。
一个关于"魔数(magic)怎么放"的小心点
我在 Pack 里 nh.magic = hdr.magic;(没转端序)。这里要澄清一个常见困惑:魔数到底要不要转端序? 魔数是"我们自己定的固定常量",只要发送和接收端看到的那串字节完全一致即可。既然两端都用同一个常量,你转不转端序,规则只要一致就能对得上。业界惯例是:有的协议魔数也统一按网络序存(方便抓包时按大端读),有的干脆不做转换。关键不是转不转,而是"发和收的规则必须一模一样"。为了减少困扰,我倾向建议:既然长度、数值都转了大端,那魔数也顺手转成大端,规则统一、抓包看着也一致。
进阶二:自描述协议——让报文"自己说"自己是啥
走到这,你可能已经完全掌握了"长度前缀"的报文。但它还有个不够聪明的地方:接收端要解析一条报文之前,得先知道"这条报文是什么类型"——是 Request 还是 Response?是登录请求还是消息推送?如果我们只有"长度前缀",那长度只能告诉接收端"读多长",却无法告诉它"读出来怎么解读"。
解决之道,是给报文再添一个"类型/名字"字段,让我们按类型决定怎么解析。这种"数据里额外携带元信息,让接收端无需外挂约定就能自己理解"的能力,就叫自描述(self-describing)。
最典型的自描述方案就是你前面已经用过的 JSON:{"datax":1,"oper":43,"datay":2} 里带着字段名,接收端看到"datax"就知道这是个数字、看到"oper"就知道它是运算符——JSON 天然自描述,因为它把"字段叫什么""值是什么类型"都写在文本里了。
即便不是 JSON,我们也可以为二进制/文本协议主动增加一个"协议类型码"字段。课件里就提到过一种更完备的报文范式:
"protocol_code\r\n" + "len\r\n" + 数据 + "\r\n"即报文不再只有一层长度前缀,而是"协议码 · 长度 · 数据"三段:接收端先读 protocol_code,就立刻知道自己接下来该用哪套规则解析后续报文。这让我们可以在同一条 TCP 连接上,纯靠一个字段就自由切换多种报文类型,而两端永远对齐。
自描述方案的最大价值是扩展性:新增一种报文类型,只要定义一个新的 protocol_code,两端只要都认识和理解这个码即可,老代码无需改动。这在成熟协议的版本兼容设计里尤其重要——这与"头部长度字段"一同构成了真实网络协议的两大支柱(长度负责划界,类型负责释义)。
到这里,三种序列化路线——结构体直发(二进制)、JSON文本、自描述——以及两种分界手段——定长/分隔/长度前缀、二进制包头——已经全部覆盖并有完整代码。最后,我们把这一路踩过的坑集中复盘一遍,然后进入思考题环节。
常见坑与边界复盘
设计任意应用层协议,下面每一条都是"过来人"用血泪换来的。逐条对照自己的代码,看看都留意到了没有。
坑 1:以为"recv 一次 = 一条报文"
这是粘包问题的头号认知错误。永远要假设 recv 读回来的可能是半条、一条、乃至多条。对策:维护"积累缓冲区",用 Decode 循环切分。
坑 2:长度字段的"符号"与"无符号"陷阱
std::string::size() 返回的是 size_t(unsigned long)。若直接写 if (package.size() < total),而 total 是 int,比较时 total 会被编译器转成无符号数——当 total 因解析出错变成负数时,这个负数转成无符号后会变成一个巨大的正数,比较结果荒谬。我们的 Decode 里专门写成 (int)package.size() < total,就是把两边都压到有符号来做,规避这个经典坑。
坑 3:std::stoi 抛异常
Decode 里 std::stoi(lens) 在 lens 不是合法数字(比如缓冲区里凑巧有个非法的 \r\n 分隔符被当成了长度字段)时会抛 std::invalid_argument 异常,直接让进程崩溃。真实项目里应当用 try/catch 包裹,并把"收到坏长度"当作一条非法报文处理(丢弃或报警)。课堂版为了聚焦主流程没加异常处理,读者务必知道这个隐患。(这也是很好的一个思考题,见文末。)
坑 4:字节对齐填充导致结构体直发"串位"
直接 send(结构体) 前,先确认结构体是否按 1 字节对齐(#pragma pack(1)/__attribute__((packed))),否则 sizeof 与成员偏移可能随编译器/平台变化,跨主机解析必然出错。
坑 5:多字节整数的端序
结构体里的 int、short、long 等所有多字节数值,跨主机传输必须 hton*/ntoh*。忘了做,数值就会在另一端颠倒。
坑 6:分隔符方案遇到"数据里恰好含分隔符"
用 \r\n 当边界时,若数据正文里恰有 \r\n,会把一条报文拦腰截断。对策:要么对正文做转义(数据里的分隔符换成别的写法,接收端再还原),要么改用"长度前缀"这种与内容无关的划界方式。
坑 7:长度前缀不校验就解析
拿到长度后必须先拿缓冲区累计字节数和它比一比,不够就继续攒、够了再切。少了这步,substr 会取到越界的空串或错误数据。
坑 8:send 可能没发完
send 返回的是"本次实际写入发送缓冲区的字节数",一次未必等于你给的 length。课堂版图省事忽略了这点,要求严格的程序应当循环发送直到发完(while(sent < len)),接收侧同理要处理"recv 返回 <=0 表示对端关闭"。
把这些坑记住,你的协议就基本"抗造"了。
思考题与详解
下面几道题把全文关键点串起来,每题都给了完整答案,建议先自己写一遍再看解析。
思考题 1:为什么说"粘包不是 TCP 的 bug"?那粘包到底是谁造成的?
答案: TCP 的定位是"可靠的字节流传输"——它只保证字节不丢、不重、顺序正确,作为连续的字节序列到达。它从来没有承诺过"一次 send 就对应一次 recv"或"报文有边界"。因此"没有边界"不是缺陷,而是流式传输的本质特性。粘包是应用层的问题:当应用层没有在字节流里约定划界规则时,接收端自然无法分辨"我这一把读到的字节里到底有几条业务报文"。职责划分是——TCP 负责可靠搬运,应用层负责切分。谁在应用层设计协议,谁就要承担提供"边界"的责任(定长、分隔符或长度前缀)。
思考题 2:分别说出"定长 / 分隔符 / 长度前缀"三种分包手段各自的优缺点,并给出各自最合适的场景。
答案:
- 定长:优点——接收方实现最简单,只需数够 N 字节即一条;缺点——报文过长要分片且不符"一条完整"语义,过短要填充、接收端要剔除填充,带宽浪费,不适用于长度差异大的场景。适用场景: 报文格式固定不变、长度差异小的数据采集(如定长传感器读数)。
- 分隔符:优点——实现直观、报文长度不限、人类易读(HTTP 头就是
<CRLF>分开);缺点——数据正文若含分隔符需转义,且每条报文要多付扫描成本。适用场景: 报文内容可控、几乎不会出现分隔符的文本协议(如日志、命令流)。 - 长度前缀:优点——报文与内容无关的可靠划界,无转义难题,字段固定时很紧凑,最主流;缺点——需要先读长度、判断完整性,逻辑比定长稍多。适用场景: 通用、健壮、待传内容多样的应用层协议(RPC、各类业务报文),也是课件采用、推荐自研协议的默认选择。
思考题 3:为什么 recv 一次可能读不到一条完整报文?"积累缓冲区"在其中扮演什么角色?
答案: 因为 TCP 是流协议,recv 能读到的字节数只取决于接收缓冲区此刻堆了多少,与"一条业务报文多长"无必然关系。可能出现一次只读到 "l(半包的零头),也可能一次读到好几条连在一起(粘包)。积累缓冲区的作用是:把所有 recv 到的原始字节都追加进同一个字符串,供 Decode 反复按"找分隔符 → 取长度 → 校验完整性"来"挤"出完整报文。一套"读 → 积累 → 解码 → 处理 → 循环读"的循环,配合"头部不足一条时继续等"的判定,就完美消解了"读不完整"和"读过头"两个方向的问题。
思考题 4:为什么"直接 send 一个结构体"在跨主机场景几乎必然出错?请至少说出两点原因。
答案:
- 字节对齐/填充不同:编译器会按 CPU 的访问偏好给结构体插入填充字节,
sizeof和成员偏移依赖编译器与平台。A 机填充后的布局,与 B 机不完全一致,双方按同一结构体读同一串字节会得到错位字段("串位")。需要#pragma pack(1)/__attribute__((packed))强制 1 字节对齐,才能得到两端一致、可预期的布局。 - 端序不同:Linux/x86 是大端还是小端因平台而异;哪怕结构体布局一致,把一个
int按内存字节直接发出,对端若端序相反会反着读,数值颠倒。需要在发送时对每个多字节字段做hton*、接收时做ntoh*(即统一走网络字节序)。
思考题 5:网络字节序到底指什么?为什么长度字段、整数绝对值都最好转网络序?
答案: 网络协议为消除"同一数值在不同 CPU 上内存排列不同(大端/小端)"带来的歧义,统一约定网络传输用 大端字节序(高位字节在前)。你的本机数值要送上网络,先按规则转成大端(htonl/htons),对端收到后再转回自己的本地序(ntohl/ntohs)。凡是会被跨主机解读的多字节数值(长度、端口、报文里的整数)都应遵守这一转换,否则数值在另一端会被反着读、彻底错掉。这也是二进制协议里每出现一个多字节整数都要套一层转换的原因。
思考题 6:为什么我们的 Decode 要把 package.size() 转成 int 再和 total 比较?
答案: size() 返回 size_t(无符号),若 total 是 int,直接比较 package.size() < total 时编译器会把 total 隐式转成 size_t。一旦 total 因为长度字段解析出错而变成一个负数,它转成无符号后会成为一个异常巨大的正数,导致 package.size() < total 恒为真(永远认为"还不够长"),函数永远不切,服务端逐步卡死。强制 (int)package.size() 把两边统一为有符号,负数就不会被"误装成巨无霸",比较结果才靠谱。
思考题 7:Decode 里 std::stoi(lens) 在什么情况下会抛异常?真实项目中该如何处理?
答案: 当 lens(第一个 \r\n 之前的子串)不是合法整数字面量时,std::stoi 会抛 std::invalid_argument;当它代表的数值超出 int 范围时会抛 std::out_of_range。这种情形可能由半包错位、或对端发来恶意/损坏数据触发。真实项目中应:先用 try/catch 捕获异常;异常即视为"本条不是合法报文",稳妥做法是将整段缓存作废/关闭连接,或至少跳过这段不可信数据并记录错误日志,而不能让异常炸掉整个进程。
思考题 8:JSON 序列化相比二进制序列化,有哪些取舍?什么场景用哪个?
答案: JSON 的优点是:自带字段名(自描述)、跨语言/跨平台天然友好(任何语言都有 JSON 库)、人可读可调试、开发效率高,所以 Web 后端、RPC 网关、配置与接口数据这类"讲究兼容与排查方便"的场景是主流。缺点是:体积大(带字段名与花括号)、解析开销高于二进制、明文可能泄露字段语义(若要保密还得加密)、浮点等类型在跨语言时会遇到精度/表示差异。二进制序列化(结构体直发)的取舍正好相反:体积小、速度快、紧凑,适合高性能/嵌入式/带宽受限的内网服务;缺点是跨语言要手动对齐、端序、版本管理,调试困难。现实工作中两者常并存:内网高频接口用二进制,对外/跨语言接口用 JSON,或用 Protobuf、Thrift 这类"编译期生成、兼有两者优点"的序列化框架(可视为本话题的进阶方向)。
思考题 9:如果要支持同一连接上同时传输"登录请求"和"心跳报文"两种不同格式,你会怎么设计报文格式?
答案: 用自描述 + 长度前缀的组合:在报文中增加一个"协议类型码(type)"字段,格式形如 type \r\n len \r\n data \r\n(或二进制版 type(2) + len(4) + data)。接收端先读 type,据 type 决定用哪套结构去解析 data;同时用 len 去判断 data 是否齐全。这样一条 TCP 连接上可自由混传多种协议,新增类型只需注册一个新 type 码并实现对应解析,老类型不受影响——这正是把"长度字段负责划界、类型字段负责释义"两大支柱合用的标准答案。若采用 JSON,则更是天然如此:type 作为一个 JSON 字段,data 按 type 字段去对应子结构。
思考题 10(动手题):把客户端改成"一次发两条请求、一次收两条响应",验证粘包代码是否真的稳。
答案提示: 把客户端里"发→收→发→收"的严格配对改成"一次性把两条 Encode 好的报文 send 出去,然后再循环 Recv+Decode 收两条"。你会发现:即便发送端把两条报文连在一起发出、接收端一次 Decode 读出两条,只要服务器与客户端的"积累缓冲区 + 循环 Decode"逻辑正确,两端依然能精确切出两条独立请求并分别响应——这正是长度前缀划界的威力,也是粘包解法正确性的实证。若你在实现中把"读一次假设只有一条"的错误代码写进去,两条报文就会粘连,客户端只会收到一条响应而卡死——这个对比正好能帮你真正理解 Decode 循环的价值。
写到这里,我们把"应用层自定义协议"这条线从头捋到了尾:从"协议就是双方约定好的结构化数据"的底层认知,到序列化/反序列化把内存结构与线上字节互转;从 Socket 的封装、Request/Response 的定义,到 jsoncpp 现成方案的落地;再重重讲透"TCP 是字节流、粘包/拆包怎么来、定长/分隔/长度前缀三种手段怎么选",最后补上了二进制协议里难啃的网络字节序、字节对齐,以及自描述方案的扩展性。
如果此刻你只想带走三句话,那就是:
- 协议 = 约定。只要两端"发"和"收"规则一致,用什么格式都行;但一条好协议,得同时回答"边界在哪(怎么切)"和"含义是什么(怎么解)"两个问题。
- 无论文本还是二进制,长度前缀 + 完整性校验,是解决流式字节粘包最稳妥的通用答案。
- 一旦跨主机,多字节整数必须走网络字节序,结构体直发必须管住字节对齐——这是两个最容易栽、但记住后一劳永逸的坑。
下一步顺理成章的方向有两个:一是把"单连接"升级成"高并发"——这就进入多路转接(select/poll/epoll)的世界;二是把"自己造轮子"换成熟框架——Protobuf、Thrift、以及真正意义上的 RPC 框架,它们在"序列化 + 划界 + 类型注册"上的工业级设计,值得你带着今天这套知识去审视。我们下一篇见。
还没有评论 — 第一条由你来留。