FastAPI 数据校验与分层
前一阶段已经打通 Vue 到 FastAPI 的请求链路,但后端仍然直接使用 dict 接收用户数据
这种写法能演示 HTTP,却不能准确表达接口需要哪些字段,也无法自动限制用户名和昵称的长度
这一阶段也只干两件事:先用 Pydantic 建立请求与响应边界,再把单文件代码拆成 Router、Schema 和 Service
最终希望得到的不是更多目录,而是一条职责清晰的数据流:
text
HTTP JSON
→ Pydantic 请求校验
→ Router 处理 HTTP
→ Service 执行业务规则
→ Pydantic 响应处理
→ HTTP JSON
一、裸 dict 的问题
最初的创建用户接口如下:
python
@app.post("/users", status_code=status.HTTP_201_CREATED)
async def create_user(user: dict):
return {
"id": 1,
"username": user["username"],
"display_name": user["display_name"],
}
user: dict 只能说明请求体应该是一个字典,无法表达更具体的约束:
text
username 是否必填
username 是否必须是字符串
username 允许多长
display_name 能否为空
请求失败时应该返回什么
如果客户端缺少字段,代码可能在执行:
python
user["username"]
时才抛出异常
这意味着无效数据已经进入路由函数,错误也不再是清晰的字段校验结果
二、Pydantic 负责运行时数据校验
前端已经定义 TypeScript 类型:
ts
export type UserCreate = {
username: string
display_name: string
}
但 TypeScript 类型不会随着 HTTP 请求发送到服务器
网络中只有 JSON,任何客户端都可以绕过 Vue,直接向 FastAPI 发送数据
因此后端必须重新校验实际收到的内容
Pydantic 模型正是后端的运行时数据边界
text
TypeScript
→ 前端开发和编译阶段检查
Pydantic
→ 后端运行时解析和校验
三、建立 Schema 目录
后端新增:
text
app/
└─ schemas/
├─ __init__.py
└─ users.py
使用 Git Bash 创建:
bash
cd /d/code/ai-workspace-rebuild/backend # 进入后端项目根目录
mkdir -p app/schemas # 创建 schemas 目录,父目录不存在时一起创建
touch app/schemas/__init__.py # 将 schemas 明确标记为 Python 包
touch app/schemas/users.py # 创建用户 Schema 模块
schemas 只描述应用边界的数据结构,不负责数据库操作,也不实现用户名重复等业务规则
四、定义请求模型 UserCreate
app/schemas/users.py 中定义:
python
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=20)
display_name: str = Field(min_length=1, max_length=50)
字段声明可以拆成三个部分:
python
username: str = Field(min_length=3, max_length=20)
text
username
→ 字段名
: str
→ Python 类型注解,声明字段值应该是字符串
= Field(...)
→ 增加运行时校验规则
五、user: UserCreate 首先给谁使用
路由参数改为:
python
def create_user(user_data: UserCreate):
这个类型注解当然能帮助编辑器补全,但首先使用它的是 FastAPI 和 Pydantic
请求进入时会经历:
text
读取 Request Body
→ 解析 JSON
→ 根据 UserCreate 校验字段
→ 创建 UserCreate 对象
→ 校验成功后调用路由函数
路由函数收到的不再是普通字典,而是 UserCreate 实例
因此字段访问方式从:
python
user_data["username"]
改为:
python
user_data.username
六、为什么校验失败返回 422
合法请求:
json
{
"username": "alice",
"display_name": "Alice"
}
会通过 Pydantic 校验并进入路由函数
如果用户名只有两个字符:
json
{
"username": "ab",
"display_name": "Alice"
}
会违反:
python
Field(min_length=3, max_length=20)
FastAPI 自动返回:
http
HTTP/1.1 422 Unprocessable Entity
错误响应大致如下:
json
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "username"],
"msg": "String should have at least 3 characters"
}
]
}
几个关键字段:
text
loc
→ 错误位于请求体的 username 字段
msg
→ 给开发者阅读的错误说明
type
→ 程序可以识别的错误类型
最重要的边界是:422 通常发生在路由函数执行之前
text
请求 JSON
→ Pydantic 校验失败
→ FastAPI 返回 422
→ create_user() 没有执行
七、请求模型与响应模型不能混用
创建请求和创建结果方向不同:
text
UserCreate
→ Client → Server
UserResponse
→ Server → Client
响应模型定义为:
python
class UserResponse(BaseModel):
id: int
username: str
display_name: str
请求中没有 id,因为资源尚未创建
响应中带有 id,因为服务端已经生成资源标识
未来请求可能包含密码,响应模型则绝不能包含密码或密码哈希
即使两个模型目前字段相似,也应该按数据方向分开
八、response_model 约束响应方向
路由声明:
python
@router.post(
"",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
)
response_model=UserResponse 表示 FastAPI 会在返回响应前按 UserResponse 处理数据
执行顺序:
text
路由或 Service 返回 Python 对象
→ UserResponse 校验
→ 按响应模型序列化
→ 生成 JSON 响应
如果原始返回值中存在响应模型没有声明的额外字段,默认情况下不会把这些字段继续返回给客户端
这可以降低内部字段意外泄漏的风险,但不能代替正确的数据设计
不要先把密码加入返回对象,再期待 response_model 永远替业务代码兜底
九、为什么要拆分代码
功能少时,所有代码放在 main.py 仍然能运行
随着接口增加,单文件会同时承担:
- 创建 FastAPI 应用
- 配置 CORS
- 声明 URL 和 HTTP 方法
- 校验数据
- 判断业务规则
- 保存数据
- 生成响应
问题不是文件行数本身,而是不同变化原因混在一起
例如修改 CORS、修改用户名规则和更换数据库,本来是三类不同工作,不应该长期集中在同一个函数中
当前拆成:
text
Router
→ HTTP
Schema
→ 数据边界
Service
→ 业务规则
Repository 和数据库会在后续阶段加入
十、Service 负责业务规则
创建目录:
bash
cd /d/code/ai-workspace-rebuild/backend # 进入后端项目根目录
mkdir -p app/services # 创建业务层目录
touch app/services/__init__.py # 将 services 标记为 Python 包
touch app/services/users.py # 创建用户业务模块
当前 Service 使用内存列表保存用户:
python
from app.schemas.users import UserCreate, UserResponse
_users: list[UserResponse] = []
def create_user(user_data: UserCreate) -> UserResponse:
username_exists = any(
existing_user.username == user_data.username
for existing_user in _users
)
if username_exists:
raise ValueError("Username already exists")
created_user = UserResponse(
id=len(_users) + 1,
username=user_data.username,
display_name=user_data.display_name,
)
_users.append(created_user)
return created_user
为什么变量叫 _users
开头的 _ 表示模块内部状态,不希望其他模块直接访问和修改
这是 Python 命名约定,不是强制的私有权限
为什么参数叫 user_data
user_data 表示客户端传入的创建数据,不是已经持久化的用户
后面接入 ORM 后,代码中会同时出现请求模型和数据库模型,明确命名能减少混淆
any() 在表达什么
python
username_exists = any(
existing_user.username == user_data.username
for existing_user in _users
)
它逐个检查已有用户,只要找到一个相同用户名就返回 True
代码表达的不是如何循环,而是是否存在重复用户名
Service 中不应该出现什么
当前 Service 不导入:
text
FastAPI
HTTPException
HTTP 状态码
Request
Response
因为用户名不能重复是业务规则,不属于 HTTP 协议
同一个 Service 将来可能被接口、命令行任务或 Agent Tool 复用
十一、422 与 409 的区别
用户名太短时,数据格式不符合请求模型:
text
Pydantic 校验失败
→ 路由不执行
→ 返回 422
用户名长度合法但已经存在时,数据格式没有问题,只是与当前业务状态冲突:
text
Pydantic 校验通过
→ Router 调用 Service
→ Service 发现用户名重复
→ 抛出业务错误
→ Router 映射成 409
两者可以概括为:
text
422
→ 这份输入本身不符合接口结构
409
→ 输入结构合法,但与现有业务状态冲突
十二、Router 把业务错误翻译成 HTTP
Router 调用 Service:
python
try:
return create_user_service(user_data)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
Service 只表达:
text
Username already exists
Router 决定它在 HTTP 接口中应该表现为:
http
HTTP/1.1 409 Conflict
json
{
"detail": "Username already exists"
}
status 与具体状态码
下面的 status 是一个模块:
python
from fastapi import status
不能直接写:
python
HTTPException(status_code=status)
status_code 需要一个具体整数,应该使用:
python
status.HTTP_409_CONFLICT
命名常量比直接写 409 更容易看懂
from exc 的作用
python
raise HTTPException(...) from exc
它表示新的 HTTP 异常由原来的业务异常引起
日志和 traceback 因此能够保留完整的异常因果关系
十三、使用 APIRouter 拆分 HTTP 层
创建 Router 文件:
bash
cd /d/code/ai-workspace-rebuild/backend # 进入后端项目根目录
mkdir -p app/routers # 创建路由目录
touch app/routers/__init__.py # 将 routers 标记为 Python 包
touch app/routers/users.py # 创建用户路由模块
APIRouter 是一组相关路由的集合,不是第二个 FastAPI 应用
python
router = APIRouter(
prefix="/users",
tags=["users"],
)
参数含义:
text
prefix="/users"
→ 所有用户路由共享 /users 前缀
tags=["users"]
→ Swagger 中将接口归入 users 分组
创建路由使用空路径:
python
@router.post(
"",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
)
最终地址来自拼接:
text
prefix="/users"
+
path=""
=
/users
十四、main.py 只负责组装应用
拆分后,app/main.py 不再直接导入用户 Schema 和 Service
python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers.users import router as users_router
app = FastAPI(title="AI workspace")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health():
return {"status": "ok", "service": "backend"}
app.include_router(users_router)
router 在自己的模块中使用通用名称,导入主应用时改名为 users_router
python
from app.routers.users import router as users_router
这样 main.py 将来同时注册多个 Router 时仍然清楚:
python
app.include_router(users_router)
十五、当前完整用户 Router
python
from fastapi import APIRouter, HTTPException, status
from app.schemas.users import UserCreate, UserResponse
from app.services.users import create_user as create_user_service
router = APIRouter(
prefix="/users",
tags=["users"],
)
@router.post(
"",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
)
def create_user(user_data: UserCreate) -> UserResponse:
try:
return create_user_service(user_data)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
这里使用同步 def,因为当前 Service 只操作普通 Python 列表,内部没有需要 await 的异步 I/O
FastAPI 同时支持同步和异步路由
text
内部需要 await 调用异步 I/O
→ async def
同步数据库驱动、同步文件操作或普通同步流程
→ def
十六、最终项目结构
text
backend/app/
├─ main.py
├─ routers/
│ ├─ __init__.py
│ └─ users.py
├─ schemas/
│ ├─ __init__.py
│ └─ users.py
└─ services/
├─ __init__.py
└─ users.py
职责关系:
text
main.py
→ 创建并组装 FastAPI 应用
schemas/users.py
→ 定义用户请求和响应的数据边界
routers/users.py
→ 处理路径、方法、状态码和异常映射
services/users.py
→ 处理用户名重复和创建用户的业务规则
十七、最终请求链路
合法用户第一次提交:
text
POST /users
→ UserCreate 校验通过
→ Router 调用 Service
→ Service 检查用户名不重复
→ 创建 UserResponse
→ Router 返回结果
→ response_model 处理响应
→ HTTP 201
用户名长度不合法:
text
POST /users
→ UserCreate 校验失败
→ Router 不执行
→ HTTP 422
合法用户名重复提交:
text
POST /users
→ UserCreate 校验通过
→ Router 调用 Service
→ Service 发现用户名重复
→ ValueError
→ Router 转换为 HTTPException
→ HTTP 409
阶段结果
分层已经建立,但当前仍然是内存版本:
- 用户保存在模块级 Python 列表中
- FastAPI 重启后用户数据消失
- 多个进程之间不能共享这份列表
id=len(_users)+1不适合支持删除后的正式系统ValueError过于通用,后续可换成专门的业务异常- 还没有 Repository 数据访问层
- 还没有 PostgreSQL、SQLAlchemy 和数据库唯一约束
- 前端仍只显示简单错误,没有解析 422 字段详情
- 前端响应仍使用
as User,没有运行时结构校验
后续逐步完善,下一阶段先搭建 Docker Compose 与 PostgreSQL,并通过删除、重建容器验证 named volume 的持久化能力