开篇介绍:
hello 大家,那么在前面的学习中,我们已经能够实现一些简单的网络通信功能,那么在本篇博客中,我们就将使用自定义协议(基于TCP)实现网络计算器。
前言
网络编程是后端开发的核心能力之一,而 TCP 作为最常用的传输层协议,因其可靠、面向连接的特性被广泛应用于各类网络应用中。但 TCP "面向字节流" 的特性也带来了一个核心问题 ------ 无法天然区分 "一个完整的请求 / 响应" 边界,直接基于原生 TCP 开发应用程序极易出现 "粘包 / 拆包" 问题。
一、TCP 协议核心特性:为什么需要自定义应用层协议?
在开始实现网络计算器前,我们必须先搞懂 TCP 的核心特性,这是设计自定义协议的根本原因。
1.1 TCP 的 "面向字节流" 本质
TCP 被称为 "面向字节流" 的协议,简单来说,就是 TCP 只负责以 "字节" 为单位传输数据,不会为数据划分任何边界。打个比方:
- 客户端分 3 次发送数据:"1"、"+"、"2";
- 服务端可能接收到 "1+2"(粘包),也可能接收到 "1+" 和 "2"(拆包),甚至可能接收到 "1"、"+2"------TCP 不会保证 "发送一次,接收一次",只会保证数据的有序性和完整性。
这种特性对于 "网络计算器" 这类需要精准解析 "一个完整计算请求(如 10+20)" 的场景来说,是致命的:如果服务端只收到 "10+",就无法进行计算;如果收到 "10+2050*3"(多个请求粘在一起),也无法区分哪些是一个完整的请求。
1.2 TCP 的缓冲区机制
理解 TCP 的缓冲区,能帮我们更清晰地认识粘包 / 拆包的成因:
- 每一个 TCP 连接都有 "发送缓冲区" 和 "接收缓冲区";
- 客户端调用
send函数时,数据并不会直接发送到网络,而是先写入发送缓冲区,由操作系统决定何时、以多大的粒度发送; - 服务端调用
recv函数时,数据也不是直接从网络读取,而是从接收缓冲区读取,操作系统会先把网络中的数据写入接收缓冲区。
缓冲区的存在进一步加剧了粘包 / 拆包:
- 粘包:发送方的多个小数据被操作系统合并发送,或接收方的接收缓冲区中累积了多个请求;
- 拆包:发送方的大数据被操作系统拆分发送,或接收方一次只读取了部分数据。

1.3 自定义应用层协议的核心目标
针对 TCP 的字节流特性,我们需要设计 "自定义应用层协议",核心目标只有一个:让通信双方能够精准识别 "一个完整的请求 / 响应" 的边界。
对于网络计算器来说,就是要让服务端能准确判断:"这一段字节流是一个完整的计算请求(如 10+20),可以开始计算了";客户端能准确判断:"这一段字节流是服务端返回的完整计算结果,可以展示了"。
二、网络计算器的核心需求与协议设计思路
2.1 网络计算器的核心需求
先明确我们要实现的网络计算器的基本功能:
- 客户端:接收用户输入的计算表达式(如 10+20、50/5、30%7),发送给服务端;
- 服务端:接收并解析计算请求,执行计算逻辑,返回结果(如 30、10、2);
- 支持的运算符:+、-、*、/、%(需处理除 0、取余 0 等异常);
- 通信稳定:即使出现粘包 / 拆包,也能正确解析请求和响应。
2.2 自定义协议的设计思路
要解决 "识别数据边界" 的问题,最常用、最易理解的思路是:为每个完整的请求 / 响应添加 "边界标识"。我们选择的方案是 "长度 + 分隔符" 的组合方案,核心逻辑如下:
- 序列化:将结构化的计算请求 / 结果转换为字符串(如把 "10+20" 转换为 JSON 字符串
{"x":10,"oper":"+","y":20}); - 加边界标识:在序列化后的字符串前添加 "字符串长度",并通过分隔符(如
\r\n)分隔长度、协议标识、序列化字符串,最终形成 "可传输的报文"; - 解析:接收数据后,先根据 "长度" 判断是否收到完整报文,再提取出序列化字符串,反序列化为结构化数据。
举个具体的例子:
- 计算请求 "10+20" 序列化后为
{"x":10,"oper":"+","y":20}(长度为 29); - 加上边界标识后,最终发送的报文为:
29\r\nwin\r\n{"x":10,"oper":"+","y":20}\r\n; - 其中:
29:序列化字符串的长度;\r\n:分隔符,用于区分不同部分;win:自定义协议号(可选,用于标识报文类型);{"x":10,"oper":"+","y":20}:核心的序列化请求数据。
这种方案的优势在于:
- 简单易懂:通过 "长度" 可以直接判断是否收到完整报文;
- 鲁棒性强:即使多个报文粘在一起(如
29\r\nwin\r\n{"x":10...}\r\n31\r\nwin\r\n{"x":50...}\r\n),也能通过长度和分隔符精准拆分; - 兼容性好:JSON 序列化 / 反序列化是通用方案,跨语言、跨平台都能解析。
2.3 快速体验:核心代码片段示例
cpp
// 快速体验:创建一个计算请求并生成完整报文
#include "Protocol.hpp"
#include <iostream>
int main() {
// 1. 创建Protocol对象
Protocol proto;
// 2. 生成"10+20"的完整请求报文
std::string request = proto.CreateRequest(10, '+', 20);
// 3. 打印完整报文(展示边界标识效果)
std::cout << "完整请求报文:" << request << std::endl;
std::cout << "报文长度:" << request.size() << std::endl;
return 0;
}
输出结果:
cpp
完整请求报文:29
win
{"x":10,"oper":"+","y":20}
报文长度:40
三、结构化数据:Request 与 Response 设计
在设计协议的具体格式前,我们需要先定义 "结构化数据"------ 也就是通信双方约定的 "请求格式" 和 "响应格式"。结构化数据是序列化 / 反序列化的基础,也是解决 "字符串无法直接计算" 的关键。
3.1 Request 类:封装计算请求
Request 的核心作用是 "封装一个完整的计算请求",简单来说,就是把 "谁和谁运算、用什么运算符" 这些信息规整起来。
3.1.1 核心属性
一个计算请求需要包含 3 个核心信息:
- 第一个操作数(如 10+20 中的 10);
- 运算符(如 10+20 中的 +);
- 第二个操作数(如 10+20 中的 20)。
这三个属性是计算的基础,必须完整、准确地在客户端和服务端之间传输。
3.1.2 序列化与反序列化
序列化:把 Request 对象中的结构化数据(x=10、oper=+、y=20)转换为字符串(如 JSON)。为什么要序列化?因为网络只能传输字节 / 字符串,无法直接传输对象,序列化是 "结构化数据→网络传输" 的必经之路。
反序列化:把服务端接收到的字符串(如{"x":10,"oper":"+","y":20})还原为 Request 对象。为什么要反序列化?因为服务端需要执行计算逻辑,无法直接对字符串进行运算,必须把字符串还原为 "能直接读取的数值和运算符"。
举个通俗的例子:
- 客户端:用户输入 "10+20"→填充到 Request 对象→序列化为 JSON 字符串→发送;
- 服务端:接收 JSON 字符串→反序列化为 Request 对象→读取 x=10、oper=+、y=20→进行计算。
3.1.3 异常处理
Request 的反序列化过程中需要做基础校验:
- 检查是否包含 "x"、"oper"、"y" 字段;
- 检查 "x" 和 "y" 是否为整数;
- 检查 "oper" 是否为合法运算符(+、-、*、/、%)。
如果校验失败,服务端可以直接返回 "请求格式错误",避免无效计算。
3.2 Response 类:封装计算结果
Response 的核心作用是 "封装一个完整的计算响应",不仅要返回计算结果,还要返回运算状态(如 "运算正常"、"除 0 错误")。
3.2.1 核心属性
- 计算结果(如 10+20 的结果 30);
- 运算状态(如 "运算正常"、"除 0 错误"、"取余 0 错误"、"错误运算符")。
添加 "运算状态" 是为了提升用户体验:如果用户输入 "10/0",服务端不仅要返回结果 0,还要明确告知 "除 0 错误",而不是让用户疑惑 "为什么结果是 0"。
3.2.2 序列化与反序列化
Response 的序列化 / 反序列化逻辑和 Request 一致:
- 服务端:计算完成后,将结果和状态填充到 Response 对象→序列化为 JSON 字符串→添加边界标识后发送;
- 客户端:接收报文→提取序列化字符串→反序列化为 Response 对象→展示结果和状态。
3.3 代码示例:Request/Response 序列化实战
cpp
// Request/Response序列化实战示例
#include "Protocol.hpp"
#include <iostream>
int main() {
// 1. 创建Request对象并序列化
Request req(10, '+', 20);
std::string req_str = req.Serialize();
std::cout << "Request序列化结果:" << req_str << std::endl;
// 2. 反序列化回Request对象
Request req2;
req2.Deserialize(req_str);
std::cout << "反序列化后:x=" << req2.GetX()
<< ", oper=" << req2.GetOper()
<< ", y=" << req2.GetY() << std::endl;
// 3. 创建Response对象并序列化
Response res;
res.SetResult(30);
res.SetStatus("运算正常");
std::string res_str = res.Serialize();
std::cout << "Response序列化结果:" << res_str << std::endl;
// 4. 反序列化回Response对象并展示
Response res2;
res2.Deserialize(res_str);
res2.ShowResultAndStatus();
return 0;
}
输出结果:
Request序列化结果:{"x":10,"oper":"+","y":20}
反序列化后:x=10, oper=+, y=20
Response序列化结果:{"result":30,"status":"运算正常"}
运算结果:30 | 运算状态:运算正常
四、自定义协议的核心:Protocol 类设计
Protocol 类是整个自定义协议的核心,负责实现 "报文的封装(编码)"、"报文的解析(解码)"、"请求 / 响应的处理逻辑",是解决粘包 / 拆包问题的关键。
4.1 编码(Encode):封装可传输的报文
编码的核心是为序列化后的字符串添加 "边界标识",形成能在 TCP 中传输的完整报文。具体步骤如下:
4.1.1 步骤 1:获取序列化字符串的长度
假设序列化后的请求字符串是{"x":10,"oper":"+","y":20},其长度为 29,我们先把长度转换为字符串 "29"。
4.1.2 步骤 2:拼接报文各部分
按照 "长度 + 分隔符 + 协议号 + 分隔符 + 序列化字符串 + 分隔符" 的格式拼接:
- 长度字符串:"29";
- 第一个分隔符:
\r\n; - 协议号:"win"(自定义,可用于区分不同业务的报文);
- 第二个分隔符:
\r\n; - 序列化字符串:
{"x":10,"oper":"+","y":20}; - 第三个分隔符:
\r\n。
最终拼接后的报文为:29\r\nwin\r\n{"x":10,"oper":"+","y":20}\r\n。
添加分隔符的目的是 "避免长度和内容混淆":如果直接写 "29 {"x":10...}",服务端可能无法准确区分 "长度" 和 "内容" 的边界,而\r\n是不可打印字符,不会和内容冲突,能精准分隔。
4.2 解码(Decode):解析完整报文
解码是编码的逆过程,核心是从接收到的字节流中提取出 "一个完整的序列化字符串",解决粘包 / 拆包问题。具体逻辑如下:
4.2.1 步骤 1:查找第一个分隔符
服务端接收到数据后,首先查找第一个\r\n的位置 ------ 这个位置之前的字符串就是 "长度字符串"。
- 如果找不到
\r\n,说明收到的数据还不完整(比如只收到 "29\r"),需要继续接收数据; - 如果找到
\r\n,就提取出长度字符串(如 "29"),并转换为整数 29。
4.2.2 步骤 2:判断报文是否完整
完整报文的总长度计算公式:总长度 = 长度字符串的长度 + 3*分隔符长度 + 协议号长度 + 序列化字符串长度
以 "29\r\nwin\r\n {"x":10...}\r\n" 为例:
- 长度字符串长度:2("29" 是 2 个字符);
- 分隔符长度:每个
\r\n是 2 个字符,3 个就是 6; - 协议号长度:3("win" 是 3 个字符);
- 序列化字符串长度:29;
- 总长度:2+6+3+29=40。
如果服务端当前接收到的数据长度小于 40,说明报文不完整,需要继续接收;如果大于等于 40,说明报文完整(或包含多个完整报文)。
4.2.3 步骤 3:提取序列化字符串
找到报文的 "内容起始位置":内容起始位置 = 长度字符串长度 + 2*分隔符长度 + 协议号长度
代入数值:2 + 2*2 + 3 = 9,即从第 9 个字符开始,往后取 29 个字符,就是序列化字符串{"x":10,"oper":"+","y":20}。
4.2.4 步骤 4:清理已解析的报文
如果接收到的数据包含多个完整报文(如 "29\r\nwin\r\n...\r\n31\r\nwin\r\n...\r\n"),提取第一个完整报文后,需要从接收缓冲区中删除这部分数据,只保留剩余数据(如 "31\r\nwin\r\n...\r\n"),以便下次解析。
这个步骤是避免粘包的关键:每次只解析一个完整报文,剩余数据留到下次处理,确保每个请求都被精准解析。
4.3 代码示例:解码函数实战(解决粘包问题)
cpp
// 解码函数实战:模拟粘包数据解析
#include "Protocol.hpp"
#include <iostream>
int main() {
Protocol proto;
// 模拟粘包数据:两个完整请求粘在一起
std::string recv_data = "29\r\nwin\r\n{\"x\":10,\"oper\":\"+\",\"y\":20}\r\n31\r\nwin\r\n{\"x\":50,\"oper\":\"/\",\"y\":5}\r\n";
std::cout << "原始粘包数据长度:" << recv_data.size() << std::endl;
// 解析第一个报文
std::string true_msg1;
bool ret1 = proto.Decode(recv_data, &true_msg1);
if (ret1) {
std::cout << "解析出第一个请求:" << true_msg1 << std::endl;
std::cout << "剩余数据长度:" << recv_data.size() << std::endl;
}
// 解析第二个报文
std::string true_msg2;
bool ret2 = proto.Decode(recv_data, &true_msg2);
if (ret2) {
std::cout << "解析出第二个请求:" << true_msg2 << std::endl;
std::cout << "剩余数据长度:" << recv_data.size() << std::endl;
}
return 0;
}
输出结果:
原始粘包数据长度:82
解析出第一个请求:{"x":10,"oper":"+","y":20}
剩余数据长度:42
解析出第二个请求:{"x":50,"oper":"/","y":5}
剩余数据长度:0
4.4 服务端处理逻辑:GetRequest
GetRequest 是服务端专用的处理函数,负责从客户端接收请求、解析请求、执行计算、返回结果,完整流程如下:
4.4.1 步骤 1:循环接收并解码报文
服务端会持续监听客户端的连接,一旦建立连接,就进入死循环:
- 调用
recv函数从接收缓冲区读取数据,累加到临时字符串中; - 调用 Decode 函数解析临时字符串,直到解析出一个完整的序列化请求字符串。
这里的 "累加" 是关键:如果第一次只收到 "29\r\nwin\r\n {"x":10"(不完整),就把这些数据保存下来,下次recv到新数据后,拼接成 "29\r\nwin\r\n {"x":10,"oper":"+","y":20}\r\n",再进行解析。
4.4.2 步骤 2:反序列化请求数据
将解析出的序列化字符串反序列化为 Request 对象,提取出 x、oper、y。
4.4.3 步骤 3:执行计算逻辑
调用计算函数(如 NetCal 的 CalFunc),传入 Request 对象,得到计算结果和状态,填充到 Response 对象中。
计算函数需要处理的异常场景:
- 除 0 错误:如果运算符是 "/" 且 y=0,返回状态 "除 0 错误";
- 取余 0 错误:如果运算符是 "%" 且 y=0,返回状态 "取余 0 错误";
- 错误运算符:如果 oper 不是 +、-、*、/、% 中的一种,返回状态 "错误运算符"。
4.4.4 步骤 4:序列化并发送响应
将 Response 对象序列化为字符串,调用 Encode 函数封装为完整报文,通过send函数发送给客户端。
4.5 客户端处理逻辑:GetResponse
GetResponse 是客户端专用的处理函数,负责接收服务端的响应、解析响应、展示结果,流程如下:
4.5.1 步骤 1:循环接收并解码报文
和服务端的逻辑一致:客户端调用recv函数读取数据,累加后调用 Decode 函数,直到解析出完整的序列化结果字符串。
4.5.2 步骤 2:反序列化响应数据
将序列化结果字符串反序列化为 Response 对象,提取出计算结果和状态。
4.5.3 步骤 3:展示结果
将计算结果和状态展示给用户,比如:"运算结果:30 | 运算状态:运算正常"。
4.6 客户端请求封装:CreateRequest
为了简化客户端的使用,Protocol 类还提供了 "一键封装请求" 的功能:
- 接收用户输入的 x、oper、y;
- 创建 Request 对象并序列化;
- 调用 Encode 函数封装为完整报文;
- 返回封装后的报文,由客户端直接发送。
这个函数把 "创建 Request、序列化、编码" 的逻辑封装起来,客户端只需要传入 x、oper、y,就能得到可发送的报文,无需关心底层细节。
五、TCP Socket 封装:模板方法模式的应用
为了让网络计算器的代码更易维护、复用性更高,我们使用 "模板方法模式" 封装了 TCP Socket 的核心操作(创建、绑定、监听、连接、发送、接收等)。
5.1 模板方法模式的核心思想
模板方法模式的核心是 "定框架、填细节":
- 定框架:在抽象类中定义算法的固定流程(如 "创建套接字→绑定→监听" 是服务端创建监听套接字的固定流程);
- 填细节:把流程中可变的步骤(如 TCP 和 UDP 的套接字创建方式不同)留给子类实现。
打个比方:模板方法模式就像做手工模型的说明书 ------ 说明书固定了 "先拼底座→再拼主体→最后拼细节" 的流程,你只需要按流程把不同的零件(子类细节)拼上去就行,既不会拼错顺序,又能做出不同样式的模型。
5.2 Socket 抽象类:定义核心流程
Socket 抽象类定义了 TCP Socket 操作的所有核心接口(抽象步骤),并提供了 "一键创建监听套接字"、"一键创建连接套接字" 的模板方法:
5.2.1 抽象步骤(留给子类实现)
CreateSocketOrNot:创建套接字(TCP 和 UDP 的创建方式不同);BindSocketOrNot:绑定端口(服务端需要,客户端可选);ListenSocketOrNot:监听端口(仅服务端需要);AcceptSocketOrNot:接收客户端连接(仅服务端需要);ConnectSocketOrNot:连接服务端(仅客户端需要);Send:发送数据;Recv:接收数据;CloseSocket:关闭套接字。
5.2.2 模板方法(固定流程)
BulidListenSocket:服务端一键创建监听套接字,流程是 "CreateSocketOrNot→BindSocketOrNot→ListenSocketOrNot";BulidConnectSocket:客户端一键创建连接套接字,流程是 "CreateSocketOrNot→ConnectSocketOrNot"。
模板方法的优势在于:用户无需记住 "先创建套接字、再绑定、再监听" 的流程,只需调用一个函数就能完成,降低了使用成本。
5.3 TcpSocket 子类:实现具体细节
TcpSocket 子类继承自 Socket 抽象类,实现了所有抽象步骤,核心逻辑如下:
5.3.1 创建套接字(CreateSocketOrNot)
调用系统的socket函数,创建 TCP 套接字(参数为AF_INET、SOCK_STREAM),并设置 "端口复用"(避免服务端重启时出现 "端口被占用" 的问题)。
5.3.2 绑定端口(BindSocketOrNot)
调用系统的bind函数,将套接字与服务端的端口绑定,IP 地址设为INADDR_ANY(表示监听所有网卡的请求)。
5.3.3 监听端口(ListenSocketOrNot)
调用系统的listen函数,设置监听队列长度(默认 16),让套接字进入 "监听状态",等待客户端连接。
5.3.4 接收连接(AcceptSocketOrNot)
调用系统的accept函数,阻塞等待客户端连接,连接成功后返回一个新的套接字(用于和该客户端一对一通信),并封装为 TcpSocket 对象返回。
5.3.5 连接服务端(ConnectSocketOrNot)
客户端调用connect函数,传入服务端的 IP 和端口,建立 TCP 连接。
5.3.6 发送数据(Send)
循环调用send函数,确保数据全部发送:
- 如果一次
send只发送了部分数据(如要发送 100 字节,只发送了 50 字节),就从剩余位置继续发送,直到全部发送完成; - 返回布尔值,表示发送是否成功。
5.3.7 接收数据(Recv)
调用recv函数读取数据,将数据追加到接收字符串中(解决拆包问题),返回recv的返回值(方便判断是否连接关闭)。
5.3.8 关闭套接字(CloseSocket)
调用系统的close函数关闭套接字,并将套接字标识为 "无效",避免重复关闭。
5.4 代码示例:Socket 封装实战
cpp
// Socket封装实战:服务端一键创建监听套接字
#include "Socket.hpp"
#include <iostream>
int main() {
// 1. 创建TcpSocket对象
std::shared_ptr<SocketModule::TcpSocket> server_socket =
std::make_shared<SocketModule::TcpSocket>();
// 2. 一键创建监听套接字(端口8080)
server_socket->BulidListenSocket(8080);
std::cout << "服务端监听套接字创建成功,端口8080" << std::endl;
std::cout << "等待客户端连接..." << std::endl;
// 3. 等待客户端连接(阻塞)
std::shared_ptr<SocketModule::TcpSocket> client_socket =
server_socket->AcceptSocketOrNot();
if (client_socket) {
std::cout << "客户端连接成功!" << std::endl;
}
return 0;
}
六、服务端的完整实现:多进程 + 守护进程
为了让网络计算器的服务端能稳定运行,我们做了两个关键优化:多进程处理客户端连接、守护进程化。
6.1 多进程处理连接
服务端的核心需求是 "同时处理多个客户端的请求",我们采用 "父进程监听、子进程 / 孙子进程处理请求" 的方案:
6.1.1 流程说明
- 父进程调用
accept接收客户端连接,得到一个新的套接字; - 父进程
fork创建子进程,子进程关闭监听套接字(避免子进程也监听端口); - 子进程再
fork创建孙子进程,子进程直接退出(避免成为僵尸进程); - 孙子进程处理客户端的请求(调用 Protocol 的 GetRequest 函数),处理完成后关闭连接套接字并退出;
- 父进程关闭连接套接字,等待子进程退出(避免僵尸进程)。
6.1.2 为什么用 "孙子进程"?
- 子进程退出后,父进程会收到
SIGCHLD信号,父进程调用waitpid回收子进程资源,避免子进程成为僵尸进程; - 孙子进程的父进程(子进程)已经退出,孙子进程会被操作系统的 "init 进程" 接管,即使服务端不主动回收,init 进程也会清理孙子进程的资源,进一步避免僵尸进程。
6.1.3 孙子进程的死循环
孙子进程会进入死循环,持续处理该客户端的请求:
- 如果孙子进程只处理一次请求就退出,客户端发送第二次请求时会失败(连接已关闭);
- 死循环能保证 "一个客户端连接建立后,可多次发送计算请求",符合用户使用习惯。
6.2 守护进程化
守护进程是 "脱离终端、在后台运行的进程",服务端守护进程化的目的是:即使关闭终端,服务端也能继续运行。
6.2.1 守护进程的创建步骤
- 忽略无关信号:忽略
SIGCHLD(避免子进程退出时打断父进程)、SIGPIPE(避免向关闭的连接写数据导致进程终止); - 创建子进程,父进程退出:解决 "进程组长无法创建新会话" 的问题;
- 子进程创建新会话:脱离原终端的控制;
- 切换工作目录到根目录:避免服务端占用某个目录(如用户的桌面目录),导致该目录无法删除;
- 关闭 / 重定向文件描述符:关闭标准输入、输出、错误(守护进程不需要终端交互),或重定向到
/dev/null(空设备,写入的数据会被丢弃)。
6.2.2 守护进程的优势
- 后台运行:不受终端关闭的影响;
- 独立会话:不依赖任何终端,避免被终端信号打断;
- 资源隔离:切换到根目录、关闭无关文件描述符,减少资源占用。
6.3 异常处理与日志
为了让服务端更稳定,我们还添加了完善的异常处理和日志记录:
- 异常处理:对
socket、bind、listen、accept、connect等系统调用的返回值进行校验,失败时打印错误信息并退出; - 日志记录:将关键操作(如连接建立、请求处理、错误信息)记录到日志文件中,方便排查问题。
6.4 代码示例:服务端完整启动流程
cpp
// 服务端完整启动流程示例(关键片段)
#include "TcpServer.hpp"
#include "NetCal.hpp"
#include "Protocol.hpp"
#include "Daemon.hpp"
#include <iostream>
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
// 1. 守护进程化
Daemon(0, 0);
std::cout << "服务端已后台运行(守护进程)" << std::endl;
// 2. 创建计算模块
std::unique_ptr<NetCal> cal = std::make_unique<NetCal>();
// 3. 创建协议模块(绑定计算函数)
std::shared_ptr<Protocol> proto = std::make_shared<Protocol>(
[&cal](const std::shared_ptr<Request>& req) {
return cal->CalFunc(req);
}
);
// 4. 创建服务端(绑定处理函数)
std::unique_ptr<TcpServer> server = std::make_unique<TcpServer>(
std::stoi(argv[1]),
[proto](std::shared_ptr<SocketModule::TcpSocket>& sock) {
proto->GetRequest(sock);
}
);
// 5. 启动服务端
server->StartTcpServer();
return 0;
}
七、客户端的完整实现
客户端的逻辑相对简单,核心是 "接收用户输入→封装请求→发送请求→接收响应→展示结果"。
7.1 核心流程
- 解析命令行参数:获取服务端的 IP 和端口;
- 创建 TCP 套接字并连接服务端:调用 TcpSocket 的
CreateSocketOrNot和ConnectSocketOrNot; - 循环接收用户输入:
- 提示用户输入计算表达式(如 10+20);
- 校验输入格式(确保是 "数字 + 运算符 + 数字");
- 调用 Protocol 的 CreateRequest 函数封装请求报文;
- 调用 TcpSocket 的 Send 函数发送报文;
- 调用 Protocol 的 GetResponse 函数接收并解析响应;
- 展示计算结果和状态;
- 询问用户是否继续计算,输入 "no" 则退出循环;
- 关闭套接字:调用 TcpSocket 的 CloseSocket 函数。
7.2 输入校验
客户端的输入校验是提升用户体验的关键:
- 如果用户输入的不是 "数字 + 运算符 + 数字"(如 "10+a"、"abc"),提示 "输入格式错误",并清空输入缓冲区,避免死循环;
- 如果用户输入的运算符不合法(如 "10@20"),服务端会返回 "错误运算符",客户端展示该状态。
7.3 异常处理
客户端的异常处理主要包括:
- 连接失败:如果无法连接到服务端(如服务端未启动、IP / 端口错误),打印错误信息并退出;
- 接收失败:如果接收响应时出现错误,打印错误信息并退出;
- 输入异常:处理用户的非法输入,避免程序崩溃。
7.4 代码示例:客户端完整交互流程
cpp
// 客户端完整交互流程示例(简化版)
#include "Socket.hpp"
#include "Protocol.hpp"
#include <iostream>
#include <limits>
int main() {
// 1. 创建客户端Socket并连接服务端
std::unique_ptr<SocketModule::TcpSocket> client_socket =
std::make_unique<SocketModule::TcpSocket>();
client_socket->CreateSocketOrNot();
client_socket->ConnectSocketOrNot("127.0.0.1", 8080);
// 2. 创建协议对象
std::unique_ptr<Protocol> proto = std::make_unique<Protocol>();
// 3. 交互循环
std::string cont = "yes";
while (cont == "yes") {
int x, y;
char oper;
// 输入计算表达式
std::cout << "\n请输入计算式(如10+20):";
if (!(std::cin >> x >> oper >> y)) {
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::cout << "输入格式错误!" << std::endl;
continue;
}
// 封装并发送请求
std::string req = proto->CreateRequest(x, oper, y);
client_socket->Send(req);
// 接收并展示结果
proto->GetResponse(client_socket);
// 询问是否继续
std::cout << "是否继续计算(yes/no):";
std::cin >> cont;
}
// 4. 关闭连接
client_socket->CloseSocket();
return 0;
}
客户端运行示例:
cpp
请输入计算式(如10+20):10+20
运算结果:30 | 运算状态:运算正常
是否继续计算(yes/no):yes
请输入计算式(如10+20):50/0
运算结果:0 | 运算状态:除0错误
是否继续计算(yes/no):no
八、网络计算器的运行流程
为了让大家更清晰地理解整个系统的运行逻辑,我们梳理一下 "客户端输入 10+20,服务端返回 30" 的完整流程:
8.1 服务端启动
- 服务端进程被守护进程化,切换到根目录,关闭无关文件描述符;
- 创建 TcpSocket 对象,调用
BulidListenSocket创建监听套接字,绑定端口并开始监听; - 进入死循环,调用
AcceptSocketOrNot等待客户端连接。
8.2 客户端启动并发送请求
- 客户端解析命令行参数(服务端 IP 和端口);
- 创建 TcpSocket 对象,连接服务端;
- 用户输入 "10+20",客户端校验输入格式;
- 调用 Protocol 的 CreateRequest 函数:
- 创建 Request 对象(x=10、oper=+、y=20);
- 序列化为 JSON 字符串
{"x":10,"oper":"+","y":20}; - 编码为报文
29\r\nwin\r\n{"x":10,"oper":"+","y":20}\r\n;
- 调用 TcpSocket 的 Send 函数发送报文。
8.3 服务端处理请求
- 服务端父进程
accept接收到客户端连接,创建子进程; - 子进程创建孙子进程后退出,父进程回收子进程资源;
- 孙子进程调用 Protocol 的 GetRequest 函数:
- 循环
recv数据,解码出序列化字符串{"x":10,"oper":"+","y":20}; - 反序列化为 Request 对象;
- 调用计算函数,得到结果 30,状态 "运算正常";
- 创建 Response 对象,序列化为
{"result":30,"status":"运算正常"}; - 编码为报文
35\r\nwin\r\n{"result":30,"status":"运算正常"}\r\n; - 调用 Send 函数发送报文。
- 循环
8.4 客户端接收并展示结果
- 客户端调用 Protocol 的 GetResponse 函数:
- 循环
recv数据,解码出序列化字符串{"result":30,"status":"运算正常"}; - 反序列化为 Response 对象;
- 循环
- 展示结果:"运算结果:30 | 运算状态:运算正常";
- 询问用户是否继续计算,用户输入 "yes" 则等待下一次输入,输入 "no" 则关闭套接字并退出。
8.5 完整运行演示
服务端启动命令:
# 编译服务端
g++ -o tcpserver TcpServer.cpp Daemon.cpp NetCal.cpp Protocol.cpp Socket.cpp -ljsoncpp -lpthread
# 启动服务端(端口8080)
./tcpserver 8080
客户端启动命令:
# 编译客户端
g++ -o tcpclient TcpClient.cpp Protocol.cpp Socket.cpp -ljsoncpp
# 启动客户端
./tcpclient 127.0.0.1 8080
客户端交互示例:
请输入计算表达式(格式:数字运算符数字,如10+20):10+20
运算结果:30 | 运算状态:运算正常
是否要继续进行计算,yes/no:yes
请输入计算表达式(格式:数字运算符数字,如10+20):50/5
运算结果:10 | 运算状态:运算正常(整数除法)
是否要继续进行计算,yes/no:yes
请输入计算表达式(格式:数字运算符数字,如10+20):10/0
运算结果:0 | 运算状态:除0错误
是否要继续进行计算,yes/no:no
九、常见问题与解决方案
在实现网络计算器的过程中,新手容易遇到以下问题,我们给出对应的解决方案:
9.1 粘包 / 拆包问题
- 现象:服务端解析出的请求不完整,或包含多个请求;
- 原因:未正确实现 "累加接收数据" 和 "按长度解析报文";
- 解决方案:
- 接收数据时,将每次
recv的数据追加到临时字符串中,而非覆盖; - 解码时先判断报文总长度,未达到总长度则继续接收;
- 解析出一个完整报文后,删除临时字符串中该报文的内容,保留剩余数据。
- 接收数据时,将每次
9.2 僵尸进程问题
- 现象:服务端运行一段时间后,出现大量僵尸进程;
- 原因:未正确回收子进程资源;
- 解决方案:
- 父进程
fork子进程后,调用waitpid回收子进程; - 使用 "孙子进程" 处理请求,子进程直接退出,由 init 进程回收孙子进程;
- 忽略
SIGCHLD信号(部分系统中,忽略该信号后,子进程退出会自动回收)。
- 父进程
9.3 端口被占用问题
- 现象:服务端重启时,提示 "Address already in use";
- 原因:TCP 的 "TIME_WAIT" 状态导致端口未释放,或未设置端口复用;
- 解决方案:
- 创建套接字后,设置
SO_REUSEADDR选项(端口复用); - 等待一段时间(通常 1-2 分钟),让 TIME_WAIT 状态结束。
- 创建套接字后,设置
9.4 除 0 错误未处理
- 现象:用户输入 "10/0" 时,服务端崩溃;
- 原因:计算函数未判断除数是否为 0;
- 解决方案:在计算函数中,对 "/" 和 "%" 运算符做特殊处理,除数为 0 时返回对应的错误状态,不执行计算。
9.5 客户端输入异常导致崩溃
- 现象:用户输入非数字(如 "abc")时,客户端崩溃;
- 原因:未校验
cin的输入状态; - 解决方案:
- 输入后检查
cin是否正常,异常时调用cin.clear()重置状态; - 调用
cin.ignore()清空输入缓冲区,避免无效数据残留。
- 输入后检查
9.6 编译错误:找不到 Jsoncpp 库
-
现象:编译时提示 "undefined reference to Json::Value::Value ()";
-
原因:未链接 Jsoncpp 库;
-
解决方案:编译时添加
-ljsoncpp参数:g++ -o tcpserver TcpServer.cpp -ljsoncpp -lpthread
完整代码目录结构
cpp
network-calculator/
├── Common.hpp # 通用常量/枚举定义
├── Daemon.cpp # 守护进程实现
├── Daemon.hpp # 守护进程头文件
├── InetAddr.hpp # 地址封装(可选)
├── Log.hpp # 日志模块(可选)
├── NetCal.cpp # 计算核心实现
├── NetCal.hpp # 计算核心头文件
├── Protocol.cpp # 自定义协议实现
├── Protocol.hpp # 自定义协议头文件
├── Socket.cpp # Socket封装实现
├── Socket.hpp # Socket封装头文件
├── TcpClient.cpp # 客户端主程序
├── TcpServer.cpp # 服务端主程序
└── TcpServer.hpp # 服务端类头文件
完整示例代码:
Common.hpp # 通用常量/枚举定义
cpp
#pragma once
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <string>
//那么为了方便我们看退出码更直观
//所以我们直接设置一个枚举体用于枚举各种错误的退出码
enum ExitCode
{
OK = 0,
USAGE_ERR,
SOCKET_ERR,
INET_PTON_ERR,
BIND_ERR,
LISTEN_ERR,
CONNECT_ERR,
ACCEPT_ERR
};
//那么还有就是我们的服务端类是不允许被拷贝或者赋值重载的
//那么我们一般是......=delete,但是这样子一个两个类还好
//多个类的话就会很麻烦
//所以,我们不妨创建一个类,然后这个类要禁止拷贝构造或者赋值重载函数
//那么其他的类再去继承这个类,即这个类成为其他类的父类
//如此一来,其他的类想要进行拷贝构造或者赋值重载函数的话
//就必须先进行父类的拷贝构造或者赋值重载函数,而父类是允许的
//那么该子类也就无法被拷贝或者赋值重载的
//所以这么一来就能减少我们的操作
class NoCopy
{
public:
NoCopy()
{}
~NoCopy()
{}
NoCopy(const NoCopy &) = delete;
const NoCopy &operator = (const NoCopy&) = delete;
};
Daemon.hpp # 守护进程实现
cpp
#pragma once
#include <iostream>
#include <unistd.h>
#include <string>
#include <sys/wait.h>
#include <cstdlib>
#include <signal.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <cstring>
#include <cerrno>
void Daemon(int ischdir, int isclose)
{
// 安排两个参数,第一个参数用于是否把进程的「当前工作目录(CWD)」切换到根目录(/);
// 是0的话,就代表是要
//那么第二个参数就好理解很多了,就是要不要关闭守护进程的标准一系列的文件描述符
//是0就代表要这么干
//不是的话我们就把它们重定向到/dev/null中
//OK,那么知道了这些之后,我们就可以开始正式干活了
//忽略可能引起程序异常退出的信号
//忽略 SIGCHLD:避免子进程僵尸+父进程被无意义信号中断
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGCHLD, &sa, nullptr);
//忽略 SIGPIPE:避免向已关闭的管道/套接字写数据导致程序直接终止
struct sigaction sa1;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGPIPE, &sa, nullptr);
//创建子进程并且父进程里面退出,解决进程组长无法创建新会话的问题
int pid=fork();
if(pid>0)
{
//父进程
exit(0);
}
else if(pid<0)
{
//创建子进程失败
std::cerr<<"创建子进程失败!!!"<<strerror(errno)<<std::endl;
//退出
exit(1);
}
//子进程
//执行创建新会话,判断是不是要更改工作目录,是不是要close
//它们的先后顺序不影响
pid_t sid=setsid();
if(sid<0)
{
//创建新会话失败
std::cerr<<"创建新会话失败!!!"<<strerror(errno)<<std::endl;
//退出
exit(1);
}
//创建新会话成功
const static std::string root="/";
//判断是不是要更改工作目录
if(ischdir==0)
{
//调用chdir函数
int ret_chdir=chdir(root.c_str());
if(ret_chdir<0)
{
//更改工作目录失败
std::cerr<<"更改工作目录失败!!!"<<strerror(errno)<<std::endl;
exit(1);
}
}
const static std::string dev_null="/dev/null";
//判断是不是要close
if(isclose==0)
{
close(0);
close(1);
close(2);
}
else
{
//打开/dev/null
int ret_open=open(dev_null.c_str(),O_RDWR);//既读既写的打开该文件
if(ret_open<0)
{
//打开/dev/null失败
std::cerr<<"打开/dev/null失败!!!"<<strerror(errno)<<std::endl;
exit(1);
}
//然后就是使用dup2函数重定向
dup2(ret_open,0);//将第一个参数覆盖给第二个参数,标准输入流
dup2(ret_open,1);//将第一个参数覆盖给第二个参数,标准输出流
dup2(ret_open,2);//将第一个参数覆盖给第二个参数,标准错误流
close(ret_open);//最后不要忘记关闭打开的/dev/null
}
}
//那么其实系统中有提供daemon函数能够达到上面我们一样的作用
//只不过实践中一般还是我们自己实现的比较靠谱
NetCal.hpp # 计算核心实现
cpp
#pragma once
#include <iostream>
#include <memory>
#include <unistd.h>
#include <functional>
#include <string>
#include "Protocol.hpp"
//OK,那么本文件要干什么呢???
//其实本文件就是要提供
//在问题字符串反序列化到Request结构之后,
//将Request结构的两个整型和一个运算符提取出来,并通过数据进行计算,最后将结算结果填充到Responce结构中
//的函数
/**
* @brief 服务端核心计算函数:处理解码后的请求并返回计算结果
* @details 该函数是服务端业务处理的核心,承接"解码请求→计算处理→封装响应"的核心逻辑,
* 最终返回封装好结果的Response对象,供服务端发送给客户端
*
* 【核心职责】
* 1. 从传入的Request对象中读取反序列化后的业务问题(如计算式、业务请求参数);
* 2. 针对读取到的问题执行具体的计算/业务处理逻辑(如解析算式、执行业务规则);
* 3. 将处理结果(含结果值、状态码、错误信息)封装到Response对象中并返回。
*
* 【参数设计】
* @param req : Request& 类型(推荐)
* - 引用:避免对象拷贝,提升程序运行效率;
* - 可选替代:std::shared_ptr<Request>(适用于Request需跨函数生命周期、动态内存管理的场景);
* - 辅助扩展:可按需添加日志对象、超时配置等辅助参数(非核心)。
*
* 【返回值设计】
* @return Response :基础版返回值(适合轻量场景,逻辑简单);
* 可选替代:std::unique_ptr<Response>/std::shared_ptr<Response>(进阶版,避免大对象拷贝,提升效率);
* 返回值内容:包含计算结果值、状态码(0=成功/非0=失败)、结果描述(成功/错误信息)。
*/
//其实这个函数的实现,还是比较简单的,要是有不清楚的,可以去看Procotol头文件中的GetRequest函数中所讲的
class NetCal
{
public:
std::unique_ptr<Response> CalFunc(const std::shared_ptr<Request>& re)
{
// 空指针防护(核心:避免崩溃)
if (!re)
{
LOG(LogLevel::ERROR) << "计算函数传入空的Request对象!";
auto res = std::make_unique<Response>();
res->SetResult(0);
res->SetStatus("错误:请求数据为空");
return res;
}
//此时传入的Request类就是已经序列化后的问题了,
//所以就可以根据类中的结构化数据去获取两个整型和运算符了
//根据运算符的不同去进行不同的运算
//最后把计算后的结果传进Response类中,并进行返回
//由服务端获取到该对象并进行序列发送给客户端
char oper=re->GetOper();//获取运算符
int x=re->GetX();//获取x
int y=re->GetY();//获取y
int result=0;//结果
std::string status;
switch(oper)
{
case '+'://运算符为加号,代表要进行加法运算
result=x+y;
status="运算正常";
break;
case '-'://运算符为减号,代表要进行减法运算
result=x-y;
status="运算正常";
break;
case '*'://运算符为乘号,代表要进行乘法运算
result=x*y;
status="运算正常";
break;
case '/'://运算符为除号,代表要进行除法运算,那么我们就得判断传入的除数是不是0,是0的话得返回状态为除0错误
if(y==0)
{
status="除0错误";
break;
}
//不是了,再去计算结果
result=x/y;
status="运算正常(整数除法)";
break;
case '%'://运算符为百分号,代表要进行取余计算,那么我们就得判断传入的除数是不是0,是0的话得返回状态为取余0错误
if(y==0)
{
status="取余0错误";
break;
}
//不是了,再去计算结果
result=x%y;
status="运算正常";
break;
default:
result=0;
status="错误运算符";
break;
}
//接着创建Response指针
std::unique_ptr<Response> res(std::make_unique<Response>());
//将结果和状态传进res中
res->SetResult(result);
res->SetStatus(status);
return res;//返回
}
};
Protocol.hpp # 自定义协议头文件
cpp
#pragma once
#include <iostream>
#include <memory>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <functional>
#include <cerrno>
#include <sys/wait.h>
#include <pthread.h>
#include <signal.h>
#include <atomic>
#include <string>
#include "Log.hpp"
#include "InetAddr.hpp"//可以利用该头文件做优化,但这里向突出最原始的,所以没有使用,在翻译这里,我们就进行使用简化
#include "Common.hpp"
#include "Socket.hpp"
#include <jsoncpp/json/json.h>
using namespace SocketModule;
//ok,那么这个文件就是我们实现网络计算器的核心所在了,在本文件中,我们将实现我们的自定义协议(基于TCP)
//我们都知道协议是什么,也知道了什么是序列化,什么是反序列化,更知道说其实OS是往网络里面去发送字符串的
//但是我们还需要知道更多的内容!!!
//在TCP协议中,其实它之所以能实现全双工,是因为在其里面,是有这接收缓冲区和发送缓冲区!!!!!!
//无论你是发送信息方,还是接收信息方,你的TCP里面都会有着这两个缓冲区!!!!!
//所以,你就懂了,其实我们之前所谓的read、write、recv、send等等函数,
//都不是我们直接就把信息发送/读取到网络的
//其实都是先丢到缓冲区中,然后再由操作系统去从缓冲区中获取,然后,再发送到网络中的!!!!!
//比如read、recv函数其实就是把信息发送到你网络层里面的发送缓冲区就完事了,不会再去管辖有木有发送到网络中
//因为这个是归TCP协议和OS来管的
//write、send函数也是一样的道理,都是把消息从接收缓冲区获取到就完事了!!!!!!
//我们用户只负责把信息发送到发送缓冲区或者是从接收缓冲区接收信息罢了!!!
//所以:
//• 在任何一台主机上,TCP连接既有发送缓冲区,又有接受缓冲区
//所以,在内核中,可以在发消息的同时,也可以收消息,即全双工
//• 这就是为什么一个tcp sockfd读写都是它的原因
//• 实际数据什么时候发,发多少,出错了怎么办,由TCP控制,所以TCP叫做传输控制协议
//那么前面也说了,序列化和反序列化需要我们自己实现,所以看了上面的内容
//你一下子就又懂了,我们的序列化就是得在send/write信息到发送缓冲区之前去实现序列化
//不然我们就没有机会去实现序列化,因为一旦进了发送缓冲区,我们就管不了了!!!
//而反序列化就得在从接收缓冲区read/recv信息之后去进行
//因为在接收缓冲区的时候,我们管不了!!!!!!!!!!!
//所以,你就明白了,为什么TCP能实现全双工!!!什么时候我们要进行序列化和反序列化了!!!
//但是,知道这些还不够!!!因为,我们还没有知道TCP协议的一个小问题
//那就是:它是面向字节流的!!!(SOCK_STREAM)
//什么意思????什么是面向字节流??????
//面向字节流的意思就是,它是以字节为单位去进行发送、接收信息的!!!!!!
//也就代表说,会出现粘包的可能性!!!
//其实面向字节流在之前的博客中,我是有讲到过的
//我们上面说过,是有发送缓冲区和接收缓冲区,那么缓冲区缓冲区,缓冲区就是指说OS并不会立马把消息传给recv
//也不会再send之后里面把信息发送到网络中!!!
//那么假设我们第一次发送了hello,第二次发送了world
//那么按照我们的设想,对面主机也应该是先收到hello,然后再去收到world
//可是,事实不是这样子的!!!
//因为TCP协议是面向字节流的,再加上有缓冲区,所以它可能会是先发送hel,然后发送lo,然后发送world
//也可能是先发送hellowor,然后再ld,都有可能,随意组合
//因为它是以字节为单位的,而字符串中一个字符的大小就是一个字节!!!!!
//那么对方收到的也就可能是helloworld,可能是h,ellow,or,ld,都有可能!!!
//那有人可能会好奇,诶,那我们不是序列化和反序列化了吗???
//哈哈,问题是,序列化和反序列化只不过是字符串和数据化结构的切换罢了,
//它并不能保证字符串丢到缓冲区、网络的完整性!!
//所以,这就很糟糕了,难道说,对方真就接收个hel,loworld??这未免太离谱了!!
//肯定不行的,所以,既然你TCP协议不能保证,那就我们自己定义协议去保证
//而这,就是自定义协议!!!!!!!!!
//我们不是无法保证recv到的数据是完好的,正确的吗???
//那我们就自定义协议为什么样的数据才是完好的,正确的,如果没到要求,
//如果是长了,就截取,如果是短了,就再去recv
//而在我们这里,我们是要实现网络计算器,所以:
//约定方案一:
//• 客户端发送一个形如"1+2"的字符串;
//• 这个字符串中有两个操作数, 都是整型;
//• 两个数字之间会有一个字符是运算符, 运算符只能是 +、-、*、/ ;
//• 数字和运算符之间没有空格;
//• ...
//约定方案二:
//• 定义结构体来表示我们需要交互的信息;
//• 发送数据时将这个结构体按照一个规则转换成字符串,
//接收到数据的时候再按照相同的规则把字符串转化回结构体;
//• 这个过程叫做 "序列化" 和 "反序列化"
//所以,根据上面的需求,我们就创建三个类
//第一个类是Request,是客户端要向服务端发送计算问题的结构化类
//那么它就得是结构化,这个很好理解
//那么它还得承担序列化和反序列化的
//序列化负责将客户端要向服务端发送计算问题的该结构的结构化数据序列化为字符串
//反序列化负责将服务端recv到的客户端发送的字符串反序列化为结构化数据
//因为你服务端要进行计算吧,而你不可能直接就用字符串去进行计算吧???
//你肯定得先获取到字符串数据,然后再将其转换为结构化数据,然后才能正常计算
//所以,客户端和服务端都得有各自的Request对象才行
//客户端负责将用户输入的问题填充到里面,服务端负责将收到的字符串信息反序列化填充到里面!!!
//而这序列化和反序列化的函数,肯定就得在Request类里面实现!!!
//第二个类是Responce,是服务端获取到计算问题后得到结果的将结果结构化的结构化类
//那么一样的道理,它也得承担序列化和反序列化
//序列化负责将得到的结果序列化为字符串数据发送给客户端
//而反序列化负责将客户端收到的服务端发来的字符串结果反序列化为结构化数据从而得到答案
//毕竟你客户端不可能直接从字符串里面去找答案吧!!!
//所以,我们就又明白了,客户端和服务端依旧也得有各自的Responce对象才行
//服务端负责将计算后的结果填充到里面,客户端负责将得到的字符串结果反序列填充到里面!!!
//这两个类确实比较难理解,本质上还是我们要知道说,
//网络的本质就是通信的双方有着一样的结构!!!
//而第三个类,就是我们的自定义协议类Protocol
//这个类是负责做什么呢???
//首先,这个类要负责在序列化后的问题字符串/结果字符串前面加上各自的长度!!!
//为什么要这样???
//还是因为面向字节流的原因,因为面向字节流,所以我们压根不知道要怎么去划分出一个准确的问题/结果
//那么有什么方法能够让我们知道呢???
//那就是在序列化后的数据前面加上该序列化数据字符串的长度!!!
//加上了长度之后,再去发送!!!
//这样子我们就能获取到该序列化数据字符串的长度!!!
//由此,我们就可以去将收到的字符串的长度去于字符串本身所标注的长度作对比
//如果大了,那么就代表收到的数据是大于一个问题/结果的,我们就得分割,提取出一个问题/结果来
//如果小了,那么就代表收到的数据是小于一个问题/结果的,我们就得再去接收数据,直到等于或者大于!!!
//而又为什么是要在序列化之后???因为本质上要获取的就是那一串序列化的数据
//之所以要加上长度是为了能够精准抓住序列化的数据!!!
//所以,这一步是非常关键的一步!!!
//那么我们可不敢仅仅只加个长度就完事,毕竟面向字节流不靠谱,所以我们就得在序列化字符串的前后再加一点分隔符
//比如/r/n,所以,我们要的效果就得是:50\r\n协议号\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
//结果:50\r\n协议号\r\n{"result": 30, "status" : "正常运算"}\r\n
//那么多个混合在一起就是50\r\n协议号\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n50\r\n协议号\r\n等等
//但是混合就混合,我们压根不担心,
//因为我们设置了分隔符还有序列化字符串本身的长度,所以我们一定能够提取出序列化字符串的!!!
//所以,protocol,首先就是要实现在要发送之前的序列化数据字符串前后加上分隔符,最前面加上序列化字符串的长度
//那么,你对序列化字符串进行加长度和分隔符了,可是真正的正确的序列化字符串不是那一个整个呀,那一整个是完整的报头
//你肯定得把正确的真正的序列化字符串提取出来给计算函数或者提取结果!!!
//所以,我们除了要有上面的加码函数去实现对序列化字符串加码,那么你就还得有相对应的解码函数
//所以,我们还需要去实现解码函数,这个函数的职责就是负责从完整报头去提取出真正的精确的序列化字符串
//所以,哦,你就知道了,我们要实现加码函数和解码函数,因为,这是我们自己的自定义协议,所以,你有加码,就必须要解码!!!!
//这一步还是要懂得的
//那么,procotol仅仅完成这个功能,还不够!!!
//想想看,我们是保障了能够提取出准确的序列化数据字符串了
//可是谁去处理收到的字符串的长度去于字符串本身所标注的长度不同的问题呢???
//如果大了,那么就代表收到的数据是大于一个问题/结果的,我们就得分割,提取出一个问题/结果来
//如果小了,那么就代表收到的数据是小于一个问题/结果的,我们就得再去接收数据,直到等于或者大于!!!
//毋庸置疑,还是protocol类
//而且,提取出问题/结果了之后,肯定还得把反序列化后的问题/结果发送给服务端/客户端吧
//所以,protocol类,还要有GetRequest函数和GetResponce函数
//GetRequest函数是服务端中调用去先接收客户端发送的消息
//然后交给解码Encode函数去提取出准确的客户端发送的序列化问题字符串,
//然后将问题字符串反序列化到Request结构,并交给计算函数去计算结果,将结果填充到Responce结构中
//最后再将填充了结果之后的Responce结构序列化为结果字符串去发送给客户端
//GetResponce函数是客户端中调用去提取出准确的服务端发送的序列化结果字符串,
//然后将结果字符串反序列化填充到Responce结构中,最后获取到结果
//由此,我们的Protocol类才算是完善一些
//下面我们就来一个一个实现我们上面所说的
/**
* Request类:客户端向服务端传递计算问题的结构化数据载体
*
* 1. 核心定位:作为封装计算请求(如x=10、y=20、oper='+')的结构化类,
* 结构化设计能让计算相关的参数(操作数、运算符)被规整管理,便于读写和维护;
*
* 2. 核心职责:承担序列化与反序列化功能
* - 序列化:将类中封装的结构化计算数据(x、y、oper)转换为字符串形式,
* 满足网络传输只能传递字节流/字符串的底层限制;
* - 反序列化:将服务端通过recv接收到的客户端字符串数据,还原为结构化的Request对象,
* 让服务端能直接获取可计算的数值和运算符;
*
* 3. 反序列化的必要性:服务端需要执行具体的计算逻辑,无法直接对字符串(如"10+20")进行运算,
* 必须先将字符串还原为包含明确数值、运算符的结构化数据,才能正常开展计算;
*
* 4. 两端使用逻辑:
* - 客户端:将用户输入的计算问题(如10+20)填充到Request对象中,序列化后发送给服务端;
* - 服务端:将接收到的字符串数据反序列化,填充到本地Request对象中,提取数据用于计算;
*
* 5. 函数实现:序列化、反序列化的核心逻辑均内置在本类中,保证数据处理逻辑的内聚性。
*/
class Request
{
public:
//构造函数
//这个函数是用于客户端让用户输入问题
//然后形成问题的
Request(int x,char oper,int y)
:_x(x)
,_y(y)
,_oper(oper)
{}
//无参构造函数
//用于GetRequset中将服务端所获取到的客户端发送的问题反序列化并传入计算函数中
Request()
{}
//析构函数
~Request()
{}
//序列化函数
//序列化负责将客户端要向服务端发送计算问题的该结构的结构化数据序列化为字符串
std::string Serialize()
{
//使用Json去进行序列化
Json::Value root;
root["x"]=_x;
//root["oper"]=_oper;
//char → 单字符string
//因为Json不支持char!!!
root["oper"] = std::string(1, _oper); // 1表示字符串长度,_oper是要存入的字符
root["y"]=_y;
Json::FastWriter fastwriter;
std::string problem=fastwriter.write(root);
return problem;
}
//反序列化函数
//反序列化负责将服务端recv到的客户端发送的字符串反序列化为结构化数据
void Deserialize(const std::string& res)
{
//使用Json去反序列化
Json::Reader reader;
Json::Value root;
bool parse_ok=reader.parse(res,root);//第一个参数传入获取到的精确的Json字符串
if(parse_ok==false)
{
return ;
}
//将反序列化后的数据都丢给成员变量
//那么这个函数也就是protocol中创建一个新的Request对象去调用的,
//然后计算函数可以从这个对象中获取x、运算符、y从而进行计算
if(root.isMember("x")&&root["x"].isInt())//检测是否正确
{
_x=root["x"].asInt();//取出
}
if(root.isMember("oper")&&root["oper"].isString())//检测是否正确
{
_oper=root["oper"].asString()[0];//取出运算符,字符串中的[0],因为Json没有提供asChar函数
}
if(root.isMember("y")&&root["y"].isInt())//检测是否正确
{
_y=root["y"].asInt();//取出
}
}
//那么反序列化函数也就是protocol中创建一个新的Request对象去调用的,
//然后计算函数可以从这个对象中获取x、运算符、y从而进行计算
//但是这些都是私有成员变量,所以就得我们自己去开放接口
const int GetX() const
{
return _x;
}
const char GetOper() const
{
return _oper;
}
const int GetY() const
{
return _y;
}
private:
//_x+_y
int _x;//前面一个数
char _oper;//中间的运算符
int _y;//后面一个数
};
/**
* @brief Response类:服务端向客户端返回计算结果的结构化数据载体
*
* 1. 核心定位:服务端解析并完成计算请求后,将计算结果(含运算值、状态码)封装为结构化类,
* 便于结果数据的规整管理和网络传输;
*
* 2. 核心职责:承担序列化与反序列化功能
* - 序列化:将类中封装的结构化计算结果转换为字符串形式,满足网络仅能传输字符串/字节流的底层限制,
* 服务端通过该功能将结果发送给客户端;
* - 反序列化:将客户端接收到的服务端字符串结果,还原为结构化的Response对象,
* 让客户端能直接提取明确的计算答案(而非从字符串中手动查找);
*
* 3. 反序列化的必要性:客户端无法直接从原始字符串中高效、准确获取计算答案,
* 必须通过反序列化将字符串转换为包含明确结果、状态码的结构化数据,才能清晰得到最终答案;
*
* 4. 两端使用逻辑:
* - 服务端:将计算完成后的结果(运算值、状态码)填充到Response对象中,序列化后发送给客户端;
* - 客户端:将接收到的字符串结果反序列化,填充到本地Response对象中,提取并展示答案;
*
* 5. 核心本质:Request和Response类的设计,本质体现了网络通信的关键原则------通信双方需约定完全一致的数据结构,
* 才能完成「结构化数据→字符串→结构化数据」的完整流转,保证数据传输和解析的正确性。
*/
class Response
{
public:
//构造函数
Response()
{}
//析构函数
~Response()
{}
//序列化负责将得到的结果序列化为字符串数据发送给客户端
std::string Serialize()
{
//使用Json进行序列化
Json::Value root;
root["result"]=_result;
root["status"]=_status;
Json::FastWriter fastwriter;
std::string result=fastwriter.write(root);
return result;
}
//反序列化负责将客户端收到的服务端发来的字符串结果反序列化为结构化数据从而得到答案
void Deserialize(std::string result)
{
//使用Json进行反序列化
Json::Reader reader;
Json::Value root;
bool parse_ok=reader.parse(result,root);//第一个参数传入获取到的精确的Json字符串
if(parse_ok==false)
{
return ;
}
if(root.isMember("result")&&root["result"].isInt())//检测是否正确
{
_result=root["result"].asInt();//取出
}
if(root.isMember("status")&&root["status"].isString())//检测是否正确
{
_status=root["status"].asString();//取出
}
}
//那么想想看,计算函数在将结果计算完毕之后,是不是得把结果存进Response对象中呢??
//所以计算函数就得创建一个Response对象
//所以我们就得设立可以赋值Response类内的成员变量的函数
void SetResult(const int result)
{
_result=result;
}
void SetStatus(const std::string& status)
{
_status=status;
}
//再设置将运算结果和状态打印出来的函数
//用于客户端调用从而打印到终端上
void ShowResultAndStatus()
{
std::cout << "运算结果:" << _result << " | 运算状态:" << _status << std::endl;
}
private:
// 5[正常]
int _result;//计算结果
std::string _status;//万一要是有除0了,我们只返回结果的话,用户不知道
//所以我们要再加个状态表明运算状态是都正确
};
/**
* @brief Protocol类:自定义应用层协议类,解决TCP字节流粘包/拆包问题,完成请求/响应的封装与解析
*
* 一、核心设计背景:
* TCP是面向字节流的传输协议,无法天然划分"一个完整的请求/响应"边界,
* 直接传输序列化后的字符串会出现粘包(多个请求/响应混在一起)、拆包(一个请求/响应被拆分)问题,
* 因此需要自定义协议规则保证能提取出准确的序列化数据。
*
* 二、核心解决方案:报文封装规则(序列化后处理)
* 1. 处理时机:必须在Request/Response序列化后操作(目标是精准抓取序列化后的核心数据);
* 2. 封装格式:长度 + 分隔符(\r\n) + 协议号(可选) + 分隔符(\r\n) + 序列化字符串 + 分隔符(\r\n)
* 示例:50\r\n协议号\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
* 结果示例:50\r\n协议号\r\n{"result": 30, "status" : "正常运算"}\r\n
* 3. 封装目的:
* - 长度:标注序列化字符串的实际长度,作为判断报文完整性的核心依据;
* - 分隔符(\r\n):辅助分割长度、协议号、序列化字符串,避免字节流混淆;
*
* 三、报文完整性判断逻辑(核心):
* 1. 接收数据后,先提取"标注的长度",对比已接收数据的总长度;
* 2. 若已接收长度 < 标注长度:说明报文不完整,需继续接收数据直至≥标注长度;
* 3. 若已接收长度 > 标注长度:说明存在粘包(多个报文混合),需按标注长度分割,提取出第一个完整报文,剩余数据留待后续处理;
* 4. 即使多个报文混合(如A报文+B报文),通过"长度+分隔符"仍能精准提取每个完整的序列化字符串。
*
* 四、核心功能:
* 1. Encode(编码):给序列化后的字符串添加长度+分隔符,构造符合协议的可传输报文;
* 2. Decode(解码):校验报文完整性,提取出完整的序列化字符串(核心解决粘包/拆包);
* 3. GetRequest(服务端专用):
* - 提取客户端发送的完整序列化请求字符串;
* - 将字符串反序列化到Request对象,交给计算函数得到结果;
* - 将结果填充到Response对象,序列化后发送给客户端;
* 4. GetResponse(客户端专用):
* - 提取服务端发送的完整序列化结果字符串;
* - 将字符串反序列化填充到Response对象,最终获取计算结果。
*/
//我们得让本类有计算函数,这样子才能调用计算函数
//所以就得进行包装!!!
using calfunc_t = std::function<std::unique_ptr<Response>(const std::shared_ptr<Request>&)>;
class Protocol
{
public:
//构造函数
Protocol()
{}
//传入计算函数的构造函数
Protocol(calfunc_t calfunc)
:_calfunc(calfunc)
{}
//析构函数
~Protocol()
{}
//Encode(编码):给序列化后的字符串添加长度+分隔符,构造符合协议的可传输报文;
//首先,这个类要负责在序列化后的问题字符串/结果字符串前面加上各自的长度!!!
//为什么要这样???
//还是因为面向字节流的原因,因为面向字节流,所以我们压根不知道要怎么去划分出一个准确的问题/结果
//那么有什么方法能够让我们知道呢???
//那就是在序列化后的数据前面加上该序列化数据字符串的长度!!!
//加上了长度之后,再去发送!!!
//这样子我们就能获取到该序列化数据字符串的长度!!!
//由此,我们就可以去将收到的字符串的长度去于字符串本身所标注的长度作对比
//如果大了,那么就代表收到的数据是大于一个问题/结果的,我们就得分割,提取出一个问题/结果来
//如果小了,那么就代表收到的数据是小于一个问题/结果的,我们就得再去接收数据,直到等于或者大于!!!
//而又为什么是要在序列化之后???因为本质上要获取的就是那一串序列化的数据
//之所以要加上长度是为了能够精准抓住序列化的数据!!!
//所以,这一步是非常关键的一步!!!
//那么我们可不敢仅仅只加个长度就完事,毕竟面向字节流不靠谱,所以我们就得在序列化字符串的前后再加一点分隔符
//比如/r/n,所以,我们要的效果就得是:50\r\n协议号\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
//结果:50\r\n协议号\r\n{"result": 30, "status" : "正常运算"}\r\n
//定义分隔符
const std::string gap="\r\n";
//定义协议号
const std::string protocolnum="win";
std::string Encode(std::string& serialize_message)
{
//50\r\n协议号\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
//所以外界要先传入序列化后的字符串
//然后我们获取到该序列化的字符串的长度
std::string serialize_message_len=std::to_string(serialize_message.size());
//然后就是进行愉快的追加append
std::string total=serialize_message_len+gap+protocolnum+gap+serialize_message+gap;
//50\r\nwin\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
return total;
}
//Decode(解码):校验报文完整性,提取出完整的序列化字符串(核心解决粘包/拆包);
//那么我们就要通过这个函数去在收到的字符串中提出正确的精确的一个序列化字符串
// 50\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
// 5
// 50
// 50\r
// 50\r\n
// 50\r\n{"x": 10, "
// 50\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
// 50\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n50\r\n{"x": 10, "y" : 20, "ope
//.....
// packge故意是&
// 1. 判断报文完整性
// 2. 如果包含至少一个完整请求,提取他, 并从收到的消息中移除它,方便处理下一个
//那么我们的解码函数,它肯定就得判断收到的信息是少了还是多了
//如果大了,那么就代表收到的数据是大于一个问题/结果的,我们就得分割,提取出一个问题/结果来
//如果小了,那么就代表收到的数据是小于一个问题/结果的,我们就得再去接收数据,直到等于或者大于!!!
//那么所以它的返回值得设置为bool,用于去让GetRequest和GetResponse函数去判断要不要继续recv数据
//但是,我们得获取到正确的精确的一个序列化字符串吧,可是不能返回啊???
//没事,那么外界就再传入一个接收该字符串的指针就行了!!!
bool Decode(std::string& recv_message,std::string* true_message)
{
if (true_message == nullptr)
{
LOG(LogLevel::ERROR) << "true_message 指针为空!";
return false;
}
//每次解码失败去再次recv数据的循环
//都会重头再去判断一遍!!!
//我们得先去找分隔符!!!
size_t pos=recv_message.find(gap);
//Return Value:The position of the first character of the first match.
if(pos==std::string::npos)
{
// 5
// 50
// 50\r
return false;//代表没找到分隔符,不好意思,请你继续recv信息
}
// 50\r\n{"x": 10, "
// 50\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
// 50\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n50\r\n{"x": 10, "y" : 20, "ope
//那么找到了分隔符了,我们就得去把分隔符前面的序列化字符串长度得到
std::string string_serialize_message_len=recv_message.substr(0,pos);
//左闭右开
//你可能会好奇,从收到的消息的第一个字符开始截取,不会出错吗??
//万一是}\r\n50\r\n{"x": 10, "y" : 20, "ope呢???
//放心,不可能出现这个情况,每次传入本函数的字符串的第一个字符一定会是数字???
//为什么???
//因为首先你第一次发送信息,开头肯定是数字开头吧
//那么当我们能获取到一个完整的序列化字符串了,我们再去把这个字符串获取到传给对应的反序列化函数
//但是没完,要是大了,那么就代表收到的数据是大于一个问题/结果的,我们就得分割,提取出一个问题/结果来
//我们还得把这一整个字符串从传入本函数的收到的信息中去删除掉,即erase函数
//这么一来,当下次传入来的收到的信息,只会加在后面,所以开头就又一定是数字了
//如此循环往复,我们就能保证了!!!
//原:50\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n50\r\n{"x": 10, "y" : 20, "ope
//新:50\r\n{"x": 10, "y" : 20, "ope
//那么,在获取了长度之后,可是不够的
//因为一个真正的完整的报头(算上我们加的长度和分隔符)是不止这些长度的
//所以我们一定得是能获取到完整的报头了,才敢去提出正确的序列化字符串!!!
//不够的话,就请再去recv
//那么怎么判断我们是否能获取到完整的报头
//简单,用完整的报头长度和传入的字符串的长度去进行比较不就行了
//50\r\nwin\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
//完整的报头长度就是记录数据长度的长度+三个\r\n的长度+数据长度
int serialize_message_len=0;
try //捕获异常,万一stoi函数失败
{
serialize_message_len = stoi(string_serialize_message_len);
}
catch (const std::exception& e)
{
LOG(LogLevel::ERROR) << "长度字段解析失败:" << e.what();
recv_message.erase(0, pos + gap.size()); // 清理错误数据,避免死循环
return false;
}
size_t total=string_serialize_message_len.size()+serialize_message_len+protocolnum.size()+3*gap.size();
if(recv_message.size()<total)
{
return false;//不够的话,就请再去recv
}
//直到这里,我们才能确定,传进来的recv的数据长度要么等于完整报头长度,要么大于完整报头长度
//接下来我们就是要提取出准确的序列化数据,然后传给输出型参数true_message
//再然后去将该完整报头从recv到的字符串中erase掉
//50\r\nwin\r\n{"x": 10, "y" : 20, "oper" : '+'}\r\n
//报头开始到序列化数据的长度
//50\r\nwin\r\n
int frontlen=string_serialize_message_len.size()+2*gap.size()+protocolnum.size();
//记得解引用
*true_message=recv_message.substr(frontlen,serialize_message_len);
//左闭右开
// substr 是 C++ string 提取子串的核心函数,参数规则是:
// string substr (size_t pos = 0, size_t len = npos) const;
// 第一个参数 pos:子串的起始位置(从 0 开始计数);
// 第二个参数 len:子串的长度(要截取多少个字符),而非结束位置;
//然后就是将该完整报头从recv到的字符串中erase掉
recv_message.erase(0,total);
return true;//表示解码成功!!!
}
//GetRequest函数是服务端中调用去先接收客户端发送的消息
//然后交给解码Encode函数去提取出准确的客户端发送的序列化问题字符串,
//然后将问题字符串反序列化到Request结构,并交给计算函数去计算结果,将结果填充到Responce结构中
//最后再将填充了结果之后的Responce结构序列化为结果字符串去发送给客户端
//那么你要接收客户端发送的序列化字符串以及发送结果序列化字符串给客户端的话,
//你就得先有服务端accept获取到的socket套接字吧
//然后还得有客户端的ip地址和端口号吧
//所以此时我们封装的TcpSocket类的作用就体现出来了
void GetRequest(std::shared_ptr<TcpSocket>& accept_socketfd)//之所以要传入IneftAddr对象,是为了将哪个客户端发送的问题传进日志中去进行记录
{
//那么你服务端获取请求要一直获取吧!!!
//不可能只获取一次就不干了,那能行吗???
//不行,所以,我们的GetRequest是得死循环执行的!!!
//GetResponse函数之所以要死循环,是因为服务端单独创建了一个孙子进程用来完全服务于一个客户端!!!
//那么你这个孙子进程,就是要一直服务于和它accept之后的一个客户端,所以你肯定就要一直获取到客户端发送的问题并进行解决然后发送回客户端吧
//你不能说,你这个孙子进程就解决一次问题之后,诶,就退出不干了!!!
//那么我客户端难道再和服务端进行链接?????
//拜托,只连接一次诶,哪里能链接了又链接!!!
//再加上你和一个客户端accept建立了链接之后的所分配的一个孙子进程肯定就要一直获取到客户端发送的问题并进行解决然后发送回客户端吧
//所以,你这个孙子进程,肯定就是需要死循环的运行GetResponse函数,去一直获取到和你所链接的客户端发送的问题并进行解决然后发送回该客户端吧
//所以这就是为什么GetResponse函数要进行死循环的原因
//要是不进行死循环的话,那么你就会发现,客户端发送了一个问题之后,诶,再发送下个问题的时候,服务端就G了
//这就是因为GetResponse函数没有死循环,孙子进程进行了一次之后就退出了,那么你这个孙子进程退出了,也就代表和你这个客户端建立链接的处理进程没了
//那么自然再发消息过去就会失败
while(true)
{
std::string recv_message;
//为什么要累加???
//因为TCP是面向字节流,因为我们怕收到的数据少于一个完整报头!!!
//所以,要是少了的话,我们就得去再recv序列化字符串!!!
//可是之前recv的呢???
//加上啊!!!!!!!!!!!!!!!!!!!!!
//然后我们要将获取到的数据丢进Decode函数中去进行解码
//那么解码的逻辑我们也知道,只要不符合要求,就再去recv序列化字符串
//所以,我们就得调用while循环
//那么既然都要while循环了,不如将一开始的recv数据也丢进循环中!!!!!!!
//创建接收准确序列化字符串的string
std::string true_message;
while(Decode(recv_message,&true_message)==false)//第一次是解析空字符串,肯定失败,所以就肯定会进入第一次循环recv数据
{
//那么由于我们封装好了TcpSocket类
//并且还传入了std::shared_ptr<TcpSocket>指针,所以我们可以直接通过这个指针里面的Recv函数去接收信息
int ret_Recv=accept_socketfd->Recv(recv_message);
if(ret_Recv<0)
{
LOG(LogLevel::FATAL)<<"recv failed";
break;
}
else if(ret_Recv==0)
{
LOG(LogLevel::INFO)<<"a stream socket peer has performed an orderly shutdown(服务端关闭)";
break;
}
}
//出了循环之后,就代表解码成功,我们获取到了准确的一个问题
//那么我们就要将这个问题序列化字符串给反序列化丢进Requset对象中
//然后再让计算函数去解决这个问题,并将结果丢进Response对象中,那么这个步骤是计算函数做的
//所以,我们就需要给计算函数传什么???
//传入Request对象啊!!!!!!!
//因为我们要反序列化问题字符串
//而这是在Request类中
//所以你也就知道计算函数需要有什么参数了!!!
//所以,我们就得先创建一个Request类
//智能指针也行,都是可以的
std::shared_ptr<Request> re(std::make_shared<Request>());
//调用反序列化函数
re->Deserialize(true_message);
//然后将该Request对象传进计算函数中用于计算结果
//那么本函数是服务端要调用的函数,除了计算结果
//还要承担起将结果发送给客户端的使命
//那么结果是放在哪里,结果是放在Response结构里面
//而哪里能计算结果,计算函数,所以,计算函数的返回值,就是Response!!!
//当然,智能指针也行,看个人爱好
std::unique_ptr<Response> res=_calfunc(re);
if (!res)
{ // 检查返回值
LOG(LogLevel::ERROR) << "计算函数返回空结果!";
return;
}
//然后我们就要将Response里面的结果序列化发送给客户端
//所以就要调用Response里面的序列化函数
std::string result=res->Serialize();
//然后再将序列化的字符串去加码添加报头
std::string total_message=Encode(result);
//然后将这个结果序列化字符串发送给客户端
//也是使用我们封装好的TcpSocket类里面的Send函数
accept_socketfd->Send(total_message);
//至此服务端才算干完了活
}
}
//GetResponce函数是客户端中调用去提取出准确的服务端发送的序列化结果字符串,
//然后将结果字符串反序列化填充到Responce结构中,最后获取到结果
//那么我们得设置形参传入客户端的TcpSocket变量,由此才能进行接收服务端发送的信息
void GetResponse(std::unique_ptr<TcpSocket>& client_tcpsocket)
{
//这里我们要注意,本函数不应该设置为死循环,为什么???
//因为本函数我们是设置的要获取到用户输入的问题之后,然后再获取结果,最后将结果返回
//那么想想看,想要将结果返回,必须先获取用户发的问题,而本函数是不会获取用户的问题的
//只会返回并打印结果,那么你要是让这个函数死循环了???
//想想看,这个函数是客户端调用的,客户端调用你这个函数然后死循环了,那么客户端还怎么去获取用户的输入呢???
//所以,我们就要知道,这个函数是不能死循环的
//而GetResponse函数之所以要死循环,是因为服务端单独创建了一个孙子进程用来完全服务于一个客户端!!!
//那么你这个孙子进程,就是要一直服务于和它accept之后的一个客户端,所以你肯定就要一直获取到客户端发送的问题并进行解决然后发送回客户端吧
//你不能说,你这个孙子进程就解决一次问题之后,诶,就退出不干了!!!
//那么我客户端难道再和服务端进行链接?????
//拜托,只连接一次诶,哪里能链接了又链接!!!
//再加上你和一个客户端accept建立了链接之后的所分配的一个孙子进程肯定就要一直获取到客户端发送的问题并进行解决然后发送回客户端吧
//所以,你这个孙子进程,肯定就是需要死循环的运行GetResponse函数,去一直获取到和你所链接的客户端发送的问题并进行解决然后发送回该客户端吧
//所以这就是为什么GetResponse函数要进行死循环的原因
//要是不进行死循环的话,那么你就会发现,客户端发送了一个问题之后,诶,再发送下个问题的时候,服务端就G了
//这就是因为GetResponse函数没有死循环,孙子进程进行了一次之后就退出了,那么你这个孙子进程退出了,也就代表和你这个客户端建立链接的处理进程没了
//那么自然再发消息过去就会失败
//那么一样的,客户端得先去接收信息
//然后进行解码,要是解码失败,就一直解码,直到获取到准确的序列化结果字符串
//思路和GetRequest的差不多
std::string recv_result;
std::string true_result;
while(Decode(recv_result,&true_result)==false)
{
//那么由于我们封装好了TcpSocket类
//并且还传入了std::unique_ptr<TcpSocket>指针,所以我们可以直接通过这个指针里面的Recv函数去接收信息
int ret_Recv=client_tcpsocket->Recv(recv_result);
if(ret_Recv<0)
{
LOG(LogLevel::FATAL)<<"recv failed";
break;
}
else if(ret_Recv==0)
{
LOG(LogLevel::INFO)<<"a stream socket peer has performed an orderly shutdown(服务端关闭)";
break;
}
}
//出来循环到这里了,就代表解码一定是成功了
//那么我们就可以将获取到序列化结果字符串反序列化并填充到Response中
std::shared_ptr<Response> res(std::make_shared<Response>());
res->Deserialize(true_result);
//再去调用将运算结果和状态打印出来的函数
//用于客户端调用从而打印到终端上
res->ShowResultAndStatus();
//至此该函数才算是大功告成
}
//那么在这里我们再加一个可以让客户端一键形成Request结构化数据以及加号报头(Encode)的函数
//不要让外界能够用到该类,实现底层封装!!!
std::string CreateRequest(int x,char oper,int y)
{
std::unique_ptr<Request> request(std::make_unique<Request>(x,oper,y));
//调用Request类中的序列化函数,形成序列化问题字符串
std::string serialize_problem=request->Serialize();
//然后再添加报头
std::string total=Encode(serialize_problem);
return total;//那么将完整报头发送给服务端的使命就交给客户端自己了!!!
}
private:
//我们得让本类有计算函数,这样子才能调用计算函数
//包装起来,统一类型
calfunc_t _calfunc;
};
//到这里,我们的自定义协议函数就算是差不多了!!!
Socket.hpp # Socket封装头文件
cpp
#pragma once
#include <iostream>
#include <memory>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <functional>
#include <cerrno>
#include <cstring>
#include <sys/wait.h>
//#include "Log.hpp"
#include "Common.hpp"
//#include "InetAddr.hpp" //可以利用该头文件做优化,但这里向突出最原始的,所以没有使用,在翻译这里,我们就进行使用简化
// OK,那么在本文件中,我们就秉承着我们之前的封装大法
// 对TCPsocket等等系列的函数都去进行一个封装,方便于外部的使用,大大提高效率
// 但是嘞,在这里我们换一种方式来进行封装
// 使用模版方法类方式,和策略模式有那么一丢丢类似
// 本质也是利用多态!!!
// 想想看,TCP和UDP都是用类似的一些函数,socket创建套接字,bind绑定,liseten监听,accept接受等等
// 虽然UDP没有后面那么些函数,但是我们是主用TCP的呀
// 那么对于UDP和TCP而言,它们都要进行socket创建套接字,bind绑定
// 那么难道要在TCP和UDP中把两个都各自实现???
// 这未免代码冗余
// 所以我们可以把这些相同的步骤,都放在基类中实现,而不用的步骤,再在各自的子类中去实现
// 大大简洁
// 其实在这里我们直接封装也可以,但使用模版方法类方式会更显得我们专业
// 具体要如何实现呢???
// 1. 模式结构
// 模板方法模式包含以下核心角色:
// 抽象类(Abstract Class):定义算法的骨架(模板方法),
// 声明抽象步骤(primitive operations),由子类实现。
// 可以提供默认实现的钩子方法(hook operations),子类可选覆盖。
// 通常将模板方法声明为 final,防止子类改变算法结构。
// 具体类(Concrete Class):实现抽象步骤,提供具体的业务逻辑,可覆盖钩子方法,调整算法的行为。
// 2. 关键概念
// 模板方法(Template Method):定义算法的流程,按固定顺序调用各个步骤。
// 抽象步骤(Primitive Operations):算法中必须由子类实现的步骤(函数),
// 即基类只负责申明这一些抽象步骤(函数),可用于多态时调用
// 钩子方法(Hook Operations):算法中可选的步骤或扩展点,通常有默认实现。
// 不变部分:在抽象类中实现,所有子类共享。
// 可变部分:在子类中实现,不同子类有不同实现。
/*************************************************************************
* 模板方法模式核心理解:
* 核心比喻:就像老师给的作文模板 ------ 开头、结尾、结构是固定的,中间的具体内容由你自己填。
* 专业定义:模板方法模式是一种「行为型设计模式」,核心是:定义一个算法的骨架(固定流程),
* 把其中可变的步骤延迟到子类中实现。这样子类可以在不改变整体流程的前提下,只修改细节步骤。
*************************************************************************/
/*************************************************************************
* 一、为什么需要模板方法模式?
* 假设要做两款饮品:泡咖啡和泡奶茶,制作流程对比:
* 泡咖啡 泡奶茶
* 1. 烧开水(100℃) 1. 烧开水(100℃)
* 2. 冲泡咖啡粉 2. 冲泡奶茶粉
* 3. 倒入杯子 3. 倒入杯子
* 4. 加方糖 4. 加珍珠 / 椰果
*
* 关键发现:步骤 1、3 是完全一样的(固定流程),步骤 2、4 是不同的(可变细节)。
* 无模板方法的问题:需要给咖啡和奶茶各写一套完整流程,重复写「烧开水」「倒杯子」的代码;
* 有模板方法的优势:把固定流程抽出来,只让子类实现可变步骤 ------ 既省代码,又保证流程不会乱
* (比如不会有人先加方糖再烧开水)。
*************************************************************************/
/*************************************************************************
* 二、模板方法模式的核心角色(通俗版)
* 不用记专业术语,记住 3 个关键部分:
* 角色 通俗解释
* 抽象模板类 定义「固定流程」(比如饮品制作的 4 步),包含:
* 1. 模板方法(固定流程)
* 2. 固定步骤(比如烧开水)
* 3. 抽象步骤(留给子类填的细节)
* 具体实现类 继承抽象模板类,只实现「抽象步骤」(比如咖啡实现 "冲泡咖啡粉",
* 奶茶实现 "冲泡奶茶粉")
* 模板方法(核心) 抽象类中固定流程的方法,通常加 final 防止子类改流程(比如不让人乱改
* "先烧开水" 的顺序)
*************************************************************************/
/*************************************************************************
* 三、实战例子:用代码实现「泡饮品」
* 极简C++版本,核心逻辑一目了然
*************************************************************************/
/*************************************************************************
* 第一步:定义抽象模板类(饮品制作模板)
* 把固定流程(烧开水、倒杯子)写死,可变步骤(冲泡、加调料)留空让子类实现
*************************************************************************/
// 抽象模板类:饮品制作模板
// class DrinkTemplate {
// public:
// // 模板方法:固定制作流程(final防止子类修改流程)
// final void makeDrink() {
// boilWater(); // 固定步骤1:烧开水
// brew(); // 抽象步骤2:冲泡原料(子类实现)
// pourInCup(); // 固定步骤3:倒入杯子
// addCondiment();// 抽象步骤4:加调料(子类实现)
// }
// // 固定步骤:烧开水(所有饮品都一样)
// void boilWater() {
// cout << "1. 烧开水(100℃)" << endl;
// }
// // 固定步骤:倒入杯子(所有饮品都一样)
// void pourInCup() {
// cout << "3. 将饮品倒入杯子" << endl;
// }
// // 抽象步骤:冲泡原料(子类自己实现)
// virtual void brew() = 0;
// // 抽象步骤:加调料(子类自己实现)
// virtual void addCondiment() = 0;
// };
/*************************************************************************
* 第二步:实现具体子类(咖啡 / 奶茶)
* 只需要补全「抽象步骤」,不用管整体流程
*************************************************************************/
// 具体实现类1:咖啡
// class Coffee : public DrinkTemplate {
// public:
// // 实现"冲泡原料":咖啡粉
// void brew() override {
// cout << "2. 冲泡咖啡粉" << endl;
// }
// // 实现"加调料":方糖
// void addCondiment() override {
// cout << "4. 加方糖" << endl;
// }
// };
// 具体实现类2:奶茶
// class MilkTea : public DrinkTemplate {
// public:
// // 实现"冲泡原料":奶茶粉
// void brew() override {
// cout << "2. 冲泡奶茶粉" << endl;
// }
// // 实现"加调料":珍珠+椰果
// void addCondiment() override {
// cout << "4. 加珍珠和椰果" << endl;
// }
// };
/*************************************************************************
* 第三步:使用模板方法
* 调用时只需要创建具体子类,执行模板方法即可
*************************************************************************/
// int main() {
// // 泡一杯咖啡
// DrinkTemplate* coffee = new Coffee();
// cout << "===== 制作咖啡 =====" << endl;
// coffee->makeDrink();
// // 泡一杯奶茶
// DrinkTemplate* milkTea = new MilkTea();
// cout << "\n===== 制作奶茶 =====" << endl;
// milkTea->makeDrink();
// delete coffee;
// delete milkTea;
// return 0;
// }
/*************************************************************************
* 运行结果:
* ===== 制作咖啡 =====
* 1. 烧开水(100℃)
* 2. 冲泡咖啡粉
* 3. 将饮品倒入杯子
* 4. 加方糖
*
* ===== 制作奶茶 =====
* 1. 烧开水(100℃)
* 2. 冲泡奶茶粉
* 3. 将饮品倒入杯子
* 4. 加珍珠和椰果
*************************************************************************/
/*************************************************************************
* 四、模板方法模式的核心好处(为什么要用?)
* 1. 代码复用:固定步骤(烧开水、倒杯子)只写一次,子类不用重复写,减少冗余;
* 2. 流程统一:所有子类都遵循相同的算法流程,避免混乱(比如不会有人先加调料再烧开水);
* 3. 扩展方便:新增饮品(比如泡红茶),只需要新建子类,实现brew()和addCondiment()即可,
* 不用改模板类(符合「开闭原则」);
* 4. 控制流程:模板方法用final修饰,子类只能改细节,不能改整体流程,保证核心逻辑不被破坏。
*************************************************************************/
/*************************************************************************
* 五、常见适用场景(哪里能用到?)
* 模板方法模式在实际开发中非常常见,比如:
* 1. 框架生命周期:Spring 的 Bean 生命周期(固定:初始化→使用→销毁,初始化细节由你写);
* 2. 报表生成:固定流程(表头→填充数据→生成文件),填充数据的逻辑由不同报表子类实现;
* 3. 支付流程:固定流程(验证参数→调用接口→处理结果),不同支付方式(微信 / 支付宝)只改「调用接口」步骤;
* 4. 游戏角色技能:固定流程(释放前摇→技能效果→释放后摇),不同技能只改「技能效果」。
*************************************************************************/
/*************************************************************************
* 六、总结
* 模板方法模式的本质就是:定框架、填细节。
* 把不变的「流程骨架」抽成模板,保证一致性;
* 把变化的「细节步骤」留给子类,保证灵活性。
* 就像你做手工模型:说明书(模板)固定了拼接顺序,你只需要按顺序把不同零件(子类细节)
* 拼上去就行,既不会拼错顺序,又能做出不同样式的模型。
*************************************************************************/
// 我们可以创建一个最基础的基类,那么这个基类要抽象出一系列的虚函数,这些也就是抽象步骤
//那么这些虚函数就是要在子类中去具体的重写实现,父类只负责声明
//那么这个时候就有问题了,为什么要在父类中去声明了,然后再去子类中去重载呢??
//不能直接子类吗???
//其实这是因为我们要把基类的部分接口去开放给外界用户使用,而这些函数就可能要调用多个子类重写的虚函数,
//比如TCP子类要重写实现listen、socket、accept、connect等函数
//那么我们在基类中可以就开放一个buildlistensocket的函数,而这个函数就是连续执行socket、listen函数
//这样子就不需要外界自己去调用子类实现的socket、listen函数,可以用这一个函数接口直接实现
//极大的便利了用户的使用,也使得我们将底层隐藏起来
//除此之外,我们把socket形成的套接字放在子类中去作为它的成员变量
//由此我们也可以去通过子类去访问到socket套接字,同时外界无法修改,大大提高安全性
//那么我们知道,模版方法类方式的本质其实是多态
//外界就创建一个基类指针可以去接收子类的指针
//然后再去调用基类指针中build等等函数,然后build函数中调用的那些重写虚函数就会自动更替为
//该基类指针所接受的子类的中的那些重写虚函数,由此实现多态,
//同时也可以通过该基类指针去调用子类和基类都重写的函数
//下面我们就按照上面的思路来实现一下模版方法类方式去封装TCPsocket
namespace SocketModule
{
class TcpSocket;
const static int DEFAULT_BACKLOG = 16; // 监听队列默认长度
const static int RECV_BUF_SIZE = 1024; // 接收缓冲区大小
const static int SOCKET_INVALID = -1; // 无效套接字标识
//最基础的基类
class Socket
{
public:
//虚析构函数
virtual ~Socket()
{}
//要声明一系列网络通信所用到的函数
//注意都得是虚函数,这样子才能重写,实现多态
//使用socket函数创建套接字的函数:
//不用返回套接字,套接字直接就在子类中成为子类的成员变量
virtual void CreateSocketOrNot()=0;
//使用bind函数将socket和程序绑定的函数
//那么就需要传入端口号,至于ip地址就不用了,因为客户端不用显式bind,在发消息的时候
//OS就会自动给客户端bind绑定上随机端口
//而服务端虽然要显式bind,但是ip地址也是设为INADDR_ANY去增大接收范围
//但是服务端需要绑定指定端口号,这样子客户端才能找的到服务端
virtual void BindSocketOrNot(const uint16_t port)=0;
//关闭自身套接字的函数
//那么我们知道,套接字是存放在子类中的成员变量的,而要是外界想关闭套接字的话
//是没办法直接访问到子类的套接字成员变量的
//所以我们就得提供一个接口用于外界调用去close套接字,避免内存泄露
virtual void CloseSocket()=0;
//使用listen函数去实现监听的函数,用于服务端
//那么由于listen函数需要传入参数去指定监听队列要有多少
//所以我们该函数也需要传入该参数,虽然我们也会设置缺省值!!!
virtual void ListenSocketOrNot(const int backlog=DEFAULT_BACKLOG)=0;
//使用accept函数去让服务端接收客户端发起的链接函数
//那么我们知道,accept函数是不需要什么外界传入的参数的
//它就是会返回accept所创建的新的socket套接字用于一对一的沟通交流
//但是我们又想把这一些都都封装起来,不想让外界可以直接修改
//所以,我们可以把accept函数封装为返回TcpSocket类对象指针
//毕竟TcpSocket类里面是可以存储socketfd套接字文件描述符的
//那么我们返回TcpSocket类对象指针,其实就是创建一个新的TcpSocket类对象指针
//然后我们将accept函数的返回值丢给返回的新的TcpSocket类对象指针
//如此一来就实现了解耦封装
virtual std::shared_ptr<TcpSocket> AcceptSocketOrNot()=0;
//使用connect函数去进行链接的封装函数
//那么我们知道,connect函数是需要外界传入要进行链接的对方主机的ip地址和端口号
//所以我们要设置参数哦
//返回值依旧是不需要
//同样的,connect函数是客户端使用的,
//所以客户端也就只能调用TcpSocket类中的创建socket、connect等函数
//是不能调用accept、listen函数的
//我们本文件只负责把这些函数都封装起来
//但是使用还是需要使用者自己规范
//比如客户端要做的就是创建socket,然后connect
//本质上就是调用该类的CreateSocketOrNot、ConnectSocketOrNot函数
virtual void ConnectSocketOrNot(const std::string& ip,const uint16_t port)=0;
//客户、服务端要用的向服务、客户端发送信息的函数的封装,即对send函数的封装
//那么肯定需要传入要发送的字符串吧,所以要设置字符串形参
//那么我们还得知道发送信息有木有成功,木有就得再发送
//总不能终止通信了吧,所以要把返回值设置为bool
//至于send函数需要的网络套接字,不就是在客户端所创建的TcpSocket类中吗
//直接传调用该函数的类的成员变量即可
//那么客户端在调用他所创建的TcpSocket类的CreateSocketOrNot、ConnectSocketOrNot函数之后,
//自然就是调用他所创建的TcpSocket类中的发送信息的函数
//因为客户端的socket套接字就存储在它所创建的TcpSocket类中!!!
//至于服务端要发送信息给客户端,就肯定是要用accept之后的和每个客户端一对一的socket套接字文件描述符
//所以服务端调用send函数的就是要用服务端调用了AcceptSocketOrNot锁返回的TcpSocket对象指针中的send函数!!!
//因为accept函数返回的和每个客户端一对一的socket套接字文件描述符就是存储在它返回的TcpSocket对象指针中
//依旧是那句话:我们本文件只负责把这些函数都封装起来,但是使用还是需要使用者自己规范
virtual bool Send(const std::string& send_message)=0;
//封装服务端、客户端接收信息的函数,其实也就是对recv函数的封装
//那么服务端我们要把至于守护进程,所以是用不到recv函数的
//而客户端是肯定要接收信息的,所以它就需要recv函数
//那么同样的,你要接收信息,那么你就得传入你要接收信息的字符串吧
//然后我把recv获取到字符串+到你所传入的字符串里
//这样子你就能获取到recv函数所获取的信息了
//那么我们还得知道接收信息有木有成功,木有就得再接收
//总不能终止通信了吧,所以要把返回值设置为int
//返回recv函数的返回值,由外界去判断是什么情况,我们这里没必要做的太具体!!!
//可不要再问我客户端的socket套接字在哪里了,去看看我对Send函数的解析吧
virtual int Recv(std::string& recv_message)=0;
//提供给外界可以一次性调用的接口
//服务端一键创建listen套接字的函数
//那么就是先创建套接字,然后bind,然后listen
void BulidListenSocket(const uint16_t port,const int backlog=DEFAULT_BACKLOG)
{
CreateSocketOrNot();
BindSocketOrNot(port);
ListenSocketOrNot(backlog);
}
//客户端一键connect的函数
//那么就是先创建socket套接字,然后connect
void BulidConnectSocket(const std::string& ip,const uint16_t port)
{
CreateSocketOrNot();
ConnectSocketOrNot(ip,port);
}
//使用这些函数的前提都是服务/客户端创建基类对象/指针
//然后将TcpSocket类对象/指针赋值给基类,然后再去调用这些函数,从而实现多态
//也能直接调用TcpSocket里面的成员函数
//因为在基类中都虚函数了,所以不用担心切片
};
//在这里统一说明一下,我们下面之所以在调用的函数前面加::,
//是为了让编译器知道,我调用的函数是系统的函数,而不是我们自己可能实现的同名函数
//避免编译器混淆报错!!!
//TcpSocket子类,要重写listen、connect等函数
class TcpSocket:public Socket
{
public:
//无参构造函数
TcpSocket()
:_socketfd(SOCKET_INVALID)
{}
//传入套接字的构造函数
//用于accept函数返回一个新的TcpSocket对象指针
//里面放accept函数的返回值,也就是新的一对一的socket套接字文件描述符
TcpSocket(int socketfd)
:_socketfd(socketfd)
{}
//析构函数
//将本类的socket套接字文件描述符close
~TcpSocket()
{
if(_socketfd>=0)
{
::close(_socketfd);
}
}
//创建套接字
//即调用socket函数
//那么其实这个函数所得出来的socket套接字在服务端中也是拿来做listen套接字
virtual void CreateSocketOrNot() override
{
int socketfd=::socket(AF_INET,SOCK_STREAM,0);
if(socketfd<0)
{
std::cerr<<"socket failed!!!"<<strerror(errno)<<std::endl;
exit(SOCKET_ERR);//在Common头文件中
}
//端口复用(服务端必须)
int opt = 1;
if (::setsockopt(socketfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0)
{
std::cerr << "setsockopt SO_REUSEADDR failed: " << strerror(errno) << std::endl;
}
//创建socket套接字成功,赋值给成员变量
_socketfd=socketfd;
//至此创建套接字成功,但是要注意是在当前这个类变量中,有着socket套接字
}
//绑定bind函数,也就服务端需要
virtual void BindSocketOrNot(const uint16_t port) override
{
//老样子,socketaddr_in结构体
struct sockaddr_in server;
::memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
server.sin_port=::htons(port);
//不用担心我们把形参设置为const是不是不可以这样子,htons只是读取它的值,不会进行修改哦
server.sin_addr.s_addr=::htonl(INADDR_ANY);
socklen_t len=sizeof(server);
int ret_bind=::bind(_socketfd,(struct sockaddr*)(&server),len);
if(ret_bind<0)
{
std::cerr<<"bind failed!!!"<<strerror(errno)<<std::endl;
exit(BIND_ERR);//在Common头文件中
}
}
//关闭自身套接字的函数
//那么我们知道,套接字是存放在子类中的成员变量的,而要是外界想关闭套接字的话
//是没办法直接访问到子类的套接字成员变量的
//所以我们就得提供一个接口用于外界调用去close套接字,避免内存泄露
virtual void CloseSocket() override
{
if(_socketfd>=0)
{
::close(_socketfd);
_socketfd = SOCKET_INVALID; //标记为无效,避免重复关闭
}
//就这么简单
}
//使用listen函数去实现监听的函数,用于服务端
//那么由于listen函数需要传入参数去指定监听队列要有多少
//所以我们该函数也需要传入该参数,虽然我们也会设置缺省值!!!
virtual void ListenSocketOrNot(const int backlog=DEFAULT_BACKLOG) override
{
//传入本类变量的socket套接字成员变量即可
//虽然我们本类也要实现accept函数的封装
//但是listen函数和accept函数的调用是分开的,且accept函数是返回新的TcpSocket类对象指针
//我们是不可能用accept函数返回的TcpSocket对象指针去执行listen函数的
//所以调用了listen函数的TcpSocket的本质其实是listensocketfd哦!!!
int ret_listen=::listen(_socketfd,backlog);
if(ret_listen<0)
{
std::cerr<<"listen failed!!!"<<strerror(errno)<<std::endl;
exit(LISTEN_ERR);//在Common头文件中
}
}
//使用accept函数去让服务端接收客户端发起的链接函数
//那么我们知道,accept函数是不需要什么外界传入的参数的
//它就是会返回accept所创建的新的socket套接字用于一对一的沟通交流
//但是我们又想把这一些都都封装起来,不想让外界可以直接修改
//所以,我们可以把accept函数封装为返回TcpSocket类对象指针
//毕竟TcpSocket类里面是可以存储socketfd套接字文件描述符的
//那么我们返回TcpSocket类对象指针,其实就是创建一个新的TcpSocket类对象指针
//然后我们将accept函数的返回值丢给返回的新的TcpSocket类对象指针
//如此一来就实现了解耦封装
virtual std::shared_ptr<TcpSocket> AcceptSocketOrNot() override
{
//老样子,创建socketaddr_in结构体
//用于存储和服务端链接的客户端信息
struct sockaddr_in client;
::memset(&client,0,sizeof(client));
socklen_t len=sizeof(client);
int accept_socketfd=::accept(_socketfd,(struct sockaddr*)(&client),&len);
if(accept_socketfd<0)
{
std::cerr<<"accept failed!!!"<<strerror(errno)<<std::endl;
exit(ACCEPT_ERR);//在Common头文件中
}
//接下来就返回存有accept函数返回值,也就是新的套接字文件描述符的TcpSocket指针
//所以我们要把新的套接字文件描述符传入新的TcpSocket指针中
//这也是为什么我们上面要实现单参数的TcpSocket构造函数的原因
std::shared_ptr<TcpSocket> ret(std::make_shared<TcpSocket>(accept_socketfd));
return ret;
}
//使用connect函数去进行链接的封装函数
//那么我们知道,connect函数是需要外界传入要进行链接的对方主机的ip地址和端口号
//所以我们要设置参数哦
//返回值依旧是不需要
//同样的,connect函数是客户端使用的,
//所以客户端也就只能调用TcpSocket类中的创建socket、connect等函数
//是不能调用accept、listen函数的
//我们本文件只负责把这些函数都封装起来
//但是使用还是需要使用者自己规范
//比如客户端要做的就是创建socket,然后connect
//本质上就是调用该类的CreateSocketOrNot、ConnectSocketOrNot函数
virtual void ConnectSocketOrNot(const std::string& ip,const uint16_t port) override
{
// 前置检查:套接字必须有效
if (_socketfd < 0)
{
std::cerr << "connect failed: socket not created" << std::endl;
exit(CONNECT_ERR);
}
//老样子,创建socketaddr_in结构体
//用于存储客户端要链接的服务端的信息,然后才能connect
struct sockaddr_in server;
::memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
server.sin_port=::htons(port);
//使用线程安全函数去将ip地址转换为网络字节序
int ret_inet_pton=::inet_pton(AF_INET,ip.c_str(),&(server.sin_addr));
if (ret_inet_pton == 0)
{
std::cerr << "inet_pton failed: invalid IP address (" << ip << ")" << std::endl;
exit(CONNECT_ERR);
}
else if (ret_inet_pton == -1)
{
std::cerr << "inet_pton failed: " << strerror(errno) << std::endl;
exit(CONNECT_ERR);
}
socklen_t len=sizeof(server);
int ret_connect=::connect(_socketfd,(struct sockaddr*)(&server),len);
if(ret_connect<0)
{
std::cerr<<"connect failed!!!"<<strerror(errno)<<std::endl;
exit(CONNECT_ERR);//在Common头文件中
}
}
//客户、服务端要用的向服务、客户端发送信息的函数的封装,即对send函数的封装
//那么肯定需要传入要发送的字符串吧,所以要设置字符串形参
//那么我们还得知道发送信息有木有成功,木有就得再发送
//总不能终止通信了吧,所以要把返回值设置为bool
//至于send函数需要的网络套接字,不就是在客户端所创建的TcpSocket类中吗
//直接传调用该函数的类的成员变量即可
//那么客户端在调用他所创建的TcpSocket类的CreateSocketOrNot、ConnectSocketOrNot函数之后,
//自然就是调用他所创建的TcpSocket类中的发送信息的函数
//因为客户端的socket套接字就存储在它所创建的TcpSocket类中!!!
//至于服务端要发送信息给客户端,就肯定是要用accept之后的和每个客户端一对一的socket套接字文件描述符
//所以服务端调用send函数的就是要用服务端调用了AcceptSocketOrNot所返回的TcpSocket对象指针中的send函数!!!
//因为accept函数返回的和每个客户端一对一的socket套接字文件描述符就是存储在它返回的TcpSocket对象指针中
//依旧是那句话:我们本文件只负责把这些函数都封装起来,但是使用还是需要使用者自己规范
virtual bool Send(const std::string& send_message) override
{
// 空字符串直接返回成功,避免无意义循环
if (send_message.empty())
{
return true;
}
int sentlen=0;//定义send函数成功发送的字节数
//要是小于我们要它发送的字符串字节数的话,就一直while循环发送
size_t send_message_len=send_message.size();//总待发送长度
const char* data = send_message.c_str();//要发送的信息的字符串形式
//至于send函数需要的网络套接字,不就是在客户端所创建的TcpSocket类中吗
//直接传调用该函数的类的成员变量即可
while(sentlen<send_message_len)
{
//发送 "未发送的剩余部分"(data+sentlen,长度send_message_len-sentlen)
//因为要是只发送了一部分,那么我们下次发送肯定就得从上次发送结束的地方再去往后发送
//不可能每次都发送全部内容吧!!!
//那么我们怎么知道上次发送结束的地方呢???不就是用sentlen记录着吗
//我们用data加上sentlen,不就是定位到了上次发送结束
//(指针可以++哦,一个字符的大小是一个字节哦)
//那么我们发送的字符长度就应该是send_message_len-sentlen才对
//这一点要注意一下
int ret_send=::send(_socketfd,data+sentlen,send_message_len-sentlen,0);
if(ret_send<0)
{
std::cerr<<"send failed!!!"<<strerror(errno)<<std::endl;
return false;//发送信息失败,自然返回fasle
}
//然后我们要给send函数成功发送的字节数的sentlen加上send函数的返回值
//因为要是一次没有发送完,那么我们得加上上次发送成功的字节数!!!
sentlen+=ret_send;
}
return true;//发送信息成功,自然返回true
}
//封装服务端、客户端接收信息的函数,其实也就是对recv函数的封装
//那么服务端我们要把至于守护进程,所以是用不到recv函数的
//而客户端是肯定要接收信息的,所以它就需要recv函数
//那么同样的,你要接收信息,那么你就得传入你要接收信息的字符串吧
//然后我把recv获取到字符串+到你所传入的字符串里
//这样子你就能获取到recv函数所获取的信息了
//那么我们还得知道接收信息有木有成功,木有就得再接收
//总不能终止通信了吧,所以要把返回值设置为int
//返回recv函数的返回值,由外界去判断是什么情况,我们这里没必要做的太具体!!!
//可不要再问我客户端的socket套接字在哪里了,去看看我对Send函数的解析吧
virtual int Recv(std::string& recv_message) override
{
char buf[RECV_BUF_SIZE]={0};
ssize_t ret_recv=::recv(_socketfd,buf,sizeof(buf)-1,0);
// if(ret_recv<0)
// {
// std::cerr<<"recv failed!!!"<<strerror(errno)<<std::endl;
// return false;//接收信息失败,自然返回fasle
// }
// else if(ret_recv==0)//发送信息的对方关闭了
// {
// std::cerr<<"a stream socket peer has performed an orderly shutdown(服务端关闭)"<<std::endl;
// return false;
// }
// else
// {
// buf[ret_recv]='\0';//在字符串末尾添加字符串终止符,符合C语言字符串
// }
//接收信息成功
//recv_message=recv_message + static_cast<std::string>(buf);
//使用string里的append函数去将收到的字符串追加进去
if(ret_recv>0)//成功了才去追加
{
recv_message.append(buf,ret_recv);
}
//为什么要累加???
//因为TCP是面向字节流,因为我们怕收到的数据少于一个完整报头!!!
//所以,要是少了的话,我们就得去再recv序列化字符串!!!
//可是之前recv的呢???
//加上啊!!!!!!!!!!!!!!!!!!!!!
//返回recv函数的返回值,由外界去判断是什么情况
return ret_recv;
}
private:
int _socketfd;
//套接字,这个套接字即可以是socket的套接字,
//也可以是accept的套接字,更可以是listen、send、recv的套接字
//就是看哪个函数调用,然后哪个函数将该值赋值
//因为不同的使用端,就是创建不同的TcpSocket类对象
//所以对象里面存储的_socketfd肯定也是不一样的
//比如客户端创建的TcpSocket类对象里面就是存储要用来send、recv、connect的
//而服务端创建的TcpSocket类对象里面就是存储要用来listen、accept的
//而accpet返回的新的TcpSocket类对象里面就是存储服务端要用来recv和send的
//我们本文件只负责把这些函数都封装起来,但是使用还是需要使用者自己规范
//由此才能实现完美的解耦封装!!!
};
}
//至于UdpSocke的封装,由于我们其实很少使用UDP,所以在这里就不实现了,并且它会比TcpSocket封装要简单!!!
TcpClient.cpp # 客户端主程序
cpp
#include <iostream>
#include <memory>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <functional>
#include <cerrno>
#include <sys/wait.h>
#include <pthread.h>
#include <signal.h>
#include <atomic>
#include <string>
#include "Log.hpp"
#include "InetAddr.hpp"//可以利用该头文件做优化,但这里向突出最原始的,所以没有使用,在翻译这里,我们就进行使用简化
#include "Common.hpp"
#include "Socket.hpp"
#include "Protocol.hpp"
#include <limits>
using namespace SocketModule;
void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}
// 命令行参数,因为我们会要求用户在调用服务端的时候就得传入服务端的端口号,
// 那么ip可以不用,因为我们使用0.0.0.0
// 所以就要求用户这么调用服务端进程:
// ./TcpClient 127.0.0.1 8080
int main(int argc, char *argv[]) // 二级指针哦
{
if (argc != 3)
{
Usage(argv[0]);
exit(USAGE_ERR);//Common.hpp头文件中
}
// std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
std::string ip=static_cast<std::string>(argv[1]);
//客户端建立socket套接字
//先创建Socket基类指针
// std::unique_ptr<Socket> client_socket(std::make_unique<TcpSocket>());//无参TcpSocjet构造函数
// client_socket->BulidConnectSocket(ip,port);
//直接创建出connect套接字!!!
//但是这么看不利于我们理解,所以我还是用最详细的
std::unique_ptr<TcpSocket> client_socket(std::make_unique<TcpSocket>());
client_socket->CreateSocketOrNot();
client_socket->ConnectSocketOrNot(ip,port);
//至此客户端就与服务端成功建立联系!!!
//我们要创建Protocol对象,用于
//接下来调用protocol里面的我们封装好的一键形成完整报头的函数
//然后再去调用Protocol里面封装的GetResponse函数用于获取结果并打印结果
std::unique_ptr<Protocol> pro(std::make_unique<Protocol>());
//接下来就是让用户输入问题,然后形成完整报头,将其发送给服务端!!!
//那么我们也可以在客户端收到消息之后再让用户输入yes/no代表还要不要进行计算
std::string continueorno="yes";//刚开始的初始值要为yes,不然没办法进入循环继续下去
while(continueorno=="yes")
{
int x,y;
char oper;
// 提示用户输入格式,提升体验
std::cout << "\n请输入计算表达式(格式:数字运算符数字,如10+20):";
// 校验输入格式(核心:避免非数字输入导致cin异常)
if (!(std::cin >> x >> oper >> y))
{
LOG(LogLevel::ERROR) << "输入格式错误!请输入 数字运算符数字 的格式";
// 重置cin错误状态 + 清空输入缓冲区,避免死循环
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
continue;
}
//接下来调用protocol里面的我们封装好的一键形成完整报头的函数
std::string problem_message;
try //捕获异常
{
problem_message = pro->CreateRequest(x, oper, y);
}
catch (const std::exception& e)
{
LOG(LogLevel::ERROR) << "封装请求报文失败:" << e.what();
continue;
}
//将该报头发送给服务端,那么直接调用我们创建的client_socket里的Send函数即可
client_socket->Send(problem_message);
//然后再去调用Protocol里面封装的GetResponse函数用于获取结果并打印结果
try //捕获异常
{
pro->GetResponse(client_socket);
}
catch (const std::exception& e)
{
LOG(LogLevel::FATAL) << "接收结果失败:" << e.what();
break;
}
std::cout<<std::endl;
//接着让用户输入是否要即继续
std::cout<<"是否要继续进行计算,yes/no:";
std::cin>>continueorno;
}
client_socket->CloseSocket();
return 0;
}
TcpServer.hpp # 服务端类头文件
cpp
#pragma once
#include <iostream>
#include <memory>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <functional>
#include <cerrno>
#include <sys/wait.h>
#include <pthread.h>
#include <signal.h>
#include <atomic>
#include <string>
#include "Log.hpp"
#include "InetAddr.hpp"
#include "Common.hpp"
#include "Socket.hpp"
#include "Protocol.hpp"
using namespace SocketModule;
//那么本文件就是服务端要进行服务的头文件,其实就是实现服务端类,我们之前也实现过无数次了
//那么服务端要做的就是,首先获取计算函数,然后将计算函数传入protocol类中的GetRequest函数
//然后我们再将该函数传入TcpServer中!!!
//所以呢,也还是比较简单的,但是呢,在头文件中的让服务端开始运行的函数中
//我们得让服务端运行处理客户端传入的问题的函数吧!!!
//那么其实就是protocol类中的GetRequest函数,所以,我们得包装一下这个函数
using server_func=std::function<void(std::shared_ptr<TcpSocket>&)>;
//那么本类还得获取到服务端的端口号才行
//其实还是比较简单的,我们下面就进行实现
class TcpServer
{
public:
//构造函数
TcpServer(uint16_t port,server_func serverfunc)
:_serverfunc(serverfunc)
,_port(port)
,_isrunning(false)
{
//将成员变量中的基类指针赋值为TcpSocket子类指针,从而实现多态
_listensocket=std::make_shared<TcpSocket>();
// unique_ptr无法被 fork 拷贝,导致子 / 孙子进程中资源无效
// 你的TcpServer类中用std::unique_ptr<Socket> _listensocket管理监听套接字,但unique_ptr是不可拷贝的(拷贝构造函数被删除)。
// 当服务端父进程fork子进程时,子进程会拷贝父进程的内存空间,但_listensocket作为unique_ptr无法被合法拷贝,
// 导致子进程 / 孙子进程中的_listensocket处于无效状态(指针为空 / 野指针)。
// 第一次请求时,孙子进程执行_listensocket->CloseSocket()时,恰好访问到无效内存(或运气好没触发崩溃);
// 第二次请求时,该操作触发空指针访问 / 野指针解引用,直接导致服务端崩溃。
_listensocket->BulidListenSocket(_port);//调用创建listen监听套接字的函数
}
//析构函数
~TcpServer() {}
//然后就是开始服务端功能的函数
void StartTcpServer()
{
_isrunning=true;
//其实就是先去accept客户端发送来的链接请求
//然后再去创建多进程执行的处理函数,依旧是很简单
while(_isrunning)
{
//调用_listensocket里的AcceptSocketOrNot函数,获取到有这一对一acceptsocket的TcpSocket指针
std::shared_ptr<TcpSocket> accept_socket=_listensocket->AcceptSocketOrNot();
if (accept_socket == nullptr)
{
continue;
}
std::cout << "accept success ..."<<std::endl;
//接下来就是创建孙子进程奥奥让孙子进程执行任务了
pid_t pid=fork();
if(pid<0)
{
LOG(LogLevel::FATAL)<<"fork failed!!!";
exit(1);
}
else if(pid==0)
{
//子进程
//关闭自己的监听套接字
_listensocket->CloseSocket();
//直接创建孙子进程
int ppid=fork();
if(ppid<0)
{
LOG(LogLevel::FATAL)<<"孙子fork failed!!!";
exit(1);
}
else if(ppid>0)
{
//子进程,直接退出
exit(0);
}
//孙子进程
//执行任务
//而GetResponse函数之所以要死循环,是因为服务端单独创建了一个孙子进程用来完全服务于一个客户端!!!
//那么你这个孙子进程,就是要一直服务于和它accept之后的一个客户端,所以你肯定就要一直获取到客户端发送的问题并进行解决然后发送回客户端吧
//你不能说,你这个孙子进程就解决一次问题之后,诶,就退出不干了!!!
//那么我客户端难道再和服务端进行链接?????
//拜托,只连接一次诶,哪里能链接了又链接!!!
//再加上你和一个客户端accept建立了链接之后的所分配的一个孙子进程肯定就要一直获取到客户端发送的问题并进行解决然后发送回客户端吧
//所以,你这个孙子进程,肯定就是需要死循环的运行GetResponse函数,去一直获取到和你所链接的客户端发送的问题并进行解决然后发送回该客户端吧
//所以这就是为什么GetResponse函数要进行死循环的原因
//要是不进行死循环的话,那么你就会发现,客户端发送了一个问题之后,诶,再发送下个问题的时候,服务端就G了
//这就是因为GetResponse函数没有死循环,孙子进程进行了一次之后就退出了,那么你这个孙子进程退出了,也就代表和你这个客户端建立链接的处理进程没了
//那么自然再发消息过去就会失败
_serverfunc(accept_socket);
//孙子进程关闭自己的accept套接字
accept_socket->CloseSocket();
//孙子进程退出
exit(0);
}
//父进程,关闭自己的accept套接字并进行等待
accept_socket->CloseSocket();
::waitpid(pid,nullptr,0);
}
_isrunning = false;
}
private:
server_func _serverfunc;//本质上就是protocol类中的GetRequest函数
bool _isrunning;//服务端是否运行的判端标准
uint16_t _port;//服务端的端口号
std::shared_ptr<Socket> _listensocket;//那么这个就是服务端中要创建的TcpSocket类,里面放着监听套接字,本质上就是使用我们封装的Socket类的多态
};
TcpServer.cpp # 服务端主程序
cpp
#include "TcpServer.hpp"
#include <iostream>
#include <functional>
#include "Socket.hpp"
#include "Protocol.hpp"
#include "NetCal.hpp"
#include "Daemon.hpp"
#include"Log.hpp"
using namespace LogModule;
using namespace SocketModule;
// 命令行参数,因为我们会要求用户在调用服务端的时候就得传入服务端的端口号,
// 那么ip可以不用,因为我们使用0.0.0.0
// 所以就要求用户这么调用服务端进程:
void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
// ./tcpserver 8080
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
std::cout << "服务器已经启动,已经是一个守护进程了" << std::endl;
//将服务端进程守护进程化
Daemon(0, 0);
// daemon(1, 1);
Use_File_Log();
// 1. 顶层
//创建计算函数对象
std::unique_ptr<NetCal> netcal = std::make_unique<NetCal>();
// 2. 协议层
//将计算函数传入Protocol中,用于GetRequest函数
//using calfunc_t = std::function<std::unique_ptr<Response>(const std::shared_ptr<Request>&)>;
std::shared_ptr<Protocol> protocol = std::make_shared<Protocol>([&netcal](const std::shared_ptr<Request>& re)->std::unique_ptr<Response>
{
return netcal->CalFunc(re);//需要有返回值,所以我们就得返回!!!
});//lambda表达式
// 服务端main函数中,Protocol对象是std::unique_ptr<Protocol>,而传递给TcpServer的 lambda 捕获了该对象的引用:
// std::unique_ptr<Protocol> protocol = ...;
// std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(...,
// [&protocol](...) { protocol->GetRequest(...); }
// );
// 父进程fork子进程后,子进程拷贝的内存中,protocol作为unique_ptr无法被合法拷贝,因此子进程中的protocol是无效对象。
// 孙子进程执行 lambda 时,调用protocol->GetRequest()会访问无效内存,触发崩溃。
// 3. 服务器层
//将GetRequest函数传入TcpServer类中
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(std::stoi(argv[1]),
[protocol](std::shared_ptr<TcpSocket>& accept_socketfd){
protocol->GetRequest(accept_socketfd);//没有返回值,所以就不需要返回
});
tsvr->StartTcpServer();
// sleep(5);
return 0;
}
结语:
各位读者,当我们将这篇关于网络计算器的技术文章翻到最后一页时,我们的心情,与其说是完成了一项任务,不如说是共同见证了一段代码从冰冷的字符,逐渐生长出生命与温度的奇妙旅程。这不仅是一篇技术博客,更是一次关于连接、理解与创造的远征。
让我们回望这段旅程,那些曾经在代码中闪烁的光点,如今都已汇聚成照亮我们前行的星河。
我们从最基础的 TCP 协议谈起,揭开了它 "面向字节流" 这层神秘的面纱。我们不再是对 "粘包"、"拆包" 感到困惑的新手,而是理解了其背后缓冲区机制的运作原理。我们明白了,TCP 协议如同一条宽阔而没有明确路标和邮戳的河流,它能保证数据的有序和可靠,却无法天然地为我们划分出一个个独立的 "包裹"。这就像我们无法仅凭声音的大小和节奏,就精准判断出对方在说的是 "我爱你" 还是 "我爱梨"。正是这种对底层机制的深刻洞察,让我们在设计自定义协议时,有了坚实的理论根基,也为我们解决 "如何在字节流中找到一个完整的请求" 这一核心难题,指明了方向。
于是,我们开始了协议的设计。从 "长度 + 分隔符" 这一朴素而强大的组合,到序列化与反序列化的精巧构思,我们一步步地将一个模糊的需求 ------"让通信双方精准识别请求 / 响应的边界"------ 转化为了一个严谨的、可执行的技术方案。我们定义了Request和Response类,让数据在网络中传输时,不再是杂乱无章的字节,而是承载着明确语义的结构化信息。JSON 的序列化,让我们能够在不同语言之间轻松地进行数据交换,仿佛为数据搭建了一座通用的桥梁。而Protocol类,则像一位技艺精湛的工匠,将这些零散的组件 ------ 长度、协议号、分隔符、序列化数据 ------ 精心地组装起来,形成了一个个完整、可传输的 "报文"。这个过程,就像是在编织一张精密的网,既要保证网眼的大小足以捕捉到目标,又要确保网的整体结构足够坚固,能够经受住网络传输中的各种 "风浪"。
紧接着,我们对 TCP Socket 进行了封装。通过模板方法模式,我们将创建套接字、绑定、监听、连接、发送、接收等一系列繁琐且容易出错的系统调用,封装成了简洁、易用的接口。这就像是为我们的网络通信穿上了一件合身的 "铠甲",让我们能够专注于业务逻辑的实现,而不必再为底层的细节而烦恼。我们不再需要去记忆send函数的返回值是否代表全部数据已发送,也不必担心recv函数会因为缓冲区的原因而只读取部分数据。我们的代码变得更加清晰、健壮,也更易于维护和复用。这是一种对代码美学的追求,更是对开发者体验的深刻关怀。
服务端的实现,是这段旅程中最富挑战性也最令人兴奋的部分。我们引入了多进程模型,用 "父进程监听、子进程处理、孙子进程干活" 的精巧设计,解决了并发处理多个客户端连接的难题。我们还将服务端守护进程化,让它能够脱离终端,在后台稳定运行,即使我们关闭了命令行窗口,它依然能坚守岗位。这不仅是技术上的实现,更是一种责任感的体现 ------ 我们希望我们的程序能够像一个真正的服务者一样,可靠、持久地为用户提供服务。在这个过程中,我们处理了各种异常情况,从粘包、拆包的困扰,到僵尸进程的清理,再到端口被占用的处理,每一个问题的解决,都让我们的代码更加成熟和稳健。
而客户端,则代表着用户与这个网络世界的交互窗口。它接收用户的输入,进行初步的校验,然后将这些输入封装成规范的请求,发送给服务端。它等待着服务端的响应,解析出结果,并将其清晰地展示给用户。这个过程,充满了人文关怀。我们考虑了用户输入的容错性,处理了各种可能的异常,确保了即使在用户操作失误时,程序也能优雅地应对,而不是崩溃。这让我们意识到,技术的最终目的,不仅仅是实现功能,更是为了服务于人,提升用户的体验。
当我们将这一切整合起来,从服务端的启动,到客户端的连接,再到一次完整的计算请求与响应,整个流程就像一部精密的机器,各个部件协同工作,最终完成了一次从输入到输出的完美闭环。这个过程,让我们深刻地体会到了软件工程的魅力 ------ 将复杂的系统分解为一个个可管理的模块,然后通过精心的设计和组合,创造出一个能够解决实际问题的、有价值的产品。
回顾这段旅程,我们不仅仅是学习了如何编写网络计算器,更是在这个过程中,培养了一种解决复杂问题的思维方式。我们学会了如何去分析问题、拆解问题、设计解决方案,并最终实现它。我们明白了,技术不是孤立的,它与我们的生活息息相关。一个小小的网络计算器,背后蕴含着如此丰富的计算机科学知识和工程实践经验。
对于每一位正在阅读和学习这段文字的朋友,我想说,这只是一个开始。网络编程的世界广阔而深邃,还有更多的知识和挑战等待着我们去探索。希望这篇文章能够成为您学习路上的一块垫脚石,激发您对网络编程的兴趣,让您在未来的学习和工作中,能够更加自信、从容地面对各种技术难题。
请记住,每一行代码都承载着我们的思考与心血。当您看到自己编写的程序能够稳定地运行,能够为用户提供服务时,那种成就感是无可替代的。技术的道路或许漫长,但只要我们保持好奇心、耐心和持续学习的热情,就一定能够不断进步,创造出属于自己的精彩。
让我们带着这份收获与感悟,继续在代码的世界里探索,去构建更多有价值、有温度的应用,为这个数字时代贡献我们的力量。