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 要分开? 这是困惑的点:

  1. 数据库字段 ≠ 接口字段。比如 users 表有 password_hash,绝不能出现在响应里;CourseResponse 就是对外暴露字段的"白名单"。
  2. 校验规则不同。数据库管的是"能不能存"(类型、长度、唯一),Pydantic 管的是"用户传得对不对"(min_length、ge=0 这类规则在请求进门那一刻就拦截)。
  3. 解耦。以后表结构变了(加个字段),只要接口契约不变,前端无感知。

前端类比:把 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()

这一层最值得学的几个点:

  1. exclude_none=True 实现"传什么改什么"
    CourseUpdateRequest 里所有字段都是可选的。data.model_dump(exclude_none=True) 把用户没传的字段剔掉,只保留真正要改的——这就是前端 Object.assign(old, pickBy(new)) 的效果,避免把没填的表单字段覆盖成 null。

  2. 查重时排除自己
    Course.id != course_id 这个条件新手必踩坑:不改编号直接点保存,如果不排除自己,编号会和"自己"冲突,误报"编号已存在"。

  3. 先应用层校验,数据库约束兜底
    明明 code 有 unique=True,为什么还要在 service 里先查一次?因为数据库报错返回的是一堆英文堆栈(IntegrityError),而 BusinessException(message="课程编号已存在") 能给前端一个能直接 toast 出来的友好文案。数据库约束防的是脏数据,应用层校验管的是用户体验。

  4. 删除前的业务保护
    删课程前查一下有没有关联课表,这就是"业务逻辑"和"纯 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": "删除成功"}

路由层只管三件事:

  1. HTTP 语义:GET /api/courses 列表、GET /api/courses/{id} 详情、POST 创建、PUT 更新、DELETE 删除——标准的 RESTful 风格,和前端的 axios 调用一一对应。
  2. 参数从哪来:路径参数(course_id: int)、查询参数(Query(...))、请求体(data: CourseCreateRequest)。FastAPI 会自动用 Pydantic 校验,传错类型直接 422,根本进不了 service。
  3. 依赖注入: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 都是同一个思想。学会这一套,等于学会了所有主流后端的组织方式,剩下的只是语法差异。

posted @ 2026-10-07 18:43  -鹿-  阅读(1)  评论(0)    收藏  举报