第 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 模型:
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
secret_cost: float | None = None5.2 response_model 与返回类型注解
两种写法都合法:
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_model | response_model |
| 只写返回类型注解 | 返回类型注解 |
| 两者都写且一致 | 同一个 |
| 两者都写且不一致 | 只用 response_model,返回类型注解被完全忽略 |
最后一行值得展开:注解是 -> Item 而装饰器写 response_model=Other 时,文档里展示 Other,运行时也按 Other 校验。返回一个符合 Item 却不含 Other 字段的对象,会直接拿到 500。别让两个都写的时候打架,或者干脆只保留一个。
⚠️ 返回类型注解写
-> None或-> Response时 FastAPI 不会套用响应模型。用Response子类做注解时直接透传,不做序列化——见 5.8。
5.3 输入模型 ≠ 输出模型(重点)
这是本章最该带走的一条实践:创建用的模型和展示用的模型必须分开。
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 rowGET /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 正确示范
❌ 不推荐:
@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 原样泄露✅ 推荐:
@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 四种过滤参数的语义差异
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 下则被剔除。
三个易被忽略的细节:
exclude_unset判断的是「有没有被赋值」,不是「值是否等于默认值」。Profile(nickname="墨谦")里website从未被设置,即使默认值就是None,也会被排除。exclude_unset影响运行时输出,不改变文档里的 schema,文档仍展示完整模型。- 这四个参数属于「响应层」配置,写在装饰器上;同一模型在别处需要完整输出时,各端点各配一份,不要改模型本身。
5.5 状态码:用常量,不要用魔法数字
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 Nonefastapi.status 是对 starlette.status 的再导出,本质是 IntEnum 常量集合。常用的一小撮:
| 常量 | 值 | 语义 |
|---|---|---|
HTTP_200_OK | 200 | 通用成功,FastAPI 默认值 |
HTTP_201_CREATED | 201 | 创建成功,应配 Location 头 |
HTTP_204_NO_CONTENT | 204 | 成功但无响应体 |
HTTP_400_BAD_REQUEST | 400 | 客户端请求语义错误 |
HTTP_401_UNAUTHORIZED | 401 | 未认证 |
HTTP_403_FORBIDDEN | 403 | 已认证但无权限 |
HTTP_404_NOT_FOUND | 404 | 资源不存在 |
HTTP_409_CONFLICT | 409 | 冲突(如唯一键重复) |
HTTP_422_UNPROCESSABLE_ENTITY | 422 | 校验失败,FastAPI 自动使用 |
HTTP_500_INTERNAL_SERVER_ERROR | 500 | 服务端未捕获异常 |
用常量的理由是可读性与可检索性: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 文档:
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 数据,但有时你需要完全掌控输出。
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
@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 会处理好头部)。
实践中的两个坑:
- 写了
status_code=204又return {...}。这不会报错,反而更危险:FastAPI 生成响应时看到 204 会把 body 静默丢弃,客户端只收到空响应,Content-Length也不存在。你以为接口返回了{"ok": true},实际上什么都没有,问题要到前端解析时才炸。约定:204 一律return None,注解写-> None。 204与response_model同时使用。若处理函数实际返回数据而响应模型又期望 body,语义直接冲突;真要返回内容就用200。
如果你需要「成功 + 少量元信息」,用 200 返回 {"ok": true},不要硬凑 204。
5.9 为什么推荐 ORJSONResponse
FastAPI 默认用 JSONResponse,底层是标准库 json;换成 ORJSONResponse 后走 orjson:
pip install orjson
# 或统一管理依赖
uv add orjsonfrom 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 原生编码场景收益明显 |
练习题
定义
ArticleIn(含title、content、author_email)与ArticleOut(含id、title、created_at,且开启 ORM 模式)。实现POST /articles返回201并用ArticleOut序列化;再写一个GET /articles/{id},缺失时用status常量抛 404,并通过responses参数把它写进文档。给定
Profile模型(nickname、bio、website、is_active均有默认值),分别构造「返回 dict」和「返回模型实例」两种实现,观察response_model_exclude_unset=True的输出差异并解释原因。实现
POST /auth/login:验证成功后通过response.set_cookie写入session_id(httponly=True、secure=True、samesite="lax")并返回{"msg": "ok"};再实现POST /auth/logout用delete_cookie清除它。说明为什么session_id不能放在响应体里让前端存 localStorage。把本章
main.py的app = FastAPI()改成FastAPI(default_response_class=ORJSONResponse),装上orjson后请求一个返回含中文与datetime的接口,对比改造前后响应体的差异(提示:注意中文是否被转义)。
下一章预告
请求进出都规范了,但鉴权、数据库会话、分页这些逻辑正在每个处理函数里重复。下一章用依赖注入把它们抽出来。