30、综合实战:高性能Web服务器:从零实现一个支持HTTP/1.1、WebSocket、静态文件服务、动态请求处理的高性能Web服务器。
终于到了最后一章。说实话,写到这里我自己都有点感慨。前面29个例子,我们从最基础的socket通信,一路走到epoll、线程池、协议解析、异步IO……现在,是时候把它们全部串起来了。
这一章,我们要从零实现一个真正能用的高性能Web服务器。它要支持HTTP/1.1持久连接、WebSocket升级、静态文件缓存、动态请求路由。嗯,听起来挺唬人的,但拆开来看,每个模块你都已经学过了。
我个人习惯把这种综合项目叫做「搭积木」。你想想看,前面每一章都是一块积木,现在我们要把它们拼成一个完整的城堡。
整体架构设计
先画一张架构图,让你对整体有个感觉。我当年第一次做这种项目时,就是先画图再写代码,少走了很多弯路。
核心数据结构
写服务器,数据结构是灵魂。我见过太多人一上来就写业务逻辑,结果后面改结构改到崩溃。先想清楚数据怎么组织,代码自然就顺了。
// 连接上下文:每个客户端连接对应一个
typedef struct {
int fd; // socket fd
char rbuf[8192]; // 读缓冲区
int rlen; // 已读数据长度
char wbuf[65536]; // 写缓冲区
int wlen; // 待写数据长度
int state; // 0:解析请求头 1:解析body 2:处理完成
http_request_t req; // 解析后的请求
http_response_t resp; // 待发送的响应
int keep_alive; // 是否保持连接
int ws_upgraded; // 是否已升级为WebSocket
} connection_t;
// HTTP请求结构
typedef struct {
char method[16]; // GET/POST/...
char path[1024]; // /index.html
char query[1024]; // ?name=xxx
char version[16]; // HTTP/1.1
kv_header_t headers[64]; // 请求头
int header_count;
char body[65536]; // 请求体
int body_len;
} http_request_t;
// 文件缓存节点(LRU链表)
typedef struct cache_node {
char path[1024];
char *data;
int data_len;
time_t mtime; // 文件修改时间,用于缓存失效
struct cache_node *prev;
struct cache_node *next;
} cache_node_t;
HTTP/1.1 持久连接实现
HTTP/1.1 默认就是 keep-alive。说白了,就是一次TCP连接可以发多个请求,不用每次重新握手。实现起来其实不复杂,关键就两点:
- 解析完一个请求后,不要关闭连接,继续等待下一个请求
- 响应头里带上
Connection: keep-alive,告诉客户端我还活着
// 事件循环核心代码(简化版)
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
connection_t *conn = (connection_t *)events[i].data.ptr;
if (events[i].events & EPOLLIN) {
// 读取数据
int ret = read(conn->fd, conn->rbuf + conn->rlen,
sizeof(conn->rbuf) - conn->rlen);
if (ret <= 0) {
close_connection(conn);
continue;
}
conn->rlen += ret;
// 尝试解析HTTP请求
if (parse_http_request(conn) == 0) {
// 解析成功,处理请求
handle_request(conn);
// 注意:这里不关闭连接,继续等待下一个请求
conn->rlen = 0; // 重置缓冲区
// 重新注册EPOLLIN事件
epoll_ctl(epfd, EPOLL_CTL_MOD, conn->fd,
&(struct epoll_event){.events=EPOLLIN, .data.ptr=conn});
}
}
if (events[i].events & EPOLLOUT) {
// 发送响应数据
int ret = write(conn->fd, conn->wbuf, conn->wlen);
if (ret > 0) {
conn->wbuf += ret;
conn->wlen -= ret;
}
if (conn->wlen == 0) {
// 数据发送完毕
if (!conn->keep_alive) {
close_connection(conn);
} else {
// 重新等待下一个请求
epoll_ctl(epfd, EPOLL_CTL_MOD, conn->fd,
&(struct epoll_event){.events=EPOLLIN, .data.ptr=conn});
}
}
}
}
}
WebSocket 升级握手
WebSocket 的握手其实就是一次特殊的HTTP请求。客户端发一个 Upgrade: websocket 的请求,服务器算出一个 Sec-WebSocket-Accept 返回,就算握手成功了。
计算方式很简单:把客户端发来的 Sec-WebSocket-Key 加上一个固定魔数 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,然后做SHA1哈希,再Base64编码。
// WebSocket 握手响应生成
void ws_handshake(connection_t *conn) {
// 找到客户端发来的key
char *client_key = get_header(conn->req.headers, "Sec-WebSocket-Key");
if (!client_key) {
send_400(conn);
return;
}
// 拼接魔数
char combined[256];
snprintf(combined, sizeof(combined), "%s%s",
client_key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
// SHA1哈希
unsigned char sha1_out[20];
SHA1(combined, strlen(combined), sha1_out);
// Base64编码
char accept_key[64];
base64_encode(sha1_out, 20, accept_key, sizeof(accept_key));
// 构造101响应
snprintf(conn->wbuf, sizeof(conn->wbuf),
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Accept: %s\r\n\r\n",
accept_key);
conn->wlen = strlen(conn->wbuf);
conn->ws_upgraded = 1;
// 注册EPOLLOUT事件准备发送
epoll_ctl(epfd, EPOLL_CTL_MOD, conn->fd,
&(struct epoll_event){.events=EPOLLOUT, .data.ptr=conn});
}
静态文件服务与缓存
静态文件服务,说白了就是把磁盘上的文件读出来发给客户端。但高性能服务器不能每次都读磁盘,得加缓存。
我用了LRU(最近最少使用)缓存策略。文件第一次请求时读入内存,后续请求直接从缓存返回。缓存满了就淘汰最久没被访问的文件。
// 静态文件处理 + LRU缓存
void serve_static_file(connection_t *conn, const char *path) {
// 安全检查:防止路径穿越攻击
if (strstr(path, "..") != NULL) {
send_403(conn);
return;
}
// 构造实际文件路径
char filepath[1024];
snprintf(filepath, sizeof(filepath), "./wwwroot%s", path);
// 先查缓存
cache_node_t *node = cache_lookup(filepath);
if (node) {
// 检查文件是否被修改
struct stat st;
stat(filepath, &st);
if (st.st_mtime == node->mtime) {
// 缓存命中,直接返回
send_response(conn, 200, "text/html", node->data, node->data_len);
return;
}
// 文件已修改,删除旧缓存
cache_remove(node);
}
// 缓存未命中,读磁盘
int fd = open(filepath, O_RDONLY);
if (fd < 0) {
send_404(conn);
return;
}
struct stat st;
fstat(fd, &st);
char *data = malloc(st.st_size);
read(fd, data, st.st_size);
close(fd);
// 加入缓存
cache_add(filepath, data, st.st_size, st.st_mtime);
// 发送响应
send_response(conn, 200, "text/html", data, st.st_size);
}
动态请求处理
动态请求,就是需要服务器执行一些逻辑再返回结果。比如处理表单提交、查询数据库等。我这里实现了一个简单的路由表,根据请求路径分发到不同的处理函数。
// 路由表定义
typedef void (*request_handler_t)(connection_t *conn);
typedef struct {
const char *method; // GET/POST/PUT/DELETE
const char *path; // /api/user/:id
request_handler_t handler;
} route_t;
route_t routes[] = {
{"GET", "/api/hello", handle_hello},
{"GET", "/api/time", handle_time},
{"POST", "/api/echo", handle_echo},
{"GET", "/api/user/*", handle_user_info},
{NULL, NULL, NULL} // 结束标记
};
// 路由分发
void route_request(connection_t *conn) {
for (int i = 0; routes[i].method != NULL; i++) {
if (strcmp(conn->req.method, routes[i].method) == 0) {
if (match_path(conn->req.path, routes[i].path)) {
routes[i].handler(conn);
return;
}
}
}
// 没有匹配的路由
send_404(conn);
}
// 一个简单的动态处理函数示例
void handle_time(connection_t *conn) {
time_t now = time(NULL);
char *time_str = ctime(&now);
// 去掉换行符
time_str[strlen(time_str)-1] = '\0';
char body[256];
snprintf(body, sizeof(body),
"{\"time\": \"%s\", \"timestamp\": %ld}",
time_str, now);
send_response(conn, 200, "application/json", body, strlen(body));
}
线程池与任务分发
虽然epoll是单线程的,但有些操作(比如读大文件、执行复杂计算)会阻塞事件循环。我的做法是:把耗时任务丢给线程池,事件循环只负责I/O。
// 线程池任务结构
typedef struct {
void (*func)(void *arg);
void *arg;
} task_t;
// 提交耗时任务到线程池
void submit_task(void (*func)(void *), void *arg) {
task_t *task = malloc(sizeof(task_t));
task->func = func;
task->arg = arg;
pthread_mutex_lock(&pool.lock);
queue_push(&pool.queue, task);
pthread_cond_signal(&pool.cond);
pthread_mutex_unlock(&pool.lock);
}
// 工作线程函数
void *worker_thread(void *arg) {
while (1) {
pthread_mutex_lock(&pool.lock);
while (queue_empty(&pool.queue)) {
pthread_cond_wait(&pool.cond, &pool.lock);
}
task_t *task = queue_pop(&pool.queue);
pthread_mutex_unlock(&pool.lock);
// 执行任务
task->func(task->arg);
free(task);
}
return NULL;
}
完整请求处理流程
最后,把整个流程串起来。一个请求从进来到离开,大致经过以下步骤:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | epoll_wait 返回可读事件 | 新连接或已有连接发来数据 |
| 2 | 读取数据到连接缓冲区 | 非阻塞read,一次读不完下次继续 |
| 3 | 解析HTTP请求 | 解析请求行、请求头、body |
| 4 | 判断是否为WebSocket升级 | 如果是,执行握手并切换协议 |
| 5 | 路由分发 | 匹配路由表,决定由谁处理 |
| 6 | 处理请求 | 静态文件走缓存,动态请求走处理函数 |
| 7 | 构造响应 | 写入写缓冲区 |
| 8 | epoll_wait 返回可写事件 | 发送响应数据 |
| 9 | 判断是否keep-alive | 是则等待下一个请求,否则关闭连接 |
性能调优要点
写完了能跑,和写得好能扛住高并发,中间差着十万八千里。这里分享几个我实际调优的经验:
- 零拷贝: 静态文件用
sendfile()系统调用,数据直接从内核缓冲区送到网卡,不经过用户空间。我试过,大文件传输性能提升30%以上。 - 内存池: 连接对象、缓冲区、缓存节点都用内存池管理,避免频繁malloc/free。服务器跑几天后,内存碎片会少很多。
- 定时器: 用最小堆管理空闲连接超时。长时间没活动的连接要主动关闭,不然文件描述符会被耗尽。我一般设30秒超时。
- 优雅关闭: 收到SIGINT或SIGTERM信号时,先停止接受新连接,等待已有连接处理完毕,再释放资源退出。这能避免数据丢失。
好了,这一章的内容就到这里。从socket到epoll,从HTTP解析到WebSocket握手,从静态缓存到动态路由,我们一步步搭起了一个完整的Web服务器。说实话,能坚持看到这里,你已经具备了独立开发高性能网络程序的能力。以后遇到类似的项目,你心里应该有个谱了。
代码写完了,别忘了跑起来试试。用浏览器访问一下,用WebSocket客户端连一下,用压测工具轰一下。看到自己的服务器稳稳地处理着成千上万的请求,那种成就感,嗯,只有做过的人才知道。