51、HTTP服务器实现:GET方法支持、静态文件服务、404处理

说实话,HTTP服务器是网络编程里最实用的练手项目之一。你想想看,我们每天都在用浏览器访问各种网站,底层其实就是HTTP协议在干活。这一章,我们就手写一个迷你HTTP服务器,支持GET请求、能返回静态文件,遇到不存在的资源还能优雅地返回404。

我个人习惯把这类项目分成三层来理解:协议解析层路由分发层响应构造层。这样拆开想,代码写起来就清晰多了。

核心要点:一个完整的HTTP GET响应,至少包含状态行、响应头、空行、响应体四个部分。少一个,浏览器就可能不认。

1. 整体架构设计

我们先画一张图,把整个服务器的处理流程理清楚。我在项目中遇到过不少新手,上来就写代码,结果调试时发现请求解析不对、响应格式错误,折腾半天。所以,先看图再动手,能省很多时间。

HTTP服务器核心处理流程 1. 创建监听Socket 2. accept() 接受连接 3. 读取HTTP请求行 4. 解析GET + 路由分发 文件存在 → 200 OK + 文件内容 文件不存在 → 404 Not Found

嗯,这张图把核心流程串起来了。从监听端口开始,到接受连接、读取请求、解析路由,最后根据文件是否存在返回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. 测试与验证

代码写完了,怎么测试?我建议你按下面几步来:

  1. 在程序所在目录创建www文件夹,里面放一个index.html
  2. 编译运行:gcc -o http_server http_server.c && ./http_server
  3. 浏览器访问http://localhost:8080,应该看到index.html的内容
  4. 访问http://localhost:8080/nonexistent.html,应该看到404页面
  5. 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