第 3 章快速介绍了如何定义 FastAPI Web 端点、向它们传递简单字符串输入,并获得响应。本章会进一步进入 FastAPI 应用程序的顶层——也可以称为接口层或路由层——以及它与 Service 层和 Data 层的集成。
和之前一样,我会先从小示例开始。然后,我会引入一些结构,把层划分成子部分,以便让开发和增长更干净。我们写的代码越少,将来需要记住和修复的东西就越少。
本书中的基础示例数据与想象中的生物,也就是神秘生物,以及它们的探索者有关。你也许能在其他信息领域中找到类似之处。
一般来说,我们会如何处理信息?像大多数网站一样,我们的网站会提供执行以下操作的方式:
- 检索
- 创建
- 修改
- 替换
- 删除
从顶层开始,我们将创建能够对数据执行这些功能的 Web 端点。一开始,我们会提供假数据,让端点能与任何 Web 客户端一起工作。在后续章节中,我们会把假数据代码下移到更低层。每一步中,我们都会确保网站仍然能工作,并且能够正确传递数据。最后,在第 10 章中,我们会去掉假数据,把真实数据存储到真实数据库中,形成一个完整的端到端网站,也就是 Web → Service → Data。
注意
允许任何匿名访问者执行所有这些操作,将会成为一堂“为什么我们不能拥有好东西”的实物教学课。第 11 章会讨论定义角色、限制谁能做什么所需的认证与授权,也就是 auth。当前章节剩余部分中,我们会暂时绕开 auth,只展示如何处理原始的 Web 功能。
插曲:自顶向下、自底向上,还是从中间向外?
设计一个网站时,你可以从以下某一层开始:
从 Web 层开始,然后向下工作
从 Data 层开始,然后向上工作
从 Service 层开始,然后向两个方向扩展
你是否已经有一个安装好、加载了数据的数据库,只是在渴望一种把它分享给全世界的方式?如果是这样,你可能会想先处理 Data 层的代码和测试,然后是 Service 层,最后再编写 Web 层。
如果你遵循领域驱动设计,可能会从中间的 Service 层开始,定义核心实体和数据模型。或者你可能想先演进 Web 接口,并伪造对下层的调用,直到你知道自己对它们有什么期待。
你可以在这些书中找到非常好的设计讨论和建议:
Leonardo Giordani 的《Clean Architectures in Python》(Digital Cat Books)
Harry J.W. Percival 和 Bob Gregory 的《Architecture Patterns with Python》(O’Reilly)
José Haro Peralta 的《Microservice APIs》(Manning)
在这些书和其他资料中,你会看到六边形架构、端口和适配器等术语。你如何推进,很大程度上取决于你已经拥有什么数据,以及你希望如何开展构建网站的工作。
我猜许多读者主要是对尝试 FastAPI 及其相关技术感兴趣,并不一定已经有一个预先定义好的成熟数据领域,想马上为它加上工具化能力。
所以,在本书中,我采用 Web 优先的方法——一步一步,从必要部分开始,然后在向下推进的过程中按需添加其他部分。有时实验会成功,有时不会。我会避免一开始就把所有东西都塞进这个 Web 层的冲动。
注意
这个 Web 层只是用户和服务之间传递数据的一种方式。也存在其他方式,例如通过 CLI 或软件开发工具包,也就是 SDK。在其他框架中,你可能会看到这个 Web 层被称为视图层或表示层。
RESTful API 设计
HTTP 是一种在 Web 客户端和服务器之间传递命令与数据的方式。但是,就像你可以用冰箱里的食材组合出从可怕到高级美食的各种结果一样,一些 HTTP 的“配方”也比另一些更好。
在第 1 章中,我提到 RESTful 已经成为 HTTP 开发中一种有用但有时模糊的模型。RESTful 设计包含以下核心组件:
Resources
你的应用程序管理的数据元素。
IDs
唯一资源标识符。
URLs
结构化的资源和 ID 字符串。
Verbs 或 actions
伴随 URL 出现、用于不同目的的术语:
GET
检索一个资源。
POST
创建一个新资源。
PUT
完整替换一个资源。
PATCH
部分替换一个资源。
DELETE
资源砰地消失。
注意
你会看到关于 PUT 和 PATCH 相对优劣的争论。如果你不需要区分部分修改和完整修改,也就是替换,那么可能不需要两者都用。
将动词和包含资源与 ID 的 URL 组合起来时,通用 RESTful 规则使用以下路径参数模式,也就是 URL 中 / 之间的内容:
verb /resource/
将动词应用于 resource 类型的所有资源。
verb /resource/id
将动词应用于 ID 为 id 的资源。
使用本书的示例数据,对端点 /thing 发起一个 GET 请求,会返回所有探索者的数据;但对 /thing/abc 发起一个 GET 请求,则只会给你 ID 为 abc 的那个 thing 资源的数据。
最后,Web 请求通常还会携带更多信息,用来表示要执行以下操作:
对结果排序
对结果分页
执行另一个函数
这些参数有时可以表示为路径参数,也就是附加在末尾、另一个 / 之后的内容,但通常会作为查询参数包含在 URL 中,也就是 ? 之后的 var=val 内容。由于 URL 有大小限制,大型请求通常通过 HTTP 请求体传递。
注意
大多数作者建议在命名资源以及相关命名空间,例如 API 部分和数据库表时,使用复数。我很长一段时间都遵循这个建议,但现在觉得单数名称在许多方面更简单,包括英语本身的一些古怪之处:
有些词本身就是复数形式:series、fish
有些词有不规则复数形式:children、people
你需要在许多地方编写定制的单复数转换代码
因为这些原因,本书中很多地方会使用单数命名方案。这违背了通常的 RESTful 建议,所以如果你不同意,可以随意忽略这一点。
文件和目录站点布局
我们的数据主要与生物和探索者有关。最初,我们可以在单个 Python 文件中定义所有 URL 以及用于访问它们数据的 FastAPI 路径函数。让我们抵制这种诱惑,并从一开始就假装我们已经是神秘生物 Web 领域正在崛起的新星。有了良好基础,添加酷炫的新东西就会容易得多。
首先,在你的机器上选择一个目录。把它命名为 fastapi,或者任何能帮助你记住这里是你折腾本书代码的地方的名字。在其中创建以下子目录:
src
包含所有网站代码。
web
FastAPI Web 层。
service
业务逻辑层。
data
存储接口层。
model
Pydantic 模型定义。
fake
早期硬编码,也就是桩数据。
这些目录中的每一个很快都会获得三个文件:
__init__.py
用于把这个目录视为一个包。
creature.py
这一层中的生物代码。
explorer.py
这一层中的探索者代码。
关于如何布置用于开发的网站,存在许多观点。这个设计旨在展示层分离,并为将来的新增内容留出空间。
现在需要做一些解释。首先,__init__.py 文件是空的。它们有点像 Python 的一个 hack,用来表示它们所在的目录应该被视为一个可以被导入的 Python 包。其次,fake 目录会在较低层被构建出来之前,向较高层提供一些桩数据。
此外,Python 的导入逻辑并不严格按照目录层级工作。它依赖 Python 包和模块。前面树形结构中列出的 .py 文件是 Python 模块,也就是源文件。如果它们的父目录包含 __init__.py 文件,那么这些父目录就是包。这是一个约定,用来告诉 Python:如果你有一个名为 sys 的目录并输入 import sys,你到底是想导入系统自带的那个,还是你本地的那个。
Python 程序可以导入包和模块。Python 解释器有一个内置的 sys.path 变量,其中包含标准 Python 代码的位置。环境变量 PYTHONPATH 是一个空字符串,或者由冒号分隔的目录名字符串,它告诉 Python,在使用 sys.path 查找导入的模块或包之前,应先检查哪些父目录。所以,如果你切换到新的 fastapi 目录,在 Linux 或 macOS 上输入下面的命令,以确保在导入时优先检查它下面的新代码:
$ export PYTHONPATH=$PWD/src
这里的 $PWD 表示 print working directory,也就是打印当前工作目录。它可以让你不用输入 fastapi 目录的完整路径,当然你愿意的话也可以输入完整路径。而 src 部分表示只在这里查找要导入的模块和包。
要在 Windows 下设置 PWD 环境变量,请查看 Python Software Foundation 网站上的“附录:设置环境变量”。
呼。
第一段网站代码
本节会讨论如何使用 FastAPI 为一个 RESTful API 网站编写请求和响应。然后,我们会开始把这些内容应用到真正的、越来越复杂的网站上。
让我们从示例 8-1 开始。在 src 中创建这个新的顶层 main.py 程序,它将启动 Uvicorn 程序和 FastAPI 包。
示例 8-1 主程序,main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def top():
return "top here"
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", reload=True)
这里的 app 是把所有内容连接在一起的 FastAPI 对象。Uvicorn 的第一个参数是 "main:app",因为文件名是 main.py;第二部分是 app,也就是 FastAPI 对象的名字。
Uvicorn 会持续运行,并且如果同一目录或任何子目录中的代码发生变化,就会重新启动。如果没有 reload=True,每次你修改代码时,都需要手动终止并重启 Uvicorn。在后面的许多示例中,你只会不断修改同一个 main.py 文件并强制重启,而不是创建 main2.py、main3.py 等文件。
在示例 8-2 中启动 main.py。
示例 8-2 运行主程序
$ python main.py &
INFO: Will watch for changes in these directories: [.../fastapi']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [92543] using StatReload
INFO: Started server process [92551]
INFO: Waiting for application startup.
INFO: Application startup complete.
最后那个 & 会把程序放到后台运行,如果你愿意,就可以在同一个终端窗口中运行其他程序。或者省略 &,并在另一个窗口或标签页中运行其他代码。
现在,你可以用浏览器或到目前为止已经见过的任何测试程序访问 localhost:8000。示例 8-3 使用 HTTPie:
示例 8-3 测试主程序
$ http localhost:8000
HTTP/1.1 200 OK
content-length: 8
content-type: application/json
date: Sun, 05 Feb 2023 03:54:29 GMT
server: uvicorn
"top here"
从现在开始,当你做出修改时,Web 服务器应该会自动重启。如果某个错误让它崩掉了,就再次用 python main.py 重启它。
示例 8-4 添加另一个测试端点,使用路径参数,也就是 URL 的一部分。
示例 8-4 添加一个端点
import uvicorn
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def top():
return "top here"
@app.get("/echo/{thing}")
def echo(thing):
return f"echoing {thing}"
if __name__ == "__main__":
uvicorn.run("main:app", reload=True)
当你在编辑器中保存对 main.py 的修改后,运行 Web 服务器的窗口应该会打印类似下面的内容:
WARNING: StatReload detected changes in 'main.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [92862]
INFO: Started server process [92872]
INFO: Waiting for application startup.
INFO: Application startup complete.
示例 8-5 显示新端点是否被正确处理,其中 -b 只打印响应体。
示例 8-5 测试新端点
$ http -b localhost:8000/echo/argh
"echoing argh"
在接下来的几节中,我们会向 main.py 添加更多端点。
请求
一个 HTTP 请求由文本头部以及一个或多个请求体部分组成。你可以自己编写代码,把 HTTP 解析成 Python 数据结构,但你不会是第一个这样做的人。在你的 Web 应用程序中,让框架替你完成这些细节会更高效。
FastAPI 的依赖注入在这里特别有用。数据可能来自 HTTP 消息的不同部分,而你已经看到,可以指定一个或多个这样的依赖,说明数据位于哪里:
Header
位于 HTTP 头中。
Path
位于 URL 中。
Query
位于 URL 中 ? 之后。
Body
位于 HTTP 请求体中。
其他更间接的来源包括:
环境变量
配置设置
示例 8-6 使用我们的老朋友 HTTPie 展示一个 HTTP 请求,并忽略返回的 HTML body 数据。
示例 8-6 HTTP 请求和响应头
$ http -p HBh http://example.com/
GET / HTTP/1.1
Accept: /
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: example.com
User-Agent: HTTPie/3.2.1
HTTP/1.1 200 OK
Age: 374045
Cache-Control: max-age=604800
Content-Encoding: gzip
Content-Length: 648
Content-Type: text/html; charset=UTF-8
Date: Sat, 04 Feb 2023 01:00:21 GMT
Etag: "3147526947+gzip"
Expires: Sat, 11 Feb 2023 01:00:21 GMT
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Server: ECS (cha/80E2)
Vary: Accept-Encoding
X-Cache: HIT
第一行请求 example.com 的顶层页面,这是一个任何人都可以在,嗯,示例中使用的免费网站。它只请求一个 URL,在其他任何地方都没有参数。第一组行是发送给网站的 HTTP 请求头,下一组包含 HTTP 响应头。
注意
从这里开始,大多数测试示例都不需要所有那些请求和响应头,因此你会更多地看到 http -b 的使用。
多个路由器
大多数 Web 服务会处理多种资源。虽然你可以把所有路径处理代码都扔进一个文件,然后去某个地方享受欢乐时光,但使用多个子路由器通常很方便,而不是使用到目前为止大多数示例中使用的单个 app 变量。
在 web 目录下,也就是与你到目前为止一直在修改的 main.py 文件所在的同一个目录中,创建一个名为 explorer.py 的文件,如示例 8-7 所示。
示例 8-7 在 web/explorer.py 中使用 APIRouter
from fastapi import APIRouter
router = APIRouter(prefix = "/explorer")
@router.get("/")
def top():
return "top explorer endpoint"
现在,示例 8-8 让顶层应用程序 main.py 知道城里来了一个新的子路由器,它将处理所有以 /explorer 开头的 URL:
示例 8-8 将主应用程序 main.py 连接到子路由器
from fastapi import FastAPI
from .web import explorer
app = FastAPI()
app.include_router(explorer.router)
这个新文件会被 Uvicorn 发现。和往常一样,在示例 8-9 中进行测试,而不是假设它会正常工作。
示例 8-9 测试新的子路由器
$ http -b localhost:8000/explorer/
"top explorer endpoint"
构建 Web 层
现在,让我们开始向 Web 层添加真正的核心函数。最初,在 Web 函数本身中伪造所有数据。在第 9 章中,我们会把假数据内容移到相应的 service 函数中;在第 10 章中,再移到 data 函数中。最后,会为 Data 层添加一个真实数据库,让它进行访问。在每个开发步骤中,对 Web 端点的调用都应该仍然有效。
定义数据模型
首先,定义我们将在各层之间传递的数据。我们的领域包含探索者和生物,因此让我们为它们定义最小的初始 Pydantic 模型。之后可能会出现其他想法,比如探险、日志,或者销售咖啡杯的电子商务。但现在,先只包含示例 8-10 中这两个会呼吸的模型,至少对生物来说通常会呼吸。
示例 8-10 model/explorer.py 中的模型定义
from pydantic import BaseModel
class Explorer(BaseModel):
name: str
country: str
description: str
示例 8-11 复活了前几章中的 Creature 定义。
示例 8-11 model/creature.py 中的模型定义
from pydantic import BaseModel
class Creature(BaseModel):
name: str
country: str
area: str
description: str
aka: str
这些是非常简单的初始模型。你还没有使用 Pydantic 的任何特性,比如必填与可选,或者受约束的值。这段简单代码之后可以增强,而不需要大规模扰动逻辑。
对于 country 值,你会使用 ISO 两字符国家代码;这样可以少打一点字,代价是需要查找不常见的国家代码。
桩数据和假数据
桩数据也被称为 mock data,是一些预设结果,在不调用普通“实时”模块的情况下返回。它们是一种快速测试路由和响应的方式。
假数据是真实数据源的替身,至少会执行某些相同功能。一个例子是模拟数据库的内存类。在本章以及接下来的几章中,随着你填充用于定义各层及其通信的代码,你会创建一些假数据。在第 10 章中,你将定义一个真正实时的数据存储,也就是数据库,来替换这些假数据。
在整个栈中创建通用函数
类似于数据示例,构建这个网站的方法也是探索式的。通常,我们并不清楚最终会需要什么,所以先从一些类似网站共有的部分开始。为数据提供前端通常需要以下能力:
获取一个、一些、全部
创建
完整替换
部分修改
删除
本质上,这些是数据库中的 CRUD 基础操作,尽管我把 U 拆成了部分修改和完整替换两个函数。也许这种区分最终会被证明并不必要!这取决于数据会把我们带向哪里。
创建假数据
自顶向下工作时,你会在所有三层中复制一些函数。为了节省输入,示例 8-12 引入了名为 fake 的顶层目录,其中的模块会提供关于探索者和生物的假数据。
示例 8-12 新模块 fake/explorer.py
from model.explorer import Explorer
# fake data, replaced in Chapter 10 by a real database and SQL
_explorers = [
Explorer(name="Claude Hande",
country="FR",
description="Scarce during full moons"),
Explorer(name="Noah Weiser",
country="DE",
description="Myopic machete man"),
]
def get_all() -> list[Explorer]:
"""Return all explorers"""
return _explorers
def get_one(name: str) -> Explorer | None:
for _explorer in _explorers:
if _explorer.name == name:
return _explorer
return None
# The following are nonfunctional for now,
# so they just act like they work, without modifying
# the actual fake _explorers list:
def create(explorer: Explorer) -> Explorer:
"""Add an explorer"""
return explorer
def modify(explorer: Explorer) -> Explorer:
"""Partially modify an explorer"""
return explorer
def replace(explorer: Explorer) -> Explorer:
"""Completely replace an explorer"""
return explorer
def delete(name: str) -> bool:
"""Delete an explorer; return None if it existed"""
return None
示例 8-13 中的生物设置类似。
示例 8-13 新模块 fake/creature.py
from model.creature import Creature
# fake data, until we use a real database and SQL
_creatures = [
Creature(name="Yeti",
aka="Abominable Snowman",
country="CN",
area="Himalayas",
description="Hirsute Himalayan"),
Creature(name="Bigfoot",
description="Yeti's Cousin Eddie",
country="US",
area="*",
aka="Sasquatch"),
]
def get_all() -> list[Creature]:
"""Return all creatures"""
return _creatures
def get_one(name: str) -> Creature | None:
"""Return one creature"""
for _creature in _creatures:
if _creature.name == name:
return _creature
return None
# The following are nonfunctional for now,
# so they just act like they work, without modifying
# the actual fake _creatures list:
def create(creature: Creature) -> Creature:
"""Add a creature"""
return creature
def modify(creature: Creature) -> Creature:
"""Partially modify a creature"""
return creature
def replace(creature: Creature) -> Creature:
"""Completely replace a creature"""
return creature
def delete(name: str):
"""Delete a creature; return None if it existed"""
return None
注意
是的,这些模块函数几乎完全相同。等到真实数据库出现,并且必须处理两个模型不同字段时,它们会发生变化。此外,这里你使用的是独立函数,而不是定义一个 Fake 类或抽象类。模块有自己的命名空间,因此这是一种等价的、把数据和函数捆绑在一起的方式。
现在让我们修改示例 8-12 和 8-13 中的 Web 函数。为了准备后续构建层,也就是 Service 和 Data,导入刚刚定义的假数据提供者,但在这里把它命名为 service:import fake.explorer as service,见示例 8-14。在第 9 章中,你将执行以下操作:
创建一个新的 service/explorer.py 文件。
在那里导入假数据。
让 web/explorer.py 导入新的 service 模块,而不是 fake 模块。
在第 10 章中,你将在 Data 层做同样的事。所有这些都只是添加部件并把它们连起来,同时尽可能少地重写代码。你要到第 10 章后面才会打开电源,也就是启用真实数据库和持久化数据。
示例 8-14 web/explorer.py 的新端点
from fastapi import APIRouter
from model.explorer import Explorer
import fake.explorer as service
router = APIRouter(prefix = "/explorer")
@router.get("/")
def get_all() -> list[Explorer]:
return service.get_all()
@router.get("/{name}")
def get_one(name) -> Explorer | None:
return service.get_one(name)
# all the remaining endpoints do nothing yet:
@router.post("/")
def create(explorer: Explorer) -> Explorer:
return service.create(explorer)
@router.patch("/")
def modify(explorer: Explorer) -> Explorer:
return service.modify(explorer)
@router.put("/")
def replace(explorer: Explorer) -> Explorer:
return service.replace(explorer)
@router.delete("/{name}")
def delete(name: str):
return None
现在,对 /creature 端点做同样的事,见示例 8-15。是的,现在这是一段类似的复制粘贴代码,但提前这样做会简化后续修改——而后续总是会有修改。
示例 8-15 web/creature.py 的新端点
from fastapi import APIRouter
from model.creature import Creature
import fake.creature as service
router = APIRouter(prefix = "/creature")
@router.get("/")
def get_all() -> list[Creature]:
return service.get_all()
@router.get("/{name}")
def get_one(name) -> Creature:
return service.get_one(name)
# all the remaining endpoints do nothing yet:
@router.post("/")
def create(creature: Creature) -> Creature:
return service.create(creature)
@router.patch("/")
def modify(creature: Creature) -> Creature:
return service.modify(creature)
@router.put("/")
def replace(creature: Creature) -> Creature:
return service.replace(creature)
@router.delete("/{name}")
def delete(name: str):
return service.delete(name)
上一次我们折腾 main.py,是为了添加 /explorer URL 的子路由器。现在,让我们在示例 8-16 中为 /creature 再添加一个。
示例 8-16 将 creature 子路由器添加到 main.py
import uvicorn
from fastapi import FastAPI
from web import explorer, creature
app = FastAPI()
app.include_router(explorer.router)
app.include_router(creature.router)
if __name__ == "__main__":
uvicorn.run("main:app", reload=True)
所有这些都正常工作了吗?如果你准确输入或粘贴了所有内容,Uvicorn 应该已经重启了应用程序。让我们尝试一些手动测试。
测试!
第 12 章会展示如何使用 pytest 在多个层级自动化测试。示例 8-17 到 8-21 使用 HTTPie 对 explorer 端点执行一些手动 Web 层测试。
示例 8-17 测试 Get All 端点
$ http -b localhost:8000/explorer/
[
{
"country": "FR",
"name": "Claude Hande",
"description": "Scarce during full moons"
},
{
"country": "DE",
"name": "Noah Weiser",
"description": "Myopic machete man"
}
]
示例 8-18 测试 Get One 端点
$ http -b localhost:8000/explorer/"Noah Weiser"
{
"country": "DE",
"name": "Noah Weiser",
"description": "Myopic machete man"
}
示例 8-19 测试 Replace 端点
$ http -b PUT localhost:8000/explorer/"Noah Weiser"
{
"country": "DE",
"name": "Noah Weiser",
"description": "Myopic machete man"
}
示例 8-20 测试 Modify 端点
$ http -b PATCH localhost:8000/explorer/"Noah Weiser"
{
"country": "DE",
"name": "Noah Weiser",
"description": "Myopic machete man"
}
示例 8-21 测试 Delete 端点
$ http -b DELETE localhost:8000/explorer/Noah%20Weiser
true
$ http -b DELETE localhost:8000/explorer/Edmund%20Hillary
false
你也可以对 /creature 端点做同样的测试。
使用 FastAPI 自动化测试表单
除了我在大多数示例中使用的手动测试之外,FastAPI 还在 /docs 和 /redocs 端点提供了非常不错的自动化测试表单。它们是同一信息的两种不同样式,所以我只会展示图 8-1 中 /docs 页面的一小部分。
Top docs page
图 8-1 生成的文档页面
尝试第一个测试:
点击顶部 GET /explorer/ 部分右侧的向下箭头。它会打开一个大的浅蓝色表单。
点击左侧的蓝色 Execute 按钮。你会看到图 8-2 中结果的顶部部分。
GET /explorer results page
图 8-2 GET /explorer/ 的生成结果页面
在下方的 “Response body” 部分,你会看到目前为止你定义的假 explorer 数据所返回的 JSON:
[ { "name": "Claude Hande", "country": "FE", "description": "Scarce during full moons" }, { "name": "Noah Weiser", "country": "DE", "description": "Myopic machete man" }]
尝试所有其他端点。对于某些端点,比如 GET /explorer/{name},你需要提供一个输入值。每个端点都会给你响应,尽管有一些端点在数据库代码加入之前实际上还什么都不做。你可以在第 9 章和第 10 章接近结尾时重复这些测试,以确保这些代码修改过程中没有刺破任何数据管道。
与 Service 层和 Data 层对话
每当 Web 层中的函数需要 Data 层管理的数据时,这个函数应该请求 Service 层作为中介。这需要更多代码,看起来可能没有必要,但这是个好主意:
就像罐子上的标签所说,Web 层处理 Web,Data 层处理外部数据存储和服务。把它们各自的细节完全分开要安全得多。
各层可以独立测试。层机制的分离使这一点成为可能。
注意
对于一个非常小的网站,如果 Service 层没有增加任何价值,你可以跳过它。第 9 章最初定义的 service 函数,除了在 Web 层和 Data 层之间传递请求与响应之外,几乎什么也不做。不过,至少要保持 Web 层和 Data 层分离。
那个 Service 层函数到底做什么?你会在下一章看到。提示:它会和 Data 层对话,但声音压得很低,所以 Web 层并不知道它到底说了什么。但它也会定义任何特定的业务逻辑,比如资源之间的交互。主要来说,Web 层和 Data 层都不应该关心那里发生了什么。Service 层是一个 Secret Service。
分页和排序
在 Web 接口中,当使用类似 GET /resource 的 URL 模式返回许多或所有东西时,你经常希望请求查找并返回以下内容:
只有一个东西
可能有许多东西
所有东西
我们要如何让这台善意但极其字面化的计算机做这些事呢?对于第一种情况,我前面提到的 RESTful 模式是在 URL 路径中包含资源 ID。当获取多个资源时,我们可能希望以特定顺序查看结果:
Sort
对所有结果排序,即使你一次只获得其中一组结果。
Paginate
一次只返回部分结果,并尊重任何排序。
在每种情况下,一组用户指定的参数会表明你想要什么。通常会把这些参数作为查询参数提供。下面是一些例子:
Sort
GET /explorer?sort=country:获取所有探索者,并按国家代码排序。
Paginate
GET /explorer?offset=10&size=10:返回整个列表中第 10 到第 19 个探索者;在这个例子中未排序。
Both
GET /explorer?sort=country&offset=10&size=10
虽然你可以把这些指定为单独的查询参数,但 FastAPI 的依赖注入可以提供帮助:
把排序和分页参数定义为一个 Pydantic 模型。
通过路径函数参数中的 Depends 功能,把这个参数模型提供给 get_all() 路径函数。
排序和分页应该在哪里发生?一开始,让数据库查询把完整结果传到 Web 层,再用 Python 在那里切分数据,可能看起来最简单。但这并不高效。这些任务通常最适合放在 Data 层,因为数据库擅长做这些事。我最终会在第 17 章中给出这些代码,那里会介绍第 10 章之外更多数据库小知识。
回顾
本章补充了第 3 章以及其他章节中的更多细节。它开启了构建一个完整网站的过程,这个网站用于提供想象生物及其探索者的信息。从 Web 层开始,你使用 FastAPI 路径装饰器和路径函数定义了端点。路径函数会从 HTTP 请求字节中数据所在的任何位置收集请求数据。模型数据会由 Pydantic 自动检查和验证。路径函数通常会把参数传递给对应的 service 函数,而这些函数将在下一章登场。