FastAPI 指南(三)
原文:
annas-archive.org/md5/aadba315b042a88fe9a981fd64d02c4a译者:飞龙
第十一章:认证与授权
尊重我的权威!
艾瑞克·卡特曼,南方公园
预览
有时一个网站是完全开放的,任何访客都可以访问任何页面。但如果网站的内容可能被修改,某些端点将被限制只允许特定的人或组访问。如果任何人都能修改亚马逊的页面,想象一下会出现什么奇怪的物品,以及某些人突然获得的惊人销量。不幸的是,这是人类的本性——对于一些人来说,他们会利用其他人支付他们活动的隐藏税。
我们应该让我们的神秘动物网站对任何用户开放访问吗?不!几乎任何规模的网络服务最终都需要处理以下问题:
认证(authn)
你是谁?
授权(authz)
你想要什么?
认证和授权(auth)代码是否应该有自己的新层,例如在 Web 和服务之间添加一个新层?还是应该所有东西都由 Web 或服务层自己处理?本章将涉及认证技术及其放置位置的讨论。
往往关于 Web 安全的描述似乎比必要的复杂得多。攻击者可以非常狡猾,而反制措施可能并不简单。
注意
正如我多次提到的,官方的 FastAPI 文档非常出色。如果本章提供的细节不足以满足您的需求,请查看安全部分。
因此,让我们一步步地进行这次讲解。我将从简单的技术开始,这些技术仅用于将认证挂接到 Web 端点以进行测试,但不能在公共网站上使用。
插曲 1:您是否需要认证?
再次强调,认证 关注的是 身份:你是谁?要实施认证,我们需要将秘密信息映射到唯一的身份。有许多方法可以做到这一点,复杂度也有很多变化。让我们从简单开始逐步深入。
往往关于 Web 开发的书籍和文章会立即深入到认证和授权的细节中,有时会把它们弄混。它们有时会跳过第一个问题:您真的需要吗?
您可以允许完全匿名访问所有网站页面。但这将使您容易受到拒绝服务攻击等攻击的威胁。尽管某些保护措施(如速率限制)可以在 Web 服务器外部实施(参见第十三章),几乎所有公共 API 提供者都至少需要一些身份验证。
除了安全之外,我们还想知道网站的效果如何:
-
有多少独立访问者?
-
哪些页面最受欢迎?
-
某些更改是否会增加页面浏览量?
-
哪些页面序列是常见的?
这些问题的答案需要对特定访问者进行认证。否则,您只能得到总计数。
注意
如果您的网站需要身份验证或授权,访问它应该是加密的(使用 HTTPS 而不是 HTTP),以防止攻击者从明文中提取机密数据。有关设置 HTTPS 的详细信息,请参见第十三章。
认证方法
有许多网络身份验证方法和工具:
用户名/电子邮件和密码
使用经典的 HTTP 基本和摘要身份验证
API 密钥
一个不透明的长字符串,带有一个附带的 秘密
OAuth2
一组用于身份验证和授权的标准
JavaScript Web Tokens(JWT)
一种包含经过加密签名的用户信息的编码格式
在本节中,我将回顾前两种方法,并向你展示如何传统地实现它们。但是在填写 API 和数据库代码之前,我会停下来。相反,我们将完全实现一个更现代的方案,使用 OAuth2 和 JWT。
全局身份验证:共享秘密
最简单的身份验证方法是传递一个通常只有 web 服务器知道的秘密。如果匹配,你就能进入。如果你的 API 网站暴露在公共网络中,使用 HTTP 而不是 HTTPS 是不安全的。如果它被隐藏在一个前端网站背后,而该前端网站本身是公开的,前端和后端可以使用一个共享的常量秘密进行通信。但是如果你的前端网站被黑客攻击了,那就麻烦了。让我们看看 FastAPI 如何处理简单的身份验证。
创建一个名为 auth.py 的新顶级文件。检查一下,你之前的章节中是否仍然有另一个仍在运行的 FastAPI 服务器,该服务器来自于那些不断变化的 main.py 文件。示例 11-1 实现了一个服务器,只是使用 HTTP 基本身份验证返回发送给它的任何 username 和 password——这是网络原始时代的一种方法。
示例 11-1. 使用 HTTP 基本身份验证获取用户信息:auth.py
import uvicorn
from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
basic = HTTPBasic()
@app.get("/who")
def get_user(
creds: HTTPBasicCredentials = Depends(basic)):
return {"username": creds.username, "password": creds.password}
if __name__ == "__main__":
uvicorn.run("auth:app", reload=True)
在示例 11-2 中,告诉 HTTPie 发送这个基本身份验证请求(这需要参数 -a name:password)。在这里,让我们使用名称 me 和密码 secret。
示例 11-2. 使用 HTTPie 进行测试
$ http -q -a me:secret localhost:8000/who
{
"password": "secret",
"username": "me"
}
使用 示例 11-3 中的 Requests 包进行测试类似,使用 auth 参数。
示例 11-3. 使用 Requests 进行测试
>>> import requests
>>> r = requests.get("http://localhost:8000/who",
auth=("me", "secret"))
>>> r.json()
{'username': 'me', 'password': 'secret'}
你还可以使用自动文档页面(*http://localhost:8000/docs*)测试 示例 11-1,如 图 11-1 所示。
图 11-1. 简单身份验证的文档页面
点击右边的下箭头,然后点击尝试按钮,然后点击执行按钮。你会看到一个请求用户名和密码的表单。输入任何内容。文档表单将命中该服务器端点,并在响应中显示这些值。
这些测试表明你可以将用户名和密码发送到服务器并返回(虽然这些测试实际上没有检查任何内容)。服务器中的某些东西需要验证这个名称和密码是否与批准的值匹配。因此,在示例 11-4 中,我将在 Web 服务器中包含一个单一的秘密用户名和密码。你现在传递的用户名和密码需要与它们匹配(每个都是一个 共享的秘密),否则你会得到一个异常。HTTP 状态码 401 官方称为 Unauthorized,但实际上它的意思是未经验证。
注意
而不是记忆所有的 HTTP 状态码,你可以导入 FastAPI 的 status 模块(该模块直接从 Starlette 导入)。因此,你可以在示例 11-4 中使用更加解释性的status_code=HTTP_401_UNAUTHORIZED,而不是简单的status_code=401。
示例 11-4. 在 auth.py 中添加一个秘密的用户名和密码
import uvicorn
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
secret_user: str = "newphone"
secret_password: str = "whodis?"
basic: HTTPBasicCredentials = HTTPBasic()
@app.get("/who")
def get_user(
creds: HTTPBasicCredentials = Depends(basic)) -> dict:
if (creds.username == secret_user and
creds.password == secret_password):
return {"username": creds.username,
"password": creds.password}
raise HTTPException(status_code=401, detail="Hey!")
if __name__ == "__main__":
uvicorn.run("auth:app", reload=True)
在示例 11-5 中,如果用户名和密码猜测错误,会收到轻微的401警告。
示例 11-5. 使用 HTTPie 测试不匹配的用户名/密码
$ http -a me:secret localhost:8000/who
HTTP/1.1 401 Unauthorized
content-length: 17
content-type: application/json
date: Fri, 03 Mar 2023 03:25:09 GMT
server: uvicorn
{
"detail": "Hey!"
}
使用这种魔法组合返回用户名和密码,如以前在示例 11-6 中所示。
示例 11-6. 使用 HTTPie 测试正确的用户名/密码
$ http -q -a newphone:whodis? localhost:8000/who
{
"password": "whodis?",
"username": "newphone"
}
简单的个人认证
前一节展示了如何使用共享秘密来控制访问。这是一种广义的方法,安全性不高。它并没有告诉你有关个体访问者的任何信息,只是它们(或有感知的人工智能)知道这个秘密。
许多网站希望执行以下操作:
-
某种方式定义个别访问者
-
标识特定访问者在访问某些端点时(认证)
-
可能为某些访问者和端点分配不同的权限(授权)
-
可能保存每个访问者的特定信息(兴趣,购买等)
如果你的访问者是人类,你可能希望他们提供用户名或电子邮件以及密码。如果它们是外部程序,你可能希望它们提供 API 密钥和密钥。
注意
从现在开始,我将仅使用用户名来指代用户选择的名称或电子邮件。
要认证真实的个人用户而不是虚拟用户,你需要做更多工作:
-
将用户值(名称和密码)作为 HTTP 标头传递给 API 服务器端点。
-
使用 HTTPS 而不是 HTTP,以避免任何人窥视这些标头的文本。
-
哈希密码为不同的字符串。其结果是不可“解哈希化”的——你不能从其哈希值推导出原始密码。
-
创建一个真实的数据库存储一个
User数据库表,包含用户名和哈希密码(绝不是原始明文密码)。 -
对新输入的密码进行哈希处理,并将结果与数据库中的哈希密码进行比较。
-
如果用户名和哈希密码匹配,将匹配的
User对象传递到堆栈上。如果它们不匹配,则返回None或引发异常。 -
在服务层中,触发与个人用户认证相关的任何指标/日志等。
-
在 Web 层中,将认证用户信息发送给需要的任何函数。
我将在接下来的章节中向你展示如何使用 OAuth2 和 JWT 等最新工具来完成所有这些事情。
更复杂的个人认证
如果你想要认证个体,你必须在某处存储一些个体信息,例如在一个包含至少一个键(用户名或 API 密钥)和一个密钥(密码或 API 密钥)的数据库中。当访问受保护的 URL 时,你的网站访问者将提供这些信息,而你需要在数据库中找到匹配项。
官方的 FastAPI 安全文档(入门 和 高级)详细描述了如何为多个用户设置认证,使用本地数据库。但是,示例网络功能模拟了实际数据库访问。
在这里,你将会做相反的操作:从数据层开始,逐步向上。你将定义用户/访客的定义、存储和访问方式。然后逐步向上到 Web 层,讨论用户身份验证的传递、评估和认证。
OAuth2
OAuth 2.0,即“开放授权”,是一个标准,旨在允许网站或应用程序代表用户访问其他网站应用程序托管的资源。
Auth0
在早期信任的 Web 时代,你可以将你在一个网站(我们称之为 B)的登录名和密码提供给另一个网站(当然是 A),让其代表你访问 B 上的资源。这会让 A 获得对 B 的完全访问权限,尽管它只被信任访问它应该访问的内容。B 和资源的例子包括 Twitter 的粉丝、Facebook 的好友、电子邮件联系人等。当然,这种做法不可能长久存在,所以各种公司和组织联合起来定义了 OAuth。它最初的设计目的只是允许网站 A 访问网站 B 上特定(而非全部)的资源。
OAuth2 是一个流行但复杂的授权标准,适用于 A/B 例子之外的多种情况。有许多对它的解释,从轻量级到深入。
注意
曾经有一个 OAuth1,但它不再使用。一些最初的 OAuth2 建议现在已被弃用(计算机术语,意思是不要使用它们)。在未来,还会有 OAuth2.1 和更远的 txauth。
OAuth 提供了各种流程以应对不同的情况。本节将使用授权码流来进行实现,逐步迈出平均大小的步骤。
首先,你需要安装这些第三方 Python 包:
JWT 处理
pip install python-jose[cryptography]
安全的密码处理
pip install passlib
表单处理
pip install python-multipart
以下部分从用户数据模型和数据库管理开始,逐步向上到服务和 Web 层,其中包括 OAuth。
用户模型
让我们从 示例 11-7 中的极简用户模型定义开始。这些将在所有层中使用。
示例 11-7. 用户定义:model/user.py
from pydantic import BaseModel
class User(BaseModel):
name: str
hash: str
User 对象包含一个任意的 name 和一个 hash 字符串(经过哈希处理的密码,而不是原始的明文密码),这是保存在数据库中的内容。我们需要这两者来验证访客。
用户数据层
示例 11-8 包含了用户数据库代码。
注意
代码创建了 user(活跃用户)和 xuser(已删除用户)表。通常开发者会向用户表添加一个布尔类型的 deleted 字段,以指示用户不再活跃,而实际上并没有从表中删除记录。我更倾向于将删除的用户数据移动到另一个表中。这样可以避免在所有用户查询中重复检查 deleted 字段。它还有助于加快查询速度:对于像布尔类型这样的低基数字段,创建索引并不会带来实质的好处。
示例 11-8. 数据层:data/user.py
from model.user import User
from .init import (conn, curs, get_db, IntegrityError)
from error import Missing, Duplicate
curs.execute("""create table if not exists
user(
name text primary key,
hash text)""")
curs.execute("""create table if not exists
xuser(
name text primary key,
hash text)""")
def row_to_model(row: tuple) -> User:
name, hash = row
return User(name=name, hash=hash)
def model_to_dict(user: User) -> dict:
return user.dict()
def get_one(name: str) -> User:
qry = "select * from user where name=:name"
params = {"name": name}
curs.execute(qry, params)
row = curs.fetchone()
if row:
return row_to_model(row)
else:
raise Missing(msg=f"User {name} not found")
def get_all() -> list[User]:
qry = "select * from user"
curs.execute(qry)
return [row_to_model(row) for row in curs.fetchall()]
def create(user: User, table:str = "user"):
"""Add <user> to user or xuser table"""
qry = f"""insert into {table}
(name, hash)
values
(:name, :hash)"""
params = model_to_dict(user)
try:
curs.execute(qry, params)
except IntegrityError:
raise Duplicate(msg=
f"{table}: user {user.name} already exists")
def modify(name: str, user: User) -> User:
qry = """update user set
name=:name, hash=:hash
where name=:name0"""
params = {
"name": user.name,
"hash": user.hash,
"name0": name}
curs.execute(qry, params)
if curs.rowcount == 1:
return get_one(user.name)
else:
raise Missing(msg=f"User {name} not found")
def delete(name: str) -> None:
"""Drop user with <name> from user table, add to xuser table"""
user = get_one(name)
qry = "delete from user where name = :name"
params = {"name": name}
curs.execute(qry, params)
if curs.rowcount != 1:
raise Missing(msg=f"User {name} not found")
create(user, table="xuser")
用户伪数据层
在排除数据库但需要一些用户数据的测试中使用了 示例 11-9 模块。
示例 11-9. 伪数据层:fake/user.py
from model.user import User
from error import Missing, Duplicate
# (no hashed password checking in this module)
fakes = [
User(name="kwijobo",
hash="abc"),
User(name="ermagerd",
hash="xyz"),
]
def find(name: str) -> User | None:
for e in fakes:
if e.name == name:
return e
return None
def check_missing(name: str):
if not find(name):
raise Missing(msg=f"Missing user {name}")
def check_duplicate(name: str):
if find(name):
raise Duplicate(msg=f"Duplicate user {name}")
def get_all() -> list[User]:
"""Return all users"""
return fakes
def get_one(name: str) -> User:
"""Return one user"""
check_missing(name)
return find(name)
def create(user: User) -> User:
"""Add a user"""
check_duplicate(user.name)
return user
def modify(name: str, user: User) -> User:
"""Partially modify a user"""
check_missing(name)
return user
def delete(name: str) -> None:
"""Delete a user"""
check_missing(name)
return None
用户服务层
示例 11-10 定义了用户的服务层。与其他服务层模块的不同之处在于增加了 OAuth2 和 JWT 函数。我认为将它们放在这里比放在 Web 层中更清晰,尽管一些 OAuth2 Web 层函数位于即将到来的 web/user.py 中。
CRUD 函数目前仍然是传递功能,但将来可以根据需求进行调整。请注意,与生物和探险者服务类似,这种设计支持在运行时使用伪数据层或真实数据层访问用户数据。
示例 11-10. 服务层:service/user.py
from datetime import timedelta, datetime
import os
from jose import jwt
from model.user import User
if os.getenv("CRYPTID_UNIT_TEST"):
from fake import user as data
else:
from data import user as data
# --- New auth stuff
from passlib.context import CryptContext
# Change SECRET_KEY for production!
SECRET_KEY = "keep-it-secret-keep-it-safe"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hash: str) -> bool:
"""Hash <plain> and compare with <hash> from the database"""
return pwd_context.verify(plain, hash)
def get_hash(plain: str) -> str:
"""Return the hash of a <plain> string"""
return pwd_context.hash(plain)
def get_jwt_username(token:str) -> str | None:
"""Return username from JWT access <token>"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if not (username := payload.get("sub")):
return None
except jwt.JWTError:
return None
return username
def get_current_user(token: str) -> User | None:
"""Decode an OAuth access <token> and return the User"""
if not (username := get_jwt_username(token)):
return None
if (user := lookup_user(username)):
return user
return None
def lookup_user(username: str) -> User | None:
"""Return a matching User from the database for <name>"""
if (user := data.get(username)):
return user
return None
def auth_user(name: str, plain: str) -> User | None:
"""Authenticate user <name> and <plain> password"""
if not (user := lookup_user(name)):
return None
if not verify_password(plain, user.hash):
return None
return user
def create_access_token(data: dict,
expires: timedelta | None = None
):
"""Return a JWT access token"""
src = data.copy()
now = datetime.utcnow()
if not expires:
expires = timedelta(minutes=15)
src.update({"exp": now + expires})
encoded_jwt = jwt.encode(src, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
# --- CRUD passthrough stuff
def get_all() -> list[User]:
return data.get_all()
def get_one(name) -> User:
return data.get_one(name)
def create(user: User) -> User:
return data.create(user)
def modify(name: str, user: User) -> User:
return data.modify(name, user)
def delete(name: str) -> None:
return data.delete(name)
用户 Web 层
示例 11-11 在 Web 层中定义了基础用户模块。它使用了来自 service/user.py 模块中 示例 11-10 的新授权代码。
示例 11-11. Web 层:web/user.py
import os
from fastapi import APIRouter, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from model.user import User
if os.getenv("CRYPTID_UNIT_TEST"):
from fake import user as service
else:
from service import user as service
from error import Missing, Duplicate
ACCESS_TOKEN_EXPIRE_MINUTES = 30
router = APIRouter(prefix = "/user")
# --- new auth stuff
# This dependency makes a post to "/user/token"
# (from a form containing a username and password)
# and returns an access token.
oauth2_dep = OAuth2PasswordBearer(tokenUrl="token")
def unauthed():
raise HTTPException(
status_code=401,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# This endpoint is directed to by any call that has the
# oauth2_dep() dependency:
@router.post("/token")
async def create_access_token(
form_data: OAuth2PasswordRequestForm = Depends()
):
"""Get username and password from OAuth form,
return access token"""
user = service.auth_user(form_data.username, form_data.password)
if not user:
unauthed()
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = service.create_access_token(
data={"sub": user.username}, expires=expires
)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/token")
def get_access_token(token: str = Depends(oauth2_dep)) -> dict:
"""Return the current access token"""
return {"token": token}
# --- previous CRUD stuff
@router.get("/")
def get_all() -> list[User]:
return service.get_all()
@router.get("/{name}")
def get_one(name) -> User:
try:
return service.get_one(name)
except Missing as exc:
raise HTTPException(status_code=404, detail=exc.msg)
@router.post("/", status_code=201)
def create(user: User) -> User:
try:
return service.create(user)
except Duplicate as exc:
raise HTTPException(status_code=409, detail=exc.msg)
@router.patch("/")
def modify(name: str, user: User) -> User:
try:
return service.modify(name, user)
except Missing as exc:
raise HTTPException(status_code=404, detail=exc.msg)
@router.delete("/{name}")
def delete(name: str) -> None:
try:
return service.delete(name)
except Missing as exc:
raise HTTPException(status_code=404, detail=exc.msg)
测试!
对于这个新用户组件的单元测试和完整测试与你已经看过的生物和探险者的测试类似。与其在这里使用墨水和纸张,不如在本书附带的网站上查看它们。¹
顶层
前一节为以 /user 开头的 URL 定义了一个新的 router 变量,所以 示例 11-12 添加了这个子路由器。
示例 11-12. 顶层:main.py
from fastapi import FastAPI
from web import explorer, creature, user
app = FastAPI()
app.include_router(explorer.router)
app.include_router(creature.router)
app.include_router(user.router)
当 Uvicorn 自动重载时,/user/… 终端现在应该是可用的。
那真是有趣,按一种拉伸的定义来说是有趣的。考虑到刚刚创建的所有用户代码,让我们给它一些事情做。
认证步骤
下面是前面章节中大量代码的回顾:
-
如果一个端点依赖于
oauth2_dep()(在 web/user.py 中),则会生成一个包含用户名和密码字段的表单,并发送给客户端。 -
在客户端填写并提交此表单后,用户名和密码(与本地数据库中已存储的相同算法的哈希值)将与本地数据库匹配。
-
如果匹配成功,将生成一个访问令牌(JWT 格式)并返回。
-
此访问令牌作为
AuthorizationHTTP 头传回 Web 服务器。此 JWT 令牌在本地服务器上被解码为用户名和其他详细信息。这个名称不需要再次在数据库中查找。 -
用户名已经过认证,服务器可以对其进行任意操作。
服务器可以如何处理这些辛苦获得的认证信息?服务器可以执行以下操作:
-
生成指标(此用户、此端点、此时段),以帮助研究谁查看了什么内容,持续多长时间等等。
-
保存用户特定信息。
JWT
此部分包含 JWT 的一些详细信息。在本章中,您实际上不需要这些信息来使用所有之前的代码,但如果您有点好奇……
JWT是一种编码方案,而不是一种认证方法。其低级细节在RFC 7519中有定义。它可用于传达 OAuth2(以及其他方法)的认证信息,在这里我将展示出来。
JWT 是一个可读的字符串,由三部分点分隔而成:
-
Header:使用的加密算法和令牌类型
-
Payload:……
-
Signature:……
每个部分由一个 JSON 字符串组成,以Base 64 URL格式编码。这里有一个示例(已在点处拆分以适应本页):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
作为纯 ASCII 字符串,它也可以安全地用作 URL 的一部分、查询参数、HTTP 头、Cookie 等,传递给 Web 服务器。
JWT 避免了数据库查找,但这也意味着您无法直接检测到已撤销的授权。
第三方认证:OIDC
您经常会看到一些网站,让您使用 ID 和密码登录,或者让您通过不同网站的账户登录,比如 Google、Facebook/Meta、LinkedIn 或许多其他网站。这些通常使用一个称为OpenID Connect (OIDC)的标准,它是建立在 OAuth2 之上的。当您连接到外部支持 OIDC 的站点时,您将收到一个 OAuth2 访问令牌(如本章的示例中所示),还会收到一个ID 令牌。
官方的 FastAPI 文档不包含与 OIDC 集成的示例代码。如果您想尝试,一些第三方包(特定于 FastAPI 和更通用的)将比自行实现节省时间:
FastAPI Repo Issues 页面包含多个代码示例,以及来自 tiangelo(Sebastián Ramírez)的评论,未来 FastAPI OIDC 示例将包含在官方文档和教程中。
授权
认证处理who(身份),授权处理what:您允许访问哪些资源(Web 端点)以及以何种方式?who和what的组合数量可能很大。
在本书中,探险者和生物一直是主要资源。查找探险者或列出它们通常比添加或修改现有资源更“开放”。如果网站应该是一种可靠的接口到某些数据,写访问应该比读访问受到更严格的限制。因为,唉,人们。
如果每个端点完全开放,您不需要授权,可以跳过此部分。最简单的授权可能是一个简单的布尔值(这个用户是否是管理员?);对于本书中的示例,您可能需要管理员授权来添加、删除或修改探险者或生物。如果您的数据库条目很多,您可能还希望为非管理员限制get_all()函数的进一步权限。随着网站变得更加复杂,权限可能变得更加细粒度化。
让我们看一下授权案例的进展。我们将使用User表,其中name可以是电子邮件、用户名或 API 密钥;“配对”表是关系数据库匹配两个单独表条目的方式。
-
如果您只想跟踪管理员访问者并让其他人匿名:
- 使用
Admin表进行经过身份验证的用户名。您可以从Admin表中查找名称,并且如果匹配,则与User表中的哈希密码进行比较。
- 使用
-
如果所有访问者都应该经过身份验证,但您只需为某些端点授权管理员:
- 与之前示例中的每个人进行身份验证(来自
User表),然后检查Admin表以查看此用户是否也是管理员。
- 与之前示例中的每个人进行身份验证(来自
-
对于多种类型的权限(如只读、读取、写入):
-
使用
Permission定义表。 -
使用
UserPermission表对用户和权限进行配对。这有时被称为访问控制列表(ACL)。
-
-
如果权限组合复杂,添加一个级别并定义角色(独立的权限集合):
-
创建一个
Role表。 -
创建一个
UserRole表,配对User和Role条目。这有时被称为基于角色的访问控制(RBAC)。
-
中间件。
FastAPI 允许在 Web 层插入代码执行以下操作:
-
拦截请求。
-
处理请求的某些操作。
-
将请求传递给路径函数。
-
拦截由补丁函数返回的响应。
-
对响应执行某些操作。
-
返回响应给调用者。
它类似于 Python 装饰器对其“包装”的函数所做的事情。
在某些情况下,您可以使用中间件或依赖注入与Depends()。中间件更方便处理像 CORS 这样的全局安全问题,这也提出了...
跨源资源共享(CORS)。
跨源资源共享(CORS)涉及与其他受信任的服务器和您的网站之间的通信。如果您的站点将所有前端和后端代码放在一个地方,那就没问题了。但是现在,将 JavaScript 前端与 FastAPI 等后端进行通信已经很普遍了。这些服务器将不具有相同的源:
协议
http 或 https
域名
互联网域名,比如 google.com 或 localhost
端口
在该域上的数字 TCP/IP 端口,比如 80、443 或 8000
后端如何知道可信任的前端和一个充满霉菌的萝卜箱或一个胡子拨弄的攻击者之间的区别?这是 CORS 的工作,它指定了后端信任的内容,最重要的是以下内容:
-
来源
-
HTTP 方法
-
HTTP 头部
-
CORS 缓存超时
你在 Web 层级上连接到 CORS。示例 11-13 展示了如何允许只有一个前端服务器(具有域名 *ui.cryptids.com*)以及任何 HTTP 头部或方法。
示例 11-13. 激活 CORS 中间件。
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://ui.cryptids.com",],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/test_cors")
def test_cors(request: Request):
print(request)
一旦完成,任何试图直接联系您的后端站点的其他域都将被拒绝。
第三方包
你现在已经阅读了如何使用 FastAPI 编写身份验证和授权解决方案的示例。但也许你不需要自己做所有的事情。FastAPI 生态系统发展迅速,可能已经有可用的包来为你做大部分工作了。
以下是一些未经测试的示例。不能保证此列表中的任何包在未来仍然存在并得到支持,但可能值得一看:
-
FastAPI Users 是 FastAPI 的用户管理模块。
-
FastAPI JWT Auth 是一个与 FastAPI 集成的 JWT 认证模块。
-
FastAPI-Login 是一个用于 FastAPI 的登录认证模块。
-
FastAPI-User-Auth 是 FastAPI 的用户认证模块。
-
FastAPI Auth Middleware 是 FastAPI 的认证中间件。
-
fastapi-jwt 是 FastAPI 的 JWT 认证模块。
-
fastapi-sso 是一个 FastAPI 单点登录(SSO)模块。
回顾
这是一个比大多数更重的章节。它展示了你可以对访问者进行身份验证并授权他们执行某些操作的方式。这是 Web 安全的两个方面。该章还讨论了 CORS,另一个重要的 Web 安全主题。
¹ 如果我按行收费,命运可能会干涉。
第十二章:测试
一个 QA 工程师走进一家酒吧。点了一杯啤酒。点了 0 杯啤酒。点了 99999999999 杯啤酒。点了一只蜥蜴。点了 -1 杯啤酒。点了一个 ueicbksjdhd。
第一个真正的客户走进来问洗手间在哪里。酒吧突然起火,所有人都死了。
Brenan Keller,Twitter
预览
本章讨论了在 FastAPI 站点上进行的各种测试类型:单元,集成 和 完整。它以 pytest 和自动化测试开发为特色。
Web API 测试
您已经看到几个手动 API 测试工具,因为已经添加了端点:
-
HTTPie
-
请求
-
HTTPX
-
Web 浏览器
还有许多其他测试工具可用:
-
Curl 是非常有名的,尽管在本书中我使用了更简单语法的 HTTPie。
-
Httpbin,由 Requests 的作者编写,是一个提供许多视图来查看您的 HTTP 请求的免费测试服务器。
-
Postman 是一个完整的 API 测试平台。
-
Chrome DevTools 是 Chrome 浏览器的一个丰富工具集。
这些都可以用于完整(端到端)测试,例如您在前几章中看到的那些。那些手动测试在代码刚敲完后迅速验证是非常有用的。
但是如果您稍后进行的更改破坏了早期的某些手动测试(回归)怎么办?您不想在每次代码更改后重新运行数十个测试。这时自动化测试变得重要。本章的其余部分将重点介绍这些内容,并讲解如何使用 pytest 构建它们。
测试位置
我已经提到了各种测试:
单元
在一个层内,测试单个函数
集成
跨层测试连接性
完整
测试完整的 API 和其下的堆栈
有时这些被称为测试金字塔,宽度表示每个组中应该有的相对测试数量(图 12-1)。
图 12-1。测试金字塔
什么要测试
在编写代码时,您应该测试什么?基本上,对于给定的输入,请确认您得到了正确的输出。您可以检查以下内容:
-
缺少输入
-
重复输入
-
不正确的输入类型
-
不正确的输入顺序
-
无效的输入值
-
大输入或输出
错误可能发生在任何地方:
Web 层
Pydantic 将捕获模型不匹配,并返回 422 HTTP 状态码。
数据层
数据库将因为缺失或重复数据,以及 SQL 查询语法错误而引发异常。当一次传递大量数据结果而不是使用生成器或分页时,可能会发生超时或内存耗尽。
任何层
可能发生普通的错误和疏忽。
章节 8 到 10 包含了一些这样的测试:
-
完全手动测试,使用像 HTTPie 这样的工具
-
单元手动测试,作为 Python 片段
-
自动化测试,使用 pytest 脚本
接下来的几节将详细介绍 pytest。
Pytest
Python 一直拥有标准包 unittest。稍后的第三方包称为 nose,试图对其进行改进。现在大多数 Python 开发者更喜欢 pytest,它比这两者都更强大且更易于使用。它没有内置在 Python 中,因此如果没有安装,你需要运行 pip install pytest。此外,运行 pip install pytest-mock 可获取自动的 mocker 固定装置;稍后在本章中你将看到这一点。
pytest 提供了什么?其中包括以下不错的自动功能:
测试发现
Python 文件名中的测试前缀或测试后缀将自动运行。此文件名匹配会进入子目录,执行那里的所有测试。
断言失败的详细信息
一个失败的 assert 语句会打印预期的内容和实际发生的内容。
固定装置
这些函数可以为整个测试脚本运行一次,或者为每个测试运行一次(其作用域),为测试函数提供参数,如标准测试数据或数据库初始化。固定装置类似于依赖注入,就像 FastAPI 为 Web 路径函数提供的那样:特定的数据传递给通用的测试函数。
参数化
这为测试函数提供了多个测试数据。
布局
你应该把测试放在哪里?似乎没有广泛的一致意见,但这里有两种合理的设计:
-
顶层有一个 test 目录,其中包含被测试的代码区域的子目录(如 web、service 等)。
-
在每个代码目录下都有一个 test 目录(如 web、service 等)。
此外,在特定的子目录中(如 test/web),是否应该为不同的测试类型(如 unit、integration 和 full)创建更多目录?在本书中,我使用了这种层次结构:
test
├── unit
│ ├── web
│ ├── service
│ └── data
├── integration
└── full
各个测试脚本存放在底层目录中。这些在本章中。
自动化单元测试
单元测试应该检查一个事物,在一个层次内。通常意味着向函数传递参数,并断言应该返回什么。
单元测试要求对被测试的代码进行隔离。如果不这样做,你也在测试其他东西。那么,如何为单元测试隔离代码呢?
模拟
在本书的代码堆栈中,通过 Web API 访问 URL 通常会调用 Web 层中的函数,该函数调用服务层中的函数,后者调用数据层中的函数,后者访问数据库。结果通过链条向上流动,最终从 Web 层返回给调用者。
单元测试听起来很简单。对于代码库中的每个函数,传入测试参数并确认其返回预期值即可。这对于纯函数(仅接受输入参数并返回响应,不引用任何外部代码)非常有效。但大多数函数还会调用其他函数,那么如何控制这些其他函数的操作?那么这些来自外部来源的数据如何处理?最常见的外部因素是控制数据库访问,但实际上可以是任何东西。
一种方法是模拟每个外部函数调用。因为在 Python 中函数是一级对象,你可以用另一个函数替换一个函数。unittest 包有一个 mock 模块可以做到这一点。
许多开发者认为模拟是隔离单元测试的最佳方法。我将首先在这里展示模拟的例子,并且提出一个论点:模拟往往需要对代码如何运作有太多了解,而不是其结果。你可能会听到结构化测试(如模拟,其中被测试的代码非常可见)和行为测试(其中代码内部不需要)这些术语。
示例 12-1 和 12-2 定义了mod1.py 和 mod2.py 这两个模块。
示例 12-1. 被调用的模块(mod1.py)
def preamble() -> str:
return "The sum is "
示例 12-2. 被调用的模块(mod2.py)
import mod1
def summer(x: int, y:int) -> str:
return mod1.preamble() + f"{x+y}"
summer()函数计算其参数的和,并返回一个包含前文和总和的字符串。示例 12-3 是一个最小化的 pytest 脚本,用于验证summer()。
示例 12-3. Pytest 脚本 test_summer1.py
import mod2
def test_summer():
assert "The sum is 11" == mod2.summer(5,6)
示例 12-4 成功运行了测试。
示例 12-4. 运行 pytest 脚本
$ pytest -q test_summer1.py
. [100%]
1 passed in 0.04s
(-q 可以安静地运行测试,不会输出大量额外的细节。)好的,测试通过了。但summer()函数从preamble函数中得到了一些文本。如果我们只是想测试加法是否成功呢?
我们可以编写一个新函数,它只返回两个数字的字符串化的和,然后重写summer()函数将其返回附加到preamble()字符串中。
或者,我们可以模拟preamble()以消除其效果,正如在示例 12-5 中展示的多种方式那样。
示例 12-5. Pytest 中的模拟(test_summer2.py)
from unittest import mock
import mod1
import mod2
def test_summer_a():
with mock.patch("mod1.preamble", return_value=""):
assert "11" == mod2.summer(5,6)
def test_summer_b():
with mock.patch("mod1.preamble") as mock_preamble:
mock_preamble.return_value=""
assert "11" == mod2.summer(5,6)
@mock.patch("mod1.preamble", return_value="")
def test_summer_c(mock_preamble):
assert "11" == mod2.summer(5,6)
@mock.patch("mod1.preamble")
def test_caller_d(mock_preamble):
mock_preamble.return_value = ""
assert "11" == mod2.summer(5,6)
这些测试显示,模拟对象可以通过多种方式创建。test_caller_a()函数使用mock.patch()作为 Python 的上下文管理器(使用with语句)。其参数列在此处:
"mod1.preamble"
在模块mod1中,preamble()函数的完整字符串名称。
return_value=""
使得这个模拟版本返回一个空字符串。
test_caller_b()函数几乎相同,但在下一行添加了as mock_preamble来使用模拟对象。
test_caller_c()函数使用 Python 的装饰器定义了模拟对象。模拟对象作为参数传递给了test_caller2()。
test_caller_d()函数类似于test_caller_b(),但在对mock_preamble进行单独调用设置return_value时添加了as mock_preamble。
在每种情况下,要模拟的事物的字符串名称必须与在正在测试的代码中调用它的方式匹配——在本例中是summer()。模拟库将该字符串名称转换为一个变量,该变量将拦截对原始具有该名称的任何变量的引用。(请记住,在 Python 中,变量只是对真实对象的引用。)
当运行示例 12-6 时,在所有四个summer()测试函数中,当调用summer(5,6)时,替代的变身模拟preamble()被调用而不是真正的函数。模拟版本会丢弃该字符串,因此测试可以确保summer()返回其两个参数的字符串版本的和。
示例 12-6. 运行模拟的 pytest
$ pytest -q test_summer2.py
.... [100%]
4 passed in 0.13s
注意
那是一个刻意的案例,为了简单起见。模拟可以非常复杂;查看像 “Understanding the Python Mock Object Library” by Alex Ronquillo 这样的文章,以及官方的 Python 文档 获取详细信息。
测试 Doubles 和 Fakes
要执行该模拟,您需要知道summer()函数从模块mod1导入了函数preamble()。这是一个结构测试,需要了解特定变量和模块名称。
是否有一种不需要这样做的行为测试方法?
一种方法是定义一个 double:在测试中执行我们希望的操作的独立代码——在本例中,使preamble()返回一个空字符串。其中一种方法是使用导入。在接下来的三个部分的层中使用它进行单元测试之前,首先将其应用于此示例。
首先,在示例 12-7 中重新定义 mod2.py。
示例 12-7. 如果进行单元测试,使 mod2.py 导入一个 double
import os
if os.get_env("UNIT_TEST"):
import fake_mod1 as mod1
else:
import mod1
def summer(x: int, y:int) -> str:
return mod1.preamble() + f"{x+y}"
示例 12-8 定义了双重模块 fake_mod1.py。
示例 12-8. 双重 fake_mod1.py
def preamble() -> str:
return ""
而示例 12-9 是测试。
示例 12-9. 测试脚本 test_summer_fake.py
import os
os.environ["UNIT_TEST"] = "true"
import mod2
def test_summer_fake():
assert "11" == mod2.summer(5,6)
….运行示例 12-10。
示例 12-10. 运行新的单元测试
$ pytest -q test_summer_fake.py
. [100%]
1 passed in 0.04s
这种导入切换方法确实需要添加一个环境变量的检查,但避免了必须为函数调用编写特定的模拟。您可以自行决定喜欢哪种方法。接下来的几节将使用import方法,这与我在定义代码层时使用的 fake 包非常匹配。
总结一下,这些示例将preamble()替换为测试脚本中的模拟,或者导入了一个 double。您可以以其他方式隔离被测试的代码,但这些方法工作起来并不像 Google 可能为您找到的其他方法那么棘手。
Web
该层实现了站点的 API。理想情况下,每个路径函数(终点)都应至少有一个测试——也许更多,如果该函数可能以多种方式失败。在 Web 层,您通常希望查看端点是否存在,是否能够使用正确的参数工作,并返回正确的状态代码和数据。
注意
这些都是浅层 API 测试,仅在 Web 层内部进行测试。因此,需要拦截服务层调用(这些调用将进一步调用数据层和数据库),以及任何其他退出 Web 层的调用。
使用上一节的 import 思想,使用环境变量 CRYPTID_UNIT_TEST 将 fake 包作为 service 导入,而不是真正的 service。这样一来,Web 函数不再调用 Service 函数,而是直接绕过它们到达 fake(双重)版本。然后,较低的数据层和数据库也不参与其中。我们得到了想要的单元测试。例子 12-11 有修改后的 web/creature.py 文件。
例子 12-11. 修改后的 web/creature.py
import os
from fastapi import APIRouter, HTTPException
from model.creature import Creature
if os.getenv("CRYPTID_UNIT_TEST"):
from fake import creature as service
else:
from service import creature as service
from error import Missing, Duplicate
router = APIRouter(prefix = "/creature")
@router.get("/")
def get_all() -> list[Creature]:
return service.get_all()
@router.get("/{name}")
def get_one(name) -> Creature:
try:
return service.get_one(name)
except Missing as exc:
raise HTTPException(status_code=404, detail=exc.msg)
@router.post("/", status_code=201)
def create(creature: Creature) -> Creature:
try:
return service.create(creature)
except Duplicate as exc:
raise HTTPException(status_code=409, detail=exc.msg)
@router.patch("/")
def modify(name: str, creature: Creature) -> Creature:
try:
return service.modify(name, creature)
except Missing as exc:
raise HTTPException(status_code=404, detail=exc.msg)
@router.delete("/{name}")
def delete(name: str) -> None:
try:
return service.delete(name)
except Missing as exc:
raise HTTPException(status_code=404, detail=exc.msg)
例子 12-12 有两个 pytest fixtures 的测试:
sample()
一个新的 Creature 对象
fakes()
一个“现有”生物列表
假物是从一个更低层次的模块中获取的。通过设置环境变量 CRYPTID_UNIT_TEST,Web 模块从 例子 12-11 导入假服务版本(提供虚假数据而不调用数据库),而不是真实的版本。这隔离了测试,这正是它的目的。
例子 12-12. 用于生物的 Web 单元测试,使用 fixtures
from fastapi import HTTPException
import pytest
import os
os.environ["CRYPTID_UNIT_TEST"] = "true"
from model.creature import Creature
from web import creature
@pytest.fixture
def sample() -> Creature:
return Creature(name="dragon",
description="Wings! Fire! Aieee!",
country="*")
@pytest.fixture
def fakes() -> list[Creature]:
return creature.get_all()
def assert_duplicate(exc):
assert exc.value.status_code == 404
assert "Duplicate" in exc.value.msg
def assert_missing(exc):
assert exc.value.status_code == 404
assert "Missing" in exc.value.msg
def test_create(sample):
assert creature.create(sample) == sample
def test_create_duplicate(fakes):
with pytest.raises(HTTPException) as exc:
_ = creature.create(fakes[0])
assert_duplicate(exc)
def test_get_one(fakes):
assert creature.get_one(fakes[0].name) == fakes[0]
def test_get_one_missing():
with pytest.raises(HTTPException) as exc:
_ = creature.get_one("bobcat")
assert_missing(exc)
def test_modify(fakes):
assert creature.modify(fakes[0].name, fakes[0]) == fakes[0]
def test_modify_missing(sample):
with pytest.raises(HTTPException) as exc:
_ = creature.modify(sample.name, sample)
assert_missing(exc)
def test_delete(fakes):
assert creature.delete(fakes[0].name) is None
def test_delete_missing(sample):
with pytest.raises(HTTPException) as exc:
_ = creature.delete("emu")
assert_missing(exc)
服务
从某种角度来看,服务层是重要的一层,可以连接到不同的 Web 和数据层。例子 12-13 类似于 例子 12-11,主要区别在于 import 和对较低级数据模块的使用。它还没有捕捉到可能来自数据层的任何异常,这些异常留待 Web 层处理。
例子 12-13. 修改后的 service/creature.py
import os
from model.creature import Creature
if os.getenv("CRYPTID_UNIT_TEST"):
from fake import creature as data
else:
from data import creature as data
def get_all() -> list[Creature]:
return data.get_all()
def get_one(name) -> Creature:
return data.get_one(name)
def create(creature: Creature) -> Creature:
return data.create(creature)
def modify(name: str, creature: Creature) -> Creature:
return data.modify(name, creature)
def delete(name: str) -> None:
return data.delete(name)
例子 12-14 有相应的单元测试。
例子 12-14. 在 test/unit/service/test_creature.py 中的服务测试
import os
os.environ["CRYPTID_UNIT_TEST"]= "true"
import pytest
from model.creature import Creature
from error import Missing, Duplicate
from data import creature as data
@pytest.fixture
def sample() -> Creature:
return Creature(name="yeti",
aka:"Abominable Snowman",
country="CN",
area="Himalayas",
description="Handsome Himalayan")
def test_create(sample):
resp = data.create(sample)
assert resp == sample
def test_create_duplicate(sample):
resp = data.create(sample)
assert resp == sample
with pytest.raises(Duplicate):
resp = data.create(sample)
def test_get_exists(sample):
resp = data.create(sample)
assert resp == sample
resp = data.get_one(sample.name)
assert resp == sample
def test_get_missing():
with pytest.raises(Missing):
_ = data.get_one("boxturtle")
def test_modify(sample):
sample.country = "CA" # Canada!
resp = data.modify(sample.name, sample)
assert resp == sample
def test_modify_missing():
bob: Creature = Creature(name="bob", country="US", area="*",
description="some guy", aka="??")
with pytest.raises(Missing):
_ = data.modify(bob.name, bob)
数据
数据层更容易在隔离环境中进行测试,因为不必担心意外调用更低层的函数。单元测试应覆盖该层中的函数以及它们使用的具体数据库查询。到目前为止,SQLite 一直是数据库“服务器”,SQL 是查询语言。但正如我在 第十四章 中提到的,您可能决定使用像 SQLAlchemy 这样的包,并使用其 SQLAlchemy 表达语言或其 ORM。那么这些需要完整的测试。到目前为止,我一直保持在最低层次:Python 的 DB-API 和原始的 SQL 查询。
与 Web 和服务单元测试不同,这次我们不需要“fake”模块来替换现有的数据层模块。相反,设置一个不同的环境变量,使数据层使用内存中的 SQLite 实例而不是基于文件的实例。这不需要对现有的数据模块进行任何更改,只需在 例子 12-15 的测试 之前 设置。
例子 12-15. 数据单元测试 test/unit/data/test_creature.py
import os
import pytest
from model.creature import Creature
from error import Missing, Duplicate
# set this before data import below
os.environ["CRYPTID_SQLITE_DB"] = ":memory:"
from data import creature
@pytest.fixture
def sample() -> Creature:
return Creature(name="yeti",
aka="Abominable Snowman",
country="CN",
area="Himalayas",
description="Hapless Himalayan")
def test_create(sample):
resp = creature.create(sample)
assert resp == sample
def test_create_duplicate(sample):
with pytest.raises(Duplicate):
_ = creature.create(sample)
def test_get_one(sample):
resp = creature.get_one(sample.name)
assert resp == sample
def test_get_one_missing():
with pytest.raises(Missing):
resp = creature.get_one("boxturtle")
def test_modify(sample):
creature.country = "JP" # Japan!
resp = creature.modify(sample.name, sample)
assert resp == sample
def test_modify_missing():
thing: Creature = Creature(name="snurfle",
description="some thing", country="somewhere")
with pytest.raises(Missing):
_ = creature.modify(thing.name, thing)
def test_delete(sample):
resp = creature.delete(sample.name)
assert resp is None
def test_delete_missing(sample):
with pytest.raises(Missing):
_ = creature.delete(sample.name)
自动化集成测试
集成测试展示了不同代码在层之间交互的良好程度。但是如果你寻找这方面的例子,你会得到许多不同的答案。你是否应该测试像 Web → Service、Web → Data 等部分调用轨迹的例子呢?
要完全测试 A → B → C 管道中的每个连接,您需要测试以下内容:
-
A → B
-
B → C
-
A → C
如果您有更多的三个连接点,箭头将填充箭袋。
或者集成测试应该基本上是完整的测试,但最后一部分——磁盘上的数据存储——被模拟了吗?
到目前为止,你一直在使用 SQLite 作为数据库,你可以使用内存中的 SQLite 作为磁盘上 SQLite 数据库的双倍(假)模拟。如果您的查询非常标准的 SQL,SQLite-in-memory 可能是其他数据库的足够模拟。如果不是,这些模块专门用于模拟特定的数据库:
PostgreSQL
MongoDB
许多
Pytest Mock Resources 在 Docker 容器中启动各种测试数据库,并与 pytest 集成。
最后,您可以只需启动与生产相同类型的测试数据库。环境变量可以包含具体信息,就像您一直在使用的单元测试/假技巧一样。
仓储模式
尽管我没有在本书中实现它,但仓储模式是一个有趣的方法。仓储 是一个简单的内存中间数据存储器——就像你到目前为止在这里看到的假数据层一样。然后它与真实数据库的可插拔后端进行通信。它伴随着 工作单元 模式,它确保单个 会话 中的一组操作要么全部提交,要么全部回滚。
到目前为止,本书中的数据库查询都是原子性的。对于真实世界的数据库工作,您可能需要多步查询,并进行某种形式的会话处理。仓储模式还与依赖注入结合,这是您在本书的其他地方已经看到并且现在可能已经有点欣赏了。
自动化完整测试
完整的测试将所有层次一起练习,尽可能接近生产使用。这本书中大多数你已经看过的测试都是完整的:调用 Web 端点,通过 Servicetown 到市中心 Dataville,然后返回带有杂货。这些是封闭的测试。一切都是真实的,你不关心它是如何做到的,只关心它是否做到了。
您可以通过两种方式完全测试整体 API 中的每个端点:
通过 HTTP/HTTPS
编写独立的 Python 测试客户端来访问服务器。本书中的许多示例都已经这样做了,使用像 HTTPie 这样的独立客户端,或者在使用 Requests 的脚本中。
使用 TestClient
使用这个内置的 FastAPI/Starlette 对象直接访问服务器,而无需显式的 TCP 连接。
但这些方法需要为每个端点编写一个或多个测试。这可能有点过时,我们现在已经过了几个世纪。一个更近代的方法基于 基于属性的测试。它利用 FastAPI 自动生成的文档。每次在 Web 层更改路径函数或路径装饰器时,FastAPI 都会创建一个名为 openapi.json 的 OpenAPI 模式。此模式详细描述了每个端点的所有内容:参数、返回值等等。这就是 OpenAPI 的作用,正如OpenAPI Initiative’s FAQ page所述:
OAS 定义了一个标准的、与编程语言无关的接口描述,用于 REST API,允许人类和计算机在不需要访问源代码、额外文档或检查网络流量的情况下发现和理解服务的能力。
OAS(OpenAPI 规范)
需要两个包:
pip install hypothesis
pip install schemathesis
假设是基础库,而 Schemathesis 则将其应用于 FastAPI 生成的 OpenAPI 3.0 模式。运行 Schemathesis 读取此模式,使用不同的数据生成大量测试(无需自行生成!),并与 pytest 协同工作。
为了简洁起见,示例 12-16 首先将 main.py 精简到其基础的 creature 和 explorer 端点。
示例 12-16. 简化后的 main.py
from fastapi import FastAPI
from web import explorer, creature
app = FastAPI()
app.include_router(explorer.router)
app.include_router(creature.router)
示例 12-17 运行测试。
示例 12-17. 运行 Schemathesis 测试
$ schemathesis http://localhost:8000/openapi.json
===================== Schemathesis test session starts =====================
Schema location: http://localhost:8000/openapi.json
Base URL: http://localhost:8000/
Specification version: Open API 3.0.2
Workers: 1
Collected API operations: 12
GET /explorer/ . [ 8%]
POST /explorer/ . [ 16%]
PATCH /explorer/ F [ 25%]
GET /explorer . [ 33%]
POST /explorer . [ 41%]
GET /explorer/{name} . [ 50%]
DELETE /explorer/{name} . [ 58%]
GET /creature/ . [ 66%]
POST /creature/ . [ 75%]
PATCH /creature/ F [ 83%]
GET /creature/{name} . [ 91%]
DELETE /creature/{name} . [100%]
我得到两个 F,都是在 PATCH 调用中 (modify() 函数)。真尴尬。
这个输出部分后面是一个标记为 FAILURES 的部分,显示任何失败测试的详细堆栈跟踪。这些需要修复。最后一个部分标记为 SUMMARY:
Performed checks:
not_a_server_error 717 / 727 passed FAILED
Hint: You can visualize test results in Schemathesis.io
by using `--report` in your CLI command.
这真是太快了,每个端点并不需要多个测试,只需想象可能导致它们出错的输入即可。基于属性的测试从 API 模式中读取输入参数的类型和约束,并为每个端点生成一系列值。
这是类型提示的又一个意想不到的好处,最初它们似乎只是一些美好的东西:
- 类型提示 → OpenAPI 模式 → 生成的文档 和 测试
安全测试
安全不是单一的事物,而是一切。你需要防范恶意行为,也要防止简单的错误,甚至是无法控制的事件。让我们把扩展性问题推迟到下一节,主要在这里分析潜在威胁。
第十一章 讨论了认证和授权。这些因素总是混乱且容易出错。诱人的是使用聪明的方法来对抗聪明的攻击,设计易于理解和实施的保护措施总是一种挑战。
但现在您已了解了 Schemathesis,请阅读其关于身份验证基于属性的测试的文档。就像它极大地简化了大多数 API 的测试一样,它可以自动化需要身份验证的端点的大部分测试。
负载测试
负载测试展示您的应用程序如何处理大量流量:
-
API 调用
-
数据库读取或写入
-
内存使用
-
磁盘使用
-
网络延迟和带宽
一些可以是完整测试,模拟一群用户争先使用您的服务;在那一天到来之前,您需要做好准备。本节内容与“性能”和“故障排除”中的内容有所重叠。
有很多优秀的负载测试工具,但我在这里推荐Locust。使用 Locust,您可以用简单的 Python 脚本定义所有测试。它可以模拟数十万用户同时访问您的网站,甚至多台服务器。
使用pip install locust在本地安装它。您的第一个测试可能是您的网站可以处理多少并发访问者。这就像在面对飓风/地震/暴风雪或其他家庭保险事件时测试建筑物能承受多少极端天气。因此,您需要一些网站结构测试。查看 Locust 的文档获取所有详细信息。
但正如电视上所说的,这还不是全部!最近,Grasshopper扩展了 Locust,可以测量跨多个 HTTP 调用的时间等功能。要尝试此扩展,请使用pip install locust-grasshopper进行安装。
回顾
本章详细介绍了各种测试类型,并举例说明了 pytest 在单元测试、集成测试和完整测试中的自动化代码测试。API 测试可以使用 Schemathesis 进行自动化。本章还讨论了如何在问题出现之前暴露安全性和性能问题。
第十三章:生产
如果建筑工人建造建筑物的方式就像程序员编写程序一样,那么第一个啄木鸟就会毁掉文明。
杰拉尔德·温伯格,计算机科学家
预览
你在本地机器上运行着一个应用程序,现在你想要分享它。本章介绍了许多场景,说明了如何将你的应用程序移动到生产环境,并确保它正确高效地运行。由于一些细节可能非常详细,在某些情况下我会参考一些有用的外部文档,而不是在这里堆砌它们。
部署
迄今为止,本书中的所有代码示例都使用了在 localhost 的端口 8000 上运行的单个 uvicorn 实例。为了处理大量流量,你需要多个服务器,运行在现代硬件提供的多个核心上。你还需要在这些服务器之上添加一些东西来执行以下操作:
-
使它们保持运行(监督者)
-
收集和提供外部请求(反向代理)
-
返回响应
-
提供 HTTPS 终止(SSL 解密)
多个工作进程
你可能见过另一个名为 Gunicorn 的 Python 服务器。这个服务器可以监视多个工作进程,但它是一个 WSGI 服务器,而 FastAPI 是基于 ASGI 的。幸运的是,有一个特殊的 Uvicorn 工作进程类可以由 Gunicorn 管理。
示例 13-1 在 localhost 的端口 8000 上设置了这些 Uvicorn 工作进程(这是从 官方文档 改编的)。引号保护 shell 免受任何特殊解释。
示例 13-1。使用 Gunicorn 和 Uvicorn 工作进程
$ pip install "uvicorn[standard]" gunicorn
$ gunicorn main:app --workers 4 --worker-class \
uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
当 Gunicorn 执行你的命令时,你会看到许多行。它将启动一个顶级 Gunicorn 进程,与四个 Uvicorn 工作进程子进程交流,所有进程共享 localhost (0.0.0.0) 上的端口 8000。如果你想要其他内容,可以更改主机、端口或工作进程数。main:app 指的是 main.py 和带有变量名 app 的 FastAPI 对象。Gunicorn 的文档宣称如下:
Gunicorn 只需要 4-12 个工作进程来处理每秒数百或数千个请求。
结果发现 Uvicorn 本身也可以启动多个 Uvicorn 工作进程,就像 示例 13-2 中一样。
示例 13-2。使用 Uvicorn 和 Uvicorn 工作进程
$ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
但这种方法不涉及进程管理,因此通常更喜欢 gunicorn 方法。其他进程管理器也适用于 Uvicorn:参见其官方文档。
这处理了前一节提到的四项工作中的三项,但不包括 HTTPS 加密。
HTTPS
官方 FastAPI HTTPS 文档,就像所有官方 FastAPI 文档一样,都非常丰富。我建议先阅读它们,然后再阅读 Ramírez 的描述,了解如何通过使用 Traefik 向 FastAPI 添加 HTTPS 支持。Traefik 位于你的 Web 服务器“之上”,类似于作为反向代理和负载均衡器的 nginx,但它包含了 HTTPS 魔法。
尽管这个过程有许多步骤,但比以前简单得多。特别是,以前你经常为数字证书向证书颁发机构支付高昂费用,以便为你的网站提供 HTTPS。幸运的是,这些机构大多被免费服务Let's Encrypt所取代。
Docker
当 Docker(在 2013 年 PyCon 的 Solomon Hykes 的五分钟闪电演讲中出现)时,大多数人第一次听说 Linux 容器。随着时间的推移,我们了解到 Docker 比虚拟机更快、更轻。每个容器不是模拟完整的操作系统,而是共享服务器的 Linux 内核,并将进程和网络隔离到自己的命名空间中。突然间,通过使用免费的 Docker 软件,你可以在单台机器上托管多个独立服务,而不必担心它们互相干扰。
十年后,Docker 已被普遍认可和支持。如果你想在云服务上托管你的 FastAPI 应用程序,通常需要首先创建它的Docker 镜像。官方 FastAPI 文档包含了如何构建你的 FastAPI 应用程序的 Docker 化版本的详细描述。其中一步是编写Dockerfile:一个包含 Docker 配置信息的文本文件,如要使用的应用程序代码和要运行的进程。只为证明这不是在火箭发射期间进行的脑外科手术,这里是来自该页面的 Dockerfile:
FROM python:3.9
WORKDIR /code
COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./app /code/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
我建议阅读官方文档,或者通过谷歌搜索fastapi docker得到的其他链接,比如“使用 Docker 部署你的应用程序的终极 FastAPI 教程第十三部分” by Christopher Samiullah。
云服务
在网上有许多付费或免费的主机来源。一些关于如何与它们一起托管 FastAPI 的指南包括以下内容:
Kubernetes
Kubernetes 起源于 Google 内部用于管理越来越复杂的内部系统的代码。当时的系统管理员(他们当时被称为)过去常常手动配置诸如负载均衡器、反向代理、湿度计¹等工具。Kubernetes 的目标是获取这些知识并自动化:不要告诉我如何处理这个问题;告诉我你想要什么。这包括保持服务运行或在流量激增时启动更多服务器等任务。
有很多关于如何在 Kubernetes 上部署 FastAPI 的描述,包括“在 Kubernetes 上部署 FastAPI 应用程序” by Sumanta Mukhopadhyay。
性能
FastAPI 的性能目前位于最高水平之一,甚至可以与像 Go 这样更快语言的框架相媲美。但其中很大一部分归功于 ASGI,通过异步避免 I/O 等待。Python 本身是一种相对较慢的语言。以下是一些提高整体性能的技巧和窍门。
异步
通常 Web 服务器不需要真正快。它大部分时间都在获取 HTTP 网络请求和返回结果(本书中的 Web 层)。在此期间,Web 服务执行业务逻辑(服务层)并访问数据源(数据层),再次花费大部分时间在网络 I/O 上。
每当 Web 服务中的代码需要等待响应时,使用异步函数(async def 而不是 def)是个不错的选择。这样可以让 FastAPI 和 Starlette 调度异步函数,在等待获取响应时做其他事情。这也是 FastAPI 的基准测试比基于 WSGI 的框架(如 Flask 和 Django)好的原因之一。
性能有两个方面:
-
处理单个请求所需的时间
-
可同时处理的请求数量
缓存
如果您有一个最终从静态源获取数据的 Web 端点(例如几乎不会更改或从不更改的数据库记录),可以在函数中缓存数据。这可以在任何层中完成。Python 提供了标准的functools 模块和函数 cache() 和 lru_cache()。
数据库、文件和内存
慢网站最常见的原因之一是数据库表缺少适当大小的索引。通常直到表增长到特定大小之后,查询突然变得更慢才能看到问题。在 SQL 中,WHERE 子句中的任何列都应该有索引。
在本书的许多示例中,creature 和 explorer 表的主键一直是文本字段 name。在创建表时,name 被声明为 primary key。到目前为止,在本书中看到的小表中,SQLite 无论如何都会忽略该键,因为仅扫描表更快。但一旦表达到一个可观的大小,比如一百万行,缺少索引就会显著影响性能。解决方案是运行查询优化器。
即使有一个小表,也可以使用 Python 脚本或开源工具进行数据库负载测试。如果您正在进行大量顺序数据库查询,可能可以将它们合并成一个批处理。如果要上传或下载大文件,请使用流式版本而不是整体读取。
队列
如果您执行的任何任务花费超过一小部分秒钟的时间(例如发送确认电子邮件或缩小图像),可能值得将其交给作业队列,例如Celery。
Python 本身
如果您的 Web 服务似乎因使用 Python 进行大量计算而变慢,您可能需要一个“更快的 Python”。替代方案包括以下内容:
-
使用PyPy而不是标准的 CPython。
-
用 C、C++或 Rust 编写 Python 扩展。
-
将缓慢的 Python 代码转换为Cython(由 Pydantic 和 Uvicorn 自身使用)。
最近一个非常引人注目的公告是Mojo 语言。它旨在成为 Python 的完整超集,具有新功能(使用相同友好的 Python 语法),可以将 Python 示例的速度提高数千倍。主要作者 Chris Lattner 之前曾在 Apple 上开发了像LLVM,Clang和MLIR以及Swift语言等编译器工具。
Mojo 旨在成为 AI 开发的单一语言解决方案,现在(在 PyTorch 和 TensorFlow 中)需要 Python/C/C++三明治,这使得开发、管理和调试变得困难。但 Mojo 也将是一个除了 AI 之外的好的通用语言。
我多年来一直在 C 中编码,并一直在等待一个像 Python 一样易于使用但性能良好的后继者。D、Go、Julia、Zig 和 Rust 都是可能的选择,但如果 Mojo 能够实现其目标,我将广泛使用 Mojo。
故障排除
从遇到问题的时间和地点向下查看。这包括时间和空间性能问题,还包括逻辑和异步陷阱。
问题类型
乍一看,您得到了什么 HTTP 响应代码?
404
出现身份验证或授权错误。
422
通常是 Pydantic 对模型使用的投诉。
500
FastAPI 背后的服务失败。
记录
Uvicorn 和其他 Web 服务器通常将日志写入 stdout。您可以检查日志以查看实际进行的调用,包括 HTTP 动词和 URL,但不包括正文、标头或 Cookie 中的数据。
如果特定的端点返回 400 级状态码,您可以尝试将相同的输入反馈并查看是否再次出现错误。如果是这样,我的第一个原始调试直觉是在相关的 Web、服务和数据函数中添加print()语句。
此外,无论何处引发异常,都要添加详细信息。如果数据库查找失败,请包含输入值和具体错误,例如尝试加倍唯一键字段。
指标
术语指标、监控、可观察性和遥测可能似乎有重叠之处。在 Python 领域中,使用以下常见实践:
-
Prometheus用于收集指标
-
Grafana用于显示它们
-
OpenTelemetry用于测量时间
您可以将这些应用于站点的所有层次:Web、服务和数据。服务层可能更加面向业务,其他层更多是技术方面的,对站点开发人员和维护者有用。
这里有一些链接可用于收集 FastAPI 的指标:
-
“入门指南:使用 Grafana 和 Prometheus 监控 FastAPI 应用程序—逐步指南” by Zoo Codes
-
“OpenTelemetry FastAPI Tutorial—Complete Implementation Guide” by Ankit Anand
回顾
生产显然并不容易。问题包括网络和磁盘超载,以及数据库问题。本章提供了如何获取所需信息的提示,以及在问题出现时从哪里开始挖掘。
¹ 等等,这些可以保持雪茄的新鲜。
第四部分:画廊
在第三部分,您创建了一个带有一些基本代码的最小网站。现在让我们用它来做些有趣的事情。接下来的章节将把 FastAPI 应用到常见的网络用途中:表单、文件、数据库、图表与图形、地图和游戏。
要将这些应用程序联系起来,并使它们比通常枯燥的计算书籍示例更有趣,我们将从一个不寻常的来源中获取数据,其中一些您已经略见一斑:来自世界民间传说的虚构生物及其追求者。将会有雪人,但也会有更为晦涩——尽管同样引人注目——的成员。
第十四章:数据库、数据科学和少量 AI
预览
本章讨论如何使用 FastAPI 存储和检索数据。它扩展了第十章中简单的 SQLite 示例,包括以下内容:
-
其他开源数据库(关系型和非关系型)
-
SQLAlchemy 的更高级用法
-
更好的错误检查
数据存储替代方案
注意
不幸地,“database”这个术语被用来指代三件事:
-
服务器 type,如 PostgreSQL、SQLite 或 MySQL
-
运行中的 server 实例
-
在该服务器上的 collection of tables
为了避免混淆——将上述最后一个项目的实例称为“PostgreSQL 数据库数据库数据库”,我会附加其他术语以表明我的意思。
网站的典型后端是数据库。网站和数据库如花生酱和果冻一般搭配,尽管你可能会考虑其他存储数据的方式(或者把花生酱配上泡菜),但在本书中我们将专注于数据库。
数据库处理了许多问题,否则您将不得不用代码自行解决,例如以下问题:
-
多重访问
-
索引
-
数据一致性
数据库的一般选择如下:
-
关系数据库,带有 SQL 查询语言
-
非关系型数据库,具有各种查询语言
关系数据库和 SQL
Python 有一个名为 DB-API 的标准关系 API 定义,由所有主要数据库的 Python 驱动程序包支持。表 14-1 列出了一些显著的关系数据库及其主要的 Python 驱动程序包。
表 14-1. 关系数据库和 Python 驱动程序
| 数据库 | Python 驱动程序 |
|---|---|
| 开源 | |
| SQLite | sqlite3 |
| PostgreSQL | psycopg2 和 asyncpg |
| MySQL | MySQLdb 和 PyMySQL |
| 商业 | |
| Oracle | python-oracledb |
| SQL Server | pyodbc 和 pymssql |
| IBM Db2 | ibm_db |
Python 主要用于关系数据库和 SQL 的软件包如下:
可以在多个层次上使用的功能齐全的库
FastAPI 的作者结合了 SQLAlchemy 和 Pydantic 的组合
来自 Requests 包的作者,一个简单的查询 API
SQLAlchemy
最流行的 Python SQL 包是 SQLAlchemy。尽管许多关于 SQLAlchemy 的解释只讨论其 ORM,但它有多个层次,我将从底层向上讨论这些。
Core
SQLAlchemy 的基础,称为 Core,包括以下内容:
-
实现了 DB-API 标准的
Engine对象 -
表达 SQL 服务器类型、驱动程序以及该服务器上特定数据库集合的 URL
-
客户端-服务器连接池
-
事务(
COMMIT和ROLLBACK) -
各种数据库类型之间的 SQL 方言 差异
-
直接 SQL(文本字符串)查询
-
在 SQLAlchemy 表达语言中进行查询
其中一些功能,如方言处理,使 SQLAlchemy 成为处理各种服务器类型的首选包。你可以用它来执行纯粹的 DB-API SQL 语句或使用 SQLAlchemy 表达语言。
到目前为止我一直在使用原始的 DB-API SQLite 驱动程序,并将继续使用。但对于更大的站点或可能需要利用特殊服务器功能的站点,SQLAlchemy(使用基本的 DB-API、SQLAlchemy 表达语言或完整的 ORM)是非常值得使用的。
SQLAlchemy 表达语言
SQLAlchemy 表达语言不是ORM,而是另一种表达对关系表查询的方式。它将底层存储结构映射到像Table和Column这样的 Python 类,并将操作映射到像select()和insert()这样的 Python 方法。这些函数转换为普通的 SQL 字符串,你可以访问它们来查看发生了什么。该语言与 SQL 服务器类型无关。如果你觉得 SQL 困难,这可能值得一试。
让我们比较几个例子。示例 14-1 显示了纯 SQL 版本。
示例 14-1. 数据/explorer.py 中get_one()的直接 SQL 代码
def get_one(name: str) -> Explorer:
qry = "select * from explorer where name=:name"
params = {"name": name}
curs.execute(qry, params)
return row_to_model(curs.fetchone())
示例 14-2 显示了部分 SQLAlchemy 表达语言的等效内容,用于设置数据库、构建表并执行插入。
示例 14-2. SQLAlchemy 表达语言用于get_one()功能
from sqlalchemy import Metadata, Table, Column, Text
from sqlalchemy import connect, insert
conn = connect("sqlite:///cryptid.db")
meta = Metadata()
explorer_table = Table(
"explorer",
meta,
Column("name", Text, primary_key=True),
Column("country", Text),
Column("description", Text),
)
insert(explorer_table).values(
name="Beau Buffette",
country="US",
description="...")
要获取更多示例,一些备选文档比官方页面更易读。
ORM
ORM 将查询表达为领域数据模型的术语,而不是数据库机器底层的关系表和 SQL 逻辑。官方文档详细介绍了所有细节。ORM 比 SQL 表达语言复杂得多。偏爱完全面向对象模式的开发人员通常更喜欢 ORM。
许多关于 FastAPI 的书籍和文章在数据库部分直接跳到 SQLAlchemy 的 ORM。我理解其吸引力,但也知道这要求你学习另一种抽象。SQLAlchemy 是一个优秀的库,但如果其抽象不总是适用,那么你就会遇到两个问题。最简单的解决方案可能是直接使用 SQL,如果 SQL 变得过于复杂再转向表达语言或 ORM。
SQLModel
FastAPI 的作者结合了 FastAPI、Pydantic 和 SQLAlchemy 的各个方面,创建了SQLModel。它重新利用了一些来自网络世界的开发技术到关系数据库中。SQLModel 将 SQLAlchemy 的 ORM 与 Pydantic 的数据定义和验证结合起来。
SQLite
我在 第十章 中介绍了 SQLite,将其用于数据层示例。它是公有领域的——你不能找到比这更开源的了。SQLite 在每个浏览器和每个智能手机中都被使用,使其成为世界上部署最广泛的软件包之一。在选择关系数据库时,它经常被忽视,但是有可能多个 SQLite “服务器” 也可以支持一些大型服务,就像一个强大的服务器一样,例如 PostgreSQL。
PostgreSQL
在关系数据库的早期,IBM 的 System R 是先驱,而分支为新市场而战——主要是开源的 Ingres 对商业的 Oracle。Ingres 采用了名为 QUEL 的查询语言,而 System R 采用了 SQL。尽管 QUEL 被一些人认为比 SQL 更好,但是 Oracle 将 SQL 作为标准,再加上 IBM 的影响力,帮助推动了 Oracle 和 SQL 的成功。
几年后,Michael Stonebraker 回归,将 Ingres 迁移到 PostgreSQL。如今,开源开发者倾向于选择 PostgreSQL,尽管几年前 MySQL 很受欢迎,现在仍然存在。
EdgeDB
尽管 SQL 多年来取得了成功,但它确实存在一些设计缺陷,使得查询变得笨拙。与 SQL 基于的数学理论(由 E. F. Codd 提出的 关系演算)不同,SQL 语言设计本身不具有 可组合性。主要是这意味着很难在较大的查询中嵌套查询,导致代码更加复杂和冗长。
所以,就只是为了好玩,我在这里加入了一个新的关系数据库。EdgeDB(用 Python 写的!)是由 Python 的 asyncio 的作者编写的。它被描述为 Post-SQL 或 graph-relational。在内部,它使用 PostgreSQL 处理繁重的系统任务。Edge 的贡献是 EdgeQL:一种旨在避免 SQL 中那些棘手的边缘的新查询语言;它实际上被转换为 SQL 以供 PostgreSQL 执行。“我对 EdgeDB 的体验” by Ivan Daniluk 方便地比较了 EdgeQL 和 SQL。可读的图解官方文档与书籍 Dracula 相呼应。
EdgeQL 是否会超越 EdgeDB 并成为 SQL 的替代品?时间会告诉我们。
非关系(NoSQL)数据库
在开源 NoSQL 或 NewSQL 领域的主要人物在 表 14-2 中列出。
表 14-2. NoSQL 数据库和 Python 驱动程序
| 数据库 | Python 驱动程序 |
|---|---|
| Redis | redis-py |
| MongoDB | PyMongo, Motor |
| Apache Cassandra | DataStax Apache Cassandra 驱动 |
| Elasticsearch | Python Elasticsearch Client |
有时 NoSQL 的意思是字面上的 no SQL,但有时是 not only SQL。关系型数据库对数据强制实施结构,通常可视化为带有列字段和数据行的矩形表,类似于电子表格。为了减少冗余并提高性能,关系型数据库使用 normal forms(数据和结构的规则)进行 normalized,例如只允许每个单元格(行/列交叉点)有一个值。
NoSQL 数据库放宽了这些规则,有时允许在单个数据行中跨列/字段使用不同类型。通常,schemas(数据库设计)可以是杂乱的结构,就像您可以在 JSON 或 Python 中表示的那样,而不是关系型的盒子。
Redis
Redis 是一个完全运行在内存中的数据结构服务器,虽然它可以保存到磁盘并从磁盘恢复。它与 Python 自身的数据结构非常匹配,因此变得非常流行。
MongoDB
MongoDB 类似于 NoSQL 服务器中的 PostgreSQL。Collection 相当于 SQL 表,document 相当于 SQL 表中的一行。另一个区别,也是使用 NoSQL 数据库的主要原因,是您不需要定义文档的结构。换句话说,没有固定的 schema。文档类似于 Python 字典,任何字符串都可以作为键。
Cassandra
Cassandra 是一个可以分布在数百个节点上的大规模数据库。它是用 Java 编写的。
一种名为 ScyllaDB 的替代数据库是用 C++ 编写的,声称与 Cassandra 兼容但性能更好。
Elasticsearch
Elasticsearch 更像是数据库索引,而不是数据库本身。它通常用于全文搜索。
SQL 数据库中的 NoSQL 特性
正如前面所述,关系型数据库传统上是规范化的——受到称为 normal forms 的不同级别规则的约束。一个基本规则是每个单元格中的值(行列交叉点)必须是 scalar(无数组或其他结构)。
NoSQL(或 document)数据库直接支持 JSON,并且通常是如果您有“不均匀”或“杂乱”数据结构的唯一选择。它们通常是 denormalized:所有需要的文档数据都包含在该文档中。在 SQL 中,您经常需要跨表进行 join 来构建完整的文档。
然而,SQL 标准的最近修订允许在关系型数据库中存储 JSON 数据。一些关系型数据库现在允许您在表单元格中存储复杂(非标量)数据,并在其中进行搜索和索引。JSON 函数以各种方式得到支持,包括 SQLite、PostgreSQL、MySQL、Oracle 等等。
具有 JSON 的 SQL 可能是两者兼得的最佳选择。SQL 数据库存在已经很长时间了,并且具有非常有用的功能,如外键和二级索引。此外,SQL 在某种程度上相当标准化,而 NoSQL 查询语言则各不相同。
最后,新的数据设计和查询语言正在尝试结合 SQL 和 NoSQL 的优势,就像我之前提到的 EdgeQL 一样。
因此,如果您的数据无法适应矩形关系框,请考虑 NoSQL 数据库,支持 JSON 的关系数据库,或者“Post-SQL”数据库。
数据库负载测试
本书主要讲述的是 FastAPI,但网站经常与数据库相关联。
本书中的数据示例都很小。要真正对数据库进行压力测试,数百万条数据将是很好的选择。与其考虑要添加的内容,不如使用像Faker这样的 Python 包更容易。Faker 可以快速生成许多类型的数据—名称、地点或您定义的特殊类型。
在示例 14-3 中,Faker 生成名称和国家,然后由load()加载到 SQLite 中。
示例 14-3. 在 test_load.py 中加载虚拟探险家
from faker import Faker
from time import perf_counter
def load():
from error import Duplicate
from data.explorer import create
from model.explorer import Explorer
f = Faker()
NUM = 100_000
t1 = perf_counter()
for row in range(NUM):
try:
create(Explorer(name=f.name(),
country=f.country(),
description=f.description))
except Duplicate:
pass
t2 = perf_counter()
print(NUM, "rows")
print("write time:", t2-t1)
def read_db():
from data.explorer import get_all
t1 = perf_counter()
_ = get_all()
t2 = perf_counter()
print("db read time:", t2-t1)
def read_api():
from fastapi.testclient import TestClient
from main import app
t1 = perf_counter()
client = TestClient(app)
_ = client.get("/explorer/")
t2 = perf_counter()
print("api read time:", t2-t1)
load()
read_db()
read_db()
read_api()
在load()中捕获Duplicate异常并忽略它,因为 Faker 从一个有限的列表中生成名称,偶尔可能会重复。所以结果可能少于加载的 10 万个探险家。
此外,您两次调用了read_db(),以便在 SQLite 执行查询时消除任何启动时间。然后read_api()的时间应该是公平的。示例 14-4 启动它。
示例 14-4. 测试数据库查询性能
$ python test_load.py
100000 rows
write time: 14.868232927983627
db read time: 0.4025074450764805
db read time: 0.39750714192632586
api read time: 2.597553930943832
所有探险家的 API 读取时间比数据层的读取时间慢得多。其中一些可能是由于 FastAPI 将响应转换为 JSON 的开销。此外,写入数据库的初始时间并不是很快。它一次写入一个探险家,因为数据层 API 有一个单独的create()函数,但没有create_many()函数;在读取方面,API 可以返回一个(get_one())或所有(get_all())。因此,如果您想要进行批量加载,可能最好添加一个新的数据加载函数和一个新的 Web 端点(带有受限制的授权)。
此外,如果您期望数据库中的任何表增长到 10 万行,也许您不应该允许随机用户在一个 API 调用中获取所有这些行。分页会很有用,或者一种从表中下载单个 CSV 文件的方法。
数据科学与人工智能
Python 已经成为数据科学总体上以及机器学习特别是最突出的语言。因此需要大量的数据处理工作,而 Python 擅长于此。
有时开发人员会使用外部工具如 pandas 来进行在 SQL 中过于棘手的数据操作。
PyTorch 是最流行的 ML 工具之一,因为它充分利用了 Python 在数据处理方面的优势。底层计算可能使用 C 或 C++ 来提高速度,但 Python 或 Go 更适合“更高级”的数据集成任务。(The Mojo 语言,Python 的超集,如果计划成功,可能会处理高低端任务。虽然它是一种通用语言,但专门解决了 AI 开发中的某些当前复杂性。)
一个名为 Chroma 的新 Python 工具是一个数据库,类似于 SQLite,但专门针对机器学习,特别是大型语言模型(LLMs)。阅读 Getting Started page 来,你懂的,开始使用。
尽管 AI 开发复杂且发展迅速,但你可以在自己的机器上使用 Python 尝试一些 AI,而不需要像 GPT-4 和 ChatGPT 那样花费巨额资金。让我们构建一个小型 FastAPI 网页接口到一个小型 AI 模型。
注意
Model 在 AI 和 Pydantic/FastAPI 中有不同的含义。在 Pydantic 中,一个 model 是一个捆绑相关数据字段的 Python 类。AI models 则涵盖了广泛的技术,用于确定数据中的模式。
Hugging Face 提供免费的 AI models、数据集和 Python 代码供使用。首先,安装 PyTorch 和 Hugging Face 代码:
$ pip install torch torchvision
$ pip install transformers
Example 14-5 展示了一个 FastAPI 应用程序,它使用 Hugging Face 的 transformers 模块访问预训练的中型开源机器语言模型,并尝试回答你的提示。(这是从 YouTube 频道 CodeToTheMoon 的命令行示例中改编的。)
示例 14-5. 顶层 LLM 测试(ai.py)
from fastapi import FastAPI
app = FastAPI()
from transformers import (AutoTokenizer,
AutoModelForSeq2SeqLM, GenerationConfig)
model_name = "google/flan-t5-base"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
config = GenerationConfig(max_new_tokens=200)
@app.get("/ai")
def prompt(line: str) -> str:
tokens = tokenizer(line, return_tensors="pt")
outputs = model.generate(**tokens,
generator_config=config)
result = tokenizer.batch_decode(outputs,
skip_special_tokens=True)
return result[0]
使用 uvicorn ai:app 运行此程序(像往常一样,首先确保你没有另一个仍在 localhost 端口 8000 上运行的网络服务器)。在 /ai 端点输入问题并获得答案,如此(注意 HTTPie 查询参数的双 ==):
$ http -b localhost:8000/ai line=="What are you?"
"a sailor"
这是一个相当小的模型,正如你所见,它并不能特别好地回答问题。我尝试了其他提示(line 参数)并得到了同样值得注意的答案:
-
Q: 猫比狗更好吗?
-
A: 不
-
Q: 大脚怪早餐吃什么?
-
A: 一只鱿鱼
-
Q: 谁会从烟囱里下来?
-
A: 一只尖叫的猪
-
Q: 约翰·克利斯在什么组里?
-
A: 披头士乐队
-
Q: 什么有尖尖的牙齿?
-
A: 一个泰迪熊
这些问题在不同时间可能会有不同的答案!有一次同一端点说大脚怪早餐吃沙子。在 AI 术语中,像这样的答案被称为 幻觉。你可以通过使用像 google/flan-75-xl 这样的更大模型来获得更好的答案,但在个人电脑上下载模型数据和响应会花费更长时间。当然,像 ChatGPT 这样的模型是使用它们能找到的所有数据训练的(使用每个 CPU、GPU、TPU 和任何其他类型的 PU),并且会给出优秀的答案。
回顾
本章扩展了我们在第十章中介绍的 SQLite 的用法,涵盖了其他 SQL 数据库,甚至 NoSQL 数据库。它还展示了一些 SQL 数据库如何通过 JSON 支持实现 NoSQL 技巧。最后,它讨论了随着机器学习持续爆炸性增长,数据库和特殊数据工具的用途变得更加重要。