FastAPI 异步 ORM 实战:从核心特性到图书 CRUD 最佳实践

开飞机的舒克 halo.gaodaimou.cn

FastAPI 异步 ORM 实战:从核心特性到图书 CRUD 最佳实践

引言

FastAPI 这几年很受 Python 后端开发者欢迎,不只是因为它"快",更因为它把现代 Web API 开发里几个痛点处理得很漂亮:

  • 异步支持 :天然支持 async def,适合数据库、缓存、HTTP 调用这类 IO 密集型服务。
  • 自动文档生成:基于类型标注和 Pydantic 模型生成 OpenAPI 文档,接口可读、可调试。
  • 依赖注入 :用 Depends 管理数据库会话、鉴权、分页参数等公共逻辑。
  • 类型友好:参数校验、编辑器提示、文档展示基本都围绕类型声明展开。

我们可以看到一个典型学习型示例:它把 FastAPI 路由、SQLAlchemy 2.x 异步 ORM、MySQL 连接池、事务提交与回滚都放在了一起。对于刚开始做后端项目的同学来说,这正好是一条很实用的主线。

项目代码在做什么

db/orm.py 的核心结构可以概括为下面这张表:

模块能力 代码元素 为什么重要
异步数据库连接 create_async_engine() 不阻塞事件循环,提升高并发 IO 场景吞吐
ORM 基类 DeclarativeBaseMappedmapped_column 使用 SQLAlchemy 2.x 推荐写法定义表结构
会话工厂 async_sessionmaker 每个请求创建独立 AsyncSession
依赖注入 get_db() + Depends(get_db) 把数据库会话生命周期交给 FastAPI 管理
路由拆分 APIRouter(prefix="/db") 让数据库相关接口形成独立模块
请求体验证 BookCreate(BaseModel) 在进入业务逻辑前完成字段校验
异常处理 HTTPException 用 HTTP 状态码表达业务错误

我的实战感悟:FastAPI 项目写久了你会发现,真正决定代码是否好维护的,不是某个接口写得多炫,而是"请求参数、业务逻辑、数据库会话、响应模型"这些边界是否清楚。

我们先看整体请求链路:

flowchart LR A['客户端请求 /db/book/add'] --> B['FastAPI 路由匹配'] B --> C['Pydantic 校验 BookCreate'] C --> D['Depends创建 AsyncSession'] D --> E['执行业务逻辑和 SQLAlchemy ORM'] E --> F['是否异常'] F -->|'否'| G['commit 提交事务'] F -->|'是'| H['rollback 回滚事务'] G --> I['返回 JSON 响应'] H --> J['返回错误响应']

FastAPI 核心特性

1. 异步支持:为什么用 async def

在传统同步 Web 框架里,一个请求访问数据库时,线程会等待数据库返回结果。FastAPI 基于 ASGI,可以用 async def 把等待 IO 的时间让出来,让事件循环继续处理其他请求。

这并不意味着单条 SQL 会变快。它真正提升的是:

  • 大量并发请求下的连接利用率。
  • 等待数据库、Redis、第三方 API 时的整体吞吐。
  • Web 服务在 IO 密集型场景下的响应能力。

这些接口都是异步的:

python 复制代码
@router.get("/book/detail")
async def read_book_detail(...):
    ...

只要内部调用的是异步数据库驱动,例如 mysql+aiomysql,就可以通过 await db.execute(...) 非阻塞地等待结果。

2. 自动文档生成:类型标注就是接口说明

FastAPI 会根据函数签名、QueryBody、Pydantic 模型自动生成 OpenAPI 文档。启动项目后,我们通常可以访问:

  • http://127.0.0.1:8000/docs
  • http://127.0.0.1:8000/redoc

比如下面这个参数:

arduino 复制代码
book_id: Annotated[int, Query(description="图书 ID")]

FastAPI 会知道它是一个查询参数,类型是整数,并把描述展示在接口文档里。

3. 依赖注入:把公共逻辑抽出来

数据库会话、当前登录用户、分页参数、权限校验,都可以用 Depends 注入。它的好处是:

  • 路由函数只关注业务逻辑。
  • 公共资源可以统一创建和释放。
  • 测试时更容易替换依赖。

get_db() 就是一个很典型的依赖:

csharp 复制代码
async def get_db():
    async with async_session() as session:
        try:
            # 使用yield返回会话对象,等待调用方使用。
            # 使用return返回会话对象,会立即关闭会话对象(需要每一次在路由函数中commit、rollback、close ,而且容易忘)
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

这个依赖在请求正常结束后提交事务,在出现异常时回滚事务。路由函数只需要这样使用:

python 复制代码
async def read_book_count(db: AsyncSession = Depends(get_db)):
    ...

请求体验证:用 Pydantic 把错误挡在入口

定义了 BookCreate

ini 复制代码
class BookCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=10, description="书名")
    price: float = Field(..., description="价格")
    author: str = Field(..., description="作者")
    description: str = Field(..., description="描述")

这段代码的价值不是"少写几个 if",而是把接口契约明确写出来:调用方必须传什么字段、字段类型是什么、长度限制是什么。

第二个可运行示例:

python 复制代码
# validation_demo.py
from typing import Annotated

from fastapi import Body, FastAPI
from pydantic import BaseModel, Field

app = FastAPI(title="Book Validation Demo")


class BookCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=10, description="书名")
    price: float = Field(..., gt=0, description="价格必须大于 0")
    author: str = Field(..., min_length=1, description="作者")
    description: str = Field(default="", max_length=255, description="描述")


@app.post("/books")
async def create_book(
    book: Annotated[BookCreate, Body(description="图书信息")],
) -> dict[str, object]:
    # 请求体已经被 FastAPI + Pydantic 校验过
    return {"message": "created", "book": book.model_dump()}

运行:

lua 复制代码
uvicorn validation_demo:app --reload

请求示例:

json 复制代码
{
  "name": "Python 入门",
  "price": 59.9,
  "author": "Alice",
  "description": "适合初学者"
}

如果 price-1,FastAPI 会直接返回 422 Unprocessable Entity,业务函数甚至不会被执行。

依赖注入:请求级数据库会话

在实际项目里,数据库连接不应该在每个路由里手动创建。更推荐的方式是:

  1. 应用启动时创建 enginesessionmaker
  2. 每个请求通过依赖创建一个 AsyncSession
  3. 请求成功提交,失败回滚。
  4. 应用关闭时释放连接池。

第三个可运行示例基于 SQLite,方便你本地直接体验异步依赖注入,不需要先安装 MySQL。

python 复制代码
# dependency_demo.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated

from fastapi import Depends, FastAPI
from sqlalchemy import Integer, String, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

engine = create_async_engine("sqlite+aiosqlite:///./demo.db", echo=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)


class Base(DeclarativeBase):
    pass


class Book(Base):
    __tablename__ = "books"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)


async def get_session() -> AsyncIterator[AsyncSession]:
    async with SessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    # 启动时建表,关闭时释放连接池
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield
    await engine.dispose()


app = FastAPI(title="Dependency Injection Demo", lifespan=lifespan)


@app.post("/books")
async def add_book(
    name: str,
    session: Annotated[AsyncSession, Depends(get_session)],
) -> dict[str, object]:
    book = Book(name=name)
    session.add(book)
    await session.flush()
    return {"id": book.id, "name": book.name}


@app.get("/books")
async def list_books(
    session: Annotated[AsyncSession, Depends(get_session)],
) -> list[dict[str, object]]:
    result = await session.execute(select(Book).order_by(Book.id.desc()))
    books = result.scalars().all()
    return [{"id": book.id, "name": book.name} for book in books]

需要依赖:

arduino 复制代码
pip install fastapi "uvicorn[standard]" "sqlalchemy[asyncio]" aiosqlite

运行:

lua 复制代码
uvicorn dependency_demo:app --reload

异步 ORM 如何工作

1. 创建异步 Engine

项目里使用的是:

ini 复制代码
ASYNC_DATABASE_URL = (
    "mysql+aiomysql://root:******@localhost:3306/fastapi_test?charset=utf8"
)

async_engine = create_async_engine(
    ASYNC_DATABASE_URL,
    echo=True,
    pool_size=10,
    max_overflow=20,
)

这里有三个关键点:

  • mysql+aiomysql 表示使用 MySQL 的异步驱动。
  • //后跟账号密码还有地址已经数据库名称(注意数据库在连接的时候需要提前创建)
  • echo=True 会打印 SQL,适合开发调试,不建议生产长期打开。
  • pool_sizemax_overflow 控制连接池容量,不能盲目调大。

最佳实践:数据库账号、密码、主机、库名建议放到环境变量或 .env 中,不要硬编码在源码里。尤其是教学代码进入真实项目时,这一步要尽早做。

2. 定义 ORM 模型

Book 继承了一个包含时间字段的基类:

ini 复制代码
class DATA_TIME(DeclarativeBase):
    create_time: Mapped[datetime] = mapped_column(
        DateTime,
        insert_default=func.now(),
        default=func.now(),
        comment="创建时间",
    )
    update_time: Mapped[datetime] = mapped_column(
        DateTime,
        insert_default=func.now(),
        default=func.now(),
        onupdate=func.now(),
        comment="修改时间",
    )

Mappedmapped_column 是 SQLAlchemy 2.x 推荐的 ORM 声明方式。它比老式写法更贴近 Python 类型系统,编辑器提示也更舒服。

图书表可以理解为:

ini 复制代码
class Book(DATA_TIME):
    __tablename__ = "book"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(255), unique=True)
    author: Mapped[str] = mapped_column(String(255))
    price: Mapped[float] = mapped_column(Float)
    description: Mapped[str] = mapped_column(String(255))

这里 name 设置了 unique=True,意味着数据库层面不允许书名重复。代码中 add_book() 也提前查了一次重名,用来给用户返回更友好的错误。

3. 创建表结构

csharp 复制代码
async def create_db_and_tables():
    async with async_engine.begin() as conn:
        await conn.run_sync(DATA_TIME.metadata.create_all)

run_sync() 是异步 SQLAlchemy 里常见的桥接方法:有些元数据操作本质上还是同步 API,需要通过异步连接安全地运行。

生产项目里,我更建议用 Alembic 管理表结构迁移。metadata.create_all() 很适合学习和原型开发,但不适合长期维护复杂数据库结构。

4. 管理事务边界

get_db() 的写法体现了一个很重要的思想:一个请求,一个会话,一个事务边界

sequenceDiagram participant Client as Client participant API as FastAPI Endpoint participant Dep as get_db() participant DB as MySQL Client->>API: HTTP Request API->>Dep: Depends(get_db) Dep->>DB: Open AsyncSession API->>DB: execute / add / delete alt success Dep->>DB: commit() else exception Dep->>DB: rollback() end DB-->>Client: JSON Response

这样设计的好处是:路由函数不用反复写 commit() / rollback(),并且异常场景不会把半完成的数据留在数据库里。

CRUD 实战:图书接口怎么写

查询单条:get()execute() 的区别

db.get(Book, 1) 适合按主键查询:

csharp 复制代码
book = await db.get(Book, 1)

如果你要按条件查询,就使用 select()

ini 复制代码
res = await db.execute(select(Book).where(Book.id == book_id))
book = res.scalar_one_or_none()

两者的使用场景如下:

方法 适合场景 返回特点
db.get(Model, pk) 按主键查一条 直接返回 ORM 对象或 None
db.execute(select(...)) 条件查询、统计、分页 返回 Result,通常继续 .scalars()

条件查询:组合表达式要注意括号

项目里有类似这样的条件:

erlang 复制代码
select(Book).where(
    Book.name.like(f"%{name}%") & (Book.price > 100)
    | (Book.author == "张三") & ~(Book.description == "无")
)

为了可读性,我建议写成:

erlang 复制代码
select(Book).where(
    (
        Book.name.like(f"%{name}%")
        & (Book.price > 100)
    )
    | (
        (Book.author == "张三")
        & ~(Book.description == "无")
    )
)

&|~ 分别表示 SQL 条件里的 AND、OR、NOT。因为 Python 运算符优先级容易让人看错,复杂条件最好主动加括号。

分页查询:先满足可用,再考虑性能

项目中分页接口使用 offset + limit

scss 复制代码
res = await db.execute(
    select(Book)
    .offset((current - 1) * size)
    .limit(size)
)
return res.scalars().all()

这是最容易理解的分页方式,适合数据量不大的后台列表。等数据量上来后,再考虑基于游标的分页,例如 id < last_id

新增图书:为什么要 flush()refresh()

scss 复制代码
db_book = Book(**book.model_dump())
db.add(db_book)
await db.flush()
await db.refresh(db_book)
return db_book

这里的重点是:

  • add():把对象加入 Session。
  • flush():把 SQL 发到数据库,但还没有最终提交事务。
  • refresh():从数据库重新加载对象,拿到自增 ID、默认时间等字段。
  • commit():在 get_db() 依赖中统一执行。

也就是说,flush() 不是提交,它只是把当前变更同步到数据库事务里。

更新和删除:先查存在性

更新:

ini 复制代码
db_book = await db.get(Book, book_id)
if db_book is None:
    raise HTTPException(status_code=404, detail="图书不存在")

db_book.name = book.name
db_book.price = book.price
db_book.author = book.author
db_book.description = book.description
await db.flush()
await db.refresh(db_book)
return db_book

删除:

csharp 复制代码
db_book = await db.get(Book, book_id)
if db_book is None:
    raise HTTPException(status_code=404, detail="图书不存在")

await db.delete(db_book)
await db.flush()
return {"msg": "删除成功"}

我的习惯是:更新、删除都先查对象是否存在。这样接口返回会更清楚,也方便后续加权限判断、审计日志等业务逻辑。

自动文档:让接口天然可调试

FastAPI 的自动文档来自三个地方:

来源 示例 文档效果
路由装饰器 @router.post("/book/add") 生成接口路径和 HTTP 方法
参数声明 Query(ge=1, description="当前页码") 生成参数约束和说明
Pydantic 模型 BookCreate 生成请求体 Schema

比如分页接口:

python 复制代码
@router.get("/book/page")
async def read_book_page(
    current: int = Query(1, ge=1, description="当前页码"),
    size: int = Query(10, ge=1, description="每页数量"),
    db: AsyncSession = Depends(get_db),
):
    ...

/docs 里,currentsize 会自动显示默认值、最小值和描述。对于前后端协作来说,这比单独维护一份接口文档省心很多。

最佳实践清单

1. 路由层不要变成"大杂烩"

当前 db/orm.py 为了学习方便,把模型、数据库连接、依赖、路由都放在了一个文件里。真实项目建议拆分:

bash 复制代码
app/
  db/
    engine.py        # engine 和 sessionmaker
    models.py        # ORM 模型
    dependencies.py  # get_db 依赖
  schemas/
    book.py          # Pydantic 请求/响应模型
  routers/
    book.py          # API 路由

拆分不是为了"显得高级",而是为了让每个文件职责更单一。等项目变大时,你会感谢自己早一点做了这件事。

2. 不要在源码里硬编码数据库密码

推荐使用环境变量:

ini 复制代码
import os

DATABASE_URL = os.environ["DATABASE_URL"]

启动时传入:

ini 复制代码
DATABASE_URL="mysql+aiomysql://user:password@127.0.0.1:3306/app?charset=utf8mb4" uvicorn main:app --reload

另外,MySQL 字符集建议优先使用 utf8mb4,比 utf8 更完整,能正确支持 emoji 和更多 Unicode 字符。

3. 给接口加 response_model

直接返回 ORM 对象在学习阶段很方便,但真实项目里我更推荐加响应模型:

python 复制代码
from pydantic import BaseModel


class BookRead(BaseModel):
    id: int
    name: str
    price: float
    author: str
    description: str


@router.get("/book/detail", response_model=BookRead | None)
async def read_book_detail(...):
    ...

这样可以避免把不该暴露的字段返回给客户端,也能让 OpenAPI 文档更准确。

4. 生产环境使用 Alembic 管理迁移

create_all() 适合入门学习,但生产环境需要可追踪、可回滚、可审核的迁移脚本。推荐引入 Alembic:

bash 复制代码
alembic init migrations
alembic revision --autogenerate -m "create book table"
alembic upgrade head

5. 事务要短,外部 IO 不要塞进事务

下面这种模式要尽量避免:

rust 复制代码
开启事务 -> 查数据库 -> 调第三方接口 -> 写数据库 -> 提交事务

第三方接口慢的时候,数据库连接和锁都会被长期占用。更好的做法是:事务里只做必要的数据库操作,外部 IO 放到事务外,复杂场景再考虑消息队列或 outbox pattern。

常见问题

flush()commit() 有什么区别

flush() 是把变更发送到数据库事务里,常用于拿自增 ID 或触发约束检查;commit() 是真正提交事务。一个请求里可以多次 flush(),但通常只在事务边界提交一次。

为什么 expire_on_commit=False

异步 ORM 下,提交后如果对象属性过期,再访问属性可能触发隐式 IO。隐式 IO 在异步环境里容易引发 MissingGreenlet 一类问题。设置 expire_on_commit=False 能减少这种意外。

为什么我建议显式写 QueryBody

因为它们不仅影响运行时校验,也会影响自动文档。对团队项目来说,文档准确本身就是生产力。

小结

通过 db/orm.py,我们其实已经把 FastAPI 后端项目里最重要的一组能力串起来了:

  • APIRouter 组织接口模块。
  • async def 和异步数据库驱动支撑高并发 IO。
  • 用 Pydantic 把请求体校验前置。
  • Depends(get_db) 管理请求级数据库会话。
  • 用 SQLAlchemy 2.x ORM 完成增删改查。
  • HTTPException 表达业务错误。

如果你刚开始学习 FastAPI,我建议先把这套图书 CRUD 跑通,再逐步把配置、模型、Schema、路由拆分出去。先跑通,再变优雅,这是我自己做后端项目时一直很相信的节奏。

参考资料

相关推荐
码栈研说1 小时前
Go 语言大白话入门 7 - 函数:把重复代码收起来
后端·程序员
程序员天天困1 小时前
后端视角看 EventBus:发布订阅总线的原理、场景与用法
java·后端
ServBay2 小时前
WordPress 核心漏洞 wp2shell,波及超 5 亿网站
后端·php
分布式存储与RustFS2 小时前
RustFS Beta.10 性能解读:PUT 全面反超 MinIO,Rust 重写对象存储成了?
开发语言·后端·rust
Reart3 小时前
Leetcode 309.买卖股票的最佳时期含冷冻期(你也想成为股票高手吗o〃ω〃o,718)
后端
Reart3 小时前
Leetcode 188.买卖股票的最佳时机4(718)
后端·算法
Reart3 小时前
Leetcode 123.买卖股票的最佳时期3(内有随心谈,718)
后端·算法
只与明月听3 小时前
LangChain 学习-掌握LangChain Core API
前端·人工智能·后端
无相求码4 小时前
数组越界为什么有时候不崩溃?VS2013 下栈上变量的幽灵布局解密
c语言·后端