ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写HTTP服务:从空目录到可用的原始方案

手写HTTP服务:从空目录到可用的原始方案 caveman —— 第一次看到这个代号我以为是哪个团队在脑暴复古风格的技能树命名单脑子里浮现的是石斧、兽皮和钻木取火。结果 README 第一行写得很直接本项目不依赖任何框架不引入第三方库从空目录开始手写一个足够日常使用的 HTTP 服务。看完我反而来了兴趣。在这个脚手架满天飞、一个create命令能拉出一整套全家桶的时代敢给自己项目起名穴居人的团队到底想干什么这篇就聊聊我参与这个caveman项目前后的一些体会。它不是那种追求大而全的中间件框架而是刻意回到最原始的方式用标准库和手写代码把 HTTP 服务最核心的骨架搭出来解析请求、路由分发、并发处理、优雅关闭一样不缺但一样不靠魔法。适合两类人看第一类是写业务写久了、对框架底层一知半解的开发者想看看一个请求从网线到业务函数之间到底发生了什么第二类是手里有小型内部工具、不想为一个小功能引入上 GB 依赖的同学这类原始方案往往比大框架轻快得多。我把整个项目从思路到落地、再到踩坑排查的过程完整拆开尽量用说人话的方式讲清楚每个关键决策背后的原因附上可以直接抄作业的代码和验证方法。1. 先聊聊caveman到底想解决什么问题1.1 框架越强developer 越瞎现在很多 Web 项目开发者写完业务代码就等于完成工作至于请求怎么被解析、路由怎么匹配、连接池怎么管理、超时怎么判断全是黑盒。用框架没有错但问题在于框架处理了 90% 的通用问题剩下的 10% 需要定制时很多人完全不知道怎么下手因为缺少对底层机制的感知。这套caveman项目的设计动机不是号召大家扔掉框架而是用一次手搓核心轮子的过程把黑盒打开。团队里几位同学日常是写业务接口的对RequestMapping、GetMapping这些注解非常熟悉但当我问你知道请求行第一行结尾是 CRLF 还是 LF你知道 TCP 粘包和半包是什么意思的时候空气明显安静了几秒。项目代号起名为caveman就是定位这个目标用最原始的手段、最少的外物把这层窗户纸捅破。1.2 为什么极简不等于简陋提到手写服务很多人第一反应是那还不一堆 bug性能能看吗刻意不引入第三方库不等于把工程质量也降级。caveman项目给自己划了几条清晰的设计边界不依赖任何 Web 框架如 Gin、Spring Boot、Express 等但可以使用语言自带的 SDK 和标准库。不追求完整的 HTTP/2、HTTPS 握手、WebSocket 等复杂能力只把 HTTP/1.1 的主干流程做扎实。不造性能轮子但必须保证在中小流量场景下稳定可靠单机扛住每秒几百上千个请求没有问题。不写死业务逻辑提供通用的路由注册和中间件扩展点让这个骨架是活的东西不只是教学玩具。极简的本质是砍掉不重要的复杂度而不是砍掉必要的健壮性。HTTP 解析、并发模型、超时控制、优雅退出这些怎么都躲不开恰恰是caveman项目里最花功夫的部分。1.3 语言与基础选型的权衡项目最初其实纠结过用 Go 还是 Python。Go 在网络编程上有天然优势goroutine 轻量、标准库 net 包层次低、内存占用可控而且编译产物就一个二进制部署非常原始、非常穴居人——扔服务器上就能跑不用装环境。Python 写起来更短socket上手零门槛但高并发要靠多进程/协程复杂度并不小。最后选了 Go。原因有三个net.Listen和net.Conn之间几乎没有魔法你能看到最原始的字节流读写。标准库bufio、strings、sync足够实现一个像样的 HTTP 解析和并发调度不需要引额外依赖。goroutine 能让代码保持同步阻塞的写法却拥有不错的并发能力理解成本比异步回调低很多。后面所有的代码示例也都是 Go版本用的 1.22 以上因为内置了增强的 ServeMux但我们不用自己写路由也是为了看清本质。2. 核心细节解析与实操要点2.1 从 socket 开始你真正面对的是什么现代框架把网络细节藏得太深导致很多人以为一个 HTTP 请求是整包到达业务层的。实际上你从底层拿到的只是一条已经建立的 TCP 连接一个持续不断的字节流。客户端发送的报文会拆成多个 TCP segment 到达也可能多个小请求合并到一个包里到达前者叫半包后者叫粘包。caveman项目第一课就是用net.Listen拿到一个监听器然后Accept一个连接直接读net.Conn上的字节流不做任何处理先看看最原始的 HTTP 报文长什么样。func main() { ln, err : net.Listen(tcp, :8080) if err ! nil { log.Fatal(err) } defer ln.Close() for { conn, err : ln.Accept() if err ! nil { log.Println(err) continue } go handleConn(conn) } } func handleConn(conn net.Conn) { defer conn.Close() // 这里先设置读超时防止死等 conn.SetReadDeadline(time.Now().Add(5 * time.Second)) buf : make([]byte, 1024) n, err : conn.Read(buf) if err ! nil { log.Println(read error:, err) return } fmt.Printf(%q\n, buf[:n]) }用curl -v http://localhost:8080/hello一测控制台会打出一串原始报文第一行是请求行比如GET /hello HTTP/1.1后面是 Header 列表每一行末尾是\r\n。看到这个原始字节流的一瞬间HTTP 协议就不再是抽象概念了。这个环节最容易踩的坑是设置 Deadline 的位置。如果只设置一次读超时后续读同一个连接上的数据比如 keep-alive 的下一个请求时会发现超时一直生效导致长连接直接断裂。正确做法是每次循环读之前重新设置 Deadline这个细节后面在问题排查部分还会展开。2.2 手写 HTTP 解析时绕不开的三大坑很多人以为解析 HTTP 就是把字节流按\r\n切分。真做起来坑不少第一坑请求行在第一行格式是METHOD SP Request-URI SP HTTP-Version CRLF中间的路径里可能带空格URL 编码后一般不会但不要假设不能用简单的Fields一刀切最好拆完动词后单独切空格边界。第二坑Header 解析不能无脑按行读因为 Header 之后可能还有请求体POST 请求需要用Content-Length或Transfer-Encoding: chunked判断请求体到哪里结束。如果我们只做简单 GET 服务可以暂时忽略 body但一旦接收 POST就必须把 body 读完否则下个请求的数据会和这个请求的 body 混在一起造成串包。第三坑Header 的 key 大小写不敏感Content-Length和content-length是同一个东西。很多新手解析时直接map[string]string存收到content-length找不到Content-Lengthbug 就来了。统一用小写 key 或者textproto.CanonicalMIMEHeaderKey处理。下面是一个足够日常使用的解析器简化版特别注意我们用一个自定义结构体而不是随手塞 map方便后续中间件扩展type Request struct { Method string Path string Proto string Headers map[string]string Body []byte } func parseRequest(reader *bufio.Reader) (*Request, error) { req : Request{Headers: make(map[string]string)} // 1. 读请求行注意不要一次读整包因为可能还没发完 line, err : readLine(reader) if err ! nil { return nil, err } parts : strings.SplitN(line, , 3) if len(parts) ! 3 { return nil, fmt.Errorf(malformed request line: %q, line) } req.Method parts[0] req.Path parts[1] req.Proto parts[2] // 2. 读 Header直到空行 for { line, err : readLine(reader) if err ! nil { return nil, err } if line { break } idx : strings.Index(line, :) if idx -1 { continue // 跳过异常行但生产环境应直接报错 } key : strings.ToLower(strings.TrimSpace(line[:idx])) value : strings.TrimSpace(line[idx1:]) req.Headers[key] value } // 3. 如果有 body按 Content-Length 读取 if cl, ok : req.Headers[content-length]; ok { length, _ : strconv.Atoi(strings.TrimSpace(cl)) if length 0 { req.Body make([]byte, length) _, err io.ReadFull(reader, req.Body) if err ! nil { return nil, err } } } return req, nil }readLine需要自己处理粘包——读到的bufio.Reader底层可能有多余字节但这没关系ReadBytes(\n)会从缓冲区里把一行读完但保留未消费的字节下一次继续读即可。这就是为什么用bufio.Reader而不是直接conn.Read来逐行读原始字节的关键。2.3 并发模型goroutine 不是免费的Go 社区有句话不要通过共享内存来通信而应该通过通信来共享内存。但到了底层连接管理caveman项目选择了一个老土但可靠的方式每个连接一个 goroutine。一个 goroutine 初始栈只有几 KB即使有几千个连接同时在线资源占用也可控。真正要小心的是连接数暴涨时goroutine 数量也跟着暴涨这没问题但每个连接如果都长期挂着不读不写比如客户端开了不发数据goroutine 就会白白占着。解决方式是给连接设置合理超时并在空闲超时后主动关闭。另外要注意ln.Accept()返回的net.Conn不是线程安全的不能让多个 goroutine 同时对同一个 conn 做Read和Write否则数据可能交叉混乱。通常做法是每个连接只有一个读循环写操作通过 channel 串行化。简单场景下直接在读循环里处理完请求后同步写响应即可不需要额外的写队列。for { conn, err : ln.Accept() if err ! nil { var ne net.Error if errors.As(err, ne) ne.Timeout() { continue } log.Println(accept error:, err) continue } go handleConn(conn) }Accept 循环里最容易忽略的是临时错误。比如某些操作系统会返回 EMFILE文件描述符耗尽如果直接log.Fatal整个服务就挂了。正确做法是区分可重试的临时错误和致命错误。3. 实操过程从空目录到一个能跑的骨架3.1 第一步搭网络骨架用 Go 从零开始不需要任何第三方依赖。先建一个空目录go mod init caveman然后写一个极简 main.go。基础结构前面已经给了这里补充一个要点监听的地址不要写死localhost因为服务器上测试时可能通过内网 IP 访问写死127.0.0.1会导致外部流量进不来。用:8080表示监听所有网卡。写完 Accept 和 handleConn 后用curl随便发一个请求得到原始报文后先别急着写解析逻辑先在纸上画出字节流走向。我习惯把这个图贴到团队 Wiki 里客户端浏览器发请求 - 操作系统 TCP 协议栈 - 内核缓冲区 - 用户态 socket - bufio.Reader - 我们的解析器 - 路由匹配 - 业务 handler。这套链路图看起来简单但真正把每一层的数据形态搞明白框架里的中间件就不再是玄学。3.2 路由与分发可以土但要好用先用一个简单的 map 存路径到处理函数的映射。注意map 不是并发安全的如果业务 handler 里有人修改路由注册就会 panic。所以注册阶段全部完成后再开服务或者用sync.RWMutex保护。这里我踩过一个坑一次caveman内部联调时有同学在 handler 里动态注册新路由结果间歇性 panic。排查半天最后在 map 写入时加了锁才解决。所以哪怕原始项目并发安全规则一条也不能少。type Handler func(w http.ResponseWriter, r *Request) type Router struct { mu sync.RWMutex routes map[string]Handler } func NewRouter() *Router { return Router{routes: make(map[string]Handler)} } func (rt *Router) Handle(method, path string, h Handler) { rt.mu.Lock() defer rt.mu.Unlock() rt.routes[method path] h } func (rt *Router) Serve(req *Request) Handler { rt.mu.RLock() defer rt.mu.RUnlock() return rt.routes[req.Method req.Path] }路由匹配不做正则不做参数提取因为核心目标不是做一个功能强大的框架而是让小组件每一步都可观测。如果以后需要路径参数只需要把注册的路径拆成段再做通配匹配。响应写法有个小细节写 Content-Length 时要用strconv.Itoa(len(body))并且把 Header 写完后再 write body。如果顺序反了连 HTTP 协议都解析不对浏览器会报错。别笑真的能犯这种错——因为 Go 的 net/http 标准库帮你处理了这些顺序手写时这些细节全都暴露出来。3.3 加上超时与优雅关闭写死一个handleConn里 Read 完就返回连 TCP keep-alive 都支持不了。但真实场景中一个页面通常会发很多请求频繁重建 TCP 连接会增加延迟。所以要实现 keep-alive 特性让同一个连接上可以连续读多个请求直到客户端主动断开或空闲超时。这个环节我强烈建议实现两个超时ReadTimeout每次读取请求前设置。IdleTimeout读完整请求后在下一次读取前等待的时间用于淘汰长时间不活跃的连接。优雅关闭则靠signal.Notify捕获 SIGINT/SIGTERM先停止 Accept 新连接再等待已有连接处理完最后退出。func waitForSignal(ln net.Listener, wg *sync.WaitGroup) { sigCh : make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) -sigCh ln.Close() wg.Wait() }这里有个坑ln.Close()之后正在阻塞的ln.Accept()会立刻返回错误不能把它当异常记录需要用errors.Is(err, net.ErrClosed)判断。3.4 压测调优记录骨架搭好后我用ab和wrk做了简单压测。测试环境是 MacBook Pro M1单机 8 并发压一个只返回Hello, caveman的接口。初始版本 QPS 大概 12000响应时间 p99 在 3ms 左右。说实话这个结果已经超出预期因为我们的解析器比标准库的 net/http 简陋得多但吞吐量也没有被甩开太多。做了一次小优化后QPS 提到 17000把每次 write 响应时的 Header 拼接从多次fmt.Fprintf改成一次性strings.Builder构造。这个数据说明很多场景下性能瓶颈往往不在框架层而在业务层、IO 调度或序列化开销上。如果业务逻辑是查数据库手写还是用框架差异远小于大多数人想象。4. 常见问题与排查技巧实录4.1 粘包半包怎么破一个经典定位过程前面说到的粘包半包问题在第一次压测时立刻暴露压测跑起来后日志一堆malformed request line看报文内容发现请求行和 Header 是乱的。定位方法很简单在 parseRequest 之前打日志把读到的字节流按十六进制打印出来。%q只能看到可读字符没法定位\r和\n的具体位置改用%x输出log.Printf(raw bytes: %x, buf[:n])打出来之后发现一个 1024 字节的 buf 里装了不止一个请求GET /ok HTTP/1.1\r\n...后面紧接着又出现了另一段GET /ok HTTP/1.1\r\n。这就是粘包。解析器如果只读了一次conn.Read拿到多少个字节就按多少字节当请求处理必然出错。正确思路是用bufio.Reader持续从连接里把字节流取出来按行解析。一次conn.Read得到的不是一个完整报文只是字节流的一个片段。解析器要维护状态当前在请求行、 Header 区还是 Body 区等空行出现时进入 Body 读取Content-Length读满后才算一个完整请求。很多框架帮你做了这个状态机但你只要亲手写一遍就再也忘不掉HTTP 是流不是包这个事实。4.2 线上最容易被攻破的角落超时设置手写服务上线测试时遇到一个诡异现象一段时间没人访问后第一次请求特别慢有时候甚至卡住几十秒。原因是 keep-alive 连接一直挂在服务器上但客户端已经断网或者休眠了。服务器不知道连接已死还在等着读数据。因为 Accept 循环不会自动回收这种空闲连接最终把 goroutine 和 fd 全部耗尽。解决就是前面提的 Deadline 和 IdleTimeout。用 Go 的net.Conn有个细节SetReadDeadline是基于绝对时间不是相对时间。每次循环读之前都要重新设置不能只在连接建立时设一次。如果设了 5 秒连接创建 5 秒后即使客户端刚发来数据也会直接超时表现就是间歇性读超时。另外客户端发送数据到服务器之间如果经过负载均衡可能连接是通的但数据没到超时时间要设得长一些避免把正常慢请求误杀。我一般把 ReadTimeout 设为 10 秒IdleTimeout 设为 60 秒慢接口单独调大。4.3 没有框架的调试三板斧没有那么多中间件和埋点工具时调试靠三个招就够第一curl -v永远的神。-v会打印请求头、响应头和握手细节能快速判断问题是出在客户端还是服务端。加上--limit-rate 100可以模拟慢网速下的分片到达。第二自己写一页/debug/conns的调试路由用原子计数器统计当前活跃连接数、读到的请求总数、错误总数。这个页面比任何监控都直观能一眼看出有没有连接泄漏。第三用tcpdump或者 Wireshark 抓包。我曾经在排查一个请求延迟问题时抓包发现客户端发起 HTTPS 握手的 SYN 包被服务器丢弃——根本不是应用层的问题是防火墙策略。应用层代码调两天不如抓包五分钟。还有一个小经验给每个请求生成一个递增的 ID写日志时带上连接 ID 和请求 ID。没有框架的上下文日志全靠这个 ID 把分散的日志串起来。最初我没加排查并发 bug 时无从下手后来加上后定位速度完全不是一个量级。写在最后的一点体会caveman这个项目从头到尾没有引入任何 Web 框架代码量也就一千多行但它让我重新理解了日常使用的框架到底帮我们扛住了多少脏活累活。一个简单的 POST 请求从网线到达代码中间有 TCP 分包、报文解析、Header 标准化、超时控制、keep-alive 状态管理、并发调度、优雅关闭……每一环单独拿出来都不难但合在一起就是框架的价值。如果你也是写业务写到有点麻木的开发我强烈建议找个周末用一个小时写一次最原始版的服务器打开 socket读字节流回一句 Hello。不需要完整实现什么光是看到自己亲手从字节流里解析出GET / HTTP/1.1那一刻很多以前靠背的东西就突然通了。这个caveman骨架后续的扩展方向也很多——加 TLS、加流式响应、加路径参数匹配、加限流器。我目前正在给它加一个极简中间件链让一些公共逻辑不用在每个 handler 里重复写。等中间件这版稳定了我可能会再写一篇拆解中间件实现原理的文章。
返回列表