内部对象直接 return,客户端看到了不该看的东西¶
上一课结束时,AI 学习助手已经有了一个结构清晰的 typed core:
@dataclass(frozen=True)
class LearningGoal:
topic: str
weekly_hours: int
@dataclass(frozen=True)
class Plan:
topic: str
daily_minutes: int
steps: list[str]
_internal_score: float = 0.0
created_by: str = "system"
def build_plan(goal: LearningGoal) -> Plan:
daily_minutes = goal.weekly_hours * 60 // 7
return Plan(
topic=goal.topic,
daily_minutes=daily_minutes,
steps=[f"每天学习 {goal.topic} {daily_minutes} 分钟"],
_internal_score=0.72,
created_by="plan_engine_v2",
)
模块边界画好了,类型契约写清了,内部协作一切井然有序。现在产品说:用户要从 浏览器调用这个功能。
你的第一反应可能和很多人一样:加一个路由,把 Plan 对象序列化成 JSON 返回
就行了。毕竟数据都在那里,何必再折腾一层?
这一章就从这个"何必折腾"开始。我们要亲眼看看直觉做法会把什么东西送到客户端 手里,然后理解为什么 HTTP 公开接口需要自己独立的契约设计。
运行环境¶
Python 3.12、FastAPI 0.115+、Pydantic v2。
本章观察的行为在 FastAPI 0.115+ 和 Pydantic v2 各小版本中一致。
1. 把内部对象直接扔给客户端¶
假设你用 Flask 的直觉来暴露 build_plan:
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/plans")
def create_plan():
data = request.get_json()
goal = LearningGoal(topic=data["topic"], weekly_hours=data["weekly_hours"])
plan = build_plan(goal)
return jsonify(plan.__dict__)
看起来五行就搞定了。现在发一个请求:
curl -X POST http://localhost:5000/plans \
-H "Content-Type: application/json" \
-d '{"topic": "Python typing", "weekly_hours": 6}'
先别往下看。预测一下:客户端会收到哪些字段?
拿到的 JSON 响应是这样的:
{
"topic": "Python typing",
"daily_minutes": 51,
"steps": ["每天学习 Python typing 51 分钟"],
"_internal_score": 0.72,
"created_by": "plan_engine_v2"
}
看到 _internal_score 了吗?这是一个内部质量评分,用于后续的排序和优化迭代。
它对客户端毫无意义,甚至可能让竞争对手看到你的评分算法倾向。
created_by 呢?这是内部追踪字段,记录是哪个引擎版本生成的计划。用户不需要
知道 plan_engine_v2 的存在,运维团队的内部命名也不该出现在公开接口里。
问题不在于这两个字段有多机密——而在于你从未决定过它们应该出现。__dict__ 把
内部对象的所有属性一股脑倒了出去,客户端收到了什么完全取决于对象碰巧有什么
字段。
这就像把整个抽屉端给客人,里面有文件也有便签纸和零食发票。你没打算给他们看 发票,只是懒得挑了。
现在停下来想一想:Plan 的哪些字段是客户端真正需要的?哪些字段不应该出现在
HTTP 响应中?
答案很直接:客户端需要 topic、daily_minutes 和 steps——这些是学习计划
的内容。_internal_score 和 created_by 是实现细节,不是客户端需要知道的
信息。
这个观察引出一个更根本的问题:内部函数签名和 HTTP 公开接口,它们各自在向谁 做承诺?
2. 内部签名和 HTTP 接口,面对的是不同的人¶
上面的问题不是偶然出现的。它指向一个更根本的分工:
这个函数签名面向谁?面向项目内部的开发者和类型工具。它说的是:
- 给我一个
LearningGoal,我还你一个Plan - IDE 可以据此补全、跳转和类型检查
- 调用方知道需要准备什么、能拿回什么
它不需要回答:用户的请求从哪条路径进来?用什么 HTTP 方法?请求体是什么格式? 成功时返回什么状态码?因为在内部调用场景里,这些问题不存在。
但一旦要让远程客户端调用这个功能,情况完全变了。客户端看不到你的 Python 代码,它只能通过网络发送一个 HTTP 请求。它需要知道的是:
| 维度 | 客户端必须知道的信息 |
|---|---|
| 路径 | 向哪个 URL 发送请求(/plans) |
| 方法 | 用 POST、GET 还是 PUT |
| 请求体 | JSON 里应该包含哪些字段、什么类型 |
| 状态码 | 成功时收到 200 还是 201 |
| 响应体 | 返回的 JSON 包含哪些字段 |
这些信息合在一起,构成了你对远程客户端的公开承诺。
把这两件事并排看:
内部函数签名:
消费者 → 项目内部的开发者、IDE、类型检查器
承诺 → 参数类型和返回类型
不涉及 → 路径、HTTP 方法、状态码、序列化格式
HTTP 公开契约:
消费者 → 远程客户端(浏览器、移动端、其他服务)
承诺 → 路径 + 方法 + 参数来源 + 状态码 + 响应体结构
不涉及 → 内部实现类型、私有字段、引擎版本
现在可以解释为什么直接 return plan.__dict__ 会出事:你把一个面向内部开发者
的对象结构,当作了面向远程客户端的公开承诺。两者的消费者不同、职责不同、
该包含的信息也不同。
这不是"多此一举"。这是两个不同岗位的工作。内部类型负责让团队高效协作, 公开契约负责让客户端正确使用你的服务。硬要一个人干两份工作,结局通常是两边 都干不好。
理解了这个分工之后,下一个自然的问题是:既然需要独立设计 HTTP 公开契约,有没有 一种方式能在一处把路径、方法、参数来源、状态码和响应结构同时声明清楚?
3. FastAPI 如何在一处同时声明公开契约¶
知道了需要独立设计 HTTP 公开契约后,下一个问题是:怎么声明它?
先看一个最小的 FastAPI endpoint:
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class CreatePlanRequest(BaseModel):
topic: str
weekly_hours: int
class PlanResponse(BaseModel):
topic: str
daily_minutes: int
steps: list[str]
@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest) -> Plan:
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
return build_plan(goal)
别急着问"这比 Flask 多了什么"。先观察这段代码声明了哪些公开契约的维度:
@router.post("/plans", ...)— 路径是/plans,方法是 POSTstatus_code=201— 成功时返回 HTTP 201 Createdrequest: CreatePlanRequest— 请求体是 JSON,必须包含topic(字符串) 和weekly_hours(整数);FastAPI 自动从请求体绑定参数response_model=PlanResponse— 响应体只包含topic、daily_minutes和steps
一个装饰器加一个函数签名,同时完成了:
- 路由绑定(路径 + 方法)
- 参数来源声明(请求体 JSON)
- 输入类型和校验规则(Pydantic 模型)
- 成功状态码
- 响应结构声明
这不是因为 FastAPI 特别"魔法"。它是把你本来就需要分别声明的公开契约维度, 集中到了同一个位置。相比 Flask 中路径、参数解析、校验和响应分散在不同代码行 甚至不同文件中,FastAPI 让你在一处就能看见完整的 HTTP 公开契约。
有了公开契约的声明方式,接下来要回答一个关键问题:函数内部返回了包含内部字段的 完整对象,但 response_model 只声明了部分字段——最终到达客户端的内容由谁决定?
4. response_model 的过滤:你 return 了什么 ≠ 客户端收到什么¶
回到上面那段代码。注意函数内部做了什么:
async def create_plan(request: CreatePlanRequest) -> Plan:
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
return build_plan(goal)
build_plan 返回的是完整的 Plan 对象——包含 _internal_score 和
created_by。函数确实 return 了一个带有内部字段的对象。
但装饰器声明了 response_model=PlanResponse,而 PlanResponse 只有三个
字段:topic、daily_minutes、steps。
先预测:客户端最终会收到什么?
发送请求试试:
curl -X POST http://localhost:8000/plans \
-H "Content-Type: application/json" \
-d '{"topic": "Python typing", "weekly_hours": 6}'
响应:
_internal_score 和 created_by 消失了。
这是本章的核心发现:函数内部返回什么,和客户端实际收到什么,之间存在一道
受控的过滤层。response_model 在序列化阶段只提取自己声明的字段,其余一律
不进入 HTTP 响应。
你的函数可以放心返回内部业务对象,不需要手动剔除字段、不需要构造另一个字典。
response_model 像一个只允许特定字段通过的闸口——声明了什么才放行什么。
这里有一个容易混淆的细节值得厘清:response_model 和函数返回值的类型标注
(-> Plan)是两件不同的事。类型标注 -> Plan 是给类型检查器看的,它保证
函数内部的实现一致性——你说要返回 Plan,类型检查器就确保你确实返回了 Plan。
而 response_model=PlanResponse 是给 FastAPI 框架看的,它决定序列化时提取
哪些字段发送给客户端。一个管内部协作的正确性,一个管外部响应的边界。两者可以
指向不同的类型,各自承担各自的职责。
现在做一个思想实验:如果去掉 response_model 会怎样?
@router.post("/plans", status_code=201)
async def create_plan(request: CreatePlanRequest) -> Plan:
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
return build_plan(goal)
没有了 response_model 的过滤,FastAPI 会尝试直接序列化返回值。如果 Plan 是
dataclass 且所有字段都可序列化,客户端就会收到完整的内部对象——包括
_internal_score 和 created_by。
这就回到了本章开头的 Flask 场景:内部字段泄露给远程客户端。
response_model 不只是"方便的序列化工具"。它是公开契约对响应的最后一道边界:
即使函数实现发生变化、内部对象增加了新字段,只要 PlanResponse 没有声明那些
字段,客户端就永远看不到它们。
这意味着你的公开契约可以独立于内部实现演化。内部加了 _debug_trace 字段?
客户端无感。重构了 Plan 的内部结构?只要 PlanResponse 声明的字段仍然能
从返回值中提取到,公开契约就保持稳定。
到这里,我们已经知道如何声明公开契约、如何通过 response_model 过滤内部字段。 最后一个需要回答的架构问题是:这些负责 HTTP 翻译的代码,应该放在哪里?它和 core 之间的依赖关系应该指向哪个方向?
5. 适配层依赖 core,而不是反过来¶
最后一个关键判断:这些代码应该怎么组织?
正确的依赖方向:
# router.py(HTTP 适配层)
from core import build_plan, LearningGoal
from schemas import CreatePlanRequest, PlanResponse
@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest) -> Plan:
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
return build_plan(goal)
router.py 知道 HTTP、知道请求模型和响应模型、知道如何调用 core。它是
适配层——负责把 HTTP 请求翻译成 core 能理解的输入,再把 core 输出翻译成
HTTP 响应。
现在看一个反例:
# core.py(反面教材)
from fastapi import Request
def build_plan(request: Request) -> dict:
data = await request.json()
topic = data["topic"]
weekly_hours = int(data["weekly_hours"])
daily_minutes = weekly_hours * 60 // 7
return {
"topic": topic,
"daily_minutes": daily_minutes,
"steps": [f"每天学习 {topic} {daily_minutes} 分钟"],
}
这段代码让 core.py 直接导入了 fastapi.Request。业务逻辑现在知道自己运行
在 HTTP 框架里,知道请求是 JSON 格式,甚至亲自做了类型转换。
后果是什么?
- 如果以后想从消息队列调用同一个
build_plan,你得把 MQ 消息伪装成Request对象——荒诞至极。 - 如果想给
build_plan写单元测试,你得构造一个假的 HTTP Request——本来只是 测试计划生成逻辑,现在还要和 HTTP 框架纠缠。 - core 不再是独立的业务模块,而是 FastAPI 的附庸。
为什么反向依赖会让测试变得困难?因为单元测试的核心价值是快速验证业务逻辑的
正确性。当 core 依赖了 fastapi.Request,测试 build_plan 时你必须先构造
一个符合 Starlette ASGI 规范的 Request 对象——设置 scope、body stream、
headers。这些都与"一个话题加上每周学习时长,能不能正确生成计划"毫无关系。
测试代码中大部分行数都在搭建框架脚手架,而不是验证业务规则。如果依赖方向
正确,测试只需要 build_plan(LearningGoal("Python", 6)) 一行就能拿到结果。
正确的方向始终是:
箭头从适配层指向 core。core 不知道也不需要知道自己会被谁调用——HTTP、CLI、 测试、消息队列都可以直接使用它的公开接口。
这延续了 W01-L01 建立的模块边界原则:核心业务通过明确的类型签名表达自己的 输入输出契约,不应依赖任何特定传输方式或框架。
边界与常见误区¶
误区一:把内部 Pydantic 业务模型直接当 response_model
如果 Plan 包含内部字段,直接把它用作 response_model 就失去了过滤保护。
response_model 应该是专门为公开契约设计的模型,只声明客户端需要看到的字段。
一个实际的反面信号是:当你发现某个 response_model 的字段列表和内部模型完全
一样时,就该停下来问自己——是公开契约碰巧和内部结构相同,还是你偷懒复用了?
误区二:认为 FastAPI 从签名推断的 schema 就是理想的公开契约
FastAPI 确实能从函数返回类型推断响应 schema。但"能推断"不等于"推断出来的 就是你想要的公开契约"。如果返回类型是内部对象,推断出的 schema 也会包含内部 字段。公开契约应该是你主动设计的,不是框架碰巧猜出来的。
举个具体例子:如果函数声明 -> Plan 且不指定 response_model,FastAPI 会用
Plan 的所有字段生成 OpenAPI schema。客户端开发者看到文档里有 _internal_score
字段,就会认为这是他们可以依赖的公开接口。等你以后想移除这个字段,就成了破坏性
变更。
误区三:混淆"函数返回值类型正确"和"客户端收到的响应与声明一致"
函数返回一个 Plan,类型检查器很满意。但如果没有 response_model,客户端收到
的 JSON 可能包含未声明的字段。类型正确是内部协作的保证,response_model 才是
外部契约的保证。这两道防线面向不同方向:类型标注向内(保证开发者之间的约定),
response_model 向外(保证对客户端的承诺)。
误区四:混淆 Pydantic 边界模型和 HTTP 请求模型
W01-L01 建立的 Pydantic 边界模型(如 LearningGoal)和 HTTP 请求模型(如
CreatePlanRequest)都用 BaseModel 定义,看起来形状相似甚至字段相同。但它们
的设计驱动力不同:
- core 边界模型的字段由"业务逻辑需要什么输入"驱动。
LearningGoal有topic和weekly_hours,是因为build_plan的算法需要这两个值。 - HTTP 请求模型的字段由"客户端需要提供什么"驱动。
CreatePlanRequest的 字段可能和LearningGoal一样,但这是巧合——如果将来 API 需要支持批量创建, 请求模型可能变成goals: list[...]的结构,而 core 边界模型不会跟着变。
两者的变更原因不同:一个因业务规则变而变,一个因客户端交互需求变而变。即使 当前长得一样,它们是两个独立的设计决策。把它们混为一谈,等于把适配层的变更 和业务逻辑的变更耦合在一起。
本章小结¶
从 W01-L01 的 typed core 走到 HTTP 公开接口,第一个需要建立的判断是:内部 类型和 HTTP 公开契约面向不同消费者、承担不同职责。
内部函数签名通过参数类型和返回类型,向开发者和类型工具承诺调用契约。HTTP 公开 契约通过路径、方法、参数来源、状态码和响应体结构,向远程客户端承诺操作语义。
FastAPI path operation 将这些维度集中在一处声明:装饰器承担路径和方法,函数 签名承担参数来源和类型,response_model 承担响应结构。response_model 在 序列化阶段过滤字段,确保函数内部返回什么不等于客户端收到什么。
HTTP 适配层依赖 typed core 的公开接口,负责将 HTTP 请求转换为 core 期望的 输入、将 core 输出通过 response_model 过滤后返回给客户端。core 不应感知 HTTP 框架细节——这保证了业务逻辑可以被任何入口复用。
练习¶
练习一:为内部模型设计公开响应¶
下面是一个会话管理系统的内部模型:
@dataclass(frozen=True)
class UserSession:
session_token: str
ip_address: str
last_active: str
display_name: str
preferences: dict[str, str]
_fraud_score: float
_ab_test_group: str
login_method: str
产品需求:提供 GET /sessions/current 接口,让前端获取当前用户的会话信息,
用于页面右上角显示用户名和偏好设置。
任务:
1. 判断哪些字段不应出现在公开 API 响应中,为每个排除的字段说明理由
2. 设计一个 UserSessionResponse 模型,只包含客户端需要的字段
练习二:从代码中提取公开契约¶
阅读下面的 FastAPI endpoint,提取出它对远程客户端做出的全部公开承诺:
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class SubmitFeedbackRequest(BaseModel):
lesson_id: str
rating: int
comment: str | None = None
class FeedbackResponse(BaseModel):
feedback_id: str
lesson_id: str
status: str
@router.post("/feedback", status_code=201, response_model=FeedbackResponse)
async def submit_feedback(body: SubmitFeedbackRequest) -> Feedback:
result = feedback_service.record(body.lesson_id, body.rating, body.comment)
return result
任务:
1. 列出这个 endpoint 公开契约的每个维度(路径、方法、请求体字段及类型、成功
状态码、响应体字段)
2. 对比:内部函数 feedback_service.record 的签名向调用方承诺了什么?
3. 指出哪些信息只在 HTTP 公开契约中存在,record 的函数签名无法表达
练习三:预测 response_model 过滤结果¶
一个订单系统的 endpoint:
class OrderDetail(BaseModel):
order_id: str
customer_name: str
items: list[str]
total_price: float
profit_margin: float
supplier_cost: float
internal_priority: int
class OrderPublicView(BaseModel):
order_id: str
customer_name: str
items: list[str]
total_price: float
@router.get("/orders/{order_id}", response_model=OrderPublicView)
async def get_order(order_id: str):
return order_repo.get_full_detail(order_id)
# 假设返回的是完整的 OrderDetail 对象
预测:
1. 客户端收到的 JSON 包含哪些字段?profit_margin、supplier_cost 和
internal_priority 会出现吗?
2. 如果把 response_model=OrderPublicView 改为 response_model=OrderDetail,
客户端会看到什么变化?
3. 如果 order_repo.get_full_detail 将来增加了 _warehouse_location 字段,
在保留当前 response_model 的情况下,客户端是否会感知到这个变化?
练习四:判断依赖方向¶
以下是两个项目的模块导入代码,判断哪个的依赖方向有问题:
项目 A 的 router.py:
from core.planning import build_plan, LearningGoal
from api.schemas import CreatePlanRequest, PlanResponse
@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest):
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
return build_plan(goal)
项目 B 的 core/planning.py:
from fastapi import Request
from fastapi.responses import JSONResponse
async def build_plan(request: Request) -> JSONResponse:
data = await request.json()
plan = _compute_plan(data["topic"], data["weekly_hours"])
return JSONResponse(content=plan, status_code=201)
任务:
1. 指出哪个项目的依赖方向有问题
2. 用两句话解释:(a) 为什么项目 B 的写法会让 core 无法被非 HTTP 入口复用?
(b) 如果将来需要同时从 CLI 和消息队列调用 build_plan,项目 B 会面临什么
困境?
练习解析¶
练习一解析¶
不应出现在响应中的字段及理由:
session_token:会话令牌是认证凭证,暴露在响应体中等于把钥匙交给了任何能 截获响应的中间人。即使是 HTTPS 传输,客户端也不应在普通业务响应中重复拿到 自己的 token。ip_address:用户的 IP 地址属于隐私信息,不应在业务接口中返回。这个字段 的存在是为了后端的安全审计和异常检测,不是为了给前端展示。_fraud_score:内部风控评分,属于实现细节。如果暴露,恶意用户可以根据分数 变化推断风控规则,从而规避检测。_ab_test_group:内部实验分组信息。暴露后可能干扰实验结果(用户可能因为 知道自己在测试组而改变行为),也泄露了产品策略。login_method:用户通过什么方式登录(密码、OAuth、SSO),属于安全相关的 内部状态。前端页面显示用户名和偏好设置不需要这个信息。
应该出现在响应中的字段:display_name、preferences、last_active。
其中 last_active 是否公开取决于业务场景:如果产品需要在页面上显示"上次活跃
时间",就应该包含;如果这个字段纯粹用于后端的会话过期判断,则不应包含。这
提醒我们:公开契约的字段选择不是纯技术决策,而是"客户端当前任务需要什么信息"
的业务决策。
设计的响应模型:
class UserSessionResponse(BaseModel):
display_name: str
preferences: dict[str, str]
last_active: str
常见错误推理:有人会认为"字段名没有下划线前缀就可以公开"。但 session_token
和 ip_address 都没有下划线前缀,照样不该出现。判断标准不是命名约定,而是
"客户端完成其任务是否需要这个信息"。
快速自检方式:对每个字段问自己——"如果前端工程师在控制台看到这个字段, 他会用它做什么?"如果答案是"什么都做不了"或"不应该用",就不该出现在响应里。
练习二解析¶
公开契约的各个维度:
| 维度 | 承诺内容 |
|---|---|
| 路径 | /feedback |
| 方法 | POST |
| 请求体字段 | lesson_id(字符串,必填)、rating(整数,必填)、comment(字符串或 null,选填) |
| 成功状态码 | 201 Created |
| 响应体字段 | feedback_id(字符串)、lesson_id(字符串)、status(字符串) |
内部函数 feedback_service.record(lesson_id, rating, comment) 向调用方承诺:
- 接受三个参数:lesson_id、rating、comment
- 返回一个
Feedback对象(从返回类型标注推断) - 调用方需要自行准备正确类型的参数
只在 HTTP 公开契约中存在、record 函数签名无法表达的信息:
- URL 路径(
/feedback) - HTTP 方法(POST)
- 成功状态码(201)
- 参数来源(从请求体 JSON 绑定)
- 字段的"选填"语义(
comment可以为 null) - 响应体的具体字段选择(经过 response_model 过滤)
这些都是网络层面的交互协议,函数签名这种纯语言层面的契约无法表达。
练习三解析¶
-
客户端收到的 JSON 只包含
order_id、customer_name、items和total_price四个字段。profit_margin、supplier_cost和internal_priority不会出现——因为response_model=OrderPublicView只声明了四个字段,序列化时其余字段被过滤掉。 -
如果改为
response_model=OrderDetail,客户端会收到全部七个字段,包括profit_margin(利润率)、supplier_cost(供应商成本)和internal_priority(内部优先级)。这意味着客户可以看到你的利润空间和 供应商价格——这是严重的商业信息泄露。 -
不会。即使
order_repo.get_full_detail返回的对象新增了_warehouse_location字段,由于OrderPublicView没有声明这个字段, response_model 的过滤层会阻止它进入 HTTP 响应。客户端完全无感。这正是 response_model 的核心价值:公开契约独立于内部实现演化。
练习四解析¶
项目 B 的依赖方向有问题。
(a) 项目 B 的 core/planning.py 导入了 fastapi.Request 和
fastapi.responses.JSONResponse,意味着业务逻辑被绑定到了 HTTP 框架。任何
非 HTTP 入口(CLI 脚本、定时任务、消息队列消费者)如果想调用 build_plan,
必须构造一个符合 ASGI 规范的 Request 对象——但这些入口根本没有 HTTP 请求。
(b) 如果将来需要同时从 CLI 和消息队列调用 build_plan,项目 B 面临的困境
是:要么为每个入口伪造 Request 对象(荒诞且脆弱),要么把 build_plan 的
核心算法重新抽取为一个不依赖框架的纯函数——等于承认最初的设计方向是错的,
需要重构。
项目 A 的方向正确:router.py(适配层)导入 core 的公开接口,core 本身对
HTTP 框架一无所知。箭头从适配层指向 core,core 可以被任何入口直接使用,
测试时只需 build_plan(LearningGoal("Python", 6)) 一行即可验证业务逻辑。