是时候把他们放在一起了
FastAPI和SQLModel的相性非常好(毕竟是一个作者),能轻松的结合在一起提供服务
从需求出发……
如果我们现在有一类资源,我们想对外部提供API来访问这一类资源。对于典型的RESTful API,我们需要实现的接口有:
- GET:获取一项/多项资源
- POST:新建一个资源
- PUT:更新一个资源,提供完整资源数据
- PATCH:更新一个资源,但是只需要提供更改的部分
- DELETE:删除一个资源
由于PATCH实际上能做所有PUT能做的事情,我们暂时先不考虑PUT。又考虑到GET有两种调用方法,那我们实际上有五个API需要实现,这五个API一般是(以Hero
模型为例):
- GET
/heroes/
:获取多个Hero
- POST
/heroes/
:创建一个hero
- GET
/heroes/{hero_id}
:获取id
为hero_id
的Hero
- PATCH
/heroes/{hero_id}
:更新id
为hero_id
的Hero
- DELETE
/heroes/{hero_id}
:删除id
为hero_id
的Hero
那么接下来,我们就来逐一实现这些API
创建一个资源
我们从创建资源开始。还是以Hero
类为例,我们的Hero
长成这样:
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: int | None = Field(default=None, index=True)
如果你对数据库熟悉,你应该会发现,此处的id
字段在数据库中应该是一个自增的变量,在创建资源时理应不需要手动指定。虽然此处的id
是int | None
,但这仍留给了我们手动指定id
的可能性,我们可不想这样。因此,在创建Hero
资源时,应不允许填写id
字段
为了明确这一需求,我们需要将创建Hero
的类从普通的Hero
类中剥离出来。我们可以这么做:
# 嘘...先别急着真的这么做
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: int | None = Field(default=None, index=True)
class HeroCreate(SQLModel):
# 这里不需要tabel=True, 因为这张表不需要真的存在数据库里
name: str
secret_name: str
age: int | None = None
但是显然,这样编写会存在很多重复字段,非常折磨
为了避免这种问题,我们可以将共同字段提取成一个基类,而Hero
和HeroCreate
继承这个基类:
class HeroBase(SQLModel):
name: str = Field(index=True)
secret_name: str
age: int | None = Field(default=None, index=True)
class Hero(HeroBase, table=True):
id: int | None = Field(defualt=None, primary_key=True)
class HeroCreate(HeroBase):
pass
"""
Oh, 真奇怪
你肯定想问,这个类既然什么都没有定义,它为什么要存在?
其实这个类是给人看的,因为API中使用“HeroBase”作为类名会令人迷惑
因此我们创建一个新类,即使它什么都没定义
"""
一般来说,在创建完资源后,我们会返回这个资源的信息,因此我们也同样需要一个类来返回资源信息。在返回时,我们希望携带该资源的id
class HeroPublic(HeroBase):
id: int
直到这时,我们才开始编写API
我们可以这么来写:
# 省略部分代码,假设你已经完成了engine的创建
@app.post("/heroes/", response_model=HeroPublic)
def create_hero(hero: HeroCreate):
with Session(engine) as session:
db_hero = Hero.model_validate(hero)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero
出现了一些新东西,让我们来进行说明:
response_model
对API返回的数据进行验证和过滤,它能将我们返回的db_hero
从Hero
类转换到HeroPublic
类,同时自动在文档中记录我们的返回格式hero: HeroCreate
代表了我们需求的参数hero
是一个HeroCreate
类,这表明我们需在请求的Body中给出HeroCreate
的所有字段(也就是name
,secret_name
和age
)。这也会在文档中体现
hero
原本是一个HeroCreate
类型的对象,我们通过Hero.model_validate()
将其验证并转换为Hero
类
这样,我们就完成了我们的第一个API。呼——
删改查
查
查询分为查询所有资源和查询单个资源,但他们都很简单!
from fastapi import FastAPI, HTTPException
from sqlmodel import Field, Session, SQLModel, create_engine, select
# 省略部分代码...
# 查询所有资源
@app.get("/heroes/", response_model=list[HeroPublic])
def read_heroes():
with Session(engine) as session:
heroes = sesson.exec(select(Hero)).all()
return heroes
# 查询单个资源
@app.get("/heroes/{hero_id}", response_model=HeroPublic)
def read_hero(hero_id: int):
with Session(engine) as session:
hero = session.get(Hero, hero_id)
if not hero: # 检验hero是否存在
raise HTTPException(status_code=404, detail="Hero not found")
return hero
这两个API的代码逻辑都很好理解。唯一要注意的是,在读取单个Hero
时,你还额外需要校验一下用户输入的hero_id
是否有对应的Hero
存在
对于读取多个资源的API,我们暂时不考虑分页的需求。也许在之后我们会再提到这件事
改
要更新数据,过程比查找会稍微复杂一些
在上文的演示中,我们为了满足Hero
在不同API下的不同字段要求,构造了几个不同的Hero
相关类。而对于改,我们也有新的需求:我们希望不能改动id
字段,而其他字段都可以选择改动
为了满足这样的需求,我们设计HeroUpdate
类如下:
class HeroUpdate(SQLModel):
name: str | None = None
secret_name: str | None = None
age: int | None = None
你可能注意到了,我们并没有继承HeroBase
类,因为我们的字段要求和他有不小区别。我们所有字段都是可选的,这代表你在更新Hero
信息时,可以仅输入其中部分字段进行更新,而非必须更新全部字段
接下来,让我们编写改的API:
@app.patch("/heroes/{hero_id}", response_model=HeroPublic)
def update_hero(hero_id: int, hero: HeroUpdate):
with Session(engine) as session:
db_hero = session.get(Hero, hero_id)
if not db_hero:
raise HTTPException(status_code=404, detail="Hero not found")
hero_data = hero.model_dump(exclude_unset=True)
db_hero.sqlmodel_update(hero_data)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero
似乎细节有点多…照例,让我们逐一讲解一下:
- 函数声明中的
hero: HeroUpdate
代表了要接受HeroUpdate
类所定义的数据。HeroUpdate
是SQLModel
的子类,SQLModel
是Pydantic
的子类,而Pydantic
在FastAPI的参数中会被解释为需要在请求的Body填写的字段。因此,API请求的Body中可以(选择性地)带上name
,secret_name
,age
字段(因为我们将其设为了可选的) model_dump()
方法能将该模型实例转换为字典,可能类似于:
{
"name": None,
"secret_name": None,
"age": 11
}
- 若
model_dump()
的exclude_unset
参数被设为True
(就像上方代码演示的那样),那其在把模型转化为字典时会去除所有请求中未声明的字段(例如在上方就只保留age
) - 并非只要值是
None
的字段就一定会被删除,实际上,如果字段在数据库中的值不为None
,而客户端想要删除这个字段的值,那么只需要发送这个字段为None
的请求即可 - 在此之后,
sqlmodel_update()
方法会用那个导出的字典来更新自己模型的数据
如果你参考了一些比较老的代码,你会发现取代
sqlmodel_update()
的是手动获取hero_data
的key-value
对,然后使用setattr()
方法手动添加(是的我也这么做过)这是旧版本的做法。新版本添加了
sqlmodel_update()
方法,你就不需要这么做了好时代,来临力
删
这个超简单
@app.delete("/heroes/{hero_id}")
def delete_hero(hero_id: int):
with Session(engine) as session:
hero = session.get(Hero, hero_id)
if not hero:
raise HTTPException(status_code=404, detail="Hero not found")
session.delete(hero)
session.commit()
return {"ok": True}
是的,我们没什么可以返回的东西了,所以我们返回{"ok": True}
总结,以及下一步
到这里为止,你已经可以初步的把FastAPI和SQLModel结合在一起使用了!恭喜!
但是,如果你不想让你的前端朋友太糟心,你一定会需要写一些更复杂的交互逻辑,例如鉴权、关系模型、依赖注入等等。现在这点知识是肯定不够的
对于这些信息,你当然可以查看FastAPI文档,也可以查看SQLModel文档(虽然截至2024.11还没有官方汉化,只有比较烂的疑似机翻),这些都能帮助你达成你的目的
如果有机会的话,我也会再写几篇文章,来介绍一下与FastAPI相关的这些技术