Sanic-Pyrhon-Web-开发-全-
Sanic Pyrhon Web 开发(全)
原文:
zh.annas-archive.org/md5/6f2d71bbfda92944375a9983f859eb42译者:飞龙
第一章:使用 Sanic 进行 Python Web 开发
版权所有 © 2022 Packt Publishing
版权所有。未经出版者事先书面许可,本书的任何部分不得以任何形式或任何手段进行复制、存储在检索系统中或通过任何方式传输,除非在评论或评论文章中嵌入的简短引用。
欢迎来到 Packt 早期访问。在本书上市之前,我们为您提供独家预览。撰写一本书可能需要数月时间,但我们的作者今天有前沿信息要与您分享。早期访问通过提供章节草案,让您了解最新的发展。目前章节可能有些粗糙,但我们的作者将随着时间的推移进行更新。当新版本准备就绪时,您将收到通知。
本标题正在开发中,还有更多章节有待撰写,这意味着你有机会对内容发表意见。我们希望出版对您和其他客户都有用的书籍,因此我们将定期向您发送问卷。所有反馈都有帮助,所以请畅所欲言您的想法和意见。我们的编辑将对书籍的文本进行修改,因此我们希望您对技术元素和作为读者的经验提供反馈。我们还将提供关于作者如何根据您的反馈修改章节的频繁更新。
您可以随意翻阅这本书或从头到尾跟随;早期访问旨在灵活。我们希望您喜欢了解更多关于编写 Packt 书籍的过程。通过贡献您的想法加入对新主题的探索,并看到它们在印刷品中变为现实。
使用 Sanic 进行 Python Web 开发
-
Sanic 和异步框架简介
-
组织项目
-
路由和接收 HTTP 请求
-
摄入 HTTP 数据
-
处理和响应视图
-
在请求处理器外操作
-
处理安全关注
-
运行 Sanic 服务器
-
提升你的 Web 应用程序的最佳实践
-
使用 Sanic 实现常见用例
第二章:1 介绍 Sanic 和异步框架
应该只有一个——最好是只有一个——明显的解决方案。
- Tim Peters,《Python 的禅意》
太常了,这个 Python 格言被理解为“必须只有一个方法来做某事”。任何 Python 网络开发者都可以简单地看看存在的网络框架数量,并告诉你选择并不简单。在 PyPI 上有数十个网络框架,在任何单个框架的生态系统中,你将找到更多解决单个问题的选项。在pypi.org的搜索栏中输入authentication。看看结果的数量,只有一个“[一个]明显的解决方案”似乎并不明显。也许这句话需要改变。或许它可以读作,“应该有一个……对你来说明显的解决方案。”为什么?因为添加我们正在讨论你的特定应用程序的上下文,使我们达到了下一个层次。
这就是 Sanic,这也是本书的目标。
对于构建股票组合跟踪器的人来说可能显而易见的事情,对于构建流媒体播放器的人来说可能并不明显。因此,为了弄清楚什么是最明显的解决方案,我们首先必须理解问题。而且,为了理解问题,我们必须对我们的特定用例保持高度警觉。
当试图找到一个问题的解决方案时,许多其他工具和框架会回答说:这是你应该这样做。你想从你的网络请求中读取数据?这是如何验证它的方法。你需要 跨站请求伪造(CSRF)保护?这是你需要添加的代码片段。这种方法无法让你成为一个更好的开发者,也无法为你找到最佳的使用案例解决方案。
为什么我应该以这种方式验证我的数据?为什么我需要这个代码片段来保护自己?因为有人为你做出了决定。你无法回答这些问题。你所知道的就是框架文档——或者某个互联网上的博客——告诉你这样做,所以你就这样做了。
这也是为什么 Sanic——以及本书——采取不同的方法。到本书结束时,我们希望你知道如何发现特殊的使用案例,以及如何调整工具以满足你的需求。你应该能够思考不同类型的实现,并选择对你最有意义的解决方案。这将是最明显的解决方案。
Sanic 以其无偏见而自豪。这并不是说项目的维护者没有强烈的观点。我欢迎你与我或社区中的任何人就代理转发、部署策略、身份验证方案等进行讨论。你肯定会找到充满激情的观点。通过“无偏见”,我们是指 Sanic 的工作是处理基础设施,而你只需要构建逻辑。如何处理问题的决策不是框架的领域。
你会发现,Sanic 开发者最热衷于寻找针对他们面临的特定挑战的超专注解决方案。开发者使用 Sanic 是因为它既快又简单。但,你也会发现,使用 Sanic 意味着问题的明显解决方案不是基于 Sanic,而是基于你独特的应用需求。
另一方面,有时你的用例并不需要超专注的解决方案。这也是可以的。因此,你会发现许多插件(其中许多由 Sanic 核心开发者团队活跃成员支持)或现成的解决方案。我们完全支持你采用它们及其模式。在这本书中,我们的例子将避免需要插件的实现。然而,当有包括插件在内的流行解决方案时,我们也会为你指出以供参考。
我们在这本书中的目标是学习识别你独特的应用需求,并将它们与我们可用的工具相匹配,以:(1)使我们的应用更好;(2)使我们成为更好的开发者。
在本章中,我们将通过以下主题来构建阅读这本书所需的基础理解:
-
什么是 Sanic?
-
提升水平
-
框架与服务器
-
为什么使用 Sanic – 构建快,运行快
技术要求
本章将包括一些基本的 Python 和终端使用。为了跟随示例,请确保你的计算机已设置 Python 3.7 或更高版本。你还需要安装 curl 或类似程序,这样我们就可以轻松地制作和检查 HTTP 请求。如果你不熟悉 curl,它是一个从终端会话执行的程序,允许你进行 HTTP 请求。它应该默认在大多数 macOS 和 Linux 安装中可用,也可以在 Windows 机器上安装。
什么是 Sanic?
由于你在阅读这本书,你很可能熟悉 Python,甚至可能了解一些用于使用 Python 构建 Web 应用的流行工具。至于 Sanic,你可能听说过它,或者已经使用过它,并希望提高你的技能和对它的理解。你可能知道 Sanic 与传统工具不同。它是从头开始构建的,完全基于异步 Python 的理念。从一开始,Sanic 就旨在追求速度。这是推动其大部分发展的关键决策之一。
要真正理解 Sanic 项目,我们可能需要从历史课开始。Sanic 是第一个将异步 Python 引入 Web 框架的合法尝试。它最初是一个概念验证,作为一个爱好项目。让我们设定场景。
Sanic 最基础的构建块是来自 Python 标准库的asyncio 模块。Sanic 项目在模块早期发布阶段诞生,并随着模块的成熟而成熟。
Python 3.4——2014 年初发布——是将 协程 的概念引入标准库中新增的 asyncio 模块的第一步。使用标准的 Python 生成器,可以在其他事情发生时暂停函数的执行,然后可以将数据注入该函数以允许其恢复执行。如果有一个“循环”对象可以遍历需要工作的任务列表,我们就可以同时进入和退出多个函数的执行。这可以在单个线程中实现“并发”,这是 asyncio 概念的基础。
在早期,这主要是一个玩具,协程并没有得到广泛的应用。当然,有一些合法的应用需求得到了解决,但这个概念在其发展初期仍然非常初级,并未完全成熟。在接下来的几个 Python 版本中,这个概念得到了细化,最终形成了我们今天所知道的 asyncio 模块。
下面快速看一下 Python 3.4 中异步编程的样子:
import asyncio
@asyncio.coroutine
def get_value():
yield from asyncio.sleep(1)
return 123
@asyncio.coroutine
def slow_operation():
value = yield from get_value()
print(">>", value)
loop = asyncio.get_event_loop()
loop.run_until_complete(slow_operation())
loop.close()
虽然我们不会深入探讨其工作原理,但值得一提的是,异步 Python 是建立在生成器的基础上的。其理念是生成器函数可以返回到某个“循环”以允许其进入和退出执行。
重要提示
我们将在稍后讨论如何使用替代循环(例如 trio),但本书将假设我们正在使用 asyncio。
新的 asyncio 模块的语言和语法都非常强大,但有点笨拙。生成器通常对不太熟练的 Python 开发者来说有点神秘和难以理解。yield from 究竟是什么?这些东西对许多人来说看起来很陌生;Python 需要更好的语法。
在继续之前,如果你不熟悉生成器,这里做一个简要的说明。Python 有一种特殊类型的函数,它返回一个生成器。这个生成器可以通过 yield 一个值来部分执行并暂停其操作,直到需要进一步处理。生成器还具有双向操作的能力,即在执行过程中可以将数据发送回它们。再次强调,这些细节超出了本书的范围,因为它们并非完全相关,但了解这一点有助于知道这就是 yield from 帮助我们实现的目标。利用生成器的双向功能,Python 能够构建异步协程的能力。
由于这个实现复杂,对于 Python 3.5 的初学者来说稍微有点困难,因此它包含了一个更简单、更干净的版本:
async def get_value():
await asyncio.sleep(1)
return 123
async def slow_operation():
value = await get_value()
print(">>", value)
这种编程风格的主要好处是它减轻了由于输入和输出导致的代码阻塞。这被称为 io-bound。一个经典的 io-bound 应用示例是网络服务器。因此,构建一个旨在创建与网络流量交互的协议的新 asyncio 模块是顺理成章的。
然而,当时没有框架或 Web 服务器采用这种方法。Python Web 生态系统建立在与异步 Python 基本对立的前提之上。
重要提示
经典的 Python 集成应用程序与 Python Web 服务器的方式被称为 Web 服务器网关接口(WSGI)。这不在本书的范围内。虽然可以将 Sanic 强行塞入 WSGI,但通常是不受欢迎的。WSGI 的问题在于其整个前提是阻塞的。Web 服务器接收一个请求,处理它,并在单个执行中发送响应。这意味着服务器一次只能处理一个请求。使用 Sanic 与 WSGI 服务器完全破坏了高效并发处理多个请求的异步能力。
经典的 Django 和 Flask 无法采用这种模式。回顾 6 年以上,这些项目最终找到了引入 async/await 的方法。但是,这并不是这些框架的自然使用模式,并且付出了多年非凡的努力。
当 asyncio 模块发布时,缺乏能够满足这一新用例的 Web 框架。甚至后来被称为 异步服务器网关接口(ASGI)的概念也尚未存在。
重要提示
ASGI 是 WSGI 的对应物,但针对异步 Python。虽然不是必需的,但可以将其用于 Sanic,我们将在 第八章,运行服务器 中进一步讨论。
在 2016 年夏季,Sanic 被构建出来以探索这个差距。想法很简单:我们能否将一个看起来简单的 Flask API 应用程序转换为 async/await?
某种程度上,这个想法起飞并获得了势头。这并不是一个最初旨在重新设计 Python 应用程序处理 Web 请求的项目。这非常典型地是一个意外曝光的案例。项目迅速爆炸并迅速创造了兴奋。很多人认为 Flask 采用这种新模式有很大的吸引力;但由于 Flask 本身无法做到这一点,许多人认为 Sanic 可以是 Flask 的异步版本。
开发者对使用最新的 Python 带来应用程序全新性能水平的新机会感到兴奋。早期的基准测试显示 Sanic 在 Flask 和 Django 之上运行得如风驰电掣。
然而,像许多项目一样,最初的热情逐渐消退。原始项目旨在回答一个问题:是否存在一个类似 Flask 的框架?答案是响亮的肯定。然而,作为一个没有打算处理所获得的级别支持和关注的单人项目,该项目开始积满灰尘。拉取请求开始堆积。问题无人回答。
在 2017 年和 2018 年间,异步 Python 的生态系统仍然非常不成熟。它缺乏值得信赖的平台,这些平台将得到支持、维护,并且适用于个人和专业的网络应用程序。此外,关于 Sanic 仍有一些身份上的疑问。有些人认为它应该是一个非常小众的软件,而另一些人则认为它可能有广泛的应用。
几个月来对项目维护者缺乏响应和挫败感的后果导致了 Sanic 任务小组的成立。这个 Sanic 社区组织的先驱是一群对找到即将失败的项目未来感兴趣的开发者松散集体。他们希望稳定 API 并回答所有悬而未决的身份问题。在 2018 年中旬的几个月里,关于如何推进项目以及如何确保项目不会再次遭受同样命运的争论一直在酝酿。
最基本的问题之一是项目是否应该被分叉。由于 Sanic 任务小组中没有人在仓库或其他资产上拥有管理员权限——而唯一有权限的人没有响应——唯一的选择就是分叉并重新命名项目。然而,Sanic 在那时已经存在两年,在 Python 社区中作为构建异步网络应用程序的一个可行(且快速)的选项而闻名。放弃现有的项目名称将对新项目重新崛起造成巨大的打击。尽管如此,这是唯一剩下的解决方案。在将项目分叉到新的 GitHub 仓库的前夕,原始维护者提供了对仓库的访问权限,SCO 因此诞生。团队努力重组社区运作,以下是一些目标:
-
定期和可预测的发布,以及弃用;
-
对响应问题和支持的问责制和责任感;以及
-
代码审查和决策的结构。
在 2018 年 12 月,SCO 发布了 Sanic 的第一个社区版本:18.12 LTS。
在建立起新的结构之后,SCO 转向了下一个问题:Sanic 是什么?最终的决定是打破任何与 Flask 兼容的尝试。虽然可以说原始项目是一个“Flask 克隆”,但这已经不再真实。你仍然会听到它被称为“类似 Flask”,但这种比较只因为它们在表面上看起来相似。功能和行为在本质上都是不同的,相似之处到此为止。我个人尽量避开这种比较,因为它贬低了数百名贡献者为让 Sanic 独立而付出的努力和改进。
升级
Sanic 鼓励实验、定制,最重要的是,好奇心。
由于它假设对 Python 和 Web 开发都有一定的了解,因此可能不是 Python 初学者的最佳工具。这并不是说项目会阻止新手,或者刚开始接触 Web 开发的人。实际上,对于那些真正想了解实践的人来说,Sanic 是一个学习 Web 开发的绝佳起点。Web 开发很大程度上是学习平衡相互竞争的决策。Sanic 开发通常包括了解这些决策点。
这触及了本书的目标:回答“构建我的 Sanic 应用的最佳路径是什么?”本书旨在探讨在 Sanic 中可能使用的不同模式。虽然我们将学习与 Sanic 相关的概念,但原则可以抽象化并应用于构建任何其他框架或语言的应用程序。记住,没有“正确”或“错误”的方法是至关重要的。在线论坛充满了“正确方法”的问题,这些假设存在标准实践。这些问题的答案指向了这样的含义,即如果他们没有遵循特定的模式,那么他们的应用程序就是错误的。
例如,有人可能会问以下问题:
-
“提供国际化内容的正确方法是什么?”
-
“我应该如何部署我的 Web 应用?”
-
“处理长时间运行操作的正确方法是什么?”
这些“正确方法”的问题存在一个关键缺陷:认为只有一个“明显”的解决方案。以这种方式构建问题属于有偏见的框架领域,这些框架不教会开发者独立思考。在 Sanic 论坛上提出相同的问题,你可能会得到一个“视情况而定”的回答。有偏见的框架阻碍了创造力和设计。它们最终迫使开发者根据框架提供的限制而不是应用程序的需求来做出选择。
相反,Sanic 提供了一套工具,帮助开发者根据他们的用例定制解决方案,而不强制执行某些实践。这就是为什么 Sanic 的内置功能专注于功能性和请求/响应周期,而不是像验证、跨源资源共享(CORS)、CSRF 和数据库管理这样的实现细节。当然,所有那些“其他东西”都很重要,我们将在后面的章节中探讨它们。但本书的意图是查看问题,并看看你“可能”如何解决问题。Web 应用程序和 Web API 有不同的需求,因此作为开发者,你应该被允许做出最适合(且明显)解决问题的选择。
回顾上述问题,更好的表述方式可能是:
-
“我该如何提供国际化内容?”
-
“考虑到我的限制,哪种部署策略对我适用?”
-
“在处理长时间运行的操作时,需要考虑哪些权衡?”
到这本书的结尾,你将能够识别问题,并运用你的创造力来提出合适的解决方案。你将学习 Sanic 提供的一些强大策略。但绝不要认为任何给定的解决方案是唯一的。仅仅因为它在这里被概述,并不意味着它是正确的。或者,所使用的工具只适用于特定的情况。
正确的方法是使用 Sanic 和 Python 提供的工具来解决你的问题。如果你被要求做汤,你会发现没有唯一的方法可以做到。你可以查找一些食谱,并尝试学习一些基本模式:烧开水,加配料等。最终,要掌握做汤的艺术,你需要在你自己的厨房、设备、配料以及你要服务的人的约束条件下烹饪。这就是我想让你了解的关于 Web 开发的知识:掌握你的工具、环境和需求,为你的特定用户构建你需要的东西。
在阅读这本书的过程中,你应该既在学习也在分析模式和代码。尝试概括概念,并思考如何在未来的代码中运用类似的想法。我们鼓励你通读全书,或者根据需要跳读章节,这两种方法都是有效的。
让我们更深入地探讨国际化的问题,看看不同的问题框架如何影响我们的应用程序和知识。
-
错误:如何正确地提供国际化内容?
-
正确:我如何提供国际化内容?
第一个问题的答案可能包括一些曾经遇到过这个问题的开发者的代码片段。开发者会复制粘贴并继续前进,在这个过程中没有学到任何东西(因此将无法将知识应用于未来类似的问题)。最好的解决方案是勉强可以接受的。最坏的情况是,解决方案对应用程序的整体设计有害,而开发者甚至不知道这一点。
将问题框架为“我如何...”留出了可能有多种途径达到同一目的的想法。错误的问题风格是狭窄的,将我们的注意力引向单一的方法。好的风格则打开了探索不同解决方案的可能性,并权衡它们的优点。在提出问题后,我们现在的工作是找出可能的解决方案,确定潜在的权衡,然后得出结论。在这个过程中,我们可以借鉴我们自己的过去经验,其他开发者的例子,以及像这本书这样的资源材料。
我们可以思考以下可能的解决方案:
-
中间件(捕获头信息或路径,并将请求重定向到不同的处理程序)
-
路由(在 URL 路径中嵌入语言代码,并从路由中提取语言)
-
函数式编程(使用不同的函数处理程序来生成单独的响应)
-
装饰器(在实际处理程序运行之前或之后执行一些逻辑)。
但应该使用哪种解决方案?我们需要了解我们应用程序的具体情况。以下是一些需要记住的重要问题:
-
谁在开发它?他们的经验水平如何?团队中有多少开发者?他们将使用哪些工具?谁将维护它?
-
谁将使用这个应用程序?它将被一个前端 JS 框架消费吗?一个移动应用?第三方集成?
-
它将扩展到多大?需要传递什么样的内容?
这些问题是这本书的领域。我们打算提出问题,并确定做出特定设计模式决策的原因。当然,我们不可能详尽无遗。相反,我们希望激发你使用尽可能多的工具和创造性解决方案的旅程。
很少有项目能和关于 Django 所写的文献数量相匹配。但正是因为 Sanic 不需要特定的模式,所以不需要如此大量的文档。唯一的前提是知道 Python。成功使用 Sanic 所需的 API 特定知识的深度并不大。你知道如何实例化对象、传递值和访问属性吗?当然,你知道,这只是 Python!
两个显然有价值的 Sanic 特定资源是用户指南(sanicframework.org)和 API 文档(sanic.readthedocs.io)。在这本书中,我们将大量参考这两个资源。但是,同样重要的是任何其他你到目前为止用来学习 Python 的在线或印刷资源。
回到 Sanic 内部处理某些任务的最明显方式的问题:使用现有的资源和工具。StackOverflow 和 Sanic 社区论坛上有很多信息。Discord 服务器是一个活跃的实时讨论频道。让自己被认识,让自己的声音被听到。
不要问“正确的方式”问题。相反,问“我该如何”问题。
框架 v 服务器
Sanic 将自己称为既是 Web 框架又是 Web 服务器。这意味着什么?更重要的是,这为什么很重要?
在我们探索这个问题之前,我们首先必须理解这些术语的含义以及它们为什么存在。
Web 服务器
Web 服务器是一种设计用来通过HTTP协议传递文档和数据的软件。它的功能是接受传入的 HTTP 请求,解码消息以理解请求试图完成什么,并返回适当的响应。Web 服务器的语言是 HTTP 协议。
我们将在稍后深入了解具体细节,但现在,我们将设置一个简单的 Sanic 服务器,从curl发出请求,并查看消息。
-
创建一个名为 server.py 的文件,然后在您的终端中运行它。
from sanic import Sanic, text, Request app = Sanic(__name__) @app.post("/") async def handler(request: Request): message = ( request.head + b"\n\n" + request.body ).decode("utf-8") print(message) return text("Done") app.run(port=9999, debug=True) -
现在,我们向我们的 API 发送一个请求:
$ curl localhost:9999 -d '{"foo": "bar"}'
在我们的控制台中,我们现在应该能看到 HTTP 请求消息:
POST / HTTP/1.1
Host: localhost:9999
User-Agent: curl/7.76.1
Accept: */*
Content-Length: 14
Content-Type: application/x-www-form-urlencoded
{"foo": "bar"}
我们看到的是三个组件:
-
第一行包含 HTTP 方法、路径以及使用的 HTTP 协议
-
接下来是 HTTP 头部列表,每行一个,格式为
key: value -
最后是 HTTP 主体,前面有一个空行。HTTP 响应非常相似:
HTTP/1.1 200 OK content-length: 4 connection: keep-alive content-type: text/plain; charset=utf-8 Done
现在的三个组件是:
-
第一行包含 HTTP 协议,后面是 HTTP 状态和状态描述
-
接下来是 HTTP 头部列表,每行一个,格式为
key: value -
最后是 HTTP 主体(如果有的话),前面有一个空行。
虽然这是 Web 服务器的语言,但编写所有这些内容非常繁琐。因此,创建了像 Web 浏览器和 HTTP 客户端库这样的工具来为我们构建和解析这些消息。
Web 框架
当然,我们可以用 Python 编写一个程序来接收这些原始的 HTTP 消息,解码它们,并返回适当的 HTTP 响应消息。然而,这需要大量的模板代码,难以扩展,并且容易出错。
有一些工具会为我们做这件事:Web 框架。Web 框架的工作是简化构建 HTTP 消息和处理请求的过程。许多框架更进一步,提供便利和实用工具以简化流程。
Python 生态系统中有许多 Web 框架,它们在提供这项工作方面各有不同。有些提供了大量的功能,有些提供得非常有限。有些非常严格,有些则更加开放。Sanic 尝试在功能丰富和不妨碍开发者之间找到一个平衡点。
Sanic 提供的一个特性是它既是 Web 框架又是 Web 服务器。
如果你对 PyPI 上的 Web 框架进行一次调查,你会发现大多数都需要安装一个单独的 Web 服务器。在部署 大多数 Python 应用程序时,机器上运行的持久操作和用于开发响应处理器的工具之间存在一条明确的界限。我们不会深入探讨 WSGI,因为它不适用于 Sanic。然而,有一个重要的范式需要理解:有一个服务器调用一个输入函数,传递有关请求的信息并期望得到响应。所有介于其间的都是框架。
如果我们的焦点缩小到支持 async/await 风格协程处理器的项目,那么绝大多数都需要你运行一个 ASGI 服务器。它遵循类似的模式:一个 ASGI 准备好的服务器调用一个 ASGI 准备好的框架。这两个组件通过一个特定的协议相互操作。目前有三个流行的 ASGI 服务器:uvicorn、hypercorn 和 daphne。
正因为 Sanic 诞生在 ASGI 之前的时代,它需要自己的服务器。随着时间的推移,这已成为其最大的资产之一,也是它优于大多数其他 Python 框架的部分原因。Sanic 服务器的开发高度专注于性能和最小化请求/响应周期。然而,近年来 Sanic 也采用了 ASGI 接口,以便它可以通过 ASGI 网络服务器运行。
然而,在这本书的大部分内容中,你可以假设当我们谈论运行 Sanic 时,我们指的是使用内部网络服务器。它是生产就绪的,并且仍然是部署 Sanic 的最佳方法之一。在 第八章,运行服务器 中,我们将讨论所有潜在的选择,并帮助你提出在决定哪种解决方案对你的需求来说是明显的问题。
为什么我们使用 Sanic——构建快速,运行快速。
让我们从了解 Sanic 的目标开始:
提供一种简单的方法来快速搭建、扩展并最终扩展高性能的 HTTP 服务器。
来源: sanicframework.org/en/guide/#goal
大多数熟悉 Sanic 项目的人都会告诉你,其定义特征是性能。虽然这当然很重要,但它只是 Sanic 项目核心哲学的一个方面。
Sanic 的口号是:“构建快速。运行快速。”这当然突出了项目的性能导向。它还表明,在 Sanic 中构建应用程序的目标是直观的。让应用程序快速运行不应该意味着学习复杂的一套 API 和几乎持续打开的文档的第二浏览器窗口。虽然其他工具大量使用“黑盒”类型的功能,如全局变量、“魔法”导入和猴子补丁,但 Sanic 通常更倾向于走向编写良好、干净且符合 Python 习惯(即 pythonic 代码)的方向。如果你了解 Python,你可以使用 Sanic 构建网络 API。
如果性能不是定义特征,那么是什么?Sanic 项目网站首页给出了六个原因,让我们来探索:
-
简单且轻量级
-
无偏见且灵活
-
性能卓越且可扩展
-
生产就绪
-
受数百万用户信赖
-
社区驱动
简单且轻量级
API 故意保持轻量级。这意味着最常见的属性和方法易于访问,你不需要花费大量时间来记忆特定的调用堆栈。在项目早期,曾有过关于添加某些功能的讨论。但通常添加功能会导致臃肿。SCO 决定,专注于提供优质的开发者体验比提供“铃铛和口哨”功能更为重要。
例如,如果我的应用程序旨在被第三方应用程序使用,那么为什么它需要 CORS?对于 Web 应用程序来说,需求差异很大,因此决定将这些功能留给插件和开发者。这导致了下一个原因。
无偏见且灵活
无偏见是一个巨大的优势。这意味着开发者决定他们是否需要会话或令牌认证,以及他们是否需要 ORM、原始 SQL 查询、NoSQL 数据库、组合,甚至根本不需要数据存储。这并不是说这些事情在其他框架中无法实现。但是,需要考虑某些设计决策。与其关注所有这些特性,Sanic 更愿意提供工具来实现你需要的功能,而无需更多。工具胜过特性。借用一句流行的谚语:Sanic 不给你鱼,而是教你如何捕鱼。
性能优异且可扩展
这就是 Sanic 最出名的特点。Sanic 以性能优先的开发方法,以及包括uvloop和ujson在内的工具在内的实现,往往能超越其他异步 Python 框架。我们不会在基准测试上花费太多时间,因为我倾向于认为在比较框架时它们的使用有限。对性能来说,更重要的是能够快速构建和快速扩展。Sanic 使得从单个部署运行多个服务器实例变得简单。第八章,运行服务器,将更详细地讨论扩展。同样重要的是要注意,由于 API 的故意灵活性,Sanic 非常适合构建单体应用程序、微服务以及介于两者之间的所有内容。
生产就绪
框架通常附带开发服务器。这些开发服务器通过包括你在工作时自动重新加载等特性,使构建过程变得更简单。然而,这些服务器往往不适合生产环境。Sanic 服务器是故意构建的,旨在成为生产系统中部署的主要策略。这导致了下一个原因。
受数百万信任
Sanic 已安装并支持众多大小不一的应用程序。它被用于企业构建的 Web 应用程序和个人 Web 项目中。它往往是 PyPI 上下载量最大的框架之一。在 2019 年 4 月到 2020 年 4 月之间,Django 的下载量达到了 4800 万次。在同一时期,Sanic 的下载量约为 4400 万次。这是一个具有高度可见性和广泛采用的项目,适用于各种使用场景。
由社区驱动
自从 2018 年从个人仓库迁移到社区组织以来,决策权已经通过社区成员所称为的“懒惰共识”共享。以下是 SCO 网站(sanicframework.org/en/guide/project/scope.html#lazy-consensus)上的说法:
通常情况下,只要没有人明确反对一个提案或补丁,它就被认为是得到了社区的认可。这被称为懒惰共识;也就是说,那些没有明确表达意见的人已经隐含地同意了提案的实施。
社区的一个重要因素是所有成员(无论是常规贡献者还是首次用户)都有能力参与到对话中,并将有价值的信息输入到对话中。尽可能多的,Sanic 试图成为“由社区,为社区”的,以确保其稳定性、功能集和未来。
强调以社区为先的组织本身是为了创造一种稳定性。项目上的所有工作都是由志愿者完成的。这是一项充满爱的工作。话虽如此,仅靠激情驱动的项目如果依赖于单一个人的肩膀,就有可能变得无人维护。这正是 Sanic 在创建 SCO 时试图避免的场景。作为一个“由社区”的项目意味着有多个愿意并且能够帮助继续前进的人。Sanic 通过一组轮换的开发者来实现这一点,并在长期稳定性和交错任期之间取得平衡。
更多关于 SCO
如果你想了解更多关于 SCO 的结构以及如何参与其中,请查看 Sanic 社区组织政策 E 手册(也称为 SCOPE):
sanicframework.org/en/guide/project/scope.html。
驱动代码决策的是什么?
虽然并没有被正式化,但 Sanic 的架构师和工程师在做出编码决策时,遵循一套潜在的原则。考虑到这个项目是由来自不同背景和经验水平的多个人共同打造的,因此了解到维护一套一致的编码实践本身就是一项挑战,也就不足为奇了。
我并不是在具体谈论诸如格式化等问题——像black、isort、flake8和mypy这样的工具已经将这些问题抽象化了。相反,代码应该是什么样子,应该如何组织,以及应该遵循哪些模式?
构建 Sanic 代码库背后的原则包括:
-
性能,
-
可用性(无偏见),以及
-
简单性
在请求/响应周期执行过程中将要运行的任何代码行都将对其性能影响进行高度审查。当面对一个将两个或更多核心原则置于对立的问题时,性能考虑几乎总是占上风。然而,有时必须使用较慢的替代方案,要么是为了:(1)不让开发者陷入尴尬的开发模式,或者(2)为开发者增加不必要的复杂性。Sanic 的“速度”不仅仅是指应用性能,还包括开发性能。
当使用 Sanic 时,我们可以确信有一支开发团队在审查每一行代码及其对性能、可用性和简洁性的影响。
让我们假设你被项目经理要求构建一个 API,他有一个明确的截止日期。为了达到这个目标,你希望尽可能快地开始运行。但是,你也不想失去对面临的问题进行迭代改进的自由,而不必担心做出糟糕的决定。本书的一个目标就是帮助你识别有用的模式,以适应并帮助你达到那里。
摘要
理解 Sanic 的历史和背后的决策有助于了解其功能集和实现。通常,Sanic 被视为将 async/await 风格编程引入 Flask 应用的尝试。虽然这可能是一个原始概念的一个公平观点,但 Sanic 已经发展出了一条非常不同的路径,其目标和影响是成为一个专为性能应用设计的强大工具。
因此,Sanic 通常被那些寻求构建一个满足其应用需求独特且明显的设计模式的丰富环境的开发者和团队所使用。项目的目的是去除构建网络服务器和提供创建高性能和可扩展网络应用工具的困难或繁琐部分。
现在我们已经了解了 Sanic 的背景,我们可以理解和欣赏使用 Sanic 作为网络框架的灵活性。了解 Sanic 的开发背景对我们学习如何在项目中使用它非常有帮助。下一步——从第二章,组织项目开始——是开始学习我们在开始任何新的网络开发项目时应该做出的基础决策。
第三章:2 组织项目
这是第 0 天。你手头有一个项目。你充满激情,准备构建一个新的 Web 应用程序。你的脑海中充满了想法,你的手指迫不及待地想要开始敲击键盘。是时候坐下来开始编码了!
或者是吗?在我们头脑中关于我们想要构建的想法开始形成时,立即开始构建应用程序是很诱人的。在这样做之前,我们应该考虑如何为成功做好准备。有一个坚实的基础将使这个过程更容易,减少错误,并产生更干净的应用程序。
开始任何 Python Web 应用程序项目的三个基础是:
-
你的 IDE/代码编辑器
-
运行你的开发应用程序的环境
-
一个项目应用程序结构
这三个元素考虑了很多个人喜好。有如此多的优秀工具和方法。没有任何一本书能够涵盖所有这些。如果你是一个经验丰富的开发者并且已经有一套偏好:太好了,继续前进并跳到下一章。
在本章中,我们将探索一些现代选项,帮助你快速启动。重点将放在第二个基础(环境)和第三个基础(应用程序结构)。我们跳过第一个基础,并假设你正在使用自己选择的现代 IDE。Python 世界中的流行选择包括 VS Code、PyCharm 和 Sublime Text。如果你没有使用这些或类似的产品,去查找并找到一个适合你的。
在我们设置好环境后,我们将探讨在 Sanic 中实现的一些模式,这将有助于定义你的应用程序架构。这不是一本软件架构的书。我强烈建议你学习像“领域驱动设计”和“清洁架构”这样的方法。这本书更多地关注在 Sanic 中构建 Web 应用程序的实用方面和决策,所以请根据需要自由调整模式。
在本章中,我们将探讨以下主题:
-
设置环境和目录
-
有效使用蓝图
-
整合所有元素
-
运行我们的应用程序
技术要求
在我们开始之前,我们将假设你已经在你的电脑上设置了以下内容:
-
现代 Python 安装(Python 3.7 或更高版本)
-
终端(以及如何执行程序的基本知识)
-
IDE(如上所述)
设置环境和目录
当你开始任何项目时,你采取的前几步将对整个项目产生重大影响。无论你是开始一个可能持续多年的项目,还是只需要几个小时就能完成的项目,这些早期的决定将塑造你和他人如何在这个项目上工作。但是,尽管这些选择很重要,不要陷入认为你需要找到完美解决方案的陷阱。没有单一的正确方式来设置环境或项目目录。记住我们之前章节的讨论:我们想要做出适合当前项目的选择。
环境
对于 Python 开发来说,一个良好的实践是将它的运行环境与其他项目隔离开来。这通常是通过虚拟环境来实现的。在最基本的理解中,虚拟环境是一个工具,它允许你在隔离的环境中安装 Python 依赖项。这很重要,因为当我们开始开发我们的应用程序时,我们可以控制所使用的需求和依赖项。如果没有它,我们可能会错误地运行我们的应用程序,导致其他项目的需求渗透到应用程序中,造成错误和意外的行为。
在 Python 开发世界中,虚拟环境的使用是如此基础,以至于在创建 Python 脚本或应用程序时,它已经成为预期的“规范”。当你开始一个新的项目时,你应该做的第一步是为它创建一个新的虚拟环境。它们的替代方案是使用操作系统安装的 Python 运行你的应用程序。不要这样做。这可能在一开始是可行的,但最终,你将遇到冲突的需求、命名冲突或其他困难,所有这些困难都源于缺乏隔离。成为更好的 Python 开发者的第一步是使用虚拟环境,如果你还没有这样做的话。
熟悉 IDE 提供的将虚拟环境连接到你的不同工具也非常有帮助。这些工具通常包括代码补全等功能,并指导你开始使用依赖项的功能。
我们最终希望使用容器来运行我们的应用程序。能够在 Docker 容器中运行我们的应用程序将大大减少未来部署应用程序的复杂性。这将在第九章,提高你的 Web 应用程序的最佳实践中进一步讨论。然而,我也相信我们的应用程序应该能够在多个环境中运行(因此可测试)。即使我们打算将来使用 Docker,我们首先也需要在没有它的本地环境中运行我们的应用程序。当我们的应用程序不依赖于过于复杂的依赖项来运行时,调试变得更加容易。因此,让我们花些时间思考如何设置虚拟环境。
关于如何使用虚拟环境有许多优秀的教程和资源。还有许多工具被创建出来以帮助管理这个过程。虽然我个人是 virtualenv 加 virtuanenvwrapper 这种简单、可靠方法的粉丝,但许多人喜欢 pipenv 或 poetry。这些后者的工具旨在成为您运行环境的更“完整”封装。如果它们适合您,那很好。我们鼓励您花些时间看看什么与您的开发模式和需求产生共鸣。
我们现在暂时不考虑虚拟环境,而是简要探索 Python 中一个相对较新的模式。在 Python 3.8 中,Python 采用了 PEP 582 中的一种新模式,该模式将需求正式包含到项目内部的一个特殊 __pypackages__ 目录中,从而在一个隔离的环境中。虽然这个概念与虚拟环境类似,但它的工作方式略有不同。
为了实现 __pypackages__,我们要求我们的虚构开发团队强制使用 pdm。这是一个相对较新的工具,它使得遵循现代 Python 开发的一些最新实践变得非常简单。如果您对此方法感兴趣,请花些时间阅读 PEP 582 (www.python.org/dev/peps/pep-0582/) 并查看 pdm (pdm.fming.dev/)。
您可以通过使用 pip 来安装它:
$ pip install --user pdm
请参考他们网站上的安装说明以获取更多详细信息:pdm.fming.dev/#installation。请特别注意像 shell 完成和 IDE 集成这样的有用功能。
现在我们继续进行设置:
-
要开始,我们为我们的应用程序创建一个新的目录,并从这个目录运行以下命令,然后按照提示设置基本结构。
$ mkdir booktracker $ cd booktracker $ pdm init -
现在我们将安装 Sanic。
$ pdm add sanic -
我们现在可以访问 Sanic。为了确认我们确实在一个隔离的环境中,让我们快速跳入 Python REPL,并使用以下命令检查 Sanic 的位置:sanic.file。
$ python >>> import sanic >>> sanic.__file__ '/path/to/booktracker/__pypackages__/3.9/lib/sanic/__init__.py'
Sanic CLI
如第八章所述,关于如何部署和运行 Sanic 有许多考虑因素。除非我们特别关注这些替代方案之一,否则在本书中您可以假设我们正在使用 Sanic CLI 运行 Sanic。这将使用集成的 Sanic 网络服务器启动我们的应用程序。
首先,我们将检查我们正在运行哪个版本:
$ sanic -v
Sanic 21.3.4
然后查看我们可以使用 CLI 的选项:
$ sanic -h
usage: sanic [-h] [-H HOST] [-p PORT] [-u UNIX] [--cert CERT] [--key KEY] [-w WORKERS] [--debug] [--access-logs | --no-access-logs] [-v] module
Sanic
Build Fast. Run Fast.
positional arguments:
module path to your Sanic app. Example: path.to.server:app
optional arguments:
-h, --help show this help message and exit
-H HOST, --host HOST host address [default 127.0.0.1]
-p PORT, --port PORT port to serve on [default 8000]
-u UNIX, --unix UNIX location of unix socket
--cert CERT location of certificate for SSL
--key KEY location of keyfile for SSL.
-w WORKERS, --workers WORKERS
number of worker processes [default 1]
--debug
--access-logs display access logs
--no-access-logs no display access logs
-v, --version show program's version number and exit
我们现在运行应用程序的标准形式将是:
$ sanic src.server:app -p 7777 --debug --workers=2
在使用此命令的决定中考虑了哪些因素?让我们看看。
为什么是 src.server:app?
首先,我们将从这个 ./booktracker 目录运行这个命令。我们所有的代码都将嵌套在 src 目录中。
其次,这是一个相对标准的做法,我们的应用程序创建一个单一的Sanic()实例,并将其分配给一个名为app的变量:
app = Sanic("BookTracker")
如果我们将它放入一个名为app.py的文件中,那么我们的模块和变量开始变得混乱。
from app import app
上述导入语句,嗯,很丑。尽可能避免模块和该模块内容之间的命名冲突是有益的。
标准库中存在一个这样的坏例子。你有没有不小心做过这个:
>>> import datetime
>>> datetime(2021, 1, 1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'module' object is not callable
哎呀,我们应该使用from datetime import datetime。我们希望最小化模块名称和属性的重复,并使我们的导入易于记忆和直观。
因此,我们将我们的全局app变量放入一个名为server.py的文件中。Sanic 会在你传入以下形式时查找我们的应用程序实例:<module>:<variable>。
为什么是-p 7777?
我们当然可以选择任何任意的端口。许多网络服务器将使用端口8000,如果我们完全忽略它,那么这就是 Sanic 的默认端口。然而,正是因为它是标准的,我们想要选择另一个端口。通常,选择一个不太可能与你的机器上运行的其它端口冲突的端口是有益的。我们能够保留的常用端口越多,我们遇到冲突的可能性就越小。
为什么是--debug?
在开发过程中,启用DEBUG模式可以提供:来自 Sanic 的更冗余的输出和一个自动重新加载的服务器。查看更多日志可能会有所帮助,但请确保在生产环境中关闭此功能。
自动重新加载功能特别有益,因为你可以在一个窗口中开始编写你的应用程序,然后在另一个终端会话中运行它。然后,每次你做出更改并保存应用程序时,Sanic 都会重新启动服务器,你的新代码立即可用于测试。
如果你想要自动重新加载,但又不想有所有额外的冗余信息,考虑使用--auto-reload代替。
为什么是--workers=2?
这并不是一个罕见的问题,有人开始构建一个应用程序,然后意识到他们没有为横向扩展做准备,最终犯了一个错误。也许他们添加了全局状态,这些状态无法在单个进程之外访问。
sessions = set()
@app.route("/login")
async def login(request):
new_session = await create_session(request)
sessions.add(new_session)
哎呀,现在那个人需要回去重新设计解决方案,如果他们想要扩展应用程序的话。这可能是一项代价高昂的任务。幸运的是,我们比那样聪明。
通过强制我们的开发模式从一开始就包括多个工作者,这将有助于我们在解决问题时提醒自己,我们的应用程序必须考虑到扩展。即使我们的最终部署不使用每个实例的多个 Sanic 工作者(例如,使用多个 Kubernetes pod,每个 pod 只有一个工作者实例–参见第九章,提高你的 Web 应用程序的最佳实践),这种持续的保护措施是保持最终目标与设计过程一致的有帮助方式。
目录结构
对于组织 Web 应用程序,你可以遵循许多不同的模式。可能最简单的是单文件server.py,其中包含所有的逻辑。由于显而易见的原因,这不是更大、更实际的项目的实际解决方案。所以我们将忽略这一点。
有哪些类型的解决方案?也许我们可以使用 Django 偏好的“apps”结构,将应用程序的离散部分组合成一个模块。或者,也许你更喜欢按类型分组,例如,将所有的视图控制器放在一起。在这里,我们不做任何关于什么更适合你需求的判断,但我们需要了解我们决策的一些后果。
在做决定时,你可能想了解一些常见的做法。这可能是一个查找以下模式的好机会:
-
模型视图控制器(MVC)
-
模型视图视图模型(MVVM)
-
领域驱动设计(DDD)
-
清洁架构(CA)
只是为了让你了解这些差异(或者至少是我对它们的解释),你可能会以以下方式之一来构建你的项目:
你可能会使用 MVC:
./booktracker
├── controllers
│ ├── book.py
│ └── author.py
├── models
│ ├── book.py
│ └── author.py
├── views
│ ├── static
│ └── templates
└── services
或者你可能使用 DDD:
./booktracker
└── domains
├── author
│ ├── view.py
│ └── model.py
├── book
│ ├── view.py
│ └── model.py
└── universal
└── middleware.py
在这本书中,我们将采用一种类似于混合方法的东西。应用这些理论结构的时间和地点是存在的。我敦促你们去学习它们。这些信息是有用的。但我们在这里是为了学习如何使用 Sanic 实际构建一个应用程序。
这是修改后的结构:
./booktracker
├── blueprints
│ ├── author
│ │ ├── view.py
│ │ └── model.py
│ └── book
│ ├── view.py
│ └── model.py
├── middleware
│ └── thing.py
├── common
│ ├── utilities
│ └── base
└── server.py
让我们逐一分析这些,看看它们可能是什么样子,并理解这个应用程序设计的背后的思考过程。
./blueprints
这可能让你觉得有些奇怪,因为最终这个目录看起来包含的不仅仅是蓝图。而且,你会是对的。查看树状结构,你会看到“blueprints”包括view.py和model.py。这个目录的目标是将你的应用程序分成逻辑组件或领域。它的工作方式与 Django 应用程序中的apps目录非常相似。如果你可以将某些结构或应用程序的一部分隔离为独立的实体,那么它可能应该有一个子目录。
本目录下的单个模块可能包含验证传入请求的模型、从数据库获取数据的实用工具以及带有附加路由处理器的蓝图。这样做可以将相关代码放在一起。
但为什么叫它blueprints?每个子目录将包含比单个Blueprint对象多得多的内容。重点是强化这样一个观点:这个目录中的所有内容都围绕这些离散组件之一。在 Sanic 中组织所谓的组件的标准方法是Blueprint(我们将在下一节中了解更多)。因此,每个子目录将有一个——而且只有一个——Blueprint对象。
另一个重要规则:./bluprints目录内的任何内容都不会引用我们的 Sanic 应用程序。这意味着在这个目录内禁止使用Sanic.get_app()和from server import app。
通常,将蓝图视为与您的 API 设计模式的一部分相对应是有帮助的。
-
example.com/auth -> ./blueprints/auth -
example.com/cake -> ./blueprints/cake -
example.com/pie -> ./blueprints/pie -
example.com/user -> ./blueprints/user
./middleware
此目录应包含任何旨在具有全局范围的中间件。
@app.on_request
async def extract_user(request):
user = await get_user_from_request(request)
request.ctx.user = user
如本章后面和第六章,响应周期之外以及 Sanic 用户指南(sanicframework.org/en/guide/best-practices/blueprints.html#middleware)中讨论的那样,中间件可以是全局的或附加到蓝图上。如果您需要将中间件应用于特定路由,基于蓝图的中间件可能是有意义的。在这种情况下,您应该将它们嵌套在适当的./blueprints目录中,而不是这里。
./common
此模块旨在存储用于构建应用程序的类定义和函数。它是关于将跨越您的蓝图并在您的应用程序中普遍使用的所有内容。
提示
尝试根据您的需求扩展这里的目录结构。但是,尽量不要添加太多的顶级目录。如果您开始使文件夹杂乱无章,考虑一下您如何能够将目录嵌套在彼此内部。通常,您会发现这会导致更清晰的架构。过度嵌套也是一件事情。例如,如果您需要在应用程序代码中导航到十层深度,可能应该适当减少。
这仍然是第一天。您头脑中还有很多关于您想要构建的伟大想法。多亏了一些深思熟虑的预先规划,我们现在已经为在本地构建应用程序提供了一个有效的设置。在这个时候,我们应该知道应用程序如何在本地运行,以及项目通常是如何组织的。接下来我们将学习的是从应用程序结构到业务逻辑的过渡步骤。
有效使用蓝图
如果您已经知道什么是蓝图,那么请暂时想象一下您不知道。当我们构建应用程序并试图以逻辑和可维护的模式结构化我们的代码库时,我们意识到我们需要不断传递我们的app对象:
from some.location import app
@app.route("/my/stuff")
async def stuff_handler(...):
...
@app.route("/my/profile")
async def profile_handler(...):
...
如果我们需要对我们的端点进行更改,这可能会变得非常繁琐。您可以想象一个场景,我们需要更新多个单独的文件,反复重复相同的更改。
可能更令人沮丧的是,我们可能会陷入一个存在循环导入的场景。
# server.py
from user import *
app = Sanic(...)
# user.py
from server import app
@app.route("/user")
...
蓝图解决了这两个问题,并允许我们抽象出一些内容,以便组件可以独立存在。回到上面的例子,我们将端点的公共部分(/my)添加到Blueprint定义中。
from sanic import Blueprint
bp = Blueprint("MyInfo", url_prefix="/my")
@bp.route("/stuff")
async def stuff_handler(...):
...
@bp.route("/profile")
async def profile_handler(...):
...
在这个例子中,我们能够将这些路由组合成一个单独的蓝图。重要的是,这允许我们将 URL 路径的公共部分(/my)拉到Blueprint中,这为我们提供了在将来进行更改的灵活性。
无论你决定如何组织你的文件结构,你可能应该始终使用蓝图。它们使组织更容易,甚至可以嵌套。我个人只会在我最简单的 Web 应用中使用@app.route。对于任何真实的项目,我总是将路由附加到蓝图上。
蓝图注册
仅创建我们的蓝图是不够的。Python 将无法知道它们的存在。我们需要导入我们的蓝图并将它们附加到我们的应用程序上。这是通过一个简单的注册方法完成的:app.blueprint()。
# server.py
from user import bp as user_bp
app = Sanic(...)
app.blueprint(user_bp)
常见的“陷阱”是误解了blueprint的作用。以下这样的代码不会按预期工作:
from sanic import Sanic, Blueprint
app = Sanic("MyApp")
bp = Blueprint("MyBrokenBp")
app.blueprint(bp)
@bp.route("/oops")
在我们注册蓝图的那一刻,所有附加到它的内容都将重新附加到应用程序上。这意味着在调用app.blueprint()之后添加到蓝图中的任何内容都不会应用。在上面的例子中,/oops将不会存在于应用程序中。因此,你应该尽可能晚地注册你的蓝图。
提示
我认为始终将蓝图变量命名为
bp非常方便。当我打开一个文件时,我自动就知道bp代表什么。有些人可能觉得将变量命名得更有意义更有帮助:user_bp或auth_bp。对我来说,我更愿意在我总是查看的文件中保持一致性,并在导入时重命名它们:from user import bp as user_bp。
蓝图版本控制
在 API 设计中,版本控制是一个非常强大且常见的结构。让我们想象一下,我们正在开发我们的书籍 API,该 API 将被客户使用。他们已经创建了他们的集成,也许他们已经使用了一段时间的 API。
你有一些新的业务需求,或者你想要支持的新功能。完成这一点的唯一方法是改变特定端点的工作方式。但这将破坏用户的向后兼容性。这是一个困境。
API 设计者通常通过版本控制他们的路由来解决此问题。Sanic 通过向路由定义添加一个关键字参数或(可能更有用)一个蓝图来简化这一点。
你可以在用户指南中了解更多关于版本控制的信息(sanicframework.org/en/guide/advanced/versioning.html),我们将在第三章“路由和接收 HTTP 请求”中更深入地讨论它。现在,我们将满足于知道我们的原始 API 设计需要修改,我们将在下一节中看到我们如何实现这一点。
分组蓝图
当你开始开发你的应用程序时,你可能会开始看到蓝图之间的相似性。就像我们看到了我们可以将路由的公共部分提取到 Blueprint 中一样,我们也可以将 Blueprint 的公共部分提取到 BlueprintGroup 中。这提供了相同的目的。
from myinfo import bp as myinfo_bp
from somethingelse import bp as somethingelse_bp
from sanic import Blueprint
bp = Blueprint.group(myinfo_bp, somethingelse_bp, url_prefix="/api")
我们现在已经将 /api 添加到了 myinfo 和 somethingelse 中定义的每个路由路径的开头。
通过分组蓝图,我们正在压缩我们的逻辑,减少重复。在上面的例子中,通过给整个组添加前缀,我们不再需要管理单个端点或甚至蓝图。我们确实需要在设计端点和项目结构布局时牢记嵌套的可能性。
在上一节中,我们提到了使用版本来提供灵活升级 API 的简单路径。让我们回到我们的图书跟踪应用程序,看看这可能会是什么样子。如果你还记得,我们的应用程序看起来是这样的:
./booktracker
└── blueprints
├── author
│ └── view.py
└── book
└── view.py
以及 view.py 文件:
-
# ./blueprints/book/view.py -
bp = Blueprint("book", url_prefix="/book") -
# ./blueprints/author/view.py -
bp = Blueprint("author", url_prefix="/author")
让我们设想这样一个场景:当我们的新业务需求是 /v2/books 路由时,这个 API 已经部署并为客户所使用。
我们将其添加到现有的架构中,它立即开始看起来丑陋且杂乱:
└── blueprints
├── author
│ └── view.py
├── book
│ └── view.py
└── book_v2
└── view.py
让我们重构这个。我们不会改变 ./blueprints/author 或 ./blueprints/book,只是让它们嵌套得深一点。这部分应用程序已经构建好了,我们不想去动它。但是,既然我们已经从错误中吸取了教训,我们想要修改我们的 /v2 端点的策略,使其看起来像这样:
└── blueprints
├── v1
│ ├── author
│ │ └── view.py
│ ├── book
│ │ └── view.py
│ └── group.py
└── v2
├── book
│ └── view.py
└── group.py
我们刚刚创建了一个新的文件,group.py:
# ./blurprints/v2/group.py
from .book.view import bp as book_bp
from sanic import Blueprint
group = Blueprint.group(book_bp, version=2)
在构建复杂的 API 时,分组蓝图是一个强大的概念。它允许我们根据需要嵌套蓝图,同时为我们提供路由和组织控制。在这个例子中,注意我们是如何将 version=2 分配给组的。这意味着现在,这个组中每个与蓝图关联的路由都将有一个 /v2 路径前缀。
连接所有组件
正如我们所学的,创建一个实用的目录结构会导致可预测且易于导航的源代码。因为对我们开发者来说是可预测的,对计算机运行来说也是可预测的。也许我们可以利用这一点。
之前我们讨论了在尝试将应用程序从单文件结构扩展时经常遇到的一个问题:循环导入。我们可以用我们的蓝图很好地解决这个问题,但这仍然让我们对在应用程序级别可能想要附加的东西(如中间件、监听器和信号)感到困惑。现在让我们看看这些用例。
控制导入
通常我们更倾向于使用嵌套目录和文件将代码拆分成模块,这有助于我们逻辑上思考我们的代码,同时也便于导航。但这并非没有代价。当有两个相互依赖的模块时会发生什么?这将导致循环导入异常,我们的 Python 应用程序将崩溃。我们需要不仅考虑如何逻辑上组织我们的代码,还要考虑代码的不同部分如何在其他位置导入和使用。
考虑以下示例。首先,创建一个名为./server.py的文件,如下所示:
app = Sanic(__file__)
其次,创建一个名为./services/db.py的第二个文件。
app = Sanic.get_app()
@app.before_server_start
async def setup_db_pool(app, _):
...
这个例子说明了问题。当我们运行我们的应用程序时,我们需要在Sanic.get_app()之前运行Sanic(__file__)。但是,我们需要导入.services.db以便它能够附加到我们的应用程序上。哪个文件先评估?由于 Python 解释器将按顺序执行指令,我们需要确保在导入db模块之前实例化Sanic()对象。
这将有效:
app = Sanic(__file__)
from .services.db import *
但是,它看起来有点丑陋,也不符合 Python 的风格。确实,如果你运行像flake8这样的工具,你将开始注意到你的环境也不太喜欢这种模式。它打破了在文件顶部放置导入的正常做法。在这里了解更多关于这种反模式的信息:www.flake8rules.com/rules/E402.html。
你可能觉得这并不重要,这是完全可以接受的。记住,我们做这件事是为了找到适合你应用程序的解决方案。然而,在我们做出决定之前,让我们看看一些其他的替代方案。
我们可以有一个单一的“启动”文件,这将是一个受控的导入顺序集合:
# ./startup.py
from .server import app
from .services.db import *
现在,我们不想运行sanic server:app,而是想将我们的服务器指向新的startup.py。
sanic startup:app
让我们继续寻找替代方案。
提示
Sanic.get_app()结构是一个非常有用的模式,可以在不通过导入传递的情况下访问你的应用程序实例。这是朝着正确方向迈出的一个非常有帮助的步骤,你可以在用户指南中了解更多关于它的信息。sanicframework.org/en/guide/basics/app.html#app-registry
工厂模式
我们将把应用程序创建移动到工厂模式。如果你来自 Flask,你可能熟悉这个概念,因为许多示例和教程都使用了类似的构造。在这里这样做的主要原因是我们希望为未来的良好开发实践设置我们的应用程序。这最终也将解决循环导入问题。在第九章后面,我们将讨论测试。如果没有好的工厂,测试将变得非常困难。
我们需要创建一个新的文件./utilities/app_factory.py,并重新做我们的./server.py。
# ./utilities/app_factory.py
from typing import Optional, Sequence
from sanic import Sanic
from importlib import import_module
DEFAULT_BLUEPRINTS = [
"src.blueprints.v1.book.view",
"src.blueprints.v1.author.view",
"src.blueprints.v2.group",
]
def create_app(
init_blueprints: Optional[Sequence[str]] = None,
) -> Sanic:
app = Sanic("BookTracker")
if not init_blueprints:
init_blueprints = DEFAULT_BLUEPRINTS
for module_name in init_blueprints:
module = import_module(module_name)
app.blueprint(getattr(module, "bp"))
return app
from .utilities.app_factory import create_app
app = create_app()
如你所见,我们的新工厂将创建app实例,并将一些蓝图附加到它上。我们特别允许工厂覆盖它将使用的蓝图。也许这并不必要,我们完全可以一直将它们硬编码。但我喜欢这种灵活性,并且发现它在以后的道路上很有帮助,当我想要开始测试我的应用程序时。
可能会跳出一个问题,那就是它要求我们的模块有一个全局的bp变量。虽然我提到这是我的标准做法,但它可能并不适用于所有场景。
自动发现
Sanic 用户指南在“如何做…”部分给了我们另一个想法。见 https://sanicframework.org/en/guide/how-to/autodiscovery.html。它建议我们创建一个autodiscover工具,该工具将为我们处理一些导入,并且还有自动附加蓝图的好处。记得我曾经说过我喜欢可预测的文件夹结构吗?我们即将利用这个模式。
让我们创建./utilities/autodiscovery.py:
# ./utilities/autodiscovery.py
from importlib import import_module
from inspect import getmembers
from types import ModuleType
from typing import Union
from sanic.blueprints import Blueprint
def autodiscover(app, *module_names: Union[str, ModuleType]) -> None:
mod = app.__module__
blueprints = set()
def _find_bps(module: ModuleType) -> None:
nonlocal blueprints
for _, member in getmembers(module):
if isinstance(member, Blueprint):
blueprints.add(member)
for module in module_names:
if isinstance(module, str):
module = import_module(module, mod)
_find_bps(module)
for bp in blueprints:
app.blueprint(bp)
此文件与用户指南中建议的内容非常相似(sanicframework.org/en/guide/how-to/autodiscovery.html#utility.py)。值得注意的是,那里展示的代码中缺少递归的概念。如果你在用户指南中查找该函数,你会看到它包括递归搜索我们的源代码以查找Blueprint实例的能力。虽然很方便,但在我们正在构建的应用程序中,我们希望通过声明每个蓝图的位置来获得明确控制。再次引用 Tim Peters 的《Python 之禅》:
明确优于隐晦。
自动发现工具的作用是允许我们将位置传递给模块,并将导入它们的任务交给应用程序。加载模块后,它将检查任何蓝图。最后,它将自动将发现的蓝图注册到我们的应用程序实例中。
现在,我们的server.py看起来是这样的:
from typing import Optional, Sequence
from sanic import Sanic
from .autodiscovery import autodiscover
DEFAULT_BLUEPRINTS = [
"src.blueprints.v1.book.view",
"src.blueprints.v1.author.view",
"src.blueprints.v2.group",
]
def create_app(
init_blueprints: Optional[Sequence[str]] = None,
) -> Sanic:
app = Sanic("BookTracker")
if not init_blueprints:
init_blueprints = DEFAULT_BLUEPRINTS
autodiscover(app, *init_blueprints)
return app
提示
在这个例子中,我们使用字符串作为导入路径。我们同样可以在这里导入模块,并传递这些对象,因为
autodiscover工具既适用于模块对象也适用于字符串。我们更喜欢字符串,因为它可以避免讨厌的循环导入异常。
另一个需要注意的事情是,这个自动发现工具可以用于包含中间件或监听器的模块。给出的例子仍然相当简单,不会涵盖所有用例。例如,我们应该如何处理深度嵌套的蓝图组?这是一个很好的机会让你进行实验,我强烈鼓励你花些时间玩转应用程序结构和自动发现工具,以找出最适合你的方法。
运行我们的应用程序
现在我们已经奠定了应用程序的基础,我们几乎准备好运行我们的服务器了。我们将在server.py中进行一个小改动,以包含一个在启动时运行的实用工具,显示已注册的路由。
from .utilities.app_factory import create_app
from sanic.log import logger
app = create_app()
@app.main_process_start
def display_routes(app, _):
logger.info("Registered routes:")
for route in app.router.routes:
logger.info(f"> /{route.path}")
你可以前往 GitHub 仓库github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/02查看完整的源代码。
我们现在可以首次启动我们的应用程序。记住,这将是我们的模式:
$ sanic src.server:app -p 7777 --debug --workers=2
我们应该看到类似这样的情况:
[2021-05-30 11:34:54 +0300] [36571] [INFO] Goin' Fast @ http://127.0.0.1:7777
[2021-05-30 11:34:54 +0300] [36571] [INFO] Registered routes:
[2021-05-30 11:34:54 +0300] [36571] [INFO] > /v2/book
[2021-05-30 11:34:54 +0300] [36571] [INFO] > /book
[2021-05-30 11:34:54 +0300] [36571] [INFO] > /author
[2021-05-30 11:34:54 +0300] [36572] [INFO] Starting worker [36572]
[2021-05-30 11:34:54 +0300] [36573] [INFO] Starting worker [36573]
欢呼!
现在,到了最诱人的部分。我们的代码实际上做了什么?打开你最喜欢的网络浏览器并访问:127.0.0.1:7777/book。现在可能看起来不多,但你应该能看到一些 JSON 数据。接下来,尝试访问/author和/v2/book。你现在应该能看到我们上面创建的内容。你可以随意玩转这些路由,向它们添加内容。每次你这样做,你都应该在浏览器中看到你的更改。
我们正式开始了网络应用程序开发的旅程。
摘要
我们已经审视了我们关于设置环境和项目组织所做的一些早期决策的重要影响。我们可以——并且应该——不断地调整我们的环境和应用程序以满足不断变化的需求。我们使用了pdm来利用运行服务器的新工具,在定义良好且隔离的环境中运行。
在我们的例子中,我们随后开始构建我们的应用程序。也许我们在添加/book路由时过于仓促,因为我们很快意识到我们需要端点以不同的方式执行。为了避免破坏现有用户的程序,我们简单地创建了一个新的蓝图组,这将是我们的 API /v2的开始。通过嵌套和分组蓝图,我们正在为未来的灵活性和开发可维护性设置应用程序。从现在开始,让我们尽可能坚持这个模式。
我们还考察了几种组织应用程序逻辑的替代方法。这些早期的决策将影响导入顺序并塑造应用程序的外观。我们决定采用一种工厂方法,这将在我们开始测试应用程序时对我们有所帮助。
基本应用结构确定后,我们将在下一章开始探讨网络服务器和框架最重要的方面:处理请求/响应周期。我们知道我们将使用蓝图,但现在是我们深入探究使用 Sanic 路由和处理器的可能性的时候了。在这一章中,我们已经对 API 版本化有所了解。在下一章中,我们还将更广泛地探讨路由,并尝试理解一些在 Web API 中设计应用程序逻辑的策略。
第四章:3 路由和接收 HTTP 请求
在第一章,Sanic 和异步框架的介绍中,我们查看了一个原始 HTTP 请求以了解它包含的信息。在本章中,我们将更仔细地查看包含 HTTP 方法和 URI 路径的第一行。正如我们所学的,Web 框架最基本的功能是将原始 HTTP 请求转换为可执行的处理程序。在我们看到如何实现之前,记住原始请求的样子是好的:
POST /path/to/endpoint HTTP/1.1
Host: localhost:7777
User-Agent: curl/7.76.1
Accept: */*
Content-Length: 14
Content-Type: application/json
{"foo": "bar"}
观察请求,我们看到以下内容:
-
第一行(有时称为起始行)包含三个子部分:HTTP 方法、请求目标和HTTP协议
-
第二部分包含零个或多个以
key: value形式出现的 HTTP 头信息,每对之间由换行符分隔 -
然后,我们有一个空白行将头部与正文分开
-
最后,我们还有可选的正文
具体的规范由 RFC 7230,3. datatracker.ietf.org/doc/html/rfc7230#section-3 覆盖
本书的一个目标是通过学习策略来设计易于消费的 API 端点,同时考虑到我们正在构建的应用程序的需求和限制。目标是理解服务器与传入的 Web 请求的第一交互,以及如何围绕这一点设计我们的应用程序。我们将学习:请求的结构;Sanic 为我们做出的选择以及它留下的选择;以及将 HTTP 请求转换为可执行代码所涉及的其他问题。记住,本书的目的不仅仅是学习如何使用一个花哨的新工具,还要提升 Web 开发和知识技能。为了成为更了解的开发者,我们不仅寻求理解如何使用 Sanic 构建,还要理解为什么我们可能以特定方式构建某些内容。通过理解一些机制,我们将学会提出更好的问题并做出更好的决策。这并不意味着我们需要成为 HTTP 协议和规范的专家。然而,通过熟悉 Sanic 对原始请求的处理,我们最终将拥有构建 Web 应用程序的更强大的工具集。
特别是,我们将涵盖以下主题:
-
理解 HTTP 方法
-
路径、斜杠及其重要性
-
高级路径参数
-
API 版本控制
-
虚拟主机
-
提供静态内容
技术要求
除了我们之前所构建的内容之外,在本章中,你应该拥有以下工具以便能够跟随示例进行学习:
-
Docker Compose
-
Curl
-
您可以在 GitHub 上找到本章的源代码:
github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/03
理解 HTTP 方法
如果你之前构建过任何类型的网站,你可能对HTTP 方法的概念有所了解;或者至少对基本的GET和POST方法有所了解。然而,你知道有九种标准的 HTTP 方法吗?在本节中,我们将了解这些不同的方法以及我们可以如何利用它们。
就像 IP 地址或网络域名是互联网上的地点一样,HTTP 方法是在互联网上的动作。它们是网络语言中的动词集合。这些 HTTP 方法有一个共同的理解和意义。Web 应用程序通常会在类似的使用场景中使用这些方法。这并不意味着你必须遵循这些约定,或者如果你的应用程序偏离了标准就会出错。我们应该学习这些规则,以便我们知道何时可能适合打破它们。这些标准存在是为了创建一个共同的语言,让 Web 开发者和消费者可以使用它来沟通:
| 方法 | 描述 | 有主体 | 安全 | Sanic 支持 |
|---|---|---|---|---|
CONNECT |
打开双向通信,如到资源的隧道 | 否 | 是 | 否 |
DELETE |
删除资源 | 否(通常) | 否 | 是 |
GET |
获取资源 | 否 | 是 | 是 |
HEAD |
仅获取资源的元数据 | 否 | 是 | 是 |
OPTIONS |
请求允许的通信选项 | 否 | 是 | 是 |
PATCH |
部分修改资源 | 是 | 否 | 是 |
POST |
向服务器发送数据 | 是 | 否 | 是 |
PUT |
创建新资源或如果存在则完全更新 | 是 | 否 | 是 |
TRACE |
执行用于调试的消息回环 | 否 | 是 | 否 |
表 3.1 - HTTP 方法概述
当我们谈论一个方法是否安全时,我们的意思是它不应该改变状态。这并不是说GET方法不能有副作用。当然,它可以。例如,有人点击端点会触发日志或某种资源计数器。这些在技术上可能是行业所说的副作用。“这里的重要区别是用户没有请求这些副作用,因此不能对它们负责。” RFC 2616,9.1.1 (datatracker.ietf.org/doc/html/rfc2616#section-9)。这意味着从用户访问资源的角度来看,确定一个端点是否安全是一个意图问题。如果用户意图检索个人资料信息,则是安全的。如果用户意图更新个人资料信息,则是不安全的。
尽管尝试坚持 表 3.1 中的方法描述当然很有帮助,但无疑你将遇到不符合这些类别的用例。当这种情况发生时,我鼓励你重新审视你的应用程序设计。有时问题可以通过新的端点路径来解决。有时我们需要创建自己的定义。这是可以的。然而,我警告不要将 安全 方法改为 不安全。使用 GET 请求进行状态更改被认为是不良的做法,是 新手错误。
在决定我们的 HTTP 方法之后,我们将进入下一节学习如何实现它们并将它们附加到路由上。
在路由处理器中使用 HTTP 方法
我们终于准备好学习和了解框架是什么了!如果你之前使用过 Flask,这会看起来很熟悉。如果没有,我们即将要做的是创建一个路由定义,它是一组指令,告诉 Sanic 将任何传入的 HTTP 请求发送到我们的路由处理器。路由定义必须有两个部分:一个 URI 路径和一个或多个 HTTP 方法。
仅匹配 URI 路径是不够的。HTTP 方法也被 Sanic 用于将你的传入请求路由到正确的处理器。即使我们实现最基本的路由定义形式,这两部分都必须存在。让我们看看最简单的用例,看看 Sanic 会做出什么默认选择:
@app.route("/my/stuff")
async def stuff_handler(...):
...
在这个例子中,我们在 /my/stuff 上定义了一个路由。通常我们会用可选的 methods 参数注入 route() 调用,以告诉它我们希望该处理器响应哪些 HTTP 方法。我们没有在这里这样做,所以它将默认为 GET。我们有告诉路由它应该处理其他 HTTP 方法的选项:
@app.route("/my/stuff", methods=["GET", "HEAD"])
async def stuff_handler(...):
return text("Hello")
重要提示
我们将在本章稍后讨论
HEAD方法。但重要的是要知道,HEAD请求不应该有任何响应体。Sanic 会为我们强制执行这一点。尽管技术上这个端点正在响应文本Hello,但 Sanic 会从响应中移除体,只发送元数据。
现在我们已经设置了一个具有多个方法的单个端点,我们可以用这两种方法来访问它。
首先,使用 GET 请求(应该注意的是,当使用 curl 时,如果你没有指定方法,它将默认为 GET):
$ curl localhost:7777/my/stuff -i
HTTP/1.1 200 OK
content-length: 5
connection: keep-alive
content-type: text/plain; charset=utf-8
Hello
Then, with a HEAD request.
$ curl localhost:7777/my/stuff -i --head
HTTP/1.1 200 OK
content-length: 5
connection: keep-alive
content-type: text/plain; charset=utf-8
为了方便起见,Sanic 为其支持的所有 HTTP 方法在应用实例和任何 Blueprint 实例上提供了快捷装饰器:
@app.get("/")
def get_handler(...):
...
@app.post("/")
def post_handler(...):
...
@app.put("/")
def put_handler(...):
...
@app.patch("/")
def patch_handler(...):
...
@app.delete("/")
def delete_handler(...):
...
@app.head("/")
def head_handler(...):
...
@app.options("/")
def options_handler(...):
...
这些装饰器也可以堆叠。我们之前看到的最后一个例子也可以这样写:
@app.head("/my/stuff")
@app.get("/my/stuff")
async def stuff_handler(...):
return text("Hello")
关于 HTTP 方法还有一点需要了解,那就是你可以访问 HTTP 请求对象上的传入方法。如果你在同一个处理器上处理不同类型的 HTTP 方法,但需要以不同的方式处理它们,这非常有帮助。以下是一个例子,我们通过查看 HTTP 方法来改变处理器的行为
from sanic.response import text, empty
from sanic.constants import HTTPMethod
@app.options("/do/stuff")
@app.post("/do/stuff")
async def stuff_handler(request: Request):
if request.method == HTTPMethod.OPTIONS:
return empty()
else:
return text("Hello")
在继续到高级方法路由之前,我们应该提到一些 Sanic 语法。这里所有的示例都使用装饰器语法来定义路由。这无疑是实现这一目标最常见的方式,因为它很方便。然而,还有一个替代方案。所有的路由定义都可以转换为如下所示的功能定义:
@app.get("/foo")
async def handler_1(request: Request):
...
async def handler_2(request: Request):
...
app.add_route(handler_2, "/bar")
在某些情况下,这可能是一个更吸引人的模式来使用。当我们在本章后面遇到基于类的视图时,我们还会再次看到它。
高级方法路由
Sanic 默认不支持 CONNECT 和 TRACE 这两种标准 HTTP 方法。但让我们想象一下,如果你想构建一个 HTTP 代理或需要在其路由处理程序中提供 CONNECT 方法的其他系统。尽管 Sanic 默认不允许这样做,但你有两种潜在的方法:
首先,我们可以创建一个中间件,它会寻找 CONNECT 并劫持请求以提供自定义响应。这种从中间件响应的 技巧 是一个允许你在处理程序接管之前停止请求/响应生命周期的功能,否则会失败并显示 404 Not Found:
async def connect_handler(request: Request):
return text("connecting...")
@app.on_request
async def method_hijack(request: Request):
if request.method == "CONNECT":
return await connect_handler(request)
你可以看到,这种方法的潜在缺点是我们需要实现自己的路由系统,如果我们想要将不同的端点发送到不同的处理程序。
第二种方法可能是告诉 Sanic 路由器 CONNECT 是一个有效的 HTTP 方法。一旦我们这样做,我们就可以将其添加到正常的请求处理程序中:
app.router.ALLOWED_METHODS = [*app.router.ALLOWED_METHODS, "CONNECT"]
@app.route("/", methods=["CONNECT"])
async def connect_handler(request: Request):
return text("connecting...")
对于这种策略的一个重要考虑是,你需要在注册新处理程序之前尽可能早地重新定义 app.router.ALLOWED_METHODS。因此,它最好直接在 app = Sanic(...) 之后进行。
这种策略提供的一个附带好处是能够创建自己的 HTTP 方法生态系统,并使用自己的定义。如果你打算让你的 API 供公众使用,这可能并不一定可取。然而,对于你自己的目的来说,这可能是有用的、实用的,或者只是纯粹的乐趣。虽然有九种标准方法,但可能性是无限的。你想要创建自己的动词吗?你当然可以这样做。
ATTACK /path/to/the/dragon HTTP/1.1
方法安全性和请求体
正如我们所学的,通常有两种类型的 HTTP 方法:安全 和 不安全。不安全的方法是 POST、PUT、PATCH 和 DELETE。这些方法通常被理解为它们是改变状态的。也就是说,通过点击这些端点,用户意图以某种方式改变或修改资源。
相反的是安全方法:GET、HEAD 和 OPTIONS。这些端点的目的是从应用程序请求信息,而不是改变状态。
被认为是一种良好的实践来遵循这一做法。如果一个端点将在服务器上做出更改,请不要使用 GET。
与这种划分相对应的是请求体的概念。让我们再次回顾一下原始的 HTTP 请求:
POST /path/to/endpoint HTTP/1.1
Host: localhost:7777
User-Agent: curl/7.76.1
Accept: */*
Content-Length: 14
Content-Type: application/json
{"foo": "bar"}
HTTP 请求可以可选地包含一个消息体。在上面的例子中,请求体是最后一行:{"foo": "bar"}。
需要注意的是,Sanic 只会花费时间读取POST、PUT和PATCH请求的消息体。如果是一个使用任何其他 HTTP 方法的 HTTP 请求,它将在读取头部后停止读取 HTTP 消息。这是一个性能优化,因为我们通常不期望在安全的 HTTP 请求中存在消息体。
你可能已经注意到这个列表中没有包括DELETE。为什么?一般来说,HTTP 规范说可能存在请求体(datatracker.ietf.org/doc/html/rfc7231#section-4.3.5)。Sanic 假设除非你告诉它,否则它不会存在。为此,我们只需设置ignore_body=False:
@app.delete("/", ignore_body=False)
async delete_something(request: Request):
await delete_something_using_request(request.body)
如果我们没有设置ignore_body=False,并且我们在DELETE请求中发送一个消息体,Sanic 将在日志中发出警告,让我们知道 HTTP 消息的一部分没有被消费。如果你打算使用DELETE方法,你应该注意这一点,因为 Sanic 做出了这样的假设。还应该注意的是,如果你习惯于接收带有消息体的 GET 请求,你也需要使用ignore_body=False。然而,我希望你有一个非常好的理由来做这件事,因为这将违反大多数网络标准。
从这个例子中我们可以得到的一个有用的启示是,开箱即用,以下两个端点并不相等。
@app.route("/one", methods=["GET"])
async def one(request: Request):
return text("one")
@app.get("/two")
async def two(request: Request):
return text("two")
/one和/two的行为将相似。然而,如果没有进一步的定制,第一个请求将花费时间尝试读取可能不存在的请求体,而第二个则假设不存在消息体。虽然性能差异可能很小,但通常更倾向于使用@app.get("/two")而不是@app.route("/one", methods=["GET"])。这两个端点之所以不同,是因为它们对ignore_body的默认值不同。
重要提示
如果你正在构建一个 GraphQL 应用程序,那么通常即使对于信息请求,端点也会使用
POST。这是因为将消息体放在POST请求中通常比放在GET请求中更容易被接受。然而,值得一提的是,如果我们真的想的话,我们可以通过设置ignore_body=False从GET请求中消费消息体。
当决定使用哪种方法时,另一个需要考虑的因素是幂等性。简而言之,幂等性意味着你可以重复执行相同的操作,每次的结果都应该是相同的。被认为是幂等的 HTTP 方法有:GET、HEAD、PUT、DELETE、OPTIONS和TRACE。在设计你的 API 时请记住这一点。
RESTful API 设计
HTTP 方法通常用于 RESTful API 设计。关于构建 RESTful API 的文献已经非常丰富,因此我们不会深入探讨它是什么,而是更多地关注我们如何实际实现它。然而,我们首先应该快速回顾一下基本前提。
Web API 端点有一个目标。这个目标是指用户想要获取信息或通过添加或更改来操作的东西。基于共同的理解,HTTP 方法告诉服务器您希望如何与该目标交互。该 目标 通常被称为 资源,在这里我们可以互换使用这些术语。
为了理解这个概念,我喜欢回想我小时候玩的冒险电脑游戏。我的冒险角色会偶然发现一个物品:比如说一个橡胶鸡。当我点击那个物品时,会出现一个菜单,显示不同的动词,告诉我我可以对这个物品做什么:捡起、查看、使用、交谈等等。有一个目标(橡胶鸡),以及方法(动词或动作)。
将这些与我们上面定义的 HTTP 方法结合起来,让我们看看一个具体的例子。在我们的假设情况下,我们将构建一个用于管理喜欢冒险电脑游戏的人们的社交媒体平台的 API。用户需要能够创建个人资料、查看其他个人资料以及更新自己的个人资料。我们可能会设计以下端点:
| 方法 | URI 路径 | 描述 |
|---|---|---|
GET |
/profiles |
所有成员个人资料的列表 |
POST |
/profiles |
创建新的个人资料 |
GET |
/profiles/<username> |
获取单个用户的个人资料 |
PUT |
/profiles/<username> |
删除旧的个人资料并用完整的个人资料替换 |
PATCH |
/profiles/<username> |
仅对个人资料的一部分进行更改 |
DELETE |
/profiles/<username> |
删除个人资料——但为什么有人会想删除他们的冒险游戏玩家个人资料呢? |
表 3.2 - 示例 HTTP 方法与端点
在我们继续之前,如果您对 Sanic 中的路由工作方式不熟悉(以及 <username> 语法意味着什么),您可以在用户指南中获取更多信息:sanicframework.org/en/guide/basics/routing.html,我们也会在本章的“从路径中提取信息”部分更详细地探讨它。您可以自由地跳过并稍后回来。
如您所见,实际上只有两个 URI 路径:/profiles 和 /profiles/<username>。然而,通过使用 HTTP 方法,我们已经能够定义与我们的 API 的六种不同交互!个人资料蓝图可能是什么样的呢?
from sanic import Blueprint, Request
bp = Blueprint("MemberProfiles", url_prefix="/profile")
@bp.get("")
async def fetch_all_profiles(request: Request):
...
@bp.post("")
async def create_new_profile(request: Request):
...
@bp.get("/<username>")
async def fetch_single_profile(request: Request, username: str):
...
@bp.put("/<username>")
async def replace_profile(request: Request, username: str):
...
@bp.patch("/<username>")
async def update_profile(request: Request, username: str):
...
@bp.delete("/<username>")
async def delete_profile(request: Request, username: str):
...
使用 HTTP 方法来定义我们的用例似乎很有帮助,并且有映射它们的装饰器似乎很方便。但是,似乎有很多样板代码和重复。接下来,我们将探讨基于类的视图以及我们如何简化我们的代码。
使用基于类的视图简化你的端点
之前的例子暴露了仅使用函数和装饰器来设计 API 的弱点。当我们想要为 /profile/<user_id:uuid> 添加端点处理程序时会发生什么?或者当我们想要对现有端点进行其他更改时?我们现在有多个地方可以做出相同的更改,这导致我们无法在所有路由定义之间保持一致性,这是违反了 DRY(不要重复自己)原则的,可能会导致错误。因此,从长远来看,维护这些端点可能比必要的更困难。
这就是使用 基于类的视图(CBV)的一个非常有说服力的原因。这个模式将给我们提供将前两个端点和最后四个端点链接在一起的机会,使它们更容易管理。它们被分组在一起是因为它们共享相同的 URI 路径。而不是独立的函数,每个 HTTP 方法将是一个类上的功能方法。而且,这个类将被分配一个公共的 URI 路径。一点代码应该能让你更容易理解:
from sanic import Blueprint, Request, HttpMethodView
bp = Blueprint("MemberProfiles", url_prefix="/profile")
class AllProfilesView(HttpMethodView):
async def get(request: Request):
"""same as fetch_all_profiles() from before"""
async def post(request: Request):
"""same as create_new_profile() from before"""
class SingleProfileView(HttpMethodView):
async def get(request: Request, username: str):
"""same as fetch_single_profile() from before"""
async def put(request: Request, username: str):
"""same as replace_profile() from before"""
async def patch(request: Request, username: str):
"""same as update_profile() from before"""
async def delete(request: Request, username: str):
"""same as delete_profile() from before"""
app.add_route(AllProfilesView.as_view(), "")
app.add_route(SingleProfileView.as_view(), "/<username>")
重要提示
在本书的后面,我们可能会看到更多使用自定义装饰器来添加共享功能的情况。值得一提的是,我们也可以很容易地将它们添加到 CBV 中,我强烈建议你花点时间查阅用户指南,看看它是如何工作的:
sanicframework.org/en/guide/advanced/class-based-views.html#decorators在向 CBV 方法添加装饰器时,需要注意的一点是实例方法的
self参数。你可能需要调整你的装饰器,或者使用staticmethod来使其按预期工作。上面提到的文档解释了如何做到这一点。
之前,我们看到了如何使用 add_route 作为将单个函数作为处理程序附加到路由定义的替代方法。它看起来是这样的:
async def handler(request: Request):
...
app.add_route(handler, "/path")
这种模式是将 CBV 附加到 Sanic 或 Blueprint 实例的主要方式之一。需要注意的是,你需要使用类方法 as_view() 来调用它。在我们之前的例子中,我们看到了它的样子:
app.add_route(SingleProfileView.as_view(), "/<username>")
这也可以通过在声明时附加 CBV 来实现。这个选项只在你已经有一个已知的 Blueprint 或 Application 实例时才有效。我们将重写 SingleProfileView 以利用这种替代语法。
class SingleProfileView(
HttpMethodView,
attach=app,
uri="/<username>"
):
async def get(request: Request, username: str):
"""same as fetch_single_profile() from before"""
async def put(request: Request, username: str):
"""same as replace_profile() from before"""
async def patch(request: Request, username: str):
"""same as update_profile() from before"""
async def delete(request: Request, username: str):
"""same as delete_profile() from before"""
你应该如何决定使用哪一个?我个人觉得第二个版本更容易、更简洁。但最大的缺点是,你不能懒加载 CBV 并稍后附加,因为它需要提前知道。
对 OPTIONS 和 HEAD 的全面支持
通常,在所有端点上支持OPTIONS和HEAD方法是一种最佳实践,只要这是合适的。这可能会变得繁琐,包括大量的重复模板代码。仅使用标准路由定义来实现这一点,就需要大量的代码重复,如下所示。下面,我们看到我们需要两个路由定义,而实际上只需要一个。现在想象一下,如果每个端点都需要有OPTIONS和HEAD会怎样!
@app.get("/path/to/something")
async def do_something(request: Request):
...
@app.post("/path/to/something")
async def do_something(request: Request):
...
@app.options("/path/to/something")
async def do_something_options(request: Request):
...
@app.head("/path/to/something")
async def do_something_head(request: Request):
...
我们可以利用 Sanic 的路由器来添加处理程序,为每个路由添加处理这些请求的处理程序。想法将是遍历我们应用程序中定义的所有路由,并在需要时动态添加OPTIONS和HEAD的处理程序。在第七章的后面,我们将使用这种策略来创建我们的自定义 CORS 策略。然而,现在我们只需要记住,我们希望能够使用以下 HTTP 方法之一来处理对有效端点的任何请求:
async def options_handler(request: Request):
...
async def head_handler(request: Request):
...
@app.before_server_start
def add_info_handlers(app: Sanic, _):
app.router.reset()
for group in app.router.groups.values():
if "OPTIONS" not in group.methods:
app.add_route(
handler=options_handler,
uri=group.uri,
methods=["OPTIONS"],
strict_slashes=group.strict,
)
app.router.finalize()
让我们仔细看看这段代码。
首先,我们创建路由处理程序:当端点被击中时将执行工作的函数。现在,它们不做任何事情。如果您想知道这个端点可能做什么,请跳转到设置有效的 CORS 策略中的 CORS 讨论,该讨论位于第七章。
async def options_handler(request: Request):
...
async def head_handler(request: Request):
...
在我们注册了所有端点之后,下一部分需要完成。在第十一章中,我们通过在工厂内部运行代码来完成这个任务。您可以随意提前查看那里的示例,以便能够将其与我们的当前实现进行比较。
在我们的当前示例中,我们没有工厂,而是在事件监听器内部添加路由。通常情况下,这是不可能的,因为我们不能在应用程序运行后更改我们的路由。当 Sanic 应用程序启动时,它内部首先做的事情之一就是调用app.router.finalize()。但是,它不会让我们调用这个方法两次。因此,我们需要在所有动态路由生成完成后运行app.router.reset(),添加我们的路由,并最终在所有动态路由生成完成后调用app.router.finalize()。您可以在可能动态添加路由的任何地方使用这种相同的策略。这是一个好主意吗?一般来说,我会说动态添加路由不是一个好主意。端点的变化可能会导致不可预测性,或者在分布式应用程序中出现奇怪的错误。然而,在这个例子中,通过动态路由生成获得的收益是巨大的,风险非常低。
Sanic 路由器为我们提供了一些不同的属性,我们可以遍历它们来查看注册了哪些路由。最常用于公共消费的两个属性是app.router.routes和app.router.groups。了解它们是什么以及它们之间的区别是有帮助的。我们将暂时暂停对OPTIONS和HEAD的讨论,来看看这两个属性是如何实现的:
@app.before_server_start
def display(app: Sanic, _):
for route in app.router.routes:
print(route)
for group in app.router.groups.values():
print(group)
@app.patch("/two")
@app.post("/two")
def two_groups(request: Request):
return text("index")
@app.route("/one", methods=["PATCH", "POST"])
def one_groups(request: Request):
return text("index")
首先,要注意的是,其中一个是生成Route对象,另一个是生成RouteGroup对象。第二个明显的启示是,一个是列表,另一个是字典。但Route和RouteGroup是什么?
在我们的控制台,我们会看到有三个Route对象,但只有两个RouteGroup对象。这是因为 Sanic 将看起来相似的路径组合在一起,以便更有效地匹配它们。Route是一个单独的定义。每次我们调用@app.route时,我们都在创建一个新的Route。在这里,我们可以看到它们是根据 URI 路径进行分组的:
<Route: name=__main__.two_groups path=two>
<Route: name=__main__.two_groups path=two>
<Route: name=__main__.one_groups path=one>
<RouteGroup: path=two len=2>
<RouteGroup: path=one len=1>
回到我们对自动化的讨论,我们将使用app.router.groups。这是因为我们想知道哪些方法被分配给了特定的路径,哪些没有被分配。最快的方法是查看 Sanic 为我们提供的组。我们只需要检查该组是否已经包含了一个处理 HTTP 方法的处理程序(这样我们就不会覆盖已经存在的内容),然后调用add_route。
for group in app.router.groups.values():
if "OPTIONS" not in group.methods:
app.add_route(
handler=options_handler,
uri=group.uri,
methods=["OPTIONS"],
strict_slashes=group.strict,
)
if "GET" in group.methods and "HEAD" not in group.methods:
app.add_route(
handler=head_handler,
uri=group.uri,
methods=["HEAD"],
strict_slashes=group.strict,
)
尽管我们现在不会查看options_handler,但我们可以更仔细地看看head_handler。根据 RFC 2616 的定义,HEAD请求与GET请求相同:“HEAD 方法与 GET 方法相同,除了服务器在响应中不得返回消息体。”(www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4)。
在 Sanic 中实现这一点相当简单。实际上我们想要做的是检索同一端点的GET处理程序的响应,但只返回元数据,而不是请求体。我们将使用functools.partial将GET处理程序传递给我们的head_handler。然后,它只需要运行get_handler并返回响应。正如我们在本章前面看到的,Sanic 会在发送响应到客户端之前为我们处理移除体(body)的工作:
from functools import partial
for group in app.router.groups.values():
if "GET" in group.methods and "HEAD" not in group.methods:
get_route = group.methods_index["GET"]
app.add_route(
handler=partial(
head_handler,
get_handler=get_route.handler
),
uri=group.uri,
methods=["HEAD"],
strict_slashes=group.strict,
name=f"{get_route.name}_head",
)
async def head_handler(request: Request, get_handler, *args, **kwargs):
return await get_handler(request: Request, *args, **kwargs)
重要提示
在上面的例子中,我们在
add_route方法中添加了name=f"{get_route.name}_head"。这是因为 Sanic 中的所有路由都有一个“名称”。如果你没有手动提供,那么 Sanic 会尝试使用handler.__name__为你生成一个名称。在这种情况下,我们传递了一个partial函数作为路由处理程序,而 Sanic 不知道如何为它生成名称,因为 Python 中的partial函数没有__name__属性。
现在我们已经了解了如何利用 HTTP 方法来获取优势,我们将探讨路由中的下一个重要领域:路径。
路径、斜杠以及它们的重要性
在石器的时代,当互联网被发明出来时,如果你导航到一个 URL,你实际上是在接收一个存在于某台电脑上的文件。如果你请求/path/to/something.html,服务器会在/path/to目录中寻找一个名为something.html的文件。如果该文件存在,它会发送给你。
虽然这种情况仍然存在,但许多应用的时代确实已经改变。互联网在很大程度上仍然基于这个前提,但通常发送的是生成的文档而不是静态文档。尽管如此,仍然保持这种心理模型在脑海中是有帮助的。认为你的 API 路径应该指向某种资源将帮助你避免某些 API 设计缺陷。例如:
/path/to/create_something << BAD
/path/to/something << GOOD
你的 URI 路径应该使用名词,而不是动词。如果我们想执行一个动作并告诉服务器做什么,我们应该像我们学的那样操作 HTTP 方法,而不是 URI 路径。走这条路——相信我,我试过——会导致一些看起来很混乱的应用程序。很可能有一天早上醒来,你会看到一堆零散和不连贯的路径,然后问自己:我做了什么?然而,可能会有时间和地点适合这样做,所以我们很快会重新讨论这个问题。
知道我们的路径应该包含名词,接下来的明显问题是它们应该是单数还是复数。我认为在互联网上关于这里什么是对的并没有一个单一的共识。许多人总是使用复数;许多人总是使用单数;还有一些人决定混合使用。虽然这个决定本身可能看起来很小,但它仍然很重要,需要建立一致性。选择一个系统并保持一致性本身比实际的决定更重要。
在这个问题解决之后,我将给出我的观点。使用复数名词。为什么?它使得路径的嵌套非常优雅,这可以很好地转化为 Blueprints 的嵌套:
/users << to get all users
/users/123 << to get user ID 123
我确实鼓励你在觉得合理的情况下使用单数名词。但如果你这样做,你必须无处不在。只要你在选择上保持一致和逻辑,你的 API 就会显得很精致。混合单数和复数路径会让你的 API 显得杂乱无章和业余。这里有一个非常好的资源,解释了如何一致地打破我刚刚提出的两个规则(使用名词,使用复数):restfulapi.net/resource-naming/。再次强调,对于我们来说,不仅学习规则或做某事的正确方式很重要和有帮助,而且学习何时打破它们,或何时制定我们自己的规则也很重要。有时遵循标准是有意义的,有时则不然。这就是我们从仅仅能够制作网络应用的人,变成知道如何设计和构建网络应用的人的过程。这种区别就是专业知识。
在设计路径时,也鼓励优先使用连字符(-)而不是空格、大写字母或下划线。这增加了 API 的人阅读性。考虑一下这些之间的区别:
/users/AdamHopkins << BAD
/users/adam_hopkins << BAD
/users/adam%20hopkins << BAD
/users/adam-hopkins << GOOD
大多数人都会认为最后一个选项是最容易阅读的。
严格的斜杠
由于传统的范式,其中端点等同于服务器的文件结构,路径中的尾部斜杠获得了特定的含义。人们普遍认为,带有和没有尾部斜杠的路径是不同的,不能互换。
如果您导航到/characters,您可能会期望收到我们虚构社交媒体应用中所有字符的列表。然而,/characters/在技术上意味着显示 characters 目录中的所有内容。因为这可能令人困惑,您被鼓励避免使用尾部斜杠。
另一方面,人们普遍认为这些确实是同一回事。事实上,许多浏览器(和网站)都把它们视为相同。我将向您展示您如何自己测试这一点:
打开您的网络浏览器并访问:sanic.readthedocs.io/en/stable/
现在打开第二个标签页并访问:sanic.readthedocs.io/en/stable
它是同一页。事实上,似乎这个网络服务器打破了刚才提到的规则,并更喜欢没有尾部斜杠。那么,这让我们处于什么位置,我们应该实现什么?这完全取决于您自己决定,让我们看看我们如何在 Sanic 中控制它。
如果您什么都不做,Sanic 会为您删除尾部斜杠。然而,Sanic 确实提供了通过设置strict_slashes参数来控制尾部斜杠是否有意义的选项。考虑一个带有和没有尾部斜杠,以及带有和没有strict_slashes的应用程序设置:
@app.route("/characters")
@app.route("/characters/")
@app.route("/characters", strict_slashes=True)
@app.route("/characters/", strict_slashes=True)
async def handler(request: Request):
...
上述定义将失败。为什么?当 Sanic 在路径定义中看到尾部斜杠时,它会将其删除,除非 strict_slashes=True。因此,第一条和第二条路由被认为是相同的。此外,第三条路由也是相同的,因此导致冲突。
虽然普遍认为规则是尾部斜杠应该有含义,但对于仅是路径一部分的尾部斜杠来说,情况并非如此。RFC 2616,第 3.2.3 节指出,空路径("")与单个斜杠路径("/")是同一回事。(datatracker.ietf.org/doc/html/rfc2616#section-3.2.3)
我整理了一个关于 Sanic 如何处理尾部斜杠可能场景的深入讨论。如果您正在考虑使用它,我建议您查看这里:community.sanicframework.org/t/route-paths-how-do-they-work/825.
如果让我发表意见,我会说不要使用它们。允许/characters和/characters/具有相同含义会更加宽容。因此,我个人会这样定义上述路由:
@app.route("/characters")
async def handler(request: Request):
...
从路径中提取信息
在本节中,我们需要考虑的最后一件事情是从我们的请求中提取可用的信息。我们经常查看的第一个地方是 URI 路径。Sanic 提供了一种简单的语法来从路径中提取参数:
@app.get("/characters/<name>")
async def profile(request: Request, name: str):
print text(f"Hello {name}")
我们已经声明路径的第二部分包含一个变量。Sanic 路由器会提取这个变量,并将其作为参数注入到我们的处理器中。需要注意的是,如果我们不做其他操作,这种注入将是一个 str 类型的值。
Sanic 还提供了一个简单的机制来转换类型。假设我们想从消息源中检索一条消息,在数据库中查询它,并返回该消息。在这种情况下,我们的数据库调用需要 message_id 是一个 int。
@app.get("/messages/<message_id:int>")
async def message_details(request: Request, message_id: int):
...
这个路由定义将告诉 Sanic 在注入之前将第二部分转换为 int 类型。同样重要的是要注意,如果值是无法转换为 int 的类型,它将引发一个 404 Not Found 错误。因此,参数类型不仅仅是类型转换,它还涉及到路由处理。
您可以参考下一节和用户指南,了解所有允许的参数类型。sanicframework.org/en/guide/basics/routing.html#path-parameters
除了从路径本身提取信息外,我们可能还想查找用户数据的两个其他地方是查询参数和请求体。查询参数是 URL 中 ? 后的部分:
/characters?count=10&name=george
我们应该如何决定信息应该通过路径、查询参数,还是作为表单或 JSON 体的部分传递?最佳实践规定,信息应该按照以下方式访问:
-
路径参数:描述我们正在寻找的资源的信息
-
查询参数:可用于过滤、搜索或排序响应的信息
-
请求体:所有其他内容
在应用程序开发初期就养成一个良好的习惯,了解不同可用的信息来源。第四章深入探讨了通过查询参数和请求体传递数据。当然,HTTP 路径本身也非常有价值。我们刚刚看到了精心设计路径可能有多么重要。接下来,我们将更深入地探讨从 HTTP 路径中提取数据。
高级路径参数
在最后一节中,我们学习了从动态 URL 路径中提取信息的基本知识,这些信息可以用于编码。这确实是所有 Web 框架的基本功能。许多框架也允许您指定路径参数应该是什么。我们了解到 /messages/<message_id:int> 会匹配 /messages/123 但不会匹配 /messages/abc。我们还了解了 Sanic 在将匹配路径段转换为整数方面提供的便利。
但是,对于更复杂的类型怎么办?或者如果我们需要在将匹配的值用于我们的应用程序之前修改它怎么办?在本节中,我们将探讨一些有助于实现这些目标的实用模式。
自定义参数匹配
默认情况下,Sanic 提供了八种可匹配的路径参数类型:
-
str:匹配任何有效的字符串 -
slug:匹配标准路径别名 -
int:匹配任何整数 -
float:匹配任何数字 -
alpha:仅匹配字母字符 -
path:匹配任何可展开的路径 -
ymd:匹配YYYY-MM-DD -
uuid:匹配一个UUID
这些中的每一个都提供了一个与匹配参数相对应的类型。例如,如果你有这个路径:/report/<report_date:ymd>,你的处理程序中的 date 对象将是一个 datetime.date 实例:
from datetime import date
@app.get("/report/<report_date:ymd>")
async def get_report(request: Request, report_date: date):
assert isinstance(report_date, date)
这是一个非常有用的模式,因为它为我们完成了两件事。首先,它确保传入的请求是正确的格式。一个请求为 /report/20210101 将收到一个 404 Not Found 响应。其次,当我们开始在处理程序中处理那个 report_date 实例时,它已经被转换成了一个可用的数据类型:date。
当我们需要对标准类型之外的类型进行路由时会发生什么?Sanic 当然允许我们通过定义一个路径段的自定义正则表达式来实现第一部分。让我们想象一下,我们有一个想要匹配有效 IPv4 地址的端点:/ip/1.2.3.4。
这里最简单的方法是找到一个相关的正则表达式并将其添加到我们的路径段定义中:
IP_ADDRESS_PATTERN = (
r"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}"
r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"
)
@app.get(f"/<ip:{IP_ADDRESS_PATTERN}>")
async def get_ip_details(request: Request, ip: str):
return text(f"type={type(ip)} {ip=}")
现在,当我们访问我们的端点时,我们应该有一个有效的匹配:
$ curl localhost:7777/1.2.3.4
type=<class 'str'> ip='1.2.3.4'
使用正则表达式匹配还允许我们在有限数量的选项之间定义一个狭窄的端点:
@app.get("/icecream/<flavor:vanilla|chocolate>")
async def get_flavor(request: Request, flavor: str):
return text(f"You chose {flavor}")
现在,我们有了基于我们两个可用选择的路由:
$ curl localhost:7777/icecream/mint
️ 404 — Not Found
==================
Requested URL /icecream/mint not found
$ curl localhost:7777/icecream/vanilla
You chose vanilla
虽然正则表达式匹配在某些时候非常有帮助,但问题在于输出仍然是一个 str。回到我们最初的 IPv4 示例,如果我们想要一个 ipaddress.IPv4Address 类的实例来工作,我们需要手动将匹配的值转换为 ipaddress.IPv4Address。
虽然如果你只有一个或两个处理程序,这可能看起来不是什么大问题,但如果你有十几个端点需要动态 IP 地址作为路径参数,这可能会变得繁琐。Sanic 的解决方案是自定义模式匹配。我们可以告诉 Sanic 我们想要创建自己的参数类型。为此,我们需要三样东西:
-
一个简短的描述符,我们将用它来命名我们的类型
-
一个函数,它将返回我们想要的值或在没有匹配时引发
ValueError -
一个回退正则表达式,它也会匹配我们的值
在 IP 地址示例中:
-
我们将参数命名为
ipv4 -
我们可以使用标准库的
ipaddress.ip_address构造函数 -
我们已经从之前有了回退正则表达式。我们可以继续注册自定义参数类型:
import ipaddress app.router.register_pattern( "ipv4", ipaddress.ip_address, IP_ADDRESS_PATTERN, ) @app.get("/<ip:ipv4>") async def get_ip_details(request: Request, ip: ipaddress.IPv4Address): return text(f"type={type(ip)} {ip=}")
现在,我们在处理程序中有一个更可用的对象(ipaddress.IPv4Address),并且我们还有一个非常容易重用的路径参数(<ip:ipv4>)。
那么,关于我们的第二个例子,冰淇淋口味呢?如果我们想要一个Enum或其他自定义模型,而不是str类型,那会怎么样?不幸的是,Python 标准库中没有用于解析冰淇淋口味的函数(也许有人应该构建一个),因此我们需要创建自己的:
-
首先,我们将使用
Enum创建我们的模型。为什么是Enum?这是一个保持我们的代码整洁和一致性的绝佳工具。如果我们设置的环境正确——因为我们已经在第二章中注意到了使用好工具——我们有一个单一的地方可以维护我们的口味,并使用代码补全:from enum import Enum, auto class Flavor(Enum): VANILLA = auto() CHOCOLATE = auto() -
接下来,我们需要一个正则表达式,我们可以在稍后的路由定义中使用它来匹配传入的请求:
flavor_pattern = "|".join( f.lower() for f in Flavor.__members__.keys() )结果模式应该是:
vanilla|chocolate。 -
我们还需要创建一个函数,它将充当我们的解析器。其任务是返回我们的目标类型或引发
ValueError:def parse_flavor(flavor: str) -> Flavor: try: return Flavor[flavor.upper()] except KeyError: raise ValueError(f"Invalid ice cream flavor: {flavor}") -
我们现在可以继续将这个模式注册到 Sanic 中。就像之前的 IP 地址示例一样,我们有我们的参数类型名称,一个用于检查匹配的函数,以及一个回退正则表达式。
app.router.register_pattern( "ice_cream_flavor", parse_flavor, flavor_pattern, ) -
现在我们已经注册了我们的模式,我们可以继续在所有的冰淇淋端点中使用它:
@app.get("/icecream/<flavor:ice_cream_flavor>") async def get_flavor(request: Request, flavor: Flavor): return text(f"You chose {flavor}")当我们现在访问端点时,我们应该有一个
Enum实例,但仍然只接受匹配我们定义的两个口味之一的请求。美味!$ curl localhost:7777/icecream/mint 404 — Not Found =============== Requested URL /icecream/mint not found $ curl localhost:7777/icecream/vanilla You chose Flavor.VANILLA
这个例子中的关键是有一个好的解析函数。在我们的例子中,我们知道如果将不良口味输入到Enum构造函数中,它将引发KeyError。这是一个问题。如果我们的应用程序无法匹配mint,它将抛出KeyError,并且应用程序将响应500 内部服务器错误。这不是我们想要的。通过捕获异常并将其转换为ValueError,Sanic 能够理解这是预期的,并且它应该响应404 未找到。
修改匹配的参数值
正如我们所学的,使用路径参数类型在构建我们的 API 以响应预期请求和忽略不良路径方面非常有帮助。尽可能具体,这是最佳实践,以便我们的端点能够获取正确的数据。我们刚刚探讨了如何使用参数类型将匹配的值重铸为更有用的数据类型。但是,如果我们不关心改变值的type,而是实际值本身呢?
回到我们的角色配置文件应用示例,假设我们有一些包含短横线的 URL。如果你不熟悉短横线,它基本上是一个使用小写字母和短横线来在 URL 路径中创建人类友好内容的字符串。我们之前已经看到了一个例子:/users/adam-hopkins。
在我们的假设应用中,我们需要构建一个端点,该端点返回关于角色实例的详细信息。
-
首先,我们将创建一个模型来描述字符对象的外观。
@dataclass class Character: name: str super_powers: List[str] favorite_foods: List[str] -
我们希望能够返回关于我们角色的具体细节。例如,端点
/characters/george/name应该返回George。因此,我们的下一个任务是定义我们的路由:@app.get("/characters/<name:alpha>/<attr:slug>") async def character_property(request: Request, name: str, attr: str): character = await get_character(name) return json(getattr(character, attr)) -
这是一个相当简单的路由。它搜索角色,然后返回请求的属性。让我们看看它在实际操作中的表现:
$ curl localhost:7777/characters/george/name "George" -
现在,让我们尝试获取乔治的超级能力。
![图片]()
哎呀,发生了什么事?我们试图访问的属性是
Character.super_powers。但是,我们的端点接受缩写(因为它们对人们来说更容易阅读)。因此,我们需要转换属性。就像在前一节中,我们可以在处理程序内部可以转换我们的值一样,这会使解决方案的扩展变得更加困难。我们可以在我们的处理程序内部运行attr.replace("-", "_"),也许这是一个可行的解决方案。它确实在处理程序中增加了额外的代码。幸运的是,我们还有另一个选择。这是一个很好的中间件用例,我们需要将所有缩写(例如this-is-a-slug)转换为蛇形小写(例如this_is_snake_case),以便将来可以编程使用。通过转换缩写,我们可以查找super_powers而不是super-powers。 -
让我们创建这个中间件:
@app.on_request def convert_slugs(request: Request): request.match_info = { key: value.replace("-", "_") for key, value in request.match_info.items() }这将修改在路由处理程序执行之前
Request实例。对我们这个用例来说,这意味着所有匹配的值都将从缩写形式转换为蛇形小写。请注意,我们在这个函数中没有返回任何内容。如果我们这样做,Sanic 会认为我们正在尝试通过提前返回来终止请求/响应周期。这不是我们的意图。我们只想修改Request。 -
让我们再次测试这个端点:
$ curl localhost:7777/characters/george/super-powers ["whistling","hand stands"] -
虽然中间件不是解决这个问题的唯一方法。Sanic 使用信号来分发应用程序可以监听的事件。而不是上面的中间件,我们可以使用信号做类似的事情,如下所示:
@app.signal("http.routing.after") def convert_slugs(request: Request, route: Route, handler, kwargs): request.match_info = { key: value.replace("-", "_") for key, value in kwargs.items() }
如您所见,这是一个非常相似的实现。也许对我们作为开发者来说最大的区别是,信号为我们提供了更多的工作参数。尽管如此,坦白说,route、handler 和 kwargs 都是可以在 Request 实例中访问的属性。中间件和信号在第六章中进行了更深入的讨论。现在,只需知道这些是在路由处理程序之外改变请求/响应周期的两种方法。稍后我们将了解更多关于它们之间的区别以及何时可能更倾向于选择其中一种。
API 版本控制
在 第二章 组织项目 中,我们讨论了如何使用 Blueprints 实现 API 版本控制。如果您还记得,这只是一个在 Blueprint 定义中添加关键字值的问题。
给定下面的 Blueprint 定义,我们得到 URL 路径:/v1/characters:
bp = Blueprint("characters", version=1, url_prefix="/characters")
@bp.get("")
async def get_all_characters(...):
...
version关键字参数在路由级别也是可用的。如果版本在多个地方定义(例如,在路由和蓝图上),则优先考虑范围最窄的。让我们看看版本可以在哪些不同的地方定义的例子,并看看结果是什么。我们将在路由级别、蓝图级别和蓝图组级别定义它:
bp = Blueprint("Characters")
bp_v2 = Blueprint("CharactersV2", version=2)
group = Blueprint.group(bp, bp_v2, version=3)
@bp.get("", version=1)
async def version_1(...):
...
@bp_v2.get("")
async def version_2(...):
...
@bp.get("")
async def version_3(...):
...
app.blueprint(group, url_prefix="/characters")
我们现在有以下路由。仔细看看例子,看看我们是如何操纵蓝图和version参数来控制每个路径交付的处理程序的:
-
/v1/characters <Route: name=main.Characters.version_1 path=v1/characters> -
/v3/characters <Route: name=main.Characters.version_3 path=v3/characters> -
/v2/characters <Route: name=main.CharactersV2.version_2 path=v2/characters>
向端点路径添加版本相当简单。但为什么我们要这样做呢?这是一个好的做法,因为它使你的 API 对用户来说既灵活又一致稳定。通过允许端点进行版本控制,你保持了对其做出更改的能力,同时仍然允许旧请求不被拒绝。随着时间的推移,当你过渡你的 API 以添加、删除或增强功能时,这会带来极大的好处。
即使你的 API 的唯一消费者是你的自己的网站,仍然是一个好的做法来对 API 进行版本控制,这样你就有了一条更容易的升级路径,而不会导致应用程序退化。
使用版本“锁定”功能是一种常见的做法。这是一种创建所谓的 API 合约的形式。将 API 合约视为开发者对 API 将继续工作的承诺。换句话说,一旦你将 API 投入使用——尤其是如果你发布了文档——你就向用户承诺 API 将继续按原样运行。你可以自由地添加新功能,但任何不向后兼容的破坏性更改都违反了该合约。因此,当你确实需要添加破坏性更改时,版本可能是你工具箱中实现目标的好方法。
这里有一个例子。我们正在构建我们的角色资料数据库。我们 API 的第一个版本提供了一个创建新资料的端点,它看起来可能像这样:
@bp.post("")
async def create_character_profile(request: Request):
async create_character(name=request.json["name"], ...)
...
此端点是建立在假设传入的 JSON 体相对简单的基础上的,如下所示:
{
"name": "Alice"
}
当我们想要处理更复杂的使用案例时会发生什么?
{
"meta": {
"pseudonuym": "The Fantastic Coder",
"real_name": "Alice"
},
"superpowers": [
{
"skill": "Blazing fast typing skills"
}
]
}
如果我们将太多逻辑放入其中,我们的路由处理程序可能会变得复杂、混乱,总体上难以维护。作为一个一般做法,我喜欢保持我的路由处理程序非常简洁。如果我看到我的代码在视图处理程序内部接近 50 行代码,我知道可能需要进行一些重构。理想情况下,我喜欢将它们保持在 20 行或更少。
我们可以保持代码整洁的一种方法是将这些用例分开。API 的版本 1 仍然可以使用更简单的数据结构创建角色,而版本 2 具有更复杂结构的处理能力。
我是否应该让所有路由都提升版本?
你可能想知道为什么你想要
通常情况下,你可能需要在单个端点增加版本,但不是所有端点都需要。这引发了一个问题:在未更改的端点上我应该使用哪个版本?最终,这将成为只能由应用程序决定的唯一问题。记住 API 的使用方式可能会有所帮助。
非常常见的是,当 API 结构有完全的断裂或某些重大的重构时,API 会提升版本。这可能会伴随着新的技术栈、新的 API 结构或设计模式。一个例子是 GitHub 将他们的 API 从 v3 更改为 v4。他们 API 的旧版本(v3)是 RESTful 的,类似于我们在本章前面讨论的。新版本(v4)基于 GraphQL(关于 GraphQL 的更多信息请见第十章)。这是 API 的完全重新设计。因为 v3 和 v4 完全不兼容,所以他们改变了版本号。
在 GitHub 的情况下,所有端点都需要更改,因为它实际上是一个全新的 API。然而,这种剧烈的变化并不是版本更改的唯一催化剂。如果我们只更改 API 的一小部分兼容性,而保持其余部分不变呢?
有些人可能会觉得在所有端点上实施新版本号是有意义的。实现这一目标的一种方法是在端点上添加多个路由定义:
v1 = Blueprint("v1", version=1)
v2 = Blueprint("v2", version=2)
@v1.route(...)
@v2.route(...)
async def unchanged_route(...):
...
这种方法的缺点是可能会变得非常繁琐,难以维护。如果你需要在更改版本时向每个处理器添加新的路由定义,你可能会从一开始就放弃添加版本。请考虑这一点。
嵌套蓝图怎么样?有没有一个在启动时动态添加路由的功能?你能想到解决方案吗?在这本书的前面我们已经看到了各种工具和策略,可能会对我们有所帮助。现在放下这本书,打开你的电脑上的代码编辑器,尝试一下。我鼓励你尝试版本和嵌套,看看哪些是可能的,哪些是不可能的。
记住app.router.routes和app.router.groups吗?尝试将单个处理器添加到多个 Blueprints 中。或者尝试将相同的 Blueprints 添加到不同的组中。我挑战你找出一个模式,让相同的处理器在不同的版本上运行,而无需像上面示例中那样进行多次定义。从这一点开始,看看你能想出什么,不要像上面那样重复路由定义:
v1 = Blueprint("v1", version=1)
v2 = Blueprint("v2", version=2)
@v1.route(...)
async def unchanged_route(...):
...
这里有一个实用的代码片段,你可以在开发过程中使用,以查看哪些路径被定义:
from sanic.log import logger
@app.before_server_start
def display(app: Sanic, _):
routes = sorted(app.router.routes, key=lambda route: route.uri)
for route in routes:
logger.debug(f"{route.uri} [{route.name}]")
回到我们的问题:我的所有路由都应该更新版本吗?有些人会说是的,但当只有一条路由发生变化时,更新所有路由的版本似乎人为地复杂。无论如何,如果这样做有意义,可以同时更新所有内容。
如果我们只想更新正在变化的路由,这又会带来另一个问题。我们应该更新到哪个版本呢?很多人会告诉你版本应该只是整数:v1、v2、v99等。我觉得这很受限制,而且它确实让以下一组端点感觉很不自然:
-
/v1/characters -
/v1/characters/puppets -
/v1/characters/super_heroes -
/v1/characters/historical -
/v2/characters
虽然我不否认这种方法,但它似乎应该为所有路由都有一个 v2,即使它们没有变化。我们正在努力避免这种情况。为什么不使用像语义版本控制那样的次版本号呢?拥有单个 /v1.1 端点似乎比单个 /v2 更自然和易于接受。再次强调,这将是根据您的应用程序需求以及将消费您的 API 的用户类型来决定的问题。如果您决定语义版本控制风格适合您的应用程序需求,您可以通过使用浮点数作为版本参数来添加它,如下所示:
@bp.post("", version=1.1)
async def create_character_profile_enhanced(request: Request):
async create_character_enhanced(data=request.json)
重要提示
语义版本控制是软件开发中的一个重要概念,但超出了这里的范围。简而言之,这个概念是通过声明由点连接的主版本号、次版本号和修订号来创建一个版本。例如:1.2.3。一般来说,语义版本控制表明主版本号的增加对应于向后不兼容的更改,次版本号对应于新功能,修订号对应于错误修复。如果您不熟悉它,我建议花些时间阅读相关的文档,因为它在软件开发中得到广泛应用:
semver.org/提示
如果您打算让第三方集成您的 API,强烈建议您在端点中使用版本。如果 API 只打算由您的应用程序使用,这可能就不那么重要了。尽管如此,它可能仍然是一个有用的模式。因此,我建议对于新项目使用
version=1,对于替换现有 API 的项目使用version=2,即使遗留应用程序没有版本方案。
版本前缀
在 Sanic 中使用版本的标准方式是 version=<int> 或 version=<float>。版本将始终插入到您的路径的最开始。无论您嵌套多深以及有多少层 url_prefix,都无关紧要。即使是深度嵌套的路由定义也可以只有一个版本,并且它将是路径中的第一个部分:/v1/some/deeply/nested/path/to/handler。
然而,当你试图在你的应用程序上构建多层时,这确实会带来一个问题。如果你想有一些 HTML 页面和一个 API,并基于它们的路径将它们分开,你会怎么做?考虑以下我们可能希望在应用程序中拥有的路径:
-
/page/profile.html -
/api/v1/characters/<name>
注意,版本化的 API 路由是以/api开头的吗?由于 Sanic总是将版本放在路径的其他部分之前,因此仅使用 URI 和 Blueprint URI 前缀是无法控制的。然而,Sanic 在所有可以使用version的地方提供了一个version_prefix参数。默认值是/v,但请随意根据需要更新它。在下面的例子中,我们可以将整个 API 设计嵌套在一个蓝图组中,以自动将/api添加到每个端点的前面:
group = Blueprint.group(bp1, bp2, bp3, version_prefix="/api/v")
提示
这里也有相同的路径参数。例如,你可以这样做:
version_prefix=/<section>/v。只是确保你记住,section现在将作为每个路由处理器的注入关键字参数。
你现在应该已经很好地掌握了如何以及何时使用版本。它们是使你的 API 更加专业和可维护的强大工具,因为它们允许更灵活的开发模式。接下来,我们将探讨另一个创建应用程序代码灵活性和可重用性的工具:虚拟主机。
虚拟主机
一些应用程序可以从多个域名访问。这带来了只有一个应用程序部署来管理的优势,但能够服务多个域名。在我们的例子中,我们将想象我们已经完成了计算机冒险游戏社交媒体网站。API 确实是件了不起的事情。
事实上,这太令人难以置信了,Alice 和 Bob 都向我们提出了成为分销商和白标我们的应用程序,或为他们的社交媒体网站重用 API 的机会。在互联网世界中,这是一种相当常见的做法,一旦提供商构建了一个应用程序,其他提供商只需将他们的域名指向同一个应用程序,并像拥有自己的那样运营。为了实现这一点,我们需要有独特的 URL。
-
mine.com -
alice.com -
bob.com
所有这些域名都将设置其 DNS 记录指向我们的应用程序。这可以在应用程序内部不进行任何进一步更改的情况下工作。但如果我们需要知道哪个域名正在处理请求,并且为每个域名执行一些不同的操作呢?这些信息应该在我们的请求头中可用。这应该仅仅是一个检查头的问题:
@bp.route("")
async def do_something(request: Request):
if request.headers["host"] == "alice.com":
await do_something_for_alice(request)
elif request.headers["host"] == "bob.com":
await do_something_for_bob(request)
else:
await do_something_for_me(request)
这个例子可能看起来很小很简单,但你可能可以想象复杂性如何增加。记住我之前提到我喜欢将每个处理器的代码行数保持到最小?这确实是一个你可以想象处理器可能会变得非常长的用例。
实际上,我们在这个端点所做的是基于主机的路由。根据传入请求的主机,我们将端点路由到不同的位置。
Sanic 已经为我们做了这件事。我们只需要将逻辑拆分成单独的路由处理器,并为每个处理器提供一个 host 参数。这样就能实现我们需要的路由,同时将其从我们的响应处理器中分离出来。
@bp.route("", host="alice.com")
async def do_something_for_alice(request: Request)::
await do_something_for_alice(request: Request)
@bp.route("", host="bob.com")
async def do_something_for_bob(request: Request):
await do_something_for_bob(request: Request)
@bp.route("", host="mine.com")
async def do_something_for_me(request: Request):
await do_something_for_me(request: Request)
如果你发现自己处于这种情况,你不需要为每个端点定义 host。只需要为那些你希望有基于主机路由的端点定义。遵循这个模式,我们可以在多个域名之间重用同一个应用程序,并且仍然有一些端点能够区分它们,而其他端点则对多个域名正在访问它的事实一无所知。
有一个重要的事情需要记住:如果你创建了一个具有主机级别路由的端点,那么该路径上的所有路由也必须具有它。例如,你不能这样做。注意第三个路由没有定义 host 参数。
以下示例将不会工作,并在启动时引发异常:
@bp.route("", host="alice.com")
async def do_something_for_alice(request: Request)::
await do_something_for_alice(request: Request)
@bp.route("", host="bob.com")
async def do_something_for_bob(request: Request):
await do_something_for_bob(request: Request)
@bp.route("")
async def do_something_for_me(request: Request):
await do_something_for_me(request: Request)
为了解决这个问题,确保所有可以组合在一起的路由都有一个 host 值。这样它们就可以被区分开来。如果其中之一有 host,那么它们都需要有。
我们已经一般性地讨论了在将网络请求路由到我们的响应处理器时需要考虑的所有因素。但是,我们还没有探讨 Sanic 如何将请求传递到静态内容(即你希望在 web 服务器上发送的实际文件,如图片和样式表)。接下来,我们将讨论一些使用和不使用 Sanic 的选项。
服务器静态内容
到目前为止,我们本章的所有讨论都是关于为响应动态生成内容。然而,我们确实讨论了在目录结构中传递文件是一个有效的用例,Sanic 支持这种情况。这是因为大多数网络应用程序都需要为一些静态内容提供服务。最常见的情况是为浏览器渲染的 JavaScript 文件、图片和样式表。现在,我们将深入了解静态内容,看看它是如何工作的,并且我们可以提供这种类型的内容。在了解 Sanic 是如何做到这一点之后,我们将看到另一种非常常见的模式,即使用代理在 Sanic 之外提供内容。
从 Sanic 服务器静态内容
我们的 app 实例上有一个名为 app.static() 的方法。该方法需要两个参数:
-
我们应用程序的 URI 路径
-
一个路径告诉 Sanic 它可以从哪里访问该资源
第二个参数可以是单个文件,也可以是目录。如果是目录,目录中的所有内容都将像我们在本章开头讨论的老式网络服务器一样可访问。
如果你计划为所有网络资源提供服务,这将非常有帮助。如果你的文件夹结构如下所示?
.
├── server.py
└── assets
├── index.html
├── css
│ └── styles.css
├── img
│ └── logo.png
└── js
└── bundle.js
我们可以使用 Sanic 来提供所有这些资源,并使它们像这样可访问:
app.static("/static", "./assets")
这些资源现在可以访问:
$ curl localhost:7777/static/css/styles.css
使用 Nginx 提供静态内容
现在我们已经看到了如何使用 Sanic 提供静态文件,一个很好的下一个问题是:你应该这样做吗?
Sanic 在创建大多数 Web API 所需的动态端点方面非常快。它甚至在提供静态内容方面做得相当不错,将所有端点逻辑保持在单个应用程序中,甚至允许操作这些端点或重命名文件。正如我们在 第一章,Sanic 和异步框架简介 中讨论的那样,Sanic 应用程序也旨在快速构建。
然而,有一种可能更快的方法来提供静态内容。对于一个旨在通过你的 API 请求数据的浏览器单页应用程序,你最大的障碍之一将是减少首次页面渲染的时间。这意味着你必须尽可能快地将所有 JS、CSS、图片或其他文件打包到浏览器中,以减少渲染延迟。
因此,你可能想要考虑在 Sanic 前使用像 Nginx 这样的代理层。代理的目的将是(1)将任何请求发送到 API 通过 Sanic,并且(2)自己处理提供静态内容。如果你打算提供大量的静态内容,你可能想要考虑这个选项。Nginx 内置了一个缓存引擎,能够比任何 Python 应用程序更快地提供静态内容。
第八章,运行服务器 讨论了在决定是否使用 Nginx 和 Docker 等工具时需要考虑的部署策略。现在,我们将使用 Docker Compose 快速轻松地启动 Nginx。
-
我们需要创建我们的
docker-compose.yml清单:version: "3" services: client: image: nginx:alpine ports: - 8888:80 volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf - ./static:/var/www如果你不太熟悉 Docker Compose 或如何安装和运行它,你应该能够在网上找到大量的教程和信息。我们示例中的简单设置将需要你在
docker-compose.yml文件中将./static的路径设置为你的静态资源所在的任何目录。提示
这是一个故意设计的非常简单的实现。你应该确保一个真实的 Nginx 部署包括像 TLS 加密和代理密钥这样的功能。查看用户指南以获取更多详细信息和使用说明。
sanicframework.org/en/guide/deployment/nginx.html#nginx-configuration -
接下来,我们将创建控制 Nginx 所需的
.nginx/default.conf文件。upstream example.com { keepalive 100; server 1.2.3.4:8000; } server { server_name example.com; root /var/www; location / { try_files $uri @sanic; } location @sanic { proxy_pass http://$server_name; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location ~* \.(jpg|jpeg|png|gif|ico|css|js|txt)$ { expires max; log_not_found off; access_log off; } }我们使用以下命令启动它:
$ docker-compose up这里最重要的更改是服务器地址。你应该将1.2.3.4:8000更改为你的应用程序可以访问的任何地址和端口。请记住,这不会是127.0.0.1或localhost。由于 Nginx 将在 Docker 容器内运行,该本地地址将指向容器本身,而不是你的计算机的本地网络地址。相反,出于开发目的,你应该考虑将其设置为你的本地 IP 地址。 -
第 3 步,你需要确保 Sanic 知道要在该网络地址上提供服务。你还记得我们在第二章中是如何说我们要运行 Sanic 的吗?就像这样:
$ sanic server:app -p 7777 --debug --workers=2在这个例子中,我们将将其更改为:
$ sanic server:app -H 0.0.0.0 -p 7777 --debug --workers=2我的本地 IP 地址是
192.168.1.7,因此我将我的 Nginx 配置中的upstream块设置为:server 192.168.1.7:7777;。 -
第 4 步,你现在应该能够访问
./static目录中的任何静态文件。我有一个名为foo.txt的文件。我使用curl的-i标志来查看头部信息。重要的头部信息是Expires和Cache-Control。这些帮助你的浏览器缓存文件而不是重新请求它。$ curl localhost:8888/foo.txt -i HTTP/1.1 200 OK Server: nginx/1.21.0 Date: Tue, 15 Jun 2021 18:42:20 GMT Content-Type: text/plain Content-Length: 9 Last-Modified: Tue, 15 Jun 2021 18:39:01 GMT Connection: keep-alive ETag: "60c8f3c5-9" Expires: Thu, 31 Dec 2037 23:55:55 GMT Cache-Control: max-age=315360000 Accept-Ranges: bytes hello...
如果你尝试向一个不存在的文件发送请求,Nginx 会将该路由发送到你的 Sanic 应用程序。当涉及到代理和 Nginx 时,这种设置只是冰山一角。然而,这种策略对于 Python 网络应用来说是非常常见的。如前所述,当我们讨论第八章中的部署选项时,我们将更深入地探讨这个话题。
流式传输静态内容
还值得重申的是,Sanic 服务器是构建并旨在作为前端服务器。这意味着它可以在没有代理服务器的情况下作为你的入口点,包括提供静态内容。是否要代理的决定——至少与交付静态文件相关——可能是一个关于流量多少以及你的应用程序可能需要交付多少文件的问题。
另一个需要考虑的重要因素是,你的应用程序是否需要流式传输文件。流式传输将在第五章中深入讨论。让我们创建一个简单的网页来流式传输视频,看看它可能是什么样子。
-
首先是 HTML。将其存储为
index.html。<html> <head> <title>Sample Stream</title> </head> <body> <video width="1280" height="720" controls> <source src="/mp4" type="video/mp4" /> </video> </body> </html> -
接下来,找到一个你想要流式传输的
mp4文件。它可以任何视频文件。如果你没有,你可以从像这样的网站免费下载一个示例文件:samplelib.com/sample-mp4.html。 -
现在,我们将创建一个小的 Sanic 应用程序来流式传输该视频。
from sanic import Sanic, response @app.route("/mp4") async def handler_file_stream(request: Request): return await response.file_stream("/path/to/sample.mp4") app.static("/index.html", "/path/to/index.html") @app.route("/") def redirect(request: Request): return response.redirect("/index.html") -
正常运行服务器并在你的网页浏览器中访问它:
localhost:7777
你应该注意到根 URI(/)将你重定向到了/index.html。使用app.static,应用程序告诉 Sanic 它应该接受对/index.html的任何请求,并从服务器上位于/path/to/index.html的静态内容中返回。这应该是你上面提供的内容。希望你有播放按钮,现在你可以将视频流到你的浏览器中。享受吧!
摘要
本章涵盖了将 HTTP 请求转换为可用内容的大量内容。在 Web 框架的核心是其将原始请求转换为可执行处理器的功能。我们已经了解了 Sanic 是如何做到这一点的,以及我们如何使用 HTTP 方法、良好的 API 设计原则、路径、路径参数提取和静态内容来构建有用的应用程序。正如我们在本书前面所学,一点初步规划就能走得很远。在编写大量代码之前,考虑 HTTP 提供的工具以及 Sanic 如何让我们利用这些功能是非常有帮助的。
如果我们在第二章中很好地设置了目录,那么轻松地镜像那种结构并将蓝图嵌套以匹配我们预期的 API 设计应该会非常容易。
本章有一些关键要点。你应该有目的地、深思熟虑地设计你的 API 端点路径——使用名词——指向预期的目标或资源。然后,应该使用 HTTP 方法作为动词,告诉你的应用程序和用户对那个目标或资源做什么。最后,你应该从那些路径中提取有用的信息,以便在处理程序中使用。
我们主要关注原始 HTTP 请求的第一行:HTTP 方法和 URI 路径。在下一章中,我们将深入探讨从请求中提取更多数据,包括头部和请求体。
第五章:4 摄入 HTTP 数据
应用程序开发中的下一个构建块涉及数据。没有数据,Web 几乎没有实用性。我并不想在这里过于哲学化,但一个公理是,互联网的目的是促进数据从一地到另一地的传输。因此,对我们作为网络专业人士的发展来说,了解数据如何从我们的应用程序(我们在第五章,“处理和响应用户视图”中处理)传输出去,以及如何传输到我们的应用程序(这是本章的目的)至关重要。我们可以构建的最简单的应用程序只是提供数据。但为了成为参与全球知识交流的交互式 Web 应用程序,即使是简单的应用程序也必须能够从 Web 请求中提取数据。
没有接收数据的 Web 应用程序就像屏幕录制。观众可以来观看演示,但演讲者与观看的人没有任何个人联系。在 COVID-19 全球大流行期间,我很幸运还能参加几个 Python 会议。许多掌声应归功于那些推动前进、向社区提供技术会议中存在的分享和学习氛围的志愿者。然而,我必须指出,作为一个演讲者,我甚至不知道有多少人观看了我的内容。
这种模型可以用来向需要从中获取信息的人传播信息。然而,这种交易完全是单方面的。我的演示无法根据观众的线索进行调整,即使在聊天或问答环节,也缺少了人际交往的经验。同样,没有接收数据的 Web 应用程序在类似的原则下运行。服务器不知道谁在收听,无法根据用户输入改变其行为或内容。这类应用程序纯粹是为了传播数据和资源。
这种类型的 Web API 通常只有GET方法,因为它们完全是为了返回信息而存在的。它们可以用于传递有关天气、航班详情或其他许多人都可能希望访问的信息集中存储库的信息。
要构建一个真正交互式的 API,我们需要它不像屏幕录制那样操作,而更像视频聊天。对话的双方都将参与信息的来回传递。正是这种双向通信,我们将在本章中探讨。
如果你还记得我们之前的讨论,原始 HTTP 请求中有三个主要部分:第一行、HTTP 头信息和正文。到目前为止,我们一直专注于与 HTTP 方法和路径相关的 HTTP 请求的接收:这些信息都出现在 HTTP 请求的第一行中。
在本章中,我们将学习如何从客户端的所有三个部分获取数据。数据可以通过查询参数、头信息和当然还有正文本身传递给 Web 服务器。因此,在本章中,我们将探讨:
-
从 cookies 和头信息中提取数据
-
使用查询参数、上传的文件和 JSON 数据中的数据
-
验证接收到的数据是否符合预期
技术要求
在本章中,你应该拥有与之前章节相同的工具,以便能够跟随示例(IDE、现代 Python 和 curl)。你可以通过 GitHub 访问本章的源代码:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/04。
读取 cookies 和头信息
正如我们在本书的前几章中看到的那样,当任何 HTTP 客户端向 Web 服务器发送请求时,它包括一个或多个键/值对形式的头信息。这些头信息旨在成为客户端和服务器之间元对话的一部分。由于 HTTP 连接是一个双向交易,包括请求和响应,我们必须记住请求头和响应头之间的区别。
本章仍然只关注 HTTP 请求。因此,我们只涵盖与请求头相关的材料。这一点值得指出,因为有些头信息在请求和响应中都常见。一个这样的例子是Content-Type,它既可用于 HTTP 请求,也可用于 HTTP 响应。所以,例如,当我们在本节中讨论Content-Type时,它仅与 HTTP 请求相关。讨论响应头的时间和地点是存在的。你可以自由地跳过,或者与第五章,处理和响应视图一起阅读这一节,我们将讨论同一枚硬币的另一面。
头信息是灵活的
HTTP 头信息并非魔法。不存在预定义的、有限的头名称列表。此外,偏离被认为是标准的内容对您的应用程序没有任何影响。记得我们之前讨论 HTTP 方法时,我们说你可以发明自己的方法吗?好吧,你也有这样的控制和能力来创建自己的头信息。
这种做法实际上是受到鼓励并且很常见的。你熟悉Cloudflare吗?简而言之,Cloudflare 是一个流行的工具,用作 Web 应用的代理。我们将在第八章,运行服务器中进一步讨论代理。这个想法很简单:Cloudflare 运行一个 Web 服务器,一个请求进入他们的服务器,他们对它进行一些操作,然后将请求捆绑起来并发送到你的服务器。当他们这样做时,他们会包含他们自己的一套非标准头。例如,他们将通过CF-Connection-IP和CF-IPCountry头将请求转发给你,以提供一些关于 IP 地址及其来源位置的有用信息。
让我们想象一下,我们正在构建一个用于农民市场的 API。他们希望设置一个 Web API,以帮助协调市场中的各种参与者:农民、餐馆老板和消费者。我们想要构建的第一个端点将用于提供有关某一天市场摊位的信息:
@app.get("/stalls/<market_date:ymd>")
async def market_stalls(request: Request, market_date: date):
info = await fetch_stall_info(market_date)
return json({"stalls": info})
来自此端点的响应内容不需要身份验证(关于这一点将在后面讨论),但确实应该针对每种用户类型进行定制。农民可能想知道有多少摊位可用。而消费者和餐馆老板可能更感兴趣的是了解将有哪些产品可供选择。因此,我们已经为同一端点确定了至少两种不同的使用场景。
一种选择可能是将这个单一端点拆分为两个:/stalls/<market_date:ymd>/availability 和 /stalls/<market_date:ymd>/products
然而,这确实给整体 API 设计增加了一些复杂性。此外,在这个上下文中使用的availability和products并不是真正的资源。给它们自己的端点可能会弄乱我们 API 到目前为止的结构。
我们真正想要表达的是,我们有一个单一的资源——一年中某一天的市场摊位集合——我们只想根据参与者类型以不同的方式展示这些资源。这实际上只有一个端点,但有两种不同的方式来展示相同的信息。
可能的另一种选择是使用查询参数(关于这些内容将在本章的查询参数部分进一步讨论)。这看起来可能是这样的:/stalls/<market_date:ymd>?participant=farmer 和 /stalls/<market_date:ymd>?participant=consumer。这也多少打破了查询参数的范式——至少是我喜欢使用它们的方式——它们通常被用来过滤和排序结果。
相反,我们将选择为我们的用例创建一个自定义头:Participant-Type: farmer。我们还将创建一个枚举来帮助我们验证和限制可接受的参与者:
from enum import Enum, auto
class ParticipantType(Enum):
UNKNOWN = auto()
FARMER = auto()
RESTAURANT = auto()
CONSUMER = auto()
@app.get("/stalls/<market_date:ymd>")
async def market_stalls(request: Request, market_date: date):
header = request.headers.get("participant-type", "unknown")
try:
paticipant_type = ParticipantType[header.upper()]
except KeyError:
paticipant_type = ParticipantType.UNKNOWN
info = await fetch_stall_info(market_date, paticipant_type)
return json(
{
"meta": {
"market_date": market_date.isoformat(),
"paticipant_type": paticipant_type.name.lower(),
},
"stalls": info,
}
)
当请求到来时,处理器将尝试读取头信息,期望存在一个有效的 ParticipantType 对象。如果没有 Participant-Type 头信息,或者传递的值是一个未知类型,我们将简单地回退到 ParticipantType.UNKNOWN。
重要提示
正如您在这个示例中可以看到的,
request.headers.get("participant-type")是小写的。这实际上并不重要。它可以是大写、小写,或者混合使用。所有头信息都将被读取为不区分大小写的键。因此,尽管request.headers对象是一个dict,但它是一种特殊的字典,它不关心大小写。从 Sanic 获取头信息时,只使用小写字母是一种约定。请随意做您认为合理的事情。然而,我还是要提醒您,在整个项目中尽量保持一致性。如果在某些时候您看到headers.get("Content-Type"),而在其他时候看到headers.get("content-type"),可能会让人感到困惑。提示
枚举很棒。您真的应该在可能的地方都使用它们。虽然在这里使用枚举进行验证可能不是它们最明显的用例,但它们在需要传递某些类型的常量时非常有帮助。想象一下,在应用程序的深处需要记住:是 restaurant-owner、restaurant_owner 还是 restaurant?使用枚举可以帮助减少错误,提供了一个单一的地方来维护和更新,如果您的 IDE 支持,还可以提供代码补全。您将在本书中看到我以各种方式使用枚举。除了
asyncio之外,标准库中的enum包可能是我最喜欢的之一。
回到我们的示例,我们现在将尝试使用几个不同的示例来调用我们的端点,看看它如何响应不同的头信息。
-
我们将使用已知类型假装成农民来访问信息:
$ curl localhost:8000/stalls/2021-06-24 -H "Participant-Type: farmer" { "meta": { "market_date": "2021-06-24", "paticipant_type": "farmer" }, "stalls": [...] } -
现在,我们将省略头信息,看看端点将如何响应任何类型的缺失:
$ curl localhost:8000/stalls/2021-06-24 { "meta": { "market_date": "2021-06-24", "paticipant_type": "unknown" }, "stalls": [...] } -
最后,我们将使用我们没有预料到的一种类型来调用端点:
$ curl localhost:9898/stalls/2021-06-24 -H "Participant-Type: organizer" { "meta": { "market_date": "2021-06-24", "paticipant_type": "unknown" }, "stalls": [...] }
我们已经成功实现了一个自定义 HTTP 头信息,我们的端点可以使用它来决定如何显示和定制输出。虽然我们将在 第六章,响应周期之外 覆盖中间件,但如果我们想在其他端点上重用 Participant-Type 头信息怎么办?这里有一个快速展示,使整个应用程序都能使用这个功能。
@app.on_request
async def determine_participant_type(request: Request):
header = request.headers.get("participant-type", "unknown")
try:
paticipant_type = ParticipantType[header.upper()]
except KeyError:
paticipant_type = ParticipantType.UNKNOWN
request.ctx.paticipant_type = paticipant_type
@app.get("/stalls/<market_date:ymd>")
async def market_stalls(request: Request, market_date: date):
info = await fetch_stall_info(market_date, request.ctx.paticipant_type)
return json(
{
"meta": {
"market_date": market_date.isoformat(),
"paticipant_type": request.ctx.paticipant_type.name.lower(),
},
"stalls": info,
}
)
通过在中间件中评估头信息,我们现在可以将 participant_type 放在请求对象上,以便于访问。
我还想指出关于这个开发示例的最后一个要点是关于可测试性的心态。注意我们如何确定了端点的三种不同潜在用途:已知类型、缺少类型和未知类型。我们将在第九章,最佳实践以改进你的 Web 应用程序中讨论测试。然而,当我们继续阅读这本书时,提醒自己不仅要了解如何使用 Sanic,还要在发现问题时我们应该思考的类型。提前考虑应用程序的用途有助于我们理解我们可能想要测试的类型,因此也理解我们的应用程序需要处理的使用案例。
提示
还有一点值得指出的是,
request.ctx对象可供你附加任何你想要的信息。这真的很强大,可以帮助传递信息,并将一些逻辑抽象为中间件,如上所示。请记住,这仅持续到请求结束。一旦有响应,request.ctx上的任何内容都将被销毁。对于整个应用程序的生命周期和单个客户端连接的生命周期,也存在类似的环境。这些分别是:app.ctx和request.conn_info.ctx。请参阅第六章,响应周期之外以获取有关这些ctx对象的更多信息。
虽然完全可以创建自己的头信息集——实际上,我强烈鼓励这样做——但确实存在一套在客户端和服务器之间标准化的常见头信息。在接下来的几节中,我们将探讨其中的一些内容。
常见头信息
在 RFC 2731 的第五部分中有一组预定义的标准头信息。datatracker.ietf.org/doc/html/rfc7231#section-5。如果你有兴趣,放下这本书去阅读那一节。我们会等你回来。如果没有,让我们尝试提取一些亮点和一些你可能需要了解的重要请求头信息。
认证头
验证 Web 请求的主要机制之一是通过使用头信息。另一种基本方法是使用 cookies(技术上讲,cookies 也是一种头信息,但关于这一点将在从 cookies 获取信息(yum!)部分中进一步说明。)虽然确实存在不同类型的认证方案(基本认证、JWT 和会话令牌等),但它们通常具有相同的结构:使用授权头。
你可能刚刚注意到了一些奇怪的地方。我们在谈论认证,至少,这是本节标题的名称。但是,我们刚才说主要的认证头信息被称为授权。这怎么可能呢?
当我们更深入地讨论访问控制时,我们将在第七章,处理安全关注点中详细介绍这方面的更多细节,但提到这种区别以及这两个相关概念试图回答的基本问题是很值得的:
-
认证:我认识这个人吗?
-
授权:我应该让他们进入吗?
认证失败会导致401 未授权错误消息,而授权失败则是一个403 禁止错误消息。遗憾的是,互联网的历史中存在一个怪癖,这些术语被混淆了,并且它们以这种方式发展起来。它们是令人困惑且不一致的。
因此,尽管标题被称为授权,并且尽管其失败应该导致未授权响应,但我们仍然在专门讨论认证并回答问题:我认识这个人吗?
由于 Sanic 没有关于您应该如何构建应用程序的立场,我们显然在如何选择消费授权请求头方面有很大的自由。以下有三种主要策略:
-
装饰器
-
中间件
-
蓝图
让我们逐一看看这些:
装饰器
首先,让我们通过一个装饰器的例子来看一下:
from functools import wraps
from sanic.exceptions import Unauthorized
def authenticated(handler=None):
def decorator(f):
@wraps(f)
async def decorated_function(request, *args, **kwargs):
auth_header = request.headers.get("authorization")
is_authenticated = await check_authentication(auth_header)
if is_authenticated:
return await f(request, *args, **kwargs)
else:
raise Unauthorized("who are you?")
return decorated_function
return decorator(handler) if handler else decorator
@app.route("/")
@authenticated
async def handler(request):
return json({"status": "authenticated"})
这个例子中的核心是内部的decorated_function。这基本上是在说:在我们实际的处理程序(即f)运行之前,先运行check_authentication。这让我们有机会在路由中执行代码,但在我们到达实际定义的处理程序之前。
这种装饰器模式在 Sanic 中非常常见。不仅用于运行检查,还用于将参数注入我们的处理程序。如果您在应用程序中没有使用某种形式的装饰器,您就放弃了真正的力量。这是一种在端点之间复制逻辑的有用方式,我强烈建议您熟悉并习惯使用它们。在 Sanic 用户指南中可以找到一个非常有帮助的入门示例:sanicframework.org/en/guide/best-practices/decorators.html。
提示
注意到
handler=None和最后一行返回吗?def
authenticated(handler=None):
...返回
decorator(handler)如果handler否则decorator我们这样做的原因是我们允许我们的装饰器以两种方式之一使用:要么通过
@authenticated,要么通过@authenticated()。您必须决定哪一种(或是否两者都)适合您的需求。
中间件
现在我们已经看到了装饰器是如何工作的,我们如何使用中间件实现相同的逻辑呢?在下一个例子中,我们将尝试实现装饰器示例提供的相同功能,但使用中间件:
@app.on_request
async def do_check_authentication(request: Request):
is_authenticated = await check_authentication(auth_header)
if not is_authenticated:
raise Unauthorized("who are you?")
这种方法的缺点是,我们刚刚锁定了我们的整个API!我们的/stalls/<market_date:ymd>端点怎么办,或者甚至是用于登录的端点?一种修复方法是检查请求是否有匹配的Route实例(它应该有,除非我们正在响应404 Not Found),如果有,确保它不是豁免路由之一。我们可以在下面通过交叉引用匹配路由的名称与一个明确的豁免端点列表来查看如何做到这一点:
@app.on_request
async def do_check_authentication(request: Request):
if request.route and request.route.name not in (
"MyApp.login",
"MyApp.market_stalls",
):
is_authenticated = await check_authentication(auth_header)
if not is_authenticated:
raise Unauthorized("who are you?")
这次,在中间件中,我们查看路由的名称,看看它是否是我们知道应该安全的路由之一。
重要提示
作为一个简短的补充——因为我们之前没有见过——所有的路由都将有一个名称。当然,您可以手动命名它们:
@app.route(..., name="hello_world")
很可能,我们只需让 Sanic 为我们命名路由。它将默认使用处理函数的名称,然后使用点符号将其附加到我们的应用程序名称(以及任何蓝图)上。这就是为什么我们看到
MyApp.login和MyApp.market_stalls。它们假设我们的应用程序名为MyApp,我们豁免端点的处理函数分别是login和market_stalls。
“等等?!你想要我保持豁免端点名称的列表?这听起来像是一场噩梦,难以维护!” 真的。如果您只处理像这种简单用例的两个项目,这可能足够管理。但一旦我们真正开始构建应用程序,这可能会变得非常难以控制。请随意决定哪种模式更有意义。使用装饰器更加明确和清晰。然而,它会导致更多的代码重复。中间件替代方案更容易实现,并且更容易审计以确保我们没有忘记保护任何路由。然而,它的缺点是,它隐藏了一些功能,如果安全端点的列表增长,维护起来会更困难。如果您对哪种适合您的需求有疑问,我建议使用更明确的认证装饰器。然而,这确实表明通常有不同解决相同问题的方法。回到第一章,Sanic 和异步框架的介绍,如果这些解决方案中的一个看起来更明显正确,那么这很可能是您应该使用的方案。
蓝图
这就是我们的第三个解决方案出现的地方:我们的朋友蓝图再次出现。这次,我们将继续使用中间件,但我们只将中间件应用于包含受保护端点的蓝图。
protected = Blueprint("Protected")
@protected.route("/")
async def handler(request):
return json({"status": "authenticated"})
@protected.on_request
async def do_check_authentication(request: Request):
auth_header = request.headers.get("authorization")
is_authenticated = await check_authentication(auth_header)
if not is_authenticated:
raise Unauthorized("who are you?")
由于我们将中间件放置在“protected” Blueprint上,它将只在该蓝图附加的路由上运行。这留下了其他所有内容都是开放的。
上下文头信息
这些标题为您提供了有关请求来源的网页浏览器的某些信息。通常,它们在分析和日志记录中非常有用,可以提供有关您的应用程序如何被使用的某些信息。我们将检查一些更常见的上下文标题。
-
引用者 这个标题包含将用户引导到当前请求的页面的名称。如果您想知道 API 请求是从您的应用程序的哪个页面发起的,这将非常有帮助。如果您的 API 不是为浏览器设计的,这可能就不那么重要了。是的,它拼写错误了。互联网并不完美。现在,让我们来一点趣闻知识:RFC 1945 于 1996 年作为 HTTP/1.0 协议的规范发布。发布它的团队中包括了蒂姆·伯纳斯-李(即万维网的发明者)。第 10.13 节介绍了
Referer标题,但在规范中不小心拼错了!随后的规范和实现都采用了这个拼写错误,并且它已经伴随着我们近 30 年了。如果不是其他的话,这确实是对使用拼写检查器的一个警告。datatracker.ietf.org/doc/html/rfc1945#section-10.13 -
来源 这个标题与
Referer类似。虽然Referer通常会包括请求来源的完整路径,但Origin标题只是 URL,通常形式为<scheme>://<hostname>:<port>,不包含路径。我们将在第七章,处理安全关注点中探讨我们如何使用它来保护我们的应用程序免受 CORS 攻击。 -
用户代理 这个标题几乎总是由每个 HTTP 客户端发送。它标识了访问您的 API 的应用程序类型。通常它是一个浏览器,但也可能是
curl、Python 库或 Postman 或 Insomnia 等工具。 -
主机 在第三章,路由和接收 HTTP 请求中,我们看到了如何使用虚拟主机进行基于主机的路由。这是通过读取
Host标题来实现的。虽然Origin是请求来源的域名,但Host是它将要到达的地方。通常,我们事先就知道这些信息。但是,有时我们可能有一个动态主机(如通配符子域名),或者多个域名指向一个应用程序。 -
转发标题 这包括
Forwarded和许多以X-Forwarded-*开头的标题。通常,当你看到一个以X-开头的标题时,这意味着它已经成为常见的实践和使用的标题,但其实现并不一定是标准的。
这些头部是什么?它们包含有关 Web 请求的详细信息,并由中间代理(如 Nginx 或 Cloudflare)传递有关请求的相关详细信息。最常见的是 X-Forwarded-For。这是一个从原始请求到当前处理请求的服务器的所有 IP 地址列表(这与 tracert 不同)。这在尝试通过 IP 地址识别请求时非常有用且非常重要。
重要提示
与 所有 头部和输入数据一样,您绝对不应该假设传入的用户数据是准确和无害的。有人伪造头部非常简单。就像始终一样,我们在读取头部时需要谨慎,而不仅仅是接受它们字面上的意思。
Sanic 为我们提取一些头部数据
Sanic 会自动从头部提取有关请求的信息,并将它们放置在 Request 对象上易于访问的属性中。这使得它们在需要时非常有用。以下是您可能会遇到的一些常见属性的参考。
| 请求属性 | 用于生成 HTTP 头部的属性 |
|---|---|
request.accept |
接受 |
request.forwarded |
转发 |
request.host |
主机 |
request.id |
X-Request-ID(可以配置) |
request.remote_addr |
转发,或 X-Forwarded-For(取决于第十一章 完整真实世界示例 中涵盖的更多配置) |
request.token |
授权 |
表 4.1 - 提取的头部数据
提示
有时可能难以确定何时使用
request.ip和何时使用request.remote_addr。前者属性始终会被设置,并且始终返回连接到它的客户端的 IP 地址。这可能并不是你想要的。如果你的应用程序位于代理服务器后面,并且你需要依赖于X-Forwarded-For,那么你想要的属性很可能是request.remote_addr。
头部作为多字典
头部在 Sanic 中以多字典的形式存储。这是一种特殊的数据类型,它将同时作为一对一键值字典和一对多键值字典操作。为了说明这一点,以下是它们通常看起来像什么:
one_to_one = {
"fruit": "apples"
}
one_to_many = {
"Fruit": ["apples", "bananas"]
}
Sanic 中的 Header 对象同时充当这两个角色。此外,它将键视为不区分大小写。你注意到在最后一个例子中键的大小写不同吗?使用标准字典,以下将是 False。
"fruit" in one_to_one and "fruit" in one_to_many
然而,由于 HTTP 规范允许 HTTP 头部不区分大小写,Sanic 的 Header 对象也不区分大小写。但它如何处理一对一和多对一的问题?
再次强调,HTTP 规范允许将多个相同的头信息连接起来,而不会相互覆盖。Sanic 选择这种特殊的数据类型以确保符合标准。如果你不做任何特殊处理,只是将Header对象当作一个普通的 Python dict在你的应用程序中使用,它将正常工作。你可能甚至永远不会注意到它不是一个普通的字典。然而,你将只能访问每个头信息传递给它的第一个值。如果你需要支持相同头信息的多个值,你可以访问所有值的完整列表。
考虑以下示例:
@app.route("/")
async def handler(request):
return json(
{
"fruit_brackets": request.headers["fruit"],
"fruit_get": request.headers.get("fruit"),
"fruit_getone": request.headers.getone("fruit"),
"fruit_getall": request.headers.getall("fruit"),
}
)
现在让我们用多个Fruit头信息来访问这个端点。
$ curl localhost:7777/ -H "Fruit: apples" -H "Fruit: Bananas"
{
"fruit_brackets": "apples",
"fruit_get": "apples",
"fruit_getone": "apples",
"fruit_getall": [
"apples",
"Bananas"
]
}
使用方括号或.get()方法可以提供apples,因为这是第一个发送的Fruit头信息。更明确的使用方法是使用.getone()。或者,我们可以使用.getall()来返回完整的Fruit头信息值列表。再次强调,对于头键,大小写不重要。然而,对于值,大小写是重要的。注意在我们的示例中Fruit变成了fruit,但Bananas的大小写完全没有改变。
从饼干中获取信息(美味!)
建立一个没有饼干的 Web 应用程序就像没有饼干的餐后一样。当然,这是可以做到的。但为什么你要这样做呢?如果可以选择,请选择饼干。
开个玩笑,饼干显然是一个需要考虑的极其重要的话题。它们是许多丰富的 Web 应用程序用户体验的支柱。饼干本身也充满了潜在的安全陷阱。当我们谈论设置饼干(第五章,处理和响应视图)以及保护我们的 Web 应用程序(第七章,处理安全关注)时,安全问题通常更为关注。在这里,我们主要感兴趣的是如何访问饼干,以便我们可以从中读取数据。
Web 饼干是一个特殊的 HTTP 头信息:Cookie。这个头信息包含由 RFC 6265,§ 5.4 定义的结构化数据集。tools.ietf.org/html/rfc6265#section-5.4。来自请求的传入饼干在 Sanic 中被当作一个普通的字典处理。
-
为了更明确地了解饼干的结构,设置一个像这样的调试处理程序
@app.route("/cookies") async def cookies(request): return json(request.cookies) -
现在,我们将使用 curl 发送一些饼干:
$ curl localhost:7777/cookie -H "Cookie: name=value; name2=value2; name3=value3" { "name": "value", "name2": "value2", "name3": "value3" }
如你所见,数据只是一个简单的键/值字典。因此,访问饼干应该非常直接。像其他形式的数据一样,当然建议对这些数据进行怀疑处理。这些值并非不可篡改,并且很容易被伪造。尽管如此,它们是网络的重要组成部分,特别是如果你的应用程序需要支持前端 UI。
虽然使用饼干将成为你应用程序中数据的一个无价来源,但用户传递信息的主要方法将来自其他形式。接下来,我们将探讨从 Web 客户端到 Web 服务器的其他数据传递方法。
读取表单、查询参数、文件、JSON 等
现在我们已经了解了从路径和头部获取输入的方法,我们将把注意力转向更多经典类型的传递输入值。通常,我们认为请求数据是从请求体中来的那些信息片段。在我们转向请求体之前,HTTP 请求的第一行还有一个项目需要检查:查询参数。
查询参数
作为提醒,HTTP 请求的第一行看起来是这样的:
GET /stalls/2021-07-01?type=fruit HTTP/1.1
如果你有过网络经验,你可能知道 URL 可以有一个由问号(?)与路径的其余部分分开的任意参数部分。这些被称为查询参数(或参数),以key=value的形式连接,并用与号(&)连接。有时它们被称为参数,有时称为参数。在这里,我们将称它们为参数,因为这是 Sanic 选择的方法,以便能够将它们与路径参数区分开来。
查询参数非常简单易用,我们可以在我们的请求实例中访问它们:
@app.route("/")
async def handler(request: Request):
print(request.args)
return text(request.args.get("fruit"))
$ curl localhost:7777\?fruit=apples
apples
重要信息
你可能已经注意到我的
curl命令中包含了\?而不是仅仅?。在某些命令行应用程序中,这是一个必要的模式,因为?本身可能有不同的含义。它也可以用引号括起来:curl "localhost:7777?fruit=apples",但我更喜欢去掉引号,选择字符转义。
使用看起来很简单,对吧?嗯,等等。接下来的明显问题是当键重复时会发生什么?或者,当我们想要传递一个数据数组时会发生什么?
在互联网上通过查询参数传递数组数据并没有一种单一的标准方法。存在几种方法:
-
?fruit[]=apples&fruit[]=bananas -
?fruit=apples,bananas -
?fruit=[apples,bananas] -
?fruit=apples&fruit=bananas
Sanic 最初拒绝了前三种方法,而是选择了第四种方案。快速浏览一下这三种被拒绝的模型,可以解释为什么选择这种模型是有道理的,以及我们如何继续使用它。
首先,fruit[]是一个对新手来说不太明显的结构,实际上是对键的劫持和修改。呃,不,谢谢。
其次,fruit=apples,bananas看起来不错,但如果我们只想传递一个字符串apples,bananas而不真正分开它们怎么办?嗯,这似乎是不可能的。传递。
第三,fruit=[apples,bananas]看起来更好,但仍然有些尴尬且不直观。它也面临着相同的歧义问题。apples,bananas是一个字符串,还是两个项目?
此外,第二种和第三种方案还面临如何处理重复键的问题。选择第一个?最后一个?合并?错误?再次,没有共识,不同的服务器处理方式不同。
最合理的方法似乎是第四种,它可以处理所有这些问题。保持简单:我们有一个键和一个值。没有更多。如果有重复的键,我们将其视为列表追加。没有数据丢失的惊喜,没有错误,数据完整性得到保持。
在我们最后的例子中,我们打印了 request.args 的值。这里是输出:
{'fruit': ['apples']}
[INFO][127.0.0.1:53842]: GET http://localhost:7777/?fruit=apples 200 6
等等?!一个 list?我以为它是一个单一值:“apples”。至少响应是这样的。查询参数是一个特殊的字典,它包含列表,但有一个独特的 .get(),它将只从该列表中获取第一个值。如果你想获取所有元素,请使用 .getlist().
@app.route("/")
async def handler(request: Request):
return json(
{
"fruit_brackets": request.args["fruit"],
"fruit_get": request.args.get("fruit"),
"fruit_getlist": request.args.getlist("fruit"),
}
)
当我们现在访问这个端点时,我们可以看到这些值:
$ curl localhost:7777\?fruit=apples\&fruit=bananas
{
"fruit_brackets": ["apples","bananas"],
"fruit_get": "apples",
"fruit_getlist": ["apples","bananas"]
}
另一个值得注意的点是,request.args 并不是查看这些键/值对的唯一方式。我们还有 request.query_args,它只是所有传递的键值对的元组列表。上面的请求看起来可能像这样:
request.query_args == [('fruit', 'apples'), ('fruit', 'bananas')]
这样的数据结构当然可以很容易地转换成标准的字典,如果需要的话。但请小心,因为你会丢失重复键的数据;只剩下每个重复键的最后一个:
>>> dict( [('fruit', 'apples'), ('fruit', 'bananas')])
{'fruit': 'bananas'}
表单和文件
通过学习如何从查询参数中提取数据,我们无意中也就学会了如何获取表单数据和上传的文件数据!这是因为查询参数、表单和文件都操作相同。为了证明这一点,我们将设置几个端点,就像之前做的那样,看看会发生什么。
@app.post("/form")
async def form_handler(request: Request):
return json(request.form)
@app.post("/files")
async def file_handler(request: Request):
return json(request.files)
接下来,我们将测试表单处理器。
$ curl localhost:7777/form -F 'fruit=apples'
{"fruit":["apples"]}
$ curl localhost:7777/form -F 'fruit=apples' -F 'fruit=bananas'
{"fruit":["apples","bananas"]}
就像之前一样,我们看到它看起来像是一个带有列表的字典。嗯,那是因为它就是这样。但它仍然会像 request.args 一样表现。我们可以使用 .get() 来获取第一个项目,对于列表中的所有项目,我们可以使用 .getlist()。
assert request.form.get("fruit") == "apples"
assert request.form.getlist("fruit") == ["apples","bananas"]
当然,我们也会看到相同的结果。
$ curl localhost:7777/files -F 'po=@/tmp/purchase_order.txt'
{
"po": [
["text\/plain","product,qty\napples,99\n","purchase_order.txt"]
]
}
我们可能想更仔细地看看这个,看看它在做什么。
当你将文件上传到 Sanic 时,它将将其转换为 File 对象。File 对象实际上只是一个包含有关文件的基本信息的 namedtuple。如果我们执行 print(request.files.get("po")),我们应该看到一个看起来像这样的对象:
File(
type='text/plain',
body=b'product,qty\napples,99\n',
name='purchase_order.txt'
)
提示
你不熟悉
namedtuple吗?它们是建模简洁对象的一个非常好的工具。我强烈推荐使用它们,因为它们的行为就像元组一样,但具有使用点符号访问特定属性的便利性。只要不需要修改它们的内容,它们就可以作为字典的替代品。这就是为什么 Sanic 在这里使用它们作为文件对象的原因。它是一个方便的小结构,我们作为开发者很容易与之合作,同时保持数据的一些安全性,以免意外损坏。
消费 JSON 数据
争议性地,最重要的请求数据类型是 JSON。现代网络应用因为其简单性而拥抱并坚持使用 JSON 来序列化和传输数据。它支持基本类型的标量值,对人类来说易于阅读,易于实现,并且在许多编程语言中得到广泛支持。难怪它是默认的方法。
因此,Sanic 使之变得非常容易:
@app.post("/")
async def handler(request: Request):
return json(request.json)
Our request JSON is converted to a Python dictionary.
$ curl localhost:7777 -d '{"foo": "bar"}'
{"foo":"bar"}
我们现在已经看到了在单个请求中访问数据的所有典型方式。接下来,我们将学习如何将数据以多个块的形式流式传输到 Sanic。
获取流式数据
“流式”这个词已经成为一个有点儿时髦的术语。许多人在技术行业之外也经常使用它。这个词——以及它所代表的实际技术概念——随着媒体内容消费持续向云端迁移,已经成为社会的一个重要组成部分。那么,流式究竟是什么?对于那些对这个词的含义不完全清楚的人来说,我们将在继续前进之前简要地了解一下它。
流式是指从连接的一侧向另一侧发送多个连续的数据块。HTTP 模型的核心基础之一是,在客户端和服务器之间建立连接后,会有一个请求,然后是响应。客户端发送一个完整的 HTTP 请求消息,然后等待服务器发送一个完整的 HTTP 响应消息。它看起来是这样的:

图 4.1 - 正常的 HTTP 请求/响应周期
我喜欢把它们看作是有限的交易。请求和响应都有一个明确且已知的终点。这些有限的请求就是我们迄今为止一直在关注的。一个请求到来,服务器对其进行处理,然后发送一个响应。需要注意的是,请求和响应都是作为一个整体在一个单独的块中发送的。
我们之前没有讨论的一个头信息是 Content-Length 头信息。这个头信息可以在请求和响应中找到。关于何时应该发送以及何时必须发送的实际规范超出了本次讨论的范围。当需要时,Sanic 会为我们处理这个问题。我提到它是因为这个头信息正是它所声称的那样:HTTP 消息中内容的长度。这告诉接收方正在传输一个特定长度的消息。而且,在这里它很重要,因为当请求头信息发送时,消息的已知长度不一定能被计算出来。
如果有大量数据需要发送,可能会超过单个连接的承载能力,或者当连接打开时发送的数据不是 100%可用,会发生什么情况?流式传输是一种方法,连接的一侧通知另一侧它正在传输一些字节,但尚未完成。应该保持连接打开,以便发送更多数据。这种交互发生的方式是通过用Transfer-Encoding: chunked头替换Content-Length头。这是连接的一侧通知另一侧它应该继续接收数据,直到收到数据流关闭通知的方式。
当大多数非专业人士听到“流式传输”这个词时,他们首先想到的是流式媒体,如电影或音乐。他们可能会将这个概念描述为在完全下载之前消费媒体。这是正确的。流式传输是将数据分成多个 块 发送,而不是一次性发送。这非常高效,可以减少整体资源开销。如果支持,它允许接收方在需要时开始处理这些数据,而不是阻塞并等待其完成。所以,当你去看你最喜欢的电影时,你可以在整个文件下载完毕之前开始观看。
然而,流式传输不仅适用于媒体,而且不仅由服务器执行。我们关注两种基本类型:请求流式传输和响应流式传输。以下是这些流程的示例:

图 4.2 - HTTP 流式请求
在 图 4.2 中,我们看到流式请求的样子。一旦 HTTP 连接打开,客户端开始发送数据。但是,它不会一次性发送消息。相反,它将消息分成块,单独发送每个字节的块。

图 4.3 - HTTP 流式响应
图 4.3 中的流式响应基本上是流式请求的相反。请求是完整发送的,但服务器决定分块发送响应,直到完成。当人们谈论流式媒体时,他们指的是响应流。我们将在 第五章,处理和响应视图 中更详细地探讨这个选项,当我们讨论不同类型的响应时。
我们目前关注的是学习 图 4.2 中展示的请求流式传输。必须明确指出,在这两者之间,这无疑是使用较少的功能。当你在网上搜索 流式 HTTP 时,你可能会找到关于它的信息较少。尽管如此,在正确的情况下,它仍然是一个强大的工具。
那么,首先我们要问,何时应该考虑使用请求流?一个潜在的使用场景是如果客户端想要预热HTTP 连接。假设你正在构建一个股票交易平台。前端 UI 和后端服务器之间的延迟至关重要。毫秒级的差异可能对金融产生影响。你的任务是尽可能快地从前端获取数据。解决方案是在用户点击输入框时立即发起POST请求。同时,前端 UI 通过带有Transfer-Encoding: chunked头的 HTTP 连接打开,表示还有更多数据要来。因此,当用户输入值时,我们已执行操作并承受了与打开连接相关的任何开销。现在服务器处于警觉状态,等待用户按下Enter键后立即接收数据。
这个端点可能看起来是什么样子?
async def read_full_body(request: Request):
result = ""
while True:
body = await request.stream.read()
if body is None:
break
result += body.decode("utf-8")
return result
@app.post("/transaction", stream=True)
async def transaction(request: Request):
body = await read_full_body(request)
data = ujson.loads(body)
await do_transaction(data)
return text("Transaction recorded", status=201)
让我们一次指出几个重要的部分。
-
我们需要告诉 Sanic 我们将在这里进行响应流。有两种选择:在路由定义中传递
stream=True,或使用@stream装饰器。它们的工作方式相同,所以这更多是一个个人选择的问题。from sanic.views import stream @app.post("/transaction", stream=True) async def transaction(request: Request): ... # OR @app.post("/transaction") @stream async def transaction(request: Request): ... -
应该有一个某种循环,继续从流中读取,直到它完成。我们如何知道它已经完成?将会有一个从流中读取空数据的情况。如果你跳过了
if body is None行,你的服务器可能会陷入无限循环。当读取数据时,它是一个bytes字符串,所以你可能想将其转换为常规的str,就像我们在这里做的那样。result = "" while True: body = await request.stream.read() if body is None: break result += body.decode("utf-8")重要的是要注意,在这个例子中,我们在继续处理请求之前完全读取了主体。另一种选择可能是将这些字节写入可以立即消费并对其采取行动的其他东西。我们很快就会看到一个这样的例子。
-
你需要自己解码主体。在常规请求中,如果你发送 JSON 数据,Sanic 会为你解码。但在这里,我们只有原始字节(转换为字符串)。如果我们需要进一步处理,我们应该自己处理。在我们的例子中,我们使用
ujson.loads,这是 Sanic 附带的一个快速将 JSON 转换为 Pythondict的方法。
我们的例子之所以有效,是因为我们预期客户端会发送单个延迟输入。你可能还会在其他地方使用这种方法,比如文件上传。如果你预期会有大文件上传,你可能希望一收到字节就立即开始读取和写入。
下面是一个示例:
@app.post("/upload")
@stream
async def upload(request: Request):
filename = await request.stream.read()
async with aiofiles.open(filename.decode("utf-8"), mode="w") as f:
while True:
body = await request.stream.read()
if body is None:
break
await f.write(body.decode("utf-8"))
return text("Done", status=201)
我们应该注意到这里的循环看起来与上一个非常相似。概念是相同的:循环直到没有更多内容可读。区别在于,我们不是将数据写入局部变量,而是使用aiofiles库异步地将字节写入文件。
您为什么要这样做呢?最大的原因可能是效率和内存利用。如果您使用常规的request.files访问器来读取文件数据,那么您实际上在处理它们之前会读取整个内容。如果涉及大文件,这可能会导致大量的内存使用。通过分块读取和写入,我们保持缓冲区的大小。
本章完全专注于读取数据的不同方法。我们知道我们可以从正文、文件、表单数据、流和查询参数中访问它。所有这些机制本身都缺少一个关键组件:验证。
数据验证
接下来我们要探讨的是本书中关于安全相关主题的第一部分。我们将在第七章,处理安全关注点中介绍更多概念。然而,这并不是一本关于安全的书。不幸的是,内容太多,无法在这本书中全部涵盖。针对安全这一章节,风险和潜在的缓解措施太多。因此,我们将针对那些不熟悉这些概念的人进行一般性讨论,然后展示一些在 Sanic 中解决该问题的方法。
这些主题中的第一个是数据验证。如果您在网络上有所涉猎,那么您就知道我在说什么,原因对您来说也很明显。您担心 SQL 注入攻击或 XSS 攻击。您知道盲目接受数据并据此采取行动可能带来的潜在威胁。我相信您已经知道这是绝对不允许的,并且您来这里是为了学习如何在 Sanic 中实施标准实践。如果您对数据验证的概念完全陌生,我建议您花些时间搜索其他在线材料,了解上述攻击带来的安全问题。
Web API 安全不是单一的方法。数据验证只是保护您的应用程序、资源和用户所需更大计划的一部分。本节的重点将主要放在现代 Web 应用程序中最常见的场景上:确保 JSON 数据符合预期。仅这些技术本身并不能使您的应用程序免受攻击。有关更多信息,请参阅第七章,处理安全关注点。我们的目标更为谦逊:当我们期望一个数字时,我们得到一个数字;当我们期望一个 UUID 时,我们得到一个 UUID。
如果您还记得第三章,路由和接收 HTTP 请求,我们实际上第一次接触到了数据验证。我们试图确保接收到的数据是已知冰淇淋口味列表中的一个。我们将在这里扩展这个概念。现在有很多库可以为我们完成这项工作。一些流行的选择包括 marshmallow、attrs 和 pydantic。在我们尝试利用现有的包之前,我们将尝试使用 Python 的数据类构建自己的验证库。
记住我们为什么要这样做是很好的。正如我们所知,Sanic 努力不替开发者做决定。数据验证是应用程序中最关键的部分之一,它可以从一个用例到另一个用例有很大的变化。因此,Sanic 核心项目没有为这一行为提供单一的方法,而是将选择权留给了你:开发者。当然,市面上有许多插件可以添加验证,但我们将尝试自己构建一个符合我们需求的插件。最终,我希望这能激发你自己在项目中的灵感,将原则应用到你自己独特的情境中。接下来的这一部分将偏离 Sanic,更多地关于 Python 编程的一般性。然而,最终我认为看到 Sanic 如何试图为你让路,让你实现自己的解决方案和业务逻辑,并且只在需要时介入,是有启发性的。
话虽如此,让我们开始吧。
第一步:开始并创建一个装饰器
我们需要做的第一件事是创建一个我们将要工作的框架。为了实现我们的目标,我们将严重依赖装饰器。这是一个绝佳的方法,因为它让我们可以创建针对每个路由的定义,但也可以根据需要轻松地在整个应用程序中重复我们的逻辑。我们追求的是类似以下这样的东西:
@app.post("/stalls")
@validate
async def book_a_stall(request: Request, body: BookStallBody):
...
这个看起来界面非常干净。这将实现什么?
-
不重复:而不是明确告诉
validate函数要做什么,我们将使用一些 Python 技巧从处理器签名中读取body: BookStallBody。 -
依赖注入:我们的
validate函数将需要注入一个body参数。这意味着我们应该有一个干净的数据结构,其中包含我们想要的确切信息,并将其转换为预期的数据类型。如果缺少某些信息,应该抛出异常并导致失败响应。 -
类型注解:通过注解
body参数,我们将从mypy和我们的 IDE 中获得有用的功能,以确保我们的代码干净、一致且无错误。
首先,我们想要创建一个既可以调用也可以不调用的装饰器。这将使我们既能使用@validate也能使用@validate(),这将使我们的体验更加灵活,更容易随着我们使用范围的扩大而扩展。我们之前在第三章,路由和接收 HTTP 请求中已经看到了一个例子。让我们看看最小化装饰器的样子:
def validate(wrapped=None):
def decorator(handler):
@wraps(handler)
async def decorated_function(request, *args, **kwargs):
return await handler(request, *args, **kwargs)
return decorated_function
return decorator if wrapped is None else decorator(wrapped)
这样一来,我们就有一个最小可行的装饰器。显然,它目前还没有做任何有用的事情,但我们可以从这个基础上开始构建我们的验证逻辑。
第二步:阅读处理器签名
接下来,我们想要确定我们想要验证请求的哪些部分。我们将从 JSON 体开始。在我们的目标实现中,我们希望通过处理器签名来控制这一点。然而,我们有一个替代方案。例如,我们可以尝试这样做:
@app.post("/stalls")
@validate(model=BookStallBody, location="body")
async def book_a_stall(request: Request, body: BookStallBody):
...
不可否认,这是一个更简单的装饰器来构建。在这个版本中,我们明确告诉validate函数,我们希望它在请求体中查找并验证一个名为BookStallBody的模型。但是,如果我们还想使用类型注解,由于我们需要将模型放入类型化函数参数中,我们最终会得到重复的代码。我们不会让困难吓倒我们!毕竟,我们知道这个装饰器将在我们的整个应用程序中到处使用。提前构建一个更好的版本将有助于我们在重用和扩展实现时走得更远。
那么,我们如何获取模型和位置信息呢?我们将使用 Python 标准库中的typing模块。在这里,我们需要非常小心。在处理装饰器时,我们需要记住,有不同层次的代码在不同的时间执行。由于我们正在评估处理器,我们只想这样做一次。如果我们设置错误,我们可能会在每个请求上执行设置代码!我们将尽力避免这种情况。
现在我们到了这里:
import typing
def validate(wrapped=None):
def decorator(handler):
annotations = typing.get_type_hints(handler)
body_model = None
for param_name, annotation in annotations.items():
if param_name == "body":
body_model = annotation
# Remainder of decorator skipped
我们检查处理器,遍历其中定义的参数。如果有一个名为body的参数,我们就获取它的注解并保存以供以后使用。
这种方法的潜在缺点之一是我们通过只允许我们的验证在名为body的参数上进行,把自己局限住了。如果我们有一个需要这样的 URL:/path/to/<body>,或者我们只是想给变量起一个不同的名字呢?让我们让这个装饰器稍微灵活一些。
def validate(wrapped=None, body_arg="body"):
def decorator(handler):
annotations = typing.get_type_hints(handler)
body_model = None
for param_name, annotation in annotations.items():
if param_name == body_arg:
body_model = annotation
@wraps(handler)
async def decorated_function(request, *args, **kwargs):
nonlocal body_model
return await handler(request, *args, **kwargs)
return decorated_function
return decorator if wrapped is None else decorator(wrapped)
通过将 body 参数的名称移动到body_arg,我们就有灵活性在需要时重命名它。
第三步:建模
下一个关键部分是我们的模型。这可能是一个添加预构建库的地方;例如,前面提到的包之一。我当然建议你看看它们。有许多贡献者投入了大量时间构建、测试和支持这些包,它们将覆盖比我们的简单示例多得多的用例。但是,由于我们还在学习,我们将继续在 dataclasses 之上构建自己的验证逻辑。
让我们创建一个基本的有效负载,这是我们可能在端点上期望的。
from dataclasses import dataclass
from enum import Enum, auto
class ProductType(Enum):
def _generate_next_value_(name, *_):
return name.lower()
FRUIT = auto()
VEGETABLES = auto()
FISH = auto()
MEAT = auto()
class ValidatorModel:
def __post_init__(self):
...
@dataclass
class Product(ValidatorModel):
name: str
product_type: ProductType
@dataclass
class BookStallBody(ValidatorModel):
name: str
vendor_id: UUID
description: str
employees: int
products: List[Product]
好吧,这里没有太多新内容。我们正在使用 Python 的 dataclasses 定义一些模型。如果你不熟悉它们,我鼓励你去查找它们。简而言之,它们是带有类型注解的数据结构,我们将很容易与之合作。它们的一个问题是类型注解在运行时不被强制执行。尽管我们说BookStallBody.vendor_id是一个UUID,Python 仍然会愉快地注入布尔值或其他类型的值。这就是ValidatorModel发挥作用的地方。我们将向dataclass添加一些简单的逻辑,以确保它使用正确的数据类型填充。
在这个简单的结构中添加的一个很好的技巧是,我们将ProductType定义为Enum。通过定义_generate_next_value_,我们强制每个枚举的值都是键的小写字符串值。例如:
assert ProductType.FRUIT.value == "fruit"
提示
当你的应用程序处理任何类型的 ID 时,你应该尽量避免从数据库传递序列 ID 记录。许多常见的数据库在插入记录时都会增加行号。如果你的 API 依赖于这个 ID,你无意中向世界广播了关于应用程序状态的信息。坚持使用 UUID 或其他形式,这将为客户面向的应用程序增加一些神秘感。不要让你的数据库 ID 离开你的服务器。
第四步:模型初始化
最终,我们希望能够向我们的端点发送一个看起来像这样的 JSON 请求:
{
"name": "Adam's Fruit Stand",
"vendor_id": "b716337f-98a9-4426-8809-2b52fbb807b3",
"employees": 1,
"description": "The best fruit you've ever tasted",
"products": [
{
"name": "bananas",
"product_type": "fruit"
}
]
}
因此,我们的目标是把这个嵌套结构转换成 Python 对象。数据类可以帮我们走一段路。缺少的是具体的类型转换和嵌套。这正是我们的ValidatorModel类将为我们提供的:
class ValidatorModel:
def __post_init__(self):
for field in fields(self.__class__):
existing = getattr(self, field.name)
hydrated = self._hydrate(field.type, existing)
if hydrated:
setattr(self, field.name, hydrated)
elif type(existing) is not field.type:
setattr(self, field.name, field.type(existing))
def _hydrate(self, field_type, value):
args = get_args(field_type)
check_type = field_type
if args:
check_type = args[0]
if is_dataclass(check_type):
if isinstance(value, list):
return [self._hydrate(check_type, item) for item in value]
elif isinstance(value, dict):
return field_type(**value)
return None
这看起来可能有很多事情要做,但实际上非常简单。在创建了一个模型实例之后,我们遍历它的所有字段。实际上现在有两种选择:要么字段注解是另一个数据类,要么它是其他东西。如果是其他东西,那么我们只想确保将其转换为新的类型。
如果我们正在处理一个数据类,那么我们还有两个选项需要确定。要么它是一个单独的项目,要么是一个项目的列表。如果是列表,那么我们只需确保我们遍历所有值并尝试为每个单独的项目进行初始化。
承认,这不会涵盖所有用例。但既然我们正在创建自己的解决方案,我们只关心它是否涵盖了我们需要的情况,并且如果未来需要添加更多复杂性,它是否相对简单易维护。
这个解决方案会为我们做到这一点。
第五步:执行验证
现在我们模型能够处理嵌套逻辑并将所有值转换为所需的类型,我们需要将其重新连接到我们的装饰器上。
现在我们所处的位置:
def validate(wrapped=None, body_arg="body"):
def decorator(handler):
annotations = get_type_hints(handler)
body_model = None
for param_name, annotation in annotations.items():
if param_name == body_arg:
body_model = annotation
@wraps(handler)
async def decorated_function(request, *args, **kwargs):
nonlocal body_model
nonlocal body_arg
if body_model:
kwargs[body_arg] = do_validation(body_model, request.json)
return await handler(request, *args, **kwargs)
return decorated_function
return decorator if wrapped is None else decorator(wrapped)
重要的更改如下:
if body_model:
kwargs[body_arg] = do_validation(body_model, request.json)
这会将我们的原始 JSON 请求数据转换为可用的(并且注释良好的)数据结构。如果数据类型失败,应抛出异常。那么,那会是什么样子呢?
from sanic.exceptions import SanicException
class ValidationError(SanicException):
status_code = 400
def do_validation(model, data):
try:
instance = model(**data)
except (ValueError, TypeError) as e:
raise ValidationError(
f"There was a problem validating {model} "
f"with the raw data: {data}.\n"
f"The encountered exception: {e}"
) from e
return instance
如果我们的数据类模型无法将值转换为预期的类型,那么它应该引发一个ValueError或TypeError。我们希望捕获这两个中的一个,并将其转换为我们的ValidationError,原因有两个。首先,通过从SanicException派生,我们可以给异常一个status_code,当这个异常被引发时,Sanic 将自动知道返回一个400响应。第九章,提高你的 Web 应用程序的最佳实践讨论了更多关于异常处理的内容,这是另一个重要的考虑因素。现在,只需知道 Sanic 在调试和常规模式下都会为我们提供一些异常处理。
使用第三方包将问题提升到下一个层次
上一个部分中的输入验证确实有点薄弱。它对我们的非常有限的使用案例工作得很好,但缺乏从合适的包中实现的一些丰富性。如果你的未来项目需要一些定制的验证逻辑,那么,无论如何,使用启动你的项目所开始的内容。
然而,我们在这里将改变模式。我们不会使用普通的 vanilla 数据类和我们的自定义ValidatorModel,而是将使用第三方包。我们将保留我们构建的大部分内容,所以我们并不是完全采用现成的解决方案。
让我们看看如果我们使用 Pydantic 会是什么样子。
使用 Pydantic 进行验证
Pydantic 是一个流行的 Python 创建模型包。它通常与类型注解配合得很好,甚至有 dataclasses 的即插即用替代品。因此,我们可以使用之前的示例,更改dataclass导入行,并移除ValidatorModel,我们就提升了我们的能力!
-
我们将我们的模型更改为使用
Pydantic 数据类:from pydantic.dataclasses import dataclass -
移除
ValidatorModel,因为它不再需要。@dataclass class Product: name: str product_type: ProductType @dataclass class BookStallBody: name: str vendor_id: UUID description: str employees: int products: List[Product] @dataclass class PaginationQuery: limit: int = field(default=0) offset: int = field(default=0) -
唯一的另一个改变是确保
do_validation将引发适当的错误消息(更多关于异常处理的内容请参阅第六章,响应周期之外)def do_validation(model, data): try: instance = model(**data) except PydanticValidationError as e: raise ValidationError( f"There was a problem validating {model} " f"with the raw data: {data}.\n" f"The encountered exception: {e}" ) from e return instance这几乎是一个相同的解决方案。请查看 GitHub 仓库中的完整示例。我们现在有了处理更复杂验证逻辑的完整库的力量。也许我们应该稍微扩展我们的装饰器,以处理其他类型的输入验证。
-
首先是一个模型,展示我们预期的查询参数将如何看起来。
from dataclasses import field @dataclass class PaginationQuery: limit: int = field(default=0) offset: int = field(default=0) -
然后,我们将装饰器扩展以处理正文和查询参数:
def validate( wrapped=None, body_arg="body", query_arg="query", ): def decorator(handler): annotations = get_type_hints(handler) body_model = None query_model = None for param_name, annotation in annotations.items(): if param_name == body_arg: body_model = annotation elif param_name == query_arg: query_model = annotation @wraps(handler) async def decorated_function(request: Request, *args, **kwargs): nonlocal body_arg nonlocal body_model nonlocal query_arg nonlocal query_model if body_model: kwargs[body_arg] = do_validation(body_model, request.json) if query_model: kwargs[query_arg] = do_validation(query_model, dict(request.query_args)) return await handler(request, *args, **kwargs) return decorated_function return decorator if wrapped is None else decorator(wrapped)在这个例子中,我们不仅寻找正文参数,还寻找查询参数。它们之间的实现看起来非常相似。我们现在可以在其他情况下重用我们的装饰器:
@app.get("/stalls/<market_date:ymd>") @validate async def check_stalls( request: Request, query: PaginationQuery, market_date: date, ): ...
现在是进行一个小实验的时候了。我们首先验证了请求 JSON。这被验证并作为body参数注入。然后我们看到扩展到query参数非常简单。你现在的挑战是放下这本书,看看你是否可以为常规表单和文件上传验证做出类似的实现。看看这里的方法,并参考我们在书中早些时候提到的request.files和request.form对象。
摘要
可以相当安全地假设所有 Web API 在某个时候都需要从用户那里获取一些输入。即使是只读 API,也常常可能允许过滤、搜索或分页数据。因此,要成为构建 Web 应用程序的专家,特别是 Sanic 应用程序的专家,你必须学会使用你手中的数据工具。
在本章中,我们涵盖了大量的内容。我们学习了如何从头部、cookie 和请求体中提取数据。当使用头部、表单数据、查询参数和文件数据时,我们发现这些对象可以作为常规字典操作,或者作为列表的字典,以符合 HTTP 标准,同时也适用于大多数常规用例。我们还看到请求体本身可以作为一个单独的数据块发送,也可以分多个数据块发送。
然而,可能最大的收获是读取数据不能,也不应该只走一条路径。提醒一下,Sanic 提供了构建最明显解决方案的工具。与其他许多项目可能会用如何在其特定 API 中实现表单数据检索的细节来填充类似的讨论不同,我们的大部分重点是如何与 Sanic 一起构建解决方案,而不是从 Sanic 中构建。这是一个试图不阻碍的框架。
例如,我们看到添加自定义和现成的验证逻辑非常简单。Sanic 没有告诉我们如何做。相反,它提供了一些便利,以帮助使我们的业务逻辑更容易构建。装饰器逻辑给了我们在整个应用程序中拥有可重用代码的灵活性。异常定义可以自动捕获和处理响应。使用 Sanic 构建应用程序更多的是构建结构良好的 Python 应用程序。
一旦收集并验证了信息,就需要对其进行处理。这是下一章的目的,我们将探讨如何处理和最终响应 Web 请求。
第六章:5 处理和响应视图
到目前为止,我们的应用程序在很大程度上是反应式的。我们致力于 Web 应用程序的不同部分,以了解如何管理传入的 HTTP 请求。如果我们想象 HTTP 请求/响应周期为一场对话,那么到目前为止,我们只是在倾听。我们的应用程序被构建成听取传入客户端要说的内容。
现在,轮到我们发言了。在本章中,我们将开始探索 HTTP 响应的不同方面。正如我们通过查看原始请求对象开始学习 HTTP 请求一样,我们将查看原始响应。它看起来几乎相同,在这个阶段应该很熟悉。我们将继续探索 Sanic 提供的强大工具。当然,有机制用于 JSON 和 HTML 响应,这些可能是今天在网络上交付的最受欢迎的内容类型。然而,Sanic 作为一个异步框架,具有优势:实现服务器驱动的响应(如:Websockets、服务器发送事件(SSE)和流式响应)非常简单。我们将在本章中探讨这些内容:
-
检查 HTTP 响应结构
-
渲染 HTML 内容
-
序列化 JSON 内容
-
流式传输数据
-
用于推送通信的服务器发送事件
-
用于双向通信的 Websockets
-
设置响应头和 cookie
技术要求
我们的一些示例将开始比我们之前看到的要长一些。为了方便起见,当你阅读本章时,你可能想将 GitHub 仓库放在手边:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/05.
检查 HTTP 响应结构
回到第三章,路由和接收 HTTP 请求,我们探讨了 HTTP 请求的结构。当 Web 服务器准备发送响应时,其格式与我们之前看到的非常相似。HTTP 响应将看起来像这样:
HTTP 1.1 200 OK
Content-Length: 13
Connection: keep-alive
Content-Type: text/plain; charset=utf-8
Hello, world.
我们看到的是以下内容:
-
包含使用的 HTTP 协议、状态码和状态描述的第一行
-
以
key: value格式和换行符分隔的响应头 -
一行空白
-
响应体
我们在这里查看这并不是因为我们必须知道它来构建一个 Web 应用。毕竟,将响应对象构建为有效的 HTTP 规范正是我们使用 Web 框架的原因之一。没有它们,构建这些数据块将会是乏味且容易出错的。相反,对我们来说,回顾和理解所发生的事情,以便我们能够增加我们对 HTTP 和 Web 应用开发的掌握是有帮助的。
大部分结构与我们之前学过的内容重复。
HTTP 响应状态
如果您比较 HTTP 请求和响应对象,最明显的区别可能是第一行。请求的第一行有三个不同的部分,而响应则更容易想象为只有两个部分:正在使用的 HTTP 协议和响应状态。我们在这本书中之前讨论了 HTTP 协议,例如见第三章,路由和接收 HTTP 请求,所以在这里我们将跳过它,并专注于响应状态。响应状态旨在成为既适合计算机又适合人类的工具,以便让客户端知道请求发生了什么。它是成功的吗?请求错误吗?服务器出错吗?这些问题以及更多,都由 HTTP 响应状态回答。
如果您过去曾经构建过网站,您可能对不同的响应码有基本的了解。即使从未构建过应用程序的人,也肯定在某个时候看到过网页上写着404 未找到或500 内部服务器错误。这些都是响应状态。HTTP 响应状态由一个数字和一个描述组成。这些数字及其具体描述的定义在RFC 7231 § 6中。datatracker.ietf.org/doc/html/rfc7231#section-6.
为了明确起见,如果您看到术语响应状态、状态码或响应码,它们都描述的是同一件事。我通常更喜欢使用响应状态来描述一般概念,而在谈论状态码的数值时使用状态码。然而,它们相当可以互换,这本书也使用了这些术语的互换。
最常见的三种状态如下:
-
200 OK
-
404 未找到
-
500 内部服务器错误
通常,Sanic 会尝试以最合适的状态响应。如果有错误,您可能会得到一个500。如果路径不存在,它将是一个404。如果服务器可以正确响应,Sanic 将使用200。让我们深入探讨一下,看看状态是如何组织的。
响应分组
标准响应被分组为 100 系列,如下所示:
-
100 系列:信息性 - 提供有关客户端应如何继续的信息的临时响应
-
200 系列:成功 - 表示请求按预期处理
-
300 系列:重定向 - 表示客户端必须采取进一步行动的响应
-
400 系列:客户端错误 - 表示客户端在尝试访问或处理某些资源时出现了错误
-
500 系列:服务器错误 - 表示服务器出现了错误,无法生成预期的响应
除了这三个主要类别之外,还有一些其他重要的响应您应该熟悉:
| 代码 | 描述 | 用途 |
|---|---|---|
201 |
已创建 | 端点成功创建了一个新资源;通常响应将包括新数据以及/或可以用来查找它的 ID |
202 |
已接受 | 应用程序已接收请求并将其推送到队列或后台进程以进行进一步操作 |
204 |
无内容 | 没有主体;通常在 OPTIONS 请求中 |
301 |
永久移动 | 目标资源现在位于一个新的永久 URI |
302 |
找到 | 目标资源临时位于不同的 URI |
400 |
错误请求 | 服务器拒绝响应请求,因为客户端有不适当的行为 |
401 |
未授权 | 请求缺少有效的认证凭据,不允许访问 |
403 |
禁止 | 请求已认证,但服务器不识别有效的授权以继续响应 |
表 5.1 – 常见状态码
通过异常进行响应
Sanic 的大多数内置异常都与特定的状态码相关联。这意味着我们可以引发一个异常,Sanic 将自动捕获该异常,并使用适当的响应和状态码提供响应。这使得它非常方便,并且简单易行。
例如,让我们想象我们正在构建一个音乐播放器应用程序。我们的端点之一允许登录用户查看他们的播放列表。然而,它被认证保护,只有与播放列表共享过的用户才能访问它。类似于以下内容:
from sanic.exceptions import NotFound
@app.get("/playlist/<playlist_name:str>")
async def show_playlist(request, playlist_name: str):
can_view = async check_if_current_user_can_see_playlist(
request,
playlist_name
)
if not can_view:
raise NotFound("Oops, that page does not exist")
...
通过引发NotFound,Sanic 将自动知道它应该返回一个404 Not Found响应:
$ curl localhost:7777/playlist/adams-awesome-music -i
HTTP/1.1 404 Not Found
content-length: 83
connection: keep-alive
content-type: application/json
{"description":"Not Found","status":404,"message":"Oops, that page does not exist"}
我们还可以通过我们自己的自定义异常处理程序扩展这个概念。
from sanic.exceptions import SanicException
class NotAcceptable(SanicException):
status_code = 406
quiet = True
@app.post("/")
async def handler(request):
if "foobar" not in request.headers:
raise NotAcceptable("You must supply a Foobar header")
return text("OK")
在这个例子中,通过继承SanicException,我们可以将异常与状态码关联起来。我们还设置了一个类属性:quiet=True。这不是必需的,但可能是期望的。这意味着异常及其跟踪信息(有关异常类型和位置的详细信息)将不会出现在您的日志中。这是SanicException的一个特定功能。对于在应用程序的正常运行过程中可能预期(但未捕获)的异常,它是有帮助的。
自定义状态
正如我们所看到的 HTTP 方法一样,只要它们有三位数,就可以创建自己的状态码。我并不是建议这是一个好主意,只是指出这是可能的,Sanic 允许我们这样做,尽管你可能不应该这样做。创建自己的状态码可能会让使用你的应用程序的浏览器或客户端感到困惑。不顾一切,我们仍然会尝试一下,看看 Sanic 是否允许我们这样做。
-
向一个“私有”变量(记住,它只是 Python,所以如果我们想,我们可以对其进行修改)添加一个新的状态类型:
from sanic.headers import _HTTP1_STATUSLINES _HTTP1_STATUSLINES[999] = b"HTTP/1.1 999 ROCK ON\r\n" @app.get("/rockon") async def handler(request): return empty(status=999)很好。现在让我们看看会发生什么。
-
检查 HTTP 返回,确保使用
-i以便我们看到原始响应:$ curl localhost:7777/rockon -i HTTP/1.1 999 ROCK ON content-length: 0 connection: keep-alive content-type: None总结一下,这里有一个有趣的小实验,以及 HTTP 规范的怪癖。将此路由输入您的应用程序:
@app.get("/coffee") async def handler(request): return text("Coffee?", status=418)
现在,使用curl查询它,以便您可以看到响应(别忘了-i):
$ curl localhost:7777/coffee -i
标题
HTTP 响应的第二部分与 HTTP 请求的第二部分相同:以key: value格式每行排列的标题。像之前一样,键不区分大小写,可以在响应中重复多次。
有一个有趣的事情需要记住,当一个网络服务器以信息状态(系列 100)响应时,它不包括标题。这些响应通常仅在将 HTTP 连接升级为 websocket 连接的上下文中使用。由于这是框架的责任,我们可以安全地忽略它,并将其作为有用的信息存档。
在 Sanic 中使用标题通常很简单。我们稍后会深入探讨它们,但到目前为止,我们需要记住我们可以简单地传递一个包含值的字典。
-
在任何响应函数中添加一个包含值的
headers参数。这里我们使用empty,因为我们没有发送任何正文响应,只是标题:@app.get("/") async def handler(request): return empty(headers={"the-square-root-of-four": "two"}) -
让我们看看使用 curl 的响应是什么样的,并确保使用
-i以便我们看到标题:$ curl localhost:7777/ -i HTTP/1.1 204 No Content the-square-root-of-four: two connection: keep-alive精明的数学家在审视我的例子时会注意到,我只部分正确。2 并不是唯一的值。我们如何会有重复的标题键?由于 Python 常规字典不允许我们重复键,我们可以使用 Sanic 为我们提供的特殊数据类型来完成这项工作。
-
使用之前相同的响应,插入一个具有两个相同键的
Header对象,如下所示:from sanic.compat import Header @app.get("/") async def handler(request): return empty( headers=Header( [ ("the-square-root-of-four", "positive two"), ("the-square-root-of-four", "negative two"), ] ) ) -
我们希望现在能看到更数学上正确的响应头;相同的键两次,但每次都有不同的值:
$ curl localhost:7777/ -i HTTP/1.1 204 No Content the-square-root-of-four: positive two the-square-root-of-four: negative two connection: keep-alive
响应正文
HTTP 响应的最后一部分是正文。它可以说是我们称之为 HTTP 的整个业务中最重要的一部分。我们可以合理地说,HTTP 响应正文是整个网络驱动的动力:内容的共享。本章的其余部分将重点介绍我们可以在 HTTP 响应正文中结构化数据的一些不同且更流行的方法。无论是 HTML、JSON 还是原始字节,我们即将深入探讨的内容将是每个你构建的 Web 应用程序的基石之一。首先是 HTML 内容,我们将探讨发送静态 HTML 内容和生成动态 HTML 内容的方法。
渲染 HTML 内容
网络的基础是 HTML。它是使浏览器能够工作的媒体,因此,一个网络服务器必须能够传递 HTML 内容是基本的。无论是构建传统的基于页面的应用程序,还是单页应用程序,HTML 传递都是必要的。在第三章,路由和接收 HTTP 请求中,我们讨论了如何将网络请求路由到我们的静态文件。如果你有静态 HTML 文件,那么这是一个很好的选择。但是,如果你需要为你的应用程序生成动态 HTML 呢?
由于有无数种实现方式,我们将查看一些可以使用 Sanic 的一般模式。
交付 HTML 文件
服务器 HTML 内容通常是一个简单的操作。我们需要向客户端发送一个包含 HTML 文本和头部的响应,告诉接收者该文档应被视为 HTML。最终,原始 HTTP 响应将看起来像这样:
HTTP/1.1 200 OK
content-length: 70
connection: keep-alive
content-type: text/html; charset=utf-8
<!DOCTYPE html><html lang="en"><meta charset="UTF-8"><title>Hello</title><div>Hi!</div>
Notice the critical HTTP response header: content-type: text/html; charset=utf-8\. Sanic has a convenient reponse function:
from sanic import html, HTTPResponse
@app.route("/")
async def handler(request) -> HTTPResponse:
return html(
'<!DOCTYPE html><html lang="en"><meta charset="UTF-8"><title>Hello</title><div>Hi!</div>'
)
快速说明,虽然前面的例子可能是有效的 HTML,但接下来的所有例子可能不是。本书的目标不是达到 100% 的 HTML 语义,所以我们可能会打破一些规则。
让我们假设我们现在正在构建一个音乐播放器应用程序。当有人访问我们的网站时,需要发生的第一个事情是登录。如果那个人已经有了一个活跃的会话,我们希望他们去最新内容页面。在第六章和第七章中,我们将探讨如何使用中间件并将其与身份验证集成。现在,我们将假设我们的应用程序已经确定了身份验证和授权。它已经将这些值存储为 request.ctx.user:
@app.route("/")
async def handler(request) -> HTTPResponse:
path = "/path/to/whatsnew.html" if request.ctx.user else "/path/to/login.html"
with open(path, "r") as f:
doc = f.read()
return html(doc)
你注意到一个模式了吗?我们真正需要做的,为了使用 Sanic 生成 HTML 内容,只是基本的字符串构建!所以,如果我们可以用字符串插值将值注入到字符串中,那么我们就有了动态 HTML。这里有一个简单的说明:
@app.route("/<name:str>")
async def handler(request, name: str) -> HTTPResponse:
return html(f"<div>Hi {name}</div>")
这次我们不用 curl,看看在浏览器中是什么样子:

图 5.1 - 混插 HTML 的浏览器截图
HTML 字符串插值只是模板化的一种花哨说法。
基本模板化
在过去,我参加过几次 Python 网络会议。在准备我的演讲时,我寻找能够使生成幻灯片变得超级简单的工具。由于我最舒服的工作环境是文本编辑器,我对将 Markdown 转换为幻灯片的解决方案特别感兴趣。我找到了一个名为 remark.js 的工具。如果你想了解更多关于 remark 的信息:remarkjs.com/
为了从 Markdown 渲染幻灯片,我只需要一个 HTML 文件和一些 Markdown 文本:
<!-- Boilerplate HTML here -->
<textarea id="source">
class: center, middle
# Title
---
# Agenda
1\. Introduction
2\. Deep-dive
3\. ...
---
# Introduction
</textarea>
<script src="https://remarkjs.com/downloads/remark-latest.min.js">
<!-- Boilerplate HTML and here -->
这非常简单,正好是我想要的。然而,有一个问题,因为我的 IDE 不知道 <textarea> 中的文本是 Markdown。因此,我没有语法高亮。真糟糕。
解决方案实际上非常简单。我只需要一种方法将我的 Markdown 注入到 HTML 文件中,并提供服务。
对 HTML 的快速修复:
<!-- Boilerplate HTML here -->
<textarea id="source">
__SLIDES__
</textarea>
<script src="https://remarkjs.com/downloads/remark-latest.min.js">
<!-- Boilerplate HTML and here -->
哇!一个 HTML 模板。现在,让我们渲染它。
from pathlib import Path
PRESENTATION = Path(__file__).parent / "presentation"
@app.get("/")
def index(_):
with open(PRESENTATION / "index.html", "r") as f:
doc = f.read()
with open(PRESENTATION / "slides.md", "r") as f:
slides = f.read()
return html(doc.replace("__SLIDES__", slides))
就这样,我们构建了一个模板引擎。任何模板引擎的基本思想是存在某种协议,用于告诉应用程序如何转换和注入动态内容。Python 通过其多种字符串插值形式来实现这一点。在我的超级简单的解决方案引擎中,我只需要替换__SLIDES__值。我相信你已经开始思考如何构建自己的简单引擎了。
事实上,也许你现在应该尝试一下。这里有一个 HTML 模板:
<p>
Hi, my name is <strong>__NAME__</strong>.
</p>
<p>
I am <em>__AGE__</em> years old.
</p>
现在开始:
def render(template: str, context: Dict[str, Any]) -> str:
...
@app.get("/hello")
async def hello(request) -> HTTPResponse:
return html(
render("hello.html", {"name": "Adam", "age": 38})
)
现在轮到你了,通过构建渲染代理来填写剩余的部分。尝试构建一个可以与任何变量名一起工作的render函数,而不仅仅是name和age。我们希望这个函数能在多个位置重复使用。
使用模板引擎
当然,你不必总是创建自己的模板引擎。已经有很多优秀的选项可供选择。Python 中流行的模板引擎有 Genshi、Mako 和 Jinja2。但请记住,我们真正需要做的只是构建一个字符串。所以任何可以做到这一点的工具都可以工作。这些包可以被视为 Python 格式函数的高级版本。它们接受字符串并将数据注入其中以生成更长的字符串。你选择的任何 Python 模板工具都可以与 Sanic 一起工作。具体到 Jinja2,已经有了一些 Sanic 插件,可以让 Sanic 和 Jinja2 之间的交互变得非常简单。你可以随时查看它们。在基本层面上,使用 Jinja2 进行模板化可以像这样轻量级:
from jinja2 import Template
template = Template("<b>Hello {{name}}</b>")
@app.get("/<name>")
async def handler(request, name):
return html(template.render(name=name))
现在来看看结果:
$ curl localhost:7777/Adam
<b>Hello Adam</b>
为了将我们的模板从 Python 移动到它们自己的 HTML 文件中,我们可以使用 Jinja2 的Environment构造。
-
使用 Jinja2 语法创建一些 HTML。这将保存在
templates目录下的index.html文件中。你可以在 GitHub 仓库中看到使用的结构:<!DOCTYPE html> <html> <head> <title>Adam's Top Songs</title> </head> <body> <h1>Adam's Top Songs</h1> <ul> {% for song in songs %} <li>{{song}}</li> {% endfor %} </ul> </body> </html> -
现在设置
Environment并将其附加到我们的应用程序上下文中,以便在整个应用程序中轻松访问:from pathlib import Path from jinja2.loaders import FileSystemLoader from jinja2 import Environment @app.before_server_start def setup_template_env(app, _): app.ctx.env = Environment( loader=FileSystemLoader(Path(__file__).parent / "templates"), autoescape=True, ) -
最后,在我们的路由处理程序中通过文件名获取模板,并向其中注入一些内容:
@app.get("/") async def handler(request): template = request.app.ctx.env.get_template("index.html") output = template.render( songs=[ "Stairway to Heaven", "Kashmir", "All along the Watchtower", "Black Hole Sun", "Under the Bridge", ] ) return html(output)
所有这些都完成之后,我们应该能够在网页浏览器中访问我们的应用程序并看到渲染的 HTML。
提示
当使用 Sanic 构建时,你可能已经注意到启用
auto_reload是多么方便。每次你点击保存按钮时,应用程序都会重新启动,并立即可供你测试。如果构建 HTML 文件时也能这样那就太好了。有一个叫做livereload的工具可以做到这一点。本质上,它会将一些 JavaScript 注入到你的 HTML 中,使其能够监听刷新页面的命令。在我之前提到的那个幻灯片演示中,我创建了一个 livereload 服务器,这样我可以在编写代码的同时,将浏览器和 IDE 并排打开。每次我点击保存,浏览器都会刷新,我可以在不离开键盘的情况下看到渲染的内容。如果你对这个主题感兴趣,可以查看第十一章。
序列化 JSON 内容
在 HTML 内容旁边,JSON 是网络上传输的最常见的数据格式之一。如果你正在构建单页应用程序(SPA),(也称为渐进式 Web 应用程序或PWA),很可能会让你的后端服务器仅或主要返回 JSON 内容。现代 Web 应用程序的一个常见构建模式是使用 JavaScript 框架构建前端用户界面,并由后端服务器提供动态 JSON 文档。
选择序列化器
Python 标准库当然附带了一个 JSON 包,它使得将 Python 对象序列化为 JSON 字符串(反之亦然)变得非常简单。然而,它并不是最有效的实现。实际上,它相当慢。许多第三方包已经出现,试图解决这个问题。我们将探索两个常与 Sanic 一起使用的常见包。
当谈到响应序列化时,我们关心的是dumps()方法的操作。这些项目中的每一个都提供了一个具有此方法的面板。要选择序列化器,我们需要做的是在两个位置之一设置dumps()方法:在响应级别或应用程序范围内。我们很快就会看到如何做到这一点。
UJSON
UltraJSON(也称为ujson)是一个用 C 编写的 JSON 实现替代方案。由于其注重性能,它被选为 Sanic 的默认 JSON 工具。如果你什么都不做,这就是 Sanic 将使用的包。
它包括一些有用的编码器选项,例如:encode_html_chars、ensure_ascii和escape_forward_slashes。考虑以下示例:
return json(
{
"homepage": request.app.url_for(
"index",
_external=True,
_server="example.com",
)
},
)
当我们访问这个端点时,ujson将默认转义我们的斜杠:
$ curl localhost:7777
{"homepage":"http:\/\/example.com\/index.html"}
我们可以使用functools.partial来改变行为。
dumps = partial(ujson.dumps, escape_forward_slashes=False)
@app.get("/")
async def handler(request):
return json(
{
"homepage": request.app.url_for(
"index",
_external=True,
_server="example.com",
)
},
dumps=dumps,
)
通过使用dumps关键字参数,我们已告诉 Sanic 使用不同的序列化器。结果应该是我们想要的:
$ curl localhost:7777
{"homepage":"http://example.com/index.html"}
如果你不想在你的项目中使用 ujson,那么你可以强制 Sanic 跳过 ujson 的安装:
$ export SANIC_NO_UJSON=true
$ pip install --no-binary :all: sanic
虽然ujson是一个很好的项目,它为 Python 中的 JSON 字符串操作添加了一些急需的性能,但它可能并不是最快的。接下来,我们将看看另一个相对较新的包,它试图将性能带到 JSON 操作中。
ORJSON
游戏中有一个新玩家是 orjson。它是用 Rust 编写的,根据基准测试声称是最快的替代方案。因此,许多人喜欢用 orjson 替换 ujson。
关于 orjson 的一个有趣的事情是它内置了对常见 Python 对象如datetime.datetime和uuid.UUID的序列化支持。由于这些在构建 Web 应用程序时都非常常见,因此无需考虑如何处理这些对象类型是非常方便的。还应该注意的是,标准库和 ujson 返回一个str值,而 orjson 返回一个bytes字符串。
我们可以轻松地告诉 Sanic 在所有地方使用 orjson:
import orjson
app = Sanic(__name__, dumps=orjson.dumps)
序列化自定义对象
在最后两个部分中,你可能已经注意到有两种方法可以覆盖默认的 dumps 方法。第一种是通过更改单个响应:
return json(..., dumps=orjson.dumps)
第二种方法将应用于全局所有路由:
Sanic(..., dumps=orjson.dumps)
随意混合使用特定处理器的方法和全局应用方法,以满足您的应用需求。
我们快速浏览了两个替代包。当然还有其他的。那么,你应该如何决定使用哪个包呢?在决定实现时,通常最大的考虑之一是如何处理自定义的非标量对象。也就是说,我们希望没有明显和内置映射到 JSON 类型(如字符串、整数、浮点数、布尔值、列表和字典)的对象在渲染为 JSON 时如何表现。为了使这一点更清晰,考虑以下示例:
假设我们有一个 Thing。它看起来像这样:
class Thing:
...
data = {"thing": Thing()}
如果我们什么都不做,序列化一个 Thing 对象并不那么直接,JSON 工具通常会抛出一个错误,因为它们不知道如何处理它。在不进行手动干预的情况下,我们可以依赖每个工具的方法来明确提供指令,当遇到 Thing 对象时。我们将考虑每个替代方案,看看我们如何将 Thing 减少到可访问的 JSON 对象。
也许,最简单的是 ujson。除了其性能外,这恰好也是我最喜欢的功能之一。如果一个对象有一个 __json__ 方法,ujson 在将对象转换为 JSON 时会调用它:
class Thing:
def __json__(self):
return json.dumps("something")
ujson.dumps(data)
由于这个功能,当我在一个项目上工作时,我经常做的事情之一是确定一些对象的基本模型,并包括一个 __json__ 方法。但其他工具怎么办呢?
Orjson 允许我们将一个 default function 传递给序列化器。如果它不知道如何渲染一个对象,它将调用这个函数。虽然 ujson 选择在对象/模型上处理这个问题,但 orjson 选择在每个单独的序列化器中处理它。你想要添加的复杂性实际上是没有限制的。由于我是一个喜欢在我的自定义对象上使用 __json__ 方法的粉丝,我们可以像这样使用 orjson 实现相同的功能:
def default(obj):
if hasattr(obj, "__json__"):
return json.loads(obj.__json__())
raise TypeError
orjson.dumps(data, default=default)
如果你经常在响应处理器中重新定义序列化方法,这可能会变得有点重复。相反,也许使用标准库来帮助是有价值的。我们可以创建一个部分函数,其中 default 参数已经填充。
from functools import partial
odumps = partial(orjson.dumps, default=default)
odumps(data)
最繁琐的实现是标准库,它要求你传递一个自定义编码器类。它与 orjson 方法非常相似,尽管需要更多的样板代码。
class CustomEncoder(json.JSONEncoder):
def default(self, obj):
return default(obj)
json.dumps(data, cls=CustomEncoder)
通过查看上述示例,你现在应该能够将 __json__ 方法添加到 CustomEncoder 中。
无论项目是什么,你很可能都会遇到这个问题。有一个标准和一致的方式来处理非标量对象是很重要的。评估你计划如何构建,并寻找有意义的模式。我通常发现这比原始性能更重要。从一个包到下一个包的性能增量变化可能不会像基于你的应用程序如何构建和维护所做的决策那样有影响。例如,如果你需要渲染一个大于 64 位的整数呢?ujson 和 orjson 都有局限性,它们会引发异常,无法处理你的数据。然而,标准库实现确实有这个能力。正如我们一开始所说的,做出最符合你需求的正确决策。但是,让我们转向一些常见的做法,看看我们能学到什么。
最佳实践
对于典型的 JSON 响应格式,有许多常见的做法。当然,内容和你的组织结构将由你的应用程序需求决定。但是,你会在开发者论坛上经常看到的一个常见问题是:“我应该如何格式化对象数组?”
回到我们之前的例子,让我们假设我们仍在构建我们的音乐应用。我们现在想要构建一个端点,列出所有可用的歌曲。每个单独的歌曲“对象”看起来可能像这样:
{
"name": "Kashmir",
"song_uid": "75b723e3-1132-4f73-931b-78bbaf2a7c04",
"released": "1975-02-24",
"runtime": 581
}
我们应该如何组织歌曲数组?有两种观点:只使用顶级对象,使用最适合你数据结构的结构。我们讨论的是以下两种结构之间的区别:
{
"songs": [
{...},
{...}
]
}
和:
[
{...},
{...}
]
为什么会有这样的争论?为什么有些人严格只使用顶级对象?2006 年,浏览器中发现了 JSON 的一个安全漏洞,允许攻击者根据第二种选择,顶级 JSON 数组执行代码。因此,许多人建议使用第一种结构更安全。
虽然这个问题现在已经不再存在,因为受影响的浏览器已经过时,但我仍然喜欢顶级对象模式。它仍然提供了第二个选项所没有的一个关键好处:灵活性而不牺牲兼容性。
如果我们的对象数组嵌套在顶级对象中,那么我们可以在未来轻松地修改我们的端点,添加新的键到顶级,而不会影响使用该端点的人。我喜欢包含的一个模式是有一个meta对象,它包含查询的一些细节,并包含分页信息。
{
"meta": {
"search_term": "Led Zeppelin",
"results": 74,
"limit": 2,
"offset": 0
},
"songs": [
{...},
{...}
]
}
因此,我建议,当有选择时,你应该像这样嵌套你的对象。有些人也喜欢嵌套单个对象:
{
"song": {
"name": "Kashmir",
"song_uid": "75b723e3-1132-4f73-931b-78bbaf2a7c04",
"released": "1975-02-24",
"runtime": 581
}
}
有人说,同样的原则也适用。如果对象是嵌套的,端点更容易扩展。然而,当处理单个对象时,这个论点似乎不太有说服力且不实用。通常,端点的任何变化都会与对象本身相关。因此,这可能是一个版本化的用例,我们在第三章中探讨了这一点。
无论你决定如何结构化数据,以 JSON 格式发送我们歌曲的信息仍然只是由构建的应用程序约束所决定的架构决策。现在我们想要进行下一步:实际上发送歌曲本身。让我们看看我们如何做到这一点。
流式数据
在第四章介绍流的概念时,我说请求流可能是两种类型中不太受欢迎的一种。我没有任何经验数据来证实这一点,但对我来说,当大多数人听到“流”这个词——无论他们是开发者还是普通人——其含义通常是“从云端”消费某种形式的媒体。
在本节中,我们想要了解我们如何实现这一点。这究竟是如何工作的?当构建流式响应时,Sanic 会添加与流式请求中看到的相同的 Transfer Encoding: chunked 标头。这是向客户端的指示,表明服务器即将发送不完整的数据。因此,它应该保持连接打开。
一旦发生这种情况,服务器就有权自行发送数据。数据块是什么?它遵循一个协议,其中服务器发送它即将发送的字节数(十六进制格式),然后是一个 \r\n 换行符,接着是一些字节,再是一个 \r\n 换行符:
1a\r\n
Now I'm free, free-falling\r\n"
当服务器完成时,它需要发送一个长度为 0 的数据块:
0\r\n
\r\n
如你所能猜到的,Sanic 将负责设置标头、确定数据块大小和添加适当的换行符的大部分工作。我们的工作是控制业务逻辑。让我们看看一个超级简单的实现是什么样的,然后我们可以从这里开始构建:
@app.get("/")
async def handler(request: Request):
resp = await request.respond()
await resp.send(b"Now I'm free, free-falling")
await resp.eof()
当我们消费流式请求时,我们需要使用 stream 关键字参数或装饰器。对于响应,最简单的方法是提前生成响应:resp = await request.respond()。在我们的例子中,resp 是一个 <class 'sanic.response.HTTPResponse'> 类型的对象。
一旦我们有了响应对象,我们就可以随时以任何方式写入它,无论是使用常规字符串 ("hello") 还是字节字符串 (b"hello")。当没有更多数据要传输时,我们使用 resp.eof() 告诉客户端,然后我们就完成了。
这种随意发送数据的异步行为确实引发了一个关于请求生命周期的有趣问题。由于我们稍微超前了一些,如果你想看看中间件如何处理流式响应,现在就跳转到第六章。
如您从我们的简单示例中可能想象到的,由于我们有 resp.send() 方法可用,我们现在可以自由地按需执行异步调用。当然,为了说明我们的观点,添加一个带有时间延迟的循环可能是一个愚蠢的例子:
@app.get("/")
async def handler(request: Request):
resp = await request.respond()
for _ in range(4):
await resp.send(b"Now I'm free, free-falling")
await asyncio.sleep(1)
await resp.eof()
在下一节中,我们将看到一个更有用且更复杂的示例,当我们开始发送服务器发送事件(SSE)时。但首先,让我们回到我们的目标。我们想要发送实际的歌。不仅仅是元数据,不仅仅是歌词:实际的音频文件,这样我们就可以通过我们的网络应用程序来听它。
文件流
做这件事最简单的方法是使用 file_stream 便利包装器。这个方法为我们处理所有工作。它将异步读取文件内容,以块的形式向客户端发送数据,并封装响应。
from sanic.response import file_stream
@app.route("/herecomesthesun")
async def handler(request):
return file_stream("/path/to/herecomesthesun.mp4")
现在是时候打开浏览器,调高音量,访问我们的网页,并享受了。
好吧,所以依赖浏览器作为我们的媒体播放器可能不是最好的用户界面。如果我们想在我们的前端嵌入歌曲内容并拥有一个实际的播放器用户界面呢?显然,HTML 和设计超出了本书的范围。但你可以至少使用以下方法开始:
<audio controls src="http://localhost:7777/herecomesthesun" />
Sanic 使用此方法默认发送 4096 字节的块。你可能希望增加或减少这个数字:
return file_stream("/path/to/herecomesthesun.mp4", chunk_size=8192)
还值得一提的是,Sanic 在幕后做一些工作,试图确定你发送的是哪种类型的文件。这样它就可以正确设置内容类型头。如果它无法确定,那么它将回退到 text/plain。Sanic 将查看文件扩展名,并尝试与操作系统的 MIME 类型定义相匹配。
服务器发送事件用于推送通信
现在我们知道我们可以控制从服务器来的信息流,我们正在进入能够为我们的网络应用构建一些伟大功能的地盘。
在过去,当我们的应用程序想要检查某物的状态时,它需要通过反复发送相同的请求来轮询网络服务器。我们讨论了构建音乐网络应用程序。我们看到了如何显示内容,获取信息,甚至流式传输一些内容来听音乐。下一步当然是使应用程序变得社交,因为我们当然想与我们的朋友分享我们的音乐。我们希望添加一个功能,可以列出谁在线,以及他们正在听的歌曲的名称。不断刷新页面可以工作,但体验不好。通过反复发送相同的请求不断轮询也行得通,但这会消耗资源,体验也不佳。
更好的是,如果我们的服务器在有人上线或他们的音乐播放器发生变化时简单地通知浏览器,这将是什么样子。这就是服务器发送事件(SSE)提供的:一套非常简单的指令,让我们的服务器向浏览器发送推送通知。
SSE 协议的基本单位是事件,它是一行文本,包含一个字段和一些正文:
data: foo
在这种情况下,字段是data,正文是foo。一条消息可以由一个或多个事件组成,这些事件由单个换行符分隔:\n。以下是一个示例:
data: foo
data: bar
当浏览器接收到这条消息时,它将被解码为:foo\nbar。服务器应通过发送两个(2)换行符来终止消息:\n\n。
SSE 协议有五个基本字段:
| 字段 | 描述 | 示例 |
|---|---|---|
<null> |
应被视为注释 | : 这是一条注释 |
event |
发送事件的类型描述 | event: songplaying |
data |
消息的主体,通常是纯文本或 JSON | data: All Along the Watchtower |
id |
用于跟踪的自创建事件 ID | id: 123456ABC |
retry |
重新连接时间(毫秒) | retry: 1000 |
图 5.2 - 允许的 SSE 字段概述
从基础知识开始
在深入探讨如何从 Sanic 实现 SSE 之前,我们需要一个可以理解如何处理这些事件的前端应用程序。我们不太关心如何构建 SSE 客户端。你可以在 GitHub 仓库中找到一个预构建的前端 HTML 客户端:…只需获取代码来跟随。
为了向客户交付,我们将把该 HTML 存储为index.html,并使用我们已知的现有工具来提供该文件。为了确保我们覆盖空白根路径(/),我们还将将其重定向到我们的index.html:
app.static("/index.html", "./index.html", name="index")
@app.route("/")
def home(request: Request):
return redirect(request.app.url_for("index"))
现在我们有一个简单的客户端,让我们构建一个与之匹配的简单服务器:
@app.get("/sse")
async def simple_sse(request: Request):
headers = {"Cache-Control": "no-cache"}
resp = await request.respond(
headers=headers,
content_type="text/event-stream"
)
await resp.send("data: hello\n\n")
await asyncio.sleep(1)
await resp.send("event: bye\ndata: goodbye\n\n")
await resp.eof()
大部分内容应该看起来很熟悉。我们之前已经看到我们可以如何控制向客户端发送的数据块,这里我们只是以更结构化的模式来做。当然,然而,这个超级简单的概念证明远远不是一个功能完整的构建,可以用于我们的音乐应用。让我们看看我们如何通过创建一个迷你框架来使其变得更好。
构建一些 SSE 对象
为了创建我们的 SSE 框架,我们将首先构建一些基本对象来帮助我们创建消息。SSE 消息确实非常简单,所以这可能有点过度。另一方面,确保我们适当地使用换行符和字段名听起来像是一场灾难。因此,在开始时采取一些深思熟虑的步骤对我们来说将大有裨益。
我们首先将构建一些用于创建正确格式化字段的对象:
class BaseField(str):
name: str
def __str__(self) -> str:
return f"{self.name}: {super().__str__()}\n"
class Event(BaseField):
name = "event"
class Data(BaseField):
name = "data"
class ID(BaseField):
name = "id"
class Retry(BaseField):
name = "retry"
class Heartbeat(BaseField):
name = ""
注意我们是如何从str开始继承的。这将使我们的对象作为字符串操作,只是涉及一些自动格式化:
>>> print(Event("foo"))
event: foo
接下来,我们需要一种方便的方式来组合字段,形成一个单一的消息。它还需要有适当的字符串格式化,这意味着在末尾添加额外的\n:
def message(*fields: BaseField):
return "".join(map(str, fields)) + "\n"
现在,看着这个,我们应该看到一个正确格式化的 SSE 消息:
>>> print(f"{message(Event('foo'), Data('thing'))}".encode())
b'event: foo\ndata: thing\n\n'
下一步是尝试我们的新构建模块,看看它们是否按预期向前端发送消息:
@app.get("/sse")
async def simple_sse(request: Request):
headers = {"Cache-Control": "no-cache"}
resp = await request.respond(headers=headers, content_type="text/event-stream")
await resp.send(message(Data("hello!")))
for i in range(4):
await resp.send(message(Data(f"{i=}")))
await asyncio.sleep(1)
await resp.send(message(Event("bye"), Data("goodbye!")))
await resp.eof()
让我们暂停一下,回顾一下我们试图实现的目标。目标是当某个事件发生时向浏览器发送通知。到目前为止,我们已经确定了两个事件:另一个用户登录(或退出)系统,以及用户开始(或停止)听歌。当这些事件之一被触发时,我们的流应该向浏览器广播通知。为了实现这一点,我们将构建一个 pubsub。
Pubsub 是一种设计范式,其中有两个参与者:发布者和订阅者。发布者的任务是发送消息,订阅者的任务是监听消息。在我们的场景中,我们希望流成为订阅者。它将监听传入的消息,并在收到消息时知道它应该调度 SSE。
由于我们仍在确定我们的通知系统应该如何工作,我们将保持其简单。我们的 pubsub 机制将是一个简单的asyncio.Queue。消息可以进来,消息也可以被消费。需要注意的是,这种设计模式将是有限的。记得一开始我们决定要使用两个工作进程来运行我们的开发服务器吗?这样做的原因是考虑到水平扩展。我们即将要做的事情将绝对破坏这一点,并且在一个分布式系统中将无法工作。因此,为了使这个系统能够投入生产,我们需要一个新的计划来跨集群分发消息。我们将在本书的第十一章的完整示例中稍后实现这一点。
-
首先,我们需要设置一个单独的队列。我们将通过一个监听器来完成这项工作:
@app.after_server_start async def setup_notification_queue(app: Sanic, _): app.ctx.notification_queue = asyncio.Queue()现在,当我们的应用程序启动时,无论我们在哪里可以访问应用程序实例,我们也可以访问通知队列。
重要提示
当我们开始构建更复杂的应用程序时,我们将看到更多
ctx对象的使用。这些是 Sanic 为我们——开发者——提供的方便位置,我们可以根据需要对其进行操作。这是一个“东西”的存储位置。Sanic 几乎从不直接使用它们。因此,我们可以在这个对象上设置任何我们想要的属性。 -
接下来,我们将创建我们的订阅者。这个实例将监听队列,并在找到队列中尚未调度的消息时发送消息:
class Notifier: def __init__( self, send: Callable[..., Coroutine[None, None, None]], queue: asyncio.Queue, ): self.send = send self.queue = queue async def run(self): await self.send(message(Heartbeat())) while True: fields = await self.queue.get() if fields: if not isinstance(fields, (list, tuple)): fields = [fields] await self.send(message(*fields))如您所见,我们的
run操作由一个无限循环组成。在这个循环内部,Notifier将暂停并等待队列中有东西。当有东西时,它会从队列中移除项目并继续循环的当前迭代,即发送该项目。但是,在我们循环之前,我们将发送一个单一的心跳消息。这将清除任何启动事件,以便我们的客户端清除其自己的队列。这不是必需的,但我认为这是一种有用的实践。 -
在我们的端点中实现这一点,看起来是这样的:
@app.get("/sse") async def simple_sse(request: Request): headers = {"Cache-Control": "no-cache"} resp = await request.respond( headers=headers, content_type="text/event-stream" ) notifier = Notifier(resp.send, request.app.ctx.notification_queue) await notifier.run() await resp.eof()
使用request.respond创建响应对象。然后,我们创建Notifier并让它运行。最后,调用eof来关闭连接。
重要提示
完全坦白地说,之前的代码示例有些缺陷。我故意让它保持简单,以便说明问题。然而,由于没有方法跳出无限循环,服务器实际上根本无法自己关闭连接。这使得包含
eof变得有些多余。虽然作为一个例子它很好,但按照这样的代码编写,它只会被客户端通过导航离开端点来停止。
-
现在应该很容易将消息推入队列。我们可以在一个单独的端点上这样做:
@app.post("login") async def login(request: Request): request.app.ctx.notification_queue.put_nowait( [Event("login"), Data("So-and-so just logged in")] ) return text("Logged in. Imagine we did something here.") -
我们现在可以测试一下!打开你的浏览器,访问:
http://localhost:7777/index.html你应该会看到类似这样的内容:![图 5.3 - 测试 SSE 的 HTML 截图]()
图 5.3 - 测试 SSE 的 HTML 截图
-
一旦我们通过点击按钮“开始”流,切换回终端,我们将访问我们的假登录端点:
$ curl localhost:7777/login -X POST Logged in. Imagine we did something here.
你在浏览器中看到了什么?继续做,随意玩这个例子,以了解不同的组件是如何工作的。
你认为你能构建一个假的“开始播放音乐”端点吗?只需确保如果你要使用Event,你的前端应用程序通过使用eventSource.addEventListener知道它。看看index.html,你会看到login事件。我建议你暂停一下,花点时间深入研究这段代码,看看不同的组件是如何协同工作以促进数据交换的。你能用这个构建出什么样的奇妙事物?
在第六章中,我们将回到这个相同的例子,并看看我们如何通过使用信号来实现它。我还应该指出,在这个例子中使用asyncio.Queue还有一个缺点:它实际上只能在单个浏览器中工作。由于我们的消费者(Notifier)会清空队列,当多个浏览器同时运行时会发生什么?嗯,只有第一个会收到消息。再次强调,这个解决方案对于实际应用来说过于简单,但希望它已经激发了您如何构建更健壮系统的想法。完全透明地说,在这种情况下,我真的很喜欢回退到 Redis。如果你熟悉 Redis,你可能知道它内置了 pubsub。通过合适的 Python 库接口,你可以轻松解决asyncio.Queue实现给我们带来的两个问题:它可以用来一次性推送消息给多个订阅者,并且可以在分布式系统中使用,其中多个发布者向它推送消息。也许在到达第十一章之前,你想看看是否能在我们当前的例子中让它工作?
如果没有其他的话,我希望你在浏览器中看到消息弹出时感到兴奋。对我来说,看到消息被推送到浏览器会话中仍然非常有趣和有趣。SSE 是一个超级简单的解决方案,可以解决一些可能很复杂的问题,最终导致强大的功能集。能够将数据推送到网络浏览器确实有助于使应用程序感觉像是从网页转变为网络应用程序。
这种实现的缺点是它们仍然只是单向的。要实现双向异步通信,我们需要使用 WebSocket。
用于双向通信的 WebSocket
你几乎肯定在你的最爱网络应用程序中体验过 WebSocket。这是一个帮助创建超级丰富用户体验的工具,可以在各种环境中使用。虽然 SSE 本质上只是一个尚未终止的开放流,但 WebSocket 是完全不同的东西。
纯粹的 HTTP 只是一个规范(或协议),用于描述如何在机器之间的 TCP 连接上格式化和传输消息。WebSocket 是一个独立的协议,其中包含了关于消息应该如何格式化、发送、接收等的详细说明。这个规范相当复杂,我们可能需要用整整一本书来讨论 WebSocket。因此,我们将简单地关注 Sanic 中的实现。关于 WebSocket 的一个值得注意的技术细节是,它们的生命周期始于一个普通的 HTTP 连接。请求到来并请求服务器升级其连接为 WebSocket。然后,客户端和服务器进行一些互动,以澄清细节。当谈判完成时,我们有一个开放的套接字,可以传递消息。想象它就像是一条双车道的高速公路,消息可以在前往两端的过程中相互通过。
想象 WebSocket 最简单的方法可能是将其视为一个聊天应用程序。我们在后端服务器上有一个单一的端点。两个独立的网络浏览器连接到服务器,并且它们以某种方式连接起来,以便当一个浏览器推送一条消息时,服务器会将该消息推送到另一侧。这样,两个客户端都能够发送和接收消息,而不管其他事情如何。
这是真正的异步网络行为。Sanic 使用async/await来利用和优化服务器效率以实现性能。但,副作用是它还允许 Sanic 提供一个几乎无需努力的机制来实现 WebSocket。虽然我们不会深入探讨它是如何工作的,你应该知道 Sanic 在底层使用了 Python 的websockets包。这是一个出色的项目,当你在 Sanic 中构建自己的 WebSocket 端点时,查看他们的文档可能会有所帮助:websockets.readthedocs.io。
在上一节中,我们开始使我们的音乐播放器应用变得社交,通过分享谁已登录的信息。让我们通过添加聊天功能来增强社交性。我们的目标是让两个浏览器并排打开,并能够相互传递文本消息。就像 SSE 示例一样,您可以从 GitHub 仓库中获取前端代码,这样我们就不必担心那些实现细节。github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/05/websockets
-
我们首先要创建的是一个
Client。当有人进入应用时,前端会立即打开 websocket。Client将是我们跟踪谁在应用中,以及如何向他们发送消息的存放地。因此我们需要一个唯一的标识符,以及发送消息的可调用函数:class Client: def __init__(self, send) -> None: self.uid = uuid4() self.send = send def __hash__(self) -> int: return self.uid.int如您所见,我们将通过为每个客户端分配一个
UUID来跟踪进入的会话。 -
在
websocket处理程序内部实例化这个Client对象。@app.websocket("/chat") async def feed(request, ws): client = Client(ws.send) -
接下来,我们需要创建我们的
ChatRoom。这将是一个在整个应用生命周期中存在的全局实例。它的作用是跟踪所有进入或离开的客户端。当有人尝试发送消息时,它将负责将消息发布给其他客户端。就像我们的 SSE 示例一样,我即将展示的实现是有限的,因为它不能在分布式集群上运行。它将在单个实例上运行得很好。这是因为我们在内存中的单个实例上注册了客户端。为了构建一个更可扩展的应用程序,用于生产环境,我们应该使用类似 Redis 或 RabbitMQ 的东西来在多个 Sanic 实例之间分发消息。现在,这就可以了:class ChatRoom: def __init__(self) -> None: self.clients: Set[Client] = set() def enter(self, client: Client): self.clients.add(client) def exit(self, client: Client): self.clients.remove(client) async def push(self, message: str, sender: UUID): recipients = (client for client in self.clients if client.uid != sender) await asyncio.gather(*[client.send(message) for client in recipients])存在一种机制可以添加和删除客户端,以及一种方法可以向已注册的客户端推送事件。在这里需要指出的一点是,我们不希望将消息发送回发送者。那会显得有些尴尬,并且对用户来说,不断收到自己的消息会有些烦恼。因此,我们将过滤掉发送客户端。
-
记住,
ChatRoom实例是一个在整个应用实例生命周期中存在的单一对象。那么,你认为它应该在何处实例化呢?没错,一个监听器!@app.before_server_start async def setup_chatroom(app, _): app.ctx.chatroom = ChatRoom() -
现在我们只需要将其连接起来!
@app.websocket("/chat") async def feed(request, ws): try: client = Client(ws.send) request.app.ctx.chatroom.enter(client) while True: message = await ws.recv() if not message: break await request.app.ctx.chatroom.push(message, client.uid) finally: request.app.ctx.chatroom.exit(client)
当用户进入时,我们将他们添加到聊天室。然后它进入一个无限循环并等待接收消息。这与我们在上一节中看到的 SSE 实现非常相似。当当前 websocket 收到消息时,它会被传递给负责将其发送给所有已注册客户端的ChatRoom对象。看起来太简单了,对吧?!现在来测试一下。打开两个并排的浏览器,并开始添加消息。享受和自己聊天的乐趣!

图 5.4 - 两个并排的 HTML websocket 应用程序的截图:我在和自己说话
运行上述 websocket HTML 应用程序(以及我们刚刚查看的后端聊天室代码)所需的代码可在 GitHub 上找到:______。
设置响应头部和 Cookies
我们已经讨论了很多关于头部的内容。在构建 Web 应用程序时,它们非常重要,通常是应用程序设计的基本部分。当构建您的应用程序响应时,您可能会找到添加处理程序到响应对象的原因。这可能出于安全目的,如 CORS 头部或内容安全策略,或者出于信息跟踪目的。当然,还有 cookies,它们是它们自己的特殊类型的头部,在 Sanic 中会得到特殊处理。
您可能已经注意到,在早期的一些示例(如 SSE 示例)中,我们实际上设置了头部。这是一个如此简单直观的过程,也许您甚至没有注意到。每次我们构建一个响应对象时,我们只需要传递一个包含键/值对的字典:
text("some message", headers={
"X-Foobar": "Hello"
})
这就是全部内容!请记住,您并不总是需要设置自己的头部。Sanic 为您处理了一些,包括:
-
content-length -
content-type -
connection -
transfer-encoding
使用请求 ID 进行响应
一个特别有帮助的模式是在每个响应上设置一个x-request-id头部。如果习惯于在日志记录或通过应用程序跟踪请求时使用request.id,那么在不可避免地需要调试某些内容时,跟踪发生的事情会变得更容易。
我们想要确保我们的响应包括以下头部:
@app.route("/")
async def handler(request):
...
return text("...", headers={"x-request-id": request.id})
这只是一个简单的例子。您可能已经意识到,如果我们想在所有请求上都这样做,这个简单的例子可能会变得乏味。您想要尝试为所有响应添加这个解决方案吗?装饰器和中间件又是潜在的工具。我们将回到这个问题,您将在第十一章的完整示例中看到一些全局设置此头部的实现。
要真正使这有用,我们应该设置我们的日志记录以包括请求 ID。我们还没有过多地谈论它,但 Sanic 为你提供了一个默认的日志记录器。你可能需要在其基础上进行扩展,但为了覆盖默认的日志格式以包括那个请求 ID。如果你想了解更多关于如何设置它的信息,请跳转到 第六章。
设置响应 cookie
你可以在单个响应上设置的最重要类型之一就是 cookie 标头。由于它们非常突出,并且设置它们可能包含一些复杂性,你可以避免直接使用 Set-Cookie。
响应 cookie 实际上是一个键/值对,它在响应中被连接成一个字符串,然后由浏览器解释。这是网络对话中的另一种共享语言。因此,虽然单个 cookie 可能像 flavor=chocolatechip 这样简单,但这种共享语言允许我们在简单示例之上设置大量的元数据。
在我们进入元数据之前,让我们看看一个简单的例子:
@app.get("/ilikecookies")
async def cookie_setter(request):
resp = text("Yum!")
resp.cookies["flavor"] = "chocolatechip"
return resp
这看起来相当直接。让我们看看它对我们的响应做了什么:
$ curl localhost:7777/ilikecookies -i
HTTP/1.1 200 OK
Set-Cookie: flavor=chocolatechip; Path=/
content-length: 4
connection: keep-alive
content-type: text/plain; charset=utf-8
Yum!
我们现在的响应头有:Set-Cookie: flavor=chocolatechip; Path=/. 那么,那个 Path 是怎么回事?这是 cookie 元数据在起作用。以下是我们可以添加的一些各种元值:
expires: datetime |
浏览器应在何时丢弃 cookie |
|---|---|
path: str |
cookie 将应用到的路径 |
comment: str |
注释 |
domain: str |
cookie 将应用到的域名 |
max-age: str |
浏览器应在多少秒后将 cookie 丢弃 |
secure: bool |
是否应该仅通过 HTTPS 发送 |
httponly: bool |
是否可以被 JavaScript 访问 |
samesite: str |
它可以从哪里发送,值可以是:lax、strict、none |
表 5.2 - Cookie 元字段
当我们设置我们的 flavor cookie 时,它看起来就像我们只是在看起来像这样的字典中添加一个字符串值:
{
"flavor": "chocolatechip"
}
这并不是真的情况。response.cookies 对象实际上是一个 CookieJar 对象,它本身是一种特殊的 dict 类型。当我们在这个 CookieJar 上设置新的键/值时,实际上是在创建一个 Cookie 对象。嗯?
当我们这样做时:
resp.cookies["flavor"] = "chocolatechip"
这更像是你正在创建一个 Cookie("flavor", "chocolatechip"),然后将其放入 CookieJar() 中。为了清理与这些实例管理相关的复杂性,Sanic 允许我们仅使用字符串。当我们设置元数据时,我们应该记住这一点,这就是我们接下来要做的。
让我们假设我们有一个应该超时的 cookie。经过一段时间后,我们希望浏览器会话忘记它的存在。例如,这对于会话 cookie 可能很有用。我们设置一些值来标识与特定用户的浏览器会话。将其存储在 cookie 中意味着在后续请求中我们可以识别出这个人是谁。然而,通过设置最大存活时间,我们可以控制这个人在需要再次登录之前可以使用应用程序的时间长度。
resp.cookies["session"] = "somesessiontoken"
resp.cookies["session"]["max-age"] = 3600
这同样适用于所有其他元字段:
resp.cookies["session"]["secure"] = True
resp.cookies["session"]["httponly"] = True
resp.cookies["session"]["samesite"] = "Strict"
如果我们将所有这些结合起来,我们的 cookie 头部最终将看起来像这样:
Set-Cookie: flavor=chocolatechip; Path=/
Set-Cookie: session=somesessiontoken; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Strict
我们最后应该考虑的是如何删除 cookies。当你想要删除一个 cookie 时,可能会倾向于像对待任何其他字典对象一样使用 del。问题是这只能做到这一步。通常,你想要做的是告诉浏览器它需要删除 cookie,这样浏览器就不会在未来的请求中发送它。实现这一点的最简单方法是将 cookie 的最大存活时间设置为 0。
resp.cookies["session"]["max-age"] = 0
你现在应该感到舒适地添加和删除响应中的 cookies。这可能是一个创建响应处理器并使用浏览器 cookie 检查工具查看如何从服务器设置、操作和删除 cookies 的好机会。
摘要
现在我们已经看到了如何操作请求和响应,我们完全有可能构建一些真正强大的应用程序。无论是构建基于 HTML 的网站、由 JSON 驱动的 Web API、流媒体内容应用程序,还是它们的组合,Sanic 都为我们提供了所需的工具。
我们首先讨论的第一件事是 Sanic 努力不阻碍应用程序的构建。作为开发者,我们有自由使用不同的工具,并将它们组合起来构建一个真正独特的平台。当我们意识到开发者获得的响应对象所赋予的自由时,这一点尤为明显。需要直接写入字节?当然可以。想要使用特定的模板引擎?没问题。
现在我们已经对如何处理 HTTP 连接的生命周期有了基本理解,从请求到响应,我们可以开始看到我们还有哪些可以利用的资源。在下一章中,我们将更深入地探讨一些已经介绍的概念,如中间件、后台任务和信号。结合这些基本构建块将帮助我们构建不仅功能强大,而且易于维护、更新和扩展的应用程序。
第七章:6 在请求处理器之外操作
在 Sanic 中,应用程序开发的基本构建块是请求处理器,有时也称为“路由处理器”。这些术语可以互换使用,意思相同。这是当请求被路由到你的应用程序进行处理和响应时 Sanic 运行的函数。这是业务逻辑和 HTTP 逻辑结合的地方,允许开发者规定如何将响应发送回客户端。这是学习如何使用 Sanic 构建时的明显起点。
然而,仅请求处理器本身并不能提供足够的强大功能来创建一个精致的应用程序体验。为了构建一个精致且专业的应用程序,我们必须跳出处理器,看看 Sanic 还能提供哪些其他工具。现在是时候考虑 HTTP 请求/响应周期不再局限于单个函数了。我们将扩大我们的范围,使得响应请求不再仅仅是处理器的责任,而是整个应用程序的责任。当我们瞥见中间件时,我们已经尝到了这种味道。
在本章中,我们将介绍以下主题:
-
利用 ctx
-
使用中间件修改请求和响应
-
利用信号进行工作内通信
-
掌握 HTTP 连接
-
实现异常处理
-
背景任务处理
当然,并非所有项目都需要这些功能,但当它们被用在正确的地方时,它们可以非常强大。您是否曾经在家中的 DIY 项目中工作,但并没有找到合适的工具?当您需要一字螺丝刀,但只有平头螺丝刀时,这可能会非常令人沮丧且效率低下。没有合适的工具会使任务变得更难,有时也会降低您能完成的工作质量。
想象一下我们在本章中探索的功能就像工具。你可能听说过这样一句俗语:“如果你手里拿着锤子,那么每个问题看起来都像钉子。”幸运的是,我们有一系列工具,我们的工作现在就是学习如何使用它们。我们即将探索 Sanic 工具包,看看我们能解决哪些问题。
技术要求
在本章中,您应该拥有与之前章节相同的工具,以便能够跟随示例(IDE、现代 Python 和 curl)。
您可以在 GitHub 上找到本章的源代码:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/06。
利用 ctx
在我们开始工具包之前,还有一个概念我们必须熟悉。它在 Sanic 中相当普遍,你会在很多地方看到它。我说的就是:ctx。那是什么?
它代表上下文。这些ctx对象可以在多个地方找到,不利用它们来构建是不切实际的。它们允许将状态从应用程序的一个位置传递到另一个位置。它们是为了开发者自己的使用而存在的,你应该自由地按照自己的意愿使用它们。也就是说,ctx对象是你的,你可以添加信息而不用担心名称冲突或以其他方式影响 Sanic 的操作。
最常见的例子是数据库连接对象。你只创建一次,但你想在许多地方访问它。这是怎么做到的?
@app.before_server_start
async def setup_db(app, loop):
app.ctx.db = await setup_my_db()
现在,你可以在任何可以访问应用实例的地方访问数据库实例。例如,你可以在某个函数内部访问它:
from sanic import Sanic
async def some_function_somewhere():
app = Sanic.get_app()
await app.ctx.db.execute(...)
Or, perhaps you need it in your route handler:
bp = Blueprint("auth")
@bp.post("/login")
async def login(request: Request):
session_id = await request.app.ctx.db.execute(...)
...
这里列出了所有具有ctx对象的位置:
| 对象 | 描述 | 示例 |
|---|---|---|
| Sanic | 在你的工作实例整个生命周期内可用。它是针对工作实例特定的,这意味着如果你运行多个工作实例,它们将不会保持同步。最适合用于连接管理,或其他需要在应用实例整个生命周期内可用的东西。 | app.ctx |
| 蓝图 | 在蓝图实例存在期间可用。这可能有助于你有一些特定的数据需要在整个工作实例生命周期内可用,但你又想控制它对特定蓝图附加内容的访问。 | bp.ctx |
| 请求 | 在单个 HTTP 请求期间可用。对于在中间件中添加详细信息并在处理程序或其他中间件中使其可用很有帮助。常见用途包括会话 ID 和用户实例。 | request.ctx |
| ConnInfo | 在整个 HTTP 连接期间(可能包括多个请求)可用。如果你使用代理,请特别小心。通常不应用于敏感信息。 | request.conn_info.ctx |
| 路由 | 在路由和信号实例上可用。这是 Sanic 实际上在 ctx 对象上存储一些细节的唯一例外。 | request.route.ctx |
表 6.1 - 使用ctx对象的 Sanic 特性
我们将经常回到ctx对象。它们在 Sanic 中是一个非常重要的概念,允许传递任意的数据和对象。它们并不完全相同,你可能会发现自己比其他任何对象更频繁地使用app.ctx和request.ctx。
现在我们有了这个基本构建块,我们将看到这些对象是如何在应用程序中传递的。在下一节关于中间件的部分,我们将看到请求对象——因此也是request.ctx——如何在应用程序的多个地方被访问。
使用中间件修改请求和响应
如果你一直跟随这本书到现在,中间件的概念应该是熟悉的。这是你应该熟悉的第一件工具。
中间件是可以在路由处理器前后运行的代码片段。中间件有两种类型:请求和响应。
请求中间件
请求中间件按照声明的顺序执行,在路由处理器之前。
@app.on_request
async def one(request):
print("one")
@app.on_request
async def two(request):
print("two")
@app.get("/")
async def handler(request):
print("three")
return text("done")
当我们尝试达到这个终点时,我们应该在终端看到以下内容:
one
two
three
(sanic.access)[INFO][127.0.0.1:47194]: GET http://localhost:7777/ 200 4
但是,这只能讲述故事的一部分。有时我们可能需要添加一些额外的逻辑来仅针对我们应用程序的部分。让我们假设我们正在构建一个电子商务应用程序。像其他在线商店一样,我们需要构建一个购物车来存放将要购买的产品。为了我们的示例,我们将想象当用户登录时,我们在数据库中创建购物车,并将其引用存储在 cookie 中。类似于这样:
@app.post("/login")
async def login(request):
user = await do_some_fancy_login_stuff(request)
cart = await generate_shopping_cart(request)
response = text(f"Hello {user.name}")
response.cookies["cart"] = cart.uid
return response
不要过于纠结于这里的细节。重点是,在每次后续请求中,都将会有一个名为 cart 的 cookie,我们可以用它从我们的数据库中获取数据。
现在,假设我们希望我们的/cart路径上的所有端点都能访问购物车。我们可能有添加项目、删除项目、更改数量等端点。然而,我们始终需要访问购物车。而不是在每个处理器中重复逻辑,我们可以在蓝图上做一次。将中间件添加到单个蓝图上的所有路由看起来和功能与应用范围中间件相似。
bp = Blueprint("ShoppingCart", url_prefix="/cart")
@bp.on_request
async def fetch_cart(request):
cart_id = request.cookies.get("cart")
request.ctx.cart = await fetch_shopping_cart(cart_id)
@bp.get("/")
async def get_cart(request):
print(request.ctx.cart)
...
正如我们所期望的,每个附加到ShoppingCart蓝图上的端点在运行处理器之前都会获取购物车。我相信你可以看到这种模式的价值。当你能够识别一组需要类似功能的路由时,有时最好将其提取到中间件中。这也是指出这一点的好时机,即这也适用于蓝图组。我们可以将中间件更改为这样,并产生相同的影响:
group = Blueprint.group(bp)
@group.on_request
async def fetch_cart(request):
cart_id = request.cookies.get("cart")
request.ctx.cart = await fetch_shopping_cart(cart_id)
正如我们所期望的,属于该蓝图组的端点现在可以访问购物车。
知道我们可以在应用范围和蓝图特定范围内执行中间件,这引发了一个有趣的问题:它们应用的顺序是什么?无论它们声明的顺序如何,所有应用范围的中间件都将始终在蓝图特定中间件之前运行。为了说明这一点,我们将使用一个混合两种类型的示例。
bp = Blueprint("Six", url_prefix="/six")
@app.on_request
async def one(request):
request.ctx.numbers = []
request.ctx.numbers.append(1)
@bp.on_request
async def two(request):
request.ctx.numbers.append(2)
@app.on_request
async def three(request):
request.ctx.numbers.append(3)
@bp.on_request
async def four(request):
request.ctx.numbers.append(4)
@app.on_request
async def five(request):
request.ctx.numbers.append(5)
@bp.on_request
async def six(request):
request.ctx.numbers.append(6)
@app.get("/")
async def app_handler(request):
return json(request.ctx.numbers)
@bp.get("/")
async def bp_handler(request):
return json(request.ctx.numbers)
app.blueprint(bp)
正如这个示例所示,我们通过交替声明应用和蓝图中间件来穿插它们:首先是应用,然后是蓝图等。虽然代码按顺序列出函数(1,2,3,4,5,6),但我们的输出不会。你应该能够预测我们的端点将如何响应,应用编号将附加在蓝图编号之前。确实如此:
$ curl localhost:7777
[1,3,5]
$ curl localhost:7777/six
[1,3,5,2,4,6]
还有一点很有帮助的是指出,由于中间件只是传递Request对象,后续中间件可以访问早期中间件所做的任何更改。在这个例子中,我们在一个中间件中创建了数字列表,然后它对所有中间件都是可用的。
响应中间件
在 HTTP 生命周期的另一边,我们有响应中间件。请求中间件的规则同样适用:
-
它是根据声明的顺序执行的,尽管它是反向顺序!
-
响应中间件可以是应用范围内的或蓝图特定的
-
所有应用范围内的中间件将在任何蓝图特定中间件之前运行
在最后一节中,我们使用中间件从 1 计数到 6。我们将使用完全相同的代码(顺序很重要!),但将请求改为响应:
bp = Blueprint("Six", url_prefix="/six")
@app.on_response
async def one(request, response):
request.ctx.numbers = []
request.ctx.numbers.append(1)
@bp.on_response
async def two(request, response):
request.ctx.numbers.append(2)
@app.on_response
async def three(request, response):
request.ctx.numbers.append(3)
@bp.on_response
async def four(request, response):
request.ctx.numbers.append(4)
@app.on_response
async def five(request, response):
request.ctx.numbers.append(5)
@bp.on_response
async def six(request, response):
request.ctx.numbers.append(6)
@app.get("/")
async def app_handler(request):
return json(request.ctx.numbers)
@bp.get("/")
async def bp_handler(request):
return json(request.ctx.numbers)
现在,当我们访问我们的端点时,我们将看到不同的顺序:
$ curl localhost:7777
500 — Internal Server Error
===========================
'types.SimpleNamespace' object has no attribute 'numbers'
AttributeError: 'types.SimpleNamespace' object has no attribute 'numbers' while handling path /
Traceback of __main__ (most recent call last):
AttributeError: 'types.SimpleNamespace' object has no attribute 'numbers'
File /path/to/sanic/app.py, line 777, in handle_request
response = await response
File /path/to/server.py, line 48, in app_handler
return json(request.ctx.numbers)
哎呀,发生了什么事?嗯,因为我们直到响应中间件才定义了我们的ctx.numbers容器,所以在处理程序内部不可用。让我们快速修改一下。我们将在请求中间件内部创建该对象。为了我们的示例,我们还将从请求处理程序返回 None,而不是从最后一个中间件创建我们的响应。在这个例子中,最后一个响应中间件将是第一个声明的蓝图响应中间件。
@bp.on_response
async def complete(request, response):
return json(request.ctx.numbers)
@app.on_request
async def zero(request):
request.ctx.numbers = []
@app.on_response
async def one(request, response):
request.ctx.numbers.append(1)
@bp.on_response
async def two(request, response):
request.ctx.numbers.append(2)
@app.on_response
async def three(request, response):
request.ctx.numbers.append(3)
@bp.on_response
async def four(request, response):
request.ctx.numbers.append(4)
@app.on_response
async def five(request, response):
request.ctx.numbers.append(5)
@bp.on_response
async def six(request, response):
request.ctx.numbers.append(6)
@bp.get("/")
async def bp_handler(request):
request.ctx.numbers = []
return json("blah blah blah")
仔细看看上面的内容。我们仍然有应用和蓝图中间件的混合。我们在处理程序内部创建了数字容器。此外,重要的是要注意,我们正在使用与请求中间件相同的顺序,后者产生了:1, 3, 5, 2, 4, 6。这里的更改只是为了向我们展示响应中间件是如何反转其顺序的。你能猜出我们的数字将按什么顺序排列吗?让我们检查一下:
$ curl localhost:7777/six
[5,3,1,6,4,2]
首先,所有应用范围内的响应中间件都会运行(按声明的反向顺序)。其次,所有蓝图特定的中间件都会运行(按声明的反向顺序)。当你创建响应中间件时,如果它们相互关联,请记住这个区别。
虽然请求中间件的常见用例是为请求对象添加一些数据以供进一步处理,但这对于响应中间件来说并不实用。我们上面的例子有点奇怪且不实用。那么响应中间件有什么用呢?最常见的情况可能是设置头和 cookie。
这里有一个简单(并且非常常见)的用例:
@app.on_response
async def add_correlation_id(request: Request, response: HTTPResponse):
header_name = request.app.config.REQUEST_ID_HEADER
response.headers[header_name] = request.id
你为什么要这样做呢?许多 Web API 使用所谓的关联 ID来帮助识别单个请求。这对于日志记录目的很有帮助,对于跟踪请求在堆栈中通过各种系统时的流动,以及对于消费你的 API 的客户来说,跟踪正在发生的事情也很有帮助。Sanic 遵循这个原则,并将自动为你设置request.id。这个值将是来自传入请求头部的传入关联 ID,或者为每个请求生成一个唯一值。默认情况下,Sanic 将为这个值生成一个 UUID。你通常不需要担心这个问题,除非你想要使用除了 UUID 之外的东西来关联 Web 请求。如果你对如何覆盖 Sanic 生成这些值的逻辑感兴趣,请查看第十一章,一个完整的真实世界示例。
回到我们上面的例子,我们看到我们只是简单地获取那个值并将其附加到我们的响应头中。我们现在可以看到它在行动:
$ curl localhost:7777 -i
HTTP/1.1 200 OK
X-Request-ID: 1e3f9c46-1b92-4d33-80ce-cca532e2b93c
content-length: 9
connection: keep-alive
content-type: text/plain; charset=utf-8
Hello, world.
这段小代码是我强烈建议你添加到所有你的应用程序中的。当你与请求 ID 记录结合使用时,它非常有用。这也是我们将在第十一章中添加到我们的应用程序中的内容。
使用中间件提前(或延迟)响应
当我们探索上一节中的响应中间件排序示例时,你是否注意到了我们的响应中发生了一些奇怪的事情?你是否看到了这个:
@bp.on_response
async def complete(request, response):
return json(request.ctx.numbers)
...
@bp.get("/")
async def bp_handler(request):
request.ctx.numbers = []
return json("blah blah blah")
我们从处理器得到了一个无意义的响应,但它并没有被返回。这是因为在我们的中间件中我们返回了一个HTTPResponse对象。无论何时你从中间件返回一个值——无论是请求还是响应——Sanic 都会假设你正在尝试结束 HTTP 生命周期并立即返回。因此,你永远不应该从中间件返回以下任何内容:
-
不是
HTTPResponse对象 -
不打算中断 HTTP 生命周期
然而,这个规则不适用于None值。如果你只是想停止中间件的执行,仍然可以使用return None。
@app.on_request
async def check_for_politeness(request: Request):
if "please" in request.headers:
return None
return text("You must say please")
让我们看看现在会发生什么:
$ curl localhost:7777/show-me-the-money
You must say please
$ curl localhost:7777/show-me-the-money -H "Please: With a cherry on top"
在第二个请求中,由于它有正确的头,因此它被允许继续进行。因此,我们可以看到从中间件返回None也是可以接受的。如果你熟悉在 Python 循环中使用continue,它大致有相同的影响:停止执行,并进入下一步。
重要提示
尽管我们正在寻找请求头部的
please值,但我们能够传递Please并且它仍然可以工作,因为头总是不区分大小写的。
中间件和流式响应
你还应该知道关于中间件的一个额外的陷阱。记得我们简单地说中间件基本上是在路由处理器前后封装吗?这并不完全正确。
真诚地说,中间件封装了响应的生成。由于这通常发生在处理器的返回语句中,这就是我们为什么采取简单方法的原因。
如果我们回顾第五章的示例并使用我们的流处理程序,这一点很容易看出。这就是我们开始的地方:
@app.get("/")
async def handler(request: Request):
resp = await request.respond()
for _ in range(4):
await resp.send(b"Now I'm free, free-falling")
await asyncio.sleep(1)
await resp.eof()
让我们添加一些打印语句和一些中间件,以便我们可以检查执行顺序。
@app.get("/")
async def handler(request: Request):
print("before respond()")
resp = await request.respond()
print("after respond()")
for _ in range(4):
print("sending")
await resp.send(b"Now I'm free, free-falling")
await asyncio.sleep(1)
print("cleanup")
await resp.eof()
print("done")
@app.on_request
async def req_middleware(request):
print("request middleware")
@app.on_response
async def resp_middleware(request, response):
print("response middleware")
现在,我们将访问端点,并查看我们的终端日志:
request middleware
before respond()
response middleware
after respond()
sending
(sanic.access)[INFO][127.0.0.1:49480]: GET http://localhost:7777/ 200 26
sending
sending
sending
cleanup
done
正如我们所预期的那样,请求中间件首先运行,然后我们开始路由处理程序。但是,响应中间件在我们调用 request.respond() 之后立即运行。对于大多数响应中间件的用例(如添加头信息),这通常不会造成问题。然而,如果你绝对必须在路由处理程序完成后执行一些代码,那么这就会成为一个问题。在这种情况下,你的解决方案是使用信号,我们将在本章后面探讨。
但首先,我们将探讨信号,有时它们可以很好地替代中间件。虽然中间件本质上是一个工具,它允许我们在路由处理器的限制之外扩展业务逻辑,并在不同的端点之间共享它,但我们会了解到信号更像是允许我们在 Sanic 中插入代码的断点。
利用信号进行工作内通信
通常,Sanic 试图让开发者能够扩展其功能以创建自定义解决方案。这也是为什么在与 Sanic 接口时,有多个选项可以注入自定义类来接管、更改或以其他方式扩展其功能。例如,你知道你可以替换其 HTTP 协议,从而将 Sanic 实质上变成一个 FTP 服务器(或任何其他基于 TCP 的协议)吗?或者,你可能想扩展路由功能?
这类定制相当高级。我们不会在本书中涵盖它们,因为对于大多数用例来说,这就像是用手锤在墙上钉钉子一样。
Sanic 团队引入信号作为一种在更用户友好的格式中扩展平台功能的方法。非常有意地,设置信号处理程序看起来和感觉就像是一个路由处理程序:
@app.signal("http.lifecycle.begin")
async def connection_begin(conn_info):
print("Hello from http.lifecycle.begin")
你可能会问:这究竟是什么,我该如何使用它?在这个例子中,我们了解到 http.lifecycle.begin 是一个事件名称。当 Sanic 向客户端打开 HTTP 连接时,它会派发这个信号。然后 Sanic 会检查是否有任何处理程序正在等待它,并运行它们。因此,我们所做的就是设置一个处理程序来附加到该事件。在本章中,我们将更深入地探讨预定义的事件。但首先,让我们更仔细地检查信号的结构和操作。
信号定义
所有信号都通过其事件名称定义,该名称由三个部分组成。我们刚刚看到了一个名为 http.lifecycle.begin 的信号事件。显然,这三个部分是 http、lifecycle 和 begin。一个事件将 仅 有三个部分。
这很重要,因为尽管 Sanic 默认提供了一组信号,但它也允许我们在过程中创建自己的信号。因此,我们需要遵循这个模式。将第一部分视为命名空间,中间部分视为引用,最后部分视为动作。有点像这样:
namespace.reference.action
以这种方式思考有助于我概念化它们。我喜欢把它们想象成路由。事实上,它们确实是!在底层,Sanic 以与路由处理器相同的方式处理信号处理器,因为它们继承自相同的基类。
如果信号本质上是一个路由,那么这意味着它也可以查找动态路径参数吗?是的!看看这个:
@app.signal("http.lifecycle.<foo>")
async def handler(**kwargs):
print("Hello!!!")
现在去访问应用中的任何路由,我们应该在我们的终端中看到以下内容:
[DEBUG] Dispatching signal: http.lifecycle.begin
Hello!!!
[DEBUG] Dispatching signal: http.lifecycle.read_head
Hello!!!
[DEBUG] Dispatching signal: http.lifecycle.request
Hello!!!
[DEBUG] Dispatching signal: http.lifecycle.handle
Hello!!!
request middleware
response middleware
[DEBUG] Dispatching signal: http.lifecycle.response
Hello!!!
[INFO][127.0.0.1:39580]: GET http://localhost:7777/ 200 20
[DEBUG] Dispatching signal: http.lifecycle.send
Hello!!!
[DEBUG] Dispatching signal: http.lifecycle.complete
Hello!!!
在继续查看可用的信号之前,还有一件事我们需要注意:条件。app.signal() 方法接受一个名为 condition 的关键字参数,可以帮助限制匹配的事件。只有与相同条件分发的事件才会被执行。
我们将查看一个具体的例子。
-
首先添加一些请求中间件。
@app.on_request async def req_middleware(request): print("request middleware") -
然后添加一个信号来连接到我们的中间件(这将在稍后看到,它是一个内置的)。
@app.signal("http.middleware.before") async def handler(**kwargs): print("Hello!!!") -
现在,让我们在访问端点后查看我们的终端:
[DEBUG] Dispatching signal: http.middleware.before request middleware嗯嗯,我们看到信号已被分发,我们的中间件也运行了,但我们的信号处理器没有。为什么?
http.middleware.*事件是特殊的,因为它们只有在满足特定条件时才会匹配。因此,我们需要修改我们的信号定义以包含所需条件。 -
将你的信号修改为添加条件,如下所示:
@app.signal("http.middleware.before", condition={"attach_to": "request"}) async def handler(**kwargs): print("Hello!!!") -
再次访问端点。现在我们应该看到预期的文本。
[DEBUG] Dispatching signal: http.middleware.before Hello!!! request middleware
条件是你可以添加到自定义信号分发中的内容(继续阅读以了解更多关于自定义信号部分的内容)。它看起来像这样:
app.dispatch("custom.signal.event", condition={"foo": "bar"})
大多数信号用例不需要这种方法。然而,如果你发现需要对信号分发进行更多控制,这可能正是你需要的工具。让我们把注意力转回到 Sanic 的内置信号,看看我们还能将信号附加到哪些其他事件上。
使用内置信号
有许多内置的信号我们可以使用。查看下面的表格,并在书中标记这一页。我强烈建议你在尝试解决问题时经常回到这个表格,看看你的选项。虽然这本书中我们提出的实现和使用可能很小,但你的任务是学习这个过程,这样你就可以更有效地解决你自己的应用需求。
首先,这些信号与路由相关。它们将在每个请求上执行。
| 事件名称 | 参数 | 描述 |
|---|---|---|
http.routing.before |
request |
当 Sanic 准备解析传入路径到路由时 |
http.routing.after |
request, route, kwargs, handler |
在找到路由之后立即 |
表 6.2 - 可用的内置路由信号
其次,我们有与请求/响应生命周期特别相关的信号。
| 事件名称 | 参数 | 描述 |
|---|---|---|
http.lifecycle.begin |
conn_info |
当建立 HTTP 连接时 |
http.lifecycle.read_head |
head |
在读取 HTTP 头部信息之后,但在解析之前 |
http.lifecycle.request |
request |
在创建请求对象之后立即 |
http.lifecycle.handle |
request |
在 Sanic 开始处理请求之前 |
http.lifecycle.read_body |
body |
每次从请求体中读取字节时 |
http.lifecycle.exception |
request, exception |
在路由处理程序或中间件中抛出异常时 |
http.lifecycle.response |
request, response |
在发送响应之前 |
http.lifecycle.send |
data |
每次将数据发送到 HTTP 传输时 |
http.lifecycle.complete |
conn_info |
当 HTTP 连接关闭时 |
表 6.3 - 可用的内置请求/响应生命周期信号
第三,我们有围绕每个中间件处理程序的事件。这些信号可能不是你经常使用的。相反,它们主要存在以供 Sanic 插件开发者受益。
| 事件名称 | 参数 | 条件 | 描述 |
|---|---|---|---|
http.middleware.before |
request, response |
{"attach_to": "request"} 或 {"attach_to": "response"} |
在每个中间件运行之前 |
http.middleware.after |
request, response |
{"attach_to": "request"} 或 {"attach_to": "response"} |
每个中间件运行之后 |
表 6.4 - 可用的内置中间件信号
最后,我们有服务器事件。这些信号与监听器事件一一对应。虽然你可以将它们作为信号调用,但描述中已指示,每个都有方便的装饰器。
| 事件名称 | 参数 | 描述 |
|---|---|---|
server.init.before |
app, loop |
在服务器启动之前(相当于 app.before_server_start) |
server.init.after |
app, loop |
服务器启动后(相当于 app.after_server_start) |
server.shutdown.before |
app, loop |
在服务器关闭之前(相当于 app.before_server_stop) |
server.shutdown.after |
app, loop |
在服务器关闭之后(相当于 app.after_server_stop) |
表 6.5 - 可用的内置服务器生命周期信号
我想分享一个例子,说明信号的力量。我为 Sanic 用户提供了很多支持。如果你花过时间查看社区资源(无论是论坛还是 Discord 服务器),你很可能见过我帮助开发者解决问题。我真的喜欢参与开源软件的这一方面。
有一次,有人向我求助,他们遇到了中间件的问题。目标是使用响应中间件在服务器发送响应时记录有用的信息。问题是,当中间件中引发异常时,它将停止其他中间件的运行。因此,这个人无法记录每个响应。在其他响应中间件中引发异常的请求从未到达记录器。解决方案——你可能已经猜到了——是使用信号。特别是,http.lifecycle.response事件在这个用例中工作得非常完美。
为了说明这一点,这里有一些代码:
-
设置两个中间件,一个用于日志记录,另一个用于引发异常。记住,它们需要按照你希望它们运行的顺序相反:
@app.on_response async def log_response(request, response): logger.info("some information for your logs") @app.on_response async def something_bad_happens_here(request, response): raise InvalidUsage("Uh oh") -
当我们访问任何端点时,
log_response将永远不会被运行。 -
为了解决这个问题,将
log_response从中间件改为信号(只需更改装饰器即可):@app.signal("http.lifecycle.response") async def log_response(request, response): logger.info("some information for your logs") -
现在,当我们访问端点并遇到异常时,我们仍然会得到预期的日志:
[ERROR] Exception occurred in one of response middleware handlers Traceback (most recent call last): File "/home/adam/Projects/Sanic/sanic/sanic/request.py", line 183, in respond response = await self.app._run_response_middleware( File "_run_response_middleware", line 22, in _run_response_middleware from ssl import Purpose, SSLContext, create_default_context File "/tmp/p.py", line 23, in something_bad_happens_here raise InvalidUsage("Uh oh") sanic.exceptions.InvalidUsage: Uh oh [DEBUG] Dispatching signal: http.lifecycle.response [INFO] some information for your logs [INFO][127.0.0.1:40466]: GET http://localhost:7777/ 200 3
我们还可以使用这个完全相同的信号来解决我们早期遇到的一个问题。记得当我们检查响应中间件并使用流处理程序得到一些令人惊讶的结果时吗?如果你回到本章的早期部分,我们会注意到响应中间件实际上是在响应对象创建时被调用的,而不是在处理程序完成后。我们可以在歌词流完之后使用http.lifecycle.response来包装。
@app.signal("http.lifecycle.response")
async def http_lifecycle_response(request, response):
print("Finally... the route handler is over")
这可能又是一个你放下书本进行探索的好时机。回到那个早期的流处理程序示例,并尝试一些这些信号。看看它们接收到的参数,并思考你如何使用它们。当然,了解它们发送的顺序也同样重要。
完成这些后,我们将探讨创建自定义信号和事件。
自定义信号
到目前为止,我们特别关注内置信号。它们是 Sanic 信号提供的狭义实现。虽然将它们视为允许我们在 Sanic 本身中插入功能的断点是有帮助的,但事实上,还有一个更通用的概念在发挥作用。
信号允许应用程序内部通信。因为它们可以作为后台任务异步发送,所以这可以成为应用程序的一部分通知另一部分发生了某事的方便方法。这引入了信号的重要概念之一:它们可以以内联或任务的形式发送。
到目前为止,我们看到的每个内置信号示例都是内联的。也就是说,Sanic 会在信号完成之前停止处理请求。这就是我们能够在生命周期中添加功能的同时保持一致流程的方式。
这可能并不总是期望的。事实上,很多时候当你想使用自定义信号实现自己的解决方案时,让它们作为后台任务运行,这给了应用程序在执行其他任务的同时继续响应请求的能力。
以记录为例。想象一下,我们回到了我们的示例,我们正在构建一个电子商务应用程序。我们想增强我们的访问日志,包括有关已认证用户(如果有)和他们购物车中物品数量的信息。让我们将我们之前的中间件示例转换为信号:
-
我们需要创建一个信号,将用户和购物车信息拉到我们的请求对象上。同样,我们只需要更改第一行。
@app.signal("http.lifecycle.handle") async def fetch_user_and_cart(request): cart_id = request.cookies.get("cart") session_id = request.cookies.get("session") request.ctx.cart = await fetch_shopping_cart(cart_id) request.ctx.user = await fetch_user(session_id) -
为了我们的示例,我们想快速组合一些模型和类似这样的假 getter:
@dataclass class Cart: items: List[str] @dataclass class User: name: str async def fetch_shopping_cart(cart_id): return Cart(["chocolate bar", "gummy bears"]) async def fetch_user(session_id): return User("Adam") -
这将足以让我们的示例运行起来,但我们想看到它。现在,我们将添加一个路由处理程序,它只是输出我们的
request.ctx:@app.get("/") async def route_handler(request: Request): return json(request.ctx.__dict__) -
我们现在应该看到我们的假用户和购物车如预期那样可用:
$ curl localhost:7777 -H 'Cookie: cart=123&session_id=456' { "cart": { "items": [ "chocolate bar", "gummy bears" ] }, "user": { "name": "Adam" } } -
由于我们想使用自己的访问日志,我们应该关闭 Sanic 的访问日志。在第二章中,我们决定将所有示例都这样运行:
$ sanic server:app -p 7777 --debug --workers=2我们现在将改变这一点。添加
--no-access-logs:$ sanic server:app -p 7777 --debug --workers=2 --no-access-logs -
现在,我们将添加我们自己的请求记录器。但是,为了说明我们想要表达的观点,我们将手动让我们的信号响应时间变长:
@app.signal("http.lifecycle.handle") async def access_log(request): await asyncio.sleep(3) name = request.ctx.user.name count = len(request.ctx.cart.items) logger.info(f"Request from {name}, who has a cart with {count} items") -
当你访问端点时,你将在日志中看到以下内容。你也应该体验到在日志出现之前,以及在你收到响应之前会有延迟。
[DEBUG] Dispatching signal: http.lifecycle.request [DEBUG] Dispatching signal: http.lifecycle.handle [INFO] Request from Adam, who has a cart with 2 items -
为了解决这个问题,我们将为我们的记录器创建一个自定义信号,并从
fetch_user_and_cart中分发事件。让我们进行以下更改:@app.signal("http.lifecycle.request") async def fetch_user_and_cart(request): cart_id = request.cookies.get("cart") session_id = request.cookies.get("session") request.ctx.cart = await fetch_shopping_cart(cart_id) request.ctx.user = await fetch_user(session_id) await request.app.dispatch( "olives.request.incoming", context={"request": request}, inline=True, ) @app.signal("olives.request.incoming") async def access_log(request): await asyncio.sleep(3) name = request.ctx.user.name count = len(request.ctx.cart.items) logger.info(f"Request from {name}, who has a cart with {count} items") -
这次当我们访问端点时,有两件事你需要注意。首先,你的响应应该几乎立即返回。我们之前遇到的延迟响应应该消失了。其次,访问日志中的延迟应该保持。
我们在这里有效地做的是将日志记录中的任何 I/O 等待时间从请求周期中移除。为了做到这一点,我们创建了一个自定义信号。这个信号被称作olives.request.incoming。这并没有什么特别之处。它是完全任意的。唯一的要求,正如我们讨论的那样,是它有三个部分。
要执行信号,我们只需要用相同名称调用app.dispatch:
await app.dispatch("one.two.three")
因为我们想在access_log中访问请求对象,所以我们使用了可选参数context来传递对象。
那么,为什么http.lifecycle.handle信号延迟了响应,而olives.request.incoming没有?因为前者是内联执行的,而后者是作为后台任务执行的。在底层,Sanic 使用inline=True调用 dispatch。现在就添加到自定义调度中,看看它如何影响响应。再次强调,日志和响应现在都延迟了。你应该在想要你的应用程序在调度时暂停,直到所有附加的信号都运行完毕时使用这个。如果这个顺序不重要,如果你省略它,你会获得更好的性能。
dispatch 还接受一些可能对你有帮助的参数。以下是函数签名:
def dispatch(
event: str,
*,
condition: Optional[Dict[str, str]] = None,
context: Optional[Dict[str, Any]] = None,
fail_not_found: bool = True,
inline: bool = False,
reverse: bool = False,
):
这个函数接受的参数如下:
-
condition:用作中间件信号,以控制额外的匹配(我们看到了http.middleware.*信号的使用) -
context:应传递给信号的参数 -
fail_not_found:如果你调度了一个不存在的事件,会发生什么?应该抛出异常还是静默失败? -
inline:是否在任务中运行,如之前讨论的那样 -
reverse:当事件上有多个信号时,它们应该按什么顺序运行?
等待事件
分派信号事件的最后一个有用之处在于,它们也可以像 asyncio 事件一样使用,以阻塞直到它们被调度。这种用例与调度不同。当你调度一个信号时,你正在导致其他操作发生,通常是在后台任务中。当你想要暂停现有任务直到该事件发生时,你应该等待信号事件。这意味着它将阻塞当前存在的任务,无论是后台任务还是正在处理的实际请求。
最简单的方法是使用一个在应用程序中持续运行的超级简单的循环来展示这一点。
-
按照以下方式设置你的循环。注意我们使用
app.event和我们的事件名称。为了简单起见,我们使用了一个内置的信号事件,但它也可以是自定义的。为了使其工作,我们只需要一个与相同名称注册的 app.signal。async def wait_for_event(app: Sanic): while True: print("> waiting") await app.event("http.lifecycle.request") print("> event found") @app.after_server_start async def after_server_start(app, loop): app.add_task(wait_for_event(app)) -
现在,当我们访问我们的端点时,我们应该在日志中看到以下内容:
> waiting [INFO] Starting worker [165193] [DEBUG] Dispatching signal: http.lifecycle.request > event found > waiting
这可能是一个有用的工具,特别是如果你的应用程序使用 websockets。例如,你可能想跟踪打开套接字的数量。随时可以回到 websockets 示例,看看你是否可以将一些事件和信号集成到你的实现中。
另一个有用的用例是,在你响应之前,你的端点需要发生多个事件。你希望将一些工作推迟到信号,但最终它确实需要在响应前完成。
我们可以这样做。设置以下处理程序和信号。
@app.signal("registration.email.send")
async def send_registration_email(email, request):
await asyncio.sleep(3)
await request.app.dispatch("registration.email.done")
@app.post("/register")
async def handle_registration(request):
await do_registration()
await request.app.dispatch(
"registration.email.send",
context={
"email": "alice@bob.co",
"request": request,
},
)
await do_something_else_while_email_is_sent()
print("Waiting for email send to complete")
await request.app.event("registration.email.done")
print("Done.")
return text("Registration email sent")
现在我们查看终端时,应该看到以下内容:
do_registration
Sending email
do_something_else_while_email_is_sent
Waiting for email send to complete
Done.
由于我们知道发送电子邮件将是一个昂贵的操作,我们将它发送到后台,同时继续处理请求。通过使用 app.event,我们能够等待registration.email.done事件被分发,然后回复电子邮件实际上已经发送。
在这个例子中,你应该注意的一点是,实际上并没有信号附加到registration.email.done上。默认情况下,Sanic 会抱怨并抛出异常。如果你想使用这种模式,你有三种选择。
-
注册一个信号。
@app.signal("registration.email.done") async def noop(): ... -
由于我们实际上不需要执行任何操作,所以我们实际上不需要一个处理器:
app.add_signal(None, "registration.email.done") -
告诉 Sanic 在发生分发时自动创建所有事件,无论是否有注册的信号:
app.config.EVENT_AUTOREGISTER = True
既然我们已经知道有几种方式可以在 HTTP 生命周期内控制业务逻辑的执行,我们将接下来探索我们可以利用新发现工具做的一些其他事情。
掌握 HTTP 连接
在第四章的早期,我们讨论了 HTTP 生命周期代表了客户端和服务器之间的对话。客户端请求信息,服务器响应。特别是,我们将它比作双向通信的视频聊天。让我们深入这个类比,以扩展我们对 HTTP 和 Sanic 的理解。
而不是将 HTTP 请求视为视频聊天,最好是将其视为一个单独的对话,或者更好,一个单一的问题和答案。类似于这样:
客户: 嗨,我的会话 ID 是 123456,我的购物车 ID 是 987654。你能告诉我我可以购买的其他商品吗?
服务器: 嗨亚当,你的购物车中已经有了纯橄榄油和特级初榨橄榄油。你可以添加:香醋或红葡萄酒醋。
Sanic 是一个“高性能”的 Web 框架,因为它能够同时与多个客户端进行这些对话。当它在为一位客户端获取结果时,它可以开始与其他客户端的对话:
客户 1: 你们销售哪些产品?
客户 2: 一桶橄榄油的价格是多少?
客户 3: 生活的意义是什么?
由于服务器能够同时对应多个视频聊天会话,因此它变得更加高效地响应。但是,当一个客户端有多个问题时会发生什么?为每个“对话”开始和结束视频聊天将会既耗时又昂贵。
开始视频聊天
客户: 这里是我的凭证,我可以登录吗?
服务器: 嗨亚当,很高兴再次见到你,这是你的会话 ID:123456。再见。
结束视频聊天
开始视频聊天
客户: 嗨,我的会话 ID 是 123456。我可以更新我的个人资料信息吗?
服务器: 哎呀,无效请求。看起来你没有发送正确的数据。再见。
结束视频聊天
每次视频聊天开始和停止时,我们都在浪费时间和资源。HTTP/1.1 通过引入持久连接来试图解决这个问题。这是通过 Keep-Alive 头部实现的。我们不需要担心客户端或服务器如何具体处理这个头部。Sanic 会相应地处理。
我们需要理解的是,它确实存在,并且包含一个超时时间。这意味着如果在这个超时期间有另一个请求到来,Sanic 不会关闭与客户端的连接。
开始视频聊天
客户端: 这是我的凭证,我可以登录吗?
服务器: 嗨,亚当,很高兴再次见到你,这是你的会话 ID:123456。
服务器: 等待中…
服务器: 等待中…
服务器: 等待中…
服务器: 再见。
停止视频聊天
我们现在在单个视频聊天中创建了一个效率,允许进行多个对话。
我们需要考虑的两个实际问题: (1) 服务器应该等待多长时间?以及 (2) 我们能否使连接更高效?
Sanic 中的 Keep-Alive
Sanic 默认会保持 HTTP 连接活跃。这使得操作更高效,正如我们之前看到的。然而,可能存在一些情况下这并不理想。也许你永远不想保持连接开启。如果你知道你的应用程序永远不会处理每个客户端超过一个请求,那么可能使用宝贵的内存来保持一个永远不会被重用的连接是浪费的。要关闭它,只需在你的应用程序实例上设置一个配置值:
app.config.KEEP_ALIVE = False
如你所能猜到的,即使是最基本的 Web 应用程序也不会落入这个类别。因此,尽管我们有关闭 keep-alive 的能力,但你可能不应该这么做。
你更有可能想要更改的是超时时间。默认情况下,Sanic 将保持连接开启五秒钟。这可能看起来不是很长,但对于大多数用例来说应该足够长,而且不会造成浪费。然而,这仅仅是 Sanic 做的一个完全猜测。你更有可能了解并理解你应用程序的需求,你应该可以自由地调整这个数字以满足你的需求。如何?再次,通过一个简单的配置值:
app.config.KEEP_ALIVE_TIMEOUT = 60
为了给你一些背景信息,这里是从 Sanic 用户指南中摘录的一段内容,它提供了一些关于其他系统如何操作的见解:
“Apache httpd 服务器默认 keepalive 超时 = 5 秒
Nginx 服务器默认 keepalive 超时 = 75 秒
Nginx 性能调整指南使用 keepalive = 15 秒
IE (5-9) 客户端硬 keepalive 限制 = 60 秒
Firefox 客户端硬 keepalive 限制 = 115 秒
Opera 11 客户端硬 keepalive 限制 = 120 秒
Chrome 13+ 客户端 keepalive 限制 > 300+ 秒"
sanicframework.org/en/guide/deployment/configuration.html#keep-alive-timeout
你如何知道是否应该增加超时时间?如果你正在构建一个单页应用程序,其中你的 API 旨在为 JS 前端提供动力,那么浏览器可能会发出很多请求。这通常是这些前端应用程序的工作方式。如果你预期用户会点击按钮、浏览一些内容,然后再次点击,这尤其正确。我首先想到的是一种网络门户类型的应用程序,其中单个用户可能需要在分钟内进行数十次调用,但这些调用可能被一些浏览时间间隔所分隔。在这种情况下,将超时时间增加到反映预期使用可能是有意义的。
这并不意味着你应该过度增加它。首先,如我们上面所看到的,浏览器通常有一个它们将保持连接打开的最大限制。其次,连接长度过长可能会造成浪费,并损害你的内存性能。你追求的是一个平衡。没有一种完美的答案,所以你可能需要通过实验来找出什么有效。
按连接缓存数据
如果你正在考虑如何利用这些工具来满足你的应用程序需求,你可能已经注意到了可以创建的潜在效率。回到本章的开头,有一个表格列出了 Sanic 中你可以使用的所有上下文(ctx)对象。其中之一是针对连接的特定对象。
这意味着你不仅能够创建有状态的请求,还可以将状态添加到单个连接中。我们的简单示例将是一个计数器。
-
首先在建立连接时创建一个计数器。我们将为此使用一个信号:
from itertools import count @app.signal("http.lifecycle.begin") async def setup_counter(conn_info): conn_info.ctx._counter = count() -
接下来,我们将使用中间件在每次请求时增加计数器:
@app.on_request async def increment(request): request.conn_info.ctx.count = next(request.conn_info.ctx._counter) -
然后,我们将将其输出到我们的请求体中,以便我们可以看到它看起来像什么:
@app.get("/") async def handler(request): return json({"request_number": request.conn_info.ctx.count}) -
现在,我们将使用 curl 发出多个请求。为此,我们只需多次给出 URL:
$ curl localhost:7777 localhost:7777 {"request_number":0} {"request_number":1}
这当然是一个简单的例子,我们可以很容易地从 Sanic 中获取这些信息:
@app.get("/")
async def handler(request):
return json(
{
"request_number": request.conn_info.ctx.count,
"sanic_count": request.protocol.state["requests_count"],
},
)
如果你有某些可能获取成本高昂的数据,但又希望它对所有请求都可用,这将非常有用。回到我们之前的角色扮演模型,这就像你的服务器在视频聊天开始时获取了一些详细信息。现在,每当客户端提出问题,服务器已经将详细信息放在缓存中。
重要提示
这确实有一个警告。如果你的应用程序通过代理暴露,可能是连接池。也就是说,代理可能会从不同的客户端接收请求并将它们捆绑在一个连接中。想象一下,如果你的视频聊天会话不是在某个人的私人住宅中,而是在一个大大学宿舍的大厅里。任何人都可以走到单个视频聊天会话前提问。你可能无法保证每次都是同一个人。因此,在你将任何敏感细节暴露在这个对象上之前,你必须知道它是安全的。你的最佳实践可能就是将敏感细节保留在
request.ctx上。
像专业人士一样处理异常
在一个理想的世界里,我们的应用程序永远不会失败,用户永远不会提交错误信息。所有端点将始终返回200 OK响应。这当然是纯粹的幻想,没有任何网络应用程序如果不解决失败的可能性就不能完整。在现实生活中,我们的代码会有错误,会有未处理的边缘情况,用户会发送错误数据并滥用应用程序。简而言之:我们的应用程序会失败。因此,我们必须时刻考虑这一点。
当然,Sanic 为我们提供了一些默认的处理方式。它包括几种不同的异常处理器样式(HTML、JSON 和文本),并且可以在生产环境和开发环境中使用。它当然是中立的,因此对于相当规模的应用程序来说可能是不够充分的。我们将在后面的回退错误处理部分更多地讨论回退错误处理。正如我们刚刚学到的,在应用程序中处理异常对于网络应用程序的质量(以及最终的安全性)至关重要。现在我们将学习如何在 Sanic 中做到这一点。
实施适当的异常处理
在我们探讨如何使用 Sanic 处理异常之前,重要的是要考虑,如果未能妥善处理这个问题,可能会变成一个安全问题。显而易见的方式可能是不小心泄露敏感信息。这被称为泄露。这种情况发生在异常被抛出(可能是用户的错误或故意为之)并且你的应用程序报告回显露出有关应用程序构建方式或存储数据的细节时。
在一个现实世界的最坏情况下,我曾经在一个网络应用程序中有一个被遗忘的旧端点,它已经不再工作。没有人再使用它,我简单地忘记了它的存在,甚至不知道它仍然处于活跃状态。问题是这个端点没有适当的异常处理,错误直接在发生时报告。这意味着即使是“无法使用用户名 ABC 和密码 EFG 连接到数据库 XYZ”这样的消息也会直接流向访问端点的人。哎呀。
因此,尽管我们直到第七章才讨论一般的安全问题,但它确实扩展到了当前对异常处理的探索。这里有两个主要问题:提供带有回溯或其他实现细节的异常消息,以及错误地使用 400 系列响应。
不良异常消息
在开发过程中,尽可能多地了解您的请求信息非常有帮助。这就是为什么在您的响应中包含异常消息和回溯是可取的。当您以调试模式构建应用程序时,您将获得所有这些详细信息。但请确保在生产环境中将其关闭!就像我希望我的应用程序始终只提供200 OK响应一样,我希望我永远不会遇到一个意外泄露调试信息的网站。这种情况在野外确实存在,所以请小心不要犯这个错误。
更常见的是在响应时没有正确考虑错误的内容。在编写将发送给最终用户的消息时,请记住,您不希望无意中泄露实现细节。
错误使用状态
与不良异常密切相关的是泄露关于您应用程序信息的异常。想象一下,如果您的银行网站有一个端点是:/accounts/id/123456789。他们做了尽职调查并正确保护了这个端点,以确保只有您才能访问它。这没问题。但是,如果有人无法访问它会发生什么?当我尝试访问您的银行账户时会发生什么?显然我会得到 401 未授权,因为这不是我的账户。然而,一旦你这样做,银行现在就承认 123456789 是一个合法的账户号码。因此,我强烈建议您使用下面的图表并将其牢记在心。
| 状态 | 描述 | Sanic 异常 | 何时使用 |
|---|---|---|---|
| 400 | 错误请求 | InvalidUsage |
当任何用户提交意外形式的数据或他们以其他方式做了您的应用程序不打算处理的事情时 |
| 401 | 未授权 | Unauthorized |
当未知用户尚未认证时。换句话说,您不知道用户是谁。 |
| 403 | 禁止访问 | Forbidden |
当已知用户没有权限在已知资源上执行某些操作时 |
| 404 | 未找到 | NotFound |
当任何用户尝试访问隐藏资源时 |
表 6.6 - Sanic 对常见 400 系列 HTTP 响应的异常
最大的失败可能是人们无意中通过 401 或 403 暴露了隐藏资源的存在。您的银行本应发送给我一个 404,并引导我到一个“页面未找到”的响应。这并不是说您应该总是优先考虑 404。但从安全角度来看,考虑谁可以访问信息,以及他们应该或不应该知道什么,对您是有利的。然后,您可以决定哪种错误响应是合适的。
通过抛出异常来响应
在 Sanic 中处理异常最方便的事情之一是它相对容易上手。记住,我们只是在编写一个 Python 脚本,你应该像对待其他任何东西一样对待它。当事情出错时,你应该怎么做?抛出异常!这里有一个例子。
-
创建一个简单的处理器,我们在这里将忽略返回值,因为我们不需要它来证明我们的观点。发挥你的想象力,看看
...之外可能是什么。@app.post("/cart) async def add_to_cart(request): if "name" not in request.json: raise InvalidUsage("You forgot to send a product name") ... -
接下来,我们将向端点提交一些 JSON 数据,但不包括名称属性。请确保使用
-i选项,这样我们就可以检查响应头。$ curl localhost:7777/cart -X POST -d '{}' -i HTTP/1.1 400 Bad Request content-length: 83 connection: keep-alive content-type: text/plain; charset=utf-8 400 — Bad Request ================= You forgot to send a product name
注意我们收到了一个 400 响应,但实际上并没有从处理器返回响应。这是因为如果你从 sanic.exceptions 中抛出任何异常,它们可以用来返回适当的状态码。此外,你会发现该模块中的许多异常(如 InvalidUsage)都有一个默认的 status_code。这就是为什么当你抛出 InvalidUsage 时,Sanic 会以 400 响应。当然,你可以通过传递不同的值来覆盖状态码。让我们看看这将如何工作:
-
设置此端点并更改
status_code为 400 以外的值:@app.post("/coffee") async def teapot(request): raise InvalidUsage("Hmm...", status_code=418) -
现在,让我们访问它:
$ curl localhost:777/coffee -X POST -i HTTP/1.1 418 I'm a teapot content-length: 58 connection: keep-alive content-type: text/plain; charset=utf-8 418 — I'm a teapot ================== Hmm...
如您所见,我们向异常传递了 418 状态码。Sanic 接受了这个代码,并将其正确转换为适当的 HTTP 响应:418 我是一把茶壶。是的,这是一个真实的 HTTP 响应。你不信?在 RFC 7168 § 2.3.3 中查找。datatracker.ietf.org/doc/html/rfc7168#section-2.3.3
这里是所有内置异常及其相关响应码的参考:
| 异常 | 状态 |
|---|---|
HeaderNotFound |
400 错误请求 |
InvalidUsage |
400 错误请求 |
Unauthorized |
401 未授权 |
Forbidden |
403 禁止 |
FileNotFound |
404 文件未找到 |
NotFound |
404 文件未找到 |
MethodNotSupported |
405 方法不允许 |
RequestTimeout |
408 请求超时 |
PayloadTooLarge |
413 请求实体过大 |
ContentRangeError |
416 请求范围不满足 |
InvalidRangeType |
416 请求范围不满足 |
HeaderExpectationFailed |
417 期望失败 |
ServerError |
500 内部服务器错误 |
URLBuildError |
500 内部服务器错误 |
ServiceUnavailable |
503 服务不可用 |
表 6.4 带内置 HTTP 响应的 Sanic 异常
因此,使用这些状态码是一个非常好的实践。一个明显的例子可能是当你正在数据库中查找不存在的东西时:
@app.get("/product/<product_id:uuid>")
async def product_details(request, product_id):
try:
product = await Product.query(product_id=product_id)
except DoesNotExist:
raise NotFound("No product found")
使用 Sanic 异常可能是获得适当响应给用户的解决方案之一。
我们当然可以更进一步。我们可以创建自己的自定义异常,这些异常从 Sanic 异常中继承,以利用相同的特性。
-
创建一个继承现有 Sanic 异常之一的异常:
from sanic.exceptions import InvalidUsage class MinQuantityError(InvalidUsage): ... -
在适当的时候提升它:
@app.post("/cart") async def add_to_cart(request): if request.json["qty"] < 5: raise MinQuantityError( "Sorry, you must purchase at least 5 of this item" ) -
当我们有一个不良请求(少于 5 项)时,可以看到错误:
$ curl localhost:777/cart -X POST -d '{"qty": 1}' -i HTTP/1.1 400 Bad Request content-length: 98 connection: keep-alive content-type: text/plain; charset=utf-8 400 — Bad Request ================= Sorry, you must purchase at least 5 of this item
鼓励使用和重用继承自 SanicException 的异常。这不仅是一种良好的实践,因为它提供了一种一致且干净的机制来组织你的代码,而且它使得提供适当的 HTTP 响应变得容易。
到目前为止,在本书中,当我们客户端遇到异常(如最后一个例子所示)时,我们已经收到了一个很好的错误文本表示。在下一节中,我们将了解其他类型的异常输出,以及我们如何控制它。
回退处理
坦白说:格式化异常是平凡的。毫无疑问,使用我们迄今为止学到的技能,我们可以构建我们自己的异常处理器集合。我们知道如何使用模板、捕获异常和返回带有错误状态的 HTTP 响应。但是创建这些需要时间和大量的样板代码。
这就是为什么 Sanic 提供了三种(3)不同的异常处理器:HTML、JSON 和纯文本。在本书的大部分例子中,我们只使用了纯文本处理器,因为这更适合在书中展示信息。让我们回到我们引发 NotFound 错误的例子,看看它使用三种不同类型的处理器可能是什么样子。
HTML
-
设置我们的端点以引发异常:
@app.get("/product/<product_name:slug>") async def product_details(request, product_name): raise NotFound("No product found") -
告诉 Sanic 使用 HTML 格式化。我们将在第八章深入探讨配置。现在,我们只需在我们的 Sanic 实例之后设置该值:
app = Sanic(__name__) app.config.FALLBACK_ERROR_FORMAT = "html" -
打开一个网页浏览器并访问我们的端点。你应该会看到类似这样的内容:

图 6.1 - 示例 404 页面,显示 Sanic 中默认的 404 未找到 HTML 页面看起来是什么样子
JSON
-
使用之前的相同设置,但将回退格式更改为
json。app.config.FALLBACK_ERROR_FORMAT = "html" -
这次我们将使用 curl 访问端点:
$ curl localhost:7777/product/missing-product { "description": "Not Found", "status": 404, "message": "No product found" }
与之前示例中看到的格式良好的 HTML 不同,我们的异常已被格式化为 JSON。如果你的端点将——例如——被一个 JavaScript 浏览器应用程序使用,这更为合适。
文本
-
再次使用相同的设置,我们将回退格式更改为
text。app.config.FALLBACK_ERROR_FORMAT = "text" -
我们将再次使用 curl 来访问端点:
$ curl localhost:7777/product/missing-product 404 — Not Found =============== No product found
如你所见,有三个方便的格式化器适用于我们的异常,可能在不同情况下都适用。
自动
前三个示例使用了 FALLBACK_ERROR_FORMAT 来展示有三种内置的错误格式。还有一个设置 FALLBACK_ERROR_FORMAT 的第四个选项:auto。它看起来是这样的。
app.config.FALLBACK_ERROR_FORMAT = "auto"
当格式设置为auto时,Sanic 将查看路由处理程序和传入的请求,以确定最合适的处理程序。例如,如果一个路由处理程序始终使用text()响应对象,那么 Sanic 将假设你希望异常也以text格式格式化。同样适用于html()和json()响应。
当处于auto模式时,Sanic 甚至会比这更进一步。它会分析传入的请求,查看头部信息,以确保它认为正确的内容与客户端所说的想要接收的内容相匹配。
每个路由的手动覆盖
我们最后一个选择是在路由定义中的单个路由上设置错误格式。这允许我们具体指定,并在需要时偏离回退选项。
-
考虑我们设置回退为
html的例子。app.config.FALLBACK_ERROR_FORMAT = "html" -
现在,让我们将本节开头的路由定义更改为以下具有特定定义的
error_format:@app.get("/product/<product_name:slug>", error_format="text") async def product_details(request, product_name): raise NotFound("No product found") -
如您可能已经猜到的,我们不会看到一个格式化的 HTML 页面,而会看到之前提到的纯文本。
$ curl localhost:7777/product/missing-product 404 — Not Found =============== No product found
捕获异常
虽然 Sanic 方便地为我们处理了很多异常,但不用说,它无法预知应用中可能出现的每一个错误。因此,我们需要考虑如何处理来自 Sanic 之外的异常。或者,更确切地说,如何处理不是通过 Sanic 的异常手动抛出的异常,使用 Sanic 方便添加响应代码的异常。
回到我们的电子商务示例,让我们想象我们正在使用第三方供应商来处理我们的信用卡交易。他们方便地为我们提供了一个我们可以用来处理信用卡的模块。当出现问题的时候,他们的模块将抛出CreditCardError。我们现在的工作是确保我们的应用程序准备好处理这个错误。
然而,在我们这样做之前,让我们看看为什么这很重要。
-
想象一下,这是我们终点:
@app.post("/cart/complete") async def complete_transaction(request): ... await submit_payment(...) ... -
现在,我们访问端点,如果出现错误,我们会得到以下响应:
$ curl localhost:7777/cart/complete -X POST 500 — Internal Server Error ============================ The server encountered an internal error and cannot complete your request.
这不是一条很有帮助的信息。然而,如果我们查看我们的日志,我们可能会看到以下内容:
[ERROR] Exception occurred while handling uri: 'http://localhost:7777/cart/complete'
Traceback (most recent call last):
File "handle_request", line 83, in handle_request
"""
File "/path/to/server.py", line 19, in complete_transaction
await submit_payment(...)
File "/path/to/server.py", line 13, in submit_payment
raise CreditCardError("Expiration date must be in format: MMYY")
CreditCardError: Expiration date must be in format: MMYY
[INFO][127.0.0.1:58334]: POST http://localhost:7777/cart/complete 500 144
这个错误看起来对我们的用户可能更有帮助。
当然,一个解决方案可能是捕获异常并返回我们想要的响应:
@app.post("/cart/complete")
async def complete_transaction(request):
...
try:
await submit_payment(...)
except CreditCardError as e:
return text(str(e), status=400)
...
然而,这种模式并不理想。当我们需要在应用程序的各个位置捕获每个潜在的异常并将它们转换为响应时,这将需要大量的额外代码。这也会使我们的代码变成一个巨大的 try/except 块混乱,使阅读和维护变得更困难。简而言之,这会违反我们在本书早期确立的一些开发原则。
一个更好的解决方案是添加一个应用程序级别的异常处理器。这告诉 Sanic,每当这个异常冒泡时,它应该捕获它并以某种方式响应。它看起来非常像路由处理器:
@app.exception(CreditCardError)
async def handle_credit_card_errors(request, exception):
return text(str(exception), status=400)
Sanic 现已将此注册为异常处理器,并在 CreditCardError 被抛出时使用它。当然,这个处理器非常简单,但你可以想象它可以用作:额外的日志记录、提供请求上下文、凌晨 3 点向你的 DevOps 团队发送紧急警报通知等等。
提示
错误处理器不仅限于你的应用程序实例。就像其他常规路由处理器一样,它们可以注册在你的 Blueprint 实例上,以便为应用程序的特定子集定制错误处理。
异常处理是应用程序开发中极其重要的一个部分。它是业余应用程序和专业应用程序之间的一个直接区分因素。我们现在知道如何使用异常不仅为用户提供有用的消息,还可以提供适当的 HTTP 响应代码。我们现在转向另一个主题(后台处理),这可以帮助将你的应用程序提升到下一个层次。
后台处理
在大多数应用程序的开发过程中,开发者或用户开始注意到应用程序感觉有点慢。有些操作似乎需要很长时间,这损害了应用程序其他部分的可用性。这可能是因为计算成本高昂,也可能是因为网络操作需要连接到另一个系统。
让我们假设你处于这种场景。你已经构建了一个优秀的应用程序和一个端点,允许用户通过点击按钮生成 PDF 报告,显示各种复杂的数据和图表。问题是,为了检索所有数据并处理这些数字似乎需要二十(20)秒。对于 HTTP 请求来说,这是一段很长的时间!在花费时间尽可能提高报告生成器的性能之后,你最终得出结论,它已经尽可能快地运行。你能做什么?
将其推送到后台。
当我们说“后台处理”时,我们真正指的是一种允许当前请求完成而不需要最终完成所需工作的解决方案。在这个例子中,这意味着在报告生成实际完成之前,完成启动报告生成的请求。无论何时何地,我都建议将工作推送到后台。在本章的“等待事件”部分中,我们看到了在后台发送注册电子邮件的用例。确实,如前所述的信号的使用是一种后台处理形式。然而,这并不是 Sanic 提供的唯一工具。
向循环中添加任务
如您可能已经知道,asyncio 库的一个基石是任务。它们本质上是在循环上运行异步工作的处理单元。如果任务或任务循环的概念对您来说仍然陌生,那么在继续之前,在互联网上做一些研究可能是个好主意。
在典型场景中,您可以通过获取事件循环并调用create_task来生成任务,如下所示:
import asyncio
async def something():
...
async def main():
loop = asyncio.get_running_loop()
loop.create_task(something())
这可能对您来说并不陌生,但它的作用是在当前任务之外启动something。
Sanic 提供了一个简单的接口来创建任务,如下所示:
async def something():
...
app.add_task(something)
这可能是最简单的后台处理形式,并且是一个您应该习惯使用的模式。为什么使用这个而不是create_task?有三个原因:
-
这更容易,因为您不需要获取循环。
-
它可以在循环开始之前在全局范围内使用。
-
它可以调用或不调用,也可以带或不带应用程序实例作为参数。
为了说明灵活性,将上一个例子与以下例子进行对比:
from sanic import Sanic
from my_app import something
app = Sanic(“MyAwesomeApp”)
app.add_task(something(app))
提示
如果任务没有像第一个例子那样被调用,Sanic 将检查该函数是否期望
app实例作为参数,并将其注入。
Asyncio 任务非常有帮助,但有时您需要一个更健壮的解决方案。让我们看看我们的其他选项。
与外部服务集成
如果您的应用程序有工作要做,但由于某种原因超出了您的 API 范围,您可能需要转向现成的解决方案。这通常是以另一种在别处运行的服务的形式出现的。现在,您的 Web API 的工作就是向该服务提供工作。
在 Python 世界中,这类工作的经典框架是 Celery。当然,这并不是唯一的选择,但鉴于这本书不是关于决定使用什么的,我们将以 Celery 为例,因为它被广泛使用且为人所知。简而言之,Celery 是一个平台,它的工作进程从队列中读取消息。某些客户端负责将工作推送到队列,当工作进程接收到消息时,它将执行该工作。
为了 Celery 能够运行,它在某台机器上运行一个进程。它有一组已知的操作可以执行(也称为“任务”)。要启动一个任务,外部客户端需要通过代理连接到它,并发送运行任务的指令。一个基本的实现可能看起来像这样。
-
我们设置了一个客户端,使其能够与进程通信。一个常见的放置位置是在
application.ctx上,以便在应用程序的任何地方使用。from celery import Celery @app.before_server_start def setup_celery(app, _): app.ctx.celery = Celery(...) -
要使用它,我们只需从路由处理程序中调用客户端,将一些工作推送到 Celery。
@app.post("/start_task") async def start_task(request): task = request.app.ctx.celery.send_task( "execute_slow_stuff", kwargs=request.json ) return text(f"Started task with {task.id=}", status=202)
这里要指出的重要一点是,我们正在使用202 已接受状态来告诉 whoever made the request,该操作已被接受处理。没有保证它会完成,或者将会完成。
在检查 Celery 之后,你可能认为它对你的需求来说有点过度。但是,app.add_task似乎还不够。接下来,我们将看看你如何开发自己的进程内队列系统。
设计进程内任务队列
有时,对于你的需求来说,显而易见的黄金比例解决方案是构建一个完全局限于 Sanic 的东西。如果你只需要担心一个服务而不是多个服务,这将更容易管理。你可能仍然希望保留“工作者”和“任务队列”的概念,而不需要像 Celery 这样的服务实现所需的额外开销。所以,让我们构建一些东西,希望这能成为你在应用程序中构建更令人惊叹的东西的起点。在我们开始之前,你可以在 GitHub 仓库中查看最终的代码产品:___。
在我们继续前进之前,让我们将名称从“任务队列”更改为“作业队列”。我们不希望因为像 asyncio 任务这样的例子而混淆自己。在本节剩余部分,单词“任务”将指代 asyncio 任务。
首先,我们将开发一套针对我们的作业队列的需求。
-
应该有一个或多个能够执行作业(在请求/响应周期之外)的“工作者”。
-
它们应该按照先入先出的顺序执行作业。
-
作业的完成顺序并不重要(例如,作业 A 在作业 B 之前开始,但哪个先完成并不重要)。
-
我们应该能够检查作业的状态。
我们实现这一点的策略是构建一个框架,其中我们有一个“工作者”,它本身就是一个后台任务。它的任务是查找通用队列中的作业并执行它们。这个概念与 Celery 非常相似,但我们将在我们的 Sanic 应用程序中使用 asyncio 任务来处理它。我们将通过查看源代码来完成这一点,但不会全部展示。与本次讨论无关的实现细节将在此省略。有关完整详情,请参阅 GitHub 仓库中的源代码:____。
-
首先,让我们设置一个非常简单的应用程序,它只有一个蓝图。
from sanic import Sanic from job.blueprint import bp app = Sanic(__name__) app.config.NUM_TASK_WORKERS = 3 app.blueprint(bp) -
那个蓝图将是我们将附加一些监听器和端点的地方。
from sanic import Blueprint from job.startup import ( setup_task_executor, setup_job_fetch, register_operations, ) from job.view import JobListView, JobDetailView bp = Blueprint("JobQueue", url_prefix="/job") bp.after_server_start(setup_job_fetch) bp.after_server_start(setup_task_executor) bp.after_server_start(register_operations) bp.add_route(JobListView.as_view(), "") bp.add_route(JobDetailView.as_view(), "/<uid:uuid>")如你所见,我们需要运行三个监听器:
setup_job fetch、setup_task_executor和register_operations。我们还有两个视图:一个是列表视图,另一个是详情视图。让我们逐一查看这些项目,看看它们是什么。 -
由于我们想要存储任务的状态,我们需要某种类型的数据存储。为了使事情尽可能简单,我创建了一个基于文件的数据库,名为
FileBackend。async def setup_job_fetch(app, _): app.ctx.jobs = FileBackend("./db") -
这个作业管理系统将根据我们的作业队列的功能驱动,该队列将使用
asyncio.Queue实现。因此,我们接下来需要设置我们的队列和工作者。async def setup_task_executor(app, _): app.ctx.queue = asyncio.Queue(maxsize=64) for x in range(app.config.NUM_TASK_WORKERS): name = f"Worker-{x}" print(f"Starting up executor: {name}") app.add_task(worker(name, app.ctx.queue, app.ctx.jobs))在创建我们的队列之后,我们创建一个或多个后台任务。正如你所见,我们只是使用 Sanic 的
add_task方法从worker函数创建一个任务。我们将在稍后看到这个函数。 -
我们需要的最后一个监听器将设置一个对象,该对象将用于存储我们所有的潜在操作。
async def register_operations(app, _): app.ctx.registry = OperationRegistry(Hello)为了提醒你,
Operation将是我们希望在后台运行的东西。在这个例子中,我们有一个操作:Hello。在查看操作之前,让我们看看两个视图。 -
列表视图将有一个 POST 调用,该调用负责将新的作业推入队列。你也可以想象这将是列出所有现有作业(当然,分页)的端点的一个合适位置。首先,它需要从请求中获取一些数据:
class JobListView(HTTPMethodView): async def post(self, request): operation = request.json.get("operation") kwargs = request.json.get("kwargs", {}) if not operation: raise InvalidUsage("Missing operation")在这里,我们执行一些非常简单的数据验证。在现实世界的场景中,你可能想要做更多的事情来确保请求的 JSON 符合你的预期。
-
在验证数据后,我们可以将有关作业的信息推送到队列。
uid = uuid.uuid4() await request.app.ctx.queue.put( { "operation": operation, "uid": uid, "kwargs": kwargs, } ) return json({"uid": str(uid)}, status=202)我们创建了一个 UUID。这个唯一标识符将用于在数据库中存储作业,并在以后检索有关它的信息。此外,重要的是指出,我们使用
202 Accepted响应,因为它是最合适的形式。 -
详细视图非常简单。使用唯一标识符,我们只需在数据库中查找并返回它。
class JobDetailView(HTTPMethodView): async def get(self, request, uid: uuid.UUID): data = await request.app.ctx.jobs.fetch(uid) return json(data) -
回到我们的
Hello操作,我们现在来构建它:import asyncio from .base import Operation class Hello(Operation): async def run(self, name="world"): message = f"Hello, {name}" print(message) await asyncio.sleep(10) print("Done.") return message如你所见,它是一个简单的对象,有一个
run方法。当运行Job时,该方法将由工作者调用。 -
工作者实际上只是一个异步函数。它的任务将运行一个永无止境的循环。在这个循环中,它将等待队列中有作业。
async def worker(name, queue, backend): while True: job = await queue.get() if not job: break size = queue.qsize() print(f"[{name}] Running {job}. {size} in queue.") -
一旦它有了如何运行作业的信息,它需要创建一个作业实例,并执行它。
job_instance = await Job.create(job, backend) async with job_instance as operation: await job_instance.execute(operation)
关于这个解决方案的几点补充:它最大的缺点是没有恢复机制。如果你的应用程序崩溃或重启,将无法继续处理已经开始的作业。在真正的任务管理过程中,这通常是一个重要的功能。因此,在 GitHub 仓库中,除了构建此解决方案所使用的源代码外,你还会找到“子进程”任务队列的源代码。我不会带你一步步构建它,因为它在很大程度上是一个类似的练习,有很多相同的代码。然而,它与这个解决方案在两个重要方面有所不同:它确实有恢复和重启未完成作业的能力,并且它不是在异步任务中运行,而是利用 Sanic 的进程管理监听器通过多进程技术创建子进程。在你继续学习和通过这本书的工作时,请花些时间查看那里的源代码。
摘要
在我看来,作为应用开发者,你可以迈出的最大一步之一是制定策略来抽象问题的解决方案,并在多个地方重用该解决方案。如果你听说过 DRY(不要重复自己)原则,这就是我的意思。应用程序很少是“完整”的。我们开发它们、维护它们并修改它们。如果我们有太多的重复代码,或者代码与单一用例过于紧密耦合,那么修改它或将其适应不同用例就会变得更加困难。学会概括我们的解决方案可以减轻这个问题。
在 Sanic 中,这意味着将逻辑从路由处理程序中提取出来。最好我们能够最小化单个处理程序中的代码量,并将这些代码放置在其他位置,以便其他端点可以重用。你是否注意到了在 设计进程内任务队列 的最终示例中,路由处理程序并没有超过十几行?虽然确切的长度并不重要,但保持这些代码简洁且简短,并将你的逻辑放在其他地方是有帮助的。
也许本章最大的收获之一应该是通常没有一种唯一的方法来完成某事。我们经常可以使用这些方法的混合来达到我们的目标。那么,应用开发者的任务就是查看工具箱,并决定在特定情况下哪种工具最适合。
因此,作为一个 Sanic 开发者,你应该学习如何制定策略来在路由处理程序之外响应网络请求。在本章中,我们学习了使用中间件、内置和自定义信号、连接管理、异常处理和后台处理等工具来帮助你完成这项任务。再次强调,将这些视为你的工具箱中的核心工具。需要拧紧螺丝吗?拿出你的中间件。需要在木头上钻孔吗?是时候从架子上拿走钻头了。你对 Sanic 中这些基本构建块(如中间件)越熟悉,你对如何构建专业级应用程序的理解就会越深入。
现在是你的任务,通过玩转这些工具并在成为更好的开发者道路上内化它们。
我们已经触及了与安全相关问题的表面。在下一章中,我们将更深入地探讨如何保护我们的 Sanic 应用程序。
第八章:7 处理安全关注
在构建 Web 应用程序时,可能会非常诱人坐下来,规划您的功能,构建它,测试它,然后才回来考虑安全问题。例如,在构建单页应用程序时,您可能甚至直到第一次在浏览器测试中看到这条消息之前都不会考虑 CORS:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at $somesite.
在很大程度上,这就是我们在本书中一直在构建的方式。我们看到一个功能,就构建它。任何时候我们在本书中遇到潜在的安全问题,我们都会将其推迟到以后。我们最终到了这个阶段,将学习如何处理 Sanic 中的安全问题。网络安全这个话题当然非常广泛,本书的范围并不包括对其进行全面研究。
相反,在本章中,我们将探讨:
-
设置有效的 CORS 策略
-
保护应用程序免受 CSRF 攻击
-
使用身份验证保护您的 Sanic 应用程序
尤其是我们希望对安全问题有一个基本的了解,这样我们就可以构建 Sanic 解决方案来解决它们。本章的更大收获将是让您对这些主题感到足够舒适,以至于它们不会成为事后之想。当这些问题被分解时,我们可以看到,从一开始就将它们构建到应用程序设计中会使它们更有效,并且实施起来不那么繁重。
技术要求
本章的要求将再次建立在我们在前几章中使用的内容之上。由于网络安全通常包括前端 JavaScript 应用程序和后端 Python 应用程序之间的交互,我们可能会看到一些使用在主流网络浏览器中广泛可用的 JavaScript 的示例。您可以在以下位置找到本章的所有源代码:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/07。
此外,我们还将使用三个常见(且经过实战考验)的安全库:cryptography、bcrypt和pyjwt。如果您还没有在虚拟环境中安装它们,现在可以添加它们:
$ pip install cryptography bcrypt pyjwt
设置有效的 CORS 策略
如果您正在构建一个服务器仅对单台计算机上的请求做出响应的 Web 应用程序,并且该计算机物理上与互联网断开连接,那么这个部分可能对您来说并不那么相关。对于其他人来说,请注意!为了清楚起见,您是“其他人”的一部分。这是重要的事情。
简而言之,跨源资源共享(CORS)是一种说法,即通过浏览器从一个域访问另一个域。如果没有有效的处理策略,您的应用程序可能会为您的用户打开一个安全风险。
无效的 CORS 存在哪些安全问题?
现代网络在浏览器中使用了大量的 JavaScript。它当然能够实现各种交互性和高质量的用户体验。这些能力之一就是代表用户发出数据请求,而用户并不知道这一点。这个特性是当今网络应用程序与 90 年代末的网络应用程序之间最大的区别之一。当用户在网站上时请求数据,这就是使网页感觉像应用程序的原因。也就是说,它使它们交互性和引人入胜。
假设你有一个假设的应用程序,对用户来说看起来是这样的:https://superawesomecatvideos.com。这是一个非常成功的网站,很多人喜欢来访问它,看看他们最喜欢的汽车视频。如果它开始在后台请求信息(因为黑客攻击或其他原因)从https://mybank.com,那么,我们当然不希望让它成功。没有任何理由让超级神奇猫视频网站能够访问我的银行中的任何内容,尤其是如果我正在我的银行网站上有一个经过验证的 Web 会话。
由于这个原因,默认情况下,网络浏览器不会允许这样做,因为存在同源策略。这意味着网络应用程序只能与同源的资源进行交互。一个源由以下部分组成:
-
HTTP 方案
-
域名
-
端口
让我们看看一些被认为是和不是同源的 URL 的例子:
| URL A | URL B | 同源? |
|---|---|---|
sacv.com |
sacv.com |
是 |
sacv.com |
sacv.com /about |
是,路径无关紧要 |
sacv.com |
sacv.com |
不,不同的 HTTP 方案 |
sacv.com |
sacv.com :8080 |
不,不同的端口 |
sacv.com |
api.sacv.com |
不,不同的域名 |
表 7.1 - URL 及其同源状态的比较
我们假设我们的超级酷猫视频网站也有域名:sacv.com。例如,如果 https://superawesomecatvideos.com 想要加载:https://superawesomecatvideos.com/catvid1234.mp4,这是可以的。当只有路径或加载的资源不同时,URL 被认为是同源的。在我们的例子中,两个 URL 都包含相同的 HTTP 方案、域名和端口指定。但是,当同一个网站 superawesomecatvideos 尝试从 https://api.superawesomecatvideos.com/videos 获取数据时,哎呀,出错了。这些都是同源策略旨在保护免受的潜在攻击向量。因此,问题变成了:我们如何允许合法的跨源请求,而不允许 所有 跨源请求?答案是,我们实际上需要创建一个白名单,并让浏览器知道我们的服务器将接受来自哪些源请求。
让我们构建一个超级简单的示例,以展示问题。我们将在这里构建两个 web 服务器。一个将作为前端应用程序的替代品,另一个将是向前端提供数据的后端。
我们首先构建并运行一个简单的 API 端点,它看起来与我们之前看到的没有任何不同。使用我们之前使用的方法搭建应用程序。以下是您的端点可能的样子:
@app.get("/<name>")
async def handler(request, name):
return text(f"Hi {name}")
您现在应该有一个在端口 7777 上运行的 Sanic 服务器,使用我们已学到的知识。您可以通过访问:http://localhost:7777/Adam 来测试它。
-
在某个位置创建一个目录,并将这个名为
index.html的文件添加到其中。以我的例子来说,它将是/path/to/directory:<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>CORS issue</title> <body> <h1>Loading...</h1> <script> const element = document.querySelector("h1") fetch("http://localhost:7777/Adam") .then(async response => { const text = await response.text() element.innerHTML = text }) </script> </body>如您所见,这个应用程序将在后台向运行在
http://localhost:7777的应用程序发送请求。在获取内容后,它将把内容替换为屏幕上的Loading ...文本。 -
要运行此应用程序,我们将使用 Sanic 包含的一个小巧的技巧,称为“Sanic Simple Server”。我们不会构建一个 Sanic 应用程序,而是将 Sanic CLI 指向一个目录,然后它将为我们提供网站服务:
$ sanic -s /path/to/directory提示
这是一个超级有用的工具,即使在不构建 Sanic 应用程序时也应该放在您的口袋里。在开发过程中,我经常发现需要快速搭建一个 Web 应用程序来在浏览器中查看静态内容。当构建仅使用静态内容的应用程序或构建需要开发服务器的 JavaScript 应用程序时,这可能很有用。
-
打开一个网页浏览器,并访问这个应该运行在
localhost:8000的应用程序。您应该看到类似这样的内容:

图 3.1 - 带有 CORS 问题的 Web 应用程序截图
哎呀,出错了。我们的应用程序抛出了一个错误:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:7777/Adam. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing).
对于大多数刚开始接触网页开发的人来说,这次经历将是他们第一次遇到 CORS。这究竟是什么意思?什么是“跨源请求”,为什么会被阻止?CORS 头信息是什么?最重要的是,我该如何让它消失?!这个问题让我很烦恼。我们不会“让它消失”,我们会理解它的含义,为什么浏览器决定设置障碍,然后继续寻找解决方案。
当一个天真的网页开发者看到这个错误时,会立即上网搜索如何处理这个问题,找到一堆部分或过于深入的信息,然后继续前进,却从未真正理解实际的问题。让错误消失会让你回到开发中,因为错误不再阻碍你的进度,但这并不能解决问题。事实上,你只是创造了一个新的问题。为了成为一名更好的开发者,我们不会在不理解的情况下仅仅实施现成的解决方案。相反,我们会停下来学习正在发生的事情以及为什么会这样。也许你自己也遇到过这个问题,如果没有,你迟早会遇到的。无论你是否在过去“解决了”这个问题,我们都会花些时间来学习这个错误背后的原因,然后再提出一个适当——或者更确切地说,明显——的解决方案。一旦你揭开了 CORS 的层层面纱,你会发现它开始变得很有道理,并且可以变得简单易掌握。
我就是那些搜索这个错误、点击第一个链接、复制粘贴解决方案以消除错误然后继续生活的人之一。浏览器不再抱怨:问题解决了。至少我认为是这样。我没有考虑我行动的后果以及我引入的安全漏洞。那个安全漏洞伪装成解决方案是什么?我找到的解决方案是添加一个简单的头信息,我没有再想它:Access-Control-Allow-Origin: *。不要这样做! 我不知道更好的做法,我继续前进,从未再次考虑过 CORS,除了它似乎在浏览器中给我带来麻烦之外。
这里的问题在于前端应用程序试图访问另一个源的数据:因此是跨源。当我添加那个头信息时,我实际上是在禁用浏览器创建的同源保护。*的意思是:允许这个应用程序请求它想要的任何跨源信息。
我的浏览器为保护我创建了一个城堡。而不是学习如何有效处理 CORS,我决定放下吊桥,打开所有的城门,让守卫们回家睡觉。
我应该做什么呢?让我们来找出答案。
制定有效处理 CORS 的策略
显然,我完全禁用浏览器防御的策略并不是最好的方法。这是条捷径,是懒惰的方法,也是不负责任的方法。我应该做的是去像 Mozilla 提供的那种资源上了解这个问题:developer.mozilla.org/en-US/docs/Web/HTTP/CORS。如果那样做了,这会吸引我的注意:
谁应该阅读这篇文章?
每个人,真的。
哦,每个人都应该阅读它吗?如果你还没有读过,现在你有机会选择与我不同的路径,现在就去读它。我不是在开玩笑。请自己做个好事:在这本书里做个书签,然后去读那个网页。然后回来这里。我保证我们会等你。它用相当简单的术语来理解,并且是一个值得你放在口袋里的权威资源。
根据官方 HTTP 规范,OPTIONS方法允许客户端确定与资源相关联的选项和/或要求,或服务器的功能,而不涉及资源操作(datatracker.ietf.org/doc/html/rfc7231#section-4.3.7)。换句话说,它给 HTTP 客户端提供了在发送实际请求之前检查端点可能需要什么的能力。如果你曾经构建过基于浏览器的 Web 应用程序,或者打算这样做,这个方法非常重要。因此,当我们深入研究 CORS 头信息时,我们也会重新回顾并大量使用我们在第三章中提到的OPTIONS处理器,即路由和接收 HTTP 请求。请翻回那一节,重新熟悉我们将如何自动为所有路由附加OPTIONS处理器。
理解 CORS 头信息
通过应用响应头来解决这些跨源访问问题。因此,我们需要了解这些头信息是什么,以及它们应该在何时应用和使用。在本节中,我们的任务将是构建包含一些基本 CORS 头的 HTTP 响应,我们可以在我们的应用程序中使用这些头。当然,我们可以选择走捷径,安装 PyPI 上的第三方包,它会自动为我们添加这些头。
实际上,我确实建议你在生产应用中这样做。CORS 问题可能很复杂,实施一个可信的解决方案应该能带来一定程度的安慰和安心。然而,在不了解基础知识的情况下依赖这些包,与完全禁用同源策略的第一种解决方案相比,也只是稍微好一点。
这里有一些我们应该知道的常见 CORS 响应头:
-
Access-Control-Allow-Origin:这是服务器用来告诉客户端它将接受和拒绝哪些来源进行跨源请求的;
-
Access-Control-Expose-Headers:服务器使用此选项告诉浏览器它可以允许 JavaScript 安全访问哪些 HTTP 头部(意味着它不包含敏感数据);
-
Access-Control-Max-Age:服务器使用此选项告诉客户端它可以缓存预检请求结果多长时间(有关预检请求的详细信息,请参阅下一节);
-
Access-Control-Allow-Credentials:服务器使用此选项告诉客户端在发送请求时是否可以或不能包含凭据;
-
Access-Control-Allow-Methods:在预检请求中,服务器使用此选项告诉客户端在给定端点上它将接受哪些 HTTP 方法;
-
Access-Control-Allow-Headers:在预检请求中,服务器使用此选项告诉客户端它允许它添加哪些 HTTP 头部。
理解预检请求
在某些场景下,在浏览器尝试访问跨源 某物 之前,它将发出一个被称为 预检请求 的请求。这是一个针对目标资源相同域名和端点的请求,发生在实际调用之前,除了使用 OPTIONS HTTP 方法。这个请求的目的是获取访问 CORS 头部信息,以了解服务器将允许和不允许什么操作。如果浏览器确定响应不是“安全的”,则不会允许它。
当浏览器决定发出预检请求时?Mozilla 在他们的 CORS 页面上提供了一个很好的概述(developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests)。总之,当:
它是 GET、HEAD 或 POST
不包含任何手动设置的头部,除了 Accept、Accept-Language、Content-Language 或 Content-Type
请求头部包括 Content-Type,并设置为 application/x-www-form-urlencoded、multipart/form-data 或 text/plain 之一
请求上没有 JavaScript 事件监听器
客户端不会对响应进行流式传输
这些请求通常旨在涵盖正常网络流量遇到的场景:导航到页面、提交 HTML 表单和基本的 AJAX 请求。一旦你的应用程序开始添加大多数单页网络应用程序典型的功能,你将开始注意到你的浏览器发出预检请求。在这种情况下,触发预检请求的最常见的两种类型是:
注入自定义头部(Authorization、X-XSRF-Token、Foobar 等)的 JS 应用程序
使用 Content-Type: application/json 提交 JSON 数据的 JS 应用程序
你可能会想:这有什么关系?了解这一点很重要,这样我们才知道何时需要响应上一节中看到的六个 CORS 响应头部。
解决 CORS 问题的 Sanic
到目前为止,我们完全避免了使用任何第三方插件。也就是说,我们避免采用任何需要我们pip install解决方案的实现。这是一个有意识的决策,以便我们在将解决方案外包给他人之前,先学习构建我们的 Web 应用程序所需的原则。虽然这在这里仍然有效,也是我们即将手动处理 CORS 请求的原因,但重要的是指出,这已经是一个已经被解决的问题。官方支持的sanic-ext包和社区支持的sanic-cors包都是实现 CORS 保护的可信选项。
话虽如此,让我们考虑每个六个(6)个响应头,以及我们将在何时以及如何实现它们。我们有一些头无论请求类型如何都要添加,还有一些只有在预请求中才会添加。我们需要一个标准且可重复的方法来在这两种情况下添加响应头。我们在这个问题上的首选策略是什么?中间件。
让我们从这个基本的中间件开始,并向其中添加代码:
def is_preflight(request: Request):
return (
request.method == "OPTIONS"
and "access-control-request-method" in request.headers
)
@app.on_response
async def add_cors_headers(request: Request, response: HTTPResponse) -> None:
# Add headers here on all requests
if is_preflight(request):
# Add headers here for preflight requests
...
我们正在做两件事来确定一个请求确实是一个预请求:
-
首先,我们知道浏览器将始终以
OPTIONS请求的形式发出它 -
其次,浏览器将始终附加一个名为
Access-Control-Request-Method的请求头,其值为它即将发送的 HTTP 请求类型
为了模拟预请求,我们将使用以下 curl 请求,该请求添加了触发预请求响应所需的两个头(Origin头和Access-Control-Request-Method头):
$ curl localhost:7777 -X OPTIONS -H "Origin: http://mysite.com" -H "Access-Control-Request-Method: GET" -i
我们最后需要的是一种能力,可以为我们的应用程序中每个现有的路由添加OPTIONS作为可行的 HTTP 方法。这是sanic-ext添加的功能,我们将在第十一章“一个完整的真实世界示例”中学习一种简单的方法来实现这一点。但首先,你可能还记得这是我们在第三章“路由和接收 HTTP 请求”中构建的。我们将重用遍历所有定义的路由并添加OPTIONS端点的代码。你可以在该章节的“对 OPTIONS 和 HEAD 的全面支持”部分找到它。
在建立这一点之后,我们将查看每个响应头,以更全面地了解它们。
Access-Control-Allow-Origin
这个头可能是最重要的一个,也是最容易被当作核选项,完全禁用 CORS 保护,正如之前讨论的那样。除非你有特定的理由接受来自任何浏览器源头的请求,你应该避免使用*。
该值应该是您预期请求来源的地址。您 不应 只是回收传入请求的 Origin 头部并将其应用。这实际上等同于 *。相反,有一个预定义的允许来源列表并与传入的 Origin 进行交叉引用是一个好的做法。如果没有匹配项,则简单地不要添加任何 CORS 头部。
这里是我们将添加到中间件中的第一个片段以实现此目的:
origin = request.headers.get("origin")
if not origin or origin not in request.app.config.ALLOWED_ORIGINS:
return
response.headers["access-control-allow-origin"] = origin
确保您还设置了配置 ALLOWED_ORIGINS 的值。这可以在创建应用程序实例的任何地方完成。
app = Sanic(__name__)
app.config.ALLOWED_ORIGINS = ["http://mysite.com"]
如您所见,我们将将其添加到所有来自浏览器的响应中。我们如何知道这是一个浏览器请求?因为我们预期浏览器会添加 Origin 头部。
Access-Control-Expose-Headers
Access-Control-Expose-Headers 头部提供了服务器控制哪些头部可以被 JavaScript 访问的能力。这是一个安全措施,用于提供白名单控制,以确定浏览器应用程序可以访问哪些信息。
让我们在浏览器中开始添加一些测试。对于这些示例,我们将使用与之前类似的基本 HTML 结构。
-
我们首先设置 HTML。这里的目的是在 JavaScript 中读取
foobar头部并在屏幕上输出:<body> <h1>CORS Testing</h1> <h2 id="foobar">Loading...</h2> <script> const element = document.querySelector("#foobar") fetch("http://localhost:7777/").then(async response => { const text = await response.text() element.innerHTML = `foobar='${response.headers.get("foobar")}'` }) </script> </body>我们需要设置我们的应用程序以查看 HTML 并添加头部信息:
@app.get("/") async def handler(request): response = text("Hi") response.headers["foobar"] = "hello, 123" return response app.static("/test", "./test.html") -
为了验证我们的好奇心,我们将使用
curl重新检查响应,以确保确实发送了头部信息:$ curl localhost:7777 -i HTTP/1.1 200 OK foobar: hello, 123 content-length: 2 connection: keep-alive content-type: text/plain; charset=utf-8 Hi -
现在,打开您的浏览器到
http://127.0.0.1:7777/test。您应该看到:CORS Testing foobar=‘null’发生的事情是浏览器被阻止访问头部。如果我们想允许它,那么我们需要明确指出。
-
因此,回到我们正在构建的
add_cors_headers中间件,让我们添加以下片段:response.headers["access-control-expose-headers"] = "foobar"不要忘记,由于我们实际上是在浏览器上测试这个,我们需要适当地设置
ALLOWED_ORIGINS配置值:app.config.ALLOWED_ORIGINS = ["http://mysite.com", "http://127.0.0.1:7777"]这次当您访问浏览器时,您应该看到 JavaScript 能够深入获取
Foobar头部的值:CORS Testing foobar=‘hello, 123’
因此,如果您打算在应用程序的客户端使用任何类型的元数据,您需要正确使用 access-control-expose-headers。
Access-Control-Max-Age
当浏览器 确实 发出一个预检请求时,它有缓存该响应的能力,以便下次它发出相同的请求时不需要击中服务器。这种性能提升可以通过服务器使用 Access-Control-Max-Age 来控制(在一定程度上),该选项指定了预检请求可以被缓存的时长(以秒为单位)。
通常,网络浏览器会为此设置一个最大值。如果你尝试将其设置为一些荒谬的大数字,它们会将其降低到预定义的最大值。因此,我通常建议使用大约 10 分钟的值。一些浏览器允许你设置到 24 小时,但这可能就是允许的最大值了。
我们现在将在我们的中间件中看到这一点:
response.headers["access-control-max-age"] = 60 * 10
Access-Control-Allow-Credentials
这个头部仅用于预检请求。因此,我们将添加的片段需要放在我们的is_preflight(request)块内部。
当一个 JavaScript 应用程序发起请求时,它必须明确地调用允许发送凭证的调用。如果不这样做,浏览器就不会在请求中包含它们。然后服务器可以发挥作用,告诉浏览器这个包含凭证的请求是否安全地暴露给 JavaScript 应用程序。
要允许它,我们设置头部如下:
response.headers["access-control-allow-credentials"] = "true"
Access-Control-Allow-Methods
到目前为止,实际上并没有必要使用任何插件。添加这些 CORS 头部相对直接。然而,下一部分可能会变得稍微复杂一些。
Access-Control-Allow-Methods头部旨在在预检请求期间向浏览器发出警告,告知浏览器允许发送到端点的跨源 HTTP 方法。许多应用程序通过允许一切来禁用这种保护。
response.headers[
"access-control-allow-methods"
] = "get,post,delete,head,patch,put,options"
这当然是一个简单的解决方案。它比我在网上找到的第一个允许任何源头的 CORS 解决方案要安全得多。但,我们仍然可以做得更好。
为了实现动态方法,这些方法与实际端点可能性相匹配,我们将在我们的代码中做一些调整。
-
记得我们是如何定义预检请求的吗?让我们在请求中间件中提前这样做。
@app.on_request async def check_preflight(request: Request) -> None: request.ctx.preflight = is_preflight(request) -
接下来,当我们为
OPTIONS请求生成处理程序时,我们将注入一个包含所有允许方法的列表,如下所示:from functools import partial @app.before_server_start def add_info_handlers(app: Sanic, _): app.router.reset() for group in app.router.groups.values(): if "OPTIONS" not in group.methods: app.add_route( handler=partial( options_handler, methods=group.methods ), uri=group.uri, methods=["OPTIONS"], strict_slashes=group.strict, name="options_handler", ) app.router.finalize() -
现在我们已经可以在我们的选项处理程序中访问预检检查,我们可以在那里进行检查并添加头部。我们还可以将传入的方法列表连接成一个逗号分隔的列表。现在,这应该提供了一组自动化的
OPTIONS端点,这些端点正好使用了将要使用的 HTTP 方法。async def options_handler(request, methods): resp = response.empty() if request.ctx.preflight: response.headers["access-control-allow-credentials"] = "true" resp.headers["access-control-allow-methods"] = ",".join(methods) return resp -
我们将使用 curl 查看预检响应,以查看所有我们的头部信息:
$ curl localhost:7777 -X OPTIONS -H "Origin: http://mysite.com" -H "Access-Control-Request-Method: GET" -i HTTP/1.1 204 No Content access-control-allow-credentials: true access-control-allow-methods: GET,PATCH,POST vary: origin access-control-allow-origin: http://mysite.com access-control-expose-headers: foobar connection: keep-alive
Access-Control-Request-Headers
我们在这里关注的最后一个头部是Access-Control-Request-Headers,它也是应该在预检响应中发送的头部。它是向浏览器指示哪些非标准头部可以在跨源请求中发送的指示。
如果 JavaScript 想要发送一个名为counting的头部,那么它会这样做:
fetch("http://localhost:7777/", {
headers: {counting: "1 2 3"}
})
然而,由于这会触发预检请求,浏览器会因为服务器没有明确允许计数作为一个可接受的头而失败,并显示 CORS 错误。
要做到这一点,我们在预检块中启用它:
resp.headers["access-control-allow-headers"] = "counting"
我们对 CORS 头的审查增加了大量的代码。要查看完整版本,请查看 GitHub 仓库:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/07/corsissue。现在我们已经完成了 CORS 的审查,接下来是类似相关的话题:CSRF
保护应用程序免受 CSRF 攻击
我们旅程的下一步是处理跨站请求伪造(CSRF)。还应注意的是,这通常也带有缩写 XSRF。如果你在网上看到这两个词,它们指的是同一个问题。那么,这个问题是什么呢?
你知道你收到的那封可疑的尴尬电子邮件,上面写着“点击这里领取你的 500 美元奖金”吗?很可能那个链接会把你带到由试图黑客攻击你的人控制的恶意网站。他们可能放置了一些链接或者在他们的网站上诱导你做一些事情,从而向合法网站发送后台请求以执行不良行为。如果你的应用程序没有保护免受这种 CSRF 攻击,那么恶意行为者可能会诱导你的用户更改密码,而他们甚至都不知道这一点!
阻止这些攻击可以在两个方面进行。当然,你的用户可以更加小心,不要在垃圾邮件箱中打开他们的电子邮件。但作为负责任的 Web 应用程序开发者,你也有责任保护你的用户。
不起作用的解决方案
Cookies。如果你跳过前面的内容,提前查看我提供的解决方案,你会发现它确实包括了 cookies。确实,cookies 可以在解决问题中发挥作用。然而,它们是一个有缺陷的安全措施,不能单独作为解决 CSRF 问题的答案。
这究竟是如何工作的呢?想象一下,你在 cookie 中设置了一个会话 ID。它是一个相当好的随机字符混合,使得有人猜对它是不切实际的。问题是,cookie 是随着每个请求发送的,不是基于请求发起的地方,而是它要去的地方。所以,如果你的浏览器看到它存储了yourapplication.com的 cookie,那么即使请求是在h4ck3rsp4r4d1se.com发起的,浏览器也会发送 cookie。
还应注意的是,引入 TLS 和读取 Origin 头也不是充分的解决方案。当然,这些是应用程序应该执行的有用和有效的事情,但单独它们并不能提供对 CSRF 的保护。例如,Origin头很容易被欺骗。
起作用的解决方案
现在我们知道了什么不能保护我们免受 CSRF 攻击,我们可以看看一些可行的解决方案,它们将有助于保护我们的 Web 应用程序。这些方案不是相互排斥的,我建议您考虑以某种形式实施它们。当然,您的决定将取决于具体情况,但以下是一些在保护应用程序免受 CSRF 攻击时需要记住的良好实践。
不要在GET请求中改变状态
这非常重要。我们之前在第三章中讨论了这个问题,但是GET请求不应该改变状态。这意味着应用程序不应该从GET请求中获取任何指示去做某事。这些请求应该只用于获取信息。通过从黑客的武器库中移除GET,我们迫使他们在其恶意网站上使用 JavaScript 漏洞。
我们想要允许这样做的原因是,浏览器有一些内置的安全措施,我们知道这些措施,并且可以利用它们来获得优势。首先,从浏览器内部,无法伪造 Origin 头。
假设我们的不良网站中包含以下代码:
fetch("http://localhost:7777/", {
headers: {origin: "http://localhost:7777"}
})
如果您访问了somebadwebsite.com,源地址仍然是http://somebadwebsite.com。这就是为什么 CORS 保护起作用的原因。通过禁止GET请求进行状态改变,我们确保了这种类型的攻击不会成功:
<img src="http://yourapplication.com/changepassword?password=kittens123">
将黑客强制使用 JavaScript——尤其是那些被强制发出预检请求的 JavaScript 请求——在接下来我们将看到的情况下,这给了我们更多的控制权。
Cookies
下一个有用的解决方案涉及 Cookies。
等一下?Cookies 不是在“不起作用”的解决方案类别中吗?这是怎么回事?
我们刚刚说过,我们想要迫使恶意攻击者在其漏洞中使用 JavaScript。这是因为我们也知道浏览器 Cookies 有一个我们可以控制的功能:HttpOnly。当服务器创建一个 Cookies 时,它可以决定 JavaScript 是否应该能够访问该 Cookies。这意味着当启用时,Cookies 将在每个 Web 请求中继续发送,但任何 JavaScript 代码都无法访问它。这使得它成为存储像会话令牌这样的安全凭证的理想位置。如果没有这个,Cookies 就会受到所谓的跨站脚本攻击(也称为“XSS”)的威胁。这是一种攻击,其中一些黑客能够使用 JavaScript 从前端浏览器中提取安全细节。
重要提示
如果您的浏览器应用程序可以使用 JavaScript 访问某些信息,黑客也可以。
我们还提到,yourapplication.com的 Cookies 仍然可能从h4ck3rsp4r4d1se.com不知情地发送。由于 JavaScript 在允许访问 Cookies 时,只能在其当前域上操作,我们在构建解决方案时又多了一个工具可以使用。
当用户登录时,如果我们设置两个 cookie(一个用于会话,一个用于 CSRF 保护),我们可以根据预期的使用情况设置HttpOnly值。会话 cookie 保持不可访问,而专门为 CSRF 保护设置的 cookie 可以是 JavaScript 可访问的。然后我们可以要求 JavaScript 在发送请求时使用该 cookie 的值。这将有效,因为运行在h4ck3rsp4r4d1se.com上的 JavaScript 将无法访问标记为其他域的 cookie。
这个 cookie 的值应该是什么?好吧,实际上任何难以猜测的东西都可以。最好保持该值与用户特定相关,这样你才能验证其内容并确信令牌是真实的。此外,该值应该改变,而不是静态的。这将使任何潜在的攻击者更难攻击。这种双重 cookie 方法并非 100%无懈可击。但对于大多数应用程序的需求来说,应该是相当安全的。问题是当你的用户开始意外下载能够绕过浏览器保护的恶意软件时。我们将把这个问题放在一边,因为它超出了我们控制的能力,并且超出了这本书的范围的深入讨论。
应该注意的是,我们并不一定关心 CSRF 令牌可能被破坏并被恶意行为者使用。这是可以的。因为即使他们能够访问它,他们也没有办法发送带有正确的来源和正确的会话令牌。
表单字段
其他框架还使用另一种形式的 CSRF 保护。例如,Django 使注入一些隐藏的 HTML 到页面上的想法变得流行:
<input type="hidden" name="csrftoken" value="SOMETOKEN" />
此值将被包含在表单响应中,或者以某种预期的方式读取到请求中。这本质上是我在这里提出的完全相同的想法。唯一的区别是,我们不是将值注入到一个隐藏的(尽管可以通过 JavaScript 访问)位置输入中,而是将其存储在 cookie 中。这两种解决方案最终都将取决于下一节中该值发送回服务器时发生的情况。
将解决方案付诸实践
现在我们对我们的方法有一个大致的了解,让我们回顾一下,以便明确。我们希望只允许认证用户在我们的应用程序中进行状态更改。为了确保更改来自我们的用户而不是黑客,我们将在以下情况下允许更改:
-
HTTP 方法为
POST、PATCH、PUT或DELETE -
进入请求的来源与我们预期的相符
-
进入请求有一个使用
HttpOnly存储的 cookie -
进入的请求有一个有效的 CSRF 令牌
为了实现我们的目标,我们需要决定我们将把实现这个目标的代码放在哪里。因此,我们回到了我们已经看到几次的辩论:装饰器或中间件。没有正确的选择,答案当然将取决于你正在构建的内容。
对于我们的示例,我们将构建它作为一个装饰器。当我们进入下一节的认证时,将更清楚地了解为什么在这里使用装饰器模式。如果你认为中间件适合你,那就继续尝试将其重建为中间件。这两种选项都是合法的模式,可能在不同的环境下满足你的需求。然而,说实话,我通常发现装饰器模式更容易适应更广泛的使用案例。以下是步骤:
-
首先,我们将创建一个基本的装饰器。为了使工作更容易,你可以从 Sanic 用户指南中获取装饰器模板:
sanicframework.org/en/guide/best-practices/decorators.html#templates。def csrf_protected(func): def decorator(f): @wraps(f) async def decorated_function(request, *args, **kwargs): response = f(request, *args, **kwargs) if isawaitable(response): response = await response return response return decorated_function return decorator(func)当发生 CSRF 失败时,正确的响应应该是
403 Forbidden。我们将创建一个自定义异常,以便在发生这种情况时抛出:from sanic.exceptions import Forbidden class CSRFFailure(Forbidden): message = "CSRF Failure. Missing or invalid CSRF token." -
考虑到我们的目标和需求,我们想要以某种方式确定请求来自浏览器。这是因为浏览器请求将受到 CSRF 保护。没有必要在直接访问 API 请求上实现它。我个人喜欢通过在每个请求上添加一个
HttpOnlycookie(如果不存在的话)来做这件事。这个值完全无关紧要。我们唯一关心的是这个值被发送了。对于Origin头也是如此。如果发送了Origin,我们将假设这是一个浏览器请求,并对其施加我们接下来将施加的更严格的要求。这确实是一个腰带加背带的方法,因为它们有点重复。然而,它确实给你一个想法,在设计自己的解决方案时应该考虑哪些类型的策略。@app.on_request async def check_request(request: Request): request.ctx.from_browser = ( "origin" in request.headers or "browser_check" in request.cookies ) @app.on_response async def mark_browser(_, response: HTTPResponse): response.cookies["browser_check"] = "1" response.cookies["browser_check"]["domain"] = "mydomain.com" response.cookies["browser_check"]["httponly"] = True提示
在每个请求上标记
browser_checkcookie 是过度的。我通常建议在着陆页上这样做。或者,以某种方式捕捉到存在Origin但没有 cookie 设置的情况。我将把这个决定权交给你,以确定设置此 cookie 的适当位置和方法。如果你控制前端应用程序,你甚至可以考虑在那里设置它。这个 cookie 的目的只是给我们一个额外的指示,表明这不是一个直接访问 API 请求。 -
再次查看我们的需求列表,让我们在我们的装饰器装饰函数中添加一些代码,以确保来源匹配。这是必要的,因为我们已经知道当请求来自浏览器的 JavaScript 时,这个值不能被伪造:
origin = request.headers.get("origin") if request.ctx.from_browser and origin not in app.config.ALLOWED_ORIGINS: raise CSRFFailure -
我们下一个需求是确保存在一个
HttpOnly令牌。目前,我们将使用我们的browser_checkcookie。如果你有一个会话 cookie,这也可以满足:origin = request.headers.get("origin") if request.ctx.from_browser and ( origin not in app.config.ALLOWED_ORIGINS or "browser_check" not in request.cookies ): raise CSRFFailure -
最后,我们需要验证我们的 CSRF 令牌。我知道我们还没有讨论这是什么,如何生成它,所以当然我们还没有到达验证的部分。我们很快就会到达那里。在此之前,让我们简单地添加一个函数来完善我们的装饰器:
origin = request.headers.get("origin") if request.ctx.from_browser and ( origin not in app.config.ALLOWED_ORIGINS or "browser_check" not in request.cookies or not csrf_check(request) ): raise CSRFFailure
现在我们终于转向 CSRF 令牌。对于我们的实现,我们将使用 Fernet 令牌。这是一种使用密钥加密一些文本的方法,这样在没有该密钥的情况下就无法更改或读取。我们将把这个令牌设置在一个 cookie 中,这个 cookie 将明确地不是HttpOnly。我们希望前端 JavaScript 应用程序读取这个值,并通过头部将其发送回应用程序。当可能有害的状态改变请求到来时,我们将验证头部和 cookie 是否匹配。我们还将提取 Fernet 令牌的有效负载并验证其内容。该令牌的实际值我们将存储在第二个 cookie 中,该 cookie 将是HttpOnly。这个双重 cookie 和双重提交验证的目的是为了保护我们的应用程序免受可能破坏我们策略的各种攻击。解决方案可能听起来比实际要复杂得多,所以让我们看看一些代码来开始拼凑这个解决方案:
-
我们将首先设置一些我们将需要的配置值。
app.config.CSRF_REF_PADDING = 12 app.config.CSRF_REF_LENGTH = 18 app.config.CSRF_SECRET = "DZsM9KOs6YAGluhGrEo9oWw4JKTjdiOot9Z4gZ0dGqg="重要提示
毫不奇怪,你绝对不应该,绝对不应该,绝对不应该在你的应用程序中硬编码这样的秘密。这只是为了示例目的。相反,你应该通过环境变量或比这更安全的方法注入秘密值。
-
我们需要一个函数来生成我们的 CSRF 引用值和令牌。为了完成这个任务,我们将使用本章开头提到的加密库。它经过实战检验,是可靠的。它应该是我们在 Python 中解决所有加密需求时的首选之地。以下是代码:
from base64 import b64encode from cryptography.fernet import Fernet def generate_csrf(secret, ref_length, padding) -> Tuple[str, str]: cipher = Fernet(secret) ref = os.urandom(ref_length) pad = os.urandom(padding) pretoken = cipher.encrypt(ref) return ref.hex(), b64encode(pad + pretoken).decode("utf-8")如您所见,这相当简单。我们使用我们的密钥创建加密对象。然后,根据加密库的建议,我们使用操作系统的随机生成器逻辑,通过
os.urandom来生成我们的引用值和一些额外的填充。引用值被加密,然后我们的令牌被填充并返回,同时附带引用值。 -
为了验证我们的令牌,我们需要执行这些步骤的逆操作,并将加密值与传递的引用值进行比较:
def verify_csrf(secret, padding, ref, token): if not ref or not token: raise InvalidToken("Token is incorrect") cipher = Fernet(secret) raw = b64decode(token.encode("utf-8")) pretoken = raw[padding:] encoded_ref = cipher.decrypt(pretoken) if ref != encoded_ref.hex(): raise InvalidToken("Token is incorrect") -
我们需要一种方法来确保这些值作为 cookie 存在。因此,我们将在这个示例中在中间件中生成它们。然而,在登录端点执行此功能可能更合理:
@app.on_response async def inject_csrf_token(request: Request, response: HTTPResponse): if ( "csrf_token" not in request.cookies or "ref_token" not in request.cookies ): ref, token = generate_csrf( request.app.config.CSRF_SECRET, request.app.config.CSRF_REF_LENGTH, request.app.config.CSRF_REF_PADDING, ) response.cookies["ref_token"] = ref response.cookies["ref_token"]["domain"] = "localhost" response.cookies["ref_token"]["httponly"] = True response.cookies["csrf_token"] = token response.cookies["csrf_token"]["domain"] = "localhost"记住,我们的计划是让
csrf_token可由 JavaScript 访问。我们希望传入的请求不仅包含在 cookie 值中,还要在 HTTP 头部注入这个值。由于同源策略,这只能通过在我们应用程序上运行的 JavaScript 来完成。CORS 来拯救。这意味着,不要忘记白名单我们即将看到的请求头部:X-XSRF-Token。
记得在我们之前的 @csrf_protected 装饰器中,有一个检查是 csrf_check(request)。现在我们终于要揭露这个函数是什么了:
def csrf_check(request: Request):
csrf_header = request.headers.get("x-xsrf-token")
csrf_cookie = request.cookies.get("csrf_token")
ref_token = request.cookies.get("ref_token")
if csrf_header != csrf_cookie:
raise CSRFFailure
try:
verify_csrf(
request.app.config.CSRF_SECRET,
request.app.config.CSRF_REF_PADDING,
ref_token,
csrf_cookie,
)
except InvalidToken as e:
raise CSRFFailure from e
return True
我们应该关注三个值:我们刚刚设置的两个 cookie 和传入的 X-XSRF-Token 头部。正如我们所知,这个头部将在客户端生成,通过提取 cookie 并将其值注入头部。现在,简单地验证这一点就足够了:
-
cookie 和头部匹配
-
受保护的
HttpOnly引用值与加密值相同
如果所有检查都无误,我们可以确信请求是真实的。
提示
你可能想知道为什么我选择在这里使用 XSRF 而不是
X-CSRF-Token,甚至只是CSRF-Token作为头部名称。原因是一些前端框架会自动为你客户端添加这个头部注入。由于从我们的角度来看,头部的名称并不重要,我们不妨与其他喜欢这样命名的工具友好地合作。
Samesite cookies
你可能熟悉 CSRF 保护中的一个较新的概念,称为 samesite cookies。这是一个可以附加到 cookie 上的值,为浏览器提供了额外的指示,说明如何处理该 cookie。简而言之,通过在服务器上的 cookie 上设置此值,我们允许应用程序告诉浏览器何时可以发送 cookie,何时不可以。仅此一项几乎可以缓解 CSRF 的问题,但它不应单独作为解决方案使用。
事实上,开放网络应用安全项目 (OWASP)——一个促进在线安全实践增强的非营利性基金会——明确指出,samesite 属性“不应取代 CSRF Token。相反,它应该与该令牌共存,以便以更稳健的方式保护用户。” cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#samesite-cookie-attribute
现在,我们将了解 samesite cookie 保护以及如何将其集成到我们的解决方案中。有三个允许的值:None、Lax 和 Strict
Samesite=None
使用 Samesite=None 的 cookies 应仅考虑用于非安全相关的 cookies。这是因为它们将与每个请求一起发送,无论它们来自哪个网站。因此,如果你在黑客的网站上,那个黑客将能够代表你向其他你访问过的网站提交请求,并利用你电脑上的 cookies。这可不是什么好事。
但是,对于正确类型的 cookies,这实际上并不是一个问题。只要这个值与安全或会话无关,这是可以接受的。然而,也应该注意的是,为了使这个功能生效,它也只有在 cookie 被标记为 Secure 时才允许。也就是说,它只允许在 https 请求中传递。在你的生产级代码中,你应该始终这样做。你使用 TLS 加密吗?如果不使用,我们将在第八章和第十章中看到这个问题的简单解决方案。
设置 Samesite=None 就像以下这样简单:
response.cookies["myfavorite"] = "chocolatechip"
response.cookies["myfavorite"]["domain"] = "mydomain.com"
response.cookies["myfavorite"]["samesite"] = None
response.cookies["myfavorite"]["secure"] = True
这将导致以下 cookie:
Set-Cookie: myfavorite=chocolatechip; Path=/; Domain=mydomain.com; SameSite=None; Secure
Samesite=Lax
现在大多数现代网络浏览器默认就是这样。然而,你不应该依赖于这个事实,并且明确地这样做仍然是最佳实践。
这个值是什么意思?它的意思是,我们一直担心的跨站 POST 请求将不会包含 cookies(这是 CSRF 保护的一大部分)。然而,在某些情况下,它将允许它们存在。要在一个跨站请求中发送,请求必须是顶级导航(可以想象成浏览器的地址栏),并且 HTTP 方法必须是 GET 或 HEAD。
这基本上归结为对 AJAX 请求的保护,但允许当有人从第三方链接导航到网站时发送 cookie。这实际上很有道理,可能是你想要为许多 cookies 使用的。
例如,如果你的会话 cookies 没有被设置为 Lax(而是 Strict),当有人从另一个网站点击链接来到你的网站时,他们不会显示为已登录。然而,一旦他们开始浏览,他们的会话突然出现。这可能会给用户带来尴尬的体验。因此,建议对于大多数典型应用,会话管理和认证 cookies 应使用 Lax。如果你正在构建一个安全的银行应用程序,你可能不希望有人链接到安全的银行页面,也许 Lax 不是一个正确的答案。然而,通常使用 Lax 进行认证是可以接受的。
如前所述,你不再需要明确声明 samesite 属性,但明确总是优于隐含。
response.cookies["session_token"] = session_token
response.cookies["session_token"]["domain"] = "localhost"
response.cookies["session_token"]["httponly"] = True
response.cookies["session_token"]["samesite"] = "lax"
response.cookies["session_token"]["secure"] = True
这将生成一个看起来像这样的 cookie:
Set-Cookie: session_token=<TOKEN>; Path=/; Domain=localhost; HttpOnly; SameSite=lax; Secure
Samesite=Strict
如上一节所暗示的,只有当请求来自正确的网站时,才会发送Strict cookie。这意味着用户必须首先登录你的应用,然后才能提交请求。在我看来,这听起来就像是一种会改变状态的请求。你明白我的意思了吗?
在我看来(而且你无疑会遇到不同的观点),CSRF 保护 cookie 应该设置为Samesite=Strict。至少在我的应用中,我想不出任何合法的使用场景(至少不是我想保护的请求类型),我会希望用户在发起这些请求之前先登录我的应用。你可能有不同的需求,这可能不适合你。如果你觉得Lax更合适,那就按你的去做。我会坚持我的选择:
response.cookies["ref_token"] = ref
response.cookies["ref_token"]["domain"] = "localhost"
response.cookies["ref_token"]["httponly"] = True
response.cookies["ref_token"]["samesite"] = "strict"
response.cookies["ref_token"]["secure"] = True
response.cookies["csrf_token"] = token
response.cookies["csrf_token"]["domain"] = "localhost"
response.cookies["csrf_token"]["samesite"] = "strict"
response.cookies["csrf_token"]["secure"] = True
如你大概能猜到的,我们的 cookie 现在看起来是这样的:
Set-Cookie: ref_token=<TOKEN>; Path=/; Domain=localhost; HttpOnly; SameSite=strict; Secure
Set-Cookie: csrf_token="<TOKEN>"; Path=/; Domain=localhost; SameSite=strict; Secure
重要提示
正如之前提到的,samesite cookie 的支持并不是普遍的。你应该检查像 CanIUse 这样的网站,看看你目标浏览器是否实现了它:
caniuse.com/same-site-cookie-attribute。此外,在这个上下文中,“相同”的网站也包括子域名。有一个公共地址列表被认为是这个上下文中的“顶级”地址,它并不完全与.com、.org、.io 等匹配。例如,github.io 上的两个网站不被认为是 samesite。完整的列表,请查看这里:publicsuffix.org。
在我们对 CSRF 的审查中,提到了很多关于会话令牌和身份验证的内容,但我们还没有探讨这一点。虽然这是一个非常深入的话题,我们将探讨如何使用 Sanic 在你的应用中实现身份验证。
使用身份验证保护你的 Sanic 应用
当许多人思考一个 Web 应用时,他们脑海中浮现的是一种在 Web 上的平台类型,他们登录后去做…某件事。这里的活动并不是我们关心的。当你读完这本书后,你将去构建一些令人惊叹的应用。我们关心的是旅程和过程。而我们现在关心的过程部分是:登录。
更具体和准确地说,我们即将探讨的是身份验证,而不是那么多的授权。虽然这两个概念非常紧密相关,但它们并不相同,也不能互换。事实上,授权通常假定身份验证已经发生。
有什么区别?
-
身份验证:回答的问题是:你是谁?
-
授权:回答的问题是:你被允许做什么?
更让人困惑的是,身份验证失败会返回401 Unauthorized响应。这是互联网早期的一个非常不幸的命名。授权失败会返回403 Forbidden响应。
在 2020 年,我在 EuroPython 会议上讨论了访问控制问题。幻灯片和 YouTube 演示文稿的链接在我的 GitHub 页面上:github.com/ahopkins/europython2020-overcoming-access-control。如果你有大约 30 分钟的时间观看关于这个激动人心的主题的引人入胜的演示,这是一个“不容错过”的机会。
演示涵盖了认证/授权这个主题,但同时也很大程度上试图回答这个问题:“保护我的 API 的不同方法有哪些?”它通过比较基于会话的认证与非基于会话的(即无状态)来回答这个问题。我们在这里将回顾这两种策略,同时也会包括如何实现 API 密钥(这在那个演示中未涉及)。
要做到这一点,有一系列问题需要回答。在我们深入探讨如何使用 Sanic 实现一些常见策略之前,我们将回顾一些在决定策略之前你应该问自己的问题。
-
谁将消费这个 API?你应该考虑 API 是否将被其他应用程序或脚本使用,或者由实际的人使用。它将被用于将集成到其应用程序中的程序员吗?或者,它将被用于为移动应用程序提供动力?前端 JavaScript 应用程序需要访问它吗?你应该关心的原因是你必须了解你的预期用例的技术能力,但也要了解其弱点。如果你的 API 将仅由其他后端脚本和应用程序消费,那么你将更容易保护它。我们之前讨论的大部分关于 cookie 的内容都高度不相关;而且 CORS 不是一个问题。另一方面,如果你打算为基于浏览器的单页应用程序提供动力,那么你可能需要一个比简单的 API 密钥更健壮的认证策略。
-
你是否控制着客户?这个问题的核心在于你(或你的组织)是否会成为 API 的消费者。将此与旨在被集成和其他应用程序消费的 API 进行对比,你应该看到这会在你如何控制访问上产生差异。例如,如果你正在构建一个不面向互联网、仅存在于高度受控网络中的微服务,那么你显然会有与为你的银行网站提供动力的 API 不同的安全担忧。
-
这将支持一个网络浏览器前端应用程序吗?这实际上是第一个问题的子集,但它的重要性足以单独考虑。这个问题之所以如此重要,是因为浏览器存在缺陷。当互联网最初被创建,并且网络浏览器最初被发布时,没有人能够准确预测互联网的方向和重要性。安全问题是——以及缓解这些问题的解决方案——是在多年的黑客试图利用一个从未真正以安全为首要考虑的系统后产生的。例如,在当今世界,非加密的
http://网站甚至存在,这真的很令人震惊。到目前为止,本章已经投入了大量精力来探讨如何处理仅因为网络浏览器存在缺陷而存在的某些安全问题。因此,知道你的应用程序甚至存在前端使用的可能性,应该在你早期就触发警告,你必须为此话题投入时间和精力。
在心中牢记这三个问题后,我们现在将探讨三种潜在的认证用户方案。但首先,还有一个提醒,即我这里所做的一些事情并不意味着你也应该这样做。运用你的技能,利用所提供的内容构建你应用程序所需的解决方案。我们谈论的是安全,所以在你走得太远之前可能需要小心。如果你对某个策略有疑问,随时可以将问题带到 Discord 社区或论坛上。
接下来,我们将探讨你可能会发现的一些策略。
使用 API 密钥
到目前为止,API 密钥是最简单的认证方案。它们易于设置,也易于最终用户实施。这也意味着它们提供的安全性较低。但这并不意味着它们应该被忽视。在适当的背景下,如果采取缓解安全问题的措施,API 密钥可以成为完成工作的确切工具。
API 密钥有许多名称,但它们归结为一个简单的概念:你的应用程序提供了一个安全的持久令牌。当请求伴随该令牌时,它就会生效。如果没有,它就会失败。就是这样简单。除了简单之外,主要好处之一是密钥易于作废。由于你将密钥存储在某个地方,你只需要更改存储的值或删除它,该密钥就不再有效。
API 密钥更容易受到攻击的原因是它们是一个单一、持久的值。这意味着理论上更容易对其进行暴力破解。黑客可以设置一台机器并尝试每一个组合,直到找到一个有效的。因此,确保您的 API 方案安全的第一步是使用强密钥。这意味着需要高熵量。
一旦生成了一个足够复杂的 API 密钥,在存储之前应该对其进行散列。不要加密你的密钥。散列和加密有什么区别?当你“加密”数据时,它可以被反转。就像我们看到的 Fernet 加密一样,我们能够反转这个过程并解密原始值。这对于 API 密钥来说是不允许的。另一方面,散列是一条单行道。一旦散列,就无法恢复原始值。因此,为了验证值,你需要使用相同的策略对传入的值进行散列,并将结果与存储的散列值进行比较。
这听起来可能像是密码管理,对吧?这是因为你应该基本上将 API 密钥当作密码来处理。这提出了使用 API 密钥时的第二个潜在安全陷阱:存储。永远不要以纯文本形式存储,永远不要以原始值可以恢复的格式存储,永远不要以散列值可以轻易预测的方式存储。
一旦你得到了新生成密钥的值,你将在存储之前添加一个“盐”。密码盐是一段随机文本,它被添加到密码中,以便在密码被散列时,它以不可预测的格式进行。如果你不加盐密码,那么散列值可以通过与常见密码的已知散列值进行比较而被破解。黑客出于这个原因保留了常见密码的散列值数据库。即使他们可能无法解密散列值,如果你没有加盐,那么他们通过简单地查看已知值就可以非常容易地反向工程出值。幸运的是,bcrypt模块使这变得简单。让我们深入一些代码。
-
我们将首先创建一个生成 API 密钥的函数。为此,我们将使用来自 Python 标准库的
secrets模块。在我们的例子中,我们将使用secrets.token_urlsafe来生成值。你也可以使用secrets.token_hex,但它将产生一个稍长的字符串来表示相同的值。我建议使用这个库及其默认设置的原因是,Python 的维护者将根据当前最佳实践更改所需的熵量。在撰写本文时,默认值是 32 字节。如果你觉得需要更多,你可以自由地增加这个值:from secrets import token_urlsafe from bcrypt import hashpw, gensalt def generate_token(): api_key = token_urlsafe() hashed_key = hashpw(api_key.encode("utf-8"), gensalt()) return api_key, hashed_key我们还使用了
bcrypt模块来生成盐。这样做是添加随机文本,创建散列,然后重复这个过程几次。通过将散列值与多轮盐值折叠,它变得难以与已知值进行比较(它也变得计算上更昂贵,所以设置得太高可能会非常耗时)。我们将使用gensalt并使用默认的 12 轮。 -
你需要一个生成并存储这些值的端点。一个典型的实现将有一个前端 UI,用户点击按钮生成 API 密钥。该值在屏幕上显示的时间足够长,以便他们复制。一旦他们离开,该值就会消失,无法恢复。在后端,这意味着我们需要一个端点,该端点使用
generate_token,将 API 密钥发送给用户,并将散列密钥存储在数据库中:@app.post("/apikey") async def gen_handler(request): api_key, hased_key = generate_token() user = await get_user_from_request(request) await store_hashed_key(user, hased_key) return json({"api_key": api_key})作为提醒,你可以回顾一下 第四章 中关于如何从请求中提取数据以获取用户等策略。在上文中,
get_user_from_request是一个占位符,表示你将根据传入的请求提取用户信息。同样,由于我们还没有查看如何与数据库交互,store_hashed_key只是一个占位符,表示你需要使用用户和散列密钥以某种方式存储值。 -
我们将创建一个新的装饰器来保护我们的 API 密钥。在这个装饰器中,我们将从请求中提取用户,并将散列密钥与用户发送的内容进行比较:
from bcrypt import checkpw from sanic.exceptions import Unauthorized def api_key_required( maybe_func=None, *, exception=Unauthorized, message="Invalid or unknown API key" ): def decorator(f): @wraps(f) async def decorated_function(request, *args, **kwargs): user = await get_user_from_request(request) is_valid = checkpw(request.token.encode("utf-8"), user.hashed_key) if not is_valid: raise exception(message) response = f(request, *args, **kwargs) if isawaitable(response): response = await response return response return decorated_function return decorator(maybe_func) if maybe_func else decorator在这里指出的一点是,Sanic 会为我们从
Authorization标头中提取一个令牌。将令牌发送在头部的这种方案被称为所谓的 bearer tokens。它们看起来像这样:Authorization: Bearer <token_here>或者:
Authorization: Token <token_here>因此,要获取该令牌的访问权限,你只需要使用
request.token,Sanic 就会从任一位置找到它。 -
现在,要实现这一点,我们只需要将我们的端点包装起来:
@app.get("/protected") @api_key_required async def protected_handler(request): return text("hi")
另一点需要指出的是,当出现问题时未能使用正确的状态码和异常消息,这会存在固有的安全漏洞。我们之前在 第六章 中提到过这一点,并且在这里看到如何解决这个问题是有价值的。你可能已经注意到,我们允许装饰器传递一个异常类和消息。这样做是为了让我们能够控制发送给最终用户的信息。
现在我们已经看到实现 正确 的 API 密钥是多么容易,唯一剩下的问题是:何时使用它们是合适的?
永远不要使用 API 密钥来保护基于浏览器的 UI。
API 密钥提供的安全性不足以处理浏览器由于存储凭据而引发的所有问题。这实际上只适用于来自外部脚本或应用程序的集成。
由于这个原因,我喜欢使用我们在本章早期创建的 check_request 中间件,以及我的授权装饰器。由于 @api_key_required 从来不应该对来自浏览器的请求有效,我喜欢将其改为:
if not is_valid:
raise exception(message)
到这里为止:
if request.ctx.from_browser or not is_valid:
raise exception(message)
现在我们知道了如何以及何时使用 API 密钥,让我们看看在适合 Web 应用的场景中处理身份验证的方法。
理解基于会话和非会话的身份验证
用户会话可能是处理 Web 应用程序中身份验证最常见的方法。一种较新的策略采用被称为JSON Web Tokens (JWT)的令牌。在大多数其他情况下,你会听到它们被称作有状态与无状态。用户会话是有状态的,JWT 是无状态的。这些都是真的,但我喜欢将它们称为基于会话和非基于会话。叫我叛逆者吧,但我觉得这样更清楚地描述了我们试图实现的目标。
首先,什么是会话?如果一个用户登录到你的应用程序,并且你在数据库中记录这次登录以便可以随意使其失效,那么你就是在创建一个会话。这意味着只要这个记录存在于你的数据库中,就有一个活跃的会话可以用来验证该特定用户。
基于会话的身份验证在前后端都非常简单易实现。而且,因为它提供了高度的安全性,这就是为什么它已经成为许多 Web 应用程序的默认方法。它的一大好处是任何活跃的会话都可以在任何时候被停用。你有没有在 Web 应用程序(可能是你的电子邮件提供商)上看到列出你所有登录位置的情况?点击一个按钮,你就可以注销其他位置。这在会话被破坏或被黑客攻击的情况下非常有帮助。
另一方面,非会话基于身份验证提供了更大的灵活性。非会话基于令牌的典型例子是 JWT。所以尽管我在具体谈论 JWT,但它们并不是处理非会话基于身份验证的唯一方式。这种策略提供最关键组件是令牌本身是自我认证的。这意味着服务器只需要查看令牌就能确定它是否真实,以及是否被篡改。
由于这个原因,JWT 的身份验证变得高度便携。你可以有一个处理身份验证和生成令牌的微服务,然后其他服务可以验证它们,而无需涉及任何身份验证服务!这允许非常可扩展的架构。这也突出了另一个好处。每次收到会话令牌时,为了验证它,你必须对你的存储引擎进行往返调用。这意味着每个 API 调用至少包含一个额外的网络调用到数据库。通过自我认证的令牌,这可以完全避免,并可能导致整体性能的提升。
JWT 特别的好处是它们可以嵌入非秘密的有效负载。这通常意味着你可以包含一个权限列表,或者关于用户的前端应用程序可以使用的元信息。
这听起来很棒,但 JWT 的缺点是,一旦发行,它们就不能被撤销。当它们被创建时,它们会被赋予一个过期时间。令牌将保持有效,直到那个时间过期。这就是为什么这些过期时间通常非常短,通常以分钟为单位(而不是像会话那样可能是小时或天)。如果一个令牌每十分钟过期一次,那么对于网络应用程序用户来说,需要频繁地重新登录将非常不方便。因此,JWT 通常伴随着刷新令牌。这个令牌是一个值,允许用户用新的 JWT 交换过期的 JWT。
此外,基于会话的令牌通常更容易通过使用我们之前看到的 HttpOnly cookies 来保护免受 XSS 攻击。由于 JWT 通常像 API 密钥一样作为携带令牌发送,实现它们也意味着我们需要重新考虑如何在浏览器内部保护它们。如果你在思考所有试图以既安全又用户友好的方式实现 JWT 的担忧,那么你并不孤单。将 JWT 添加到应用程序中肯定比会话更复杂。因此,在决定使用哪种策略时,你必须考虑你特定的应用程序需求。
“等等!” 你可能正在对自己说,“*如果 JWT 有这么多好处,为什么不把它们当作会话令牌来处理,并将它们存储为 cookies?此外,我们可以通过将它们与黑名单进行比较来绕过令牌的失效!然后,我们可以使它们更长,并在想要登出或使它们失效时将它们添加到黑名单中。两个问题都解决了。””
是的,这是真的。让我们依次查看这两个建议。
首先,将 JWT 存储为类似于会话令牌的 cookie 是可行的。但是,你现在失去了一个很大的好处:认证的有效载荷。记住,其中一个好处是它们可以携带元数据,这些数据可以被你的前端应用程序使用。如果它们被困在 HttpOnly cookie 中,那么这些信息将不可用。(当我们查看 JWT 实现时,我们将探讨解决这个问题的一种方法)。
其次,如果你在维护一个令牌黑名单以允许撤销或使令牌失效,那么你不再使用基于非会话的认证。相反,你正在使用基于 JWT 的会话方案。这是可以接受的,人们确实这么做。然而,这使得你的令牌更不便携,因为它们需要一个集中的存储库来验证,并且还需要额外的网络调用。自行承担风险。
现在我们转向 Sanic 中的实现策略。因为我们还没有查看数据库实现,所以当需要获取和存储信息时,我们仍然会使用一些替代函数。现在先尝试了解这些细节,因为我们更关注如何处理不持久化数据的身份验证。如果你查看 GitHub 仓库中的这些示例,将会有一些这些函数的模拟版本,以便使示例能够运行。现在尽量不要纠结于这些细节。
使用会话
在阅读了基于会话的身份验证与非基于会话的身份验证这一节之后,你已经决定状态会话是你的应用程序的正确选择。太好了,你实际上已经知道了你需要的几乎所有东西。
我们已经看到了如何处理密码(与 API 密钥相同)。因此,实现登录路由应该是简单的。
我们已经知道会话令牌需要不从 JavaScript 中访问,以对抗 XSS 攻击。因此,我们将使用HttpOnlycookie。
我们也知道,仅使用HttpOnlycookie 会使应用程序容易受到 CSRF 攻击。因此,我们将我们的实现与之前提出的 CSRF 保护方案相结合。
还剩下什么?不多。我们需要为以下内容创建端点:
注册用户(负责安全地存储密码);
登录(接受用户名和密码并验证它,就像在 API 密钥示例中一样,创建会话密钥,存储它,并将其设置为 cookie);以及
登出(从数据库中删除会话)。
这是一个很好的机会,让你尝试根据这些要求构建自己的解决方案。放下这本书,构建这三个端点。如果你卡住了,GitHub 仓库中有一个示例解决方案。
为了保护您的端点,我们将采用与装饰器类似的方法。你还记得我们之前构建的@csrf_protected装饰器吗?如果你正在使用基于会话的身份验证,那么我建议将这个装饰器与我们正在构建的装饰器结合起来。它们很好地互补,这样就可以更容易地正确保护您的端点。
-
这里是我们将如何重建它的方法。我们正在添加一个类似于 API 密钥装饰器的块,如果会话验证失败,它将引发异常:
def session_protected( maybe_func=None, *, exception=Unauthorized, message="Invalid or unknown API key" ): def decorator(f): @wraps(f) async def decorated_function(request, *args, **kwargs): origin = request.headers.get("origin") if request.ctx.from_browser and ( origin not in app.config.ALLOWED_ORIGINS or "browser_check" not in request.cookies or not csrf_check(request) ): raise CSRFFailure session_token = request.cookies.get("session_token") if not session_token or not await verify_session(session_token): raise exception(message) response = f(request, *args, **kwargs) if isawaitable(response): response = await response return response return decorated_function return decorator(maybe_func) if maybe_func else decorator -
会话验证确实取决于你的数据库实现。但,一般来说,它应该看起来像这样:
async def verify_session(session_token): try: await get_session_from_database(session_token): except NotFound: return False return True
如果会话令牌存在,那么我们可以继续。如果不存在,则返回 False。
如你所见,一旦你有了从数据库存储和检索数据的基本功能,会话通常很容易实现。我们现在转向更复杂的替代方案。
JWT (JSON Web Token)
所以,你已经阅读了关于基于会话和非会话认证的部分,并决定实施 JWT。现在怎么办?我们需要解决的问题是,在前端应用程序中充分利用它们会带来两个问题:
-
如何存储和发送它们,既不牺牲功能也不牺牲安全性?
-
如何在不牺牲安全性的情况下保持合理的用户体验?
我们将依次解决这些问题,然后开发一个既能让我们满意的解决方案。
要 cookie,还是不要 cookie?
在决定如何发送访问令牌(请注意,从现在开始,访问令牌与 JWT 同义)时,有两个相互竞争的利益:可用性和安全性。如果我们通过头部发送令牌,它看起来会是这样:
Authentication: Bearer <JWT>
为了实现这一点,我们需要一些客户端 JavaScript 来读取值并将其注入到我们的请求中:
const accessToken = document.cookie
.split('; ')
.find(row => row.startsWith('access_token='))
.split('=')[1]
fetch(url, {headers: {Authorization: `Bearer ${accessToken}`}})
你现在可能已经怀疑了这个问题:XSS 漏洞!如果我们的前端应用程序可以从 JavaScript 中访问令牌,那么这意味着任何恶意脚本也可以。真糟糕。
重要提示
你可能自己在想,为什么 JWT 被存储在客户端的 cookie 中,而不是在 web 存储(无论是 localStorage 还是 sessionStorage)中?原因在于,这两个解决方案都非常适合处理非敏感细节。它们容易受到我们试图防止的 XSS 攻击。你可能在网上看到很多建议说你可以用这些来存储 JWT。不要这么做! 这里提供的解决方案将更加安全,并且仍然不会牺牲可用性。这只需要在服务器端做一点额外的工作,所以请耐心等待,不要匆忙选择这个次标准的替代方案。
为了解决这个问题,我们使用HttpOnly,并让我们的应用程序自己发送 cookie。在这种情况下,我们将依赖服务器根据需要写入和读取 cookie。但是,在这样做的时候,我们无法访问 JWT 的有效载荷。还有我们之前已经看到几次的 CSRF 问题,但现在你应该已经理解如何解决这个问题。如果不理解,请回到本章阅读保护应用程序免受 CSRF 攻击的部分。
一种选择是在你首次登录时返回访问令牌的有效载荷。这些细节可以安全地存储在 web 存储中,并在需要时使用。在服务器上,这可能看起来像这样:
@app.post("/login")
async def login(request):
user = await authenticate_login_credentials(
request.json["username"],
request.json["password"],
)
access_token = generate_access_token(user)
response = json({"payload": access_token.payload})
response.cookies["access_token"] = access_token
response.cookies["access_token"]["domain"] = "localhost"
response.cookies["access_token"]["httponly"] = True
response.cookies["access_token"]["samesite"] = "lax"
response.cookies["access_token"]["secure"] = True
return response
我支持这种方法,并且它肯定能行。你可以访问有效载荷,并且有安全的方式来传输和存储访问令牌。
第二个选择是使用分割的 cookie。关于这一点,稍后我会详细说明。您可以随意跳过,或者回到本章开头我提到的那个 EuroPython 演讲,我在那里讨论了这种方法。
“您的会话在 10 分钟后已过期,请重新登录?”
你是否曾访问过这样做的一个网站?通常,这是银行或金融应用程序,因为它们担心用户从电脑上站起来离开,留下登录会话。也许这正是你的需求,太好了!你可以安心地使用 JWT 作为解决方案,并且无需担心频繁地使令牌过期。
然而,对于大多数应用程序来说,这会导致糟糕的用户体验。
记住,我们之所以在如此短的时间内使访问令牌过期,是为了减少潜在的攻击面。如果令牌落入错误的手中,它只能在一个非常小的窗口中使用。过期时间越短,令牌就越安全。
解决这个问题的解决方案需要一点前端复杂性。但,我认为它提供的保护是值得的。实际上有两种解决方案你可以选择:
使用 JavaScript 的 setInterval 在用户不知情的情况下定期发送请求以刷新令牌
将你的 JavaScript fetch 调用包裹在一个合适的异常处理器中。它捕获了提交了过期令牌的场景,发送请求来刷新令牌,然后使用新的令牌重试原始请求
随意选择对你来说有效的方法。GitHub 仓库有一些实现每种策略的示例 JavaScript 代码。
为了实现刷新令牌,我们将借用我们之前用于制作 API 令牌的一些概念。当用户执行登录时,我们将继续生成访问令牌,但我们将通过重用 API 令牌逻辑生成和存储刷新令牌。
-
创建一个同时生成和存储刷新令牌的登录端点:
@app.post("/login") async def login(request): user = await authenticate_login_credentials( request.json["username"], request.json["password"], ) access_token = generate_access_token(user) refresh_token, hased_key = generate_token() await store_refresh_token(user, hased_key) response = json({"payload": access_token.payload}) response.cookies["access_token"] = access_token response.cookies["access_token"]["domain"] = "localhost" response.cookies["access_token"]["httponly"] = True response.cookies["access_token"]["samesite"] = "lax" response.cookies["access_token"]["secure"] = True response.cookies["refresh_token"] = refresh_token response.cookies["refresh_token"]["domain"] = "localhost" response.cookies["refresh_token"]["httponly"] = True response.cookies["refresh_token"]["samesite"] = "strict" response.cookies["refresh_token"]["secure"] = True return response返回到 使用 API 密钥 部分,查看
generate_token函数。 -
要发行新的访问令牌,我们需要创建一个新的端点来验证刷新令牌(就像我们验证 API 令牌一样)。作为额外的安全级别(因为从浏览器来的单一认证点不是一个好主意),即使它已经过期,我们也将要求提供一个之前发行的访问令牌:
from bcrypt import checkpw from sanic.exceptions import Forbidden from sanic.response import empty @app.post("/refresh") async def refresh_access_token(request): user = await get_user_from_request(request) access_token = request.cookies["access_token"] refresh_token = request.cookies["refresh_token"] is_valid_refresh = checkpw( refresh_token.encode("utf-8"), user.refresh_hash ) is_valid_access = check_access_token(access_token, allow_expired=True) if not is_valid_refresh or not is_valid_access: return Forbidden("Invalid request") access_token = generate_access_token(user) response = empty() response.cookies["access_token"] = access_token response.cookies["access_token"]["domain"] = "localhost" response.cookies["access_token"]["httponly"] = True response.cookies["access_token"]["samesite"] = "lax" response.cookies["access_token"]["secure"] = True return response
我们还没有看到如何验证 JWT,所以不要担心你不确定如何实现 check_access_token。我们将在下一步做那件事。
解决基于浏览器的应用程序中的 JWT
到现在为止,我们一般已经理解了我们想要实现的目标。我们现在需要关注的是:
-
如何生成访问令牌
-
如何验证访问令牌(无论是带过期还是不带过期)
-
如何“分割”令牌使其可用且安全
要生成令牌,我们将使用 pyjwt。我们首先需要做的是创建一个带有密钥的应用程序。就像之前一样,我将在示例中硬编码它,但你会从环境变量或其他安全方法中获取值。
-
设置密钥和一些其他我们需要配置的值:
from datetime import timedelta app.config.JWT_SECRET = "somesecret" app.config.JWT_EXPIRATION = timedelta(minutes=10) app.config.REFRESH_EXPIRATION = timedelta(hours=24) app.config.COOKIE_DOMAIN = "127.0.0.1" -
创建一个将保存我们的 JWT 细节的模型:
from dataclasses import dataclass @dataclass class AccessToken: payload: Dict[str, Any] token: str def __str__(self) -> str: return self.token @property def header_payload(self): return self._parts[0] @property def signature(self): return self._parts[0] @property def _parts(self): return self.token.rsplit(".", maxsplit=1) -
使用一些负载生成令牌。在 JWT 的术语中,负载本质上只是一个值的字典。它可以包含一个“声明”,这是一个特殊的键值对,可以用于验证令牌。如果你开始使用 JWT,我建议你深入研究一些标准的声明。在我们的例子中,我们只使用了一个,那就是过期声明:
exp。除此之外,你可以随意将任何你想要的内容添加到负载中:import jwt def generate_access_token(user: User, secret: str, exp: int) -> AccessToken: payload = { "whatever": "youwant", "exp": exp, } raw_token = jwt.encode(payload, secret, algorithm="HS256") access_token = AccessToken(payload, raw_token) return access_token To verify the token, we can do the reverse. We do have a use case for when we will accept an expired token (when using the refresh token). Therefore, we need a flag to allow us to skip the check of the exp claim. def check_access_token( access_token: str, secret: str, allow_expired: bool = False ) -> bool: try: jwt.decode( access_token, secret, algorithms=["HS256"], require=["exp"], verify_exp=(not allow_expired), ) except jwt.exceptions.InvalidTokenError as e: error_logger.exception(e) return False -
一旦你生成了
AccessToken对象,将其拆分为两个 cookie 将变得非常简单。其中一个将是 JavaScript 可访问的,另一个将是HttpOnly。我们还想让刷新令牌也是HttpOnly。你的登录处理程序可能如下所示:access_token_exp = datetime.now() + request.app.config.JWT_EXPIRATION refresh_token_exp = datetime.now() + request.app.config.REFRESH_EXPIRATION access_token = generate_access_token( user, request.app.config.JWT_SECRET, int(access_token_exp.timestamp()), ) refresh_token, hased_key = generate_token() await store_refresh_token(user, hased_key) response = json({"payload": access_token.payload}) -
我们随后使用便利函数设置所有 cookie。请仔细注意这些 cookie 是如何设置与
httponly和samesite相关的:set_cookie( response, "access_token", access_token.header_payload, httponly=False, domain=request.app.config.COOKIE_DOMAIN, exp=access_token_exp, ) set_cookie( response, "access_token", access_token.signature, httponly=True, domain=request.app.config.COOKIE_DOMAIN, exp=access_token_exp, ) set_cookie( response, "refresh_token", refresh_token, httponly=True, samesite="strict", domain=request.app.config.COOKIE_DOMAIN, exp=refresh_token_exp, )
我们现在已经拥有了构建端点和装饰器所需的所有构建块。现在是时候检验你的技能,并尝试从本章的知识中拼凑出端点。不用担心,GitHub 仓库中有一个完整的解决方案,包括上面使用的set_cookie便利函数。
这里有一点自我推销:我为 Sanic 构建的第一个库之一是一个用于处理 JWT 的认证和授权的包。它允许处理这种拆分令牌的方法,并包括所有其他各种好东西和保护。如果你不想自己构建解决方案,它已经在社区中得到了广泛采用。查看我的个人 GitHub 页面以获取更多详细信息:github.com/ahopkins/sanic-jwt。
摘要
本章涵盖了大量的内容。即便如此,它也只是触及了 Web 安全的表面。要真正提高安全标准,你应该继续做一些自己的研究。还有一些其他常见的头部信息,比如:Content-Security-Policy、X-Content-Type-Options和X-Frame-Options,我们没有机会涉及。尽管如此,凭借你在这里收集的信息和你的独创性,你应该能够实现——例如——适用于你应用的Content-Security-Policy。我寻找这类材料的第一地方是 Mozilla 的 MDN 网站:developer.mozilla.org/en-US/。我强烈建议你访问它,了解 Web 标准和实践。
那么,我们涵盖了哪些内容?
你应该熟悉同源概念,以及如何开发 CORS 策略来抵御 CSRF 和 XSS 攻击。我们还探讨了三种常见的用户认证方案:API 密钥、会话令牌和 JWT。当然,通过查看所有示例,你应该正在学习如何使用 Sanic 工具集来定制你自己的独特且明显的模式以满足应用程序的需求。在本书的这一部分,我们实际上已经涵盖了构建 Web 应用程序所需的大部分内容。你应该熟悉所有基本构建块,并开始有一些想法,了解如何将它们组合起来构建解决方案。
我们现在所缺少的是关于如何部署我们的应用程序和运行它们的知识。这正是我们将要探讨的内容。
第九章:8 运行 Sanic 服务器
在我参与 Sanic 项目的时间里——特别是,通过回答其他开发者的支持问题来帮助他们——有一个主题似乎比其他任何主题都更频繁地出现:部署。这个单词常常与困惑和恐惧的混合情绪联系在一起。
构建 Web 应用程序可以非常有趣。我怀疑我不是唯一一个在构建过程中本身就能找到巨大满足感的人。我喜欢软件开发的其中一个原因——尤其是 Web 开发——是因为我喜欢将解决方案与给定问题相匹配的几乎像谜一样的氛围。当构建完成,是时候启动时,焦虑就出现了。
我无法过分强调接下来的观点。Sanic 最大的优势之一是其捆绑的 Web 服务器。这不仅仅是一个噱头,或者一些可以忽略的辅助功能。Sanic 附带自己的 Web 服务器确实简化了构建过程。想想传统的 Python Web 框架,比如 Django 或 Flask,或者一些较新的 ASGI 框架。为了使它们能够运行并连接到网络,你需要一个生产级的 Web 服务器。构建应用程序只是第一步。部署它需要另一项工具的知识和熟练度。通常,用于部署使用这些框架构建的应用程序的网络服务器与您开发时使用的服务器不同。为此,您有一个开发服务器。这不仅增加了复杂性和依赖性,还意味着您不是在针对将在生产中运行您代码的实际服务器进行开发。还有人在想我正在想的事情吗?错误。
在本章中,我们将探讨运行 Sanic 所需的内容。我们将探索在开发和生产环境中运行 Sanic 的不同方法,以使部署过程尽可能简单。我们将首先查看服务器生命周期。然后,我们将讨论设置本地和具有生产级可扩展性的服务。我们将涵盖:
-
处理服务器生命周期
-
配置应用程序
-
在本地运行 Sanic
-
部署到生产环境
-
使用 TLS 保护您的应用程序
-
部署示例
当我们完成时,您因部署而产生的焦虑应该会成为过去式。
技术要求
当然,我们将继续构建前几章的工具和知识。在第三章,路由和接收 HTTP 请求中,我们看到了一些使用 Docker 的实现。具体来说,我们使用 Docker 运行 Nginx 服务器来提供静态内容。虽然这不是部署 Sanic 所必需的,但了解 Docker 和(在一定程度上)Kubernetes 将有所帮助。在本章中,我们将探讨与 Sanic 部署一起使用 Docker 的方法。如果你不是黑带 Docker 或 Kubernetes 专家,不要担心。GitHub 仓库中将有示例:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/08。我们希望和期望的是对这些工具有一些基本的理解和熟悉。
如果你没有安装这些工具,你需要它们来跟上进度:
-
git -
docker -
doctl -
kubectl
处理服务器生命周期
在这本书中,我们花了很多时间讨论传入 HTTP 请求的生命周期。在那段时间里,我们看到了我们如何在生命周期中的不同点附加、修改和运行代码。嗯,整个应用服务器的生命周期也没有什么不同。
虽然我们有中间件和信号,但服务器生命周期中有什么被称为“监听器”。实际上,监听器在本质上(有一个小的例外)就是信号本身。在我们探讨如何使用它们之前,我们将看看有哪些监听器可用。
服务器监听器
监听器的基本前提是你在服务器生命周期中的某个事件上附加了一个函数。随着服务器通过启动和关闭过程,Sanic 将触发这些事件,因此你可以轻松地插入自己的功能。Sanic 在启动和关闭阶段都会触发事件。对于服务器生命周期中的任何其他事件,你应该参考第六章,响应周期之外中的利用信号进行工作进程间通信部分。
事件的顺序如下:
-
before_server_start:这个事件自然是在服务器启动之前运行的。这是一个连接数据库或执行任何需要在应用程序生命周期开始时进行的操作的绝佳地方。你可能会在全局范围内做的事情几乎总是在这里做得更好。唯一值得注意的注意事项是,如果你在 ASGI 模式下运行,服务器在 Sanic 被触发之前就已经运行了。在这种情况下,before_server_start和after_server_start之间没有区别。 -
after_server_start:关于这个事件的常见误解是它可能会遇到一个竞态条件,其中事件在服务器开始响应 HTTP 请求时运行。这并不是情况。这个事件意味着已经创建并附加到操作系统上的 HTTP 服务器。基础设施已经到位,可以开始接受请求,但还没有发生。只有当所有after_server_start的监听器都完成后,Sanic 才会开始接受 HTTP 流量。 -
before_server_stop:这是一个开始任何你需要进行的清理工作的好地方。当你在这个位置时,Sanic 仍然能够接受传入的流量,所以你可能需要处理的所有东西仍然可用(比如数据库连接)。 -
after_server_stop:一旦服务器被关闭,现在可以安全地开始任何剩余的清理工作。如果你处于 ASGI 模式,就像before_server_start,这个事件实际上在服务器关闭后并不会被触发,因为 Sanic 不控制这一点。相反,它将立即跟随任何before_server_stop监听器以保持它们的顺序。
还有两个额外的监听器可供你使用。然而,这些额外的监听器仅在 Sanic 服务器上可用,因为它们是特定于 Sanic 服务器生命周期的。这是由于服务器的工作方式。当你使用多个工作者运行 Sanic 时,发生的情况是有主进程充当指挥者。它为每个请求的工作者启动多个子进程。如果你想深入了解每个工作者进程的生命周期,那么你已经有了我们刚才看到的四个监听器所提供的工具。
然而,如果你想在主进程而不是每个工作进程中运行一些代码:那个指挥者?答案是 Sanic 服务器的主进程事件。它们是 main_process_start 和 main_process_stop。除了它们在主进程中运行而不是在工作者中,它们在其他方面的工作方式与其他监听器相同。记得我提到监听器本身就是信号,但有例外吗?这就是那个例外。这些监听器不是伪装成信号的。从所有实际目的来看,这种区别并不重要。
还值得一提的是,尽管这些事件旨在允许在多工作者模式下在主进程中而不是工作者进程中运行代码,但即使你在单个工作者进程中运行时,它们也会被触发。在这种情况下,它们将在你的生命周期的极端开始和极端结束时运行。
这引发了一个有趣且经常出现的错误:双重执行。在继续处理监听器之前,我们将关注错误地多次运行代码。
在全局作用域中运行代码
当你准备应用程序运行时,初始化各种服务、客户端、接口等是很常见的。你很可能需要在服务器开始运行之前,在处理过程的早期对应用程序执行一些操作。
例如,让我们假设你正在寻找一个解决方案来帮助你更好地跟踪你的异常。你发现了一个第三方服务,你可以将所有的异常和回溯报告给它,以帮助你更好地分析、调试和修复你的应用程序。要开始使用,该服务提供了一些文档,说明如何使用他们的软件开发工具包(SDK)如下:
from third_party_sdk import init_error_reporting
init_error_reporting(app)
你将这个设置和运行在你的多工作进程应用程序中,然后你立即开始注意到它运行了多次,而不是像预期的那样在你的工作进程中。这是怎么回事?
很可能的问题是你在全局范围内运行了初始化代码。在 Python 中,我们所说的“全局范围”是指不在函数或方法中执行的部分。它在 Python 文件的最外层运行。在上面的例子中,init_error_reporting 在全局范围内运行,因为它没有被另一个函数包裹。问题是,当多个工作进程运行时,你需要意识到代码在哪里以及何时运行。由于多个工作进程意味着多个进程,并且每个进程都可能运行你的全局范围,因此你需要小心地放置其中的内容。
作为一条非常普遍的规则,坚持将任何可操作的代码放在监听器中。这允许你控制位置和时机,并且将以更一致和易于预测的方式运行。
设置监听器
使用监听器应该看起来非常熟悉,因为它们遵循 Sanic 其他地方发现的类似模式。你创建一个监听器处理程序(它只是一个函数),然后使用装饰器将其包装起来。它应该看起来像这样:
@app.before_server_start
async def setup_db(app, loop):
app.ctx.db = await db_setup()
我们在这里看到的是 Sanic 开发中非常重要的一点。这个模式应该牢记在心,因为将元素附加到你的应用程序ctx对象中,可以增加你在开发中的整体灵活性。在这个例子中,我们设置了数据库客户端,以便可以从任何地方访问它,即代码中的任何地方。
有一个重要的事情要知道,你可以根据定义的时间来控制监听器的执行顺序。对于“启动”时间监听器(before_server_start、after_server_start 和 main_process_start),它们按照声明的顺序执行。
对于“停止”时间监听器(before_server_stop、after_server_stop 和 main_process_stop),情况正好相反。它们按照声明的相反顺序运行。
如何决定使用“在...之前”监听器或“在...之后”监听器
如上所述,存在一个常见的误解,即在你想在请求开始之前执行某些操作的情况下,逻辑必须添加到 before_server_start 中。这种担忧是,使用 after_server_start 可能会导致某种竞争条件,其中某些请求可能会在触发该事件之前的一瞬间击中服务器。
这是不正确的。before_server_start 和 after_server_start 都会在允许任何请求进入之前运行完成。
那么,问题就变成了何时应该优先考虑其中一个?当然,可能会有一些个人和特定于应用程序的偏好。然而,一般来说,我喜欢使用 before_server_start 事件来设置我的应用程序上下文。如果需要初始化某个对象并将其持久化到 app.ctx,那么我会选择 before_server_start。对于任何其他用例(如执行其他类型的外部调用或配置),我喜欢使用 after_server_start。这绝对不是一条不可更改的规则,我经常自己打破它。
现在我们已经了解了服务器的生命周期,但在我们可以运行应用程序之前,我们还需要了解一些其他的信息:配置。
配置应用程序
Sanic 尝试在默认情况下对你的应用程序做出一些合理的假设。考虑到这一点,你当然可以启动一个应用程序,并且它应该已经有一些合理的默认设置。虽然这可能适用于一个简单的原型,但当你开始构建应用程序时,你会意识到你需要对其进行配置。
正是这个时候,Sanic 的配置系统开始发挥作用。
配置主要有两种形式:调整 Sanic 运行时操作,以及声明一个全局常量的状态,以便在整个应用程序中使用。这两种类型的配置都很重要,并且都遵循应用值的一般原则。
我们将更详细地探讨配置对象是什么,我们如何访问它,以及如何更新或更改它。
Sanic 配置对象是什么?
当你创建一个 Sanic 应用程序实例时,它将创建一个配置对象。这个对象实际上只是一个花哨的 dict 类型。正如你将看到的,它确实有一些特殊属性。不要被它迷惑。你应该记住:它是一个 dict。你可以像处理任何其他 dict 对象一样处理它。这在我们探索如何使用该对象时将非常有用。
如果你不同意我的观点,那么请将以下代码放入你的应用程序中:
app = Sanic(__name__)
assert isinstance(app.config, dict)
这意味着,使用默认值获取配置值与 Python 中的任何其他 dict 没有区别:
environment = app.config.get("ENVIRONMENT", "local")
然而,配置对象比任何其他 dict 都要重要得多。它包含了许多对应用程序操作至关重要的设置。当然,我们在 第六章 中已经看到,我们可以用它来修改我们的默认错误处理:
app.config.FALLBACK_ERROR_FORMAT = "text"
要了解您可以调整的全套设置,您应该查看 Sanic 文档:sanicframework.org/en/guide/deployment/configuration.html#builtin-values.
如何访问应用程序的配置对象?
访问配置对象的最佳方式是首先获取访问应用程序实例的权限。根据您当前面临的场景,有三种主要方式可以获取应用程序实例的访问权限:
-
使用请求对象(
request.app)访问应用程序实例 -
从 Blueprint 实例访问应用程序(
bp.apps) -
从应用程序注册表中检索应用程序实例(
Sanic.get_app())
获取应用程序实例(以及由此扩展的配置对象)最常见的方式可能是从处理器内部的请求对象中获取:
@bp.route("")
async def handler(request):
environment = request.app.config.ENVIRONMENT
如果您不在易于访问请求对象的路由处理器(或中间件)外部,那么下一个最佳选择可能是使用应用程序注册表。很少会使用 Blueprint 的 apps 属性。它是蓝图应用到的应用程序集合。然而,因为它只存在于注册之后,并且可能不清楚您需要哪个应用程序,所以我通常不会将其作为解决方案。尽管如此,了解它的存在是好的。
您可能已经看到我们使用第三个选项了。一旦应用程序被实例化,它就成为了一个全局注册表的一部分,可以通过以下方式查找:
from sanic import Sanic
app = Sanic.get_app()
每当我不在处理器中时,我通常会采用这个解决方案。您需要注意的两个限制是:
-
确保应用程序实例已经实例化。如果您不小心处理导入顺序,使用
app = Sanic.get_app()在全局范围内可能会很棘手。稍后,在 第十一章,一个完整的真实世界示例 中,当我们构建一个完整的应用程序时,我会向您展示我用来绕过这个问题的技巧。 -
如果您正在构建包含多个应用程序实例的运行时,那么您需要使用应用程序名称来区分它们:
main_app = Sanic("main") side_app = Sanic("side") assert Sanic.get_app("main") is main_app
一旦您有了这个对象,您通常会直接将其作为属性访问配置值,例如,app.config.FOOBAR。如前所述,您也可以使用各种 Python 访问器:
app.config.FOOBAR
app.config.get("FOOBAR")
app.config["FOOBAR"]
getattr(app.config, "FOOBAR")
如何设置配置对象?
如果您访问 Sanic 文档,您会看到已经设置了一堆默认值。这些值可以通过多种方法进行更新。当然,您可以使用 object 和 dict 设置器:
app.config.FOOBAR = 123
setattr(app.config, "FOOBAR", 123)
app.config["FOOBAR"] = 123
app.config.update({"FOOBAR": 123})
你通常会在创建应用程序实例后立即设置这些值。例如,在这本书中,我反复使用curl来访问我创建的端点。查看异常的最简单方法就是使用基于文本的异常渲染器。因此,在大多数情况下,我使用了以下模式来确保当出现异常时,它能够轻松地格式化以在本书中显示:
app = Sanic(__name__)
app.config.FALLBACK_ERROR_FORMAT = "text"
这通常不是一个完全构建的应用的理想选择。如果你之前参与过 Web 应用开发,那么你可能不需要我告诉你配置应该根据你的部署环境轻松更改。因此,Sanic 会在配置值以SANIC_为前缀的情况下将其加载为环境变量。
这意味着上述FALLBACK_ERROR_FORMAT也可以通过环境变量在应用程序外部设置:
$ export SANIC_FALLBACK_ERROR_FORMAT=text
实现这一点的最佳方法显然取决于你的部署策略。我们将在本章的后面更深入地探讨这些策略,以及如何设置这些变量的具体细节将有所不同,并且超出了本书的范围。
另一个你可能熟悉的选择是将所有配置集中在一个位置。Django 通过settings.py来实现这一点。虽然我个人并不喜欢这种模式,但你可能喜欢。你可以轻松地像这样复制它:
-
创建一个
settings.py文件。FOO = "bar" -
将配置应用到应用程序实例:
import settings app.update_config(settings) -
根据需要访问值:
print(app.config.FOO)settings.py文件名并没有什么特殊之处。你只需要一个包含大量大写属性的全局模块。实际上,你可以通过一个对象来复制这个功能。 -
现在将所有常量放入一个对象中:
class MyConfig: FOO = "bar" -
应用来自该对象的配置。
app.update_config(MyConfig)
结果将是相同的。
关于配置的一些通用规则
我有一些关于配置的通用规则,我喜欢遵循。我鼓励你采用它们,因为它们是从多年的错误中演变而来的。但是,我也同样强烈地鼓励你在必要时打破这些规则:
-
使用简单值:如果你有一些复杂的对象,比如
datetime,那么配置可能不是放置它的最佳位置。配置的一部分灵活性在于它可以以多种不同的方式设置;包括在应用程序外部通过环境变量。虽然 Sanic 能够转换布尔值和整数,但其他所有内容都将是一个字符串。因此,为了保持一致性和灵活性,尽量只使用简单值类型。 -
将它们视为常量:是的,这是 Python。这意味着一切都是对象,并且一切都会受到运行时变化的影响。但不要这样做。如果你有一个需要在应用程序运行时更改的值,请使用
app.ctx。在我看来,一旦before_server_start完成,你的配置对象应该被视为固定不变。 -
不要硬编码值:或者,至少要尽力避免。在构建你的应用程序时,你无疑会发现需要创建某种类型的常量值。如果不了解你的具体应用程序,很难猜测这种情况可能出现的场景。但当你意识到你即将创建一个常量或某个值时,问问自己这个配置是否更合适。最具体的例子可能是你用来连接数据库、供应商集成或任何其他第三方服务的设置。
配置你的应用程序几乎肯定会在应用程序的生命周期中发生变化。随着你构建它、运行它并添加新功能(或修复损坏的功能),经常返回配置是很常见的。专业级应用程序的一个标志是它严重依赖于这种类型的配置。这是为了提供你在不同环境中运行应用程序的灵活性。例如,你可能有一些只在本地开发中才有益的功能,但在生产环境中没有。也可能相反。因此,配置通常总是与你要部署应用程序的环境紧密耦合。
我们现在将注意力转向这些部署选项,看看 Sanic 在开发和生产环境中的表现会如何。
本地运行 Sanic
我们终于到了运行 Sanic 的时候了——好吧,是在本地。然而,我们也知道自从 第二章,组织项目 以来,我们一直在做这件事。Sanic CLI 已经可能是一个相当舒适且熟悉的工具了。但还有一些事情你应该知道。其他框架只有开发服务器。由于我们知道 Sanic 的服务器旨在用于开发和生产环境,我们需要了解这些环境有何不同。
本地运行 Sanic 与生产环境有何不同?
本地生产中最常见的配置更改是开启调试模式。这可以通过三种方式实现:
-
它可以直接在应用程序实例上启用。你通常会在 Sanic 从脚本(而不是 CLI)以编程方式运行时看到这种工厂模式。你可以直接设置值,如下所示:
def create_app(..., debug: bool = False) -> Sanic: app = Sanic(__name__) app.debug = debug ... -
它可能更常见的是将其设置为
app.run的参数。这种用法的一个常见场景可能是当读取环境变量以确定 Sanic 应如何初始化时。在以下示例中,当 Sanic 服务器开始运行时,会读取并应用环境值。from os import environ from path.to.somewhere import create_app def main(): app = create_app() debug = environ.get("RUNNING_ENV", "local") != "production" app.run(..., debug=debug) -
最终的选择是使用 Sanic CLI。这通常是我的首选解决方案,如果你一直跟随这本书学习,那么我们一直都在使用这个方案。这个方法如以下所示非常直接:
$ sanic path.to:app --debug
我更喜欢这个最终选项的原因是我喜欢将服务器的操作方面与其他配置区分开来。
例如,超时是配置值,这些值与框架的操作紧密相关,而不是服务器本身。它们影响框架对请求的响应。通常,这些值无论应用程序部署在哪里都会是相同的。
另一方面,调试模式与部署环境的关系更为紧密。你希望在本地将其设置为 True,但在生产环境中设置为 False。因此,由于我们将使用 Docker 等工具来控制 Sanic 的部署,控制服务器在应用程序之外的操作能力是有意义的。
“好吧”,你说,“开启调试模式很简单,但我为什么要这么做呢?”我很高兴你问了这个问题。当你以调试模式运行 Sanic 时,它会进行一些重要的更改。最明显的是,你开始看到来自 Sanic 的调试日志和访问日志。当然,这在开发过程中是非常有帮助的。
提示
当我坐下来开发一个网络应用程序时,我总是同时看到三个窗口:
我的集成开发环境(IDE)
类似 Insomnia 或 Postman 的 API 客户端
一个显示我的 Sanic 日志(在调试模式下)的终端
调试级别日志的终端是你了解应用程序构建过程中发生情况的窗口。
调试模式可能带来的最大变化是,任何异常都将包括其跟踪信息在响应中。在下一章中,我们将探讨一些如何充分利用这些异常信息的例子。
这在开发过程中非常重要且有用。然而,在生产环境中意外地将其留下是一个巨大的安全问题。绝对不要在实时网络应用程序中开启调试模式。这包括任何不在本地机器上的应用程序实例。例如,如果你有一个托管在互联网上的测试环境,它可能不是你的“生产”环境。然而,它仍然绝对不能运行调试模式。最好的情况是,它会泄露有关应用程序构建的细节。最坏的情况是,它会使敏感信息可用。请确保在生产环境中关闭调试模式。
说到生产环境,让我们继续探讨将 Sanic 部署到野外生产环境所需的内容。
部署到生产环境
我们终于做到了。在经历了应用程序开发过程后,终于有一个产品可以发布到万维网的浩瀚之中。那么,显而易见的问题就是:我的选择有哪些?实际上,有两个问题集需要回答:
-
第一个问题:哪个服务器应该运行 Sanic?有三个选项:Sanic 服务器、ASGI 服务器或 Gunicorn。
-
第二个问题:你希望在何处运行应用程序?一些典型的选择包括:裸机虚拟机、容器化镜像、平台即服务(PaaS)、或自托管或完全管理的编排容器集群。如果我们把这些常用的产品名称加到这些选择上,可能更有意义:
| 部署类型 | 潜在供应商 |
|---|---|
| 虚拟机 | Amazon EC2, Google Cloud, Microsoft Azure, Digital Ocean, Linode |
| 容器 | Docker |
| 平台即服务 | Heroku |
| 编排集群 | Kubernetes |
表 8.1 – 常见托管提供商和工具的示例
选择正确的服务器选项
正如我们所提到的,运行 Sanic 主要有三种方式:内置服务器、与 ASGI 兼容的服务器或使用 Gunicorn。在我们决定运行哪种服务器之前,我们将简要地看看每种选项的优缺点,从性能最低的选项开始。
Gunicorn
如果你是从 WSGI 世界来到 Sanic 的,你可能已经熟悉 Gunicorn 了。实际上,你可能甚至会对了解到 Sanic 可以用 Gunicorn 运行感到惊讶,因为它是为 WSGI 应用程序构建的,而不是像 Sanic 这样的异步应用程序。正因为如此,使用 Gunicorn 运行 Sanic 的最大缺点是性能的显著下降。Gunicorn 实际上破坏了利用asyncio模块进行并发的大部分工作。这是运行 Sanic 最慢的方式,在大多数情况下并不推荐。
在某些情况下,这仍然可能是一个不错的选择。特别是,如果你需要一组功能丰富的配置选项,而又不能使用像 Nginx 这样的工具,那么这可能是一个方法。Gunicorn 提供了大量的选项,可以用来微调服务器操作。然而,根据我的经验,我通常看到人们出于习惯而不是必要性而选择它。人们只是因为熟悉而使用它。对于从 Flash/Django 世界过渡到 Sanic 的人来说,他们可能已经习惯了以像 Supervisor 和 Gunicorn 这样的工具为中心的特定部署模式。这当然是可以的,但它有点过时了,不应该成为 Sanic 部署的首选模式。
对于这些人,我强烈建议你们考虑另一个选择。你们正在使用一个新的框架进行构建,为什么不也用一个新的策略来部署呢?
然而,如果你确实发现自己需要 Gunicorn 提供的更多精细控制,我建议你看看 Nginx,它拥有同样(如果不是更多)令人印象深刻的特性集。Gunicorn 会设置为实际运行 Sanic,而 Nginx 的实现将依赖于 Sanic 通过其他两种策略之一运行,并在其前面放置一个 Nginx 代理。关于 Nginx 代理的更多内容将在本章后面讨论。这个选项将允许你在不牺牲性能的情况下保留大量的服务器控制。然而,这确实需要更多的复杂性,因为你实际上需要运行两个服务器而不是一个。
如果最后你仍然决定使用 Gunicorn,那么最好的方法就是使用 Uvicorn 的工作器适配器。Uvicorn 是一个 ASGI 服务器,我们将在下一节中了解更多关于它的内容。然而,在这个上下文中,它还附带了一个工作器类,允许 Gunicorn 与之集成。这实际上将 Sanic 置于 ASGI 模式。Gunicorn 仍然作为网络服务器运行,但它会将流量传递给 Uvicorn,然后 Uvicorn 将像处理 ASGI 应用程序一样深入 Sanic。这将保留 Sanic 提供的许多性能和异步编程(尽管仍然不如 Sanic 服务器本身高效)。你可以按照以下方式完成此操作:
-
首先,确保 Gunicorn 和 Uvicorn 都已安装:
$ pip install gunicorn uvicorn -
接下来,按照以下方式运行应用程序:
$ gunicorn \ --bind 127.0.0.1:7777 \ --worker-class=uvicorn.workers.UvicornWorker \ path.to:app
你现在应该已经掌握了 Gunicorn 配置的全套内容。
ASGI 服务器
我们在第一章,Sanic 和异步框架的介绍中简要介绍了 ASGI。如果你还记得,ASGI代表异步服务器网关接口,它是一种设计规范,说明了服务器和框架如何异步地相互通信。它是作为与较旧的、与现代异步 Python 实践不兼容的 WSGI 标准替代方法开发的。这个标准催生了三个流行的 ASGI 网络服务器:Uvicorn、Hypercorn 和 Daphne。所有这三个都遵循 ASGI 协议,因此可以运行任何遵循该协议的框架。因此,目标是创建一种通用语言,允许这些 ASGI 服务器之一运行任何 ASGI 框架。
在这里,我们必须讨论 Sanic 与 ASGI 的关系,我们必须在心中清楚地区分服务器和框架之间的差异。第一章详细讨论了这种差异。作为一个快速回顾,网络服务器是应用程序中负责连接到操作系统的套接字协议并处理字节到可用网络请求转换的部分。框架接收处理过的网络请求,并为应用程序开发者提供响应和构建适当 HTTP 响应所需的工具。然后,服务器将此响应发送回操作系统,以便将其发送回客户端。
Sanic 处理整个流程,并且在执行时,它是在 ASGI 之外操作的,因为那个接口并不需要。然而,它也有能力使用 ASGI 框架的语言,因此可以与任何 ASGI 网络服务器一起使用。
将 Sanic 作为 ASGI 应用程序运行的一个好处是,它使用更广泛的 Python 工具集标准化了运行时环境。例如,有一组 ASGI 中间件可以实现,在服务器和应用程序之间添加一层功能。
然而,一些标准化是以性能为代价的。
Sanic 服务器
默认机制是使用内置的网络服务器运行 Sanic。它是以性能为导向构建的,这一点并不令人意外。因此,Sanic 服务器通过放弃 ASGI 的标准化和互操作性所失去的,它通过作为单一用途服务器的优化能力来弥补。
我们已经提到了使用 Sanic 服务器的一些潜在缺点。其中之一是静态内容。没有 Python 服务器能够在处理静态内容方面与 Nginx 相匹敌。如果你已经使用 Nginx 作为 Sanic 的代理,并且你知道静态资源的已知位置,那么使用它来处理这些资源可能是有意义的。然而,如果你没有使用它,那么你需要确定性能差异是否值得额外的运营成本。在我看来,如果你可以轻松地将它添加到你的 Nginx 配置中:很好。然而,如果需要大量的复杂努力,或者你直接暴露 Sanic,那么这种好处可能不如让它保持原样并从 Sanic 提供内容那么大。有时,例如,最简单的事情就是从单个服务器运行你的整个前端和后端。这确实是一个我会建议学习竞争利益并做出适当决定,而不是试图做出完美决定的案例。
带着这些知识,你现在应该能够决定哪个服务器最适合你的需求。我们假设这本书的剩余部分我们仍然使用 Sanic 服务器进行部署,但由于这主要是一个更改命令行可执行文件的问题,所以这种差异不应该造成影响。
如何选择部署策略?
上一节概述了三个用于 Sanic 应用的潜在网络服务器。但是,那个网络服务器需要在网络主机上运行。但是,在决定使用哪家网络托管公司之前,还有一个非常重要的缺失组件:你将如何将你的代码从你的本地机器传输到网络主机?换句话说:你将如何部署你的应用程序?现在,我们将探讨一些部署 Sanic 应用程序的选择。
这里有一些假设的知识,所以如果这里的一些技术或术语不熟悉,请随时停下来查阅它们。
虚拟机
这可能是最简单的方法。好吧,除了 PAAS 之外,设置一个虚拟机(VM)现在非常简单。只需点击几个按钮,你就可以为 VM 配置一个自定义配置。这使得这成为一个简单选项的原因是,你只需以与你在本地机器上相同的方式运行你的 Sanic 应用程序。这在使用 Sanic 服务器时尤其吸引人,因为这实际上意味着你可以使用与本地相同的命令在生产环境中运行 Sanic。然而,将你的代码部署到 VM 中,维护它,然后最终扩展它将使这个选项变得最困难。坦白说,我几乎永远不会推荐这个解决方案。它对新手来说很有吸引力,因为它看起来很简单。但外表可能会欺骗人。
实际上,可能会有这种解决方案是合适的时刻。如果是这样,那么部署会是什么样的呢?实际上,与本地运行并没有太大的不同。你运行服务器并将其绑定到地址和端口。随着云计算的普及,服务提供商已经使得建立虚拟机变得如此简单。我个人发现 Digital Ocean 和 Linode 这样的平台非常用户友好,是极佳的选择。其他明显的选择包括 Amazon AWS、Google Cloud 和 Microsoft Azure。然而,在我看来,它们对云计算新手来说稍微不那么友好。有了它们良好的文档,使用 Digital Ocean 和 Linode 相对便宜且痛苦不大,只需点击几个按钮就可以运行一个实例。一旦他们给你提供了一个 IP 地址,现在就是你的责任将你的代码部署到机器上并运行应用程序。
你可能会想,将你的代码移动到服务器的最简单方法就是使用 git。然后你只需要启动应用程序就完成了。但是,如果你需要更多实例或冗余怎么办?是的,Sanic 自带启动多个工作进程的能力。但如果这还不够怎么办?现在你需要另一个 VM 以及某种方式来管理在它们之间平衡你的传入 Web 流量。你将如何处理错误补丁或新功能的重新部署?环境变量的更改怎么办?如果你不小心,这些复杂性可能会导致许多不眠之夜。
这也在一定程度上忽略了另一个事实,即并非所有环境都是平等的。虚拟机可能具有不同的依赖项,导致维护服务器和包的时间浪费。
这并不意味着这不能或不应该是一个解决方案。实际上,如果你只是为了自己的使用创建一个简单的服务,这可能是一个非常棒的解决方案。也许你需要一个网络服务器来连接到智能家居网络。但无疑这是一个需要开发者小心的案例。在裸金属虚拟机上运行网络服务器通常不像乍一看那么简单。
使用 Docker 的容器
解决上述问题的方法之一是使用 Docker 容器。对于那些已经使用过 Docker 的人来说,你可能可以跳到下一节,因为你已经理解了它提供的强大功能。如果你是容器的新手,那么我强烈建议你了解它们。
简而言之,你编写一个简单的清单,称为 Dockerfile。这个清单描述了一个预期的操作系统以及构建运行应用程序的理想环境所需的指令。一个示例清单可以在 GitHub 仓库中找到:github.com/PacktPublishing/Web-Development-with-Sanic/blob/main/chapters/08/k8s/Dockerfile。
这可能包括安装一些依赖项(包括 Sanic),复制源代码,并定义一个用于运行应用程序的命令。有了这些准备,Docker 随后构建一个包含运行应用程序所需所有内容的单个镜像。这个镜像可以被上传到仓库,并在任何环境中运行。例如,你可以选择使用这种方法来代替管理所有那些独立的虚拟机环境。将所有这些捆绑在一起并简单地运行它要简单得多。
在构建我们的新版本和决定在哪里运行镜像方面,仍然存在一些复杂性,但保持一致的构建是一个巨大的收益。这应该真正成为你部署的重点。因此,尽管容器是解决方案的一部分,但仍然存在运行它和维护其运行和更新的成本问题。
我几乎总是会建议将 Docker 作为部署实践的一部分。如果你了解 Docker Compose,你可能认为它是管理部署的一个很好的选择。我会同意你的看法,只要我们谈论的是在本地机器上的部署。在生产环境中使用 Docker Compose 通常不是我会考虑的事情。原因很简单:水平扩展。就像在虚拟机上运行 Sanic,或者在单个虚拟机上运行单个容器一样,在单个虚拟机上运行 Docker Compose 也带来了相同的问题:水平扩展。解决方案是编排。
使用 Kubernetes 进行容器编排
容器的问题在于,它们只能通过为你的应用程序创建一个一致且可重复的策略来解决环境问题。但它们仍然面临着可扩展性问题。再次强调,当你的应用程序需要扩展到单台机器上可用的资源之外时,会发生什么?像 Kubernetes(又称“K8S”)这样的容器编排器对于过去做过 DevOps 的人来说是一个梦想成真。通过创建一组清单,你将向 Kubernetes 描述你的理想应用程序将是什么样子:副本的数量、它们需要的资源数量、如何暴露流量等等。就是这样!你所需要做的就是用一些 YAML 文件描述你的应用程序。Kubernetes 将处理其余的事情。它还有一个额外的优点,即允许滚动部署,这样你可以在应用程序零停机的情况下推出新代码。
当然,这个选项是最复杂的。它适用于更严肃的应用程序,其中复杂性是可接受的,并且可以带来额外的收益。然而,对于许多项目来说,这可能是一种过度设计。这是任何将会有大量流量的应用程序的默认部署策略。当然,K8S 集群的复杂性和规模可以根据其需求进行扩展。这种动态特性使其成为越来越多行业专业人士采用的标准化部署策略。
它是适用于由多个服务协同工作或需要超出单台机器边界进行扩展的平台的理想解决方案。
然而,这确实提出了一个有趣的问题。我们知道 Sanic 有能力通过在多个进程中复制其工作进程在单个主机上进行水平扩展。Kubernetes 通过启动副本Pod来具备水平扩展的能力。假设你假设你需要四个应用程序实例来处理负载。你应该有两个每个运行两个工作进程的 Pod,还是四个每个只有一个工作进程的 Pod?
我听说这两种方法都被提出作为理想的解决方案。有些人说你应该最大化每个容器的资源。另一些人则说每个容器不应有超过一个进程。从性能的角度来看,它们表现相同。这些解决方案实际上执行相同的操作。因此,这完全取决于应用程序构建者的选择。没有正确或错误答案。
在本章的后面部分,我们将更详细地探讨使用 Kubernetes 启动 Sanic 应用程序所需的内容。
平台即服务
Heroku 可能是最知名的PAAS供应商之一。它已经存在了一段时间,并已成为这些低接触部署策略的行业领导者。Heroku 并非唯一提供者,Google 和 AWS 分别在各自的云平台上提供 PAAS 服务,Digital Ocean 也推出了自己的竞争性服务。PAAS 之所以非常方便,是因为你只需要编写代码。没有容器管理、环境处理或部署难题。它旨在成为部署代码的超简单低接触解决方案。通常,部署应用程序就像将代码推送到 git 仓库一样简单。
因此,这个简单的选项非常适合原型应用或其他需要快速部署的构建。我也知道很多人通过这些服务运行更健壮和可扩展的应用程序,它们确实可以是一个很好的替代品。这些服务的一个巨大卖点是通过将部署、扩展和服务维护外包给服务提供商,你可以腾出时间专注于应用程序逻辑。
由于这种简单性和最终灵活性,我们将在本章的“部署示例”部分稍后更详细地探讨如何使用 PAAS 供应商启动 Sanic。PAAS 的一个优点是它处理了很多细节,比如设置 TLS 证书并为你的应用程序启用https://地址。然而,在下一节中,我们将学习在没有 PAAS 便利性的情况下如何为你的应用程序设置https://地址。
使用 TLS 保护你的应用程序
如果你没有加密你的 Web 应用程序的流量,你就是在做错事。为了在浏览器和你的应用程序之间传输信息时保护信息,添加加密是绝对必要的。国际标准是 TLS(代表传输层安全性)。它是一种数据可以在两个来源之间加密的协议。然而,它通常被称为SSL(这是一个较早的协议,TLS 取代了它)或HTTPS(技术上它是 TLS 的实现,而不是 TLS 本身)。由于我们并不关心它是如何工作的,我们只关心它是否需要完成,因此我们可以将这些术语互换使用。因此,你可以安全地认为 TLS 和 HTTPS 是同一件事。
那么,它是什么呢?简单的答案是,你从互联网上某个信誉良好的来源请求一对密钥。你的下一步是让它们可供你的 Web 服务器使用,并在安全端口上公开你的应用程序——通常是端口 443。之后,你的 Web 服务器应该处理剩下的工作,你现在应该能够通过https://地址而不是http://访问你的应用程序。
在 Sanic 中设置 TLS
你应该熟悉两种常见的场景。如果你直接公开你的 Sanic 应用程序,或者如果你将 Sanic 放在代理后面。这将决定你希望在何处 终止 你的 TLS 连接。这仅仅意味着你应该在哪里设置你的面向公众的证书。我们目前假设 Sanic 是直接公开的。我们还假设你已经有了证书。如果你不知道如何获取它们,不要担心,我们将在下一节提供一个可能的解决方案。
我们需要做的只是告诉 Sanic 服务器如何访问这些证书。此外,由于 Sanic 默认使用端口 8000,我们需要确保将其设置为 443。
-
考虑到这一点,我们的新运行时命令(在生产环境中)将是这样的:
$ sanic \ --host=0.0.0.0 \ --port=443 \ --cert=/path/to/cert \ --key=/path/to/keyfile \ --workers=4 \ path.to.server:app -
如果你使用
app.run而不是app.run,操作基本上是相同的:ssl = {"cert": "/path/to/cert", "key": "/path/to/keyfile"} app.run(host="0.0.0.0", port=443, ssl=ssl, workers=4)
当你直接公开你的 Sanic 应用程序,并且因此通过 Sanic 终止 TLS 时,通常会希望添加 HTTP 到 HTTPS 的重定向。为了用户的方便,你可能希望他们始终被重定向到 HTTPS,并且这个重定向对他们来说是 神奇地 发生的,而无需思考。
Sanic 用户指南为我们提供了一个简单的解决方案,该方案涉及在我们的主应用内部运行第二个 Sanic 应用程序。它的唯一目的将是绑定到端口 80(这是默认的非加密 HTTP 端口)并重定向所有流量。让我们快速检查这个解决方案并逐步进行:
-
首先,除了我们的主应用程序外,我们还需要一个负责重定向的第二个应用程序。因此,我们将设置两个应用程序和一些配置细节:
main_app = Sanic("MyApp") http_app = Sanic("MyHTTPProxy") main_app.config.SERVER_NAME = "example.com" http_app.config.SERVER_NAME = "example.com" -
我们只向
http_app添加了一个端点,该端点将负责将所有流量重定向到main_app。@http_app.get("/<path:path>") def proxy(request, path): url = request.app.url_for( "proxy", path=path, _server=main_app.config.SERVER_NAME, _external=True, _scheme="https", ) return response.redirect(url) -
为了使运行 HTTP 重定向应用程序更容易,我们将利用主应用程序的生命周期,这样就不需要创建另一个可执行文件。因此,当主应用程序启动时,它也将创建和绑定 HTTP 应用程序:
@main_app.before_server_start async def start(app, _): app.ctx.http_server = await http_app.create_server( port=80, return_asyncio_server=True ) app.ctx.http_server.app.finalize()你应该注意我们是如何将那个服务器分配给我们的主应用程序的
ctx,这样我们就可以再次使用它。 -
最后,当主应用程序关闭时,它也将负责关闭 HTTP 应用程序:
@main_app.before_server_stop async def stop(app, _): await app.ctx.http_server.close()
在此设置完成后,任何对 http://example.com 的请求都应自动重定向到同一页面的 https:// 版本。
在步骤 1 和步骤 2 中,这个例子似乎跳过了这样一个事实,即你需要获取实际用于加密网络流量的证书文件。这主要是因为你需要自带证书到桌面上。如果你不熟悉 如何 做这件事,下一节提供了一个可能的解决方案。
从 Let’s Encrypt 获取和更新证书
回到互联网的古老时代,如果您想为您的 Web 应用程序添加 HTTPS 保护,这将花费您一定的成本。证书并不便宜,而且管理起来有些繁琐和复杂。实际上,如果您自己购买证书,证书仍然不便宜,尤其是如果您想购买覆盖子域的证书。然而,这不再是您的唯一选择,因为一些参与者联合起来寻找创建更安全在线体验的方法。解决方案:免费 TLS 证书。这些免费(且信誉良好的)证书由Let’s Encrypt提供,也是为什么每个生产网站都应该加密的原因。成本不再是借口。在这个时候,如果我看到某个网站在实时环境中仍在运行http://,我的一部分会感到厌恶,就像我正在逃跑一样。
如果您目前的应用程序还没有 TLS 证书,请前往letsencrypt.org获取一个。从Let’s Encrypt获取证书的过程需要您遵循一些基本步骤,然后证明您拥有该域名。由于平台具体细节较多,且超出了本书的范围,我们不会深入探讨如何获取证书的细节。稍后,本章将在Kubernetes (as-a-service)部分逐步介绍如何获取用于 Kubernetes 部署的 Let’s Encrypt 证书。
然而,我强烈建议您在项目预算不允许您外出购买证书的情况下使用 Let’s Encrypt。
拿到证书后,终于可以查看一些实际代码并决定哪种部署策略适合您的项目。
部署示例
在之前讨论各种部署策略选择时,有两个选项脱颖而出:PAAS 和 Kubernetes。当部署 Sanic 到生产环境时,我几乎总是推荐这些解决方案之一。这里没有固定的规则,但我通常认为 Kubernetes 是适用于将运行多个服务、需要更多控制部署配置以及拥有更多资源和开发团队的平台的最佳选择。另一方面,PAAS 更适合单开发者项目或没有资源维护更丰富部署管道的项目。现在,我们将探讨在两个环境中运行 Sanic 所需的条件。
平台即服务
正如我们之前所述,Heroku 是通过 PAAS 部署应用程序的知名行业领导者。这有很好的原因,因为他们自 2007 年以来一直在提供这些服务,并在推广这一概念中发挥了关键作用。他们使新开发者和经验丰富的开发者都感到过程非常简单。然而,在本节中,我们将转而查看使用 Digital Ocean 的 PAAS 提供的 Sanic 应用程序的部署。步骤应该几乎相同,并且适用于 Heroku 或其他任何现有的服务:
-
首先,当然,你需要访问他们的网站并注册一个账户,如果你还没有的话。他们的 PAAS 被称为 Apps,你可以在登录后从主仪表板的左侧找到它。
-
接下来,你将经历一系列步骤,这些步骤将要求你连接一个 git 仓库。
-
接下来,你需要通过他们的 UI 配置应用程序。你的屏幕可能看起来像这样:
![图 8.1 - PAAS 设置的示例配置]()
图 8.1 - PAAS 设置的示例配置
这里需要注意的一个重要事项是,我们设置了
--host=0.0.0.0。这意味着我们正在告诉 Sanic 它应该绑定到 Digital Ocean 提供的任何 IP 地址。没有此配置,Sanic 将绑定到127.0.0.1地址。正如任何做过网页开发的人都知道,127.0.0.1 地址映射到大多数计算机上的 localhost。这意味着 Sanic 只能在该特定计算机上的网络流量中访问。这并不好。如果你部署的应用程序无法访问,首先要检查的是端口和主机是否设置正确。最简单的方法之一就是使用0.0.0.0,这就像通配符 IP 地址的等效物。 -
接下来,你将被要求选择一个数据中心的位置。通常,你希望选择一个靠近你目标受众的位置,以减少延迟。
-
然后,你需要选择一个合适的包。如果你不知道该选择什么,从小规模开始,然后根据需要扩展。
-
剩下的唯一事情就是设置我们仓库中的文件。GitHub 上有一个示例供你参考:
github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/08/paas。 -
最后,我们需要一个
requirements.txt文件,列出我们的依赖项:sanic 和一个server.py,就像我们迄今为止所做的每个构建一样。
完成这些后,每次你向仓库推送时,你的应用程序都应该被重新构建并可供你使用。这个好处之一是,你将获得一个 TLS 证书,无需配置即可使用 https。
看起来很简单吗?让我们看看一个更复杂的 Kubernetes 设置。
Kubernetes (作为服务)
我们将把注意力转向 Kubernetes:这是最广泛采用和使用的容器部署编排平台之一。当然,你也可以启动一些虚拟机,在上面安装 Kubernetes,并管理你自己的集群。然而,我发现一个更有价值的解决方案是直接采用 Kubernetes 作为服务的解决方案。你仍然拥有 Kubernetes 的全部功能(我们将使用常见的缩写:K8S),但没有任何维护的烦恼。
我们将再次查看 Digital Ocean 并使用他们的平台作为我们的示例。
-
在我们的本地目录中,我们需要几个文件:
-
Dockerfile用于描述我们的 Docker 容器 -
app.yml,下面将描述的 K8S 配置文件 -
ingress.yml,下面将描述的 K8S 配置文件 -
load-balancer.yml,下面将描述的 K8S 配置文件 -
server.py,这同样是一个 Sanic 应用程序你可以通过 GitHub 仓库中的文件来跟进:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/08/k8s。
-
-
我们的 Dockerfile 是构建我们容器的一组指令。我们将采取捷径,使用 Sanic 社区的一个预安装了 Python 和 Sanic 的基础镜像:
FROM sanicframework/sanic:3.9-latest COPY . /srv WORKDIR /srv EXPOSE 7777 ENTRYPOINT ["sanic", "server:app", "--port=7777", "--host=0.0.0.0", ]就像我们在 PAAS 解决方案中看到的那样,我们绑定到主机
0.0.0.0出于相同的原因。我们在这里不会为每个容器添加多个工作进程。再次强调,如果你愿意,你也可以这样做。 -
接下来,我们需要构建一个镜像:
$ docker build -t admhpkns/my-sanic-example-app . -
让我们尝试在本地运行它以确保它工作
$ docker run -p 7000:7777 --name=myapp admhpkns/my-sanic-example-app -
不要忘记清理你的环境,并在完成时删除容器,如下所示:
$ docker rm myapp -
当然,你还需要将你的容器推送到某个可访问的仓库。为了方便使用和演示目的,我将将其推送到我的公共 Docker Hub 仓库:
$ docker push admhpkns/my-sanic-example-app:latest -
在接下来的部分,我们将通过 Digital Ocean 的 CLI 工具与之交互。如果你还没有安装,请访问
docs.digitalocean.com/reference/doctl/how-to/install/。你需要确保你登录:$ doctl auth init -
我们需要一个 Digital Ocean K8S 集群。登录到他们的网络门户,在主仪表板上点击Kubernetes并设置一个集群。目前,默认设置是足够的。
-
接下来,我们需要启用
kubectl(与 K8S 交互的工具)以便能够与我们的 Digital Ocean K8S 集群通信。如果kubectl未安装,请查看以下说明:kubernetes.io/docs/reference/kubectl/overview/。你需要执行的命令可能看起来像这样:$ doctl kubernetes cluster kubeconfig save afb87d0b-9bbb-43c6-a711-638bc4930f7a一旦你的集群可用并且
kubectl已设置,你可以通过运行以下命令来验证它:$ kubectl get pods当然,我们还没有设置任何东西,所以目前还没有任何东西可以查看。
-
当配置 Kubernetes 时,我们需要首先在我们的
app.yml上运行kubectl apply。提示
在继续之前,您会看到很多在线教程使用这种命令风格:
$ kubectl create ...我通常尽量避免这样做,而是选择以下方式:
$ kubectl apply ...它们基本上做的是同一件事,但便利之处在于使用
apply创建的资源可以通过反复“应用”相同的清单进行持续修改。app.yml中有什么内容?查看 GitHub 仓库以获取完整版本。它相当长,包括一些与当前讨论无关的样板内容,所以在这里我只展示相关片段。这适用于我们示例中的所有 K8S 清单。该文件应包含运行应用程序所需的 Kubernetes 基本组件:一个 服务 和一个 部署。该服务应该看起来像以下这样:spec: ports: - port: 80 targetPort: 7777 selector: app: ch08-k8s-app注意我们是如何将端口
7777映射到80的。这是因为我们将在 Sanic 前终止 TLS,并且我们的入口控制器将通过未加密的 HTTP 与 Sanic 通信。由于所有这些都位于单个集群中,这是可以接受的。您的需求可能更为敏感,那么您应该考虑加密该连接。app.yml中的另一件事是部署,它应该看起来像以下这样:spec: selector: matchLabels: app: ch08-k8s-app replicas: 4 template: metadata: labels: app: ch08-k8s-app spec: containers: - name: ch08-k8s-app image: admhpkns/my-sanic-example-app:latest ports: - containerPort: 7777在这里,我们定义了我们想要的副本数量,并将容器指向我们的 Docker 镜像仓库。
-
创建该文件后,我们将应用它,您应该看到类似以下的结果:
$ kubectl apply -f app.yml service/ch08-k8s-app created deployment.apps/ch08-k8s-app created您现在可以检查以确认它是否成功:
$ kubectl get pods $ kubectl get svc -
我们接下来将使用现成的解决方案来创建一个 NGINX 入口。这将是我们终止 TLS 的代理层,并将 HTTP 请求馈入 Sanic。我们将按照以下方式安装它:
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.0.0/deploy/static/provider/do/deploy.yaml注意,在撰写本文时,v1.0.0 是最新版本。这可能不是您阅读本文时的真实情况,因此您可能需要更改它。您可以在他们的 GitHub 页面上找到最新版本:
github.com/kubernetes/ingress-nginx。 -
接下来,我们将设置我们的入口。创建一个
ingress.yml,按照我们 GitHub 仓库示例中的模式进行。$ kubectl apply -f ingress.yml您会注意到有一些行被故意注释掉了。我们稍后会讨论这个问题。让我们快速验证它是否成功:
$ kubectl get pods -n ingress-nginx -
我们应该退一步,跳转到 Digital Ocean 控制台。在左侧有一个名为 网络 的标签页。转到那里,然后在 域名 标签页中按照以下步骤添加您自己的域名。在示例中,我们在
ingress.yml中添加了example.com作为入口域名。您添加到 Digital Ocean 站点的任何域名都应该与您的入口匹配。如果您需要返回并更新并重新应用带有域名的ingress.yml文件,请现在就做。 -
一旦所有配置都完成,我们应该能够看到我们的应用程序正在运行:
$ curl http://example.com Hello from 141.226.169.179当然,这并不是理想的情况,因为它仍然位于
http://上。我们现在将获取一个 Let’s Encrypt 证书并设置 TLS。 -
最简单的方法是设置一个名为
cert-manager的工具。它将为我们与 Let’s Encrypt 进行所有必要的接口操作。首先安装它:$ kubectl apply --validate=false -f https://github.com/jetstack/cert-manager/releases/download/v1.5.3/cert-manager.yaml再次,请检查最新的版本是什么,并相应地更新此命令。我们可以在以下位置验证其安装:
$ kubectl get pods --namespace cert-manager -
接下来,根据 GitHub 仓库中的示例创建
load-balancer.yml。它应该看起来像这样:apiVersion: v1 kind: Service metadata: annotations: service.beta.kubernetes.io/do-loadbalancer-hostname: example.com name: ingress-nginx-controller namespace: ingress-nginx spec: type: LoadBalancer externalTrafficPolicy: Local ports: - name: http port: 80 protocol: TCP targetPort: http - name: https port: 443 protocol: TCP targetPort: https selector: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/instance: ingress-nginx app.kubernetes.io/component: controller -
应用该清单并确认它已成功:
$ kubectl apply -f load-balancer.yml service/ingress-nginx-controller configured -
你的 K8S 集群现在将开始获取证书的过程。
提示
你可能会遇到的一个问题是,在请求证书时进程会卡住。如果你遇到这种情况,解决方案是在你的Digital Ocean仪表板上开启代理协议。前往以下设置,如果需要就将其开启:
网络 > 负载均衡器 > 管理设置 > 代理协议 > 启用
-
我们几乎完成了!打开那个
ingress.yml文件,取消注释之前注释掉的几行。然后apply该文件。$ kubectl apply -f ingress.yml
完成!你不应该自动从http://重定向到https://,并且你的应用程序得到了完全保护。
更好的是,你现在拥有了一个可部署的 Sanic 应用程序,它具有 K8S 容器编排提供的所有好处、灵活性和可扩展性。
摘要
构建一个优秀的 Sanic 应用程序只是工作的一半。将其部署以使我们的应用程序在野外可用是另一半。在本章中,我们探讨了你需要考虑的一些重要概念。考虑部署永远不会太早。你越早知道将使用哪个服务器,以及你的应用程序将托管在哪里,你就越早可以相应地规划。
当然,有许多部署选项的组合,我只为你提供了一小部分样本。一如既往,你需要了解对你项目和团队有效的方法。将你在这里学到的知识应用到实践中。
然而,如果你要问我如何将这些信息归纳起来,并询问我关于如何部署 Sanic 的个人建议,我会告诉你这一点:
-
使用内置的 Sanic 服务器运行你的应用程序
-
在应用程序外部终止 TLS
-
对于个人或较小项目,或者如果你想要一个更简单的部署选项,使用 PAAS 提供商
-
对于需要扩展并拥有更多开发资源的较大项目,使用托管 Kubernetes 解决方案
就这样。你现在应该能够构建一个 Sanic 应用程序并在互联网上运行它。我们的时间结束了,对吧?你现在应该具备走出去并构建一些伟大事物的技能和知识,所以现在就去做吧。在这本书的剩余部分,我们将开始探讨在构建 Web 应用程序时出现的更多实际问题,并查看一些最佳实践策略来解决这些问题。
第十章:提高你的 Web 应用程序的 9 个最佳实践
从第一章到第八章,我们学习了如何从构思到部署构建 Web 应用程序。给自己鼓掌,给自己来个满堂彩。构建和部署 Web 应用程序不是一件简单的事情。那么,我们学到了什么呢?我们当然花了时间学习 Sanic 提供的所有基本工具:路由处理程序、蓝图、中间件、信号、监听器、装饰器、异常处理程序等等。然而,更重要的是,我们花了时间思考 HTTP 是如何工作的,以及我们如何可以使用这些工具来设计和构建安全、可扩展、可维护且易于部署的应用程序。
这本书中有许多特定的模式供你使用,但我故意留下很多模糊性。你不断地读到这样的声明:“这取决于你的应用程序需求。”毕竟,Sanic 项目的目标之一是保持“无偏见”。
这听起来很好,而且灵活性是极好的。但是,如果你是一个尚未确定哪些模式有效,哪些无效的开发者呢?编写一个“Hello, world”应用程序和编写一个生产就绪、真实世界的应用程序之间的差异是巨大的。如果你在编写应用程序方面只有有限的经验,那么你在犯错误方面的经验也是有限的。正是通过这些错误(无论是你自己犯的,还是从犯过错误的其他人那里学到的教训),我真正相信我们成为了更好的开发者。就像生活中许多其他事情一样,失败导致成功。
因此,本章的目的是包括我从 20 多年的构建 Web 应用程序中学到的几个示例和偏好。这意味着在本章中你将学习的每一个最佳实践,可能都伴随着我犯过的某个错误。这是一套基础级别的最佳实践,我认为对于任何专业级应用程序从一开始就包括在内是至关重要的。
在本章中,我们将探讨:
-
实用的真实世界异常处理程序
-
如何设置可测试的应用程序
-
真实世界日志和跟踪的好处
-
管理数据库连接
技术要求
没有新的技术要求是你之前没有见过的。到这一点,你希望有一个适合构建 Sanic 的环境,以及我们一直在使用的所有工具,如 Docker、Git、Kubernetes 和 Curl。你可以在 GitHub 仓库上的代码示例中跟随:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/09。
实施实用的真实世界异常处理程序
到目前为止,异常处理不是一个新概念。我们在第六章的 实现适当的异常处理 部分探讨了这一主题。我强调了创建我们自己的异常集的重要性,这些异常包括默认状态消息和响应代码。这个有用的模式旨在让你能够快速启动并运行,以便能够向用户发送 有用的 消息。
例如,设想我们正在为旅行代理人开发一个应用程序,用于为客户预订机票。你可以想象操作步骤之一可能是协助通过连接机场匹配航班。
好吧,如果客户选择了两个时间间隔太短的航班,你可能会这样做:
from sanic.exceptions import SanicException
class InsufficientConnection(SanicException):
status_code = 400
message = "Selected flights do no leave enough time for connection to be made."
我喜欢这个模式,因为它使我们现在能够重复性地抛出 InsufficientConnection 异常,并为用户提供已知的响应。但是,正确地响应用户只是战斗的一半。在我们的应用程序的 现实世界 中,当发生错误时,我们想要知道。我们的应用程序需要能够报告问题,以便如果确实存在问题,我们可以修复它。
那么,我们如何解决这个问题呢?日志记录当然是必不可少的(我们将在后面的 从日志和跟踪中获得洞察力 部分很快探讨这一点)。有一个可靠的方式来获取你的系统日志是绝对必须的,出于很多原因。但你真的想每天整天监控你的日志,寻找跟踪信息吗?当然不想!
以某种方式,你需要设置一些警报来通知你发生了异常。你会了解到并非所有异常都是平等的,而且只有有时你才真正希望你的注意力被吸引到异常发生的事实上。如果客户忘记输入有效数据,你不需要在凌晨 3 点被你的手机吵醒。虽然设置系统监控和警报工具超出了本书的范围,但我试图说明的是,你的应用程序应该主动警告你在某些事情发生时。有时会发生不好的事情,你想要确保你能够从噪音中筛选出真正重要的问题。这种简单形式可能是在发生特别糟糕的事情时发送一封电子邮件。
根据你迄今为止对 Sanic 的了解,如果我来找你并要求你构建一个系统,每当抛出 PinkElephantError 异常时,就给我发送一封电子邮件,你会怎么做?
我希望这并不是你的答案:
if there_is_a_pink_elephant():
await send_adam_an_email()
raise PinkElephantError
“为什么?”你可能会问。首先,如果需要在几个地方实现这个功能,然后我们需要将通知从 send_adam_an_email() 更改为 build_a_fire_and_send_a_smoke_signal(),那会怎样?现在你需要搜索所有代码以确保它是一致的,并希望你没有错过任何东西。
你还能做什么?你如何在应用程序中简单地编写以下代码,并让它知道它需要给我发送电子邮件?
if there_is_a_pink_elephant():
raise PinkElephantError
我们下次再学习这个。
使用中间件捕获错误
在我们抛出异常的地方旁边添加通知机制(在这种情况下,send_adam_an_email())并不是最好的解决方案。一个解决方案是使用响应中间件捕获异常,并从那里发送警报。响应不太可能有一个易于解析的异常供你使用。如果 PinkElephantError 抛出一个 400 响应,你如何能够将它与其他任何 400 响应区分开来?当然,你可以有 JSON 格式,并检查异常类型。但这只能在 DEBUG 模式下工作,因为在 PRODUCTION 模式下,你无法获得这些信息。
一个创造性的解决方案可能是附加一个任意的异常代码,并在中间件中按如下方式重写:
class PinkElephantError(SanicException):
status_code = 4000
message = "There is a pink elephant in the room"
@app.on_response
async def exception_middleware(request, response):
if response.status == 4000:
response.status = 400
await send_adam_an_email()
对于一些非常具体的用例,我可以想象这可能会很有用,如果你觉得它对你有用,我会鼓励你这样做。它确实让我想起了老式的错误编码风格。你知道的,那种你需要查找表将数字转换为错误,而这个错误仍然有些难以理解,因为没有标准化或文档的情况?仅仅想象在我四处寻找用户手册来查找 E19 的含义时,我的咖啡机上的这个错误代码就足以提高我的压力水平。我想说的是:“省去麻烦,尝试找到一种比附加一些难以理解的错误代码更好的方法来识别异常,这些错误代码你后来还需要翻译成其他东西。”
使用信号捕获错误
记得我们以前的老朋友信号吗?在 第六章 的 利用信号进行工作间通信 部分中提到的?如果你还记得,Sanic 在某些事情发生时会派发事件信号。其中之一就是当抛出异常时。更好的是,信号上下文包括异常实例,这使得识别哪个异常发生了变得 非常 容易。
对于上述代码的一个更干净、更易于维护的解决方案如下:
@app.signal("http.lifecycle.exception")
async def exception_signal(request, exception):
if isinstance(exception, PinkElephantError):
await send_adam_an_email()
我想你已经可以看出这是一个更优雅、更合适的解决方案。对于许多用例来说,这可能是你最好的解决方案。因此,我建议你记住这个简单的 4 行模式。现在,当我们需要将 send_adam_an_email() 更改为 build_a_fire_and_send_a_smoke_signal() 时,这将是一个超级简单的代码更改。
长期以来一直在构建 Sanic 应用程序的开发者可能会看到我的这个例子,想知道我们是否可以直接使用 app.exception?这当然是一个可接受的模式,但并非没有潜在的风险。我们接下来看看这个问题。
捕获错误并手动响应
当异常被抛出时,Sanic 会停止常规的路由处理过程,并将其移动到ErrorHandler实例。这是一个在整个应用程序实例生命周期中存在的单个对象。它的目的是充当一种迷你路由器,接收传入的异常并确保它们被传递到适当的异常处理程序。如果没有,则使用默认的异常处理程序。正如我们之前所看到的,默认处理程序是我们可以通过使用error_format参数来修改的。
这里有一个快速示例,展示了 Sanic 中的异常处理程序是什么样的:
@app.exception(PinkElephantError)
async def handle_pink_elephants(request, exception):
...
这个模式的问题在于,因为你接管了实际的异常处理,现在你需要负责提供适当的响应。如果你构建了一个包含 10 个、20 个甚至更多这些异常处理程序的应用程序,保持它们的响应一致性就会变成一项繁琐的工作。
正是因为这个原因,我真正地尽量避免自定义异常处理,除非我需要。根据我的经验,通过控制格式化(如第六章“操作在请求处理程序之外”的Fallback 处理部分中讨论的)可以获得更好的结果。然而,这里有一个需要注意的例外,我们将在下一节中探讨。
我尽量避免只针对单一用例进行一次性响应定制。在构建应用程序时,我们可能需要为许多类型的异常构建错误处理程序,而不仅仅是PinkElephantError。因此,当需要处理错误(如发送邮件)而不是仅仅处理用户输出的格式时,我通常不倾向于使用异常处理程序。
好吧,好吧,我认输了。我会告诉你一个秘密:你仍然可以使用app.exception模式来拦截错误,对它进行一些操作,然后使用内置的错误格式化。如果你更喜欢异常处理程序模式而不是信号,那么你可以使用它,而无需担心我关于格式化太多自定义错误响应的担忧。让我们看看我们如何实现这一点。
-
首先,让我们创建一个简单的端点来抛出我们的错误,并以文本格式报告:
class PinkElephantError(SanicException): status_code = 400 message = "There is a pink elephant in the room" quiet = True @app.get("/", error_format="text") async def handler(request: Request): raise PinkElephantError我已经将
quiet=True添加到异常中,这样就会抑制跟踪信息被记录。当跟踪信息对你来说不重要,只是妨碍了你的工作,这是一个有用的技术。 -
接下来,创建一个异常处理程序来发送邮件,但仍然使用默认的错误响应:
async def send_adam_an_email(): print("EMAIL ADAM") @app.exception(PinkElephantError) async def handle_pink_elephants(request, exception): await send_adam_an_email() return request.app.error_handler.default(request, exception)
我们可以使用应用程序实例来访问默认的ErrorHandler实例,如前述代码所示。
我希望你能使用curl来访问那个端点,这样你就可以看到它按预期工作。你应该得到默认的文本响应,并看到日志中记录了一个模拟的邮件发送给我。
正如你也能看到的,我们正在使用存在于应用程序范围内的error_handler对象。在我们下一节中,我们将探讨如何修改该对象。
修改 ErrorHandler
当 Sanic 启动时,它首先做的事情之一就是创建一个ErrorHandler实例。我们在前面的例子中看到,我们可以从应用程序实例中访问它。它的目的是确保当你定义异常处理程序时,请求会从正确的位置得到响应。
这个对象的另一个好处是它易于定制,并且会在每个异常上触发。因此,在 Sanic 引入信号之前的日子里,这是在每次异常上运行任意代码的最简单方法,就像我们的错误报告工具一样。
修改默认的ErrorHandler实例可能看起来像这样:
-
创建一个
ErrorHandler并注入报告代码。from sanic.handlers import ErrorHandler class CustomErrorHandler(ErrorHandler): def default(self, request, exception): ... -
使用你的新处理程序实例化你的应用程序。
from sanic import Sanic app = Sanic(..., error_handler=CustomErrorHandler())
就这样。就我个人而言,在处理警报或其他错误报告时,我几乎总是会选择信号解决方案。信号的好处是它是一个更简洁、更有针对性的解决方案。它不需要我子类化或修补任何对象。然而,了解如何创建自定义ErrorHandler实例是有帮助的,因为你在野外会看到它。
例如,你会在第三方错误报告服务中看到它们。这些服务是你可以订阅的平台,它们会聚合和跟踪你的应用程序中的异常。它们在识别和调试生产应用程序中的问题方面可以非常有帮助。通常,它们通过挂钩到你的正常异常处理流程来实现。由于在 Sanic 中,覆盖ErrorHandler曾经是访问所有异常的底层访问的最佳方法,因此许多这些提供商将提供示例代码或库来使用该策略。
无论你使用自定义的ErrorHandler还是信号,这仍然是一个个人喜好的问题。然而,信号的最大好处是它们在单独的asyncio任务中运行。这意味着 Sanic 将有效地管理对用户的报告(前提是你没有引入其他阻塞代码)的并发响应。
Does this mean that subclassing ErrorHandler is not a worthwhile effort? Of course not. In fact, if you are unhappy with the default error formats that Sanic uses, I would recommend that you change it using the previous example with CustomErrorHandler.
考虑到这一点,你现在有权限根据需要格式化所有的错误。与此策略不同的替代策略是使用异常处理程序来管理。这种方法的问题是你可能会失去 Sanic 内置的自动格式化逻辑。提醒一下,默认ErrorHandler的一个巨大好处是它会尝试根据情况以适当的格式(如 HTML、JSON 或纯文本)做出响应。
异常处理可能不是构建中最激动人心的事情。然而,它无疑是任何专业级 Web 应用程序的一个极其重要的基本组件。在设计策略时,请确保考虑你的应用程序需求。你可能会发现你需要信号、异常处理程序和自定义ErrorHandler的混合。
现在,我们将注意力转向专业级应用程序开发的另一个重要方面,这可能对一些人来说构建起来并不令人兴奋:测试。
设置可测试的应用程序
想象一下这个场景:灵感突然降临,你有一个很棒的应用程序想法。当你开始在脑海中构思要构建的内容时,你的兴奋和创造力都在流淌。当然,你不会直接冲进去构建它,因为你已经阅读了这本书的所有前面的章节。你花了一些时间来规划它,然后在咖啡因的驱动下,你开始着手编写代码。慢慢地,你开始看到应用程序的轮廓,它运行得非常完美。几个小时过去了,也许是一天或一周——你不确定,因为你完全沉浸在其中。最后,经过所有这些工作,你得到了一个最小可行产品(MVP)。你部署了它,然后去享受一些应得的睡眠。
问题在于你从未设置过测试。毫无疑问,当你现在上线并检查你根据上一节建议设置的错误处理系统时,你会发现它被错误信息淹没了。哎呀。用户在你的应用程序中做了你没有预料到的事情。数据没有按照你想象的方式表现。你的应用程序出问题了。///
我敢打赌,大多数开发过 Web 应用程序或进行过任何软件开发的人都能对这个故事表示同情。我们之前都经历过这种情况。对于许多新手和经验丰富的开发者来说,测试并不有趣。也许你是那些少数喜欢设置测试环境的工程师之一。如果是这样,我真诚地向你致敬。对于其他人来说,简单地说,如果你想构建一个专业应用程序,你需要找到内在的动力来开发测试套件。
测试是一个巨大的领域,我这里不会详细讲解。有许多测试策略,包括经常被称赞的测试驱动设计(TDD)。如果你知道它是什么并且它对你有效:太好了。如果你不知道:我不会评判你。如果你不熟悉它,我建议你花些时间在网上对这个主题进行一些研究。它是许多专业发展工作流程的基础部分,许多公司已经采用了它。
同样,有许多测试术语,如单元测试和集成测试。再次强调,这本书不是关于测试理论的,所以我们将使用简化的定义:单元测试是在测试单个组件或端点时进行的,集成测试是在测试与另一个系统(如数据库)交互的组件或端点时进行的。我知道有些人可能不喜欢我的定义,但术语的语义对我们当前的需求并不重要。
在本书中,我们关注的是您如何在单元测试和集成测试中测试您的 Sanic 应用程序。因此,虽然我希望这里的一般思想和方法是有用的,但要真正拥有一个经过良好测试的应用程序,您需要超越本书的页面。
我们需要解决的最后一个基本规则是,这里的测试都将假设您正在使用pytest。它是最广泛使用的测试框架之一,拥有许多插件和资源。
开始使用 sanic-testing
Sanic 社区组织(维护此项目的开发者社区)还维护了一个用于 Sanic 的测试库。尽管它的主要效用是 Sanic 项目本身用来实现高水平的测试覆盖率,但它仍然为与 Sanic 一起工作的开发者找到了一个归宿和用例。我们将广泛使用它,因为它提供了一个方便的接口来与 Sanic 交互。
首先,我们需要将它安装到您的虚拟环境中。在此过程中,我们还将安装pytest:
$ pip install sanic-testing pytest
那么,sanic-testing到底做了什么?它提供了一个 HTTP 客户端,您可以使用它来访问您的端点。
一个典型的基本实现可能看起来像这样:
-
首先,您将在某个模块或工厂中定义您的应用程序。目前,它将是一个全局作用域的变量,但稍后在本章中,我们将开始使用工厂模式应用程序,其中应用程序实例是在一个函数内部定义的。
# server.py from sanic import Sanic app = Sanic(__name__) @app.get("/") async def handler(request): return text("...") -
然后,在您的测试环境中,您初始化一个测试客户端。由于我们正在使用
pytest,让我们在conftest.py文件中将其设置为一个固定装置,这样我们就可以轻松访问它:# conftest.py import pytest from sanic_testing.testing import SanicTestClient from server import app @pytest.fixture def test_client(): return SanicTestClient(app) -
现在,您将能够在单元测试中访问 HTTP 客户端:
# test_sample.py def test_sample(test_client): request, response = test_client.get("/") assert response.status == 200 -
现在运行您的测试只是执行 pytest 命令。它应该看起来像这样:
$ pytest ================= test session starts ================= platform linux -- Python 3.9.7, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 rootdir: /path/to/testing0 plugins: anyio-3.3.4 collected 1 item test_sample.py . [100%] ================= 1 passed in 0.09s ===================
那么,这里到底发生了什么?发生的事情是测试客户端获取了您的应用程序实例,并在您的操作系统上实际运行了它。它启动了 Sanic 服务器,将其绑定到操作系统上的主机和端口地址,并运行了附加到您的应用程序上的任何事件监听器。然后,一旦服务器运行起来,它使用httpx作为接口向服务器发送实际的 HTTP 请求。然后,它将Request和HTTPResponse对象捆绑在一起,并将它们作为返回值提供。
这个示例的代码可以在 GitHub 仓库中找到:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/09/testing0。
这是我无法强调得足够的事情。几乎每次有人向我提出关于或使用sanic-testing的问题时,都是因为那个人没有理解测试客户端实际上是在运行您的应用程序。这种情况在每次调用中都会发生。
例如,考虑以下内容:
request, response = test_client.get("/foo")
request, response = test_client.post("/bar")
当您运行此操作时,它将首先启动应用程序并向 /foo 发送 GET 请求。然后服务器完成完全关闭。接下来,它再次启动应用程序并向 /bar 发送 POST 请求。
对于大多数测试用例,这种启动和停止服务器的操作是首选的。这确保了每次您的应用程序都在一个干净的环境中运行。这个过程非常快,您仍然可以快速完成一系列单元测试,而不会感到性能上的惩罚。
在接下来的几节中,我们将探索一些其他选项。
更实用的测试客户端实现
现在您已经看到了测试客户端是如何工作的,我要向您透露一个小秘密:您实际上并不需要实例化测试客户端。实际上,除了之前的例子之外,我从未在真实的应用程序中以这种方式使用 sanic-testing。
Sanic 应用实例有一个内置属性,如果已经安装了 sanic-testing,则可以为您设置测试客户端。由于我们已经安装了该包,我们可以直接开始使用它。您所需的一切就是访问您的应用实例。
设置应用程序 fixture
在继续之前,我们将重新审视 pytest 的 fixture。如果您不熟悉它们,它们可能对您来说有些神奇。简而言之,它们是 pytest 中声明一个将返回值的函数的模式。该值然后可以用来将对象注入到您的单个测试中。
因此,例如,在我们的上一个用例中,我们在一个名为 conftest.py 的特殊文件中定义了一个 fixture。在那里定义的任何 fixture 都将在您的测试环境中任何地方可用。这就是我们能够在测试用例中将 test_client 作为参数注入的原因。
我发现几乎总是有益于使用应用实例来做这件事。无论您是使用全局定义的实例,还是使用工厂模式,使用 fixture 都会使测试变得更容易。
因此,我总是在我的 conftest.py 中做类似的事情:
import pytest
from server import app as application_instance
@pytest.fixture
def app():
return application_instance
我现在可以在测试环境的任何地方访问我的应用实例,而无需导入它:
def test_sample(app):
...
提示
您还需要了解关于 fixture 的一个快速技巧。您可以使用 yield 语法在这里帮助您在测试前后注入代码。这对于应用程序尤其有用,如果您需要在测试运行后进行任何清理操作。为了实现这一点,请执行以下操作:
@pytest.fixture
def app():
print("Running before the test")
yield application_instance
print("Running after the test")
通过使用 fixture 访问我们的应用实例,我们现在可以像这样重写之前的单元测试:
def test_sample(app: Sanic):
request, response = app.test_client.get("/")
assert response.status == 200
为了让我们的生活变得简单一些,我为 fixture 添加了类型注解,这样我的 集成开发环境(IDE)就知道它是一个 Sanic 实例。尽管类型提示的主要目的是尽早捕捉错误,但我还喜欢在类似的情况下使用它来使我的 IDE 体验更佳。
这个例子表明,访问测试客户端只是使用 app.test_client 属性的问题。通过这样做,只要安装了包,Sanic 就会自动为您实例化客户端。这使得编写这样的单元测试变得非常简单。
测试蓝图
有时候,你可能会遇到一个场景,你想测试蓝图上存在的某些功能。在这种情况下,我们假设在蓝图之前运行的任何应用程序范围的中间件或监听器都与我们的测试无关。这意味着我们正在测试一些完全包含在蓝图边界内的功能。
我喜欢这种情况,并且会积极寻找它们。原因在于,正如我们将在下一分钟看到的那样,这些测试非常容易进行。这些类型的测试模式最好理解为与我们在 测试完整应用程序部分 中将要做的对比。主要区别在于,在这些测试中,我们的端点不依赖于第三方系统(如数据库)的存在。也许更准确地说,我应该说我应该说的是,它们不依赖于第三方系统可能产生的影响。功能性和业务逻辑是自包含的,因此非常适合单元测试。
当我发现这种情况时,我首先会在我 的 conftest.py 文件中添加一个新的 fixture。它将作为一个我可以用于测试的虚拟应用程序。我创建的每个单元测试都可以使用这个虚拟应用程序,并附加我的目标蓝图,而无需其他任何内容。这使我的单元测试能够更专注于单个示例。让我们看看接下来会是什么样子。
-
在这里,我们将创建一个新的 fixture,它将创建一个新的应用程序实例。
# conftest.py import pytest from sanic import Sanic @pytest.fixture def dummy_app(): return Sanic("DummyApp") -
我们现在可以在蓝图测试中创建一个测试桩:
# test_some_blueprint.py import pytest from path.to.some_blueprint import bp @pytest.fixture def app_with_bp(dummy_app): dummy_app.blueprint(bp) return dummy_app def test_some_blueprint_foobar(app_with_bp): ...
在这个例子中,我们看到我创建了一个仅限于这个模块的 fixture。这样做是为了创建一个可重用的应用程序实例,该实例附加了我的目标蓝图。
这种类型测试的一个简单用例可能是输入验证。让我们添加一个执行一些输入验证的蓝图。该蓝图将有一个简单的 POST 处理程序,它检查传入的 JSON 主体,并仅检查键是否存在,以及类型是否与预期匹配。
-
首先,我们将创建一个模式,它将是我们的端点预期能够测试的键和值类型。
from typing import NamedTuple class ExpectedTypes(NamedTuple): a_string: str an_int: int -
第二,我们将创建一个简单的类型检查器,根据值是否存在以及是否为预期的类型,响应三个值之一:
def _check( exists: bool, value: Any, expected: Type[object], ) -> str: if not exists: return "missing" return "OK" if type(value) is expected else "WRONG" -
最后,我们将创建我们的端点,它将接收请求 JSON 并响应一个字典,说明传递的数据是否有效。
from sanic import Blueprint, Request, json bp = Blueprint("Something", url_prefix="/some") @bp.post("/validation") async def check_types(request: Request): valid = { field_name: _check( field_name in request.json, request.json.get(field_name), field_type ) for field_name, field_type in ExpectedTypes.__annotations__.items() } expected_length = len(ExpectedTypes.__annotations__) status = ( 200 if all(value == "OK" for value in valid.values()) and len(request.json) == expected_length else 400 ) return json(valid, status=status)如您所见,我们现在创建了一个非常简单的数据检查器。我们遍历模式中的定义,并检查每个定义是否符合预期。所有值都应该是
"OK",并且请求数据的长度应该与模式相同。
我们现在可以在我们的测试套件中测试这一点。我们首先可以测试的是确保所有必需的字段都存在。这里有三种可能的场景:输入缺少字段,输入只有正确的字段,以及输入有额外的字段。让我们看看这些场景并为它们创建一些测试:
-
首先,我们将创建一个测试来检查没有缺少字段。
def test_some_blueprint_no_missing(app_with_bp): _, response = app_with_bp.test_client.post( "/some/validation", json={ "a_string": "hello", "an_int": "999", }, ) assert not any( value == "MISSING" for value in response.json.values() ) assert len(response.json) == 2在这个测试中,我们发送了一些不良数据。注意
an_int值实际上是一个str。但我们现在并不关心这一点。这意味着要测试的是所有适当的字段都已发送。 -
接下来是一个应该包含所有输入、正确类型但没有更多内容的测试。
def test_some_blueprint_correct_data(app_with_bp): _, response = app_with_bp.test_client.post( "/some/validation", json={ "a_string": "hello", "an_int": 999, }, ) assert response.status == 200在这里,我们只需要断言响应是 200,因为我们知道如果数据有问题,它将是 400。
-
最后,我们创建一个测试来检查我们没有发送多余的信息。
def test_some_blueprint_bad_data(app_with_bp): _, response = app_with_bp.test_client.post( "/some/validation", json={ "a_string": "hello", "an_int": 999, "a_bool": True, }, ) assert response.status == 400在这个最终测试中,我们发送了已知的不良数据,因为它包含与上一个测试完全相同的有效载荷,除了额外的
"a_bool": True。因此,我们应该断言响应将是 400。
看这些测试,似乎非常重复。虽然“不要重复自己”的原则(简称 DRY)通常被引用为抽象逻辑的理由,但在测试中要小心这一点。我更愿意看到重复的测试代码,而不是一些高度抽象、美丽、闪亮的工厂模式。根据我的经验——是的,我过去曾多次因此受到伤害——在测试代码中添加花哨的抽象层是灾难的配方。一些抽象可能是有帮助的(创建dummy_app固定值是一个好的抽象例子),但过多可能会造成灾难。在未来需要更改某些功能时,解开这些抽象将变得是一场噩梦。这确实是在开发中科学与艺术之间界限模糊的一个领域。创建一个功能强大的测试套件,在重复和抽象之间取得适当的平衡,需要一些实践,并且非常主观。
在消除这些警告之后,有一个抽象层我确实很喜欢。它利用了pytest.parametrize。这是一个超级有用的功能,允许你创建一个测试并针对多个输入运行它。我们并不是在抽象测试本身,而是在使用各种输入测试相同的代码。
使用pytest.parametrize,我们实际上可以将这三个测试压缩成一个测试:
-
我们创建了一个有两个参数的装饰器:一个包含逗号分隔的参数名称列表的字符串,以及一个包含要注入测试中的值的可迭代对象。
@pytest.mark.parametrize( "input,has_missing,expected_status", ( ( { "a_string": "hello", }, True, 400, ), ( { "a_string": "hello", "an_int": "999", }, False, 400, ), ( { "a_string": "hello", "an_int": 999, }, False, 200, ), ( { "a_string": "hello","an_int": 999, "a_bool": True, }, False, 400, ), ), )我们有三个值要注入到我们的测试中:
input、has_missing和expected_status。测试将运行多次,每次它都会从参数的元组中抽取一个来注入到测试函数中。 -
我们的测试函数现在可以抽象化以使用这些参数:
def test_some_blueprint_data_validation( app_with_bp, input, has_missing, expected_status, ): _, response = app_with_bp.test_client.post( "/some/validation", json=input, ) assert any( value == "MISSING" for value in response.json.values() ) is has_missing assert response.status == expected_status这样做,我们更容易为不同的用例编写多个单元测试。您可能已经注意到,我实际上创建了一个第四个测试。由于使用这种方法添加更多测试非常简单,我包含了一个我们之前没有测试过的用例。我希望您能看到这种做法带来的巨大好处,并学会喜欢使用
@pytest.mark.parametrize进行测试。
在这个例子中,我们定义了输入和预期的结果。通过参数化单个测试,它实际上在pytest内部转换成了多个测试。
这些示例的代码可以在 GitHub 仓库中找到:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/09/testing2。
模拟服务
我们测试的样本蓝图显然不是我们会在现实生活中使用的。在那个例子中,我们实际上并没有对数据进行任何操作。我之所以简化它,是为了我们不必担心如何处理与数据库访问层等服务的交互。当我们测试一个真实端点时怎么办?而且,我所说的真实端点是指那些旨在与数据库接口的端点。例如,注册端点怎么样?我们如何测试注册端点实际上执行了它应该执行的操作,并按预期注入了数据?
即使我知道我的端点需要执行一些数据库操作,我仍然喜欢使用dummy_app模式进行测试。我们将探讨如何使用 Python 的模拟工具来模拟我们有一个真实的数据库层:
-
首先,我们需要重构我们的蓝图,使其看起来像您可能在野外遇到的东西:
@bp.post("/") async def check_types(request: Request): _validate(request.json, RegistrationSchema) connection: FakeDBConnection = request.app.ctx.db service = RegistrationService(connection) await service.register_user(request.json["username"], request.json["email"]) return json(True, status=201)我们仍在进行输入验证。然而,我们不会仅仅将注册详情存储在内存中,而是将它们发送到数据库以写入磁盘。您可以在
github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/09/testing3查看完整的代码,以了解输入验证。这里需要注意的重要事项是我们有一个RegistrationService,并且它调用了一个register_user方法。 -
由于我们还没有探讨对象关系映射(ORM)的使用,我们的数据库存储函数最终只是调用一些原始的 SQL 查询。我们将在“管理数据库连接”中更详细地探讨 ORM,但现在,让我们创建注册服务:
from .some_db_connection import FakeDBConnection class RegistrationService: def __init__(self, connection: FakeDBConnection) -> None: self.connection = connection async def register_user( self, username: str, email: str ) -> None: query = "INSERT INTO users VALUES ($1, $2);" await self.connection.execute(query, username, email) -
注册服务调用我们的数据库来执行一些 SQL 语句。我们还需要一个到数据库的连接。为了举例说明,我使用了一个假类,但这个类(并且应该是)您的应用程序用来连接数据库的实际对象。因此,想象这是一个合适的 DB 客户端:
from typing import Any class FakeDBConnection: async def execute(self, query: str, *params: Any): ... -
在此基础上,我们现在可以创建一个新的测试用例,它将取代我们的数据访问层。通常,你会创建类似的东西来实例化客户端:
from sanic import Sanic from .some_db_connection import FakeDBConnection app = Sanic.get_app() @app.before_server_start async def setup_db_connection(app, _): app.ctx.db = FakeDBConnection()发挥你的想象力,想象一下上述代码片段存在于我们的实际应用程序中。它初始化数据库连接,并允许我们在端点内访问客户端,如前述代码所示,因为我们的连接使用了应用程序的
ctx对象。由于我们的单元测试无法访问数据库,我们需要创建一个模拟数据库并将其附加到我们的模拟应用程序上。 -
为了做到这一点,我们将创建我们的
dummy_app,然后导入实际应用程序使用的实际监听器来实例化模拟客户端。@pytest.fixture def dummy_app(): app = Sanic("DummyApp") import_module("testing3.path.to.some_startup") return app -
为了强制我们的客户端使用模拟方法而不是实际向数据库发送网络请求,我们将使用 pytest 的一个功能来 monkeypatch DB 客户端。设置一个类似的测试用例:
from unittest.mock import AsyncMock @pytest.fixture def mocked_execute(monkeypatch): execute = AsyncMock() monkeypatch.setattr( testing3.path.to.some_db_connection.FakeDBConnection, "execute", execute ) return execute现在我们已经用模拟的
execute方法替换了真实的execute方法,我们可以继续构建我们的注册蓝图测试。使用unittest.mock库的一个巨大好处是,它允许我们创建断言,表明数据库客户端已被调用。我们将在下一部分看到这会是什么样子。 -
在这里,我们创建了一个包含一些断言的测试,这些断言帮助我们了解正确数据将如何到达数据访问层:
@pytest.mark.parametrize( "input,expected_status", ( ( { "username": "Alice", "email": "alice@bob.com", }, 201, ), ), ) def test_some_blueprint_data_validation( app_with_bp, mocked_execute, input, expected_status, ): _, response = app_with_bp.test_client.post( "/registration", json=input, ) assert response.status == expected_status if expected_status == 201: mocked_execute.assert_awaited_with( "INSERT INTO users VALUES ($1, $2);", input["username"], input["email"] )就像之前一样,我们使用
parametrize,这样我们就可以使用不同的输入运行多个测试。关键要点是,由于我们正在使用模拟的execute方法,我们可以要求 pytest 为我们提供它,以便我们的测试可以断言它被调用,就像我们预期的那样。
这对于测试隔离问题当然很有帮助,但当我们需要进行应用程序范围内的测试时怎么办?我们将在下一部分探讨这个问题。
测试完整的应用程序
随着应用程序从其婴儿期发展,可能会开始形成一个由中间件、监听器和信号组成的网络,这些网络处理请求,不仅限于路由处理器。此外,还可能与其他服务(如数据库)建立连接,这会复杂化整个过程。典型的 Web 应用程序不能在真空中运行。当它启动时,它需要连接到其他服务。这些连接对于应用程序的正确性能至关重要,因此如果它们不存在,则应用程序无法启动。测试这些连接可能会非常麻烦。不要只是举手投降并放弃。抵制诱惑。在前面的测试中,我们已经看到了如何简单实现这一点。事实上,我们已经成功测试了我们的数据库。但那还不够?
有时候仅对dummy_app进行测试是不够的。
这也是为什么我非常喜欢由工厂模式创建的应用程序。本章的 GitHub 仓库就是一个我经常使用的工厂模式的例子。它包含了一些非常有用的功能。本质上,最终结果是返回一个带有所有附加内容的 Sanic 实例的函数。通过 Sanic 标准库的实现,该函数会遍历你的源代码,寻找可以附加到其上的内容(路由、蓝图、中间件、信号、监听器等等),并且被设置为避免循环导入问题。我们之前在第二章,组织项目中讨论了工厂模式和它们的优点。
目前特别重要的是,GitHub 仓库中的工厂可以选择性选择要实例化的内容。这意味着我们可以使用具有针对性功能的有效应用程序。让我提供一个例子。
以前,我正在构建一个应用程序。了解它在现实世界中的确切性能至关重要。因此,我创建了一个中间件,它会计算一些性能指标,然后将它们发送给供应商进行分析。性能至关重要——这也是我最初选择使用 Sanic 的原因之一。当我尝试进行一些测试时,我意识到如果它没有连接到供应商,我就无法在我的测试套件中运行应用程序。是的,我可以模拟它。然而,更好的策略是根本跳过这个操作。有时候,真的没有必要测试每一个功能点。
为了使这一点更具体,这里是对我所谈论的内容的一个快速解释。这是一个中间件代码片段,它在请求的开始和结束时计算运行时间,并将它发送出去:
from time import time
from sanic import Sanic
app = Sanic.get_app()
@app.on_request
async def start_timer(request: Request) -> None:
request.ctx.start_time = time()
@app.on_response
async def stop_timer(request: Request, _) -> None:
end_time = time()
total = end_time - request.ctx.start_time
async send_the_value_somewhere(total)
解决我的测试与生产行为对比问题的解决方案之一可能是将应用程序代码更改为仅在生产中运行:
if app.config.ENVIRONMENT == "PRODUCTION":
...
但在我看来,更好的解决方案是根本跳过这个中间件。使用仓库中显示的工厂模式,我可以这样做:
from importlib import import_module
from typing import Optional, Sequence
from sanic import Sanic
DEFAULT = ("path.to.some_middleware.py",)
def create_app(modules: Optional[Sequence[str]] = None) -> Sanic:
app = Sanic("MyApp")
if modules is None:
modules = DEFAULT
for module in modules:
import_module(module)
return app
在这个工厂中,我们正在创建一个新的应用程序实例,并遍历一个已知模块列表来导入它们。在正常使用中,我们会通过调用create_app()来创建一个应用程序,工厂会导入DEFAULT已知模块。通过导入它们,它们将附加到我们的应用程序实例上。更重要的是,这个工厂允许我们发送一个任意模块列表来加载。这使我们能够在测试中创建一个使用我们应用程序的实际工厂模式的固定装置,同时拥有选择加载内容的控制权。
在我们的用例中,我们决定我们不想测试性能中间件。我们可以通过创建一个简单地忽略该模块的测试固定装置来跳过它:
from path.to.factory import create_app
@pytest.fixture
def dummy_app():
return create_app(modules=[])
如你所见,这为我创建针对我实际应用程序特定部分的测试打开了大门,而不仅仅是模拟应用程序。通过使用包含和排除,我可以创建只包含我需要的功能的单元测试,并避免不必要的功能。
我希望你的脑海中现在充满了这个为你打开的可能性。当应用程序本身是可组合的时候,测试变得容易得多。这个神奇的技巧是真正将你的应用程序开发提升到下一个水平的一种方式。一个容易组合的应用程序成为一个容易测试的应用程序。这导致应用程序得到良好的测试,现在你真正走上了成为高级开发者的道路。
如果你还没有开始,我强烈建议你使用像我这样的工厂。大胆地复制它。只需向我承诺你会用它来创建一些单元测试。
使用可重用客户端进行测试
到目前为止,我们一直在使用一个测试客户端,每次调用它都会启动和停止一个服务。sanic-testing包附带另一个可以手动启动和停止的测试客户端。因此,可以在调用之间或测试之间重用它。在下一小节中,我们将了解这个可重用测试客户端。
每个测试运行一个单独的测试服务器
你有时可能需要在同一实例上运行多个 API 调用。例如,如果你在内存中在调用之间存储一些临时状态,这可能是有用的。显然,在大多数情况下,这不是一个好的解决方案,因为将状态存储在内存中会使水平扩展变得困难。不谈这个问题,让我们快速看看你可能如何实现它:
-
我们首先创建一个只输出计数器的端点:
from sanic import Sanic, Request, json from itertools import count app = Sanic("test") @app.before_server_start def setup(app, _): app.ctx.counter = count() @app.get("") async def handler(request: Request): return json(next(request.app.ctx.counter))在这个简化的例子中,每次你点击端点时,它都会增加一个数字。
-
我们可以通过以下方式使用
ReusableClient实例来测试维护内部状态的端点:from sanic_testing.reusable import ReusableClient def test_reusable_context(): client = ReusableClient(app, host="localhost", port=9999) with client: _, response = client.get("/") assert response.json == 0 _, response = client.get("/") assert response.json == 1 _, response = client.get("/") assert response.json == 2只要你在那个
with上下文管理器内部使用客户端,那么你每次调用都会遇到你应用程序的相同实例。 -
我们可以通过使用固定值来简化前面的代码:
from sanic_testing.reusable import ReusableClient import pytest @pytest.fixture def test_client(): client = ReusableClient(app, host="localhost", port=9999) client.run() yield client client.stop()现在,当你设置单元测试时,它将保持服务器在测试函数执行期间运行。
-
如前所述的单元测试可以写成以下形式:
def test_reusable_fixture(test_client): _, response = test_client.get("/") assert response.json == 0 _, response = test_client.get("/") assert response.json == 1 _, response = test_client.get("/") assert response.json == 2如你所见,如果你只想在测试函数的整个运行期间运行单个服务器,这是一个潜在的强大策略。那么,如果你想在整个测试期间保持实例运行呢?最简单的方法就是将固定值的
scope改为session:@pytest.fixture(scope="session") def test_client(): client = ReusableClient(app, host="localhost", port=9999) client.run() yield client client.stop()
使用这个设置,无论你在pytest中运行测试在哪里,它都会使用相同的应用程序。虽然我个人从未觉得有必要使用这种模式,但我确实看到了它的实用性。
这个示例的代码可以在 GitHub 仓库中找到:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/09/testing4。
在适当的异常管理和测试完成之后,任何真正的专业应用程序的下一个关键添加是日志。
从日志和跟踪中获得洞察
当谈到日志时,我认为大多数 Python 开发者可以分为三大类:
-
总是使用
print语句的人。 -
对日志设置有极端意见和极其复杂的人。
-
知道不应该使用
print,但没有时间或精力去理解 Python 的logging模块的人。
如果你属于第二类,你大可以跳过这一节。里面对你来说没有什么内容,除非你只是想批评我的解决方案,并告诉我有更好的方法。
如果你属于第一类,那么你真的需要学会改变你的习惯。不要误会,print非常棒。然而,它不适合专业级 Web 应用,因为它不提供日志模块提供的灵活性。
“等一下!”我听到第一类人已经开始喊叫了。“如果我使用容器和 Kubernetes 部署我的应用程序,它可以从那里获取我的输出并将其重定向。”/// 如果你坚决反对使用日志,那么我想我可能无法改变你的想法。然而,抛开配置复杂性不谈,考虑一下日志模块提供了丰富的 API 来发送不同级别和元上下文的消息。
看一下标准的 Sanic 访问日志。访问记录器发送的消息实际上是空的。如果你想查看 Sanic 代码库,请自行查看。访问日志是这样的:
access_logger.info("")
你实际上看到的是更类似于以下的内容:
[2021-10-21 09:39:14 +0300] - (sanic.access)[INFO][127.0.0.1:58388]: GET http://localhost:9999/ 200 13
在那一行中嵌入了一堆既适合机器读取又适合人类阅读的元数据,这要归功于logging模块。实际上,你可以使用日志存储任意数据,一些日志配置会为你存储这些数据。例如:
log.info("Some message", extra={"arbitrary": "data"})
如果我已经说服了你,并且你想了解更多关于如何在 Sanic 中使用日志的信息,那么让我们继续。
Sanic 日志记录器的类型
Sanic 自带了三个日志记录器。你可以在log模块中访问它们:
from sanic.log import access_logger, error_logger, logger
随意使用这些在你的应用程序中。特别是在较小的项目中,我经常为了方便而使用 Sanic 的logger对象。当然,这些实际上是供 Sanic 本身使用的,但没有任何阻止你使用它们的。事实上,这可能很方便,因为你知道所有的日志都是格式一致的。我唯一的警告是,最好让access_logger对象保持原样,因为它有一个非常具体的工作。
为什么你想同时使用error_logger和常规 logger 呢?我认为答案取决于你希望你的日志发生什么。有许多选项可供选择。最简单的形式当然是直接输出到控制台。然而,对于错误日志来说,这并不是一个好主意,因为你没有方法持久化消息,在发生错误时无法查看它们。因此,你可能采取下一步,将你的error_logger输出到文件。当然,这可能会变得繁琐,所以你决定使用第三方系统将日志发送到另一个应用程序以存储并使其可访问。无论你希望设置什么,使用多个 logger 可能在处理和分发日志消息中扮演特定的角色。
创建自己的 logger,我应用程序开发的第一个步骤
当我面对一个新的项目时,我会问自己一个问题:我的生产日志会发生什么?当然,这是一个高度依赖于你应用程序的问题,你需要自己决定。尽管如此,提出这个问题却突出了一个非常重要的观点:开发日志和生产日志之间有一个区别。很多时候,我甚至不知道在生产环境中我想对它们做什么。我们可以将这个问题推迟到另一天。
在我开始编写应用程序之前,我会创建一个日志框架。我知道目标是拥有两套配置,所以我从我的开发日志开始。
我想再次强调:构建应用程序的第一步是创建一个超级简单的框架,用于设置带有日志的应用程序。那么,我们现在就来看看这个设置过程:
-
第一件事,我们将按照在第二章,“组织项目”中确立的模式,创建一个超级基本的脚手架:
. ├── Dockerfile ├── myapp │ ├── common │ │ ├── __init__.py │ │ └── log.py │ ├── __init__.py │ └── server.py └── tests这是我喜欢与之工作的应用程序结构,因为它使我开发起来非常容易。使用这种结构,我们可以轻松地创建一个专注于本地运行应用程序、测试应用程序、记录日志和构建镜像的开发环境。在这里,我们显然关注的是本地运行应用程序并记录日志。
-
接下来,我喜欢创建我的应用程序工厂,并在上面放置一个我将稍后删除的虚拟路由。以下是
server.py的创建方式。我们将继续添加内容:from sanic import Sanic, text from myapp.common.log import setup_logging, app_logger def create_app(): app = Sanic(__name__) setup_logging() @app.route("") async def dummy(_): app_logger.debug("This is a DEBUG message") app_logger.info("This is a INFO message") app_logger.warning("This is a WARNING message") app_logger.error("This is a ERROR message") app_logger.critical("This is a CRITICAL message") return text("") return app在创建我的应用程序实例之后,我之所以称之为
setup_logging,是因为我想能够使用 Sanic 的配置逻辑来加载可能用于创建我的日志设置的环境变量。在继续之前,我想先提一下。在创建 Pythonlogger对象时,有两种不同的观点。一方面认为,在每一个模块中创建一个新的logger是最佳实践。在这种情况下,你会在每个 Python 文件的顶部放置以下代码:from logging import getLogger logger = getLogger(__name__)这种方法的优点是创建它的模块名称与日志记录器名称紧密相关。这当然有助于追踪日志的来源。然而,另一方却认为应该是一个单一的全球变量,被导入并重复使用,因为这可能更容易配置和控制。此外,我们可以通过适当的日志格式快速获取特定的文件名和行号,因此没有必要在日志记录器名称中包含模块名称。虽然我不否认本地化、按模块的方法,但我也更喜欢导入单个实例的简单性:
from logging import getLogger logger = getLogger("myapplogger")如果你深入研究日志记录,这也为你提供了更大的能力来控制不同的日志记录实例如何操作。类似于关于异常处理器的讨论,我更愿意限制我需要控制的实例数量。在我刚刚展示的
server.py示例中,我选择了第二个选项,使用单个全局logging实例。这是一个个人选择,在我看来没有错误答案。两种策略都有其利弊,所以选择对你来说有意义的那个。 -
下一步是创建基本的
log.py。目前,让我们保持它非常简单,然后我们将从那里开始构建:import logging app_logger = logging.getLogger("myapplogger") def setup_logging(): ... -
在此基础上,我们就可以运行应用程序并对其进行测试。但是等等?!我们传递给
sanic命令的应用程序在哪里?我们之前使用这个来运行我们的应用程序:$ sanic src.server:app -p 7777 --debug --workers=2相反,我们将告诉 Sanic CLI
create_app函数的位置,并让它为我们运行。将你的启动方式改为如下:$ sanic myapp.server:create_app --factory -p 7777 --debug --workers=2
你现在应该能够到达你的端点并看到一些基本的消息输出到你的终端。你很可能没有DEBUG消息,因为日志记录器可能仍然设置为只记录INFO级别及以上。你应该看到一些非常基础的类似如下内容:
This is a WARNING message
This is a ERROR message
This is a CRITICAL message
配置日志记录
前面的日志消息正是使用print可以提供的内容。接下来我们需要添加一些配置,以便输出一些元数据并格式化消息。重要的是要记住,某些日志细节可能需要根据生产环境进行定制:
-
因此,我们将首先创建一个简单的配置。
DEFAULT_LOGGING_FORMAT = "[%(asctime)s] [%(levelname)s] [%(filename)s:%(lineno)s] %(message)s" def setup_logging(app: Sanic): formatter = logging.Formatter( fmt=app.config.get("LOGGING_FORMAT", DEFAULT_LOGGING_FORMAT), datefmt="%Y-%m-%d %H:%M:%S %z", ) handler = logging.StreamHandler() handler.setFormatter(formatter) app_logger.addHandler(handler)请注意,我们已经将
setup_logging函数的签名函数更改为现在接受应用程序实例作为参数。请确保回到更新你的server.py文件以反映这一变化。作为旁注,有时你可能想简化你的日志记录,以强制 Sanic 使用相同的处理器。虽然你当然可以通过更新 Sanic 日志记录器配置的过程(见sanicframework.org/en/guide/best-practices/logging.html#changing-sanic-loggers)来完成,但我发现这太过繁琐。一个更简单的方法是设置日志处理器,然后将它们简单地应用到 Sanic 日志记录器上,如下所示:from sanic.log import logger, error_logger def setup_logging(app: Sanic): ... logger.handlers = app_logger.handlers error_logger.handlers = app_logger.handlers总是保留一个
StreamHandler是一个好的实践。这将用于将日志输出到控制台。但是,当我们想要为生产添加一些额外的日志工具时怎么办?由于我们还不完全确定我们的生产需求是什么,我们将暂时将日志设置到文件中。这总可以在另一个时间替换。 -
将你的
log.py修改如下:def setup_logging(app: Sanic): formatter = logging.Formatter( fmt=app.config.get("LOGGING_FORMAT", DEFAULT_LOGGING_FORMAT), datefmt="%Y-%m-%d %H:%M:%S %z", ) handler = logging.StreamHandler() handler.setFormatter(formatter) app_logger.addHandler(handler) if app.config.get("ENVIRONMENT", "local") == "production": file_handler = logging.FileHandler("output.log") file_handler.setFormatter(formatter) app_logger.addHandler(file_handler)
你可以很容易地看到如何配置不同的日志处理程序或格式,这可能与不同环境中的需求更接近。
所示的所有配置都使用了日志实例的程序控制。logging库的一个巨大灵活性是所有这些都可以通过一个单一的dict配置对象来控制。因此,你会发现保留包含日志配置的 YAML 文件是一个非常常见的做法。这些文件易于更新,可以在构建环境中互换以控制生产设置。
添加颜色上下文
上述设置完全有效,你可以在这里停止。然而,对我来说,这还不够。当我开发一个网络应用程序时,我总是打开终端输出日志。在消息的海洋中,可能很难筛选出所有文本。我们如何使它更好?我们将通过适当使用颜色来实现这一点。
由于我通常不需要在我的生产输出中添加颜色,所以我们只会在本地环境中添加颜色格式化:
-
我们将首先设置一个自定义的日志格式化器,该格式化器将根据日志级别添加颜色。任何调试信息都是蓝色,警告是黄色,错误是红色,而一个关键信息将以红色和白色背景显示,以便突出显示(在深色终端中):
class ColorFormatter(logging.Formatter): COLORS = { "DEBUG": "\033[34m", "WARNING": "\033[01;33m", "ERROR": "\033[01;31m", "CRITICAL": "\033[02;47m\033[01;31m", } def format(self, record) -> str: prefix = self.COLORS.get(record.levelname) message = super().format(record) if prefix: message = f"{prefix}{message}\033[0m" return message我们使用大多数终端都理解的标准的颜色转义码来应用颜色。这将使整个消息着色。当然,你可以通过只着色消息的一部分来变得更为复杂,如果你对此感兴趣,我建议你尝试使用这个格式化器看看你能实现什么。
-
在创建这个之后,我们将快速创建一个内部函数来决定使用哪个格式化器:
import sys def _get_formatter(is_local, fmt, datefmt): formatter_type = logging.Formatter if is_local and sys.stdout.isatty(): formatter_type = ColorFormatter return formatter_type( fmt=fmt, datefmt=datefmt, )如果我们处于一个 TTY 终端的本地环境,那么我们使用我们的颜色格式化器。
-
我们需要更改
setup_logging函数的开始部分,以考虑到这些变化。我们还将抽象出更多细节到我们的配置中,以便可以轻松地按环境更改它们:DEFAULT_LOGGING_FORMAT = "[%(asctime)s] [%(levelname)s] [%(filename)s:%(lineno)s] %(message)s" DEFAULT_LOGGING_DATEFORMAT = "%Y-%m-%d %H:%M:%S %z" def setup_logging(app: Sanic): environment = app.config.get("ENVIRONMENT", "local") logging_level = app.config.get( "LOGGING_LEVEL", logging.DEBUG if environment == "local" else logging.INFO ) fmt = app.config.get("LOGGING_FORMAT", DEFAULT_LOGGING_FORMAT) datefmt = app.config.get("LOGGING_DATEFORMAT", DEFAULT_LOGGING_DATEFORMAT) formatter = _get_formatter(environment == "local", fmt, datefmt) ...
除了动态获取格式化器之外,这个示例还为这个谜题增加了一个新的部分。它是使用配置值来确定你的日志记录器的日志级别。
添加一些基本的跟踪请求 ID
日志中常见的问题之一是它们可能会变得嘈杂。可能很难将特定的日志与特定的请求相关联。例如,你可能同时处理多个请求。如果出现错误,并且你想回顾早先的消息,你该如何知道哪些日志应该被分组在一起?
完全有第三方应用程序添加了所谓的跟踪功能。如果你正在构建一个由相互关联的微服务组成的系统,这些微服务协同工作以响应传入的请求,这将特别有帮助。虽然不一定深入探讨微服务架构,但在这里提一下,跟踪是一个重要的概念,应该添加到你的应用程序中。这适用于无论你的应用程序架构是否使用微服务。
对于我们的目的,我们想要实现的是为每个请求添加一个请求标识符。每当该请求尝试记录某些内容时,该标识符将自动注入到我们的请求格式中。为了实现这个目标:
-
首先,我们需要一种机制将请求对象注入到每个日志操作中。
-
其次,我们需要一种方法来显示标识符如果它存在,或者如果不存在则忽略它。
在我们进入代码实现之前,我想指出,第二部分可以通过几种方式处理。最简单的方法可能是创建一个特定的记录器,它只会在请求上下文中使用。这意味着你将有一个用于启动和关闭操作的记录器,另一个仅用于请求。我见过这种方法被很好地使用。
问题是我们再次使用了多个记录器。坦白说,我真的很喜欢只有一个实例,它可以适用于我所有的用例的简单性。这样我就不需要费心考虑我应该使用哪个记录器。因此,我将在这里向你展示如何构建第二种选择:一个可以在应用程序的任何地方使用的全能型记录器。如果你更喜欢更具体类型,那么我挑战你在这里使用我的概念,构建两个记录器而不是一个。
我们将从处理传递请求上下文的问题开始。记住,由于 Sanic 是异步操作的,我们无法保证哪个请求将以什么顺序被处理。幸运的是,Python 标准库有一个与 asyncio 工作得很好的实用工具,那就是 contextvars 模块。我们将开始创建一个监听器,设置一个我们可以用来共享请求对象并将其传递给日志框架的上下文:
-
创建一个名为
./middleware/request_context.py的文件。它应该看起来像这样:from contextvars import ContextVar from sanic import Request, Sanic app = Sanic.get_app() @app.after_server_start async def setup_request_context(app, _): app.ctx.request = ContextVar("request") @app.on_request async def attach_request(request: Request): request.app.ctx.request.set(request)这里发生的事情是我们正在创建一个可以在任何有权访问我们的应用程序的地方访问的上下文对象。然后,在每次请求中,我们将当前请求附加到上下文变量中,使其在任何应用程序实例可访问的地方都可以访问。
-
接下来需要做的事情是创建一个日志过滤器,它会获取请求(如果存在)并将其添加到我们的日志记录中。为了做到这一点,我们实际上会在
log.py文件中覆盖 Python 创建日志记录的函数:old_factory = logging.getLogRecordFactory() def _record_factory(*args, app, **kwargs): record = old_factory(*args, **kwargs) record.request_info = "" if hasattr(app.ctx, "request"): request = app.ctx.request.get(None) if request: display = " ".join([str(request.id), request.method, request.path]) record.request_info = f"[{display}] " return record确保你注意到我们需要保存默认的记录工厂,因为我们想利用它。然后当这个函数执行时,它会检查是否有当前请求,通过查看我们刚刚设置的请求上下文。
-
我们还需要更新我们的格式以使用这条新信息。确保更新这个值:
DEFAULT_LOGGING_FORMAT = "[%(asctime)s] [%(levelname)s] [%(filename)s:%(lineno)s] %(request_info)s%(message)s" -
最后,我们可以按照以下方式注入新的工厂:
from functools import partial def setup_logging(app: Sanic): ... logging.setLogRecordFactory(partial(_record_factory, app=app))随意检查这本书的 GitHub 仓库,以确保你的
log.py看起来像我的一样:github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/09/tracing. -
在所有这些准备就绪后,是时候访问我们的端点了。你现在应该在终端看到一些漂亮的颜色,以及一些请求信息被插入:
[2021-10-21 12:22:48 +0300] [DEBUG] [server.py:12] [b5e7da51-68b0-4add-a850-9855c0a16814 GET /] This is a DEBUG message [2021-10-21 12:22:48 +0300] [INFO] [server.py:13] [b5e7da51-68b0-4add-a850-9855c0a16814 GET /] This is a INFO message [2021-10-21 12:22:48 +0300] [WARNING] [server.py:14] [b5e7da51-68b0-4add-a850-9855c0a16814 GET /] This is a WARNING message [2021-10-21 12:22:48 +0300] [ERROR] [server.py:15] [b5e7da51-68b0-4add-a850-9855c0a16814 GET /] This is a ERROR message [2021-10-21 12:22:48 +0300] [CRITICAL] [server.py:16] [b5e7da51-68b0-4add-a850-9855c0a16814 GET /] This is a CRITICAL message
在运行完这些示例后,你可能注意到了一些之前没有注意到的事情,那就是request.id。这是什么,它从哪里来?
使用 X-Request-ID
使用 UUID 来跟踪请求是一种常见的做法。这使得客户端应用程序跟踪请求并将它们与特定实例相关联变得非常容易。这就是为什么你经常会听到它们被称为关联 ID。如果你听到这个术语,它们就是同一回事。
作为关联请求实践的一部分,许多客户端应用程序会发送一个 X-Request-ID 头。如果 Sanic 在传入的请求中看到这个头,那么它将获取这个 ID 并使用它来识别请求。如果没有,那么它将自动为你生成一个 UUID。因此,你应该能够发送以下请求到我们的日志应用,并看到日志中填充了这个 ID:
$ curl localhost:7777 -H 'x-request-id: abc123'
为了简化,我没有使用 UUID。
你的日志现在应该反映这一点:
[2021-10-21 12:36:00 +0300] [DEBUG] [server.py:12] [abc123 GET /] This is a DEBUG message
[2021-10-21 12:36:00 +0300] [INFO] [server.py:13] [abc123 GET /] This is a INFO message
[2021-10-21 12:36:00 +0300] [WARNING] [server.py:14] [abc123 GET /] This is a WARNING message
[2021-10-21 12:36:00 +0300] [ERROR] [server.py:15] [abc123 GET /] This is a ERROR message
[2021-10-21 12:36:00 +0300] [CRITICAL] [server.py:16] [abc123 GET /] This is a CRITICAL message
记录日志是专业级 Web 应用的一个关键组件。它实际上并不需要那么复杂。我见过超级冗长且过于冗词的配置,老实说这让我感到害怕。然而,只要稍微注意细节,你就可以轻松地获得一个真正出色的日志体验。我鼓励你获取这个源代码,并修改它直到满足你的需求。
我们接下来将注意力转向 Web 应用的另一个关键组件:数据库管理。
管理数据库连接
这本书首先希望为你提供信心,让你能够以自己的方式构建应用程序。这意味着我们正在积极努力消除复制粘贴式开发。你知道我的意思。你访问Stackoverflow或另一个网站,复制代码,粘贴,然后继续你的日子,没有多想。
这种复制粘贴的心态在数据库连接方面可能最为普遍。是时候接受挑战了。启动一个新的 Sanic 应用程序并将其连接到数据库。一些开发者可能会通过前往其他代码库(来自另一个项目、文章、文档或帮助网站)来应对这个挑战,复制一些基本的连接函数,更改凭证,然后结束。他们可能从未深入思考过连接到数据库意味着什么:如果它工作,那么它就是好的。我知道我确实长时间这样做过。
这不是我们在这里要做的。相反,我们将考虑一些常见的场景,思考我们的担忧,并围绕它们开发解决方案。
使用 ORM 还是不要使用 ORM,这是一个问题
为了让那些不知道 ORM 是什么的人受益,这里有一个简短的定义:
ORM 是一个用于构建 Python 原生对象的框架。这些对象直接与数据库模式相关联,并且也用于构建查询以从数据库中检索数据,以便在构建 Python 对象时使用。换句话说,它们是一个具有从 Python 和数据库双向翻译能力的数据访问层。当人们谈论 ORM 时,他们通常指的是旨在与基于 SQL 的数据库一起使用的 ORM。
关于是否使用 ORM 的疑问充满了强烈的观点。在某些情况下,如果不用 ORM 而是手动编写 SQL 查询,人们可能会认为你生活在石器时代。另一方面,有些人可能会认为 ORM 是一种麻烦,会导致过于简单化,同时又复杂且低效的查询。我想在一定程度上,这两组人都是正确的。
理想情况下,我无法告诉你应该做什么或不应该做什么。实现细节和用例与任何决策都高度相关。在我的项目中,我倾向于避免使用它们。我喜欢使用 databases 项目(github.com/encode/databases)来构建自定义 SQL 查询,然后将结果映射到 dataclass 对象。在手工编写我的 SQL 之后,我使用一些实用工具将它们从原始、非结构化的值转换为模式定义的 Python 对象。我过去也广泛使用了像 peewee (github.com/coleifer/peewee) 和 SQLAlchemy (github.com/sqlalchemy/sqlalchemy) 这样的 ORM。当然,由于我多年来一直在 Django 中开发,我在其内部 ORM 上也做了很多工作。
你应该在什么时候使用 ORM?首先,对于大多数项目来说,使用 ORM 可能应该是默认选项。它们在增加所需的安全性和安全性方面非常出色,以确保你不会意外地引入安全漏洞。通过强制类型,它们在维护数据完整性方面可以极为有益。当然,还有抽象化大量数据库知识的优势。它们可能不足之处在于处理复杂性的能力。随着项目表的数量和相互关系的增加,可能更难继续使用 ORM。此外,SQL 语言(如 Postgresql)中还有许多更高级的选项,这些选项你无法通过 ORM 构建查询来完成。我发现它们在更简单的 CRUD(创建/读取/更新/删除)应用中表现得非常出色,但实际上会阻碍更复杂的数据库模式。
ORM 的另一个潜在缺点是,它们使你自己的项目变得非常容易受到破坏。在构建低效查询时犯的一个小错误可能会导致极端长的响应时间,以及超级快的响应。作为一名经历过这种错误的人,我发现使用 ORM 构建的应用程序往往会过度获取数据,并且运行比所需的更多网络调用。如果你对 SQL 感到舒适,并且知道你的数据将变得相当复杂,那么你可能最好编写自己的 SQL 查询。使用手工编写的 SQL 的最大好处是它克服了 ORM 的复杂性扩展问题。
尽管这本书不是关于 SQL 的,但经过深思熟虑,我认为我们最好的时间是构建一个自定义数据层,而不是使用现成的 ORM。这个选项将迫使我们做出良好的选择,以维护我们的连接池并开发安全且实用的 SQL 查询。此外,这里讨论的任何关于实现的内容都可以轻松地替换为功能更全面的 ORM。如果你更熟悉并舒适地使用 SQLAlchemy(现在有异步支持),那么请随意相应地替换我的代码。
在 Sanic 中创建自定义数据访问层
在决定这本书使用哪种策略时,我探索了那里的大量选项。我查看了我看到人们与 Sanic 一起使用的所有流行的 ORM。一些选项(如 SQLAlchemy)有如此多的材料,我根本无法做到公正。其他选项鼓励较低质量的设计模式。因此,我们转向我最喜欢的之一,使用asyncpg连接到 Postgres(我选择的 SQL 关系数据库)。目标是实现良好的连接管理,
我强烈建议您查看 GitHub 仓库中的代码github.com/PacktPublishing/Web-Development-with-Sanic/tree/main/chapters/09/hikingapp。这是我们第一次创建一个完整的应用程序。我的意思是,这是一个示例应用程序,它会出去获取一些数据。回到第二章,组织项目中关于项目布局的讨论,您将看到我们可能如何构建一个真实世界的应用程序的示例。那里还有很多内容,与这里的讨论(主要关注数据库连接)有些不同,所以我们现在不会深入探讨。但请放心,当我们构建完整的应用程序时,我们将在第十一章再次回到应用程序的模式。同时,现在可能是您回顾源代码的好机会。尝试理解项目的结构,运行它,然后测试一些端点。说明在仓库中:github.com/PacktPublishing/Web-Development-with-Sanic/blob/main/chapters/09/hikingapp/README.md。
我还想指出,随着我们新增了一个服务,我们的应用程序正在增长,所以我打算开始使用 docker-compose 和 Docker 容器在本地上运行它。所有构建材料都存放在 GitHub 仓库中,供您复制以满足自己的需求。但当然,您不会仅仅复制粘贴代码而不真正理解它,所以让我们确保您确实做到了。
我们正在讨论的应用程序是一个用于存储徒步旅行详情的 Web API。它将其已知的徒步旅行数据库连接到可以跟踪他们徒步旅行总距离以及何时徒步旅行某些路线的用户。当您启动数据库时,应该有一些预先填充的信息供您使用。
我们必须做的第一件事是确保我们的连接细节来自环境变量。永远不要将它们存储在项目文件中。除了与这种做法相关的安全顾虑之外,如果您需要更改连接池的大小或轮换密码,通过重新部署应用程序并使用不同的值来更改这些值非常有帮助。让我们开始:
-
使用 docker-compose、kubernetes 或其他您用于运行容器的工具来存储您的连接设置。如果您不在容器中运行 Sanic(例如,您计划部署到一个通过 GUI 提供环境变量的 PAAS),那么我喜欢的用于本地开发的一个选项是
dotenv(github.com/theskumar/python-dotenv)。目前我们关心的配置值是数据源名称(DSN)和池设置。如果您不熟悉 DSN,它是一个包含连接到数据库所需所有信息的字符串,其形式可能对您来说像 URL 一样熟悉。什么是连接池?想象一个场景,当网络请求到来时,您的应用程序会打开一个网络套接字到您的数据库。它获取信息,序列化并发送回客户端。但是,它也会关闭该连接。下次发生这种情况时,您的应用程序需要重新打开到数据库的连接。这非常低效。相反,您的应用程序可以通过打开它们并保留在备用中,来预热几个连接。 -
然后,当应用程序需要连接时,它不需要打开新的连接,而可以直接通过连接池连接到您的数据库,并将该对象存储在您的应用程序
ctx上:# ./application/hiking/worker/postgres.py @app.before_server_start async def setup_postgres(app, _): app.ctx.postgres = Database( app.config.POSTGRES_DSN, min_size=app.config.POSTGRES_MIN, max_size=app.config.POSTGRES_MAX, ) @app.after_server_start async def connect_postgres(app, _): await app.ctx.postgres.connect() @app.after_server_stop async def shutdown_postgres(app, _): await app.ctx.postgres.disconnect()如您所见,有三个主要的事情正在发生:
-
第一件事是我们创建了一个数据库对象,它存储我们的连接池并作为查询的接口。我们将其存储在
app.ctx对象上,以便它可以在应用程序的任何地方轻松访问。这被放置在before_server_start监听器中,因为它改变了我们应用程序的状态。 -
第二个是监听器实际上打开了到数据库的连接,并在需要之前保持它们就绪。我们提前预热连接池,这样我们就不需要在查询时间上花费开销。
-
当然,我们做的最重要的步骤是确保我们的应用程序正确关闭其连接。
-
-
下一步我们需要做的是创建我们的端点。在这个例子中,我们将使用基于类的视图:
from sanic import Blueprint, json, Request from sanic.views import HTTPMethodView from .executor import TrailExecutor bp = Blueprint("Trails", url_prefix="/trails") class TrailListView(HTTPMethodView, attach=bp): async def get(self, request: Request): executor = TrailExecutor(request.app.ctx.postgres) trails = await executor.get_all_trails() return json({"trails": trails})在这里,根级别的
/trails端点的GET端点旨在提供数据库中所有小径的列表(不考虑分页)。TrailExecutor是那些我不打算现在深入探讨的对象之一。但正如你可能从这段代码中猜到的,它接受我们数据库的实例(我们在上一步中启动的)并提供从数据库获取数据的方法。我非常喜欢数据库包的一个原因是因为它使得处理连接池和会话管理变得极其简单。它基本上在幕后为你做了所有事情。但有一个好习惯是无论你使用什么系统都应该养成(将多个连续写入数据库的操作包裹在一个事务中)。想象一下,你需要做类似这样的事情:executor = FoobarExecutor(app.ctx.postgres) await executor.update_foo(value=3.141593) await executor.update_bar(value=1.618034)
经常在单个函数中有多个数据库写入时,你可能希望它们全部成功或全部失败。例如,成功和失败的混合可能会使你的应用程序处于不良状态。当你识别出这种情况时,几乎总是有益于在单个事务中嵌套你的函数。为了在我们的示例中实现此类事务,它看起来可能像这样:
executor = FoobarExecutor(app.ctx.postgres)
async with app.ctx.postgres.transaction():
await executor.update_foo(value=3.141593)
await executor.update_bar(value=1.618034)
现在,如果由于任何原因查询失败,数据库状态将回滚到更改之前的状态。我强烈建议你无论使用什么框架连接到数据库,都采用类似的实践。
当然,关于数据库的讨论并不一定局限于 SQL 数据库。有许多 NoSQL 选项可供选择,而你当然应该找出适合你需求的那一个。接下来,我们将看看如何将我最喜欢的数据库选项 Redis 连接到 Sanic:
将 Sanic 连接到 Redis
Redis 是一个快速且简单的数据库,易于工作。许多人认为它仅仅是一个键/值存储,这是它做得非常好的事情。它还具有许多其他可以被视为某种共享原始数据类型的功能。例如,Redis 有哈希表、列表和集合。这些与 Python 的 dict、list 和 set 对应得很好。正因为如此,我经常向需要跨水平扩展共享数据的人推荐这个解决方案。
在我们的示例中,我们将使用 Redis 作为缓存层。为此,我们依赖于其哈希表功能来存储一个类似 dict 的结构,其中包含有关响应的详细信息。我们有一个可能需要几秒钟才能生成响应的端点。现在让我们模拟一下:
-
首先创建一个需要一段时间才能生成响应的路由:
@app.get("/slow") async def wow_super_slow(request): wait_time = 0 for _ in range(10): t = random.random() await asyncio.sleep(t) wait_time += t return text(f"Wow, that took {wait_time:.2f}s!") -
检查它是否工作:
$ curl localhost:7777/slow Wow, that took 5.87s!
为了解决这个问题,我们将创建一个装饰器,其任务是查找预缓存响应并在存在的情况下提供该响应:
-
首先,我们将安装
aioredis:$ pip install aioredis -
创建一个类似于上一节中我们所做的数据库连接池:
from sanic import Sanic import aioredis app = Sanic.get_app() @app.before_server_start async def setup_redis(app, _): app.ctx.redis_pool = aioredis.BlockingConnectionPool.from_url( app.config.REDIS_DSN, max_connections=app.config.REDIS_MAX ) app.ctx.redis = aioredis.Redis(connection_pool=app.ctx.redis_pool) @app.after_server_stop async def shutdown_redis(app, _): await app.ctx.redis_pool.disconnect() -
接下来,我们将创建一个用于端点的装饰器。
def cache_response(build_key, exp: int = 60 * 60 * 72): def decorator(f): @wraps(f) async def decorated_function(request, *handler_args, **handler_kwargs): cache: Redis = request.app.ctx.redis key = make_key(build_key, request) if cached_response := await get_cached_response(request, cache, key): response = raw(**cached_response) else: response = await f(request, *handler_args, **handler_kwargs) await set_cached_response(response, cache, key, exp) return response return decorated_function return decorator这里发生的事情相当简单。首先,我们生成一些键,这些键将用于查找和存储值。然后我们检查是否有任何与该键相关的内容。如果有,则使用它来构建响应。如果没有,则执行实际的路由处理程序(我们知道这需要一些时间)。
-
让我们看看我们在实际操作中取得了什么成果。首先,我们将再次访问端点。为了强调我的观点,我将包括一些来自
curl的统计数据:$ curl localhost:7777/v1/slow Wow, that took 5.67s! status=200 size=21 time=5.686937 content-type="text/plain; charset=utf-8" -
现在,我们将再次尝试:
$ curl localhost:7777/v1/slow Wow, that took 5.67s! status=200 size=21 time=0.004090 content-type="text/plain; charset=utf-8"
哇!它几乎是瞬间返回的!在第一次尝试中,它只用了不到 6 秒来响应。在第二次,因为信息已经被存储在 Redis 中,我们大约在 4/1000 秒内得到了相同的响应。而且,别忘了在这 4/1000 秒内,Sanic 已经去 Redis 获取数据了。太神奇了!
使用 Redis 作为缓存层非常强大,因为它可以显著提高你的性能。然而,正如之前使用过缓存的人所知道的那样,你需要有一个合适的用例和一个用于使缓存失效的机制。在上面的例子中,这通过两种方式实现。如果你查看 GitHub 上的源代码(github.com/PacktPublishing/Web-Development-with-Sanic/blob/main/chapters/09/hikingapp/application/hiking/common/cache.py#L43),你会看到我们自动在 72 小时后过期值,或者如果有人向端点发送?refresh=1查询参数。
摘要
既然我们已经超越了讨论应用开发基本概念的阶段,我们已经提升到了探索我在多年开发 Web 应用过程中所学到的最佳实践的水平。这显然只是冰山一角,但它们是一些非常重要的基础实践,我鼓励你采纳。本章的例子可以成为开始你的下一个 Web 应用流程的绝佳基础。
首先,我们看到了如何使用智能和可重复的异常处理来为你的用户提供一致和周到的体验。其次,我们探讨了创建可测试应用程序的重要性,以及一些使其易于接近的技术。第三,我们讨论了在开发和生产环境中实现日志记录,以及如何使用这些日志轻松地调试和跟踪应用程序中的请求。最后,我们花了时间学习如何将数据库集成到你的应用程序中。
在下一章中,我们将继续扩展我们已经建立的基本平台。你将看到很多相同的模式(如日志记录)在我们的例子中继续出现,因为我们查看 Sanic 的一些常见用例。
第十一章:10 使用 Sanic 实现常见用例
我们已经学会了如何使用 Sanic,我们也学到了一些良好的实践和习惯。现在,让我们开始构建一些有趣的东西。当你开始一个新的项目时,从这里开始是非常诱人的。毕竟,你头脑中关于要构建的想法就是实现,对吧?你有一个最终的想法(比如聊天机器人)。所以,你坐下来开始构建机器人。
但本章放在书的末尾的原因是因为你显然不能从这里开始。只有在你获得了 HTTP 和 Sanic 的知识,并在过程中提升我们的技术技能之后,我们才能深入研究实现细节。本章的目标是查看一些你可能需要构建的实际功能。随着管道问题的解决,现在我们已经有了对 HTTP 和 Sanic 的稳固基础和理解,我们可以开始构建一些真实世界的用例。
在考虑本章要包含哪些主题时,我想到了一些我知道 Sanic 被用于的常见用例。选择一些经常出现的实现,然后尝试一起构建是有意义的。显然,这本书的空间有限,所以我们不会深入细节。相反,我们将查看一些实现细节,讨论一些考虑因素,并描述你可能会采取的一般方法来解决这个问题。我希望向你展示我在处理类似这样的项目时的思考过程。
就像上一章一样,仓库中将有大量代码,但书中并没有。这仅仅是因为它并不都与对话相关,但我想要确保你有完整的工作示例来作为你自己的项目的起点。为了获得完整的理解,你真的应该在线跟随源代码阅读本章。我会指出具体的设计决策,并包括一些特别值得提到的精选片段。
那么,我们将要构建什么?列表包括:
-
同步 WebSocket 数据流
-
渐进式 Web 应用后端
-
GraphQL API
-
聊天机器人
技术要求
由于本章建立在前面章节的基础上,你应该已经满足了所有的技术需求。我们将开始看到一些额外的第三方包的使用,所以请确保你有pip。
如果你想要提前跳过以确保你的环境已经设置好,以下是我们将要使用的 pip 包:
$ pip instal aioredis ariande databases[postgresql] nextcord
此外,如果你还记得,在第二章中我们讨论了使用工厂模式。因为我们现在开始构建可能成为真实世界应用基础的东西,我觉得在这里使用一个可扩展的工厂模式会更好。因此,在这本书的剩余部分,你将看到越来越多的我们已经建立并使用的工厂模式的用法。
WebSocket 数据流
在本书的早期,我们在第五章的“Websockets for two-way communication”部分探讨了 websockets。如果您还没有阅读该部分,我鼓励您现在去阅读。现在,我们将对我们的 websocket 实现进行扩展,创建一个水平可扩展的 websocket feed。这里代码的基本前提将与该部分相同,这就是为什么在继续到这里的示例之前,您应该了解我们在那里构建的内容。
我们将要构建的 feed 的目的是在多个浏览器之间共享发生的事件。基于第五章的示例,我们将添加一个第三方代理,这将使我们能够运行多个应用程序实例。这意味着我们可以水平扩展我们的应用程序。之前的实现存在一个问题,即它在内存中存储客户端信息。由于没有机制在多个应用程序实例之间共享状态或广播消息,因此一个 websocket 连接无法保证能够将消息推送到每个其他客户端。最多它只能将消息推送到恰好被路由并连接到同一应用程序实例的客户端。最终,这使得无法通过多个工作者扩展应用程序。
目标现在将是创建所谓的pubsub。这是一个意味着“发布和订阅”的术语,因为该模式依赖于多个源订阅中央代理。当这些源中的任何一个向代理发布消息时,所有已订阅的其他源都会接收到该消息。术语 pubsub 是对代理和源之间这种推送和拉取的简单描述。我们在构建 feed 时将使用这个概念。
在我看来,处理 pubsub 的最简单方法是使用 Redis,它有一个非常简单的内置 pubsub 机制。想法很简单:每个应用程序实例都将是一个源。在启动时,应用程序实例将订阅 Redis 实例上的特定频道。通过建立这种连接,现在它现在具有从该代理在特定频道上推送和拉取消息的能力。通过将此推送到第三方服务,所有我们的应用程序都将能够通过 pubsub 的推送和拉取访问相同的信息。
在第五章的 websockets 示例中,当收到消息时,服务器会将该消息推送到连接到同一应用程序实例的其他客户端。我们还将做类似的事情。浏览器客户端将使用多个 web 服务器之一打开一个 websocket 连接,该连接将保持与客户端的连接。这同样会被保存在内存中。当客户端实例有传入消息时,它不会直接将消息分发到其他客户端,而是将消息推送到 pubsub 代理。然后,所有其他实例都会接收到该消息,因为它们都订阅了同一个代理,并且可以将消息推送到任何连接到该应用程序实例的 websocket 客户端。这样,我们可以构建一个分布式 websocket 流。
要开始,我们将使用docker-compose启动一个 Redis 服务以及我们的开发应用程序。请查看仓库中的详细信息,了解如何完成此操作:____。我们将假设你有一个可用的 Redis 实例并且正在运行。
-
我们首先创建一个 websocket 处理程序并将其附加到一个蓝图上。
from sanic import Blueprint from sanic.log import logger from .channel import Channel bp = Blueprint("Feed", url_prefix="/feed") @bp.websocket("/<channel_name>") async def feed(request, ws, channel_name): logger.info("Incoming WS request") channel, is_existing = await Channel.get( request.app.ctx.pubsub, request.app.ctx.redis, channel_name ) if not is_existing: request.app.add_task(channel.receiver()) client = await channel.register(ws) try: await client.receiver() finally: await channel.unregister(client)这是我们在这个示例中 Sanic 集成的全部内容。我们定义了一个 websocket 端点。该端点要求我们通过访问
channel_name来访问一个源,这个channel_name意味着一个唯一的监听位置。这可以是用户名或聊天室股票行情等。重点是channel_name意味着代表你应用程序中的一个位置,人们将希望作为源持续从你的应用程序中检索信息。例如,这也可以用来构建一种共享编辑应用程序,其中多个用户可以同时更改同一资源。在这个示例中,处理程序通过获取一个Channel对象来工作。如果它创建了一个新的Channel,那么我们将向后台发送一个receiver任务,该任务负责监听我们的 pubsub 代理。处理程序中的下一件事是将我们的当前 websocket 连接注册到通道上,然后创建另一个receiver。第二个client.receiver的目的就是监听 websocket 连接,并将传入的消息推送到 pubsub 代理。 -
让我们快速看一下
Client对象。from dataclasses import dataclass, field from uuid import UUID, uuid4 from aioredis import Redis from sanic.server.websockets.impl import WebsocketImplProtocol @dataclass class Client: protocol: WebsocketImplProtocol redis: Redis channel_name: str uid: UUID = field(default_factory=uuid4) def __hash__(self) -> int: return self.uid.int async def receiver(self): while True: message = await self.protocol.recv() if not message: break await self.redis.publish(self.channel_name, message) async def shutdown(self): await self.protocol.close()正如刚才所说的,这个对象的目的是在有消息时监听当前的 websocket 连接并将消息发送到 pubsub 代理。这是通过
publish方法实现的。 -
我们现在将查看
Channel对象。这个类比Client类要长一些,所以我们将分部分查看其代码。打开 GitHub 仓库查看完整的类定义可能会有所帮助。class ChannelCache(dict): ... class Channel: cache = ChannelCache() def __init__(self, pubsub: PubSub, redis: Redis, name: str) -> None: self.pubsub = pubsub self.redis = redis self.name = name self.clients: Set[Client] = set() self.lock = Lock() @classmethod async def get(cls, pubsub: PubSub, redis: Redis, name: str) -> Tuple[Channel, bool]: is_existing = False if name in cls.cache: channel = cls.cache[name] await channel.acquire_lock() is_existing = True else: channel = cls(pubsub=pubsub, redis=redis, name=name) await channel.acquire_lock() cls.cache[name] = channel await pubsub.subscribe(name) return channel, is_existing
每个应用程序实例都会在内存中创建并缓存一个频道。这意味着对于每个请求加入特定频道的传入请求的应用程序实例,只有一个该频道的实例被创建。即使我们有十个(10)应用程序实例,我们也不关心我们是否有十个(10)个频道的实例。我们关心的是在任何单个应用程序实例上,永远不会超过一个Channel订阅单个 Redis pubsub 频道。在同一个应用程序实例上有多个频道可能会变得混乱并导致内存泄漏。因此,我们还想添加一个机制,在频道不再需要时清理缓存。我们可以这样做:
async def destroy(self) -> None:
if not self.lock.locked():
logger.debug(f"Destroying Channel {self.name}")
await self.pubsub.reset()
del self.__class__.cache[self.name]
else:
logger.debug(f"Abort destroying Channel {self.name}. It is locked")
我们在这里使用Lock的原因是试图避免多个请求尝试销毁频道实例的竞态条件。
如果你记得上面提到的,在频道创建(或从缓存中检索)之后,我们在 Channel 实例上注册了 websocket 连接,看起来像这样:
async def register(self, protocol: WebsocketImplProtocol) -> Client:
client = Client(protocol=protocol, redis=self.redis, channel_name=self.name)
self.clients.add(client)
await self.publish(f"Client {client.uid} has joined")
return client
我们简单地创建Client对象,将其添加到需要在此实例上接收消息的已知客户端中,并发送一条消息让其他客户端知道有人刚刚加入。发布消息的方法看起来就像这样:
async def publish(self, message: str) -> None:
logger.debug(f"Sending message: {message}")
await self.redis.publish(self.name, message)
一旦客户端已注册,它还需要有注销的能力。注销的方法如下:
async def unregister(self, client: Client) -> None:
if client in self.clients:
await client.shutdown()
self.clients.remove(client)
await self.publish(f"Client {client.uid} has left")
if not self.clients:
self.lock.release()
await self.destroy()
在这里,我们将当前客户端从Channel上的已知客户端中移除。如果不再有客户端监听此频道,那么我们可以关闭它并自行清理。
这是一个非常简单的模式,提供了巨大的灵活性。事实上,在我提供支持和帮助人们使用 Sanic 应用程序的过程中,我多次提供了使用与此类似模式构建应用程序的帮助。使用这个,你可以创建一些真正令人难以置信的前端应用程序。我知道我做到了。说到这里,在我们接下来的章节中,我们将开始探讨 Sanic 与在浏览器中运行的前端 Web 应用程序之间的相互作用。
支持渐进式 Web 应用
构建 Web API 的许多用例是为了支持渐进式 Web 应用(PWA,也称为单页应用,或 SPA)。像许多其他 Web 开发者一样,真正吸引我从事 Web 开发的原因是为了在浏览器中构建一个可用的应用程序或网站。让我们说实话,我们中很少有人会编写curl命令来使用我们最喜欢的 API。Web API 的真正力量在于它能够支持其他事物。
一个 PWA 需要什么才能运行?当你构建一个 PWA 时,最终产品是一堆静态文件。好吧,所以我们将这些文件放入一个名为./public的目录中,然后我们提供这些文件:
app.static("/", "./public")
好了,我们现在正在运行一个 PWA。我们已经完成了。
好吧,不是那么快。能够提供静态内容很重要,但这不是你需要考虑的唯一因素。让我们看看构建 PWA 时需要考虑的一些因素。
处理子域名和 CORS
在第七章中,我们花了很多时间从安全的角度研究 CORS。我敢打赌,要求 CORS 保护的最大理由是需要为 PWA 提供服务。这类应用在互联网上无处不在,通常需要应对。这种情况通常发生的原因是,PWA 的前端和后端通常位于不同的子域名上。这通常是因为它们运行在不同的服务器上。静态内容可能由 CDN 提供,而后端位于 VPS 或 PAAS 提供(有关 Sanic 部署选项的更多信息,请参阅第八章)。
CORS 是一个大话题。它也是容易出错的东西。幸运的是,有一个简单的方法可以使用 Sanic Extensions 来实现这一点,这是一个由 Sanic 团队开发和维护的包,用于向 Sanic 添加一些额外功能。Sanic Extensions 专注于所有更具有意见和特定用例实现的特性,这些特性不适合核心项目。CORS 就是其中之一。
那么,我们如何开始呢?
$ pip install sanic[ext]
或者
$ pip install sanic sanic-ext
就这些。只需将sanic-ext包安装到你的环境中,你就可以获得开箱即用的 CORS 保护。截至版本 21.12,如果你环境中已有sanic-ext,Sanic 将为你自动实例化它。
我们现在唯一需要做的就是进行配置。通常,要开始进行 CORS 配置,我们需要设置允许的源:
app.config.CORS_ORIGINS = "http://foobar.com,http://bar.com"
好吧,等等,你说,“我不能只从 Sanic 提供前端资源并避免 CORS,因为前后端都在同一台服务器上吗?”是的。如果这种方法对你有效,那就去做吧。让我们看看这可能是什么样子(从开发的角度来看)。
运行开发服务器
当你决定希望前端和后端应用都在同一台服务器上运行时会发生什么?或者,当你想使用上面显示的app.static方法来提供你的项目文件时?在本地构建这可能非常棘手。
这种情况之所以如此,是因为在构建前端应用时,你需要一个前端服务器。大多数框架都有某种构建要求。也就是说,你输入一些代码,保存后,然后像webpack或rollup这样的包会编译你的 JS,并通过本地开发 Web 服务器为你提供服务。你的前端开发服务器可能运行在 5555 端口上,所以你访问http://localhost:5555。
但是,你希望从前端应用程序访问本地运行的后端以填充内容。后端运行在 http://localhost:7777。哎呀,你看到这里的问题了吗?我们又回到了 CORS 的问题。只要你的前端应用程序是由与后端不同的服务器运行的,你将继续遇到 CORS 问题。
最终,我们试图让单个服务器同时运行后端和前端。由于我们谈论的是本地开发,我们还想为我们的 Python 文件和 JavaScript 文件提供自动重新加载功能。我们还需要触发 JavaScript 的重建,最后我们需要从这个位置提供所有服务。
幸运的是,Sanic 可以为我们做所有这些。现在让我们使用 Sanic 作为前端项目的本地开发服务器。
这将适用于你想要的任何前端工具,因为我们本质上将在 Python 中调用这些工具。我选择的前端开发框架是 Svelte,但你可以随意尝试使用 React、Vue 或其他许多替代方案。我不会带你设置前端项目的步骤,因为这里并不重要。想象一下你已经完成了。如果你想跟随代码,请参阅 GitHub 仓库:___。
为了实现我们的目标,我们将设置 Sanic 服务器,为前端应用程序的构建目录添加自动重新加载功能。对于使用 rollup(一个流行的 JS 构建工具)的 Svelte 项目,这是一个 ./public 目录。
-
我们首先声明静态文件的位置,并使用
static来提供服务:app = Sanic("MainApp") app.config.FRONTEND_DIR = Path(__file__).parent / "my-svelte-project" app.static("/", app.config.FRONTEND_DIR / "public") -
当我们运行 Sanic 时,请确保将此目录添加到自动重新加载器中,如下所示:
sanic server:app -d -p7777 -R ./my-svelte-project/src -
我们接下来想要做的是定义一个自定义信号。我们将稍后使用它,所以现在它只需要定义它。它只需要存在,这样我们就可以稍后等待事件。
@app.signal("watchdog.file.reload") async def file_reloaded(): ... -
现在,我们准备构建一个将检查重新加载的文件并决定是否需要触发
rollup构建过程的程序。我们将分两部分来看。首先,我们创建一个启动监听器,该监听器检查文件扩展名以确定服务器启动是由任何.svelte或.js文件扩展名的重新加载触发的。@app.before_server_start async def check_reloads(app, _): do_rebuild = False if reloaded := app.config.get("RELOADED_FILES"): reloaded = reloaded.split(",") do_rebuild = any( ext in ("svelte", "js") for filename in reloaded if (ext := filename.rsplit(".", 1)[-1]) )截至 21.12 版本,触发重新加载的文件被存储在
SANIC_RELOADED_FILES环境变量中。由于任何以 SANIC_ 前缀开始的任何环境变量都被加载到我们的app.config中,因此如果存在,我们可以简单地读取该值并检查文件扩展名。假设需要重建,我们接下来想要触发对我们的 shell 的子进程调用以运行构建命令:if do_rebuild: rebuild = await create_subprocess_shell( "yarn run build", stdout=PIPE, stderr=PIPE, cwd=app.config.FRONTEND_DIR, ) while True: message = await rebuild.stdout.readline() if not message: break output = message.decode("ascii").rstrip() logger.info(f"[reload] {output}") await app.dispatch("watchdog.file.reload")最后,当所有这些都完成时,我们将触发我们之前创建的定制事件。
-
到目前为止,自动重新加载和自动重建都按预期工作。我们现在唯一缺少的是触发网页刷新的能力。这可以通过一个名为
livereload.js的工具来实现。您可以通过搜索并安装 JavaScript 来访问 livereload.js。本质上,它将创建一个到端口 35729 的服务器的 websocket 连接。然后,从这个 websocket 中,您可以发送消息提示浏览器刷新。为了从 Sanic 中这样做,我们将运行嵌套应用程序。添加第二个应用程序定义:livereload = Sanic("livereload") livereload.static("/livereload.js", app.config.FRONTEND_DIR / "livereload.js") -
我们还需要声明几个更多的常量。这些主要是为了运行 livereload 需要从服务器发送的两种类型的信息:
INDEX_HTML = app.config.FRONTEND_DIR / "public" / "index.html" HELLO = { "command": "hello", "protocols": [ "http://livereload.com/protocols/official-7", ], "serverName": app.name, } RELOAD = {"command": "reload", "path": str(INDEX_HTML)} -
接下来,设置必要的监听器以运行嵌套服务器:
@app.before_server_start async def start(app, _): app.ctx.livereload_server = await livereload.create_server( port=35729, return_asyncio_server=True ) app.add_task(runner(livereload, app.ctx.livereload_server)) @app.before_server_stop async def stop(app, _): await app.ctx.livereload_server.close()上面代码中使用的
runner任务应该看起来像这样:async def runner(app, app_server): app.is_running = True try: app.signalize() app.finalize() await app_server.serve_forever() finally: app.is_running = False app.is_stopping = True -
是时候添加 websocket 处理器了:
@livereload.websocket("/livereload") async def livereload_handler(request, ws): global app logger.info("Connected") msg = await ws.recv() logger.info(msg) await ws.send(ujson.dumps(HELLO)) while True: await app.event("watchdog.file.reload") await ws.send(ujson.dumps(RELOAD))
如您所见,处理器接受来自 livereload 的初始消息,然后发送一个HELLO消息回。之后,我们将运行一个循环并等待我们创建的自定义信号被触发。当它被触发时,我们发送 RELOAD 消息,这将触发浏览器刷新网页。
哇!现在我们已经在 Sanic 内部运行了一个完整的 JavaScript 开发环境。这对于那些希望从同一位置提供前端和后端内容的 PWA 来说是一个完美的设置。
既然我们已经在谈论前端内容,接下来我们将探讨前端开发者另一个重要的话题:GraphQL
GraphQL
在 2015 年,Facebook 公开发布了一个旨在与传统 Web API 竞争并颠覆 RESTful Web 应用程序概念的项目。这个项目就是我们今天所知道的 GraphQL。这本书到目前为止都假设我们正在使用将 HTTP 方法与深思熟虑的路径相结合的传统方法来构建端点。在这种方法中,Web 服务器负责作为客户端和数据源(即数据库)之间的接口。GraphQL 的概念摒弃了所有这些,并允许客户端直接请求它想要接收的信息。有一个单一的端点(通常是/graphql)和一个单一的 HTTP 方法(通常是POST)。单一的路线定义旨在用于检索数据和在应用程序中引起状态变化。所有这些都在通过该单一端点发送的查询体中完成。GraphQL 旨在彻底改变我们构建 Web 的方式,并成为未来标准的实践。至少,这是许多人所说的将要发生的事情。
实际上并没有发生这种情况。在撰写本文时,GraphQL 的流行似乎已经达到顶峰,现在正在下降。尽管如此,我确实相信 GraphQL 在 Web 应用程序世界中填补了一个必要的空白,并且它将继续作为替代实现存在多年(只是不是作为替代品)。因此,我们确实需要了解如何将其与 Sanic 集成,以便在需要部署这些服务器的情况下。
在我们回答“为什么要使用 GraphQL?”这个问题之前,我们必须了解它是什么。正如其名称所暗示的,GraphQL 是一种查询语言。查询是一种类似于 JSON 的请求,用于以特定格式提供信息。一个希望从 Web 服务器接收信息的客户端可能会发送一个包含查询的 POST 请求,如下所示:
{
countries (limit: 3, offset:2) {
name
region
continent
capital {
name
district
}
languages {
language
isofficial
percentage
}
}
}
作为回报,服务器将去获取它所需的数据,并编译一个符合该描述的返回 JSON 文档:
{
"data": {
"countries": [
{
"name": "Netherlands Antilles",
"region": "Caribbean",
"continent": "North America",
"capital": {
"name": "Willemstad",
"district": "Curaçao"
},
"languages": [
{
"language": "Papiamento",
"isofficial": true,
"percentage": 86.19999694824219
},
{
"language": "English",
"isofficial": false,
"percentage": 7.800000190734863
},
{
"language": "Dutch",
"isofficial": true,
"percentage": 0
}
]
},
...
]
}
}
如你所见,这成为了一个非常强大的客户端工具,因为它可以将可能需要多个网络调用的信息捆绑成一个单一的操作。它还允许客户端(例如 PWA)以它需要的格式具体检索它所需的数据。
我为什么要使用 GraphQL?
我认为 GraphQL 是前端开发者的最佳拍档,但对于后端开发者来说却是噩梦。确实,使用 GraphQL 的 Web 应用程序通常比它们的替代品向 Web 服务器发出更少的 HTTP 请求。同样确实的是,前端开发者使用 GraphQL 操作来自 Web 服务器获取的响应会更加容易,因为他们可以成为数据结构架构的设计师。
GraphQL 提供了一种非常简单的数据检索方法。因为它是一个强类型规范,这使得可以拥有使生成查询过程非常优雅的工具。例如,许多 GraphQL 实现都附带了一个开箱即用的 Web UI,可用于开发。这些 UI 通常包括导航模式的能力,可以看到可以执行的所有类型的查询,以及可以检索的信息。见图 __ 中的示例。
插入图片
图 ___. 显示“模式”选项卡的 GraphQL UI 示例,该选项卡显示了所有可用的信息
在使用这些工具时,你可以玩得很开心,以构建你想要的确切信息。简单来说:GraphQL 使用简单,易于实现。当你开始构建临时的自定义查询时,它还具有非常令人满意的“酷”感。
除了在后台是个噩梦。尽管从客户端的角度来看简化了很多,但现在的网络服务器需要处理更高级别的复杂性。因此,当有人告诉我他们想要构建一个 GraphQL 应用程序时,我通常会问他们:为什么?如果他们是为了构建一个面向公众的 API,那么这可能是很棒的。GitHub 是一个很好的例子,它提供了一个面向公众的 GraphQL API,使用起来非常愉快。查询 GitHub API 简单直观。然而,如果他们是为了自己的内部用途构建 API,那么必须考虑一系列权衡。
GraphQL 并非在总体上比 REST 更容易或更简单。相反,它几乎将复杂性完全转移到网络服务器上。这可能是可以接受的,但这是一个你必须考虑的权衡。我通常发现后端复杂性的总体增加超过了实施带来的任何好处。
我知道这听起来可能像我不喜欢 GraphQL。这并不是真的。我认为 GraphQL 是一个很好的概念,并且我认为有一些非常出色的工具(包括 Python 世界中的工具)可以帮助构建这些应用程序。如果你想将 GraphQL 包含在你的 Sanic 应用程序中,我强烈推荐使用像 Ariadne (ariadnegraphql.org/) 和 Strawberry (strawberry.rocks/) 这样的工具。即使有了这些工具,在我看来,构建一个好的 GraphQL 应用程序仍然比较困难,而且还有一些陷阱等着吞噬你。当我们探讨如何构建 Sanic GraphQL 应用程序时,我会尝试指出这些问题,以便我们可以绕过它们。
将 GraphQL 添加到 Sanic
我为这一部分构建了一个小的 GraphQL 应用程序。当然,所有的代码都存放在这本书的 GitHub 仓库中:____。我强烈建议你在阅读时将代码准备好。坦白说,整个代码过于复杂和冗长,无法全部在这里展示。因此,我们将一般性地讨论它,并将具体细节指回仓库。为了方便起见,我在代码库本身中也添加了一些注释和进一步的讨论点。
当我们在第九章的 To ORM or Not to ORM, that is the question 节中讨论数据库访问时,我们讨论了是否应该实现 ORM。讨论的是你是否应该使用工具来帮助你构建 SQL 查询,还是自己构建它们。双方都有非常好的论点:支持 ORM 与反对 ORM。我选择了一种相对混合的方法,手动构建 SQL 查询,然后构建一个轻量级的实用工具来将数据填充到可用的模型中。
在这里也可以提出一个类似的问题:我应该自己构建还是使用一个包?我的回答是,你应该绝对使用一个包。我看不出有任何理由要尝试自己构建一个自定义实现。Python 中有几个不错的选择;我个人的偏好是 Ariadne。我特别喜欢这个包采用的 schema-first 方法。使用它允许我在 .gql 文件中定义我的应用程序的 GraphQL 部分,因此使我的 IDE 能够添加语法高亮和其他语言特定便利。
让我们开始:
-
由于我们在示例中使用了 Ariadne,所以我们首先将其安装到我们的虚拟环境中:
$ pip install ariadne -
要启动 Ariadne 的“hello world”应用程序并不需要太多:
from ariadne import QueryType, graphql, make_executable_schema from ariadne.constants import PLAYGROUND_HTML from graphql.type import GraphQLResolveInfo from sanic import Request, Sanic, html, json app = Sanic(__name__) query = QueryType() type_defs = """ type Query { hello: String! } """ @query.field("hello") async def resolve_hello(_, info: GraphQLResolveInfo): user_agent = info.context.headers.get("user-agent", "guest") return "Hello, %s!" % user_agent @app.post("/graphql") async def graphql_handler(request: Request): success, result = await graphql( request.app.ctx.schema, request.json, context_value=request, debug=app.debug, ) status_code = 200 if success else 400 return json(result, status=status_code) @app.get("/graphql") async def graphql_playground(request: Request): return html(PLAYGROUND_HTML) @app.before_server_start async def setup_graphql(app, _): app.ctx.schema = make_executable_schema(type_defs, query)
如您所见,有两个端点:
-
一个显示 GraphQL 查询构建器的
GET请求 -
一个
POST请求,是 GraphQL 后端的入口
从这个简单的起点开始,你可以根据你的心愿构建 Sanic 和 Ariadne。让我们看看你可能采取的一种潜在策略。
-
放弃上述内容,我们开始创建一个结构与我们之前看到的非常相似的程序。创建
./blueprints/graphql/query.py并放置您的根级 GraphQL 对象。from ariadne import QueryType query = QueryType() -
现在,我们在我们的 GraphQL 蓝图实例内部创建所需的两个端点:
from sanic import Blueprint, Request, html, json from sanic.views import HTTPMethodView from ariadne.constants import PLAYGROUND_HTML bp = Blueprint("GraphQL", url_prefix="/graphql") class GraphQLView(HTTPMethodView, attach=bp, uri=""): async def get(self, request: Request): return html(PLAYGROUND_HTML) async def post(self, request: Request): success, result = await graphql( request.app.ctx.schema, request.json, context_value=request, debug=request.app.debug, ) status_code = 200 if success else 400 return json(result, status=status_code)如您所见,这几乎与之前的简单版本相同。
-
在这个相同的蓝图实例上,我们将放置所有我们的启动逻辑。这使所有内容都位于一个方便的位置,并允许我们一次性将其附加到我们的应用程序实例上。
from ariadne import graphql, make_executable_schema from world.common.dao.integrator import RootIntegrator from world.blueprints.cities.integrator import CityIntegrator from world.blueprints.countries.integrator import CountryIntegrator from world.blueprints.languages.integrator import LanguageIntegrator @bp.before_server_start async def setup_graphql(app, _): integrator = RootIntegrator.create( CityIntegrator, CountryIntegrator, LanguageIntegrator, query=query, ) integrator.load() integrator.attach_resolvers() defs = integrator.generate_query_defs() additional = integrator.generate_additional_schemas() app.ctx.schema = make_executable_schema(defs, query, *additional)
你可能想知道,什么是集成器,所有这些代码都在做什么。这就是我将要向您推荐查看特定细节的仓库的地方,但我们将在这里解释这个概念。
在我的应用程序示例中,Integrator 是一个存在于领域内的对象,它是设置 Ariadne 可使用的 GraphQL 模式的通道。
在 GitHub 仓库中,您将看到最简单的集成器是为 languages 模块设计的。它看起来像这样:
from world.common.dao.integrator import BaseIntegrator
class LanguageIntegrator(BaseIntegrator):
name = "language"
旁边有一个名为 schema.gql 的文件:
type Language {
countrycode: String
language: String
isofficial: Boolean
percentage: Float
}
然后,RootIntegrator 的任务是整合所有不同的领域,并使用动态生成的模式和如上所示的硬编码模式为 Ariadne 生成模式。
我们还需要为我们的 GraphQL 查询创建一个起始点。一个查询可能看起来像这样:
async def query_country(
self, _, info: GraphQLResolveInfo, *, name: str
) -> Country:
executor = CountryExecutor(info.context.app.ctx.postgres)
return await executor.get_country_by_name(name=name)
用户创建一个查询,然后我们从数据库中获取它。这里的 Executor 与 hikingapp 中的工作方式完全相同。请参阅第 ___ 章。因此,有了这样的查询,我们现在可以将 GraphQL 查询转换为对象。
{
country(name: "Israel") {
name
region
continent
capital {
name
district
}
languages {
language
isofficial
percentage
}
}
}
利用 GraphQL 的力量,我们的响应应该是这样的:
{
"data": {
"country": {
"name": "Israel",
"region": "Middle East",
"continent": "Asia",
"capital": {
"name": "Jerusalem",
"district": "Jerusalem"
},
"languages": [
{
"language": "Hebrew",
"isofficial": true,
"percentage": 63.099998474121094
},
{
"language": "Arabic",
"isofficial": true,
"percentage": 18
},
{
"language": "Russian",
"isofficial": false,
"percentage": 8.899999618530273
}
]
}
}
}
Ariadne(以及其他 GraphQL 实现)的工作方式是,你定义一个强类型模式。有了对这个模式的了解,你可能会得到嵌套对象。例如,上面的 Country 模式可能看起来像这样:
type Country {
code: String
name: String
continent: String
region: String
capital: City
languages: [Language]
}
Country类型有一个名为capital的字段,它是一个City类型。由于这不是一个简单可以序列化为 JSON 的标量值,我们需要告诉 Ariadne 如何翻译或解析这个字段。根据 GitHub 中的示例,我们需要像这样查询我们的数据库:
class CountryIntegrator(BaseIntegrator):
name = "country"
async def resolve_capital(
self,
country: Country,
info: GraphQLResolveInfo
) -> City:
executor = CityExecutor(info.context.app.ctx.postgres)
return await executor.get_city_by_id(country.capital)
这就是我们在不同对象之间跟随路径的方法。然后,Ariadne 的任务是将所有这些不同的查询和解析器拼凑在一起,生成一个最终要返回的对象。这是 GraphQL 的力量。
你可能也注意到了一个缺陷。因为每个解析器都旨在独立操作,并将单个字段转换为值,所以你很容易从数据库中检索过量的数据。这尤其在你有一个所有对象都解析到同一数据库实例的数组时更为明显。这被称为“n+1”问题。虽然这不是 GraphQL 特有的问题,但许多 GraphQL 系统的设计使其特别容易受到这个问题的影响。如果你忽略这个问题,当响应单个请求时,你的服务器可能会反复从数据库请求相同的信息,尽管它应该已经有了这些信息。
许多应用程序都存在这个问题。它们依赖的数据库查询比实际需要的要多得多。所有这些过度检索累积起来,降低了 Web 应用程序的性能和效率。虽然你在开发任何应用程序时都应该意识到这个问题,并且要有意识地去处理,但我认为在 GraphQL 实现中,你必须特别计划这一点,因为 GraphQL 依赖于简化的解析器。因此,我在构建这类应用程序时能提供的最重要的建议就是考虑基于内存、基于请求的缓存。也就是说,在请求实例上缓存对象可能会节省大量的 SQL 查询。
我鼓励你花些时间审查 GitHub 仓库中的其余代码。有一些有用的模式可以在实际应用中使用。由于它们不一定与 Sanic 或 Sanic 中的 GraphQL 实现直接相关,我们在这里暂时停止讨论,转而讨论另一个 Sanic 的流行用例:聊天机器人。
构建一个 Discord 机器人(在另一个服务中运行 Sanic)
在 2021 年初的某个时候,我被 Sanic 社区的一些人说服,我们需要迁移我们的主要讨论和社区建设工具。我们有一个相对未被充分利用的聊天应用程序,还有一个主要用于较长风格支持问题的社区论坛。Discord 比其他选项提供的对话更加亲密。当有人建议我使用 Discord 时,我有点犹豫是否要在我的工具箱中添加另一个应用程序。尽管如此,我们还是继续了。如果这本书的读者中有 Discord 的粉丝,那么你不需要我向你解释它的好处。对于其他人来说,Discord 是一个非常易于使用且引人入胜的平台,它真正促进了对我们这个网络角落有益的讨论。
随着我对这个平台了解的越来越多,最让我印象深刻的是聊天机器人无处不在。有一个我之前不知道的、与构建机器人相关的惊人亚文化。这些机器人中的绝大多数都是使用 SDKs 构建的,这些 SDKs 是围绕与 Discord API 接口所需的大部分客户端 HTTP 交互的开放源代码项目。在这个基础上建立起了整个生态系统和框架,以帮助开发者构建引人入胜的机器人。
自然地,人们经常问的下一个问题是:我该如何将 Sanic 集成到我的机器人应用程序中?我们将尝试做到这一点。
但首先,我想指出的是,虽然我们将要构建的示例使用的是 Discord,但其中的原则与在 Discord 上运行这一点毫无关联。我们即将要做的是运行一些asyncio进程,并重用这个循环来运行 Sanic。这意味着你实际上可以使用这种完全相同的技术来运行嵌套的 Sanic 应用程序。我们将在下一节中看到这会是什么样子。
构建简单的 Discord 机器人
我并不是 Discord 的专家。基于这个平台,有一个完整的开发领域,我并不假装自己是这方面的权威。我们的目标是集成一个与 Sanic 的机器人应用程序。为此,我们将使用nextcord搭建一个基本的 Discord 机器人。如果你对nextcord不熟悉,截至本书编写时,它是对已废弃的discord.py项目的活跃维护分支。如果你对那个也不熟悉,不用担心。简单的解释是,这些是用于在 Discord 上构建机器人应用程序的框架。类似于 Sanic 提供 HTTP 通信的工具,这些框架提供了与 Discord 通信的工具。
让我们花一分钟时间来考虑一下他们文档中的基本“Hello World”应用程序:
import nextcord
client = nextcord.Client()
@client.event
async def on_ready():
print(f'We have logged in as {client.user}')
@client.event
async def on_message(message):
if message.author == client.user:
return
if message.content.startswith('$hello'):
await message.channel.send('Hello!')
client.run('your token here')
老实说,这看起来与我们构建的 Sanic 并没有太大的不同。它从应用程序实例开始。然后,有装饰器包装处理程序。最后我们看到的是client.run。
这是我们要构建的关键。这个run方法将创建一个循环,并在应用程序关闭之前运行它。我们现在的任务是运行 Sanic 在这个应用程序内部。这意味着我们不会使用 Sanic cli 来启动我们的应用程序。相反,我们将使用以下方式运行应用程序:
$ python bot.py
让我们开始吧。
-
首先,从他们的文档中复制最小的机器人示例到
bot.py。你可以在这里获取代码:nextcord.readthedocs.io/en/latest/quickstart.html -
创建一个简单的 Sanic 应用程序作为概念验证。
from sanic import Sanic, Request, json app = Sanic(__name__) @app.get("/") async def handler(request: Request): await request.app.ctx.general.send("Someone sent a message") return json({"foo": "bar"}) @app.before_server_start async def before_server_start(app, _): await app.ctx.general.send("Wadsworth, reporting for duty")目前还没有什么特别的事情发生。我们有一个单独的处理程序,在服务器启动之前在监听器中发送消息。此外,我们还有一个单独的处理程序,当路由端点被击中时,也会触发向我们的 Discord 服务器发送消息。
-
为了将此与 Discord 机器人集成,我们将使用
on_ready事件来运行我们的 Sanic 服务器。from server import app @client.event async def on_ready(): app.config.GENERAL_CHANNEL_ID = 906651165649928245 app.ctx.wadsworth = client app.ctx.general = client.get_channel(app.config.GENERAL_CHANNEL_ID) if not app.is_running: app_server = await app.create_server(port=9999, return_asyncio_server=True) app.ctx.app_server = app_server client.loop.create_task(runner(app_server))重要通知
为了简化,我只是从
server导入app。这是因为这是一个超级简单的实现。实际上,如果我要构建一个合适的应用程序,我不会使用这种模式。相反,我会使用本书中反复讨论的工厂模式,并从可调用对象构建我的应用程序。这是为了帮助导入管理和避免传递全局作用域变量。这里发生了一些我们需要讨论的事情。首先,如前所述,这是告诉
nextcord在应用程序启动并连接到 Discord 且因此“就绪”时运行此处理器的语法。但是,根据他们的文档,此事件可能会被触发多次。尝试多次运行 Sanic 将是一个错误,因为它将无法正确绑定到套接字。为了避免这种情况,我们查看app.is_running标志以确定是否应该再次运行它。接下来会发生的事情是,我们将手动创建一个 Sanic 服务器。之后——这部分非常重要——我们将该应用程序服务器实例传递给一个新的任务。为什么?因为如果我们从这个当前任务运行 Sanic,它将无限期地阻塞,Discord 机器人永远不会真正运行。由于我们希望它们同时运行,因此从另一个asyncio任务运行 Sanic 是至关重要的。 -
接下来,我们需要创建那个
runner操作。这里的任务是运行创建的服务器。这意味着我们需要手动触发所有监听事件。这也意味着我们需要执行一些连接关闭操作。因为我们操作的水平比正常情况低得多,所以你需要更加亲自动手。async def runner(app_server: AsyncioServer): app.is_running = True try: await app_server.startup() await app_server.before_start() await app_server.after_start() await app_server.serve_forever() finally: app.is_running = False app.is_stopping = True await app_server.before_stop() await app_server.close() for connection in app_server.connections: connection.close_if_idle() await app_server.after_stop() app.is_stopping = False
这里的工作看起来很简单。它启动应用程序,运行一些监听事件,然后将持续监听,直到应用程序关闭。在完全退出之前,我们需要在finally块中运行一些清理操作。
一旦您实现了所有这些,您就可以像我们之前所说的那样通过执行 bot.py 脚本来运行它。现在,您应该会在启动应用程序生命周期期间由 Sanic 触发的 Discord 服务器上看到这条消息。
<<<< 图片 >>>>>
接下来,您应该能够点击您的单个端点并看到另一条消息:
<<<< 图片 >>>>>
由于我们不是使用运行 Sanic 的标准方法,我不太推荐这种方法。首先,很容易搞错调用顺序,要么遗漏一些关键事件,要么不恰当地处理像关闭这样的操作。诚然,上面的关闭机制是不完整的。首先,它不包括对现有连接的优雅关闭处理。
这引出了下一个问题:我们是否可以在 Sanic 内部运行 Discord 机器人,而不是在 Discord 机器人内部运行 Sanic?是的,这正是我们接下来要做的。
在 Sanic 中运行 Discord 机器人
在我们开始之前,让我们考虑一下client.run正在做什么。它执行运行其服务所需的任何内部实例化,包括连接到 Discord 服务器。然后,它进入一个循环,异步接收和发送消息到 Discord 服务器。这听起来非常类似于 Sanic 服务器所做的事情。因此,我们可以做与我们刚才所做完全相同的事情,只是顺序相反。
-
取出我们刚刚构建的代码,并从机器人中移除
on_ready事件。 -
添加一个启动时间监听器,在新的后台任务中启动机器人。
@app.before_server_start async def startup_wadsworth(app, _): app.ctx.wadsworth = client app.add_task(client.start(app.config.DISCORD_TOKEN)) while True: if client.is_ready(): app.ctx.general = client.get_channel(app.config.GENERAL_CHANNEL_ID) await app.ctx.general.send("Wadsworth, reporting for duty") break await asyncio.sleep(0.1)在这个监听器中,我们也在做与上一个示例中相同的事情。我们设置了
app.ctx.wadsworth和app.ctx.general,以便它们在构建过程中易于访问和使用。此外,我们希望在 Wadsworth 上线并准备好工作的时候发送一条消息。是的,我们可以像之前一样使用on_ready从机器人中完成这个操作,但我们也可以从 Sanic 中完成这个操作。在上面的代码中,我们创建了一个循环来检查机器人的状态。一旦它准备好了,我们将发送消息并关闭循环。 -
我们接下来需要确保正确关闭机器人连接。我们将在关闭监听器中完成这个操作。
@app.before_server_stop async def shutdown(app, _): await client.close()
现在,您已经具备了从 Sanic 运行机器人的全部能力。这应该表现得与之前完全一样,但您现在可以使用 Sanic CLI 运行应用程序的全部功能,正如我们在本书的其余部分所做的那样。现在就启动它吧:
$ sanic server:app -p 7777 --debug --workers=2
这种嵌套其他asyncio应用程序的模式不仅适用于同时运行 Discord 机器人和 Sanic,其适用范围更广。它还允许我们在同一进程中运行多个 Sanic 应用程序,尽管它们在不同的端口上。这正是我们接下来要做的。
嵌套 Sanic 应用程序:在 Sanic 内部运行 Sanic 以创建 HTTP 代理
在 Sanic 内运行 Sanic 似乎有点像俄罗斯套娃。虽然这最初可能看起来像是一个惊人的思想实验,但它确实有一些实际应用。这种运行两个 Sanic 实例的最明显例子是创建你自己的 HTTP 到 HTTPS 代理。这正是我们现在要做的。或者,至少是某种程度上的。
我想添加的一个注意事项是,这个示例将使用 自签名证书。这意味着它不适合生产使用。你应该查看第七章中名为 ___ 的部分,以了解如何使用 TLS 正确保护你的应用程序的详细信息。
首先,我们将创建两个服务器。为了简单起见,一个将是 server.py(你的主要应用程序在 443 端口上运行 HTTPS),另一个将是 redirect.py(在 80 端口上运行的 HTTP 到 HTTPS 代理)。
-
我们将首先创建我们的自签名证书。如果你在 Windows 机器上,你可能需要查找如何在你的操作系统上完成这个操作。
$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -
接下来,我们在 server.py 中使用简单的工厂模式开始构建我们的 Sanic 应用程序。这个构建的代码可以在 ___ 找到。
from sanic import Sanic from wadsworth.blueprints.view import bp from wadsworth.blueprints.info.view import bp as info_view from wadsworth.applications.redirect import attach_redirect_app def create_app(): app = Sanic("MainApp") app.config.SERVER_NAME = "localhost:8443" app.blueprint(bp) app.blueprint(info_view) attach_redirect_app(app) return app提示
我首先想指出的是
SERVER_NAME的使用。这是一个在 Sanic 中默认未设置的配置值。这通常是你应该在所有应用程序中使用的东西。这是一个在 Sanic 背景中在几个位置使用的有用值。在我们的示例中,我们想使用它来帮助我们使用 app.url_for 生成稍后路线的 URL。该值应该是你的应用程序的域名,加上端口(如果它不是使用标准 80 或 443)。你不应该包括 http:// 或 https://。attach_redirect_app是什么?这是一个另一个应用程序工厂。但它的运作方式会有所不同,因为它还将负责将 resitect 应用程序嵌套在MainApp内。最后一点值得指出的是,有一个 Blueprint Group bp,我们将把所有的 Blueprints 都附加到它上面。不过,info_view将是独立的。更多细节稍后揭晓。 -
我们开始第二个工厂模式:
attach_redirect_app在redirect.py。def attach_redirect_app(main_app: Sanic): redirect_app = Sanic("RedirectApp") redirect_app.blueprint(info_view) redirect_app.blueprint(redirect_view) redirect_app.ctx.main_app = main_app我们将附加两个视图:我们刚刚附加到
MainApp的相同info_view,以及将执行重定向逻辑的redirect_view。我们将在完成这里的工厂和redirect.py中的服务器后查看它。此外,请注意,我们将main_app附加到redirect_app.ctx以便稍后检索。正如我们所学的,通过 ctx 传递对象是处理需要在应用程序中引用的对象的首选方法。 -
接下来,我们将向
MainApp添加一些监听器。这将在attach_redirect_app工厂内部发生。有些软件架构师可能不喜欢我将逻辑关注点嵌套在一起,但我们将忽略批评者并继续这样做,因为我们追求的是必须紧密耦合的逻辑,这将使我们未来更容易调试和更新。def attach_redirect_app(main_app: Sanic): ... @main_app.before_server_start async def startup_redirect_app(main: Sanic, _): app_server = await redirect_app.create_server( port=8080, return_asyncio_server=True ) if not app_server: raise ServerError("Failed to create redirect server") main_app.ctx.redirect = app_server main_app.add_task(runner(redirect_app, app_server))在这里,我们深入到 Sanic 服务器的较低级别操作。我们基本上需要模仿 Sanic CLI 和 app.run 所做的操作,但要在现有循环的范围内进行。当你运行一个 Sanic 服务器实例时,它将阻塞进程直到关闭。但我们需要运行两个服务器。因此,
RedirectApp服务器需要作为一个后台任务运行。我们通过使用 add_task 推迟运行服务器的工作来实现这一点。我们将在完成工厂后回到 runner。 -
RedirectApp也需要关闭。因此,我们向 MainApp 添加另一个监听器来完成这项工作。def attach_redirect_app(main_app: Sanic): ... @main_app.after_server_stop async def shutdown_redirect_app(main: Sanic, _): await main.ctx.redirect.before_stop() await main.ctx.redirect.close() for connection in main.ctx.redirect.connections: connection.close_if_idle() await main.ctx.redirect.after_stop() redirect_app.is_stopping = False This includes all of the major elements you need for turning down Sanic. It is a little bit basic and if you are implementing this in the real world, you might want to take a look into how Sanic server performs a graceful shutdown to close out any existing requests. We now turn to runner, the function that we passed off to be run in a background task to run the RedirectApp. async def runner(app: Sanic, app_server: AsyncioServer): app.is_running = True try: app.signalize() app.finalize() ErrorHandler.finalize(app.error_handler) app_server.init = True await app_server.before_start() await app_server.after_start() await app_server.serve_forever() finally: app.is_running = False app.is_stopping = True再次强调,我们正在完成 Sanic 在幕后启动服务器的一些高级步骤。它确实在
create_server之前运行before_start。影响很小。由于我们的RedirectApp甚至没有使用任何事件监听器,我们可以没有before_start和after_start(以及关闭事件)。 -
现在转到应用程序的重要部分:重定向视图。
from sanic import Blueprint, Request, response from sanic.constants import HTTP_METHODS bp = Blueprint("Redirect") @bp.route("/<path:path>", methods=HTTP_METHODS) async def proxy(request: Request, path: str): return response.redirect( request.app.url_for( "Redirect.proxy", path=path, _server=request.app.ctx.main_app.config.SERVER_NAME, _external=True, _scheme="https", ), status=301, )此路由将非常全面。它基本上将接受所有未匹配的端点,无论使用什么 HTTP 方法。这是通过使用路径参数类型并将
HTTP_METHODS常量传递给路由定义来实现的。任务是重定向到 https 版本的精确相同的请求。你可以这样做几种方式。例如,以下方法有效:f"https://{request.app.ctx.main_app.config.SERVER_NAME}{request.path}"然而,对我来说和我的大脑,我喜欢使用
url_for。如果你更喜欢替代方案:你做你的。重定向函数是一个方便的方法,用于生成适当的重定向响应。由于我们的用例需要从 http 重定向到 https,我们使用 301 重定向来表示这是一个永久(而不是临时)的重定向。让我们看看它是如何工作的。 -
要运行我们的应用程序,我们需要使用我们生成的 TLS 证书。
$ sanic wadsworth.applications.server:create_app \ --factory --workers=2 --port=8443 \ --cert=./wadsworth/certs/cert.pem \ --key=./wadsworth/certs/key.pem我们再次使用 CLI 运行应用程序。请确保使用
--factory,因为我们正在传递一个可调用对象。同时,我们告诉 Sanic 它可以在哪里找到为 TLS 加密生成的证书和密钥。 -
一旦运行起来,我们将进入终端使用
curl进行测试。首先,我们将确保两个应用程序都是可访问的:$ curl http://localhost:8080/info {"server":"RedirectApp"} That looks right. $ curl -k https://localhost:8443/info {"server":"MainApp"}这看起来也是正确的。请注意,我在 curl 命令中包含了
-k。这是因为我们创建的自签名证书。由于它不是来自官方受信任的证书颁发机构,curl将不会自动发出请求,直到你明确告诉它证书是好的。关于这一点,真正有趣的是/info端点并没有被定义两次。如果你查看源代码,你会看到它是一个蓝图,已经应用于两个应用程序。非常方便。 -
现在我们来到了最后的测试:重定向。
$ curl -kiL http://localhost:8080/v1/hello/Adam HTTP/1.1 301 Moved Permanently Location: https://localhost:8443/v1/hello/Adam content-length: 0 connection: keep-alive content-type: text/html; charset=utf-8 HTTP/1.1 200 OK content-length: 16 connection: keep-alive content-type: application/json {"hello":"Adam"}
请确保注意我们正在访问 8080 端口,这是RedirectApp。我们再次使用-k来告诉 curl 不要担心证书验证。我们还使用-L来告诉curl跟随任何重定向。最后,我们添加-i来输出完整的 HTTP 响应,这样我们就可以看到发生了什么。
如您从上面的响应中可以看到,我们生成了一个适当的 301 重定向,并将用户引导到了 https 版本,它用我的名字亲切地问候了我。
就这样:一个简单的 HTTP 到 HTTPS 重定向应用程序,在 Sanic 内部运行 Sanic。
摘要
我喜欢构建 Web 应用程序的机会,可以构建解决问题的方案。例如,在本章的早期,我们遇到了从 Sanic 运行 JavaScript 开发服务器的问题。如果你让五位不同的开发者来解决这个问题,你可能会得到五种不同的解决方案。我相信,在某种程度上,构建 Web 应用程序是一种艺术形式。也就是说,它不是一个必须以唯一一种明显的方式解决的问题的严格领域。相反,什么是明显的,只能根据你构建的独特环境和参数来确定。
当然,我们在这里构建的只是 Sanic 可能性的冰山一角。显示的选择既有一些流行的用例,也有一些可能不太直接的用例。我希望你能从中汲取一些想法和模式,并加以有效利用。通过阅读这本书并内化本章中的示例,我希望我已经帮助激发了你在构建应用程序方面的创造性思维。
如果我们将本章的所有想法整合成一个单一的应用程序,你将得到一个由 Sanic 驱动的 PWA,使用分布式 WebSocket 流和 GraphQL API,同时还运行一个 Discord 机器人。我的观点是,在应用程序中实现功能不能在真空中完成。在决定如何构建某物时,你必须考虑你的架构的其他部分。本章旨在帮助您了解我在解决这些问题时的思考过程。
随着我们接近本书的结尾,我们最后需要做的是将我们所知的大量内容整合成一个可部署的应用程序。这就是我们在第十一章要做的:构建一个完全功能、适用于生产的 Sanic 应用程序。
第十二章:使用 Sanic 进行 Python 网络开发
版权 ©2021 Packt 出版公司
保留所有权利。未经出版者事先书面许可,本书的任何部分不得以任何形式或通过任何手段进行复制、存储在检索系统中或以任何方式传输,除非在评论或评论文章中嵌入的简短引用。
在准备本书的过程中,我们已尽一切努力确保所提供信息的准确性。然而,本书中的信息销售时不附带任何明示或暗示的保证。作者、Packt 出版公司及其经销商和分销商不对由此书直接或间接造成的任何损害承担责任。
Packt 出版公司已尽力通过适当使用大写字母来提供本书中提到的所有公司和产品的商标信息。然而,Packt 出版公司不能保证此信息的准确性。
早期访问出版:使用 Sanic 进行 Python 网络开发
早期访问生产参考:B17504
由 Packt 出版有限公司出版
Livery Place
35 Livery Street
伯明翰
B3 2PB,英国
ISBN:978-1-80181-441-6





浙公网安备 33010602011771号