Python 入门指南第二版(七)
原文:
annas-archive.org/md5/4b0fd2cf0da7c8edae4b5ecfd40159bf译者:飞龙
第十七章:空间中的数据:网络
时间是自然使一切都不同时发生的方式。空间则是防止一切同时发生于我身上的东西。
在 第十五章 中,您了解了 并发性:如何同时做多件事情。现在我们将尝试在多个地方做事情:分布式计算 或 网络。有许多充分的理由来挑战时间和空间:
性能
您的目标是保持快速组件繁忙,而不是等待慢组件。
鲁棒性
数量越多越安全,因此您希望复制任务以解决硬件和软件故障。
简洁性
最佳实践是将复杂的任务分解成许多更容易创建、理解和修复的小任务。
可伸缩性
增加服务器以处理负载,减少以节省成本。
在本章中,我们从网络基元向更高级别的概念发展。让我们从 TCP/IP 和套接字开始。
TCP/IP
互联网基于关于如何建立连接、交换数据、终止连接、处理超时等规则。这些被称为 协议,它们按层次排列。层次的目的是允许创新和以不同方式做事情的替代方式;只要您在处理上层和下层约定时遵循惯例,您可以在任何一层上做任何您想做的事情。
最低层次管理诸如电信号等方面;每个更高的层次都建立在下面的层次之上。中间差不多是 IP(Internet Protocol)层,它指定网络位置的寻址方式以及数据包(数据块)的流动方式。在其上一层,有两个协议描述如何在位置之间传递字节:
UDP(用户数据报协议)
这用于短期交流。数据报 是一种以单次传送为单位的微小消息,就像明信片上的便签。
TCP(传输控制协议)
这个协议用于持久连接。它发送字节流,确保它们按顺序到达且不重复。
UDP 消息不会收到确认,因此你永远不确定它们是否到达目的地。如果你想通过 UDP 讲笑话:
Here's a UDP joke. Get it?
TCP 在发送方和接收方之间建立秘密握手以确保良好的连接。一个 TCP 笑话会以这样开始:
Do you want to hear a TCP joke?
Yes, I want to hear a TCP joke.
Okay, I'll tell you a TCP joke.
Okay, I'll hear a TCP joke.
Okay, I'll send you a TCP joke now.
Okay, I'll receive the TCP joke now.
... (and so on)
您的本地机器始终具有 IP 地址 127.0.0.1 和名称 localhost。您可能会看到这被称为 环回接口。如果连接到互联网,您的机器还将有一个 公共 IP。如果您只是使用家用计算机,则它位于诸如电缆调制解调器或路由器等设备之后。您可以在同一台机器上的进程之间运行互联网协议。
我们与之交互的大多数互联网——网页、数据库服务器等——都是基于 TCP 协议运行在 IP 协议之上的;简称 TCP/IP。让我们先看一些基本的互联网服务。之后,我们探索通用的网络模式。
套接字
如果你想知道事情是如何工作的,一直到底层,这一部分是为你准备的。
网络编程的最底层使用了一个socket,从 C 语言和 Unix 操作系统中借用而来。套接字级别的编码很繁琐。使用像 ZeroMQ 这样的东西会更有趣,但看到底层是很有用的。例如,当发生网络错误时,关于套接字的消息经常出现。
让我们编写一个非常简单的客户端-服务器交换,一次使用 UDP,一次使用 TCP。在 UDP 示例中,客户端将字符串发送到服务器的 UDP 数据报中,服务器返回一个包含字符串的数据包。服务器需要在特定地址和端口上监听,就像一个邮局和一个邮箱一样。客户端需要知道这两个值以便发送消息并接收任何回复。
在以下客户端和服务器代码中,address是一个元组(地址,端口)。address是一个字符串,可以是名称或IP 地址。当你的程序只是在同一台机器上相互交流时,你可以使用名称'localhost'或等效的地址字符串'127.0.0.1'。
首先,让我们从一个进程向另一个进程发送一些数据,并将一些数据返回给发送者。第一个程序是客户端,第二个是服务器。在每个程序中,我们打印时间并打开一个套接字。服务器将监听其套接字的连接,客户端将写入其套接字,该套接字将向服务器传输一条消息。
示例 17-1 呈现了第一个程序,udp_server.py。
示例 17-1。udp_server.py
from datetime import datetime
import socket
server_address = ('localhost', 6789)
max_size = 4096
print('Starting the server at', datetime.now())
print('Waiting for a client to call.')
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(server_address)
data, client = server.recvfrom(max_size)
print('At', datetime.now(), client, 'said', data)
server.sendto(b'Are you talking to me?', client)
server.close()
服务器必须通过从socket包导入的两种方法来设置网络。第一个方法,socket.socket,创建一个套接字,第二个方法,bind,绑定到它(监听到达该 IP 地址和端口的任何数据)。AF_INET表示我们将创建一个 IP 套接字。(还有另一种类型的Unix 域套接字,但这些仅在本地机器上工作。)SOCK_DGRAM表示我们将发送和接收数据报,换句话说,我们将使用 UDP。
此时,服务器坐在那里等待数据报的到来(recvfrom)。当数据报到达时,服务器将唤醒并获取数据以及关于客户端的信息。client变量包含了到达客户端所需的地址和端口组合。服务器最后通过发送回复并关闭连接来结束。
让我们看一下udp_client.py(示例 17-2)。
示例 17-2。udp_client.py
import socket
from datetime import datetime
server_address = ('localhost', 6789)
max_size = 4096
print('Starting the client at', datetime.now())
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.sendto(b'Hey!', server_address)
data, server = client.recvfrom(max_size)
print('At', datetime.now(), server, 'said', data)
client.close()
客户端有大部分与服务器相同的方法(除了bind())。客户端先发送再接收,而服务器先接收。
首先,在它自己的窗口中启动服务器。它将打印问候语,然后静静地等待,直到客户端发送一些数据:
$ python udp_server.py
Starting the server at 2014-02-05 21:17:41.945649
Waiting for a client to call.
接下来,在另一个窗口中启动客户端。它将打印问候语,向服务器发送数据(字节值为'Hey'),打印回复,然后退出:
$ python udp_client.py
Starting the client at 2014-02-05 21:24:56.509682
At 2014-02-05 21:24:56.518670 ('127.0.0.1', 6789) said b'Are you talking to me?'
最后,服务器将打印接收到的消息,并退出:
At 2014-02-05 21:24:56.518473 ('127.0.0.1', 56267) said b'Hey!'
客户端需要知道服务器的地址和端口号,但不需要为自己指定端口号。系统会自动分配端口号——在这种情况下是 56267。
注意
UDP 以单个数据块发送数据。它不保证传递。如果通过 UDP 发送多个消息,它们可能无序到达,或者根本不到达。UDP 快速、轻便、无连接且不可靠。UDP 在需要快速推送数据包并且可以偶尔容忍丢失数据包的情况下非常有用,比如 VoIP(互联网电话)。
这让我们来谈谈 TCP(传输控制协议)。TCP 用于更长时间的连接,比如 Web。TCP 按照发送顺序传递数据。如果出现任何问题,它会尝试重新发送。这使得 TCP 比 UDP 稍慢,但通常在需要所有数据包按正确顺序到达时更可靠。
注意
Web 协议 HTTP 的前两个版本基于 TCP,但 HTTP/3 基于一个称为 QUIC 的协议,QUIC 本身使用 UDP。因此,在 UDP 和 TCP 之间选择可能涉及许多因素。
让我们使用 TCP 从客户端到服务器再返回射击几个数据包。
tcp_client.py 的行为类似于之前的 UDP 客户端,只向服务器发送一个字符串,但套接字调用中有小差异,示例 17-3 中有所说明。
示例 17-3. tcp_client.py
import socket
from datetime import datetime
address = ('localhost', 6789)
max_size = 1000
print('Starting the client at', datetime.now())
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(address)
client.sendall(b'Hey!')
data = client.recv(max_size)
print('At', datetime.now(), 'someone replied', data)
client.close()
我们已经用 SOCK_STREAM 替换了 SOCK_DGRAM 以获取流协议 TCP。我们还添加了一个 connect() 调用来建立流。我们在 UDP 中不需要这样做,因为每个数据报都是独立的,存在于广阔而不受控制的互联网上。
正如示例 17-4 所示,tcp_server.py 与其 UDP 表兄弟也有所不同。
示例 17-4. tcp_server.py
from datetime import datetime
import socket
address = ('localhost', 6789)
max_size = 1000
print('Starting the server at', datetime.now())
print('Waiting for a client to call.')
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(address)
server.listen(5)
client, addr = server.accept()
data = client.recv(max_size)
print('At', datetime.now(), client, 'said', data)
client.sendall(b'Are you talking to me?')
client.close()
server.close()
server.listen(5) 被配置为在拒绝新连接之前排队最多五个客户端连接。server.accept() 获取到达的第一个可用消息。
client.recv(1000) 设置了最大可接受消息长度为 1,000 字节。
就像之前一样,先启动服务器,然后启动客户端,看看有趣的事情发生了。首先是服务器:
$ python tcp_server.py
Starting the server at 2014-02-06 22:45:13.306971
Waiting for a client to call.
At 2014-02-06 22:45:16.048865 <socket.socket object, fd=6, family=2, type=1,
proto=0> said b'Hey!'
现在,启动客户端。它会将消息发送到服务器,接收响应,然后退出:
$ python tcp_client.py
Starting the client at 2014-02-06 22:45:16.038642
At 2014-02-06 22:45:16.049078 someone replied b'Are you talking to me?'
服务器收集消息,打印消息,响应消息,然后退出:
At 2014-02-06 22:45:16.048865 <socket.socket object, fd=6, family=2, type=1,
proto=0> said b'Hey!'
注意到 TCP 服务器调用了 client.sendall() 来响应,而之前的 UDP 服务器调用了 client.sendto()。TCP 在多次套接字调用中维护客户端-服务器连接,并记住客户端的 IP 地址。
这看起来还不错,但如果你尝试写更复杂的内容,你会看到套接字在低层真正的操作方式:
-
UDP 发送消息,但其大小受限且不能保证到达目的地。
-
TCP 发送字节流,而不是消息。你不知道系统在每次调用中会发送或接收多少字节。
-
要使用 TCP 交换整个消息,您需要一些额外的信息来从其段重新组装完整消息:固定的消息大小(字节),或完整消息的大小,或一些分隔字符。
-
因为消息是字节而不是 Unicode 文本字符串,所以您需要使用 Python 的
bytes类型。有关更多信息,请参阅第十二章。
在这一切之后,如果你对套接字编程感兴趣,请查看 Python 套接字编程HOWTO获取更多详细信息。
Scapy
有时候你需要深入网络流并观察数据字节的传递。您可能需要调试 Web API 或追踪某些安全问题。scapy库和程序提供了一个领域特定语言,用于在 Python 中创建和检查数据包,这比编写和调试等效的 C 程序要容易得多。
标准安装使用pip install scapy。文档非常详尽。如果您使用tcpdump或wireshark等工具来调查 TCP 问题,您应该查看scapy。最后,请不要将scapy与scrapy混淆,后者在“爬取和抓取”中有介绍。
Netcat
另一个测试网络和端口的工具是Netcat,通常缩写为nc。这里有一个连接到谷歌网站的 HTTP 示例,并请求其主页的一些基本信息:
$ $ nc www.google.com 80
HEAD / HTTP/1.1
HTTP/1.1 200 OK
Date: Sat, 27 Jul 2019 21:04:02 GMT
...
在下一章中,有一个示例使用“使用 telnet 进行测试”来做同样的事情。
网络模式
您可以从一些基本模式构建网络应用程序:
-
最常见的模式是请求-回复,也称为请求-响应或客户端-服务器。这种模式是同步的:客户端等待服务器响应。在本书中您已经看到了许多请求-响应的例子。您的 Web 浏览器也是一个客户端,向 Web 服务器发送 HTTP 请求,后者返回一个响应。
-
另一种常见模式是推送或扇出:您将数据发送到进程池中的任何可用工作进程。一个例子是负载均衡器后面的 Web 服务器。
-
推送的反义是拉取或扇入:您从一个或多个来源接受数据。一个例子是一个日志记录器,它从多个进程接收文本消息并将它们写入单个日志文件。
-
一种模式类似于广播电台或电视广播:发布-订阅,或pub-sub。使用此模式,发布者发送数据。在简单的发布-订阅系统中,所有订阅者都会收到一份副本。更常见的是,订阅者可以指示他们仅对某些类型的数据感兴趣(通常称为主题),发布者将仅发送这些数据。因此,与推送模式不同,可能会有多个订阅者收到给定数据。如果没有主题的订阅者,则数据将被忽略。
让我们展示一些请求-响应的例子,稍后再展示一些发布-订阅的例子。
请求-响应模式
这是最熟悉的模式。您可以从适当的服务器请求 DNS、Web 或电子邮件数据,它们会回复,或告诉您是否有问题。
我刚刚向你展示了如何使用 UDP 或 TCP 进行一些基本的请求,但在套接字级别上构建网络应用程序很难。让我们看看 ZeroMQ 是否可以帮助解决这个问题。
ZeroMQ
ZeroMQ 是一个库,不是一个服务器。有时被描述为增强版套接字,ZeroMQ 套接字执行的是您预期普通套接字能够执行的操作:
-
交换整个消息
-
重试连接
-
缓冲数据以在发件人和接收者之间的时间不匹配时保护它们
在线指南写得很好,富有幽默感,并且它提供了我见过的最佳网络模式描述。印刷版(ZeroMQ: Messaging for Many Applications,作者 Pieter Hintjens,出版商是 O'Reilly,上面有一条大鱼)有着良好的代码质量,但不是反过来。印刷指南中的所有示例都是用 C 语言编写的,但在线版本可以让您为每个代码示例选择多种语言,Python 的示例也可以查看。在本章中,我将向您展示一些基本的请求-回复 ZeroMQ 示例。
ZeroMQ 就像一个乐高积木套件,我们都知道您可以从几个乐高形状构建出各种惊人的东西。在这种情况下,您从几种套接字类型和模式构建网络。以下列出的基本“乐高积木”是 ZeroMQ 套接字类型,出于某种巧合,它们看起来像我们已经讨论过的网络模式:
-
REQ(同步请求)
-
REP(同步回复)
-
DEALER(异步请求)
-
ROUTER(异步回复)
-
PUB(发布)
-
SUB(订阅)
-
PUSH(扇出)
-
PULL(汇聚)
要自己尝试这些内容,您需要通过输入以下命令安装 Python ZeroMQ 库:
$ pip install pyzmq
最简单的模式是一个单一的请求-回复对。这是同步的:一个套接字发送请求,然后另一个套接字回复。首先是回复(服务器)的代码,zmq_server.py,如示例 17-5 所示。
示例 17-5. zmq_server.py
import zmq
host = '127.0.0.1'
port = 6789
context = zmq.Context()
server = context.socket(zmq.REP)
server.bind("tcp://%s:%s" % (host, port))
while True:
# Wait for next request from client
request_bytes = server.recv()
request_str = request_bytes.decode('utf-8')
print("That voice in my head says: %s" % request_str)
reply_str = "Stop saying: %s" % request_str
reply_bytes = bytes(reply_str, 'utf-8')
server.send(reply_bytes)
我们创建一个Context对象:这是一个维护状态的 ZeroMQ 对象。然后,我们创建一个类型为REP(代表回复)的 ZeroMQsocket。我们调用bind()方法使其监听特定的 IP 地址和端口。请注意,它们以字符串形式指定,如'tcp://localhost:6789',而不是像普通套接字示例中的元组。
此示例保持接收来自发送方的请求并发送响应。消息可能非常长,但 ZeroMQ 会处理这些细节。
示例 17-6 展示了相应请求(客户端)的代码,zmq_client.py。其类型为REQ(代表请求),并调用了connect()而不是bind()。
示例 17-6. zmq_client.py
import zmq
host = '127.0.0.1'
port = 6789
context = zmq.Context()
client = context.socket(zmq.REQ)
client.connect("tcp://%s:%s" % (host, port))
for num in range(1, 6):
request_str = "message #%s" % num
request_bytes = request_str.encode('utf-8')
client.send(request_bytes)
reply_bytes = client.recv()
reply_str = reply_bytes.decode('utf-8')
print("Sent %s, received %s" % (request_str, reply_str))
现在是时候启动它们了。与普通套接字示例的一个有趣区别是,你可以按任意顺序启动服务器和客户端。请在一个窗口后台启动服务器:
$ python zmq_server.py &
在同一窗口中启动客户端:
$ python zmq_client.py
你会看到客户端和服务器交替输出的这些行:
That voice in my head says 'message #1'
Sent 'message #1', received 'Stop saying message #1'
That voice in my head says 'message #2'
Sent 'message #2', received 'Stop saying message #2'
That voice in my head says 'message #3'
Sent 'message #3', received 'Stop saying message #3'
That voice in my head says 'message #4'
Sent 'message #4', received 'Stop saying message #4'
That voice in my head says 'message #5'
Sent 'message #5', received 'Stop saying message #5'
我们的客户端在发送其第五条消息后结束,但我们没有告诉服务器退出,所以它仍然在等待另一条消息。如果您再次运行客户端,它将打印相同的五行,服务器也会打印它的五行。如果您不终止 zmq_server.py 进程并尝试运行另一个,Python 将投诉地址已经在使用中。
$ python zmq_server.py
[2] 356
Traceback (most recent call last):
File "zmq_server.py", line 7, in <module>
server.bind("tcp://%s:%s" % (host, port))
File "socket.pyx", line 444, in zmq.backend.cython.socket.Socket.bind
(zmq/backend/cython/socket.c:4076)
File "checkrc.pxd", line 21, in zmq.backend.cython.checkrc._check_rc
(zmq/backend/cython/socket.c:6032)
zmq.error.ZMQError: Address already in use
消息需要以字节字符串形式发送,因此我们以 UTF-8 格式对示例的文本字符串进行了编码。您可以发送任何类型的消息,只要将其转换为 bytes。我们使用简单的文本字符串作为消息的源,因此 encode() 和 decode() 足以进行字节字符串的转换。如果您的消息具有其他数据类型,可以使用像 MessagePack 这样的库。
即使这种基本的 REQ-REP 模式也允许一些花式通信模式,因为任意数量的 REQ clients 可以 connect() 到单个 REP server。服务器一次处理一个请求,同步地,但不会丢弃同时到达的其他请求。ZeroMQ 缓冲消息,直到它们能够通过,这是它名字中 Q 的来源。Q 代表队列,M 代表消息,而 Zero 意味着不需要任何代理。
虽然 ZeroMQ 不强制使用任何中央代理(中介),但可以在需要时构建它们。例如,使用 DEALER 和 ROUTER sockets 异步连接多个源和/或目的地。
多个 REQ sockets 连接到单个 ROUTER,后者将每个请求传递给 DEALER,然后再联系连接到它的任何 REP sockets(Figure 17-1)。这类似于一堆浏览器联系位于 web 服务器群前面的代理服务器。它允许您根据需要添加多个客户端和服务器。
REQ sockets 只连接到 ROUTER socket;DEALER 则连接到它后面的多个 REP sockets。ZeroMQ 处理繁琐的细节,确保请求负载平衡,并且确保回复返回到正确的地方。
另一个网络模式称为ventilator,使用 PUSH sockets 分发异步任务,并使用 PULL sockets 收集结果。
ZeroMQ 的最后一个显著特点是,通过在创建时改变套接字的连接类型,它可以进行上下的扩展:
-
tcp是在一台或多台机器上进程之间的通信方式。 -
ipc是在同一台机器上进程之间的通信方式。 -
inproc是单个进程内线程之间的通信方式。
最后一个,inproc,是一种在不使用锁的情况下在线程之间传递数据的方式,也是在“Threads”中 threading 示例的替代方式。
图 17-1. 使用经纪人连接多个客户端和服务
使用了 ZeroMQ 后,您可能不再想编写原始套接字代码。
其他消息传递工具
ZeroMQ 绝对不是 Python 支持的唯一消息传递库。消息传递是网络中最流行的概念之一,而 Python 与其他语言保持同步:
-
Apache 项目,我们在“Apache”中看到其 Web 服务器,还维护 ActiveMQ 项目,包括使用简单文本 STOMP 协议的几个 Python 接口。
-
NATS 是一个快速的消息系统,使用 Go 编写。
发布-订阅模式
发布-订阅不是队列,而是广播。一个或多个进程发布消息。每个订阅进程指示它想接收哪些类型的消息。每条消息的副本都发送给与其类型匹配的每个订阅者。因此,给定消息可能会被处理一次,多次或根本不处理。就像一个孤独的无线电操作员一样,每个发布者只是广播,并不知道谁在听。
Redis
在第十六章中,您已经看过 Redis,主要作为数据结构服务器,但它也包含发布-订阅系统。发布者通过主题和值发送消息,订阅者指定它们想要接收的主题。
示例 17-7 包含一个发布者,redis_pub.py。
示例 17-7. redis_pub.py
import redis
import random
conn = redis.Redis()
cats = ['siamese', 'persian', 'maine coon', 'norwegian forest']
hats = ['stovepipe', 'bowler', 'tam-o-shanter', 'fedora']
for msg in range(10):
cat = random.choice(cats)
hat = random.choice(hats)
print('Publish: %s wears a %s' % (cat, hat))
conn.publish(cat, hat)
每个主题都是一种猫的品种,伴随的消息是一种帽子的类型。
示例 17-8 展示了一个单一的订阅者,redis_sub.py。
示例 17-8. redis_sub.py
import redis
conn = redis.Redis()
topics = ['maine coon', 'persian']
sub = conn.pubsub()
sub.subscribe(topics)
for msg in sub.listen():
if msg['type'] == 'message':
cat = msg['channel']
hat = msg['data']
print('Subscribe: %s wears a %s' % (cat, hat))
此订阅者希望接收所有 'maine coon' 和 'persian' 类型的消息,而不是其他类型。listen() 方法返回一个字典。如果其类型是 'message',则它是由发布者发送并符合我们的标准。 'channel' 键是主题(猫),'data' 键包含消息(帽子)。
如果您先启动发布者而没有人在监听,就像一位哑剧艺术家在森林中倒下(他会发出声音吗?),所以先启动订阅者:
$ python redis_sub.py
接下来启动发布者。它会发送 10 条消息然后退出:
$ python redis_pub.py
Publish: maine coon wears a stovepipe
Publish: norwegian forest wears a stovepipe
Publish: norwegian forest wears a tam-o-shanter
Publish: maine coon wears a bowler
Publish: siamese wears a stovepipe
Publish: norwegian forest wears a tam-o-shanter
Publish: maine coon wears a bowler
Publish: persian wears a bowler
Publish: norwegian forest wears a bowler
Publish: maine coon wears a stovepipe
订阅者只关心两种类型的猫:
$ python redis_sub.py
Subscribe: maine coon wears a stovepipe
Subscribe: maine coon wears a bowler
Subscribe: maine coon wears a bowler
Subscribe: persian wears a bowler
Subscribe: maine coon wears a stovepipe
我们没有告诉订阅者退出,所以它仍在等待消息。如果重新启动发布者,订阅者将抓取更多消息并打印它们。
您可以拥有任意数量的订阅者(和发布者)。如果没有消息的订阅者,消息会从 Redis 服务器中消失。但是,如果有订阅者,消息会一直留在服务器中,直到所有订阅者都检索到它们。
ZeroMQ
ZeroMQ 没有中央服务器,因此每个发布者都向所有订阅者写入。发布者 zmq_pub.py 提供在示例 17-9。
示例 17-9. zmq_pub.py
import zmq
import random
import time
host = '*'
port = 6789
ctx = zmq.Context()
pub = ctx.socket(zmq.PUB)
pub.bind('tcp://%s:%s' % (host, port))
cats = ['siamese', 'persian', 'maine coon', 'norwegian forest']
hats = ['stovepipe', 'bowler', 'tam-o-shanter', 'fedora']
time.sleep(1)
for msg in range(10):
cat = random.choice(cats)
cat_bytes = cat.encode('utf-8')
hat = random.choice(hats)
hat_bytes = hat.encode('utf-8')
print('Publish: %s wears a %s' % (cat, hat))
pub.send_multipart([cat_bytes, hat_bytes])
请注意,此代码如何使用 UTF-8 编码来处理主题和值字符串。
订阅者的文件名为 zmq_sub.py(例 17-10)。
例 17-10. zmq_sub.py
import zmq
host = '127.0.0.1'
port = 6789
ctx = zmq.Context()
sub = ctx.socket(zmq.SUB)
sub.connect('tcp://%s:%s' % (host, port))
topics = ['maine coon', 'persian']
for topic in topics:
sub.setsockopt(zmq.SUBSCRIBE, topic.encode('utf-8'))
while True:
cat_bytes, hat_bytes = sub.recv_multipart()
cat = cat_bytes.decode('utf-8')
hat = hat_bytes.decode('utf-8')
print('Subscribe: %s wears a %s' % (cat, hat))
在此代码中,我们订阅了两个不同的字节值:topics 中的两个字符串,编码为 UTF-8。
注意
看起来有点反向,但如果您想订阅 所有 主题,则需要订阅空字节字符串 b'';如果不这样做,您将一无所获。
请注意,在发布者中我们称之为 send_multipart(),而在订阅者中称之为 recv_multipart()。这使我们能够发送多部分消息并将第一部分用作主题。我们也可以将主题和消息作为单个字符串或字节字符串发送,但将它们分开看起来更清晰。
启动订阅者:
$ python zmq_sub.py
启动发布者。它立即发送 10 条消息然后退出:
$ python zmq_pub.py
Publish: norwegian forest wears a stovepipe
Publish: siamese wears a bowler
Publish: persian wears a stovepipe
Publish: norwegian forest wears a fedora
Publish: maine coon wears a tam-o-shanter
Publish: maine coon wears a stovepipe
Publish: persian wears a stovepipe
Publish: norwegian forest wears a fedora
Publish: norwegian forest wears a bowler
Publish: maine coon wears a bowler
订阅者打印其请求和接收的内容:
Subscribe: persian wears a stovepipe
Subscribe: maine coon wears a tam-o-shanter
Subscribe: maine coon wears a stovepipe
Subscribe: persian wears a stovepipe
Subscribe: maine coon wears a bowler
其他 Pub-Sub 工具
您可能希望探索一些其他 Python 发布-订阅链接:
-
RabbitMQ 是一个著名的消息代理,
pika是其 Python API。参见 pika 文档 和 发布-订阅教程。 -
PubSubHubbub 允许订阅者向发布者注册回调。
-
NATS 是一个快速、开源的消息系统,支持发布-订阅、请求-响应和排队。
互联网服务
Python 拥有广泛的网络工具集。在接下来的几节中,我们将探讨如何自动化一些最流行的互联网服务。官方、全面的 文档 在线可用。
域名系统
计算机具有如 85.2.101.94 这样的数字 IP 地址,但我们更容易记住名称而不是数字。域名系统(DNS)是一个关键的互联网服务,通过分布式数据库将 IP 地址与名称相互转换。每当您在使用 Web 浏览器时突然看到“looking up host”这样的消息时,您可能已经失去了互联网连接,而您的第一个线索是 DNS 失败。
一些 DNS 功能可以在低级别的 socket 模块中找到。gethostbyname() 返回域名的 IP 地址,而扩展版的 gethostbyname_ex() 返回名称、备用名称列表和地址列表:
>>> import socket
>>> socket.gethostbyname('www.crappytaxidermy.com')
'66.6.44.4'
>>> socket.gethostbyname_ex('www.crappytaxidermy.com')
('crappytaxidermy.com', ['www.crappytaxidermy.com'], ['66.6.44.4'])
getaddrinfo() 方法查找 IP 地址,但它还返回足够的信息以创建一个连接到该地址的套接字:
>>> socket.getaddrinfo('www.crappytaxidermy.com', 80)
[(2, 2, 17, '', ('66.6.44.4', 80)),
(2, 1, 6, '', ('66.6.44.4', 80))]
前面的调用返回了两个元组:第一个用于 UDP,第二个用于 TCP(2, 1, 6 中的 6 是 TCP 的值)。
您可以仅请求 TCP 或 UDP 信息:
>>> socket.getaddrinfo('www.crappytaxidermy.com', 80, socket.AF_INET,
socket.SOCK_STREAM)
[(2, 1, 6, '', ('66.6.44.4', 80))]
一些 TCP 和 UDP 端口号 由 IANA 保留,并与服务名称相关联。例如,HTTP 被命名为 http 并分配给 TCP 端口 80。
这些函数在服务名称和端口号之间进行转换:
>>> import socket
>>> socket.getservbyname('http')
80
>>> socket.getservbyport(80)
'http'
Python 电子邮件模块
标准库包含以下电子邮件模块:
-
smtplib用于通过简单邮件传输协议 (SMTP) 发送电子邮件消息 -
email用于创建和解析电子邮件消息 -
poplib用于通过邮局协议 3 (POP3) 读取电子邮件 -
imaplib用于通过互联网消息访问协议 (IMAP) 读取电子邮件
如果你想编写自己的 Python SMTP 服务器,请尝试smtpd,或者新的异步版本aiosmtpd。
其他协议
使用标准的ftplib 模块,你可以通过文件传输协议 (FTP) 传输字节。尽管这是一个老协议,FTP 仍然表现非常好。
你在本书的多个地方看到了这些模块,但也尝试一下标准库支持的互联网协议的文档。
Web 服务和 API
信息提供者总是有网站,但这些网站面向的是人类眼睛,而不是自动化。如果数据仅在网站上发布,任何想要访问和结构化数据的人都需要编写爬虫(如“爬取和解析”中所示),并在页面格式更改时重新编写。这通常很乏味。相比之下,如果一个网站提供其数据的 API,数据就可以直接提供给客户端程序。API 的更改频率比网页布局低,因此客户端重写较少。一个快速、清晰的数据管道也使得构建未曾预见但有用的组合更加容易。
在许多方面,最简单的 API 是一个 Web 接口,但提供的数据是结构化格式,如 JSON 或 XML,而不是纯文本或 HTML。API 可能很简单,也可能是完整的 RESTful API(在“Web API 和 REST”中定义),但它为这些不安静的字节提供了另一个出口。
在本书的开头,你看到了一个 Web API 查询互联网档案馆以获取旧网站的副本。
API 尤其适用于挖掘像 Twitter、Facebook 和 LinkedIn 这样的知名社交媒体网站。所有这些网站都提供免费使用的 API,但需要注册并获取一个密钥(一个长生成的文本字符串,有时也称为令牌)以在连接时使用。密钥使得网站能够确定谁在访问其数据。它还可以作为限制请求流量到服务器的一种方式。
这里有一些有趣的服务 API:
你可以在第二十一章看到地图 API 的示例,以及在第二十二章看到其他内容。
数据序列化
如你在 第十六章 中看到的,像 XML、JSON 和 YAML 这样的格式是存储结构化文本数据的方式。网络应用程序需要与其他程序交换数据。在内存中和“在传输线上”之间的数据转换称为 序列化 或 编组。JSON 是一种流行的序列化格式,特别适用于 Web RESTful 系统,但它不能直接表示所有 Python 数据类型。另外,作为文本格式,它往往比某些二进制序列化方法更冗长。让我们看看一些你可能会遇到的方法。
使用 pickle 进行序列化
Python 提供了 pickle 模块来保存和恢复任何对象到一个特殊的二进制格式。
还记得 JSON 在遇到 datetime 对象时变得混乱吗?对于 pickle 来说不是问题:
>>> import pickle
>>> import datetime
>>> now1 = datetime.datetime.utcnow()
>>> pickled = pickle.dumps(now1)
>>> now2 = pickle.loads(pickled)
>>> now1
datetime.datetime(2014, 6, 22, 23, 24, 19, 195722)
>>> now2
datetime.datetime(2014, 6, 22, 23, 24, 19, 195722)
pickle 也可以处理你自己定义的类和对象。让我们定义一个叫做 Tiny 的小类,在被当作字符串处理时返回字符串 'tiny':
>>> import pickle
>>> class Tiny():
... def __str__(self):
... return 'tiny'
...
>>> obj1 = Tiny()
>>> obj1
<__main__.Tiny object at 0x10076ed10>
>>> str(obj1)
'tiny'
>>> pickled = pickle.dumps(obj1)
>>> pickled
b'\x80\x03c__main__\nTiny\nq\x00)\x81q\x01.'
>>> obj2 = pickle.loads(pickled)
>>> obj2
<__main__.Tiny object at 0x10076e550>
>>> str(obj2)
'tiny'
pickled 是从对象 obj1 制作的 pickled 二进制字符串。我们将其转换回对象 obj2 来复制 obj1。使用 dump() 将 pickle 到文件,使用 load() 从文件中反序列化。
multiprocessing 模块使用 pickle 在进程之间交换数据。
如果 pickle 无法序列化你的数据格式,一个更新的第三方包叫做 dill 可能会有所帮助。
注意
因为 pickle 可以创建 Python 对象,所以同样适用于之前讨论过的安全警告。公共服务公告:不要反序列化你不信任的内容。
其他序列化格式
这些二进制数据交换格式通常比 XML 或 JSON 更紧凑且更快:
-
Serialize是一个 Python 前端,支持 JSON、YAML、pickle 和 MsgPack 等其他格式。
因为它们是二进制的,所以无法通过文本编辑器轻松编辑。
一些第三方包可以互相转换对象和基本的 Python 数据类型(允许进一步转换为/从 JSON 等格式),并提供以下的 验证:
-
数据类型
-
值范围
-
必需与可选数据
这些包括:
-
Pydantic——使用类型提示,因此至少需要 Python 3.6。
这些通常与 Web 服务器一起使用,以确保通过 HTTP 传输的字节最终进入正确的数据结构以供进一步处理。
远程过程调用
远程过程调用(RPC)看起来像是普通的函数,但是在网络上的远程机器上执行。与在 URL 或请求体中编码参数并调用 RESTful API 不同,您在自己的机器上调用 RPC 函数。你的本地机器:
-
将你的函数参数序列化为字节。
-
将编码的字节发送到远程机器。
远程机器:
-
接收编码的请求字节。
-
将字节反序列化回数据结构。
-
找到并调用具有解码数据的服务函数。
-
对函数结果进行编码。
-
将编码的字节发送回调用者。
最后,启动一切的本地机器:
- 解码字节以返回值。
RPC 是一种流行的技术,人们已经用许多方式实现了它。在服务器端,您启动一个服务器程序,将其连接到某些字节传输和编码/解码方法,定义一些服务函数,然后点亮您的RPC 已开放营业标志。客户端连接到服务器并通过 RPC 调用其中一个函数。
XML RPC
标准库包括一个使用 XML 作为交换格式的 RPC 实现:xmlrpc。您在服务器上定义和注册函数,客户端调用它们就像它们被导入一样。首先,让我们探索文件xmlrpc_server.py,如示例 17-11 所示。
示例 17-11. xmlrpc_server.py
from xmlrpc.server import SimpleXMLRPCServer
def double(num):
return num * 2
server = SimpleXMLRPCServer(("localhost", 6789))
server.register_function(double, "double")
server.serve_forever()
我们在服务器上提供的函数称为double()。它期望一个数字作为参数,并返回该数字乘以 2 的值。服务器在地址和端口上启动。我们需要注册函数以使其通过 RPC 对客户端可用。最后,开始服务并继续。
现在——你猜对了——xmlrpc_client.py,自豪地呈现在示例 17-12 中。
示例 17-12. xmlrpc_client.py
import xmlrpc.client
proxy = xmlrpc.client.ServerProxy("http://localhost:6789/")
num = 7
result = proxy.double(num)
print("Double %s is %s" % (num, result))
客户端通过使用ServerProxy()连接到服务器。然后,它调用函数proxy.double()。这是从哪里来的?它是由服务器动态创建的。RPC 机制神奇地将此函数名连接到对远程服务器的调用中。
试一试——启动服务器,然后运行客户端:
$ python xmlrpc_server.py
再次运行客户端:
$ python xmlrpc_client.py
Double 7 is 14
然后服务器打印如下内容:
127.0.0.1 - - [13/Feb/2014 20:16:23] "POST / HTTP/1.1" 200 -
流行的传输方法包括 HTTP 和 ZeroMQ。
JSON RPC
JSON-RPC(版本1.0和2.0)类似于 XML-RPC,但使用 JSON。有许多 Python JSON-RPC 库,但我找到的最简单的一个分为两部分:客户端和服务器端。
安装这两者都很熟悉:pip install jsonrpcserver和pip install jsonrpclient。
这些库提供了许多写客户端和服务器的替代方法。在示例 17-13 和示例 17-14 中,我使用了这个库的内置服务器,它使用端口 5000,是最简单的。
首先,服务器端。
示例 17-13. jsonrpc_server.py
from jsonrpcserver import method, serve
@method
def double(num):
return num * 2
if __name__ == "__main__":
serve()
其次,客户端。
示例 17-14. jsonrpc_client.py
from jsonrpcclient import request
num = 7
response = request("http://localhost:5000", "double", num=num)
print("Double", num, "is", response.data.result)
与本章中的大多数客户端-服务器示例一样,首先启动服务器(在其自己的终端窗口中,或者使用后面的&将其放入后台),然后运行客户端:
$ python jsonrpc_server.py &
[1] 10621
$ python jsonrpc_client.py
127.0.0.1 - - [23/Jun/2019 15:39:24] "POST / HTTP/1.1" 200 -
Double 7 is 14
如果将服务器放入后台,请在完成后将其关闭。
MessagePack RPC
编码库 MessagePack 有自己的Python RPC 实现。以下是如何安装它:
$ pip install msgpack-rpc-python
这也会安装tornado,这是一个 Python 基于事件的 Web 服务器,该库将其用作传输。和往常一样,首先启动服务器(msgpack_server.py)(示例 17-15)。
示例 17-15. msgpack_server.py
from msgpackrpc import Server, Address
class Services():
def double(self, num):
return num * 2
server = Server(Services())
server.listen(Address("localhost", 6789))
server.start()
Services类将其方法公开为 RPC 服务。继续启动客户端,msgpack_client.py(示例 17-16)。
示例 17-16. msgpack_client.py
from msgpackrpc import Client, Address
client = Client(Address("localhost", 6789))
num = 8
result = client.call('double', num)
print("Double %s is %s" % (num, result))
要运行这些,请按照通常的步骤-在单独的终端窗口中启动服务器和客户端¹,并观察结果:
$ python msgpack_server.py
$ python msgpack_client.py
Double 8 is 16
Zerorpc
由 Docker 的开发人员(当时被称为 dotCloud)编写,zerorpc使用 ZeroMQ 和 MsgPack 连接客户端和服务器。它会将函数神奇地公开为 RPC 端点。
输入pip install zerorpc来安装它。示例 17-17 和示例 17-18 中的示例代码显示了一个请求-回复客户端和服务器。
示例 17-17. zerorpc_server.py
import zerorpc
class RPC():
def double(self, num):
return 2 * num
server = zerorpc.Server(RPC())
server.bind("tcp://0.0.0.0:4242")
server.run()
示例 17-18. zerorpc_client.py
import zerorpc
client = zerorpc.Client()
client.connect("tcp://127.0.0.1:4242")
num = 7
result = client.double(num)
print("Double", num, "is", result)
客户端调用client.double(),即使在其中没有定义:
$ python zerorpc_server &
[1] 55172
$ python zerorpc_client.py
Double 7 is 14
该网站有许多其他示例。
gRPC
谷歌创建了gRPC,作为一种便捷快速定义和连接服务的方式。它将数据编码为协议缓冲区。
安装 Python 部分:
$ pip install grpcio
$ pip install grpcio-tools
Python 客户端文档非常详细,所以这里我只是简要概述。你可能也喜欢这个单独的教程。
要使用 gRPC,你需要编写一个 .proto 文件来定义一个service及其rpc方法。
一个rpc方法类似于一个函数定义(描述其参数和返回类型),并且可以指定以下其中一种网络模式:
-
请求-响应(同步或异步)
-
请求-流式响应
-
流式请求-响应(同步或异步)
-
流式请求-流式响应
单个响应可以是阻塞或异步的。流式响应会被迭代。
接下来,你将运行grpc_tools.protoc程序为客户端和服务器创建 Python 代码。gRPC 处理序列化和网络通信;你将应用特定的代码添加到客户端和服务器存根中。
gRPC 是 Web REST API 的顶级替代方案。它似乎比 REST 更适合服务间通信,而 REST 可能更适合公共 API。
Twirp
Twirp 类似于 gRPC,但声称更简单。你可以像使用 gRPC 一样定义一个.proto文件,twirp 可以生成处理客户端和服务器端的 Python 代码。
远程管理工具
-
Salt是用 Python 编写的。它起初是一种实现远程执行的方法,但发展成为一个完整的系统管理平台。基于 ZeroMQ 而不是 SSH,它可以扩展到数千台服务器。 -
Ansible软件包,像 Salt 一样是用 Python 编写的,也是类似的。它可以免费下载和使用,但支持和一些附加软件包需要商业许可证。它默认使用 SSH,不需要在要管理的机器上安装任何特殊软件。
Salt 和 Ansible 都是 Fabric 的功能超集,处理初始配置、部署和远程执行。
大数据
随着谷歌和其他互联网公司的发展,他们发现传统的计算解决方案无法满足需求。对单台机器有效的软件,甚至只是几十台机器,无法跟上数千台机器。
数据库和文件的磁盘存储涉及太多寻址,这需要磁盘头的机械移动。(想象一下黑胶唱片,以及将唱针手动从一条曲目移动到另一条曲目所需的时间。再想想当你把它掉得太重时发出的尖叫声,更不用说唱片所有者发出的声音了。)但你可以更快地流传统磁盘的连续片段。
开发人员发现,将数据分布式地分析在许多网络化的机器上比在单个机器上更快。他们可以使用听起来简单但实际上在处理海量分布式数据时效果更好的算法。其中之一是 MapReduce,它将计算分布在许多机器上,然后收集结果。这类似于使用队列。
Hadoop
谷歌在一篇论文中公布了其 MapReduce 的结果后,雅虎推出了一个名为Hadoop的开源基于 Java 的软件包(以主要程序员儿子的毛绒大象玩具命名)。
短语大数据适用于这里。通常它只是指“数据太大,无法放在我的机器上”:超过磁盘、内存、CPU 时间或以上所有的数据。对一些组织来说,如果问题中提到了大数据,答案总是 Hadoop。Hadoop 在机器之间复制数据,通过map(分散)和reduce(聚集)程序运行数据,并在每一步将结果保存在磁盘上。
这个批处理过程可能很慢。一个更快的方法叫做Hadoop streaming,类似于 Unix 管道,通过程序流式传输数据,而不需要在每一步都写入磁盘。你可以用任何语言编写 Hadoop 流式处理程序,包括 Python。
许多 Python 模块已经为 Hadoop 编写,并且一些在博文“Python 框架指南”中有所讨论。以流媒体音乐而闻名的 Spotify,开源了其 Hadoop 流处理的 Python 组件Luigi。
Spark
一位名为Spark的竞争对手被设计成比 Hadoop 快 10 到 100 倍。它可以读取和处理任何 Hadoop 数据源和格式。Spark 包括 Python 等语言的 API。你可以在网上找到安装文档。
Disco
另一个替代 Hadoop 的选择是Disco,它使用 Python 进行 MapReduce 处理,并使用 Erlang 进行通信。不幸的是,你不能用pip安装它;请参阅文档。
Dask
Dask类似于 Spark,尽管它是用 Python 编写的,并且主要与 NumPy、Pandas 和 scikit-learn 等科学 Python 包一起使用。它可以将任务分散到千台机器的集群中。
要获取 Dask 及其所有额外的帮助程序:
$ pip install dask[complete]
参见第二十二章,了解并行编程的相关示例,其中大型结构化计算分布在许多机器之间。
Clouds
我真的不了解云。
Joni Mitchell
不久以前,你会购买自己的服务器,将它们安装在数据中心的机架上,并在其上安装各种软件层:操作系统、设备驱动程序、文件系统、数据库、Web 服务器、电子邮件服务器、名称服务器、负载均衡器、监视器等等。任何最初的新奇感都会随着你试图保持多个系统的运行和响应而消失。而且你会持续担心安全问题。
许多托管服务提供了为你管理服务器的服务,但你仍然租用物理设备,并且必须始终支付峰值负载配置的费用。
随着个体机器的增多,故障不再是偶发的:它们非常普遍。你需要横向扩展服务并冗余存储数据。不能假设网络像单一机器一样运行。根据 Peter Deutsch,分布式计算的八个谬误如下:
-
网络是可靠的。
-
延迟是零。
-
带宽是无限的。
-
网络是安全的。
-
拓扑结构不会改变。
-
只有一个管理员。
-
运输成本为零。
-
网络是同质的。
你可以尝试构建这些复杂的分布式系统,但这是一项艰巨的工作,需要不同的工具集。借用一个类比,当你有一小群服务器时,你对待它们像宠物一样——给它们起名字,了解它们的个性,并在需要时照顾它们。但在规模化时,你更像对待牲畜一样对待服务器:它们看起来一样,有编号,有问题就直接更换。
而不是自己搭建,您可以在云中租用服务器。通过采用这种模式,维护工作成为了别人的问题,您可以专注于您的服务、博客或者任何您想向世界展示的内容。使用 Web 仪表板和 API,您可以快速轻松地启动具有所需配置的服务器——它们弹性。您可以监视它们的状态,并在某些指标超过给定阈值时收到警报。云目前是一个非常热门的话题,企业在云组件上的支出激增。
大型云供应商包括:
-
亚马逊 (AWS)
-
谷歌
-
Microsoft Azure
亚马逊网络服务
当亚马逊从数百台服务器增长到数千台、数百万台时,开发人员遇到了所有分布式系统的可怕问题。大约在 2002 年某一天,CEO 杰夫·贝佐斯向亚马逊员工宣布,今后所有数据和功能都必须仅通过网络服务接口公开——而不是文件、数据库或本地函数调用。他们必须设计这些接口,就像它们是向公众提供的一样。备忘录以一句激励人心的话结束:“任何不这样做的人都会被解雇。”
没有什么奇怪的,开发人员开始行动,并随着时间的推移构建了一个庞大的面向服务的架构。他们借鉴或创新了许多解决方案,发展成为亚马逊网络服务 (AWS),目前主导市场。官方的 Python AWS 库是 boto3:
使用以下命令安装:
$ pip install boto3
您可以使用 boto3 作为 AWS 的基于 Web 的管理页面的替代品。
谷歌云
谷歌在内部大量使用 Python,并雇佣了一些著名的 Python 开发者(甚至包括 Guido van Rossum 自己)。从其主页和Python页面,您可以找到其许多服务的详细信息。
Microsoft Azure
Microsoft 在其云服务提供中赶上了亚马逊和谷歌,Azure。查看Python on Azure以了解如何开发和部署 Python 应用程序。
OpenStack
OpenStack 是一个 Python 服务和 REST API 的开源框架。许多服务类似于商业云中的服务。
Docker
随着一个简单的标准化集装箱彻底改变了国际贸易。仅仅几年前,Docker 将容器这个名字和类比应用于使用一些鲜为人知的 Linux 特性的虚拟化方法。容器比虚拟机轻得多,比 Python 的虚拟环境重一点。它们允许您将应用程序与同一台机器上的其他应用程序分开打包,只共享操作系统内核。
若要安装 Docker 的 Python 客户端库:
$ pip install docker
Kubernetes
容器在计算领域迅速流行开来。最终,人们需要管理多个容器的方法,并希望自动化一些在大型分布式系统中通常需要的手动步骤:
-
故障转移
-
负载均衡
-
扩展和收缩
看起来 Kubernetes 在这个新领域的容器编排中处于领先地位。
要安装 Python 客户端 库:
$ pip install kubernetes
即将到来
正如电视上所说,我们的下一个嘉宾无需介绍。了解为什么 Python 是驯服网络的最佳语言之一。
要做的事情
17.1 使用一个普通的 socket 来实现一个当前时间服务。当客户端向服务器发送字符串 time 时,返回当前日期和时间作为 ISO 字符串。
17.2 使用 ZeroMQ 的 REQ 和 REP sockets 来做同样的事情。
17.3 尝试使用 XMLRPC 来做同样的事情。
17.4 你可能看过经典的《我爱 Lucy》电视剧集,讲述了 Lucy 和 Ethel 在巧克力工厂工作的故事。由于给她们加工的传送带开始以越来越快的速度运转,二人落后了。编写一个模拟,将不同类型的巧克力推送到一个 Redis 列表中,Lucy 是一个执行阻塞弹出此列表的客户端。她需要 0.5 秒来处理一块巧克力。打印 Lucy 收到的每块巧克力的时间和类型,以及剩下需要处理的数量。
17.5 使用 ZeroMQ 逐字发布来自练习 12.4 的诗歌(来自 示例 12-1),一次一个字。编写一个 ZeroMQ 消费者,打印每个以元音开头的单词,以及打印每个包含五个字母的单词。忽略标点符号字符。
¹ 或使用最后的 & 将服务器放在后台。
第十八章:解开网络的秘密
哦,我们编织的网……
沃尔特·斯科特,《马尔米翁》
横跨法国和瑞士边境的是 CERN——一个粒子物理研究所,多次粉碎原子,以确保。
所有这些粉碎产生了大量数据。1989 年,英国科学家蒂姆·伯纳斯-李在 CERN 内部首次提出了一个提议,帮助在那里和研究界传播信息。他称之为万维网,并将其设计简化为三个简单的想法:
HTTP(超文本传输协议)
一种用于网络客户端和服务器交换请求和响应的协议。
HTML(超文本标记语言)
结果的演示格式。
URL(统一资源定位符)
表示服务器和该服务器上的资源的唯一方式。
在其最简单的用法中,Web 客户端(我认为伯纳斯-李是第一个使用术语浏览器的人)通过 HTTP 连接到 Web 服务器,请求 URL,并接收 HTML。
所有这一切都建立在来自互联网的网络基础之上,当时互联网是非商业化的,并且仅为少数大学和研究组织所知。
他在一台 NeXT¹电脑上写了第一款网页浏览器和服务器。1993 年,当伊利诺伊大学的一群学生发布了适用于 Windows、Macintosh 和 Unix 系统的 Mosaic 网页浏览器和 NCSA httpd服务器时,网络意识真正扩展开来。那个夏天我下载了 Mosaic 并开始建站时,我完全没有想到网络和互联网很快会成为日常生活的一部分。当时互联网²仍然正式非商业化;全球约有 500 台已知的网络服务器(详见)。到 1994 年底,网络服务器数量已增长到 10,000 台。互联网开放商业化,Mosaic 的作者们创立了 Netscape 公司,开发商业化的网络软件。Netscape 随着早期互联网狂热而上市,网络的爆炸性增长从未停止。
几乎每种计算机语言都被用来编写网络客户端和服务器。动态语言 Perl、PHP 和 Ruby 尤为流行。在本章中,我展示了为何 Python 是各个层次上进行网络工作的特别优秀语言:
-
客户端,用于访问远程站点
-
服务器,为网站和 Web API 提供数据
-
Web API 和服务,以其他方式交换数据,而不仅仅是可视化的网页
顺便说一句,我们将在本章末尾的练习中构建一个实际的交互式网站。
网络客户端
互联网的低级网络管道被称为传输控制协议/因特网协议,或更常见的 TCP/IP(“TCP/IP”详细介绍了这一点)。它在计算机之间传输字节,但不关心这些字节的含义。这是高级协议的工作——专门用于特定目的的语法定义。HTTP 是网络数据交换的标准协议。
Web 是一个客户端-服务器系统。客户端向服务器发送一个请求:它通过 HTTP 打开 TCP/IP 连接,发送 URL 和其他信息,并接收一个响应。
响应的格式也由 HTTP 定义。它包括请求的状态,以及(如果请求成功)响应的数据和格式。
最著名的 Web 客户端是 Web 浏览器。它可以以多种方式发出 HTTP 请求。您可以通过在位置栏中键入 URL 或单击 Web 页面中的链接手动发出请求。返回的数据通常用于显示网站——HTML 文档、JavaScript 文件、CSS 文件和图像——但它可以是任何类型的数据,不仅仅是用于显示的数据。
HTTP 的一个重要特点是它是无状态的。您建立的每个 HTTP 连接都是彼此独立的。这简化了基本的 Web 操作,但也使其他操作变得复杂。这里只是一些挑战的样本:
缓存
不变的远程内容应该由 Web 客户端保存并使用,以避免再次从服务器下载。
会话
购物网站应该记住您购物车的内容。
认证
需要您的用户名和密码的网站在您登录时应该记住它们。
解决无状态性的方法包括cookies,服务器向客户端发送足够的特定信息,以便在客户端将 cookie 发送回来时能够唯一识别它。
使用 telnet 进行测试
HTTP 是基于文本的协议,因此您实际上可以自己输入以进行 Web 测试。古老的telnet程序允许您连接到任何运行服务的服务器和端口,并向任何正在运行的服务输入命令。对于与其他机器的安全(加密)连接,它已被ssh取代。
让我们询问每个人最喜欢的测试网站,Google,关于其主页的一些基本信息。输入以下内容:
$ telnet www.google.com 80
如果在 80 端口上有一个 Web 服务器(这是未加密的http通常运行的地方;加密的https使用 443 端口)在google.com上(我认为这是一个安全的赌注),telnet将打印一些令人放心的信息,然后显示一个最终的空行,这是您输入其他内容的信号:
Trying 74.125.225.177...
Connected to www.google.com.
Escape character is '^]'.
现在,键入一个实际的 HTTP 命令,让telnet发送给 Google Web 服务器。最常见的 HTTP 命令(当您在浏览器的位置栏中键入 URL 时使用的命令)是GET。这会检索指定资源的内容,例如 HTML 文件,并将其返回给客户端。对于我们的第一个测试,我们将使用 HTTP 命令HEAD,它只是检索资源的一些基本信息关于资源:
HEAD / HTTP/1.1
添加额外的换行符以发送空行,以便远程服务器知道您已经完成并希望得到响应。该 HEAD / 发送 HTTP HEAD 动词(命令)以获取关于首页(/)的信息。您将收到类似以下内容的响应(我使用...裁剪了一些长行,以防止其超出书籍):
HTTP/1.1 200 OK
Date: Mon, 10 Jun 2019 16:12:13 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=ISO-8859-1
P3P: CP="This is not a P3P policy! See g.co/p3phelp for more info."
Server: gws
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
Set-Cookie: 1P_JAR=...; expires=... GMT; path=/; domain=.google.com
Set-Cookie: NID=...; expires=... GMT; path=/; domain=.google.com; HttpOnly
Transfer-Encoding: chunked
Accept-Ranges: none
Vary: Accept-Encoding
这些是 HTTP 响应头及其值。像 Date 和 Content-Type 这样的头是必需的。其他头,如 Set-Cookie,用于跟踪您在多次访问中的活动(我们稍后在本章讨论状态管理)。当您发出 HTTP HEAD 请求时,您只会收到头信息。如果您使用了 HTTP GET 或 POST 命令,您还将从首页收到数据(其中包含 HTML、CSS、JavaScript 和 Google 决定加入首页的其他内容)。
我不想让你陷在 telnet 中。要关闭 telnet,请键入以下内容:
q
使用 curl 进行测试
使用 telnet 简单,但是这是一个完全手动的过程。curl 程序可能是最流行的命令行 Web 客户端。其文档包括书籍Everything Curl,提供 HTML、PDF 和电子书格式。一张表比较了 curl 与类似工具。下载页面包含了所有主要平台和许多不太常见的平台。
使用 curl 的最简单方式执行隐式 GET(此处截断了输出):
$ curl http://www.example.com
<!doctype html>
<html>
<head>
<title>Example Domain</title>
...
这使用 HEAD:
$ curl --head http://www.example.com
HTTP/1.1 200 OK
Content-Encoding: gzip
Accept-Ranges: bytes
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Date: Sun, 05 May 2019 16:14:30 GMT
Etag: "1541025663"
Expires: Sun, 12 May 2019 16:14:30 GMT
Last-Modified: Fri, 09 Aug 2013 23:54:35 GMT
Server: ECS (agb/52B1)
X-Cache: HIT
Content-Length: 606
如果要传递参数,您可以在命令行或数据文件中包含它们。在这些示例中,我使用以下内容:
-
url 适用于任何网站
-
data.txt是一个文本数据文件,内容如下:a=1&b=2 -
data.json作为 JSON 数据文件,其内容为:{"a":1, "b": 2} -
a=1&b=2作为两个数据参数
使用默认的(form-encoded)参数:
$ curl -X POST -d "a=1&b=2" *url*
$ curl -X POST -d "@data.txt" *url*
对于 JSON 编码的参数:
$ curl -X POST -d "{'a':1,'b':2}" -H "Content-Type: application/json" *url*
$ curl -X POST -d "@data.json" *url*
使用 httpie 进行测试
比 curl 更 Pythonic 的选择是httpie。
$ pip install httpie
要进行与上述 curl 方法类似的表单编码 POST,请使用 -f 作为 --form 的同义词:
$ http -f POST *url* a=1 b=2
$ http POST -f *url* < data.txt
默认编码是 JSON:
$ http POST *url* a=1 b=2
$ http POST *url* < data.json
httpie 还处理 HTTP 头部、cookie、文件上传、身份验证、重定向、SSL 等等。如往常一样,请参阅文档
使用 httpbin 进行测试
您可以针对网站httpbin测试您的 Web 查询,或者在本地 Docker 映像中下载并运行该站点:
$ docker run -p 80:80 kennethreitz/httpbin
Python 的标准 Web 库
在 Python 2 中,Web 客户端和服务器模块有点分散。Python 3 的目标之一是将这些模块打包成两个包(请记住来自第十一章的定义,包只是包含模块文件的目录):
-
http管理所有客户端-服务器 HTTP 细节:-
client处理客户端相关工作 -
server帮助你编写 Python Web 服务器 -
cookies和cookiejar管理 cookie,用于在访问站点时保存数据
-
-
urllib基于http运行:-
request处理客户端请求 -
response处理服务器响应 -
parse解析 URL 的各个部分
-
注意
如果你试图编写同时兼容 Python 2 和 Python 3 的代码,请记住urllib在这两个版本之间有很大的变化。查看更好的替代方案,请参考“超越标准库:requests”。
让我们使用标准库从网站获取一些内容。以下示例中的 URL 返回来自测试网站的信息:
>>> import urllib.request as ur
>>>
>>> url = 'http://www.example.com/'
>>> conn = ur.urlopen(url)
这段小小的 Python 代码打开了一个到远程 Web 服务器www.example.com的 TCP/IP 连接,发送了一个 HTTP 请求,并接收到了一个 HTTP 响应。响应中包含的不仅仅是页面数据。在官方文档中,我们发现conn是一个具有多个方法和属性的HTTPResponse对象。响应的一个重要部分是 HTTP 状态码:
>>> print(conn.status)
200
200表示一切顺利。HTTP 状态码分为五个范围,根据其第一个(百位)数字进行分组:
1xx(信息)
服务器已接收请求,但有一些额外的信息要传递给客户端。
2xx(成功)
成功;除了 200 之外的所有成功代码都携带额外的细节。
3xx(重定向)
资源已移动,所以响应将新的 URL 返回给客户端。
4xx(客户端错误)
客户端出现了一些问题,比如众所周知的 404(未找到)。418(我是茶壶)是愚人节玩笑。
5xx(服务器错误)
500 是通用的“哎呀”错误;如果 Web 服务器和后端应用程序服务器之间存在断开连接,则可能会看到 502(坏网关)。
要获取网页的实际数据内容,请使用conn变量的read()方法。这将返回一个bytes值。让我们获取数据并打印前 50 个字节:
>>> data = conn.read()
>>> print(data[:50])
b'<!doctype html>\n<html>\n<head>\n <title>Example D'
我们可以将这些字节转换为字符串,并打印其前 50 个字符:
>>> str_data = data.decode('utf8')
>>> print(str_data[:50])
<!doctype html>
<html>
<head>
<title>Example D
>>>
其余是更多的 HTML 和 CSS。
纯粹出于好奇,我们收到了哪些 HTTP 头部回复?
>>> for key, value in conn.getheaders():
... print(key, value)
...
Cache-Control max-age=604800
Content-Type text/html; charset=UTF-8
Date Sun, 05 May 2019 03:09:26 GMT
Etag "1541025663+ident"
Expires Sun, 12 May 2019 03:09:26 GMT
Last-Modified Fri, 09 Aug 2013 23:54:35 GMT
Server ECS (agb/5296)
Vary Accept-Encoding
X-Cache HIT
Content-Length 1270
Connection close
还记得之前的telnet示例吗?现在,我们的 Python 库正在解析所有这些 HTTP 响应头,并将它们以字典形式提供。Date和Server似乎很直观;其他一些可能不那么直观。了解 HTTP 具有一组标准头部,如Content-Type和许多可选头部,这是很有帮助的。
超越标准库:requests
在第一章的开头,有一个程序通过使用标准库urllib.request和json访问了 Wayback Machine API。接下来是一个使用第三方模块requests的版本。requests版本更简短,更易于理解。
对于大多数情况,我认为使用requests进行 Web 客户端开发更容易。你可以浏览文档(非常好),了解所有细节。我将在本节中展示requests的基础知识,并在整本书中用它来处理 Web 客户端任务。
首先,安装requests库:
$ pip install requests
现在,让我们用 requests 重新执行我们的 example.com 查询:
>>> import requests
>>> resp = requests.get('http://example.com')
>>> resp
<Response [200]>
>>> resp.status_code
200
>>> resp.text[:50]
'<!doctype html>\n<html>\n<head>\n <title>Example D'
要显示一个 JSON 查询,这是一个本章末尾出现的程序的最小版本。你提供一个字符串,它使用互联网档案馆搜索 API 来搜索那里保存的数十亿多媒体项目的标题。请注意,在 示例 18-1 中显示的 requests.get() 调用中,你只需要传递一个 params 字典,requests 将处理所有的查询构造和字符转义。
示例 18-1. ia.py
import json
import sys
import requests
def search(title):
url = "http://archive.org/advancedsearch.php"
params = {"q": f"title:({title})",
"output": "json",
"fields": "identifier,title",
"rows": 50,
"page": 1,}
resp = requests.get(url, params=params)
return resp.json()
if __name__ == "__main__":
title = sys.argv[1]
data = search(title)
docs = data["response"]["docs"]
print(f"Found {len(docs)} items, showing first 10")
print("identifier\ttitle")
for row in docs[:10]:
print(row["identifier"], row["title"], sep="\t")
他们关于食人魔物品的库存如何?
$ python ia.py wendigo
Found 24 items, showing first 10
identifier title
cd_wendigo_penny-sparrow Wendigo
Wendigo1 Wendigo 1
wendigo_ag_librivox The Wendigo
thewendigo10897gut The Wendigo
isbn_9780843944792 Wendigo mountain ; Death camp
jamendo-060508 Wendigo - Audio Leash
fav-lady_wendigo lady_wendigo Favorites
011bFearTheWendigo 011b Fear The Wendigo
CharmedChats112 Episode 112 - The Wendigo
jamendo-076964 Wendigo - Tomame o Dejame>
第一列(标识符)可以用来实际查看存档网站上的项目。你将在本章末尾看到如何做到这一点。
Web 服务器
Web 开发人员发现 Python 是编写 Web 服务器和服务器端程序的优秀语言。这导致了许多基于 Python 的 Web 框架 的出现,以至于在它们中间和做选择时可能很难导航——更不用说决定哪些应该写进一本书了。
一个 Web 框架提供了一些功能,你可以用它来构建网站,所以它不仅仅是一个简单的 Web(HTTP)服务器。你会看到一些功能,比如路由(将 URL 映射到服务器功能)、模板(包含动态内容的 HTML)、调试等等。
我不打算在这里涵盖所有的框架,只介绍那些我发现相对简单易用且适合真实网站的。我还会展示如何用 Python 运行网站的动态部分以及用传统 Web 服务器运行其他部分。
最简单的 Python Web 服务器
你可以通过输入一行 Python 来运行一个简单的 Web 服务器:
$ python -m http.server
这实现了一个简单的 Python HTTP 服务器。如果没有问题,它将打印初始状态消息:
Serving HTTP on 0.0.0.0 port 8000 ...
那个 0.0.0.0 意味着 任何 TCP 地址,所以 Web 客户端无论服务器有什么地址都可以访问它。有关 TCP 和其他网络底层细节,你可以在第十七章中进一步阅读。
现在,你可以请求文件,路径相对于你当前的目录,它们将被返回。如果在你的 Web 浏览器中输入 http://localhost:8000,你应该能看到那里的目录列表,并且服务器将打印类似这样的访问日志行:
127.0.0.1 - - [20/Feb/2013 22:02:37] "GET / HTTP/1.1" 200 -
localhost 和 127.0.0.1 是 你的本地计算机 的 TCP 同义词,所以这在无论你是否连接互联网时都能工作。你可以这样解释这一行:
-
127.0.0.1是客户端的 IP 地址 -
第一个
-是远程用户名(如果有的话) -
第二个
-是登录用户名(如果需要的话) -
[20/Feb/2013 22:02:37]是访问的日期和时间 -
"GET / HTTP/1.1"是发送给 Web 服务器的命令:-
HTTP 方法(
GET) -
所请求的资源(
/,顶层) -
HTTP 版本(
HTTP/1.1)
-
-
最后的
200是 Web 服务器返回的 HTTP 状态码
点击任何文件。如果你的浏览器能够识别格式(HTML、PNG、GIF、JPEG 等),它应该会显示它,并且服务器将记录该请求。例如,如果你的当前目录中有 oreilly.png 文件,请求 http://localhost:8000/oreilly.png 应该返回 图 20-2 中那个怪异家伙的图像,并且日志应该显示类似于这样的内容:
127.0.0.1 - - [20/Feb/2013 22:03:48] "GET /oreilly.png HTTP/1.1" 200 -
如果你的电脑相同目录下还有其他文件,它们应该显示在你的显示器上,你可以点击任何一个来下载。如果你的浏览器配置了显示该文件的格式,你会在屏幕上看到结果;否则,你的浏览器会询问你是否要下载并保存该文件。
默认使用的端口号是 8000,但你可以指定其他端口号:
$ python -m http.server 9999
你应该看到这个:
Serving HTTP on 0.0.0.0 port 9999 ...
这个仅支持 Python 的服务器最适合进行快速测试。你可以通过结束其进程来停止它;在大多数终端中,按 Ctrl+C。
你不应该将这个基本服务器用于繁忙的生产网站。传统的 Web 服务器如 Apache 和 NGINX 对于提供静态文件要快得多。此外,这个简单的服务器无法处理动态内容,而更复杂的服务器可以通过接受参数来实现这一点。
Web 服务器网关接口(WSGI)
很快,只提供简单文件的吸引力就消失了,我们需要一个能够动态运行程序的 Web 服务器。在 Web 的早期,通用网关接口(CGI)是为客户端执行 Web 服务器运行外部程序并返回结果设计的。CGI 也处理通过服务器从客户端获取输入参数传递到外部程序。然而,这些程序会为 每个 客户端访问重新启动。这种方式不能很好地扩展,因为即使是小型程序也有可观的启动时间。
为了避免这种启动延迟,人们开始将语言解释器合并到 Web 服务器中。Apache 在其 mod_php 模块中运行 PHP,Perl 在 mod_perl 中,Python 在 mod_python 中。然后,这些动态语言中的代码可以在长时间运行的 Apache 进程中执行,而不是在外部程序中执行。
另一种方法是在单独的长期运行程序中运行动态语言,并让它与 Web 服务器通信。FastCGI 和 SCGI 就是其中的例子。
Python Web 开发通过定义 Web 服务器网关接口(WSGI)迈出了一大步,这是 Python Web 应用程序与 Web 服务器之间的通用 API。本章剩余部分介绍的所有 Python Web 框架和 Web 服务器都使用 WSGI。通常情况下你不需要知道 WSGI 如何工作(其实它并不复杂),但了解一些底层组成部分的名称有助于你的理解。这是一个 同步 连接,一步接着一步。
ASGI
迄今为止,在几个地方,我已经提到 Python 正在引入像async、await和asyncio这样的异步语言特性。ASGI(异步服务器网关接口)是 WSGI 的一个对应项,它使用这些新特性。在附录 C 中,您将看到更多讨论,并且会有使用 ASGI 的新 Web 框架示例。
Apache
apache Web 服务器最好的 WSGI 模块是mod_wsgi。这可以在 Apache 进程内或与 Apache 通信的独立进程中运行 Python 代码。
如果您的系统是 Linux 或 macOS,您应该已经有apache。对于 Windows,您需要安装apache。
最后,安装您喜欢的基于 WSGI 的 Python Web 框架。我们在这里尝试bottle。几乎所有的工作都涉及配置 Apache,这可能是一门黑暗的艺术。
创建如示例 18-2 所示的测试文件,并将其保存为*/var/www/test/home.wsgi*。
示例 18-2. home.wsgi
import bottle
application = bottle.default_app()
@bottle.route('/')
def home():
return "apache and wsgi, sitting in a tree"
这次不要调用run(),因为它会启动内置的 Python Web 服务器。我们需要将变量application赋值,因为这是mod_wsgi用来连接 Web 服务器和 Python 代码的地方。
如果apache和其mod_wsgi模块正常工作,我们只需将它们连接到我们的 Python 脚本即可。我们希望在定义此apache服务器默认网站的文件中添加一行,但找到该文件本身就是一个任务。它可能是*/etc/apache2/httpd.conf*,也可能是*/etc/apache2/sites-available/default*,或者是某人宠物蝾螈的拉丁名。
现在假设您已经理解了apache并找到了那个文件。在管理默认网站的部分内添加此行:
WSGIScriptAlias / /var/www/test/home.wsgi
那一节可能看起来像这样:
<VirtualHost *:80>
DocumentRoot /var/www
WSGIScriptAlias / /var/www/test/home.wsgi
<Directory /var/www/test>
Order allow,deny
Allow from all
</Directory>
</VirtualHost>
启动apache,或者如果它已经运行,则重新启动以使用这个新配置。然后,如果您浏览到http://localhost/,您应该会看到:
apache and wsgi, sitting in a tree
这将在嵌入模式下运行mod_wsgi,作为apache本身的一部分。
您也可以将其以守护程序模式运行,作为一个或多个与apache分开的进程。为此,请在您的apache配置文件中添加两行新的指令行:
WSGIDaemonProcess *domain-name* user=*user-name* group=*group-name* threads=25
WSGIProcessGroup *domain-name*
在上述示例中,*user-name和group-name是操作系统用户和组名,domain-name*是您的互联网域名。一个最小的apache配置可能如下所示:
<VirtualHost *:80>
DocumentRoot /var/www
WSGIScriptAlias / /var/www/test/home.wsgi
WSGIDaemonProcess mydomain.com user=myuser group=mygroup threads=25
WSGIProcessGroup mydomain.com
<Directory /var/www/test>
Order allow,deny
Allow from all
</Directory>
</VirtualHost>
NGINX
NGINX Web 服务器没有内置的 Python 模块。相反,它是一个前端,用于连接到诸如 uWSGI 或 gUnicorn 之类的独立 WSGI 服务器。它们共同构成了一个非常快速和可配置的 Python Web 开发平台。
您可以从其网站安装nginx。有关使用 NGINX 和 WSGI 服务器设置 Flask 的示例,请参阅此处。
其他 Python Web 服务器
以下是一些独立的基于 Python 的 WSGI 服务器,类似于 apache 或 nginx,使用多进程和/或线程(见“并发”)处理并发请求:
这里有一些基于事件的服务器,它们使用单一进程但避免在任何单个请求上阻塞:
在关于第十五章中关于并发的讨论中,我还有更多要说的。
Web 服务器框架
Web 服务器处理 HTTP 和 WSGI 的细节,但是你使用 Web 框架来实际编写驱动站点的 Python 代码。因此,让我们先谈谈框架,然后再回到实际使用它们的网站服务的替代方法。
如果你想用 Python 写一个网站,有许多(有人说太多)Python Web 框架。Web 框架至少处理客户端请求和服务器响应。大多数主要的 Web 框架包括以下任务:
-
HTTP 协议处理
-
认证(authn,或者你是谁?)
-
认证(authz,或者你可以做什么?)
-
建立一个会话
-
获取参数
-
验证参数(必需/可选、类型、范围)
-
处理 HTTP 动词
-
路由(函数/类)
-
提供静态文件(HTML、JS、CSS、图像)
-
提供动态数据(数据库、服务)
-
返回值和 HTTP 状态
可选功能包括:
-
后端模板
-
数据库连接、ORMs
-
速率限制
-
异步任务
在接下来的章节中,我们将为两个框架(bottle 和 flask)编写示例代码。这些是同步的。稍后,我将讨论特别是用于数据库支持网站的替代方案。你可以找到一个 Python 框架来支持你可以想到的任何网站。
Bottle
Bottle 由单个 Python 文件组成,因此非常容易尝试,并且稍后易于部署。Bottle 不是标准 Python 的一部分,因此要安装它,请键入以下命令:
$ pip install bottle
这里是一个将运行一个测试 Web 服务器并在你的浏览器访问 URL http://localhost:9999/ 时返回一行文本的代码。将其保存为 bottle1.py(示例 18-3)。
示例 18-3. bottle1.py
from bottle import route, run
@route('/')
def home():
return "It isn't fancy, but it's my home page"
run(host='localhost', port=9999)
Bottle 使用 route 装饰器将 URL 关联到以下函数;在这种情况下,/(主页)由 home() 函数处理。通过键入以下命令使 Python 运行此服务器脚本:
$ python bottle1.py
当你访问http://localhost:9999/时,你应该在你的浏览器上看到这个:
It isn't fancy, but it's my home page
run() 函数执行 bottle 内置的 Python 测试 Web 服务器。对于 bottle 程序,你不需要使用它,但在初始开发和测试时很有用。
现在,不再在代码中创建主页文本,让我们创建一个名为 index.html 的单独 HTML 文件,其中包含这行文本:
My <b>new</b> and <i>improved</i> home page!!!
使bottle在请求主页时返回此文件的内容。将此脚本保存为bottle2.py(示例 18-4)。
示例 18-4. bottle2.py
from bottle import route, run, static_file
@route('/')
def main():
return static_file('index.html', root='.')
run(host='localhost', port=9999)
在调用static_file()时,我们想要的是root所指示的目录中的文件index.html(在本例中,为 '.',即当前目录)。如果你之前的服务器示例代码仍在运行,请停止它。现在,运行新的服务器:
$ python bottle2.py
当你请求浏览器获取*http:/localhost:9999/*时,你应该看到这个:
My `new` and *`improved`* home page!!!
让我们最后添加一个示例,展示如何向 URL 传递参数并使用它们。当然,这将是bottle3.py,你可以在示例 18-5 中看到它。
示例 18-5. bottle3.py
from bottle import route, run, static_file
@route('/')
def home():
return static_file('index.html', root='.')
@route('/echo/<thing>')
def echo(thing):
return "Say hello to my little friend: %s!" % thing
run(host='localhost', port=9999)
我们有一个名为echo()的新函数,并希望在 URL 中传递一个字符串参数。这就是前面示例中的@route('/echo/<thing>')一行所做的事情。路由中的<thing>意味着在/echo/之后的 URL 中的任何内容都将被分配给字符串参数thing,然后传递给echo函数。要查看发生了什么,请停止旧服务器(如果仍在运行)然后使用新代码启动它:
$ python bottle3.py
然后,在你的网络浏览器中访问*http://localhost:9999/echo/Mothra*。你应该看到以下内容:
Say hello to my little friend: Mothra!
现在,让bottle3.py运行一分钟,这样我们可以尝试其他东西。你一直通过在浏览器中输入 URL 并查看显示的页面来验证这些示例是否有效。你也可以使用诸如requests之类的客户端库来为你完成工作。将此保存为bottle_test.py(示例 18-6)。
示例 18-6. bottle_test.py
import requests
resp = requests.get('http://localhost:9999/echo/Mothra')
if resp.status_code == 200 and \
resp.text == 'Say hello to my little friend: Mothra!':
print('It worked! That almost never happens!')
else:
print('Argh, got this:', resp.text)
太棒了!现在,运行它:
$ python bottle_test.py
你应该在终端上看到这个:
It worked! That almost never happens!
这是一个单元测试的小例子。第十九章详细介绍了为什么测试很重要以及如何在 Python 中编写测试。
Bottle 还有更多功能,这里没有展示出来。特别是,你可以在调用run()时添加这些参数:
-
debug=True如果出现 HTTP 错误,则会创建一个调试页面; -
reloader=True如果更改任何 Python 代码,它会在浏览器中重新加载页面。
这在开发者网站上有详细文档。
Flask
Bottle 是一个不错的初始 Web 框架。如果你需要更多功能,可以尝试 Flask。它于 2010 年愚人节笑话开始,但是热烈的反响鼓励了作者阿尔明·罗纳赫将其制作成一个真正的框架。他将结果命名为 Flask,这是对 Bottle 的一种文字游戏。
Flask 的使用方法与 Bottle 差不多,但它支持许多在专业 Web 开发中有用的扩展,比如 Facebook 认证和数据库集成。它是我个人在 Python Web 框架中的首选,因为它在易用性和丰富功能集之间取得了平衡。
flask包含werkzeug WSGI 库和jinja2模板库。你可以从终端安装它:
$ pip install flask
让我们在 Flask 中复制最终的 Bottle 示例代码。不过,在此之前,我们需要做一些更改:
-
Flask 的默认静态文件目录主目录是
static,那里的文件 URL 也以/static开头。我们将文件夹更改为'.'(当前目录),URL 前缀更改为''(空),以便将 URL/映射到文件index.html。 -
在
run()函数中,设置debug=True还会激活自动重新加载器;bottle为调试和重新加载使用了单独的参数。
将此文件保存为flask1.py(示例 18-7)。
示例 18-7. flask1.py
from flask import Flask
app = Flask(__name__, static_folder='.', static_url_path='')
@app.route('/')
def home():
return app.send_static_file('index.html')
@app.route('/echo/<thing>')
def echo(thing):
return "Say hello to my little friend: %s" % thing
app.run(port=9999, debug=True)
然后,在终端或窗口中运行服务器:
$ python flask1.py
通过将此网址输入浏览器中来测试主页:
http://localhost:9999/
您应该看到以下内容(就像对bottle一样):
My `new` and *`improved`* home page!!!
尝试/echo端点:
http://localhost:9999/echo/Godzilla
您应该看到这个:
Say hello to my little friend: Godzilla
当调用run时将debug设置为True还有另一个好处。如果服务器代码出现异常,Flask 将返回一个格式特殊的页面,其中包含有关发生了什么错误以及在哪里的有用详细信息。更好的是,您可以输入一些命令来查看服务器程序中变量的值。
警告
不要在生产 Web 服务器中设置debug = True。这会向潜在入侵者暴露关于您服务器的过多信息。
到目前为止,Flask 示例只是复制了我们在 Bottle 中所做的事情。Flask 可以做到 Bottle 不能做到的是什么?Flask 包含jinja2,一个更广泛的模板系统。这里是如何将jinja2和 Flask 结合使用的一个小例子。
创建一个名为templates的目录,并在其中创建一个名为flask2.html的文件(示例 18-8)。
示例 18-8. flask2.html
<html>
<head>
<title>Flask2 Example</title>
</head>
<body>
Say hello to my little friend: {{ thing }}
</body>
</html>
接下来,我们编写服务器代码来获取此模板,填充我们传递给它的thing的值,并将其呈现为 HTML(我在这里省略了home()函数以节省空间)。将其保存为flask2.py(示例 18-9)。
示例 18-9. flask2.py
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/echo/<thing>')
def echo(thing):
return render_template('flask2.html', thing=thing)
app.run(port=9999, debug=True)
那个thing = thing参数意味着将一个名为thing的变量传递给模板,其值为字符串thing。
确保flask1.py没有在运行,并启动flask2.py:
$ python flask2.py
现在,输入此网址:
http://localhost:9999/echo/Gamera
您应该看到以下内容:
Say hello to my little friend: Gamera
让我们修改我们的模板,并将其保存在templates目录中,文件名为flask3.html:
<html>
<head>
<title>Flask3 Example</title>
</head>
<body>
Say hello to my little friend: {{ thing }}.
Alas, it just destroyed {{ place }}!
</body>
</html>
您可以通过多种方式将第二个参数传递给echo网址。
作为 URL 路径的一部分传递一个参数
使用此方法,您只需扩展 URL 本身。将示例 18-10 中显示的代码保存为flask3a.py。
示例 18-10. flask3a.py
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/echo/<thing>/<place>')
def echo(thing, place):
return render_template('flask3.html', thing=thing, place=place)
app.run(port=9999, debug=True)
通常情况下,如果之前的测试服务器脚本仍在运行,请先停止它,然后尝试这个新的:
$ python flask3a.py
URL 将如下所示:
http://localhost:9999/echo/Rodan/McKeesport
而且您应该看到以下内容:
Say hello to my little friend: Rodan. Alas, it just destroyed McKeesport!
或者,您可以按照示例 18-11 中显示的方式将参数作为GET参数提供;将其保存为flask3b.py。
示例 18-11. flask3b.py
from flask import Flask, render_template, request
app = Flask(__name__)
@app.route('/echo/')
def echo():
thing = request.args.get('thing')
place = request.args.get('place')
return render_template('flask3.html', thing=thing, place=place)
app.run(port=9999, debug=True)
运行新的服务器脚本:
$ python flask3b.py
这一次,使用此网址:
http://localhost:9999/echo?thing=Gorgo&place=Wilmerding
您应该得到与这里看到的相同的结果:
Say hello to my little friend: Gorgo. Alas, it just destroyed Wilmerding!
当使用GET命令访问 URL 时,任何参数都以&*`key1`*=*`val1`*&*`key2`*=*`val2`*&...的形式传递。
你还可以使用字典 ** 运算符将多个参数从单个字典传递到模板(称为 flask3c.py),如 示例 18-12 所示。
示例 18-12. flask3c.py
from flask import Flask, render_template, request
app = Flask(__name__)
@app.route('/echo/')
def echo():
kwargs = {}
kwargs['thing'] = request.args.get('thing')
kwargs['place'] = request.args.get('place')
return render_template('flask3.html', **kwargs)
app.run(port=9999, debug=True)
**kwargs 就像 thing=thing, place=place。如果有很多输入参数,这样可以节省一些输入。
jinja2 模板语言比这个做的更多。如果你用过 PHP 编程,你会看到很多相似之处。
Django
Django 是一个非常流行的 Python web 框架,特别适用于大型网站。有很多理由值得学习它,包括 Python 工作广告中对 django 经验的频繁需求。它包括 ORM 代码(我们在 “对象关系映射器(ORM)” 中讨论了 ORM)来为典型数据库的 CRUD 功能(创建、替换、更新、删除)自动生成网页,我们在 第十六章 中看过。它还包括一些自动管理页面,但这些页面设计用于程序员内部使用,而不是公共网页使用。如果你喜欢其他 ORM,比如 SQLAlchemy 或直接 SQL 查询,你也不必使用 Django 的 ORM。
其他框架
你可以通过查看这个 在线表格 来比较这些框架:
-
fastapi处理同步(WSGI)和异步(ASGI)调用,使用类型提示,生成测试页面,文档齐全。推荐使用。 -
web2py覆盖了与django类似的大部分领域,但风格不同。 -
pyramid从早期的pylons项目发展而来,与django在范围上类似。 -
turbogears支持 ORM、多个数据库和多个模板语言。 -
wheezy.web是一个性能优化的较新框架。它在最近的测试中比其他框架都要 快。 -
molten也使用类型提示,但仅支持 WSGI。 -
apistar类似于 fastapi,但更像是 API 验证工具而不是 web 框架。 -
masonite是 Python 版本的 Ruby on Rails 或 PHP 的 Laravel。
数据库框架
在计算中,网络和数据库就像花生酱和果冻一样,一个地方会找到另一个地方。在现实生活中的 Python 应用程序中,你可能需要为关系数据库提供网页界面(网站和/或 API)。
你可以用以下方法构建自己的:
-
像 Bottle 或 Flask 这样的 web 框架
-
一个数据库包,比如 db-api 或 SQLAlchemy
-
数据库驱动,比如 pymysql
相反,你可以使用像这些之一的 web/数据库包:
或者,你可以使用一个具有内置数据库支持的框架,比如 Django。
你的数据库可能不是关系型的。如果你的数据模式差异显著——不同行之间的列差异较大——考虑使用一个无模式数据库可能会更有价值,比如第十六章中讨论的某些NoSQL数据库。我曾在一个网站工作过,最初将其数据存储在一个 NoSQL 数据库中,然后切换到关系型数据库,再到另一个关系型数据库,然后再到另一个不同的 NoSQL 数据库,最后又回到一个关系型数据库。
Web Services and Automation
我们刚刚看了传统的网络客户端和服务器应用程序,消费和生成 HTML 页面。然而,网络已经被证明是将应用程序和数据粘合在一起的强大方式,支持比 HTML 更多格式的数据。
webbrowser
让我们开始一个小小的惊喜。在终端窗口中启动一个 Python 会话,并输入以下内容:
>>> import antigravity
这会秘密调用标准库的 webbrowser 模块,并将你的浏览器导向一个启发性的 Python 链接。³
你可以直接使用这个模块。这个程序会在你的浏览器中加载主 Python 网站的页面:
>>> import webbrowser
>>> url = 'http://www.python.org/'
>>> webbrowser.open(url)
True
这会在一个新窗口中打开:
>>> webbrowser.open_new(url)
True
这会在一个新标签页中打开,如果你的浏览器支持标签页的话:
>>> webbrowser.open_new_tab('http://www.python.org/')
True
webbrowser 让你的浏览器做所有的工作。
webview
webview 与 webbrowser 不同,它在自己的窗口中显示页面,使用您计算机的本地图形用户界面。要在 Linux 或 macOS 上安装:
$ pip install pywebview[qt]
对于 Windows:
$ pip install pywebview[cef]
如果你遇到问题,请查看安装说明。
这是一个示例,我访问了美国政府官方当前时间的网站:
>>> import webview
>>> url = input("URL? ")
URL? http://time.gov
>>> webview.create_window(f"webview display of {url}", url)
图 18-1 显示了我得到的结果。
图 18-1. webview 显示窗口
要停止程序,请关闭显示窗口。
Web API 和 REST
数据通常仅在网页中可用。如果你想要访问这些数据,就需要通过网络浏览器访问页面并读取它。如果网站的作者自你上次访问以来进行了任何更改,数据的位置和样式可能已经改变。
你可以通过网络应用程序编程接口(API)提供数据,而不是发布网页。客户端通过向 URL 发送请求并获取包含状态和数据的响应来访问你的服务。数据不是 HTML 页面,而是更易于程序消费的格式,比如 JSON 或 XML(关于这些格式的更多信息请参考第十六章)。
表述性状态转移(REST)由罗伊·菲尔丁在他的博士论文中定义。许多产品声称拥有REST 接口或RESTful 接口。实际上,这通常意味着它们拥有一个Web 接口——用于访问 Web 服务的 URL 定义。
一个RESTful服务以特定方式使用 HTTP 动词:
-
HEAD获取资源的信息,但不获取其数据。 -
GET从服务器检索资源的数据。这是你的浏览器使用的标准方法。GET不应该用于创建、更改或删除数据。 -
POST创建一个新的资源。 -
PUT替换现有资源,如果不存在则创建。 -
PATCH部分更新资源。 -
DELETE删除。广告里面的真相!
一个 RESTful 客户端也可以通过使用 HTTP 请求头从服务器请求一个或多个内容类型。例如,具有 REST 接口的复杂服务可能更喜欢其输入和输出为 JSON 字符串。
爬取和抓取
有时,你可能只想要一点信息——电影评分、股价或产品可用性,但你需要的信息只在由广告和无关内容环绕的 HTML 页面中。
你可以通过以下步骤手动提取你要查找的信息:
-
将 URL 输入到你的浏览器中。
-
等待远程页面加载。
-
在显示的页面中查找你想要的信息。
-
把它写在某个地方。
-
可能要重复处理相关的网址。
然而,自动化一些或所有这些步骤会更加令人满足。自动网络抓取器称为爬虫或蜘蛛。⁴ 在从远程 Web 服务器检索内容后,抓取器会解析它以在海量信息中找到所需的信息。
Scrapy
如果你需要一个强大的联合爬虫和抓取器,Scrapy值得下载:
$ pip install scrapy
这将安装模块和一个独立的命令行scrapy程序。
Scrapy 是一个框架,不仅仅是一个模块,比如BeautifulSoup。它功能更强大,但设置更复杂。想了解更多关于 Scrapy 的信息,请阅读“Scrapy 简介”和教程。
BeautifulSoup
如果你已经从网站获得了 HTML 数据,只想从中提取数据,BeautifulSoup是一个不错的选择。HTML 解析比听起来要难。这是因为公共网页上的大部分 HTML 在技术上都是无效的:未闭合的标签,不正确的嵌套以及其他复杂情况。如果你试图通过使用正则表达式(在“文本字符串:正则表达式”中讨论)编写自己的 HTML 解析器,很快就会遇到这些混乱。
要安装BeautifulSoup,输入以下命令(不要忘记最后的4,否则pip会尝试安装旧版本并且可能失败):
$ pip install beautifulsoup4
现在,让我们用它来获取网页上的所有链接。HTML a 元素表示一个链接,href 是表示链接目标的属性。在例子 18-13 中,我们将定义函数get_links()来完成这项繁重的工作,并且一个主程序来获取一个或多个 URL 作为命令行参数。
例子 18-13. links.py
def get_links(url):
import requests
from bs4 import BeautifulSoup as soup
result = requests.get(url)
page = result.text
doc = soup(page)
links = [element.get('href') for element in doc.find_all('a')]
return links
if __name__ == '__main__':
import sys
for url in sys.argv[1:]:
print('Links in', url)
for num, link in enumerate(get_links(url), start=1):
print(num, link)
print()
我把这个程序保存为links.py,然后运行这个命令:
$ python links.py http://boingboing.net
这是它打印的前几行:
Links in http://boingboing.net/
1 http://boingboing.net/suggest.html
2 http://boingboing.net/category/feature/
3 http://boingboing.net/category/review/
4 http://boingboing.net/category/podcasts
5 http://boingboing.net/category/video/
6 http://bbs.boingboing.net/
7 javascript:void(0)
8 http://shop.boingboing.net/
9 http://boingboing.net/about
10 http://boingboing.net/contact
Requests-HTML
Kenneth Reitz,流行的 Web 客户端包requests的作者,已经编写了一个名为requests-html的新抓取库(适用于 Python 3.6 及更新版本)。
它获取一个页面并处理其元素,因此你可以查找例如其所有链接或任何 HTML 元素的所有内容或属性。
它具有干净的设计,类似于requests和其他同一作者的包。总体而言,它可能比beautifulsoup或 Scrapy 更容易使用。
让我们看电影
让我们构建一个完整的程序。
它使用 Internet Archive 的 API 搜索视频。⁵ 这是为数不多允许匿名访问且在本书印刷后仍应存在的 API 之一。
注意
大多数网络 API 要求你首先获取一个API 密钥,并在每次访问该 API 时提供它。为什么?这是公地悲剧:匿名访问的免费资源经常被过度使用或滥用。这就是为什么我们不能有好东西。
在示例 18-14 中显示的以下程序执行以下操作:
-
提示你输入电影或视频标题的一部分
-
在 Internet Archive 搜索它
-
返回标识符、名称和描述的列表
-
列出它们并要求你选择其中一个
-
在你的 Web 浏览器中显示该视频
将此保存为iamovies.py。
search()函数使用requests访问 URL,获取结果并将其转换为 JSON。其他函数处理其他所有事务。你会看到列表推导、字符串切片和你在之前章节中看到的其他内容。(行号不是源代码的一部分;它们将用于练习中定位代码片段。)
示例 18-14. iamovies.py
1 """Find a video at the Internet Archive
2 by a partial title match and display it."""
3
4 import sys
5 import webbrowser
6 import requests
7
8 def search(title):
9 """Return a list of 3-item tuples (identifier,
10 title, description) about videos
11 whose titles partially match :title."""
12 search_url = "https://archive.org/advancedsearch.php"
13 params = {
14 "q": "title:({}) AND mediatype:(movies)".format(title),
15 "fl": "identifier,title,description",
16 "output": "json",
17 "rows": 10,
18 "page": 1,
19 }
20 resp = requests.get(search_url, params=params)
21 data = resp.json()
22 docs = [(doc["identifier"], doc["title"], doc["description"])
23 for doc in data["response"]["docs"]]
24 return docs
25
26 def choose(docs):
27 """Print line number, title and truncated description for
28 each tuple in :docs. Get the user to pick a line
29 number. If it's valid, return the first item in the
30 chosen tuple (the "identifier"). Otherwise, return None."""
31 last = len(docs) - 1
32 for num, doc in enumerate(docs):
33 print(f"{num}: ({doc[1]}) {doc[2][:30]}...")
34 index = input(f"Which would you like to see (0 to {last})? ")
35 try:
36 return docs[int(index)][0]
37 except:
38 return None
39
40 def display(identifier):
41 """Display the Archive video with :identifier in the browser"""
42 details_url = "https://archive.org/details/{}".format(identifier)
43 print("Loading", details_url)
44 webbrowser.open(details_url)
45
46 def main(title):
47 """Find any movies that match :title.
48 Get the user's choice and display it in the browser."""
49 identifiers = search(title)
50 if identifiers:
51 identifier = choose(identifiers)
52 if identifier:
53 display(identifier)
54 else:
55 print("Nothing selected")
56 else:
57 print("Nothing found for", title)
58
59 if __name__ == "__main__":
60 main(sys.argv[1])
这是我运行此程序并搜索**eegah**时得到的结果:⁶
$ python iamovies.py eegah
0: (Eegah) From IMDb : While driving thro...
1: (Eegah) This film has fallen into the ...
2: (Eegah) A caveman is discovered out in...
3: (Eegah (1962)) While driving through the dese...
4: (It's "Eegah" - Part 2) Wait till you see how this end...
5: (EEGAH trailer) The infamous modern-day cavema...
6: (It's "Eegah" - Part 1) Count Gore De Vol shares some ...
7: (Midnight Movie show: eegah) Arch Hall Jr...
Which would you like to see (0 to 7)? 2
Loading https://archive.org/details/Eegah
它在我的浏览器中显示了页面,准备运行(图 18-2)。
图 18-2. 电影搜索结果
即将发生的事情
下一章是一个非常实用的章节,涵盖现代 Python 开发的方方面面。学习如何成为一个眼明手快、持有 Python 会员卡的 Pythonista。
要做的事情
18.1 如果你还没有安装flask,请立即安装。这也会安装werkzeug、jinja2和可能的其他包。
18.2 使用 Flask 的调试/重新加载开发 Web 服务器构建一个骨架网站。确保服务器在默认端口5000上为主机名localhost启动。如果你的计算机已经在使用端口 5000 进行其他操作,请使用其他端口号。
18.3 添加一个home()函数来处理对主页的请求。设置它返回字符串It's alive!。
18.4 创建一个名为home.html的 Jinja2 模板文件,内容如下:
<html>
<head>
<title>It's alive!</title>
<body>
I'm of course referring to {{thing}}, which is {{height}} feet tall and {{color}}.
</body>
</html>
18.5 修改你的服务器的home()函数,使用home.html模板。为其提供三个GET参数:thing、height和color。
¹ 史蒂夫·乔布斯在被迫离开苹果期间创立的一家公司。
² 让我们在这里揭穿一个僵尸谎言。参议员(后来是副总统)阿尔·戈尔倡导了大力推动早期互联网发展的两党立法和合作,包括为编写 Mosaic 的团队提供资金。他从未声称自己“发明了互联网”;这个短语是在 2000 年他开始竞选总统时由政治对手错误地归因给他的。
³ 如果出现某种原因没有看到它,请访问xkcd。
⁴ 对恐蜘蛛症患者来说,这些术语可能不吸引人。
⁵ 如果你还记得,在我们在第一章中看到的主要示例程序中,我使用了另一个档案 API。
⁶ 由理查德·基尔饰演穴居人,这是他在邦德电影中扮演钢牙前的多年。