"caveman"这个项目名,是我在一个失眠的深夜敲定的。当时我正被一套相当"现代化"的Web工具链折腾得怀疑人生:装依赖、配构建、等编译,一个Hello World硬是花了整个晚上。盯着终端里五颜六色的报错,我忽然特别怀念小时候用记事本写HTML、双击就能在浏览器打开的年代。于是就有了这个叫caveman的小实验——一个只用Python标准库、零第三方依赖、从socket开始徒手实现的极简HTTP服务器。它不是一个能上生产的框架,更像一次把自己丢回"石器时代"的底层演练:我想亲眼看着一个HTTP请求进来,再亲手把一个响应送回去。这篇文章就是caveman从设计到落地的完整复盘,适合被框架封装惯了、想真正吃透Web协议底层的开发者,也适合刚学完socket想找点练手项目的同学。
1. 为什么是caveman:一个返璞归真的Web服务实验
1.1 项目到底做了什么
caveman不是什么大厂开源项目,它是我个人用来"拆解黑盒"的练手作品。它具备一个最基础Web服务器该有的能力:能监听本机端口,能解析HTTP请求,能返回静态文件,能区分GET和POST,还能用一个简单的线程池同时服务多个浏览器连接。整套实现只依赖Python标准库里的socket、os、threading、functools等模块,没有一行第三方依赖。压缩下来不到两百行代码,却完整地跑通了"输入网址、敲回车、看到页面"这条最日常的请求响应链路。
我特意把代码规模压在几百行以内,因为它的定位是显微镜而不是盾牌。你要能一眼看完所有逻辑,知道每一次recv、每一行split、每一个sendall是为了什么。一旦规模膨胀到几千行,这个项目就失去了意义。这也决定了caveman的边界:不做HTTPS、不做HTTP/2、不追求高并发、不兼容复杂网关,只踏踏实实把HTTP那套明文协议练扎实。
1.2 为什么要用"穴居人"思路做Web服务
说句实在话,现在写Web应用太"幸福"了:框架帮你把路由、中间件、模板、ORM全部包装好,你只要注册一个装饰器,剩下的交给黑盒。可一旦遇到性能问题、奇怪Bug,你连从哪里下手都不知道。caveman的思路就是穴居人式的——不搞全自动厨房,只磨一把趁手的石斧。先解决"请求来了怎么回应"这个最底层的问题,你才能真正理解框架到底帮你做了什么。
我见过不少人用了一年Flask,却说不清请求进来后第一个被执行的东西是什么。这不怪开发者,因为框架把太多东西吞进了肚子里。你天天用筷子吃饭,不代表你懂水稻怎么变成大米;同理,天天用框架写接口,也不代表你懂HTTP。caveman逼着我把一碗米从稻谷开始亲手碾出来,这是它最大的价值。
1.3 这个实验适合谁、不适合谁
我做完之后的感受是,它特别适合三类人:刚入门后端、对Flask/Django之类的框架处于"只会用、不懂原理"状态的开发者;想练习网络编程、想搞清楚TCP和HTTP关系的同学;以及被复杂中间件折磨到想回归本质的老熟人。反过来,如果你要的是生产级高可用服务,或者你只是想快速搭一个能上线的API,那请直接去用成熟的Web框架。caveman不是它们的替代品,它是你真正理解那些框架之前的必修课。
2. 核心细节拆解:HTTP请求与响应的最小闭环
2.1 请求进入的完整链路
理解一个HTTP服务器,首先要吃透一条最基本的事实:HTTP协议是跑在TCP之上的文本协议。浏览器发出去的请求,到了服务端就是一段有固定格式的字节流,用什么高级框架也不例外。在caveman里,链路非常直观:服务器在某个端口上accept,等来一个客户端连接后,用recv读取一段字节,然后按照HTTP的规则把这些字节拆解成方法和路径。请求的格式是每行以\r\n结尾,第一行是请求行,形如GET /index.html HTTP/1.1;从第二行开始是请求头,每一行是一个key: value;再往下空一行,之后才是请求体。
这里有个特别容易踩的小坑:HTTP协议的换行符是\r\n,也就是回车加换行,不是单纯\n。第一次手写解析器时我也图省事只按\n分割,结果浏览器倒还认,但如果你用nc手工调试或对接严格的服务端时,就会遇到各种微妙的问题。所以解析函数里我总是先把\r\n处理好,标准协议永远照着标准来。
def parse_request(raw: bytes): text = raw.decode('utf-8', errors='replace') lines = text.split('\r\n') request_line = lines[0] parts = request_line.split(' ') method, target, version = parts[0], parts[1], parts[2] if len(parts) >= 3 else 'HTTP/1.1' headers = {} content_length = 0 for line in lines[1:]: if not line: break # 空行,头部结束 key, _, value = line.partition(':') headers[key.strip()] = value.strip() if key.lower() == 'content-length': content_length = int(value.strip()) body = b'' # 简化做法:在recv缓冲区里找空行之后的内容 header_end = text.find('\r\n\r\n') if header_end != -1: body = raw[header_end + 4:] return method, target, headers, body2.2 响应为什么差一个Content-Length都不行
请求解析完,服务器的任务就是返回响应。响应格式同样传统:状态行、响应头、空行、响应体四段。状态行如HTTP/1.1 200 OK;响应头里最容易被忽略的就是Content-Length。这个字段告诉浏览器"响应体一共多少字节"。如果你漏了它,且连接没有关闭,浏览器就会进入漫长的等待状态——它不知道内容还有没有后续,一直转圈,直到超时。这是新手手写服务器最容易遇到的问题,我曾在caveman早期版本里因为忘记计算字节长度,开着浏览器干瞪眼了好几分钟。
def http_response(body: bytes, status: str = '200 OK', content_type: str = 'text/html; charset=utf-8'): headers = [ f'HTTP/1.1 {status}', f'Content-Type: {content_type}', f'Content-Length: {len(body)}', 'Connection: close', 'Server: caveman/0.1', ] head = ('\r\n'.join(headers) + '\r\n\r\n').encode('utf-8') return head + body注意这里的Content-Length用的是len(body),也就是字节数,不是字符串长度。如果body是中文,字符串长度和字节数会差很远,用错就又是一场乱码灾难。
2.3 静态文件服务:路径和MIME是两道关卡
静态文件是浏览器访问最频繁的场景。在caveman里,我只处理一个根目录,请求路径对应一个文件。这里有两道关卡:第一道是默认页,访问/时自动返回index.html;第二道是MIME类型。MIME本质是告诉浏览器"这个文件该用什么方式打开",比如html文件给text/html,png图片给image/png。如果不给正确MIME,浏览器可能直接把图片当成下载文件弹下来,体验非常奇怪。
读取文件时必须用"rb"二进制模式,图片等二进制内容才不会在中途损坏。千万不要用"r"文本模式去读png,你会在编码环节稀里糊涂地得到一堆乱码。MIME映射不需要覆盖全宇宙,把常用的列出来就够了。
| 扩展名 | Content-Type |
|---|---|
| .html | text/html; charset=utf-8 |
| .css | text/css; charset=utf-8 |
| .js | application/javascript |
| .json | application/json |
| .png | image/png |
| .jpg / .jpeg | image/jpeg |
| .svg | image/svg+xml |
| .txt | text/plain; charset=utf-8 |
2.4 路由、查询参数和POST表单的简单实现
caveman支持路径路由,但我不打算做一个复杂的Router,只用if-else判断target路径即可。查询参数藏在路径问号后面,例如/hello?name=caveman,用Python自带的urllib.parse.parse_qs就能拆成字典。POST请求不同,它的参数放在请求体里,需要先拿到请求头里的Content-Length,然后按长度读取body,再解析body里的key=value串。这也是为什么解析函数里我特别注意Content-Length的原因——GET的body基本为空,而POST的body有明确长度,读完正好,多读会阻塞,少读会丢数据。
from urllib.parse import urlparse, parse_qs, unquote # 路由示例 parsed = urlparse(target) path = unquote(parsed.path) query = parse_qs(parsed.query) if path == '/': path = '/index.html' elif path == '/hello': name = query.get('name', ['caveman'])[0] # 返回一段动态HTML3. 实操全流程:让caveman跑起来
3.1 环境检查与最小监听器
动手之前先确认环境,caveman依赖的是Python3,不需要装任何包。我建议在项目目录下建一个public文件夹,把HTML、CSS、JS都放进去,服务器只从这个目录往外读文件,其他目录一概不碰。最小监听器十几行就能写出来:创建socket、绑定端口、监听、进入accept循环。
import socket HOST, PORT = '127.0.0.1', 8080 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen(128) print(f'caveman listening on http://{HOST}:{PORT}') while True: conn, addr = server.accept() data = conn.recv(4096) print(f'new request from {addr}: {data[:60]}...') conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok') conn.close()端口选8080而不是80,是因为1024以下的端口在Linux和macOS上需要root权限,做实验没必要跟权限较劲。HOST用127.0.0.1意味着只在本地回环地址监听,外网无法访问,安全又省事。
3.2 搭出请求解析器
把前面的parse_request接进来,再带上recv后判断空数据的逻辑。浏览器关闭连接时recv可能返回空字节,这种连接要跳过去,否则后面解析会出错。
while True: conn, addr = server.accept() raw = conn.recv(4096) if not raw: conn.close() continue method, target, headers, body = parse_request(raw) print(method, target, headers.get('User-Agent', ''))recv(4096)里的4096是单次接收缓冲区上限,不是必须读满,所以不要假设一次recv就能收完整个请求。HTTP请求头通常很小,这个数字足够覆盖绝大多数场景;如果请求体更大,就得配合Content-Length循环读取。caveman这个教学版本只做单次读取,够用就行。
3.3 实现静态文件服务和安全校验
静态文件这边最重要的一条是防路径穿越。如果用户请求的URL是/../../etc/passwd,拼接后就会跳到项目目录之外,后果不堪设想。解决办法很简单:先把真实路径resolve出来,再检查这个真实路径是否以根目录的真实路径开头。
import os ROOT = os.path.join(os.path.dirname(__file__), 'public') ROOT_REAL = os.path.realpath(ROOT) def serve_static(path): if path == '/': path = '/index.html' full = os.path.realpath(os.path.join(ROOT_REAL, path.lstrip('/'))) if not full.startswith(ROOT_REAL): return http_response(b'403 Forbidden', '403 Forbidden', 'text/plain; charset=utf-8') if not os.path.isfile(full): return http_response(b'404 Not Found', '404 Not Found', 'text/plain; charset=utf-8') with open(full, 'rb') as f: content = f.read() ctype = guess_mime(full) return http_response(content, '200 OK', ctype)用startswith判断前缀有一些边缘Case,比如根目录是/path/public,另一个目录是/path/public_evil,也会误判。更严谨的做法是在路径后面加一个os.sep再判断,也就是检查full + os.sep或直接判断full.startswith(ROOT_REAL + os.sep)或full == ROOT_REAL。这个小细节我后来才补上,属于手写服务器才遇得到的坑。
3.4 用线程池解决并发阻塞
到这里服务器已经能用了,但你很快会发现一个严重问题:一次只能处理一个请求。如果某个请求响应慢,后面的请求全被堵在accept外面。caveman补上并发能力的方式很朴素,用concurrent.futures.ThreadPoolExecutor开一个固定大小的线程池,每个连接丢给线程池处理。线程数不用多,默认10个左右足够撑起这个实验。注意不要为了炫技开几千线程,线程切换开销会让性能急剧下降。
from concurrent.futures import ThreadPoolExecutor pool = ThreadPoolExecutor(max_workers=10) while True: conn, addr = server.accept() pool.submit(handle_connection, conn, addr)3.5 一个可以直接运行的整体代码骨架
我把上面的零件拼成一个完整版,你可以直接存成server.py跑一下。核心思路依然是穴居人式的:任何部分都能看穿,任何行为都可预期。
import socket import os from concurrent.futures import ThreadPoolExecutor from urllib.parse import urlparse, parse_qs, unquote HOST, PORT = '127.0.0.1', 8080 ROOT = os.path.join(os.path.dirname(__file__), 'public') ROOT_REAL = os.path.realpath(ROOT) MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.txt': 'text/plain; charset=utf-8', } def guess_mime(path): ext = os.path.splitext(path)[1].lower() return MIME.get(ext, 'application/octet-stream') def parse_request(raw): text = raw.decode('utf-8', errors='replace') lines = text.split('\r\n') parts = lines[0].split(' ') method = parts[0] target = parts[1] if len(parts) > 1 else '/' headers = {} for line in lines[1:]: if not line: break key, _, value = line.partition(':') headers[key.strip()] = value.strip() body = b'' header_end = text.find('\r\n\r\n') if header_end != -1: body = raw[header_end + 4:] return method, target, headers, body def http_response(body, status='200 OK', content_type='text/html; charset=utf-8'): headers = [ f'HTTP/1.1 {status}', f'Content-Type: {content_type}', f'Content-Length: {len(body)}', 'Connection: close', 'Server: caveman/0.1', ] return ('\r\n'.join(headers) + '\r\n\r\n').encode('utf-8') + body def serve_static(path): if path == '/': path = '/index.html' full = os.path.realpath(os.path.join(ROOT_REAL, path.lstrip('/'))) if not full.startswith(ROOT_REAL): return http_response(b'403 Forbidden', '403 Forbidden', 'text/plain; charset=utf-8') if not os.path.isfile(full): return http_response(b'404 Not Found', '404 Not Found', 'text/plain; charset=utf-8') with open(full, 'rb') as f: content = f.read() return http_response(content, '200 OK', guess_mime(full)) def handle_connection(conn, addr): try: raw = conn.recv(4096) if not raw: return method, target, headers, body = parse_request(raw) parsed = urlparse(target) path = unquote(parsed.path) if path == '/hello': query = parse_qs(parsed.query) name = query.get('name', ['caveman'])[0] html = f'<h1>Hello, {name}</h1><p>from caveman server</p>'.encode('utf-8') resp = http_response(html) else: resp = serve_static(path) conn.sendall(resp) finally: conn.close() server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen(128) print(f'caveman listening on http://{HOST}:{PORT}') pool = ThreadPoolExecutor(max_workers=10) while True: conn, addr = server.accept() pool.submit(handle_connection, conn, addr)运行前先在项目目录里建一个public文件夹,放一个index.html。我日常测试时就放了一句Welcome to caveman。然后python server.py,浏览器打开http://127.0.0.1:8080/就能看到。想测试动态路由,访问/hello?name=yourname试试。
注意:Windows下最后一行会一直阻塞在accept,如果想停止服务直接Ctrl+C即可。
4. 常见问题与排查技巧实录
4.1 浏览器一直转圈却不显示页面
做caveman最大的翻车现场就是页面一直转圈。绝大多数情况是响应头少了Content-Length,或长度写错。浏览器无法判断请求是否彻底结束,只能等待超时。排查方法很简单,用curl -v看响应头,如果返回内容里没有Content-Length,问题就锁定在响应构造上。
4.2 页面里的中文全变成乱码
如果响应头用的是text/html而不是text/html; charset=utf-8,中文字符大概率就会乱码。字符串编码成bytes之后,浏览器不知道它是什么编码,只能靠猜。规范做法是:所有text类资源的Content-Type后面都带charset=utf-8,JavaScript和CSS也建议加;图片则是二进制格式,不需要charset。
4.3 静态资源404,但文件明明存在
这种问题大半出在路径拼接上。请求路径是/user/../index.html,拼接时如果直接用原始字符串可能错位,甚至出现路径穿越风险。我统一用os.path.realpath做规范化,再检查前缀。如果发现页面能访问但CSS/JS加载失败,优先看浏览器Network标签里资源的URL和服务器返回的状态码,十有八九是路径拼接的锅。
4.4 请求一多就卡死
单线程版本遇到慢客户端会形成"队头阻塞":一个连接没处理完,其他请求全部排队。解决方案就是线程池,或者更进阶的select/epoll事件循环。caveman选线程池是因为代码直观,每个连接一个任务,子线程之间互不干扰。要注意的是线程池的max_workers别贪大,我在本机试过10个和100个,10个时的实际吞吐并不差,100个反而会因为线程上下文切换而抖动。
4.5 手工调试的几个命令
有时候写服务器最高效的调试方式不是写测试,而是直接发原始请求。curl提供了丰富的参数;nc更是能把HTTP请求亲手打一遍。我调试caveman时最常用的命令列在这里。
| 命令 | 用途 |
|---|---|
| curl -v http://127.0.0.1:8080/ | 查看完整请求与响应头 |
| curl -I http://127.0.0.1:8080/ | 只获取响应头 |
| curl -X POST -d 'name=caveman' http://127.0.0.1:8080/hello | 发送POST表单 |
| echo -e 'GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n' | nc 127.0.0.1 8080 | 手写HTTP请求 |
5. 再往前一步:给caveman留的扩展位
5.1 保持原始感的小扩展
实验做完不代表项目就废了,我给它留了几个不违背"穴居人"原则的扩展位:把if-else路由换成一张路由表格,用字典映射路径到处理函数;增加一个简单的访问日志模块;给静态资源加Last-Modified和If-Modified-Since做简单缓存;或者用select模型替代线程池。每加一个特性,都要回到项目初衷:要让每个字节都可解释,而不是堆复杂度。我现在又给caveman加了POST表单解析和简单的Cookie读取,依然只有两百多行代码。
5.2 做完caveman之后我的真实感受
说点掏心窝的话,做完这个项目,我最明显的改变是再看框架文档时,那些原本玄乎的概念突然都落到了实处。中间件不过是我在handle_connection里那个"先处理、再传给下一个函数"的模式;路由不过是一个查表动作;模板就是"把字符串拼进HTML再发出去"的封装。以后再碰到连接异常、响应超时,我能先掰着指头数一遍字节流,而不是对着报错瞎猜。我也强烈建议每个后端开发,不论用什么语言,都试着用标准库写一次HTTP服务器。几百行代码,换回的是对整个Web技术栈再也骗不了你的底气。caveman的代码我到现在还在迭代,每次往里面加一个小功能,都像给石器时代的石斧加了一个防滑手柄,工具原始,但握在手里踏实。