FastAPI教程——Pydantic、类型提示与模型导览

17 阅读6分钟

使用 Python 类型提示进行数据验证和设置管理。
Pydantic 快速且可扩展,能与你的 linter、IDE 和大脑友好协作。用纯粹、规范的 Python 3.6+ 定义数据应该是什么样子;再用 Pydantic 验证它。
——Samuel Colvin,Pydantic 开发者

预览

FastAPI 很大程度上建立在一个名为 Pydantic 的 Python 包之上。它使用模型,也就是 Python 对象类,来定义数据结构。这些模型在 FastAPI 应用程序中被大量使用,并且在编写较大型应用程序时是一个真正的优势。

类型提示

现在是时候再多了解一点 Python 类型提示了。

第 2 章提到过,在许多计算机语言中,变量会直接指向内存中的某个值。这要求程序员声明它的类型,这样才能确定该值的大小和比特。在 Python 中,变量只是与对象关联的名字,而真正拥有类型的是对象。

在标准编程中,一个变量通常会与同一个对象关联。如果我们把一个类型提示与这个变量关联起来,就可以避免一些编程错误。因此,Python 在语言中加入了类型提示,并把它放在标准 typing 模块中。Python 解释器会忽略类型提示语法,并像它不存在一样运行程序。那么这有什么意义呢?

你可能在某一行把一个变量当作字符串处理,后来却忘了这一点,并给它赋了一个不同类型的对象。虽然其他语言的编译器会抱怨,但 Python 不会。标准 Python 解释器会捕获普通语法错误和运行时异常,但不会捕获某个变量的类型混用。像 mypy 这样的辅助工具会关注类型提示,并在存在任何不匹配时警告你。

此外,这些提示对 Python 开发者是可用的,他们可以编写工具,做比类型错误检查更多的事情。下面几节会描述 Pydantic 包是如何被开发出来,以满足一些原本并不明显的需求的。之后,你会看到它与 FastAPI 的集成如何让许多 Web 开发问题变得更容易处理。

顺便说一句,类型提示长什么样?变量有一种语法,函数返回值有另一种语法。

变量类型提示可以只包含类型:

name: type

也可以同时用一个值初始化变量:

name: type = value

类型可以是标准 Python 简单类型之一,比如 intstr,也可以是集合类型,比如 tuplelistdict

thing: str = "yeti"

注意

在 Python 3.9 之前,你需要从 typing 模块中导入这些标准类型名称的大写版本:

from typing import Str
thing: Str = "yeti"

下面是一些带初始化的例子:

physics_magic_number: float = 1.0/137.03599913
hp_lovecraft_noun: str = "ichor"
exploding_sheep: tuple = "sis", "boom", bah!"
responses: dict = {"Marco": "Polo", "answer": 42}

你也可以包含集合的子类型:

name: dict[keytype, valtype] = {key1: val1, key2: val2}

typing 模块为子类型提供了一些有用的额外能力;最常见的是下面这些:

Any

任意类型

Union

指定类型中的任意一种,例如 Union[str, int]

注意

在 Python 3.10 及更高版本中,你可以写 type1 | type2,而不是 Union[type1, type2]

Python dict 的 Pydantic 定义示例如下:

from typing import Any
responses: dict[str, Any] = {"Marco": "Polo", "answer": 42}

或者,更具体一点:

from typing import Union
responses: dict[str, Union[str, int]] = {"Marco": "Polo", "answer": 42}

或者,Python 3.10 及更高版本:

responses: dict[str, str | int] = {"Marco": "Polo", "answer": 42}

注意,一行带类型提示的变量是合法的 Python,但一行裸变量不是:

$ python
...
>>> thing0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name thing0 is not defined
>>> thing0: str

另外,普通 Python 解释器不会捕获错误的类型使用:

$ python
...
>>> thing1: str = "yeti"
>>> thing1 = 47

但它们会被 mypy 捕获。如果你还没有安装它,请运行:

pip install mypy

把前面那两行保存到一个名为 stuff.py 的文件中,¹ 然后尝试下面的命令:

$ mypy stuff.py
stuff.py:2: error: Incompatible types in assignment
(expression has type "int", variable has type "str")
Found 1 error in 1 file (checked 1 source file)

函数返回类型提示使用箭头,而不是冒号:

function(args) -> type:

下面是一个函数返回的 Pydantic 示例:

def get_thing() -> str:
   return "yeti"

你可以使用任何类型,包括你自己定义的类,或者它们的组合。几页之后你会看到这一点。

数据分组

我们经常需要把一组相关变量放在一起,而不是到处传递许多个单独变量。我们该如何把多个变量作为一组整合起来,同时保留类型提示呢?

让我们告别前几章中那个温吞的问候示例,从现在开始使用更丰富的数据。和本书其余部分一样,我们会使用神秘生物,也就是想象中的生物,以及寻找它们的同样想象中的探险者作为示例。最初的神秘生物定义只会包含以下字符串变量:

name

country

两个字符的 ISO 国家代码,即 3166-1 alpha 2,或者 * 表示全部

area

可选;美国州或其他国家分区

description

自由格式

aka

Also known as,也就是“也被称为……”

探险者则会包含以下内容:

name

country

两个字符的 ISO 国家代码

description

自由格式

Python 历史上的数据分组结构,也就是除了基础的 int、字符串等之外的结构,列在下面:

tuple

对象的不可变序列

list

对象的可变序列

set

可变的互异对象集合

dict

可变的键值对象对,其中键需要是不可变类型

元组,见示例 5-1,和列表,见示例 5-2,只允许你通过偏移量访问成员变量,因此你必须记住什么东西放在了哪里。

示例 5-1 使用元组

>>> tuple_thing = ("yeti", "CN", "Himalayas",
    "Hirsute Himalayan", "Abominable Snowman")
>>> print("Name is", tuple_thing[0])
Name is yeti

示例 5-2 使用列表

>>> list_thing = ["yeti", "CN", "Himalayas",
    "Hirsute Himalayan", "Abominable Snowman"]
>>> print("Name is", list_thing[0])
Name is yeti

示例 5-3 显示,通过为整数偏移量定义名字,你可以让它稍微更有解释性。

示例 5-3 使用元组和命名偏移量

>>> NAME = 0
>>> COUNTRY = 1
>>> AREA = 2
>>> DESCRIPTION = 3
>>> AKA = 4
>>> tuple_thing = ("yeti", "CN", "Himalayas",
    "Hirsute Himalayan", "Abominable Snowman")
>>> print("Name is", tuple_thing[NAME])
Name is yeti

字典在示例 5-4 中稍微更好一些,因为它允许你通过具有描述性的键进行访问。

示例 5-4 使用字典

>>> dict_thing = {"name": "yeti",
...     "country": "CN",
...     "area": "Himalayas",
...     "description": "Hirsute Himalayan",
...     "aka": "Abominable Snowman"}
>>> print("Name is", dict_thing["name"])
Name is yeti

集合只包含唯一值,因此对于聚合各种变量来说并不太有帮助。

在示例 5-5 中,命名元组是一种元组,它允许你通过整数偏移量或名称进行访问。

示例 5-5 使用命名元组

>>> from collections import namedtuple
>>> CreatureNamedTuple = namedtuple("CreatureNamedTuple",
...     "name, country, area, description, aka")
>>> namedtuple_thing = CreatureNamedTuple("yeti",
...     "CN",
...     "Himalaya",
...     "Hirsute HImalayan",
...     "Abominable Snowman")
>>> print("Name is", namedtuple_thing[0])
Name is yeti
>>> print("Name is", namedtuple_thing.name)
Name is yeti

注意

你不能写 namedtuple_thing["name"]。它是元组,不是字典,因此索引需要是整数。

示例 5-6 定义了一个新的 Python 类,并使用 self 添加所有属性。但仅仅为了定义这些属性,你就需要写很多东西。

示例 5-6 使用标准类

>>> class CreatureClass():
...     def __init__(self,
...       name: str,
...       country: str,
...       area: str,
...       description: str,
...       aka: str):
...         self.name = name
...         self.country = country
...         self.area = area
...         self.description = description
...         self.aka = aka
...
>>> class_thing = CreatureClass(
...     "yeti",
...     "CN",
...     "Himalayas"
...     "Hirsute Himalayan",
...     "Abominable Snowman")
>>> print("Name is", class_thing.name)
Name is yeti

注意

你可能会想,这有什么不好?使用常规类,你可以添加更多数据,也就是属性,尤其还可以添加行为,也就是方法。某个疯狂的日子里,你可能决定添加一个方法,用来查找某个探险者最喜欢的歌曲。这不适用于某个生物。² 但这里的使用场景只是把一团数据原封不动地在各层之间移动,并在输入和输出途中进行验证。此外,方法就像方钉,很难塞进数据库这样的圆孔里。

Python 中有没有类似其他计算机语言中所谓 record 或 struct 的东西,也就是一组名称和值?Python 最近添加的一个功能是 dataclass。示例 5-7 展示了使用 dataclass 后,所有那些 self 东西如何消失。

示例 5-7 使用 dataclass

>>> from dataclasses import dataclass
>>>
>>> @dataclass
... class CreatureDataClass():
...     name: str
...     country: str
...     area: str
...     description: str
...     aka: str
...
>>> dataclass_thing = CreatureDataClass(
...     "yeti",
...     "CN",
...     "Himalayas"
...     "Hirsute Himalayan",
...     "Abominable Snowman")
>>> print("Name is", dataclass_thing.name)
Name is yeti

对于“把变量放在一起”这部分来说,这已经相当不错了。但我们想要更多,所以让我们向圣诞老人索要这些东西:

可能替代类型的联合
缺失值/可选值
默认值
数据验证
与 JSON 等格式之间的序列化和反序列化

替代方案

使用 Python 内置数据结构是很诱人的,尤其是字典。但你不可避免地会发现,字典有点太“松”了。自由是有代价的。你需要检查所有事情:

键是否可选?
如果键缺失,是否有默认值?
键是否存在?
如果存在,这个键的值是否是正确类型?
如果是正确类型,这个值是否处于正确范围内,或者是否匹配某个模式?

至少有三种解决方案可以满足这些需求中的至少一部分:

Dataclasses

标准 Python 的一部分。

attrs

第三方库,但它是 dataclasses 的超集。

Pydantic

也是第三方库,但集成到了 FastAPI 中。所以,如果你已经在使用 FastAPI,它就是一个容易的选择。而如果你正在读这本书,那你很可能就是这样。

YouTube 上有一个对这三者的实用比较。其中一个结论是,Pydantic 在验证方面很突出,而且它与 FastAPI 的集成能捕获许多潜在的数据错误。另一个结论是,Pydantic 依赖继承,也就是继承自 BaseModel 类,而另外两者使用 Python 装饰器来定义对象。这更多是风格问题。

在另一个比较中,Pydantic 的表现优于较老的验证包,比如 marshmallow,以及名字颇有趣的 Voluptuous。Pydantic 的另一个很大优势是,它使用标准 Python 类型提示语法;较老的库早于类型提示出现,因此它们实现了自己的写法。

所以,本书中我会选择 Pydantic,但如果你不使用 FastAPI,也可能会找到另外两种方案的用途。

Pydantic 提供了指定以下检查的任意组合的方式:

必填与可选
如果未指定但又是必填时的默认值
期望的数据类型或类型集合
值范围限制
如果需要,还可以使用其他基于函数的检查
序列化和反序列化

一个简单示例

你已经见过如何通过 URL、查询参数或 HTTP 请求体,把一个简单字符串传给 Web 端点。问题在于,你通常请求和接收的是由许多类型组成的数据组。这正是 Pydantic 模型首次出现在 FastAPI 中的地方。

这个初始示例会使用三个文件:

model.py 定义一个 Pydantic 模型。
data.py 是一个假数据源,定义一个模型实例。
web.py 定义一个 FastAPI Web 端点,用于返回假数据。

为了简化本章内容,我们先把所有文件放在同一个目录中。在后续讨论更大型网站的章节中,我们会把它们分离到各自对应的层中。首先,在示例 5-8 中定义一个生物模型。

示例 5-8 定义一个生物模型:model.py

from pydantic import BaseModel

class Creature(BaseModel):
    name: str
    country: str
    area: str
    description: str
    aka: str

thing = Creature(
    name="yeti",
    country="CN",
    area="Himalayas",
    description="Hirsute Himalayan",
    aka="Abominable Snowman")
)
print("Name is", thing.name)

Creature 类继承自 Pydantic 的 BaseModel。在 namecountryareadescriptionaka 后面的 : str 部分是类型提示,表示每个字段都是一个 Python 字符串。

注意

在这个示例中,所有字段都是必填的。在 Pydantic 中,如果类型描述中没有 Optional,该字段就必须有一个值。

在示例 5-9 中,如果你包含参数名,就可以按任意顺序传递参数。

示例 5-9 创建一个生物

>>> thing = Creature(
...     name="yeti",
...     country="CN",
...     area="Himalayas"
...     description="Hirsute Himalayan",
...     aka="Abominable Snowman")
>>> print("Name is", thing.name)
Name is yeti

现在,示例 5-10 定义了一个极小的数据源;在后续章节中,数据库会承担这个职责。类型提示 list[Creature] 告诉 Python,这是一个只包含 Creature 对象的列表。

示例 5-10 在 data.py 中定义假数据

from model import Creature

_creatures: list[Creature] = [
    Creature(name="yeti",
             country="CN",
             area="Himalayas",
             description="Hirsute Himalayan",
             aka="Abominable Snowman"
             ),
    Creature(name="sasquatch",
             country="US",
             area="*",
             description="Yeti's Cousin Eddie",
             aka="Bigfoot")
]

def get_creatures() -> list[Creature]:
    return _creatures

我们使用 "*" 表示 Bigfoot 的 area,因为它几乎无处不在。

这段代码导入了我们刚刚写的 model.py。它通过把 Creature 对象列表命名为 _creatures 来做一点数据隐藏,并提供 get_creatures() 函数来返回它们。

示例 5-11 列出了 web.py,这是一个定义 FastAPI Web 端点的文件。

示例 5-11 定义一个 FastAPI Web 端点:web.py

from model import Creature
from fastapi import FastAPI

app = FastAPI()

@app.get("/creature")
def get_all() -> list[Creature]:
    from data import get_creatures
    return get_creatures()

现在在示例 5-12 中启动这个单端点服务器。

示例 5-12 启动 Uvicorn

$ uvicorn creature:app
INFO:     Started server process [24782]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

在另一个窗口中,示例 5-13 使用 HTTPie Web 客户端访问这个 Web 应用程序。如果你愿意,也可以试试浏览器或 Requests 模块。

示例 5-13 使用 HTTPie 测试

$ http http://localhost:8000/creature
HTTP/1.1 200 OK
content-length: 183
content-type: application/json
date: Mon, 12 Sep 2022 02:21:15 GMT
server: uvicorn

[
    {
        "aka": "Abominable Snowman",
        "area": "Himalayas",
        "country": "CN",
        "name": "yeti",
        "description": "Hirsute Himalayan"
    },
    {
        "aka": "Bigfoot",
        "country": "US",
        "area": "*",
        "name": "sasquatch",
        "description": "Yeti's Cousin Eddie"
    }

FastAPI 和 Starlette 会自动把原始的 Creature 模型对象列表转换为 JSON 字符串。这是 FastAPI 中的默认输出格式,因此我们不需要指定它。

此外,你最初启动 Uvicorn Web 服务器的那个窗口应该已经打印出一行日志:

 INFO:     127.0.0.1:52375 - "GET /creature HTTP/1.1" 200 OK

验证类型

上一节展示了如何做到以下几点:

对变量和函数应用类型提示
定义并使用 Pydantic 模型
从数据源返回模型列表
把模型列表返回给 Web 客户端,并自动将模型列表转换为 JSON

现在,让我们真正把它用于验证数据。

尝试给一个或多个 Creature 字段分配错误类型的值。让我们用一个独立测试来做这件事,Pydantic 并不应用于任何 Web 代码;它是数据层面的东西。

示例 5-14 列出了 test1.py

示例 5-14 测试 Creature 模型

from model import Creature

dragon = Creature(
    name="dragon",
    description=["incorrect", "string", "list"],
    country="*" ,
    area="*",
    aka="firedrake")

现在尝试运行示例 5-15 中的测试。

示例 5-15 运行测试

$ python test1.py
Traceback (most recent call last):
  File ".../test1.py", line 3, in <module>
    dragon = Creature(
  File "pydantic/main.py", line 342, in
    pydantic.main.BaseModel.init
    pydantic.error_wrappers.ValidationError:
    1 validation error for Creature description
  str type expected (type=type_error.str)

这发现我们给 description 字段分配了一个字符串列表,而它想要的是一个朴素的普通字符串。

验证值

即使某个值的类型与 Creature 类中的规范匹配,可能仍然需要通过更多检查。可以对值本身施加一些限制:

整数(conint)或浮点数:

gt

大于

lt

小于

ge

大于或等于

le

小于或等于

multiple_of

某个值的整数倍

字符串(constr):

min_length

最小字符长度,不是字节长度

max_length

最大字符长度

to_upper

转换为大写

to_lower

转换为小写

regex

匹配一个 Python 正则表达式

元组、列表或集合:

min_items

最小元素数量

max_items

最大元素数量

这些内容会在模型的类型部分中指定。

示例 5-16 确保 name 字段始终至少有两个字符。否则,"",也就是空字符串,也是一个有效字符串。

示例 5-16 查看一次验证失败

>>> from pydantic import BaseModel, constr
>>>
>>> class Creature(BaseModel):
...     name: constr(min_length=2)
...     country: str
...     area: str
...     description: str
...     aka: str
...
>>> bad_creature = Creature(name="!",
...     description="it's a raccoon",
...     area="your attic")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "pydantic/main.py", line 342,
  in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError:
1 validation error for Creature name
  ensure this value has at least 2 characters
  (type=value_error.any_str.min_length; limit_value=2)

这里的 constr 表示 constrained string,也就是受约束字符串。示例 5-17 使用了另一种方式,即 Pydantic 的 Field 规范。

示例 5-17 另一次验证失败,使用 Field

>>> from pydantic import BaseModel, Field
>>>
>>> class Creature(BaseModel):
...     name: str = Field(..., min_length=2)
...     country: str
...     area: str
...     description: str
...     aka: str
...
>>> bad_creature = Creature(name="!",
...     area="your attic",
...     description="it's a raccoon")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "pydantic/main.py", line 342,
  in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError:
1 validation error for Creature name
  ensure this value has at least 2 characters
  (type=value_error.any_str.min_length; limit_value=2)

传给 Field() 的那个 ... 参数表示这个值是必填的,并且没有默认值。

这只是对 Pydantic 的一个最小介绍。主要结论是:它让你能够自动化验证数据。当你从 Web 层或 Data 层获取数据时,你会看到这有多么有用。

回顾

模型是在你的 Web 应用程序中定义会被传来传去的数据的最佳方式。Pydantic 利用 Python 的类型提示来定义数据模型,并在你的应用程序中传递这些模型。接下来要讲的是:定义依赖,以便把特定细节从你的通用代码中分离出来。

¹ 我有没有任何可察觉的想象力?嗯……没有。
² 除了那一小群会约德尔唱法的雪人之外,这倒是个不错的乐队名。