文章目录
-
- 应用层协议设计实战(一):自定义协议与网络计算器
- 一、为什么需要应用层协议
-
- [1.1 回顾我们之前写的Socket](#1.1 回顾我们之前写的Socket)
- [1.2 如何传输结构化数据](#1.2 如何传输结构化数据)
- [1.3 协议的本质](#1.3 协议的本质)
- 二、TCP全双工深度剖析
-
- [2.1 为什么一个fd既能读又能写](#2.1 为什么一个fd既能读又能写)
- [2.2 收发缓冲区的细节](#2.2 收发缓冲区的细节)
- [2.3 全双工的含义](#2.3 全双工的含义)
- [2.4 TCP为什么叫传输控制协议](#2.4 TCP为什么叫传输控制协议)
- 三、网络计算器协议设计
-
- [3.1 需求分析](#3.1 需求分析)
- [3.2 定义Request和Response结构体](#3.2 定义Request和Response结构体)
- [3.3 序列化与反序列化](#3.3 序列化与反序列化)
- 四、协议格式设计
-
- [4.1 报文边界问题](#4.1 报文边界问题)
- [4.2 解决方案:长度前缀](#4.2 解决方案:长度前缀)
- [4.3 为什么用\r\n](#4.3 为什么用\r\n)
- 五、Encode和Decode实现
-
- [5.1 Encode:编码(打包)](#5.1 Encode:编码(打包))
- [5.2 Decode:解码(拆包)](#5.2 Decode:解码(拆包))
-
- [5.2.1 关键点:判断报文完整性](#5.2.1 关键点:判断报文完整性)
- [5.2.2 处理粘包](#5.2.2 处理粘包)
- 六、Socket封装设计
-
- [6.1 为什么要封装Socket](#6.1 为什么要封装Socket)
- [6.2 设计模式:模板方法](#6.2 设计模式:模板方法)
- [6.3 TcpSocket实现](#6.3 TcpSocket实现)
-
- [6.3.1 Recv的特殊设计](#6.3.1 Recv的特殊设计)
- 七、本篇总结
-
- [7.1 核心要点](#7.1 核心要点)
- [7.2 容易混淆的点](#7.2 容易混淆的点)
应用层协议设计实战(一):自定义协议与网络计算器
💬 开篇:前面三篇把TCP编程从单连接到线程池全部讲完了,但都是Echo Server------客户端发什么,服务器原样返回什么,只是传输字符串。真实的网络应用要传输结构化数据,比如用户信息(姓名、年龄、地址)、订单数据(商品ID、数量、价格)、计算请求(操作数、运算符、结果)。如何把这些结构化数据通过网络传输?这就涉及应用层协议的设计。这一篇从一个网络计算器开始,从零设计一套应用层协议,包括协议格式、序列化方案、报文边界处理,深入理解TCP全双工原理和粘包问题的本质。
👍 点赞、收藏与分享:这篇会手把手教你设计自己的网络协议,从协议格式到代码实现,每个细节都讲清楚。如果对你有帮助,请点赞收藏!
🚀 循序渐进:从为什么需要协议开始,到TCP全双工的底层原理,到协议格式设计,到Encode/Decode实现,到Socket封装,一步步构建完整的协议栈。
一、为什么需要应用层协议
1.1 回顾我们之前写的Socket
回顾之前的Echo Server,客户端发送:
cpp
std::string msg = "Hello Server";
write(sockfd, msg.c_str(), msg.size());
服务器接收:
cpp
char buffer[1024];
read(sockfd, buffer, sizeof(buffer));
read和write的本质是字节流传输,发送方写入一段字节序列,接收方读取一段字节序列。没有任何结构信息。
1.2 如何传输结构化数据
现在要实现一个网络计算器:客户端发送两个数和一个运算符,服务器计算结果并返回。
客户端需要发送的数据:
cpp
int x = 10;
int y = 20;
char op = '+';
这是三个变量,怎么通过write发送?不能这样:
cpp
write(sockfd, &x, sizeof(x));
write(sockfd, &y, sizeof(y));
write(sockfd, &op, sizeof(op));
因为服务器的read可能一次读取所有数据,也可能分多次读取,无法确定边界。而且直接传内存地址,涉及大小端问题、内存对齐问题,不同平台不兼容。
1.3 协议的本质
协议就是通信双方约定好的数据格式和处理规则。
有了协议,发送方知道如何把结构化数据编码成字节流,接收方知道如何把字节流解码成结构化数据。
两种常见方案:
方案一:自定义文本协议
bash
客户端发送:"10+20"
服务器解析:找到'+'号,前面是x,后面是y
优点:简单直观,易于调试(抓包能直接看懂)
缺点:解析复杂(要处理各种边界情况),扩展性差(增加字段要改协议)
方案二:结构体+序列化
cpp
struct Request {
int x;
int y;
char op;
};
// 序列化:结构体 → 字符串
std::string Serialize(Request &req);
// 反序列化:字符串 → 结构体
Request Deserialize(std::string &s);
优点:结构清晰,易于扩展,成熟方案(JSON、Protobuf、XML)支持
缺点:需要序列化库,略微增加复杂度
我们采用方案二,用Jsoncpp库做序列化。
二、TCP全双工深度剖析
2.1 为什么一个fd既能读又能写
UDP要用recvfrom和sendto,每次都要指定对方地址。
TCP只用一个sockfd,既能read也能write,不需要指定地址。为什么?
答案:TCP连接建立后,内核为这个连接分配了发送缓冲区和接收缓冲区。
bash
客户端主机:
应用层:write(sockfd, data, len)
↓
发送缓冲区(send buffer)
↓
TCP协议栈:打包成TCP段,加上序列号、确认号等
↓
网络层:加上IP头
↓
网卡:发送到网络
服务器主机:
网卡:接收数据包
↓
网络层:解析IP头
↓
TCP协议栈:解析TCP段,确认、排序、去重
↓
接收缓冲区(recv buffer)
↓
应用层:read(sockfd, buffer, len)
2.2 收发缓冲区的细节
每个TCP连接在内核中有两个缓冲区:
| 缓冲区 | 作用 | 大小 |
|---|---|---|
| 发送缓冲区 | 应用层write的数据先放这里,TCP协议栈从这里取数据发送 | 默认16KB-64KB |
| 接收缓冲区 | TCP协议栈收到的数据放这里,应用层read从这里取 | 默认16KB-64KB |
write的过程:
bash
1. 应用层调用write(sockfd, data, len)
2. 内核把data拷贝到发送缓冲区
3. write立刻返回(只要缓冲区还有空间)
4. TCP协议栈从发送缓冲区取数据,打包成TCP段发送
5. 收到对方ACK后,清空缓冲区对应的数据
read的过程:
bash
1. TCP协议栈收到TCP段,放入接收缓冲区
2. 应用层调用read(sockfd, buffer, len)
3. 内核从接收缓冲区拷贝数据到buffer
4. read返回实际读取的字节数
2.3 全双工的含义
全双工(Full Duplex):通信双方可以同时发送和接收数据。
TCP支持全双工,因为:
- 每个主机都有发送缓冲区和接收缓冲区
- 发送和接收是独立的(不同的缓冲区、不同的序列号)
示例:
bash
客户端:
线程1:while(true) { write(sockfd, ...); } // 一直发送
线程2:while(true) { read(sockfd, ...); } // 一直接收
服务器:
线程1:while(true) { read(sockfd, ...); } // 一直接收
线程2:while(true) { write(sockfd, ...); } // 一直发送
四个线程同时运行,互不影响。
2.4 TCP为什么叫传输控制协议
应用层调用write后,数据只是被拷贝到发送缓冲区,什么时候真正发送、发送多少、如果丢包怎么办,全部由TCP协议栈控制。
应用层无法控制:
- 何时发送(TCP有Nagle算法,可能攒够一批再发)
- 发送多少(TCP有滑动窗口,根据网络状况调整)
- 重传机制(TCP自动重传丢失的数据包)
- 流量控制(TCP根据接收方缓冲区调整发送速度)
- 拥塞控制(TCP根据网络拥塞程度调整发送速度)
这就是为什么TCP叫"传输控制协议"(Transmission Control Protocol)。
三、网络计算器协议设计
3.1 需求分析
功能:客户端发送两个数和一个运算符,服务器计算结果并返回。
请求数据:
- 第一个操作数(int)
- 第二个操作数(int)
- 运算符(char):+、-、*、/、%
响应数据:
- 计算结果(int)
- 状态码(int):0表示成功,1表示除零错误,2表示非法运算符等
3.2 定义Request和Response结构体
cpp
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; // 运算符
};
class Response
{
public:
Response() : _result(0), _code(0) {}
Response(int result, int code) : _result(result), _code(code) {}
int GetResult() { return _result; }
int GetCode() { return _code; }
void SetResult(int res) { _result = res; }
void SetCode(int code) { _code = code; }
private:
int _result; // 计算结果
int _code; // 状态码:0成功,1除零,2非法运算符
};
这两个类封装了请求和响应的数据。但现在它们是C++对象,无法直接通过网络传输。
3.3 序列化与反序列化
序列化(Serialize):把C++对象转换成字符串。
反序列化(Deserialize):把字符串转换成C++对象。
为Request和Response添加序列化方法:
cpp
class Request
{
public:
bool Serialize(std::string *out); // 序列化
bool Deserialize(std::string &in); // 反序列化
// ...
};
class Response
{
public:
bool Serialize(std::string *out);
bool Deserialize(std::string &in);
// ...
};
具体实现用Jsoncpp库(下一篇详细讲),这里先理解概念。
四、协议格式设计
4.1 报文边界问题
假设Request序列化后是:{"datax":10,"datay":20,"oper":43}(JSON格式)
客户端连续发送两个请求:
cpp
std::string req1 = "{\"datax\":10,\"datay\":20,\"oper\":43}";
std::string req2 = "{\"datax\":5,\"datay\":3,\"oper\":45}";
write(sockfd, req1.c_str(), req1.size());
write(sockfd, req2.c_str(), req2.size());
服务器接收:
cpp
char buffer[1024];
int n = read(sockfd, buffer, sizeof(buffer));
可能读到:
bash
"{\"datax\":10,\"datay\":20,\"oper\":43}{\"datax\":5,\"datay\":3,\"oper\":45}"
两个JSON粘在一起了!服务器如何知道第一个请求在哪里结束,第二个请求从哪里开始?
这就是粘包问题------TCP是字节流协议,不保留消息边界。
4.2 解决方案:长度前缀
在每个报文前加上长度信息:
bash
协议格式:"len\r\nmessage\r\n"
- len:报文长度(不包括len自己和分隔符)
- \r\n:分隔符(用于区分长度字段和报文内容)
- message:实际的JSON内容
- \r\n:报文结束标记
示例:
bash
请求1的JSON:{"datax":10,"datay":20,"oper":43}
长度:36
完整报文:"36\r\n{"datax":10,"datay":20,"oper":43}\r\n"
请求2的JSON:{"datax":5,"datay":3,"oper":45}
长度:33
完整报文:"33\r\n{"datax":5,"datay":3,"oper":45}\r\n"
发送时,两个报文连在一起:
bash
"36\r\n{"datax":10,"datay":20,"oper":43}\r\n33\r\n{"datax":5,"datay":3,"oper":45}\r\n"
接收方解析:
- 读到第一个
\r\n,前面是长度"36" - 再读36字节,得到第一个JSON
- 读到第二个
\r\n,第一个报文结束 - 重复上述步骤,解析第二个报文
4.3 为什么用\r\n
\r\n是回车换行:
\r(Carriage Return,CR,ASCII码13)\n(Line Feed,LF,ASCII码10)
\r\n 只用于分隔"长度字段"和"消息体",消息体的边界由长度决定,因此消息体里即使包含 \r\n 也不会影响解析。
很多网络协议都用\r\n作为分隔符(HTTP、SMTP、FTP等),原因:
- 人类可读(抓包时能看到换行)
- 历史传统(早期电传打字机需要回车+换行两个动作)
也可以用其他分隔符(如\n、\0、自定义字符),只要双方约定好即可。
五、Encode和Decode实现
5.1 Encode:编码(打包)
cpp
const std::string LineBreakSep = "\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;
}
流程:
bash
输入:message = "{\"datax\":10,\"datay\":20,\"oper\":43}"
步骤1:计算长度 len = "36"
步骤2:拼接 package = "36" + "\r\n" + "{...}" + "\r\n"
输出:"36\r\n{\"datax\":10,\"datay\":20,\"oper\":43}\r\n"
5.2 Decode:解码(拆包)
cpp
bool Decode(std::string &package, std::string *message)
{
// 步骤1:找到第一个\r\n
auto pos = package.find(LineBreakSep);
if (pos == std::string::npos)
return false; // 没找到,说明数据不完整
// 步骤2:提取长度字段
std::string lens = package.substr(0, pos);
int messagelen = std::stoi(lens);
// 步骤3:计算完整报文的总长度
int total = lens.size() + messagelen + 2 * LineBreakSep.size();
// 步骤4:检查package是否包含完整报文
if (package.size() < total)
return false; // 数据不完整,等待更多数据
// 步骤5:提取message部分
*message = package.substr(pos + LineBreakSep.size(), messagelen);
// 步骤6:从package中删除已处理的报文
package.erase(0, total);
return true;
}
5.2.1 关键点:判断报文完整性
假设接收缓冲区当前内容是:
bash
"36\r\n{\"datax\":10,\"da
这是一个不完整的报文(只收到了一部分)。
Decode的处理:
bash
步骤1:找到\r\n,pos=2
步骤2:lens="36",messagelen=36
步骤3:total=2+36+4=42(长度字段2字节 + 报文36字节 + 两个\r\n共4字节)
步骤4:package.size()=18 < 42,数据不完整
步骤5:return false,不做任何处理,等待更多数据
下次调用Decode时,缓冲区可能已经收到完整数据:
bash
"36\r\n{\"datax\":10,\"datay\":20,\"oper\":43}\r\n"
此时:
bash
步骤4:package.size()=42 >= 42,数据完整
步骤5:提取message="{\"datax\":10,\"datay\":20,\"oper\":43}"
步骤6:删除已处理的42字节,package变成空字符串
步骤7:return true
5.2.2 处理粘包
假设缓冲区一次性收到了两个完整报文:
bash
"36\r\n{\"datax\":10,\"datay\":20,\"oper\":43}\r\n33\r\n{\"datax\":5,\"datay\":3,\"oper\":45}\r\n"
第一次调用Decode:
bash
提取第一个报文:"{\"datax\":10,\"datay\":20,\"oper\":43}"
删除前42字节,package变成:"33\r\n{\"datax\":5,\"datay\":3,\"oper\":45}\r\n"
return true
第二次调用Decode:
bash
提取第二个报文:"{\"datax\":5,\"datay\":3,\"oper\":45}"
删除前39字节,package变成空字符串
return true
第三次调用Decode:
bash
package为空,找不到\r\n
return false
所以Decode要在循环中调用:
cpp
std::string package; // 接收缓冲区
while (true) {
// 从socket读取数据,追加到package
char buffer[1024];
int n = read(sockfd, buffer, sizeof(buffer));
if (n > 0) {
buffer[n] = 0;
package += buffer;
}
// 循环解包
std::string message;
while (Decode(package, &message)) {
// 处理一个完整的报文
ProcessMessage(message);
}
}
六、Socket封装设计
6.1 为什么要封装Socket
之前的代码每次都要写:
cpp
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
bind(...);
listen(...);
accept(...);
如果有多个服务器,就要重复这些代码。
封装的好处:
- 代码复用,减少重复
- 接口统一,易于维护
- 隐藏细节,降低出错概率
6.2 设计模式:模板方法
定义一个抽象基类Socket,声明虚函数接口:
cpp
class Socket
{
public:
virtual ~Socket() {}
virtual void CreateSocketOrDie() = 0;
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;
virtual void SetSockFd(int sockfd) = 0;
virtual void CloseSocket() = 0;
virtual bool Recv(std::string *buffer, int size) = 0;
virtual void Send(std::string &send_str) = 0;
};
定义"模板方法"(Template Method),封装固定的流程:
cpp
class Socket
{
public:
void BuildListenSocketMethod(uint16_t port, int backlog)
{
CreateSocketOrDie();
BindSocketOrDie(port);
ListenSocketOrDie(backlog);
}
bool BuildConnectSocketMethod(std::string &serverip, uint16_t serverport)
{
CreateSocketOrDie();
return ConnectServer(serverip, serverport);
}
void BuildNormalSocketMethod(int sockfd)
{
SetSockFd(sockfd);
}
};
模板方法的优势:
- 服务器调用
BuildListenSocketMethod,自动完成socket→bind→listen三步 - 客户端调用
BuildConnectSocketMethod,自动完成socket→connect两步 - accept返回的新fd调用
BuildNormalSocketMethod,直接设置fd
6.3 TcpSocket实现
继承Socket,实现具体的TCP操作:
cpp
class TcpSocket : public Socket
{
public:
TcpSocket(int sockfd = -1) : _sockfd(sockfd) {}
void CreateSocketOrDie() override
{
_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
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;
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(port);
int n = ::bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if (n < 0)
exit(BindError);
}
void ListenSocketOrDie(int backlog) override
{
int n = ::listen(_sockfd, backlog);
if (n < 0)
exit(ListenError);
}
Socket *AcceptConnection(std::string *peerip, uint16_t *peerport) override
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int newsockfd = ::accept(_sockfd, (struct sockaddr*)&peer, &len);
if (newsockfd < 0)
return nullptr;
*peerport = ntohs(peer.sin_port);
*peerip = inet_ntoa(peer.sin_addr);
return new TcpSocket(newsockfd);
}
// 其他方法...
private:
int _sockfd;
};
6.3.1 Recv的特殊设计
cpp
bool Recv(std::string *buffer, int size) override
{
char inbuffer[size];
ssize_t n = recv(_sockfd, inbuffer, size-1, 0);
if (n > 0) {
inbuffer[n] = 0;
buffer->append(inbuffer, n); // 故意拼接
return true;
}
else if (n == 0) return false; // 对端关闭
else return false; // 错误
}
注意buffer->append(inbuffer, n)------这是故意拼接的。
为什么?因为一次recv可能读不完一个完整报文,需要多次recv拼接:
bash
第一次recv:读到"36\r\n{\"datax\":10"
第二次recv:读到",\"datay\":20,\"oper\":43}\r\n"
拼接后:"36\r\n{\"datax\":10,\"datay\":20,\"oper\":43}\r\n"
然后调用Decode解析完整报文。
七、本篇总结
7.1 核心要点
为什么需要应用层协议:
- Socket只能传字节流,无法直接传结构化数据
- 需要序列化(对象→字符串)和反序列化(字符串→对象)
- 需要定义协议格式,明确报文边界
TCP全双工原理:
- 每个TCP连接有发送缓冲区和接收缓冲区
- write把数据拷贝到发送缓冲区,read从接收缓冲区读取
- 发送和接收独立,可以同时进行
- TCP协议栈控制何时发送、重传、流量控制、拥塞控制
协议格式设计:
- 长度前缀方案:"len\r\nmessage\r\n"
- len表示message的字节数
- \r\n作为分隔符,区分长度字段和报文内容
- 能够处理粘包和半包问题
Encode/Decode实现:
- Encode:计算长度→拼接格式化字符串
- Decode:提取长度→检查完整性→提取message→删除已处理数据
- Decode要循环调用,处理缓冲区中的所有完整报文
Socket封装:
- 模板方法模式,封装固定流程
- BuildListenSocketMethod:服务器初始化
- BuildConnectSocketMethod:客户端连接
- Recv故意拼接,方便处理半包
7.2 容易混淆的点
-
read/write和收发缓冲区的关系:write不是直接发送到网络,而是拷贝到发送缓冲区;read不是直接从网络读取,而是从接收缓冲区读取。
-
为什么一个fd能读又能写:因为内核为这个连接维护了两个独立的缓冲区,读和写操作的是不同的缓冲区。
-
Decode为什么要检查package.size() < total:因为TCP是流式的,可能只收到一部分数据,必须等数据完整后再解析。
-
为什么Recv要拼接:因为一次recv可能只读到半个报文,需要多次recv拼接成完整报文后再Decode。
-
\r\n算不算报文的一部分:不算,它是协议分隔符,Decode时会删除。
-
粘包和半包的区别:粘包是多个报文粘在一起,半包是一个报文被拆成多次接收。Decode同时处理这两种情况。
💬 总结:这一篇从应用层协议的必要性讲起,深入剖析了TCP全双工的底层原理(收发缓冲区、为什么一个fd能读又能写),设计了网络计算器的协议格式(长度前缀+分隔符),实现了Encode/Decode来处理报文边界和粘包问题,最后用模板方法模式封装了Socket类。理解了这些基础,下一篇就能实现完整的网络计算器,包括Jsoncpp序列化、Factory工厂模式、完整的TcpServer和TcpClient。
👍 点赞、收藏与分享:如果这篇帮你理解了应用层协议的设计思路,请点赞收藏!下一篇会把所有代码都实现出来,完成一个真正可用的网络计算器!