Python-网络编程学习手册-全-
Python 网络编程学习手册(全)
原文:
zh.annas-archive.org/md5/b9ea58a6220e445a9f19c9c78aff8a58译者:飞龙
前言
欢迎来到使用 Python 进行网络编程的世界。Python 是一种功能齐全的面向对象的编程语言,具有一个标准库,其中包括了快速构建强大网络应用所需的一切。此外,它还有大量的第三方库和包,将 Python 扩展到网络编程的各个领域。结合使用 Python 的乐趣,我们希望通过这本书让您开始您的旅程,以便掌握这些工具并产生一些出色的网络代码。
在本书中,我们专注于 Python 3。尽管 Python 3 仍在确立自己作为 Python 2 的继任者,但第 3 版是该语言的未来,我们希望证明它已经准备好用于网络编程。它相对于以前的版本有许多改进,其中许多改进都提高了网络编程体验,包括增强的标准库模块和新的添加。
我们希望您喜欢这本关于使用 Python 进行网络编程的介绍。
本书内容
第一章,网络编程和 Python,介绍了对网络编程新手来说的核心网络概念,并介绍了 Python 中的网络编程方法。
第二章,HTTP 和网络工作,向您介绍了 HTTP 协议,并介绍了如何使用 Python 作为 HTTP 客户端检索和操作 Web 内容。我们还研究了标准库urllib和第三方Requests模块。
第三章,API 的实际应用,向您介绍了使用 HTTP 使用 Web API。我们还介绍了 XML 和 JSON 数据格式,并指导您使用 Amazon Web Services Simple Storage Service(S3)和 Twitter API 开发应用程序。
第四章,与电子邮件互动,涵盖了发送和接收电子邮件时使用的主要协议,如 SMTP、POP3 和 IMAP,以及如何在 Python 3 中使用它们。
第五章,与远程系统交互,指导您如何使用 Python 连接服务器并执行常见的管理任务,包括通过 SSH 执行 shell 命令,使用 FTP 和 SMB 进行文件传输,使用 LDAP 进行身份验证以及使用 SNMP 监视系统。
第六章,IP 和 DNS,讨论了 Internet Protocol(IP)的细节,以及在 Python 中处理 IP 的方法,以及如何使用 DNS 解析主机名。
第七章,使用套接字编程,涵盖了使用 Python 编写 TCP 和 UDP 套接字以编写低级网络应用程序。我们还介绍了用于安全数据传输的 HTTPS 和 TLS。
第八章,客户端和服务器应用程序,介绍了为基于套接字的通信编写客户端和服务器程序。通过编写一个回显应用程序和一个聊天应用程序,我们研究了开发基本协议、构建网络数据的方法,并比较了多线程和基于事件的服务器架构。
第九章,Web 应用程序,向您介绍了如何在 Python 中编写 Web 应用程序。我们涵盖了主要方法,Python Web 应用程序的托管方法,并在 Flask 微框架中开发了一个示例应用程序。
附录,使用 Wireshark,涵盖了数据包嗅探器、Wireshark 的安装以及如何使用 Wireshark 应用程序捕获和过滤数据包。
本书所需内容
本书针对 Python 3。虽然许多示例可以在 Python 2 中运行,但使用最新版本的 Python 3 来完成本书的学习会获得最佳体验。在撰写本文时,最新版本是 3.4.3,并且示例已针对此版本进行了测试。
尽管 Python 3.4 是首选版本,所有示例都应该在 Python 3.1 或更高版本上运行,除了以下情况:
-
第八章中的
asyncio示例,客户端和服务器应用,因为asyncio模块只包含在 3.4 版本中 -
第九章中的 Flask 示例,网络应用,需要 Python 3.3 或更高版本
我们还针对 Linux 操作系统,并假设您正在使用 Linux 操作系统。尽管示例已在 Windows 上进行了测试,但我们会注意到在需求或结果方面可能存在差异的地方。
虚拟环境
强烈建议您在使用本书时使用 Python 虚拟环境,或者“venvs”,实际上,在使用 Python 进行任何工作时都应该使用。venv 是 Python 可执行文件和相关文件的隔离副本,为安装 Python 模块提供了一个独立的环境,独立于系统 Python 安装。您可以拥有尽可能多的 venv,这意味着您可以设置多个模块配置,并且可以轻松地在它们之间切换。
从 3.3 版本开始,Python 包括一个venv模块,提供了这个功能。文档和示例可以在docs.python.org/3/using/scripts.html找到。还有一个独立的工具可用于早期版本,可以在virtualenv.pypa.io/en/latest/找到。
安装 Python 3
大多数主要的 Linux 发行版都预装了 Python 2。在这样的系统上安装 Python 3 时,重要的是要注意我们并没有替换 Python 2 的安装。许多发行版使用 Python 2 进行核心系统操作,并且这些操作将针对系统 Python 的主要版本进行调整。替换系统 Python 可能会对操作系统的运行产生严重后果。相反,当我们安装 Python 3 时,它会与 Python 2 并存。安装 Python 3 后,可以使用python3.x可执行文件来调用它,其中的x会被相应安装的次要版本替换。大多数软件包还提供了指向这个可执行文件的symlink,名为python3,可以代替运行。
大多数最新发行版都提供了安装 Python 3.4 的软件包,我们将在这里介绍主要的发行版。如果软件包不可用,仍然有一些选项可以用来安装一个可用的 Python 3.4 环境。
Ubuntu 和 Debian
Ubuntu 15.04 和 14.04 已经预装了 Python 3.4;所以如果您正在运行这些版本,您已经准备就绪。请注意,14.04 中存在一个错误,这意味着必须手动安装 pip 在使用捆绑的venv模块创建的任何 venv 中。您可以在askubuntu.com/questions/488529/pyvenv-3-4-error-returned-non-zero-exit-status-1找到解决此问题的信息。
对于 Ubuntu 的早期版本,Felix Krull 维护了一个最新的 Ubuntu Python 安装的存储库。完整的细节可以在launchpad.net/~fkrull/+archive/ubuntu/deadsnakes找到。
在 Debian 上,Jessie 有一个 Python 3.4 包(python3.4),可以直接用apt-get安装。Wheezy 有一个 3.2 的包(python3.2),Squeeze 有python3.1,可以类似地安装。为了在后两者上获得可用的 Python 3.4 安装,最简单的方法是使用 Felix Krull 的 Ubuntu 存储库。
RHEL、CentOS、Scientific Linux
这些发行版不提供最新的 Python 3 软件包,因此我们需要使用第三方存储库。对于 Red Hat Enterprise Linux、CentOS 和 Scientific Linux,可以从社区支持的软件集合(SCL)存储库获取 Python 3。有关使用此存储库的说明可以在www.softwarecollections.org/en/scls/rhscl/python33/找到。撰写时,Python 3.3 是最新可用版本。
Python 3.4 可从另一个存储库 IUS 社区存储库中获得,由 Rackspace 赞助。安装说明可以在iuscommunity.org/pages/IUSClientUsageGuide.html找到。
Fedora
Fedora 21 和 22 提供带有python3软件包的 Python 3.4:
**$ yum install python3**
对于早期版本的 Fedora,请使用前面列出的存储库。
备用安装方法
如果您正在使用的系统不是前面提到的系统之一,并且找不到适用于您的系统安装最新的 Python 3 的软件包,仍然有其他安装方法。我们将讨论两种方法,Pythonz和JuJu。
Pythonz
Pythonz 是一个管理从源代码编译 Python 解释器的程序。它从源代码下载并编译 Python,并在您的主目录中安装编译的 Python 解释器。然后可以使用这些二进制文件创建虚拟环境。这种安装方法的唯一限制是您需要在系统上安装构建环境(即 C 编译器和支持软件包),以及编译 Python 的依赖项。如果这不包含在您的发行版中,您将需要 root 访问权限来最初安装这些。完整的说明可以在github.com/saghul/pythonz找到。
JuJu
JuJu 可以作为最后的手段使用,它允许在任何系统上安装工作的 Python 3.4,而无需 root 访问权限。它通过在您的主目录中的文件夹中创建一个微型 Arch Linux 安装,并提供工具,允许我们切换到此安装并在其中运行命令。使用此方法,我们可以安装 Arch 的 Python 3.4 软件包,并且可以使用此软件包运行 Python 程序。Arch 环境甚至与您的系统共享主目录,因此在环境之间共享文件很容易。JuJu 主页位于github.com/fsquillace/juju。
JuJu 应该适用于任何发行版。要安装它,我们需要这样做:
**$ mkdir ~/.juju**
**$ curl https:// bitbucket.org/fsquillace/juju-repo/raw/master/juju- x86_64.tar.gz | tar -xz -C ~/.juju**
这将下载并提取 JuJu 映像到~/.juju。如果您在 32 位系统上运行,需要将x86_64替换为x86。接下来,设置PATH以获取 JuJu 命令:
**$ export PATH=~/.juju/opt/juju/bin:$PATH**
将此添加到您的.bashrc是个好主意,这样您就不需要每次登录时都运行它。接下来,我们在JuJu环境中安装 Python,我们只需要这样做一次:
**$ juju -f**
**$ pacman --sync refresh**
**$ pacman --sync --sysupgrade**
**$ pacman --sync python3**
**$ exit**
这些命令首先以 root 身份激活JuJu环境,然后使用pacman Arch Linux 软件包管理器更新系统并安装 Python 3.4。最后的exit命令退出JuJu环境。最后,我们可以以普通用户的身份访问JuJu环境:
**$ juju**
然后我们可以开始使用安装的 Python 3:
**$ python3**
**Python 3.4.3 (default, Apr 28 2015, 19:59:08)**
**[GCC 4.7.2] on linux**
**Type "help", "copyright", "credits" or "license" for more information.**
**>>>**
Windows
与一些较旧的 Linux 发行版相比,在 Windows 上安装 Python 3.4 相对容易;只需从www.python.org下载 Python 3.4 安装程序并运行即可。唯一的问题是它需要管理员权限才能这样做,因此如果您在受限制的计算机上,事情就会更加棘手。目前最好的解决方案是 WinPython,可以在winpython.github.io找到。
其他要求
我们假设您有一个正常工作的互联网连接。几章使用互联网资源广泛,而且没有真正的方法来离线模拟这些资源。拥有第二台计算机也对探索一些网络概念以及在真实网络中尝试网络应用程序非常有用。
我们还在几章中使用 Wireshark 数据包嗅探器。这将需要一台具有 root 访问权限(或 Windows 中的管理员访问权限)的机器。Wireshark 安装程序和安装说明可在www.wireshark.org找到。有关使用 Wireshark 的介绍可以在附录中找到,使用 Wireshark。
这本书是为谁写的
如果您是 Python 开发人员,或者具有 Python 经验的系统管理员,并且希望迈出网络编程的第一步,那么这本书适合您。无论您是第一次使用网络还是希望增强现有的网络和 Python 技能,您都会发现这本书非常有用。
约定
在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“通过在 Windows 上运行ip addr或ipconfig /all命令为您的计算机分配了 IP 地址。”
代码块设置如下:
import sys, urllib.request
try:
rfc_number = int(sys.argv[1])
except (IndexError, ValueError):
print('Must supply an RFC number as first argument')
sys.exit(2)
template = 'http://www.ietf.org/rfc/rfc{}.txt'
url = template.format(rfc_number)
rfc_raw = urllib.request.urlopen(url).read()
rfc = rfc_raw.decode()
print(rfc)
当我们希望引起您对代码块的特定部分的注意时,相关行或项目会被突出显示:
<body>
...
<div id="content">
<h1>Debian “jessie” Release Information</h1>
<p>**Debian 8.0** was
released October 18th, 2014.
The release included many major
changes, described in
...
任何命令行输入或输出都是这样写的:
**$ python RFC_downloader.py 2324 | less**
新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中出现,如:“我们可以看到开始按钮下面有一个接口列表。”
注意
警告或重要说明会出现在这样的框中。
提示
提示和技巧会以这种方式出现。
我们尽量遵循 PEP 8,但我们也遵循实用性胜过纯粹的原则,并在一些领域偏离。导入通常在一行上执行以节省空间,而且我们可能不严格遵守换行约定,因为这是印刷媒体的特性;我们的目标是“可读性至关重要”。
我们还选择专注于过程式编程风格,而不是使用面向对象的示例。这样做的原因是,熟悉面向对象编程的人通常更容易将过程式示例重新制作为面向对象的格式,而对于不熟悉面向对象编程的人来说,反过来做则更困难。
第一章:网络编程和 Python
本书将重点关注编写使用互联网协议套件的网络程序。为什么我们选择这样做呢?嗯,Python 标准库支持的协议集中,TCP/IP 协议是迄今为止最广泛应用的。它包含了互联网使用的主要协议。通过学习为 TCP/IP 编程,您将学会如何与连接到这个庞大网络电缆和电磁波的几乎每个设备进行通信。
在本章中,我们将研究一些关于网络和 Python 网络编程的概念和方法,这些内容将贯穿本书始终。
本章分为两个部分。第一部分,TCP/IP 网络简介,提供了对基本网络概念的介绍,重点介绍了 TCP/IP 协议栈。我们将研究网络的组成,互联网协议(IP)如何允许数据在网络之间传输,以及 TCP/IP 如何为我们提供帮助开发网络应用程序的服务。本节旨在为这些基本领域提供基础,并作为它们的参考点。如果您已经熟悉 IP 地址、路由、TCP 和 UDP 以及协议栈层等概念,那么您可能希望跳到第二部分,使用 Python 进行网络编程。
在第二部分,我们将看一下使用 Python 进行网络编程的方式。我们将介绍主要的标准库模块,看一些示例以了解它们与 TCP/IP 协议栈的关系,然后我们将讨论一般的方法来找到和使用满足我们网络需求的模块。我们还将看一下在编写通过 TCP/IP 网络进行通信的应用程序时可能遇到的一些一般问题。
TCP/IP 网络简介
互联网协议套件,通常称为 TCP/IP,是一组旨在共同工作以在互连网络上提供端到端消息传输的协议。
以下讨论基于互联网协议第 4 版(IPv4)。由于互联网已经用尽了 IPv4 地址,已经开发了一个新版本 IPv6,旨在解决这种情况。然而,尽管 IPv6 在一些领域得到了应用,但其部署进展缓慢,大多数互联网可能会继续使用 IPv4。我们将在本节重点讨论 IPv4,然后在本章的第二部分讨论 IPv6 的相关变化。
TCP/IP 在称为请求评论(RFCs)的文件中进行了规定,这些文件由互联网工程任务组(IETF)发布。RFCs 涵盖了广泛的标准,而 TCP/IP 只是其中之一。它们可以在 IETF 的网站上免费获取,网址为www.ietf.org/rfc.html。每个 RFC 都有一个编号,IPv4 由 RFC 791 记录,随着我们的进展,其他相关的 RFC 也会被提到。
请注意,本章不会教你如何设置自己的网络,因为这是一个大课题,而且很遗憾,有些超出了本书的范围。但是,至少它应该能让你与网络支持人员进行有意义的交流!
IP 地址
所以,让我们从你可能熟悉的内容开始,即 IP 地址。它们通常看起来像这样:
203.0.113.12
它们实际上是一个 32 位的数字,尽管它们通常被写成前面示例中显示的数字;它们以四个由点分隔的十进制数的形式书写。这些数字有时被称为八位组或字节,因为每个数字代表 32 位数字中的 8 位。因此,每个八位组只能取 0 到 255 的值,因此有效的 IP 地址范围从 0.0.0.0 到 255.255.255.255。这种写 IP 地址的方式称为点十进制表示法。
IP 地址执行两个主要功能。它们如下:
-
它们唯一地寻址连接到网络的每个设备
-
它们帮助在网络之间路由流量
您可能已经注意到您使用的网络连接设备都分配了 IP 地址。分配给网络设备的每个 IP 地址都是唯一的,没有两个设备可以共享一个 IP 地址。
网络接口
您可以通过在终端上运行ip addr(或在 Windows 上运行ipconfig /all)来查找分配给您计算机的 IP 地址。在第六章IP 和 DNS中,我们将看到在使用 Python 时如何做到这一点。
如果我们运行这些命令之一,那么我们可以看到 IP 地址分配给我们设备的网络接口。在 Linux 上,这些将具有名称,如eth0;在 Windows 上,这些将具有短语,如Ethernet adapter Local Area Connection。
在 Linux 上运行ip addr命令时,您将获得以下输出:
**$ ip addr**
**1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN**
**link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00**
**inet 127.0.0.1/8 scope host lo**
**valid_lft forever preferred_lft forever**
**2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000**
**link/ether b8:27:eb:5d:7f:ae brd ff:ff:ff:ff:ff:ff**
**inet 192.168.0.4/24 brd 192.168.0.255 scope global eth0**
**valid_lft forever preferred_lft forever**
在前面的示例中,接口的 IP 地址出现在单词inet之后。
接口是设备与其网络媒体的物理连接。它可以是连接到网络电缆的网络卡,也可以是使用特定无线技术的无线电。台式电脑可能只有一个用于网络电缆的接口,而智能手机可能至少有两个接口,一个用于连接 Wi-Fi 网络,一个用于连接使用 4G 或其他技术的移动网络。
通常一个接口只分配一个 IP 地址,设备中的每个接口都有不同的 IP 地址。因此,回到前面部分讨论的 IP 地址的目的,我们现在可以更准确地说,它们的第一个主要功能是唯一地寻址每个设备与网络的连接。
每个设备都有一个名为环回接口的虚拟接口,您可以在前面的列表中看到它作为接口1。这个接口实际上并不连接到设备外部的任何东西,只有设备本身才能与它通信。虽然这听起来有点多余,但在进行本地网络应用程序测试时非常有用,它也可以用作进程间通信的手段。环回接口通常被称为本地主机,它几乎总是被分配 IP 地址 127.0.0.1。
分配 IP 地址
IP 地址可以通过网络管理员以两种方式之一分配给设备:静态分配,其中设备的操作系统手动配置 IP 地址,或动态分配,其中设备的操作系统使用动态主机配置协议(DHCP)进行配置。
在使用 DHCP 时,设备第一次连接到网络时,它会自动从预定义的池中由 DHCP 服务器分配一个地址。一些网络设备,如家用宽带路由器,提供了开箱即用的 DHCP 服务器服务,否则必须由网络管理员设置 DHCP 服务器。DHCP 被广泛部署,特别适用于不同设备可能频繁连接和断开的网络,如公共 Wi-Fi 热点或移动网络。
互联网上的 IP 地址
互联网是一个庞大的 IP 网络,每个通过它发送数据的设备都被分配一个 IP 地址。
IP 地址空间由一个名为互联网数字分配机构(IANA)的组织管理。IANA 决定 IP 地址范围的全球分配,并向全球区域互联网注册机构(RIRs)分配地址块,然后 RIRs 向国家和组织分配地址块。接收组织有自由在其分配的地址块内自由分配地址。
有一些特殊的 IP 地址范围。IANA 定义了私有地址范围。这些范围永远不会分配给任何组织,因此任何人都可以用于他们的网络。私有地址范围如下:
-
10.0.0.0 到 10.255.255.255
-
172.16.0.0 到 172.31.255.255
-
192.168.0.0 到 192.168.255.255
你可能会想,如果任何人都可以使用它们,那么这是否意味着互联网上的设备最终会使用相同的地址,从而破坏 IP 的唯一寻址属性?这是一个很好的问题,这个问题已经通过禁止私有地址的流量在公共互联网上传输来避免。每当使用私有地址的网络需要与公共互联网通信时,会使用一种称为网络地址转换(NAT)的技术,这实质上使得来自私有网络的流量看起来来自单个有效的公共互联网地址,这有效地隐藏了私有地址。我们稍后会讨论 NAT。
如果你检查家庭网络上ip addr或ipconfig /all的输出,你会发现你的设备正在使用私有地址范围,这些地址是通过 DHCP 由你的宽带路由器分配给它们的。
数据包
在接下来的章节中,我们将讨论网络流量,所以让我们先了解一下它是什么。
许多协议,包括互联网协议套件中的主要协议,使用一种称为数据包化的技术来帮助管理数据在网络上传输时的情况。
当一个数据包协议被给定一些数据进行传输时,它将数据分解成小单元——典型的几千字节长的字节序列,然后在每个单元前面加上一些特定于协议的信息。前缀称为头部,前缀和数据一起形成一个数据包。数据包中的数据通常被称为其有效载荷。
数据包的内容如下图所示:

一些协议使用数据包的替代术语,如帧,但我们现在将使用数据包这个术语。头部包括协议实现在另一个设备上运行所需的所有信息,以便能够解释数据包是什么以及如何处理它。例如,IP 数据包头部中的信息包括源 IP 地址、目标 IP 地址、数据包的总长度和头部数据的校验和。
一旦创建,数据包被发送到网络上,然后独立路由到它们的目的地。以数据包形式发送数据有几个优点,包括多路复用(多个设备可以同时在网络上发送数据),快速通知网络上可能发生的错误,拥塞控制和动态重路由。
协议可能调用其他协议来处理它们的数据包;将它们的数据包传递给第二个协议进行传递。当两个协议都使用数据包化时,会产生嵌套数据包,如下图所示:

这被称为封装,正如我们很快将看到的,这是一种构造网络流量的强大机制。
网络
网络是一组连接的网络设备。网络的规模可以有很大的差异,它们可以由较小的网络组成。您家中连接到网络的设备或大型办公楼中连接到网络的计算机都是网络的例子。
有很多种定义网络的方法,有些宽泛,有些非常具体。根据上下文,网络可以由物理边界、管理边界、机构边界或网络技术边界来定义。
在本节中,我们将从网络的简化定义开始,然后逐渐朝着更具体的 IP 子网定义发展。
因此,对于我们简化的定义,网络的共同特征将是网络上的所有设备共享与互联网的单一连接点。在一些大型或专业网络中,您会发现有多个连接点,但为了简单起见,我们将在这里坚持单一连接。
这个连接点被称为网关,通常采用一种称为路由器的特殊网络设备的形式。路由器的工作是在网络之间传输流量。它位于两个或多个网络之间,并且被称为位于这些网络的边界。它总是有两个或更多个网络接口;每个网络都连接一个。路由器包含一组称为路由表的规则,告诉它如何根据数据包的目标 IP 地址将通过它传递的数据包进一步传递。
网关将数据包转发到另一个路由器,该路由器被称为上游,通常位于网络的互联网服务提供商(ISP)处。ISP 的路由器属于第二类路由器,即它位于前面描述的网络之外,并在网络网关之间路由流量。这些路由器由 ISP 和其他通信实体运行。它们通常按层次排列,上层区域路由器为一些大片国家或大陆的流量路由,并形成互联网的骨干网。
由于这些路由器可以位于许多网络之间,它们的路由表可能会变得非常庞大,并且需要不断更新。下图显示了一个简化的示例:

前面的图表给了我们一个布局的想法。每个 ISP 网关连接 ISP 网络到区域路由器,每个家庭宽带路由器都连接着一个家庭网络。在现实世界中,随着向顶部前进,这种布局变得更加复杂。ISP 通常会有多个连接它们到区域路由器的网关,其中一些也会充当区域路由器。区域路由器的层次也比这里显示的更多,它们之间有许多连接,这些连接的布局比这个简单的层次结构复杂得多。从 2005 年收集的数据中得出的互联网部分的渲染提供了一个美丽的插图,展示了这种复杂性,可以在en.wikipedia.org/wiki/Internet_backbone#/media/File:Internet_map_1024.jpg找到。
使用 IP 进行路由
我们提到路由器能够将流量路由到目标网络,并暗示这是通过使用 IP 地址和路由表来完成的。但这里真正发生了什么呢?
路由器确定要转发流量到正确路由器的一种明显的方法可能是在每个路由器的路由表中为每个 IP 地址编程一个路由。然而,在实践中,随着 40 多亿个 IP 地址和不断变化的网络路由,这种方法被证明是完全不可行的。
那么,路由是如何完成的?答案在 IP 地址的另一个属性中。IP 地址可以被解释为由两个逻辑部分组成:网络前缀和主机标识符。网络前缀唯一标识设备所在的网络,设备可以使用这个来确定如何处理它生成的流量,或者接收到的用于转发的流量。当 IP 地址以二进制形式写出时(记住 IP 地址实际上只是一个 32 位的数字),网络前缀是 IP 地址的前n位。这n位由网络管理员作为设备的网络配置的一部分提供,同时也提供了 IP 地址。
您会看到n以两种方式之一写出。它可以简单地附加到 IP 地址后面,用斜杠分隔,如下所示:
192.168.0.186/24
这被称为CIDR 表示法。或者,它可以被写成子网掩码,有时也被称为网络掩码。这通常是在设备的网络配置中指定n的方式。子网掩码是一个以点十进制表示的 32 位数字,就像 IP 地址一样。
255.255.255.0
这个子网掩码等同于/24。我们可以通过将其转换为二进制来得到n。以下是一些例子:
255.0.0.0 = 11111111 00000000 00000000 00000000 = /8
255.192.0.0 = 11111111 11000000 00000000 00000000 = /10
255.255.255.0 = 11111111 11111111 11111111 00000000 = /24
255.255.255.240 = 11111111 11111111 11111111 11110000 = /28
n只是子网掩码中设置为 1 的位数。(总是设置为 1 的最左边的位,因为这使我们可以通过对 IP 地址和子网掩码进行按位AND操作来快速得到二进制中的网络前缀)。
那么,这如何帮助路由?当网络设备生成需要发送到网络的网络流量时,它首先将目的地的 IP 地址与自己的网络前缀进行比较。如果目的地 IP 地址与发送设备的网络前缀相同,那么发送设备将认识到目的设备在同一网络上,因此可以直接将流量发送到目的地。如果网络前缀不同,那么它将将消息发送到默认网关,后者将将其转发到接收设备。
当路由器接收到需要转发的流量时,它首先检查目的地 IP 地址是否与它连接到的任何网络的网络前缀匹配。如果是这样,它将直接将消息发送到该网络上的目的设备。如果不是,它将查看其路由表。如果找到匹配的规则,它将将消息发送到列出的路由器,如果没有明确的规则定义,它将将流量发送到自己的默认网关。
当我们使用给定的网络前缀创建一个网络时,在 IP 地址的 32 位中,网络前缀右侧的数字可用于分配给网络设备。我们可以通过将 2 的幂次方提高到可用位数来计算可用地址的数量。例如,在/28网络前缀中,我们有 4 位剩下,这意味着有 16 个地址可用。实际上,我们能够分配更少的地址,因为计算范围中的两个地址总是保留的。这些是:范围中的第一个地址,称为网络地址和范围中的最后一个地址,称为广播地址。
这个地址范围,由其网络前缀标识,被称为子网。当 IANA、RIR 或 ISP 向组织分配 IP 地址块时,子网是分配的基本单位。组织将子网分配给它们的各种网络。
组织可以通过使用比他们分配的更长的网络前缀来将他们的地址进一步分区。他们可能这样做是为了更有效地使用他们的地址,或者创建一个网络层次结构,可以在整个组织中委派。
DNS
我们已经讨论了使用 IP 地址连接到网络设备。但是,除非您在网络或系统管理中工作,否则您很少会经常看到 IP 地址,尽管我们中的许多人每天都使用互联网。当我们浏览网页或发送电子邮件时,我们通常使用主机名或域名连接到服务器。这些必须以某种方式映射到服务器的 IP 地址。但是这是如何完成的呢?
作为 RFC 1035 记录的域名系统(DNS)是主机名和 IP 地址之间映射的全球分布式数据库。它是一个开放和分层的系统,许多组织选择运行自己的 DNS 服务器。DNS 也是一种协议,设备使用它来查询 DNS 服务器以将主机名解析为 IP 地址(反之亦然)。
nslookup工具随大多数 Linux 和 Windows 系统一起提供,并允许我们在命令行上查询 DNS,如下所示:
**$ nslookup python.org**
**Server: 192.168.0.4**
**Address: 192.168.0.4#53**
**Non-authoritative answer:**
**Name: python.org**
**Address: 104.130.43.121**
在这里,我们确定python.org主机的 IP 地址为104.130.42.121。DNS 通过使用分层的缓存服务器系统来分发查找主机名的工作。连接到网络时,您的网络设备将通过 DHCP 或手动方式获得本地 DNS 服务器,并在进行 DNS 查找时查询此本地服务器。如果该服务器不知道 IP 地址,那么它将查询自己配置的更高层服务器,依此类推,直到找到答案。ISP 运行其自己的 DNS 缓存服务器,宽带路由器通常也充当缓存服务器。在此示例中,我的设备的本地服务器是192.168.0.4。
设备的操作系统通常处理 DNS,并提供编程接口,应用程序使用该接口来请求解析主机名和 IP 地址。Python 为此提供了一个接口,我们将在第六章中讨论IP 和 DNS。
协议栈或为什么互联网就像蛋糕
互联网协议是互联网协议套件中的一种协议。套件中的每个协议都设计用于解决网络中的特定问题。我们刚刚看到 IP 如何解决寻址和路由问题。
套件中的核心协议被设计为在堆栈内一起工作。也就是说,套件中的每个协议都占据堆栈内的一层,并且其他协议位于该层的上方和下方。因此,它就像蛋糕一样分层。每一层为其上面的层提供特定的服务,同时隐藏其自身操作的复杂性,遵循封装的原则。理想情况下,每一层只与其下面的层进行接口,以便从下面的所有层的问题解决能力中获益。
Python 提供了用于与不同协议进行接口的模块。由于协议采用封装,我们通常只需要使用一个模块来利用底层堆栈的功能,从而避免了较低层的复杂性。
TCP/IP 套件定义了四层,尽管通常为了清晰起见使用五层。这些列在下表中:
| 层 | 名称 | 示例协议 |
|---|---|---|
| 5 | 应用层 | HTTP,SMTP,IMAP |
| 4 | 传输层 | TCP,UDP |
| 3 | 网络层 | IP |
| 2 | 数据链路层 | 以太网,PPP,FDDI |
| 1 | 物理层 | - |
层 1 和层 2 对应于 TCP/IP 套件的第一层。这两个底层处理低级网络基础设施和服务。
第 1 层对应于网络的物理介质,例如电缆或 Wi-Fi 无线电。第 2 层提供了将数据从一个网络设备直接连接到另一个网络设备的服务。只要第 3 层的互联网协议可以要求它使用任何可用的物理介质将数据传输到网络中的下一个设备,此层可以使用各种第 2 层协议,例如以太网或 PPP。
当使用 Python 时,我们不需要关注最低的两层,因为我们很少需要与它们进行交互。它们的操作几乎总是由操作系统和网络硬件处理。
第 3 层有时被称为网络层和互联网层。它专门使用互联网协议。正如我们已经看到的,它的主要任务是进行互联网寻址和路由。同样,在 Python 中我们通常不直接与这一层进行交互。
第 4 层和第 5 层对我们的目的更有趣。
第 4 层 - TCP 和 UDP
第 4 层是我们可能想要在 Python 中使用的第一层。这一层可以使用两种协议之一:传输控制协议(TCP)和用户数据报协议(UDP)。这两种协议都提供了在不同网络设备上的应用程序之间端到端数据传输的常见服务。
网络端口
尽管 IP 促进了数据从一个网络设备传输到另一个网络设备,但它并没有为我们提供一种让目标设备知道一旦接收到数据应该做什么的方法。解决这个问题的一个可能方案是编写运行在目标设备上的每个进程,以检查所有传入的数据,看看它们是否感兴趣,但这很快会导致明显的性能和安全问题。
TCP 和 UDP 通过引入端口的概念提供了答案。端口是一个端点,附加到网络设备分配的 IP 地址之一。端口由设备上运行的进程占用,然后该进程被称为在该端口上监听。端口由一个 16 位数字表示,因此设备上的每个 IP 地址都有 65,535 个可能的端口,进程可以占用(端口号 0 被保留)。端口一次只能被一个进程占用,尽管一个进程可以同时占用多个端口。
当通过 TCP 或 UDP 在网络上传送消息时,发送应用程序在 TCP 或 UDP 数据包的标头中设置目标端口号。当消息到达目的地时,运行在接收设备上的 TCP 或 UDP 协议实现读取端口号,然后将消息有效载荷传递给在该端口上监听的进程。
在发送消息之前,需要知道端口号。这主要是通过约定来实现的。除了管理 IP 地址空间外,IANA 还负责管理端口号分配给网络服务。
服务是一类应用程序,例如 Web 服务器或 DNS 服务器,通常与应用程序协议相关联。端口分配给服务而不是特定的应用程序,因为这样可以让服务提供者灵活选择要使用的软件类型来提供服务,而不必担心用户需要查找和连接到新的端口号,仅仅是因为服务器开始使用 Apache 而不是 IIS,例如。
大多数操作系统都包含了这个服务列表及其分配的端口号的副本。在 Linux 上,通常可以在/etc/services找到,在 Windows 上,通常可以在c:\windows\system32\drivers\etc\services找到。完整的列表也可以在www.iana.org/assignments/port-numbers上在线查看。
TCP 和 UDP 数据包头也可能包括源端口号。对于 UDP 来说,这是可选的,但对于 TCP 来说是强制的。源端口号告诉服务器上的接收应用程序在向客户端发送数据时应该将回复发送到哪里。应用程序可以指定它们希望使用的源端口,或者如果没有为 TCP 指定源端口,则在发送数据包时操作系统会随机分配一个。一旦操作系统有了源端口号,它就会将其分配给调用应用程序,并开始监听以获取回复。如果在该端口上收到回复,则接收到的数据将传递给发送应用程序。
因此,TCP 和 UCP 都通过提供端口为应用程序数据提供端到端的传输,并且它们都使用互联网协议将数据传输到目标设备。现在,让我们来看看它们的特点。
UDP
UDP 的文档编号为 RFC 768。它故意简单:它除了我们在前一节中描述的服务之外,不提供任何服务。它只是获取我们要发送的数据,使用目标端口号(和可选的源端口号)对其进行数据包化,并将其交给本地互联网协议实现进行传递。接收端的应用程序以与数据包化时相同的离散块看到数据。
IP 和 UDP 都是所谓的无连接协议。这意味着它们试图尽最大努力交付它们的数据包,但如果出现问题,它们将只是耸耸肩并继续交付下一个数据包。我们的数据包到达目的地的保证,并且如果交付失败,也没有错误通知。如果数据包成功到达,也不能保证它们会按照发送顺序到达。这取决于更高层的协议或发送应用程序来确定数据包是否已到达以及如何处理任何问题。这些是一种“发射即忘”的协议。
UDP 的典型应用是互联网电话和视频流。DNS 查询也使用 UDP 进行传输。
我们现在将看一下 UDP 的更可靠的兄弟 TCP,然后讨论它们之间的区别,以及应用程序可能选择使用其中一个的原因。
TCP
传输控制协议的文档编号为 RFC 761。与 UDP 相反,TCP 是一种基于连接的协议。在这种协议中,直到服务器和客户端执行了初始的控制数据包交换之前,才会发送数据。这种交换被称为握手。这建立了一个连接,从那时起就可以发送数据。接收到的每个数据包都会得到接收方的确认,它通过发送一个称为ACK的数据包来进行确认。因此,TCP 总是要求数据包包括源端口号,因为它依赖于持续的双向消息交换。
从应用程序的角度来看,UDP 和 TCP 之间的关键区别是应用程序不再以离散的块看到数据;TCP 连接将数据呈现给应用程序作为连续的、无缝的字节流。如果我们发送的消息大于典型的数据包,这会使事情变得简单得多,但这意味着我们需要开始考虑帧我们的消息。虽然使用 UDP,我们可以依赖其数据包化来提供这样的手段,但是使用 TCP,我们必须决定一个机制来明确地确定我们的消息从哪里开始和结束。我们将在第八章中看到更多关于这一点,“客户端和服务器应用程序”。
TCP 提供以下服务:
-
按顺序交付
-
接收确认
-
错误检测
-
流和拥塞控制
通过 TCP 发送的数据保证按发送顺序传递到接收应用程序。接收 TCP 实现在接收设备上缓冲接收的数据包,然后等待直到能够按正确顺序传递它们给应用程序。
由于数据包被确认,发送应用程序可以确保数据正在到达,并且可以继续发送数据。如果发送的数据包没有收到确认,那么在一定时间内数据包将被重新发送。如果仍然没有响应,那么 TCP 将以递增的间隔不断重新发送数据包,直到第二个更长的超时期限到期。在这一点上,它将放弃并通知调用应用程序遇到了问题。
TCP 头部包括头部数据和有效载荷的校验和。这允许接收方验证数据包的内容在传输过程中是否被修改。
TCP 还包括算法,确保流量不会发送得太快,以至于接收设备无法处理,并且这些算法还推断网络条件并调节传输速率以避免网络拥塞。
这些服务共同为应用程序数据提供了强大可靠的传输系统。这是许多流行的高级协议(如 HTTP、SMTP、SSH 和 IMAP)依赖 TCP 的原因之一。
UDP 与 TCP
鉴于 TCP 的特性,您可能想知道无连接协议 UDP 的用途是什么。嗯,互联网仍然是一个相当可靠的网络,大多数数据包确实会被传递。无连接协议在需要最小传输开销和偶尔丢包不是大问题的情况下很有用。TCP 的可靠性和拥塞控制需要额外的数据包和往返时间,并且在数据包丢失时引入故意的延迟以防止拥塞。这可能会大大增加延迟,这是实时服务的大敌,而对它们并没有提供任何真正的好处。一些丢失的数据包可能会导致媒体流中的瞬时故障或信号质量下降,但只要数据包继续到达,流通常可以恢复。
UDP 也是用于 DNS 的主要协议,这很有趣,因为大多数 DNS 查询都适合在一个数据包内,因此通常不需要 TCP 的流能力。DNS 通常也配置为不依赖于可靠的连接。大多数设备配置有多个 DNS 服务器,通常更快地重新发送查询到第二个服务器,而不是等待 TCP 的退避期限到期。
UDP 和 TCP 之间的选择取决于消息大小,延迟是否是一个问题,以及应用程序希望自己执行多少 TCP 功能。
第 5 层 - 应用层
最后我们来到了堆栈的顶部。应用层在 IP 协议套件中被故意保持开放,它实际上是任何在 TCP 或 UDP(甚至 IP,尽管这些更少见)之上由应用程序开发人员开发的协议的综合。应用层协议包括 HTTP、SMTP、IMAP、DNS 和 FTP。
协议甚至可以成为它们自己的层,其中一个应用程序协议建立在另一个应用程序协议之上。一个例子是简单对象访问协议(SOAP),它定义了一种基于 XML 的协议,可以在几乎任何传输上使用,包括 HTTP 和 SMTP。
Python 具有许多应用层协议的标准库模块和许多第三方模块。如果我们编写低级服务器应用程序,那么我们更有可能对 TCP 和 UDP 感兴趣,但如果不是,那么应用层协议就是我们将要使用的协议,我们将在接下来的几章中详细讨论其中一些。
接下来是 Python!
好了,关于 TCP/IP 栈的介绍就到此为止。我们将继续本章的下一部分,我们将看一下如何开始使用 Python 以及如何处理我们刚刚涵盖的一些主题。
使用 Python 进行网络编程
在这一部分,我们将看一下 Python 中网络编程的一般方法。我们将看一下 Python 如何让我们与网络栈进行接口,如何追踪有用的模块,并涵盖一些一般的网络编程技巧。
打破一些蛋
网络协议层模型的强大之处在于更高层可以轻松地建立在较低层提供的服务之上,这使它们能够向网络添加新的服务。Python 提供了用于与网络栈中不同层级的协议进行接口的模块,而支持更高层协议的模块通过使用较低级别协议提供的接口来遵循前述原则。我们如何可以可视化这一点呢?
嗯,有时候看清楚这样的东西的一个好方法就是打破它。所以,让我们打破 Python 的网络栈。或者更具体地说,让我们生成一个回溯。
是的,这意味着我们要写的第一段 Python 将生成一个异常。但是,这将是一个好的异常。我们会从中学到东西。所以,启动你的 Python shell 并运行以下命令:
**>>> import smtplib**
**>>> smtplib.SMTP('127.0.0.1', port=66000)**
我们在这里做什么?我们正在导入smtplib,这是 Python 用于处理 SMTP 协议的标准库。SMTP 是一个应用层协议,用于发送电子邮件。然后,我们将尝试通过实例化一个SMTP对象来打开一个 SMTP 连接。我们希望连接失败,这就是为什么我们指定了端口号 66000,这是一个无效的端口。我们将为连接指定本地主机,因为这将导致它快速失败,而不是让它等待网络超时。
运行上述命令时,您应该会得到以下回溯:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "**/usr/lib/python3.4/smtplib.py**", line 242, in __init__
(code, msg) = self.connect(host, port)
File "**/usr/lib/python3.4/smtplib.py**", line 321, in connect
self.sock = self._get_socket(host, port, self.timeout)
File "**/usr/lib/python3.4/smtplib.py**", line 292, in _get_socket
self.source_address)
File "**/usr/lib/python3.4/socket.py**", line 509, in create_connection
raise err
File "**/usr/lib/python3.4/socket.py**", line 500, in create_connection
sock.connect(sa)
ConnectionRefusedError: [Errno 111] Connection refused
这是在 Debian 7 机器上使用 Python 3.4.1 生成的。如果你在 Windows 上运行这个命令,最终的错误消息将与此略有不同,但堆栈跟踪将保持不变。
检查它将揭示 Python 网络模块如何作为一个栈。我们可以看到调用栈从smtplib.py开始,然后向下移动到socket.py。socket模块是 Python 的标准接口,用于传输层,并提供与 TCP 和 UDP 的交互功能,以及通过 DNS 查找主机名的功能。我们将在第七章使用套接字编程和第八章客户端和服务器应用程序中学到更多。
从前面的程序中可以清楚地看出,smtplib模块调用了socket模块。应用层协议已经使用了传输层协议(在本例中是 TCP)。
在回溯的最底部,我们可以看到异常本身和Errno 111。这是操作系统的错误消息。您可以通过查看/usr/include/asm-generic/errno.h(某些系统上的asm/errno.h)来验证这一点,以获取错误消息编号 111(在 Windows 上,错误将是 WinError,因此您可以看到它显然是由操作系统生成的)。从这个错误消息中,我们可以看到socket模块再次调用并要求操作系统为其管理 TCP 连接。
Python 的网络模块正在按照协议栈设计者的意图工作。它们调用协议栈中的较低级别来利用它们的服务来执行网络任务。我们可以通过对应用层协议(在本例中为 SMTP)进行简单调用来工作,而不必担心底层网络层。这就是网络封装的实际应用,我们希望在我们的应用程序中尽可能多地利用这一点。
从顶部开始
在我们开始为新的网络应用程序编写代码之前,我们希望尽可能充分利用现有的堆栈。这意味着找到一个提供我们想要使用的服务接口的模块,并且尽可能高地找到。如果我们幸运的话,有人已经编写了一个提供我们需要的确切服务接口的模块。
让我们用一个例子来说明这个过程。让我们编写一个工具,用于从 IETF 下载请求评论(RFC)文档,然后在屏幕上显示它们。
让我们保持 RFC 下载器简单。我们将把它制作成一个命令行程序,只接受 RFC 编号,下载 RFC 的文本格式,然后将其打印到stdout。
现在,有可能有人已经为此编写了一个模块,所以让我们看看能否找到任何东西。
我们应该总是首先查看 Python 标准库。标准库中的模块得到了很好的维护和文档化。当我们使用标准库模块时,您的应用程序的用户不需要安装任何额外的依赖项来运行它。
在docs.python.org的库参考中查看,似乎没有显示与我们要求直接相关的内容。这并不完全令人惊讶!
因此,接下来我们将转向第三方模块。可以在pypi.python.org找到 Python 软件包索引,这是我们应该寻找这些模块的地方。在这里,围绕 RFC 客户端和 RFC 下载主题运行几次搜索似乎没有发现任何有用的东西。下一个要查找的地方将是 Google,尽管再次搜索没有发现任何有希望的东西。这有点令人失望,但这就是我们学习网络编程的原因,以填补这些空白!
还有其他方法可以找到有用的第三方模块,包括邮件列表、Python 用户组、编程问答网站stackoverflow.com和编程教材。
现在,让我们假设我们真的找不到一个用于下载 RFC 的模块。接下来呢?嗯,我们需要在网络堆栈中考虑更低的层次。这意味着我们需要自己识别我们需要使用的网络协议,以便以文本格式获取 RFC。
RFC 的 IETF 登陆页面是www.ietf.org/rfc.html,通过阅读它告诉我们确切的信息。我们可以使用形式为www.ietf.org/rfc/rfc741.txt的 URL 访问 RFC 的文本版本。在这种情况下,RFC 编号是 741。因此,我们可以使用 HTTP 获取 RFC 的文本格式。
现在,我们需要一个可以代表我们说 HTTP 的模块。我们应该再次查看标准库。您会注意到,实际上有一个名为http的模块。听起来很有希望,尽管查看其文档将告诉我们它是一个低级库,而名为urllib的东西将被证明更有用。
现在,查看urllib的文档,我们发现它确实可以做我们需要的事情。它通过一个简单的 API 下载 URL 的目标。我们找到了我们的协议模块。
下载 RFC
现在我们可以编写我们的程序。为此,创建一个名为RFC_downloader.py的文本文件,并将以下代码保存到其中:
import sys, urllib.request
try:
rfc_number = int(sys.argv[1])
except (IndexError, ValueError):
print('Must supply an RFC number as first argument')
sys.exit(2)
template = 'http://www.ietf.org/rfc/rfc{}.txt'
url = template.format(rfc_number)
rfc_raw = urllib.request.urlopen(url).read()
rfc = rfc_raw.decode()
print(rfc)
我们可以使用以下命令运行前面的代码:
**$ python RFC_downloader.py 2324 | less**
在 Windows 上,您需要使用more而不是less。RFC 可能有很多页,因此我们在这里使用一个分页器。如果您尝试这样做,那么您应该会在咖啡壶的远程控制上看到一些有用的信息。
让我们回顾一下我们迄今为止所做的工作。
首先,我们导入我们的模块并检查命令行上是否提供了 RFC 编号。然后,我们通过替换提供的 RFC 编号来构造我们的 URL。接下来,主要活动是urlopen()调用将为我们的 URL 构造一个 HTTP 请求,然后它将通过互联网联系 IETF 网络服务器并下载 RFC 文本。接着,我们将文本解码为 Unicode,最后将其打印到屏幕上。
因此,我们可以轻松地从命令行查看任何我们喜欢的 RFC。回顾起来,毫不奇怪没有一个模块可以做到这一点,因为我们可以使用urllib来完成大部分繁重的工作!
深入了解
但是,如果 HTTP 是全新的,没有像urllib这样的模块可以代表我们发起 HTTP 请求,那该怎么办呢?那么我们将不得不再次向下调整堆栈,并使用 TCP 来实现我们的目的。让我们根据这种情况修改我们的程序,如下所示:
import sys, socket
try:
rfc_number = int(sys.argv[1])
except (IndexError, ValueError):
print('Must supply an RFC number as first argument')
sys.exit(2)
host = 'www.ietf.org'
port = 80
sock = socket.create_connection((host, port))
req = (
'GET /rfc/rfc{rfcnum}.txt HTTP/1.1\r\n'
'Host: {host}:{port}\r\n'
'User-Agent: Python {version}\r\n'
'Connection: close\r\n'
'\r\n'
)
req = req.format(
rfcnum=rfc_number,
host=host,
port=port,
version=sys.version_info[0]
)
sock.sendall(req.encode('ascii'))
rfc_raw = bytearray()
while True:
buf = sock.recv(4096)
if not len(buf):
break
rfc_raw += buf
rfc = rfc_raw.decode('utf-8')
print(rfc)
第一个显而易见的变化是我们使用了socket而不是urllib。Socket 是 Python 操作系统 TCP 和 UDP 实现的接口。命令行检查保持不变,但接着我们会发现现在需要处理一些urllib之前为我们做的事情。
我们必须告诉套接字我们想要使用哪种传输层协议。我们通过使用socket.create_connection()便利函数来实现这一点。这个函数将始终创建一个 TCP 连接。您会注意到我们还必须显式提供socket应该用来建立连接的 TCP 端口号。为什么是 80?80 是 HTTP 上的 Web 服务的标准端口号。我们还必须将主机与 URL 分开,因为socket不理解 URL。
我们创建的发送到服务器的请求字符串也比我们之前使用的 URL 复杂得多:它是一个完整的 HTTP 请求。在下一章中,我们将详细讨论这些。
接下来,我们处理 TCP 连接上的网络通信。我们使用sendall()调用将整个请求字符串发送到服务器。通过 TCP 发送的数据必须是原始字节,因此我们必须在发送之前将请求文本编码为 ASCII。
然后,我们在while循环中将服务器的响应拼接在一起。通过 TCP 套接字发送给我们的字节以连续流的形式呈现给我们的应用程序。因此,就像任何长度未知的流一样,我们必须进行迭代读取。在服务器发送所有数据并关闭连接后,recv()调用将返回空字符串。因此,我们可以将其用作打破循环并打印响应的条件。
我们的程序显然更加复杂。与我们之前的程序相比,这在维护方面并不好。此外,如果您运行程序并查看输出 RFC 文本的开头,您会注意到开头有一些额外的行,如下所示:
HTTP/1.1 200 OK
Date: Thu, 07 Aug 2014 15:47:13 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: close
Set-Cookie: __cfduid=d1983ad4f7…
Last-Modified: Fri, 27 Mar 1998 22:45:31 GMT
ETag: W/"8982977-4c9a-32a651f0ad8c0"
因为我们现在正在处理原始的 HTTP 协议交换,我们看到了 HTTP 在响应中包含的额外头部数据。这与较低级别的数据包头部具有类似的目的。HTTP 头部包含有关响应的 HTTP 特定元数据,告诉客户端如何解释它。以前,urllib为我们解析了这些数据,将数据添加为响应对象的属性,并从输出数据中删除了头部数据。为了使这个程序与我们的第一个程序一样强大,我们需要添加代码来完成这一点。
从代码中无法立即看到的是,我们还错过了urllib模块的错误检查和处理。虽然低级网络错误仍会生成异常,但我们将不再捕获urllib本应捕获的 HTTP 层的任何问题。
上述标题的第一行中的200值是 HTTP 状态码,告诉我们 HTTP 请求或响应是否存在任何问题。200 表示一切顺利,但其他代码,如臭名昭著的 404“未找到”,可能意味着出现了问题。 urllib 模块会为我们检查这些并引发异常。但在这里,我们需要自己处理这些问题。
因此,尽可能在堆栈的顶部使用模块是有明显好处的。我们的最终程序将更简单,这将使它们更快地编写,并更容易维护。这也意味着它们的错误处理将更加健壮,并且我们将受益于模块开发人员的专业知识。此外,我们还将受益于模块为捕捉意外和棘手的边缘情况问题而经历的测试。在接下来的几章中,我们将讨论更多位于堆栈顶部的模块和协议。
为 TCP/IP 网络编程
最后,我们将看一下 TCP/IP 网络中经常遇到的一些方面,这些方面可能会让以前没有遇到过它们的应用程序开发人员感到困惑。这些是:防火墙,网络地址转换以及 IPv4 和 IPv6 之间的一些差异。
防火墙
防火墙是一种硬件或软件,它检查流经它的网络数据包,并根据数据包的属性过滤它允许通过的内容。它是一种安全机制,用于防止不需要的流量从网络的一部分移动到另一部分。防火墙可以位于网络边界,也可以作为网络客户端和服务器上的应用程序运行。例如,iptables 是 Linux 的事实防火墙软件。您经常会在桌面防病毒程序中找到内置防火墙。
过滤规则可以基于网络流量的任何属性。常用的属性包括:传输层协议(即流量是否使用 TCP 或 UDP)、源和目标 IP 地址以及源和目标端口号。
常见的过滤策略是拒绝所有入站流量,并仅允许符合非常特定参数的流量。例如,一家公司可能有一个希望允许从互联网访问的 Web 服务器,但希望阻止来自互联网的所有流量,这些流量指向其网络中的任何其他设备。为此,它将在其网关的正面或背面直接放置一个防火墙,然后配置它以阻止所有传入流量,除了目标 IP 地址为 Web 服务器的 TCP 流量和目标端口号为 80 的流量(因为端口 80 是 HTTP 服务的标准端口号)。
防火墙也可以阻止出站流量。这可能是为了阻止恶意软件从内部网络设备上找到家或发送垃圾邮件。
因为防火墙阻止网络流量,它们可能会对网络应用程序造成明显的问题。在通过网络测试我们的应用程序时,我们需要确保存在于我们的设备之间的防火墙被配置为允许我们应用程序的流量通过。通常,这意味着我们需要确保我们需要的端口在防火墙上对源和目标 IP 地址之间的流量是开放的。这可能需要与 IT 支持团队进行一些协商,可能需要查看我们操作系统和本地网络路由器的文档。此外,我们需要确保我们的应用程序用户知道他们需要在自己的环境中执行任何防火墙配置,以便使用我们的程序。
网络地址转换
早些时候,我们讨论了私有 IP 地址范围。虽然它们可能非常有用,但有一个小问题。源地址或目的地址在私有范围内的数据包被禁止在公共互联网上传输!因此,如果没有一些帮助,使用私有范围地址的设备无法与使用公共互联网上的地址的设备通信。然而,通过网络地址转换(NAT),我们可以解决这个问题。由于大多数家庭网络使用私有范围地址,NAT 很可能是你会遇到的东西。
尽管 NAT 可以在其他情况下使用,但它最常见的用法是由一个位于公共互联网和使用私有范围 IP 地址的网络边界的网关执行。为了使来自网关网络的数据包在网关接收到发送到互联网的网络的数据包时能够在公共互联网上路由,它会重写数据包的头,并用自己的公共范围 IP 地址替换私有范围的源 IP 地址。如果数据包包含 TCP 或 UDP 数据包,并且这些数据包包含源端口,则它还可能在其外部接口上打开一个新的用于监听的源端口,并将数据包中的源端口号重写为匹配这个新号码。
在进行这些重写时,它记录了新打开的源端口与内部网络上的源设备之间的映射。如果它接收到对新源端口的回复,那么它会反转转换过程,并将接收到的数据包发送到内部网络上的原始设备。发起网络设备不应该意识到其流量正在经历 NAT。
使用 NAT 有几个好处。内部网络设备免受来自互联网的恶意流量的侵害,使用 NAT 设备的设备由于其私有地址被隐藏而获得了一层隐私,需要分配宝贵的公共 IP 地址的网络设备数量减少。实际上,正是 NAT 的大量使用使得互联网在耗尽 IPv4 地址的情况下仍然能够继续运行。
如果在设计时没有考虑 NAT,NAT 可能会对网络应用程序造成一些问题。
如果传输的应用程序数据包含有关设备网络配置的信息,并且该设备位于 NAT 路由器后面,那么如果接收设备假定应用程序数据与 IP 和 TCP/UDP 头数据匹配,就可能会出现问题。NAT 路由器将重写 IP 和 TCP/UDP 头数据,但不会重写应用程序数据。这是 FTP 协议中一个众所周知的问题。
FTP 与 NAT 的另一个问题是,在 FTP 主动模式中,协议操作的一部分涉及客户端打开一个用于监听的端口,服务器创建一个新的 TCP 连接到该端口(而不仅仅是一个常规的回复)。当客户端位于 NAT 路由器后面时,这将失败,因为路由器不知道如何处理服务器的连接尝试。因此,要小心假设服务器可以创建新的连接到客户端,因为它们可能会被 NAT 路由器或防火墙阻止。一般来说,最好根据这样的假设进行编程,即服务器无法与客户端建立新连接。
IPv6
我们提到早期的讨论是基于 IPv4 的,但有一个名为 IPv6 的新版本。IPv6 最终被设计来取代 IPv4,但这个过程可能要等一段时间才能完成。
由于大多数 Python 标准库模块现在已经更新以支持 IPv6 并接受 IPv6 地址,因此在 Python 中转移到 IPv6 对我们的应用程序不应该有太大影响。然而,还是有一些小问题需要注意。
您将在 IPv6 中注意到的主要区别是地址格式已更改。新协议的主要设计目标之一是缓解 IPv4 地址的全球短缺,并防止再次发生,因此 IETF 将地址长度增加了四倍,达到 128 位,从而创建了足够大的地址空间,以便为地球上的每个人提供比整个 IPv4 地址空间中的地址多十亿倍的地址。
新格式的 IP 地址写法不同,看起来像这样:
2001:0db8:85a3:0000:0000:b81a:63d6:135b
注意使用冒号和十六进制格式。
还有一些规则可以以更
2001:db8:85a3::b81a:63d6:135b
如果程序需要比较或解析文本格式的 IPv6 地址,那么它将需要了解这些压缩规则,因为单个 IPv6 地址可以以多种方式表示。这些规则的详细信息可以在 RFC 4291 中找到,可在www.ietf.org/rfc/rfc4291.txt上找到。
由于冒号可能在 URI 中使用时会引起冲突,因此在以这种方式使用时,IPv6 地址需要用方括号括起来,例如:
http://[2001:db8:85a3::b81a:63d6:135b]/index.html
此外,在 IPv6 中,网络接口现在标准做法是分配多个 IP 地址。IPv6 地址根据其有效范围进行分类。范围包括全局范围(即公共互联网)和链路本地范围,仅对本地子网有效。可以通过检查其高阶位来确定 IP 地址的范围。如果我们枚举用于特定目的的本地接口的 IP 地址,那么我们需要检查我们是否使用了正确的地址来处理我们打算使用的范围。RFC 4291 中有更多细节。
最后,随着 IPv6 中可用的地址数量之多,每个设备(和组件,和细菌)都可以被分配一个全球唯一的公共 IP 地址,NAT 将成为过去。尽管在理论上听起来很棒,但一些人对这对用户隐私等问题的影响提出了一些担忧。因此,为缓解这些担忧而设计的附加功能已添加到协议中(www.ietf.org/rfc/rfc3041.txt)。这是一个受欢迎的进展;然而,它可能会对一些应用程序造成问题。因此,如果您计划使用 IPv6 来使用您的程序,阅读 RFC 是值得的。
总结
在本章的第一部分,我们看了一下使用 TCP/IP 进行网络的基本知识。我们讨论了网络堆栈的概念,并研究了互联网协议套件的主要协议。我们看到了 IP 如何解决在不同网络上的设备之间发送消息的问题,以及 TCP 和 UDP 如何为应用程序提供端到端的传输。
在第二部分中,我们看了一下在使用 Python 时通常如何处理网络编程。我们讨论了使用模块的一般原则,这些模块尽可能地与网络堆栈上层的服务进行接口。我们还讨论了在哪里可以找到这些模块。我们看了一些使用与网络堆栈在不同层进行接口的模块来完成简单网络任务的示例。
最后,我们讨论了为 TCP/IP 网络编程的一些常见陷阱以及可以采取的一些措施来避免它们。
这一章在网络理论方面非常重要。但是,现在是时候开始使用 Python 并让一些应用层协议为我们工作了。
第二章:HTTP 和网络应用
超文本传输协议(HTTP)可能是最广泛使用的应用层协议。最初开发是为了让学者分享 HTML 文档。如今,它被用作互联网上无数应用程序的核心协议,并且是万维网的主要协议。
在本章中,我们将涵盖以下主题:
-
HTTP 协议结构
-
使用 Python 通过 HTTP 与服务通信
-
下载文件
-
HTTP 功能,如压缩和 cookies
-
处理错误
-
URL
-
Python 标准库
urllib包 -
Kenneth Reitz 的第三方
Requests包
urllib包是 Python 标准库中用于 HTTP 任务的推荐包。标准库还有一个名为http的低级模块。虽然这提供了对协议几乎所有方面的访问,但它并不是为日常使用而设计的。urllib包有一个更简单的接口,并且处理了我们将在本章中涵盖的所有内容。
第三方Requests包是urllib的一个非常受欢迎的替代品。它具有优雅的界面和强大的功能集,是简化 HTTP 工作流的绝佳工具。我们将在本章末讨论它如何替代urllib使用。
请求和响应
HTTP 是一个应用层协议,几乎总是在 TCP 之上使用。HTTP 协议被故意定义为使用人类可读的消息格式,但仍然可以用于传输任意字节数据。
一个 HTTP 交换包括两个元素。客户端发出的请求,请求服务器提供由 URL 指定的特定资源,以及服务器发送的响应,提供客户端请求的资源。如果服务器无法提供客户端请求的资源,那么响应将包含有关失败的信息。
这个事件顺序在 HTTP 中是固定的。所有交互都是由客户端发起的。服务器不会在没有客户端明确要求的情况下向客户端发送任何内容。
这一章将教你如何将 Python 用作 HTTP 客户端。我们将学习如何向服务器发出请求,然后解释它们的响应。我们将在第九章中讨论编写服务器端应用程序,网络应用。
到目前为止,最广泛使用的 HTTP 版本是 1.1,定义在 RFC 7230 到 7235 中。HTTP 2 是最新版本,正式批准时本书即将出版。版本 1.1 和 2 之间的语义和语法大部分保持不变,主要变化在于 TCP 连接的利用方式。目前,HTTP 2 的支持并不广泛,因此本书将专注于版本 1.1。如果你想了解更多,HTTP 2 在 RFC 7540 和 7541 中有记录。
HTTP 版本 1.0,记录在 RFC 1945 中,仍然被一些较老的软件使用。版本 1.1 与 1.0 向后兼容,urllib包和Requests都支持 HTTP 1.1,所以当我们用 Python 编写客户端时,不需要担心连接到 HTTP 1.0 服务器。只是一些更高级的功能不可用。几乎所有现在的服务都使用版本 1.1,所以我们不会在这里讨论差异。如果需要更多信息,可以参考堆栈溢出的问题:stackoverflow.com/questions/246859/http-1-0-vs-1-1。
使用 urllib 进行请求
在讨论 RFC 下载器时,我们已经看到了一些 HTTP 交换的例子,第一章网络编程和 Python。urllib包被分成几个子模块,用于处理我们在使用 HTTP 时可能需要执行的不同任务。为了发出请求和接收响应,我们使用urllib.request模块。
使用urllib从 URL 检索内容是一个简单的过程。打开你的 Python 解释器,然后执行以下操作:
**>>> from urllib.request import urlopen**
**>>> response = urlopen('http://www.debian.org')**
**>>> response**
**<http.client.HTTPResponse object at 0x7fa3c53059b0>**
**>>> response.readline()**
**b'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">\n'**
我们使用urllib.request.urlopen()函数发送请求并接收www.debian.org上资源的响应,这里是一个 HTML 页面。然后我们将打印出我们收到的 HTML 的第一行。
响应对象
让我们更仔细地看一下我们的响应对象。从前面的例子中我们可以看到,urlopen()返回一个http.client.HTTPResponse实例。响应对象使我们能够访问请求资源的数据,以及响应的属性和元数据。要查看我们在上一节中收到的响应的 URL,可以这样做:
**>>> response.url**
**'http://www.debian.org'**
我们通过类似文件的接口使用readline()和read()方法获取请求资源的数据。我们在前一节看到了readline()方法。这是我们使用read()方法的方式:
**>>> response = urlopen('http://www.debian.org')**
**>>> response.read(50)**
**b'g="en">\n<head>\n <meta http-equiv="Content-Type" c'**
read()方法从数据中返回指定数量的字节。这里是前 50 个字节。调用read()方法而不带参数将一次性返回所有数据。
类似文件的接口是有限的。一旦数据被读取,就无法使用上述函数之一返回并重新读取它。为了证明这一点,请尝试执行以下操作:
**>>> response = urlopen('http://www.debian.org')**
**>>> response.read()**
**b'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">\n<html lang="en">\n<head>\n <meta http-equiv**
**...**
**>>> response.read()**
**b''**
我们可以看到,当我们第二次调用read()函数时,它返回一个空字符串。没有seek()或rewind()方法,所以我们无法重置位置。因此,最好将read()输出捕获在一个变量中。
readline()和read()函数都返回字节对象,http和urllib都不会对接收到的数据进行解码为 Unicode。在本章的后面,我们将看到如何利用Requests库来处理这个问题。
状态码
如果我们想知道我们的请求是否发生了意外情况怎么办?或者如果我们想知道我们的响应在读取数据之前是否包含任何数据怎么办?也许我们期望得到一个大的响应,我们想快速查看我们的请求是否成功而不必读取整个响应。
HTTP 响应通过状态码为我们提供了这样的方式。我们可以通过使用其status属性来读取响应的状态码。
**>>> response.status**
**200**
状态码是告诉我们请求的情况的整数。200代码告诉我们一切都很好。
有许多代码,每个代码传达不同的含义。根据它们的第一个数字,状态码被分为以下几组:
-
100:信息
-
200:成功
-
300:重定向
-
400:客户端错误
-
500:服务器错误
一些常见的代码及其消息如下:
-
200:OK -
404:未找到 -
500:内部服务器错误
状态码的官方列表由 IANA 维护,可以在www.iana.org/assignments/http-status-codes找到。我们将在本章中看到各种代码。
处理问题
状态码帮助我们查看响应是否成功。200 范围内的任何代码表示成功,而 400 范围或 500 范围内的代码表示失败。
应该始终检查状态码,以便我们的程序在出现问题时能够做出适当的响应。urllib包通过在遇到问题时引发异常来帮助我们检查状态码。
让我们看看如何捕获这些异常并有用地处理它们。为此,请尝试以下命令块:
**>>> import urllib.error**
**>>> from urllib.request import urlopen**
**>>> try:**
**... urlopen('http://www.ietf.org/rfc/rfc0.txt')**
**... except urllib.error.HTTPError as e:**
**... print('status', e.code)**
**... print('reason', e.reason)**
**... print('url', e.url)**
**...**
**status: 404**
**reason: Not Found**
**url: http://www.ietf.org/rfc/rfc0.txt**
在这里,我们请求了不存在的 RFC 0。因此服务器返回了 404 状态代码,urllib已经发现并引发了HTTPError。
您可以看到HTTPError提供了有关请求的有用属性。在前面的示例中,我们使用了status、reason和url属性来获取有关响应的一些信息。
如果网络堆栈中出现问题,那么适当的模块将引发异常。urllib包捕获这些异常,然后将它们包装为URLErrors。例如,我们可能已经指定了一个不存在的主机或 IP 地址,如下所示:
**>>> urlopen('http://192.0.2.1/index.html')**
**...**
**urllib.error.URLError: <urlopen error [Errno 110] Connection timed out>**
在这种情况下,我们已经从192.0.2.1主机请求了index.html。192.0.2.0/24 IP 地址范围被保留供文档使用,因此您永远不会遇到使用前述 IP 地址的主机。因此 TCP 连接超时,socket引发超时异常,urllib捕获,重新包装并为我们重新引发。我们可以像在前面的例子中一样捕获这些异常。
HTTP 头部
请求和响应由两个主要部分组成,头部和正文。当我们在第一章中使用 TCP RFC 下载器时,我们简要地看到了一些 HTTP 头部,网络编程和 Python。头部是出现在通过 TCP 连接发送的原始消息开头的协议特定信息行。正文是消息的其余部分。它与头部之间由一个空行分隔。正文是可选的,其存在取决于请求或响应的类型。以下是一个 HTTP 请求的示例:
GET / HTTP/1.1
Accept-Encoding: identity
Host: www.debian.com
Connection: close
User-Agent: Python-urllib/3.4
第一行称为请求行。它由请求方法组成,在这种情况下是GET,资源的路径,在这里是/,以及 HTTP 版本1.1。其余行是请求头。每行由一个头部名称后跟一个冒号和一个头部值组成。前述输出中的请求只包含头部,没有正文。
头部用于几个目的。在请求中,它们可以用于传递额外的数据,如 cookies 和授权凭据,并询问服务器首选资源格式。
例如,一个重要的头部是Host头部。许多 Web 服务器应用程序提供了在同一台服务器上使用相同的 IP 地址托管多个网站的能力。为各个网站域名设置了 DNS 别名,因此它们都指向同一个 IP 地址。实际上,Web 服务器为每个托管的网站提供了多个主机名。IP 和 TCP(HTTP 运行在其上)不能用于告诉服务器客户端想要连接到哪个主机名,因为它们都仅仅在 IP 地址上操作。HTTP 协议允许客户端在 HTTP 请求中提供主机名,包括Host头部。
我们将在下一节中查看一些更多的请求头部。
以下是响应的一个示例:
HTTP/1.1 200 OK
Date: Sun, 07 Sep 2014 19:58:48 GMT
Content-Type: text/html
Content-Length: 4729
Server: Apache
Content-Language: en
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">\n...
第一行包含协议版本、状态代码和状态消息。随后的行包含头部、一个空行,然后是正文。在响应中,服务器可以使用头部通知客户端有关正文长度、响应正文包含的内容类型以及客户端应存储的 cookie 数据等信息。
要查看响应对象的头部,请执行以下操作:
**>>> response = urlopen('http://www.debian.org)**
**>>> response.getheaders()**
**[('Date', 'Sun, 07 Sep 2014 19:58:48 GMT'), ('Server', 'Apache'), ('Content-Location', 'index.en.html'), ('Vary', 'negotiate,accept- language,Accept-Encoding')...**
getheaders()方法以元组列表的形式返回头部(头部名称,头部值)。HTTP 1.1 头部及其含义的完整列表可以在 RFC 7231 中找到。让我们看看如何在请求和响应中使用一些头部。
自定义请求
利用标头提供的功能,我们在发送请求之前向请求添加标头。为了做到这一点,我们不能只是使用urlopen()。我们需要按照以下步骤进行:
-
创建一个
Request对象 -
向请求对象添加标头
-
使用
urlopen()发送请求对象
我们将学习如何自定义一个请求,以检索 Debian 主页的瑞典版本。我们将使用Accept-Language标头,告诉服务器我们对其返回的资源的首选语言。请注意,并非所有服务器都保存多种语言版本的资源,因此并非所有服务器都会响应Accept-Language。
首先,我们创建一个Request对象:
**>>> from urllib.request import Request**
**>>> req = Request('http://www.debian.org')**
接下来,添加标头:
**>>> req.add_header('Accept-Language', 'sv')**
add_header()方法接受标头的名称和标头的内容作为参数。Accept-Language标头采用两字母的 ISO 639-1 语言代码。瑞典语的代码是sv。
最后,我们使用urlopen()提交定制的请求:
**>>> response = urlopen(req)**
我们可以通过打印前几行来检查响应是否是瑞典语:
**>>> response.readlines()[:5]**
**[b'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">\n',**
**b'<html lang="sv">\n',**
**b'<head>\n',**
**b' <meta http-equiv="Content-Type" content="text/html; charset=utf- 8">\n',**
**b' <title>Debian -- Det universella operativsystemet </title>\n']**
Jetta bra!Accept-Language标头已经告知服务器我们对响应内容的首选语言。
要查看请求中存在的标头,请执行以下操作:
**>>> req = Request('http://www.debian.org')**
**>>> req.add_header('Accept-Language', 'sv')**
**>>> req.header_items()**
**[('Accept-language', 'sv')]**
当我们在请求上运行urlopen()时,urlopen()方法会添加一些自己的标头:
**>>> response = urlopen(req)**
**>>> req.header_items()**
**[('Accept-language', 'sv'), ('User-agent': 'Python-urllib/3.4'), ('Host': 'www.debian.org')]**
添加标头的一种快捷方式是在创建请求对象的同时添加它们,如下所示:
**>>> headers = {'Accept-Language': 'sv'}**
**>>> req = Request('http://www.debian.org', headers=headers)**
**>>> req.header_items()**
**[('Accept-language', 'sv')]**
我们将标头作为dict提供给Request对象构造函数,作为headers关键字参数。通过这种方式,我们可以一次性添加多个标头,通过向dict添加更多条目。
让我们看看我们可以用标头做些什么其他事情。
内容压缩
Accept-Encoding请求标头和Content-Encoding响应标头可以一起工作,允许我们临时对响应主体进行编码,以便通过网络传输。这通常用于压缩响应并减少需要传输的数据量。
这个过程遵循以下步骤:
-
客户端发送一个请求,其中在
Accept-Encoding标头中列出了可接受的编码 -
服务器选择其支持的编码方法
-
服务器使用这种编码方法对主体进行编码
-
服务器发送响应,指定其在
Content-Encoding标头中使用的编码 -
客户端使用指定的编码方法解码响应主体
让我们讨论如何请求一个文档,并让服务器对响应主体使用gzip压缩。首先,让我们构造请求:
**>>> req = Request('http://www.debian.org')**
接下来,添加Accept-Encoding标头:
**>>> req.add_header('Accept-Encoding', 'gzip')**
然后,借助urlopen()提交请求:
**>>> response = urlopen(req)**
我们可以通过查看响应的Content-Encoding标头来检查服务器是否使用了gzip压缩:
**>>> response.getheader('Content-Encoding')**
**'gzip'**
然后,我们可以使用gzip模块对主体数据进行解压:
**>>> import gzip**
**>>> content = gzip.decompress(response.read())**
**>>> content.splitlines()[:5]**
**[b'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">',**
**b'<html lang="en">',**
**b'<head>',**
**b' <meta http-equiv="Content-Type" content="text/html; charset=utf-8">',**
**b' <title>Debian -- The Universal Operating System </title>']**
编码已在 IANA 注册。当前列表包括:gzip、compress、deflate和identity。前三个是指特定的压缩方法。最后一个允许客户端指定不希望对内容应用任何编码。
让我们看看如果我们使用identity编码来请求不进行压缩会发生什么:
**>>> req = Request('http://www.debian.org')**
**>>> req.add_header('Accept-Encoding', 'identity')**
**>>> response = urlopen(req)**
**>>> print(response.getheader('Content-Encoding'))**
**None**
当服务器使用identity编码类型时,响应中不包括Content-Encoding标头。
多个值
为了告诉服务器我们可以接受多种编码,我们可以在Accept-Encoding标头中添加更多值,并用逗号分隔它们。让我们试试。我们创建我们的Request对象:
**>>> req = Request('http://www.debian.org')**
然后,我们添加我们的标头,这次我们包括更多的编码:
**>>> encodings = 'deflate, gzip, identity'**
**>>> req.add_header('Accept-Encoding', encodings)**
现在,我们提交请求,然后检查响应的编码:
**>>> response = urlopen(req)**
**>>> response.getheader('Content-Encoding')**
**'gzip'**
如果需要,可以通过添加q值来给特定编码分配相对权重:
**>>> encodings = 'gzip, deflate;q=0.8, identity;q=0.0'**
q值跟随编码名称,并且由分号分隔。最大的q值是1.0,如果没有给出q值,则默认为1.0。因此,前面的行应该被解释为我的首选编码是gzip,我的第二个首选是deflate,如果没有其他可用的编码,则我的第三个首选是identity。
内容协商
使用Accept-Encoding标头进行内容压缩,使用Accept-Language标头进行语言选择是内容协商的例子,其中客户端指定其关于所请求资源的格式和内容的首选项。以下标头也可以用于此目的:
-
Accept:请求首选文件格式 -
Accept-Charset:请求以首选字符集获取资源
内容协商机制还有其他方面,但由于支持不一致并且可能变得相当复杂,我们不会在本章中进行介绍。RFC 7231 包含您需要的所有详细信息。如果您发现您的应用程序需要此功能,请查看 3.4、5.3、6.4.1 和 6.5.6 等部分。
内容类型
HTTP 可以用作任何类型文件或数据的传输。服务器可以在响应中使用Content-Type头来通知客户端有关它在主体中发送的数据类型。这是 HTTP 客户端确定如何处理服务器返回的主体数据的主要手段。
要查看内容类型,我们检查响应标头的值,如下所示:
**>>> response = urlopen('http://www.debian.org')**
**>>> response.getheader('Content-Type')**
**'text/html'**
此标头中的值取自由 IANA 维护的列表。这些值通常称为内容类型、互联网媒体类型或MIME 类型(MIME代表多用途互联网邮件扩展,在该规范中首次建立了这种约定)。完整列表可以在www.iana.org/assignments/media-types找到。
对于通过互联网传输的许多数据类型都有注册的媒体类型,一些常见的类型包括:
| 媒体类型 | 描述 |
|---|---|
| text/html | HTML 文档 |
| text/plain | 纯文本文档 |
| image/jpeg | JPG 图像 |
| application/pdf | PDF 文档 |
| application/json | JSON 数据 |
| application/xhtml+xml | XHTML 文档 |
另一个感兴趣的媒体类型是application/octet-stream,在实践中用于没有适用的媒体类型的文件。这种情况的一个例子是一个经过 pickle 处理的 Python 对象。它还用于服务器不知道格式的文件。为了正确处理具有此媒体类型的响应,我们需要以其他方式发现格式。可能的方法如下:
-
检查已下载资源的文件名扩展名(如果有)。然后可以使用
mimetypes模块来确定媒体类型(转到第三章,APIs in Action,以查看此示例)。 -
下载数据,然后使用文件类型分析工具。对于图像,可以使用 Python 标准库的
imghdr模块,对于其他类型,可以使用第三方的python-magic包或GNU文件命令。 -
检查我们正在下载的网站,看看文件类型是否已经在任何地方有文档记录。
内容类型值可以包含可选的附加参数,提供有关类型的进一步信息。这通常用于提供数据使用的字符集。例如:
Content-Type: text/html; charset=UTF-8.
在这种情况下,我们被告知文档的字符集是 UTF-8。参数在分号后面包括,并且它总是采用键/值对的形式。
让我们讨论一个例子,下载 Python 主页并使用它返回的Content-Type值。首先,我们提交我们的请求:
**>>> response = urlopen('http://www.python.org')**
然后,我们检查响应的Content-Type值,并提取字符集:
**>>> format, params = response.getheader('Content-Type').split(';')**
**>>> params**
**' charset=utf-8'**
**>>> charset = params.split('=')[1]**
**>>> charset**
**'utf-8'**
最后,我们通过使用提供的字符集来解码我们的响应内容:
**>>> content = response.read().decode(charset)**
请注意,服务器通常要么在Content-Type头中不提供charset,要么提供错误的charset。因此,这个值应该被视为一个建议。这是我们稍后在本章中查看Requests库的原因之一。它将自动收集关于解码响应主体应该使用的字符集的所有提示,并为我们做出最佳猜测。
用户代理
另一个值得了解的请求头是User-Agent头。使用 HTTP 通信的任何客户端都可以称为用户代理。RFC 7231 建议用户代理应该在每个请求中使用User-Agent头来标识自己。放在那里的内容取决于发出请求的软件,尽管通常包括一个标识程序和版本的字符串,可能还包括操作系统和运行的硬件。例如,我当前版本的 Firefox 的用户代理如下所示:
Mozilla/5.0 (X11; Linux x86_64; rv:24.0) Gecko/20140722 Firefox/24.0 Iceweasel/24.7.0
尽管这里被分成了两行,但它是一个单独的长字符串。正如你可能能够解释的那样,我正在运行 Iceweasel(Debian 版的 Firefox)24 版本,运行在 64 位 Linux 系统上。用户代理字符串并不是用来识别个别用户的。它们只标识用于发出请求的产品。
我们可以查看urllib使用的用户代理。执行以下步骤:
**>>> req = Request('http://www.python.org')**
**>>> urlopen(req)**
**>>> req.get_header('User-agent')**
**'Python-urllib/3.4'**
在这里,我们创建了一个请求并使用urlopen提交了它,urlopen添加了用户代理头到请求中。我们可以使用get_header()方法来检查这个头。这个头和它的值包含在urllib发出的每个请求中,所以我们向每个服务器发出请求时都可以看到我们正在使用 Python 3.4 和urllib库。
网站管理员可以检查请求的用户代理,然后将这些信息用于各种用途,包括以下内容:
-
为了他们的网站统计分类访问
-
阻止具有特定用户代理字符串的客户端
-
发送给已知问题的用户代理的资源的替代版本,比如在解释某些语言(如 CSS)时出现的错误,或者根本不支持某些语言(比如 JavaScript)。
最后两个可能会给我们带来问题,因为它们可能会阻止或干扰我们访问我们想要的内容。为了解决这个问题,我们可以尝试设置我们的用户代理,使其模拟一个知名的浏览器。这就是所谓的欺骗,如下所示:
**>>> req = Request('http://www.debian.org')**
**>>> req.add_header('User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:24.0) Gecko/20140722 Firefox/24.0 Iceweasel/24.7.0')**
**>>> response = urlopen(req)**
服务器将会响应,就好像我们的应用程序是一个普通的 Firefox 客户端。不同浏览器的用户代理字符串可以在网上找到。我还没有找到一个全面的资源,但是通过谷歌搜索浏览器和版本号通常会找到一些信息。或者你可以使用 Wireshark 来捕获浏览器发出的 HTTP 请求,并查看捕获的请求的用户代理头。
Cookies
Cookie 是服务器在响应的一部分中以Set-Cookie头发送的一小段数据。客户端会将 cookie 存储在本地,并在以后发送到服务器的任何请求中包含它们。
服务器以各种方式使用 cookie。它们可以向其中添加一个唯一的 ID,这使它们能够跟踪客户端访问站点的不同区域。它们可以存储一个登录令牌,这将自动登录客户端,即使客户端离开站点然后以后再次访问。它们也可以用于存储客户端的用户偏好或个性化信息的片段,等等。
Cookie 是必需的,因为服务器没有其他方式在请求之间跟踪客户端。HTTP 被称为无状态协议。它不包含一个明确的机制,让服务器确切地知道两个请求是否来自同一个客户端。如果没有 cookie 允许服务器向请求添加一些唯一标识信息,像购物车(这是 cookie 开发的最初问题)这样的东西将变得不可能构建,因为服务器将无法确定哪个篮子对应哪个请求。
我们可能需要在 Python 中处理 cookie,因为没有它们,一些网站的行为不如预期。在使用 Python 时,我们可能还想访问需要登录的站点的部分,登录会话通常通过 cookie 来维护。
Cookie 处理
我们将讨论如何使用urllib处理 cookie。首先,我们需要创建一个存储服务器将发送给我们的 cookie 的地方:
**>>> from http.cookiejar import CookieJar**
**>>> cookie_jar = CookieJar()**
接下来,我们构建一个名为urllib opener **的东西。这将自动从我们收到的响应中提取 cookie,然后将它们存储在我们的 cookie jar 中:
**>>> from urllib.request import build_opener, HTTPCookieProcessor**
**>>> opener = build_opener(HTTPCookieProcessor(cookie_jar))**
然后,我们可以使用我们的 opener 来发出 HTTP 请求:
**>>> opener.open('http://www.github.com')**
最后,我们可以检查服务器是否发送了一些 cookie:
**>>> len(cookie_jar)**
**2**
每当我们使用opener发出进一步的请求时,HTTPCookieProcessor功能将检查我们的cookie_jar,看它是否包含该站点的任何 cookie,然后自动将它们添加到我们的请求中。它还将接收到的任何进一步的 cookie 添加到 cookie jar 中。
http.cookiejar模块还包含一个FileCookieJar类,它的工作方式与CookieJar相同,但它提供了一个额外的函数,用于轻松地将 cookie 保存到文件中。这允许在 Python 会话之间持久保存 cookie。
了解您的 cookie
值得更详细地查看 cookie 的属性。让我们来检查 GitHub 在前一节中发送给我们的 cookie。
为此,我们需要从 cookie jar 中取出 cookie。CookieJar模块不允许我们直接访问它们,但它支持迭代器协议。因此,一个快速获取它们的方法是从中创建一个list:
**>>> cookies = list(cookie_jar)**
**>>> cookies**
**[Cookie(version=0, name='logged_in', value='no', ...),**
**Cookie(version=0, name='_gh_sess', value='eyJzZxNzaW9uX...', ...)**
**]**
您可以看到我们有两个Cookie对象。现在,让我们从第一个对象中提取一些信息:
**>>> cookies[0].name**
**'logged_in'**
**>>> cookies[0].value**
**'no'**
cookie 的名称允许服务器快速引用它。这个 cookie 显然是 GitHub 用来查明我们是否已经登录的机制的一部分。接下来,让我们做以下事情:
**>>> cookies[0].domain**
**'.github.com'**
**>>> cookies[0].path**
**'/'**
域和路径是此 cookie 有效的区域,因此我们的urllib opener 将在发送到www.github.com及其子域的任何请求中包含此 cookie,其中路径位于根目录下方的任何位置。
现在,让我们来看一下 cookie 的生命周期:
**>>> cookies[0].expires**
**2060882017**
这是一个 Unix 时间戳;我们可以将其转换为datetime:
**>>> import datetime**
**>>> datetime.datetime.fromtimestamp(cookies[0].expires)**
**datetime.datetime(2035, 4, 22, 20, 13, 37)**
因此,我们的 cookie 将在 2035 年 4 月 22 日到期。到期日期是服务器希望客户端保留 cookie 的时间。一旦到期日期过去,客户端可以丢弃 cookie,并且服务器将在下一个请求中发送一个新的 cookie。当然,没有什么能阻止客户端立即丢弃 cookie,尽管在一些站点上,这可能会破坏依赖 cookie 的功能。
让我们讨论两个常见的 cookie 标志:
**>>> print(cookies[0].get_nonstandard_attr('HttpOnly'))**
**None**
存储在客户端上的 cookie 可以通过多种方式访问:
-
由客户端作为 HTTP 请求和响应序列的一部分
-
由客户端中运行的脚本,比如 JavaScript
-
由客户端中运行的其他进程,比如 Flash
HttpOnly标志表示客户端只有在 HTTP 请求或响应的一部分时才允许访问 cookie。其他方法应该被拒绝访问。这将保护客户端免受跨站脚本攻击的影响(有关这些攻击的更多信息,请参见第九章Web 应用程序)。这是一个重要的安全功能,当服务器设置它时,我们的应用程序应该相应地行事。
还有一个secure标志:
**>>> cookies[0].secure**
**True**
如果值为 true,则Secure标志表示 cookie 只能通过安全连接发送,例如 HTTPS。同样,如果已设置该标志,我们应该遵守这一点,这样当我们的应用程序发送包含此 cookie 的请求时,它只会将它们发送到 HTTPS URL。
您可能已经发现了一个不一致之处。我们的 URL 已经请求了一个 HTTP 响应,然而服务器却发送了一个 cookie 给我们,要求它只能在安全连接上发送。网站设计者肯定没有忽视这样的安全漏洞吧?请放心,他们没有。实际上,响应是通过 HTTPS 发送的。但是,这是如何发生的呢?答案就在于重定向。
重定向
有时服务器会移动它们的内容。它们还会使一些内容过时,并在不同的位置放上新的东西。有时他们希望我们使用更安全的 HTTPS 协议而不是 HTTP。在所有这些情况下,他们可能会得到请求旧 URL 的流量,并且在所有这些情况下,他们可能更愿意能够自动将访问者发送到新的 URL。
HTTP 状态码的 300 系列是为此目的而设计的。这些代码指示客户端需要采取进一步的行动才能完成请求。最常见的操作是在不同的 URL 上重试请求。这被称为重定向。
我们将学习在使用urllib时如何工作。让我们发出一个请求:
**>>> req = Request('http://www.gmail.com')**
**>>> response = urlopen(req)**
很简单,但现在,看一下响应的 URL:
**>>> response.url**
**'https://accounts.google.com/ServiceLogin?service=mail&passive=true&r m=false...'**
这不是我们请求的 URL!如果我们在浏览器中打开这个新的 URL,我们会发现这实际上是 Google 的登录页面(如果您已经有缓存的 Google 登录会话,则可能需要清除浏览器的 cookie 才能看到这一点)。Google 将我们从www.gmail.com重定向到其登录页面,urllib自动跟随了重定向。此外,我们可能已经被重定向了多次。看一下我们请求对象的redirect_dict属性:
**>>> req.redirect_dict**
**{'https://accounts.google.com/ServiceLogin?service=...': 1, 'https://mail.google.com/mail/': 1}**
urllib包将我们通过的每个 URL 添加到这个dict中。我们可以看到我们实际上被重定向了两次,首先是到mail.google.com,然后是到登录页面。
当我们发送第一个请求时,服务器会发送一个带有重定向状态代码的响应,其中之一是 301、302、303 或 307。所有这些都表示重定向。此响应包括一个Location头,其中包含新的 URL。urllib包将向该 URL 提交一个新的请求,在上述情况下,它将收到另一个重定向,这将导致它到达 Google 登录页面。
由于urllib为我们跟随重定向,它们通常不会影响我们,但值得知道的是,urllib返回的响应可能是与我们请求的 URL 不同的 URL。此外,如果我们对单个请求进行了太多次重定向(对于urllib超过 10 次),那么urllib将放弃并引发urllib.error.HTTPError异常。
URL
统一资源定位符,或者URL是 Web 操作的基础,它们已经在 RFC 3986 中正式描述。URL 代表主机上的资源。URL 如何映射到远程系统上的资源完全取决于系统管理员的决定。URL 可以指向服务器上的文件,或者在收到请求时资源可能是动态生成的。只要我们请求时 URL 有效,URL 映射到什么并不重要。
URL 由几个部分组成。Python 使用urllib.parse模块来处理 URL。让我们使用 Python 将 URL 分解为其组成部分:
**>>> from urllib.parse import urlparse**
**>>> result = urlparse('http://www.python.org/dev/peps')**
**>>> result**
**ParseResult(scheme='http', netloc='www.python.org', path='/dev/peps', params='', query='', fragment='')**
urllib.parse.urlparse()函数解释了我们的 URL,并识别http作为方案,www.python.org/作为网络位置,/dev/peps作为路径。我们可以将这些组件作为ParseResult的属性来访问:
**>>> result.netloc**
**'www.python.org'**
**>>> result.path**
**'/dev/peps'**
对于网上几乎所有的资源,我们将使用http或https方案。在这些方案中,要定位特定的资源,我们需要知道它所在的主机和我们应该连接到的 TCP 端口(这些组合在一起是netloc组件),我们还需要知道主机上资源的路径(path组件)。
可以通过将端口号附加到主机后来在 URL 中明确指定端口号。它们与主机之间用冒号分隔。让我们看看当我们尝试使用urlparse时会发生什么。
**>>> urlparse('http://www.python.org:8080/')**
**ParseResult(scheme='http', netloc='www.python.org:8080', path='/', params='', query='', fragment='')**
urlparse方法只是将其解释为 netloc 的一部分。这没问题,因为这是urllib.request.urlopen()等处理程序期望它格式化的方式。
如果我们不提供端口(通常情况下),那么http将使用默认端口 80,https将使用默认端口 443。这通常是我们想要的,因为这些是 HTTP 和 HTTPS 协议的标准端口。
路径和相对 URL
URL 中的路径是指主机和端口之后的任何内容。路径总是以斜杠(/)开头,当只有一个斜杠时,它被称为根。我们可以通过以下操作来验证这一点:
**>>> urlparse('http://www.python.org/')**
**ParseResult(scheme='http', netloc='www.python.org', path='/', params='', query='', fragment='')**
如果请求中没有提供路径,默认情况下urllib将发送一个请求以获取根目录。
当 URL 中包含方案和主机时(如前面的例子),该 URL 被称为绝对 URL。相反,也可能有相对 URL,它只包含路径组件,如下所示:
**>>> urlparse('../images/tux.png')**
**ParseResult(scheme='', netloc='', path='../images/tux.png', params='', query='', fragment='')**
我们可以看到ParseResult只包含一个path。如果我们想要使用相对 URL 请求资源,那么我们需要提供缺失的方案、主机和基本路径。
通常,我们在已从 URL 检索到的资源中遇到相对 URL。因此,我们可以使用该资源的 URL 来填充缺失的组件。让我们看一个例子。
假设我们已经检索到了www.debian.org的 URL,并且在网页源代码中找到了“关于”页面的相对 URL。我们发现它是intro/about的相对 URL。
我们可以通过使用原始页面的 URL 和urllib.parse.urljoin()函数来创建绝对 URL。让我们看看我们可以如何做到这一点:
**>>> from urllib.parse import urljoin**
**>>> urljoin('http://www.debian.org', 'intro/about')**
**'http://www.debian.org/intro/about'**
通过向urljoin提供基本 URL 和相对 URL,我们创建了一个新的绝对 URL。
在这里,注意urljoin是如何在主机和路径之间填充斜杠的。只有当基本 URL 没有路径时,urljoin才会为我们填充斜杠,就像前面的例子中所示的那样。让我们看看如果基本 URL 有路径会发生什么。
**>>> urljoin('http://www.debian.org/intro/', 'about')**
**'http://www.debian.org/intro/about'**
**>>> urljoin('http://www.debian.org/intro', 'about')**
**'http://www.debian.org/about'**
这将给我们带来不同的结果。请注意,如果基本 URL 以斜杠结尾,urljoin会将其附加到路径,但如果基本 URL 不以斜杠结尾,它将替换基本 URL 中的最后一个路径元素。
我们可以通过在路径前加上斜杠来强制路径替换基本 URL 的所有元素。按照以下步骤进行:
**>>> urljoin('http://www.debian.org/intro/about', '/News')**
**'http://www.debian.org/News'**
如何导航到父目录?让我们尝试标准的点语法,如下所示:
**>>> urljoin('http://www.debian.org/intro/about/', '../News')**
**'http://www.debian.org/intro/News'**
**>>> urljoin('http://www.debian.org/intro/about/', '../../News')**
**'http://www.debian.org/News'**
**>>> urljoin('http://www.debian.org/intro/about', '../News')**
**'http://www.debian.org/News'**
它按我们的预期工作。注意基本 URL 是否有尾随斜杠的区别。
最后,如果“相对”URL 实际上是绝对 URL 呢:
**>>> urljoin('http://www.debian.org/about', 'http://www.python.org')**
**'http://www.python.org'**
相对 URL 完全替换了基本 URL。这很方便,因为这意味着我们在使用urljoin时不需要担心 URL 是相对的还是绝对的。
查询字符串
RFC 3986 定义了 URL 的另一个属性。它们可以包含在路径之后以键/值对形式出现的附加参数。它们通过问号与路径分隔,如下所示:
docs.python.org/3/search.html?q=urlparse&area=default
这一系列参数称为查询字符串。多个参数由&分隔。让我们看看urlparse如何处理它:
**>>> urlparse('http://docs.python.org/3/search.html? q=urlparse&area=default')**
**ParseResult(scheme='http', netloc='docs.python.org', path='/3/search.html', params='', query='q=urlparse&area=default', fragment='')**
因此,urlparse将查询字符串识别为query组件。
查询字符串用于向我们希望检索的资源提供参数,并且通常以某种方式自定义资源。在上述示例中,我们的查询字符串告诉 Python 文档搜索页面,我们要搜索术语urlparse。
urllib.parse模块有一个函数,可以帮助我们将urlparse返回的query组件转换为更有用的内容:
**>>> from urllib.parse import parse_qs**
**>>> result = urlparse ('http://docs.python.org/3/search.html?q=urlparse&area=default')**
**>>> parse_qs(result.query)**
**{'area': ['default'], 'q': ['urlparse']}**
parse_qs() 方法读取查询字符串,然后将其转换为字典。看看字典值实际上是以列表的形式存在的?这是因为参数可以在查询字符串中出现多次。尝试使用重复参数:
**>>> result = urlparse ('http://docs.python.org/3/search.html?q=urlparse&q=urljoin')**
**>>> parse_qs(result.query)**
**{'q': ['urlparse', 'urljoin']}**
看看这两个值都已添加到列表中?由服务器决定如何解释这一点。如果我们发送这个查询字符串,那么它可能只选择一个值并使用它,同时忽略重复。您只能尝试一下,看看会发生什么。
通常,您可以通过使用 Web 浏览器通过 Web 界面提交查询并检查结果页面的 URL 来弄清楚对于给定页面需要在查询字符串中放置什么。您应该能够找到搜索文本的文本,从而推断出搜索文本的相应键。很多时候,查询字符串中的许多其他参数实际上并不需要获得基本结果。尝试仅使用搜索文本参数请求页面,然后查看发生了什么。然后,如果预期的结果没有实现,添加其他参数。
如果您向页面提交表单并且结果页面的 URL 没有查询字符串,则该页面将使用不同的方法发送表单数据。我们将在接下来的HTTP 方法部分中查看这一点,同时讨论 POST 方法。
URL 编码
URL 仅限于 ASCII 字符,并且在此集合中,许多字符是保留字符,并且需要在 URL 的不同组件中进行转义。我们通过使用称为 URL 编码的东西来对它们进行转义。它通常被称为百分比编码,因为它使用百分号作为转义字符。让我们对字符串进行 URL 编码:
**>>> from urllib.parse import quote**
**>>> quote('A duck?')**
**'A%20duck%3F'**
特殊字符' '和?已被转换为转义序列。转义序列中的数字是十六进制中的字符 ASCII 代码。
需要转义保留字符的完整规则在 RFC 3986 中给出,但是urllib为我们提供了一些帮助我们构造 URL 的方法。这意味着我们不需要记住所有这些!
我们只需要:
-
对路径进行 URL 编码
-
对查询字符串进行 URL 编码
-
使用
urllib.parse.urlunparse()函数将它们组合起来
让我们看看如何在代码中使用上述步骤。首先,我们对路径进行编码:
**>>> path = 'pypi'**
**>>> path_enc = quote(path)**
然后,我们对查询字符串进行编码:
**>>> from urllib.parse import urlencode**
**>>> query_dict = {':action': 'search', 'term': 'Are you quite sure this is a cheese shop?'}**
**>>> query_enc = urlencode(query_dict)**
**>>> query_enc**
**'%3Aaction=search&term=Are+you+quite+sure+this+is+a+cheese+shop%3F'**
最后,我们将所有内容组合成一个 URL:
**>>> from urllib.parse import urlunparse**
**>>> netloc = 'pypi.python.org'**
**>>> urlunparse(('http', netloc, path_enc, '', query_enc, ''))**
**'http://pypi.python.org/pypi?%3Aaction=search&term=Are+you+quite+sure +this+is+a+cheese+shop%3F'**
quote()函数已经设置用于特定编码路径。默认情况下,它会忽略斜杠字符并且不对其进行编码。在前面的示例中,这并不明显,尝试以下内容以查看其工作原理:
**>>> from urllib.parse import quote**
**>>> path = '/images/users/+Zoot+/'**
**>>> quote(path)**
**'/images/users/%2BZoot%2B/'**
请注意,它忽略了斜杠,但转义了+。这对路径来说是完美的。
urlencode()函数类似地用于直接从字典编码查询字符串。请注意,它如何正确地对我们的值进行百分比编码,然后使用&将它们连接起来,以构造查询字符串。
最后,urlunparse()方法期望包含与urlparse()结果匹配的元素的 6 元组,因此有两个空字符串。
对于路径编码有一个注意事项。如果路径的元素本身包含斜杠,那么我们可能会遇到问题。示例在以下命令中显示:
**>>> username = '+Zoot/Dingo+'**
**>>> path = 'images/users/{}'.format(username)**
**>>> quote(path)**
**'images/user/%2BZoot/Dingo%2B'**
注意用户名中的斜杠没有被转义吗?这将被错误地解释为额外的目录结构,这不是我们想要的。为了解决这个问题,首先我们需要单独转义可能包含斜杠的路径元素,然后手动连接它们:
**>>> username = '+Zoot/Dingo+'**
**>>> user_encoded = quote(username, safe='')**
**>>> path = '/'.join(('', 'images', 'users', username))**
**'/images/users/%2BZoot%2FDingo%2B'**
注意用户名斜杠现在是百分比编码了吗?我们单独对用户名进行编码,告诉quote不要忽略斜杠,通过提供safe=''参数来覆盖其默认的忽略列表/。然后,我们使用简单的join()函数组合路径元素。
在这里,值得一提的是,通过网络发送的主机名必须严格遵循 ASCII,但是socket和http模块支持将 Unicode 主机名透明地编码为 ASCII 兼容的编码,因此在实践中我们不需要担心编码主机名。关于这个过程的更多细节可以在codecs模块文档的encodings.idna部分找到。
URL 总结
在前面的部分中,我们使用了相当多的函数。让我们简要回顾一下我们每个函数的用途。所有这些函数都可以在urllib.parse模块中找到。它们如下:
-
将 URL 拆分为其组件:
urlparse -
将绝对 URL 与相对 URL 组合:
urljoin -
将查询字符串解析为
dict:parse_qs -
对路径进行 URL 编码:
quote -
从
dict创建 URL 编码的查询字符串:urlencode -
从组件创建 URL(
urlparse的反向):urlunparse
HTTP 方法
到目前为止,我们一直在使用请求来请求服务器向我们发送网络资源,但是 HTTP 提供了更多我们可以执行的操作。我们请求行中的GET是一个 HTTP 方法,有几种方法,比如HEAD、POST、OPTION、PUT、DELETE、TRACE、CONNECT和PATCH。
我们将在下一章中详细讨论其中的一些,但现在我们将快速查看两种方法。
HEAD 方法
HEAD方法与GET方法相同。唯一的区别是服务器永远不会在响应中包含正文,即使在请求的 URL 上有一个有效的资源。HEAD方法用于检查资源是否存在或是否已更改。请注意,一些服务器不实现此方法,但当它们这样做时,它可以证明是一个巨大的带宽节省者。
我们使用urllib中的替代方法,通过在创建Request对象时提供方法名称:
**>>> req = Request('http://www.google.com', method='HEAD')**
**>>> response = urlopen(req)**
**>>> response.status**
**200**
**>>> response.read()**
**b''**
这里服务器返回了一个200 OK响应,但是正文是空的,这是预期的。
POST 方法
POST方法在某种意义上是GET方法的相反。我们使用POST方法向服务器发送数据。然而,服务器仍然可以向我们发送完整的响应。POST方法用于提交 HTML 表单中的用户输入和向服务器上传文件。
在使用POST时,我们希望发送的数据将放在请求的正文中。我们可以在那里放入任何字节数据,并通过在我们的请求中添加Content-Type头来声明其类型,使用适当的 MIME 类型。
让我们通过一个例子来看看如何通过 POST 请求向服务器发送一些 HTML 表单数据,就像浏览器在网站上提交表单时所做的那样。表单数据始终由键/值对组成;urllib让我们可以使用常规字典来提供这些数据(我们将在下一节中看到这些数据来自哪里):
**>>> data_dict = {'P': 'Python'}**
在发布 HTML 表单数据时,表单值必须以与 URL 中的查询字符串相同的方式进行格式化,并且必须进行 URL 编码。还必须设置Content-Type头为特殊的 MIME 类型application/x-www-form-urlencoded。
由于这种格式与查询字符串相同,我们可以在准备数据时使用urlencode()函数:
**>>> data = urlencode(data_dict).encode('utf-8')**
在这里,我们还将结果额外编码为字节,因为它将作为请求的主体发送。在这种情况下,我们使用 UTF-8 字符集。
接下来,我们将构建我们的请求:
**>>> req = Request('http://search.debian.org/cgi-bin/omega', data=data)**
通过将我们的数据作为data关键字参数添加,我们告诉urllib我们希望我们的数据作为请求的主体发送。这将使请求使用POST方法而不是GET方法。
接下来,我们添加Content-Type头:
**>>> req.add_header('Content-Type', 'application/x-www-form-urlencode; charset=UTF-8')**
最后,我们提交请求:
**>>> response = urlopen(req)**
如果我们将响应数据保存到文件并在网络浏览器中打开它,那么我们应该会看到一些与 Python 相关的 Debian 网站搜索结果。
正式检查
在前一节中,我们使用了 URLhttp://search.debian.org/cgibin/omega,和字典data_dict = {'P': 'Python'}。但这些是从哪里来的呢?
我们通过访问包含我们手动提交以获取结果的表单的网页来获得这些信息。然后我们检查网页的 HTML 源代码。如果我们在网络浏览器中进行上述搜索,那么我们很可能会在www.debian.org页面上,并且我们将通过在右上角的搜索框中输入搜索词然后点击搜索来进行搜索。
大多数现代浏览器允许您直接检查页面上任何元素的源代码。要做到这一点,右键单击元素,这种情况下是搜索框,然后选择检查元素选项,如此屏幕截图所示:

源代码将在窗口的某个部分弹出。在前面的屏幕截图中,它位于屏幕的左下角。在这里,您将看到一些代码行,看起来像以下示例:
<form action="http://search.debian.org/cgi-bin/omega"
method="get" name="P">
<p>
<input type="hidden" value="en" name="DB"></input>
**<input size="27" value="" name="P"></input>**
<input type="submit" value="Search"></input>
</p>
</form>
您应该看到第二个高亮显示的<input>。这是对应于搜索文本框的标签。高亮显示的<input>标签上的name属性的值是我们在data_dict中使用的键,这种情况下是P。我们data_dict中的值是我们要搜索的术语。
要获取 URL,我们需要在高亮显示的<input>上方查找包围的<form>标签。在这里,我们的 URL 将是action属性的值,search.debian.org/cgi-bin/omega。本书的源代码下载中包含了此网页的源代码,以防 Debian 在您阅读之前更改他们的网站。
这个过程可以应用于大多数 HTML 页面。要做到这一点,找到与输入文本框对应的<input>,然后从包围的<form>标签中找到 URL。如果您不熟悉 HTML,那么这可能是一个反复试验的过程。我们将在下一章中看一些解析 HTML 的更多方法。
一旦我们有了我们的输入名称和 URL,我们就可以构建并提交 POST 请求,就像在前一节中所示的那样。
HTTPS
除非另有保护,所有 HTTP 请求和响应都是以明文发送的。任何可以访问消息传输的网络的人都有可能拦截我们的流量并毫无阻碍地阅读它。
由于网络用于传输大量敏感数据,已经创建了一些解决方案,以防止窃听者阅读流量,即使他们能够拦截它。这些解决方案在很大程度上采用了某种形式的加密。
加密 HTTP 流量的标准方法称为 HTTP 安全,或HTTPS。它使用一种称为 TLS/SSL 的加密机制,并应用于 HTTP 流量传输的 TCP 连接上。HTTPS 通常使用 TCP 端口 443,而不是默认的 HTTP 端口 80。
对于大多数用户来说,这个过程几乎是透明的。原则上,我们只需要将 URL 中的 http 更改为 https。由于urllib支持 HTTPS,因此对于我们的 Python 客户端也是如此。
请注意,并非所有服务器都支持 HTTPS,因此仅将 URL 方案更改为https:并不能保证适用于所有站点。如果是这种情况,连接尝试可能会以多种方式失败,包括套接字超时、连接重置错误,甚至可能是 HTTP 错误,如 400 范围错误或 500 范围错误。然而,越来越多的站点正在启用 HTTPS。许多其他站点正在切换到 HTTPS 并将其用作默认协议,因此值得调查它是否可用,以便为应用程序的用户提供额外的安全性。
Requests库
这就是关于urllib包的全部内容。正如你所看到的,访问标准库对于大多数 HTTP 任务来说已经足够了。我们还没有涉及到它的所有功能。还有许多处理程序类我们没有讨论,而且打开接口是可扩展的。
然而,API 并不是最优雅的,已经有几次尝试来改进它。其中一个是非常受欢迎的第三方库Requests。它作为requests包在 PyPi 上可用。它可以通过 Pip 安装,也可以从docs.python-requests.org下载,该网站提供了文档。
Requests库自动化并简化了我们一直在研究的许多任务。最快的说明方法是尝试一些示例。
使用Requests检索 URL 的命令与使用urllib包检索 URL 的命令类似,如下所示:
**>>> import requests**
**>>> response = requests.get('http://www.debian.org')**
我们可以查看响应对象的属性。尝试:
**>>> response.status_code**
**200**
**>>> response.reason**
**'OK'**
**>>> response.url**
**'http://www.debian.org/'**
**>>> response.headers['content-type']**
**'text/html'**
请注意,前面命令中的标头名称是小写的。Requests响应对象的headers属性中的键是不区分大小写的。
响应对象中添加了一些便利属性:
**>>> response.ok**
**True**
ok属性指示请求是否成功。也就是说,请求包含的状态码在 200 范围内。另外:
**>>> response.is_redirect**
**False**
is_redirect属性指示请求是否被重定向。我们还可以通过响应对象访问请求属性:
**>>> response.request.headers**
**{'User-Agent': 'python-requests/2.3.0 CPython/3.4.1 Linux/3.2.0-4- amd64', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*'}**
请注意,Requests会自动处理压缩。它在Accept-Encoding头中包括gzip和deflate。如果我们查看Content-Encoding响应,我们会发现响应实际上是gzip压缩的,而Requests会自动为我们解压缩:
**>>> response.headers['content-encoding']**
**'gzip'**
我们可以以更多的方式查看响应内容。要获得与HTTPResponse对象相同的字节对象,执行以下操作:
**>>> response.content**
**b'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">\n<html lang="en">...**
但是,Requests还会自动解码。要获取解码后的内容,请执行以下操作:
**>>> response.text**
**'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">\n<html lang="en">\n<head>\n**
**...**
请注意,这现在是str而不是bytes。Requests库使用头中的值来选择字符集并将内容解码为 Unicode。如果无法从头中获取字符集,则使用chardet库(pypi.python.org/pypi/chardet)从内容本身进行估计。我们可以看到Requests选择了哪种编码:
**>>> response.encoding**
**'ISO-8859-1'**
我们甚至可以要求它更改已使用的编码:
**>>> response.encoding = 'utf-8'**
更改编码后,对于此响应的text属性的后续引用将返回使用新编码设置解码的内容。
Requests库会自动处理 Cookie。试试这个:
**>>> response = requests.get('http://www.github.com')**
**>>> print(response.cookies)**
**<<class 'requests.cookies.RequestsCookieJar'>**
**[<Cookie logged_in=no for .github.com/>,**
**<Cookie _gh_sess=eyJzZxNz... for ..github.com/>]>**
Requests库还有一个Session类,允许重复使用 cookie,这类似于使用http模块的CookieJar和urllib模块的HTTPCookieHandler对象。要在后续请求中重复使用 cookie,请执行以下操作:
**>>> s = requests.Session()**
**>>> s.get('http://www.google.com')**
**>>> response = s.get('http://google.com/preferences')**
Session对象具有与requests模块相同的接口,因此我们可以像使用“requests.get()”方法一样使用其get()方法。现在,遇到的任何 cookie 都将存储在Session对象中,并且在将来使用get()方法时将随相应的请求发送。
重定向也会自动跟随,方式与使用urllib时相同,并且任何重定向的请求都会被捕获在history属性中。
不同的 HTTP 方法很容易访问,它们有自己的功能:
**>>> response = requests.head('http://www.google.com')**
**>>> response.status_code**
**200**
**>>> response.text**
**''**
自定义标头以类似于使用urllib时的方式添加到请求中:
**>>> headers = {'User-Agent': 'Mozilla/5.0 Firefox 24'}**
**>>> response = requests.get('http://www.debian.org', headers=headers)**
使用查询字符串进行请求是一个简单的过程:
**>>> params = {':action': 'search', 'term': 'Are you quite sure this is a cheese shop?'}**
**>>> response = requests.get('http://pypi.python.org/pypi', params=params)**
**>>> response.url**
**'https://pypi.python.org/pypi?%3Aaction=search&term=Are+you+quite+sur e+this+is+a+cheese+shop%3F'**
Requests库为我们处理所有的编码和格式化工作。
发布也同样简化,尽管我们在这里使用data关键字参数:
**>>> data = {'P', 'Python'}**
**>>> response = requests.post('http://search.debian.org/cgi- bin/omega', data=data)**
使用 Requests 处理错误
Requests中的错误处理与使用urllib处理错误的方式略有不同。让我们通过一些错误条件来看看它是如何工作的。通过以下操作生成一个 404 错误:
**>>> response = requests.get('http://www.google.com/notawebpage')**
**>>> response.status_code**
**404**
在这种情况下,urllib会引发异常,但请注意,Requests不会。 Requests库可以检查状态代码并引发相应的异常,但我们必须要求它这样做:
**>>> response.raise_for_status()**
**...**
**requests.exceptions.HTTPError: 404 Client Error**
现在,尝试在成功的请求上进行测试:
**>>> r = requests.get('http://www.google.com')**
**>>> r.status_code**
**200**
**>>> r.raise_for_status()**
**None**
它不做任何事情,这在大多数情况下会让我们的程序退出try/except块,然后按照我们希望的方式继续。
如果我们遇到协议栈中较低的错误会发生什么?尝试以下操作:
**>>> r = requests.get('http://192.0.2.1')**
**...**
**requests.exceptions.ConnectionError: HTTPConnectionPool(...**
我们已经发出了一个主机不存在的请求,一旦超时,我们就会收到一个ConnectionError异常。
与urllib相比,Requests库简化了在 Python 中使用 HTTP 所涉及的工作量。除非您有使用urllib的要求,我总是建议您在项目中使用Requests。
总结
我们研究了 HTTP 协议的原则。我们看到如何使用标准库urllib和第三方Requests包执行许多基本任务。
我们研究了 HTTP 消息的结构,HTTP 状态代码,我们可能在请求和响应中遇到的不同标头,以及如何解释它们并用它们来定制我们的请求。我们看了 URL 是如何形成的,以及如何操作和构建它们。
我们看到了如何处理 cookie 和重定向,如何处理可能发生的错误,以及如何使用安全的 HTTP 连接。
我们还介绍了如何以提交网页表单的方式向网站提交数据,以及如何从页面源代码中提取我们需要的参数。
最后,我们看了第三方的Requests包。我们发现,与urllib包相比,Requests自动化并简化了我们可能需要用 HTTP 进行的许多常规任务。这使得它成为日常 HTTP 工作的绝佳选择。
在下一章中,我们将运用我们在这里学到的知识,与不同的网络服务进行详细的交互,查询 API 以获取数据,并将我们自己的对象上传到网络。
第三章:API 的实际应用
当我们谈论 Python 中的 API 时,通常指的是模块向我们呈现的类和函数,以便与之交互。在本章中,我们将谈论一些不同的东西,即 Web API。
Web API 是一种通过 HTTP 协议与之交互的 API 类型。如今,许多 Web 服务提供一组 HTTP 调用,旨在由客户端以编程方式使用,也就是说,它们是为机器而不是人类设计的。通过这些接口,可以自动化与服务的交互,并执行诸如提取数据、以某种方式配置服务以及将自己的内容上传到服务中等任务。
在本章中,我们将看到:
-
Web API 使用的两种流行数据交换格式:XML 和 JSON
-
如何与两个主要 Web API 进行交互:Amazon S3 和 Twitter
-
在 API 不可用时如何从 HTML 页面中提取数据
-
如何为提供这些 API 和网站的网络管理员简化工作
有数百种提供 Web API 的服务。这些服务的相当全面且不断增长的列表可以在www.programmableweb.com找到。
我们将首先介绍 Python 中如何使用 XML,然后解释一种基于 XML 的 API,称为 Amazon S3 API。
开始使用 XML
可扩展标记语言(XML)是一种以标准文本格式表示分层数据的方式。在使用基于 XML 的 Web API 时,我们将创建 XML 文档,并将其作为 HTTP 请求的主体发送,并接收 XML 文档作为响应的主体。
以下是 XML 文档的文本表示,也许代表奶酪店的库存:
<?xml version='1.0'?>
<inventory>
<cheese id="c01">
<name>Caerphilly</name>
<stock>0</stock>
</cheese>
<cheese id="c02">
<name>Illchester</name>
<stock>0</stock>
</cheese>
</inventory>
如果您以前使用过 HTML 编码,那么这可能看起来很熟悉。 XML 是一种基于标记的格式。它来自与 HTML 相同语言系列。数据以元素形式的层次结构进行组织。每个元素由两个标签表示,例如开始标签<name>和匹配的结束标签,例如</name>。在这两个标签之间,我们可以放置数据,例如Caerphilly,或者添加更多标签,代表子元素。
与 HTML 不同,XML 被设计成我们可以定义自己的标签并创建自己的数据格式。此外,与 HTML 不同,XML 语法始终严格执行。在 HTML 中,小错误(例如标签以错误顺序关闭,完全缺少关闭标签或属性值缺少引号)是可以容忍的,但在 XML 中,这些错误将导致完全无法阅读的 XML 文档。格式正确的 XML 文档称为格式良好的。
XML API
处理 XML 数据有两种主要方法:
-
读取整个文档并创建基于对象的表示,然后使用面向对象的 API 进行操作。
-
从头到尾处理文档,并在遇到特定标签时执行操作
现在,我们将专注于使用名为ElementTree的 Python XML API 的基于对象的方法。第二种所谓的拉或事件驱动方法(也经常称为SAX,因为 SAX 是这一类别中最流行的 API 之一)设置更加复杂,并且仅在处理大型 XML 文件时才需要。我们不需要这个来处理 Amazon S3。
ElementTree 的基础知识
我们将使用 Python 标准库中的ElementTree API 实现,该 API 位于xml.etree.ElementTree模块中。
让我们看看如何使用ElementTree创建上述示例 XML 文档。打开 Python 解释器并运行以下命令:
**>>> import xml.etree.ElementTree as ET**
**>>> root = ET.Element('inventory')**
**>>> ET.dump(root)**
**<inventory />**
我们首先创建根元素,也就是文档的最外层元素。我们在这里创建了一个根元素<inventory>,然后将其字符串表示打印到屏幕上。<inventory />表示是<inventory></inventory>的 XML 快捷方式。它用于显示一个空元素,即没有数据和子标签的元素。
我们通过创建一个新的ElementTree.Element对象来创建<inventory>元素。您会注意到我们给“Element()”的参数是创建的标签的名称。
我们的<inventory>元素目前是空的,所以让我们往里面放点东西。这样做:
**>>> cheese = ET.Element('cheese')**
**>>> root.append(cheese)**
**>>> ET.dump(root)**
**<inventory><cheese /></inventory>**
现在,在我们的<inventory>元素中有一个<cheese>元素。当一个元素直接嵌套在另一个元素内时,那么嵌套的元素称为外部元素的子元素,外部元素称为父元素。同样,处于同一级别的元素称为兄弟元素。
让我们再添加另一个元素,这次给它一些内容。添加以下命令:
**>>> name = ET.SubElement(cheese, 'name')**
**>>> name.text = 'Caerphilly'**
**>>> ET.dump(root)**
**<inventory><cheese><name>Caerphilly</name></cheese></inventory>**
现在,我们的文档开始成形了。我们在这里做了两件新事情:首先,我们使用了快捷类方法“ElementTree.SubElement()”来创建新的<name>元素,并将其作为<cheese>的子元素一次性插入树中。其次,我们通过将一些文本赋给元素的text属性来为其赋予一些内容。
我们可以使用父元素上的“remove()”方法来删除元素,如下面的命令所示:
**>>> temp = ET.SubElement(root, 'temp')**
**>>> ET.dump(root)**
**<inventory><cheese><name>Caerphilly</name></cheese><temp /></inventory>**
**>>> root.remove(temp)**
**>>> ET.dump(root)**
**<inventory><cheese><name>Caerphilly</name></cheese></inventory>**
漂亮打印
我们能够以更易读的格式生成输出将会很有用,比如在本节开头展示的例子。ElementTree API 没有用于执行此操作的函数,但标准库提供的另一个 XML APIminidom有,并且使用起来很简单。首先,导入minidom:
**>>> import xml.dom.minidom as minidom**
其次,使用以下命令打印一些格式良好的 XML:
**>>> print(minidom.parseString(ET.tostring(root)).toprettyxml())**
**<?xml version="1.0" ?>**
**<inventory>**
**<cheese>**
**<name>Caerphilly</name>**
**</cheese>**
**</inventory>**
这些乍一看不是最容易的代码行,所以让我们来分解一下。 minidom库不能直接处理 ElementTree 元素,因此我们使用 ElementTree 的“tostring()”函数来创建我们的 XML 的字符串表示。我们通过使用“minidom.parseString()”将字符串加载到minidom API 中,然后使用“toprettyxml()”方法输出我们格式化的 XML。
这可以封装成一个函数,使其更加方便。在 Python shell 中输入以下命令块:
**>>> def xml_pprint(element):**
**... s = ET.tostring(element)**
**... print(minidom.parseString(s).toprettyxml())**
现在,只需执行以下操作进行漂亮的打印:
**>>> xml_pprint(root)**
**<?xml version="1.0" ?>**
**<inventory>**
**<cheese>**
**...**
元素属性
在本节开头展示的例子中,您可能已经注意到了<cheese>元素的开标签中的内容,“id =c01”。这被称为属性。我们可以使用属性来附加额外的信息到元素上,元素可以拥有的属性数量没有限制。属性始终由属性名称组成,在本例中是id,以及一个值,在本例中是c01。值可以是任何文本,但必须用引号括起来。
现在,按照以下方式为<cheese>元素添加id属性:
**>>> cheese.attrib['id'] = 'c01'**
**>>> xml_pprint(cheese)**
**<?xml version="1.0" ?>**
**<cheese id="c01">**
**<name>Caerphilly</name>**
**</cheese>**
元素的attrib属性是一个类似字典的对象,保存着元素的属性名称和值。我们可以像操作常规dict一样操作 XML 属性。
到目前为止,您应该能够完全重新创建本节开头展示的示例文档。继续尝试吧。
转换为文本
一旦我们有了满意的 XML 树,通常我们会希望将其转换为字符串以便通过网络发送。我们一直在使用的“ET.dump()”函数不适用于此。 “dump()”函数所做的只是将标签打印到屏幕上。它不会返回我们可以使用的字符串。我们需要使用“ET.tostring()”函数,如下面的命令所示:
**>>> text = ET.tostring(name)**
**>>> print(text)**
**b'<name>Caerphilly</name>'**
请注意它返回一个字节对象。它为我们编码字符串。默认字符集是us-ascii,但最好使用 UTF-8 进行 HTTP 传输,因为它可以编码完整的 Unicode 字符范围,并且得到了 Web 应用的广泛支持。
**>>> text = ET.tostring(name, encoding='utf-8')**
目前,这就是我们需要了解有关创建 XML 文档的所有内容,让我们看看如何将其应用到 Web API。
亚马逊 S3 API
亚马逊 S3 是一个数据存储服务。它支撑了今天许多知名的网络服务。尽管提供了企业级的弹性、性能和功能,但它非常容易上手。它价格合理,并且提供了一个简单的 API 用于自动访问。它是不断增长的亚马逊网络服务(AWS)组合中的众多云服务之一。
API 不断变化,通常会被赋予一个版本号,以便我们可以跟踪它们。我们将使用当前版本的 S3 REST API,“2006-03-01”。
您会注意到在 S3 文档和其他地方,S3 Web API 被称为REST API。REST代表表述性状态转移,这是 Roy Fielding 在他的博士论文中最初提出的关于如何使用 HTTP 进行 API 的相当学术的概念。尽管一个 API 应该具有被认为是 RESTful 的属性是非常具体的,但实际上几乎任何基于 HTTP 的 API 现在都被贴上了 RESTful 的标签。S3 API 实际上是最具有 RESTful 特性的高调 API 之一,因为它适当地使用了 HTTP 方法的广泛范围。
注意
如果您想了解更多关于这个主题的信息,Roy Fielding 的博士论文可以在这里找到ics.uci.edu/~fielding/pubs/dissertation,而最初提出这个概念并且是一本很好的读物的书籍之一,RESTful Web Services由Leonard Richardson和Sam Ruby,现在可以从这个页面免费下载restfulwebapis.org/rws.html。
注册 AWS
在我们可以访问 S3 之前,我们需要在 AWS 上注册。API 通常要求在允许访问其功能之前进行注册。您可以使用现有的亚马逊账户或在www.amazonaws.com上创建一个新账户。虽然 S3 最终是一个付费服务,但如果您是第一次使用 AWS,那么您将获得一年的免费试用,用于低容量使用。一年的时间足够完成本章的学习!试用提供 5GB 的免费 S3 存储空间。
认证
接下来,我们需要讨论认证,这是在使用许多 Web API 时的一个重要讨论话题。我们使用的大多数 Web API 都会指定一种提供认证凭据的方式,允许向它们发出请求,通常我们发出的每个 HTTP 请求都必须包含认证信息。
API 需要这些信息有以下原因:
-
确保其他人无法滥用应用程序的访问权限
-
应用每个应用程序的速率限制
-
管理访问权限的委托,以便应用程序可以代表服务的其他用户或其他服务进行操作
-
收集使用统计数据
所有的 AWS 服务都使用 HTTP 请求签名机制进行认证。为了签署一个请求,我们使用加密密钥对 HTTP 请求中的唯一数据进行哈希和签名,然后将签名作为标头添加到请求中。通过在服务器上重新创建签名,AWS 可以确保请求是由我们发送的,并且在传输过程中没有被更改。
AWS 签名生成过程目前处于第 4 版,需要进行详细讨论,因此我们将使用第三方库,即requests-aws4auth。这是一个Requests模块的伴侣库,可以自动处理签名生成。它可以在 PyPi 上获得。因此,请在命令行上使用pip安装它:
**$ pip install requests-aws4auth**
**Downloading/unpacking requests-aws4auth**
**...**
设置 AWS 用户
要使用身份验证,我们需要获取一些凭据。
我们将通过 AWS 控制台进行设置。注册 AWS 后,登录到console.aws.amazon.com控制台。
一旦您登录,您需要执行这里显示的步骤:
-
点击右上角的您的名称,然后选择安全凭据。
-
点击屏幕左侧列表中的用户,然后点击顶部的创建新用户按钮。
-
输入用户名,确保已选中为每个用户生成访问密钥,然后点击右下角的创建按钮。
您将看到一个新页面,显示用户已成功创建。点击右下角的下载凭据按钮下载一个 CSV 文件,其中包含此用户的访问 ID和访问密钥。这些很重要,因为它们将帮助我们对 S3 API 进行身份验证。请确保将它们安全地存储,因为它们将允许完全访问您的 S3 文件。
然后,点击屏幕底部的关闭,点击将出现的列表中的新用户,然后点击附加策略按钮。将显示一系列策略模板。滚动此列表并选择AmazonS3FullAccess策略,如下图所示:

最后,当它出现时,点击右下角的附加策略按钮。现在,我们的用户已完全访问 S3 服务。
区域
AWS 在世界各地都有数据中心,因此当我们在 AWS 中激活服务时,我们选择希望其存在的区域。S3 的区域列表在docs.aws.amazon.com/general/latest/gr/rande.html#s3_region上。
最好选择离将使用该服务的用户最近的区域。目前,您将是唯一的用户,所以只需为我们的第一个 S3 测试选择离您最近的区域。
S3 存储桶和对象
S3 使用两个概念来组织我们存储在其中的数据:存储桶和对象。对象相当于文件,即具有名称的数据块,而存储桶相当于目录。存储桶和目录之间唯一的区别是存储桶不能包含其他存储桶。
每个存储桶都有自己的 URL 形式:
http://<bucketname>.s3-<region>.amazonaws.com。
在 URL 中,<bucketname>是存储桶的名称,<region>是存储桶所在的 AWS 区域,例如eu-west-1。存储桶名称和区域在创建存储桶时设置。
存储桶名称在所有 S3 用户之间是全局共享的,因此它们必须是唯一的。如果您拥有域名,则该域名的子域名将成为适当的存储桶名称。您还可以使用您的电子邮件地址,将@符号替换为连字符或下划线。
对象在我们首次上传时命名。我们通过将对象名称作为路径添加到存储桶的 URL 末尾来访问对象。例如,如果我们在eu-west-1区域有一个名为mybucket.example.com的存储桶,其中包含名为cheeseshop.txt的对象,那么我们可以通过 URLmybucket.example.com.s3-eu-west-1.amazonaws.com/cheeseshop.txt来访问它。
让我们通过 AWS 控制台创建我们的第一个存储桶。我们可以通过这个网页界面手动执行 API 公开的大多数操作,并且这是检查我们的 API 客户端是否执行所需任务的好方法:
-
登录到
console.aws.amazon.com控制台。 -
转到 S3 服务。您将看到一个页面,提示您创建一个存储桶。
-
点击创建存储桶按钮。
-
输入存储桶名称,选择一个区域,然后点击创建。
-
您将被带到存储桶列表,并且您将能够看到您的存储桶。
一个 S3 命令行客户端
好了,准备工作足够了,让我们开始编码。在接下来的 S3 部分中,我们将编写一个小的命令行客户端,这将使我们能够与服务进行交互。我们将创建存储桶,然后上传和下载文件。
首先,我们将设置我们的命令行解释器并初始化身份验证。创建一个名为s3_client.py的文件,并将以下代码块保存在其中:
import sys
import requests
import requests_aws4auth as aws4auth
import xml.etree.ElementTree as ET
import xml.dom.minidom as minidom
access_id = '<ACCESS ID>'
access_key = '<ACCESS KEY>'
region = '<REGION>'
endpoint = 's3-{}.amazonaws.com'.format(region)
auth = aws4auth.AWS4Auth(access_id, access_key, region, 's3')
ns = 'http://s3.amazonaws.com/doc/2006-03-01/'
def xml_pprint(xml_string):
print(minidom.parseString(xml_string).toprettyxml())
def create_bucket(bucket):
print('Bucket name: {}'.format(bucket))
if __name__ == '__main__':
cmd, *args = sys.argv[1:]
globals()cmd
提示
下载示例代码
您可以从www.packtpub.com的帐户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。
您需要用之前下载的凭据 CSV 中的值替换<ACCESS ID>和<ACCESS KEY>,并用您选择的 AWS 区域替换<REGION>。
那么,我们在这里做什么呢?首先,我们设置了我们的端点。端点是一个通用术语,用于访问 API 的 URL。一些 Web API 只有一个端点,一些有多个端点,这取决于 API 的设计方式。我们在这里生成的端点实际上只是我们在使用存储桶时将使用的完整端点的一部分。我们的实际端点是由存储桶名称前缀的端点。
接下来,我们创建我们的auth对象。我们将与Requests一起使用它来为我们的 API 请求添加 AWS 身份验证。
ns变量是一个字符串,我们需要用它来处理来自 S3 API 的 XML。我们将在使用它时讨论这个。
我们已经包含了我们的xml_pprint()函数的修改版本,以帮助调试。目前,create_bucket()函数只是一个占位符。我们将在下一节中了解更多。
最后,我们有命令解释器本身 - 它只是获取脚本在命令行上给出的第一个参数,并尝试运行一个同名的函数,将任何剩余的命令行参数传递给函数。让我们进行一次测试。在命令提示符中输入以下内容:
**$ python3.4 s3_client.py create_bucket mybucket**
**Bucket name: mybucket**
您可以看到脚本从命令行参数中提取create_bucket,因此调用create_bucket()函数,将myBucket作为参数传递。
这个框架使得添加功能来扩展我们客户的能力成为一个简单的过程。让我们从使create_bucket()做一些有用的事情开始。
使用 API 创建一个存储桶
每当我们为 API 编写客户端时,我们的主要参考点是 API 文档。文档告诉我们如何构造执行操作的 HTTP 请求。S3 文档可以在docs.aws.amazon.com/AmazonS3/latest/API/APIRest.html找到。docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketPUT.html URL 将提供存储桶创建的详细信息。
这份文档告诉我们,要创建一个存储桶,我们需要通过使用 HTTP PUT方法向我们新存储桶的端点发出 HTTP 请求。它还告诉我们,请求正文必须包含一些 XML,其中指定了我们希望创建存储桶的 AWS 区域。
所以,现在我们知道我们的目标是什么,让我们讨论我们的功能。首先,让我们创建 XML。用以下代码块替换create_bucket()的内容:
def create_bucket(bucket):
XML = ET.Element('CreateBucketConfiguration')
XML.attrib['xmlns'] = ns
location = ET.SubElement(XML, 'LocationConstraint')
location.text = auth.region
data = ET.tostring(XML, encoding='utf-8')
xml_pprint(data)
在这里,我们创建一个遵循 S3 文档中给出的格式的 XML 树。如果我们现在运行我们的客户端,那么我们将看到这里显示的 XML:
**$ python3.4 s3_client.py create_bucket mybucket.example.com**
**<?xml version="1.0" ?>**
**<CreateBucketConfiguration >**
**<LocationConstraint>eu-west-1</LocationConstraint>**
**</CreateBucketConfiguration>**
这与文档中指定的格式相匹配。您可以看到我们使用ns变量来填充xmlns属性。这个属性在整个 S3 XML 中都会出现,预定义ns变量使得更快地处理它。
现在,让我们添加代码来发出请求。将create_bucket()末尾的xml_pprint(data)替换为以下内容:
url = 'http://{}.{}'.format(bucket, endpoint)
r = requests.put(url, data=data, auth=auth)
if r.ok:
print('Created bucket {} OK'.format(bucket))
else:
xml_pprint(r.text)
这里显示的第一行将从我们的存储桶名称和端点生成完整的 URL。第二行将向 S3 API 发出请求。请注意,我们使用requests.put()函数使用 HTTP PUT方法进行此请求,而不是使用requests.get()方法或requests.post()方法。还要注意,我们已经提供了我们的auth对象给调用。这将允许Requests为我们处理所有 S3 身份验证!
如果一切顺利,我们将打印出一条消息。如果一切不如预期,我们将打印出响应正文。S3 将错误消息作为 XML 返回到响应正文中。因此,我们使用我们的xml_pprint()函数来显示它。稍后我们将在处理错误部分讨论处理这些错误。
现在运行客户端,如果一切正常,那么我们将收到确认消息。确保您选择的存储桶尚未创建:
**$ python3.4 s3_client.py create_bucket mybucket.example.com**
**Created bucket mybucket.example.com OK**
当我们在浏览器中刷新 S3 控制台时,我们将看到我们的存储桶已创建。
上传文件
现在我们已经创建了一个存储桶,我们可以上传一些文件。编写一个上传文件的函数类似于创建一个存储桶。我们查看文档以了解如何构建我们的 HTTP 请求,找出应该在命令行收集哪些信息,然后编写函数。
我们需要再次使用 HTTP PUT。我们需要存储文件的存储桶名称以及我们希望文件在 S3 中存储的名称。请求的正文将包含文件数据。在命令行中,我们将收集存储桶名称,我们希望文件在 S3 服务中存储的名称以及要上传的本地文件的名称。
在create_bucket()函数之后将以下函数添加到您的s3_client.py文件中:
def upload_file(bucket, s3_name, local_path):
data = open(local_path, 'rb').read()
url = 'http://{}.{}/{}'.format(bucket, endpoint, s3_name)
r = requests.put(url, data=data, auth=auth)
if r.ok:
print('Uploaded {} OK'.format(local_path))
else:
xml_pprint(r.text)
在创建此函数时,我们遵循了与创建存储桶类似的模式:
-
准备要放入请求正文中的数据。
-
构建我们的 URL。
-
发出请求。
-
检查结果。
请注意,我们以二进制模式打开本地文件。文件可以包含任何类型的数据,因此我们不希望应用文本转换。我们可以从任何地方获取这些数据,例如数据库或另一个 Web API。在这里,我们只是简单地使用本地文件。
URL 与我们在create_bucket()中构建的端点相同,并且 S3 对象名称附加到 URL 路径。稍后,我们可以使用此 URL 检索对象。
现在,运行这里显示的命令来上传一个文件:
**$ python3.4 s3_client.py mybucket.example.com test.jpg ~/test.jpg**
**Uploaded ~/test.jpg OK**
您需要将mybucket.example.com替换为您自己的存储桶名称。一旦文件上传完成,您将在 S3 控制台中看到它。
我使用了一个存储在我的主目录中的 JPEG 图像作为源文件。您可以使用任何文件,只需将最后一个参数更改为适当的路径。但是,使用 JPEG 图像将使您更容易重现以下部分。
通过 Web 浏览器检索已上传的文件
默认情况下,S3 对存储桶和对象应用限制权限。创建它们的帐户具有完全的读写权限,但对于其他人完全拒绝访问。这意味着我们刚刚上传的文件只有在下载请求包括我们帐户的身份验证时才能下载。如果我们在浏览器中尝试结果 URL,那么我们将收到访问被拒绝的错误。如果我们试图使用 S3 与其他人共享文件,这并不是很有用。
解决此问题的方法是使用 S3 的一种机制来更改权限。让我们看看使我们上传的文件公开的简单任务。将upload_file()更改为以下内容:
def upload_file(bucket, s3_name, local_path, acl='private'):
data = open(local_path, 'rb').read()
url = 'http://{}.{}/{}'.format(bucket, endpoint, s3_name)
headers = {'x-amz-acl': acl}
r = requests.put(url, data=data, headers=headers, auth=auth)
if r.ok:
print('Uploaded {} OK'.format(local_path))
else:
xml_pprint(r.text)
我们现在在我们的 HTTP 请求中包含了一个头部,x-amz-acl,它指定了要应用于对象的权限集。我们还在函数签名中添加了一个新的参数,这样我们就可以在命令行上指定权限集。我们使用了 S3 提供的所谓的预设 ACLs(预设 访问控制列表),并在docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl中进行了记录。
我们感兴趣的 ACL 称为public-read。这将允许任何人下载文件而无需任何形式的身份验证。现在,我们可以重新运行我们的上传,但这次会将这个 ACL 应用到它上面:
**$ python3.4 s3_client.py mybucket.example.com test.jpg ~/test.jpg public-read**
**Uploaded test.jpg OK**
现在,在浏览器中访问文件的 S3 URL 将给我们下载文件的选项。
在 Web 浏览器中显示上传的文件
如果你上传了一张图片,那么你可能会想知道为什么浏览器要求我们保存它而不是直接显示它。原因是我们没有设置文件的Content-Type。
如果你还记得上一章,HTTP 响应中的Content-Type头部告诉客户端,这里是我们的浏览器,正文中的文件类型。默认情况下,S3 应用binary/octet-stream的内容类型。由于这个Content-Type,浏览器无法知道它正在下载一个图像,所以它只是将它呈现为一个可以保存的文件。我们可以通过在上传请求中提供Content-Type头部来解决这个问题。S3 将存储我们指定的类型,并在随后的下载响应中使用它作为Content-Type。
在s3_client.py的开头添加以下代码块到导入中:
import mimetypes
然后将upload_file()更改为以下内容:
def upload_file(bucket, s3_name, local_path, acl='private'):
data = open(local_path, 'rb').read()
url = 'http://{}.{}/{}'.format(bucket, endpoint, s3_name)
headers = {'x-amz-acl': acl}
mimetype = mimetypes.guess_type(local_path)[0]
if mimetype:
headers['Content-Type'] = mimetype
r = requests.put(url, data=data, headers=headers, auth=auth)
if r.ok:
print('Uploaded {} OK'.format(local_path))
else:
xml_pprint(r.text)
在这里,我们使用了mimetypes模块来猜测一个适合的Content-Type,通过查看local_path的文件扩展名。如果mimetypes无法从local_path确定Content-Type,那么我们就不包括Content-Type头部,让 S3 应用默认的binary/octet-stream类型。
不幸的是,在 S3 中,我们无法通过简单的PUT请求覆盖现有对象的元数据。可以通过使用PUT复制请求来实现,但这超出了本章的范围。现在,最好的方法是在上传文件之前使用 AWS 控制台从 S3 中删除文件。我们只需要做一次。现在,我们的代码将自动为我们上传的任何新文件添加Content-Type。
一旦你删除了文件,就像上一节所示重新运行客户端,也就是说,用新的Content-Type上传文件并尝试在浏览器中再次下载文件。如果一切顺利,那么图像将被显示。
使用 API 下载文件
通过 S3 API 下载文件与上传文件类似。我们只需要再次提供存储桶名称、S3 对象名称和本地文件名,但是发出一个GET请求而不是PUT请求,然后将接收到的数据写入磁盘。
在你的程序中添加以下函数,放在upload_file()函数下面:
def download_file(bucket, s3_name, local_path):
url = 'http://{}.{}/{}'.format(bucket, endpoint, s3_name)
r = requests.get(url, auth=auth)
if r.ok:
open(local_path, 'wb').write(r.content)
print('Downloaded {} OK'.format(s3_name))
else:
xml_pprint(r.text)
现在,运行客户端并下载一个文件,你之前上传的文件,使用以下命令:
**$ python3.4 s3_client.py download_file mybucket.example.com test.jpg ~/test_downloaded.jpg**
**Downloaded test.jpg OK**
解析 XML 和处理错误
如果在运行上述代码时遇到任何错误,那么你会注意到清晰的错误消息不会被显示。S3 将错误消息嵌入到响应体中返回的 XML 中,直到现在我们只是将原始 XML 转储到屏幕上。我们可以改进这一点,并从 XML 中提取文本。首先,让我们生成一个错误消息,这样我们就可以看到 XML 的样子。在s3_client.py中,将你的访问密钥替换为空字符串,如下所示:
access_secret = ''
现在,尝试在服务上执行以下操作:
**$ python3.4 s3_client.py create_bucket failbucket.example.com**
**<?xml version="1.0" ?>**
**<Error>**
**<Code>SignatureDoesNotMatch</Code>**
**<Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>**
**<AWSAccessKeyId>AKIAJY5II3SZNHZ25SUA</AWSAccessKeyId>**
**<StringToSign>AWS4-HMAC-SHA256...</StringToSign>**
**<SignatureProvided>e43e2130...</SignatureProvided>**
**<StringToSignBytes>41 57 53 34...</StringToSignBytes>**
**<CanonicalRequest>PUT...</CanonicalRequest>**
**<CanonicalRequestBytes>50 55 54...</CanonicalRequestBytes>**
**<RequestId>86F25A39912FC628</RequestId>**
**<HostId>kYIZnLclzIW6CmsGA....</HostId>**
**</Error>**
前面的 XML 是 S3 错误信息。我已经截断了几个字段以便在这里显示。你的代码块会比这个稍微长一点。在这种情况下,它告诉我们它无法验证我们的请求,这是因为我们设置了一个空的访问密钥。
解析 XML
打印所有的 XML 对于错误消息来说太多了。有很多无用的额外信息对我们来说没有用。最好的办法是只提取错误消息的有用部分并显示出来。
嗯,ElementTree为我们从 XML 中提取这样的信息提供了一些强大的工具。我们将回到 XML 一段时间,来探索这些工具。
首先,我们需要打开一个交互式的 Python shell,然后使用以下命令再次生成上述错误消息:
**>>> import requests**
**>>> import requests_aws4auth**
**>>> auth = requests_aws4auth.AWS4Auth('<ID>', '', 'eu-west-1', '')**
**>>> r = requests.get('http://s3.eu-west-1.amazonaws.com', auth=auth)**
你需要用你的 AWS 访问 ID 替换<ID>。打印出r.text以确保你得到一个错误消息,类似于我们之前生成的那个。
现在,我们可以探索我们的 XML。将 XML 文本转换为ElementTree树。一个方便的函数是:
**>>> import xml.etree.ElementTree as ET**
**>>> root = ET.fromstring(r.text)**
现在我们有了一个 ElementTree 实例,root作为根元素。
查找元素
通过使用元素作为迭代器来浏览树的最简单方法。尝试做以下事情:
**>>> for element in root:**
**... print('Tag: ' + element.tag)**
**Tag: Code**
**Tag: Message**
**Tag: AWSAccessKeyId**
**Tag: StringToSign**
**Tag: SignatureProvided**
**...**
迭代root会返回它的每个子元素,然后我们通过使用tag属性打印出元素的标签。
我们可以使用以下命令对我们迭代的标签应用过滤器:
**>>> for element in root.findall('Message'):**
**... print(element.tag + ': ' + element.text)**
**Message: The request signature we calculated does not match the signature you provided. Check your key and signing method.**
在这里,我们使用了root元素的findall()方法。这个方法将为我们提供与指定标签匹配的root元素的所有直接子元素的列表,在这种情况下是<Message>。
这将解决我们只提取错误消息文本的问题。现在,让我们更新我们的错误处理。
处理错误
我们可以回去并将这添加到我们的s3_client.py文件中,但让我们在输出中包含更多信息,并结构化代码以允许重用。将以下函数添加到download_file()函数下面的文件中:
def handle_error(response):
output = 'Status code: {}\n'.format(response.status_code)
root = ET.fromstring(response.text)
code = root.find('Code').text
output += 'Error code: {}\n'.format(code)
message = root.find('Message').text
output += 'Message: {}\n'.format(message)
print(output)
你会注意到我们在这里使用了一个新的函数,即root.find()。这与findall()的工作方式相同,只是它只返回第一个匹配的元素,而不是所有匹配的元素列表。
然后,用handle_error(r)替换文件中每个xml_pprint(r.text)的实例,然后再次使用错误的访问密钥运行客户端。现在,你会看到一个更详细的错误消息:
**$ python3.4 s3_client.py create_bucket failbucket.example.com**
**Status code: 403**
**Error code: SignatureDoesNotMatch**
**Message: The request signature we calculated does not match the signature you provided. Check your key and signing method.**
进一步的增强
这就是我们要为客户提供的服务。我们编写了一个命令行程序,可以在 Amazon S3 服务上执行创建存储桶、上传和下载对象等基本操作。还有很多操作可以实现,这些可以在 S3 文档中找到;例如列出存储桶内容、删除对象和复制对象等操作。
我们可以改进一些其他东西,特别是如果我们要将其制作成一个生产应用程序。命令行解析机制虽然紧凑,但从安全角度来看并不令人满意,因为任何有权访问命令行的人都可以运行任何内置的 python 命令。最好是有一个函数白名单,并使用标准库模块之一,如argparse来实现一个适当的命令行解析器。
将访问 ID 和访问密钥存储在源代码中也是安全问题。由于密码存储在源代码中,然后上传到云代码仓库,发生了几起严重的安全事件。最好在运行时从外部来源加载密钥,比如文件或数据库。
Boto 包
我们已经讨论了直接使用 S3 REST API,并且这给了我们一些有用的技术,让我们能够在将来编写类似 API 时进行编程。在许多情况下,这将是我们与 Web API 交互的唯一方式。
然而,一些 API,包括 AWS,有现成的包可以暴露服务的功能,而无需处理 HTTP API 的复杂性。这些包通常使代码更清晰、更简单,如果可用的话,应该优先用于生产工作。
AWS 包被称为Boto。我们将快速浏览一下Boto包,看看它如何提供我们之前编写的一些功能。
boto包在 PyPi 中可用,所以我们可以用pip安装它:
**$ pip install boto**
**Downloading/unpacking boto**
**...**
现在,启动一个 Python shell,让我们试一试。我们需要先连接到服务:
**>>> import boto**
**>>> conn = boto.connect_s3('<ACCESS ID>', '<ACCESS SECRET>')**
您需要用您的访问 ID 和访问密钥替换<ACCESS ID>和<ACCESS SECRET>。现在,让我们创建一个存储桶:
**>>> conn.create_bucket('mybucket.example.com')**
这将在默认的标准美国地区创建存储桶。我们可以提供不同的地区,如下所示:
**>>> from boto.s3.connection import Location**
**>>> conn.create_bucket('mybucket.example.com', location=Location.EU)**
我们需要使用不同的区域名称来执行此功能,这些名称与我们之前创建存储桶时使用的名称不同。要查看可接受的区域名称列表,请执行以下操作:
**>>> [x for x in dir(Location) if x.isalnum()]**
**['APNortheast', 'APSoutheast', 'APSoutheast2', 'CNNorth1', 'DEFAULT', 'EU', 'SAEast', 'USWest', 'USWest2']**
执行以下操作以显示我们拥有的存储桶列表:
**>>> buckets = conn.get_all_buckets()**
**>>> [b.name for b in buckets]**
**['mybucket.example.com', 'mybucket2.example.com']**
我们还可以列出存储桶的内容。为此,首先我们需要获取对它的引用:
**>>> bucket = conn.get_bucket('mybucket.example.com')**
然后列出内容:
**>>> [k.name for k in bucket.list()]**
**['cheesehop.txt', 'parrot.txt']**
上传文件是一个简单的过程。首先,我们需要获取要放入的存储桶的引用,然后我们需要创建一个Key对象,它将代表我们在存储桶中的对象:
**>>> bucket = conn.get_bucket('mybucket.example.com')**
**>>> from boto.s3.key import Key**
**>>> key = Key(bucket)**
接下来,我们需要设置Key名称,然后上传我们的文件数据:
**>>> key.key = 'lumberjack_song.txt'**
**>>> key.set_contents_from_filename('~/lumberjack_song.txt')**
boto包在上传文件时会自动设置Content-Type,它使用了我们之前用于确定类型的mimetypes模块。
下载也遵循类似的模式。尝试以下命令:
**>>> bucket = conn.get_bucket('mybucket.example.com')**
**>>> key = bucket.get_key('parrot.txt')**
**>>> key.get_contents_to_filename('~/parrot.txt')**
这将下载mybucket.example.com存储桶中的parrot.txt S3 对象,然后将其存储在~/parrot.txt本地文件中。
一旦我们有了对Key的引用,只需使用以下内容来设置 ACL:
**>>> key.set_acl('public-read')**
我将让您通过教程进一步探索boto包的功能,该教程可以在boto.readthedocs.org/en/latest/s3_tut.html找到。
显然,对于 Python 中的日常 S3 工作,boto应该是您的首选包。
结束 S3
因此,我们已经讨论了 Amazon S3 API 的一些用途,并学到了一些关于在 Python 中使用 XML 的知识。这些技能应该让您在使用任何基于 XML 的 REST API 时有一个良好的开端,无论它是否有像boto这样的预构建库。
然而,XML 并不是 Web API 使用的唯一数据格式,S3 处理 HTTP 的方式也不是 Web API 使用的唯一模型。因此,我们将继续并看一看今天使用的另一种主要数据格式,JSON 和另一个 API:Twitter。
JSON
JavaScript 对象表示法(JSON)是一种用文本字符串表示简单对象(如列表和字典)的标准方式。尽管最初是为 JavaScript 开发的,但 JSON 是与语言无关的,大多数语言都可以使用它。它轻巧,但足够灵活,可以处理广泛的数据范围。这使得它非常适合在 HTTP 上传数据,许多 Web API 使用它作为其主要数据格式。
编码和解码
我们使用json模块来处理 Python 中的 JSON。通过以下命令,让我们创建一个 Python 列表的 JSON 表示:
**>>> import json**
**>>> l = ['a', 'b', 'c']**
**>>> json.dumps(l)**
**'["a", "b", "c"]'**
我们使用json.dumps()函数将对象转换为 JSON 字符串。在这种情况下,我们可以看到 JSON 字符串似乎与 Python 对列表的表示相同,但请注意这是一个字符串。通过以下操作确认:
**>>> s = json.dumps(['a', 'b', 'c'])**
**>>> type(s)**
**<class 'str'>**
**>>> s[0]**
**'['**
将 JSON 转换为 Python 对象也很简单,如下所示:
**>>> s = '["a", "b", "c"]'**
**>>> l = json.loads(s)**
**>>> l**
**['a', 'b', 'c']**
**>>> l[0]**
**'a'**
我们使用json.loads()函数,只需传递一个 JSON 字符串。正如我们将看到的,这在与 Web API 交互时非常强大。通常,我们将收到一个 JSON 字符串作为 HTTP 响应的主体,只需使用json.loads()进行解码,即可提供可立即使用的 Python 对象。
使用 JSON 的字典
JSON 本身支持映射类型对象,相当于 Python 的dict。这意味着我们可以直接通过 JSON 使用dicts。
**>>> json.dumps({'A':'Arthur', 'B':'Brian', 'C':'Colonel'})**
**'{"A": "Arthur", "C": "Colonel", "B": "Brian"}'**
此外,了解 JSON 如何处理嵌套对象也是有用的。
**>>> d = {**
**... 'Chapman': ['King Arthur', 'Brian'],**
**... 'Cleese': ['Sir Lancelot', 'The Black Knight'],**
**... 'Idle': ['Sir Robin', 'Loretta'],**
**... }**
**>>> json.dumps(d)**
**'{"Chapman": ["King Arthur", "Brian"], "Idle": ["Sir Robin", "Loretta"], "Cleese": ["Sir Lancelot", "The Black Knight"]}'**
不过有一个需要注意的地方:JSON 字典键只能是字符串形式。
**>>> json.dumps({1:10, 2:20, 3:30})**
**'{"1": 10, "2": 20, "3": 30}'**
注意,JSON 字典中的键如何成为整数的字符串表示?要解码使用数字键的 JSON 字典,如果我们想将它们作为数字处理,我们需要手动进行类型转换。执行以下操作来实现这一点:
**>>> j = json.dumps({1:10, 2:20, 3:30})**
**>>> d_raw = json.loads(j)**
**>>> d_raw**
**{'1': 10, '2': 20, '3': 30}**
**>>> {int(key):val for key,val in d_raw.items()}**
**{1: 10, 2: 20, 3: 30}**
我们只需使用字典推导将int()应用于字典的键。
其他对象类型
JSON 只能干净地处理 Python 的lists和dicts,对于其他对象类型,json可能会尝试将对象类型转换为其中一个,或者完全失败。尝试一个元组,如下所示:
**>>> json.dumps(('a', 'b', 'c'))**
**'["a", "b", "c"]'**
JSON 没有元组数据类型,因此json模块将其转换为list。如果我们将其转换回:
**>>> j = json.dumps(('a', 'b', 'c'))**
**>>> json.loads(j)**
**['a', 'b', 'c']**
它仍然是一个list。json模块不支持sets,因此它们也需要重新转换为lists。尝试以下命令:
**>>> s = set(['a', 'b', 'c'])**
**>>> json.dumps(s)**
**...**
**TypeError: {'a', 'c', 'b'} is not JSON serializable**
**>>> json.dumps(list(s))**
**'["a", "b", "c"]'**
这将导致类似于元组引起的问题。如果我们将 JSON 转换回 Python 对象,那么它将是一个list而不是set。
我们几乎从不遇到需要这些专门的 Python 对象的 Web API,如果我们确实遇到,那么 API 应该提供一些处理它的约定。但是,如果我们将数据存储在除lists或dicts之外的任何格式中,我们需要跟踪我们需要应用于传出或传入对象的任何转换。
现在我们对 JSON 有了一定的了解,让我们看看它在 Web API 中是如何工作的。
Twitter API
Twitter API 提供了访问我们可能希望 Twitter 客户端执行的所有功能。使用 Twitter API,我们可以创建搜索最新推文、查找趋势、查找用户详细信息、关注用户时间线,甚至代表用户发布推文和直接消息的客户端。
我们将查看 Twitter API 版本 1.1,这是撰写本章时的当前版本。
注意
Twitter 为其 API 提供了全面的文档,可以在dev.twitter.com/overview/documentation找到。
一个 Twitter 世界时钟
为了说明 Twitter API 的一些功能,我们将编写一个简单的 Twitter 世界时钟的代码。我们的应用程序将定期轮询其 Twitter 账户,寻找包含可识别城市名称的提及,如果找到,则会回复推文并显示该城市的当前当地时间。在 Twitter 中,提及是指包含我们账户名前缀@的任何推文,例如@myaccount。
Twitter 的身份验证
与 S3 类似,我们需要确定在开始之前如何管理身份验证。我们需要注册,然后了解 Twitter 希望我们如何对请求进行身份验证。
为 Twitter API 注册您的应用程序
我们需要创建一个 Twitter 账户,注册我们的应用程序,并且我们将收到我们应用程序的身份验证凭据。另外,建立一个第二个账户也是一个好主意,我们可以用它来向应用程序账户发送测试推文。这提供了一种更干净的方式来检查应用程序是否正常工作,而不是让应用程序账户向自己发送推文。您可以创建的 Twitter 账户数量没有限制。
要创建帐户,请转到www.twitter.com并完成注册过程。一旦您拥有 Twitter 帐户,执行以下操作注册您的应用程序:
-
使用您的主要 Twitter 帐户登录
apps.twitter.com,然后创建一个新应用程序。 -
填写新应用程序表格,注意 Twitter 应用程序名称需要在全球范围内是唯一的。
-
转到应用程序设置,然后更改应用程序权限以具有读写访问权限。您可能需要注册您的手机号码以启用此功能。即使您不愿意提供这个信息,我们也可以创建完整的应用程序;但是,最终发送回复推文的最终功能将不会激活。
现在我们需要获取我们的访问凭证,如下所示:
-
转到Keys and Access Tokens部分,然后记下Consumer Key和Access Secret。
-
生成一个访问令牌。
-
记下访问令牌和访问密钥。
认证请求
我们现在有足够的信息来进行请求认证。Twitter 使用一个称为oAuth的认证标准,版本 1.0a。详细描述在oauth.net/core/1.0a/。
oAuth 认证标准有点棘手,但幸运的是,Requests模块有一个名为requests-oauthlib的伴侣库,它可以为我们处理大部分复杂性。这在 PyPi 上可用,因此我们可以使用pip下载和安装它。
**$ pip install requests-oauthlib**
**Downloading/unpacking requests-oauthlib**
**...**
现在,我们可以为我们的请求添加认证,然后编写我们的应用程序。
一个 Twitter 客户端
将此处提到的代码保存到文件中,并将其保存为twitter_worldclock.py。您需要用从上述 Twitter 应用程序配置中获取的值替换<CONSUMER_KEY>,<CONSUMER_SECRET>,<ACCESS_TOKEN>和<ACCESS_SECRET>:
import requests, requests_oauthlib, sys
consumer_key = '<CONSUMER_KEY>'
consumer_secret = '<CONSUMER_SECRET>'
access_token = '<ACCESS_TOKEN>'
access_secret = '<ACCESS_KEY>'
def init_auth():
auth_obj = requests_oauthlib.OAuth1(
consumer_key, consumer_secret,
access_token, access_secret)
if verify_credentials(auth_obj):
print('Validated credentials OK')
return auth_obj
else:
print('Credentials validation failed')
sys.exit(1)
def verify_credentials(auth_obj):
url = 'https://api.twitter.com/1.1/' \
'account/verify_credentials.json'
response = requests.get(url, auth=auth_obj)
return response.status_code == 200
if __name__ == '__main__':
auth_obj = init_auth()
请记住,consumer_secret和access_secret充当您的 Twitter 帐户的密码,因此在生产应用程序中,它们应该从安全的外部位置加载,而不是硬编码到源代码中。
在上述代码中,我们通过使用我们的访问凭证在init_auth()函数中创建OAuth1认证实例auth_obj。每当我们需要发出 HTTP 请求时,我们将其传递给Requests,通过它Requests处理认证。您可以在verify_credentials()函数中看到这个例子。
在verify_credentials()函数中,我们测试 Twitter 是否识别我们的凭据。我们在这里使用的 URL 是 Twitter 专门用于测试我们的凭据是否有效的终点。如果它们有效,则返回 HTTP 200 状态代码,否则返回 401 状态代码。
现在,让我们运行twitter_worldclock.py,如果我们已经注册了我们的应用程序并正确填写了令牌和密钥,那么我们应该会看到验证凭据 OK。现在认证已经工作,我们程序的基本流程将如下图所示:

我们的程序将作为守护程序运行,定期轮询 Twitter,查看是否有任何新的推文需要我们处理和回复。当我们轮询提及时间线时,我们将下载自上次轮询以来接收到的任何新推文,以便我们可以处理所有这些推文而无需再次轮询。
轮询推文
让我们添加一个函数来检查并从我们的提及时间线中检索新推文。在我们添加循环之前,我们将使其工作。在verify_credentials()下面添加新函数,然后在主部分中添加对此函数的调用;同时,在文件开头的导入列表中添加json:
def get_mentions(since_id, auth_obj):
params = {'count': 200, 'since_id': since_id,
'include_rts': 0, 'include_entities': 'false'}
url = 'https://api.twitter.com/1.1/' \
'statuses/mentions_timeline.json'
response = requests.get(url, params=params, auth=auth_obj)
response.raise_for_status()
return json.loads(response.text)
if __name__ == '__main__':
auth_obj = init_auth()
since_id = 1
for tweet in get_mentions(since_id, auth_obj):
print(tweet['text'])
使用get_mentions(),我们通过连接到statuses/mentions_timeline.json端点来检查并下载提及我们应用账户的任何推文。我们提供了一些参数,Requests将其作为查询字符串传递。这些参数由 Twitter 指定,它们控制推文将如何返回给我们。它们如下:
-
'count':这指定将返回的最大推文数。Twitter 将允许通过单个请求接收 200 条推文。 -
'include_entities':这用于从检索到的推文中删除一些多余的信息。 -
'include_rts':这告诉 Twitter 不要包括任何转发。如果有人转发我们的回复,我们不希望用户收到另一个时间更新。 -
'since_id':这告诉 Twitter 只返回 ID 大于此值的推文。每条推文都有一个唯一的 64 位整数 ID,后来的推文比先前的推文具有更高的值 ID。通过记住我们处理的最后一条推文的 ID,然后将其作为此参数传递,Twitter 将过滤掉我们已经看过的推文。
在运行上述操作之前,我们希望为我们的账户生成一些提及,这样我们就有东西可以下载。登录您的 Twitter 测试账户,然后创建一些包含@username的推文,其中您将username替换为您的应用账户用户名。之后,当您进入应用账户的通知选项卡的提及部分时,您将看到这些推文。
现在,如果我们运行上述代码,我们将在屏幕上打印出我们提及的文本。
处理推文
下一步是解析我们的提及,然后生成我们想要包含在回复中的时间。解析是一个简单的过程。在这里,我们只需检查推文的“text”值,但生成时间需要更多的工作。实际上,为此,我们需要一个城市及其时区的数据库。这在pytz包中可用,在 PyPi 上可以找到。为此,请安装以下包:
**$ pip install pytz**
**Downloading/unpacking pytz**
**...**
然后,我们可以编写我们的推文处理函数。将此函数添加到get_mentions()下方,然后在文件开头的导入列表中添加datetime和pytz:
def process_tweet(tweet):
username = tweet['user']['screen_name']
text = tweet['text']
words = [x for x in text.split() if
x[0] not in ['@', '#']]
place = ' '.join(words)
check = place.replace(' ', '_').lower()
found = False
for tz in pytz.common_timezones:
tz_low = tz.lower()
if check in tz_low.split('/'):
found = True
break
if found:
timezone = pytz.timezone(tz)
time = datetime.datetime.now(timezone).strftime('%H:%M')
reply = '@{} The time in {} is currently {}'.format(username, place, time)
else:
reply = "@{} Sorry, I didn't recognize " \
"'{}' as a city".format(username, place)
print(reply)
if __name__ == '__main__':
auth_obj = init_auth()
since_id = 1
for tweet in get_mentions(since_id, auth_obj):
process_tweet(tweet)
process_tweet()的大部分内容用于格式化推文文本和处理时区数据。首先,我们将从推文中删除任何@username提及和#hashtags。然后,我们准备剩下的推文文本与时区名称数据库进行比较。时区名称数据库存储在pytz.common_timezones中,但名称中还包含地区,用斜杠(/)与名称分隔。此外,在这些名称中,下划线用于代替空格。
我们通过扫描数据库来检查格式化的推文文本。如果找到匹配项,我们将构建一个包含匹配时区的当地时间的回复。为此,我们使用datetime模块以及由pytz生成的时区对象。如果在时区数据库中找不到匹配项,我们将组成一个回复,让用户知道这一点。然后,我们将我们的回复打印到屏幕上,以检查它是否按预期工作。
同样,在运行此操作之前,我们可能希望创建一些只包含城市名称并提及我们的世界时钟应用账户的推文,以便函数有东西可以处理。在时区数据库中出现的一些城市包括都柏林、纽约和东京。
试一试!当您运行它时,您将在屏幕上得到一些包含这些城市和这些城市当前当地时间的推文回复文本。
速率限制
如果我们多次运行上述操作,然后我们会发现它在一段时间后会停止工作。要么凭据暂时无法验证,要么get_mentions()中的 HTTP 请求将失败。
这是因为 Twitter 对其 API 应用速率限制,这意味着我们的应用程序只允许在一定时间内对端点进行一定数量的请求。限制在 Twitter 文档中列出,根据认证路线(稍后讨论)和端点的不同而有所不同。我们使用statuses/mentions_timeline.json,因此我们的限制是每 15 分钟 15 次请求。如果我们超过这个限制,那么 Twitter 将以429 Too many requests状态代码做出响应。这将迫使我们等待下一个 15 分钟窗口开始之前,才能让我们获得任何有用的数据。
速率限制是 Web API 的常见特征,因此在使用它们时,有一些有效的测试方法是很有用的。使用速率限制的 API 数据进行测试的一种方法是下载一些数据,然后将其存储在本地。之后,从文件中加载它,而不是从 API 中拉取它。通过使用 Python 解释器下载一些测试数据,如下所示:
**>>> from twitter_worldclock import ***
**>>> auth_obj = init_auth()**
**Credentials validated OK**
**>>> mentions = get_mentions(1, auth_obj)**
**>>> json.dump(mentions, open('test_mentions.json', 'w'))**
当您运行此时,您需要在与twitter_worldclock.py相同的文件夹中。这将创建一个名为test_mentions.json的文件,其中包含我们的 JSON 化提及。在这里,json.dump()函数将提供的数据写入文件,而不是将其作为字符串返回。
我们可以通过修改程序的主要部分来使用这些数据,看起来像下面这样:
if __name__ == '__main__':
mentions = json.load(open('test_mentions.json'))
for tweet in mentions:
process_tweet(tweet)
发送回复
我们需要执行的最后一个函数是对提及进行回复。为此,我们使用statuses/update.json端点。如果您尚未在应用帐户中注册您的手机号码,则这将无法工作。因此,只需将程序保持原样。如果您已经注册了手机号码,则在process_tweets()下添加此功能:
def post_reply(reply_to_id, text, auth_obj):
params = {
'status': text,
'in_reply_to_status_id': reply_to_id}
url = 'https://api.twitter.com/1.1./statuses/update.json'
response = requests.post(url, params=params, auth=auth_obj)
response.raise_for_status()
并在process_tweet()末尾的print()调用下面,与相同的缩进级别:
post_reply(tweet['id'], reply, auth_obj)
现在,如果您运行此程序,然后检查您的测试帐户的 Twitter 通知,您将看到一些回复。
post_reply()函数只是使用以下参数调用端点,通知 Twitter 要发布什么:
-
status:这是我们回复推文的文本。 -
in_reply_to_status_id:这是我们要回复的推文的 ID。我们提供这个信息,以便 Twitter 可以将推文链接为对话。
在测试时,我们可能会收到一些403状态代码响应。这没关系,只是 Twitter 拒绝让我们连续发布两条相同文本的推文,这可能会发生在这个设置中,具体取决于我们发送了什么测试推文。
最后的修饰
建筑模块已经就位,我们可以添加主循环使程序成为守护进程。在顶部导入time模块,然后将主要部分更改为以下内容:
if __name__ == '__main__':
auth_obj = init_auth()
since_id = 1
error_count = 0
while error_count < 15:
try:
for tweet in get_mentions(since_id, auth_obj):
process_tweet(tweet)
since_id = max(since_id, tweet['id'])
error_count = 0
except requests.exceptions.HTTPError as e:
print('Error: {}'.format(str(e)))
error_count += 1
time.sleep(60)
这将每 60 秒调用get_mentions(),然后处理已下载的任何新推文。如果出现任何 HTTP 错误,它将在退出程序之前重试 15 次。
现在,如果我们运行程序,它将持续运行,回复提及世界时钟应用帐户的推文。试一试,运行程序,然后从您的测试帐户发送一些推文。一分钟后,您将看到一些回复您的通知。
进一步进行
现在我们已经编写了一个基本的功能 Twitter API 客户端,肯定有一些可以改进的地方。虽然本章没有空间详细探讨增强功能,但值得提到一些,以便通知您可能想要承担的未来项目。
轮询和 Twitter 流 API
您可能已经注意到一个问题,即我们的客户端每次轮询最多只能拉取 200 条推文。在每次轮询中,Twitter 首先提供最近的推文。这意味着如果我们在 60 秒内收到超过 200 条推文,那么我们将永久丢失最先收到的推文。实际上,使用statuses/mentions_timeline.json端点没有完整的解决方案。
Twitter 针对这个问题的解决方案是提供一种另类的 API,称为流式 API。连接到这些 API 时,HTTP 响应连接实际上是保持打开状态的,并且传入的推文会不断通过它进行流式传输。Requests包提供了处理这种情况的便捷功能。Requests响应对象具有iter_lines()方法,可以无限运行。它能够在服务器发送数据时输出一行数据,然后我们可以对其进行处理。如果您发现您需要这个功能,那么在 Requests 文档中有一个示例可以帮助您入门,可以在docs.python-requests.org/en/latest/user/advanced/#streaming-requests找到。
替代 oAuth 流程
我们的设置是让我们的应用程序针对我们的主账户进行操作,并为发送测试推文使用第二个账户,这有点笨拙,特别是如果您将您的应用账户用于常规推文。有没有更好的办法,专门有一个账户来处理世界时钟的推文?
嗯,是的。理想的设置是在一个主账户上注册应用程序,并且您也可以将其用作常规 Twitter 账户,并且让应用程序处理第二个专用世界时钟账户的推文。
oAuth 使这成为可能,但需要一些额外的步骤才能使其正常工作。我们需要世界时钟账户来授权我们的应用代表其行事。您会注意到之前提到的 oAuth 凭据由两个主要元素组成,消费者和访问。消费者元素标识我们的应用程序,访问元素证明了访问凭据来自授权我们的应用代表其行事的账户。在我们的应用程序中,我们通过让应用程序代表注册时的账户,也就是我们的应用账户,来简化完整的账户授权过程。当我们这样做时,Twitter 允许我们直接从dev.twitter.com界面获取访问凭据。要使用不同的用户账户,我们需要插入一个步骤,让用户转到 Twitter,这将在 Web 浏览器中打开,用户需要登录,然后明确授权我们的应用程序。
注意
这个过程在requests-oauthlib文档中有演示,可以在requests-oauthlib.readthedocs.org/en/latest/oauth1_workflow.html找到。
HTML 和屏幕抓取
尽管越来越多的服务通过 API 提供其数据,但当一个服务没有这样做时,以编程方式获取数据的唯一方法是下载其网页,然后解析 HTML 源代码。这种技术称为屏幕抓取。
虽然原则上听起来很简单,但屏幕抓取应该被视为最后的手段。与 XML 不同,XML 的语法严格执行,数据结构通常是相对稳定的,有时甚至有文档记录,而网页源代码的世界却是一个混乱的世界。这是一个不断变化的地方,代码可能会意外改变,以一种完全破坏你的脚本并迫使你从头开始重新设计解析逻辑的方式。
尽管如此,有时这是获取基本数据的唯一方法,因此我们将简要讨论开发一种抓取方法。我们将讨论在 HTML 代码发生变化时减少影响的方法。
在抓取之前,您应该始终检查网站的条款和条件。一些网站明确禁止自动解析和检索。违反条款可能导致您的 IP 地址被禁止。然而,在大多数情况下,只要您不重新发布数据并且不进行过于频繁的请求,您应该没问题。
HTML 解析器
我们将解析 HTML 就像我们解析 XML 一样。我们再次可以选择拉取式 API 和面向对象的 API。我们将使用ElementTree,原因与之前提到的相同。
有几个可用的 HTML 解析库。它们的区别在于它们的速度、在 HTML 文档中导航的接口,以及它们处理糟糕构建的 HTML 的能力。Python 标准库不包括面向对象的 HTML 解析器。这方面普遍推荐的第三方包是lxml,它主要是一个 XML 解析器。但是,它确实包含一个非常好的 HTML 解析器。它快速,提供了几种浏览文档的方式,并且对破碎的 HTML 宽容。
lxml库可以通过python-lxml包在 Debian 和 Ubuntu 上安装。如果您需要一个最新版本,或者无法安装系统包,那么可以通过pip安装lxml。请注意,您需要一个构建环境。Debian 通常带有一个已经设置好的环境,但如果缺少,那么以下内容将为 Debian 和 Ubuntu 都安装一个:
**$ sudo apt-get install build-essential**
然后你应该能够像这样安装lxml:
**$ sudo STATIC_DEPS=true pip install lxml**
如果您在 64 位系统上遇到编译问题,那么您也可以尝试:
**$ CFLAGS="$CFLAGS -fPIC" STATIC_DEPS=true pip install lxml**
在 Windows 上,可以从lxml网站lxml.de/installation.html获取安装程序包。如果您的 Python 版本没有安装程序,可以在页面上查找第三方安装程序的链接。
如果lxml对您不起作用,下一个最好的库是 BeautifulSoup。BeautifulSoup 是纯 Python,因此可以使用pip安装,并且应该可以在任何地方运行。尽管它有自己的 API,但它是一个备受尊重和有能力的库,实际上它可以使用lxml作为后端库。
给我看数据
在开始解析 HTML 之前,我们需要解析的东西!让我们从 Debian 网站上获取最新稳定版 Debian 发行版的版本和代号。有关当前稳定版发行版的信息可以在www.debian.org/releases/stable/找到。
我们想要的信息显示在页面标题和第一句中:

因此,我们应该提取"jessie"代号和 8.0 版本号。
使用 lxml 解析 HTML
让我们打开一个 Python shell 并开始解析。首先,我们将使用Requests下载页面。
**>>> import requests**
**>>> response = requests.get('https://www.debian.org/releases/stable')**
接下来,我们将源代码解析成ElementTree树。这与使用标准库的ElementTree解析 XML 相同,只是这里我们将使用lxml专家HTMLParser。
**>>> from lxml.etree import HTML**
**>>> root = HTML(response.content)**
HTML()函数是一个快捷方式,它读取传递给它的 HTML,然后生成一个 XML 树。请注意,我们传递的是response.content而不是response.text。lxml库在使用原始响应而不是解码的 Unicode 文本时会产生更好的结果。
lxml库的ElementTree实现已经被设计为与标准库的 100%兼容,因此我们可以像处理 XML 一样开始探索文档:
**>>> [e.tag for e in root]**
**['head', 'body']**
**>>> root.find('head').find('title').text**
**'Debian –- Debian \u201cjessie\u201d Release Information'**
在上面的代码中,我们已经打印出了文档的<title>元素的文本内容,这是在上面截图的标签中显示的文本。我们已经看到它包含了我们想要的代号。
聚焦
屏幕抓取是一种寻找明确地址 HTML 元素的艺术,这些元素包含我们想要的信息,并且只从这些元素中提取信息。
然而,我们也希望选择标准尽可能简单。我们依赖文档的内容越少,页面的 HTML 发生变化时就越不容易破坏。
让我们检查页面的 HTML 源代码,看看我们正在处理什么。为此,可以在 Web 浏览器中使用查看源代码,或者将 HTML 保存到文件中并在文本编辑器中打开。本书的源代码下载中也包含了页面的源代码。搜索文本Debian 8.0,这样我们就可以直接找到我们想要的信息。对我来说,它看起来像以下代码块:
<body>
...
<div id="content">
<h1>Debian “jessie” Release Information</h1>
<p>**Debian 8.0** was
released October 18th, 2014.
The release included many major
changes, described in
...
我跳过了<body>和<div>之间的 HTML,以显示<div>是<body>元素的直接子元素。从上面可以看出,我们想要<div>元素的<p>标签子元素的内容。
如果我们使用之前使用过的ElementTree函数导航到此元素,那么我们最终会得到类似以下的内容:
**>>> root.find('body').findall('div')[1].find('p').text**
**Debian 8.0 was.**
**...**
但这并不是最佳方法,因为它相当大程度上依赖于 HTML 结构。例如,插入一个我们需要的<div>标签之前的变化会破坏它。此外,在更复杂的文档中,这可能导致可怕的方法调用链,难以维护。我们在上一节中使用<title>标签来获取代号的方法是一个很好的技巧的例子,因为文档中始终只有一个<head>和一个<title>标签。找到我们的<div>的更好方法是利用它包含的id="content"属性。将页面分成几个顶级<div>,如页眉、页脚和内容,并为<div>赋予标识它们的id属性,是一种常见的网页设计模式。
因此,如果我们可以搜索具有id属性为"content"的<div>,那么我们将有一种干净的方法来选择正确的<div>。文档中只有一个匹配的<div>,并且不太可能会添加另一个类似的<div>到文档中。这种方法不依赖于文档结构,因此不会受到对结构所做的任何更改的影响。我们仍然需要依赖于<div>中的<p>标签是出现的第一个<p>标签,但鉴于没有其他方法来识别它,这是我们能做的最好的。
那么,我们如何运行这样的搜索来找到我们的内容<div>呢?
使用 XPath 搜索
为了避免穷举迭代和检查每个元素,我们需要使用XPath,它比我们迄今为止使用的更强大。它是一种专门为 XML 开发的查询语言,并且得到了lxml的支持。此外,标准库实现对其提供了有限的支持。
我们将快速了解 XPath,并在此过程中找到之前提出的问题的答案。
要开始使用 Python shell,可以执行以下操作:
**>>> root.xpath('body')**
**[<Element body at 0x39e0908>]**
这是 XPath 表达式的最简单形式:它搜索当前元素的子元素,其标签名称与指定的标签名称匹配。当前元素是我们在其上调用xpath()的元素,在本例中是root。root元素是 HTML 文档中的顶级<html>元素,因此返回的元素是<body>元素。
XPath 表达式可以包含多个级别的元素。搜索从进行xpath()调用的节点开始,并随着它们在表达式中匹配连续元素而向下工作。我们可以利用这一点来仅查找<body>的<div>子元素。
**>>> root.xpath('body/div')**
**[<Element div at 0x39e06c8>, <Element div at 0x39e05c8>, <Element div at 0x39e0608>]**
body/div表达式意味着匹配当前元素的<body>子元素的<div>子元素。在 XML 文档中,具有相同标签的元素可以在同一级别出现多次,因此 XPath 表达式可以匹配多个元素,因此xpath()函数始终返回一个列表。
前面的查询是相对于我们称之为xpath()的元素的,但我们可以通过在表达式开头添加斜杠来强制从树的根部进行搜索。我们还可以通过双斜杠来对元素的所有后代进行搜索。要做到这一点,请尝试以下操作:
**>>> root.xpath('//h1')**
**[<Element h1 at 0x2ac3b08>]**
在这里,我们只通过指定单个标记就直接找到了我们的<h1>元素,即使它在root下面几个级别。表达式开头的双斜杠将始终从根目录搜索,但如果我们希望从上下文元素开始搜索,可以在前面加上一个点。
**>>> root.find('head').xpath('.//h1')**
**[]**
这将找不到任何内容,因为<head>没有<h1>的后代。
XPath 条件
因此,通过提供路径,我们可以非常具体,但 XPath 的真正力量在于对路径中的元素应用附加条件。特别是,我们前面提到的问题,即测试元素属性。
**>>> root.xpath('//div[@id="content"]')**
**[<Element div at 0x39e05c8>]**
在div后面的方括号[@id="content"]形成了我们放在匹配的<div>元素上的条件。id之前的@符号表示id是一个属性,因此条件的含义是:只有id属性等于"content"的元素。这就是我们如何找到我们的内容<div>。
在我们使用它来提取信息之前,让我们简单介绍一下我们可以使用条件做的一些有用的事情。我们可以只指定一个标记名称,如下所示:
**>>> root.xpath('//div[h1]')**
**[<Element div at 0x39e05c8>]**
这将返回所有具有<h1>子元素的<div>元素。也可以尝试:
**>>> root.xpath('body/div[2]'):**
**[<Element div at 0x39e05c8>]**
将数字作为条件将返回匹配列表中的该位置的元素。在这种情况下,这是<body>的第二个<div>子元素。请注意,这些索引从1开始,而不像 Python 索引从0开始。
XPath 还有很多功能,完整的规范是万维网联盟(W3C)的标准。最新版本可以在www.w3.org/TR/xpath-3/上找到。
汇总
现在我们已经将 XPath 添加到我们的超能力中,让我们通过编写一个脚本来获取我们的 Debian 版本信息来完成。创建一个新文件get_debian_version.py,并将以下内容保存到其中:
import re
import requests
from lxml.etree import HTML
response = requests.get('http://www.debian.org/releases/stable/')
root = HTML(response.content)
title_text = root.find('head').find('title').text
release = re.search('\u201c(.*)\u201d', title_text).group(1)
p_text = root.xpath('//div[@id="content"]/p[1]')[0].text
version = p_text.split()[1]
print('Codename: {}\nVersion: {}'.format(release, version))
在这里,我们通过 XPath 下载和解析了网页,通过 XPath 提取我们想要的文本。我们使用了正则表达式来提取jessie,并使用split来提取版本 8.0。最后我们将其打印出来。
因此,像这里显示的那样运行它:
**$ python3.4 get_debian_version.py**
**Codename: jessie**
**Version: 8.0**
了不起。至少非常巧妙。有一些第三方包可用于加快抓取和表单提交的速度,其中两个流行的包是 Mechanize 和 Scrapy。请在wwwsearch.sourceforge.net/mechanize/和scrapy.org上查看它们。
伟大的力量……
作为 HTTP 客户端开发人员,您可能有不同的优先级,与运行网站的网络管理员不同。网络管理员通常会为人类用户提供网站;可能提供旨在产生收入的服务,并且很可能所有这些都需要在非常有限的资源的帮助下完成。他们将对分析人类如何使用他们的网站感兴趣,并且可能有他们希望自动客户端不要探索的网站区域。
自动解析和下载网站页面的 HTTP 客户端被称为各种各样的东西,比如机器人、网络爬虫和蜘蛛。机器人有许多合法的用途。所有的搜索引擎提供商都大量使用机器人来爬取网页并构建他们庞大的页面索引。机器人可以用来检查死链接,并为存储库存档网站,比如 Wayback Machine。但是,也有许多可能被认为是非法的用途。自动遍历信息服务以提取其页面上的数据,然后在未经网站所有者许可的情况下重新打包这些数据以在其他地方展示,一次性下载大批量的媒体文件,而服务的精神是在线查看等等,这些都可能被认为是非法的。一些网站有明确禁止自动下载的服务条款。尽管一些行为,比如复制和重新发布受版权保护的材料,显然是非法的,但其他一些行为则需要解释。这个灰色地带是一个持续辩论的话题,而且不太可能会得到所有人的满意解决。
然而,即使它们确实有合法的目的,总的来说,机器人确实使网站所有者的生活变得更加困难。它们污染了 Web 服务器日志,而网站所有者用这些日志来计算他们的人类受众如何使用他们的网站的统计数据。机器人还会消耗带宽和其他服务器资源。
使用本章中我们正在研究的方法,编写一个执行许多前述功能的机器人是非常简单的。网站所有者为我们提供了我们将要使用的服务,因此,作为回报,我们应该尊重上述领域,并设计我们的机器人,使它们对他们的影响尽可能小。
选择用户代理
我们可以做一些事情来帮助我们的网站所有者。我们应该为我们的客户端选择一个合适的用户代理。网站所有者从日志文件中过滤出机器人流量的主要方法是通过用户代理分析。
有已知机器人的用户代理列表,例如,可以在www.useragentstring.com/pages/Crawlerlist/找到这样的列表。
网站所有者可以在他们的过滤器中使用这些。许多网站所有者也会简单地过滤掉包含bot、spider或crawler等词的用户代理。因此,如果我们编写的是一个自动化机器人而不是一个浏览器,那么如果我们使用包含这些词中的一个的用户代理,那么这将使网站所有者的生活变得更加轻松。搜索引擎提供商使用的许多机器人都遵循这个惯例,这里列举了一些例子:
-
Mozilla/5.0compatible; bingbot/2.0; http://www.bing.com/bingbot.htm -
Baiduspider: http://www.baidu.com/search/spider.htm -
Mozilla/5.0 compatible; Googlebot/2.1; http://www.google.com/bot.html
在 HTTP RFC 7231 的第 5.5.3 节中也有一些指南。
Robots.txt 文件
有一个非官方但标准的机制,可以告诉机器人网站的哪些部分不应该被爬取。这个机制称为robots.txt,它采用一个名为robots.txt的文本文件的形式。这个文件总是位于网站的根目录,以便机器人总是可以找到它。它包含描述网站可访问部分的规则。文件格式在www.robotstxt.org中有描述。
Python 标准库提供了urllib.robotparser模块,用于解析和处理robots.txt文件。您可以创建一个解析器对象,将robots.txt文件传递给它,然后可以简单地查询它,以查看给定用户代理是否允许给定 URL。在标准库的文档中可以找到一个很好的例子。如果您在访问之前检查客户端可能想要访问的每个 URL,并遵守网站所有者的意愿,那么您将会帮助他们。
最后,由于我们可能会频繁地进行请求来测试我们新建的客户端,最好是在本地复制你想让客户端解析和测试的网页或文件。这样,我们既可以为自己节省带宽,也可以为网站节省带宽。
总结
在本章中,我们涵盖了很多内容,但现在你应该能够开始真正利用你遇到的 Web API 了。
我们研究了 XML,如何构建文档,解析它们并通过使用ElementTree API 从中提取数据。我们研究了 Python 的ElementTree实现和lxml。我们还研究了 XPath 查询语言如何有效地从文档中提取信息。
我们研究了 Amazon S3 服务,并编写了一个客户端,让我们可以执行基本操作,比如创建存储桶,通过 S3 REST API 上传和下载文件。我们学习了如何设置访问权限和内容类型,使文件在 Web 浏览器中正常工作。
我们讨论了 JSON 数据格式,如何将 Python 对象转换为 JSON 数据格式,以及如何将它们转换回 Python 对象。
然后,我们探索了 Twitter API,并编写了一个按需的世界时钟服务,通过这个服务,我们学会了如何阅读和处理账户的推文,以及如何发送推文作为回复。
我们看到了如何从网页的 HTML 源代码中提取信息。我们学习了在使用ElementTree和lxml HTML 解析器时如何处理 HTML。我们还学习了如何使用 XPath 来帮助使这个过程更加高效。
最后,我们研究了如何回报给为我们提供所有数据的网站管理员。我们讨论了一些编写客户端的方式,使网站管理员的生活变得更轻松,并尊重他们希望我们如何使用他们的网站。
所以,暂时就介绍这么多关于 HTTP 了。我们将在第九章中重新讨论 HTTP,Web 应用程序,届时我们将学习如何使用 Python 构建 Web 应用程序的服务器端。在下一章中,我们将讨论互联网的另一个重要工具:电子邮件。
第四章: 与电子邮件互动
电子邮件是数字通信最流行的方式之一。Python 有丰富的内置库用于处理电子邮件。在本章中,我们将学习如何使用 Python 来撰写、发送和检索电子邮件。本章将涵盖以下主题:
-
通过
smtplib库使用 SMTP 发送电子邮件 -
使用 TLS 保护电子邮件传输
-
使用
poplib通过 POP3 检索电子邮件 -
使用
imapclient通过 IMAP 检索电子邮件 -
使用 IMAP 在服务器上操作电子邮件
-
使用
logging模块发送电子邮件
电子邮件术语
在我们开始使用 Python 撰写第一封电子邮件之前,让我们重新审视一些电子邮件的基本概念。通常,最终用户使用软件或图形用户界面(GUI)来撰写、发送和接收电子邮件。这种软件称为电子邮件客户端,例如 Mozilla Thunderbird、Microsoft Outlook 等都是电子邮件客户端。同样的任务也可以通过 Web 界面完成,即 Web 邮件客户端界面。一些常见的例子包括:Gmail、Yahoo 邮件、Hotmail 等。
您从客户端界面发送的邮件不会直接到达接收者的计算机。您的邮件会经过多个专用电子邮件服务器。这些服务器运行一个名为邮件传输代理(MTA)的软件,其主要工作是通过分析邮件头等内容将电子邮件路由到适当的目的地。
还有许多其他事情发生在路上,然后邮件到达收件人的本地电子邮件网关。然后,收件人可以使用他或她的电子邮件客户端检索电子邮件。
上述过程涉及一些协议。其中最常见的已列在这里:
-
简单邮件传输协议(SMTP):MTA 使用 SMTP 协议将您的电子邮件传递到收件人的电子邮件服务器。SMTP 协议只能用于从一个主机发送电子邮件到另一个主机。
-
邮局协议 3(POP3):POP3 协议为用户提供了一种简单和标准化的方式,以便访问邮箱,然后将邮件下载到他们的计算机上。使用 POP3 协议时,您的电子邮件消息将从互联网服务提供商(ISP)的邮件服务器下载到本地计算机。您还可以将电子邮件的副本留在 ISP 服务器上。
-
互联网消息访问协议(IMAP):IMAP 协议还提供了一种简单和标准化的方式,用于从 ISP 的本地服务器访问您的电子邮件。IMAP 是一种客户端/服务器协议,其中电子邮件由 ISP 接收并保存。由于这只需要进行少量数据传输,即使在较慢的连接(如手机网络)上,这种方案也能很好地工作。只有当您发送请求读取特定的电子邮件时,该电子邮件消息才会从 ISP 下载。您还可以做一些其他有趣的事情,比如在服务器上创建和操作文件夹或邮箱、删除消息等。
Python 有三个模块,smtplib、poplib和imaplib,分别支持 SMTP、POP3 和 IMAP 协议。每个模块都有选项,可以使用传输层安全(TLS)协议安全地传输信息。每个协议还使用某种形式的身份验证来确保数据的保密性。
使用 SMTP 发送电子邮件
我们可以使用smtplib和e-mail包从 Python 脚本发送电子邮件。smtplib模块提供了一个 SMTP 对象,用于使用 SMTP 或扩展 SMTP(ESMTP)协议发送邮件。e-mail模块帮助我们构造电子邮件消息,并使用各种标题信息和附件。该模块符合tools.ietf.org/html/rfc2822.html中描述的Internet Message Format(IMF)。
撰写电子邮件消息
让我们使用email模块中的类构造电子邮件消息。email.mime模块提供了从头开始创建电子邮件和 MIME 对象的类。MIME是多用途互联网邮件扩展的缩写。这是原始互联网电子邮件协议的扩展。这被广泛用于交换不同类型的数据文件,如音频、视频、图像、应用程序等。
许多类都是从 MIME 基类派生的。我们将使用一个 SMTP 客户端脚本,使用email.mime.multipart.MIMEMultipart()类作为示例。它接受通过关键字字典传递电子邮件头信息。让我们看看如何使用MIMEMultipart()对象指定电子邮件头。多部分 mime 指的是在单个电子邮件中发送 HTML 和 TEXT 部分。当电子邮件客户端接收多部分消息时,如果可以呈现 HTML,它将接受 HTML 版本,否则它将呈现纯文本版本,如下面的代码块所示:
from email.mime.multipart import MIMEMultipart()
msg = MIMEMultipart()
msg['To'] = recipient
msg['From'] = sender
msg['Subject'] = 'Email subject..'
现在,将纯文本消息附加到此多部分消息对象。我们可以使用MIMEText()对象来包装纯文本消息。这个类的构造函数接受额外的参数。例如,我们可以将text和plain作为它的参数。可以使用set_payload()方法设置此消息的数据,如下所示:
part = MIMEText('text', 'plain')
message = 'Email message ….'
part.set_payload(message)
现在,我们将将纯文本消息附加到多部分消息中,如下所示:
msg.attach(part)
该消息已准备好通过一个或多个 SMTP MTA 服务器路由到目标邮件服务器。但是,显然,脚本只与特定的 MTA 通信,而该 MTA 处理消息的路由。
发送电子邮件消息
smtplib模块为我们提供了一个 SMTP 类,可以通过 SMTP 服务器套接字进行初始化。成功初始化后,这将为我们提供一个 SMTP 会话对象。SMTP 客户端将与服务器建立适当的 SMTP 会话。这可以通过为 SMTPsession对象使用ehlo()方法来完成。实际的消息发送将通过将sendmail()方法应用于 SMTP 会话来完成。因此,典型的 SMTP 会话将如下所示:
session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
session.ehlo()
session.sendmail(sender, recipient, msg.as_string())
session.quit()
在我们的示例 SMTP 客户端脚本中,我们使用了谷歌的免费 Gmail 服务。如果您有 Gmail 帐户,那么您可以通过 SMTP 从 Python 脚本发送电子邮件到该帐户。您的电子邮件可能会被最初阻止,因为 Gmail 可能会检测到它是从不太安全的电子邮件客户端发送的。您可以更改 Gmail 帐户设置,并启用您的帐户以从不太安全的电子邮件客户端发送/接收电子邮件。您可以在 Google 网站上了解有关从应用程序发送电子邮件的更多信息,网址为support.google.com/a/answer/176600?hl=en。
如果您没有 Gmail 帐户,则可以在典型的 Linux 框中使用本地 SMTP 服务器设置并运行此脚本。以下代码显示了如何通过公共 SMTP 服务器发送电子邮件:
#!/usr/bin/env python3
# Listing 1 – First email client
import smtplib
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
SMTP_SERVER = 'aspmx.l.google.com'
SMTP_PORT = 25
def send_email(sender, recipient):
""" Send email message """
msg = MIMEMultipart()
msg['To'] = recipient
msg['From'] = sender
subject = input('Enter your email subject: ')
msg['Subject'] = subject
message = input('Enter your email message. Press Enter when finished. ')
part = MIMEText('text', "plain")
part.set_payload(message)
msg.attach(part)
# create smtp session
session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
session.ehlo()
#session.set_debuglevel(1)
# send mail
session.sendmail(sender, recipient, msg.as_string())
print("You email is sent to {0}.".format(recipient))
session.quit()
if __name__ == '__main__':
sender = input("Enter sender email address: ")
recipient = input("Enter recipient email address: ")
send_email(sender, recipient)
如果您运行此脚本,则可以看到输出与此处提到的类似。出于匿名性考虑,在以下示例中未显示真实的电子邮件地址:
**$ python3 smtp_mail_sender.py**
**Enter sender email address: <SENDER>@gmail.com**
**Enter recipeint email address: <RECEIVER>@gmail.com**
**Enter your email subject: Test mail**
**Enter your email message. Press Enter when finished. This message can be ignored**
**You email is sent to <RECEIVER>@gmail.com.**
这个脚本将使用 Python 的标准库模块smtplib发送一个非常简单的电子邮件消息。为了构成消息,从email.mime子模块导入了MIMEMultipart和MIMEText类。这个子模块有各种类型的类,用于以不同类型的附件组成电子邮件消息,例如MIMEApplication()、MIMEAudio()、MIMEImage()等。
在这个例子中,send_mail()函数被调用了两个参数:发件人和收件人。这两个参数都是电子邮件地址。电子邮件消息是由MIMEMultipart()消息类构造的。这个类命名空间中添加了To、From和Subject等基本标头。消息的正文是由MIMEText()类的实例组成的。这是通过set_payload()方法完成的。然后,这个有效载荷通过attach()方法附加到主消息上。
为了与 SMTP 服务器通信,将通过实例化smtplib模块的SMTP()类创建与服务器的会话。服务器名称和端口参数将传递给构造函数。根据 SMTP 协议,客户端将通过ehlo()方法向服务器发送扩展的问候消息。消息将通过sendmail()方法发送。
请注意,如果在 SMTP 会话对象上调用set_debuglevel()方法,它将产生额外的调试消息。在前面的例子中,这行被注释掉了。取消注释该行将产生类似以下的调试消息:
**$ python3 smtp_mail_sender.py**
**Enter sender email address: <SENDER>@gmail.com**
**Enter recipeint email address: <RECEIVER>@gmail.com**
**Enter your**
**email subject: Test email**
**Enter your email message. Press Enter when finished. This is a test email**
**send: 'mail FROM:<SENDER@gmail.com> size=339\r\n'**
**reply: b'250 2.1.0 OK hg2si4622244wib.38 - gsmtp\r\n'**
**reply: retcode (250); Msg: b'2.1.0 OK hg2si4622244wib.38 - gsmtp'**
**send: 'rcpt TO:<RECEIVER@gmail.com>\r\n'**
**reply: b'250 2.1.5 OK hg2si4622244wib.38 - gsmtp\r\n'**
**reply: retcode (250); Msg: b'2.1.5 OK hg2si4622244wib.38 - gsmtp'**
**send: 'data\r\n'**
**reply: b'354 Go ahead hg2si4622244wib.38 - gsmtp\r\n'**
**reply: retcode (354); Msg: b'Go ahead hg2si4622244wib.38 - gsmtp'**
**data: (354, b'Go ahead hg2si4622244wib.38 - gsmtp')**
**send: 'Content-Type: multipart/mixed;
boundary="===============1431208306=="\r\nMIME-Version: 1.0\r\nTo: RECEIVER@gmail.com\r\nFrom: SENDER@gmail.com\r\nSubject: Test email\r\n\r\n--===============1431208306==\r\nContent-Type: text/plain; charset="us-ascii"\r\nMIME-Version: 1.0\r\nContent- Transfer-Encoding: 7bit\r\n\r\nThis is a test email\r\n-- ===============1431208306==--\r\n.\r\n'**
**reply: b'250 2.0.0 OK 1414233177 hg2si4622244wib.38 - gsmtp\r\n'**
**reply: retcode (250); Msg: b'2.0.0 OK 1414233177 hg2si4622244wib.38 - gsmtp'**
**data: (250, b'2.0.0 OK 1414233177 hg2si4622244wib.38 - gsmtp')**
**You email is sent to RECEIVER@gmail.com.**
**send: 'quit\r\n'**
**reply: b'221 2.0.0 closing connection hg2si4622244wib.38 - gsmtp\r\n'**
**reply: retcode (221); Msg: b'2.0.0 closing connection hg2si4622244wib.38 - gsmtp'**
这很有趣,因为消息是通过逐步方式通过公共 SMTP 服务器发送的。
使用 TLS 安全地发送电子邮件
TLS 协议是 SSL 或安全套接字层的后继者。这确保了客户端和服务器之间的通信是安全的。这是通过以加密格式发送消息来实现的,以便未经授权的人无法看到消息。使用smtplib使用 TLS 并不困难。创建 SMTP 会话对象后,需要调用starttls()方法。在发送电子邮件之前,需要使用 SMTP 服务器凭据登录到服务器。
这是第二个电子邮件客户端的示例:
#!/usr/bin/env python3
# Listing 2
import getpass
import smtplib
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
SMTP_SERVER = 'smtp.gmail.com'
SMTP_PORT = 587 # ssl port 465, tls port 587
def send_email(sender, recipient):
""" Send email message """
msg = MIMEMultipart()
msg['To'] = recipient
msg['From'] = sender
msg['Subject'] = input('Enter your email subject: ')
message = input('Enter your email message. Press Enter when finished. ')
part = MIMEText('text', "plain")
part.set_payload(message)
msg.attach(part)
# create smtp session
session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
session.set_debuglevel(1)
session.ehlo()
session.starttls()
session.ehlo
password = getpass.getpass(prompt="Enter you email password: ")
# login to server
session.login(sender, password)
# send mail
session.sendmail(sender, recipient, msg.as_string())
print("You email is sent to {0}.".format(recipient))
session.quit()
if __name__ == '__main__':
sender = input("Enter sender email address: ")
recipient = input("Enter recipeint email address: ")
send_email(sender, recipient)
前面的代码与我们的第一个例子类似,只是对服务器进行了身份验证。在这种情况下,SMTP 用户会被服务器验证。如果我们在打开 SMTP 调试后运行脚本,那么我们将看到类似以下的输出:
**$ python3 smtp_mail_sender_tls.py**
**Enter sender email address: SENDER@gmail.com**
**Enter recipeint email address: RECEPIENT@gmail.com**
**Enter your email subject: Test email**
**Enter your email message. Press Enter when finished. This is a test email that can be ignored.**
用户输入后,将开始与服务器的通信。它将通过ehlo()方法开始。作为对这个命令的响应,SMTP 服务器将发送几行带有返回代码250的响应。这个响应将包括服务器支持的特性。
这些响应的摘要将表明服务器准备好与客户端继续,如下所示:
**send: 'ehlo debian6box.localdomain.loc\r\n'**
**reply: b'250-mx.google.com at your service, [77.233.155.107]\r\n'**
**reply: b'250-SIZE 35882577\r\n'**
**reply: b'250-8BITMIME\r\n'**
**reply: b'250-STARTTLS\r\n'**
**reply: b'250-ENHANCEDSTATUSCODES\r\n'**
**reply: b'250-PIPELINING\r\n'**
**reply: b'250-CHUNKING\r\n'**
**reply: b'250 SMTPUTF8\r\n'**
**reply: retcode (250); Msg: b'mx.google.com at your service, [77.233.155.107]\nSIZE 35882577\n8BITMIME\nSTARTTLS\nENHANCEDSTATUSCODES\nPIPELINING\ nCHUNKING\nSMTPUTF8'**
在初始命令之后,客户端将使用starttls()方法将连接升级到 TLS,如下所示:
**send: 'STARTTLS\r\n'**
**reply: b'220 2.0.0 Ready to start TLS\r\n'**
**reply: retcode (220); Msg: b'2.0.0 Ready to start TLS'**
**Enter you email password:**
**send: 'ehlo debian6box.localdomain.loc\r\n'**
**reply: b'250-mx.google.com at your service, [77.233.155.107]\r\n'**
**reply: b'250-SIZE 35882577\r\n'**
**reply: b'250-8BITMIME\r\n'**
**reply: b'250-AUTH LOGIN PLAIN XOAUTH XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER\r\n'**
**reply: b'250-ENHANCEDSTATUSCODES\r\n'**
**reply: b'250-PIPELINING\r\n'**
**reply: b'250-CHUNKING\r\n'**
**reply: b'250 SMTPUTF8\r\n'**
**reply: retcode (250); Msg: b'mx.google.com at your service, [77.233.155.107]\nSIZE 35882577\n8BITMIME\nAUTH LOGIN PLAIN XOAUTH XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER\nENHANCEDSTATUSCODES\nPIPELINING\nCHUNKING\nSMTPUTF8'**
在认证阶段,客户端脚本通过login()方法发送认证数据。请注意,认证令牌是一个 base-64 编码的字符串,用户名和密码之间用空字节分隔。还有其他支持的身份验证协议适用于复杂的客户端。以下是认证令牌的示例:
**send: 'AUTH PLAIN A...dvXXDDCCD.......sscdsvsdvsfd...12344555\r\n'**
**reply: b'235 2.7.0 Accepted\r\n'**
**reply: retcode (235); Msg: b'2.7.0 Accepted'**
客户端经过认证后,可以使用sendmail()方法发送电子邮件消息。这个方法传递了三个参数,发件人、收件人和消息。示例输出如下:
**send: 'mail FROM:<SENDER@gmail.com> size=360\r\n'**
**reply: b'250 2.1.0 OK xw9sm8487512wjc.24 - gsmtp\r\n'**
**reply: retcode (250); Msg: b'2.1.0 OK xw9sm8487512wjc.24 - gsmtp'**
**send: 'rcpt TO:<RECEPIENT@gmail.com>\r\n'**
**reply: b'250 2.1.5 OK xw9sm8487512wjc.24 - gsmtp\r\n'**
**reply: retcode (250); Msg: b'2.1.5 OK xw9sm8487512wjc.24 - gsmtp'**
**send: 'data\r\n'**
**reply: b'354 Go ahead xw9sm8487512wjc.24 - gsmtp\r\n'**
**reply: retcode (354); Msg: b'Go ahead xw9sm8487512wjc.24 - gsmtp'**
**data: (354, b'Go ahead xw9sm8487512wjc.24 - gsmtp')**
**send: 'Content-Type: multipart/mixed; boundary="===============1501937935=="\r\nMIME-Version: 1.0\r\n**
**To: <Output omitted>-===============1501937935==--\r\n.\r\n'**
**reply: b'250 2.0.0 OK 1414235750 xw9sm8487512wjc.24 - gsmtp\r\n'**
**reply: retcode (250); Msg: b'2.0.0 OK 1414235750 xw9sm8487512wjc.24 - gsmtp'**
**data: (250, b'2.0.0 OK 1414235750 xw9sm8487512wjc.24 - gsmtp')**
**You email is sent to RECEPIENT@gmail.com.**
**send: 'quit\r\n'**
**reply: b'221 2.0.0 closing connection xw9sm8487512wjc.24 - gsmtp\r\n'**
**reply: retcode (221); Msg: b'2.0.0 closing connection xw9sm8487512wjc.24 - gsmtp'**
使用 poplib 通过 POP3 检索电子邮件
存储的电子邮件消息可以通过本地计算机下载和阅读。 POP3 协议可用于从电子邮件服务器下载消息。 Python 有一个名为poplib的模块,可以用于此目的。 此模块提供了两个高级类,POP()和POP3_SSL(),它们分别实现了与 POP3/POP3S 服务器通信的 POP3 和 POP3S 协议。 它接受三个参数,主机、端口和超时。 如果省略端口,则可以使用默认端口(110)。 可选的超时参数确定服务器上的连接超时长度(以秒为单位)。
POP3()的安全版本是其子类POP3_SSL()。 它接受附加参数,例如 keyfile 和 certfile,用于提供 SSL 证书文件,即私钥和证书链文件。
编写 POP3 客户端也非常简单。 要做到这一点,通过初始化POP3()或POP3_SSL()类来实例化一个邮箱对象。 然后,通过以下命令调用user()和pass_()方法登录到服务器:
mailbox = poplib.POP3_SSL(<POP3_SERVER>, <SERVER_PORT>)
mailbox.user('username')
mailbox.pass_('password')
现在,您可以调用各种方法来操作您的帐户和消息。 这里列出了一些有趣的方法:
-
stat(): 此方法根据两个整数的元组返回邮箱状态,即消息计数和邮箱大小。 -
list(): 此方法发送一个请求以获取消息列表,这在本节后面的示例中已经演示过。 -
retr(): 此方法给出一个参数消息编号,表示要检索的消息。 它还标记消息为已读。 -
dele(): 此方法提供了要删除的消息的参数。 在许多 POP3 服务器上,直到 QUIT 才执行删除操作。 您可以使用rset()方法重置删除标志。 -
quit(): 此方法通过提交一些更改并将您从服务器断开连接来使您脱离连接。
让我们看看如何通过访问谷歌的安全 POP3 电子邮件服务器来读取电子邮件消息。 默认情况下,POP3 服务器在端口995上安全监听。 以下是使用 POP3 获取电子邮件的示例:
#!/usr/bin/env python3
import getpass
import poplib
GOOGLE_POP3_SERVER = 'pop.googlemail.com'
POP3_SERVER_PORT = '995'
def fetch_email(username, password):
mailbox = poplib.POP3_SSL(GOOGLE_POP3_SERVER, POP3_SERVER_PORT)
mailbox.user(username)
mailbox.pass_(password)
num_messages = len(mailbox.list()[1])
print("Total emails: {0}".format(num_messages))
print("Getting last message")
for msg in mailbox.retr(num_messages)[1]:
print(msg)
mailbox.quit()
if __name__ == '__main__':
username = input("Enter your email user ID: ")
password = getpass.getpass(prompt="Enter your email password: ")
fetch_email(username, password)
正如您在前面的代码中所看到的,fetch_email()函数通过调用POP3_SSL()以及服务器套接字创建了一个邮箱对象。 通过调用user()和pass_()方法在此对象上设置了用户名和密码。 成功验证后,我们可以通过使用list()方法调用 POP3 命令。 在此示例中,消息的总数已显示在屏幕上。 然后,使用retr()方法检索了单个消息的内容。
这里显示了一个示例输出:
**$ python3 fetch_email_pop3.py**
**Enter your email user ID: <PERSON1>@gmail.com**
**Enter your email password:**
**Total emails: 330**
**Getting last message**
**b'Received: by 10.150.139.7 with HTTP; Tue, 7 Oct 2008 13:20:42 -0700**
**(PDT)'**
**b'Message-ID: <fc9dd8650810...@mail.gmail.com>'**
**b'Date: Tue, 7 Oct 2008 21:20:42 +0100'**
**b'From: "Mr Person1" <PERSON1@gmail.com>'**
**b'To: "Mr Person2" <PERSON2@gmail.com>'**
**b'Subject: Re: Some subject'**
**b'In-Reply-To: <1bec119d...@mail.gmail.com>'**
**b'MIME-Version: 1.0'**
**b'Content-Type: multipart/alternative; '**
**b'\tboundary="----=_Part_63057_22732713.1223410842697"'**
**b'References: <fc9dd8650809270....@mail.gmail.com>'**
**b'\t <1bec119d0810060337p557bc....@mail.gmail.com>'**
**b'Delivered-To: PERSON1@gmail.com'**
**b''**
**b'------=_Part_63057_22732713.1223410842697'**
**b'Content-Type: text/plain; charset=ISO-8859-1'**
**b'Content-Transfer-Encoding: quoted-printable'**
**b'Content-Disposition: inline'**
**b''**
**b'Dear Person2,'**
使用 imaplib 通过 IMAP 检索电子邮件
正如我们之前提到的,通过 IMAP 协议访问电子邮件不一定会将消息下载到本地计算机或手机。 因此,即使在任何低带宽互联网连接上使用,这也可以非常高效。
Python 提供了一个名为imaplib的客户端库,可用于通过 IMAP 协议访问电子邮件。 这提供了实现 IMAP 协议的IMAP4()类。 它接受两个参数,即用于实现此协议的主机和端口。 默认情况下,143已被用作端口号。
派生类IMAP4_SSL()提供了 IMAP4 协议的安全版本。 它通过 SSL 加密套接字连接。 因此,您将需要一个 SSL 友好的套接字模块。 默认端口是993。 与POP3_SSL()类似,您可以提供私钥和证书文件路径。
可以在这里看到 IMAP 客户端的典型示例:
mailbox = imaplib.IMAP4_SSL(<IMAP_SERVER>, <SERVER_PORT>)
mailbox.login('username', 'password')
mailbox.select('Inbox')
上述代码将尝试启动一个 IMAP4 加密客户端会话。在login()方法成功之后,您可以在创建的对象上应用各种方法。在上述代码片段中,使用了select()方法。这将选择用户的邮箱。默认邮箱称为Inbox。此邮箱对象支持的方法的完整列表可在 Python 标准库文档页面上找到,网址为docs.python.org/3/library/imaplib.html。
在这里,我们想演示如何使用search()方法搜索邮箱。它接受字符集和搜索条件参数。字符集参数可以是None,其中将向服务器发送不带特定字符的请求。但是,至少需要指定一个条件。为了执行高级搜索以对消息进行排序,可以使用sort()方法。
与 POP3 类似,我们将使用安全的 IMAP 连接来连接到服务器,使用IMAP4_SSL()类。以下是一个 Python IMAP 客户端的简单示例:
#!/usr/bin/env python3
import getpass
import imaplib
import pprint
GOOGLE_IMAP_SERVER = 'imap.googlemail.com'
IMAP_SERVER_PORT = '993'
def check_email(username, password):
mailbox = imaplib.IMAP4_SSL(GOOGLE_IMAP_SERVER, IMAP_SERVER_PORT)
mailbox.login(username, password)
mailbox.select('Inbox')
tmp, data = mailbox.search(None, 'ALL')
for num in data[0].split():
tmp, data = mailbox.fetch(num, '(RFC822)')
print('Message: {0}\n'.format(num))
pprint.pprint(data[0][1])
break
mailbox.close()
mailbox.logout()
if __name__ == '__main__':
username = input("Enter your email username: ")
password = getpass.getpass(prompt="Enter you account password: ")
check_email(username, password)
在此示例中,创建了IMPA4_SSL()的实例,即邮箱对象。在其中,我们将服务器地址和端口作为参数。成功使用login()方法登录后,您可以使用select()方法选择要访问的邮箱文件夹。在此示例中,选择了Inbox文件夹。为了阅读消息,我们需要从收件箱请求数据。其中一种方法是使用search()方法。在成功接收一些邮件元数据后,我们可以使用fetch()方法检索电子邮件消息信封部分和数据。在此示例中,使用fetch()方法寻找了 RFC 822 类型的标准文本消息。我们可以使用 Python 的 pretty print 或 print 模块在屏幕上显示输出。最后,将close()和logout()方法应用于邮箱对象。
上述代码将显示类似以下内容的输出:
$ python3 fetch_email_imap.py
Enter your email username: RECIPIENT@gmail.comn
Enter you Google password:
Message b'1'
b'X-Gmail-Received: 3ec65fa310559efe27307d4e37fdc95406deeb5a\r\nDelivered-To: RECIPIENT@gmail.com\r\nReceived: by 10.54.40.10 with SMTP id n10cs1955wrn;\r\n [Message omitted]
发送电子邮件附件
在前面的部分中,我们已经看到如何使用 SMTP 协议发送纯文本消息。在本节中,让我们探讨如何通过电子邮件消息发送附件。我们可以使用我们的第二个示例,其中我们使用了 TLS 发送电子邮件。在撰写电子邮件消息时,除了添加纯文本消息,还包括附加附件字段。
在此示例中,我们可以使用email.mime.image子模块的MIMEImage类型。一个 GIF 类型的图像将附加到电子邮件消息中。假设可以在文件系统路径的任何位置找到 GIF 图像。该文件路径通常基于用户输入。
以下示例显示了如何在电子邮件消息中发送附件:
#!/usr/bin/env python3
import os
import getpass
import re
import sys
import smtplib
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
SMTP_SERVER = 'aspmx.l.google.com'
SMTP_PORT = 25
def send_email(sender, recipient):
""" Sends email message """
msg = MIMEMultipart()
msg['To'] = recipient
msg['From'] = sender
subject = input('Enter your email subject: ')
msg['Subject'] = subject
message = input('Enter your email message. Press Enter when finished. ')
part = MIMEText('text', "plain")
part.set_payload(message)
msg.attach(part)
# attach an image in the current directory
filename = input('Enter the file name of a GIF image: ')
path = os.path.join(os.getcwd(), filename)
if os.path.exists(path):
img = MIMEImage(open(path, 'rb').read(), _subtype="gif")
img.add_header('Content-Disposition', 'attachment', filename=filename)
msg.attach(img)
# create smtp session
session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
session.ehlo()
session.starttls()
session.ehlo
# send mail
session.sendmail(sender, recipient, msg.as_string())
print("You email is sent to {0}.".format(recipient))
session.quit()
if __name__ == '__main__':
sender = input("Enter sender email address: ")
recipient = input("Enter recipeint email address: ")
send_email(sender, recipient)
如果运行上述脚本,它将询问通常的内容,即电子邮件发送者、收件人、用户凭据和图像文件的位置。
**$ python3 smtp_mail_sender_mime.py**
**Enter sender email address: SENDER@gmail.com**
**Enter recipeint email address: RECIPIENT@gmail.com**
**Enter your email subject: Test email with attachment**
**Enter your email message. Press Enter when finished. This is a test email with atachment.**
**Enter the file name of a GIF image: image.gif**
**You email is sent to RECIPIENT@gmail.com.**
通过日志模块发送电子邮件
在任何现代编程语言中,都提供了常见功能的日志记录设施。同样,Python 的日志模块在功能和灵活性上非常丰富。我们可以使用日志模块的不同类型的日志处理程序,例如控制台或文件日志处理程序。您可以最大化日志记录的好处的一种方法是在生成日志时将日志消息通过电子邮件发送给用户。Python 的日志模块提供了一种称为BufferingHandler的处理程序类型,它能够缓冲日志数据。
稍后显示了扩展BufferingHandler的示例。通过BufferingHandler定义了一个名为BufferingSMTPHandler的子类。在此示例中,使用日志模块创建了一个记录器对象的实例。然后,将BufferingSMTPHandler的实例绑定到此记录器对象。将日志级别设置为 DEBUG,以便记录任何消息。使用了一个包含四个单词的示例列表来创建四个日志条目。每个日志条目应类似于以下内容:
**<Timestamp> INFO First line of log**
**This accumulated log message will be emailed to a local user as set on top of the script.**
现在,让我们来看一下完整的代码。以下是使用日志模块发送电子邮件的示例:
import logging.handlers
import getpass
MAILHOST = 'localhost'
FROM = 'you@yourdomain'
TO = ['%s@localhost' %getpass.getuser()]
SUBJECT = 'Test Logging email from Python logging module (buffering)'
class BufferingSMTPHandler(logging.handlers.BufferingHandler):
def __init__(self, mailhost, fromaddr, toaddrs, subject, capacity):
logging.handlers.BufferingHandler.__init__(self, capacity)
self.mailhost = mailhost
self.mailport = None
self.fromaddr = fromaddr
self.toaddrs = toaddrs
self.subject = subject
self.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s %(message)s"))
def flush(self):
if len(self.buffer) > 0:
try:
import smtplib
port = self.mailport
if not port:
port = smtplib.SMTP_PORT
smtp = smtplib.SMTP(self.mailhost, port)
msg = "From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n" % (self.fromaddr, ",".join(self.toaddrs), self.subject)
for record in self.buffer:
s = self.format(record)
print(s)
msg = msg + s + "\r\n"
smtp.sendmail(self.fromaddr, self.toaddrs, msg)
smtp.quit()
except:
self.handleError(None) # no particular record
self.buffer = []
def test():
logger = logging.getLogger("")
logger.setLevel(logging.DEBUG)
logger.addHandler(BufferingSMTPHandler(MAILHOST, FROM, TO, SUBJECT, 10))
for data in ['First', 'Second', 'Third', 'Fourth']:
logger.info("%s line of log", data)
logging.shutdown()
if __name__ == "__main__":
test()
如您所见,我们的BufferingSMTPHandler方法只覆盖了一个方法,即flush()。在构造函数__init__()中,设置了基本变量以及使用setFormatter()方法设置了日志格式。在flush()方法中,我们创建了一个SMTP()对象的实例。使用可用数据创建了 SMTP 消息头。将日志消息附加到电子邮件消息,并调用sendmail()方法发送电子邮件消息。flush()方法中的代码包裹在try-except块中。
所讨论的脚本的输出将类似于以下内容:
**$ python3 logger_mail_send.py**
**2014-10-25 13:15:07,124 INFO First line of log**
**2014-10-25 13:15:07,127 INFO Second line of log**
**2014-10-25 13:15:07,127 INFO Third line of log**
**2014-10-25 13:15:07,129 INFO Fourth line of log**
现在,当您使用电子邮件命令(Linux/UNIX 机器上的本机命令)检查电子邮件消息时,您可以期望本地用户已收到电子邮件,如下所示:
**$ mail**
**Mail version 8.1.2 01/15/2001\. Type ? for help.**
**"/var/mail/faruq": 1 message 1 new**
**>N 1 you@yourdomain Sat Oct 25 13:15 20/786 Test Logging email from Python logging module (buffering)**
您可以通过在命令提示符上输入消息 ID 和&来查看消息的内容,如下输出所示:
**& 1**
**Message 1:**
**From you@yourdomain Sat Oct 25 13:15:08 2014**
**Envelope-to: faruq@localhost**
**Delivery-date: Sat, 25 Oct 2014 13:15:08 +0100**
**Date: Sat, 25 Oct 2014 13:15:07 +0100**
**From: you@yourdomain**
**To: faruq@localhost**
**Subject: Test Logging email from Python logging module (buffering)**
**2014-10-25 13:15:07,124 INFO First line of log**
**2014-10-25 13:15:07,127 INFO Second line of log**
**2014-10-25 13:15:07,127 INFO Third line of log**
**2014-10-25 13:15:07,129 INFO Fourth line of log**
最后,您可以通过在命令提示符上输入快捷键q来退出邮件程序,如下所示:
**& q**
**Saved 1 message in /home/faruq/mbox**
总结
本章演示了 Python 如何与三种主要的电子邮件处理协议交互:SMTP、POP3 和 IMAP。在每种情况下,都解释了客户端代码的工作方式。最后,展示了在 Python 的日志模块中使用 SMTP 的示例。
在下一章中,您将学习如何使用 Python 与远程系统一起执行各种任务,例如使用 SSH 进行管理任务,通过 FTP、Samba 等进行文件传输。还将简要讨论一些远程监控协议,如 SNMP,以及身份验证协议,如 LDAP。因此,请在下一章中享受编写更多的 Python 代码。
第五章:与远程系统交互
如果您的计算机连接到互联网或局域网(LAN),那么现在是时候与网络上的其他计算机进行通信了。在典型的家庭、办公室或校园局域网中,您会发现许多不同类型的计算机连接到网络上。一些计算机充当特定服务的服务器,例如文件服务器、打印服务器、用户认证管理服务器等。在本章中,我们将探讨网络中的计算机如何相互交互以及如何通过 Python 脚本访问一些服务。以下任务列表将为您提供本章涵盖的主题的概述:
-
使用
paramiko访问 SSH 终端 -
通过 SFTP 传输文件
-
通过 FTP 传输文件
-
阅读 SNMP 数据包
-
阅读 LDAP 数据包
-
使用 SAMBA 共享文件
这一章需要一些第三方软件包,如paramiko、pysnmp等。您可以使用操作系统的软件包管理工具进行安装。以下是在 Ubuntu 14、python3 中安装paramiko模块以及本章涵盖的其他主题的理解所需的其他模块的快速操作指南:
**sudo apt-get install python3**
**sudo apt-get install python3-setuptools**
**sudo easy_install3 paramiko**
**sudo easy_install3 python3-ldap**
**sudo easy_install3 pysnmp**
**sudo easy_install3 pysmb**
使用 Python 进行安全外壳访问
SSH 已经成为一种非常流行的网络协议,用于在两台计算机之间进行安全数据通信。它提供了出色的加密支持,使得无关的第三方在传输过程中无法看到数据的内容。SSH 协议的详细信息可以在这些 RFC 文档中找到:RFC4251-RFC4254,可在www.rfc-editor.org/rfc/rfc4251.txt上找到。
Python 的paramiko库为基于 SSH 的网络通信提供了非常好的支持。您可以使用 Python 脚本来从 SSH-based 远程管理中获益,例如远程命令行登录、命令执行以及两台网络计算机之间的其他安全网络服务。您可能还对使用基于paramiko的pysftp模块感兴趣。有关此软件包的更多详细信息可以在 PyPI 上找到:pypi.python.org/pypi/pysftp/。
SSH 是一种客户端/服务器协议。双方都使用 SSH 密钥对加密通信。每个密钥对都有一个私钥和一个公钥。公钥可以发布给任何可能感兴趣的人。私钥始终保持私密,并且除了密钥所有者之外,不允许任何人访问。
SSH 公钥和私钥可以由外部或内部证书颁发机构生成并进行数字签名。但这给小型组织带来了很多额外开销。因此,作为替代,可以使用ssh-keygen等实用工具随机生成密钥。公钥需要提供给所有参与方。当 SSH 客户端首次连接到服务器时,它会在一个名为~/.ssh/known_hosts的特殊文件上注册服务器的公钥。因此,随后连接到服务器可以确保客户端与之前通话的是同一台服务器。在服务器端,如果您希望限制对具有特定 IP 地址的某些客户端的访问,则可以将允许主机的公钥存储到另一个名为ssh_known_hosts的特殊文件中。当然,如果重新构建机器,例如服务器机器,那么服务器的旧公钥将与~/.ssh/known_hosts文件中存储的公钥不匹配。因此,SSH 客户端将引发异常并阻止您连接到它。您可以从该文件中删除旧密钥,然后尝试重新连接,就像第一次一样。
我们可以使用paramiko模块创建一个 SSH 客户端,然后将其连接到 SSH 服务器。这个模块将提供SSHClient()类。
ssh_client = paramiko.SSHClient()
默认情况下,此客户端类的实例将拒绝未知的主机密钥。因此,您可以设置接受未知主机密钥的策略。内置的AutoAddPolicy()类将在发现时添加主机密钥。现在,您需要在ssh_client对象上运行set_missing_host_key_policy()方法以及以下参数。
ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
如果您想要限制仅连接到某些主机,那么您可以定义自己的策略并将其替换为AutoAddPolicy()类。
您可能还希望使用load_system_host_keys()方法添加系统主机密钥。
ssh_client.load_system_host_keys()
到目前为止,我们已经讨论了如何加密连接。然而,SSH 需要您的身份验证凭据。这意味着客户端需要向服务器证明特定用户在交谈,而不是其他人。有几种方法可以做到这一点。最简单的方法是使用用户名和密码组合。另一种流行的方法是使用基于密钥的身份验证方法。这意味着用户的公钥可以复制到服务器上。有一个专门的工具可以做到这一点。这是随后版本的 SSH 附带的。以下是如何使用ssh-copy-id的示例。
**ssh-copy-id -i ~/.ssh/id_rsa.pub faruq@debian6box.localdomain.loc**
此命令将 faruq 用户的 SSH 公钥复制到debian6box.localdomain.loc机器:
在这里,我们可以简单地调用connect()方法以及目标主机名和 SSH 登录凭据。要在目标主机上运行任何命令,我们需要通过将命令作为其参数来调用exec_command()方法。
ssh_client.connect(hostname, port, username, password)
stdin, stdout, stderr = ssh_client.exec_command(cmd)
以下代码清单显示了如何对目标主机进行 SSH 登录,然后运行简单的ls命令:
#!/usr/bin/env python3
import getpass
import paramiko
HOSTNAME = 'localhost'
PORT = 22
def run_ssh_cmd(username, password, cmd, hostname=HOSTNAME, port=PORT):
ssh_client = paramiko.SSHClient()
ssh_client.set_missing_host_key_policy(\
paramiko.AutoAddPolicy())
ssh_client.load_system_host_keys()
ssh_client.connect(hostname, port, username, password)
stdin, stdout, stderr = ssh_client.exec_command(cmd)
print(stdout.read())
if __name__ == '__main__':
username = input("Enter username: ")
password = getpass.getpass(prompt="Enter password: ")
cmd = 'ls -l /dev'
run_ssh_cmd(username, password, cmd)
在运行之前,我们需要确保目标主机(在本例中为本地主机)上运行 SSH 服务器守护程序。如下面的截图所示,我们可以使用netstat命令来执行此操作。此命令将显示所有监听特定端口的运行服务:

前面的脚本将与本地主机建立 SSH 连接,然后运行ls -l /dev/命令。此脚本的输出将类似于以下截图:

检查 SSH 数据包
看到客户端和服务器之间的网络数据包交换将会非常有趣。我们可以使用本机tcpdump命令或第三方 Wireshark 工具来捕获网络数据包。使用tcpdump,您可以指定目标网络接口(-i lo)和端口号(端口22)选项。在以下数据包捕获会话中,在 SSH 客户端/服务器通信会话期间显示了五次数据包交换:
**root@debian6box:~# tcpdump -i lo port 22**
**tcpdump: verbose output suppressed, use -v or -vv for full protocol decode**
**listening on lo, link-type EN10MB (Ethernet), capture size 65535 bytes**
**12:18:19.761292 IP localhost.50768 > localhost.ssh: Flags [S], seq 3958510356, win 32792, options [mss 16396,sackOK,TS val 57162360 ecr 0,nop,wscale 6], length 0**
**12:18:19.761335 IP localhost.ssh > localhost.50768: Flags [S.], seq 1834733028, ack 3958510357, win 32768, options [mss 16396,sackOK,TS val 57162360 ecr 57162360,nop,wscale 6], length 0**
**12:18:19.761376 IP localhost.50768 > localhost.ssh: Flags [.], ack 1, win 513, options [nop,nop,TS val 57162360 ecr 57162360], length 0**
**12:18:19.769430 IP localhost.50768 > localhost.ssh: Flags [P.], seq 1:25, ack 1, win 513, options [nop,nop,TS val 57162362 ecr 57162360], length 24**
**12:18:19.769467 IP localhost.ssh > localhost.50768: Flags [.], ack 25, win 512, options [nop,nop,TS val 57162362 ecr 57162362], length 0**
尽管使用tcpdump非常快速和简单,但该命令不会像其他 GUI 工具(如 Wireshark)那样解释它。前面的会话可以在 Wireshark 中捕获,如下面的截图所示:

这清楚地显示了前三个数据包如何完成 TCP 握手过程。然后,随后的 SSH 数据包协商了客户端和服务器之间的连接。看到客户端和服务器如何协商加密协议是很有趣的。在这个例子中,客户端端口是50768,服务器端口是22。客户端首先启动 SSH 数据包交换,然后指示它想要使用SSHv2协议进行通信。然后,服务器同意并继续数据包交换。
通过 SFTP 传输文件
SSH 可以有效地用于在两个计算机节点之间安全地传输文件。在这种情况下使用的协议是安全文件传输协议(SFTP)。Python 的paramiko模块将提供创建 SFTP 会话所需的类。然后,此会话可以执行常规的 SSH 登录。
ssh_transport = paramiko.Transport(hostname, port)
ssh_transport.connect(username='username', password='password')
SFTP 会话可以从 SSH 传输中创建。paramiko 在 SFTP 会话中的工作将支持诸如get()之类的正常 FTP 命令。
sftp_session = paramiko.SFTPClient.from_transport(ssh_transport)
sftp_session.get(source_file, target_file)
正如您所看到的,SFTP 的get命令需要源文件的路径和目标文件的路径。在下面的示例中,脚本将通过 SFTP 下载位于用户主目录中的test.txt文件:
#!/usr/bin/env python3
import getpass
import paramiko
HOSTNAME = 'localhost'
PORT = 22
FILE_PATH = '/tmp/test.txt'
def sftp_download(username, password, hostname=HOSTNAME, port=PORT):
ssh_transport = paramiko.Transport(hostname, port)
ssh_transport.connect(username=username, password=password)
sftp_session = paramiko.SFTPClient.from_transport(ssh_transport)
file_path = input("Enter filepath: ") or FILE_PATH
target_file = file_path.split('/')[-1]
sftp_session.get(file_path, target_file)
print("Downloaded file from: %s" %file_path)
sftp_session.close()
if __name__ == '__main__':
hostname = input("Enter the target hostname: ")
port = input("Enter the target port: ")
username = input("Enter yur username: ")
password = getpass.getpass(prompt="Enter your password: ")
sftp_download(username, password, hostname, int(port))
在这个例子中,使用 SFTP 下载了一个文件。请注意,paramiko使用SFTPClient.from_transport(ssh_transport)类创建了 SFTP 会话。
脚本可以按照以下截图所示运行。在这里,我们将首先创建一个名为/tmp/test.txt的临时文件,然后完成 SSH 登录,然后使用 SFTP 下载该文件。最后,我们将检查文件的内容。

使用 FTP 传输文件
与 SFTP 不同,FTP 使用明文文件传输方法。这意味着通过网络传输的任何用户名或密码都可以被不相关的第三方检测到。尽管 FTP 是一种非常流行的文件传输协议,但人们经常使用它将文件从他们的个人电脑传输到远程服务器。
在 Python 中,ftplib是一个用于在远程机器之间传输文件的内置模块。您可以使用FTP()类创建一个匿名 FTP 客户端连接。
ftp_client = ftplib.FTP(path, username, email)
然后,您可以调用正常的 FTP 命令,例如CWD。为了下载二进制文件,您需要创建一个文件处理程序,如下所示:
file_handler = open(DOWNLOAD_FILE_NAME, 'wb')
为了从远程主机检索二进制文件,可以使用此处显示的语法以及RETR命令:
ftp_client.retrbinary('RETR remote_file_name', file_handler.write)
在下面的代码片段中,可以看到完整的 FTP 文件下载示例:
#!/usr/bin/env python
import ftplib
FTP_SERVER_URL = 'ftp.kernel.org'
DOWNLOAD_DIR_PATH = '/pub/software/network/tftp'
DOWNLOAD_FILE_NAME = 'tftp-hpa-0.11.tar.gz'
def ftp_file_download(path, username, email):
# open ftp connection
ftp_client = ftplib.FTP(path, username, email)
# list the files in the download directory
ftp_client.cwd(DOWNLOAD_DIR_PATH)
print("File list at %s:" %path)
files = ftp_client.dir()
print(files)
# downlaod a file
file_handler = open(DOWNLOAD_FILE_NAME, 'wb')
#ftp_cmd = 'RETR %s ' %DOWNLOAD_FILE_NAME
ftp_client.retrbinary('RETR tftp-hpa-0.11.tar.gz', file_handler.write)
file_handler.close()
ftp_client.quit()
if __name__ == '__main__':
ftp_file_download(path=FTP_SERVER_URL, username='anonymous', email='nobody@nourl.com')
上述代码说明了如何从ftp.kernel.org下载匿名 FTP,这是托管 Linux 内核的官方网站。FTP()类接受三个参数,如远程服务器上的初始文件系统路径、用户名和ftp用户的电子邮件地址。对于匿名下载,不需要用户名和密码。因此,可以从/pub/software/network/tftp路径上找到的tftp-hpa-0.11.tar.gz文件中下载脚本。
检查 FTP 数据包
如果我们在公共网络接口的端口21上在 Wireshark 中捕获 FTP 会话,那么我们可以看到通信是如何以明文形式进行的。这将向您展示为什么应该优先使用 SFTP。在下图中,我们可以看到,在成功与客户端建立连接后,服务器发送横幅消息:220 欢迎来到 kernel.org。随后,客户端将匿名发送登录请求。作为回应,服务器将要求密码。客户端可以发送用户的电子邮件地址进行身份验证。
检查 FTP 数据包
令人惊讶的是,您会发现密码已经以明文形式发送。在下面的截图中,显示了密码数据包的内容。它显示了提供的虚假电子邮件地址nobody@nourl.com。

获取简单网络管理协议数据
SNMP 是一种广泛使用的网络协议,用于网络路由器(如交换机、服务器等)通信设备的配置、性能数据和控制设备的命令。尽管 SNMP 以“简单”一词开头,但它并不是一个简单的协议。在内部,每个设备的信息都存储在一种称为管理信息库(MIB)的信息数据库中。SNMP 协议根据协议版本号提供不同级别的安全性。在 SNMP v1和v2c中,数据受到称为 community 字符串的密码短语的保护。在 SNMP v3中,需要用户名和密码来存储数据。并且,数据可以通过 SSL 进行加密。在我们的示例中,我们将使用 SNMP 协议的v1和v2c版本。
SNMP 是一种基于客户端/服务器的网络协议。服务器守护程序向客户端提供请求的信息。在您的计算机上,如果已安装和配置了 SNMP,则可以使用snmpwalk实用程序命令通过以下语法查询基本系统信息:
**# snmpwalk -v2c -c public localhost**
**iso.3.6.1.2.1.1.1.0 = STRING: "Linux debian6box 2.6.32-5-686 #1 SMP Tue May 13 16:33:32 UTC 2014 i686"**
**iso.3.6.1.2.1.1.2.0 = OID: iso.3.6.1.4.1.8072.3.2.10**
**iso.3.6.1.2.1.1.3.0 = Timeticks: (88855240) 10 days, 6:49:12.40**
**iso.3.6.1.2.1.1.4.0 = STRING: "Me <me@example.org>"**
**iso.3.6.1.2.1.1.5.0 = STRING: "debian6box"**
**iso.3.6.1.2.1.1.6.0 = STRING: "Sitting on the Dock of the Bay"**
上述命令的输出将显示 MIB 编号及其值。例如,MIB 编号iso.3.6.1.2.1.1.1.0显示它是一个字符串类型的值,如Linux debian6box 2.6.32-5-686 #1 SMP Tue May 13 16:33:32 UTC 2014 i686。
在 Python 中,您可以使用一个名为pysnmp的第三方库来与snmp守护程序进行交互。您可以使用 pip 安装pysnmp模块。
**$ pip install pysnmp**
该模块为snmp命令提供了一个有用的包装器。让我们学习如何创建一个snmpwalk命令。首先,导入一个命令生成器。
from pysnmp.entity.rfc3413.oneliner import cmdgen
cmd_generator = cmdgen.CommandGenerator()
然后假定snmpd守护程序在本地机器的端口161上运行,并且 community 字符串已设置为 public,定义连接的必要默认值。
SNMP_HOST = 'localhost'
SNMP_PORT = 161
SNMP_COMMUNITY = 'public'
现在使用必要的数据调用getCmd()方法。
error_notify, error_status, error_index, var_binds = cmd_generator.getCmd(
cmdgen.CommunityData(SNMP_COMMUNITY),
cmdgen.UdpTransportTarget((SNMP_HOST, SNMP_PORT)),
cmdgen.MibVariable('SNMPv2-MIB', 'sysDescr', 0),
lookupNames=True, lookupValues=True
)
您可以看到cmdgen接受以下参数:
-
CommunityData(): 将 community 字符串设置为 public。 -
UdpTransportTarget(): 这是主机目标,snmp代理正在运行的地方。这是由主机名和 UDP 端口组成的一对。 -
MibVariable: 这是一个值元组,包括 MIB 版本号和 MIB 目标字符串(在本例中为sysDescr;这是指系统的描述)。
该命令的输出由一个四值元组组成。其中三个与命令生成器返回的错误有关,第四个与绑定返回数据的实际变量有关。
以下示例显示了如何使用前面的方法从运行的 SNMP 守护程序中获取 SNMP 主机描述字符串:
from pysnmp.entity.rfc3413.oneliner import cmdgen
SNMP_HOST = 'localhost'
SNMP_PORT = 161
SNMP_COMMUNITY = 'public'
if __name__ == '__manin__':
cmd_generator = cmdgen.CommandGenerator()
error_notify, error_status, error_index, var_binds = cmd_generator.getCmd(
cmdgen.CommunityData(SNMP_COMMUNITY),
cmdgen.UdpTransportTarget((SNMP_HOST, SNMP_PORT)),
cmdgen.MibVariable('SNMPv2-MIB', 'sysDescr', 0),
lookupNames=True, lookupValues=True
)
# Check for errors and print out results
if error_notify:
print(error_notify)
elif error_status:
print(error_status)
else:
for name, val in var_binds:
print('%s = %s' % (name.prettyPrint(), val.prettyPrint()))
运行上述示例后,将出现类似以下的输出:
**$ python 5_4_snmp_read.py**
**SNMPv2-MIB::sysDescr."0" = Linux debian6box 2.6.32-5-686 #1 SMP Tue May 13 16:33:32 UTC 2014 i686**
检查 SNMP 数据包
我们可以通过捕获网络接口的端口 161 上的数据包来检查 SNMP 数据包。如果服务器在本地运行,则仅需监听loopbook接口即可。Wireshak 生成的snmp-get请求格式和snmp-get响应数据包格式如下截图所示:

作为对客户端的 SNMP 获取请求的响应,服务器将生成一个 SNMP 获取响应。这可以在以下截图中看到:

读取轻量级目录访问协议数据
长期以来,LDAP 一直被用于访问和管理分布式目录信息。这是一个在 IP 网络上运行的应用级协议。目录服务在组织中被广泛用于管理有关用户、计算机系统、网络、应用程序等信息。LDAP 协议包含大量的技术术语。它是基于客户端/服务器的协议。因此,LDAP 客户端将向正确配置的 LDAP 服务器发出请求。在初始化 LDAP 连接后,连接将需要使用一些参数进行身份验证。简单的绑定操作将建立 LDAP 会话。在简单情况下,您可以设置一个简单的匿名绑定,不需要密码或其他凭据。
如果您使用ldapsearch运行简单的 LDAP 查询,那么您将看到如下结果:
**# ldapsearch -x -b "dc=localdomain,dc=loc" -h 10.0.2.15 -p 389**
**# extended LDIF**
**#**
**# LDAPv3**
**# base <dc=localdomain,dc=loc> with scope subtree**
**# filter: (objectclass=*)**
**# requesting: ALL**
**#**
**# localdomain.loc**
**dn: dc=localdomain,dc=loc**
**objectClass: top**
**objectClass: dcObject**
**objectClass: organization**
**o: localdomain.loc**
**dc: localdomain**
**# admin, localdomain.loc**
**dn: cn=admin,dc=localdomain,dc=loc**
**objectClass: simpleSecurityObject**
**objectClass: organizationalRole**
**cn: admin**
**description: LDAP administrator**
**# groups, localdomain.loc**
**dn: ou=groups,dc=localdomain,dc=loc**
**ou: groups**
**objectClass: organizationalUnit**
**objectClass: top**
**# users, localdomain.loc**
**dn: ou=users,dc=localdomain,dc=loc**
**ou: users**
**objectClass: organizationalUnit**
**objectClass: top**
**# admin, groups, localdomain.loc**
**dn: cn=admin,ou=groups,dc=localdomain,dc=loc**
**cn: admin**
**gidNumber: 501**
**objectClass: posixGroup**
**# Faruque Sarker, users, localdomain.loc**
**dn: cn=Faruque Sarker,ou=users,dc=localdomain,dc=loc**
**givenName: Faruque**
**sn: Sarker**
**cn: Faruque Sarker**
**uid: fsarker**
**uidNumber: 1001**
**gidNumber: 501**
**homeDirectory: /home/users/fsarker**
**loginShell: /bin/sh**
**objectClass: inetOrgPerson**
**objectClass: posixAccount**
**# search result**
**search: 2**
**result: 0 Success**
**# numResponses: 7**
**# numEntries: 6**
前面的通信可以通过 Wireshark 来捕获。您需要在端口 389 上捕获数据包。如下截图所示,在成功发送bindRequest之后,LDAP 客户端-服务器通信将建立。以匿名方式与 LDAP 服务器通信是不安全的。为了简单起见,在下面的示例中,搜索是在不绑定任何凭据的情况下进行的。

Python 的第三方python-ldap软件包提供了与 LDAP 服务器交互所需的功能。您可以使用pip安装此软件包。
**$ pip install python-ldap**
首先,您需要初始化 LDAP 连接:
import ldap
ldap_client = ldap.initialize("ldap://10.0.2.15:389/")
然后以下代码将展示如何执行简单的绑定操作:
ldap_client.simple_bind("dc=localdomain,dc=loc")
然后您可以执行 LDAP 搜索。您需要指定必要的参数,如基本 DN、过滤器和属性。以下是在 LDAP 服务器上搜索用户所需的语法示例:
ldap_client.search_s( base_dn, ldap.SCOPE_SUBTREE, filter, attrs )
以下是使用 LDAP 协议查找用户信息的完整示例:
import ldap
# Open a connection
ldap_client = ldap.initialize("ldap://10.0.2.15:389/")
# Bind/authenticate with a user with apropriate rights to add objects
ldap_client.simple_bind("dc=localdomain,dc=loc")
base_dn = 'ou=users,dc=localdomain,dc=loc'
filter = '(objectclass=person)'
attrs = ['sn']
result = ldap_client.search_s( base_dn, ldap.SCOPE_SUBTREE, filter, attrs )
print(result)
前面的代码将搜索 LDAP 目录子树,使用ou=users,dc=localdomain,dc=loc基本DN和[sn]属性。搜索限定为人员对象。
检查 LDAP 数据包
如果我们分析 LDAP 客户端和服务器之间的通信,我们可以看到 LDAP 搜索请求和响应的格式。我们在我们的代码中使用的参数与 LDAP 数据包的searchRequest部分有直接关系。如 Wireshark 生成的以下截图所示,它包含数据,如baseObject、scope和Filter。

LDAP 搜索请求生成服务器响应,如下所示:

当 LDAP 服务器返回搜索响应时,我们可以看到响应的格式。如前面的截图所示,它包含了搜索结果和相关属性。
以下是从 LDAP 服务器搜索用户的示例:
#!/usr/bin/env python
import ldap
import ldap.modlist as modlist
LDAP_URI = "ldap://10.0.2.15:389/"
BIND_TO = "dc=localdomain,dc=loc"
BASE_DN = 'ou=users,dc=localdomain,dc=loc'
SEARCH_FILTER = '(objectclass=person)'
SEARCH_FILTER = ['sn']
if __name__ == '__main__':
# Open a connection
l = ldap.initialize(LDAP_URI)
# bind to the server
l.simple_bind(BIND_TO)
result = l.search_s( BASE_DN, ldap.SCOPE_SUBTREE, SEARCH_FILTER, SEARCH_FILTER )
print(result)
在正确配置的 LDAP 机器中,前面的脚本将返回类似以下的结果:
**$ python 5_5_ldap_read_record.py**
**[('cn=Faruque Sarker,ou=users,dc=localdomain,dc=loc', {'sn': ['Sarker']})]**
使用 SAMBA 共享文件
在局域网环境中,您经常需要在不同类型的机器之间共享文件,例如 Windows 和 Linux 机器。用于在这些机器之间共享文件和打印机的协议是服务器消息块(SMB)协议或其增强版本称为公共互联网文件系统(CIFS)协议。CIFS 运行在 TCP/IP 上,由 SMB 客户端和服务器使用。在 Linux 中,您会发现一个名为 Samba 的软件包,它实现了SMB协议。
如果您在 Windows 框中运行 Linux 虚拟机,并借助软件(如 VirtualBox)进行文件共享测试,则可以在 Windows 机器上创建一个名为C:\share的文件夹,如下屏幕截图所示:

现在,右键单击文件夹,然后转到共享选项卡。有两个按钮:共享和高级共享。您可以单击后者,它将打开高级共享对话框。现在您可以调整共享权限。如果此共享处于活动状态,则您将能够从 Linux 虚拟机中看到此共享。如果在 Linux 框中运行以下命令,则将看到先前定义的文件共享:
**$smbclient -L 10.0.2.2 -U WINDOWS_USERNAME%PASSWPRD -W WORKGROUP**
**Domain=[FARUQUESARKER] OS=[Windows 8 9200] Server=[Windows 8 6.2]**
**Sharename Type Comment**
**--------- ---- -------**
**ADMIN$ Disk Remote Admin**
**C$ Disk Default share**
**IPC$ IPC Remote IPC**
**Share Disk**
以下屏幕截图显示了如何在 Windows 7 下共享文件夹,如前所述:

可以使用第三方模块pysmb从 Python 脚本访问前面的文件共享。您可以使用pip命令行工具安装pysmb:
**$ pip install pysmb**
该模块提供了一个SMBConnection类,您可以通过该类传递必要的参数来访问 SMB/CIFS 共享。例如,以下代码将帮助您访问文件共享:
from smb.SMBConnection import SMBConnection
smb_connection = SMBConnection(username, password, client_machine_name, server_name, use_ntlm_v2 = True, domain='WORKGROUP', is_direct_tcp=True)
如果前面的工作正常,则以下断言将为真:
assert smb_connection.connect(server_ip, 445)
您可以使用listShares()方法列出共享文件:
shares = smb_connection.listShares()
for share in shares:
print share.name
如果您可以使用tmpfile模块从 Windows 共享复制文件。例如,如果您在C:\Share\test.rtf路径中创建一个文件,则以下附加代码将使用 SMB 协议复制该文件:
import tempfile
files = smb_connection.listPath(share.name, '/')
for file in files:
print file.filename
file_obj = tempfile.NamedTemporaryFile()
file_attributes, filesize = smb_connection.retrieveFile('Share', '/test.rtf', file_obj)
file_obj.close()
如果我们将整个代码放入单个源文件中,它将如下所示:
#!/usr/bin/env python
import tempfile
from smb.SMBConnection import SMBConnection
SAMBA_USER_ID = 'FaruqueSarker'
PASSWORD = 'PASSWORD'
CLIENT_MACHINE_NAME = 'debian6box'
SAMBA_SERVER_NAME = 'FARUQUESARKER'
SERVER_IP = '10.0.2.2'
SERVER_PORT = 445
SERVER_SHARE_NAME = 'Share'
SHARED_FILE_PATH = '/test.rtf'
if __name__ == '__main__':
smb_connection = SMBConnection(SAMBA_USER_ID, PASSWORD, CLIENT_MACHINE_NAME, SAMBA_SERVER_NAME, use_ntlm_v2 = True, domain='WORKGROUP', is_direct_tcp=True)
assert smb_connection.smb_connectionect(SERVER_IP, SERVER_PORT = 445)
shares = smb_connection.listShares()
for share in shares:
print share.name
files = smb_connection.listPath(share.name, '/')
for file in files:
print file.filename
file_obj = tempfile.NamedTemporaryFile()
file_attributes, filesize = smb_connection.retrieveFile(SERVER_SHARE_NAME, SHARED_FILE_PATH, file_obj)
# Retrieved file contents are inside file_obj
file_obj.close()
检查 SAMBA 数据包
如果我们在端口445上捕获 SMABA 数据包,则可以看到 Windows 服务器如何通过 CIFS 协议与 Linux SAMBA 客户端进行通信。在以下两个屏幕截图中,已呈现了客户端和服务器之间的详细通信。连接设置如下截图所示:

以下屏幕截图显示了如何执行文件复制会话:

以下屏幕截图显示了典型的 SAMBA 数据包格式。此数据包的重要字段是NT_STATUS字段。通常,如果连接成功,则会显示STATUS_SUCESS。否则,它将打印不同的代码。如下屏幕截图所示:

总结
在本章中,我们已经接触了几种网络协议和 Python 库,用于与远程系统进行交互。SSH 和 SFTP 用于安全连接和传输文件到远程主机。FTP 仍然用作简单的文件传输机制。但是,由于用户凭据以明文形式传输,因此不安全。我们还研究了处理 SNMP、LDAP 和 SAMBA 数据包的 Python 库。
在下一章中,将讨论最常见的网络协议之一,即 DNS 和 IP。我们将使用 Python 脚本探索 TCP/IP 网络。
第六章:IP 和 DNS
连接到网络的每台计算机都需要一个 IP 地址。在第一章中,介绍了 TCP/IP 网络编程。IP 地址使用数字标识符标记机器的网络接口,也标识了机器的位置,尽管可靠性有限。域名系统(DNS)是一种核心网络服务,将名称映射到 IP 地址,反之亦然。在本章中,我们将主要关注使用 Python 操作 IP 和 DNS 协议。除此之外,我们还将简要讨论网络时间协议(NTP),它有助于将时间与集中式时间服务器同步。以下主题将在此处讨论:
-
检索本地计算机的网络配置
-
操作 IP 地址
-
GeoIP 查找
-
使用 DNS
-
使用 NTP
检索本地计算机的网络配置
在做任何其他事情之前,让我们用 Python 语言问一下,我的名字是什么?。在网络术语中,这相当于找出机器的名称或主机的名称。在 shell 命令行上,可以使用hostname命令来发现这一点。在 Python 中,您可以使用 socket 模块来实现这一点。
**>>> import socket**
**>>> socket.gethostname()**
**'debian6box.localdomain.loc'**
现在,我们想要查看本地计算机的 IP。这可以通过在 Linux 中使用ifconfig命令和在 Windows OS 中使用ipconfig命令来实现。但是,我们想要使用以下内置函数在 Python 中执行此操作:
**>>> socket.gethostbyname('debian6box.localdomain.loc')**
**'10.0.2.15'**
如您所见,这是第一个网络接口的 IP。如果您的 DNS 或主机文件未正确配置,它还可以显示我们的环回接口(127.0.0.1)的 IP。在 Linux/UNIX 中,可以将以下行添加到您的/etc/hosts文件中以获取正确的 IP 地址:
10.0.2.15 debian6box.localdomain.loc debian6box
这个过程被称为基于主机文件的名称解析。您可以向 DNS 服务器发送查询,询问特定主机的 IP 地址。如果名称已经正确注册,那么您将从服务器收到响应。但是,在向远程服务器发出查询之前,让我们先了解一些关于网络接口和网络的更多信息。
在每个局域网中,主机被配置为充当网关,与外部世界通信。为了找到网络地址和子网掩码,我们可以使用 Python 第三方库 netifaces(版本> 0.10.0)。这将提取所有相关信息。例如,您可以调用netifaces.gateways()来查找配置为外部世界的网关。同样,您可以通过调用netifaces.interfaces()来枚举网络接口。如果您想要知道特定接口eth0的所有 IP 地址,那么可以调用netifaces.ifaddresses('eth0')。以下代码清单显示了如何列出本地计算机的所有网关和 IP 地址:
#!/usr/bin/env python
import socket
import netifaces
if __name__ == '__main__':
# Find host info
host_name = socket.gethostname()
ip_address = socket.gethostbyname(host_name)
print("Host name: {0}".format(host_name))
# Get interfaces list
ifaces = netifaces.interfaces()
for iface in ifaces:
ipaddrs = netifaces.ifaddresses(iface)
if netifaces.AF_INET in ipaddrs:
ipaddr_desc = ipaddrs[netifaces.AF_INET]
ipaddr_desc = ipaddr_desc[0]
print("Network interface: {0}".format(iface))
print("\tIP address: {0}".format(ipaddr_desc['addr']))
print("\tNetmask: {0}".format(ipaddr_desc['netmask']))
# Find the gateway
gateways = netifaces.gateways()
print("Default gateway: {0}".format(gateways['default'][netifaces.AF_INET][0]))
如果您运行此代码,则会打印本地网络配置的摘要,类似于以下内容:
**$ python 6_1_local_network_config.py**
**Host name: debian6box**
**Network interface: lo**
**IP address: 127.0.0.1**
**Netmask: 255.0.0.0**
**Network interface: eth0**
**IP address: 10.0.2.15**
**Netmask: 255.255.255.0**
**Default gateway: 10.0.2.2**
操作 IP 地址
通常,您需要操作 IP 地址并对其执行某种操作。Python3 具有内置的ipaddress模块,可帮助您执行此任务。它具有方便的函数来定义 IP 地址和 IP 网络,并查找许多有用的信息。例如,如果您想知道给定子网中存在多少 IP 地址,例如10.0.1.0/255.255.255.0或10.0.2.0/24,则可以使用此处显示的代码片段找到它们。此模块将提供几个类和工厂函数;例如,IP 地址和 IP 网络具有单独的类。每个类都有 IP 版本 4(IPv4)和 IP 版本 6(IPv6)的变体。以下部分演示了一些功能:
IP 网络对象
让我们导入ipaddress模块并定义一个net4网络。
**>>> import ipaddress as ip**
**>>> net4 = ip.ip_network('10.0.1.0/24')**
现在,我们可以找到一些有用的信息,比如net4的netmask、网络/广播地址等:
**>>> net4.netmask**
**IP4Address(255.255.255.0)**
net4的netmask属性将显示为IP4Address对象。如果您正在寻找其字符串表示形式,则可以调用str()方法,如下所示:
**>>> str(net4.netmask)**
**'255.255.255.0'**
同样,您可以通过执行以下操作找到net4的网络和广播地址:
**>>> str(net4.network_address)**
**10.0.1.0**
**>>> str(net4.broadcast_address)**
**10.0.1.255**
net4总共有多少个地址?这可以通过使用以下命令找到:
**>>> net4.num_addresses**
**256**
因此,如果我们减去网络和广播地址,那么总共可用的 IP 地址将是 254。我们可以在net4对象上调用hosts()方法。它将生成一个 Python 生成器,它将提供所有主机作为IPv4Adress对象。
**>>> all_hosts = list(net4.hosts())**
**>>> len(all_hosts)**
**254**
您可以通过遵循标准的 Python 列表访问表示法来访问单个 IP 地址。例如,第一个 IP 地址将是以下内容:
**>>> all_hosts[0]**
**IPv4Address('10.0.1.1')**
您可以通过使用列表表示法来访问最后一个 IP 地址,如下所示:
**>>> all_hosts[-1]**
**IPv4Address('10.0.1.1')**
我们还可以从IPv4Network对象中找到子网信息,如下所示:
**>>> subnets = list( net4.subnets())**
**>>> subnets**
**[ IPv4Network('10.0.1.0/25'), IPv4Network('10.0.1.128/25') ]**
任何IPv4Network对象都可以告诉关于其父超网的信息,这与子网相反。
**>>> net4.supernet()**
**IPv4Network('10.0.1.0/23')**
网络接口对象
在ipaddress模块中,一个方便的类用于详细表示接口的 IP 配置。IPv4 Interface 类接受任意地址并表现得像一个网络地址对象。让我们定义并讨论我们的网络接口eth0,如下截图所示:

正如您在前面的截图中所看到的,已经定义了一个带有IPv4Address类的网络接口 eth0。它具有一些有趣的属性,例如 IP、网络地址等。与网络对象一样,您可以检查地址是否为私有、保留或多播。这些地址范围已在各种 RFC 文档中定义。ipaddress模块的帮助页面将向您显示这些 RFC 文档的链接。您也可以在其他地方搜索这些信息。
IP 地址对象
IP 地址类有许多其他有趣的属性。您可以对这些对象执行一些算术和逻辑操作。例如,如果一个 IP 地址大于另一个 IP 地址,那么您可以向 IP 地址对象添加数字,这将给您一个相应的 IP 地址。让我们在以下截图中看到这个演示:

ipaddress模块的演示
在这里,已经定义了带有私有 IP 地址192.168.1.1的eth0接口,以及已经定义了另一个私有 IP 地址192.168.2.1的eth1。同样,回环接口lo定义为 IP 地址127.0.0.1。正如您所看到的,您可以向 IP 地址添加数字,它将给您相同序列的下一个 IP 地址。
您可以检查 IP 是否属于特定网络。在这里,网络 net 是由网络地址192.168.1.0/24定义的,并且已经测试了eth0和eth1的成员资格。还在这里测试了一些其他有趣的属性,比如is_loopback,is_private等。
为您的本地区域网络规划 IP 地址
如果您想知道如何选择合适的 IP 子网,那么您可以尝试使用ipaddress模块。以下代码片段将展示如何根据小型私有网络所需的主机 IP 地址数量选择特定子网的示例:
#!/usr/bin/env python
import ipaddress as ip
CLASS_C_ADDR = '192.168.0.0'
if __name__ == '__main__':
not_configed = True
while not_configed:
prefix = input("Enter the prefixlen (24-30): ")
prefix = int(prefix)
if prefix not in range(23, 31):
raise Exception("Prefixlen must be between 24 and 30")
net_addr = CLASS_C_ADDR + '/' + str(prefix)
print("Using network address:%s " %net_addr)
try:
network = ip.ip_network(net_addr)
except:
raise Exception("Failed to create network object")
print("This prefix will give %s IP addresses" %(network.num_addresses))
print("The network configuration will be")
print("\t network address: %s" %str(network.network_address))
print("\t netmask: %s" %str(network.netmask))
print("\t broadcast address: %s" %str(network.broadcast_address))
first_ip, last_ip = list(network.hosts())[0], list(network.hosts())[-1]
print("\t host IP addresses: from %s to %s" %(first_ip, last_ip))
ok = input("Is this configuration OK [y/n]? ")
ok = ok.lower()
if ok.strip() == 'y':
not_configed = False
如果您运行此脚本,它将显示类似以下内容的输出:
**# python 6_2_net_ip_planner.py**
**Enter the prefixlen (24-30): 28**
**Using network address:192.168.0.0/28**
**This prefix will give 16 IP addresses**
**The network configuration will be**
**network address: 192.168.0.0**
**netmask: 255.255.255.240**
**broadcast address: 192.168.0.15**
**host IP addresses: from 192.168.0.1 to 192.168.0.14**
**Is this configuration OK [y/n]? n**
**Enter the prefixlen (24-30): 26**
**Using network address:192.168.0.0/26**
**This prefix will give 64 IP addresses**
**The network configuration will be**
**network address: 192.168.0.0**
**netmask: 255.255.255.192**
**broadcast address: 192.168.0.63**
**host IP addresses: from 192.168.0.1 to 192.168.0.62**
**Is this configuration OK [y/n]? y**
GeoIP 查找
有时,许多应用程序需要查找 IP 地址的位置。例如,许多网站所有者可能对跟踪其访问者的位置以及根据国家、城市等标准对其 IP 进行分类感兴趣。有一个名为python-geoip的第三方库,它具有一个强大的接口,可以为您提供 IP 位置查询的答案。这个库由 MaxMind 提供,还提供了将最新版本的 Geolite2 数据库作为python-geoip-geolite2软件包进行发布的选项。这包括由 MaxMind 创建的 GeoLite2 数据,可在www.maxmind.com上以知识共享署名-相同方式共享 3.0 未本地化许可证下获得。您也可以从他们的网站购买商业许可证。
让我们看一个如何使用这个 Geo-lookup 库的例子:
import socket
from geoip import geolite2
import argparse
if __name__ == '__main__':
# Setup commandline arguments
parser = argparse.ArgumentParser(description='Get IP Geolocation info')
parser.add_argument('--hostname', action="store", dest="hostname", required=True)
# Parse arguments
given_args = parser.parse_args()
hostname = given_args.hostname
ip_address = socket.gethostbyname(hostname)
print("IP address: {0}".format(ip_address))
match = geolite2.lookup(ip_address)
if match is not None:
print('Country: ',match.country)
print('Continent: ',match.continent)
print('Time zone: ', match.timezone)
此脚本将显示类似以下的输出:
**$ python 6_3_geoip_lookup.py --hostname=amazon.co.uk**
**IP address: 178.236.6.251**
**Country: IE**
**Continent: EU**
**Time zone: Europe/Dublin**
您可以从开发者网站pythonhosted.org/python-geoip/上找到有关此软件包的更多信息。
DNS 查找
IP 地址可以被翻译成称为域名的人类可读字符串。DNS 是网络世界中的一个重要主题。在本节中,我们将在 Python 中创建一个 DNS 客户端,并看看这个客户端将如何通过使用 Wirshark 与服务器通信。
PyPI 提供了一些 DNS 客户端库。我们将重点关注dnspython库,该库可在www.dnspython.org/上找到。您可以使用easy_install命令或pip命令安装此库:
**$ pip install dnspython**
对主机的 IP 地址进行简单查询非常简单。您可以使用dns.resolver子模块,如下所示:
**import dns.resolver**
**answers = dns.resolver.query('python.org', 'A')**
**for rdata in answers:**
**print('IP', rdata.to_text())**
如果您想进行反向查找,那么您需要使用dns.reversename子模块,如下所示:
**import dns.reversename**
**name = dns.reversename.from_address("127.0.0.1")**
**print name**
**print dns.reversename.to_address(name)**
现在,让我们创建一个交互式 DNS 客户端脚本,它将完成可能的记录查找,如下所示:
import dns.resolver
if __name__ == '__main__':
loookup_continue = True
while loookup_continue:
name = input('Enter the DNS name to resolve: ')
record_type = input('Enter the query type [A/MX/CNAME]: ')
answers = dns.resolver.query(name, record_type)
if record_type == 'A':
print('Got answer IP address: %s' %[x.to_text() for x in answers])
elif record_type == 'CNAME':
print('Got answer Aliases: %s' %[x.to_text() for x in answers])
elif record_type == 'MX':
for rdata in answers:
print('Got answers for Mail server records:')
print('Mailserver', rdata.exchange.to_text(), 'has preference', rdata.preference)
print('Record type: %s is not implemented' %record_type)
lookup_more = input("Do you want to lookup more records? [y/n]: " )
if lookup_more.lower() == 'n':
loookup_continue = False
如果您使用一些输入运行此脚本,那么您将得到类似以下的输出:
**$ python 6_4_dns_client.py**
**Enter the DNS name to resolve: google.com**
**Enter the query type [A/MX/CNAME]: MX**
**Got answers for Mail server records:**
**Mailserver alt4.aspmx.l.google.com. has preference 50**
**Got answers for Mail server records:**
**Mailserver alt2.aspmx.l.google.com. has preference 30**
**Got answers for Mail server records:**
**Mailserver alt3.aspmx.l.google.com. has preference 40**
**Got answers for Mail server records:**
**Mailserver aspmx.l.google.com. has preference 10**
**Got answers for Mail server records:**
**Mailserver alt1.aspmx.l.google.com. has preference 20**
**Do you want to lookup more records? [y/n]: y**
**Enter the DNS name to resolve: www.python.org**
**Enter the query type [A/MX/CNAME]: A**
**Got answer IP address: ['185.31.18.223']**
**Do you want to lookup more records? [y/n]: y**
**Enter the DNS name to resolve: pypi.python.org**
**Enter the query type [A/MX/CNAME]: CNAME**
**Got answer Aliases: ['python.map.fastly.net.']**
**Do you want to lookup more records? [y/n]: n**
检查 DNS 客户端/服务器通信
在以前的章节中,也许您注意到我们如何通过使用 Wireshark 捕获客户端和服务器之间的网络数据包。这是一个示例,显示了从 PyPI 安装 Python 软件包时的会话捕获:

FDNS 客户端/服务器通信
在 Wireshark 中,您可以通过导航到捕获 | 选项 | 捕获过滤器来指定端口 53。这将捕获所有发送到/从您的计算机的 DNS 数据包。
如您在以下截图中所见,客户端和服务器有几个请求/响应周期的 DNS 记录。它是从对主机地址(A)的标准请求开始的,然后是一个合适的响应。

如果您深入查看数据包,您可以看到来自服务器的响应的请求格式,如下截图所示:

NTP 客户端
本章将涵盖的最后一个主题是 NTP。与集中式时间服务器同步时间是任何企业网络中的关键步骤。我们想要比较各个服务器之间的日志文件,并查看每个服务器上的时间戳是否准确;日志事件可能不会相互关联。许多认证协议,如 Kerberos,严格依赖于客户端报告给服务器的时间戳的准确性。在这里,将介绍第三方 Python ntplib库,然后调查 NTP 客户端和服务器之间的通信。
要创建一个 NTP 客户端,您需要调用 ntplib 的NTPCLient类。
**import ntplib**
**from time import ctime**
**c = ntplib.NTPClient()**
**response = c.request('pool.ntp.org')**
**print ctime(response.tx_time)**
在这里,我们选择了pool.ntp.org,这是一个负载平衡的网络服务器。因此,一组 NTP 服务器将准备好响应客户端的请求。让我们从 NTP 服务器返回的响应中找到更多信息。
import ntplib
from time import ctime
HOST_NAME = 'pool.ntp.org'
if __name__ == '__main__':
params = {}
client = ntplib.NTPClient()
response = client.request(HOST_NAME)
print('Received time: %s' %ctime(response.tx_time))
print('ref_clock: ',ntplib.ref_id_to_text(response.ref_id, response.stratum))
print('stratum: ',response.stratum)
print('last_update: ', response.ref_time)
print('offset: %f' %response.offset)
print('precision: ', response.precision)
print('root_delay: %.6f' %response.root_delay)
print('root_dispersion: %.6f' %response.root_dispersion)
详细的响应将如下所示:
**$ python 6_5_ntp_client.py**
**Received time: Sat Feb 28 17:08:29 2015**
**ref_clock: 213.136.0.252**
**stratum: 2**
**last_update: 1425142998.2**
**offset: -4.777519**
**precision: -23**
**root_delay: 0.019608**
**root_dispersion: 0.036987**
上述信息是 NTP 服务器提供给客户端的。这些信息可用于确定所提供的时间服务器的准确性。例如,stratum 值 2 表示 NTP 服务器将查询另一个具有直接附加时间源的 stratum 值 1 的 NTP 服务器。有关 NTP 协议的更多信息,您可以阅读tools.ietf.org/html/rfc958上的 RFC 958 文档,或访问www.ntp.org/。
检查 NTP 客户端/服务器通信
您可以通过查看捕获的数据包来了解更多关于 NTP 的信息。为此,上述 NTP 客户端/服务器通信已被捕获,如下两个截图所示:
第一张截图显示了 NTP 客户端请求。如果您查看标志字段内部,您将看到客户端的版本号。

类似地,NTP 服务器的响应显示在以下截图中:

总结
在本章中,讨论了用于 IP 地址操作的标准 Python 库。介绍了两个第三方库dnspython和ntplib,分别用于与 DNS 和 NTP 服务器交互。正如您通过上述示例所看到的,这些库为您提供了与这些服务通信所需的接口。
在接下来的章节中,我们将介绍 Python 中的套接字编程。这是另一个对网络程序员来说非常有趣和受欢迎的主题。在那里,您将找到用于与 BSD 套接字编程的低级和高级 Python 库。
第七章:使用套接字进行编程
在 Python 中与各种客户端/服务器进行交互后,您可能会渴望为自己选择的任何协议创建自定义客户端和服务器。Python 在低级网络接口上提供了很好的覆盖。一切都始于 BSD 套接字接口。正如您可以想象的那样,Python 有一个socket模块,为您提供了与套接字接口一起工作所需的功能。如果您以前在 C/C++等其他语言中进行过套接字编程,您会喜欢 Python 的socket模块。
在本章中,我们将通过创建各种 Python 脚本来探索套接字模块。
本章的亮点如下:
-
套接字基础
-
使用 TCP 套接字
-
使用 UDP 套接字
-
TCP 端口转发
-
非阻塞套接字 I/O
-
使用 SSL/TLS 保护套接字
-
创建自定义 SSL 客户端/服务器
套接字基础
任何编程语言中的网络编程都可以从套接字开始。但是什么是套接字?简而言之,网络套接字是实体可以进行进程间通信的虚拟端点。例如,一台计算机中的一个进程与另一台计算机上的一个进程交换数据。我们通常将发起通信的第一个进程标记为客户端,后一个进程标记为服务器。
Python 有一种非常简单的方式来开始使用套接字接口。为了更好地理解这一点,让我们先了解一下整体情况。在下图中,显示了客户端/服务器交互的流程。这将让您了解如何使用套接字 API。

通过套接字进行客户端/服务器交互
在典型客户端和服务器之间的交互中,服务器进程必须做更多的工作,正如您可能已经想到的那样。创建套接字对象后,服务器进程将该套接字绑定到特定的 IP 地址和端口。这很像使用分机号的电话连接。在公司办公室中,新员工分配了他的办公电话后,通常会被分配一个新的分机号。因此,如果有人给这位员工打电话,可以使用他的电话号码和分机号建立连接。成功绑定后,服务器进程将开始监听新的客户端连接。对于有效的客户端会话,服务器进程可以接受客户端进程的请求。此时,我们可以说服务器和客户端之间的连接已经建立。
然后客户端/服务器进入请求/响应循环。客户端进程向服务器进程发送数据,服务器进程处理数据并返回响应给客户端。当客户端进程完成时,通过关闭连接退出。此时,服务器进程可能会回到监听状态。
上述客户端和服务器之间的交互是实际情况的一个非常简化的表示。实际上,任何生产服务器进程都有多个线程或子进程来处理来自成千上万客户端的并发连接,这些连接是通过各自的虚拟通道进行的。
使用 TCP 套接字
在 Python 中创建套接字对象非常简单。您只需要导入socket模块并调用socket()类:
from socket import*
import socket
#create a TCP socket (SOCK_STREAM)
s = socket.socket(family=AF_INET, type=SOCK_STREAM, proto=0)
print('Socket created')
传统上,该类需要大量参数。以下是其中一些:
-
套接字族:这是套接字的域,例如
AF_INET(大约 90%的互联网套接字属于此类别)或AF_UNIX,有时也会使用。在 Python 3 中,您可以使用AF_BLUETOOTH创建蓝牙套接字。 -
套接字类型:根据您的需求,您需要指定套接字的类型。例如,通过分别指定
SOCK_STREAM和SOCK_DGRAM来创建基于 TCP 和 UDP 的套接字。 -
协议:这指定了套接字族和类型内协议的变化。通常,它被留空为零。
由于许多原因,套接字操作可能不成功。例如,如果作为普通用户没有权限访问特定端口,可能无法绑定套接字。这就是为什么在创建套接字或进行一些网络绑定通信时进行适当的错误处理是个好主意。
让我们尝试将客户端套接字连接到服务器进程。以下代码是一个连接到服务器套接字的 TCP 客户端套接字的示例:
import socket
import sys
if __name__ == '__main__':
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
except socket.error as err:
print("Failed to crate a socket")
print("Reason: %s" %str(err))
sys.exit();
print('Socket created')
target_host = input("Enter the target host name to connect: ")
target_port = input("Enter the target port: ")
try:
sock.connect((target_host, int(target_port)))
print("Socket Connected to %s on port: %s" %(target_host, target_port))
sock.shutdown(2)
except socket.error as err:
print("Failed to connect to %s on port %s" %(target_host, target_port))
print("Reason: %s" %str(err))
sys.exit();
如果您运行上述的 TCP 客户端,将显示类似以下的输出:
**# python 7_1_tcp_client_socket.py**
**Socket created**
**Enter the target host name to connect: 'www.python.org'**
**Enter the target port: 80**
**Socket Connected to www.python.org on port: 80**
然而,如果由于某种原因套接字创建失败,比如无效的 DNS,将显示类似以下的输出:
**# python 7_1_tcp_client_socket.py**
**Socket created**
**Enter the target host name to connect: www.asgdfdfdkflakslalalasdsdsds.invalid**
**Enter the target port: 80**
**Failed to connect to www.asgdfdfdkflakslalalasdsdsds.invalid on port 80**
**Reason: [Errno -2] Name or service not known**
现在,让我们与服务器交换一些数据。以下代码是一个简单 TCP 客户端的示例:
import socket
HOST = 'www.linux.org' # or 'localhost'
PORT = 80
BUFSIZ = 4096
ADDR = (HOST, PORT)
if __name__ == '__main__':
client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_sock.connect(ADDR)
while True:
data = 'GET / HTTP/1.0\r\n\r\n'
if not data:
break
client_sock.send(data.encode('utf-8'))
data = client_sock.recv(BUFSIZ)
if not data:
break
print(data.decode('utf-8'))
client_sock.close()
如果您仔细观察,您会发现上述的代码实际上创建了一个从 Web 服务器获取网页的原始 HTTP 客户端。它发送一个 HTTP 的GET请求来获取主页:
**# python 7_2_simple_tcp_client.py**
**HTTP/1.1 200 OK**
**Date: Sat, 07 Mar 2015 16:23:02 GMT**
**Server: Apache**
**Last-Modified: Mon, 17 Feb 2014 03:19:34 GMT**
**Accept-Ranges: bytes**
**Content-Length: 111**
**Connection: close**
**Content-Type: text/html**
**<html><head><META HTTP-EQUIV="refresh" CONTENT="0;URL=/cgi- sys/defaultwebpage.cgi"></head><body></body></html>**
检查客户端/服务器通信
通过交换网络数据包进行的客户端和服务器之间的交互可以使用任何网络数据包捕获工具进行分析,比如 Wireshark。您可以配置 Wireshark 通过端口或主机过滤数据包。在这种情况下,我们可以通过端口 80 进行过滤。您可以在捕获 | 选项菜单下找到选项,并在捕获过滤器选项旁边的输入框中输入port 80,如下面的屏幕截图所示:

在接口选项中,我们选择捕获通过任何接口传递的数据包。现在,如果您运行上述的 TCP 客户端连接到www.linux.org,您可以在 Wireshark 中看到交换的数据包序列,如下面的屏幕截图所示:

正如您所见,前三个数据包通过客户端和服务器之间的三次握手过程建立了 TCP 连接。我们更感兴趣的是第四个数据包,它向服务器发出了 HTTP 的GET请求。如果您双击所选行,您可以看到 HTTP 请求的详细信息,如下面的屏幕截图所示:

如您所见,HTTP 的GET请求还有其他组件,比如请求 URI,版本等。现在您可以检查来自 Web 服务器的 HTTP 响应到您的客户端。它是在 TCP 确认数据包之后,也就是第六个数据包之后。在这里,服务器通常发送一个 HTTP 响应代码(在本例中是200),内容长度和数据或网页内容。这个数据包的结构如下面的屏幕截图所示:

通过对客户端和服务器之间的交互进行上述分析,您现在可以在基本层面上理解当您使用 Web 浏览器访问网页时发生了什么。在下一节中,您将看到如何创建自己的 TCP 服务器,并检查个人 TCP 客户端和服务器之间的交互。
TCP 服务器
正如您从最初的客户端/服务器交互图中所理解的,服务器进程需要进行一些额外的工作。它需要绑定到套接字地址并监听传入的连接。以下代码片段显示了如何创建一个 TCP 服务器:
import socket
from time import ctime
HOST = 'localhost'
PORT = 12345
BUFSIZ = 1024
ADDR = (HOST, PORT)
if __name__ == '__main__':
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(ADDR)
server_socket.listen(5)
server_socket.setsockopt( socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 )
while True:
print('Server waiting for connection...')
client_sock, addr = server_socket.accept()
print('Client connected from: ', addr)
while True:
data = client_sock.recv(BUFSIZ)
if not data or data.decode('utf-8') == 'END':
break
print("Received from client: %s" % data.decode('utf- 8'))
print("Sending the server time to client: %s" %ctime())
try:
client_sock.send(bytes(ctime(), 'utf-8'))
except KeyboardInterrupt:
print("Exited by user")
client_sock.close()
server_socket.close()
让我们修改之前的 TCP 客户端,向任何服务器发送任意数据。以下是一个增强型 TCP 客户端的示例:
import socket
HOST = 'localhost'
PORT = 12345
BUFSIZ = 256
if __name__ == '__main__':
client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
host = input("Enter hostname [%s]: " %HOST) or HOST
port = input("Enter port [%s]: " %PORT) or PORT
sock_addr = (host, int(port))
client_sock.connect(sock_addr)
payload = 'GET TIME'
try:
while True:
client_sock.send(payload.encode('utf-8'))
data = client_sock.recv(BUFSIZ)
print(repr(data))
more = input("Want to send more data to server[y/n] :")
if more.lower() == 'y':
payload = input("Enter payload: ")
else:
break
except KeyboardInterrupt:
print("Exited by user")
client_sock.close()
如果您在一个控制台中运行上述的 TCP 服务器,另一个控制台中运行 TCP 客户端,您可以看到客户端和服务器之间的以下交互。运行 TCP 服务器脚本后,您将得到以下输出:
**# python 7_3_tcp_server.py**
**Server waiting for connection...**
**Client connected from: ('127.0.0.1', 59961)**
**Received from client: GET TIME**
**Sending the server time to client: Sun Mar 15 12:09:16 2015**
**Server waiting for connection...**
当您在另一个终端上运行 TCP 客户端脚本时,您将得到以下输出:
**# python 7_4_tcp_client_socket_send_data.py**
**Enter hostname [www.linux.org]: localhost**
**Enter port [80]: 12345**
**b'Sun Mar 15 12:09:16 2015'**
**Want to send more data to server[y/n] :n**
检查客户端/服务器交互
现在,您可以再次配置 Wireshark 来捕获数据包,就像上一节讨论的那样。但是,在这种情况下,您需要指定服务器正在侦听的端口(在上面的示例中是12345),如下面的屏幕截图所示:

由于我们在非标准端口上捕获数据包,Wireshark 不会在数据部分解码它(如上面屏幕截图的中间窗格所示)。但是,您可以在底部窗格上看到解码后的文本,服务器的时间戳显示在右侧。
使用 UDP 套接字
与 TCP 不同,UDP 不会检查交换的数据报中的错误。我们可以创建类似于 TCP 客户端/服务器的 UDP 客户端/服务器。唯一的区别是在创建套接字对象时,您必须指定SOCK_DGRAM而不是SOCK_STREAM。
让我们创建一个 UDP 服务器。使用以下代码创建 UDP 服务器:
from socket import socket, AF_INET, SOCK_DGRAM
maxsize = 4096
sock = socket(AF_INET,SOCK_DGRAM)
sock.bind(('',12345))
while True:
data, addr = sock.recvfrom(maxsize)
resp = "UDP server sending data"
sock.sendto(resp,addr)
现在,您可以创建一个 UDP 客户端,向 UDP 服务器发送一些数据,如下面的代码所示:
from socket import socket, AF_INET, SOCK_DGRAM
MAX_SIZE = 4096
PORT = 12345
if __name__ == '__main__':
sock = socket(AF_INET,SOCK_DGRAM)
msg = "Hello UDP server"
sock.sendto(msg.encode(),('', PORT))
data, addr = sock.recvfrom(MAX_SIZE)
print("Server says:")
print(repr(data))
在上面的代码片段中,UDP 客户端发送一行文本Hello UDP server并从服务器接收响应。下面的屏幕截图显示了客户端发送到服务器的请求:

下面的屏幕截图显示了服务器发送给客户端的响应。在检查 UDP 客户端/服务器数据包之后,我们可以很容易地看到 UDP 比 TCP 简单得多。它通常被称为无连接协议,因为没有涉及确认或错误检查。

TCP 端口转发
我们可以使用 TCP 套接字编程进行一些有趣的实验,比如设置 TCP 端口转发。这有很好的用例。例如,如果您在没有 SSL 能力进行安全通信的公共服务器上运行不安全的程序(FTP 密码可以在传输过程中以明文形式看到)。由于这台服务器可以从互联网访问,您必须确保密码是加密的,才能登录到服务器。其中一种方法是使用安全 FTP 或 SFTP。我们可以使用简单的 SSH 隧道来展示这种方法的工作原理。因此,您本地 FTP 客户端和远程 FTP 服务器之间的任何通信都将通过这个加密通道进行。
让我们运行 FTP 程序到同一个 SSH 服务器主机。但是从本地机器创建一个 SSH 隧道,这将给您一个本地端口号,并将直接连接您到远程 FTP 服务器守护程序。
Python 有一个第三方的sshtunnel模块,它是 Paramiko 的SSH库的包装器。以下是 TCP 端口转发的代码片段,显示了如何实现这个概念:
import sshtunnel
from getpass import getpass
ssh_host = '192.168.56.101'
ssh_port = 22
ssh_user = 'YOUR_SSH_USERNAME'
REMOTE_HOST = '192.168.56.101'
REMOTE_PORT = 21
from sshtunnel import SSHTunnelForwarder
ssh_password = getpass('Enter YOUR_SSH_PASSWORD: ')
server = SSHTunnelForwarder(
ssh_address=(ssh_host, ssh_port),
ssh_username=ssh_user,
ssh_password=ssh_password,
remote_bind_address=(REMOTE_HOST, REMOTE_PORT))
server.start()
print('Connect the remote service via local port: %s' %server.local_bind_port)
# work with FTP SERVICE via the `server.local_bind_port.
try:
while True:
pass
except KeyboardInterrupt:
print("Exiting user user request.\n")
server.stop()
让我们捕获从本地机器192.168.0.102到远程机器192.168.0.101的数据包传输。您将看到所有网络流量都是加密的。当您运行上述脚本时,您将获得一个本地端口号。使用ftp命令连接到该本地端口号:
**$ ftp <localhost> <local_bind_port>**
如果您运行上述命令,那么您将得到以下屏幕截图:

在上面的屏幕截图中,您看不到任何 FTP 流量。正如您所看到的,首先我们连接到本地端口5815(参见前三个数据包),然后突然之间与远程主机建立了加密会话。您可以继续观察远程流量,但是没有 FTP 的痕迹。
如果您还可以在远程机器(192.168.56.101)上捕获数据包,您可以看到 FTP 流量,如下面的屏幕截图所示:

有趣的是,您可以看到您的 FTP 密码从本地机器(通过 SSH 隧道)以明文形式发送到远程计算机,而不是通过网络发送,如下图所示:
TCP 端口转发
因此,您可以将任何敏感的网络流量隐藏在 SSL 隧道中。不仅 FTP,您还可以通过 SSH 通道加密传输远程桌面会话。
非阻塞套接字 I/O
在本节中,我们将看到一个小的示例代码片段,用于测试非阻塞套接字 I/O。如果您知道同步阻塞连接对您的程序不是必需的,这将非常有用。以下是非阻塞 I/O 的示例:
import socket
if __name__ == '__main__':
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(0)
sock.settimeout(0.5)
sock.bind(("127.0.0.1", 0))
socket_address =sock.getsockname()
print("Asynchronous socket server launched on socket: %s" %str(socket_address))
while(1):
sock.listen(1)
此脚本将以非阻塞方式运行套接字服务器并进行监听。这意味着您可以连接更多的客户端,他们不一定会因 I/O 而被阻塞。
使用 TLS/SSL 保护套接字
您可能已经遇到了使用安全套接字层(SSL)或更精确地说是传输层安全(TLS)进行安全网络通信的讨论,这已被许多其他高级协议采用。让我们看看如何使用 SSL 包装普通套接字连接。Python 具有内置的ssl模块,可以实现此目的。
在此示例中,我们希望创建一个普通的 TCP 套接字并连接到启用了 HTTPS 的 Web 服务器。然后,我们可以使用 SSL 包装该连接并检查连接的各种属性。例如,要检查远程 Web 服务器的身份,我们可以查看 SSL 证书中的主机名是否与我们期望的相同。以下是一个基于安全套接字的客户端的示例:
import socket
import ssl
from ssl import wrap_socket, CERT_NONE, PROTOCOL_TLSv1, SSLError
from ssl import SSLContext
from ssl import HAS_SNI
from pprint import pprint
TARGET_HOST = 'www.google.com'
SSL_PORT = 443
# Use the path of CA certificate file in your system
CA_CERT_PATH = '/usr/local/lib/python3.3/dist- packages/requests/cacert.pem'
def ssl_wrap_socket(sock, keyfile=None, certfile=None, cert_reqs=None, ca_certs=None, server_hostname=None, ssl_version=None):
context = SSLContext(ssl_version)
context.verify_mode = cert_reqs
if ca_certs:
try:
context.load_verify_locations(ca_certs)
except Exception as e:
raise SSLError(e)
if certfile:
context.load_cert_chain(certfile, keyfile)
if HAS_SNI: # OpenSSL enabled SNI
return context.wrap_socket(sock, server_hostname=server_hostname)
return context.wrap_socket(sock)
if __name__ == '__main__':
hostname = input("Enter target host:") or TARGET_HOST
client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_sock.connect((hostname, 443))
ssl_socket = ssl_wrap_socket(client_sock, ssl_version=PROTOCOL_TLSv1, cert_reqs=ssl.CERT_REQUIRED, ca_certs=CA_CERT_PATH, server_hostname=hostname)
print("Extracting remote host certificate details:")
cert = ssl_socket.getpeercert()
pprint(cert)
if not cert or ('commonName', TARGET_HOST) not in cert['subject'][4]:
raise Exception("Invalid SSL cert for host %s. Check if this is a man-in-the-middle attack!" )
ssl_socket.write('GET / \n'.encode('utf-8'))
#pprint(ssl_socket .recv(1024).split(b"\r\n"))
ssl_socket.close()
client_sock.close()
如果运行上述示例,您将看到远程 Web 服务器(例如www.google.com)的 SSL 证书的详细信息。在这里,我们创建了一个 TCP 套接字并将其连接到 HTTPS 端口443。然后,该套接字连接使用我们的ssl_wrap_socket()函数包装成 SSL 数据包。此函数将以下参数作为参数:
-
sock:TCP 套接字 -
keyfile:SSL 私钥文件路径 -
certfile:SSL 公共证书路径 -
cert_reqs:确认是否需要来自另一方的证书以建立连接,以及是否需要验证测试 -
ca_certs:公共证书颁发机构证书路径 -
server_hostname:目标远程服务器的主机名 -
ssl_version:客户端要使用的预期 SSL 版本
在 SSL 套接字包装过程开始时,我们使用SSLContext()类创建了一个 SSL 上下文。这是必要的,以设置 SSL 连接的特定属性。除了使用自定义上下文外,我们还可以使用ssl模块默认提供的默认上下文,使用create_default_context()函数。您可以使用常量指定是否要创建客户端或服务器端套接字。以下是创建客户端套接字的示例:
context = ssl.create_default_context(Purpose.SERVER_AUTH)
SSLContext对象接受 SSL 版本参数,在我们的示例中设置为PROTOCOL_TLSv1,或者您应该使用最新版本。请注意,SSLv2 和 SSLv3 已经被破解,严重的安全问题不能在任何生产代码中使用。
在上面的示例中,CERT_REQUIRED表示连接需要服务器证书,并且稍后将验证此证书。
如果已提供 CA 证书参数并提供了证书路径,则使用load_verify_locations()方法加载 CA 证书文件。这将用于验证对等服务器证书。如果您想在系统上使用默认证书路径,您可能会调用另一个上下文方法;load_default_certs(purpose=Purpose.SERVER_AUTH)。
当我们在服务器端操作时,通常使用load_cert_chain()方法加载密钥和证书文件,以便客户端可以验证服务器的真实性。
最后,调用wrap_socket()方法返回一个 SSL 包装套接字。请注意,如果OpenSSL库启用了服务器名称指示(SNI)支持,您可以在包装套接字时传递远程服务器的主机名。当远程服务器使用不同的 SSL 证书为单个 IP 地址使用不同的安全服务,例如基于名称的虚拟主机时,这将非常有用。
如果运行上述 SSL 客户端代码,您将看到远程服务器的 SSL 证书的各种属性,如下图所示。这用于通过调用getpeercert()方法验证远程服务器的真实性,并将其与返回的主机名进行比较。

有趣的是,如果任何其他虚假的 Web 服务器想要假冒 Google 的 Web 服务器,除非您检查由认可的证书颁发机构签署的 SSL 证书,否则它根本无法做到这一点,除非认可的 CA 已被破坏/颠覆。对您的 Web 浏览器进行的这种形式的攻击通常被称为中间人(MITM)攻击。
检查标准 SSL 客户端/服务器通信
以下屏幕截图显示了 SSL 客户端与远程服务器之间的交互:

让我们来看看客户端和服务器之间的 SSL 握手过程。在 SSL 握手的第一步中,客户端向远程服务器发送一个Hello消息,说明它在处理密钥文件、加密消息、进行消息完整性检查等方面的能力。在下面的屏幕截图中,您可以看到客户端向服务器呈现了一组38个密码套件,以选择相关的算法。它还发送了 TLS 版本号1.0和一个随机数,用于生成用于加密后续消息交换的主密钥。这有助于防止任何第三方查看数据包内容。在Hello消息中看到的随机数用于生成预主密钥,双方将进一步处理以得到主密钥,然后使用该密钥生成对称密钥。

在服务器发送到客户端的第二个数据包中,服务器选择了密码套件TLS_ECDHE_RSA_WITH_RC4_128_SHA以连接到客户端。这大致意味着服务器希望使用 RSA 算法处理密钥,使用 RC4 进行加密,并使用 SHA 进行完整性检查(哈希)。这在以下屏幕截图中显示:

在 SSL 握手的第二阶段,服务器向客户端发送 SSL 证书。如前所述,此证书由 CA 颁发。它包含序列号、公钥、有效期和主题和颁发者的详细信息。以下屏幕截图显示了远程服务器的证书。您能在数据包中找到服务器的公钥吗?

在握手的第三阶段,客户端交换密钥并计算主密钥以加密消息并继续进一步通信。客户端还发送更改在上一阶段达成的密码规范的请求。然后指示开始加密消息。以下屏幕截图显示了这个过程:

在 SSL 握手过程的最后一个任务中,服务器为客户端的特定会话生成了一个新的会话票证。这是由于 TLS 扩展引起的,客户端通过在客户端Hello消息中发送一个空的会话票证扩展来宣传其支持。服务器在其服务器Hello消息中回答一个空的会话票证扩展。这个会话票证机制使客户端能够记住整个会话状态,服务器在维护服务器端会话缓存方面变得不那么忙碌。以下截图显示了一个呈现 SSL 会话票证的示例:

创建自定义 SSL 客户端/服务器
到目前为止,我们更多地处理 SSL 或 TLS 客户端。现在,让我们简要地看一下服务器端。由于您已经熟悉 TCP/UDP 套接字服务器创建过程,让我们跳过那部分,只集中在 SSL 包装部分。以下代码片段显示了一个简单 SSL 服务器的示例:
import socket
import ssl
SSL_SERVER_PORT = 8000
if __name__ == '__main__':
server_socket = socket.socket()
server_socket.bind(('', SSL_SERVER_PORT))
server_socket.listen(5)
print("Waiting for ssl client on port %s" %SSL_SERVER_PORT)
newsocket, fromaddr = server_socket.accept()
# Generate your server's public certificate and private key pairs.
ssl_conn = ssl.wrap_socket(newsocket, server_side=True, certfile="server.crt", keyfile="server.key", ssl_version=ssl.PROTOCOL_TLSv1)
print(ssl_conn.read())
ssl_conn.write('200 OK\r\n\r\n'.encode())
print("Served ssl client. Exiting...")
ssl_conn.close()
server_socket.close()
正如您所看到的,服务器套接字被wrap_socket()方法包装,该方法使用一些直观的参数,如certfile、keyfile和SSL版本号。您可以通过按照互联网上找到的任何逐步指南轻松生成证书。例如,www.akadia.com/services/ssh_test_certificate.html建议通过几个步骤生成 SSL 证书。
现在,让我们制作一个简化版本的 SSL 客户端,与上述 SSL 服务器进行通信。以下代码片段显示了一个简单 SSL 客户端的示例:
from socket import socket
import ssl
from pprint import pprint
TARGET_HOST ='localhost'
TARGET_PORT = 8000
CA_CERT_PATH = 'server.crt'
if __name__ == '__main__':
sock = socket()
ssl_conn = ssl.wrap_socket(sock, cert_reqs=ssl.CERT_REQUIRED, ssl_version=ssl.PROTOCOL_TLSv1, ca_certs=CA_CERT_PATH)
target_host = TARGET_HOST
target_port = TARGET_PORT
ssl_conn.connect((target_host, int(target_port)))
# get remote cert
cert = ssl_conn.getpeercert()
print("Checking server certificate")
pprint(cert)
if not cert or ssl.match_hostname(cert, target_host):
raise Exception("Invalid SSL cert for host %s. Check if this is a man-in-the-middle attack!" %target_host )
print("Server certificate OK.\n Sending some custom request... GET ")
ssl_conn.write('GET / \n'.encode('utf-8'))
print("Response received from server:")
print(ssl_conn.read())
ssl_conn.close()
运行客户端/服务器将显示类似于以下截图的输出。您能否看到与我们上一个示例客户端/服务器通信相比有什么不同?

检查自定义 SSL 客户端/服务器之间的交互
让我们再次检查 SSL 客户端/服务器的交互,以观察其中的差异。第一个截图显示了整个通信序列。在以下截图中,我们可以看到服务器的Hello和证书合并在同一消息中。

客户端的客户端 Hello数据包看起来与我们之前的 SSL 连接非常相似,如下截图所示:

服务器的服务器 Hello数据包有点不同。您能识别出区别吗?密码规范不同,即TLS_RSA_WITH_AES_256_CBC_SHA,如下截图所示:

客户端密钥交换数据包看起来也很熟悉,如下截图所示:

以下截图显示了在此连接中提供的新会话票证数据包:

现在让我们来看一下应用数据。那加密了吗?对于捕获的数据包,它看起来像垃圾。以下截图显示了隐藏真实数据的加密消息。这就是我们使用 SSL/TLS 想要实现的效果。

总结
在本章中,我们讨论了使用 Python 的socket和ssl模块进行基本的 TCP/IP 套接字编程。我们演示了如何将简单的 TCP 套接字包装为 TLS,并用于传输加密数据。我们还发现了使用 SSL 证书验证远程服务器真实性的方法。还介绍了套接字编程中的一些其他小问题,比如非阻塞套接字 I/O。每个部分中的详细数据包分析帮助我们了解套接字编程练习中发生了什么。
在下一章中,我们将学习关于套接字服务器设计,特别是流行的多线程和事件驱动方法。
第八章:客户端和服务器应用程序
在上一章中,我们通过使用套接字接口来查看设备之间的数据交换。在本章中,我们将使用套接字来构建网络应用程序。套接字遵循计算机网络的主要模型之一,即客户端/服务器模型。我们将重点关注构建服务器应用程序。我们将涵盖以下主题:
-
设计一个简单的协议
-
构建回声服务器和客户端
-
构建聊天服务器和客户端
-
多线程和事件驱动的服务器架构
-
eventlet和asyncio库
本章的示例最好在 Linux 或 Unix 操作系统上运行。Windows 套接字实现有一些特殊之处,这可能会导致一些错误条件,我们在这里不会涉及。请注意,Windows 不支持我们将在一个示例中使用的poll接口。如果您使用 Windows,那么您可能需要使用ctrl + break来在控制台中终止这些进程,而不是使用ctrl - c,因为在 Windows 命令提示符中,当 Python 在套接字发送或接收时阻塞时,它不会响应ctrl - c,而在本章中这种情况会经常发生!(如果像我一样,不幸地尝试在没有break键的 Windows 笔记本上测试这些内容,那么请准备好熟悉 Windows 任务管理器的结束任务按钮)。
客户端和服务器
客户端/服务器模型中的基本设置是一个设备,即运行服务并耐心等待客户端连接并请求服务的服务器。一个 24 小时的杂货店可能是一个现实世界的类比。商店等待顾客进来,当他们进来时,他们请求某些产品,购买它们然后离开。商店可能会进行广告以便人们知道在哪里找到它,但实际的交易发生在顾客访问商店时。
一个典型的计算示例是一个 Web 服务器。服务器在 TCP 端口上监听需要其网页的客户端。例如,当客户端,例如 Web 浏览器,需要服务器托管的网页时,它连接到服务器然后请求该页面。服务器回复页面的内容,然后客户端断开连接。服务器通过具有主机名来进行广告,客户端可以使用该主机名来发现 IP 地址,以便连接到它。
在这两种情况下,都是客户端发起任何交互-服务器纯粹是对该交互的响应。因此,运行在客户端和服务器上的程序的需求是非常不同的。
客户端程序通常面向用户和服务之间的接口。它们检索和显示服务,并允许用户与之交互。服务器程序被编写为长时间运行,保持稳定,高效地向请求服务的客户端提供服务,并可能处理大量同时连接而对任何一个客户端的体验影响最小化。
在本章中,我们将通过编写一个简单的回声服务器和客户端来查看这个模型,然后将其升级为一个可以处理多个客户端会话的聊天服务器。Python 中的socket模块非常适合这项任务。
回声协议
在编写我们的第一个客户端和服务器程序之前,我们需要决定它们将如何相互交互,也就是说,我们需要为它们的通信设计一个协议。
我们的回声服务器应该保持监听,直到客户端连接并发送一个字节字符串,然后我们希望它将该字符串回显给客户端。我们只需要一些基本规则来做到这一点。这些规则如下:
-
通信将通过 TCP 进行。
-
客户端将通过创建套接字连接到服务器来启动回声会话。
-
服务器将接受连接并监听客户端发送的字节字符串。
-
客户端将向服务器发送一个字节字符串。
-
一旦它发送了字节字符串,客户端将等待服务器的回复
-
当服务器从客户端接收到字节字符串时,它将把字节字符串发送回客户端。
-
当客户端从服务器接收了字节字符串后,它将关闭其套接字以结束会话。
这些步骤足够简单。这里缺少的元素是服务器和客户端如何知道何时发送了完整的消息。请记住,应用程序将 TCP 连接视为无尽的字节流,因此我们需要决定字节流中的什么将表示消息的结束。
框架
这个问题被称为分帧,我们可以采取几种方法来处理它。主要方法如下:
-
将其作为协议规则,每次连接只发送一个消息,一旦发送了消息,发送方将立即关闭套接字。
-
使用固定长度的消息。接收方将读取字节数,并知道它们有整个消息。
-
在消息前加上消息的长度。接收方将首先从流中读取消息的长度,然后读取指示的字节数以获取消息的其余部分。
-
使用特殊字符定界符指示消息的结束。接收方将扫描传入的流以查找定界符,并且消息包括定界符之前的所有内容。
选项 1 是非常简单协议的一个很好选择。它易于实现,不需要对接收到的流进行任何特殊处理。但是,它需要为每条消息建立和拆除套接字,当服务器同时处理多条消息时,这可能会影响性能。
选项 2 再次实现简单,但只有在我们的数据以整齐的固定长度块出现时才能有效利用网络。例如,在聊天服务器中,消息长度是可变的,因此我们将不得不使用特殊字符,例如空字节,来填充消息到块大小。这仅适用于我们确切知道填充字符永远不会出现在实际消息数据中的情况。还有一个额外的问题,即如何处理长于块长度的消息。
选项 3 通常被认为是最佳方法之一。虽然编码可能比其他选项更复杂,但实现仍然相当简单,并且它有效地利用了带宽。包括每条消息的长度所带来的开销通常与消息长度相比是微不足道的。它还避免了对接收到的数据进行任何额外处理的需要,这可能是选项 4 的某些实现所需要的。
选项 4 是最节省带宽的选项,当我们知道消息中只会使用有限的字符集,例如 ASCII 字母数字字符时,这是一个很好的选择。如果是这种情况,那么我们可以选择一个定界字符,例如空字节,它永远不会出现在消息数据中,然后当遇到这个字符时,接收到的数据可以很容易地被分成消息。实现通常比选项 3 简单。虽然可以将此方法用于任意数据,即定界符也可能出现为消息中的有效字符,但这需要使用字符转义,这需要对数据进行额外的处理。因此,在这些情况下,通常更简单的是使用长度前缀。
对于我们的回显和聊天应用程序,我们将使用 UTF-8 字符集发送消息。在 UTF-8 中,除了空字节本身,空字节在任何字符中都不使用,因此它是一个很好的分隔符。因此,我们将使用空字节作为定界符来对我们的消息进行分帧。
因此,我们的规则 8 将变为:
消息将使用 UTF-8 字符集进行编码传输,并以空字节终止。
现在,让我们编写我们的回显程序。
一个简单的回显服务器
当我们在本章中工作时,我们会发现自己在重复使用几段代码,因此为了避免重复,我们将设置一个具有有用函数的模块,我们可以在以后重复使用。创建一个名为tincanchat.py的文件,并将以下代码保存在其中:
import socket
HOST = ''
PORT = 4040
def create_listen_socket(host, port):
""" Setup the sockets our server will receive connection requests on """
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))
sock.listen(100)
return sock
def recv_msg(sock):
""" Wait for data to arrive on the socket, then parse into messages using b'\0' as message delimiter """
data = bytearray()
msg = ''
# Repeatedly read 4096 bytes off the socket, storing the bytes
# in data until we see a delimiter
while not msg:
recvd = sock.recv(4096)
if not recvd:
# Socket has been closed prematurely
raise ConnectionError()
data = data + recvd
if b'\0' in recvd:
# we know from our protocol rules that we only send
# one message per connection, so b'\0' will always be
# the last character
msg = data.rstrip(b'\0')
msg = msg.decode('utf-8')
return msg
def prep_msg(msg):
""" Prepare a string to be sent as a message """
msg += '\0'
return msg.encode('utf-8')
def send_msg(sock, msg):
""" Send a string over a socket, preparing it first """
data = prep_msg(msg)
sock.sendall(data)
首先,我们定义一个默认接口和要侦听的端口号。在HOST变量中指定的空的''接口告诉socket.bind()侦听所有可用的接口。如果要将访问限制为仅限于您的计算机,则将代码开头的HOST变量的值更改为127.0.0.1。
我们将使用create_listen_socket()来设置我们的服务器监听连接。这段代码对于我们的几个服务器程序是相同的,因此重复使用它是有意义的。
recv_msg()函数将被我们的回显服务器和客户端用于从套接字接收消息。在我们的回显协议中,我们的程序在等待接收消息时不需要做任何事情,因此此函数只是在循环中调用socket.recv(),直到接收到整个消息为止。根据我们的分帧规则,它将在每次迭代中检查累积的数据,以查看是否收到了空字节,如果是,则将返回接收到的数据,去掉空字节并解码为 UTF-8。
send_msg()和prep_msg()函数一起用于对消息进行分帧和发送。我们将空字节终止和 UTF-8 编码分离到prep_msg()中,因为我们将在以后单独使用它们。
处理接收到的数据
请注意,就字符串编码而言,我们在发送和接收函数之间划定了一条谨慎的界限。Python 3 字符串是 Unicode,而我们通过网络接收的数据是字节。我们最不想做的最后一件事就是在程序的其余部分处理这些数据的混合,因此我们将在程序的边界处仔细编码和解码数据,数据进入和离开网络的地方。这将确保我们代码的其余部分可以假定它们将使用 Python 字符串,这将在以后为我们带来很多便利。
当然,并非我们可能想要通过网络发送或接收的所有数据都是文本。例如,图像、压缩文件和音乐无法解码为 Unicode 字符串,因此需要一种不同的处理方式。通常,这将涉及将数据加载到类中,例如Python Image Library(PIL)图像,如果我们要以某种方式操作对象。
在对接收到的数据进行完整处理之前,可以在此处对接收到的数据进行基本检查,以快速标记数据中的任何问题。此类检查的一些示例如下:
-
检查接收到的数据的长度
-
检查文件的前几个字节是否有魔术数字来确认文件类型
-
检查更高级别协议头的值,例如
HTTP请求中的Host头
这种检查将允许我们的应用程序在出现明显问题时快速失败。
服务器本身
现在,让我们编写我们的回显服务器。打开一个名为1.1-echo-server-uni.py的新文件,并将以下代码保存在其中:
import tincanchat
HOST = tincanchat.HOST
PORT = tincanchat.PORT
def handle_client(sock, addr):
""" Receive data from the client via sock and echo it back """
try:
msg = tincanchat.recv_msg(sock) # Blocks until received
# complete message
print('{}: {}'.format(addr, msg))
tincanchat.send_msg(sock, msg) # Blocks until sent
except (ConnectionError, BrokenPipeError):
print('Socket error')
finally:
print('Closed connection to {}'.format(addr))
sock.close()
if __name__ == '__main__':
listen_sock = tincanchat.create_listen_socket(HOST, PORT)
addr = listen_sock.getsockname()
print('Listening on {}'.format(addr))
while True:
client_sock, addr = listen_sock.accept()
print('Connection from {}'.format(addr))
handle_client(client_sock, addr)
这是一个服务器可以变得多么简单的例子!首先,我们使用create_listen_socket()调用设置我们的监听套接字。其次,我们进入我们的主循环,在那里我们永远监听来自客户端的传入连接,阻塞在listen_sock.accept()上。当客户端连接进来时,我们调用handle_client()函数,根据我们的协议处理客户端。我们为此代码创建了一个单独的函数,部分原因是为了保持主循环的整洁,部分原因是因为我们将来会想要在后续程序中重用这组操作。
这就是我们的服务器,现在我们只需要创建一个客户端来与它通信。
一个简单的回显客户端
创建一个名为1.2-echo_client-uni.py的文件,并将以下代码保存在其中:
import sys, socket
import tincanchat
HOST = sys.argv[-1] if len(sys.argv) > 1 else '127.0.0.1'
PORT = tincanchat.PORT
if __name__ == '__main__':
while True:
try:
sock = socket.socket(socket.AF_INET,
socket.SOCK_STREAM)
sock.connect((HOST, PORT))
print('\nConnected to {}:{}'.format(HOST, PORT))
print("Type message, enter to send, 'q' to quit")
msg = input()
if msg == 'q': break
tincanchat.send_msg(sock, msg) # Blocks until sent
print('Sent message: {}'.format(msg))
msg = tincanchat.recv_msg(sock) # Block until
# received complete
# message
print('Received echo: ' + msg)
except ConnectionError:
print('Socket error')
break
finally:
sock.close()
print('Closed connection to server\n')
如果我们在与运行客户端的计算机不同的计算机上运行服务器,则可以将服务器的 IP 地址或主机名作为命令行参数提供给客户端程序。如果不这样做,它将默认尝试连接到本地主机。
代码的第三和第四行检查服务器地址的命令行参数。一旦确定要连接的服务器,我们进入我们的主循环,该循环将一直循环,直到我们通过输入q来终止客户端。在主循环中,我们首先创建与服务器的连接。其次,我们提示用户输入要发送的消息,然后使用tincanchat.send_msg()函数发送消息。然后我们等待服务器的回复。一旦收到回复,我们打印它,然后根据我们的协议关闭连接。
尝试运行我们的客户端和服务器。通过使用以下命令在终端中运行服务器:
**$ python 1.1-echo_server-uni.py**
**Listening on ('0.0.0.0', 4040)**
在另一个终端中,运行客户端并注意,如果您需要连接到另一台计算机,您将需要指定服务器,如下所示:
**$ python 1.2-echo_client.py 192.168.0.7**
**Type message, enter to send, 'q' to quit**
并排运行终端是一个好主意,因为您可以同时看到程序的行为。
在客户端中输入一些消息,看看服务器如何接收并将它们发送回来。与客户端断开连接也应该在服务器上提示通知。
并发 I/O
如果您有冒险精神,那么您可能已经尝试过同时使用多个客户端连接到我们的服务器。如果您尝试从它们中的两个发送消息,那么您会发现它并不像我们希望的那样工作。如果您还没有尝试过,请试一试。
客户端上的工作回显会话应该是这样的:
**Type message, enter to send. 'q' to quit**
**hello world**
**Sent message: hello world**
**Received echo: hello world**
**Closed connection to server**
然而,当尝试使用第二个连接的客户端发送消息时,我们会看到类似这样的情况:
**Type message, enter to send. 'q' to quit**
**hello world**
**Sent message: hello world**
当发送消息时,客户端将挂起,并且不会收到回显回复。您还可能注意到,如果我们使用第一个连接的客户端发送消息,那么第二个客户端将收到其响应。那么,这里发生了什么?
问题在于服务器一次只能监听来自一个客户端的消息。一旦第一个客户端连接,服务器就会在tincanchat.recv_msg()中的socket.recv()调用处阻塞,等待第一个客户端发送消息。在此期间,服务器无法接收其他客户端的消息,因此当另一个客户端发送消息时,该客户端也会阻塞,等待服务器发送回复。
这是一个稍微牵强的例子。在这种情况下,可以通过在建立与服务器的连接之前要求用户输入来轻松解决客户端端的问题。但是在我们完整的聊天服务中,客户端需要能够同时监听来自服务器的消息,同时等待用户输入。这在我们目前的程序设置中是不可能的。
解决这个问题有两种方法。我们可以使用多个线程或进程,或者使用非阻塞套接字以及事件驱动架构。我们将研究这两种方法,首先从多线程开始。
多线程和多进程
Python 具有允许我们编写多线程和多进程应用程序的 API。多线程和多进程背后的原则很简单,即复制我们的代码并在额外的线程或进程中运行它们。操作系统会自动调度可用 CPU 核心上的线程和进程,以提供公平的处理时间分配给所有线程和进程。这有效地允许程序同时运行多个操作。此外,当线程或进程阻塞时,例如等待 IO 时,操作系统可以将线程或进程降低优先级,并将 CPU 核心分配给其他有实际计算任务的线程或进程。
以下是线程和进程之间关系的概述:

线程存在于进程内。 一个进程可以包含多个线程,但它始终至少包含一个线程,有时称为主线程。 同一进程中的线程共享内存,因此线程之间的数据传输只是引用共享对象的情况。 进程不共享内存,因此必须使用其他接口(如文件,套接字或专门分配的共享内存区域)来在进程之间传输数据。
当线程有操作要执行时,它们会请求操作系统线程调度程序为它们分配一些 CPU 时间,调度程序会根据各种参数(从 OS 到 OS 不等)将等待的线程分配给 CPU 核心。 同一进程中的线程可以同时在不同的 CPU 核心上运行。
尽管在前面的图中显示了两个进程,但这里并没有进行多进程处理,因为这些进程属于不同的应用程序。 显示第二个进程是为了说明 Python 线程和大多数其他程序中线程之间的一个关键区别。 这个区别就是 GIL 的存在。
线程和 GIL
CPython 解释器(可从www.python.org下载的 Python 标准版本)包含一个称为全局解释器锁(GIL)的东西。 GIL 的存在是为了确保在 Python 进程中只能同时运行一个线程,即使存在多个 CPU 核心。 有 GIL 的原因是它使 Python 解释器的底层 C 代码更容易编写和维护。 这样做的缺点是,使用多线程的 Python 程序无法利用多个核心进行并行计算。
这是一个引起很多争议的原因; 但是,对我们来说,这并不是一个大问题。 即使有 GIL 存在,仍然在 I/O 阻塞的线程被 OS 降低优先级并置于后台,因此有计算工作要做的线程可以运行。 以下图是这一点的简化说明:

等待 GIL状态是指线程已发送或接收了一些数据,因此准备退出阻塞状态,但另一个线程拥有 GIL,因此准备好的线程被迫等待。 在许多网络应用程序中,包括我们的回显和聊天服务器,等待 I/O 的时间远远高于处理数据的时间。 只要我们没有非常多的连接(这是我们在后面讨论事件驱动架构时会讨论的情况),由 GIL 引起的线程争用相对较低,因此线程仍然是这些网络服务器应用程序的合适架构。
考虑到这一点,我们将在我们的回显服务器中使用多线程而不是多进程。 共享数据模型将简化我们需要允许聊天客户端彼此交换消息的代码,并且因为我们是 I/O 绑定的,所以我们不需要进程进行并行计算。 在这种情况下不使用进程的另一个原因是,进程在 OS 资源方面更“笨重”,因此创建新进程比创建新线程需要更长的时间。 进程还使用更多内存。
需要注意的一点是,如果您需要在网络服务器应用程序中执行密集计算(也许您需要在将大型文件发送到网络之前对其进行压缩),那么您应该调查在单独的进程中运行此操作的方法。 由于 GIL 的实现中存在一些怪癖,即使在多个 CPU 核心可用时,将单个计算密集型线程放在主要是 I/O 绑定的进程中也会严重影响所有 I/O 绑定线程的性能。 有关更多详细信息,请查看以下信息框中链接到的 David Beazley 演示文稿:
注意
进程和线程是不同的东物,如果你对这些区别不清楚,值得阅读一下。一个很好的起点是维基百科关于线程的文章,可以在en.wikipedia.org/wiki/Thread_(computing)找到。
本主题的一个很好的概述在 Benjamin Erb 的论文第四章中给出,可以在berb.github.io/diploma-thesis/community/找到。
关于 GIL 的更多信息,包括保持它在 Python 中的原因,可以在官方 Python 文档中找到,网址为wiki.python.org/moin/GlobalInterpreterLock。
您还可以在 Nick Coghlan 的 Python 3 问答中阅读更多关于这个主题的内容,网址为python-notes.curiousefficiency.org/en/latest/python3/questions_and_answers.html#but-but-surely-fixing-the-gil-is-more-important-than-fixing-unicode。
最后,David Beazley 对多核系统上 GIL 的性能进行了一些引人入胜的研究。两个重要的演示资料可以在线找到。它们提供了一个与本章相关的很好的技术背景。这些可以在pyvideo.org/video/353/pycon-2010--understanding-the-python-gil---82和www.youtube.com/watch?v=5jbG7UKT1l4找到。
多线程回显服务器
多线程方法的一个好处是操作系统为我们处理线程切换,这意味着我们可以继续以过程化的方式编写程序。因此,我们只需要对服务器程序进行小的调整,使其成为多线程,并因此能够同时处理多个客户端。
创建一个名为1.3-echo_server-multi.py的新文件,并将以下代码添加到其中:
import threading
import tincanchat
HOST = tincanchat.HOST
PORT = tincanchat.PORT
def handle_client(sock, addr):
""" Receive one message and echo it back to client, then close
socket """
try:
msg = tincanchat.recv_msg(sock) # blocks until received
# complete message
msg = '{}: {}'.format(addr, msg)
print(msg)
tincanchat.send_msg(sock, msg) # blocks until sent
except (ConnectionError, BrokenPipeError):
print('Socket error')
finally:
print('Closed connection to {}'.format(addr))
sock.close()
if __name__ == '__main__':
listen_sock = tincanchat.create_listen_socket(HOST, PORT)
addr = listen_sock.getsockname()
print('Listening on {}'.format(addr))
while True:
client_sock,addr = listen_sock.accept()
# Thread will run function handle_client() autonomously
# and concurrently to this while loop
thread = threading.Thread(target=handle_client,
args=[client_sock, addr],
daemon=True)
thread.start()
print('Connection from {}'.format(addr))
您可以看到,我们刚刚导入了一个额外的模块,并修改了我们的主循环,以在单独的线程中运行handle_client()函数,而不是在主线程中运行它。对于每个连接的客户端,我们创建一个新的线程,只运行handle_client()函数。当线程在接收或发送时阻塞时,操作系统会检查其他线程是否已经退出阻塞状态,如果有任何线程退出了阻塞状态,那么它就会切换到其中一个线程。
请注意,我们在线程构造函数调用中设置了daemon参数为True。这将允许程序在我们按下ctrl - c时退出,而无需我们显式关闭所有线程。
如果您尝试使用多个客户端进行此回显服务器,则会发现第二个连接并发送消息的客户端将立即收到响应。
设计聊天服务器
我们已经有一个工作的回显服务器,它可以同时处理多个客户端,所以我们离一个功能齐全的聊天客户端很近了。然而,我们的服务器需要将接收到的消息广播给所有连接的客户端。听起来很简单,但我们需要克服两个问题才能实现这一点。
首先,我们的协议需要进行改进。如果我们考虑从客户端的角度来看需要发生什么,那么我们就不能再依赖简单的工作流程:
客户端连接 > 客户端发送 > 服务器发送 > 客户端断开连接。
客户现在可能随时接收消息,而不仅仅是当他们自己向服务器发送消息时。
其次,我们需要修改我们的服务器,以便向所有连接的客户端发送消息。由于我们使用多个线程来处理客户端,这意味着我们需要在线程之间建立通信。通过这样做,我们正在涉足并发编程的世界,这需要谨慎和深思熟虑。虽然线程的共享状态很有用,但在其简单性中也是具有欺骗性的。有多个控制线程异步访问和更改相同资源是竞争条件和微妙死锁错误的理想滋生地。虽然关于并发编程的全面讨论远远超出了本文的范围,但我们将介绍一些简单的原则,这些原则可以帮助保持您的理智。
一个聊天协议
我们协议更新的主要目的是规定客户端必须能够接受发送给它们的所有消息,无论何时发送。
理论上,一个解决方案是让我们的客户端自己建立一个监听套接字,这样服务器在有新消息要传递时就可以连接到它。在现实世界中,这个解决方案很少适用。客户端几乎总是受到某种防火墙的保护,防止任何新的入站连接连接到客户端。为了让我们的服务器连接到客户端的端口,我们需要确保任何中间的防火墙都配置为允许我们的服务器连接。这个要求会让我们的软件对大多数用户不那么吸引,因为已经有一些不需要这样做的聊天解决方案了。
如果我们不能假设服务器能够连接到客户端,那么我们需要通过仅使用客户端发起的连接到服务器来满足我们的要求。我们可以通过两种方式来做到这一点。首先,我们可以让我们的客户端默认运行在断开状态,然后定期连接到服务器,下载任何等待的消息,然后再次断开连接。或者,我们可以让我们的客户端连接到服务器,然后保持连接打开。然后他们可以持续监听连接,并在一个线程中处理服务器发送的新消息,同时在另一个线程中接受用户输入并通过相同的连接发送消息。
您可能会认出这些情景,它们是一些电子邮件客户端中可用的拉和推选项。它们被称为拉和推,是因为操作对客户端的外观。客户端要么从服务器拉取数据,要么服务器向客户端推送数据。
使用这两种方法都有利有弊,决定取决于应用程序的需求。拉取会减少服务器的负载,但会增加客户端接收消息的延迟。虽然这对于许多应用程序来说是可以接受的,比如电子邮件,在聊天服务器中,我们通常希望立即更新。虽然我们可以频繁轮询,但这会给客户端、服务器和网络带来不必要的负载,因为连接会反复建立和拆除。
推送更适合聊天服务器。由于连接保持持续打开,网络流量的量仅限于初始连接设置和消息本身。此外,客户端几乎可以立即从服务器获取新消息。
因此,我们将使用推送方法,现在我们将编写我们的聊天协议如下:
-
通信将通过 TCP 进行。
-
客户端将通过创建套接字连接到服务器来启动聊天会话。
-
服务器将接受连接,监听来自客户端的任何消息,并接受它们。
-
客户端将在连接上监听来自服务器的任何消息,并接受它们。
-
服务器将把来自客户端的任何消息发送给所有其他连接的客户端。
-
消息将以 UTF-8 字符集进行编码传输,并以空字节终止。
处理持久连接上的数据
我们持久连接方法引发的一个新问题是,我们不能再假设我们的 socket.recv() 调用将只包含来自一个消息的数据。在我们的回显服务器中,由于我们已经定义了协议,我们知道一旦看到空字节,我们收到的消息就是完整的,并且发送者不会再发送任何内容。也就是说,我们在最后一个 socket.recv() 调用中读取的所有内容都是该消息的一部分。
在我们的新设置中,我们将重用同一连接来发送无限数量的消息,这些消息不会与我们从每个 socket.recv() 中提取的数据块同步。因此,很可能从一个 recv() 调用中获取的数据将包含多个消息的数据。例如,如果我们发送以下内容:
caerphilly,
illchester,
brie
然后在传输中它们将如下所示:
caerphilly**\0**illchester**\0**brie**\0**
然而,由于网络传输的变化,一系列连续的 recv() 调用可能会接收到它们:
recv 1: caerphil
recv 2: ly**\0**illches
recv 3: ter**\0**brie**\0**
请注意,recv 1 和 recv 2 一起包含一个完整的消息,但它们也包含下一个消息的开头。显然,我们需要更新我们的解析。一种选择是逐字节从套接字中读取数据,也就是使用 recv(1),并检查每个字节是否为空字节。然而,这是一种非常低效的使用网络套接字的方式。我们希望在调用 recv() 时尽可能多地读取数据。相反,当我们遇到不完整的消息时,我们可以缓存多余的字节,并在下次调用 recv() 时使用它们。让我们这样做,将这些函数添加到 tincanchat.py 文件中:
def parse_recvd_data(data):
""" Break up raw received data into messages, delimited
by null byte """
parts = data.split(b'\0')
msgs = parts[:-1]
rest = parts[-1]
return (msgs, rest)
def recv_msgs(sock, data=bytes()):
""" Receive data and break into complete messages on null byte
delimiter. Block until at least one message received, then
return received messages """
msgs = []
while not msgs:
recvd = sock.recv(4096)
if not recvd:
raise ConnectionError()
data = data + recvd
(msgs, rest) = parse_recvd_data(data)
msgs = [msg.decode('utf-8') for msg in msgs]
return (msgs, rest)
从现在开始,我们将在以前使用 recv_msg() 的地方使用 recv_msgs()。那么,我们在这里做什么呢?通过快速浏览 recv_msgs(),您可以看到它与 recv_msg() 类似。我们重复调用 recv() 并像以前一样累积接收到的数据,但现在我们将使用 parse_recvd_data() 进行解析,期望它可能包含多个消息。当 parse_recvd_data() 在接收到的数据中找到一个或多个完整的消息时,它将将它们拆分成列表并返回它们,如果在最后一个完整消息之后还有任何剩余内容,则使用 rest 变量另外返回这些内容。然后,recv_msgs() 函数解码来自 UTF-8 的消息,并返回它们和 rest 变量。
rest 值很重要,因为我们将在下次调用 recv_msgs() 时将其返回,并且它将被添加到 recv() 调用的数据前缀。这样,上次 recv_msgs() 调用的剩余数据就不会丢失。
因此,在我们之前的例子中,解析消息将按照以下方式进行:
recv_msgs 调用 |
data 参数 |
recv 结果 |
累积的 data |
msgs |
rest |
|---|---|---|---|---|---|
| 1 | - | 'caerphil' |
'caerphil' |
[] |
b'' |
| 1 | - | 'ly\0illches' |
'caerphilly\0illches' |
['caerphilly'] |
'illches' |
| 2 | 'illches' |
'ter\0brie\0' |
'illchester\0brie\0' |
['illchester', 'brie'] |
b'' |
在这里,我们可以看到第一个 recv_msgs() 调用在其第一次迭代后没有返回。它循环是因为 msgs 仍然为空。这就是为什么 recv_msgs 调用编号为 1、1 和 2 的原因。
一个多线程聊天服务器
因此,让我们利用这一点并编写我们的聊天服务器。创建一个名为 2.1-chat_server-multithread.py 的新文件,并将以下代码放入其中:
import threading, queue
import tincanchat
HOST = tincanchat.HOST
PORT = tincanchat.PORT
send_queues = {}
lock = threading.Lock()
def handle_client_recv(sock, addr):
""" Receive messages from client and broadcast them to
other clients until client disconnects """
rest = bytes()
while True:
try:
(msgs, rest) = tincanchat.recv_msgs(sock, rest)
except (EOFError, ConnectionError):
handle_disconnect(sock, addr)
break
for msg in msgs:
msg = '{}: {}'.format(addr, msg)
print(msg)
broadcast_msg(msg)
def handle_client_send(sock, q, addr):
""" Monitor queue for new messages, send them to client as
they arrive """
while True:
msg = q.get()
if msg == None: break
try:
tincanchat.send_msg(sock, msg)
except (ConnectionError, BrokenPipe):
handle_disconnect(sock, addr)
break
def broadcast_msg(msg):
""" Add message to each connected client's send queue """
with lock:
for q in send_queues.values():
q.put(msg)
def handle_disconnect(sock, addr):
""" Ensure queue is cleaned up and socket closed when a client
disconnects """
fd = sock.fileno()
with lock:
# Get send queue for this client
q = send_queues.get(fd, None)
# If we find a queue then this disconnect has not yet
# been handled
if q:
q.put(None)
del send_queues[fd]
addr = sock.getpeername()
print('Client {} disconnected'.format(addr))
sock.close()
if __name__ == '__main__':
listen_sock = tincanchat.create_listen_socket(HOST, PORT)
addr = listen_sock.getsockname()
print('Listening on {}'.format(addr))
while True:
client_sock,addr = listen_sock.accept()
q = queue.Queue()
with lock:
send_queues[client_sock.fileno()] = q
recv_thread = threading.Thread(target=handle_client_recv,
args=[client_sock, addr],
daemon=True)
send_thread = threading.Thread(target=handle_client_send,
args=[client_sock, q,
addr],
daemon=True)
recv_thread.start()
send_thread.start()
print('Connection from {}'.format(addr))
现在我们为每个客户端使用两个线程。一个线程处理接收到的消息,另一个线程处理发送消息的任务。这里的想法是将可能发生阻塞的每个地方都分解成自己的线程。这将为每个客户端提供最低的延迟,但这会以系统资源为代价。我们减少了可能同时处理的客户端数量。我们可以使用其他模型,比如为每个客户端使用单个线程接收消息,然后自己将消息发送给所有连接的客户端,但我选择了优化延迟。
为了方便分开线程,我们将接收代码和发送代码分别放入handle_client_recv()函数和handle_client_send()函数中。
我们的handle_client_recv线程负责从客户端接收消息,而handle_client_send线程负责向客户端发送消息,但是接收到的消息如何从接收线程传递到发送线程呢?这就是queue、send_queue、dict和lock对象发挥作用的地方。
队列
Queue是一个先进先出(FIFO)管道。您可以使用put()方法向其中添加项目,并使用get()方法将它们取出。Queue对象的重要之处在于它们完全是线程安全的。在 Python 中,除非在其文档中明确指定,否则对象通常不是线程安全的。线程安全意味着对对象的操作保证是原子的,也就是说,它们将始终在没有其他线程可能到达该对象并对其执行意外操作的情况下完成。
等一下,你可能会问,之前,你不是说由于全局解释器锁(GIL),操作系统在任何给定时刻只能运行一个 Python 线程吗?如果是这样,那么两个线程如何能同时对一个对象执行操作呢?嗯,这是一个公平的问题。实际上,Python 中的大多数操作实际上由许多操作组成,这些操作是在操作系统级别进行的,线程是在操作系统级别进行调度的。一个线程可以开始对一个对象进行操作,比如向list中添加一个项目,当线程进行到操作系统级别的操作的一半时,操作系统可能会切换到另一个线程,这个线程也开始向同一个list中添加。由于list对象在被线程滥用时(它们不是线程安全的)没有对其行为提供任何保证,接下来可能发生任何事情,而且不太可能是一个有用的结果。这种情况可以称为竞争条件。
线程安全对象消除了这种可能性,因此在线程之间共享状态时,绝对应该优先选择它们。
回到我们的服务器,Queues的另一个有用的行为是,如果在空的Queue上调用get(),那么它将阻塞,直到有东西被添加到Queue中。我们利用这一点在我们的发送线程中。注意,我们进入一个无限循环,第一个操作是对Queue调用get()方法。线程将在那里阻塞并耐心等待,直到有东西被添加到它的Queue中。而且,你可能已经猜到了,我们的接收线程将消息添加到队列中。
我们为每个发送线程创建一个Queue对象,并将队列存储在send_queues字典中。为了广播新消息给我们的接收线程,它们只需要将消息添加到send_queues中的每个Queue中,这是在broadcast_msgs()函数中完成的。我们等待的发送线程将解除阻塞,从它们的Queue中取出消息,然后将其发送给它们的客户端。
我们还添加了一个handle_disconnect()函数,每当客户端断开连接或套接字发生错误时都会调用该函数。该函数确保与关闭连接相关的队列被清理,并且套接字从服务器端正确关闭。
锁
将我们对Queues对象的使用与我们对send_queues的使用进行对比。Dict对象不是线程安全的,不幸的是,在 Python 中没有线程安全的关联数组类型。由于我们需要共享这个dict,所以我们在访问它时需要额外小心,这就是Lock发挥作用的地方。Lock对象是一种同步原语。这些是具有功能的特殊对象,可以帮助管理我们的线程,并确保它们不会互相干扰。
Lock要么被锁定,要么被解锁。线程可以通过调用acquire()来锁定线程,或者像我们的程序一样,将其用作上下文管理器。如果一个线程已经获取了锁,另一个线程也试图获取锁,那么第二个线程将在acquire()调用上阻塞,直到第一个线程释放锁或退出上下文。一次可以有无限多个线程尝试获取锁 - 除了第一个之外,所有线程都会被阻塞。通过用锁包装对非线程安全对象的所有访问,我们可以确保没有两个线程同时操作该对象。
因此,每当我们向send_queues添加或删除内容时,我们都会将其包装在Lock上下文中。请注意,当我们迭代send_queues时,我们也在保护它。即使我们没有改变它,我们也希望确保在我们处理它时它不会被修改。
尽管我们很小心地使用锁和线程安全的原语,但我们并没有完全保护自己免受所有可能的与线程相关的问题。由于线程同步机制本身会阻塞,因此仍然很可能会出现死锁,即两个线程同时在由另一个线程锁定的对象上阻塞。管理线程通信的最佳方法是将对共享状态的所有访问限制在代码中尽可能小的区域内。在这个服务器的情况下,这个模块可以重新设计为提供最少数量的公共方法的类。它还可以被记录下来,以阻止任何内部状态的更改。这将使线程的这一部分严格限制在这个类中。
多线程聊天客户端
现在我们有了一个新的、全接收和广播的聊天服务器,我们只需要一个客户端。我们之前提到,当尝试同时监听网络数据和用户输入时,我们的过程化客户端会遇到问题。现在我们对如何使用线程有了一些想法,我们可以试着解决这个问题。创建一个名为2.2-chat_client-multithread.py的新文本文件,并将以下代码保存在其中:
import sys, socket, threading
import tincanchat
HOST = sys.argv[-1] if len(sys.argv) > 1 else '127.0.0.1'
PORT = tincanchat.PORT
def handle_input(sock):
""" Prompt user for message and send it to server """
print("Type messages, enter to send. 'q' to quit")
while True:
msg = input() # Blocks
if msg == 'q':
sock.shutdown(socket.SHUT_RDWR)
sock.close()
break
try:
tincanchat.send_msg(sock, msg) # Blocks until sent
except (BrokenPipeError, ConnectionError):
break
if __name__ == '__main__':
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
print('Connected to {}:{}'.format(HOST, PORT))
# Create thread for handling user input and message sending
thread = threading.Thread(target=handle_input,
args=[sock],
daemon=True)
thread.start()
rest = bytes()
addr = sock.getsockname()
# Loop indefinitely to receive messages from server
while True:
try:
# blocks
(msgs, rest) = tincanchat.recv_msgs(sock, rest)
for msg in msgs:
print(msg)
except ConnectionError:
print('Connection to server closed')
sock.close()
break
我们已经更新了我们的客户端,通过创建一个新线程来处理用户输入和发送消息,同时在主线程中处理接收消息,来遵守我们的新聊天协议。这允许客户端同时处理用户输入和接收消息。
请注意,这里没有共享状态,所以我们不必在Queues或同步原语上耍花招。
让我们来试试我们的新程序。启动多线程聊天服务器,然后启动至少两个客户端。如果可以的话,在终端中运行它们,这样你就可以同时观看它们。现在,尝试从客户端发送一些消息,看看它们是如何发送到所有其他客户端的。
事件驱动服务器
对于许多目的来说,线程是很好的,特别是因为我们仍然可以以熟悉的过程化、阻塞 IO 风格进行编程。但是它们的缺点是在同时管理大量连接时会遇到困难,因为它们需要为每个连接维护一个线程。每个线程都会消耗内存,并且在线程之间切换会产生一种称为上下文切换的 CPU 开销。虽然这对于少量线程来说不是问题,但是当需要管理许多线程时,它会影响性能。多进程也面临类似的问题。
使用事件驱动模型是线程和多进程的一种替代方法。在这种模型中,我们不是让操作系统自动在活动线程或进程之间切换,而是使用一个单线程,将阻塞对象(如套接字)注册到操作系统中。当这些对象准备好离开阻塞状态时,例如套接字接收到一些数据,操作系统会通知我们的程序;我们的程序可以以非阻塞模式访问这些对象,因为它知道它们处于立即可用的状态。在非阻塞模式下调用对象的调用总是立即返回。我们的应用程序围绕一个循环进行结构化,等待操作系统通知我们阻塞对象上的活动,然后处理该活动,然后回到等待状态。这个循环被称为事件循环。
这种方法提供了与线程和多进程相当的性能,但没有内存或上下文切换的开销,因此可以在相同的硬件上实现更大的扩展。工程应用程序能够有效处理大量同时连接的挑战在历史上被称为c10k 问题,指的是在单个线程中处理一万个并发连接。借助事件驱动架构,这个问题得到了解决,尽管这个术语在处理许多并发连接时仍经常被使用。
注意
在现代硬件上,使用多线程方法实际上可以处理一万个并发连接,也可以参考这个 Stack Overflow 问题来了解一些数字stackoverflow.com/questions/17593699/tcp-ip-solving-the-c10k-with-the-thread-per-client-approach。
现代挑战是“c10m 问题”,即一千万并发连接。解决这个问题涉及一些激进的软件甚至操作系统架构的变化。尽管这在短期内可能无法通过 Python 来解决,但可以在c10m.robertgraham.com/p/blog-page.html找到有关该主题的有趣(尽管不幸是不完整的)概论。
下图显示了事件驱动服务器中进程和线程的关系:

尽管 GIL 和操作系统线程调度器在这里是为了完整性而显示的,但在事件驱动服务器的情况下,它们对性能没有影响,因为服务器只使用一个线程。I/O 处理的调度是由应用程序完成的。
低级事件驱动聊天服务器
因此,事件驱动架构有一些很大的好处,但问题在于,对于低级实现,我们需要以完全不同的风格编写我们的代码。让我们编写一个事件驱动的聊天服务器来说明这一点。
请注意,这个例子在 Windows 上根本无法工作,因为 Windows 缺乏我们将在这里使用的poll接口。然而,Windows 支持一个名为select的旧接口,但它更慢,更复杂。我们稍后讨论的事件驱动框架会自动切换到select,如果我们在 Windows 上运行的话。
有一个称为epoll的poll的高性能替代品,它在 Linux 操作系统上可用,但它也更复杂,所以为了简单起见,我们将在这里坚持使用poll。同样,我们稍后讨论的框架会自动利用epoll。
最后,令人费解的是,Python 的poll接口位于一个名为select的模块中,因此我们将在程序中导入select。
创建一个名为3.1-chat_server-poll.py的文件,并将以下代码保存在其中:
import select
import tincanchat
from types import SimpleNamespace
from collections import deque
HOST = tincanchat.HOST
PORT = tincanchat.PORT
clients = {}
def create_client(sock):
""" Return an object representing a client """
return SimpleNamespace(
sock=sock,
rest=bytes(),
send_queue=deque())
def broadcast_msg(msg):
""" Add message to all connected clients' queues """
data = tincanchat.prep_msg(msg)
for client in clients.values():
client.send_queue.append(data)
poll.register(client.sock, select.POLLOUT)
if __name__ == '__main__':
listen_sock = tincanchat.create_listen_socket(HOST, PORT)
poll = select.poll()
poll.register(listen_sock, select.POLLIN)
addr = listen_sock.getsockname()
print('Listening on {}'.format(addr))
# This is the event loop. Loop indefinitely, processing events
# on all sockets when they occur
while True:
# Iterate over all sockets with events
for fd, event in poll.poll():
# clear-up a closed socket
if event & (select.POLLHUP |
select.POLLERR |
select.POLLNVAL):
poll.unregister(fd)
del clients[fd]
# Accept new connection, add client to clients dict
elif fd == listen_sock.fileno():
client_sock,addr = listen_sock.accept()
client_sock.setblocking(False)
fd = client_sock.fileno()
clients[fd] = create_client(client_sock)
poll.register(fd, select.POLLIN)
print('Connection from {}'.format(addr))
# Handle received data on socket
elif event & select.POLLIN:
client = clients[fd]
addr = client.sock.getpeername()
recvd = client.sock.recv(4096)
if not recvd:
# the client state will get cleaned up in the
# next iteration of the event loop, as close()
# sets the socket to POLLNVAL
client.sock.close()
print('Client {} disconnected'.format(addr))
continue
data = client.rest + recvd
(msgs, client.rest) = \
tincanchat.parse_recvd_data(data)
# If we have any messages, broadcast them to all
# clients
for msg in msgs:
msg = '{}: {}'.format(addr, msg)
print(msg)
broadcast_msg(msg)
# Send message to ready client
elif event & select.POLLOUT:
client = clients[fd]
data = client.send_queue.popleft()
sent = client.sock.send(data)
if sent < len(data):
client.sends.appendleft(data[sent:])
if not client.send_queue:
poll.modify(client.sock, select.POLLIN)
这个程序的关键是我们在执行开始时创建的poll对象。这是一个用于内核poll服务的接口,它允许我们注册套接字,以便操作系统在它们准备好供我们使用时通知我们。
我们通过调用poll.register()方法注册套接字,将套接字作为参数与我们希望内核监视的活动类型一起传递。我们可以通过指定各种select.POLL*常量来监视几种条件。在这个程序中,我们使用POLLIN和POLLOUT来监视套接字何时准备好接收和发送数据。在我们的监听套接字上接受新的传入连接将被视为读取。
一旦套接字被注册到poll中,操作系统将监视它,并记录当套接字准备执行我们请求的活动时。当我们调用poll.poll()时,它返回一个列表,其中包含所有已准备好供我们使用的套接字。对于每个套接字,它还返回一个event标志,指示套接字的状态。我们可以使用此事件标志来判断我们是否可以从套接字读取(POLLIN事件)或向套接字写入(POLLOUT事件),或者是否发生了错误(POLLHUP,POLLERR,POLLNVAL事件)。
为了利用这一点,我们进入我们的事件循环,重复调用poll.poll(),迭代返回的准备好的对象,并根据它们的event标志对它们进行操作。
因为我们只在一个线程中运行,所以我们不需要在多线程服务器中使用的任何同步机制。我们只是使用一个常规的dict来跟踪我们的客户端。如果你以前没有遇到过,我们在create_client()函数中使用的SimpleNamespace对象只是一个创建带有__dict__的空对象的新习惯用法(这是必需的,因为Object实例没有__dict__,所以它们不会接受任意属性)。以前,我们可能会使用以下内容来给我们一个可以分配任意属性的对象:
class Client:
pass
client = Client()
Python 版本 3.3 及更高版本为我们提供了新的更明确的SimpleNamespace对象。
我们可以运行我们的多线程客户端与这个服务器进行通信。服务器仍然使用相同的网络协议,两个程序的架构不会影响通信。试一试,验证是否按预期工作。
这种编程风格,使用poll和非阻塞套接字,通常被称为非阻塞和异步,因为我们使用非阻塞模式的套接字,并且控制线程根据需要处理 I/O,而不是锁定到单个 I/O 通道直到完成。但是,你应该注意,我们的程序并不完全是非阻塞的,因为它仍然在poll.poll()调用上阻塞。在 I/O 绑定系统中,这几乎是不可避免的,因为当没有发生任何事情时,你必须等待 I/O 活动。
框架
正如你所看到的,使用这些较低级别的线程和pollAPI 编写服务器可能会相当复杂,特别是考虑到一些在生产系统中预期的事情,比如日志记录和全面的错误处理,由于简洁起见,我们的示例中没有包括。
许多人在我们之前遇到了这些问题,并且有几个库和框架可用于减少编写网络服务器的工作量。
基于 eventlet 的聊天服务器
eventlet库提供了一个高级 API,用于事件驱动编程,但它的风格模仿了我们在多线程服务器中使用的过程式阻塞 IO 风格。结果是,我们可以有效地采用多线程聊天服务器代码,对其进行一些小的修改,以使用eventlet,并立即获得事件驱动模型的好处!
eventlet库可在 PyPi 中找到,并且可以使用pip进行安装,如下所示:
**$ pip install eventlet**
**Downloading/unpacking eventlet**
注意
如果poll不可用,eventlet库会自动退回到select,因此它将在 Windows 上正常运行。
安装完成后,创建一个名为4.1-chat_server-eventlet.py的新文件,并将以下代码保存在其中:
import eventlet
import eventlet.queue as queue
import tincanchat
HOST = tincanchat.HOST
PORT = tincanchat.PORT
send_queues = {}
def handle_client_recv(sock, addr):
""" Receive messages from client and broadcast them to
other clients until client disconnects """
rest = bytes()
while True:
try:
(msgs, rest) = tincanchat.recv_msgs(sock)
except (EOFError, ConnectionError):
handle_disconnect(sock, addr)
break
for msg in msgs:
msg = '{}: {}'.format(addr, msg)
print(msg)
broadcast_msg(msg)
def handle_client_send(sock, q, addr):
""" Monitor queue for new messages, send them to client as
they arrive """
while True:
msg = q.get()
if msg == None: break
try:
tincanchat.send_msg(sock, msg)
except (ConnectionError, BrokenPipe):
handle_disconnect(sock, addr)
break
def broadcast_msg(msg):
""" Add message to each connected client's send queue """
for q in send_queues.values():
q.put(msg)
def handle_disconnect(sock, addr):
""" Ensure queue is cleaned up and socket closed when a client
disconnects """
fd = sock.fileno()
# Get send queue for this client
q = send_queues.get(fd, None)
# If we find a queue then this disconnect has not yet
# been handled
if q:
q.put(None)
del send_queues[fd]
addr = sock.getpeername()
print('Client {} disconnected'.format(addr))
sock.close()
if __name__ == '__main__':
server = eventlet.listen((HOST, PORT))
addr = server.getsockname()
print('Listening on {}'.format(addr))
while True:
client_sock,addr = server.accept()
q = queue.Queue()
send_queues[client_sock.fileno()] = q
eventlet.spawn_n(handle_client_recv,
client_sock,
addr)
eventlet.spawn_n(handle_client_send,
client_sock,
q,
addr)
print('Connection from {}'.format(addr))
我们可以使用我们的多线程客户端进行测试,以确保它按预期工作。
正如你所看到的,它与我们的多线程服务器几乎完全相同,只是做了一些更改以使用eventlet。请注意,我们已经删除了同步代码和send_queues周围的lock。我们仍然使用队列,尽管它们是eventlet库的队列,因为我们希望保留Queue.get()的阻塞行为。
注意
在 eventlet 网站上有更多使用 eventlet 进行编程的示例,网址为eventlet.net/doc/examples.html。
基于 asyncio 的聊天服务器
asyncio标准库模块是 Python 3.4 中的新功能,它是在标准库中围绕异步 I/O 引入一些标准化的努力。asyncio库使用基于协程的编程风格。它提供了一个强大的循环类,我们的程序可以将准备好的任务(称为协程)提交给它,以进行异步执行。事件循环处理任务的调度和性能优化,以处理阻塞 I/O 调用。
它内置支持基于套接字的网络,这使得构建基本服务器成为一项简单的任务。让我们看看如何做到这一点。创建一个名为5.1-chat_server-asyncio.py的新文件,并将以下代码保存在其中:
import asyncio
import tincanchat
HOST = tincanchat.HOST
PORT = tincanchat.PORT
clients = []
class ChatServerProtocol(asyncio.Protocol):
""" Each instance of class represents a client and the socket
connection to it. """
def connection_made(self, transport):
""" Called on instantiation, when new client connects """
self.transport = transport
self.addr = transport.get_extra_info('peername')
self._rest = b''
clients.append(self)
print('Connection from {}'.format(self.addr))
def data_received(self, data):
""" Handle data as it's received. Broadcast complete
messages to all other clients """
data = self._rest + data
(msgs, rest) = tincanchat.parse_recvd_data(data)
self._rest = rest
for msg in msgs:
msg = msg.decode('utf-8')
msg = '{}: {}'.format(self.addr, msg)
print(msg)
msg = tincanchat.prep_msg(msg)
for client in clients:
client.transport.write(msg) # <-- non-blocking
def connection_lost(self, ex):
""" Called on client disconnect. Clean up client state """
print('Client {} disconnected'.format(self.addr))
clients.remove(self)
if __name__ == '__main__':
loop = asyncio.get_event_loop()
# Create server and initialize on the event loop
coroutine = loop.create_server(ChatServerProtocol,
host=HOST,
port=PORT)
server = loop.run_until_complete(coroutine)
# print listening socket info
for socket in server.sockets:
addr = socket.getsockname()
print('Listening on {}'.format(addr))
# Run the loop to process client connections
loop.run_forever()
同样,我们可以使用我们的多线程客户端进行测试,以确保它按我们的预期工作。
让我们逐步了解代码,因为它与我们以前的服务器有很大不同。我们首先定义了服务器行为,它是asyncio.Protocol抽象类的子类。我们需要重写三个方法connection_made()、data_received()和connection_lost()。通过使用这个类,我们可以在事件循环上实例化一个新的服务器,它将监听一个套接字,并根据这三种方法的内容进行操作。我们在主要部分中使用loop.create_server()调用来执行这个实例化。
当新客户端连接到我们的套接字时,将调用connection_made()方法,这相当于socket.accept()接收到一个连接。它接收的transport参数是一个可写流对象,也就是一个asyncio.WriteTransport实例。我们将使用它向套接字写入数据,因此通过将其分配给self.transport属性来保留它。我们还通过使用transport.get_extra_info('peername')来获取客户端的主机和端口。这是传输的socket.getpeername()的等价物。然后我们设置一个rest属性来保存从tincanchat.parse_recvd_data()调用中剩下的数据,然后我们将我们的实例添加到全局的clients列表中,以便其他客户端可以向其进行广播。
data_received()方法是发生操作的地方。每次Protocol实例的套接字接收到任何数据时,都会调用此函数。这相当于poll.poll()返回POLLIN事件,然后我们在套接字上执行recv()。调用此方法时,将接收到的数据作为data参数传递给该方法,然后我们使用tincanchat.parse_recvd_data()进行解析,就像以前一样。
然后,我们遍历接收到的任何消息,并对每条消息,通过在客户端的传输对象上调用write()方法,将其发送到clients列表中的每个客户端。这里需要注意的重要一点是,Transport.write()调用是非阻塞的,因此会立即返回。发送只是被提交到事件循环中,以便很快安排完成。
connection_lost()方法在客户端断开连接或连接丢失时被调用,这相当于socket.recv()返回一个空结果,或者一个ConnectionError。在这里,我们只是从clients全局列表中移除客户端。
在主模块代码中,我们获取一个事件循环,然后创建我们的Protocol服务器的实例。调用loop.run_until_complete()在事件循环上运行我们服务器的初始化阶段,设置监听套接字。然后我们调用loop.run_forever(),这将使我们的服务器开始监听传入的连接。
更多关于框架
在最后一个示例中,我打破了我们通常的过程形式,采用了面向对象的方法,原因有两个。首先,虽然可以使用asyncio编写纯过程风格的服务器,但这需要比我们在这里提供的更深入的理解协程。如果你感兴趣,可以阅读asyncio文档中的一个示例协程风格的回显服务器,网址为docs.python.org/3/library/asyncio-stream.html#asyncio-tcp-echo-server-streams。
第二个原因是,这种基于类的方法通常是在完整系统中更易管理的模型。
实际上,Python 3.4 中有一个名为selectors的新模块,它提供了一个基于select模块中 IO 原语快速构建面向对象服务器的 API(包括poll)。文档和示例可以在docs.python.org/3.4/library/selectors.html中找到。
还有其他第三方事件驱动框架可用,流行的有 Tornado(www.tornadoweb.org)和 circuits(github.com/circuits/circuits)。如果你打算为项目选择一个框架,这两个都值得进行比较。
此外,没有讨论 Python 异步 I/O 的内容是完整的,而没有提到 Twisted 框架。直到 Python 3 之前,这一直是任何严肃的异步 I/O 工作的首选解决方案。它是一个事件驱动引擎,支持大量的网络协议,性能良好,并且拥有庞大而活跃的社区。不幸的是,它还没有完全转向 Python 3(迁移进度可以在rawgit.com/mythmon/twisted-py3-graph/master/index.html中查看)。由于我们在本书中专注于 Python 3,我们决定不对其进行详细处理。然而,一旦它到达那里,Python 3 将拥有另一个非常强大的异步框架,这将值得你为你的项目进行调查。
推动我们的服务器前进
有许多事情可以做来改进我们的服务器。对于多线程系统,通常会有一种机制来限制同时使用的线程数量。这可以通过保持活动线程的计数并在超过阈值时立即关闭来自客户端的任何新传入连接来实现。
对于我们所有的服务器,我们还希望添加一个日志记录机制。我强烈推荐使用标准库logging模块,它的文档非常完整,包含了很多好的例子。如果你以前没有使用过,基本教程是一个很好的起点,可以在docs.python.org/3/howto/logging.html#logging-basic-tutorial中找到。
我们还希望更全面地处理错误。由于我们的服务器意图是长时间运行并且最小干预,我们希望确保除了关键异常之外的任何情况都不会导致进程退出。我们还希望确保处理一个客户端时发生的错误不会影响其他已连接的客户端。
最后,聊天程序还有一些基本功能可能会很有趣:让用户输入一个名字,在其他客户端上显示他们的消息旁边;添加聊天室;以及在套接字连接中添加 TLS 加密以提供隐私和安全性。
总结
我们研究了如何在考虑诸如连接顺序、数据传输中的数据帧等方面开发网络协议,以及这些选择对客户端和服务器程序架构的影响。
我们通过编写一个简单的回显服务器并将其升级为多客户端聊天服务器,演示了网络服务器和客户端的不同架构,展示了多线程和事件驱动模型之间的差异。我们讨论了围绕线程和事件驱动架构的性能问题。最后,我们看了一下eventlet和asyncio框架,这些框架在使用事件驱动方法时可以极大地简化服务器编写的过程。
在本书的下一章和最后一章中,我们将探讨如何将本书的几个主题融合起来,用于编写服务器端的 Web 应用程序。
第九章:网络应用
在第二章 HTTP 和网络工作中,我们探讨了 HTTP 协议——万维网主要使用的协议,并学习了如何使用 Python 作为 HTTP 客户端。在第三章 API 实践中,我们扩展了这一点,并研究了消费 Web API 的方法。在本章中,我们将把重点转向,看看如何使用 Python 构建应用程序,以响应 HTTP 请求。
在本章中,我们将涵盖以下内容:
-
Python web frameworks
-
一个 Python 网络应用
-
托管 Python 和 WSGI
我应该提前指出,托管现代 Web 应用是一个非常庞大的主题,完整的处理远远超出了本书的范围,我们的重点是将 Python 代码应用于网络问题。诸如数据库访问、选择和配置负载均衡器和反向代理、容器化以及保持整个系统运行所需的系统管理技术等主题在这里不会涉及。然而,有许多在线资源可以为您提供一个起点,我们将尽量在相关的地方提及尽可能多的资源。
话虽如此,上述列出的技术并不是创建和提供基于 Python 的 Web 应用程序的要求,它们只是在服务达到规模时所需的。正如我们将看到的,对于易于管理的小规模应用程序托管也有选择。
Web 服务器中包含什么?
要了解如何使用 Python 来响应 HTTP 请求,我们需要了解一些通常需要发生的事情,以便响应请求,以及已经存在的工具和模式。
基本的 HTTP 请求和响应可能如下所示:

在这里,我们的 Web 客户端向服务器发送 HTTP 请求,其中 Web 服务器程序解释请求,创建适当的 HTTP 响应,并将其发送回来。在这种情况下,响应主体只是从中读取的 HTML 文件的内容,响应头由 Web 服务器程序添加。
Web 服务器负责响应客户端请求的整个过程。它需要执行的基本步骤是:

首先,Web 服务器程序需要接受客户端的 TCP 连接尝试。然后,它通过 TCP 连接从客户端接收 HTTP 请求。服务器需要在生成 HTTP 响应时保持 TCP 连接打开,并使用连接将响应发送回客户端。服务器在此之后对连接的处理取决于所使用的 HTTP 版本以及请求中可能的 Connection 头的值(有关完整细节,请参阅 RFC tools.ietf.org/html/rfc7230#section-6.3)。
一旦 Web 服务器收到请求,它会解析请求,然后生成响应。当请求的 URL 映射到服务器上的有效资源时,服务器将使用该 URL 处的资源进行响应。资源可以是磁盘上的文件(所谓的静态内容),如前面的基本 HTTP 请求和响应的图表所示,它可以是一个 HTTP 重定向,或者它可以是一个动态生成的 HTML 页面。如果出现问题,或者 URL 无效,则响应将包含4xx或5xx范围内的状态代码。准备好响应后,服务器通过 TCP 连接将其发送回客户端。
在 Web 的早期,几乎所有请求的资源都是从磁盘读取的静态文件,Web 服务器可以用一种语言编写,并且可以轻松处理前面图像中显示的所有四个步骤。然而,随着越来越多的动态内容的需求,例如购物篮和数据库驱动的资源,如博客、维基和社交媒体,很快就发现将这些功能硬编码到 Web 服务器本身是不切实际的。相反,Web 服务器内置了设施,允许调用外部代码作为页面生成过程的一部分。
因此,Web 服务器可以用快速的语言(如 C 语言)编写,并处理低级别的 TCP 连接、请求的初始解析和验证以及处理静态内容,但在需要动态响应时,可以调用外部代码来处理页面生成任务。
这个外部代码是我们在谈论 Web 应用程序时通常指的内容。因此,响应过程的职责可以分为以下几个部分,如下图所示:

Web 应用程序可以用 Web 服务器能够调用的任何语言编写,提供了很大的灵活性,并允许使用更高级别的语言。这可以大大减少开发新 Web 服务所需的时间。如今有很多语言可以用来编写 Web 应用程序,Python 也不例外。
Python 和 Web
使用本书中讨论的一些技术,特别是第八章中讨论的技术,可以使用 Python 编写一个完整的 Web 服务器,处理我们在前一节中列出的处理 HTTP 请求的四个步骤。已经有几个正在积极开发的 Web 服务器纯粹用 Python 编写,包括 Gunicorn (gunicorn.org)和 CherryPy (www.cherrypy.org)。标准库 http.server 模块中甚至有一个非常基本的 HTTP 服务器。
编写一个完整的 HTTP 服务器并不是一项微不足道的任务,详细的处理远远超出了本书的范围。由于已经准备好部署的优秀 Web 服务器的普及,这也不是一个非常常见的需求。如果你确实有这个需求,我建议你首先查看前面提到的 Web 服务器的源代码,更详细地查看第八章中列出的框架,客户端和服务器应用程序,并阅读相关 RFC 中的完整 HTTP 规范。您可能还想阅读 WSGI 规范,稍后在 WSGI 部分讨论,以便允许服务器充当其他 Python Web 应用程序的主机。
更强的要求是构建一个 Web 服务应用程序来生成一些动态内容,并快速运行起来。在这种情况下,Python 为我们提供了一些优秀的选择,以 Web 框架的形式。
Web 框架
Web 框架是位于 Web 服务器和我们的 Python 代码之间的一层,它提供了抽象和简化的 API,用于执行解释 HTTP 请求和生成响应的许多常见操作。理想情况下,它还应该结构良好,引导我们采用经过良好测试的 Web 开发模式。Python Web 应用程序的框架通常是用 Python 编写的,并且可以被视为 Web 应用程序的一部分。
框架提供的基本服务包括:
-
HTTP 请求和响应的抽象
-
URL 空间的管理(路由)
-
Python 代码和标记(模板)的分离
今天有许多 Python 网络框架在使用,以下是一些流行的框架列表,排名不分先后:
-
Django(www.djangoproject.com)
-
CherryPy(www.cherrypy.org)
-
Flask(flask.pocoo.org)
-
Tornado(www.tornadoweb.org)
-
TurboGears(www.turbogears.org)
注意
维护有关框架的最新列表wiki.python.org/moin/WebFrameworks和docs.python-guide.org/en/latest/scenarios/web/#frameworks。
有这么多框架是因为可以采用许多方法来执行它们执行的任务,并且对于它们甚至应该执行的任务有许多不同的观点。
一些框架提供了快速构建简单 Web 应用程序所需的最低功能。这些通常被称为微框架,这里最受欢迎的是 Armin Ronacher 的出色的 Flask。尽管它们可能不包括一些重量级框架的功能,但它们通常做得非常好,并提供了钩子,以便轻松扩展更复杂的任务。这允许完全定制的 Web 应用程序开发方法。
其他框架采用更多的电池包含方式,为现代 Web 应用程序的所有常见需求提供支持。这里的主要竞争者是 Django,它包括从模板到表单管理和数据库抽象,甚至完整的开箱即用的基于 Web 的数据库管理界面的所有内容。TurboGears 通过将核心微框架与其他功能的几个已建立的软件包集成来提供类似的功能。
还有其他框架提供支持具有事件驱动架构的 Web 应用程序的功能,例如 Tornado 和 CherryPy。这两者还具有自己内置的生产质量 Web 服务器。
选择一个框架可能是一个棘手的决定,没有正确的答案。我们将快速浏览今天最流行的框架之一,以了解框架可以提供的服务,然后讨论如何选择一个框架的方法。
Flask-微框架
为了体验使用 Python Web 框架的感觉,我们将使用 Flask 编写一个小应用程序。我们选择 Flask,因为它提供了一个精简的接口,为我们提供了所需的功能,同时让我们编写代码。而且,它不需要任何重要的预配置,我们需要做的就是安装它,就像这样:
**>>> pip install flask**
**Downloading/unpacking flask**
Flask 也可以从项目的主页flask.pocoo.org下载。请注意,要在 Python 3 下运行 Flask,您将需要 Python 3.3 或更高版本。
现在创建一个项目目录,并在目录中创建一个名为tinyflaskapp.py的文本文件。我们的应用程序将允许我们浏览 Python 内置函数的文档字符串。将其输入到tinyflaskapp.py中:
from flask import Flask, abort
app = Flask(__name__)
app.debug = True
objs = __builtins__.__dict__.items()
docstrings = {name.lower(): obj.__doc__ for name, obj in objs if
name[0].islower() and hasattr(obj, '__name__')}
@app.route('/')
def index():
link_template = '<a href="/functions/{}">{}</a></br>'
links = []
for func in sorted(docstrings):
link = link_template.format(func, func)
links.append(link)
links_output = '\n'.join(links)
return '<h1>Python builtins docstrings</h1>\n' + links_output
@app.route('/functions/<func_name>')
def show_docstring(func_name):
func_name = func_name.lower()
if func_name in docstrings:
output = '<h1>{}</h1>\n'.format(func_name)
output += '<pre>{}</pre>'.format(docstrings[func_name])
return output
else:
abort(404)
if __name__ == '__main__':
app.run()
此代码可以在本书本章的源代码下载中找到,位于1-init文件夹中。
Flask 包含一个开发 Web 服务器,因此要尝试我们的应用程序,我们只需要运行以下命令:
**$ python3.4 tinyflaskapp.py**
*** Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)**
*** Restarting with stat**
我们可以看到 Flask 服务器告诉我们它正在侦听的 IP 地址和端口。现在在 Web 浏览器中连接到它显示的 URL(在前面的示例中,这是http://127.0.0.1:5000/),您应该会看到一个列出 Python 内置函数的页面。单击其中一个应该显示一个显示函数名称及其文档字符串的页面。
如果您想在另一个接口或端口上运行服务器,可以更改app.run()调用,例如更改为app.run(host='0.0.0.0', port=5001)。
让我们来看看我们的代码。从顶部开始,我们通过创建一个 Flask 实例来创建我们的 Flask 应用,这里给出了我们主要模块的名称。然后我们将调试模式设置为活动状态,在浏览器中出现问题时提供良好的回溯,并且还设置开发服务器以自动重新加载代码更改而无需重新启动。请注意,调试模式永远不应该在生产应用中保持活动状态!这是因为调试器具有交互元素,允许在服务器上执行代码。默认情况下,调试是关闭的,因此当我们将应用投入生产时,我们只需要删除 app.config.debug 行即可。
接下来,我们将内置的函数对象从全局变量中过滤出来,并提取它们的文档字符串以备后用。现在我们有了应用程序的主要部分,我们遇到了 Flask 的第一个超能力:URL 路由。一个 Flask 应用的核心是一组函数,通常称为视图,它们处理我们 URL 空间的各个部分的请求——index() 和 show_docstring() 就是这样的函数。您会看到这两个函数都是由 Flask 装饰器函数 app.route() 预先处理的。这告诉 Flask 装饰的函数应该处理我们 URL 空间的哪些部分。也就是说,当一个请求带有与 app.route() 装饰器中的模式匹配的 URL 时,将调用具有匹配装饰器的函数来处理请求。视图函数必须返回 Flask 可以返回给客户端的响应,但稍后会详细介绍。
我们的 index() 函数的 URL 模式只是站点根目录 '/',这意味着只有对根目录的请求才会由 index() 处理。
在 index() 中,我们只需将输出的 HTML 编译为字符串——首先是我们链接到函数页面的列表,然后是一个标题——然后我们返回字符串。Flask 获取字符串并创建响应,使用字符串作为响应主体,并添加一些 HTTP 头。特别是对于 str 返回值,它将 Content-Type 设置为 text/html。
show_docstrings() 视图也做了类似的事情——它在 HTML 标题标签中返回我们正在查看的内置函数的名称,以及包含在 <pre> 标签中的文档字符串(以保留换行和空格)。
有趣的部分是 app.route('/functions/<func_name>') 的调用。在这里,我们声明我们的函数页面将位于 functions 目录中,并使用 <func_name> 段捕获请求的函数名称。Flask 捕获 URL 的尖括号部分,并使其可用于我们的视图。我们通过为 show_docstring() 声明 func_name 参数将其引入视图命名空间。
在视图中,我们通过查看名称是否出现在 docstrings 字典中来检查提供的名称是否有效。如果有效,我们构建并返回相应的 HTML。如果无效,我们通过调用 Flask 的 abort() 函数向客户端返回 404 Not Found 响应。此函数会引发一个 Flask HTTPException,如果我们的应用程序没有处理,Flask 将生成一个错误页面并将其返回给客户端,同时返回相应的状态码(在本例中为 404)。这是在遇到错误请求时快速失败的好方法。
模板
从我们之前的视图中可以看出,即使在调皮地省略了通常的 HTML 正式性,比如 <DOCTYPE> 和 <html> 标签以节省复杂性,但在 Python 代码中构建 HTML 仍然很笨拙。很难对整个页面有所感觉,对于没有 Python 知识的设计师来说,无法进行页面设计。此外,将呈现代码的生成与应用逻辑混合在一起会使两者都更难测试。
几乎所有的 Web 框架都通过使用模板习语来解决这个问题。由于大部分 HTML 是静态的,问题就出现了:为什么还要将它保留在应用程序代码中呢?有了模板,我们可以将 HTML 完全提取到单独的文件中。然后这些文件包括 HTML 代码,包括一些特殊的占位符和逻辑标记,以允许动态元素被插入。
Flask 使用了 Armin Ronacher 的另一个作品Jinja2模板引擎来完成这项任务。让我们来适应我们的应用程序来使用模板。在你的项目文件夹中,创建一个名为templates的文件夹。在里面,创建三个新的文本文件,base.html,index.html和docstring.html。填写它们如下:
base.html文件将是这样的:
<!DOCTYPE html>
<html>
<head>
<title>Python Builtins Docstrings</title>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
index.html文件将是这样的:
{% extends "base.html" %}
{% block body %}
<h1>Python Builtins Docstrings</h1>
<div>
{% for func in funcs %}
<div class="menuitem link">
<a href="/functions/{{ func }}">{{ func }}</a>
</div>
{% endfor %}
</table>
{% endblock %}
docstring.html文件将是这样的:
{% extends 'base.html' %}
{% block body %}
<h1>{{ func_name }}</h1>
<pre>{{ doc }}</pre>
<p><a href="/">Home</a></p>
{% endblock %}
在tinyflaskapp.py顶部的from flask import...行中添加render_template,然后修改你的视图如下:
@app.route('/')
def index():
return render_template('index.html', funcs=sorted(docstrings))
@app.route('/functions/<func_name>')
def show_docstring(func_name):
func_name = func_name.lower()
if func_name in docstrings:
return render_template('docstring.html',
func_name=func_name,
doc=docstrings[func_name])
else:
abort(404)
这段代码可以在本章源代码的2-templates文件夹中找到。
注意到视图变得简单得多,HTML 现在更加可读了吗?我们的视图不再手动组合返回字符串,而是简单地调用render_template()并返回结果。
那么render_template()做了什么呢?它会在templates文件夹中查找作为第一个参数提供的文件,读取它,运行文件中的任何处理指令,然后将处理后的 HTML 作为字符串返回。提供给render_template()的任何关键字参数都会传递给模板,并在其处理指令中可用。
看看这些模板,我们可以看到它们大部分是 HTML,但是包含一些额外的指令供 Flask 使用,包含在{{ }}和{% %}标签中。{{ }}指令简单地将命名变量的值替换到 HTML 的相应位置。所以例如docstrings.html中的{{ func_name }}会将我们传递给render_template()的func_name的值替换进去。
{% %}指令包含逻辑和流程控制。例如,index.html中的{% for func in funcs %}指令循环遍历funcs中的值,并重复包含的 HTML 对于每个值。
最后,你可能已经注意到模板允许继承。这是由{% block %}和{% extends %}指令提供的。在base.html中,我们声明了一些共享的样板 HTML,然后在<body>标签中我们只有一个{% block body %}指令。在index.html和docstring.html中,我们不包括样板 HTML;相反我们extend了base.html,这意味着这些模板将填充在base.html中声明的block指令。在index.html和docstring.html中,我们声明了一个body block,Flask 将其内容插入到base.html中的 HTML 中,替换匹配的{% block body %}。继承允许共享代码的重用,并且可以级联到任意级别。
在 Jinja2 模板指令中还有更多的功能可用;在jinja.pocoo.org/docs/dev/templates/查看模板设计者文档以获取完整列表。
其他模板引擎
Jinja2 显然不是唯一存在的模板包;你可以在wiki.python.org/moin/Templating找到一个维护的 Python 模板引擎列表。
像框架一样,存在不同的引擎是因为对于什么是一个好的引擎有不同的哲学观念。有些人认为逻辑和表现应该绝对分开,模板中不应该有流程控制和表达式,只提供值替换机制。其他人则采取相反的方式,允许在模板标记中使用完整的 Python 表达式。而其他一些引擎则采取中间路线的方式,比如 Jinja2。还有一些引擎使用完全不同的方案,比如基于 XML 的模板或者通过特殊的 HTML 标签属性声明逻辑。
没有“正确”的方法;最好尝试一些方法,看看哪种对你最有效。然而,如果一个框架有自己的引擎,比如 Django,或者与现有引擎紧密集成,比如 Flask,通常最好使用它们提供的内容,如果可以的话,你通常会更顺利。
添加一些样式
目前,我们的页面看起来有点单调。让我们添加一些样式。我们将通过包含一个静态 CSS 文档来实现这一点,但是相同的方法也可以用于包含图像和其他静态内容。本节的代码可以在本章源代码的3-style文件夹中找到。
首先,在你的项目文件夹中创建一个新的static文件夹,在其中创建一个名为style.css的新文本文件。将以下内容保存到其中:
body { font-family: Sans-Serif; background: white; }
h1 { color: #38b; }
pre { margin: 0px; font-size: 1.2em; }
.menuitem { float: left; margin: 1px 1px 0px 0px; }
.link { width: 100px; padding: 5px 25px; background: #eee; }
.link a { text-decoration: none; color: #555; }
.link a:hover { font-weight: bold; color: #38b; }
接下来,更新你的base.html文件的<head>部分,使其看起来像这样:
<head>
<title>Python Builtins Docstrings</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"/>
</head>
注意前面代码中的第三行和第四行——即<link>标签——应该在你的代码中是一行。再次在浏览器中尝试你的 Web 应用程序,注意它看起来(希望)更加现代化。
在这里,我们只是在base.html中的基本 HTML 中添加了一个样式表,添加了一个指向我们的static/style.css文件的<link>标签。我们使用 Flask 的url_for()函数来实现这一点。url_for()函数返回我们 URL 空间中命名部分的路径。在这种情况下,它是特殊的static文件夹,默认情况下 Flask 会在我们的 Web 应用程序的根目录中查找。url_for()还可以用于获取我们视图函数的路径,例如,url_for('index')会返回/。
你可以将图像和其他资源放在static文件夹中,并以相同的方式引用它们。
关于安全性的说明
如果你是新手网页编程,我强烈建议你了解网页应用程序中两种常见的安全漏洞。这两种漏洞都相当容易避免,但如果不加以解决,可能会产生严重后果。
XSS
第一个是跨站脚本(XSS)。这是攻击者将恶意脚本代码注入到网站的 HTML 中,导致用户的浏览器在不知情的情况下以该网站的安全上下文执行操作。一个典型的向量是用户提交的信息在没有适当的净化或转义的情况下重新显示给用户。
例如,一个方法是诱使用户访问包含精心制作的GET参数的 URL。正如我们在第二章中看到的,HTTP 和 与 Web 的工作,这些参数可以被 Web 服务器用来生成页面,有时它们的内容被包含在响应页面的 HTML 中。如果服务器在显示时不小心用 HTML 转义代码替换 URL 参数中的特殊字符,攻击者可以将可执行代码(例如 Javascript)放入 URL 参数中,并在访问该 URL 时实际执行它。如果他们能够诱使受害者访问该 URL,那么该代码将在用户的浏览器中执行,使攻击者有可能执行用户可以执行的任何操作。
基本的 XSS 预防措施是确保从 Web 应用程序外部接收的任何输入在返回给客户端时都得到适当的转义。Flask 在这方面非常有帮助,因为它默认激活了 Jinja2 的自动转义功能,这意味着我们通过模板渲染的任何内容都会自动受到保护。然而,并非所有的框架都具有这个功能,有些框架需要手动设置。此外,这仅适用于用户生成的内容不能包含标记的情况。在允许用户生成内容中包含一些标记的维基等情况下,你需要更加小心——请参阅本章的5-search文件夹中的源代码下载以获取示例。你应该始终确保查看你的框架文档。
CSRF
第二种攻击形式是跨站请求伪造(CSRF)。在这种攻击中,网站被欺骗以在用户的安全上下文中执行操作,而用户并不知情或同意。这通常是由 XSS 攻击引发的,导致用户的浏览器在用户登录的情况下在目标站点上执行操作。需要注意的是,这可能会影响网站,即使用户并没有在主动浏览它们;网站通常只有在用户明确注销时才清除 cookie 身份验证令牌,因此从网站和浏览器的角度来看,即使用户停止浏览网站后来自浏览器的任何请求,如果他们没有注销,都将被视为用户仍然登录。
防止 CSRF 攻击的一种技术是使潜在可滥用的操作(例如提交表单)需要一个仅服务器和客户端知道的一次性令牌值。CRSF 攻击通常采取预先组合的 HTTP 请求的形式,模仿用户提交表单或类似操作。然而,如果每次服务器向客户端发送一个表单时都包含一个不同的一次性令牌值,那么攻击者就无法在预先组合的请求中包含这个值,因此攻击尝试可以被检测并拒绝。这种技术对 XSS 发起的攻击和攻击者窃听浏览会话的 HTTP 流量的攻击效果较差。前者很难完全防范,最好的解决方案是确保首先不存在 XSS 漏洞。后者可以通过使用 HTTPS 而不是 HTTP 来减轻。有关更多信息,请参阅下面链接的 OWASP 页面。
不同的框架对提供基于一次性令牌的 CSRF 保护有不同的方法。Flask 没有内置此功能,但很容易添加一些内容,例如:
@app.before_request
def csrf_protect():
if request.method == "POST":
token = session.pop('_csrf_token', None)
if not token or token != request.form.get('_csrf_token'):
abort(403)
def generate_csrf_token():
if '_csrf_token' not in session:
session['_csrf_token'] = some_random_string()
return session['_csrf_token']
app.jinja_env.globals['csrf_token'] = generate_csrf_token
然后在带有表单的模板中,只需执行以下操作:
<form method="post" action="<whatever>">
<input name="_csrf_token" type="hidden" value="{{ csrf_token() }}">
这是来自 Flask 网站的:flask.pocoo.org/snippets/3/。虽然这包含了一些我们还没有涵盖的 Flask 功能,包括会话和@app.before_request()装饰器,你只需要在你的应用程序中包含上面的代码,并确保在每个表单中包含一个_ csrf_token隐藏输入。另一种方法是使用 Flask-WTF 插件,它提供了与WTForms包的集成,该包具有内置的 CSRF 保护。
另一方面,Django 具有内置的保护,但您需要启用并使用它。其他框架各不相同。始终检查您选择的框架的文档。
注意
关于 XSS 和 CSRF 的更多信息,请参阅 Flask 和 Django 网站:
同样在 OWASP 网站上,有一个包含各种与计算机安全相关信息的存储库:
结束框架
这就是我们在 Flask 中的涉足的尽头。在本章的可下载源代码中,有一些进一步适应我们应用程序的示例,特别是表单提交、访问请求中的表单值和会话。Flask 教程详细介绍了其中许多元素,非常值得一看flask.pocoo.org/docs/0.10/tutorial/。
这就是一个非常基本的 Python web 应用程序的样子。显然,有很多种方式可以编写相同的应用程序,就像有很多框架一样,那么你该如何选择一个框架呢?
首先,明确你的应用程序的目标是有帮助的。你是否需要数据库交互?如果是的话,像 Django 这样的更集成的解决方案可能更快开始。你是否需要基于网络的数据输入或管理界面?同样,如果是的话,Django 已经内置了这个功能。
接下来你可以看看你的环境。你的组织中是否已经有了一些首选的包,用于你可能想要执行的操作,比如数据库访问或单元测试?如果有,是否有任何框架已经在使用这些?如果没有,那么微框架可能是一个更好的选择,插入你所需的包。你是否有首选的操作系统或网络服务器用于托管,哪些框架支持这些?你的托管是否在 Python 版本、数据库技术或类似方面限制了你?另外,如果你有网页设计师,你是否有时间让他们熟悉复杂的模板语言,还是必须保持简单?
这些问题的答案可以帮助你缩小选择范围。然后,研究这些框架,询问正在使用它们的人,并尝试一些看起来可能的选择,将会让你达到你需要去的地方。
话虽如此,对于一个需要用户表单提交和数据库访问的一般网络应用程序,Django 是一个不错的选择。它真的是“电池已包含”,它的数据库模型很优雅,它的开箱即用的数据库管理和数据输入界面非常强大,可以节省大量时间。对于像 API 这样的简单应用程序,Flask 也是一个很好的选择,如果需要数据库访问,可以与 SQLAlchemy 一起使用。
正如我之前提到的,没有正确的答案,但通过探索现有的选择,看看框架采用的不同方法,可以学到很多东西。
当然,一旦我们有了我们的网络应用程序,我们需要一种托管它的方式。我们现在将看一些选项。
托管 Python 网络应用程序
正如我们在本章开头讨论的那样,为了运行 Python 网络应用程序,我们需要一个网络服务器来托管它。今天存在许多网络服务器,你很可能已经听说过几个。流行的例子有 Apache、nginx(发音为engine-x)、lhttpd(发音为lighty)和微软的Internet Information Services(IIS)。
关于网络服务器和它们可以使用的各种机制,有很多术语。我们将简要介绍一下网络应用程序的历史,以帮助解释其中一些概念。
CGI
在 Web 的早期,网络服务器主要只需要向客户端发送 HTML 页面,或偶尔的图像文件。就像之前的 HTTP 请求旅程图中一样,这些静态资源会存在于服务器的硬盘上,网络服务器的主要任务是接受来自客户端的套接字连接,将请求的 URL 映射到本地文件,并将文件作为 HTTP 响应通过套接字发送回去。
然而,随着对动态内容的需求的增加,网络服务器被赋予了通过调用外部程序和脚本来生成页面的能力,这就是我们今天所说的网络应用程序。网络应用程序最初采用脚本或编译后的可执行文件的形式,它们与常规静态内容一样存在于已发布的 Web 树的磁盘上。网络服务器将被配置,以便当客户端请求这些网络应用程序文件时,网络服务器不仅仅是读取文件并返回它,而是启动一个新的操作系统进程并执行文件,将结果作为请求的 HTML 网页返回。
如果我们更新我们之前图像中的 HTTP 请求的旅程,我们的请求的旅程现在看起来会是这样的:

显然,Web 服务器和 Web 应用程序之间需要一种协议来传递它们之间的 HTTP 请求和返回的 HTML 页面。最早的机制被称为通用网关接口(CGI)。Web 服务器会将请求分解为环境变量,并在调用处理程序时将其添加到环境中,并通过标准输入将请求的主体(如果有的话)传递给程序。然后,程序会简单地将其生成的 HTTP 响应传输到其标准输出,Web 服务器会捕获并返回给客户端。
然而,由于性能问题,CGI 在今天逐渐不受青睐,如果可能的话,应该避免编写 Python CGI 应用程序。
为了更美好的世界而回收利用
CGI 可以工作,但主要缺点是必须为每个请求启动一个新进程。从操作系统资源的角度来看,启动进程是昂贵的,因此这种方法非常低效。已经开发出了替代方案。
两种方法变得常见。第一种是使 Web 服务器在启动时启动和维护多个进程,准备接受新连接——这种技术称为预分叉。使用这种技术,仍然存在一对一的进程-客户端关系,但是当新客户端连接时,进程已经创建,从而提高了响应时间。此外,可以重复使用进程,而不是在每次连接时重新创建。
除此之外,Web 服务器被制作成可扩展的,并且创建了与不同语言的绑定,以便 Web 应用程序可以嵌入到 Web 服务器进程中。最常见的例子是 Apache Web 服务器的各种语言模块,用于诸如 PHP 和 Perl 之类的语言。
通过预分叉和 Web 应用程序嵌入,我们的请求的旅程可能如下所示:

在这里,请求由语言绑定代码转换,我们的 Web 应用程序看到的请求取决于绑定本身的设计。这种管理 Web 应用程序的方法对于一般的 Web 负载效果相当不错,今天仍然是托管 Web 应用程序的一种流行方式。现代浏览器通常也提供多线程变体,其中每个进程可以使用多个线程处理请求,每个客户端连接使用一个线程,进一步提高效率。
解决 CGI 性能问题的第二种方法是将 Web 应用程序进程的管理完全交给一个单独的系统。这个单独的系统会预先分叉并维护运行 Web 应用程序代码的进程池。与 Web 服务器预分叉一样,这些进程可以为每个客户端连接重复使用。开发了新的协议,允许 Web 服务器将请求传递给外部进程,其中最值得注意的是 FastCGI 和 SCGI。在这种情况下,我们的旅程将是:

同样,请求如何转换并呈现给 Web 应用程序取决于所使用的协议。
尽管在实践中这可能更复杂一些,但它比在预分叉的 Web 服务器进程中嵌入应用程序代码具有优势。主要是,Web 应用程序进程池可以独立于 Web 服务器进程池进行管理,从而更有效地调整两者。
事件驱动服务器
然而,Web 客户端数量继续增长,服务器需要能够处理非常大量的同时客户端连接,这些数字使用多进程方法证明是有问题的。这促使了事件驱动 Web 服务器的发展,例如nginx和lighttpd,它们可以在单个进程中处理许多数千个同时连接。这些服务器还利用预分叉,保持与机器中 CPU 核心数量一致的一些事件驱动进程,从而确保服务器的资源得到充分利用,同时也获得事件驱动架构的好处。
WSGI
Python Web 应用程序最初是针对这些早期集成协议编写的:CGI,FastCGI 和现在基本上已经废弃的mod_python Apache 模块。然而,这证明是麻烦的,因为 Python Web 应用程序与它们编写的协议或服务器绑定在一起。将它们移动到不同的服务器或协议需要对应用程序代码进行一些重新工作。
这个问题通过 PEP 333 得到解决,它定义了Web 服务网关接口(WSGI)协议。这为 Web 服务器调用 Web 应用程序代码建立了一个类似于 CGI 的通用调用约定。当 Web 服务器和 Web 应用程序都支持 WSGI 时,服务器和应用程序可以轻松交换。WSGI 支持已添加到许多现代 Web 服务器中,现在是在 Web 上托管 Python 应用程序的主要方法。它在 PEP 3333 中更新为 Python 3。
我们之前讨论的许多 Web 框架在幕后支持 WSGI 与其托管 Web 服务器进行通信,包括 Flask 和 Django。这是使用这样的框架的另一个重要好处-您可以免费获得完整的 WSGI 兼容性。
Web 服务器可以使用 WSGI 托管 Web 应用程序的两种方法。首先,它可以直接支持托管 WSGI 应用程序。纯 Python 服务器,如 Gunicorn,遵循这种方法,它们使得提供 Python Web 应用程序非常容易。这正变得越来越受欢迎。
第二种方法是非 Python 服务器使用适配器插件,例如 Apache 的mod_wsgi,或者 nginx 的mod_wsgi插件。
WSGI 革命的例外是事件驱动服务器。WSGI 不包括允许 Web 应用程序将控制权传递回调用进程的机制,因此对于使用阻塞 IO 风格 WSGI Web 应用程序来说,使用事件驱动服务器没有好处,因为一旦应用程序阻塞,例如,对于数据库访问,它将阻塞整个 Web 服务器进程。
因此,大多数事件驱动框架包括一个生产就绪的 Web 服务器-使 Web 应用程序本身成为事件驱动,并将其嵌入到 Web 服务器进程中是托管它的唯一方法。要使用这些框架托管 Web 应用程序,请查看框架的文档。
实际托管
那么这在实践中是如何工作的呢?就像我们在 Flask 中看到的那样,许多框架都配备了自己内置的开发 Web 服务器。然而,这些不建议在生产环境中使用,因为它们通常不是为了在重视安全性和可伸缩性的环境中使用而设计的。
目前,使用 Gunicorn 服务器可能是托管 Python Web 应用程序的生产质量服务器的最快方法。使用我们之前的 Flask 应用程序,我们可以通过几个步骤将其启动和运行。首先我们安装 Gunicorn:
**$ pip install gunicorn**
接下来,我们需要稍微修改我们的 Flask 应用程序,以便在 Gunicorn 下正确使用__builtins__。在您的tinyflaskapp.py文件中,找到以下行:
objs = __builtins__.__dict__.items()
将其更改为:
objs = __builtins__.items()
现在我们可以运行 Gunicorn。在 Flask 应用程序项目文件夹中,运行以下命令:
**$ gunicorn --bind 0.0.0.0:5000 tinyflaskapp:app**
这将启动 Gunicorn Web 服务器,在所有可用接口上监听端口 5000,并为我们的 Flask 应用提供服务。如果我们现在通过http://127.0.0.1:5000在 Web 浏览器中访问它,我们应该看到我们的文档索引页面。有关如何使 Gunicorn 成为守护进程的说明,以便它在后台运行,并且随系统自动启动和停止,可以在文档页面上找到,网址为gunicorn-docs.readthedocs.org/en/latest/deploy.html#monitoring。
Gunicorn 使用了之前描述的预分叉进程模型。您可以使用-w命令行选项设置进程数(Gunicorn 称它们为工作进程)。文档的“设计”部分包含有关确定要使用的最佳工作进程数量的详细信息,尽管一个好的起点是(2 x $num_cores) + 1,其中$num_cores是可供 Gunicorn 使用的 CPU 核心数量。
Gunicorn 提供了两种标准的工作类型:同步和异步。同步类型提供严格的每个客户端连接一个工作进程的行为,异步类型使用 eventlet(有关此库的详细信息和安装说明,请参见第八章,“客户端和服务器应用程序”)来提供基于事件的工作进程,可以处理多个连接。只有在使用反向代理时,才建议使用同步类型,因为使用同步类型直接提供给互联网会使您的应用程序容易受到拒绝服务攻击的影响(有关更多详细信息,请参见文档的设计部分)。如果不使用反向代理,则应改用异步类型。工作类型是使用-k选项在命令行上设置的。
进一步提高性能并扩展的一种有效方法是使用快速的事件驱动 Web 服务器,例如 nginx,作为我们 Gunicorn 实例前面的反向代理。反向代理充当传入 Web 请求的第一行服务器。它直接响应任何它确定是错误的请求,并且还可以配置为提供静态内容以替代我们的 Gunicorn 实例。但是,它还配置为将需要动态内容的任何请求转发到我们的 Gunicorn 实例,以便我们的 Python Web 应用程序可以处理它们。通过这种方式,我们可以获得 nginx 处理大部分 Web 流量的性能优势,而 Gunicorn 和我们的 Web 应用程序可以专注于提供动态页面。
注意
有关配置此反向代理配置的详细说明可以在 Gunicorn 页面上找到,网址为gunicorn-docs.readthedocs.org/en/latest/deploy.html#nginx-configuration。
如果您更喜欢使用 Apache,那么另一种有效的托管方法是使用带有mod_wsgi模块的 Apache。这需要一些更多的配置,完整的说明可以在以下网址找到:code.google.com/p/modwsgi/。mod_wsgi默认在嵌入模式下运行应用程序,其中 Web 应用程序托管在每个 Apache 进程中,这导致了类似于前面的预分叉示例的设置。或者它提供了一个守护程序模式,其中mod_wsgi管理一个外部于 Apache 的进程池,类似于之前的 FastCGI 示例。实际上,守护程序模式是出于稳定性和内存性能的考虑而推荐的。有关此配置的说明,请参阅mod_wsgi快速配置文档,网址为:code.google.com/p/modwsgi/wiki/QuickConfigurationGuide。
总结
我们已经快速浏览了将 Python 应用程序放在 Web 上的过程。我们概述了 Web 应用程序架构及其与 Web 服务器的关系。我们看了看 Python Web 框架的实用性,注意到它们为我们提供了工具和结构,可以更快地编写更好的 Web 应用程序,并帮助我们将我们的应用程序与 Web 服务器集成起来。
我们在 Flask Web 框架中编写了一个小应用程序,看到了它如何帮助我们优雅地管理我们的 URL 空间,以及模板引擎如何帮助我们清晰地管理应用逻辑和 HTML 的分离。我们还强调了一些常见的潜在安全漏洞——XSS 和 CSRF——并介绍了一些基本的缓解技术。
最后,我们讨论了 Web 托管架构以及可以用于将 Python Web 应用程序部署到 Web 的各种方法。特别是,WSGI 是 Web 服务器/ Web 应用程序交互的标准协议,Gunicorn 可用于快速部署,并与 nginx 反向代理一起扩展。Apache 与 mod_wsgi 也是一种有效的托管方法。
在这本书中,我们涵盖了很多内容,还有很多探索工作要做。我们希望这本书让你对可能性有所了解,并且渴望发现更多,希望这只是你在使用 Python 进行网络编程冒险的开始。
附录 A. 使用 Wireshark
在开发网络应用程序时,能够准确查看网络上传输的内容通常是很有用的。也许您的帧存在一些奇怪的问题,您正在尝试发现浏览器的用户代理,或者您想查看 IP 协议或更低层发生了什么。我们可以使用一类工具叫做数据包嗅探器来做到这一点。
数据包嗅探器
数据包嗅探器旨在捕获进出计算机的所有网络流量,使我们能够查看我们的程序发送和接收的所有数据包的完整原始内容,以及堆栈上所有协议的所有标头和有效负载。
我们将快速浏览其中一个应用程序。它不仅为我们提供了一个非常有用的网络编程调试工具,还直接展示了网络流量的结构,并让您更好地了解分层和封装的概念。
在我们开始之前,有一个小小的警告;如果您在不属于自己的网络上使用计算机,比如在您的工作场所或学习场所,您应该在运行数据包嗅探器之前获得网络管理员的许可。在使用网络集线器而不是交换机的网络上,嗅探器可能会捕获发送到其他计算机的数据。此外,运行数据包嗅探器可能违反您的网络使用政策。即使不违反,数据包嗅探器也是强大的网络监控工具,管理员通常希望在使用时知道。
如果这变得困难,不要惊慌!本书在任何时候都不依赖于使用数据包嗅探器;我们只是认为在编程网络时会发现它们很方便。
Wireshark
我们要看的程序叫做Wireshark。它是一个支持解释广泛的网络协议的开源数据包嗅探器。
安装
对于 Windows 和 Linux,Wireshark 可以从www.wireshark.org下载。在 Debian,Ubuntu,RHEL,CentOS 和 Fedora 上,它作为wireshark软件包可用。
您需要 root 或管理员访问权限才能安装此软件。在 Windows 上,如果要求安装或更新WinPcap库,请确保这样做,并在提示时允许它在启动时启动WinPcap驱动程序。
在 Debian 和 Ubuntu 上,您需要配置 Wireshark 以允许常规用户运行捕获。运行以下命令:
**$ sudo dpkg-reconfigure wireshark-common**
对于Should non-superusers be able to capture packets?选择Yes。请注意,这并不会自动允许所有非超级用户使用 Wireshark,他们仍然需要被添加到wireshark组。例如,现在为您自己的用户执行此操作:
**$ sudo usermod -aG wireshark myuser**
您可能需要注销并重新登录才能生效,或者甚至需要重新启动。对于其他 Linux 发行版,请查看其文档,或者在 Wireshark 的 wiki 上有关于分配这些权限的说明,网址是wiki.wireshark.org/CaptureSetup/CapturePrivileges。
如果在任何时候遇到问题,您可以在wiki.wireshark.org/CaptureSetup的 wiki 上获得有关安装的更多帮助。
配置完成后,在 Linux 上只需在X会话中运行wireshark即可启动图形界面。
捕获一些数据包
安装并运行 Wireshark 后,您将看到一个窗口,类似于这样:

数据包嗅探通常分为两个步骤:首先,我们运行一个流量捕获会话,然后分析捕获的流量。在捕获过程中,Wireshark 向操作系统请求其处理的所有网络流量的副本,然后 Wireshark 将其保存在缓冲区中供我们分析。Wireshark 提供了工具,让我们过滤捕获的数据,以便我们只处理我们想要的数据流,并深入每个数据包以查看标头数据和有效载荷。
因此,首先,我们需要选择要捕获流量的接口。我们可以看到开始按钮下面有一个接口列表。Wireshark 捕获我们选择的所有接口上传输的所有网络流量;这通常意味着我们最终会捕获许多我们实际上不感兴趣的数据。为了减少这种噪音,最好尽可能少地捕获接口,理想情况下只捕获一个接口。
我们将使用第一个 RFC 下载器,来自第一章网络编程和 Python,RFC_downloader.py,生成一些要分析的网络流量。由于此程序与互联网上的主机通信,我们希望捕获提供我们互联网连接的网络接口。
如果您不确定哪个接口是您的互联网接口,那么点击上面的开始按钮旁边的接口列表按钮,以打开窗口,如下面的屏幕截图所示:

在对话框的右侧,您可以看到自打开窗口以来通过每个接口的数据包数量的实时计数。如果没有太多活动,您可以通过浏览网站来生成一些互联网流量。数据包计数上升最快的接口将是互联网接口(在 Linux 上忽略any接口)。记下接口的名称并关闭窗口。
网络接口可以以两种模式之一捕获数据包:混杂模式和非混杂模式。在混杂模式下,接口将把它接收到的所有流量传递给嗅探器,即使这些流量并非是发送到我们计算机的流量。在非混杂模式下,接口会过滤掉任何不是发送给我们计算机的流量。除非您有非常特定的原因,通常最好以非混杂模式运行,因为这减少了我们需要手动过滤的多余流量。Wireshark 默认启用混杂模式。要禁用,进入捕获 | 选项...,确保未选中“在所有接口上使用混杂模式”。然后检查选项窗口顶部的接口列表中的“Prom Mode”列,并确保您要捕获的接口的状态为已禁用。完成后,关闭选项窗口返回到主屏幕。
从主屏幕上开始按钮下方的接口列表中选择您的互联网接口,并点击开始开始捕获。一会儿后,我们应该会看到一些数据包进来:

在 Wireshark 捕获数据包时,让我们生成一些我们有兴趣分析的流量。在终端中运行RFC_downloader.py RFC 下载程序以下载 RFC 2324:
**$ python3 RFC_downloader.py 2324**
**...**
**Network Working Group L. Masinter**
**Request for Comments: 2324 1 April 1998**
**Category: Informational**
**...**
下载完成后,返回到 Wireshark,并通过工具栏中的停止按钮停止捕获。如果捕获出现问题,不要担心,我们可以再试一次;只需停止捕获,然后在工具栏中点击开始新的实时捕获按钮,并在提示时不保存对先前捕获的更改。运行RFC_downloader.py。一旦您有包含 RFC 下载器流量的捕获,让我们仔细看一下。
在前面的截图中可以看到,Wireshark 捕获屏幕分为三个部分。顶部部分列出了捕获的数据包,每行一个数据包,并为每个数据包提供基本信息,例如源和目的地地址,以及数据包包含数据的最高层协议的名称。
中间部分包含所选数据包中存在的协议的详细信息。顶部行相当于网络堆栈中的第 1 层,随后的行对应于更高的层。
底部部分包含整个捕获数据包的原始列表。这被分为三个主要的垂直区域。左侧第一列中的数字是从数据包开头的十六进制字节偏移量。中间部分包括两列每列 8 个十六进制数字;这部分显示数据包中的每个字节作为十六进制整数。右侧部分包括两列 ASCII 字符,是数据包中字节的 ASCII 表示。在这里使用点,当一个字节值映射到一个不可打印的字符时。
过滤
让我们看看我们的下载程序生成了哪些数据包。在捕获中可能有相当多额外的网络数据,所以首先,我们需要将其过滤掉。
Wireshark 允许我们使用其支持的任何协议的任何属性进行过滤。要进行过滤,我们使用工具栏下方的过滤框。Wireshark 有一个完整的过滤语言,您可以在帮助系统中进行调查。现在,我们只是要做一些基本的查询来找到我们的数据包。在过滤框中输入http,然后单击应用按钮。这将限制显示的数据包只涉及 HTTP 协议,如下图所示:

即使在捕获会话期间您故意生成的唯一 HTTP 流量是通过下载程序,我们可能会看到更多的 HTTP 数据包。这是因为一些程序,例如文件云存储客户端,经常通过 HTTP 在后台与其服务进行通信。此外,Wireshark 目前将 SSDP 协议数据包识别为 HTTP,因为 SSDP 源自 HTTP。
不过没关系,我们可以优化我们的过滤器。我们下载器数据包的唯一标识特征是我们通信的服务器,www.ietf.org。如果我们看一下数据包列表,你会发现捕获的数据包的源和目的地地址是 IP 地址,所以在编写新的过滤器之前,我们需要找出www.ietf.org的 IP 地址。
获取主机名的 IP 地址称为名称解析,这正是 DNS 设计的任务。我们可以使用几种机制与 DNS 交互。在 Linux 和 Windows 上,我们可以使用nslookup命令行工具。运行以下命令:
**$ nslookup www.ietf.org**
**Server: 127.0.1.1**
**Address: 127.0.1.1#53**
**Non Authoritative answer:**
**www.ietf.org canonical name = www.ietf.org.cdn.cloudflare-**
**dnssec.net.**
**Name: www.ietf.org.cdn.cloudflare-dnssec.net**
**Address: 104.20.1.85**
**Name: www.ietf.org.cdn.cloudflare-dnssec.net**
**Address: 104.20.0.85**
输出表明www.ietf.org实际上托管在两个 IP 地址上:104.20.1.85和104.20.0.85。随着越来越多的网站部署负载均衡和内容传送网络来分散服务器的工作负载,这种情况变得越来越频繁。
快速查看我们捕获的 HTTP 数据包列表可能会让我们看到我们最终连接到的服务器。在前面的示例中,它是104.20.0.85。但是,为了确保,我们可以过滤这两个 IP 地址。
请注意,nslookup可能返回与前面示例中显示的 IP 地址不同的 IP 地址。Web 服务可以因各种原因更改其服务器的 IP 地址。
现在,我们可以过滤www.ietf.org。使用刚刚解析的 IP 地址,输入以下新查询到过滤框中:
http and (ip.addr == 104.20.1.85 or ip.addr == 104.20.0.85)
再次单击应用按钮。此查询添加了额外的条件,即除了涉及 HTTP 协议外,数据包必须具有 IP 源地址或目的地址为104.20.1.85或104.20.0.85。
ip.addr语法是过滤协议属性的典型示例。还有很多其他的。例如,如果我们只想按源地址过滤而不是源地址和目的地址,我们可以使用以下命令:
http and (ip.src == 104.20.1.85 or ip.src == 104.20.0.85)
要探索所有可用的协议及其属性,请单击过滤框右侧的表达式...按钮。在出现的窗口的左侧窗格中,我们可以看到列出的所有协议,并且可以通过单击相应的三角形或+符号来展开其中一个,这将显示其属性。在此窗口中,IP 被列为IPv4。
检查数据包
回到我们的 RFC 下载器数据包,如果表达式窗口打开了,让我们关闭它,然后将注意力转向主窗口。应用了http and (ip.addr == 104.20.1.85 or ip.addr == 104.20.0.85)过滤器后,我们应该在屏幕顶部的列表中看到两个数据包:

第一个是urlopen()发送到服务器的 HTTP 请求,第二个是服务器的 HTTP 响应。
单击第一个数据包以选择它,然后将注意力转向窗口的中间部分。我们可以看到五行信息。每一行对应网络堆栈中的一层和该层中使用的协议。在屏幕底部的原始数据包列表上保持关注,单击中间部分的不同行。您会看到突出显示不同区域的原始数据包列表。突出显示的区域是您单击的协议相关的原始数据包的部分。对于第一层(以Frame开头的行),它会突出显示整个数据包,因为整个数据包是通过电线发送的。对于最后一层,超文本传输协议,它会突出显示数据包的部分,即 HTTP 请求,如前面的示例所示。对于中间的层,它只会突出显示该协议封装数据包的标头。
我们可以通过单击中间部分协议行左侧的三角形或+符号来查看每个封装数据包的标头数据。如果我们对超文本传输协议行这样做,我们会得到类似于这样的东西:

我们的请求中的HTTP标头已被 Wireshark 解释并拆分,以使其更易读。您可以以相同的方式探索其他协议的数据。
让我们检查我们捕获的第二个数据包,即 HTTP 响应。现在在窗口的顶部部分单击它:

您会注意到中间部分的这个数据包有一些额外的行。指示重新组装的 TCP 段的行表明 HTTP 响应实际上足够大,可以跨四个 TCP 数据包进行分割。Wireshark 识别到这一点,并通过组合相关的 TCP 数据包重新组装了完整的 HTTP 数据包,因此当我们单击超文本传输协议行时,我们会看到整个 HTTP 数据包。
注意
如果您没有看到这个选项,您可能需要在选项菜单中打开它。转到编辑 | 首选项...以打开首选项窗口,然后在屏幕左侧的列表中展开协议,并向下滚动找到HTTP。确保检查了涉及跨多个 TCP 段的两个选项。
最后,基于行的文本数据行向我们显示了响应内容的媒体类型(在第二章中描述,HTTP 和与网络工作),展开该行会显示响应正文的文本数据。
一个多功能工具
正如您在浏览菜单时可能会注意到的那样,Wireshark 是一个功能非常丰富的网络分析器,我们甚至还没有完全发掘出它的全部功能。我鼓励您在阅读本书时随时使用它,并且在您希望更仔细地查看网络上传输或接收的数据时使用它。


浙公网安备 33010602011771号