← 代码课堂

第 02 课:手写 HTTP 服务器 —— 路由与 JSON

难度:★★☆(需要会 Python 函数和 if/else)
教材:查资料/server.py(重点第 908-1451 行)
预计时间:讲解 50 分钟 + 练习 30 分钟
学完你能:说清"浏览器打开网页时到底发生了什么",并且能给项目加一个自己的接口


主线实验:一个 GET 怎样变成一份 JSON

前置:能读函数、字典、if 和异常。这里只需要认识“类把若干方法放在一起”:Handler 是标准库在收到请求时使用的处理器,self 代表这次处理器对象;不要求你先实现自己的类框架。

在下载包 assets/主线实验/ 运行 py -3 server.py,访问 http://127.0.0.1:8030/api/about。预期 200,正文含 name 和 version;访问 /missing 预期 404 和 {"error": "not found"}。

按顺序看一次请求

  1. 浏览器向本机 8030 端口发送方法 GET、路径 /api/about。
  2. 服务器把请求交给 Handler 的 do_GET,urlsplit 提取路径。
  3. if 命中后创建 Python 字典,交给 reply。
  4. json.dumps 把字典变成文本,encode 把文本变成 UTF-8 字节。
  5. 写状态行、响应头、空行和正文,浏览器按 Content-Type 展示。

在 F12 Network 中核对方法、状态、类型和响应正文。这四项共同构成验证,网页“显示了一些字”还不足以证明接口契约正确。

为什么长度要在编码后计算

在单独练习文件中运行:

text = "学习"
data = text.encode("utf-8")
print(len(text))
print(len(data))

预期是 2 和 6。Content-Length 说明正文字节数,不能直接填字符串字符数。json.dumps、encode、写响应是三个有先后依赖的动作。

再看同一资源的 POST

回首页添加卡片,观察 POST /api/cards 返回 201。请求中的 JSON 由服务器读取、解析、校验后才存储;标题为空是 400,路径不存在是 404。GET 与 POST 的区别不仅是路径相同还是不同,也包括方法以及请求体的语义。

原课的片段省略了部分 import 和函数实现,完整可运行版本请使用主线实验。只把 self._json({...}) 复制到一个空文件,当然不能运行,因为它是说明调用位置的摘录。

练习与验收

在主线实验 do_GET 中增加 /api/version,返回 {"version": "2.0"}。重启服务:新路径 200、未知路径仍 404、原有创建卡片仍成功。记录三次请求,而不是只看一次成功。

排错:改完没生效先重启;返回 HTML 时检查是否访问了 /;400 时看 JSON 格式和 title;连接失败时先看监听端口。路由匹配出错与网络没连上是不同阶段。

并发只作边界认识:ThreadingHTTPServer 可以用线程分别处理请求,但共享数据、数据库写锁、CPU 工作和资源上限仍可能造成等待。“用了线程”不等于所有工作互不阻塞。


课前须知:为什么这个项目值得讲

「查资料」没有用 Flask、没有用 FastAPI,而是用 Python 标准库 http.server
自己撸了一个完整服务器。听起来很硬核,但拆开看逻辑非常朴素。

学会它,你会获得两个好处:

  1. 你以后再看到 Flask/FastAPI 的代码,会想:"哦,这不就是帮我干了这些杂活吗"
  2. 面试被问"HTTP 请求是怎么处理的",你有真实的代码可以讲

第一步:先观察现象

  1. 启动「查资料」:双击 启动服务.bat
  2. 浏览器打开 http://127.0.0.1:8000/
  3. 按 F12 打开开发者工具,切到「网络 / Network」标签,刷新页面
  4. 你会看到一条条请求:/、/static/js/web.js、/api/cards……

随便点开一条,注意看:

这些请求,就是接下来要讲的代码在处理。 现在读代码,每一段都能对上号。


第二步:模块地图(Handler 类)

整个网络层就是 server.py 里的一个类:Handler(第 908 行开始)。
它其实是三类方法的组合:

Handler(第 908-1350 行)
│
├── ① 工具箱(第 912-960 行)—— 把"怎么回复"的动作封装成小函数
│     _json()      回复 JSON 数据(接口用)
│     _text()      回复纯文本
│     _body_json() 读取请求带来的 JSON(POST 用)
│     _send_file() 把文件发回去(网页、css、js 用)
│
├── ② 路由入口(第 962-1049 行)—— 决定"哪个 URL 交给谁处理"
│     do_GET()     处理浏览器"要数据/要页面"
│     do_POST()    处理"提交/新建/操作"
│     do_DELETE()  处理"删除"
│
└── ③ 业务方法(第 1051-1350 行)—— 具体每个接口的逻辑
      _cards_list() / _card_create() / _search() / _graph() / _export() ...

一句话总结:**浏览器发请求 → ② 路由找到负责人 → ③ 业务方法干活
(会用①工具箱回复,还会用数据层查库)→ 返回。**


第三步:逐块精讲

块 1:_json() —— 所有接口的统一出口(第 916-923 行)

def _json(self, obj, status=200):
    data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
    self.send_response(status)
    self.send_header("Content-Type", "application/json; charset=utf-8")
    self.send_header("Content-Length", str(len(data)))
    self.send_header("Cache-Control", "no-store")
    self.end_headers()
    self.wfile.write(data)

小白翻译,逐行看:

代码人话
json.dumps(obj, ensure_ascii=False)把 Python 字典变成 JSON 字符串;ensure_ascii=False 是为了中文不变成 \uXXXX
.encode("utf-8")字符串变成字节流(网络只能传字节)
send_response(status)先告诉浏览器:"这次结果的状态码是 200(成功)"
Content-Type: application/json告诉浏览器:"内容是 JSON 格式,用 UTF-8 解码"
Content-Length"内容一共 N 个字节"——浏览器靠它知道什么时候读完
end_headers()响应头写完了,接下来写正文
self.wfile.write(data)把正文(数据)真正发出去

重点理解:HTTP 响应 = 状态行 + 响应头 + 空行 + 正文。
这段代码就是在按格式拼一个响应。所有接口都靠它回复,所以只写一次。

块 2:do_GET() —— 极简版"路由表"(第 963-1009 行)

def do_GET(self):
    parsed = urllib.parse.urlparse(self.path)
    path = parsed.path.rstrip("/") or "/"
    qs = urllib.parse.parse_qs(parsed.query)

    if path == "/api/health":
        return self._json({...})

    if path == "/api/cards":
        return self._cards_list(qs)

    if path.startswith("/api/cards/"):
        cid = urllib.parse.unquote(path[len("/api/cards/"):])
        return self._card_get(cid)
    ...

小白翻译:

关键设计点:

  1. 前缀匹配:path.startswith("/api/cards/") 表示 /api/cards/abc123

这种"带编号"的 URL 走这里,abc123 就是卡片 id
(unquote 是解码中文/特殊字符)

  1. 精确匹配顺序:注意 /api/cards/recent 写在 /api/cards/ 前面!

如果反过来,recent 会被当成卡片 id 吃掉——路由顺序是有讲究的

  1. 兜底是静态文件:所有不是 /api/ 开头的路径,都当作"要网页文件"处理

块 3:一个安全细节(第 1007-1008 行)—— 防"路径穿越"

if os.path.commonpath([path, WEB_DIR]) != WEB_DIR:
    return self._json({"error": "forbidden"}, 403)

这段什么意思?浏览器如果请求 /../server.py(想偷看源码),
拼出来的路径会跑到 web/ 文件夹外面。这行代码就是在检查:
"你要的文件必须老老实实待在 web 目录里,不然 403 拒绝。"

练习里会让你亲手试一次攻击,看看 403 长什么样。

块 4:do_POST() —— 表驱动的小聪明(第 1011-1039 行)

POST 是"提交数据",所以要先把请求正文读出来(_body_json,第 933 行):
从 Content-Length 知道有多长 → 读那么多字节 → 解析 JSON。

有意思的是这段(第 1029 行):

for suffix, act in (("/link", "link"), ("/links", "unlink"), ("/move", "move"), ("/dup", "dup"), ("/set-tags", "set-tags")):
    if path.endswith(suffix):
        ...break

这叫表驱动:把"后缀 → 动作名"写成一张表,循环匹配。
比写 5 个 if 更整齐,加新动作时只改表。是一个值得学的小技巧。

块 5:为什么能多人同时访问(第 1451 行)

server = ThreadingHTTPServer((HOST, PORT), Handler)
server.serve_forever()

冷知识:Handler 类里必须叫 do_GET / do_POST / do_DELETE,
这是 BaseHTTPRequestHandler 定的规矩——请求方法是什么,就找 do_什么。
名字写错,服务器就"听不懂"(返回 501)。


第四步:对照开源,你的代码 vs 框架

对照 Python 官方示例

官方文档里最简单的例子是这样的:

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"Hello")

教材项目 = 这个例子的完整版:
多了路由(if 链)、工具箱(_json/_send_file)、业务层(查数据库)、安全校验。
你不是在"用"http.server,你是在它上面盖了一栋楼。

对照 Flask(最流行的轻量框架)

from flask import Flask, jsonify
app = Flask(__name__)

@app.get("/api/cards")
def cards_list():
    return jsonify(load_cards())

对照 FastAPI(新一代框架)

@app.get("/api/cards/{cid}")
def card_get(cid: str):
    return card_by_id(cid)

一句话总结

你的 server.pyFlaskFastAPI
路由方式if 链装饰器 + 路由表装饰器 + 类型注解
JSON 序列化自己写 _json()jsonify()自动
参数解析手工解析/裁剪半自动全自动
并发线程式服务器取决于 WSGI 服务器配置取决于 ASGI 服务器及应用中的阻塞操作
学习价值彻底看懂原理上手快现代标准

面试时你可以说:"我用标准库手写过 HTTP 服务,所以理解框架在路由、
序列化、并发上分别帮我封装了什么。"——这句话含金量很高。


动手练习(做完才算过关)

练习 1(必做):加一个自己的接口
在 do_GET() 里加一条:访问 /api/about 时,返回"项目名 + 你的名字"的 JSON。
提示:照抄 /api/health 那三行,改路径和内容,改完重启服务再访问。

练习 2(必做):给接口加字段
在 /api/config 的返回值里加一个 "version": "1.0",刷新网页看看前端能不能收到变化
(可选进阶:在 web/js/ 里找到用它的地方)。

练习 3(安全实验):
在本地副本中阅读路径校验:先把候选路径规范化,再检查它是否仍位于允许目录。浏览器常会先规范化 URL 中的 ..,因此在地址栏输入 /../server.py 不能保证发出的就是原始路径,也不能保证得到 403。记录 Network 中实际发出的路径,并用临时目录的路径解析用例验证目录内允许、目录外拒绝;无需删除正在运行服务的校验。

练习 4(选做):
用 F12 网络面板,找一个 404 的请求(比如随便访问一个不存在的路径),
观察它的状态码和响应内容,说说看你的代码里哪一行决定了它返回 404。


本课小结

术语表

术语人话解释
HTTP浏览器和服务器聊天的“语言协议”
GET / POST / DELETE要数据 / 交数据 / 删数据 三种基本动作
路由(routing)根据 URL 路径决定由哪段代码处理
状态码200 成功、404 找不到、403 没权限、500 服务器出错
Content-Type正文的格式说明(JSON、HTML、图片……)
端口(port)一台电脑上不同服务的“门牌号”,8000 是这个应用的门牌
线程操作系统里的“打工人”,多线程 = 多雇几个人同时干活
路径穿越用 ../ 跳出允许目录去偷文件的黑客手法

下节预告

第 03 课:单片应用也要分模块:MVC。
换一本教材——「关系图」,打开就是 1680 行的一个 HTML 文件。
但它内部用注释切成了清清楚楚的五段,还有一个经典的 MVC 架构。
我们会看看"一个文件如何写出多文件项目的秩序感",并对照 ECharts 官方示例。