2.2 请求体建模:Pydantic与手写解析对比


2.2 请求体建模:Pydantic 模型与手写解析的对比

请求体建模用 Pydantic 模型在函数签名处声明:模型类定义字段、类型、默认值与约束,框架负责把 JSON 解析、类型转换、逐字段校验、错误汇总全部完成,业务函数拿到的直接是强类型实例。本节用一个贯穿案例对比三种方案——Flask 手写字典校验、marshmallow Schema、Pydantic 模型——并展开嵌套模型、列表字段与可选字段的设计准则。

学习目标

阅读完本节,你应当能够:

  1. 用 Pydantic 定义带嵌套与列表的请求体模型并声明到接口上;
  2. 说出三种校验方案在代码量、漂移风险、文档产出上的差别;
  3. 区分"缺字段"与"空值"两类错误在模型里的表达;
  4. 用 Field 约束与示例数据让模型自解释;
  5. 应用模型设计的三条准则避免常见返工。

从一个真实需求出发

电商场景的"创建订单"接口,请求体里有一等字段、有嵌套的收货地址、有变长的商品明细列表:

{ "buyer_note": "放门口", "address": {"province": "浙江", "city": "杭州", "detail": "某路 1 号"}, "items": [ {"sku": "A-1001", "qty": 2}, {"sku": "B-2002", "qty": 1} ] }

先看 Flask 手写校验的典型形态:

data = request.get_json(silent=True) or {} note = data.get("buyer_note", "") addr = data.get("address") or {} province = addr.get("province") city = addr.get("city") detail = addr.get("detail") if not province or not city or not detail: return jsonify(error="地址不完整"), 400 items = data.get("items") if not isinstance(items, list) or not items: return jsonify(error="items 必须是非空列表"), 400 for it in items: if not isinstance(it.get("qty"), int) or it["qty"] < 1: return jsonify(error="数量非法"), 400

二十行过去,还只覆盖了字段存在性与两个浅层约束;错误格式靠每次手写;文档里不会出现任何结构说明。marshmallow 方案好一些:定义 Schema 类、调用 load,校验与反序列化分离且可复用——但视图与 Schema 之间仍需要一段粘合代码,Schema 与接口的对应关系靠命名约定维持。

Pydantic 版本把结构定义压成一个模型树:

from pydantic import BaseModel, Field class Address(BaseModel): province: str city: str detail: str class OrderItem(BaseModel): sku: str = Field(min_length=1) qty: int = Field(ge=1, le=999) class CreateOrder(BaseModel): buyer_note: str = "" address: Address items: list[OrderItem] = Field(min_length=1) @app.post("/orders") def create_order(payload: CreateOrder): total_kinds = len(payload.items) return {"kinds": total_kinds, "city": payload.address.city}

结构即类型,类型即校验。传一个缺 city 的地址进来,得到的 422 会精确定位到 body.address.city,并回显你传的整个地址对象。三种方案的差距不在"能不能做到",而在做到之后还有多少结构残留:手写版的结构残留是二十行每次重写的 if;marshmallow 版是粘合代码与两套命名;Pydantic 版只剩模型本身,而模型同时是文档、是测试样例工厂、是前端 TypeScript 类型的源头。

嵌套与列表的解析顺序

嵌套模型的错误定位值得单独看,因为它是排错时最依赖的能力。loc 字段是一个路径数组:["body", "address", "city"] 表示请求体的 address 对象的 city 字段非法。列表字段再进一层用下标:["body", "items", 2, "qty"] 表示第三个商品的 qty 超范围。前端拿到这个数组可以精确地在表单对应输入框上标红——这种联动在 Flask 手写方案里需要后端额外设计错误编码协议才能做到,Pydantic 里它是默认产物。

解析顺序上有个实用细节:模型树的校验是一次性全量进行的,不是短路式。三个字段都错时,422 的 detail 数组里会有三条错误,而不是只报第一个。这让"提交一次修完所有问题"成为可能,联调阶段的往返次数显著减少。手写 if 版本天然是短路的,用户修一个错、提交、再发现下一个错,体验差距在字段多的表单上尤其明显。

"缺字段"与"空值":两个不同的概念

请求体里最容易混淆的一对概念:字段没传,与字段传了但是空值。Pydantic 的表达方式:

class Query(BaseModel): keyword: str | None = None # 可缺省,传 null 也接受 region: str = "" # 可缺省,缺省时是空串 tag: str # 必填,缺了就 422

三行展示了三种语义。keyword 适合"可选筛选条件"——没传与传 null 都等价于不筛选;region 适合"有默认语义的字段"——空串可能表示"全部地区";tag 是硬性必填。设计模型时的自问:这个字段缺失时业务上意味着什么?答案决定了用哪一行写法。含糊地全用 Optional 是反模式——它让"用户显式清空"与"用户没碰这个字段"变成同一件事, PATCH 更新接口里这两者的差别是实质性的(详见第 4 章响应与部分更新的讨论)。

Field 的元数据:让模型自解释

Field 除了约束还能携带示例与描述:

class Register(BaseModel): email: EmailStr = Field(examples=["user@example.com"]) age: int = Field(ge=18, description="成年用户")

examples 会流进 OpenAPI 文档的请求体示例,前端联调时直接可抄;description 出现在字段说明里。对比一下维护成本:这些信息写在文档平台上,与代码隔一次同步;写在 Field 里,与定义同生共死。所有"会过期的注释"都应该考虑搬进签名与模型——这是贯穿本教程的一条工程原则,第 6 章 OpenAPI 一节会看到它的完整形态。

嵌套示例的推荐写法是用模型级配置统一声明:

from pydantic import BaseModel, ConfigDict class Register(BaseModel): model_config = ConfigDict(json_schema_extra={ "examples": [{"email": "a@b.com", "age": 20}] }) email: EmailStr age: int = Field(ge=18)

💡 关键直觉:把 Pydantic 模型当作"接口的数据库表结构"对待——字段类型想清楚再写、约束一次到位、示例随手带上,后续每一层(文档、测试、前端类型)都在消费这份定义。

模型设计的三条准则

第一条,模型跟着接口走,不跟着数据库走。请求体模型描述"外部世界允许提交什么",与存储层的表结构是两个关注点。订单表可能有十几个字段,创建接口只接受五个——分开建模,别让 ORM 模型直接暴露成请求体(这既是安全问题也是耦合问题,第 6 章数据库集成会再遇到)。

第二条,嵌套不超过三层,超过就拆接口或拍平order.customer.profile.preferences.notify_channels 这种访问链意味着请求体结构过深,前端构造困难、错误定位冗长。深结构往往是把"一个接口做完一个业务流程"的信号,拆成两步提交通常更清晰。

第三条,列表字段必须带约束min_length(非空保证)、元素上限(防止恶意超长请求)都要显式写。无约束的 list[str] 理论上能接收百万元素,解析与校验的内存开销会先于你的业务代码把服务打垮——输入上限是接口的自我保护,不是可有可无的装饰。

三方案对比总表

维度 手写字典校验 marshmallow Pydantic 模型
结构定义 散在 if 里 Schema 类 模型类即签名
错误定位 手写格式 较完整 结构化 loc 路径
错误聚合 短路式 可配置 默认全量汇总
文档产出 需扩展 自动进 OpenAPI
与接口的耦合点 视图开头 load 调用 函数签名
复用形态 复制粘贴 Schema 复用 模型复用加组合

模型之外:请求体的三种非常规形态

并非所有请求体都是 JSON。三类例外要知道怎么接。

**表单与文件上传。**HTML 表单直传或 multipart 文件上传时,签名用 Form 与 File:

from fastapi import Form, File, UploadFile @app.post("/avatars") async def upload_avatar( uid: int = Form(), image: UploadFile = File(), ): content = await image.read() return {"uid": uid, "size": len(content), "type": image.content_type}

UploadFile 比裸 bytes 多了文件名与内容类型,且以流式方式读——大文件不会一次性进内存。对照 Flask 的 request.files,能力等价,差异仍是声明的位置:FastAPI 版的表单字段(uid 必填、image 必须是文件)在签名与文档双确认。文件上传的安全清单值得背下:校验扩展名与内容类型(双查,只信一个都可能被绕过)、限制大小(在反向代理层加 body 大小限制更早拦截)、重命名存储(用户文件名直接落盘有路径注入风险,与第 6 章要讲的目录遍历是近亲)。

**裸文本与二进制体。**webhook 回调常发纯文本或原始字节,签名用 body: bytes 即可拿到原始体自行解析。JSON 之外的结构化格式(某些遗留系统的 XML)也走这条路——先收字节再交给对应的解析库,Pydantic 模型在解析之后接管校验。

**同一接口多形态。**罕见但存在:同一个端点按 Content-Type 接收 JSON 或表单。FastAPI 不鼓励在一个签名里同时声明 Body 与 Form(歧义),惯用做法是拆成两个接口或自己读 Request 对象判断类型——遇到这种需求先怀疑设计,多半是历史包袱,该在网关层转换掉。

模型的组织:一个资源一组模型树

接口数量上来后,模型的文件组织要跟上。推荐按资源分组——订单的创建、更新、输出模型放在一起,而不是按模型类型分组(所有输入模型一处、所有输出模型一处)。改一个资源时只动一个区块,review 的上下文也完整。命名后缀全项目统一一种(In 加 Out 加 Update,或 Create 加 Read 加 Update),混用两套命名是后期维护的持续摩擦源。

复用方式上优先组合而不是继承:Address 被 Order 和 Profile 共用时,作为嵌套模型出现在两者里,而不是造一个含 Address 的基类让两边继承。继承树在模型层容易长歪——基类加一个字段等于全部子类同时多出字段,影响面失控;组合的耦合是显式、可枚举的,删掉一个组合不惊动别人。这条原则与第 7 章项目结构里"模型归位"的讨论同源:模型层是契约的载体,契约的变化要可控可追溯

常见问题速答

**问题:模型定义和数据库表太像了,能复用吗?**诱人但要克制。请求体模型与 ORM 模型的相似是表面的:字段集合不同(请求没有 id 与时间戳)、约束语义不同(数据库的唯一性与对用户的友好提示是两回事)、演进节奏不同(表结构重构不该立刻改变对外契约)。复用一个类等于把两层耦合焊死,第 6 章会给出完整的双轨方案。

**问题:客户端传了模型外的多余字段,会发生什么?**默认被忽略——Pydantic 只取模型声明的字段。想更严格(拒绝未知字段,防拼写错误导致参数悄悄失效)可以在模型配置里开启额外字段禁止策略,422 会指出未知字段名。对开放 API 推荐开启,这是帮客户端发现字段名拼错的善意;内部可控系统可保持宽松,减少兼容负担。

**问题:模型能继承复用吗?**能但少用。继承在模型层的问题是基类字段被动地出现在全部子类——想让管理员创建接口比普通创建多一个字段,继承会把改动放大到整个家族。组合(嵌套)与工厂函数(按条件构造模型)通常是更可控的复用方式。简单场景直接复制几个字段,代码量多三行,换来的是各接口模型的独立演进。

本节要点回顾

  • 模型即契约:嵌套模型树把请求体结构、类型、约束、示例收敛到一处定义,文档与错误定位都是它的副产品。
  • 错误定位loc 数组精确到嵌套字段与列表下标,前端可直接映射到表单控件。
  • 全量校验:错误一次性全部返回,联调往返更少;手写方案天然短路。
  • 三种空语义:缺省可空、缺省有默认、必填,用默认值与 | None 的组合表达,别全用 Optional 含糊了事。
  • Field 元数据:examples 与 description 写进定义处,文档不再单独维护。
  • 三条准则:模型跟接口不跟表、嵌套不超三层、列表必带约束。

模型定义好了,下一节打开引擎盖:Pydantic 自己在 v1 与 v2 之间发生了什么,为什么存量教程的校验器写法在新项目里会报错。

延伸与边界

本节的建模思想有一条清晰的能力边界:Pydantic 擅长"结构与静态规则",不擅长"需要外部世界的判定"。字段格式、长度、嵌套形状都是静态规则,模型层一手包办;库存是否充足、用户是否存在,这些要查世界的判定天然属于业务层。混淆两者的代码有共同气味——模型校验器里出现数据库调用。看到这种气味就重构:把规则搬出模型,搬进路由或服务层,让模型回到"只看请求本身"的纯粹位置。守住这条边界,模型才能保持可独立测试、可被文档消费、可跨接口复用的三重价值。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U