目录
[二、Socket.hpp : Socket 的封装与实现](#二、Socket.hpp : Socket 的封装与实现)
[1. 基础定义与命名空间](#1. 基础定义与命名空间)
[2. 抽象基类 Socket:定义接口规范](#2. 抽象基类 Socket:定义接口规范)
[3. 具体实现子类 TcpSocket](#3. 具体实现子类 TcpSocket)
[Accepter() 函数](#Accepter() 函数)
[三、Protocol.hpp 协议模块](#三、Protocol.hpp 协议模块)
[1. Request 请求报文类](#1. Request 请求报文类)
[2. Response 响应报文类](#2. Response 响应报文类)
[3. Protocol 协议处理类](#3. Protocol 协议处理类)
[封包 Packet 与解包 Unpack](#封包 Packet 与解包 Unpack)
[ParseRequest() 服务器端解析函数](#ParseRequest() 服务器端解析函数)
[ParseResponse() 客户端解析函数](#ParseResponse() 客户端解析函数)
[四、Calculator.hpp 计算器模块](#四、Calculator.hpp 计算器模块)
[五、TcpServer.hpp 服务端网络通信模块](#五、TcpServer.hpp 服务端网络通信模块)
[1. 头文件](#1. 头文件)
[2. 私有成员变量](#2. 私有成员变量)
[3. 构造函数:创建监听套接字](#3. 构造函数:创建监听套接字)
[4. Loop ():服务器主循环](#4. Loop ():服务器主循环)
[5. service ():处理单个客户端的读写](#5. service ():处理单个客户端的读写)
[六、NetCalServer.cc 服务端主文件](#六、NetCalServer.cc 服务端主文件)
[七、NetCalClient.cc 客户端主文件](#七、NetCalClient.cc 客户端主文件)
[1. 创建客户端套接字并连接服务端](#1. 创建客户端套接字并连接服务端)
[2. 初始化协议层(绑定打印回调)](#2. 初始化协议层(绑定打印回调))
[3. 主循环:循环输入、发请求、收响应](#3. 主循环:循环输入、发请求、收响应)
[5. 发送、接收、解析(和服务端收发逻辑闭环)](#5. 发送、接收、解析(和服务端收发逻辑闭环))
[九、深度剖析 OSI 七层模型](#九、深度剖析 OSI 七层模型)
[1. 应用层:Calculator 业务层(对应 OSI 第七层)](#1. 应用层:Calculator 业务层(对应 OSI 第七层))
[2. 表示层:Protocol 协议层(对应 OSI 第六层)](#2. 表示层:Protocol 协议层(对应 OSI 第六层))
[3. 会话层:TcpServer 网络管理层(对应 OSI 第五层)](#3. 会话层:TcpServer 网络管理层(对应 OSI 第五层))
[4. 下四层:操作系统内核全权接管](#4. 下四层:操作系统内核全权接管)
[5. 总结](#5. 总结)
在上一篇文章中我们已经了解了自定义应用层协议和JSON 序列化与反序列化,并企之前的 UDP 和 TCP 的编码中我们也都清楚了 TCP 网络通信的原理。那这篇文章那个我们将实现一个TCP 网络版本的计算器,把前面学的知识都应用起来。
一、设计框架
我们要做的 TCP 计算器,核心是客户端发送请求、服务端发送结果。第一步就是服务端和客户端建立网络通信。
-
之前我们在写 UDP 和 TCP 的代码时都是直接将网络通信接口API直接写在服务端和客户端内的,而今天我们不这样做了,我们直接将这些API封装成一个 Socket.hpp 头文件,后续不管是服务器监听,还是客户端连接,直接调用封装好的方法就行,不用再碰复杂的系统调用。
-
下来第二个文件就是 TcpServer.hpp 服务端头文件,搭建 TCP 服务器骨架,默认开启监听、接收连接,用 fork 多进程并发处理客户端。
-
再下来我们需要实现第三个文件 Protocol.hpp 文件,这是一个核心协议模块文件,在这个文件中我们要解决两个问题:一个是序列化和反序列化问题,未来我们可能定义结构体,客户端应该把把结构体内容序列化成 JSON 字符串,服务器收到后反序列化出结构体。这里 JSON 格式字符串本质就是适配网络发送的连续字节流,我们在上一篇文章已经讲过。另一个问题就是自定义应用层协议,给数据做 "打包 / 解包" 处理,解决数据的粘包问题,因为 TCP 是流式传输,只管发字节,不管边界。如果直接发 JSON 字符串,服务器有可能出现分不清哪条是完整请求。所以我们要自己定规矩:每条完整报文 = 长度头 + JSON 内容 + 分隔符。客户端给 JSON 字符串加长度头,服务器按长度精准截取完整报文。
-
下一个实现 Calculator.hpp 计算逻辑的文件, 接收解析好的请求,做加减乘除,返回计算结果;将业务逻辑和网络、协议分开,这样以后想改计算规则、加新运算,不用动网络和协议代码。保持解耦。
-
再下来就是服务端和客户端的两个入口文件 NetCalServer.cc 和 NetCalClinet.cc 文件,把所有模块 "组装" 起来。
-
还有日志文件 Logger.hpp,打印关键步骤信息。InetAddr.hpp 文件负责 IP 地址封装。我们前面都实现过,这里就不做过多介绍。
二、Socket.hpp : Socket 的封装与实现
我们在 Socket.hpp 文件中将 Linux 原生的 Socket API 封装好,后续就直接调用封装好的方法进行使用。我们采用了 "模板方法模式" 来实现,通过抽象基类定义接口规范,由具体子类实现不同协议(TCP/UDP)的逻辑:
整个 Socket.hpp 文件分为三层,职责分明:
- 命名空间与基础定义:提供统一的日志宏、全局常量和错误码枚举,为整个模块提供基础支持。
- 抽象基类 Socket:定义所有 Socket 操作的纯虚接口,以及模板方法,规范 TCP/UDP 等子类的行为。
- 具体实现子类 TcpSocket:继承自 Socket,重写基类的纯虚函数,实现 TCP 协议专属的 Socket 逻辑。
这种设计让我们可以轻松扩展,未来如果需要支持 UDP,只需要新增一个 UdpSocket 类继承并重写方法即可。
1. 基础定义与命名空间
命名空间 NS_SOCKET_MODULE:将所有 Socket 相关代码包裹其中,避免全局命名冲突。
日志模块引入 NS_LOG_MODULE:依赖之前实现的日志模块,统一处理日志输出。
全局常量 gbacklog:定义 TCP 服务器 listen() 函数的连接队列长度,这里设为 16。
错误码枚举:定义了创建、绑定、监听等关键步骤可能出现的错误码,方便程序错误处理。
2. 抽象基类 Socket:定义接口规范
1. 在我们的 Socket 基类中,我们可以看到无论是初始化的 Create、Bind、Listen,还是通信的Recv、Send、Accept,都被定义为 virtual 纯虚函数,原因就是为了实现多态,让基类统一规范接口,让子类实现不同协议的底层细节。
-
我们知道,网络通信不止有 TCP,还有 UDP。TCP 是面向连接的流式通信,UDP 是无连接的数据报通信,二者底层的系统调用和执行逻辑不同。如果我们不使用虚函数,就需要为 TCP、UDP 分别写两套完全独立的类,上层代码调用时,还要区分是 TCP 对象还是 UDP 对象,代码扩展性较差。
-
而虚函数的设计解决了这个问题,Socket 作为抽象基类,把所有套接字该有的行为(创建、绑定、收发、连接)全部定义为虚函数,这样 TCP 和 UDP 就分别可以作为子类重写这些虚函数,实现各自协议的专属逻辑;而上层的服务器、客户端代码,只需要和基类 Socket 打交道,不用关心底层是 TCP 还是 UDP,后续想扩展协议,只需要新增子类即可。这也是面向对象多态思想在网络编程中的核心落地。
-
再看一下权限划分:为什么前三个初始化函数(CreateSocketOrDie()、BindSocketOrDie(port)、ListenSocketOrDie())是 protected,后面的通信函数(Recv、Send、Accept)是 public 呢?首先,前三个函数在子类的重写实现中分别对应创建套接字,绑定,监听三个步骤,这三个步骤是 TCP 服务器套接字初始化的固定流水线,它们的顺序是固定的。如果我们把这三个函数设为public,上层代码就可以随意单独调用,一旦有人写错顺序,比如先绑定再创建,程序会直接崩溃。
-
因此我们将前三个函数的权限设为 protected,代表着只有子类 TcpSocket 可以访问,上层的服务器、客户端代码无法访问;并提供 public 模板方法,在基类中写一个 BuildTcpSocketMethod 公共函数,把这三步按固定顺序封装进去。上层代码不用关心内部三步怎么执行,只需要调用这一个模板方法,就能一键完成套接字初始化,既安全又简洁。
-
通信函数和初始化函数不同,Recv、Send、Accept、Close、Connect 这些函数,是套接字初始化完成后,运行时独立、按需调用的通信行为。服务器启动后,会循环调用 Accept 接受客户端连接;连接建立后,会反复调用 Recv、Send 收发数据;通信结束后,调用 Close 释放资源。这些函数没有固定的执行顺序。所以我们把它们设为 public ,直接对外暴露接口,让上层的服务器、客户端代码可以随时、灵活地调用,完成网络通信的核心逻辑。
3. 具体实现子类 TcpSocket
TcpSocket 子类继承 Socket 基类,并重写了所有纯虚函数,实现了 TCP 协议的所有细节。
首先我们先重写 Socket 基类的这三个 protected 纯虚函数,函数内部分别调用 socket()、bind()、listen() 系统调用完成重写。
现在我们看通信阶段的核心方法,这也是上层服务端/客户端代码真正会用到的接口:
封装了原生的 accept 系统调用,服务端会阻塞等待,直到有客户端发起连接;成功后返回一个新的文件描述符 sockfd,专门用于和这个客户端通信;clientaddr = addr 表示把客户端地址信息保存到传入的引用参数里,上层可以拿到客户端 IP 和端口,方便日志打印和调试。accept 拿到一个新的客户端 fd,把这个 fd 装进 TcpSocket 对象里,包成智能指针给上层,上层拿着这个智能指针,直接调用 Recv、Send、Close就行。
Accepter() 函数
为什么原生 accept 返回裸 fd,我们封装 Accepter() 函数后却返回智能指针?
首先明确核心前提:原生 Linux 的 accept 系统调用,返回的本质是一个纯 int 类型的文件描述符,这个数字就是服务端与单个客户端通信的唯一标识,后续所有和该客户端的收发、关闭操作,都依赖这个 fd。而我们封装的 TcpSocket 子类,核心作用就是把这个原生裸 fd 进行面向对象封装,让我们不再直接操作裸 fd,而是通过对象的成员方法完成通信,这是整个封装设计的核心初衷。Accepter 函数内部第一步,依然会调用原生的 accept 拿到一个全新的通信 fd;但它不会直接把这个 int 数字返回,而是立刻将这个新 fd 作为成员变量,创建一个全新的 TcpSocket 子类对象,再用 std::make_shared 把这个对象托管给智能指针,最终返回这个智能指针。
智能指针和对象到底是什么关系?
TcpSocket 对象是一个 "装着 fd 的工具箱",里面封装了收发、关闭等所有通信操作;而 std::shared_ptr 智能指针,就是这个工具箱的 "专属管理员"。std::make_shared 的作用,就是先在堆上创建出这个工具箱(Socket 对象),再生成一个管理员(智能指针),专门负责看管这个工具箱。我们拿到的智能指针,本质是一个变量,这个变量里存着工具箱的内存地址;我们通过智能指针,就能间接访问到工具箱里的 fd 和所有成员方法。简单说:智能指针是 "管对象的变量",对象是 "存 fd 的实体",二者是管理与被管理的关系,相辅相成。
为什么非要把新 fd 重新封装进 Socket 对象,再返回智能指针?
第一,统一操作接口。服务端的监听套接字、客户端的连接套接字、Accept 生成的通信套接字,本质都是 fd,都需要收发、关闭操作。把所有 fd 都封装进 Socket 类后,我们只需要调用统一的 Recv、Send、Close 方法,不用区分是监听 fd 还是通信 fd,代码风格完全统一;
第二,自动安全管理。原生裸 fd 用完必须手动 close,一旦忘记就会造成文件描述符泄露;而智能指针会在生命周期结束时,自动调用 Socket 对象的析构函数,内部自动关闭 fd,从根源上杜绝资源泄露;
第三,适配上层逻辑。我们下面写的 TcpServer 的 service 函数,参数接收的就是 std::shared_ptr<Socket> 类型。上层逻辑完全不需要关心底层 fd 是什么、怎么来的,只需要接收这个被封装好的对象,直接调用方法即可,完美实现底层细节与上层业务的解耦。
Sockfd() 获取底层文件描述符,返回当前套接字的原生文件描述符;上层代码如果需要直接操作 fd,可以通过这个方法获取;
Recv() 接收客户端数据,通过 *out += inbuffer 追加,这样设计的好处是,上层可以维护一个 inbuffer,多次 Recv 调用的数据会自动拼接起来,完美适配 TCP 的流式传输特性,后续我们的协议解包逻辑,就是基于这个拼接后的完整字符串处理的。
Send() 发送数据给客户端,这里我们使用了 send() 系统调用,它和 write() 本质上是一样的,只是参数多了一个标志位。
Close() 关闭套接字,调用 close(_sockfd) 释放文件描述符,再把 _sockfd 置为 -1,标记为无效;配合智能指针的 RAII 特性,对象销毁时自动调用 Close,实现资源自动释放,杜绝资源泄漏。
Connect() 客户端连接服务器,addr 是服务器的 IP 和端口,通过我们封装的 InetAddr 类传入;调用 connect() 系统调用,成功返回 0,失败返回 - 1。
三、Protocol.hpp 协议模块
这个文件有三个类:
Request 类:定义客户端发给服务器的计算请求,做序列化 / 反序列化(结构体↔JSON)
Response 类:定义服务器返回给客户端的计算结果,同样做序列化 / 反序列化
Protocol 类:定义我们自定义的报文打包 / 解包规则,解决 TCP 粘包,串联解包→反序列化→业务计算→序列化→打包全流程
1. Request 请求报文类
这个 Request 类的作用是专门封装客户端要发给服务器的计算请求,只存 3 个核心数据:左数、右数、运算符。比如说用户输入了三个数据 : '10' '+' '20',就是创建 Request 对象 (10,20,'+'),在这个类中有序列化和反序列化函数,然后会将数据序列化为 JSON 字符串。
有两个构造函数,一个是无参构造 (默认初始化为 0,创建空请求对象),另一个是有参构造,直接给三个成员赋值,上层创建请求时一行代码搞定。
下来就是 Serialize() 序列化函数和 Deserialize() 反序列化函数。
Serialize() 序列化函数是把 C++ 结构体转换为 JSON 字符串,使用 Json::Value 创建一个 JSON 万能容器,把结构体的左数、右数、运算符,装进 JSON 的 key-value 里,再使用 writer() 把 JSON 对象序列化转成字符串,最后把生成的 JSON 字符串,写入传入的指针参数 out (输出型参数),返回 true表示序列化成功。举个例子:结构体是 x=10,y=20,oper='+',序列化后 JSON 字符串是 {"left":10,"right":20,"oper":"+"}。
Deserialize 反序列化函数把服务器收到的 JSON 字符串 → 还原成 C++ 结构体,parse 专门反序列化解析 JSON 字符串,把传入的 JSON 字符串 in,解析到 root 容器里,最后通过 asInit() 从 root 里取出对应的值,赋值给结构体成员。整体功能就是客户端发来 JSON 字符串,服务器收到后,用这个函数把 JSON 变回结构体,就能拿到要计算的三个数。
问题 1:为什么 Serialize() 的参数是 std::string* out(指针),而Deserialize参数是std::string& in(引用) ?答案就是 *out 是输出型参数,因为我们要把序列化好的 JSON 字符串输出到函数外面的变量里。比如我们在调用这个函数时就得传地址 req.Serialize(&json),在函数内部通过解引用 *out = "生成的JSON字符串",从而就生成好的JSON字符串传给调用时得参数 json,从而我们就可以使用 json 了。用指针 * 是因为 C++ 我们默认函数要修改外部变量,必须传递变量的内存地址,指针正是传递地址的方式;这个参数是用来接收函数内部生成的结果。
反序列化函数是引用的原因是反序列化是用来接收外部已存在的 JSON 字符串,仅读取字符串内容,不修改原字符串,解析后将数据赋值给当前 Request/Response 对象自身的成员变量。
问题 2:为什么两个函数的返回值都是 bool 布尔类型?而不返回解析后的 Request 对象?两个函数返回 bool,核心目的是标记函数执行的成功与失败。
反序列化修改的是当前已存在的对象自身,而非生成新对象。上层调用时会先提前定义好Request req 空对象,再调用 req.Deserialize(json_string),函数内部直接修改这个已定义对象的成员值。因此我们在调用这个函数时就已经将成员变量修改了,就没必要在返回该类型。返回 bool 值是刚好能判断序列化和反序列化是否正确。
2. Response 响应报文类
这个类的核心作用是专门封装服务器要返回给客户端的计算结果,存 2 个核心数据:计算结果、状态码。Request 是客户端发出去的请求,Response 是服务器发回来的响应。这个类里的序列化/反序列化逻辑和 Request 一样,只是存的数据不一样。状态码 _code = 0 表示计算成功,result 是正确结果。_code!=0 表示计算失败(比如除零、非法运算符),result 无意义。
3. Protocol 协议处理类
数据传输的第一步就是序列化为 JSON 字符串。我们之所以要把自定义的 Request、Response 对象转换成 JSON 字符串,核心目的就是适配 TCP 网络传输规则。因为TCP 网络底层只能识别和传输二进制字节流,而 JSON 字符串直接被编码为二进制字节流,完全契合网络传输的底层数据要求;所以序列化和反序列化解决了 "数据在网络中传输" 的问题,让原本无法直接在网络中传递的 C++ 对象,变成了网络可识别、可传输的字节流载体。
但即便完成了 JSON 序列化,得到了可传输的字符串,也不代表数据传输就万无一失,因为还可能会导致粘包、半包问题。这里我们很容易陷入一个认知误区:觉得客户端每发送一条序列化后的 JSON 字符串,服务器就会精准接收到一条完整数据,觉得发送和接收是一一对应的。但实际上并不是,因为 TCP 底层是流式传输,数据以字节流的形式连续传输,没有固定的报文边界,底层传输协议会根据网络拥堵情况、缓冲区大小、Nagle 算法等,自主对数据进行合并或拆分。这就会导致两种异常情况:要么多条 JSON 字符串被合并成一段数据传输,服务器无法区分单条报文,形成粘包;要么一条 JSON 字符串被拆分成多段传输,服务器只接收到部分数据,形成半包,最终都会导致 JSON 解析失败、程序运行异常。如下:
那为了解决 TCP 流式传输的报文边界问题,我们就需要引入封包与解包机制,就是给序列化后的 JSON 字符串添加统一的传输标识,让接收端能精准区分每一条完整报文。为了解决 TCP 粘包、半包问题,我们约定了一套极简且高效的自定义报文格式,一共分为三个部分,报文格式如下 :
1. 有效载荷长度:也就是 JSON 字符串内容的字节长度,用一个十进制数字表示;
特殊分隔符 \r\n :作为报文各部分的边界标识,用来拆分长度头与内容、标记报文结尾;
有效载荷内容:序列化后的纯净 JSON 字符串(数据内容)。
我们举个例子 : 假如已经序列化后的 JSON 字符串如下:
cpp{"left":10,"right":20,"oper":"+"}那封包后完整报文就如下:
其中 29 是 JSON 字符串的实际长度;第一个 \r\n 用来拆分长度头和 JSON 内容;第二个 \r\n 通过换行来表示整条报文已结束,方便调我们区分报文边界。
封包 Packet 与解包 Unpack
因此我们将封包和解包等核心功能封装在我们自定义的 Protocol 协议类。下面我们来看一下 Protocol 协议类中封包 Packet 与解包 Unpack 的操作:
首先我们在全局定义一个全局分隔符 "\r\n"。
这是 Packet 封包函数,用来将序列化后的 JSON 字符串封包程可传输的完整报文,按照 "长度头 + 分隔符 + JSON + 分隔符" 的完整网络报文。
std::to_string(json_string.size()) 用来获取 JSON 字符串的长度,并转为字符串形式作为长度头;然后拼接第一个 \r\n 分割长度头与 JSON 内容;再拼接原始 JSON 字符串;最后拼接第二个 \r\n 标记报文结尾,完成封包。返回一个完整的封包好的字符串。
这是 Unpack 解包函数,表示我们要将网络完整的报文解包为纯净的 JSON 字符串。第二个参数也是一个输出型参数,把解包后的 JSON 字符串通过指针带出来。
Unpack 就是 Packet 的反向操作,把接收端收到的报文 "脱掉协议外壳",还原出最开始的纯净 JSON 字符串,同时清理缓冲区,为下一次解析做准备。
前面我们已经搞懂了 Protocol 类中 Packet 封包、Unpack 解包这两个基础工具函数,解决了网络报文的格式封装与解析问题。但在实际项目中,封包、解包只是 "底层能力",我们还需要一个上层调度逻辑,这个逻辑就是把「解包→反序列化→业务处理的结果→序列化→封包」这一整条通信链路连接起来。因此我们就需要在类中再设计出了 ParseRequest 和 ParseResponse 这两个函数 :
ParseRequest() 服务器端解析函数
下面我们再对函数的各个参数变量和逻辑进行一下梳理:
首先对于参数和返回值来讲:
inbuffer 是函数引用传递的参数,本质是服务器的 TCP 接收缓冲区,存储着经过客户端 Packet 封包后,从网络中发送过来的完整的网络报文。这里的内容不是纯净的 JSON 字符串,而是 长度头 + 分隔符 + JSON + 分隔符 的封包格式,并且因为 TCP 粘包问题,缓冲区中可能堆积 1 条、多条完整报文,或是末尾带有半包数据。
result 是函数内部定义的返回值字符串,专门存储经过计算处理后生成的所有完整的报文,最终函数会将 result 里的内容一次性返回,由服务器统一发送给客户端。
-
函数首先以 while(true) 死循环开启处理,核心目的是一次性将 inbuffer 中所有完整的客户端请求报文处理完毕。因为 TCP 接收缓冲区中可能一次性堆积多条粘在一起的封包报文,如果不循环处理,仅能处理第一条报文,那剩余报文会永久滞留在缓冲区,导致逻辑出错;只有当解包识别出半包数据时,循环才会终止,等待下一次 TCP 接收补充数据。
-
循环内首先调用 Unpack(inbuffer, &json_string) 函数,对inbuffer中的网络报文进行解包操作。此时 inbuffer 中是封包后的完整报文,解包操作会精准剥离长度头、分隔符等协议外壳,提取出其中的纯净 JSON 字符串存入 json_string;同时同步修改 inbuffer,将已处理完毕的报文从缓冲区中删除,仅保留未处理的半包数据。若解包返回1:成功提取一条完整 JSON 字符串,继续向下执行业务处理;若解包返回0:缓冲区剩余数据不足一条完整报文(半包),直接终止循环,返回已处理完成的响应结果;若解包返回<0:参数非法,直接终止流程。
-
解包得到纯净的 json_string 后,我们定义空的 Request req 对象,调用 req.Deserialize (json_string) 完成反序列化操作。这里的 json_string 是客户端 Request 对象序列化后的产物,通过反序列化,会将 JSON 字符串中的左操作数、右操作数、运算符精准赋值到 req 对象的内部成员中,此时 req 对象中就完整存储了客户端的核心计算请求,为后续业务计算提供数据支撑;若反序列化失败,直接终止处理,丢弃脏数据。
-
我们定义空的 Response resp 对象,通过 _handler_request 回调函数,将存储着客户端请求的req 对象传入上层业务逻辑。回调函数会读取 req 中的运算数据,完成加减乘除等核心业务计算,最终将运算结果、状态码等数据赋值给 resp 对象。
-
resp 对象中已经存储了完整的计算结果,但该对象无法直接在网络中传输,因此需要调用resp.Serialize(&resp_json_string) 进行序列化操作。该操作会将 resp 对象中的结果数据,转换为纯净的 JSON 字符串存入 resp_json_string,完成从业务对象到网络可传输字符串的转换,这一步与客户端 Request 对象的序列化逻辑完全对称。
-
序列化得到纯净的 resp_json_string 后,调用 Packet(resp_json_string) 进行封包操作,为响应 JSON 字符串拼接长度头、分隔符,封装成符合协议格式的完整网络报文;随后将该封包后的响应报文追加到result容器中,等待所有缓冲区中的请求处理完毕后,函数将result一次性返回,由服务器完成最终的网络发送。
高度总结:
从 TCP 接收缓冲区中读取到的数据,是客户端按照自定义协议封包完成后的报文,我们无法确定缓冲区中包含几条独立的请求报文,因此需要通过循环配合解包操作逐条提取出纯净的 JSON 字符串;之所以要对解包得到的 JSON 字符串进行反序列化,是因为 JSON 仅为便于网络传输的格式,必须将其还原为 Request 业务对象,才能提取其中的操作数与运算符并调用回调函数完成业务计算,计算完成后将结果存入 Response 响应对象中;而对 Response 对象进行序列化,是因为服务器需要将计算结果回传给客户端,网络传输只能识别 JSON 字符串格式,序列化能够将业务对象转换为可传输的纯净 JSON,序列化完成后还需进行封包处理,这是为了保持通信协议的对称性,客户端发来的是封包后的报文,服务器回传的响应也必须遵循相同的封包格式,无论服务器最终需要发送几条响应报文,封包与解包机制都能适配,保证收发双方都能稳定提取出完整独立的单条报文,规避 TCP 粘包、半包带来的数据解析异常问题。
返回值:
这个函数的返回值逻辑和我们平时遇到的函数的返回逻辑不一样,它并非在函数执行完毕后统一返回,而是遵循 "处理到半包即返回" 的核心原则。函数内部定义的 result 变量,用于持续累积所有已处理完成、可以正常回传给客户端的响应报文;循环处理过程中,一旦解包返回 n == 0,就意味着缓冲区剩余数据不足以构成一条完整报文,出现了半包状态,此时程序已无法继续解析新的完整请求,就会将 result 中累积的所有响应报文一次性返回;另一种特殊情况是解包返回 n < 0,代表出现参数非法等异常,整个处理流程无法正常开展,因此直接返回空字符串终止流程。
简单来说,函数只有在遇到无法继续处理的半包数据,或是出现异常错误时才会返回,半包时返回已处理完成的所有报文,异常时直接终止处理流程,这一设计既保证了完整报文能被及时处理和响应,又合理保留了半包数据,等待下一次接收数据后继续解析,完美适配 TCP 流式传输的特性。
粘包问题的补充:
我们要明确一个核心本质:无论是裸 JSON 字符串,还是我们加了长度头、分隔符封包后的完整报文,本质上都是一串连续的字符。TCP 传输的底层只认 "字节流",它不管这串字符是 "裸 JSON",还是 "长度头 + 分隔符 + JSON" 的协议格式。所以封包后的报文,依然 100% 会出现粘包,这是 TCP 协议本身的物理特性决定的,和报文里加了什么内容没有关系。
很多人会误以为 "我加了长度头,就不会粘包了",这是一个巨大的误区。真相是:加长度头,不是为了阻止粘包发生,而是为了「粘包发生后,能精准把它拆开」。客户端发出去的每一条封包报文,依然会被 TCP 底层、网络路由随意合并、拆分,粘包、半包该发生还是会发生。区别就在于:裸 JSON 粘在一起,服务器完全不知道从哪进行切割;而我们自定义的协议格式,通过 "长度头" 给每一条报文做了精准的 "身份证标记",哪怕 10 条报文粘成一大串,服务器也能通过 Unpack 解包函数,按照长度一条一条精准切开,完整还原出每一条独立的请求。
ParseResponse() 客户端解析函数
ParseResponse 是客户端的解析函数,用来处理服务器返回的响应报文。它的作用就是:把 TCP 接收缓冲区里的封包数据,循环拆包、解析,还原出服务器的响应结果,交给客户端的业务回调处理。和 ParseRequest 相比,它没有 "再生成响应发回去" 的步骤,因为客户端收到结果后,通信链路就完成了,所以流程更短。
逻辑总结:
ParseResponse 作为客户端侧专属的响应解析函数,它的参数 inbuffer 存储的正是服务器通过网络回传的、已完成业务计算并经过封包处理的报文,受 TCP 粘包特性影响,缓冲区中可能堆积多条完整响应报文。函数内部通过死循环持续调用解包函数,逐条剥离协议格式外壳,提取出纯净的响应 JSON 字符串;随后定义Response业务对象,通过反序列化将 JSON 中的运算结果、状态码等核心数据还原到对象中。此时解析工作完成,解析后的完整结果会通过 _handler_response 回调函数传递给客户端上层业务逻辑,上层代码进而在回调中打印计算结果、同样也实现了底层网络解析与上层业务处理解耦。
回调函数的理解:
客户端侧的 _handler_response 回调函数,本质就是将解析完成的结果交给上层业务逻辑处理,在我们这个计算器项目中,它的核心逻辑就是读取 Response 对象中存储的最终运算结果与状态码,将其打印输出给用户,完成一次完整的通信交互。这和服务端回调负责执行业务计算逻辑不同。
两个函数的不同:
因为这里是客户端,通信角色和服务器相反。服务器收到客户端请求后,算出结果、再序列化、封包、发回去,完成一次 "请求 - 响应" 的双向闭环;而客户端收到服务器返回的响应报文,拿到最终计算结果后,本次通信任务就彻底结束了,既不需要再执行业务计算,也不需要再序列化、封包、向服务器回传新数据。所以 ParseResponse 函数自然省去了序列化、封包、拼接响应报文的步骤,只需要完成解包、反序列化,最后通过回调把结果交给上层打印展示即可。
私有成员变量和回调逻辑
在 Protocol 类的私有成员中,_version 仅作为协议版本标识,用于版本兼容校验,并非核心功能;真正支撑整个网络通信业务解耦设计的,是下面的两个回调函数成员变量。首先通过using 关键字完成回调类型的别名定义,其中 HandlerRequest_t 被定义为一个 std::function函数容器,专门用于存放接收 Request 对象引用、返回 Response 对象的业务计算函数,对应服务器侧的回调逻辑,当服务器解析完客户端请求后,便会触发该回调,传入封装好请求数据的 Request 对象,执行业务计算逻辑并返回封装运算结果的 Response 对象;而HandlerResponse_t 则被定义为存放接收 Response 对象引用、无返回值的函数容器,对应客户端侧的回调逻辑,客户端解析完服务器响应报文后,触发该回调,传入存储运算结果的 Response 对象,完成结果打印展示的上层业务操作。这两个回调函数的具体实现逻辑,我们将在主函数中实现并注册到 Protocol 对象中,Protocol 类仅预留回调入口即可,不关心具体业务细节,通过这种回调机制,彻底实现了底层网络协议层与上层业务逻辑层的解耦,让网络通信框架具备极强的通用性和扩展性。
四、Calculator.hpp 计算器模块
在完成了Protocol协议类的底层通信框架搭建后,我们已经具备了报文解析、数据收发的完整能力,但整个通信链路还缺少最核心的业务处理环节。此前我们在 Protocol 类中预留了HandlerRequest_t 业务回调接口,专门用于对接上层计算逻辑,而现在要讲解的 Calculator 类,正是这个回调接口的具体的实现载体。Calculator 类完全脱离网络层,只负责计算逻辑。如下:
这个类中我们用了三个错误码来区分不同的异常场景:
- resp._code == 0(默认值):表示计算成功,resp._result 是有效结果;
- resp._code == 1:除零错误;
- resp._code == 2:取模运算除数为 0;
- resp._code == 3:非法运算符。
这样客户端收到 Response 后,通过判断 _code 字段,就能知道服务器计算是否成功,以及失败的具体原因,方便给用户友好的提示。
Execute() 函数的签名和 HandlerRequest_t 的定义完全匹配,所以 Calculator::Execute 可以直接被注册为 Protocol 类的业务回调函数。
这个 Calculator 类和 Protocol 类,通过回调机制形成了明确的分工协作:Protocol 负责网络数据接收 → 解包 → 反序列化为 Request → 调用注册的 HandlerRequest_t 回调;Calculator 负责:接收 Request → 计算 → 返回 Response;Protocol 再把返回的 Response 序列化 → 封包 → 发送给客户端。
这种设计实现了网络层与业务层的解耦:如果以后我们要把计算器改成聊天、文件传输等其他业务,只需要新增一个类似的业务类,实现相同签名的 Execute 方法,再注册给 Protocol 即可,协议层代码完全不用修改。
五、TcpServer.hpp 服务端网络通信模块
前面我们把协议解析、业务计算都讲了,但这些模块本质上都是 "空中楼阁",必须依托底层的网络通信才能真正跑起来。而 TcpServer.hpp 就是专门负责服务端网络通信的底层模块,它封装了 TCP 套接字创建、绑定、监听、数据收发等所有基础网络操作,把复杂的系统调用做了统一封装,只给上层暴露简洁的调用接口。接下来我们就详细讲解这个文件,搞清楚它是如何为上层协议模块和业务模块提供网络通信支撑的。
整个类的代码如上,下面我们逐个板块讲解:
1. 头文件
- Socket.hpp:封装了系统调用 socket()、bind()、listen()、accept()、recv()、send() 等操作,让我们不用直接写系统调用。
- InetAddr.hpp:封装了 IP 地址和端口号的管理,比如 sockaddr_in 结构体的创建、转换、打印。
- Logger.hpp:日志工具,帮我们打印不同级别的日志。
2. 私有成员变量
_port:服务器绑定的端口号
_listensock:监听套接字,由 Socket.hpp 提供的封装类管理
_handler:收到数据后,要调用的上层处理回调(也就是 Protocol 层)
这是一个函数对象类型,它的签名是:
- 参数:std::string& ------ 收到的原始网络数据(inbuffer)
- 返回值:std::string ------ 处理后要发回给客户端的响应数据(outbuffer)
在我们项目里,这个 Handler_t 最终就是 protocol->ParseRequest(inbuffer),也就是协议层的解析函数。
3. 构造函数:创建监听套接字
这里依赖了 Socket.hpp 里的 TcpSocket 类:
BuildTcpSocketMethod(_port) 内部封装了 socket() → bind() → listen() 这三步
构造完成后,_listensock 就是一个已经绑定端口、处于监听状态的套接字
4. Loop ():服务器主循环

这是服务器的核心循环
5. service ():处理单个客户端的读写
1. service 函数的两个参数,本质上是我们对底层网络细节做了一层面向对象的封装。第一个参数是经过 Accept() 调用后创建的通信套接字对象,我们把原生 int 类型的文件描述符 fd 封装进了 Socket 类内部,并通过智能指针来管理它的生命周期,保证资源安全。第二个参数则是 InetAddr 封装对象,Accept 时就已经把客户端的 IP 与端口信息填充完成,用于标识当前连接的客户端身份。这两个参数,一个负责通信数据的收发,一个保存客户端的地址信息。
-
Recv() 函数里的参数 inbuffer 是输出型参数。调用前 inbuffer 是空字符串调用 sockfd->Recv(&inbuffer) 后函数内部把从网络上读到的字节,全部填进了 inbuffer 里。outbuffer 是我们自己定义的空字符串,后面靠回调函数 _handler 往里面填数据。
-
下来的回调逻辑核心,我们通过 Recv 函数将网络数据读取至 inbuffer 后,并不会直接处理这些原始字节流,而是借助提前注册的回调函数,将 inbuffer 传递给协议层,也就是调用 Protocol 类的 ParseRequest 方法。在该方法内部,会依次完成对原始字节数据的解包、反序列化操作,解析出标准的 Request 请求对象,随后触发第二层业务回调,交由 Calculator 类完成核心计算逻辑,再将计算结果封装为 Response 对象,经过序列化、封包处理后,生成一段可直接发送的完整报文并返回。
-
而关于缓冲区的设计也尤为关键:outbuffer 在每次循环处理前都会被清空,避免上一次的响应数据残留导致报文错乱;它与 inbuffer 不同,inbuffer 是由 Recv 通过输出型参数填充而来,outbuffer 则是接收回调函数处理后返回的有效响应报文。同时得益于 while 循环的持续处理,每解析完一个完整的客户端请求报文,就会将对应的报文追加至 outbuffer 中,再统一发送给客户端。
-
当服务端完成所有业务处理与协议封装后,就来到了最终的数据发送环节。此时调用 Send 函数发送的 outbuffer,已经历经了解包、反序列化、业务计算、结果序列化、协议封包全流程处理,里面存放的是携带 4 字节长度头、搭配 JSON 字符串的完整合规报文,是可以直接传输的最终数据,无需再做任何额外处理。服务端通过 Send 将这份封装好的响应报文,经由 TCP 通信链路发送给对应的客户端后,客户端的网络模块会率先接收到这份原始报文数据,随即进入客户端的协议处理流程:会调用协议层的 ParseResponse 方法,先读取报文头部的 4 字节长度字段,精准拆分出有效的 JSON 数据部分,再将其反序列化为 Response 响应对象,拿到最终的计算结果与状态码。紧接着便会触发客户端专属的结果打印回调,将计算结果、错误提示等信息直观展示出来,完成一次从客户端发起请求到服务端响应反馈的完整通信闭环。
-
日志的打印:

六、NetCalServer.cc 服务端主文件
在完成了协议解析层 Protocol 与纯业务计算层 Calculator 的编写后,我们已经把底层通信能力与核心计算逻辑准备完毕,接下来就需要一个入口程序,将网络通信、协议解析、业务计算三者组装起来,形成一个能真正运行的服务器。这就是我们接下来要讲解的服务端 main.cc 文件,它负责搭建 TCP 监听、注册业务回调、循环接收并处理客户端数据,是整个服务端程序的启动入口与调度核心。
这是整个服务端程序的入口函数,它的核心作用只有一个:把网络层(TcpServer)、协议层(Protocol)、业务层(Calculator)三个模块组装起来,形成一个能跑起来的完整服务器。
第一步创建 Calculator 业务对象,它就是我们前面写的加减乘除取模的业务实现;用 std::unique_ptr 管理,自动释放内存,更安全;这个对象的 Execute 方法,就是我们后面要注册给 Protocol 的业务回调。
第二部创建 Protocol 协议对象,并注册业务回调。Protocol 构造函数需要传入一个 HandlerRequest_t 类型的回调;这里我们用一个lambda 表达式,把 cal->Execute(req) 包装成 Protocol 需要的回调:捕获 cal 智能指针;参数是 Request &req;返回值是 Response;这样 Protocol 在 ParseRequest 中,解析出 Request 对象后,就会自动调用这个 lambda,最终执行 cal->Execute(req),完成计算;这一步就是把 "协议层" 和 "业务层" 通过回调绑定起来了。
第三步就是创建 TcpServer 网络对象,并注册协议解析回调,TcpServer 构造函数需要传入两个东西:一个 "收到数据后的处理回调";服务器要绑定的端口号。这里的 lambda 回调,就是把 protocol->ParseRequest(inbuffer) 包装成 TcpServer 需要的类型:捕获 protocol 智能指针;参数是 std::string &inbuffer (TCP 接收缓冲区);返回值是 std::string (处理完后要发送的响应数据);这样 TcpServer 收到客户端数据后,就会自动调用这个 lambda,最终执行 protocol->ParseRequest (inbuffer),完成解包、解析、计算、封包响应的整个流程;这一步就是把 "网络层" 和 "协议层" 通过回调绑定起来了。
第五步启动服务器 tsvr->Loop(),启动 TCP 服务器的事件循环,开始监听端口、接收客户端连接、处理数据;服务器启动后,程序就停在这里,等待客户端请求,不会往下执行;只有当服务器退出时,才会执行 return 0;。
七、NetCalClient.cc 客户端主文件
前面我们已经把服务端从网络底层、协议解析、业务计算到入口组装的整套逻辑完整梳理完毕,服务端已经可以稳定接收连接、处理计算请求并返回响应。但整个 TCP 通信模型是双向的,光有服务端还不够,必须有客户端主动发起连接、发送请求、接收并展示结果,才能完成一次完整的交互闭环。接下来我们就来解析整个项目的最后一块核心代码 ------ 客户端的 .cc 文件,它是整个客户端程序的入口,负责主动连接服务端、封装用户输入的计算请求、发送报文、接收服务端响应并解析展示结果,和我们之前讲的服务端逻辑形成完美对称,共同构成完整的 TCP 计算器通信架构。
代码如上,下面我们进行分析:
Socket.hpp 同时封装了服务端监听套接字和客户端通信套接字。
和服务端的用法提示逻辑一致,客户端启动时必须传入两个参数:服务端 IP + 服务端端口。
这就是我们之前说的第三层回调。
- 服务端回调:网络→协议→计算;
- 客户端回调:协议→结果打印。
协议层解析完服务端返回的 Response 对象后,调用这个回调,把计算结果、错误码直接打印到控制台。
Usage 和 HandlerResponse 两个函数被修饰为static,核心目的是限制函数的作用域,使其仅在当前 client.cc 文件内可见、可调用,避免全局命名冲突,同时明确这两个函数是客户端专属的内部工具函数。C++ 里,普通全局函数,整个项目所有文件都能调用;加了 static 之后,这个函数就变成了文件级私有函数:
1. 创建客户端套接字并连接服务端
客户端套接字的创建采用了 C++ 智能指针的写法,使用 std::unique_ptr 作为管理对象,配合std::make_unique 完成内存创建。std::unique_ptr是独占式智能指针,保证套接字对象在整个客户端生命周期中仅被单一指针管理;而std::make_unique是 C++ 推荐的安全创建方式,会自动完成对象内存分配与智能指针封装,无需手动管理 new 和 delete,避免内存泄漏。
BuildTcpClientSockMethod() 内部只调用了 socket() 创建套接字,客户端不用绑定端口,操作系统会自动分配临时端口;InetAddr 把服务端的 IP 和端口封装成系统需要的地址结构;Connect() 和服务端建立连接;连接失败直接打印错误并退出。
2. 初始化协议层(绑定打印回调)
创建 Protocol 对象,把客户端的打印回调传进去;后面解析服务端数据时,协议层会自动调用 HanlderResponse 打印结果;inbuffer 专门用来存服务端发回来的、带长度头的完整报文。
3. 主循环:循环输入、发请求、收响应
我们先在循环外层定义了发送缓冲区 outbuffer,它专门用来存储待发送至服务端、已完成协议封包的完整报文。随后程序进入三次嵌套循环,实现连续输入三组计算请求的逻辑,每次输入都会先构建 Request 对象,依托我们此前定义的请求类,存储输入的操作数 x、y 与运算符。接着依次执行序列化操作,将结构化的 Request 对象转换为标准 JSON 字符串,再通过提前初始化好的协议对象,调用其 Packet 封包方法,为 JSON 字符串添加 4 字节长度头,完成报文边界封装,处理后的结果存入send_req_string,此时该变量中已是一条独立完整、可直接传输的封包报文。
因为要连续发送三组请求,我们便将每次生成的完整封包报文,逐次追加到 outbuffer 发送缓冲区中,最终让三条完整报文拼接在一起。这样的设计,本质是为了主动测试 TCP 粘包场景,验证服务端能否凭借报文头部的长度字段,正确拆分、解析粘连的多条数据,也能充分检验我们自定义协议处理粘包问题的有效性。
5. 发送、接收、解析(和服务端收发逻辑闭环)
时outbuffer 中已存储三条连续拼接、各自携带 4 字节长度头的完整封包报文,每条报文都是独立合规的请求数据,我们可以先将其打印输出,直观查看拼接后的报文形态。随后直接调用套接字的Send 方法,将 outbuffer 中拼接好的三条报文一次性发送给服务端。
接下来调用套接字的 Recv 方法接收服务端返回的数据,这里的 inbuffer 是标准输出型参数,和服务端接收数据的逻辑完全对应,Recv 函数会将服务端传回的响应数据直接填充至 inbuffer 中,此时 inbuffer 里同样是三条连续拼接的封包报文。但是这个报文已经是我们计算好的报文,数据接收完成后,通过协议对象调用 ParseResponse 方法,对 inbuffer 中的连续报文逐一分包、解包,再将 JSON 格式的数据反序列化为 Response 对象,过程中会自动调用我们提前注册的HandleResponse 静态打印回调,将计算结果、状态码直观输出到控制台。
循环结束后关闭套接字,断开和服务端的连接。
到现在为止我们所有的文件和模块都已经讲解完了,现在我们再梳理一下客户端和服务端的逻辑 :


八、运行结果:
客户端:
客户端运行现象验证了项目通信全链路:客户端连续输入三条计算请求,经序列化、添加长度头封包后批量发送,控制台打印出的长度头与 JSON 分行显示,是封包时 \r\n 换行符作用于终端的视觉效果;服务端接收报文后完成解包、计算、封包并回传响应,客户端解析后触发打印回调,输出带错误码的结果,精准区分正常计算与除零异常;协议层解析完成后打印日志标记单次通信结束,客户端循环等待新一轮输入。
\n 代表换行,使光标下移一行;\r 代表回车,使光标回到当前行的起始位置,二者组合是跨系统的标准换行格式。
服务端:
服务端日志完整还原通信全流程:先是建立客户端连接,生成专属通信套接字;接着读取客户端批量发来、发生粘包的三条完整封包报文;随后按长度头逐次解包,提取纯净 JSON 请求,剩余未解析报文逐步减少;全部解析完成后完成业务计算、响应封包,输出待发送的响应报文,整套流程验证了自定义协议成功处理 TCP 粘包问题。
九、深度剖析 OSI 七层模型
至此,我们已经完整实现并拆解了整套 TCP 计算器项目的全链路代码与运行逻辑。而当我们将项目架构与经典的OSI 七层网络参考模型对照时,会发现我们亲手编写的每一行业务代码,都精准对应并在用户态模拟实现了 OSI 上三层的核心功能。下面我们就结合代码,深度剖析项目与网络模型的映射关系,从底层原理上升华整个项目的设计思想。

OSI 七层模型从上至下分为:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。其中,下四层(传输层、网络层、数据链路层、物理层)由 Linux 操作系统内核协议栈全权实现,我们无法在用户态代码中直接模拟;而上三层(应用层、表示层、会话层)则完全暴露给用户,由我们的业务代码来实现和定义。我们服务端主函数中定义的三大核心对象,正是对这三层模型最精准的代码级复刻。

1. 应用层:Calculator 业务层(对应 OSI 第七层)
OSI 模型中,应用层的核心定义是 "针对特定应用的协议",它直接面向用户,定义具体的业务功能。在我们的项目中,第一行代码创建的 Calculator 计算器对象,正是应用层的直接体现。计算器的加减乘除逻辑,就是本次网络通信的 "特定应用",是整个 TCP 传输最终要服务的业务目的。数据穿过层层网络,最终就是要交给这一层来处理和计算。因此,Calculator业务层,完美承担了 OSI 应用层的职责。
2. 表示层:Protocol 协议层(对应 OSI 第六层)
OSI 模型中,表示层的核心定义是 "设备固有数据格式和网络标准数据格式的转换"。简单来说,就是负责数据的序列化、反序列化、加密、压缩,解决 "不同设备怎么看懂同一份数据" 的问题。我们项目中的Protocol协议层,正是表示层的代码实现。客户端要把内存中的 Request 对象(设备私有格式)序列化为 JSON 字符串,再封包为网络字节流(网络标准格式);服务端则要将网络字节流解包、反序列化为 Request 对象。这一套序列化 / 反序列化、封包 / 解包的逻辑,正是表示层最核心的 "格式转换" 工作。我们通过自定义协议,亲手定义了网络数据的传输格式,就是在实现表示层的功能。
3. 会话层:TcpServer 网络管理层(对应 OSI 第五层)
OSI 模型中,会话层的核心定义是 "通信管理,负责建立、断开和维护通信连接",它负责管理两个设备之间的逻辑通路。我们项目中的 TcpServer 网络服务对象,承担的正是会话层的职责。服务端通过它完成监听、Accept 获取新连接、管理客户端的通信生命周期、收发数据、断开连接。我们封装的 Socket 类,本质就是在管理 TCP 会话,控制着连接的建立与销毁。因此,TcpServer 网络管理层,精准对应了 OSI 会话层的通信管理功能。
4. 下四层:操作系统内核全权接管
而 OSI 模型中剩下的传输层(TCP)、网络层(IP)、数据链路层、物理层,全部由 Linux 操作系统内核协议栈帮我们完成。我们代码中调用的 socket、connect、send、recv 等系统调用,本质上就是我们的应用程序向操作系统内核 "下达指令",由内核去完成 TCP 握手、路由寻址、网卡驱动等底层复杂工作。这些底层细节被操作系统完全封装,我们无法、也无需在用户态代码中模拟。
5. 总结
我们整个 TCP 计算器项目,本质上就是一次OSI 上三层的完整落地实践:我们用Calculator实现了应用层的业务逻辑,用Protocol实现了表示层的数据格式转换,用TcpServer实现了会话层的连接管理。通过分层回调将这三层解耦,各司其职,完美复刻了网络模型的设计思想。这也是我们为什么要分层、为什么要解耦的根本原因 ------代码分层,就是在模拟网络分层。理解了这一点,才算真正吃透了网络编程的本质。
十、代码展示

Socket.hpp
cpp
#pragma once
#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include "Logger.hpp"
#include "InetAddr.hpp"
namespace NS_SOCKET_MODULE
{
using namespace NS_LOG_MODULE;
static const int gbacklog = 16;
enum
{
OK = 0,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
// 模版方法模式!
class Socket
{
public:
~Socket()
{}
protected:
virtual void CreateSocketOrDie() = 0;
virtual void BindSocketOrDie(uint16_t port) = 0;
virtual void ListenSocketOrDie() = 0;
public:
virtual std::shared_ptr<Socket> Accepter(InetAddr &addr) = 0;
virtual int Sockfd() = 0;
virtual int Recv(std::string *out) = 0;
virtual int Send(const std::string &in) = 0;
virtual void Close() = 0;
virtual bool Connect(InetAddr &addr) = 0;
public:
void BuildTcpSocketMethod(uint16_t port) // 模版方法
{
CreateSocketOrDie();
BindSocketOrDie(port);
ListenSocketOrDie();
}
void BuildTcpClientSockMethod()
{
CreateSocketOrDie();
}
};
class TcpSocket : public Socket
{
public:
TcpSocket() : _sockfd(0)
{
}
TcpSocket(int sockfd): _sockfd(sockfd)
{}
void CreateSocketOrDie() override
{
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "create socket error";
exit(SOCKET_ERR);
}
}
void BindSocketOrDie(uint16_t port) override
{
InetAddr addr(port);
if (bind(_sockfd, addr.NetAddress(), addr.Len()) != 0)
{
LOG(LogLevel::FATAL) << "bind socket error";
exit(BIND_ERR);
}
}
void ListenSocketOrDie() override
{
if (listen(_sockfd, gbacklog) != 0)
{
LOG(LogLevel::FATAL) << "listen socket error";
exit(LISTEN_ERR);
}
}
std::shared_ptr<Socket> Accepter(InetAddr &clientaddr) override
{
struct sockaddr_in addr;
socklen_t len = sizeof(addr);
int sockfd = accept(_sockfd, CONV(&addr), &len);
if(sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept error";
return nullptr;
}
clientaddr = addr;
return std::make_shared<TcpSocket>(sockfd);
}
int Sockfd() override
{
return _sockfd;
}
int Recv(std::string *out) override
{
char inbuffer[1024];
ssize_t n = recv(_sockfd, inbuffer, sizeof(inbuffer)-1, 0);
if(n > 0)
{
inbuffer[n] = 0;
*out += inbuffer; // 追加写入的
}
return n;
}
int Send(const std::string &in) override
{
return send(_sockfd, in.c_str(), in.size(), 0);
}
void Close() override
{
if(_sockfd>=0)
{
close(_sockfd);
_sockfd = -1;
}
}
bool Connect(InetAddr &addr) override
{
int n = connect(_sockfd, addr.NetAddress(), addr.Len());
if(n < 0)
return false;
else
return true;
}
~TcpSocket() {}
private:
int _sockfd;
};
// class UdpSocket: public Socket
// {
// };
}
Protocol.hpp
cpp
#pragma once
// 自定义协议部分
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
#include <functional>
#include "Logger.hpp"
using namespace NS_LOG_MODULE;
// 请求报文
class Request
{
public:
Request() : _data_x(0), _data_y(0), _oper(0)
{
}
Request(int x, int y, char oper) : _data_x(x), _data_y(y), _oper(oper)
{
}
bool Serialize(std::string *out)
{
// 结构化 -> "_data_x _oper _data_y"
Json::Value root;
root["left"] = _data_x;
root["right"] = _data_y;
root["oper"] = _oper;
Json::FastWriter writer;
*out = writer.write(root);
return true;
}
bool Deserialize(std::string &in) // "_data_x _oper _data_y"
{
// "_data_x _oper _data_y" -> 结构化
Json::Value root;
Json::Reader reader;
bool parsesuccess = reader.parse(in, root);
if (!parsesuccess)
return false;
_data_x = root["left"].asInt();
_data_y = root["right"].asInt();
_oper = root["oper"].asInt();
return true;
}
~Request()
{
}
// get set
public:
// 10 20 '-' -> 10 - 20 = ?
// _data_x _oper _data_y
int _data_x;
int _data_y;
char _oper; // '+' '-' '/' '*' '%'
};
// 应答报文
class Response
{
public:
Response() : _result(0), _code(0)
{
}
Response(int result, int code) : _result(result), _code(code)
{
}
bool Serialize(std::string *out)
{
Json::Value root;
root["result"] = _result;
root["code"] = _code;
Json::FastWriter writer;
*out = writer.write(root);
return true;
}
bool Deserialize(std::string &in)
{
Json::Value root;
Json::Reader reader;
bool parsesuccess = reader.parse(in, root);
if (!parsesuccess)
return false;
_result = root["result"].asInt();
_code = root["code"].asInt();
return true;
}
~Response()
{
}
public:
int _result; // 结果
int _code; // 状态码
};
const std::string gsep = "\r\n";
using HandlerRequest_t = std::function<Response(Request &)>;
using HandlerResponse_t = std::function<void (Response &)>;
class Protocol
{
public:
Protocol(HandlerRequest_t handler) : _version("1.0"), _handler_request(handler)
{
}
Protocol(HandlerResponse_t handler_response):_version("1.0"), _handler_response(handler_response)
{
}
// {"left": 10, "right": 20, oper: '+'}
// len\r\n{"left": 10, "right": 20, oper: '+'}\r\n
std::string Packet(const std::string &json_string)
{
return std::to_string(json_string.size()) + gsep + json_string + gsep;
}
// len\r\n{"left": 10, "right": 20, oper: '+'}\r\n
// len\r\n{"left": 10, "right": 20, oper: '+'}\r\nlen\r\n{"left": 10, "right": 20, oper: '+'}\r\n
// len\r\n{"left": 10, "right": 20, oper: '+'}\r\nlen\r\n{"left": 10,
// len\r\n{"left": 10, "right": 20, o
// le
// ret > 0: no error, json_string != NULL
// ret == 0: no error, json_string == NULL
// ret < 0 : error.
int Unpack(std::string &packet, std::string *json_string)
{
if (packet.empty())
return 0;
if (json_string == nullptr)
return -1;
// 分析报文
auto pos = packet.find(gsep);
if (pos == std::string::npos)
return 0;
std::string lenstr = packet.substr(0, pos);
// lenstr 合法性判断,lenstr -> 123 345
int len = std::stoi(lenstr);
int total = lenstr.size() + len + 2 * gsep.size();
if (packet.size() < total)
return 0;
// 提取报文
*json_string = packet.substr(pos + gsep.size(), len);
packet.erase(0, total);
return 1;
}
// 如果读到半个报文,什么都不做
// 如果读到一个报文+,循环处理,把所有合法的报文都进行统一处理
std::string ParseRequest(std::string &inbuffer)
{
std::string result;
while (true)
{
std::string json_string;
// 1. 解包
int n = Unpack(inbuffer, &json_string);
if (n < 0)
{
LOG(LogLevel::DEBUG) << "no way !!";
return std::string();
}
if (n == 0)
{
LOG(LogLevel::INFO) << inbuffer << " parse done";
return result;
}
LOG(LogLevel::DEBUG) << "json_string:\n" << json_string;
LOG(LogLevel::DEBUG) << "unpack done, inbuffer:\n" << inbuffer;
// 2. 反序列化
// 得到一个完整的报文jsonstring
Request req;
if (!req.Deserialize(json_string))
return std::string();
// 3. 业务计算
Response resp;
if (_handler_request)
resp = _handler_request(req);
// 4. 应答序列化
std::string resp_json_string;
resp.Serialize(&resp_json_string);
// 5. 添加报头
result += Packet(resp_json_string);
}
}
std::string ParseResponse(std::string &inbuffer)
{
while (true)
{
std::string json_string;
// 1. 解包
int n = Unpack(inbuffer, &json_string);
if (n < 0)
{
LOG(LogLevel::DEBUG) << "no way !!";
return std::string();
}
if (n == 0)
{
LOG(LogLevel::INFO) << inbuffer << " parse done";
return std::string();
}
// 2. 反序列化
// 得到一个完整的报文jsonstring
Response resp;
if (!resp.Deserialize(json_string))
return std::string();
// 3. 回调处理
if (_handler_response)
_handler_response(resp);
}
}
~Protocol()
{
}
private:
std::string _version;
HandlerRequest_t _handler_request;
HandlerResponse_t _handler_response;
};
Calculator.hpp
cpp
#pragma once
#include "Protocol.hpp"
#include <iostream>
#include <string>
class Calculator
{
public:
Response Execute(const Request &req)
{
Response resp;
switch (req._oper)
{
case '+':
resp._result = req._data_x + req._data_y;
break;
case '-':
resp._result = req._data_x - req._data_y;
break;
case '*':
resp._result = req._data_x * req._data_y;
break;
case '/':
{
if (req._data_y == 0)
resp._code = 1; // div error
else
resp._result = req._data_x / req._data_y;
}
break;
case '%':
{
if (req._data_y == 0)
resp._code = 2; // mod error
else
resp._result = req._data_x % req._data_y;
}
break;
default:
resp._code = 3; // 非法操作
break;
}
return resp;
}
};
TcpServer.hpp
cpp
#include "Logger.hpp"
#include "InetAddr.hpp"
#include "Socket.hpp"
#include <memory>
#include <unistd.h>
#include <signal.h>
#include <functional>
static uint16_t gport = 8080;
using namespace NS_SOCKET_MODULE;
using Handler_t = std::function<std::string(std::string&)>;
class TcpServer
{
public:
TcpServer(Handler_t handler, uint16_t port = gport)
: _port(port),
_listensock(std::make_unique<TcpSocket>()),
_handler(handler)
{
_listensock->BuildTcpSocketMethod(_port); // 使用创建listensockfd的模版方法!
LOG(LogLevel::INFO) << "create listen socket success: " << _listensock->Sockfd();
}
void Loop()
{
signal(SIGCHLD, SIG_IGN);
while (true)
{
InetAddr clientaddr;
auto sockfd = _listensock->Accepter(clientaddr);
if (!sockfd)
continue;
LOG(LogLevel::DEBUG) << "get a new link, socket address: " << clientaddr.ToString() << " sockfd: " << sockfd->Sockfd();
if (fork() == 0)
{
// child
service(sockfd, clientaddr);
sockfd->Close();
exit(0);
}
}
}
~TcpServer()
{
}
private:
void service(std::shared_ptr<Socket> sockfd, InetAddr &clientaddr)
{
std::string inbuffer, outbuffer;
while (true)
{
outbuffer.clear();
int n = sockfd->Recv(&inbuffer);
if(n <= 0)
{
LOG(LogLevel::WARNING) << "recv: client quit, " << clientaddr.ToString();
break;
}
LOG(LogLevel::DEBUG) << "inbuffer:\n" << inbuffer;
if(_handler)
outbuffer += _handler(inbuffer);
if(outbuffer.empty())
continue;
LOG(LogLevel::DEBUG) << "outbuffer:\n" << outbuffer;
n = sockfd->Send(outbuffer);
if(n < 0)
{
LOG(LogLevel::WARNING) << "send: client quit, " << clientaddr.ToString();
break;
}
}
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listensock;
Handler_t _handler;
};
cpp
#include "TcpServer.hpp"
#include "Protocol.hpp"
#include "Calculator.hpp"
#include <memory>
static void Usage(const std::string &proc)
{
std::cout << "Usage:\n\t" << proc << " port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = std::stoi(argv[1]);
// 1. 定义计算机
std::unique_ptr<Calculator> cal = std::make_unique<Calculator>();
// 2. 定义协议对象
std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>(
[&cal](Request &req)->Response{
return cal->Execute(req);
}
);
// 3. 定义网络对象
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(
[&protocol](std::string &inbuffer)->std::string{
return protocol->ParseRequest(inbuffer);
}, port
);
// 4. 启动
tsvr->Loop();
return 0;
}
cpp
#include <iostream>
#include <string>
#include <memory>
#include "InetAddr.hpp"
#include "Socket.hpp"
#include "Protocol.hpp"
using namespace NS_SOCKET_MODULE;
static void Usage(const std::string &proc)
{
std::cout << "Usage:\n\t" << proc << " server_ip server_port" << std::endl;
}
static void HanlderResponse(Response &resp)
{
std::cout << "result: " << resp._result << "[" << resp._code << "]" << std::endl;
}
// ./netcal_client 目标IP 目标主机端口
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
std::unique_ptr<Socket> socket = std::make_unique<TcpSocket>();
socket->BuildTcpClientSockMethod();
InetAddr serveraddress(server_port, server_ip);
bool n = socket->Connect(serveraddress);
if (!n)
{
std::cerr << "connect error: " << serveraddress.ToString() << std::endl;
exit(2);
}
Protocol procotol(HanlderResponse);
std::string inbuffer;
while (true)
{
int cnt = 3;
std::string outbuffer;
while (cnt--)
{
// 0. 获取数据
int x, y;
char oper;
std::cout << "Enter Your x: ";
std::cin >> x;
std::cout << "Enter Your y: ";
std::cin >> y;
std::cout << "Enter Your oper: ";
std::cin >> oper;
// 1. 定义请求对象
Request req(x, y, oper);
// 2. 序列化
std::string req_json;
req.Serialize(&req_json);
// 3. 封装报头
std::string send_req_string = procotol.Packet(req_json);
outbuffer += send_req_string;
}
std::cout << "\n" << outbuffer << std::endl;
// 4. 发送
socket->Send(outbuffer);
// 5. 接收
socket->Recv(&inbuffer);
// 6. 解析
procotol.ParseResponse(inbuffer);
}
socket->Close();
return 0;
}
Makefile
cpp
.PHONY:all
all:netcal_server netcal_client
netcal_server:NetCalServer.cc
g++ -o $@ $^ -std=c++17 -ljsoncpp
netcal_client:NetCalClient.cc
g++ -o $@ $^ -std=c++17 -ljsoncpp
.PHONY:clean
clean:
rm -f netcal_server netcal_client
InetAddr.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define CONV(address) ((struct sockaddr *)address)
// 对客户端进行先描述
class InetAddr
{
public:
InetAddr()
{
}
InetAddr(const struct sockaddr_in &address) : _address(address), _len(sizeof(address))
{
// _ip = inet_ntoa(_address.sin_addr);
char ipstr[32];
inet_ntop(AF_INET, &(_address.sin_addr), ipstr, sizeof(ipstr));
_ip = ipstr;
_port = ntohs(_address.sin_port);
}
InetAddr(uint16_t port, const std::string &ip = "0.0.0.0") : _ip(ip), _port(port)
{
bzero(&_address, sizeof(_address));
_address.sin_family = AF_INET;
_address.sin_port = htons(_port); // h->n
//_address.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1. 字符串ip->4字节IP 2. hton
inet_pton(AF_INET, ip.c_str(), &(_address.sin_addr));
_len = sizeof(_address);
}
bool operator==(const InetAddr &addr)
{
return (this->_ip == addr._ip) && (this->_port == addr._port);
}
void operator=(struct sockaddr_in &addr)
{
_address = addr;
char ipstr[32];
inet_ntop(AF_INET, &(_address.sin_addr), ipstr, sizeof(ipstr));
_ip = ipstr;
_port = ntohs(_address.sin_port);
}
std::string ToString()
{
return "[" + _ip + ":" + std::to_string(_port) + "]";
}
struct sockaddr *NetAddress()
{
return CONV(&_address);
}
socklen_t Len()
{
return _len;
}
~InetAddr()
{
}
private:
// net address
struct sockaddr_in _address;
socklen_t _len;
// host address
std::string _ip;
uint16_t _port;
};
Logger.hpp
cpp
#ifndef __LOGGER_HPP
#define __LOGGER_HPP
#include <iostream>
#include <cstdio>
#include <string>
#include <memory>
#include <sstream>
#include <ctime>
#include <sys/time.h>
#include <unistd.h>
#include <filesystem> // C++17
#include <fstream>
#include "Mutex.hpp"
namespace NS_LOG_MODULE
{
enum class LogLevel
{
INFO,
WARNING,
ERROR,
FATAL,
DEBUG
};
std::string LogLevel2Message(LogLevel level)
{
switch (level)
{
case LogLevel::INFO:
return "INFO";
case LogLevel::WARNING:
return "WARNING";
case LogLevel::ERROR:
return "ERROR";
case LogLevel::FATAL:
return "FATAL";
case LogLevel::DEBUG:
return "DEBUG";
default:
return "UNKNOWN";
}
}
// 1. 时间戳 2. 日期+时间
std::string GetCurrentTime()
{
struct timeval current_time;
int n = gettimeofday(¤t_time, nullptr);
(void)n;
// current_time.tv_sec; current_time.tv_usec;
struct tm struct_time;
localtime_r(&(current_time.tv_sec), &struct_time); // r: 可重入函数
char timestr[128];
snprintf(timestr, sizeof(timestr), "%04d-%02d-%02d %02d:%02d:%02d.%ld",
struct_time.tm_year + 1900,
struct_time.tm_mon + 1,
struct_time.tm_mday,
struct_time.tm_hour,
struct_time.tm_min,
struct_time.tm_sec,
current_time.tv_usec);
return timestr;
}
// 输出角度 -- 刷新策略
// 1. 显示器打印
// 2. 文件写入
// 策略模式,策略接口
class LogStrategy
{
public:
virtual ~LogStrategy() = default;
virtual void SyncLog(const std::string &message) = 0;
};
// 控制台日志刷新策略, 日志将来要向显示器打印
class ConsoleStrategy : public LogStrategy
{
public:
void SyncLog(const std::string &message) override
{
LockGuard lockguard(_mutex);
std::cerr << message << std::endl; // ??
}
~ConsoleStrategy()
{
}
private:
Mutex _mutex;
};
const std::string defaultpath = "./log";
const std::string defaultfilename = "log.txt";
// 文件策略
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const std::string &path = defaultpath, const std::string &name = defaultfilename)
: _logpath(path),
_logfilename(name)
{
LockGuard lockguard(_mutex);
if (std::filesystem::exists(_logpath))
return;
try
{
std::filesystem::create_directories(_logpath);
}
catch (const std::filesystem::filesystem_error &e)
{
std::cerr << e.what() << '\n';
}
}
void SyncLog(const std::string &message) override
{
{
LockGuard lockguard(_mutex);
if (!_logpath.empty() && _logpath.back() != '/')
{
_logpath += "/";
}
std::string targetlog = _logpath + _logfilename; // "./log/log.txt"
std::ofstream out(targetlog, std::ios::app); // 追加方式写入
if (!out.is_open())
{
std::cerr << "open " << targetlog << "failed" << std::endl;
return;
}
out << message << "\n";
out.close();
}
}
~FileLogStrategy()
{
}
private:
std::string _logpath;
std::string _logfilename;
Mutex _mutex;
};
// 日志类:
// 1. 日志的生成
// 2. 根据不同的策略,进行刷新
class Logger
{
// 日志的生成:
// 构建日志字符串
public:
Logger()
{
UseConsoleStrategy();
}
void UseConsoleStrategy()
{
_strategy = std::make_unique<ConsoleStrategy>();
}
void UseFileStrategy()
{
_strategy = std::make_unique<FileLogStrategy>();
}
// 内部类, 标识一条完整的日志信息
// 一条完整的日志信息 = 做半部分固定部分 + 右半部分不固定部分
// LogMessage RAII风格的方式,进行刷新
class LogMessage
{
public:
LogMessage(LogLevel level, std::string &filename, int line, Logger &logger)
: _level(level),
_curr_time(GetCurrentTime()),
_pid(getpid()),
_filename(filename),
_line(line),
_logger(logger)
{
// 先构建出来左半部分
std::stringstream ss;
ss << "[" << _curr_time << "] "
<< "[" << LogLevel2Message(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _filename << "] "
<< "[" << _line << "] "
<< " - ";
_loginfo = ss.str();
}
template <typename T>
LogMessage &operator<<(const T &info)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this; // 返回当前LogMessage对象,方便下次继续进行<<
}
~LogMessage()
{
if (_logger._strategy)
{
_logger._strategy->SyncLog(_loginfo);
}
}
private:
LogLevel _level;
std::string _curr_time;
pid_t _pid;
std::string _filename;
int _line;
std::string _loginfo; // 一条完整的日志信息
// 一个引用,引用外部的Logger类对象
Logger &_logger; // 方便我们后续进行策略式刷新
};
// 这里已经不是内部类了
// 故意采用拷贝LogMessage
LogMessage operator()(LogLevel level, std::string filename, int line)
{
return LogMessage(level, filename, line, *this);
}
~Logger()
{
}
private:
std::unique_ptr<LogStrategy> _strategy; // 刷新策略
};
// 日志对象,全局使用
Logger logger;
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleStrategy();
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileStrategy();
#define LOG(level) logger(level, __FILE__, __LINE__)
}
#endif
Mutex.hpp
cpp
#pragma once
#include <iostream>
#include <pthread.h>
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_lock, nullptr);
}
void Lock()
{
pthread_mutex_lock(&_lock);
}
pthread_mutex_t *Ptr()
{
return &_lock;
}
void Unlock()
{
pthread_mutex_unlock(&_lock);
}
~Mutex()
{
pthread_mutex_destroy(&_lock);
}
private:
pthread_mutex_t _lock;
};
class LockGuard // RAII风格代码
{
public:
LockGuard(Mutex &lock):_lockref(lock)
{
_lockref.Lock();
}
~LockGuard()
{
_lockref.Unlock();
}
private:
Mutex &_lockref;
};
十一、总结
本文详细介绍了如何实现一个基于TCP协议的网络计算器系统。该系统采用分层架构设计,包含网络通信层(TcpServer)、协议解析层(Protocol)和业务计算层(Calculator),分别对应OSI模型中的会话层、表示层和应用层。通过自定义应用层协议解决TCP粘包问题,使用JSON进行序列化和反序列化,实现了客户端请求的发送和服务端结果的返回。系统采用回调机制实现模块解耦,支持多进程并发处理客户端请求。文章从设计框架到具体实现,详细讲解了Socket封装、协议处理、业务逻辑等核心模块,并展示了运行效果和与OSI七层模型的对应关系。
谢谢大家的观看!


封装了原生的 accept 系统调用,服务端会阻塞等待,直到有客户端发起连接;成功后返回一个新的文件描述符 sockfd,专门用于和这个客户端通信;clientaddr = addr 表示把客户端地址信息保存到传入的引用参数里,上层可以拿到客户端 IP 和端口,方便日志打印和调试。accept 拿到一个新的客户端 fd,把这个 fd 装进 TcpSocket 对象里,包成智能指针给上层,上层拿着这个智能指针,直接调用 Recv、Send、Close就行。
Sockfd() 获取底层文件描述符,返回当前套接字的原生文件描述符;上层代码如果需要直接操作 fd,可以通过这个方法获取;
Recv() 接收客户端数据,通过 *out += inbuffer 追加,这样设计的好处是,上层可以维护一个 inbuffer,多次 Recv 调用的数据会自动拼接起来,完美适配 TCP 的流式传输特性,后续我们的协议解包逻辑,就是基于这个拼接后的完整字符串处理的。
Send() 发送数据给客户端,这里我们使用了 send() 系统调用,它和 write() 本质上是一样的,只是参数多了一个标志位。
Close() 关闭套接字,调用 close(_sockfd) 释放文件描述符,再把 _sockfd 置为 -1,标记为无效;配合智能指针的 RAII 特性,对象销毁时自动调用 Close,实现资源自动释放,杜绝资源泄漏。
Connect() 客户端连接服务器,addr 是服务器的 IP 和端口,通过我们封装的 InetAddr 类传入;调用 connect() 系统调用,成功返回 0,失败返回 - 1。
下来就是 Serialize() 序列化函数和 Deserialize() 反序列化函数。
这个类的核心作用是专门封装服务器要返回给客户端的计算结果,存 2 个核心数据:计算结果、状态码。Request 是客户端发出去的请求,Response 是服务器发回来的响应。这个类里的序列化/反序列化逻辑和 Request 一样,只是存的数据不一样。状态码 _code = 0 表示计算成功,result 是正确结果。_code!=0 表示计算失败(比如除零、非法运算符),result 无意义。
1. 有效载荷长度:也就是 JSON 字符串内容的字节长度,用一个十进制数字表示;
首先我们在全局定义一个全局分隔符 "\r\n"。
这是 Packet 封包函数,用来将序列化后的 JSON 字符串封包程可传输的完整报文,按照 "长度头 + 分隔符 + JSON + 分隔符" 的完整网络报文。
这是 Unpack 解包函数,表示我们要将网络完整的报文解包为纯净的 JSON 字符串。第二个参数也是一个输出型参数,把解包后的 JSON 字符串通过指针带出来。
在 Protocol 类的私有成员中,_version 仅作为协议版本标识,用于版本兼容校验,并非核心功能;真正支撑整个网络通信业务解耦设计的,是下面的两个回调函数成员变量。首先通过using 关键字完成回调类型的别名定义,其中 HandlerRequest_t 被定义为一个 std::function函数容器,专门用于存放接收 Request 对象引用、返回 Response 对象的业务计算函数,对应服务器侧的回调逻辑,当服务器解析完客户端请求后,便会触发该回调,传入封装好请求数据的 Request 对象,执行业务计算逻辑并返回封装运算结果的 Response 对象;而HandlerResponse_t 则被定义为存放接收 Response 对象引用、无返回值的函数容器,对应客户端侧的回调逻辑,客户端解析完服务器响应报文后,触发该回调,传入存储运算结果的 Response 对象,完成结果打印展示的上层业务操作。这两个回调函数的具体实现逻辑,我们将在主函数中实现并注册到 Protocol 对象中,Protocol 类仅预留回调入口即可,不关心具体业务细节,通过这种回调机制,彻底实现了底层网络协议层与上层业务逻辑层的解耦,让网络通信框架具备极强的通用性和扩展性。
这个类中我们用了三个错误码来区分不同的异常场景:
Execute() 函数的签名和 HandlerRequest_t 的定义完全匹配,所以 Calculator::Execute 可以直接被注册为 Protocol 类的业务回调函数。
_port:服务器绑定的端口号
这是一个函数对象类型,它的签名是:
这里依赖了 Socket.hpp 里的 TcpSocket 类:
第一步创建 Calculator 业务对象,它就是我们前面写的加减乘除取模的业务实现;用 std::unique_ptr 管理,自动释放内存,更安全;这个对象的 Execute 方法,就是我们后面要注册给 Protocol 的业务回调。
第二部创建 Protocol 协议对象,并注册业务回调。Protocol 构造函数需要传入一个 HandlerRequest_t 类型的回调;这里我们用一个lambda 表达式,把 cal->Execute(req) 包装成 Protocol 需要的回调:捕获 cal 智能指针;参数是 Request &req;返回值是 Response;这样 Protocol 在 ParseRequest 中,解析出 Request 对象后,就会自动调用这个 lambda,最终执行 cal->Execute(req),完成计算;这一步就是把 "协议层" 和 "业务层" 通过回调绑定起来了。
第三步就是创建 TcpServer 网络对象,并注册协议解析回调,TcpServer 构造函数需要传入两个东西:一个 "收到数据后的处理回调";服务器要绑定的端口号。这里的 lambda 回调,就是把 protocol->ParseRequest(inbuffer) 包装成 TcpServer 需要的类型:捕获 protocol 智能指针;参数是 std::string &inbuffer (TCP 接收缓冲区);返回值是 std::string (处理完后要发送的响应数据);这样 TcpServer 收到客户端数据后,就会自动调用这个 lambda,最终执行 protocol->ParseRequest (inbuffer),完成解包、解析、计算、封包响应的整个流程;这一步就是把 "网络层" 和 "协议层" 通过回调绑定起来了。
第五步启动服务器 tsvr->Loop(),启动 TCP 服务器的事件循环,开始监听端口、接收客户端连接、处理数据;服务器启动后,程序就停在这里,等待客户端请求,不会往下执行;只有当服务器退出时,才会执行 return 0;。
Socket.hpp 同时封装了服务端监听套接字和客户端通信套接字。
和服务端的用法提示逻辑一致,客户端启动时必须传入两个参数:服务端 IP + 服务端端口。
这就是我们之前说的第三层回调。
客户端套接字的创建采用了 C++ 智能指针的写法,使用 std::unique_ptr 作为管理对象,配合std::make_unique 完成内存创建。std::unique_ptr是独占式智能指针,保证套接字对象在整个客户端生命周期中仅被单一指针管理;而std::make_unique是 C++ 推荐的安全创建方式,会自动完成对象内存分配与智能指针封装,无需手动管理 new 和 delete,避免内存泄漏。
创建 Protocol 对象,把客户端的打印回调传进去;后面解析服务端数据时,协议层会自动调用 HanlderResponse 打印结果;inbuffer 专门用来存服务端发回来的、带长度头的完整报文。
我们先在循环外层定义了发送缓冲区 outbuffer,它专门用来存储待发送至服务端、已完成协议封包的完整报文。随后程序进入三次嵌套循环,实现连续输入三组计算请求的逻辑,每次输入都会先构建 Request 对象,依托我们此前定义的请求类,存储输入的操作数 x、y 与运算符。接着依次执行序列化操作,将结构化的 Request 对象转换为标准 JSON 字符串,再通过提前初始化好的协议对象,调用其 Packet 封包方法,为 JSON 字符串添加 4 字节长度头,完成报文边界封装,处理后的结果存入send_req_string,此时该变量中已是一条独立完整、可直接传输的封包报文。
时outbuffer 中已存储三条连续拼接、各自携带 4 字节长度头的完整封包报文,每条报文都是独立合规的请求数据,我们可以先将其打印输出,直观查看拼接后的报文形态。随后直接调用套接字的Send 方法,将 outbuffer 中拼接好的三条报文一次性发送给服务端。
客户端运行现象验证了项目通信全链路:客户端连续输入三条计算请求,经序列化、添加长度头封包后批量发送,控制台打印出的长度头与 JSON 分行显示,是封包时 \r\n 换行符作用于终端的视觉效果;服务端接收报文后完成解包、计算、封包并回传响应,客户端解析后触发打印回调,输出带错误码的结果,精准区分正常计算与除零异常;协议层解析完成后打印日志标记单次通信结束,客户端循环等待新一轮输入。
服务端日志完整还原通信全流程:先是建立客户端连接,生成专属通信套接字;接着读取客户端批量发来、发生粘包的三条完整封包报文;随后按长度头逐次解包,提取纯净 JSON 请求,剩余未解析报文逐步减少;全部解析完成后完成业务计算、响应封包,输出待发送的响应报文,整套流程验证了自定义协议成功处理 TCP 粘包问题。