在网络编程中,TCP协议因其可靠性和稳定性被广泛应用于各类网络服务。本项目使用C++语言,基于Linux平台实现了一个完整的TCP服务器与客户端通信程序,服务器端采用了线程池技术实现高效并发处理,支持守护进程运行,并实现了完整的日志系统。
本项目的目标是:
想象一下,当你给朋友发送一条短信时,这条信息是如何从你的手机传递到朋友的手机的?这个过程涉及:
计算机网络通信也遵循类似的原理,只是更加复杂和规范化。TCP/IP协议就像是计算机之间沟通的"语言规则",确保信息能够正确传递。
套接字可以理解为网络通信的"插座",就像家里的电源插座连接电器一样,套接字连接网络中的应用程序。
应用程序 <---> 套接字 <---> 网络 <---> 套接字 <---> 应用程序
在我们的项目中:
// 创建套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
这行代码就像是安装了一个"网络插座",其中:
AF_INET
表示使用IPv4地址SOCK_STREAM
表示使用TCP协议0
表示使用默认协议想象一下,如果你的手机应用必须保持前台运行才能接收消息,那将是多么不便!守护进程就像是手机的"后台应用",即使你关闭了终端窗口,它仍然在默默工作。
守护进程的创建过程可以类比为一个员工的"独立":
// 创建守护进程的关键步骤
if (fork() > 0) exit(0); // 父进程退出
pid_t n = setsid(); // 创建新会话
想象一家餐厅:
线程池就是这样的"服务员团队":
// 线程池的核心:等待并处理任务
while (true) {
T t;
{
LockGuard lockguard(td->threadpool->mutex());
while (td->threadpool->isQueueEmpty()) {
td->threadpool->threadWait(); // 等待新任务
}
t = td->threadpool->pop(); // 获取任务
}
t(); // 执行任务
}
RAII就像是一个自动化的"资源管家"。想象你去图书馆:
RAII确保:
// RAII的典型应用:自动管理锁
{
LockGuard lockguard(&_mutex); // 构造时自动加锁
_task_queue.push(in);
pthread_cond_signal(&_cond);
} // 离开作用域时自动解锁
+-------------+
| tcpServer.cc|
+------+------+
|
v
+----------+ +------+-------+ +-----------+
| daemon.hpp|<-----| tcpServer.hpp|----->| Task.hpp |
+----------+ +------+-------+ +-----+-----+
| |
v v
+------+-------+ +------+------+
|ThreadPool.hpp|<----|serviceIO() |
+------+-------+ +-------------+
|
v
+------+-------+
| Thread.hpp |
+------+-------+
|
v
+------+-------+
| LockGuard.hpp|
+-------------+
想象一个餐厅的运作流程:
餐厅开业(服务器启动):
客人到来(客户端连接):
服务过程(数据交换):
就餐结束(连接关闭):
tcpServer.hpp
、tcpServer.cc
)TCP服务器就像是一个电话总机,负责接听来电并将其转接给合适的接线员。其主要职责是:
void initServer() {
// 1. 创建通信渠道
_listensock = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定地址和端口(公布联系方式)
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
bind(_listensock, (struct sockaddr *)&local, sizeof(local));
// 3. 开始监听(等待来电)
listen(_listensock, gbacklog);
}
void start() {
// 4. 准备接线员团队(初始化线程池)
ThreadPool<Task>::getInstance()->run();
for (;;) {
// 5. 接听来电
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(_listensock, (struct sockaddr *)&peer, &len);
// 6. 转接给接线员(提交任务到线程池)
ThreadPool<Task>::getInstance()->push(Task(sock, serviceIO));
}
}
错误处理的重要性:网络编程中,各种意外情况都可能发生(端口被占用、连接突然断开等)。良好的错误处理能让程序更加健壮。
为什么使用INADDR_ANY:使用INADDR_ANY
(值为0.0.0.0)允许服务器监听所有网络接口,无论客户端从哪个网卡连接都能接受。
backlog参数的意义:listen(_listensock, gbacklog)
中的gbacklog
表示等待连接队列的最大长度。当连接请求过多时,超过这个值的连接会被拒绝。
ThreadPool.hpp
、Thread.hpp
)线程池就像一个高效的工作团队:
// 线程的工作循环
static void *handlerTask(void *args) {
ThreadData<T> *td = (ThreadData<T> *)args;
while (true) {
T t;
{
// 1. 等待任务分配
LockGuard lockguard(td->threadpool->mutex());
while (td->threadpool->isQueueEmpty()) {
td->threadpool->threadWait(); // 没有任务时等待
}
// 2. 领取任务
t = td->threadpool->pop();
}
// 3. 执行任务
t();
}
return nullptr;
}
// 添加新任务
void push(const T &in) {
// 1. 锁定任务队列
LockGuard lockguard(&_mutex);
// 2. 添加任务
_task_queue.push(in);
// 3. 通知等待的线程
pthread_cond_signal(&_cond);
}
为什么使用条件变量:条件变量允许线程在特定条件满足前进入睡眠状态,避免了忙等待(不断检查条件)带来的CPU资源浪费。
单例模式的优势:整个程序只需要一个线程池实例,单例模式确保了资源的统一管理,避免了重复创建带来的开销。
双重检查锁定:在getInstance()
方法中使用双重检查锁定,既保证了线程安全,又避免了每次获取实例都加锁带来的性能损失。
模板设计的灵活性:使用模板设计线程池,使其能够处理不同类型的任务,提高了代码的复用性。
Task.hpp
)任务处理模块就像是一个标准化的"工作指南":
// 具体的任务处理函数
void serviceIO(int sock) {
char buffer[1024];
while (true) {
// 1. 接收客户端数据
ssize_t n = read(sock, buffer, sizeof(buffer) - 1);
if (n > 0) {
// 2. 处理数据
buffer[n] = 0;
std::cout << "recv message: " << buffer << std::endl;
// 3. 准备响应
std::string outbuffer = buffer;
outbuffer += " server[echo]";
// 4. 发送响应
write(sock, outbuffer.c_str(), outbuffer.size());
}
else if (n == 0) {
// 5. 客户端断开连接
logMessage(NORMAL, "client quit, me too!");
break;
}
}
// 6. 关闭连接
close(sock);
}
// 任务封装类
class Task {
using func_t = std::function<void(int)>;
public:
Task(int sock, func_t func)
: _sock(sock), _callback(func) {}
// 统一的任务执行接口
void operator()() {
_callback(_sock);
}
private:
int _sock;
func_t _callback;
};
为什么使用std::function:std::function
提供了一种类型安全的函数封装,可以存储、复制和调用任何可调用目标(函数、lambda表达式、函数对象等)。
为什么重载operator():重载operator()
使Task对象可以像函数一样被调用,符合线程池对任务的要求,同时提供了更清晰的接口。
read/write vs recv/send:本项目使用read/write
而非recv/send
,因为前者更符合Unix “一切皆文件” 的哲学,可以统一处理文件、管道、套接字等I/O操作。
为什么接收用char[]而发送用string:
char[]
缓冲区,可以直接与系统调用配合,避免动态内存分配string
,便于字符串操作(如拼接)c_str()
转换回C风格字符串进行发送log.hpp
)日志系统就像飞机的黑匣子,记录系统运行的各种状态和事件:
void logMessage(int level, const char *format, ...) {
// 1. 构建日志前缀
char logprefix[NUM];
snprintf(logprefix, sizeof(logprefix), "[%s][%ld][pid: %d]",
to_levelstr(level), (long int)time(nullptr), getpid());
// 2. 处理可变参数
char logcontent[NUM];
va_list arg;
va_start(arg, format);
vsnprintf(logcontent, sizeof(logcontent), format, arg);
va_end(arg);
// 3. 选择日志文件
FILE *log = fopen(LOG_NORMAL, "a");
FILE *err = fopen(LOG_ERR, "a");
if(log != nullptr && err != nullptr) {
FILE *curr = nullptr;
if(level <= WARNING) curr = log;
else curr = err;
// 4. 写入日志
if(curr) fprintf(curr, "%s%s\n", logprefix, logcontent);
fclose(log);
fclose(err);
}
}
可变参数的处理:使用va_list
、va_start
、va_end
和vsnprintf
处理可变参数,实现了类似printf
的灵活接口。
日志分级的意义:
为什么分文件存储:将普通日志和错误日志分开存储,便于快速定位问题,同时避免重要的错误信息被大量普通日志淹没。
时间戳和进程ID:记录时间戳和进程ID,便于在多进程环境下追踪问题,确定事件发生的顺序。
daemon.hpp
)守护进程就像是系统的"隐形服务员":
void daemonSelf(const char *currPath = nullptr) {
// 1. 忽略管道破裂信号
signal(SIGPIPE, SIG_IGN);
// 2. 创建子进程,父进程退出
if (fork() > 0)
exit(0);
// 3. 创建新会话,脱离控制终端
pid_t n = setsid();
assert(n != -1);
// 4. 重定向标准输入输出
int fd = open(DEV, O_RDWR);
if(fd >= 0) {
dup2(fd, 0); // 标准输入
dup2(fd, 1); // 标准输出
dup2(fd, 2); // 标准错误
close(fd);
}
// 5. 更改工作目录
if(currPath) chdir(currPath);
}
为什么忽略SIGPIPE信号:当写入一个已关闭的管道或套接字时,系统会发送SIGPIPE信号,默认处理是终止进程。忽略此信号可以防止服务器因客户端异常断开而崩溃。
为什么使用fork():使用fork()
创建子进程,然后父进程退出,使子进程成为孤儿进程,被init进程收养,从而脱离原来的控制终端。
setsid()的作用:setsid()
创建一个新的会话,使进程成为会话首进程,没有控制终端,不会接收终端相关的信号。
为什么重定向到/dev/null:重定向标准输入输出到/dev/null
,确保进程不会因为读写终端而阻塞,同时避免输出信息干扰系统运行。
LockGuard.hpp
)锁管理模块就像是一个自动化的门禁系统:
class Mutex {
public:
Mutex(pthread_mutex_t *lock_p = nullptr): lock_p_(lock_p) {}
void lock() {
if(lock_p_) pthread_mutex_lock(lock_p_);
}
void unlock() {
if(lock_p_) pthread_mutex_unlock(lock_p_);
}
private:
pthread_mutex_t *lock_p_;
};
class LockGuard {
public:
LockGuard(pthread_mutex_t *mutex): mutex_(mutex) {
mutex_.lock(); // 构造时自动加锁
}
~LockGuard() {
mutex_.unlock(); // 析构时自动解锁
}
private:
Mutex mutex_;
};
RAII的优势:使用RAII模式管理锁资源,无需手动解锁,即使发生异常也能确保锁被释放,避免死锁。
分离Mutex和LockGuard:将Mutex和LockGuard分开实现,提高了代码的复用性和灵活性。Mutex封装了底层锁操作,LockGuard提供了RAII风格的接口。
空指针检查:在lock()和unlock()方法中检查指针是否为空,提高了代码的健壮性,避免空指针异常。
使用示例:
{
LockGuard guard(&mutex); // 进入作用域,自动加锁
// 临界区代码...
} // 离开作用域,自动解锁
定义:确保一个类只有一个实例,并提供一个全局访问点。
应用:线程池使用单例模式,确保整个程序只有一个线程池实例。
优势:
实现:
static ThreadPool<T> *getInstance() {
if (nullptr == tp) {
_singlock.lock();
if (nullptr == tp) {
tp = new ThreadPool<T>();
}
_singlock.unlock();
}
return tp;
}
定义:定义对象间的一种一对多依赖关系,使得当一个对象状态改变时,所有依赖于它的对象都会得到通知。
应用:线程池中的条件变量机制实际上是观察者模式的一种变体。
优势:
实现:
// 生产者(通知者)
void push(const T &in) {
LockGuard lockguard(&_mutex);
_task_queue.push(in);
pthread_cond_signal(&_cond); // 通知等待的线程
}
// 消费者(观察者)
while (td->threadpool->isQueueEmpty()) {
td->threadpool->threadWait(); // 等待通知
}
定义:定义一个创建对象的接口,但由子类决定要实例化的类是哪一个。
应用:Task类使用了工厂方法模式的思想,通过回调函数创建不同的任务处理逻辑。
优势:
实现:
Task(int sock, func_t func)
: _sock(sock), _callback(func) {}
void operator()() {
_callback(_sock); // 调用工厂方法创建的处理逻辑
}
连接池:除了线程池,还可以实现连接池,预先建立和维护一组数据库连接,避免频繁创建和销毁连接的开销。
零拷贝技术:使用sendfile()
等系统调用,减少数据在内核空间和用户空间之间的拷贝,提高文件传输效率。
事件驱动模型:使用epoll
、kqueue
等I/O多路复用技术,实现高效的事件驱动模型,支持更多并发连接。
输入验证:对客户端输入进行严格验证,防止缓冲区溢出、SQL注入等攻击。
加密通信:实现SSL/TLS加密,保护数据传输安全。
资源限制:对连接数、请求频率等进行限制,防止DoS攻击。
插件系统:设计插件接口,支持动态加载功能模块。
配置中心:实现集中式配置管理,支持动态配置更新。
服务发现:集成服务发现机制,支持分布式部署。
本项目实现了一个完整的TCP服务器与客户端通信系统,涵盖了网络编程、多线程编程、线程池、守护进程、日志系统等多个核心知识点。通过模块化设计和面向对象编程,我们构建了一个结构清晰、功能完善的网络服务框架。
从这个项目出发,你可以进一步探索:
网络编程是现代软件开发的基础技能,希望这个项目能够帮助你打开网络编程的大门,为你的技术成长提供坚实的基础。