用 FastAPI 搭一个新闻项目,router / crud / model / schema 应该怎么分层

学完路由、参数、响应、依赖注入、ORM 之后,很多人会遇到第二个问题。

单个知识点都会了,但一到项目里,就不知道文件该怎么放。

新闻接口写哪里?

SQL 查询写哪里?

Pydantic 模型写哪里?

数据库表结构写哪里?

统一响应、异常处理、认证工具又写哪里?

如果所有代码都塞进 main.py,项目很快就会变成一坨。

这篇我们用课程里的 AI 掘金头条项目,把 FastAPI 项目分层讲清楚。

核心思路很简单:

main.py 只做装配,router 管 HTTP,crud 管数据库操作,model 管表结构,schema 管输入输出形状,utils 放横切工具。

本篇准备

这一篇不引入新的依赖,沿用前面已经准备好的 FastAPI + SQLAlchemy 环境:

bash 复制代码
pip install fastapi uvicorn sqlalchemy aiomysql

本篇重点不是多装一个包,而是看清楚文件边界:入口、路由、数据访问、ORM 模型、请求响应模型分别应该放在哪里。

1. 为什么不能把所有代码都写进 main.py

刚开始学 FastAPI,这样写没问题:

python 复制代码
from fastapi import FastAPI

app = FastAPI()

@app.get("/news/list")
async def get_news_list():
    return []

但真实项目里,一个新闻系统至少有这些功能:

  • 新闻分类
  • 新闻列表
  • 新闻详情
  • 用户注册
  • 用户登录
  • 获取用户信息
  • 收藏新闻
  • 浏览历史
  • Redis 缓存
  • 异常处理
  • 跨域配置

如果全部堆在 main.py,你会得到一个几千行的入口文件。

这类代码最可怕的地方不是长,而是边界消失。

HTTP 参数、数据库查询、业务规则、响应格式、异常处理全混在一起,后面想改一个接口都不知道会影响哪里。

所以项目必须分层。

2. AI 掘金头条的后端结构

课程最终项目的后端结构大概是这样:

text 复制代码
toutiao_backend/
├── main.py
├── config/
│   ├── db_conf.py
│   └── cache_conf.py
├── models/
│   ├── users.py
│   ├── news.py
│   ├── favorite.py
│   └── history.py
├── schemas/
│   ├── base.py
│   ├── users.py
│   ├── news.py
│   ├── favorite.py
│   └── history.py
├── crud/
│   ├── news.py
│   ├── news_cache.py
│   ├── users.py
│   ├── favorite.py
│   └── history.py
├── routers/
│   ├── news.py
│   ├── users.py
│   ├── favorite.py
│   └── history.py
├── cache/
│   └── news_cache.py
└── utils/
    ├── auth.py
    ├── security.py
    ├── response.py
    ├── exception.py
    └── exception_handlers.py

看起来文件不少,但逻辑很清楚。
#mermaid-svg-rcaRWzrd7HGqv2qv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rcaRWzrd7HGqv2qv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rcaRWzrd7HGqv2qv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rcaRWzrd7HGqv2qv .error-icon{fill:#552222;}#mermaid-svg-rcaRWzrd7HGqv2qv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rcaRWzrd7HGqv2qv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rcaRWzrd7HGqv2qv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rcaRWzrd7HGqv2qv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rcaRWzrd7HGqv2qv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rcaRWzrd7HGqv2qv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rcaRWzrd7HGqv2qv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rcaRWzrd7HGqv2qv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rcaRWzrd7HGqv2qv .marker.cross{stroke:#333333;}#mermaid-svg-rcaRWzrd7HGqv2qv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rcaRWzrd7HGqv2qv p{margin:0;}#mermaid-svg-rcaRWzrd7HGqv2qv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rcaRWzrd7HGqv2qv .cluster-label text{fill:#333;}#mermaid-svg-rcaRWzrd7HGqv2qv .cluster-label span{color:#333;}#mermaid-svg-rcaRWzrd7HGqv2qv .cluster-label span p{background-color:transparent;}#mermaid-svg-rcaRWzrd7HGqv2qv .label text,#mermaid-svg-rcaRWzrd7HGqv2qv span{fill:#333;color:#333;}#mermaid-svg-rcaRWzrd7HGqv2qv .node rect,#mermaid-svg-rcaRWzrd7HGqv2qv .node circle,#mermaid-svg-rcaRWzrd7HGqv2qv .node ellipse,#mermaid-svg-rcaRWzrd7HGqv2qv .node polygon,#mermaid-svg-rcaRWzrd7HGqv2qv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rcaRWzrd7HGqv2qv .rough-node .label text,#mermaid-svg-rcaRWzrd7HGqv2qv .node .label text,#mermaid-svg-rcaRWzrd7HGqv2qv .image-shape .label,#mermaid-svg-rcaRWzrd7HGqv2qv .icon-shape .label{text-anchor:middle;}#mermaid-svg-rcaRWzrd7HGqv2qv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rcaRWzrd7HGqv2qv .rough-node .label,#mermaid-svg-rcaRWzrd7HGqv2qv .node .label,#mermaid-svg-rcaRWzrd7HGqv2qv .image-shape .label,#mermaid-svg-rcaRWzrd7HGqv2qv .icon-shape .label{text-align:center;}#mermaid-svg-rcaRWzrd7HGqv2qv .node.clickable{cursor:pointer;}#mermaid-svg-rcaRWzrd7HGqv2qv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rcaRWzrd7HGqv2qv .arrowheadPath{fill:#333333;}#mermaid-svg-rcaRWzrd7HGqv2qv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rcaRWzrd7HGqv2qv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rcaRWzrd7HGqv2qv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rcaRWzrd7HGqv2qv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rcaRWzrd7HGqv2qv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rcaRWzrd7HGqv2qv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rcaRWzrd7HGqv2qv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rcaRWzrd7HGqv2qv .cluster text{fill:#333;}#mermaid-svg-rcaRWzrd7HGqv2qv .cluster span{color:#333;}#mermaid-svg-rcaRWzrd7HGqv2qv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rcaRWzrd7HGqv2qv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rcaRWzrd7HGqv2qv rect.text{fill:none;stroke-width:0;}#mermaid-svg-rcaRWzrd7HGqv2qv .icon-shape,#mermaid-svg-rcaRWzrd7HGqv2qv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rcaRWzrd7HGqv2qv .icon-shape p,#mermaid-svg-rcaRWzrd7HGqv2qv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rcaRWzrd7HGqv2qv .icon-shape .label rect,#mermaid-svg-rcaRWzrd7HGqv2qv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rcaRWzrd7HGqv2qv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rcaRWzrd7HGqv2qv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rcaRWzrd7HGqv2qv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} main.py 应用入口
routers 路由层
crud 数据访问层
models ORM 模型
MySQL
schemas 请求/响应模型
utils 横切工具
cache 缓存封装
Redis

这张图比目录本身更重要。

读一个 FastAPI 项目时,不要先问有多少文件。

先问这些文件各自负责什么。

3. main.py,只做应用级装配

最终项目里的 main.py 做的事情很少。

大概是:

python 复制代码
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import news, users, favorite, history
from utils.exception_handlers import register_exception_handlers

app = FastAPI()

register_exception_handlers(app)

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",
        "http://127.0.0.1:5173",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(news.router)
app.include_router(users.router)
app.include_router(favorite.router)
app.include_router(history.router)

你会发现,main.py 基本不写业务逻辑。

它只负责:

  • 创建 FastAPI 应用
  • 注册异常处理器
  • 注册 CORS 中间件
  • 挂载业务路由

这就是一个干净入口文件应该有的样子。

入口文件越干净,项目越容易维护。

4. routers,负责 HTTP 接口层

routers/news.py 这类文件负责定义接口。

它关心的是:

  • URL 是什么
  • HTTP 方法是什么
  • 参数从哪里来
  • 需要哪些依赖
  • 调用哪个 CRUD 函数
  • 返回什么响应

简化后的新闻列表接口可能长这样:

python 复制代码
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from config.db_conf import get_db
from crud import news

router = APIRouter(prefix="/api/news", tags=["新闻"])

@router.get("/list")
async def get_news_list(
    category_id: int | None = Query(None, alias="categoryId"),
    page: int = Query(1, ge=1),
    page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
    db: AsyncSession = Depends(get_db)
):
    return await news.get_news_list(
        db=db,
        category_id=category_id,
        page=page,
        page_size=page_size
    )

这里有个边界很重要。

router 可以处理 HTTP 参数,但不应该堆复杂 SQL。

如果 router 里全是 select(...)join(...)where(...),说明数据访问逻辑已经漏到 HTTP 层了。

5. crud,负责数据库操作

crud/news.py 才是写数据库查询的地方。

示例:

python 复制代码
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from models.news import News

async def get_news_list(
    db: AsyncSession,
    category_id: int | None,
    page: int,
    page_size: int
):
    stmt = select(News)

    if category_id:
        stmt = stmt.where(News.category_id == category_id)

    total_result = await db.execute(
        select(func.count()).select_from(stmt.subquery())
    )
    total = total_result.scalar()

    offset = (page - 1) * page_size
    result = await db.execute(
        stmt.offset(offset).limit(page_size)
    )

    return {
        "list": result.scalars().all(),
        "total": total,
        "hasMore": total > page * page_size
    }

crud 层关心的是数据库。

它不应该关心:

  • 请求头是什么
  • HTTP 状态码怎么返回
  • 前端路由叫什么

这能让数据访问逻辑更容易复用。

比如同一个 get_news_list,未来可能被接口调用,也可能被后台任务调用。

6. models,负责数据库表结构

models/news.py 表达的是表结构。

简化版:

python 复制代码
from sqlalchemy import String, Text, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column

class News(Base):
    __tablename__ = "news"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    title: Mapped[str] = mapped_column(String(255))
    description: Mapped[str | None] = mapped_column(String(500))
    content: Mapped[str] = mapped_column(Text)
    category_id: Mapped[int] = mapped_column(ForeignKey("news_category.id"))
    views: Mapped[int] = mapped_column(Integer, default=0)

model 层不写接口逻辑。

它只回答一个问题:

数据库里这张表长什么样。

7. schemas,负责请求和响应形状

数据库字段不等于 API 字段。

这句话非常重要。

数据库里可能叫 publish_time,前端可能希望拿到 publishTime

数据库里可能有 password,接口绝不能返回。

这时就需要 schema。

python 复制代码
from datetime import datetime
from pydantic import BaseModel, ConfigDict

class NewsItemResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    title: str
    description: str | None
    image: str | None
    author: str | None
    views: int
    publish_time: datetime

Pydantic v2 里,ConfigDict(from_attributes=True) 可以让模型从 ORM 对象属性里读取数据。

这样你就能把 SQLAlchemy 对象转成响应模型。

schema 层的价值是定义 API 契约。

只要契约稳定,数据库内部怎么调整,就不会轻易影响前端。

8. utils,放横切工具

项目里的 utils 包含:

文件 作用
auth.py 获取当前用户,Token 认证依赖
security.py 密码哈希和校验
response.py 统一成功响应
exception.py 异常处理函数
exception_handlers.py 注册异常处理器

这些逻辑不属于某一个具体业务模块。

它们横跨多个模块,所以放在 utils 比较合适。

但也要注意,utils 很容易变成杂物间。

判断一个函数该不该进 utils,可以问一句:

它是不是多个模块都需要,而且不依赖某个具体业务?

如果只是新闻模块内部用,就别放 utils,放 crud/news.py 或新闻相关模块里更清楚。

9. 一次新闻列表请求的完整链路

现在把这些层串起来。

用户访问:

http 复制代码
GET /api/news/list?categoryId=1&page=1&pageSize=10

项目内部大概这样跑:
MySQL models/news.py crud/news.py routers/news.py main.py Vue 前端 MySQL models/news.py crud/news.py routers/news.py main.py Vue 前端 #mermaid-svg-CsHH1Jh4ntgq4Xe3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .error-icon{fill:#552222;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .marker.cross{stroke:#333333;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 p{margin:0;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .sequenceNumber{fill:white;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 #sequencenumber{fill:#333;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .messageText{fill:#333;stroke:none;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .labelText,#mermaid-svg-CsHH1Jh4ntgq4Xe3 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .loopText,#mermaid-svg-CsHH1Jh4ntgq4Xe3 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .noteText,#mermaid-svg-CsHH1Jh4ntgq4Xe3 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .actorPopupMenu{position:absolute;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 .actor-man circle,#mermaid-svg-CsHH1Jh4ntgq4Xe3 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-CsHH1Jh4ntgq4Xe3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /api/news/list 路由匹配 解析 Query 参数 调用 get_news_list 使用 News 模型构造查询 执行 SQL 返回数据 返回列表和 total JSON 响应

这条链路看懂了,项目结构就不再是死记硬背。

10. CORS 为什么放在 main.py

前端 Vite 默认可能跑在:

text 复制代码
http://localhost:5173

后端 FastAPI 跑在:

text 复制代码
http://127.0.0.1:8000

协议、域名、端口只要有一个不同,就是不同源。

浏览器会触发 CORS 限制。

FastAPI 通过 CORSMiddleware 解决:

python 复制代码
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",
        "http://127.0.0.1:5173",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

本地开发时,把 Vite 前端常用的两个地址写进去就够了。

正式上线时不要这么放。

应该明确指定前端域名,例如:

python 复制代码
allow_origins=[
    "https://your-frontend-domain.com"
]

这是安全边界,不是格式问题。

尤其要注意,如果 allow_credentials=True,就不要再用 allow_origins=["*"]。浏览器携带 Cookie、认证头这类凭证时,跨域规则必须更明确。

11. 分层不是为了好看,是为了控制变化

项目分层的核心不是"看起来专业"。

而是控制变化范围。

变化 应该主要影响哪里
URL 改了 router
数据库查询改了 crud
表字段改了 model 和迁移脚本
API 返回字段改了 schema
Token 认证规则改了 utils/auth
响应格式改了 utils/response

这就是分层的意义。

当变化来了,你知道去哪改,也知道不该碰哪里。

12. 小结

FastAPI 项目分层可以用这句话记:

text 复制代码
main.py 装配应用
router 接 HTTP
crud 查数据库
model 映射表
schema 定契约
utils 放通用工具

AI 掘金头条项目从 day03 开始做新闻模块,day04 加用户模块,day05 加收藏和历史,day06 加 Redis 缓存。

功能越来越多,但只要分层边界清楚,项目不会失控。

下一篇我们继续看用户模块。

注册、登录、Token、密码哈希、统一响应、全局异常处理,这些东西加上之后,一个 FastAPI 项目才真正开始像一个项目。

参考资料