Python 网络编程基础知识(三)
八、缓存和消息队列
这一章虽然简短,但可能是本书中最重要的一章。它调查了两种技术——缓存和消息队列——这两种技术已经成为高负载下服务的基本构件。这本书到了一个转折点。前面的章节已经探讨了 sockets API 以及 Python 如何使用原始的 IP 网络操作来构建通信通道。如果你提前看的话,你会发现接下来的所有章节都是关于建立在套接字上的特定协议——如何从万维网上获取文档,发送电子邮件,以及向远程服务器提交命令。
你将在本章中看到的这两个工具的区别是什么?他们有几个共同的特点。
- 这些技术都很受欢迎,因为它是一个强大的工具。使用 Memcached 或消息队列的意义在于,它是一个编写良好的服务,可以为您解决特定的问题,而不是因为它实现了一个有趣的协议,可以让您与任何其他工具进行互操作。
- 这些工具解决的问题往往是组织内部的问题。您通常无法从外部判断哪个缓存、队列和负载分布工具正被用于支持特定的网站或网络服务。
- 虽然 HTTP 和 SMTP 等协议是根据特定的有效负载(分别是超文本文档和电子邮件)构建的,但缓存和消息队列往往完全不知道它们为您传输的数据。
本章无意成为这些技术的手册。提到的每一个库都有大量的在线文档,对于更受欢迎的库,您甚至可以找到关于它们的整本书。相反,本章的目的是向您介绍每个工具解决的问题,解释如何使用服务来解决该问题,并给出一些使用 Python 工具的提示。
毕竟,程序员经常面临的最大挑战——除了学习编程本身的基本、终身过程之外——是识别存在快速预建解决方案的常见问题。程序员有一个令人遗憾的习惯,那就是费力地重新发明轮子。把这一章想象成给你两个成品轮子,希望你可以避免自己造轮子。
使用 Memcached
Memcached 是“内存缓存守护进程”它将安装它的服务器上的空闲内存合并到一个大型的最近最少使用(LRU)缓存中。据说,它对许多大型互联网服务的影响是革命性的。在看了如何从 Python 中使用它之后,我将讨论它的实现,这将教你一个重要的现代网络概念,叫做分片 。
使用 Memcached 的实际过程被设计得很简单。
- 您在每台有一些空闲内存的服务器上运行一个 Memcached 守护进程。
- 您可以列出新的 Memcached 守护进程的 IP 地址和端口号,并将这个列表分发给所有将使用缓存的客户机。
- 您的客户端程序现在可以访问组织范围内的、速度惊人的键值缓存,它的作用就像一个大的 Python 字典,所有服务器都可以共享。缓存在 LRU 的基础上运行,丢弃一段时间没有被访问的旧项目,以便它有空间接受新的条目并保留频繁访问的记录。
Memcached 当前列出了足够多的 Python 客户端,我最好将您发送到列出它们的页面,而不是尝试在这里查看它们。
首先列出的客户机是用纯 Python 编写的,因此不需要编译任何库。由于可以从 Python 包索引中获得,它应该可以非常干净地安装到虚拟环境中(参见第一章)。Python 3 的版本可以通过一个命令安装。
$ pip install python3-memcached
这个包的 API 很简单。虽然您可能希望有一个更像 Python 字典的接口,带有像__getitem__()这样的本地方法,但是这个 API 的作者选择使用与 Memcached 支持的其他语言中使用的相同的方法名。这是一个很好的决定,因为它使得将 Memcached 示例翻译成 Python 变得更加容易。如果您在机器上安装了 Memcached 并运行在默认端口 11211,那么 Python 提示符下的一个简单交互可能如下所示:
>>> import memcache
>>> mc = memcache.Client(['127.0.0.1:11211'])
>>> mc.set('user:19', 'Simple is better than complex.')
True
>>> mc.get('user:19')
'Simple is better than complex.'
您可以看到这里的接口非常像 Python 字典。当你像这样提交一个字符串时,这个字符串会以 UTF-8 的形式直接写入 Memcached,然后在你以后获取它时再次被解码。除了一个简单的字符串之外,任何其他类型的 Python 对象都会触发memcache模块为您自动提取值(参见第五章)并将二进制 pickle 存储在 Memcached 中。如果您编写的 Python 应用与用其他语言编写的客户端共享 Memcached 缓存,那么请记住这一点。对于用其他语言编写的客户端来说,只有保存为字符串的值才能被破译。
请记住,存储在 Memcached 中的数据可能会被服务器随意丢弃。缓存旨在通过记住重新计算代价高昂的结果来加速操作。它不是为存储无法从其他信息源重建的数据而设计的!如果前面的命令是针对足够繁忙的 Memcached 运行的,并且如果在set()和get()操作之间经过了足够长的时间,那么get()可以很容易地发现该字符串已经从缓存中过期并且不再存在。
清单 8-1 显示了从 Python 中使用 Memcached 的基本模式。在进行(人工)昂贵的整数平方运算之前,这段代码检查 Memcached,看答案是否已经存储在缓存中。如果是这样,那么答案可以立即返回,而不需要重新计算。如果不是,则在返回之前计算并存储在缓存中。
清单 8-1 。使用 Memcached 加速昂贵的操作
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter08/squares.py
# Using memcached to cache expensive results.
import memcache, random, time, timeit
def compute_square(mc, n):
value = mc.get('sq:%d' % n)
if value is None:
time.sleep(0.001) # pretend that computing a square is expensive
value = n * n
mc.set('sq:%d' % n, value)
return value
def main():
mc = memcache.Client(['127.0.0.1:11211'])
def make_request():
compute_square(mc, random.randint(0, 5000))
print('Ten successive runs:')
for i in range(1, 11):
print(' %.2fs' % timeit.timeit(make_request, number=2000), end='')
print()
if __name__ == '__main__':
main()
同样,Memcached 守护进程需要在您的机器上的端口 11211 上运行,这个例子才能成功。当然,对于最初的几百个请求,程序将以其通常的速度运行;每次它第一次询问一个特定整数的平方时,它会发现它在 RAM 缓存中丢失了,而不得不计算它。然而,当程序运行并开始一次又一次地遇到相同的整数时,它会开始加速,因为它会找到自上次看到特定整数以来仍然存在于缓存中的方块。
在从 5000 个可能的输入整数中提取出几千个请求后,程序应该显示出显著的加速。在我的机器上,第十批 2000 个方块的运行速度比第一批快了六倍多。
$ python squares.py
Ten successive runs:
2.87s 2.04s 1.50s 1.18s 0.95s 0.73s 0.64s 0.56s 0.48s 0.45s
这种模式通常是缓存的特征。随着缓存开始学习足够多的键和值,运行时逐渐提高,然后随着 Memcached 的填充以及输入域的百分比覆盖率达到最大值,提高的速度趋于平稳。
在实际的应用中,您希望将哪种数据写入缓存?
许多程序员只是缓存最低级别的高开销调用,比如对数据库的查询、对文件系统的读取或对外部服务的查询。在这个级别,通常很容易理解哪些项目可以缓存多长时间,而不会使信息太过时。如果数据库行发生变化,那么甚至可以先发制人地清除缓存中与变化值相关的过时项。但是有时在应用的更高层缓存中间结果会有很大的价值,比如数据结构、HTML 片段,甚至整个网页。这样,缓存命中不仅防止了数据库访问,还防止了将结果转换为数据结构,然后转换为呈现的 HTML 的成本。
从 Memcached 站点上可以链接到许多很好的介绍和深入的指南,以及令人惊讶的大量 FAQ 就好像 Memcached 的开发人员已经发现,教义问答是向人们介绍他们的服务的最佳方式。我将在这里提出一些一般性的观点。
首先,键必须是唯一的,因此开发人员倾向于使用前缀和编码来区分他们存储的各种类型的对象。您经常会看到像user:19、mypage:/node/14,甚至是一个 SQL 查询的整个文本被用作一个键。键的长度只能是 250 个字符,但是通过使用一个强大的散列函数,您可以进行支持更长字符串的查找。顺便说一下,存储在 Memcached 中的值可以比键长,但长度限制在 1MB 以内。
第二,你必须记住 Memcached 是一个缓存。它是短暂的,它使用 RAM 进行存储,如果重新启动,它不会记得你曾经存储过的任何东西!如果缓存消失,您的应用应该总是能够恢复和重建其所有数据。
第三,确保您的缓存不会返回太旧而无法准确呈现给用户的数据。“太老了”完全取决于你的问题领域。银行余额可能需要绝对最新,而“今日头条”在新闻网站的首页上可能是几分钟前的事了。
有三种方法可以解决陈旧数据的问题,并确保它得到清理,而不是永远超过其有用的保存期限。
- Memcached 将允许您为放入缓存中的每个项目设置一个到期日期和时间,当到期时,它会自动删除这些项目。
- 如果您有办法从一条信息的标识映射到缓存中可能包含它的所有键,那么您可以在特定缓存条目失效时主动使它们失效。
- 您可以重写和替换无效的条目,而不是简单地删除它们,这对于每秒钟可能被点击几十次的条目很有效。不是所有这些客户端都找到丢失的条目并同时尝试重新计算它,而是在那里找到重写的条目。出于同样的原因,在应用首次启动时预填充缓存对于大型站点来说是一项至关重要的生存技能。
正如您可能猜到的,decorators 是在 Python 中添加缓存的一种流行方式,因为它们包装函数调用而不改变它们的名称或签名。如果您查看 Python 包索引,您会发现几个可以利用 Memcached 的装饰缓存库。
哈希和分片
Memcached 的设计说明了一个重要的原则,这个原则在其他几种数据库中使用,您可能想在自己的体系结构中使用它。当面对一个列表中的几个 Memcached 实例时,Memcached 客户机将通过散列每个键的字符串值来分割数据库,并让散列决定 Memcached 集群中的哪个服务器用于存储特定的键。
为了理解为什么这是有效的,考虑一个特殊的键-值对——比如键sq:42和值1764,它们可能被清单 8-1 存储。为了充分利用可用的 RAM,Memcached 集群希望将这个键和值存储一次。但是为了使服务更快,它希望避免重复,而不需要不同服务器之间的任何协调或所有客户机之间的通信。
这意味着,除了(a)密钥和(b)配置它们的 Memcached 服务器列表之外,没有任何其他信息的所有客户机都需要某种方案来确定这条信息属于哪里。如果他们不能做出相同的决定,那么不仅键和值可能被复制到几个服务器并减少可用的总内存,而且客户端试图删除无效条目可能会在其他地方留下其他无效副本。
解决方案是所有客户端都实现一个单一的、稳定的算法,该算法可以将一个密钥转换为整数 n ,从它们的列表中选择一个服务器。他们通过使用“哈希”算法来实现这一点,该算法在形成一个数字时混合一个字符串的各个位,这样字符串中的任何模式在理想情况下都会被删除。
要了解为什么键值中的模式必须被删除,考虑清单 8-2 中的。它加载一个英语单词字典(您可能需要下载自己的字典或调整路径以使脚本在您自己的机器上运行),并探索如果这些单词被用作键,它们将如何分布在四个服务器上。第一种算法试图将字母表分成四个大致相等的部分,并使用它们的第一个字母来分配密钥;另外两种算法使用散列函数。
清单 8-2 。向服务器分配数据的两种方案:数据模式和来自散列的位
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter08/hashing.py
# Hashes are a great way to divide work.
import hashlib
def alpha_shard(word):
"""Do a poor job of assigning data to servers by using first letters."""
if word[0] < 'g': # abcdef
return 'server0'
elif word[0] < 'n': # ghijklm
return 'server1'
elif word[0] < 't': # nopqrs
return 'server2'
else: # tuvwxyz
return 'server3'
def hash_shard(word):
"""Assign data to servers using Python's built-in hash() function."""
return 'server%d' % (hash(word) % 4)
def md5_shard(word):
"""Assign data to servers using a public hash algorithm."""
data = word.encode('utf-8')
return 'server%d' % (hashlib.md5(data).digest()[-1] % 4)
if __name__ == '__main__':
words = open('/usr/share/dict/words').read().split()
for function in alpha_shard, hash_shard, md5_shard:
d = {'server0': 0, 'server1': 0, 'server2': 0, 'server3': 0}
for word in words:
d[function(word.lower())] += 1
print(function.__name__[:-6])
for key, value in sorted(d.items()):
print(' {} {} {:.2}'.format(key, value, value / len(words)))
print()
hash()函数是 Python 自己的内置散列例程,它被设计得非常快,因为它在内部用于实现 Python 字典查找。MD5 算法要复杂得多,因为它实际上是作为加密散列而设计的。虽然它现在被认为太弱,不适合安全使用,但使用它来跨服务器分布负载是没问题的(尽管比 Python 的内置哈希慢)。
结果非常清楚地表明,试图使用任何可能直接暴露数据中模式的方法来分配负载是危险的。
$ python hashing.py
alpha
server0 35285 0.36
server1 22674 0.23
server2 29097 0.29
server3 12115 0.12
hash
server0 24768 0.25
server1 25004 0.25
server2 24713 0.25
server3 24686 0.25
md5
server0 24777 0.25
server1 24820 0.25
server2 24717 0.25
server3 24857 0.25
您可以看到,按第一个字母分配负载,其中四个箱中的每一个都分配有大致相等数量的字母,导致服务器 0 的负载是服务器 3 的三倍以上,即使它只分配了六个字母而不是七个字母!然而,杂凑套路都表现得像冠军。尽管所有的强模式不仅表征了英文单词的首字母,还表征了整个结构和结尾,但是哈希函数将单词均匀地分布在这四台虚拟服务器上。
尽管许多数据集不像英语单词的字母分布那样偏斜,但像 Memcached 这样的分片数据库总是不得不应对输入数据中出现的模式。
例如,清单 8-1 在使用键的时候并不少见,这些键总是以一个公共前缀开始,然后是受限字母表中的字符:十进制数字。这些明显的模式就是为什么分片应该总是通过散列函数来执行。
当然,这是一个实现细节,当您使用 Memcached 这样的数据库系统(其客户端库在内部支持分片)时,您可能经常会忽略这个细节。但是,如果您需要设计一个自己的服务,自动将工作或数据分配给集群中的节点,并且需要在同一数据存储的几个客户端之间可重复,那么您会发现在您自己的代码中使用了相同的技术。
消息队列
消息队列协议让你发送可靠的数据块,协议称之为消息而不是数据报,因为正如你在第二章中看到的,数据报的概念是特定于不可靠的服务的,在不可靠的服务中,数据可能被底层网络丢失、复制或重新排序。通常,消息队列承诺可靠地传输消息,并以原子方式传递消息:消息要么完整无缺地到达,要么根本就没有到达。成帧是由消息队列协议本身执行的。使用消息队列的客户机永远不必循环并一直调用类似于recv()的东西,直到整个消息到达。
消息队列提供的另一个创新是,您可以在消息客户端之间建立各种拓扑,而不是仅支持像 TCP 这样的 IP 传输可能实现的点对点连接。消息队列有许多可能的用途。
- 当您使用您的电子邮件地址在一个新网站上注册一个帐户时,该网站通常会立即响应一个页面,上面写着“谢谢您,请注意您的收件箱,有一封确认电子邮件”,而不会让您等待几分钟,因为该网站可能会联系到您的电子邮件服务提供商来发送邮件。该网站通常通过将您的电子邮件地址放入消息队列来实现这一点,当后端服务器准备好尝试新的传出 SMTP 连接时,可以从消息队列中检索地址。如果传递尝试暂时失败,那么您的电子邮件地址可以简单地放回队列中,等待更长的超时时间,以便稍后重试。
- 消息队列可以用作定制远程过程调用(RPC) (参见第十八章)服务的基础,在这种模式下,繁忙的前端服务器可以通过将请求放在消息队列上来卸载困难的工作,该消息队列可能有数十或数百个后端服务器监听它,然后等待响应。
- 需要聚合或集中存储和分析的大量事件数据通常作为微小高效的消息通过消息队列传输。在某些站点上,这完全取代了本地硬盘上的机器日志和旧的日志传输机制,如 syslog。
消息队列应用设计的特点是能够混合和匹配所有的客户端和服务器,或者发布者和订阅者进程,方法是将它们都连接到同一个消息传递结构。
消息队列的使用可以给你写程序带来一点革命。典型的单片应用由一层又一层的 API 组成,通过这些 API,单个控制线程可以从从套接字读取 HTTP 数据到验证和解释请求,再到调用 API 来执行定制的图像处理,最后将结果写入磁盘。单个控制线程使用的每个 API 都必须存在于单个机器上,加载到 Python 运行时的单个实例中。但是,一旦消息队列成为您的工具包的一部分,您就会开始问,为什么像图像处理这样的密集型、专门化和与 web 无关的东西应该与您的前端 HTTP 服务共享 CPU 和磁盘驱动器。您不再从安装了几十个异构库的大型机器上构建服务,而是开始转向被分组到提供单一服务的集群中的专用机器。您的操作人员可以轻松地开始拆除、升级和重新连接图像处理服务器,甚至不需要接触位于您的消息队列前面的 HTTP 服务的负载平衡池,只要操作人员了解消息传递拓扑和用于分离服务器的协议,这样就不会丢失任何消息。
每个品牌的消息队列通常支持几种拓扑。
- 一个管道拓扑是这样一种模式,当你想到一个队列时,它可能最像你脑海中的画面:一个生产者创建消息并将它们提交给队列,然后消费者可以从队列中接收消息。例如,照片共享网站的前端 web 计算机可能接受来自最终用户的图像上传,并将传入的文件注册到内部队列中。然后,装满缩略图生成器的机房可以从队列中读取,每个代理一次接收一个包含图像的消息,它应该为该图像生成几个缩略图。当网站繁忙时,队列可能会变长,然后在使用率相对较低的时期变短或变空,但无论如何,前端 web 服务器都可以快速向等待的客户返回响应,告诉客户他们的上传成功,他们的图像将很快出现在他们的照片流中。
- 一个发布者-订阅者或扇出拓扑 看起来像一个管道,但有一个关键的区别。虽然管道确保每个排队的消息都被准确地传递给一个消费者——毕竟,为两个缩略图服务器分配相同的照片是一种浪费——但订阅者通常希望接收发布者排队的所有消息。或者,订阅者可以指定一个过滤器,将他们的兴趣缩小到具有特定格式的消息。这种队列可用于支持需要向外界推送事件的外部服务。它还可以形成一个结构,一个装满服务器的机房可以用来通告哪些系统正在运行,哪些系统正在进行维护,甚至可以在创建和销毁其他消息队列时发布它们的地址。
- 最后,请求-回复模式是最复杂的,因为消息必须往返。前面的两种模式都没有让消息的生产者承担什么责任:生产者连接到队列并传输它的消息,然后就完成了。但是发出请求的消息队列客户机必须保持连接,并等待收到回复。为了支持这一点,队列必须以某种寻址方案为特征,通过该方案,回复可以被定向到正确的客户端,可能是数千个连接的客户端中的一个,该客户端仍然在等待回复。但是尽管其潜在的复杂性,这可能是所有模式中最强大的。它允许数十或数百个客户端的负载平均分布在大量服务器上,除了设置消息队列之外,无需任何工作。因为一个好的消息队列将允许服务器在不丢失消息的情况下连接和分离,所以这种拓扑还允许服务器以一种客户机不可见的方式停止维护。
请求-应答队列是一种很好的方式,可以将可以在特定机器上一起运行的数百个轻量级工作线程(比如 web 服务器前端的线程)连接到数据库客户端或文件服务器,这些客户端或文件服务器有时需要被调用来代表前端执行更繁重的工作。请求-应答模式非常适合 RPC 机制,它还有一个更简单的 RPC 系统通常没有的好处;也就是说,在扇入或扇出工作模式中,许多消费者或生产者都可以连接到同一个队列,而任何一组客户端都不知道这种区别。
使用 Python 中的消息队列
最流行的消息队列被实现为独立的服务器。您选择用来构建应用的所有各种任务——生产者、消费者、过滤器和 RPC 服务——都可以附加到消息队列,而不必了解彼此的地址甚至身份。AMQP 协议是最广泛实现的语言无关的消息队列协议之一,它受到可以安装的开源服务器的支持,比如 RabbitMQ、Apache Qpid 服务器和许多其他项目。
许多程序员自己从来没有学习过消息协议。相反,他们依赖第三方库,这些库打包了消息队列的好处,以便通过 API 轻松使用。例如,许多使用 Django web 框架的 Python 程序员使用流行的 Celery 分布式任务队列,而不是自己学习 AMQP。库还可以通过支持其他后端服务来提供协议独立性。在 Celery 的例子中,您可以使用简单的 Redis 键值存储作为您的“消息队列”,而不是专用的消息传递设备。
然而,出于本书的目的,一个不需要安装完全独立的消息队列服务器的示例更方便,因此我将介绍 MQ,即由 AMQP 公司创建的零消息队列 ,,它将消息传递智能从一个集中的代理转移到您的每一个消息客户端程序中。换句话说,将 MQ 库嵌入到您的每个程序中,可以让您的代码自发地构建消息传递结构,而不需要集中的代理。这在方法上与基于中央代理的体系结构有一些不同,中央代理可以为磁盘提供可靠性、冗余、重传和持久性。MQ 网站:www.zeromq.org/docs:welcome-from-amqp提供了对优点和缺点的总结。
为了保持本节中的例子是独立的,清单 8-3 处理了一个简单的问题,它并不真正需要消息队列:通过使用一个简单的,虽然效率不高的蒙特卡罗方法 来计算π的值。重要的消息传递拓扑如图 8-1 所示。一个bitsource例程产生由 1 和 0 组成的长度为 2 n 的字符串。我将使用奇数位作为一个 n 位整数 x 坐标,偶数位作为一个 n 位整数 y 坐标。这个坐标是在以原点为圆心的四分之一圆的内部还是外部,这个圆的半径是这两个整数的最大值?
图 8-1 。π的简单蒙特卡罗估计的拓扑
使用发布-订阅拓扑,您为这些二进制字符串构建了两个听众。always_yes监听器将只接收以00开头的数字串,因此总是可以推送答案Y,因为,如果你的两个坐标都以数字 0 开头,那么该点一定位于域的左下象限,因此安全地落在圆内。然而,前两位的其他三种可能的模式必须由进行真正测试的judge例程来处理。它必须要求pythagoras计算两个整数坐标的平方和,以确定它们指定的点是在圆内还是圆外,并相应地将T或F推入其输出队列。
拓扑底部的计数例程接收为每个生成的随机位模式产生的T或F,通过将 T 答案的数量与 T 和 F 答案的总数进行比较,它可以估计π的值。如果你对数学感兴趣,可以在网上搜索一下圆周率的蒙特卡罗估计值*。*
清单 8-3 实现了这种五个工人的拓扑结构,它让程序运行 30 秒后退出。它需要 MQ,您可以通过创建一个虚拟环境,然后键入以下内容,很容易地将 MQ 提供给 Python:
$ pip install pyzmq
如果您使用的操作系统已经为您打包了 Python 或者像 Anaconda 这样的独立 Python 安装,那么这个包可能已经安装好了。在这两种情况下,清单 8-3 将能够在没有导入错误的情况下运行。
清单 8-3 。MQ 消息传递结构连接五个不同的工作人员
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter08/queuecrazy.py
# Small application that uses several different message queues
import random, threading, time, zmq
B = 32 # number of bits of precision in each random integer
def ones_and_zeros(digits):
"""Express `n` in at least `d` binary digits, with no special prefix."""
return bin(random.getrandbits(digits)).lstrip('0b').zfill(digits)
def bitsource(zcontext, url):
"""Produce random points in the unit square."""
zsock = zcontext.socket(zmq.PUB)
zsock.bind(url)
while True:
zsock.send_string(ones_and_zeros(B * 2))
time.sleep(0.01)
def always_yes(zcontext, in_url, out_url):
"""Coordinates in the lower-left quadrant are inside the unit circle."""
isock = zcontext.socket(zmq.SUB)
isock.connect(in_url)
isock.setsockopt(zmq.SUBSCRIBE, b'00')
osock = zcontext.socket(zmq.PUSH)
osock.connect(out_url)
while True:
isock.recv_string()
osock.send_string('Y')
def judge(zcontext, in_url, pythagoras_url, out_url):
"""Determine whether each input coordinate is inside the unit circle."""
isock = zcontext.socket(zmq.SUB)
isock.connect(in_url)
for prefix in b'01', b'10', b'11':
isock.setsockopt(zmq.SUBSCRIBE, prefix)
psock = zcontext.socket(zmq.REQ)
psock.connect(pythagoras_url)
osock = zcontext.socket(zmq.PUSH)
osock.connect(out_url)
unit = 2 ** (B * 2)
while True:
bits = isock.recv_string()
n, m = int(bits[::2], 2), int(bits[1::2], 2)
psock.send_json((n, m))
sumsquares = psock.recv_json()
osock.send_string('Y' if sumsquares < unit else 'N')
def pythagoras(zcontext, url):
"""Return the sum-of-squares of number sequences."""
zsock = zcontext.socket(zmq.REP)
zsock.bind(url)
while True:
numbers = zsock.recv_json()
zsock.send_json(sum(n * n for n in numbers))
def tally(zcontext, url):
"""Tally how many points fall within the unit circle, and print pi."""
zsock = zcontext.socket(zmq.PULL)
zsock.bind(url)
p = q = 0
while True:
decision = zsock.recv_string()
q += 1
if decision == 'Y':
p += 4
print(decision, p / q)
def start_thread(function, *args):
thread = threading.Thread(target=function, args=args)
thread.daemon = True # so you can easily Ctrl-C the whole program
thread.start()
def main(zcontext):
pubsub = 'tcp://127.0.0.1:6700'
reqrep = 'tcp://127.0.0.1:6701'
pushpull = 'tcp://127.0.0.1:6702'
start_thread(bitsource, zcontext, pubsub)
start_thread(always_yes, zcontext, pubsub, pushpull)
start_thread(judge, zcontext, pubsub, reqrep, pushpull)
start_thread(pythagoras, zcontext, reqrep)
start_thread(tally, zcontext, pushpull)
time.sleep(30)
if __name__ == '__main__':
main(zmq.Context())
这些线程中的每一个都小心翼翼地创建自己的通信套接字,因为两个线程试图共享一个消息套接字是不安全的。但是这些线程确实共享一个上下文对象,这确保它们都存在于您可能称之为 URL、消息和队列的共享竞技场中。您通常希望为每个进程只创建一个 MQ 上下文。
尽管这些套接字提供的方法名称类似于我们熟悉的套接字操作,比如recv()和send(),但是请记住它们具有不同的语义。消息按顺序保存,从不重复,但是它们被清晰地分隔为单独的消息,而不是在连续的流中丢失。
这个例子显然是人为设计的,这样,在几行代码中,您就有理由使用典型队列提供的大多数主要消息传递模式。always_yes和judge与bitsource建立的连接形成了一个发布-订阅系统,在这个系统中,每一个连接的客户端都会收到发布者发送的每一条消息的副本(在这个例子中,不包括任何被过滤掉的消息)。应用于 MQ 套接字的每个过滤器通过选择前几个数字与过滤器字符串匹配的每个消息来增加而不是减少所接收的消息总数。然后,你的用户对保证接收到由bitsource产生的每一个位串,因为在它们的四个过滤器中有两个领先二进制数字的每一种可能的组合。
judge和pythagoras 之间的关系是一个经典的 RPC 请求-应答关系,持有REQ套接字的客户端必须首先发言,以便将其消息分配给连接到其套接字的一个等待代理。(这种情况下当然只附带一个代理。)消息传递结构在后台自动将返回地址添加到请求中。一旦代理完成了它的工作和回复,返回地址可以用于通过REP套接字传输回复,这样它将到达正确的客户端,即使当前有几十个或几百个客户端。
最后,tally worker 说明了推拉式安排是如何保证每个被推的项目都将被一个且只有一个连接到套接字的代理接收;如果你要启动几个tally工人,那么来自上游的每个新数据将只到达其中一个,并且它们将分别在π上收敛。
注意,与本书中介绍的所有其他套接字编程不同,这个清单根本不需要注意是bind()还是connect()先出现!这是 MQ 的一个特性,它使用超时和轮询在后台不断重试失败的connect(),以防 URL 描述的端点稍后出现。这使得它能够在应用运行时抵御来来去去的代理。
当程序退出时,由此产生的工人系统在我的笔记本电脑上能够计算π到大约三位数。
$ python queuepi.py
...
Y 3.1406089633937735
这个简单的例子可能会让 MQ 编程看起来过于简单。在现实生活中,您通常需要比这里提供的模式更复杂的模式,以确保消息的传递,在消息还不能被处理的情况下持久化它们,并进行流控制以确保缓慢的代理不会被最终排队等待的消息数量所淹没。有关如何为产品服务实现这些模式的详细讨论,请参阅官方文档。最后,许多程序员发现,芹菜后面的 RabbitMQ、Qpid 或 Redis 等成熟的消息代理以最少的工作量和最少的出错可能性向他们提供了他们想要的保证。
摘要
在现代社会,为成千上万的客户服务已经成为应用开发人员的日常任务。出现了几项关键技术来帮助他们满足这一规模,并且可以很容易地从 Python 访问它们。
一个流行的服务是 Memcached,它将安装它的所有服务器上的空闲 RAM 合并到一个大的 LRU 缓存中。只要您有一些过程来使过期的条目失效或替换这些条目,或者处理可能在固定的、可预测的时间表过期的数据,Memcached 就可以从您的数据库或其他后端存储中移除大量负载。它可以在加工过程中的几个不同点插入。例如,与其保存昂贵的数据库查询的结果,不如简单地缓存最终呈现的 web 小部件。
消息队列是另一种通用机制,它为应用的不同部分提供了一个协调和集成点,这可能需要不同的硬件、负载平衡技术、平台,甚至编程语言。它们可以负责在许多等待的消费者或服务器之间分发消息,这是普通 TCP 套接字提供的单一点对点链接所无法做到的,它们还可以使用数据库或其他持久存储来确保在服务器停机时消息不会丢失。消息队列还提供弹性和灵活性,因为如果系统的某个部分暂时成为瓶颈,消息队列可以通过允许许多消息排队等待该服务来吸收冲击。通过隐藏为特定类型的请求提供服务的服务器或进程,消息队列模式还使得断开、升级、重启和重新连接服务器变得容易,而不会被基础设施的其他部分察觉。
许多程序员在更友好的 API 后面使用消息队列,例如在 Django 社区中流行的 Celery 项目。它也可以使用 Redis 作为后端。虽然本章没有涉及,但 Redis 值得您关注。它在维护键和值方面类似于 Memcached,在将它们保存到存储器方面类似于数据库,在 FIFO 是它可以支持的可能值之一方面类似于消息队列。
如果这些模式中的任何一个听起来像是解决了您的问题,那么就在 Python 包索引中搜索可能实现它们的 Python 库的好线索。在本书出版期间,Python 社区中与这些通用工具和技术相关的最新技术将继续发展,并且可以通过博客、tweets 和特别是 Stack Overflow 来探索,因为那里有一种强大的文化,即随着解决方案的老化和新解决方案的出现,保持答案的更新。
在研究了这些建立在 IP/TCP 之上的简单而具体的技术之后,您将在接下来的三章中把注意力转向该协议,该协议已经变得如此占主导地位,以至于许多人认为它就是 Internet 本身的同义词:实现万维网的 HTTP 协议。
九、HTTP 客户端
这是关于 HTTP 的三章中的第一章。在这一章中,你将从一个客户端程序的角度学习如何使用该协议,这个客户端程序想要获取和缓存文档,并且可能还向服务器提交查询或数据。在这个过程中,您将了解协议如何运行的规则。第十章将会介绍 HTTP 服务器的设计和部署。这两章都将考虑协议最原始的概念形式,也就是说,简单地作为获取或发布文档的机制。
虽然 HTTP 可以传送多种类型的文档——图像、pdf、音乐和视频——第十一章研究了使 HTTP 和互联网闻名于世的特殊类型的文档:超文本文档的万维网,它们由于 URL 的发明而相互链接,这也在第十一章中有所描述。在那里,您将了解模板库、表单和 Ajax 支持的编程模式,以及试图将所有这些模式整合到一个易于编程的表单中的 web 框架。
HTTP 版本 1.1 ,当今使用最普遍的版本,在 RFCs 7230–7235 中定义,如果这些章节的文本看起来含糊不清或者让你想知道更多,你应该参考它。对于协议设计背后的理论的更多技术介绍,你可以参考罗伊·托马斯·菲尔丁的著名博士论文“架构风格和基于网络的软件架构的设计”的第五章
现在,您的旅程从这里开始,您将学习查询服务器并获得响应文档。
Python 客户端库
HTTP 协议和它提供的大量数据资源是 Python 程序员长期以来的热门话题,这一点在多年来大量声称比标准库中内置的 urllib 做得更好的第三方客户端中得到了反映。
然而,今天,一个单独的第三方解决方案独树一帜,不仅彻底横扫了竞争者的领域,而且还取代了 urllib,成为想要使用 HTTP 的 Python 程序员的首选工具。这个库是 Requests,由 Kenneth Reitz 编写,由 urllib3 的连接池逻辑提供支持,由 Andrey Petrov 维护。
当你在本章中学习 HTTP 时,你将回到 urllib 和 Requests 来看看当面对每一个 HTTP 特性时,它们做得好和不好的地方。它们的基本接口非常相似——它们都提供了一个 callable,该 callable 打开一个 HTTP 连接,发出一个请求,并在返回一个将它们呈现给程序员的 response 对象之前等待响应头。响应体留在传入套接字的队列中,只有在程序员要求时才读取。
在本章的大部分例子中,我将在一个名为http://httpbin.org的小型测试网站上测试这两个 HTTP 客户端库,这个网站由 Kenneth Reitz 设计,你可以通过安装pip在本地运行它,然后在一个 WSGI 容器(参见第十章)中运行它,比如 Gunicorn。要在localhost端口8000上运行它,以便您可以在您自己的机器上尝试本章中的示例,而不需要点击httpbin.org的公共版本,只需键入以下命令:
$ pip install gunicorn httpbin requests
$ gunicorn httpbin:app
然后,您应该能够用 urllib 和请求获取它的一个页面,看看它们的接口乍一看是如何相似的。
>>> import requests
>>> r = requests.get('http://localhost:8000/headers')
>>> print(r.text)
{
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Host": "localhost:8000",
"User-Agent": "python-requests/2.3.0 CPython/3.4.1 Linux/3.13.0-34-generic"
}
}
>>> from urllib.request import urlopen
>>> import urllib.error
>>> r = urlopen('http://localhost:8000/headers')
>>> print(r.read().decode('ascii'))
{
"headers": {
"Accept-Encoding": "identity",
"Connection": "close",
"Host": "localhost:8000",
"User-Agent": "Python-urllib/3.4"
}
}
已经可以看出两个不同之处,它们是本章内容的良好铺垫。Requests 预先声明它支持 gzip 和 deflate 压缩的 HTTP 响应,而 urllib 对此一无所知。此外,虽然 Requests 已经能够确定将这个 HTTP 响应从原始字节转换为文本的正确解码,但 urllib 库只是返回字节并让您自己执行解码。
在强大的 Python HTTP 客户端方面也有其他尝试,其中许多都致力于变得更像浏览器。他们想超越本章中描述的 HTTP 协议,并引入你将在第十一章中学习的概念,将 HTML 的结构、表单的语义以及当你完成一个表单并点击提交时浏览器应该做的规则结合在一起。例如,图书馆机械化曾流行过一段时间。
然而,最终,网站往往过于复杂,无法与除了完整浏览器之外的任何东西进行交互,因为表单如今之所以有效,只是因为 JavaScript 进行了注释或调整。许多现代表单甚至没有真正的提交按钮,而是激活一个脚本来完成工作。事实证明,控制浏览器的技术比 mechanize 更有用,我会在第十一章中介绍其中一些技术。
本章的目的是让你理解 HTTP,看看它有多少特性是可以通过请求和 urllib 访问的,并帮助你理解当你使用内置于标准库中的 urllib 包时你的操作范围。如果您发现自己无法安装第三方库,而是需要执行高级 HTTP 操作,那么您不仅要查阅 urllib 库自己的文档,还要查阅其他两个资源:它的本周 Python 模块条目和在线深入 Python书籍中关于 HTTP 的章节。
http://pymotw.com/2/urllib2/index.html#module-urllib2
http://www.diveintopython.net/http_web_services/index.html
这些资源都是在 Python 2 时代编写的,因此调用库urllib2而不是urllib.request,但是你应该会发现它们仍然是 urllib 笨拙而过时的面向对象设计的基本指南。
端口、加密和成帧
端口 80 是纯文本 HTTP 对话的标准端口。端口 443 是客户端的标准端口,这些客户端希望首先协商一个加密的 TLS 对话(见第六章),然后只在加密建立后才开始说 HTTP 这是一个名为安全超文本传输协议(HTTPS)的协议变体。在加密通道内,HTTP 的传输方式与正常情况下通过未加密的套接字传输完全相同。
正如您将在第十一章中了解到的,从用户的角度来看,在 HTTP 和 HTTPS 之间以及在标准或非标准端口之间的选择通常是通过他们构建或给出的 URL 来表达的。
请记住,TLS 的目的不仅是防止流量被窃听,而且是验证客户端所连接的服务器的身份(此外,如果提供了客户端证书,则允许服务器验证客户端身份作为回报)。如果 HTTPS 客户端不检查服务器提供的证书是否与客户端尝试连接的主机名匹配,请不要使用该客户端。本章涉及的所有客户端都会执行这样的检查。
在 HTTP 中,是客户端先说话,传输一个命名文档的请求 。一旦整个请求都在网络上,客户机就一直等待,直到从服务器接收到一个完整的响应,该响应或者指出一个错误条件,或者提供有关客户机所请求的文档的信息。至少在目前流行的 HTTP/1.1 版本的协议中,不允许客户机通过同一个套接字发送第二个请求,直到响应完成。
HTTP 中有一个重要的对称:请求和响应使用相同的规则来建立格式和帧。下面是一个请求和响应示例,您可以在阅读下面的协议描述时参考:
GET /ip HTTP/1.1
User-Agent: curl/7.35.0
Host: localhost:8000
Accept: */*
HTTP/1.1 200 OK
Server: gunicorn/19.1.1
Date: Sat, 20 Sep 2014 00:18:00 GMT
Connection: close
Content-Type: application/json
Content-Length: 27
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
{
"origin": "127.0.0.1"
}
请求是以GET开始的文本块。响应从版本HTTP/1.1开始,一直到标题下面的空白行,包括三行 JSON 文本。请求和响应在标准中都称为 HTTP 消息 ,每个消息由三部分组成。
- 第一行命名请求中的方法和文档,并命名响应中的返回代码和描述。该行以回车和换行结束(CR-LF,ASCII 码 13 和 10)。
- 由名称、冒号和值组成的零个或多个标头。标头名称不区分大小写,因此它们可以按照客户机或服务器的要求大写。每个标题都以 CR-LF 结尾。然后一个空行结束整个头列表——四个字节 CR-LF-CR-LF 形成一对行尾序列,中间没有任何内容。无论上面是否出现标题,该空行都是必需的。
- 紧跟在结束标题的空行之后的可选正文。您将很快了解到,构建实体有几种选择。
第一行和头每个都由它们的终端 CR-LF 序列构成,整个程序集由结尾的空白行构成一个单元,因此服务器或客户端可以通过调用recv()直到四字符序列 CR-LF-CR-LF 出现来发现结尾。对于行和标题可能有多长,事先没有提供警告,所以许多服务器对它们的长度设置了常识性的最大值,以避免当麻烦制造者连接并发送无限长的标题时耗尽 RAM。
如果邮件中附加了正文,则有三种不同的正文框架选项。
最常见的成帧是 Content-Length 头的出现,它的值应该是一个十进制整数,以字节为单位给出正文的长度。这很容易实现。客户端可以简单地循环一个重复的recv()调用,直到累积的字节最终等于指定的长度。但是,当动态生成数据时,声明内容长度有时是不可行的,并且直到过程完成时才能知道它的长度。
如果报头指定了“chunked”的传输编码,则激活更复杂的方案它不是预先指定长度,而是以一系列更小的片段交付,每个片段都单独以其长度为前缀。每个块至少包含一个十六进制(与十进制的 Content-Length 头相反!)长度字段、两个字符 CR-LF、精确指定长度的数据块以及两个字符 CR-LF。这些块以最后一个块结束,该块声明它的长度为零——至少是数字零,一个 CR-LF,然后是另一个 CR-LF。
在块长度之后但在 CR-LF 之前,发送者可以插入一个分号,然后指定一个适用于该块的“扩展”选项。最后,在最后一个块给出了它的长度 0 和它的 CR-LF 之后,发送者可以附加一些最后的 HTTP 头。如果您自己正在实现 HTTP,可以参考 RFC 7230 了解这些细节。
Content-Length 的另一个替代方案相当突然:服务器可以指定“Connection: close”, 发送尽可能多或尽可能少的正文,然后关闭 TCP 套接字。这带来了一种危险,即客户端无法判断套接字是因为整个主体被成功传递而关闭,还是因为服务器或网络错误而提前关闭,并且它还通过强制客户端为每个请求重新连接而降低了协议的效率。
(标准规定客户端不能尝试“连接:关闭”技巧,因为这样它就不能接收服务器的响应。难道他们没有听说过套接字上的单向shutdown()的概念吗,它允许客户机结束它的方向,同时仍然能够从服务器读回数据?).
方法
HTTP 请求的第一个字指定了客户端请求服务器执行的操作。有两种常见的方法,GET 和 POST,以及一些为服务器定义的不太常见的方法,这些服务器希望向可能访问它们的其他计算机程序呈现完整的文档 API(通常是它们自己已经交付给浏览器的 JavaScript)。
GET 和 POST 这两个基本方法提供了 HTTP 的基本“读”和“写”操作。
GET 它不能包含正文。该标准坚持认为,在任何情况下,服务器都不能让客户机用这种方法修改数据。任何附加到路径的参数(参见第十一章了解 URL)只能修改被返回的文档,如在?q=python或?results=10中,不能要求在服务器上进行修改。GET 不能修改数据的限制允许客户端在第一次尝试被中断的情况下安全地重新尝试 GET,允许 GET 响应被缓存(您将在本章后面了解缓存),并使 web 抓取程序(参见第十一章)可以安全地访问任意多个 URL,而不必担心它们正在创建或删除它们所遍历的站点上的内容。
POST 当客户端要向服务器提交新数据时使用。传统的 web 表单如果不简单地将表单域复制到 URL 中,通常会使用 POST 来传递您的请求。面向程序员的 API 也使用 POST 来提交新的文档、注释和数据库行。因为运行同一个帖子两次可能会在服务器上执行两次操作,就像给一个商家第二次支付 100 美元,所以帖子的结果既不能被缓存以满足将来重复的帖子,也不能在响应没有到达时自动重试帖子。
剩下的 HTTP 方法可以分为 GET 和 POST 两类。
像 GET 这样的方法有 OPTIONS 和 HEAD。选项方法这使得客户端可以检查内容类型等内容,而不会产生下载主体的成本。
像 POST 这样的操作是 PUT 和 DELETE,因为它们被期望对服务器存储的内容执行可能是不可逆的更改。正如您从它们的名字中所料, PUT 意在传递一个新文档,该文档从此将存在于请求指定的路径中,而 DELETE 要求服务器销毁该路径以及与之相关的任何内容。有趣的是,这两种方法——在请求“写入”服务器内容的同时——在某种程度上是安全的,而 POST 却不是:它们是等幂的,并且可以根据客户端的需要重试任意多次,因为运行其中任何一种方法一次的效果应该与运行多次的效果相同。
最后,该标准指定了一个调试方法 TRACE 和一个方法 CONNECT ,用于将协议切换到 HTTP 之外的东西(正如你将在第十一章中看到的,它用于打开 WebSockets)。然而,它们很少被使用,而且在任何情况下,它们都与作为 HTTP 核心职责的文档传递无关,而这正是你在本章中学到的。有关它们的更多信息,请参考标准。
注意,标准库的urlopen()的一个怪癖是它不可见地选择了它的 HTTP 动词:如果调用者指定了数据参数,则选择 POST,否则选择 GET。这是一个不幸的选择,因为 HTTP 动词的正确使用对于安全的客户端和服务器设计至关重要。对于这些本质上不同的方法来说,get()和post()的请求选择要好得多。
路径和主机
HTTP 的第一个版本允许请求只包含动词和路径。
GET /html/rfc7230
在早期,当每台服务器只托管一个网站时,这种方法工作得很好,但是当管理员希望能够部署大型 HTTP 服务器来服务几十个或几百个网站时,这种方法就失效了。仅给定一个路径,服务器如何猜测用户在 URL 中输入了哪个主机名——尤其是对于像/这样通常存在于每个网站上的路径?
解决方案是使至少一个报头(主机报头)成为强制性的。协议的现代版本还在最低限度正确的请求中包括协议版本,其内容如下:
GET /html/rfc7230 HTTP/1.1
Host: tools.ietf.org
许多 HTTP 服务器会发出一个客户端错误信号,除非客户端至少提供一个主机头来显示 URL 中使用了哪个主机名。如果没有,结果通常是 400 个坏请求。有关错误代码及其含义的更多信息,请参见下一节。
状态代码
响应行以协议版本开始,而不是像请求行那样以协议版本结束,然后它提供一个标准的状态代码,最后是一个非正式的状态文本描述,以呈现给用户或记录在日志文件中。当一切进展顺利时,状态代码为 200,在这种情况下,响应行通常如下所示:
HTTP/1.1 200 OK
因为代码后面的文本只是非正式的,所以服务器可以用 Okay 或 Yippee 替换 OK,或者用服务器运行所在国家的国际化文本替换 OK。
该标准——特别是 RFC 7231——为一般和特殊情况指定了二十多个返回代码。如果你需要了解完整的列表,你可以参考标准。一般来说,200 表示成功,300 表示重定向,400 表示客户端请求是不可理解或非法的,500 表示出现了完全是服务器错误的意外情况。
在这一章中,只有少数几个与你有关。
- 200 OK :请求成功。如果一个帖子,它有其预期的效果。
- 301 永久移动 : 路径虽然有效,但不是所讨论资源的规范路径(尽管它可能是在过去的某个时间点),客户端应该请求响应的 Location 头中指定的 URL。如果客户端想要缓存,所有未来的请求都可以跳过这个旧的 URL,直接进入新的 URL。
- 303 See Other : 客户端可以通过对响应的 Location 头中指定的 URL 进行 GET 来了解这个特定的、唯一的请求的结果。但是,以后任何访问此资源的尝试都需要返回到此位置。正如你将在第十一章中看到的,这个状态对于网站的设计至关重要——任何用 POST 成功提交的表单都应该返回 303,这样客户看到的实际页面就可以用一个安全的、幂等的 GET 操作取而代之。
- 304 未修改 : 文档正文不需要包含在响应中,因为请求头清楚地表明客户机的缓存中已经有了文档的最新版本(参见“缓存和验证”一节)。
- 307 临时重定向 : 无论客户端发出什么请求,无论是 GET 还是 POST,都应该针对响应的 Location 头中指定的不同 URL 再次尝试。但是将来任何访问该资源的尝试都需要返回到该位置。在其他事情中,这允许在服务器停机或不可用的情况下将表单传送到备用地址。
- 400 错误请求 : 该请求似乎不是有效的 HTTP。
- 403 禁止的:请求中没有密码或 cookie(两者都有,见本章后面)或其他识别数据向服务器证明客户机有权限访问它。
- 找不到 404:路径没有命名现有的资源。这可能是最著名的异常代码,因为用户永远不会看到屏幕上显示的 200 代码;他们看到的是一份文件。
- 405 不允许的方法 : 服务器识别方法和路径,但是这个特定的方法在针对这个特定的路径运行时没有意义。
- 500 服务器错误 : 又一个熟悉的状态。服务器想要完成请求,但是由于一些内部错误,现在无法完成。
- 501 未实现 : 服务器不识别你的 HTTP 动词。
- 502 错误网关 : 服务器是网关或代理(见第十章),但是它不能联系它后面的服务器,该服务器应该为该路径提供响应。
虽然具有 3 个 xx 状态代码的响应不被期望携带主体,但是 4 个 xx 和 5 个 xx 响应通常都携带主体——通常提供某种人类可读的错误描述。信息较少的例子通常是编写 web 服务器的语言或框架的未修改的错误页面。服务器作者经常手工制作更多信息的页面,以帮助用户或开发人员知道如何从错误中恢复。
当您正在学习一个特定的 Python HTTP 客户端时,有两个关于状态代码的重要问题要问。
第一个问题是一个库是否自动遵循重定向。如果没有,你必须自己检测 3 个 xx 状态码并跟踪它们的位置头。虽然内置于标准库中的低级 httplib 模块会让您自己跟踪重定向,但 urllib 模块会按照标准为您跟踪重定向。Requests 库也做同样的事情,它还为您提供了一个历史属性,列出了将您带到最终位置的一系列重定向。
>>> r = urlopen('http://httpbin.org/status/301')
>>> r.status, r.url
(200, 'http://httpbin.org/get')
>>> r = requests.get('http://httpbin.org/status/301')
>>> (r.status, r.url)
(200, 'http://httpbin.org/get')
>>> r.history
[<Response [301]>, <Response [302]>]
如果您愿意,Requests 库还允许您使用一个简单的关键字参数来关闭重定向——这是一个可行的策略,但是如果使用 urllib 的话会困难得多。
>>> r = requests.get('http://httpbin.org/status/301',
... allow_redirects=False)
>>> r.raise_for_status()
>>> (r.status_code, r.url, r.headers['Location'])
(301, 'http://localhost:8000/status/301', '/redirect/1')
如果您的 Python 程序花时间检测 301 错误并试图在将来避免这些 URL,将会减少您查询的服务器上的负载。如果你的程序保持一个持久的状态,那么它可能能够缓存 301 错误以避免重新访问这些路径,或者直接重写 URL。如果用户以交互方式请求 URL,那么您可以打印一条有用的消息,通知他们页面的新位置。
两个最常见的重定向涉及前缀www是否属于您用来联系服务器的主机名的前面。
>>> r = requests.get('http://google.com/')
>>> r.url
'http://www.google.com/'
>>> r = requests.get('http://www.twitter.com/')
>>> r.url
'https://twitter.com/'
在这个问题上,两个受欢迎的网站对前缀是否应该成为他们官方主机名的一部分采取了相反的立场。然而,在这两种情况下,他们都愿意使用重定向来加强他们的偏好,同时也防止他们的网站出现混乱,出现在两个不同的 URL 上。除非您的应用小心地学习这些重定向并避免重复它们,否则如果您的 URL 是从错误的主机名构建的,您将最终对您获取的每个资源执行两个 HTTP 请求,而不是一个。
关于您的 HTTP 客户端,需要研究的另一个问题是,如果获取 URL 的尝试失败,并显示 4 xx 或 5 xx 状态代码,它会选择如何提醒您。对于所有这样的代码,标准库urlopen()会引发一个异常,使得你的代码不可能意外地处理一个从服务器返回的错误页面,就像它是正常数据一样。
>>> urlopen('http://localhost:8000/status/500')
Traceback (most recent call last):
...
urllib.error.HTTPError: HTTP Error 500: INTERNAL SERVER ERROR
如果urlopen() 用一个异常打断了你,你如何检查响应的细节呢?答案是通过检查 exception 对象,它执行双重任务,既是一个异常,又是一个带有头和体的响应对象。
>>> try:
... urlopen('http://localhost:8000/status/500')
... except urllib.error.HTTPError as e:
... print(e.status, repr(e.headers['Content-Type']))
500 'text/html; charset=utf-8'
请求库呈现的情况更令人惊讶——即使是错误状态代码也会导致将响应对象不带注释地返回给调用者。调用者负责测试响应的状态代码,或者自愿调用它的raise_for_status()方法 ,这将触发 4 xx 或 5 xx 状态代码的异常。
>>> r = requests.get('http://localhost:8000/status/500')
>>> r.status_code
500
>>> r.raise_for_status()
Traceback (most recent call last):
...
requests.exceptions.HTTPError: 500 Server Error: INTERNAL SERVER ERROR
如果您担心每次调用requests.get时都必须记住执行状态检查,那么您可以考虑编写自己的包装函数来自动执行检查。
缓存和验证
HTTP 包括几个设计良好的机制,让客户机避免重复获取它们经常使用的资源,但它们只有在服务器选择向允许它们的资源添加头时才起作用。对于服务器作者来说,考虑缓存并尽可能地允许缓存是很重要的,因为它减少了网络流量和服务器负载,同时也让客户端应用运行得更快。
RFCs 7231 和 7232 详尽地描述了所有这些机制。本节仅试图提供一个基本的介绍。
当服务架构师想要添加头来打开缓存时,他们可以问的最重要的问题是,两个请求是否真的应该仅仅因为它们的路径相同而返回相同的文档。关于一对请求,有没有其他的事情会导致它们需要返回两个不同的资源?如果是这样,那么服务需要在每个响应中包含一个 Vary header ,列出文档内容所依赖的其他头。常见的选择有Host、Accept-Encoding,如果设计者向不同的用户返回不同的文档,尤其是Cookie。
一旦 Vary 头设置正确,就可以激活不同级别的缓存。
可以禁止将资源存储在客户端缓存中,这将禁止客户端在非易失性存储上对响应进行任何类型的自动复制。目的是让用户控制他们是否选择“保存”来将资源的副本存档到磁盘。
HTTP/1.1 200 OK
Cache-control: no-store
...
如果服务器选择允许缓存,那么它通常会希望防止这样的可能性,即每次用户请求资源的缓存副本时,客户端可能会一直显示它,直到它变得完全过时。服务器不需要担心资源是否会被永久缓存的一种情况是,它很小心地将给定的路径仅用于文档或图像的一个永久版本。例如,如果每次设计者设计出新版本的公司徽标时,URL 末尾的版本号或散列值都会增加或改变,那么任何给定版本的徽标都可以交付,并允许永久保存。
服务器有两种方法可以防止资源的客户端副本被永久使用。首先,它可以指定一个截止日期和时间,在此之后,如果没有返回给服务器的请求,资源就不能被重用。
HTTP/1.1 200 OK
Expires: Thu, 01 Dec 1994 16:00:00 GMT
...
但是使用日期和时间会带来一种危险,即不正确设置的客户机时钟会导致资源的缓存副本被使用太长时间。一个好得多的方法是指定资源一旦被接收就可以被缓存的秒数的现代机制,只要客户机时钟不是简单地停止,这种机制就可以工作。
HTTP/1.1 200 OK
Cache-control: max-age=3600
...
此处显示的两个标头授予客户端在有限的时间内继续使用资源的旧副本的单方面能力,而无需与服务器进行任何协商。
但是,如果服务器希望保留对是否使用缓存资源或获取新版本的否决权,该怎么办呢?在这种情况下,它将不得不要求客户端在每次想要使用资源时使用 HTTP 请求进行检查。这将比让客户端静默地使用缓存的副本并且不进行网络操作更昂贵,但是它仍然可以节省时间,因为如果客户端拥有的唯一旧副本确实是过时的,则服务器将不得不发送资源的新副本。
有两种机制,通过这两种机制,服务器可以让客户端检查资源的每次使用,但如果可能的话,让客户端重用其缓存的资源副本。这些在标准中被称为条件请求,因为只有当测试显示客户端缓存过期时,它们才会导致主体的传输。
第一种机制要求服务器知道资源最后被修改的时间。这可以很容易地确定资源是否由文件系统上的某个文件支持,但是很难或者不可能确定资源是否是从没有审计日志或者最后修改日期的数据库表中提取的。如果信息可用,服务器可以将它包含在每个响应中。
HTTP/1.1 200 OK
Last-Modified: Tue, 15 Nov 1994 12:45:26 GMT
...
想要重用资源的缓存副本的客户机也可以缓存这个日期,然后在下次需要使用资源时将它重复发送给服务器。如果服务器发现自从客户机最后一次接收到资源以来,该资源没有被修改,那么服务器可以通过简单地发送报头和特殊状态码 304 来选择不发送主体。
GET / HTTP/1.1
If-Modified-Since: Tue, 15 Nov 1994 12:45:26 GMT
...
HTTP/1.1 304 Not Modified
...
第二种机制处理资源标识,而不是修改时间。在这种情况下,服务器需要某种方法来为资源的每个版本创建一个唯一的标记,该标记保证在每次资源更改时都会更改为一个新的唯一值——校验和或数据库 UUIDs 是此类信息的可能来源。服务器无论何时构建回复,都需要在 e tag 头中传递标签。
HTTP/1.1 200 OK
ETag: "d41d8cd98f00b204e9800998ecf8427e"
...
已经缓存并拥有该版本资源的客户端,当它想要再次重用该副本以满足用户动作时,可以向服务器请求该资源,并在它仍然命名该资源的当前版本的情况下包括缓存的标签。
GET / HTTP/1.1
If-None-Match: "d41d8cd98f00b204e9800998ecf8427e"
...
HTTP/1.1 304 Not Modified
...
ETag 和 If-None-Match 中使用的引号反映了这样一个事实,即该方案实际上可以进行更强大的比较,而不仅仅是比较两个字符串是否相等。如果您想了解细节,请参考 RFC 7232 第 3.2 节。
再次注意,If-Modified-Since 和 If-None-Match 都仅通过防止再次传输资源来节省带宽,从而也节省了传输所花费的时间。在客户端可以继续使用资源之前,它们仍然至少会产生到服务器的往返行程。
缓存功能强大,对现代 Web 的性能至关重要。然而,默认情况下,您所看到的 Python 客户端库都不会执行缓存。urllib 和 Requests 都认为他们的工作是在需要的时候执行一个真正的实时网络 HTTP 请求,而不是管理一个缓存,这个缓存可能会使您从一开始就不需要通过网络进行对话。如果您想要一个包装器,当指向您可以提供的某种形式的本地持久存储时,它使用 Expires 和 Cache-control 头、修改日期和 ETags 来尝试最小化您的客户机引起的延迟和网络流量,那么您必须寻找第三方库。
如果你正在配置或运行一个代理,缓存也是很重要的,这个话题我将在第十章中讨论。
内容编码
理解 HTTP 传输编码和内容编码之间的区别至关重要。
传输编码只是一种将资源转换成 HTTP 响应体的方案。根据定义,传输编码的选择最终没有区别。例如,无论响应是用内容长度编码还是分块编码组织的,客户端都应该发现已经传递了相同的文档或图像。为了加快传输速度,无论字节是原始发送还是压缩发送,资源看起来都应该是一样的。传输编码只是一个用于数据传递的包装,而不是底层数据本身的变化。
尽管现代 web 浏览器支持几种传输编码,但最受程序员欢迎的可能是 gzip。能够接受这种传输编码的客户端必须在 Accept-Encoding 头中声明,并准备好检查响应的传输编码头,以确定服务器是否接受了它的提议。
GET / HTTP/1.1
Accept-Encoding: gzip
...
HTTP/1.1 200 OK
Content-Length: 3913
Transfer-Encoding: gzip
...
urllib 库不支持这种机制,所以它需要您自己的代码来生成和检测这些头,然后如果您想利用压缩的传输编码,就自己解压缩响应体。
Requests 库自动声明一个 Accept-Encodinggzip,deflate,如果服务器响应一个适当的 Transfer-Encoding,它会自动解压缩主体。这使得压缩在服务器支持时是自动的,并且对请求的用户是不可见的。
内容协商
内容类型和内容编码,与传输编码相反,对于执行 HTTP 请求的最终用户或客户端程序是完全可见的。它们决定了选择何种文件格式来表示给定的资源,以及如果格式是文本,将使用何种编码来将文本代码点转换成字节。
这些标题允许不能显示新的 PNG 图像的旧浏览器表明它更喜欢 GIF 和 JPG,并且它们允许以用户已经向他们的 web 浏览器表明他们更喜欢的语言来交付资源。以下是由现代 web 浏览器生成的此类标题的示例:
GET / HTTP/1.1
Accept: text/html;q=0.9,text/plain,image/jpg,*/*;q=0.8
Accept-Charset: unicode-1-1;q=0.8
Accept-Language: en-US,en;q=0.8,ru;q=0.6
User-Agent: Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML)
...
首先列出的类型和语言具有最强的首选值 1.0,而在标题中后面列出的类型和语言通常被降级为 q=0.9 或 q=0.8,以确保服务器知道它们不是优于最佳选择的首选。
许多简单的 HTTP 服务和站点完全忽略这些头,而是为它们拥有的资源的每个版本使用单独的 URL。例如,如果一个网站同时支持英语和法语,它的首页可能会有两个版本/en/index.html和/fr/index.html。相同的公司徽标可能位于路径/logo.png和/logo.gif的两个位置,并且当用户浏览公司的新闻包时,可能会同时提供给用户下载。RESTful web 服务的文档(参见第十章)通常会指定不同的 URL 查询参数,如?f=json和?f=xml,用于选择返回的表示。
但是这并不是 HTTP 设计的工作方式。
HTTP 的意图是资源应该有一个路径,不管有多少不同的机器格式或人类语言可以用来呈现它,并且服务器使用这些内容协商头来选择资源。
为什么内容协商经常被忽视?
首先,使用内容协商会使用户无法控制他们的用户体验。再想象一个同时提供英语和法语页面的网站。如果它根据 Accept-Language 标题显示一种语言,而用户希望看到另一种语言,那么服务器无法控制这种情况——它必须建议用户打开 web 浏览器的控制面板,更改他们的默认语言。如果用户找不到该设置怎么办?如果他们从公共终端浏览,并且没有权限设置首选项,该怎么办?
许多网站不是将语言选择的控制权交给一个可能写得不好、不连贯或不容易配置的浏览器,而是简单地构建几个冗余的路径集,每个路径集对应一种他们想要支持的人类语言。当用户第一次到达时,他们可能会检查 Accept-Language 标头,以便自动将浏览器定向到最可能合适的语言。但是如果选择不合适,他们希望用户能够从另一个方向返回浏览。
其次,内容协商经常被忽略(或者与基于 URL 的机制一起强制返回正确版本的内容),因为 HTTP 客户端 API(无论该 API 是由浏览器中的 JavaScript 使用,还是由其他语言在自己的运行时提供)通常很难控制 Accepts 头。将控制元素放入 URL 内部的路径中令人愉快的一点是,任何使用最原始的工具来获取 URL 的人都可以通过调整 URL 来旋转旋钮。
最后,内容协商意味着 HTTP 服务器必须通过在几个轴之间做出选择来生成或选择内容。您可能会认为服务器逻辑总是可以访问 Accepts 头,但事实并非总是如此。如果不考虑内容协商,服务器端的编程通常会更容易。
但是对于想要支持它的复杂服务来说,内容协商可以帮助减少 URL 的可能空间,同时仍然提供一种机制,通过这种机制,智能 HTTP 客户端可以获得已经按照其数据格式或人类读者的需求呈现的内容。如果您打算使用它,请查阅 RFC 7231,了解各种接受头语法的详细信息。
最后一个麻烦是用户代理字符串。
用户代理根本不应该成为内容协商的一部分,而只是作为一个应急的权宜之计,以解决特定浏览器的局限性。换句话说,它是一种针对特定客户端的精心设计的修复机制,同时允许任何其他客户端毫无问题地访问页面。
但是由客户呼叫中心支持的应用的开发者很快发现,他们可以通过禁止除了单一版本的 Internet Explorer 之外的任何浏览器访问他们的网站来消除兼容性问题,并减少预先支持电话的数量。客户端和浏览器之间的军备竞赛导致了你今天所拥有的非常长的用户代理字符串,正如在http://webaim.org/blog/user-agent-string-history/中所描述的那样。
您正在探索的两个客户端库 urllib 和 Requests 都允许您将任何 Accept 头放入请求中。它们还都支持自动使用您喜欢的标题创建客户端的模式。Requests 将这个特性构建到了它的Session概念中。
>>> s = requests.Session()
>>> s.headers.update({'Accept-Language': 'en-US,en;q=0.8'})
所有对类似于s.get()的方法的后续调用都将使用这个缺省的头值,除非它们用一个不同的值覆盖它。
urllib 库提供了自己的模式来设置可以注入默认头的默认处理程序,但是,由于它们错综复杂,而且是面向对象的,我建议您参考文档。
内容类型
一旦服务器检查了来自客户端的各种 Accepts 头,并决定传递哪种资源表示,它就相应地设置传出响应的 Content-Type 头。
内容类型是从已经为作为电子邮件消息的一部分传输的多媒体建立的各种 MIME 类型中选择的(参见第十二章)。类型text/plain和text/html以及图像格式image/gif、image/jpg和image/png都很常见。文件可以按类型发送,包括application/pdf。一个简单的字节序列被赋予了application/octet-stream的内容类型,对于这个序列,服务器不能保证没有更具体的解释。
在处理通过 HTTP 传递的内容类型头时,有一个复杂的问题需要注意。如果主要类型(斜线左边的单词)是text,那么服务器有许多关于如何编码这些文本字符以传输到客户机的选项。它通过在 Content-Type 头后面附加一个分号和一个用于将文本转换成字节的字符编码声明来声明它的选择。
Content-Type: text/html; charset=utf-8
这意味着,如果不首先检查分号字符并将其分成两部分,就不能简单地将 Content-Type 头与 MIME 类型列表进行比较。大多数图书馆在这里不会给你任何帮助。无论您使用 urllib 还是使用 Requests,如果您编写需要检查内容类型的代码,您都必须负责在分号上进行拆分(尽管如果您向其Response对象请求已经解码的text属性,请求至少会使用内容类型的 charset 设置,如果不告诉您的话)。
本书中唯一允许内容类型和字符集分别操作的库是 Ian Bicking 的 WebOb 库 ( 第十章),它的Response对象提供了单独的属性content_type和charset,这些属性按照标准用分号放在内容类型头中。
HTTP 认证
正如单词 authentic 表示真实的、真实的、实际的或真实的东西一样,认证 描述了确定请求是否真的来自被授权的人的任何程序。正如您与银行或航空公司的电话交谈会以关于您的地址和个人身份的问题为前缀,以确定这确实是帐户持有人打来的电话一样,HTTP 请求通常也需要携带关于发出请求的机器或人员的身份的内置证明。
未授权的错误代码 401 由服务器使用,这些服务器希望通过协议本身正式发出信号,表明它们无法验证您的身份,或者身份是正确的,但无权查看此特定资源。
许多真实世界的 HTTP 服务器实际上从来不会返回 401,因为它们纯粹是为人类用户设计的。在这些服务器上,试图在没有正确标识的情况下获取资源很可能会将 303 See Other 返回到他们的登录页面。这对人来说很有帮助,但对 Python 程序来说就没那么有帮助了,因为 Python 程序必须学会区分 303 See Other(真正表明身份验证失败)和无害的重定向(实际上只是试图将您带到资源)。
因为每个 HTTP 请求都是独立的,并且独立于所有其他请求,甚至是在同一套接字上紧随其后的请求,所以任何认证信息都必须在每个请求中单独携带。这种独立性使得代理服务器和负载平衡器可以安全地在任意多的服务器之间分发 HTTP 请求,甚至是通过同一个套接字到达的请求。
您可以阅读 RFC 7235 来了解最新的 HTTP 认证机制。早期的最初步骤并不令人鼓舞。
第一种机制,基本认证(或“基本认证”),包括服务器在其 401 未授权头中包含一个称为领域 的字符串。领域字符串允许单个服务器使用不同的密码保护其文档树的不同部分,因为浏览器可以跟上哪个用户密码与哪个领域相匹配。然后,客户机用一个授权头重复它的请求,这个授权头给出了用户名和密码(base-64 编码,好像这样会有帮助),理想情况下,它会得到一个 200 的回复。
GET / HTTP/1.1
...
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="engineering team"
...
GET / HTTP/1.1
Authorization: Basic YnJhbmRvbjphdGlnZG5nbmF0d3dhbA==
...
HTTP/1.1 200 OK
...
明文传递用户名和密码在今天听起来是不合理的,但在那个更早、更天真的时代,还没有无线网络,交换设备往往是固态的,而不是运行可能被入侵的软件。随着协议设计者开始考虑这些危险,一个更新的“摘要访问认证”方案被创建,其中服务器发出一个挑战,而客户端用挑战加密码的 MD5 散列来代替。但结果仍然是一场灾难。即使使用了摘要式身份验证,您的用户名仍然清晰可见。所有提交的表单数据和从网站返回的所有资源都清晰可见。然后,一个足够有野心的攻击者可以发起中间人攻击,这样,你就认为他们是服务器,签署了他们自己刚刚从服务器收到的挑战,他们可以利用这个挑战来冒充你。
如果银行想显示你的余额,如果亚马逊想让你输入信用卡信息,网站就需要真正的安全性。因此,SSL 被发明出来创造了 HTTPS,随后是你今天喜欢的各种版本的 TLS,详见第六章。
TLS 的加入意味着,原则上,基本 Auth 不再有任何问题。许多简单的受 HTTPS 保护的 API 和 web 应用现在都在使用它。只有当您构建一系列要安装在 URL 打开器中的对象时,urllib 才支持它(有关详细信息,请参阅文档),而 Requests 支持带有单个关键字参数的基本 Auth。
>>> r = requests.get('http://example.com/api/',
... auth=('brandon', 'atigdngnatwwal'))
您还可以准备一个请求Session进行认证,以避免自己对每个get()或post()重复请求。
>>> s = requests.Session()
>>> s.auth = 'brandon', 'atigdngnatwwal'
>>> s.get('http://httpbin.org/basic-auth/brandon/atigdngnatwwal')
<Response [200]>
请注意,这种由 Requests 或其他现代库实现的机制并不是成熟的协议!先前指定的用户名和密码没有绑定到任何特定领域。没有 401 响应甚至可以提供一个领域,因为用户名和密码是随请求单方面提供的,没有首先检查服务器是否需要它们。auth关键字参数,或者等效的Session设置,仅仅是一种设置授权头的方法,而不必自己进行任何 base-64 编码。
现代开发人员更喜欢这种简单性,而不是完全基于领域的协议。通常,他们的唯一目标是对面向程序员的 API 的 GET 或 POST 请求进行独立的身份验证,以确定发出请求的用户或应用的身份。单边授权头非常适合这种情况。它还有另一个优点:当客户已经有充分的理由相信将需要密码时,获得初始 401 不会浪费时间和带宽。
如果您最终与一个真正的遗留系统对话,该系统需要您在同一台服务器上对不同的领域使用不同的密码,那么 Requests 对您没有任何帮助。这将取决于你使用正确的密码和正确的网址。这是一个 urllib 能够做正确的事情而 Requests 不能的罕见领域!但是我从来没有听到过对请求中这一缺点的抱怨,这表明真正的基本授权协商已经变得多么罕见。
饼干
如今,以 HTTP 为媒介的认证已经很少见了。最终,对于 HTTP 资源来说,这是一个失败的提议,因为 HTTP 资源是为使用 web 浏览器的人设计的。
HTTP 认证和用户有什么问题?网站设计者通常希望以自己的方式执行自己的身份验证。他们想要一个自定义的友好的登录页面,遵循他们自己的用户交互准则。当被要求进行协议内 HTTP 认证时,web 浏览器提供的可怜的小弹出窗口是侵入性的。即使在最好的情况下,它们的信息量也不是很大。他们让用户完全脱离了网站的体验。此外,如果没有输入正确的用户名和密码,可能会导致弹出窗口反复出现,而用户不知道发生了什么问题,也不知道如何纠正。
于是饼干被发明了。
从客户端的角度来看, cookie 是一个不透明的键值对。它可以在客户端从服务器收到的任何成功响应中传递。
GET /login HTTP/1.1
...
HTTP/1.1 200 OK
Set-Cookie: session-id=d41d8cd98f00b204e9800998ecf8427e; Path=/
...
当向该特定服务器发出所有进一步的请求时,客户端会将该名称和值包含在 Cookie 头中。
GET /login HTTP/1.1
Cookie: session-id=d41d8cd98f00b204e9800998ecf8427e
...
这使得站点生成登录页面成为可能。当使用无效凭证提交登录表单时,服务器可以根据需要再次显示尽可能多的有用提示或支持链接,所有样式都与站点的其余部分完全一样。一旦表单被正确提交,它就可以授予客户端一个特制的 cookie,以便在所有后续请求中使站点确信用户的身份。
更微妙的是,一个不是真正的 web 表单而是使用 Ajax 停留在同一页面上的登录页面(见第十一章)仍然可以享受 cookies 的好处,如果 API 驻留在相同的主机名上的话。当执行登录的 API 调用确认用户名和密码并返回 200 OK 和一个 cookie 头时,它就授权了对同一站点的所有后续请求——不仅仅是 API 调用,还有对页面、图像和数据的请求——提供 Cookie 并被识别为来自一个经过身份验证的用户。
注意,cookies 应该设计成不透明的。它们应该是随机的 UUID 字符串,引导服务器找到给出真实用户名的数据库记录,或者是加密的字符串,只有服务器可以解密以了解用户身份。如果它们是用户可解析的——例如,如果一个 cookie 有值THIS-USER-IS-brandon——那么一个聪明的用户可以编辑 cookie 产生一个伪造的值,并在下一次请求时提交它,以冒充他们知道或能够猜出其用户名的其他用户。
真实世界的 Set-Cookie 头可能比给出的例子复杂得多,如 RFC 6265 中详细描述的那样。我应该提一下secure属性。它指示 HTTP 客户端在向站点发出未加密的请求时不要显示 cookie。如果没有这个属性,cookie 可能会被暴露,允许与用户共享咖啡店 wi-fi 的任何其他人了解 cookie 的值,并使用它来冒充用户。有些网站给你一个 cookie 只是为了访问。当你在网站上走动时,他们可以跟踪你的访问。收集到的历史记录已经可以在你浏览时用来定向投放广告,如果你以后用用户名登录,还可以复制到你的永久帐户历史记录中。
如果没有 cookies 跟踪您的身份并证明您已经过身份验证,许多用户导向的 HTTP 服务将无法运行。用 urllib 跟踪 cookies 需要面向对象;请阅读它的文档。如果您创建并持续使用一个Session对象,跟踪请求中的 cookies 会自动发生。
连接、保持活动和 httplib
如果连接已经打开,则可以避免启动 TCP 连接的三次握手(见第三章第一部分),这甚至在早期为 HTTP 提供了动力,使连接在浏览器下载 HTTP 资源、JavaScript、CSS 和图像时保持打开。随着 TLS(见第六章)作为所有 HTTP 连接的最佳实践的出现,建立新连接的成本甚至更高,增加了连接重用的好处。
协议版本 HTTP/1.1 将 HTTP 连接设置为在收到请求后保持打开状态。如果客户机或服务器计划在请求完成后挂断,它们可以指定Connection: close,否则可以重复使用单个 TCP 连接从服务器获取客户机想要的资源。Web 浏览器通常为每个站点同时创建四个或更多的 TCP 连接,以便可以并行下载一个页面及其所有支持文件和图像,从而尽可能快地将它们呈现在用户面前。
如果您是对细节感兴趣的实现者,应该参考 RFC 7230 的第六部分来了解完整的连接控制方案。
不幸的是,urllib 模块没有提供连接重用。只有使用较低级别的 httplib 模块,才能通过标准库在同一个套接字上发出两个请求。
>>> import http.client
>>> h = http.client.HTTPConnection('localhost:8000')
>>> h.request('GET', '/ip')
>>> r = h.getresponse()
>>> r.status
200
>>> h.request('GET', '/user-agent')
>>> r = h.getresponse()
>>> r.status
200
请注意,被挂起的HTTPConnection对象不会返回错误,但是当您要求它执行另一个请求时,它会悄悄地创建一个新的 TCP 连接来替换旧的连接。HTTPSConnection类提供了同一对象的 TLS 保护版本。
相比之下,Requests library Session对象由一个名为 urllib3 的第三方包支持,该包将维护一个到 HTTP 服务器的开放连接的连接池,您最近与这些服务器进行了通信,因此当您从同一站点向它请求另一个资源时,它可以尝试自动重用它们。
摘要
HTTP 协议用于根据资源的主机名和路径获取资源。标准库中的 urllib 客户端可以在简单的情况下工作,但它功能不足,并且缺乏请求的功能,这是 Python 库的一种互联网感觉,是希望从 Web 获取信息的程序员的首选工具。
HTTP 在端口 80 上明文运行,受端口 443 上 TLS 的保护,它在网络上对客户机请求和服务器响应使用相同的基本布局:一行信息,后跟名称-值头,最后是一个空行,然后是可以用几种不同方式编码和定界的正文(可选)。客户端总是先说话,发送请求,然后等待服务器完成响应。
最常见的 HTTP 方法是 GET 和 POST,前者用于获取资源,后者用于向服务器发送更新的信息。还存在其他几种方法,但是它们要么类似 GET,要么类似 POST。服务器为每个响应返回一个状态代码,指示请求是成功了还是失败了,或者客户端是否需要被重定向以加载另一个资源才能完成。
HTTP 内置了几个同心设计层。缓存头可能允许资源被缓存并在客户机上重复使用,而不会被再次提取,或者头可能让服务器跳过重新传送未更改的资源。这两种优化对于繁忙网站的性能都至关重要。
内容协商有希望使数据格式和人类语言符合客户和使用它的人的确切偏好,但它在实践中遇到了问题,使它没有得到普遍应用。内置的 HTTP 身份验证对于交互式使用来说是一个糟糕的设计,已经被定制的登录页面和 cookies 所取代,但是基本的身份验证有时仍然用于对 TLS 安全 API 的请求进行身份验证。
默认情况下,HTTP/1.1 连接可以继续存在并被重用,请求库尽可能小心地这样做。
在下一章中,你将带着你在这里学到的所有知识,反过来看,你将从编写服务器的角度来看待编程的任务。
十、HTTP 服务器
Python 程序如何作为响应 HTTP 请求的服务器运行?在第七章中,你学习了几种编写基于 TCP 的网络服务器的基本套接字和并发模式。使用 HTTP,您不太可能需要编写那么低级的东西,因为该协议的流行已经为您可能需要的所有主要服务器模式提供了现成的解决方案。
虽然本章将关注第三方工具,但标准库确实有一个内置的 HTTP 服务器实现。它甚至可以从命令行调用。
$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 ...
该服务器遵循 20 世纪 90 年代建立的从文件系统提供文件的旧惯例。HTTP 请求中的路径被转换成在本地文件系统中搜索的路径。服务器被设计为仅在其当前工作目录下提供文件。文件正常送达。当命名一个目录时,服务器要么返回其index.html文件的内容(如果存在的话),要么返回其中文件的动态生成列表。
这些年来,当我需要在机器之间传输文件并且没有更具体的文件传输协议可用时,在安装 Python 的任何地方都有一个可用的小型 web 服务器使我摆脱了不止一次的尴尬。但是,如果您需要更多的东西——如果您需要让自己的软件负责响应 HTTP 请求,该采取什么步骤呢?
这本书用两个独立的章节来解决这个问题。本章将着眼于服务器架构和部署,回答需要解决的问题,无论您的代码是返回文档还是面向程序员的 API。第十一章将描述万维网,它将研究返回 HTML 页面和与用户浏览器交互的工具。
WSGI(消歧义)
在 HTTP 编程的早期,许多 Python 服务被写成简单的 CGI 脚本,每个传入请求调用一次。服务器将 HTTP 请求分成几部分,并在其环境变量中提供给 CGI 脚本。Python 程序员可以直接检查这些并打印一个 HTTP 响应到标准输出,或者从标准库中的cgi模块获得帮助。
为每个传入的 HTTP 请求启动一个新的进程对服务器性能造成了很大的限制,因此语言运行库开始实现自己的 HTTP 服务器。Python 获得了其http.server标准库模块,该模块邀请程序员通过将do_GET() 和do_POST()方法 添加到他们自己的BaseHTTPRequestHandler子类中来实现他们的服务。
其他程序员希望从 web 服务器提供动态页面,该服务器也可以提供静态内容,如图像和样式表。因此,mod_python被写成了:一个 Apache 模块,它允许正确注册的 Python 函数提供定制的 Apache 处理程序,这些处理程序可以提供认证、日志和内容。这个 API 是 Apache 独有的。用 Python 编写的处理程序接收一个特殊的 Apache request对象作为参数,并可以调用apache模块中的特殊函数来与 web 服务器交互。使用mod_python的应用与那些用 CGI 或http.server编写的程序几乎没有相似之处。
这种情况意味着用 Python 编写的每个 HTTP 应用都倾向于锚定到一个特定的机制上,以便与 web 服务器进行交互。为 CGI 编写的服务至少需要部分重写才能与http.server一起工作,并且两者都需要修改才能在 Apache 下运行。这使得 Python web 服务很难移植到新的平台上。
社区回应了 PEP 333,Web 服务器网关接口(WSGI) 。
正如 David Wheeler 的名言,“计算机科学中的所有问题都可以通过另一个间接层来解决”,WSGI 标准创建了额外的间接层,这是 Python HTTP 服务与任何 web 服务器进行互操作所必需的。它规定了一个调用约定,如果在所有主要的 web 服务器上实现,将允许低级服务和完整的 web 框架插入到他们想要使用的任何 web 服务器中。到处实现 WSGI 的努力很快成功了,它现在是 Python 讲 HTTP 的标准方式。
该标准将 WSGI 应用定义为带有两个参数的可调用程序。 清单 10-1 中显示了一个例子,其中可调用的是一个简单的 Python 函数。(其他可能是 Python 类,它是另一种可调用的类型,或者甚至是带有__call__()方法的类实例。)第一个参数environ接收一个字典,该字典提供了我们熟悉的 CGI 环境变量集的扩展版本。第二个参数本身是可调用的,通常命名为start_response(),WSGI 应用应该用它来声明它的响应头。在被调用后,应用或者可以开始产生字节字符串(如果它本身是一个生成器),或者可以返回一个迭代时产生字节字符串的 iterable(例如,返回一个简单的 Python 列表就足够了)。
清单 10-1 。作为 WSGI 客户端 编写的简单 HTTP 服务
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter10/wsgi_env.py
# A simple HTTP service built directly against the low-level WSGI spec.
from pprint import pformat
from wsgiref.simple_server import make_server
def app(environ, start_response):
headers = {'Content-Type': 'text/plain; charset=utf-8'}
start_response('200 OK', list(headers.items()))
yield 'Here is the WSGI environment:\r\n\r\n'.encode('utf-8')
yield pformat(environ).encode('utf-8')
if __name__ == '__main__':
httpd = make_server('', 8000, app)
host, port = httpd.socket.getsockname()
print('Serving on', host, 'port', port)
httpd.serve_forever()
清单 10-1 可能让 WSGI 看起来简单,但那只是因为清单选择以简单的方式运行,而不是充分利用规范。当实现规范的服务器端时,复杂程度更高,因为在这种情况下,必须为充分利用标准中描述的许多警告和边缘情况的应用准备代码。如果您想了解其中的内容,可以阅读 PEP 3333,它是 WSGI 的现代 Python 3 版本。
在 WSGI 首次亮相之后,WSGI 中间件的想法达到了全盛时期 Python HTTP 服务将来可能会从一系列同心的 WSGI 包装器中设计出来。 一个包装器可能提供认证。另一个可能在返回 500 内部服务器错误页面之前捕获异常并记录下来。还有一种可能是将遗留的 URL 反向代理到一个仍在组织中运行的旧 CMS,并使用 Diazo(一个延续至今的项目)来重新主题化它,以匹配该组织更现代的页面。
尽管仍然有开发人员编写和使用 WSGI 中间件,但今天大多数 Python 程序员使用 WSGI 只是为了它在应用或框架与监听传入 HTTP 请求的 web 服务器之间提供的可插入性。
异步服务器框架
然而,有一种应用模式还没有被 WSGI 革命所触及,那就是异步服务器,它支持协程或绿色线程。
WSGI callable 的设计目标是传统的多线程或多进程服务器,因此该 callable 在需要执行任何 I/O 时都会被阻塞。WSGI 没有提供一种机制,通过这种机制,可调用程序可以将控制权交还给主服务器线程,以便其他可调用程序可以轮流取得进展。(参见第七章中关于异步的讨论,了解异步服务如何将其逻辑分割成小的、非阻塞的代码片段。)
因此,每个异步服务器框架都必须为编写 web 服务提供自己的约定。虽然这些模式在简洁性和便利性方面有所不同,但它们通常负责解析传入的 HTTP 请求,并且它们有时为自动进行 URL 分派和提交数据库连接提供便利(参见第十一章)。
这就是为什么本节的标题包括“服务器框架”在 Python 中探索异步的项目必须在它们特定的引擎上产生一个 HTTP web 服务器,然后发明一个调用约定,通过它它们已经解析的请求信息可以传递给你自己的代码。与 WSGI 生态系统不同,您不能单独选择异步 HTTP 服务器和 web 框架。两者很可能会出现在同一个包中。
Twisted server 支持许多不同的协议处理程序,十多年来一直为编写 web 服务提供自己的约定。最近,脸书开发并开源了其 Tornado 引擎,该引擎不支持许多协议,而是专门关注 HTTP 的性能。它支持一组与 Twisted 不同的回调约定。Eventlet 项目的绿色线程是隐式异步的,而不是在每个 I/O 操作期间显式地将控制权交还,它允许您编写看起来像普通 WSGI 的可调用程序,但当它们试图阻塞操作时,会悄悄地交出控制权。
展望未来,Python 的发明者吉多·范·罗苏姆支持 Python 3.4 (见第七章)中的新asyncio引擎,因为它提供了一个统一的接口,不同的事件循环实现可以通过这个接口插入不同的异步协议框架。虽然这可能有助于统一低级事件循环的多样化世界,但它似乎不会对想要编写异步 HTTP 服务的作者产生任何直接影响,因为它没有指定专门讲述 HTTP 请求和响应语言的 API。
要记住的限制是,如果您计划使用特定的异步引擎(如asyncio或 Tornado 或 Twisted)编写 HTTP 服务,您需要选择 HTTP 服务器和帮助您解析请求和编写响应的框架。您将无法混合搭配服务器和框架。
正向和反向代理
HTTP 代理(无论是正向还是反向)是一个 HTTP 服务器,它接收传入的请求,并且至少对于某些路径来说,返回并成为一个客户端,向它后面的服务器发出 HTTP 请求,最后将该服务器的响应传递回原始客户端。阅读 RFC 7230 第 2.3 节,了解代理的介绍以及 HTTP 的设计如何预测它们的需求:https://tools.ietf.org/html/rfc7230#section-2.3。
早期对网络的描述似乎认为转发代理 将是最常见的代理模式。例如,一个雇主可能会提供一个 HTTP 代理,供其员工的 web 浏览器请求,而不是直接与远程服务器对话。一大早,一百名员工的 web 浏览器首先请求 Google 徽标,这可能会导致代理服务器只向 Google 发送一个请求,然后就可以缓存该请求并用于满足所有后续员工的请求。如果谷歌在过期和缓存控制头上足够慷慨,那么雇主将花费更少的带宽,员工将体验更快的网络。
但是随着 TLS 作为保护用户隐私和凭证的通用最佳实践的出现,转发代理变得不可能。代理不能检查或缓存它不能读取的请求。
另一方面,反向代理现在在大型 HTTP 服务中无处不在。一个反向 代理 作为 web 服务本身的一部分运行,对于 HTTP 客户端是不可见的。当客户认为他们正在连接到python.org时,他们实际上是在和一个反向代理说话。如果核心python.org服务器小心翼翼地包含 Expires 或 Cache-Control 头,代理可以直接从其缓存中为许多资源提供服务,包括静态和动态资源。反向代理通常可以承担运行服务的大部分负载,因为只有当资源不可缓存或已从代理的缓存中过期时,HTTP 请求才需要转发到核心服务器。
反向代理必须执行 TLS 终止,并且它必须是持有其代理的服务的证书和私钥的服务。除非代理可以检查每个传入的 HTTP 请求,否则它不能执行缓存或转发。
如果您采用反向代理的使用,无论是以 Apache 或 nginx 这样的前端 web 服务器的形式,还是以 Varnish 这样的专用守护进程的形式,与缓存相关的头比如 Expires 和 Cache-Control 变得比正常情况下更加重要。它们不再仅仅与最终用户的浏览器相关,而是成为您自己的服务架构的各层之间的重要信号。
反向代理甚至可以帮助处理您可能认为不应该缓存的数据,比如需要精确到秒的标题页面或事件日志,只要您能够容忍结果至少存在几秒钟。毕竟,无论如何,客户端检索一个资源通常需要几分之一秒的时间。如果资源多存在一秒钟,真的会有损失吗?想象一下,在一个每秒接收一百个请求的关键提要或事件日志的缓存控制头中放置一秒钟的最大年龄。您的反向代理将开始工作,并有可能将您的服务器负载降低 100 倍:它只需要在每秒开始时获取一次资源,然后它可以为所有其他请求的客户端重用缓存的结果。
如果您将在一个代理后面设计和部署一个大型的 HTTP 服务,那么您将需要参考 RFC 7234 及其对 HTTP 缓存设计及其预期好处的扩展讨论。您会发现专门针对中间缓存(如 Varnish)的选项和设置,而不是针对最终用户的 HTTP 客户端的选项和设置,如 proxy-revidate 和 s-maxage,当您接近服务设计时,您应该在工具箱中有这些选项和设置。
仔细阅读 RFC 7231 第 7.1.4 节中的
Vary标题描述,以及第九章中的标题描述。值Vary: Cookie通常是确保正确行为所必需的,原因将变得清楚。
四种架构
虽然架构师似乎有能力从更小的部分产生无限数量的复杂方案来组装 HTTP 服务,但是有四种主要的设计已经成为 Python 社区中的习惯(参见图 10-1 )。如果您已经编写了 Python 代码来生成动态内容,并且选择了可以使用 WSGI 的 API 或框架,那么将 HTTP 服务放到网上有什么选择呢?
- 运行一个本身用 Python 编写的服务器,它可以从自己的代码中直接调用您的 WSGI 端点。绿色独角兽(“gunicorn”)服务器 是目前最受欢迎的,但也有其他生产就绪的纯 Python 服务器。例如,久经考验的 CherryPy 服务器至今仍在项目中使用,Flup 仍然吸引着用户。(最好避免使用原型服务器,如
wsgiref,除非您的服务负载较轻,并且位于组织内部。)如果你使用一个异步服务器引擎,那么服务器和框架将必然存在于同一个进程中。 - 运行 Apache,将
mod_wsgi配置为在单独的WSGIDaemonProcess中运行 Python 代码,产生一种混合方法:两种不同的语言在工作,但是在一个服务器中。静态资源可以直接从 Apache 的 C 语言引擎获得,而动态路径被提交给mod_wsgi,以便它可以调用 Python 解释器来运行您的应用代码。(该选项对于异步 web 框架不可用,因为 WSGI 没有提供一种机制,应用可以通过这种机制暂时放弃控制权,然后再完成工作。) - 在 web 服务器后面运行像 Gunicorn 这样的 Python HTTP 服务器(或者由您选择的异步框架指定的任何服务器),它可以直接提供静态文件,但也可以充当您用 Python 编写的动态资源的反向代理。Apache 和 nginx 都是这个任务的流行前端服务器。如果您的 Python 应用超出了一个单独的机器,它们还可以在几个后端服务器之间对请求进行负载平衡。
- 在 Apache 或 nginx 后面运行一个 Python HTTP 服务器,它本身位于 Varnish 这样的纯反向代理后面,创建一个面向现实世界的第三层。这些反向代理可以在地理上分布,以便从靠近客户机的位置提供缓存的资源,而不是从同一个大陆提供。Fastly 等内容交付网络通过在各大洲的机房部署大批 Varnish 服务器,然后使用它们为您提供全套服务,既终止您面向外部的 TLS 证书,又将请求转发到您的中央服务器。
图 10-1 。独立部署 Python 代码或在反向 HTTP 代理后部署 Python 代码的四种常用技术
在这四种架构之间的选择在历史上是由 C Python 运行时的三个特性驱动的:解释器很大,很慢,并且它的全局解释锁防止一次有多个线程执行 Python 字节码。
解释器锁的局限性鼓励使用独立的 Python 进程,而不是多个 Python 线程共享同一个进程。但是解释器的大小反过来了:只有一定数量的 Python 实例可以轻松放入 RAM,这限制了进程的数量。
在 Apache 下运行 Python
如果您想象一个使用旧的mod_python在 Apache 下运行的早期 Python 支持的 web 站点,您就能最好地理解前面描述的问题。对一个典型网站的大多数请求(见第十一章)都是针对静态资源的:对于每一个要求 Python 动态生成页面的请求,可能会有十几个对附带的 CSS、JavaScript 和图像的请求。然而mod_python让每个 Apache 工人都负担起自己的 Python 解释器运行时副本,其中大部分处于闲置状态。每十几个工人中可能只有一个在给定的时刻运行 Python,而其他人使用 Apache 的核心 C 代码假脱机输出文件。
如果 Python 解释器与将静态内容从磁盘转移到等待套接字的 web 服务器工作器独立运行,这种僵局就会被打破。这产生了两种相互竞争的方法。
避免用 Python 解释器加重每个 Apache 线程负担的第一种方法是使用现代的mod_wsgi模块,激活其“守护进程”特性。在这种模式下,Apache 的工作人员——无论是线程还是进程——都省去了加载或执行 Python 的费用,只产生了动态链接到mod_wsgi本身的成本。相反,mod_wsgi创建并管理一个单独的 Python 工作进程池,它可以向该池转发请求,并且 WSGI 应用将在该池中被实际调用。几十个小的 Apache 工人可以忙着为每个大的 Python 解释器输出静态文件,这些解释器慢慢地构建动态页面。
纯 Python HTTP 服务器的兴起
然而,一旦您接受了这样一个事实,即 Python 不会存在于主服务器进程本身中,而是 HTTP 请求必须被序列化并从 Apache 进程转发到 Python 进程中,为什么不直接使用 HTTP 呢?为什么不将 Apache 配置为将每个动态请求反向代理到 Gunicorn,并在其中运行您的服务呢?
的确,您现在必须启动和管理两个不同的守护进程——Apache 和 guni corn——而在此之前,您只需启动 Apache 并让mod_wsgi负责生成您的 Python 解释器。但是作为回报,你获得了很大的灵活性。首先,Apache 和 Gunicorn 不再有任何理由需要生活在同一个盒子上;您可以在针对大量并发连接和无序文件系统访问进行优化的服务器上运行 Apache,在针对动态语言运行时对数据库进行后端请求进行优化的单独服务器上运行 Gunicorn。
一旦 Apache 从您的应用容器变成了一个具有反向代理功能的静态文件服务器,您可以选择替换它。毕竟,nginx 也可以在反向代理其他路径的同时提供文件服务,就像许多其他现代 web 服务器一样。
最后,mod_wsgi选项变成了真正的反向代理的一个有限的专有版本:您在必须运行在同一台机器上的进程之间使用自己的内部协议,而您可以使用真正的 HTTP,并且可以根据您的需要选择在同一台机器上或不同的机器上运行 Python。
反向代理的好处
如果 HTTP 应用只提供由 Python 代码生成的动态内容,而不涉及静态资源,该怎么办?在这种情况下,Apache 或 nginx 似乎没什么事可做,您可能会试图忽略它们,而将 Gunicorn 或另一个纯 Python web 服务器直接公开。
在这种情况下,一定要考虑反向代理提供的安全性。要让你的 web 服务暂停,所有人需要做的就是用 n 个套接字连接到你的 n -worker 服务,提供一些请求数据的初始杂乱字节,然后冻结。您的所有工作人员现在都将忙于等待一个可能永远也不会到达的完整请求。相比之下,有了 Apache 或 nginx 在您的服务前面,那些需要很长时间才能到达的请求——无论是出于恶意,还是因为您的一些客户端运行在移动设备上,或者带宽很低——都会被反向代理的缓冲区缓慢收集,反向代理通常不会将请求转发给您,直到请求被完整接收。
当然,在转发请求之前收集完整请求的代理并不能抵御真正的拒绝服务攻击——唉,什么都不是——但是它确实可以防止动态语言运行时在来自客户端的数据尚未到来时停止工作。它还将 Python 与许多其他类型的病态输入隔离开来,从兆字节长的头名称到完全畸形的请求,因为 Apache 或 nginx 会直接拒绝这些带有 4 个 xx 错误的请求,而您的后端应用代码甚至不会怀疑。
在前面的列表中,我目前倾向于架构的三个最佳点。
我的默认设置是 nginx 后面的 Gunicorn,或者如果系统管理员喜欢的话,是 Apache。
如果我正在运行一个真正的纯 API 服务,并且不涉及任何静态组件,那么我有时会尝试单独运行 Gunicorn,或者直接在 Varnish 后面运行,如果我想让我的动态资源受益于它的一流缓存逻辑的话。
只有在设计大型 web 服务时,我才会全力以赴地使用三个层次:我的 Python 在 Gunicorn 中运行,在 nginx 或 Apache 之后,在本地或地理上分布的 Varnish 集群之后。
当然,许多其他配置也是可能的,我希望前面的讨论包含了足够多的注意事项和权衡,这样当问题出现在您自己的项目和组织中时,您将能够明智地做出选择。
即将出现的一个重要问题是像 PyPy 这样可以以机器速度运行的 Python 运行时的出现。一旦 Python 代码可以像 Apache 一样快速运行,为什么不让 Python 同时服务静态和动态内容呢?看看由快速 Python 运行时支持的服务器是否会对 Apache 和 nginx 等旧的可靠解决方案造成任何竞争,这将是一件有趣的事情。当行业的最爱被系统管理员很好地记录、理解和喜爱时,Python 服务器能为迁移提供什么激励呢?
当然,任何先前的模式都可能有变化。Gunicorn 可以直接运行在 Varnish 之后,例如,如果不需要提供静态文件,或者如果您愿意让 Python 将它们从磁盘中取出来。另一种选择是使用 nginx 或 Apache,打开它们的反向缓存选项,这样它们就可以提供基本的类似 Varnish 的缓存,而不需要第三层。一些网站试验了前端服务器和 Python 之间对话的替代协议,如 Flup 和 uwsgi 项目支持的协议。本节介绍的四种模式只是最常见的几种。还有许多其他可能的设计,其中大部分在今天的某个地方使用。
平台即服务
上一节中提到的许多主题——负载平衡、多层代理服务器和应用部署——开始转向系统管理和操作规划。诸如选择前端负载平衡器或使 HTTP 服务在物理上和地理上冗余所涉及的选择等问题并不是 Python 所独有的。如果包含在本章中,它们将带您远离 Python 网络编程的主题。
当您将 Python 作为提供网络服务策略的一部分时,我鼓励您也阅读自动化部署、持续集成和高性能扩展,以了解可能适用于您自己的服务和组织的技术。这里没有足够的空间来覆盖它们。
但是有一个话题值得一提:平台即服务(PaaS)提供商 的出现,以及如何打包您的应用以部署在此类服务上的问题。
有了 PaaS,建立和运行 HTTP 服务的许多繁琐工作都被自动化了——或者,至少,移交给了 PaaS 提供商,而不是您自己。您无需租用服务器,为其提供存储和 IP 地址,配置管理和重启服务器的 root 访问权限,安装正确版本的 Python,将应用复制到每台服务器,以及在重启或断电后自动启动服务所需的系统脚本。
相反,这些负担由 PaaS 提供商承担,他们可能会安装或租用数千台机器、数百台数据库服务器和数十台负载平衡器,以便为其客户群提供服务。自动化了所有这些步骤之后,提供商需要的只是您提供的配置文件。然后,提供商可以将您的域名添加到它的 DNS 中,将其指向它的一个负载平衡器,在操作系统映像中安装正确版本的 Python 和您的所有 Python 依赖项,并启动和运行您的应用。该过程可以使向他们推送新的源代码变得容易,并且当面对真实用户时,如果应用的新版本似乎会产生错误,也可以使回滚变得容易。您不必创建一个单独的/etc/init.d文件或重启一台机器。
Heroku 是 PaaS 领域目前最受欢迎的产品,它为 Python 应用提供一流的支持,作为其生态系统的一部分。Heroku 和它的竞争对手对于那些缺乏专业知识或内部时间来设置和管理负载平衡器等工具的小型组织来说尤其有价值。
新兴的 Docker 生态系统是 Heroku 的潜在竞争对手,因为它让你可以在自己的 Linux 机器上创建和运行 Heroku 风格的容器,这比你想要调整的每一行配置都需要在 Heroku 上进行漫长而缓慢的推送和重建要容易得多。
如果您对 PaaS 不太熟悉,那么您可能会期望这样一个服务能够让您的 WSGI-ready Python 应用运行起来,而无需任何额外的工作。
事实证明并非如此。在 Heroku 下或 Docker 实例中,您仍然有责任选择 web 服务器。
其原因是,虽然 PaaS 提供商提供了负载平衡、容器化、版本控制配置、容器映像缓存和数据库管理,但他们仍然希望您的应用提供 HTTP 互操作性方面的黄金标准:一个开放端口,PaaS 负载平衡器可以连接到该端口并发出 HTTP 请求。为了将您的 WSGI 应用或框架转变成一个监听网络端口,您显然需要一个服务器。
一些开发人员对 PaaS 服务将为他们进行负载平衡感到满意,他们选择了一个简单的单线程服务器,并让 PaaS 服务负责根据他们的需要启动尽可能多的应用实例。
但是许多开发人员选择 Gunicorn 或它的竞争对手,这样他们的每个容器可以同时运行几个工人。这使得单个容器能够接受多个请求,以防 PaaS 负载平衡器的循环逻辑在其第一个请求完成之前将其返回到同一个容器,如果您的服务提供的一些资源可能需要几秒钟才能呈现,并导致后续请求排队等待,直到第一个请求完成,这将是一个特别的问题。
请注意,大多数 PaaS 提供商并没有为提供静态内容做任何准备,除非您从 Python 提供静态内容,或者将 Apache 或 nginx 添加到您的容器中。虽然您可以设计 URL 空间,使静态资源来自与动态页面完全不同的主机名,并在其他地方托管这些静态资源,但许多架构师更喜欢能够在单个名称空间中混合静态和动态资源。
GET 和 POST 模式以及 REST 问题
Roy Fielding 博士是当前 HTTP 标准的主要作者之一,他的博士论文是关于其设计的。他创造了表述性状态转移(REST) 这个术语来命名当像 HTTP 这样的超文本系统的所有功能都全速运行时出现的架构。他的论文在网上,如果你想查阅的话。第五章是他从一系列更简单的概念中建立 REST 概念 的地方。
www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm
Fielding 博士明确指出“REST 是由四个接口约束定义的”,他在论文的第 5.1.5 节末尾简要列举了这些约束。
- 资源的识别
- 通过表示操纵资源
- 自我描述的消息
- 作为应用状态引擎的超媒体
许多服务设计者希望他们的设计与 HTTP 的设计一致,而不是相反,他们渴望创建能够赢得“RESTful”这一荣誉的服务。菲尔丁博士竭力反对他们中的大多数人不这样做。他们错在哪里?
第一个约束,“资源的标识”,排除了几乎所有传统形式的 RPC。JSON-RPC 和 XML-RPC(参见第十八章)都没有在 HTTP 协议本身的层次上公开资源标识。假设一个客户端想要获取一篇博客文章,更新其标题,然后再次获取该文章以查看差异。如果这些步骤是作为 RPC 方法调用实现的,那么 HTTP 可见的方法和路径如下所示:
POST /rpc-endpoint/ ® 200 OK
POST /rpc-endpoint/ ® 200 OK
POST /rpc-endpoint/ ® 200 OK
大概在每个帖子的正文中的某个地方, 每个请求都将类似“post 1022”的内容命名为客户端想要获取或编辑的特定资源。但是 RPC 使得这对于 HTTP 协议来说是不透明的。一个渴望 REST 的接口将使用资源路径来指定哪个 post 被操纵,也许可以命名为/post/1022/。
第二个约束,“通过表示操纵资源 ”,禁止设计者指定特定于他们的服务的特定机制,通过该机制标题必须被更新。毕竟,这将要求客户作者每次想了解如何执行更新时都要费力地阅读特定于服务的文档。在 REST 中,不需要学习改变文章标题的特殊技巧,因为文章的表示——不管是使用 HTML、JSON、XML 还是其他格式——是可以表达读或写的唯一形式。要更新一篇博客文章的标题,客户机只需获取当前的表示,更改标题,并将新的表示提交回服务。
GET /post/1022/ ® 200 OK
PUT /post/1022/ ® 200 OK
GET /post/1022/ ® 200 OK
获取或更新一打资源必须需要一打往返服务的想法是许多设计者的痛处,也是对架构做出务实例外的强烈诱惑。但是 REST 的优点是读写资源的操作和在 HTTP 协议中暴露有意义的语义之间的对称性。该协议现在可以看出哪些请求是读,哪些是写,如果 GET 响应包括正确的头,那么即使程序在没有浏览器参与的情况下相互通信,缓存和条件请求也变得可能。
显式缓存头将我们带到了第三个约束,“自描述性消息”, ,因为这样的头使得消息是自描述性的。编写客户端的程序员不需要查阅 API 文档来了解,例如,/post/1022/是 JSON 格式,或者只有在使用条件请求来确保缓存的副本是最新的情况下才能缓存,而像/post/?q=news这样的搜索可以在检索后的 60 秒内直接从缓存中提供。相反,这种知识在传输的每个 HTTP 响应的头中重新声明。
如果实现了 REST 的前三个约束,那么服务对于 HTTP 协议就变得完全透明了,因此对于所有的代理、缓存和客户机都是透明的,它们都是为了利用它的语义而编写的。此外,他们可以这样做,无论服务是为人类消费设计的,提供充斥着表单和 JavaScript 的 HTML 页面(见第十一章),还是为机器消费设计的,使用简洁的 URL 指向 JSON 或 XML 表示。
但是最后一个限制很少实现。
“作为应用状态引擎的超媒体”已经变得足够有争议,需要一个缩写!虽然在菲尔丁博士的论文中没有被单独提出来特别关注,但在随后的文献和辩论中,它已被缩写为“hate OAS”。他通过一篇博客文章“REST API 必须是超文本驱动的”引起了人们对这一约束的注意,这篇文章抱怨了一个所谓的 REST API 的发布,事实上,它没有通过这最后一个约束。
http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven
在那里,他将 HATEOAS 约束分解成不少于六个独立的要点,其中最后一点可能是最全面的。它是这样开始的,“除了最初的 URI(书签)和一组适合目标受众的标准化媒体类型之外,REST API 应该在没有任何先验知识的情况下输入。”
这将使几乎所有熟悉的 HTTP 驱动的 API 失去资格。无论是由 Google 还是 GitHub 提供,他们的文档似乎总是以“每个帖子位于一个类似于/post/1022/的 URL 中,该 URL 命名了帖子的唯一 ID”来开始对每个资源类型的讨论。通过这种策略,API 已经脱离了完全的 RESTfulness,进入了一个模糊的领域,文档中嵌入的特殊规则,而不是超文本链接,正在引导客户找到正确的资源。
相比之下,完全 RESTful 的 API 只有一个入口点。返回的媒体可能包括一系列表单,其中一个表单可以用来提交一个博客文章 ID 以获取其 URL。然后,服务本身,而不是人类可读的文档,会动态地将“ID 为 1022 的帖子”与特定的路径链接起来。
对 Fielding 博士来说,超文本的这种包含性概念是对旨在使用几十年的服务的一个关键限制,这种服务将能够支持许多代的 HTTP 客户机,以及以后当旧服务的原始用户都早已不在时的数据考古。但是,由于 HTTP 的大多数优势——无状态、冗余和缓存加速——可以通过前三个要素单独获得,因此似乎很少有服务能够应对完全 REST 合规性的挑战。
没有框架的 WSGI
第七章展示了几种编写网络服务的模式,其中任何一种都可以用来响应 HTTP 请求。但是很少需要编写自己的低级套接字代码 来讲协议。许多协议细节可以委托给 web 服务器,如果您选择使用 web 服务器,也可以委托给 web 框架。两者有什么区别?
web 服务器是保存监听套接字、运行accept()接收新连接并解析每个传入 HTTP 请求的代码。甚至不需要调用您的代码,服务器就可以处理这样的情况,比如一个客户端连接但从不完成它的请求,以及一个客户端的请求不能被解析为 HTTP。一些服务器还会超时并关闭空闲的客户端套接字,并拒绝路径或报头过长的请求。通过调用已经向服务器注册的 WSGI callable,只有格式良好的完整请求才会被传递到您的框架或代码中。服务器通常会根据自己的权限生成 HTTP 响应代码 (参见第九章),如下所示:
400 Bad Request:如果传入的 HTTP 请求难以理解或超出您指定的大小限制500 Server Error:如果您的 WSGI callable 引发了一个异常,而不是成功运行完成
有两种方法可以构建 WSGI callable,您的 web 服务器将为成功到达并解析的 HTTP 请求调用它。您可以自己构建 callable,也可以编写代码,插入到提供自己的 WSGI callable 的 web 框架中。有什么区别?
一个 web 框架的基本任务是承担分派的责任。每个 HTTP 请求在可能的方法、主机名和路径空间中命名一个坐标。您可能只在一个或几个主机名上运行服务, 不是所有可能的主机名。您可能准备好处理 GET 或 POST,但是请求可以命名它想要的任何方法,甚至是一个发明的方法。也许有许多途径 能让你做出有用的回应,但可能更多的途径你做不到。该框架将允许您声明您支持哪些路径和方法,因此该框架可以承担自动回复那些不支持的路径和方法的责任,其状态代码如下:
404 Not Found405 Method Not Allowed501 Not Implemented
第十一章探讨了传统和异步框架如何承担分派的责任,并调查了它们为程序员提供的其他主要特性。但是如果没有它们,您的代码会是什么样子呢?如果您自己的代码直接与 WSGI 接口并负责执行分派,会怎么样?
有两种方法可以构建这样的应用:要么阅读 WSGI 规范并自己学习阅读其环境字典,要么使用类似于竞争对手 WebOb 和 Werkzeug 工具包(可从 Python 包索引中获得)所提供的包装器。清单 10-2 展示了在原始 WSGI 环境中工作所必需的冗长编码风格 。
清单 10-2 。用于返回当前时间的原始 WSGI 可调用
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter10/timeapp_raw.py
# A simple HTTP service built directly against the low-level WSGI spec.
import time
def app(environ, start_response):
host = environ.get('HTTP_HOST', '127.0.0.1')
path = environ.get('PATH_INFO', '/')
if ':' in host:
host, port = host.split(':', 1)
if '?' in path:
path, query = path.split('?', 1)
headers = [('Content-Type', 'text/plain; charset=utf-8')]
if environ['REQUEST_METHOD'] != 'GET':
start_response('501 Not Implemented', headers)
yield b'501 Not Implemented'
elif host != '127.0.0.1' or path != '/':
start_response('404 Not Found', headers)
yield b'404 Not Found'
else:
start_response('200 OK', headers)
yield time.ctime().encode('ascii')
在缺乏框架的情况下,您的代码必须做所有的负面工作,确定哪些主机名、路径和方法与您打算提供的服务不匹配。为了在主机名127.0.0.1处提供路径/的 GET,您必须为您能够检测到的请求参数组合的每个偏差返回一个错误。当然,对于像这样的小服务来说,不简单地接受任何主机名似乎是愚蠢的。但我们假装我们可能会成长为一个大型服务,在几十个不同的主机名上提供不同的内容,所以我们小心翼翼地关注它们。
请注意,如果客户端提供类似于127.0.0.1:8000的主机头,您需要负责拆分主机名和端口。此外,您必须在字符?上拆分路径,以防 URL 的末尾出现类似/?name=value的查询字符串。(按照惯例,清单假设您希望忽略无关的查询字符串,而不是返回404 Not Found。)
接下来的两个清单演示了如何通过第三方库使这些原始的 WSGI 模式变得更容易,这些库可以用标准的“pip”安装工具 (参见第一章)。
$ pip install WebOb
$ pip install Werkzeug
最初由 Ian Bicking 编写的 WebOb“Web Object”库 ,是一个轻量级的对象接口,它包装了一个标准的 WSGI 字典,以提供对其信息的更方便的访问。清单 10-3 展示了它是如何从前面的例子中删除几个常见模式的。
清单 10-3 。用 WebOb 编写的 WSGI Callable,用于返回当前时间
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter10/timeapp_webob.py
# A WSGI callable built using webob.
import time, webob
def app(environ, start_response):
request = webob.Request(environ)
if environ['REQUEST_METHOD'] != 'GET':
response = webob.Response('501 Not Implemented', status=501)
elif request.domain != '127.0.0.1' or request.path != '/':
response = webob.Response('404 Not Found', status=404)
else:
response = webob.Response(time.ctime())
return response(environ, start_response)
WebOb 已经实现了两种常见的模式,一种是从主机头中单独检查主机名,而不检查任何可能附加的可选端口号,另一种是查看没有尾随查询字符串的路径。它还提供了一个了解所有内容类型和编码的Response对象——默认为纯文本——因此您只需要为响应体提供一个字符串,WebOb 会处理所有其他事情。
注意 WebOb 有一个特性使它在众多 Python HTTP 响应对象实现中几乎是独一无二的。WebOb
Response类允许您将像text/plain; charset=utf-8这样的内容类型头的两部分视为两个独立的值,并将其公开为独立的属性content_type和charset。
就纯 WSGI 编码而言,没有 WebOb 受欢迎,但也受到忠实粉丝的支持的是阿明·罗纳彻的 Werkzeug 库,这也是他的 Flask 框架的基础(在第十一章中讨论)。它的请求和响应对象是不可变的,而不是允许底层的 WSGI 环境被改变。清单 10-4 展示了在这种情况下它与 WebOb 的不同之处。
清单 10-4 。用 Werkzeug 编写的 WSGI Callable,用于返回当前时间
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter10/timeapp_werkz.py
# A WSGI callable built using Werkzeug.
import time
from werkzeug.wrappers import Request, Response
@Request.application
def app(request):
host = request.host
if ':' in host:
host, port = host.split(':', 1)
if request.method != 'GET':
return Response('501 Not Implemented', status=501)
elif host != '127.0.0.1' or request.path != '/':
return Response('404 Not Found', status=404)
else:
return Response(time.ctime())
Werkzeug 甚至没有让您记住 WSGI callable 的正确签名,而是给了您一个装饰器,将您的函数切换到一个简单得多的调用约定。您自动接收一个 Werkzeug Request对象作为您唯一的参数,并被赋予简单地返回一个Response对象的特权——库将为您处理所有其他事情。
用 WebOb 编写的代码中唯一轻微的倒退是,您必须自己将主机名(如127.0.0.1:8000)一分为二,而不是用一个方便的方法将它们拆分出来。尽管如此,有了这个小小的区别,这两个库正在做相同的工作,让您在比 WSGI 约定更高的层次上谈论 HTTP 请求和响应。
通常,作为一名开发人员,不值得花时间在这种低水平上操作,而不是使用 web 框架。但是,如果您想在将传入的 HTTP 请求交给 web 框架进行处理之前对它们进行一些转换,那么用原始的 WSGI 进行编写确实很方便。如果您正在用 Python 语言编写定制的反向代理或另一个纯 HTTP 服务,那么直接的 WSGI 应用也可能是合适的。
可以认为原始的 WSGI 调用在 Python 编程中的地位与正向代理和反向代理在整个 HTTP 生态系统中的地位相同。它们更适合于过滤、规范化和调度请求等低级任务,而不是在特定的主机名和路径上提供资源(您希望以 HTTP 服务的形式提供)。关于 WSGI callable 如何在将请求传递给下一个 callable 之前修改请求的详细信息,请阅读规范或参考 WebOb 或 Werkzeug 文档中给出的编写中间件的模式。
摘要
Python 内置了一个http.server模块,当从命令行启动时,它提供当前工作目录下的文件。虽然在紧急情况下或检查直接存储在磁盘上的网站时很方便,但该模块很少再用于创建新的 HTTP 服务。
Python 中正常的同步 HTTP 通常由 WSGI 标准来协调。服务器解析传入的请求以产生一个充满信息的字典,应用在返回 HTTP 头和可选的响应体之前检查字典。这使您可以将任何 web 服务器用于任何标准的 Python web 框架。
异步 web 服务器是 WSGI 生态系统的一个例外。因为 WSGI 可调用程序不是完整的协同例程,所以每个异步 HTTP 服务器都必须采用自己的约定,以便在自定义框架中编写服务。在这种情况下,服务器和框架是捆绑在一起的,通常不可能有更广泛的互操作性。
从 Python 提供 HTTP 服务有四种流行的架构。独立服务器可以使用 Gunicorn 或其他纯 Python 服务器实现(如 CherryPy)来运行。其他架构师选择通过mod_wsgi在 Apache 的控制下运行他们的 Python。然而,现在反向代理的概念是所有类型的 web 服务的首选模式,许多架构师发现将 Gunicorn 或另一个纯 Python 服务器直接放在 nginx 或 Apache 后面作为一个单独的 HTTP 服务更简单,他们可以将资源动态生成的路径请求转发给该服务。
然后,这些模式中的任何一个都可以在前面放置清漆或另一个反向代理,以提供缓存层。缓存实例可以位于同一机房(甚至同一台机器)的本地,但是它们通常在地理上是分散的,以便更接近特定的 HTTP 客户端群体。
在 PaaS 提供商上安装您的服务通常会提供缓存、反向代理和负载平衡作为服务的一部分。您的应用所要负责的就是响应 HTTP 请求,通常使用一个简单的容器,比如 Gunicorn。
关于服务的一个常见问题是它们是否是 RESTful 的:它们是否具有标准作者 Roy Fielding 博士所描述的 HTTP 设计意图的特性。虽然今天的许多服务已经远离了隐藏服务正在做什么的不透明的方法和路径选择,但很少有人采纳 Fielding 的完整愿景,即通过超文本而不是程序员指导的文档来支持语义。
小型服务,尤其是那些过滤或转换 HTTP 请求的服务,可以写成 WSGI callable。WebOb 或 Werkzeug 这两种竞争解决方案都可以将原始的 WSGI 环境简化为更容易使用的Request对象,并且它们还可以通过它们的Response类帮助您构建答案。
在下一章中,通过学习万维网——使互联网闻名于世的大量互连文档,您将超越一般 HTTP 服务和低级 WSGI 编程。您将学习如何获取和处理超文本文档,并使用流行的 web 框架自己实现网站。
十一、万维网
第九章和第十章将超文本传输协议(HTTP)解释为一种通用机制,通过这种机制,客户机可以请求文档,服务器可以通过提供文档来响应。
然而,有些事情无法解释。为什么协议的名字以超文本这个词开头?
答案是 HTTP 不仅仅是作为一种传输文件的新方法而设计的。它不仅仅是 FTP 之类的旧文件传输协议的花哨的缓存替代品(见第十七章)。虽然它当然能够传送独立的文档,如书籍、图像和视频,但 HTTP 的目的更为远大:允许世界各地的服务器发布文档,通过相互交叉引用,这些文档成为一个单一的相互链接的信息结构。
HTTP 是为传送万维网而建立的。
超媒体和 URL
几千年来,书籍一直引用其他书籍。但是人类必须通过获取另一本书并翻页直到找到引用的文本来制定每个引用。万维网(WWW,或简称“Web”)实现的梦想是将解析引用的责任委托给机器。
当惰性文本如“第九章第一节中关于 cookies 的讨论”在电脑屏幕上变成下划线并可点击时,点击会将你带到它所引用的文本,它就变成了一个超链接。其文本可以包含嵌入超链接的完整文档被称为超文本文档。当图像、声音和视频被添加到混音中时,用户正在体验超媒体。
在每种情况下,前缀 hyper- 表示介质本身理解文档相互引用的方式,并且可以为用户建立这些链接。印刷书籍中的“参见第 103 页”这句话本身并没有力量带你到达它所描述的目的地。相比之下,显示超链接的浏览器确实有这种能力。
为了给超媒体提供动力,发明了统一资源定位符(URL) 。它提供了一个统一的方案,通过这个方案,不仅现代的超文本文档,甚至旧的 FTP 文件和 Telnet 服务器都可以被引用。你已经在网络浏览器的地址栏中看到了很多这样的例子。
# Some sample URLs
https://www.python.org/
http://en.wikipedia.org/wiki/Python_(programming_language)
http://localhost:8000/headers
ftp://ssd.jpl.nasa.gov/pub/eph/planets/README.txt
telnet://rainmaker.wunderground.com
像https或http这样的初始标签是方案,它命名了可以检索文档的协议。冒号和两个斜线://后面是主机名和可选端口号。最后,路径从服务上可能可用的所有文档中选择一个特定的文档。
这种语法可以用于比描述从网络上获取的材料更一般的目的。统一资源标识符(URI) 的更一般的概念可以用于识别物理网络可访问的文档,或者作为通用的唯一标识符,用于给概念实体提供计算机可读的名称,即被称为统一资源名称(urn)的标签。这本书里的所有内容都是一个 URL。
对了,网址的发音是 you-are-ell 。“伯爵”是英国贵族中的一员,其级别不完全是侯爵,但确实高于子爵——所以伯爵相当于欧洲大陆上的伯爵(换句话说,不是网络文档地址)。
当基于用户指定的参数自动生成一个文档时,该 URL 用一个 查询字符串 扩展,该查询字符串以一个问号(?)开始,然后使用&符号(&)来分隔每个进一步的参数。每个参数由名称、等号和值组成。
https://www.google.com/search?q=apod&btnI=yes
最后,URL 可以以一个片段作为后缀,该片段命名了链接所指向的页面上的特定位置。
http://tools.ietf.org/html/rfc2324#section-2.3.2
片段不同于 URL 的其他组成部分。因为 web 浏览器假定它需要获取由路径命名的整个页面,以便找到由片段命名的元素,所以它实际上并不在其 HTTP 请求中传输片段!当服务器获取一个 HTTP URL 时,它能从浏览器获知的只有主机名、路径和查询。您可能还记得第九章中的主机名,它是作为主机头发送的,路径和查询被连接在一起,产生完整的路径,该路径遵循请求第一行中的 HTTP 方法。
如果你研究 RFC 3986,你会发现一些很少使用的附加特性。当您遇到想要了解更多的罕见特性时,可以参考这个权威资源,比如在 URL 本身中包含一个user@password认证字符串的可能性。
解析并构建 URL
Python 标准库中内置的 urllib.parse模块提供了解释和构建 URL 所需的工具。将一个 URL 拆分成多个组成部分是一个函数调用。它返回的内容在 Python 的早期版本中只是一个元组,您仍然可以以这种方式查看结果,并使用整数索引(或赋值语句中的元组解包)来访问它的项。
>>> from urllib.parse import urlsplit
>>> u = urlsplit('https://www.google.com/search?q=apod&btnI=yes')
>>> tuple(u)
('https', 'www.google.com', '/search', 'q=apod&btnI=yes', '')
但是 tuple 还支持对其项目的命名属性访问,以帮助您在检查 URL 时提高代码的可读性。
>>> u.scheme
'https'
>>> u.netloc
'www.google.com'
>>> u.path
'/search'
>>> u.query
'q=apod&btnI=yes'
>>> u.fragment
''
“网络位置”netloc可以有几个从属片段,但是它们很少见,所以urlsplit()不会将它们作为单独的条目放在元组中。相反,它们只能作为结果的属性。
>>> u = urlsplit('https://brandon:atigdng@localhost:8000/')
>>> u.netloc
'brandon:atigdng@localhost:8000'
>>> u.username
'brandon'
>>> u.password
'atigdng'
>>> u.hostname
'localhost'
>>> u.port
8000
将一个 URL 化整为零只是解析过程的一半。路径和查询组件都可以包含在成为 URL 的一部分之前必须转义的字符。例如,&和#不能按字面意思出现,因为它们分隔了 URL 本身。如果字符/出现在特定的路径组件中,则需要对其进行转义,因为斜杠用于分隔路径组件。
URL 的查询部分有自己的编码规则。查询值通常包含空格——想想你在谷歌中输入的所有包含空格的搜索——因此加号+被指定为查询中空格编码的替代方式。否则,查询字符串只能选择像 URL 的其余部分一样对空格进行编码,作为一个%20十六进制转义码。
为了访问“TCP/IP”部分并在那里搜索有关“数据包丢失”的信息,解析正在访问您站点的“Q&A”部分的 URL 的唯一正确方法如下:
>>> from urllib.parse import parse_qs, parse_qsl, unquote
>>> u = urlsplit('http://example.com/Q%26A/TCP%2FIP?q=packet+loss')
>>> path = [unquote(s) for s in u.path.split('/')]
>>> query = parse_qsl(u.query)
>>> path
['', 'Q&A', 'TCP/IP']
>>> query
[('q', 'packet loss')]
注意,我使用split()对路径进行的分割返回了一个初始的空字符串,因为这个特定的路径是一个以斜杠开头的绝对路径。
查询以元组列表的形式给出,而不是简单的字典,因为 URL 查询字符串允许多次指定查询参数。如果您正在编写不关心这种可能性的代码,您可以将元组列表传递给dict(),您将只看到每个参数的最后一个值。如果您想要回一个字典,但也想让一个参数被多次指定,那么您可以从parse_qsl()切换到parse_qs(),并取回一个值为列表的字典。
>>> parse_qs(u.query)
{'q': ['packet loss']}
标准库提供了从另一个方向返回的所有必要的例程。给定前面显示的path和query,Python 可以通过引用每个路径组件、用斜线将它们重新连接在一起、对查询进行编码并将结果呈现给“unsplit”例程(与前面调用的urlsplit()函数相反)来从 URL 的各个部分重建 URL。
>>> from urllib.parse import quote, urlencode, urlunsplit
>>> urlunsplit(('http', 'example.com',
... '/'.join(quote(p, safe='') for p in path),
... urlencode(query), ''))
'http://example.com/Q%26A/TCP%2FIP?q=packet+loss'
如果您仔细地将所有的 URL 解析委托给这些标准的库例程,您会发现完整规范的所有微小细节都为您考虑到了。
前面例子中的代码是如此的完全正确,以至于一些程序员甚至会把它描述为大惊小怪,甚至过度紧张。实际上,路径组件本身有多长时间有斜线?大多数网站都小心翼翼地设计路径元素,开发人员称之为 slugs ,这样它们就不需要丑陋的转义出现在 URL 中。如果一个网站只允许 URL 段包含字母、数字、破折号和下划线,那么担心段本身包含斜杠显然是错误的。
如果您确定您正在处理的路径在单个路径组件中从来没有转义斜杠,那么您可以简单地将整个路径暴露给quote()和unquote(),而不必先将其拆分。
>>> quote('Q&A/TCP IP')
'Q%26A/TCP%20IP'
>>> unquote('Q%26A/TCP%20IP')
'Q&A/TCP IP'
事实上,quote()例程认为这是常见的情况,因此它的参数默认值是safe='/',通常不会改变斜线。这就是被safe=''在繁琐的代码版本中覆盖的内容。
标准库urllib.parse模块有几个比之前概述的通用例程更专门的例程,包括用于在#字符处将 URL 从其片段中分离出来的urldefrag()。阅读文档以了解这个函数和其他函数,这些函数可以使一些特殊情况变得更加方便。
相对网址
您的文件系统命令行支持“更改工作目录”命令,该命令确定了系统将开始搜索相对路径的位置,该路径缺少前导斜杠。以斜杠开头的路径明确声明它们从文件系统的根开始搜索。它们是绝对的路径,不管你的工作目录是什么,它们总是命名同一个位置。
$ wc -l /var/log/dmesg
977 dmesg
$ wc -l dmesg
wc: dmesg: No such file or directory
$ cd /var/log
$ wc -l dmesg
977 dmesg
超文本也有同样的概念。如果文档中的所有链接都是绝对 URL,就像上一节中的那样,那么它们链接到的资源就没有问题。但是,如果文档包含相对 URL,那么就必须考虑文档自己的位置。
Python 提供了一个urljoin()例程,它理解整个标准的所有细微差别。给定一个从超文本文档中恢复的 URL,它可能是相对的,也可能是绝对的,您可以将它传递给urljoin()来填充任何缺失的信息。如果 URL 是绝对的,没问题;它将被原封不动地退回。
urljoin()的参数顺序与os.path.join()相同。首先提供您正在检查的文档的基本 URL,然后提供您在其中找到的 URL。有几种不同的方法可以重写相对 URL 的基本部分。
>>> from urllib.parse import urljoin
>>> base = 'http://tools.ietf.org/html/rfc3986'
>>> urljoin(base, 'rfc7320')
'http://tools.ietf.org/html/rfc7320'
>>> urljoin(base, '.')
'http://tools.ietf.org/html/'
>>> urljoin(base, '..')
'http://tools.ietf.org/'
>>> urljoin(base, '/dailydose/')
'http://tools.ietf.org/dailydose/'
>>> urljoin(base, '?version=1.0')
'http://tools.ietf.org/html/rfc3986?version=1.0'
>>> urljoin(base, '#section-5.4')
'http://tools.ietf.org/html/rfc3986#section-5.4'
同样,为urljoin()提供一个绝对 URL 是绝对安全的,因为它会检测到它是完全自包含的,并在不修改基本 URL 的情况下返回它。
>>> urljoin(base, 'https://www.google.com/search?q=apod&btnI=yes')
'https://www.google.com/search?q=apod&btnI=yes'
相对 URL 使得编写不知道是由 HTTP 还是 HTTPS 提供服务的网页变得容易,即使在页面的静态部分也是如此,因为相对 URL 可以省略模式,但指定其他所有内容。在这种情况下,只有方案是从基本 URL 复制的。
>>> urljoin(base, '//www.google.com/search?q=apod')
'http://www.google.com/search?q=apod'
如果你的站点要使用相对 URL,那么你必须严格控制页面是否带有斜杠,因为相对 URL 有两种不同的含义,取决于是否有斜杠。
>>> urljoin('http://tools.ietf.org/html/rfc3986', 'rfc7320')
'http://tools.ietf.org/html/rfc7320'
>>> urljoin('http://tools.ietf.org/html/rfc3986/', 'rfc7320')
'http://tools.ietf.org/html/rfc3986/rfc7320'
这两个基本 URL 之间的细微差别对于任何相对链接的意义来说都是至关重要的!第一个 URL 可以被认为是访问了html目录,以便显示它在那里找到的rfc3986文件,这使得“当前工作目录”成为了html目录。相反,第二个 URL 将rfc3986本身视为它正在访问的目录,因为在真正的文件系统中,只有目录可以带一个尾随斜杠。因此,构建在第二个 URL 之上的相对链接从rfc3986组件开始构建,而不是从它的父组件html开始构建。
总是设计你的网站,让用户到达一个以错误方式书写的 URL 时,可以立即被重定向到正确的路径。例如,如果您试图访问前一个示例中的第二个 URL,那么 IETF web 服务器将检测到错误的尾部斜杠,并在其响应中声明一个带有正确 URL 的 Location: header。
如果您曾经编写过 web 客户端,这是一个教训:相对 URL 是而不是相对于您在 HTTP 请求中提供的路径!如果站点选择用一个位置头来响应,那么相对 URL 应该相对于那个可替换的位置来构造。
超文本标记语言
有很多关于推动网络发展的核心文档格式的书籍。还有描述超文本文档格式本身的活动标准、用层叠样式表(CSS) 对其进行样式化的可用机制,以及当用户与文档交互或从服务器检索更多信息时,浏览器嵌入语言(如 JavaScript (JS ))可以通过其对文档进行实时更改的 API。核心标准和资源如下:
http://www.w3.org/TR/html5/
http://www.w3.org/TR/CSS/
https://developer.mozilla.org/en-US/docs/Web/JavaScript
https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
由于这是一本网络编程的书,我将把我的注意力限制在这些技术涉及网络的方式上。
超文本标记语言 (HTML) 是一种使用几乎不合理数量的尖括号对纯文本进行修饰的方案——也就是说,小于号和大于号<...>被重新映射为开括号和闭括号。每对尖括号创建一个标签,它或者在文档中打开一个新的元素,或者用一个初始斜杠表示它关闭了一个先前打开的元素。一个简单的段落,一个单词用粗体,另一个用斜体,可能如下所示:
<p>This is a paragraph with <b>bold</b> and <i>italic</i> words.</p>
一些标签是独立的,而不需要相应的结束标签出现在后面——最著名的是,标签<br>创建了一个段落中间的换行符。更谨慎的作者将其作为自结束标记<br/>输入,这是他们从可扩展标记语言(XML) 中学来的习惯,但是 HTML 使其成为可选的。
事实上,HTML 让很多事情变得可选,包括适当的结束标签。当一个<ul>无序列表元素结束时,一致性解析器也将理解它一直在读取的特定列表元素<li>现在也被关闭和结束,无论是否遇到实际的</li>标签。
前面给出的示例段落清楚地表明 HTML 是同心的。设计者可以将元素放在其他元素的内部,就像他们构建一个完整的网页一样。随着设计人员的构建,他们几乎不可避免地会重用 HTML 为页面上的不同目的而定义的有限集合中的元素。尽管新的 HTML5 标准允许在页面中间即时创建新元素,但设计师倾向于坚持使用标准元素。
一个大页面可能会使用一个通用的标签,比如 <div>(这是最通用的一种框)或<span>(标记运行文本的最通用方式),分别用于十几个不同的目的。当所有的<div>元素都是完全相同的标签时,CSS 如何恰当地设计每个元素的样式,JavaScript 如何让用户与它们进行不同的交互?
答案是 HTML 作者可以为每个元素指定一个类,提供一个更具体的标签,通过这个标签可以对元素进行寻址。有两种使用类的通用方法。
一揽子方法是让设计者为他们设计中的每一个 HTML 元素附加一个独特的类。
<div class="weather">
<h5 class="city">Provo</h5>
<p class="temperature">61°F</p>
</div>
他们的 CSS 和 JavaScript 可以用类似.city和.temperature的选择器来引用这些元素,或者,如果他们想更具体一点的话,h5.city和p.temperature。CSS 选择器最简单的形式是提供一个标记名和一个以句点为前缀的类名,这两者都是可选的。
或者设计者可能会认为一个<h5>在他们的天气标志中只有一个目的,一个段落也只有一个目的,所以选择用一个类来装饰外部元素。
<div class="weather"><h5>Provo</h5><p>61°F</p></div>
他们现在需要更复杂的模式来指定他们想要的<h5>和<p>存在于一个<div>中,这个类使得它的<div>是唯一的。模式是通过空格构建的——将匹配外部标签的模式与内部标签的模式连接起来。
.weather h5
.weather p
参考 CSS 标准或 CSS 介绍,了解除了这些简单的可能性之外的所有可用选项。如果您想了解如何使用选择器从浏览器中运行的实时代码中定位元素,还可以阅读 JavaScript 简介或 jQuery 等强大的文档操作库。
你可以通过谷歌 Chrome 或 Firefox 等现代浏览器的两个功能来调查你最喜欢的网站是如何包装信息的。如果你按下 Ctrl+U,它们会向你显示你正在查看的页面的 HTML 代码——语法高亮显示——你可以右击任何元素并选择 Inspect Element 来调出调试工具,让你调查每个文档元素如何与你在页面上看到的内容相关联,如图 11-1 所示。
图 11-1 。Google Chrome 中的 Inspect 标签
在检查器中,您可以切换到一个网络选项卡,该选项卡将显示所有其他下载的资源,并显示为访问该页面的结果。
注意,图 11-2 中的所示的网络面板通常是空的。启动后,单击“重新加载”,查看信息填充情况。
图 11-2 。Google Chrome 网络标签显示下载的渲染 python.org 的资源
请注意,您使用 Inspect Element 调查的实时文档可能与最初作为页面源提供的 HTML 很少或没有相似之处,这取决于 JavaScript 在初始页面加载后是否工作并从页面中添加或删除了元素。如果您在检查器中看到您感兴趣的元素,但在原始源代码中找不到它,您可能需要访问调试器的 Network 选项卡,以确定 JavaScript 正在获取哪些额外的资源,它可能已用于构建这些额外的页面元素。
当您现在开始在随后的程序清单中试验小型 web 应用时,您将希望尽可能使用浏览器的 Inspect Element 特性来检查应用返回的页面。
读取和写入数据库
想象一个简单的银行应用,它希望允许帐户持有人使用 web 应用互相发送付款。至少,这样的应用需要一个付款表,一种插入新付款的方法,以及一种获取所有涉及当前登录用户帐户的付款以便显示的方法。
清单 11-1 展示了一个简单的库,展示了所有这三个特性,它由内置于 Python 标准库中的 SQLite 数据库提供支持。因此,这个清单应该可以在任何安装了 Python 的地方工作!
清单 11-1 。用于构建和与数据库对话的例程
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter11/bank.py
# A small library of database routines to power a payments application.
import os, pprint, sqlite3
from collections import namedtuple
def open_database(path='bank.db'):
new = not os.path.exists(path)
db = sqlite3.connect(path)
if new:
c = db.cursor()
c.execute('CREATE TABLE payment (id INTEGER PRIMARY KEY,'
' debit TEXT, credit TEXT, dollars INTEGER, memo TEXT)')
add_payment(db, 'brandon', 'psf', 125, 'Registration for PyCon')
add_payment(db, 'brandon', 'liz', 200, 'Payment for writing that code')
add_payment(db, 'sam', 'brandon', 25, 'Gas money-thanks for the ride!')
db.commit()
return db
def add_payment(db, debit, credit, dollars, memo):
db.cursor().execute('INSERT INTO payment (debit, credit, dollars, memo)'
' VALUES (?, ?, ?, ?)', (debit, credit, dollars, memo))
def get_payments_of(db, account):
c = db.cursor()
c.execute('SELECT * FROM payment WHERE credit = ? or debit = ?'
' ORDER BY id', (account, account))
Row = namedtuple('Row', [tup[0] for tup in c.description])
return [Row(*row) for row in c.fetchall()]
if __name__ == '__main__':
db = open_database()
pprint.pprint(get_payments_of(db, 'brandon'))
SQLite 引擎将每个数据库放在磁盘上的单个文件中,因此open_database()函数可以检查该文件是否存在,以确定数据库是正在创建还是仅仅被重新打开。在创建数据库时,它构建一个付款表并添加三个付款示例,这样您的 web 应用除了显示一个空的付款列表之外,还会显示一些内容。
该模式过于简单——这是运行该应用的最低要求。在现实生活中,需要有一个用户名和安全密码散列的用户表,以及一个正式的银行账户表,钱可以从那里来,也可以存到那里。这款应用并不现实,而是允许用户在输入时创建示例帐户名。
本例中要研究的一个关键操作是,它的 SQL 调用的所有参数都被正确转义。如今,安全缺陷的一个主要来源是程序员在将特殊字符提交给 SQL 这样的解释型语言时,未能正确地对其进行转义。如果 web 前端的恶意用户想出了一种方法来键入 memo 字段,使其包含特殊的 SQL 代码,该怎么办?最好的保护是依靠数据库本身——而不是您自己的逻辑——来正确引用数据。
清单 11-1 通过在代码需要插值的地方给 SQLite 一个问号(?)来正确地做到这一点,而不是试图自己进行任何转义或插值。
另一个关键操作是将原始数据库行混合成更具语义的内容。fetchall()方法并不是 sqlite3 独有的,而是所有现代 Python 数据库连接器支持的互操作性的 DB-API 2.0 的一部分。此外,它不会为从数据库返回的每一行返回一个对象,甚至是一个字典。它为每个返回的行返回一个元组。
(1, 'brandon', 'psf', 125, 'Registration for PyCon')
处理这些原始元组的结果可能是不幸的。在你的代码中,像“贷记的账户”或“支付的美元数”这样的概念可能会以row[2]或row[3]的形式出现,很难阅读。因此,bank.py转而创建了一个快速命名元组类,这个类也将响应属性名,比如row.credit和row.dollars。每次调用SELECT时创建一个新的类并不是最佳选择,但是在一两行代码中提供了 web 应用代码需要的语义——让您更快地转向 web 应用代码本身。
一个糟糕的 Web 应用(在 Flask 中)
除了阅读下面的程序清单之外,您还可以在下面的几个清单中试验示例 web 应用,方法是查看本章的源代码库:
https://github.com/brandon-rhodes/fopnp
您可以在此处浏览特定于本章的文件:
https://github.com/brandon-rhodes/fopnp/tree/m/py3/chapter11
您应该研究的第一个文件是app_insecure.py ,如清单 11-2 所示。在面对这些问题之前,仔细通读代码是值得的:它看起来像是导致安全妥协和公众耻辱的那种可怕和不可信的代码吗?它看起来危险吗?
清单 11-2 。一个不安全的 Web 应用(不是 Flask 的错!)
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter11/app_insecure.py
# A poorly-written and profoundly insecure payments application.
# (Not the fault of Flask, but of how we are choosing to use it!)
import bank
from flask import Flask, redirect, request, url_for
from jinja2 import Environment, PackageLoader
app = Flask(__name__)
get = Environment(loader=PackageLoader(__name__, 'templates')).get_template
@app.route('/login', methods=['GET', 'POST'])
def login():
username = request.form.get('username', '')
password = request.form.get('password', '')
if request.method == 'POST':
if (username, password) in [('brandon', 'atigdng'), ('sam', 'xyzzy')]:
response = redirect(url_for('index'))
response.set_cookie('username', username)
return response
return get('login.html').render(username=username)
@app.route('/logout')
def logout():
response = redirect(url_for('login'))
response.set_cookie('username', '')
return response
@app.route('/')
def index():
username = request.cookies.get('username')
if not username:
return redirect(url_for('login'))
payments = bank.get_payments_of(bank.open_database(), username)
return get('index.html').render(payments=payments, username=username,
flash_messages=request.args.getlist('flash'))
@app.route('/pay', methods=['GET', 'POST'])
def pay():
username = request.cookies.get('username')
if not username:
return redirect(url_for('login'))
account = request.form.get('account', '').strip()
dollars = request.form.get('dollars', '').strip()
memo = request.form.get('memo', '').strip()
complaint = None
if request.method == 'POST':
if account and dollars and dollars.isdigit() and memo:
db = bank.open_database()
bank.add_payment(db, username, account, dollars, memo)
db.commit()
return redirect(url_for('index', flash='Payment successful'))
complaint = ('Dollars must be an integer' if not dollars.isdigit()
else 'Please fill in all three fields')
return get('pay.html').render(complaint=complaint, account=account,
dollars=dollars, memo=memo)
if __name__ == '__main__':
app.debug = True
app.run()
列表不仅危险,而且容易受到现代网络上活跃的许多最重要的攻击媒介的攻击!通过在本章接下来的几节中研究它的缺点,您将了解到一个应用生存所需要的最基本的防护。这些弱点都是数据处理过程中的错误,与网站是否首先受到 TLS 的适当保护以防止窥探的问题是分开的。你可以继续想象它确实受到加密的保护,也许是通过位于服务器前的反向代理(见第十章),因为我将考虑攻击者甚至可以在无法看到特定用户和应用之间的数据传递的情况下做什么。
该应用使用 Flask web 框架来处理作为一个 Python web 应用的基本操作:回答 404 应用没有定义的页面,解析来自 HTML 表单的数据(您将在下面的部分中了解到),并使编写正确的 HTTP 响应变得容易,这些响应包含来自一个模板的 HTML 文本或重定向到另一个 URL。通过访问http://flask.pocoo.org/网站上的 Flask 文档,你可以了解到比本章提到的更多的关于 Flask 的知识。
想象一下,不熟悉 Web 的程序员编写了这个清单。他们听说过模板语言可以很容易地将自己的文本添加到 HTML 中,所以他们想出了如何加载并运行Jinja2。此外,他们发现 Flask 微框架的受欢迎程度仅次于 Django,因为 Flask 应用可以放在一个文件中,所以他们决定尝试一下。
从上到下阅读,可以看到一个 login()页和一个logout()页。因为这个应用没有真正的用户数据库,登录页面只是硬编码了两个可能的用户帐户和密码。稍后您将了解更多关于表单逻辑的内容,但是您已经可以看到,登录和注销的结果是 cookie 的创建和删除(参见第九章的和第十章的),当这些 cookie 出现在后续请求中时,会将它们标记为属于特定的认证用户。
站点上的其他两个页面通过查找这个 cookie 和重定向回登录页面(如果他们对缺少值不满意)来保护自己免受未授权用户的攻击。除了对登录用户的检查之外,login()视图只有两行代码(因为行的长度,只有三行):它从数据库中提取当前用户的付款,并将它们与一些其他信息放在一起提供给 HTML 页面模板。页面可能想知道用户名是有道理的,但是为什么代码要检查名为'flash'的消息的 URL 参数(Flask 将其作为request.args字典使用)?
如果你读了第 pay()页,答案就很明显了。在成功付款的情况下,用户将被重定向到索引页面,但可能希望得到一些指示,表明表单达到了预期的效果。这是由显示在页面顶部的 flash 消息提供的,web 框架这样称呼它们。(这个名字与旧的 Adobe Flash 系统写广告没有任何关系,而是指当下一次查看一个页面时,消息“闪现”在用户面前,然后消失)。在这个 web 应用的第一个草案中,flash 消息只是作为 URL 中的一个查询字符串。
http://example.com/?flash=Payment+successful
对于 web 应用的读者来说,pay()例程的其余部分是熟悉的:检查表单是否已经成功提交,如果已经提交,则执行一些操作。因为用户或浏览器可能已经提供或省略了任何表单参数,所以代码小心翼翼地使用request.form字典的get()方法寻找它们,如果缺少一个键,该方法可以返回一个默认值(这里是空字符串'')。
如果请求令人满意,那么付款将永久添加到数据库中。否则,表单将呈现给用户。如果他们已经完成了输入一些信息的工作,那么代码会小心地不要丢弃这些工作:它不会向他们显示一个空白表单和错误消息来丢弃他们的工作,而是将他们输入的值传递回模板,以便可以重新显示它们。
回顾清单 11-2 中提到的三个 HTML 模板对下一节讨论表单和方法至关重要。实际上有四个模板,因为 HTML 的公共设计元素已经被分解到一个基础模板中,这是构建多页面站点的设计者最常用的模式。
清单 11-3 中的模板定义了一个带有插入点的页面框架,其他模板可以在其中插入页面标题和页面正文。请注意,标题可以使用两次,一次在<title>元素中,一次在<h1>元素中,这要感谢 Jinja2 模板语言设计得如此之好——由阿明·罗纳切尔编写,他还编写了 Werkzeug(见第十章)和 Flask。
清单 11-3 。base.html页面 Jinja2 模板
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="/static/style.css">
</head>
<body>
<h1>{{ self.title() }}</h1>
{% block body %}{% endblock %}
</body>
</html>
例如, Jinja2 模板语言决定了双括号语法(如在{{ username }}中)是如何要求将一个值替换到模板中的,并且像{% for %}这样的括号百分比策略可以用于循环和重复产生相同的 HTML 模式。关于它的语法和特性的更多信息,请参见它在http://jinja.pocoo.org/的文档。
清单 11-4 所示的登录页面除了标题和表单本身什么也没有。您可以第一次看到一个您将再次看到的模式:一个表单元素,它提供了一个初始的value="...",当它第一次出现在屏幕上时,应该已经出现在可编辑元素中了。
清单 11-4 。login.html Jinja2 模板
{% extends "base.html" %}
{% block title %}Please log in{% endblock %}
{% block body %}
<form method="post">
<label>User: <input name="username" value="{{ username }}"></label>
<label>Password: <input name="password" type="password"></label>
<button type="submit">Log in</button>
</form>
{% endblock %}
通过将这个{{ username }}替换为value="...",该表单将帮助用户避免在输入错误密码时重新输入用户名,并再次获得相同的表单。
将位于/的索引页面在它的模板中有更多的内容,你可以从清单 11-5 中看到。任何简讯,如果有的话,放在标题的正下方。然后出现一个无序列表(<ul>)的列表项(<li>),每个列表项描述了登录用户账户的一笔付款,标题为“你的付款”。最后,还有到新支付页面和注销链接的链接。
清单 11-5 。index.html Jinja2 模板
{% extends "base.html" %}
{% block title %}Welcome, {{ username }}{% endblock %}
{% block body %}
{% for message in flash_messages %}
<div class="flash_message">{{ message }}<a href="/">×</a></div>
{% endfor %}
<p>Your Payments</p>
<ul>
{% for p in payments %}
{% set prep = 'from' if (p.credit == username) else 'to' %}
{% set acct = p.debit if (p.credit == username) else p.credit %}
<li class="{{ prep }}">${{ p.dollars }} {{ prep }} <b>{{ acct }}</b>
for: <i>{{ p.memo }}</i></li>
{% endfor %}
</ul>
<a href="/pay">Make payment</a> | <a href="/logout">Log out</a>
{% endblock %}
注意,代码对一遍又一遍地显示当前用户的帐户名称不感兴趣,因为它循环显示他们的收入和支出。因此,对于每笔付款,它会计算出credit或debit账户名称是否与当前用户匹配,然后确保打印另一个账户名称——使用正确的介词,这样用户就可以知道他们的钱流向了哪里。这要感谢 Jinja2 的{% set ... %}命令,当设计者意识到他们想要什么时,这使得像这样的快速小演示计算很容易在模板中完成。
用户似乎经常有几十种方法不能正确填写表单,清单 11-6 准备好接收一个complaint字符串,以便在表单顶部突出显示,如果提供了这样的字符串的话。除此之外,代码大部分是重复的:如果表单填写不正确,需要重新显示三个表单字段,当用户尝试提交表单时,需要用用户已经存在的文本进行预填充。
清单 11-6 。pay.html Jinja2 模板
{% extends "base.html" %}
{% block title %}Make a Payment{% endblock %}
{% block body %}
<form method="post" action="/pay">
{% if complaint %}<span class="complaint">{{ complaint }}</span>{% endif %}
<label>To account: <input name="account" value="{{ account }}"></label>
<label>Dollars: <input name="dollars" value="{{ dollars }}"></label>
<label>Memo: <input name="memo" value="{{ memo }}"></label>
<button type="submit">Send money</button> | <a href="/">Cancel</a>
</form>
{% endblock %}
在网站的每个提交按钮旁边都有一条退路是一个最佳实践。实验表明,如果退路明显比提交表单的默认动作小且不重要,用户犯的错误最少——尤其重要的是,退路而不是看起来像按钮!
因此,pay.html小心翼翼地使它的“取消”退出路径成为一个简单的链接,通过当前在这种视觉上下文中流行的常规管道符号(|)在视觉上与按钮分开。
如果您想尝试这个应用,您可以检查源代码,进入包含bank.py、app_insecure.py和相关联的templates/目录的chapter11目录,并键入以下内容:
$ pip install flask
$ python3 app_insecure.py
结果应该是一个声明,它已经启动并运行在一个 URL 上,它将打印到您的屏幕上。
* Running on http://127.0.0.1:5000/
* Restarting with reloader
打开调试模式后(参见清单 11-2 中的倒数第二行),如果你编辑其中一个清单,Flask 甚至会自动重启并重新加载你的应用,这使得快速探索代码的微小变化的效果变得很容易。
这里少了一个小细节。如果清单 11-3 中的 base.html提到了style.css,在哪里?它位于static/目录中,您可以在源代码库中的应用旁边找到。如果你发现你不仅对网络编程感兴趣,而且对网页设计的想法感兴趣,你会想要复习一下。
表单和 HTTP 方法之舞
HTML 表单的默认动作是 GET,它可以简单到只有一个输入字段。
<form action="/search">
<label>Search: <input name="q"></label>
<button type="submit">Go</button>
</form>
本书没有篇幅来讨论表单设计——一个充满技术决策的庞大主题。除了像这里这样的文本字段之外,还有十几种输入要考虑。甚至文本字段周围也有许多选项。您是否打算使用 CSS3 向输入字段添加一些示例文本,当用户开始输入时,这些文本就会消失?在用户输入搜索词之前,浏览器内的 JavaScript 代码是否应该让提交按钮变灰?你应该在输入框下面放一些说明或者一些示例搜索词来给用户提供建议吗?提交按钮应该说“提交”还是说表单提交到服务器后会发生什么?极简设计者会要求你完全省略 Go 按钮,简化网站,但要求用户知道他们可以点击 Return 提交搜索吗?
但是这些问题在关于网页设计的书籍和网站上都有详细的介绍。这本书只能关注形式对网络意味着什么。
执行 GET 的表单将输入字段直接放在 URL 中,从而放在随 HTTP 请求传输的路径中。
GET /search?q=python+network+programming HTTP/1.1
Host: example.com
想想这意味着什么。GET 的参数成为你的浏览器历史的一部分,任何人越过你的肩膀看浏览器的地址栏都能看到。这意味着 GET 永远不能用于传递敏感信息,如密码或凭证。当你填写 GET 表单时,你是在陈述“我下一步想去哪里?”您实际上是在帮助浏览器为您希望服务器创建的页面编写一个手工制作的 URL,以便您可以访问它。用三个不同的短语填写之前的搜索表单将导致创建三个单独的页面,三个您可以稍后返回的浏览器历史记录条目,以及三个可以与朋友共享的 URL(如果您希望他们看到相同的结果页面)。
执行 GET 请求的表单就是你如何请求去某个地方,仅仅通过描述你的目的地。
这与相反类型的 HTML 表单形成了鲜明的对比,后者的方法是 POST、PUT 或 DELETE。对于这些表单,表单中的任何信息都不会进入 URL,也不会进入 HTTP 请求中的路径。
<form method="post" action="/donate">
<label>Charity: <input name="name"></label>
<label>Amount: <input name="dollars"></label>
<button type="submit">Donate</button>
</form>
当提交这个 HTML 表单时,浏览器将数据完整地放入请求的主体中,而完全不考虑路径。
POST /donate HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 39
name=PyCon%20scholarships&dollars=35
在这里,你不是被动地要求去访问一个“35 美元的 PyCon 奖学金”网页,因为你有兴趣看它。恰恰相反。你承诺了一个行动——如果你决定执行两次帖子而不是一次,这个行动将会花费两倍的钱,产生两倍的影响。表单参数没有放在 URL 中,因为“$35 for PyCon scholarships”不是您想去的地方的名称。这就是已故哲学家 J.L. Austin 所说的一种言语行为,即在世界上引起一种新的事态的话语。
顺便说一下,有一种基于 MIME 标准的替代形式编码multipart/forms(第十二章),浏览器可以使用它来上传大型有效载荷,如整个文件。然而,无论哪种方式,POST 表单的语义都是相同的。
Web 浏览器对 POST 非常谨慎,因为他们认为这是一个动作。如果用户在查看 POST 返回的页面时试图点击 Reload,浏览器会用一个对话框打断他们。如果您从清单 11-2 中调出 web 应用,访问它的/pay表单,然后不输入任何内容就提交表单,这样它会立即返回抱怨“美元必须是整数”当我点击谷歌浏览器中的重新加载时,会弹出一个对话框。
Confirm Form Resubmission
The page that you're looking for used information that you entered. Returning to the page might cause any action you took to be repeated. Do you want to continue?
您应该会在自己的浏览器中看到类似的警告。在用肉眼看表单的同时,可以清楚地看到表单 submit 似乎并没有生效;但是浏览器无法知道帖子没有产生效果。它发送了一个帖子,收到了一个页面,据它所知,页面上写着“感谢您捐赠 1000 美元”,再次提交的效果可能是灾难性的。
网站可以使用两种技术来避免用户滞留在一个由帖子引起的页面上,这样会给用户浏览器的重新加载和前进后退按钮带来无尽的麻烦。
- 使用 JavaScript 或 HTML5 表单输入约束,首先尝试防止用户提交无效值。如果在表单准备好提交之前,submit 按钮没有亮起,或者如果整个表单往返过程可以用 JavaScript 处理,而不需要重新加载页面,那么无效的提交——比如您刚才提交的空表单——不会使用户陷入 POST 结果中。
- 当表单最终被正确提交并且动作成功时,web 应用应该抵制诱惑,不要直接用一个 200 OK 的页面来描述完成的动作。相反,用 303 See Other 重定向到 Location 头中指定的另一个 URL 来响应。这将迫使浏览器在成功发布后立即获取,将用户带到其他地方。用户现在可以点击 Reload、Forward 和 Back 来查看自己喜欢的内容,这样只能安全地重复获得结果页面,而不是重复尝试提交表单。
虽然清单 11-2 中的简单应用过于简单,无法在表单无效的情况下屏蔽用户查看 POST 结果,但是当/login表单或/pay表单成功时,它至少执行了一次成功的 303 See(也由 Flask redirect()构造函数提供支持)。这是一个最佳实践,您应该可以在所有 web 框架中找到支持。
当表单使用错误的方法时
滥用 HTTP 方法的 Web 应用会导致自动化工具、用户期望和浏览器出现问题。
我记得有一个朋友,他的小企业网页存储在一家本地托管公司自己开发的 PHP 内容管理系统中。一个管理界面向他展示了他的网站上使用的图片链接。我们突出显示了这个页面,并要求浏览器下载所有的链接,这样他就有了自己的图像备份。几分钟后,他收到了一个朋友的短信:为什么所有的图片都从他的网站上消失了?
事实证明,每张图片旁边的删除按钮,唉,并不是一个真正的启动 POST 操作的按钮。相反,每次删除仅仅是一个普通旧网址的链接,如果你访问它,就会有删除图片的副作用!他的浏览器愿意获取页面上的上百个链接,因为在任何情况下,获取都应该是安全的操作。他的托管公司背叛了这种信任,结果是他的网站不得不从他们的备份中恢复。
相反的错误——用 POST 执行“读取”操作——产生的后果不那么可怕。它只是破坏了可用性,而不是删除你所有的文件。
我曾经不喜欢使用一个大型机构内部开发的搜索引擎。经过几次搜索,我面前有一页结果需要我的主管查看,所以我突出显示了 URL,并准备将其粘贴到电子邮件中。
然后我看了网址,很沮丧。即使不知道服务器是如何工作的,我也确信当我的主管访问它的时候,/search.pl不会自己把这一页结果带回来!
我的浏览器地址栏看不到该查询,因为搜索表单被错误地设计为使用 POST。这使得每一个搜索的 URL 看起来完全一样,这意味着搜索既不能共享也不能加书签。当我试图用浏览器的前进和后退按钮浏览一系列搜索时,我得到了一系列弹出窗口,询问我是否真的想重新提交每个搜索!就浏览器所知,这些帖子都可能有副作用。
使用 GET for places 和 POST for actions 是至关重要的,这不仅是为了协议,也是为了高效的用户体验。
安全和不安全的 cookie
清单 11-2 中的 web 应用试图为其用户提供隐私。它需要一个成功的登录,然后才能泄露用户的支付列表,以响应对/页面的获取。它还要求用户在接受向/pay表单发送的允许用户转账的帖子之前登录。
不幸的是,利用该应用并代表另一个用户进行支付是非常容易的!
考虑一下恶意用户在获得站点访问权后可能采取的步骤,也许是在站点上打开自己的帐户来调查它是如何工作的。他们将在 Firefox 或 Google Chrome 中打开调试工具,然后登录到该网站,在网络窗格中观察传出和传入的标头,以了解该网站是如何工作的。他们的用户名和密码会得到什么样的回应?
HTTP/1.0 302 FOUND
...
Set-Cookie: username=badguy; Path=/
...
多有趣啊!他们的成功登录将一个名为username的 cookie 发送到他们的浏览器,其值为他们自己的用户名badguy。显然,该网站很高兴地相信,随后用这个 cookie 发出的请求一定表明他们已经正确地输入了用户名和密码。
但是调用者可以给这个 cookie 任何他们想要的值吗?
他们可以尝试通过点击浏览器中正确的隐私菜单来伪造 cookie,或者尝试从 Python 访问该站点。使用请求,他们可能会先看看是否能获取首页。不出所料,未经身份验证的请求会被转发到/login页面。
>>> import requests
>>> r = requests.get('http://localhost:5000/')
>>> print(r.url)
http://localhost:5000/login
但是,如果坏人插入一个 cookie,让它看起来像是brandon用户已经登录了,该怎么办呢?
>>> r = requests.get('http://localhost:5000/', cookies={'username': 'brandon'})
>>> print(r.url)
http://localhost:5000/
成功!因为站点相信它设置了这个 cookie 的值,所以它现在响应 HTTP 请求,就像它们来自另一个用户一样。坏人只需要知道支付系统的另一个用户的用户名,他们就可以伪造一个请求,将钱汇往他们想去的任何地方。
>>> r = requests.post('http://localhost:5000/pay',
... {'account': 'hacker', 'dollars': 100, 'memo': 'Auto-pay'},
... cookies={'username': 'brandon'})
>>> print(r.url)
http://localhost:5000/?flash=Payment+successful
这招奏效了——100 美元已经从brandon账户支付给了他们控制下的一个人。
教训是,cookies 永远不应该被设计成用户可以自己创建一个。假设您的用户很聪明,如果您所做的只是用 base-64 编码模糊他们的用户名,或者交换字母,或者用常量掩码对值执行简单的异或运算,他们最终会明白的。创建不可伪造的 cookies 有三种安全的方法。
- 您可以让 cookie 可读,但要用数字签名来签名。这让攻击者感到沮丧。他们可以看到 cookie 中有他们的用户名,并希望他们可以用他们想要劫持的帐户的用户名重写用户名。但是因为他们不能伪造数字签名来签署这个新版本的 cookie,所以他们不能使您的站点相信重写的 cookie 是合法的。
- 您可以完全加密 cookie,这样用户甚至无法解释它的值。它将显示为他们无法解析或理解的不透明值。
- 您可以使用标准的 UUID 库为 cookie 创建一个没有内在含义的纯粹随机的字符串,并将其保存在您自己的数据库中,这样当用户发出下一个请求时,您就可以识别出该 cookie 是属于用户的。如果来自同一个用户的几个连续的 HTTP 请求可能最终被转发到不同的服务器,那么您的所有前端 web 机器都需要能够访问这个持久会话存储。一些应用将会话放在主数据库中,而另一些应用使用 Redis 实例或其他短期存储来防止增加主持久数据存储的查询负载。
对于这个示例应用,您可以利用 Flask 的内置功能对 cookies 进行数字签名,这样它们就不会被伪造。在真实的生产服务器上,您可能希望将签名密钥与源代码安全地分开,但是对于本例,它可以放在源文件的顶部附近。将密钥包含在生产系统的源代码中不仅会将密钥泄露给任何能够访问您的版本控制系统的人,而且还可能会将凭证暴露给开发人员的笔记本电脑和您的持续集成过程。
app.secret_key = 'saiGeij8AiS2ahleahMo5dahveixuV3J'
Flask 会在每次你使用它的特殊的session对象设置一个 cookie 时使用这个密钥,比如在登录时。
session['username'] = username
session['csrf_token'] = uuid.uuid4().hex
Flask 将在信任从传入请求中提取的任何 cookie 值之前再次使用该密钥。签名不正确的 cookie 被认为是伪造的,并被视为根本不存在于请求中。
username = session.get('username')
您将在清单 11-8 中看到这些改进。
对 cookies 的另一个担心是,它们永远不应该通过未加密的 HTTP 通道传递,因为这样一来,在同一个咖啡店无线网络上的其他所有人都可以看到它们。许多网站小心翼翼地使用 HTTP 安全登录页面设置 cookies,只是为了在浏览器从同一个主机名提取所有 CSS、JavaScript 和图像时完全暴露它们。
为了防止暴露 cookie,了解如何让您的 web 框架在发送到浏览器的每个 cookie 上设置Secure参数。然后,它会小心不要将它包含在对资源的未加密请求中,无论如何每个人都可以访问这些资源。
非持久性跨站点脚本
如果对手不能窃取或伪造一个 cookie,让他们的浏览器(或 Python 程序)代表另一个用户执行操作,那么他们就可以改变策略。如果他们能想出如何控制另一个登录用户的浏览器,那么他们甚至永远也不会看到 cookie。通过使用该浏览器执行操作,cookie 将自动包含在每个请求中。
对于这种攻击,至少有三种众所周知的方法。清单 11-2 中的服务器容易受到这三种攻击,现在你将依次了解它们。
第一种类型是跨站点脚本 (XSS)的非持久性版本,在这种版本中,攻击者想出如何让一个网站——就像示例支付系统——呈现攻击者编写的内容,就好像它来自该网站一样。假设攻击者想要向他们控制的账户发送 110 美元。他们可能会编写清单 11-7 中所示的 JavaScript。
清单 11-7 。用于支付的脚本attack.js
<script>
var x = new XMLHttpRequest();
x.open('POST', 'http://localhost:5000/pay');
x.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
x.send('account=hacker&dollars=110&memo=Theft');
</script>
如果当用户登录到支付应用时,这个代码仅仅出现在页面上,那么它所描述的 POST 请求将自动发出,并代表无辜的用户进行支付。因为在查看呈现的网页时,<script>标记内的代码是不可见的,所以用户甚至不会发现有什么问题,除非他们按 Ctrl+U 查看源代码——即使这样,他们也必须将<script>元素识别为不寻常的东西,通常不是页面的一部分。
但是攻击者如何让这个 HTML 出现呢?
答案是攻击者可以简单地通过代码插入的flash参数将这个 HTML 原始地插入到/页面的页面模板中!因为清单 11-2 的作者没有阅读足够的文档,他们没有意识到原始形式的 Jinja2 不会自动转义特殊字符,如<和>,因为它不知道——除非有人告诉它——你正在用它来编写 HTML。
攻击者可以构建一个 URL,其flash参数包含他们的脚本。
>>> with open('/home/brandon/fopnp/py3/chapter11/attack.js') as f:
... query = {'flash': f.read().strip().replace('\n', ' ')}
>>> print('http://localhost:5000/?' + urlencode(query))
http://localhost:5000/?flash=%3Cscript%3E+var+x+%3D+new+XMLHttpRequest%28%29%3B+x.open%28%27POST%27%2C+%27http%3A%2F%2Flocalhost%3A5000%2Fpay%27%29%3B+x.setRequestHeader%28%27Content-Type%27%2C+%27application%2Fx-www-form-urlencoded%27%29%3B+x.send%28%27account%3Dhacker%26dollars%3D110%26memo%3DTheft%27%29%3B+%3C%2Fscript%3E
最后,攻击者需要设计一种方法来引诱用户查看并点击链接。
当瞄准一个特定的用户时,这可能是困难的。攻击者可能需要伪造一封看起来像是来自用户的一个真正朋友的电子邮件,在用户想要点击的文本后面隐藏着链接。需要研究,失效模式很多。攻击者可能会登录到用户正在聊天的 IRC 频道,并说该链接是一篇关于用户刚刚发表意见的主题的“文章”。在后一种情况下,攻击者通常会共享一个缩短的链接,只有当用户单击它时,该链接才会扩展到 XSS 链接,因为看到之前显示的完整链接可能会使用户产生怀疑。
然而,当不针对特定用户和大型站点时,例如数百万人使用的支付处理系统,攻击者通常不太具体。嵌入在发送给数百万人的诱人垃圾邮件中的有毒链接可能会让登录支付系统的人点击几下,从而为攻击者带来收入。
尝试使用前面给出的请求代码生成链接。然后点击它,无论你是否登录到支付网站。
当您登录后,您应该会发现——每次重新加载主页时——会出现另一笔付款,由您访问过的链接本身自动为您执行。在 Firefox 或 Google Chrome 中按 Ctrl+U,可以看到 JavaScript 和周围的<script>标签已经完整地进入页面。
如果您发现攻击不起作用,请在浏览器中打开 JavaScript 控制台。我的 Chrome 版本足够复杂,能够检测并取消攻击:“XSS 审计员拒绝执行脚本...因为在请求中找到了它的源代码。只有当这种保护被关闭或者如果攻击者找到一种更邪恶的方法来利用 flash 消息,一个好的现代浏览器才能被这里发起的攻击的原始版本所欺骗。
即使攻击成功,出现一个没有任何信息的空白绿色消息框可能会让用户感到可疑。作为一个练习,尝试修复之前 URL 中的这个缺陷:在脚本标记之外,看看是否可以提供一点像“欢迎回来”这样的真实文本,这将使绿色消息区域看起来更容易被接受。
对清单 11-8 中攻击的防御是从 URL 中完全删除 flash 消息——这一点关于/pay表单刚刚完成的内容的上下文信息,应用希望在用户访问的下一个页面上显示。相反,您可以将 flash 消息保存在服务器端,直到下一个请求到来。像大多数框架一样,Flask 已经在它的函数对flash()和get_flashed_messages()中为此提供了一种机制。
持久的跨站点脚本
如果不能通过又长又难看的 URL 设置 flash 消息,攻击者将不得不通过其他机制注入 JavaScript。浏览主页时,他们的目光可能会落在显示付款的备忘录字段上。他们可以在备忘录中输入什么字符?
当然,让备忘录出现在你的页面上比在他们可以匿名提供给你的 URL 中提供备忘录要困难得多。攻击者必须使用伪造的凭证在网站上注册,或者侵入另一个用户的账户,以便向您发送一笔付款,该付款的 Memo 字段包含清单 11-7 中的元素和 JavaScript。
你可以自己注入这样的代码。使用你在清单 11-2 中看到的密码,以sam的身份登录应用,然后试着给我发一笔付款。附上一张漂亮的小纸条,说明你很喜欢这本书,并且给了我额外的小费。这样,我就不会怀疑你的付款了。一旦您添加了脚本元素,但在您单击“发送资金”之前,字段将类似于以下内容:
To account: brandon
Dollars: 1
Memo: A small thank-you.<script>...</script>
现在按提交按钮。然后注销,以brandon的身份重新登录,开始点击 Reload。每当brandon用户访问首页时,又会从他的账户中支付一笔费用!
如您所见,这种持续版本的跨站点脚本攻击非常强大。虽然之前创建的链接只有在用户点击时才起作用,但持久版本 JavaScript 现在以不可见的方式出现,并在用户每次访问网站时运行——将反复出现,直到服务器上的数据被清除或删除。当 XSS 攻击通过易受攻击网站上的公共形式消息发起时,它们已经影响了成百上千的用户,直到最终被修复。
清单 11-2 易受这个问题攻击的原因是它的作者在没有真正理解 Jinja2 模板的情况下使用了它们。他们的文件清楚地表明,他们不会自动逃脱。只有你知道打开它的转义,Jinja2 才会保护像<和>这样在 HTML 中比较特殊的字符。
清单 11-8 将通过 Flask render_template()函数调用 Jinja2 来抵御所有 XSS 攻击,当它看到模板文件名以扩展名html结尾时,将自动打开 HTML 转义。通过依赖 web 框架的通用模式,而不是自己做事情,您可以选择能够保护您免受不谨慎的设计决策影响的模式。
跨站请求伪造
现在,您的网站上的所有内容都被正确地屏蔽了,XSS 攻击应该不再是一个问题。但是攻击者还有一个锦囊妙计:试图从一个完全不同的站点提交表单,因为他们没有理由从您的站点启动表单。他们可以提前预测所有字段值需要是什么,因此他们可以从您可能访问的任何其他网页自由地向/pay发出请求。
他们所要做的就是邀请你访问一个他们隐藏了 JavaScript 的页面,或者如果他们发现你在一个没有正确地从论坛评论中转义或删除脚本标签的站点上参与了一个论坛主题,就把它嵌入到一个评论中。
您可能认为攻击者需要构建一个准备好向他们汇款的表单,然后让它的按钮成为鼠标的诱人目标。
<form method="post" action="http://localhost:5000/pay">
<input type="hidden" name="account" value="sam">
<input type="hidden" name="dollars" value="220">
<input type="hidden" name="message" value="Someone won big">
<button type="submit">Reply</button>
</form>
然而,由于你的浏览器可能打开了 JavaScript,他们可能只需将清单 11-7 中的<script>元素插入到你要加载的页面、论坛帖子或页面评论中,然后坐等付款出现在他们的账户中。
这是一种典型的跨站点请求伪造 (CSRF)攻击,它不需要攻击者想出如何破坏支付系统。所需要的是易于与世界上任何地方的任何网站组合的支付表单,攻击者可以在该网站上添加 JavaScript,并且您可能会访问该网站。您访问的每个网站都需要安全,以防范这种注射的可能性。
因此,应用需要防范它。
应用如何防止 CSRF 攻击?通过使表格难以填写和提交。他们需要一个额外的包含秘密的字段,只有表单的合法用户或他们的浏览器才能看到,而不是用支付所需的最少字段来制作简单的表单;它不需要对通过浏览器阅读和使用表单的用户可见。因为攻击者不知道任何特定用户在他们提交的每个/pay表单中隐藏的值,所以攻击者无法伪造一个服务器会相信的到那个地址的帖子。
同样,清单 11-8 将使用 Flask 将秘密安全放入 cookie 的能力,在每次用户登录时为他们分配一个秘密随机字符串。当然,这个例子要求你想象一个支付站点在现实生活中会受到 HTTPS 的保护,这样在网页或 cookie 中传递秘密是安全的,在传输过程中不会被发现。
选择了每个会话的随机秘密后,支付网站可以将它无形地添加到呈现给用户的每个/pay表单中。出于 CSRF 保护等原因,隐藏表单域是 HTML 的一个内置特性。以下字段被添加到pay2.html中的表格,替换清单 11-8 将使用的清单 11-6 :
<input name="csrf_token" type="hidden" value="{{ csrf_token }}">
每次提交表单时都会进行额外的检查,以确保表单中的 CSRF 值与表单的 HTML 版本中提交给用户的值相匹配。如果它们不匹配,那么站点认为攻击者试图代表用户提交表单,并以 403 Forbidden 拒绝该尝试。
清单 11-8 中的 CSRF 保护是手动完成的,这样你可以看到移动的部分,并理解随机选择的额外字段是如何让攻击者无法猜测如何构建一个有效的表单的。在现实生活中,您应该会发现 CSRF 保护内置于您选择的任何 web 框架中,或者至少作为一个标准插件提供。Flask 社区提出了几种方法,其中一种内置于流行的 Flask-WTF 库中,用于构建和解析 HTML 表单。
改进的应用
清单 11-8 中的的名字是app_improved.py,并不“完美”或“安全”,因为坦率地说,很难证明任何特定的示例程序真的完全没有可能的漏洞。
清单 11-8 。app_improved.py付款申请
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter11/app_improved.py
# A payments application with basic security improvements added.
import bank, uuid
from flask import (Flask, abort, flash, get_flashed_messages,
redirect, render_template, request, session, url_for)
app = Flask(__name__)
app.secret_key = 'saiGeij8AiS2ahleahMo5dahveixuV3J'
@app.route('/login', methods=['GET', 'POST'])
def login():
username = request.form.get('username', '')
password = request.form.get('password', '')
if request.method == 'POST':
if (username, password) in [('brandon', 'atigdng'), ('sam', 'xyzzy')]:
session['username'] = username
session['csrf_token'] = uuid.uuid4().hex
return redirect(url_for('index'))
return render_template('login.html', username=username)
@app.route('/logout')
def logout():
session.pop('username', None)
return redirect(url_for('login'))
@app.route('/')
def index():
username = session.get('username')
if not username:
return redirect(url_for('login'))
payments = bank.get_payments_of(bank.open_database(), username)
return render_template('index.html', payments=payments, username=username,
flash_messages=get_flashed_messages())
@app.route('/pay', methods=['GET', 'POST'])
def pay():
username = session.get('username')
if not username:
return redirect(url_for('login'))
account = request.form.get('account', '').strip()
dollars = request.form.get('dollars', '').strip()
memo = request.form.get('memo', '').strip()
complaint = None
if request.method == 'POST':
if request.form.get('csrf_token') != session['csrf_token']:
abort(403)
if account and dollars and dollars.isdigit() and memo:
db = bank.open_database()
bank.add_payment(db, username, account, dollars, memo)
db.commit()
flash('Payment successful')
return redirect(url_for('index'))
complaint = ('Dollars must be an integer' if not dollars.isdigit()
else 'Please fill in all three fields')
return render_template('pay2.html', complaint=complaint, account=account,
dollars=dollars, memo=memo,
csrf_token=session['csrf_token'])
if __name__ == '__main__':
app.debug = True
app.run()
在我写这篇文章的时候,Shellshock 漏洞刚刚被公布:在过去的 22 年里,没有人注意到,广泛使用的 Bash shell 愿意运行作为特殊格式的环境变量呈现给它的任何代码——就像旧的 CGI 机制很乐意根据输入的不可信 HTTP 头设置的那些代码。如果在二十多年后,主要的生产软件可能容易受到意想不到的特性和交互的攻击,那么我很难保证我专门为本章编写的演示 web 应用的绝对安全性。
但这是清单。它的模板进行了适当的转义,它使用内部存储来存储 flash 消息,而不是通过用户的浏览器来回发送它们,并且在它呈现给用户的每个表单中隐藏了一个随机 UUID,使得它们不可能被伪造。
请注意,两个主要的改进——切换到内部存储的 flash 消息和要求 Jinja2 在将字符添加到 HTML 之前进行适当的字符转义——是通过使用 Flask 中已经内置的标准机制而不是依赖我自己的代码实现的。
这说明了重要的一点。如果您通读框架文档并尽可能多地利用它的特性,那么您的应用不仅通常会更短、更简洁、更方便编写,而且通常会更安全,因为您将使用由专业人员编写并由 web 框架的整个社区精心改进的模式。在许多情况下,这些便利将解决您可能甚至没有意识到的安全性或性能问题。
当应用与网络交互时,它现在已经相当自动化了。但是当涉及到视图和表单的处理时,仍然有许多漏洞。
代码必须手动检查用户是否登录。每个表单字段都需要从请求中手动复制到 HTML 中,这样用户就不需要重新输入。与数据库的对话是令人失望的低层次;如果您希望 SQLite 永久记录付款,您必须自己打开数据库会话,然后记得提交。
Flask 社区中有很好的最佳实践和第三方工具,您可以求助于它们来解决这些常见的模式。相反,为了多样化,最后一个例子将是在一个框架中编写的同一个应用,它从第一天起就把这些责任从你身上拿走了。
Django 中的支付应用
Django web 框架可能是当今 Python 程序员中最受欢迎的,因为它是一个“全栈”web 框架,内置了编程新手需要的所有东西。Django 不仅有一个模板系统和 URL 路由框架,而且它还可以为您与数据库对话,将结果呈现为 Python 对象,甚至在不需要单一第三方库的情况下编写和解释表单。在一个许多 Web 编程人员几乎没有受过培训的世界里,一个建立一致和安全模式的框架可能比一个更灵活的工具更有价值,这种工具让程序员寻找他们自己的 ORM 和表单库,而他们可能甚至不清楚这些部分如何组合在一起。
您可以在本书的源代码库中找到完整的 Django 应用。同样,这是本章的网址:
https://github.com/brandon-rhodes/fopnp/tree/m/py3/chapter11
有几个样板文件不值得在这本书的页面中全文引用。
manage.py:这是一个位于chapter11/目录中的可执行脚本,允许您运行 Django 命令,以开发模式设置和启动应用,稍后您将看到这一点。djbank/__init__.py:这是一个空文件,告诉 Python 这个目录是一个 Python 包,可以从这个包中加载模块。djbank/admin.py:这包含三行代码,使Payment模型出现在管理界面中,如下面的“选择 Web 框架”一节所述。djbank/settings.py:它包含管理应用如何加载和运行的插件和配置。我对 Django 1.7 编写的缺省值所做的唯一更改是最后一行,它将 Django 指向主chapter11/目录中的static/文件目录,这样 Django 应用就可以共享由清单 11-2 和清单 11-8 使用的同一个style.css文件。djbank/templates/*.html:页面模板比清单 11-3 到 11-6 中显示的 Jinja2 模板要简单一些,因为 Django 模板语言不太方便,功能也不太强大。但是,因为基本语法是相同的,所以差异不值得在本书中讨论。如果您想了解细节,请查阅两个模板系统的文档。- 这提供了一个 WSGI callable,一个符合 WSGI 的 web 服务器,无论是 Gunicorn 还是 Apache(见第十章)都可以调用它来启动和运行支付应用。
剩下的四个文件很有趣,因为框架不需要任何扩展,就已经支持许多 Python 代码可以利用的常见模式。
由于内置了对象关系映射器(ORM) ,Django 免除了应用必须知道如何编写自己的 SQL 查询的麻烦。正确引用 SQL 值的整个问题也随之消失了。清单 11-9 通过在一个声明性的 Python 类中列出其字段来描述数据库表,该类将用于在返回时表示表行。如果您的数据限制超出了仅由字段类型所能表达的范围,Django 允许您将复杂的验证逻辑附加到这样的类上。
清单 11-9 。Django 应用的models. py
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter11/djbank/models.py
# Model definitions for our Django application.
from django.db import models
from django.forms import ModelForm
class Payment(models.Model):
debit = models.CharField(max_length=200)
credit = models.CharField(max_length=200, verbose_name='To account')
dollars = models.PositiveIntegerField()
memo = models.CharField(max_length=200)
class PaymentForm(ModelForm):
class Meta:
model = Payment
fields = ['credit', 'dollars', 'memo']
底层的类声明告诉 Django 为创建和编辑数据库行准备一个表单。它将只询问用户列出的三个字段,将debit字段留给您从当前登录的用户名填写。正如您将看到的,这个类能够在 web 应用与用户的对话中面向两个方向:它可以将表单呈现为一系列 HTML <input>字段,然后它可以返回并解析表单提交后返回的 HTTP POST 数据,以便构建或修改Payment数据库行。
当你使用像 Flask 这样的微框架时,你必须选择一个外部库来支持这样的操作。例如,SQLAlchemy 是一个著名的 ORM,许多程序员选择不使用 Django,这样他们就可以享受 SQLAlchemy 的强大和优雅。
但是 SQLAlchemy 本身并不了解 HTML 表单,所以使用微框架的程序员需要找到另一个第三方库来完成前面的models.py文件为 Django 程序员所做的另一半工作。
Django 没有让程序员使用 Flask 风格的装饰器将 URL 路径附加到 Python 视图函数,而是让应用编写人员创建一个类似于清单 11-10 中的所示的urls.py文件。虽然这使得每个单独的视图在单独阅读时上下文少了一些,但它使每个视图独立于位置,并致力于集中控制 URL 空间。
清单 11-10 。Django 应用的urls. py
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter11/djbank/urls.py
# URL patterns for our Django application.
from django.conf.urls import patterns, include, url
from django.contrib import admin
from django.contrib.auth.views import login
urlpatterns = patterns('',
url(r'^admin/', include(admin.site.urls)),
url(r'^accounts/login/$', login),
url(r'^$', 'djbank.views.index_view', name='index'),
url(r'^pay/$', 'djbank.views.pay_view', name='pay'),
url(r'^logout/$', 'djbank.views.logout_view'),
)
Django 做了一个奇怪的决定,使用正则表达式匹配来匹配 URL,当一个 URL 包含几个可变部分时,这会导致难以阅读的模式。它们也很难调试,这是我的经验之谈。
这些模式基本上建立了与早期 Flask 应用相同的 URL 空间,除了到登录页面的路径是 Django 认证模块期望它所在的位置。这段代码依赖于标准的 Django 登录页面,而不是编写您自己的登录页面——并希望您写得正确,没有一些微妙的安全缺陷。
清单 11-11 中最终将 Django 应用联系在一起的视图比 Flask 版本的应用中相应的视图既简单又复杂。
清单 11-11 。Django 应用的views. py
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter11/djbank/views.py
# A function for each view in our Django application.
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth import logout
from django.db.models import Q
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods, require_safe
from .models import Payment, PaymentForm
def make_payment_views(payments, username):
for p in payments:
yield {'dollars': p.dollars, 'memo': p.memo,
'prep': 'to' if (p.debit == username) else 'from',
'account': p.credit if (p.debit == username) else p.debit}
@require_http_methods(['GET'])
@login_required
def index_view(request):
username = request.user.username
payments = Payment.objects.filter(Q(credit=username) | Q(debit=username))
payment_views = make_payment_views(payments, username)
return render(request, 'index.html', {'payments': payment_views})
@require_http_methods(['GET', 'POST'])
@login_required
def pay_view(request):
form = PaymentForm(request.POST or None)
if form.is_valid():
payment = form.save(commit=False)
payment.debit = request.user.username
payment.save()
messages.add_message(request, messages.INFO, 'Payment successful.')
return redirect('/')
return render(request, 'pay.html', {'form': form})
@require_http_methods(['GET'])
def logout_view(request):
logout(request)
return redirect('/')
你应该问的大问题是,跨站脚本保护在哪里?答案是,当我让 Django 用manage.py startapp命令为这个应用构建框架时,它被自动添加到settings.py中并打开!
你甚至不必知道 CSRF 保护的存在,你的表单将拒绝工作,除非你记得添加{% csrf_token %}到你的表单模板。如果你忘记了,Django 的开发模式显示的错误消息解释了这个需求。对于不了解所涉问题的新 web 开发人员来说,这是一个非常强大的模式:Django 的默认设置通常会保护他们免受表单和字段最常见的灾难性错误的影响,这是微框架很少能与之相比的。
这个应用中的视图在概念上比 Flask-powered 清单中的视图更简单,因为这段代码几乎依赖于内置的 Django 特性,而不是必须实现诸如登录和会话操作之类的东西。登录页面甚至没有出现,因为urls.py只是使用 Django 的。注销页面可以只调用logout()而不用担心它是如何工作的。视图可以用@login_required标记,不必担心用户是否登录。
与我们的 Flask 应用中的类似特性直接对应的唯一助手是 @require_http_methods() decorator,它为您提供了与 Flask 内置于其自己的视图 decorator 中的相同的保护,防止无效或不受支持的 HTTP 方法。
使用数据库现在非常简单。bank.py模块及其 SQL 已经完全消失了。Django 已经选择建立一个 SQLite 数据库——这是已经存在于settings.py中的默认数据库之一——并且它准备在代码从models.py文件中查询模型类的时候打开一个到数据库的会话。当代码在新的支付中调用save()时,它也会自动调用COMMIT,因为代码没有要求 Django 为您打开一个扩展的数据库事务。
付款表单的字段,因为表单被写成 HTML 格式,然后从 POST 参数中被拉回来,就这样消失了。按照要求,它没有指定debit字段,以便代码可以用当前用户名填充它。但是 Django 表单库会为您处理所有其他事情。
一个尴尬的地方是,真正属于模板的一点逻辑——围绕主页上支付显示的文字和表示的选择——现在不得不移到 Python 代码中,因为 Django 模板系统没有使逻辑易于表达。但是 Python 为您提供了相当简单的选择:index()视图调用一个生成器来生成关于每笔付款的信息的dict,将原始对象转换成模板感兴趣的值。
一些程序员对这种功能不足的模板系统感到恼火。其他人学习如何编写 Django“模板标签”,让他们从模板内部调用自己的逻辑。还有一些开发人员认为像清单 11-11 这样的代码从长远来看是最好的,因为为make_payment_views()这样的例程编写测试比为困在模板中的逻辑编写测试更容易。
要运行这个 Django 应用,请查看前面给出的链接中的第十一章源代码,在 Python 3 下安装 Django 1.7,并运行以下三个命令:
$ python manage.py syncdb
$ python manage.py loaddata start
$ python manage.py runserver
最后一个命令启动并运行后,您可以访问http://localhost:8000/并了解 Django 如何让您构建与本章前面使用 Flask 构建的应用非常相似的应用。
选择 Web 框架
web 框架的前景总是在像 Python 编程语言这样强大而健康的社区中不断创新。尽管这本书在短短几年内可能会显得过时,但这里有一个最流行框架的快速调查,以便让您对典型开发人员面临的选择有所了解:
- Django :初学 web 程序员的好框架。内置了 CSRF 保护等功能。它的 ORM 和模板语言是内置的。这不仅让业余爱好者不必自己选择单独的库,还意味着所有第三方 Django 工具都可以采用一组通用的接口来处理 HTML 和数据库。Django 以其管理界面而闻名——运行清单 11-11 后,尝试访问
/admin页面,查看管理员如何通过自动生成的创建、编辑和删除表单直接与数据库交互的示例! - Tornado :一个与这里列出的其他框架不同的 web 框架,因为它使用第九章中的异步回调模式,允许每个操作系统线程支持几十或几百个客户端连接,而不是每个线程只支持一个客户端。它的突出之处还在于它不依赖于对 WSGI 的支持——它直接支持 Web 套接字(在下一节中描述)。代价是许多库很难使用它的回调模式,所以程序员不得不寻找通常的 ORM 或数据库连接器的异步替代品。
- 烧瓶 :最流行的微框架,建立在可靠的工具之上,支持许多现代特性(如果程序员知道寻找并利用它们的话)。通常与 SQLAlchemy 或非关系数据库后端结合使用。
- 瓶 :瓶的替代物,适合在一个单独的文件
bottle.py中,而不需要安装几个单独的包。对于那些还没有在工作流程中使用 pip 安装工具的开发人员来说尤其具有吸引力。它的模板语言设计得特别好。 - Pyramid :这是一个卓越的高性能综合,总结了旧的 Zope 和 Pylons 社区中的社区成员所学到的经验教训,是开发人员在流动的 URL 空间中工作的首选框架,就像您创作一个内容管理系统 (CMS)时所创建的那样,用户只需点击鼠标就可以创建子文件夹和附加网页。虽然它可以支持预定义的 URL 结构以及任何以前的框架,但它可以通过支持对象遍历走得更远,其中框架本身理解您的 URL 组件正在命名 URL 正在访问的容器、内容和视图,就像文件系统路径在到达文件之前访问目录一样。
你可能会被它的名声所诱惑而选择一个 web 框架——也许是基于前面的段落,加上对他们网站的仔细阅读和你在社交媒体网站或 Stack Overflow 上看到的。
但是我将建议一个更重要的方向:如果您在本地 Python meetup 上有同事或朋友,他们已经是某个框架的支持者,并且可以通过电子邮件或 IRC 定期向您提供帮助,那么您可能希望选择该框架,而不是您更喜欢其网站或特性列表的类似框架。拥有一个已经经历过典型错误信息和误解的人的实时帮助,通常可以胜过框架的某个特定特性是否稍微更难使用。
WebSockets
由 JavaScript 驱动的网站通常希望支持内容的实时更新。如果有人发推文,那么 Twitter 希望更新你正在查看的页面,而不需要浏览器每秒轮询一次来询问是否有新内容出现。Websocket 协议(RFC 6455)是解决这个“长轮询问题”的最强大的解决方案。
早期的解决方法是可能的,比如著名的彗星技术。一种 Comet 技术是让客户机向路径发出 HTTP 请求;作为响应,服务器挂起,让套接字保持打开,并等待响应,直到实际事件(比如新的传入 tweet)最终发生并可以在响应中传递。
因为 WSGI 只支持传统的 HTTP,所以为了支持 WebSockets,您必须跳出标准 web 框架和所有兼容 WSGI 的 web 服务器的范围,比如 Gunicorn、Apache 和 nginx。
WSGI 不能做 WebSockets 的事实是独立的 Tornado 服务器框架流行的一个主要原因。
HTTP 以锁步方式运行,客户端首先发出一个请求,然后等待服务器完成响应,然后再发出另一个请求,而切换到 WebSockets 模式的套接字支持消息在任何时候双向传输,无需等待对方。当用户在屏幕上移动与网页交互时,客户机可以向服务器发送实时更新,而服务器同时发送来自其他来源的更新。
在网络上,一个 WebSocket 会话开始于一个看起来像 HTTP 请求和响应的内容,但是在它们的头和状态代码中,它们正在协商一个离开套接字上的 HTTP 的切换。一旦切换完成,一个新的数据组帧系统就会接管,详见 RFC。
WebSocket 编程通常涉及前端 JavaScript 库和服务器上运行的代码之间的大量协调工作,这不在本书讨论范围之内。一个简单的起点是tornado.websocket模块的文档,其中包括一段 Python 和 JavaScript 代码,它们可以通过一对对称的回调函数相互对话。查看任何关于异步前端浏览器编程的好参考资料,了解如何使用这种机制来动态更新网页。
网页抓取
通过尝试创建一个网站开始其 web 编程生涯的程序员的数量可能比通过编写自己的示例站点开始的程序员的数量要多得多。毕竟,与那些很容易想到他们想要复制的已经在网上的数据的人相比,有多少初学编程的人能够接触到大量等待在网上显示的数据呢?
关于网络抓取的第一条建议是:尽可能避免!
除了 raw scraping 之外,获取数据的方式往往还有很多。使用这样的数据源不仅对程序员来说更便宜,而且对站点本身来说也更便宜。互联网电影数据库将允许你从www.imdb.com/interfaces下载电影数据,这样你就可以运行好莱坞电影的统计数据,而不必强迫主网站呈现成千上万的额外页面,然后强迫你解析它们!许多网站,如 Google 和 Yahoo,为它们的核心服务提供 API,可以帮助你避免返回原始的 HTML。
如果谷歌搜索了你想要的数据,但没有找到任何下载或 API 的替代品,有一些规则要记住。搜索你的目标网站是否有一个“服务条款”页面。还要检查一个/robots.txt文件,它会告诉你哪些网址是为搜索引擎下载而设计的,哪些应该避免。这可以帮助你避免得到同一篇文章的几个副本,但是有不同的广告,同时也帮助网站控制它所面临的负载。
遵守服务条款和robots.txt也可以使你的 IP 地址不太可能因为提供过多的流量而被阻止。
在大多数情况下,抓取一个网站需要你在第九章第一节学过的所有知识,第十章第三节学过的 ??,以及这一章关于 ?? 的 HTTP 和网络浏览器使用它的方式。
- GET 和 POST 方法以及方法、路径和头如何组合形成 HTTP 请求
- HTTP 响应的状态代码和结构,包括成功、重定向、暂时失败和永久失败之间的区别
- 基本 HTTP 身份验证—服务器响应如何要求身份验证,然后在客户端请求中提供身份验证
- 基于表单的身份验证以及它如何设置 cookiess,这些 cookie 需要出现在您的后续请求中才能被判断为可信
- 基于 JavaScript 的身份验证,登录表单直接回发到 web 服务器,而不需要浏览器本身参与表单提交
- 当你浏览网站时,隐藏的表单域,甚至新的 cookies,可以在 HTTP 响应中提供,以保护网站免受 CSRF 攻击
- 将数据附加到 URL 并对该位置执行 GET 的查询或操作与将数据直接发送到服务器并作为请求体传送的操作之间的区别
- 为来自浏览器的表单编码数据而设计的 POST URLs 与为与前端 JavaScript 代码直接交互而设计的 URL 之间的对比,因此可能期望并返回 JSON 或其他程序员友好格式的数据
抓取一个复杂的网站通常需要数小时的实验、调整,以及长时间点击浏览器的 web 开发工具来了解正在发生的事情。三个选项卡是必不可少的,一旦你右击一个页面并选择 Inspect Element,这三个选项卡在 Firefox 或 Google Chrome 中都应该可用。元素选项卡(参见图 11-1 )向您显示动态文档,即使 JavaScript 已经添加和删除了一些东西,以便您可以了解哪些元素存在于哪些其他元素中。网络选项卡(参见图 11-2 )让你点击重新加载并查看 HTTP 请求和响应——甚至是那些由 JavaScript 启动的请求和响应——它们一起提供了一个完整的页面。控制台可以让您看到页面遇到的任何错误,包括那些您作为用户可能没有意识到的错误。
程序员处理两种常见的自动化风格。
第一种是你撒网的地方,因为有大量的数据要下载。除了初始登录步骤获取所需 cookies 的可能性之外,这种任务往往涉及重复的 get 操作,当您从正在下载的页面中读取链接时,这些操作可能会进一步引发 GET。这种模式与网络搜索引擎用来了解每个网站上存在的页面的“蜘蛛”程序所采用的模式相同。
这些程序的术语“蜘蛛”来自早期,那时术语“??”网“??”仍然让人们想到蜘蛛网。
另一种风格是当你只在一两个页面上执行一个特定的目标动作,而不是想要一个网站的整个部分。这可能是因为您只需要来自特定页面的数据—可能您希望您的 shell 提示符打印来自特定天气页面的温度—或者因为您正在尝试自动执行通常需要浏览器的操作,例如向客户付款或列出昨天的信用卡交易,以便您可以查找欺诈。这通常需要对点击、表单和认证更加谨慎,并且通常需要一个成熟的浏览器来运行,而不是由 Python 本身来运行,因为银行使用页面内 JavaScript 来阻止未经授权访问账户的自动尝试。
记得在考虑释放一个自动化程序来对付它之前,检查服务条款和网站的文件。如果你的程序的行为——即使它陷入了你没有预料到的边缘情况——变得明显比普通用户点击他们停下来浏览或阅读的页面更加苛刻,你也要做好被阻止的准备。
我甚至不打算谈论 OAuth 和其他使程序员更难运行程序来完成程序员需要浏览器才能完成的事情的策略。当涉及到不熟悉的操作或协议时,尽可能多地从第三方库中寻求帮助,并仔细观察您的传出邮件头,尽量使它们与您发布表单或使用浏览器成功访问页面时看到的邮件头完全匹配。甚至用户代理领域也很重要,这取决于网站的固执己见程度!
获取页面
有三种从 Web 获取页面的方法,这样您就可以在 Python 程序中检查它们的内容。
- 使用 Python 库发出直接的 GET 或 POST 请求。使用 Requests 库作为您的首选解决方案,向它请求一个
Session对象,这样它就可以跟踪 cookies 并为您进行连接池。如果您想留在标准库中,低复杂性情况的一个替代方法是urllib.request。 - 曾经有一种中间地带的工具,它们可以像原始的 web 浏览器一样工作,它们可以找到
<form>元素,并帮助您使用浏览器将表单输入传递回服务器所使用的相同规则来构建 HTTP 请求。Mechanize 是最著名的,但是我没有发现它被维护过——可能是因为现在很多网站已经足够复杂,以至于 JavaScript 几乎是浏览现代网络的一个要求。 - 你可以使用真正的网络浏览器。在接下来的例子中,您将使用 Selenium Webdriver 库来控制 Firefox,但是使用“无头”工具的实验也在进行中,这些工具可以像浏览器一样工作,而不必打开整个窗口。它们通常通过创建一个不连接到实际窗口的 WebKit 实例来工作。PhantomJS 使这种方法在 JavaScript 社区中流行起来,而
Ghost.py是当前将这种能力引入 Python 的一个实验。
如果你已经知道你想要访问的网址,你的算法会很简单。获取 URL 列表,对每个 URL 运行 HTTP 请求,并保存或检查其内容。只有当你事先不知道 URL 列表,并且需要边走边学的时候,事情才会变得复杂。然后你需要跟上你去过的地方,这样你就不会访问一个 URL 两次,然后永远循环下去。
清单 11-12 显示了一个目标明确的刮刀的适度例子。它旨在登录到支付应用,并报告用户已经获得的收入。在运行它之前,在一个窗口中启动支付程序的副本。
$ python app_improved.py
清单 11-12 。登录支付系统并累加收入
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter11/mscrape.py
# Manual scraping, that navigates to a particular page and grabs data.
import argparse, bs4, lxml.html, requests
from selenium import webdriver
from urllib.parse import urljoin
ROW = '{:>12} {}'
def download_page_with_requests(base):
session = requests.Session()
response = session.post(urljoin(base, '/login'),
{'username': 'brandon', 'password': 'atigdng'})
assert response.url == urljoin(base, '/')
return response.text
def download_page_with_selenium(base):
browser = webdriver.Firefox()
browser.get(base)
assert browser.current_url == urljoin(base, '/login')
css = browser.find_element_by_css_selector
css('input[name="username"]').send_keys('brandon')
css('input[name="password"]').send_keys('atigdng')
css('input[name="password"]').submit()
assert browser.current_url == urljoin(base, '/')
return browser.page_source
def scrape_with_soup(text):
soup = bs4.BeautifulSoup(text)
total = 0
for li in soup.find_all('li', 'to'):
dollars = int(li.get_text().split()[0].lstrip('$'))
memo = li.find('i').get_text()
total += dollars
print(ROW.format(dollars, memo))
print(ROW.format('-' * 8, '-' * 30))
print(ROW.format(total, 'Total payments made'))
def scrape_with_lxml(text):
root = lxml.html.document_fromstring(text)
total = 0
for li in root.cssselect('li.to'):
dollars = int(li.text_content().split()[0].lstrip('$'))
memo = li.cssselect('i')[0].text_content()
total += dollars
print(ROW.format(dollars, memo))
print(ROW.format('-' * 8, '-' * 30))
print(ROW.format(total, 'Total payments made'))
def main():
parser = argparse.ArgumentParser(description='Scrape our payments site.')
parser.add_argument('url', help='the URL at which to begin')
parser.add_argument('-l', action='store_true', help='scrape using lxml')
parser.add_argument('-s', action='store_true', help='get with selenium')
args = parser.parse_args()
if args.s:
text = download_page_with_selenium(args.url)
else:
text = download_page_with_requests(args.url)
if args.l:
scrape_with_lxml(text)
else:
scrape_with_soup(text)
if __name__ == '__main__':
main()
一旦这个 Flask 应用在端口 5000 上运行,您就可以在另一个终端窗口中启动mscrape.py。先安装漂亮的 Soup 第三方库,如果你的系统上没有,你也会需要请求。
$ pip install beautifulsoup4
$ pip install requests
$ python mscrape.py http://127.0.0.1:5000/
125 Registration for PyCon
200 Payment for writing that code
-------- ------------------------------
325 Total payments made
像这样在默认模式下运行,mscrape.py首先使用请求库通过登录表单登录到站点。这将为Session对象提供成功获取首页所需的 cookie。然后,该脚本解析页面,获取用类to标记的列表项元素,并通过几个print()调用显示这些支出,同时将这些支出相加。
通过提供-s选项,您可以切换mscrape.py,让它做一些更令人兴奋的事情:运行 Firefox 的完整版本,如果它发现您的系统上安装了 Firefox,就访问网站!您将需要安装 Selenium 包才能使用这种模式。
$ pip install selenium
$ python mscrape.py -s http://127.0.0.1:5000/
125 Registration for PyCon
200 Payment for writing that code
-------- ------------------------------
325 Total payments made
一旦脚本打印出输出,您可以按 Ctrl+W 来关闭 Firefox。虽然您可以编写 Selenium 脚本,让它们自动关闭 Firefox,但我更喜欢在编写和调试时让它保持打开,这样我就可以在程序出错时看到浏览器中出现了什么问题。
这两种方法之间的差异值得强调。要编写使用请求的代码,您需要自己打开网站,研究登录表单,并将您在那里找到的信息复制到post()方法用来登录的数据中。一旦这样做了,您的代码就无法知道登录表单将来是否会改变。它将简单地继续使用硬编码的输入名称'username'和'password',不管它们是否仍然相关。
所以,请求方法,至少在这样写的时候,真的不像浏览器。打开登录页面并在那里看到一个表单是没有意义的。更确切地说,它假设登录页面存在,并绕过它运行一次,以发布作为其结果的表单。显然,如果登录表单被赋予了一个秘密令牌来防止大量猜测用户密码的尝试,这种方法就会失效。在这种情况下,您将需要添加对/login页面本身的第一个 GET,以获取需要与您的用户名和密码相结合的秘密令牌,从而生成有效的帖子。
mscape.py中的基于硒的代码采取了相反的方法。就像用户坐在浏览器前一样,它的行为就好像只是看到一个表单,选择它的元素并开始输入。然后它到达并点击按钮提交表单。只要它的 CSS 选择器继续正确识别表单字段,代码就能成功登录,不管有什么秘密标记或特殊的 JavaScript 代码来签署或自动化表单发布,因为 Selenium 只是在 Firefox 中做与登录完全一样的事情。
当然,Selenium 比 Requests 慢得多,尤其是当您第一次启动它并且必须等待 Firefox 启动时。但是它可以快速执行一些操作,否则使用 Python 可能需要几个小时的实验。一种解决困难的抓取工作的有趣方法可以是一种混合方法:您能否使用 Selenium 登录并获得必要的 cookies,然后告诉请求关于它们的信息,这样您对更多页面的大量获取就不需要在浏览器上等待了?
抓取页面
当一个站点以 CSV、JSON 或其他一些可识别的数据格式返回数据时,您当然会使用标准库中或第三方库中的相应模块对其进行解析,以便能够对其进行处理。但是如果您需要的信息隐藏在面向用户的 HTML 中呢?
在谷歌 Chrome 或 Firefox 中按下 Ctrl+U 后阅读原始 HTML 可能会很累,这取决于网站选择的格式。右键单击,选择 Inspect Element,然后愉快地浏览浏览器看到的可折叠的元素文档树,这通常更令人愉快——假设 HTML 格式正确,并且标记中的错误没有隐藏浏览器中需要的数据!正如您已经看到的,live element inspector 的问题是,当您看到文档时,在网页中运行的任何 JavaScript 程序可能已经将它编辑得面目全非了。
查看这样的页面至少有两个简单的技巧。第一种方法是关闭浏览器中的 JavaScript,然后点击重新加载您正在阅读的页面。它现在应该会重新出现在元素检查器中,但没有进行任何更改:您应该会看到 Python 代码在下载相同文档时会看到的内容。
另一个技巧是使用某种“整洁”的程序,就像 W3C 发布的,可以在 Debian 和 Ubuntu 上以tidy包的形式获得。原来清单 11-12 中使用的两个解析库都内置了这样的例程。一旦soup对象存在,您就可以使用以下有用的缩进在屏幕上显示它的元素:
print(soup.prettify())
lxml 文档树需要更多的工作来显示。
from lxml import etree
print(etree.tostring(root, pretty_print=True).decode('ascii'))
无论哪种方式,如果提供 HTML 的站点没有将元素放在单独的行上并缩进它们以使它们的文档结构清晰,结果可能比原始 HTML 更容易阅读——当然,这些步骤可能不方便,并且会增加任何提供 HTML 的站点的带宽需求。
检查 HTML 包括以下三个步骤:
- 要求您选择的库解析 HTML。这对图书馆来说可能很困难,因为网络上的许多 HTML 包含错误和损坏的标记。但是设计者通常不会注意到这一点,因为浏览器总是试图恢复和理解标记。毕竟,哪个浏览器供应商会希望自己的浏览器是唯一一个返回某个流行网站错误的浏览器,而其他所有浏览器都正常显示呢?清单 11-12 中使用的两个库都以健壮的 HTML 解析器而闻名。
- 使用选择器进入文档,这些选择器是文本模式,会自动找到您想要的元素。虽然您可以自己动手,慢慢地遍历每个元素的子元素,寻找您感兴趣的标签和属性,但是使用选择器通常要快得多。它们通常还会产生更清晰、更易读的 Python 代码。
- 向每个元素对象询问所需的文本和属性值。然后,您又回到了普通 Python 字符串的世界,可以使用所有普通字符串方法对数据进行后处理。
这个三阶段过程在清单 11-12 中使用两个独立的库执行了两次。
scrape_with_soup()函数使用古老的 BeautifulSoup 库,这是全世界程序员的首选资源。它的 API 古怪而独特,因为它是第一个让 Python 中的文档解析如此方便的库,但它确实完成了任务。
所有的“soup”对象,无论是代表整个文档的对象还是代表单个元素的从属对象,都提供了一个find_all()方法,用于搜索与给定标签名和可选的 HTML 类名相匹配的从属元素。 get_text()方法可以在您最终到达您想要的底部元素并准备好读取其内容时使用。仅用这两种方法,代码就能从这个简单的网站中获取数据,甚至复杂的网站也常常只需六个或十几个单独的步骤就能完成。
完整的 BeautifulSoup 文档可在www.crummy.com/software/BeautifulSoup/在线获取。
scrape_with_lxml()函数使用了构建在 libxml2 和 libxslt 之上的现代快速 lxml 库。如果您使用的是没有安装编译器的传统操作系统,或者如果您没有安装操作系统可能支持编译的 Python 包的python-dev或python-devel包,那么安装起来可能会很困难。Debian 衍生的操作系统已经将针对系统 Python 编译的库作为一个包,通常简称为 python-lxml。
像 Anaconda 这样的现代 Python 发行版已经编译好了 lxml,可以安装在 Mac OS X 和 Windows 上。
如果你能够安装它,清单 11-12 可以使用这个库来解析 HTML。
$ pip install lxml
$ python mscrape.py -l http://127.0.0.1:5000/
125 Registration for PyCon
200 Payment for writing that code
-------- ------------------------------
325 Total payments made
同样,基本操作步骤与 BeautifulSoup 相同。您从文档的顶部开始,使用 find 或 search 方法——在本例中是cssselect()——来锁定您感兴趣的元素,然后使用进一步的搜索来获取从属元素,或者最后向元素询问它们所包含的文本,以便您可以解析和显示它。
lxml 不仅比 BeautifulSoup 快,而且它还提供了许多选择元素的选项。
- 它用
cssselect()支持 CSS 模式。这在按类查找元素时尤其重要,因为无论元素的类属性是写为class="x"、class="x y"还是class="w x",它都被认为是在类x中。 - 它用它的
xpath()方法支持 XPath 表达式,受到 XML 爱好者的喜爱。例如,它们看起来像'.//p'来查找所有段落。XPath 表达式的一个有趣的方面是,您可以用'.../text()'结束它,并简单地获取每个元素内部的文本,而不是获取 Python 对象,然后您必须请求它们内部的文本。 - 它本身通过其
find()和findall()方法支持 XPath 操作的快速子集。
请注意,在这两种情况下,scraper 必须做一些工作,因为付款描述字段是它自己的<i>元素,但网站设计者没有将每行开头的美元金额放在它自己的元素中。这是一个相当典型的问题;你想从页面中得到的一些东西会很方便地单独放在一个元素中,而另一些会放在其他文本的中间,需要你使用传统的 Python 字符串方法,比如split()和strip() 来把它们从上下文中解救出来。
递归抓取
这本书的源代码库包括一个小的静态网站,使得 web 抓取器很难到达它的所有页面。您可以在这里在线观看:
https://github.com/brandon-rhodes/fopnp/tree/m/py3/chapter11/tinysite/
如果您已经签出了源代码存储库,那么您可以使用 Python 的内置 web 服务器在您自己的机器上提供它。
$ cd py3/chapter11/tinysite
$ python -m http.server
Serving HTTP on 0.0.0.0 port 8000 ...
如果您查看页面源代码,然后使用浏览器的 web 调试工具环顾四周,您会发现并不是首页上的所有链接在http://127.0.0.1:8000/都是在同一时刻提交的。事实上,只有两个(“page1”和“page2”)在页面的原始 HTML 中作为具有href=""属性的真正锚标记出现。
接下来的两个页面位于带有搜索提交按钮的表单后面,除非单击该按钮,否则无法访问它们。
最后两个链接(“第 5 页”和“第 6 页”)出现在屏幕底部,是一小段动态 JavaScript 代码的结果。这模拟了网站的行为,这些网站快速地向您显示页面的框架,但是在您感兴趣的数据出现之前又向服务器做了一次往返。
在这一点上——你想对一个网站上的所有 URL 甚至只是其中的一部分进行全面的递归搜索——你可能想去寻找一个可以帮助你的网络抓取引擎。与 web 框架从 web 应用中提取常见模式的方式一样,比如需要为不存在的页面返回 404,抓取框架知道如何跟踪已经访问过的页面以及哪些页面仍需要访问。
目前最流行的网络抓取工具是 Scrapy ( http://scrapy.org/),如果你想尝试以一种适合其模型的方式描述一个抓取任务,你可以研究它的文档。
在清单 11-13 中,你可以看看幕后,看看一个真实的——如果简单的话——铲运机下面是什么样子。这个库需要 lxml,所以如果可能的话,安装第三方库,如前一节所述。
清单 11-13 。简单的递归 Web Scraper 得到了
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter11/rscrape1.py
# Recursive scraper built using the Requests library.
import argparse, requests
from urllib.parse import urljoin, urlsplit
from lxml import etree
def GET(url):
response = requests.get(url)
if response.headers.get('Content-Type', '').split(';')[0] != 'text/html':
return
text = response.text
try:
html = etree.HTML(text)
except Exception as e:
print(' {}: {}'.format(e.__class__.__name__, e))
return
links = html.findall('.//a[@href]')
for link in links:
yield GET, urljoin(url, link.attrib['href'])
def scrape(start, url_filter):
further_work = {start}
already_seen = {start}
while further_work:
call_tuple = further_work.pop()
function, url, *etc = call_tuple
print(function.__name__, url, *etc)
for call_tuple in function(url, *etc):
if call_tuple in already_seen:
continue
already_seen.add(call_tuple)
function, url, *etc = call_tuple
if not url_filter(url):
continue
further_work.add(call_tuple)
def main(GET):
parser = argparse.ArgumentParser(description='Scrape a simple site.')
parser.add_argument('url', help='the URL at which to begin')
start_url = parser.parse_args().url
starting_netloc = urlsplit(start_url).netloc
url_filter = (lambda url: urlsplit(url).netloc == starting_netloc)
scrape((GET, start_url), url_filter)
if __name__ == '__main__':
main(GET)
除了启动和读取其命令行参数的任务之外,清单 11-13 只有两个移动的部分。最简单的是它的GET()函数,尝试下载一个 URL,如果类型是 HTML 就尝试解析;只有在这些步骤成功的情况下,它才会获取所有锚标签(<a>)的href=""属性,以了解当前页面链接到的其他页面。因为这些链接中的任何一个都可能是相对 URL,所以它在每个链接上调用urljoin()来提供它们可能缺少的任何基本组件。
对于GET()函数在页面文本中发现的每个 URL,它返回一个元组,表明它希望抓取引擎在它发现的 URL 上调用自己,除非引擎知道它已经这样做了。
引擎本身只需要跟上它已经调用的函数和 URL 的组合,以便在网站上反复出现的 URL 只被访问一次。它保存一组它以前见过的 URL 和另一组还没有访问过的 URL,并继续循环,直到后一组最终为空。
你可以在一个大型公共网站上运行这个 scraper,比如 httpbin。
$ python rscrape1.py http://httpbin.org/
或者你可以在一个小的静态站点上运行它,这个站点的 web 服务器是你在几段前启动的——唉,这个抓取器只会找到两个链接,这两个链接实际上是由 HTTP 响应第一次提交的。
$ python rscrape1.py http://127.0.0.1:8000/
GET http://127.0.0.1:8000/
GET http://127.0.0.1:8000/page1.html
GET http://127.0.0.1:8000/page2.html
如果刮刀要看到更多,需要两种成分。
首先,您需要在真实的浏览器中加载 HTML,以便 JavaScript 可以运行并加载页面的其余部分。
第二,除了GET()之外,你还需要进行第二次操作,深呼吸,点击搜索按钮,看看它背后有什么。
这种操作在任何情况下都不应该成为自动抓取器的一部分,自动抓取器是用来从公共网站上抓取一般内容的,因为正如您现在所了解到的,表单提交是专门为用户操作设计的,尤其是在有 POST 操作支持的情况下。(在这种情况下,表单执行 GET,因此至少安全一点。)然而,在这种情况下,您已经研究了这个小网站,并得出结论,点击按钮应该是安全的。
请注意,清单 11-14 可以简单地重用前一个 scraper 的引擎,因为该引擎没有紧密地耦合到它应该调用什么函数的任何特定观点。它将调用作为工作提交给它的任何函数。
清单 11-14 。用 Selenium 递归抓取网站
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter11/rscrape2.py
# Recursive scraper built using the Selenium Webdriver.
from urllib.parse import urljoin
from rscrape1 import main
from selenium import webdriver
class WebdriverVisitor:
def __init__(self):
self.browser = webdriver.Firefox()
def GET(self, url):
self.browser.get(url)
yield from self.parse()
if self.browser.find_elements_by_xpath('.//form'):
yield self.submit_form, url
def parse(self):
# (Could also parse page.source with lxml yourself, as in scraper1.py)
url = self.browser.current_url
links = self.browser.find_elements_by_xpath('.//a[@href]')
for link in links:
yield self.GET, urljoin(url, link.get_attribute('href'))
def submit_form(self, url):
self.browser.get(url)
self.browser.find_element_by_xpath('.//form').submit()
yield from self.parse()
if __name__ == '__main__':
main(WebdriverVisitor().GET)
因为创建 Selenium 实例的成本很高——毕竟它们必须启动 Firefox 的副本——所以您不敢在每次需要获取 URL 时都调用Firefox()方法。相反,GET()例程在这里被编写为一个方法,而不是一个裸函数,这样浏览器属性可以从一个GET()调用延续到下一个,并且在您准备调用submit_form()时也是可用的。
submit_form()方法 是这个清单与前一个清单真正不同的地方。当GET()方法看到页面上的搜索表单时,它会向引擎发回一个附加的元组。除了为它在页面上看到的每个链接生成一个元组之外,它还会生成一个元组来加载页面并单击大搜索按钮。这就是为什么这个刮刀比前一个更深入这个地方。
$ python rscrape2.py http://127.0.0.1:8000/
GET http://127.0.0.1:8000/
GET http://127.0.0.1:8000/page1.html
GET http://127.0.0.1:8000/page2.html
submit_form http://127.0.0.1:8000/
GET http://127.0.0.1:8000/page5.html
GET http://127.0.0.1:8000/page6.html
GET http://127.0.0.1:8000/page4.html
GET http://127.0.0.1:8000/page3.html
因此,尽管有些链接是通过 JavaScript 动态加载的,而其他链接只能通过表单发布才能到达,scraper 还是能够找到网站上的每一个页面。通过这种强大的技术,您应该会发现您与任何网站的交互都可以通过 Python 实现自动化。
摘要
HTTP 的设计是为了提供万维网:一个由超链接互连的文档集合,每个文档都指定了另一个页面或页面的一部分的 URL,只需单击超链接的文本就可以访问该页面。Python 标准库提供了一些有用的例程,用于解析和构建 URL,以及将部分“相对 URL”转换为绝对 URL,方法是用来自出现它们的页面的基本 URL 的信息填充任何不完整的组件。
Web 应用通常使用响应传入 HTTP 请求的代码连接一些持久数据存储,如数据库,并作为响应构建 HTML 页面。当您试图从 Web 上插入不可信的信息时,让数据库自己进行引用是至关重要的,并且 DB-API 2.0 和您可能在 Python 中使用的任何 ORM 都会小心地正确进行引用。
Web 框架的范围从简单到全栈。有了一个简单的框架,你可以自己选择模板语言和 ORM 或其他持久层。一个全栈框架将提供这些工具的自己的版本。在这两种情况下,将 URL 连接到您自己的代码的一些方法将是可用的,这些方法既支持静态 URL,也支持像/person/123/这样的 URL,其路径组件可以变化。还将提供呈现和返回模板以及返回重定向或 HTTP 错误的快速方法。
每个网站作者面临的巨大危险是,在像 Web 这样的复杂系统中,组件交互的许多方式可能会让用户颠覆你自己的意图或彼此的意图。在外部世界和您自己的代码之间的接口上,跨站点脚本攻击、跨站点请求伪造和对您的用户隐私的攻击的可能性都必须牢记在心。在编写代码接受来自 URL 路径、URL 查询字符串、POST 或文件上传的数据之前,应该彻底了解这些危险。
框架之间的权衡通常是在一个像 Django 这样的全栈解决方案和一个像 Flash 或 Bottle 这样的解决方案之间进行选择,前者鼓励您使用它的工具集,但倾向于为您选择好的默认设置(比如在表单中自动打开 CSRF 保护),后者感觉更时尚、更轻便,并允许您组装自己的解决方案,但这需要您预先了解所需的所有部分。如果你在 Flask 中编写一个应用,只是不知道你需要 CSRF 保护,你将没有它。
Tornado 框架因其异步方法而脱颖而出,该方法允许从单个操作系统级别的控制线程为许多客户端提供服务。随着 Python 3 中asyncio的出现,像 Tornado 这样的方法有望朝着一组通用的习惯用法发展,就像今天 WSGI 已经为线程化 web 框架提供的那些习惯用法一样。
翻转和抓取网页需要对网站的正常工作方式有透彻的了解,这样通常的用户交互就可以被编写成脚本,包括登录、填写和提交表单等复杂操作。Python 中有几种获取页面和解析页面的方法。此时,用于获取的请求或 Selenium 和用于解析的 BeautifulSoup 或 lxml 是最受欢迎的。
因此,通过对 web 应用编写和抓取的研究,本书完成了对 HTTP 和万维网的覆盖。下一章开始了对 Python 标准库中支持的几个不太为人所知的协议的浏览,转到了电子邮件的主题以及它们是如何被格式化的。