Linux下 网络计算器小程序(socket封装、协议自定义、业务逻辑解耦)

欢迎来到我的频道!【点击跳转专栏】

本文所有代码已托管至码云:【点此转跳】

文章目录

  • [0. 项目获取](#0. 项目获取)
  • [1. 这里的"三层"是什么](#1. 这里的“三层”是什么)
    • [1.1 三层再代码中的对应](#1.1 三层再代码中的对应)
    • [1.2 认识项目文件](#1.2 认识项目文件)
    • [1.3 三层之间传什么](#1.3 三层之间传什么)
  • [2. 封装 Socket](#2. 封装 Socket)
    • [2.1 Socket:抽象统一动作](#2.1 Socket:抽象统一动作)
    • [2.2 TcpSocket:把系统调用藏在实现内部](#2.2 TcpSocket:把系统调用藏在实现内部)
    • [2.3 TcpServer:从"套接字"提升到"服务器"](#2.3 TcpServer:从“套接字”提升到“服务器”)
    • [2.4 fork() 后,谁接入、谁服务、谁关闭](#2.4 fork() 后,谁接入、谁服务、谁关闭)
    • [2.5 完整流程图](#2.5 完整流程图)
  • [3. 先定义双方交流的数据](#3. 先定义双方交流的数据)
    • [3.1 Request:明确一次计算的数据](#3.1 Request:明确一次计算的数据)
    • [3.2 为什么使用 JSON](#3.2 为什么使用 JSON)
    • [3.2 Serialize():对象转 JSON](#3.2 Serialize():对象转 JSON)
    • [3.4 DeSerialize():JSON 转对象](#3.4 DeSerialize():JSON 转对象)
    • [3.5 Response:同时表达结果和状态](#3.5 Response:同时表达结果和状态)
    • [3.6 Protocol 的回调](#3.6 Protocol 的回调)
  • [4. 沿着 Protocol:从字节流恢复消息,再驱动业务(一定要理解3的内容再看)](#4. 沿着 Protocol:从字节流恢复消息,再驱动业务(一定要理解3的内容再看))
    • [4.1 为什么需要 `Packet()`](#4.1 为什么需要 Packet())
    • [4.2 Unpack():从字节流中取出完整消息](#4.2 Unpack():从字节流中取出完整消息)
    • [4.3 ParseRequest():串起解包、反序列化和业务处理](#4.3 ParseRequest():串起解包、反序列化和业务处理)
    • [4.4 为什么使用回调](#4.4 为什么使用回调)
    • [4.5 ParseResponse():客户端对称处理响应](#4.5 ParseResponse():客户端对称处理响应)
    • [4.6 一次接收过程](#4.6 一次接收过程)
    • [4.7 总结与流程图](#4.7 总结与流程图)
  • [5. 业务层:只处理"算什么"](#5. 业务层:只处理“算什么”)
  • [6. 服务端如何把三层组装起来](#6. 服务端如何把三层组装起来)
  • [7. 项目完整流程图](#7. 项目完整流程图)

作者说: 网络计算器看起来只是"客户端发送两个数和一个运算符,服务端返回结果",但它恰好覆盖了网络编程中最值得建立的几个概念: 如何封装 Socket、TCP 为什么需要应用层协议,以及网络代码、协议代码和业务代码如何解耦。

也就是学习教育意义很大!!

我们根据代码解耦的方式将项目进行三层进行解析。

这是项目完整效果:

0. 项目获取

打开终端:依次输入

bash 复制代码
指令1: cd desktop
指令2: git clone https://gitee.com/hellofanz/linux_code.git

完整代码在 https://gitee.com/hellofanz/linux_code.gitlesson64/NetCal路径下!

1. 这里的"三层"是什么

项目在 NetCalServer.cc 中明确分出了三部分:

代码层次 主要文件 负责的问题 输入与输出
业务层 Calculator.hpp 请求具体要做什么 Request -> Response
协议层 Protocol.hpp 字节流如何变成请求,请求如何变回字节流 string <-> Request/Response
网络层 InetAddr.hppSocket.hppTcpServer.hpp 如何建立连接、收发数据和管理服务循环 TCP 字节流

1.1 三层再代码中的对应

配图中的职责 在 NetCal 中怎样理解 对应代码 需要保留的区别
应用层:处理某个应用的具体需求 算术运算、业务状态码 Calcultator::Execute() 这是计算器的业务逻辑
表示层:处理数据表示与转换 C++ 对象与 JSON 互相转换 Request/Response 的序列化与反序列化 Protocol 还负责长度报文和请求分发,职责多于格式转换
会话层:组织通信过程 接受连接、服务循环、处理结束 TcpServer::Loop()service() 可以作职责类比,但 TCP 连接管理不等于完整的 OSI 会话层实现

⚠️: 从 TCP/IP 的角度看,NetCal 自定义的计算协议属于应用层协议TcpServerTcpSocket 是应用程序内的网络通信代码,利用 Socket API 调用操作系统中的 TCP 能力;它们本身是没有自己实现 TCP 重传、拥塞控制或 IP 路由的!

1.2 认识项目文件

text 复制代码
NetCal/
├── NetCalServer.cc   服务端入口:创建对象、连接回调、启动 Loop
├── NetCalClient.cc   客户端入口:输入、三条请求组批、收发、结果展示
├── InetAddr.hpp      IPv4 地址与端口的包装
├── Socket.hpp        Socket 抽象、TcpSocket 实现(采用模版方法的设计模式) 
├── TcpServer.hpp     监听、accept、fork、每条连接的 service 循环
├── Protocol.hpp      Request、Response、JSON 转换、封包与解包
├── Calculator.hpp    Calcultator::Execute:计算业务
├── Logger.hpp        日志输出及输出策略
├── Mutex.hpp         日志使用的互斥锁和 LockGuard
└── makefile          构建客户端与服务端

1.3 三层之间传什么

网络层接收和返回的是字符串,协议层把它转换成请求和响应对象,业务层根据对象执行计算。相邻层的处理逻辑通过回调连接:

text 复制代码
TcpServer
   │  收到字节流后调用 Handler_t
   ▼
Protocol::ParseRequest
   │  得到 Request 后调用 HandlerRequest_t
   ▼
Calcultator::Execute
   │
   ▼
Response -> 协议封包 -> TCP 发送

因此,理解这个项目可以抓住一句话:网络层搬运字节,协议层解释字节,业务层处理数据。

2. 封装 Socket

原生 TCP 服务端通常要依次调用:

text 复制代码
socket -> bind -> listen -> accept -> recv/send -> close

客户端则通常是:

text 复制代码
socket -> connect -> send/recv -> close

这些接口直接暴露文件描述符、sockaddr、字节序和错误码。如果业务代码到处直接调用它们,网络细节就会渗透进协议和计算逻辑。NetCal 用三个层次逐步包住这些细节:

  1. InetAddr 封装 IPv4 地址。
  2. Socket 定义统一的 Socket 操作,TcpSocket 实现 TCP 版本。
  3. TcpServer 在 Socket 之上封装服务端的生命周期和收发循环。

这三步是网络模块内部的封装层次 。其中 InetAddr 处理地址,TcpSocket 处理一个套接字,TcpServer 组织整个服务端;它们共同属于前面介绍的网络层(代码层面的)

2.1 Socket:抽象统一动作

Socket 是抽象基类,声明了创建连接、接收连接以及收发数据等操作:

cpp 复制代码
virtual std::shared_ptr<Socket> Accepter(InetAddr &addr) = 0;
virtual int Recv(std::string *out) = 0;
virtual int Send(const std::string &in) = 0;
virtual void Close() = 0;
virtual bool Connect(const InetAddr &addr) = 0;

创建、绑定、监听被放在受保护的虚函数中,基类再用固定流程组合它们:

cpp 复制代码
void BuildTcpSocketMethod(uint16_t port)
{
    CreateSocketOrDie();
    BindSocketOrDie(port);
    ListenSocketOrDie();
}

void BuildTcpClientSockMethod()
{
    CreateSocketOrDie();
}

这里采用的是设计模式中的模板方法模式!!

  • BuildTcpSocketMethod() 是固定流程,本身不是虚函数。
  • CreateSocketOrDie()BindSocketOrDie()ListenSocketOrDie() 是可由派生类实现的步骤。
  • 基类规定本项目服务端的初始化顺序: 先创建,再绑定指定端口,最后监听。

日常开发中 : 服务端把新套接字变成监听套接字,需要三个步骤;客户端只创建套接字,然后通过独立的 Connect() 发起连接。客户端通常不必显式 bind,操作系统会为它选择合适的本地地址和临时端口。

2.2 TcpSocket:把系统调用藏在实现内部

TcpSocket 保存 _sockfd,各成员函数基本是一层语义化包装:

  • CreateSocketOrDie() 调用 socket(AF_INET, SOCK_STREAM, 0)
  • BindSocketOrDie() 调用 bind
  • ListenSocketOrDie() 调用 listen,积压队列长度为 16。
  • Accepter() 调用 accept,返回一个代表已连接套接字的新 TcpSocket
  • Recv() 把收到的内容追加到字符串缓冲区。
  • Send() 调用 send
  • Connect() 调用 connect
  • Close() 关闭文件描述符并将其置为 -1

accept 后返回新对象这一点很重要。监听套接字只负责接收连接;每个连接都由另一个套接字负责收发。二者角色不同:

text 复制代码
监听 TcpSocket ──accept──> 已连接 TcpSocket A
                 ├───────> 已连接 TcpSocket B
                 └───────> 已连接 TcpSocket C

Accepter() 的关键代码为:

cpp 复制代码
int sockfd = accept(_sockfd, CONV(&addr), &len);
if (sockfd < 0) {
    return nullptr;
}
clientaddr = addr;
return std::make_shared<TcpSocket>(sockfd);

这里返回TcpSocket的智能指针,是因为方便让另一个套接字直接调用封装好的发送与接收接口!!

2.3 TcpServer:从"套接字"提升到"服务器"

TcpServer 构造时创建监听套接字,并通过模板方法完成 socket + bind + listen

cpp 复制代码
TcpServer(Handler_t handler, uint16_t port)
    : _port(port),
      _listensock(std::make_unique<TcpSocket>()),
      _handler(handler)
{
    _listensock->BuildTcpSocketMethod(_port);
}

Loop() 持续执行 accept。得到连接后,当前实现使用 fork() 创建子进程,并在子进程中调用 service()。同时父进程signal(SIGCHLD, SIG_IGN) 用来自动回收退出的子进程,避免僵尸进程。

service() 的主线可以简化为下面三步,实际源码还包含返回值判断和日志:

cpp 复制代码
int n = sockfd->Recv(&inbuffer);
outbuffer += _handler(inbuffer);
sockfd->Send(outbuffer);

它不知道收到的是加法还是除法,也不知道正文采用 JSON。它只负责接收字节、把缓冲区交给上层、发送上层返回的字节。这正是网络层应有的边界。

这就是代码解耦的魅力!

2.4 fork() 后,谁接入、谁服务、谁关闭

Loop() 在父进程中持续接受连接,每接受一个连接,就调用一次 fork()。以下节选保留主要分支:

cpp 复制代码
auto sockfd = _listensock->Accepter(clientaddr);
if (!sockfd)
{
    continue;
}
if (fork() == 0) 
{
//子进程
    _listensock->Close();
    service(sockfd, clientaddr);
    sockfd->Close();
    exit(0);
}
//父进程
 sockfd->Close();

父进程继续接受其他客户端,子进程独立为当前客户端运行 service()。一个客户端可以在同一条连接上提交多批计算请求,并不是每次计算都重新创建一个进程。

进程 应保留 应及时关闭
父进程 监听套接字,继续 accept 本轮新接受的连接套接字
子进程 当前客户端的连接套接字 继承的监听套接字

⚠️:fd资源是十分珍贵的,所以记得要及时关闭!

  • 子进程:关闭监听套接字,处理客户端请求,然后关闭客户端套接字。
  • 父进程:关闭自己持有的客户端套接字,继续等待下一个连接。

2.5 完整流程图

3. 先定义双方交流的数据

text 复制代码
先约定计算需要什么数据
    ↓
再约定这些数据怎样变成字符串
    ↓
再约定连续字符串中怎样划分一条消息
    ↓
最后把消息解析、业务处理和响应生成组织起来

3.1 Request:明确一次计算的数据

Request 包含三个字段:

cpp 复制代码
int _data_x;
int _data_y;
char _oper;

它分别表示左操作数、右操作数和运算符,例如 Request(10, 20, '+') 表示一次加法请求。

两个构造函数对应不同场景:

cpp 复制代码
Request() : _data_x(0), _data_y(0), _oper(0) {}
Request(int x, int y, char oper)
    : _data_x(x), _data_y(y), _oper(oper) {}

3.2 为什么使用 JSON

网络只能传输字节,必须约定这些字节代表什么。JSON 负责把结构化数据转换为文本,项目则约定字段名称和含义:

  • left:左操作数
  • right:右操作数
  • oper:运算符

相比手工拼接和解析字符串,JSON 更适合处理字段增加、字符串和嵌套结构。但使用 JSON 并不意味着不需要设计应用协议,字段、类型和消息边界仍需双方约定。

3.2 Serialize():对象转 JSON

cpp 复制代码
root["left"] = _data_x;
root["right"] = _data_y;
root["oper"] = _oper;

随后由 FastWriterJson::Value 转成字符串。以 10 + 20 为例,结果类似:

text 复制代码
{"left":10,"oper":43,"right":20}\n

需要注意:

  • char 类型的 '+' 通常会按整数 43 写入;
  • FastWriter 默认会追加换行符;
  • 正文长度必须使用实际字符串的 size()
  • 字段顺序不重要,接收端按键名读取。

3.4 DeSerialize():JSON 转对象

DeSerialize() 先解析 JSON,再按约定的键名恢复成员:

cpp 复制代码
_data_x = root["left"].asInt();
_data_y = root["right"].asInt();
_oper = root["oper"].asInt();

解析成功只代表 JSON 语法正确,不代表字段完整、类型正确或数值合法。当前实现也不会修改输入字符串。

反序列化完成后,业务层可以直接调用:

cpp 复制代码
Calcultator::Execute(req);

无需再次处理字符串或 JSON。

3.5 Response:同时表达结果和状态

响应包含两个字段:

cpp 复制代码
int _result;
int _code;

_result 表示计算结果,_code 表示处理状态。当前约定为:

  • 0:成功
  • 1:除零
  • 2:模零
  • 3:非法操作符

只返回一个数字无法区分"正常结果为 0"和"计算失败",因此需要同时传递结果和状态。

请求与响应的转换路径如下:

text 复制代码
客户端:Request  → 请求 JSON
服务端:请求 JSON → Request

服务端:Response → 响应 JSON
客户端:响应 JSON → Response

3.6 Protocol 的回调

协议层负责解析数据,业务处理通过回调交给外部:

cpp 复制代码
using HandlerRequest_t = std::function<Response(Request &)>;
using HandlerRespone_t = std::function<void(Response &)>;

服务端收到 Request 后执行业务并返回 Response;客户端收到 Response 后进行展示或后续处理。

因此,Protocol 不需要内置具体业务逻辑,只需在解析完成后调用相应回调。带参构造函数分别保存请求处理和响应处理函数;默认构造函数不安装回调,但仍可使用 Packet()Unpack()

4. 沿着 Protocol:从字节流恢复消息,再驱动业务(一定要理解3的内容再看)

4.1 为什么需要 Packet()

JSON 只解决数据表示问题,TCP 仍然只是有序字节流,不保留 send() 的消息边界。因此,一条消息可能被分多次接收,多条消息也可能一次读到。

Packet() 通过"长度 + 分隔符 + 正文 + 分隔符"补充消息边界:

cpp 复制代码
std::string Packet(const std::string &json_string)
{
    return std::to_string(json_string.size())
         + gsep + json_string + gsep;
}

其中 gsep"\r\n",格式如下:

text 复制代码
正文长度\r\n正文\r\n

例如正文长度为 33 字节时:

text 复制代码
33\r\n{"left":10,"oper":43,"right":20}\n\r\n

长度字段用于确定正文应读取多少字节,帧尾则属于当前格式的一部分。

4.2 Unpack():从字节流中取出完整消息

cpp 复制代码
int Unpack(std::string &packet, std::string *json_string)

packet 是持续累积的接收缓冲区,可能包含半个包、一个包或多个包;json_string 用来保存取出的正文

找到长度分隔符后,先计算完整帧长度:

cpp 复制代码
std::string lenstr = packet.substr(0, pos);
int len = std::stoi(lenstr);
int total = lenstr.size() + len + 2 * gsep.size();

if (packet.size() < total) 
{
    return 0;
}

这里判断使用 <,因为缓冲区可能包含多帧,只要第一帧已经完整就可以先处理。

完整后,先复制正文,再删除整帧:

cpp 复制代码
*json_string = packet.substr(pos + gsep.size(), len);
packet.erase(0, total);

substr() 取出正文,erase() 消费已处理的字节,使下一次仍从剩余数据的开头开始。

4.3 ParseRequest():串起解包、反序列化和业务处理

服务端通过循环处理缓冲区中所有完整请求:

cpp 复制代码
std::string ParseRequest(std::string &inbuffer)
{
    std::string result;

    while (true) 
    {
        std::string json_string;
        int n = Unpack(inbuffer, &json_string);

        if (n < 0) 
        {
            return std::string();
        }
        if (n == 0) 
        {
            return result;
        }

        Request req;
        if (!req.DeSerialize(json_string))
        {
            return std::string();
        }

        Response resp;
        if (_handler_request) 
        {
            resp = _handler_request(req);
        }

        std::string resp_json_string;
        resp.Serialize(&resp_json_string);
        result += Packet(resp_json_string);
    }
}

处理顺序是:

text 复制代码
字节流 →(解包) 完整 JSON →(反序列化) Request → (业务层处理后返回结果)Response →(序列化) 响应 JSON (封包)→ 响应帧

4.4 为什么使用回调

协议层只负责数据转换和流程组织,具体业务通过回调注入:

cpp 复制代码
using HandlerRequest_t = std::function<Response(Request &)>;
using HandlerRespone_t = std::function<void(Response &)>;

服务端回调接收 Request 并返回 Response,客户端回调接收 Response 并负责展示或后续处理。

例如服务端可以这样连接计算器:

cpp 复制代码
[&cal](Request &req) -> Response {
    return cal->Execute(req);
}

因此,协议层不需要直接依赖计算器,也不需要知道客户端如何显示结果。

4.5 ParseResponse():客户端对称处理响应

客户端同样先解包,再反序列化:

cpp 复制代码
std::string ParseResponse(std::string &inbuffer)
{
    while (true) 
    {
        std::string json_string;
        int n = Unpack(inbuffer, &json_string);

        if (n <= 0)
         {
            return std::string();
        }

        Response resp;
        if (!resp.DeSerialize(json_string)) 
        {
            return std::string();
        }

        if (_handler_reponse)
         {
            _handler_reponse(resp);
        }
    }
}

客户端已经处于通信终点,因此不需要再次序列化或封包。响应结果通过 _handler_reponse 交给应用使用。该函数虽然返回 std::string,但当前实际返回值没有被使用。

4.6 一次接收过程

客户端连续发送多个请求时,服务端可能按以下方式收到数据:

text 复制代码
第一次:半个请求,暂不处理
第二次:一个完整请求 + 下一个半包,处理前者
第三次:补齐下一个请求,继续处理

如果一次收到多条完整请求,ParseRequest() 会在内部循环逐条处理;如果只收到半包,则返回 0,等待外层接收循环追加新数据。

4.7 总结与流程图

各部分职责可以概括为:

模块 主要职责
Request/Response 描述请求和响应的数据字段
Serialize/DeSerialize 在对象和 JSON 之间转换
Packet() 为正文添加消息边界
Unpack() 从字节流中取出完整帧
ParseRequest() 解包、反序列化、调用业务并生成响应
ParseResponse() 解包、反序列化并交给客户端处理
回调 连接协议层和具体业务

整体设计可以概括为:

text 复制代码
对象定义语义,JSON 统一表示,长度字段恢复边界,
缓冲区保存半包,循环消费完整消息,回调连接业务。

5. 业务层:只处理"算什么"

业务层只有 Calcultator::Execute()。类名在源码中确实拼作 Calcultator,本文沿用代码名称。

它接收 Request,根据 _oper 执行加、减、乘、除、取模,并返回 Response~!

这个部分就是我们所谓的业务流程 这里只是个简单的计算机 但是在未来就可以升级为其他业务 单独分一层也是代码解耦的表现!

6. 服务端如何把三层组装起来

NetCalServer.cc 是整个程序的组装点:对象在这里创建,层与层之间的依赖也在这里连接。

cpp 复制代码
std::unique_ptr<Calcultator> cal =
    std::make_unique<Calcultator>();

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>
(
    [&protocol](std::string &inbuffer) -> std::string {
        return protocol->ParseRequest(inbuffer);
    }, port);

tsvr->Loop();

两条 lambda 是整套结构的关键:

text 复制代码
TcpServer 的 Handler_t
string& -> string
        │
        └── 调用 Protocol::ParseRequest(协议信息处理)

Protocol 的 HandlerRequest_t
Request& -> Response
         │
         └── 调用 Calcultator::Execute(调用计算器服务)
text 复制代码
客户端数据到达
 -> service() 执行 _handler(inbuffer)(
 -> 第一层 lambda 执行 protocol->ParseRequest(inbuffer)
 -> 完整帧反序列化为 req
 -> ParseRequest() 执行 _handler_request(req)
 -> 第二层 lambda 执行 cal->Execute(req)

7. 项目完整流程图

假设客户端输入 10 + 20,一次请求可以压缩成四步:

  1. 客户端组装 :创建 Request(10, 20, '+'),调用 Serialize() 得到 JSON,再由 Packet() 加上长度和分隔符,最后交给 TcpSocket::Send()
  2. 服务端解包Recv() 把字节追加到 inbufferUnpack() 取出完整正文,DeSerialize() 还原 Request
  3. 业务计算 :协议层调用 Calcultator::Execute(req),得到 Response{30, 0};随后序列化并封成响应帧,通过 Send() 发回。
  4. 客户端解析 :客户端接收响应,ParseResponse() 循环解包和反序列化,HandlerRespone() 输出 result:30[0]
相关推荐
东城居士1 小时前
Linux驱动程序开发环境的快速搭建
linux·嵌入式系统
雨田言炎1 小时前
STM32之自定义协议帧格式
网络·笔记·stm32·单片机
云栖梦泽1 小时前
网络设备驱动(3)
linux·运维·服务器·网络·嵌入式硬件
蓝速科技2 小时前
蓝速科技 F100 双屏翻译机:中小企业跨国会议提效方案
大数据·网络·数据结构·人工智能·科技·运维开发
sugar__salt2 小时前
大模型流式输出完全指南(上):从 HTTP 长连接到手写 SSE
网络·网络协议·http
牛奶yu茶2 小时前
数据链路层的MTU
网络·网络通信·通信·通信协议·通信网络
ShineWinsu2 小时前
对于MySQL:索引的解析
linux·数据库·mysql·面试·笔试·索引·海量数据
wuminyu2 小时前
Kafka配置TLS/SSL加密传输时零拷贝失效分析
java·linux·c语言·jvm·c++
keyipatience2 小时前
5种IO模型与阻塞IO,select,poll,epoll,LT和ET模式
linux·服务器·网络·数据结构·c++·算法