面向想快速上手 Python 后端的中级开发者。读完你会发现:一个"能跑、能扩、能讲明白"的后端,骨架其实就那么几块拼图。
开篇:为什么我选了 FastAPI,而不是 NestJS?
先说结论,免得你纠结:
- FastAPI:上手快、类型友好、自带 Swagger 文档,适合把 AI 能力快速包装成 Web API(这也是它在 AI 圈火爆的原因)。
- NestJS:企业级、分层严谨、依赖注入一整套,适合复杂、长期演进的业务系统。
说白了就是------简单场景用 FastAPI 快刀斩乱麻,复杂场景用 NestJS 稳扎稳打。这篇博客的场景是"把一套 CRUD 博客系统讲透",所以 FastAPI 是更合适的刀。
技术栈选型很朴素:
| 角色 | 选型 | 理由 |
|---|---|---|
| Web 服务 | FastAPI + Uvicorn | 异步、自带文档、8000 端口 |
| 数据库 | SQLite | 单 .db 文件随程序发布,不用像 MySQL/Postgres 那样独立部署 |
| ORM | SQLAlchemy 2.0 | 对象关系映射,写对象就等于写 SQL |
| 密码加密 | bcrypt | 永不存明文密码 |
一、先看目录:分层架构就是后端的心智地图
一个项目的目录结构,往往比任何注释都更能说明"作者在想什么"。这套代码的分层非常清晰:
bash
python/fastapi/
├── main.py # 单点入口 + 路由注册
├── database.py # 引擎、会话、Base、get_db(ORM 基础设施)
├── models.py # 数据表 → 对象(ORM 实体)
├── schemas.py # 入参/出参的约束(Pydantic)
├── crud.py # Service 层,所有脏活累活的归宿
└── routers/
├── __init__.py # 把一个目录变成包(关键的小文件)
├── users.py # 用户模块路由
└── posts.py # 文章模块路由
记住这条数据流,后面每一层都对得上号:
markdown
HTTP 请求 → 路由(routers) → Schema 校验 → CRUD/Service → ORM → 数据库
↑ ↓
响应模型 ←──────────────────────────────────── 数据对象
金句: 分层的本质,是把"容易变的东西"和"不容易变的东西"隔离开。路由会变、校验规则会变、数据库操作也会变,但把它们各放一层,改哪块就不用牵一发动全身。
这里有个容易被新手忽略的细节:routers/__init__.py 即使只写一行注释,也必须存在------它把一个普通文件夹升级成能 import 的 Python 包。
二、ORM 三剑客:engine / session / Base
database.py 是整个数据层的根,只干三件事:建引擎、建会话工厂、建 Base。别看代码短,全是知识点:
ini
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = "sqlite:///./blog.db" # 轻量文本关系型数据库
# echo=True 会打印出真实执行的 SQL,调试神器,上线务必关掉
engine = create_engine(DATABASE_URL, echo=True)
# 会话工厂:autoflush/autocommit 都关掉,把控制权牢牢握在自己手里
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
三个角色的分工,一句话记住:
- engine:数据库引擎(驱动 + 连接池),是"通往数据库的那口井"。
- SessionLocal:会话工厂。每次请求开一个 session,用完就关,就像每次办事拿一张临时通行证。
- Base:所有 ORM 模型的父类。继承它,一个普通类就"升格"成数据库表的映射。
autoflush=False 不是随便写的------它意味着你手动干活(commit)之前,ORM 不会偷偷帮你把中间状态刷进库里。这在"新增后想拿到自增 ID"这类场景里,既能保证数据正确,又能避免多余的 SQL,是性能和安全之间的平衡点。
踩坑预警:
create_engine在顶层sqlalchemy,而sessionmaker、DeclarativeBase在sqlalchemy.orm。别想当然一行 import 全包,我就在这儿栽过(详见后文踩坑实录)。
三、get_db:yield 是 FastAPI 依赖注入的灵魂
数据库会话怎么在请求间安全地开关?FastAPI 用依赖注入 + 生成器给出了标准答案:
csharp
def get_db():
db = SessionLocal()
try:
yield db # 把会话交给路由函数使用,函数在此暂停
finally:
db.close() # 请求结束(无论成功还是报错)都执行,释放连接
yield 之前是"准备工作",yield 之后(finally)是"善后工作"。FastAPI 会在请求进入时执行前半段,请求结束时执行后半段------哪怕中间抛了异常,会话也一定会被关闭,绝不会出现连接泄漏。
这种设计的美妙之处在于:路由函数只需要声明 db: Session = Depends(get_db),就自动拿到一个"随取随用、用完即还"的会话,完全不用操心生命周期。
好的框架不会替你写业务,但会替你处理好"开门"和"关门"这种最容易忘的事。
四、models.py:用 Mapped 把表写成类
SQLAlchemy 2.0 最大的变化,是全面走向类型注解风格。看这套用户表的写法,干净得像在读自然语言:
ini
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
email: Mapped[str] = mapped_column(String(100), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(200))
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
posts: Mapped[list["Post"]] = relationship(
back_populates="author", cascade="all, delete-orphan"
)
几个值得单独拎出来讲的点:
Mapped[类型]+mapped_column(...):左边是 Python 类型,右边是列约束,IDE 能补全、类型检查能报警,告别"字段写错运行时才炸"。index=True:给username、email建索引。这两个字段天天查、还要求唯一,索引是刚需。server_default=func.now():时间由数据库自己生成,不靠应用层传,省心又统一。relationship+back_populates:用户和文章的一对多关系,双向都能导航。cascade="all, delete-orphan":删用户时,他名下文章一并清理,不留孤儿数据。
文章表用外键把归属关系钉死:
ini
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(String(5000))
published: Mapped[bool] = mapped_column(default=False)
...
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
author: Mapped["User"] = relationship(back_populates="posts")
user_id 是那根"藤",author 是顺着藤摸到的"瓜"。ORM 帮你把 JOIN 这种有点枯燥的东西,翻译成了 post.author.username 这么直觉的一行代码。
五、schemas.py:Pydantic 是入参出参的"双保险"
这里有个新手常混淆的概念必须先讲清楚:models 是"数据库长什么样",schemas 是"接口收什么、发什么" 。两者职责完全不同。
python
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50, examples=["zhangsan"])
email: EmailStr = Field(examples=["zhang@example.com"]) # 自带邮箱格式校验
password: str = Field(min_length=8, max_length=100) # 明文密码,仅用于入参
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) # 允许从 ORM 对象读取
id: int
username: str
email: str
is_active: bool
created_at: datetime
关键点一:入参 schema 和出参 schema 要分开 。UserCreate 里有 password,UserResponse 里就绝不能有------response_model 会像一个严格的过滤器,就算你不小心把 hashed_password 塞进返回对象,也不会泄漏给前端。
关键点二:ConfigDict(from_attributes=True) 是"ORM 对象 → JSON"的翻译开关。有了它,直接 return db_user 就行,Pydantic 会自动把对象的属性读成字段。
更新场景用"全可选"的 schema,配合 exclude_unset 实现只改传了的字段(下面 CRUD 层会用到):
python
class UserUpdate(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=50)
email: EmailStr | None = None
password: str | None = Field(default=None, min_length=8, max_length=100)
is_active: bool | None = None
还有一个很实用的技巧------嵌套响应模型。文章列表要带上作者信息,不必写一个扁平的大对象,直接嵌套:
python
class PostWithAuthor(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
published: bool
created_at: datetime
author: UserResponse # 嵌套另一个 Pydantic 模型,自动展开作者字段
金句: Schema 不是束缚,它是你和前端之间的"契约"。写清楚了契约,前端不用猜、后端不怕改。
六、crud.py:把所有脏活关进"小黑屋"
路由层要保持清爽,脏活累活都该沉到 service 层。crud.py 就是这间小黑屋------所有 select、commit、refresh 都在这儿。
密码加密:永远别存明文
scss
def create_user(db: Session, user_in: UserCreate) -> User:
hashed = bcrypt.hashpw(
user_in.password.encode("utf-8"),
bcrypt.gensalt()
).decode("utf-8")
db_user = User(username=user_in.username, email=user_in.email,
hashed_password=hashed) # 注意字段叫 hashed_password
db.add(db_user) # 等价于 INSERT
db.commit()
db.refresh(db_user) # 把自增 id、数据库生成的时间回填到对象上
return db_user
db.refresh 这一步千万别省------commit 之后,数据库里自动生成的主键、created_at 这些值,需要 refresh 才能同步回你的 db_user 对象,否则返回给前端的可能是个残缺对象。
查询:SQLAlchemy 2.0 的 select 风格
sql
def get_user_by_email(db: Session, email: str) -> User | None:
stmt = select(User).where(User.email == email)
return db.scalars(stmt).first()
def get_users(db: Session, skip: int = 0, limit: int = 100) -> list[User]:
stmt = select(User).offset(skip).limit(limit) # 分页 = offset + limit
return db.scalars(stmt).all()
select(...).where(...) 不是立即执行的 SQL,而是一个 "SQL 描述对象" ,只有 db.scalars() 去取结果时才真正打库。这种"先描述、后执行"的模式,让查询的拼装和组合变得非常灵活。
局部更新:exclude_unset 是精髓
python
def update_post(db: Session, post: Post, post_in: PostUpdate) -> Post:
update_data = post_in.model_dump(exclude_unset=True) # 只拿"用户真正传了"的字段
for field, value in update_data.items():
setattr(post, field, value) # 反射式赋值
db.commit()
db.refresh(post)
return post
exclude_unset=True 过滤掉了"没传的字段",于是前端只想改标题就只发标题,其余字段原封不动。这就是 PATCH 语义的正确打开方式,比"全量覆盖"安全太多了。
一个顺手的小提醒
我在代码里发现 delete_post 被写了两遍(第 99 行和第 104 行重复定义)------虽然 Python 后者会覆盖前者、不影响运行,但这类"复制粘贴残留"是后期维护的隐形炸弹。写完记得扫一眼,让代码干干净净。
七、main.py:单点入口 + 建表 + 路由注册
入口文件短小精悍,但每一步都有讲究:
python
from database import engine, Base
from routers import users, posts
import models # 必须 import!否则模型没被注册
Base.metadata.create_all(bind=engine) # 根据所有模型类自动建表
app = FastAPI(title="Blog API", description="FastAPI + SQLAlchemy 博客系统", version="1.0.0")
@app.get("/")
def root():
return {"message": "Blog API", "docs": "/docs"}
app.include_router(users.router) # 用户模块
app.include_router(posts.router)
两个隐藏彩蛋:
import models不能删。create_all只认识"已经被导入、已经注册到 Base 元数据里"的模型类。少了这行,表会"凭空消失"------一个非常隐蔽的坑。include_router让模块化落地。每个业务模块自带prefix和tags,main.py只负责"组装",真正做到"高内聚、低耦合"。
八、路由层:RESTful + 响应模型 + 状态码
以用户模块为例,一套标准的 CRUD RESTful 接口,每个细节都值得学:
python
router = APIRouter(prefix="/users", tags=["用户管理"])
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(user_in: UserCreate, db: Session = Depends(get_db)):
existing = crud.get_user_by_email(db, user_in.email)
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该邮箱已经注册")
return crud.create_user(db, user_in)
@router.get("/", response_model=list[UserResponse])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
return crud.get_users(db, skip=skip, limit=limit)
@router.get("/{user_id}", response_model=UserResponse)
def read_user(user_id: int, db: Session = Depends(get_db)):
user = crud.get_user(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, db: Session = Depends(get_db)):
user = crud.get_user(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
crud.delete_user(db, user)
几个必须掌握的约定:
response_model是出参的"海关":字段按它裁剪、类型按它校验。status_code精准表达语义。状态码用对了,前端少写一半 if:
| 状态码 | 场景 |
|---|---|
| 200 OK | 查询成功 |
| 201 Created | 新建成功(POST) |
| 204 No Content | 更新/删除成功,无返回体 |
| 400 Bad Request | 参数/业务校验失败(如邮箱已注册) |
| 404 Not Found | 资源不存在 |
{user_id}路径参数会被自动解析成int(因为类型注解写了int),传个abc进来 FastAPI 直接给你 422,连手动校验都省了。
文章模块(posts.py)还演示了一个漂亮的"归属校验"模式------创建文章前必须确认用户存在:
less
@router.post("/", response_model=PostResponse, status_code=status.HTTP_201_CREATED)
def create_post(user_id: int, post_in: PostCreate, db: Session = Depends(get_db)):
user = crud.get_user(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return crud.create_post(db, post_in, user_id=user_id)
以及一个很优雅的路由设计:GET /posts/user/{user_id} 拿某个用户的全部文章,路径语义一目了然。
金句: RESTful 的精髓不是"路径好看",而是"让 URL 自己会说话"。看到
/posts/user/3,不用查文档就知道它在干嘛。
九、踩坑实录:我在这套系统上栽过的两个坑
理论讲完,来点血泪。这两个坑,希望能帮你少熬两个夜。
坑一:NameError: name 'sessionmaker' is not defined
一开始 database.py 只写了:
python
from sqlalchemy import create_engine # 只有这一行
结果一启动就炸:sessionmaker 找不到。原因很单纯------create_engine 在顶层 sqlalchemy,而 sessionmaker 和 DeclarativeBase 在 sqlalchemy.orm:
python
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase # 补上这一行
教训:SQLAlchemy 2.0 的导入路径不要凭记忆写 ,两个命名空间容易混。报错 NameError: name 'xxx' is not defined,十有八九就是"以为导入了其实没导入"。
坑二:满心期待 GET,却等来 405 Method Not Allowed
这个坑更阴间。代码里明明写了 @router.get("/"),可一请求就是:
bash
GET /users/ HTTP/1.1" 405 Method Not Allowed
排查半天才发现真相:编辑器缓冲区里有 GET 路由,但磁盘上的文件还停在只有 POST 的旧版本 。Uvicorn 从磁盘加载代码,它跑的根本不是你"心里想的那版"。按一下 Ctrl+S 保存,--reload 一刷新,GET 立刻恢复正常。
教训一句话点破:
金句: 你调试的永远是磁盘上的代码,而不是你脑子里以为的那一版。改完不保存,一切白搭。
这两个坑叠加起来,其实指向同一件事------报错信息是朋友,不是敌人 。NameError 告诉你缺导入,405 告诉你方法没注册,顺着线索走,答案都在里面。
十、跑起来:三条命令,一个文档页
css
# 装依赖
pip install fastapi uvicorn sqlalchemy pydantic[email] bcrypt
# 启动(--reload 改代码自动重载,开发期必备)
uvicorn main:app --reload
启动后浏览器打开 http://127.0.0.1:8000/docs,你会得到一份自动生成、可直接点着测的 Swagger 文档 ------这正是 FastAPI 最圈粉的地方:代码即文档,改完即生效 。所有 schema 里的 examples、min_length、EmailStr 校验,全都直观展示在这儿。
验证一遍完整链路:
POST /users/建个用户 → 返回 201,密码已被 bcrypt 加密存储;GET /users/拿到列表 → 里面只有公开字段,没有hashed_password;POST /posts/?user_id=1给他发篇文章;GET /posts/user/1拿回他名下所有文章。
一条从"用户"到"文章"、带外键归属和嵌套作者信息的完整业务链就通了。
结语:后端骨架的本质
回过头看,这套系统没有一行炫技代码,但每一层都各就各位:
- database 管连接,models 管表,schemas 管契约;
- crud 管逻辑,routers 管接口,main 管组装。
你要的"能跑、能扩、能讲明白",就藏在这六个文件的边界里。
最后送你一句: 框架负责规范和兜底,你负责把业务讲清楚。当你能把这套骨架完整复述给别人听,你就已经不只是"会用 FastAPI",而是"懂后端"了。