开飞机的舒克 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 基类 | DeclarativeBase、Mapped、mapped_column |
使用 SQLAlchemy 2.x 推荐写法定义表结构 |
| 会话工厂 | async_sessionmaker |
每个请求创建独立 AsyncSession |
| 依赖注入 | get_db() + Depends(get_db) |
把数据库会话生命周期交给 FastAPI 管理 |
| 路由拆分 | APIRouter(prefix="/db") |
让数据库相关接口形成独立模块 |
| 请求体验证 | BookCreate(BaseModel) |
在进入业务逻辑前完成字段校验 |
| 异常处理 | HTTPException |
用 HTTP 状态码表达业务错误 |
我的实战感悟:FastAPI 项目写久了你会发现,真正决定代码是否好维护的,不是某个接口写得多炫,而是"请求参数、业务逻辑、数据库会话、响应模型"这些边界是否清楚。
我们先看整体请求链路:
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 会根据函数签名、Query、Body、Pydantic 模型自动生成 OpenAPI 文档。启动项目后,我们通常可以访问:
http://127.0.0.1:8000/docshttp://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,业务函数甚至不会被执行。
依赖注入:请求级数据库会话
在实际项目里,数据库连接不应该在每个路由里手动创建。更推荐的方式是:
- 应用启动时创建
engine和sessionmaker。 - 每个请求通过依赖创建一个
AsyncSession。 - 请求成功提交,失败回滚。
- 应用关闭时释放连接池。
第三个可运行示例基于 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_size和max_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="修改时间",
)
Mapped 和 mapped_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() 的写法体现了一个很重要的思想:一个请求,一个会话,一个事务边界。
这样设计的好处是:路由函数不用反复写 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 里,current 和 size 会自动显示默认值、最小值和描述。对于前后端协作来说,这比单独维护一份接口文档省心很多。
最佳实践清单
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 能减少这种意外。
为什么我建议显式写 Query、Body
因为它们不仅影响运行时校验,也会影响自动文档。对团队项目来说,文档准确本身就是生产力。
小结
通过 db/orm.py,我们其实已经把 FastAPI 后端项目里最重要的一组能力串起来了:
- 用
APIRouter组织接口模块。 - 用
async def和异步数据库驱动支撑高并发 IO。 - 用 Pydantic 把请求体校验前置。
- 用
Depends(get_db)管理请求级数据库会话。 - 用 SQLAlchemy 2.x ORM 完成增删改查。
- 用
HTTPException表达业务错误。
如果你刚开始学习 FastAPI,我建议先把这套图书 CRUD 跑通,再逐步把配置、模型、Schema、路由拆分出去。先跑通,再变优雅,这是我自己做后端项目时一直很相信的节奏。
参考资料
- FastAPI 官方文档:fastapi.tiangolo.com/
- FastAPI Dependencies:fastapi.tiangolo.com/tutorial/de...
- FastAPI Body - Fields:fastapi.tiangolo.com/tutorial/bo...
- SQLAlchemy asyncio 文档:docs.sqlalchemy.org/en/20/orm/e...
- SQLAlchemy ORM Querying Guide:docs.sqlalchemy.org/en/20/orm/q...
- SQLAlchemy Session Basics:docs.sqlalchemy.org/en/20/orm/s...