
前段时间我把一个攒了很久的想法落了地用Go标准库从零手写一个HTTP服务器项目代号就叫caveman。起因很简单用框架写了几年业务代码对HTTP的理解始终停在“发请求、拿响应”的层面遇到线上诡异问题只能靠猜。所以我想做一个“原始人”级别的实现——不依赖任何Web框架自己处理TCP连接、自己解析请求、自己拼响应把最底层那层皮剥开看清楚。这个项目最终大约500行代码支持路由、静态文件、并发访问和一个勉强能用的日志器跑起来之后我对HTTP的理解彻底不一样了。如果你也想搞懂Nginx、Express、Spring MVC背后到底发生了什么或者正在学网络编程找不到合适的练手项目这篇实战记录应该对你有帮助。1. 为什么要把HTTP服务器做成“原始人”版本1.1 框架把一切藏得太好了平时写接口一个RequestMapping或者app.get()就把路由解决了中间件、模板渲染、参数绑定全被封装成黑盒。黑盒用着舒服但一出问题就很痛苦。我记得有一次线上服务出现大量TIME_WAIT连接同事排查了半天最后发现是响应头没设置Content-Length导致连接无法复用。这种问题如果理解HTTP报文的基本结构定位几乎是秒级的——所以我觉得每个做后端的人都应该亲手写一次最原始的HTTP服务器。caveman这个名字就是故意取的原始人用石头砸坚果我们用标准库砸TCP和HTTP。它解决问题的核心是把Web框架替你做的事情一件件亲手做一遍。不需要去找现成的轮子不需要引入依赖就靠标准库的net、strings、strconv等包完成。做完之后你会发现所谓高端框架底层都是这么一回事。1.2 设计原则最小可用但结构完整我给caveman定了三条设计原则全程照着执行不引入任何第三方依赖所有功能只用标准库实现协议解析必须严谨该校验的校验、该兼容的兼容代码结构保持可扩展后续能轻松加HTTPS、HTTP/2、路由前缀等功能这样的好处是每一行代码都是自己写出来的逻辑出了问题一眼能看懂。坏处当然也有——性能没法跟Nginx比功能也远不如真实框架但作为学习项目它的价值恰恰在于“简单到可以完全掌控”。2. 核心细节HTTP在网络上到底长什么样2.1 一条TCP连接上的“对话”HTTP的基础是TCP。客户端和服务器先完成三次握手建立连接然后客户端发请求服务器回响应最后四次挥手关闭连接。整个过程可以类比成打电话拨号握手→ 说需求请求→ 听答复响应→ 挂断挥关。caveman的核心工作就是三件事监听TCP端口、逐字节读取并解析请求、按规则生成响应。这三件事缺一不可但每一件的难度其实都不高难的是把细节做对。2.2 请求行方法、路径、协议版本一个标准HTTP请求报文长这样GET /index.html HTTP/1.1 Host: localhost:8080 User-Agent: curl/8.0 Connection: keep-alive第一行叫请求行包含三个部分以空格分隔字段示例含义方法GET希望服务器执行的操作请求目标/index.html资源路径可能带查询参数协议版本HTTP/1.1客户端支持的协议版本解析请求行时最关键的坑在于不能用固定的字节长度去截取必须按行读取、按空格切分。协议允许每部分长度不固定而且请求目标里可能包含URL编码的特殊字符比如/search?q%E4%B8%AD%E6%96%87。我最初实现时写死了缓冲区长度一旦请求路径超过容量就报错后来改成ReadString(\n)按行读取就稳定了。2.3 头部字段与请求体容易被忽略的空行请求行之后是若干头部字段每行都是键: 值的格式以空行\r\n\r\n结束。空行之后才是请求体POST请求的数据就在这里。解析头部的要点是键不区分大小写比如Content-Type和content-type是同一个字段值前后的空格要去掉同一个键可能出现多次比如多个Cookie字段需要按字段名聚合。请求体的长度取决于Content-Length字段或者使用Transfer-Encoding: chunked分段传输。caveman刚开始只支持前者遇到分块编码就直接报错。后来为了能处理表单提交我补了一个简单的chunked解码器——这部分逻辑比较绕简单来说就是每个数据块前面有一行十六进制数字声明块长度读到0结束块碰到\r\n就切换状态。折腾完这一轮再看抓包工具里的原始报文真的会有一眼通透的感觉。3. 动手实现从监听端口到第一个响应3.1 环境准备与骨架搭建我用的是Go 1.21不需要任何额外安装标准库就够了。先搭一个最小骨架package main import ( fmt net ) func main() { listener, err : net.Listen(tcp, :8080) if err ! nil { panic(err) } defer listener.Close() fmt.Println(caveman server listening on :8080) for { conn, err : listener.Accept() if err ! nil { continue } go handleConnection(conn) } }这段代码的意图很明确net.Listen创建一个监听器Accept阻塞等待新连接每来一个连接就开一个goroutine去处理。这里我一开始犯了个错误——没有对Accept的错误做判断一旦遇到临时错误整个服务就崩了。后来改成continue跳过错误连接才稳定下来。3.2 逐字解析请求不读满不放过handleConnection是核心逻辑是循环读取请求直到遇到空行然后根据请求类型决定是否继续读请求体func handleConnection(conn net.Conn) { defer conn.Close() req, err : readRequest(conn) if err ! nil { writeError(conn, 400, Bad Request) return } resp : route(req) conn.Write(resp) } func readRequest(conn net.Conn) (*Request, error) { req : Request{ Headers: make(map[string]string), } // 读取请求行 requestLine, err : readLine(conn) if err ! nil { return nil, err } parts : strings.Split(requestLine, ) if len(parts) ! 3 { return nil, fmt.Errorf(malformed request line) } req.Method parts[0] req.Target parts[1] req.Proto parts[2] // 读取头部 for { line, err : readLine(conn) if err ! nil { return nil, err } if line { break // 空行表示头部结束 } colon : strings.Index(line, :) if colon 0 { continue } key : strings.TrimSpace(line[:colon]) value : strings.TrimSpace(line[colon1:]) req.Headers[strings.ToLower(key)] value } // 读取请求体 if lengthStr, ok : req.Headers[content-length]; ok { length, _ : strconv.Atoi(lengthStr) body : make([]byte, length) _, err : io.ReadFull(conn, body) if err ! nil { return nil, err } req.Body string(body) } return req, nil }这里最关键的是readLine函数。HTTP协议规定行分隔符是\r\n但有些客户端不按规范只发\n所以要把两种情况都兼容func readLine(conn net.Conn) (string, error) { var line []byte buf : make([]byte, 1) for { n, err : conn.Read(buf) if n 0 { if buf[0] \n { break } if buf[0] ! \r { line append(line, buf[0]) } } if err ! nil { return , err } } return string(line), nil }逐字节读看似低效但对学习项目来说是最清晰的做法。实测本地访问这个函数一秒能解析上万行性能完全够用。3.3 生成响应状态行、头部与Body响应报文格式和请求对称先是状态行比如HTTP/1.1 200 OK然后是响应头空行最后是响应体。我把响应生成封装成了一个辅助函数func writeResponse(conn net.Conn, code int, contentType, body string) { statusText : map[int]string{ 200: OK, 404: Not Found, 400: Bad Request, 500: Internal Server Error, } response : fmt.Sprintf(HTTP/1.1 %d %s\r\n, code, statusText[code]) response fmt.Sprintf(Content-Type: %s\r\n, contentType) response fmt.Sprintf(Content-Length: %d\r\n, len(body)) response Connection: close\r\n response \r\n response body conn.Write([]byte(response)) }Content-Length是重中之重响应字节数必须和它严格一致。我第一次实现时直接在Content-Type后面漏了空行结果浏览器把整个响应头当成了页面文本显示排查了很久才发现是格式问题。HTTP/1.1默认是持续连接没有Content-Length或Transfer-Encoding字段时客户端无法判断响应到哪里结束只能等连接关闭。我在响应里显式加了Connection: close让客户端感知到连接关闭即响应结束简化了逻辑。3.4 路由与静态文件服务路由部分我实现了一个思路极简的方案一个映射表加一个默认的404。为了保证安全性路径穿越是必须处理的——/../etc/passwd这种路径如果直接拼接文件系统路径后果不堪设想。我用了filepath.Clean后再检查前缀确保解析后的路径仍在根目录内func route(req *Request) (int, string) { if req.Method GET strings.HasPrefix(req.Target, /static/) { return serveStatic(. strings.TrimPrefix(req.Target, /static)) } switch req.Target { case /: return 200, htmlbodyh1Hello from caveman/h1/body/html default: return 404, htmlbodyh1404 Not Found/h1/body/html } }静态文件这块有个容易被忽视的点需要根据文件扩展名设置对应的Content-Type。.html、.css、.js、.png的MIME类型各不相同如果全部返回text/plain浏览器虽然能显示文本但遇到图片就会乱码遇到CSS就不会应用样式。我用一个简单的map做了映射虽然只有十来种常见类型但作为“原始人”版本已经够用了。4. 并发模型与性能考量4.1 连接级并发每个连接一个goroutinecaveman的并发模型是经典的“每连接一协程”优势是编写简单、阻塞式IO天然适配。每个连接独立处理互不干扰即使某个请求处理得很慢也不会阻塞其他连接。这个模型在连接数不多几百到几千时表现非常好大部分Web框架和早期服务器比如Apache的prefork模式走的都是类似思路。我实测用一个简单的压测脚本开了1000个并发连接请求/路径全部成功返回200没有出现连接被拒的情况。但这里有个前提每个连接处理完都必须关闭连接否则goroutine会泄漏。我在handleConnection里用defer conn.Close()保证无论哪个分支return连接都被回收。4.2 超时与资源保护优雅比性能重要框架自动帮你做的超时控制在caveman里必须自己加。如果不设超时一个客户端建立连接后不发数据对应的goroutine就会一直挂在那资源白白占用。我在监听连接后立刻设置了读写超时conn.SetReadDeadline(time.Now().Add(10 * time.Second)) conn.SetWriteDeadline(time.Now().Add(10 * time.Second))这样即使遇到“慢客户端”或者恶意闲置连接10秒后也会自动断开不会拖垮服务器。实际线上服务中这个值通常由业务场景决定静态资源服务可以设短一点长轮询的接口就要单独调整。这个思路后来被我迁移到了正经项目里给所有出站HTTP调用都加了超时配置线上故障率降了不少。4.3 压测结果别拿原始人去打现代化战争用ab工具简单压了一下结果很符合预期在我这台普通笔记本上单进程caveman大概能撑住每秒1500到2000个简单GET请求跟Nginx动辄几万的QPS没法比。但注意这个数字对理解网络编程的人来说已经足够了——瓶颈主要出在逐字节读取和每次请求都写日志上这两块都有明确的优化空间改用bufio.Reader批量读取而不是每次conn.Read一个字节日志输出改为异步批量写避免同步I/O阻塞主流程响应体可以预先用sync.Pool池化减少字符串拼接开销我觉得学习项目不该过度优化把瓶颈找到、知道怎么优化比实际优化完成更重要。5. 常见问题与排查实录5.1 客户端收到空响应踩到过最诡异的一个问题curl请求caveman返回Empty reply from server但服务器日志显示请求已经处理并写了响应。排查后发现是write的顺序问题——我先关闭了写入端再调用conn.Write或者反过来在连接已关闭的半双工状态下写入数据。Go的net.Conn没有明确区分关闭方向只有Close方法所以只要记住一个原则在写响应完成之前绝对不要调用Close。后来我调整了handleConnection中return的时机确保所有写操作完成后再关闭连接问题消失。5.2 请求头被截断解析报错另一个常见问题是浏览器正常、curl正常但某些HTTP客户端发来的请求头特别大比如带了很多Cookie一旦超过我设置的缓冲区就报malformed request line。排查方式是用tcpdump抓包看完整报文发现对方发送的请求行里包含了额外的\r符号。修复方式是readLine里把\r剥掉前面代码里已经处理。这也是为什么协议解析必须逐字节处理而不是简单ReadString——各种客户端实现细节不同不兼容就报错。5.3 curl能通但浏览器打不开浏览器对HTTP响应比curl严格得多。curl只要收到字节就算成功浏览器则要求响应头完整、Content-Length准确、编码声明正确。我遇到的情况是返回了HTML但没有声明编码浏览器默认用windows-1252解码导致中文乱码。后来在响应头里加上Content-Type: text/html; charsetutf-8才解决。这类问题在实际开发中非常常见自查顺序是Content-Type→Content-Length→ 空行 → 响应体编码。5.4 常见问题速查表现象可能原因排查方法连接被拒绝端口未监听或监听地址错误netstat -an | grep 8080响应为空写响应后立即关闭连接检查conn.Close调用顺序页面乱码未设置charsetutf-8检查Content-Type响应头静态资源404路径拼接错误或存在路径穿越检查filepath.Clean后的前缀并发高时崩溃goroutine泄漏或未处理Accept错误用pprof查看goroutine数量请求超时未设置读写Deadline检查SetReadDeadline6. 实测、后续扩展与个人体会6.1 亲手验证一个完整请求把caveman跑起来后我用最原始的方式验证了正确性先开一个终端启动服务器再开另一个终端用nc工具手动发原始HTTP请求$ printf GET / HTTP/1.1\r\nHost: localhost:8080\r\nConnection: close\r\n\r\n | nc localhost 8080返回的响应报文完整打印在我的终端上从状态行到响应头到HTML内容每一行都是自己写的代码生成的。那个瞬间真的挺有成就感——平时藏在框架后面的东西现在从第一根网线到最后一字节响应全链路都握在手里了。做完caveman之后我的收获不只是会写一个服务器。回头再看日常开发中那些“莫名其妙”的问题为什么Content-Length不对会导致前端卡住、为什么请求头大小有限制、为什么Connection: keep-alive能减少握手开销——全都找到了根因。这个项目的架构也方便继续扩展我已经计划后续把HTTPS支持用crypto/tls包和HTTP/2头部压缩的简化版加进去让这个“原始人”逐步进化成“现代人”。6.2 给想动手的人几条实在建议最后分享几点个人体会。第一一定要用抓包工具配合调试WireShark或者简单的tcpdump能把TCP和HTTP分层展示你对每一层发生了什么会有更直观的感知。第二先做单线程版本再做并发版我一开始直接上goroutine出问题时分不清是协议逻辑还是并发逻辑出错退回单线程把协议跑通后再加并发定位问题快得多。第三不要沉迷造轮子caveman这类项目的目的是理解原理不是替代生产级服务器——理解完原理回去用成熟框架写业务才是最有效率的工作方式。这个项目整个做完大约花了一周下班时间核心代码就一个server.go文件。如果你正在学网络编程或者对HTTP协议半懂不懂我强烈建议你也拿Go、Python或者Rust写一个同样的“原始人”服务器。代码量不大踩坑不少但每一个坑踩完你对Web世界的理解都会往前扎实地迈一步。我现在写业务代码时偶尔还会打开caveman的源码看一眼——它时刻提醒我越是底层的东西越值得亲手碰一碰。