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,因此会推导为不允许空值
前面的 UserCreate 和 UserResponse 描述接口输入、输出;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
]
删除原来的 _users 和 len(_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 错误展示,再继续实现用户查询、修改与删除