FastAPI 教程

Chapter 02 · 数据校验:让数据安全进门

📚 学完本章,你将能够:

  • 解释"为什么后端不能信任前端传来的任何数据"
  • 用 Pydantic 模型定义数据的形状和规则
  • 设置字段约束:必填/可选、长度限制、数值范围、正则匹配
  • 处理嵌套数据(如"订单里包含多个商品")
  • 区分请求模型和响应模型,防止泄露敏感字段

💡 场景引入

你的图书 API 上线了。一个用户尝试注册,输入的用户名是 ""(空字符串),邮箱是 "not-an-email",密码只有 2 位。

如果没有校验,这些垃圾数据会直接存进你的"数据库"(目前还是内存列表),导致系统崩溃或安全漏洞。后端永远不应该相信前端传来的数据。

2.1 没有校验的世界有多可怕

看看不做校验的代码,想想会出现什么问题:

❌ 没有校验的代码
@app.post("/users")
async def create_user(data: dict):  # 只是个字典,里面有什么完全不知道
    users.append(data)
    return data

用户可以这样攻击你:

攻击示例
// 请求 1:字段缺失
{ "username": "alice" }  // 没有密码,没有邮箱

// 请求 2:类型错误
{ "username": 12345, "age": "hello" }  // 用户名是数字,年龄是字符串

// 请求 3:恶意数据
{ "username": "", "password": "" }

你的代码必须手动处理每一种情况——这会让函数变成一团浆糊。Pydantic 就是来解决这个问题的。

2.2 Pydantic:数据的守门员

Pydantic 是 Python 的数据校验库,FastAPI 把它集成到了骨子里。核心概念很简单:你定义一个模型类,说明每个字段应该是什么类型、有什么限制,Pydantic 会自动检查传入的数据。

定义模型
from pydantic import BaseModel

class UserCreate(BaseModel):
    username: str
    email: str
    password: str
    age: int | None = None  # 可选字段,默认 None

当用户发送 POST 请求时,FastAPI 会:

Pydantic 校验流程
1

接收原始 JSON

{"username": "alice", "email": "a@b.com"}

2

检查字段是否齐全

password 缺失 → 直接返回 422 错误

3

检查类型是否正确

age: "twenty" → 无法转成 int → 返回 422

4

构造合法对象

全部通过后,传入你的函数的是一个干净的 UserCreate 对象

2.3 给字段加规则:Field()

类型检查只是第一步。生产环境需要更精细的规则:用户名至少 3 个字符,密码至少 8 位,邮箱必须符合格式……

带约束的模型
from pydantic import BaseModel, Field

class UserCreate(BaseModel):
    username: str = Field(min_length=3, max_length=20, description="用户登录名")
    email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")
    password: str = Field(min_length=8)
    age: int | None = Field(default=None, ge=0, le=150)
约束含义示例
min_length / max_length字符串长度限制Field(min_length=3)
ge / le大于等于 / 小于等于Field(ge=0, le=150)
gt / lt大于 / 小于Field(gt=0)(价格必须为正)
pattern正则表达式匹配Field(pattern=r"^\d{11}$")(手机号)
default默认值Field(default="guest")
写作惯例

在 Pydantic v2 中,Field() 的 default=... 和直接写 age: int | None = None 效果一样。后者更简洁,推荐用于简单的默认值。

2.4 响应模型:防止数据泄露

假设你的用户模型里有 password 字段。如果注册接口直接返回这个模型,密码就暴露给了前端!

❌ 危险:返回含密码的模型
class User(BaseModel):
    id: int
    username: str
    email: str
    password: str  # 会暴露!

@app.post("/users", response_model=User)
async def create_user(data: UserCreate):
    ...
    return new_user  # 响应里包含 password 字段
✅ 安全:分开定义请求和响应模型
class UserCreate(BaseModel):  # 接收时用
    username: str
    email: str
    password: str

class UserOut(BaseModel):     # 返回时用
    id: int
    username: str
    email: str
    # 注意:没有 password!

@app.post("/users", response_model=UserOut)
async def create_user(data: UserCreate):
    ...
    return new_user  # password 被自动过滤

response_model=UserOut 的魔力在于:它会自动过滤掉响应对象中不在 UserOut 里的字段。即使你不小心返回了密码,它也不会出现在 JSON 响应中。

2.5 嵌套模型:处理复杂数据结构

现实中的数据往往是嵌套的。比如一个"订单"包含多个"商品项":

嵌套模型
class OrderItem(BaseModel):
    product_name: str
    quantity: int = Field(gt=0)
    unit_price: float = Field(gt=0)

class OrderCreate(BaseModel):
    customer_name: str
    items: list[OrderItem]  # 嵌套列表!
    address: str

@app.post("/orders")
async def create_order(order: OrderCreate):
    total = sum(item.quantity * item.unit_price for item in order.items)
    return {"total": total, "item_count": len(order.items)}

对应的 JSON 请求体:

JSON 请求体
{
  "customer_name": "张三",
  "address": "北京市海淀区",
  "items": [
    { "product_name": "iPhone", "quantity": 1, "unit_price": 5999.0 },
    { "product_name": "AirPods", "quantity": 2, "unit_price": 999.0 }
  ]
}

Pydantic v2 中,把模型对象转成字典用 .model_dump(),转成 JSON 字符串用 .model_dump_json()。

序列化
user = UserOut(id=1, username="alice", email="a@b.com")

# 转字典
d = user.model_dump()  # {"id": 1, "username": "alice", "email": "a@b.com"}

# 转 JSON 字符串
json_str = user.model_dump_json()  # '{"id":1,...}'

# 排除未设置的字段(用于 PATCH 部分更新)
partial = user.model_dump(exclude_unset=True)

如果你在网上看到 .dict() 或 .json(),那是 Pydantic v1 的写法,v2 已经改名了。

2.6 枚举类型:限制可选值

如果某个字段只能取固定的几个值(如订单状态:待付款、已付款、已发货),用 Enum 比用字符串更安全:

枚举
from enum import Enum

class OrderStatus(str, Enum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"
    COMPLETED = "completed"

class OrderUpdate(BaseModel):
    status: OrderStatus  # 只能传入这四个值之一

🎯 知识检测

问题 1
response_model 的作用是什么?
只是为了生成好看的 API 文档
定义响应结构,并自动过滤不在模型中的字段
让接口运行得更快
校验请求体数据
解析:response_model 有两个核心作用:1) 告诉客户端会收到什么结构;2) 自动过滤敏感字段。校验请求体是请求模型的职责。
问题 2
以下哪个定义表示"可选的年龄字段,必须在 0-150 之间"?
age: int = Field(ge=0, le=150)
age: int | None = Field(default=None, ge=0, le=150)
age: int | None = Field(ge=0, le=150)
age: int = None
解析:可选字段需要 | None 和默认值 = None。Field(ge=0, le=150) 设置范围。选项 A 没有默认值所以是必填;选项 C 的语法在 Python 3.10+ 可以但 Field 缺少 default;选项 D 的类型和默认值不匹配。
问题 3
用户发送了错误的 JSON,FastAPI 自动返回什么状态码?
400 Bad Request
422 Unprocessable Entity
500 Internal Server Error
403 Forbidden
解析:FastAPI 使用 422 表示"语法正确的 JSON,但语义不符合模型要求"。400 通常表示 JSON 都解析不了(如语法错误)。500 是服务器内部错误。403 是权限不足。

本章小结

✓

后端不信任前端

任何从外部进来的数据都必须经过校验。这是安全的第一道防线。

✓

Pydantic 模型 = 数据的合同

定义清楚字段、类型、限制,FastAPI 自动执行校验。

✓

请求模型和响应模型要分开

防止密码等敏感字段随响应泄露。response_model 是安全网。

✓

嵌套模型处理复杂 JSON

列表、嵌套对象用 Pydantic 依然优雅,不用手写解析代码。

🛠️ 动手实验

定义完整的用户注册系统模型:UserCreate(用户名3-20字符、邮箱正则、密码8位以上)和 UserOut(不含密码)。
再定义一个 ProductCreate 模型(名称、价格大于0、库存整数、分类枚举)。尝试传入非法数据,观察 FastAPI 返回的 422 错误详情。