《Python编程入门》16.2 FastAPI 快速上手

用 FastAPI 写下第一个真实可跑的 API:路径参数、查询参数、Pydantic 请求体、response_model、HTTPException、BackgroundTasks、APIRouter 与 Depends,看它自动生成的 OpenAPI 文档,并用 TestClient 写测试;最后实测 def 与 async def 端点的线程差异。

本节目标:用 FastAPI 从零写出可运行的 API,掌握路由、参数校验、响应模型、依赖与测试,理解 def 与 async def 端点的执行差异。
适用版本:Python 3.12+(实测 3.14.6)

16.2 FastAPI 快速上手

16.1 节我们手写了 ASGI 应用,靠 await send({...}) 拼响应——能用,但太底层。这一节请出 FastAPI:它把 ASGI 的样板全部收进框架,让你用「类型注解 + 装饰器」描述接口,剩下的校验、序列化、文档全自动完成。安装(版本号来自本机实测):pip install "fastapi==0.143.0" "uvicorn==0.51.0"。

16.2.1 FastAPI 的三块拼图

FastAPI 本身很薄,它站在两个库的肩膀上:

层由谁提供负责什么
Web 框架Starlette(1.7.0)路由、中间件、ASGI 适配、TestClient
数据校验Pydantic(2.13.5)请求/响应的模型校验与序列化
文档FastAPI 自研由类型注解自动生成 OpenAPI + Swagger UI

所以「会用 FastAPI」约等于「会用 Starlette 的路由 + Pydantic 的模型」——这两样我们在 9.3 节和 16.1 节都打过底。类型注解是唯一的真相来源:你写的注解同时驱动运行时校验和文档生成。

16.2.2 第一个应用

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI(title="Book API", version="0.1.0")

@app.get("/")
def root():
    return {"app": "Book API"}

@app.get("/items/{item_id}")
def get_item(item_id: int):          # 路径参数,声明成 int
    return {"item_id": item_id, "type": type(item_id).__name__}

client = TestClient(app)
print(client.get("/").json())
print(client.get("/items/42").json())
print(client.get("/items/abc").status_code)

真实输出:

{'app': 'Book API'}
{'item_id': 42, 'type': 'int'}
422

item_id: int 这行注解做了三件事:把路径里的字符串 "42" 转成整数 42、自动生成文档、非法时返回 422。用 uvicorn 起服务只需 uvicorn main:app --reload(main:app 指 main.py 里的 app 对象,--reload 改代码自动重启,仅开发用)。

16.2.3 自动文档:/docs 与 /redoc

FastAPI 启动时会扫描所有路由,生成一份 OpenAPI 规范,再据此渲染两套 UI。实测三个内置端点的状态码:

/docs          200 text/html        # Swagger UI,可直接在页面里调接口
/redoc         200 text/html        # ReDoc,更适合阅读
/openapi.json  200 application/json # 机器可读的规范本身

/openapi.json 里 /items/{item_id} 的真实片段(省略无关字段):

{
  "summary": "Get Item",
  "parameters": [
    { "name": "item_id", "in": "path", "required": true,
      "schema": { "type": "integer", "maximum": 1000, "minimum": 1 } }
  ],
  "responses": { "200": { "description": "Successful Response" } }
}

注意 maximum: 1000, minimum: 1 是从 Path(ge=1, le=1000) 自动推出的——约束写一遍,运行时校验和文档同时生效。这份 openapi.json 还能直接喂给前端代码生成器,前后端联调从此少一半口水。

16.2.4 路径参数与查询参数的校验

用 Path / Query 给参数加约束,语义与 9.3 节的 Field 一致:

from fastapi import FastAPI, Path, Query

app = FastAPI()

@app.get("/items/{item_id}")
def get_item(item_id: int = Path(ge=1, le=1000)):
    return {"item_id": item_id}

@app.get("/search")
def search(q: str = Query(min_length=2), limit: int = 10):
    return {"q": q, "limit": limit}
GET /items/0      -> 422  (greater_than_equal)
GET /search?q=a   -> 422  (string_too_short)
GET /search?q=hi  -> 200  {"q": "hi", "limit": 10}

没有默认值的参数是必填的,有默认值就是可选。路径参数因为出现在 URL 模板里,永远必填。查询参数若写成 q: str(无默认值)也必填,访问不带 q 会 422。

16.2.5 请求体与 response_model

请求体用 Pydantic 模型声明,FastAPI 会自动解析 JSON 并校验:

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()

class UserIn(BaseModel):
    name: str
    email: str
    password: str

class UserOut(BaseModel):          # 出口模型:不含 password
    name: str
    email: str

@app.post("/users", response_model=UserOut)
def create_user(u: UserIn):
    return u                        # 输入有 password,输出被过滤掉

c = TestClient(app)
print(c.post("/users", json={"name": "Ada", "email": "a@x.com", "password": "s3cr3t"}).json())
{'name': 'Ada', 'email': 'a@x.com'}

response_model 是一道安全闸门:它按 UserOut 重新序列化返回值,password 无论怎么混进来都出不去。永远不要让「输入模型」直接当「输出模型」。

想让「未显式设置的字段不出现在响应里」,加 response_model_exclude_unset=True:

class Profile(BaseModel):
    name: str
    bio: str = "这个人很懒"
    website: str | None = None

@app.get("/profile", response_model=Profile, response_model_exclude_unset=True)
def profile():
    return {"name": "Ada"}          # bio/website 没设,就不输出
GET /profile -> {'name': 'Ada'}

不加这个开关,bio 会带上默认值一起返回。默认值适合「业务兜底」,exclude_unset 适合「PATCH 式部分更新」,按场景选。

16.2.6 状态码与 HTTPException

创建成功应回 201,用 status_code 声明;出错用 HTTPException:

from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient

app = FastAPI()
ITEMS = {1: {"name": "键盘", "price": 199.0}}

@app.post("/items", status_code=status.HTTP_201_CREATED)   # 创建成功回 201
def create_item():
    return {"item_id": 3}

@app.get("/items/{item_id}")
def get_item(item_id: int):
    if item_id not in ITEMS:
        raise HTTPException(status_code=404, detail="Item not found")
    return {"item_id": item_id, **ITEMS[item_id]}

c = TestClient(app)
print(c.post("/items").status_code)
print(c.get("/items/999").status_code, c.get("/items/999").json())
201
404 {'detail': 'Item not found'}

HTTPException 会被转成 {"detail": ...} 的 JSON 响应。注意区分:校验失败(422)是框架自动的,业务失败(404/409/…)要你自己 raise。

16.2.7 后台任务 BackgroundTasks

有些活儿不必让用户等(发邮件、写审计日志),丢给 BackgroundTasks:

from fastapi import FastAPI, BackgroundTasks

app = FastAPI()
log = []

def write_audit(msg: str):
    log.append(msg)

@app.post("/signup")
def signup(bg: BackgroundTasks):
    bg.add_task(write_audit, "audit: new signup")
    return {"ok": True}

用 TestClient 请求后,响应体是 {'ok': True},而 log 里已经多出一条 'audit: new signup'。

响应先返回,任务在响应之后执行。但它跑在同一个进程里,只适合几秒内的小任务;真正耗时的工作要用消息队列交给独立 worker,否则会拖慢甚至压垮 Web 进程。

16.2.8 路由分组:APIRouter

接口一多,路由堆在一个文件里就乱了。APIRouter 把一组路由装进一个对象,再用 prefix / tags 统一前缀与文档分组:

from fastapi import FastAPI, APIRouter

app = FastAPI()
router = APIRouter(prefix="/api/v1", tags=["v1"])

@router.get("/ping")
def ping():
    return {"pong": True}

app.include_router(router)

此时 ping 的实际路径是 /api/v1/ping,且在 /docs 里归入 v1 分组。按业务拆成 users.py、orders.py 各自的 router,是 FastAPI 项目组织的标准做法。

16.2.9 Depends 的最小用法

依赖注入让「准备数据」和「处理请求」解耦。函数依赖写起来就是普通函数:

from typing import Annotated
from fastapi import FastAPI, Depends

app = FastAPI()

def pagination(skip: int = 0, limit: int = 10) -> dict:
    return {"skip": skip, "limit": limit}

@app.get("/list")
def list_items(p: Annotated[dict, Depends(pagination)]):
    return p
GET /list?skip=5&limit=20 -> {'skip': 5, 'limit': 20}

Depends(pagination) 告诉 FastAPI「先调用 pagination,把结果注入 p」。妙处在于 pagination 的 skip/limit 也是带类型的查询参数,校验与文档一并自动生成。带 yield 的依赖能做资源清理(等价于 pytest 的 fixture),16.3 节讲数据库时会重点展开。

16.2.10 async def 端点 vs def 端点

FastAPI 有个容易踩坑的行为:端点函数用 def 还是 async def,执行方式完全不同。

  • async def 端点:在事件循环线程里直接运行。
  • def 端点:被丢进 anyio 线程池,由工作线程运行。

实测(uvicorn 起真实服务,打印各端点所在线程):

uvicorn 事件循环线程 id = 6140866560
/async -> thread=6140866560  name=Thread-1 (run)        是事件循环线程: True
/sync  -> thread=6157692928  name=AnyIO worker thread   是事件循环线程: False

含义很关键:def 端点里哪怕写了阻塞调用(requests、同步数据库),也只是占住一个工作线程,不会卡住整个事件循环;而 async def 端点里如果做了阻塞操作,会把整个事件循环卡死。实测 4 个并发请求各阻塞 0.5 秒:

4 个并发请求 /block-async(async def 里 time.sleep)耗时 2.05s
4 个并发请求 /block-sync (def 里 time.sleep)        耗时 0.52s

async def 里串行阻塞,4 个请求排成 2 秒;def 里被线程池并行处理,只要 0.5 秒。结论:async def 要配真正的异步库(如 13 章的 httpx.AsyncClient),def 才配同步库——最忌讳 async def 里塞同步阻塞代码。

16.2.11 用 TestClient 写测试

TestClient 让你不启动服务器就能测接口,写法像同步函数,底层走的是 ASGI:

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.get("/items/{item_id}")
def get_item(item_id: int):
    return {"item_id": item_id}

client = TestClient(app)

def test_ok():
    assert client.get("/items/1").json() == {"item_id": 1}

def test_bad_type():
    assert client.get("/items/abc").status_code == 422

配合 14 章的 pytest 与 fixture,就能覆盖「正常路径 + 校验失败 + 业务异常」三类用例。response 有 .status_code / .json() / .headers,断言非常直接。

16.2.12 生产部署与多进程

开发用 uvicorn main:app --reload 单进程即可;生产用 uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 开多进程榨干多核。--workers 会拉起多个子进程,每个进程各跑一个事件循环。为什么要多进程而不是多线程? 回到 12.1 节的 GIL:一个 CPython 进程同一时刻只有一个线程执行字节码,纯计算无法靠线程并行。ASGI 的 I/O 并发已经很强,但要吃满多核 CPU 仍得靠多进程。经验公式是 workers ≈ 2 × CPU 核数 + 1,但现代容器更推荐「一个容器一个进程」,用编排层横向扩容。

小结

  • FastAPI = Starlette(Web)+ Pydantic(校验)+ 自动 OpenAPI 文档;类型注解是唯一真相来源。
  • 路径参数靠注解自动转换与校验,Path/Query 加约束,非法输入统一 422。
  • response_model 是出口安全闸门,response_model_exclude_unset 控制部分更新;业务错误用 HTTPException。
  • BackgroundTasks 只做轻量收尾,重活交给消息队列;APIRouter 按业务拆分路由。
  • Depends 把准备数据与处理请求解耦;async def 端点跑在事件循环线程,def 端点跑在线程池——别在 async def 里写阻塞代码。
  • 生产用 uvicorn --workers 多进程吃多核(GIL 决定了要进程而非线程)。

到这里,你已经能写出一批能跑的接口了。但真实服务要落库、要校验复杂规则、要复用连接——下一节我们把 Pydantic 校验、Depends 依赖注入和 SQLAlchemy 数据库访问接进来,拼成一个像样的后端。

阅读导航:上一节:HTTP 与 WSGI / ASGI · 下一节:数据校验、依赖注入与数据库访问 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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