基于FCGI的web后端服务程序设计
1. 概述
FastCGI(FCGI)是一种让交互程序与Web服务器通信的协议,是CGI(Common Gateway Interface)的增强版本。FCGI进程可以常驻内存,处理多个请求,避免了CGI每次请求都需要创建新进程的开销。本文将详细介绍一个FCGI常驻服务程序的设计与实现,包括FCGI初始化、守护进程模式、服务启动和停止等关键环节。
项目源码:https://gitcode.com/embeddedPrj/webserver/tree/main/src/fcgiServer
2. FCGI初始化
FCGI服务器的初始化主要包括以下步骤:
2.1 FCGI库初始化
FCGX_Init();
这个函数初始化FCGI库,为后续操作做准备。
2.2 创建FCGI套接字
// 移除可能存在的旧套接字文件
unlink(FCGI_SOCKET_PATH);
// 创建新的套接字
cgi_sock = FCGX_OpenSocket(FCGI_SOCKET_PATH, 512);
if(cgi_sock < 0) {
log_info("open FCGX socket failed\n");
return -1;
}
// 设置套接字文件权限,确保Web服务器(如Nginx)可以访问
if (chmod(FCGI_SOCKET_PATH, 0666) < 0) {
log_info("chmod socket file failed\n");
close(cgi_sock);
unlink(FCGI_SOCKET_PATH);
return -1;
}
这段代码创建了一个FCGI套接字,用于与Web服务器通信。FCGI_SOCKET_PATH
定义了套接字文件的路径,512
是连接队列的大小。创建套接字后,通过chmod
设置适当的权限,确保Web服务器可以访问该套接字。
2.3 创建工作线程
for(i = 1; i < CGI_THREAD_NUM; i++) {
pthread_create(&id[i], NULL, thread_cgi, (void*)&i);
}
为了处理并发请求,程序创建了多个工作线程。每个线程执行thread_cgi
函数,等待并处理FCGI请求。
2.4 工作线程函数
void *thread_cgi(void *param)
{
int ret;
FCGX_Request cgi_request;
while(1){
memset(&cgi_request, 0, sizeof(cgi_request));
FCGX_InitRequest(&cgi_request, cgi_sock, 0);
ret = FCGX_Accept_r(&cgi_request);
if(ret == 0){
cgi_service(&cgi_request); //处理cgi request的请求
FCGX_Finish_r(&cgi_request);
} else {
printf("CGI accept fail\n");
}
}
}
工作线程在一个无限循环中执行以下操作:
- 初始化FCGI请求结构
- 接受新的请求
- 处理请求
- 完成请求处理,释放资源
3. 守护进程实现
守护进程(Daemon)是在后台运行的服务进程,不受终端控制。实现守护进程的关键步骤如下:
3.1 守护进程转换函数
void daemonize() {
pid_t pid;
// 创建子进程
pid = fork();
// 创建子进程失败
if (pid < 0) {
log_error("Failed to fork daemon process");
exit(1);
}
// 父进程退出
if (pid > 0) {
exit(0);
}
// 子进程继续
// 创建新会话,使子进程成为会话首进程
if (setsid() < 0) {
log_error("Failed to create new session");
exit(1);
}
// 忽略SIGHUP信号
signal(SIGHUP, SIG_IGN);
// 再次fork,确保进程不是会话首进程,防止获取控制终端
pid = fork();
if (pid < 0) {
log_error("Failed to fork daemon process (second fork)");
exit(1);
}
if (pid > 0) {
exit(0);
}
// 更改工作目录到根目录
if (chdir("/") < 0) {
log_error("Failed to change working directory");
exit(1);
}
// 重设文件创建掩码
umask(0);
// 关闭所有打开的文件描述符
for (int i = 0; i < 1024; i++) {
close(i);
}
// 重定向标准输入、输出和错误到/dev/null
int fd = open("/dev/null", O_RDWR);
if (fd < 0) {
log_error("Failed to open /dev/null");
exit(1);
}
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
if (fd > 2) {
close(fd);
}
log_info("Process daemonized successfully with PID: %d", getpid());
}
守护进程的创建过程包括以下关键步骤:
- 第一次fork:创建子进程,父进程退出。这使子进程不再是进程组的首进程。
- 创建新会话:调用
setsid()
创建新的会话,使进程脱离原来的控制终端。 - 忽略SIGHUP信号:防止进程在终端关闭时被终止。
- 第二次fork:再次创建子进程,父进程退出。这确保进程不能重新获得控制终端。
- 更改工作目录:切换到根目录,避免占用可能被卸载的文件系统。
- 重设文件创建掩码:确保创建的文件有正确的权限。
- 关闭文件描述符:关闭所有继承的文件描述符。
- 重定向标准输入输出:将标准输入、输出和错误重定向到
/dev/null
。
3.2 在主函数中使用守护进程模式
// 如果指定了守护进程模式,则将进程转换为守护进程
if (daemon_mode) {
log_info("Running in daemon mode");
daemonize();
// 在守护进程模式下,如果PID文件创建失败,则退出程序
if (write_pid_file() != 0) {
log_error("Failed to create PID file in daemon mode, exiting");
exit(EXIT_FAILURE);
}
log_info("PID file created successfully with PID: %d", getpid());
} else {
// 非守护进程模式下,创建PID文件但失败不退出
if (write_pid_file() != 0) {
log_error("Failed to create PID file, continuing anyway");
} else {
log_info("PID file created successfully with PID: %d", getpid());
}
}
程序通过命令行参数-d
或--daemon
来决定是否以守护进程模式运行。在守护进程模式下,如果PID文件创建失败,程序会直接退出;而在非守护进程模式下,即使PID文件创建失败,程序也会继续运行。
4. 服务启动和停止设计
4.1 命令行参数解析
// 解析命令行参数
static struct option long_options[] = {
{"daemon", no_argument, 0, 'd'},
{"stop", no_argument, 0, 's'},
{0, 0, 0, 0}
};
while ((opt = getopt_long(argc, argv, "ds", long_options, NULL)) != -1) {
switch (opt) {
case 'd':
daemon_mode = 1;
break;
case 's':
stop_mode = 1;
break;
default:
fprintf(stderr, "Usage: %s [-d|--daemon] [-s|--stop]\n", argv[0]);
fprintf(stderr, " -d, --daemon Run as daemon\n");
fprintf(stderr, " -s, --stop Stop running server\n");
exit(EXIT_FAILURE);
}
}
程序支持两个主要的命令行选项:
-d
或--daemon
:以守护进程模式运行-s
或--stop
:停止正在运行的服务
4.2 PID文件管理
PID文件是管理守护进程的重要工具,它存储了进程的PID,便于后续操作(如停止服务)。
4.2.1 写入PID文件
int write_pid_file() {
FILE *fp;
pid_t pid = getpid();
// 打开PID文件
fp = fopen(PID_FILE, "w");
if (fp == NULL) {
log_error("Failed to open PID file: %s", strerror(errno));
return -1;
}
// 写入PID
fprintf(fp, "%d\n", pid);
fflush(fp); // 确保数据被写入磁盘
fclose(fp);
// 设置PID文件权限
if (chmod(PID_FILE, 0644) < 0) {
log_error("Failed to set PID file permissions: %s", strerror(errno));
return -1;
}
log_info("PID file created: %s (PID: %d)", PID_FILE, pid);
return 0;
}
写入PID文件的过程包括:
- 打开PID文件
- 写入当前进程的PID
- 刷新缓冲区,确保数据写入磁盘
- 设置适当的文件权限
4.2.2 停止运行中的服务
int stop_running_server() {
FILE *fp;
pid_t pid;
int ret = -1;
// 打开PID文件
fp = fopen(PID_FILE, "r");
if (fp == NULL) {
log_error("Failed to open PID file: %s. Is the server running?", strerror(errno));
return -1;
}
// 读取PID
char pid_str[32] = {0};
if (fgets(pid_str, sizeof(pid_str), fp) == NULL) {
log_error("Failed to read PID from file: %s", strerror(errno));
fclose(fp);
return -1;
}
// 转换PID字符串为整数
pid = atoi(pid_str);
if (pid <= 0) {
log_error("Invalid PID read from file: %s", pid_str);
fclose(fp);
return -1;
}
fclose(fp);
log_info("Stopping fcgiServer with PID: %d", pid);
// 发送SIGTERM信号
if (kill(pid, SIGTERM) == 0) {
// 等待进程退出
int max_wait = 10; // 最多等待10秒
while (max_wait-- > 0) {
if (kill(pid, 0) < 0) {
if (errno == ESRCH) {
// 进程已经退出
ret = 0;
break;
}
}
sleep(1);
}
if (ret != 0) {
log_error("Process did not terminate within timeout, sending SIGKILL");
kill(pid, SIGKILL);
ret = 0;
}
} else {
if (errno == ESRCH) {
log_error("No process with PID %d is running", pid);
// 进程不存在,可能已经退出,删除PID文件
unlink(PID_FILE);
ret = 0;
} else {
log_error("Failed to send signal to process: %s", strerror(errno));
}
}
// 删除PID文件
if (ret == 0) {
unlink(PID_FILE);
log_info("fcgiServer stopped successfully");
}
return ret;
}
停止服务的过程包括:
- 从PID文件中读取进程ID
- 向该进程发送SIGTERM信号
- 等待进程退出(最多10秒)
- 如果进程未在超时时间内退出,发送SIGKILL信号强制终止
- 删除PID文件
4.3 在主函数中处理停止模式
// 如果是停止模式,则停止运行中的服务并退出
if (stop_mode) {
log_init(FCGI_SERVER_LOG_FILE_NAME);
log_info("Stopping fcgiServer...");
int ret = stop_running_server();
return (ret == 0) ? EXIT_SUCCESS : EXIT_FAILURE;
}
当用户使用-s
或--stop
选项启动程序时,程序会尝试停止正在运行的服务,然后退出。
5. 信号处理和资源清理
5.1 信号处理函数
// 信号处理函数,用于清理资源并退出
void cleanup_handler(int sig) {
log_info("接收到信号 %d,清理资源并退出...", sig);
// 关闭FCGI套接字
if (cgi_sock >= 0) {
close(cgi_sock);
cgi_sock = -1;
}
// 删除套接字文件
unlink(FCGI_SOCKET_PATH);
// 删除PID文件
unlink(PID_FILE);
log_info("清理完成,退出程序");
exit(0);
}
信号处理函数cleanup_handler
负责在程序接收到终止信号时进行资源清理:
- 关闭FCGI套接字
- 删除套接字文件
- 删除PID文件
- 记录日志并退出程序
5.2 注册信号处理函数
// 注册信号处理函数
signal(SIGTERM, cleanup_handler);
signal(SIGINT, cleanup_handler);
程序注册了两个信号的处理函数:
SIGTERM
:终止信号,通常由kill
命令发送SIGINT
:中断信号,通常由Ctrl+C产生
这确保了无论程序是正常终止还是被强制终止,都能进行适当的资源清理。
6. 主循环设计
FCGI服务器的主循环是整个程序的核心,它负责接受和处理请求。在多线程模式下,主线程也参与请求处理:
// 主线程也处理请求
thread_cgi(NULL);
主线程和工作线程都执行相同的thread_cgi
函数,形成一个请求处理池。这种设计充分利用了系统资源,提高了并发处理能力。
7. 总结
FCGI常驻服务程序的设计涉及多个关键方面:
- FCGI初始化:初始化FCGI库,创建套接字,设置权限,创建工作线程。
- 守护进程实现:通过双重fork、会话创建、文件描述符重定向等步骤,将程序转换为后台服务。
- 服务启动和停止:通过命令行参数控制程序行为,使用PID文件管理服务生命周期。
- 信号处理和资源清理:注册信号处理函数,确保程序在终止时能够释放资源。
- 多线程处理:创建多个工作线程处理并发请求,提高服务性能。
这种设计模式适用于需要长时间运行的服务程序,特别是Web后端服务。通过FCGI协议,服务可以高效地处理来自Web服务器的请求,而守护进程模式则确保服务能够在后台稳定运行。
8. 启动脚本设计
为了方便服务的日常管理,我们通常会编写启动脚本来封装服务的启动、停止和重启操作。下面是一个典型的FCGI服务启动脚本示例(scripts/fcgiServer.sh
):
#!/bin/bash
set -e
CURRENT_DIR=$(pwd)
FCGISERVER_ROOT_DIR=$CURRENT_DIR/../bin
NAME=fcgiServer
start_fcgiServer()
{
cd $FCGISERVER_ROOT_DIR
sudo ./fcgiServer -d
cd -
}
shut_fcgiServer()
{
cd $FCGISERVER_ROOT_DIR
sudo ./fcgiServer -s
cd -
}
case "$1" in
start)
start_fcgiServer
;;
stop)
shut_fcgiServer
;;
restart)
shut_fcgiServer
start_fcgiServer
;;
*)
echo "Usage: $NAME {start|stop|restart}" >&2
exit 2
;;
esac
8.1 脚本结构分析
这个启动脚本的设计遵循了常见的Linux服务管理脚本模式,主要包括以下几个部分:
环境设置:
set -e CURRENT_DIR=$(pwd) FCGISERVER_ROOT_DIR=$CURRENT_DIR/../bin NAME=fcgiServer
set -e
:当脚本中的任何命令返回非零状态时立即退出,提高脚本的健壮性- 设置工作目录变量,便于定位服务可执行文件
- 定义服务名称,用于显示帮助信息
启动函数:
start_fcgiServer() { cd $FCGISERVER_ROOT_DIR sudo ./fcgiServer -d cd - }
- 切换到服务可执行文件所在目录
- 使用
sudo
以管理员权限启动服务,并使用-d
参数以守护进程模式运行 - 返回原目录
停止函数:
shut_fcgiServer() { cd $FCGISERVER_ROOT_DIR sudo ./fcgiServer -s cd - }
- 切换到服务可执行文件所在目录
- 使用
sudo
以管理员权限停止服务,通过-s
参数发送停止信号 - 返回原目录
命令行参数处理:
case "$1" in start) start_fcgiServer ;; stop) shut_fcgiServer ;; restart) shut_fcgiServer start_fcgiServer ;; *) echo "Usage: $NAME {start|stop|restart}" >&2 exit 2 ;; esac
- 使用case语句处理不同的命令行参数
- 支持三种操作:start(启动)、stop(停止)和restart(重启)
- 对于无效参数,显示使用帮助并返回错误码
8.2 使用方法
脚本的使用方法非常简单:
启动服务:
./scripts/fcgiServer.sh start
停止服务:
./scripts/fcgiServer.sh stop
重启服务:
./scripts/fcgiServer.sh restart
9. 最佳实践
在实现FCGI常驻服务程序时,可以考虑以下最佳实践:
- 健壮的PID文件处理:确保PID文件的读写操作是安全的,处理各种边缘情况。
- 优雅的终止机制:先尝试使用SIGTERM信号,给程序一定时间进行清理,然后才使用SIGKILL强制终止。
- 资源限制:考虑使用
setrlimit
设置资源限制,防止程序占用过多系统资源。 - 日志轮转:实现日志轮转机制,防止日志文件过大。
- 监控和自动重启:结合外部工具(如systemd、supervisor等)实现服务监控和自动重启。
- 安全性考虑:确保套接字文件和PID文件的权限设置正确,防止未授权访问。
- 线程安全:在多线程环境中,确保共享资源的访问是线程安全的。
- 优雅降级:在资源紧张时,实现优雅降级机制,保证核心功能正常运行。
- 健康检查:实现健康检查接口,便于外部监控系统检测服务状态。
- 标准化启动脚本:使用标准化的启动脚本,支持常见的服务管理命令(start、stop、restart、status)。
- 系统集成:考虑与系统服务管理工具(如systemd、upstart等)集成,实现更好的系统集成。
通过遵循这些最佳实践,可以构建更加健壮、安全和高效的FCGI常驻服务程序。