FastAPI系列-06-请求与响应

FastAPI系列-06-请求与响应

_

06 请求与响应 —— Pydantic 模型是你的双向契约

前置阅读: 04-路由05-参数分类
关键词: Pydantic, response_model, async def, ORM 序列化
难度: ★★★☆☆

场景导入:同样的字段,请求和响应能共用一套模型吗?

图书管理 API 的 POST /books 需要接收书名、作者 ID、ISBN——三个字段,缺一不可,ISBN 还得刚好 13 位。而 GET /books/42 返回的却是 idtitleisbnstatus——字段集合完全不同。更麻烦的是,写入时 id 由数据库自增生成,客户端根本不应该传;输出时 author_id 可能想替换为嵌套的作者对象。

如果你让处理函数直接接收 dict,字段缺失、类型错误、多余字段全得靠 if 判断,代码很快就变成意大利面。FastAPI 的解法是用 Pydantic 模型分别定义输入契约和输出契约——BookCreate 管写入,BookOut 管返回。框架在 handler 执行前后各做一次校验:请求体先过 BookCreate,响应体再过 BookOut,两层防线互不干扰。

原理解析:输入和输出是两条独立管线

请求与响应虽然对称,但在 FastAPI 内部走的是两条完全不同的处理链:

flowchart TD A["客户端发送 JSON"] --> B["读取请求体"] B --> C{"Pydantic 校验"} C -->|"字段缺失或类型错误"| D["RequestValidationError"] D --> E["默认 422 JSON"] C -->|"校验通过"| F["构造 BookCreate 实例"] F --> G["执行 handler(可能是 async def)"] G --> H["返回 ORM 实例或 Pydantic 模型"] H --> I["response_model 过滤与校验"] I --> J["序列化为 JSON 响应"]

(图注:请求模型负责输入边界,在 handler 之前拦截脏数据;响应模型负责输出边界,在 handler 之后过滤敏感字段。两条管线独立运行。)

Pydantic 模型本质上是一份"数据契约"。BookCreate 描述新增接口允许写入的字段和约束,BookOut 描述对外暴露的字段。两者不应该共用一个模型——写入模型暴露可写字段,输出模型只暴露可读字段。把数据库内部列(如 created_atversion)直接泄露给客户端,或者让客户端有机会传一个 id 过来,都是契约设计的典型反模式。

Pydantic v2 中一个关键的配置是 ConfigDict(from_attributes=True)(等价于 v1 的 orm_mode = True)。它告诉 Pydantic:你可以直接从 ORM 实例的属性读取数据,不需要先转成字典。当 response_model=BookOut 且 handler 返回 SQLAlchemy 的 Book 实例时,FastAPI 调用 BookOut.model_validate(book) 完成属性读取和字段过滤。整个过程不需要你手写任何转换代码。

这条链路能顺畅运行有一个隐藏前提:13 章设定的 expire_on_commit=False。如果不设这个,事务提交后 ORM 对象的所有属性都会"过期",序列化阶段访问任何属性都会触发一次隐式的 SELECT——这在异步栈下尤其危险,因为那次 SELECT 可能根本不可 await

从 handler 返回数据到客户端拿到 JSON 的完整流程:

sequenceDiagram participant C as 客户端 participant R as FastAPI 路由 participant P as Pydantic participant D as DAO / DB participant M as response_model C->>R: POST /books + JSON R->>P: 验证 BookCreate P-->>R: 合法模型实例 R->>D: await book_dao.create_book(...) D-->>R: Book ORM 实例 R->>M: model_validate(Book) M-->>C: 201 JSON 响应

(图注:异步栈下 handler 需要 await 数据库调用,响应阶段再由 response_model 把 ORM 实例转为对外 JSON 结构。)

handler 形态的选择仍然遵循 03 章的准则:POST /books 因为要 await DAO 调用,保持 async defGET /books/schema 只是返回预构造的 Pydantic 模型,不涉及任何 I/O,写成 def 就好,框架把它扔线程池。

代码实现:输入模型和输出模型分家

先定义两个独立的 Schema:

# app/schemas/book.py
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.book import BookStatus


class BookOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    title: str
    isbn: str
    status: BookStatus


class BookCreate(BaseModel):
    title: str
    author_id: int
    isbn: str

注意 BookCreate 没有 id、没有 status、没有 version——这些都是数据库负责的字段,客户端不该碰。BookOut 通过 from_attributes=True 能直接从 ORM 实例读取属性,你不需要在 handler 里写 book.idbook.title 逐个字段手动赋值。

路由层的代码干净利落:

# app/api/books.py(节选)
from fastapi import APIRouter, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.book import BookCreate, BookOut
from app.models.book import Book, BookStatus

router = APIRouter(prefix="/books", tags=["books"])
DBDep = ...  # Annotated[AsyncSession, Depends(get_async_db)],见第 15 章


@router.post("", response_model=BookOut, status_code=status.HTTP_201_CREATED)
async def create_book(payload: BookCreate, db: DBDep) -> Book:
    """POST /books:异步栈预告,完整实现在第 17 章。"""
    from app.dao import book_dao
    return await book_dao.create_book(db, **payload.model_dump())


@router.get("/schema", response_model=BookOut)
def book_schema() -> BookOut:
    """纯响应构造,无数据库 I/O,使用 def。"""
    return BookOut(
        id=0,
        title="preview",
        isbn="0000000000000",
        status=BookStatus.AVAILABLE,
    )

几个细节值得展开:

  • create_book 返回的是 SQLAlchemy 的 Book ORM 实例,不是 BookOutresponse_model=BookOut 在响应阶段自动完成转换——model_validate(book) 读取属性并过滤掉不在 BookOut 中定义的字段。

  • payload.model_dump() 把 Pydantic 模型展平为关键字参数字典,这样 DAO 函数可以保持扁平签名,不依赖 Schema 类型。

  • book_schema 写成 def,因为它只构造一个静态对象返回,不涉及任何数据库或网络 I/O。Starlette 自动把它投递到线程池。

避坑指南:序列化阶段的隐藏炸弹

  • response_model 字段越多、嵌套越深,序列化成本越高。列表接口应该用轻量级的输出模型(如 BookSummary,只含 idtitlestatus),而不是把完整的作者信息和借阅记录全嵌套进去。这是一条经验法则:查询列表用摘要模型,查询详情用完整模型。

  • from_attributes=True 只读已加载的属性。如果 ORM 实例的关联字段(如 book.author)没有被 selectinloadjoinedload 提前加载,序列化阶段会触发 lazy loading,产生 N+1 查询。解决办法是在 DAO 层明确加载策略,不要指望 Pydantic 替你优化。

  • response_model 和返回类型注解都能影响 OpenAPI 文档,但行为不同response_model 在响应阶段强制按声明模型过滤和校验——即使 handler 返回的字典里有额外字段,客户端也只看得到 response_model 声明的字段。而裸的返回类型注解(-> BookOut)不会过滤多余字段。生产项目中,始终显式声明 response_model

  • v1 迁 v2 的配置变更ConfigDict(from_attributes=True) 等价于 v1 的 class Config: orm_mode = True。迁移时如果忘了改,ORM 实例就无法直接传入 model_validate,会报类型错误。

面试 QA

Q1 [原理]: Pydantic 模型在 FastAPI 中承担哪些角色?为什么请求和响应不应该共用一个模型?

Pydantic 模型在 FastAPI 中是双向契约:请求侧负责校验输入,响应侧负责过滤输出。但这两个方向的字段集合几乎总是不同的——写入时不需要 idcreated_at,输出时可能不想暴露 version 或内部状态。

生产项目通常维护三套模型:BookCreate(写入)、BookUpdate(部分更新)、BookOut(输出)。它们之间可能有字段重叠,但职责互不干扰。只用一套模型的后果是,数据库列的新增或删除会直接暴露给 API 消费者——你加了一个内部用的 review_status 列,客户端就能在响应里看到它,而且客户端还能在 POST 请求里尝试传一个 id 过来。

源码层面,请求体的解析入口在 fastapi/dependencies/utils.py,响应序列化在 fastapi/routing.pyserialize_response。两条链路共享 Pydantic 的校验引擎,但触发时机完全不同。

Q2 [项目]: response_model 与 handler 的返回类型注解有什么差别?为什么推荐显式声明 response_model

两者都能影响 OpenAPI 文档生成,但运行时行为不同。response_model 在响应阶段强制按声明模型过滤字段——handler 返回的 ORM 实例哪怕有 20 个属性,客户端也只看到 response_model 声明的 4 个。裸返回类型注解(-> BookOut)只在缺少 response_model 时充当序列化目标,而且不会过滤多余字段。

简单说:response_model 是"强制执行",返回类型注解是"仅供参考"。生产项目中任何对外暴露的端点,都应该显式声明 response_model,把 schema 版本、数据库投影和 OpenAPI 变更纳入统一的发布检查。

小结

本篇用 BookCreateBookOut 两个独立的 Pydantic 模型划分了输入与输出边界。核心原则很简单:永远不要让客户端看到数据库内部列,也永远不要让客户端有机会写入应由服务端生成的字段。response_model 不只是文档生成工具——它是 API 契约的最后一道防线。

POST /booksasync def)和 GET /books/schemadef)也再次验证了 03 章的准则:handler 形态只看有没有可 await 的 I/O,与参数类型无关。下一篇《07 自定义响应数据格式》将讨论 JSONResponseORJSONResponse 等更灵活的响应控制,以及 expire_on_commit=False 在序列化阶段的具体作用。

FastAPI系列-07-自定义响应数据格式 2026-06-20
FastAPI系列-05-参数分类 2026-06-16

评论区