🔥小叶-duck:个人主页
❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux系统从入门到实践》《Linux网络从入门到实践》
《Qt 方寸极境》 《MySQL》
✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游
目录
前言
一、核心模块源码深度解析
1.1 Socket 封装:Socket.hpp(模板方法模式)
1.1.1 模板方法模式思想
1.1.2 Socket 基类设计
1.1.3 子类 TcpSocket 实现
Accept && Recv 设计注意事项
1.2 网络地址封装:InetAddr.hpp
1.3 线程安全互斥锁封装:Mutex.hpp
1.4 通用工具头文件:Common.hpp
二、整体架构设计:三层解耦的工业级架构
2.1 三层架构设计
2.2 三层架构的核心职责
2.3 核心设计思想:回调函数解耦
2.4 网络传输层:TCP 服务器实现(TcpServer.hpp)
2.5 协议层核心实现:Protocol.hpp
GetRequest && GetResponse 设计注意事项
2.6 业务层:计算器实现(Calculator.hpp)
三. 服务端与客户端完整实现
3.1 服务端实现:OnlineCalServer.cc
3.2 客户端实现:OnlineCalClient.cc
3.3 编译与运行
结束语
前言
在上一篇文章《应用层自定义协议(一):序列化、sk_buff 与粘包半包处理》中,我们已经梳理清楚 TCP 粘包问题成因、自定义报文协议、序列化反序列化等网络编程相关理论知识点。纸上得来终觉浅,理论知识需要落地到真实项目当中加深理解。本文依托一套完整可运行的工业级 C++ 代码,手把手带领大家完成一套三层解耦架构的在线计算器 TCP 服务。
我们会从底层 Socket 类封装、网络地址封装、互斥锁与通用工具组件开始讲解,逐层向上完成协议层封包解包逻辑,再编写上层计算器业务代码,最后完成服务端、客户端完整工程实现以及编译部署,完整复刻企业网络服务的开发流程。文中全部代码均经过实测验证,读完本篇你不仅可以吃透 TCP 自定义协议的工程落地细节,还能掌握模板方法模式、回调解耦、RAII 资源管理等经典技术思想在网络项目中的实践,以上内容同样也是后端开发面试的高频考察点。
一、核心模块源码深度解析
1.1 Socket 封装:Socket.hpp(模板方法模式)
1.1.1 模板方法模式思想
- 设计模式:模板方法模式,定义算法骨架,具体步骤延迟到子类实现。
- 设计目的:统一 TCP/UDP/Unix 域套接字的创建流程,代码复用、解耦、易扩展。
- 核心:基类
Socket定义纯虚接口,模板方法组合接口流程;子类TcpSocket重写接口。 - 优势:新增套接字类型只需继承基类,无需修改原有代码,符合开闭原则。
1.1.2 Socket 基类设计
- 基类定义套接字必备操作:创建、绑定、监听、接收、连接、收发数据、关闭。
- 模板方法:
- BuildTcpSocketMethod:服务端套接字流程(创建→绑定→监听)。
- BuildTcpClientSocketMethod:客户端套接字流程(创建→连接)。
- 析构函数设为虚函数,保证子类析构能被正确调用。
const static int g_backlog = 16; class Socket { public: // 纯虚接口,子类必须实现 virtual void SocketOrDie() = 0; virtual void BindOrDie(uint16_t port) = 0; virtual void ListenOrDie(int backlog) = 0; virtual std::shared_ptr<Socket> Accept(InetAddr *clientptr) = 0; virtual void Close() = 0; virtual int Recv(std::string *out) = 0; virtual int Send(const std::string &in) = 0; virtual int Connect(uint16_t port, const std::string &ip) = 0; virtual ~Socket() {} // 虚析构,防止内存泄漏 public: // 纯虚接口,子类必须实现 void BuildTcpSocketMethod(uint16_t port, int backlog = g_backlog) { SocketOrDie(); BindOrDie(port); ListenOrDie(backlog); } // 模板方法:客户端套接字创建流程 void BuildTcpClientSocketMethod() { SocketOrDie(); } // void BuildUdpSocketMethod() // { // SocketOrDie(); // BindOrDie(); // } };1.1.3 子类 TcpSocket 实现
- 继承
Socket基类,重写所有纯虚接口,实现 TCP 套接字具体操作。 CreateSocketOrDie:创建 TCP 套接字,失败直接退出。BindSocketOrDie:绑定端口号,失败直接退出。ListenSocketOrDie:启动监听,队列长度gbacklog=16。Accepter:接收客户端连接,返回新套接字的智能指针。Recv/Send:封装recv/send系统调用,Recv自动拼接缓冲区 数据。Close:关闭文件描述符,防止资源泄漏。
class TcpSocket : public Socket { public: TcpSocket() : _sockfd(-1) { } // 构造函数重载 TcpSocket(int sockfd) : _sockfd(sockfd) { } void SocketOrDie() override { _sockfd = socket(AF_INET, SOCK_STREAM, 0); if (_sockfd < 0) { LOG(LogLevel::FATAL) << "socket error"; exit(SOCKET_ERR); } LOG(LogLevel::INFO) << "socket success"; } void BindOrDie(uint16_t port) override { InetAddr addr(port); int n = bind(_sockfd, addr.Addr(), addr.AddrLen()); if (n < 0) { LOG(LogLevel::FATAL) << "bind error"; exit(BIND_ERR); } LOG(LogLevel::INFO) << "bind success"; } void ListenOrDie(int backlog) override { int n = listen(_sockfd, backlog); if (n < 0) { LOG(LogLevel::FATAL) << "listen error"; exit(LISTEN_ERR); } LOG(LogLevel::INFO) << "listen success"; } // int Accept(InetAddr *clientptr) override std::shared_ptr<Socket> Accept(InetAddr *clientptr) override { struct sockaddr_in peer; socklen_t len = sizeof(peer); int fd = accept(_sockfd, (struct sockaddr *)&peer, &len); if (fd < 0) { // return -1; return nullptr; } // 关键点:clientptr是输出型参数,我们需要将获取到的peer传入到clientptr进行向外输出 clientptr->SetAddr(peer); // 这样外部传入InetAddr指针,就能获取到accept输出的struct sockaddr_in的信息字段 // return fd; return std::make_shared<TcpSocket>(fd); } void Close() { if (_sockfd >= 0) close(_sockfd); } int Recv(std::string *out) override { char buffer[1024]; int n = recv(_sockfd, &buffer, sizeof(buffer), 0); // int n = read(_sockfd, &buffer, sizeof(buffer)); //也可以 if (n > 0) { buffer[n] = 0; *out += buffer; } return n; } int Send(const std::string &in) override { return send(_sockfd, in.c_str(), in.size(), 0); // return write(_sockfd, in.c_str(), in.size()); //也可以 } int Connect(uint16_t port, const std::string &ip) override { InetAddr server(port, ip); return connect(_sockfd, server.Addr(), server.AddrLen()); } ~TcpSocket() { } private: int _sockfd; };核心设计解读:
- 模板方法模式:基类定义了BuildTcpSocketMethod和BuildTcpClientSocketMethod两个模板方法,封装了服务端和客户端 Socket 构建的固定流程。子类只需实现具体的步骤,无需关心整体流程。
- OrDie 设计:对于 Socket 创建、绑定、监听这些关键操作,如果失败直接退出程序。因为这些操作失败意味着服务无法正常启动,继续运行没有意义。
- 关键的拼接逻辑:
Recv方法中使用*outstr += buffer而不是覆盖,这是解决 TCP 半包问题的基础。不完整的报文会被拼接在缓冲区中,等待下次读取。 - 面向对象封装:将文件描述符封装在类内部,通过成员函数操作,避免了全局文件描述符带来的混乱。
Accept && Recv 设计注意事项
1.2 网络地址封装:InetAddr.hpp
在网络编程开发过程中,IP 地址、端口号的处理十分高频,其中就包含主机字节序与网络字节序之间的相互转换。原生 Socket 的 API 接口较为底层,每次开发都要手动调用转换函数,重复代码多,也容易出现字节序遗忘转换的低级 bug。InetAddr类把这部分繁琐的底层逻辑做封装,对外暴露简洁统一的调用接口,简化网络地址相关业务开发。
- 封装
sockaddr_in结构体,统一处理网络字节序↔主机字节序转换。 - 提供
Addr()/AddrLen()方法,直接适配bind/connect/accept系统调用。 StringAddress():将地址转为[IP:端口]格式,用于日志打印。
#ifndef INETADDR_HPP #define INETADDR_HPP #include "Common.hpp" // 网络地址和主机地址之间进行转换的类 class InetAddr { public: // 无参构造 InetAddr() {} // 构造函数的函数重载: // 网络转本地:主要用于接收来自网络客户端消息后,解析获取本地ip地址和端口号 InetAddr(struct sockaddr_in &addr) { // _ip = inet_ntoa(_addr.sin_addr); // _port = ntohs(_addr.sin_port); // 复用SetAddr函数 SetAddr(addr); } // 本地转网络:主要用于客户端已知服务端ip地址和端口号,填充获取struct sockaddr_in _addr,向服务端发送消息 InetAddr(uint16_t port, const std::string &ip) : _port(port), _ip(ip) { bzero(&_addr, sizeof(_addr)); _addr.sin_family = AF_INET; _addr.sin_port = htons(_port); _addr.sin_addr.s_addr = inet_addr(_ip.c_str()); } // 本地转网络,我们还能再写一个没有ip参数的函数重载 // 为了方便服务端的使用构建struct sockaddr_in(服务端无需传入ip地址:INADDR_ANY) InetAddr(uint16_t port) : _port(port), _ip("0") { bzero(&_addr, sizeof(_addr)); _addr.sin_family = AF_INET; _addr.sin_port = htons(_port); _addr.sin_addr.s_addr = INADDR_ANY; } // 获取主机字节序的端口号 uint16_t Port() { return _port; } // 获取点分十进制字符串 IP std::string Ip() { return _ip; } struct sockaddr_in &Addr_in() { return _addr; } // 获取指向底层 sockaddr 结构的指针,用于 sendto 等系统调用 struct sockaddr *Addr() { return (struct sockaddr *)&_addr; } // 获取底层结构体的大小,用于套接字系统调用时的长度参数 socklen_t AddrLen() { return sizeof(_addr); } // 将地址信息转化为易读的字符串格式,如 [127.0.0.1:8080] // 常用于打印日志信息 std::string StringAddress() { return "[" + _ip + ":" + std::to_string(_port) + "]"; } // 补充功能:重载运算符==,用于在在线用户列表中查找指定客户端 bool operator==(const InetAddr &who) { return this->_ip == who._ip && this->_port == who._port; } // 补充功能:accept获取对于客户端套接字,用于InetAddr参数向外输出 void SetAddr(struct sockaddr_in &addr) { _ip = inet_ntoa(addr.sin_addr); _port = ntohs(addr.sin_port); _addr = addr; } ~InetAddr() { } private: // 本地主机格式的地址信息 uint16_t _port; // 客户端的端口号 std::string _ip; // 客户端的IP地址 // 原始网络格式的地址结构体 struct sockaddr_in _addr; // 客户端的struct sockaddr_in }; #endif核心设计解读:
- 双构造函数的设计思想:提供两套构造逻辑,分别适配「主机字节序转网络字节序」、「网络字节序转主机字节序」两类典型场景。完整覆盖服务端 bind 绑定监听、客户端发起 connect 连接、获取对端连接地址等绝大多数网络地址处理场景。
- 字节序自动屏蔽底层细节:字节序转换的逻辑全部封装在类内部完成,上层业务代码无需手动调用
htons、ntohs等接口,开发者不用关心大小端的底层差异,降低字节序相关错误的概率。 - 面向调试的日志友好接口:对外提供
StringAddress()成员方法,能够把网络地址格式化输出为[IP:Port]的可读字符串格式,方便日志打印、问题排查,提升调试效率。
1.3 线程安全互斥锁封装:Mutex.hpp
多线程并发场景下,多个执行流会同时访问共享临界资源,极易引发数据竞争,互斥锁就是用来解决该问题的核心同步工具。本模块对 POSIX 原生互斥锁进行二次封装,并且实现 RAII 风格的锁守卫类,依靠 C++ 对象生命周期自动完成锁的申请与释放,降低手动操作锁带来的各类隐患。
// 互斥锁的封装 #ifndef MUTEX_HPP #define MUTEX_HPP #include <iostream> #include <pthread.h> #include <string> namespace MutexModule { class Mutex { public: Mutex() { pthread_mutex_init(&_mutex, nullptr); // std::cout << "mutex init success" << std::endl; } void Lock() { int n = pthread_mutex_lock(&_mutex); if (n != 0) { std::cerr << "pthread_mutex_lock false" << std::endl; } } void Unlock() { int n = pthread_mutex_unlock(&_mutex); if (n != 0) { std::cerr << "pthread_mutex_unlock false" << std::endl; } } ~Mutex() { int n = pthread_mutex_destroy(&_mutex); if (n != 0) { std::cerr << "pthread_mutex_destroy false" << std::endl; } else { // std::cout << "mutex destroy success" << std::endl; } } pthread_mutex_t *GetMutex() { return &_mutex; } private: pthread_mutex_t _mutex; }; // RAII风格的互斥锁的封装(实现自动解锁) class LockGuard { public: LockGuard(Mutex &mutex) : _mutex(mutex) { _mutex.Lock(); } ~LockGuard() { _mutex.Unlock(); } private: Mutex &_mutex; }; } #endif核心设计解读:
- RAII 自动锁管理:
LockGuard锁守卫类充分利用 C++ RAII 特性,对象构造阶段完成加锁,对象析构时自动执行解锁逻辑。即便函数内部出现异常抛出、提前 return 退出等情况,锁依旧可以被正常释放,从根源上规避因忘记解锁造成的死锁问题。 - 兼容原生底层接口:对外提供
origin()接口,能够获取底层原生 pthread_mutex_t 锁指针,方便对接依赖 POSIX 锁的第三方组件,做到上层封装的同时保留底层接口的拓展能力。
1.4 通用工具头文件:Common.hpp
Common.hpp 封装项目通用基础能力,统一定义程序退出错误码与不可拷贝基类,收拢项目所需各类头文件依赖,是整个网络项目的公共基础头文件。
#pragma once #include <iostream> #include <string> #include <memory> #include <cstdlib> #include <unistd.h> #include <functional> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/wait.h> #include <strings.h> enum ExitCode { OK = 1, USAGE_ERR, SOCKET_ERR, BIND_ERR, LISTEN_ERR, CONNECT_ERR, FORK_ERR, OPEN_ERR }; // 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化 // delete掉唯一父类的拷贝构造和赋值拷贝,则所有子类服务器也就全都不能拷贝了 class Nocopy { public: Nocopy() {} ~Nocopy() {} // 禁止父类的拷贝构造和赋值拷贝 Nocopy(const Nocopy &) = delete; const Nocopy &operator=(const Nocopy &) = delete; };源码核心解读:
- 定义一套统一的程序退出错误码 ExitCode:用于标识 socket、bind、listen、fork 等各类系统调用失败场景,统一项目异常退出标识;
- 实现 Nocopy 禁止拷贝基类:继承该类的子类自动禁用拷贝构造与赋值重载,避免对象拷贝引发的资源管理问题,网络服务类可以直接继承 Nocopy。
Common.hpp 集中收纳 C++ 标准库、Linux 系统网络编程头文件,项目其余代码只需要引入这一个头文件,就可以获得网络开发需要的基础依赖,简化项目头文件引入工作。
二、整体架构设计:三层解耦的工业级架构
2.1 三层架构设计
正式编码实现之前,需要先完成整体架构规划。一套高质量网络服务程序,应当遵循高内聚、低耦合的设计原则,方便后续迭代拓展与维护。这里我们选用经典三层分层架构进行开发:
┌─────────────────┐ │ 业务层 │ Calculator.hpp:负责具体的计算逻辑 ├─────────────────┤ │ 协议层 │ Protocol.hpp:负责封包、解包、序列化、反序列化 ├─────────────────┤ │ 网络通信层 │ Socket.hpp + TcpServer.hpp:负责TCP连接与数据收发 └─────────────────┘- 业务层:
Calculator,负责具体的业务逻辑(计算器运算)。 - 协议层:
Protocol,负责封包 / 解包、序列化 / 反序列化、粘包解决。 - 网络通信层:
TcpServer/Socket,负责连接管理、IO 收发。 - 解耦方式:回调函数注入,层与层之间互不感知内部实现。
2.2 三层架构的核心职责
- 业务层:聚焦业务本身逻辑,对底层网络传输细节无感知。接收协议层交付好的结构化请求,执行业务处理之后,输出结构化响应交还给协议层。
- 协议层:充当网络通信层和业务层之间的中间桥梁。把网络传来的原始字节流解析为结构化请求对象;同时将业务层输出的响应对象序列化为网络字节流,并且处理 TCP 粘包、半包这类经典网络问题。
- 网络通信层:只专注原始字节流的接收和发送,完全不感知上层业务语义。对外封装统一 Socket 接口,接管 TCP 连接创建、端口监听、连接接受、数据读写等底层网络操作。
2.3 核心设计思想:回调函数解耦
- 网络层收到原始字节数据后,不做任何业务解析,而是通过预先注册的回调函数,把数据原样传递给协议层处理。
- 协议层从字节流中解析出完整的结构化请求后,再通过回调函数,把请求对象交给业务层执行具体业务逻辑。
- 业务层处理完成,将响应结果返回给协议层,由协议层负责序列化、封包,再交回网络层进行发送给客户端。
这种设计的核心优势在于:各层可以独立开发、独立测试、独立替换,层与层之间只依赖约定好的接口,不依赖具体实现。
2.4 网络传输层:TCP 服务器实现(TcpServer.hpp)
基于封装好的 Socket 类,我们实现一个多进程的 TCP 服务器,每个客户端连接由一个独立的子进程处理。
#ifndef TCPSERVER_HPP #define TCPSERVER_HPP #include "Socket.hpp" #include <functional> using namespace SocketModule; // 回调处理 using callback_t = std::function<bool(InetAddr &, std::string &, std::string *)>; class TcpServer : public Nocopy { public: TcpServer(uint16_t port, callback_t service) : _port(port), _listen_sockptr(std::make_unique<TcpSocket>()), _isrunning(false), _service(service) { _listen_sockptr->BuildTcpSocketMethod(_port); } void Start() { _isrunning = true; while (_isrunning) { InetAddr client; // int sockfd = _listen_sockptr->Accept(&client); //也不是不行,只是后续不能面向对象使用接口 auto sockptr = _listen_sockptr->Accept(&client); // 传入client地址,就能获取到对应accept的客户端信息 if (sockptr == nullptr) { LOG(LogLevel::WARNING) << "accept error..."; continue; } LOG(LogLevel::INFO) << client.StringAddress() << " accpet success..."; // sockptr && client // 使用多进程 pid_t id = fork(); if (id < 0) { LOG(LogLevel::FATAL) << "fork error"; exit(FORK_ERR); } else if (id == 0) { // 子进程 if (fork() > 0) exit(OK); // 孙子进程(子进程的子进程) // 关闭不需要的监听套接字(在哪? ——> _listen_sockptr) _listen_sockptr->Close(); Run(sockptr, client); sockptr->Close(); exit(OK); } else { // 父进程 // 关闭不需要的accept返回的文件描述符(在哪? ——> sockptr),只负责监听 // close(sockfd); //如果Accept返回值是int,那么这里就只能调用系统调用close了 sockptr->Close(); waitpid(id, nullptr, 0); } } _isrunning = false; } //TCP层只负责报文的收发 void Run(std::shared_ptr<Socket> &sockptr, InetAddr &client) { // 设计的是长服务:只要客户端不断开,就一直循环处理请求 std::string buffer_queue; while (true) { std::string send_str; int n = sockptr->Recv(&buffer_queue); if (n > 0) { // 执行任务(回调处理:TcpServer不关心怎么处理任务) if(!_service(client, buffer_queue, &send_str)) { continue; } sockptr->Send(send_str); } else if(n == 0) { LOG(LogLevel::INFO) << client.StringAddress() << " Quit..."; break; } else { LOG(LogLevel::WARNING) << client.StringAddress() << " recv error"; break; } } } ~TcpServer() { } private: uint16_t _port; std::unique_ptr<Socket> _listen_sockptr; bool _isrunning; callback_t _service; // 回调参数 }; #endif核心设计解读
- 多进程服务模型:每接入一个客户端,就创建独立子进程专门处理该连接。该方案编码简单、稳定性强,单个客户端发生崩溃、异常,只会毁掉对应的子进程,不会干扰主服务与其他客户端。适合并发连接数量不高的业务场景。
- 子进程资源自动回收:注册信号
signal(SIGCHLD, SIG_IGN),交给操作系统内核完成子进程的善后清理工作,不用手动调用 wait/waitpid,从源头规避僵尸进程问题。 - 文件描述符管控:fork 之后父子进程会复制文件描述符,父子双方分别把自身不再使用的 fd 执行 close 关闭,杜绝文件描述符泄漏,防止后期出现无法新建连接的问题。
- 回调实现模块解耦:TcpServer 模块的职责仅限于网络层面的数据收发,完全不介入业务逻辑。依靠回调接口把收到的数据向上交付上层模块处理,做到网络通信层和上层协议、业务代码解耦。
2.5 协议层核心实现:Protocol.hpp
协议层是整个自定义协议的核心,负责解决序列化 / 反序列化和粘包 / 半包问题两大核心问题。
#ifndef PROTOCOL_HPP #define PROTOCOL_HPP #include "Common.hpp" #include "Socket.hpp" #include <jsoncpp/json/json.h> using namespace SocketModule; // 实现一个自定义的网络版本计算器 // 约定好各个字段的含义,本质就是约定好协议! // ===================== 请求报文:client -> server ===================== // client->server class Request { public: Request() { } Request(int x, int y, char oper) : _x(x), _y(y), _oper(oper) { } // 序列化:将结构化的请求对象,转换成Json字符串 std::string Serialize() { Json::Value root; root["x"] = _x; root["y"] = _y; root["oper"] = _oper; Json::FastWriter writer; std::string s = writer.write(root); return s; } // 反序列化 bool Deserialize(std::string &in) { Json::Value root; Json::Reader reader; bool ok = reader.parse(in, root); if (ok) { _x = root["x"].asInt(); _y = root["y"].asInt(); _oper = root["oper"].asInt(); } return ok; } int X() const { return _x; } int Y() const { return _y; } char Oper() const { return _oper; } ~Request() { } private: int _x; int _y; char _oper; // + - * / % ——> 要求:_x _oper _y -> 10 + 20 }; // ===================== 应答报文:server -> client ===================== // server->client class Responce { public: Responce() { } Responce(int result, int code) : _result(result), _code(code) { } // 序列化 std::string Serialize() { Json::Value root; root["result"] = _result; root["code"] = _code; Json::FastWriter writer; std::string s = writer.write(root); return s; } // 反序列化 bool Deserialize(std::string &in) { Json::Value root; Json::Reader reader; bool ok = reader.parse(in, root); if (ok) { _result = root["result"].asInt(); _code = root["code"].asInt(); } return ok; } void SetResult(int res) { _result = res; } void SetCode(int code) { _code = code; } void ShowResult() { std::cout << "计算结果是: " << _result << "[" << _code << "]" << std::endl; } ~Responce() { } private: int _result; // 运算结果,无法区分清楚应答的结果是正常计算结果,还是异常值 int _code; // 规定 0:success; 1/2/3/4.. : 不同的运算异常情况 }; const std::string sep = "\r\n"; // 分隔符 using func_t = std::function<Responce(const Request &)>; class Protocol { public: Protocol() { } Protocol(func_t func) : _func(func) { } std::string EnCode(const std::string &jsonstr) { // 50\r\n{"x": 10, "y": 20, "oper": '+'}\r\n // 1. 将报文长度转为字符串 std::string jsonstr_len = std::to_string(jsonstr.size()); // 2. 按照约定格式封装报文 return jsonstr_len + sep + jsonstr + sep; // 其实就可以理解为是在应用层封装报头 } bool DeCode(std::string &buffer, std::string *package) { // 1. 查找第一个分隔符,提取报文长度 ssize_t pos = buffer.find(sep); if (pos == std::string::npos) { // 说明根本没有找到分隔符\r\n,说明是上面的前两种情况,也就说明是没有读取到完整报文(其实都没有报文...) return false; // 让调用方继续从内核中读取数据直到是至少一个完整报文的情况 } // 2. 提取报文长度字符串,转为整型 std::string package_len_str = buffer.substr(0, pos); // 获取理想一个完整报头长度的字符串 int package_len = std::stoi(package_len_str); // 获取理想一个完整报头的长度 // 3. 计算完整报文的总长度 // 总长度 = 长度字符串长度 + 2个分隔符长度 + 业务报文长度 // 到这一定存在报文的内容,但是一定是一个完整的报文吗?? int target_len = package_len_str.size() + package_len + 2 * sep.size(); // 理想一个封装报头后完整报文的长度 // 4. 判断缓冲区中是否有完整的报文 if (buffer.size() < target_len) { // 这种情况就对应于上面的第四种情况,也就是存在报文但不是一个完整的报文 // 也就和上面没有找到分隔符一样,需要让调用方继续从内核中读取数据直到是至少一个完整报文的情况 return false; } // 5. 提取完整的业务报文 // 到这里就说明了一定是存在至少一个完整的报文内容了(buffer.size() >= target_len) // 所以接下来我们就需要将一个完整的报文取出来并且在buffer中进行剔除,用于后续再次使用 *package = buffer.substr(pos + sep.size(), package_len); // 所以这就是为什么函数第二个参数是指针,因为是输出型参数,我们需要将获取到的完整报文传出去 // 我们只需要报文的内容而不需要封装的报头,所以截取位置并不是从0开始,这其实也就可以理解为是在应用层进行解包 // 6. 从缓冲区中移除已经处理过的报文,保留剩余数据 // 获取到了完整报文,接下来就需要将完整报文从buffer剔除,保留多余部分进行后续使用 buffer.erase(0, target_len); // 我们要剔除的不只是一个完整报文,而是包含报头的完整报文 // 并且这也就是为什么我们函数第一个参数传的是引用,目的就是为了这个剔除操作能直接影响外部传入的数据 // 这样下次外部同样的字符串buffer_queue获取到新的报文,就能继续做判断 return true; } // 协议层只负责处理一次报文判断完整性以及对报文进行处理(序列化/反序列化) bool GetRequest(InetAddr &client, std::string &buffer_queue, std::string *send_str) { std::string json_package; // 1、解析报文,提取完整的json请求,判断如果报文不完整,就让服务器继续追加读取 if (!DeCode(buffer_queue, &json_package)) { // 要么没有找到分隔符要么读取长度不是完整报文长度 // 总之:读取到的不是一个完整的报文!继续读取 return false; } // 到这里就能说明:json_package一定获取到了一个完整的报文! // 2、请求json串,反序列化 Request req; req.Deserialize(json_package); // 3、到这我就一定得到了一个内部属性已经被设置了的req了 // 通过req->resp,不就是要完成计算功能了嘛 ——> 业务 Responce resp = _func(req); // 业务处理:回调 // 4、序列化 std::string json_str = resp.Serialize(); // 5、封装报头(添加自定义长度) *send_str = EnCode(json_str); // 携带长度的应答报文 return true; } // 协议层只负责处理一次报文判断完整性以及对报文进行处理(序列化/反序列化) bool GetResponse(std::string &resp_buffer, Responce *resp) { std::string json_package; // 1、解析报文 if (!DeCode(resp_buffer, &json_package)) { // 获取到的不是一个完整的应答报文,返回false继续在tcp中获取 return false; } // 到此就获得了一个去掉报头的完整应答报文 // 2、反序列化 resp->Deserialize(json_package); // resp作为输出型参数已经获取到了_result和_code return true; } std::string BuildRequestString(int x, int y, char oper) { // 1、构建一个完整的请求 Request req(x, y, oper); // 2、序列化 std::string json_req = req.Serialize(); // 封装报头 return EnCode(json_req); } ~Protocol() { } private: Request _req; Responce _resp; func_t _func; }; #endif核心设计解读
- 报文格式设计:采用「
长度\r\nJson字符串\r\n」的报文结构,这是工业级网络应用中最经典、最常用的报文格式之一。长度字段让接收方可以精准获知本次业务报文的真实大小,分隔符\r\n则用于区分长度字段和后续的业务数据,降低解析复杂度。 (补充:后续学习的 HTTP 协议的头部也大量使用\r\n作为行分隔符,这种设计在网络协议中非常普遍,调试时也方便肉眼识别报文边界)
- 粘包半包问题的彻底解决:
- 封包时前置长度头部,接收方据此知道每条报文的准确字节数;
- 解包时先提取长度值,再按照长度精准截取完整报文;
- 不完整的半包数据会暂存在接收缓冲区中,等待下一轮读取到更多字节后再继续解析;
- 多条粘连在一起的报文,会被循环逐条解析处理,不会遗漏任何一条业务数据。
- 错误处理与安全防护:对长度字段做合法性校验,一旦收到非法数据(比如长度值异常巨大、负数、超出缓冲区上限),直接关闭当前连接,防止恶意构造的报文造成缓冲区溢出或服务端资源耗尽,抵御基础的网络攻击。
- 回调解耦:协议层只负责报文的封包、解包、序列化与反序列化,完全不关心具体业务逻辑。解析出结构化请求后,通过回调函数把请求对象传递给业务层处理,实现协议层与业务层的彻底解耦。
GetRequest && GetResponse 设计注意事项
2.6 业务层:计算器实现(Calculator.hpp)
- 业务层只负责运算逻辑,与网络、协议完全解耦。
- 支持
+ - * / %五种运算。 - 错误处理:除 0、模 0、非法操作码,返回对应状态码。
#ifndef NETCALCULATOR_HPP #define NETCALCULATOR_HPP #include "protocol.hpp" // 业务逻辑层(Business Logic Layer): // 这是一个纯粹的计算组件,实现了高度的“模块解耦”。 // 它完全不感知底层的网络通信(TCP流)或数据的序列化方式(JSON格式),只负责根据标准化的 Request 对象运算并返回 Response。 class Cal { public: Responce Execute(const Request &req) { Responce resp(0, 0); switch (req.Oper()) { case '+': resp.SetResult(req.X() + req.Y()); break; case '-': resp.SetResult(req.X() - req.Y()); break; case '*': resp.SetResult(req.X() * req.Y()); break; case '/': if (req.Y() == 0) { resp.SetCode(1); // 1:除零错误 } else { resp.SetResult(req.X() / req.Y()); } break; case '%': if (req.Y() == 0) { resp.SetCode(2); // 2:余零错误 } else { resp.SetResult(req.X() % req.Y()); } break; default: resp.SetCode(3); // 3:非法操作 break; } return resp; } private: }; #endif核心设计解读:
- 纯业务逻辑:Calculator 类只包含计算逻辑,不关心网络、协议等任何底层细节。
- 错误码设计:通过
_exitcode字段返回错误信息,客户端可以根据错误码进行相应的处理。 - 日志记录:在关键节点添加日志,方便调试和问题定位。
三. 服务端与客户端完整实现
3.1 服务端实现:OnlineCalServer.cc
服务端将各个模块组合起来,通过回调函数将三层连接在一起。
#include "TcpServer.hpp" #include "protocol.hpp" #include "NetCalculator.hpp" // ./tcpserver port int main(int argc, char *argv[]) { if (argc != 2) { std::cerr << "Usage: " << argv[0] << " port" << std::endl; exit(USAGE_ERR); } // std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(std::stoi(argv[1])); // 1. 业务层 -> OSI 会话层 -> TCP/IP 应用层 // 最内层的“小鱼”:纯业务计算对象,没有任何网络属性,只知道如何根据 Request 算出 Response std::unique_ptr<Cal> calculator = std::make_unique<Cal>(); // 4. 协议层 —> OSI 表示层 -> TCP/IP 应用层 // 中间的“中鱼”:负责报文定界(解决粘包)和序列化/反序列化。 // 【依赖注入】:通过 Lambda 表达式 [&cal] 捕获业务层对象。 // 当 Protocol 层成功解析出一个 Request 对象后,会调用这个 Lambda,从而触发 cal->Exec() 进行计算。 std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>([&calculator] (const Request &req)->Responce { return calculator->Execute(req); }); // 5. 网络通信层 -> OSI 应用层 -> TCP/IP 应用层 (注:在OSI中TcpServer实际偏向会话层/传输层接口,但在TCP/IP中统归应用层) // 最外层的“大鱼”:负责底层的 Socket 建立、监听、多进程并发和字节流收发。 // 【依赖注入】:通过 Lambda 表达式 [&protocol] 捕获协议层对象。 // 当 TcpServer 从网络中 Recv 到一串字节流(streamstr)时,会调用这个 Lambda,将其全权交给 protocol 去做拆包和解析。 std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(std::stoi(argv[1]), [&protocol] (InetAddr &client, std::string &buffer_queue, std::string *send_str){ return protocol->GetRequest(client, buffer_queue, send_str); }); tsvr->Start(); return 0; }3.2 客户端实现:OnlineCalClient.cc
客户端实现与服务端对称,同样需要进行序列化、封包、解包、反序列化操作。
#include "TcpServer.hpp" #include "Socket.hpp" #include "protocol.hpp" void GetDataFromStdin(int *x, int *y, char *oper) { std::cout << "Please Enter x: "; std::cin >> *x; std::cout << "Please Enter y: "; std::cin >> *y; std::cout << "Please Enter oper: "; std::cin >> *oper; } bool _isrunning = true; // ./tcpserver ip port int main(int argc, char *argv[]) { if (argc != 3) { std::cerr << "Usage: " << argv[0] << " ip" << " port" << std::endl; exit(USAGE_ERR); } std::string ip = argv[1]; uint16_t port = std::stoi(argv[2]); // std::unique_ptr<Socket> client = std::make_unique<TcpSocket>(); std::shared_ptr<Socket> client = std::make_shared<TcpSocket>(); client->BuildTcpClientSocketMethod(); if (client->Connect(port, ip) != 0) { // connect失败 std::cout << "connect error" << std::endl; exit(CONNECT_ERR); } // 成功连接到服务端 // 创建协议,保证协议服务端、客户端都能看到 std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>(); // TCP处理读写IO操作 std::string req_buffer; while (true) { int x, y; char oper; // 1、输入请求 GetDataFromStdin(&x, &y, &oper); // 2、构建出序列化后的请求字符串 std::string req_str = protocol->BuildRequestString(x, y, oper); // 3、发送请求 client->Send(req_str); Responce resp; // 4、获取服务端发送的应答报文(未处理) while (true) { int n = client->Recv(&req_buffer); if (n > 0) { if (!protocol->GetResponse(req_buffer, &resp)) continue; break; } else if (n == 0) { // 服务端退出 std::cout << "Server quit..." << std::endl; _isrunning = false; break; } else { // 获取异常 std::cout << "recv error..." << std::endl; _isrunning = false; break; } } if (_isrunning == false) break; // 5、到这resp就已经得到结果了,显示出来 resp.ShowResult(); } client->Close(); return 0; }3.3 编译与运行
编写 Makefile 文件:
.PHONY:all all:tcpserver tcpclient tcpserver:TcpServer.cc g++ -o $@ $^ -std=c++17 -ljsoncpp tcpclient:TcpClient.cc g++ -o $@ $^ -std=c++17 -ljsoncpp .PHONY:clean clean: rm -rf tcpserver tcpclient结束语
本篇我们完成了一套三层解耦的 TCP 在线计算器服务完整实现,从底层 Socket 封装、基础工具组件,到协议层自定义报文设计,再到上层计算器业务逻辑,走完了一个小型工业级网络服务的完整开发流程。
我们不仅解决 TCP 粘包半包的工程难题,同时实践了模板方法模式、回调解耦、RAII 资源管理、多进程服务模型等关键技术点。网络编程不只是会调用 socket、connect、recv 等系统调用,更考验架构分层、异常处理、资源管控的工程思维。
这份代码可以作为学习 TCP 网络编程的参考范本,你也可以在此基础上继续拓展,比如把多 进程模型替换为多线程、增加更多运算能力、完善错误码体系,进一步打磨属于自己的网络服务项目。希望本篇内容能够帮你夯实网络编程基础,为后续学习高并发网络框架打下扎实根基。