1. 项目整体设计与思路拆解
1.1 为什么选择 SQLModel 而不是纯 SQLAlchemy 或 Pydantic
在 FastAPI 生态里做数据库操作,常见的方案有三种:直接用 SQLAlchemy、用 Pydantic 定义数据模型然后手动写 SQL 或使用 Tortoise-ORM,以及 Sebastian Ramirez(FastAPI 的作者)亲手打造的 SQLModel。我最初做 FastAPI 项目时,用的是 SQLAlchemy + Pydantic 分离的方案,但很快发现一个问题:模型定义要写两遍 —— 一遍是 SQLAlchemy 的 ORM 模型,另一遍是 Pydantic 的 schema 用于请求/响应校验。虽然可以借助一些技巧减少重复,但维护成本依然很高。后来迁移到 SQLModel,才真正体会到“干净利落”四个字。
SQLModel 的核心思路是:将 SQLAlchemy 的 ORM 能力与 Pydantic 的数据校验能力合二为一。你只需要定义一个类,它既是数据库表映射,也是 API 的请求/响应模型。这意味着:
- 减少重复代码。一个模型类同时描述了表结构和数据格式。
- 类型提示完整。SQLModel 全面继承了 Pydantic 的类型系统,IDE 补全和类型检查非常友好。
- 异步支持原生。SQLModel 底层基于 SQLAlchemy 2.0 的异步引擎,和 FastAPI 的异步路线天然契合。
我做了一个小对比,供你参考:
| 对比维度 | SQLAlchemy + Pydantic 分离方案 | SQLModel 方案 |
|---|---|---|
| 模型定义 | 需要定义 ORM 模型和 Pydantic schema 至少两个类 | 一个类完成 |
| 类型提示 | Pydantic 部分有类型,ORM 部分类型较弱 | 全程强类型 |
| 与 FastAPI 整合 | 需要手动编写依赖注入和转换逻辑 | 提供原生 Session 依赖,无缝集成 |
| 迁移工具 | 常用 Alembic + SQLAlchemy | 同样支持 Alembic,但模型更简洁 |
| 学习曲线 | 需要同时掌握 SQLAlchemy 和 Pydantic | 只需掌握 SQLModel,底层还是 SQLAlchemy |
当然,SQLModel 也不是银弹。如果你的项目已经成熟使用了 SQLAlchemy 的复杂特性(比如多态继承、自定义方言等),迁移成本可能不小。但多数新项目或中小型项目,SQLModel 绝对是更高效的选择。
1.2 封装的目标:通用 CRUD 与业务逻辑分离
“封装”这个词在标题里很关键。我们做 ORM 封装,不是简单地把 SQLModel 套在 FastAPI 里,而是要提炼出一套可复用的底层操作,让业务代码不再重复写增删改查。我总结了一套“三层封装”的思路:
- 第一层:基础会话管理。封装数据库连接、会话生命周期,让 FastAPI 的依赖注入系统自动管理。
- 第二层:通用 CRUD 基类。提供
create,get,update,delete等泛型方法,支持过滤、分页、排序。 - 第三层:业务逻辑层。基于第二层实现具体业务方法,比如“创建用户时检查邮箱唯一性”。
这样,业务代码只需要关注逻辑,不需要关心 SQL 语句、会话提交、回滚等细节。这套封装理念在多个项目里验证过,团队协作效率提升明显。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 SQLModel 模型定义的关键细节
定义模型时,最核心的一点是理解 table=True 参数。SQLModel 的类有两种模式:数据库表模型(table=True)和普通数据模型(不设置 table=True)。前者会映射到数据库表,后者只作为数据校验容器,不会建表。
python复制from sqlmodel import SQLModel, Field
from typing import Optional
# 数据库表模型
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True)
email: str
password_hash: str
is_active: bool = True
# 普通数据模型(用于创建请求)
class UserCreate(SQLModel):
username: str
email: str
password: str
注意:id 字段设为 Optional[int] 并给定 default=None,是因为插入时数据库自增主键,ORM 会忽略这个字段。如果设为 int 且没有默认值,Pydantic 在校验时会要求必须提供,这就不对。这是很多人一开始会踩的坑。
另外,Field 函数支持 SQLAlchemy 的所有字段参数,比如 index=True、unique=True、foreign_key、sa_column 等。如果你需要自定义列类型或约束,可以通过 sa_column 参数直接传入 SQLAlchemy 的 Column,比如:
python复制from sqlalchemy import Column, DateTime, func
class Article(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
title: str
content: str
created_at: datetime = Field(sa_column=Column(DateTime, server_default=func.now()))
2.2 关系定义与查询优化
SQLModel 支持 SQLAlchemy 的关系定义,但语法略有不同。推荐使用 Relationship 属性,而不是直接写 SQLAlchemy 的 relationship。例如:
python复制from typing import List, Optional
from sqlmodel import SQLModel, Field, Relationship
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str
posts: List["Post"] = Relationship(back_populates="author")
class Post(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
title: str
user_id: Optional[int] = Field(default=None, foreign_key="user.id")
author: Optional[User] = Relationship(back_populates="posts")
这里有个关键点:Relationship 不会自动加载关联数据,默认是懒加载(lazy='select')。在 FastAPI 的异步上下文中,懒加载可能引发 MissingGreenlet 错误,因为异步会话需要显式开启关联加载。解决办法有两种:
- 在查询时使用
selectinload或joinedload预加载。 - 在模型定义时设置
lazy='selectin'或lazy='joined'。
我倾向于在查询时手动控制,避免全局设置影响性能。例如:
python复制from sqlalchemy.orm import selectinload
from sqlmodel import select, Session
async def get_user_with_posts(user_id: int):
async with Session(engine) as session:
stmt = select(User).where(User.id == user_id).options(selectinload(User.posts))
