【Linux网络】TCP 半包粘包怎么解决?从长度报头、JSON 拆包到网络计算器完整实战

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

  • 前言
  • [一、JSON 消息也需要边界](#一、JSON 消息也需要边界)
  • 二、按长度提取完整消息
    • [2.1 Protocol.hpp](#2.1 Protocol.hpp)
    • [2.2 为什么必须保留 buffer](#2.2 为什么必须保留 buffer)
      • [沿着一条消息看 buffer 怎么变](#沿着一条消息看 buffer 怎么变)
    • [2.3 客户端不能读到任意字节就返回成功](#2.3 客户端不能读到任意字节就返回成功)
    • [2.4 为什么要限制长度](#2.4 为什么要限制长度)
  • [三、计算业务不处理 Socket](#三、计算业务不处理 Socket)
  • 四、服务器接到连接后交给协议层
  • 五、客户端发送请求并等待应答
    • [5.1 对象创建与简单工厂](#5.1 对象创建与简单工厂)
  • 六、把各层串起来
    • [6.1 文件与编译关系](#6.1 文件与编译关系)
    • [6.2 从编译到发出一次请求](#6.2 从编译到发出一次请求)
    • [6.3 出问题时先分清停在哪一层](#6.3 出问题时先分清停在哪一层)
    • [6.4 拆包函数与实际 TCP 收发分别检查](#6.4 拆包函数与实际 TCP 收发分别检查)
      • [先明确检查 Decode 的输入](#先明确检查 Decode 的输入)
      • [再通过真正的 Socket 发请求](#再通过真正的 Socket 发请求)
  • 总结

前言

JSON 解决了字段怎样表示,但还没有解决一次 read 到底读了多少条消息。这里继续沿用长度报头,把发送、拆包与计算过程连起来。

下面的网络计算器已在 Ubuntu、Linux 5.15.0-181-generic、GCC 11.4.0、JsonCpp 1.9.5-3 上编译运行,使用 C++17。实际发出 10+20 和除零等请求,分别收到结果 30、状态 0,以及状态 1 的应答;一条报文分次写入、两条报文一起写入也完成了往返。另对 Encode 生成的报文逐个切分位置检查 Decode,确认半包不丢失、完整消息能依次取出。后面把这些现象对应到具体代码。

一、JSON 消息也需要边界

JSON 串通过 TCP 传递时,一次读取可能只得到半条消息,也可能得到一条、多条,甚至一条半。read/recv 返回多少字节,与应用一次发送了几条请求没有固定对应关系。

接收时机、已到达的数据量和本次缓冲区大小等,都会影响读取结果。不需要等缓冲区快满,才会遇到半条消息。

多条消息一次读到,通常称为"粘包";一条消息分多次读到,常称为"半包"。这些都是字节流的正常表现。接收端要做的是累计字节,按双方的协议找到边界,不能拿到什么就立即当作一条 JSON 解析。

所以我们进一步定制协议。以客户端为例,我们可以规定客户端发来的请求不能只包含json串,还需要在json串头部包含表示整个json有效载荷的长度字段。为了能够正确处理请求,我们要求在长度和json串中间使用\r\n进行区分。为了有更好的可读性,要求在json串结尾也要带\r\n。

所以完整报文示例如下:

text 复制代码
26\r\n{"x":10,"y":20,"oper":"+"}\r\n

这里的 \r\n 表示回车、换行两个实际字节,不是把反斜线和字母原样发出去。

长度字段使用十进制文本。先找到它后面的 CRLF,就知道长度写完了;再按这个长度取正文,最后检查结尾的 CRLF。**长度只统计正文,不包含外层分隔符。**请求和应答使用相同的封包规则。

为什么把网络计算器拆成 Socket、协议和业务几层?因为它们处理的问题不同:Socket 管字节收发,协议层管边界和字段,计算模块管运算结果。

这里借用分层思路理解软件结构,并不意味着 C++ 类与 OSI 层一一对应,也不是说操作系统只能支持一种应用协议。不同业务可以复用相同的网络收发代码。

文件保存也有类似问题。连续写入多个对象的字段后,读取方同样需要知道每条记录从哪里开始、到哪里结束。可以先序列化,再把长度和正文一起写入,读取时按相反顺序恢复。

不过,直接保存链表对象的指针并没有意义;应该保存实际字段与关系。文件和 Socket 都能读写字节,并不意味着它们的断开、错误和等待行为完全相同。

二、按长度提取完整消息

这里的 length 统计实际正文的字节数,不是中文字符数,也不包括外层的两个 CRLF。示例里的加号写成 JSON 字符串时,紧凑正文 {"x":10,"y":20,"oper":"+"} 实际是 26 个 ASCII 字节;实际项目采用整数 oper,FastWriter 还可能带末尾换行,因此不要手填长度,一律用 body.size()。

接收缓存可能只有长度的一半,也可能已经有五条半消息。处理顺序如下:

  1. 找到长度后的 CRLF;找不到则继续读。
  2. 校验长度只包含数字,并检查上限。
  3. 计算整帧长度:长度字段字符数 + 2 + 正文长度 + 2。
  4. 字节不够时保留缓存,等待后续数据。
  5. 字节足够时检查末尾 CRLF,提取一条正文。
  6. 只移除这一帧,然后继续检查剩余缓存。

**报文不完整和报文不合法不能混为一谈。**前者继续等待,后者不能无限等待。本例用 false 表示还没收齐,用异常报告格式错误,由连接处理入口捕获并结束本次服务。

2.1 Protocol.hpp

这个头文件依赖前两篇的 Socket.hpp 和 Message.hpp。**Encode/Decode 只处理外层长度与正文,不判断正文是不是合法计算请求。**GetRequest 把拆包、反序列化和业务调用连接起来;GetResponse 则供客户端取出一条应答。

两个 while 各有用途:外层继续接收字节,内层尽量处理缓存里已经完整的消息。内层停止,不一定是连接结束,也可能只是剩下了半条消息。

cpp 复制代码
#pragma once
#include "Socket.hpp"
#include "Message.hpp"
#include <functional>
#include <stdexcept>
#include <utility>

using namespace SocketModule;
using func_t = std::function<Response(Request &)>;

class Protocol
{
public:
    Protocol() = default;
    explicit Protocol(func_t handler) : _func(std::move(handler)) {}

    std::string Encode(const std::string &body)
    {
        if (body.size() > max_body) throw std::length_error("frame too large");
        return std::to_string(body.size()) + "\r\n" + body + "\r\n";
    }

    bool Decode(std::string &buffer, std::string *body)
    {
        auto end = buffer.find("\r\n");
        if (end == std::string::npos) {
            if (buffer.size() > 11) throw std::runtime_error("invalid header");
            return false;
        }
        if (end == 0 || end > 10) throw std::runtime_error("invalid length");
        std::size_t length = 0;
        for (std::size_t index = 0; index < end; ++index) {
            char digit = buffer[index];
            if (digit < '0' || digit > '9') throw std::runtime_error("invalid length");
            std::size_t value = static_cast<std::size_t>(digit - '0');
            if (length > (max_body - value) / 10)
                throw std::runtime_error("frame too large");
            length = length * 10 + value;
        }
        std::size_t total = end + 2 + length + 2;
        if (buffer.size() < total) return false;
        if (buffer.compare(end + 2 + length, 2, "\r\n") != 0)
            throw std::runtime_error("invalid frame ending");
        *body = buffer.substr(end + 2, length);
        buffer.erase(0, total);
        return true;
    }

    void GetRequest(std::shared_ptr<Socket> &sock, InetAddr &client)
    {
        std::string buffer;
        try {
            while (sock->Recv(&buffer) > 0) {
                std::string body;
                while (Decode(buffer, &body)) {
                    Request request;
                    if (!request.Deserialize(body)) {
                        LOG(LogLevel::WARNING) << client.StringAddr() << " invalid request";
                        return;
                    }
                    Response response = _func(request);
                    if (sock->Send(Encode(response.Serialize())) < 0) return;
                }
            }
        } catch (const std::exception &error) {
            LOG(LogLevel::WARNING) << client.StringAddr() << " " << error.what();
        }
    }

    bool GetResponse(std::shared_ptr<Socket> &client,
                     std::string &buffer, Response *response)
    {
        try {
            while (true) {
                std::string body;
                // 先处理已有缓存,半包继续读,成功时只取一条应答。
                if (Decode(buffer, &body)) return response->Deserialize(body);
                if (client->Recv(&buffer) <= 0) return false;
            }
        } catch (const std::exception &error) {
            std::cerr << "protocol error: " << error.what() << '\n';
            return false;
        }
    }

    std::string BuildRequestString(int left, int right, char operation)
    {
        Request request(left, right, operation);
        return Encode(request.Serialize());
    }

private:
    static constexpr std::size_t max_body = 1024 * 1024;
    func_t _func;
};

2.2 为什么必须保留 buffer

**buffer 属于一条连接,必须跨多次 Recv 存活。**把它放在循环内部,每轮重新定义,刚保存的半包就会丢失;把所有客户端共用一份 buffer,则会把不同连接的数据混到一起。

完整消息取出后,erase 只删除对应的前缀。后面剩下的一条半、半个长度字段甚至单独一个回车,都必须留到下一轮继续处理。

沿着一条消息看 buffer 怎么变

还是看上面的 26 字节示例。如果先读到 26\r,报头的 CRLF 尚未完整,Decode 返回 false,缓存原样保留。后续补齐 \n 和一部分正文,长度虽然知道了,但整帧字节数仍不够,也不能提前解析 JSON。

**整帧长度 = 长度字段自身的字节数 + 2 + 正文字节数 + 2。**这个示例是 2+2+26+2,共 32 字节。只有缓存至少包含这些字节,并且帧尾正确,才能提取正文。

如果一次收到了两帧,第一轮只移除前 32 字节,第二轮从剩下的报头继续判断。不要直接清空 buffer,否则第二帧会一起丢掉。这里是在推演解析过程,网络实际分几次返回由运行时情况决定。

2.3 客户端不能读到任意字节就返回成功

客户端的 GetResponse 先检查缓存是否已经包含完整应答;只有 Decode 成功且反序列化成功,才返回 true。如果这次只收到半个长度字段,就继续 Recv。

每次只取一条应答,剩余内容留在缓存里。否则在一个循环中不断覆盖同一个 Response,调用方最后只能看到最后一条结果。

2.4 为什么要限制长度

长度来自对端,不能直接信任。负数、非数字、超长数字、错误结尾都应拒绝。这里限制正文为 1 MiB,只是本例的资源上限,不是 TCP 或 JSON 的规定。计算器的消息很小,足够使用。

TCP 保证有序字节流,不负责划分应用请求。Decode 成功后得到的是完整正文,Deserialize 成功后才得到可使用的业务对象。

三、计算业务不处理 Socket

下面保存为 NetCal.hpp。**Cal 只接收 Request 并返回 Response,不操作 Socket。**它不知道客户端 IP,也不负责收发,所以调整计算规则时,不需要改动拆包循环。

cpp 复制代码
#pragma once
#include "Protocol.hpp"
#include <limits>
#include <cstdint>

class Cal
{
public:
    Response Execute(Request &request)
    {
        std::int64_t left = request.X();
        std::int64_t right = request.Y();
        std::int64_t result = 0;
        switch (request.Oper()) {
        case '+': result = left + right; break;
        case '-': result = left - right; break;
        case '*': result = left * right; break;
        case '/':
            if (right == 0) return Response(0, 1);
            result = left / right;
            break;
        case '%':
            if (right == 0) return Response(0, 2);
            result = left % right;
            break;
        default: return Response(0, 3);
        }
        if (result < std::numeric_limits<int>::min() ||
            result > std::numeric_limits<int>::max()) return Response(0, 4);
        return Response(static_cast<int>(result), 0);
    }
};

这个版本保留原有的 0~3 状态码,额外用 4 表示超出 int 范围。客户端应先看状态码,再使用结果;通信双方增加状态码时也需要同步理解。

四、服务器接到连接后交给协议层

下面保存为 TcpServer.hpp。服务器继续使用多进程版本:主进程接收连接,处理连接的进程调用 Protocol,Protocol 再调用 Cal。

cpp 复制代码
#include "Socket.hpp"
#include <iostream>
#include <memory>
#include <sys/wait.h>
#include <functional>

using namespace SocketModule;
using namespace LogModule;

using ioservice_t = std::function<void(std::shared_ptr<Socket> &sock, InetAddr &client)>;
class TcpServer
{
public:
    TcpServer(uint16_t port, ioservice_t service) : _port(port),
                               _listensockptr(std::make_unique<TcpSocket>()),
                               _isrunning(false),
                               _service(service)
    {
        _listensockptr->BuildTcpSocketMethod(_port);
    }
    void Start()
    {
        _isrunning = true;
        while(_isrunning)
        {
            InetAddr client;
            auto sock = _listensockptr->Accept(&client);
            if(sock == nullptr)
            {
                continue;
            }
            LOG(LogLevel::DEBUG) << "accept success ..."<<client.StringAddr();
            pid_t id = fork();
            if(id < 0)
            {
                LOG(LogLevel::FATAL) << "fork error ...";
                sock->Close();
                continue;
            }
            else if(id == 0)
            {
                _listensockptr->Close();
                pid_t worker = fork();
                if (worker < 0) _exit(FORK_ERR);
                if (worker > 0) _exit(OK);
                _service(sock, client);
                sock->Close();
                _exit(OK);
            }
            else
            {
                sock->Close();
                while (::waitpid(id, nullptr, 0) < 0 && errno == EINTR) {}
            }
        }
        _isrunning = false;
    }
    ~TcpServer() {}

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensockptr;
    bool _isrunning;
    ioservice_t _service;
};

这里仍要区分监听 fd 与通信 fd:**处理连接的进程不再接新连接,父进程也不参与这一条连接的业务读写。**因此前者关闭继承的监听端,后者关闭自己那份通信 fd。

为什么父进程 waitpid 后还能继续接收其他连接?它等待的是第一次 fork 出来的子进程;这个子进程完成第二次 fork 就退出,真正服务的是孙子进程。孙子进程之后由相应的收割进程接管,不由这里的 waitpid 等待。

第二次 fork 失败也必须退出,不能当成孙子进程分支继续运行。这个例子保留多进程结构来说明调用关系,不额外展开线程池或事件循环。

五、客户端发送请求并等待应答

下面保存为 TcpClient.cc。它每次发送一个计算请求,再等待对应应答;先把这一来一回看清楚。

cpp 复制代码
#include "Socket.hpp"
#include "Common.hpp"
#include "Protocol.hpp"
#include <iostream>
#include <string>
#include <memory>

using namespace SocketModule;

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}

void GetDataFromStdin(int *x, int *y, char *oper)
{
    std::cout << "Please Enter x: ";
    std::cin >> *x;
    std::cout << "Please Enter y: ";
    std::cin >> *y;
    std::cout << "Please Enter oper: ";
    std::cin >> *oper;
}
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    std::string server_ip = argv[1];
    uint16_t server_port = 0;
    if (!ParsePort(argv[2], &server_port)) {
        std::cerr << "port must be an integer in [1, 65535]\n";
        return USAGE_ERR;
    }
    std::shared_ptr<Socket> client = std::make_shared<TcpSocket>();
    client->BuildTcpClientSocketMethod();

    if (client->Connect(server_ip, server_port) != 0)
    {
        std::cerr << "connect error" << std::endl;
        exit(CONNECT_ERR);
    }

    std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>();
    std::string resp_buffer;
    while (true)
    {
        int x, y;
        char oper;
        GetDataFromStdin(&x, &y, &oper);
        if (!std::cin) break;
        std::string req_str = protocol->BuildRequestString(x, y, oper);
        if (client->Send(req_str) < 0) break;
        Response resp;
        bool res = protocol->GetResponse(client, resp_buffer, &resp);
        if(res == false)
            break;
        resp.ShowResult();
    }
    client->Close();
    return 0;
}

resp_buffer 放在循环外,这是为了保留尚未消费的字节。读取输入失败时结束循环;发送失败或应答解析失败时也不继续展示一个未初始化的结果。

5.1 对象创建与简单工厂

Request、Response 也可以由 Factory 统一创建,例如用 make_shared 返回请求或应答对象。工厂只负责创建,不负责发送,也不改变对象的序列化格式。当前代码直接构造对象,若创建步骤增多,再集中到工厂中即可。

六、把各层串起来

主程序先创建计算对象,再把计算方法交给 Protocol;最后把 GetRequest 交给 TcpServer。这里的回调是在明确对象生命周期后使用的,不能让回调引用已销毁的局部对象。

cpp 复制代码
#include "NetCal.hpp"
#include "Protocol.hpp"
#include "TcpServer.hpp"
#include <memory>
#include "Log.hpp"
#include "Daemon.hpp"
void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }

    uint16_t port = 0;
    if (!ParsePort(argv[1], &port)) {
        std::cerr << "port must be an integer in [1, 65535]\n";
        return USAGE_ERR;
    }

    Daemon(0, 0);
    Enable_File_Log_Strategy();
    std::unique_ptr<Cal> cal = std::make_unique<Cal>();
    std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>([&cal](Request &req)->Response{
        return cal->Execute(req);
    });
    std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port,
        [&protocol](std::shared_ptr<Socket> &sock, InetAddr &client){
            protocol->GetRequest(sock, client);
    });

    tsvr->Start();

    return 0;
}

**先校验端口,再进行守护进程化。**如果在标准错误已重定向之后才发现参数错误,用户可能只看到终端返回,却不知道服务为什么没启动。

Daemon 的具体实现在后面的守护进程篇。第一次看收发流程时,可以暂时注释 Daemon(0, 0) 和 Enable_File_Log_Strategy() 两行,让服务及日志留在当前终端;理解收发后再恢复后台运行。

端口解析放在 Common.hpp,服务端与客户端共用。这里拒绝空字符串、非数字、0 和大于 65535 的数,避免直接窄化转换后得到另一个端口:

cpp 复制代码
inline bool ParsePort(const std::string &text, uint16_t *port)
{
    if (text.empty()) return false;
    unsigned int value = 0;
    for (char digit : text) {
        if (digit < '0' || digit > '9') return false;
        value = value * 10 + static_cast<unsigned int>(digit - '0');
        if (value > 65535) return false;
    }
    if (value == 0) return false;
    *port = static_cast<uint16_t>(value);
    return true;
}

6.1 文件与编译关系

text 复制代码
main.cc / TcpClient.cc
  ├── Socket.hpp → InetAddr.hpp、Common.hpp、Log.hpp
  ├── Protocol.hpp → Message.hpp
  ├── NetCal.hpp
  └── Daemon.hpp
makefile 复制代码
.PHONY: all clean
all: ServerNetCald client_netcal

ServerNetCald: main.cc $(wildcard *.hpp)
	g++ -std=c++17 -Wall -Wextra -pthread main.cc -ljsoncpp -o $@

client_netcal: TcpClient.cc $(wildcard *.hpp)
	g++ -std=c++17 -Wall -Wextra -pthread TcpClient.cc -ljsoncpp -o $@

clean:
	rm -f ServerNetCald client_netcal

本例以 Linux 常见的 32 位 int 为计算参数范围,int64_t 用于承接加减乘的中间结果。

库依赖放在使用它的源文件或目标文件后面。文件日志默认写入 /var/log/my.log,需要具备对应写权限;普通账号运行时应改成自己可写的绝对路径,不要把没有日志误判为没有启动。

实际头文件路径以安装环境为准,本文沿用 <jsoncpp/json/json.h>。

6.2 从编译到发出一次请求

确认当前目录中已经放齐上面的头文件,以及项目原有的 Common.hpp、InetAddr.hpp、Log.hpp、Mutex.hpp。只复制 Protocol.hpp,不会自动得到这些依赖。日志策略通过基类指针管理派生对象,LogStrategy 的析构函数应声明为 virtual ~LogStrategy() = default;。

下面沿用前台观察方式。先在一个 Linux 终端中编译并启动服务器:

bash 复制代码
make
./ServerNetCald 8080

再开一个终端,在同一台机器上连接:

bash 复制代码
./client_netcal 127.0.0.1 8080

程序会依次提示输入 x、y 和 oper,可以分别输入 10、20、+。这对应 Request(10, 20, '+');按计算逻辑应得到 result 为 30、code 为 0 的应答。换成 10、0、/,则应进入除零分支,返回 code 为 1,不能把此时的 result 当作正常结果。

这是两次请求应经过的代码路径,不要求 JSON 字段的打印顺序与截图逐字相同。跨机器运行时,把 127.0.0.1 换成服务器的实际地址,同时检查端口是否允许访问;127.0.0.1 始终指向当前机器自身。

6.3 出问题时先分清停在哪一层

  • 编译失败:先检查头文件是否放齐、JsonCpp 开发包及链接参数,Makefile 命令行前要使用 Tab。
  • 连接失败:先确认服务器仍在运行、端口与地址一致,不要立刻修改 JSON 格式。
  • 连接成功但没有结果:先看发送返回值与拆包条件。长度多算一个字节,就可能一直等待尚未到达的正文。
  • 收到错误状态码:说明已经进入业务应答流程,按 code 检查运算符、除零或数值范围。

正文使用 FastWriter 的实际输出长度,不能把演示中手写的 26 复制进封包函数。长度必须由 Serialize 返回的字符串计算,收发两端不能各算一套。

6.4 拆包函数与实际 TCP 收发分别检查

服务器与客户端均按前面的编译命令构建,编译完成后再开始收发。

观察期间,服务器暂时以前台方式运行,监听地址限定为 127.0.0.1;也就是不调用主程序中的 Daemon 和文件日志切换,并把地址封装中的 INADDR_ANY 改为回环地址。协议、计算业务和 Socket 收发函数保持相同实现。

先明确检查 Decode 的输入

拿 Encode 实际生成的一帧报文,在每个非端点字节位置分别切成前后两段。每次先把前段交给 Decode,检查它返回 false,而且没有丢弃缓存;随后补上后段,再检查提取的正文与发送前完全相同,缓存中也没有多余字节。

对两帧相连的情况,第一次 Decode 后,缓存应完整留下第二帧;第二次再取出它。实际检查输出为:

text 复制代码
PASS  all two-part frame splits retain incomplete data
PASS  joined frames: first decode retains second frame
PASS  joined frames: second decode consumes remainder
PASS  reject non-decimal frame length
PASS  reject body larger than 1 MiB

这里分别覆盖了不完整输入、连续多帧、非法长度和长度上限。**半包是继续等待的理由,非法报头不是。**不能因为 Decode 都没给出正文,就把这两类情况归到同一个分支。

再通过真正的 Socket 发请求

启动文中的 ServerNetCald,用回环连接发送请求,收到应答后按同样的长度规则读取完整正文。实际结果如下:

text 复制代码
PASS  TCP 10 + 20 => {'code': 0, 'result': 30}
PASS  TCP 10 / 0 => {'code': 1, 'result': 0}
PASS  TCP 10 % 0 => {'code': 2, 'result': 0}
PASS  TCP 10 ? 20 => {'code': 3, 'result': 0}
PASS  TCP request sent in two writes
PASS  two framed requests sent together, two responses received

这些字典形式是检查程序解析应答后显示的字段,并不是直接发送在网络上的 Python 表达式。网络正文仍是 Request/Response 约定的 JSON。

最后再用项目里的 client_netcal 发出 10+20 请求,实际得到 计算结果是: 30[0],确认交互式客户端也走通了一来一回。

**网络收发检查与拆包函数检查不能相互代替。**发送时调用两次 send,并不能强制对端调用两次 recv;所以前面先直接构造每一种分段输入检查 Decode,随后再用真实连接检查整个服务流程。

这次验证没有人为限制内核发送缓冲区,也没有证明循环发送中的每一轮都出现过短写;不能把普通连通测试说成已经覆盖了所有网络故障。跨机器运行、慢连接和超时处理,仍需结合后续服务设计考虑。

总结

一次请求依次经过:构造字段、序列化、加长度报头、发送、累计接收、提取完整帧、反序列化和计算。应答沿反方向执行相同过程。拆包时留下半包,读取时处理短读,发送时处理短写,才不会把简单回显时暂时没遇到的问题带进计算器。

配套项目:网络计算器源码。

往期博客分享

【Linux网络】TCP 线程池服务器怎么写?从 accept、任务队列到连接调度与 fd 生命周期

【Linux网络】TCP 为什么一次 read 读不到完整消息?从应用层协议、序列化到网络计算器

【Linux网络】JsonCpp 怎么实现序列化?从 Request/Response 到网络计算器消息设计

相关推荐
say_fall2 小时前
【Linux网络编程】UDP套接字编程:系统编程组件、基础回显服务、InetAddr与地址转换、翻译服务与线程池聊天室
linux·服务器·网络·udp·vr
ö Constancy2 小时前
一台电脑的硬件组成与作用:按功能分类详解(Windows 与 Linux 视角)
linux·windows·电脑
江湖有缘2 小时前
基于Ubuntu部署SoduWork:企业级 AI 原生协同平台实战全流程
linux·人工智能·ubuntu·ai·soduwork
无奈笑天下11 小时前
银河麒麟上nproc命令教程详解
linux·运维·服务器
我不会插花弄玉14 小时前
6.传输层协议UDP【由浅入深-网络】
网络·网络协议·udp
Rabitebla15 小时前
【Linux】进程控制(十二) 自定义 Shell(二):内建命令与进程工作目录管理
linux·c++·笔记·学习·bash
火云代理15 小时前
更换网络后为什么会退出登录?分清 IP、Cookie 与会话的关系
服务器·网络·网络协议·tcp/ip·php·ip
威嵌神州15 小时前
嵌入式排故实录:高并发下串口数据“超时丢包”的根因剖析与架构优化
运维·服务器·嵌入式硬件·缓存
Decay__16 小时前
SQLServer 索引与 SQL 优化 —— 聚集索引、非聚集索引、覆盖索引、碎片维护与执行计划
运维·数据库·sql·mysql·架构