第30章 综合项目实战:实现一个简易HTTP服务器

终于到了这一步。说实话,每次带新人做这个项目,我都挺感慨的。前面学了那么多零散的知识点——socket、多线程、文件I/O、进程管理……现在终于要把它们串起来了。

这一章,我们手写一个简易HTTP服务器。它要支持三件事:静态文件服务、CGI脚本执行、并发处理。别小看这个项目,很多商业服务器的核心逻辑,说白了也就是这三板斧。

30.1 整体架构设计

动手写代码前,先想清楚架构。我个人习惯先画一张图,把模块之间的关系理清楚。

简易HTTP服务器架构图 主监听线程 accept() 连接 静态文件处理 CGI 脚本处理 404/错误处理 线程池/进程池 fork() 子进程 返回错误页面 三种请求路径:静态资源 → 直接返回文件;CGI → 创建子进程执行;未知路径 → 返回404

这张图里,核心就三个模块。主线程负责监听端口,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;
我的经验:path 和 query 分开存储,是因为解析 CGI 参数时特别方便。曾经有个项目把 query 混在 path 里,结果解析 URL 编码时出了各种 bug。

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);
}
我曾经犯过的错:一开始没做路径穿越检查,结果有人用 "../../etc/passwd" 把系统文件读走了。从那以后,我所有文件操作都加上了路径校验。

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 完整测试与调试

写完了怎么测?我一般分三步走:

  1. 用 curl 测试静态文件curl -v http://localhost:8080/index.html
  2. 用 curl 测试 CGIcurl -v "http://localhost:8080/test.cgi?name=hello"
  3. 用 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