基于 TCP 协议的 C++ 计算器项目实现:从网络通信到协议封装
在网络编程中,TCP 流式协议的粘包 / 拆包问题、模块化设计、日志调试是核心痛点。本文将通过一个完整的「TCP 计算器项目」,带你从 0 到 1 理解如何用 C++ 封装 Socket、设计自定义协议、实现日志系统,并完成客户端与服务端的通信逻辑。项目支持加减乘除取模运算,包含错误处理(如除零错误),且可灵活切换序列化方式(自定义格式 / JSON)。
一、项目整体架构
先看项目的文件结构与模块分工,清晰的模块化设计是代码可维护性的关键:
文件名 | 核心功能 | 所属模块 |
---|---|---|
Log.hpp | 多级别日志(Info/Debug/Warning 等)、多输出方式 | 日志模块 |
Socket.hpp | 封装 Socket API(创建 / 绑定 / 监听 / 连接 / 关闭) | 网络通信模块 |
Protocol.hpp | 自定义 TCP 协议(解决粘包)、Request/Response 序列化 | 协议与序列化模块 |
ServerCal.hpp | 计算器核心逻辑(运算处理、错误码定义) | 业务逻辑模块 |
TcpServer.hpp | 服务端框架(多进程处理客户端、信号处理) | 服务端框架 |
client.cpp | 客户端入口(生成随机请求、发送 / 接收数据) | 客户端 |
server.cpp | 服务端入口(初始化服务、绑定业务回调) | 服务端 |
二、核心模块详解
1. 日志模块:Log.hpp
—— 调试与问题排查的基石
日志是开发和线上问题排查的核心工具。本模块支持5 级日志级别和3 种输出方式,且通过全局单例lg
简化调用。
1.1 核心设计
日志级别:从低到高分为
Info
(普通信息)、Debug
(调试信息)、Warning
(警告)、Error
(错误)、Fatal
(致命错误,触发程序退出)。输出方式:
Screen
:输出到控制台(开发阶段用);Onefile
:所有日志写入单个文件(./log/log.txt
);Classfile
:按级别分文件(如log.txt.Debug
、log.txt.Error
)。
日志格式:
[级别][年-月-日 时:分:秒] 自定义信息
,例如:[Info][2024-05-20 15:30:00] init server .... done
。
1.2 关键代码片段
// 日志级别转字符串
std::string levelToString(int level) {switch (level) {case Info: return "Info";case Debug: return "Debug";case Warning: return "Warning";case Error: return "Error";case Fatal: return "Fatal";default: return "None";}
}// 核心日志输出(支持可变参数,类似printf)
void operator()(int level, const char *format, ...) {// 1. 格式化时间time_t t = time(nullptr);struct tm *ctime = localtime(&t);char time_buf[1024];snprintf(time_buf, sizeof(time_buf), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(),ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,ctime->tm_hour, ctime->tm_min, ctime->tm_sec);// 2. 格式化自定义参数(可变参数处理)va_list args;va_start(args, format);char content_buf[1024];vsnprintf(content_buf, sizeof(content_buf), format, args);va_end(args);// 3. 拼接日志并输出char log_buf[2048];snprintf(log_buf, sizeof(log_buf), "%s %s\n", time_buf, content_buf);printLog(level, log_buf); // 根据输出方式分发
}
1.3 使用示例
// 输出Info级日志(服务端初始化完成)
lg(Info, "init server .... done");
// 输出Debug级日志(打印接收的数据包)
lg(Debug, "debug:\n%s", inbuffer_stream.c_str());
// 输出Fatal级日志(Socket创建失败,触发退出)
lg(Fatal, "socker error, %s: %d", strerror(errno), errno);
2. 网络通信模块:Socket.hpp
—— 简化 Socket API 调用
原始的 Socket API(socket
/bind
/listen
等)参数繁琐且错误处理重复,本模块将其封装为Sock
类,统一错误处理(结合日志),降低调用成本。
2.1 核心方法解析
方法名 | 功能描述 | 关键细节 |
---|---|---|
Socket() | 创建 TCP Socket 文件描述符 | 失败时输出 Fatal 日志并退出 |
Bind() | 绑定端口(服务端用) | 支持INADDR_ANY (绑定所有网卡 IP) |
Listen() | 监听端口(服务端用) | _backlog_设为 10(等待连接队列长度) |
Accept() | 接收客户端连接(服务端用) | 返回新的通信 Socket,获取客户端 IP 和端口 |
Connect() | 连接服务端(客户端用) | 失败时返回false ,方便客户端重试 |
Fd() | 获取 Socket 文件描述符 | 供读写操作(read /write )使用 |
Close() | 关闭 Socket | 释放资源,避免文件描述符泄漏 |
2.2 服务端绑定端口示例
Sock listensock;listensock.Socket(); // 创建Socketlistensock.Bind(8080); // 绑定8080端口listensock.Listen(); // 开始监听
2.3 客户端连接服务端示例
Sock sockfd;
sockfd.Socket();
// 连接服务端(IP:127.0.0.1,端口:8080)
if (!sockfd.Connect("127.0.0.1", 8080)) {std::cerr << "连接服务端失败" << std::endl;return 1;
}
3. 协议与序列化模块:Protocol.hpp
—— 解决 TCP 粘包问题
TCP 是流式协议,发送方多次发送的数据可能被合并,接收方一次读取可能包含多个数据包(粘包),或一次只读取部分数据包(拆包)。本模块通过「自定义协议」和「序列化」解决该问题。
3.1 自定义协议设计
核心思路:长度前缀 + 分隔符,确保接收方能准确拆分数据包。
编码(Encode):将原始内容(如
"10+20"
)包装为长度\n内容\n
的格式。示例:原始内容
"10+20"
(长度 5)→ 编码后"5\n10+20\n"
。解码(Decode):先读取长度,再按长度读取对应内容,最后移除已处理的数据包。
3.2 协议核心代码
// 编码:内容 → 长度\n内容\n
std::string Encode(std::string &content) {std::string package = std::to_string(content.size()); // 长度package += "\n"; // 分隔符package += content; // 原始内容package += "\n"; // 分隔符return package;
}// 解码:长度\n内容\n → 内容(成功返回true)
bool Decode(std::string &package, std::string *content) {// 1. 找到第一个\n(分隔长度和内容)std::size_t pos = package.find("\n");if (pos == std::string::npos) return false; // 未找到分隔符,数据包不完整// 2. 解析长度std::string len_str = package.substr(0, pos);std::size_t len = std::stoi(len_str); // 内容长度// 3. 检查数据包是否完整(总长度 = 长度字符串长度 + 内容长度 + 2个\n)std::size_t total_len = len_str.size() + len + 2;if (package.size() < total_len) return false; // 数据包不完整,等待后续数据// 4. 提取内容并移除已处理的数据包*content = package.substr(pos + 1, len);package.erase(0, total_len); // 关键:删除已解码的部分,避免重复处理return true;
}
3.3 Request 与 Response 序列化
Request
(客户端请求)和Response
(服务端响应)是业务数据载体,支持两种序列化方式(通过#define MySelf
切换):
自定义格式:简单字符串拼接(如
Request
为"10 + 20"
,Response
为"30 0"
);JSON 格式:基于
JsonCpp
库,可读性更强,适合复杂数据结构。
3.3.1 Request 类(客户端请求)
class Request {
public:int x; // 第一个操作数int y; // 第二个操作数char op; // 运算符(+/-/*///%)// 序列化:Request → 字符串(自定义格式)bool Serialize(std::string *out) {
#ifdef MySelf*out = std::to_string(x) + " " + op + " " + std::to_string(y);// 示例:x=10, y=20, op='+' → "10 + 20"
#else// JSON序列化(需要链接JsonCpp库)Json::Value root;root["x"] = x;root["y"] = y;root["op"] = op;Json::StyledWriter writer;*out = writer.write(root);
#endifreturn true;}// 反序列化:字符串 → Requestbool Deserialize(const std::string &in) {
#ifdef MySelf// 解析"10 + 20" → x=10, op='+', y=20std::size_t left = in.find(" ");std::size_t right = in.rfind(" ");if (left == std::string::npos || right == std::string::npos) return false;x = std::stoi(in.substr(0, left));op = in[left + 1];y = std::stoi(in.substr(right + 1));
#else// JSON反序列化Json::Value root;Json::Reader reader;if (!reader.parse(in, root)) return false;x = root["x"].asInt();y = root["y"].asInt();op = root["op"].asInt();
#endifreturn true;}
};
3.3.2 Response 类(服务端响应)
class Response {
public:int result; // 运算结果(正确时有效)int code; // 错误码(0=成功,1=除零,2=模零,3=非法运算符)// 序列化/反序列化逻辑与Request类似,此处省略...
};
4. 业务逻辑模块:ServerCal.hpp
—— 计算器核心
该模块封装运算逻辑和错误处理,与网络框架解耦,便于后续扩展(如增加浮点数运算)。
4.1 错误码定义
enum {Div_Zero = 1, // 除零错误Mod_Zero, // 模零错误Other_Oper // 非法运算符
};
4.2 运算核心方法
// 运算逻辑(根据Request计算,返回Response)
Response CalculatorHelper(const Request &req) {Response resp(0, 0); // 初始:result=0,code=0(成功)switch (req.op) {case '+': resp.result = req.x + req.y; break;case '-': resp.result = req.x - req.y; break;case '*': resp.result = req.x * req.y; break;case '/':if (req.y == 0) resp.code = Div_Zero; // 除零错误else resp.result = req.x / req.y;break;case '%':if (req.y == 0) resp.code = Mod_Zero; // 模零错误else resp.result = req.x % req.y;break;default: resp.code = Other_Oper; // 非法运算符}return resp;
}// 对接协议:解码→反序列化→计算→序列化→编码
std::string Calculator(std::string &package) {std::string content;// 1. 解码(解决粘包)if (!Decode(package, &content)) return "";// 2. 反序列化(字符串→Request)Request req;if (!req.Deserialize(content)) return "";// 3. 计算(核心业务逻辑)Response resp = CalculatorHelper(req);// 4. 序列化(Response→字符串)resp.Serialize(&content);// 5. 编码(准备发送)return Encode(content);
}
5. 服务端框架:TcpServer.hpp
—— 多进程处理客户端
服务端需要同时处理多个客户端连接,本项目采用「多进程模型」:父进程监听端口,每接收一个客户端连接就fork
一个子进程处理,父进程继续监听。
5.1 核心设计
信号处理:
SIGCHLD
:忽略该信号,避免子进程成为僵尸进程;SIGPIPE
:忽略该信号,避免客户端断开后服务端写操作崩溃。
回调函数:通过
std::function
将业务逻辑(如Calculator
)与网络框架解耦,便于替换业务(如改为 echo 服务)。
5.2 服务端启动核心代码
void Start() {// 信号处理signal(SIGCHLD, SIG_IGN);signal(SIGPIPE, SIG_IGN);while (true) {// 接收客户端连接std::string clientip;uint16_t clientport;int sockfd = listensock_.Accept(&clientip, &clientport);if (sockfd < 0) continue;lg(Info, "新连接:sockfd=%d, IP=%s, 端口=%d", sockfd, clientip.c_str(), clientport);// fork子进程处理客户端if (fork() == 0) {listensock_.Close(); // 子进程不需要监听Socket,关闭节省资源std::string inbuffer_stream; // 缓存接收的数据(解决拆包)while (true) {char buffer[1280];ssize_t n = read(sockfd, buffer, sizeof(buffer));if (n <= 0) break; // 客户端断开或读取错误buffer[n] = 0;inbuffer_stream += buffer; // 追加到缓存// 循环解码(处理粘包:可能包含多个数据包)while (true) {std::string resp = callback_(inbuffer_stream); // 调用业务回调(如Calculator)if (resp.empty()) break; // 数据包不完整,等待后续数据write(sockfd, resp.c_str(), resp.size()); // 发送响应}}close(sockfd);exit(0); // 子进程处理完连接后退出}close(sockfd); // 父进程关闭通信Socket(子进程已复制该fd)}
}
三、客户端与服务端入口实现
1. 服务端入口:server.cpp
#include "TcpServer.hpp"
#include "ServerCal.hpp"
#include <unistd.h>// 打印用法
static void Usage(const std::string &proc) {std::cout << "Usage: " << proc << " port" << std::endl;
}int main(int argc, char *argv[]) {if (argc != 2) {Usage(argv[0]);exit(0);}uint16_t port = std::stoi(argv[1]);ServerCal cal; // 业务逻辑实例// 创建TcpServer,绑定Calculator回调TcpServer server(port, std::bind(&ServerCal::Calculator, &cal, std::placeholders::_1));server.InitServer(); // 初始化服务(创建Socket、绑定、监听)daemon(0, 0); // 后台运行(守护进程)server.Start(); // 启动服务,开始处理客户端return 0;
}
2. 客户端入口:client.cpp
客户端生成 10 次随机请求(操作数 1-100,运算符随机选+-*/%=-=&^
),发送给服务端并打印响应。
#include <iostream>
#include <string>
#include <ctime>
#include <cassert>
#include <unistd.h>
#include "Socket.hpp"
#include "Protocol.hpp"static void Usage(const std::string &proc) {std::cout << "Usage: " << proc << " serverip serverport" << std::endl;
}int main(int argc, char *argv[]) {if (argc != 3) {Usage(argv[0]);exit(0);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);// 连接服务端Sock sockfd;sockfd.Socket();if (!sockfd.Connect(serverip, serverport)) return 1;// 初始化随机数(结合PID避免多客户端重复)srand(time(nullptr) ^ getpid());const std::string opers = "+-*/%=-=&^"; // 支持的运算符std::string inbuffer_stream; // 接收缓存// 发送10次请求for (int cnt = 1; cnt <= 10; cnt++) {std::cout << "===============第" << cnt << "次测试===============" << std::endl;// 生成随机请求int x = rand() % 100 + 1; // 1-100usleep(1234); // 避免随机数重复int y = rand() % 100; // 0-99(故意包含0,测试除零错误)usleep(4321);char oper = opers[rand() % opers.size()];Request req(x, y, oper);req.DebugPrint(); // 打印请求(如“新请求构建完成: 10+20=?”)// 序列化→编码→发送std::string package;req.Serialize(&package);package = Encode(package);write(sockfd.Fd(), package.c_str(), package.size());// 接收响应→解码→反序列化→打印char buffer[128];ssize_t n = read(sockfd.Fd(), buffer, sizeof(buffer));if (n > 0) {buffer[n] = 0;inbuffer_stream += buffer;std::string content;assert(Decode(inbuffer_stream, &content)); // 解码Response resp;assert(resp.Deserialize(content)); // 反序列化resp.DebugPrint(); // 打印响应(如“结果响应完成, result: 30, code: 0”)}std::cout << "=================================================" << std::endl;sleep(1); // 间隔1秒}sockfd.Close();return 0;
}
四、项目编译与运行
1. 依赖安装
项目使用JsonCpp
库(JSON 序列化),Ubuntu 下安装:
sudo apt-get install libjsoncpp-dev
2. 编译命令
# 编译服务端g++ server.cpp -o server -ljsoncpp# 编译客户端g++ client.cpp -o client -ljsoncpp
3. 运行步骤
- 启动服务端(端口 8080):
./server 8080
- 启动客户端(连接本地服务端):
./client 127.0.0.1 8080
4. 运行效果示例
五、项目优化方向
用线程池替代多进程:多进程内存开销大,线程池可减少资源消耗(推荐用
pthread
或 C++11std::thread
);Protobuf 替代 JSON:JSON 可读性强但效率低,Protobuf 是二进制格式,序列化 / 反序列化速度更快;
增加配置文件:将端口、日志级别、日志路径等参数写入配置文件(如
ini
/yaml
),避免硬编码;增加监控告警:统计客户端连接数、请求成功率、错误率,异常时触发告警(如邮件 / 短信);
支持浮点数运算:当前仅支持整数,可扩展
Request
为double x
/double y
,处理浮点数运算。
六、总结
本项目通过模块化设计,将网络通信、协议封装、业务逻辑、日志系统解耦,解决了 TCP 粘包、错误处理、多客户端并发等核心问题。无论是学习 C++ 网络编程,还是理解分布式系统中的协议设计,都具有参考价值。
如果你有任何疑问或优化建议,欢迎在评论区交流!