07 自定义响应数据格式 —— 当默认 JSON 编码不够用的时候
前置阅读: 04-路由、06-请求与响应
关键词: JSONResponse, ORJSONResponse, async def, def
难度: ★★★☆☆
场景导入:你需要的不是另一种响应,而是更快的序列化
大多数时候,让 FastAPI 默认处理响应就够了——handler 返回一个 Pydantic 模型或字典,框架自动校验、过滤、编码为 JSON。但有两种场景会让你想要接管控制权:一是需要固定的 JSON 外壳(比如所有响应都包一层 {"code": 0, "data": ...}),二是对序列化吞吐有要求(比如列表接口一次返回上千条记录,json.dumps 成为 CPU 瓶颈)。
FastAPI 提供了 JSONResponse 和 ORJSONResponse 两种显式响应类,前者走标准库路径,后者走 Rust 实现的 orjson 编码器。但这里有一个容易被误解的点:响应类负责"怎样编码",handler 用 async def 还是 def,取决于"怎样获取数据"——两项选择彼此独立。
原理解析:两条编码路径,同一个接口
返回普通 Python 数据时,FastAPI 的处理链是:先应用 response_model 过滤字段 → 再交给默认响应类(JSONResponse)编码。但当你直接返回一个 Response 实例时,框架会跳过 response_model 校验——响应体、媒体类型、状态码和响应头在构造 Response 时就已经确定了。
这意味着两条路径之间有明确的取舍:
JSONResponse 和 ORJSONResponse 的继承关系很简单——区别只在 render() 方法里用什么库做编码:
(图注:两种 JSON 响应共享协议字段和 ASGI 发送能力,唯一区别是 render() 里的编码器——json.dumps vs orjson.dumps。)
ORJSONResponse 通常比 JSONResponse 快,但额外引入了 orjson 依赖。做这个选择之前最好用真实的响应结构压测一次——如果 P99 延迟的大头在数据库查询上,替换编码器的收益可能微乎其微。
函数形态和响应类的选择是完全正交的两个维度:
(图注:选 async def 还是 def 看 I/O,选用哪个响应类看编码需求。四个组合都是合法的。)
还有一个与响应序列化相关的关键配置:13 章在 async_sessionmaker 中设的 expire_on_commit=False。它让 ORM 对象的标量属性在事务提交后保持可读,响应序列化时不会再触发隐式的 SELECT。但它不会自动加载未读取的关联字段——关联数据仍应在查询阶段显式预加载。
代码实现:同一个端点,两种响应类
下面两个端点放在 app/api/books.py 中,router 已设 prefix="/books",实际路径分别为 /books/raw 和 /books/fast:
# app/api/books.py(节选)
from fastapi.responses import JSONResponse, ORJSONResponse
@router.get("/raw", response_class=JSONResponse)
async def raw_books() -> JSONResponse:
"""异步栈端点,数据库实现在第 16 章。"""
return JSONResponse({"books": []})
@router.get("/fast", response_class=ORJSONResponse)
def fast_books() -> ORJSONResponse:
"""纯响应构造,无 I/O,使用 def。"""
return ORJSONResponse({"books": []})
raw_books 将来要 await db.execute(select(Book)),所以现在就是 async def——即使当前是占位实现,签名也预留了异步空间。fast_books 只做内存中的响应构造,用 def 就够,Starlette 投递到线程池。两种响应类和两种函数形态之间的排列组合,在这个简短的示例里已经全部覆盖。
有一点要特别注意:显式构造响应对象时,框架不会帮你做任何字段过滤。绝不要把未清理的 ORM 内部字段直接放进 content——那样等于绕过了第 06 章精心设计的 response_model 防线。
性能与坑点
编码器只是拼图的一块。
ORJSONResponse通常比JSONResponse快 2-5 倍,但如果你的接口 P99 延迟是 200ms,其中数据库查询占了 180ms,换个编码器最多省下几毫秒。先把慢查询优化好,再考虑编码器层级。大响应体 = 大内存峰值。即使编码器再快,整个响应体在发送前必须完整驻留在内存中。数据量大到一定程度时,应该改成分页(
limit/offset),或者用StreamingResponse分块发送,降低内存峰值和首字节延迟。expire_on_commit=False救不了未加载的关联。它只保留已加载的标量属性。如果你在 DAO 查询时没有selectinload(Book.author),序列化阶段访问book.author.name照样触发额外 SELECT。加载策略要在查询时决定,别指望序列化层替你优化。
面试 QA
Q1 [原理]: JSONResponse 与 ORJSONResponse 有什么区别?什么时候该换?
两者都生成 application/json 响应。前者调用标准库的 json.dumps,零额外依赖;后者调用 Rust 实现的 orjson,通常有更高吞吐。选型取决于序列化在端到端延迟中的占比——如果响应体大且字段多,orjson 的优势明显;如果大部分时间花在数据库查询和网络传输上,换编码器收益有限。
另外,orjson 对某些 Python 类型(如 datetime)的默认序列化行为与标准 json 不同,迁移时要验证响应结构。建议先在 staging 环境用真实数据压测,再决定是否全面替换默认编码器。
Q2 [项目]: 如何实现自定义响应类?
继承 Response 或已有的 JSON 响应类,设置 media_type,重写 render(content) -> bytes。然后可以通过路由级 response_class=MyResponse 声明默认类型,也可以直接在 handler 里 return MyResponse(...)。
但大多数情况下你不需要自定义响应类——如果只是想统一 JSON 外壳(比如 {"code": 0, "data": ...}),在中间件层包装比修改响应类更干净。只有在需要特殊编码格式(如 MessagePack、自定义二进制协议)时才值得写一个自定义 render()。
小结
本篇把显式响应类摆上了桌面,结论很直接:响应类负责编码策略,handler 形态由 I/O 决定,两者互不干扰。ORM 输出借助 expire_on_commit=False 保留已加载属性,关联关系仍需在查询时提前加载。当数据规模继续增长时,优化方向应该从"更快编码"转向"分页或流式传输"。
下一篇《08 异常处理》将讨论 HTTPException、全局异常处理器和 IntegrityError 到 HTTP 状态码的映射——这是 API 健壮性的基石。