前端手摸手跑路之 AI 应用开发(五)

FastAPI 接入 PostgreSQL:从数据库连接到用户持久化

上一篇用 Docker Compose 启动了 PostgreSQL,也验证了删除并重建容器后,命名卷中的数据仍然存在

但应用里的用户还保存在 Python 列表中,数据库启动并不等于业务已经持久化。这一篇把创建用户和查询用户列表接到 PostgreSQL,完整走一遍连接、建模、迁移和数据访问

一、SQLAlchemy 和 psycopg 分别做什么

SQLAlchemy 提供 SQL 表达式、连接池、ORM 和事务管理;psycopg 是 PostgreSQL 驱动,负责实际通信

在 Git Bash 中安装:

bash 复制代码
cd /d/code/ai-workspace-rebuild/backend  # cd 切换到后端项目目录
uv add sqlalchemy 'psycopg[binary]'  # add 添加依赖,并同步项目配置、锁文件和虚拟环境

[binary] 是 psycopg 的可选依赖组,会安装预编译实现和所需客户端库。单引号避免 Bash 把方括号解释为文件名匹配模式

最初只安装了 psycopg,Windows 环境导入驱动时出现:

text 复制代码
ImportError: no pq wrapper available
libpq library not found

错误发生在导入驱动阶段,还没有连接数据库。补装 psycopg[binary] 后解决

二、建立数据库模块

创建文件:

bash 复制代码
touch app/database.py  # touch 创建空文件,文件已存在时不会清空内容

先写入连接配置:

python 复制代码
import os

from sqlalchemy import create_engine

DATABASE_URL = os.getenv(
    "DATABASE_URL",
    "postgresql+psycopg://ai_workspace:ai_workspace_dev@localhost:5432/ai_workspace",
)

engine = create_engine(DATABASE_URL)

连接地址的格式是:

text 复制代码
数据库类型+驱动://用户名:密码@主机:端口/数据库名

FastAPI 当前在宿主机运行,因此使用 localhost:5432,对应 Compose 暴露到宿主机的端口

os.getenv("DATABASE_URL", 默认值) 优先读取进程环境变量,不存在时使用第二个参数。它本身不会读取 .env 文件

这里保留本地数据库的默认地址,部署时应从外部传入真实配置,不把生产密码写进源码

create_engine() 创建数据库操作入口和连接池管理者,通常不会立即建立数据库网络连接。执行 engine.connect() 时才取得连接

三、用 SELECT 1 验证连接

在后端目录执行:

bash 复制代码
# python -c 执行后面的代码;单引号内可以直接换行输入
uv run python -c '
from sqlalchemy import text
from app.database import engine

with engine.connect() as connection:
    print(connection.execute(text("SELECT 1")).scalar_one())
'

输出 1,说明 Python 已通过驱动连接 PostgreSQL,并成功执行了一次查询

SELECT 1 是让数据库返回常量 1,不读取业务表,也不是查询 ID 为 1 的记录,因此适合检查基础连接

把查询拆开看:

python 复制代码
with engine.connect() as connection:
    statement = text("SELECT 1")
    query_result = connection.execute(statement)
    result = query_result.scalar_one()
  • with ... as connection:获取连接并命名,离开代码块时自动清理,通常将连接归还连接池
  • text():把 SQL 字符串包装成 SQLAlchemy 可执行的对象
  • execute():执行 SQL,返回结果对象
  • scalar_one():要求结果恰好一行,取这一行的第一列;没有行或超过一行都会报错

scalar_one() 不要求查询只有一列,它只取第一列。with engine.connect() 也不会自动提交写入事务

四、添加数据库健康检查

app/main.py 补充导入和路由:

python 复制代码
from sqlalchemy import text

from app.database import engine


@app.get("/health/database")
def database_health():
    with engine.connect() as connection:
        statement = text("SELECT 1")
        query_result = connection.execute(statement)
        result = query_result.scalar_one()

    return {"status": "ok", "database": result}

访问 http://127.0.0.1:8000/health/database,正常响应为:

json 复制代码
{"status": "ok", "database": 1}

这里使用同步 def,与当前同步数据库调用对应。这个接口检查连接与查询,不证明用户表已经存在

五、用 ORM 描述用户表

ORM 是 Object-Relational Mapping,即对象关系映射。Python 类对应数据库表,对象对应一行数据,属性对应列

先在 app/database.py 增加公共基类:

python 复制代码
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass

pass 是占位语句,这个类直接使用继承来的能力。后续模型都继承 Base,表结构统一登记到 Base.metadata

创建模型模块:

bash 复制代码
mkdir -p app/models  # mkdir 创建目录,-p 补齐父目录且允许目录已存在
touch app/models/__init__.py app/models/users.py  # 创建包标记和用户模型模块

app/models/users.py

python 复制代码
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column

from app.database import Base


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(String(20), unique=True)
    display_name: Mapped[str] = mapped_column(String(50))

__tablename__ 指定表名。Mapped[str] 声明 ORM 属性的 Python 类型,mapped_column() 配置数据库列

String(20) 限制字符串列长度,unique=True 建立唯一约束,primary_key=True 指定主键。当前字段类型没有包含 None,因此会推导为不允许空值

前面的 UserCreateUserResponse 描述接口输入、输出;User 描述存储结构,三者各自承担不同边界

只定义模型不会自动创建表,本项目通过 Alembic 执行建表

六、初始化 Alembic

bash 复制代码
uv add alembic  # 安装迁移工具
uv run alembic init alembic  # init 初始化迁移环境,最后的 alembic 是生成目录名

主要生成:

路径 职责
alembic.ini 配置入口
alembic/env.py 获取连接、加载目标表结构、运行迁移
alembic/versions/ 保存每个迁移版本

alembic/env.py 顶部增加:

python 复制代码
from app.database import DATABASE_URL, Base
from app.models.users import User  # noqa: F401

config = context.config 后增加:

python 复制代码
config.set_main_option("sqlalchemy.url", DATABASE_URL)

target_metadata = None 替换为:

python 复制代码
target_metadata = Base.metadata

导入 User 会执行类定义,把 users 表登记到 metadata 中。虽然没有直接引用变量 User,这条导入仍然必要,# noqa: F401 用来保留这条有副作用的导入

七、生成迁移后,先检查再执行

bash 复制代码
uv run alembic revision --autogenerate -m "create users table"  # 比较数据库与模型,生成迁移草稿

revision 创建版本,--autogenerate 自动比较结构,-m 指定版本说明。生成草稿会读取数据库结构,但不会执行草稿里的建表操作

这次自动生成除了创建 users,还包含:

python 复制代码
op.drop_table("persistence_test")

原因是上一篇创建的实验表存在于数据库中,却没有出现在 ORM metadata 中,Alembic 将它识别为待删除的表

因此,从 upgrade() 删除这条删表操作,同时从 downgrade() 删除对应的 persistence_test 建表操作,保留实验表。修正后的迁移只负责:

python 复制代码
def upgrade() -> None:
    op.create_table(
        "users",
        sa.Column("id", sa.Integer(), nullable=False),
        sa.Column("username", sa.String(length=20), nullable=False),
        sa.Column("display_name", sa.String(length=50), nullable=False),
        sa.PrimaryKeyConstraint("id"),
        sa.UniqueConstraint("username"),
    )


def downgrade() -> None:
    op.drop_table("users")

这是生成文件中的两个函数,保留文件原有导入和版本标识。upgrade() 应用变更,downgrade() 描述反向变更;删除表再重建并不能恢复原来的数据

检查后执行:

bash 复制代码
uv run alembic upgrade head  # upgrade 执行待应用的迁移,head 指迁移链最新版本
uv run alembic current  # current 查看数据库记录的当前迁移版本

本次版本为 58ee956adde2,重新生成时版本号会不同。执行完成后,数据库中的 alembic_version 表记录已应用的版本

以后生成迁移时,实验表仍可能再次被识别为待删除对象,需要继续检查草稿;本次手动修改不会永久改变自动比较规则

八、Session 与 yield

Engine 管理连接配置和连接池,Session 管理一次数据库工作中的 ORM 对象、查询和事务

此时 app/database.py 的完整内容是:

python 复制代码
import os
from collections.abc import Iterator

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session


class Base(DeclarativeBase):
    pass


DATABASE_URL = os.getenv(
    "DATABASE_URL",
    "postgresql+psycopg://ai_workspace:ai_workspace_dev@localhost:5432/ai_workspace",
)

engine = create_engine(DATABASE_URL)


def get_session() -> Iterator[Session]:
    with Session(engine) as session:
        yield session

yield 产出 Session 并暂停函数,让调用方在 with 仍打开时使用它。FastAPI 在依赖清理阶段恢复函数,退出 with,关闭 Session 并释放资源

若改成 return session,函数会立即退出 with,提前触发清理,因此不能承担同样的生命周期管理

Iterator[Session] 描述生成器产出的值类型。接口参数得到的是产出的 Session,而不是生成器本身

关闭 Session 不等于提交,get_session() 只负责生命周期

九、Repository 封装数据库访问

创建仓储模块:

bash 复制代码
mkdir -p app/repositories  # 创建仓储目录
touch app/repositories/__init__.py app/repositories/users.py  # 创建包标记和仓储模块

app/repositories/users.py

python 复制代码
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models.users import User
from app.schemas.users import UserCreate


class UserRepository:
    def __init__(self, session: Session):
        self._session = session

    def exists_by_username(self, username: str) -> bool:
        statement = select(User.id).where(User.username == username)
        return self._session.scalar(statement) is not None

    def list_all(self) -> list[User]:
        statement = select(User).order_by(User.id)
        users = self._session.scalars(statement).all()
        return list(users)

    def create(self, user_data: UserCreate) -> User:
        new_user = User(
            username=user_data.username,
            display_name=user_data.display_name,
        )

        self._session.add(new_user)
        self._session.commit()
        self._session.refresh(new_user)

        return new_user

__init__ 接收本次请求的 Session,保存到内部属性 _session。Repository 不自己创建全局 Session

select(User.id).where(...) 构造按用户名查询 ID 的语句。User.username == username 在这里生成 SQL 条件,值由 SQLAlchemy 作为参数传递

scalar() 取第一行第一项,查不到返回 None,因此可用于存在性判断。列表查询使用 select(User),结果中的第一项是 ORM 对象,scalars().all() 收集这些对象,order_by(User.id) 指定排序

几个相似方法要区分:

方法 当前用途
scalar(statement) 取第一行第一项,没有结果返回 None
scalars(statement).all() 收集每行第一项,得到一组 ORM 对象
result.scalar_one() 要求恰好一行并取第一列

写入操作中,add() 将对象纳入待处理状态;commit() 自动触发 flush,将 SQL 发往数据库并提交事务;refresh() 再查询该记录以更新对象字段

主键由数据库插入时生成,SQLAlchemy 通常在 flush 阶段就能获取,refresh() 并不负责生成 ID

十、Service 去掉内存列表

app/services/users.py

python 复制代码
from app.repositories.users import UserRepository
from app.schemas.users import UserCreate, UserResponse


def create_user(user_data: UserCreate, repository: UserRepository) -> UserResponse:
    if repository.exists_by_username(user_data.username):
        raise ValueError("Username already exists")

    created_user = repository.create(user_data)

    return UserResponse(
        id=created_user.id,
        username=created_user.username,
        display_name=created_user.display_name,
    )


def list_users(repository: UserRepository) -> list[UserResponse]:
    users = repository.list_all()

    return [
        UserResponse(
            id=user.id,
            username=user.username,
            display_name=user.display_name,
        )
        for user in users
    ]

删除原来的 _userslen(_users) + 1。Service 通过 Repository 获取存储结果,再把 ORM 对象转换为响应模型

列表推导式逐个遍历 users,为每个用户创建 UserResponse,最后得到响应列表

十一、Router 注入 Session

app/routers/users.py

python 复制代码
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.database import get_session
from app.repositories.users import UserRepository
from app.schemas.users import UserCreate, UserResponse
from app.services.users import create_user as create_user_service
from app.services.users import list_users as list_users_service

router = APIRouter(prefix="/users", tags=["users"])


@router.get("", response_model=list[UserResponse])
def list_users(
    session: Annotated[Session, Depends(get_session)],
) -> list[UserResponse]:
    repository = UserRepository(session)
    return list_users_service(repository)


@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
    user_data: UserCreate,
    session: Annotated[Session, Depends(get_session)],
) -> UserResponse:
    repository = UserRepository(session)

    try:
        return create_user_service(user_data, repository)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=str(exc),
        ) from exc

Annotated[Session, Depends(get_session)] 把参数类型和依赖说明放在一起:类型是 Session,值由 FastAPI 调用 get_session 获取。传入函数本身,不要写成 get_session()

也可以直接写成 session: Session = Depends(get_session),但 Annotated 写法参数语义更清楚、便于直接调用和复用,还可以避免 Ruff 等格式化工具进行默认参数检查的告警

Annotated 可以理解为"带附加说明的类型":

scss 复制代码
Session              → 参数是什么类型
Depends(get_session) → FastAPI 从哪里获取它

此时参数没有默认值,是一个必须提供的参数,区别在直接调用函数时很明显:

python 复制代码
def old(session: Session = Depends(get_session)):
    return session


def new(session: Annotated[Session, Depends(get_session)]):
    return session

如果直接执行:

python 复制代码
old()  # 返回 Depends 标记,不是真实 Session
new()  # 立即报错:缺少 session 参数

普通 Python 调用不会自动注入依赖。Annotated 写法能让编辑器和 Python 更早发现遗漏,测试时则明确传入:

python 复制代码
new(session=test_session)

Annotated 还有一个很重要的优势:它可以把一整套规则定义成可复用的类型,例如:

python 复制代码
Username = Annotated[
    str,
    Field(min_length=3, max_length=20),
]

class UserCreate(BaseModel):
    username: Username


class UserUpdate(BaseModel):
    username: Username

这样 username 的规则就不用到处重复写

main.py 继续通过已有的 app.include_router(users_router) 注册路由,最终地址为 /users,不额外添加 /api 前缀

十二、验收数据是否真正保存

使用原有 Vue 表单提交一个新用户名,预期创建成功;再次提交相同用户名,预期返回 409

然后在浏览器访问:

text 复制代码
http://127.0.0.1:8000/users

应该看到包含新用户的 JSON 数组。停止并重新启动 FastAPI:

bash 复制代码
# 在原后端终端按 Ctrl+C 停止服务,然后重新启动
uv run fastapi dev app/main.py  # uv run 使用项目环境,dev 以开发模式启动指定入口

重新访问列表,用户仍然存在,说明数据已跨进程重启保留。没有数据时,列表返回 []

阶段结果

到这里,FastAPI 已通过 SQLAlchemy 和 psycopg 连接 PostgreSQL,使用 Alembic 管理用户表结构,并通过 Session 和 Repository 完成用户创建与列表查询。用户数据不再保存在 Python 内存列表中,重启后端后仍能查询到

但当前只完成了用户持久化的基本链路:

  • 前端响应仍使用 as User,没有运行时结构校验
  • 前端还没有解析 422 字段错误并展示对应提示
  • 用户列表已有接口,前端还没有列表页面
  • 还没有按 ID 查询、修改和删除用户的接口
  • 顺序提交的重复用户名会返回 409,并发插入触发的数据库唯一约束异常还没有转换为 409
  • 当前由 Repository 提交单次写入,还没有涉及多个操作共同成功或失败的事务管理

下一阶段先完善前端响应校验和 409、422 错误展示,再继续实现用户查询、修改与删除

相关推荐
弈栈录1 小时前
LangGraph 入门:用状态机设计可靠的 Agent 工作流
后端·程序员
小羊没烦恼!2 小时前
Memory 记忆设计讨论:Agent Memory 的数据模型可以怎么设计
java·开发语言·前端·c++·c#
码上成长2 小时前
Mapbox 围栏编辑踩过的几个坑:key 串了、形状炸了、icon 挡住绿点
前端·前端框架
SGAMERrain2 小时前
做了一个免费的在线绘画网格工具 DrawGrid,聊聊一个小工具是怎么越做越完整的
前端
我家猫叫佩奇2 小时前
🦭 厌倦了千篇一律的线性图标?Naive Icons 正式开源
前端·javascript·css
LEE3 小时前
前端转型全栈 00:AI 时代该学哪些,不该学哪些
前端·后端
Go_error3 小时前
上下文取消链:摧毁我们支付系统的 bug
后端·go
晚安日记wanna3 小时前
SSR 为什么不适合登录态从水合冲突到缓存串号
前端·react.js·面试
aixingpan3 小时前
aixingpan.cn API开发文档:api_docs_bichart_natalvssolararc2接口指南
前端·php