《Python编程入门》16.1 HTTP 与 WSGI / ASGI

从一次真实的 HTTP 交互出发,看懂请求行、状态码、响应头与消息体;再分别手写最小 WSGI 应用(wsgiref)与最小 ASGI 应用(uvicorn)并真跑起来,对照 environ 与 scope 结构,理解同步阻塞模型与事件循环模型的根本差异。

本节目标:看懂 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 本身无状态:服务器处理完一个请求就忘掉它,下一个请求对服务器而言是全新来客。那登录状态怎么维持?靠两段协作:

  1. 服务器在响应头里下发 Set-Cookie: session_id=abc123; HttpOnly;
  2. 浏览器后续每个同域请求自动带上 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 environASGI 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 快速上手 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时