
简介Http-server 是一套基于 C 的异步 HTTP 服务器库采用 epoll 事件驱动与 C20 协程构建面向具备一定 C 基础、希望深入理解高性能网络编程与协程实践的开发者。它可用于搭建轻量级 Web 服务仓库内还附带一个便笺共享示例应用帮助读者把服务端路由、请求校验与前端交互串成完整链路。资源包共 50 个文件以 18 个 h 头文件与 2 个 cpp 源文件构成核心实现辅以 14 个 js、1 个 html、1 个 css 等前端资源另有 txt、json、md 说明文档及 CMake、yml、sh 等构建脚本压缩包约 56KB结构紧凑。代码示例展示了通过 Validators 对请求体做长度校验、以 json 指针定位字段等用法便于理解接口设计与参数约束。目前已有 197 人学习下载适合作为协程网络编程的练手项目与参考实现。1. 从零手写一个 http-server为什么我不建议你直接抄开源代码很多人第一次接触网络编程都是从“写一个能返回 Hello World 的 HTTP 服务器”开始的。你搜http-server能搜到一堆 Node.js 的 npm 包、Python 的http.server模块甚至 Go 标准库里的net/http。但如果你正在学 C或者想搞明白 HTTP 协议到底怎么在 TCP 之上跑起来直接调库其实跳过了最值钱的部分——那些bind、listen、accept、recv、send的调用顺序那些 HTTP 报文头的解析边界那些阻塞与非阻塞的切换时机。这个项目就是一个用 C 从零实现的轻量级 HTTP 服务器不依赖任何 Web 框架只靠 POSIX socket API 和标准库。它适合两类人一是刚学完 C 语法、想找个能跑起来的网络项目练手的二是写了几年业务代码、但对“请求怎么从浏览器到达你的处理函数”一直模模糊糊的。我拆过不少类似的源码包这个项目的价值不在于性能多强而在于它把 HTTP 协议的黑匣子拆成了你能一行行读懂的代码。2. 动手之前C http-server 的架构选型与核心模块拆解2.1 为什么选阻塞 IO 多线程而不是 epoll 或 io_uring你拿到一个 C 的 http-server 源码包第一件事应该是看它用什么 IO 模型。常见的有三种阻塞 IO 配多线程、非阻塞 IO 配 epollLinux或 kqueueBSD、以及更现代的 io_uring。这个项目选的是第一种原因很实际——代码可读性优先。阻塞 IO 下accept返回一个连接 fd直接丢给一个线程去recv和send逻辑是线性的新手能跟下来。epoll 版本虽然能撑更多并发但事件循环、状态机、缓冲区管理一上来代码量翻三倍调试难度也翻三倍。我一般会建议如果你是要理解 HTTP 协议本身阻塞 IO 足够了如果你是要做压测对比或者真上生产那再去看 epoll 版本。这个项目的线程模型是“主线程 accept 工作线程池处理”线程池大小在配置文件里可调默认是 4。注意这不是 Reactor 模式每个连接独占一个线程直到关闭所以并发连接数受线程数限制。对于学习项目来说这个取舍是合理的。2.2 目录结构与关键类职责一个典型的 C http-server 源码包目录结构不会太复杂。我拆过的版本里通常包含这几个部分src/main.cpp入口解析命令行参数初始化服务器启动事件循环。src/Server.cpp/Server.h核心类负责 socket 创建、bind、listen、accept以及线程池调度。src/HttpParser.cpp/HttpParser.hHTTP 请求解析器把原始字节流解析成 method、path、headers、body。src/HttpResponse.cpp/HttpResponse.h响应构造器支持设置状态码、头部、body并序列化成字节流。src/ThreadPool.cpp/ThreadPool.h一个简单的线程池用条件变量和任务队列实现。src/Utils.cpp/Utils.h工具函数比如字符串分割、URL 解码、MIME 类型映射。config/server.conf配置文件指定监听端口、线程数、静态文件根目录、日志级别。www/静态文件目录放 index.html、css、js 等。CMakeLists.txt构建脚本。这个划分的好处是职责清晰。Server只管网络连接HttpParser只管协议解析HttpResponse只管响应构造。你改任何一块都不会牵一发动全身。我见过一些新手写的版本把所有逻辑塞在main.cpp里三百行之后自己都找不到recv在哪。2.3 编译环境与依赖确认这个项目只依赖 C11 标准库和 POSIX 线程库不需要额外装 Boost 或任何第三方库。在 Ubuntu 20.04 或 CentOS 7 以上版本直接装build-essential和cmake就行。Windows 下需要 WSL 或者 MinGW但原生 Windows socket 和 POSIX socket 有差异源码里如果用了sys/socket.h和unistd.h在 Windows 上编译会报错。所以建议在 Linux 环境或者 WSL 里跑。macOS 也能编译但要注意SO_REUSEPORT的行为和 Linux 不完全一致。# 安装编译工具链Ubuntu/Debian sudo apt update sudo apt install -y build-essential cmake git # 检查 cmake 版本要求 3.10 以上 cmake --version # 检查 g 是否支持 C11 g --version这三条命令跑完如果cmake版本低于 3.10需要手动升级。g版本建议 7.0 以上否则某些 C11 特性比如std::thread的某些重载可能有问题。确认完环境就可以进入下一步了。3. 从编译到跑通http-server 的构建、配置与请求验证3.1 用 CMake 构建并处理常见编译错误拿到源码包后先看CMakeLists.txt里的cmake_minimum_required和set(CMAKE_CXX_STANDARD 11)。如果标准写的是 11但你系统默认 g 版本太老可能会报‘thread’ is not a member of ‘std’。解决办法是在CMakeLists.txt里显式指定编译器路径或者升级 g。# 进入项目根目录 cd http-server # 创建构建目录保持源码目录干净 mkdir build cd build # 生成 Makefile-DCMAKE_BUILD_TYPERelease 开启优化 cmake .. -DCMAKE_BUILD_TYPERelease # 编译-j4 表示用 4 个核并行编译 make -j4如果cmake报找不到Threads需要在CMakeLists.txt里加find_package(Threads REQUIRED)和target_link_libraries(http-server Threads::Threads)。这个坑很常见因为std::thread在 Linux 上需要链接pthread。编译成功后build/目录下会生成一个可执行文件名字通常是http-server或者server。3.2 配置文件参数详解与修改建议配置文件config/server.conf是键值对格式用分隔。我拆过的版本里关键参数有这些参数名默认值含义调整建议port8080监听端口改成 80 需要 root 权限建议保持 8080thread_num4工作线程数压测时改成 CPU 核数 × 2root_dir./www静态文件根目录用绝对路径避免相对路径歧义log_levelinfo日志级别调试时改debug生产改warnkeep_alivetrue是否支持长连接学习阶段可关掉简化连接管理max_body_size1048576请求体最大字节数上传大文件时调大但注意内存改端口的时候注意1024 以下需要 root 权限普通用户跑会报Permission denied。root_dir如果写相对路径服务器启动时的当前工作目录会影响结果所以建议改成绝对路径比如/home/user/http-server/www。keep_alive打开后一个 TCP 连接可以处理多个请求但你的recv循环要正确处理Connection: close头否则浏览器会一直等。3.3 启动服务器并用 curl 验证 GET/POST配置改好后直接运行# 前台运行日志输出到终端 ./http-server -c ../config/server.conf # 或者后台运行日志重定向到文件 nohup ./http-server -c ../config/server.conf server.log 21 启动后用curl发请求验证。先测 GET# -v 显示详细请求响应头-I 只拿头部 curl -v http://127.0.0.1:8080/index.html # 测试 404 curl -v http://127.0.0.1:8080/not-exist.html再测 POST# 发送 JSON 数据 curl -v -X POST http://127.0.0.1:8080/api/echo \ -H Content-Type: application/json \ -d {name:test,value:123}如果服务器返回200 OK并且 body 正确说明基本链路通了。如果返回400 Bad Request大概率是HttpParser在解析请求行或头部时遇到了不符合预期的格式。常见原因是curl默认发的User-Agent头里有特殊字符而解析器没做转义处理。这时候去看server.log里的 debug 日志一般会打印出原始请求报文对照着看哪一行解析失败了。提示如果curl卡住不返回先检查防火墙是否拦了 8080 端口再用netstat -tlnp | grep 8080确认服务器确实在监听。4. 避坑指南http-server 调试中常见的五个翻车现场4.1 端口被占用导致 bind 失败现象启动时报bind: Address already in use程序直接退出。原因上一次运行的进程没杀干净或者系统里别的服务占了 8080。解决用lsof -i:8080或netstat -tlnp | grep 8080找到占用进程kill -9掉。如果不想每次手动杀可以在代码里设置SO_REUSEADDR选项允许绑定处于TIME_WAIT状态的端口。这个选项在setsockopt里加一行就行但注意它不能解决“另一个进程正在监听”的情况。4.2 请求体读取不完整导致 JSON 解析失败现象POST 请求返回400日志显示 body 只有前半截。原因recv一次不一定能读完整个请求体尤其是 body 超过 TCP 缓冲区大小时。很多新手写的解析器只调一次recv就当成完整请求。解决先读头部从Content-Length头里拿到 body 长度然后循环recv直到读满这个长度。注意Content-Length是十进制字符串要用std::stoi转成整数。如果用了Transfer-Encoding: chunked那更复杂需要按 chunk 解析学习项目一般不支持这个。4.3 静态文件路径穿越漏洞现象请求GET /../../etc/passwd竟然返回了系统文件内容。原因拼接文件路径时没做规范化直接用了root_dir path。解决在拼接之前先用realpath把路径转成绝对路径然后检查这个绝对路径是否以root_dir的绝对路径开头。如果不是直接返回403 Forbidden。这个坑血泪经验很多开源的小型 http-server 都栽在这里。4.4 多线程下日志输出交错现象日志文件里两行日志粘在一起比如[INFO] accept[INFO] recv。原因多个线程同时往std::cout或日志文件写没有加锁。解决给日志函数加一个std::mutex每次写之前lock_guard一下。或者用线程安全的日志库但学习项目自己加锁就够了。注意锁的粒度要小别把整个请求处理都锁住。4.5 浏览器发来的请求带多余空行导致解析死循环现象用浏览器访问正常用curl正常但用某些 HTTP 客户端工具时服务器 CPU 跑满。原因解析器在找\r\n\r\n时如果recv返回 0连接关闭但缓冲区里没有完整头部代码进入了while(true)循环。解决在循环里判断recv返回值如果小于等于 0直接 break 并关闭连接。另外给解析器加一个最大头部长度限制比如 8KB超过就返回431 Request Header Fields Too Large。5. 进阶技巧用 wrk 压测并观察线程池瓶颈跑通之后别急着关掉。我一般会做一轮压测看看这个 http-server 在并发下的真实表现。用wrk比ab更准因为它支持多线程和长连接。# 安装 wrkUbuntu sudo apt install -y wrk # 压测4 线程100 并发持续 30 秒 wrk -t4 -c100 -d30s http://127.0.0.1:8080/index.html跑完之后看Requests/sec和Latency分布。如果Requests/sec很低比如几百而Latency的Stdev很大说明线程池太小或者锁竞争严重。把thread_num从 4 改成 8 再跑一次对比数据。如果提升不明显瓶颈可能在accept那一层——主线程只有一个accept是串行的。这时候可以考虑把accept也放到线程池里或者用SO_REUSEPORT让多个线程各自bind同一个端口内核会做负载均衡。另一个技巧是观察keep_alive打开和关闭时的差异。关闭时每个请求都要经历 TCP 三次握手和四次挥手Requests/sec会明显下降。打开时连接复用但要注意服务器端要设置一个超时时间比如 5 秒没新请求就主动关闭否则连接会一直占着线程。// 在 Server.cpp 里设置 socket 超时 struct timeval tv; tv.tv_sec 5; // 5 秒 tv.tv_usec 0; setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));这段代码放在accept返回之后、丢给线程池之前。SO_RCVTIMEO会让recv在超时后返回 -1 并设置errno为EAGAIN或EWOULDBLOCK你在recv循环里判断这个错误码然后关闭连接。注意这个超时是每次recv调用的超时不是整个连接的生命周期超时。如果你要实现“空闲 5 秒关闭”需要在应用层记录最后一次活动时间。从那以后我每次拆这类网络项目都会先跑一轮wrk把thread_num和keep_alive两个参数交叉调一遍心里有个底再去看代码。希望帮到你。本文还有配套的精品资源点击获取