Python-网络编程基础知识-全-

Python 网络编程基础知识(全)

原文:Foundations of Python Network Programming

协议:CC BY-NC-SA 4.0

零、简介

对于 Python 社区来说,这是一个激动人心的时刻。经过二十年的精心创新,这种语言在保持语法和概念简单的同时,获得了上下文管理器、生成器和理解等功能,Python 终于起飞了。

Python 不再被视为只能由谷歌和美国国家航空航天局(n as a)等顶级编程公司冒险开发的精品语言,而是正在被迅速采用,既包括传统的编程角色,如 web 应用设计,也包括“不情愿的程序员”的广阔世界,如科学家、数据专家和工程师——这些人学习编程不是为了编程本身,而是因为如果他们要在自己的领域取得进展,就必须编写程序。我认为,简单的编程语言为偶尔的或非专业的程序员提供的好处不能被夸大。

蟒蛇 3

在 2008 年首次亮相后,Python 3 经历了几年的修改和精简,才准备好扮演其前身的角色。但是随着它现在进入第二个五年,它已经成为 Python 社区中首选的创新平台。

无论是着眼于根本性的改进,如真正的 Unicode 文本现在是 Python 3 中的默认字符串类型,还是着眼于个别的改进,如正确支持 SSL,一个用于异步编程的内置asyncio框架,以及对大大小小的标准库模块的调整,Python 3 为网络程序员提供的平台几乎在各个方面都得到了改进。这是一项重大成就。Python 2 已经是让程序员在现代互联网上快速有效地工作的最佳语言之一。

这本书并不是从 Python 2 切换到 Python 3 的全面指南。它不会告诉您如何在旧的print语句中添加括号,将标准库模块导入重命名为新名称,或者调试有严重缺陷的网络代码,这些代码依赖于 Python 2 在字节字符串和 Unicode 字符串之间危险的自动转换——这些转换总是基于粗略的猜测。已经有很好的资源可以帮助您完成这种转变,甚至可以帮助您足够仔细地编写库,以便它们的代码可以在 Python 2 和 Python 3 下工作,以防您需要支持这两种受众。

相反,这本书侧重于网络编程,在 Python 提示符下,每个示例脚本和代码片段都使用 Python 3。这些例子旨在构建一幅全面的图像,说明如何利用该语言提供的工具最好地构建网络客户机、网络服务器和网络工具。读者可以通过将本书第二版每章中使用的脚本与第三版中的清单进行比较,来研究从 Python 2 到 Python 3 的过渡——这两个版本都可以在https://github.com/brandon-rhodes/fopnp/tree/m/获得,这要归功于在线提供源代码的优秀策略。接下来每一章的目标只是向您展示 Python 3 如何最好地用于解决现代网络编程问题。

通过直接关注如何用 Python 3 以正确的方式完成事情,这本书希望让准备从头开始编写新应用的程序员和准备将旧代码基础转换到新约定的程序员都做好准备。两位程序员都应该知道 Python 3 中正确的网络代码是什么样的,因此也应该知道他们的目标应该是什么样的代码。

此版本中的改进

除了将 Python 3 作为其目标语言,以及在过去五年中对标准库和第三方 Python 模块进行了许多更新之外,本书还尝试对之前的版本进行了一些改进。

  • 每个 Python 程序清单现在都被写成一个模块。也就是说,每个模块执行其导入并定义其函数或类,然后小心地保护一个if语句中的任何导入时动作,该语句仅在模块__name__具有特殊字符串值'__main__'时触发,该字符串值表示该模块作为主程序运行。这是 Python 的最佳实践,在本书的前一版本中几乎完全被忽略了,它的缺失使得示例清单更难被引入真正的代码库并用于解决读者的问题。通过将可执行逻辑放在左边,而不是放在一个if语句中,旧的程序清单可能节省了一两行代码,但是它们给 Python 程序员新手提供的如何编排真正代码的练习要少得多。
  • 本书中的大多数脚本现在使用标准库argparse模块来解释选项和参数,而不是专门使用原始的sys.argv字符串列表来解释命令行。这不仅阐明并记录了每个脚本在调用期间所期望的语义,还让每个脚本的用户在从 Windows 或 Unix 命令行启动脚本时使用–h--help查询选项来获得交互式帮助。
  • 程序清单现在通过在一个控制with语句中打开文件来努力执行适当的资源控制,当它完成时将自动关闭文件。在以前的版本中,大多数清单依赖于这样一个事实,即 Python 主网站上的 C Python 运行时通常会确保文件立即关闭,这要归功于它积极的引用计数。
  • 这些清单在很大程度上已经过渡到了执行字符串插值的现代format()方法,并远离了旧的模运算符 hack string % tuple,这在 20 世纪 90 年代是有意义的,当时大多数程序员都知道 C 语言,但对于今天进入该领域的新程序员来说可读性较差——功能也较弱,因为单个 Python 类不能像使用新类型那样覆盖百分比格式。
  • 关于 HTTP 和万维网的三章(第九章到第十一章)已经被从头开始重写,重点是更好地解释协议和介绍 Python 为程序员提供的最现代的工具。HTTP 协议的解释现在使用请求库作为执行客户端操作的 API,第十一章在 Flask 和 Django 中都有例子。
  • 关于 SSL/TLS 的材料(第六章)已经完全重写,以匹配 Python 3 为安全应用提供的支持方面的巨大改进。虽然 Python 2 中的ssl模块是一个弱的折中方案,它甚至不能验证服务器的证书是否与 Python 所连接的主机名相匹配,但是 Python 3 中的相同模块提供了一个设计更加精心、更加广泛的 API,它提供了对其特性的大量控制。

因此,就如何构造清单和示例而言,即使不考虑 Python 3 对该语言以前版本的改进,本书的这个版本也是学习程序员的更好资源。

网络游乐场

本书中程序列表的源代码可以在网上获得,因此本书的当前所有者和潜在读者都可以研究它们。这本书的每一章都有目录。你可以在这里找到章节目录:


https://github.com/brandon-rhodes/fopnp/tree/m/py3

但是程序清单对于支持好奇的网络编程学生来说只能到此为止。网络编程有许多特性很难在单台主机上探索。因此,本书的源代码库提供了一个由 12 台机器组成的示例网络,每台机器都实现为一个 Docker 容器。提供了一个安装脚本来构建映像、启动映像并将其联网。您可以在下面的源代码库中找到脚本和图像:


https://github.com/brandon-rhodes/fopnp/tree/m/playground

在图 i-1 中可以看到 12 台机器以及它们的相互连接。这个网络被设计成一个微型的互联网。

9781430258544_FM-01.jpg

图 1 网络游乐场的拓扑结构

  • 代表客户在家中或咖啡店中的典型情况的是modemAmodemB后面的客户机,它们不仅不向互联网提供服务,而且实际上在更广泛的互联网上根本看不到。它们只拥有本地 IP 地址,这些地址只有在它们与同一家或咖啡店中的任何其它主机共享的子网上才有意义。当它们与外界建立连接时,这些连接看起来似乎来自调制解调器本身的 IP 地址。

  • 直接连接允许调制解调器连接到更广阔的互联网上的一个isp网关,它由一个单独的backbone路由器代表,在它所连接的网络之间转发数据包。

  • example.com及其相关的机器代表一个简单的面向服务的机房的配置。这里,没有网络转换或伪装发生。example.com后面的三台服务器的服务端口完全暴露给来自互联网的客户端流量。

  • 每台服务机器ftpmailwww都已经正确地配置好守护进程并运行,因此本书中的 Python 脚本可以在操场上的其他机器上运行,以成功连接到每个服务的代表性示例。

  • 所有的服务机器都已经正确安装了 TLS 证书(参见第六章),并且客户端机器都已经安装了example.com签名证书作为可信证书。这意味着要求真正 TLS 认证的 Python 脚本将能够实现它

随着 Python 和 Docker 的不断发展,网络游乐场将继续得到维护。存储库中将保存如何在自己的机器上下载和运行网络的说明,并将根据用户报告进行调整,以确保提供操场的虚拟机可以由 Linux、Mac OS X 和 Windows 机器上的读者运行。

有了在任何一台游戏机器上连接和运行命令的能力,您将能够在网络上任何您希望看到客户机和服务器之间流量通过的地方设置数据包跟踪。文档中展示的示例代码,结合本书中的示例和说明,应该有助于您对网络如何帮助客户端和服务器通信有一个坚实而生动的理解。

一、客户端-服务器网络简介

这本书探索了 Python 语言的网络编程。它涵盖了使用最流行的 Internet 通信协议与远程机器通信时可能用到的基本概念、模块和第三方库。

如果你以前从未见过 Python 语言,或者你甚至从未写过计算机程序,那么这本书缺乏足够的篇幅来教你如何用 Python 编程;它假设您已经从许多优秀的教程和书籍中学到了一些关于 Python 编程的知识。我希望书中的 Python 例子能让你了解如何构建和编写自己的代码。但是我将使用各种各样的高级 Python 特性,而不做任何解释或道歉——不过,偶尔,当我认为某项技术或构造特别有趣或聪明时,我可能会指出我是如何使用它的。

另一方面,这本书并没有从假设你了解任何网络开始。只要你曾经使用过网络浏览器或发送过电子邮件,你就应该知道足够多的知识来开始阅读这本书,并在此过程中了解计算机网络。我将从一个应用程序员的角度来研究网络,这个程序员要么正在实现一个与网络相连的服务——比如一个网站、一个电子邮件服务器或一个联网的计算机游戏——要么正在编写一个旨在使用这种服务的客户端程序。

但是,请注意,您不会从本书中学到如何设置或配置网络。网络设计、服务器机房管理和自动化供应等学科本身就是完整的主题,不会与本书中涉及的计算机编程学科重叠。虽然由于 OpenStack、SaltStack 和 Ansible 等项目,Python 确实正在成为供应领域的一个重要组成部分,但如果您想了解更多信息,您将需要搜索专门关于供应及其许多技术的书籍和文档。

积木:书库和图书馆

当您开始探索 Python 网络编程时,有两个概念会反复出现。

  • 一个协议栈 的想法,其中简单的网络服务被用作构建更复杂服务的基础。
  • 事实上,您将经常使用以前编写的代码的 Python ——无论是 Python 附带的内置标准库中的模块,还是您下载并安装的第三方发行版中的包——它们已经知道如何使用您想要使用的网络协议。

在许多情况下,网络编程只是选择和使用已经支持您需要执行的网络操作的库。本书的主要目的是向您介绍 Python 可用的几个关键网络库,同时也向您介绍构建这些库所基于的底层网络服务。了解底层材料是有用的,这样既可以理解库是如何工作的,也可以理解当底层出错时会发生什么。

让我们从一个简单的例子开始。以下是邮寄地址:

207 N. Defiance St
Archbold, OH

我想知道这个物理地址的纬度和经度。恰好 Google 提供了一个地理编码 API,可以执行这样的转换。为了利用 Python 的这一网络服务,您需要做些什么?

当查看您想要使用的新网络服务时,总是有必要从了解是否有人已经实现了该协议开始,在这种情况下,就是您的程序需要使用的 Google 地理编码协议。首先浏览 Python 标准库文档,查找与地理编码有关的内容。

http://docs.python.org/3/library/

你看到任何关于地理编码的东西了吗?不,我也不知道。但是对于一个 Python 程序员来说,经常浏览标准库的目录是很重要的,即使你通常找不到你要找的东西,因为每次通读都会让你更熟悉 Python 中包含的服务。Doug Hellmann 的“本周 Python 模块”博客是另一个很好的参考,从中您可以了解 Python 由于其标准库而带来的功能。

因为在这种情况下,标准库没有可用的包,所以您可以求助于 Python 包索引,这是一个很好的资源,可以找到由世界各地的其他程序员和组织提供的各种通用 Python 包。当然,您也可以查看您将使用其服务的供应商的网站,看看它是否提供了访问它的 Python 库。或者,你可以在谷歌上搜索 Python,加上你想使用的任何网络服务的名字,看看前几个结果中是否有链接到你想尝试的包。

在本例中,我搜索了 Python 包索引,它位于以下 URL:

https://pypi.python.org/

在那里,我输入了地理编码,我立即找到了一个名为pygeocoder的包,它提供了一个清晰的谷歌地理编码特性的接口(不过,你会从它的描述中注意到,它是而不是供应商提供的,而是由谷歌以外的人编写的)。

http://pypi.python.org/pypi/pygeocoder/

这是一个很常见的情况——找到一个 Python 包,听起来它可能已经做了您想要做的事情,并且您想在您的系统上尝试它——我应该暂停一下,向您介绍快速尝试新库的最佳 Python 技术:virtualenv

在过去,安装 Python 包是一件可怕且不可逆转的事情,需要对您的机器拥有管理权限,并且会使您的系统 Python 安装被永久更改。经过几个月繁重的 Python 开发,您的系统 Python 安装可能会变成几十个包的废墟,全部都是手工安装的,您甚至会发现您试图安装的新包会崩溃,因为它们与硬盘上的旧包不兼容,而旧包来自一个几个月前结束的项目。

细心的 Python 程序员再也不会遭受这种情况了。我们中的许多人在系统范围内只安装一个 Python 包——这就是virtualenv!。一旦安装了virtualenv,您就有能力创建任意数量的小型、自包含的“虚拟 Python 环境”,在这里可以安装和卸载包,并且您可以进行试验,所有这些都不会污染您的系统范围的 Python。当一个特定的项目或实验结束时,您只需删除它的虚拟环境目录,您的系统就干净了。

在这种情况下,您想要创建一个虚拟环境来测试pygeocoder包。如果您以前从未在系统上安装过virtualenv,请访问以下 URL 下载并安装它:

http://pypi.python.org/pypi/virtualenv

一旦安装了virtualenv,就可以使用下面的命令创建一个新环境。(在 Windows 上,虚拟环境中包含 Python 二进制文件的目录将被命名为Scripts,而不是bin。)

$ virtualenv –p python3 geo_env
$ cd geo_env
$ ls
bin/  include/  lib/
$ . bin/activate
$ python -c 'import pygeocoder'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ImportError: No module named 'pygeocoder'

如你所见,pygeocoder包还没有发布。要安装它,请使用您的虚拟环境中的pip命令,由于您已经运行了activate命令,该命令现在位于您的路径上。

$ pip install pygeocoder
Downloading/unpacking pygeocoder
  Downloading pygeocoder-1.2.1.1.tar.gz
  Running setup.py egg_info for package pygeocoder

Downloading/unpacking requests>=1.0 (from pygeocoder)
  Downloading requests-2.0.1.tar.gz (412kB): 412kB downloaded
  Running setup.py egg_info for package requests

Installing collected packages: pygeocoder, requests
  Running setup.py install for pygeocoder

  Running setup.py install for requests

Successfully installed pygeocoder requests
Cleaning up...

virtualenv中的python二进制文件现在可以使用pygeocoder包了。

$ python -c 'import pygeocoder'

现在您已经安装了pygeocoder包,您应该能够运行名为search1.py的简单程序,如清单 1-1 所示。

清单 1-1 。获取经纬度

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/search1.py

from pygeocoder import Geocoder

if __name__ == '__main__':
    address = '207 N. Defiance St, Archbold, OH'
    print(Geocoder.geocode(address)[0].coordinates)

通过在命令行运行它,您应该会看到如下结果:

$ python3 search1.py
(41.521954, -84.306691)

在那里,就在你的电脑屏幕上是我们关于地址的纬度和经度问题的答案!答案直接来自谷歌的网络服务。第一个范例程序取得了令人振奋的成功。

打开一本关于 Python 网络编程的书,却发现自己立即被引导下载并安装了一个第三方包,该包将一个有趣的网络问题变成了一个令人厌烦的三行 Python 脚本,您对此感到恼火吗?安息吧!90%的情况下,您会发现这正是解决编程挑战的方法——通过在 Python 社区中找到已经解决了您所面临的问题的其他程序员,然后在他们的解决方案的基础上聪明而简单地构建。

然而,您还没有探索完这个例子。您已经看到,一个复杂的网络服务通常可以很容易地访问。但是漂亮的pygeocoder界面背后是什么呢?这项服务实际上是如何工作的?现在,您将详细探索这种复杂的服务实际上是如何成为至少包含六个不同级别的网络堆栈的顶层。

应用层

第一个程序清单使用了从 Python 包索引下载的第三方 Python 库来解决问题。它对谷歌地理编码 API 及其使用规则了如指掌。但是如果那个图书馆不存在呢?如果你必须自己为谷歌地图 API 构建一个客户端会怎么样?

答案请看search2.py,如清单 1-2 所示。它没有使用支持地理编码的第三方库,而是下降一级,使用位于pygeocoding之后的流行的requests库,正如您在前面的pip install命令中看到的,它也已经安装在您的虚拟环境中。

清单 1-2 。从 Google 地理编码 API 获取 JSON 文档

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/search2.py

import requests

def geocode(address):
    parameters = {'address': address, 'sensor': 'false'}
    base = 'http://maps.googleapis.com/maps/api/geocode/json'
    response = requests.get(base, params=parameters)
    answer = response.json()
    print(answer['results'][0]['geometry']['location'])

if __name__ == '__main__':
    geocode('207 N. Defiance St, Archbold, OH')

运行这个 Python 程序会返回一个与第一个脚本非常相似的答案。

$ python3 search2.py
{'lat': 41.521954, 'lng': -84.306691}

输出并不完全相同——例如,您可以看到 JSON 数据将结果编码为一个“对象”,requests将它作为 Python 字典交给您。但是很明显,这个脚本完成了与第一个脚本相同的事情。

关于这段代码,您会注意到的第一件事是,高层模块pygeocoder提供的语义不存在了。除非您仔细查看这段代码,否则您可能根本看不出它在询问邮寄地址!鉴于search1.py直接要求将地址转换成纬度和经度,第二个清单费力地构建了一个基本 URL 和一组查询参数,除非您已经阅读了 Google 文档,否则您可能不清楚它们的用途。如果你想阅读文档,顺便说一下,你可以找到这里描述的 API:

http://code.google.com/apis/maps/documentation/geocoding/

如果您仔细查看search2.py中查询参数的字典,您会看到address参数提供了您所询问的特定邮寄地址。另一个参数通知 Google,由于来自移动设备位置传感器的实时数据,您不会发出这个位置查询。

当您在查找这个 URL 的结果中收到一个文档时,您可以手动调用response.json()方法将其解释为 JSON,然后深入到多层的结果数据结构中,找到包含纬度和经度的正确元素。

然后,search2.py脚本做与search1.py相同的事情——但它不是用地址和纬度的语言来做,而是谈论构建 URL、获取响应并将其解析为 JSON 的具体细节。当您从网络堆栈的一层向下一层时,这是一个常见的区别:高级代码谈论请求意味着什么,而低级代码只能看到请求如何构造的细节

讲协议

因此,第二个示例脚本创建了一个 URL 并获取与之对应的文档。这个操作听起来很简单,当然,你的网络浏览器努力让它看起来很简单。当然,URL 可以用来获取文档的真正原因是,URL 是一种描述在哪里找到以及如何获取 Web 上给定文档的方法。URL 由协议名、文档所在的机器名组成,并以命名该机器上特定文档的路径结束。那么,search2.py Python 程序能够解析 URL 并获取文档的原因是,URL 提供了指令,告诉低层协议如何找到文档。

事实上,URL 使用的底层协议是著名的超文本传输协议(HTTP),它是几乎所有现代网络通信的基础。你会在本书的第九章、 10 章和 11 章中了解到更多。HTTP 提供了请求库从 Google 获取结果的机制。如果你去掉这层魔法,你认为会是什么样子——如果你想使用 HTTP 直接获取结果呢?结果是search3.py,如清单 1-3 所示。

清单 1-3 。建立到谷歌地图的原始 HTTP 连接

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/search3.py

import http.client
import json
from urllib.parse import quote_plus

base = '/maps/api/geocode/json'

def geocode(address):
    path = '{}?address={}&sensor=false'.format(base, quote_plus(address))
    connection = http.client.HTTPConnection('maps.google.com')
    connection.request('GET', path)
    rawreply = connection.getresponse().read()
    reply = json.loads(rawreply.decode('utf-8'))
    print(reply['results'][0]['geometry']['location'])

if __name__ == '__main__':
    geocode('207 N. Defiance St, Archbold, OH')

在这个清单中,您直接操作 HTTP 协议:要求它连接到一台特定的机器,发出一个带有您手工构建的路径的GET请求,最后直接从 HTTP 连接读取回复。您不能方便地将查询参数作为字典中单独的键和值来提供,而是必须将它们直接手动嵌入到您所请求的路径中,方法是首先写一个问号(?),然后是由&字符分隔的name=value格式的参数。

然而,运行该程序的结果与前面显示的程序非常相似。

$ python3 search3.py
{'lat': 41.521954, 'lng': -84.306691}

正如您将在本书中看到的,HTTP 只是 Python 标准库提供内置实现的众多协议之一。在search3.py中,不必担心 HTTP 如何工作的所有细节,您的代码可以简单地请求发送一个请求,然后查看结果响应。当然,脚本必须处理的协议细节比那些search2.py更原始,因为您已经在协议栈中降低了另一个级别,但至少您仍然能够依靠标准库来处理实际的网络数据,并确保您得到正确的数据。

原始网络对话

当然,HTTP 不能简单地使用稀薄的空气在两台机器之间发送数据。相反,HTTP 协议必须通过使用一些更简单的抽象来运行。事实上,它利用现代操作系统的能力,通过使用 TCP 协议来支持 IP 网络上两个不同程序之间的纯文本网络对话。换句话说,HTTP 协议的运作方式是精确地指定在两台能说 TCP 的主机之间来回传递的消息的文本是什么样子。

当您移动到 HTTP 下面查看它下面发生的事情时,您正在下降到网络堆栈的最底层,您仍然可以从 Python 轻松地访问它。仔细看看search4.py,如清单 1-4 所示。它向谷歌地图发出与前三个程序完全相同的联网请求,但它是通过在互联网上发送一条原始文本消息并接收一组文本作为回报来做到这一点的。

清单 1-4 。通过一个裸露的套接字与谷歌地图对话

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/search4.py

import socket
from urllib.parse import quote_plus

request_text = """\
GET /maps/api/geocode/json?address={}&sensor=false HTTP/1.1\r\n\
Host: maps.google.com:80\r\n\
User-Agent: search4.py (Foundations of Python Network Programming)\r\n\
Connection: close\r\n\
\r\n\
"""

def geocode(address):
    sock = socket.socket()
    sock.connect(('maps.google.com', 80))
    request = request_text.format(quote_plus(address))
    sock.sendall(request.encode('ascii'))
    raw_reply = b''
    while True:
        more = sock.recv(4096)
        if not more:
            break
        raw_reply += more
    print(raw_reply.decode('utf-8'))

if __name__ == '__main__':
    geocode('207 N. Defiance St, Archbold, OH')

search3.pysearch4.py,你已经跨过了一个重要的门槛。在之前的每个程序清单中,您使用的是 Python 库——用 Python 本身编写——它知道如何代表您讲述复杂的网络协议。但是这里您已经到达了底部:您正在调用由主机操作系统提供的原始的socket()函数来支持 IP 网络上的基本网络通信。换句话说,您使用的机制与低级系统程序员在 C 语言中编写相同的网络操作时使用的机制相同。

在接下来的几章中,你会学到更多关于套接字的知识。现在,您可以在search4.py中注意到,原始网络通信就是发送和接收字节串。您发送的请求是一个字节的字符串,而回复是另一个大字节的字符串,在本例中,您只需将它打印到屏幕上,这样您就可以体验它所有的低级荣耀。(请参阅本章后面的“编码和解码”一节,了解为什么要在打印之前解码字符串的详细信息。)HTTP 请求的文本可以在sendall()函数中看到,它由单词GET——您想要执行的操作的名称——后跟您想要获取的文档的路径和您支持的 HTTP 版本组成。

GET /maps/api/geocode/json?address=207+N.+Defiance+St%2C+Archbold%2C+OH&sensor=false HTTP/1.1

然后是一系列的头,每个头由一个名称、一个冒号和一个值组成,最后是一个结束请求的回车/换行符对。

如果运行search4.py,回复将作为脚本的输出打印出来,如清单 1-5 中的所示。在本例中,我选择简单地将回复打印到屏幕上,而不是编写复杂的文本操作代码来解释响应。我这样做是因为我认为,简单地阅读屏幕上的 HTTP 回复会让你对它的样子有更好的了解,而不是你必须破译用来解释它的代码。

清单 1-5 。运行search4.py的输出

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Date: Sat, 23 Nov 2013 18:34:30 GMT
Expires: Sun, 24 Nov 2013 18:34:30 GMT
Cache-Control: public, max-age=86400
Vary: Accept-Language
Access-Control-Allow-Origin: *
Server: mafe
X-XSS-Protection: 1; mode=block
X-Frame-Options: SAMEORIGIN
Alternate-Protocol: 80:quic
Connection: close

{
   "results" : [
      {
         ...
         "formatted_address" : "207 North Defiance Street, Archbold, OH 43502, USA",
         "geometry" : {
            "location" : {
               "lat" : 41.521954,
               "lng" : -84.306691
            },
            ...
         },
         "types" : ["street_address"]
      }
   ],
   "status" : "OK"
}

您可以看到 HTTP 回复在结构上与 HTTP 请求非常相似。它以一个状态行开始,后面是一些标题。在一个空行之后,显示响应内容本身:一个 JavaScript 数据结构,格式简单,称为 JSON,它通过描述 Google Geocoding API 搜索返回的地理位置来回答您的查询。

当然,所有这些状态行和标题正是 Python 的httplib在早期清单中处理的那种低级细节。在这里,你可以看到,如果去掉这层软件,通信会是什么样子。

海龟一路向下

我希望您喜欢这些 Python 网络编程的初始示例。退一步讲,我可以用这一系列的例子来说明关于 Python 中网络编程的几点。

首先,您现在也许可以更清楚地看到术语协议栈的含义:它意味着在更简单、更基本的对话之上构建一个高级的、语义复杂的对话(“我想要这个邮件地址的地理位置”),这些对话最终只是使用网络硬件在两台计算机之间来回发送的文本字符串。

您刚刚探索的特定协议栈有四个协议高。

  • 最上面是 Google Geocoding API,它告诉您如何将您的地理查询表达为获取包含坐标的 JSON 数据的 URL。
  • URL 命名可以使用 HTTP 检索的文档。
  • HTTP 支持面向文档的命令,比如使用原始 TCP/IP 套接字的 GET。
  • TCP/IP 套接字只知道如何发送和接收字节串。

您可以看到,堆栈的每一层都使用其下一层提供的工具,并依次为下一个更高层提供功能。

通过这些例子清楚地表明的第二点是,Python 对你刚才操作的每一个网络级别的支持是多么完整。只有当使用特定于供应商的协议,并且需要格式化请求以便谷歌能够理解时,才有必要求助于第三方库;我选择第二个清单中的requests,不是因为标准库缺少urllib.request模块,而是因为它的 API 过于笨重。您遇到的每一个其他协议级别都已经在 Python 标准库中得到了强有力的支持。无论您想要在特定的 URL 获取文档,还是在原始网络套接字上发送和接收字符串,Python 都准备好了可以用来完成工作的函数和类。

第三,注意我的程序质量下降了很多,因为我强迫自己使用越来越低级的协议。例如,search2.pysearch3.py清单开始以一种不灵活的方式对表单结构和主机名进行硬编码,这种方式以后可能很难维护。search4.py中的代码更糟糕:它包括一个手写的、未参数化的 HTTP 请求,其结构对 Python 来说完全不透明。当然,它不包含解析和解释 HTTP 响应以及理解可能发生的任何网络错误情况所需的任何实际逻辑。

这说明了一个你应该在本书后面的每一章都记住的教训:正确实现网络协议是困难的,你应该尽可能使用标准库或第三方库。尤其是当你在编写网络客户端的时候,你总会被诱惑去过度简化你的代码;您往往会忽略许多可能出现的错误情况,只准备最有可能的响应,避免正确地转义参数,因为您天真地认为您的查询字符串将只包含简单的字母字符,并且通常会编写非常脆弱的代码,这些代码尽可能地不了解它正在与之对话的服务。相反,通过使用已经开发了一个协议的完整实现的第三方库,它必须支持许多不同的 Python 开发人员,这些开发人员使用该库来完成各种任务,您将从库实现者已经发现并学会如何正确处理的所有边缘情况和棘手问题中受益。

第四,需要强调的是,更高级别的网络协议——比如用于解析街道地址的谷歌地理编码 API 通常通过隐藏它们下面的网络层来工作。如果您只使用过pygeocoder库,您可能甚至不知道 URL 和 HTTP 是用于构造和回答查询的底层机制!

一个有趣的问题是,这个库是否正确地隐藏了那些较低级别的错误,这个问题的答案因 Python 库编写的细致程度而异。一个网络错误会使你的位置暂时无法访问谷歌,在试图查找街道地址坐标的代码中引发一个原始的低级网络异常吗?还是会把所有的错误都改成针对地理编码的更高级别的异常?在阅读本书的过程中,请特别注意捕捉网络错误的主题,尤其是第一部分中强调底层网络的章节。

最后,我们已经到达了本书第一部分的主题:在search4.py中使用的socket()接口是而不是,事实上,当你向 Google 发出这个请求时,它是最低的协议级别!正如这个例子中的网络协议运行在原始套接字之上一样,在套接字抽象之下也有协议,Python 看不到这些协议,因为它们是由操作系统管理的。

socket() API 下运行的层如下:

  • 传输控制协议(TCP) 通过发送(或者重新发送)、接收和重新排序称为数据包的小型网络消息,支持由字节流组成的双向对话。
  • 互联网协议(IP)知道如何在不同的计算机之间发送数据包。
  • 最底层的“链路层”由网络硬件设备组成,如以太网端口和无线网卡,它们可以在直接链接的计算机之间发送物理消息。

在本章的其余部分以及接下来的两章中,您将探索这些最低的协议级别。在本章中,您将首先研究 IP 层,然后在接下来的章节中了解两种完全不同的协议(UDP 和 TCP)如何支持两种基本类型的会话,这两种会话可能在一对连接到 Internet 的主机上的应用之间进行。

但是首先,说几句关于字节和字符的话。

编码和解码

Python 3 语言对字符串和低级字节序列进行了严格的区分。字节是计算机在网络通信中来回传输的实际二进制数,每个二进制数由八个二进制数字组成,范围从二进制值 0000000 到 1111111,因此从十进制整数 0 到 255。Python 中由字符组成的字符串可以包含 Unicode 符号,如a(unicode 标准称之为“拉丁小写字母 a”)或}(右花括号)或∅(空集)。虽然每个 Unicode 字符确实都有一个与之相关联的数字标识符,称为其码位,但您可以将此视为内部实现细节——Python 3 小心翼翼地使字符始终表现得像字符一样,只有当您要求时,Python 才会将字符与实际的外部可见字节进行相互转换。

这两种操作都有正式名称。

解码是指当字节通过进入你的应用时,你需要弄清楚它们的意思。当您的应用从文件或通过网络接收字节时,可以把它想象成一个典型的冷战间谍,其任务是解密通过通信信道传输的原始字节。

编码是这样一个过程:当数字计算机需要使用作为其唯一真实货币的字节来传输或存储符号时,使用许多编码中的一种,将您准备呈现给外界的字符串转换为字节。把你的间谍想象成必须把他们的信息转换成数字来传输,把符号转换成可以通过网络发送的代码。

这两个操作在 Python 3 中非常简单和明显地公开为一个decode()方法,可以在读入字节串后应用于它们,以及一个encode()方法,可以在准备写回字符串时调用它们。这些技术在清单 1-6 中有说明。

清单 1-6 。解码输入字节和编码输出字符

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/stringcodes.py

if __name__ == '__main__':
    # Translating from the outside world of bytes to Unicode characters.
    input_bytes = b'\xff\xfe4\x001\x003\x00 \x00i\x00s\x00 \x00i\x00n\x00.\x00'
    input_characters = input_bytes.decode('utf-16')
    print(repr(input_characters))

    # Translating characters back into bytes before sending them.
    output_characters = 'We copy you down, Eagle.\n'
    output_bytes = output_characters.encode('utf-8')
    with open('eagle.txt', 'wb') as f:
        f.write(output_bytes)

本书中的例子试图仔细区分字节和字符。请注意,当您显示它们的repr()时,这两者有不同的外观:字节字符串以字母 b 开头,看起来像b'Hello',而真正完整的字符串没有初始字符,只是看起来像'world'。为了避免字节字符串和字符串之间的混淆,Python 3 只在字符串类型上提供了大多数字符串方法。

互联网协议

两种联网,当你用一条物理链路连接几台计算机以便它们能够通信时发生,以及互联,它链接相邻的物理网络形成一个像互联网一样的更大的系统,本质上只是允许资源共享的精心设计的方案。

当然,计算机中的各种东西都需要共享:磁盘驱动器、内存和 CPU 都由操作系统小心翼翼地保护着,这样运行在计算机上的各个程序就可以访问这些资源,而不会互相影响。网络是操作系统需要保护的另一种资源,以便程序可以相互通信,而不会干扰同一网络上发生的其他会话。

您的计算机用于通信的物理网络设备(如以太网卡、无线发射器和 USB 端口)本身都设计有一个精心设计的功能,可以在许多想要通信的不同设备之间共享一个物理介质。一打以太网卡可能被插入同一个集线器;30 个无线卡可能共享同一个无线信道;DSL 调制解调器使用频域多路复用,这是电气工程中的一个基本概念,可以防止自己的数字信号干扰电话通话时线路上传输的模拟信号。

网络设备之间共享的基本单位——货币,如果你愿意的话,他们交易的货币——是数据包。数据包是一个字节串,其长度可能从几个字节到几千个字节不等,它作为一个单元在网络设备之间传输。虽然确实存在专用网络,特别是在电信等领域,传输线路上的每个字节都可能被分别路由到不同的目的地,但用于为现代计算机构建数字网络的更通用的技术都是基于更大的数据包单元。

一个包在物理层通常只有两个属性:它携带的字节串数据和它要被传送到的地址。物理数据包的地址通常是一个唯一的标识符,用来命名与传输数据包的计算机连接到同一以太网网段或无线信道的其它网卡。网卡的工作就是发送和接收这样的数据包,而不需要计算机的操作系统关心网络如何使用电线、电压和信号来运行的细节。

那么,什么是互联网协议呢?

互联网协议是一种在全世界所有连接互联网的计算机上实施统一地址系统的方案,使数据包能够从互联网的一端传输到另一端。理想情况下,像 web 浏览器这样的应用应该能够连接到任何地方的主机,而不必知道每个数据包在它的旅程中穿过哪个网络设备迷宫。

Python 程序很少能在如此低的层次上运行,以至于看到互联网协议本身在起作用,但至少了解它是如何工作的是有帮助的。

IP 地址

互联网协议的最初版本为连接到全球网络的每台计算机分配一个 4 字节的地址。这种地址通常由四个十进制数组成,用句点分隔,每个句点代表地址的一个字节。因此,每个数字的范围可以从 0 到 255。因此,传统的四字节 IP 地址如下所示:

130.207.244.244

因为纯数字地址对人类来说很难记住,使用互联网的人通常会看到主机名而不是 IP 地址。用户可以简单地键入google.com,而忘记在幕后这将解析为一个类似74.125.67.103的地址,他们的计算机实际上可以将数据包发送到这个地址,以便在互联网上传输。

getname.py脚本中,如清单 1-7 所示,您可以看到一个简单的 Python 程序,它要求操作系统——Linux、Mac OS、Windows 或该程序运行的任何系统——解析主机名www.python.org。被称为域名系统的特定网络服务非常复杂,我将在第四章中更详细地讨论它。

清单 1-7 。将主机名转换为 IP 地址

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/getname.py

import socket

if __name__ == '__main__':
    hostname = 'www.python.org'
    addr = socket.gethostbyname(hostname)
    print('The IP address of {} is {}'.format(hostname, addr))

现在,你只需要记住两件事。

  • 首先,不管一个互联网应用看起来多么奇特,实际的互联网协议总是使用数字 IP 地址将数据包导向目的地。
  • 其次,如何将主机名解析为 IP 地址的复杂细节通常由操作系统来处理。

像互联网协议的大多数操作细节一样,您的操作系统喜欢自己处理它们,对您和您的 Python 代码隐藏这些细节。

实际上,现在的寻址情况可能比刚才描述的简单的 4 字节方案要复杂一些。因为世界上的 4 字节 IP 地址即将耗尽,一种被称为 IPv6 的扩展地址方案正在部署,它允许绝对庞大的 16 字节地址在未来很长一段时间内满足人类的需求。它们的写法不同于 4 字节的 IP 地址,看起来像这样:

fe80::fcfd:4aff:fecf:ea4e

但是,只要您的代码接受来自用户的 IP 地址或主机名,并将它们直接传递给网络库进行处理,您就可能永远不需要担心 IPv4 和 IPv6 之间的区别。运行 Python 代码的操作系统将知道它使用的 IP 版本,并相应地解释地址。

通常,传统的 IP 地址可以从左到右读取:前一个或两个字节指定一个组织,然后下一个字节通常指定目标机器所在的特定子网。最后一个字节将地址缩小到特定的机器或服务。还有一些特殊的 IP 地址范围具有特殊的含义。

  • 127。.*.** :以字节127开头的 IP 地址位于一个特殊的保留范围内,该范围是运行应用的机器的本地地址。当您的 web 浏览器、FTP 客户端或 Python 程序连接到此范围内的某个地址时,它会要求与同一台机器上运行的其他服务或程序进行对话。大多数机器在整个范围内只使用一个地址:IP 地址127.0.0.1通常被用来表示“运行这个程序的机器本身”,通常可以通过主机名localhost来访问。
  • 10。.., 172.16–31.., 192.168..* :这些 IP 范围是为所谓的私有子网保留的。管理互联网的当局已经做出了绝对的承诺:他们永远不会把这三个范围中的任何一个 IP 地址提供给真正建立服务器或服务的公司。因此,在互联网上,这些地址肯定是没有意义的;它们没有指定您想要连接的主机。因此,您可以在您组织的任何内部网络上免费使用这些地址,您可以在内部自由分配 IP 地址,而无需选择从 Internet 上的其他位置访问这些主机。

您甚至可能在自己的家中看到这些私有地址中的一些:您的无线路由器或 DSL 调制解调器通常会将这些私有范围中的一个 IP 地址分配给您的家庭计算机和笔记本电脑,并将您的所有互联网流量隐藏在您的互联网服务提供商分配给您使用的单个“真实”IP 地址之后。

路由

一旦应用要求操作系统向特定的 IP 地址发送数据,操作系统就必须决定如何使用机器所连接的物理网络之一来传输数据。这一决定(即,根据 IP 地址选择将每个互联网协议数据包发送到哪里)被称为路由

您在职业生涯中编写的大部分(也许是全部)Python 代码将运行在互联网边缘的主机上,通过一个网络接口将它们与世界其他地方连接起来。对于这样的机器,路由成为一个非常简单的决定。

  • 如果 IP 地址看起来像127.*.*.*,那么操作系统就知道这个包的目的地是运行在同一台机器上的另一个应用。它甚至不会提交给物理网络设备进行传输,而是由操作系统通过内部数据副本直接交给另一个应用。
  • 如果 IP 地址与机器本身在同一个子网中,那么只需检查本地以太网网段、无线信道或本地网络,然后将数据包发送到本地连接的机器,就可以找到目的主机。
  • 否则,你的机器会将数据包转发给一台网关机器,它将你的本地子网连接到互联网的其余部分。之后,将由网关机器决定将数据包发送到哪里。

当然,在因特网的边缘,路由只是这么简单,在那里,唯一的决定是把信息包留在本地网上,还是把它送到因特网的其他地方。您可以想象,对于构成互联网主干的专用网络设备来说,路由决策要复杂得多!在那里,在连接整个大陆的交换机上,必须构建、查阅并不断更新复杂的路由表,以便知道去往谷歌的数据包是一个方向,去往亚马逊 IP 地址的数据包是另一个方向,而去往你的机器的数据包又是另一个方向。但是 Python 应用很少能在互联网主干路由器上运行,所以刚刚概述的更简单的路由情况几乎总是您将看到的实际情况。

在前面的段落中,我对你的计算机如何决定一个 IP 地址是属于一个本地子网,还是应该通过一个网关转发到互联网的其余部分有点含糊。为了说明子网的概念,其所有主机共享相同的 IP 地址前缀,我一直在编写前缀,后跟星号来表示地址中可能变化的部分。当然,运行操作系统网络堆栈的二进制逻辑实际上不会将小的 ASCII 星号插入其路由表!相反,子网是通过将 IP 地址与一个掩码组合来指定的,该掩码指示主机必须匹配多少个最高有效位才能属于该子网。如果您记住 IP 地址中的每个字节代表八位二进制数据,那么您将能够很容易地读取子网号。它们看起来像这样:

  • 127.0.0.0/8 :这个模式描述了前面讨论过的 IP 地址范围,并且是为本地主机保留的,它指定前 8 位(1 个字节)必须与数字 127 匹配,而剩余的 24 位(3 个字节)可以是它们想要的任何值。
  • 192.168.0.0/16 :此模式将匹配属于私有 192.168 范围内的任何 IP 地址,因为前 16 位必须完全匹配。32 位地址的最后 16 位可以是他们想要的任何值。
  • 192.168.5.0/24 :这里有一个特定子网的规范。这可能是整个互联网上最常见的子网掩码。地址的前三个字节是完全指定的,它们必须匹配才能使 IP 地址落入此范围。在此范围内,只有最后一个字节(最后八位)允许在机器之间有所不同。这样就剩下 256 个唯一的地址。通常,.0地址被用作子网的名称,.255地址被用作“广播包”的目的地,该“广播包”寻址子网中的所有主机(您将在下一章中看到),这样就有 254 个地址可以分配给计算机。地址.1通常用于连接子网和互联网的网关,但是一些公司和学校选择使用另一个号码作为网关。

几乎在所有情况下,您的 Python 代码将简单地依赖其主机操作系统来正确地做出数据包路由选择,就像它首先依赖操作系统来将主机名解析为 IP 地址一样。

数据包碎片

最后一个值得一提的互联网协议概念是数据包分段。虽然它被认为是一个模糊的细节,通过你的操作系统的网络堆栈的聪明成功地隐藏在你的程序之外,但它在互联网的历史上已经引起了足够多的问题,至少值得在这里简单地提一下。

分段是必要的,因为 Internet 协议支持非常大的数据包(最大长度可达 64KB ),但构建 IP 网络的实际网络设备通常支持小得多的数据包。例如,以太网只支持 1500 字节的数据包。因此,互联网数据包包括一个“不分段”(DF)标志,如果数据包太大,无法通过位于源计算机和目的地之间的物理网络,发送方可以使用该标志选择他们希望发生的情况:

  • 如果 DF 标志未置位,则允许分段,当数据包到达其无法容纳的网络阈值时,网关可以将其拆分成更小的数据包,并标记它们以便在另一端重新组装。
  • 如果设置了 DF 标志,则禁止分段,如果该数据包不适合,则它将被丢弃,并且错误消息将被发送回发送该数据包的机器(在一个称为互联网控制消息协议(ICMP) 数据包的特殊信令数据包中),以便该机器可以尝试将消息分成更小的片段并重新发送。

您的 Python 程序通常无法控制 DF 标志;而是由操作系统设置。粗略地说,系统通常使用的逻辑是这样的:如果你正在进行一个 UDP 会话(参见第二章),该会话由单独的数据报组成,这些数据报在互联网上传播,那么操作系统将保留 DF 不变,这样每个数据报以所需的任意数量到达目的地;但是如果你正在进行一个 TCP 对话(参见第三章),其长数据流可能有数百或数千个数据包长,那么操作系统将设置 DF 标志,这样它就可以准确地选择正确的数据包大小,让对话顺利进行,而不会使数据包在途中不断被分割,这将使对话的效率稍有下降。

互联网子网可以接受的最大数据包被称为其最大传输单元(MTU) ,MTU 处理曾经有一个大问题,给很多互联网用户带来了问题。20 世纪 90 年代,互联网服务提供商(最著名的是提供 DSL 链接的电话公司)开始使用 PPPoE,这是一种将 IP 数据包放在一个胶囊中的协议,该胶囊仅留给它们 1492 字节的空间,而不是通常允许通过以太网的 1500 字节。许多互联网站点对此毫无准备,因为它们默认使用 1500 字节的数据包,并作为一种错误的安全措施阻止了所有 ICMP 数据包。结果,他们的服务器永远不会收到 ICMP 错误,告诉他们,他们的 1500 字节的“不分段”大数据包正在到达客户的 DSL 链路,无法通过它们。

这种情况令人抓狂的症状是,小文件或网页可以毫无问题地查看,而 Telnet 和 SSH 之类的交互协议可以工作,因为这两种活动都倾向于发送长度小于 1,492 字节的小数据包。但是,一旦客户试图下载一个大文件,或者一旦 Telnet 或 SSH 命令一次输出几个屏幕,连接就会冻结,变得没有响应。

今天,这个问题很少遇到,但它说明了一个低级的 IP 功能是如何产生用户可见的症状的,因此,在编写和调试网络程序时记住 IP 的所有功能是有好处的。

了解更多关于知识产权的信息

在接下来的章节中,您将逐步了解 IP 之上的协议层,并了解您的 Python 程序如何通过使用建立在 Internet 协议之上的不同服务来进行不同类型的网络对话。但是,如果你对前面关于 IP 如何工作的概述感兴趣,并想了解更多,该怎么办呢?

描述 Internet 协议的官方资源是 IETF 发布的注释请求(RFC ),它准确描述了协议的工作原理。它们写得很仔细,再加上一杯浓咖啡和几个小时的自由阅读时间,会让你了解互联网协议如何运作的每一个细节。例如,这里是定义互联网协议本身的 RFC:

http://tools.ietf.org/html/rfc791

您还可以在维基百科等一般资源上找到参考 RFC,RFC 通常会引用其他 RFC 来描述协议或寻址方案的更多细节。

如果你想了解互联网协议和运行在其上的其他协议的一切,你可能会有兴趣获得 Kevin R. Fall 和 w . Richard Stevens(Addison-Wesley Professional,2011)编写的经典文本 TCP/IP Illustrated,Volume 1:The Protocols(2nd Edition)。它非常详细地涵盖了所有的协议操作,这本书只有篇幅来描述这些操作。还有其他一些关于网络的好书,如果你在工作中或者仅仅是在家里设置 IP 网络和路由来让你的电脑上网,这些书可能会对网络配置有所帮助。

摘要

除了最基本的服务之外,所有的网络服务都是在其他一些更基本的网络功能之上实现的。

你已经在本章的开始部分探索了这样一个“堆栈”。TCP/IP 协议(将在第三章中介绍)支持在客户端和服务器之间传输字节串。HTTP 协议(见第九章)描述了这样一个连接如何被客户端用来请求一个特定的文档,以及服务器如何通过提供它来响应。万维网(第十一章)将检索 HTTP 托管文档的指令编码到一个称为 URL 的特殊地址中,当服务器返回的文档需要向客户机呈现结构化数据时,标准的 JSON 数据格式很受欢迎。在这整个大厦之上,谷歌提供地理编码服务,让程序员建立一个 URL,谷歌回复一个描述地理位置的 JSON 文档。

每当文本信息要在网络上传输时——或者就此而言,要保存到面向字节的持久存储器(如磁盘)中——就需要将字符编码成字节。有几种广泛使用的将字符表示为字节的方案。现代互联网上最常见的是简单而有限的 ASCII 编码和强大而通用的 Unicode 系统,尤其是其被称为 UTF-8 的特殊编码。Python 字节串可以使用它们的decode()方法转换成真实的字符,普通的字符串可以通过它们的encode()方法改回来。Python 3 从不尝试自动将字节转换成字符串——这项操作只需要猜测您想要的编码——因此 Python 3 代码通常会比 Python 2 下的实践更多地调用decode()encode()

为了让 IP 网络代表应用传输数据包,网络管理员、设备供应商和操作系统程序员必须共同合作,为各个机器分配 IP 地址,在机器和路由器级别建立路由表,并配置域名系统(第四章)以将 IP 地址与用户可见的名称相关联。Python 程序员应该知道,每个 IP 包都以自己的方式穿过网络到达目的地,如果包太大而无法通过路径上路由器之间的一个“跳”,那么它可能会被分割。

在大多数应用中,使用 IP 有两种基本方式。它们要么将每个数据包作为独立的消息使用,要么请求自动拆分成数据包的数据流。这些协议被命名为 UDP 和 TCP,它们是本书第二章和第三章的主题。

二、UDP

前一章描述了支持短消息传输的现代网络硬件,这些短消息被称为数据包,通常不超过几千字节。如何将这些微小的单个消息组合起来,形成 web 浏览器和服务器之间或电子邮件客户端和 ISP 邮件服务器之间的对话?

IP 协议只负责尝试将每个数据包传送到正确的机器。如果单独的应用要维护会话,通常需要两个额外的功能,而提供这些功能是建立在 IP 之上的协议的工作。

  • 在两台主机之间传输的许多数据包需要被标记,以便可以将 web 数据包与电子邮件数据包区分开,并且可以将两者与机器参与的任何其他网络会话区分开。这叫做复用??。
  • 从一台主机单独传输到另一台主机的数据包流可能发生的所有损坏都需要修复。丢失的数据包需要重新传输,直到它们到达。无序到达的数据包需要重组为正确的顺序。最后,需要丢弃重复的数据包,以便数据流中没有重复的信息。这就是所谓的提供可靠的运输

这本书专门为 IP 上使用的两个主要协议各写了一章。

第一个是用户数据报协议 (UDP),在本章中有记录。它只解决了前面概述的两个问题中的第一个。它提供端口号,如下一节所述,以便在一台机器上发往不同服务的数据包可以被正确地解复用。然而,使用 UDP 的网络程序仍然必须在数据包丢失、复制和排序方面保护自己。

第二个是传输控制协议 (TCP),解决了这两个问题。它既使用与 UDP 相同的规则合并了端口号,又提供了有序可靠的数据流,对应用隐藏了这样一个事实,即连续的数据流实际上已经被分割成数据包,然后在另一端重新组装。你将在第三章中学习使用 TCP。

请注意,一些罕见的专用应用,如局域网上所有主机共享的多媒体,既不选择任何协议,而是选择创建一个全新的基于 IP 的协议,与 TCP 和 UDP 并列,作为在 IP 网络上进行对话的新方式。这不仅不寻常,而且作为一个底层操作,不太可能用 Python 编写,所以在本书中你不会探究协议工程。本书中在 IP 上构建原始数据包的最接近的方法是在第一章末尾的“构建和检查数据包”部分,它构建原始 ICMP 数据包并接收 ICMP 回复。

我应该首先承认,您不太可能在自己的任何应用中使用 UDP。如果您认为 UDP 非常适合您的应用,那么您可能希望研究一下消息队列(参见第八章)。尽管如此,在你准备好在第三章中学习 TCP 之前,UDP 给你的原始包多路复用的体验是重要的一步。

端口号

在计算机网络和电磁信号理论中,区分共享同一信道的许多信号是一个普遍的问题。允许几个对话共享一种媒介或机制的解决方案被称为多路复用?? 方案。众所周知,人们发现无线电信号可以通过使用不同的频率相互分离。在数据包的数字领域,UDP 的设计者选择使用一种粗略的技术来区分不同的对话,这种技术用一对无符号的 16 位端口号来标记每个 UDP 数据包,端口号范围为 0 到 65,536。源端口 标识从源机器发送数据包的特定进程或程序,而目的地端口 指定通信应该传送到的目的地 IP 地址的应用。

在 IP 网络层,所有可见的都是流向特定主机的数据包。

Source IP ® Destination IP

但是两台通信机器的网络堆栈——毕竟必须控制和争论这么多可能正在对话的独立应用——认为对话更具体地是每台机器上的 IP 地址和端口号对之间的对话。

Source (IP : port number) ® Destination (IP : port number)

属于特定会话的传入数据包将始终具有相同的四个坐标值,而以另一种方式发送的回复只是在它们的源和目的地字段中交换了两个 IP 号码和两个端口号。

为了使这个想法具体化,假设您在一台 IP 地址为 192.168.1.9 的机器上设置了一个 DNS 服务器(第四章)。为了允许其他计算机找到该服务,服务器将向操作系统请求许可,以接收到达具有标准 DNS 端口号(端口 53)的 UDP 端口的数据包。假设还没有运行一个进程来声明这个端口号,DNS 服务器将被授予这个端口。

接下来,假设一台 IP 地址为 192.168.1.30 的客户机想要向服务器发出一个查询。它将在内存中创建一个请求,然后要求操作系统将该数据块作为 UDP 数据包发送。因为当数据包返回时需要某种方法来识别客户端,并且客户端没有明确请求端口号,所以操作系统会为其分配一个随机的端口号,比如端口 44137。

因此,该数据包将向端口 53 飞去,其地址如下所示:

Source (192.168.1.30:44137) ® Destination (192.168.1.9:53)

一旦它制定了一个响应,DNS 服务器将要求操作系统发送一个 UDP 数据包作为响应,将这两个地址反过来,以便将回复直接返回给发送方。

Source (192.168.1.9:53) ® Destination (192.168.1.30:44137)

因此,UDP 方案实际上非常简单;只需要一个 IP 地址和端口就可以将数据包发送到目的地。

但是客户端程序如何知道它应该连接的端口号呢?有三种通用方法。

  • 惯例 :互联网号码分配机构(IANA) 指定了许多端口号作为特定服务的官方、知名端口。这就是为什么在前面的例子中,DNS 应该在 UDP 端口 53。
  • 自动配置 :通常,当计算机首次连接到网络时,会使用 DHCP 之类的协议获知 DNS 之类的关键服务的 IP 地址。通过将这些 IP 地址与众所周知的端口号相结合,程序可以访问这些基本服务。
  • 手动配置 :对于前两种情况未涵盖的所有情况,管理员或用户的手动干预将必须提供服务的 IP 地址或相应的主机名。这种意义上的手动配置正在发生,例如,每当您在 web 浏览器中键入 web 服务器名称时。

当决定定义端口号时,例如 DNS 的 53,IANA 认为它们分为三个范围——这适用于 UDP 和 TCP 端口号。

  • 著名港口(0–1023)是最重要和最广泛使用的服务。在许多类似 Unix 的操作系统上,普通用户程序不能监听这些端口。在过去,这可以防止麻烦的大学生在多用户的大学机器上运行伪装成重要系统服务的程序。如今,当托管公司分发命令行 Linux 账户时,同样的谨慎也适用。
  • 注册的端口(1024–49151)通常不会被操作系统视为特殊端口——例如,任何用户都可以编写一个程序来占用端口 5432 并伪装成 PostgreSQL 数据库——但是它们可以被 IANA 注册用于特定的服务,IANA 建议您避免将它们用于除了指定服务之外的任何事情。
  • 剩余的端口号(49152–65535)可以自由使用。正如您将看到的,它们是现代操作系统用来生成任意端口号的池,当客户端不关心它的输出连接分配了什么端口时。

当您编写接受来自用户输入(如命令行或配置文件)的端口号的程序时,不仅允许数字端口号,而且允许众所周知的端口的可读名称,这是很友好的。这些名字是标准的,它们可以通过 Python 的标准socket模块中的getservbyname()函数获得。如果你想询问域名服务的端口,你可以这样找到:

>>> import socket
>>> socket.getservbyname('domain')
53

正如你将在第四章中看到的,端口名也可以由更复杂的getaddrinfo()函数解码,该函数也由socket模块提供。

众所周知的服务名和端口号的数据库通常保存在 Linux 和 Mac OS X 机器上的文件/etc/services中,您可以在闲暇时仔细阅读。特别是文件的前几页,散落着古老的协议,尽管多年来世界上任何地方都没有真正的数据包发给它们,但这些协议仍然保留着号码。在www.iana.org/assignments/port-numbers,IANA 还在线维护了一份最新的(通常更广泛的)副本。

套接字

Python 没有试图发明自己的网络编程 API,而是做出了一个有趣的决定。本质上,Python 的标准库只是提供了一个基于对象的接口 给所有普通的、粗糙的、低级操作系统调用,这些调用通常用于在 POSIX 兼容的操作系统上完成网络任务。这些调用甚至与它们包装的底层操作同名。Python 愿意公开传统的系统调用,而在它出现之前,每个人都已经理解了这些调用,这也是 Python 在 20 世纪 90 年代早期给我们这些努力学习低级语言的人带来一股新鲜空气的原因之一。最终,一种更高级的语言出现了,它允许我们在需要时进行低级操作系统调用,而不是坚持使用笨拙、功能不足但表面上“更漂亮”的特定于语言的 API。记住一组在 C 和 Python 中都有效的调用要容易得多。

在 Windows 和 POSIX 系统(如 Linux 和 Mac OS X)上,底层系统要求联网,其中心思想是一个被称为套接字的通信端点。操作系统使用整数来标识套接字,但是 Python 会向您的 Python 代码返回一个更方便的socket.socket对象。它在内部记住这个整数(你可以调用它的fileno()方法来查看它),并在每次你调用它的一个方法来请求在套接字上运行一个系统调用时自动使用它。

Image 注意在 POSIX 系统上,标识套接字的fileno()整数也是从表示打开文件的整数池中提取的文件描述符。假设在 POSIX 环境中,您可能会遇到这样的代码,它获取这个整数,然后使用它对文件描述符执行非网络调用,如os.read()os.write(),对实际上是网络通信端点的对象执行类似文件的操作。但是,因为本书中的代码也是为在 Windows 上工作而设计的,所以您将只对您的套接字执行真正的套接字操作。

插座在运行中是什么样子的?看看清单 2-1 中的,它显示了一个简单的 UDP 服务器和客户端。您可以看到,它只对函数socket.socket()进行了一次 Python 标准库调用,所有其他调用都是对它返回的 socket 对象的方法进行的。

清单 2-1 。UDP 服务器和客户端在环回接口上

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter02/udp_local.py
# UDP client and server on localhost

import argparse, socket
from datetime import datetime

MAX_BYTES = 65535

def server(port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind(('127.0.0.1', port))
    print('Listening at {}'.format(sock.getsockname()))
    while True:
        data, address = sock.recvfrom(MAX_BYTES)
        text = data.decode('ascii')
        print('The client at {} says {!r}'.format(address, text))
        text = 'Your data was {} bytes long'.format(len(data))
        data = text.encode('ascii')
        sock.sendto(data, address)

def client(port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    text = 'The time is {}'.format(datetime.now())
    data = text.encode('ascii')
    sock.sendto(data, ('127.0.0.1', port))
    print('The OS assigned me the address {}'.format(sock.getsockname()))
    data, address = sock.recvfrom(MAX_BYTES)  # Danger!
    text = data.decode('ascii')
    print('The server {} replied {!r}'.format(address, text))

if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Send and receive UDP locally')
    parser.add_argument('role', choices=choices, help='which role to play')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='UDP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.p)

即使您当前不在网络范围内,您也应该能够在自己的计算机上运行该脚本,因为服务器和客户端都只使用本地主机 IP 地址,无论您是否连接到真实的网络,该地址都应该可用。请先尝试启动服务器。

$ python udp_local.py server
Listening at ('127.0.0.1', 1060)

打印完这行输出后,服务器等待传入的消息。

在源代码中,您可以看到服务器启动和运行需要三个步骤。

它首先用socket() 创建了一个普通的套接字调用。这个新的套接字尚未绑定到 IP 地址或端口号,尚未连接到任何东西,如果您试图使用它进行通信,将会引发异常。然而,这个套接字至少被标记为一种特殊的类型:它的族是AF_INET,互联网协议族,它是SOCK_DGRAM数据报类型,这意味着它将在 IP 网络上使用 UDP。请注意,术语数据报(而不是数据包)是应用级传输数据块的官方术语,因为操作系统网络堆栈不保证网络上的单个数据包实际上代表单个数据报。(参见下一节,我坚持数据报和包之间的一一对应,以便您可以测量最大传输单位[MTU]。)

接下来,这个简单的服务器使用bind()命令 请求一个 UDP 网络地址,您可以看到这是一个简单的 Python 元组,由一个str IP 地址(稍后您将看到,主机名也是可以接受的)和一个int UDP 端口号组成。如果另一个程序已经在使用该 UDP 端口,而服务器脚本无法获得该端口,则此步骤可能会失败并出现异常。尝试运行该服务器的另一个副本,您将看到它如下所示:

$ python udp_local.py server
Traceback (most recent call last):
  ...
OSError: [Errno 98] Address already in use

当然,在第一次运行服务器时,有很小的可能会收到这个异常,因为 UDP 端口 1060 已经在您的机器上使用了。碰巧的是,在为第一个例子选择端口号时,我发现自己有点困惑。当然,它必须大于 1023,否则如果不是系统管理员,您就无法运行该脚本——尽管我确实喜欢我的小示例脚本,但我真的不想鼓励任何人以系统管理员的身份运行它们!我可以让操作系统选择端口号(正如我对客户机所做的那样,稍后您将会看到),让服务器将它打印出来,然后让您将它作为命令行参数之一键入客户机。然而,那样我就不能亲自向您展示请求特定端口号的语法了。最后,我考虑使用前面描述的编号较大的“短暂”端口,但是这些端口可能已经被您机器上的其他应用使用,比如您的 web 浏览器或 SSH 客户端 。

因此,我唯一的选择似乎是从 1023 以上的预留但不知名的范围中选择一个端口。我浏览了一下列表,打赌你,亲爱的读者,不会在运行我的 Python 脚本的笔记本电脑、台式机或服务器上运行 SAP BusinessObjects Polestar。如果是,那么尝试给服务器一个–p选项来选择不同的端口号。

注意,Python 程序总是可以使用套接字的getsockname()方法 来检索包含套接字绑定的当前 IP 地址和端口的元组。

一旦套接字绑定成功,服务器就可以开始接收请求了!它进入一个循环并重复运行recvfrom() ,告诉例程它将愉快地接收最大长度为 65,535 字节的消息——这个值恰好是 UDP 数据报可能具有的最大长度,因此您将始终看到每个数据报的完整内容。直到你与客户发送消息,你的recvfrom()呼叫将永远等待。

一旦数据报到达,recvfrom()将返回给你发送数据报的客户端的地址以及数据报的内容(以字节为单位)。使用 Python 将字节直接转换为字符串的能力,您可以将消息打印到控制台,然后将回复数据报返回给客户机。

因此,让我们启动我们的客户端并检查结果。客户端代码也显示在清单 2-1 中。

(顺便说一句,我希望这个例子不要混淆——像书中的其他例子一样——将服务器和客户机代码组合成一个清单,通过命令行参数选择。我通常更喜欢这种风格,因为它使服务器和客户端逻辑在页面上彼此靠近,并且更容易看出哪些服务器代码片段与哪些客户端代码片段相匹配。)

当服务器仍在运行时,在系统上打开另一个命令窗口,并尝试连续运行客户机两次,如下所示:

$ python udp_local.py client
The OS assigned me the address ('0.0.0.0', 46056)
The server ('127.0.0.1', 1060) replied 'Your data was 46 bytes long'
$ python udp_local.py client
The OS assigned me the address ('0.0.0.0', 39288)
The server ('127.0.0.1', 1060) replied 'Your data was 46 bytes long'

在服务器的命令窗口中,您应该看到它报告它所服务的每个连接。

The client at ('127.0.0.1', 46056) says 'The time is 2014-06-05 10:34:53.448338'
The client at ('127.0.0.1', 39288) says 'The time is 2014-06-05 10:34:54.065836'

尽管客户机代码比服务器代码稍微简单一些——只有三行网络代码——但它引入了两个新概念。

客户端对sendto() 的调用提供了消息和目的地址。这个简单的调用是向服务器发送数据报所必需的!但是,当然,如果要进行通信,您需要客户端的 IP 地址和端口号。因此,操作系统会自动分配一个,从调用getsockname()的输出中可以看到。正如承诺的那样,每个客户端端口号都来自 IANA 的“短暂”端口号范围。(至少它们在这里,在我的笔记本电脑上,在 Linux 下;在不同的操作系统下,你可能会得到不同的结果。)

当您使用完服务器后,您可以在运行它的终端中按 Ctrl+C 来终止它。

滥交的客户和不受欢迎的回复

清单 2-1 中的客户端程序实际上是危险的!如果您查看它的源代码,您会发现虽然recvfrom()返回了传入数据报的地址,但是代码从不检查它接收到的数据报的源地址,以验证它实际上是来自服务器的回复。

您可以通过延迟服务器的回复来发现这个问题,并查看其他人是否可以发送这个天真的客户端可以信任的响应。在像 Windows 这样功能较弱的操作系统上,您可能需要在服务器的接收和发送之间添加一个很长的time.sleep()调用,以模拟一个需要很长时间来响应的服务器。然而,在 Mac OS X 和 Linux 上,一旦服务器建立了它的套接字来模拟一个需要很长时间来响应的服务器,您可以更简单地用 Ctrl+Z 暂停服务器。

因此,启动一个新的服务器,然后使用 Ctrl+Z 暂停它。

$ python udp_local.py server
Listening at ('127.0.0.1', 1060)
^Z
[1]  + 9370 suspended  python udp_local.py server
$

如果您现在运行客户端,它将发送其数据报,然后挂起,等待接收回复。

$ python udp_local.py client
The OS assigned me the address ('0.0.0.0', 39692)

假设您现在是一个攻击者,想要通过在服务器有机会发送自己的回复之前跳入并发送您的数据报来伪造来自服务器的响应。由于客户端已经告诉操作系统它愿意接收任何数据报,并且没有对结果进行健全性检查,所以它应该相信您的假回复实际上来自服务器。您可以在 Python 提示符下使用快速会话发送这样的包。

$ python3
Python 3.4.0 (default, Apr 11 2014, 13:05:18)
[GCC 4.8.2] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import socket
>>> sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
>>> sock.sendto('FAKE'.encode('ascii'), ('127.0.0.1', 39692))
4

客户端将立即退出,并愉快地将这个第三方回复解释为它正在等待的响应。

The server ('127.0.0.1', 37821) replied 'FAKE'

您现在可以通过键入fg取消服务器的冻结,让它继续运行(它现在会看到已经排队等待的客户机数据包,并将它的回复发送到现在关闭的客户机套接字)。照常按 Ctrl+C 杀死它。

请注意,客户端容易受到任何能够将 UDP 数据包发送到它的人的攻击。这不是一个中间人攻击者控制网络并能从虚假地址伪造数据包的例子,这种情况只能通过使用加密来防止(见第六章)。相反,无特权的发送者完全在规则内操作并发送具有合法返回地址的分组,然而其数据被接受。

一个侦听网络客户端将接受或记录它看到的每一个数据包,而不考虑该数据包是否被正确寻址,这在技术上被称为混杂客户端。有时我们故意写这些,例如当我们进行网络监控并希望看到到达接口的所有数据包时。然而,在这种情况下,滥交是一个问题。

只有好的、写得好的加密才能让你的代码确信它已经与正确的服务器进行了对话。除此之外,你还可以做两个快速检查。首先,设计或使用在请求中包含唯一标识符或请求 ID 的协议,该标识符或请求 ID 会在应答中重复出现。如果回复包含您正在寻找的 ID,那么——只要 ID 的范围足够大,某人不可能简单地用包含每个可能的 ID 的数千或数百万个包来迅速淹没您——看到您的请求的某人至少必须已经编写了它。第二,要么对照您发送的地址检查回复包的地址(记住 Python 中的元组可以简单地与==进行比较),要么使用connect()禁止其他地址向您发送包。更多详细信息,请参见以下章节“连接 UDP 套接字” 和“请求 id”。

不可靠性、回退、阻塞和超时

因为前面几节中的客户机和服务器都运行在同一台机器上,并通过它的环回接口进行通信——环回接口不是一个可能会出现信号故障的物理网卡——所以数据包不可能真正丢失,所以您实际上看不到清单 2-1 中的 UDP 的任何不便之处。当数据包真的可能丢失时,代码是如何变得更加复杂的?

看一下清单 2-2 。该服务器并不总是响应客户端请求,而是随机选择只响应来自客户端的一半请求,这将让您了解如何在客户端代码中建立可靠性,而无需等待可能需要几个小时才会在网络上出现真正的数据包丢失!

清单 2-2 。UDP 服务器和客户端在不同的机器上

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter02/udp_remote.py
# UDP client and server for talking over the network

import argparse, random, socket, sys

MAX_BYTES = 65535

def server(interface, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((interface, port))
    print('Listening at', sock.getsockname())
    while True:
        data, address = sock.recvfrom(MAX_BYTES)
        if random.random() < 0.5:
            print('Pretending to drop packet from {}'.format(address))
            continue
        text = data.decode('ascii')
        print('The client at {} says {!r}'.format(address, text))
        message = 'Your data was {} bytes long'.format(len(data))
        sock.sendto(message.encode('ascii'), address)

def client(hostname, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    hostname = sys.argv[2]
    sock.connect((hostname, port))
    print('Client socket name is {}'.format(sock.getsockname()))

    delay = 0.1  # seconds
    text = 'This is another message'
    data = text.encode('ascii')
    while True:
        sock.send(data)
        print('Waiting up to {} seconds for a reply'.format(delay))
        sock.settimeout(delay)
        try:
            data = sock.recv(MAX_BYTES)
        except socket.timeout:
            delay *= 2  # wait even longer for the next request
            if delay > 2.0:
                raise RuntimeError('I think the server is down')
        else:
            break   # we are done, and can stop looping

    print('The server says {!r}'.format(data.decode('ascii')))

if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Send and receive UDP,'
                                     ' pretending packets are often dropped')
    parser.add_argument('role', choices=choices, help='which role to take')
    parser.add_argument('host', help='interface the server listens at;'
                        'host the client sends to')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='UDP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.host, args.p)

虽然前面示例中的服务器告诉操作系统它只需要数据包,这些数据包是通过专用的 127.0.0.1 接口从同一台机器上的其他进程到达的,但是您可以通过将服务器 IP 地址指定为空字符串来使该服务器更加慷慨。这意味着“任何本地接口”,我的 Linux 笔记本电脑意味着向操作系统请求 IP 地址 0.0.0.0。

$ python udp_remote.py server ""
Listening at ('0.0.0.0', 1060)

每次收到一个请求,服务器将通过random()抛硬币来决定这个请求是否会被响应,这样你就不必整天运行客户机来等待一个真正被丢弃的包。无论它做出什么决定,它都会在屏幕上显示一条信息,这样您就可以跟上它的活动。

我们如何编写一个“真正的”UDP 客户端,一个必须处理数据包可能丢失的事实的客户端?

首先,UDP 的不可靠性 意味着客户端必须在一个循环中执行它的请求。它要么准备好永远等待回复,要么武断地决定何时等待回复“太久”,需要发送另一个回复。这种困难的选择是必要的,因为客户通常没有办法区分这三种完全不同的事件:

  • 回复需要很长时间才能回来,但很快就会到了。
  • 回复永远不会到达,因为它或请求已经丢失。
  • 服务器关闭了,它没有回复任何人。

因此,UDP 客户端必须选择一个时间表,如果它等待一段合理的时间而没有得到响应,它将根据该时间表发送重复的请求。当然,这样做可能会浪费服务器的时间,因为第一个回复可能即将到达,而请求的第二个副本可能会导致服务器执行不必要的重复工作。然而,在某些时候,客户端必须决定重新发送请求,否则就要冒永远等待的风险。

因此,这个客户端首先在套接字上执行一个settimeout(),而不是让操作系统在recv()调用中永远暂停。这通知系统,客户端不愿意在套接字操作中等待超过delay秒,并且一旦调用等待了那么长时间,它希望调用以socket.timeout异常中断。

等待网络操作完成的呼叫被称为阻塞呼叫者。术语阻塞 用于描述类似recv()的调用,它使客户端等待直到新数据到达。当你读到第七章讨论服务器架构的时候,阻塞和非阻塞网络调用之间的区别将变得非常明显!

这个特定的客户端开始时只等待了十分之一秒。对于我的家庭网络,ping 时间通常是几十毫秒,这很少会导致客户端仅仅因为回复延迟而发送重复的请求。

这个客户端程序的一个重要特性是如果超时 到达会发生什么。它不会而不是简单地以固定的时间间隔一遍又一遍地发送重复请求!由于数据包丢失的主要原因是拥塞——正如任何人都知道在上传照片或视频的同时试图通过 DSL 调制解调器向上游发送正常数据——您最不想做的事情就是通过发送更多的数据包来应对可能丢失的数据包。

因此,该客户端使用一种称为指数补偿 的技术,在这种情况下,它的尝试变得越来越不频繁。这有助于在几个丢弃的请求或回复中幸存下来,同时使拥塞的网络能够慢慢恢复,因为所有活动客户端都放弃了它们的需求,并逐渐发送更少的数据包。虽然存在更好的指数回退算法,例如,该算法的以太网版本增加了一些随机性,以便两个竞争的网卡不太可能按照完全相同的时间表回退,但基本效果可以通过在每次未收到回复时将延迟加倍来实现。

请注意,如果请求是向 200 毫秒之外的服务器发出的,那么这个简单的算法每次都会发送每个请求的至少两个副本,因为它永远不会知道对这个服务器的请求总是需要 0.1 秒以上的时间。如果您正在编写一个生存时间很长的 UDP 客户端,请考虑让它记住最后几个请求需要多长时间才能完成,这样它就可以延迟第一次重试,直到服务器有足够的时间进行回复。

当您运行清单 2-2 客户机时,给它运行服务器脚本的另一台机器的主机名,如前所示。有时候,这个客户会很幸运,得到一个即时回复。

$ python udp_remote.py client guinness
Client socket name is ('127.0.0.1', 45420)
Waiting up to 0.1 seconds for a reply
The server says 'Your data was 23 bytes long'

然而,它经常会发现它的一个或多个请求从未得到答复,它将不得不重试。如果您仔细观察它的重复尝试,您甚至可以看到实时发生的指数后退,因为随着延迟计时器加速,回显到屏幕上的打印语句越来越慢。

$ python udp_remote.py client guinness
Client socket name is ('127.0.0.1', 58414)
Waiting up to 0.1 seconds for a reply
Waiting up to 0.2 seconds for a reply
Waiting up to 0.4 seconds for a reply
Waiting up to 0.8 seconds for a reply
The server says 'Your data was 23 bytes long'

您可以在运行服务器的终端上看到请求是否真的发出了,或者您是否碰巧在网络上遇到了真正的丢包。当我运行前面的测试时,我可以查看服务器的控制台,看到所有的数据包都成功了。

Pretending to drop packet from ('192.168.5.10', 53322)
Pretending to drop packet from ('192.168.5.10', 53322)
Pretending to drop packet from ('192.168.5.10', 53322)
Pretending to drop packet from ('192.168.5.10', 53322)
The client at ('192.168.5.10', 53322) says, 'This is another message'

如果服务器完全瘫痪了怎么办?不幸的是,UDP 让我们无法区分服务器故障和网络状况不佳,丢弃所有数据包或回复。当然,我认为我们不应该把这个问题归咎于 UDP。毕竟,世界本身无法让我们区分无法探测的事物和不存在的事物!因此,客户端能做的最好的事情就是在尝试了足够多之后放弃。终止服务器进程,然后再次尝试运行客户端。

$ python udp_remote.py client guinness
Client socket name is ('127.0.0.1', 58414)
Waiting up to 0.1 seconds for a reply
Waiting up to 0.2 seconds for a reply
Waiting up to 0.4 seconds for a reply
Waiting up to 0.8 seconds for a reply
Waiting up to 1.6 seconds for a reply
Traceback (most recent call last):
  ...
socket.timeout: timed out

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
RuntimeError: I think the server is down

当然,只有当你的程序试图执行一些简单的任务,需要产生输出或者返回某种结果给用户时,放弃才有意义。如果你正在编写一个全天运行的守护程序——比如说,在屏幕的角落有一个天气图标,显示从远程 UDP 服务获取的温度和天气预报——那么让代码“永远”重试是没问题的毕竟,台式机或笔记本电脑可能会长时间脱离网络,您的代码可能需要耐心等待数小时或数天,直到可以再次联系到预测服务器。

如果您正在编写整天重试的守护程序代码,那么不要坚持严格的指数后退,否则您很快就会将延迟增加到两个小时,然后您可能会错过整个半小时的时间,在此期间,笔记本电脑所有者会坐在咖啡店里,而您实际上可以访问网络。相反,选择某个最大延迟,比如说五分钟,一旦指数回退达到该时间,就保持该时间,这样,一旦用户在长时间断开连接后在网络上停留了五分钟,您就可以始终保证尝试更新。

如果您的操作系统允许您的进程收到类似网络恢复这样的事件的信号,那么您将能够做得比玩计时器和猜测网络何时恢复好得多。但遗憾的是,这种特定于系统的机制已经超出了本书的范围,所以现在让我们回到 UDP 以及它引发的一些问题。

连接 UDP 套接字

清单 2-2 ,您在上一节中研究过的,引入了另一个需要解释的新概念。我已经讨论过绑定——服务器用来获取它想要使用的地址的显式bind()调用,以及当客户端第一次尝试使用套接字并被操作系统分配了一个随机的临时端口号时发生的隐式绑定。

但是清单 2-2 中的远程 UDP 客户端也使用了一个我之前没有讨论过的新调用:套接字操作。你可以很容易地看到它做了什么。与每次您想向服务器发送东西时必须使用带有显式地址元组的sendto()不同,connect()调用让操作系统提前知道您想向其发送数据包的远程地址,因此您可以简单地向send()调用提供数据,而不必再次重复服务器地址。

但是connect()做了其他更重要的事情,这一点从阅读清单 2-2 中根本看不出来:它解决了客户混杂的问题!如果您在这个客户端上执行您在“混乱”部分中执行的测试,您将发现清单 2-2 中的客户端不容易接收来自其他服务器的数据包。这是因为使用connect()配置 UDP 套接字的首选目的地的第二个不太明显的影响:一旦您运行了connect(),操作系统将丢弃任何传入到您的端口的数据包,这些数据包的返回地址与您连接的地址不匹配。

那么,有两种方法来编写 UDP 客户端,它们要小心到达的数据包的返回地址。

  • 您可以使用sendto()将每个传出的数据包定向到一个特定的目的地,然后使用recvfrom()接收回复,并根据您发出未完成请求的服务器列表仔细检查每个返回地址。
  • 你可以在创建完套接字后立即connect()你的套接字,并与send()recv()通信。操作系统会为你过滤掉不需要的数据包。这仅适用于一次与一个服务器对话,因为在同一个套接字上再次运行connect()不会添加第二个目的地址。相反,它会彻底清除第一个地址,这样就不会再有来自更早地址的回复发送到您的程序。

使用connect()连接了 UDP 套接字后,可以使用套接字的getpeername()方法来记住它连接到的地址。在尚未连接的套接字上调用此函数时要小心。该调用不会返回 0.0.0.0 或其他通配符响应,而是会引发socket.error

关于connect()电话会议,应该提出最后两点。

首先,在 UDP 套接字上做connect()不会不会通过网络发送任何信息,或者做任何事情来警告服务器数据包可能到来。它只是将地址写入操作系统的内存中,供您稍后调用send()recv()时使用。

第二,请记住,使用回邮地址自己做一个connect()—或者甚至过滤掉不想要的数据包——并不是一种安全形式!如果网络上有人真的怀有恶意,他们的计算机通常很容易伪造带有服务器返回地址的数据包,这样他们伪造的回复就能顺利通过您的地址过滤器。

用另一台计算机的返回地址发送数据包被称为欺骗 ,这是协议设计者在设计被认为是安全抗干扰的协议时首先要考虑的事情之一。更多信息见第六章。

请求 id:好主意

在清单 2–1 和清单 2–2 中发送的消息都是简单的 ASCII 文本。但是,如果您曾经为 UDP 请求和响应设计过自己的方案,您应该认真考虑为每个请求添加一个序列号,并确保您接受的回复使用相同的序列号。在服务器端,只需将每个请求的编号复制到相应的回复中。这至少有两大好处。

首先,它保护您不被重复的请求所迷惑,这些请求被执行指数回退循环的客户端重复了几次。

你可以很容易地看到复制是如何发生的。你发送请求 a。你在等待答复时感到厌烦,所以你重复请求 a。然后你终于得到了答复,回复 a。你认为第一个副本丢失了,所以你愉快地继续赶路。

但是,如果两个请求都到达了服务器,而回复返回的速度有点慢,该怎么办呢?您收到了两个回复中的一个,但是另一个是否即将到达?如果你现在向服务器发送请求 B 并开始监听,你几乎会立即收到重复的回复 A,也许会认为这是你在请求 B 中所提问题的答案,你会变得困惑。从那时起,你可能会完全步调不一,把每一个回复理解为对应于一个不同的请求,而不是你认为的那个请求!

请求 id 可以保护您不受此影响。如果您为请求 A 的每个副本指定了请求 ID #42496,为请求 B 指定了 ID #16916,那么等待 B 的回答的程序循环可以简单地丢弃 ID 不等于#16916 的回答,直到它最终接收到一个匹配的回答。这防止了重复应答,重复应答不仅出现在您重复问题的情况下,而且出现在网络结构中的冗余意外地在服务器和客户端之间的某个地方生成数据包的两个副本的罕见情况下。

正如在“混乱”一节中提到的,请求 id 的另一个作用是提供对欺骗的威慑,至少在攻击者看不到您的数据包的情况下是如此。当然,如果他们可以,那么你就完全迷路了:他们会看到你发送的每个数据包的 IP、端口号和请求 ID,并且可以尝试发送假的回复,当然,希望他们的回复在服务器的回复之前到达,任何他们喜欢的请求!但是,如果攻击者无法观察到您的流量,不得不盲目地向您的服务器发送 UDP 数据包,一个大小合适的请求 ID 号会使您的客户端不太可能接受他们的回答。

您会注意到,我在刚刚讲述的故事中使用的示例请求 id 既不是连续的,也不容易猜测。这些特征意味着攻击者不知道什么是可能的序列号。如果你从 0 或 1 开始向上计数,攻击者的工作就容易多了。相反,尝试使用random模块来生成大整数。如果您的 ID 号是 0 到 N 之间的一个随机数,那么攻击者用一个有效的数据包攻击您的机会——即使假设攻击者知道服务器的地址和端口——最多是 1/N,如果他或她不得不疯狂地尝试攻击您机器上所有可能的端口号,这个机会可能会小得多。

但是,当然,这些都不是真正的安全——它只是防止无法观察您的网络流量的人发起幼稚的欺骗攻击。真正的安全保护你,即使攻击者既可以观察你的流量,也可以随时插入他们自己的消息。在第六章中,你将看到真正的安全是如何工作的。

绑定到接口

到目前为止,您已经看到了服务器发出的bind()调用中使用的 IP 地址的两种可能性。您可以使用'127.0.0.1'来表示您希望来自其他程序的数据包只在同一台机器上运行,或者您可以使用空字符串''作为通配符来表示您愿意接收通过任何网络接口到达服务器的数据包。

还有第三种选择。您可以提供机器的一个外部 IP 接口的 IP 地址,如以太网连接或无线网卡,,服务器将只监听发往这些 IP 的数据包。您可能已经注意到清单 2-2 实际上允许您为bind()调用提供一个服务器字符串,这将允许您做一些实验。

如果只绑定到外部接口会怎样?像这样运行服务器,使用你的操作系统告诉你的系统的外部 IP 地址:

$ python udp_remote.py server 192.168.5.130
Listening at ('192.168.5.130', 1060)

从另一台机器连接到这个 IP 地址应该仍然可以正常工作。

$ python udp_remote.py client guinness
Client socket name is ('192.168.5.10', 35084)
Waiting up to 0.1 seconds for a reply
The server says 'Your data was 23 bytes'

但是,如果您在同一台机器上运行客户机脚本,尝试通过环回接口连接到服务,数据包将永远不会被传送。

$ python udp_remote.py client 127.0.0.1
Client socket name is ('127.0.0.1', 60251)
Waiting up to 0.1 seconds for a reply
Traceback (most recent call last):
  ...
socket.error: [Errno 111] Connection refused

实际上,至少在我的操作系统上,结果甚至比数据包从未被传送要好。因为操作系统可以在不通过网络发送数据包的情况下查看自己的某个端口是否打开,所以它会立即回复到该端口的连接是不可能的!但是请注意,UDP 返回“连接被拒绝”的这种能力是环回的一种强大功能,在真实的网络中是永远不会看到的。在那里,必须简单地发送分组,而不指示是否有接收它的目的地端口。

尝试在同一台机器上再次运行客户端,但这次使用机器的外部 IP 地址。

$ python udp_remote.py client 192.168.5.130
Client socket name is ('192.168.5.130', 34919)
Waiting up to 0.1 seconds for a reply
The server says 'Your data was 23 bytes'

你看到发生了什么吗?允许本地运行的程序发送来自它们想要的任何机器 IP 地址的请求——即使它们只是使用那个 IP 地址与同一机器上的另一个服务进行对话!

因此,绑定到 IP 接口可能会限制哪些外部主机可以与您对话。但它肯定不会限制与同一台机器上的其他客户机的对话,只要它们知道应该连接的 IP 地址。

如果你试图同时运行两个服务器会发生什么?停止所有正在运行的脚本,并尝试在同一台机器上运行两台服务器。您将把其中一个连接到环回接口。

$ python udp_remote.py server 127.0.0.1
Listening at ('127.0.0.1', 1060)

现在这个地址被占用了,您不能在这个地址运行第二个服务器,因为这样操作系统就不知道哪个进程应该得到到达这个地址的任何给定的数据包。

$ python udp_remote.py server 127.0.0.1
Traceback (most recent call last):
  ...
OSError: [Errno 98] Address already in use

但是更令人惊讶的是,你也不能在通配符 IP 地址上运行服务器。

$ python udp_remote.py server
Traceback (most recent call last):
  ...
OSError: [Errno 98] Address already in use

这将失败,因为通配符地址包括 127.0.0.1,因此它与第一个服务器进程已经获取的地址冲突。但是,如果不是尝试在所有 IP 接口上运行第二台服务器,而是在外部 IP 接口上运行第二台服务器—服务器的第一个副本不监听该接口,那会怎么样呢?让我们试试。

$ python udp_remote.py server 192.168.5.130
Listening at ('192.168.5.130', 1060)

成功了。现在,这台机器上运行着两台具有相同 UDP 端口号的服务器,其中一台绑定到向内查看的环回接口,另一台向外查看到达我的无线网卡所连接的网络的数据包。如果您碰巧在一个有几个远程接口的机器上,您可以启动更多的服务器,每个远程接口一个服务器。

一旦你运行了这些服务器,试着用你的 UDP 客户端给它们发送一些包。您会发现只有一个服务器接收每个请求,并且在每种情况下,它将是保存您将 UDP 请求数据包定向到的特定 IP 地址的服务器。

所有这一切的教训是,IP 网络堆栈从不认为 UDP 端口是在任何给定时刻完全可用或正在使用的单独实体。相反,它认为 UDP“套接字名称”总是一对链接 IP 接口(即使它是通配符接口)和 UDP 端口号的名称。正是这些套接字名称在任何给定时刻都不能在监听服务器之间发生冲突,而不是正在使用的裸 UDP 端口。

最后一个警告是适当的。由于前面的讨论表明将您的服务器绑定到接口 127.0.0.1 可以保护您免受外部网络上可能生成的恶意数据包的攻击,您可能会认为绑定到一个外部接口可以保护您免受其他外部网络上的不满意者生成的恶意数据包的攻击。例如,在一个有多个网卡的大型服务器上,您可能想绑定到一个面向其他服务器的私有子网,并因此认为可以避免欺骗数据包到达您面向 Internet 的公共 IP 地址。

可悲的是,生活并非如此简单。实际上,这取决于您选择的操作系统及其配置方式,即是否允许发往一个接口的入站数据包到达另一个接口。如果数据包出现在您的公共互联网连接上,您的系统可能会很乐意接受声称来自您网络上其他服务器的数据包!请查阅您的操作系统文档,或咨询您的系统管理员,以了解有关您的具体情况的更多信息。在您的机器上配置和运行防火墙也可以提供保护,如果您的操作系统不这样做的话。

UDP 碎片

到目前为止,我在本章中一直在说,UDP 让您作为用户发送原始数据报,这些数据报只是简单地打包成 IP 数据包,只带有一点点附加信息——发送方和接收方的端口。但是您可能已经开始怀疑了,因为前面的程序清单表明 UDP 数据包的大小可以达到 64kB,而您可能已经知道您的以太网或无线网卡只能处理大约 1500 字节的数据包。

实际情况是,虽然 UDP 确实将小数据报作为单个 IP 包发送,但它必须将较大的 UDP 数据报分割成几个小的 IP 包,以便它们可以穿过网络(正如在第一章中简要讨论的那样)。这意味着大数据包更有可能被丢弃,因为如果它们中的任何一个片段未能到达目的地,那么整个数据包就永远无法被重组并传送到侦听操作系统。

除了较高的失败几率之外,将大型 UDP 数据包分段以适合网络传输的过程应该对您的应用不可见。然而,它在三个方面可能是相关的。

  • 如果您考虑效率,您可能希望将您的协议限制为小数据包,以减少重新传输的可能性,并限制远程 IP 堆栈重组您的 UDP 数据包并将其交给等待的应用所需的时间。
  • 如果 ICMP 数据包被防火墙错误地阻止,防火墙通常会允许您的主机自动检测您和远程主机之间的 MTU(这是 20 世纪 90 年代末的常见情况),那么您的较大 UDP 数据包可能会在您不知不觉中消失。MTU 是两台主机之间所有网络设备支持的“最大传输单位”或“最大数据包大小”。

如果您的协议可以自行选择如何在不同的数据报之间分割数据,并且您希望能够根据两台主机之间的实际 MTU 自动调整该大小,则某些操作系统允许您关闭分段,并在 UDP 数据包太大时收到错误消息。然后,您可以小心地设计最小单位下的数据报。

Linux 是支持最后一种选择的操作系统。看一下清单 2-3 ,它发送了一个很大的数据报。

清单 2-3 。发送大型 UDP 数据包

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter02/big_sender.py
# Send a big UDP datagram to learn the MTU of the network path.

import IN, argparse, socket

if not hasattr(IN, 'IP_MTU'):
    raise RuntimeError('cannot perform MTU discovery on this combination'
                       ' of operating system and Python distribution')

def send_big_datagram(host, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.IPPROTO_IP, IN.IP_MTU_DISCOVER, IN.IP_PMTUDISC_DO)
    sock.connect((host, port))
    try:
        sock.send(b'#' * 65000)
    except socket.error:
        print('Alas, the datagram did not make it')
        max_mtu = sock.getsockopt(socket.IPPROTO_IP, IN.IP_MTU)
        print('Actual MTU: {}'.format(max_mtu))
    else:
        print('The big datagram was sent!')

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Send UDP packet to get MTU')
    parser.add_argument('host', help='the host to which to target the packet')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='UDP port (default 1060)')
    args = parser.parse_args()
    send_big_datagram(args.host, args.p)

如果我在家庭网络中的其他服务器上运行这个程序,我会发现我的无线网络允许物理数据包不超过 1500 字节,而以太网通常支持 1500 字节。

$ python big_sender.py guinness
Alas, the datagram did not make it
Actual MTU: 1500

更令人惊讶的是,我的笔记本电脑上的环回接口,大概可以支持和我的 RAM 一样大的数据包,也强加了一个 MTU。

$ python big_sender.py 127.0.0.1
Alas, the datagram did not make it
Actual MTU: 65535

但是检查 MTU 的能力并不是到处都有的;有关详细信息,请查看您的操作系统文档。

插座选项

POSIX 套接字接口 支持控制网络套接字特定行为的各种套接字选项。你在清单 2-3 的中看到的IP_MTU_DISCOVER选项只是冰山一角。通过 Python 套接字方法getsockopt()setsockopt()访问选项,使用操作系统文档中为这两个系统调用列出的选项。在 Linux 上,尝试查看手册页 socket (7)、 udp (7),以及—当您进入下一章时— tcp (7)。

当设置套接字选项时,你首先必须命名它们所在的选项组,然后,作为后续参数,命名你想要设置的实际选项。有关这些组的名称,请查阅您的操作系统手册。就像 Python 调用getattr()setattr() 一样,set 调用只是比 get 多了一个参数。

value = s.getsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, value)

许多选项是特定于特定操作系统的,他们可能对选项的呈现方式很挑剔。以下是一些比较常见的选项:

  • SO_BROADCAST :这允许发送和接收广播 UDP 包,我将在下一节中介绍。
  • SO_DONTROUTE :只愿意发送寻址到该计算机直接连接的子网上的主机的数据包。例如,如果设置了这个套接字选项,我的笔记本电脑此时会愿意将数据包发送到网络 127.0.0.0/8 和 192.168.5.0/24,但不会愿意将它们发送到任何其他地方,因为数据包必须通过网关进行路由。
  • SO_TYPE :当传递给getsockopt()时,它返回给你一个套接字是属于SOCK_DGRAM类型并可用于 UDP 还是属于SOCK_STREAM类型并支持 TCP 的语义(参见第三章)。

下一章将进一步介绍一些专门适用于 TCP 套接字的套接字选项。

广播

如果说 UDP 有什么超能力,那就是它支持广播的能力。您可以将数据报寻址到您的计算机所连接的整个子网,并让物理网卡广播数据报,这样所有连接的主机都可以看到数据报,而不必将数据单独复制到每个主机,而不必将数据报发送到其他特定主机。

应该立即提到的是,广播现在被认为是过时的,因为一种称为多播的更复杂的技术已经被开发出来,它让现代操作系统能够更好地利用许多网络和网络接口设备中内置的智能。此外,多播可以与不在本地子网上的主机一起工作。但是,如果您想要一种简单的方法来保持本地局域网上的游戏客户端或自动记分牌等内容保持最新,并且每个客户端都可以经受住偶尔丢失的数据包,那么 UDP 广播是一种简单的选择。

清单 2-4 展示了一个可以接收广播包的服务器和一个可以发送广播包的客户端的例子。如果仔细观察,您会发现这个清单和以前的清单中使用的技术几乎只有一处不同。在使用这个 socket 对象之前,您调用它的setsockopt()方法来打开广播。除此之外,服务器和客户机都很正常地使用套接字。

清单 2-4 。UDP 广播

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter02/udp_broadcast.py
# UDP client and server for broadcast messages on a local LAN

import argparse, socket

BUFSIZE = 65535

def server(interface, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((interface, port))
    print('Listening for datagrams at {}'.format(sock.getsockname()))
    while True:
        data, address = sock.recvfrom(BUFSIZE)
        text = data.decode('ascii')
        print('The client at {} says: {!r}'.format(address, text))

def client(network, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    text = 'Broadcast datagram!'
    sock.sendto(text.encode('ascii'), (network, port))

if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Send, receive UDP broadcast')
    parser.add_argument('role', choices=choices, help='which role to take')
    parser.add_argument('host', help='interface the server listens at;'
                        ' network the client sends to')
    parser.add_argument('-p', metavar='port', type=int, default=1060,
                        help='UDP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.host, args.p)

当尝试这个服务器和客户机时,您应该注意的第一件事是,如果您只是使用客户机发送寻址到特定服务器的 IP 地址的数据包,它们的行为就像普通的客户机和服务器一样。打开 UDP 套接字的广播不会禁用或更改它发送和接收特定地址数据包的正常能力。

当您查看本地网络的设置并使用其 IP“广播地址”作为客户端的目的地时,神奇的事情就发生了。首先,使用如下命令在网络上启动一台或两台服务器:

$ python udp_broadcast.py server ""
Listening for broadcasts at ('0.0.0.0', 1060)

然后,当这些服务器运行时,首先使用客户机向每个服务器发送消息。您将看到只有一台服务器接收每条消息。

$ python udp_broadcast.py client 192.168.5.10

但是当您使用本地网络的广播地址时,您会突然发现所有的广播服务器同时收到数据包!(但是没有普通的服务器会看到它——运行普通udp_remote.py服务器的几个副本,同时进行广播以使人信服。)目前在我的本地网络上,ifconfig命令告诉我广播地址是这样的:

$ python udp_broadcast.py client 192.168.5.255

果然,两台服务器都立即报告它们看到了该消息。如果您的操作系统很难确定广播地址,并且您不介意从主机的每个网络端口进行广播,Python 允许您在使用 UDP 套接字发送时使用特殊的主机名'<broadcast>'。在将这个名字传递给客户机时,要注意引用它,因为对于任何普通的 POSIX shell 来说,<>字符都是非常特殊的。

$ python udp_broadcast.py client "<broadcast>"

如果有任何独立于平台的方法来了解每个连接的子网及其广播地址,我会告诉你。不幸的是,如果您想做比使用这个特殊的'<broadcast>'字符串更具体的事情,您必须查阅您自己的操作系统文档。

何时使用 UDP

您可能认为 UDP 对于发送小消息会很有效。实际上,只有当主机一次只发送一条消息,然后等待响应时,UDP 才是有效的。如果您的应用可能会在一个脉冲串中发送几条消息,那么使用像 MQ 这样的智能消息队列实际上会更有效,因为它会设置一个短的计时器,让它将几条小消息捆绑到一个单独的传输中,可能是在 TCP 连接上,这比您更好地将有效负载分成片段!

然而,有一些使用 UDP 的好理由。

  • 因为您正在实现一个已经存在的协议,并且它使用 UDP。
  • 因为您正在设计一个时间关键的媒体流,它的冗余允许偶尔的数据包丢失,并且您永远不希望这一秒钟的数据被挂起,等待几秒钟前尚未传送的旧数据(TCP 就发生了这种情况)。
  • 因为不可靠的局域网子网多播对您的应用来说是一个很好的模式,UDP 完全支持它。

除了这三种情况之外,你可能应该看看本书后面的章节,寻找如何为你的应用构建通信的灵感。有句老话说,当你的应用有了 UDP 协议时,你可能已经彻底改造了 TCP——很糟糕。

摘要

用户数据报协议让用户级程序通过 IP 网络发送单个数据包。通常,客户端程序向服务器发送数据包,然后服务器使用每个 UDP 数据包中内置的返回地址进行回复。

POSIX 网络堆栈通过“套接字”的概念为您提供对 UDP 的访问,套接字是一个通信端点,可以位于一个 IP 地址和 UDP 端口号上——这两个东西合起来称为套接字的名称地址——并发送和接收数据报。Python 通过内置的socket模块提供这些原始的网络操作。

服务器需要bind()到一个地址和端口,然后才能接收输入的数据包。客户端 UDP 程序可以直接开始发送,操作系统会自动为它们选择一个端口号。

因为 UDP 是建立在网络数据包的实际行为之上的,所以它是不可靠的。数据包可能会因为网络传输介质上的故障或网段太忙而被丢弃。客户端必须对此进行补偿,愿意重新发送请求,直到收到回复。为了防止使繁忙的网络变得更糟,当客户端遇到重复的故障时,它们应该使用指数回退,并且如果它们发现到服务器的往返所花费的时间比它们最初愿意等待的时间长,它们也应该延长它们的初始等待时间。

请求 id 对于解决回复重复问题至关重要,在这种情况下,您认为丢失的回复最终会到达,并可能被误认为是您当前问题的回复。如果随机选择,请求 id 也有助于防止幼稚的欺骗攻击。

当使用套接字时,重要的是区分绑定的行为——通过这种行为,您获取一个特定的 UDP 端口供自己使用——和客户端通过连接执行的行为,这种行为限制了所有接收到的回复,因此它们只能来自您想要与之对话的特定服务器。

在 UDP 套接字可用的套接字选项中,最强大的是广播,它允许您向子网中的每台主机发送数据包,而不必分别发送给每台主机。这有助于编写本地局域网游戏或其他协作计算程序,也是您选择 UDP 作为新应用的少数原因之一。

三、TCP

传输控制协议(正式名称为 TCP/IP,但在本书的其余部分都称为 TCP)是互联网的主力。它于 1974 年首次定义,建立在互联网协议(IP,在第一章中描述)的数据包传输技术之上,让应用使用连续的数据流进行通信。除非连接因为网络问题而终止或冻结,否则 TCP 保证数据流将完好无损地到达,不会丢失、复制或打乱任何信息。

携带文档和文件的协议几乎总是建立在 TCP 之上。这包括将网页发送到您的浏览器、文件传输以及所有主要的电子邮件传输机制。TCP 也是人们或计算机之间进行长时间对话的协议选择的基础,例如 SSH 终端会话和许多流行的聊天协议。

当互联网还比较年轻的时候,通过在 UDP 上构建一个应用(参见第二章)并自己仔细选择每个单独数据报的大小和时间,有时很容易试图从网络中挤出更多的性能。但是现代 TCP 实现往往是复杂的,受益于 30 多年的改进、创新和研究。除了协议设计专家,很少有人能改进现代 TCP 栈的性能。如今,甚至像消息队列这样的性能关键型应用(第八章)通常也选择 TCP 作为它们的媒介。

TCP 如何工作

正如你在第一章和第二章中了解到的,网络是变化无常的生物。它们有时会丢弃您试图通过它们传输的数据包。他们偶尔会创建数据包的额外副本。此外,它们经常不按顺序发送数据包。对于 UDP 这样的简单数据报工具,您自己的应用代码必须考虑每个数据报是否到达,并在没有到达时制定恢复计划。但是使用 TCP,数据包本身隐藏在协议之下,您的应用可以简单地将数据流向其目的地,确信丢失的信息将被重新传输,直到它最终成功到达。

TCP/IP 的经典定义是 1981 年的 RFC 793,尽管许多后续的 RFC 都有详细的扩展和改进。

TCP 如何提供可靠的连接?以下是它的基本原则:

  • 每个 TCP 数据包都有一个序列号,这样接收端的系统就可以按正确的顺序将它们放回一起,还可以注意到序列中丢失的数据包,并要求重新传输它们。
  • 代替使用连续的整数(1,2,3...)为了对数据包进行排序,TCP 使用一个计数器来计算传输的字节数。例如,序列号为 7,200 的 1,024 字节的数据包后面会跟一个序列号为 8,224 的数据包。这意味着繁忙的网络堆栈不需要记住它是如何将数据流分解成数据包的。如果请求重传,它可以通过其他方式将数据流分成新的数据包(如果现在有更多的字节等待传输,这可能会让数据包容纳更多的数据),而接收器仍然可以将数据包重新组合在一起。
  • 在好的 TCP 实现中,初始序列号是随机选择的,这样恶棍就不能假定每个连接都从字节 0 开始。不幸的是,可预测的序列号使得伪造数据包变得更容易,这些数据包看起来像是数据的合法部分,可能会中断对话。
  • TCP 不是在发送下一个数据包之前需要对每个数据包进行确认,从而在锁定步骤中运行得非常慢,而是在等待响应之前一次发送整个数据包突发。在任何给定时刻,发送者愿意在网络上传输的数据量被称为 TCP 窗口的大小。
  • 接收端的 TCP 实现可以调整发送端的窗口大小,从而减慢或暂停连接。这被称为流量控制。这使得接收器在其输入缓冲区已满的情况下禁止传输额外的数据包,即使数据到达,它也必须丢弃更多的数据。
  • 最后,如果 TCP 认为数据包正在被丢弃,它会认为网络正在变得拥塞,并减少每秒发送的数据量。这对于无线网络和其他介质来说可能是一场灾难,在这些介质中,数据包仅仅因为噪声而丢失。它还会破坏正常运行的连接,直到路由器重启,端点无法通话,比如说 20 秒。当网络恢复时,两个 TCP 对等体将会认为网络的流量已经超负荷了,在重新建立联系时,它们将首先拒绝以除涓涓细流之外的任何方式向对方发送数据。

TCP 的设计除了刚才描述的行为之外,还涉及许多其他细微差别和细节,但理想情况下,这种描述会让您对它的工作方式有一个良好的感觉——尽管您会记得,您的应用看到的只是一个数据流,实际的数据包和序列号被您的操作系统网络堆栈巧妙地隐藏了起来。

何时使用 TCP

如果你的网络程序和我的完全一样,那么你从 Python 执行的大部分网络通信将使用 TCP。事实上,您可能在整个职业生涯中都没有刻意从您的代码中生成 UDP 包。(不过,正如你将在第五章中看到的,每当你的程序需要查找 DNS 主机名时,UDP 可能会出现在后台。)

尽管当两个互联网程序需要通信时,TCP 几乎已经成为通用的默认协议,但我将介绍一些它的行为不是最佳的实例,以防您正在编写的应用属于这些类别之一。

首先,TCP 是一种笨拙的协议,在这种协议中,客户端希望向服务器发送单个的、小的请求,然后它们就完成了,不会再与服务器进一步对话。两台主机建立 TCP 连接需要三个数据包,即著名的 SYN、SYN-ACK 和 ACK 序列。

  • SYN :“我想说话;这是我将开始使用的数据包序列号。”
  • SYN-ACK :“好的,这是我将在我的方向上使用的初始序列号。”
  • ACK :“好的!”

当连接完成时,需要另外三个或四个数据包来关闭连接——要么是快速 FIN、FIN-ACK 和 ACK,要么是每个方向上一对稍长的单独 FIN 和 ACK 数据包。总之,至少需要六个数据包来传递一个请求!在这种情况下,协议设计者很快转向 UDP。

但是,有一个问题要问,客户机是否想打开一个 TCP 连接,然后用它在几分钟或几小时内向同一台服务器发出许多单独的请求。一旦连接开始并且支付了握手的成本,每个实际的请求和响应在每个方向上只需要一个包,这将受益于 TCP 关于重传、指数补偿和流量控制的所有智能。

UDP 真正的优势在于客户机和服务器之间不存在长期的关系,特别是在客户机太多的情况下,如果必须为每个活动客户机提供单独的数据流,典型的 TCP 实现就会耗尽内存。

TCP 不适用的第二种情况是,当数据包丢失时,应用可以做一些比简单地重新传输数据更聪明的事情。例如,想象一个音频聊天对话。如果一秒钟的数据因为丢包而丢失,那么简单地一遍又一遍地重新发送同样一秒钟的音频,直到它最终到达,也没有什么好处。相反,客户端应该用它可以从确实到达的数据包中拼凑的任何音频来填充这尴尬的一秒钟(一个聪明的音频协议将使用来自前后时刻的一点高度压缩的音频来开始和结束每个数据包,以准确地覆盖这种情况),然后在中断后继续进行,就像它没有发生一样。这对于 TCP 来说是不可能的,它会顽固地重新传输丢失的信息,即使这些信息已经太旧而没有任何用处。UDP 数据报通常是互联网上实时流媒体的基础。

TCP 套接字是什么意思

正如 UDP 在第二章中的情况一样,TCP 使用端口号来区分在同一 IP 地址上运行的不同应用,并且它遵循关于众所周知的短暂端口号的完全相同的约定。如果您想查看详细信息,请重读该章中的“端口号”一节。

正如您在上一章中看到的,只需要一个套接字就可以发出 UDP:一个服务器可以打开一个 UDP 端口,然后从成千上万个不同的客户端接收数据报。虽然当然有可能将数据报套接字connect()到一个特定的对等体,使得套接字总是只send()到该对等体和从该对等体发回的recv()数据包,但是连接的概念只是为了方便。connect()的效果与您的应用简单地自行决定只向一个地址发送sendto()呼叫,然后忽略来自同一地址以外的任何地址的响应是完全一样的。

但是对于像 TCP 这样的有状态流协议,connect()调用成为所有进一步网络通信的开端。这是操作系统的网络堆栈启动上一节描述的握手协议的时刻,如果成功,TCP 流的两端都可以使用。

这意味着 TCP connect()与 UDP 套接字上的相同调用不同,可能会失败。远程主机可能不应答,或者拒绝连接。或者可能发生更模糊的协议错误,如立即接收到 RST(“复位”)分组。因为流连接涉及在两台主机之间建立持久连接,所以另一台主机需要监听并准备好接受您的连接。

在“服务器端”——根据定义,会话伙伴不执行connect()调用,而是接收 connect 调用发起的 SYN 包——传入的连接为 Python 应用生成了一个更重要的事件:创建一个新的套接字!这是因为 TCP 的标准 POSIX 接口实际上涉及两种完全不同的套接字:“被动”监听套接字和主动“连接”套接字。

  • 被动 套接字监听 套接字 维护服务器准备接收连接的“套接字名称”——地址和端口号。这种套接字不能接收或发送任何数据。它不代表任何实际的网络对话。相反,它是服务器如何首先提醒操作系统它愿意在给定的 TCP 端口号上接收传入的连接。
  • 一个活动的、连接的 套接字 被绑定到一个具有特定 IP 地址和端口号的特定远程会话伙伴。它只能用于与那个伙伴来回通话,而且它可以被读取和写入,而不用担心产生的数据如何被分割成包。流看起来非常像管道或文件,在 Unix 系统上,一个连接的 TCP 套接字可以传递给另一个希望从普通文件中读取的程序,而该程序甚至永远不会知道它正在通过网络进行对话。

请注意,虽然被动套接字通过它正在侦听的接口地址和端口号变得唯一(不允许任何其他人获取相同的地址和端口),但是可以有许多主动套接字共享相同的本地套接字名称。例如,一个繁忙的 web 服务器,有一千个客户端都与它建立了 HTTP 连接,它将有一千个活动套接字都绑定到 TCP 端口 80 的公共 IP 地址。活动套接字的独特之处在于四部分坐标,如下所示:

(local_ip, local_port, remote_ip, remote_port)

正是这个四元组,操作系统通过它来命名每个活动的 TCP 连接,并且检查传入的 TCP 包以查看它们的源地址和目的地址是否将它们与系统上任何当前活动的套接字相关联。

一个简单的 TCP 客户端和服务器

看一下清单 3-1 。正如我在前一章所做的,我在这里将两个独立的程序合并成一个清单——因为它们共享一些公共代码,这样客户端和服务器代码可以更容易地一起阅读。

清单 3-1 。简单的 TCP 服务器和客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter03/tcp_sixteen.py
# Simple TCP client and server that send and receive 16 octets

import argparse, socket

def recvall(sock, length):
    data = b''
    while len(data) < length:
        more = sock.recv(length - len(data))
        if not more:
            raise EOFError('was expecting %d bytes but only received'
                           ' %d bytes before the socket closed'
                           % (length, len(data)))
        data += more
    return data

def server(interface, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((interface, port))
    sock.listen(1)
    print('Listening at', sock.getsockname())
    while True:
        sc, sockname = sock.accept()
        print('We have accepted a connection from', sockname)
        print('  Socket name:', sc.getsockname())
        print('  Socket peer:', sc.getpeername())
        message = recvall(sc, 16)
        print('  Incoming sixteen-octet message:', repr(message))
        sc.sendall(b'Farewell, client')
        sc.close()
        print('  Reply sent, socket closed')

def client(host, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((host, port))
    print('Client has been assigned socket name', sock.getsockname())
    sock.sendall(b'Hi there, server')
    reply = recvall(sock, 16)
    print('The server said', repr(reply))
    sock.close()

if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Send and receive over TCP')
    parser.add_argument('role', choices=choices, help='which role to play')
    parser.add_argument('host', help='interface the server listens at;'
                        ' host the client sends to')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='TCP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.host, args.p)

在第二章的中,我非常仔细地讨论了bind() 的主题,因为你作为参数提供的地址做出了一个重要的选择:它决定了远程主机是否可以尝试连接到我们的服务器,或者你的服务器是否受到保护,不能与外部连接,只能被运行在同一台机器上的其他程序联系。相应地,第二章从仅将自己绑定到环回接口的安全程序列表开始,然后发展到接受来自网络上其他主机的连接的更危险的程序列表。

但是在这里,我将两种可能性合并到一个清单中。使用您从命令行提供的host参数,您可以更安全地选择绑定到 127.0.0.1,或者您可以选择绑定到您机器的一个外部 IP 地址——或者您可以提供一个空字符串来表示您将接受您机器的任何 IP 地址的连接。再次,如果你想记住所有的规则,回顾一下第二章,这些规则同样适用于 TCP 和 UDP 连接和套接字。

您选择的端口号也与您在第二章中为 UDP 选择端口号时具有相同的权重,同样,TCP 和 UDP 在端口号主题上的对称性非常相似,您可以简单地应用您在那里使用的推理来理解为什么在本章中使用了相同的选择。

那么,UDP 的早期成果与构建在 TCP 之上的新客户端和服务器之间有什么区别呢?

客户实际上看起来差不多。它创建一个套接字,用它想要与之通信的服务器的地址运行connect(),然后就可以自由地发送和接收数据了。但除此之外,还有几个不同之处。

首先,TCP connect() 调用——正如我刚才所讨论的——不是本地套接字配置的无关紧要的部分,它在 UDP 的情况下仅仅设置一个默认的远程地址,用于任何后续的send()recv()调用。这里,connect()是一个真实的实时网络操作,它启动客户机和服务器之间的三次握手,以便它们准备好进行通信。这意味着connect()可能会失败,因为您可以在服务器不运行时通过执行客户端来轻松验证。

$ python tcp_deadlock.py client localhost
Sending 16 bytes of data, in chunks of 16 bytes
Traceback (most recent call last):
  ...
ConnectionRefusedError: [Errno 111] Connection refused

第二,您将看到这个 TCP 客户端在某种程度上比 UDP 客户端简单得多,因为它不需要为丢弃的数据包做任何准备。由于 TCP 提供的保证,它可以send()数据,甚至不用停下来检查远程端是否接收到它,并且运行recv()而不必考虑重新传输其请求的可能性。客户端可以放心,网络堆栈将执行任何必要的重新传输,以使其数据通过。

第三,这个程序实际上比等效的 UDP 代码更复杂——这可能会让你感到惊讶,因为尽管有它的保证,对程序员来说,TCP 流似乎比 UDP 数据报更简单。但是正因为 TCP 认为你的输出和输入数据只是没有开始和结束的数据流,所以它可以随心所欲地把它们分成包。因此send()recv() 的含义与之前有所不同。在 UDP 的情况下,它们仅仅意味着“发送这个数据报”或“接收一个数据报”,每个数据报都是原子的:它要么到达,要么不是一个自包含的数据单元。应用永远不会看到只发送了一半或接收了一半的 UDP 数据报。只有完整的数据报才会被传送到 UDP 应用。

但是 TCP 可能会在传输过程中将其数据流拆分成几个不同大小的数据包,然后在接收端逐渐重新组合它们。尽管对于清单 3-1 中的 16 个八位字节的小消息来说,这几乎是不可能的,但是您的代码仍然需要为这种可能性做好准备。对于send()recv()呼叫,TCP 流的结果是什么?

从考虑send()开始。当你执行一个 TCP send() 时,你的操作系统的网络堆栈将面临三种情况之一。

  • 数据可以立即被本地系统的网络堆栈接受,要么是因为网卡可以立即自由传输,要么是因为系统有空间将数据复制到一个临时的传出缓冲区,以便您的程序可以继续运行。在这些情况下,send()立即返回,并且它将返回数据字符串的长度作为其返回值,因为整个字符串正在被传输。
  • 另一种可能是,网卡正忙,此套接字的传出数据缓冲区已满,系统无法(或不愿)分配更多空间。在这种情况下,send()的默认行为只是阻塞,暂停你的程序,直到数据可以被接受传输。
  • 还有最后一种中间可能性:输出缓冲区几乎满了,但还没有满,所以你试图发送的数据的部分可以立即排队。但是数据块的其余部分将不得不等待。在这种情况下,send()会立即完成,并返回从数据字符串开始处接受的字节数,但不会处理其余的数据。

由于最后一种可能性,您不能简单地在流套接字上调用send()而不检查返回值。您必须将一个send()调用放入一个循环中,在部分传输的情况下,这个循环将继续尝试发送剩余的数据,直到整个字节串都被发送完。有时,您会看到在网络代码中使用如下所示的循环来表达这一点:

bytes_sent = 0
while bytes_sent < len(message):
    message_remaining = message[bytes_sent:]
    bytes_sent += s.send(message_remaining)

幸运的是,Python 不会在每次有数据块要发送的时候强迫您自己跳这种舞。作为一个特殊的便利,标准库socket实现提供了一个友好的sendall()方法() ,清单 3-1 使用了这个方法。sendall()不仅比自己做要快,因为它是用 C 实现的,而且(对于那些知道这意味着什么的读者来说)它在循环期间释放了全局解释器锁,这样其他 Python 线程可以无争用地运行,直到所有数据都被传输完。

不幸的是,没有为recv()调用提供等效的标准库包装器,尽管它也有不完全传输的可能。在内部,recv() 的操作系统实现使用的逻辑非常接近发送时使用的逻辑。

  • 如果没有数据可用,那么recv()阻塞,你的程序暂停,直到数据到达。
  • 如果传入缓冲区中已经有足够的数据可用,那么您将获得与您给予recv()的许可一样多的字节。
  • 如果缓冲区只包含一些等待数据,但没有你允许recv()返回的那么多,那么你会立即返回那里发生的事情,即使它没有你请求的那么多。

这就是为什么recv()调用必须在一个循环中。操作系统无法知道这个简单的客户机和服务器正在使用固定宽度的 16 位字节消息。因为它不能猜测输入的数据何时最终会达到你的程序所认为的一个完整的消息,所以它会尽可能快地给你任何数据。

为什么 Python 标准库包含了sendall()却没有recv()方法的等价物?这可能是因为如今固定长度的消息非常少见。大多数协议对于如何分隔传入流的一部分有着复杂得多的规则,而不是简单的“消息总是 16 字节长”的决定在大多数现实世界的程序中,运行recv()的循环要比清单 3-1 中的更复杂,因为一个程序经常要读取或处理部分消息,然后才能猜测还会有多少消息。例如,一个 HTTP 响应由头、一个空行组成,然后在Content-Length头中指定了更多字节的数据。您不知道要运行多少次recv(),直到您至少收到了头,然后解析它们以找出内容长度,这种细节最好留给您的应用,而不是标准库。

每个对话一个套接字

转到清单 3-1 中的服务器代码,你会看到一个与你之前看到的非常不同的模式,这种差异取决于 TCP 流套接字的真正含义。回想一下我们之前的讨论,有两种不同类型的流套接字:监听套接字,服务器使用它们为传入的连接提供一个端口,以及连接套接字,它们代表服务器与特定客户端的对话。

在清单 3-1 中,你可以看到这种区别是如何在实际的服务器代码中实现的。这个链接可能会让您觉得奇怪,因为侦听套接字实际上会返回一个新的、连接的套接字,作为您通过调用accept()获得的值!让我们按照程序清单中的步骤来看看套接字操作发生的顺序。

首先,服务器运行bind() 来声明一个特定的端口。请注意,这还不能决定程序是客户端还是服务器,也就是说,它是主动建立连接还是被动等待接收传入的连接。它只是声明一个特定的端口,或者在一个特定的接口上,或者在所有的接口上,供这个程序使用。如果出于某种原因,客户端希望从其机器上的特定端口访问服务器,而不是简单地使用分配给它们的临时端口号,那么它们也可以使用这个调用。

真正的决策时刻伴随着下一个方法调用,当服务器宣布它想要使用套接字到listen() 。在 TCP 套接字上运行它完全改变了它的特性。在调用了listen()之后,套接字被不可撤销地改变了,并且从这一点开始,再也不能被用来发送或接收数据。这个特定的套接字对象现在永远不会连接到任何特定的客户端。相反,套接字现在只能用于通过其accept()方法接收传入的连接——这种方法你在本书中还没有见过,因为它的目的只是支持监听 TCP 套接字——这些调用中的每一个都等待新的客户端连接,然后返回一个全新的新的套接字,该套接字控制刚刚与它们开始的新对话。

从代码中可以看出,getsockname()对监听和连接的套接字都很有效,在这两种情况下,它都可以让您找出套接字使用的本地 TCP 端口。要了解一个已连接的套接字所链接到的客户机的地址,您可以在任何时候运行getpeername() 方法,或者您可以存储作为第二个返回值从accept()返回的套接字名称。当您运行此服务器时,您会看到两个值为您提供了相同的地址。

$ python tcp_sixteen.py server ""
Listening at ('0.0.0.0', 1060)
Waiting to accept a new connection
We have accepted a connection from ('127.0.0.1', 57971)
  Socket name: ('127.0.0.1', 1060)
  Socket peer: ('127.0.0.1', 57971)
  Incoming sixteen-octet message: b'Hi there, server'
  Reply sent, socket closed
Waiting to accept a new connection

让客户端与服务器建立一个连接,就像这样,产生了前面的输出:

$ python3 tcp_sixteen.py client 127.0.0.1
Client has been assigned socket name ('127.0.0.1', 57971)
The server said b'Farewell, client'

您可以从其余的服务器代码中看到,一旦连接的套接字被accept()返回,它就像客户端套接字一样工作,在它们的通信模式中没有进一步的不对称性。当数据变得可用时,recv()调用返回数据,当您想确保所有数据都被传输时,sendall()是发送整个数据块的最佳方式。

您会注意到,当在服务器套接字上调用listen()时,向它提供了一个整数参数。这个数字表明,在操作系统开始忽略新的连接并推迟任何进一步的三次握手之前,应该允许多少个等待的连接(这些连接还没有被accept()调用创建套接字)进行堆叠。我在示例中使用非常小的值1,因为我一次只支持一个示例客户端连接,但是当我在第七章中谈到网络服务器设计时,我会考虑更大的值。

一旦客户机和服务器完成了它们需要的一切,它们就close()告诉操作系统传输仍然留在输出缓冲区中的任何剩余数据,然后通过前面提到的 FIN-packet 关闭过程结束 TCP 会话。

地址已被使用

在清单 3-1 中还有最后一个你可能会好奇的细节。为什么服务器在尝试绑定到端口之前会小心翼翼地设置套接字选项SO_ REUSEADDR

如果您注释掉该行,然后尝试运行服务器,就可以看到未能设置该选项的后果。起初,你可能认为这没有什么后果。如果您所做的只是停止和启动服务器,那么您将看不到任何效果(这里我启动服务器,然后在终端的提示下用一个简单的 Ctrl+C 终止它):

$ python tcp_sixteen.py server ""
Listening at ('127.0.0.1', 1060)
Waiting to accept a new connection
^C
Traceback (most recent call last):
  ...
KeyboardInterrupt
$ python tcp_sixteen.py server ""
Listening at ('127.0.0.1', 1060)
Waiting to accept a new connection

但是,如果您启动服务器,对其运行客户机,然后尝试终止并重新运行服务器,您会看到很大的不同。当服务器开始备份时,您将收到一条错误消息:

$ python tcp_sixteen.py server
Traceback (most recent call last):
  ...
OSError: [Errno 98] Address already in use

多么神秘!为什么一个可以一遍又一遍重复的bind()会仅仅因为一个客户端已经连接而突然变得不可能?如果您继续尝试在没有SO_REUSEADDR选项的情况下运行服务器,您会发现该地址直到您最后一次连接客户端几分钟后才再次可用。

这种限制的原因是操作系统的网络堆栈非常小心。仅仅在监听的服务器套接字可以立即关闭并被遗忘。但是,一个连接的 TCP 套接字(实际上是在与客户机对话)不会立即消失,即使客户机和服务器可能都关闭了它们的连接并在每个方向上发送 FIN 数据包。为什么呢?因为即使在网络堆栈发送了关闭套接字的最后一个数据包之后,它也无法确定是否收到了该数据包。如果它碰巧被网络丢弃,那么远程终端可能在任何时候都想知道是什么用了这么长时间才发送最后一个包,并重新发送它的 FIN 包,希望最终收到一个应答。

像 TCP 这样可靠的协议显然必须有这样一个停止说话的点;从逻辑上来说,一些最后的包必须被挂起而不被确认,否则系统将不得不承诺无休止的交换“好吧,我们都同意我们都完成了,对吗?”消息,直到机器最终被关闭。然而,即使是最后一个数据包也可能会丢失,需要重新传输几次,另一端才能最终收到它。解决办法是什么?

答案是,从应用的角度来看,一旦一个已连接的 TCP 连接最终关闭,操作系统的网络堆栈实际上会在等待状态下保存一份长达四分钟的记录。RFC 将这些状态命名为关闭等待和时间等待。当关闭的套接字仍然处于这些状态中的任何一个时,任何最终的 FIN 分组都可以被正确地应答。如果 TCP 实现只是忘记了连接,那么它就不能用正确的 ACK 回复 FIN。

因此,一个服务器试图声明一个在过去几分钟内正在运行活动连接的端口,实际上是试图声明一个在某种意义上仍在使用的端口。这就是为什么如果你尝试一个bind()到那个地址,你会返回一个错误。通过指定套接字选项SO_REUSEADDR,您表明您的应用可以拥有一个端口,该端口的旧连接可能仍然在网络上的某个客户端上关闭。实际上,我在编写服务器代码时总是使用SO_REUSEADDR,从来没有遇到过任何问题。

绑定到接口

正如我在第二章讨论 UDP 时所解释的,当你执行bind()操作时,你与一个端口号配对的 IP 地址告诉操作系统你希望从哪个网络接口接收连接。清单 3-1 中的示例调用使用了本地 IP 地址 127.0.0.1,这可以保护您的代码免受来自其他机器的连接。

您可以通过在服务器模式下运行清单 3-1 来验证这一点,如前所示,并尝试从另一台机器连接客户机。

$ python tcp_sixteen.py client 192.168.5.130
Traceback (most recent call last):
  ...
ConnectionRefusedError: [Errno 111] Connection refused

您可以看到,如果您让服务器运行,它甚至没有反应。操作系统甚至不会通知它到其端口的传入连接被拒绝。(请注意,如果您的计算机上运行了防火墙,客户端在尝试连接时可能会挂起,而不是得到友好的“连接被拒绝”异常来告诉它正在发生什么!)

但是,如果您使用空字符串作为主机名来运行服务器,这将告诉 Python bind()例程您愿意接受通过您机器的任何活动网络接口的连接,那么客户端可以从另一台主机成功连接(空字符串是通过在命令行末尾给 shell 加上这两个双引号来提供的)。

$ python tcp_sixteen.py server ""
Listening at ('0.0.0.0', 1060)
Waiting to accept a new connection
We have accepted a connection from ('127.0.0.1', 60359)
  Socket name: ('127.0.0.1', 1060)
  Socket peer: ('127.0.0.1', 60359)
  Incoming sixteen-octet message: b'Hi there, server'
  Reply sent, socket closed
Waiting to accept a new connection

如前所述,我的操作系统使用特殊的 IP 地址 0.0.0.0 来表示“接受任何接口上的连接”,但这种约定在您的操作系统上可能有所不同,Python 通过让您使用空字符串来隐藏这种差异。

僵局

术语死锁用于计算机科学中的各种情况,在这些情况下,共享有限资源的两个程序可能因为糟糕的计划而永远等待对方。事实证明,在使用 TCP 时,这种情况很容易发生。

我前面提到过,典型的 TCP 栈使用缓冲区,这样它们就有地方放置传入的数据包数据,直到应用准备好读取它,并且它们可以收集传出的数据,直到网络硬件准备好传输传出的数据包。这些缓冲区的大小通常非常有限,系统通常不愿意让程序用未发送的网络数据填满所有的 RAM。毕竟,如果远程端还没有准备好处理数据,那么花费系统资源来生成更多的数据是没有意义的。

如果你遵循清单 3-1 中所示的客户端-服务器模式 ,这种限制通常不会给你带来麻烦,在这种模式中,每一端总是在转身向另一个方向发送数据之前读取其伙伴的完整消息。但是,如果您设计的客户机和服务器让太多的数据等待,而没有及时读取这些数据的安排,那么您很快就会遇到麻烦。

看看清单 3-2 中的一个例子,一个服务器和客户端不考虑后果就试图变得有点聪明。在这里,服务器作者做了一些实际上相当聪明的事情。服务器的工作是将任意数量的文本转换成大写字母。认识到客户端请求可以任意大,并且在尝试处理输入流之前尝试读取整个输入流可能会耗尽内存,服务器一次读取和处理 1,024 字节的小数据块。

清单 3-2 。可能死锁的 TCP 服务器和客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter03/tcp_deadlock.py
# TCP client and server that leave too much data waiting

import argparse, socket, sys

def server(host, port, bytecount):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((host, port))
    sock.listen(1)
    print('Listening at', sock.getsockname())
    while True:
        sc, sockname = sock.accept()
        print('Processing up to 1024 bytes at a time from', sockname)
        n = 0
        while True:
            data = sc.recv(1024)
            if not data:
                break
            output = data.decode('ascii').upper().encode('ascii')
            sc.sendall(output)  # send it back uppercase
            n += len(data)
            print('\r  %d bytes processed so far' % (n,), end=' ')
            sys.stdout.flush()
        print()
        sc.close()
        print('  Socket closed')

def client(host, port, bytecount):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    bytecount = (bytecount + 15) // 16 * 16  # round up to a multiple of 16
    message = b'capitalize this!'  # 16-byte message to repeat over and over

    print('Sending', bytecount, 'bytes of data, in chunks of 16 bytes')
    sock.connect((host, port))

    sent = 0
    while sent < bytecount:
        sock.sendall(message)
        sent += len(message)
        print('\r  %d bytes sent' % (sent,), end=' ')
        sys.stdout.flush()

    print()
    sock.shutdown(socket.SHUT_WR)

    print('Receiving all the data the server sends back')

    received = 0
    while True:
        data = sock.recv(42)
        if not received:
            print('  The first data received says', repr(data))
        if not data:
            break
        received += len(data)
        print('\r  %d bytes received' % (received,), end=' ')

    print()
    sock.close()

if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Get deadlocked over TCP')
    parser.add_argument('role', choices=choices, help='which role to play')
    parser.add_argument('host', help='interface the server listens at;'
                        ' host the client sends to')
    parser.add_argument('bytecount', type=int, nargs='?', default=16,
                        help='number of bytes for client to send (default 16)')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='TCP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.host, args.p, args.bytecount)

它可以很容易地分割工作——不需要做框架或分析——因为它只是试图在普通 ASCII 字符上运行upper()字符串方法。这是一个可以在每个输入块上单独执行的操作,而不用担心之前或之后的块。如果服务器试图运行像title()这样更复杂的字符串操作,事情就不会这么简单了,如果单词碰巧跨越了块边界而没有正确地重新组合,那么这个字符串操作就会在单词中间大写字母。例如,如果一个特定的数据流被分割成 16 字节的块,那么错误就会像这样出现:

>>> message = 'the tragedy of macbeth'
>>> blocks = message[:16], message[16:]
>>> ''.join(b.upper() for b in blocks)   # works fine
'THE TRAGEDY OF MACBETH'
>>> ''.join(b.title() for b in blocks)   # whoops
'The Tragedy Of MAcbeth'

在固定长度的块 上分割的同时处理文本也不适用于 UTF-8 编码的 Unicode 数据,因为多字节字符可能会在两个二进制块之间的边界上分割。在这种情况下,服务器必须比这个例子更加小心,并且在一个数据块和下一个数据块之间传递某种状态。

在任何情况下,像这样一次处理一个块的输入对于服务器来说都是非常智能的,即使这里用于说明的 1,024 字节的块大小对于今天的服务器和网络来说实际上是一个非常小的值。通过分段处理数据并立即发送响应,服务器限制了每次必须保存在内存中的数据量。这样设计的服务器可以同时处理数百台客户机,每台客户机发送的数据流总计数千兆字节,而不会增加内存或其他硬件资源的负担。

对于小数据流,清单 3-2 中的客户端和服务器看起来工作得很好。如果您启动服务器,然后使用指定适度字节数的命令行参数运行客户端,比如说,要求它发送 32 字节的数据,那么它将获得全部大写的文本。为简单起见,它会将您提供的任何值四舍五入到 16 字节的倍数。

$ python tcp_deadlock.py client 127.0.0.1 32
Sending 32 bytes of data, in chunks of 16 bytes
  32 bytes sent
Receiving all the data the server sends back
  The first data received says b'CAPITALIZE THIS!CAPITALIZE THIS!'
  32 bytes received

服务器将报告它确实代表其最近的客户端处理了 32 个字节。顺便说一下,服务器需要运行在同一台机器上,这个脚本使用本地主机 IP 地址来使示例尽可能简单。

Processing up to 1024 bytes at a time from ('127.0.0.1', 60461)
  32 bytes processed so far
  Socket closed

因此,当用少量数据测试时,这段代码看起来工作得很好。事实上,它也可能适用于更大的数量。尝试用数百或数千字节运行客户端,看看它是否继续工作。

顺便说一下,第一个数据交换示例向您展示了我之前描述的recv()的行为。即使服务器要求接收 1,024 个字节,如果这是可用的数据量,并且还没有来自客户端的数据,那么recv(1024)也很乐意只返回 16 个字节。

但是这个客户端和服务器可能会被推到可怕的境地。如果你尝试一个足够大的值,那么灾难就来了!试着使用客户端发送一个大的数据流,比如说,一个总计 1gb 的数据流。

$ python tcp_deadlock.py client 127.0.0.1 1073741824

您将看到客户端和服务器都在紧张地更新它们的终端窗口,它们气喘吁吁地向您更新它们发送和接收的数据量。这些数字会不断攀升,直到突然之间,两个连接都冻结了。实际上,如果您仔细观察,您会看到服务器首先停止,然后客户端很快停止。在我写这一章的 Ubuntu 笔记本电脑上,在它们停止之前处理的数据量各不相同,但是在我刚刚在我的笔记本电脑上完成的测试运行中,Python 脚本停止了,服务器说:

$ python tcp_deadlock.py server ""
Listening at ('0.0.0.0', 1060)
Processing up to 1024 bytes at a time from ('127.0.0.1', 60482)
  4452624 bytes processed so far

并且客户端在写入其输出数据流时被冻结了大约 350,000 字节。

$ python tcp_deadlock.py client "" 16000000
Sending 16000000 bytes of data, in chunks of 16 bytes
  8020912 bytes sent

为什么客户端和服务器都停止了?

答案是服务器的输出缓冲区和客户机的输入缓冲区最终都已填满,TCP 使用其窗口调整协议来通知这一事实,并阻止套接字发送额外的数据,这些数据将被丢弃并在以后重新发送。

为什么这会导致僵局?考虑每个数据块传输时会发生什么。客户端用sendall()发送。然后服务器通过recv()接受它,处理它,并通过另一个sendall()调用将它的大写版本传输回来。然后呢。嗯,没什么!客户端从不运行任何recv()调用——当它仍有数据要发送时——因此越来越多的数据备份,直到操作系统缓冲区不再愿意接受更多数据。

在前面显示的运行过程中,在网络堆栈确定客户端的传入队列已满之前,操作系统在其中缓冲了大约 4MB。在这一点上,服务器阻塞了它的sendall()调用,它的进程被操作系统暂停,直到阻塞被清除,它可以发送更多的数据。随着服务器不再处理数据或运行更多的recv()调用,现在轮到客户机开始备份数据了。操作系统似乎已经将它愿意在该方向排队的数据量限制在 3.5MB 左右,因为客户端在最终停止之前已经大致产生了数据。

在你自己的系统上,你可能会发现达到了不同的极限;上述数字是任意的,基于我的笔记本电脑此刻的心情。它们根本不是 TCP 工作方式所固有的。

这个例子的目的是教你两件事——当然,除此之外,显示如果立即可用的字节数更少,那么recv(1024)确实返回少于 1,024 的字节数!

首先,这个例子应该使网络连接两端的 TCP 栈中有缓冲区 的想法更加具体。这些缓冲区可以临时保存数据,这样,如果数据包到达时,它们的读取器恰好不在recv()调用内,就不会被丢弃并最终被重新发送。但是缓冲区不是无限的。最终,试图写入从未被接收或处理的数据的 TCP 例程将发现自己不再能够写入,直到一些数据最终被读取并且缓冲区开始变空。

第二,这个例子清楚地表明了协议 中包含的危险,这些协议没有交替锁定步骤,客户端请求有限数量的数据,然后等待服务器应答或确认。如果一个协议没有严格要求服务器读取一个完整的请求,直到客户端完成发送,然后在另一个方向发送一个完整的响应,那么像这里创建的情况可能会导致两者都冻结,除了手动终止程序,然后重写它以改进它的设计。

但是,网络客户机和服务器应该如何处理大量数据 而不进入死锁呢?事实上,有两种可能的答案。首先,他们可以使用套接字选项来关闭阻塞,这样像send()recv()这样的调用如果发现它们还不能发送任何数据,就会立即返回。你将在第七章的中了解到更多关于这个选项的信息,在那里你将认真地寻找构建网络服务器程序的可能方法。

或者,程序可以使用几种技术中的一种来一次处理来自几个输入的数据,或者通过分成单独的线程或进程(一个任务是将数据发送到套接字,另一个任务是将数据读取回来),或者通过运行操作系统调用,如select()poll(),让它们同时等待繁忙的传出和传入套接字,并对准备好的套接字做出响应。这些也将在第七章中探讨。

最后,请注意,当您使用 UDP 时,上述情况永远不会发生。这是因为 UDP 不实现流量控制。如果到达的数据报多于可以处理的数据报,那么 UDP 可以简单地丢弃其中一些数据报,让应用去发现它们丢失了。

封闭连接、半开连接

从前面的例子中可以看出,在另一个不同的问题上,还有两点需要说明。

首先,清单 3-2 向您展示了当到达文件结尾时 Python 套接字对象的行为。就像 Python 文件对象在没有剩余数据时返回一个空字符串一样,套接字在关闭时只返回一个空字符串。

在清单 3-1 中,我从不担心这个问题,因为在那种情况下,我对协议施加了足够严格的结构——交换一对正好 16 字节的消息——当通信完成时,我不需要关闭套接字来发送信号。客户机和服务器可以发送消息,同时让套接字保持打开状态,稍后再关闭它们的套接字,而不用担心有人在等待它们关闭。

但是在清单 3-2 中,客户端发送——因此服务器也处理并发回——任意数量的数据,其长度仅由用户在命令行输入的数字决定。所以你可以在代码中看到两次相同的模式:一个while循环,一直运行到最后看到一个从recv()返回的空字符串。注意,一旦到达第七章的并探索非阻塞套接字,这种正常的 Pythonic 模式将不再工作,其中recv()可能仅仅因为此刻没有可用的数据而引发异常。在这种情况下,使用其他技术来确定套接字是否已经关闭。

其次,您将看到客户端在发送完传输后,在套接字上发出一个 shutdown()调用。这解决了一个重要问题。如果服务器将一直读取,直到看到文件结束,那么客户端将如何避免在套接字上执行完整的close()操作,从而禁止自己运行许多recv()调用来接收服务器的响应呢?解决方案是“半关闭”套接字——也就是说,在不破坏套接字本身的情况下永久关闭一个方向的通信。在这种状态下,服务器不能再读取任何数据,但是它仍然可以在另一个方向上发送任何剩余的回复,该方向仍然是开放的。

如清单 3-2 中的所示,shutdown()调用可以用来结束双向套接字中的任何一个方向的通信。它的参数可以是三个符号之一。

  • 这是最常用的值,因为在大多数情况下,程序知道自己的输出何时完成,但不一定知道它的对话伙伴何时结束。这个值表示调用者将不再向套接字写入数据,从另一端读取的数据应该响应没有更多数据并指示文件结束。
  • SHUT_RD:这用于关闭传入的套接字流,这样,如果您的对等方试图在套接字上向您发送更多数据,就会遇到文件结束错误。
  • SHUT_RDWR:关闭套接字上的双向通信。起初,这可能看起来没什么用,因为您也可以只在套接字上执行一个close(),并且通信在两个方向上都是类似地结束的。关闭一个套接字和双向关闭套接字之间的区别是相当高级的。如果你的操作系统上的几个程序被允许共享一个套接字,那么close()仅仅是结束你的进程与套接字的关系,但是只要另一个进程还在使用它,它就保持打开。另一方面,shutdown()方法总是会立即禁用每个使用它的人的套接字。

由于不允许通过标准的socket()调用创建单向套接字,许多只需要在一个套接字上单向发送信息的程序员会先创建它,然后——一旦连接上了——立即运行shutdown(),向他们不需要的方向发送。这意味着,如果与之通信的对等体意外地试图以不应该的方向发送数据,操作系统缓冲区不会被不必要地填满。

在应该是单向的套接字上立即运行shutdown(),还会为混淆并试图发送数据的对等体提供更明显的错误消息。否则,意外数据要么会被忽略,要么甚至会填满缓冲区并导致死锁,因为它永远不会被读取。

像文件一样使用 TCP 流

由于 TCP 支持数据流,它们可能已经让您想起了普通文件,普通文件也支持读写顺序数据作为基本操作。Python 很好地将这些概念分开。文件对象可以read()write(),而套接字只能send()recv()。没有一种物体能同时做到这两点。(与底层 POSIX 接口相比,这实际上是一个更干净、更可移植的概念划分,它允许 C 程序员不加区别地调用套接字上的read()write(),就像它是一个普通的文件描述符一样。)

但是有时候你会想把一个套接字当作一个普通的 Python 文件对象——通常是因为你想把它传递给这样的代码,像很多 Python 模块,比如picklejsonzlib,可以直接从文件中读写数据。为此,Python 在每个返回 Python 文件对象的套接字上提供了一个makefile()方法,该对象真正在幕后调用recv()send()

>>> import socket
>>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>> hasattr(sock, 'read')
False
>>> f = sock.makefile()
>>> hasattr(f, 'read')
True

像 Ubuntu 和 Mac OS X 这样的 Unix 衍生系统上的套接字,像普通的 Python 文件一样,也有一个fileno()方法,让您发现它们的文件描述符编号,以防您需要将它提供给低级调用。当你在第七章的中探索select()poll()时,你会发现这很有帮助。

摘要

TCP 驱动的“流”套接字做任何必要的事情,包括重新传输丢失的数据包,重新排序无序到达的数据包,以及将大量数据流拆分为适合您的网络的最佳大小的数据包,以支持两个套接字之间的网络数据流的传输和接收。

与 UDP 一样,TCP 使用端口号来区分可能存在于一台机器上的许多流端点。想要接受传入的 TCP 连接的程序需要bind()到一个端口,在套接字上运行listen(),然后进入一个循环,该循环反复运行accept()来为每个传入的连接接收一个新的套接字,通过该套接字它可以与每个连接的特定客户端对话。想要连接到现有服务器端口的程序只需要创建一个套接字和一个地址的connect()

服务器通常希望在它们的套接字上设置选项,以免服务器最后一次运行时在同一个端口上关闭的旧连接阻止操作系统允许绑定。

使用send()recv()实际发送和接收数据。一些运行在 TCP 之上的协议会标记它们的数据,以便客户端和服务器自动知道通信何时完成。其他协议将 TCP 套接字视为真正的流,并发送和接收,直到到达文件结尾。shutdown()套接字方法可用于在一个套接字上产生一个方向的文件结束(所有套接字本质上都是双向的),同时保持另一个方向开放。

如果两个对等体被写入,使得套接字填充越来越多的永远不会被读取的数据,则会发生死锁。最终,一个方向将不再能够send()并可能永远等待积压清除。

如果你想把一个套接字传递给一个知道如何读写普通文件对象的 Python 例程,makefile() socket 方法会给你一个 Python 对象,当调用者需要读写时,这个对象会在后台调用recv()send()

四、套接字名称和 DNS

在前两章中,我们已经学习了 UDP 和 TCP 的基础知识,这是 IP 网络上两种主要的数据传输方式,现在是时候后退一步,讨论两个需要解决的更大的问题了,不管您使用哪种数据传输方式。在这一章中,我将讨论网络地址的主题,并且我将描述允许名字被解析为原始 IP 地址的分布式服务。

主机名和套接字

我们很少在浏览器或电子邮件客户端输入原始 IP 地址。相反,我们输入域名。一些域名标识整个组织,如python.orgbbc.co.uk,而另一些域名则指定特定的主机或服务,如www.google.comasaph.rhodesmill.org。一些网站让你通过简单地输入asaph来缩写一个主机名,他们会自动为你填写剩下的名字,假设你指的是同一个网站的asaph机器。然而,不管任何本地定制,指定一个包含所有部分(包括顶级域名)的完全限定域名总是正确的。

一个顶级域名(TLD)的想法曾经很简单:要么是。com,。net,。org,。gov,。mil,或者国际公认的两个字母的国家代码。uk。但是今天,许多其他更无聊的顶级域名,如.beer正在增加,这将使区分完全合格和部分合格域名变得更加困难(除非你试图记住顶级域名的整个列表!).

通常,每个 TLD 都有自己的一组服务器,并由负责授予 TLD 下的域所有权的组织运行。当你注册一个域名时,他们会在他们的服务器上添加一个条目。然后,当在世界任何地方运行的客户端想要解析您的域内的名称时,顶级服务器可以将该客户端转到您自己的域服务器,以便您的组织可以返回它想要的您创建的各种主机名的地址。使用顶级域名和推荐系统来回答域名请求的全球服务器集合一起提供了域名服务(DNS)

前两章已经向您介绍了这样一个事实,即套接字不能像数字或字符串那样用一个简单的 Python 值来命名。相反,TCP 和 UDP 都使用整数端口号在可能运行的许多不同的应用之间共享单个机器的 IP 地址,因此地址和端口号必须组合起来才能产生一个套接字名称,如下所示:

('18.9.22.69', 80)

虽然您可能已经能够从前几章中获得一些关于套接字名称的零散事实——比如第一项可以是主机名或带点的 IP 地址——但是现在是时候更深入地研究整个主题了。

您还记得,在创建和使用套接字的过程中,套接字名称在几个方面非常重要。作为参考,这里列出了所有主要的套接字方法,它们都需要某种套接字名称作为参数:

  • mysocket.accept() :每次在有传入连接准备移交给应用的侦听 TCP 流套接字上调用此函数时,它都会返回一个元组,其第二项是已连接的远程地址(元组中的第一项是连接到该远程地址的新套接字)。
  • mysocket.bind (address):这将给定的本地地址分配给套接字,以便传出的数据包有一个始发地址,并且来自其他机器的任何传入连接都有一个它们可以连接的名称。
  • mysocket.connect (address):这建立了通过这个套接字发送的数据将被定向到给定的远程地址。对于 UDP 套接字,如果调用者使用send()而不是sendto()recv()而不是recvfrom(),但不立即执行任何网络通信,那么这只是设置所使用的默认地址。但是,对于 TCP 套接字,这实际上是使用三次握手与另一台机器协商新的流,如果协商失败,将引发 Python 异常。
  • mysocket.getpeername() :返回该套接字连接的远程地址。
  • mysocket.getsockname() :返回这个套接字自己的本地端点的地址。
  • mysocket.recvfrom(...) :对于 UDP 套接字,它返回一个元组,该元组将一串返回的数据与接收它的地址配对。
  • mysocket.sendto (data, address):一个未连接的 UDP 端口使用这种方法在一个特定的远程地址发送数据包。

你有它!这些都是与套接字地址有关的主要套接字操作,都在一个地方,因此您对后面的注释有一些了解。一般来说,上述任何方法都可以接收或返回后面的任何类型的地址,这意味着无论您使用的是 IPv4、IPv6 还是我在本书中不涉及的不太常见的地址族,它们都可以工作。

五插座坐标

在研究第二章和第三章中的示例程序时,您特别注意了它们的套接字使用的主机名和 IP 地址。但是这些只是在每个 socket 对象的构造和部署过程中做出的五个主要决策的最后两个坐标。回想一下,步骤是这样的:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('localhost', 1060))

您可以看到这里指定了四个值:两个用于配置套接字,两个用于处理bind()调用。实际上有第五个可能的坐标,因为socket()接受第三个可选参数,总共有五个选择。我将依次讨论它们,从socket()的三个可能参数开始。

首先,地址族做出最大的决定:它从一台特定机器可能连接的多种网络中指定你想与之对话的网络。

在本书中,我将始终使用值AF_INET来表示地址族,因为我相信撰写关于 IP 网络的文章将最适合绝大多数 Python 程序员,同时给你提供在 Linux、Mac OS 甚至 Windows 上工作的技能。然而,如果你导入socket模块,打印出dir(socket),并寻找以AF_(“地址族”)开头的符号,你会看到其他选择,它们的名字你可能认识,比如 AppleTalk 和蓝牙。在 POSIX 系统上特别流行的是AF_UNIX地址族,它提供了非常类似于互联网套接字的连接,但是它通过“连接”到文件名而不是主机名和端口号,直接在同一台机器上的程序之间运行。

其次,在地址族之后是套接字类型。它选择您想要在所选网络上使用的特定类型的通信技术。您可能会猜测,每个地址族都代表完全不同的套接字类型,您必须去查找每一种类型。毕竟除了AF_INET之外还有什么地址家族是要呈现 UDP 和 TCP 这样的套接字类型的?

幸运的是,这种怀疑是错误的。尽管 UDP 和 TCP 确实非常特定于AF_INET协议族,但是套接字接口设计者决定为基于包的套接字的广泛概念创建更通用的名称。这被称为SOCK_DGRAM,以及可靠的流量控制数据流的广义概念,如你所见,被称为SOCK_STREAM。因为许多地址族支持这两种机制中的一种或两种,所以只需要这两个符号就可以涵盖各种不同地址族下的许多协议。

socket()调用中的第三个字段,即协议,很少使用,因为一旦您指定了地址族和套接字类型,您通常会将可能的协议缩小到只有一个主要选项。因此,程序员通常不指定它,或者他们提供值 0 来强制自动选择它。如果你想要一个 IP 下的流,系统知道选择 TCP。如果你想要数据报,那么它选择 UDP。这就是为什么本书中没有一个socket()调用有第三个参数:它在实践中几乎从不需要。在socket模块中查找以IPPROTO开头的名字,以获得为AF_INET家族定义的协议的一些例子。你会看到这本书列出了两个名字IPPROTO_TCPIPPROTO_UDP

最后,用于建立连接的第四个和第五个值是 IP 地址和端口号,这在前两章中有详细说明。

我们应该立即退后一步,注意到正是由于我们对前三个坐标的特定选择,我们的套接字名称才具有两个组成部分:主机名和端口。如果你选择了 AppleTalk 或 ATM 或 Bluetooth 作为你的地址族,那么可能需要一些其他的数据结构,而不是一个内部包含一个字符串和一个整数的元组。因此,我在本节中提到的五个坐标实际上是创建套接字所需的三个固定坐标,然后是您的特定地址族需要您使用的更多坐标,以便建立网络连接。

IPv6

现在,在解释了所有这些之后,事实证明这本书实际上需要在迄今为止使用的AF_INET之外引入一个额外的地址族:名为AF_INET6的 IPv6 地址族,这是通向未来的道路,在未来世界不会而不是最终耗尽 IP 地址。

一旦旧的阿帕网真正开始起飞,它选择的 32 位地址名称——这在计算机内存以千字节计量的时代是很有意义的——成为一个明显而令人担忧的限制。只有 40 亿个可用的地址为地球上的每个人提供了不到一个 IP 地址,这意味着一旦每个人都有了电脑和智能手机,那就真的麻烦了!

尽管今天互联网上只有一小部分计算机通过其互联网服务提供商使用 IPv6 与全球网络进行通信(其中“今天”是 2014 年 6 月),但使您的 Python 程序与 IPv6 兼容的必要步骤非常简单,因此您应该继续尝试编写代码,为未来做好准备。

在 Python 中,可以通过检查socket模块中的has_ipv6布尔属性来直接测试底层平台是否支持 IPv6。

>>> import socket
>>> socket.has_ipv6
True

请注意,这并不能而不是告诉您实际的 IPv6 接口是否已启动和配置,并且当前是否可以用于向任何地方发送数据包!这纯粹是断言 IPv6 支持是否已经编译到操作系统中,而不是断言它是否正在使用中。

如果一个接一个地列出 IPv6 将为您的 Python 代码带来的不同,听起来可能会令人望而生畏。

  • 如果您被要求在 IPv6 网络上运行,您的套接字必须使用家族AF_INET6创建。
  • 套接字名称不再仅仅由两部分组成——地址和端口号。相反,它们还可以包含提供“流”信息和“范围”标识符的附加坐标。
  • 您可能已经从配置文件或命令行参数中读到的漂亮的 IPv4 八位字节(如18.9.22.69)现在有时会被 IPv6 主机地址代替,而且您可能还没有很好的正则表达式来表示它们。它们有很多冒号,可以包含十六进制数字,通常看起来很难看。

IPv6 过渡的好处不仅在于它将提供数量惊人的地址,而且该协议比 IPv4 的大多数实现更全面地支持诸如链路层安全之类的东西。

但是,如果您习惯于编写笨重的老式代码,通过自己设计的正则表达式扫描或汇集 IP 地址和主机名,那么刚才列出的更改听起来可能会有很多麻烦。换句话说,如果您一直从事以任何形式解释地址的工作,您可能会认为向 IPv6 的过渡会让您编写比以前更复杂的代码。不要担心:我的实际建议是您将地址解释和扫描中解脱出来!下一节将向您展示如何操作。

现代地址解析

为了使您的代码简单、强大,并且不受从 IPv4 到 IPv6 过渡的复杂性的影响,您应该将注意力转向 Python socket 用户武器库中最强大的工具之一:getaddrinfo()

getaddrinfo()函数位于socket模块中,与大多数其他涉及地址的操作一起。除非您正在做一些专门的事情,否则它可能是您需要用来将用户指定的主机名和端口号转换成套接字方法可以使用的地址的唯一例程。

它的方法很简单。使用socket模块中的旧例程时,需要一点一点地解决寻址问题,而不是这样,它让您在一次调用中指定您所知道的关于连接的一切。作为响应,它返回我前面讨论过的所有坐标,这是创建套接字并将其连接到指定目的地所必需的。

它的基本用法很简单,它是这样的(注意,pprint“pretty print”模块与网络无关,但它在显示元组列表方面比普通的print函数做得更好):

>>> from pprint import pprint
>>> infolist = socket.getaddrinfo('gatech.edu', 'www')
>>> pprint(infolist)
[(2, 1, 6, '', ('130.207.244.244', 80)),
 (2, 2, 17, '', ('130.207.244.244', 80))]
>>> info = infolist[0]
>>> info[0:3]
(2, 1, 6)
>>> s = socket.socket(*info[0:3])
>>> info[4]
('130.207.244.244', 80)
>>> s.connect(info[4])

这里名为info的变量包含了创建套接字并使用它建立连接所需的一切。它提供了一个系列、一个类型、一个协议、一个规范名称,最后是一个地址。提供给getaddrinfo()的参数有哪些?我问过连接到主机gatech.edu的 HTTP 服务的可能方法,返回的二元列表告诉你有两种方法:要么创建一个使用IPPROTO_TCP(协议号6)的SOCK_STREAM套接字(套接字类型1),要么使用一个使用IPPROTO_UDP(用整数17表示的协议)的SOCK_ DGRAM (套接字类型2)套接字。

是的,前面的回答表明 HTTP 官方支持 TCP 和 UDP,至少根据发布端口号的官方组织是这样的。当您稍后从脚本中调用getaddrinfo()时,您通常会指定您想要哪种套接字,而不是将答案留给运气。

如果您在代码中使用getaddrinfo(),那么与第二章和第三章中的清单不同,它们使用真实的符号,如AF_INET 只是为了更清楚地说明底层套接字机制是如何工作的,您的生产 Python 代码将不会引用来自socket模块的任何符号,除了那些向getaddrinfo()解释您想要哪种地址的符号。相反,您将使用getaddrinfo()返回值中的前三项作为socket()构造函数的参数,然后使用第五项作为任何地址感知调用的地址,如本章第一节中列出的connect()

从前面的代码片段中可以看出,getaddrinfo()通常不仅允许主机名,而且允许端口名是像'www'这样的符号,而不是整数,如果用户想要提供像wwwsmtp这样的符号端口号,而不是 80 或 25,就不需要旧的 Python 代码进行额外的调用。

在讨论getaddrinfo()支持的所有选项之前,看看它是如何支持三种基本网络操作的会更有用。我将按照您在套接字上执行操作的顺序来处理它们:绑定、连接,然后识别向您发送信息的远程主机。

使用 getaddrinfo()将服务器绑定到端口

如果您希望向bind()提供一个地址,或者因为您正在创建一个服务器套接字,或者因为某种原因您希望您的客户端从一个可预测的地址连接到其他人,那么您将调用getaddrinfo(),使用None作为主机名,但是填充端口号和套接字类型。注意,在这里,就像在下面的getaddrinfo()调用中一样,零在应该包含数字的字段中充当通配符:

>>> from socket import getaddrinfo
>>> getaddrinfo(None, 'smtp', 0, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
[(2, 1, 6, '', ('0.0.0.0', 25)), (10, 1, 6, '', ('::', 25, 0, 0))]
>>> getaddrinfo(None, 53, 0, socket.SOCK_DGRAM, 0, socket.AI_PASSIVE)
[(2, 2, 17, '', ('0.0.0.0', 53)), (10, 2, 17, '', ('::', 53, 0, 0))]

这里我问了两个不同的问题,第一个问题使用了一个字符串端口标识符,第二个问题使用了一个原始的数字端口号。首先,我问如果我想使用 TCP 为 SMTP 流量提供服务,我应该将套接字bind()连接到哪个地址。其次,我问了关于使用 UDP 服务端口 53 (DNS)流量的问题。我得到的答案是适当的通配符地址,它将允许您绑定到本地机器上的每个 IPv4 和每个 IPv6 接口,在每种情况下都具有套接字系列、套接字类型和协议的所有正确值。

如果您想bind()到一个特定的 IP 地址,您知道这个 IP 地址被配置为您正在运行的机器的本地地址,那么省略AI_PASSIVE标志,只指定主机名。例如,这里有两种方法可以尝试绑定到localhost:

>>> getaddrinfo('127.0.0.1', 'smtp', 0, socket.SOCK_STREAM, 0)
[(2, 1, 6, '', ('127.0.0.1', 25))]
>>> getaddrinfo('localhost', 'smtp', 0, socket.SOCK_STREAM, 0)
[(10, 1, 6, '', ('::1', 25, 0, 0)), (2, 1, 6, '', ('127.0.0.1', 25))]

您可以看到,为本地主机提供 IPv4 地址会限制您只能通过 IPv4 接收连接,而使用符号名localhost(至少在我的 Linux 笔记本电脑上有一个配置良好的/etc/hosts文件)可以为机器提供 IPv4 和 IPv6 本地名称。

顺便说一句,此时你可能已经在问的一个问题是,当你声称你想要提供一个基本服务,而getaddrinfo()给了你几个地址让你使用时,你到底应该做什么——你当然不能创建一个套接字并bind()它到多个地址!在第七章中,我将介绍一些你可以使用的技术,如果你正在编写服务器代码,并且想要几个绑定的服务器套接字同时运行的话。

使用 getaddrinfo()连接到服务

除非您绑定到一个本地地址来自己提供服务,否则您将使用getaddrinfo()来了解如何连接到其他服务。在查找服务时,您可以使用空字符串来表示您想要使用环回接口连接回本地主机,或者提供一个给出 IPv4 地址、IPv6 地址或主机名的字符串来命名您的目的地。

当你准备connect()sendto()一项服务时,用AI_ADDRCONFIG标志调用getaddrinfo(),它会过滤掉任何你的计算机无法到达的地址。例如,一个组织可能同时拥有 IPv4 和 IPv6 范围的 IP 地址。如果您的特定主机只支持 IPv4,那么您会希望过滤的结果只包括该系列中的地址。为了应对这样的情况,即本地机器只有一个 IPv6 网络接口,但是您所连接的服务只支持 IPv4,您还需要指定AI_V4MAPPED来返回重新编码为 IPv6 地址的 IPv4 地址,您可以实际使用这些地址。

将这些部分放在一起,在连接之前,您通常会这样使用getaddrinfo():

>>> getaddrinfo('ftp.kernel.org', 'ftp', 0, socket.SOCK_STREAM, 0,
...            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED)
[(2, 1, 6, '', ('204.152.191.37', 21)),
 (2, 1, 6, '', ('149.20.20.133', 21))]

作为回报,您已经得到了您想要的东西:一个列表,列出了通过 TCP 连接到名为ftp.kernel.org的主机的 FTP 端口的所有连接方式。请注意,返回了几个 IP 地址,因为为了分散负载,此服务位于 Internet 上的几个不同地址。当多个地址像这样返回时,通常应该使用返回的第一个地址,只有当连接尝试失败时,才应该尝试剩余的地址。通过遵循远程服务的管理员希望您尝试联系他们的服务器的顺序,您将提供他们想要的工作负载。

下面是另一个查询,询问我如何从我的笔记本电脑连接到 IANA 的 HTTP 接口,该接口首先分配端口号:

>>> getaddrinfo('iana.org', 'www', 0, socket.SOCK_STREAM, 0,
...            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED)
[(2, 1, 6, '', ('192.0.43.8', 80))]

IANA 网站实际上是一个很好的展示AI_ADDRCONFIG标志效用的网站,因为像任何其他好的互联网标准组织一样,它的网站已经支持 IPv6。碰巧我的笔记本电脑只能在它当前连接的无线网络上使用 IPv4,所以前面的调用很小心地只返回了一个 IPv4 地址。但是,如果您去掉第六个参数中精心选择的标志,那么您就可以看到无法使用的 IPv6 地址。

>>> getaddrinfo('iana.org', 'www', 0, socket.SOCK_STREAM, 0)
[(2, 1, 6, '', ('192.0.43.8', 80)),
 (10, 1, 6, '', ('2001:500:88:200::8', 80, 0, 0))]

如果您不打算自己使用这些地址,而是要向其他主机或程序提供某种目录信息,这将非常有用。

向 getaddrinfo()请求规范的主机名

您经常遇到的最后一种情况是,您要么正在建立一个新的连接,要么刚刚在您自己的一个服务器套接字上接受了一个传入的连接,并且您想知道正式属于您的套接字另一端的 IP 地址的主机名。

虽然这种愿望是可以理解的,但请注意,它伴随着一个严重的危险:事实上,当你的机器执行反向查找时,IP 地址的所有者可以让他们的 DNS 服务器返回他们想要的任何东西作为规范名称!他们可以自称为google.compython.org或任何他们想要的人。当你问他们哪个主机名属于他们的一个 IP 地址时,他们可以完全控制鹦鹉学舌般地回答你的字符串。

在信任规范名称查找(也称为反向 DNS 查找,因为它将 IP 地址映射到主机名,而不是主机名映射到 IP 地址)之前,您可能需要查找返回的名称,看看它是否真正解析为原始 IP 地址。如果不是,那么要么主机名是故意误导,要么它是来自一个域的善意回答,该域的正向和反向名称和 IP 地址没有正确配置以使它们匹配。

规范名称查找成本很高。它们需要通过全球 DNS 服务进行额外的往返,因此在进行日志记录时通常会被忽略。停下来反向查找每一个建立连接的 IP 地址的服务往往又慢又笨拙,系统管理员试图让系统更好地响应的一个经典举措是记录裸露的 IP 地址。如果其中一个导致了问题,当您在日志文件中看到它时,您总是可以手动查找它。

但是,如果您很好地使用了主机的规范名称,并且想要尝试查找,那么只需在打开了AI_CANONNAME标志的情况下运行getaddrinfo(),它返回的任何元组的第四项——在前面的示例中是空字符串——将包含规范名称:

>>> getaddrinfo('iana.org', 'www', 0, socket.SOCK_STREAM, 0,
...            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED | socket.AI_CANONNAME)
[(2, 1, 6, '43-8.any.icann.org', ('192.0.43.8', 80))]

您还可以向getaddrinfo()提供已经连接到远程对等体的套接字的名称,并获得一个规范的名称作为回报。

>>> mysock = server_sock.accept()
>>> addr, port = mysock.getpeername()
>>> getaddrinfo(addr, port, mysock.family, mysock.type, mysock.proto,
...            socket.AI_CANONNAME)
[(2, 1, 6, 'rr.pmtpa.wikimedia.org', ('208.80.152.2', 80))]

同样,只有当 IP 地址的所有者恰好有一个为其定义的名称时,这才会起作用。互联网上的许多 IP 地址不提供有用的反向名称,因此您无法知道哪个主机真正联系了您,除非您使用加密来验证与您通信的对等方。

其他 getaddrinfo()标志

刚才给出的例子演示了三个最重要的getaddrinfo()标志的操作。可用的标志因操作系统而有所不同,如果您对计算机选择返回的值感到困惑,应该经常查阅自己计算机的文档(更不用说它的配置了)。但是有几个标志倾向于跨平台。以下是一些比较重要的例子:

  • AI_ALL:我已经讨论过了,AI_V4MAPPED选项可以保护您免受以下情况的影响:您在一个纯 IPv6 连接的主机上,但是您想要连接的主机只通告 IPv4 地址。它通过将 IPv4 地址重写为 IPv6 地址来解决这个问题。但是,如果某些 IPv6 地址碰巧可用,那么它们将是唯一显示的地址,并且没有 IPv4 地址将包括在返回值中。这个问题可以通过这个选项来解决:如果您希望看到来自 IPv6 连接的主机的所有地址,即使有一些非常好的 IPv6 地址可用,那么将这个AI_ALL标志与AI_V4MAPPED结合起来,返回给您的列表将包含目标主机的所有已知地址。
  • AI_NUMERICHOST:这关闭了将主机名参数——getaddrinfo()的第一个参数——解释为文本主机名(如cern.ch)的任何尝试,并且它仅尝试将主机名字符串解释为文字 IPv4 或 IPv6 主机名(如74.207.234.78fe80::fcfd:4aff:fecf:ea4e)。这要快得多,因为提供地址的用户或配置文件不会导致您的程序进行 DNS 往返来查找名称(见下一节),并防止可能不可信的用户输入迫使您的系统向其他人控制的名称服务器发出查询。
  • AI_NUMERICSERV:这关闭了像'www'这样的符号端口名,并坚持使用像80这样的端口号来代替。您不需要使用它来保护您的程序免受慢速 DNS 查找的影响,因为端口号数据库通常存储在支持 IP 的机器上,而不是进行远程查找。在 POSIX 系统上,解析一个符号端口名通常只需要快速扫描一下/etc/services文件(但是检查一下/etc/nsswitch.conf文件的服务选项以确保正确)。但是,如果您知道您的端口字符串应该总是一个整数,那么激活这个标志可以是一个有用的健全性检查。

关于标志的最后一点:你不必担心一些操作系统提供的与 IDN 相关的标志,它告诉getaddrinfo()解析那些含有 Unicode 字符的新域名。相反,Python 会检测一个字符串是否需要特殊的编码,并设置任何必要的选项来转换它:

>>> getaddrinfo('παράδειγμα.δοκιμή', 'www', 0, socket.SOCK_STREAM, 0,
...            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED)
[(2, 1, 6, '', ('199.7.85.13', 80))]

如果您对这在幕后是如何工作的感到好奇,请阅读从 RFC 3492 开始的相关国际标准,并注意 Python 现在包括一个可以与国际化域名相互转换的'idna'编解码器。

>>> 'παράδειγμα.δοκιμή'.encode('idna')
b'xn--hxajbheg2az3al.xn--jxalpdlp'

当您输入前面示例中所示的希腊语示例域名时,实际上就是这个生成的纯 ASCII 字符串被发送到域名服务。同样,Python 将为您隐藏这种复杂性。

原始名称服务例程

getaddrinfo()风靡之前,做套接字级编程的程序员通过操作系统支持的一个更简单的名字服务例程集合就可以了。现在应该避免使用它们,因为它们中的大多数都是硬连线的,只能说 IPv4。

您可以在socket模块的标准库页面中找到它们的文档。在这里,我将展示几个简单的例子来说明每个调用。两个调用返回当前机器的主机名。

>>> socket.gethostname()
'asaph'
>>> socket.getfqdn()
'asaph.rhodesmill.org'

另外两个允许您在 IPv4 主机名和 IP 地址之间转换。

>>> socket.gethostbyname('cern.ch')
'137.138.144.169'
>>> socket.gethostbyaddr('137.138.144.169')
('webr8.cern.ch', [], ['137.138.144.169'])

最后,三个例程让您使用操作系统已知的符号名称来查找协议号和端口。

>>> socket.getprotobyname('UDP')
17
>>> socket.getservbyname('www')
80
>>> socket.getservbyport(80)
'www'

如果您想了解运行 Python 程序的机器的主 IP 地址,您可以尝试将其完全限定的主机名传递到一个gethostbyname()调用中,如下所示:

>>> socket.gethostbyname(socket.getfqdn())
'74.207.234.78'

然而,由于任何一个调用都可能失败并返回一个地址错误(参见第五章中关于错误处理的部分),你的代码应该有一个备份计划,以防这对调用未能返回一个有用的 IP 地址。

在您自己的代码中使用 getsockaddr()

为了把所有的东西放在一起,我收集了一个简单的例子,展示了getaddrinfo()在实际代码中的样子。看看清单 4-1 中的。

清单 4-1 。使用getaddrinfo()创建并连接一个插座

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter04/www_ping.py
# Find the WWW service of an arbitrary host using getaddrinfo().

import argparse, socket, sys

def connect_to(hostname_or_ip):
    try:
        infolist = socket.getaddrinfo(
            hostname_or_ip, 'www', 0, socket.SOCK_STREAM, 0,
            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED | socket.AI_CANONNAME,
            )
    except socket.gaierror as e:
        print('Name service failure:', e.args[1])
        sys.exit(1)

    info = infolist[0]  # per standard recommendation, try the first one
    socket_args = info[0:3]
    address = info[4]
    s = socket.socket(*socket_args)
    try:
        s.connect(address)
    except socket.error as e:
        print('Network failure:', e.args[1])
    else:
        print('Success: host', info[3], 'is listening on port 80')

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Try connecting to port 80')
    parser.add_argument('hostname', help='hostname that you want to contact')
    connect_to(parser.parse_args().hostname)

这个脚本执行一个简单的“你在吗?”通过尝试使用流套接字快速连接到端口 80,测试您在命令行上指定的任何 web 服务器。使用该脚本看起来会像这样:

$ python www_ping.py mit.edu
Success: host mit.edu is listening on port 80
$ python www_ping.py smtp.google.com
Network failure: Connection timed out
$ python www_ping.py no-such-host.com
Name service failure: Name or service not known

注意这个脚本的三点:

  • 它是完全通用的,既没有提到作为协议的 IP,也没有提到作为传输的 TCP。如果用户碰巧输入了一个主机名,系统认为它是通过 AppleTalk 连接的主机(如果你能想象这个时代的这种事情),那么getaddrinfo()将自由地返回 AppleTalk 套接字系列、类型和协议,这将是你最终创建和连接的套接字类型。
  • getaddrinfo()故障会导致特定的名称服务错误,Python 称之为gaierror,而不是在脚本结束时检测到的普通网络故障所使用的那种普通套接字错误。你将在第五章的中了解更多关于错误处理的知识。
  • 您没有给socket()构造函数一个包含三个独立项目的列表。相反,参数列表由星号引入,这意味着socket_args列表的三个元素作为三个独立的参数传递给构造函数。这与您需要对返回的实际地址所做的事情相反,实际地址是作为一个单元传递给所有需要它的套接字例程的。

DNS 协议

域名系统(DNS) 是数百万互联网主机合作回答什么主机名解析成什么 IP 地址的方案。DNS 是这样一个事实的背后:你可以在你的网络浏览器中键入python.org,而不是总是不得不为那些使用 IPv4 的人记住82.94.164.162,或者如果你已经喜欢 IPv6,记住2001:888:2000:d::a2

DNS 协议

目的:通过返回 IP 地址来解析主机名

标准:RFC 1034 和 RFC 1035(自 1987 年起)

运行于:UDP/IP 和 TCP/IP 之上

端口号:53

库:第三方,包括dnspython3

计算机为执行此解析而发送的消息会穿过服务器的层次结构。如果您的本地计算机和名称服务器无法解析主机名,因为它既不是您组织的本地主机名,也不是最近才被发现的主机名,因此仍在名称服务器的缓存中,那么下一步是查询世界顶级名称服务器之一,以找出哪些计算机负责您需要查询的域。一旦 DNS 服务器 IP 地址被返回,就可以依次查询它们以获得域名本身。

在检查细节之前,我们应该先退后一步,看看这个操作通常是如何开始的。

考虑域名www.python.org。如果你的网络浏览器需要知道这个地址,那么浏览器会运行一个类似getaddrinfo() 的调用来请求操作系统解析这个名称。您的系统本身将知道它正在运行自己的名称服务器,或者它所连接的网络提供名称服务。现在,当您的机器连接到网络时,通常会通过 DHCP 自动配置名称服务器信息,无论是连接到公司办公室或教育机构的局域网、无线网络,还是通过家庭电缆或 DSL 连接。在其他情况下,DNS 服务器 IP 地址将在系统管理员设置您的机器时手动配置。无论哪种方式,DNS 服务器都必须通过它们的原始 IP 地址来指定,因为很明显,在您知道其他到达服务器的方法之前,您不能执行任何 DNS 查询。

有时,人们对他们的 ISP 的 DNS 行为或性能不满意,他们选择配置自己选择的第三方 DNS 服务器,如谷歌在8.8.8.88.8.4.4运行的服务器。在极少数情况下,本地 DNS 域名服务器可以通过计算机使用的其他名称集识别,如 WINS Windows 命名服务。但是,无论如何,必须识别 DNS 服务器,以便进行域名解析。

你的电脑甚至不用咨询域名服务就知道一些主机名。当您发出类似getaddrinfo()的调用时,向 DNS 查询主机名实际上并不是操作系统通常做的第一件事。事实上,因为进行 DNS 查询可能很耗时,所以它通常是最后的选择!根据您的/etc/nsswitch.conf文件中的 hosts 条目(如果您在 POSIX box 上),或者根据您的 Windows 控制面板设置,在转向 DNS 之前,操作系统可能会首先查看一个或几个其他位置。例如,在我的 Ubuntu 笔记本电脑上,每次查找主机名时都会首先检查/etc/hosts文件。然后,如果可能的话,使用称为多播 DNS 的专用协议。只有当失败或不可用时,成熟的 DNS 才会响应主机名查询。

继续我们的例子,假设名称www.python.org没有在您的机器上本地定义,并且最近没有被查询到足以在您运行 web 浏览器的机器上的任何本地缓存中。在这种情况下,计算机将查找本地 DNS 服务器,并且通常通过 UDP 向其发送单个 DNS 请求数据包。

现在问题掌握在真正的 DNS 服务器手中。在接下来的讨论中,我将称它为“您的 DNS 服务器”,意思是“为您执行主机名查找的特定 DNS 服务器”当然,服务器本身可能属于其他人,比如你的雇主、你的 ISP 或谷歌,因此从你拥有它的意义上来说,它实际上并不属于你。

您的 DNS 服务器的第一个动作将是检查其自己的最近查询的域名的缓存,以查看在过去的几分钟或几小时内www.python.org是否已经被 DNS 服务器服务的其他机器检查过。如果条目存在并且尚未过期(每个域名的所有者可以选择其过期超时,因为一些组织喜欢在需要时快速更改 IP 地址,而其他组织则乐于让旧 IP 地址在全球 DNS 缓存中停留几个小时或几天),则可以立即将其返回。但是想象一下,现在是早上,你是今天在办公室或咖啡店里第一个尝试与www.python.org交谈的人,因此 DNS 服务器必须从头开始寻找主机名。

您的 DNS 服务器现在将开始一个递归过程,询问位于全球 DNS 服务器层级顶端的www.python.org,“根级”域名服务器,它们知道所有顶级域名(TLD),如.com.org.net,并且知道负责每个域名的服务器组。域名服务器软件一般都内置了这些顶级服务器的 IP 地址,这样就解决了你在实际连接域名系统之前,如何找到任何域名服务器的自举问题。通过这第一次 UDP 往返,您的 DNS 服务器将了解(如果它不知道已经从另一个最近的查询)哪些服务器保留了.org域的完整索引。

现在将发出第二个 DNS 请求,这次是向其中一个.org服务器发出请求,询问谁在运行python.org域。您可以通过在 POSIX 系统上运行whois命令行程序,或者如果您没有在本地安装该命令,则使用在线的许多“whois”网页之一,来了解这些顶级服务器对某个域了解多少。

$ whois python.org
Domain Name:PYTHON.ORG
Created On:27-Mar-1995 05:00:00 UTC
Last Updated On:07-Sep-2006 20:50:54 UTC
Expiration Date:28-Mar-2016 05:00:00 UTC
...
Registrant Name:Python Software Foundation
...
Name Server:NS2.XS4ALL.NL
Name Server:NS.XS4ALL.NL

这就是我们的答案!无论您在世界的哪个地方,您对python.org内任何主机名的 DNS 请求都必须被传递到该条目中指定的两个 DNS 服务器之一。当然,当您的 DNS 服务器向顶级域名服务器发出这一请求时,它实际上并不会只返回两个名称,就像刚才给出的那样。取而代之的是,他们的 IP 地址也给了它,这样它就可以直接联系他们,而不会招致另一轮昂贵的 DNS 查找。

您的 DNS 服务器现在已经完成了与根级 DNS 服务器和顶级.org DNS 服务器的对话,它可以直接与NS2.XS4ALL.NLNS.XS4ALL.NL通信,询问关于python.org域的信息。事实上,它将尝试其中一个,然后如果第一个不可用,则退回到尝试另一个。这增加了你得到答案的机会,但是,当然,一次失败会增加你坐在那里盯着你的网页浏览器看的时间,直到网页真正显示出来。

根据python.org如何配置其名称服务器,DNS 服务器可能只需要再进行一次查询就能得到答案,或者如果组织是一个拥有许多部门和子部门的大型组织,它可能需要再进行几次查询,这些部门都运行自己的 DNS 服务器,需要将请求委派给这些服务器。在这种情况下,www.python.org查询可以直接由刚刚命名的两个服务器中的任何一个来回答,并且您的 DNS 服务器现在可以向您的浏览器返回一个 UDP 数据包,告诉它哪些 IP 地址属于该主机名。

请注意,此过程需要四次单独的网络往返。您的机器发出一个请求,并从您自己的 DNS 服务器得到一个响应,为了响应这个请求,您的 DNS 服务器必须进行一个递归查询,这个查询包括到其他服务器的三次不同的往返。难怪当你第一次输入域名时,你的浏览器会不停地旋转。

为什么不使用原始 DNS

我希望,前面对典型 DNS 查询的解释已经清楚地表明,当您需要查找主机名时,您的操作系统已经为您做了很多工作。出于这个原因,我将建议您,除非出于非常特殊的原因绝对需要使用 DNS,否则您总是依赖于getaddrinfo()或其他一些系统支持的机制来解析主机名。考虑让您的操作系统为您查找名称的这些好处:

  • DNS 通常不是系统获取名称信息的唯一途径。如果您的应用运行并试图使用 DNS 作为其解析域名的首选,那么用户将会注意到,一些在您系统的任何地方都可以使用的计算机名称——在他们的浏览器中,在文件共享路径中,等等——在他们使用您的应用时突然不起作用了,因为您没有像操作系统本身那样咨询 WINS 或/etc/hosts之类的机制。
  • 本地机器可能有一个最近查询的域名缓存,其中可能已经包含您需要其 IP 地址的主机。如果你试着自己说 DNS 来回答你的问题,你将会重复已经做过的工作。
  • 运行 Python 脚本的系统已经知道本地域名服务器,这要归功于系统管理员的手动配置或 DHCP 等网络设置协议。要在 Python 程序中启动 DNS,您必须学习如何在您的特定操作系统中查询这些信息——这是一个特定于操作系统的操作,我不会在本书中介绍。
  • 如果您不使用本地 DNS 服务器,那么您将无法受益于它自己的缓存,该缓存会阻止您的应用和在同一网络上运行的其他应用重复请求您所在位置经常使用的主机名。
  • 不时地,对世界 DNS 基础设施进行调整,并且操作系统库和守护进程逐渐更新以适应这一点。如果您的程序自己进行原始 DNS 调用,那么您必须自己跟踪这些更改,并确保您的代码与 TLD 服务器 IP 地址的最新更改、涉及国际化的约定以及对 DNS 协议本身的调整保持同步。

最后,请注意 Python 并没有在标准库中内置任何 DNS 工具。如果你打算使用 Python 来讨论 DNS,那么你必须选择并学习一个第三方库。

从 Python 发出 DNS 查询

然而,从 Python 进行 DNS 调用有一个可靠且合法的理由。这是因为您是一个邮件服务器,或者至少是一个试图直接向您的收件人发送邮件而不需要运行本地邮件中继的客户端,并且您想要查找与一个域相关联的 MX 记录,以便您可以在@example.com为您的朋友找到正确的邮件服务器。

因此,在我们结束本章时,让我们来看看 Python 的一个第三方 DNS 库。Python 3 目前最好的版本是 dnspython3,您可以使用标准的 Python 打包工具来安装它。

$ pip install dnspython3

该库使用自己的技巧来找出您的 Windows 或 POSIX 操作系统当前使用的域名服务器,然后它要求这些服务器代表它进行递归查询。因此,本章中没有任何一段代码不需要正确配置的主机,而管理员或网络配置服务已经为该主机配置了有效的名称服务器。

清单 4-2 展示了一个简单而全面的查找。

清单 4-2 。一个简单的 DNS 查询做自己的递归

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter04/dns_basic.py
# Basic DNS query

import argparse, dns.resolver

def lookup(name):
    for qtype in 'A', 'AAAA', 'CNAME', 'MX', 'NS':
        answer = dns.resolver.query(name, qtype, raise_on_no_answer=False)
        if answer.rrset is not None:
            print(answer.rrset)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Resolve a name using DNS')
    parser.add_argument('name', help='name that you want to look up in DNS')
    lookup(parser.parse_args().name)

您可以看到一次只能尝试一种类型的 DNS 查询,因此这个小脚本在一个循环中运行,请求与作为命令行参数给出的单个主机名相关的不同类型的记录。对python.org运行这个命令会立即教会你一些关于 DNS 的事情。

$ python dns_basic.py python.org
python.org. 42945 IN A 140.211.10.69
python.org. 86140 IN MX 50 mail.python.org.
python.org. 86146 IN NS ns4.p11.dynect.net.
python.org. 86146 IN NS ns3.p11.dynect.net.
python.org. 86146 IN NS ns1.p11.dynect.net.
python.org. 86146 IN NS ns2.p11.dynect.net.

从程序中可以看出,回复中返回的每个“答案”都由一系列对象表示。按顺序,打印在每一行上的键如下:

  • 名字查了一下。
  • 在名称过期之前允许您缓存该名称的时间(秒)。
  • IN这样的“类”,它表明你正在返回互联网地址响应。
  • 记录的“类型”。一些常见的是用于 IPv4 地址的A、用于 IPv6 地址的AAAANS用于列出名称服务器的记录,以及MX用于给出应该用于域的邮件服务器的回复。
  • 最后,“数据”提供了连接或联系服务所需的信息。

在刚刚引用的查询中,您了解到关于python.org域的三件事情。首先,A记录告诉您,如果您想要连接到一台实际的python.org机器——建立 HTTP 连接、启动 SSH 会话或做任何其他事情,因为用户已经提供了python.org作为他或她想要连接的机器——那么您应该将您的数据包定向到 IP 地址140.211.10.69。其次,NS 记录告诉您,如果您想要查询python.org下的任何主机的名称,那么您应该让名称服务器ns1.p11.dynect.netns4.p11.dynect.net(最好按照给定的顺序,而不是数字顺序)为您解析这些名称。最后,如果你想给电子邮件域名为@python.org的人发送电子邮件,那么你需要去查找主机名mail.python.org

DNS 查询还可以返回一个记录类型CNAME,这表明您查询的主机名实际上只是另一个主机名的别名——然后您必须单独去查找!因为它经常需要两次往返,所以这种记录类型现在并不流行,但是您仍然可能会遇到它。

解析邮件域

我之前提到过,在大多数 Python 程序中,解析电子邮件域是对原始 DNS 的合法使用。最近在 RFC 5321 中指定了进行这种解析的规则。简而言之,如果MX记录存在,那么您必须尝试联系这些 SMTP 服务器,如果它们都不接受消息,则向用户返回一个错误(或者将消息放在重试队列中)。如果它们的优先级不相等,则按优先级从低到高的顺序尝试它们。如果不存在任何MX记录,但是为该域提供了一个AAAAA记录,那么您可以尝试通过 SMTP 连接到该地址。如果两个记录都不存在,但是指定了一个CNAME,那么应该使用相同的规则在它提供的域名中搜索MXA记录。

清单 4-3 显示了如何实现这个算法。通过进行一系列的 DNS 查询,它在可能的目的地中前进,在前进的过程中打印出它的决定。通过调整像这样的例程来返回地址而不仅仅是打印它们,您可以为需要向远程主机发送电子邮件的 Python 邮件调度程序提供支持。

清单 4-3 。解析电子邮件域名

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter04/dns_mx.py
# Looking up a mail domain - the part of an email address after the `@`

import argparse, dns.resolver

def resolve_hostname(hostname, indent=''):
    "Print an A or AAAA record for `hostname`; follow CNAMEs if necessary."
    indent = indent + '    '
    answer = dns.resolver.query(hostname, 'A')
    if answer.rrset is not None:
        for record in answer:
            print(indent, hostname, 'has A address', record.address)
        return
    answer = dns.resolver.query(hostname, 'AAAA')
    if answer.rrset is not None:
        for record in answer:
            print(indent, hostname, 'has AAAA address', record.address)
        return
    answer = dns.resolver.query(hostname, 'CNAME')
    if answer.rrset is not None:
        record = answer[0]
        cname = record.address
        print(indent, hostname, 'is a CNAME alias for', cname) #?
        resolve_hostname(cname, indent)
        return
    print(indent, 'ERROR: no A, AAAA, or CNAME records for', hostname)

def resolve_email_domain(domain):
    "For an email address `name@domain` find its mail server IP addresses."
    try:
        answer = dns.resolver.query(domain, 'MX', raise_on_no_answer=False)
    except dns.resolver.NXDOMAIN:
        print('Error: No such domain', domain)
        return
    if answer.rrset is not None:
        records = sorted(answer, key=lambda record: record.preference)
        for record in records:
            name = record.exchange.to_text(omit_final_dot=True)
            print('Priority', record.preference)
            resolve_hostname(name)
    else:
        print('This domain has no explicit MX records')
        print('Attempting to resolve it as an A, AAAA, or CNAME')
        resolve_hostname(domain)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Find mailserver IP address')
    parser.add_argument('domain', help='domain that you want to send mail to')
    resolve_email_domain(parser.parse_args().domain)

当然,这里显示的resolve_hostname()的实现相当脆弱,因为它应该根据当前主机是连接到 IPv4 还是 IPv6 网络,在AAAAA记录之间做出动态决定。事实上,很可能我们的朋友getsockaddr()真的应该被推迟到这里,而不是试图解决我们自己的邮件服务器主机名!但是由于清单 4-3 旨在展示 DNS 是如何工作的,我想我也可以使用纯 DNS 来完成这个逻辑,这样你就可以看到查询是如何被解析的。

真正的邮件服务器实现显然不会打印邮件服务器地址,而是尝试向它们发送邮件,并在第一次成功后停止。(如果它在成功后继续检查服务器列表,那么将会生成电子邮件的几个副本,每个副本对应一个成功送达的服务器。)尽管如此,这个简单的脚本让您对这个过程有了一个很好的了解。您可以看到python.org目前只有一个邮件服务器 IP 地址。

$ python dns_mx.py python.org
This domain has 1 MX records
Priority 50
     mail.python.org has A address 82.94.164.166

当然,无论该 IP 属于一台机器还是由一群主机共享,从外部是不容易看到的。其他组织更积极地给收到的电子邮件提供几个着陆点。IANA 目前有不少于六个电子邮件服务器(或者,至少它提供了六个 IP 地址供你连接,不管它实际上运行着多少服务器)。

$ python dns_mx.py iana.org
This domain has 6 MX records
Priority 10
     pechora7.icann.org has A address 192.0.46.73
Priority 10
     pechora5.icann.org has A address 192.0.46.71
Priority 10
     pechora8.icann.org has A address 192.0.46.74
Priority 10
     pechora1.icann.org has A address 192.0.33.71
Priority 10
     pechora4.icann.org has A address 192.0.33.74
Priority 10
     pechora3.icann.org has A address 192.0.33.73

通过在许多不同的域中尝试这个脚本,您将能够看到大型和小型组织是如何将传入的电子邮件路由到 IP 地址的。

摘要

Python 程序经常需要将主机名转换成套接字地址,这样它们就可以真正地建立连接。

大多数主机名查找应该通过socket模块中的getsockaddr()函数进行,因为它的智能通常由您的操作系统提供,它不仅知道如何使用所有可用的机制查找域名,还知道本地 IP 堆栈配置为支持哪种类型的地址(IPv4 或 IPv6)。

传统的 IPv4 地址仍然是互联网上最普遍的地址,但是 IPv6 也变得越来越普遍。通过将所有主机名和端口名的查找推迟到getsockaddr(),您的 Python 程序可以将地址视为不透明的字符串,而不必担心解析或解释它们。

大多数域名解析的背后是 DNS,它是一个分布在世界各地的数据库,将域名查询直接转发给拥有域名的组织的服务器。虽然不常在 Python 中直接使用,但它有助于根据以电子邮件地址中的@符号命名的电子邮件域来确定将电子邮件定向到哪里。

既然你已经了解了如何命名你将要连接套接字的主机,那么第五章将探讨编码和定界你将要传输的数据有效载荷的不同选择。

五、网络数据和网络错误

本书的前四章介绍了如何在 IP 网络上命名主机,以及如何建立和拆除主机之间的 TCP 流和 UDP 数据报连接。但是,您应该如何准备传输数据呢?应该如何编码和格式化?Python 程序需要为哪种错误做准备?

无论您使用的是数据流还是数据报,这些问题都是相关的,本章提供了所有这些问题的基本答案。

字节和字符串

计算机内存芯片和网卡都支持将字节作为通用货币。这个微小的 8 位信息包已经成为我们的全球信息存储单位。然而,内存芯片和网卡是有区别的。Python 能够完全隐藏程序运行时它在内存中如何表示数字、字符串、列表和字典的选择。除非您使用特殊的调试工具,否则您甚至看不到存储这些数据结构的字节,只能从外部看到它们的行为。

网络通信是不同的,因为套接字接口公开了字节,并使它们对程序员和应用都可见。在进行网络编程时,您通常不可避免地要考虑数据将如何在网络上表示,这就提出了 Python 这样的高级语言可以避免的问题。

所以,现在让我们考虑字节的属性。

  • 一个是信息的最小单位。它是一个数字,可以是零也可以是一。在电子学中,一个比特通常被实现为一根导线,其电压要么是热的,要么是接地的。
  • 八位一起构成一个字节。

这些比特需要被排序,这样你就可以区分哪个是哪个。当你像01100001一样写二进制数时,你按照和写十进制数相同的方向排列数字,首先是最高有效位(就像在十进制数 234 中,2 是最高有效位,4 是最低有效位,因为百位比十位或一位对数字的大小影响更大)。

将单字节解释为 00000000 到 1111111 之间的数字是一种方法。如果你算一下,这是十进制的值 0 和 255。

您还可以将 0 到 255 范围内的最高字节值解释为负数,因为您可以通过从 0 向后回绕来达到它们。常见的选择是将 10000000 到 11111111(通常是 128 到 255)解释为-128 到-1,因为这样最高有效位会告诉您该数字是否为负数。(这被称为二进制补码运算。)或者,您可以使用各种更复杂的规则来解释一个字节,这些规则可以通过表格的方式将一些符号或含义分配给该字节,或者通过将该字节与其他字节放在一起来构建更大的数字。

网络标准使用术语八位字节表示 8 位字节,因为在过去,一个字节在不同的计算机上可以有各种不同的长度。

在 Python 中,通常以两种方式之一来表示字节:要么表示为值恰好在 0 到 255 之间的整数,要么表示为长度为 1 的字节字符串,其中字节是它包含的单个值。您可以使用 Python 源代码中支持的任何典型基数(二进制、八进制、十进制和十六进制)键入字节值数字。

>>> 0b1100010
98
>>> 0b1100010 == 0o142 == 98 == 0x62
True

您可以通过将这些数字传递给序列中的bytes()类型来将它们转换成字节字符串,并且可以通过尝试遍历字节字符串来转换回来。

>>> b = bytes([0, 1, 98, 99, 100])
>>> len(b)
5
>>> type(b)
<class 'bytes'>
>>> list(b)
[0, 1, 98, 99, 100]

可能有点混乱的是,字节字符串对象的repr() 使用 ASCII 字符作为数组元素的简写,这些数组元素的字节值恰好对应于可打印的字符代码,并且它只对不对应于可打印的 ASCII 字符的字节使用显式十六进制格式\xNN

>>> b
b'\x00\x01bcd'

但是,不要被愚弄了:字节串在它们的语义上根本就不是 ASCII 码,它们只是用来表示 8 位字节的序列。

字符串

如果您真的想通过一个套接字传输一串符号,您需要一种将每个符号分配给一个有效字节值的编码。最流行的这种编码是 ASCII,代表美国信息交换标准码,它定义了 0 到 127 的字符码,可以放入 7 位。因此,当 ASCII 以字节存储时,最高有效位始终为零。代码 0 到 31 代表输出显示的控制命令,而不是实际的字形,如字母、数字和标点符号,因此它们不能显示在如下的快速图表中。如您所见,代表字形的 ASCII 字符的三个后续 32 字符层是第一层标点和数字,然后是包含大写字母的层,最后是小写字母层:

>>> for i in range(32, 128, 32):
...   print(' '.join(chr(j) for j in range(i, i+32)))
...
  ! " # $ % & ' ( ) * + , - . / 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
@ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \] ^ _
` a b c d e f g h i j k l m n o p q r s t u v w x y z { | } ~

顺便说一下,左上角的字符是字符代码 32 处的空格。(奇怪的是,右下角的不可见字符是最后一个控制字符:127 位的 Delete。)请注意这个 1960 年的编码中的两个巧妙之处。首先,数字是有序的,这样您就可以通过减去数字零的代码来计算任何数字的数学值。此外,通过翻转 32 位,您可以在大写和小写字母之间切换,或者通过设置或清除整串字母的 32 位来强制字母变成一种或另一种大小写。

但是 Python 3 在其字符串可以包含的字符代码方面远远超出了 ASCII。多亏了一个更近的名为 Unicode 的标准,我们现在有了超过 128 个 ASCII 码的数字的字符代码分配,甚至可以达到数千甚至数百万。Python 认为字符串是由一系列 Unicode 字符组成的,和 Python 数据结构一样,当您使用这种语言时,Python 字符串在 RAM 中的实际表示形式会被小心地隐藏起来。但是当处理文件中或网络上的数据时,您将不得不考虑外部表示和两个术语,这两个术语有助于您清楚地了解信息的含义以及信息是如何传输或存储的:

  • 编码字符意味着将一串真实的 Unicode 字符转换成可以发送到 Python 程序之外的真实世界的字节。
  • 解码 字节数据是指将一个字节串转换成真实的字符。

如果您认为外部世界是由存储在密码中的字节组成的,如果您的 Python 程序要正确处理这些字节,那么它可能会帮助您记住这些单词所指的转换。要将数据移出 Python 程序,它必须成为代码;要搬回来,必须解码。

当今世界上有许多可能的编码在使用。它们分为两大类。

最简单的编码是单字节编码 ,它最多可以表示 256 个单独的字符,但保证每个字符都适合一个字节。这些在编写网络代码时很容易使用。例如,您提前知道从套接字读取 n 字节将生成 n 个字符,并且您还知道当一个流被分割成多个部分时,每个字节都是一个独立的字符,可以安全地对其进行解释,而不需要知道后面是什么字节。此外,通过查看第 n 个字节,您可以立即查找输入中的字符 n

多字节编码 更加复杂,并且失去了这些优点。有些,如 UTF-32,每个字符使用固定的字节数,当数据主要由 ASCII 字符组成时,这是一种浪费,但好处是每个字符的长度总是相同的。其他的,像 UTF-8,每个字符占用不同的字节数,因此需要非常小心;如果数据流是分段传送的,那么就没有办法提前知道一个字符是否已经被跨边界分割,如果不从头开始读,直到你读完那么多字符,你就找不到字符 n

通过查找codecs模块的标准库文档,可以找到 Python 支持的所有编码的列表。

Python 中内置的大多数单字节编码都是 ASCII 的扩展,它们将剩余的 128 个值用于特定于地区的字母或符号:

>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('latin1')
'ghiçèé'
>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('latin2')
'ghiç
é'
>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('greek')
'ghiηθι'
>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('hebrew')

标准库中列出的许多 Windows 代码页也是如此。然而,一些单字节编码与 ASCII 毫无共同之处,因为它们是基于过去大型 IBM 大型机的替代标准。

>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('EBCDIC-CP-BE')
'ÅÇÑXYZ'

您最有可能遇到的多字节编码是旧的 UTF-16 方案(它曾有过短暂的全盛时期,当时 Unicode 要小得多,可以容纳 16 位)、现代的 UTF-32 方案和普遍流行的变宽 UTF-8,它看起来像 ASCII,除非您开始包含代码大于 127 的字符。下面是使用这三种格式的 Unicode 字符串的样子:

>>> len('Namárië!')
8
>>> 'Namárië!'.encode('UTF-16')
b'\xff\xfeN\x00a\x00m\x00\xe1\x00r\x00i\x00\xeb\x00!\x00'
>>> len(_)
18
>>> 'Namárië!'.encode('UTF-32')
b'\xff\xfe\x00\x00N\x00\x00\x00a\x00\x00\x00m\x00\x00\x00\xe1\x00\x00\x00r\x00\x00\x00i\x00\x00\x00\xeb\x00\x00\x00!\x00\x00\x00'
>>> len(_)
36
>>> 'Namárië!'.encode('UTF-8')
b'Nam\xc3\xa1ri\xc3\xab!'
>>> len(_)
10

如果您仔细查看每个编码,您应该能够找到散布在代表非 ASCII 字符的字节值中的简单 ASCII 字母Namri

请注意,多字节编码各包含一个额外字符,使 UTF-16 编码达到完整的(8 × 2) + 2 字节,UTF-32 编码达到(8 × 4) + 4 字节。这个特殊字符\xfeff是字节顺序标记(BOM ),可以让读者自动检测每个 Unicode 字符的几个字节是以最高有效字节还是最低有效字节优先存储。(有关字节顺序的更多信息,请参见下一节。)

在处理编码文本时,您会遇到两种典型的错误:试图从实际上不遵循您试图解释的编码规则的编码字节字符串中加载,以及试图对实际上无法在您请求的编码中表示的字符进行编码。

>>> b'\x80'.decode('ascii')
Traceback (most recent call last):
  ...
UnicodeDecodeError: 'ascii' codec can't decode byte 0x80 in position 0: ordinal not in range(128)
>>> 'ghiηθι'.encode('latin-1')
Traceback (most recent call last):
  ...
UnicodeEncodeError: 'latin-1' codec can't encode characters in position 3-5: ordinal not in range(256)

通常,您会希望通过确定您使用了错误的编码或找出数据不符合预期编码的原因来修复此类错误。然而,如果这两种修复都不起作用,并且您发现您的代码必须在声明的编码和实际的字符串和数据不匹配的情况下正常运行,那么您将希望阅读标准库文档来了解错误的替代方法,而不是必须处理异常。

>>> b'ab\x80def'.decode('ascii', 'replace')
'ab⍰def'
>>> b'ab\x80def'.decode('ascii', 'ignore')
'abdef'
>>> 'ghiηθι'.encode('latin-1', 'replace')
b'ghi???'
>>> 'ghiηθι'.encode('latin-1', 'ignore')
b'ghi'

这些在标准库文档中有关于codecs模块的描述,你也可以在 Doug Hellman 的关于codecs的每周 Python 模块条目中找到更多的例子。

请再次注意,如果您使用的编码使用多个字节对一些字符进行编码,则对部分接收的消息进行解码是很危险的,因为这些字符中的一个可能已经在您已经接收到的消息部分和尚未到达的数据包之间进行了拆分。请参阅本章后面的“框架和引用”部分,了解解决此问题的一些方法。

二进制数和网络字节顺序

如果您想要通过网络发送的只是文本,那么编码和组帧(您将在下一节处理)将是您唯一的担忧。

但是,有时您可能希望用比文本更紧凑的格式来表示数据。或者,您可能正在编写 Python 代码来与已经选择使用原始二进制数据的服务进行交互。无论是哪种情况,您都可能不得不开始担心一个新问题:网络字节顺序。

为了理解字节顺序的问题,考虑通过网络发送整数的过程。具体来说,想想整数 4253。

当然,许多协议只是将这个整数作为字符串'4253'来传输,也就是说,作为四个不同的字符。至少在任何通常的文本编码中,这四个数字将需要至少四个字节来传输。使用十进制数字还会涉及一些计算开销:由于数字不是以 10 为基数存储在计算机中的,所以传输该值的程序需要反复除法——检查余数——才能确定这个数字实际上是由 4 千、2 百、5 十和 3 余数组成的。当收到四位数的字符串'4253'时,需要重复加法和乘以 10 的幂次,才能将文本重新组合成一个数字。

尽管冗长,使用纯文本表示数字的技术实际上可能是当今互联网上最流行的。例如,每次获取一个网页时,HTTP 协议使用一串十进制数字来表示结果的内容长度,就像'4253'一样。web 服务器和客户机都不假思索地进行十进制转换,尽管会有一些开销。事实上,过去 20 年网络的大部分故事都是用简单、明显和人类可读的协议取代密集的二进制格式——即使与它们的前辈相比计算成本很高。

当然,现代处理器上的乘法和除法也比二进制格式更普遍的时候便宜——这不仅是因为处理器的速度大幅提高,还因为它们的设计者在实现整数数学方面变得更加聪明,因此今天相同的运算所需的周期比 20 世纪 80 年代初的处理器少得多。

在任何情况下,字符串'4253'都不是你的计算机在 Python 中将这个数表示为整数变量的方式。相反,它会将其存储为二进制数,使用几个连续字节的位来表示单个大数的 1 位、2 位、4 位等等。您可以通过在 Python 提示符下使用hex()内置函数来了解整数的存储方式。

>>> hex(4253)
'0x109d'

每个十六进制数字对应四位,因此每对十六进制数字代表一个字节的数据。不是存储为四个十进制数字(4、4、2 和 3),前 4 个是“最高有效”数字(因为调整它的值会使数字偏离 1000),3 是其最低有效数字,而是将数字存储为一个最高有效字节0x10和一个最低有效字节0x9d,在内存中彼此相邻。

但是这两个字节应该以什么顺序出现呢?在这里,我们看到了不同品牌的计算机处理器架构之间的巨大差异。虽然他们都同意内存中的字节有一个顺序,并且他们都将按照从C开始到3结束的顺序存储像Content-Length: 4253这样的字符串,但是他们对于二进制数的字节应该存储的顺序没有一个共同的想法。

我们这样描述区别:一些计算机是“大端的”(例如,较老的 SPARC 处理器),把最高有效字节放在第一位,就像我们写十进制数字一样;其他计算机(如几乎无处不在的 x86 体系结构)是“小端”的,将最低有效字节放在第一位(其中“第一”意味着“在具有较低内存地址的字节上”)。

要从有趣的历史角度来看待这个问题,一定要读一读丹尼·科恩的论文 IEN-137,“论圣战和对和平的呼吁”,其中引入了大端小端这两个词,是对乔纳森·斯威夫特的模仿:www.ietf.org/rfc/ien/ien137.txt

Python 很容易看出这两种字节序的区别。只需使用struct模块,该模块提供了多种将数据与流行的二进制格式相互转换的操作。下面是首先以小端格式表示,然后以大端顺序表示的数字4253:

>>> import struct
>>> struct.pack('<i', 4253)
b'\x9d\x10\x00\x00'
>>> struct.pack('>i', 4253)
b'\x00\x00\x10\x9d'

这里我使用了结构格式化代码'i',它使用四个字节来存储一个整数,对于像4253这样的小数字,它将两个高位字节保留为零。您可以将这两个订单的struct字符顺序代码'<''>'想象成指向一个字节串的最低有效端的小箭头,如果这有助于您记住使用哪一个的话。参见标准库中的struct模块文档,了解其支持的所有数据格式。它还支持一个unpack()操作,将二进制数据转换回 Python 数字。

>>> struct.unpack('>i', b'\x00\x00\x10\x9d')
(4253,)

如果 big-endian 格式直观上对您更有意义,那么您可能会高兴地得知,它“赢得了”将成为网络数据标准的 endianness 的竞赛。因此,struct模块提供了另一个符号,'!',它与pack()unpack()中的'>'意思相同,但是对其他程序员说(当然,当你稍后阅读代码时对你自己说),“我正在打包这些数据,以便我可以通过网络发送它们。”

总之,我对准备通过网络套接字传输二进制数据有以下建议:

  • 使用struct模块生成二进制数据,以便在网络上传输,并在到达时将其解包。
  • 如果控制数据格式,选择带'!'前缀的网络字节顺序。
  • 如果其他人设计了协议并指定了 little-endian,那么您必须使用'<'来代替。

始终测试struct,看看它如何将您的数据与您正在使用的协议规范进行比较;注意,打包格式字符串中的'x'字符可以用来插入填充字节。

您可能会看到较老的 Python 代码使用了来自socket模块的一系列命名混乱的函数,以便按照网络顺序将整数转换成字节串。这些函数的名字类似于ntohl()htons(),它们对应于 POSIX 网络库中的同名函数,POSIX 网络库中还提供了socket()bind()等调用。建议你忽略这些笨拙的函数,改用struct模块;它更灵活,更通用,并且产生更可读的代码。

框架和引用

如果您使用 UDP 数据报进行通信,那么协议本身会以离散的、可识别的数据块来传送您的数据。然而,如果网络出现任何问题,你将不得不自己重新排序并重新传输这些数据块,如第二章所述。

然而,如果你选择了使用 TCP 流进行通信这一更常见的选项,那么你将面临框架 — 的问题,即如何界定你的消息,以便接收方能够知道一条消息在哪里结束,下一条消息在哪里开始。由于您提供给sendall()的数据可能会被分解成几个数据包,以便在网络上进行实际传输,因此接收您的消息的程序可能需要进行几次recv()调用才能读取整个消息——或者,如果在操作系统有机会再次调度该进程时所有数据包都已到达,则可能不会!

帧的问题提出了这样一个问题:当接收者最终停止调用recv()是安全的,因为整个消息或数据已经完整无缺地到达,并且现在可以作为一个整体来解释或处理它?

正如您可能想象的那样,有几种方法。

首先,有一种模式可以被非常简单的网络协议使用,这种协议只涉及数据的传递— 不期望有响应,所以永远不会出现接收方决定“够了!”并转过身来发送响应。在这种情况下,发送方可以循环,直到所有的输出数据都被传递到套接字的sendall()close()。接收者只需要重复调用recv(),直到调用最终返回一个空字符串,表明发送者最终关闭了套接字。你可以在清单 5-1 中看到这种模式。

清单 5-1 。只需发送所有数据,然后关闭连接

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter05/streamer.py
# Client that sends data then closes the socket, not expecting a reply.

import socket
from argparse import ArgumentParser

def server(address):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(address)
    sock.listen(1)
    print('Run this script in another window with "-c" to connect')
    print('Listening at', sock.getsockname())
    sc, sockname = sock.accept()
    print('Accepted connection from', sockname)
    sc.shutdown(socket.SHUT_WR)
    message = b''
    while True:
        more = sc.recv(8192)  # arbitrary value of 8k
        if not more: # socket has closed when recv() returns ''
            print('Received zero bytes - end of file')
            break
        print('Received {} bytes'.format(len(more)))
        message += more
    print('Message:\n')
    print(message.decode('ascii'))
    sc.close()
    sock.close()

def client(address):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(address)
    sock.shutdown(socket.SHUT_RD)
    sock.sendall(b'Beautiful is better than ugly.\n')
    sock.sendall(b'Explicit is better than implicit.\n')
    sock.sendall(b'Simple is better than complex.\n')
    sock.close()

if __name__ == '__main__':
    parser = ArgumentParser(description='Transmit & receive a data stream')
    parser.add_argument('hostname', nargs='?', default='127.0.0.1',
                        help='IP address or hostname (default: %(default)s)')
    parser.add_argument('-c', action='store_true', help='run as the client')
    parser.add_argument('-p', type=int, metavar='port', default=1060,
                        help='TCP port number (default: %(default)s)')
    args = parser.parse_args()
    function = client if args.c else server
    function((args.hostname, args.p))

如果您将此脚本作为服务器运行,然后在另一个命令提示符下运行客户端版本,您将会看到所有客户端数据都完好无损地传送到服务器,由客户端关闭套接字生成的文件结束事件是唯一需要的帧。

$ python streamer.py
Run this script in another window with "-c" to connect
Listening at ('127.0.0.1', 1060)
Accepted connection from ('127.0.0.1', 49057)
Received 96 bytes
Received zero bytes - end of file
Message:

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.

请注意,由于这个套接字并不打算接收任何数据,因此客户端和服务器都会继续关闭它们不打算使用的方向上的通信。这防止了套接字在另一个方向上的任何意外使用——这种使用最终可能会将足够多的未读数据排队以产生死锁,正如你在第三章的中的清单 3-2 中看到的那样。只有客户机或服务器需要调用套接字上的shutdown(),但是从两个方向都这样做可以提供对称性和冗余性。

第二种模式是第一种模式的变体:双向流动。插座最初在两个方向上都是开放的。首先,数据以一个方向流动——正如清单 5-1 中的所示——然后那个方向被关闭。其次,数据以另一个方向流动,套接字最终关闭。第三章中的清单 3-2 再次说明了一个重要的警告:在返回到另一个方向之前,一定要完成一个方向的数据传输,否则你可能会产生一个死锁的客户机和服务器。

第三种模式,也在第三章中有说明,是使用固定长度的消息,如清单 3-1 所示。您可以使用 Python sendall()方法来传输您的字节串,然后使用您自己设计的recv()循环来确保您接收到完整的消息。

def recvall(sock, length):
    data = ''
    while len(data) < length:
        more = sock.recv(length - len(data))
        if not more:
            raise EOFError('socket closed {} bytes into a {}-byte'
                           ' message'.format(len(data), length))
        data += more
    return data

固定长度的消息有点少见,因为现在似乎很少有数据适合静态边界。然而,特别是在传输二进制数据时(例如,考虑一种总是产生相同长度的数据块的struct格式),您可能会发现它非常适合某些情况。

第四种模式是用特殊字符分隔消息。接收者将在一个类似刚才所示的recv()循环中等待,但不会退出循环,直到它累积的回复字符串最终包含指示消息结束的定界符。如果消息中的字节或字符保证在某个有限的范围内,那么显而易见的选择是用从该范围之外选择的符号来结束每条消息。例如,如果您正在发送 ASCII 字符串,您可能会选择空字符'\0'作为分隔符,或者选择一个完全在 ASCII 范围之外的字符,如'\xff'

相反,如果消息可以包含任意数据,,那么使用分隔符就成了一个问题:如果您试图用作分隔符的字符出现在数据中怎么办?答案当然是引用——就像必须在 Python 字符串中间将单引号字符表示为\',而该字符串本身是由单引号字符分隔的。

'All\'s well that ends well.'

然而,我建议只在消息字母表受到限制的情况下使用定界符方案;如果您必须处理任意数据,实现正确的引用和取消引用通常太麻烦了。首先,您对分隔符是否到达的测试必须确保您不会将带引号的分隔符与真正结束消息的分隔符混淆。第二个复杂性是,您必须传递消息以删除保护分隔符文本出现的引号字符。最后,这意味着消息长度无法测量,直到您执行了解码;长度为 400 的消息可能有 400 个符号长,也可能有 200 个定界符实例,并带有引号,或者介于两者之间。

第五种模式是给每个消息加上其长度前缀。这是高性能协议的普遍选择,因为二进制数据块可以逐字发送,而无需分析、引用或插入。当然,长度本身必须使用前面给出的技术之一来构造,通常长度是一个简单的固定宽度的二进制整数,或者是一个可变长度的十进制字符串,后跟一个文本分隔符。无论哪种方式,一旦长度被读取和解码,接收者可以进入一个循环并重复调用recv()直到整个消息到达。这个循环看起来和清单 3-1 中的一模一样,但是用一个长度变量代替了数字 16。

最后,如果您想要第五种模式的简单性和效率,但是您事先不知道每条消息的长度—可能是因为发送者正在从他们无法预测长度的源读取数据,该怎么办?在这种情况下,您是否必须放弃优雅,在数据中苦苦寻找分隔符?

如果使用第六种也是最后一种模式,未知长度没有问题。不要只发送一个,试着发送几个数据块,每个数据块都以长度为前缀。这意味着,当每个新信息块对发送者可用时,它可以用它的长度来标记,并放在输出流上。当结尾最终到达时,发送方可以发出一个商定的信号——可能是一个长度字段,给出数字 0——告诉接收方这一系列块已经完成。

这个想法的一个简单例子显示在清单 5-2 中。和前面的清单一样,它只向一个方向发送数据——从客户机到服务器——但是数据结构比前面的清单有趣得多。每条消息都以包含在struct中的 4 字节长度为前缀。由于'I'表示 32 位无符号整数,每个帧的长度可以达到 4GB。此示例代码向服务器发送一系列三个块,后面跟一个零长度消息,这只是一个长度字段,里面有零,后面没有消息数据,以表示这一系列块已经结束。

清单 5-2 。通过在每个数据块前面加上其长度来构造数据块

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter05/blocks.py
# Sending data over a stream but delimited as length-prefixed blocks.

import socket, struct
from argparse import ArgumentParser

header_struct = struct.Struct('!I')  # messages up to 2**32 - 1 in length

def recvall(sock, length):
    blocks = []
    while length:
        block = sock.recv(length)
        if not block:
            raise EOFError('socket closed with %d bytes left'
                           ' in this block'.format(length))
        length -= len(block)
        blocks.append(block)
    return b''.join(blocks)

def get_block(sock):
    data = recvall(sock, header_struct.size)
    (block_length,) = header_struct.unpack(data)
    return recvall(sock, block_length)

def put_block(sock, message):
    block_length = len(message)
    sock.send(header_struct.pack(block_length))
    sock.send(message)

def server(address):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(address)
    sock.listen(1)
    print('Run this script in another window with "-c" to connect')
    print('Listening at', sock.getsockname())
    sc, sockname = sock.accept()
    print('Accepted connection from', sockname)
    sc.shutdown(socket.SHUT_WR)
    while True:
        block = get_block(sc)
        if not block:
            break
        print('Block says:', repr(block))
    sc.close()
    sock.close()

def client(address):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(address)
    sock.shutdown(socket.SHUT_RD)
    put_block(sock, b'Beautiful is better than ugly.')
    put_block(sock, b'Explicit is better than implicit.')
    put_block(sock, b'Simple is better than complex.')
    put_block(sock, b'')
    sock.close()

if __name__ == '__main__':
    parser = ArgumentParser(description='Transmit & receive blocks over TCP')
    parser.add_argument('hostname', nargs='?', default='127.0.0.1',
                        help='IP address or hostname (default: %(default)s)')
    parser.add_argument('-c', action='store_true', help='run as the client')
    parser.add_argument('-p', type=int, metavar='port', default=1060,
                        help='TCP port number (default: %(default)s)')
    args = parser.parse_args()
    function = client if args.c else server
    function((args.hostname, args.p))

注意你必须多小心!即使 4 字节长度字段是如此之小的数据量,以至于您可能无法想象recv()不会一次返回所有数据,但是只有当您小心地将recv()包装在一个循环中时,代码才是正确的,这个循环(以防万一)将一直要求更多的数据,直到所有四个字节都到达。在编写网络代码时,这种谨慎是必要的。

因此,您至少有六种选择来将无止境的数据流分割成可理解的块,以便客户端和服务器知道消息何时完成,并可以转身响应。请注意,许多现代协议将它们混合在一起,您可以自由地做同样的事情。

不同框架技术之间混搭的一个很好的例子是 HTTP 协议,,在本书的后面你会学到更多。它使用一个分隔符——空行'\r\n\r\n'—来表示它的标题何时完成。因为标题是文本,所以这样可以安全地将行尾作为特殊字符处理。但是,由于实际的有效负载可以是纯二进制数据,比如图像或压缩文件,所以在头文件中提供了一个以字节为单位的Content-Length,以确定在头文件结束后还要从套接字读取多少数据。因此,HTTP 混合了您在这里看到的第四种和第五种模式。事实上,它还可以使用第六个选项:如果服务器正在传输一个长度无法预测的响应,那么 HTTP 可以使用“分块编码”,发送一系列以长度为前缀的块。一个零长度字段标志着传输的结束,就像清单 5-2 中的一样。

Pickles 和自定界格式

请注意,您可能通过网络发送的某些类型的数据已经包含某种形式的内置定界。如果您正在传输这样的数据,那么您可能不需要在数据已经在做的基础上强加您自己的框架。

以 Python“pickles”为例,它是标准库附带的序列化的原生形式。pickle 使用文本命令和数据的奇怪组合来存储 Python 数据结构的内容,以便您可以稍后或在不同的机器上重建它。

>>> import pickle
>>> pickle.dumps([5, 6, 7])
b'\x80\x03]q\x00(K\x05K\x06K\x07e.'

关于这个输出数据,有趣的是您在前面的字符串末尾看到的'.'字符。这是格式的方式来标志一个泡菜的结束。一旦遇到它,加载程序可以停止并返回值,而无需进一步读取。因此,你可以拿着前面的泡菜,在末尾贴上一些难看的数据,看看loads()会完全忽略多余的数据,把原来的列表还给你。

>>> pickle.loads(b'\x80\x03]q\x00(K\x05K\x06K\x07e.blahblahblah')
[5, 6, 7]

当然,以这种方式使用loads()对网络数据没有用,因为它没有告诉你为了重新加载 pickle 它处理了多少字节;您仍然不知道字符串中有多少是 pickle 数据。但是如果您切换到从文件读取并使用 pickle load()函数,那么文件指针将保持在 pickle 数据的末尾,如果您想读取 pickle 之后的内容,您可以从那里开始读取。

>>> from io import BytesIO
>>> f = BytesIO(b'\x80\x03]q\x00(K\x05K\x06K\x07e.blahblahblah')
>>> pickle.load(f)
[5, 6, 7]
>>> f.tell()
14
>>> f.read()
b'blahblahblah'

或者,您可以创建一个协议,在两个 Python 程序之间来回发送 pickles。请注意,您不需要放入清单 5-2 中的recvall()函数中的那种循环,因为pickle库知道所有关于从文件中读取的内容,以及它可能必须如何重复读取,直到读取完整个 pickle。如果你想在 Python 文件对象中封装一个套接字,供 pickle load()函数之类的例程使用,使用makefile()套接字方法(在第三章中讨论)。

请注意,在处理大型数据结构时有许多微妙之处,尤其是当它们包含的 Python 对象超出了简单的内置类型(如整数、字符串、列表和字典)时。更多详情见pickle模块文档。

XML 和 JSON

如果您的协议需要可以在其他编程语言中使用,或者如果您只是喜欢通用标准而不是 Python 特有的格式,那么 JSON 和 XML 数据格式都是受欢迎的选择。请注意,这两种格式都不支持组帧,因此您必须先弄清楚如何通过网络提取完整的文本字符串,然后才能处理它。

JSON 是目前在不同计算机语言之间发送数据的最佳选择之一。从 Python 2.6 开始,它作为一个名为json的模块被包含在标准库中。它提供了序列化简单数据结构的通用技术。

>>> import json
>>> json.dumps([51, 'Namárië!'])
'[51, "Nam\\u00e1ri\\u00eb!"]'
>>> json.dumps([51, 'Namárië!'], ensure_ascii=False)
'[51, "Namárië!"]'
>>> json.loads('{"name": "Lancelot", "quest": "Grail"}')
{u'quest': u'Grail', u'name': u'Lancelot'}

从这个例子中可以注意到,JSON 不仅允许在其字符串中使用 Unicode 字符,如果您告诉 Python json模块它不需要将其输出限制为 ASCII 字符,它甚至可以在其有效载荷中包含 Unicode 字符。还要注意,JSON 表示被定义为产生一个字符串,这就是为什么这里使用完整的字符串而不是简单的 Python 字节对象作为json模块的输入和输出。根据 JSON 标准,您需要将其字符串编码为 UTF-8,以便在网络上传输。

XML 格式更适合于文档,因为它的基本结构是获取字符串,并通过将它们放在尖括号元素中来标记它们。在第十章中,你将广泛了解 Python 中处理 XML 和相关格式文档的各种选项。但是现在,请记住,您不必将 XML 的使用限制在实际使用 HTTP 协议的时候。可能会有这样的情况,当您需要文本中的标记时,您会发现 XML 在与其他协议结合使用时非常有用。

在开发人员可能要考虑的许多其他格式中,有像 Thrift 和 Google Protocol Buffers 这样的二进制格式,它们与刚刚定义的格式有些不同,因为客户端和服务器端都需要有一个代码定义,来定义每条消息将包含的内容。然而,这些系统包含对不同协议版本的规定,使得新的服务器可以投入生产,仍然与具有较旧协议版本的其他机器通信,直到它们都可以被更新到新版本。它们很有效率,传递二进制数据没有问题。

压缩

由于通过网络传输数据所需的时间通常比 CPU 准备数据传输所需的时间更长,因此在发送数据之前压缩数据通常是值得的。流行的 HTTP 协议,正如你将在第九章中看到的,让客户机和服务器判断它们是否都支持压缩。

关于 GNU zlib工具,的一个有趣的事实是,它可以通过 Python 标准库获得,并且是当今互联网上最普遍的压缩形式之一,它是自组织的。如果你开始给它一个压缩的数据流,那么它可以告诉你什么时候压缩的数据已经结束了,并让你访问随后可能出现的未压缩的有效载荷。

大多数协议选择自己成帧,然后,如果需要,将结果块传递给zlib进行解压缩。然而,你可以向自己承诺,你将总是在每个 zlib 压缩字符串的末尾添加一点未压缩的数据(这里,我将使用一个单独的b'.'字节),并观察你的压缩对象分离出“额外数据”,作为你完成的信号。

考虑两个压缩数据流的组合:

>>> import zlib
>>> data = zlib.compress(b'Python') + b'.' + zlib.compress(b'zlib') + b'.'
>>> data
b'x\x9c\x0b\xa8,\xc9\xc8\xcf\x03\x00\x08\x97\x02\x83.x\x9c\xab\xca\xc9L\x02\x00\x04d\x01\xb2.'
>>> len(data)
28

请注意,大多数压缩方案,当给定微小的有效载荷时,倾向于使它们更长而不是更短,因为压缩格式的开销超过了有效载荷中任何微小的可压缩性。

假设这 28 个字节以 8 字节数据包的形式到达目的地。处理完第一个包,你会发现解压对象的unused_data槽还是空的,告诉你还有更多数据要来。

>>> d = zlib.decompressobj()
>>> d.decompress(data[0:8]), d.unused_data
(b'Pytho', b'')

所以,你会想再次recv()上插座。第二个八个字符的块,当输入到解压缩对象时,将完成您等待的压缩数据,并返回一个非空的unused_data值,表明您最终收到了b'.'字节:

>>> d.decompress(data[8:16]), d.unused_data
('n', '.x')

句点之后的字符必须是压缩数据的第一位之后的任何有效载荷的第一个字节。因为这里您期望进一步的压缩数据,所以您将把'x'提供给一个新的解压缩对象,然后您可以向该对象提供您正在模拟的最终的 8 字节“数据包”:

>>> d = zlib.decompressobj()
>>> d.decompress(b'x'), d.unused_data
(b'', b'')
>>> d.decompress(data[16:24]), d.unused_data
(b'zlib', b'')
>>> d.decompress(data[24:]), d.unused_data
(b'', b'.')

在这一点上,unused_data再次显示您已经阅读了第二轮压缩数据的结尾,并且可以检查其内容,因为它已经完整无损地到达。

同样,大多数协议设计者将压缩设为可选,并简单地进行他们自己的组帧。尽管如此,如果您提前知道您将总是想要使用zlib,那么像这样的约定将让您利用内置于zlib中的流终止,并自动检测每个压缩流的结尾。

网络例外

本书中的示例脚本通常被设计成只捕捉那些对所演示的特性不可或缺的异常。因此,当我在清单 2-2 的中说明套接字超时时,我很小心地捕捉到了异常socket.timeout,因为这就是超时的信号方式。然而,我忽略了所有其他可能发生的异常,如果命令行上提供的主机名无效,远程 IP 与bind()一起使用,与bind()一起使用的端口已经繁忙,或者对等体无法联系或停止响应。

使用套接字会导致什么错误?虽然在使用网络连接时可能发生的错误数量相当大——包括在复杂的 TCP/IP 协议的每个阶段可能发生的每一个错误——但幸运的是,套接字操作可能影响您的程序的实际异常数量相当少。套接字操作特有的例外如下:

  • OSError:这是socket模块的主力,在网络传输的任何阶段,几乎每一个可能发生的故障都会引发。几乎在任何套接字调用中都可能发生这种情况,即使是在您最不希望的时候。例如,当先前的send()从远程主机引发了一个重置(RST)包时,您将实际看到由您下一次在该套接字上尝试的任何套接字操作所引发的错误。

  • socket.gaierror:当getaddrinfo()找不到您要查询的名称或服务时,就会引发这个异常,这就是其名称中出现字母 gai 的原因。不仅当您显式调用getaddrinfo()时,而且当您为类似bind()connect()的调用提供主机名而不是 IP 地址,并且主机名查找失败时,都会引发该问题。如果捕捉到这个异常,可以在异常对象内部查找错误号和消息。

    >>> import socket
    >>> s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    >>> try:
    ...   s.connect(('nonexistent.hostname.foo.bar', 80))
    ... except socket.gaierror as e:
    ...   raise
    ...
    Traceback (most recent call last):
      ...
    socket.gaierror: [Errno -2] Name or service not known
    >>> e.errno
    -2
    >>> e.strerror
    'Name or service not known'
    
  • 只有当你或你正在使用的库决定在套接字上设置超时,而不是愿意永远等待一个send()recv()完成时,才会引发这个异常。它表示在操作正常完成之前,超时确实已过期。

您将看到socket模块的标准库文档也描述了一个herror异常。幸运的是,只有当你使用某些老式的地址查找调用,而不是遵循第四章中概述的实践时,这种情况才会发生。

在使用 Python 的基于套接字的高级协议时,一个大问题是它们是否允许原始套接字错误影响您自己的代码,或者它们是否捕捉这些错误并将其转化为自己的错误。这两种方法的例子都存在于 Python 标准库中!例如,httplib认为自己级别很低,可以让您看到由于连接到未知主机名而导致的原始套接字错误。

>>> import http.client
>>> h = http.client.HTTPConnection('nonexistent.hostname.foo.bar')
>>> h.request('GET', '/')
Traceback (most recent call last):
  ...
socket.gaierror: [Errno -2] Name or service not known

但是urllib2,可能是因为它想保留作为一个干净和中立的系统来解析文档 URL 的语义,隐藏了同样的错误并提出了URLError

>>> import urllib.request
>>> urllib.request.urlopen('http://nonexistent.hostname.foo.bar/')
Traceback (most recent call last):
  ...
socket.gaierror: [Errno -2] Name or service not known

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  ...
urllib.error.URLError: <urlopen error [Errno -2] Name or service not known>

因此,根据您使用的协议实现,您可能必须只处理特定于该协议的异常,或者您可能必须同时处理特定于协议的异常和原始套接字错误。如果您对某个特定库采用的方法有疑问,请仔细查阅文档。对于我将在本书后续章节中介绍的主要软件包,我已经尝试提供了一些插图,列出了每个库可以让您的代码遵循的可能的异常。

当然,您可以随时启动有问题的库,为它提供一个不存在的主机名,或者甚至在断开网络连接时运行它,看看会出现什么样的异常。

编写网络程序时,应该如何处理可能出现的所有错误?当然,这个问题并不是真正针对网络的。所有种类的 Python 程序都必须处理异常,我在本章中简单讨论的技术也适用于许多其他种类的程序。无论您是将异常打包以供调用您的 API 的其他程序员处理,还是拦截异常以将它们适当地报告给最终用户,您的方法都会有所不同。

提出更具体的例外情况

有两种方法可以将异常传递给你正在编写的 API 的用户。当然,在许多情况下,你将是你正在编写的模块或例程的唯一客户。然而,仍然值得把你未来的自己想象成一个客户,他将会忘记关于这个模块的几乎所有事情,并且会非常欣赏它在处理异常时的简单性和清晰性。

一种选择是根本不处理网络异常。然后调用者就可以看到它们并进行处理,调用者可以根据自己的选择捕获或报告它们。这种方法非常适合于级别较低的网络例程,在这些例程中,调用者可以生动地描绘出为什么要设置套接字,以及为什么它的设置或使用可能会遇到错误。只有当 API 调用和底层网络操作之间的映射清晰时,编写调用代码的开发人员才会想到网络错误。

另一种方法是将网络错误包装在您自己的异常中。对于那些对你如何实现你的例程知之甚少的作者来说,这要容易得多,因为他们的代码现在可以捕捉特定于你的代码执行的操作的异常,而不必知道你如何使用套接字的细节。自定义异常还让您有机会精心制作错误消息,准确描述当您的库与网络发生冲突时它试图完成的任务。

例如,如果您编写一个小的mycopy()方法,将一个文件从一台远程机器复制到另一台机器,那么socket.error不会帮助调用者知道错误是与源机器的连接有关还是与目的机器的连接有关,或者完全是其他问题。在这种情况下,定义您自己的异常可能会好得多——也许是SourceErrorDestinationError——它们与您的 API 有紧密的语义关系。你总是可以通过raise...from异常链接来包含最初的套接字错误,以防你的 API 的一些用户想要进一步调查。

class DestinationError(Exception):
    def __str__(self):
        return '%s: %s' % (self.args[0], self.__cause__.strerror)

# ...

try:
    host = sock.connect(address)
except socket.error as e:
    raise DestinationError('Error connecting to destination') from e

当然,这段代码假设DestinationError只会像socket.error一样包装OSError的后代。否则,__str__()方法将不得不更加复杂,以处理原因异常在属性而不是strerror中保存其文本信息的情况。但这至少说明了模式。捕捉到一个DestinationError的调用者可以检查它的__cause__,以了解他们实际捕捉到的语义更丰富的异常背后的网络错误。

捕捉和报告网络异常

有两种捕获异常的基本方法:粒度异常处理程序和总括异常处理程序。

对异常的粒度方法是在你曾经进行的每个网络调用周围包装一个try...except子句,并在它的位置打印出一个简洁的错误消息。虽然适用于短程序,但这在长程序中会变得重复,不一定能为用户提供更多的信息。当您用另一个try...except和特定的错误消息包装程序中的第一百个网络操作时,问问自己是否真的提供了更多的信息。

另一种方法是使用一揽子异常处理程序。这涉及到从代码中退一步,找出做特定事情的大区域,比如:

  • "这整个程序是关于连接到许可证服务器."
  • "这个函数中的所有套接字操作都从数据库中获取响应."
  • "这最后一部分是所有的清理和关闭代码."

然后,程序的外部部分(收集输入、命令行参数和配置设置,然后启动大型操作的部分)可以用如下处理程序包装这些大型操作:

import sys

...

try:
    deliver_updated_keyfiles(...)
except (socket.error, socket.gaierror) as e:
    print('cannot deliver remote keyfiles: {}'.format(e), file=sys.stderr)
    exit(1)

更好的方法是,让您的代码引发一个您自己设计的错误,指出一个特别需要暂停程序并为用户打印错误输出的错误。

except:
    FatalError('cannot send replies: {}'.format(e))

然后,在程序的最顶层,捕捉所有抛出的FatalError异常,并在那里打印错误消息。这样,当您希望添加一个命令行选项,将致命错误发送到系统错误日志而不是屏幕时,您只需调整一段代码,而不是十几段!

还有最后一个原因可能会决定在网络程序中添加异常处理程序的位置:您可能希望智能地重试失败的操作。在长时间运行的程序中,这是很常见的。想象一个定期发送电子邮件告知其状态的工具。如果它突然不能成功发送它们,那么它可能不想因为可能只是一个暂时的错误而关闭。相反,电子邮件线程可能会记录错误,等待几分钟,然后重试。

在这种情况下,您将在特定的网络操作序列周围添加异常处理程序,您希望将这些网络操作序列视为单个组合操作的成功或失败。“如果这里出现任何问题,我就会放弃,等待十分钟,然后再次尝试发送电子邮件。”这里,您正在执行的网络操作的结构和逻辑——而不是用户或程序员的便利——将指导您在哪里部署try...except子句。

摘要

对于要放在网络上的机器信息,必须对其进行转换,以便不管您的机器内部使用的是什么私有的和特殊的存储机制,数据都使用公共的和可再现的表示来呈现,这些表示可以在其他系统上被其他程序读取,甚至可能被其他编程语言读取。

对于文本来说,最大的问题是选择一种编码方式,这样你要传输的符号就可以变成字节,因为 8 位八位字节是 IP 网络的通用货币。二进制数据需要您注意确保字节的排序方式在不同的机器之间是兼容的;Python struct模块将在这方面帮助你。最后,数据结构和文档有时最好使用 JSON 或 XML 之类的东西来发送,这提供了一种在机器之间共享结构化数据的通用方法。

当使用 TCP/IP 流时,你将面临的一个大问题是关于成帧:在长数据流中,你将如何辨别一个特定消息的开始和结束?有许多可能的技术来实现这一点,所有这些都必须小心处理,因为recv()可能在每次调用时只返回部分传入传输。特殊的分隔符或模式、固定长度的消息和分块编码方案都是修饰数据块的可能方法,这样就可以区分它们。

Python pickles 不仅会将数据结构转换成可以通过网络发送的字符串,而且pickle模块会知道传入的 pickle 在哪里结束。这让您不仅可以使用 pickles 对数据进行编码,还可以将单个消息组织到流中。通常与 HTTP 一起使用的zlib压缩模块也可以判断压缩段何时结束,从而为您提供廉价的帧。

套接字会引发几种异常,代码使用的网络协议也是如此。何时使用try...except子句的选择将取决于你的受众——你是为其他开发者编写一个库还是为最终用户编写一个工具?这还取决于语义:从调用者或最终用户的角度来看,如果所有代码都在做一件大事,那么你可以将程序的整个部分包装在一个try...except中。

最后,您可能希望用一个try...except单独包装操作,如果错误是暂时的,调用可能会在以后成功,那么可以自动重试。

六、TLS/SSL

传输层安全性(TLS) 最初由 Netscape 于 1995 年发布,当时称为安全套接字层(SSL ), 1999 年成为互联网标准,可能是当今互联网上使用最广泛的加密形式。正如您将在本章中了解到的,它与现代互联网上的许多基本协议一起用于验证服务器身份和保护传输中的数据。

TLS 的正确使用和部署是一个移动的目标。每年都有针对其加密算法的新攻击出现,新的密码和技术也因此应运而生。TLS 1.2 是第三版Python 网络编程基础的最新版本,但毫无疑问,未来几年还会发布更多版本。随着技术的进步,我将尽量保持在线存储在本书源代码库中的示例脚本的更新。因此,请务必访问本章中显示的每个脚本顶部的 URL,并从版本控制中找到的代码版本中进行剪切和粘贴。

本章将首先阐明 TLS 的功能,并概述它使用的技术。然后,您将查看简单和复杂的 Python 示例,了解如何在 TCP 套接字上激活和配置 TLS。最后,您将看到 TLS 是如何集成到现实世界的协议中的,您将在本书的其余部分中了解到这一点。

TLS 未能保护什么

正如您将在本章后面看到的,通过配置良好的 TLS 套接字传递的数据对任何观看者来说都应该是乱码。此外,除非数学让 TLS 的设计者们失望,否则它将是令人印象深刻的难以理解的胡言乱语,甚至对一个拥有大量预算的政府机构也是如此。它应该防止任何窃听者(比如 HTTPS 连接的窃听者)获知您请求的 URL、返回的内容或任何识别信息(比如可能在套接字上双向传递的密码或 cookie)。(参见第九章了解更多关于 HTTP 特性的信息,如密码和 cookies。)

尽管如此,你应该立即退后一步,记住除了数据之外,还有多少关于连接的信息是由 TLS 保密的,并且仍然可以被任何第三方观察到。

  • 你的机器和远程主机的地址都是可见的,在每个数据包的 IP 头中是纯字节。
  • 客户端和服务器的端口号也会出现在每个 TCP 报头中。
  • 您的客户机首先发出的获取服务器 IP 地址的 DNS 请求可能会以明文形式通过网络。

观察者可以观察通过 TLS 加密套接字在每个方向上传递的数据块的大小。尽管 TLS 会试图隐藏确切的字节数,但仍有可能大致了解数据通过了多大的块,以及请求和响应的总体模式。

我用一个例子来说明前面的弱点。想象一下,你使用一个安全的 HTTPS 客户端(比如你最喜欢的网络浏览器)通过咖啡店的无线网络获取https://pypi.python.org/pypi/skyfield/。观察者会知道什么——“观察者”可能是连接到咖啡店无线网络的任何人,或者是控制着咖啡店和互联网其余部分之间的路由器的任何人。观察者将首先看到你的机器对pypi.python.org进行 DNS 查询,除非在返回的 IP 地址上有许多其他网站,否则他们将猜测你随后在端口 443 与该 IP 地址的对话是为了查看https://pypi.python.org网页。他们将知道您的 HTTP 请求和服务器响应之间的差异,因为 HTTP 是一个锁步协议,在响应被写回之前,每个请求都被完整地写出。此外,他们将大致知道每个返回文档的大小,以及它们被获取的顺序。

想想观察者能学到什么!不同的页面在https://pypi.python.org会有不同的大小,观察者可以通过用网络刮刀扫描网站来分类(见第十一章)。不同类型的页面会包含不同的图片和 HTML 中引用的其他资源,需要在第一次查看时下载,或者如果它们已经从浏览器的缓存中过期。虽然外部观察者可能不确切知道您输入的搜索和您最终访问或下载的包,但他们通常能够根据他们看到的您获取的文件的粗略大小做出很好的猜测。

关于如何保持你的浏览习惯的秘密,或者隐藏任何其他在公共互联网上传播的个人数据,这个大问题远远超出了本书的范围,并且将涉及对在线匿名网络(例如 Tor 最近出现在新闻中)和匿名回复邮件者等机制的研究。即使采用了这样的机制,您的机器仍然可能发送和接收数据块,这些数据块的大小可能被用来猜测您正在做什么。一个足够强大的对手甚至可能会注意到,您的请求模式与离开匿名网络到达特定目的地的有效负载相对应。

本章的其余部分将关注 TLS 可以实现什么以及 Python 代码如何有效地使用它。

会出什么问题呢?

为了浏览 TLS 的基本特性,您将考虑协议本身在建立连接时面临的一系列挑战,并了解如何面对和克服每个障碍。

让我们假设您想要在互联网上的某个地方使用特定的主机名和端口号打开 TCP 会话,并且您已经不情愿地接受了您对主机名的 DNS 查找将是公开的,您正在连接的端口号也是公开的(这将暴露您正在使用的协议,除非您正在连接的服务的所有者将其绑定到非标准或误导性的端口号)。您将继续进行,并建立到 IP 地址和端口的标准 TCP 连接。如果你所说的协议要求在打开加密之间有一个介绍,那么那些最初的几个字节将会清晰地传递给每个人看。(协议在这一细节上有所不同——HTTPS 在启用加密之前不会发送任何东西,但 SMTP 会交换几行文本。在本章的后面,您将了解几个主要协议的行为。)

一旦你启动并运行了套接字,并交换了你的协议规定的任何客套话,为加密做准备,是时候让 TLS 接管并开始建立关于你在与谁交谈以及你和你与之交谈的对等方(另一方)如何保护数据免受窥探的强有力的保证了。

你的 TLS 客户端的第一个需求将是远程服务器提供一个名为证书 的二进制文档,其中包括密码学家所说的公钥——一个可以用来加密数据的整数,这样只有相应的私钥整数的拥有者才能解密信息并理解它。如果远程服务器被正确配置并且从未被破坏,那么它将拥有私钥的副本,并且是互联网上唯一拥有该副本的服务器(其集群中的其他机器可能例外)。您的 TLS 实现如何验证远程服务器实际持有私钥?简单!您的 TLS 库通过网络发送一些用公钥加密的信息,它要求远程服务器提供一个校验和,证明数据是用密钥成功解密的。

您的 TLS 堆栈还必须关注远程证书是否被伪造的问题。毕竟,任何能够访问openssl命令行工具(或许多其他工具中的任何一个)的人都可以创建一个证书,其通用名称是cn= www.google.comcn=pypi.python.org或其他名称。你为什么会相信这样的说法?解决方案是让您的 TLS 会话保留一个证书颁发机构(CAs) 列表,它信任该列表来验证互联网主机身份。默认情况下,您的操作系统 TLS 库或 web 浏览器的 TLS 库使用数百个证书的标准全球 CA 列表,这些证书代表执行可信站点验证业务的组织。但是,如果您对默认设置不满意,或者如果您想使用您的组织为免费签署您自己的私有主机证书而生成的私有 CA,您可以随时提供您自己的 CA 列表。当没有外部客户端要连接并且您只需要支持您自己的服务之间的连接时,这是一个流行的选项。

CA 在证书上做出的表示其认可的数学标记称为签名 。在接受证书有效之前,您的 TLS 库将根据相应 CA 证书的公钥验证证书的签名。

一旦 TLS 验证了证书正文确实已提交给可信第三方并由其签名,它将检查证书本身的数据字段。有两种领域会引起特别的兴趣。首先,证书包括一个notBefore日期和一个notAfter日期,以将它们有效的时间段括起来,从而属于被盗私钥的证书不会永远有效。您的 TLS 堆栈将使用您的系统时钟来检查这些,这意味着一个坏的或错误配置的时钟实际上会破坏您通过 TLS 进行通信的能力!第二,证书的通用名称应该与您试图连接的主机名相匹配——毕竟,如果您想连接到https://pypi.python.org,如果站点用一个完全不同的主机名的证书来响应,您很难放心!

一个证书实际上可以在许多主机名之间共享。现代证书可以用存储在其subjectAltName字段中的附加名称来补充其subject字段中的单值通用名称。此外,这些名称中的任何一个都可以包含通配符,比如*.python.org,它可以匹配多个主机名,而不是每个主机名只匹配一个。现代 TLS 算法会自动为您执行这种匹配,Python ssl模块也有自己的能力来完成这一任务。

最后,客户机和服务器上的 TLS 代理协商一个共享密钥和密码,用它来加密通过连接传递的实际数据。这是 TLS 可能失败的最后一点,因为正确配置的软件将拒绝它认为不合适的密码或密钥长度。事实上,这可能发生在两个层次上:TLS 可能失败,要么是因为另一端想要使用的 TLS 协议版本太过时且不安全,要么是因为另一端支持的特定密码被认为不够强大而不可信。

一旦对密码达成一致,并且双方都生成了密钥(既用于加密数据,也用于对每个数据块进行签名),控制权就会交还给两端的应用。他们传输的每个数据块都用加密密钥加密,然后用签名密钥对生成的块进行签名,以向另一端证明它确实是由另一个对等体生成的,而不是某个跳到网络上试图进行中间人攻击的人。数据可以不受任何限制地在两个方向上流动,就像在普通的 TCP 套接字上一样,直到 TLS 关闭,套接字要么关闭,要么返回到纯文本模式。

在接下来的章节中,您将学习如何控制 Python 的ssl库,因为它做出了前面概述的每一个主要决策。请查阅官方参考资料以获取更多信息,以及诸如 Bruce Schneier 的书籍、Google 在线安全博客和 Adam Langley 的博客等资源。我自己发现 Hynek Schlawack 在 PyCon 2014 上的“SSL 的糟糕状态”演讲很有帮助,你可以在线观看。如果在您阅读本书时,更多关于 TLS 的最新演讲已经出现在会议上,那么它们可能是关于密码学动态实践的最新信息的良好来源。

生成证书

Python 标准库并不关心私钥生成或证书签名。如果您需要执行这些步骤,您将不得不使用其他工具。最广泛使用的工具之一是openssl命令行工具。如果您想看几个如何调用它的例子,请参见本书源代码库的playground/certs目录中的 Makefile。

https://github.com/brandon-rhodes/fopnp/tree/m/playground/certs

目录还包含几个在网络游戏中使用的证书(见第一章,其中几个你将在本章的例子中的命令行中使用。ca.crt证书是一个小型的自包含证书颁发机构,您将告诉 Python 在使用 TLS 的其他证书时要信任它,它已经签署了所有其他证书。

简而言之,证书创建通常始于两条信息——一条是人工生成的,另一条是机器生成的。它们分别是由证书描述的实体的文本描述和使用操作系统提供的真随机性源精心产生的私钥。我通常将手写的身份描述保存到一个受版本控制的文件中,供以后参考;然而,一些管理员在得到提示时简单地将字段输入到openssl中。举个例子,清单 6-1 显示了用于为网络游乐场的www.example.com网络服务器生成证书的www.cnf文件。

清单 6-1 。OpenSSL 命令行使用的 X.509 证书的配置

[req]
prompt = no
distinguished_name = req_distinguished_name

[req_distinguished_name]
countryName            = us
stateOrProvinceName    = New York
localityName           = New York
0.organizationName     = Example from Apress Media LLC
organizationalUnitName = Foundations of Python Network Programming 3rd Ed
commonName             = www.example.com
emailAddress           = root@example.com

[ssl_client]
basicConstraints = CA:FALSE
nsCertType = client
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth

请记住,commonName和任何subjectAltName条目(在本例中不存在)是 TLS 将与主机名进行比较的关键字段,以确定它是否正在与正确的主机对话。

对于备份证书的私钥的适当长度和类型,专家们目前有几种意见,一些管理员选择 RSA,而另一些则更喜欢 Diffie-Hellman。不讨论这个问题,下面是一个示例命令行,用于创建一个 RSA 密钥,其长度目前被认为是相当可观的:

$ openssl genrsa -out www.key 4096
Generating RSA private key, 4096 bit long modulus
................................................................................
.............++
.............++
e is 65537 (0x10001)

有了这两个部分,管理员就可以创建证书签名请求(CSR) 提交给证书颁发机构了——无论是管理员自己的还是属于第三方的。

$ openssl req -new -key www.key -config www.cnf -out www.csr

如果您想了解由openssl工具创建私有 CA 的步骤,以及它如何签署 CSR 以生成与前面生成的请求相对应的www.crt文件,请查阅 Makefile。当与公共证书颁发机构打交道时,您可能会在电子邮件中收到您的www.crt(在您惊慌失措之前,请记住,该证书是应该是公开的!)或者在证书准备就绪时从您的帐户下载签名证书。无论如何,为了方便起见,使您的证书易于在 Python 中使用的最后一步是将证书和密钥合并到一个文件中。如果文件是由前面的命令生成的标准 PEM 格式,那么合并它们就像运行 Unix“concatenate”命令一样简单。

$ cat www.crt www.key > www.pem

结果文件应该包含证书内容的文本摘要,然后是证书本身,最后是私钥。小心这个文件!如果www.key或这个包含私钥的 PEM 文件www.pem被泄露或被第三方获得,那么该第三方将能够在整个几个月或几年内冒充您的服务,直到密钥过期。文件的三个部分看起来应该类似于清单 6-2 。(注意省略号——我们对文件进行了缩写,实际上需要两到三页书的篇幅!)

清单 6-2 。捆绑到单个 PEM 文件中的证书和私钥

Certificate:
    Data:
        Version: 1 (0x0)
        Serial Number: 3 (0x3)
    Signature Algorithm: sha1WithRSAEncryption
        Issuer: C=us, ST=New York, L=New York, O=Example CA from Apress Media LLC,
                OU=Foundations of Python Network Programming 3rd Ed,
                CN=ca/emailAddress=ca@example.com
        Validity
            Not Before: Mar  8 16:58:12 2014 GMT
            Not After : Feb 12 16:58:12 2114 GMT
        Subject: C=us, ST=New York, O=Example from Apress Media LLC,
                 OU=Foundations of Python Network Programming 3rd Ed,
                 CN=www.example.com/emailAddress=root@example.com
...
-----BEGIN CERTIFICATE-----
MIIE+zCCA2MCAQMwDQYJKoZIhvcNAQEFBQAwgcUxCzAJBgNVBAYTAnVzMREwDwYD
VQQIEwhOZXcgWW9yazERMA8GA1UEBxMITmV3IFlvcmsxKTAnBgNVBAoTIEV4YW1w
I7Ahb1Dobi7EoK9tXFMrXutOTQkoFe ... pT7/ivFnx+ZaxE0mcR8qyzyQqWTDQ
SBH14aSHQPSodSHC1AAAfB3B+CHII1TkAXUudh67swE2qvR/mFbFtHwuSVEbSHZ+
2ukF5Z8mSgkNlr6QnikCDIYbBWDOSiTzmX/zPorqlw==
-----END CERTIFICATE-----
-----BEGIN RSA PRIVATE KEY-----
MIIG5QIBAAKCAYEA3rM3H+kGaWhbbfqyKzoePLIiYBOLw3W+wuKigsU1qDPFJBKk
JF4UqCo6OfZuJLpAHAIPwb/0ihA2hXK8/I9Rd75t3leiYER6Oefg9TRGuxloD0om
8ZFW8k3p4RA7uDBMjHF3tZqIGpHpY6 ... f8QJ7ZsdXLRsVmHM+95T1Sy6QgmW2
Worz0PhhWVzGT7MgSduY0c8efArdZC5aVo24Gvd3i+di2pRQa0g9rSL7VJrm4BdB
NmdPSZN/rGhvwbWbPVQ5ofhFOMod1qgAp626ladmlublPtFt9sRJESU=
-----END RSA PRIVATE KEY-----

存在比 CA 直接签署证书供服务器使用更复杂的安排。例如,一些组织希望他们的服务器只使用在过期前持续几天或几周的短期证书。如果服务器遭到破坏,其私钥被盗,这可以最大限度地减少损失。这样的组织可以让 CA 签署一个更长寿命的中间证书,该组织将该证书的私钥作为秘密持有,并用于签署实际放在服务器上的用户可见的证书,而不必每隔几天就联系(并支付)CA 组织进行更换。由此产生的证书链信任链结合了拥有自己的 CA 的灵活性(因为您可以随时签署新证书)和公认的公共 CA 的优势(因为您不必在想要与您对话的每个浏览器或客户端中安装自定义 CA 证书)。只要您的 TLS 支持的服务器为客户端提供它自己的特定服务器证书以及中间证书,使加密链接返回到客户端知道可以信任的 CA 证书,客户端软件验证它们的身份应该没有问题。

如果您发现自己的任务是建立组织及其服务的加密标识,请查阅有关证书签名的书籍或文档。

卸载 TLS

在向您展示如何使用 Python 中的 TLS 之前——特别是如果您准备编写一个服务器的话——我应该注意到,许多专家会问为什么您首先要在 Python 应用中直接执行加密。毕竟,已经有许多工具已经仔细地实现了 TLS,如果您在另一个端口上运行应用,它们可以代表您负责应答客户端连接,并将未加密的数据转发到您的应用。

与您自己的服务器代码、Python 和底层 OpenSSL 库的组合相比,为您的 Python 应用提供 TLS 终止的独立守护程序或服务可能更容易升级和调整。此外,第三方工具通常会公开 Python ssl模块甚至在 Python 3.4 下还不允许您定制的 TLS 特性。例如,普通的ssl模块目前似乎无法使用 ECDSA 椭圆曲线签名或微调会话重新协商。会话重新协商是一个特别重要的主题。它可以显著降低提供 TLS 的 CPU 成本,但是如果配置不当,它会损害您承诺完美的前向安全性的能力(参见“精选的密码和完美的前向安全性”一节)。2013 年在https://www.imperialviolet.org/2013/06/27/botchingpfs.html发表的旧博文“如何修补 TLS 转发保密”仍然是对该主题的最佳介绍之一。

前端 HTTPS 服务器是提供 TLS 终止的第三方守护程序的一个很好的例子。第三方工具包装 HTTP 特别容易,因为 HTTPS 标准规定,在任何特定于协议的消息通过通道传递之前,客户端和服务器应该先协商加密。无论您是在 Python web 服务前面部署 Apache、nginx 或其他一些反向代理作为额外的防御级别,还是订阅像 Fastly 这样的内容交付网络,将请求通过隧道传输到您自己的服务器,您都会发现 TLS 可以从您的 Python 代码中消失,进入周围的基础设施中。

但是,如果您设置一个简单的守护进程(如stunnel)在您的公共 TCP 端口上运行,并私下将连接转发到您的服务,那么即使您自己的原始套接字协议(没有第三方工具可用)也可以接受第三方 TLS 保护。

如果您选择将 TLS 卸载到另一个工具,那么在开始阅读该工具的文档之前,您可能只需要浏览一下本章的其余部分(以便熟悉您将寻找的旋钮)。它将是那个工具,而不是 Python 本身,它将加载您的证书和私钥,并且它将需要被适当地配置以提供您所需要的针对弱密码的保护级别。唯一要问的问题是,您选择的前端将如何告诉您的 Python 服务远程 IP 地址和(如果您使用客户端证书)已连接的每个客户端的身份。对于 HTTP 连接,关于客户端的信息可以作为附加的头添加到请求中。对于更原始的工具,比如 stunnel 或 haproxy,它们实际上可能不会使用 HTTP,像客户端 IP 地址这样的额外信息必须作为额外的字节放在传入数据流的前面。无论哪种方式,工具本身都将提供 TLS 超能力,本章的其余部分将使用纯 Python 套接字来说明这一点。

Python 3.4 默认上下文

TLS 有几种开源实现。Python 标准库选择包装最流行的 OpenSSL 库,尽管最近发生了几起安全事故,但它似乎仍然被认为是大多数系统和语言的最佳选择。一些 Python 发行版自带 OpenSSL,而其他发行版只是包装了碰巧与您的操作系统捆绑在一起的 OpenSSL 版本。标准库模块有一个古老而怀旧的名字ssl。尽管在本书中你会将注意力集中在ssl上,但是请注意 Python 社区中正在进行其他的密码学项目,包括一个pyOpenSSL项目,它揭示了更多的底层库的 API。

通过引入ssl.create_default_context()函数 ,Python 3.4 比早期版本的 Python 更容易让 Python 应用安全地使用 TLS。这是大多数用户需要的“自以为是的 API”的一个很好的例子。我们应该感谢 Christian Heimes 为标准库添加了默认上下文的概念,也应该感谢 Donald Stufft 为标准库提供了有力而有用的观点。ssl模块为建立 TLS 连接提供的其他机制被迫坚持使用旧的、不太安全的默认机制,因为它们已经承诺在新版本的 Python 出现时不会破坏向后兼容性。但是如果你一直使用的 TLS 密码或密钥长度现在被认为是不安全的,那么create_default_context()很愿意在你下次升级 Python 时抛出一个异常。

通过放弃在不改变应用行为的情况下升级 Python 的承诺,create_default_context()可以仔细选择它将支持的密码,这让您摆脱了困境——如果您只是依赖它的建议,然后在您的机器上更新 Python,您就不必成为 TLS 专家和阅读安全博客。每次升级后都要重新测试您的应用,以确保它们仍然可以连接到它们的 TLS 对等端。如果某个应用出现故障,那么就要调查有问题的连接的另一端的对等设备是否也可以升级,以支持更现代的密码或机制。

如何创建和使用默认上下文?清单 6-3 展示了一个简单的客户端和服务器如何使用 TLS 安全地保护一个 TCP 套接字。

清单 6-3 。在 Python 3.4 或更新版本中,使用 TLS 保护客户端和服务器的套接字

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter06/safe_tls.py
# Simple TLS client and server using safe configuration defaults

import argparse, socket, ssl

def client(host, port, cafile=None):
    purpose = ssl.Purpose.SERVER_AUTH
    context = ssl.create_default_context(purpose, cafile=cafile)

    raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    raw_sock.connect((host, port))
    print('Connected to host {!r} and port {}'.format(host, port))
    ssl_sock = context.wrap_socket(raw_sock, server_hostname=host)

    while True:
        data = ssl_sock.recv(1024)
        if not data:
            break
        print(repr(data))

def server(host, port, certfile, cafile=None):
    purpose = ssl.Purpose.CLIENT_AUTH
    context = ssl.create_default_context(purpose, cafile=cafile)
    context.load_cert_chain(certfile)

    listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listener.bind((host, port))
    listener.listen(1)
    print('Listening at interface {!r} and port {}'.format(host, port))
    raw_sock, address = listener.accept()
    print('Connection from host {!r} and port {}'.format(*address))
    ssl_sock = context.wrap_socket(raw_sock, server_side=True)

    ssl_sock.sendall('Simple is better than complex.'.encode('ascii'))
    ssl_sock.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Safe TLS client and server')
    parser.add_argument('host', help='hostname or IP address')
    parser.add_argument('port', type=int, help='TCP port number')
    parser.add_argument('-a', metavar='cafile', default=None,
                        help='authority: path to CA certificate PEM file')
    parser.add_argument('-s', metavar='certfile', default=None,
                        help='run as server: path to server PEM file')
    args = parser.parse_args()
    if args.s:
        server(args.host, args.port, args.s, args.a)
    else:
        client(args.host, args.port, args.a)

您可以在清单中看到,保护套接字只需要三个步骤。首先,创建一个 TLS 上下文对象,它知道您关于证书验证和密码选择的所有偏好。第二,使用上下文的wrap_socket()方法让 OpenSSL 库控制你的 TCP 连接,与另一端交换必要的问候,建立加密通道。最后,与已经返回给您的ssl_sock进行所有进一步的通信,以便 TLS 层总是有机会在数据实际到达网络之前对其进行加密。你会注意到,这个包装器提供了所有与普通套接字相同的方法,例如send()recv()close(),这是你在第三章中从普通 TCP 套接字的经验中学到的。

选择是为尝试验证其连接的服务器的客户端创建上下文(Purpose.SERVER_AUTH)还是为需要接受客户端连接的服务器创建上下文(Purpose.CLIENT_AUTH)会影响返回的新上下文中的几个设置。从标准库作者的角度来看,拥有两套不同设置背后的理论是,您希望 TLS 客户端对较旧的密码稍微宽容一些,因为它们有时会发现自己连接到不受您控制的服务器,并且可能有点过时。但是他们认为,你肯定会希望你的自己的服务器坚持使用现代安全的密码!虽然create_default_context()选择的设置会随着 Python 的每个新版本而改变,但这里是它在 Python 3.4 下做出的一些选择,为您提供一个示例:

  • 您的客户机和服务器都愿意协商所使用的 TLS 版本,这要感谢在创建您的新的SSLContext对象时create_default_context()将协议设置为PROTOCOL_SSLv23
  • 您的客户机和服务器都将拒绝使用旧协议 SSLv2 和 SSLv3,因为它们都有已知的弱点。取而代之的是,他们会坚持让对方使用至少和 TLSv1 一样新的方言。(这种选择排除的最常见的客户端是运行 Windows XP 的 Internet Explorer 6,这种组合太老了,甚至不再得到微软的官方支持)。
  • TLS 压缩被关闭是因为它使攻击成为可能。
  • 这是客户端和服务器设置之间的第一个区别。由于互联网上的大多数 TLS 对话都涉及到一个没有自己签名证书的客户端(比如一个典型的 web 浏览器)与一个服务器(比如 PyPI 或 Google 或 GitHub 或你的银行)对话,告诉服务器是否拥有一个有效的签名证书,Python 告诉服务器甚至不要尝试对等证书验证(上下文的verify_mode被设置为ssl.CERT_NONE),但是它坚持客户端总是验证远程证书,如果不能验证,则异常失败(ssl.CERT_REQUIRED)。
  • 客户机和服务器的另一个区别是它们选择的密码。客户端设置支持更大的可能密码列表,甚至包括旧的 RC4 流密码。服务器设置要严格得多,并强烈倾向于提供完美前向安全性(PFS)的现代密码,以便被泄露的服务器密钥(无论是被罪犯捕获还是被法院命令释放)都不会导致旧对话被泄露。

编译前面的列表很容易:我只需在标准库中打开ssl.py并阅读create_default_context()的源代码来了解它做出的选择。您可以自己这样做,特别是当新的 Python 版本出现时,以前的列表开始过时。ssl.py源代码甚至包括了客户端和服务器操作的原始密码列表,目前命名为_DEFAULT_CIPHERS_RESTRICTED_SERVER_CIPHERS,如果你有足够的好奇心想要回顾它们的话。您可以查阅最近的 OpenSSL 文档来了解每个字符串中的选项的含义。

在清单 6-3 中构建上下文时提供的 cafile选项决定了您的脚本在验证远程证书时愿意信任哪些证书颁发机构。如果它的值是None,如果您选择不指定cafile关键字,这是默认值,那么create_default_context()将在返回它之前自动调用您的新上下文的load_default_certs()方法。这将尝试加载操作系统上的浏览器在连接到远程站点时信任的所有默认 CA 证书,并且应该足以验证已从知名公共证书颁发机构购买证书的公共网站和其他服务。如果cafile是一个指定文件名的字符串,那么不会从操作系统加载任何证书,只有该文件中提供的 CA 证书才会被信任来验证 TLS 连接的远端。(请注意,如果您在创建上下文时将cafile设置为None,然后调用load_verify_locations()来安装任何其他证书,那么您可以使这两种证书都可用。)

最后,在清单 6-3 中有两个关键选项提供给wrap_socket()——一个用于服务器,另一个用于客户端。服务器被给予选项server_side=True,仅仅是因为两端中的一端必须承担服务器的责任,否则协商将失败并出错。客户端调用需要更具体的东西:您认为您已经与connect()连接的主机的名称,以便可以根据服务器提供的证书的主题字段进行检查。这个极其重要的检查是自动执行的,只要您始终向wrap_socket()提供server_hostname关键字,如清单所示。

为了保持代码简单,清单 6-3 中的客户端和服务器都不在循环中运行。取而代之的是,他们各自进行一次单独的对话尝试。一个简单的localhost证书和一个已经签名的 CA 可以在列表在线的chapter06目录中获得;如果你想用它们来测试脚本,可以通过访问以下网址并点击 Raw 按钮来下载:

https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter06/ca.crt
https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter06/localhost.pem

如果您已经检查了该书脚本的整个源代码库,那么您可以跳过单独下载它们,只需将cd放入chapter06目录,在那里您会发现脚本和证书已经彼此相邻。不管怎样,清单 6-3 可以成功运行,只要localhost别名作为 127.0.0.1 IP 地址的同义词在您的系统上正常工作。首先用-s运行服务器,并在一个终端窗口中显示服务器 PEM 文件的路径。

$ /usr/bin/python3.4 safe_tls.py -s localhost.pem '' 1060

还记得第二章和第三章中的吗,空主机名''告诉 Python 你希望你的服务器监听所有可用的接口。现在打开另一个终端窗口,首先,使用您的正常系统 CA 证书列表运行客户端,该列表是您的浏览器在公共互联网上运行时使用的。

$ /usr/bin/python3.4 safe_tls.py localhost 1060
Connected to host 'localhost' and port 1060
Traceback (most recent call last):
  ...
ssl.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:598)

因为没有公共机构在localhost.pem内签署证书,所以您的客户端拒绝信任该服务器。您还会看到服务器已经死亡,并显示一条消息,指示客户端开始尝试连接,但随后放弃了连接。接下来,重启服务器,然后使用-a选项重新运行客户端,这告诉它信任任何由ca.crt签署的证书。

$ /usr/bin/python3.4 safe_tls.py -a ca.crt localhost 1060
Connected to host 'localhost' and port 1060
b'Simple is better than complex.'

这一次,您可以看到对话取得了圆满成功,一条简单的消息从服务器传送到了客户端。如果你打开像tcpdump 这样的数据包嗅探器,你会发现不可能从你捕获的数据包内容中破译信息的纯文本。在我的系统上,我可以通过以 root 用户身份运行以下命令来监控对话(查看您的操作系统文档,了解如何在您自己的机器上使用tcpdump或 WireShark 或其他工具执行数据包捕获):

# tcpdump -n port 1060 -i lo –X

前几个包将包含一些清晰的信息:证书和公钥,它们可以安全地发送,因为它毕竟是一个公钥。当数据包经过时,我的数据包转储向我显示清晰的公钥片段。

        0x00e0:  5504 0a13 2045 7861 6d70 6c65 2043 4120  U....Example.CA.
        0x00f0:  6672 6f6d 2041 7072 6573 7320 4d65 6469  from.Apress.Medi
        0x0100:  6120 4c4c 4331 3930 3706 0355 040b 1330  a.LLC1907..U...0
        0x0110:  466f 756e 6461 7469 6f6e 7320 6f66 2050  Foundations.of.P
        0x0120:  7974 686f 6e20 4e65 7477 6f72 6b20 5072  ython.Network.Pr
        0x0130:  6f67 7261 6d6d 696e 6720 3372 6420 4564  ogramming.3rd.Ed

但是一旦加密的密码投入使用,第三方就不可能再进行检查(假设加密中没有漏洞或弱点)。以下是刚刚从服务器向我的机器上的客户端传送字节'Simple is better than complex'的数据包:

16:49:26.545897 IP 127.0.0.1.1060 > 127.0.0.1.40220:
  Flags [P.], seq 2082:2141, ack 426, win 350, options
  [nop,nop,TS val 51288448 ecr 51285953], length 59
        0x0000:  4500 006f 645f 4000 4006 d827 7f00 0001  E..od_@.@..'....
        0x0010:  7f00 0001 0424 9d1c dbbf f412 f4d0 24a3  .....$........$.
        0x0020:  8018 015e fe63 0000 0101 080a 030e 9980  ...^.c..........
        0x0030:  030e 8fc1 1703 0300 367f 9b5d e6c3 dfbd  ........6..]....
        0x0040:  8f21 d83f 8b61 569f 78a0 2ac3 090b bc9f  .!.?.aV.x.*.....
        0x0050:  101d 2cb1 1c07 ee08 f784 f277 b11e 9214  ..,........w....
        0x0060:  ce02 8e2b 1c0b b630 9c2d f323 3674 f5    ...+...0.-.#6t.

请再次注意我在本章前面警告过的内容:服务器和客户机的 IP 地址和端口号完全不受阻碍地通过。只有数据有效载荷本身受到保护,不会被任何外部观察者看到。

套接字包装的变化

本章中的所有脚本都给出了使用ssl模块实现 TLS 的简单而通用的步骤:创建一个描述您的安全需求的已配置的SSLContext对象,使用普通套接字自己建立从客户端到服务器的连接,然后调用上下文的wrap_socket()方法来执行实际的 TLS 协商。我的例子总是使用这种模式的原因是因为它是使用模块 API 的健壮、高效和最灵活的方法。这是一种在 Python 应用中可以成功使用的模式,通过始终使用这种模式,您将制作出易于阅读的客户端和服务器,因为它们的方法是一致的,并且它们的代码易于与这里的示例进行比较以及相互比较。

然而,标准库ssl模块提供了一些不同的快捷方式,你可能会在其他代码中看到,因此我应该提到它们。让我来描述一下它们以及它们的缺点。

您将遇到的第一种选择是调用模块级函数ssl.wrap_socket(),而不首先创建上下文。在较旧的代码中,您会经常看到这种情况,因为在 Python 3.2 中添加上下文对象之前,这实际上是创建 TLS 连接的唯一方式!它至少有四个缺点。

  • 它的效率较低,因为在幕后,它每次被调用时都创建一个新的充满设置的上下文对象。相反,通过构建和配置您自己的上下文,您可以一次又一次地重复使用它,并且只需创建一次。
  • 它无法提供真实上下文的灵活性——尽管它提供了九个(!)不同的可选关键字参数,绝望地试图提供足够的旋钮和按钮——它仍然设法忽略了一些事情,例如让您指定您愿意使用的密码。
  • 可悲的是,它允许弱密码,因为它承诺向后兼容 10 年前的 Python 版本。
  • 最后,它不能提供真正的安全性,因为它不执行主机名检查!除非您记得在“成功”连接后通过运行match_hostname()来跟进,否则您甚至不知道您的对等方提供的证书是否是针对您认为您所连接的同一个主机名。

出于所有这些原因,您应该避免使用ssl.wrap_socket(),并准备好从您可能维护的任何旧代码中迁移出来。相反,使用清单 6-3 中所示的实践。

您将看到的另一个主要快捷方式是在连接套接字之前对其进行包装,在运行connect()之前包装客户端套接字,或者在运行accept()之前包装服务器套接字。在这两种情况下,包装的套接字不能真正立即协商 TLS,但它会等到套接字连接后再执行 TLS 协商。显然,这只适用于像 HTTPS 这样的协议,它们在连接后的第一步就是激活 TLS。像 SMTP 这样需要用一些明文开始对话的协议不能使用这种方法,所以在包装时有一个关键字选项do_handshake_on_connect可用,如果您想等到以后再用套接字的do_handshake()方法触发 TLS 协商,可以将它设置为False

诚然,预先包装套接字本身并不会降低安全性,但我建议不要这样做,原因有三,涉及到代码的可读性:

  • 首先,它将包装调用放在代码中实际发生 TLS 协商的地方之外的其他地方,这可能会对阅读您最终的connect()accept()调用的人隐藏,甚至会涉及到 TLS 协议。
  • 与前一个问题相关的是这样一个事实:如果协商不顺利,connect()accept()现在不仅会因为它们通常的套接字或 DNS 异常而失败,还会因为 TLS 错误而失败。任何包装这些调用的try...except子句现在将不得不担心两个完全不同的错误类别,因为两个完全不同的操作将隐藏在一个方法调用的背后。
  • 最后,您会发现您现在拥有了一个SSLSocket对象,事实上,它可能没有进行任何加密。只有当一个连接建立或者一个显式的do_handshake()被调用时(如果你关闭了自动协商),所谓的SSLSocket才会提供任何真正的加密!相比之下,本书程序清单中提供的模式只有在加密真正激活的时候才会转变为SSLSocket,从而在当前 socket 对象的类和底层连接的状态之间建立更清晰的语义链接。

我见过的唯一有趣的使用预包装的情况是当试图使用一个旧的、简单的、只支持明文通信的库时。通过提供一个预包装的套接字并将do_handshake_on_connect关键字参数设置为其默认值True,您可以在协议不知道的情况下为协议提供 TLS 保护。然而,这是一种特殊情况,最好(如果可能的话)通过使底层库支持 TLS 并能够接受 TLS 上下文作为参数来处理。

精选密码和完美前向安全性

如果您对数据安全性很挑剔,那么您可能会发现自己想要准确地指定 OpenSSL 可以使用的密码,而不是依赖于由create_default_context()函数提供的默认值。

随着加密领域的不断发展,无疑会出现我们做梦也想不到的问题、漏洞和对策。但这本书即将出版的一个重要问题是完美前向安全性(PFS)的问题,也就是说,未来有人获得(或破解)你的旧私钥,是否能够读取他们捕获并存档以备将来解密的旧 TLS 对话。今天最流行的密码是那些通过使用短暂的(临时)密钥来执行每个新套接字的加密来防止这种可能性的密码。希望保证 PFS 是想要手工指定上下文对象属性的最流行的原因之一。

请注意,尽管ssl模块的默认上下文不要求支持 PFS 的密码,但是如果您的客户端和服务器都运行的是最新版本的 OpenSSL,您可能会得到一个。例如,如果我在服务器模式下启动清单 6-3 中给出的safe_tls.py脚本,并使用您将在清单 6-4 中遇到的test_tls.py脚本连接到它,那么(给定我特定的笔记本电脑、操作系统和 OpenSSL 版本)我可以看到 Python 脚本已经优先考虑支持 PFS 的椭圆曲线 Diffie–Hellman exchange(ECDHE)密码,而无需我的询问。

$ python3.4 test_tls.py -a ca.crt localhost 1060
...
Cipher chosen for this connection... ECDHE-RSA-AES256-GCM-SHA384
Cipher defined in TLS version....... TLSv1/SSLv3
Cipher key has this many bits....... 256
Compression algorithm in use........ none

因此,Python 通常会做出好的选择,而不需要你做具体的说明。尽管如此,如果您想要保证特定的协议版本或算法投入使用,只需锁定您的特定选择的上下文。例如,在本书即将出版之际,一个好的服务器配置(对于不期望客户端提供 TLS 证书的服务器,因此可以选择CERT_NONE作为其验证模式)如下:

context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
context.verify_mode = ssl.CERT_NONE
context.options |= ssl.OP_CIPHER_SERVER_PREFERENCE  # choose *our* favorite cipher
context.options |= ssl.OP_NO_COMPRESSION            # avoid CRIME exploit
context.options |= ssl.OP_SINGLE_DH_USE             # for PFS
context.options |= ssl.OP_SINGLE_ECDH_USE           # for PFS
context.set_ciphers('ECDH+AES128 ')                 # choose over AES256, says Schneier

每当创建服务器套接字时,您都可以将这些代码行替换到类似于清单 6-3 的程序中。在这里,确切的 TLS 版本和密码被锁定为只有几个显式选项。任何试图连接但不支持这些选项的客户端都将失败,而不是成功建立连接。如果您将前面的代码添加到清单 6-3 中来代替默认的上下文,那么一个试图使用稍旧一点的 TLS 版本(如 1.1)或稍弱一点的密码(如 3DES)进行连接的客户端将会被拒绝。

$ python3.4 test_tls.py -p TLSv1_1 -a ca.crt localhost 1060
Address we want to talk to.......... ('localhost', 1060)
Traceback (most recent call last):
  ...
ssl.SSLError: [SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:598)

$ python3.4 test_tls.py -C 'ECDH+3DES' -a ca.crt localhost 1060
Address we want to talk to.......... ('localhost', 1060)
Traceback (most recent call last):
  ...
ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:598)

在每种情况下,服务器还会引发一个 Python 异常,从自己的角度诊断故障。因此,如果成功的话,最终的连接保证使用 TLS (1.2)的最新和最有能力的版本,并具有可用于保护您的数据的最佳密码之一。

ssl模块的默认上下文切换到像这样的手动设置的问题是,当你第一次编写应用时,你不仅必须进行研究以确定你的需求并选择 TLS 版本和密码,而且你——或你将来维护软件的继任者——必须继续保持最新,以防你的选择后来被发现容易受到新的利用。TLS 版本 1.2 结合椭圆曲线 Diffie-Hellman 看起来很棒,至少这本书即将出版。然而,有一天这个选择可能会显得过时甚至古怪。或者看起来很不自信。你能很快学会这一点并把你的软件项目中的手动选择更新到更好的吗?

除非有一天获得一个选项,让你坚持完美的远期安全,否则你会发现自己卡在这两个选项之间。要么您必须信任默认上下文,并接受与您通信的一些客户端(或服务器)可能不会受到 PFS 保护,要么您必须锁定密码的选择,然后跟上来自加密社区的新闻。

请注意,PFS 只有在您的机制定期丢弃服务器维护的会话状态或会话票证密钥时才是“完美的”。在最简单的情况下,每天晚上简单地重新启动您的服务器进程应该可以确保生成新的密钥,但是如果您有一整队服务器要部署,并且希望它们能够有效地支持 TLS 客户端池(利用会话重新启动),则需要做进一步的研究。(然而,在这种情况下——希望整个集群的会话重启键得到协调,而不损害 PFS——可能开始更有意义地考虑使用 Python 之外的工具来执行 TLS 终止!)

最后要考虑的是,如果您是编写或至少配置客户机和服务器的人,锁定密码的选择要容易得多,如果您在自己的机房内或在自己的服务器之间建立加密通信,就可能是这种情况。当由其他方管理的其他软件开始发挥作用时,一个不太灵活的密码集可能会使其他人更难与您的服务进行互操作,特别是如果他们的工具使用 TLS 的其他实现。如果您确实只锁定了几个选项,请尝试为编写和配置客户端的人员清楚、突出地记录这些选项,以便他们可以诊断为什么旧客户端可能会出现连接问题。

TLS 的协议支持

到目前为止,大多数广泛使用的互联网协议都增加了 TLS 支持。当使用 Python 标准库模块或第三方库中的这些协议时,要搜索的重要特性是如何配置 TLS 密码和选项,以防止对等方使用弱协议版本、弱密码或削弱协议的选项(如压缩)进行连接。这种配置可能采取特定于库的 API 调用的形式,或者可能只是允许您传递一个带有配置选择的SSLContext对象。

以下是 Python 标准库附带的 TLS 感知协议:

  • http.client:当你构建一个HTTPSConnection对象(见第九章)时,你可以使用构造函数的context关键字传入一个带有你自己设置的SSLContext。不幸的是,urllib.request和第九章中记录的第三方请求库目前都不接受SSLContext参数作为它们 API 的一部分。
  • smtplib:当你构建一个SMTP_SSL对象(见第十三章)时,你可以使用构造函数的context关键字传入一个带有你自己设置的SSLContext。相反,如果您创建一个普通的SMTP对象,然后调用它的starttls()方法,那么您就为该方法调用提供了context参数。
  • poplib:当你构建一个POP3_SSL对象(见第十四章)的时候,你可以使用构造函数的context关键字传入一个带有你自己设置的SSLContext。相反,如果您创建一个普通的POP3对象,然后调用它的stls()方法,那么您将为该方法调用提供context参数。
  • imaplib:当你构建一个IMAP4_SSL对象(见第十五章)时,你可以使用构造函数的ssl_context关键字传入一个带有你自己设置的SSLContext。相反,如果您创建一个普通的IMAP4对象,然后调用它的starttls()方法,那么您将为该方法调用提供ssl_context参数。
  • ftplib:当你构建一个FTP_TLS对象(见第十七章)时,你可以使用构造函数的context关键字传入一个带有你自己设置的SSLContext。请注意,在您有机会打开加密之前,FTP 对话的前一行或前两行总是明文通过(例如“220”欢迎消息,通常包含服务器主机名)。在login()方法发送用户名和密码之前,FTP_TLS对象会自动打开加密。如果您没有登录到远程服务器,但是无论如何都想打开加密,您将不得不手动调用auth()方法,作为您在连接后采取的第一个动作。
  • 虽然 NNTP 网络新闻(新闻组)协议不在本书的讨论范围之内,但是我应该注意到它也是可以被保护的。如果你构建了一个NNTP_SSL,你可以使用构造函数的ssl_context关键字来传递一个带有你自己设置的SSLContext。相反,如果您创建一个普通的NNTP对象,然后调用它的starttls()方法,那么您将为该方法调用提供context参数。

请注意,几乎所有这些协议都有一个共同的主题,那就是可以用两种不同的方式用 TLS 来扩展旧的纯文本协议。一种方法是,在使用协议的传统端口号进行老式的纯文本连接之后,在协议中添加一个新命令,允许在对话过程中升级到 TLS。另一种方法是让 Internet 标准专门为协议的 TLS 保护版本分配第二个明确定义的 TCP 端口号,在这种情况下,TLS 协商可以在连接时立即发生,而无需请求。前面提到的大多数协议都支持这两种选择,但是 HTTP 只选择了第二种,因为该协议在设计上是无状态的。

如果您连接到由另一个团队或组织配置的服务器,该服务器支持某个以前协议的 TLS 版本,那么您可能只需进行测试(在没有他们可能提供的任何文档的情况下),以确定他们是否打开了协议的新 TLS 端口,或者只支持基于旧纯文本协议的 TLS 升级。

如果您不是依赖标准库来进行网络通信,而是使用从本书或其他地方了解到的第三方包,那么您会希望查阅其文档来了解如何提供自己的SSLContext。如果没有提供任何机制——在我输入本文时,甚至流行的第三方库通常也没有为 Python 3.4 和更新版本的用户提供这种能力——那么您将不得不尝试该包提供的任何旋钮和设置,并测试结果(可能使用下一节介绍的清单 6-4 ,以查看第三方库是否保证足够强大的协议和密码来保护您的数据所需的隐私。

学习细节

为了帮助您了解更多关于 TLS 协议版本和您的客户端和服务器可以做出的密码选择,清单 6-4 提供了一个 Python 3.4 脚本,它创建一个加密连接,然后报告它的特性。为此,它使用了标准库ssl模块的SSLSocket对象的几个最新特性,这些特性现在允许 Python 脚本自省其 OpenSSL 驱动的连接的状态,以查看它们是如何配置的。

它用来进行报告的方法如下:

  • getpeercert() :这是SSLSocket的一个长期特性,在以前的几个 Python 版本中都有,这个方法返回从 TLS 会话所连接的对等体的 X.509 证书中挑选出来的字段的 Python 字典。但是最近的 Python 版本已经扩展了公开的证书特性的范围。
  • cipher() :返回 OpenSSL 和对等体的 TLS 实现最终商定的、当前在连接上使用的密码的名称。
  • compression( ) :返回正在使用的压缩算法的名称,否则返回 Python singleton None

为了使它的报告尽可能完整,清单 6-4 中的脚本还尝试使用ctypes来学习正在使用的 TLS 协议(理想情况下,到 Python 3.5 发布时,这将成为ssl模块的原生特性)。通过将这些部分组合在一起,清单 6-4 让您连接到您已经构建好的客户机或服务器,并了解它将协商或不协商什么样的密码和协议。

清单 6-4 。连接到任何 TLS 端点并报告协商的密码

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter06/test_tls.py
# Attempt a TLS connection and, if successful, report its properties

import argparse, socket, ssl, sys, textwrap
import ctypes
from pprint import pprint

def open_tls(context, address, server=False):
    raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    if server:
        raw_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        raw_sock.bind(address)
        raw_sock.listen(1)
        say('Interface where we are listening', address)
        raw_client_sock, address = raw_sock.accept()
        say('Client has connected from address', address)
        return context.wrap_socket(raw_client_sock, server_side=True)
    else:
        say('Address we want to talk to', address)
        raw_sock.connect(address)
        return context.wrap_socket(raw_sock)

def describe(ssl_sock, hostname, server=False, debug=False):
    cert = ssl_sock.getpeercert()
    if cert is None:
        say('Peer certificate', 'none')
    else:
        say('Peer certificate', 'provided')
        subject = cert.get('subject', [])
        names = [name for names in subject for (key, name) in names
                 if key == 'commonName']
        if 'subjectAltName' in cert:
            names.extend(name for (key, name) in cert['subjectAltName']
                         if key == 'DNS')

        say('Name(s) on peer certificate', *names or ['none'])
        if (not server) and names:
            try:
                ssl.match_hostname(cert, hostname)
            except ssl.CertificateError as e:
                message = str(e)
            else:
                message = 'Yes'
            say('Whether name(s) match the hostname', message)
        for category, count in sorted(context.cert_store_stats().items()):
            say('Certificates loaded of type {}'.format(category), count)

    try:
        protocol_version = SSL_get_version(ssl_sock)
    except Exception:
        if debug:
            raise
    else:
        say('Protocol version negotiated', protocol_version)

    cipher, version, bits = ssl_sock.cipher()
    compression = ssl_sock.compression()

    say('Cipher chosen for this connection', cipher)
    say('Cipher defined in TLS version', version)
    say('Cipher key has this many bits', bits)
    say('Compression algorithm in use', compression or 'none')

    return cert

class PySSLSocket(ctypes.Structure):
    """The first few fields of a PySSLSocket (see Python's Modules/_ssl.c)."""

    _fields_ = [('ob_refcnt', ctypes.c_ulong), ('ob_type', ctypes.c_void_p),
                ('Socket', ctypes.c_void_p), ('ssl', ctypes.c_void_p)]

def SSL_get_version(ssl_sock):
    """Reach behind the scenes for a socket's TLS protocol version."""

    lib = ctypes.CDLL(ssl._ssl.__file__)
    lib.SSL_get_version.restype = ctypes.c_char_p
    address = id(ssl_sock._sslobj)
    struct = ctypes.cast(address, ctypes.POINTER(PySSLSocket)).contents
    version_bytestring = lib.SSL_get_version(struct.ssl)
    return version_bytestring.decode('ascii')

def lookup(prefix, name):
    if not name.startswith(prefix):
        name = prefix + name
    try:
        return getattr(ssl, name)
    except AttributeError:
        matching_names = (s for s in dir(ssl) if s.startswith(prefix))
        message = 'Error: {!r} is not one of the available names:\n {}'.format(
            name, ' '.join(sorted(matching_names)))
        print(fill(message), file=sys.stderr)
        sys.exit(2)

def say(title, *words):
    print(fill(title.ljust(36, '.') + ' ' + ' '.join(str(w) for w in words)))

def fill(text):
    return textwrap.fill(text, subsequent_indent='    ',
                         break_long_words=False, break_on_hyphens=False)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Protect a socket with TLS')
    parser.add_argument('host', help='hostname or IP address')
    parser.add_argument('port', type=int, help='TCP port number')
    parser.add_argument('-a', metavar='cafile', default=None,
                        help='authority: path to CA certificate PEM file')
    parser.add_argument('-c', metavar='certfile', default=None,
                        help='path to PEM file with client certificate')
    parser.add_argument('-C', metavar='ciphers', default='ALL',
                        help='list of ciphers, formatted per OpenSSL')
    parser.add_argument('-p', metavar='PROTOCOL', default='SSLv23',
                        help='protocol version (default: "SSLv23")')
    parser.add_argument('-s', metavar='certfile', default=None,
                        help='run as server: path to certificate PEM file')
    parser.add_argument('-d', action='store_true', default=False,
                        help='debug mode: do not hide "ctypes" exceptions')
    parser.add_argument('-v', action='store_true', default=False,
                        help='verbose: print out remote certificate')
    args = parser.parse_args()

    address = (args.host, args.port)
    protocol = lookup('PROTOCOL_', args.p)

    context = ssl.SSLContext(protocol)
    context.set_ciphers(args.C)
    context.check_hostname = False
    if (args.s is not None) and (args.c is not None):
        parser.error('you cannot specify both -c and -s')
    elif args.s is not None:
        context.verify_mode = ssl.CERT_OPTIONAL
        purpose = ssl.Purpose.CLIENT_AUTH
        context.load_cert_chain(args.s)
    else:
        context.verify_mode = ssl.CERT_REQUIRED
        purpose = ssl.Purpose.SERVER_AUTH
        if args.c is not None:
            context.load_cert_chain(args.c)
    if args.a is None:
        context.load_default_certs(purpose)
    else:
        context.load_verify_locations(args.a)

    print()
    ssl_sock = open_tls(context, address, args.s)
    cert = describe(ssl_sock, args.host, args.s, args.d)
    print()
    if args.v:
        pprint(cert)

通过使用标准的–h帮助选项运行该工具,您可以很容易地了解该工具支持的命令行选项。它试图通过它的命令行选项来展示一个SSLContext的所有主要特性,这样你就可以试验它们并了解它们是如何影响谈判的。例如,您可以调查使用 Python 3.4 的create_default_context()的服务器的默认设置如何比使用它的客户端的设置更严格。在一个终端窗口中,作为服务器启动清单 6-3 中的脚本。我将再次假设您已经从本书的源代码库的chapter06目录中获得了证书文件ca.crtlocalhost.pem

$ /usr/bin/python3.4 safe_tls.py -s localhost.pem '' 1060

此服务器乐于接受使用最新协议版本和密码的连接;事实上,如果有机会,它会协商一个启用了完美前向安全性的强配置。简单地使用 Python 的默认值,看看如果使用清单 6-4 中的连接会发生什么,如下所示:

$ /usr/bin/python3.4 test_tls.py -a ca.crt localhost 1060

Address we want to talk to.......... ('localhost', 1060)
Peer certificate.................... provided
Name(s) on peer certificate......... localhost
Whether name(s) match the hostname.. Yes
Certificates loaded of type crl..... 0
Certificates loaded of type x509.... 1
Certificates loaded of type x509_ca. 0
Protocol version negotiated......... TLSv1.2
Cipher chosen for this connection... ECDHE-RSA-AES128-GCM-SHA256
Cipher defined in TLS version....... TLSv1/SSLv3
Cipher key has this many bits....... 128
Compression algorithm in use........ none

The combination ECDHE-RSA-AES128-GCM- SHA256 是 OpenSSL 目前提供的最好的之一!但是safe_tls.py服务器将拒绝与只支持 Windows XP 加密级别的客户端对话。再次启动safe_tls.py服务器进行另一次运行,这次使用以下选项进行连接:

$ /usr/bin/python3.4 test_tls.py -p SSLv3 -a ca.crt localhost 1060

Address we want to talk to.......... ('localhost', 1060)
Traceback (most recent call last):
  ...
ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:598)

旧的 SSLv3 协议被 Python 提供的谨慎的服务器设置断然拒绝。即使与现代协议结合使用,像 RC4 这样的旧的生命周期终结密码也将导致失败。

$ /usr/bin/python3.4 test_tls.py -C 'RC4' -a ca.crt localhost 1060

Address we want to talk to.......... ('localhost', 1060)
Traceback (most recent call last):
  ...
ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:598)

但是,如果您将“安全”脚本放在客户端角色中,它的行为会发生很大的变化,因为前面讨论过,实际上是服务器负责决定连接应该有多安全,而客户端作者通常只希望在不完全暴露数据的情况下工作。请记住,安全服务器在早期测试时会拒绝使用 RC4 语。看看当你尝试使用 RC4 的tls_safe.py 客户端时会发生什么。首先,关闭您已经运行的任何服务器,并作为服务器运行测试脚本,用-C设置密码。

$ /usr/bin/python3.4 test_tls.py -C 'RC4' -s localhost.pem '' 1060

Interface where we are listening.... ('', 1060)

然后转到另一个终端窗口,尝试使用使用 Python 3.4 默认上下文的safe_tls.py脚本进行连接。

$ /usr/bin/python3.4 safe_tls.py -a ca.crt localhost 1060

即使使用安全的默认上下文,连接也会成功!在服务器窗口,你会看到 RC4 确实被选为流密码。然而,通过为–C选项提供不同的字符串,您可以确认 RC4 低到安全脚本愿意弯腰的程度。像MD5这样的密码或算法将被彻底拒绝,因为对于试图确保与用户可能想要与之通信的任何服务器最大限度兼容的客户端来说,这甚至是不合理的。

查阅ssl模块文档,然后查阅官方 OpenSSL 文档,了解更多关于定制协议和密码选择的信息。如果您的系统包含本机 OpenSSL 命令行,那么这是一个非常有用的工具,它可以打印出与特定密码字符串匹配的所有密码——这个字符串可能是您用其–C选项提供给清单 6-3 的,或者在您自己的代码中用set_cipher()方法指定的。另外,随着加密技术的不断进步和系统上 OpenSSL 的升级,命令行将允许您测试各种密码规则如何随着时间的推移而改变它们的效果。现在,为了展示其用法的一个例子,下面是在我输入的 Ubuntu 笔记本电脑上使用时与ECDH+AES128密码字符串匹配的密码:

$ openssl ciphers -v 'ECDH+AES128'
ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH     Au=RSA  Enc=AESGCM(128) Mac=AEAD
ECDHE-ECDSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH     Au=ECDSA Enc=AESGCM(128) Mac=AEAD
ECDHE-RSA-AES128-SHA256 TLSv1.2 Kx=ECDH     Au=RSA  Enc=AES(128)  Mac=SHA256
ECDHE-ECDSA-AES128-SHA256 TLSv1.2 Kx=ECDH     Au=ECDSA Enc=AES(128)  Mac=SHA256
ECDHE-RSA-AES128-SHA    SSLv3 Kx=ECDH     Au=RSA  Enc=AES(128)  Mac=SHA1
ECDHE-ECDSA-AES128-SHA  SSLv3 Kx=ECDH     Au=ECDSA Enc=AES(128)  Mac=SHA1
AECDH-AES128-SHA        SSLv3 Kx=ECDH     Au=None Enc=AES(128)  Mac=SHA1
ECDH-RSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH/RSA Au=ECDH Enc=AESGCM(128) Mac=AEAD
ECDH-ECDSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH/ECDSA Au=ECDH Enc=AESGCM(128) Mac=AEAD
ECDH-RSA-AES128-SHA256  TLSv1.2 Kx=ECDH/RSA Au=ECDH Enc=AES(128)  Mac=SHA256
ECDH-ECDSA-AES128-SHA256 TLSv1.2 Kx=ECDH/ECDSA Au=ECDH Enc=AES(128)  Mac=SHA256
ECDH-RSA-AES128-SHA     SSLv3 Kx=ECDH/RSA Au=ECDH Enc=AES(128)  Mac=SHA1
ECDH-ECDSA-AES128-SHA   SSLv3 Kx=ECDH/ECDSA Au=ECDH Enc=AES(128)  Mac=SHA1

在设置set_cipher('ECDH+AES128')下, OpenSSL 库将认为这些组合中的任何一个都是公平游戏。同样,我的建议是尽可能使用默认上下文,或者测试您希望使用的特定客户端和服务器,尝试选择一两个它们都支持的强密码。但是如果你最终做了更多的试验和调试,那么我希望清单 6-4 将成为你试验和缩小 OpenSSL 行为的有用工具。如果有机会的话,一定要从顶部评论中的 URL 下载清单 6-4 的新版本,因为书中的版本会过时;我将努力保持在线更新,更新密码学和 Python ssl API 的最新进展。

摘要

本章讨论了一个很少有人真正精通的主题:使用加密技术来保护通过 TCP 套接字传输的数据,特别是使用 Python 中的 TLS 协议(曾被称为 SSL)。

在典型的 TLS 交换中,客户端要求服务器提供一个证书——一个声明身份的数字文档。客户机和服务器都信任的权威机构应该对它进行签名,它必须包含一个公钥,服务器需要用这个公钥来证明它确实拥有一个副本。客户端应该验证证书中声明的身份是否与它认为已经连接到的主机名相匹配。最后,客户机和服务器协商诸如密码、压缩和密钥等设置,然后使用这些设置来保护通过套接字双向传递的数据。

许多管理员甚至不想在他们的应用中支持 TLS。相反,他们将应用隐藏在 Apache、nginx 或 HAProxy 等工业级前端之后,这些前端可以代表他们执行 TLS。前面有内容交付网络的服务也必须卸载 TLS 责任,而不是将其嵌入到自己的应用中。

尽管网络搜索会显示可以在 Python 中执行 TLS 的第三方库,但该语言的内置功能来自标准库中 OpenSSL 驱动的ssl模块。假设ssl可用,并且在您的操作系统和 Python 版本上正常工作,那么就可以建立基本的加密通道,只需要一个服务器证书就可以运行。

为 Python 3.4 和更高版本编写的 Python 应用(如果您的应用要执行自己的 TLS,我强烈建议至少使用 3.4 版本)通常会遵循这样的模式:创建一个“上下文”对象,打开一个连接,然后调用上下文的wrap_socket()方法将连接交给 TLS 协议控制。尽管ssl模块确实提供了一两个在旧代码中使用的快捷函数,但是上下文连接包装模式是最通用和最灵活的。

许多 Python 客户端和服务器可以简单地接受由ssl.create_default_context()返回的默认“上下文”对象提供的设置,该对象试图使服务器在它们将接受的设置方面稍微严格一些,但使客户端稍微宽松一些,以便它们可以成功地连接到只有旧版本 TLS 可用的服务器。其他 Python 应用会希望实例化自己的SSLContext,以便根据自己的特定需求定制协议和密码。在任何情况下,本章中显示的测试脚本或另一个 TLS 工具都可以用来研究由设置导致的行为。

标准库支持许多协议,这些协议可以选择用 TLS 来保护,其中大部分将在本书后面的章节中探讨。如果你能提供一个对象,它们都支持一个SSLContext对象。目前,第三方库对上下文的支持很差,因为 Python 3.4 最近才发布,而且大多数 Python 程序员仍然在使用 Python 2。理想情况下,这两种情况都会随着时间的推移而改善。

一旦在应用中实现了 TLS,使用工具测试它总是值得的,这些工具将使用不同的参数集尝试各种类型的连接。在 Python 之外,第三方工具和网站都可以用来测试 TLS 客户端和服务器,如果您想在 OpenSSL 上使用不同的设置来查看它如何协商和行为,那么清单 6-4 中显示的工具可以在您自己的机器上与 Python 3.4 一起使用。

七、服务器架构

网络服务的作者面临两个挑战。第一个是核心挑战,即编写代码来正确响应传入的请求并精心制作适当的响应。第二个任务是在 Windows 服务或 Unix 守护进程中安装该网络代码,该守护进程在系统启动时自动启动,将其活动记录到持久性存储中,如果无法连接到其数据库或后端数据存储,则发出警报,或者完全保护自己免受所有可能的故障模式的影响,或者在出现故障时可以快速重启。

本书关注这两个挑战中的第一个。第二个挑战是保持一个进程在您选择的操作系统上运行,这不仅是一个可以用一整本书来专门讨论的主题,而且是一个将使本书远离其网络编程中心主题的主题。因此,本章将只花一节介绍部署的主题,然后再讨论如何将网络服务器制作成软件的真正主题。

我们对网络服务器的处理将自然地分成三个主题。我将首先介绍一个简单的单线程服务器,类似于 UDP 服务器(在第二章中介绍)和 TCP 服务器(在第三章中介绍),并重点介绍它的局限性:它一次只能服务一个客户端,使任何其他客户端等待,甚至当与该客户端通信时,它可能会使系统 CPU 几乎完全空闲。一旦您理解了这一挑战,您将继续研究两种相互竞争的解决方案:要么在多个线程或进程中复制单线程服务器,要么将多路复用的任务从操作系统中分离出来,通过使用异步网络操作在您自己的代码中完成。

在研究线程和异步网络代码时,您将首先从头开始实现每个模式,然后您将看到代表您实现每个模式的框架。我举例说明的所有框架都将来自 Python 标准库,但本文也将指出标准库的主要第三方竞争对手。

本章中的大多数脚本也可以在 Python 2 下运行,但是引入的最先进的框架——新的asyncio模块——是专门针对 Python 3 的,这是标准化方面的一大进步,只有准备升级的程序员才能享受到。

关于部署的几句话

您可以将网络服务部署到一台或多台机器上。客户只需连接到它的 IP 地址,就可以使用一台机器上的服务。在几台机器上运行的服务需要更复杂的方法。您可以为每个客户端提供服务的单个实例的地址或主机名,例如,与特定客户端在同一机房运行的实例,但是您不会获得冗余。如果该服务实例关闭,那么硬连线到其主机名或 IP 地址的客户端将无法连接。

更可靠的方法是让 DNS 服务器返回服务名称被访问时服务所在的每个 IP 地址,并在第一个 IP 地址失败的情况下,将客户端写回第二个或第三个 IP 地址。当今行业中可伸缩性最好的方法是将您的服务置于客户端直接连接的负载平衡器 之后,然后将每个传入连接转发给位于其后的实际服务器。如果一台服务器出现故障,负载均衡器就会停止转发请求,直到它恢复运行,这使得大型客户端几乎看不到服务器故障。最大的互联网服务结合了这些方法:每个机房中的负载平衡器和服务器群具有公共 DNS 名称,该名称返回负载平衡器的 IP 地址,该负载平衡器的机房在地理上似乎离你最近。

无论您的服务架构多么简单或宏伟,您都需要某种方式在物理或虚拟机上运行您的 Python 服务器代码,这个过程称为部署。关于部署有两个学派。老式的技术是为你编写的每一个服务器程序配备一个服务的所有特性:双分叉成为一个 Unix 守护进程(或者将自己注册为一个 Windows 服务),安排系统级日志,支持一个配置文件,并提供一个可以启动、关闭和重启它的机制。您可以通过使用已经解决了这些问题的第三方库或者在您自己的代码中从头再做一遍来做到这一点。

类似、十二要素应用 这样的宣言推广了一种竞争方式。他们提倡一种极简主义的方法,在这种方法中,每个服务都被编写成一个在前台运行的普通程序,并不努力成为一个守护进程。这样的程序从它的环境(Python 中的sys.environ字典)中获取任何它需要的配置选项,而不是期望一个系统范围的配置文件。它连接到环境命名的任何后端服务。它将日志信息直接打印到屏幕上——甚至通过 Python 自己的print()函数这样一种简单的机制。通过打开并监听环境配置指定的任何端口来接受网络请求。

以这种极简风格编写的服务对于开发人员来说很容易在 shell 提示符下运行以进行测试。然而,只需在应用周围搭建合适的支架,就可以将它变成守护程序或系统服务,或者部署到 web 级的服务器群。例如,脚手架可以从中央配置服务中提取环境变量设置,将应用的标准输出和标准错误连接到远程日志服务器,并在服务失败或似乎冻结时重新启动服务。因为程序本身并不知道这一点,只是像往常一样打印到标准输出,所以程序员有信心,服务代码在生产中的运行与在开发中的运行完全一样。

现在有大型平台即服务提供商将为您托管此类应用,在一个面向公众的域名和 TCP 负载平衡器的背后构建数十份甚至数百份应用副本,然后聚合所有生成的日志进行分析。有些提供商允许您直接提交 Python 应用代码。其他人更喜欢将您的代码、Python 解释器和您需要的任何依赖项打包在一个容器中(尤其是“Docker”容器正在成为一种流行的机制),该容器可以在您自己的笔记本电脑上进行测试,然后进行部署,从而确保您的 Python 代码将从与您在测试中使用的映像完全相同的映像中运行。无论哪种方式,您都不必编写一个本身会产生多个流程的服务;您的服务的所有冗余/重复都由平台处理。

在 Python 社区中,让程序员摆脱编写独立服务的工作已经存在很长时间了。流行的supervisord工具就是一个很好的例子。它可以运行程序的一个或多个副本,将标准输出和错误转移到日志文件中,在失败时重新启动进程,甚至在服务开始频繁失败时发送警报。

尽管有这些诱惑,如果您决定编写一个知道如何将自己变成守护进程的进程,您应该在 Python 社区中找到这样做的好模式。PEP 3143(可从http://python.org获得)是一个很好的起点,它的“其他守护进程实现”一节是一个关于所需步骤的资源列表。supervisord源代码可能也是感兴趣的,还有 Python 的标准库模块logging的文档。

无论您拥有独立的 Python 进程还是基于平台的 web 级服务,如何最有效地使用操作系统网络堆栈和操作系统进程来满足网络请求的问题都是一样的。在本章的其余部分,您将把注意力转向这个问题,目标是使系统尽可能忙碌,以便客户机在网络请求得到响应之前尽可能少地等待。

简单的协议

为了让您的注意力集中在服务器设计提供的各种选项上,本章中的示例采用了一种最简单的 TCP 协议,其中客户端从三个纯文本 ASCII 问题中选择一个进行提问,然后等待服务器完成回答。与 HTTP 一样,客户端可以在套接字保持打开的情况下问尽可能多的问题,然后在没有任何警告的情况下关闭连接。每个问题的结尾用 ASCII 问号字符分隔。

Beautiful is better than?

然后,以句点分隔的答案被发送回来。

Ugly.

三个问答对中的每一个都基于 Python 之禅的格言之一,这是一首关于 Python 语言内部一致性设计的诗。运行 Python,在你需要灵感并想重读这首诗的任何时候输入import this

为了围绕这个协议构建一个客户机和几个服务器,在清单 7-1 中定义了一些例程,你会注意到它没有自己的命令行界面。该模块的存在只是为了被后续清单作为支持模块导入,这样它们就可以重用它的模式,而不必重复它们。

清单 7-1 。支持 Toy Zen-of-Python 协议的数据和例程

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/zen_utils.py
# Constants and routines for supporting a certain network conversation.

import argparse, socket, time

aphorisms = {b'Beautiful is better than?': b'Ugly.',
             b'Explicit is better than?': b'Implicit.',
             b'Simple is better than?': b'Complex.'}

def get_answer(aphorism):
    """Return the string response to a particular Zen-of-Python aphorism."""
    time.sleep(0.0)  # increase to simulate an expensive operation
    return aphorisms.get(aphorism, b'Error: unknown aphorism.')

def parse_command_line(description):
    """Parse command line and return a socket address."""
    parser = argparse.ArgumentParser(description=description)
    parser.add_argument('host', help='IP or hostname')
    parser.add_argument('-p', metavar='port', type=int, default=1060,
                        help='TCP port (default 1060)')
    args = parser.parse_args()
    address = (args.host, args.p)
    return address

def create_srv_socket(address):
    """Build and return a listening server socket."""
    listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listener.bind(address)
    listener.listen(64)
    print('Listening at {}'.format(address))
    return listener

def accept_connections_forever(listener):
    """Forever answer incoming connections on a listening socket."""
    while True:
        sock, address = listener.accept()
        print('Accepted connection from {}'.format(address))
        handle_conversation(sock, address)

def handle_conversation(sock, address):
    """Converse with a client over `sock` until they are done talking."""
    try:
        while True:
            handle_request(sock)
    except EOFError:
        print('Client socket to {} has closed'.format(address))
    except Exception as e:
        print('Client {} error: {}'.format(address, e))
    finally:
        sock.close()

def handle_request(sock):
    """Receive a single client request on `sock` and send the answer."""
    aphorism = recv_until(sock, b'?')
    answer = get_answer(aphorism)
    sock.sendall(answer)

def recv_until(sock, suffix):
    """Receive bytes over socket `sock` until we receive the `suffix`."""
    message = sock.recv(4096)
    if not message:
        raise EOFError('socket closed')
    while not message.endswith(suffix):
        data = sock.recv(4096)
        if not data:
            raise IOError('received {!r} then socket closed'.format(message))
        message += data
    return message

客户机期望服务器理解的三个问题在aphorisms字典中被列为关键字,它们的答案被存储为值。get_answer()函数是一种快捷的方式,可以在字典中安全地查找答案,如果警句未被识别,会返回一条简短的错误消息。请注意,客户端请求总是以问号结尾,而答案——即使是回退错误消息——也总是以句点结尾。这两个标点符号为这个微小的协议提供了框架。

接下来的两个函数提供了一些将在服务器之间共享的公共启动代码。parse_command_line()函数提供了一个读取命令行参数的通用方案,而create_srv_socket()可以构建一个监听 TCP 套接字,服务器需要这个套接字来接收传入的连接。

但是在最后四个例程中,清单开始展示服务器进程的核心模式。这一连串的四个功能只是重复了你已经在第三章和第五章中学到的手势,第三章是关于为监听套接字创建一个 TCP 服务器,第五章是关于构造数据和处理错误。

  • accept_connections_forever() 是一个简单的listen()循环,它在将套接字传递给下一个函数进行操作之前,用print()通知每个连接客户端。
  • handle_conversation() 是一个错误捕捉例程,它封装了无限数量的请求-响应循环,使得客户端套接字的任何问题都不可能导致程序崩溃。异常EOFError被捕获在它自己特定的except子句中,因为它是最里面的数据接收循环如何发出客户端已经完成请求并最终挂断的信号——在这个特定的协议(如 HTTP)中,这是正常的,而不是真正的异常事件。但是所有其他异常都被视为错误,并在被捕获后用print()报告。(回想一下,所有正常的 Python 错误都继承自Exception,因此将被这个except子句拦截!)子句确保客户端套接字总是关闭的,不管该函数退出的代码路径是什么。像这样运行close()总是安全的,因为 Python 中已经关闭的文件和套接字对象允许close()被再次调用,以达到程序想要的次数。
  • handle_request() 与客户端来回执行一次,读取其问题,然后回复一个答案。注意小心使用send_all(),因为send()调用本身不能保证整个有效载荷的交付。
  • recv_until() 使用第五章中概述的练习来执行框架。对套接字的recv()进行重复调用,直到累积的字节串最终成为一个完整的问题。

这些例程是构建几个服务器的工具箱。

为了练习本章中的各种服务器,您需要一个客户端程序。清单 7-2 中提供了一个简单的命令行工具。

清单 7-2 。客户端程序,例如 Zen-of-Python 协议

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/client.py
# Simple Zen-of-Python client that asks three questions then disconnects.

import argparse, random, socket, zen_utils

def client(address, cause_error=False):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(address)
    aphorisms = list(zen_utils.aphorisms)
    if cause_error:
        sock.sendall(aphorisms[0][:-1])
        return
    for aphorism in random.sample(aphorisms, 3):
        sock.sendall(aphorism)
        print(aphorism, zen_utils.recv_until(sock, b'.'))
    sock.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Example client')
    parser.add_argument('host', help='IP or hostname')
    parser.add_argument('-e', action='store_true', help='cause an error')
    parser.add_argument('-p', metavar='port', type=int, default=1060,
                        help='TCP port (default 1060)')
    args = parser.parse_args()
    address = (args.host, args.p)
    client(address, args.e)

在正常情况下,当cause_errorFalse时,这个客户端创建一个 TCP 套接字并传输三个格言,在每个格言之后等待服务器回复一个答案。但是如果你想知道本章中的任何一个服务器在出错时会做什么,这个客户端的-e选项会让它发送一个不完整的问题,然后在服务器上突然挂断。否则,如果服务器启动并正常运行,您应该会看到三个问题及其答案。

$ python client.py 127.0.0.1
b'Beautiful is better than?' b'Ugly.'
b'Simple is better than?' b'Complex.'
b'Explicit is better than?' b'Implicit.'

与本书中的许多其他示例一样,本章中的客户端和服务器使用端口 1060,但是接受一个-p选项,如果您的系统上没有该端口,该选项可以指定一个替代端口。

单线程服务器

清单 7-1 的模块中提供的丰富的工具集将编写一个简单的单线程服务器的任务——最简单的可能设计,你已经在第三章看到了——减少到只有清单 7-3 的的三行函数。

清单 7-3 。最简单的服务器可能是单线程的

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_single.py
# Single-threaded server that serves one client at a time; others must wait.

import zen_utils

if __name__ == '__main__':
    address = zen_utils.parse_command_line('simple single-threaded server')
    listener = zen_utils.create_srv_socket(address)
    zen_utils.accept_connections_forever(listener)

和你在第二章和第三章中写的服务器程序一样,这个服务器需要一个命令行参数:服务器应该监听传入连接的接口。为了保护服务器免受局域网或网络中其他人的攻击,请指定标准本地主机 IP 地址。

$ python srv_single.py 127.0.0.1
Listening at ('127.0.0.1', 1060)

或者更大胆一点,通过指定空字符串在机器的所有接口上提供服务,Python 将空字符串解释为当前机器上的每个接口。

$ python srv_single.py ''
Listening at ('', 1060)

无论哪种方式,服务器都会打印一行来宣布它成功地打开了它的服务器端口,然后等待传入的连接。服务器还支持一个-h帮助选项和一个-p 选项来选择 1060 以外的端口,如果你想玩这些的话。一旦它启动并运行,尝试执行上一节中记录的客户机脚本来查看服务器的运行情况。当您的客户端连接和断开连接时,您会看到服务器在其运行的终端窗口中报告客户端活动。

Accepted connection from ('127.0.0.1', 40765)
Client socket to ('127.0.0.1', 1060) has closed
Accepted connection from ('127.0.0.1', 40768)
Client socket to ('127.0.0.1', 1060) has closed

如果您的网络服务一次只有一个客户端建立一个连接,那么这种设计就是您所需要的。一旦前一个连接关闭,该服务器就为下一个连接做好准备。只要连接存在,这个服务器要么在一个recv()调用中被阻塞,等待操作系统在更多数据到达时唤醒它,要么它尽可能快地收集一个答案并立即传输它。send()sendall()可以阻塞的唯一情况是当客户端还没有准备好接收数据时,在这种情况下,一旦客户端准备好,数据将被发送——并且服务器将被解除阻塞以返回到它的recv()。因此,在所有情况下,响应都是以计算和接收响应的速度提供给客户端的。

当服务器仍在与第一个客户端对话时,第二个客户端试图连接时,这种单线程设计的弱点就很明显了。如果listen()的整数参数大于零,那么操作系统将至少愿意通过三次 TCP 握手来确认第二个进入的客户端以建立连接,这在服务器最终准备好对话时节省了一点时间。但是,该连接将一直处于操作系统的监听队列中,直到服务器与第一个客户机的对话完成。只有当第一个客户机对话完成并且服务器代码返回到它对accept()的下一个调用时,第二个客户机的连接才可用于服务器,并且它通过该套接字的第一个请求才能够得到响应。

对这个单线程服务器执行拒绝服务攻击很简单:连接并且永远不要关闭连接。服务器将在recv()中保持永久阻塞,等待您的数据。如果服务器作者变得聪明,试图用sock.settimeout()设置超时以避免永远等待,那么调整您的拒绝服务工具,使它发送请求的频率足够高,以至于永远不会超时。任何其他客户端都不能使用该服务器。

最后,单线程设计没有充分利用服务器 CPU 和系统资源,因为它不能在等待客户端发送下一个请求时执行其他操作。您可以通过在标准库中的trace模块的控制下运行单线程服务器的每一行来计时它需要多长时间。为了将输出限制在服务器代码本身,告诉跟踪器忽略标准库模块(在我的系统上,Python 3.4 安装在/usr目录下)。

$ python3.4 -m trace -tg --ignore-dir=/usr srv_single.py ''

每一行输出都给出了一行 Python 代码开始执行的时刻,从服务器启动时开始计算,以秒为单位。您将看到,大多数行在前一行结束时就开始执行,或者在同一百分之一秒内执行,或者在下一百分之一秒内执行。但是每次服务器需要在客户端等待的时候,执行就会停止,不得不等待。下面是一个运行示例:

3.02 zen_utils.py(40):         print('Accepted connection...'...)
3.02 zen_utils.py(41):         handle_conversation(sock, address)

3.02 zen_utils.py(57):     aphorism = recv_until(sock, b'?')
3.03 zen_utils.py(63):     message = sock.recv(4096)
3.03 zen_utils.py(64):     if not message:
3.03 zen_utils.py(66):     while not message.endswith(suffix):

3.03 zen_utils.py(57):     aphorism = recv_until(sock, b'?')
3.03 zen_utils.py(63):     message = sock.recv(4096)
3.08 zen_utils.py(64):     if not message:
3.08 zen_utils.py(66):     while not message.endswith(suffix):

3.08 zen_utils.py(57):     aphorism = recv_until(sock, b'?')
3.08 zen_utils.py(63):     message = sock.recv(4096)
3.12 zen_utils.py(64):     if not message:
3.12 zen_utils.py(66):     while not message.endswith(suffix):

3.12 zen_utils.py(57):     aphorism = recv_until(sock, b'?')
3.12 zen_utils.py(63):     message = sock.recv(4096)
3.16 zen_utils.py(64):     if not message:
3.16 zen_utils.py(65):         raise EOFError('socket closed')

3.16 zen_utils.py(48):     except EOFError:
3.16 zen_utils.py(49):         print('Client socket...has closed'...)
3.16 zen_utils.py(53):         sock.close()
3.16 zen_utils.py(39):         sock, address = listener.accept()

这是与client.py程序的整个对话——三个请求和响应。在该跟踪的第一行和最后一行之间总共 0.14 秒的处理时间内,它必须在客户机上等待三次,总共大约 0.05 + 0.04 + 0.04 = 0.13 秒的空闲时间!这意味着在这个交换过程中,CPU 的占用率只有 0.01/0.14 = 7%。当然,这只是一个粗略的数字。我们在trace下运行的事实降低了服务器的速度,增加了它的 CPU 使用率,这些数字的分辨率首先是近似的。但是如果你使用更复杂的工具,你会发现这个结果得到了证实。除非单线程服务器在每个请求期间都要做大量的 CPU 内工作,否则单线程服务器在最大限度地利用服务器方面表现不佳。当其他客户端排队等待服务时,CPU 处于空闲状态。

有两个有趣的技术细节值得评论。一个是第一个recv()立即返回的事实——只有第二个和第三个recv()调用在返回数据之前显示延迟,最后一个recv()在得知套接字已经关闭之前也是如此。这是因为操作系统的网络堆栈很聪明地将第一个请求的文本包含在建立 TCP 连接的三次握手中。因此,当连接正式存在并且accept()可以返回时,已经有数据等待从recv()立即返回!

另一个细节是send()不会导致延迟。这是因为它在 POSIX 系统上的语义是,一旦输出数据被登记到操作系统网络堆栈的输出缓冲区中,它就返回。不能保证系统真的发送了任何数据,因为send()已经返回!只有转过身来监听更多的客户端数据,程序才能迫使操作系统阻塞它的进程,等待看到发送的结果。

让我们回到正题。如何克服单线程服务器的这些局限性?本章的其余部分将探讨两种防止单个客户端独占服务器的竞争技术。这两种技术都允许服务器同时与几个客户机对话。首先,我将介绍线程的使用(进程也可以),让操作系统在不同的客户机之间切换服务器的注意力。然后我将转向异步服务器设计,在这里我将展示如何自己处理注意力的切换,以便在一个控制线程中同时与几个客户机进行对话。

线程和多处理服务器

如果您希望您的服务器同时与几个客户端进行对话,一个流行的解决方案是利用操作系统的内置支持,通过创建共享相同内存占用的线程或创建彼此独立运行的进程,允许几个控制线程独立处理同一段代码。

这种方法的优势在于它的简单性:使用运行单线程服务器的相同代码,并启动它的几个副本。

它的缺点是您可以与之对话的客户端数量受到您的操作系统并发机制的限制。即使一个空闲或缓慢的客户端也会占用整个线程或进程的注意力,即使在recv()中被阻塞,也会占用系统 RAM 和进程表中的一个槽。操作系统很少能很好地适应成千上万或更多同时运行的线程,当系统的注意力从一个客户端转移到下一个客户端时,所需的上下文切换会随着服务变得繁忙而使服务陷入困境。

您可能认为多线程或多进程服务器需要由一个主控制线程组成,该线程运行一个紧密的accept()循环,然后将新的客户端套接字交给某种等待队列的工作线程。令人高兴的是,操作系统让事情变得简单多了:完全允许每个线程拥有一个监听服务器套接字的副本,并运行自己的accept()语句。操作系统会将每个新的客户端连接交给等待其accept()完成的线程,或者如果所有线程当前都忙,则让连接排队,直到其中一个线程准备好。清单 7-4 显示了一个例子。

清单 7-4 。多线程服务器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_threaded.py
# Using multiple threads to serve several clients in parallel.

import zen_utils
from threading import Thread

def start_threads(listener, workers=4):
    t = (listener,)
    for i in range(workers):
        Thread(target=zen_utils.accept_connections_forever, args=t).start()

if __name__ == '__main__':
    address = zen_utils.parse_command_line('multi-threaded server')
    listener = zen_utils.create_srv_socket(address)
    start_threads(listener)

请注意,这只是多线程程序的一种可能的设计:主线程启动 n 个服务器线程,然后退出,确信那些 n 个线程将永远运行,从而保持进程存活。其他选择也是可能的。例如,主线程可以保持活动状态,并成为服务器线程本身。或者它可以充当一个监视器,定期检查以确保 n 个服务器线程仍然运行,并在其中任何一个线程死亡时重新启动替换线程。从threading.Threadmultiprocessing.Process的切换将为每个控制线程提供其自己独立的内存映像和文件描述符空间,从操作系统的角度来看,这增加了开销,但更好地隔离了线程,并使它们更难使主监视器线程崩溃。

然而,所有这些模式,你可以在threadingmultiprocessing模块的文档中,以及 Python 并发性的书籍和指南中了解到,都有一个共同的基本特征:将一个有点昂贵的操作系统可见的控制线程分配给每一个连接的客户端,无论该客户端此刻是否忙于发出请求。但是,由于您的服务器代码可以在处于多个线程控制下时保持不变(假设每个线程都建立自己的数据库连接并打开文件,因此线程之间不需要资源协调),因此在您的服务器工作负载上尝试多线程方法就足够简单了。如果它被证明能够处理您的请求负载,那么它的简单性使它成为一种特别有吸引力的技术,用于不被公众接触的内部服务,在这种情况下,对手不能简单地打开空闲连接,直到您耗尽您的线程或进程池。

传统 SocketServer 框架

在上一节使用操作系统可见的控制线程同时处理多个客户端对话中建立的模式非常流行,以至于有一个框架实现了 Python 标准库中内置的模式。虽然现在它已经显示出它的年龄,90 年代的设计充满了面向对象和多种继承的混合,但它值得一个快速的例子来展示多线程模式如何被推广,并使您熟悉该模块,以防您需要维护使用它的旧代码。

socketserver模块(在 Python 2 时代被称为SocketServer )从处理程序模式中分解出服务器模式,该模式知道如何打开监听套接字并接受新的客户端连接,该模式知道如何通过打开的套接字进行对话。这两种模式通过实例化一个服务器对象来组合,这个服务器对象被赋予一个 handler 类作为它的参数之一,正如你在清单 7-5 中看到的。

清单 7-5 。构建在标准库服务器模式之上的线程服务器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_legacy1.py
# Uses the legacy "socketserver" Standard Library module to write a server.

from socketserver import BaseRequestHandler, TCPServer, ThreadingMixIn
import zen_utils

class ZenHandler(BaseRequestHandler):
    def handle(self):
        zen_utils.handle_conversation(self.request, self.client_address)

class ZenServer(ThreadingMixIn, TCPServer):
    allow_reuse_address = 1
    # address_family = socket.AF_INET6  # uncomment if you need IPv6

if __name__ == '__main__':
    address = zen_utils.parse_command_line('legacy "SocketServer" server')
    server = ZenServer(address, ZenHandler)
    server.serve_forever()

通过用ForkingMixIn代替ThreadingMixIn,程序员可以用完全隔离的进程代替线程来服务进入的客户端。

通过与早期的清单 7-4 进行比较,这种方法的巨大缺点应该是显而易见的,早期的清单 7-4 启动了固定数量的线程,服务器管理员可以根据给定的服务器和操作系统在不显著降低性能的情况下可以轻松管理的控制线程的数量来选择这些线程。相比之下,清单 7-5 让连接的客户端池决定启动多少线程——对最终在服务器上运行的线程数量没有限制!这使得攻击者很容易使服务器瘫痪。因此,不建议将此标准库模块用于生产和面向客户的服务。

异步服务器

在向客户端发送应答和接收下一个请求之间的延迟期间,如何让 CPU 保持忙碌,而不产生操作系统开销——每个客户端的可见控制线程?答案是,你可以使用一种异步模式来编写你的服务器,在这种模式下,代码不是阻塞和等待数据到达或离开一个特定的客户端,而是愿意从一个完整的等待客户端套接字列表中进行监听,并在其中一个客户端准备好进行更多交互时做出响应。

现代操作系统网络堆栈的两个特性使得这种模式成为可能。首先,它们提供了一个系统调用,让一个进程块等待一个完整的客户端套接字列表,而不是只等待一个客户端套接字,这允许一个线程同时服务数百或数千个客户端套接字。第二个特性是套接字可以被配置为非阻塞的,它承诺永远不会在send()recv()调用中阻塞调用线程,但无论对话是否有进一步的进展,它总是会立即从send()recv()系统调用中返回。如果进度被延迟,那么当客户端看起来准备好进行进一步的交互时,由调用者决定是否再试一次。

名称 asynchronous 意味着客户端代码永远不会停止等待特定的客户端,并且运行代码的控制线程不会与任何一个特定客户端的会话同步,或者以锁步方式等待。相反,它在所有连接的客户端之间自由切换,以完成服务工作。

操作系统通过几个调用来支持异步模式。最古老的是 POSIX 调用select(),但是它有几个低效之处,这启发了现代的替代品,如 Linux 上的poll()和 BSD 上的epoll()。W. Richard Stevens 的《UNIX 网络编程》一书(Prentice Hall,2003)是这方面的标准参考资料。这里我将关注poll()并跳过其他的,因为本章的目的并不是让你实现自己的异步控制循环。相反,你只是拿一个poll()驱动的循环作为例子,这样你就可以理解在一个完整的异步框架下会发生什么,这也是你真正想要在你的程序中实现异步的方式。几个框架将在下面的章节中阐述。

清单 7-6 显示了简单 Zen 协议的原始异步服务器的完整内部结构。

清单 7-6 。原始异步事件循环

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_async.py
# Asynchronous I/O driven directly by the poll() system call.

import select, zen_utils

def all_events_forever(poll_object):
    while True:
        for fd, event in poll_object.poll():
            yield fd, event

def serve(listener):
    sockets = {listener.fileno(): listener}
    addresses = {}
    bytes_received = {}
    bytes_to_send = {}

    poll_object = select.poll()
    poll_object.register(listener, select.POLLIN)

    for fd, event in all_events_forever(poll_object):
        sock = sockets[fd]

        # Socket closed: remove it from our data structures.

        if event & (select.POLLHUP | select.POLLERR | select.POLLNVAL):
            address = addresses.pop(sock)
            rb = bytes_received.pop(sock, b'')
            sb = bytes_to_send.pop(sock, b'')
            if rb:
                print('Client {} sent {} but then closed'.format(address, rb))
            elif sb:
                print('Client {} closed before we sent {}'.format(address, sb))
            else:
                print('Client {} closed socket normally'.format(address))
            poll_object.unregister(fd)
            del sockets[fd]

        # New socket: add it to our data structures.

        elif sock is listener:
            sock, address = sock.accept()
            print('Accepted connection from {}'.format(address))
            sock.setblocking(False) # force socket.timeout if we blunder
            sockets[sock.fileno()] = sock
            addresses[sock] = address
            poll_object.register(sock, select.POLLIN)

        # Incoming data: keep receiving until we see the suffix.

        elif event & select.POLLIN:
            more_data = sock.recv(4096)
            if not more_data:  # end-of-file
                sock.close()  # next poll() will POLLNVAL, and thus clean up
                continue
            data = bytes_received.pop(sock, b'') + more_data
            if data.endswith(b'?'):
                bytes_to_send[sock] = zen_utils.get_answer(data)
                poll_object.modify(sock, select.POLLOUT)
            else:
                bytes_received[sock] = data

        # Socket ready to send: keep sending until all bytes are delivered.

        elif event & select.POLLOUT:
            data = bytes_to_send.pop(sock)
            n = sock.send(data)
            if n < len(data):
                bytes_to_send[sock] = data[n:]
            else:
                poll_object.modify(sock, select.POLLIN)

if __name__ == '__main__':
    address = zen_utils.parse_command_line('low-level async server')
    listener = zen_utils.create_srv_socket(address)
    serve(listener)

这个事件循环的本质是,它负责在自己的数据结构中维护每个客户端对话的状态,而不是依赖操作系统在活动从一个客户端转移到另一个客户端时切换上下文。服务器实际上有两个深度循环:一个是反复调用poll()while循环,另一个是处理poll()返回的每个事件的内部循环,因为每个调用可以返回许多事件。您将这两级迭代隐藏在生成器中,以防止主服务器循环被不必要地隐藏两级缩进。

维护了一个sockets 的字典,这样当poll()告诉你文件描述符 n 准备好进行更多的活动时,你可以找到相应的 Python 套接字。您还记得您的套接字的addresses,以便您可以用正确的远程地址打印诊断消息,即使在套接字已经关闭并且操作系统不再提醒您它所连接的端点之后。

但是异步服务器的真正核心是它的缓冲区:在等待请求完成时,您可以将传入的数据填入其中的bytes_received字典,以及在操作系统可以调度它们进行传输之前,传出的字节等待的bytes_to_send字典。连同你告诉poll()你在每个套接字上等待的事件,这些数据结构形成了一个完整的状态机,用于一次一小步地处理客户端对话。

  1. 准备好连接的客户机首先表现为侦听服务器套接字上的活动,您让它永久处于POLLIN(“轮询输入”)状态。您通过运行accept()来响应这样的活动,将套接字及其地址保存在您的字典中,并告诉 poll 对象您已经准备好从新的客户端套接字接收数据。
  2. 当客户端套接字本身呈现给你一个POLLIN事件时,你recv()了 4KB 的数据。如果请求还没有用一个结束问号框起来,那么您将数据保存到bytes_received字典中,并继续返回到循环的顶部,进一步执行poll()。否则,你就有了一个完整的问题,你可以根据客户的要求,通过查找相应的回复并将其放入你的bytes_to_send字典中。这涉及到一个关键点:将套接字从POLLIN模式切换到POLLOUT模式,在POLLIN模式下,您希望知道更多数据何时到达,在POLLOUT模式下,您希望在传出缓冲区空闲时得到通知,因为您现在使用套接字发送而不是接收。
  3. 现在,每当客户端套接字上的传出缓冲区可以接受至少一个字节时,poll()调用就会立即用POLLOUT通知您,您可以通过尝试对剩余的要传输的所有内容进行send()并只保留send()无法挤入传出缓冲区的字节来做出响应。
  4. 最后,一个POLLOUT到达,它的send()让您完成所有剩余外向数据的传输。此时,一个请求-响应循环就完成了,您将套接字转回POLLIN模式以处理另一个请求。
  5. 当客户端套接字最终给你一个错误或关闭状态时,你要处理它和任何输出或输入的缓冲区。在你可能同时进行的所有对话中,至少有一个现在已经完成了。

异步方法的关键在于,这种单一的控制线程可以处理数百个,甚至数千个客户端对话。当每个客户端套接字为下一个事件做好准备时,代码会进入该套接字的下一个操作,接收或发送它能够接收或发送的数据,然后立即返回到poll()以观察更多的活动。不需要单个操作系统上下文切换(除了为poll()recv()send()close()系统调用进入操作系统本身所涉及的特权模式升级和降级之外),这种单个控制线程可以通过将所有客户端对话状态保存在一组字典中(由客户端套接字索引)来处理大量客户端。本质上,您用 Python 字典支持的键查找代替了成熟的操作系统上下文切换,多线程或多进程服务器需要这种操作系统上下文切换来将其注意力从一个客户端切换到另一个客户端。

从技术上讲,即使没有用sock.setblocking(False)将每个新的客户端套接字设置为非阻塞模式,前面的代码也可以正确运行。为什么呢?因为清单 7-6 除非有等待数据,否则从不调用recv(),如果至少有一个字节的输入准备好了,recv()从不阻塞;除非可以传输数据,否则它永远不会调用send(),如果至少有一个字节可以写入操作系统的输出网络缓冲区,send()永远不会阻塞。但是这个setblocking()调用是谨慎的,以防你出错。在它不存在的情况下,对send()recv()的错误调用将阻塞并使您对所有客户端都没有响应,除了您被阻塞的那个客户端。有了setblocking()调用,您的一个混淆将引发socket.timeout并提醒您,您已经设法进行了一个操作系统无法立即执行的调用。

如果您对这个服务器释放几个客户机,您会看到它的单线程泰然自若地处理所有同时进行的对话。但是你必须用清单 7-6 中的深入了解相当多的操作系统内部。如果您想专注于您的客户代码,而让其他人去担心select()poll()epoll()的细节,该怎么办?

回调式异步

Python 3.4 向标准库引入了新的asyncio框架,该框架部分由 Python 发明者吉多·范·罗苏姆设计。它为基于select()epoll()和类似机制的事件循环提供了一个标准接口,试图统一一个在 Python 2 时代已经变得支离破碎的领域。

在考虑了清单 7-6 并注意到它的代码很少是专门针对你在本章中学习的示例问答协议的之后,你可能已经想象出了这样一个框架所承担的责任。它保持了一个中央选择风格的循环。它保存了一个关于预期 I/O 活动的套接字表,并在必要时从 select 循环中添加或删除它们。一旦套接字关闭,它就清理并放弃它们。最后,当实际数据到达时,它根据用户代码来确定正确的响应。

asyncio框架支持两种编程风格。其中一个让程序员想起了 Python 2 下的老式 Twisted 框架,它让用户通过一个对象实例来保持与每个开放客户端的连接。在这种设计模式中,清单 7-6 推进客户端对话的步骤变成了对对象实例的方法调用。你可以在清单 7-7 中看到熟悉的读入问题并给出回答的步骤,它是以直接插入asyncio框架的方式编写的。

清单 7-7 。回调风格的 asyncio 服务器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_asyncio1.py
# Asynchronous I/O inside "asyncio" callback methods.

import asyncio, zen_utils

class ZenServer(asyncio.Protocol):

    def connection_made(self, transport):
        self.transport = transport
        self.address = transport.get_extra_info('peername')
        self.data = b''
        print('Accepted connection from {}'.format(self.address))

    def data_received(self, data):
        self.data += data
        if self.data.endswith(b'?'):
            answer = zen_utils.get_answer(self.data)
            self.transport.write(answer)
            self.data = b''

    def connection_lost(self, exc):
        if exc:
            print('Client {} error: {}'.format(self.address, exc))
        elif self.data:
            print('Client {} sent {} but then closed'
                  .format(self.address, self.data))
        else:
            print('Client {} closed socket'.format(self.address))

if __name__ == '__main__':
    address = zen_utils.parse_command_line('asyncio server using callbacks')
    loop = asyncio.get_event_loop()
    coro = loop.create_server(ZenServer, *address)
    server = loop.run_until_complete(coro)
    print('Listening at {}'.format(address))
    try:
        loop.run_forever()
    finally:
        server.close()
        loop.close()

在清单 7-7 的中,你可以看到实际的套接字对象被小心地保护起来,不受协议代码的影响。您向框架而不是套接字请求远程地址。数据是通过一个方法调用传递的,这个方法调用只显示已经到达的字符串。您想要传输的答案通过其transport.write()方法调用被传递给框架,让您的代码不参与循环——确切地说——关于数据何时被传递给操作系统以传输回客户端。该框架向您保证它会尽快发生,只要它不会阻塞其他需要注意的客户端连接的进度。

异步工人通常会变得比这更复杂。一个常见的例子是,对客户端的响应不能像这里一样简单地编写,而是涉及读取文件系统上的文件或咨询后端服务,如数据库。在这种情况下,您的客户机代码将不得不面向两个不同的方向:在向客户机发送和接收数据时,以及在从文件系统或数据库发送和接收数据时,它都将遵从框架。在这种情况下,您的回调方法可能自己构建futures对象,提供进一步的回调,在数据库或文件系统 I/O 最终完成时调用。详见官方asyncio文档。

corroutine-style 异步

asyncio框架构建协议代码的另一种方法是构建一个协程,这是一个当它想要执行 I/O 时暂停的函数——将控制返回给它的调用者——而不是阻塞 I/O 例程本身。Python 语言支持协程的规范形式是通过生成器:内部有一个或多个yield语句的函数,因此在被调用时,这些函数可以一口气说出一系列项目,而不是以一个返回值结束。

如果你以前编写过通用生成器,它的yield语句只是提供消费项目,那么你会对asyncio目标生成器的外观感到有点惊讶。它们利用了在 PEP 380 中开发的扩展的yield语法。扩展的语法不仅允许一个正在运行的生成器用yield from语句输出另一个生成器生成的所有项目,还允许yield向协程内部返回值,甚至在用户需要时引发异常。这允许一种模式,在这种模式中,协程对一个对象执行一个result = yield操作,描述它想要执行的一些操作——可能是对另一个套接字的读取或者对文件系统的访问——并且或者在result中接收回成功操作的结果,或者就在协程中经历一个异常,指示操作失败。

清单 7-8 展示了作为协程实现的协议。

清单 7-8 。协程风格的 asyncio 服务器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_asyncio2.py
# Asynchronous I/O inside an "asyncio" coroutine.

import asyncio, zen_utils

@asyncio.coroutine
def handle_conversation(reader, writer):
    address = writer.get_extra_info('peername')
    print('Accepted connection from {}'.format(address))
    while True:
        data = b''
        while not data.endswith(b'?'):
            more_data = yield from reader.read(4096)
            if not more_data:
                if data:
                    print('Client {} sent {!r} but then closed'
                          .format(address, data))
                else:
                    print('Client {} closed socket normally'.format(address))
                return
            data += more_data
        answer = zen_utils.get_answer(data)
        writer.write(answer)

if __name__ == '__main__':
    address = zen_utils.parse_command_line('asyncio server using coroutine')
    loop = asyncio.get_event_loop()
    coro = asyncio.start_server(handle_conversation, *address)
    server = loop.run_until_complete(coro)
    print('Listening at {}'.format(address))
    try:
        loop.run_forever()
    finally:
        server.close()
        loop.close()

将这个清单与之前在服务器上的工作进行比较,您会发现所有的代码。重复调用recv()while循环是一种老的帧策略,之后是写一个回复给等待的客户端,所有这些都包含在一个while循环中,这个循环很乐意继续响应客户端想要的请求。但是有一个关键的区别,它阻止您简单地重用这个相同逻辑的早期实现。这里它采用了一个生成器的形式,在任何地方执行一个yield from,早期的代码只是执行一个阻塞操作,然后等待操作系统响应。正是这种差异让这个生成器可以插入asyncio子系统,而不会阻塞它,也不会阻止一个以上的工作人员同时取得进展。

PEP 380 推荐协程使用这种方法,因为它可以很容易地看出你的生成器可能会在哪里暂停。它可以在每次执行yield时无限期地停止运行。一些程序员不喜欢用显式的yield语句来修饰他们的代码,在 Python 2 中有像geventeventlet这样的框架,它们用正常的阻塞 I/O 调用来获取正常的网络代码,并专门拦截那些调用来执行真正的异步 I/O。在撰写本文时,这些框架还没有被移植到 Python 3,如果移植,它们仍然会面临竞争,因为asyncio现在已经内置到标准库中了。如果它们真的出现了,那么程序员将不得不在冗长但显式的asyncio协程方法和隐式但更紧凑的代码之间做出选择,前者在任何可能发生暂停的地方都可以看到“让步”,而后者在像recv()这样的调用将控制返回到异步 I/O 循环时,代码本身看起来像是无辜的方法调用。

遗留模块 asyncore

如果您遇到任何针对asyncore标准库模块编写的服务,清单 7-9 使用它来实现示例协议。

清单 7-9 。使用旧的 asyncore 框架

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_legacy2.py
# Uses the legacy "asyncore" Standard Library module to write a server.

import asyncore, asynchat, zen_utils

class ZenRequestHandler(asynchat.async_chat):

    def __init__(self, sock):
        asynchat.async_chat.__init__(self, sock)
        self.set_terminator(b'?')
        self.data = b''

    def collect_incoming_data(self, more_data):
        self.data += more_data

    def found_terminator(self):
        answer = zen_utils.get_answer(self.data + b'?')
        self.push(answer)
        self.initiate_send()
        self.data = b''

class ZenServer(asyncore.dispatcher):

    def handle_accept(self):
        sock, address = self.accept()
        ZenRequestHandler(sock)

if __name__ == '__main__':
    address = zen_utils.parse_command_line('legacy "asyncore" server')
    listener = zen_utils.create_srv_socket(address)
    server = ZenServer(listener)
    server.accepting = True  # we already called listen()
    asyncore.loop()

如果您是一名经验丰富的 Python 程序员,这个清单将会引起您的警惕。ZenServer对象从未被传递给asyncore.loop()方法或以任何方式显式注册,然而控制循环似乎神奇地知道服务是可用的!显然,这个模块是在模块级的全局变量或其他一些恶意变量中进行交易,以在主控制循环、服务器对象和它所创建的请求处理程序之间建立链接,但这样做的方式你看不太清楚。

然而,你可以看到许多相同的步骤是在幕后完成的,这一点asyncio已经公开了。每个新的客户端连接都会导致创建一个新的ZenRequestHandler实例,您可以在它的实例变量中存储任何必要的状态,以跟上客户端对话的进展。此外,正如您一直在研究的这些异步框架一样,接收和发送之间存在不对称。接收数据包括将控制返回并移交给框架,然后针对作为输入到达的每个新字节块进行回调。但是发送数据是一个“一劳永逸”的操作——你将整个输出负载交给框架,然后可以返回控制权,相信框架会根据需要进行尽可能多的send()调用来传输数据。

最后一次,你会看到异步框架,除非它们像geventeventlet(目前只有 Python 2 才有)一样有看不见的魔力,否则会迫使你使用不同的习惯用法来编写你的服务器代码,而不是像清单 7-3 所示的简单服务器那样。多线程和多处理只是不加修改地运行单线程代码,而异步方法迫使您将代码分成小块,每个小块都可以运行而不会阻塞。回调样式强制每个不可阻止的代码段存在于一个方法中;协程风格让你在yieldyield from语句之间插入每个基本的不可阻塞操作。

两全其美

这些异步服务器可以通过简单地从一个协议对象浏览到另一个协议对象,在一个客户端的流量和另一个客户端的流量之间灵活地切换(或者,在更原始的清单 7-6 的情况下,在一个字典条目和另一个字典条目之间切换)。与操作系统需要参与上下文切换相比,这可以用少得多的费用为客户端服务。

但是异步服务器有一个硬性限制。正是因为它在单个操作系统线程中完成所有工作,所以一旦它的 CPU 达到 100%的利用率,它就会碰壁,无法再处理更多的客户端工作。这是一种模式,至少在其原始形式中是如此,它总是被限制在单个处理器上,而不管您的服务器具有多少个内核。

幸运的是,解决方案就在眼前。当您需要高性能时,使用异步回调对象或协程编写您的服务,并在异步框架下启动它。然后后退一步,配置您的服务器操作系统,启动与您拥有的 CPU 内核一样多的事件循环进程!(请向您的服务器管理员咨询一个细节:您是否应该为操作系统留出一到两个内核,而不是占用所有内核?)你现在将拥有两个世界的精华。在给定的 CPU 上,异步框架可以快速运行,随心所欲地在活动的客户端套接字之间切换,而不会导致一个上下文切换到另一个进程。但是操作系统可以在所有活动的服务器进程之间分配新的连接,理想地平衡整个服务器的负载。

正如在“关于部署的一些话”一节中所讨论的,您可能希望将这些进程限制在一个守护进程中,该守护进程可以监视它们的健康状况并重新启动它们,或者在它们失败时通知工作人员。从supervisord一直到完整的平台即服务容器化,这里讨论的任何机制都应该可以很好地用于异步服务。

在 inetd 下运行

在结束这一章之前,我应该提到古老的inetd守护进程,它适用于几乎所有的 BSD 和 Linux 发行版。发明于互联网早期,它解决了当系统启动时,如果你想在给定的服务器上提供不同的网络服务,需要启动不同的守护进程的问题。在它的/etc/inetd.conf文件中,您只需列出机器上您想要监听的每个端口。

inetd守护进程对它们中的每一个执行bind()listen(),但是只有当客户端真正连接时,它才会启动服务器进程。这种模式使得支持在普通用户帐户下运行的低端口号服务变得容易,因为inetd本身就是打开低端口号端口的进程。对于像本章中这样的 TCP 服务(关于 UDP 数据报服务的更复杂的情况,请参见您的inetd(8)文档),inetd守护进程可以为每个客户端连接启动一个进程,或者在接受第一个连接后,期待您的服务器保持运行并继续监听新的连接。

为每个连接创建一个进程的成本更高,并且会给服务器带来更高的负载,但这也更简单。单次服务由服务的inetd.conf条目的第四个字段中的字符串nowait指定。

1060 stream tcp nowait brandon /usr/bin/python3 /usr/bin/python3 in_zen1.py

这样的服务将启动并发现它的标准输入、输出和错误已经连接到客户端套接字。该服务只需要与该客户端进行对话,然后退出。清单 7-10 给出了一个例子,可以和刚刚给出的inetd.conf行结合使用。

清单 7-10 。回答一个客户端,它的套接字是 stdin/stdout/stderr

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/in_zen1.py
# Single-shot server for the use of inetd(8).

import socket, sys, zen_utils

if __name__ == '__main__':
    sock = socket.fromfd(0, socket.AF_INET, socket.SOCK_STREAM)
    sys.stdin = open('/dev/null', 'r')
    sys.stdout = sys.stderr = open('log.txt', 'a', buffering=1)
    address = sock.getpeername()
    print('Accepted connection from {}'.format(address))
    zen_utils.handle_conversation(sock, address)

该脚本小心翼翼地用更合适的打开文件替换 Python 标准的输入、输出和错误对象,因为您很少希望原始的回溯和状态消息(Python 或其某个库可能会指向标准输出或特别是标准错误)中断您与客户端的对话。注意,这种策略只修复了 Python 内部的 I/O 尝试,因为它只触及到了sys内部的文件对象,而不是真正的文件描述符。如果您的服务器调用任何执行自己的标准 I/O 的低级 C 库,那么您还需要关闭底层文件描述符 0、1 和 2。然而,在这种情况下,您开始着手进行沙箱化,这种沙箱化通过supervisord(一种守护进程模块,或者平台风格的容器化,如前面“关于部署的一些话”一节中所述)来完成更好。

您可以在您的普通用户命令行中测试清单 7-10 ,只要您选择的端口不是一个低编号的端口,通过对一个包含前面给出的行的微小配置文件运行inetd -d inet.conf,然后像往常一样用client.py连接到端口。

另一种模式是在您的inetd.conf条目的第四个字段中指定字符串wait,这意味着您的脚本将被赋予监听器套接字本身。这给你的脚本一个任务,为当前正在等待的客户端调用accept()。这样做的好处是,您的服务器可以选择保持活动状态,继续运行accept()来接收更多的客户端连接,而不需要inetd参与进来。这比为每个传入的连接启动一个全新的过程更有效。如果客户端停止连接一段时间,您的服务器可以自由地exit()减少服务器机器的内存占用,直到客户端再次需要服务;inetd将检测到您的服务已经退出,并再次接管监听工作。

清单 7-11 设计用于wait模式。它能够永远接受新的连接,但如果几秒钟后没有任何新的客户端连接,它也可以超时并退出,使服务器不再需要将它保存在内存中。

清单 7-11 。回答一个或多个客户端连接,但最终会感到厌烦并超时

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/in_zen2.py
# Multi-shot server for the use of inetd(8).

import socket, sys, zen_utils

if __name__ == '__main__':
    listener = socket.fromfd(0, socket.AF_INET, socket.SOCK_STREAM)
    sys.stdin = open('/dev/null', 'r')
    sys.stdout = sys.stderr = open('log.txt', 'a', buffering=1)
    listener.settimeout(8.0)
    try:
        zen_utils.accept_connections_forever(listener)
    except socket.timeout:
        print('Waited 8 seconds with no further connections; shutting down')

当然,这个服务器和我在本章开始时使用的是同一个原始的单线程设计。在生产中,你可能想要一个更健壮的设计,你可以使用本章讨论的任何方法。唯一的要求是他们能够接受一个已经监听的套接字,并在其上一遍又一遍地运行accept(),直到永远。这很简单,如果你对你的服务器进程感到满意,一旦被inetd启动,就永远不会退出。如果您希望服务器能够超时并在一段时间不活动后关闭,情况可能会变得更复杂一些(超出了本书的范围),因为对于一组线程或进程来说,确认它们中没有一个当前正在与客户端对话,并且它们中没有一个最近收到了足以保证服务器保持活动的客户端连接是很棘手的。

还有一个简单的基于 IP 地址和主机名的访问控制机制,它内置在某些版本的inetd中。这个机制是一个名为tcpd的老程序的派生,在被整合到同一个进程中之前,它曾经和inetd一起工作。它的/etc/hosts.allow/etc/hosts.deny文件可以根据它们的规则阻止部分(或全部!)IP 地址连接到您的服务之一。如果您正在调试客户端无法访问您的某个inetd驱动的服务的问题,请务必阅读您的系统文档并查看您的系统管理员是如何配置这些文件的!

摘要

第二章的和第三章的的示例网络服务器一次只能与一个客户端交互,而所有其他的都必须等待,直到前一个客户端套接字关闭。有两种方法可以超越这个障碍。

从编程的角度来看,最简单的是多线程(或多处理),其中服务器代码通常可以保持不变,操作系统负责在工作线程之间进行不可见的切换,以便等待的客户端可以快速获得响应,而空闲的客户端不会消耗服务器 CPU。这种技术不仅允许几个客户端对话同时进行,而且还可以更好地利用服务器 CPU,否则服务器 CPU 可能会在大部分时间处于空闲状态,等待来自一个客户端的更多工作。

更复杂但更强大的方法是采用异步编程风格,通过向操作系统提供它当前正在与之对话的套接字的完整列表,让单个控制线程在任意多个客户端之间切换它的注意力。复杂的是,这需要读取客户端请求并构建响应的逻辑,将响应拆分为小的、非阻塞的代码片段,当需要再次等待客户端时,这些代码片段可以将控制权交还给异步框架。虽然可以使用类似于select()poll()的机制手工编写异步服务器,但大多数程序员会希望依赖于一个框架,比如 Python 3.4 及更新版本中内置于标准库中的asyncio框架。

安排您编写的服务安装在服务器上,并在系统启动时开始运行,这被称为部署,它可以使用许多现代机制实现自动化,或者使用supervisord之类的工具,或者将控制权交给平台即服务容器。对于基线 Linux 服务器来说,最简单的部署可能是旧的inetd守护进程,它提供了一种基本的方法来确保您的服务在客户端第一次需要它的时候启动。

在这本书里你会再次看到服务器的主题。在之后,第八章讨论了现代 Python 程序员所依赖的一些基本的基于网络的服务,第九章到第十一章将着眼于 HTTP 协议的设计和作为客户机和服务器的 Python 工具,在这里你将看到本章介绍的设计在诸如 Gunicorn 的分叉 web 服务器和诸如 Tornado 的异步框架之间的选择中再次可用。

八、缓存和消息队列

这一章虽然简短,但可能是本书中最重要的一章。它调查了两种技术——缓存和消息队列——这两种技术已经成为高负载下服务的基本构件。这本书到了一个转折点。前面的章节已经探讨了 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:19mypage:/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 坐标。这个坐标是在以原点为圆心的四分之一圆的内部还是外部,这个圆的半径是这两个整数的最大值?

9781430258544_Fig08-01.jpg

图 8-1 。π的简单蒙特卡罗估计的拓扑

使用发布-订阅拓扑,您为这些二进制字符串构建了两个听众。always_yes监听器将只接收以00开头的数字串,因此总是可以推送答案Y,因为,如果你的两个坐标都以数字 0 开头,那么该点一定位于域的左下象限,因此安全地落在圆内。然而,前两位的其他三种可能的模式必须由进行真正测试的judge例程来处理。它必须要求pythagoras计算两个整数坐标的平方和,以确定它们指定的点是在圆内还是圆外,并相应地将TF推入其输出队列。

拓扑底部的计数例程接收为每个生成的随机位模式产生的TF,通过将 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_yesjudgebitsource建立的连接形成了一个发布-订阅系统,在这个系统中,每一个连接的客户端都会收到发布者发送的每一条消息的副本(在这个例子中,不包括任何被过滤掉的消息)。应用于 MQ 套接字的每个过滤器通过选择前几个数字与过滤器字符串匹配的每个消息来增加而不是减少所接收的消息总数。然后,你的用户对保证接收到由bitsource产生的每一个位串,因为在它们的四个过滤器中有两个领先二进制数字的每一种可能的组合。

judgepythagoras 之间的关系是一个经典的 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 ,列出文档内容所依赖的其他头。常见的选择有HostAccept-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/plaintext/html以及图像格式image/gifimage/jpgimage/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_typecharset,这些属性按照标准用分号放在内容类型头中。

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,当您接近服务设计时,您应该在工具箱中有这些选项和设置。

Image仔细阅读 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 证书,又将请求转发到您的中央服务器。

9781430258544_Fig10-01.jpg

图 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 Found
  • 405 Method Not Allowed
  • 501 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 会处理所有其他事情。

Image 注意 WebOb 有一个特性使它在众多 Python HTTP 响应对象实现中几乎是独一无二的。WebOb Response类允许您将像text/plain; charset=utf-8这样的内容类型头的两部分视为两个独立的值,并将其公开为独立的属性content_typecharset

就纯 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

httpshttp这样的初始标签是方案,它命名了可以检索文档的协议。冒号和两个斜线://后面是主机名和可选端口号。最后,路径从服务上可能可用的所有文档中选择一个特定的文档。

这种语法可以用于比描述从网络上获取的材料更一般的目的。统一资源标识符(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']}

标准库提供了从另一个方向返回的所有必要的例程。给定前面显示的pathquery,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.cityp.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 所示。

9781430258544_Fig11-01.jpg

图 11-1 。Google Chrome 中的 Inspect 标签

在检查器中,您可以切换到一个网络选项卡,该选项卡将显示所有其他下载的资源,并显示为访问该页面的结果。

注意,图 11-2 中的所示的网络面板通常是空的。启动后,单击“重新加载”,查看信息填充情况。

9781430258544_Fig11-02.jpg

图 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.creditrow.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-3base.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-4login.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-5index.html Jinja2 模板

{% extends "base.html" %}
{% block title %}Welcome, {{ username }}{% endblock %}
{% block body %}
{% for message in flash_messages %}
  <div class="flash_message">{{ message }}<a href="/">&times;</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 %}

注意,代码对一遍又一遍地显示当前用户的帐户名称不感兴趣,因为它循环显示他们的收入和支出。因此,对于每笔付款,它会计算出creditdebit账户名称是否与当前用户匹配,然后确保打印另一个账户名称——使用正确的介词,这样用户就可以知道他们的钱流向了哪里。这要感谢 Jinja2 的{% set ... %}命令,当设计者意识到他们想要什么时,这使得像这样的快速小演示计算很容易在模板中完成。

用户似乎经常有几十种方法不能正确填写表单,清单 11-6 准备好接收一个complaint字符串,以便在表单顶部突出显示,如果提供了这样的字符串的话。除此之外,代码大部分是重复的:如果表单填写不正确,需要重新显示三个表单字段,当用户尝试提交表单时,需要用用户已经存在的文本进行预填充。

清单 11-6pay.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.pyapp_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-8app_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 包括以下三个步骤:

  1. 要求您选择的库解析 HTML。这对图书馆来说可能很困难,因为网络上的许多 HTML 包含错误和损坏的标记。但是设计者通常不会注意到这一点,因为浏览器总是试图恢复和理解标记。毕竟,哪个浏览器供应商会希望自己的浏览器是唯一一个返回某个流行网站错误的浏览器,而其他所有浏览器都正常显示呢?清单 11-12 中使用的两个库都以健壮的 HTML 解析器而闻名。
  2. 使用选择器进入文档,这些选择器是文本模式,会自动找到您想要的元素。虽然您可以自己动手,慢慢地遍历每个元素的子元素,寻找您感兴趣的标签和属性,但是使用选择器通常要快得多。它们通常还会产生更清晰、更易读的 Python 代码。
  3. 向每个元素对象询问所需的文本和属性值。然后,您又回到了普通 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-devpython-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 标准库中支持的几个不太为人所知的协议的浏览,转到了电子邮件的主题以及它们是如何被格式化的。

十二、构建和解析电子邮件

这是关于电子邮件这个重要话题的四章中的第一章。这一章不讨论网络通信。相反,它为接下来的三个阶段做好了准备:

  • 本章描述了电子邮件的格式,特别关注多媒体和国际化的正确包含。这为随后三章中概述的协议建立了有效载荷格式。
  • 第十三章解释了简单邮件传输协议(SMTP) ,该协议用于将电子邮件从编写它们的机器传输到保存邮件的服务器,使它们为特定的收件人阅读做好准备。
  • 第十四章描述了陈旧的、设计不良的邮局协议(POP ),通过该协议,准备阅读电子邮件的人可以下载并查看电子邮件服务器上收件箱中等待的新邮件。
  • 第十五章介绍了互联网邮件访问协议(IMAP ),这是一个更好、更现代的本地查看电子邮件服务器上的邮件的选项。IMAP 不仅支持获取和查看,还允许您将邮件标记为已读,并将其存储在服务器本身的不同文件夹中。

如你所见,这四章的顺序暗示了电子邮件的自然寿命。首先,电子邮件由各种文本、多媒体和元数据组成,如发件人和收件人。然后 SMTP 将它从原始位置传送到目的服务器。最后,收件人的电子邮件客户端(通常是 Mozilla Thunderbird 或 Microsoft Outlook)使用 POP 或 IMAP 之类的协议,将邮件的副本拖到他们的台式机、笔记本电脑或平板电脑上进行查看。然而,请注意,最后一步变得越来越不常见:今天许多人通过网络邮件服务阅读他们的电子邮件,这允许他们用网络浏览器登录并查看以 HTML 格式呈现的电子邮件,而无需电子邮件离开电子邮件服务器。Hotmail 曾经非常受欢迎,而 Gmail 可能是当今最大的此类服务。

请记住,无论电子邮件后来发生了什么——无论您使用 SMTP、POP 还是 IMAP——关于电子邮件如何格式化和表示的规则都是完全相同的。这些规则是本章的主题。

电子邮件格式

著名的 1982 年 RFC 822 作为电子邮件的定义统治了近 20 年,直到最后需要更新。此更新由 RFC 2822 在 2001 年提供,随后在 2008 年被 RFC 5322 取代。当您需要编写非常严肃或引人注目的代码来处理电子邮件时,您会希望参考这些标准。出于这里的目的,只有几个关于电子邮件格式的事实需要立即引起注意。

  • 电子邮件表示为普通的 ASCII 文本,使用字符代码 1 到 127。
  • 行尾标记是两个字符的序列——回车加换行(CRLF ),这是用于在老式电传打字机上前进到下一行的同一对代码,并且仍然是今天互联网协议中的标准行尾序列。
  • 一封电子邮件由标题、空行和正文组成。
  • 每个头都被格式化为一个不区分大小写的名称、一个冒号和一个值,如果头的第二行和随后的行用空格缩进,则该值可以扩展到几行。
  • 因为在纯文本中既不允许 Unicode 字符也不允许二进制有效载荷,所以我将在本章后面解释的其他标准提供了编码,通过这些编码可以将更丰富的信息混合到纯 ASCII 文本中进行传输和存储。

在清单 12-1 的中,你可以读到一封真实的电子邮件,当它到达我的收件箱时。

清单 12-1 。交付完成后的真实电子邮件

X-From-Line: rms@gnu.orgSPI_AMP#160; Fri Dec  3 04:00:59 1999
Return-Path: <rms@gnu.org>
Delivered-To: brandon@europa.gtri.gatech.edu
Received: from pele.santafe.edu (pele.santafe.edu [192.12.12.119])
        by europa.gtri.gatech.edu (Postfix) with ESMTP id 6C4774809
        for <brandon@rhodesmill.org>; Fri,  3 Dec 1999 04:00:58 -0500 (EST)
Received: from aztec.santafe.edu (aztec [192.12.12.49])
        by pele.santafe.edu (8.9.1/8.9.1) with ESMTP id CAA27250
        for <brandon@rhodesmill.org>; Fri, 3 Dec 1999 02:00:57 -0700 (MST)
Received: (from rms@localhost)
        by aztec.santafe.edu (8.9.1b+Sun/8.9.1) id CAA29939;
        Fri, 3 Dec 1999 02:00:56 -0700 (MST)
Date: Fri, 3 Dec 1999 02:00:56 -0700 (MST)
Message-Id: <199912030900.CAA29939@aztec.santafe.edu>
X-Authentication-Warning: aztec.santafe.edu: rms set sender to rms@gnu.org using -f
From: Richard Stallman <rms@gnu.org>
To: brandon@rhodesmill.org
In-reply-to: <m3k8my7x1k.fsf@europa.gtri.gatech.edu> (message from Brandon
        Craig Rhodes on 02 Dec 1999 00:04:55 -0500)
Subject: Re: Please proofread this license
Reply-To: rms@gnu.org
References: <199911280547.WAA21685@aztec.santafe.edu> <m3k8my7x1k.fsf@europa.gtri.gatech.edu>
Xref: 38-74.clients.speedfactory.net scrapbook:11
Lines: 1

Thanks.

尽管这条消息实际上只传递了一行正文,但是您可以看到,在通过互联网传输的过程中,它积累了相当多的附加信息。

尽管在撰写电子邮件时,发件人一行以下的所有标题可能都已经存在,但它上面的许多标题可能是在传输历史的不同阶段添加的。处理电子邮件的每个客户端和服务器都保留添加额外邮件头的权利。这意味着每封电子邮件在网络中传播时都会积累一段个人历史,通常可以从最后一个邮件头开始向上阅读,直到到达第一个邮件头。

在这种情况下,电子邮件似乎源自圣达菲一台名为aztec的机器,其作者通过本地主机内部接口直接连接。然后aztec机器使用 SMTP 将消息转发给pele,后者可能为一个部门或整个校园执行电子邮件传输。最后,pele通过 SMTP 连接到我在佐治亚理工学院桌子上的europa机器,它把信息写到磁盘上,以便我以后可以阅读。

现在,我将暂停一下,介绍几个特定的电子邮件标题;完整列表见标准。

  • From 指定电子邮件的作者。和后面的标题一样,它既支持真实姓名,也支持尖括号内的电子邮件地址。
  • 回复到指定回复的目的地,如果不是从头的中列出的作者。
  • 是一个或多个主要收件人的列表。
  • Cc 列出一个或多个收件人,他们应该收到电子邮件的“副本”,但不是通信的直接地址。
  • 密件抄送列出了应该在其他收件人不知情的情况下获得电子邮件秘密副本的收件人。因此,细心的电子邮件客户在真正发送电子邮件之前会先将的密件抄送去掉。
  • 主题是由消息作者编写的消息内容的人类可读摘要。
  • 日期指定发送或接收消息的时间。通常,如果发件人的电子邮件客户端包含日期,则接收电子邮件的服务器和阅读器不会覆盖它。但是如果发件人没有注明日期,那么在收到电子邮件时,为了完整起见,可能会在后面加上日期。
  • Message-Id 是用于识别电子邮件的唯一字符串。
  • 回复至是该消息回复的先前消息的唯一消息 Id。如果要求您构建一个线索显示,将回复消息放在它们所回复的电子邮件的下面,那么这些将非常有用。
  • Received 是在电子邮件通过 SMTP 到达互联网途中的另一个“跳”时添加的。电子邮件服务器管理员经常仔细检查这些树环,以确定邮件被正确传递或未被正确传递的原因。

您可以看到电子邮件的纯文本限制对标题和正文都有影响:在这样一个简单的例子中,它们都被限制为 ASCII。在接下来的小节中,我将解释管理邮件头如何包含国际字符的标准,以及设置电子邮件正文如何包含国际或二进制数据的标准。

构建电子邮件消息

Python 中构建电子邮件消息的主要接口是EmailMessage类,本章列出的每个程序都会用到它。这是 Python 模块大师 r .大卫·穆雷辛勤工作的结果,我要感谢他在我整理本章脚本时给予的指导和建议。最简单的例子如清单 12-2 所示。

清单 12-2 。生成简单的文本电子邮件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/build_basic_email.py

import email.message, email.policy, email.utils, sys

text = """Hello,
This is a basic message from Chapter 12.
 - Anonymous"""

def main():
    message = email.message.EmailMessage(email.policy.SMTP)
    message['To'] = 'recipient@example.com'
    message['From'] = 'Test Sender <sender@example.com>'
    message['Subject'] = 'Test Message, Chapter 12'
    message['Date'] = email.utils.formatdate(localtime=True)
    message['Message-ID'] = email.utils.make_msgid()
    message.set_content(text)
    sys.stdout.buffer.write(message.as_bytes())

if __name__ == '__main__':
    main()

Image 注意本章中的代码专门针对 Python 3.4 及更高版本,该版本将EmailMessage类引入到旧的电子邮件模块中。如果您需要针对旧版本的 Python 3 并且无法升级,请在https://github.com/brandon-rhodes/fopnp/tree/m/py3/old-chapter12学习旧的脚本。

您可以通过省略此处显示的标题来生成更简单的电子邮件,但这是您在现代互联网上通常应该考虑的最小集合。

EmailMessage的 API 让您的代码非常接近地反映您的电子邮件消息的文本。虽然您可以自由地设置标题,并以最适合您的代码的任何顺序提供内容,但是先设置标题,然后最后设置正文,可以使消息在网络上以及在电子邮件客户端上的显示方式具有令人满意的对称性。

请注意,我在这里设置了两个您应该始终包含的头,但是它们的值不会自动为您设置。我将利用 Python 中内置于标准电子邮件工具集的formatdate()函数,以电子邮件标准所要求的特殊格式提供日期。Message-Id 也是从随机信息中精心构造的,以使它(希望)在所有过去编写的或将来编写的电子邮件中是唯一的。

生成的脚本只是将电子邮件打印到它的标准输出上,这使得试验变得非常容易,并立即显示您所做的任何编辑或修改的结果。

To: recipient@example.com
From: Test Sender <sender@example.com>
Subject: Test Message, Chapter 12
Date: Fri, 28 Mar 2014 16:54:17 -0400
Message-ID: <20140328205417.5927.96806@guinness>
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
MIME-Version: 1.0

Hello,
This is a basic message from Chapter 12.
 - Anonymous

如果您要使用旧的Message类而不是EmailMessage来构建电子邮件消息,您将会看到其中的几个头将会丢失。老式的电子邮件消息(如清单 12-1 中的消息)没有指定传输编码、多用途互联网邮件扩展(MIME)版本和内容类型,而是简单地省略了这些标题,并相信电子邮件客户端会采用传统的默认值。但是现代的EmailMessage构建器更加小心地指定显式值,以确保与现代工具最高水平的互操作性。

如前所述,标头名称不区分大小写。因此,符合要求的电子邮件客户端将不会区分清单 12-1 中的Message-Id和生成的电子邮件中的Message-ID(改为大写的 D)的含义。

如果不想让函数使用当前日期和时间,可以给函数一个特定的 Python datetime来显示,也可以选择让它使用格林威治标准时间(GMT)而不是本地时区。详见 Python 的文档。

请注意,惟一的Message-ID是由几条信息构成的,如果您处于非常高安全性的情况下,您可能不想公开这些信息:您调用make_msgid()的确切时间、日期和毫秒数,您的 Python 脚本的这次调用的进程 ID,如果您没有提供可选的domain=关键字,甚至是您当前的主机名。如果您想避免泄露任何这些信息,请实现一个替代的唯一 id 解决方案(可能需要一个工业级的通用唯一标识符[UUID] 算法)。

最后,请注意,尽管文本并不正式符合作为电子邮件传输的要求——为了节省脚本中的垂直空间,用三重引号括起来的字符串常量没有结束行——set_content()as_bytes()的组合确保了电子邮件消息以换行符正确结束。

添加 HTML 和多媒体

在早期,人们发明了许多专门的机制来在 7 位 ASCII 世界的电子邮件中传送二进制数据,但正是 MIME 标准为非 ASCII 有效负载建立了一种可互操作和可扩展的机制。MIME 允许内容类型的电子邮件头指定一个边界字符串,每当电子邮件出现在前面有两个连字符的行上时,它就将电子邮件分割成更小的消息部分。每个部分都有自己的头,因此也有自己的内容类型和编码。如果一个部件甚至指定了自己的边界字符串,那么部件甚至可以由更多的子部件组成,从而创建一个层次结构。

Python email模块确实提供了低级支持,可以根据您的需要从任何部分和子部分构建 MIME 消息。简单地构建几个email.message.MIMEPart 对象——每个对象都可以被赋予标题和主体,使用与EmailMessage相同的接口——然后attach()它们到它们的父部分或消息:

my_message.attach(part1)
my_message.attach(part2)
...

但是,如果您要精确地再现某个特定的消息结构(这是您的应用或项目规范所要求的),那么您应该只求助于手动组装。在大多数情况下,您可以简单地创建一个EmailMessage(如清单 12-2 中的所示)并依次调用以下四个方法来构建您的结果:

  • 应该先调用set_content() 来安装主消息体。
  • add_related() 可以被调用零次或更多次,以便用其他需要呈现的资源来补充主要内容。通常,当您的主要内容是 HTML,并且需要图像、CSS 样式表和 JavaScript 文件在支持丰富内容的电子邮件客户端中正确呈现时,您会使用这种方法。每个相关资源都应该有一个 Content-Id ( cid),主 HTML 文档可以通过它在超链接中引用它。
  • 然后可以调用add_alternative() 零次或多次,以提供您的电子邮件消息的其他呈现。例如,如果正文是 HTML,您可以为功能较弱的电子邮件客户端提供纯文本替代呈现。
  • add_attachment() 可以被调用零次或多次,以提供任何附件,如 PDF 文档、图像或电子表格,它们应该伴随消息。传统上,如果收件人要求他们的电子邮件客户端保存附件,则每个附件指定一个默认文件名。

回头看,您可以看到清单 12-2 中的完全遵循了上面的过程——它调用了set_content()作为第一步,然后简单地选择调用其他三个方法中的每一个零次。其结果是最简单的电子邮件结构,呈现了一个没有子部分的统一主体。

但是当事情变得更复杂时,电子邮件看起来如何呢?清单 12-3 旨在给出答案。

清单 12-3 。构建带有 HTML、内嵌图像和附件的 MIME 驱动的电子邮件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/build_mime_email.py

import argparse, email.message, email.policy, email.utils, mimetypes, sys

plain = """Hello,
This is a MIME message from Chapter 12.
- Anonymous"""

html = """<p>Hello,</p>
<p>This is a <b>test message</b> from Chapter 12.</p>
<p>- <i>Anonymous</i></p>"""

img = """<p>This is the smallest possible blue GIF:</p>
<img src="cid:{}" height="80" width="80">"""

# Tiny example GIF from http://www.perlmonks.org/?node_id=7974
blue_dot = (b'GIF89a1010\x900000\xff000,000010100\x02\x02\x0410;'
            .replace(b'0', b'\x00').replace(b'1', b'\x01'))

def main(args):
    message = email.message.EmailMessage(email.policy.SMTP)
    message['To'] = 'Test Recipient <recipient@example.com>'
    message['From'] = 'Test Sender <sender@example.com>'
    message['Subject'] = 'Foundations of Python Network Programming'
    message['Date'] = email.utils.formatdate(localtime=True)
    message['Message-ID'] = email.utils.make_msgid()

    if not args.i:
        message.set_content(html, subtype='html')
        message.add_alternative(plain)
    else:
        cid = email.utils.make_msgid()  # RFC 2392: must be globally unique!
        message.set_content(html + img.format(cid.strip('<>')), subtype='html')
        message.add_related(blue_dot, 'image', 'gif', cid=cid,
                            filename='blue-dot.gif')
        message.add_alternative(plain)

    for filename in args.filename:
        mime_type, encoding = mimetypes.guess_type(filename)
        if encoding or (mime_type is None):
            mime_type = 'application/octet-stream'
        main, sub = mime_type.split('/')
        if main == 'text':
            with open(filename, encoding='utf-8') as f:
                text = f.read()
            message.add_attachment(text, sub, filename=filename)
        else:
            with open(filename, 'rb') as f:
                data = f.read()
            message.add_attachment(data, main, sub, filename=filename)

    sys.stdout.buffer.write(message.as_bytes())

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Build, print a MIME email')
    parser.add_argument('-i', action='store_true', help='Include GIF image')
    parser.add_argument('filename', nargs='*', help='Attachment filename')
    main(parser.parse_args())

有四种不同的方法可以调用清单 12-3 中的脚本。按照复杂性递增的顺序,它们是:

  • python3 build_mime_email.py
  • python3 build_mime_email.py attachment.txt attachment.gz
  • python3 build_mime_email.py -i
  • python3 build_mime_email.py -i attachment.txt attachment.gz

为了节省空间,我将在这里只显示这四个命令行的第一个和最后一个的输出,但是如果您想了解 MIME 标准如何根据调用者的需要支持逐渐增加的复杂程度,您应该自己下载build_mime_email.py 并尝试其他的。尽管两个示例文件— attachment.txt(纯文本)和attachment.gz(二进制)—包含在本书的源代码库中,就在脚本旁边,但是您可以随意在命令行中列出您想要的任何附件。这样做将让您看到不同的二进制有效载荷是如何被 Python email模块编码的。

不带任何选项或附件调用build_mime_email.py会产生最简单的 MIME 结构,提供电子邮件的两种可选版本:HTML 和纯文本。这里显示了这样做的结果。

To: Test Recipient <recipient@example.com>
From: Test Sender <sender@example.com>
Subject: Foundations of Python Network Programming
Date: Tue, 25 Mar 2014 17:14:01 -0400
Message-ID: <20140325232008.15748.50494@guinness>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="===============1627694678=="

--===============1627694678==
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: 7bit

<p>Hello,</p>
<p>This is a <b>test message</b> from Chapter 12.</p>
<p>- <i>Anonymous</i></p>

--===============1627694678==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
MIME-Version: 1.0

Hello,
This is a MIME message from Chapter 12.
- Anonymous

--===============1627694678==--

在最高层,上面的电子邮件遵循旧的标准格式:标题、空行和正文。但是现在身体突然更有意思了。为了承载两种有效载荷,纯文本和 HTML,头部指定了一个边界,将主体分成几个更小的部分。每个部分本身都是传统格式:标题、空行和正文。对一个部分的内容只有一个(相当明显的)限制:一个部分不能包含它自己的边界线或任何封闭消息的边界线的副本。

multipart/alternative内容类型是整个multipart/*内容类型家族的一个例子,所有这些内容类型都遵循完全相同的规则,这些规则涉及边界线的建立及其在划定其下的 MIME 子部分时的使用。它的作用是传递信息的几个版本,任何一个版本都可以显示给用户,从而传达信息的全部含义。在这种情况下,可以向用户显示 HTML 或纯文本,但无论以哪种方式,用户都将看到基本相同的电子邮件。如果能够显示的话,大多数客户会选择 HTML。尽管大多数电子邮件客户端会隐藏提供替代版本的事实,但有些确实提供了按钮或下拉菜单,如果用户愿意,可以让他们看到替代版本。

注意到MIME-Version头只在消息的顶层被指定,但是email模块已经处理了这个问题,发送者不需要知道这个标准的细节。

关于multipart段的规则如下:

  • 如果你至少调用了一次add_related(),那么你用set_content()指定的主体将会和所有相关内容一起被分组到一个单独的multipart/related部分中。
  • 如果您至少调用了一次add_alternative(),那么就会创建一个multipart/alternative容器来保存原始主体和您添加的替代部分。
  • 最后,如果您至少调用了一次add_attachment(),那么就会生成一个外部的multipart/mixed容器来保存您添加的所有附件旁边的内容。

通过检查下面的输出,您可以看到所有这些机制一起发挥作用,该输出来自上面给出的四个命令行中最复杂的一个。它要求一个内嵌相关的图像伴随 HTML 和–i,还要求在正文后包含附件。

To: Test Recipient <recipient@example.com>
From: Test Sender <sender@example.com>
Subject: Foundations of Python Network Programming
Date: Tue, 25 Mar 2014 17:14:01 -0400
Message-ID: <20140325232008.15748.50494@guinness>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="===============0086939546=="

--===============0086939546==
Content-Type: multipart/alternative; boundary="===============0903170602=="

--===============0903170602==
Content-Type: multipart/related; boundary="===============1911784257=="

--===============1911784257==
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: 7bit

<p>Hello,</p>
<p>This is a <b>test message</b> from Chapter 12.</p>
<p>- <i>Anonymous</i></p><p>This is the smallest possible blue GIF:</p>
<img src="cid:20140325232008.15748.99346@guinness" height="80" width="80">

--===============1911784257==
Content-Type: image/gif
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="blue-dot.gif"
Content-ID: <20140325232008.15748.99346@guinness>
MIME-Version: 1.0

R0lGODlhAQABAJAAAAAA/wAAACwAAAAAAQABAAACAgQBADs=

--===============1911784257==--

--===============0903170602==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
MIME-Version: 1.0

Hello,
This is a MIME message from Chapter 12.
- Anonymous

--===============0903170602==--

--===============0086939546==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment; filename="attachment.txt"
MIME-Version: 1.0

This is a test

--===============0086939546==
Content-Type: application/octet-stream
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="attachment.gz"
MIME-Version: 1.0

H4sIAP3o2D8AAwvJyCxWAKJEhZLU4hIuAIwtwPoPAAAA

--===============0086939546==--

这封电子邮件是同心的,有三个层次的多部分内容。和以前一样,您可以看到所有的细节都已经为我们处理好了。每个级别都有自己随机生成的边界,不会与任何其他级别的边界冲突。在每种情况下,都为包含在其中的内容选择了适当的多部分容器。

最后,指定了正确的编码。纯文本已被允许在电子邮件正文中传播,而 Base64 编码已被用于非 7 位安全的二进制数据类图像。请注意,在这两个生成脚本中,电子邮件对象被要求以字节形式显式呈现,而不是要求在保存或传输之前对文本进行编码。

添加内容

用于在清单 12-3 中添加内容的所有四种方法共享相同的调用约定。查阅 Python 文档,了解您正在使用的 Python 3 的特定版本所支持的每种可能的组合。以下是方法set_content()add_related()add_alternative()add_attachment()的一些常见组合:

  • method('string data of type str')
    method('string data of type str', subtype='html')

    这些创造了一些有文本味道的部分。内容类型将是text/plain,除非您提供一个定制的子类型——例如,第二个示例调用产生了一个内容类型text/html

  • method(b'raw binary payload of type bytes', type='image', subtype='jpeg')

    如果您提供原始的二进制数据,那么 Python 将不会试图猜测应该是什么类型。您必须自己提供 MIME 类型和子类型,它们将在输出中与一个斜杠组合在一起。请注意,清单 12-3 使用了email模块本身之外的一种机制,即mimetypes模块,来尝试猜测您在命令行上指定的每个附件文件的适当类型。

  • method(..., cte='quoted-printable')

    所有这些方法似乎都默认为仅有的两种内容传输编码之一。安全的 7 位信息使用简单可读的 ASCII 编码逐字包含在电子邮件中,而任何更危险的信息都使用 Base64 编码。如果您经常手动检查收到或发出的电子邮件,您可能会发现后一种选择很不幸,例如,这意味着包含一个 Unicode 字符的文本部分将变成完全不可读的 Base64 垃圾。您可以用cte关键字覆盖编码选择。特别是,您可能会发现quoted-printable编码很有吸引力:ASCII 字符在编码的电子邮件中被一字不差地保留下来,转义序列用于第八位被设置的任何字节。

  • add_related(..., cid='<Content ID>')

    通常,您会希望每个相关部分由一个自定义内容 ID 来标识,以便您的 HTML 可以链接到它。在您的调用中,内容 ID 应该总是用尖括号括起来,但是当您在 HTML 中实际形成cid:链接时,应该去掉它们。值得注意的是,内容 ID 应该是全球唯一的——您在文档中包含的每个内容 ID 都应该是整个世界历史上电子邮件中包含的所有内容 ID 中唯一的!清单 12-3 使用make_msgid(),因为email模块没有提供构建唯一内容 id 的特定工具。

  • add_attachment(..., filename='data.csv')

    当添加附件时,大多数电子邮件客户端(以及他们的用户)将期望至少一个建议的文件名,尽管当然当他们选择“保存”时,电子邮件接收者可以覆盖该默认。

同样,您可以在官方 Python 文档中了解这些特殊情况调用的其他更复杂的版本,但是这些版本应该可以帮助您完成构建 MIME 电子邮件的最常见情况。

解析电子邮件消息

使用email模块中的一个函数解析电子邮件后,有两种基本方法可以阅读它。简单的方法是假设消息通过 MIME 的标准和习惯用法提供了正文和附件,并让内置于EmailMessage中的便利方法帮助您找到它们。更复杂的方法是手动访问消息的所有部分和子部分,然后自己决定它们的含义以及如何保存或显示它们。

清单 12-4 展示了这个简单的方法。与保存电子邮件消息一样,重要的是要小心地将输入读取为字节,然后将这些字节传递给email模块,而不要尝试自己的任何解码步骤。

清单 12-4 。请求发送邮件正文和附件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/display_email.py

import argparse, email.policy, sys

def main(binary_file):
    policy = email.policy.SMTP
    message = email.message_from_binary_file(binary_file, policy=policy)
    for header in ['From', 'To', 'Date', 'Subject']:
        print(header + ':', message.get(header, '(none)'))
    print()

    try:
        body = message.get_body(preferencelist=('plain', 'html'))
    except KeyError:
        print('<This message lacks a printable text or HTML body>')
    else:
        print(body.get_content())

    for part in message.walk():
        cd = part['Content-Disposition']
        is_attachment = cd and cd.split(';')[0].lower() == 'attachment'
        if not is_attachment:
            continue
        content = part.get_content()
        print('* {} attachment named {!r}: {} object of length {}'.format(
            part.get_content_type(), part.get_filename(),
            type(content).__name__, len(content)))

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Parse and print an email')
    parser.add_argument('filename', nargs='?', help='File containing an email')
    args = parser.parse_args()
    if args.filename is None:
        main(sys.stdin.buffer)
    else:
        with open(args.filename, 'rb') as f:
            main(f)

一旦脚本的命令行参数被解析,消息本身被读取并转换成EmailMessage,脚本就很自然地分成两部分。因为您希望email模块能够访问消息在磁盘上的精确二进制表示,所以您要么以二进制模式 'rb'打开它的文件,要么使用 Python 的标准输入对象的二进制buffer属性,这将返回原始字节。

第一个关键步骤是对get_body()方法的调用,该方法让 Python 在消息的 MIME 结构中搜索得越来越深,寻找最适合作为主体的部分。您指定的preferencelist 应该以您喜欢的格式排序,优先于您不太可能想要显示的格式。这里 HTML 内容比正文的纯文本版本更受欢迎,但是两者都可以接受。如果找不到合适的身体,那么KeyError被提高。

请注意,如果您没有指定自己的元素,则使用默认的preferencelist,它有三个元素,因为它将multipart/related 放在 HTML 和纯文本之前作为首选项。如果您正在编写一个复杂的电子邮件客户端(可能是一个 webmail 服务或一个带有内置 WebKit 窗格的应用),它不仅可以正确格式化 HTML,还可以显示内嵌图像并支持样式表,那么这个缺省值是合适的。您得到的对象将是相关内容 MIME 部分本身,然后您必须在其中查找 HTML 和它需要的所有多媒体。因为这里的小脚本只是简单地将结果体打印到标准输出,但是,我跳过了这种可能性。

在显示了所能找到的最佳正文之后,就该搜索用户可能希望显示或保存的任何附件了。注意,示例脚本要求 MIME 为附件指定的所有基本信息:内容类型、文件名,然后是数据本身。在实际的应用中,您可能会打开一个文件进行写入并保存这些数据,而不是仅仅将它的长度和类型打印到屏幕上。

注意,由于 Python 3.4 中的一个错误,这个显示脚本被迫自己决定哪些消息部分是附件,哪些不是。在 Python 的未来版本中,您将能够用对消息的iter_attachments()方法的简单调用来代替树的手动迭代并测试每个部分的内容处理。

接下来的脚本将处理由前面的脚本生成的任何 MIME 消息,不管有多复杂。给定最简单的消息,它只显示“有趣的”标题和正文。

$ python3 build_basic_email.py > email.txt
$ python3 display_email.py email.txt
From: Test Sender <sender@example.com>
To: recipient@example.com
Date: Tue, 25 Mar 2014 17:14:01 -0400
Subject: Test Message, Chapter 12

Hello,
This is a basic message from Chapter 12.
 - Anonymous

但即使是最复杂的信息对它来说也不过分。在重新出现电子邮件正文的 HTML 版本之前,get_body()逻辑成功地进入混合的多部分外层,进入可选的多部分中间层,最后甚至深入到消息的相关多部分内部。此外,还会检查包含的每个附件。

$ python3 build_mime_email.py -i attachment.txt attachment.gz > email.txt
$ python3 display_email.py email.txt
From: Test Sender <sender@example.com>
To: Test Recipient <recipient@example.com>
Date: Tue, 25 Mar 2014 17:14:01 -0400
Subject: Foundations of Python Network Programming

Hello,
This is a MIME message from Chapter 12.
- Anonymous

* image/gif attachment named 'blue-dot.gif': bytes object of length 35
* text/plain attachment named 'attachment.txt': str object of length 15
* application/octet-stream attachment named 'attachment.gz': bytes object of length 33

行走的哑剧角色

如果清单 12-4 中的逻辑最终不能满足您的应用——如果它不能找到您的项目需要能够解析的特定电子邮件的正文,或者如果您的客户需要访问的某些指定不当的附件被跳过——那么您将需要退回到自己访问电子邮件消息的每一部分,并实现您自己的算法来决定哪些部分要显示,哪些部分要保存为附件,哪些部分要忽略或丢弃。

当分解一封 MIME 邮件时,有三个基本规则要记住。

  • 在检查一个部分时,您的第一个调用应该是对is_multipart()方法的调用,以确定您正在检查的 MIME 部分是否是其他 MIME 子部分的容器。如果您想要主类型和子类型之间有斜杠的完全限定类型,您也可以调用get_content_type() ,如果您只关心其中的一半,则可以调用get_content_maintype()get_content_subtype()
  • 当遇到一个多部分时,使用iter_parts()方法循环或获取紧接在它下面的部分,这样您就可以依次发现哪些子部分本身是多部分的,哪些只是包含内容。
  • 当检查一个普通部件时,Content-Disposition 头将告诉您它是否是一个附件(在头的值中的任何分号之前寻找单词 attachment )。
  • 调用get_content()方法解码并返回 MIME 部分中的数据本身,作为文本str或二进制bytes对象,这取决于主内容类型是否为text

清单 12-5 中的代码使用递归生成器来访问多部分消息的每一部分。生成器的操作与内置walk()方法的操作类似,除了该生成器会跟上每个子部分的索引,以防以后需要获取。

清单 12-5 。手动访问多部分方法的每个部分

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/display_structure.py

import argparse, email.policy, sys

def walk(part, prefix=''):
    yield prefix, part
    for i, subpart in enumerate(part.iter_parts()):
        yield from walk(subpart, prefix + '.{}'.format(i))

def main(binary_file):
    policy = email.policy.SMTP
    message = email.message_from_binary_file(binary_file, policy=policy)
    for prefix, part in walk(message):
        line = '{} type={}'.format(prefix, part.get_content_type())
        if not part.is_multipart():
            content = part.get_content()
            line += ' {} len={}'.format(type(content).__name__, len(content))
            cd = part['Content-Disposition']
            is_attachment = cd and cd.split(';')[0].lower() == 'attachment'
            if is_attachment:
                line += ' attachment'
            filename = part.get_filename()
            if filename is not None:
                line += ' filename={!r}'.format(filename)
        print(line)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Display MIME structure')
    parser.add_argument('filename', nargs='?', help='File containing an email')
    args = parser.parse_args()
    if args.filename is None:
        main(sys.stdin.buffer)
    else:
        with open(args.filename, 'rb') as f:
            main(f)

您可以针对早期脚本可以生成的任何电子邮件来使用该脚本。(当然,你也可以试着给它发一封你自己的电子邮件。)对使用上述脚本生成的最复杂的消息运行它会产生以下结果。

$ python3 build_mime_email.py -i attachment.txt attachment.gz > email.txt
$ python3 display_structure.py email.txt
 type=multipart/mixed
.0 type=multipart/alternative
.0.0 type=multipart/related
.0.0.0 type=text/html str len=215
.0.0.1 type=image/gif bytes len=35 attachment filename='blue-dot.gif'
.0.1 type=text/plain str len=59
.1 type=text/plain str len=15 attachment filename='attachment.txt'
.2 type=application/octet-stream bytes len=33 attachment filename='attachment.gz'

介绍每行输出的部件号可以在您编写的其他代码中使用,以便通过向get_payload()方法提供每个整数索引来直接进入消息以获取您感兴趣的特定部件。例如,如果您想从该邮件中获取蓝点 GIF 图像,您可以调用:

part = message.get_payload(0).get_payload(0).get_payload(1)

请再次注意,只有多部分部分才允许包含更多的 MIME 子部分。具有非多部分内容类型的每个部分都是上面树中的一个叶节点,包含简单的内容,下面没有进一步的与电子邮件相关的结构。

标题编码

由于有了email模块,上面的解析脚本将正确处理使用 RFC 2047 惯例编码特殊字符的国际化报头,而无需任何修改。清单 12-6 生成这样一封电子邮件,您可以用它来执行测试。注意,因为 Python 3 的源代码默认是 UTF-8 编码的,所以可以包含国际字符,而不需要像 Python 2 那样在顶部声明-*- coding: utf-8 -*-

清单 12-6 。生成一封国际化的电子邮件来测试解析脚本

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/build_unicode_email.py

import email.message, email.policy, sys

text = """\
Hwær cwom mearg? Hwær cwom mago?
Hwær cwom maþþumgyfa?
Hwær cwom symbla gesetu?
Hwær sindon seledreamas?"""

def main():
    message = email.message.EmailMessage(email.policy.SMTP)
    message['To'] = 'Böðvarr <recipient@example.com>'
    message['From'] = 'Eardstapa <sender@example.com>'
    message['Subject'] = 'Four lines from The Wanderer'
    message['Date'] = email.utils.formatdate(localtime=True)
    message.set_content(text, cte='quoted-printable')
    sys.stdout.buffer.write(message.as_bytes())

if __name__ == '__main__':
    main()

由于 To:标头中包含特殊字符,因此输出电子邮件对二进制数据使用特殊的 ASCII 编码。此外,根据前面给出的建议,请注意,通过为正文指定一个quoted-printable内容编码,您避免了生成一个 Base64 数据块,而是用它们的直接 ASCII 代码来表示大多数字符,如下面的结果所示。

To: =?utf-8?b?QsO2w7B2YXJy?= <recipient@example.com>
From: Eardstapa <sender@example.com>
Subject: Four lines from The Wanderer
Date: Fri, 28 Mar 2014 22:11:48 -0400
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
MIME-Version: 1.0

Hw=C3=A6r cwom mearg? Hw=C3=A6r cwom mago?
Hw=C3=A6r cwom ma=C3=BE=C3=BEumgyfa?
Hw=C3=A6r cwom symbla gesetu?
Hw=C3=A6r sindon seledreamas?

显示脚本成功地解决了所有这些问题,因为email模块为我们完成了所有的解码和处理。

$ python3 build_unicode_email.py > email.txt
$ python3 display_email.py email.txt
From: Eardstapa <sender@example.com>
To: Böðvarr <recipient@example.com>
Date: Tue, 25 Mar 2014 17:14:01 -0400
Subject: Four lines from The Wanderer

Hwær cwom mearg? Hwær cwom mago?
Hwær cwom maþþumgyfa?
Hwær cwom symbla gesetu?
Hwær sindon seledreamas?

如果您想进一步研究电子邮件头编码,请阅读较低级别的email.header模块的 Python 文档,特别是它的Header类。

解析日期

符合标准的日期通过email.utils中的formatdate()函数在上面的脚本中使用,默认情况下使用当前日期和时间。但是也可以为它们提供一个低级的 Unix 时间戳。如果您正在进行更高级别的日期操作,并且已经生成了一个datetime对象,那么只需使用format_datetime()函数来进行同样的格式化。

当解析一封邮件时,可以通过email.utils中的另外三个方法执行相反的操作。

  • parsedate()parsedate_tz()都通过其time模块返回 Python 在底层支持的时间元组,遵循旧的 C 语言惯例进行日期算术和表示。
  • 现代的parsedate_to_datetime()函数返回一个完整的datetime对象,这可能是您在大多数产品代码中想要进行的调用。

请注意,许多电子邮件程序在编写日期头时没有严格遵循相关标准,尽管这些例程试图宽容一些,但可能会出现它们无法生成有效日期值并返回None的情况。在假设您已经返回了一个日期之前,您需要检查这个值。下面是几个调用示例。

>>> from email import utils
>>> utils.parsedate('Tue, 25 Mar 2014 17:14:01 -0400')
(2014, 3, 25, 17, 14, 1, 0, 1, -1)
>>> utils.parsedate_tz('Tue, 25 Mar 2014 17:14:01 -0400')
(2014, 3, 25, 17, 14, 1, 0, 1, -1, -14400)
>>> utils.parsedate_to_datetime('Tue, 25 Mar 2014 17:14:01 -0400')
datetime.datetime(2014, 3, 25, 17, 14, 1,
                  tzinfo=datetime.timezone(datetime.timedelta(-1, 72000)))

如果您打算对日期进行任何运算,我强烈建议您研究第三方的pytz模块,它已经成为社区中关于日期操作的最佳实践。

摘要

r .大卫·穆雷在 Python 3.4 中引入的强大的email.message.EmailMessage类使得 MIME 消息的生成和消费比以前的 Python 版本方便得多。像往常一样,唯一的警告是密切注意字节和字符串之间的区别。尝试将整个套接字或文件 I/O 作为字节,并让email模块完成所有自己的编码,这样每一步都能正确完成。

电子邮件通常通过实例化EmailMessage然后指定标题和内容来生成。通过将消息视为具有不区分大小写的字符串键的字典来设置头,字符串值存储在字典中,如果字符串值的任何字符是非 ASCII 字符,则在输出时会对其进行正确编码。内容是通过级联的四种方法设置的— set_content()add_related()add_alternative()add_attachment()—在所有情况下都能正确处理文本和字节有效载荷。

通过运行任何一个email模块的解析函数(message_from_binary_file()是本章清单中使用的方法),可以将一个电子邮件消息作为一个EmailMessage对象读回并进行检查,并使用一个策略参数打开EmailMessage类的所有现代特性。每个结果对象要么是一个多部分,其中包含更多的子部分,要么是 Python 以字符串或字节数据形式返回的一段空白内容。

标头在输出和输入时会自动国际化和解码。特殊日期头的格式受email.utils中的方法支持,这些方法允许您的代码使用现代 Python datetime对象的实例来读写它的值。

下一章将专门研究 SMTP 协议在电子邮件传输中的应用。

十三、SMTP

如第十二章开头所述,电子邮件在系统间的实际移动是通过简单的邮件传输协议 SMTP 完成的。它于 1982 年在 RFC 821 中首次定义;SMTP 的最新 RFC 定义是 RFC 5321。该协议通常有两个作用:

  1. 当用户在笔记本电脑或台式机上键入电子邮件时,电子邮件客户端使用 SMTP 将电子邮件提交给服务器,服务器可以将电子邮件发送到目的地。
  2. 电子邮件服务器本身使用 SMTP 来传递消息,通过互联网将每条消息从一个服务器发送到另一个服务器,直到它到达负责收件人电子邮件地址的(电子邮件地址中@符号后的部分)的服务器。

SMTP 用于提交和传递的方式有几个不同之处。然而,在讨论它们之前,我将快速概述使用本地电子邮件客户端查看电子邮件的用户和使用 ?? 网络邮件服务的用户之间的区别。

电子邮件客户端与网络邮件服务

如果我追溯一下用户使用互联网电子邮件的历史,SMTP 在消息提交中的作用可能是最不容易混淆的。

要理解的关键概念是,用户从来没有被要求坐下来等待电子邮件真正送达。在电子邮件真正送达目的地之前,这一过程通常要花费相当多的时间,并且要反复尝试几十次。许多事情都可能导致延迟:一条消息可能必须等待,因为其他消息已经在有限带宽的链路上传输,目标服务器可能停机几个小时,或者其网络可能由于故障而无法访问。如果电子邮件的目的地是一个大的组织,如一所大学,当它到达大的大学服务器时,可能必须经过几个不同的“跳”,然后被定向到大的大学中一个特定学院的较小的电子邮件机器,最后被传送到系里的电子邮件服务器。

因此,理解当用户点击发送时会发生什么,本质上就是理解完成的电子邮件消息是如何被提交到几个电子邮件队列中的第一个的,它可以在队列中等待,直到环境正好适合它的传递发生。(这将在下一节电子邮件传递中讨论。)

一开始是命令行

第一代电子邮件用户的用户名和密码是由他们的公司或大学提供的,这使他们能够通过命令行访问保存用户文件和通用程序的大型主机。每台大型机器通常运行一个电子邮件守护进程,维护一个传出队列;就在同一个盒子上,用户正忙着用小的命令行电子邮件程序输入信息。几个这样的项目各有其鼎盛时期;紧随其后的是更高级的mailx,然后是界面更漂亮、功能更强大的elmpine,最后是mutt

SMTP 协议

目的:向服务器发送电子邮件

标准:RFC 2821

在顶层运行:TCP 或 TLS

端口号:53

库:smtplib

但是对于所有这些早期用户来说,网络甚至没有参与简单的电子邮件提交任务;毕竟,电子邮件客户端和服务器在同一台机器上!弥合这一微小差距和执行电子邮件提交的实际方法仅仅是一个实现细节,通常隐藏在命令行客户端程序之后,该程序与服务器软件一起提供,确切地知道如何与之通信。第一个广泛使用的电子邮件守护进程sendmail,是由一个名为/usr/lib/sendmail的提交电子邮件的程序带来的。

因为第一代用于读写电子邮件的客户端程序是为与sendmail交互而设计的,所以后来流行起来的电子邮件守护程序,如qmailpostfixexim,通常会通过提供自己的sendmail二进制文件(由于最近的文件系统标准,它的正式名称现在是/usr/sbin)来效仿,当用户的电子邮件程序调用该二进制文件时,它会遵循特定的电子邮件守护程序自己的特殊过程来将消息移入队列。

当一封电子邮件到达时,它通常被存放在一个属于邮件收件人的文件中。运行在命令行上的电子邮件客户端可以简单地打开这个文件并解析它,以查看等待用户阅读的消息。本书不涉及这些邮箱格式,因为它的重点是电子邮件如何使用网络。然而,如果您很好奇,您可以查看 Python 标准库中的mailbox包,它支持多年来各种电子邮件程序向磁盘读写消息的所有奇怪和好奇的方式。

客户的崛起

开始使用互联网的下一代用户通常不熟悉命令行的概念。用户熟练使用苹果麦金塔(Apple Macintosh)的图形界面,或者后来出现的微软视窗(Microsoft Windows)操作系统,他们希望通过点击图标和运行图形程序来完成任务。因此,许多不同的电子邮件客户端被编写出来,将这种互联网服务带到了桌面上。Mozilla Thunderbird 和微软 Outlook 是目前仍在使用的最受欢迎的客户端中的两个。

这种方法的问题显而易见。首先,阅读收到的电子邮件从电子邮件程序的简单任务(以前只能打开本地文件并阅读)转变为现在需要网络连接的操作。当你打开你的图形化电子邮件程序时,它不得不通过互联网到达一个全职服务器,当你不在的时候,它代表你接收电子邮件,并把电子邮件带到本地机器。

其次,用户因没有正确备份他们的台式机和笔记本电脑文件系统而臭名昭著,而客户端下载并在本地存储消息,因此当笔记本电脑或台式机硬盘崩溃时,这些消息很容易被删除。相比之下,大学和工业服务器——尽管它们的命令行笨拙——通常有一小群人专门负责保持数据的存档、复制和安全。

第三,笔记本电脑和台式机通常不适合作为电子邮件服务器及其待发邮件队列的环境。毕竟,用户经常在用完电脑后关掉它们,断开网络连接,或者离开网吧,失去无线信号。发出的邮件通常需要在线多花一些时间来完成重试和最终传输,因此完成的电子邮件需要通过某种方式提交回全职服务器进行排队和发送。

但是程序员是聪明人,他们想出了一系列解决这些问题的办法。首先,发明了新的协议——首先是邮局协议(POP) ,我将在第十四章的中讨论,然后是互联网消息访问协议(IMAP) ,这将在第十五章中介绍——它让用户的电子邮件客户端通过密码进行认证,并从存储电子邮件的全职服务器下载电子邮件。密码是必要的,以防止其他人连接到您的互联网服务提供商的服务器和阅读您的电子邮件!这解决了第一个问题。

但是第二个问题呢,坚持;即避免台式机和笔记本电脑硬盘崩溃时丢失电子邮件?这激发了两组进展。首先,使用 POP 的人经常学会关闭其默认模式,在这种模式下,服务器上的电子邮件一旦被下载就被删除,他们学会在服务器上留下重要电子邮件的副本,如果他们不得不重新安装计算机并从头开始,他们可以从服务器上再次获取电子邮件。其次,他们开始转向 IMAP,如果他们的电子邮件服务器确实选择支持这种更高级的协议的话。使用 IMAP 意味着他们不仅可以将收到的电子邮件留在服务器上妥善保管,还可以将邮件整理到服务器上的文件夹中。这使他们能够将他们的电子邮件客户端程序仅仅作为一个浏览电子邮件的窗口,电子邮件本身仍然存储在服务器上,而不必管理他们的笔记本电脑或台式机上的电子邮件存储区。

最后,当用户写完一封电子邮件并单击 Send 时,电子邮件如何返回到服务器?这个任务——再次正式称为电子邮件提交——把我带回了本章的主题;也就是说,电子邮件提交使用 SMTP 协议进行。但是,正如我将要解释的那样,SMTP 通常有两个不同之处,一个是在因特网上的服务器之间使用的,另一个是在客户端提交电子邮件时使用的,这两个不同之处都是由现代对抗垃圾邮件的需要所驱动的。首先,大多数 ISP 会阻止从笔记本电脑和台式机到端口 25 的传出 TCP 连接,这样这些小型机器就不会被病毒劫持并用作电子邮件服务器。相反,电子邮件提交通常被定向到端口 587。第二,为了防止垃圾邮件发送者连接到您的 ISP 并声称他们想要从您这里发送邮件,电子邮件客户端使用包含用户用户名和密码的认证 SMTP

通过这些机制,电子邮件被带到了桌面上——无论是在像大学和企业这样的大型组织中,还是在面向家庭用户的 ISP 中。向每个用户提供说明,告诉他们:

  • 安装一个电子邮件客户端,如雷鸟或 Outlook。
  • 输入可以从中提取电子邮件的主机名和协议。
  • 配置发送服务器的名称和 SMTP 端口号。
  • 分配用户名和密码,使用该用户名和密码可以对两个服务的连接进行身份验证。

尽管电子邮件客户端配置起来很麻烦,服务器也很难维护,但它们最初是使用熟悉的图形界面向盯着彩色大显示器的新一代用户提供电子邮件的唯一方式。如今,他们允许用户有令人羡慕的选择自由:他们的 ISP 只需决定是支持 POP、IMAP,还是两者都支持,而用户(或者至少是非企业用户!)可以自由地尝试各种电子邮件客户端,并选定他们最喜欢的一个。

转移到 Webmail

最后,互联网上又发生了一次世代交替。用户曾经不得不下载并安装大量的客户端来体验互联网所提供的一切。许多有经验的读者会记得,他们在 Windows 或 Mac 机器上最终安装了各种协议的客户机程序,如 Telnet、FTP、Gopher 目录服务、新闻组新闻组,以及万维网。(Unix 用户通常会发现,当他们第一次登录到一台配置良好的机器时,已经安装了每个基本协议的客户端,尽管他们可能会选择安装一些更高级的替代程序,如ncftp来代替笨重的默认 FTP 客户端。)

但是,再也不会了!如今,普通的互联网用户只知道一个客户端:他们的网络浏览器。由于网页现在可以在用户点击键盘时使用 JavaScript 来响应和重绘自己,网络不仅取代了所有传统的互联网协议——用户在网页上浏览和获取文件,而不是通过 FTP 他们阅读留言板,而不是连接到新闻组——但这也消除了对许多传统桌面客户端的需求。如果你的应用是可以通过交互式网页提供的,为什么要说服成千上万的用户下载并安装一个新的电子邮件客户端,点击几个关于你的软件可能如何损害他们的计算机的警告呢?

事实上,网络浏览器已经变得如此卓越,以至于许多互联网用户甚至没有意识到他们已经有了一个网络浏览器。因此,他们交替使用“互联网”和“网络”这两个词,他们认为这两个词都指“所有给我脸书、YouTube 和维基百科的文件和链接”这种对他们正在通过某个特定的有名字和身份的客户端程序——比如说,通过 Internet Explorer 的窗格——来查看 Web 荣耀的事实的忽视,是 Firefox、Google Chrome 和 Opera 等替代产品的传播者的一个持续挫折,他们发现很难说服人们改变他们甚至没有意识到他们正在使用的程序!

显然,如果这样的用户要阅读电子邮件,就必须在网页上呈现给他们,他们在网页上阅读收到的电子邮件,将其分类到文件夹中,并撰写和发送回复。因此有许多网站通过浏览器提供电子邮件服务——Gmail 和 Yahoo!邮件是最受欢迎的,还有服务器软件,如流行的 SquirrelMail,如果系统管理员想为学校或公司的用户提供网络邮件,他们可以安装这些软件。

这种转变对电子邮件协议和网络意味着什么?有趣的是,网络邮件现象本质上把我们从过去的简单日子带回到过去,那时电子邮件提交和电子邮件阅读是私人事务,局限于单个主机服务器,通常根本不涉及使用公共协议。当然,这些现代服务,尤其是由大型互联网服务提供商以及谷歌和雅虎这样的公司运营的服务。必须是庞大的事务,涉及分布在世界各地的数百台服务器;因此,毫无疑问,网络协议涉及到电子邮件存储和检索的每一个层面。

但问题是,这些现在是私人交易,在运行网络邮件服务的组织内部进行。你在网络浏览器中浏览电子邮件;你用同样的界面写电子邮件;当你点击发送时,谁知道谷歌或雅虎用的是什么协议呢?在内部使用将新消息从接收您的 HTTP POST 的 web 服务器传递到邮件队列,并从该队列中进行传递。可能是 SMTP 它可以是内部 RPC 协议;或者它甚至可以是 web 和电子邮件服务器都连接到的公共文件系统上的操作。

就本书的目的而言,重要的是,除非你是在这样一个机构工作的工程师,否则你永远也不会看到在你用来操纵你的信息的网络邮件界面背后,是 POP、IMAP 还是其他什么东西在起作用。

因此,电子邮件的浏览和提交变成了一个黑匣子:您的浏览器与 web API 交互,而在另一端,当电子邮件在各个方向传递时,您将看到普通的旧式 SMTP 连接从大型组织发出并发往该组织。但是在 webmail 的世界中,客户端协议被从等式中删除,将我们带回到纯服务器到服务器的未经认证的 SMTP 的旧时代。

如何使用 SMTP

前面的叙述有望帮助你构建关于互联网电子邮件协议的思维。幸运的话,它还帮助你认识到它们如何在更大的范围内相互配合,从用户那里获取信息。

然而,这一章的主题是一个比较狭窄的主题——简单邮件传输协议。首先,我会用你在本书第一部分学到的术语来陈述基础知识:

  • SMTP 是基于 TCP/IP 的协议。
  • 连接可以经过身份验证,也可以不经过身份验证。
  • 连接可以加密,也可以不加密。

如今,互联网上的大多数电子邮件连接似乎缺乏任何加密尝试,这意味着无论谁拥有互联网主干路由器,理论上都能够读取他人数量惊人的电子邮件。根据上一节的讨论,使用 SMTP 的两种方式是什么?

首先,SMTP 可以用于像 Thunderbird 或 Outlook 这样的客户端电子邮件程序和已经给了用户电子邮件地址的组织的服务器之间的电子邮件提交。这些连接通常使用身份验证,因此垃圾邮件发送者无法在没有密码的情况下代表用户连接和发送数百万条消息。一旦接收到一条消息,服务器就把它放在队列中等待发送,这样电子邮件客户端就可以忘记这条消息,并认为服务器会继续尝试发送这条消息。

第二,SMTP 用于互联网电子邮件服务器之间的,因为它们将电子邮件从其起点移动到目的地。这通常不涉及身份验证;毕竟,像谷歌、雅虎这样的大公司。,而微软不知道对方用户的密码,所以当雅虎!收到一封来自 Google 的电子邮件,声称它是由一个@gmail.com用户 Yahoo!只要相信他们(或者不信——有时如果太多的垃圾邮件通过他们的服务器,组织会将彼此列入黑名单。当 Hotmail 的电子邮件服务器因为所谓的垃圾邮件问题而停止接受来自 GoDaddy 服务器的电子邮件简讯时,我的一个朋友就遇到了这种情况。

因此,通常在使用 SMTP 的服务器之间不会进行身份验证——甚至很少使用针对窥探路由器的加密。

由于垃圾邮件发送者连接到电子邮件服务器并声称从另一个组织的用户发送电子邮件的问题,已经尝试锁定哪些特定的服务器可以代表一个组织发送电子邮件。尽管有争议,但一些电子邮件服务器参考 RFC 4408 中定义的发件人策略框架(SPF) ,以查看它们与之对话的服务器是否真的有权传递它正在传输的电子邮件。

让我们转到技术问题,即如何在 Python 程序中实际使用 SMTP。图 13-1 提供了一个 Python 驱动的 SMTP 会话的例子。

9781430258544_Fig13-01.jpg

图 13-1 。Python 驱动的 SMTP 会话示例

发送电子邮件

在分享 SMTP 协议的本质细节之前,有一个警告是适当的:如果您正在编写一个需要发送电子邮件的交互式程序、守护程序或网站,那么您的站点或系统管理员(在不是您的情况下)可能会对您的程序如何发送电子邮件有意见,他们这样做可能会为您节省很多工作!

如前所述,成功发送电子邮件通常需要一个队列,在该队列中,消息可以停留几秒、几分钟甚至几天,直到它可以成功地传输到其目的地。因此,你通常希望你的前端程序使用 Python 的smtplib将电子邮件直接发送到消息的目的地,因为如果你的第一次传输尝试失败了,那么你将不得不写一个完整的邮件传输代理(MTA ),因为 RFC 调用电子邮件服务器,并给它一个完整的符合标准的重试队列。这不仅是一项艰巨的工作,而且也是一项已经做得很好的工作,在尝试自己写东西之前,明智的做法是利用现有的 MTA 之一(看看postfixeximqmail)。

您很少会通过 Python 与外界建立 SMTP 连接。更常见的情况是,您的系统管理员会告诉您两件事情中的一件:

  • 您应该使用属于您的应用的用户名和密码,与您组织中已经存在的电子邮件服务器建立一个经过身份验证的 SMTP 连接。
  • 你应该在系统上运行一个本地二进制程序——比如sendmail程序——系统管理员已经进行了配置,以便本地程序可以发送电子邮件。

Python 库 FAQ 有调用一个sendmail兼容程序的示例代码。看看“如何从 Python 脚本发送邮件”一节。发现于http://docs.python.org/faq/library.html

因为这本书是关于网络的,所以我不会详细讨论这种可能性。然而,记住,当你的机器上没有更简单的发送电子邮件的机制时,你自己只做原始的 SMTP。

标题和信封收件人

SMTP 中的一个关键概念一直让初学者感到困惑,那就是你所熟悉的收件人标题——To、Cc(抄送)和 Bcc(密件抄送)——并没有被 SMTP 协议咨询来决定你的电子邮件的去向。这让许多用户感到惊讶。毕竟,几乎所有现存的电子邮件程序都要求你填写收件人字段,当你点击发送时,邮件就会飞向这些邮箱。还有什么比这更自然的呢?但事实证明,这是电子邮件客户端本身的特性,而不是 SMTP 协议的特性:协议只知道每封邮件周围都有一个“信封”,标明了发件人和一些收件人。SMTP 本身并不关心这些名称是否是它可以在邮件头中找到的名称。

如果你考虑一下密件抄送标题,你会发现电子邮件必须以这种方式工作。与“收件人”和“抄送”标题不同,“密件抄送”标题可以将邮件发送到目的地,并让每个收件人看到其他收件人,而“密件抄送”标题可以在其他收件人不知道的情况下指定您希望接收电子邮件的人。密件可以让您悄悄地将邮件传递给某人,而不会引起其他收件人的注意。

像“密件抄送”这样的邮件头在您撰写邮件时可能会出现,但实际上不会包含在外发邮件中,这种邮件头的存在提出了两点:

  • 您的电子邮件客户端会在发送邮件前编辑邮件标题。除了删除“密件抄送”邮件头以使电子邮件的收件人无法获得其副本之外,客户端通常还会添加邮件头,例如唯一的邮件 ID,可能还有电子邮件客户端本身的名称(例如,我刚刚在桌面上收到的一封电子邮件,将发送它的 X-Mailer 标识为 YahooMailClassic)。
  • 一封电子邮件可以通过 SMTP 到达一个在邮件头或正文中没有提到的目的地址(?? )--------------------------------------------------------------------------------它可以出于最合理的理由这样做。

这种机制也有助于支持电子邮件列表,这样,To 行显示为advocacy@python.org的电子邮件就可以实际发送给订阅该列表的数十或数百人,而不需要向列表的每个读者公开他们的所有电子邮件地址。

因此,当您阅读下面对 SMTP 的描述时,请时刻提醒自己,构成电子邮件本身的标题加正文与协议描述中提到的“信封发送者”和“信封接收者”是分开的。没错,你的电子邮件客户端,不管你用的是/usr/sbin/sendmail还是雷鸟或者谷歌邮箱,很可能只向你要过一次收件人的邮箱地址;但是它接着在两个不同的地方使用了它:一次是在邮件顶部的“收件人”标题中,另一次是在邮件的“外部”,当它使用 SMTP 来发送邮件时。

多跳

曾几何时,电子邮件通常只通过一个 SMTP“跳”在主机和存储收件人收件箱的机器之间传递。如今,信息在到达目的地之前,通常要经过六七台或更多的服务器。这意味着上一节中描述的 SMTP 信封收件人会随着邮件接近其目的地而不断变化。

举个例子应该能说明这一点。以下几个细节是虚构的,但它们应该能让您很好地了解消息实际上是如何在互联网上传输的。

想象一下,佐治亚理工学院中央 IT 部门的一名员工告诉他的朋友他的电子邮件地址是brandon@gatech.edu。当朋友后来给他发消息时,朋友的电子邮件提供商会在域名服务(DNS;参见第四章,接收一系列 MX 记录作为回复,并连接到其中一个 IP 地址来传递消息。很简单,对吧?

但是gatech.edu的服务器服务于整个校园!为了找出brandon在哪里,它查询一个表,找到他的部门,并得知他的官方电子邮件地址实际上是:

brandon.rhodes@oit.gatech.edu

因此,gatech.edu服务器依次对oit.gatech.edu进行 DNS 查询,然后使用 SMTP——消息的第二个 SMTP 跳,如果你算的话——将消息发送到 OIT 的电子邮件服务器,信息技术办公室。

但是 OIT 很久以前就放弃了他们的单服务器解决方案,该方案将所有电子邮件保存在一台 Unix 服务器上。相反,他们现在运行一个复杂的电子邮件解决方案,用户可以通过 webmail、POP 和 IMAP 访问它。到达oit.gatech.edu的电子邮件首先被随机发送到几个垃圾邮件过滤服务器中的一个(第三跳),比如说名为spam3.oit.gatech.edu的服务器。然后,如果它通过了垃圾邮件检查并且没有被丢弃,它会被随机地发送到八个冗余的电子邮件服务器中的一个,这样在第四次跳跃之后,邮件就在mail7.oit.gatech.edu的队列中了。

mail7这样的路由服务器随后可以查询中央目录服务,以确定哪些连接到大型磁盘阵列的后端邮件存储托管哪些用户的邮箱。因此mail7brandon.rhodes进行 LDAP 查找,得出的结论是他的电子邮件保存在anvil.oit.gatech.edu服务器上,在第五次也是最后一次 SMTP 跳跃中,电子邮件被发送到anvil并被写入其冗余磁盘阵列。

这就是为什么电子邮件通常至少需要几秒钟才能通过互联网:大型组织和大型互联网服务提供商往往有几级服务器,邮件在发送前必须经过这些服务器的协商。

你如何调查一封电子邮件的路径?前面已经强调过,SMTP 协议不读取电子邮件标题,但是它自己知道消息应该去往哪里——正如您刚才看到的,它可以随着消息到达目的地的每一跳而改变。但是事实证明,电子邮件服务器被鼓励添加新的标题,精确地跟踪信息从其起点到目的地的迂回路线。

这些标题被称为 Received 标题,对于试图调试电子邮件系统问题的困惑的系统管理员来说,它们是一座金矿。看看任何一封电子邮件,让你的电子邮件客户端显示所有的邮件头。您应该能够看到消息到达目的地的每一步。(垃圾邮件发送者通常在邮件的顶部写几个虚构的 Received 标头,使邮件看起来像是来自一个声誉良好的组织。)最后,当链中的最后一个服务器最终能够成功地将消息写入某人邮箱中的物理存储时,可能会写入一个 Delivered-to 标头。

因为每个服务器倾向于将其接收的报头添加到电子邮件消息的顶部,这节省了时间,并且避免了每个服务器必须搜索到目前已经写入的接收的报头的底部。你应该倒着读:最早收到的邮件头会列在最后,所以当你在屏幕上向上读的时候,你会跟着邮件从起点到终点。试试看:调出您最近收到的一封电子邮件,选择它的“查看所有邮件标题”或“显示原始邮件”选项,并在靠近顶部的位置查找已收到的邮件标题。邮件到达收件箱所需的步骤比您预期的多还是少?

SMTP 库简介

Python 的内置 SMTP 实现在 Python 标准库模块smtplib中,这使得用 SMTP 做简单的任务变得很容易。

在下面的例子中,程序被设计成接受几个命令行参数:SMTP 服务器的名称、发件人地址和一个或多个收件人地址。请谨慎使用;只说出一个你自己运行的或者你知道会乐意接收你的测试信息的 SMTP 服务器,以免你的 IP 地址因为发送垃圾邮件而被禁止!

如果你不知道在哪里可以找到 SMTP 服务器,你可以试着在本地运行一个电子邮件守护进程,比如postfixexim,然后将这些示例程序指向localhost。一些 UNIX、Linux 和 Mac OS X 系统有一个这样的 SMTP 服务器,已经在监听来自本地机器的连接。

否则,请咨询您的网络管理员或互联网提供商以获得正确的主机名和端口。请注意,您通常不能随意选择电子邮件服务器;许多仅存储或转发来自特定授权客户的电子邮件。

解决了这个问题,您就可以继续看清单 13-1 了,它展示了一个非常简单的 SMTP 程序。

清单 13-1 。使用smtplib.sendmail()发送电子邮件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/simple.py

import sys, smtplib

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the simple.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("usage: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    connection = smtplib.SMTP(server)
    connection.sendmail(fromaddr, toaddrs, message)
    connection.quit()

    s = '' if len(toaddrs) == 1 else 's'
    print("Message sent to {} recipient{}".format(len(toaddrs), s))

if __name__ == '__main__':
    main()

这个程序非常简单,因为它使用了 Python 标准库中一个非常强大的通用函数。它首先从用户的命令行参数生成一条简单的消息(关于生成包含简单纯文本之外的元素的花哨消息的详细信息,参见第十二章)。然后它创建一个连接到指定服务器的smtplib.SMTP对象。最后,所需要的就是调用sendmail()。如果成功返回,那么您就知道电子邮件服务器已经无误地接受了邮件。

正如本章前面提到的,你可以看到谁接收消息的概念——“信封收件人”——在这个层次上,与消息的实际文本是分开的。这个特定的程序写入一个 To 头,这个头恰好包含它发送消息的相同地址;但是 To 头只是一段文本,它可以表示其他任何内容。(收件人的电子邮件客户端是否愿意显示“其他任何内容”,或者是否会导致服务器将该邮件作为垃圾邮件丢弃,这是另一个问题!)

如果你从书的网络操场内部运行程序,它应该能够成功地像这样连接:

$ python3 simple.py mail.example.com sender@example.com recipient@example.com
Message successfully sent to 1 recipient

感谢 Python 标准库的作者为sendmail()方法付出的努力,这可能是您需要的唯一一个 SMTP 调用!但是,为了理解传递消息所采取的步骤,让我们更详细地研究一下 SMTP 是如何工作的。

错误处理和对话调试

使用smtplib编程时,可能会出现几种不同的异常。它们是:

  • socket.gaierror查找地址信息时出现错误
  • socket.error对于一般网络和通信问题
  • socket.herror对于其他寻址错误
  • smtplib.SMTPException或它的一个子类,用于 SMTP 会话问题

前三个错误在第三章中有更详细的介绍;它们在操作系统的 TCP 栈中被提出,被 Python 的网络代码检测并作为异常提出,然后直接通过smtplib模块到达你的程序。然而,只要底层 TCP 套接字工作,所有实际涉及 SMTP 电子邮件会话的问题都会导致一个smtplib.SMTPException

smtplib模块还提供了一种获取一系列关于发送电子邮件步骤的详细信息的方法。要启用该详细级别,您可以调用以下选项:

connection.set_debuglevel(1)

使用这个选项,您应该能够跟踪任何问题。请看一下清单 13-2 中的示例程序,它提供了基本的错误处理和调试。

清单 13-2 。更加谨慎的 SMTP 客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/debug.py

import sys, smtplib, socket

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the debug.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("usage: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    try:
        connection = smtplib.SMTP(server)
        connection.set_debuglevel(1)
        connection.sendmail(fromaddr, toaddrs, message)
    except (socket.gaierror, socket.error, socket.herror,
            smtplib.SMTPException) as e:
        print("Your message may not have been sent!")
        print(e)
        sys.exit(1)
    else:
        s = '' if len(toaddrs) == 1 else 's'
        print("Message sent to {} recipient{}".format(len(toaddrs), s))
        connection.quit()

if __name__ == '__main__':
    main()

这个程序看起来和上一个类似;但是,输出会很不一样。看一下清单 13-3 中的例子。

清单 13-3 。调试来自smtplib的输出

$ python3 debug.py mail.example.com sender@example.com recipient@example.com
send: 'ehlo [127.0.1.1]\r\n'
reply: b'250-guinness\r\n'
reply: b'250-SIZE 33554432\r\n'
reply: b'250 HELP\r\n'
reply: retcode (250); Msg: b'guinness\nSIZE 33554432\nHELP'
send: 'mail FROM:<sender@example.com> size=212\r\n'
reply: b'250 OK\r\n'
reply: retcode (250); Msg: b'OK'
send: 'rcpt TO:<recipient@example.com>\r\n'
reply: b'250 OK\r\n'
reply: retcode (250); Msg: b'OK'
send: 'data\r\n'
reply: b'354 End data with <CR><LF>.<CR><LF>\r\n'
reply: retcode (354); Msg: b'End data with <CR><LF>.<CR><LF>'
data: (354, b'End data with <CR><LF>.<CR><LF>')
send: b'To: recipient@example.com\r\nFrom: sender@example.com\r\nSubject: Test Message from simple.py\r\n\r\nHello,\r\n\r\nThis is a test message sent to you from the debug.py program\r\nin Foundations of Python Network Programming.\r\n.\r\n'
reply: b'250 OK\r\n'
reply: retcode (250); Msg: b'OK'
data: (250, b'OK')
send: 'quit\r\n'
reply: b'221 Bye\r\n'
reply: retcode (221); Msg: b'Bye'
Message sent to 1 recipient

从这个例子中,您可以看到smtplib正在通过网络与 SMTP 服务器进行对话。当您实现使用更高级 SMTP 特性的代码时,这里显示的细节将会更重要,所以让我们看看发生了什么。

首先,客户端(smtplib库)发送一个EHLO命令(一个更古老的命令的“扩展”后继命令,更容易理解的名字是HELO),其中包含您的主机名。远程服务器用自己的主机名进行响应,并列出它支持的任何可选 SMTP 功能。

接下来,客户端发送mail from命令,该命令说明了“信封发送者”的电子邮件地址和邮件的大小。这时的服务器有机会拒绝消息(比如因为它认为你是垃圾邮件发送者);但是在这种情况下,它用250 Ok来响应。(注意,在这种情况下,代码250是重要的;剩下的文本只是人们可读的注释,并且因服务器而异。)

然后客户端发送一个带有“信封接收者”的rcpt to命令,我在本章前面已经讨论过了。您最终可以看到,在使用 SMTP 协议时,它确实是与消息本身的文本分开传输的。如果您要将消息发送给多个收件人,他们将分别列在rcpt to行上。

最后,客户机发送一个data命令,传输实际的消息(您会注意到,按照互联网电子邮件标准,使用冗长的回车换行行尾),并结束对话。

在本例中,smtplib模块会自动为您完成所有这些工作。在本章的其余部分,我将解释如何更好地控制这个过程,以利用一些更高级的特性。

Image 注意不要有一种错误的自信感,因为在第一跳中没有检测到错误,所以你确信消息现在保证被传递。在许多情况下,电子邮件服务器可能会接受一封邮件,只是在稍后的时间传递失败。重读“多跳”一节,想象在示例消息到达目的地之前有多少失败的可能性!

EHLO 获取信息

有时,知道远程 SMTP 服务器将接受哪种类型的邮件是一件好事。例如,大多数 SMTP 服务器对它们允许的邮件大小有限制,如果您没有先检查,那么您可能会传输一个非常大的邮件,但当您完成传输时,它会被拒绝。

在最初的 SMTP 版本中,客户端会发送一个HELO命令作为对服务器的初始问候。SMTP 的一组扩展,称为 ESMTP,已经被开发出来以允许更强大的对话。ESMTP 感知的客户端将与EHLO开始对话,这将向 ESMTP 感知的服务器发出信号,它可以用扩展信息进行回复。该扩展信息包括最大邮件大小,以及服务器支持的任何可选 SMTP 功能。

但是,您必须小心检查返回代码。一些服务器不支持 ESMTP。在那些服务器上,EHLO只会返回一个错误。在这种情况下,您必须发送一个HELO命令。

在前面的例子中,我在创建 SMTP 对象后立即使用了sendmail(),因此smtplib自动向服务器发送自己的“hello”消息,为您启动对话。但是如果它看到你试图自己发送EHLOHELO命令,那么 Python 的sendmail()方法将不会试图自己发送 hello 命令。

清单 13-4 显示了一个从服务器获取最大大小的程序,如果消息太大,它会在发送前返回一个错误。

清单 13-4 。检查邮件大小限制

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/ehlo.py

import smtplib, socket, sys

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the ehlo.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("usage: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    try:
        connection = smtplib.SMTP(server)
        report_on_message_size(connection, fromaddr, toaddrs, message)
    except (socket.gaierror, socket.error, socket.herror,
            smtplib.SMTPException) as e:
        print("Your message may not have been sent!")
        print(e)
        sys.exit(1)
    else:
        s = '' if len(toaddrs) == 1 else 's'
        print("Message sent to {} recipient{}".format(len(toaddrs), s))
        connection.quit()

def report_on_message_size(connection, fromaddr, toaddrs, message):
    code = connection.ehlo()[0]
    uses_esmtp = (200 <= code <= 299)
    if not uses_esmtp:
        code = connection.helo()[0]
        if not (200 <= code <= 299):
            print("Remote server refused HELO; code:", code)
            sys.exit(1)

    if uses_esmtp and connection.has_extn('size'):
        print("Maximum message size is", connection.esmtp_features['size'])
        if len(message) > int(connection.esmtp_features['size']):
            print("Message too large; aborting.")
            sys.exit(1)

    connection.sendmail(fromaddr, toaddrs, message)

if __name__ == '__main__':
    main()

如果您运行此程序,并且远程服务器提供了它的最大消息大小,则该程序将在您的屏幕上显示该大小,并在发送之前验证它的消息没有超过该大小。(对于像这样的小消息,检查是相当愚蠢的,但是清单展示了可以成功用于大得多的消息的模式。)

下面是运行这个程序的样子:

$ python3 ehlo.py mail.example.com sender@example.com recipient@example.com
Maximum message size is 33554432
Message successfully sent to 1 recipient

看一下验证调用ehlo()helo()的结果的代码部分。这两个函数返回一个列表;列表中的第一项是来自远程 SMTP 服务器的数字结果代码。结果在 200 和 299 之间,包括 200 和 299,表示成功;其他一切都表明失败。因此,如果结果在该范围内,您就知道服务器正确地处理了消息。

Image 注意事项与之前相同的注意事项也适用于此。第一个 SMTP 服务器接受邮件的事实并不意味着它将被实际传递;较新的服务器可能有更严格的最大大小限制。

除了消息大小,其他 ESMTP 信息也是可用的。例如,如果提供了8BITMIME功能,一些服务器可能接受原始 8 位模式的数据。其他的可能支持加密,如下一节所述。有关 ESMTP 及其功能(可能因服务器而异)的更多信息,请参考 RFC 1869 或您自己的服务器文档。

使用安全套接字层和传输层实现安全

如前所述,通过 SMTP 以纯文本形式发送的电子邮件可以被任何人阅读,只要他能够访问数据包碰巧经过的互联网网关或路由器,包括咖啡店的无线网络,您的电子邮件客户端可能试图从该网络发送邮件。这个问题的最佳解决方案是用一个公钥加密每封电子邮件,该公钥的私钥只由您要向其发送电子邮件的人拥有;有像 GNU 隐私卫士这样的免费系统可以做到这一点。但是不管消息本身是否受到保护,特定机器对之间的单独 SMTP 对话可以使用 SSL/TLS 进行加密和认证,如第六章中介绍的那样。在本节中,您将了解 SSL/TLS 如何适应 SMTP 对话。

请记住,TLS 只保护选择使用它的 SMTP“跃点”——即使您小心地使用 TLS 向服务器发送电子邮件,如果该服务器必须将您的电子邮件通过另一个跃点转发到其目的地,您也无法控制该服务器是否再次使用 TLS。

在 SMTP 中使用 TLS 的一般过程如下:

  1. 照常创建 SMTP 对象。
  2. 发送EHLO命令。如果远程服务器不支持EHLO,那么它将不会支持 TLS。
  3. 检查s.has_extn()以查看starttls是否存在。如果不支持,则远程服务器不支持 TLS,消息只能以明文形式正常发送。
  4. 构建一个 SSL 上下文对象来验证服务器的身份。
  5. 调用starttls()启动加密通道。
  6. 第二次呼叫ehlo();这次,它被加密了。
  7. 最后,发送您的消息。

使用 TLS 时,您必须问自己的第一个问题是,如果 TLS 不可用,您是否应该返回一个错误。根据您的应用,您可能希望在下列任何情况下引发错误:

  • 远程端不支持 TLS。
  • 远程端无法正确建立 TLS 会话。
  • 远程服务器提供了一个无法验证的证书。

让我们逐一查看这些场景,看看它们何时应该出现错误消息。

首先,有时将缺乏对 TLS 的支持完全视为一种错误是恰当的。如果您正在编写一个只与有限的一组电子邮件服务器通信的应用,可能会出现这种情况,这些服务器可能是由您的公司运行的、您知道应该支持 TLS 的电子邮件服务器,或者是由您知道支持 TLS 的机构运行的电子邮件服务器。

因为今天互联网上只有少数电子邮件服务器支持 TLS,所以一般来说,电子邮件程序不应该将它的缺失视为错误。许多支持 TLS 的 SMTP 客户端将使用 TLS(如果可用的话),但在其他情况下将依靠标准的、不安全的传输。这就是所谓的机会加密,它没有强制加密所有通信安全,但它可以在有能力时保护消息。

其次,有时远程服务器声称知道 TLS,但却无法正确建立 TLS 连接。这通常是由于服务器端的配置错误。为了尽可能地健壮,您可能希望通过一个您甚至没有尝试加密的新连接来重试到这样一个服务器的失败的加密传输。

第三种情况是,您无法完全认证远程服务器。同样,关于对等验证的完整讨论,请参见第六章。如果您的安全策略规定您必须只与受信任的服务器交换电子邮件,那么缺少身份验证显然是一个问题,需要一个错误消息。

清单 13-5 充当一个支持 TLS 的通用客户端。如果可以的话,它将连接到服务器并使用 TLS 否则,它将退回并照常发送消息。如果在与表面上有能力的服务器对话时启动 TLS 的尝试失败,它报错而死。

清单 13-5 。机会主义地使用 TLS

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/tls.py

import sys, smtplib, socket, ssl

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the tls.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("Syntax: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    try:
        connection = smtplib.SMTP(server)
        send_message_securely(connection, fromaddr, toaddrs, message)
    except (socket.gaierror, socket.error, socket.herror,
            smtplib.SMTPException) as e:
        print("Your message may not have been sent!")
        print(e)
        sys.exit(1)
    else:
        s = '' if len(toaddrs) == 1 else 's'
        print("Message sent to {} recipient{}".format(len(toaddrs), s))
        connection.quit()

def send_message_securely(connection, fromaddr, toaddrs, message):
    code = connection.ehlo()[0]
    uses_esmtp = (200 <= code <= 299)
    if not uses_esmtp:
        code = connection.helo()[0]
        if not (200 <= code <= 299):
            print("Remove server refused HELO; code:", code)
            sys.exit(1)

    if uses_esmtp and connection.has_extn('starttls'):
        print("Negotiating TLS....")
        context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
        context.set_default_verify_paths()
        context.verify_mode = ssl.CERT_REQUIRED
        connection.starttls(context=context)
        code = connection.ehlo()[0]
        if not (200 <= code <= 299):
            print("Couldn't EHLO after STARTTLS")
            sys.exit(5)
        print("Using TLS connection.")
    else:
        print("Server does not support TLS; using normal connection.")

    connection.sendmail(fromaddr, toaddrs, message)

if __name__ == '__main__':
    main()

注意,不管是否使用了 TLS,最后几个清单中对sendmail()的调用是相同的。一旦启动了 TLS,系统就会对您隐藏这一层复杂性,因此您无需担心。

经过验证的 SMTP

最后,还有认证 SMTP 的话题,在他们允许你发送电子邮件之前,你的 ISP、大学或公司的电子邮件服务器需要你用用户名和密码登录,以证明你不是垃圾邮件发送者。

为了获得最大的安全性,TLS 应该与身份验证结合使用;否则,任何观察连接的人都可以看到您的密码(和用户名)。正确的做法是首先建立 TLS 连接,然后只通过加密的通信信道发送您的身份验证信息。

认证本身很简单;smtplib提供了一个接受用户名和密码的login()函数。清单 13-6 显示了一个例子。为了避免重复前面清单中已经显示过的代码,这个清单没有而不是采纳上一段中提供的建议,它通过一个未经认证的连接发送用户名和密码,这个连接将明文发送它们。

清单 13-6 。通过 SMTP 认证

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/login.py

import sys, smtplib, socket
from getpass import getpass

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the login.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("Syntax: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    username = input("Enter username: ")
    password = getpass("Enter password: ")

    try:
        connection = smtplib.SMTP(server)
        try:
            connection.login(username, password)
        except smtplib.SMTPException as e:
            print("Authentication failed:", e)
            sys.exit(1)
        connection.sendmail(fromaddr, toaddrs, message)
    except (socket.gaierror, socket.error, socket.herror,
            smtplib.SMTPException) as e:
        print("Your message may not have been sent!")
        print(e)
        sys.exit(1)
    else:
        s = '' if len(toaddrs) == 1 else 's'
        print("Message sent to {} recipient{}".format(len(toaddrs), s))
        connection.quit()

if __name__ == '__main__':
    main()

Internet 上的大多数发送电子邮件服务器不支持身份验证。如果您使用的服务器不支持认证,您将会收到来自login()尝试的认证失败错误消息。如果远程服务器支持 ESMTP,你可以通过调用connection.ehlo()后检查connection.has_extn('auth')来防止这种情况。

您可以像前面的例子一样运行这个程序。如果在支持身份验证的服务器上运行,系统会提示您输入用户名和密码。如果它们被接受,那么程序将继续传送你的信息。

SMTP 提示

以下是一些帮助您实现 SMTP 客户端的提示:

  • 没有办法保证消息被传递。有时,您会立即知道您的尝试失败了,但是没有错误并不意味着在消息被安全地传递给接收者之前,其他事情不会出错。
  • 如果的任何一个接收者失败了,那么sendmail()函数会引发一个异常,尽管消息可能仍然被发送给了其他接收者。查看您返回的异常以了解更多详细信息。如果知道哪些地址失败的细节对您来说非常重要——比方说,因为您希望稍后尝试重新传输,而不为已经收到该消息的人制作副本——您可能需要为每个收件人单独调用sendmail()。但是,请注意,这种更简单的方法会导致消息体被多次传输,每个收件人一次。
  • 没有证书验证,SSL/TLS 是不安全的:在验证发生之前,您可以与任何临时控制标准服务器 IP 地址的旧服务器进行对话。为了支持证书验证,请记住创建一个 SSL 上下文对象,如 TLS 前面的示例所示,并将其作为唯一的参数提供给starttls()
  • Python 的smtplib并不意味着是一个通用的电子邮件中继。相反,您应该使用它将消息发送到您附近的 SMTP 服务器,该服务器将处理电子邮件的实际传递。

摘要

SMTP 用于将电子邮件传输到电子邮件服务器。Python 提供了smtplib模块供 SMTP 客户端使用。通过调用 SMTP 对象的sendmail()方法,可以传输消息。指定消息的实际接收者的唯一方法是使用sendmail()的参数;邮件正文中的“收件人”、“抄送”和“密件抄送”邮件头与实际的收件人列表是分开的。

SMTP 会话期间可能会引发几种不同的异常。交互程序应该适当地检查和处理它们。

ESMTP 是 SMTP 的扩展。它允许您在传输邮件之前发现远程 SMTP 服务器支持的最大邮件大小。ESMTP 也允许 TLS,这是一种加密你与远程服务器对话的方法。TLS 的基础知识包含在第六章中。

一些 SMTP 服务器需要身份验证。可以用login()方法认证。SMTP】不提供从邮箱下载邮件到自己电脑的功能。为此,您将需要接下来两章中讨论的协议。第十四章中讨论的 POP 是下载信息的一种简单方式。在第十五章中讨论的 IMAP 是一个更强大的协议。

十四、POP

邮局协议 POP ,是一个从服务器下载电子邮件的简单协议。它通常通过电子邮件客户端使用,如 Thunderbird 或 Outlook。如果你想了解电子邮件客户端和像 POP 这样的协议在互联网电子邮件历史中的位置,你可以重读第十三章的前几节。

如果你很想用 POP,那么你应该考虑用 IMAP 来代替;第十五章将解释 IMAP 提供的特性,这些特性使它成为比 POP 支持的原始操作更坚实的远程电子邮件访问基础。

POP 最常见的实现是版本 3,通常称为 POP3。因为版本 3 是如此占主导地位,POP 和 POP3 这两个术语在今天实际上是可以互换的。

POP 的主要优点——也是它最大的缺点——是它的简单。如果您只是需要访问远程邮箱,下载任何新出现的电子邮件,并在下载后选择删除电子邮件,那么 POP 将是您的完美选择。您将能够快速完成这项任务,无需复杂的代码。

但是下载和删除几乎是 POP 的全部功能。它不支持远程端的多个邮箱,也不提供任何可靠、持久的消息标识。这意味着您不能使用 POP 作为电子邮件同步的协议,即您将每封电子邮件的原始副本留在服务器上,同时制作一份副本供本地读取,因为当您稍后返回到服务器时,您无法轻易辨别您已经下载了哪些邮件。如果你需要这个功能,你应该看看 IMAP,这将在第十五章中介绍。

Python 标准库提供了poplib模块,为使用 POP 提供了便捷的接口。本章将解释如何使用poplib连接到 POP 服务器,收集关于邮箱的摘要信息,下载邮件,以及从服务器上删除邮件原件。一旦您知道如何完成这四项任务,您将涵盖所有标准的 POP 功能!

请注意,Python 标准库不提供充当 POP 服务器的功能,而只提供客户端功能。如果您需要实现一个服务器,您将需要找到一个提供 POP 服务器功能的第三方 Python 包。

POP 服务器兼容性

众所周知,POP 服务器不遵守标准。对于一些 POP 行为来说,标准也根本不存在,把细节留给了服务器软件的作者。因此,虽然基本的操作通常可以正常工作,但某些行为确实会因服务器而异。

例如,一些服务器会在您连接到服务器时将您的所有邮件标记为已读——无论您是否下载了任何邮件!其他服务器会在下载邮件时将其标记为只读。有些服务器根本不会将任何邮件标记为已读。标准本身似乎假设了后一种行为,但两者都不清楚。阅读本章时,请记住这些不同之处。

图 14-1 展示了一个由 Python 驱动的非常简单的 POP 对话。

9781430258544_Fig14-01.jpg

图 14-1 。使用 POP 的简单对话

连接和认证

POP 支持多种身份验证方法。最常见的两种是基本用户名-密码验证和 APOP,后者是 POP 的可选扩展,如果您使用的是不支持 SSL 的老式 POP 服务器,它有助于防止密码以纯文本形式发送。

Python 中连接和验证远程服务器的过程如下所示:

  1. 创建一个POP3_SSL或者只是一个普通的POP3对象,并将远程主机名和端口传递给它。
  2. 调用user()pass_()发送用户名和密码。注意pass_()中的下划线!它之所以存在,是因为pass是 Python 中的一个关键字,不能用于方法名。
  3. 如果引发异常poplib.error_proto,则意味着登录失败,异常的字符串值包含服务器发送的错误解释。

POP3POP3_SSL之间的选择取决于你的电子邮件提供商是否提供——或者,在这个时代,甚至要求——你通过加密连接进行连接。查阅第六章以获得更多关于 SSL 的信息,但是一般规则应该是只要可行就使用 SSL。

清单 14-1 使用上述步骤登录到远程 POP 服务器。一旦连接上,它就调用stat(),返回一个简单的元组,给出邮箱中的消息数量和消息的总大小。最后,程序调用quit(),关闭 POP 连接。

POP-3 协议

目的:允许从收件箱下载电子邮件

标准:RFC 1939(1996 年 5 月)

运行于:TCP/IP 之上

默认端口:110(明文),995 (SSL)

`库:弹出式菜单

清单 14-1 。非常简单的流行音乐会

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter14/popconn.py

import getpass, poplib, sys

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        exit(2)

    hostname, username = sys.argv[1:]
    passwd = getpass.getpass()

    p = poplib.POP3_SSL(hostname)  # or "POP3" if SSL is not supported
    try:
        p.user(username)
        p.pass_(passwd)
    except poplib.error_proto as e:
        print("Login failed:", e)
    else:
        status = p.stat()
        print("You have %d messages totaling %d bytes" % status)
    finally:
        p.quit()

if __name__ == '__main__':
    main()

Image 注意虽然这个程序不会改变任何消息,但是一些 POP 服务器会仅仅因为你连接就改变邮箱标志。对实时邮箱运行本章中的示例可能会导致您丢失有关已读、未读、新邮件或旧邮件的信息。不幸的是,这种行为依赖于服务器,并且不受 POP 客户端的控制。我强烈建议对测试邮箱运行这些示例,而不是对您的真实邮箱!

这个程序有两个命令行参数:POP 服务器的主机名和用户名。如果您不知道此信息,请联系您的互联网提供商或网络管理员。请注意,在某些服务中,您的用户名将是一个普通字符串(如guido),而在其他服务中,它将是您的完整电子邮件地址(guido@example.com)。

然后,程序会提示您输入密码。最后,它将显示邮箱的状态,而不接触或改变你的任何邮件。

下面是你如何在 Mininet playground 中运行程序,你可以从本书的源代码库中下载(见第一章第一节):

$ python3 popconn.py mail.example.com brandon
Password: abc123
You have 3 messages totaling 5675 bytes

如果您看到这样的输出,那么您的第一次 POP 对话就成功了!

当 POP 服务器不支持 SSL 来保护您的连接免受窥探时,它们有时至少支持一种称为 APOP 的替代认证协议,该协议使用挑战-响应方案来确保您的密码不会以明文形式发送。(但是,您的所有电子邮件仍然会被任何看到数据包经过的第三方看到!)Python 标准库使这种尝试变得非常容易:只需调用apop()方法,然后如果您与之对话的 POP 服务器不理解,就退回到基本认证。

要使用 APOP,但回到简单的认证,您可以在您的 POP 程序中使用类似于清单 14-2 中所示的一段代码(如清单 14-1 中的)。

清单 14-2 。尝试 APOP 和后退

print("Attempting APOP authentication...")
try:
p.apop(user, passwd)
except poplib.error_proto:
print("Attempting standard authentication...")
try:
p.user(user)
p.pass_(passwd)
except poplib.error_proto as e:
print("Login failed:", e)
sys.exit(1)

Image 注意无论何种方式只要登录成功,一些较老的 POP 服务器就会锁定邮箱。锁定可能意味着不能对邮箱进行任何更改,甚至意味着在锁定解除之前不能再发送任何电子邮件。问题是一些 POP 服务器不能正确地检测错误,如果你没有调用quit()就挂断了连接,它们会无限期地锁定一个盒子。曾经世界上最流行的 POP 服务器就属于这一类!因此,在结束 POP 会话时,在 Python 程序中始终调用quit()是至关重要的。您会注意到,这里显示的所有程序清单总是小心翼翼地quit()到 Python 保证最后执行的finally块中。

获取邮箱信息

前面的例子向您展示了stat(),它返回邮箱中的邮件数量及其总大小。另一个有用的 POP 命令是list(),它返回每条消息的更多详细信息。

最有趣的部分是消息编号,稍后检索消息时需要用到它。请注意,在消息编号中可能会有间隙:例如,在给定时刻,一个邮箱可能只包含消息编号 1、2、5、6 和 9。此外,在您与 POP 服务器的每次连接中,分配给特定邮件的号码可能会有所不同。

清单 14-3 展示了如何使用list()命令来显示每条消息的信息。

清单 14-3 。使用POP list()命令

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter14/mailbox.py

import getpass, poplib, sys

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        exit(2)

    hostname, username = sys.argv[1:]
    passwd = getpass.getpass()

    p = poplib.POP3_SSL(hostname)
    try:
        p.user(username)
        p.pass_(passwd)
    except poplib.error_proto as e:
        print("Login failed:", e)
    else:
        response, listings, octet_count = p.list()
        if not listings:
            print("No messages")
        for listing in listings:
            number, size = listing.decode('ascii').split()
            print("Message %s has %s bytes" % (number, size))
    finally:
        p.quit()

if __name__ == '__main__':
    main()

list()函数返回一个包含三项的元组。你一般应该注意第二项。这是目前我的一个 POP 邮箱的原始输出,其中有三条消息:

('+OK 3 messages (5675 bytes)', ['1 2395', '2 1626', '3 1654'], 24)

第二个项目中的三个字符串给出了收件箱中三封邮件的邮件号和大小。清单 14-3 中执行的简单解析让它以更漂亮的格式呈现输出。下面是你如何在部署在书的网络游乐场内部的 POP 服务器上运行它(见第一章):

$ python3 mailbox.py mail.example.com brandon
Password: abc123
Message 1 has 354 bytes
Message 2 has 442 bytes
Message 3 has 1173 bytes

下载和删除邮件

您现在应该已经掌握了 POP 的诀窍:当使用poplib时,您发出一些小的原子命令,这些命令总是返回一个元组,元组内部是各种字符串和字符串列表,向您显示结果。您现在实际上已经准备好处理消息了!三种相关方法都使用由list()返回的相同整数标识符来标识消息,如下所示:

  • retr(num) :该方法下载一条消息,并返回一个包含结果代码和消息本身的元组,以行列表的形式传递。这将导致大多数 POP 服务器将该邮件的“已读”标志设置为“真”,禁止您再次从 POP 中看到该邮件(除非您有其他方法进入您的邮箱,让您将邮件设置回“未读”)。
  • top(num, body_lines) :该方法以与retr()相同的格式返回结果,而不将消息标记为“已看到”但是它并没有返回整个消息,而是只返回标题以及您在body_lines中请求的正文的行数。如果您想让用户决定下载哪些邮件,这对于预览邮件很有用。
  • dele(num) :该方法将消息标记为从 POP 服务器中删除,在您退出 POP 会话时发生。通常,只有当用户直接请求不可撤销地销毁消息时,或者如果您已经将消息存储到冗余存储中(并且可能已经备份了它),并且已经使用类似于fsync()的东西来确保数据确实已经被写入,您才会这样做,因为您将再也无法从服务器中检索消息。

为了把所有的东西放在一起,看一下清单 14-4 ,这是一个功能相当强大的电子邮件客户端,说 POP!它检查你的收件箱,以确定有多少信息,并学习他们的号码;然后它使用top()来提供每一个的预览;而且,根据用户的选择,它可以检索整个邮件并将其从邮箱中删除。

清单 14-4 。一个简单的 POP 电子邮件阅读器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter14/download-and-delete.py

import email, getpass, poplib, sys

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        exit(2)

    hostname, username = sys.argv[1:]
    passwd = getpass.getpass()

    p = poplib.POP3_SSL(hostname)
    try:
        p.user(username)
        p.pass_(passwd)
    except poplib.error_proto as e:
        print("Login failed:", e)
    else:
        visit_all_listings(p)
    finally:
        p.quit()

def visit_all_listings(p):
    response, listings, octets = p.list()
    for listing in listings:
        visit_listing(p, listing)

def visit_listing(p, listing):
    number, size = listing.decode('ascii').split()
    print('Message', number, '(size is', size, 'bytes):')
    print()
    response, lines, octets = p.top(number, 0)
    document = '\n'.join(line.decode('ascii') for line in lines)
    message = email.message_from_string(document)
    for header in 'From', 'To', 'Subject', 'Date':
        if header in message:
            print(header + ':', message[header])
    print()
    print('Read this message [ny]?')
    answer = input()
    if answer.lower().startswith('y'):
        response, lines, octets = p.retr(number)
        document = '\n'.join(line.decode('ascii') for line in lines)
        message = email.message_from_string(document)
        print('-' * 72)
        for part in message.walk():
            if part.get_content_type() == 'text/plain':
                print(part.get_payload())
                print('-' * 72)
    print()
    print('Delete this message [ny]?')
    answer = input()
    if answer.lower().startswith('y'):
        p.dele(number)
        print('Deleted.')

if __name__ == '__main__':
    main()

你会注意到清单使用了在第十二章的中引入的email模块,这是一个很大的优势,因为即使是带有 HTML 和图像的花哨的现代 MIME 电子邮件通常也有一个text/plain部分,email模块可以代表这样一个简单的程序提取该部分以打印到屏幕上。

如果你在书中的网络游戏中运行这个程序(参见第一章,你会看到类似下面的输出:

$ python3 download-and-delete.py mail.example.com brandon
password: abc123
Message 1 (size is 354 bytes):

From: Administrator <admin@mail.example.com>
To: Brandon <brandon@mail.example.com>
Subject: Welcome to example.com!

Read this message [ny]? y
------------------------------------------------------------------------
We are happy that you have chosen to use example.com's industry-leading
Internet e-mail service and we hope that you experience is a pleasant
one.  If you ever need your password reset, simply contact our staff!

- example.com
------------------------------------------------------------------------

Delete this message [ny]? y
Deleted.

摘要

POP 提供了一种下载存储在远程服务器上的电子邮件的简单方法。使用 Python 的poplib接口,您可以获得关于邮箱中邮件数量和每封邮件大小的信息。您也可以按号码检索或删除单个留言。

连接到 POP 服务器可能会锁定邮箱。因此,尽量缩短 POP 会议,并在会议结束后打电话给quit()是很重要的。

只要有可能,POP 应该与 SSL 一起使用,以保护您的密码和电子邮件的内容。在没有 SSL 的情况下,尝试至少使用 APOP;只有在你迫切需要使用 POP 并且没有更好的选项可用的可怕情况下,才发送你的密码。

尽管 POP 是一种简单且广泛使用的协议,但它有许多缺点,不适合某些应用。例如,它只能访问一个文件夹,并且不提供对单个邮件的持久跟踪。

下一章将讨论 IMAP,这是一个提供 POP 特性和许多新特性的协议。`

十五、IMAP

乍一看,互联网消息访问协议(IMAP)类似于第十四章中描述的 POP 协议。另外,如果你读了《??》第十三章第三部分的第一部分,它提供了电子邮件如何在互联网上传输的全貌,你就会知道这两种协议扮演了一个非常相似的角色:POP 和 IMAP 是笔记本电脑或台式电脑连接到远程互联网服务器查看和处理用户电子邮件的两种方式。

相似之处到此为止。尽管 POP 的功能相当贫乏——用户可以将新邮件下载到他们的个人电脑上——但 IMAP 协议提供了如此全面的功能,许多用户将他们的电子邮件分类并永久存档在服务器上,使其免受笔记本电脑或台式机硬盘崩溃的影响。IMAP 优于 POP 的优势包括:

  • 邮件可以被分类到几个文件夹中,而不是放在一个收件箱里。
  • 每封邮件都支持标志,如“已读”、“已回复”、“已查看”和“已删除”
  • 可以在服务器上搜索邮件中的文本字符串,而不必下载每一个。
  • 本地存储的消息可以直接上传到其中一个远程文件夹。
  • 维护持久的唯一消息编号,使得本地消息存储库和服务器上保存的消息之间的健壮同步成为可能。
  • 文件夹可以与其他用户共享,也可以标记为只读。
  • 一些 IMAP 服务器可以显示非邮件源,如新闻组新闻组,就好像它们是电子邮件文件夹一样。
  • IMAP 客户端可以有选择地下载邮件的一部分,例如,抓取特定的附件或只抓取邮件头,而不必等待下载邮件的其余部分。

综上所述,这些特性意味着 IMAP 可以用于比 POP 支持的简单的下载-删除痉挛更多的操作。许多电子邮件阅读器,如 Thunderbird 和 Outlook,可以显示 IMAP 文件夹,这样它们就可以像本地存储的文件夹一样工作。当用户点击一封邮件时,电子邮件阅读器从 IMAP 服务器下载并显示它,而不必预先下载所有的邮件;读者也可以同时设置消息的“已读”标志。

IMAP 协议

目的:阅读、整理和删除电子邮件文件夹中的电子邮件

标准:RFC 3501 (2003)

运行于:TCP/IP 之上

默认端口:143(明文),993 (SSL)

库:imaplib,IMAPClient

Exceptions : socket.error, socket.gaierror, IMAP4.error,
IMAP4.abort, IMAP4.readonly

IMAP 客户端也可以与 IMAP 服务器同步。某个即将出差的人可能会将 IMAP 文件夹下载到笔记本电脑上。然后,在路上,电子邮件可能被阅读、删除或回复,用户的电子邮件程序将记录这些行为。当笔记本电脑最终重新连接到网络时,他们的电子邮件客户端可以用已经在本地设置的相同“已读”或“已回复”标志来标记服务器上的邮件,并且可以继续从服务器上删除已经在本地删除的邮件,以便用户不会看到它们两次。

结果是 IMAP 相对于 POP 的最大优势之一:用户可以从他们所有的笔记本电脑和台式机上看到相同状态的相同电子邮件。POP 用户只能多次看到相同的电子邮件(如果他们告诉他们的电子邮件客户端将电子邮件留在服务器上),或者每封邮件将只下载一次到他们碰巧阅读该邮件的机器上(如果电子邮件客户端删除了该邮件),这意味着他们的电子邮件将分散在他们检查邮件的所有机器上。IMAP 用户避免了这种困境。

当然,IMAP 也可以和 POP 完全一样的方式使用——下载邮件,存储在本地,并立即从服务器上删除邮件——对于那些不想要或不需要它的高级功能的人来说。

有几种版本的 IMAP 协议可用。最近的,也是目前最流行的,被称为 IMAP4rev1 。事实上,术语“IMAP”现在通常与 IMAP4rev1 同义。本章假设所有 IMAP 服务器都是 IMAP4rev1 服务器。非常旧的 IMAP 服务器很少见,可能不支持本章讨论的所有功能。

您还可以通过以下链接访问关于编写 IMAP 客户端的入门教程:

www.dovecot.org/imap-client-coding-howto.html
www.imapwiki.org/ClientImplementation

如果您正在做的事情不仅仅是编写一个小型的、单一用途的客户端来汇总收件箱中的邮件或自动下载附件,那么您应该彻底阅读上述资源中的信息,或者如果您想要更彻底的参考资料,请阅读关于 IMAP 的书籍,以便您可以正确处理在不同的服务器及其 IMAP 实现中可能遇到的所有情况。本章将教授基础知识,重点是如何最好地从 Python 进行连接。

理解 Python 中的 IMAP

Python 标准库包含一个名为imaplib的 IMAP 客户端接口,它提供了对协议的基本访问。不幸的是,它仅限于知道如何发送请求和将它们的响应传递回您的代码。它没有真正尝试实现 IMAP 规范中解析返回数据的详细规则。作为一个从imaplib返回的值通常太原始而不能在程序中使用的例子,看看清单 15-1 。这是一个简单的脚本,它使用imaplib连接到一个 IMAP 帐户,列出服务器公布的“功能”,然后显示由LIST命令返回的状态代码和数据。

清单 15-1 。连接到 IMAP 并列出文件夹

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/open_imaplib.py
# Opening an IMAP connection with the pitiful Python Standard Library

import getpass, imaplib, sys

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        sys.exit(2)

    hostname, username = sys.argv[1:]
    m = imaplib.IMAP4_SSL(hostname)
    m.login(username, getpass.getpass())
    try:
        print('Capabilities:', m.capabilities)
        print('Listing mailboxes ')
        status, data = m.list()
        print('Status:', repr(status))
        print('Data:')
        for datum in data:
            print(repr(datum))
    finally:
        m.logout()

if __name__ == '__main__':
    main()

如果您使用适当的参数运行这个脚本,它将首先询问您的密码;IMAP 身份验证几乎总是通过用户名和密码来完成:

$ python open_imaplib.py imap.example.com brandon@example.com
Password:

如果您的密码是正确的,那么它将显示一个类似于清单 15-2 中所示结果的响应。正如所承诺的,您将首先看到“功能”,它列出了该服务器支持的 IMAP 特性。而且,我必须承认,这个列表的类型非常 Pythonic 化:无论列表在网络上是什么形式,它都被转换成了一个令人愉快的字符串元组。

清单 15-2 。前面清单的输出示例

Capabilities: ('IMAP4REV1', 'UNSELECT', 'IDLE', 'NAMESPACE', 'QUOTA',
 'XLIST', 'CHILDREN', 'XYZZY', 'SASL-IR', 'AUTH=XOAUTH')
Listing mailboxes
Status: 'OK'
Data:
b'(\\HasNoChildren) "/" "INBOX"'
b'(\\HasNoChildren) "/" "Personal"'
b'(\\HasNoChildren) "/" "Receipts"'
b'(\\HasNoChildren) "/" "Travel"'
b'(\\HasNoChildren) "/" "Work"'
b'(\\Noselect \\HasChildren) "/" "[Gmail]"'
b'(\\HasChildren \\HasNoChildren) "/" "[Gmail]/All Mail"'
b'(\\HasNoChildren) "/" "[Gmail]/Drafts"'
b'(\\HasChildren \\HasNoChildren) "/" "[Gmail]/Sent Mail"'
b'(\\HasNoChildren) "/" "[Gmail]/Spam"'
b'(\\HasNoChildren) "/" "[Gmail]/Starred"'
b'(\\HasChildren \\HasNoChildren) "/" "[Gmail]/Trash"'

但是当你转向list()方法的结果时,事情就分崩离析了。首先,您将返回一个普通字符串'OK'形式的状态代码,因此使用imaplib的代码必须不停地检查代码是否为'OK'或者它是否指示一个错误。这并不是可怕的 Python,因为 Python 程序通常可以不做错误检查就运行,并且是安全的,因为它知道如果出现任何错误,就会抛出异常。

其次,imaplib对解释结果没有任何帮助!该 IMAP 帐户中的电子邮件文件夹列表使用各种特定于协议的引号:列表中的每个项目命名每个文件夹上设置的标志,然后指定用于分隔文件夹和子文件夹的字符(在本例中为斜杠字符),最后提供带引号的文件夹名称。但是所有这些都返回到原始数据,需要您解释如下所示的字符串:

(\HasChildren \HasNoChildren) "/" "[Gmail]/Sent Mail"

第三,输出是不同序列的混合:标志仍然是未解释的字节串,而每个分隔符和文件夹名已经被解码为真正的 Unicode 字符串。

因此,除非您想自己实现协议的几个细节,否则您将需要一个更强大的 IMAP 客户端库。

IMAPClient

幸运的是,确实存在一个流行的、经过实战检验的 Python IMAP 库,可以从 Python 包索引中轻松安装。友好的 Python 程序员 Menno Smits 编写了 IMAPClient 包,它实际上在幕后使用 Python 标准库imaplib来完成工作。

如果你想试用 IMAPClient,试着把它安装在一个“virtualenv”中,如第一章所述。一旦安装完毕,你可以在虚拟环境中使用python解释器来运行程序,如清单 15-3 所示。

清单 15-3 。用 IMAPClient 列出 IMAP 文件夹

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/open_imap.py
# Opening an IMAP connection with the powerful IMAPClient

import getpass, sys
from imapclient import IMAPClient

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        sys.exit(2)

    hostname, username = sys.argv[1:]
    c = IMAPClient(hostname, ssl=True)
    try:
        c.login(username, getpass.getpass())
    except c.Error as e:
        print('Could not log in:', e)
    else:
        print('Capabilities:', c.capabilities())
        print('Listing mailboxes:')
        data = c.list_folders()
        for flags, delimiter, folder_name in data:
            print('  %-30s%s %s' % (' '.join(flags), delimiter, folder_name))
    finally:
        c.logout()

if __name__ == '__main__':
    main()

从代码中您可以立即看到,现在正在代表您处理协议交换的更多细节。例如,您不再得到每次运行命令都必须检查的状态代码;相反,库会替你做检查,如果有任何问题,它会抛出一个异常来阻止你。图 15-1 提供了一个 Python 和 IMAP 服务器之间的对话示例。

9781430258544_Fig15-01.jpg

图 15-1 。Python 和 IMAP 服务器之间的对话示例

其次,您可以看到来自LIST命令的每一个结果——在这个库中是作为list_folders()方法提供的,而不是由imaplib提供的list()方法——已经被解析成 Python 数据类型。每行数据作为一个元组返回,为您提供文件夹标志、文件夹名称分隔符和文件夹名称,标志本身是一个字符串序列。

查看清单 15-4 中的,看看第二个脚本的输出是什么样子。

清单 15-4 。正确解析的标志和文件夹名称

Capabilities: ('IMAP4REV1', 'UNSELECT', 'IDLE', 'NAMESPACE', 'QUOTA', 'XLIST', 'CHILDREN', 'XYZZY', 'SASL-IR', 'AUTH=XOAUTH')
Listing mailboxes:
  \HasNoChildren                / INBOX
  \HasNoChildren                / Personal
  \HasNoChildren                / Receipts
  \HasNoChildren                / Travel
  \HasNoChildren                / Work
  \Noselect \HasChildren        / [Gmail]
  \HasChildren \HasNoChildren   / [Gmail]/All Mail
  \HasNoChildren                / [Gmail]/Drafts
  \HasChildren \HasNoChildren   / [Gmail]/Sent Mail
  \HasNoChildren                / [Gmail]/Spam
  \HasNoChildren                / [Gmail]/Starred
  \HasChildren \HasNoChildren   / [Gmail]/Trash

为每个文件夹列出的标准标志可以是零个或多个以下内容:

  • \Noinferiors:这意味着该文件夹不包含任何子文件夹,并且将来也不可能包含子文件夹。如果 IMAP 客户端试图在该文件夹中创建子文件夹,它将收到一个错误。
  • \Noselect:这意味着不能在该文件夹上运行select_folder();也就是说,该文件夹不包含也不能包含任何邮件。(一种可能是,它的存在只是为了允许它下面有子文件夹。)
  • 这意味着服务器认为这个盒子在某种程度上是有趣的。通常,这表示自上次选择该文件夹后,已有新邮件送达。然而,\Marked的缺失并不能保证不会保证文件夹中不包含新消息;有些服务器根本就没有实现\Marked
  • \Unmarked:这样可以保证文件夹中不包含新邮件。

一些服务器返回标准中没有包含的附加标志。您的代码必须能够接受和忽略这些额外的标志。

检查文件夹

在实际下载、搜索或修改任何邮件之前,您必须“选择”要查看的特定文件夹。这意味着 IMAP 协议是有状态的:它会记住您当前正在查看的文件夹,它的命令会对当前文件夹进行操作,而不会让您一遍又一遍地重复它的名称。只有当你的连接关闭并重新连接时,你才是从一个干净的石板上重新开始。这可以使交互更加愉快,但这也意味着你的程序必须小心,它总是知道什么文件夹被选中,否则它可能会对错误的文件夹做一些事情。

因此,当您选择一个文件夹时,您告诉 IMAP 服务器随后的所有命令(直到您更改文件夹或退出当前文件夹)将应用于所选文件夹。

选择时,通过提供一个readonly=True参数,您可以选择“只读”文件夹,而不是以完全读/写模式选择它。这将导致任何删除或修改消息的操作在您尝试这些操作时返回错误消息。除了防止您在想要保持所有邮件不变时犯任何错误之外,您正在阅读的事实可以被服务器用来优化对文件夹的访问。(例如,当您选中磁盘上的实际文件夹存储时,它可能会读锁定,但不会写锁定。)

消息编号与 uid

IMAP 提供了两种不同的方式来引用文件夹中的特定邮件:通过临时邮件编号(通常为 1、2、3 等)或通过唯一标识符(UID) 。两者的区别在于坚持。当您通过特定连接选择一个文件夹时,就会分配消息编号。这意味着它们可以是漂亮的和连续的,但也意味着如果你以后再次访问同一个文件夹,给定的邮件可能会有不同的号码。对于实时电子邮件阅读器或简单的下载脚本等程序,这种行为(与 POP 相同)没有问题;您不会在意下次连接时号码可能会有所不同。

但是相比之下,UID 被设计为保持不变,即使您关闭与服务器的连接并且不再重新连接。如果一封邮件今天的 UID 为 1053,那么同一封邮件明天的 UID 将为 1053,并且该文件夹中的其他邮件都不会有 UID 1053。如果你正在编写一个同步工具,这个行为是相当有用的!它将允许您 100%确定地验证正在对正确的消息采取行动。这是 IMAP 比 POP 更有趣的地方之一。

请注意,如果您返回到一个 IMAP 帐户,而用户在没有通知您的情况下删除了一个文件夹,然后用相同的名称创建了一个新的文件夹,那么在您的程序看来,可能是同一个文件夹存在,但 UID 号相互冲突,不再一致。即使是文件夹重命名,如果您没有注意到,也可能会让您忘记 IMAP 帐户中的哪些邮件与您已经下载的邮件相对应。但事实证明,IMAP 已经准备好保护您不受这种影响,并且(我很快会解释)提供了一个UIDVALIDITY folder 属性,您可以从一个会话到下一个会话进行比较,以查看该文件夹中的 uid 是否真的与您上次连接时相同消息的 uid 相对应。

大多数处理特定邮件的 IMAP 命令可以采用邮件号或 uid。通常,IMAPClient 总是使用 uid,并忽略 IMAP 分配的临时消息编号。但是如果您想查看临时数字,只需用一个use_uid=False参数实例化 IMAPClient,或者您甚至可以在 IMAP 会话期间动态地将该类的use_uid属性的值设置为FalseTrue

消息范围

大多数处理邮件的 IMAP 命令可以处理一封或多封邮件。如果您需要一整组消息,这可以使处理速度快得多。您可以将一组消息作为一个整体来操作,而不是针对每条单独的消息发出单独的命令并接收单独的响应。这通常会更快,因为您不再需要为每个命令处理网络往返。

在通常需要提供消息编号的地方,您可以提供一个逗号分隔的消息编号列表。此外,如果您想要号码在某个范围内的所有消息,但您不想必须列出它们的所有号码(或者如果您甚至不知道它们的号码—也许您想要“一切从消息一开始”而不必先获取它们的号码),您可以使用冒号来分隔开始和结束消息号码。星号表示“和所有其余的消息”下面是一个规范示例:

2,4:6,20:*

它表示“邮件夹末尾的邮件 2、邮件 4 至 6 和邮件 20”

汇总信息

当您第一次选择一个文件夹时,IMAP 服务器会提供一些关于它的摘要信息—关于文件夹本身以及它的邮件。

摘要由IMAPClient作为字典返回。以下是运行select_folder()时大多数 IMAP 服务器将返回的密钥:

  • EXISTS :给出文件夹中消息数量的整数。
  • FLAGS :该文件夹中的邮件可以设置的标志列表。
  • RECENT :指定自 IMAP 客户端最后一次在文件夹上运行select_folder()以来,服务器对文件夹中出现的邮件数量的估计。
  • PERMANENTFLAGS :指定可以在消息上设置的自定义标志列表;这通常是空的。
  • UIDNEXT :服务器猜测将分配给下一个传入(或上传)消息的 UID。
  • UIDVALIDITY :客户端可以用来验证 UID 编号没有改变的字符串。如果您返回到一个文件夹,并且这是一个不同于上次连接时的值,那么 UID 号已经重新开始,您存储的 UID 值不再有效。
  • UNSEEN :指定文件夹中第一条看不见的消息(没有\Seen标志的消息)的消息号。

在这些标志中,服务器只需要返回FLAGSEXISTSRECENT,尽管大多数也会至少包括UIDVALIDITY。清单 15-5 显示了一个样例程序,它读取并显示我的INBOX电子邮件文件夹的摘要信息。

清单 15-5 。显示文件夹摘要信息

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/folder_info.py
# Opening an IMAP connection with IMAPClient and listing folder information.

import getpass, sys
from imapclient import IMAPClient

def main():
    if len(sys.argv) != 4:
        print('usage: %s hostname username foldername' % sys.argv[0])
        sys.exit(2)

    hostname, username, foldername = sys.argv[1:]
    c = IMAPClient(hostname, ssl=True)
    try:
        c.login(username, getpass.getpass())
    except c.Error as e:
        print('Could not log in:', e)
    else:
        select_dict = c.select_folder(foldername, readonly=True)
        for k, v in sorted(select_dict.items()):
            print('%s: %r' % (k, v))
    finally:
        c.logout()

if __name__ == '__main__':
    main()

运行时,该程序显示如下结果:

$ ./folder_info.py imap.example.com brandon@example.com
Password:
EXISTS: 3
PERMANENTFLAGS: ('\\Answered', '\\Flagged', '\\Draft', '\\Deleted',
'\\Seen', '\\*')
READ-WRITE: True
UIDNEXT: 2626
FLAGS: ('\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen')
UIDVALIDITY: 1
RECENT: 0

这表明我的INBOX文件夹包含三条消息,自从我上次检查以来,没有一条消息到达。如果您的程序对使用它在以前的会话中存储的 uid 感兴趣,记得将UIDVALIDITY与以前的会话中存储的值进行比较。

下载整个邮箱

对于 IMAP,FETCH命令用于下载邮件,IMAPClient 将其公开为其fetch()方法。

最简单的获取方法是一口气下载所有信息。尽管这是最简单的,并且需要的网络流量最少(因为您不必发出重复的命令并接收多个响应),但这确实意味着当您的程序检查它们时,所有返回的消息都需要一起存放在内存中。对于邮件有很多附件的非常大的邮箱来说,这显然是不实际的!

清单 15-6 以 Python 数据结构将INBOX文件夹中的所有消息下载到你计算机的内存中,然后显示每条消息的一些摘要信息。

清单 15-6 。下载文件夹中的所有邮件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/folder_summary.py
# Opening an IMAP connection with IMAPClient and retrieving mailbox messages.

import email, getpass, sys
from imapclient import IMAPClient

def main():
    if len(sys.argv) != 4:
        print('usage: %s hostname username foldername' % sys.argv[0])
        sys.exit(2)

    hostname, username, foldername = sys.argv[1:]
    c = IMAPClient(hostname, ssl=True)
    try:
        c.login(username, getpass.getpass())
    except c.Error as e:
        print('Could not log in:', e)
    else:
        print_summary(c, foldername)
    finally:
        c.logout()

def print_summary(c, foldername):
    c.select_folder(foldername, readonly=True)
    msgdict = c.fetch('1:*', ['BODY.PEEK[]'])
    for message_id, message in list(msgdict.items()):
        e = email.message_from_string(message['BODY[]'])
        print(message_id, e['From'])
        payload = e.get_payload()
        if isinstance(payload, list):
            part_content_types = [part.get_content_type() for part in payload]
            print('  Parts:', ' '.join(part_content_types))
        else:
            print('  ', ' '.join(payload[:60].split()), '...')

if __name__ == '__main__':
    main()

请记住,IMAP 是有状态的:首先您使用select_folder()将自己放在给定的文件夹中,然后您可以运行fetch()来询问消息内容。(如果你想离开,不想再呆在给定的文件夹里,你可以稍后运行close_folder()。)范围'1:*'表示“邮件文件夹末尾的第一封邮件”,因为邮件 id——无论是临时的还是 uid——总是正整数。

可能看起来很奇怪的字符串'BODY.PEEK[] '是向 IMAP 请求消息“整体”的方式。字符串'BODY[]'的意思是“整个消息”;正如您将看到的,在方括号内,您可以只要求消息的特定部分。

PEEK表示你只是在查看消息内部以建立一个摘要,并且你不希望服务器自动为你设置所有这些消息的\Seen标志,从而破坏它关于用户已经阅读了哪些消息的记忆。(对我来说,这似乎是一个很好的特性,可以添加到这样一个小脚本中,您可以对一个真实的邮箱运行该脚本——我不想将您的所有邮件都标记为已读!)

返回的字典将消息 uid 映射到给出关于每条消息的信息的字典。当您遍历它的键和值时,您在每个消息字典中查找 IMAP 已经用您所请求的消息的信息填充的'BODY[]'键:它的完整文本,作为一个大字符串返回。

使用我在第十二章的中讨论的email模块,脚本要求 Python 抓取From:行和一点消息内容,并作为摘要打印到屏幕上。当然,如果您想扩展这个脚本,以便将消息保存在文件或数据库中,您可以省略email解析步骤,而是将消息体作为一个单独的字符串存储在存储器中,供以后解析。

以下是运行该脚本时的结果:

$ ./mailbox_summary.py imap.example.com brandon INBOX
Password:
2590 "Amazon.com" <order-update@amazon.com>
  Dear Brandon, Portable Power Systems, Inc. shipped the follo ...
2469 Meetup Reminder <info@meetup.com>
  Parts: text/plain text/html
2470 billing@linode.com
  Thank you. Please note that charges will appear as "Linode.c ...

当然,如果邮件包含很大的附件,仅仅为了打印摘要而下载完整的附件可能是毁灭性的;但是因为这是最简单的消息获取操作,所以我认为从它开始比较合理!

单独下载邮件

电子邮件可能非常大,电子邮件文件夹也可能非常大——许多电子邮件系统允许用户拥有数百或数千封邮件,每封邮件可能有 10MB 或更大。如果一次下载完所有内容,这种邮箱很容易超过客户机上的 RAM,就像前面的例子一样。

为了帮助不想保留每封邮件的本地副本的基于网络的电子邮件客户端,除了前面讨论的“获取整个邮件”命令之外,IMAP 还支持多种操作。

  • 电子邮件的标题可以作为文本块下载,与邮件分开。
  • 可以请求并返回邮件的特定标题,而无需下载所有标题。
  • 可以要求服务器递归地探索并返回消息的 MIME 结构的轮廓。
  • 可以返回消息的特定部分的文本。

这使得 IMAP 客户端可以执行非常高效的查询,只下载需要向用户显示的信息,从而降低 IMAP 服务器和网络的负载,并允许更快地向用户显示结果。

关于一个简单的 IMAP 客户端如何工作的例子,请看清单 15-7 ,它汇集了许多关于浏览 IMAP 账户的想法。如果这些特性在这一章的这一点上分散在六个更短的程序清单中,这将提供更多的上下文。您可以看到客户端由三个同心循环组成,每个循环在用户查看电子邮件文件夹列表、特定电子邮件文件夹中的邮件列表以及特定邮件的各个部分时接受用户的输入。

清单 15-7 。一个简单的 IMAP 客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/simple_client.py
# Letting a user browse folders, messages, and message parts.

import getpass, sys
from imapclient import IMAPClient

banner = '-' * 72

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        sys.exit(2)

    hostname, username = sys.argv[1:]
    c = IMAPClient(hostname, ssl=True)
    try:
        c.login(username, getpass.getpass())
    except c.Error as e:
        print('Could not log in:', e)
    else:
        explore_account(c)
    finally:
        c.logout()

def explore_account(c):
    """Display the folders in this IMAP account and let the user choose one."""

    while True:

        print()
        folderflags = {}
        data = c.list_folders()
        for flags, delimiter, name in data:
            folderflags[name] = flags
        for name in sorted(folderflags.keys()):
            print('%-30s %s' % (name, ' '.join(folderflags[name])))
        print()

        reply = input('Type a folder name, or "q" to quit: ').strip()
        if reply.lower().startswith('q'):
            break
        if reply in folderflags:
            explore_folder(c, reply)
        else:
            print('Error: no folder named', repr(reply))

def explore_folder(c, name):
    """List the messages in folder `name` and let the user choose one."""

    while True:
        c.select_folder(name, readonly=True)
        msgdict = c.fetch('1:*', ['BODY.PEEK[HEADER.FIELDS (FROM SUBJECT)]',
                                  'FLAGS', 'INTERNALDATE', 'RFC822.SIZE'])
        print()
        for uid in sorted(msgdict):
            items = msgdict[uid]
            print('%6d  %20s  %6d bytes  %s' % (
                uid, items['INTERNALDATE'], items['RFC822.SIZE'],
                ' '.join(items['FLAGS'])))
            for i in items['BODY[HEADER.FIELDS (FROM SUBJECT)]'].splitlines():
                print(' ' * 6, i.strip())

        reply = input('Folder %s - type a message UID, or "q" to quit: '
                          % name).strip()
        if reply.lower().startswith('q'):
            break
        try:
            reply = int(reply)
        except ValueError:
            print('Please type an integer or "q" to quit')
        else:
            if reply in msgdict:
                explore_message(c, reply)

    c.close_folder()

def explore_message(c, uid):
    """Let the user view various parts of a given message."""

    msgdict = c.fetch(uid, ['BODYSTRUCTURE', 'FLAGS'])

    while True:
        print()
        print('Flags:', end=' ')
        flaglist = msgdict[uid]['FLAGS']
        if flaglist:
            print(' '.join(flaglist))
        else:
            print('none')
        print('Structure:')
        display_structure(msgdict[uid]['BODYSTRUCTURE'])
        print()
        reply = input('Message %s - type a part name, or "q" to quit: '
                          % uid).strip()
        print()
        if reply.lower().startswith('q'):
            break
        key = 'BODY[%s]' % reply
        try:
            msgdict2 = c.fetch(uid, [key])
        except c._imap.error:
            print('Error - cannot fetch section %r' % reply)
        else:
            content = msgdict2[uid][key]
            if content:
                print(banner)
                print(content.strip())
                print(banner)
            else:
                print('(No such section)')

def display_structure(structure, parentparts=[]):
    """Attractively display a given message structure."""

    # The whole body of the message is named 'TEXT'.

    if parentparts:
        name = '.'.join(parentparts)
    else:
        print('  HEADER')
        name = 'TEXT'

    # Print a simple, non-multipart MIME part.  Include its disposition,
    # if available.

    is_multipart = not isinstance(structure[0], str)

    if not is_multipart:
        parttype = ('%s/%s' % structure[:2]).lower()
        print('  %-9s' % name, parttype, end=' ')
        if structure[6]:
            print('size=%s' % structure[6], end=' ')
        if structure[9]:
            print('disposition=%s' % structure[9][0],
                  ' '.join('{}={}'.format(k, v) for k, v in structure[9][1:]),
                  end=' ')
        print()
        return

    # For a multipart part, print all of its subordinate parts.

    parttype = 'multipart/%s' % structure[1].lower()
    print('  %-9s' % name, parttype, end=' ')
    print()
    subparts = structure[0]
    for i in range(len(subparts)):
        display_structure(subparts[i], parentparts + [str(i + 1)])

if __name__ == '__main__':
    main()

您可以看到,外层函数使用一个简单的list_folders()调用向用户显示电子邮件文件夹列表,就像前面讨论的一些程序清单一样。还会显示每个文件夹的 IMAP 标志。这使得程序可以让用户在文件夹之间进行选择:

INBOX                          \HasNoChildren
Receipts                       \HasNoChildren
Travel                         \HasNoChildren
Work                           \HasNoChildren
Type a folder name, or "q" to quit:

一旦用户选择了一个文件夹,事情就变得更有趣了:必须为每条消息打印一个摘要。不同的电子邮件客户端对显示文件夹中每封邮件的信息做出不同的选择。清单 15-7 中的代码选择了几个标题字段以及消息的日期和大小。请注意,使用BODY.PEEK而不是BODY来获取这些项目是很小心的,因为 IMAP 服务器会将这些消息标记为\Seen,仅仅因为它们显示在摘要中!

一旦选择了电子邮件文件夹,此fetch()调用的结果将打印到屏幕上:

2703   2010-09-28 21:32:13   19129 bytes  \Seen
From: Brandon Craig Rhodes
Subject: Digested Articles

2704   2010-09-28 23:03:45   15354 bytes
Subject: Re: [venv] Building a virtual environment for offline testing
From: "W. Craig Trader"

2705   2010-09-29 08:11:38   10694 bytes
Subject: Re: [venv] Building a virtual environment for offline testing
From: Hugo Lopes Tavares

Folder INBOX - type a message UID, or "q" to quit:

正如您所看到的,可以向 IMAP fetch() 命令提供几个感兴趣的项目,这一事实允许您构建相当复杂的消息摘要,只需与服务器进行一次往返!

一旦用户选择了一条特定的消息,就会使用一种我到目前为止还没有讨论过的技术:fetch()被要求返回消息的BODYSTRUCTURE,这是查看 MIME 消息的各个部分而不必下载整个文本的关键。BODYSTRUCTURE不是让你通过网络传输几兆字节来列出一个大邮件的附件,而是简单地列出它的 MIME 部分作为一个递归数据结构。

简单的 MIME 部分 作为元组返回:

('TEXT', 'PLAIN', ('CHARSET', 'US-ASCII'), None, None, '7BIT', 2279, 48)

在 RFC 3501 的 7.4.2 节中详细描述的该元组的元素如下(当然,从项目索引零开始):

  1. MIME 类型
  2. MIME 子类型
  3. 主体参数,表示为一个元组(name, value, name, value, ...),其中每个参数名称后跟其值
  4. 内容 ID
  5. 内容描述
  6. 内容编码
  7. 内容大小(字节)
  8. 对于文本 MIME 类型,这给出了以行为单位的内容长度

当 IMAP 服务器发现一个消息是多部分的,或者当它检查它发现的消息的一个部分本身是多部分的(参见第十二章了解更多关于 MIME 消息如何在其中嵌套其他 MIME 消息的信息),那么它返回的元组将以一个子结构列表开始,每个子结构都是一个元组,就像外部结构一样。然后,它将以将这些部分绑定在一起的多部分容器的一些信息结束:

([(...), (...)], "MIXED", ('BOUNDARY', '=-=-='), None, None)

"MIXED"准确地指示了所表示的多部分容器的类型——在本例中,完整类型是multipart/mixed。其他常见的“多部分”子类型,除了"MIXED",还有"ALTERNATIVE""DIGEST""PARALLEL"。multipart 类型之外的其余项是可选的,但如果存在,它们会提供一组名称-值参数(此处指示 MIME multipart 边界字符串)、multipart 的部署、语言和位置(通常由 URL 给出)。

给定这些规则,你可以看到像清单 15-7 中的display_structure()这样的递归例程是如何完美地展开和显示消息中各部分的层次结构。当 IMAP 服务器返回一个BODYSTRUCTURE时,例程开始工作并打印出如下内容供用户检查:

Folder INBOX - type a message UID, or "q" to quit: 2701
Flags: \Seen
HEADER
TEXT      multipart/mixed
1         multipart/alternative
1.1       text/plain size=253
1.2       text/html size=508
2         application/octet-stream size=5448 ATTACHMENT FILENAME='test.py'
Message 2701 - type a part name, or "q" to quit:

您可以看到,这里显示的消息结构是一个非常典型的现代电子邮件,对于在浏览器或现代电子邮件客户端查看它的用户,它有一个精美的富文本 HTML 部分,对于使用更传统的设备或应用的用户,它有一个相同消息的纯文本版本。它还包含一个文件附件,并提供了一个建议的文件名,以防用户希望将其下载到本地文件系统。为了简单和安全起见,这个示例程序并不试图在硬盘上保存任何东西;相反,用户可以选择消息的任何部分——例如特殊部分HEADERTEXT,或者像1.1这样的特定部分之一——其内容将被打印到屏幕上。

如果您检查程序清单,您会发现所有这些都是通过调用 IMAP fetch()方法来支持的。像HEADER1.1这样的部件名只是在调用fetch()时可以指定的更多选项,它们可以和其他值一起使用,比如BODY.PEEKFLAGS。唯一的区别是后面的值适用于所有消息;而类似于2.1.3的部分名称将只存在于其结构包含具有该名称的部分的多部分消息中。

您将注意到的一个奇怪之处是,IMAP 协议实际上并没有而不是为您提供特定消息支持的任何多部分名称!相反,您必须从索引1开始计算BODYSTRUCTURE中列出的零件数量,以确定您应该请求哪个零件号。你可以看到这里的display_structure()例程使用一个简单的循环来完成这个计数。

关于fetch()命令的最后一个注意事项:它不仅可以让您在任何给定的时刻提取您需要的消息部分,而且如果它们很长,并且您只想从头提供一段摘录来吸引用户,它还可以截断它们!要使用此功能,请在任何部分名称后加上尖括号中的切片,以指示所需的字符范围,这与 Python 的切片操作非常相似:

BODY[]<0.100>

这将返回消息体的前 100 个字节,从偏移量 0 到偏移量 100。这可以让您在让用户决定是否选择或下载附件之前,检查附件的文本和开头,以了解更多有关其内容的信息。

标记和删除消息

在试用清单 15-7 中的或阅读其示例输出时,您可能已经注意到,IMAP 用名为 flags 的属性来标记消息,这些属性通常采用以反斜杠为前缀的单词的形式,比如刚刚引用的一条消息中的\Seen。其中有几个是标准的,它们在 RFC 3501 中定义,可以在所有 IMAP 服务器上使用。以下是最重要的几条的含义:

  • \Answered:用户已回复消息。
  • \Draft:用户还没有写完消息。
  • 这条消息不知何故被特别挑了出来;该标志的目的和意义因电子邮件阅读器而异。
  • 以前没有 IMAP 客户端看到过这条消息。该标志是唯一的,因为不能通过普通命令添加或删除该标志;选择邮箱后,它会自动删除。
  • \Seen:消息已被阅读。

如您所见,这些标志大致对应于许多电子邮件读者直观呈现的关于每封邮件的信息。尽管术语可能不同(许多客户谈论“新”邮件而不是“未看到”邮件),但几乎所有的电子邮件阅读器都显示这些标志。特定的服务器也可能支持其他标志,这些标志的代码不一定以反斜杠开头。此外,并非所有服务器都可靠地支持\Recent标志,因此通用 IMAP 客户端最多只能将其视为一个提示。

IMAPClient 库支持几种使用标志的方法。最简单的方法是检索标志,就像你做了一个fetch()请求'FLAGS'一样,但是它继续下去并删除每个答案周围的字典:

>>> c.get_flags(2703)
{2703: ('\\Seen',)}

也有在邮件中添加和移除标志的调用:

c.remove_flags(2703, ['\\Seen'])
c.add_flags(2703, ['\\Answered'])

如果您想完全更改某个特定消息的标志集,而不确定正确的添加和删除序列,您可以单方面使用set_flags()将整个消息标志列表替换为一个新的标志:

c.set_flags(2703, ['\\Seen', '\\Answered'])

这些操作中的任何一个都可以接受消息 UID 的列表,而不是这些示例中显示的单个 UID。

删除消息

标志的最后一个有趣用途是在 IMAP 如何支持邮件删除中发现的。为了安全起见,这个过程分两步:首先,客户端用\Delete标志标记一条或多条消息;然后,它调用expunge()作为单个操作执行挂起的请求删除。

然而,IMAPClient 库不会让您手动这样做(尽管这样做也可以);相反,它隐藏了这样一个事实,即在一个简单的为您标记消息的delete_messages()例程后面包含了标志。但是,如果您真的希望操作生效,它后面还必须跟有expunge():

c.delete_messages([2703, 2704])
c.expunge()

注意,expunge()将对邮箱中消息的临时 id 重新排序,这也是使用 uid 的另一个原因。

正在搜索

搜索是另一个非常重要的功能,对于一个旨在让您将所有电子邮件保存在电子邮件服务器本身上的协议来说:如果没有搜索,电子邮件客户端将不得不在用户第一次想要执行全文搜索来查找电子邮件时下载用户的所有电子邮件。

搜索的本质很简单:在 IMAP 客户机实例上调用search()方法,然后返回符合条件的消息的 uid(当然,假设您接受 IMAP client 缺省值use_uid=True):

>>> c.select_folder('INBOX')
>>> c.search('SINCE 13-Jul-2013 TEXT Apress')
[2590L, 2652L, 2653L, 2654L, 2655L, 2699L]

然后,这些 uid 可以成为一个fetch()命令的主题,该命令检索关于您需要的每条消息的信息,以便向用户呈现搜索结果的摘要。

前面的例子中显示的查询结合了两个标准:一个请求最近的消息(那些自 2013 年 7 月 13 日以来到达的消息,我键入这个消息的日期),另一个请求消息文本在某个地方有单词 Apress,并且结果将只包括满足第一个标准的消息第二个标准——这是用一个空格连接两个标准以形成单个字符串的结果。相反,如果您希望消息至少匹配其中一个标准,但不需要同时匹配两个标准,那么您可以使用一个OR操作符来连接标准:

OR (SINCE 20-Aug-2010) (TEXT Apress)

为了形成一个查询,可以组合许多标准。像 IMAP 的其余部分一样,它们在 RFC 3501 中被指定。有些标准非常简单,指的是二进制属性,如标志:

ALL: Every message in the mailbox
UID (id, ...): Messages with the given UIDs
LARGER n: Messages more than n octets in length
SMALLER m: Messages less than m octets in length
ANSWERED: Have the flag \Answered
DELETED: Have the flag \Deleted
DRAFT: Have the flag \Draft
FLAGGED: Have the flag \Flagged
KEYWORD flag: Have the given keyword flag set
NEW: Have the flag \Recent
OLD: Lack the flag \Recent
UNANSWERED: Lack the flag \Answered
UNDELETED: Lack the flag \Deleted
UNDRAFT: Lack the flag \Draft
UNFLAGGED: Lack the flag \Flagged
UNKEYWORD flag: Lack the given keyword flag
UNSEEN: Lack the flag \Seen

还有许多标志与每个邮件头中的项目相匹配。除了“发送”测试之外,它们都在同名的头中搜索给定的字符串,发送测试查看Date头:

BCC string
CC string
FROM string
HEADER name string
SUBJECT string
TO string

一条 IMAP 消息有两个日期:由发送者指定的内部Date报头,称为其发送日期,以及它实际到达 IMAP 服务器的日期。(前者显然可能是伪造的;后者和 IMAP 服务器及其时钟一样可靠。)因此,根据您要查询的日期,有两组日期条件:

BEFORE 01-Jan-1970
ON 01-Jan-1970
SINCE 01-Jan-1970
SENTBEFORE 01-Jan-1970
SENTON 01-Jan-1970
SENTSINCE 01-Jan-1970

最后,有两种搜索操作涉及到邮件本身的文本,它们是支持全文搜索的主要工具,当用户在电子邮件客户端的搜索字段中键入内容时,他们可能会希望进行全文搜索:

BODY string: The message body must contain the string.
TEXT string: The entire message, either body or header, must contain the string somewhere.

请参阅您正在使用的特定 IMAP 服务器的文档,以了解它是否返回任何“近似”匹配(如现代搜索引擎所支持的匹配),或者只返回与您提供的单词完全匹配的匹配。

如果字符串包含 IMAP 认为特殊的字符,请尝试用双引号将它们括起来,然后用反斜杠将字符串中的任何双引号括起来:

>>> c.search(r'TEXT "Quoth the raven, \"Nevermore.\""')
[2652L]

注意,通过在这里使用一个原始的 Python r'...'字符串,我避免了必须将反斜杠加倍才能将单个反斜杠传递到 IMAP。

操作文件夹和信息

在 IMAP 中创建或删除文件夹非常简单,只需提供文件夹的名称:

c.create_folder('Personal')
c.delete_folder('Work')

某些 IMAP 服务器或配置可能不允许这些操作,或者对命名有限制。在调用它们时,一定要进行错误检查。

除了等待别人给你发送邮件的“正常”方法外,还有两种操作可以在你的 IMAP 帐户中创建新的电子邮件。

首先,您可以将现有邮件从其个人文件夹拷贝到另一个文件夹。首先使用select_folder()访问邮件所在的文件夹,然后像这样运行copy方法:

c.select_folder('INBOX')
c.copy([2653L, 2654L], 'TODO')

最后,可以使用 IMAP 向邮箱添加邮件。您不需要先用 SMTP 发送邮件;IMAP 是所有需要的。添加消息是一个简单的过程,尽管有一些事情你必须知道。

主要关注的是行尾。许多 Unix 机器使用单个 ASCII 换行字符(Python 中的0x0a'\n')来指定一行文本的结尾。Windows 机器使用两个字符:CR-LF,一个手动回车符(0x0D,或者 Python 中的'\r',后跟一个换行符。老款 MAC 电脑只使用手动回位。

像许多互联网协议(HTTP 立即浮现在脑海中)一样,IMAP 在内部使用CR-LF(Python 中的'\r\n')来指定一行的结束。如果您上传的邮件使用任何其他字符作为行尾,某些 IMAP 服务器会出现问题。因此,在翻译上传的邮件时,您必须始终注意正确的行尾。这个问题比你想象的更常见,因为大多数本地邮箱格式只在每行末尾使用'\n'

但是,您还必须小心如何更改行尾,因为一些邮件可能在其中的某个地方使用了'\r\n',尽管在最初的几十行中只使用了'\n',如果邮件使用了两种不同的行尾,IMAP 客户端就会失败!解决方案很简单,这要感谢 Python 强大的splitlines()字符串方法,它可以识别所有三种可能的行尾;只需调用消息中的函数,然后用标准行尾重新加入行:

>>> 'one\rtwo\nthree\r\nfour'.splitlines()
['one', 'two', 'three', 'four']
>>> '\r\n'.join('one\rtwo\nthree\r\nfour'.splitlines())
'one\r\ntwo\r\nthree\r\nfour'

一旦行尾正确,添加消息的实际操作是通过调用 IMAP 客户机上的append()方法来安排的:

c.append('INBOX', my_message)

通过传递一个普通的 Python datetime对象,还可以提供一个列表flags作为关键字参数,以及一个msg_time作为它的到达时间。

异步

最后,需要承认这一章中关于 IMAP 的方法:尽管我把 IMAP 描述成了同步协议,但实际上它支持客户机通过套接字向服务器发送许多请求,然后以服务器能够最有效地从磁盘获取电子邮件并作出响应的任何顺序接收返回的响应。

IMAPClient 库总是发送一个请求,等待响应,然后返回该值,从而隐藏了这种协议灵活性。但是其他库,尤其是 Twisted Python 中提供的 IMAP 功能,可以让您利用它的异步性。

对于大多数需要编写邮箱交互脚本的 Python 程序员来说,本章中采用的同步方法应该可以很好地工作。如果你扩展到异步库,那么你至少已经从本章的描述中了解了所有的 IMAP 命令,你只需要学习如何通过异步库的 API 发送相同的命令。

摘要

IMAP 是访问存储在远程服务器上的电子邮件的可靠协议。Python 有许多 IMAP 库;imaplib内置于 Python 标准库,但是它需要你自己做各种低级的响应解析。更好的选择是 Menno Smits 的 IMAPClient,您可以从 Python 包索引中安装它。

在 IMAP 服务器上,您的电子邮件被分组到文件夹中,其中一些由您的特定 IMAP 提供商预定义,另一些您可以自己创建。IMAP 客户端可以创建文件夹、删除文件夹、将新邮件插入文件夹以及在文件夹之间移动现有邮件。

一旦选择了一个文件夹,这就相当于 IMAP 文件系统上的“更改目录”命令,可以非常灵活地列出和提取邮件。客户端不必下载完整的消息(当然,这也是一个选项),而是可以从消息中请求特定的信息,比如几个标题及其消息结构,以便构建用户可以点击的显示或摘要,按需从服务器下载消息部分和附件。

客户端还可以在每条消息上设置标志——其中一些对服务器也有意义——并且它可以通过设置\Delete标志然后执行 expunge 操作来删除消息。

最后,IMAP 提供了完善的搜索功能,因此无需将电子邮件数据下载到本地机器就可以支持常见的用户操作。

在下一章中,我们将离开电子邮件的主题,考虑一种完全不同的通信类型:向远程服务器发送 shell 命令并接收它们的响应输出。

十六、Telnet 和 SSH

如果你从未读过这本书,那么你应该泡一杯你最喜欢的咖啡,坐下来,听听尼尔·斯蒂芬森的文章《开始》。。。曾是命令行》(威廉·莫罗平装本,1999)。您也可以在www.cryptonomicon.com/beginning.html从他的网站上下载一份原始文本文件(足够合适)。

命令行是本章的主题。它涵盖了如何通过网络访问它,以及关于它的典型行为的足够多的讨论,以帮助您解决在尝试使用它时可能遇到的任何令人沮丧的问题。

令人高兴的是,对于许多读者来说,向另一台计算机发送简单的文本命令这一过时的想法将是本书最相关的主题之一。讨论的主要网络协议——安全外壳(SSH)——似乎在任何地方都被用来配置和维护各种机器。

当你在一家虚拟主机公司获得一个新帐户,并且已经使用它的控制面板设置好你的域名和网络应用列表后,命令行就成了你安装和运行网站代码的主要手段。

Rackspace 和 Linode 等公司的虚拟服务器或物理服务器几乎总是通过 SSH 连接来管理。

如果您使用基于 API 的虚拟主机服务(如 Amazon AWS)构建一个动态分配的服务器云,您会发现 Amazon 允许您通过向您询问 SSH 密钥并安装它来访问您的新主机,以便您可以立即登录到您的新实例,而无需密码。

就好像一旦早期的计算机能够接收文本命令并返回文本输出作为响应,它们就达到了一种有用的顶峰,但仍有待改进。语言是人类用来表达意思的最强大的手段,当你打字时,即使是在 Unix 外壳的狭窄和精确的语言中,再多的指向、点击或拖动鼠标也无法表达哪怕是一小部分的内容。

命令行自动化

在详细了解命令行的工作原理以及如何通过网络访问远程命令行之前,请注意,如果您的特定目标是执行远程系统管理,那么您可能需要检查更多特定的工具。为了增加复杂性,Python 社区已经在三个方向上采用了远程自动化:

  1. Fabric
  2. Ansible 是一个圆滑而强大的系统,它让你声明几十或几百台远程机器应该如何配置。它用 SSH 连接到它们中的每一个,并执行任何必要的检查或更新。它的速度和设计不仅引起了 Python 社区的注意,也引起了整个系统管理学科的注意(见http://docs.ansible.com/index.html)。
  3. SaltStack 让您在每台客户机上安装自己的代理,而不是简单地安装在 SSH 之上。这允许主服务器将新信息推送到其他机器,比通过成百上千个同步 SSH 连接要快得多。反过来,它的速度快得惊人,即使对于大型设备和大型集群也是如此(见www.saltstack.com/)。

最后,我应该提一下 pexpect 。虽然从技术上来说,它并不是一个知道如何使用网络的程序,但当 Python 程序员想要自动化与某种远程提示的交互时,它经常被用来控制系统sshtelnet命令。这通常发生在设备没有可用的 API,每次命令行提示符出现时只需键入命令的情况下。配置简单的网络硬件通常需要这种笨拙的逐步交互。你可以在http://pypi.python.org/pypi/pexpect了解更多关于 pexpect 的信息。

当然,可能没有像这样的自动化解决方案能够满足您的项目,您实际上必须卷起袖子,自己学习如何操作远程外壳协议。那样的话,你来对地方了。继续读!

命令行扩展和报价

如果您曾经在 Unix 命令提示符下键入过命令,您会意识到并不是您键入的每个字符都会被逐字解释。例如,考虑以下命令。(注意,在这个例子和本章后面的所有例子中,我将使用美元符号$,作为 shell 的提示符,它告诉你“该你打字了。”)

$ echo *
sftp.py shell.py ssh_commands.py ssh_simple.py ssh_simple.txt ssh_threads.py telnet_codes.py
telnet_login.py

该命令中的星号(* ) 并不表示“将实际的星号字符打印到屏幕上”相反,shell 认为我试图编写一个模式来匹配当前目录中的所有文件名。要打印一个真正的星号,我必须使用另一个特殊字符,一个转义字符,因为它让我从 shell 的正常含义中“转义”出来,告诉它我只是在字面上表示星号。

$ echo Here is a lone asterisk: \*
Here is a lone asterisk: *

$ echo And here are '*' two "*" more asterisks
And here are * two * more asterisks

Shells 可以运行子进程,然后在另一个命令的文本中使用子进程的输出——现在它们甚至可以做数学运算。为了计算尼尔·斯蒂芬森的《在开始》的纯文本版本中每行有多少单词。。。如果是命令行”短文,您可以要求无处不在的 Bourne-again shell——目前大多数 Linux 系统上的标准 shell——将短文中的字数除以行数并产生一个结果。

$ echo $(($(wc -w < command.txt) / $(wc -l < command.txt))) words per line
44 words per line

从这个例子可以明显看出,现代 shells 解释命令行中特殊字符的规则已经变得相当复杂。 bash shell 的手册页目前总共运行了 5375 行,或者说在一个标准的 80×24 终端窗口中有 223 个充满文本的屏幕!很明显,如果我只探索 shell 破坏您输入的命令的一小部分可能方式,这将会把本章引入歧途。

相反,为了帮助您有效地使用命令行,在接下来的部分中,您将只关注两个要点:

  • 特殊字符被你正在使用的 shell 解释为特殊,比如bash。它们对操作系统本身没有任何特殊意义。
  • 当在本地或通过网络向 shell 传递命令时,您需要对所使用的特殊字符进行转义,这样它们就不会在远程系统上扩展为非预期的值。

现在,我将在各自的章节中逐一解决这些问题。请记住,我说的是通用的服务器操作系统,如 Linux 和 OS X,而不是更原始的操作系统,如 Windows,我将在单独的章节中讨论。

Unix 命令参数可以包括(几乎)任何字符

纯粹的低级 Unix 命令行没有特殊字符或保留字符。这是你要把握的一个重要事实。如果您使用类似于bash的 shell 已经有一段时间了,您可能会认为您的系统命令行就像是一个雷区。一方面,所有的特殊字符使得命名当前目录中的所有文件作为命令的参数变得容易。然而,从另一方面来说,很难将消息回显到屏幕上,这样做就像将单引号和双引号混合起来一样简单,而且很难知道哪些字符是安全的,哪些字符是 shell 认为特殊的。

本节的简单教训是,关于 shell 特殊字符的整套约定与您的操作系统无关。它们完全是bash shell 的行为,或者是您正在使用的其他流行(或神秘)shell 的行为。不管这些规则看起来有多熟悉,或者想象一下没有它们使用一个类似 Unix 的系统有多困难。如果你把外壳拿走,那么特殊字符的现象就消失了。

您可以通过自己启动一个进程并尝试在一个熟悉的命令中加入一些特殊字符来观察到这一点。

>>> import subprocess
>>> args = ['echo', 'Sometimes', '*', 'is just an asterisk']
>>> subprocess.call(args)
Sometimes * is just an asterisk

在这里,您选择启动一个带有参数的新进程,而不要求 shell 介入。这个过程——在本例中是echo命令——获取的正是这些字符,而不是先将*转换成文件名列表。

虽然星号通配符经常使用,但是 shell 中最常见的特殊字符是您一直在使用的:空格字符。每个空格都被解释为分隔参数的分隔符。当人们在 Unix 文件名中包含空格,然后试图将文件移动到其他地方时,这会导致无休止的娱乐。

$ mv Smith Contract.txt ~/Documents
mv: cannot stat `Smith': No such file or directory
mv: cannot stat `Contract.txt': No such file or directory

为了让 shell 理解您正在讨论的是一个名称中带有空格的文件,而不是两个文件,您必须设计类似以下可能的命令行之一:

$ mv Smith\ Contract.txt ~/Documents
$ mv "Smith Contract.txt" ~/Documents
$ mv Smith*Contract.txt ~/Documents

最后一种可能性显然意味着与前两种不同的东西,因为它将匹配任何碰巧以Smith开始并以Contract.txt结束的文件名,而不管它们之间的文本是简单的空格字符还是更长的文本序列。我经常看到用户在学习 shell 约定并且不记得如何键入空格字符时,沮丧地求助于使用通配符。

如果你想让自己相信bash shell 教你要小心的字符没有什么特别的,清单 16-1 展示了一个用 Python 写的简单 shell,它只将空格字符视为特殊字符,但将其他所有内容直接传递给命令。

清单 16-1 。外壳支持空格分隔的参数

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/shell.py
# A simple shell, so you can try running commands at a prompt where no
# characters are special (except that whitespace separates arguments).

import subprocess

def main():
    while True:
        args = input('] ').strip().split()
        if not args:
            pass
        elif args == ['exit']:
            break
        elif args[0] == 'show':
            print("Arguments:", args[1:])
        else:
            try:
                subprocess.call(args)
            except Exception as e:
                print(e)

if __name__ == '__main__':
    main()

当然,这个简单的 shell 没有提供特殊的引用字符,这意味着您不能用它来讨论名称中有空格的文件,因为它总是毫无例外地认为空格意味着一个参数的结束和下一个参数的开始。

通过运行这个 shell 并尝试各种您害怕使用的特殊字符,您会发现如果直接传递到您使用的常用命令中,它们毫无意义。(清单 16-2 中的shell 使用了一个]提示符,以便于与您自己的 shell 区分开来。)

$ python shell.py
] echo Hi there!
Hi there!
] echo An asterisk * is not special.
An asterisk * is not special.
] echo The string $HOST is not special, nor are "double quotes".
The string $HOST is not special, nor are "double quotes".
] echo What? No *<>!$ special characters?
What? No *<>!$ special characters?
] show "The 'show' built-in lists its arguments."
Arguments: ['"The', "'show'", 'built-in', 'lists', 'its', 'arguments."']
] exit

您可以在这里看到绝对的证据,Unix 命令——在本例中是您反复调用的/bin/echo命令——不注意它们参数中的特殊字符。echo命令很乐意接受参数列表中的双引号、美元符号和星号等字符,并将它们都视为文字字符。正如前面的show命令所示,Python 只是将您的参数简化为一个字符串列表,供操作系统在创建新进程时使用。

如果您未能将命令拆分成单独的参数,并将命令名和参数作为单个字符串传递给操作系统,那该怎么办?

>>> import subprocess
>>> subprocess.call(['echo hello'])
Traceback (most recent call last):
  ...
FileNotFoundError: [Errno 2] No such file or directory: 'echo hello'

你看到发生了什么吗?操作系统不知道空格应该是特殊的。因此,系统认为它被要求运行一个名为echo[space]hello的命令,除非您已经在当前目录中创建了这样一个文件,否则它无法找到它并引发一个异常。

事实上,有一个字符是系统特有的:空字符(Unicode 和 ASCII 码为零的字符)。在类似 Unix 的系统中,null 字符用于标记内存中每个命令行参数的结束。因此,如果您尝试在参数中使用空字符,Unix 将认为参数已经结束,并忽略其文本的其余部分。为了防止您犯这种错误,如果您在命令行参数中包含一个空字符,Python 会让您停下来。

>>> subprocess.call(['echo', 'Sentences can end\0 abruptly.'])
Traceback (most recent call last):
  ...
TypeError: embedded NUL character

令人高兴的是,由于系统上的每个命令都被设计为符合这一限制,您通常会发现无论如何都没有理由在命令行参数中使用空字符。(具体来说,它们不能出现在文件名中的原因与它们不能出现在参数列表中的原因完全相同:操作系统提供了以空终止字符串表示的文件名。)

引用保护字符

在上一节中,您使用 Python 的subprocess模块中的例程直接调用命令。这很棒,它允许您传递对于普通的交互式 shell 来说很特殊的字符。如果您有一个很大的文件名列表,其中包含空格和其他特殊字符,那么简单地将它们传递到一个子进程调用中,并让接收端的命令完全理解您的意思可能会非常好。

然而,当您在网络上使用远程 shell 协议时,您通常会与类似于bash的 shell 对话,而不是像通过subprocess模块那样直接调用命令。这意味着远程 shell 协议将感觉更像来自os模块的system()例程,它调用一个 shell 来解释您的命令,因此涉及到 Unix 命令行的所有复杂性。

>>> import os
>>> os.system('echo *')
sftp.py shell.py ssh_commands.py ssh_simple.py ssh_simple.txt ssh_threads.py telnet_codes.py
telnet_login.py

您的网络程序可能连接的各种系统和嵌入式 shells 提供了各种各样的引用和通配符约定。在某些情况下,它们可能相当神秘。然而,如果网络连接的另一端是一个标准的 Unix shell 家族,如bashzsh,那么你很幸运:通常用于构建复杂 shell 命令行的相当晦涩的 Python pipes模块包含一个非常适合转义参数的 helper 函数。它被称为quote,可以简单地传递一个字符串。

>>> from pipes import quote
>>> print(quote("filename"))
filename
>>> print(quote("file with spaces"))
'file with spaces'
>>> print(quote("file 'single quoted' inside!"))
 'file '"'"'single quoted'"'"' inside!'
>>> print(quote("danger!; rm -r *"))
'danger!; rm -r *'

因此,为远程执行准备命令行可以简单到对每个参数运行quote(),然后将结果与空格粘贴在一起。

请注意,使用 Python 向远程 shell 发送命令通常不会让您陷入两级 shell 引用的恐惧中,如果您曾经尝试构建一个本身使用花哨引用的远程 SSH 命令行,您可能会遇到这种情况。试图编写将参数传递给远程 shell 的 shell 命令往往会产生一系列类似这样的实验:

$ echo $HOST
guinness
$ ssh asaph echo $HOST
guinness
$ ssh asaph echo \$HOST
asaph
$ ssh asaph echo \\$HOST
guinness
$ ssh asaph echo \\\$HOST
$HOST
$ ssh asaph echo \\\\$HOST
\guinness

这些回答中的每一个都是合理的,你可以向自己证明。首先使用echo来查看每个命令在被本地 shell 引用时的样子,然后将该文本粘贴到远程 SSH 命令行中,以查看处理后的文本在那里是如何处理的。然而,这些命令可能很难编写,即使是经验丰富的 Unix shell 脚本编写人员在试图预测上述一系列命令的输出时也可能会猜错!

可怕的 Windows 命令行

您喜欢阅读前面关于 Unix shell 以及参数最终是如何传递给进程的部分吗?好吧,如果你打算使用远程 shell 协议连接到一台 Windows 机器,那么你可以忘记你刚刚读到的所有内容。Windows 惊人的原始。它不是将命令行参数作为单独的字符串传递给一个新进程,而是简单地将整个命令行的文本传递给正在启动的新进程,并让该进程尝试自己找出用户可能是如何引用包含空格的文件名的!

当然,仅仅是为了生存,Windows 世界的人们已经或多或少地采用了关于命令如何解释他们的参数的一致传统。例如,您可以用双引号将一个多字文件名括起来,并期望几乎所有的程序都能识别出您是在命名一个文件,而不是几个文件。大多数命令还试图理解文件名中的星号是通配符。但这总是由您正在运行的程序做出的选择,而不是由命令提示符做出的选择。

正如您将看到的,存在一种原始的网络协议——古代的 Telnet 协议——它也像 Windows 一样简单地以文本形式发送命令行,因此如果您的程序发送包含空格或特殊字符的参数,它将不得不进行某种形式的转义。但是,如果您使用的是现代远程协议,如 SSH,它允许您以字符串列表而不是单个字符串的形式发送参数,那么请注意,在 Windows 系统上,SSH 所能做的只是将您精心构建的命令行重新粘贴在一起,并希望 Windows 命令能够识别它。

当向 Windows 发送命令时,您可能想要利用 Python subprocess模块提供的list2cmdline()例程。它接受一个与 Unix 命令类似的参数列表,并尝试将它们粘贴在一起(必要时使用双引号和反斜杠),以便传统的 Windows 程序可以将命令行解析回完全相同的参数。

>>> from subprocess import list2cmdline
>>> args = ['rename', 'salary "Smith".xls', 'salary-smith.xls']
>>> print(list2cmdline(args))
rename "salary \"Smith\".xls" salary-smith.xls

对您选择的网络库和远程 shell 协议进行一些快速实验,应该有助于您了解 Windows 在您的情况下需要什么。对于本章的其余部分,我将做一个简化的假设,即您正在连接到使用现代的类似 Unix 的操作系统的服务器,该操作系统可以在不使用额外引号的情况下将离散的命令行参数分开。

在一个终端里事情是不同的

通过 Python 支持的远程连接,您可能会与更多的程序对话,而不仅仅是一个 shell。您可能经常希望观察传入的数据流,查看您正在运行的命令输出的数据和错误。有时,您还想发回数据,或者向远程程序提供输入,或者对程序提出的问题和提示做出响应。

当执行诸如此类的任务时,有时您可能会沮丧地发现程序无限期地挂起,甚至没有发送您所等待的输出。或者,您发送的数据可能看起来无法通过。为了帮助您解决这种情况,需要对 Unix 终端进行简单的讨论。

一个终端?? 是一个用户输入文本的设备,计算机的响应可以显示在它的屏幕上。如果 Unix 机器有物理串行端口,可以容纳一个物理终端,那么设备目录将包含像/dev/ttyS1这样的条目,程序可以用这些条目向该设备发送和接收字符串。然而,现在大多数终端实际上是其他程序:xterm 终端、Gnome 或 KDE 终端程序、Mac OS X iTerm 或终端,甚至是 Windows 机器上的 PuTTY 客户机,它们通过本章讨论的远程 shell 协议进行连接。

在计算机终端内运行的程序通常会试图自动检测它们是否在与人对话,只有当它们连接到终端设备时,它们才会认为它们的输出应该是为人类格式化的。因此,Unix 操作系统提供了一组名为/dev/tty42的“伪终端”设备(这些设备可能被命名为“虚拟”终端,以免混淆),如果您想让进程确信它们正在与一个真实的人进行通信,可以将进程连接到这些设备。当有人使用 xterm 或通过 SSH 连接时,xterm 或 SSH 守护进程会获取一个新的伪终端,对其进行配置,并运行附加到其上的用户 shell。shell 检查它的标准输入,发现它是一个终端,并给出一个提示,因为它认为它正在与一个人对话。

Image 因为嘈杂的电传打字机是计算机终端的最早例子,所以 Unix 常用 TTY 作为终端设备的缩写。这就是为什么测试你的输入是否是终端的调用被命名为isatty()

这是一个需要理解的关键区别:shell 给出一个提示是因为,而且仅仅是因为,它认为自己连接到了一个终端。如果您启动一个 shell 并给它一个不是终端的标准输入——比如说,来自另一个命令的管道——那么将不会打印任何提示,但是它仍然会响应命令。

$ cat | bash
echo Here we are inside of bash, with no prompt
Here we are inside of bash, with no prompt
python3
print('Python has not printed a prompt, either.')
import sys
print('Is this a terminal?', sys.stdin.isatty())

不仅bash没有打印提示,Python 也没有。事实上,Python 异常安静。虽然bash至少用一行文本响应了我们的echo命令,但是此时你已经在 Python 中输入了三行内容,却没有看到任何响应。这是怎么回事?

答案是,由于它的输入不是一个终端, Python 认为它应该从标准输入中盲目地读取整个 Python 脚本。毕竟它的输入是一个文件,文件里面有完整的脚本。为了完成 Python 正在执行的这种潜在的无休止的读取直到文件结束的操作,您必须按 Ctrl-D 向cat发送一个“文件结束”,然后它将关闭自己的输出,让示例结束。

一旦您关闭了它的输入,Python 将解释并运行您提供的三行脚本(在刚刚显示的会话中,除了单词python之外的所有内容),您将在终端上看到结果,随后是您启动的 shell 的提示符。

Python has not printed a prompt, either.
Is this a terminal? False

一些程序根据它们是否与终端对话来自动调整它们的输出格式。如果交互使用,ps命令会将每个输出行截断到您的终端宽度,但是如果它的输出是管道或文件,则会产生任意宽度的输出。此外,ls命令的基于列的输出被替换为每行一个文件名(您必须承认,这是另一个程序更容易读取的格式)。

$ ls
sftp.py   ssh_commands.py  ssh_simple.txt  telnet_codes.py
shell.py  ssh_simple.py    ssh_threads.py  telnet_login.py
$ ls | cat
sftp.py
shell.py
ssh_commands.py
ssh_simple.py
ssh_simple.txt
ssh_threads.py
telnet_codes.py
telnet_login.py

那么,所有这些和网络编程有什么关系呢?你所看到的两种行为——如果连接到终端,程序倾向于显示提示,但如果从文件或另一个命令的输出中读取,则忽略它们并静默运行——也发生在你在本章中考虑的 shell 协议的远端。

例如,一个运行在 Telnet 后面的程序总是认为它在和一个终端对话。因此,每次 shell 准备好输入时,您的脚本或程序都必须期望看到提示,以此类推。然而,当您通过更复杂的 SSH 协议建立连接时,您实际上可以选择程序是否认为它的输入是一个终端或只是一个普通的管道或文件。如果有另一台可以连接的计算机,您可以从命令行轻松测试这一点。

$ ssh -t asaph
asaph$ echo "Here we are, at a prompt."
Here we are, at a prompt.
asaph$ exit
$ ssh -T asaph
echo "The shell here on asaph sees no terminal; so, no prompt."
The shell here on asaph sees no terminal; so, no prompt.
exit
$

因此,当您通过 SSH 之类的现代协议生成一个命令时,您需要考虑是否希望远程端的程序认为您是一个通过终端输入的人,或者是否最好认为它正在与通过文件或管道输入的原始数据进行对话。

当与终端对话时,程序实际上不需要有任何不同的行为。只是为了我们的方便,他们改变了他们的行为。他们通过调用相当于 Python 的isatty()调用(“这是电传吗?”),然后根据该调用返回的内容改变它们的行为。以下是他们不同行为的一些常见方式:

  • 经常交互使用的程序在与终端对话时会给出一个可读的提示。然而,当他们认为输入来自一个文件时,他们会避免打印提示符,因为否则当您运行一个长的 shell 脚本或 Python 程序时,您的屏幕会被数百个连续的提示符弄得乱七八糟!
  • 如今,当复杂的交互程序的输入是 TTY 时,它们通常会打开命令行编辑。这使得许多控制字符很特别,因为它们习惯于访问命令行历史和执行编辑命令。当它们不受终端控制时,这些程序关闭命令行编辑,并把控制字符作为输入流的正常部分。
  • 许多程序在监听终端时一次只读取一行输入,因为人类喜欢对他们输入的每一个命令都得到立即的响应。然而,当从管道或文件中读取时,这些相同的程序会等到数千个字符到达后,才试图解释它们的第一批输入。正如您刚才看到的,bash保持在一次一行的模式,即使它的输入是一个文件,但是 Python 决定在试图执行它的第一行之前从它的输入中读取整个 Python 脚本。
  • 更常见的是,程序根据是否与终端对话来调整输出。如果用户可能正在观看,他们希望输出的每一行,甚至每一个字符立即出现。但是,如果他们只是在与一个文件或管道对话,他们会等待并批量处理大块的输出,更有效地一次发送整个块。

最后两个问题都涉及缓冲,当您采用通常手动完成的过程并试图使其自动化时,会导致各种各样的问题——因为在这样做时,您经常从终端输入转移到通过文件或管道提供的输入,并且突然发现程序的行为完全不同。它们甚至可能会挂起,因为“print”语句不会立即产生输出,而是保存它们的结果,以便在它们的输出缓冲区已满时一次全部推出。

前面的问题就是为什么许多精心编写的程序,无论是 Python 还是其他语言,都频繁地在它们的输出上调用flush() ,以确保缓冲区中等待的任何东西都继续前进并被发送出去,而不管输出看起来是否像一个终端。

因此,这些是终端和缓冲的基本问题:当与终端对话时,程序通常以特殊的方式改变它们的行为,如果它们认为它们正在写入文件或管道,而不是让您立即看到它们的输出,它们通常会开始大量缓冲它们的输出。

端子做缓冲

除了刚刚描述的特定于程序的行为之外,终端设备还会带来另一类问题。当您希望程序一次读取一个字符的输入,但是 Unix 终端设备本身正在缓冲您的击键,以便将它们作为一整行来传送时,会发生什么情况呢?这种常见的问题之所以会发生,是因为 Unix 终端默认采用“规范的”输入处理,即让用户输入一整行——甚至通过退格和重新键入来编辑它——然后最后按 enter 键,让程序看到他们键入的内容。

如果你想关闭规范处理,这样程序就可以看到输入的每个字符,你可以使用stty“设置当前 TTY 的设置”命令来禁用它。

$ stty -icanon

另一个问题是,Unix 终端传统上支持两次击键,这两次击键最初是为了让用户可以暂停输出,并在屏幕滚动并被更多文本取代之前阅读满屏的文本。通常,这些字符 Ctrl+S 表示“停止”,Ctrl+Q 表示“继续”,如果二进制数据进入自动 Telnet 连接,这是一个非常令人烦恼的事情,因为第一个 Ctrl+S 会暂停终端并可能破坏会话。

同样,可以使用stty关闭该设置。

$ stty -ixon -ixoff

这是你在终端缓冲时会遇到的两个最大的问题,但是还有很多不太出名的设置也会让你伤心。因为有太多的方式——并且因为它们在不同的 Unix 实现之间有所不同——stty命令实际上支持两种模式。模式是cookedraw,它们一起打开和关闭几十种设置,如icanonixon

$ stty raw
$ stty cooked

如果在经过一些实验后,您的终端设置变得一塌糊涂,大多数 Unix 系统都提供了一个命令,用于将终端重置为合理的设置。(请注意,如果您玩stty玩得太认真,您可能需要按 Ctrl+J 来提交重置命令,因为您的回车键(相当于 Ctrl+M)实际上只用于提交命令,因为终端设置称为icrnl。)

$ reset

如果您不是试图让终端在 Telnet 或 SSH 会话中运行,而是碰巧从自己的 Python 脚本中与终端对话,请查看标准库附带的termios模块。通过研究它的示例代码并记住布尔逐位数学是如何工作的,您应该能够控制刚才通过stty命令访问的所有相同的设置。

本书篇幅有限,无法详细介绍终端(因为一两章示例可以很容易地插入到这里,以涵盖更有趣的技术和案例),但是有很多很好的资源可以学习更多关于它们的知识——经典的是 W. Richard Stevens 的《UNIX 环境中的高级编程》第十九章“伪终端”( Addison-Wesley Professional,1992)。

远程登录

这一小段就是你在这本书中所能找到的关于古代远程登录协议的全部内容。为什么?这是不安全的:任何人看到你的 Telnet 包飞过都会看到你的用户名、密码和你在远程系统上做的一切。它很笨重,对于大多数系统管理来说,它已经被完全抛弃了。

ImageTELNET 协议

用途:远程外壳访问

标准:RFC 854 (1989)

运行于:TCP/IP

默认端口:23 库:telnetlib

异常:socket.error、socket.gaierror、EOFError、select.error

我唯一一次发现自己需要 Telnet 是在与小型嵌入式系统通信时,比如 Linksys 路由器或 DSL 调制解调器或防火墙严密的公司网络内部的网络交换机。如果您必须编写一个 Python 程序来与这些设备之一进行 Telnet 对话,这里有一些关于使用 Python telnetlib的提示。

首先,你必须意识到 Telnet 所做的只是建立一个通道——事实上,是一个相当简单的 TCP 套接字(见第三章)——然后通过这个通道双向复制信息。你输入的所有内容都通过电线发送出去,Telnet 把它收到的所有内容打印到屏幕上。这意味着 Telnet 不知道您可能希望远程外壳协议知道的所有事情。

例如,当您远程登录到 Unix 机器时,通常会出现一个login:提示,让您输入用户名,然后出现一个password:提示,让您输入密码。如今仍在使用 Telnet 的小型嵌入式设备可能遵循稍微简单一些的脚本,但是它们经常要求某种密码或认证。不管怎样,Telnet 本身对这种交换模式一无所知!对于您的 Telnet 客户机来说,password:只是九个随机字符,它们通过 TCP 连接飞过来,必须打印到您的屏幕上。它不知道你正在被提示,你正在响应,或者,过一会儿,远程系统会知道你是谁。

Telnet 不知道认证的事实有一个重要的后果:您不能给 Telnet 命令本身任何参数来对远程系统进行预认证,也不能避免首次连接时弹出的登录和密码提示。如果您打算使用普通的 Telnet,不知何故,您必须观察这两个提示(或者远程系统提供的任何提示)的输入文本,然后通过键入正确的回复进行响应。

显然,如果系统呈现的用户名和密码提示不同,那么当您的密码失败时,您很难期望打印的错误消息或响应是标准化的。这就是为什么 Telnet 很难用 Python 这样的语言编写脚本和编程。除非您知道远程系统在响应您的登录和密码时可能会输出的每一条错误消息(可能不仅仅是“错误密码”消息,还可能是“无法生成 shell:内存不足”、“未安装主目录”和“超出配额:将您限制在受限制的 shell 中”之类的消息),否则您的脚本有时会遇到等待查看命令提示符或特定错误消息的情况,相反,它将永远等待,而看不到它所识别的入站字符流中的任何内容。

因此,如果您使用 Telnet,您就是在玩一个纯文本的游戏。您等待文本到达,然后尝试用远程系统可以理解的内容进行回复。为了帮助您做到这一点,Python telnetlib 不仅提供了发送和接收数据的基本方法,还提供了一些例程来监视和等待来自远程系统的特定字符串。在这方面,telnetlib有点像我在本章前面提到的第三方 Python pexpect库,因此它有点像古老的 Unix expect命令。事实上,这些telnetlib套路之一,为了纪念它的前辈,被命名为expect()

清单 16-2 连接到一个主机,自动完成整个来回的登录对话,然后运行一个简单的命令,这样你就可以看到它的输出。这是自动化远程登录对话的最低限度。

清单 16-2 。使用 Telnet 登录远程主机

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/telnet_login.py
# Connect to localhost, watch for a login prompt, and try logging in

import argparse, getpass, telnetlib

def main(hostname, username, password):
    t = telnetlib.Telnet(hostname)
    # t.set_debuglevel(1)        # uncomment to get debug messages
    t.read_until(b'login:')
    t.write(username.encode('utf-8'))
    t.write(b'\r')
    t.read_until(b'assword:')    # first letter might be 'p' or 'P'
    t.write(password.encode('utf-8'))
    t.write(b'\r')
    n, match, previous_text = t.expect([br'Login incorrect', br'\$'], 10)
    if n == 0:
        print('Username and password failed - giving up')
    else:
        t.write(b'exec uptime\r')
        print(t.read_all().decode('utf-8'))  # read until socket closes

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Use Telnet to log in')
    parser.add_argument('hostname', help='Remote host to telnet to')
    parser.add_argument('username', help='Remote username')
    args = parser.parse_args()
    password = getpass.getpass('Password: ')
    main(args.hostname, args.username, password)

如果脚本成功,它会向您展示简单的uptime命令在远程系统上打印的内容。

$ python telnet_login.py example.com brandon
Password: *abc123*
10:24:43 up 5 days, 12:13, 14 users, load average: 1.44, 0.91, 0.73

清单向您展示了由telnetlib驱动的会话的一般结构。首先,建立一个连接,在 Python 中由一个Telnet类的实例表示。这里只指定了主机名,但是您也可以提供一个端口号来连接到标准 Telnet 之外的其他服务端口。

如果想让 Telnet 对象打印出它在会话期间发送和接收的所有字符串,可以调用set_debuglevel(1) 。事实证明,这对于编写清单中所示的非常简单的脚本非常重要,因为在两种不同的情况下,脚本挂起,我必须在打开调试消息的情况下重新运行它,以便我可以看到实际的输出并修复脚本。(有一次我无法匹配返回的确切文本,另一次我忘记了 uptime 命令末尾的'\r'。)我通常只在程序运行良好时关闭调试,然后在我想对脚本做更多工作时再打开它。

请注意,Telnet并没有掩盖它的服务由 TCP 套接字支持的事实,它会将引发的任何socket.errorsocket.gaierror异常传递给你的程序。

一旦建立了 Telnet 会话,交互通常就变成了接收-发送模式,在这种模式下,您等待来自远端的提示或响应,然后发送下一条信息。该清单说明了等待文本到达的两种方法:

  • 简单的read_until()方法等待一个文字字符串到达,然后它返回一个字符串,该字符串提供了从它开始列出到最终看到您等待的字符串的所有文本。
  • 更强大、更复杂的expect()方法采用 Python 正则表达式列表。一旦来自远端的文本最终与某个正则表达式相匹配,expect()返回三项:匹配模式列表中的索引、正则表达式SRE_Match对象本身,以及接收到的导致匹配文本的文本。关于如何使用SRE_Match的更多信息,包括查找模式中任何子表达式的值,请阅读re模块的标准库文档。

正则表达式一如既往,必须认真编写。当我第一次编写这个脚本时,我使用'$'作为expect()模式来等待 shell 提示符出现——唉,这是正则表达式中的一个特殊字符!因此,清单中显示的修改后的脚本对$进行了转义,这样expect()实际上会一直等待,直到看到来自远端的美元符号。

如果脚本因为密码不正确而看到一条错误消息,并且没有永远等待登录或密码提示,而这些提示从未到达或者看起来与预期不同,那么它将退出。

$ python telnet_login.py example.com brandon
Password: *wrongpass*
Username and password failed - giving up

如果您最终编写了一个必须使用 Telnet 的 Python 脚本,它将只是这里显示的相同简单模式的一个更大或更复杂的版本。

read_until()expect()都有一个可选的第二个参数,名为timeout,这个参数限制了调用在放弃并将控制权返回给 Python 脚本之前,以秒为单位观察文本模式的最长时间。如果它们因为超时而退出并放弃,它们不会引发错误;相反(非常尴尬),他们只是返回到目前为止看到的文本,并让您来判断该文本是否包含模式!

在 Telnet 对象中有一些零碎的东西,我不需要在这里介绍。你可以在telnetlib标准库文档中找到它们,包括一个interact()方法,它允许用户使用终端直接通过你的 Telnet 连接“交谈”!这种调用在过去很流行,那时您希望自动登录,但是自己控制并发出普通命令。

Telnet 协议确实有嵌入控制信息的约定,并且telnetlib小心地遵循这些协议规则,以将您的数据与出现的任何控制代码分开。因此,您可以使用一个Telnet对象来发送和接收您想要的所有二进制数据,并且忽略控制代码也可能到达的事实。但是,如果您正在做一个复杂的基于 Telnet 的项目,那么您可能需要处理选项。

通常,每次 Telnet 服务器发送选项请求时,telnetlib都会断然拒绝发送或接收该选项。但是,您可以为 Telnet 对象提供自己的回调函数来处理选项。一个适度的例子显示在清单 16-3 中。对于大多数选项,它只是重新实现默认的telnetlib行为,并拒绝处理任何选项。(永远记住以这样或那样的方式回应每个选项;不这样做通常会挂起 Telnet 会话,因为服务器会永远等待您的回复。)如果服务器表示对“终端类型”选项感兴趣,那么该客户端发送一个回复mypython,它在登录后运行的 shell 命令将该回复视为其$TERM环境变量。

清单 16-3 。如何处理 Telnet 选项代码

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/telnet_codes.py
# How your code might look if you intercept Telnet options yourself

import argparse, getpass
from telnetlib import Telnet, IAC, DO, DONT, WILL, WONT, SB, SE, TTYPE

def process_option(tsocket, command, option):
    if command == DO and option == TTYPE:
        tsocket.sendall(IAC + WILL + TTYPE)
        print('Sending terminal type "mypython"')
        tsocket.sendall(IAC + SB + TTYPE + b'\0' + b'mypython' + IAC + SE)
    elif command in (DO, DONT):
        print('Will not', ord(option))
        tsocket.sendall(IAC + WONT + option)
    elif command in (WILL, WONT):
        print('Do not', ord(option))
        tsocket.sendall(IAC + DONT + option)

def main(hostname, username, password):
    t = Telnet(hostname)
    # t.set_debuglevel(1)        # uncomment to get debug messages
    t.set_option_negotiation_callback(process_option)
    t.read_until(b'login:', 10)
    t.write(username.encode('utf-8') + b'\r')
    t.read_until(b'password:', 10)    # first letter might be 'p' or 'P'
    t.write(password.encode('utf-8') + b'\r')
    n, match, previous_text = t.expect([br'Login incorrect', br'\$'], 10)
    if n == 0:
        print("Username and password failed - giving up")
    else:
        t.write(b'exec echo My terminal type is $TERM\n')
        print(t.read_all().decode('ascii'))

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Use Telnet to log in')
    parser.add_argument('hostname', help='Remote host to telnet to')
    parser.add_argument('username', help='Remote username')
    args = parser.parse_args()
    password = getpass.getpass('Password: ')
    main(args.hostname, args.username, password)

有关 Telnet 选项如何工作的更多详细信息,您也可以参考相关的 RFC。在下一节中,我将抛开古老的不安全的 Telnet 协议,开始讨论一种运行远程命令的现代而安全的方法。

SSH:安全外壳

SSH 协议是安全加密协议最著名的例子之一(HTTPS 可能是最著名的)。

ImageSSH 协议

用途:安全远程外壳、文件传输、端口转发

标准:RFC 4250–4256(2006)

运行于:TCP/IP

默认端口:22

库:paramiko

异常:socket.error、socket.gaierror、paramiko。SSHException

SSH 起源于一个早期的协议,该协议支持名为rloginrshrcp的“远程登录”、“远程 shell”和“远程文件复制”命令,在当时,这些命令在支持它们的站点上比 Telnet 更受欢迎。你无法想象rcp是一个怎样的启示,特别是,除非你花了几个小时试图在只有 Telnet 和一个试图为你输入密码的脚本的计算机之间传输一个二进制文件,却发现你的文件包含一个看起来像 Telnet 或远程终端的控制字符的字节,导致整个事情挂起,直到你添加一个转义层(或者想出如何禁用 Telnet escape 键和所有发生在远程终端上的解释)。

然而,rlogin 家族成员的最大特点是,他们不只是在不知道正在发生的事情的意义的情况下重复用户名和密码提示。相反,他们在整个认证过程中都参与其中,你甚至可以在你的主目录下创建一个文件,告诉他们“当一个叫brandon的人试图从asaph机器连接时,不需要密码就让他们进来。”突然之间,系统管理员和 Unix 用户每个月都有了原本用于输入密码的时间。此外,突然间你可以rcp将十个文件从一台机器复制到另一台机器上,就像你将它们复制到本地文件夹中一样容易。

SSH 保留了早期远程 shell 协议的所有这些优秀特性,同时带来了安全性和硬加密,在管理关键服务器方面得到了全世界的信任。本章将关注第三方的paramiko Python 包,它可以使用 SSH 协议,并且做得如此成功,以至于它实际上也已经被移植到 Java 上,因为 Java 世界的人们希望能够像我们使用 Python 时一样容易地使用 SSH。

SSH 概述

这本书的第一部分谈了很多关于多路复用的内容——关于 UDP ( 第二章)和 TCP ( 第三章)如何采用底层 IP 协议,该协议没有考虑到实际上可能有几个用户或应用在一台计算机上需要通信,并添加了 UDP 和 TCP 端口号的概念,以便一对 IP 地址之间可以同时进行几个不同的对话。

一旦复用的基本层次建立起来,我们或多或少就把这个话题抛在了脑后。到目前为止,我们已经学习了十几章的协议,这些协议采用 UDP 或 TCP 连接,然后愉快地将它用于一件事——下载网页或发送电子邮件——从不试图通过一个套接字同时做几件事。

现在我们来到 SSH,我们发现一个非常复杂的协议,它实际上实现了自己的多路复用。几个信息“通道”可以共享同一个 SSH 套接字。SSH 通过其套接字发送的每个信息块都标有一个“通道”标识符,以便几个会话可以共享该套接字。

子通道有意义至少有两个原因。首先,尽管通道 ID 为传输的每个信息块占用了一点带宽,但是与 SSH 为了协商和维护加密而必须传输的额外信息相比,额外的数据是很少的。其次,通道是有意义的,因为 SSH 连接的真正开销是设置它。主机密钥协商和身份验证总共需要几秒钟的时间,一旦建立了连接,您就希望能够使用它进行尽可能多的操作。由于通道的 SSH 概念,您可以在关闭连接之前通过执行许多操作来分摊连接的高成本。

连接后,您可以创建几种通道:

  • 交互式 shell 会话,如 Telnet 支持的会话
  • 单个命令的单独执行
  • 让您浏览远程文件系统的文件传输会话
  • 截取 TCP 连接的端口转发

在接下来的部分中,您将了解所有这些类型的渠道。

SSH 主机密钥

当一个 SSH 客户机第一次连接到一个远程主机时,两者交换临时公钥,这样它们就可以加密剩下的对话,而不会向任何正在监视的第三方透露任何信息。然后,在客户机愿意透露任何进一步的信息之前,它要求证明远程服务器的身份。这是很有意义的第一步:如果你真的在和一个暂时设法获取远程服务器 IP 的黑客软件交谈,你不希望 SSH 泄露你的用户名——更不用说你的密码了!

正如你在第六章中看到的,互联网上机器身份问题的一个答案是建立一个公钥基础设施。首先,您指定一组名为认证机构 的组织来发布证书。然后,在所有的 web 浏览器和其他现有的 SSL 客户机中安装它们的公钥列表。然后这些组织向你收费,以验证你真的是google.com(或者你是谁)并且你应该得到你的google.com SSL 证书的签名。最后,您可以在 web 服务器上安装证书,每个人都会信任您的身份。

从 SSH 的角度来看,这个系统有很多问题。虽然您确实可以在组织内部构建一个公钥基础设施,将您自己的签名机构的证书分发到您的 web 浏览器或其他应用,然后可以在不支付第三方费用的情况下签署您自己的服务器证书,但是对于 SSH 之类的东西来说,公钥基础设施仍然是一个非常麻烦的过程。服务器管理员希望一直设置、使用和拆除服务器,而不必先与中央机构联系。

因此,SSH 的想法是,每台服务器在安装时都会创建自己的随机公钥-私钥对,这个密钥对没有经过任何人的签名。相反,两种方法中的一种被用于密钥分发。

  • 系统管理员编写一个脚本,收集组织中的所有主机公钥,创建一个列出所有公钥的ssh_known_hosts,并将该文件放在组织中每个系统的/etc/sshd目录中。他们还可能使它对任何桌面客户端都可用,比如 Windows 下的 PuTTY 命令。现在,每个 SSH 客户机甚至在第一次连接之前就知道每个 SSH 主机密钥。
  • 或者,管理员可以简单地放弃提前知道主机密钥的想法,而是让每个 SSH 客户端在第一次连接时记住它们。SSH 命令行的用户对此会很熟悉:客户机说它不识别您正在连接的主机,您本能地回答“是”,它的密钥存储在您的~/.ssh/known_hosts文件中。实际上,在第一次见面时,你无法保证你真的在和你认为的主人交谈。尽管如此,至少你可以保证,你对那台机器的每一次后续连接都是到正确的地方,而不是到其他服务器,而这些服务器是有人在同一个 IP 地址交换的(当然,除非有人偷了那台主机的密钥)。

当 SSH 命令行看到不熟悉的主机时,熟悉的提示符如下所示:

$ ssh asaph.rhodesmill.org
The authenticity of host 'asaph.rhodesmill.org (74.207.234.78)' can't be established.
RSA key fingerprint is 85:8f:32:4e:ac:1f:e9:bc:35:58:c1:d4:25:e3:c7:8c.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added 'asaph.rhodesmill.org,74.207.234.78' (RSA) to the list of known hosts.

深埋在倒数第二个完整行中的答案是我输入的答案,它让宋承宪可以进行连接并记住下次使用的密钥。如果 SSH 曾经连接到一个主机并看到一个不同的密钥,它的反应是非常严重的。

$ ssh asaph.rhodesmill.org
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!

任何曾经不得不从头开始重建服务器并忘记保存旧的 SSH 密钥的人都会熟悉这条消息。如果没有它们,新重建的主机现在将使用重新安装生成的新密钥。走到所有 SSH 客户端并删除有问题的旧密钥,以便它们在重新连接时悄悄地学习新密钥,这可能是很痛苦的。

paramiko库完全支持围绕主机密钥的所有常规 SSH 策略。然而,它的默认行为是相当宽容的。默认情况下,它不加载任何主机密钥文件,因此它必须为您连接的第一台主机引发一个异常,因为它将无法验证其密钥。

>>> import paramiko
>>> client = paramiko.SSHClient()
>>> client.connect('example.com', username='test')
Traceback (most recent call last):
  ...
paramiko.ssh_exception.SSHException: Server 'example.com' not found in known_hosts

要像普通的 SSH 命令一样工作,在建立连接之前加载系统和当前用户的已知主机密钥。

>>> client.load_system_host_keys()
>>> client.load_host_keys('/home/brandon/.ssh/known_hosts')
>>> client.connect('example.com', username='test')

paramiko 库还允许您选择如何处理未知主机。一旦创建了一个客户机对象,就可以为它提供一个决策类,当主机键未被识别时,会询问该如何处理。您可以通过继承MissingHostKeyPolicy类来自己构建这些类。

>>> class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
...    def missing_host_key(self, client, hostname, key):
...        return
...
>>> client.set_missing_host_key_policy(AllowAnythingPolicy())
>>> client.connect('example.com', username='test')

请注意,通过对missing_host_key()方法 的争论,您可以获得几条信息来作为决策的基础。例如,您可以允许在没有主机密钥的情况下连接到您自己的服务器子网上的机器,但不允许所有其他机器。

在 paramiko 中,也有几个决策类已经实现了几个基本的主机键选项。

  • paramiko.AutoAddPolicy:当第一次遇到主机密钥时,主机密钥会自动添加到您的用户主机密钥库(Unix 系统上的文件~/.ssh/known_hosts)中,但是从那时起,主机密钥的任何更改都会引发致命的异常。
  • 使用未知密钥连接到主机只会引发一个异常。
  • paramiko.WarningPolicy:未知主机导致记录警告,但允许连接继续。

当编写一个将执行 SSH 的脚本时,我总是从使用普通的ssh命令行工具“手动”连接到远程主机开始,这样我就可以对它的提示回答“是”,并在我的主机密钥文件中获得远程主机的密钥。这样,我的程序就永远不必担心处理丢失键的情况,如果遇到错误,也不会出错。

然而,如果你不像我一样喜欢手工操作,那么AutoAddPolicy可能是你最好的选择。它从来不需要人类的互动,但它至少会在随后的遭遇中向你保证,你仍然和以前一样在和同一台机器说话。因此,即使这台机器是一个特洛伊木马,它正在记录你与它的所有交互并秘密记录你的密码(如果你正在使用的话),它至少必须向你证明它在你每次连接时都持有相同的密钥。

SSH 认证

SSH 验证的整个主题是大量优秀文档、文章和博客文章的主题,所有这些都可以在 Web 上找到。关于配置常见的 SSH 客户端、在 Unix 或 Windows 主机上设置 SSH 服务器以及使用公共密钥来验证自己的身份(这样您就不必一直输入密码)的信息非常丰富。因为这一章主要是关于如何在 Python 中“说 SSH ”,所以我将简单概述一下认证是如何工作的。

通常有三种方法向您通过 SSH 联系的远程服务器证明您的身份。

  • 您可以提供用户名和密码。
  • 您可以提供一个用户名,然后让您的客户端成功地执行一个公钥挑战响应。这个巧妙的操作设法证明您拥有一个秘密的“身份”密钥,而不会将其内容暴露给远程系统。
  • 您可以执行 Kerberos 身份验证。如果远程系统被设置为允许 Kerberos(这在目前看来非常罕见),并且如果您已经运行了kinit命令行工具来向 SSH 服务器的认证域中的一个主 Kerberos 服务器证明您的身份,那么您应该可以在没有密码的情况下进入。

由于第三种选择很少,我们将集中讨论前两种。

在 paramiko 中使用用户名和密码很简单——您只需在对connect()方法的调用中提供它们。

>>> client.connect('example.com', username='brandon', password=mypass)

公钥认证使用ssh-keygen创建一个“身份”密钥对(通常存储在~/.ssh目录中),无需密码就可以用来认证您的身份,这使得 Python 代码更加简单!

>>> client.connect('my.example.com')

如果您的身份密钥文件不是存储在普通的~/.ssh/id_rsa文件中,那么您可以手动向connect()方法提供它的文件名——或者一个完整的 Python 文件名列表。

>>> client.connect('my.example.com', key_filename='/home/brandon/.ssh/id_sysadmin')

当然,根据 SSH 的一般规则,只有在将id_sysadmin.pub文件中的公钥附加到远程端的“authorized hosts”文件之后,提供这样的公钥身份才有效,通常命名为:

/home/brandon/.ssh/authorized_keys

如果您在让公钥认证工作时遇到问题,请始终检查远程.ssh目录和其中文件的文件权限。如果 SSH 服务器的某些版本看到这些文件是组可读或组可写的,它们会感到不安。对.ssh目录使用模式 0700,对里面的文件使用模式 0600,往往会让 SSH 最开心。在最近的版本中,将 SSH 密钥复制到其他帐户的任务实际上已经通过一个小命令实现了自动化,该命令将确保为您正确设置文件权限。

ssh-copy-id -i ~/.ssh/id_rsa.pub myaccount@example.com

一旦connect()方法成功,您现在就可以开始执行远程操作了,所有这些操作都将通过同一个物理套接字转发,而不需要重新协商主机密钥、您的身份或保护 SSH 套接字本身的加密。

Shell 会话和单个命令

一旦您有了一个连接的 SSH 客户机,SSH 操作的整个世界就向您敞开了。只需询问,您就可以访问远程 shell 会话,运行单独的命令,开始文件传输会话,并设置端口转发。您将依次查看这些操作。

首先,SSH 可以为您建立一个原始的 shell 会话,运行在虚拟终端内的远程终端上,这样程序在终端上与用户交互时就像平时一样。这种连接的行为非常像 Telnet 连接。看一下清单 16-4 中的示例,它在远程 shell 中推送一个简单的echo命令,然后要求它退出。

清单 16-4 。在 SSH 下运行交互式 Shell】

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/ssh_simple.py
# Using SSH like Telnet: connecting and running two commands

import argparse, paramiko, sys

class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
    def missing_host_key(self, client, hostname, key):
        return

def main(hostname, username):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(AllowAnythingPolicy())
    client.connect(hostname, username=username)  # password='')

    channel = client.invoke_shell()
    stdin = channel.makefile('wb')
    stdout = channel.makefile('rb')

    stdin.write(b'echo Hello, world\rexit\r')
    output = stdout.read()
    client.close()

    sys.stdout.buffer.write(output)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Connect over SSH')
    parser.add_argument('hostname', help='Remote machine name')
    parser.add_argument('username', help='Username on the remote machine')
    args = parser.parse_args()
    main(args.hostname, args.username)

您可以看到这个脚本带有在终端上运行的程序的痕迹。它不能整齐地封装它发出的两个命令中的每一个并分离它们的参数,而是必须使用空格和回车,并信任远程 shell 来正确地划分内容。请注意,这个脚本是在假设您有一个身份文件和一个远程授权密钥文件的情况下编写的,因此不需要键入密码。如果是这样,那么您可以使用注释掉的 password 参数来编辑脚本以提供一个。为了避免在 Python 文件中输入密码,您可以让它调用getpass() ,就像您在 Telnet 示例中所做的那样。

此外,如果您运行这个命令,您将看到您键入的命令实际上被回显了两次,并且没有明显的方法将这些命令回显与实际的命令输出分开。

Welcome to Ubuntu 13.10 (GNU/Linux 3.11.0-19-generic x86_64)
Last login: Wed Apr 23 15:06:03 2014 from localhost

echo Hello, world
exit
test@guinness:~$ echo Hello, world
Hello, world
test@guinness:~$ exit
logout

你能猜到发生了什么事吗?

因为在发出echoexit命令(这需要一个循环重复进行read()调用)之前,您没有暂停并耐心等待 shell 提示,所以命令文本在远程主机发出欢迎消息的过程中就被发送到了远程主机。因为 Unix 终端在默认情况下处于“熟”状态,它会响应用户的击键,所以命令会打印出来,就在“Last login”行的下面。

然后实际的bash shell 启动,将终端设置为raw模式,因为它喜欢提供自己的命令行编辑界面,然后开始逐字符读取命令。因为它假设您希望看到您正在键入的内容(即使您实际上已经完成了键入,并且它只是从几毫秒前的缓冲区中读取字符),所以它会将每个命令第二次回显到屏幕上。

当然,如果没有很好的解析和智能,您将很难编写一个 Python 例程,从您通过 SSH 连接接收的输出中挑选出实际的命令输出(单词Hello, world)。

由于所有这些古怪的、依赖于终端的行为,你通常应该避免使用invoke_shell() ,除非你实际上是在编写一个交互式终端程序,让一个真实的用户输入命令。

运行远程命令的一个更好的选择是使用exec_command(),而不是启动整个 shell 会话,只运行一个命令。它让您可以控制该命令的标准输入、输出和错误流,就像您使用标准库中的 subprocess模块在本地运行该命令一样。清单 16-5 显示了一个演示其使用的脚本。exec_command() 和本地子进程的区别(当然,除了命令在远程机器上运行的事实之外!)的缺点是您没有机会将命令行参数作为单独的字符串传递给远程服务器。相反,您必须传递一个完整的命令行,以便由远程端的 shell 进行解释。

清单 16-5 。运行单个 SSH 命令

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/ssh_commands.py
# Running three separate commands, and reading three separate outputs

import argparse, paramiko

class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
    def missing_host_key(self, client, hostname, key):
        return

def main(hostname, username):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(AllowAnythingPolicy())
    client.connect(hostname, username=username)  # password='')

    for command in 'echo "Hello, world!"', 'uname', 'uptime':
        stdin, stdout, stderr = client.exec_command(command)
        stdin.close()
        print(repr(stdout.read()))
        stdout.close()
        stderr.close()

    client.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Connect over SSH')
    parser.add_argument('hostname', help='Remote machine name')
    parser.add_argument('username', help='Username on the remote machine')
    args = parser.parse_args()
    main(args.hostname, args.username)

与我们之前的所有 Telnet 和 SSH 对话不同,这个脚本将这三个命令的输出作为完全独立的数据流接收。不会将其中一个命令的输出与任何其他命令的输出混淆。

$ python3 ssh_commands.py localhost brandon
'Hello, world!\n'
'Linux\n'
'15:29:17 up 5 days, 22:55,  5 users,  load average: 0.78, 0.83, 0.71\n'

除了安全性之外,这是 SSH 提供的巨大进步:能够在远程机器上执行语义上独立的任务,而不必单独连接到远程机器。

正如在前面的“Telnet”一节中提到的,如果您需要引用命令行参数,以便包含文件名和特殊字符的空格能够被远程 shell 正确解释,那么在为exec_command()函数构建命令行时,您可能会发现 Python pipes 模块中的quotes()非常有用。

每次使用invoke_shell()启动一个新的 SSH shell 会话,以及每次使用exec_command()启动一个命令,都会在后台创建一个新的 SSH“通道”来提供类似文件的 Python 对象,让您可以与远程命令的标准输入、输出和错误流进行对话。这些通道并行运行,SSH 会在您的单个 SSH 连接上巧妙地交错它们的数据,以便所有的对话同时发生而不会混淆。

看一下清单 16-6 中的一个简单例子。这里有两个命令行是远程启动的,每个都是一个简单的 shell 脚本,其中有一些夹杂着sleep停顿的echo命令。如果您愿意,您可以假装这些是真正的文件系统命令,它们在遍历文件系统时返回数据,或者它们是 CPU 密集型操作,只缓慢地生成和返回结果。对于宋承宪来说,这种差异根本无关紧要。重要的是,这些通道每次都处于空闲状态几秒钟,然后随着更多的数据变得可用而再次活跃起来。

清单 16-6 。SSH 通道并行运行

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/ssh_threads.py
# Running two remote commands simultaneously in different channels

import argparse, paramiko, threading

class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
    def missing_host_key(self, client, hostname, key):
        return

def main(hostname, username):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(AllowAnythingPolicy())
    client.connect(hostname, username=username)  # password='')

    def read_until_EOF(fileobj):
        s = fileobj.readline()
        while s:
            print(s.strip())
            s = fileobj.readline()

    ioe1 = client.exec_command('echo One;sleep 2;echo Two;sleep 1;echo Three')
    ioe2 = client.exec_command('echo A;sleep 1;echo B;sleep 2;echo C')
    thread1 = threading.Thread(target=read_until_EOF, args=(ioe1[1],))
    thread2 = threading.Thread(target=read_until_EOF, args=(ioe2[1],))
    thread1.start()
    thread2.start()
    thread1.join()
    thread2.join()

    client.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Connect over SSH')
    parser.add_argument('hostname', help='Remote machine name')
    parser.add_argument('username', help='Username on the remote machine')
    args = parser.parse_args()
    main(args.hostname, args.username)

为了能够同时处理这两个数据流,您启动了两个线程,并为它们提供了一个读取通道。两者都在新信息到达时打印每一行,并在readline()命令通过返回一个空字符串指示文件结束时退出。运行时,该脚本应该返回如下内容:

$ python3 ssh_threads.py localhost brandon
One
A
B
Two
Three
C

正如您所看到的,同一 TCP 连接上的 SSH 通道是完全独立的,每个通道都可以按照自己的速度接收(和发送)数据,并且可以在它们正在对话的特定命令最终终止时独立关闭。您即将看到的功能也是如此——文件传输和端口转发。

SFTP:通过 SSH 进行文件传输

SSH 协议的第 2 版包括一个名为 SSH 文件传输协议(SFTP) 的子协议,它允许您遍历远程目录树,创建和删除目录和文件,以及在本地和远程机器之间来回复制文件。事实上,SFTP 的功能是如此复杂和完整,它们不仅支持简单的文件复制操作,而且它们还可以支持图形化的文件浏览器,甚至可以让远程文件系统安装在本地!(谷歌一下sshfs系统了解详情。)

对于我们这些曾经不得不使用脆弱的脚本复制文件的人来说,SFTP 协议是一个不可思议的福音,脆弱的脚本试图通过小心地转义二进制数据来通过 Telnet 发送数据。每次您想要移动文件时,SSH 不会让您启动它自己的sftp命令行,而是遵循 RSH 的传统,提供一个scp命令行工具,其行为就像传统的cp命令一样,但是它允许您在任何文件名前面加上hostname:来表示它存在于远程机器上。这意味着远程复制命令保留在您的命令行历史中,就像您的其他 shell 命令一样,而不是丢失到单独命令提示符的单独历史缓冲区中,您必须调用然后退出(这是传统 FTP 客户端的一大烦恼)。

此外,SFTP 和 sftpscp命令的最大成就在于,它们不仅支持密码认证,还允许你使用相同的公钥机制复制文件,这让你在使用ssh命令运行远程命令时避免反复输入密码。

如果你在旧的 FTP 系统上简单浏览一下第十七章,你会对 SFTP 支持的操作种类有一个很好的了解。事实上,大多数 SFTP 命令与您已经运行的用于操作 Unix shell 帐户上的文件的本地命令同名,如chmodmkdir,或者与您可能已经通过 Python os模块熟悉的 Unix 系统调用同名,如lstatunlink。因为这些操作是如此的熟悉,所以在编写 SFTP 命令时,除了在www.lag.net/paramiko/docs/paramiko . SFTPClient-class为 Python SFTP 客户端提供的 paramiko 文档之外,我从不需要任何其他支持。

以下是在 SFTP 时要记住的主要事情:

  • SFTP 协议是有状态的,就像 FTP 和普通的 shell 帐户一样。因此,您可以将所有文件名和目录名作为从文件系统根目录开始的绝对路径传递,或者使用getcwd()chdir()在文件系统中移动,然后使用相对于您到达的目录的路径。
  • 您可以使用file()open()方法打开一个文件(就像 Python 有一个内置的 callable,它存在于两个名称下),然后您得到一个类似 file 的对象,它连接到一个独立于您的 SFTP 通道运行的 SSH 通道。也就是说,您可以继续发出 SFTP 命令,然后在文件系统中移动,复制或打开更多的文件,原始通道仍将连接到其文件,并准备好进行读写。
  • 因为每个打开的远程文件都有一个独立的通道,所以文件传输可以异步进行。您可以一次打开许多远程文件,并让它们全部传输到您的磁盘驱动器,或者打开新文件并以另一种方式发送数据。注意你要认识到这一点,否则你可能会同时打开如此多的通道,以至于每一个都慢得像爬行一样。
  • 最后,请记住,您在 SFTP 传递的任何文件名都不会进行 shell 扩展。如果您尝试使用类似于*的文件名或包含空格或特殊字符的文件名,它们会被简单地解释为文件名的一部分。使用 SFTP 时不涉及 shell。由于 SSH 服务器本身的支持,您可以直接与远程文件系统对话。这意味着,您想要向用户提供的任何模式匹配支持都必须通过自己获取目录内容,然后使用 Python 标准库中的fnmatch中提供的例程,对照每个内容检查它们的模式。

清单 16-7 显示了一个 SFTP 会话的例子。它做一些系统管理员可能经常需要的简单事情(但是,当然,他们也可以用一个scp命令轻松完成):它连接到远程系统并从/var/log目录中复制消息日志文件,也许是为了在本地机器上进行扫描或分析。

清单 16-7 。列出一个目录并用 SFTP 抓取文件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/sftp_get.py
# Fetching files with SFTP

import argparse, functools, paramiko

class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
    def missing_host_key(self, client, hostname, key):
        return

def main(hostname, username, filenames):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(AllowAnythingPolicy())
    client.connect(hostname, username=username)  # password='')

    def print_status(filename, bytes_so_far, bytes_total):
        percent = 100\. * bytes_so_far / bytes_total
        print('Transfer of %r is at %d/%d bytes (%.1f%%)' % (
            filename, bytes_so_far, bytes_total, percent))

    sftp = client.open_sftp()
    for filename in filenames:
        if filename.endswith('.copy'):
            continue
        callback = functools.partial(print_status, filename)
        sftp.get(filename, filename + '.copy', callback=callback)
    client.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Copy files over SSH')
    parser.add_argument('hostname', help='Remote machine name')
    parser.add_argument('username', help='Username on the remote machine')
    parser.add_argument('filename', nargs='+', help='Filenames to fetch')
    args = parser.parse_args()
    main(args.hostname, args.username, args.filename)

注意,虽然我花了很大篇幅谈论你用 SFTP 打开的每个文件是如何使用它自己的独立通道的,但是 paramiko 提供的简单的get()put()便利函数,是一个open()的轻量级包装器,后面是一个读写循环,并没有尝试任何异步;相反,它们只是阻塞并等待,直到每个完整的文件到达。这意味着前面的脚本一次平静地传输一个文件,产生类似下面的输出:

$ python sftp.py guinness brandon W-2.pdf miles.png
Transfer of 'W-2.pdf' is at 32768/115065 bytes (28.5%)
Transfer of 'W-2.pdf' is at 65536/115065 bytes (57.0%)
Transfer of 'W-2.pdf' is at 98304/115065 bytes (85.4%)
Transfer of 'W-2.pdf' is at 115065/115065 bytes (100.0%)
Transfer of 'W-2.pdf' is at 115065/115065 bytes (100.0%)
Transfer of 'miles.png' is at 15577/15577 bytes (100.0%)
Transfer of 'miles.png' is at 15577/15577 bytes (100.0%)

再次参考刚才提到的 URL 上的优秀的 paramiko 文档,查看 SFTP 支持的简单而完整的文件操作集。

其他特征

在过去的几节中,我已经介绍了基本SSHClient对象上的方法所支持的所有 SSH 操作。您可能比较熟悉的模糊特性,比如远程 X11 会话和端口转发,要求您在 paramiko 接口中更深入一层,直接与客户机的“transport”对象对话。

传输层是实际上知道底层操作的类,这些底层操作组合在一起为 SSH 连接提供动力。你可以很容易地向客户要求运输。

>>> transport = client.get_transport()

虽然我没有足够的篇幅在这里介绍其他 SSH 特性,但是您在本章中获得的对 SSH 的理解应该有助于您理解它们,因为 paramiko 文档结合了示例代码——无论是从 paramiko 项目本身的demos目录,还是从博客、Stack Overflow 或您可能在网上找到的关于 paramiko 的其他资料。

我应该明确提到的一个特性是端口转发,其中 SSH 在本地或远程主机上打开一个端口——至少使该端口可用于来自本地主机的连接,也可能接受来自互联网上其他机器的连接——并通过 SSH 通道“转发”这些连接,在 SSH 通道上连接到远程端的其他主机和端口,来回传递数据。

端口转发很有用。例如,我有时发现自己在开发一个 web 应用,但我无法在笔记本电脑上轻松运行,因为它需要访问数据库和其他资源,而这些资源只能在服务器群中获得。但是我可能不想要在公共端口上运行应用的麻烦,在那里我可能不得不调整防火墙规则来打开它,然后让 HTTPS 运行,这样第三方就看不到我正在进行的工作。

一个简单的解决方案是在远程开发机器上运行正在开发的 web 应用,就像我在本地一样——监听 localhost:8080,这样就不能从另一台计算机上联系到它——然后告诉 SSH,我希望在我的笔记本电脑上建立的到我的本地端口 8080 的连接被转发出去,这样它们就可以真正连接到本地机器上的端口 8080。

$ ssh -L 8080:localhost:8080 devel.example.com

如果在用 paramiko 运行 SSH 连接时需要创建端口转发,那么我有一个坏消息和一个好消息。坏消息是顶级 SSHClient 不提供创建转发的简单方法,因为它支持更常见的操作,比如 shell 会话。相反,您必须通过直接与“transport”对象对话来创建转发,然后自己编写在转发上双向复制数据的循环。

但是好消息是 paramiko 附带了示例脚本,展示了如何编写端口转发循环。paramiko 主干中的这两个脚本应该可以帮助您入门:

http://github.com/paramiko/paramiko/blob/master/demos/forward.py
http://github.com/paramiko/paramiko/blob/master/demos/rforward.py

当然,由于端口转发数据在 SSH 连接内部的通道之间来回传递,所以您不必担心它们是原始的、未受保护的 HTTP 或其他通常对第三方可见的流量;因为它们现在嵌入在 SSH 中,所以它们受到自身加密的保护,不会被拦截。

摘要

远程 shell 协议允许您连接到远程机器,运行 shell 命令,并查看它们的输出,就像命令在本地终端窗口中运行一样。有时您使用这些协议连接到实际的 Unix shell,有时连接到路由器或其他需要配置的网络硬件中的小型嵌入式 shell。

和往常一样,在使用 Unix 命令时,您需要注意输出缓冲、特殊 shell 字符和终端输入缓冲等问题,这些问题会通过篡改数据甚至挂起 shell 连接而使您的生活变得困难。

Python 标准库通过其telnetlib模块支持 Telnet 协议。尽管 Telnet 是古老的、不安全的、难以编写脚本,但它可能是您想要连接的简单设备所支持的唯一协议。

安全 Shell 协议是当前的技术水平,不仅用于连接到远程主机的命令行,还用于复制文件和转发 TCP/IP 端口。得益于第三方 paramiko 包,Python 拥有相当出色的 SSH 支持。当建立 SSH 连接时,您需要记住三件事。

  • Paramiko 将需要验证(或者被明确告知忽略)远程机器的身份,该身份被定义为建立连接时它所提供的主机密钥。
  • 认证通常通过一个密码或通过使用一个公钥-私钥对来完成,公钥-私钥对的公钥部分已经放在远程服务器上的authorized_keys文件中。
  • 一旦通过身份验证,您就可以启动所有种类的 SSH 服务——远程 shells、单个命令和文件传输会话——它们都可以立即运行,而无需您打开新的 SSH 连接,这是因为它们都将在主 SSH 连接中获得自己的“通道”。

下一章将考察一个更老、功能更弱的文件传输协议,它可以追溯到互联网的早期:SFTP 所基于的文件传输协议。

十七、FTP

文件传输协议(FTP)曾经是互联网上使用最广泛的协议之一,每当用户想要在联网的计算机之间传输文件时都会调用。唉,《议定书》曾经风光一时,如今,它的每一个主要角色都有了更好的替代方案。

FTP 曾经支持四种主要活动。FTP 的第一个也是最主要的用途是文件下载。允许公众访问的“匿名”FTP 服务器列表被分发,用户连接以检索文档、新程序的源代码以及图像或电影等媒体。(你用用户名“anonymous”或“ftp”登录他们,然后——出于礼貌,让他们知道谁在使用他们的带宽——你输入你的电子邮件地址作为密码。)当文件需要在计算机帐户之间移动时,FTP 总是首选的协议,因为试图用 Telnet 客户端传输大文件通常是一个冒险的提议。

第二,FTP 经常被人为操纵以提供匿名上传。许多组织希望外部人员能够提交文档或文件,他们的解决方案是建立 FTP 服务器,允许将文件写入一个目录,该目录的内容不能再次列出。这样,用户就看不到(希望也猜不到!)其他用户刚刚提交的文件的名称,并在站点管理员之前得到它们。

第三,该协议通常用于支持计算机帐户之间整个文件树的同步。通过使用提供递归 FTP 操作的客户端,用户可以将整个目录树从他们的一个帐户推到另一个帐户,并且服务器管理员可以克隆或安装新服务,而不必在新机器上从头开始重建它们。当像这样使用 FTP 时,用户通常不知道实际的协议是如何工作的,也不知道传输这么多不同的文件需要许多单独的命令:相反,他们单击一个按钮,一个大的批处理操作就会运行,然后完成这个过程。

第四,也是最后一点,FTP 被用于它最初的目的:交互式的、成熟的文件管理。早期的 FTP 客户端提供了一个命令行提示,感觉有点像 Unix shell 帐户本身,并且——正如我将解释的——该协议从 shell 帐户借用了“当前工作目录”和从一个目录移动到另一个目录的cd命令的概念。后来的客户模仿了类似 Mac 界面的想法,在电脑屏幕上绘制文件夹和文件。但无论是哪种情况,在文件系统浏览活动中,FTP 的全部功能最终都发挥了作用:它不仅支持列出目录、上传和下载文件的操作,还支持创建和删除目录、调整文件权限和重命名文件的操作。

用什么代替 FTP

今天,有比 FTP 协议更好的选择,几乎可以做任何你想做的事情。偶尔,你仍会看到以ftp://开头的网址,但它们变得相当罕见了。如果你有一个遗留系统,并且你需要从你的 Python 程序中使用 FTP,或者因为你想学习更多关于文件传输协议的知识,这一章将会很有用,FTP 是一个很好的历史起点。

该协议最大的问题是缺乏安全性:不仅仅是文件,用户名和密码都是完全明文发送的,任何观察网络流量的人都可以看到。

第二个问题是,FTP 用户倾向于建立一个连接,选择一个工作目录,并在同一个网络连接上进行多项操作。拥有数百万用户的现代互联网服务更喜欢像 HTTP(见第九章第一节)这样的协议,它由简短的、完全独立的请求组成,而不是需要服务器记住当前工作目录等信息的长时间运行的 FTP 连接。

最后一个大问题是文件系统安全性。早期的 FTP 服务器倾向于简单地暴露整个文件系统,让用户cd/四处窥探系统是如何配置的,而不是向用户显示所有者想要暴露的主机文件系统的一小部分。的确,你可以在一个单独的ftp用户下运行服务器,并试图拒绝该用户访问尽可能多的文件;但是 Unix 文件系统的许多区域需要完全公开可读,以便普通用户可以使用那里的程序。

那么有哪些选择呢?

  • 对于文件下载,HTTP(见第九章)是当今互联网的标准协议,必要时为了安全用 SSL 保护。HTTP 不像 FTP 那样公开特定于系统的文件名约定,而是支持独立于系统的 URL。
  • 匿名上传有点不太标准,但是一般趋势是在网页上使用一个表单,指示浏览器使用 HTTP POST 操作来传输用户选择的文件。
  • 自从递归 FTP 文件复制成为将文件传输到另一台计算机的唯一常见方式以来,文件同步已经有了不可估量的改进。像rsyncrdist这样的现代命令可以有效地比较连接两端的文件,只复制新的或已更改的文件,而不是浪费地复制每个文件。(本书不涉及这些命令;试试谷歌一下。)非程序员最有可能使用 Python 驱动的 Dropbox 服务或任何与之竞争的“云驱动”服务,这些服务现在由大型提供商提供。
  • 完全文件系统访问实际上是 FTP 在今天的互联网上仍然常见的一个领域:尽管缺乏安全性,数以千计的低价 ISP 继续支持 FTP,作为用户可以将他们的媒体和(通常)PHP 源代码复制到他们的 web 帐户的手段。如今,一个更好的选择是服务提供商转而支持 SFTP(见第十六章)。

Image 注意FTP 标准是 RFC 959,可在www.faqs.org/rfcs/rfc959.html获得。

通信渠道

FTP 不常见,因为默认情况下,它实际上在操作期间使用了两个 TCP 连接。一个连接是控制信道,它传送命令和结果确认或错误代码。第二个连接是数据通道,它仅用于传输文件数据或其他信息块,如目录列表。从技术上讲,数据通道是全双工的,这意味着它允许文件同时双向传输。然而,在实际操作中,很少使用这种能力。

在传统操作中,从 FTP 服务器下载文件的过程是这样的:

  1. 首先,FTP 客户端通过连接到服务器上的 FTP 端口来建立命令连接。
  2. 客户端通常使用用户名和密码进行身份验证。
  3. 客户端将服务器上的目录更改为它希望存放或检索文件的位置。
  4. 客户端开始监听数据连接的新端口,然后通知服务器该端口。
  5. 服务器连接到客户端打开的端口。
  6. 文件被传输。
  7. 数据连接已关闭。

这种服务器应该连接回客户端的想法在互联网的早期工作得很好;当时,几乎每台可以运行 FTP 客户端的机器都有一个公共 IP 地址,防火墙也相对少见。然而,今天的情况更加复杂。阻止台式机和笔记本电脑连接的防火墙现在很常见,而且许多无线、DSL 和内部商业网络无论如何都不为客户机提供真正的公共 IP 地址。

为了适应这种情况,FTP 还支持所谓的被动模式。在这个场景中,数据连接是反向的:服务器打开一个额外的端口,它告诉客户机进行第二次连接。除此之外,一切都是一样的。

今天,被动模式是大多数 FTP 客户端的默认模式,包括 Python 的ftplib模块,我将在本章解释。

在 Python 中使用 FTP

Python 模块ftplib是 Python 程序员 FTP 的主要接口。它为您处理建立各种连接的细节,并为自动化常见命令提供了方便的方法。

Image 提示如果你只对下载文件感兴趣,那么第一章中介绍的urllib2模块支持 FTP,对于简单的下载任务可能更容易使用;只需用一个ftp:// URL 运行它。在这一章中,我描述了ftplib,因为它提供了urllib2所没有的 FTP 特有的特性。

清单 17-1 展示了一个非常基本的ftplib例子。该程序连接到远程服务器,显示欢迎消息,并打印当前工作目录。

清单 17-1 。建立简单的 FTP 连接

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/connect.py

from ftplib import FTP

def main():
    ftp = FTP('ftp.ibiblio.org')
    print("Welcome:", ftp.getwelcome())
    ftp.login()
    print("Current working directory:", ftp.pwd())
    ftp.quit()

if __name__ == '__main__':
    main()

欢迎消息通常没有程序可以有效解析的信息,但是如果用户以交互方式调用您的客户端,您可能希望显示它。login()函数可以接受几个参数,包括用户名、密码和第三个很少使用的认证令牌,FTP 称之为“帐户”这里调用它时没有参数,让用户以“匿名”身份登录,并输入一个通用的密码值。

回想一下,FTP 会话可以访问不同的目录,就像 shell 提示符可以使用cd在不同的位置之间移动一样。这里,pwd()函数返回连接的远程站点上的当前工作目录。最后,quit()功能注销并关闭连接。

下面是程序运行时的输出:

$ ./connect.py
Welcome: 220 ProFTPD Server (Bring it on...)
Current working directory: /

ASCII 和二进制文件

在进行 FTP 传输时,您必须决定是希望将文件视为一整块二进制数据,还是希望将其解析为文本文件,以便您的本地计算机可以使用您的平台固有的任何行尾字符将其行粘贴回一起。

正如您所料,当您要求 Python 3 在文本模式下运行时,Python 3 忠实地期望并返回普通字符串,但是如果您正在处理二进制文件数据,它需要字节字符串。

一个以所谓的 ASCII 模式传输的文件一次传送一行,非常尴尬的是,传送到你的程序时没有行尾,所以你必须自己把这些行粘在一起。看一下清单 17-2 中的程序,它下载一个众所周知的文本文件并保存在你的本地目录中。

清单 17-2 。下载 ASCII 文件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/asciidl.py
# Downloads README from remote and writes it to disk.

import os
from ftplib import FTP

def main():
    if os.path.exists('README'):
        raise IOError('refusing to overwrite your README file')

    ftp = FTP('ftp.kernel.org')
    ftp.login()
    ftp.cwd('/pub/linux/kernel')

    with open('README', 'w') as f:
        def writeline(data):
            f.write(data)
            f.write(os.linesep)

        ftp.retrlines('RETR README', writeline)

    ftp.quit()

if __name__ == '__main__':
    main()

在清单中,cwd()函数在远程系统上选择一个新的工作目录。然后retrlines()功能开始传输。它的第一个参数指定在远程系统上运行的命令,通常是RETR,后面跟着一个文件名。它的第二个参数是一个函数,当文本文件的每一行被检索时,这个函数被反复调用;如果省略,数据将简单地打印到标准输出。传递行时去掉了行尾字符,所以自制的writeline()函数只是在写出每一行时将系统的标准行尾附加到每一行上。

尝试运行此程序;程序完成后,您当前的目录中应该有一个名为README的文件。

基本二进制文件传输的工作方式与文本文件传输非常相似。清单 17-3 展示了一个这样的例子。

清单 17-3 。下载二进制文件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/binarydl.py

import os
from ftplib import FTP

def main():
    if os.path.exists('patch8.gz'):
        raise IOError('refusing to overwrite your patch8.gz file')

    ftp = FTP('ftp.kernel.org')
    ftp.login()
    ftp.cwd('/pub/linux/kernel/v1.0')

    with open('patch8.gz', 'wb') as f:
        ftp.retrbinary('RETR patch8.gz', f.write)

    ftp.quit()

if __name__ == '__main__':
    main()

运行时,这个程序会将一个名为patch8.gz的文件存放到您当前的工作目录中。retrbinary()函数只是将数据块传递给指定的函数。这很方便,因为文件对象的write()函数需要数据,所以在这种情况下,不需要定制函数。

高级二进制下载

ftplib模块提供了第二个可以用于二进制下载的函数:ntransfercmd() 。该命令提供了一个低级界面,但是如果您想了解更多关于下载过程中发生的事情,它会很有用。特别是,这个更高级的命令允许您跟踪传输的字节数,并且您可以使用该信息为用户显示状态更新。清单 17-4 显示了一个使用ntransfercmd()的示例程序。

清单 17-4 。带状态更新的二进制下载

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/advbinarydl.py

import os, sys
from ftplib import FTP

def main():
    if os.path.exists('linux-1.0.tar.gz'):
        raise IOError('refusing to overwrite your linux-1.0.tar.gz file')

    ftp = FTP('ftp.kernel.org')
    ftp.login()
    ftp.cwd('/pub/linux/kernel/v1.0')
    ftp.voidcmd("TYPE I")

    socket, size = ftp.ntransfercmd("RETR linux-1.0.tar.gz")
    nbytes = 0

    f = open('linux-1.0.tar.gz', 'wb')

    while True:
        data = socket.recv(2048)
        if not data:
            break
        f.write(data)
        nbytes += len(data)
        print("\rReceived", nbytes, end=' ')
        if size:
            print("of %d total bytes (%.1f%%)"
                  % (size, 100 * nbytes / float(size)), end=' ')
        else:
            print("bytes", end=' ')
        sys.stdout.flush()

    print()
    f.close()
    socket.close()
    ftp.voidresp()
    ftp.quit()

if __name__ == '__main__':
    main()

这里有一些新的东西需要注意。首先是对voidcmd()的调用。这会将一个 FTP 命令直接传递给服务器并检查错误,但不返回任何内容。在这种情况下,原始命令是TYPE I。这会将传输模式设置为“图像”,这是 FTP 在内部引用二进制文件的方式。在前面的例子中,retrbinary()自动在后台运行这个命令,但是低级别的ntransfercmd()没有。

接下来,注意ntransfercmd()返回一个由数据套接字和估计大小组成的元组。始终记住,尺寸仅仅是一个估计,它不应该被认为是权威的;文件可能会很快结束,也可能会比该值长得多。同样,如果来自 FTP 服务器的大小估计根本不可用,那么返回的估计大小将是None

对象datasock实际上是一个普通的 TCP 套接字,它具有本书第一部分描述的所有行为(具体见第三章)。在这个例子中,一个简单的循环调用recv(),直到它从套接字中读取了所有数据,沿途将数据写到磁盘,并将状态更新打印到屏幕上。

Image 提示注意清单 17-4 中打印到屏幕上的关于状态更新的两件事。首先,不是打印一个消失在终端顶部的滚动列表,而是每一行都以回车符'\r'开始,它将光标移回到终端的左边缘,这样每一个状态行都会覆盖前一行,并创建一个增加的动画百分比的假象。第二,因为您告诉每个print语句以空格而不是新行结束一行,所以您实际上从未让它完成一行输出,所以您必须flush()标准输出以确保状态更新立即到达屏幕。

收到数据后,关闭数据套接字并调用voidresp()是很重要的,它从服务器读取命令响应代码,如果在传输过程中有任何错误,就会引发异常。即使您不关心检测错误,未能调用voidresp()也会使将来的命令失败,因为服务器的输出套接字将被阻塞,等待您读取结果。

以下是运行该程序的输出示例:

$ ./advbinarydl.py
Received 1259161 of 1259161 bytes (100.0%)

上传数据

文件数据也可以通过 FTP 上传。和下载一样,上传也有两个基本功能:storbinary()storlines() 。两者都需要运行一个命令和传输一个类似文件的对象。storbinary()函数将对该对象反复调用read()方法,直到其内容用尽,而storlines()则相反,调用readline()方法。

与相应的下载函数不同,这些方法不要求您提供自己的可调用函数。(当然,您可以传递一个您自己制作的类似文件的对象,它的read()readline()方法在传输过程中计算输出数据!)

清单 17-5 展示了如何以二进制模式上传文件。

清单 17-5 。二进制上传

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/binaryul.py

from ftplib import FTP
import sys, getpass, os.path

def main():
    if len(sys.argv) != 5:
        print("usage:", sys.argv[0],
              "<host> <username> <localfile> <remotedir>")
        exit(2)

    host, username, localfile, remotedir = sys.argv[1:]
    prompt = "Enter password for {} on {}: ".format(username, host)
    password = getpass.getpass(prompt)

    ftp = FTP(host)
    ftp.login(username, password)
    ftp.cwd(remotedir)
    with open(localfile, 'rb') as f:
        ftp.storbinary('STOR %s' % os.path.basename(localfile), f)
    ftp.quit()

if __name__ == '__main__':
    main()

这个程序看起来与早期的工作非常相似。因为大多数匿名 FTP 站点不允许文件上传,所以你必须找一个服务器来测试它;我只是在我的笔记本电脑上安装了旧的、古老的ftpd几分钟,然后像这样运行测试:

$ python binaryul.py localhost brandon test.txt /tmp

我在提示符下输入了密码(brandon是我在这台机器上的用户名)。当程序完成时,我检查了一下,果然,test.txt文件的副本现在在/tmp中。记住不要通过网络在另一台机器上尝试,因为 FTP 不会加密或保护你的密码!

只需将storbinary()改为storlines(),就可以修改这个程序,以 ASCII 模式上传文件。

高级二进制上传

正如下载过程有一个复杂的原始版本一样,也可以使用ntransfercmd()手动上传文件,如清单 17-6 所示。

清单 17-6 。一次上传一个块的文件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/advbinarydl.py

import os, sys
from ftplib import FTP

def main():
    if os.path.exists('linux-1.0.tar.gz'):
        raise IOError('refusing to overwrite your linux-1.0.tar.gz file')

    ftp = FTP('ftp.kernel.org')
    ftp.login()
    ftp.cwd('/pub/linux/kernel/v1.0')
    ftp.voidcmd("TYPE I")

    socket, size = ftp.ntransfercmd("RETR linux-1.0.tar.gz")
    nbytes = 0

    f = open('linux-1.0.tar.gz', 'wb')

    while True:
        data = socket.recv(2048)
        if not data:
            break
        f.write(data)
        nbytes += len(data)
        print("\rReceived", nbytes, end=' ')
        if size:
            print("of %d total bytes (%.1f%%)"
                  % (size, 100 * nbytes / float(size)), end=' ')
        else:
            print("bytes", end=' ')
        sys.stdout.flush()

    print()
    f.close()
    socket.close()
    ftp.voidresp()
    ftp.quit()

if __name__ == '__main__':
    main()

请注意,完成传输后,您要做的第一件事是调用datasock.close()。上传数据时,关闭套接字就是给服务器上传完成的信号!如果您在上传完所有数据后未能关闭数据套接字,服务器将继续等待其余数据的到达。

现在,您可以执行上传,上传过程中会持续显示其状态:

$ python binaryul.py localhost brandon patch8.gz /tmp
Enter password for brandon on localhost:
Sent 6408 of 6408 bytes (100.0%)

处理错误

和大多数 Python 模块一样,ftplib会在错误发生时抛出异常。它定义了自己的几个异常,还可以引发socket.errorIOError。为了方便起见,它提供了一个名为ftplib.all_errors的元组,其中列出了所有可能由ftplib引发的异常。这通常是编写try...except子句的有用捷径。

基本的retrbinary()函数的一个问题是,为了方便使用,您通常会在远程端开始传输之前在本地端打开文件。如果您针对远程端的命令反驳说该文件不存在,或者如果RETR命令失败,那么您将不得不关闭并删除您刚刚创建的本地文件(或者以零长度文件结束文件系统)。

相比之下,使用ntransfercmd()方法,您可以在打开本地文件之前检查问题。清单 17-6 已经遵循了这些准则:如果ntransfercmd()失败,异常将导致程序在本地文件打开之前终止。

扫描目录

FTP 提供了两种发现服务器文件和目录信息的方法。这些在ftplib中被实现为nlst()dir()方法。

nlst()方法返回给定目录中的条目列表——所有文件和目录都在里面。但是,返回的都是简单的名称。没有关于哪些特定条目是文件还是目录、存在的文件的大小或任何其他信息。

更强大的dir()函数从遥控器返回一个目录列表。该列表采用系统定义的格式,但通常包含文件名、大小、修改日期和文件类型。在 Unix 服务器上,它通常是以下两个 shell 命令之一的输出:

$ ls -l
$ ls -la

Windows 服务器可能会使用dir的输出。虽然输出可能对最终用户有用,但由于输出格式的变化,程序很难使用。一些需要这些数据的客户机实现了许多不同格式的解析器,这些格式是由lsdir跨机器和操作系统版本产生的;其他人只能解析在特定情况下使用的一种格式。

清单 17-7 展示了一个使用nlst()获取目录信息的例子。

清单 17-7 。获取一个空的目录列表

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/nlst.py

from ftplib import FTP

def main():
    ftp = FTP('ftp.ibiblio.org')
    ftp.login()
    ftp.cwd('/pub/academic/astronomy/')
    entries = ftp.nlst()
    ftp.quit()

    print(len(entries), "entries:")
    for entry in sorted(entries):
        print(entry)

if __name__ == '__main__':
    main()

当您运行这个程序时,您将看到如下输出:

$ python nlst.py
13 entries:
INDEX
README
ephem_4.28.tar.Z
hawaii_scope
incoming
jupitor-moons.shar.Z
lunar.c.Z
lunisolar.shar.Z
moon.shar.Z
planetary
sat-track.tar.Z
stars.tar.Z
xephem.tar.Z

如果您使用 FTP 客户端手动登录到服务器,您会看到相同的文件列表。当您尝试另一个文件列表命令时,结果会有所不同,如列表 17-8 所示。

清单 17-8 。得到一个奇特的目录列表

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/dir.py

from ftplib import FTP

def main():
    ftp = FTP('ftp.ibiblio.org')
    ftp.login()
    ftp.cwd('/pub/academic/astronomy/')
    entries = []
    ftp.dir(entries.append)
    ftp.quit()

    print(len(entries), "entries:")
    for entry in entries:
        print(entry)

if __name__ == '__main__':
    main()

请注意,文件名采用了便于自动化处理的格式—一个简单的文件名列表—但是没有额外的信息。将您之前看到的文件名列表与清单 17-8 中的输出进行对比,清单 17-8 中使用了dir():

$ python dir.py
13 entries:
-rw-r--r--   1 (?) »    (?) »   »     750 Feb 14  1994 INDEX
-rw-r--r--   1 root »   bin »   »     135 Feb 11  1999 README
-rw-r--r--   1 (?) »    (?) »      341303 Oct  2  1992 ephem_4.28.tar.Z
drwxr-xr-x   2 (?) »    (?) »   »    4096 Feb 11  1999 hawaii_scope
drwxr-xr-x   2 (?) »    (?) »   »    4096 Feb 11  1999 incoming
-rw-r--r--   1 (?) »    (?) »   »    5983 Oct  2  1992 jupitor-moons.shar.Z
-rw-r--r--   1 (?) »    (?) »   »    1751 Oct  2  1992 lunar.c.Z
-rw-r--r--   1 (?) »    (?) »   »    8078 Oct  2  1992 lunisolar.shar.Z
-rw-r--r--   1 (?) »    (?) »   »   64209 Oct  2  1992 moon.shar.Z
drwxr-xr-x   2 (?) »    (?) »   »    4096 Jan  6  1993 planetary
-rw-r--r--   1 (?) »    (?) »      129969 Oct  2  1992 sat-track.tar.Z
-rw-r--r--   1 (?) »    (?) »   »   16504 Oct  2  1992 stars.tar.Z
-rw-r--r--   1 (?) »    (?) »      410650 Oct  2  1992 xephem.tar.Z

dir()方法为每一行调用一个函数,像retrlines()传递特定文件的内容一样传递目录列表。在这里,您只需简单地提供普通旧 Python entries列表的append()方法。

检测目录和递归下载

如果您不能保证 FTP 服务器可能选择从其dir()命令返回什么信息,您将如何区分目录和普通文件——从服务器下载整个文件树时的一个重要步骤?

唯一确定的答案,如清单 17-9 所示,就是简单地尝试在nlst()返回的每个名称中添加一个cwd() ,如果成功,则断定该实体是一个目录!这个示例程序不做任何实际的下载;相反,为了简单起见(并且不要让样本数据淹没您的磁盘),它打印出它访问屏幕的目录。

清单 17-9 。尝试递归进入目录

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/recursedl.py

from ftplib import FTP, error_perm

def walk_dir(ftp, dirpath):
    original_dir = ftp.pwd()
    try:
        ftp.cwd(dirpath)
    except error_perm:
        return  # ignore non-directores and ones we cannot enter
    print(dirpath)
    names = sorted(ftp.nlst())
    for name in names:
        walk_dir(ftp, dirpath + '/' + name)
    ftp.cwd(original_dir)  # return to cwd of our caller

def main():
    ftp = FTP('ftp.kernel.org')
    ftp.login()
    walk_dir(ftp, '/pub/linux/kernel/Historic/old-versions')
    ftp.quit()

if __name__ == '__main__':
    main()

这个示例程序运行起来有点慢——事实证明,在 Linux 内核归档的旧版本目录中有相当多的文件——但是在几十秒钟内,您应该会看到屏幕上显示的结果目录树:

$ python recursedl.py
/pub/linux/kernel/Historic/old-versions
/pub/linux/kernel/Historic/old-versions/impure
/pub/linux/kernel/Historic/old-versions/old
/pub/linux/kernel/Historic/old-versions/old/corrupt
/pub/linux/kernel/Historic/old-versions/tytso

通过添加一些print语句,您可以通过显示递归过程正在(缓慢地)发现的每个文件来补充这个目录列表。此外,通过添加另外几行代码,您可以将文件本身下载到您在本地创建的相应目录中。然而,递归下载的唯一真正必要的逻辑已经在清单 17-9 中的代码中运行了:但是要知道一个条目是否是一个你被允许进入的目录,唯一简单的方法是试着对它运行cwd()

创建目录,删除东西

最后,FTP 支持文件删除,它支持目录的创建和删除。这些更模糊的调用都在ftplib文档中有描述:

  • delete(filename)将从服务器上删除一个文件。
  • 尝试创建新目录。
  • rmd(dirname)将删除一个目录;请注意,大多数系统首先要求目录为空。
  • rename(oldname, newname)的工作方式基本上与 Unix 命令mv类似:如果两个名称在同一个目录中,文件基本上被重命名;但是如果目的地指定了不同目录中的名称,那么文件实际上被移动了。

请注意,与所有其他 FTP 操作一样,这些命令的执行或多或少就像您使用登录 FTP 时使用的用户名真正登录到远程服务器命令行一样。正是由于这最后几个命令,FTP 可以用于支持文件浏览器应用,使用户可以在本地系统和远程主机之间无缝地拖放文件和目录。

安全地执行 FTP

虽然我在本章开始时指出,对于几乎任何可以使用 FTP 完成的事情,都有比 FTP 好得多的协议可以采用,特别是对 SSH 的健壮和安全的 SFTP 扩展(参见第十六章),但我应该公平地指出,一些 FTP 服务器支持 TLS 加密(参见第六章),如果你想利用它,Python 的ftplib确实提供了这种保护。

要使用 TLS,用FTP_TLS类而不是普通的FTP类创建 FTP 连接。简单地这样做,你的用户名和密码,事实上,整个 FTP 命令通道将受到保护,免受窥探。如果您随后额外运行该类的prot_p()方法(它没有参数),那么 FTP 数据连接也将受到保护。如果出于某种原因,您想在会话期间使用未加密的数据连接,有一个prot_c()方法可以将数据流恢复正常。同样,只要您使用FTP_TLS类,您的命令将继续受到保护。

如果您最终需要 FTP: http://docs.python.org/3/library/ftplib.html的这个扩展,请查看 Python 标准库文档以了解更多细节(其中包括一个小代码示例)。

摘要

FTP 允许您在计算机上运行的客户端和远程 FTP 服务器之间传输文件。尽管该协议不安全,而且与 SFTP 等更好的选择相比已经过时,但您可能仍然会发现需要您使用它的服务和机器。在 Python 中,ftplib库用于与 FTP 服务器对话。

FTP 支持二进制和 ASCII 传输。ASCII 传输通常用于文本文件,它们允许在文件传输时调整行尾。二进制传输用于其他一切。retrlines()函数用于以 ASCII 模式下载文件,而retrbinary()以二进制模式下载文件。

您也可以将文件上传到远程服务器。storlines()函数以 ASCII 模式上传文件,storbinary()以二进制模式上传文件。

ntransfercmd()函数可用于二进制文件的上传和下载。它让您对传输过程有更多的控制,它通常用于支持用户的进度条。

模块ftplib在出错时引发异常。特殊的元组ftplib.all_errors可以用来捕捉它可能引发的任何错误。

您可以使用cwd()切换到远端的特定目录。nlst()命令返回给定目录中所有条目(文件或目录)的简单列表。dir()命令返回一个更详细的列表,但是采用特定于服务器的格式。即使只有nlst(),您通常也可以通过尝试使用cwd()改变条目并注意是否出现错误来检测条目是文件还是目录。

在下一章中,我们将从简单的文件传输操作转向更一般的操作,即调用另一台服务器上的远程过程,并获取类型化数据,而不是空字符串作为响应。

十八、RPC

远程过程调用(RPC )系统允许您使用与调用本地 API 或库中的例程相同的语法调用另一个进程或远程服务器上的函数。这在两种情况下很有用:

  • 您的程序有许多工作要做,并且您希望通过在网络上进行调用来将这些工作分散到多台机器上,但是不需要更改进行调用的代码,现在调用是远程的。
  • 您需要仅在另一个硬盘或网络上可用的数据或信息,RPC 接口让您可以轻松地向另一个系统发送查询以获得答案。

第一个远程过程系统倾向于为 C 之类的低级语言编写。它们将字节放在网络上,看起来非常像每次一个 C 函数调用另一个 C 函数时已经写入处理器堆栈的字节。正如一个 C 程序不能安全地调用一个没有头文件的库函数,头文件告诉它如何在内存中精确地布置函数的参数(任何错误经常导致崩溃),RPC 调用不能在没有提前知道数据将如何序列化的情况下进行。事实上,每个 RPC 负载看起来就像一个二进制数据块,由第五章中讨论的 Python struct模块格式化。

然而,今天我们的机器和网络已经足够快了,所以我们经常用一些内存和速度来交换协议,这些协议更健壮,并且在对话的两段代码之间需要更少的协调。旧的 RPC 协议会发送如下所示的字节流:

0, 0, 0, 1, 64, 36, 0, 0, 0, 0, 0, 0

接收器应该知道函数的参数是一个 32 位整数和一个 64 位浮点数,然后将 12 个字节解码为一对值“整数 1”和“浮点 10.0”然而,更现代的 RPC 协议使用像 XML 这样的自文档格式,这种格式的编写方式使得几乎不可能将参数解释为除整数和浮点数之外的任何东西:

<params>
  <param><value><i4>41</i4></value></param>
  <param><value><double>10.</double></value></param>
</params>

早期的程序员可能会对 12 字节的实际二进制数据膨胀成 108 字节的协议感到震惊,这些协议必须由发送方生成,然后在接收方进行解析,消耗数百个 CPU(中央处理器)周期。尽管如此,消除协议中的模糊性通常被认为是值得的。当然,上面的一对值也可以用比 XML 更现代的有效负载格式来表达,比如 JSON JavaScript 对象符号:

[1, 10.0]

然而,在这两种情况下,您可以看到明确的文本表示已经成为当今的主流,它已经取代了发送原始二进制数据的旧做法,而原始二进制数据的含义必须事先知道。

当然,此时您可能会问到底是什么让 RPC 协议如此特别。毕竟,我在这里所说的选择——选择数据格式、发送请求和接收响应——并不特定于过程调用;但是它们对于任何有意义的网络协议都是通用的!举两个前几章的例子,HTTP 和 SMTP 都必须序列化数据和定义消息格式。所以,你可能会想:是什么让 RPC 如此特别?有三个特征将协议标记为 RPC 的一个例子。

首先,RPC 协议的特点是它缺乏对每个调用含义的强语义。HTTP 用于检索文档,SMTP 支持消息的传递,而 RPC 协议除了支持整数、浮点、字符串和列表之类的基本数据类型之外,不对传递的数据赋予任何意义。相反,这取决于您使用 RPC 协议来定义其调用的含义的每个特定 API。

第二,RPC 机制是一种调用方法的方式,但是它们不定义方法。当你阅读像 HTTP 或 SMTP 这样更单一用途的协议的规范时,你会注意到它们定义了有限数量的基本操作,像 HTTP 的GETPUT,或者当你使用 SMTP 时的EHLOMAIL。但是 RPC 机制让您来定义您的服务器将支持的动词或函数调用;他们不会提前指定它们。

第三,当您使用 RPC 时,您的客户端和服务器代码看起来不应该与任何其他使用函数调用的代码有很大不同。除非您知道一个对象代表一个远程服务器,否则您可能在代码中注意到的唯一模式是对被传递的对象的某种谨慎——大量的数字、字符串和列表,但通常不是像打开的文件这样的 live 对象。然而,虽然传递的参数种类可能有限,但函数调用将“看起来很正常”,不需要修饰或精心制作就可以通过网络传递。

RPC 的特性

除了让您进行看似本地的函数或方法调用(实际上是通过网络传递到不同的服务器)这一基本目的之外,RPC 协议还有几个关键特性和一些差异,在选择和部署 RPC 客户端或服务器时,您应该记住这些特性和差异。

首先,每种 RPC 机制对可以传递的数据类型都有限制。事实上,最通用的 RPC 机制往往是最具限制性的,因为它们被设计为与许多不同的编程语言一起工作,因此只能支持几乎所有这些语言中出现的最小公分母特性。

因此,最流行的协议只支持几种数字和字符串;一种序列或列表数据类型;然后是类似结构或关联数组的东西。许多 Python 程序员在得知通常只支持位置参数时感到失望,因为目前很少有其他语言支持关键字参数。

当 RPC 机制被绑定到特定的编程语言时,它可以自由地支持更大范围的参数。在某些情况下,如果协议能够找到某种方法在远程端重建活动对象,甚至可以传递活动对象。在这种情况下,只有由实时操作系统资源支持的对象,如打开的文件、实时套接字或共享内存区域,才不可能通过网络传递。

第二个常见特性是服务器在运行远程功能时发出异常信号的能力。在这种情况下,客户端 RPC 库通常会自己引发一个异常,告诉调用者发生了错误。当然,Python 提供给异常处理程序的那种活堆栈帧通常不能被传回;毕竟,每个堆栈框架都可能引用甚至不存在于客户端程序中的模块。但是,当服务器上的调用失败时,至少必须在 RPC 会话的客户端引发某种代理异常,给出正确的错误消息。

第三,许多 RPC 机制提供了自省,这是一种让客户端列出特定 RPC 服务所支持的调用的方式,并且可能发现它们采用了哪些参数。一些重量级的 RPC 协议实际上要求客户机和服务器交换描述它们支持的库或 API 的大型文档;其他的只是允许客户端获取函数名和参数类型的列表;而其他 RPC 实现根本不支持内省。Python 在支持自省方面有点弱,因为 Python 不像静态类型语言,不知道编写每个函数的程序员想要哪些参数类型。

第四,每个 RPC 机制都需要支持某种寻址方案,这样您就可以接触并连接到特定的远程 API。有些这样的机制相当复杂,它们甚至可以自动将您连接到网络上的正确服务器上,以执行特定的任务,而无需您事先知道它的名称。其他机制非常简单,只要求您提供想要访问的服务的 IP 地址、端口号或 URL。这些机制公开了底层网络寻址方案,而不是创建自己的方案。

最后,一些 RPC 机制支持身份验证、访问控制,甚至当 RPC 调用是由几个使用不同凭证的不同客户端程序发出时,支持特定用户帐户的完全模拟。但是像这样的功能并不总是可用的;事实上,简单和流行的 RPC 机制通常完全没有它们。简单的 RPC 方案使用像 HTTP 这样的底层协议来提供自己的身份验证,如果您希望您的 RPC 服务不被任意访问,那么您可以自行配置保护底层协议所需的密码、公钥或防火墙规则。

XML-RPC

让我们通过查看 Python 中内置的用于表达 XML-RPC 的工具来开始这个简短的 RPC 机制之旅。对于第一个例子来说,这似乎不是一个好的选择。毕竟,XML 是出了名的笨重和冗长,XML-RPC 在新服务中的受欢迎程度多年来一直在下降。

但是 XML-RPC 在 Python 的标准库中有本地支持,正是因为它是互联网时代的第一批 RPC 协议之一,通过 HTTP 本地操作,而不是坚持自己的在线协议。这意味着这里给出的例子甚至不需要第三方模块。尽管这使得 RPC 服务器的功能比使用第三方库时要差一些,但这也将使初次尝试 RPC 的例子变得简单。

XML-RPC 协议

目的:远程过程调用

标准:www.xmlrpc.com/spec

运行在顶端:HTTP

数据类型:intfloatunicodelistdictunicode键;带非标准扩展,datetimeNone

库:xmlrpclibSimpleXMLRPCServerDocXMLRPCServer

如果您曾经使用过原始 XML,那么您会对它缺乏任何数据类型语义这一事实很熟悉。例如,它不能表示数字,而只能表示包含其他元素、文本字符串和文本字符串属性的元素。因此,XML-RPC 规范必须在纯 XML 文档格式的基础上构建额外的语义,以便指定像数字这样的东西在转换成带标记的文本时应该是什么样子。

Python 标准库使得编写 XML-RPC 客户机或服务器变得容易。清单 18-1 显示了一个基本服务器,它在端口 7001 上启动一个 web 服务器,然后监听进入的 Internet 连接。

清单 18-1 。一个 XML-RPC 服务器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/xmlrpc_server.py
# XML-RPC server

import operator, math
from xmlrpc.server import SimpleXMLRPCServer
from functools import reduce

def main():
    server = SimpleXMLRPCServer(('127.0.0.1', 7001))
    server.register_introspection_functions()
    server.register_multicall_functions()
    server.register_function(addtogether)
    server.register_function(quadratic)
    server.register_function(remote_repr)
    print("Server ready")
    server.serve_forever()

def addtogether(*things):
    """Add together everything in the list `things`."""
    return reduce(operator.add, things)

def quadratic(a, b, c):
    """Determine `x` values satisfying: `a` * x*x + `b` * x + c == 0"""
    b24ac = math.sqrt(b*b - 4.0*a*c)
    return list(set([(-b-b24ac) / 2.0*a,
                      (-b+b24ac) / 2.0*a]))

def remote_repr(arg):
    """Return the `repr()` rendering of the supplied `arg`."""
    return arg

if __name__ == '__main__':
    main()

XML-RPC 服务存在于网站的单个 URL 中,所以实际上不必像这样将整个端口专用于 RPC 服务。相反,您可以将它与一个普通的 web 应用集成,该应用在其他 URL 上提供各种其他页面,甚至是单独的 RPC 服务。但是,如果您确实有整个端口空闲,那么 Python XML-RPC 服务器提供了一种简单的方法来建立一个除了 XML-RPC 之外什么也不做的 web 服务器。

您可以看到服务器通过 XML-RPC 提供的三个示例函数(通过register_function()调用添加到 RPC 服务的那些)是非常典型的 Python 函数。这也是 XML-RPC 的全部意义——它让您可以通过网络调用例程,而不必像编写程序中提供的普通函数一样编写它们。

Python 标准库提供的SimpleXMLRPCServer,顾名思义,相当简单;它不能提供其他 web 页面,它不理解任何类型的 HTTP 认证,如果不自己创建子类并添加更多代码,您就不能要求它提供 TLS 安全性。尽管如此,它将很好地服务于这里的目的,向您展示 RPC 的一些基本特性和限制,同时还让您只用几行代码就可以开始运行。

注意,除了注册函数的三个调用之外,还进行了两个额外的配置调用。它们中的每一个都开启了一个额外的服务,这个服务是可选的,但是通常是由 XML-RPC 服务器提供的:一个自检例程,客户端可以用它来询问给定的服务器支持哪些 RPC 调用,以及支持一个 multicall function 的能力,这个函数允许将几个单独的函数调用捆绑到一个网络往返中。

在您尝试下面三个程序列表之前,需要运行此服务器,因此请打开一个命令窗口并启动它:

$ python xmlrpc_server.py
Server ready

服务器现在正在本地主机端口 7001 上等待连接。所有正常的寻址规则都适用于您在第二章和第三章中学到的这个 TCP 服务器,所以除非您调整代码绑定到一个非本地主机的接口,否则您将不得不从同一系统上的另一个命令提示符连接到它。首先打开另一个命令窗口,并准备尝试下面三个清单。

首先,让我们试试您在这个特定的服务器上打开的自省功能。请注意,这种能力是可选的,您在线使用或自己部署的许多其他 XML-RPC 服务可能不具备这种能力。清单 18-2 从客户的角度展示了自省是如何发生的。

清单 18-2 。询问 XML-RPC 服务器它支持什么功能

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/xmlrpc_introspect.py
# XML-RPC client

import xmlrpc.client

def main():
    proxy = xmlrpc.client.ServerProxy('http://127.0.0.1:7001')

    print('Here are the functions supported by this server:')
    for method_name in proxy.system.listMethods():

        if method_name.startswith('system.'):
            continue

        signatures = proxy.system.methodSignature(method_name)
        if isinstance(signatures, list) and signatures:
            for signature in signatures:
                print('%s(%s)' % (method_name, signature))
        else:
            print('%s(...)' % (method_name,))

        method_help = proxy.system.methodHelp(method_name)
        if method_help:
            print('  ', method_help)

if __name__ == '__main__':
    main()

自省机制不仅仅是一个可选的扩展,它实际上并没有在 XML-RPC 规范本身中定义!它让客户端调用一系列以字符串system开头的特殊方法,以区别于普通方法。这些特殊的方法给出了关于其他可用调用的信息。让我们从称呼listMethods() 开始。如果支持内省,那么您将收到一个其他方法名称的列表。对于这个清单示例,让我们忽略系统方法,只打印出其他方法的信息。对于每个方法,您将尝试检索它的签名,以了解它接受什么参数和数据类型。因为服务器是用 Python(一种没有类型声明的语言)编写的,所以它实际上不知道函数期望什么数据类型:

$ python xmlrpc_introspect.py
Here are the functions supported by this server:
concatenate(...)
   Add together everything in the list `things`.
quadratic(...)
   Determine `x` values satisfying: `a` * x*x + `b` * x + c == 0
remote_repr(...)
   Return the `repr()` rendering of the supplied `arg`.

但是,您可以看到,虽然在这种情况下没有给出参数类型,但是确实提供了文档字符串。事实上,SimpleXMLRPCServer已经获取了函数的文档字符串并返回了它们。在真实的客户机中,您可能会发现自省有两种用途。首先,如果您正在编写一个使用特定 XML-RPC 服务的程序,那么它的在线文档可能会提供人类可读的帮助。第二,如果您正在编写一个客户机,该客户机访问一系列类似的 XML-RPC 服务,这些服务提供的方法各不相同,那么一个listMethods()调用可能会帮助您确定哪些服务器提供哪些命令。

您可能还记得,RPC 服务的全部目的是让目标语言中的函数调用看起来尽可能自然。此外,正如你在清单 18-3 中看到的,标准库的xmlrpclib给了你一个代理对象,用于对服务器进行函数调用。这些调用看起来完全像本地方法调用。

清单 18-3 。进行 XML-RPC 调用

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/xmlrpc_client.py
# XML-RPC client

import xmlrpc.client

def main():
    proxy = xmlrpc.client.ServerProxy('http://127.0.0.1:7001')
    print(proxy.addtogether('x', 'ÿ', 'z'))
    print(proxy.addtogether(20, 30, 4, 1))
    print(proxy.quadratic(2, -4, 0))
    print(proxy.quadratic(1, 2, 1))
    print(proxy.remote_repr((1, 2.0, 'three')))
    print(proxy.remote_repr([1, 2.0, 'three']))
    print(proxy.remote_repr({'name': 'Arthur',
                             'data': {'age': 42, 'sex': 'M'}}))
    print(proxy.quadratic(1, 0, 1))

if __name__ == '__main__':
    main()

在示例服务器上运行前面的代码会产生输出,从中您可以了解一些关于 XML-RPC 和 RPC 机制的一般情况。请注意几乎所有的调用都工作顺利,以及清单 18-1 中的函数本身看起来完全像普通的 Python 它们没有任何特定于网络的特性:

$ python xmlrpc_client.py
xÿz
55
[0.0, 8.0]
[-1.0]
[1, 2.0, 'three']
[1, 2.0, 'three']
{'data': {'age': [42], 'sex': 'M'}, 'name': 'Arthur'}
Traceback (most recent call last):
  ...
xmlrpclib.Fault: <Fault 1: "<type 'exceptions.ValueError'>:math domain error">

但是有几个细节你需要注意。首先,注意 XML-RPC 没有对您提供的参数类型施加任何限制。您可以用字符串或数字调用addtogether(),并且可以提供任意数量的参数。协议本身并不关心;对于一个函数应该有多少个参数或者应该是什么类型,它没有先入为主的概念。当然,如果您正在调用一种关心的语言——甚至是一个不支持可变长度参数列表的 Python 函数——那么远程语言可能会引发一个异常。但是这是语言的抱怨,而不是 XML-RPC 协议本身。

第二,注意 XML-RPC 函数调用,就像 Python 和其他语言的函数调用一样,可以接受几个参数,但是它们只能返回一个结果值。该值可能是复杂的数据结构,但它将作为单个结果返回。并且该协议不关心该结果是否具有一致的形状或大小;由quadratic()返回的列表(是的,我厌倦了 XML-RPC 示例中使用的所有简单的add()subtract()数学函数!)在没有来自网络逻辑的任何抱怨的情况下返回的元素数量变化。

第三,注意丰富多样的 Python 数据类型必须减少到 XML-RPC 本身恰好支持的较小集合。特别是,XML-RPC 只支持单一的序列类型:列表。因此,当您向remote_repr()提供一个包含三个条目的元组时,它实际上是服务器接收到的三个条目的列表。这是所有 RPC 机制与特定语言结合时的一个共同特征。它们不直接支持的类型要么必须映射到不同的数据结构(因为元组在这里变成了列表),要么必须引发异常,抱怨特定的参数类型无法传输。

第四,XML-RPC 中的复杂数据结构可以是递归的。您不会受限于内部只有一个复杂数据类型级别的参数。正如您所看到的,传递一个字典并把另一个字典作为它的一个值就可以了。

最后,注意,如前所述,服务器上函数中的一个异常成功地通过网络返回,并在客户机上由一个xmlrpclib.Fault实例表示。这个实例提供了远程异常名和与之相关的错误消息。无论使用什么语言来实现服务器例程,您总是可以预期 XML-RPC 异常具有这种结构。追溯信息并不丰富;虽然它告诉您代码中的哪个调用触发了异常,但是堆栈的最内层只是xmlrpclib本身的代码。

到目前为止,我已经介绍了 XML-RPC 的一般特性和限制。如果您查阅 Python 标准库中客户端或服务器模块的文档,您可以了解更多的特性。特别是,您可以通过向ServerProxy类提供更多参数来学习如何使用 TLS 和认证。但是有一个特性很重要,足以在这里讨论:当服务器支持时,在网络往返中进行多次调用的能力(这是那些可选扩展中的另一个),如清单 18-4 所示。

清单 18-4 。使用 XML-RPC 多调用

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/xmlrpc_multicall.py
# XML-RPC client performing a multicall

import xmlrpc.client

def main():
    proxy = xmlrpc.client.ServerProxy('http://127.0.0.1:7001')
    multicall = xmlrpc.client.MultiCall(proxy)
    multicall.addtogether('a', 'b', 'c')
    multicall.quadratic(2, -4, 0)
    multicall.remote_repr([1, 2.0, 'three'])
    for answer in multicall():
        print(answer)

if __name__ == '__main__':
    main()

当您运行这个脚本时,您可以观察服务器的命令窗口,以确认只发出了一个 HTTP 请求来响应所有三个函数调用:

localhost - - [04/Oct/2010 00:16:19] "POST /RPC2 HTTP/1.0" 200 -

顺便说一下,可以关闭像前面那样记录消息的功能;此类记录由SimpleXMLRPCServer中的一个选项控制。注意,服务器和客户机使用的默认 URL 是路径/RPC2,除非您查阅文档并对客户机和服务器进行不同的配置。

在我继续研究另一个 RPC 机制之前,最后三点值得一提:

  • 有两种额外的数据类型有时被证明是不可或缺的,所以许多 XML-RPC 机制都支持它们:日期和值,Python 称之为None(其他语言称之为nullnil)。Python 的客户机和服务器都支持允许传输和接收这些非标准值的选项。
  • 唉,XML-RPC 不支持关键字参数,因为很少有语言成熟到可以包含它们。一些服务通过允许将字典作为函数的最终参数来传递,或者通过完全处理位置参数并对每个函数使用单个字典参数(通过名称指定其所有参数)来解决这个问题。
  • 最后,请记住,只有当字典的所有键都是字符串时,才能传递字典,不管是普通的还是 Unicode 的。有关如何考虑这一限制的更多信息,请参阅本章后面的“自我记录数据”一节。

尽管像 XML-RPC 这样的 RPC 协议的全部目的是让您忘记网络传输的细节,专注于正常的编程,但是您应该至少看到一次您的调用在网络上是什么样子的!下面是示例客户端程序对quadratic ()的第一次调用:

<?xml version='1.0'?>
<methodCall>
<methodName>quadratic</methodName>
<params>
<param>
<value><int>2</int></value>
</param>
<param>
<value><int>-4</int></value>
</param>
<param>
<value><int>0</int></value>
</param>
</params>
</methodCall>

对前面调用的响应如下所示:

<?xml version='1.0'?>
<methodResponse>
<params>
<param>
<value><array><data>
<value><double>0.0</double></value>
<value><double>8.0</double></value>
</data></array></value>
</param>
</params>
</methodResponse>

如果这个响应对于它所传输的数据量来说看起来有点冗长,那么您将很乐意了解我接下来要处理的 RPC 机制,JSON-RPC。

JSON-RPC

JSON 背后的好主意是使用 JavaScript 编程语言的语法将数据结构序列化为字符串。这意味着,理论上,JSON 字符串可以简单地通过使用eval()函数在 web 浏览器中变回数据。(然而,对于不受信任的数据这样做通常是不明智的,所以大多数程序员使用正式的 JSON 解析器,而不是利用它与 JavaScript 的兼容性。)通过使用专门为数据设计的语法,而不是采用像 XML 这样冗长的文档标记语言,这种远程过程调用机制可以使数据更加紧凑,同时简化解析器和库代码。

JSON-RPC 协议

目的:远程过程调用

标准:http://json-rpc.org/wiki/specification

运行在顶端:HTTP

数据类型:intfloatunicodelistdictunicode键;None

库:许多第三方,包括jsonrpclib

Python 标准库中不支持 JSON-RPC,因此您必须从几个可用的第三方发行版中选择一个。您可以在 Python 包索引中找到这些发行版。第一个正式支持 Python 3 的是jsonrpclib-pelix。如果你把它安装在一个虚拟环境中(见第一章,那么你可以分别试用清单 18-5 和清单 18-6 中的服务器和客户端。

清单 18-5 。JSON-RPC 服务器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/jsonrpc_server.py
# JSON-RPC server needing "pip install jsonrpclib-pelix"

from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer

def lengths(*args):
    """Measure the length of each input argument.

    Given N arguments, this function returns a list of N smaller
    lists of the form [len(arg), arg] that each state the length of
    an input argument and also echo back the argument itself.

    """
    results = []
    for arg in args:
        try:
            arglen = len(arg)
        except TypeError:
            arglen = None
        results.append((arglen, arg))
    return results

def main():
    server = SimpleJSONRPCServer(('localhost', 7002))
    server.register_function(lengths)
    print("Starting server")
    server.serve_forever()

if __name__ == '__main__':
    main()

服务器代码非常简单,就像 RPC 机制一样。和 XML-RPC 一样,你只需要命名你想在网络上提供的函数,它们就可以被查询了。(您也可以传递一个对象,它的方法将一次注册到服务器。)

清单 18-6 。JSON-RPC 客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/jsonrpc_client.py
# JSON-RPC client needing "pip install jsonrpclib-pelix"

from jsonrpclib import Server

def main():
    proxy = Server('http://localhost:7002')
    print(proxy.lengths((1,2,3), 27, {'Sirius': -1.46, 'Rigel': 0.12}))

if __name__ == '__main__':
    main()

编写客户端代码也很简单。发送几个需要测量长度的对象,并让这些数据结构立即被服务器回显,这样就可以看到这个特定协议的几个细节。

首先,请注意,该协议允许您发送任意多的参数;它不会因为不能从函数中自省静态方法签名而烦恼。这类似于 XML-RPC,但它与为传统的静态类型语言构建的 XML-RPC 机制有很大不同。

其次,注意服务器回复中的None值会不受阻碍地传回。这是因为该值本身就受协议支持,无需激活任何非标准扩展:

$ python jsonrpc_server.py
Starting server

[In another command window:]
$ python jsonrpc_client.py
[[3, [1, 2, 3]], [None, 27], [2, {'Rigel': 0.12, 'Sirius': -1.46}]]

第三,注意 JSON-RPC 只支持一种序列,这意味着客户机发送的元组必须被强制转换成一个列表才能通过。

当然,JSON-RPC 和 XML-RPC 之间的最大区别——在这种情况下,数据负载是一个小而简洁的 JSON 消息,它本身知道如何表示每种数据类型——在这里甚至看不到。这是因为这两种机制都很好地将网络隐藏在代码之外。当在我的本地主机接口上运行 Wireshark,同时运行这个示例客户端和服务器时,我可以看到实际传递的消息如下:

{"version": "1.1",
 "params": [[1, 2, 3], 27, {"Rigel": 0.12, "Sirius": -1.46}],
 "method": "lengths"}
{"result": [[3, [1, 2, 3]], [null, 27],
            [2, {"Rigel": 0.12, "Sirius": -1.46}]]}

请注意,JSON-RPC version 1 的流行已经导致了几个用附加特性来扩展和补充该协议的竞争尝试。如果你想了解标准的现状以及围绕标准的讨论,你可以在网上做研究。对于大多数基本任务,您可以简单地使用一个好的第三方 Python 实现,而不用担心关于标准扩展的争论。

离开这个话题而不提一个重要的事实是我的失职。尽管前面的例子是同步的——客户机发送一个请求,然后耐心等待只接收一个响应,并且在此期间不做任何有用的事情——JSON-RPC 协议确实支持为每个请求附加id值。这意味着在收到带有相同的id的匹配响应之前,您可以有几个正在进行的请求。我不会在这里进一步探讨这个想法,因为严格地说,异步超越了 RPC 机制的传统角色。毕竟,传统过程语言中的函数调用是严格同步的事件。但是,如果您觉得这个想法很有趣,那么您应该阅读该标准,然后探究哪些 Python JSON-RPC 库可能支持您对异步的需求。

自我记录数据

您已经看到,XML-RPC 和 JSON-RPC 似乎都支持非常像 Python 字典的数据结构,但是有一个令人讨厌的限制。在 XML-RPC 中,数据结构被称为结构,而 JSON 称之为对象。然而,对于 Python 程序员来说,它看起来像一个字典,您的第一反应可能是它的键不能是整数、浮点数或元组。

我们来看一个具体的例子。假设您有一个由原子序数索引的物理元素符号字典:

{1: 'H', 2: 'He', 3: 'Li', 4: 'Be', 5: 'B', 6: 'C', 7: 'N', 8: 'O'}

如果您需要通过 RPC 机制传输这个字典,您的第一反应可能是将数字改为字符串,以便字典可以作为结构或对象传递。事实证明,在大多数情况下,这种本能是错误的。

简单地说,struct 和 object RPC 数据结构并不是为了将任意大小的容器中的键与值配对而设计的。相反,它们被设计成将一小组预定义的属性名与它们恰好为某个特定对象携带的属性值相关联。如果您试图使用 struct 来将随机的键和值配对,您可能会无意中使您的服务对于那些不幸使用静态类型编程语言的人来说非常难以使用。

相反,您应该把跨 RPC 发送的字典想象成 Python 对象,通常每个对象都有一个代码熟知的属性名的小集合。同样,通过 RPC 发送的字典应该将少量预定义的键与其相关值相关联。

所有这些都意味着,如果通用 RPC 机制要使用前面给出的字典,那么它实际上应该序列化为一个显式标记值的列表:

[{'number': 1, 'symbol': 'H'},
 {'number': 2, 'symbol': 'He'},
 {'number': 3, 'symbol': 'Li'},
 {'number': 4, 'symbol': 'Be'},
 {'number': 5, 'symbol': 'B'},
 {'number': 6, 'symbol': 'C'},
 {'number': 7, 'symbol': 'N'},
 {'number': 8, 'symbol': 'O'}]

请注意,前面的示例显示了 Python 字典,因为您将它传递到 RPC 调用中,而不是它在网络上的表示方式。

这种方法的关键区别(除了这本字典长得惊人之外)是,早期的数据结构毫无意义,除非您事先知道键和值的含义。它依靠惯例来赋予数据意义。但是在这里,您在数据中包含了名称,这使得它可以自我描述:在网络上或程序中查看这些数据的人有更大的机会猜测它们代表什么。

这就是 XML-RPC 和 JSON-RPC 希望您使用它们的键值类型的方式,这也是名称 structobject 的来源。它们分别是保存命名属性的实体的 C 语言和 JavaScript 术语。同样,这使得它们更像 Python 对象,而不是 Python 字典。

如果您有一个类似这里讨论的 Python 字典,您可以将它转换成一个适合 RPC 的数据结构,然后用如下代码将它改回来:

>>>elements = {1: 'H', 2: 'He'}
>>>t = [{'number': key, 'symbol': value} for key, value in elements.items()]
>>>t
[{'symbol': 'H', 'number': 1}, {'symbol': 'He', 'number': 2}]
>>> {obj['number']: obj['symbol']) for obj in t}
{1: 'H', 2: 'He'}

如果您发现自己创建和销毁了太多的字典,使得这种转换没有吸引力,那么使用命名元组(因为它们存在于 Python 的最新版本中)可能是在发送这些值之前整理它们的更好的方法。

谈论对象:Pyro 和 RPyC

如果 RPC 的想法是让远程函数调用看起来像本地函数调用,那么前面讨论的两个基本 RPC 机制实际上非常失败。如果您调用的函数碰巧在它们的参数和返回值中只使用了基本数据类型,那么 XML-RPC 和 JSON-RPC 就能很好地工作。但是,想想所有使用更复杂的参数和返回值的情况吧!当你需要传递活的物体时会发生什么?这通常是一个很难解决的问题,原因有二。

首先,对象在不同的编程语言中有不同的行为和语义。因此,支持对象的机制往往要么局限于一种特定的语言,要么从它想要支持的语言的最小公分母中挑选出对“对象”如何行为的描述。

第二,通常不清楚一个对象需要多少状态才能在另一台计算机上有用。的确,RPC 机制可以开始递归地向下进入对象的属性,并准备好这些值以便在网络上传输。然而,在中等复杂程度的系统上,您可以通过对属性值进行简单的递归来遍历内存中的大多数对象。收集了数兆字节的数据进行传输后,远程终端实际需要所有这些数据的可能性有多大?

除了发送作为参数传递或作为值返回的每个对象的全部内容之外,另一种方法是只发送一个对象名,如果需要的话,远程端可以使用它来询问有关对象属性的问题。这意味着高度连接的对象图中只有一个项目可以被快速传输,并且只有远程站点实际需要的那些部分最终会被传输。然而,这两种方案通常导致昂贵和缓慢的服务,并且它们使得很难跟踪一个对象如何被允许影响网络另一端的另一个服务所提供的答案。

事实上,XML-RPC 和 JSON-RPC 强加给你的任务(即,分解你想问远程服务的问题,以便简单的数据类型可以容易地传输)通常最终只是软件架构的任务。对参数和返回值数据类型的限制使您仔细考虑您的服务,直到您确切地看到远程服务需要什么以及为什么需要。因此,我建议不要仅仅为了避免设计您的远程服务和弄清楚他们需要哪些数据来完成他们的工作而跳到一个更加基于对象的 RPC 服务。

有几个著名的 RPC 机制,如 SOAP 和 CORBA,它们在不同程度上试图解决这样一个大问题,即如何支持可能存在于一个服务器上的对象,同时代表客户端程序从第三个服务器发送 RPC 消息,将这些对象传递到另一个服务器。一般来说,Python 程序员似乎像躲避瘟疫一样避免这些 RPC 机制,除非合同或任务特别要求他们将这些协议与另一个现有的系统交流。它们超出了本书的范围;而且,如果你需要使用它们,你应该准备好购买至少一整本关于每种技术的书,因为它们可能是如此复杂!

然而,当您只有需要相互通信的 Python 程序时,至少有一个极好的理由来寻找了解 Python 对象及其方式的 RPC 服务。Python 有许多非常强大的数据类型,所以试图“贬低”XML-RPC 和 JSON-RPC 等有限数据格式的方言是不合理的。当 Python 字典、集合和datetime对象能够准确表达您想要表达的内容时,尤其如此。

有两个 Python 原生的 RPC 系统我应该提一下: PyroRPyC 。Pyro 项目可以在http://pythonhosted.org/Pyro4/找到。这个完善的 RPC 库建立在 Python pickle模块之上,它可以发送任何类型的参数和响应值,这是固有的可处理的。基本上,这意味着如果一个对象及其属性可以被简化为它的基本类型,那么它就可以被传输。然而,如果你想发送或接收的值是那些被pickle模块阻塞的值,那么 Pyro 就不适合你的情况。(尽管您也可以查看 Python 标准库中的pickle文档。这个库包含了当 Python 不能自己解决如何 pickle 类的问题时,如何使类可 pickle 的说明。)

RPyC 的一个例子

RPyC 项目可以在http://rpyc.readthedocs.org/en/latest/找到。这个项目对对象采取了一种更加复杂的方法。事实上,它更像 CORBA 中可用的方法,在 CORBA 中,实际上通过网络传递的是对一个对象的引用,如果接收者需要,该对象可以用于回调和调用它的更多方法。最新的版本似乎在安全性方面投入了更多的心思,如果您让其他组织使用您的 RPC 机制,这一点很重要。毕竟,如果你让别人给你一些数据,你基本上是让他们在你的电脑上运行任意代码!

您可以分别在清单 18-7 和清单 18-8 中看到一个示例客户端和服务器。如果你想要一个像 RPyC 这样的系统所能实现的不可思议的事情的例子,你应该仔细研究这些列表。

清单 18-7 。RPyC 的客户

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/rpyc_client.py
# RPyC client

import rpyc

def main():
    config = {'allow_public_attrs': True}
    proxy = rpyc.connect('localhost', 18861, config=config)
    fileobj = open('testfile.txt')
    linecount = proxy.root.line_counter(fileobj, noisy)
    print('The number of lines in the file was', linecount)

def noisy(string):
    print('Noisy:', repr(string))

if __name__ == '__main__':
    main()

清单 18-8 。RPyC 服务器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/rpyc_server.py
# RPyC server

import rpyc

def main():
    from rpyc.utils.server import ThreadedServer
    t = ThreadedServer(MyService, port = 18861)
    t.start()

class MyService(rpyc.Service):
    def exposed_line_counter(self, fileobj, function):
        print('Client has invoked exposed_line_counter()')
        for linenum, line in enumerate(fileobj.readlines()):
            function(line)
        return linenum + 1

if __name__ == '__main__':
    main()

起初,客户端可能看起来像一个使用 RPC 服务的相当标准的程序。毕竟,它用一个网络地址调用一个一般命名为connect()的函数,然后访问返回的代理对象的方法,就好像调用是在本地执行的一样。然而,如果你仔细观察,你会发现一些惊人的差异!RPC 函数的第一个参数实际上是一个动态文件对象,它不一定存在于服务器上。另一个参数是一个函数;另一个活动对象,而不是 RPC 机制通常支持的那种惰性数据结构。

服务器公开一个方法,该方法接受提供的文件对象和可调用函数。它完全像在单个进程中的普通 Python 程序中一样使用这些。它调用文件对象的readlines(),并且它期望返回值是一个迭代器,一个for循环可以在其上重复。最后,服务器调用传入的函数对象,而不考虑函数实际位于何处(即在客户机中)。注意,RPyC 的新安全模型规定,如果没有任何特殊许可,它将只允许客户端调用以特殊前缀exposed_开头的方法。

假设当前目录中确实存在一个小的testfile.txt,并且其中包含一些有智慧的话,那么查看运行客户端生成的输出特别有启发性:

$ python rpyc_client.py
Noisy: 'Simple\n'
Noisy: 'is\n'
Noisy: 'better\n'
Noisy: 'than\n'
Noisy: 'complex.\n'
The number of lines in the file was 5

同样令人吃惊的是两个事实。首先,服务器能够迭代来自readlines()的多个结果,尽管这需要反复调用客户机上的文件对象逻辑。第二,服务器没有以某种方式复制noisy()函数的代码对象,以便它可以直接运行该函数;相反,它在连接的客户端反复调用函数,每次都使用正确的参数!

这是怎么回事?很简单,RPyC 采用了与前面研究的其他 RPC 机制完全相反的方法。尽管所有其他技术都试图序列化并通过网络发送尽可能多的信息,然后让远程代码要么成功要么失败,而没有进一步的信息,但 RPyC 方案只序列化完全不可变的项目,如 Python 整数、浮点、字符串和元组。对于其他所有事情,它通过一个远程对象标识符传递,该标识符允许远程端返回到客户端以访问这些活动对象的属性和调用方法。

这种方法会产生大量的网络流量。如果在操作完成之前,大量的对象操作必须在客户机和服务器之间来回传递,也会导致明显的延迟。建立适当的安全性也是一个问题。为了允许服务器在客户端自己的对象上调用类似于readlines()的东西,我选择用allow_public_attrs的一揽子断言来建立客户端连接。但是,如果您不愿意让您的服务器代码拥有这种完全的控制权,那么您可能需要花一些时间来获得正确的权限,以使您的操作能够正常工作,而不会暴露太多潜在的危险功能。

因此,这项技术可能很昂贵,而且如果客户端和服务器彼此不信任,安全性也很棘手。但是当你需要它的时候,没有什么比 RPyC 更能让网络边界两端的 Python 对象相互协作了。你甚至可以让两个以上的进程玩游戏;查看 RPyC 文档了解更多细节!

事实上,RPyC 可以成功地处理普通的 Python 函数和对象,而不需要它们继承或混合任何特殊的网络功能,这是 Python 赋予我们拦截对对象执行的操作并以我们自己的方式处理这些事件的能力的不可思议的证明——甚至通过跨网络询问一个问题!

RPC、Web 框架和消息队列

愿意为您的 RPC 服务工作探索替代的传输机制。例如,Python 标准库中为 XML-RPC 提供的类甚至没有被许多需要使用该协议的 Python 程序员使用。毕竟,人们经常将 RPC 服务部署为大型网站的一部分,并且必须在单独的端口上为这种特定类型的 web 请求运行单独的服务器,这是非常烦人的。

有三种有用的方法可以帮助您超越过于简单的示例代码,这些代码使您看起来好像必须为您希望从特定站点提供的每个 RPC 服务创建一个新的 web 服务器。

首先,看看是否可以利用 WSGI 的可插拔性来安装一个 RPC 服务,这个服务已经合并到一个正在部署的大型 web 项目中。在检查传入 URL 的过滤器下,将普通的 web 应用和 RPC 服务实现为 WSGI 服务器,使您能够允许这两种服务使用相同的主机名和端口号。它还允许您利用这样一个事实,即您的 WSGI web 服务器可能已经提供了 RPC 服务本身没有提供的线程和可伸缩性。

如果 RPC 服务本身缺少身份验证,那么将 RPC 服务放在更大的 WSGI 堆栈的底部也可以为您提供一种添加身份验证的方法。参见第十一章了解更多关于 WSGI 的信息。

其次,您可能会发现,您选择的 web 框架已经知道如何托管 XML-RPC、JSON-RPC 或其他类型的 RPC 调用,而不是使用专用的 RPC 库。这意味着您可以像 web 框架允许您定义视图或 RESTful 资源一样轻松地声明 RPC 端点。查阅您的 web 框架文档,并在 web 上搜索 RPC 友好的第三方插件。

第三,您可能希望尝试通过替代传输发送 RPC 消息,该传输比协议的本地传输更好地将调用路由到准备好处理它们的服务器。在第八章中讨论过的消息队列,通常是 RPC 调用的一个很好的载体,当你想要整个服务器机架忙于分担传入请求的负载时。

从网络错误中恢复

当然,网络上有一个 RPC 服务无法轻易隐藏的现实:当您试图发起一个调用时,网络可能会关闭,或者它甚至可能在一个特定的 RPC 调用过程中关闭。

您会发现,如果调用被中断并且没有完成,大多数 RPC 机制只会引发一个异常。请注意,不幸的是,一个错误并不能保证远端没有处理该请求——也许它确实完成了处理,但是在发送最后一个应答包时,网络就中断了。在这种情况下,从技术上讲,您的调用应该已经发生,并且数据应该已经成功地添加到数据库或写入文件或 RPC 调用所做的任何事情。但是,您会认为调用失败了,并想再试一次—可能会将相同的数据存储两次。幸运的是,在编写通过网络委托一些函数调用的代码时,您可以使用一些技巧。

首先,尝试编写提供可以安全重试的幂等操作的服务。虽然像“从我的银行帐户中删除 10 美元”这样的操作本质上是不安全的,因为重试它可能会从您的帐户中再删除 10 美元,但是像“执行交易 583812,从我的帐户中删除 10 美元”这样的操作是完全安全的,因为服务器可以通过存储交易号来确定您的请求实际上是重复的,并且可以报告成功,而不必实际重复扣款。

第二,采纳第五章中提供的建议:不要在 RPC 调用的任何地方都用try...except乱丢你的代码,尝试使用tryexcept来包装更大的代码片段,这些代码片段具有可靠的语义,可以更干净地重试或恢复。如果您用异常处理程序来保护每一个调用,您将会失去 RPC 的大部分好处:您的代码应该是方便编写的,而不是让您经常注意到函数调用实际上是通过网络转发的事实!如果您决定您的程序应该重试一次失败的调用,您可能想要尝试使用类似于在第三章中讨论的 UDP 指数后退算法。这种方法可以让您避免打击过载的服务,避免让情况变得更糟。

最后,要小心处理网络中异常细节的丢失。除非您使用的是支持 Python 的 RPC 机制,否则您可能会发现,通常在远程端熟悉友好的KeyErrorValueError变成了某种特定于 RPC 的错误,您必须检查其文本或数字错误代码,以便有机会知道发生了什么。

摘要

RPC 让您编写看似普通的 Python 函数调用,实际上是通过网络调用另一台服务器上的函数。它们通过序列化参数来做到这一点,这样它们就可以被传输;然后,它们对发回的返回值进行同样的处理。

所有 RPC 机制的工作方式几乎都是一样的:您建立一个网络连接,然后调用给您的代理对象,以便调用远程端的代码。Python 标准库中原生支持旧的 XML-RPC 协议,而对于更时尚、更现代的 JSON-RPC,则有很好的第三方库。

这两种机制都只允许少量的数据类型在客户机和服务器之间传递。如果您想要更完整的 Python 数据类型,那么您应该看看 Pyro 系统,它可以通过网络链接 Python 程序,并广泛支持本地 Python 类型。RPyC 系统甚至更广泛,它允许实际的对象在系统之间传递,这样对这些对象的方法调用被转发到对象实际所在的系统。

回顾这本书的内容,你会禁不住开始把每一章都看作是关于 RPC 的;也就是说,关于客户端程序和服务器之间的信息交换,通过一个关于请求将包含什么以及响应看起来如何的协议作为中介。既然您已经学习了 RPC,那么您已经看到了这种交换的最大特点,它不是为了支持任何特定的动作,而是为了支持任意的通信。当实现新服务时——尤其是当您想使用 RPC 时——总是要考虑您的问题是否真的需要 RPC 的灵活性,或者您的客户机和服务器之间的事务是否可以简化为本书前面提到的一种更简单、用途有限的协议。如果你为你面临的每一个问题选择正确的协议,不会招致不必要的复杂性,你将会得到简单、可靠和易于维护的网络系统的良好回报。

posted @ 2024-08-10 15:28  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报