FastAPI 多模型实战:用 UserIn/UserOut 分离、Pydantic 继承与 Union/list/dict 构建清晰的数据层
发布时间:2026/9/9 23:01:42 锦皓数字建站

FastAPI 多模型实战用 UserIn/UserOut 分离、Pydantic 继承与 Union/list/dict 构建清晰的数据层【免费下载链接】fastapiFastAPI framework, high performance, easy to learn, fast to code, ready for production项目地址: https://gitcode.com/GitHub_Trending/fa/fastapi导读本指南聚焦 FastAPI 中“一个业务实体对应多个数据模型”的经典设计。围绕用户模型我们将拆解为什么必须把输入模型、输出模型与数据库模型分开声明并系统讲解**user_in.model_dump()数据传递技巧、用 Pydantic 基类继承消除重复、用Union表达“多选一”响应以及模型列表和任意dict响应四种进阶写法。阅读本文后你将能独立设计出既不泄露敏感字段、又充分复用且能被 OpenAPI 完整文档化的数据层。本文依据仓库中的韩文教程 docs/ko/docs/tutorial/extra-models.md 编写对应英文版 docs/en/docs/tutorial/extra-models.md所有示例均出自 docs_src/extra_models 下的可运行源码并有 tests/test_tutorial/test_extra_models 中的测试逐一验证。为什么一个实体需要多个模型延续前序教程中的用户注册示例真实项目里一个用户“实体”往往同时存在多种形态因为不同场景对字段的要求截然不同输入模型接收客户端请求需要包含明文password用于登录或注册输出模型返回给客户端绝不能包含密码字段避免把敏感信息原样回传数据库模型用于持久化存储的应当是密码的安全哈希而不是明文。由此可见“一个实体一个模型”的简单做法行不通密码字段要么泄露要么缺失无法同时满足输入、输出、存储三种诉求。关于密码官方文档特别给出了一条红色警示详见 extra-models.md 开头⚠️切勿以明文形式存储用户密码。务必保存一个“之后可以校验的安全哈希secure hash”。如果对“password hash”还不熟悉可以前往 安全章节 学习密码哈希的原理与做法。多模型的基础示例UserIn / UserOut / UserInDB下面是一段最直观的“一实体三模型”示例完整源码见 docs_src/extra_models/tutorial001_py310.pyfrom 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_passwordhashed_password) print(User saved! ..not really) return user_in_db app.post(/user/, response_modelUserOut) async def create_user(user_in: UserIn): user_saved fake_save_user(user_in) return user_saved三个模型各自承担明确职责模型字段用途UserInusername、password、emailEmailStr、full_name可选接收请求体含明文密码UserOutusername、email、full_name输出响应不含任何密码UserInDBusername、hashed_password、email、full_name模拟“入库”形态密码已被哈希路由的写法值得注意端点参数类型是UserInresponse_modelUserOut而内部数据全程以UserInDB流转。借助response_model的过滤机制即便把包含hashed_password的UserInDB对象直接返回FastAPI 也只会按UserOut声明的字段输出密码信息在到达客户端前就被剥离。这一点在仓库测试中得到了完整验证。在 tests/test_tutorial/test_extra_models/test_tutorial001_tutorial002.py 中test_post提交了包含password: secret的 JSON随后断言响应 JSON 只有username、email、full_name三个键——password与内部构造的hashed_password都没有出现在响应里。同一个文件里的test_openapi_schema还精确比对了/openapi.json快照components.schemas中只登记了UserIn与UserOut而UserInDB并不在文档里——这是因为 FastAPI 只把出现在路由请求/响应类型位置上的模型写入 OpenAPI纯内部使用的模型不会被自动暴露这也反向印证了“输出模型必须显式收敛字段”的设计意图。深入**user_in.model_dump()的数据搬运技巧fake_save_user中最核心的一行是user_in_db UserInDB(**user_in.model_dump(), hashed_passwordhashed_password)它把“从输入模型到数据库模型”的字段迁移压缩成了一行代码。拆开看它由三个 Python 语言特性组成。Pydantic 的.model_dump()user_in是 Pydantic 模型UserIn的实例。Pydantic v2 为每个模型提供了.model_dump()方法返回包含当前模型数据的普通dict不再是模型对象。例如user_in UserIn(usernamejohn, passwordsecret, emailjohn.doeexample.com)执行user_dict user_in.model_dump()此时user_dict就是一个普通的 Python 字典。打印它print(user_dict)输出Python 3.7 字典保持插入顺序{ username: john, password: secret, email: john.doeexample.com, full_name: None, }兼容性提示在 Pydantic v1 中对应方法名为.dict()v2 起统一为.model_dump()本仓库使用 v2故文档与示例均采用新写法。用**解包字典把一个dict如user_dict以**user_dict形式传给函数或类构造器时Python 会执行“字典解包”把字典的每个键值对直接展开成对应的关键字参数。因此UserInDB(**user_dict)等价于UserInDB( usernamejohn, passwordsecret, emailjohn.doeexample.com, full_nameNone, )更准确地说user_dict将来无论新增了哪些键它都会等价于下面的“逐项取键”写法UserInDB( username user_dict[username], password user_dict[password], email user_dict[email], full_name user_dict[full_name], )用另一个模型的数据创建新模型正因为user_in.model_dump()返回的是dict配合**解包就可以“从另一个 Pydantic 模型的数据构造新模型”user_dict user_in.model_dump() UserInDB(**user_dict)与下面的一行写法完全等价UserInDB(**user_in.model_dump())其原理就是先调用.model_dump()拿到dict再用**让 Python 把它解包后传给UserInDB构造器。字典解包 追加关键字参数若还要在解包之外补充额外字段只需在**user_in.model_dump()之后追加关键字参数UserInDB(**user_in.model_dump(), hashed_passwordhashed_password)这等价于UserInDB( username user_dict[username], password user_dict[password], email user_dict[email], full_name user_dict[full_name], hashed_password hashed_password, )注意Python 语法中**解包必须位于关键字参数之前因此hashed_passwordhashed_password只能放在**表达式之后。上述示例中hashed_password由演示用的fake_password_hasher生成——官方特别说明fake_password_hasher与fake_save_user仅用于演示数据流转并不提供任何真实安全性真实项目必须使用经过充分审校的密码哈希方案例如标准库hashlib的pbkdf2_hmac、bcrypt或argon2等。用 Pydantic 继承消除模型间重复仔细对比UserIn、UserOut、UserInDB会发现username、email、full_name三个字段及其类型在三个类中重复声明了三次。减少代码重复是 FastAPI 的核心设计理念之一——重复越多引入 bug、安全漏洞以及“一处更新、别处未同步”的代码失同步风险就越大。更好的做法是声明一个UserBase基类让其它模型继承它从而自动继承字段、类型声明与校验逻辑各子类只声明彼此之间的差异部分。完整代码见 docs_src/extra_models/tutorial002_py310.pyfrom 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_passwordhashed_password) print(User saved! ..not really) return user_in_db app.post(/user/, response_modelUserOut) async def create_user(user_in: UserIn): user_saved fake_save_user(user_in) return user_saved经过重构后各模型与基类的关系一目了然UserBase公共字段username、email、full_nameUserIn(UserBase)仅追加明文passwordUserOut(UserBase)pass即与基类字段完全一致不含任何密码UserInDB(UserBase)仅追加hashed_password。继承后的数据转换、字段校验、接口文档等一切行为照常工作没有任何额外成本。文档原文用一句话总结了这种做法“这样一来只需声明模型之间的差异即可带明文password、带hashed_password、以及不带密码三种形态。”值得说明的是这一重构在功能上与原版完全对等仓库测试 test_tutorial001_tutorial002.py 通过参数化夹具对tutorial001_py310与tutorial002_py310两个模块共用同一套test_post与test_openapi_schema断言从侧面证实继承重构不改变任何可观察行为——请求校验、响应过滤与 OpenAPI 结构两者完全一致。用UnionOpenAPI 的anyOf表达“多选一”响应有些接口的同一路径可能返回多种不同类型的对象。此时可以把响应声明为两种及以上类型的Union表示“响应会是其中任意一种”。在 OpenAPI 中这会被定义为anyOf。示例使用交通工具的泛化模型完整代码见 docs_src/extra_models/tutorial003_py310.pyfrom 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, its my aeroplane, type: plane, size: 5, }, } app.get(/items/{item_id}, response_modelPlaneItem | CarItem) async def read_item(item_id: str): return items[item_id]当请求/items/item1时返回CarItem形状的数据请求/items/item2时返回PlaneItem形状的数据但端点签名只有一份。其中CarItem与PlaneItem都继承了BaseItem并通过给type设置默认值car/plane来区分各自类型。类型声明顺序有一个重要讲究官方注释原文提示定义Union时应把更具体的类型放在前面较宽泛的类型放在后面。例如Union[PlaneItem, CarItem]中更具体的PlaneItem排在CarItem之前。这是因为联合类型校验时通常按声明顺序尝试匹配顺序不当可能让响应落入错误的分支。这一行为在 OpenAPI 与运行时两个层面都被测试锁定。在 tests/test_tutorial/test_extra_models/test_tutorial003.py 中test_get_car与test_get_plane分别断言两种响应各返回了正确字段test_openapi_schema则校验了 200 响应 schema 中anyOf依次引用PlaneItem与CarItem两个$ref且各模型属性如PlaneItem的size: integer、两个模型的type默认值都被正确登记进components.schemas。Python 3.10 中如何使用Union原文档在这里补充了一个语法层面的注意点由于Union[PlaneItem, CarItem]是作为response_model的参数值传入、而不是写在类型注解里因此即使在 Python 3.10 中语义上也更推荐显式使用typing.Union。具体来说若写在类型注解位置可以使用 PEP 604 的竖线语法例如some_variable: PlaneItem | CarItem但文档同时提醒早期把这句经验套到赋值语句response_modelPlaneItem | CarItem上时Python 曾会把它解释成在PlaneItem与CarItem之间做一次非法|运算而报错而非类型联合——这就是“注解位置”与“参数值位置”的差异来源。需要补充的是在当前仓库中该提醒与 Python 3.10 的实际行为已经出现演进。示例源码 tutorial003_py310.py 的response_model就直接写成了PlaneItem | CarItem而对应测试全部通过——因为 Python 3.10 起 PEP 604 为类型对象实现了运行时__or__两个模型类之间用|会求值为联合类型对象当前 FastAPI 与 Pydantic v2 均能正确解析并生成anyOf。因此实践建议是如果确定运行在 Python 3.10 与较新的 FastAPI/Pydantic 环境response_modelX | Y可以直接使用若需要兼容更早的 Python 版本3.9 及以下或较旧依赖栈则务必按文档原话使用typing.Union[X, Y]。模型列表list[Model]形式的对象数组响应同理会存在“接口返回一批对象”的场景。为此直接用标准 Python 的list泛型即可示例见 docs_src/extra_models/tutorial004_py310.pyfrom 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: Its my aeroplane}, ] app.get(/items/, response_modellist[Item]) async def read_items(): return itemsresponse_modellist[Item]声明响应体是一个数组数组元素必须是Item形状的对象name、description两个字符串字段。请求/items/时 FastAPI 会对列表逐项做校验与序列化。测试 tests/test_tutorial/test_extra_models/test_tutorial004.py 验证了两点test_get_items断言接口按数组原样返回两项数据test_openapi_schema断言 OpenAPI 中响应 schema 的type为array、items引用#/components/schemas/Item——数组结构被完整表达在接口文档中前端与自动生成的客户端可以据此获得类型。返回任意dict只约束键值类型有些场景下返回数据的字段名在编写代码时无法预知例如外部系统传来的键值对、用户自定义的权重表此时为每个未知字段都预定义 Pydantic 模型并不现实。FastAPI 允许直接以普通的dict作为响应模型只声明键与值的类型。示例见 docs_src/extra_models/tutorial005_py310.pyfrom fastapi import FastAPI app FastAPI() app.get(/keyword-weights/, response_modeldict[str, float]) async def read_keyword_weights(): return {foo: 2.3, bar: 3.4}dict[str, float]表示响应是一个字典所有键必须是str所有值必须是float。这里没有任何 Pydantic 模型参与FastAPI 只按键值类型约束校验与序列化因而可以承载任意数量的键。测试 tests/test_tutorial/test_extra_models/test_tutorial005.py 展示了其在 OpenAPI 中的表达test_openapi_schema断言响应 schema 的type为object同时additionalProperties为{type: number}——即“允许任意键、值必须为数字”这正是dict[str, float]的标准 JSON Schema 映射。小结让每个实体自由拥有多个模型综合全文可以得到一套可复用的建模方法论按场景拆分模型一个业务实体如果需要呈现多种“状态”如用户的明文密码态、哈希密码态、无密码态不必强求“一实体一模型”而是分别定义输入、输出、存储等形态模型间自由继承把公共字段沉淀到基类如UserBase子类只声明差异用继承消除重复、用pass表示“与基类一致”数据在模型间流转善用.model_dump()**解包必要时再追加关键字参数如hashed_password把模型互转收敛为一行表达式响应类型灵活声明多选一用UnionOpenAPIanyOf、对象数组用list[Model]、键值未知用dict[str, T]三者均会被 FastAPI 自动文档化并生成对应 schema。全部示例均位于 docs_src/extra_models 目录逐一对应的验证测试位于 tests/test_tutorial/test_extra_models读者可以参照测试中的请求体与 OpenAPI 快照自行运行验证上述每种响应声明在真实请求与自动文档中的具体表现。【免费下载链接】fastapiFastAPI framework, high performance, easy to learn, fast to code, ready for production项目地址: https://gitcode.com/GitHub_Trending/fa/fastapi创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。