学完路由、参数、响应、依赖注入、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 项目才真正开始像一个项目。