首页
/ FastAPI 多模型实战:UserIn/UserOut/UserInDB 继承、Union 响应与任意 dict 返回

FastAPI 多模型实战:UserIn/UserOut/UserInDB 继承、Union 响应与任意 dict 返回

2026-09-06 12:10:06作者:晏闻田Solitary

在 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,响应体只包含 usernameemailfull_name,并且 OpenAPI Schema 中请求体引用 UserIn、响应引用 UserOut

关于 user_in.model_dump()

Pydantic 的 .model_dump() 方法

user_inUserIn 类的 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_hasherfake_save_user 只是演示数据如何流转的"假"实现,当然不提供任何真实安全性。生产环境应使用成熟的哈希库(如 bcryptargon2)配合 FastAPI 安全章节的做法。

消除重复:用继承共享公共字段

减少代码重复是 FastAPI 的核心理念之一。重复代码会增加出错概率、安全隐患以及代码失步的风险(你更新了其中一处却忘了更新另一处)。

上面三个模型共享了相当多字段(usernameemailfull_name),每个模型都在重复声明属性名和类型。我们可以做得更好:声明一个 UserBase 基础模型,然后让其他模型继承它。子类会继承父类的全部属性(类型声明、校验等),而数据转换、校验、文档生成(OpenAPI Schema)等一切功能照常工作。这样我们只需声明各模型之间的差异(明文 passwordhashed_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 允许的合法用法。
  • UserInUserInDB 各自只声明差异字段,公共字段的类型、校验、默认值全部由 UserBase 统一维护,将来改一处即可全局生效。
  • 转换逻辑 UserInDB(**user_in.model_dump(), hashed_password=hashed_password) 保持不变,说明继承改造是"无感"的——路由、响应过滤行为完全一致。测试 tests/test_tutorial/test_extra_models/test_tutorial001_tutorial002.py 用参数化 fixture 同时对 tutorial001tutorial002 两个版本运行相同的 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 中。同时,响应序列化仍按 PlaneItemCarItem 各自的定义进行过滤:item1 返回 car 数据,item2 返回带 sizeplane 数据。

关于 Python 3.10 中的 Union 写法

在这个示例里,我们把 PlaneItem | CarItem 作为 response_model 参数的传入。这里有一个微妙之处需要理解:

  • 类型注解中(例如 some_variable: PlaneItem | CarItem),Python 3.10+ 的 | 运算符被解释为类型联合;
  • 但在普通表达式位置(比如赋值语句 response_model=PlaneItem | CarItem 的右侧),PlaneItemCarItem 是两个类对象,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.pytest_tutorial004.py 分别验证了这两种响应形态的 Schema 生成与实际返回值。

小结

  • 可以放心地使用多个 Pydantic 模型,并按需继承,让每个"状态"拥有专属模型;
  • 一个实体不必只有一个数据模型。当实体必须呈现不同状态时(用户实体就是典型例子:带 password、带 hashed_password、或完全不带密码),拆分为 UserIn / UserOut / UserInDB 这类模型并用 model_dump() + ** 解包互相转换,是清晰且可维护的做法;
  • 响应可以是 Union(OpenAPI anyOf)、list[Model],甚至 dict[K, V],覆盖绝大多数"额外模型"场景;
  • 继承(UserBase)消除了字段重复声明,数据转换、校验、文档生成功能不受影响。

相关实现可进一步参阅:路由与响应处理见 fastapi/routing.py,OpenAPI Schema 生成(含 anyOf 字段定义)见 fastapi/openapi/models.pyfastapi/openapi/utils.py,全部示例代码与测试位于 docs_src/extra_models/tests/test_tutorial/test_extra_models/

登录后查看全文
热门项目推荐
相关项目推荐