【Linux】应用层协议设计实战(一):自定义协议与网络计算器

文章目录

    • 应用层协议设计实战(一):自定义协议与网络计算器
    • 一、为什么需要应用层协议
      • [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));

readwrite的本质是字节流传输,发送方写入一段字节序列,接收方读取一段字节序列。没有任何结构信息。

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要用recvfromsendto,每次都要指定对方地址。

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"

接收方解析:

  1. 读到第一个\r\n,前面是长度"36"
  2. 再读36字节,得到第一个JSON
  3. 读到第二个\r\n,第一个报文结束
  4. 重复上述步骤,解析第二个报文

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 容易混淆的点

  1. read/write和收发缓冲区的关系:write不是直接发送到网络,而是拷贝到发送缓冲区;read不是直接从网络读取,而是从接收缓冲区读取。

  2. 为什么一个fd能读又能写:因为内核为这个连接维护了两个独立的缓冲区,读和写操作的是不同的缓冲区。

  3. Decode为什么要检查package.size() < total:因为TCP是流式的,可能只收到一部分数据,必须等数据完整后再解析。

  4. 为什么Recv要拼接:因为一次recv可能只读到半个报文,需要多次recv拼接成完整报文后再Decode。

  5. \r\n算不算报文的一部分:不算,它是协议分隔符,Decode时会删除。

  6. 粘包和半包的区别:粘包是多个报文粘在一起,半包是一个报文被拆成多次接收。Decode同时处理这两种情况。


💬 总结:这一篇从应用层协议的必要性讲起,深入剖析了TCP全双工的底层原理(收发缓冲区、为什么一个fd能读又能写),设计了网络计算器的协议格式(长度前缀+分隔符),实现了Encode/Decode来处理报文边界和粘包问题,最后用模板方法模式封装了Socket类。理解了这些基础,下一篇就能实现完整的网络计算器,包括Jsoncpp序列化、Factory工厂模式、完整的TcpServer和TcpClient。
👍 点赞、收藏与分享:如果这篇帮你理解了应用层协议的设计思路,请点赞收藏!下一篇会把所有代码都实现出来,完成一个真正可用的网络计算器!

相关推荐
allway28 小时前
基于华为taishan200服务器、arm架构kunpeng920 cpu的虚拟化实战
linux·运维·服务器
Junlan278 小时前
Cursor使用入门及连接服务器方法(更新中)
服务器·人工智能·笔记
CSCN新手听安8 小时前
【linux】高级IO,I/O多路转接之poll,接口和原理讲解,poll版本的TCP服务器
linux·运维·服务器·c++·计算机网络·高级io·poll
熊文豪8 小时前
服务器炸了才知道?Ward+cpolar让异常无处藏
运维·服务器·cpolar
杜子不疼.8 小时前
【Linux】教你在 Linux 上搭建 Web 服务器,步骤清晰无门槛
linux·服务器·前端
荔枝吻8 小时前
忘记服务器密码,在Xshell7中查看已保存密码
运维·服务器·github
码农阿豪8 小时前
多服务器批量指令管理:从Xshell到自动化运维
运维·服务器·自动化
Pocker_Spades_A8 小时前
在家也能连公司服务器写代码?GoLand+CPolar 远程开发实测
运维·服务器
CSCN新手听安8 小时前
【linux】网络基础(三)TCP服务端网络版本计算器的优化,Json的使用,服务器守护进程化daemon,重谈OSI七层模型
linux·服务器·网络·c++·tcp/ip·json