30、综合实战:实现一个简易HTTP服务器或聊天室
终于到了这一步。前面我们学了socket、多线程、IO复用、协议解析……说实话,这些知识点单独拎出来都不难。但把它们串起来,做成一个真正能跑的东西——嗯,这才是网络编程最有意思的地方。
这一章,我带你做两个实战项目:一个简易HTTP服务器,一个多人在线聊天室。你不需要两个都做,选一个你感兴趣的就行。我个人建议先做HTTP服务器,因为它的逻辑更清晰,调试也方便——你打开浏览器就能看到效果。
核心目标:用纯C语言,基于socket API,实现一个能处理多个客户端请求的服务端程序。不依赖任何第三方库,全部手写。
30.1 项目一:简易HTTP服务器
HTTP服务器,说白了就是监听80端口,收到浏览器的请求后,把对应的文件返回回去。我当年第一次写的时候,以为要处理什么复杂的协议,结果一看HTTP报文——其实就是几行文本。
30.1.1 核心流程
- 创建socket,绑定端口,监听
- 接受客户端连接
- 读取HTTP请求行(比如
GET /index.html HTTP/1.1) - 解析请求路径,找到对应的文件
- 读取文件内容,构造HTTP响应
- 发送响应,关闭连接
你看,就这么几步。但要注意,浏览器发来的请求不止一行,还有一堆请求头。我们这里只解析第一行就够了,剩下的直接忽略——简易版嘛。
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 架构设计
我画了一张图,帮你理清思路:
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_detach或pthread_join,否则会泄漏资源。 - 大文件传输:HTTP服务器里如果文件很大,一次
read可能读不完,要用循环读取。
我的习惯:写网络程序时,我会在关键位置加日志打印。比如「收到连接」「发送响应」「客户端断开」。这样出问题时,看一眼日志就知道卡在哪一步了。
30.5 扩展思考
做完这两个项目,你可以试着给它们加点功能:
- HTTP服务器:支持POST请求、支持CGI脚本、支持虚拟主机
- 聊天室:私聊功能、房间功能、消息历史记录
说实话,网络编程的核心就是「连接管理」和「数据收发」。你掌握了这两个,再复杂的系统也不过是往上堆业务逻辑而已。
好了,动手写吧。代码跑起来的那一刻,你会觉得前面学的所有知识都值了。