D7 学习笔记:Pydantic + FastAPI 数据校验——AI 工程师的"安全网"

系列:海口三港 AI 全栈实战 · 从参赛大屏到 AI 平台
仓库:https://github.com/2003Tim/haikou-ai-port


前言:D7 我搞清楚了 12 个"为什么"

D6 我有了 FastAPI Hello World,但所有数据都是 dict,传错类型不报错。D7 引入 Pydantic 后,API 自动校验 +自动文档 +自动错误——这才是生产级的 FastAPI。

但 D7 我问了 12 个"为什么"才真正理解:Pydantic、BaseModel、Field、Query/Path/Body、response_model、status_code、Optional、HTTPException、dict vs JSON、ge/gt/le/lt、alias、422 vs 500……

这篇博客把这些问题全部整理清楚,方便未来自己回顾。

💡 为什么 Pydantic 必学一次?LangChain、Pydantic AI、SQLModel、FastAPI 都强依赖它。学一次,后续所有 AI 项目都用得上。


一、Pydantic 是什么?

Pydantic 是一个 Python 第三方库——专门做"数据验证 + 序列化 + 文档生成"。

FastAPI 强依赖它

# FastAPI 安装时自动安装 Pydantic
uv add fastapi
# → 自动装 pydantic v2

一句话定位

数据进 Python 之前自动验证,数据出 Python 之前自动格式化。


二、BaseModel:数据模型的"魔法父类"

继承 BaseModel 你的类就自动获得 6 大能力:

from pydantic import BaseModel

class Port(BaseModel):
    id: int
    name: str
    capacity: int = 0

自动获得的 6 大能力

能力 说明 代码
类型校验 name: str 传123 自动报错 Port(name=123) → ValidationError
自动类型转换 "123" 自动转 123 Port(id="1")Port(id=1)
JSON 序列化 直接 model_dump_json() Port(...).model_dump_json()
反序列化 直接从 JSON 创建 Port.model_validate_json('{...}')
字段访问 像属性一样访问 p.name
Swagger 文档 自动出现在 /docs 免费!

BaseModel vs dataclass(D3 学的)

dataclass BaseModel
数据容器
自动验证
JSON 序列化 ❌(要手写)
类型转换

简单说:dataclass 是"装数据的盒子",BaseModel 是"装数据 + 验证 + 序列化"的智能盒子。


三、Field():给字段加约束的工具

Field() 是给字段加默认值、约束、描述的工具。

完整能力清单

from pydantic import BaseModel, Field

class Port(BaseModel):
    # ① 默认值(第一个参数)
    name: str = Field("default")         # 默认 "default"
    code: str = Field(...)                # 必填(`...` = Ellipsis)

    # ② 数值约束
    capacity: int = Field(0, ge=0, le=100000)   # 0 <= x <= 100000
    ratio: float = Field(0.5, gt=0.0, lt=1.0)   # 0 < x < 1(无等号!)

    # ③ 字符串约束
    title: str = Field("", min_length=1, max_length=50)
    email: str = Field(..., pattern=r"^[\w.+-]+@[\w-]+\.[\w.-]+$")

    # ④ 描述(Swagger UI 显示)
    description: str = Field(..., description="港口名称")

    # ⑤ 别名(序列化改名)
    full_name: str = Field(..., alias="fullName")

    # ⑥ 示例值(Swagger UI 显示)
    port_code: str = Field(..., examples=["XYG-001"])

Field(...)... 是啥?

... 是 Python 的 Ellipsis(省略号),意思是"必须有值,不能为空"

简写形式

capacity: int = 0               # 默认 0,无约束
capacity: int = Field(0, ge=0) # 默认 0,有约束(必须用 Field)

💡 简单默认值可以直接写,带约束就必须用 Field()


四、Query / Path / Body:三种参数来源

工具 参数位置 何时用
Path() URL 路径 /ports/{port_id} 必填,标识"哪个资源"
Query() URL 查询 ?min_capacity=6000 可选,过滤/排序/分页
Body() 请求体 JSON POST/PUT 创建/更新的数据

一个综合例子

@app.post("/ports/{port_id}/sailings")
def create_sailing(
    # ① 路径参数:必填,标识"哪个港口的班次"
    port_id: int = Path(..., ge=1, le=10, description="港口 ID"),

    # ② 查询参数:可选,决定"要不要真创建"
    dry_run: bool = Query(False, description="试运行"),

    # ③ 请求体:必填,班次的具体数据
    sailing: SailingCreate = Body(..., description="班次数据"),
):

调用:

POST /ports/1/sailings?dry_run=true
Body: {"start_time": "12:00", "destination": "海安新港"}

为什么需要 Query() 这种包装?

简化版 vs 完整版对比:

# ❌ 简化版:每次都要手写校验
def list_ports(min_capacity: int = None):
    if min_capacity is not None and min_capacity < 0:
        raise HTTPException(400, "不能小于 0")
    if min_capacity is not None and min_capacity > 100000:
        raise HTTPException(400, "不能大于 100000")
    # 一堆 if ...

# ✅ 完整版:一行搞定
def list_ports(min_capacity: Optional[int] = Query(None, ge=0, le=100000)):
    # 不需要手写校验,FastAPI 自动处理
    ...

Query 的 4 大好处

  1. 校验自动 —— 传 -1 直接返回 422,不用手写 if
  2. Swagger UI 自动显示 —— 文档自动带约束
  3. 错误自动 —— 标准的 422 错误格式
  4. 类型提示 —— IDE 能推断类型,提示更准

五、response_model:响应模型

告诉 FastAPI:"用这个 Pydantic 模型序列化响应"

@app.get("/ports", response_model=List[Port])
def list_ports():
    # 内部数据(可能包含敏感字段)
    return [{"id":1, "name":"秀英港", "secret":"内部密钥"}]
    #                              ^^^^^^^^^^^^^^^^^^^^^
    #                              response_model 自动删除 secret

输出:

[{"id": 1, "name": "秀英港"}]  ← secret 被自动过滤

三大作用

作用 例子
过滤敏感字段 数据库有 password,API 只返回需要字段
保证返回结构 返回值一定符合模型(类型安全)
Swagger 文档 自动显示响应示例

六、status_code:HTTP 状态码语义

RESTful 状态码规范:

方法 状态码 含义 代码
GET 200 OK 读取成功 默认
POST 201 Created 创建成功 status_code=201
PUT 200 OK 更新成功 默认
DELETE 204 No Content 删除成功(无 body) status_code=204
任何方法 422 请求数据错 Pydantic 自动
任何方法 404 找不到资源 HTTPException
任何方法 500 服务器错 异常自动

201 的好处

  • 语义清晰:客户端知道"创建成功",可以做后续操作
  • 前端可基于状态码做特殊处理
  • 监控/日志能精确分类

FastAPI 提供语义化常量

from fastapi import status

status_code=status.HTTP_201_CREATED   # 201
status_code=status.HTTP_404_NOT_FOUND # 404
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY  # 422

七、Optional[T]:强制考虑 None

"可能是 T,也可能是 None"

from typing import Optional

# 旧写法(D3 学的)
age: Optional[int] = None

# 新写法(Python 3.10+)
age: int | None = None

为什么需要 Optional?

场景:查数据库里的港口(可能找不到):

def find_port(port_id: int) -> Optional[Port]:
    for port in PORTS_DATA:
        if port.id == port_id:
            return port
    return None  # ⚠️ 必须显式返回 None

调用方必须做 None 检查:

result = find_port(999)
if result is not None:        # ← 用 is not None,不是 != None
    print(result.name)
else:
    print("未找到")

💡 Optional 的工程意义:强制你考虑 None,避免程序因为"找不到"而崩溃。


八、dict vs JSON:长得像,不一样

# Python 字典(内存对象)
port = {"name": "秀英港", "capacity": 6400}
type(port)         # <class 'dict'>

# JSON 字符串(网络传输)
port_str = '{"name": "秀英港", "capacity": 6400}'
type(port_str)     # <class 'str'>

5 个关键差异

维度 Python dict JSON 字符串
类型 dict(内存对象) str(文本)
引号 单引号 '" 都行 只能双引号 "
布尔 True / False true / false(小写)
None None null
用途 程序内部 网络传输/文件存储

转换工具

import json

# dict → JSON 字符串
json.dumps({"name": "Tim"})

# JSON 字符串 → dict
json.loads('{"name": "Tim"}')

💡 为什么 FastAPI 用 JSON:浏览器、JS、Java、Go、Python 几乎所有语言都支持,通用性极强。dict 只是 Python 内部的方言,出不了 Python。


九、ge / gt / le / lt 命名约定

"大于 / 小于" 的英文首字母缩写:

缩写 全称 含义 符号
ge greater than or equal 大于等于 >=
gt greater than 大于 >
le less than or equal 小于等于 <=
lt less than 小于 <

记忆口诀

g = greater (大)
l = less    (小)
e = or equal (或等于)
t = than    (比...)

// g+e = 大或等于 = >=
// g+t = 大于    = >
// l+e = 小或等于 = <=
// l+t = 小于    = <

字符串长度类(全名)

name: str = Field(..., min_length=2, max_length=20)
#                       ^^^^^^^^^^  ^^^^^^^^^^
#                     最少 2 字符    最多 20 字符

十、alias:字段名翻译官

Python 字段名 ↔ JSON 字段名的映射,解决命名冲突。

真实场景

前端(JS 习惯驼峰)vs 后端(Python 习惯下划线):

class User(BaseModel):
    full_name: str = Field(..., alias="fullName")

输入(接受 JS 风格):

User.model_validate({"fullName": "Tim"})  # ✅
User.model_validate({"full_name": "Tim"}) # ✅ 也行

输出(默认 Python 风格):

user.model_dump()  # {"full_name": "Tim"}
user.model_dump(by_alias=True)  # {"fullName": "Tim"}

何时用 alias

  • 对接外部 API(用对方字段名)
  • 团队前后端命名规范不一致
  • 兼容老的数据库列名

十一、HTTPException:标准错误返回

from fastapi import HTTPException

@app.get("/ports/{port_id}")
def get_port(port_id: int):
    for port in PORTS_DATA:
        if port.id == port_id:
            return port
    raise HTTPException(
        status_code=404,
        detail=f"港口 ID {port_id} 不存在",
    )

返回:

{
  "detail": "港口 ID 999 不存在"
}

HTTPException vs raise

HTTPException 普通 raise
返回 标准 JSON 错误 可能 500
状态码 自动设置 不设置
文档 Swagger 显示 不显示

十二、422 vs 500:为什么不是服务器错误?

错误码 含义 谁的问题
400 请求错(URL/参数) 客户端
401 未认证 客户端
403 没权限 客户端
404 找不到资源 客户端
422 数据格式错(Pydantic) 客户端
500 服务器内部错误 服务端

422 = 你的请求格式不对(比如 age 传字符串),不是服务器崩了。FastAPI 用 422 而不是 400,因为 400 太通用,422 更精确("请求语法对,但语义错")。


十三、我踩过的坑

坑1:Apifox 期望 200,代码返回 201

HTTP 状态码应当是 200 ❌

真相:这是 Apifox 配置问题,不是代码错。我们的代码用 201(正确),改 Apifox 期望值为 201 即可。

坑2:路径参数忘记写 Path(...)

# ❌ 没写 Path,约束不生效
@app.get("/ports/{port_id}")
def get_port(port_id):  # 没有类型注解,没有约束
    ...

# ✅ 写 Path,自动校验
@app.get("/ports/{port_id}")
def get_port(port_id: int = Path(..., ge=1, le=10)):
    ...

坑3:POST 请求漏写 body 参数

# ❌ FastAPI 不知道怎么解析 body
@app.post("/ports")
def create_port(name, location):  # 参数不知道怎么来
    ...

# ✅ 用 Pydantic 模型
@app.post("/ports")
def create_port(port: PortCreate):  # 自动从 JSON 解析
    ...

坑4:wrapper 里忘 return result(D4 装饰器)

装饰器里如果 wrapper 不 return result,装饰后的函数返回 None。


十四、D7 完整代码

from typing import Optional, List
from fastapi import FastAPI, Path, Query, HTTPException, status
from pydantic import BaseModel, Field

class PortBase(BaseModel):
    name: str = Field(..., min_length=1, max_length=50)
    location: str = Field(...)

class Port(PortBase):
    id: int = Field(..., ge=1, le=100)
    capacity: int = Field(default=0, ge=0)

class PortCreate(PortBase):
    capacity: int = Field(default=0, ge=0)

PORTS_DATA: List[Port] = [
    Port(id=1, name="秀英港", location="海口市秀英区", capacity=6400),
    Port(id=2, name="新海港", location="海口市西秀镇", capacity=8000),
    Port(id=3, name="铁路南港", location="海口市西秀镇", capacity=5200),
]

app = FastAPI(title="海口三港 AI 平台", version="0.1.0")

@app.get("/ports", response_model=List[Port])
def list_ports(
    min_capacity: Optional[int] = Query(None, ge=0),
    location_keyword: Optional[str] = Query(None),
):
    result = PORTS_DATA
    if min_capacity is not None:
        result = [p for p in result if p.capacity >= min_capacity]
    if location_keyword:
        result = [p for p in result if location_keyword in p.location]
    return result

@app.get("/ports/{port_id}", response_model=Port)
def get_port(port_id: int = Path(..., ge=1, le=10)):
    for port in PORTS_DATA:
        if port.id == port_id:
            return port
    raise HTTPException(404, f"港口 ID {port_id} 不存在")

@app.post("/ports", response_model=Port, status_code=201)
def create_port(port: PortCreate):
    new_id = max(p.id for p in PORTS_DATA) + 1
    new_port = Port(id=new_id, **port.model_dump())
    PORTS_DATA.append(new_port)
    return new_port

下一步:D8 学什么?

D8 接入数据库!D7 数据还是硬编码的 3 个港口,D8 我会:

  • SQLite(零配置,Windows 友好)
  • SQLModel(FastAPI 作者做的 ORM)
  • 港口数据从数据库读取(不再硬编码)
  • API 增删改查全部走数据库

到 D8 结束时,v1.0 的核心全部完成!


参考资料

posted @ 2026-08-23 00:39  吴逸清  阅读(21)  评论(1)    收藏  举报