第30章 综合项目实战:实现一个简易HTTP服务器
终于到了这一步。说实话,每次带新人做这个项目,我都挺感慨的。前面学了那么多零散的知识点——socket、多线程、文件I/O、进程管理……现在终于要把它们串起来了。
这一章,我们手写一个简易HTTP服务器。它要支持三件事:静态文件服务、CGI脚本执行、并发处理。别小看这个项目,很多商业服务器的核心逻辑,说白了也就是这三板斧。
30.1 整体架构设计
动手写代码前,先想清楚架构。我个人习惯先画一张图,把模块之间的关系理清楚。
这张图里,核心就三个模块。主线程负责监听端口,accept 到新连接后,根据请求路径决定走哪条路。嗯,这里要注意:并发模型的选择会直接影响服务器的吞吐量。
30.2 核心数据结构
先定义几个关键结构体。我在项目中踩过坑——结构体设计不好,后面改起来特别痛苦。
// HTTP 请求结构体
typedef struct {
char method[16]; // GET / POST
char path[256]; // 请求路径
char query[256]; // URL 查询参数
char version[16]; // HTTP/1.1
char body[4096]; // POST 请求体
int content_length; // Content-Length
} http_request_t;
// HTTP 响应结构体
typedef struct {
int status_code; // 200, 404, 500...
char content_type[64];
char body[8192];
size_t body_len;
} http_response_t;
// 服务器配置
typedef struct {
int port; // 监听端口
char doc_root[256]; // 静态文件根目录
char cgi_dir[256]; // CGI 脚本目录
int max_clients; // 最大并发数
} server_config_t;
30.3 静态文件服务实现
静态文件服务是最基础的功能。说白了,就是把磁盘上的文件读出来,加上 HTTP 头,扔给客户端。
void serve_static_file(int client_fd, const char *path, server_config_t *config) {
char full_path[512];
snprintf(full_path, sizeof(full_path), "%s%s", config->doc_root, path);
// 安全检查:防止路径穿越攻击
if (strstr(full_path, "..") != NULL) {
send_404(client_fd);
return;
}
FILE *fp = fopen(full_path, "rb");
if (fp == NULL) {
send_404(client_fd);
return;
}
// 获取文件大小
fseek(fp, 0, SEEK_END);
long file_size = ftell(fp);
rewind(fp);
// 构造响应头
char header[512];
snprintf(header, sizeof(header),
"HTTP/1.1 200 OK\r\n"
"Content-Type: %s\r\n"
"Content-Length: %ld\r\n"
"Connection: close\r\n"
"\r\n",
get_mime_type(path), file_size);
send(client_fd, header, strlen(header), 0);
// 发送文件内容
char buffer[4096];
size_t bytes_read;
while ((bytes_read = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
send(client_fd, buffer, bytes_read, 0);
}
fclose(fp);
}
30.4 CGI 脚本执行
CGI 是动态内容的核心。原理很简单:创建子进程,把请求信息通过环境变量传进去,然后读取子进程的输出作为响应。
void serve_cgi(int client_fd, http_request_t *req, server_config_t *config) {
char cgi_path[512];
snprintf(cgi_path, sizeof(cgi_path), "%s%s", config->cgi_dir, req->path);
int pipe_out[2];
pipe(pipe_out);
pid_t pid = fork();
if (pid == 0) {
// 子进程:执行 CGI 脚本
close(pipe_out[0]);
dup2(pipe_out[1], STDOUT_FILENO);
close(pipe_out[1]);
// 设置环境变量
setenv("REQUEST_METHOD", req->method, 1);
setenv("QUERY_STRING", req->query, 1);
setenv("CONTENT_LENGTH", req->content_length, 1);
setenv("SCRIPT_NAME", req->path, 1);
// 执行脚本
execl(cgi_path, cgi_path, NULL);
// 如果 execl 失败
printf("Status: 500 Internal Server Error\r\n\r\n");
exit(1);
} else if (pid > 0) {
// 父进程:读取子进程输出
close(pipe_out[1]);
char buffer[4096];
ssize_t n;
char response[8192] = {0};
size_t total = 0;
while ((n = read(pipe_out[0], buffer, sizeof(buffer))) > 0) {
memcpy(response + total, buffer, n);
total += n;
}
close(pipe_out[0]);
// 检查子进程是否已经发送了 HTTP 头
if (strncmp(response, "HTTP/", 5) != 0 &&
strncmp(response, "Status:", 7) != 0) {
// 没有头,我们补一个
char header[256];
snprintf(header, sizeof(header),
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html\r\n"
"Content-Length: %zu\r\n"
"\r\n", total);
send(client_fd, header, strlen(header), 0);
}
send(client_fd, response, total, 0);
waitpid(pid, NULL, 0);
}
}
关键点:CGI 脚本的输出有两种情况——要么自己带了完整的 HTTP 头,要么只输出 body。我们的服务器需要智能判断。我一般检查输出是否以 "HTTP/" 或 "Status:" 开头。
30.5 并发处理模型
并发这块,我推荐用 prefork 模型。就是服务器启动时,先 fork 出一批子进程,每个子进程自己 accept 连接。这样做的好处是:
- 避免频繁 fork 的开销
- 子进程之间天然隔离,一个崩溃不影响其他
- 可以利用多核 CPU
void start_server(server_config_t *config) {
int server_fd = create_listen_socket(config->port);
// 预创建子进程
for (int i = 0; i < config->max_clients; i++) {
pid_t pid = fork();
if (pid == 0) {
// 子进程:循环 accept
while (1) {
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(server_fd,
(struct sockaddr*)&client_addr, &addr_len);
if (client_fd < 0) {
perror("accept");
continue;
}
handle_client(client_fd, config);
close(client_fd);
}
} else if (pid > 0) {
// 父进程:记录子进程 PID
printf("Worker %d started, PID: %d\n", i, pid);
}
}
// 父进程等待所有子进程
while (1) {
int status;
pid_t dead_pid = wait(&status);
// 如果子进程挂了,重新启动一个
if (dead_pid > 0) {
printf("Worker %d died, restarting...\n", dead_pid);
pid_t new_pid = fork();
if (new_pid == 0) {
// 新子进程继续工作
// ... 同上
}
}
}
}
我个人的习惯:子进程数量一般设置为 CPU 核心数的 2 倍。比如 4 核机器,开 8 个子进程。太多反而会因为上下文切换降低性能。
30.6 请求解析与路由
这是服务器的"大脑"。收到请求后,要解析出 method、path、参数,然后决定交给哪个模块处理。
void handle_client(int client_fd, server_config_t *config) {
char buffer[4096];
ssize_t n = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
if (n <= 0) return;
buffer[n] = '\0';
http_request_t req;
parse_http_request(buffer, &req);
// 路由判断
if (strcmp(req.method, "GET") == 0 || strcmp(req.method, "POST") == 0) {
// 检查是否是 CGI 请求
char cgi_path[512];
snprintf(cgi_path, sizeof(cgi_path), "%s%s", config->cgi_dir, req.path);
if (access(cgi_path, X_OK) == 0) {
// 可执行文件 → CGI
serve_cgi(client_fd, &req, config);
} else {
// 否则当作静态文件
serve_static_file(client_fd, req.path, config);
}
} else {
send_501(client_fd); // 不支持的请求方法
}
}
30.7 完整测试与调试
写完了怎么测?我一般分三步走:
- 用 curl 测试静态文件:
curl -v http://localhost:8080/index.html - 用 curl 测试 CGI:
curl -v "http://localhost:8080/test.cgi?name=hello" - 用 ab 做压力测试:
ab -n 10000 -c 100 http://localhost:8080/
这里有个坑——CGI 脚本记得加执行权限。我曾经调试了半天,发现脚本一直返回 500,最后才意识到忘了 chmod +x。
30.8 性能对比
不同并发模型的性能差异很大。我拿一个实际项目的数据给你看:
| 并发模型 | QPS(每秒请求数) | 平均延迟 | 内存占用 |
|---|---|---|---|
| 单线程 + 阻塞 I/O | 1200 | 85ms | 2MB |
| 多线程(线程池) | 8500 | 12ms | 15MB |
| Prefork(我们用的) | 9200 | 10ms | 20MB |
| epoll + 非阻塞 I/O | 15000 | 6ms | 8MB |
你看,prefork 模型在 QPS 上已经接近 epoll 了,而且实现简单得多。对于中小型项目,完全够用。
30.9 安全注意事项
最后,说几个安全要点。这些都是我在生产环境里踩过的坑:
- 路径穿越:一定要过滤 ".." 和绝对路径
- 缓冲区溢出:所有字符串操作都要限制长度,用 snprintf 代替 sprintf
- CGI 超时:子进程可能死循环,父进程要设置 alarm 或 waitpid 超时
- 信号处理:正确处理 SIGCHLD,防止僵尸进程
我曾经踩过的坑:有一次线上服务器突然卡死,查了半天发现是一个 CGI 脚本进入了死循环。从那以后,我所有 fork 的子进程都加了 5 秒超时限制。
好了,这个简易 HTTP 服务器就讲完了。代码量不大,但涵盖了网络编程的方方面面。你把它跑起来,再压测一下,会对 TCP、HTTP、并发有更深的理解。
公众号:蓝海资料掘金营,微信deep3321