项目实战三:简易HTTP服务器(socket模块、请求解析模块、响应模块)
说实话,HTTP服务器这个项目,是我当年从嵌入式转网络编程时第一个真正练手的作品。那时候我还在做物联网网关,设备上报数据用的是私有协议,后来客户要求必须支持HTTP,我硬着头皮用C语言撸了一个微型服务器。嗯,踩了不少坑,但也真正理解了什么叫「协议栈的分层设计」。
今天这个实战,咱们不搞复杂的epoll、不搞线程池,就聚焦在三个核心模块上:socket通信、请求解析、响应组装。你想想看,一个HTTP服务器再花哨,底层不就是这三板斧吗?
核心目标:用C语言实现一个单线程、阻塞式的简易HTTP服务器,能够接收GET请求,解析请求行和头部,返回静态文件或404页面。
一、整体架构设计
我习惯在动手写代码之前,先画一张模块关系图。这样能避免写到一半发现「哎呀,这个函数该放哪个文件里」的尴尬。
你看,这个架构图里,main.c只负责串联流程,真正的业务逻辑全部分散到三个模块中。我在做第一个版本时,把所有代码塞进一个文件,结果调试时找函数定义找得想砸键盘。后来老老实实拆文件,世界清净了。
二、socket模块:最底层的管道
socket模块说白了就是建立TCP连接。HTTP基于TCP,所以你得先学会用socket API把管道搭起来。
我个人习惯把socket的创建、绑定、监听、接受连接这四个步骤封装成两个函数:
// socket_server.h
#ifndef SOCKET_SERVER_H
#define SOCKET_SERVER_H
int server_init(int port); // 创建、绑定、监听
int server_accept(int server_fd); // 接受客户端连接,返回客户端fd
#endif
// socket_server.c
#include "socket_server.h"
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
int server_init(int port) {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return -1;
}
// 设置SO_REUSEADDR,避免服务器重启时"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_addr.s_addr = INADDR_ANY; // 监听所有网络接口
addr.sin_port = htons(port);
if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
close(server_fd);
return -1;
}
if (listen(server_fd, 5) < 0) { // 最大等待队列长度5
perror("listen");
close(server_fd);
return -1;
}
printf("Server listening on port %d...\n", port);
return server_fd;
}
int server_accept(int server_fd) {
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");
return -1;
}
printf("New client connected: %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
return client_fd;
}
避坑指南:我曾经在调试时发现服务器第二次启动就报错,查了半天发现是没设置SO_REUSEADDR。TCP的TIME_WAIT状态会让端口被占用几分钟,不设置这个选项,你每次改完代码重启服务器都得等。嗯,血的教训。
三、请求解析模块:把字节流变成结构化数据
客户端发来的HTTP请求是一串文本,比如:
GET /index.html HTTP/1.1
Host: localhost:8080
User-Agent: curl/7.68.0
我们需要把它解析成结构体,方便后续处理。我设计了一个简单的请求结构体:
// http_parser.h
#ifndef HTTP_PARSER_H
#define HTTP_PARSER_H
#define MAX_METHOD_LEN 8
#define MAX_PATH_LEN 256
#define MAX_HEADER_LEN 1024
typedef struct {
char method[MAX_METHOD_LEN]; // GET, POST等
char path[MAX_PATH_LEN]; // /index.html
char version[16]; // HTTP/1.1
char headers[MAX_HEADER_LEN]; // 原始头部,简单处理
} http_request_t;
int parse_request(int client_fd, http_request_t *req);
#endif
解析函数的实现,我用了最简单的逐行读取方式。为什么不用复杂的正则?因为嵌入式环境资源有限,而且对于GET请求,我们只需要前两行就够了。
// http_parser.c
#include "http_parser.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int parse_request(int client_fd, http_request_t *req) {
char buf[2048];
int n = read(client_fd, buf, sizeof(buf) - 1);
if (n <= 0) return -1;
buf[n] = '\0';
// 解析请求行:GET /index.html HTTP/1.1
sscanf(buf, "%s %s %s", req->method, req->path, req->version);
// 简单校验:只支持GET
if (strcmp(req->method, "GET") != 0) {
printf("Unsupported method: %s\n", req->method);
return -1;
}
// 如果路径是"/",默认返回index.html
if (strcmp(req->path, "/") == 0) {
strcpy(req->path, "/index.html");
}
// 复制头部(实际项目中需要更精细的解析)
char *header_start = strstr(buf, "\r\n");
if (header_start) {
strncpy(req->headers, header_start + 2, MAX_HEADER_LEN - 1);
}
printf("Parsed request: %s %s\n", req->method, req->path);
return 0;
}
注意:这个解析器非常简陋,它假设一次read就能读完整个请求。实际HTTP请求可能分多次到达,需要做缓冲区管理。但在教学项目中,我们先用这种「一次读完」的简化方式,让你先理解核心逻辑。
四、响应模块:组装HTTP响应报文
响应模块的任务是根据请求路径,读取本地文件,然后组装成HTTP响应发回去。HTTP响应的格式是:
HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Content-Length: 123\r\n
\r\n
[文件内容]
我习惯把响应头构建和文件发送分开,这样逻辑更清晰:
// http_response.h
#ifndef HTTP_RESPONSE_H
#define HTTP_RESPONSE_H
int send_response(int client_fd, const char *file_path);
#endif
// http_response.c
#include "http_response.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
int send_response(int client_fd, const char *file_path) {
// 尝试打开文件
int file_fd = open(file_path + 1, O_RDONLY); // 去掉开头的'/'
if (file_fd < 0) {
// 文件不存在,返回404
const char *not_found =
"HTTP/1.1 404 Not Found\r\n"
"Content-Type: text/html\r\n"
"Content-Length: 48\r\n"
"\r\n"
"<html><body><h1>404 Not Found</h1></body></html>";
write(client_fd, not_found, strlen(not_found));
return -1;
}
// 获取文件大小
struct stat st;
fstat(file_fd, &st);
int file_size = st.st_size;
// 构建响应头
char header[256];
snprintf(header, sizeof(header),
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html\r\n"
"Content-Length: %d\r\n"
"\r\n",
file_size);
write(client_fd, header, strlen(header));
// 发送文件内容
char buf[1024];
int n;
while ((n = read(file_fd, buf, sizeof(buf))) > 0) {
write(client_fd, buf, n);
}
close(file_fd);
return 0;
}
一个小技巧:我在实际项目中,会把Content-Type做成映射表,根据文件后缀自动选择。比如.html对应text/html,.css对应text/css。这样服务器就能同时支持静态网页和样式文件了。
五、主程序:把模块串起来
最后,main.c负责调用这三个模块,形成完整的处理循环:
// main.c
#include "socket_server.h"
#include "http_parser.h"
#include "http_response.h"
#include <stdio.h>
#include <unistd.h>
#define PORT 8080
int main() {
int server_fd = server_init(PORT);
if (server_fd < 0) {
return -1;
}
printf("HTTP Server running on http://localhost:%d\n", PORT);
while (1) {
int client_fd = server_accept(server_fd);
if (client_fd < 0) continue;
http_request_t req;
if (parse_request(client_fd, &req) == 0) {
send_response(client_fd, req.path);
}
close(client_fd);
printf("Connection closed.\n");
}
close(server_fd);
return 0;
}
你看,整个主程序只有不到30行。每个模块各司其职,main.c只负责「接受连接→解析请求→发送响应→关闭连接」这个循环。这就是模块化编程的魅力——每个文件都很短,但组合起来能完成一个完整的网络服务。
六、编译与运行
编译时记得把所有.c文件都加上:
gcc -o http_server main.c socket_server.c http_parser.c http_response.c
./http_server
然后在浏览器访问 http://localhost:8080/index.html,前提是你当前目录下有一个index.html文件。
扩展思考:这个服务器目前只能处理一个请求,因为它是单线程阻塞的。如果你想支持并发,可以引入多线程或select/poll。但那是下一阶段的事了。先把单线程跑通,理解HTTP协议的本质,比什么都重要。
好了,这个实战项目就到这里。代码量不大,但涵盖了socket编程、字符串解析、文件I/O、HTTP协议这几个嵌入式网络编程的核心知识点。你把它跑通之后,可以试着加一个POST方法的支持,或者加一个简单的路由表。相信我,当你看到浏览器里显示出你自己服务器返回的页面时,那种成就感——嗯,值得的。
公众号:蓝海资料掘金营,微信deep3321