学习 FastAPI 的 Day 2:用异步 ORM 完成增删改查

96 阅读12分钟

🚀 FastAPI 学习传送门

Day 1:看懂接口与请求流程
Day 2:用异步 ORM 完成图书增删改查(当前文章)
Day 3:企业级目录与数据库迁移
Day 4:完成用户系统与接口联调

📦 GitHub 源码: dragonxjy/FastAPI

昨天的接口已经能够接收参数、返回数据,但这些数据还没有真正“住”进数据库。刷新页面后内容依然存在,新增、修改和删除也能落到数据表中,才算迈进了真实后端开发的大门。

今天讲述一下如何让 FastAPI 连接 MySQL,并用图书接口练习查询、分页、新增、修改和删除。代码虽然比第一天多,但只要记住一条主线:拿到数据库会话,执行语句,处理结果。 📚

一、启动项目并查看接口文档

项目使用 FastAPI、SQLAlchemy、aiomysql 和 Uvicorn。启动前先运行 MySQL,并创建连接地址中的数据库。

# uv:当前项目推荐
uv sync
uv run uvicorn ORM:app --reload

# pip
pip install fastapi uvicorn "sqlalchemy[asyncio]" aiomysql
uvicorn ORM:app --reload

打开 http://127.0.0.1:8000/docs,可以看到查询、分页、新增、修改和删除接口:

fastapi-orm-docs.png

📌 如果提示“无法连接 MySQL”,先检查数据库服务、端口和账号密码,再排查接口代码。

PixPin_2026-09-05_14-35-03.png

二、创建异步数据库引擎

数据库引擎负责管理应用与数据库的连接:

from sqlalchemy.ext.asyncio import create_async_engine


DATABASE_URL = "mysql+aiomysql://用户名:密码@localhost:3306/fast_api?charset=utf8"


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

连接地址中,mysql+aiomysql 表示使用异步 MySQL 驱动,localhost:3306 是数据库地址和端口,fast_api 是数据库名。

三个常见配置如下:

参数通俗解释
echo=True在控制台打印 SQL,学习和排错很方便,生产环境通常关闭
pool_size=10连接池平时最多保留 10 个连接
max_overflow=20连接不够时,最多再临时创建 20 个连接

💡 实际项目不要把密码提交到代码仓库,应放到环境变量或配置文件中。

三、用 ORM 模型描述数据表

ORM 让我们用 Python 类操作数据表:类对应表,对象对应一行,属性对应字段。

from datetime import datetime

from sqlalchemy import DateTime, Float, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    create_time: Mapped[datetime] = mapped_column(
        DateTime,
        # 使用数据库当前时间作为新记录的创建时间
        default=func.now(),
    )
    update_time: Mapped[datetime] = mapped_column(
        DateTime,
        default=func.now(),
        # 数据发生更新时,由数据库自动刷新修改时间
        onupdate=func.now(),
    )


class Book(Base):
    __tablename__ = "book"

    # MySQL 会为整数主键自动生成递增的 id
    id: Mapped[int] = mapped_column(primary_key=True)
    bookname: Mapped[str] = mapped_column(String(50), nullable=False)
    author: Mapped[str] = mapped_column(String(50), nullable=False)
    price: Mapped[float] = mapped_column(Float, nullable=False)
    publisher: Mapped[str] = mapped_column(String(50), nullable=False)

Mapped[int]Mapped[str] 告诉 Python 字段是什么类型,mapped_column() 则告诉数据库这个字段应该怎样保存。

写法含义
primary_key=True把整数字段设为主键;在当前 MySQL 表中,id 会自动递增
String(50)字符串最多保存 50 个字符
nullable=False字段不能为空
default=func.now()新增数据时写入当前时间
onupdate=func.now()更新数据时刷新修改时间

🔎 func.now() 后面的括号不能漏。func.now 只是拿到函数,func.now() 才会真正使用数据库的当前时间。漏写括号可能导致更新时间失败。

数据库用 DateTime 保存时间,接口显示格式交给响应模型。不要把 strftime() 得到的字符串直接存进时间字段。

项目在应用启动时创建尚不存在的数据表:

async def create_tables():
    async with async_engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

create_all() 只会创建还不存在的表,不会自动修改旧表。以后增加或删除字段时,需要使用数据库迁移工具更新表结构。

四、管理应用生命周期与数据库会话

生命周期函数负责启动时建表、关闭时释放连接:

from contextlib import asynccontextmanager

from fastapi import FastAPI


@asynccontextmanager
async def lifespan(_app: FastAPI):
    # yield 之前:FastAPI 启动时执行,创建尚不存在的数据表
    await create_tables()

    # yield 期间:应用正常运行并接收请求
    yield

    # yield 之后:FastAPI 关闭时执行,释放数据库连接池
    await async_engine.dispose()


app = FastAPI(lifespan=lifespan)

每次请求数据库都需要一个独立会话,项目把它封装成了依赖:

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker


# 需求:查询功能的接口,查询图书→依赖注入:创建依赖项获取数据库会话+Depends 注入路由处理函数
AsyncSessionLocal = async_sessionmaker(
    bind=async_engine,  # 绑定数据库引擎
    class_=AsyncSession,  # 指定会话类
    expire_on_commit=False  # 提交后会话不过期,不会重新查询数据库
)


# 依赖项
async def get_database():
    async with AsyncSessionLocal() as session:
        try:
            yield session  # 返回数据库会话给路由处理函数
            await session.commit()  # 路由成功结束后统一提交事务
        except Exception:
            await session.rollback()  # 有异常,回滚
            raise
        finally:
            await session.close()  # 关闭会话

路由通过 Depends 取得这个会话:

from fastapi import Depends


async def get_books(
    # 先执行 get_database,再把 yield 返回的数据库会话注入 db 参数
    # scope="function" 表示响应发送前执行 yield 后面的提交或回滚
    db: AsyncSession = Depends(get_database, scope="function"),
):
    ...

一次请求的顺序是:

请求到达
  ↓
创建 AsyncSession
  ↓
yield 把会话交给路由
  ↓
路由执行 SQL
  ↓
成功则 commit,异常则 rollback
  ↓
关闭会话
  ↓
返回响应

get_database() 统一管理事务:接口用 flush() 执行 SQL,成功后 commit(),出错时 rollback()scope="function" 保证提交或回滚发生在响应返回前。

五、掌握查询、筛选与分页

按主键查询

已知主键时,get() 最直接:

book = await db.get(Book, book_id)

找不到数据时会得到 None,因此更新和删除前都要先判断。

使用 select 组合条件

条件查询分三步:创建语句、执行语句、取出结果。

from sqlalchemy import func, select


statement = select(Book).where(Book.id == book_id)
result = await db.execute(statement)
book = result.scalar_one_or_none()
取值方法适合什么结果
scalar_one_or_none()最多一条数据,没有数据时返回 None
scalars().all()多条 ORM 对象,最终得到列表
scalar()单个值,例如平均价格或总数量

项目中还出现了几种常见筛选条件:

# 价格不低于 200
select(Book).where(Book.price >= 200)

# 书名同时包含“百”和“Py”
select(Book).where(
    (Book.bookname.like("%百%")) &
    (Book.bookname.like("%Py%"))
)

# 主键为 1 或 3
select(Book).where(Book.id.in_([1, 3]))

% 代表任意数量的字符,因此 "%Py%" 表示书名中只要包含 Py 即可。& 表示条件同时成立,| 表示满足其中一个条件。

⚠️ 当前书名搜索接口连续查询了三次,每次都把新结果放进 result,所以前两次结果会被覆盖,最后只能拿到第三次结果。多次赋值不会自动合并查询结果。需要组合条件时,应先写好一个完整的 where(),然后只查询一次。

聚合查询

平均价格查询返回一个数字:

result = await db.execute(select(func.avg(Book.price)))
average_price = result.scalar()

avg 换成 countmaxminsum,就可以完成计数、最大值、最小值和求和。

分页查询

分页的核心是 offset() 跳过多少条,limit() 最多取多少条:

skip = (page - 1) * page_size

statement = (
    select(Book)
    .offset(skip)
    .limit(page_size)
)
result = await db.execute(statement)
return result.scalars().all()

例如 page=2page_size=10,会跳过前 10 条,再读取 10 条。还可以限制页码和每页数量:

from fastapi import Query


page: int = Query(default=1, ge=1)
page_size: int = Query(default=10, ge=1, le=100)

六、完成图书的增删改查

使用请求与响应模型

请求模型检查客户端提交的数据,响应模型整理接口返回的数据:

from datetime import datetime

from fastapi import Depends, HTTPException
from pydantic import BaseModel, ConfigDict, field_serializer
from sqlalchemy.ext.asyncio import AsyncSession


class BookBase(BaseModel):
    bookname: str
    author: str
    price: float
    publisher: str


class BookUpdate(BaseModel):
    id: int
    bookname: str
    author: str
    price: float
    publisher: str


# 接口响应模型:控制返回字段,并统一日期时间的显示格式
class BookResponse(BaseModel):
    id: int
    bookname: str
    author: str
    price: float
    publisher: str
    create_time: datetime
    update_time: datetime

    # 允许 Pydantic 直接读取 SQLAlchemy ORM 对象的属性
    model_config = ConfigDict(from_attributes=True)

    # field_serializer 用于控制字段返回给客户端时的格式
    # 这里让 create_time 和 update_time 共用同一个序列化方法
    @field_serializer("create_time", "update_time")
    def serialize_datetime(self, value: datetime) -> str:
        # 将 datetime 转为 2026-02-12 13:12:23 格式的字符串
        # 只改变接口输出,不改变数据库中的 DateTime 类型
        return value.strftime("%Y-%m-%d %H:%M:%S")

ConfigDict(from_attributes=True) 让响应模型可以直接读取 ORM 对象。field_serializer 只负责整理返回给客户端的时间格式,不会修改数据库字段。最终返回的时间如下:

{
  "create_time": "2026-02-12 13:12:23",
  "update_time": "2026-02-12 13:12:23"
}

book-response-schema.png 这些模型会自动出现在 /docsSchemas 中:

BookBase 不包含 id。新增图书时,客户端只需提交书名、作者、价格和出版社,数据库会自动生成递增的主键。BookUpdate 中的 id 用来查找要修改的图书,BookResponse 中的 id 用来把主键返回给客户端。

新增图书

新增时把请求模型转成 ORM 对象,再加入会话:

@app.post("/book/add_book", response_model=BookResponse)
async def add_book(
    book: BookBase,
    db: AsyncSession = Depends(get_database, scope="function"),
):
    # 将 Pydantic 请求模型转成字典,再创建 ORM 对象
    book_db = Book(**book.model_dump())
    db.add(book_db)
    # 执行 INSERT 但不提交,最终由 get_database 统一提交事务
    await db.flush()
    # 重新读取数据库生成的主键和时间字段
    await db.refresh(book_db)
    return book_db

model_dump() 取出请求数据,flush() 执行 SQL,最后由依赖提交事务。

📌 为什么还要 refresh() idcreate_timeupdate_time 是数据库生成的。flush() 执行 SQL 后,这些值不一定已经回到 ORM 对象中;refresh() 会按主键重新查询一次,把最新值读回来。否则 BookResponse 读取必填的时间字段时,可能出现 ResponseValidationErrorMissingGreenlet,接口最终返回 500。

新增和修改需要返回最新的主键或时间,所以同时使用 flush()refresh();删除接口只返回成功消息,因此执行 flush() 即可。

修改图书

修改时先查询,查不到就返回 404,找到后再赋值:

@app.put("/book/update_book", response_model=BookResponse)
async def update_book(
    data: BookUpdate,
    db: AsyncSession = Depends(get_database, scope="function"),
):
    db_book = await db.get(Book, data.id)

    if db_book is None:
        raise HTTPException(status_code=404, detail="查无此书")

    db_book.bookname = data.bookname
    db_book.author = data.author
    db_book.price = data.price
    db_book.publisher = data.publisher

    # 执行 UPDATE 但不提交,最终由 get_database 统一提交事务
    await db.flush()
    # 重新读取数据库自动更新的 update_time
    await db.refresh(db_book)
    return db_book

查询结果已经由会话管理,修改属性后直接 flush() 即可,不需要再次 add()

删除图书

删除同样要先确认数据存在:

@app.delete("/book/delete_book/{book_id}")
async def delete_book(
    book_id: int,
    db: AsyncSession = Depends(get_database, scope="function"),
):
    db_book = await db.get(Book, book_id)

    if db_book is None:
        raise HTTPException(status_code=404, detail="查无此书")

    await db.delete(db_book)
    # 执行 DELETE 但不提交,最终由 get_database 统一提交事务
    await db.flush()
    return {"message": "删除成功"}

🧩 新增使用 add(),查询使用 select()get(),修改直接赋值,删除使用 delete()。它们都通过同一个异步会话操作数据库。

补充:复杂查询应该怎么写

条件变多时也应先用 select(),它能完成动态筛选、排序、分组和统计。

动态组合查询条件

下面的接口会根据客户端实际传入的书名和价格添加条件:

from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession


# 可以通过 /book/advanced_search?keyword=Python&min_price=50 访问
@app.get("/book/advanced_search", response_model=list[BookResponse])
async def search_books(
    keyword: str | None = None,
    min_price: float | None = None,
    max_price: float | None = None,
    db: AsyncSession = Depends(get_database, scope="function"),
):
    # 先创建一个没有筛选条件的查询
    statement = select(Book)

    if keyword:
        # contains 表示书名中包含关键字
        statement = statement.where(Book.bookname.contains(keyword))

    if min_price is not None:
        statement = statement.where(Book.price >= min_price)

    if max_price is not None:
        statement = statement.where(Book.price <= max_price)

    # 按价格从高到低排列
    statement = statement.order_by(Book.price.desc())

    result = await db.execute(statement)
    return result.scalars().all()

使用 is not None 判断价格,可以让 0 也成为有效条件。条件添加完成后,只执行一次查询。

分组和聚合查询

先看一个简单需求:统计每家出版社分别有多少本图书。

from fastapi import Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession


@app.get("/book/publisher_count")
async def get_publisher_count(
    db: AsyncSession = Depends(get_database, scope="function"),
):
    statement = (
        select(
            Book.publisher,
            func.count(Book.id).label("book_count"),
        )
        # 把相同出版社的图书放到一组
        .group_by(Book.publisher)
    )

    result = await db.execute(statement)
    rows = result.mappings().all()
    return [dict(row) for row in rows]

group_by() 按出版社分组,count() 统计每组数量。统计结果不是完整图书对象,因此用 mappings().all() 读取。

了解即可:什么时候使用 text 和原生 SQL

SQLAlchemy 难以表达需求,或必须使用 MySQL 特有语法时,再考虑 text()

📖 新手了解用途和安全写法即可,日常查询优先练习 select()

from sqlalchemy import text


# 查询目的:统计达到最低价格要求的图书
# 先按出版社分组,再计算每组的图书数量和平均价格
# 只保留图书数量达到要求的出版社,最后按平均价格降序排列
# :min_price 和 :min_count 是参数占位符,实际值在执行 SQL 时传入
statement = text("""
    SELECT
        publisher,
        COUNT(id) AS book_count,
        AVG(price) AS average_price
    FROM book
    WHERE price >= :min_price
    GROUP BY publisher
    HAVING COUNT(id) >= :min_count
    ORDER BY average_price DESC
""")

# 参数单独传入,不要使用 f-string 拼接到 SQL 中
result = await db.execute(
    statement,
    {"min_price": 100, "min_count": 2},
)
rows = result.mappings().all()
return [dict(row) for row in rows]

:min_price:min_count 是参数占位符。SQL 与参数分开传递,可以降低 SQL 注入风险。

📌 普通查询优先使用 SQLAlchemy;特殊语法再用 text(),并始终绑定参数。

七、结语

第二天的重点,是理解 FastAPI 如何借助 SQLAlchemy 异步会话完成数据库操作。先把“引擎、模型、会话、SQL 结果”这条主线理顺,再逐步补充响应模型、数据库迁移和更完善的异常处理,会比死记每个方法更有效。

文章会随着项目继续完善,也欢迎讨论代码中的问题和更合适的写法。💬