用 FastAPI + SQLAlchemy 从零手搓一个博客系统:一套能落地、能讲清楚的 RESTful 后端

面向想快速上手 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)

两个隐藏彩蛋:

  1. import models 不能删。create_all 只认识"已经被导入、已经注册到 Base 元数据里"的模型类。少了这行,表会"凭空消失"------一个非常隐蔽的坑。
  2. 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 校验,全都直观展示在这儿。

验证一遍完整链路:

  1. POST /users/ 建个用户 → 返回 201,密码已被 bcrypt 加密存储;
  2. GET /users/ 拿到列表 → 里面只有公开字段,没有 hashed_password;
  3. POST /posts/?user_id=1 给他发篇文章;
  4. GET /posts/user/1 拿回他名下所有文章。

一条从"用户"到"文章"、带外键归属和嵌套作者信息的完整业务链就通了。

结语:后端骨架的本质

回过头看,这套系统没有一行炫技代码,但每一层都各就各位:

  • database 管连接,models 管表,schemas 管契约;
  • crud 管逻辑,routers 管接口,main 管组装。

你要的"能跑、能扩、能讲明白",就藏在这六个文件的边界里。

最后送你一句: 框架负责规范和兜底,你负责把业务讲清楚。当你能把这套骨架完整复述给别人听,你就已经不只是"会用 FastAPI",而是"懂后端"了。

相关推荐
JustNow_Man1 小时前
【Harness】本地文本向量化搜索解决方案--qmd
git·python·算法
小年糕是糕手1 小时前
【AI】用 Python 做一个 AI 截图检索助手:把收藏夹里的截图找回来
linux·人工智能·python·chatgpt·agent·codex·deepseek
不识君1 小时前
esl-client 2.0.0 发布:Java 项目接入 FreeSWITCH,从引入到使用
spring boot·后端·开源
2601_967980111 小时前
wxPython 入门教程.pdf
python·wxpython·gui·教程·移植性
牧瀬クリスだ1 小时前
Redis五大数据类型全解析:从入门到实战
java·数据库·redis·后端·缓存
计算机毕业编程指导师1 小时前
2027计算机毕设选题指南:网站AI化、大数据预测+算法(XGBoost/LightGBM)、深度学习三方向57题+选题方法论
大数据·python·计算机·毕业设计·课程设计·毕设选题·1024程序员节
李航19831 小时前
DeepSeek AI建模(二)让AI建第一个模型
人工智能·python·计算机视觉·ai·ai编程
Rain的Java大神之路2 小时前
北漂 13 年,我把 Java 家底钉成了这张知识图谱(全站免费)
java·后端·程序员
Amos_Web2 小时前
通过 Rspack 源码学 Rust(一):`mod`、`pub` 与 `use crate`/`use super`
后端