首页
/ FastAPI 多模型响应实践:UserIn/UserOut/UserInDB 继承复用与 Union anyOf 响应类型

FastAPI 多模型响应实践:UserIn/UserOut/UserInDB 继承复用与 Union anyOf 响应类型

2026-09-07 14:37:09作者:齐添朝

本篇基于 FastAPI 官方教程“Extra Models”(额外模型)章节,讲解如何为一个业务实体(如用户)声明多个关联的 Pydantic 模型——输入模型、输出模型与数据库模型,以及如何通过类继承消除字段重复;同时覆盖用 Union 声明 OpenAPI anyOf 联合响应、列表响应与任意 dict 响应的完整写法,并结合 FastAPI 源码说明响应校验与序列化的底层流程。读完后,你能掌握“一个实体多种状态”场景下的模型组织方案,并理解 response_model 在框架内部从校验到序列化的调用链。

为什么要为同一个实体准备多个模型

延续 FastAPI 教程中此前的用户创建示例,实际项目中很常见的情形是:同一个业务实体需要多个相互关联的模型。这对用户模型尤其典型,因为:

  • 输入模型(input model)需要能够携带明文密码;
  • 输出模型(output model)绝不应该包含密码;
  • 数据库模型通常需要存储的是密码的安全哈希值(hashed password)。

危险(Danger):永远不要以明文存储用户密码。应始终存储一个可事后校验的“安全哈希”。如果还不了解什么是“密码哈希”,可以在 FastAPI 教程的安全章节中学习。

下面完整给出教程 tutorial001 的示例代码,它展示了三类模型及其密码字段、以及各自的使用位置:

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

关键数据流是:POST /user/ 接收 UserIn(含明文 password)→ fake_save_user 中计算 hashed_password,并用 UserInDB(**user_in.model_dump(), hashed_password=...) 构建数据库模型 → 路由声明 response_model=UserOut,最终只把 usernameemailfull_name 序列化返回,password 不会出现在响应中。

警告(Warning):辅助函数 fake_password_hasherfake_save_user 只是用于演示数据流动,并不提供任何真实的安全保障。

**user_in.model_dump() 的数据流详解

这一节继承原文档中对 UserInDB(**user_in.model_dump(), hashed_password=hashed_password) 这行核心代码的逐层拆解。

Pydantic 的 .model_dump() 方法

user_in 是一个 UserIn 类的 Pydantic 模型对象。Pydantic 模型提供 .model_dump() 方法,它会返回一个包含模型数据的 dict

例如,创建对象:

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,
}

解包(Unpacking)一个 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,Python 就把它解包了。这样就从一个 Pydantic 模型的数据创建了另一个 Pydantic 模型。

解包 dict 并追加额外的关键字参数

再加上一个额外的关键字参数 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,
)

这正是 tutorial001fake_save_user 完成“输入模型 → 数据库模型”转换的标准写法。

用继承减少模型重复(UserBase)

减少代码重复是 FastAPI 的核心理念之一:重复代码会提高 bug、安全问题以及多处代码不同步(改了这里忘了那里)的风险。上面的三个模型共享了大量数据,属性名和类型都在重复声明。

更好的做法是:声明一个 UserBase 基础模型,其他模型通过子类化继承它的属性(类型声明、校验等)。所有数据转换、校验、文档生成都照常工作,而你只需要声明各模型之间的差异(带明文 password、带 hashed_password、或不带密码)。完整示例见 tutorial002

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),因为输出模型恰好就是基础模型的全部字段;
  • UserInUserInDB 各自只追加了差异字段 password / hashed_password

对应的测试 test_tutorial001_tutorial002.py 对两种写法分别验证了 POST /user/ 的请求校验与响应结构,确保重构后行为一致。

响应声明为 Union(OpenAPI anyOf

你可以把某个接口的响应声明为两个(或多个)类型的 Union,即响应可以是其中任意一种类型。在 OpenAPI 中它会以 anyOf 定义。做法是使用标准 Python 类型标注 typing.Union(完整示例见 tutorial003):

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]

备注:定义 Union 时,应把最具体的类型放在第一位,其次再放较不具体的类型。在下例中,较具体的 PlaneItem 位于 Union[PlaneItem, CarItem]CarItem 之前。

源码层面:anyOf 如何进入 OpenAPI Schema

从源码结构看,anyOf 是 FastAPI 内置 OpenAPI Schema 模型的一个字段:在 fastapi/openapi/models.py 中,Schema 模型定义了 allOfanyOfoneOf 等 JSON Schema 组合关键字。当 response_model 是一个 Union 类型时,FastAPI 生成的 OpenAPI 响应 schema 就会是一个 anyOf 结构,其中每个分支用 $ref 引用对应模型。

这一点在测试 test_tutorial003.py 中被完整固化:GET /openapi.json 返回的 200 响应 schema 为:

{
    "title": "Response Read Item Items  Item Id  Get",
    "anyOf": [
        {"$ref": "#/components/schemas/PlaneItem"},
        {"$ref": "#/components/schemas/CarItem"}
    ]
}

同时该测试用 TestClient 分别请求 item1(车)与 item2(飞机),验证返回的 JSON 与各自模型完全一致。

Union 在 Python 3.10 中的写法差异

在上例中,PlaneItem | CarItem(即 Union[PlaneItem, CarItem])是作为 response_model 参数传入的。因为它是传给参数的值而不是类型注解,所以即使在 Python 3.10 上也要保证它是一个有效的类型表达式(3.10 的 | 语法在运行期的类型表达式中可用;但在低版本 Python 中必须写作 Union[PlaneItem, CarItem])。

如果它出现在类型注解位置,可以像这样使用竖线:

some_variable: PlaneItem | CarItem

但如果在赋值语句 response_model=PlaneItem | CarItem 中写成运行期的“类与类的 | 运算”而不符合类型语法规则,Python 会尝试对 PlaneItemCarItem 执行一个非法操作(把两个类当作普通对象做 or/按位运算语义的合并),从而产生错误,而不是把它解释成类型注解。因此“参数值”与“类型注解”两种位置在使用 Union 时要区分对待。

列表响应:list[Model]

同样的方式,你也可以声明返回对象列表的响应,使用标准 Python list 即可(示例见 tutorial004):

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

响应中每个元素都会按 Item 模型校验与序列化;测试 test_tutorial004.py 验证了 GET /items/ 返回的两个对象及 OpenAPI schema 的数组定义。

用任意 dict 声明响应

你也可以不用 Pydantic 模型,仅声明键和值的类型,用普通 dict 来声明响应(示例见 tutorial005):

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 模型要求预先声明字段)的场景下非常有用。测试 test_tutorial005.py 验证了该接口返回的键值类型校验行为。

源码印证:response_model 的校验与序列化流程

上述“响应必须是某模型(或 Union、list、dict)”的约束,在框架内部的落点是 fastapi/routing.py 中的 serialize_response()

  1. 路由注册时,FastAPI 根据 response_model 创建 response_field(见 fastapi/routing.py 处的 create_model_field);
  2. 端点返回后,框架调用 serialize_response(field=response_field, response_content=返回值)
  3. 其中先执行 field.validate(response_content, {}, loc=("response",))——这一步对返回值做与请求体同源的 Pydantic 校验;对 Union 响应,即“尝试匹配 PlaneItemCarItem 之一”;
  4. 校验失败会抛出 ResponseValidationError,由框架转为 500 响应;
  5. 校验通过后,用 field.serialize / field.serialize_json 完成序列化,includeexcludeby_aliasexclude_unsetexclude_defaultsexclude_none 等参数决定了最终 JSON 字段的选择(这也是为什么返回 UserInDB 对象、声明 response_model=UserOut 时,hashed_password 会被过滤掉)。

也就是说,“输入模型带密码、输出模型不带密码”不仅是文档层面的约定,而是由 response_field 的校验与序列化在每次请求时强制执行的。

小结

  • 为同一个业务实体(如用户)自由使用多个 Pydantic 模型并按需继承:输入模型带明文 password、数据库模型带 hashed_password、输出模型不带任何密码字段——实体不需要“一物一模型”,当实体有多种“状态”(passwordpassword_hash、或无密码)时,多模型 + 继承是标准方案;
  • user_in.model_dump() 得到 dict,再用 ** 解包传给另一个模型构造函数,可高效完成“模型到模型”的转换,并支持追加额外关键字参数;
  • 响应可以是 Union(OpenAPI 中的 anyOf,最具体的类型放前面)、list[Model],或仅声明键值类型的任意 dict
  • 所有这些约束都由 FastAPI 的 response_field 在响应序列化阶段统一校验与过滤,参见 fastapi/routing.py,并有 tests/test_tutorial/test_extra_models/ 下的测试套件覆盖验证。
登录后查看全文
热门项目推荐
相关项目推荐