30、综合实战:实现一个简易HTTP服务器或聊天室

终于到了这一步。前面我们学了socket、多线程、IO复用、协议解析……说实话,这些知识点单独拎出来都不难。但把它们串起来,做成一个真正能跑的东西——嗯,这才是网络编程最有意思的地方。

这一章,我带你做两个实战项目:一个简易HTTP服务器,一个多人在线聊天室。你不需要两个都做,选一个你感兴趣的就行。我个人建议先做HTTP服务器,因为它的逻辑更清晰,调试也方便——你打开浏览器就能看到效果。

核心目标:用纯C语言,基于socket API,实现一个能处理多个客户端请求的服务端程序。不依赖任何第三方库,全部手写。

30.1 项目一:简易HTTP服务器

HTTP服务器,说白了就是监听80端口,收到浏览器的请求后,把对应的文件返回回去。我当年第一次写的时候,以为要处理什么复杂的协议,结果一看HTTP报文——其实就是几行文本。

30.1.1 核心流程

  1. 创建socket,绑定端口,监听
  2. 接受客户端连接
  3. 读取HTTP请求行(比如 GET /index.html HTTP/1.1
  4. 解析请求路径,找到对应的文件
  5. 读取文件内容,构造HTTP响应
  6. 发送响应,关闭连接

你看,就这么几步。但要注意,浏览器发来的请求不止一行,还有一堆请求头。我们这里只解析第一行就够了,剩下的直接忽略——简易版嘛。

30.1.2 代码实现

#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 PORT 8080
#define BUFFER_SIZE 4096

// 构造HTTP响应头
void build_response(const char *status, const char *content_type,
                    const char *body, char *response) {
    sprintf(response,
        "HTTP/1.1 %s\r\n"
        "Content-Type: %s\r\n"
        "Content-Length: %zu\r\n"
        "Connection: close\r\n"
        "\r\n"
        "%s",
        status, content_type, strlen(body), body);
}

// 处理一个客户端请求
void handle_client(int client_fd) {
    char buffer[BUFFER_SIZE] = {0};
    char response[BUFFER_SIZE * 2] = {0};
    char method[16], path[256], protocol[16];

    // 读取请求
    read(client_fd, buffer, sizeof(buffer) - 1);
    sscanf(buffer, "%s %s %s", method, path, protocol);

    // 只处理GET请求
    if (strcmp(method, "GET") != 0) {
        build_response("405 Method Not Allowed", "text/plain",
                       "Method Not Allowed", response);
        write(client_fd, response, strlen(response));
        close(client_fd);
        return;
    }

    // 默认首页
    if (strcmp(path, "/") == 0) {
        strcpy(path, "/index.html");
    }

    // 去掉开头的'/'
    char *filepath = path + 1;
    if (strlen(filepath) == 0) {
        strcpy(filepath, "index.html");
    }

    // 尝试打开文件
    FILE *fp = fopen(filepath, "rb");
    if (fp == NULL) {
        build_response("404 Not Found", "text/plain",
                       "404 - File Not Found", response);
        write(client_fd, response, strlen(response));
        close(client_fd);
        return;
    }

    // 读取文件内容
    fseek(fp, 0, SEEK_END);
    long fsize = ftell(fp);
    rewind(fp);

    char *body = malloc(fsize + 1);
    fread(body, 1, fsize, fp);
    body[fsize] = '\0';
    fclose(fp);

    // 判断Content-Type
    char *content_type = "text/plain";
    if (strstr(filepath, ".html") || strstr(filepath, ".htm")) {
        content_type = "text/html";
    } else if (strstr(filepath, ".css")) {
        content_type = "text/css";
    } else if (strstr(filepath, ".js")) {
        content_type = "application/javascript";
    } else if (strstr(filepath, ".png")) {
        content_type = "image/png";
    } else if (strstr(filepath, ".jpg") || strstr(filepath, ".jpeg")) {
        content_type = "image/jpeg";
    }

    build_response("200 OK", content_type, body, response);
    write(client_fd, response, strlen(response));

    free(body);
    close(client_fd);
}

int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);

    // 创建socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket");
        exit(1);
    }

    // 设置端口复用
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, 10);

    printf("HTTP Server running on http://localhost:%d\n", PORT);

    while (1) {
        client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
        if (client_fd < 0) {
            perror("accept");
            continue;
        }

        // 这里用单线程处理,实际项目应该用多线程或epoll
        handle_client(client_fd);
    }

    close(server_fd);
    return 0;
}

小提示:上面的代码是单线程的,一次只能处理一个请求。你可以用之前学过的pthread,每来一个连接就创建一个线程去处理。不过要注意,频繁创建线程开销不小,生产环境一般用线程池。

30.1.3 测试方法

编译运行后,在浏览器输入 http://localhost:8080。如果当前目录下有 index.html,就会显示出来。没有的话,你会看到404页面。

我曾经犯过一个低级错误:忘记在响应头里加 Content-Length,结果浏览器一直转圈,以为还在加载。嗯,这个字段告诉浏览器响应体有多长,少了它浏览器不知道什么时候读完。

30.2 项目二:多人在线聊天室

聊天室比HTTP服务器稍微复杂一点。核心问题是:一个客户端发消息,怎么让其他客户端都收到?

说白了就是「广播」。服务端维护一个所有客户端socket的列表,收到一条消息,就遍历列表,发给每个人。

30.2.1 架构设计

我画了一张图,帮你理清思路:

聊天服务器 客户端 A 客户端 B 客户端 C 客户端 D 发送消息 广播消息

30.2.2 关键代码片段

这里我只给出核心的广播逻辑。完整的代码篇幅太长,你可以在课程配套资源里找到。

// 全局客户端列表
int client_fds[FD_SETSIZE];
int client_count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 广播消息给所有客户端
void broadcast(const char *msg, int sender_fd) {
    pthread_mutex_lock(&mutex);
    for (int i = 0; i < client_count; i++) {
        if (client_fds[i] != sender_fd) {
            write(client_fds[i], msg, strlen(msg));
        }
    }
    pthread_mutex_unlock(&mutex);
}

// 每个客户端的处理线程
void *client_handler(void *arg) {
    int fd = *(int *)arg;
    char buffer[1024];
    char nickname[32];

    // 先接收昵称
    read(fd, nickname, sizeof(nickname));
    nickname[strcspn(nickname, "\n")] = '\0';

    char welcome[128];
    snprintf(welcome, sizeof(welcome), "[系统] %s 加入了聊天室\n", nickname);
    broadcast(welcome, -1);

    while (1) {
        memset(buffer, 0, sizeof(buffer));
        int n = read(fd, buffer, sizeof(buffer) - 1);
        if (n <= 0) {
            // 客户端断开
            snprintf(welcome, sizeof(welcome), "[系统] %s 离开了聊天室\n", nickname);
            broadcast(welcome, -1);

            // 从列表中移除
            pthread_mutex_lock(&mutex);
            for (int i = 0; i < client_count; i++) {
                if (client_fds[i] == fd) {
                    client_fds[i] = client_fds[--client_count];
                    break;
                }
            }
            pthread_mutex_unlock(&mutex);
            close(fd);
            break;
        }

        // 构造带昵称的消息
        char msg[1100];
        snprintf(msg, sizeof(msg), "[%s] %s", nickname, buffer);
        broadcast(msg, fd);
    }
    return NULL;
}

注意:多线程操作全局列表时,一定要加锁。我曾经有一次忘了加锁,结果两个线程同时修改列表,导致数组越界,程序直接崩溃。排查了一下午才发现是竞态条件。

30.3 两个项目的对比与选择

对比维度 HTTP服务器 聊天室
难度 ★★☆☆☆ ★★★☆☆
核心知识点 HTTP协议、文件IO 多线程、广播、同步
调试方式 浏览器直接看效果 需要多个终端或telnet
实用价值 理解Web服务器原理 理解实时通信模型

如果你时间有限,我建议先做HTTP服务器。它更直观,调试也方便。你想想看,写完之后打开浏览器,看到自己写的服务器返回的页面——那种成就感,嗯,很爽。

30.4 避坑指南

这两个项目我前前后后写过不下十遍,踩过的坑列出来给你:

  • 端口被占用:程序退出后端口不会立即释放,用 SO_REUSEADDR 解决。
  • 粘包问题:TCP是流式协议,一次 read 可能读到多条消息。聊天室里可以用换行符 \n 作为消息分隔符。
  • 僵尸线程:创建线程后记得 pthread_detachpthread_join,否则会泄漏资源。
  • 大文件传输:HTTP服务器里如果文件很大,一次 read 可能读不完,要用循环读取。

我的习惯:写网络程序时,我会在关键位置加日志打印。比如「收到连接」「发送响应」「客户端断开」。这样出问题时,看一眼日志就知道卡在哪一步了。

30.5 扩展思考

做完这两个项目,你可以试着给它们加点功能:

  • HTTP服务器:支持POST请求、支持CGI脚本、支持虚拟主机
  • 聊天室:私聊功能、房间功能、消息历史记录

说实话,网络编程的核心就是「连接管理」和「数据收发」。你掌握了这两个,再复杂的系统也不过是往上堆业务逻辑而已。

好了,动手写吧。代码跑起来的那一刻,你会觉得前面学的所有知识都值了。


公众号:蓝海资料掘金营,微信 deep3321