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

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

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

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

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

bash 复制代码
# 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,可以看到查询、分页、新增、修改和删除接口:

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

二、创建异步数据库引擎

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

python 复制代码
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 类操作数据表:类对应表,对象对应一行,属性对应字段。

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() 得到的字符串直接存进时间字段。

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

python 复制代码
async def create_tables():
    async with async_engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

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

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

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

python 复制代码
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)

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

python 复制代码
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 取得这个会话:

python 复制代码
from fastapi import Depends


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

一次请求的顺序是:

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

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

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

按主键查询

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

python 复制代码
book = await db.get(Book, book_id)

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

使用 select 组合条件

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

python 复制代码
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() 单个值,例如平均价格或总数量

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

python 复制代码
# 价格不低于 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(),然后只查询一次。

聚合查询

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

python 复制代码
result = await db.execute(select(func.avg(Book.price)))
average_price = result.scalar()

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

分页查询

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

python 复制代码
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 条。还可以限制页码和每页数量:

python 复制代码
from fastapi import Query


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

六、完成图书的增删改查

使用请求与响应模型

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

python 复制代码
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 只负责整理返回给客户端的时间格式,不会修改数据库字段。最终返回的时间如下:

json 复制代码
{
  "create_time": "2026-02-12 13:12:23",
  "update_time": "2026-02-12 13:12:23"
}

这些模型会自动出现在 /docsSchemas 中:

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

新增图书

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

python 复制代码
@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,找到后再赋值:

python 复制代码
@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()

删除图书

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

python 复制代码
@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(),它能完成动态筛选、排序、分组和统计。

动态组合查询条件

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

python 复制代码
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 也成为有效条件。条件添加完成后,只执行一次查询。

分组和聚合查询

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

python 复制代码
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()

python 复制代码
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 结果"这条主线理顺,再逐步补充响应模型、数据库迁移和更完善的异常处理,会比死记每个方法更有效。

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

相关推荐
智搜广告1 小时前
GEO优化公司怎么选?智搜广告从三个维度帮你判断
大数据·人工智能·python·elasticsearch·microsoft·geo
SamDeepThinking1 小时前
if嵌套最好控制在3层以内
java·后端·程序员
苏渡苇1 小时前
Spring Insight 里上报 Span 为什么用 JDK HttpClient 而不是 RestTemplate
java·后端·spring·spring cloud·springboot·性能监控
tonydf1 小时前
AI驱动历史项目安全审查
后端·ai编程
wanglei2007081 小时前
软件架构设计中,各个组件之间的通信方式有哪些?
python
会飞的拖把1 小时前
Python 面向对象编程详解:从类与对象到动态属性方法
开发语言·python
她的男孩1 小时前
同一个接口,为什么销售只看到自己的订单?我拆了 681 行数据权限拦截器的源码
java·后端·架构
微小冷1 小时前
Python CasADi初步教程
python·控制·模型预测·casadi·符号表达式·sx
元界metalite1 小时前
SpringBoot接口通用响应对象设计 - 一个 Resp 管理五种责任
后端