Python 技术手册第三版(七)
原文:
annas-archive.org/md5/9e375b08cb0be52e8b7c2a9eba6f5313译者:飞龙
第十八章:网络基础
面向连接协议的工作方式类似于打电话。您请求与特定的网络端点建立连接(类似于拨打某人的电话号码),您的对方要么接听要么不接听。如果接听,您可以与他们交谈并听到他们的回答(如果需要可以同时进行),并且您知道没有任何信息丢失。在对话结束时,您都会说再见并挂断电话,因此如果没有发生这种关闭事件,就明显表明出了问题(例如,如果突然听不到对方的声音)。传输控制协议(TCP)是互联网的主要面向连接传输协议,被 Web 浏览器、安全外壳、电子邮件和许多其他应用程序使用。
无连接或者数据报协议更像是通过发送明信片进行通信。大多数情况下,消息可以传递,但是如果出了问题,你必须准备好应对后果——协议不会通知你消息是否已接收,而且消息可能会无序到达。对于交换短消息并获取答案,数据报协议的开销比面向连接的协议小,前提是整体服务能够处理偶发的中断。例如,域名服务(DNS)服务器可能无法响应:直到最近,大多数 DNS 通信都是无连接的。用户数据报协议(UDP)是互联网通信的主要无连接传输协议。
如今,安全性变得越来越重要:理解安全通信的基础知识有助于确保您的通信达到所需的安全水平。如果这个摘要让您在没有充分了解问题和风险的情况下尝试实现这样的技术,那么它将发挥出有价值的作用。
所有网络接口之间的通信都是通过字节串交换的。要传输文本或者其他大多数信息,发送方必须将其编码为字节,接收方必须解码。在本章中,我们将讨论单个发送方和单个接收方的情况。
伯克利套接字接口
如今大多数网络使用套接字。套接字提供了独立端点之间的管道访问,使用传输层协议在这些端点之间传输信息。套接字的概念足够通用,使得端点可以位于同一台计算机上,也可以位于联网的不同计算机上,无论是在本地还是通过广域网连接。
如今最常用的传输层是 UDP(用于无连接的网络)和 TCP(用于面向连接的网络);每种传输层在通用 Internet 协议(IP)网络层上运行。这些协议堆栈以及运行在其上的许多应用协议总称为TCP/IP。Gordon McMillan 的(有些陈旧但仍然有效的)Socket 编程指南提供了很好的介绍。
两种最常见的套接字家族是基于 TCP/IP 通信的互联网套接字(提供现代 IPv6 和更传统的 IPv4 两种版本)和Unix 套接字,尽管还有其他家族可用。互联网套接字允许任何两台可以交换 IP 数据报的计算机进行通信;Unix 套接字只能在同一 Unix 机器上的进程之间通信。
为了支持许多并发的互联网套接字,TCP/IP 协议栈使用由 IP 地址、端口号和协议标识的端点。端口号允许协议处理软件在相同 IP 地址上使用相同协议时区分不同的端点。连接的套接字还与一个远程端点关联,即连接并能够通信的对方套接字。
大多数 Unix 套接字在 Unix 文件系统中具有名称。在 Linux 平台上,以零字节开头的套接字存在于内核维护的名称池中。例如,这些对于与chroot-jail 进程通信非常有用,例如,两个进程之间没有共享文件系统。
互联网套接字和 Unix 套接字均支持无连接和面向连接的网络,因此如果您仔细编写程序,它们可以在任何套接字家族上工作。本书不讨论其他套接字家族的范围,尽管我们应该提到原始套接字,它是互联网套接字家族的一个子类型,允许直接发送和接收链路层数据包(例如以太网数据包)。这对于一些实验性应用和数据包嗅探很有用。
创建互联网套接字后,您可以将特定的端口号与套接字关联(只要该端口号没有被其他套接字使用)。这是许多服务器使用的策略,提供所谓的众所周知的端口号,由互联网标准定义为 1-1,023 范围内的端口号。在 Unix 系统上,需要root权限才能访问这些端口。典型的客户端不关心使用的端口号,因此通常请求由协议驱动程序分配并保证在主机上唯一的临时端口。无需绑定客户端端口。
考虑同一台计算机上的两个进程,每个进程都作为同一个远程服务器的客户端。它们套接字的完整关联具有五个组成部分,(本地 IP 地址、本地端口号、协议、远程 IP 地址、远程端口号)。当数据包到达远程服务器时,目标 IP 地址、源 IP 地址、目标端口号和协议对两个客户端来说是相同的。短暂端口号的唯一性保证了服务器能区分来自两个客户端的流量。这就是 TCP/IP 处理同一对 IP 地址之间的多个会话的方式。¹
套接字地址
不同类型的套接字使用不同的地址格式:
-
Unix 套接字地址是命名文件系统中节点的字符串(在 Linux 平台上,是以 b'\0' 开头的字节串,并对应内核表中的名称)。
-
IPv4 套接字地址是 (地址, 端口) 对。第一项是 IPv4 地址,第二项是范围在 1 到 65,535 的端口号。
-
IPv6 套接字地址是四项 (地址, 端口, 流信息, 作用域 ID) 元组。在提供地址作为参数时,通常可以省略 流信息 和 作用域 ID,只要 地址作用域 不重要即可。
客户端/服务器计算
我们接下来讨论的模式通常称为 客户端/服务器 网络,其中 服务器 在特定端点上监听来自需要服务的 客户端 的流量。我们不涵盖 点对点 网络,因为缺少任何中央服务器,必须包含对等方发现的能力。
大多数(虽然并非全部)网络通信是通过客户端/服务器技术进行的。服务器在预定或公布的网络端点处监听传入的流量。在缺少此类输入时,它不做任何操作,只是坐在那里等待来自客户端的输入。连接在面向无连接和面向连接的端点之间的通信有所不同。
在面向无连接的网络中(例如通过 UDP),请求随机到达服务器并立即处理:响应立即发送给请求者。每个请求单独处理,通常不参考先前在两方之间可能发生的任何通信。面向无连接的网络非常适合短期无状态交互,例如 DNS 或网络引导所需的交互。
在面向连接的网络中,客户端与服务器进行初始交换,有效地在两个进程之间建立了网络管道上的连接(有时称为虚拟电路),在这些进程可以进行通信,直到两者表示愿意结束连接。在这种情况下,服务需要使用并行处理(通过线程、进程或异步机制:参见第十五章)来异步或同时处理每个传入的连接。如果没有并行处理,服务器将无法在早期的连接终止之前处理新的传入连接,因为对套接字方法的调用通常会阻塞(即它们会暂停调用它们的线程,直到它们终止或超时)。连接是处理诸如邮件交换、命令行交互或传输 Web 内容等长时间交互的最佳方法,并在使用 TCP 时提供自动错误检测和纠正。
无连接的客户端和服务器结构。
无连接服务器的整体逻辑流程如下:
-
通过调用 socket.socket 创建类型为 socket.SOCK_DGRAM 的套接字。
-
通过调用套接字的 bind 方法将套接字与服务端点关联。
-
重复以下步骤无限期:
-
通过调用套接字的 recvfrom 方法请求来自客户端的传入数据报;此调用会阻塞,直到接收到数据报。
-
计算或查找结果。
-
通过调用套接字的 sendto 方法将结果发送回客户端。
-
服务器大部分时间都在步骤 3a 中等待来自客户端的输入。
无连接客户端与服务器的交互如下:
-
通过调用 socket.socket 创建类型为 socket.SOCK_DGRAM 的套接字。
-
可选地,通过调用套接字的 bind 方法将套接字与特定端点关联。
-
通过调用套接字的 sendto 方法向服务器端点发送请求。
-
通过调用套接字的 recvfrom 方法等待服务器的回复;此调用会阻塞,直到收到响应。必须对此调用应用超时,以处理数据报丢失的情况,程序必须重试或中止尝试:无连接套接字不保证传递。
-
在客户端程序的剩余逻辑中使用结果。
单个客户端程序可以与同一个或多个服务器执行多次交互,具体取决于需要使用的服务。许多这样的交互对应用程序员来说是隐藏在库代码中的。一个典型的例子是将主机名解析为适当的网络地址,通常使用 gethostbyname 库函数(在 Python 的 socket 模块中实现,稍后讨论)。无连接的交互通常涉及向服务器发送一个数据包并接收一个响应数据包。主要的例外情况涉及流协议,如实时传输协议(RTP),² 这些协议通常构建在 UDP 之上,以最小化延迟和延迟:在流式传输中,发送和接收许多数据报。
连接导向客户端和服务器结构
连接导向服务器的逻辑流程如下:
-
通过调用
socket.socket创建socket.SOCK_STREAM类型的套接字。 -
通过调用套接字的
bind方法将套接字与适当的服务器端点关联起来。 -
通过调用套接字的
listen方法开始端点监听连接请求。 -
无限重复以下步骤ad infinitum:
-
通过调用套接字的
accept方法等待传入的客户端连接;服务器进程会阻塞,直到收到传入的连接请求。当这样的请求到达时,会创建一个新的套接字对象,其另一个端点是客户端程序。 -
创建一个新的控制线程或进程来处理这个特定的连接,将新创建的套接字传递给它;主控线程然后通过回到步骤 4a 来继续。
-
在新的控制线程中,使用新套接字的
recv和send方法与客户端进行交互,分别用于从客户端读取数据和向其发送数据。recv方法会阻塞,直到从客户端接收到数据(或客户端指示希望关闭连接,在这种情况下recv返回空结果)。当服务器希望关闭连接时,可以通过调用套接字的close方法来实现,可选择先调用其shutdown方法。
-
服务器大部分时间都在步骤 4a 中等待来自客户端的连接请求。
连接导向客户端的总体逻辑如下:
-
通过调用
socket.socket创建socket.SOCK_STREAM类型的套接字。 -
可选地,通过调用套接字的
bind方法将套接字与特定端点关联。 -
通过调用套接字的
connect方法建立与服务器的连接。 -
使用套接字的 recv 和 send 方法与服务器进行交互,分别用于从服务器读取数据和向其发送数据。recv 方法会阻塞,直到从服务器接收到数据(或者服务器指示希望关闭连接,在这种情况下,recv 调用会返回空结果)。send 方法仅在网络软件缓冲区有大量数据时才会阻塞,导致通信暂停,直到传输层释放部分缓冲内存。当客户端希望关闭连接时,可以调用套接字的 close 方法,可选地先调用其 shutdown 方法。
面向连接的交互通常比无连接的更复杂。具体来说,确定何时读取和写入数据更加复杂,因为必须解析输入以确定何时传输完毕。在面向连接的网络中使用的更高层协议适应了这种确定性;有时通过在内容中指示数据长度来实现,有时则采用更复杂的方法。
socket 模块
Python 的 socket 模块通过 socket 接口处理网络。虽然各平台间有些许差异,但该模块隐藏了大部分差异,使得编写可移植的网络应用相对容易。
该模块定义了三个异常类,均为内置异常类 OSError 的子类(见 Table 18-1)。
Table 18-1. socket 模块异常类
| herror | 用于识别主机名解析错误:例如,socket.gethostbyname 无法将名称转换为网络地址,或者 socket.gethostbyaddr 找不到网络地址对应的主机名。相关的值是一个两元组(h_errno,string),其中 h_errno 是来自操作系统的整数错误号,string 是错误的描述。 |
|---|---|
| gaierror | 用于识别在 socket.getaddrinfo 或 socket.getnameinfo 中遇到的地址解析错误。 |
| timeout | 当操作超过超时限制时(依据 socket.setdefaulttimeout,可以在每个套接字上覆盖),引发此异常。 |
该模块定义了许多常量。其中最重要的是地址家族(AF_)和套接字类型(SOCK_),列在 Table 18-2 中,作为 IntEnum 集合的成员。此外,该模块还定义了许多其他用于设置套接字选项的常量,但文档未对其进行详细定义:要使用它们,您必须熟悉 C 套接字库和系统调用的文档。
Table 18-2. socket 模块中定义的重要常量
| AF_BLUETOOTH | 用于创建蓝牙地址家族的套接字,用于移动和个人区域网络(PAN)应用中。 |
|---|---|
| AF_CAN | 用于创建 Controller Area Network (CAN) 地址家族的套接字,在自动化、汽车和嵌入式设备应用中广泛使用。 |
| AF_INET | 用于创建 IPv4 地址族的套接字。 |
| AF_INET6 | 用于创建 IPv6 地址族的套接字。 |
| AF_UNIX | 用于创建 Unix 地址族的套接字。此常量仅在支持 Unix 套接字的平台上定义。 |
| SOCK_DGRAM | 用于创建无连接套接字,提供尽力而为的消息传递,无连接能力或错误检测。 |
| SOCK_RAW | 用于创建直接访问链路层驱动程序的套接字;通常用于实现较低级别的网络功能。 |
| SOCK_RDM | 用于创建在透明进程间通信(TIPC)协议中使用的可靠连接的无连接消息套接字。 |
| SOCK_SEQPACKET | 用于创建在 TIPC 协议中使用的可靠连接的面向连接的消息套接字。 |
| SOCK_STREAM | 用于创建面向连接的套接字,提供完整的错误检测和修正功能。 |
该模块定义了许多函数来创建套接字、操作地址信息,并辅助标准数据的表示。本书未涵盖所有函数,因为套接字模块的文档非常全面;我们只处理编写网络应用程序中必需的部分。
套接字模块包含许多函数,其中大多数仅在特定情况下使用。例如,当网络端点之间进行通信时,端点可能存在架构差异,并以不同方式表示相同的数据,因此存在处理有限数据类型转换的函数,以及从网络中立形式转换的函数。Table 18-3 列出了此模块提供的一些更普遍适用的函数。
Table 18-3. 套接字模块的有用函数
| getaddrinfo | socket.getaddrinfo(host, port, family=0, type=0, proto=0, flags=0) 接受host和port,返回形如(family, type, proto, canonical_name, socket)的五元组列表,可用于创建到特定服务的套接字连接。当您传递主机名而不是 IP 地址时,getaddrinfo 返回一个元组列表,每个 IP 地址与名称关联。 |
|---|---|
| getdefaulttimeout | socket.getdefaulttimeout() 返回套接字操作的默认超时值(以秒为单位),如果未设置值则返回None。某些函数允许您指定显式超时。 |
| getfqdn | socket.getfqdn([host]) 返回与主机名或网络地址关联的完全限定域名(默认情况下是调用它的计算机的域名)。 |
| gethostbyaddr | socket.gethostbyaddr(ip_address) 接受包含 IPv4 或 IPv6 地址的字符串,并返回一个形如 (hostname, aliaslist, ipaddrlist) 的三元组。 hostname 是 IP 地址的规范名称,aliaslist 是一个替代名称列表,ipaddrlist 是一个 IPv4 和 IPv6 地址列表。 |
| gethostbyname | socket.gethostbyname(hostname) 返回一个包含与给定主机名关联的 IPv4 地址的字符串。如果使用 IP 地址调用,则返回该地址。此函数不支持 IPv6:请使用 getaddrinfo 获取 IPv6。 |
| getnameinfo | socket.getnameinfo(sock_addr, flags=0) 接受套接字地址并返回一个 (host, port) 对。没有标志*,* host 是一个 IP 地址,port 是一个整数。 |
| setdefaulttimeout | socket.setdefaulttimeout(timeout) 将套接字的默认超时设置为浮点秒值。新创建的套接字按照 timeout 值确定的模式运行,如下一节所述。将 timeout 作为 None 传递以取消随后创建的套接字上的隐式超时使用。 |
Socket 对象
socket 对象是 Python 中网络通信的主要手段。当 SOCK_STREAM 套接字接受连接时,也会创建一个新的套接字,每个这样的套接字都用于与相应的客户端通信。
Socket 对象和 with 语句
每个 socket 对象都是上下文管理器:您可以在 with 语句中使用任何 socket 对象,以确保在退出语句体时正确终止套接字。有关详细信息,请参见“with 语句和上下文管理器”。
有几种创建 socket 的方式,如下一节所述。根据超时值,套接字可以在三种不同的模式下运行,如表 18-4 所示,可以通过不同的方式设置超时值:
-
通过在创建 socket 时提供超时值作为参数
-
通过调用 socket 对象的 settimeout 方法
-
根据 socket 模块的默认超时值,由 socket.getdefaulttimeout 函数返回
建立每种可能模式的超时值列在 表 18-4 中。
表 18-4. 超时值及其关联模式
| None | 设置 阻塞 模式。每个操作都会暂停线程(阻塞),直到操作完成,除非操作系统引发异常。 |
|---|---|
| 0 | 设置 非阻塞 模式。每个操作在无法立即完成或发生错误时引发异常。使用 selectors 模块 查找操作是否可以立即完成。 |
| >0.0 | 设置 超时 模式。每个操作都会阻塞,直到完成,或超时(在这种情况下会引发 socket.timeout 异常),或发生错误。 |
套接字对象表示网络端点。socket 模块提供了多个函数来创建套接字(参见 表 18-5)。
表 18-5. 套接字创建函数
| create_connection | create_connection([address[, timeout[, source_address]]]) 创建一个连接到地址(一个(host, port)对)的 TCP 端点的套接字。host 可以是数字网络地址或 DNS 主机名;在后一种情况下,将尝试为 AF_INET 和 AF_INET6(顺序不确定)进行名称解析,然后依次尝试连接返回的每个地址——这是创建既能使用 IPv6 又能使用 IPv4 的客户端程序的便捷方式。
timeout 参数(如果提供)指定连接超时时间(单位为秒),从而设置套接字的模式(参见表 18-4);当参数不存在时,将调用 socket.getdefaulttimeout 函数来确定该值。如果提供 source_address 参数,那么它也必须是一个(host, port)对,远程套接字将其作为连接端点传递。当 host 为 '' 或 port 为 0 时,将使用默认的操作系统行为。
| socket | socket(family=AF_INET, type=SOCK_STREAM, proto=0, fileno=None)
创建并返回适当地址族和类型的套接字(默认为 IPv4 上的 TCP 套接字)。子进程不会继承创建的套接字。协议编号 proto 仅在 CAN 套接字中使用。当传递 fileno 参数时,将忽略其他参数:函数返回已关联给定文件描述符的套接字。
| socketpair | socketpair([family[, type[, proto]]]) 返回给定地址族、套接字类型和(仅对于 CAN 套接字)协议的连接对套接字。当未指定 family 时,在支持该族的平台上,套接字为 AF_UNIX;否则,它们为 AF_INET。当未指定 type 时,默认为 SOCK_STREAM。 |
|---|
套接字对象 s 提供了 表 18-6 中列出的方法。那些涉及连接或需要已连接套接字的方法仅适用于 SOCK_STREAM 套接字,而其他方法适用于 SOCK_STREAM 和 SOCK_DGRAM 套接字。对于带有 flags 参数的方法,可用的确切标志集取决于您的特定平台(可用值在 Unix 手册页面的 recv(2) 和 send(2) 中以及 Windows 文档 中有记录);如果省略,flags 默认为 0。
表 18-6. 套接字实例 s 的方法
| accept | accept() 阻塞直到客户端与 s 建立连接(s 必须已绑定到一个地址(通过调用 s.bind)并设置为侦听状态(通过调用 s.listen))。返回一个新套接字对象,可用于与连接的另一端点通信。 |
|---|---|
| bind | bind(address) 将 s 绑定到特定地址。address 参数的形式取决于套接字的地址族(参见“套接字地址”)。 |
| close | close() 标记套接字为关闭状态。调用 s.close 并不一定会立即关闭连接,这取决于是否还有对套接字的其他引用。如果需要立即关闭连接,首先调用 s.shutdown 方法。确保套接字及时关闭的最简单方法是在 with 语句中使用它,因为套接字是上下文管理器。 |
| connect | connect(address) 连接到地址为 address 的远程套接字。address 参数的形式取决于地址族(参见“套接字地址”)。 |
| detach | detach() 将套接字置于关闭模式,但允许套接字对象用于进一步的连接(通过再次调用 connect)。 |
| dup | dup() 返回套接字的副本,不能被子进程继承。 |
| fileno | fileno() 返回套接字的文件描述符。 |
| getblocking | getblocking() 如果套接字被设置为阻塞模式,则返回True,可以通过调用 s.setblocking(True) 或 s.settimeout(None) 进行设置。否则,返回False。 |
| get_inheritable | get_inheritable() 当套接字能够被子进程继承时返回True。否则,返回False。 |
| getpeername | getpeername() 返回此套接字连接的远程端点的地址。 |
| getsockname | getsockname() 返回此套接字正在使用的地址。 |
| gettimeout | gettimeout() 返回与此套接字关联的超时时间。 |
| listen | listen([backlog]) 开始监听套接字的关联端点上的流量。如果给定,整数参数 backlog 确定操作系统在开始拒绝连接之前允许排队的未接受连接数量。 |
| makefile | makefile(mode, buffering=None, *, encoding=None, newline=None) 返回一个文件对象,允许套接字用于类似文件的操作,如读和写。参数类似于内置的 open 函数(参见“使用 open 创建文件对象”)。mode 可以是 'r' 或 'w';对于二进制传输,可以添加 'b'。套接字必须处于阻塞模式;如果设置了超时值,当超时发生时可能会观察到意外的结果。 |
| recv | recv(bufsiz[, flags]) 接收并返回来自套接字 s 的最多 bufsiz 字节数据。 |
| recvfrom | recvfrom(bufsiz[, flags]) 从 s 接收最多 bufsiz 字节的数据。返回一对(bytes,address):bytes 是接收到的数据,address 是发送数据的对方套接字的地址。 |
| recvfrom_into | recvfrom_into(buffer[, nbytes[, flags]]) 从 s 接收最多 nbytes 字节的数据,并将其写入给定的 buffer 对象中。如果省略 nbytes 或为 0,则使用 len(buffer)。返回一个二元组 (nbytes, address):nbytes 是接收的字节数,address 是发送数据的对方套接字的地址(**_into* 函数比分配新缓冲区的“普通”函数更快)。 |
| recv_into | recv_into(buffer[, nbytes[, flags]]) 从 s 接收最多 nbytes 字节的数据,并将其写入给定的 buffer 对象中。如果省略 nbytes 或为 0,则使用 len(buffer)。返回接收的字节数。 |
| recvmsg | recvmsg(bufsiz[, ancbufsiz[, flags]]) 在套接字上接收最多 bufsiz 字节的数据和最多 ancbufsiz 字节的辅助(“带外”)数据。返回一个四元组 (data, ancdata, msg_flags, address),其中 data 是接收的数据,ancdata 是表示接收的辅助数据的三元组 (cmsg_level, cmsg_type, cmsg_data) 列表,msg_flags 包含与消息一起接收的任何标志(在 Unix 手册页中记录了 recv(2) 系统调用或 Windows 文档中有详细说明),address 是发送数据的对方套接字的地址(如果套接字已连接,则此值未定义,但可以从套接字中确定发送方)。 |
| send | send(bytes[, flags]) 将给定的数据 bytes 发送到已连接到远程端点的套接字上。返回发送的字节数,应检查:调用可能不会传输所有数据,此时必须单独请求剩余部分的传输。 |
| sendall | sendall(bytes[, flags]) 将所有给定的数据 bytes 发送到已连接到远程端点的套接字上。套接字的超时值适用于所有数据的传输,即使需要多次传输也是如此。 |
| sendfile | sendfile(file, offset=0, count=None) 将文件对象 file 的内容(必须以二进制模式打开)发送到连接的端点。在支持 os.sendfile 的平台上,使用该函数;否则,使用 send 调用。如果指定了 offset,则确定从文件中哪个字节位置开始传输;count 设置要传输的最大字节数。返回传输的总字节数。 |
| sendmsg | sendmsg(buffers[, ancdata[, flags[, address]]]) 向连接的端点发送普通和辅助(带外)数据。 buffers 应该是类字节对象的可迭代对象。 ancdata 参数应该是 (data, ancdata, msg_flags, address) 元组的可迭代对象,表示辅助数据。 msg_flags 是在 Unix 手册页上的 send(2) 系统调用或在 Windows 文档 中记录的标志位。 address 应仅在未连接的套接字中提供,并确定要发送数据的端点。 |
| sendto | sendto(bytes,[flags,]address) 将 bytes(s 不能连接)传输到给定的套接字地址,并返回发送的字节数。 可选的 flags 参数与 recv 中的含义相同。 |
| setblocking | setblocking(flag) 根据 flag 的真值确定 s 是否以阻塞模式运行(见 “套接字对象”)。 s.setblocking(True) 的作用类似于 s.settimeout(None); s.set_blocking(False) 的作用类似于 s.settimeout(0.0)。 |
| set_inheritable | set_inheritable(flag) 根据 flag 的真值确定套接字是否由子进程继承。 |
| settimeout | settimeout(timeout) 根据 timeout 的值(见 “套接字对象”)建立 s 的模式。 |
| shutdown | shutdown(how) 根据 how 参数的值关闭套接字连接的一个或两个部分,如此处详细说明:
socket.SHUT_RD
s 上不能再执行更多的接收操作。
socket.SHUT_RDWR
s 上不能再执行更多的接收或发送操作。
socket.SHUT_WR
s 上不能再执行更多的发送操作。
|
套接字对象 s 还具有属性 family(s 的套接字家族)和 type(s 的套接字类型)。
无连接套接字客户端
考虑一个简单的数据包回显服务,在这个服务中,客户端将使用 UTF-8 编码的文本发送到服务器,服务器将相同的信息返回给客户端。 在无连接服务中,客户端只需将每个数据块发送到定义的服务器端点:
`import` socket
UDP_IP = 'localhost'
UDP_PORT = 8883
MESSAGE = """\ This is a bunch of lines, each
of which will be sent in a single
UDP datagram. No error detection
or correction will occur.
Crazy bananas! £€ should go through."""
server = UDP_IP, UDP_PORT
encoding = 'utf-8'
`with` socket.socket(socket.AF_INET, *`# IPv4`*
socket.SOCK_DGRAM, *`# UDP`*
) `as` sock:
`for` line `in` MESSAGE.splitlines():
data = line.encode(encoding)
bytes_sent = sock.sendto(data, server)
print(f'SENT {data!r} ({bytes_sent} of {len(data)})'
f' to {server}')
response, address = sock.recvfrom(1024) *`# buffer size: 1024`*
print(f'RCVD {response.decode(encoding)!r}'
f' from {address}')
print('Disconnected from server')
请注意,服务器仅执行基于字节的回显功能。 因此,客户端将其 Unicode 数据编码为字节串,并使用相同的编码将从服务器接收的字节串响应解码为 Unicode 文本。
无连接套接字服务器
前一节描述的数据包回显服务的服务器也非常简单。 它绑定到其端点,在该端点接收数据包(数据报),并向客户端返回每个数据报具有完全相同数据的数据包。 服务器平等地对待所有客户端,不需要使用任何类型的并发(尽管这种最后一个方便的特性可能不适用于处理请求时间更长的服务)。
以下服务器工作,但除了通过中断(通常从键盘上的 Ctrl-C 或 Ctrl-Break)无其他终止服务的方法:
`import` socket
UDP_IP = 'localhost'
UDP_PORT = 8883
with socket.socket(socket.AF_INET, *`# IPv4`*
socket.SOCK_DGRAM *`# UDP`*
) as sock:
sock.bind((UDP_IP, UDP_PORT))
print(f'Serving at {UDP_IP}:{UDP_PORT}')
`while` `True`:
data, sender_addr = sock.recvfrom(1024) *`# 1024-byte buffer`*
print(f'RCVD {data!r}) from {sender_addr}')
bytes_sent = sock.sendto(data, sender_addr)
print(f'SENT {data!r} ({bytes_sent}/{len(data)})'
f' to {sender_addr}')
同样没有任何机制来处理丢包和类似的网络问题;这在简单服务中通常是可以接受的。
可以使用 IPv6 运行相同的程序:只需将套接字类型 AF_INET 替换为 AF_INET6。
面向连接的套接字客户端
现在考虑一个简单的面向连接的“回显式”协议:服务器允许客户端连接到其监听套接字,从客户端接收任意字节,并将服务器接收到的相同字节发送回每个客户端,直到客户端关闭连接。以下是一个基本测试客户端的示例:³
`import` socket
IP_ADDR = 'localhost'
IP_PORT = 8881
MESSAGE = """\ A few lines of text
including non-ASCII characters: €£
to test the operation
of both server
and client."""
encoding = 'utf-8'
`with` socket.socket(socket.AF_INET, *`# IPv4`*
socket.SOCK_STREAM *`# TCP`*
) `as` sock:
sock.connect((IP_ADDR, IP_PORT))
print(f'Connected to server {IP_ADDR}:{IP_PORT}')
`for` line `in` MESSAGE.splitlines():
data = line.encode(encoding)
sock.sendall(data)
print(f'SENT {data!r} ({len(data)})')
response, address = sock.recvfrom(1024) *`# buffer size: 1024`*
print(f'RCVD {response.decode(encoding)!r}'
f' ({len(response)}) from {address}')
print('Disconnected from server')
注意数据是文本,因此必须用适当的表示方法进行编码。我们选择了常见的 UTF-8 编码。服务器以字节为单位工作(因为是字节(也称为八位字节)在网络上传输);接收到的字节对象在打印之前会用 UTF-8 解码为 Unicode 文本。也可以选择其他合适的编解码器:关键是在传输之前对文本进行编码,在接收后进行解码。服务器在字节方面工作,甚至不需要知道使用的是哪种编码,除了可能用于日志记录之类的目的。
面向连接的套接字服务器
这是一个简单的服务器,对应于前一节中显示的测试客户端,使用并发.future 进行多线程处理(详见“concurrent.futures 模块”):
`import` concurrent
`import` socket
IP_ADDR = 'localhost'
IP_PORT = 8881
`def` handle(new_sock, address):
print('Connected from', address)
`with` new_sock:
`while` True:
received = new_sock.recv(1024)
`if` `not` received:
`break`
s = received.decode('utf-8', errors='replace')
print(f'Recv: {s!r}')
new_sock.sendall(received)
print(f'Echo: {s!r}')
print(f'Disconnected from {address}')
`with` socket.socket(socket.AF_INET, # IPv4
socket.SOCK_STREAM # TCP
) `as` servsock:
servsock.bind((IP_ADDR, IP_PORT))
servsock.listen(5)
print(f'Serving at {servsock.getsockname()}')
`with` cconcurrent.futures.ThreadPoolExecutor(20) `as` e:
`while` True:
new_sock, address = servsock.accept()
e.submit(handle, new_sock, address)
此服务器有其局限性。特别是,它仅运行 20 个线程,因此无法同时为超过 20 个客户端提供服务;当 20 个其他客户端正在接受服务时,试图连接的进一步客户端将等待在 servsock 的监听队列中。如果队列填满了等待接受的五个客户端,试图连接的进一步客户端将被直接拒绝。此服务器仅作为演示示例而非坚固、可扩展或安全系统。
与之前一样,可以通过将套接字类型 AF_INET 替换为 AF_INET6 来使用 IPv6 运行相同的程序。
传输层安全性
传输层安全性(TLS)是安全套接字层(SSL)的后继者,提供 TCP/IP 上的隐私和数据完整性,帮助防止服务器冒充、窃听交换的字节以及恶意修改这些字节。对于 TLS 的介绍,我们推荐阅读广泛的Wikipedia 条目。
在 Python 中,您可以通过标准库的 ssl 模块使用 TLS。要很好地使用 ssl,您需要深入理解其丰富的在线文档,以及 TLS 本身的深入广泛理解(尽管作为一个庞大而复杂的主题,维基百科的文章只是开始涵盖这个话题)。特别是,您必须研究并彻底理解在线文档安全考虑部分,以及该部分提供的众多有用链接中的所有材料。
如果这些警告使您觉得完美实施安全预防措施是一项艰巨的任务,那是因为它确实如此。在安全领域,您需要将智慧和技能与那些可能更熟悉所涉问题细节的高级攻击者的智慧和技能相比较:他们专注于发现漏洞和入侵方法,而您的焦点通常不仅限于此类问题——相反,您试图在代码中提供一些有用的服务。将安全视为事后或次要问题是有风险的——它必须始终处于核心位置,以赢得技术和智慧之战。
尽管如此,我们强烈建议所有读者进行上述 TLS 学习——开发者对安全考虑的理解越深入,我们就越安全(除了那些渴望破解安全系统的人)。
除非您已经深入广泛地了解了 TLS 和 Python 的 ssl 模块(在这种情况下,您会知道应该做什么——比我们可能的建议更好!),我们建议使用 SSLContext 实例来保存 TLS 使用的所有细节。使用 ssl.create_default_context 函数构建该实例,如果需要,添加您的证书(如果您正在编写安全服务器,则需要这样做),然后使用实例的 wrap_socket 方法将您创建的每个 socket.socket 实例包装成 ssl.SSLSocket 实例——它的行为几乎与包装的 socket 对象相同,但几乎透明地添加了安全检查和验证“在一侧”。
默认的 TLS 上下文在安全性和广泛适用性之间取得了良好的折衷,我们建议您坚持使用它们(除非您足够了解以便为特殊需求调整和加强安全性)。如果您需要支持无法使用最新、最安全的 TLS 实现的过时对应方案,您可能会有种放松安全要求的冲动。但请自行承担风险——我们绝对不建议冒险进入这样的领域!
在接下来的章节中,我们将介绍您在仅想遵循我们建议时需要熟悉的 ssl 的最小子集。但即使是这种情况,也请务必阅读有关 TLS 和 ssl 的内容,以便对所涉及的复杂问题有所了解。这可能在某一天对您大有裨益!
SSLContext
ssl 模块提供了 ssl.SSLContext 类,其实例保存关于 TLS 配置的信息(包括证书和私钥),并提供许多方法来设置、更改、检查和使用这些信息。如果你确切知道自己在做什么,你可以手动实例化、设置和使用自己专门目的的 SSLContext 实例。
但是,我们建议你使用经过良好调整的函数 ssl.create_default_context 实例化 SSLContext,只需一个参数:ssl.Purpose.CLIENT_AUTH 如果你的代码是服务器(因此可能需要对客户端进行认证),或者 ssl.Purpose.SERVER_AUTH 如果你的代码是客户端(因此绝对需要对服务器进行认证)。如果你的代码既是某些服务器的客户端又是其他客户端的服务器(例如一些互联网代理),那么你将需要两个 SSLContext 实例,每个目的一个。
对于大多数客户端使用场景,你的 SSLContext 已经准备好了。如果你在编写服务器端或者一个少数需要对客户端进行 TLS 认证的服务器的客户端,你需要有一个证书文件和一个密钥文件(参见在线文档了解如何获取这些文件)。通过将证书和密钥文件的路径传递给 load_cert_chain 方法,将它们添加到 SSLContext 实例中(以便对方可以验证你的身份),例如:
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain(certfile='mycert.pem', keyfile='mykey.key')
一旦你的上下文实例 ctx 准备好了,如果你在编写客户端,只需调用 ctx.wrap_socket 来包装你即将连接到服务器的任何套接字,并使用包装后的结果(一个 ssl.SSLSocket 实例)而不是刚刚包装的套接字。例如:
sock = socket.socket(socket.AF_INET)
sock = ctx.wrap_socket(sock, server_hostname='www.example.com')
sock.connect(('www.example.com', 443))
*`# use 'sock' normally from here on`*
注意,在客户端的情况下,你还应该传递一个 server_hostname 参数给 wrap_socket,该参数对应你即将连接的服务器;这样,连接可以验证你最终连接到的服务器的身份是否确实正确,这是绝对关键的安全步骤。
在服务器端,不要 包装绑定到地址、监听或接受连接的套接字;只需包装 accept 返回的新套接字。例如:
sock = socket.socket(socket.AF_INET)
sock.bind(('www.example.com', 443))
sock.listen(5)
`while` `True``:`
newsock, fromaddr = sock.accept()
newsock = ctx.wrap_socket(newsock, server_side=`True`)
*`# deal with 'newsock' as usual; shut down, then close it, when done`*
在这种情况下,你需要向 wrap_socket 传递参数 server_side=True,以便它知道你是服务器端的操作。
再次强烈建议查阅在线文档,尤其是示例,以便更好地理解,即使你仅仅使用 SSL 操作的这个简单子集。
¹ 当你编写应用程序时,通常会通过更高级别的抽象层(如第十九章中涵盖的那些)来使用套接字。
² 还有相对较新的多路复用连接传输协议 QUIC,在 Python 中由第三方 aioquic 支持。
³ 这个客户端示例并不安全;参见“传输层安全性”了解如何使其安全。
⁴ 我们说“几乎”是因为,当你编写服务器时,你不会包装你绑定、监听和接受连接的套接字。
第十九章:客户端网络协议模块
Python 的标准库提供了几个模块,简化了客户端和服务器端使用互联网协议的过程。如今,被称为 PyPI 的 Python 包索引 提供了更多类似的包。因为许多标准库模块可以追溯到上个世纪,您会发现现在第三方包支持更多的协议,并且一些比标准库提供的 API 更好。当您需要使用标准库中缺少的或者您认为标准库中的方式不尽如人意的网络协议时,请务必搜索 PyPI——您很可能会在那里找到更好的解决方案。
本章介绍了一些标准库包,允许相对简单地使用网络协议:这些包使您可以在不需要第三方包的情况下编码,使您的应用程序或库更容易安装在其他计算机上。因此,在处理遗留代码时可能会遇到它们,它们的简单性也使它们成为 Python 学习者感兴趣的阅读材料。
对于 HTTP 客户端和其他最好通过 URL 访问的网络资源(如匿名 FTP 站点)的频繁使用情况,第三方 requests 包 甚至被 Python 文档推荐,因此我们涵盖了它并推荐使用,而不是使用标准库模块。
电子邮件协议
今天,大多数电子邮件是通过实现简单邮件传输协议(SMTP)的服务器发送,并通过使用邮局协议版本 3(POP3)和/或互联网消息访问协议版本 4(IMAP4)的服务器和客户端接收。¹ 这些协议的客户端由 Python 标准库模块 smtplib、poplib 和 imaplib 支持,我们在本书中介绍了前两者。当您需要处理电子邮件消息的解析或生成时,请使用电子邮件包,本书在 第二十一章 中有所介绍。
如果你需要编写一个既可以通过 POP3 也可以通过 IMAP4 连接的客户端,一个标准的建议是选择 IMAP4,因为它更强大,并且——根据 Python 自己的在线文档——通常在服务器端实现得更精确。不幸的是,imaplib 非常复杂,远超过本书的涵盖范围。如果你选择这条路,需要使用在线文档,并且不可避免地要补充 IMAP RFCs,可能还包括其他相关的 RFCs,如 5161 和 6855(用于能力)以及 2342(用于命名空间)。除了标准库模块的在线文档外,还必须使用 RFCs:imaplib 函数和方法传递的许多参数以及调用它们的结果,都是字符串格式,只有 RFCs 中有详细说明,而 Python 的文档中没有。一个强烈推荐的替代方案是使用更简单、更高抽象级别的第三方包IMAPClient,可以通过pip install安装,并且有很好的在线文档。
poplib 模块
poplib 模块提供了一个访问 POP 邮箱的 POP3 类。² 构造函数具有以下签名:
| POP3 | class POP3(host, port=110) 返回一个连接到指定host和端口的 POP3 类实例p。类 POP3_SSL 的行为完全相同,但通过安全的 TLS 通道连接到主机(默认端口 995);这对需要连接到要求一定最小安全性的电子邮件服务器(如 pop.gmail.com)是必需的。^(a) |
|---|---|
| ^(a) 要连接到 Gmail 账户,特别是,你需要配置该账户以启用 POP,“允许不安全的应用程序”,并避免两步验证——这些一般情况下我们不推荐,因为它们会削弱你的电子邮件安全性。 |
类 POP3 的实例p提供了许多方法;最常用的列在表 19-1 中。在每种情况下,msgnum,一个消息的标识号,可以是包含整数值的字符串或整数。
表 19-1. POP3 类实例p的方法
| dele | p.dele(msgnum) 标记消息msgnum以便删除,并返回服务器响应字符串。服务器会排队这样的删除请求,只有在你通过调用p.quit 终止连接时才执行。^(a) |
|---|---|
| list | p.list(msgnum=None) 返回一个三元组(response, messages, octets),其中response是服务器响应字符串;messages是一个由字节串组成的列表,每个字节串由两个词组成 b'msgnum bytes',每个消息的消息编号和字节长度;octets是总响应的字节长度。当msgnum不为None时,list 返回一个字符串,给定msgnum的响应,而不是一个元组。 |
| pass_ | p.pass_(password) 向服务器发送密码,并返回服务器响应字符串。必须在 p.user 之后调用。名称中的下划线是因为 pass 是 Python 的关键字。 |
| quit | p.quit() 结束会话并告知服务器执行调用 p.dele 请求的删除操作。返回服务器响应字符串。 |
| retr | p.retr(msgnum) 返回一个三元组(response, lines, bytes),其中 response 是服务器响应字符串,lines 是消息 msgnum 的所有行的列表(以字节串形式),bytes 是消息的总字节数。 |
| set_debuglevel | p.set_debuglevel(debug_level) 将调试级别设置为 debug_level,一个整数,值为 0(默认)表示无调试输出,1 表示适量的调试输出,2 或更高表示所有与服务器交换的控制信息的完整输出跟踪。 |
| stat | p.stat() 返回一个二元组(num_msgs, bytes),其中 num_msgs 是邮箱中的消息数,bytes 是总字节数。 |
| top | p.top(msgnum, maxlines) 类似 retr,但最多返回消息体的 maxlines 行(除了所有的头部行)。对于查看长消息开头很有用。 |
| user | p.user(username) 向服务器发送用户名;随后必然调用 p.pass_。 |
| ^(a) 标准规定,如果在 quit 调用之前发生断开连接,不应执行删除操作。尽管如此,某些服务器在任何断开连接后(计划或非计划)都会执行删除操作。 |
smtplib 模块
smtplib 模块提供一个 SMTP 类来通过 SMTP 服务器发送邮件。³ 构造函数的签名如下:
| SMTP | class SMTP([host, port=25]) 返回 SMTP 类的实例 s。当给定 host(和可选的 port)时,隐式调用 s.connect(host, port)。SMTP_SSL 类的行为完全相同,但通过安全的 TLS 通道连接到主机(默认端口 465),用于连接要求一定最小安全性的电子邮件服务器,如 smtp.gmail.com。 |
|---|
SMTP 类的实例 s 提供许多方法。其中最常用的列在 表 19-2 中。
表 19-2. SMTP 实例 s 的方法
| connect | s.connect(host=127.0.0.1, port=25) 连接到给定主机(默认为本地主机)和端口的 SMTP 服务器(SMTP 服务的默认端口为 25;更安全的“SMTP over TLS”默认端口为 465)。 |
|---|---|
| login | s.login(user, password) 使用给定的 user 和 password 登录服务器。只有在 SMTP 服务器需要身份验证时才需要(几乎所有服务器都需要)。 |
| quit | s.quit() 终止 SMTP 会话。 |
| sendmail | s.sendmail(from_addr, to_addrs, msg_string) 从字符串from_addr中发送邮件消息msg_string,并分别发送给列表to_addrs中的每个收件人。^(a) msg_string必须是一个完整的 RFC 822 消息,是一个多行的字节字符串:包括头部、用于分隔的空行,然后是正文。邮件传输机制仅使用from_addr和to_addrs来确定路由,忽略msg_string中的任何头部。^(b) 要准备符合 RFC 822 的消息,请使用 email 包,该包在“MIME 和电子邮件格式处理”中介绍。 |
| send_message | s.send_message(msg, from_addr=None, to_addrs=None) 这是一个便捷函数,第一个参数为 email.message.Message 对象。如果from_addr和to_addrs中任一或两者为None,则会从消息中提取它们。 |
| ^(a) 标准并未限制from_addr中收件人的数量,但是各个邮件服务器可能会限制,因此建议每个批处理消息中的收件人数量不要太多。^(b) 这样做可以支持邮件系统实现密送(Bcc)邮件,因为路由不依赖于消息信封。 |
HTTP 和 URL 客户端
绝大多数情况下,您的代码会通过更高级别的 URL 层使用 HTTP 和 FTP 协议,这些协议由下一节介绍的模块和包支持。Python 标准库还提供了较低级别的、特定于协议的模块,不过这些模块使用频率较低:对于 FTP 客户端,使用ftplib;对于 HTTP 客户端,使用 http.client(我们在第二十章介绍 HTTP 服务器)。如果需要编写 FTP 服务器,请考虑第三方模块pyftpdlib。在撰写本书时,较新的HTTP/2实现可能尚不完全成熟,但目前最佳选择是第三方模块HTTPX。我们在本书中不涉及这些较低级别的模块,而是专注于整个下一节的更高级别、URL 级别的访问。
URL 访问
URI 是一种统一资源标识符(URI)的一种类型。URI 是一个字符串,用于标识资源(但不一定定位资源),而 URL 用于在互联网上定位资源。URL 由几个部分(某些可选)组成,称为组件:scheme, location, path, query和fragment。(第二个组件有时也被称为网络位置或简称netloc。)具有所有部分的 URL 看起来像这样:
*scheme://lo.ca.ti.on/pa/th?qu=ery#fragment*
例如,在www.python.org/community/a… 方案的众所周知端口为 80。)某些标点符号是其分隔的组件的一部分;其他标点字符只是分隔符,并非任何组件的一部分。省略标点符号意味着缺少组件。例如,在mailto:me@you.com中,方案为mailto*,路径为me@you.com(mailto:me@you.com),无位置、查询或片段。没有//表示 URI 无位置,没有?表示 URI 无查询,没有#表示 URI 无片段。
如果位置以冒号结尾,后跟一个数字,则表示终点的 TCP 端口。否则,连接使用与方案关联的众所周知端口(例如,HTTP 的端口 80)。
urllib 包
urllib 包提供了几个模块来解析和利用 URL 字符串及其相关资源。除了这里描述的 urllib.parse 和 urllib.request 模块外,还包括 urllib.robotparser 模块(专门用于根据RFC 9309解析站点的robots.txt文件)和 urllib.error 模块,包含其他 urllib 模块引发的所有异常类型。
urllib.parse 模块
urllib.parse 模块提供了用于分析和合成 URL 字符串的函数,并通常使用from urllib import parse as urlparse 导入。其最常用的函数列在表 19-3 中。
表 19-3. urllib.parse 模块的常用函数
| urljoin | urljoin(base_url_string, relative_url_string) 返回一个 URL 字符串u,该字符串通过将relative_url_string(可能是相对的)与base_url_string连接而得到。urljoin 执行的连接过程可总结如下:
-
当任一参数字符串为空时,u即为另一个参数。
-
当relative_url_string明确指定与base_url_string不同的方案时,u为relative_url_string。否则,u的方案为base_url_string的方案。
-
当方案不允许相对 URL(例如,mailto)或relative_url_string明确指定位置(即使与base_url_string的位置相同)时,u的所有其他组件均为relative_url_string的组件。否则,u的位置为base_url_string的位置。
-
u的路径通过根据绝对和相对 URL 路径的标准语法连接base_url_string和relative_url_string的路径而获得。^(a) 例如:
urlparse.urljoin(
'http://host.com/some/path/here','../other/path')
*`# Result is: 'http://host.com/some/other/path'`*
|
| urlsplit | urlsplit(url_string, default_scheme='', allow_fragments=True) 分析 url_string 并返回一个五个字符串项的元组(实际上是 SplitResult 实例,可以将其视为元组或与命名属性一起使用):scheme、netloc、path、query 和 fragment*。当 allow_fragments 为 False 时,无论 url_string 是否具有片段,元组的最后一项始终为''。对应缺少部分的项也为''。例如:
urlparse.urlsplit(
'http://www.python.org:80/faq.cgi?src=file')
*`# Result is:`*
*`# 'http','www.python.org:80','/faq.cgi','src=file',''`*
|
| urlunsplit | urlunsplit(url_tuple) url_tuple 是任何具有确切五个项的可迭代对象,全部为字符串。从 urlsplit 调用的任何返回值都是 urlunsplit 的可接受参数。urlunsplit 返回具有给定组件和所需分隔符但无冗余分隔符的 URL 字符串(例如,当 fragment,url_tuple 的最后一项为''时,结果中没有 #)。例如:
urlparse.urlunsplit((
'http','www.python.org','/faq.cgi','src=fie',''))
*`# Result is: 'http://www.python.org/faq.cgi?src=fie'`*
urlunsplit(urlsplit(x)) 返回 URL 字符串 x 的规范形式,这不一定等于 x,因为 x 不一定是规范化的。例如:
urlparse.urlsplit('http://a.com/path/a?'))
*`# Result is: 'http://a.com/path/a'`*
在这种情况下,规范化确保结果中不存在冗余分隔符,例如在 urlsplit 参数中的尾部 ?。
| ^(a) 根据 RFC 1808。 |
|---|
urllib.request 模块
urllib.request 模块提供了访问标准互联网协议上的数据资源的函数,其中最常用的列在 Table 19-4 中(表中的示例假定您已导入了该模块)。
表 19-4. urllib.request 模块的有用函数
| urlopen | urlopen(url, data=None*,* timeout*,* context=None) 返回一个响应对象,其类型取决于 url 中的方案:
-
HTTP 和 HTTPS URL 返回一个 http.client.HTTPResponse 对象(具有修改的 msg 属性以包含与 reason 属性相同的数据;详情请参阅在线文档)。您的代码可以像处理可迭代对象一样使用此对象,并作为上下文管理器在 with 语句中使用。
-
FTP、文件和数据 URL 返回一个 urllib.response.addinfourl 对象。
url 是要打开的 URL 的字符串或 urllib.request.Request 对象。data 是一个可选的 bytes 对象、类文件对象或 bytes 的可迭代对象,用于编码发送到 URL 的额外数据,格式为 application/x-www-form-urlencoded。timeout 是一个可选参数,用于指定 URL 打开过程中阻塞操作的超时时间,单位为秒,仅适用于 HTTP、HTTPS 和 FTP URL。当给出 context 时,必须包含一个 ssl.SSLContext 对象,指定 SSL 选项;context 取代了已弃用的 cafile、capath 和 cadefault 参数。以下示例从 HTTPS URL 下载文件并提取为本地的 bytes 对象,unicode_db:
unicode_url = ("`https://www.unicode.org/Public`"
"/14.0.0/ucd/UnicodeData.txt")
`with` urllib.request.urlopen(unicode_url
)`as` url_response:
unicode_db = url_response.read()
|
| urlretrieve | urlretrieve(url_string, filename=None, report_hook=None, data=None) 一个兼容性函数,用于支持从 Python 2 遗留代码的迁移。url_string 给出要下载资源的 URL。filename 是一个可选的字符串,用于命名从 URL 检索的数据存储在本地文件中。report_hook 是一个可调用对象,支持在下载过程中报告进度,每次检索数据块时调用一次。data 类似于 urlopen 的 data 参数。在其最简单的形式下,urlretrieve 等效于:
`def` urlretrieve(url, filename=`None`):
`if` filename `is` `None`:
filename = *`.``.``.``parse filename from url...`*
`with` urllib.request.urlopen(url
)`as` url_response:
`with` open(filename, "wb") `as` save_file:
save_file.write(url_response.read())
`return` filename, url_response.info()
由于这个函数是为了 Python 2 的兼容性而开发的,您可能仍然会在现有的代码库中看到它。新代码应该使用 urlopen。
要全面了解 urllib.request,请参阅在线文档和 Michael Foord 的 HOWTO,其中包括根据 URL 下载文件的示例。在 “使用 BeautifulSoup 进行 HTML 解析的示例” 中有一个使用 urllib.request 的简短示例。
第三方 requests 包
第三方 requests package(非常好地在线文档记录)是我们推荐您访问 HTTP URL 的方式。像其他第三方包一样,最好通过简单的 pip install requests 进行安装。在本节中,我们总结了如何在相对简单的情况下最佳地使用它。
请求模块本地仅支持 HTTP 和 HTTPS 传输协议;要访问其他协议的 URL,您需要安装其他第三方包(称为协议适配器),例如 requests-ftp 用于 FTP URL,或作为丰富的 requests-toolbelt 包的一部分提供的其他实用工具包。
requests 包的功能主要依赖于它提供的三个类:Request,模拟发送到服务器的 HTTP 请求;Response,模拟服务器对请求的 HTTP 响应;以及 Session,提供在一系列请求中的连续性,也称为会话。对于单个请求/响应交互的常见用例,您不需要连续性,因此通常可以忽略 Session。
发送请求
通常情况下,您不需要显式考虑 Request 类:而是调用实用函数 request,它内部准备并发送请求,并返回 Response 实例。request 有两个必需的位置参数,都是字符串:method,要使用的 HTTP 方法,和 url,要访问的 URL。然后,可能会跟随许多可选的命名参数(在下一节中,我们涵盖了 request 函数最常用的命名参数)。
为了进一步方便,requests 模块还提供了一些函数,它们的名称与 HTTP 方法 delete、get、head、options、patch、post 和 put 相同;每个函数都接受一个必需的位置参数 url,然后是与函数 request 相同的可选命名参数。
当您希望在多个请求中保持一致性时,调用 Session 创建一个实例 s,然后使用 s 的方法 request、get、post 等,它们就像直接由 requests 模块提供的同名函数一样(然而,s 的方法将 s 的设置与准备发送到给定 url 的每个请求的可选命名参数合并)。
request 的可选命名参数
函数 request(就像函数 get、post 等以及类 Session 的实例 s 上的同名方法一样)接受许多可选的命名参数。如果您需要高级功能,比如控制代理、身份验证、特殊的重定向处理、流式传输、cookies 等,请参阅 requests 包的优秀在线文档获取完整的参数集合。表 19-5 列出了最常用的命名参数。
表 19-5. request 函数接受的命名参数列表
| data | 一个字典、一组键值对、一个字节串或者一个类似文件的对象,用作请求的主体 |
|---|---|
| files | 一个以名称为键、文件对象或文件元组为值的字典,与 POST 方法一起使用,用于指定多部分编码文件上传(我们将在下一节中讨论文件值的格式) |
| headers | 发送到请求中的 HTTP 头的字典 |
| json | Python 数据(通常是一个字典)编码为 JSON 作为请求主体 |
| params | 一个(name, value)项的字典,或者作为查询字符串发送的字节串与请求一起 |
| timeout | 秒数的浮点数,等待响应的最长时间,在引发异常之前 |
data、json 和 files 是用于指定请求主体的相互不兼容的方式;通常您应该最多使用其中一个,只用于使用主体的 HTTP 方法(即 PATCH、POST 和 PUT)。唯一的例外是您可以同时使用传递一个字典的 data 参数和一个 files 参数。这是非常常见的用法:在这种情况下,字典中的键值对和文件形成一个请求主体,作为一个multipart/form-data整体。⁴
files 参数(以及其他指定请求主体的方法)
当您使用 json 或 data(传递一个字节串或者一个必须打开以供读取的类文件对象,通常在二进制模式下)指定请求主体时,生成的字节直接用作请求的主体。当您使用 data(传递一个字典或者一组键值对)指定时,主体以表单的形式构建,从键值对按application/x-www-form-urlencoded格式进行格式化,根据相关的网络标准。
当你用 files 指定请求的主体时,该主体也作为一个表单构建,在这种情况下格式设置为multipart/form-data(在 PATCH、POST 或 PUT HTTP 请求中上传文件的唯一方式)。你上传的每个文件都被格式化为表单的一个单独部分;如果你还希望表单向服务器提供进一步的非文件参数,则除了 files 外,还需要传递一个数据参数,其值为字典(或键/值对序列)用于这些进一步的参数。这些参数被编码为多部分表单的补充部分。
为了灵活性,files 参数的值可以是一个字典(其条目被视为(name, value)对的序列),或者是(name, value)对的序列(结果请求体中的顺序保持不变)。
无论哪种方式,(name, value)对中的每个值可以是一个 str(或者更好地,⁵ 是一个字节或字节数组),直接用作上传文件的内容,或者是一个打开用于读取的类文件对象(此时,requests 调用 .read() 方法并使用结果作为上传文件的内容;我们强烈建议在这种情况下以二进制模式打开文件,以避免任何关于内容长度的歧义)。当满足这些条件之一时,requests 使用对的 name 部分(例如,字典中的键)作为文件的名称(除非它能够改进这一点,因为打开的文件对象能够显示其基础文件名),尝试猜测内容类型,并为文件的表单部分使用最小的标头。
或者,每个(name, value)对中的值可以是一个包含两到四个项目的元组,(fn, fp[, ft[, fh]])(使用方括号作为元语法来表示可选部分)。在这种情况下,fn 是文件的名称,fp 提供内容(与前一段中的方式相同),可选的 ft 提供内容类型(如果缺失,requests 将猜测它,如前一段中所示),可选的字典 fh 提供文件表单部分的额外标头。
如何解释 requests 的示例
在实际应用中,通常不需要考虑类 requests.Request 的内部实例 r,该类函数类似于 requests.post 在你的代表中构建、准备,然后发送。然而,为了确切了解 requests 的操作,以较低的抽象级别(构建、准备和检查 r —— 无需发送它!)是有益的。例如,在导入 requests 后,传递数据如下示例中所示:
r = requests.Request('GET', 'http://www.example.com',
data={'foo': 'bar'}, params={'fie': 'foo'})
p = r.prepare()
print(p.url)
print(p.headers)
print(p.body)
输出(为了可读性,将 p.headers 字典的输出拆分):
http://www.example.com/?fie=foo
{'Content-Length': '7',
'Content-Type': 'application/x-www-form-urlencoded'}
foo=bar
类似地,在传递文件时:
r = requests.Request('POST', 'http://www.example.com',
data={'foo': 'bar'}, files={'fie': 'foo'})
p = r.prepare()
print(p.headers)
print(p.body)
这将输出(几行被拆分以提高可读性):
{'Content-Length': '228',
'Content-Type': 'multipart/form-data; boundary=dfd600d8aa58496270'}
b'--dfd600d8aa58496270\r\nContent-Disposition: form-data;
="foo"\r\n\r\nbar\r\n--dfd600d8aa584962709b936134b1cfce\r\n
Content-Disposition: form-data; name="fie" filename="fie"\r\n\r\nfoo\r\n
--dfd600d8aa584962709b936134b1cfce--\r\n'
愉快的交互式探索!
响应类
requests 模块中你总是需要考虑的一个类是 Response:每个请求,一旦发送到服务器(通常是通过 get 等方法隐式完成),都会返回一个 requests.Response 实例 r。
你通常想要做的第一件事是检查r.status_code,一个告诉你请求进行情况的整数,在典型的“HTTPese”中:200 表示“一切正常”,404 表示“未找到”,等等。如果你希望对指示某种错误的状态码只获取异常,请调用r.raise_for_status;如果请求成功,它将不执行任何操作,但否则将引发 requests.exceptions.HTTPError 异常(其他异常,不对应任何特定的 HTTP 状态码,可能会被引发,而不需要任何明确的调用:例如任何网络问题的 ConnectionError,或者超时的 TimeoutError)。
接下来,你可能想要检查响应的 HTTP 头:为此,请使用r.headers,一个字典(具有特殊功能,其大小写不敏感的字符串键指示头名称,例如在Wikipedia中列出的 HTTP 规范)。大多数头部可以安全地忽略,但有时你可能更愿意检查。例如,你可以通过r.headers.get('content-language')验证响应是否指定了其主体所写的自然语言,以提供不同的呈现选择,例如使用某种语言翻译服务使响应对用户更有用。
通常情况下,你不需要对重定向进行特定的状态或头检查:默认情况下,requests 会自动对除 HEAD 以外的所有方法进行重定向(你可以在请求中显式传递 allow_redirection 命名参数来更改该行为)。如果允许重定向,你可能需要检查r.history,一个沿途累积的 Response 实例列表,从最旧到最新,但不包括r本身(如果没有重定向,r.history 为空)。
大多数情况下,可能在检查状态和头之后,你想使用响应的主体。在简单情况下,只需将响应的主体作为字节字符串访问,r.content,或者通过调用r.json 将其解码为 JSON(一旦你检查到它是如何编码的,例如通过r.headers.get('content-type'))。
通常,你更倾向于将响应的主体作为(Unicode)文本访问,使用属性r.text。后者使用编解码器解码(从实际构成响应主体的八位字节),编解码器由请求根据 Content-Type 头和对主体本身的粗略检查认为最佳来确定。你可以通过属性r.encoding 检查使用的(或即将使用的)编解码器;其值将是与 codecs 模块注册的编解码器名称,详见“codecs 模块”。你甚至可以通过将r.encoding 分配为你选择的编解码器的名称来覆盖编解码器的选择。
我们不在本书中涵盖其他高级问题,例如流式传输;请参阅 requests 包的在线文档获取更多信息。
其他网络协议
许多,许多 其他网络协议在使用中—一些最好由 Python 标准库支持,但大多数你会在 PyPI 上找到更好和更新的第三方模块。
要像登录到另一台机器(或自己节点上的单独登录会话)一样连接,可以使用 Secure Shell (SSH) 协议,由第三方模块 paramiko 或围绕它的更高抽象层包装器,第三方模块 spur 支持。(你也可以,虽然可能存在某些安全风险,仍然使用经典的 Telnet,由标准库模块 telnetlib 支持。)
其他网络协议包括,但不限于:
-
NNTP,用于访问 Usenet 新闻服务器,由标准库模块 nntplib 支持。
-
XML-RPC,用于基本的远程过程调用功能,由 xmlrpc.client 支持。
没有单一的书籍(甚至包括本书!)能够涵盖所有这些协议及其支持模块。相反,我们在这个问题上的最佳建议是战略性的:每当你决定通过某种网络协议使你的应用程序与其他系统交互时,不要急于实现自己的模块来支持该协议。而是搜索并询问,你很可能会找到优秀的现有 Python 模块(第三方或标准库),支持该协议。⁶
如果你发现这些模块中存在错误或缺失功能,请提交错误或功能请求(并且最好提供一个修复问题并满足你应用程序需求的补丁或拉取请求)。换句话说,成为开源社区的积极成员,而不仅仅是被动用户:你将会受到欢迎,解决你自己的问题,并在此过程中帮助许多其他人。“给予回馈”,因为你无法“回馈”给所有为你提供大部分工具的了不起的人们!
¹ IMAP4,参见 RFC 1730;或 IMAP4rev1,参见 RFC 2060。
² POP 协议的规范可在 RFC 1939 中找到。
³ SMTP 协议的规范可在 RFC 2821 中找到。
⁴ 根据 RFC 2388。
⁵ 它能让你完全、明确地控制上传的八位字节。
⁶ 更重要的是,如果你认为需要发明一个全新的协议并在套接字之上实现它,再三考虑,并仔细搜索:很可能已经有大量现有的互联网协议完全符合你的需求!
第二十章:提供 HTTP
当浏览器(或任何其他 Web 客户端)从服务器请求页面时,服务器可以返回静态或动态内容。提供动态内容涉及服务器端 Web 程序根据存储在数据库中的信息动态生成和传递内容。
在 Web 的早期历史上,服务器端编程的标准是 通用网关接口(CGI),它要求服务器在客户端请求动态内容时每次运行一个单独的程序。进程启动时间、解释器初始化、连接数据库和脚本初始化累积起来会带来可衡量的开销;CGI 的扩展性不佳。
如今,Web 服务器支持许多特定于服务器的方式来减少开销,从可以为多次访问提供动态内容的进程中服务,而不是每次访问都启动新进程。因此,本书不涵盖 CGI。要维护现有的 CGI 程序,或更好地将它们移植到更现代的方法,请参阅在线文档(特别是 PEP 594 的建议)并查看标准库模块 cgi(自 3.11 版起已弃用)和 http.cookies。¹
随着基于 微服务 的系统的出现,HTTP 对于分布式系统设计变得更加基础,提供了一种方便的方式来传输经常使用的 JSON 内容之间的数据。互联网上有成千上万的公共可用 HTTP 数据 API。虽然 HTTP 的原则自其于 1990 年代中期问世以来几乎未发生变化,但多年来已显著增强其功能以扩展其能力。² 对于深入学习并具有优秀参考资料,我们推荐阅读 HTTP: The Definitive Guide(David Gourley 等著,O’Reilly)。
http.server
Python 的标准库包括一个模块,其中包含服务器和处理程序类,用于实现简单的 HTTP 服务器。
你可以通过命令行直接运行此服务器:
$ python -m http.server *port_number*
默认情况下,服务器监听所有接口,并提供对当前目录中文件的访问。一位作者将其用作文件传输的简单方式:在源系统的文件目录中启动 Python http.server,然后使用 wget 或 curl 等工具将文件复制到目标系统。
http.server 的安全功能非常有限。你可以在在线文档中找到有关 http.server 的更多信息。对于生产使用,我们建议您使用以下章节中提到的框架之一。
WSGI
Python 的Web 服务器网关接口(WSGI)是所有现代 Python Web 开发框架与底层 Web 服务器或网关交互的标准方式。WSGI 不适用于你的应用程序直接使用;相反,你使用众多高级抽象框架之一编写你的程序,然后框架使用 WSGI 与 Web 服务器交互。
只有在为尚未提供 WSGI 接口的 Web 服务器实现 WSGI 接口(如果有这样的服务器存在的话),或者如果你正在构建一个新的 Python Web 框架时,你才需要关注 WSGI 的细节。³在这种情况下,你需要研究 WSGI PEP,标准库包wsgiref的文档,以及WSGI.org的存档。
如果你使用轻量级框架(即与 WSGI 紧密匹配的框架),一些 WSGI 概念可能对你很重要。WSGI 是一个接口,这个接口有两个方面:Web 服务器/网关方面和应用程序/框架方面。
框架的工作是提供一个WSGI 应用对象,一个可调用对象(通常是一个具有 call 特殊方法的类的实例,但这是一个实现细节),符合 PEP 中的约定,并通过特定服务器文档中指定的任何手段连接应用程序对象(通常是几行代码,或配置文件,或只是一个约定,例如将 WSGI 应用对象命名为模块中的顶级属性 application)。服务器为每个传入的 HTTP 请求调用应用程序对象,应用程序对象适当地响应,以便服务器可以形成传出的 HTTP 响应并将其发送出去——都按照上述约定进行。一个框架,即使是一个轻量级的框架,也会屏蔽这些细节(除非你可能需要根据具体的服务器来实例化和连接应用程序对象)。
WSGI 服务器
你可以在线上找到一个广泛的服务器和适配器列表,用于运行 WSGI 框架和应用程序(用于开发和测试,在生产 Web 设置中,或两者兼而有之)——这是一个广泛的列表,但仅仅是部分列表。例如,它没有提到 Google App Engine 的 Python 运行时也是一个 WSGI 服务器,准备根据app.yaml配置文件指示调度 WSGI 应用。
如果你正在寻找一个用于开发或部署在生产环境中的 WSGI 服务器,例如在基于 Nginx 的负载均衡器后面,那么你应该会对 Gunicorn 感到满意:纯 Python 的良好支持,仅支持 WSGI,非常轻量级。一个值得考虑的(也是纯 Python 和仅支持 WSGI 的)替代方案,目前在 Windows 上支持更好的是 Waitress。如果你需要更丰富的功能(例如对 Perl 和 Ruby 的支持以及许多其他形式的可扩展性),请考虑更大、更复杂的 uWSGI⁴。
WSGI 还有 middleware 的概念,这是一个实现了 WSGI 服务器和应用程序两端的子系统。中间件对象“包装”了一个 WSGI 应用程序;可以选择性地更改请求、环境和响应;并向服务器呈现自身为“应用程序”。允许并且常见的是多层包装,形成为实际应用级别代码提供服务的中间件“堆栈”。如果你想编写一个跨框架的中间件组件,那么你可能确实需要成为一个 WSGI 专家。
ASGI
如果你对异步 Python(本书不涵盖)感兴趣,你应该一定要调查 ASGI,它旨在做的基本上与 WSGI 做的一样,但是是异步的。通常情况下,在网络环境中进行异步编程时,它可以提供大大提高的性能,尽管(有人认为)会增加开发者的认知负担。
Python Web 框架
对于 Python web 框架的调查,请参阅 Python 维基页面。它权威性的原因在于它位于官方 Python.org 网站上,并且是由社区共同维护的,因此随着时间的推移始终保持更新。该维基列出并指向数十个被识别为“活跃”的框架⁵,以及被识别为“已停用/不活跃”的许多其他框架。此外,它还指向了关于 Python 内容管理系统、Web 服务器以及相关 Web 组件和库的单独维基页面。
“全栈”与“轻量级”框架
大致来说,Python web 框架可以被分类为 全栈(试图提供构建 Web 应用程序所需的所有功能)或 轻量级(仅提供与 Web 服务本身的便利接口,并让您选择自己喜欢的组件用于诸如与数据库接口和模板化等任务)。当然,像所有分类法一样,这个分类法并不精确和完整,并且需要价值判断;然而,这是开始理解众多 Python web 框架的一种方式。
在本书中,我们不深入研究任何全栈框架——每一个都太复杂了。尽管如此,其中之一可能是您特定应用的最佳选择,因此我们提到了一些最流行的框架,并建议您访问它们的网站。
几种流行的全栈框架
迄今为止最流行的全栈框架是Django,它庞大而可扩展。Django 所谓的应用程序实际上是可重用的子系统,而 Django 称之为项目的通常被称为“应用程序”。Django 需要其独特的思维模式,但换取巨大的力量和功能。
一个很好的选择是web2py:它几乎和 Django 一样强大,更易学,并因其对向后兼容性的奉献而闻名(如果它保持其良好的记录,今天编写的任何 web2py 应用程序将长期保持运行)。web2py 还有出色的文档。
第三个值得一提的是TurboGears,它开始时是一个轻量级框架,但通过完全集成其他独立的第三方项目来实现“全栈”状态,以满足大多数 Web 应用程序中所需的数据库接口和模板等各种功能,而不是设计自己的功能。另一个在哲学上类似的“轻量但功能丰富”的框架是Pyramid。
使用轻量级框架时的考虑事项
每当您使用轻量级框架时,如果您需要任何数据库、模板或其他与 HTTP 不严格相关的功能,您将需要挑选和集成单独的组件来实现。然而,框架越轻量级,您就需要理解和集成的组件越多,例如对用户进行身份验证或通过给定用户的 Web 请求保持状态。许多 WSGI 中间件包可以帮助您完成这些任务。一些优秀的中间件集中于特定任务——例如,Oso用于访问控制,Beaker用于以轻量级会话形式维护状态等等。
然而,当我们(本书的作者)需要用于几乎任何目的的良好 WSGI 中间件时,我们几乎总是首先检查Werkzeug,这是一个令人惊叹的组件集合,具有广泛的应用和高质量。我们在本书中不涵盖 Werkzeug(就像我们不涵盖其他中间件一样),但我们强烈推荐它(Werkzeug 也是 Flask 的基础,Flask 是我们最喜欢的轻量级框架,在本章后面我们会详细介绍)。
你可能注意到,正确使用轻量级框架要求你理解 HTTP(换句话说,知道你在做什么),而全栈框架试图手把手地指导你做正确的事情,而不需要真正理解为什么或如何是正确的——这是以时间和资源为代价,并接受全栈框架的概念图和思维方式。本书的作者们热衷于知识密集、资源轻的轻量级框架方法,但我们承认,在许多情况下,富有、重量级、全面性的全栈框架更为合适。各取所需!
几个流行的轻量级框架
如前所述,Python 有多个框架,包括许多轻量级框架。我们在这里介绍了两个后者:流行的通用框架 Flask 和面向 API 的 FastAPI。
Flask
最受欢迎的 Python 轻量级框架是Flask,一个第三方可通过 pip 安装的包。尽管轻巧,它包含了开发服务器和调试器,并且显式地依赖于其他精选的包,如 Werkzeug 用于中间件和Jinja用于模板(这两个包最初由 Flask 的作者 Armin Ronacher 编写)。
除了项目网站(包含丰富详细的文档),还可以查看GitHub 上的源代码和PyPI 条目。如果你想在 Google App Engine 上运行 Flask(在本地计算机上或在 Google 的服务器上*appspot.com*),Dough Mahugh 的Medium 文章可能非常方便。
我们还强烈推荐 Miguel Grinberg 的书籍Flask Web Development(O'Reilly):尽管第二版在撰写本文时已经过时(几乎四年),但它仍然为你提供了一个优秀的基础,使你更容易学习最新的新增内容。
Flask 包提供的主要类被命名为 Flask。一个 flask.Flask 的实例,除了作为一个 WSGI 应用程序外,还通过其 wsgi_app 属性包装了一个 WSGI 应用程序。当你需要在 WSGI 中间件中进一步包装 WSGI 应用程序时,请使用以下习惯用法:
`import` flask
app = flask.Flask(__name__)
app.wsgi_app = *`some_middleware`*(app.wsgi_app)
当你实例化 flask.Flask 时,始终将应用程序名称作为第一个参数传递(通常只是模块中 name 特殊变量的值;如果你在一个包内实例化它,通常在*__init__.py中,使用 name.partition('.')[0]也可以)。可选地,你还可以传递命名参数,如 static_folder 和 template_folder 来自定义静态文件和 Jinja 模板的位置;但这很少需要——默认值(分别位于与实例化 flask.Flask 的 Python 脚本相同的文件夹中的子文件夹static和templates*)非常合理。
flask.Flask 的实例 app 提供了超过 100 个方法和属性,其中许多是装饰器,用于将函数绑定到 app 中的各种角色,例如 视图函数(在 URL 上提供 HTTP 动词)或 钩子(在处理请求前或构建响应后修改请求、处理错误等)。
flask.Flask 在实例化时只需几个参数(而且这些参数通常不需要在你的代码中计算),它提供了一些装饰器,你在定义例如视图函数时会用到。因此,在 Flask 中的正常模式是在你的主脚本早期实例化 app,就像你的应用程序启动时一样,这样 app 的装饰器和其他方法属性在你 def 视图函数等时就可用了。
由于存在单个全局 app 对象,你可能会想知道在访问、修改和重新绑定 app 的属性和属性时,它的线程安全性如何。不用担心:你看到的名称实际上只是特定请求上下文中实际对象的代理,在特定线程或 greenlet 的上下文中。永远不要对这些属性进行类型检查(它们的类型实际上是不透明的代理类型),你就没问题。
Flask 还提供许多其他实用函数和类;通常,后者会从其他包中的类继承或包装,以添加无缝、便捷的 Flask 集成。例如,Flask 的 Request 和 Response 类通过子类化相应的 Werkzeug 类添加了一些便捷的功能。
Flask 请求对象
类 flask.Request 提供了大量 详细记录的属性。表 20-1 列出了你经常使用的属性。
表 20-1. flask.Request 的有用属性
| args | 一个 MultiDict,包含请求的查询参数 |
|---|---|
| cookies | 一个包含请求中的 cookies 的字典 |
| data | 一个字节字符串,请求的主体(通常用于 POST 和 PUT 请求) |
| files | 一个 MultiDict,包含请求中上传的文件,将文件名映射到包含每个文件数据的类文件对象 |
| form | 一个 MultiDict,包含请求体中提供的表单字段 |
| headers | 一个 MultiDict,包含请求的头部 |
| values | 一个 MultiDict,合并了 args 和 form 属性 |
MultiDict 类似于字典,但可以为一个键拥有多个值。对 MultiDict 实例 m 进行索引和获取时会返回该键的任意一个值;要获取一个键的值列表(如果该键不在 m 中则返回空列表),可以调用 m.getlist(key)。
Flask 响应对象
通常,Flask 视图函数可以直接返回一个字符串(它将成为响应的主体):Flask 会自动在字符串周围包装一个 flask.Response 实例 r,因此您无需担心响应类。然而,有时您需要修改响应的标头;在这种情况下,在视图函数中调用 r = flask.make_response(astring),按您的要求修改 r.headers,然后返回 r。(要设置一个 cookie,请勿使用 r.headers;而是调用 r.set_cookie。)
Flask 与其他系统的许多内置集成无需子类化:例如,模板集成会将 Flask 全局对象 config、request、session 和 g(后者是方便的“全局捕获”对象 flask.g,在应用上下文中,您的代码可以存储请求处理期间想要“存放”的任何内容)隐式注入到 Jinja 上下文中,以及函数 url_for(将端点转换为相应的 URL,与 flask.url_for 相同)和 get_flashed_messages(支持 flashed messages,在本书中我们不涵盖;与 flask.get_flashed_messages 相同)。Flask 还提供了方便的方式,让您的代码将更多过滤器、函数和值注入到 Jinja 上下文中,无需任何子类化。
大多数官方认可或批准的 Flask 扩展(在撰写本文时有数百种可在 PyPI 上获取)采用类似的方法,提供类和实用函数,以无缝集成其他流行的子系统到您的 Flask 应用程序中。
此外,Flask 还引入了其他功能,如 signals,以提供“发布/订阅”模式中更松散的动态耦合,以及 blueprints,以一种高度模块化、灵活的方式提供 Flask 应用程序功能的大部分子集,以便于重构大型应用程序。我们在本书中不涵盖这些高级概念。
示例 20-1 展示了一个简单的 Flask 示例。(使用 pip 安装 Flask 后,使用命令 flask --app flask_example run 运行该示例。)
示例 20-1. 一个 Flask 示例
`import` datetime, flask
app = flask.Flask(__name__)
*`# secret key for cryptographic components such as encoding session cookies;`*
*`# for production use, use secrets.token_bytes()`*
app.secret_key = b`'``\xc5``\x8f``\xbc``\xa2``\x1d``\xeb``\xb3``\x94``;:d``\x03``'`
@app.route('/')
`def` greet():
lastvisit = flask.session.get('lastvisit')
now = datetime.datetime.now()
newvisit = now.ctime()
template = '''
<html><head><title>Hello, visitor!</title>
</head><body>
{% if lastvisit %}
<p>Welcome back to this site!</p>
<p>You last visited on {{lastvisit}} UTC</p>
<p>This visit on {{newvisit}} UTC</p>
{% else %}
<p>Welcome to this site on your first visit!</p>
<p>This visit on {{newvisit}} UTC</p>
<p>Please Refresh the web page to proceed</p>
{% endif %}
</body></html>'''
flask.session['lastvisit'] = newvisit
`return` flask.render_template_string(
template, newvisit=newvisit, lastvisit=lastvisit)
此示例展示了如何使用 Flask 提供的众多构建模块中的一小部分:Flask 类、视图函数以及渲染响应(在本例中,使用 Jinja 模板的 render_template_string;在实际应用中,通常将模板保存在单独的文件中,并使用 render_template 渲染)。该示例还展示了如何通过方便的 flask.session 变量,在同一浏览器中多次交互与服务器时保持状态的连续性。(也可以使用 Python 代码直接组合 HTML 响应,而不是使用 Jinja,并直接使用 cookie 而非 session;然而,实际的 Flask 应用程序更倾向于使用 Jinja 和 session。)
如果此应用程序有多个视图函数,可能希望在会话中设置lastvisit为触发请求的任何 URL。以下是如何编写和装饰钩子函数以在每个请求后执行的代码:
@app.after_request
`def` set_lastvisit(response):
now = datetime.datetime.now()
flask.session['lastvisit'] = now.ctime()
`return` response
现在,您可以从视图函数greet中删除flask.session['lastvisit'] = newvisit语句,应用程序将继续正常工作。
FastAPI
FastAPI 的设计比 Flask 或 Django 更为新颖。尽管后者都有非常可用的扩展以提供 API 服务,但 FastAPI 的目标直指生成基于 HTTP 的 API,正如其名称所示。它也完全能够生成面向浏览器消费的动态网页,使其成为一款多才多艺的服务器。FastAPI 的主页提供了简单、简洁的示例,展示了它的工作原理和优势,支持非常全面和详细的参考文档。
由于类型注释(在第五章中介绍)进入了 Python 语言,它们在工具中的使用范围超出了最初的意图,例如pydantic,它使用它们来执行运行时解析和验证。FastAPI 服务器利用此支持来创建清晰的数据结构,通过内置和定制的输入转换和验证来展示通过对输入进行转换和验证的内置和定制功能,从而极大地提高了 Web 编码的生产力。
FastAPI 还依赖于Starlette,一个高性能的异步 Web 框架,该框架又使用 ASGI 服务器,如Uvicorn 或 Hypercorn。您无需直接使用异步技术即可利用 FastAPI。您可以使用更传统的 Python 风格编写您的应用程序,尽管如果您切换到异步风格,它可能会表现得更快。
FastAPI 能够提供类型准确的 API(以及自动生成的文档),与您的注释所指示的类型相符,这意味着它可以对输入和输出的数据进行自动解析和转换。
请考虑示例 20-2 中显示的示例代码,该示例为 pydantic 和 mongoengine 定义了一个简单的模型。每个模型都有四个字段:name 和 description 是字符串,price 和 tax 是十进制数。对于 name 和 price 字段,需要值,但 description 和 tax 是可选的。pydantic 为后两个字段建立了默认值None;mongoengine 不存储值为None的字段的值。
示例 20-2. models.py:pydantic 和 mongoengine 数据模型
`from` decimal `import` Decimal
`from` pydantic `import` BaseModel, Field
`from` mongoengine `import` Document, StringField, DecimalField
`from` typing `import` Optional
`class` PItem(BaseModel):
"pydantic typed data class."
name: str
price: Decimal
description: Optional[str] = `None`
tax: Optional[Decimal] = `None`
`class` MItem(Document):
"mongoengine document."
name = StringField(primary_key=`True`)
price = DecimalField()
description = StringField(required=`False`)
tax = DecimalField(required=`False`)
假设您希望通过 Web 表单或 JSON 接受此类数据,并能够将数据作为 JSON 检索或在 HTML 中显示。骨架 示例 20-3(不提供维护现有数据的功能)展示了您如何使用 FastAPI 实现这一点。此示例使用 Uvicorn HTTP 服务器,但未显式使用 Python 的异步特性。与 Flask 一样,程序从创建应用程序对象 app 开始。此对象具有用于每种 HTTP 方法的装饰器方法,但是它避免了 app.route 装饰器,而是选择 app.get 用于 HTTP GET,app.post 用于 HTTP POST 等,这些确定了哪个视图函数处理不同 HTTP 方法的路径请求。
示例 20-3. server.py:FastAPI 接受并显示项目数据的示例代码
`from` decimal `import` Decimal
`from` fastapi `import` FastAPI, Form
`from` fastapi.responses `import` HTMLResponse, FileResponse
`from` mongoengine `import` connect
`from` mongoengine.errors `import` NotUniqueError
`from` typing `import` Optional
`import` json
`import` uvicorn
`from` models `import` PItem, MItem
DATABASE_URI = "mongodb://localhost:27017"
db=DATABASE_URI+"/mydatabase"
connect(host=db)
app = FastAPI()
`def` save(item):
`try`:
return item.save(force_insert=`True`)
`except` NotUniqueError:
`return` `None`
@app.get('/')
`def` home_page():
"View function to display a simple form."
`return` FileResponse("index.xhtml")
@app.post("/items/new/form/", response_class=HTMLResponse)
`def` create_item_from_form(name: str=Form(...),
price: Decimal=Form(...),
description: Optional[str]=Form(""),
tax: Optional[Decimal]=Form(Decimal("0.0"))):
"View function to accept form data and create an item."
mongoitem = MItem(name=name, price=price, description=description,
tax=tax)
value = save(mongoitem)
`if` value:
body = f"Item({name!r}, {price!r}, {description!r}, {tax!r})"
`else`:
body = f"Item {name!r} already present."
`return` f"""<html><body><h2>{body}</h2></body></html>"""
@app.post("/items/new/")
`def` create_item_from_json(item: PItem):
"View function to accept JSON data and create an item."
mongoitem = MItem(**item.dict())
value = save(mongoitem)
`if` `not` value:
`return` f"Primary key {item.name!r} already present"
`return` item.dict()
@app.get("/items/{name}/")
`def` retrieve_item(name: str):
"View function to return the JSON contents of an item."
m_item = MItem.objects(name=name).get()
`return` json.loads(m_item.to_json())
`if` __name__ == "__main__":
# host as "localhost" or "127.0.0.1" allows only local apps to access the
# web page. Using "0.0.0.0" will accept access from apps on other hosts,
# but this can raise security concerns, and is generally not recommended.
uvicorn.run("__main__:app", host="127.0.0.1", port=8000, reload=True)
home_page 函数不带参数,简单地呈现包含来自 index.xhtml 文件的表单的最小 HTML 主页。该表单提交到 /items/new/form/ 端点,触发调用 create_item_from_form 函数,在路由装饰器中声明生成 HTML 响应而不是默认的 JSON。
示例 20-4. index.xhtml 文件
<!DOCTYPE html>
<html lang="en">
<body>
<h2>FastAPI Demonstrator</h2>
<form method="POST" action="/items/new/form/">
<table>
<tr><td>Name</td><td><input name="name"></td></tr>
<tr><td>Price</td><td><input name="price"></td></tr>
<tr><td>Description</td><td><input name="description"></td></tr>
<tr><td>Tax</td><td><input name="tax"></td></tr>
<tr><td></td><td><input type="submit"></td></tr>
</table>
</form>
</body>
</html>
表单,显示在 图 20-1 中,由 create_item_from_form 函数处理,其签名为每个表单字段指定一个参数,并使用注解定义每个字段为表单字段。注意,签名为描述和税收定义了自己的默认值。该函数从表单数据创建一个 MItem 对象,并尝试将其保存到数据库中。save 函数强制插入,抑制更新现有记录,并通过返回 None 报告失败;返回值用于构建简单的 HTML 回复。在生产应用中,通常会使用像 Jinja 这样的模板引擎来渲染响应。
图 20-1. FastAPI 演示程序的输入表单
create_item_from_json 函数,从 /items/new/ 端点路由,接收来自 POST 请求的 JSON 输入。其签名接受一个 pydantic 记录,在这种情况下,FastAPI 将使用 pydantic 的验证来确定输入是否可接受。该函数返回一个 Python 字典,FastAPI 会自动将其转换为 JSON 响应。可以通过一个简单的客户端轻松测试,如 示例 20-5 所示。
示例 20-5. FastAPI 测试客户端
`import` requests, json
result = requests.post('http://localhost:8000/items/new/',
json={"name": "Item1",
"price": 12.34,
"description": "Rusty old bucket"})
print(result.status_code, result.json())
result = requests.get('http://localhost:8000/items/Item1/')
print(result.status_code, result.json())
result = requests.post('http://localhost:8000/items/new/',
json={"name": "Item2",
"price": "Not a number"})
print(result.status_code, result.json())
运行此程序的结果如下:
200 {'name': 'Item1', 'price': 12.34, 'description': 'Rusty old
bucket'> 'tax': None}
200 {'_id': 'Item1', 'price': 12.34, 'description': 'Rusty old bucket'}
422 {'detail': [{'loc': ['body', 'price'], 'msg': 'value is not a valid
decimal', 'type': 'type_error.decimal'}]}
第一个 POST 请求到 /items/new/ 会看到服务器返回与其展示的相同数据,确认其已保存在数据库中。请注意,未提供税收字段,因此这里使用了 pydantic 的默认值。第二行显示了检索到的新存储项的输出(mongoengine 使用名称 _id 标识主键)。第三行显示了一个错误消息,由于尝试将非数值值存储在价格字段中而生成。
最后,retrieve_item 视图函数,由诸如 /items/Item1/ 这样的 URL 路由,提取第二个路径元素作为键,并返回给定项的 JSON 表示。它在 mongoengine 中查找给定的键,并将返回的记录转换为字典,FastAPI 将其呈现为 JSON。
¹ 一个历史遗留问题是,在 CGI 中,服务器通过操作系统环境(在 Python 中为 os.environ)向 CGI 脚本提供关于要服务的 HTTP 请求的信息;直至今日,Web 服务器和应用程序框架之间的接口仍然依赖于“一个环境”,这本质上是一个字典,并且泛化并加速了相同的基本思想。
² 还存在更 高级版本的 HTTP,但本书不涉及它们。
³ 请不要。正如 Titus Brown 曾指出的那样,Python 因拥有比关键字还多的 Web 框架而(臭名昭著)。本书的一位作者曾在 Guido 设计 Python 3 时向他展示了如何轻松解决这个问题——只需添加几百个新关键字——但出于某种原因,Guido 对这一建议并不十分接受。
⁴ 在 Windows 上安装 uWSGI 目前需要使用 Cygwin 进行编译。
⁵ 由于 Python 关键字少于 40 个,你可以理解为什么 Titus Brown 曾指出 Python 拥有比关键字更多的 Web 框架。
第二十一章:电子邮件、MIME 和其他网络编码
网络上传输的是字节流,也被网络行话称为八位字节。字节当然可以表示文本,通过多种可能的编码方式之一。然而,你希望通过网络发送的内容往往比单纯的文本或字节流有更复杂的结构。多用途互联网邮件扩展(MIME)和其他编码标准填补了这一差距,它们规定了如何将结构化数据表示为字节或文本。虽然这些编码通常最初是为电子邮件设计的,但也被用于网络和许多其他网络系统。Python 通过各种库模块支持这些编码,如 base64、quopri 和 uu(在“将二进制数据编码为 ASCII 文本”中介绍),以及 email 包的模块(在下一节中介绍)。这些编码允许我们无缝地创建一个编码中包含附件的消息,避免了许多麻烦的任务。
MIME 和电子邮件格式处理
email 包处理 MIME 文件(如电子邮件消息)、网络新闻传输协议(NNTP)帖子、HTTP 交互等的解析、生成和操作。Python 标准库还包含其他处理这些工作部分的模块。然而,email 包提供了一种完整和系统的方法来处理这些重要任务。我们建议您使用 email,而不是部分重叠 email 功能的旧模块。尽管名为 email,但它与接收或发送电子邮件无关;对于这些任务,请参见 imaplib、poplib 和 smtplib 模块(在“电子邮件协议”中介绍)。相反,email 处理的是在接收到 MIME 消息之后或在发送之前正确构造它们的任务。
email 包中的函数
email 包提供了四个工厂函数,从字符串或文件中返回一个类 email.message.Message 的实例 m(参见表 21-1)。这些函数依赖于类 email.parser.Parser,但工厂函数更方便、更简单。因此,本书不再深入介绍 email.parser 模块。
表 21-1. 构建来自字符串或文件的消息对象的电子邮件工厂函数
| message_from_binary_file | 使用二进制文件对象 f(必须已打开以供读取)的内容解析构建 m |
|---|---|
| message_from_bytes | 使用字节串 s 解析构建 m |
| message_from_file | 使用文本文件对象 f(必须已打开以供读取)的内容解析构建 m |
| message_from_string | 使用字符串 s 解析构建 m |
email.message 模块
email.message 模块提供了 Message 类。电子邮件包的所有部分都创建、修改或使用 Message 实例。 Message 的一个实例 m 模拟了 MIME 消息,包括 headers 和 payload(数据内容)。 m 是一个映射,以头部名称为键,以头部值字符串为值。
要创建一个最初为空的 m,请不带参数调用 Message。更常见的情况是,通过 Table 21-1 中的工厂函数之一解析来创建 m,或者通过 “创建消息” 中涵盖的其他间接方式。 m 的有效载荷可以是字符串、另一个 Message 实例或者 多部分消息(一组递归嵌套的其他 Message 实例)。
你可以在构建的电子邮件消息上设置任意头部。几个互联网 RFC(请求评论)指定了各种目的的头部。主要适用的 RFC 是 RFC 2822;你可以在非规范性的 RFC 2076 中找到关于头部的许多其他 RFC 的摘要。
为了使 m 更方便,作为映射的语义与字典的语义不同。 m 的键大小写不敏感。 m 保持您添加的顺序的头部,方法 keys、values 和 items 返回按照该顺序排列的头部列表(而不是视图!)。 m 可以有多个名为 key 的头部:m[key] 返回任意一个这样的头部(或者头部缺失时返回 None),del m[key] 删除所有这样的头部(如果头部缺失则不会报错)。
要获取所有具有特定名称的头部的列表,请调用 m.get_all(key)。 len(m) 返回总头部数,计算重复项,而不仅仅是不同头部名称的数量。当没有名为 key* 的头部时,m[key] 返回 None 并且不会引发 KeyError(即它的行为类似于 m.get(key): del m[key] 在这种情况下不起作用,而 m.get_all(key) 返回 None)。您可以直接在 m 上循环:这就像在 m.keys() 上循环一样。
Message 的一个实例 m 提供了各种处理 m 的头部和有效载荷的属性和方法,列在 Table 21-2 中。
表 21-2. Message 实例 m 的属性和方法
| add_header | m.add_header(name, _value, **_params) 类似于 m[name]=_value,但您还可以作为命名参数提供头部参数。对于每个命名参数 pname=pvalue,add_header 将 pname 中的任何下划线更改为破折号,然后将一个形式为的字符串附加到头部的值:
; pname="pvalue"
当 pvalue 为 None 时,add_header 仅附加形式为的字符串:
; pname
当参数的值包含非 ASCII 字符时,将其指定为一个三项元组,(CHARSET, LANGUAGE, VALUE)。CHARSET 指定用于值的编码。LANGUAGE 通常为 None 或 '',但可以根据 RFC 2231 设置任何语言值。VALUE 是包含非 ASCII 字符的字符串值。
| as_string | m.as_string(unixfrom=False) 返回整个消息作为字符串。当 unixfrom 为 true 时,还包括第一行,通常以 'From ' 开头,称为消息的 envelope header。 |
|---|---|
| attach | m.attach(payload) 将 payload,即消息,添加到 m 的载荷中。当 m 的载荷为 None 时,m 的载荷现在是单个项目列表 [payload]。当 m 的载荷为消息列表时,将 payload 追加到列表中。当 m 的载荷为其他任何内容时,m.attach(payload) 引发 MultipartConversionError。 |
| epilogue | 属性 m.epilogue 可以是 None,或者是一个字符串,在最后一个边界线之后成为消息字符串形式的一部分。邮件程序通常不显示此文本。epilogue 是 m 的正常属性:在处理任何 m 时,您的程序可以访问它,并在构建或修改 m 时将其绑定。 |
| get_all | m.get_all(name, default=None) 返回一个列表,其中包含按照添加到 m 的顺序命名为 name 的所有头部的所有值。当 m 没有名为 name 的头部时,get_all 返回 default。 |
| get_boundary | m.get_boundary(default=None) 返回 m 的 Content-Type 头部的 boundary 参数的字符串值。当 m 没有 Content-Type 头部或头部没有 boundary 参数时,get_boundary 返回 default。 |
| get_charsets | m.get_charsets(default=None) 返回参数 charset 在 m 的 Content-Type 头部中的字符串值列表 L。当 m 是多部分时,L 每个部分有一项;否则,L 的长度为 1。对于没有 Content-Type 头部、没有 charset 参数或者主类型与 'text' 不同的部分,L 中对应的项目是 default。 |
| get_content_maintype | m.get_content_maintype(default=None) 返回 m 的主内容类型:从头部 Content-Type 中取出的小写字符串 'maintype'。例如,当 Content-Type 是 'Text/Html' 时,get_content_maintype 返回 'text'。当 m 没有 Content-Type 头部时,get_content_maintype 返回 default。 |
| get_content_subtype | m.get_content_subtype(default=None) 返回 m 的内容子类型:从头部 Content-Type 中取出的小写字符串 'subtype'。例如,当 Content-Type 是 'Text/Html' 时,get_content_subtype 返回 'html'。当 m 没有 Content-Type 头部时,get_content_subtype 返回 default。 |
| get_content_type | m.get_content_type(default=None) 返回 m 的内容类型:从头部 Content-Type 中取得一个小写字符串 'maintype/subtype'。例如,当 Content-Type 为 'Text/Html' 时,get_content_type 返回 'text/html'。当 m 没有 Content-Type 头部时,get_content_type 返回 default。 |
| get_filename | m.get_filename(default=None) 返回 m 的 Content-Disposition 头部的 filename 参数的字符串值。当 m 没有 Content-Disposition 头部,或头部没有 filename 参数时,get_filename 返回 default。 |
| get_param | m.get_param(param, default=None, header='Content-Type') 返回 m 的头部 header 中参数 param 的字符串值。对于仅由名称指定(没有值)的参数,返回 ''。当 m 没有头部 header,或头部没有名为 param 的参数时,get_param 返回 default。 |
| get_params | m.get_params(default=None, header='Content-Type') 返回 m 的头部 header 的参数,一个由字符串对组成的列表,每个参数给出其名称和值。对于仅由名称指定(没有值)的参数,使用 '' 作为值。当 m 没有头部 header 时,get_params 返回 default。 |
| get_payload | m.get_payload(i=None, decode=False) 返回 m 的载荷。当 m.is_multipart 为 False 时,i 必须为 None,m.get_payload 返回 m 的整个载荷,一个字符串或消息实例。如果 decode 为 true,并且头部 Content-Transfer-Encoding 的值为 'quoted-printable' 或 'base64',m.get_payload 还会对载荷进行解码。如果 decode 为 false,或头部 Content-Transfer-Encoding 缺失或具有其他值,m.get_payload 返回未更改的载荷。
当 m.is_multipart 为 True 时,decode 必须为 false。当 i 为 None 时,m.get_payload 返回 m 的载荷作为列表。否则,m.get_payload(i) 返回载荷的第 i 项,如果 i < 0 或 i 太大,则引发 TypeError。
| get_unixfrom | m.get_unixfrom() 返回 m 的信封头字符串,当 m 没有信封头时返回 None。 |
|---|---|
| is_multipart | m.is_multipart() 当 m 的载荷为列表时返回 True;否则返回 False。 |
| preamble | 属性 m.preamble 可以是 None,或成为消息的字符串形式的一部分,出现在第一个边界线之前。邮件程序仅在不支持多部分消息时显示此文本,因此您可以使用此属性来提醒用户需要使用其他邮件程序来查看。preamble 是 m 的普通属性:在处理由任何手段构建的 m 时,您的程序可以访问它,并在构建或修改 m 时绑定、重新绑定或解绑它。 |
| set_boundary | m.set_boundary(boundary) 将 m 的 Content-Type 头部的 boundary 参数设置为 boundary*.*。当 m 没有 Content-Type 头部时,引发 HeaderParseError。 |
| set_payload | m.set_payload(payload) 将 m 的 payload 设置为 payload,它必须是字符串或适合 m 的 Content-Type 的 Message 实例列表。 |
| set_unixfrom | m.set_unixfrom(unixfrom) 设置 m 的信封头字符串为 unixfrom。unixfrom 是整个信封头行,包括前导的 'From ' 但不包括尾部的 '\n'。 |
| walk | m.walk() 返回一个迭代器,用于遍历 m 的所有部分和子部分,深度优先(参见 “递归”)。 |
email.Generator 模块
email.Generator 模块提供了 Generator 类,您可以使用它来生成消息 m 的文本形式。m.as_string() 和 str(m) 或许已经足够,但 Generator 提供了更多的灵活性。使用必需参数 outfp 和两个可选参数实例化 Generator 类:
| Generator | class Generator(outfp, mangle_from_=False, maxheaderlen=78) outfp 是一个文件或类文件对象,供应写入方法。当 mangle_from_ 为真时,g 会在 payload 中以 'From ' 开头的任何行前添加大于号 (>),以便更容易解析消息的文本形式。g 将每个标题行在分号处包装成不超过 maxheaderlen 字符的物理行。要使用 g,调用 g.flatten;例如:
*`g`*.flatten(*`m`*, unixfrom=`False`)
这将 m 以文本形式发射到 outfp,类似于(但比以下代码消耗更少的内存):
*`outfp`*.write(*`m`*.as_string(*`unixfrom`*))
. |
创建消息
email.mime 子包提供各种模块,每个模块都有一个名为模块的 Message 子类。模块的名称为小写(例如,email.mime.text),而类名为混合大小写。这些类列在 Table 21-3 中,帮助您创建不同 MIME 类型的 Message 实例。
Table 21-3. email.mime 提供的类
| MIMEAudio | class MIMEAudio(_audiodata, _subtype=None, encoder=None, **params) 创建主类型为 'audio' 的 MIME 消息对象。_audiodata 是音频数据的字节串,用于打包为 'audio/_subtype' MIME 类型的消息。当 _subtype 为 None 时,_audiodata 必须可被标准 Python 库模块 sndhdr 解析以确定子类型;否则,MIMEAudio 会引发 TypeError。3.11+ 由于 sndhdr 已被弃用,您应该始终指定 _subtype。当 _encoder 为 None 时,MIMEAudio 会将数据编码为 Base64,这通常是最佳的。否则,_encoder 必须是可调用的,带有一个参数 m,即正在构建的消息;_encoder 必须调用 m.get_payload 获取载荷,对载荷进行编码,通过调用 m.set_payload 将编码形式放回,并设置 m 的 Content-Transfer-Encoding 头。MIMEAudio 将 _params 字典的命名参数名和值传递给 m.add_header 以构造 m 的 Content-Type 头。 |
|---|
| MIMEBase | class MIMEBase(_maintype, subtype, **params) MIME 类的基类,扩展自 Message。实例化:
*`m`* = MIMEBase(*`mainsub`*, ***`params`*)
等同于更长且稍微不太方便的习语:
*`m`* = Message()
*`m`*.add_header('Content-Type', f'{*`main`*}/{*`sub`*}',
***`params`*)
*`m`*.add_header('Mime-Version', '1.0')
|
| MIMEImage | class MIMEImage(_imagedata, _subtype=None, _encoder=None, **_params) 类似于 MIMEAudio,但主类型为 'image';使用标准 Python 模块 imghdr 来确定子类型(如果需要)。3.11+ 由于 imghdr 已被弃用,因此应始终指定 _subtype。 |
|---|---|
| MIMEMessage | class MIMEMessage(msg, _subtype='rfc822') 将 msg(必须是 Message 的一个实例(或子类))打包为 MIME 类型为 'message/_subtype' 的消息的有效载荷。 |
| MIMEText | class MIMEText(_text, _subtype='plain', _charset='us-ascii', _encoder=None) 将文本字符串 _text 打包为 MIME 类型为 'text/_subtype' 的消息的有效载荷,并使用给定的 _charset。当 _encoder 为 None 时,MIMEText 不对文本进行编码,这通常是最佳选择。否则,_encoder 必须是可调用的,带有一个参数 m,即正在构造的消息;然后,_encoder 必须调用 m.get_payload 获取有效载荷,对有效载荷进行编码,通过调用 m.set_payload 将编码形式放回,然后适当设置 m 的 Content-Transfer-Encoding 标题。 |
email.encoders 模块
email.encoders 模块提供了一些函数,这些函数以一个 nonmultipart 消息 m 作为它们唯一的参数,对 m 的有效载荷进行编码,并适当设置 m 的标题。这些函数列在表 21-4 中。
表 21-4. email.encoders 模块的函数
| encode_base64 | encode_base64(m) 使用 Base64 编码,通常对任意二进制数据最优(参见“The base64 Module”)。 |
|---|---|
| encode_noop | encode_noop(m) 不对 m 的有效载荷和标题进行任何操作。 |
| encode_quopri | encode_quopri(m) 使用 Quoted Printable 编码,通常对几乎但不完全是 ASCII 的文本最优(参见“The quopri Module”)。 |
| encode_7or8bit | encode_7or8bit(m) 不对 m 的有效载荷进行任何操作,但在 m 的有效载荷的任何字节具有高位设置时,将标题 Content-Transfer-Encoding 设置为 '8bit';否则,将其设置为 '7bit'。 |
email.utils 模块
email.utils 模块提供了几个用于电子邮件处理的函数,这些函数在表 21-5 中列出。
表 21-5. email.utils 模块的函数
| formataddr | formataddr(pair) 接受一对字符串(realname, email_address),并返回一个字符串 s,该字符串可插入到标题字段(如 To 和 Cc)中。当 realname 为假(例如空字符串 '')时,formataddr 返回 email_address。 |
|---|---|
| formatdate | formatdate(timeval=None, localtime=False) 返回一个按照 RFC 2822 指定格式的时间瞬间的字符串。timeval 是自纪元以来的秒数。当 timeval 为 None 时,formatdate 使用当前时间。当 localtime 为 True 时,formatdate 使用本地时区;否则,它使用 UTC。 |
| getaddresses | getaddresses(L) 解析 L 的每个项目,L 是地址字符串的列表,如标题字段 To 和 Cc 中使用的,返回字符串对的列表 (name, address)。当 getaddresses 无法将 L 的项目解析为电子邮件地址时,它将 ('', '') 设置为列表中相应的项目。 |
| mktime_tz | mktime_tz(t) 返回一个浮点数,表示自纪元以来的秒数(UTC 时间),对应于 t 所指示的时刻。t 是一个包含 10 项的元组。t 的前九项与模块 time 中使用的格式相同,详见“时间模块”。t[-1] 是一个时间偏移量,单位为秒,相对于 UTC(与 time.timezone 的相反符号,由 RFC 2822 指定)。当 t[-1] 为 None 时,mktime_tz 使用本地时区。 |
| parseaddr | parseaddr(s) 解析字符串 s,其中包含像 To 和 Cc 这样的标题字段中通常指定的地址,并返回一个字符串对 (realname, address)。当 parseaddr 无法将 s 解析为地址时,它返回 ('', '')。 |
| parsedate | parsedate(s) 根据 RFC 2822 中的规则解析字符串 s,并返回一个包含九项的元组 t,如模块 time 中使用的(项 t[-3:] 无意义)。parsedate 还尝试解析一些通常遇到的邮件客户端使用的 RFC 2822 的错误变体。当 parsedate 无法解析 s 时,它返回 None。 |
| parsedate_tz | parsedate_tz(s) 类似于 parsedate,但返回一个包含 10 项的元组 t,其中 t[-1] 是 s 的时区,单位为秒,与 mktime_tz 接受的参数一样,但符号与 time.timezone 相反,如 RFC 2822 所指定。t[-4:-1] 项无意义。当 s 没有时区时,t[-1] 为 None。 |
| quote | quote(s) 返回字符串 s 的副本,其中每个双引号 (") 都变为 '"',每个现有的反斜杠都重复。 |
| unquote | unquote(s) 返回字符串 s 的副本,其中移除了前导和尾随的双引号 (") 和尖括号 (<>),如果它们包围着 s 的其余部分。 |
邮件包的示例用法
邮件包不仅帮助您阅读和撰写邮件和类似邮件的消息(但不涉及接收和传输此类消息:这些任务属于单独的模块,在第十九章中涵盖)。以下是如何使用邮件来读取可能是多部分消息并将每个部分解包到给定目录中的文件的示例:
`import` pathlib, email
`def` unpack_mail(mail_file, dest_dir):
*`'''Given file object mail_file, open for reading, and dest_dir,`*
*`a string that is a path to an existing, writable directory,`*
*`unpack each part of the mail message from mail_file to a`*
*`file within dest_dir.`*
*`'''`*
dest_dir_path = pathlib.Path(dest_dir)
`with` mail_file:
msg = email.message_from_file(mail_file)
`for` part_number, part `in` enumerate(msg.walk()):
`if` part.get_content_maintype() == 'multipart':
*`# we get each specific part later in the loop,`*
*`# so, nothing to do for the 'multipart' itself`*
`continue`
dest = part.get_filename()
`if` dest `is` `None`: dest = part.get_param('name')
`if` dest `is` `None`: dest = f'part-{part_number}'
*`# in real life, make sure that dest is a reasonable filename`*
*`# for your OS; otherwise, mangle that name until it is`*
part_payload = part.get_payload(decode=`True`)
(dest_dir_path / dest).write_text(part_payload)
这里有一个执行大致相反任务的示例,将直接位于给定源目录下的所有文件打包成一个适合邮件发送的单个文件:
`def` pack_mail(source_dir, **headers):
*`'''Given source_dir, a string that is a path to an existing,`*
*`readable directory, and arbitrary header name/value pairs`*
*`passed in as named arguments, packs all the files directly`*
*`under source_dir (assumed to be plain text files) into a`*
*`mail message returned as a MIME-formatted string.`*
*`'''`*
source_dir_path = pathlib.Path(source_dir)
msg = email.message.Message()
`for` name, value `in` headers.items():
msg[name] = value
msg['Content-type'] = 'multipart/mixed'
filepaths = [path for path in source_dir_path.iterdir()
if path.is_file()]
`for` filepath `in` filepaths:
m = email.message.Message()
m.add_header('Content-type', 'text/plain', name=filename)
m.set_payload(filepath.read_text())
msg.attach(m)
`return` msg.as_string()
将二进制数据编码为 ASCII 文本
几种媒体(例如电子邮件消息)只能包含 ASCII 文本。当您想通过这些媒体传输任意二进制数据时,需要将数据编码为 ASCII 文本字符串。Python 标准库提供支持名为 Base64、Quoted Printable 和 Unix-to-Unix 的标准编码的模块,下面将对这些进行描述。
模块 base64
base64 模块支持 RFC 3548 中指定的编码,包括 Base16、Base32 和 Base64。这些编码是一种将任意二进制数据表示为 ASCII 文本的紧凑方式,没有尝试生成可读的结果。base64 提供 10 个函数:6 个用于 Base64,以及 2 个用于 Base32 和 Base16。六个 Base64 函数列在 表 21-6 中。
表 21-6. base64 模块的 Base64 函数
| b64decode | b64decode(s, altchars=None, validate=False) 解码 B64 编码的字节串 s,并返回解码后的字节串。altchars,如果不为 None,必须是至少两个字符的字节串(多余字符将被忽略),指定要使用的两个非标准字符,而不是 + 和 /(可能对解码 URL 安全或文件系统安全的 B64 编码字符串有用)。当 validate 为 True 时,如果 s 包含任何无效的 B64 编码字符串(默认情况下,这些字节只是被忽略和跳过),则调用会引发异常。如果 s 没有按照 Base64 标准正确填充,则调用会引发异常。 |
|---|---|
| b64encode | b64encode(s, altchars=None) 编码字节串 s,并返回具有相应 B64 编码数据的字节串。altchars,如果不为 None,必须是至少两个字符的字节串(多余字符将被忽略),指定要使用的两个非标准字符,而不是 + 和 /(可能对制作 URL 安全或文件系统安全的 B64 编码字符串有用)。 |
| standard_b64decode | standard_b64decode(s) 类似于 b64decode(s)。 |
| standard_b64encode | standard_b64encode(s) 类似于 b64encode(s)。 |
| urlsafe_b64decode | urlsafe_b64decode(s) 类似于 b64decode(s, '-_')。 |
| urlsafe_b64encode | urlsafe_b64encode(s) 类似于 b64encode(s, '-_')。 |
四个 Base16 和 Base32 函数列在 表 21-7 中。
表 21-7. base64 模块的 Base16 和 Base32 函数
| b16decode | b16decode(s, casefold=False) 解码 B16 编码的字节串 s,并返回解码后的字节串。当 casefold 为 True 时,s 中的小写字符将被视为其大写等价字符;默认情况下,如果存在小写字符,调用会引发异常。 |
|---|---|
| b16encode | b16encode(s) 编码字节串 s,并返回具有相应 B16 编码数据的字节串。 |
| b32decode | b32decode(s, casefold=False, map01=None) 解码 B32 编码的字节串 s,返回解码后的字节串。当 casefold 为 True 时,将 s 中的小写字符视为它们的大写形式;默认情况下,如果 s 中存在小写字符,调用将引发异常。当 map01 为 None 时,输入中不允许字符 0 和 1;当 map01 不为 None 时,必须是一个指定 1 映射到的单字符字节串,即小写 'l' 或大写 'L';0 总是映射到大写 'O'。 |
| b32encode | b32encode(s) 编码字节串 s,返回相应的 B32 编码数据的字节串。 |
该模块还提供了用于编码和解码非标准但流行的编码 Base85 和 Ascii85 的函数,这些编码虽然未在 RFC 中规范,也不互通,但使用更大的字母表对编码的字节串进行了空间节省,节省率达 15%。详细信息请参阅在线文档中的相关函数。
quopri 模块
quopri 模块支持 RFC 1521 指定的 Quoted Printable(QP)编码。QP 可将任何二进制数据表示为 ASCII 文本,但主要用于大部分为文本且带有高位设置(即 ASCII 范围之外的字符)的数据。对于这样的数据,QP 生成的结果既紧凑又易读。quopri 模块提供了四个函数,列在表 21-8 中。
表 21-8. quopri 模块的功能
| decode | decode(infile, outfile, header=False) 通过调用 infile.readline 读取类似文件的二进制对象 infile,直到文件末尾(即直到 infile.readline 返回空字符串),解码读取的 QP 编码的 ASCII 文本,并将结果写入类似文件的二进制对象 outfile*.* 当 header 为 true 时,decode 也会将 _(下划线)转换为空格(根据 RFC 1522)。 |
|---|---|
| decodestring | decodestring(s, header=False) 解码 QP 编码的 ASCII 文本字节串 s,返回解码后的字节串。当 header 为 true 时,decodestring 也会将 _(下划线)转换为空格。 |
| encode | encode(infile, outfile, quotetabs, header=False) 通过调用 infile.readline 读取类似文件的二进制对象 infile,直到文件末尾(即直到 infile.readline 返回空字符串),使用 QP 编码读取的数据,并将编码的 ASCII 文本写入类似文件的二进制对象 outfile*.* 当 quotetabs 为 true 时,encode 也会编码空格和制表符。当 header 为 true 时,encode 将空格编码为 _(下划线)。 |
| encodestring | encodestring(s, quotetabs=False, header=False) 编码包含任意字节的字节串 s,返回包含 QP 编码的 ASCII 文本的字节串。当 quotetabs 为 true 时,encodestring 也会编码空格和制表符。当 header 为 true 时,encodestring 将空格编码为 _(下划线)。 |
uu 模块
支持经典Unix-to-Unix(UU)编码的uu模块¹受到 Unix 程序uuencode和uudecode的启发。 UU 将编码数据以开始行开头,包括正在编码的文件的文件名和权限,并以结束行结尾。 因此,UU 编码允许您将编码数据嵌入其他非结构化文本中,而 Base64 编码(在“base64 模块”中讨论)依赖于其他指示编码数据开始和结束的存在。 uu 模块提供了两个函数,列在表 21-9 中。
表 21-9. uu 模块的函数
| decode | decode(infile, outfile=None, mode=None) 通过调用infile.readline 读取类文件对象infile,直到文件末尾(即,直到调用infile.readline 返回空字符串)或终止行(由任意数量的空白包围的字符串'end')。 decode 解码读取的 UU 编码文本,并将解码后的数据写入类文件对象 outfile。 当 outfile 为None时,decode 会根据 UU 格式的开始行创建文件,并使用 mode 指定的权限位(当 mode 为None时,在开始行中指定的权限位)。 在这种情况下,如果文件已经存在,decode 会引发异常。 |
|---|---|
| encode | encode(infile, outfile, name='-', mode=0o666) 通过调用infile.read(每次读取 45 字节数据,这是 UU 编码后每行 60 个字符数据的量)读取类文件对象infile,直到文件末尾(即,直到调用infile.read 返回空字符串)。 它将读取的数据以 UU 编码方式编码,并将编码文本写入类文件对象outfile。 encode 还在文本之前写入一个 UU 格式的开始行,并在文本之后写入 UU 格式的结束行。 在开始行中,encode 指定文件名为 name,并指定 mode 为 mode。 |
¹ 在 Python 3.11 中已弃用,将在 Python 3.13 中删除;在线文档建议用户更新现有代码,使用 base64 模块处理数据内容,并使用 MIME 头处理元数据。
第二十二章:结构化文本:HTML
网络上的大多数文档使用 HTML,即超文本标记语言。 标记 是在文本文档中插入特殊标记(称为 标签 )以结构化文本。HTML 理论上是一种大而普遍的标准应用,称为 标准通用标记语言(SGML)。然而,在实践中,许多网络文档以松散或不正确的方式使用 HTML。
HTML 设计用于在浏览器中呈现文档。随着网络内容的发展,用户意识到它缺乏 语义标记 的能力,其中标记指示划分文本的意义而不仅仅是其外观。完全、精确地提取 HTML 文档中的信息通常是不可行的。一个更严格的标准称为 XHTML 试图弥补这些缺点。XHTML 类似于传统的 HTML,但是它是以 XML(可扩展标记语言)的术语来定义的,比 HTML 更加精确。您可以使用 第二十三章 中涵盖的工具处理格式良好的 XHTML。然而,截至本文撰写时,XHTML 并未取得压倒性成功,而是被更为实用的 HTML5 所取代。
尽管存在困难,通常可以从 HTML 文档中提取至少一些有用信息(称为 网页抓取、蜘蛛行动 或仅为 抓取 的任务)。Python 标准库尝试帮助您,提供了用于解析 HTML 文档的 html 包,无论是为了呈现文档还是更典型地作为尝试从中提取信息的一部分。然而,当处理有些不完整的网页(这几乎总是情况!)时,第三方模块 BeautifulSoup 通常是您最后的、最好的希望。在本书中,出于实际原因,我们主要涵盖 BeautifulSoup,忽略与其竞争的标准库模块。寻求替代方案的读者也应该调查越来越流行的 scrapy 包。
生成 HTML 和在 HTML 中嵌入 Python 也是相当频繁的任务。标准的 Python 库不支持 HTML 生成或嵌入,但可以使用 Python 字符串格式化,并且第三方模块也可以提供帮助。BeautifulSoup 允许您修改 HTML 树(因此,特别是可以程序化地构建一个,甚至“从头”开始);一个常见的替代方法是 模板化,例如由第三方模块 jinja2 支持,我们在 “jinja2 包” 中提供了基本内容。
html.entities 模块
Python 标准库中的 html.entities 模块提供了几个属性,全部都是映射关系(参见 表 22-1)。无论你用什么一般方法解析、编辑或生成 HTML,包括下一节介绍的 BeautifulSoup 包,这些属性都很有用。
表 22-1. html.entities 的属性
| codepoint2name | 将 Unicode 代码点映射到 HTML 实体名称。例如,entities.codepoint2name[228] 是 'auml',因为 Unicode 字符 228,ä,“带分音符的小写 a”,在 HTML 中编码为 'ä'。 |
|---|---|
| entitydefs | 将 HTML 实体名称映射到相应的 Unicode 等效单字符字符串。例如,entities.entitydefs['auml'] 是 'ä',而 entities.entitydefs['sigma'] 是 'σ'。 |
| html5 | 将 HTML5 命名字符引用映射到等效的单字符字符串。例如,entities.xhtml5['gt;'] 是 '>'。键中的尾部分号 确实 重要 —— 少数但远非所有 HTML5 命名字符引用可以选择性地省略尾部分号,在这些情况下,entities.xhtml5 中会同时存在带有和不带有尾部分号的键。 |
| name2codepoint | 将 HTML 实体名称映射到 Unicode 代码点。例如,entities.name2codepoint['auml'] 是 228。 |
BeautifulSoup 第三方包
BeautifulSoup 允许你解析 HTML,即使它的格式相当糟糕。它使用简单的启发式方法来弥补典型的 HTML 损坏,并且在大多数情况下成功地完成这一艰巨任务。当前的 BeautifulSoup 主要版本是版本 4,也称为 bs4。在本书中,我们特别涵盖了版本 4.10;截至撰写本文时,这是 bs4 的最新稳定版本。
安装与导入 BeautifulSoup
BeautifulSoup 是那些包装要求你在 Python 内外使用不同名称的烦人模块之一。你可以通过在 shell 命令提示符下运行 pip install beautifulsoup4 来安装该模块,但在 Python 代码中导入时,你使用 import bs4。
BeautifulSoup 类
bs4 模块提供了 BeautifulSoup 类,通过调用它并传入一个或两个参数来实例化:第一个是 htmltext —— 可以是类似文件的对象(读取其中的 HTML 文本以解析),或者是字符串(作为要解析的文本)—— 第二个是可选的解析器参数。
BeautifulSoup 使用的解析器
如果您没有传递解析器参数,BeautifulSoup 会“嗅探周围”以选择最佳解析器(但在这种情况下可能会收到 GuessedAtParserWarning 警告)。 如果您没有安装其他解析器,则 BeautifulSoup 将默认使用 Python 标准库中的 html.parser; 如果您已安装其他解析器,则 BeautifulSoup 将默认使用其中之一(目前首选的是 lxml)。 除非另有说明,否则以下示例使用默认的 Python html.parser。 为了获得更多控制并避免 BeautifulSoup 文档中提到的解析器之间的差异,请在实例化 BeautifulSoup 时将要使用的解析器库的名称作为第二个参数传递。¹
例如,如果你已经安装了第三方包 html5lib(以与所有主流浏览器相同的方式解析 HTML,尽管速度较慢),你可以调用:
soup = bs4.BeautifulSoup(thedoc, 'html5lib')
当您将'xml'作为第二个参数传递时,您必须已经安装了第三方包 lxml。 BeautifulSoup 然后将文档解析为 XML,而不是 HTML。 在这种情况下,soup 的属性 is_xml 为True; 否则,soup.is_xml 为False。 如果您将'xml'作为第二个参数传递,也可以使用 lxml 解析 HTML。 更一般地说,您可能需要根据传递给 bs4.BeautifulSoup 调用的第二个参数选择安装适当的解析器库; 如果您没有这样做,BeautifulSoup 会通过警告消息提醒您。
这是在同一字符串上使用不同解析器的示例:
>>> `import` bs4, lxml, html5lib
>>> sh = bs4.BeautifulSoup('<p>hello', 'html.parser')
>>> sx = bs4.BeautifulSoup('<p>hello', 'xml')
>>> sl = bs4.BeautifulSoup('<p>hello', 'lxml')
>>> s5 = bs4.BeautifulSoup('<p>hello', 'html5lib')
>>> `for` s `in` [sh, sx, sl, s5]:
... print(s, s.is_xml)
...
<p>hello</p> False
<?xml version="1.0" encoding="utf-8"?>
<p>hello</p> True
<html><body><p>hello</p></body></html> False
<html><head></head><body><p>hello</p></body></html> False
修复无效 HTML 输入中解析器之间的差异
在上面的示例中,'html.parser'仅插入了输入中缺失的结束标记
。 其他解析器在通过添加所需标记修复无效的 HTML 输入方面有所不同,例如、和,您可以在示例中看到。BeautifulSoup、Unicode 和编码
BeautifulSoup 使用 Unicode,根据输入是否为字节串或二进制文件来推断或猜测编码²。 对于输出,prettify 方法返回树的 str 表示,包括标签及其属性。 prettify 使用空格和换行符添加到元素中以缩进元素,显示嵌套结构。 为了使其返回给定编码的 bytes 对象(字节串),请将编码名称作为参数传递给它。 如果您不想结果“漂亮”,请使用 encode 方法获取字节串,并使用 decode 方法获取 Unicode 字符串。 例如:
>>> s = bs4.BeautifulSoup('<p>hello', 'html.parser')
>>> print(s.prettify())
<p>
hello
</p>
>>> print(s.decode())
<p>hello</p>
>>> print(s.encode())
b'<p>hello</p>'
bs4 的可导航类
类 BeautifulSoup 的实例b提供了“导航”解析 HTML 树的属性和方法,返回navigable classes Tag 和 NavigableString 的实例,以及 NavigableString 的子类(CData、Comment、Declaration、Doctype 和 ProcessingInstruction,仅在输出时的不同)。 。
每个可导航类的实例都可以让您继续导航——即几乎使用与b本身相同的一组导航属性和搜索方法获取更多信息。存在一些差异:Tag 的实例可以在 HTML 树中具有 HTML 属性和子节点,而 NavigableString 的实例不能(NavigableString 的实例始终具有一个文本字符串,一个父 Tag 和零个或多个同级,即同一父标记的其他子节点)。
可导航类术语
当我们说“NavigableString 的实例”时,我们包括其任何子类的实例;当我们说“Tag 的实例”时,我们包括 BeautifulSoup 的实例,因为后者是 Tag 的子类。可导航类的实例也称为树的元素或节点。
所有可导航类的实例都有属性名称:对于 Tag 实例,它是标签字符串,对于 BeautifulSoup 实例,它是'[document]',对于 NavigableString 实例,它是None。
Tag 的实例允许您通过索引访问它们的 HTML 属性,或者您可以通过实例的.attrs 属性将它们全部作为字典获取。
索引 Tag 的实例
当t是 Tag 的实例时,t['foo']会查找t的 HTML 属性中名为 foo 的属性,并返回 foo 属性的字符串。当t没有名为 foo 的 HTML 属性时,t['foo']会引发 KeyError 异常;类似于字典上的操作,可以调用t.get('foo', default=None)来获取默认参数值,而不是异常。
一些属性,如 class,在 HTML 标准中被定义为可以具有多个值(例如,...)。在这些情况下,索引返回一个值列表,例如 soup.body['class']将是['foo', 'bar'](如果属性不存在,再次,您将得到一个 KeyError 异常;使用 get 方法而不是索引来获取默认值)。
要获得将属性名称映射到值(或在 HTML 标准中定义的少数情况下,值列表)的字典,请使用属性t.attrs:
>>> s = bs4.BeautifulSoup('<p foo="bar" class="ic">baz')
>>> s.get('foo')
>>> s.p.get('foo')
'bar'
>>> s.p.attrs
{'foo': 'bar', 'class': ['ic']}
如何检查 Tag 实例是否具有某个特定属性
要检查 Tag 实例t的 HTML 属性是否包含名为'foo'的属性,请不要使用 if 'foo' in t:——在 Tag 实例上的 in 运算符会在 Tag 的子级中查找,而不是在其属性中查找。而是,请使用 if 'foo' in t.attrs:或者更好地,使用 if t.has_attr('foo'):。
获取实际字符串
当您有一个 NavigableString 的实例时,通常希望访问它包含的实际文本字符串。当您有一个 Tag 的实例时,您可能希望访问它包含的唯一字符串,或者如果包含多个字符串,则希望访问所有这些字符串,也许还带有其周围任何空格的文本剥离。以下是您可以完成这些任务的最佳方法。
当你有一个 NavigableString 实例 s 并且需要将其文本存储或处理在其他地方,而不需要进一步对其进行导航时,请调用 str(s)。或者,使用 s.encode(codec='utf8') 得到一个字节串,或者 s.decode() 得到一个文本字符串(即 Unicode)。这些方法给出了实际的字符串,而不包含对 BeautifulSoup 树的引用,这会妨碍垃圾回收(s 支持 Unicode 字符串的所有方法,因此如果这些方法满足你的需求,可以直接调用它们)。
给定一个包含单个 NavigableString 实例 s 的 Tag 实例 t,你可以使用 t.string 来获取 s(或者,如果只想从 s 获取你想要的文本,可以使用 t.string.decode())。当 t 有一个单一子项是 NavigableString,或者有一个单一子项是 Tag,其唯一子项是 NavigableString 时,t.string 才有效;否则,t.string 为 None。
作为所有包含的(可导航的)字符串的迭代器,使用 t.strings。你可以使用 ''.join(t.strings) 将所有字符串连接成一个单独的字符串,一次完成。要忽略每个包含字符串周围的空白,请使用迭代器 t.stripped_strings(它还会跳过所有空白字符串)。
或者,调用 t.get_text():这将返回一个单一的(Unicode)字符串,其中包含 t 的后代中所有的文本,按照树的顺序(等同于访问属性 t.text)。你可以选择传递一个字符串作为分隔符的唯一位置参数。默认为空字符串,''。传递命名参数 strip=True 可以使每个字符串去除周围的空白,并跳过所有空白字符串。
以下示例演示了从标签内获取字符串的这些方法:
>>> soup = bs4.BeautifulSoup('<p>Plain <b>bold</b></p>')
>>> print(soup.p.string)
None
>>> print(soup.p.b.string)
bold
>>> print(''.join(soup.strings))
Plain bold
>>> print(soup.get_text())
Plain bold
>>> print(soup.text)
Plain bold
>>> print(soup.get_text(strip=True))
Plainbold
BeautifulSoup 和 Tag 的实例上的属性引用。
在 bs4 中,导航 HTML 树或子树的最简单、最优雅的方法是使用 Python 的属性引用语法(只要你命名的每个标签都是唯一的,或者你只关心每个下降级别的第一个命名标签)。
给定 Tag 的任何实例 t,类似 t.foo.bar 的结构会查找 t 的后代中的第一个 foo 标签,并获取它的 Tag 实例 ti,然后查找 ti 的后代中的第一个 bar 标签,并返回 bar 标签的 Tag 实例。
当你知道在可导航实例的后代中有某个标签的单一出现,或者当你只关心第一个出现的几个时,这是一种简洁而优雅的导航树的方式。但要注意:如果任何查找层级找不到正在寻找的标签,则属性引用的值为 None,然后任何进一步的属性引用都会引发 AttributeError。
警惕标签实例属性引用中的拼写错误。
由于这种 BeautifulSoup 的行为,如果在 Tag 实例的属性引用中存在任何拼写错误,将会得到 None 的值,而不是 AttributeError 异常——因此,请特别小心!
bs4 还提供了更一般的方法沿树向下、向上和侧向导航。特别地,每个可导航类实例都有属性,用于标识单个“相对”的或者复数形式下的所有相同类的迭代器。
contents、children 和 descendants
给定 Tag 的实例t,您可以获取其所有子节点的列表作为t.contents,或者作为所有子节点的迭代器的t.children。要获取所有descendants(子节点、子节点的子节点等),请使用t.descendants 的迭代器:
>>> soup = bs4.BeautifulSoup('<p>Plain <b>bold</b></p>')
>>> list(t.name `for` t `in` soup.p.children)
[None, 'b']
>>> list(t.name `for` t `in` soup.p.descendants)
[None, 'b', None]
为None的名称对应于 NavigableString 节点;它们中的第一个是 p 标签的child,但两者都是该标签的descendants。
parent 和 parents
给定任何可导航类的实例n,其父节点是n.parent:
>>> soup = bs4.BeautifulSoup('<p>Plain <b>bold</b></p>')
>>> soup.b.parent.name
'p'
一个在树中向上迭代所有祖先节点的迭代器是n.parents。这也包括 NavigableString 的实例,因为它们也有父节点。BeautifulSoup 的实例b的b.parent 是None,并且b.parents 是一个空迭代器。
next_sibling、previous_sibling、next_siblings 和 previous_siblings
给定任何可导航类的实例n,其紧邻左侧的兄弟节点是n.previous_sibling,紧邻右侧的兄弟节点是n.next_sibling;如果n没有这样的兄弟节点,则可以是None。在树中向左迭代所有左侧兄弟节点的迭代器是n.previous_siblings;在树中向右迭代所有右侧兄弟节点的迭代器是n.next_siblings(这两个迭代器都可能为空)。这也包括 NavigableString 的实例,因为它们也有兄弟节点。对于 BeautifulSoup 的实例b,b.previous_sibling 和b.next_sibling 都是None,其兄弟节点迭代器都是空的:
>>> soup = bs4.BeautifulSoup('<p>Plain <b>bold</b></p>')
>>> soup.b.previous_sibling, soup.b.next_sibling
('Plain ', None)
next_element、previous_element、next_elements 和 previous_elements
给定任何可导航类的实例n,其解析的前一个节点是n.previous_element,解析的后一个节点是n.next_element;当n是第一个或最后一个解析的节点时,其中一个或两者可以是None。在树中向后迭代所有先前元素的迭代器是n.previous_elements;在树中向前迭代所有后续元素的迭代器是n.next_elements(这两个迭代器都可能为空)。NavigableString 的实例也具有这些属性。对于 BeautifulSoup 的实例b,b.previous_element 和b.next_element 都是None,其元素迭代器都是空的:
>>> soup = bs4.BeautifulSoup('<p>Plain <b>bold</b></p>')
>>> soup.b.previous_element, soup.b.next_element
('Plain ', 'bold')
如前例所示,b 标签没有 next_sibling(因为它是其父节点的最后一个子节点);但是,它确实有一个 next_element(紧随其后解析的节点,在本例中是其包含的'bold'字符串)。
bs4 的 find…方法(又称搜索方法)
每个可导航类在 bs4 中提供了几种方法,这些方法的名称以 find 开头,称为搜索方法,用于定位满足指定条件的树节点。
搜索方法成对出现——每对方法中的一个方法遍历树的所有相关部分并返回满足条件的节点列表,而另一个方法在找到满足所有条件的单个节点时停止并返回它(或在找不到这样的节点时返回 None)。因此,调用后者的方法就像调用前者的方法,并使用参数 limit=1,然后索引结果为单项目列表以获得其单个项目,但更快更优雅。
因此,例如,对于任何标签实例 t 和由 ... 表示的任何位置参数和命名参数组,以下等价性总是成立:
just_one = *`t`*.find(...)
other_way_list = *`t`*.find_all(..., limit=1)
other_way = other_way_list[0] `if` other_way_list `else` `None`
`assert` just_one == other_way
方法对列在 表 22-2 中。
表格 22-2. bs4 find... 方法对
| find, find_all | b.find(...),b.find_all(...)
搜索 b 的 descendants 或者当你传递命名参数 recursive=False(仅适用于这两种方法,而不适用于其他搜索方法)时,仅限于 b 的 children。这些方法在 NavigableString 实例上不可用,因为它们没有后代;所有其他搜索方法在标签和 NavigableString 实例上都可用。
由于经常需要 find_all,bs4 提供了一个优雅的快捷方式:调用一个标签就像调用它的 find_all 方法一样。换句话说,当 b 是一个标签时,b(...) 等同于 b.find_all(...)。
另一个快捷方式,已在 “BeautifulSoup 和 Tag 实例上的属性引用” 中提到,即 b.foo.bar 等同于 b.find('foo').find('bar')。
| find_next, find_all_next | b.find_next(...),b.find_all_next(...)
搜索 b 的下一个元素。
| find_next_sibling, find_next_siblings | b.find_next_sibling(...),b.find_next_siblings(...)
搜索 b 的下一个兄弟。
| find_parent, find_parents | b.find_parent(...),b.find_parents(...)
搜索 b 的父元素。
| find_previous, find_all_previous | b.find_previous(...),b.find_all_previous(...)
搜索 b 的前一个元素。
| find_previous_sibling, find_previous_siblings | b.find_previous_sibling(...),b.find_previous_siblings(...) 搜索 b 的前一个兄弟。 |
|---|
搜索方法的参数
每个搜索方法都有三个可选参数:name、attrs 和 string。name 和 string 是 filters,如下一小节所述;attrs 是一个字典,如本节后面所述。另外,如 表 22-2 中所述,仅 find 和 find_all(而不是其他搜索方法)可以选择使用命名参数 recursive=False 进行调用,以限制搜索范围仅限于子代,而不是所有后代。
返回列表的任何搜索方法(即其名称为复数或以 find_all 开头)可以选择接受命名参数 limit:其值(如果有)为整数,将返回的列表长度上限化(当您传递 limit 时,如有必要,返回的列表结果将被截断)。
在这些可选参数之后,每个搜索方法可以选择具有任意数量的任意命名参数:参数名称可以是任何标识符(除了搜索方法的特定参数名称),而值是筛选器。
筛选器
filter应用于target,target可以是标签的名称(当作为name参数传递时)、Tag 的字符串或 NavigableString 的文本内容(当作为string参数传递时)、或 Tag 的属性(当作为命名参数的值传递或在attrs参数中)。 每个筛选器可以是:
Unicode 字符串
筛选器成功时,字符串完全等于目标。
字节字符串
使用 utf-8 解码为 Unicode,当生成的 Unicode 字符串完全等于目标时,筛选器成功。
正则表达式对象(由 re.compile 生成,详见“正则表达式和 re 模块”)
当 RE 的搜索方法以目标作为参数调用成功时,筛选器成功。
字符串列表
如果任何字符串完全等于目标(如果任何字符串为字节字符串,则使用 utf-8 解码为 Unicode)则筛选器成功。
函数对象
当使用 Tag 或 NavigableString 实例作为参数调用函数时返回 True 时,筛选器成功。
True
筛选器总是成功。
作为“筛选器成功”的同义词,我们也说“目标与筛选器匹配”。
每个搜索方法都会查找所有与其所有筛选器匹配的相关节点(即,在每个候选节点上隐式执行逻辑and操作)。 (不要将此逻辑与具有列表作为参数值的特定筛选器的逻辑混淆。其中一个筛选器匹配列表中的任何项时,该筛选器隐式执行逻辑or操作。)
名称
要查找名称匹配筛选器的标签,请将筛选器作为搜索方法的第一个位置参数传递,或将其作为 name=filter传递:
*`# return all instances of Tag 'b' in the document`*
soup.find_all('b') *`# or soup.find_all(name='b')`*
*`# return all instances of Tags 'b' and 'bah' in the document`*
soup.find_all(['b', 'bah'])
*`# return all instances of Tags starting with 'b' in the document`*
soup.find_all(re.compile(r'^b'))
*`# return all instances of Tags including string 'bah' in the document`*
soup.find_all(re.compile(r'bah'))
*`# return all instances of Tags whose parent's name is 'foo'`*
`def` child_of_foo(tag):
`return` tag.parent.name == 'foo'
soup.find_all(child_of_foo)
字符串
要查找其.string 文本与筛选器匹配的 Tag 节点,或者文本与筛选器匹配的 NavigableString 节点,请将筛选器作为字符串=filter传递:
*`# return all instances of NavigableString whose text is 'foo'`*
soup.find_all(string='foo')
*`# return all instances of Tag 'b' whose .string's text is 'foo'`*
soup.find_all('b', string='foo')
属性
要查找具有值匹配筛选器的属性的 Tag 节点,请使用将属性名称作为键和相应筛选器作为相应值的字典d。 然后,将d作为搜索方法的第二个位置参数传递,或将 attrs=d传递。
作为特例,您可以使用d中的值None而不是筛选器;这将匹配缺少相应属性的节点。
作为单独的特殊情况,如果 attrs 的值 f 不是字典,而是过滤器,则相当于具有 attrs={'class': *f*}。 (此便捷快捷方式非常有用,因为查找具有特定 CSS 类的标签是频繁的任务。)
你不能同时应用这两种特殊情况:要搜索没有任何 CSS 类的标签,必须显式地传递 attrs={'class': **None**}(即使用第一个特殊情况,但不能同时使用第二个):
*`# return all instances of Tag 'b' w/an attribute 'foo' and no 'bar'`*
soup.find_all('b', {'foo': `True`, 'bar': `None`})
匹配具有多个 CSS 类的标签
与大多数属性不同,标签的 'class' 属性可以具有多个值。这些在 HTML 中显示为以空格分隔的字符串(例如,'<p class='foo bar baz'>...'),在 bs4 中作为字符串列表显示(例如,t['class'] 为 ['foo', 'bar', 'baz'])。
在任何搜索方法中按 CSS 类过滤时,如果标签的多个 CSS 类中有一个匹配,过滤器将匹配该标签。
要通过多个 CSS 类匹配标签,可以编写自定义函数并将其作为过滤器传递给搜索方法;或者,如果不需要搜索方法的其他增加功能,则可以避免搜索方法,而是使用后续部分中介绍的 *t*.select 方法,并按 CSS 选择器的语法进行操作。
其他命名参数
命名参数,超出搜索方法已知名称的参数,用于增强已指定的 attrs 约束(如果有)。例如,调用搜索方法带有 *foo*=*bar* 相当于带有 attrs={'*foo*': *bar*}。
bs4 CSS 选择器
bs4 标签提供 select 和 select_one 方法,大致相当于 find_all 和 find,但接受一个字符串作为参数,该字符串是 CSS 选择器,分别返回满足该选择器的 Tag 节点列表或第一个这样的 Tag 节点。例如:
`def` foo_child_of_bar(t):
`return` t.name=='foo' `and` t.parent `and` t.parent.name=='bar'
*`# return tags with name 'foo' children of tags with name 'bar'`*
soup.find_all(foo_child_of_bar)
*`# equivalent to using find_all(), with no custom filter function needed`*
soup.select('bar > foo')
bs4 仅支持丰富的 CSS 选择器功能的子集,在本书中不再详细介绍 CSS 选择器。(要完整了解 CSS,建议阅读 O’Reilly 的 CSS: The Definitive Guide,作者是 Eric Meyer 和 Estelle Weyl。)在大多数情况下,前一节中介绍的搜索方法是更好的选择;然而,在一些特殊情况下,调用 select 可以避免编写自定义过滤函数(稍微麻烦的小事)。
使用 BeautifulSoup 进行 HTML 解析的示例
以下示例使用 bs4 执行典型任务:从 Web 获取页面、解析页面并输出页面中的 HTTP 超链接:
`import` urllib.request, urllib.parse, bs4
f = urllib.request.urlopen('http://www.python.org')
b = bs4.BeautifulSoup(f)
seen = set()
`for` anchor `in` b('a'):
url = anchor.get('href')
`if` url `is` `None` `or` url `in` seen:
`continue`
seen.add(url)
pieces = urllib.parse.urlparse(url)
`if` pieces[0].startswith('http'):
print(urllib.parse.urlunparse(pieces))
首先调用类 bs4.BeautifulSoup 的实例(等同于调用其 find_all 方法),以获取特定标签(这里是 <a> 标签)的所有实例,然后再获取该标签实例的 get 方法来获取属性的值(这里是 'href'),或者在该属性缺失时返回 None。
生成 HTML
Python 没有专门用于生成 HTML 的工具,也没有让你直接在 HTML 页面中嵌入 Python 代码的工具。通过模板化(在“模板化”中讨论),通过分离逻辑和表示问题来简化开发和维护。还可以使用 bs4 在 Python 代码中创建 HTML 文档,逐步修改非常简单的初始文档。由于这些修改依赖于 bs4 解析某些 HTML,因此使用不同的解析器会影响输出,如在“BeautifulSoup 使用哪个解析器”中提到的那样。
使用 bs4 编辑和创建 HTML
编辑 Tag 的实例t有各种选项。你可以通过赋值给t.name 改变标签名,通过将t视为映射来改变t的属性:赋值给索引以添加或更改属性,或删除索引以移除属性(例如,del t['foo']移除属性 foo)。如果你将一些字符串赋给t.string,那么所有先前的t.contents(标签和/或字符串—t的整个子树)都将被丢弃,并替换为具有该字符串作为其文本内容的新 NavigableString 实例。
给定 NavigableString 实例s,你可以替换其文本内容:调用s.replace_with('other')将s的文本替换为'other'。
构建和添加新节点
修改现有节点很重要,但从头开始构建 HTML 文档时创建新节点并将其添加到树中至关重要。
要创建一个新的 NavigableString 实例,请调用类并将文本内容作为唯一参数:
s = bs4.NavigableString(' some text ')
要创建一个新的 Tag 实例,请调用 BeautifulSoup 实例的 new_tag 方法,将标签名作为唯一的位置参数,并(可选地)为属性命名参数。
>>> soup = bs4.BeautifulSoup()
>>> t = soup.new_tag('foo', bar='baz')
>>> print(t)
<foo bar="baz"></foo>
要将节点添加到 Tag 的子节点中,请使用 Tag 的 append 方法。这将在任何现有子节点之后添加节点:
>>> t.append(s)
>>> print(t)
<foo bar="baz"> some text </foo>
如果你希望新节点不是在结尾,而是在t的子节点中的某个索引处,请调用t.insert(n, s)将s放置在t.contents 的索引n处(t.append 和t.insert 的工作方式就像t是其子节点列表一样)。
如果你有一个可导航的元素b,想要将一个新节点x添加为b的 previous_sibling,请调用b.insert_before(x)。如果你希望x代替b的 next_sibling,请调用b.insert_after(x)。
如果你想将新的父节点t包裹在b周围,调用b.wrap(t)(这也返回新包裹的标签)。例如:
>>> print(t.string.wrap(soup.new_tag('moo', zip='zaap')))
<moo zip="zaap"> some text </moo>
>>> print(t)
<foo bar="baz"><moo zip="zaap"> some text </moo></foo>
替换和移除节点
你可以在任何标签t上调用t.replace_with:该调用将替换t及其先前的所有内容为参数,并返回具有其原始内容的t。例如:
>>> soup = bs4.BeautifulSoup(
... '<p>first <b>second</b> <i>third</i></p>', 'lxml')
>>> i = soup.i.replace_with('last')
>>> soup.b.append(i)
>>> print(soup)
<html><body><p>first <b>second<i>third</i></b> last</p></body></html>
你可以在任何标签t上调用t.unwrap:该调用将替换t及其内容,并返回“清空”的t(即,没有内容)。例如:
>>> empty_i = soup.i.unwrap()
>>> print(soup.b.wrap(empty_i))
<i><b>secondthird</b></i>
>>> print(soup)
<html><body><p>first <i><b>secondthird</b></i> last</p></body></html>
t.clear 移除t的内容,销毁它们,并将t留空(但仍然位于树中的原始位置)。t.decompose 移除并销毁t本身及其内容:
>>> *`# remove everything between <i> and </i> but leave tags`*
>>> soup.i.clear()
>>> print(soup)
<html><body><p>first <i></i> last</p></body></html>
>>> *`# remove everything between <p> and </p> incl. tags`*
>>> soup.p.decompose()
>>> print(soup)
<html><body></body></html>
>>> *`# remove <body> and </body>`*
>>> soup.body.decompose()
>>> print(soup)
<html></html>
最后,t.extract 提取并返回t及其内容,但不销毁任何内容。
使用 bs4 构建 HTML
下面是一个示例,展示了如何使用 bs4 的方法生成 HTML。具体来说,以下函数接受一个“行”(序列)的序列,并返回一个字符串,该字符串是一个 HTML 表格,用于显示它们的值:
`def` mktable_with_bs4(seq_of_rows):
tabsoup = bs4.BeautifulSoup('<table>')
tab = tabsoup.table
`for` row `in` seq_of_rows:
tr = tabsoup.new_tag('tr')
tab.append(tr)
`for` item `in` row:
td = tabsoup.new_tag('td')
tr.append(td)
td.string = str(item)
`return` tab
这里是使用我们刚刚定义的函数的示例:
>>> example = (
... ('foo', 'g>h', 'g&h'),
... ('zip', 'zap', 'zop'),
... )
>>> print(mktable_with_bs4(example))
<table><tr><td>foo</td><td>g>h</td><td>g&h</td></tr>
<tr><td>zip</td><td>zap</td><td>zop</td></tr></table>
注意,bs4 会自动将标记字符如<, >和&转换为它们对应的 HTML 实体;例如,'g>h'呈现为'g>h'。
模板化
要生成 HTML,通常最好的方法是模板化。您可以从一个模板开始——一个文本字符串(通常从文件、数据库等读取),它几乎是有效的 HTML,但包含占位符(称为占位符),在动态生成的文本必须插入的位置;您的程序生成所需的文本并将其替换到模板中。
在最简单的情况下,您可以使用形式为{name}的标记。将动态生成的文本设置为某个字典d中键'name'的值。Python 的字符串格式化方法.format(在“字符串格式化”中讨论)让您完成剩下的工作:当t是模板字符串时,t.format(d)是模板的副本,所有值都得到了正确的替换。
一般来说,除了替换占位符之外,您还会希望使用条件语句,执行循环,并处理其他高级格式和展示任务;在将“业务逻辑”与“展示问题”分离的精神下,您更喜欢所有后者作为模板的一部分。这就是专门的第三方模板化包的用武之地。这里有许多这样的包,但本书的所有作者,都曾使用过并编写过其中一些,目前更倾向于使用jinja2,接下来进行详细介绍。
jinja2 包
对于严肃的模板化任务,我们推荐使用 jinja2(在PyPI上可用,像其他第三方 Python 包一样,因此可以轻松通过pip install jinja2安装)。
jinja2 文档非常出色和详尽,涵盖了模板语言本身(在概念上模仿 Python,但有许多不同之处,以支持在 HTML 中嵌入它和特定于展示问题的独特需求);您的 Python 代码用于连接到 jinja2 的 API,并在必要时扩展或扩展它;以及其他问题,从安装到国际化,从代码沙箱到从其他模板引擎移植——更不用说宝贵的提示和技巧了。
在本节中,我们仅涵盖了 jinja2 强大功能的一小部分,这些足以让你在安装后开始使用。我们强烈建议阅读 jinja2 的文档,以获取大量额外有用的信息。
jinja2.Environment 类
当你使用 jinja2 时,总会涉及一个 Environment 实例——在少数情况下,你可以让它默认为一个通用的“共享环境”,但这不推荐。只有在非常高级的用法中,当你从不同来源获取模板(或使用不同的模板语言语法)时,才会定义多个环境实例——通常情况下,你会实例化一个单独的 Environment 实例 env,用于渲染所有需要的模板。
你可以在构建 env 时通过向其构造函数传递命名参数的方式进行多种方式的定制(包括修改关键的模板语言语法方面,比如哪些定界符用于开始和结束块、变量、注释等)。在实际使用中,你几乎总是会传递一个名为 loader 的命名参数(其他很少设置)。
环境的 loader 指定了如何在请求时加载模板——通常是文件系统中的某个目录,或者也许是某个数据库(你需要编写 jinja2.Loader 的自定义子类来实现后者),但也有其他可能性。你需要一个 loader 来让模板享受 jinja2 的一些最强大的特性,比如 template inheritance。
你可以在实例化 env 时配备自定义 filters, tests, extensions 等(这些也可以稍后添加)。
在稍后的示例中,我们假设 env 是通过 loader=jinja2.FileSystemLoader('/path/to/templates') 实例化的,没有进一步的增强——事实上,为简单起见,我们甚至不会使用 loader 参数。
env.get_template(name) 获取、编译并返回基于 env.loader(name) 返回内容的 jinja2.Template 实例。在本节末尾的示例中,为简单起见,我们将使用罕见的 env.from_string(s) 来从字符串 s 构建 jinja2.Template 的实例。
jinja2.Template 类
jinja2.Template 的一个实例 t 拥有许多属性和方法,但在实际生活中你几乎只会使用以下这个:
| 渲染 | t.render(...context...) context 参数与传递给 dict 构造函数的内容相同——一个映射实例,和/或丰富和潜在覆盖映射键值连接的命名参数。
t.render(context) 返回一个(Unicode)字符串,该字符串是应用于模板 t 的 context 参数后生成的结果。
使用 jinja2 构建 HTML
这里是一个使用 jinja2 模板生成 HTML 的示例。具体来说,就像在“用 bs4 构建 HTML”中一样,以下函数接受一个“行”(序列)的序列,并返回一个 HTML 表格来显示它们的值:
TABLE_TEMPLATE = '''\ <table>
{% for s in s_of_s %}
<tr>
{% for item in s %}
<td>{{item}}</td>
{% endfor %}
</tr>
{% endfor %}
</table>'''
`def` mktable_with_jinja2(s_of_s):
env = jinja2.Environment(
trim_blocks=`True`,
lstrip_blocks=`True`,
autoescape=`True`)
t = env.from_string(TABLE_TEMPLATE)
`return` t.render(s_of_s=s_of_s)
函数使用选项 autoescape=True,自动“转义”包含标记字符如<, >和&的字符串;例如,使用 autoescape=True,'g>h'渲染为'g>h'。
选项 trim_blocks=True和 lstrip_blocks=True纯粹是为了美观起见,以确保模板字符串和渲染的 HTML 字符串都能被良好地格式化;当然,当浏览器渲染 HTML 时,HTML 文本本身是否良好格式化并不重要。
通常情况下,您会始终使用加载器参数构建环境,并通过方法调用如t = env.get_template(template_name)从文件或其他存储加载模板。在这个示例中,为了一目了然,我们省略了加载器,并通过调用方法env.from_string 从字符串构建模板。请注意,jinja2 不是 HTML 或 XML 特定的,因此仅使用它并不能保证生成内容的有效性,如果需要符合标准,您应该仔细检查生成的内容。
该示例仅使用 jinja2 模板语言提供的众多功能中最常见的两个特性:循环(即,用{% for ... %}和{% endfor %}括起来的块)和参数替换(内联表达式用{{和}}括起来)。
这里是我们刚定义的函数的一个示例用法:
>>> example = (
... ('foo', 'g>h', 'g&h'),
... ('zip', 'zap', 'zop'),
... )
>>> print(mktable_with_jinja2(example))
<table>
<tr>
<td>foo</td>
<td>g>h</td>
<td>g&h</td>
</tr>
<tr>
<td>zip</td>
<td>zap</td>
<td>zop</td>
</tr>
</table>
¹ BeautifulSoup 的文档提供了关于安装各种解析器的详细信息。
² 正如在 BeautifulSoup 的文档中解释的那样,它还展示了各种指导或完全覆盖 BeautifulSoup 关于编码猜测的方法。