欢迎来到我的频道!【点击跳转专栏】
本文所有代码已托管至码云:【点此转跳】
文章目录
- [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.git 中
lesson64/NetCal路径下!
1. 这里的"三层"是什么

项目在 NetCalServer.cc 中明确分出了三部分:
| 代码层次 | 主要文件 | 负责的问题 | 输入与输出 |
|---|---|---|---|
| 业务层 | Calculator.hpp |
请求具体要做什么 | Request -> Response |
| 协议层 | Protocol.hpp |
字节流如何变成请求,请求如何变回字节流 | string <-> Request/Response |
| 网络层 | InetAddr.hpp、Socket.hpp、TcpServer.hpp |
如何建立连接、收发数据和管理服务循环 | TCP 字节流 |
1.1 三层再代码中的对应
| 配图中的职责 | 在 NetCal 中怎样理解 | 对应代码 | 需要保留的区别 |
|---|---|---|---|
| 应用层:处理某个应用的具体需求 | 算术运算、业务状态码 | Calcultator::Execute() |
这是计算器的业务逻辑 |
| 表示层:处理数据表示与转换 | C++ 对象与 JSON 互相转换 | Request/Response 的序列化与反序列化 |
Protocol 还负责长度报文和请求分发,职责多于格式转换 |
| 会话层:组织通信过程 | 接受连接、服务循环、处理结束 | TcpServer::Loop()、service() |
可以作职责类比,但 TCP 连接管理不等于完整的 OSI 会话层实现 |
⚠️: 从 TCP/IP 的角度看,NetCal 自定义的计算协议属于应用层协议 。TcpServer 和 TcpSocket 是应用程序内的网络通信代码,利用 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 用三个层次逐步包住这些细节:
InetAddr封装 IPv4 地址。Socket定义统一的 Socket 操作,TcpSocket实现 TCP 版本。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;
随后由 FastWriter 将 Json::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,一次请求可以压缩成四步:
- 客户端组装 :创建
Request(10, 20, '+'),调用Serialize()得到 JSON,再由Packet()加上长度和分隔符,最后交给TcpSocket::Send()。 - 服务端解包 :
Recv()把字节追加到inbuffer,Unpack()取出完整正文,DeSerialize()还原Request。 - 业务计算 :协议层调用
Calcultator::Execute(req),得到Response{30, 0};随后序列化并封成响应帧,通过Send()发回。 - 客户端解析 :客户端接收响应,
ParseResponse()循环解包和反序列化,HandlerRespone()输出result:30[0]。

