Skip to content

第 5 章:响应模型与状态码 ​

数据校验只做了进的一半。出的一半同样要管:声明响应模型能过滤敏感字段、约束输出结构、自动生成准确的文档。本章把「返回什么、以什么状态码返回、怎么返回」讲清楚。


学习目标 ​

  • 理解声明响应模型的三个价值:数据过滤、输出校验、文档化
  • 掌握 response_model 与函数返回类型注解的等价性与优先级差异
  • 能独立设计 UserIn / UserOut 分离的输入输出模型,并用 from_attributes 直接返回 ORM 对象
  • 区分 exclude_unset / exclude_none / include / exclude 四种过滤语义
  • 掌握 status 常量、responses 声明、Response 直构与 204 无内容响应

📌 本章仍用单文件 main.py;第 11 章会把这里的 schema 迁到 app/schemas/。


5.1 为什么必须声明响应模型 ​

很多人写完 return obj 就收工,直到某天上线发现接口把 hashed_password、internal_note 一起吐给了前端。响应模型解决三个问题:

价值说明不声明会怎样
数据过滤只有模型声明的字段会被序列化敏感字段随 ORM 对象整体泄露
输出校验返回值不符合模型时抛 ResponseValidationError前端拿到 null 或类型错乱,问题在客户端才暴露
文档化Swagger UI 的 Response Schema、示例、字段说明都来自它文档里只有一个笼统的 200 successful

注意那个 500 分支:响应模型校验失败是服务端 bug,不是客户端错误,所以它不会变成 422,而是 500。这是排查时的重要线索。

后面小节共用一个 Item 模型:

python
from pydantic import BaseModel


class Item(BaseModel):
    name: str
    price: float
    secret_cost: float | None = None

5.2 response_model 与返回类型注解 ​

两种写法都合法:

python
from fastapi import FastAPI

app = FastAPI()


# 写法 A:显式 response_model
@app.get("/items/a", response_model=Item)
async def read_item_a() -> dict[str, object]:
    return {"name": "键盘", "price": 499.0, "secret_cost": 210.0, "extra": "被丢弃"}


# 写法 B:函数返回类型注解
@app.get("/items/b")
async def read_item_b() -> Item:
    return {"name": "键盘", "price": 499.0, "secret_cost": 210.0, "extra": "被丢弃"}

两者行为一致:extra 被丢弃(模型未声明),secret_cost 保留。优先级如下:

场景实际生效的响应模型
只写 response_modelresponse_model
只写返回类型注解返回类型注解
两者都写且一致同一个
两者都写且不一致只用 response_model,返回类型注解被完全忽略

最后一行值得展开:注解是 -> Item 而装饰器写 response_model=Other 时,文档里展示 Other,运行时也按 Other 校验。返回一个符合 Item 却不含 Other 字段的对象,会直接拿到 500。别让两个都写的时候打架,或者干脆只保留一个。

⚠️ 返回类型注解写 -> None 或 -> Response 时 FastAPI 不会套用响应模型。用 Response 子类做注解时直接透传,不做序列化——见 5.8。


5.3 输入模型 ≠ 输出模型(重点) ​

这是本章最该带走的一条实践:创建用的模型和展示用的模型必须分开。

python
from datetime import datetime

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field


# ---------- 输入:允许出现密码 ----------
class UserIn(BaseModel):
    username: str = Field(min_length=3, max_length=20)
    email: str
    password: str = Field(min_length=8)


# ---------- 输出:绝不允许出现密码 ----------
class UserOut(BaseModel):
    # 关键开关:允许从对象属性读取
    model_config = ConfigDict(from_attributes=True)

    id: int
    username: str
    email: str
    created_at: datetime


# ---------- ORM 模型(第 10 章会换成真正的 SQLAlchemy 模型) ----------
class UserORM:
    """先用普通类模拟 ORM 实例,方便本章单文件运行。"""

    def __init__(self, id: int, username: str, email: str, password_hash: str, created_at: datetime) -> None:
        self.id = id
        self.username = username
        self.email = email
        self.password_hash = password_hash
        self.created_at = created_at


FAKE_DB: dict[int, UserORM] = {
    1: UserORM(1, "moqian", "moqian@example.com", "$argon2id$v=19$...", datetime(2025, 1, 1, 9, 30)),
}

app = FastAPI()


@app.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: int) -> UserORM:
    row = FAKE_DB.get(user_id)
    if row is None:
        raise HTTPException(status_code=404, detail="用户不存在")
    # 直接把 ORM 对象交出去
    return row


@app.post("/users", response_model=UserOut, status_code=201)
async def create_user(payload: UserIn) -> UserORM:
    new_id = max(FAKE_DB) + 1
    row = UserORM(new_id, payload.username, payload.email, f"hashed:{payload.password}", datetime.now())
    FAKE_DB[new_id] = row
    return row

GET /users/1 的响应里只有 id / username / email / created_at——password_hash 连出现的资格都没有,因为 UserOut 里根本没这个字段。过滤发生在序列化阶段,靠的是「不声明」,而不是靠「记得删」。

⚠️ 一个容易误解的细节:FastAPI 在响应序列化时是带 from_attributes=True 去校验返回值的,所以即使 UserOut 没写 model_config,直接 return row 也常常能成功。但这个宽松只存在于「FastAPI 处理响应」这一条路径上——你在 service 或测试里手写 UserOut.model_validate(row),没开 from_attributes 就会抛 Input should be a valid dictionary or instance of UserOut。显式写上 from_attributes=True,让响应、service、测试三条路径行为一致。

💡 命名上用 XxxIn / XxxCreate / XxxUpdate 表示输入,XxxOut / XxxRead / XxxPublic 表示输出。第 11 章的分层结构里,这一对模型分别放在 app/schemas/ 的不同文件。

错误示范 vs 正确示范 ​

❌ 不推荐:

python
@app.get("/users/{user_id}")
async def get_user_bad(user_id: int):
    row = FAKE_DB.get(user_id)
    return row  # 没有 response_model:password_hash 原样泄露

✅ 推荐:

python
@app.get("/users/{user_id}", response_model=UserOut)
async def get_user_good(user_id: int) -> UserORM:
    row = FAKE_DB.get(user_id)
    if row is None:
        raise HTTPException(status_code=404, detail="用户不存在")
    return row  # 由 UserOut 决定对外暴露哪些字段

5.4 四种过滤参数的语义差异 ​

python
class Profile(BaseModel):
    nickname: str | None = None
    bio: str | None = None
    website: str | None = None
    is_active: bool = True


@app.get(
    "/profiles/{pid}",
    response_model=Profile,
    response_model_exclude_unset=True,
)
async def read_profile(pid: int) -> dict[str, object]:
    # 只设置了一部分字段
    return {"nickname": "墨谦", "is_active": True}
参数语义结果(以上例为返回 dict 时)适用场景
response_model_exclude_unset=True只输出被显式赋值过的字段{"nickname": "墨谦", "is_active": true},bio / website 不出现PATCH 局部更新后回显
response_model_exclude_none=True只输出值不为 None 的字段显式为 None 的字段被剔除,其余保留前端不想处理 null,减少 payload
response_model_include={"nickname"}白名单,只保留指定字段{"nickname": "墨谦"}同一模型复用于多个精简端点
response_model_exclude={"bio", "website"}黑名单,剔除指定字段其余字段全部输出临时屏蔽少数字段

exclude_unset 与 exclude_none 的区别在「显式传了 None」时会暴露出来:{"nickname": "墨谦", "bio": None} 在 exclude_unset 下会输出 bio: null(因为它被赋值了),在 exclude_none 下则被剔除。

三个易被忽略的细节:

  1. exclude_unset 判断的是「有没有被赋值」,不是「值是否等于默认值」。Profile(nickname="墨谦") 里 website 从未被设置,即使默认值就是 None,也会被排除。
  2. exclude_unset 影响运行时输出,不改变文档里的 schema,文档仍展示完整模型。
  3. 这四个参数属于「响应层」配置,写在装饰器上;同一模型在别处需要完整输出时,各端点各配一份,不要改模型本身。

5.5 状态码:用常量,不要用魔法数字 ​

python
from fastapi import FastAPI, status

app = FastAPI()


@app.post("/items", status_code=status.HTTP_201_CREATED)
async def create_item() -> dict[str, str]:
    return {"status": "created"}


@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(item_id: int) -> None:
    return None

fastapi.status 是对 starlette.status 的再导出,本质是 IntEnum 常量集合。常用的一小撮:

常量值语义
HTTP_200_OK200通用成功,FastAPI 默认值
HTTP_201_CREATED201创建成功,应配 Location 头
HTTP_204_NO_CONTENT204成功但无响应体
HTTP_400_BAD_REQUEST400客户端请求语义错误
HTTP_401_UNAUTHORIZED401未认证
HTTP_403_FORBIDDEN403已认证但无权限
HTTP_404_NOT_FOUND404资源不存在
HTTP_409_CONFLICT409冲突(如唯一键重复)
HTTP_422_UNPROCESSABLE_ENTITY422校验失败,FastAPI 自动使用
HTTP_500_INTERNAL_SERVER_ERROR500服务端未捕获异常

用常量的理由是可读性与可检索性:status.HTTP_404_NOT_FOUND 搜得到、看得出语义,裸 404 只能靠脑补。团队里只要有人写裸数字,就会出现 status_code=204 却 return {...} 这类组合错误。

💡 函数内部手动抛错时用 HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="...");装饰器上的 status_code 只决定成功路径的默认状态码。


5.6 声明额外响应:responses={...} ​

只靠 response_model 描述不了「失败时返回什么」,responses 参数把错误响应写进 OpenAPI 文档:

python
from fastapi import HTTPException, status
from pydantic import BaseModel


class ErrorDetail(BaseModel):
    code: str
    message: str


@app.get(
    "/items/{item_id}",
    response_model=Item,
    responses={
        status.HTTP_404_NOT_FOUND: {
            "model": ErrorDetail,
            "description": "商品不存在",
        },
        status.HTTP_403_FORBIDDEN: {
            "description": "无权访问该商品",
        },
    },
)
async def read_item(item_id: int) -> Item:
    if item_id > 1000:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail={"code": "ITEM_NOT_FOUND", "message": "商品不存在"},
        )
    return Item(name="键盘", price=499.0)

responses 的 key 是状态码,value 支持 description、model(或 content + schema)。这不是装饰性配置:它决定前端同学能否在 Swagger 上看到错误结构,也是契约测试的依据。


5.7 直接构造响应:Response 与它的子类 ​

响应模型适合 JSON 数据,但有时你需要完全掌控输出。

python
from fastapi import FastAPI, Response, status
from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse

app = FastAPI()


# 1) 注入 Response 对象:改状态码、加响应头、种 Cookie,函数照常返回数据
@app.post("/login")
async def login(response: Response) -> dict[str, str]:
    response.status_code = status.HTTP_200_OK
    response.headers["X-Request-Id"] = "req-123"
    response.set_cookie(
        key="session_id",
        value="abc123",
        httponly=True,      # 阻止 JS 读取,降低 XSS 窃取风险
        secure=True,        # 仅 HTTPS 传输
        samesite="lax",     # 缓解 CSRF
        max_age=3600,
    )
    return {"msg": "ok"}


# 2) 直接返回响应实例:FastAPI 完全透传,不再套用 response_model
@app.get("/raw")
async def raw() -> Response:
    return JSONResponse(
        status_code=status.HTTP_202_ACCEPTED,
        content={"task_id": "t-1"},
        headers={"X-Task-Id": "t-1"},
    )


@app.get("/plain")
async def plain() -> PlainTextResponse:
    return PlainTextResponse("pong")


@app.get("/old-docs")
async def old_docs() -> RedirectResponse:
    # 307 保留原请求方法与 body,302 可能被客户端改写成 GET
    return RedirectResponse(url="/docs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)

要记住的取舍:

  • 注入 Response 参数的方式保留 FastAPI 的序列化与 response_model 处理,只是多了一个能改头/改状态的句柄。日常首选这种。
  • 直接返回响应实例时,装饰器上的 response_model、status_code 都不生效,你写的响应对象说了算。适合非 JSON body、流式输出、精确控制字节的场景。
  • set_cookie 不带 httponly / secure / samesite 是常见安全疏漏;delete_cookie(key) 用于登出。

5.8 无内容响应:204 为什么不能有 body ​

python
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(item_id: int) -> None:
    # 正确:什么都不返回
    return None

按 RFC 9110,204 No Content 表示「成功,且故意不返回内容」。它不得携带消息体,也不能带 Content-Length(Starlette 会处理好头部)。

实践中的两个坑:

  1. 写了 status_code=204 又 return {...}。这不会报错,反而更危险:FastAPI 生成响应时看到 204 会把 body 静默丢弃,客户端只收到空响应,Content-Length 也不存在。你以为接口返回了 {"ok": true},实际上什么都没有,问题要到前端解析时才炸。约定:204 一律 return None,注解写 -> None。
  2. 204 与 response_model 同时使用。若处理函数实际返回数据而响应模型又期望 body,语义直接冲突;真要返回内容就用 200。

如果你需要「成功 + 少量元信息」,用 200 返回 {"ok": true},不要硬凑 204。


5.9 为什么推荐 ORJSONResponse ​

FastAPI 默认用 JSONResponse,底层是标准库 json;换成 ORJSONResponse 后走 orjson:

bash
pip install orjson
# 或统一管理依赖
uv add orjson
python
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse

# 全局默认:所有端点都受益
app = FastAPI(default_response_class=ORJSONResponse)


# 也可以单端点覆盖
@app.get("/metrics", response_class=ORJSONResponse)
async def metrics() -> list[Metric]:
    return [Metric(name="qps", value=1234.5)]

它换来三件事:

  • 更快:orjson 用 Rust 实现,序列化大列表时比标准 json 明显更快,payload 越大收益越显著。
  • 原生支持常见类型:datetime、date、uuid.UUID、numpy 数组都能直接编码,不必预先转字符串。
  • 输出更紧凑:默认不输出多余空格、默认 UTF-8 直出(ensure_ascii=False),中文不会变成 \uXXXX。

什么时候不必换:接口只返回几十字节的小对象、或 QPS 很低时,收益可忽略,多一个依赖反而不划算。

⚠️ orjson 是独立依赖,不随 FastAPI 安装,忘了装会在导入时报 ModuleNotFoundError。


常见坑与排查 ​

现象原因解决
UserOut.model_validate(orm_obj) 抛 Input should be a valid dictionary or instance of UserOut该模型没开 from_attributes,model_validate 默认只接受 dict输出模型加 model_config = ConfigDict(from_attributes=True)
返回类型注解与 response_model 不一致,实际生效的是后者两者都写时只采用 response_model,注解被完全忽略两者写一致,或只保留 response_model
返回值结构不对,接口返回 500 而不是 422响应校验失败属于服务端 bug,走 ResponseValidationError检查业务层返回的字段与类型,别指望它变成 4xx
datetime 序列化成 "2025-01-01T09:30:00",前端时区处理出错Pydantic 按 ISO 8601 输出,datetime 无时区时不带偏移量统一存带时区的 datetime,或改用时间戳字段
status_code=204 却返回了内容,客户端拿到空响应204 不得携带 body,FastAPI 会静默丢弃 body,不报错204 一律 return None,注解 -> None
exclude_unset=True 的结果和预期不符它判断的是「字段是否被赋值」,不是「值是否等于默认值」需要按值过滤时改用 exclude_none=True 或 exclude
多传字段没被过滤掉过滤只针对响应模型声明过的字段;extra="allow" 会保留未知字段输出模型不设 extra="allow",敏感字段不声明即可
response_model 被当成万能校验器,业务错误照样返回 200它只做结构校验,不做业务规则校验业务失败用 HTTPException + 合适的 status 常量
Cookie 设了但前端读取不到httponly=True 阻止 JS 访问,这是预期行为需要前端可读的信息放响应体,不要放 HttpOnly Cookie

本章小结 ​

要点说明
三重价值过滤敏感字段、校验输出结构、生成准确的 OpenAPI 文档
两种写法response_model= 与 -> Model 等价;两者都写时只有 response_model 生效
输入输出分离UserIn 带密码、UserOut 不带;安全靠「不声明」而非「记得删」
from_attributes让 model_validate 能读对象属性;响应路径本身宽松,显式声明是为三条路径一致
四个过滤参数exclude_unset 按「是否赋值」、exclude_none 按「值是否为 None」、include/exclude 按字段名
状态码用 fastapi.status 常量;201 用于创建,204 用于无内容
额外响应responses={404: {"model": ..., "description": ...}} 写进文档
响应对象注入 Response 改头/Cookie 是首选;直接返回响应实例则绕过响应模型
204不得携带 body,FastAPI 会静默丢弃多余的 body,必须返回 None
ORJSONResponse需单独装 orjson;大 payload、datetime/UUID 原生编码场景收益明显

练习题 ​

  1. 定义 ArticleIn(含 title、content、author_email)与 ArticleOut(含 id、title、created_at,且开启 ORM 模式)。实现 POST /articles 返回 201 并用 ArticleOut 序列化;再写一个 GET /articles/{id},缺失时用 status 常量抛 404,并通过 responses 参数把它写进文档。

  2. 给定 Profile 模型(nickname、bio、website、is_active 均有默认值),分别构造「返回 dict」和「返回模型实例」两种实现,观察 response_model_exclude_unset=True 的输出差异并解释原因。

  3. 实现 POST /auth/login:验证成功后通过 response.set_cookie 写入 session_id(httponly=True、secure=True、samesite="lax")并返回 {"msg": "ok"};再实现 POST /auth/logout 用 delete_cookie 清除它。说明为什么 session_id 不能放在响应体里让前端存 localStorage。

  4. 把本章 main.py 的 app = FastAPI() 改成 FastAPI(default_response_class=ORJSONResponse),装上 orjson 后请求一个返回含中文与 datetime 的接口,对比改造前后响应体的差异(提示:注意中文是否被转义)。


下一章预告 ​

请求进出都规范了,但鉴权、数据库会话、分页这些逻辑正在每个处理函数里重复。下一章用依赖注入把它们抽出来。

👉 第 6 章:依赖注入系统

📖本文阅读--次|📊全站访问--次|👥访客--人