在网络编程中,我们经常需要把内存中的结构体、类对象、整数、字符串 等数据通过 socket 发送给另一端。但是 socket 只认识字节流 。怎样把 {x=10, y=20, oper='+'} 这样一个请求对象变成字节流发出去?接收方又怎样从字节流中还原出原来的对象?这两个过程就是 序列化(Serialize) 和 反序列化(Deserialize)。
所有代码均为 C++17,依赖
jsoncpp库。编译命令见 Makefile。
一、 项目整体概览
这是一个典型的 C/S 架构 应用:
-
服务器 :监听端口,接收客户端发来的运算请求(如
10 + 20),计算后返回结果。 -
客户端:从标准输入读取运算表达式,发送给服务器,并打印结果。
| 文件名 | 类型 | 核心职责 |
|---|---|---|
Common.hpp |
头文件 | 全局常量、枚举、工具宏、禁止拷贝基类 |
InetAddr.hpp |
头文件 | 网络地址 ↔ 主机地址 的双向转换封装 |
Mutex.hpp |
头文件 | 互斥锁 的 RAII 封装 |
Log.hpp |
头文件 | 策略模式 日志系统(控制台/文件) |
Socket.hpp |
头文件 | 模板方法模式 Socket 抽象层 + TCP 实现 |
Protocol.hpp |
头文件 | 序列化/反序列化 + 自定义协议编解码 + 粘包处理 |
Cal.hpp |
头文件 | 业务层:计算器逻辑 + 错误码体系 |
TcpServer.hpp |
头文件 | 服务器层:TCP 监听 + 多进程并发模型 |
main.cc |
源文件 | 服务端入口:三层组装(业务→协议→服务器) |
TcpClient.cc |
源文件 | 客户端入口:用户交互 + 请求发送 + 响应接收 |
Makefile |
构建文件 | 编译规则 |

二、Common.hpp:地基文件
2.1 文件存在的意义
这是整个项目的**"基础设施层"** 。所有其他文件都 #include "Common.hpp",因为它提供了:
-
统一的错误码枚举
-
通用的类型转换宏
-
禁止拷贝的基类(防止资源管理类被误拷贝)
2.2 代码解析(错误码)
enum ExitCode {
OK = 0, // 正常退出
USAGE_ERR, // 命令行参数错误
SOCKET_ERR, // socket() 系统调用失败
BIND_ERR, // bind() 系统调用失败
LISTEN_ERR, // listen() 系统调用失败
CONNECT_ERR, // connect() 系统调用失败
FORK_ERR, // fork() 系统调用失败
OPEN_ERR // 文件打开失败
};
为什么需要这个枚举?
-
让
exit()有语义化 的参数,而不是exit(1)、exit(2)这种魔法数字 -
方便调试时快速定位是哪一步系统调用出错
2.3 禁止拷贝/赋值
class NoCopy {
public:
NoCopy() {}
~NoCopy() {}
NoCopy(const NoCopy &) = delete; // 禁止拷贝构造
const NoCopy &operator=(const NoCopy&) = delete; // 禁止拷贝赋值
};
存在价值 :TcpSocket 持有文件描述符 _sockfd,如果允许拷贝,两个对象会指向同一个 fd,析构时重复 close() 导致错误。继承 NoCopy 就从编译期杜绝这个问题。
2.4 宏封装
#define CONV(addr) ((struct sockaddr*)&addr)
存在价值 :bind()、accept()、connect() 等系统函数要求 struct sockaddr* 参数,但代码中使用的是 struct sockaddr_in。每次强转很麻烦,用宏封装后代码更简洁。
#pragma once
#include <iostream>
#include <functional>
#include <unistd.h>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
enum ExitCode
{
OK = 0,
USAGE_ERR,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR,
CONNECT_ERR,
FORK_ERR,
OPEN_ERR
};
class NoCopy
{
public:
NoCopy(){}
~NoCopy(){}
NoCopy(const NoCopy &) = delete;
const NoCopy &operator = (const NoCopy&) = delete;
};
#define CONV(addr) ((struct sockaddr*)&addr)
三、InetAddr.hpp:地址转换专家
3.1 文件存在的意义
网络编程中,IP 地址和端口有两种表示形式:
-
主机字节序 :人类可读(如
192.168.1.1:8080) -
网络字节序 :大端整数(如
0xC0A80101:0x1F90)
这个类负责双向无缝转换,让上层代码完全不用关心大小端问题。
3.2 三个构造函数详解
// 构造函数1:默认构造
InetAddr() {}
场景 :先定义一个空对象,后续用 SetAddr() 填充。比如 Accept() 时先定义 InetAddr client,再传入 &client 让系统调用填充。
// 构造函数2:从已有的 sockaddr_in 构造(网络→主机)
InetAddr(struct sockaddr_in &addr) { SetAddr(addr); }
场景 :accept() 返回的是 struct sockaddr_in,需要转成人类可读的 IP:Port 用于日志打印。
// 构造函数3:从 IP + Port 构造(主机→网络)
InetAddr(const std::string &ip, uint16_t port) : _ip(ip), _port(port) {
memset(&_addr, 0, sizeof(_addr));
_addr.sin_family = AF_INET;
inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr); // 点分十进制 → 网络整数
_addr.sin_port = htons(_port); // 主机端口 → 网络端口
}
场景 :客户端连接服务器时,用户输入的是 "127.0.0.1" 和 8080,需要转成 sockaddr_in 给 connect() 使用。
核心函数对比 :
| 函数 | 方向 | 用途 |
|---|---|---|
inet_pton() |
主机 → 网络 | 字符串 IP 转整数 |
inet_ntop() |
网络 → 主机 | 整数 IP 转字符串 |
htons() |
主机 → 网络 | 端口转大端 |
ntohs() |
网络 → 主机 | 端口转小端 |
void SetAddr(struct sockaddr_in &addr) {
_addr = addr;
_port = ntohs(_addr.sin_port); // 网络端口 → 主机端口
char ipbuffer[64];
inet_ntop(AF_INET, &_addr.sin_addr, ipbuffer, sizeof(ipbuffer)); // 网络IP → 字符串
_ip = ipbuffer;
}
存在价值 :Accept() 后调用,把系统填充的 sockaddr_in 转成可打印的 _ip 和 _port。
const struct sockaddr *NetAddrPtr() { return CONV(_addr); }
socklen_t NetAddrLen() { return sizeof(_addr); }
存在价值 :这两个函数是胶水代码 ,让 InetAddr 对象能无缝传给 bind()、connect()、accept() 等系统调用。
#pragma once
#include "Common.hpp"
// 网络地址和主机地址之间进行转换的类
class InetAddr
{
public:
InetAddr() {}
InetAddr(struct sockaddr_in &addr)
{
SetAddr(addr);
}
InetAddr(const std::string &ip, uint16_t port) : _ip(ip), _port(port)
{
// 主机转网络
memset(&_addr, 0, sizeof(_addr));
_addr.sin_family = AF_INET;
inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);
_addr.sin_port = htons(_port);
// local.sin_addr.s_addr = inet_addr(_ip.c_str()); // TODO
}
InetAddr(uint16_t port) : _port(port), _ip()
{
// 主机转网络
memset(&_addr, 0, sizeof(_addr));
_addr.sin_family = AF_INET;
_addr.sin_addr.s_addr = INADDR_ANY;
_addr.sin_port = htons(_port);
}
void SetAddr(struct sockaddr_in &addr)
{
_addr = addr;
// 网络转主机
_port = ntohs(_addr.sin_port); // 从网络中拿到的!网络序列
// _ip = inet_ntoa(_addr.sin_addr); // 4字节网络风格的IP -> 点分十进制的字符串风格的IP
char ipbuffer[64];
inet_ntop(AF_INET, &_addr.sin_addr, ipbuffer, sizeof(_addr));
_ip = ipbuffer;
}
uint16_t Port() { return _port; }
std::string Ip() { return _ip; }
const struct sockaddr_in &NetAddr() { return _addr; }
const struct sockaddr *NetAddrPtr()
{
return CONV(_addr);
}
socklen_t NetAddrLen()
{
return sizeof(_addr);
}
bool operator==(const InetAddr &addr)
{
return addr._ip == _ip && addr._port == _port;
}
std::string StringAddr()
{
return _ip + ":" + std::to_string(_port);
}
~InetAddr()
{
}
private:
struct sockaddr_in _addr;
std::string _ip;
uint16_t _port;
};
四、Mutex.hpp:线程安全的 RAII 封装
4.1 文件存在的意义
日志系统需要线程安全 (多个客户端进程可能同时写日志),但裸 pthread_mutex_t 容易忘了解锁导致死锁。RAII 封装让锁的生命周期与对象绑定。
4.2 逐类解析
class Mutex {
pthread_mutex_t _mutex;
public:
Mutex() { pthread_mutex_init(&_mutex, nullptr); }
void Lock() { pthread_mutex_lock(&_mutex); }
void Unlock() { pthread_mutex_unlock(&_mutex); }
~Mutex() { pthread_mutex_destroy(&_mutex); }
pthread_mutex_t *Get() { return &_mutex; }
};
存在价值 :封装了 pthread_mutex_t 的完整生命周期(创建→加锁→解锁→销毁),避免资源泄漏。
class LockGuard {
Mutex &_mutex;
public:
LockGuard(Mutex &mutex) : _mutex(mutex) { _mutex.Lock(); }
~LockGuard() { _mutex.Unlock(); }
};
这是 RAII 的经典应用:
-
构造时加锁
-
析构时解锁(无论是否发生异常!)
使用场景:
void SyncLog(const std::string &message) {
LockGuard lockguard(_mutex); // 构造 = 加锁
std::cout << message; // 临界区操作
} // 析构 = 解锁(自动!)
#pragma once
#include <iostream>
#include <pthread.h>
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
~Mutex()
{
pthread_mutex_destroy(&_mutex);
}
pthread_mutex_t *Get()
{
return &_mutex;
}
private:
pthread_mutex_t _mutex;
};
class LockGuard
{
public:
LockGuard(Mutex &mutex) : _mutex(mutex)
{
_mutex.Lock();
};
~LockGuard()
{
_mutex.Unlock();
};
private:
Mutex &_mutex;
};
}
五、Log.hpp:策略模式日志系统
5.1 文件存在的意义
一个优秀的网络服务必须可观测。日志系统需要:
-
支持多种输出目标(控制台、文件、甚至未来可能的数据库)
-
线程安全:多进程/多线程并发写日志不能乱序
-
使用简单:一行宏就能记录时间、级别、文件、行号
5.2 策略模式架构
// 抽象策略
class LogStrategy {
virtual void SyncLog(const std::string &message) = 0;
};
// 具体策略1:控制台
class ConsoleLogStrategy : public LogStrategy {
void SyncLog(const std::string &message) override {
LockGuard lockguard(_mutex);
std::cout << message << gsep;
}
};
// 具体策略2:文件
class FileLogStrategy : public LogStrategy {
void SyncLog(const std::string &message) override {
LockGuard lockguard(_mutex);
std::ofstream out(filename, std::ios::app); // append 模式
out << message << gsep;
}
};
策略模式的好处:
-
运行时切换策略:
logger.EnableFileLogStrategy() -
新增策略无需修改现有代码(如未来加
DatabaseLogStrategy)
5.3 流式日志接口(最精妙的设计)
#define LOG(level) logger(level, __FILE__, __LINE__)
这个宏是"语法糖"的核心:
__FILE__:当前文件名(编译器内置宏)
__LINE__:当前行号(编译器内置宏)
// 使用示例
LOG(LogLevel::DEBUG) << "收到请求: " << json_package;
背后发生了什么?
-
宏展开为:
logger(LogLevel::DEBUG, "main.cc", 42) -
logger()返回一个LogMessage临时对象 -
<< "收到请求"调用LogMessage::operator<<,拼接字符串 -
语句结束,
LogMessage析构 ,自动调用SyncLog()输出class LogMessage {
public:
LogMessage(LogLevel &level, std::string &src_name, int line_number, Logger &logger)
: _curr_time(GetTimeStamp()),
_level(level),
_pid(getpid()),
_src_name(src_name),
_line_number(line_number),
_logger(logger)
{
// 构造时拼接"日志头部":[时间] [级别] [PID] [文件] [行号] -
std::stringstream ss;
ss << "[" << _curr_time << "] "
<< "[" << Level2Str(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _src_name << "] "
<< "[" << _line_number << "] "
<< "- ";
_loginfo = ss.str();
}template <typename T> LogMessage &operator<<(const T &info) { std::stringstream ss; ss << info; _loginfo += ss.str(); // 拼接"日志内容" return *this; // 链式调用 } ~LogMessage() { if (_logger._fflush_strategy) { _logger._fflush_strategy->SyncLog(_loginfo); // 析构时刷新! } }};
为什么设计成"析构时刷新"?
-
保证一行日志是原子输出的,不会被其他线程插进来
-
支持链式
<<:LOG(INFO) << "a" << "b" << 123;最终是一整条日志#ifndef LOG_HPP
#define LOG_HPP#include <iostream>
#include <cstdio>
#include <string>
#include <filesystem> //C++17
#include <sstream>
#include <fstream>
#include <memory>
#include <ctime>
#include <unistd.h>
#include "Mutex.hpp"namespace LogModule
{
using namespace MutexModule;const std::string gsep = "\r\n"; // 策略模式,C++多态特性 // 2. 刷新策略 a: 显示器打印 b:向指定的文件写入 // 刷新策略基类 class LogStrategy { public: ~LogStrategy() = default; virtual void SyncLog(const std::string &message) = 0; }; // 显示器打印日志的策略 : 子类 class ConsoleLogStrategy : public LogStrategy { public: ConsoleLogStrategy() { } void SyncLog(const std::string &message) override { LockGuard lockguard(_mutex); std::cout << message << gsep; } ~ConsoleLogStrategy() { } private: Mutex _mutex; }; // 文件打印日志的策略 : 子类 const std::string defaultpath = "/var/log/"; const std::string defaultfile = "my.log"; class FileLogStrategy : public LogStrategy { public: FileLogStrategy(const std::string &path = defaultpath, const std::string &file = defaultfile) : _path(path), _file(file) { LockGuard lockguard(_mutex); if (std::filesystem::exists(_path)) { return; } try { std::filesystem::create_directories(_path); } catch (const std::filesystem::filesystem_error &e) { std::cerr << e.what() << '\n'; } } void SyncLog(const std::string &message) override { LockGuard lockguard(_mutex); std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file; // "./log/" + "my.log" std::ofstream out(filename, std::ios::app); // 追加写入的 方式打开 if (!out.is_open()) { return; } out << message << gsep; out.close(); } ~FileLogStrategy() { } private: std::string _path; // 日志文件所在路径 std::string _file; // 日志文件本身 Mutex _mutex; }; // 形成一条完整的日志&&根据上面的策略,选择不同的刷新方式 // 1. 形成日志等级 enum class LogLevel { DEBUG, INFO, WARNING, ERROR, FATAL }; std::string Level2Str(LogLevel level) { switch (level) { case LogLevel::DEBUG: return "DEBUG"; case LogLevel::INFO: return "INFO"; case LogLevel::WARNING: return "WARNING"; case LogLevel::ERROR: return "ERROR"; case LogLevel::FATAL: return "FATAL"; default: return "UNKNOWN"; } } std::string GetTimeStamp() { time_t curr = time(nullptr); struct tm curr_tm; localtime_r(&curr, &curr_tm); char timebuffer[128]; snprintf(timebuffer, sizeof(timebuffer),"%4d-%02d-%02d %02d:%02d:%02d", curr_tm.tm_year+1900, curr_tm.tm_mon+1, curr_tm.tm_mday, curr_tm.tm_hour, curr_tm.tm_min, curr_tm.tm_sec ); return timebuffer; } // 1. 形成日志 && 2. 根据不同的策略,完成刷新 class Logger { public: Logger() { EnableConsoleLogStrategy(); } void EnableFileLogStrategy() { _fflush_strategy = std::make_unique<FileLogStrategy>(); } void EnableConsoleLogStrategy() { _fflush_strategy = std::make_unique<ConsoleLogStrategy>(); } // 表示的是未来的一条日志 class LogMessage { public: LogMessage(LogLevel &level, std::string &src_name, int line_number, Logger &logger) : _curr_time(GetTimeStamp()), _level(level), _pid(getpid()), _src_name(src_name), _line_number(line_number), _logger(logger) { // 日志的左边部分,合并起来 std::stringstream ss; ss << "[" << _curr_time << "] " << "[" << Level2Str(_level) << "] " << "[" << _pid << "] " << "[" << _src_name << "] " << "[" << _line_number << "] " << "- "; _loginfo = ss.str(); } // LogMessage() << "hell world" << "XXXX" << 3.14 << 1234 template <typename T> LogMessage &operator<<(const T &info) { // a = b = c =d; // 日志的右半部分,可变的 std::stringstream ss; ss << info; _loginfo += ss.str(); return *this; } ~LogMessage() { if (_logger._fflush_strategy) { _logger._fflush_strategy->SyncLog(_loginfo); } } private: std::string _curr_time; LogLevel _level; pid_t _pid; std::string _src_name; int _line_number; std::string _loginfo; // 合并之后,一条完整的信息 Logger &_logger; }; // 这里故意写成返回临时对象 LogMessage operator()(LogLevel level, std::string name, int line) { return LogMessage(level, name, line, *this); } ~Logger() { } private: std::unique_ptr<LogStrategy> _fflush_strategy; }; // 全局日志对象 Logger logger; // 使用宏,简化用户操作,获取文件名和行号 #define LOG(level) logger(level, __FILE__, __LINE__) #define Enable_Console_Log_Strategy() logger.EnableConsoleLogStrategy() #define Enable_File_Log_Strategy() logger.EnableFileLogStrategy()}
#endif