FastAPI实战
FastAPI 四层架构实战:课程管理模块的完整实现(models / schemas / services / api)
(FastAPI + SQLAlchemy)。
本文用一个「课程(Course)」模块,把一个接口从 HTTP 请求到数据库落库的完整链路走一遍。
其实这些后端架构 都差不多 理解起来都一样(Java,Net,) 服务MVC 一样,数据库,控制层,服务层, 好吧 在详细说下
一、为什么要分四层?
写前端的时候我们不会把「发请求、校验表单、调接口、处理数据」全塞在一个 .react 文件里,后端也一样。一个规范的 FastAPI 项目通常长这样:
backend/
└── app/
├── main.py # 应用入口,挂载路由
├── database.py # 数据库连接、Session、Base
├── common/ # 公共异常、响应包装
│ └── exceptions.py
├── models/ # ① 数据库模型(ORM,对应表结构)
│ ├── course.py
│ └── schedule.py
├── schemas/ # ② Pydantic 校验模型(对应前端 TS interface)
│ └── course.py
├── services/ # ③ 业务逻辑层(复杂校验 + 数据库操作)
│ └── course_service.py
└── api/ # ④ 路由层(接收 HTTP 请求)
└── course.py
用一个前端类比先建立直觉:
| 后端层 | 干什么 | 前端类比 |
|---|---|---|
api(路由层) |
接收 HTTP 请求,解析参数,返回响应 | React 页面组件:只负责交互和展示 |
schemas(校验层) |
用 Pydantic 定义请求/响应的数据结构和校验规则 | TypeScript 的 interface + 表单校验规则 |
services(业务层) |
处理复杂校验、组合多次数据库操作,与路由解耦 | src/api/xxx.ts + store 里的 action |
models(模型层) |
SQLAlchemy ORM 类,映射数据库表 | 类似 Prisma/TypeORM 的 Entity |
一句话总结各层的职责边界:
api:只关心 HTTP(路径、方法、参数从哪来、返回什么状态码),不写业务。schemas:只关心数据长什么样(字段、类型、必填、长度),不碰数据库。services:只关心业务规则("课程编号不能重复""课程不存在要报错"),是连接模型和路由的桥梁。models:只关心表结构(字段对应哪一列、外键指向谁),对业务一无所知。
二、准备工作:数据库连接与公共异常
2.1 app/database.py —— 数据库连接
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
# SQLite 演示用;生产环境换成 MySQL:
# SQLALCHEMY_DATABASE_URL = "mysql+pymysql://user:password@localhost:3306/dbname"
SQLALCHEMY_DATABASE_URL = "sqlite:///./campus.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}, # 仅 SQLite 需要
)
# Session 工厂:每次请求拿一个会话,用完归还
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
"""所有 ORM 模型的基类"""
pass
def get_db():
"""FastAPI 依赖注入:每个请求一个 Session,请求结束自动关闭"""
db = SessionLocal()
try:
yield db
finally:
db.close()
前端类比:
get_db就像 axios 实例——每次请求都从连接池里拿一个会话,用完(响应返回后)自动关闭,防止连接泄漏。
2.2 app/common/exceptions.py —— 统一业务异常
class BusinessException(Exception):
"""业务异常:由 services 层抛出,全局拦截后转成统一格式的响应"""
def __init__(self, message: str = "操作失败", code: int = 400):
self.message = message
self.code = code
super().__init__(message)
前端类比:这就是 axios 响应拦截器里
reject(new Error(res.message))的那颗"雷"——业务层只管抛错,统一在拦截器(全局异常处理器)里转成{ code, message }的响应格式,路由层完全不用写 try/except。
在 main.py 里注册全局处理器:
# app/main.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.common.exceptions import BusinessException
from app.api import course
from app.database import engine, Base
from app.models import course as course_model # 确保模型被加载,建表用
Base.metadata.create_all(bind=engine) # 启动时自动建表(仅开发环境)
app = FastAPI(title="课程管理")
@app.exception_handler(BusinessException)
async def business_exception_handler(request: Request, exc: BusinessException):
return JSONResponse(
status_code=200,
content={"code": exc.code, "message": exc.message, "data": None},
)
app.include_router(course.router, prefix="/api/courses", tags=["课程管理"])
三、第 1 层:models/course.py —— 数据库模型
这一层用 SQLAlchemy 定义表结构。每个类对应一张表,每个属性对应一列:
# app/models/course.py
from sqlalchemy import String, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Course(Base):
__tablename__ = "courses"
__table_args__ = {"comment": "课程信息"}
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True, comment="课程ID"
)
name: Mapped[str] = mapped_column(
String(100), comment="课程名称", nullable=False
)
code: Mapped[str] = mapped_column(
String(50), comment="课程编号", nullable=False, unique=True
)
teacher: Mapped[str] = mapped_column(
String(50), comment="授课教师", nullable=True
)
credit: Mapped[int] = mapped_column(
Integer, comment="学分", nullable=True
)
description: Mapped[str] = mapped_column(
Text, comment="课程简介", nullable=True
)
上一张截图里的 Schedule(课表)就在这一层,它通过外键关联课程:
# app/models/schedule.py
from datetime import date
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Date, ForeignKey, Integer, String
from app.database import Base
class Schedule(Base):
__tablename__ = "schedules"
__table_args__ = {"comment": "课表信息"}
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), comment="学生ID", nullable=False
)
class_date: Mapped[date] = mapped_column(Date, comment="上课日期", nullable=False)
period: Mapped[str] = mapped_column(String(50), comment="节次", nullable=False)
course_id: Mapped[int] = mapped_column(
Integer, ForeignKey("courses.id"), comment="课程ID", nullable=False
)
location: Mapped[str] = mapped_column(String(100), comment="地点", nullable=True)
要点解释:
Mapped[int]/mapped_column(...)是 SQLAlchemy 2.0 的新式写法,类型声明和列定义分开,IDE 提示非常友好。comment="课程名称"会写进建表语句,数据库里直接能看到中文注释,团队协作很舒服。unique=True给code加唯一约束,这是"课程编号不能重复"的最后一道防线(第一道在 services 层,后面讲)。ForeignKey("courses.id")就是表与表之间的关联,等价于前端联调时接口返回的course_id,靠它去另一张表"找爸爸"。
四、第 2 层:schemas/course.py —— Pydantic 数据校验模型
这一层定义的是接口的数据契约:请求体长什么样、响应体长什么样。它就是后端版的 TypeScript interface,但比 TS 强——运行时真的会校验,不合法直接返回 422。
# app/schemas/course.py
from typing import Optional
from pydantic import BaseModel, Field
class CourseCreateRequest(BaseModel):
"""创建课程的请求体"""
name: str = Field(..., min_length=1, max_length=100, description="课程名称")
code: str = Field(..., min_length=1, max_length=50, description="课程编号")
teacher: Optional[str] = Field(None, max_length=50, description="授课教师")
credit: Optional[int] = Field(None, ge=0, le=20, description="学分")
description: Optional[str] = Field(None, description="课程简介")
class CourseUpdateRequest(BaseModel):
"""更新课程的请求体:所有字段可选,传什么改什么(PATCH 语义)"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
code: Optional[str] = Field(None, min_length=1, max_length=50)
teacher: Optional[str] = Field(None, max_length=50)
credit: Optional[int] = Field(None, ge=0, le=20)
description: Optional[str] = None
class CourseResponse(BaseModel):
"""课程响应体:决定接口对外暴露哪些字段"""
id: int
name: str
code: str
teacher: Optional[str] = None
credit: Optional[int] = None
description: Optional[str] = None
model_config = {"from_attributes": True} # 允许直接从 ORM 对象转换
为什么 models 和 schemas 要分开? 这是困惑的点:
- 数据库字段 ≠ 接口字段。比如
users表有password_hash,绝不能出现在响应里;CourseResponse就是对外暴露字段的"白名单"。 - 校验规则不同。数据库管的是"能不能存"(类型、长度、唯一),Pydantic 管的是"用户传得对不对"(
min_length、ge=0这类规则在请求进门那一刻就拦截)。 - 解耦。以后表结构变了(加个字段),只要接口契约不变,前端无感知。
前端类比:把
schemas理解成接口文档的代码化——CourseCreateRequest就是 POST 请求体的 interface,CourseResponse就是响应数据的 interface,还自带async-validator的校验能力。
五、第 3 层:services/course_service.py —— 业务逻辑层(核心)
这一层是整个模块的大脑:复杂校验、数据库增删改查、组装响应,全在这里。路由层只是它的"传话筒"。
# app/services/course_service.py
from sqlalchemy.orm import Session
from app.models.course import Course
from app.schemas.course import CourseCreateRequest, CourseUpdateRequest, CourseResponse
from app.common.exceptions import BusinessException
def list_courses(db: Session, page: int = 1, size: int = 10) -> dict:
"""分页查询课程列表"""
query = db.query(Course)
total = query.count()
rows = (
query.order_by(Course.id.desc())
.offset((page - 1) * size)
.limit(size)
.all()
)
return {
"total": total,
"items": [CourseResponse.model_validate(row) for row in rows],
}
def get_course(db: Session, course_id: int) -> CourseResponse:
"""查询单个课程"""
row = db.query(Course).filter(Course.id == course_id).first()
if not row:
raise BusinessException(message="课程不存在")
return CourseResponse.model_validate(row)
def create_course(db: Session, data: CourseCreateRequest) -> CourseResponse:
"""创建课程"""
# 业务校验 1:去除首尾空格,防止 " 数学" 和 "数学" 变成两门课
name = data.name.strip()
code = data.code.strip()
# 业务校验 2:课程编号不能重复(数据库的 unique 约束是兜底,这里先友好报错)
conflict = db.query(Course).filter(Course.code == code).first()
if conflict:
raise BusinessException(message="课程编号已存在")
# 组装 ORM 对象并落库
course = Course(
name=name,
code=code,
teacher=data.teacher,
credit=data.credit,
description=data.description,
)
db.add(course)
db.commit() # 提交事务
db.refresh(course) # 回读数据库生成的 id 等字段
return CourseResponse.model_validate(course)
def update_course(
db: Session, course_id: int, data: CourseUpdateRequest
) -> CourseResponse:
"""更新课程"""
row = db.query(Course).filter(Course.id == course_id).first()
if not row:
raise BusinessException(message="课程不存在")
# exclude_none=True:只取用户真正传了的字段,没传的字段不碰
payload = data.model_dump(exclude_none=True)
if "name" in payload:
payload["name"] = payload["name"].strip()
if "code" in payload:
code = payload["code"].strip()
# 关键细节:查重时要排除自己(Course.id != course_id),
# 否则用户不改编号提交更新,会误报"编号已存在"
conflict = (
db.query(Course)
.filter(Course.code == code, Course.id != course_id)
.first()
)
if conflict:
raise BusinessException(message="课程编号已存在")
payload["code"] = code
if "description" in payload:
payload["description"] = payload["description"].strip()
# 逐个字段赋值并提交
for field, value in payload.items():
setattr(row, field, value)
db.commit()
db.refresh(row)
return CourseResponse.model_validate(row)
def delete_course(db: Session, course_id: int) -> None:
"""删除课程"""
row = db.query(Course).filter(Course.id == course_id).first()
if not row:
raise BusinessException(message="课程不存在")
# 业务保护:如果该课程已有课表安排,禁止删除(防止脏数据)
from app.models.schedule import Schedule
has_schedule = (
db.query(Schedule).filter(Schedule.course_id == course_id).first()
)
if has_schedule:
raise BusinessException(message="该课程已有课表安排,无法删除")
db.delete(row)
db.commit()
这一层最值得学的几个点:
-
exclude_none=True实现"传什么改什么"
CourseUpdateRequest里所有字段都是可选的。data.model_dump(exclude_none=True)把用户没传的字段剔掉,只保留真正要改的——这就是前端Object.assign(old, pickBy(new))的效果,避免把没填的表单字段覆盖成null。 -
查重时排除自己
Course.id != course_id这个条件新手必踩坑:不改编号直接点保存,如果不排除自己,编号会和"自己"冲突,误报"编号已存在"。 -
先应用层校验,数据库约束兜底
明明code有unique=True,为什么还要在 service 里先查一次?因为数据库报错返回的是一堆英文堆栈(IntegrityError),而BusinessException(message="课程编号已存在")能给前端一个能直接 toast 出来的友好文案。数据库约束防的是脏数据,应用层校验管的是用户体验。 -
删除前的业务保护
删课程前查一下有没有关联课表,这就是"业务逻辑"和"纯 CRUD"的区别——这种规则放在路由层会很别扭,放在 service 层天经地义。
六、第 4 层:api/course.py —— 路由层
到了这一层,你会发现代码薄得像一张纸——因为活儿都被 services 干完了:
# app/api/course.py
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.course import (
CourseCreateRequest,
CourseUpdateRequest,
CourseResponse,
)
from app.services import course_service
router = APIRouter()
@router.get("", summary="课程分页列表")
def list_courses(
page: int = Query(1, ge=1, description="页码"),
size: int = Query(10, ge=1, le=100, description="每页条数"),
db: Session = Depends(get_db),
):
return course_service.list_courses(db, page, size)
@router.get("/{course_id}", summary="课程详情",
response_model=CourseResponse)
def get_course(course_id: int, db: Session = Depends(get_db)):
return course_service.get_course(db, course_id)
@router.post("", summary="创建课程", response_model=CourseResponse)
def create_course(data: CourseCreateRequest, db: Session = Depends(get_db)):
return course_service.create_course(db, data)
@router.put("/{course_id}", summary="更新课程", response_model=CourseResponse)
def update_course(
course_id: int, data: CourseUpdateRequest, db: Session = Depends(get_db)
):
return course_service.update_course(db, course_id, data)
@router.delete("/{course_id}", summary="删除课程")
def delete_course(course_id: int, db: Session = Depends(get_db)):
course_service.delete_course(db, course_id)
return {"message": "删除成功"}
路由层只管三件事:
- HTTP 语义:
GET /api/courses列表、GET /api/courses/{id}详情、POST创建、PUT更新、DELETE删除——标准的 RESTful 风格,和前端的 axios 调用一一对应。 - 参数从哪来:路径参数(
course_id: int)、查询参数(Query(...))、请求体(data: CourseCreateRequest)。FastAPI 会自动用 Pydantic 校验,传错类型直接 422,根本进不了 service。 - 依赖注入:
db: Session = Depends(get_db)声明"我这个接口需要数据库会话",FastAPI 自动创建、自动关闭。
注意路由函数里没有一行 SQL、没有一个 try/except——这就是分层架构的意义:路由层回归"页面组件"的本分,只负责接收请求和返回响应。
七、串起来:一次请求的完整旅程
以前端发起 PUT /api/courses/1 为例,数据是这样流动的:
前端 axios.put('/api/courses/1', { code: 'MATH101' })
│
▼
① api/course.py 接收请求,Pydantic 自动校验请求体
│ (code 超 50 字符?直接 422,后面都不用走)
▼
② schemas/course.py CourseUpdateRequest 定义了数据契约
│
▼
③ services/course_service.py
├─ 课程存在吗? 不存在 → 抛 BusinessException("课程不存在")
├─ 编号和别人撞了吗? 撞了 → 抛 BusinessException("课程编号已存在")
└─ 都没问题 → 逐字段赋值 → db.commit()
│
▼
④ models/course.py ORM 对象映射到 courses 表,UPDATE 落库
│
▼
CourseResponse 过滤字段 → JSON 返回给前端
│
▼
全局异常处理器:任何 BusinessException → { code, message }(前端直接 toast)
八、常见问题 FAQ
Q1:为什么 service 函数都要手动传 db,不能像 Java Spring 那样自动注入吗?
可以,FastAPI 里也有更高级的玩法(依赖类、Annotated),但显式传参的好处是 service 层完全不依赖 Web 框架——以后写单元测试,自己 new 一个 Session 传进去就能测,不需要启动 HTTP 服务。这就是"与路由解耦"的实际收益。
Q2:校验逻辑到底放 schemas 还是 services?
记一个原则:"格式对不对"放 schemas(Pydantic),"业务允不允许"放 services。
- "学分必须是 0~20 的数字" → schemas,格式问题。
- "课程编号不能和已有的重复" → services,要查数据库才知道,是业务问题。
Q3:返回的数据为什么非要过一遍 CourseResponse.model_validate()?
两个目的:一是字段白名单,ORM 对象里将来加的敏感字段不会意外泄露;二是契约稳定,将来表结构改了,只要 Response 不变,前端就不用动。
Q4:这个分层是不是过度设计?我就两个小接口。
两个接口的时候确实像杀鸡用牛刀。但项目一旦超过 10 个接口,所有 SQL 堆在路由里就会变成灾难:改一个表结构要翻遍所有路由文件。分层是用一点点前期的啰嗦,换后期的可维护性。
九、完整目录回顾
backend/
└── app/
├── main.py # 入口:建表、注册异常处理器、挂载路由
├── database.py # 连接:engine / Session / get_db
├── common/
│ └── exceptions.py # BusinessException 统一业务异常
├── models/
│ ├── course.py # Course 表结构
│ └── schedule.py # Schedule 表结构(外键关联课程)
├── schemas/
│ └── course.py # 请求/响应契约 + 格式校验
├── services/
│ └── course_service.py # 业务大脑:校验、查重、事务、保护
└── api/
└── course.py # 路由:HTTP 语义 + 参数解析 + 依赖注入
启动方式:
pip install fastapi sqlalchemy pydantic uvicorn
uvicorn app.main:app --reload
访问 http://127.0.0.1:8000/docs 可以看到自动生成的 Swagger 文档,直接在线调通所有接口。
结语:这套 api → schemas → services → models 的分层不是 FastAPI 独有——Spring Boot 的 Controller / DTO / Service / Entity、NestJS 的 Controller / DTO / Service / Entity 都是同一个思想。学会这一套,等于学会了所有主流后端的组织方式,剩下的只是语法差异。

浙公网安备 33010602011771号