本节目标:看懂 HTTP 请求与响应的原始字节形态,理解 WSGI 与 ASGI 两种网关协议各自的调用约定与并发模型。
适用版本:Python 3.12+(实测 3.14.6)
16.1 HTTP 与 WSGI / ASGI
第 12 章我们用 socket 和 httpx 做过客户端。现在换个方向:站在服务端看一个 Web 应用究竟收到了什么、又要交回什么。这一节先把 HTTP 拉回它最朴素的样子——一段有格式的文本;再看 Python 用 WSGI 和 ASGI 两套「网关协议」把这段文本接进应用代码。理解这两层,后面学 FastAPI 才不是背 API。
16.1.1 一次 HTTP 交互的原始文本
HTTP 不是什么神秘的东西:客户端往 TCP 连接里写一段有格式的文本,服务器回写另一段。我们用一个裸 socket 直接和本地的 http.server 对话,把双方发送的原始字节打印出来:
import http.server, socketserver, threading, socket
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = "你好,HTTP".encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # 关掉默认日志,输出更干净
pass
srv = socketserver.TCPServer(("127.0.0.1", 0), Handler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
sock = socket.create_connection(("127.0.0.1", port))
request = (f"GET /greet?name=Ada HTTP/1.1\r\n"
f"Host: 127.0.0.1:{port}\r\n"
f"User-Agent: raw-socket-demo\r\n"
f"Connection: close\r\n\r\n")
sock.sendall(request.encode("ascii"))
data = b""
while chunk := sock.recv(4096):
data += chunk
print(data.decode("utf-8"))
真实输出(响应部分):
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.14.6
Date: Thu, 08 Oct 2026 23:35:03 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 13
你好,HTTP
一段请求 = 请求行 + 请求头 + 空行 + 请求体,响应 = 状态行 + 响应头 + 空行 + 响应体。空行(\r\n\r\n)是分界线:它之前的都是元数据,之后的才是载荷。我们发的请求没有体(GET 通常如此),所以空行后直接结束。
注意响应第一行是 HTTP/1.0,而请求是 HTTP/1.1——因为 BaseHTTPRequestHandler 默认按 HTTP/1.0 回复,每条连接处理一个请求就关闭。这正是下面要讲的 keep-alive 的反面例子。
16.1.2 方法、状态码、头与体
| 组成 | 位置 | 作用 | 例子 |
|---|---|---|---|
| 请求方法 | 请求行首词 | 表达「想做什么」 | GET / POST / PUT / DELETE |
| 请求目标 | 请求行第二词 | 路径 + 查询串 | /greet?name=Ada |
| 协议版本 | 请求行末词 | 会话规则 | HTTP/1.1 |
| 状态码 | 状态行第二词 | 结果分类 | 200 / 404 / 500 |
| 头字段 | 空行之前 | 描述元信息 | Content-Type、Host |
| 消息体 | 空行之后 | 真正的数据 | JSON、HTML、二进制 |
状态码按首位分五类:1xx 信息、2xx 成功、3xx 重定向、4xx 客户端错、5xx 服务端错。记住这点,读任何 API 文档都快一半。
方法要区分「幂等」与「安全」:GET / HEAD 是安全的(不改变服务端状态);GET / PUT / DELETE 是幂等的(重复执行结果相同);POST 两者都不是,所以用它提交订单时要靠业务逻辑去重。
16.1.3 Content-Type 与字符集
Content-Type 告诉对方「这段体是什么格式、用什么编码」:
Content-Type: text/plain; charset=utf-8
Content-Type: application/json; charset=utf-8
Content-Type: application/x-www-form-urlencoded
Content-Type: multipart/form-data; boundary=----abc
字符集必须显式声明 utf-8。省略时,浏览器对 text/* 会猜成 Latin-1,中文就会变乱码;而 application/json 按 RFC 8259 规定必须是 UTF-8,写不写 charset 都一样。这正是 10.2 节「编码是显式的,不是猜的」在 HTTP 层的延续。
16.1.4 无状态、Cookie 与 Session
HTTP 本身无状态:服务器处理完一个请求就忘掉它,下一个请求对服务器而言是全新来客。那登录状态怎么维持?靠两段协作:
- 服务器在响应头里下发
Set-Cookie: session_id=abc123; HttpOnly; - 浏览器后续每个同域请求自动带上
Cookie: session_id=abc123。
服务器拿 session_id 去自己的存储(内存、Redis)里查「这个 id 属于谁」,这就是 Session。状态不在 HTTP 里,而在服务器的一个外部表里,Cookie 只是那张表的钥匙。 所以水平扩容时要共享 Session 存储,否则请求被负载均衡打到另一台机器就「掉登录」。
16.1.5 HTTP/1.1 与 keep-alive
HTTP/1.0 默认「一请求一连接」——每个请求都要重新三次握手,昂贵。HTTP/1.1 默认 持久连接(keep-alive):一条 TCP 连接上可以串行发送多个请求。上面响应头里若出现 Connection: keep-alive(且没有 Connection: close),连接就会被复用。
但 1.1 有队头阻塞:同一连接上的请求必须按序处理,前一个慢,后面全等。于是有了 HTTP/2(多路复用)与 HTTP/3(基于 QUIC)。这些由服务器/客户端协商,应用代码一般无需关心——但你要知道「一个连接不等于一个请求」。
16.1.6 WSGI:一个可调用对象
现在把 HTTP 接进 Python。WSGI(Web Server Gateway Interface,PEP 3333)规定了服务器与应用之间唯一的契约:应用是一个可调用对象,签名固定为 app(environ, start_response)。
environ:一个 dict,装着本次请求的全部信息(方法、路径、头、输入流)。start_response:应用调它来声明状态码与响应头,返回一个写体的函数。- 返回值:一个可迭代对象,逐块产出响应体字节。
用标准库 wsgiref.simple_server 起一个最小 WSGI 应用:
from wsgiref.simple_server import make_server
import threading, urllib.request, json
def app(environ, start_response):
body = json.dumps(
{"method": environ["REQUEST_METHOD"],
"path": environ["PATH_INFO"],
"query": environ.get("QUERY_STRING", "")},
ensure_ascii=False,
).encode("utf-8")
headers = [("Content-Type", "application/json; charset=utf-8"),
("Content-Length", str(len(body)))]
start_response("200 OK", headers)
return [body]
srv = make_server("127.0.0.1", 8001, app)
threading.Thread(target=srv.serve_forever, daemon=True).start()
with urllib.request.urlopen("http://127.0.0.1:8001/hello?lang=zh") as resp:
print(resp.status, resp.read().decode("utf-8"))
srv.shutdown()
真实输出:
200 {"method": "GET", "path": "/hello", "query": "lang=zh"}
environ 里几个关键键(实测打印):
REQUEST_METHOD = 'GET'
PATH_INFO = '/a/b'
QUERY_STRING = 'x=1'
SERVER_PROTOCOL = 'HTTP/1.1'
HTTP_HOST = '127.0.0.1:8003'
HTTP_USER_AGENT = 'Python-urllib/3.14'
规律是:请求头 Foo-Bar 变成 HTTP_FOO_BAR(大写、连字符换下划线、加前缀),而方法、路径、查询串等核心字段有专用键名。wsgi.input 是一个类文件对象,请求体从它读。任何 WSGI 框架(Flask、Django)本质上都在做同一件事:把 environ 解析成漂亮的 request 对象,再把你的返回值编码成字节流。
16.1.7 WSGI 的天花板:同步阻塞模型
WSGI 应用是同步函数。服务器在一个线程/进程里调用它,从第一行执行到最后一行,全程独占这个执行单元。如果应用里做了一次慢 I/O(查数据库、调第三方接口),这个 worker 就卡在那里干等,没法去处理别的请求。
想提升并发只能加 worker(多进程或多线程)——一个 worker 一次只服务一个请求。这就是 12.1 节 GIL 与 12.3 节线程池讨论的模型在 Web 场景的复现:I/O 密集型负载下,大量 worker 其实都在阻塞等待,CPU 却闲着。 WSGI 的天花板,正是「同步阻塞」这四个字。
16.1.8 ASGI:async def app(scope, receive, send)
ASGI(Asynchronous Server Gateway Interface)是 WSGI 的异步继任者,同样定义一个可调用对象,但签名变成三个参数:
async def app(scope, receive, send):
...
scope:一个 dict,描述本次连接(类型、方法、路径、头)。receive:await receive()得到一个事件(如http.request),即读。send:await send({...})发送一个事件(如http.response.start),即写。
事件驱动的读写让应用可以在 await 处主动让出,事件循环转去处理别的连接。用 uvicorn 真跑一个最小 ASGI 应用:
import threading, time, uvicorn, httpx
async def app(scope, receive, send):
assert scope["type"] == "http"
body = f"{scope['method']} {scope['path']}".encode("utf-8")
await send({"type": "http.response.start", "status": 200,
"headers": [(b"content-type", b"text/plain; charset=utf-8")]})
await send({"type": "http.response.body", "body": body})
config = uvicorn.Config(app, host="127.0.0.1", port=8002, log_level="warning")
server = uvicorn.Server(config)
threading.Thread(target=server.run, daemon=True).start()
time.sleep(1)
resp = httpx.get("http://127.0.0.1:8002/ping?q=1", trust_env=False)
print(resp.status_code, resp.text, resp.headers["server"])
server.should_exit = True
真实输出:
200 GET /ping uvicorn
注意响应分成两个事件:先 http.response.start(状态 + 头),再 http.response.body(体)。中间可以插入更多 body 事件实现流式响应,这正是 WSGI 做不到的。ASGI 还统一支持 WebSocket(scope["type"] == "websocket")与生命周期事件(lifespan),一套协议覆盖三种连接。
16.1.9 environ 与 scope 对照
| 信息 | WSGI environ | ASGI scope |
|---|---|---|
| 连接类型 | 隐含(都是 HTTP) | scope["type"]:http/websocket/lifespan |
| 方法 | environ["REQUEST_METHOD"] | scope["method"] |
| 路径 | environ["PATH_INFO"] | scope["path"] |
| 查询串 | environ["QUERY_STRING"] | scope["query_string"](bytes) |
| 头 | 扁平化为 HTTP_* 键 | scope["headers"]((bytes, bytes) 列表) |
| 读体 | environ["wsgi.input"].read()(同步) | await receive()(异步事件) |
| 写响应 | start_response(...) + 返回可迭代体 | await send(...)(多次事件) |
| 调用形式 | app(environ, start_response) | await app(scope, receive, send) |
一眼可见:WSGI 用扁平 dict + 同步返回值,ASGI 用事件流 + 协程。ASGI 的 headers 是原始字节列表,不做归一化,保真但需要应用自己解析。
16.1.10 中间件在两种协议里的位置
中间件是「包在应用外面」的一层,做日志、鉴权、CORS 等横切逻辑。位置就在服务器与应用之间:
- WSGI 中间件:本身也是一个
(environ, start_response)可调用对象。它拿到请求先做点事,再把(可能改写过的)environ交给内层应用;内层start_response产出的响应头,它也能改。 - ASGI 中间件:本身也是一个
async (scope, receive, send)。它包住内层的receive/send,可以拦截、改写事件流。
两者结构完全对称,区别只在同步还是异步。FastAPI/Starlette 的中间件就是 ASGI 中间件,这也解释了为什么它们的中间件都要 async def dispatch。
16.1.11 反向代理与 X-Forwarded-*
生产环境里,应用前面通常站着 Nginx(反向代理)。用户连的是 Nginx,Nginx 再把请求转给 uvicorn。于是应用看到的 client 地址是 Nginx 的,协议是内部的 HTTP 而非用户侧的 HTTPS。代理会补上几个头来还原真相:
| 头 | 含义 |
|---|---|
X-Forwarded-For | 原始客户端 IP(可能是逗号分隔的链) |
X-Forwarded-Proto | 用户侧协议(https) |
X-Forwarded-Host | 用户请求的 Host |
X-Real-IP | 原始客户端 IP(Nginx 常用单值形式) |
应用必须显式信任这些头(只信任自己的代理,否则可被伪造),uvicorn 的 --proxy-headers 与 Starlette 的 ProxyHeadersMiddleware 就是干这个的。生成重定向 URL、记访问日志、判断 HTTPS 时都会用到它们。
小结
- HTTP 是文本协议:请求/响应都由「起始行 + 头 + 空行 + 体」组成,空行是元数据与载荷的分界。
- 方法是语义(安全/幂等),状态码是结果分类,
Content-Type的charset必须显式写utf-8。 - HTTP 无状态,登录态靠 Cookie + 服务端 Session 表维持;HTTP/1.1 默认 keep-alive,连接可复用但有队头阻塞。
- WSGI 应用是
app(environ, start_response),同步阻塞,靠加 worker 扩容,I/O 密集时 CPU 利用率低。 - ASGI 应用是
async def app(scope, receive, send),事件驱动、支持流式与 WebSocket,是 FastAPI/Starlette 的底座。 - 中间件在两种协议里都是「包一层的可调用对象」,反向代理场景要显式信任
X-Forwarded-*。
这一节我们把「HTTP 之下的地板」铺好了:知道请求怎么进来、响应怎么出去、协议契约长什么样。下一节就用 ASGI 之上的 FastAPI,把这些底层细节收进框架,专心写业务——你会不断看到本节概念的影子。
阅读导航:上一节:版本约束、锁定与可复现构建 · 下一节:FastAPI 快速上手 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。