51、HTTP服务器实现:GET方法支持、静态文件服务、404处理
说实话,HTTP服务器是网络编程里最实用的练手项目之一。你想想看,我们每天都在用浏览器访问各种网站,底层其实就是HTTP协议在干活。这一章,我们就手写一个迷你HTTP服务器,支持GET请求、能返回静态文件,遇到不存在的资源还能优雅地返回404。
我个人习惯把这类项目分成三层来理解:协议解析层、路由分发层、响应构造层。这样拆开想,代码写起来就清晰多了。
核心要点:一个完整的HTTP GET响应,至少包含状态行、响应头、空行、响应体四个部分。少一个,浏览器就可能不认。
1. 整体架构设计
我们先画一张图,把整个服务器的处理流程理清楚。我在项目中遇到过不少新手,上来就写代码,结果调试时发现请求解析不对、响应格式错误,折腾半天。所以,先看图再动手,能省很多时间。
嗯,这张图把核心流程串起来了。从监听端口开始,到接受连接、读取请求、解析路由,最后根据文件是否存在返回200或404。说白了,就这么几步。
2. 核心代码实现
下面我们直接上代码。我习惯把HTTP服务器的核心逻辑放在一个函数里,这样主循环看起来干净。你注意看handle_client这个函数,它负责处理一个完整的HTTP请求-响应周期。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <fcntl.h>
#define BUFFER_SIZE 4096
#define WEB_ROOT "./www"
// 发送HTTP响应头
void send_headers(int client_fd, int status_code, const char *status_text,
const char *content_type, long content_length) {
char headers[BUFFER_SIZE];
snprintf(headers, sizeof(headers),
"HTTP/1.1 %d %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %ld\r\n"
"Connection: close\r\n"
"\r\n",
status_code, status_text, content_type, content_length);
send(client_fd, headers, strlen(headers), 0);
}
// 发送404页面
void send_404(int client_fd) {
const char *body = "<html><body><h1>404 Not Found</h1>"
"<p>The requested resource was not found.</p>"
"</body></html>";
send_headers(client_fd, 404, "Not Found", "text/html", strlen(body));
send(client_fd, body, strlen(body), 0);
}
// 发送静态文件
void send_file(int client_fd, const char *filepath) {
int fd = open(filepath, O_RDONLY);
if (fd < 0) {
send_404(client_fd);
return;
}
struct stat st;
fstat(fd, &st);
long file_size = st.st_size;
// 根据扩展名判断Content-Type
const char *ext = strrchr(filepath, '.');
const char *content_type = "text/plain";
if (ext) {
if (strcmp(ext, ".html") == 0 || strcmp(ext, ".htm") == 0)
content_type = "text/html";
else if (strcmp(ext, ".css") == 0)
content_type = "text/css";
else if (strcmp(ext, ".js") == 0)
content_type = "application/javascript";
else if (strcmp(ext, ".png") == 0)
content_type = "image/png";
else if (strcmp(ext, ".jpg") == 0 || strcmp(ext, ".jpeg") == 0)
content_type = "image/jpeg";
}
send_headers(client_fd, 200, "OK", content_type, file_size);
// 用sendfile零拷贝发送文件(Linux特性)
off_t offset = 0;
while (offset < file_size) {
ssize_t sent = sendfile(client_fd, fd, &offset, file_size - offset);
if (sent <= 0) break;
}
close(fd);
}
// 处理客户端请求
void handle_client(int client_fd) {
char buffer[BUFFER_SIZE];
int bytes_read = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
if (bytes_read <= 0) {
close(client_fd);
return;
}
buffer[bytes_read] = '\0';
// 解析请求行:GET /index.html HTTP/1.1
char method[16], path[256], version[16];
sscanf(buffer, "%s %s %s", method, path, version);
// 只支持GET方法
if (strcmp(method, "GET") != 0) {
// 返回405 Method Not Allowed
const char *body = "<html><body><h1>405 Method Not Allowed</h1></body></html>";
send_headers(client_fd, 405, "Method Not Allowed", "text/html", strlen(body));
send(client_fd, body, strlen(body), 0);
close(client_fd);
return;
}
// 构造文件路径,防止路径穿越攻击
char filepath[512];
if (strcmp(path, "/") == 0) {
snprintf(filepath, sizeof(filepath), "%s/index.html", WEB_ROOT);
} else {
// 过滤掉路径中的".."
if (strstr(path, "..") != NULL) {
send_404(client_fd);
close(client_fd);
return;
}
snprintf(filepath, sizeof(filepath), "%s%s", WEB_ROOT, path);
}
// 检查文件是否存在
struct stat st;
if (stat(filepath, &st) == 0 && S_ISREG(st.st_mode)) {
send_file(client_fd, filepath);
} else {
send_404(client_fd);
}
close(client_fd);
}
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
exit(1);
}
// 设置端口复用,避免"Address already in use"
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(1);
}
if (listen(server_fd, 10) < 0) {
perror("listen");
exit(1);
}
printf("HTTP Server running on http://localhost:8080\n");
while (1) {
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept");
continue;
}
// 简单处理:单线程串行处理
handle_client(client_fd);
}
close(server_fd);
return 0;
}
3. 关键设计要点
代码看完了,我们来拆几个关键点。这些是我在实际项目中踩过坑的地方,你写的时候多留意。
3.1 请求解析的陷阱
用sscanf解析请求行虽然简单,但有个问题:如果请求头里包含特殊字符,或者请求行格式不规范,解析就会出错。我曾经在生产环境遇到过客户端发送了带空格的URL,结果sscanf直接截断了路径,返回了404。后来我改成了逐字符解析,才彻底解决。
小技巧:如果你想要更健壮的解析,可以用strtok按空格分割,或者自己写一个简单的状态机。对于教学示例,sscanf够用了,但生产环境建议用专业的HTTP解析库。
3.2 路径穿越攻击防护
你想想看,如果用户请求GET /../../../etc/passwd HTTP/1.1,你的服务器会怎么做?如果不加防护,服务器就会把系统文件返回给客户端,这是严重的安全漏洞。
我在代码里做了两层防护:
- 检查路径中是否包含
..,如果包含直接返回404 - 用
stat检查文件是否存在且是普通文件(不是目录或设备文件)
注意:只检查..还不够,攻击者可能用URL编码绕过。更严谨的做法是用realpath()获取绝对路径,然后检查是否在WEB_ROOT目录下。这个留给你自己扩展。
3.3 零拷贝发送文件
代码里用了sendfile系统调用,这是Linux特有的高效文件发送方式。传统方式需要先把文件读到用户空间缓冲区,再写到socket,数据在用户态和内核态之间拷贝了两次。sendfile直接在文件描述符和socket之间传输数据,省掉了中间拷贝。
我记得有一次优化一个文件下载服务,把read+write改成sendfile后,CPU占用率直接降了30%。对于大文件传输,效果尤其明显。
4. 测试与验证
代码写完了,怎么测试?我建议你按下面几步来:
- 在程序所在目录创建
www文件夹,里面放一个index.html - 编译运行:
gcc -o http_server http_server.c && ./http_server - 浏览器访问
http://localhost:8080,应该看到index.html的内容 - 访问
http://localhost:8080/nonexistent.html,应该看到404页面 - 用
curl -v http://localhost:8080/查看完整的HTTP响应头
验证清单:
| 测试场景 | 预期结果 | 检查点 |
|---|---|---|
| 访问根路径 / | 返回 index.html | 状态码200,Content-Type正确 |
| 访问存在的文件 | 返回文件内容 | Content-Length与实际文件大小一致 |
| 访问不存在的文件 | 返回404页面 | 状态码404,响应体包含错误信息 |
| 发送POST请求 | 返回405 | 状态码405,提示Method Not Allowed |
| 路径穿越攻击 | 返回404 | 不返回系统文件 |
5. 扩展思考
这个示例虽然简单,但已经具备了HTTP服务器的核心骨架。如果你想继续深入,可以考虑:
- 多线程/多进程:用线程池处理并发请求,而不是串行处理
- Keep-Alive:支持HTTP长连接,减少TCP握手开销
- 目录列表:当请求路径是目录时,自动生成文件列表页面
- MIME类型映射:用配置文件管理Content-Type映射,而不是硬编码
嗯,这一章的内容就到这里。代码虽然不长,但HTTP服务器的核心逻辑都在里面了。你动手跑一遍,再试着加几个功能,对网络编程的理解会深很多。
公众号:蓝海资料掘金营,微信deep3321