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直接返回 422,不用手写 if - Swagger UI 自动显示 —— 文档自动带约束
- 错误自动 —— 标准的 422 错误格式
- 类型提示 —— 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 的核心全部完成!
参考资料
- Pydantic 官方文档:https://docs.pydantic.dev/latest/
- FastAPI 官方文档:https://fastapi.tiangolo.com/zh/
- 《流畅的 Python》第 13 章(Pydantic 入门)
- Real Python - Pydantic:https://realpython.com/python-pydantic/

浙公网安备 33010602011771号