Python-网络编程学习手册(一)

Python 网络编程学习手册(一)

原文:zh.annas-archive.org/md5/b9ea58a6220e445a9f19c9c78aff8a58

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到使用 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 的软件包,仍然有其他安装方法。我们将讨论两种方法,PythonzJuJu

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 addripconfig /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 &ldquo;jessie&rdquo; 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 addripconfig /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.pysocket模块是 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 交换的例子,第一章网络编程和 Pythonurllib包被分成几个子模块,用于处理我们在使用 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()函数都返回字节对象,httpurllib都不会对接收到的数据进行解码为 Unicode。在本章的后面,我们将看到如何利用Requests库来处理这个问题。

状态码

如果我们想知道我们的请求是否发生了意外情况怎么办?或者如果我们想知道我们的响应在读取数据之前是否包含任何数据怎么办?也许我们期望得到一个大的响应,我们想快速查看我们的请求是否成功而不必读取整个响应。

HTTP 响应通过状态码为我们提供了这样的方式。我们可以通过使用其status属性来读取响应的状态码。

**>>> response.status**
**200**

状态码是告诉我们请求的情况的整数。200代码告诉我们一切都很好。

有许多代码,每个代码传达不同的含义。根据它们的第一个数字,状态码被分为以下几组:

  • 100:信息

  • 200:成功

  • 300:重定向

  • 400:客户端错误

  • 500:服务器错误

一些常见的代码及其消息如下:

  • 200OK

  • 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提供了有关请求的有用属性。在前面的示例中,我们使用了statusreasonurl属性来获取有关响应的一些信息。

如果网络堆栈中出现问题,那么适当的模块将引发异常。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.html192.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 注册。当前列表包括:gzipcompressdeflateidentity。前三个是指特定的压缩方法。最后一个允许客户端指定不希望对内容应用任何编码。

让我们看看如果我们使用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 来维护。

我们将讨论如何使用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 的属性。让我们来检查 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'**

对于网上几乎所有的资源,我们将使用httphttps方案。在这些方案中,要定位特定的资源,我们需要知道它所在的主机和我们应该连接到的 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,但是sockethttp模块支持将 Unicode 主机名透明地编码为 ASCII 兼容的编码,因此在实践中我们不需要担心编码主机名。关于这个过程的更多细节可以在codecs模块文档的encodings.idna部分找到。

URL 总结

在前面的部分中,我们使用了相当多的函数。让我们简要回顾一下我们每个函数的用途。所有这些函数都可以在urllib.parse模块中找到。它们如下:

  • 将 URL 拆分为其组件:urlparse

  • 将绝对 URL 与相对 URL 组合:urljoin

  • 将查询字符串解析为dictparse_qs

  • 对路径进行 URL 编码:quote

  • dict创建 URL 编码的查询字符串:urlencode

  • 从组件创建 URL(urlparse的反向):urlunparse

HTTP 方法

到目前为止,我们一直在使用请求来请求服务器向我们发送网络资源,但是 HTTP 提供了更多我们可以执行的操作。我们请求行中的GET是一个 HTTP 方法,有几种方法,比如HEADPOSTOPTIONPUTDELETETRACECONNECTPATCH

我们将在下一章中详细讨论其中的一些,但现在我们将快速查看两种方法。

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头中包括gzipdeflate。如果我们查看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而不是bytesRequests库使用头中的值来选择字符集并将内容解码为 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模块的CookieJarurllib模块的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 以获取数据,并将我们自己的对象上传到网络。

posted @ 2024-04-18 10:50  绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报