FastAPI 多模型实战:UserIn/UserOut/UserInDB 继承、Union 响应与任意 dict 返回
在 FastAPI 应用中,同一业务实体(如用户)往往在接收输入、返回输出、持久化存储时呈现不同的"状态":输入模型需要明文密码、输出模型绝不能暴露密码、数据库模型则需要哈希密码。本篇围绕 FastAPI 官方的"额外模型(Extra Models)"文档展开,完整讲解如何用 model_dump() 与 ** 解包在模型间转换数据、如何用 Pydantic 继承消除重复声明、如何用 Union(OpenAPI anyOf)声明多态响应,以及如何用 list[Model] 和 dict[K, V] 直接声明列表与任意字典响应——读完即可掌握 FastAPI 中"一实体多模型"的标准工程实践,并理解其在 源码实现 与 OpenAPI 生成链路中的落点。
为什么一个实体会对应多个模型
紧接在请求体模型之后,一个常见需求是拥有多个相关联的模型。这在用户模型中尤为典型,因为:
- 输入模型(UserIn) 必须能携带明文密码;
- 输出模型(UserOut) 不应包含任何密码;
- 数据库模型(UserInDB) 通常需要一个哈希后的密码。
安全警示:永远不要存储用户的明文密码。应始终存储一个"安全哈希",之后用它来验证密码。如果你不清楚"密码哈希"是什么,可以在 FastAPI 安全章节中了解(参见 简单 OAuth2 文档 中的密码哈希部分)。
多个模型:UserIn、UserOut 与 UserInDB
下面这个示例展示了三个模型各自的密码字段形态,以及它们在路由中如何使用(对应 docs_src/extra_models/tutorial001_py310.py):
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: EmailStr
full_name: str | None = None
class UserOut(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
class UserInDB(BaseModel):
username: str
hashed_password: str
email: EmailStr
full_name: str | None = None
def fake_password_hasher(raw_password: str):
return "supersecret" + raw_password
def fake_save_user(user_in: UserIn):
hashed_password = fake_password_hasher(user_in.password)
user_in_db = UserInDB(**user_in.model_dump(), hashed_password=hashed_password)
print("User saved! ..not really")
return user_in_db
@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
user_saved = fake_save_user(user_in)
return user_saved
这里数据流是:UserIn(带 password)→ 哈希后转换为 UserInDB(带 hashed_password)→ 通过 response_model=UserOut 返回时自动过滤掉密码字段。仓库中的测试 tests/test_tutorial/test_extra_models/test_tutorial001_tutorial002.py 验证了这一点:向 /user/ 发送包含 password 的 JSON,响应体只包含 username、email、full_name,并且 OpenAPI Schema 中请求体引用 UserIn、响应引用 UserOut。
关于 user_in.model_dump()
Pydantic 的 .model_dump() 方法
user_in 是 UserIn 类的 Pydantic 模型。Pydantic 模型提供 .model_dump() 方法,返回一个包含该模型全部数据的 dict(字典:即键值对映射,其他语言中也叫 Hash、Map、Object)。
如果我们创建一个 user_in 对象,例如:
user_in = UserIn(username="john", password="secret", email="john.doe@example.com")
然后调用:
user_dict = user_in.model_dump()
user_dict 就是一个普通 dict(不再是 Pydantic 模型对象)。此时打印它:
print(user_dict)
会得到:
{
'username': 'john',
'password': 'secret',
'email': 'john.doe@example.com',
'full_name': None,
}
注意:full_name 虽未传入,但因为声明了默认值 None,它同样会出现在 model_dump() 的结果里。
解包一个 dict
如果我们拿到 user_dict 这样一个字典,并用 **user_dict 的形式传给某个函数(或类),Python 会把它"解包":字典的键值对会直接作为关键字参数传入。因此:
UserInDB(**user_dict)
等价于:
UserInDB(
username="john",
password="secret",
email="john.doe@example.com",
full_name=None,
)
更严谨地说,它等价于直接用 user_dict 里的值逐项赋值:
UserInDB(
username = user_dict["username"],
password = user_dict["password"],
email = user_dict["email"],
full_name = user_dict["full_name"],
)
从一个 Pydantic 模型构造另一个 Pydantic 模型
由于上面的 user_dict 来自 user_in.model_dump(),这两行代码:
user_dict = user_in.model_dump()
UserInDB(**user_dict)
就等价于一行:
UserInDB(**user_in.model_dump())
user_in.model_dump() 返回一个 dict,然后用 ** 解包传给 UserInDB 构造函数。这就是 FastAPI 生态里"从一个 Pydantic 模型的数据构造另一个 Pydantic 模型"的标准写法。
解包 dict 的同时追加额外关键字参数
在 docs_src/extra_models/tutorial001_py310.py 中,还追加了关键字参数 hashed_password=hashed_password:
UserInDB(**user_in.model_dump(), hashed_password=hashed_password)
展开后等价于:
UserInDB(
username = user_dict["username"],
password = user_dict["password"],
email = user_dict["email"],
full_name = user_dict["full_name"],
hashed_password = hashed_password,
)
注意:示例中的
fake_password_hasher和fake_save_user只是演示数据如何流转的"假"实现,当然不提供任何真实安全性。生产环境应使用成熟的哈希库(如bcrypt、argon2)配合 FastAPI 安全章节的做法。
消除重复:用继承共享公共字段
减少代码重复是 FastAPI 的核心理念之一。重复代码会增加出错概率、安全隐患以及代码失步的风险(你更新了其中一处却忘了更新另一处)。
上面三个模型共享了相当多字段(username、email、full_name),每个模型都在重复声明属性名和类型。我们可以做得更好:声明一个 UserBase 基础模型,然后让其他模型继承它。子类会继承父类的全部属性(类型声明、校验等),而数据转换、校验、文档生成(OpenAPI Schema)等一切功能照常工作。这样我们只需声明各模型之间的差异(明文 password、hashed_password 或无密码),对应 docs_src/extra_models/tutorial002_py310.py:
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserBase(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
class UserIn(UserBase):
password: str
class UserOut(UserBase):
pass
class UserInDB(UserBase):
hashed_password: str
def fake_password_hasher(raw_password: str):
return "supersecret" + raw_password
def fake_save_user(user_in: UserIn):
hashed_password = fake_password_hasher(user_in.password)
user_in_db = UserInDB(**user_in.model_dump(), hashed_password=hashed_password)
print("User saved! ..not really")
return user_in_db
@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
user_saved = fake_save_user(user_in)
return user_saved
几个值得注意的细节:
UserOut(UserBase)直接pass:输出模型与基础模型完全一致,无需新增字段。这是 Pydantic 允许的合法用法。UserIn和UserInDB各自只声明差异字段,公共字段的类型、校验、默认值全部由UserBase统一维护,将来改一处即可全局生效。- 转换逻辑
UserInDB(**user_in.model_dump(), hashed_password=hashed_password)保持不变,说明继承改造是"无感"的——路由、响应过滤行为完全一致。测试 tests/test_tutorial/test_extra_models/test_tutorial001_tutorial002.py 用参数化 fixture 同时对tutorial001和tutorial002两个版本运行相同的 POST 请求与 OpenAPI Schema 断言,证明两个实现的行为完全等价。
Union 响应与 OpenAPI 的 anyOf
你可以声明响应是多个类型的 Union(并集),即响应是其中的任意一个。在 OpenAPI 中这会以 anyOf 的形式定义。
要这样做,使用 Python 标准类型提示 typing.Union(对应 docs_src/extra_models/tutorial003_py310.py):
提示:定义
Union时,把最具体的类型放在前面,较不具体的类型放在后面。下面示例中更具体的PlaneItem(多了必填字段size)排在CarItem之前。
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class BaseItem(BaseModel):
description: str
type: str
class CarItem(BaseItem):
type: str = "car"
class PlaneItem(BaseItem):
type: str = "plane"
size: int
items = {
"item1": {"description": "All my friends drive a low rider", "type": "car"},
"item2": {
"description": "Music is my aeroplane, it's my aeroplane",
"type": "plane",
"size": 5,
},
}
@app.get("/items/{item_id}", response_model=PlaneItem | CarItem)
async def read_item(item_id: str):
return items[item_id]
从测试 tests/test_tutorial/test_extra_models/test_tutorial003.py 的 OpenAPI 快照可以看到,FastAPI 确实生成了 "anyOf": [{"$ref": ".../PlaneItem"}, {"$ref": ".../CarItem"}] 这样的响应 Schema,并把两个模型都注册到了 components/schemas 中。同时,响应序列化仍按 PlaneItem 或 CarItem 各自的定义进行过滤:item1 返回 car 数据,item2 返回带 size 的 plane 数据。
关于 Python 3.10 中的 Union 写法
在这个示例里,我们把 PlaneItem | CarItem 作为 response_model 参数的值传入。这里有一个微妙之处需要理解:
- 在类型注解中(例如
some_variable: PlaneItem | CarItem),Python 3.10+ 的|运算符被解释为类型联合; - 但在普通表达式位置(比如赋值语句
response_model=PlaneItem | CarItem的右侧),PlaneItem和CarItem是两个类对象,Python 会尝试对两个类执行位或运算并抛出TypeError,而不是把它解释成类型联合。
因此,当把联合类型作为参数值传递时,应当使用 typing.Union[PlaneItem, CarItem] 的写法;只有出现在注解上下文里(例如函数返回注解 -> PlaneItem | CarItem)时,才可以放心使用竖线语法。示例文件之所以能在两种写法间切换,正是因为它在注解位置使用了竖线,而注解会被 Python 求值为 types.UnionType,FastAPI 同样能识别。
声明模型列表响应
同理,你可以声明返回对象列表的响应,使用 Python 标准的 list(对应 docs_src/extra_models/tutorial004_py310.py):
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str
items = [
{"name": "Foo", "description": "There comes my hero"},
{"name": "Red", "description": "It's my aeroplane"},
]
@app.get("/items/", response_model=list[Item])
async def read_items():
return items
response_model=list[Item] 会让 FastAPI:1)对列表中每个元素按 Item 模型做校验与字段过滤;2)在 OpenAPI 中生成 {"type": "array", "items": {"$ref": ".../Item"}} 的 Schema。
返回任意 dict 的响应
你还可以声明一个"任意字典"响应:只声明键和值的类型,而不使用 Pydantic 模型(对应 docs_src/extra_models/tutorial005_py310.py):
from fastapi import FastAPI
app = FastAPI()
@app.get("/keyword-weights/", response_model=dict[str, float])
async def read_keyword_weights():
return {"foo": 2.3, "bar": 3.4}
这在事先不知道合法的字段/属性名时非常有用——而字段名恰恰是定义 Pydantic 模型所必需的。dict[str, float] 告诉 FastAPI 和 OpenAPI 消费者:响应是一个 JSON 对象,键为字符串、值为浮点数。测试 tests/test_tutorial/test_extra_models/test_tutorial005.py 与 test_tutorial004.py 分别验证了这两种响应形态的 Schema 生成与实际返回值。
小结
- 可以放心地使用多个 Pydantic 模型,并按需继承,让每个"状态"拥有专属模型;
- 一个实体不必只有一个数据模型。当实体必须呈现不同状态时(用户实体就是典型例子:带
password、带hashed_password、或完全不带密码),拆分为UserIn/UserOut/UserInDB这类模型并用model_dump()+**解包互相转换,是清晰且可维护的做法; - 响应可以是
Union(OpenAPIanyOf)、list[Model],甚至dict[K, V],覆盖绝大多数"额外模型"场景; - 继承(
UserBase)消除了字段重复声明,数据转换、校验、文档生成功能不受影响。
相关实现可进一步参阅:路由与响应处理见 fastapi/routing.py,OpenAPI Schema 生成(含 anyOf 字段定义)见 fastapi/openapi/models.py 与 fastapi/openapi/utils.py,全部示例代码与测试位于 docs_src/extra_models/ 与 tests/test_tutorial/test_extra_models/。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00