赞
踩
本书的前四章介绍了如何在 IP 网络上命名主机,以及如何建立和拆除主机之间的 TCP 流和 UDP 数据报连接。但是,您应该如何准备传输数据呢?应该如何编码和格式化?Python 程序需要为哪种错误做准备?
无论您使用的是数据流还是数据报,这些问题都是相关的,本章提供了所有这些问题的基本答案。
字节和字符串
计算机内存芯片和网卡都支持将字节作为通用货币。这个微小的 8 位信息包已经成为我们的全球信息存储单位。然而,内存芯片和网卡是有区别的。Python 能够完全隐藏程序运行时它在内存中如何表示数字、字符串、列表和字典的选择。除非您使用特殊的调试工具,否则您甚至看不到存储这些数据结构的字节,只能从外部看到它们的行为。
网络通信是不同的,因为套接字接口公开了字节,并使它们对程序员和应用都可见。在进行网络编程时,您通常不可避免地要考虑数据将如何在网络上表示,这就提出了 Python 这样的高级语言可以避免的问题。
所以,现在让我们考虑字节的属性。
这些比特需要被排序,这样你就可以区分哪个是哪个。当你像01100001
一样写二进制数时,你按照和写十进制数相同的方向排列数字,首先是最高有效位(就像在十进制数 234 中,2 是最高有效位,4 是最低有效位,因为百位比十位或一位对数字的大小影响更大)。
将单字节解释为 00000000 到 1111111 之间的数字是一种方法。如果你算一下,这是十进制的值 0 和 255。
您还可以将 0 到 255 范围内的最高字节值解释为负数,因为您可以通过从 0 向后回绕来达到它们。常见的选择是将 10000000 到 11111111(通常是 128 到 255)解释为-128 到-1,因为这样最高有效位会告诉您该数字是否为负数。(这被称为二进制补码运算。)或者,您可以使用各种更复杂的规则来解释一个字节,这些规则可以通过表格的方式将一些符号或含义分配给该字节,或者通过将该字节与其他字节放在一起来构建更大的数字。
网络标准使用术语八位字节表示 8 位字节,因为在过去,一个字节在不同的计算机上可以有各种不同的长度。
在 Python 中,通常以两种方式之一来表示字节:要么表示为值恰好在 0 到 255 之间的整数,要么表示为长度为 1 的字节字符串,其中字节是它包含的单个值。您可以使用 Python 源代码中支持的任何典型基数(二进制、八进制、十进制和十六进制)键入字节值数字。
>>> 0b1100010
98
>>> 0b1100010 == 0o142 == 98 == 0x62
True
您可以通过将这些数字传递给序列中的bytes()
类型来将它们转换成字节字符串,并且可以通过尝试遍历字节字符串来转换回来。
>>> b = bytes([0, 1, 98, 99, 100])
>>> len(b)
5
>>> type(b)
<class 'bytes'>
>>> list(b)
[0, 1, 98, 99, 100]
可能有点混乱的是,字节字符串对象的repr()
使用 ASCII 字符作为数组元素的简写,这些数组元素的字节值恰好对应于可打印的字符代码,并且它只对不对应于可打印的 ASCII 字符的字节使用显式十六进制格式\xNN
。
>>> b
b'\x00\x01bcd'
但是,不要被愚弄了:字节串在它们的语义上根本就不是 ASCII 码,它们只是用来表示 8 位字节的序列。
字符串
如果您真的想通过一个套接字传输一串符号,您需要一种将每个符号分配给一个有效字节值的编码。最流行的这种编码是 ASCII,代表美国信息交换标准码,它定义了 0 到 127 的字符码,可以放入 7 位。因此,当 ASCII 以字节存储时,最高有效位始终为零。代码 0 到 31 代表输出显示的控制命令,而不是实际的字形,如字母、数字和标点符号,因此它们不能显示在如下的快速图表中。如您所见,代表字形的 ASCII 字符的三个后续 32 字符层是第一层标点和数字,然后是包含大写字母的层,最后是小写字母层:
>>> for i in range(32, 128, 32):
... print(' '.join(chr(j) for j in range(i, i+32)))
...
! " # $ % & ' ( ) * + , - . / 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
@ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \] ^ _
` a b c d e f g h i j k l m n o p q r s t u v w x y z { | } ~
顺便说一下,左上角的字符是字符代码 32 处的空格。(奇怪的是,右下角的不可见字符是最后一个控制字符:127 位的 Delete。)请注意这个 1960 年的编码中的两个巧妙之处。首先,数字是有序的,这样您就可以通过减去数字零的代码来计算任何数字的数学值。此外,通过翻转 32 位,您可以在大写和小写字母之间切换,或者通过设置或清除整串字母的 32 位来强制字母变成一种或另一种大小写。
但是 Python 3 在其字符串可以包含的字符代码方面远远超出了 ASCII。多亏了一个更近的名为 Unicode 的标准,我们现在有了超过 128 个 ASCII 码的数字的字符代码分配,甚至可以达到数千甚至数百万。Python 认为字符串是由一系列 Unicode 字符组成的,和 Python 数据结构一样,当您使用这种语言时,Python 字符串在 RAM 中的实际表示形式会被小心地隐藏起来。但是当处理文件中或网络上的数据时,您将不得不考虑外部表示和两个术语,这两个术语有助于您清楚地了解信息的含义以及信息是如何传输或存储的:
如果您认为外部世界是由存储在密码中的字节组成的,如果您的 Python 程序要正确处理这些字节,那么它可能会帮助您记住这些单词所指的转换。要将数据移出 Python 程序,它必须成为代码;要搬回来,必须解码。
当今世界上有许多可能的编码在使用。它们分为两大类。
最简单的编码是单字节编码 ,它最多可以表示 256 个单独的字符,但保证每个字符都适合一个字节。这些在编写网络代码时很容易使用。例如,您提前知道从套接字读取 n 字节将生成 n 个字符,并且您还知道当一个流被分割成多个部分时,每个字节都是一个独立的字符,可以安全地对其进行解释,而不需要知道后面是什么字节。此外,通过查看第 n 个字节,您可以立即查找输入中的字符 n 。
多字节编码 更加复杂,并且失去了这些优点。有些,如 UTF-32,每个字符使用固定的字节数,当数据主要由 ASCII 字符组成时,这是一种浪费,但好处是每个字符的长度总是相同的。其他的,像 UTF-8,每个字符占用不同的字节数,因此需要非常小心;如果数据流是分段传送的,那么就没有办法提前知道一个字符是否已经被跨边界分割,如果不从头开始读,直到你读完那么多字符,你就找不到字符 n 。
通过查找codecs
模块的标准库文档,可以找到 Python 支持的所有编码的列表。
Python 中内置的大多数单字节编码都是 ASCII 的扩展,它们将剩余的 128 个值用于特定于地区的字母或符号:
>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('latin1')
'ghiçèé'
>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('latin2')
'ghiç
é'
>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('greek')
'ghiηθι'
>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('hebrew')
标准库中列出的许多 Windows 代码页也是如此。然而,一些单字节编码与 ASCII 毫无共同之处,因为它们是基于过去大型 IBM 大型机的替代标准。
>>> b'\x67\x68\x69\xe7\xe8\xe9'.decode('EBCDIC-CP-BE')
'ÅÇÑXYZ'
您最有可能遇到的多字节编码是旧的 UTF-16 方案(它曾有过短暂的全盛时期,当时 Unicode 要小得多,可以容纳 16 位)、现代的 UTF-32 方案和普遍流行的变宽 UTF-8,它看起来像 ASCII,除非您开始包含代码大于 127 的字符。下面是使用这三种格式的 Unicode 字符串的样子:
>>> len('Namárië!')
8
>>> 'Namárië!'.encode('UTF-16')
b'\xff\xfeN\x00a\x00m\x00\xe1\x00r\x00i\x00\xeb\x00!\x00'
>>> len(_)
18
>>> 'Namárië!'.encode('UTF-32')
b'\xff\xfe\x00\x00N\x00\x00\x00a\x00\x00\x00m\x00\x00\x00\xe1\x00\x00\x00r\x00\x00\x00i\x00\x00\x00\xeb\x00\x00\x00!\x00\x00\x00'
>>> len(_)
36
>>> 'Namárië!'.encode('UTF-8')
b'Nam\xc3\xa1ri\xc3\xab!'
>>> len(_)
10
如果您仔细查看每个编码,您应该能够找到散布在代表非 ASCII 字符的字节值中的简单 ASCII 字母N
、a
、m
、r
和i
。
请注意,多字节编码各包含一个额外字符,使 UTF-16 编码达到完整的(8 × 2) + 2 字节,UTF-32 编码达到(8 × 4) + 4 字节。这个特殊字符\xfeff
是字节顺序标记(BOM ),可以让读者自动检测每个 Unicode 字符的几个字节是以最高有效字节还是最低有效字节优先存储。(有关字节顺序的更多信息,请参见下一节。)
在处理编码文本时,您会遇到两种典型的错误:试图从实际上不遵循您试图解释的编码规则的编码字节字符串中加载,以及试图对实际上无法在您请求的编码中表示的字符进行编码。
>>> b'\x80'.decode('ascii')
Traceback (most recent call last):
...
UnicodeDecodeError: 'ascii' codec can't decode byte 0x80 in position 0: ordinal not in range(128)
>>> 'ghiηθι'.encode('latin-1')
Traceback (most recent call last):
...
UnicodeEncodeError: 'latin-1' codec can't encode characters in position 3-5: ordinal not in range(256)
通常,您会希望通过确定您使用了错误的编码或找出数据不符合预期编码的原因来修复此类错误。然而,如果这两种修复都不起作用,并且您发现您的代码必须在声明的编码和实际的字符串和数据不匹配的情况下正常运行,那么您将希望阅读标准库文档来了解错误的替代方法,而不是必须处理异常。
>>> b'ab\x80def'.decode('ascii', 'replace')
'ab⍰def'
>>> b'ab\x80def'.decode('ascii', 'ignore')
'abdef'
>>> 'ghiηθι'.encode('latin-1', 'replace')
b'ghi???'
>>> 'ghiηθι'.encode('latin-1', 'ignore')
b'ghi'
这些在标准库文档中有关于codecs
模块的描述,你也可以在 Doug Hellman 的关于codecs
的每周 Python 模块条目中找到更多的例子。
请再次注意,如果您使用的编码使用多个字节对一些字符进行编码,则对部分接收的消息进行解码是很危险的,因为这些字符中的一个可能已经在您已经接收到的消息部分和尚未到达的数据包之间进行了拆分。请参阅本章后面的“框架和引用”部分,了解解决此问题的一些方法。
二进制数和网络字节顺序
如果您想要通过网络发送的只是文本,那么编码和组帧(您将在下一节处理)将是您唯一的担忧。
但是,有时您可能希望用比文本更紧凑的格式来表示数据。或者,您可能正在编写 Python 代码来与已经选择使用原始二进制数据的服务进行交互。无论是哪种情况,您都可能不得不开始担心一个新问题:网络字节顺序。
为了理解字节顺序的问题,考虑通过网络发送整数的过程。具体来说,想想整数 4253。
当然,许多协议只是将这个整数作为字符串'4253'
来传输,也就是说,作为四个不同的字符。至少在任何通常的文本编码中,这四个数字将需要至少四个字节来传输。使用十进制数字还会涉及一些计算开销:由于数字不是以 10 为基数存储在计算机中的,所以传输该值的程序需要反复除法——检查余数——才能确定这个数字实际上是由 4 千、2 百、5 十和 3 余数组成的。当收到四位数的字符串'4253'
时,需要重复加法和乘以 10 的幂次,才能将文本重新组合成一个数字。
尽管冗长,使用纯文本表示数字的技术实际上可能是当今互联网上最流行的。例如,每次获取一个网页时,HTTP 协议使用一串十进制数字来表示结果的内容长度,就像'4253'
一样。web 服务器和客户机都不假思索地进行十进制转换,尽管会有一些开销。事实上,过去 20 年网络的大部分故事都是用简单、明显和人类可读的协议取代密集的二进制格式——即使与它们的前辈相比计算成本很高。
当然,现代处理器上的乘法和除法也比二进制格式更普遍的时候便宜——这不仅是因为处理器的速度大幅提高,还因为它们的设计者在实现整数数学方面变得更加聪明,因此今天相同的运算所需的周期比 20 世纪 80 年代初的处理器少得多。
在任何情况下,字符串'4253'
都不是你的计算机在 Python 中将这个数表示为整数变量的方式。相反,它会将其存储为二进制数,使用几个连续字节的位来表示单个大数的 1 位、2 位、4 位等等。您可以通过在 Python 提示符下使用hex()
内置函数来了解整数的存储方式。
>>> hex(4253)
'0x109d'
每个十六进制数字对应四位,因此每对十六进制数字代表一个字节的数据。不是存储为四个十进制数字(4、4、2 和 3),前 4 个是“最高有效”数字(因为调整它的值会使数字偏离 1000),3 是其最低有效数字,而是将数字存储为一个最高有效字节0x10
和一个最低有效字节0x9d
,在内存中彼此相邻。
但是这两个字节应该以什么顺序出现呢?在这里,我们看到了不同品牌的计算机处理器架构之间的巨大差异。虽然他们都同意内存中的字节有一个顺序,并且他们都将按照从C
开始到3
结束的顺序存储像Content-Length: 4253
这样的字符串,但是他们对于二进制数的字节应该存储的顺序没有一个共同的想法。
我们这样描述区别:一些计算机是“大端的”(例如,较老的 SPARC 处理器),把最高有效字节放在第一位,就像我们写十进制数字一样;其他计算机(如几乎无处不在的 x86 体系结构)是“小端”的,将最低有效字节放在第一位(其中“第一”意味着“在具有较低内存地址的字节上”)。
要从有趣的历史角度来看待这个问题,一定要读一读丹尼·科恩的论文 IEN-137,“论圣战和对和平的呼吁”,其中引入了大端和小端这两个词,是对乔纳森·斯威夫特的模仿:www.ietf.org/rfc/ien/ien137.txt
。
Python 很容易看出这两种字节序的区别。只需使用struct
模块,该模块提供了多种将数据与流行的二进制格式相互转换的操作。下面是首先以小端格式表示,然后以大端顺序表示的数字4253
:
>>> import struct
>>> struct.pack('<i', 4253)
b'\x9d\x10\x00\x00'
>>> struct.pack('>i', 4253)
b'\x00\x00\x10\x9d'
这里我使用了结构格式化代码’i'
,它使用四个字节来存储一个整数,对于像4253
这样的小数字,它将两个高位字节保留为零。您可以将这两个订单的struct
字符顺序代码'<'
和'>'
想象成指向一个字节串的最低有效端的小箭头,如果这有助于您记住使用哪一个的话。参见标准库中的struct
模块文档,了解其支持的所有数据格式。它还支持一个unpack()
操作,将二进制数据转换回 Python 数字。
>>> struct.unpack('>i', b'\x00\x00\x10\x9d')
(4253,)
如果 big-endian 格式直观上对您更有意义,那么您可能会高兴地得知,它“赢得了”将成为网络数据标准的 endianness 的竞赛。因此,struct
模块提供了另一个符号,'!'
,它与pack()
和unpack()
中的'>'
意思相同,但是对其他程序员说(当然,当你稍后阅读代码时对你自己说),“我正在打包这些数据,以便我可以通过网络发送它们。”
总之,我对准备通过网络套接字传输二进制数据有以下建议:
struct
模块生成二进制数据,以便在网络上传输,并在到达时将其解包。'!'
前缀的网络字节顺序。'<'
来代替。始终测试struct
,看看它如何将您的数据与您正在使用的协议规范进行比较;注意,打包格式字符串中的'x'
字符可以用来插入填充字节。
您可能会看到较老的 Python 代码使用了来自socket
模块的一系列命名混乱的函数,以便按照网络顺序将整数转换成字节串。这些函数的名字类似于ntohl()
和htons()
,它们对应于 POSIX 网络库中的同名函数,POSIX 网络库中还提供了socket()
和bind()
等调用。建议你忽略这些笨拙的函数,改用struct
模块;它更灵活,更通用,并且产生更可读的代码。
框架和引用
如果您使用 UDP 数据报进行通信,那么协议本身会以离散的、可识别的数据块来传送您的数据。然而,如果网络出现任何问题,你将不得不自己重新排序并重新传输这些数据块,如第二章所述。
然而,如果你选择了使用 TCP 流进行通信这一更常见的选项,那么你将面临框架 — 的问题,即如何界定你的消息,以便接收方能够知道一条消息在哪里结束,下一条消息在哪里开始。由于您提供给sendall()
的数据可能会被分解成几个数据包,以便在网络上进行实际传输,因此接收您的消息的程序可能需要进行几次recv()
调用才能读取整个消息——或者,如果在操作系统有机会再次调度该进程时所有数据包都已到达,则可能不会!
帧的问题提出了这样一个问题:当接收者最终停止调用recv()
是安全的,因为整个消息或数据已经完整无缺地到达,并且现在可以作为一个整体来解释或处理它?
正如您可能想象的那样,有几种方法。
首先,有一种模式可以被非常简单的网络协议使用,这种协议只涉及数据的传递— 不期望有响应,所以永远不会出现接收方决定“够了!”并转过身来发送响应。在这种情况下,发送方可以循环,直到所有的输出数据都被传递到套接字的sendall()
和close()
。接收者只需要重复调用recv()
,直到调用最终返回一个空字符串,表明发送者最终关闭了套接字。你可以在清单 5-1 中看到这种模式。
清单 5-1 。只需发送所有数据,然后关闭连接
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter05/streamer.py # Client that sends data then closes the socket, not expecting a reply. import socket from argparse import ArgumentParser def server(address): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(address) sock.listen(1) print('Run this script in another window with "-c" to connect') print('Listening at', sock.getsockname()) sc, sockname = sock.accept() print('Accepted connection from', sockname) sc.shutdown(socket.SHUT_WR) message = b'' while True: more = sc.recv(8192) # arbitrary value of 8k if not more: # socket has closed when recv() returns '' print('Received zero bytes - end of file') break print('Received {} bytes'.format(len(more))) message += more print('Message:\n') print(message.decode('ascii')) sc.close() sock.close() def client(address): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(address) sock.shutdown(socket.SHUT_RD) sock.sendall(b'Beautiful is better than ugly.\n') sock.sendall(b'Explicit is better than implicit.\n') sock.sendall(b'Simple is better than complex.\n') sock.close() if __name__ == '__main__': parser = ArgumentParser(description='Transmit & receive a data stream') parser.add_argument('hostname', nargs='?', default='127.0.0.1', help='IP address or hostname (default: %(default)s)') parser.add_argument('-c', action='store_true', help='run as the client') parser.add_argument('-p', type=int, metavar='port', default=1060, help='TCP port number (default: %(default)s)') args = parser.parse_args() function = client if args.c else server function((args.hostname, args.p))
如果您将此脚本作为服务器运行,然后在另一个命令提示符下运行客户端版本,您将会看到所有客户端数据都完好无损地传送到服务器,由客户端关闭套接字生成的文件结束事件是唯一需要的帧。
$ python streamer.py
Run this script in another window with "-c" to connect
Listening at ('127.0.0.1', 1060)
Accepted connection from ('127.0.0.1', 49057)
Received 96 bytes
Received zero bytes - end of file
Message:
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
请注意,由于这个套接字并不打算接收任何数据,因此客户端和服务器都会继续关闭它们不打算使用的方向上的通信。这防止了套接字在另一个方向上的任何意外使用——这种使用最终可能会将足够多的未读数据排队以产生死锁,正如你在第三章的中的清单 3-2 中看到的那样。只有客户机或服务器需要调用套接字上的shutdown()
,但是从两个方向都这样做可以提供对称性和冗余性。
第二种模式是第一种模式的变体:双向流动。插座最初在两个方向上都是开放的。首先,数据以一个方向流动——正如清单 5-1 中的所示——然后那个方向被关闭。其次,数据以另一个方向流动,套接字最终关闭。第三章中的清单 3-2 再次说明了一个重要的警告:在返回到另一个方向之前,一定要完成一个方向的数据传输,否则你可能会产生一个死锁的客户机和服务器。
第三种模式,也在第三章中有说明,是使用固定长度的消息,如清单 3-1 所示。您可以使用 Python sendall()
方法来传输您的字节串,然后使用您自己设计的recv()
循环来确保您接收到完整的消息。
def recvall(sock, length):
data = ''
while len(data) < length:
more = sock.recv(length - len(data))
if not more:
raise EOFError('socket closed {} bytes into a {}-byte'
' message'.format(len(data), length))
data += more
return data
固定长度的消息有点少见,因为现在似乎很少有数据适合静态边界。然而,特别是在传输二进制数据时(例如,考虑一种总是产生相同长度的数据块的struct
格式),您可能会发现它非常适合某些情况。
第四种模式是用特殊字符分隔消息。接收者将在一个类似刚才所示的recv()
循环中等待,但不会退出循环,直到它累积的回复字符串最终包含指示消息结束的定界符。如果消息中的字节或字符保证在某个有限的范围内,那么显而易见的选择是用从该范围之外选择的符号来结束每条消息。例如,如果您正在发送 ASCII 字符串,您可能会选择空字符'\0'
作为分隔符,或者选择一个完全在 ASCII 范围之外的字符,如'\xff'
。
相反,如果消息可以包含任意数据,,那么使用分隔符就成了一个问题:如果您试图用作分隔符的字符出现在数据中怎么办?答案当然是引用——就像必须在 Python 字符串中间将单引号字符表示为\'
,而该字符串本身是由单引号字符分隔的。
'All\'s well that ends well.'
然而,我建议只在消息字母表受到限制的情况下使用定界符方案;如果您必须处理任意数据,实现正确的引用和取消引用通常太麻烦了。首先,您对分隔符是否到达的测试必须确保您不会将带引号的分隔符与真正结束消息的分隔符混淆。第二个复杂性是,您必须传递消息以删除保护分隔符文本出现的引号字符。最后,这意味着消息长度无法测量,直到您执行了解码;长度为 400 的消息可能有 400 个符号长,也可能有 200 个定界符实例,并带有引号,或者介于两者之间。
第五种模式是给每个消息加上其长度前缀。这是高性能协议的普遍选择,因为二进制数据块可以逐字发送,而无需分析、引用或插入。当然,长度本身必须使用前面给出的技术之一来构造,通常长度是一个简单的固定宽度的二进制整数,或者是一个可变长度的十进制字符串,后跟一个文本分隔符。无论哪种方式,一旦长度被读取和解码,接收者可以进入一个循环并重复调用recv()
直到整个消息到达。这个循环看起来和清单 3-1 中的一模一样,但是用一个长度变量代替了数字 16。
最后,如果您想要第五种模式的简单性和效率,但是您事先不知道每条消息的长度—可能是因为发送者正在从他们无法预测长度的源读取数据,该怎么办?在这种情况下,您是否必须放弃优雅,在数据中苦苦寻找分隔符?
如果使用第六种也是最后一种模式,未知长度没有问题。不要只发送一个,试着发送几个数据块,每个数据块都以长度为前缀。这意味着,当每个新信息块对发送者可用时,它可以用它的长度来标记,并放在输出流上。当结尾最终到达时,发送方可以发出一个商定的信号——可能是一个长度字段,给出数字 0——告诉接收方这一系列块已经完成。
这个想法的一个简单例子显示在清单 5-2 中。和前面的清单一样,它只向一个方向发送数据——从客户机到服务器——但是数据结构比前面的清单有趣得多。每条消息都以包含在struct
中的 4 字节长度为前缀。由于'I'
表示 32 位无符号整数,每个帧的长度可以达到 4GB。此示例代码向服务器发送一系列三个块,后面跟一个零长度消息,这只是一个长度字段,里面有零,后面没有消息数据,以表示这一系列块已经结束。
清单 5-2 。通过在每个数据块前面加上其长度来构造数据块
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter05/blocks.py # Sending data over a stream but delimited as length-prefixed blocks. import socket, struct from argparse import ArgumentParser header_struct = struct.Struct('!I') # messages up to 2**32 - 1 in length def recvall(sock, length): blocks = [] while length: block = sock.recv(length) if not block: raise EOFError('socket closed with %d bytes left' ' in this block'.format(length)) length -= len(block) blocks.append(block) return b''.join(blocks) def get_block(sock): data = recvall(sock, header_struct.size) (block_length,) = header_struct.unpack(data) return recvall(sock, block_length) def put_block(sock, message): block_length = len(message) sock.send(header_struct.pack(block_length)) sock.send(message) def server(address): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(address) sock.listen(1) print('Run this script in another window with "-c" to connect') print('Listening at', sock.getsockname()) sc, sockname = sock.accept() print('Accepted connection from', sockname) sc.shutdown(socket.SHUT_WR) while True: block = get_block(sc) if not block: break print('Block says:', repr(block)) sc.close() sock.close() def client(address): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(address) sock.shutdown(socket.SHUT_RD) put_block(sock, b'Beautiful is better than ugly.') put_block(sock, b'Explicit is better than implicit.') put_block(sock, b'Simple is better than complex.') put_block(sock, b'') sock.close() if __name__ == '__main__': parser = ArgumentParser(description='Transmit & receive blocks over TCP') parser.add_argument('hostname', nargs='?', default='127.0.0.1', help='IP address or hostname (default: %(default)s)') parser.add_argument('-c', action='store_true', help='run as the client') parser.add_argument('-p', type=int, metavar='port', default=1060, help='TCP port number (default: %(default)s)') args = parser.parse_args() function = client if args.c else server function((args.hostname, args.p))
注意你必须多小心!即使 4 字节长度字段是如此之小的数据量,以至于您可能无法想象recv()
不会一次返回所有数据,但是只有当您小心地将recv()
包装在一个循环中时,代码才是正确的,这个循环(以防万一)将一直要求更多的数据,直到所有四个字节都到达。在编写网络代码时,这种谨慎是必要的。
因此,您至少有六种选择来将无止境的数据流分割成可理解的块,以便客户端和服务器知道消息何时完成,并可以转身响应。请注意,许多现代协议将它们混合在一起,您可以自由地做同样的事情。
不同框架技术之间混搭的一个很好的例子是 HTTP 协议,,在本书的后面你会学到更多。它使用一个分隔符——空行'\r\n\r\n'
—来表示它的标题何时完成。因为标题是文本,所以这样可以安全地将行尾作为特殊字符处理。但是,由于实际的有效负载可以是纯二进制数据,比如图像或压缩文件,所以在头文件中提供了一个以字节为单位的Content-Length
,以确定在头文件结束后还要从套接字读取多少数据。因此,HTTP 混合了您在这里看到的第四种和第五种模式。事实上,它还可以使用第六个选项:如果服务器正在传输一个长度无法预测的响应,那么 HTTP 可以使用“分块编码”,发送一系列以长度为前缀的块。一个零长度字段标志着传输的结束,就像清单 5-2 中的一样。
Pickles 和自定界格式
请注意,您可能通过网络发送的某些类型的数据已经包含某种形式的内置定界。如果您正在传输这样的数据,那么您可能不需要在数据已经在做的基础上强加您自己的框架。
以 Python“pickles”为例,它是标准库附带的序列化的原生形式。pickle 使用文本命令和数据的奇怪组合来存储 Python 数据结构的内容,以便您可以稍后或在不同的机器上重建它。
>>> import pickle
>>> pickle.dumps([5, 6, 7])
b'\x80\x03]q\x00(K\x05K\x06K\x07e.'
关于这个输出数据,有趣的是您在前面的字符串末尾看到的'.'
字符。这是格式的方式来标志一个泡菜的结束。一旦遇到它,加载程序可以停止并返回值,而无需进一步读取。因此,你可以拿着前面的泡菜,在末尾贴上一些难看的数据,看看loads()
会完全忽略多余的数据,把原来的列表还给你。
>>> pickle.loads(b'\x80\x03]q\x00(K\x05K\x06K\x07e.blahblahblah')
[5, 6, 7]
当然,以这种方式使用loads()
对网络数据没有用,因为它没有告诉你为了重新加载 pickle 它处理了多少字节;您仍然不知道字符串中有多少是 pickle 数据。但是如果您切换到从文件读取并使用 pickle load()
函数,那么文件指针将保持在 pickle 数据的末尾,如果您想读取 pickle 之后的内容,您可以从那里开始读取。
>>> from io import BytesIO
>>> f = BytesIO(b'\x80\x03]q\x00(K\x05K\x06K\x07e.blahblahblah')
>>> pickle.load(f)
[5, 6, 7]
>>> f.tell()
14
>>> f.read()
b'blahblahblah'
或者,您可以创建一个协议,在两个 Python 程序之间来回发送 pickles。请注意,您不需要放入清单 5-2 中的recvall()
函数中的那种循环,因为pickle
库知道所有关于从文件中读取的内容,以及它可能必须如何重复读取,直到读取完整个 pickle。如果你想在 Python 文件对象中封装一个套接字,供 pickle load()
函数之类的例程使用,使用makefile()
套接字方法(在第三章中讨论)。
请注意,在处理大型数据结构时有许多微妙之处,尤其是当它们包含的 Python 对象超出了简单的内置类型(如整数、字符串、列表和字典)时。更多详情见pickle
模块文档。
XML 和 JSON
如果您的协议需要可以在其他编程语言中使用,或者如果您只是喜欢通用标准而不是 Python 特有的格式,那么 JSON 和 XML 数据格式都是受欢迎的选择。请注意,这两种格式都不支持组帧,因此您必须先弄清楚如何通过网络提取完整的文本字符串,然后才能处理它。
JSON 是目前在不同计算机语言之间发送数据的最佳选择之一。从 Python 2.6 开始,它作为一个名为json
的模块被包含在标准库中。它提供了序列化简单数据结构的通用技术。
>>> import json
>>> json.dumps([51, 'Namárië!'])
'[51, "Nam\\u00e1ri\\u00eb!"]'
>>> json.dumps([51, 'Namárië!'], ensure_ascii=False)
'[51, "Namárië!"]'
>>> json.loads('{"name": "Lancelot", "quest": "Grail"}')
{u'quest': u'Grail', u'name': u'Lancelot'}
从这个例子中可以注意到,JSON 不仅允许在其字符串中使用 Unicode 字符,如果您告诉 Python json
模块它不需要将其输出限制为 ASCII 字符,它甚至可以在其有效载荷中包含 Unicode 字符。还要注意,JSON 表示被定义为产生一个字符串,这就是为什么这里使用完整的字符串而不是简单的 Python 字节对象作为json
模块的输入和输出。根据 JSON 标准,您需要将其字符串编码为 UTF-8,以便在网络上传输。
XML 格式更适合于文档,因为它的基本结构是获取字符串,并通过将它们放在尖括号元素中来标记它们。在第十章中,你将广泛了解 Python 中处理 XML 和相关格式文档的各种选项。但是现在,请记住,您不必将 XML 的使用限制在实际使用 HTTP 协议的时候。可能会有这样的情况,当您需要文本中的标记时,您会发现 XML 在与其他协议结合使用时非常有用。
在开发人员可能要考虑的许多其他格式中,有像 Thrift 和 Google Protocol Buffers 这样的二进制格式,它们与刚刚定义的格式有些不同,因为客户端和服务器端都需要有一个代码定义,来定义每条消息将包含的内容。然而,这些系统包含对不同协议版本的规定,使得新的服务器可以投入生产,仍然与具有较旧协议版本的其他机器通信,直到它们都可以被更新到新版本。它们很有效率,传递二进制数据没有问题。
压缩
由于通过网络传输数据所需的时间通常比 CPU 准备数据传输所需的时间更长,因此在发送数据之前压缩数据通常是值得的。流行的 HTTP 协议,正如你将在第九章中看到的,让客户机和服务器判断它们是否都支持压缩。
关于 GNU zlib
工具,的一个有趣的事实是,它可以通过 Python 标准库获得,并且是当今互联网上最普遍的压缩形式之一,它是自组织的。如果你开始给它一个压缩的数据流,那么它可以告诉你什么时候压缩的数据已经结束了,并让你访问随后可能出现的未压缩的有效载荷。
大多数协议选择自己成帧,然后,如果需要,将结果块传递给zlib
进行解压缩。然而,你可以向自己承诺,你将总是在每个 zlib 压缩字符串的末尾添加一点未压缩的数据(这里,我将使用一个单独的b'.'
字节),并观察你的压缩对象分离出“额外数据”,作为你完成的信号。
考虑两个压缩数据流的组合:
>>> import zlib
>>> data = zlib.compress(b'Python') + b'.' + zlib.compress(b'zlib') + b'.'
>>> data
b'x\x9c\x0b\xa8,\xc9\xc8\xcf\x03\x00\x08\x97\x02\x83.x\x9c\xab\xca\xc9L\x02\x00\x04d\x01\xb2.'
>>> len(data)
28
请注意,大多数压缩方案,当给定微小的有效载荷时,倾向于使它们更长而不是更短,因为压缩格式的开销超过了有效载荷中任何微小的可压缩性。
假设这 28 个字节以 8 字节数据包的形式到达目的地。处理完第一个包,你会发现解压对象的unused_data
槽还是空的,告诉你还有更多数据要来。
>>> d = zlib.decompressobj()
>>> d.decompress(data[0:8]), d.unused_data
(b'Pytho', b'')
所以,你会想再次recv()
上插座。第二个八个字符的块,当输入到解压缩对象时,将完成您等待的压缩数据,并返回一个非空的unused_data
值,表明您最终收到了b'.'
字节:
>>> d.decompress(data[8:16]), d.unused_data
('n', '.x')
句点之后的字符必须是压缩数据的第一位之后的任何有效载荷的第一个字节。因为这里您期望进一步的压缩数据,所以您将把'x'
提供给一个新的解压缩对象,然后您可以向该对象提供您正在模拟的最终的 8 字节“数据包”:
>>> d = zlib.decompressobj()
>>> d.decompress(b'x'), d.unused_data
(b'', b'')
>>> d.decompress(data[16:24]), d.unused_data
(b'zlib', b'')
>>> d.decompress(data[24:]), d.unused_data
(b'', b'.')
在这一点上,unused_data
再次显示您已经阅读了第二轮压缩数据的结尾,并且可以检查其内容,因为它已经完整无损地到达。
同样,大多数协议设计者将压缩设为可选,并简单地进行他们自己的组帧。尽管如此,如果您提前知道您将总是想要使用zlib
,那么像这样的约定将让您利用内置于zlib
中的流终止,并自动检测每个压缩流的结尾。
网络例外
本书中的示例脚本通常被设计成只捕捉那些对所演示的特性不可或缺的异常。因此,当我在清单 2-2 的中说明套接字超时时,我很小心地捕捉到了异常socket.timeout
,因为这就是超时的信号方式。然而,我忽略了所有其他可能发生的异常,如果命令行上提供的主机名无效,远程 IP 与bind()
一起使用,与bind()
一起使用的端口已经繁忙,或者对等体无法联系或停止响应。
使用套接字会导致什么错误?虽然在使用网络连接时可能发生的错误数量相当大——包括在复杂的 TCP/IP 协议的每个阶段可能发生的每一个错误——但幸运的是,套接字操作可能影响您的程序的实际异常数量相当少。套接字操作特有的例外如下:
OSError
:这是socket
模块的主力,在网络传输的任何阶段,几乎每一个可能发生的故障都会引发。几乎在任何套接字调用中都可能发生这种情况,即使是在您最不希望的时候。例如,当先前的send()
从远程主机引发了一个重置(RST)包时,您将实际看到由您下一次在该套接字上尝试的任何套接字操作所引发的错误。
socket.gaierror
:当getaddrinfo()
找不到您要查询的名称或服务时,就会引发这个异常,这就是其名称中出现字母 g 、 a 和 i 的原因。不仅当您显式调用getaddrinfo()
时,而且当您为类似bind()
或connect()
的调用提供主机名而不是 IP 地址,并且主机名查找失败时,都会引发该问题。如果捕捉到这个异常,可以在异常对象内部查找错误号和消息。
>>> import socket
>>> s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>> try:
... s.connect(('nonexistent.hostname.foo.bar', 80))
... except socket.gaierror as e:
... raise
...
Traceback (most recent call last):
...
socket.gaierror: [Errno -2] Name or service not known
>>> e.errno
-2
>>> e.strerror
'Name or service not known'
只有当你或你正在使用的库决定在套接字上设置超时,而不是愿意永远等待一个send()
或recv()
完成时,才会引发这个异常。它表示在操作正常完成之前,超时确实已过期。
您将看到socket
模块的标准库文档也描述了一个herror
异常。幸运的是,只有当你使用某些老式的地址查找调用,而不是遵循第四章中概述的实践时,这种情况才会发生。
在使用 Python 的基于套接字的高级协议时,一个大问题是它们是否允许原始套接字错误影响您自己的代码,或者它们是否捕捉这些错误并将其转化为自己的错误。这两种方法的例子都存在于 Python 标准库中!例如,httplib
认为自己级别很低,可以让您看到由于连接到未知主机名而导致的原始套接字错误。
>>> import http.client
>>> h = http.client.HTTPConnection('nonexistent.hostname.foo.bar')
>>> h.request('GET', '/')
Traceback (most recent call last):
...
socket.gaierror: [Errno -2] Name or service not known
但是urllib2
,可能是因为它想保留作为一个干净和中立的系统来解析文档 URL 的语义,隐藏了同样的错误并提出了URLError
。
>>> import urllib.request
>>> urllib.request.urlopen('http://nonexistent.hostname.foo.bar/')
Traceback (most recent call last):
...
socket.gaierror: [Errno -2] Name or service not known
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
...
urllib.error.URLError: <urlopen error [Errno -2] Name or service not known>
因此,根据您使用的协议实现,您可能必须只处理特定于该协议的异常,或者您可能必须同时处理特定于协议的异常和原始套接字错误。如果您对某个特定库采用的方法有疑问,请仔细查阅文档。对于我将在本书后续章节中介绍的主要软件包,我已经尝试提供了一些插图,列出了每个库可以让您的代码遵循的可能的异常。
当然,您可以随时启动有问题的库,为它提供一个不存在的主机名,或者甚至在断开网络连接时运行它,看看会出现什么样的异常。
编写网络程序时,应该如何处理可能出现的所有错误?当然,这个问题并不是真正针对网络的。所有种类的 Python 程序都必须处理异常,我在本章中简单讨论的技术也适用于许多其他种类的程序。无论您是将异常打包以供调用您的 API 的其他程序员处理,还是拦截异常以将它们适当地报告给最终用户,您的方法都会有所不同。
提出更具体的例外情况
有两种方法可以将异常传递给你正在编写的 API 的用户。当然,在许多情况下,你将是你正在编写的模块或例程的唯一客户。然而,仍然值得把你未来的自己想象成一个客户,他将会忘记关于这个模块的几乎所有事情,并且会非常欣赏它在处理异常时的简单性和清晰性。
一种选择是根本不处理网络异常。然后调用者就可以看到它们并进行处理,调用者可以根据自己的选择捕获或报告它们。这种方法非常适合于级别较低的网络例程,在这些例程中,调用者可以生动地描绘出为什么要设置套接字,以及为什么它的设置或使用可能会遇到错误。只有当 API 调用和底层网络操作之间的映射清晰时,编写调用代码的开发人员才会想到网络错误。
另一种方法是将网络错误包装在您自己的异常中。对于那些对你如何实现你的例程知之甚少的作者来说,这要容易得多,因为他们的代码现在可以捕捉特定于你的代码执行的操作的异常,而不必知道你如何使用套接字的细节。自定义异常还让您有机会精心制作错误消息,准确描述当您的库与网络发生冲突时它试图完成的任务。
例如,如果您编写一个小的mycopy()
方法,将一个文件从一台远程机器复制到另一台机器,那么socket.error
不会帮助调用者知道错误是与源机器的连接有关还是与目的机器的连接有关,或者完全是其他问题。在这种情况下,定义您自己的异常可能会好得多——也许是SourceError
和DestinationError
——它们与您的 API 有紧密的语义关系。你总是可以通过raise...from
异常链接来包含最初的套接字错误,以防你的 API 的一些用户想要进一步调查。
class DestinationError(Exception):
def __str__(self):
return '%s: %s' % (self.args[0], self.__cause__.strerror)
# ...
try:
host = sock.connect(address)
except socket.error as e:
raise DestinationError('Error connecting to destination') from e
当然,这段代码假设DestinationError
只会像socket.error
一样包装OSError
的后代。否则,__str__()
方法将不得不更加复杂,以处理原因异常在属性而不是strerror
中保存其文本信息的情况。但这至少说明了模式。捕捉到一个DestinationError
的调用者可以检查它的__cause__
,以了解他们实际捕捉到的语义更丰富的异常背后的网络错误。
捕捉和报告网络异常
有两种捕获异常的基本方法:粒度异常处理程序和总括异常处理程序。
对异常的粒度方法是在你曾经进行的每个网络调用周围包装一个try...except
子句,并在它的位置打印出一个简洁的错误消息。虽然适用于短程序,但这在长程序中会变得重复,不一定能为用户提供更多的信息。当您用另一个try...except
和特定的错误消息包装程序中的第一百个网络操作时,问问自己是否真的提供了更多的信息。
另一种方法是使用一揽子异常处理程序。这涉及到从代码中退一步,找出做特定事情的大区域,比如:
然后,程序的外部部分(收集输入、命令行参数和配置设置,然后启动大型操作的部分)可以用如下处理程序包装这些大型操作:
import sys
...
try:
deliver_updated_keyfiles(...)
except (socket.error, socket.gaierror) as e:
print('cannot deliver remote keyfiles: {}'.format(e), file=sys.stderr)
exit(1)
更好的方法是,让您的代码引发一个您自己设计的错误,指出一个特别需要暂停程序并为用户打印错误输出的错误。
except:
FatalError('cannot send replies: {}'.format(e))
然后,在程序的最顶层,捕捉所有抛出的FatalError
异常,并在那里打印错误消息。这样,当您希望添加一个命令行选项,将致命错误发送到系统错误日志而不是屏幕时,您只需调整一段代码,而不是十几段!
还有最后一个原因可能会决定在网络程序中添加异常处理程序的位置:您可能希望智能地重试失败的操作。在长时间运行的程序中,这是很常见的。想象一个定期发送电子邮件告知其状态的工具。如果它突然不能成功发送它们,那么它可能不想因为可能只是一个暂时的错误而关闭。相反,电子邮件线程可能会记录错误,等待几分钟,然后重试。
在这种情况下,您将在特定的网络操作序列周围添加异常处理程序,您希望将这些网络操作序列视为单个组合操作的成功或失败。“如果这里出现任何问题,我就会放弃,等待十分钟,然后再次尝试发送电子邮件。”这里,您正在执行的网络操作的结构和逻辑——而不是用户或程序员的便利——将指导您在哪里部署try...except
子句。
摘要
对于要放在网络上的机器信息,必须对其进行转换,以便不管您的机器内部使用的是什么私有的和特殊的存储机制,数据都使用公共的和可再现的表示来呈现,这些表示可以在其他系统上被其他程序读取,甚至可能被其他编程语言读取。
对于文本来说,最大的问题是选择一种编码方式,这样你要传输的符号就可以变成字节,因为 8 位八位字节是 IP 网络的通用货币。二进制数据需要您注意确保字节的排序方式在不同的机器之间是兼容的;Python struct
模块将在这方面帮助你。最后,数据结构和文档有时最好使用 JSON 或 XML 之类的东西来发送,这提供了一种在机器之间共享结构化数据的通用方法。
当使用 TCP/IP 流时,你将面临的一个大问题是关于成帧:在长数据流中,你将如何辨别一个特定消息的开始和结束?有许多可能的技术来实现这一点,所有这些都必须小心处理,因为recv()
可能在每次调用时只返回部分传入传输。特殊的分隔符或模式、固定长度的消息和分块编码方案都是修饰数据块的可能方法,这样就可以区分它们。
Python pickles 不仅会将数据结构转换成可以通过网络发送的字符串,而且pickle
模块会知道传入的 pickle 在哪里结束。这让您不仅可以使用 pickles 对数据进行编码,还可以将单个消息组织到流中。通常与 HTTP 一起使用的zlib
压缩模块也可以判断压缩段何时结束,从而为您提供廉价的帧。
套接字会引发几种异常,代码使用的网络协议也是如此。何时使用try...except
子句的选择将取决于你的受众——你是为其他开发者编写一个库还是为最终用户编写一个工具?这还取决于语义:从调用者或最终用户的角度来看,如果所有代码都在做一件大事,那么你可以将程序的整个部分包装在一个try...except
中。
最后,您可能希望用一个try...except
单独包装操作,如果错误是暂时的,调用可能会在以后成功,那么可以自动重试。
传输层安全性(TLS) 最初由 Netscape 于 1995 年发布,当时称为安全套接字层(SSL ), 1999 年成为互联网标准,可能是当今互联网上使用最广泛的加密形式。正如您将在本章中了解到的,它与现代互联网上的许多基本协议一起用于验证服务器身份和保护传输中的数据。
TLS 的正确使用和部署是一个移动的目标。每年都有针对其加密算法的新攻击出现,新的密码和技术也因此应运而生。TLS 1.2 是第三版Python 网络编程基础的最新版本,但毫无疑问,未来几年还会发布更多版本。随着技术的进步,我将尽量保持在线存储在本书源代码库中的示例脚本的更新。因此,请务必访问本章中显示的每个脚本顶部的 URL,并从版本控制中找到的代码版本中进行剪切和粘贴。
本章将首先阐明 TLS 的功能,并概述它使用的技术。然后,您将查看简单和复杂的 Python 示例,了解如何在 TCP 套接字上激活和配置 TLS。最后,您将看到 TLS 是如何集成到现实世界的协议中的,您将在本书的其余部分中了解到这一点。
TLS 未能保护什么
正如您将在本章后面看到的,通过配置良好的 TLS 套接字传递的数据对任何观看者来说都应该是乱码。此外,除非数学让 TLS 的设计者们失望,否则它将是令人印象深刻的难以理解的胡言乱语,甚至对一个拥有大量预算的政府机构也是如此。它应该防止任何窃听者(比如 HTTPS 连接的窃听者)获知您请求的 URL、返回的内容或任何识别信息(比如可能在套接字上双向传递的密码或 cookie)。(参见第九章了解更多关于 HTTP 特性的信息,如密码和 cookies。)
尽管如此,你应该立即退后一步,记住除了数据之外,还有多少关于连接的信息是由 TLS 保密的,并且仍然可以被任何第三方观察到。
观察者可以观察通过 TLS 加密套接字在每个方向上传递的数据块的大小。尽管 TLS 会试图隐藏确切的字节数,但仍有可能大致了解数据通过了多大的块,以及请求和响应的总体模式。
我用一个例子来说明前面的弱点。想象一下,你使用一个安全的 HTTPS 客户端(比如你最喜欢的网络浏览器)通过咖啡店的无线网络获取https://pypi.python.org/pypi/skyfield/
。观察者会知道什么——“观察者”可能是连接到咖啡店无线网络的任何人,或者是控制着咖啡店和互联网其余部分之间的路由器的任何人。观察者将首先看到你的机器对pypi.python.org
进行 DNS 查询,除非在返回的 IP 地址上有许多其他网站,否则他们将猜测你随后在端口 443 与该 IP 地址的对话是为了查看https://pypi.python.org
网页。他们将知道您的 HTTP 请求和服务器响应之间的差异,因为 HTTP 是一个锁步协议,在响应被写回之前,每个请求都被完整地写出。此外,他们将大致知道每个返回文档的大小,以及它们被获取的顺序。
想想观察者能学到什么!不同的页面在https://pypi.python.org
会有不同的大小,观察者可以通过用网络刮刀扫描网站来分类(见第十一章)。不同类型的页面会包含不同的图片和 HTML 中引用的其他资源,需要在第一次查看时下载,或者如果它们已经从浏览器的缓存中过期。虽然外部观察者可能不确切知道您输入的搜索和您最终访问或下载的包,但他们通常能够根据他们看到的您获取的文件的粗略大小做出很好的猜测。
关于如何保持你的浏览习惯的秘密,或者隐藏任何其他在公共互联网上传播的个人数据,这个大问题远远超出了本书的范围,并且将涉及对在线匿名网络(例如 Tor 最近出现在新闻中)和匿名回复邮件者等机制的研究。即使采用了这样的机制,您的机器仍然可能发送和接收数据块,这些数据块的大小可能被用来猜测您正在做什么。一个足够强大的对手甚至可能会注意到,您的请求模式与离开匿名网络到达特定目的地的有效负载相对应。
本章的其余部分将关注 TLS 可以实现什么以及 Python 代码如何有效地使用它。
会出什么问题呢?
为了浏览 TLS 的基本特性,您将考虑协议本身在建立连接时面临的一系列挑战,并了解如何面对和克服每个障碍。
让我们假设您想要在互联网上的某个地方使用特定的主机名和端口号打开 TCP 会话,并且您已经不情愿地接受了您对主机名的 DNS 查找将是公开的,您正在连接的端口号也是公开的(这将暴露您正在使用的协议,除非您正在连接的服务的所有者将其绑定到非标准或误导性的端口号)。您将继续进行,并建立到 IP 地址和端口的标准 TCP 连接。如果你所说的协议要求在打开加密之间有一个介绍,那么那些最初的几个字节将会清晰地传递给每个人看。(协议在这一细节上有所不同——HTTPS 在启用加密之前不会发送任何东西,但 SMTP 会交换几行文本。在本章的后面,您将了解几个主要协议的行为。)
一旦你启动并运行了套接字,并交换了你的协议规定的任何客套话,为加密做准备,是时候让 TLS 接管并开始建立关于你在与谁交谈以及你和你与之交谈的对等方(另一方)如何保护数据免受窥探的强有力的保证了。
你的 TLS 客户端的第一个需求将是远程服务器提供一个名为证书 的二进制文档,其中包括密码学家所说的公钥——一个可以用来加密数据的整数,这样只有相应的私钥整数的拥有者才能解密信息并理解它。如果远程服务器被正确配置并且从未被破坏,那么它将拥有私钥的副本,并且是互联网上唯一拥有该副本的服务器(其集群中的其他机器可能例外)。您的 TLS 实现如何验证远程服务器实际持有私钥?简单!您的 TLS 库通过网络发送一些用公钥加密的信息,它要求远程服务器提供一个校验和,证明数据是用密钥成功解密的。
您的 TLS 堆栈还必须关注远程证书是否被伪造的问题。毕竟,任何能够访问openssl
命令行工具(或许多其他工具中的任何一个)的人都可以创建一个证书,其通用名称是cn=
www.google.com
或cn=pypi.python.org
或其他名称。你为什么会相信这样的说法?解决方案是让您的 TLS 会话保留一个证书颁发机构(CAs) 列表,它信任该列表来验证互联网主机身份。默认情况下,您的操作系统 TLS 库或 web 浏览器的 TLS 库使用数百个证书的标准全球 CA 列表,这些证书代表执行可信站点验证业务的组织。但是,如果您对默认设置不满意,或者如果您想使用您的组织为免费签署您自己的私有主机证书而生成的私有 CA,您可以随时提供您自己的 CA 列表。当没有外部客户端要连接并且您只需要支持您自己的服务之间的连接时,这是一个流行的选项。
CA 在证书上做出的表示其认可的数学标记称为签名 。在接受证书有效之前,您的 TLS 库将根据相应 CA 证书的公钥验证证书的签名。
一旦 TLS 验证了证书正文确实已提交给可信第三方并由其签名,它将检查证书本身的数据字段。有两种领域会引起特别的兴趣。首先,证书包括一个notBefore
日期和一个notAfter
日期,以将它们有效的时间段括起来,从而属于被盗私钥的证书不会永远有效。您的 TLS 堆栈将使用您的系统时钟来检查这些,这意味着一个坏的或错误配置的时钟实际上会破坏您通过 TLS 进行通信的能力!第二,证书的通用名称应该与您试图连接的主机名相匹配——毕竟,如果您想连接到https://pypi.python.org
,如果站点用一个完全不同的主机名的证书来响应,您很难放心!
一个证书实际上可以在许多主机名之间共享。现代证书可以用存储在其subjectAltName
字段中的附加名称来补充其subject
字段中的单值通用名称。此外,这些名称中的任何一个都可以包含通配符,比如*.python.org
,它可以匹配多个主机名,而不是每个主机名只匹配一个。现代 TLS 算法会自动为您执行这种匹配,Python ssl
模块也有自己的能力来完成这一任务。
最后,客户机和服务器上的 TLS 代理协商一个共享密钥和密码,用它来加密通过连接传递的实际数据。这是 TLS 可能失败的最后一点,因为正确配置的软件将拒绝它认为不合适的密码或密钥长度。事实上,这可能发生在两个层次上:TLS 可能失败,要么是因为另一端想要使用的 TLS 协议版本太过时且不安全,要么是因为另一端支持的特定密码被认为不够强大而不可信。
一旦对密码达成一致,并且双方都生成了密钥(既用于加密数据,也用于对每个数据块进行签名),控制权就会交还给两端的应用。他们传输的每个数据块都用加密密钥加密,然后用签名密钥对生成的块进行签名,以向另一端证明它确实是由另一个对等体生成的,而不是某个跳到网络上试图进行中间人攻击的人。数据可以不受任何限制地在两个方向上流动,就像在普通的 TCP 套接字上一样,直到 TLS 关闭,套接字要么关闭,要么返回到纯文本模式。
在接下来的章节中,您将学习如何控制 Python 的ssl
库,因为它做出了前面概述的每一个主要决策。请查阅官方参考资料以获取更多信息,以及诸如 Bruce Schneier 的书籍、Google 在线安全博客和 Adam Langley 的博客等资源。我自己发现 Hynek Schlawack 在 PyCon 2014 上的“SSL 的糟糕状态”演讲很有帮助,你可以在线观看。如果在您阅读本书时,更多关于 TLS 的最新演讲已经出现在会议上,那么它们可能是关于密码学动态实践的最新信息的良好来源。
生成证书
Python 标准库并不关心私钥生成或证书签名。如果您需要执行这些步骤,您将不得不使用其他工具。最广泛使用的工具之一是openssl
命令行工具。如果您想看几个如何调用它的例子,请参见本书源代码库的playground/certs
目录中的 Makefile。
https://github.com/brandon-rhodes/fopnp/tree/m/playground/certs
目录还包含几个在网络游戏中使用的证书(见第一章,其中几个你将在本章的例子中的命令行中使用。ca.crt
证书是一个小型的自包含证书颁发机构,您将告诉 Python 在使用 TLS 的其他证书时要信任它,它已经签署了所有其他证书。
简而言之,证书创建通常始于两条信息——一条是人工生成的,另一条是机器生成的。它们分别是由证书描述的实体的文本描述和使用操作系统提供的真随机性源精心产生的私钥。我通常将手写的身份描述保存到一个受版本控制的文件中,供以后参考;然而,一些管理员在得到提示时简单地将字段输入到openssl
中。举个例子,清单 6-1 显示了用于为网络游乐场的www.example.com
网络服务器生成证书的www.cnf
文件。
清单 6-1 。OpenSSL 命令行使用的 X.509 证书的配置
[req] prompt = no distinguished_name = req_distinguished_name [req_distinguished_name] countryName = us stateOrProvinceName = New York localityName = New York 0.organizationName = Example from Apress Media LLC organizationalUnitName = Foundations of Python Network Programming 3rd Ed commonName = www.example.com emailAddress = root@example.com [ssl_client] basicConstraints = CA:FALSE nsCertType = client keyUsage = digitalSignature, keyEncipherment extendedKeyUsage = clientAuth
请记住,commonName
和任何subjectAltName
条目(在本例中不存在)是 TLS 将与主机名进行比较的关键字段,以确定它是否正在与正确的主机对话。
对于备份证书的私钥的适当长度和类型,专家们目前有几种意见,一些管理员选择 RSA,而另一些则更喜欢 Diffie-Hellman。不讨论这个问题,下面是一个示例命令行,用于创建一个 RSA 密钥,其长度目前被认为是相当可观的:
$ openssl genrsa -out www.key 4096
Generating RSA private key, 4096 bit long modulus
................................................................................
.............++
.............++
e is 65537 (0x10001)
有了这两个部分,管理员就可以创建证书签名请求(CSR) 提交给证书颁发机构了——无论是管理员自己的还是属于第三方的。
$ openssl req -new -key www.key -config www.cnf -out www.csr
如果您想了解由openssl
工具创建私有 CA 的步骤,以及它如何签署 CSR 以生成与前面生成的请求相对应的www.crt
文件,请查阅 Makefile。当与公共证书颁发机构打交道时,您可能会在电子邮件中收到您的www.crt
(在您惊慌失措之前,请记住,该证书是应该是公开的!)或者在证书准备就绪时从您的帐户下载签名证书。无论如何,为了方便起见,使您的证书易于在 Python 中使用的最后一步是将证书和密钥合并到一个文件中。如果文件是由前面的命令生成的标准 PEM 格式,那么合并它们就像运行 Unix“concatenate”命令一样简单。
$ cat www.crt www.key > www.pem
结果文件应该包含证书内容的文本摘要,然后是证书本身,最后是私钥。小心这个文件!如果www.key
或这个包含私钥的 PEM 文件www.pem
被泄露或被第三方获得,那么该第三方将能够在整个几个月或几年内冒充您的服务,直到密钥过期。文件的三个部分看起来应该类似于清单 6-2 。(注意省略号——我们对文件进行了缩写,实际上需要两到三页书的篇幅!)
清单 6-2 。捆绑到单个 PEM 文件中的证书和私钥
Certificate: Data: Version: 1 (0x0) Serial Number: 3 (0x3) Signature Algorithm: sha1WithRSAEncryption Issuer: C=us, ST=New York, L=New York, O=Example CA from Apress Media LLC, OU=Foundations of Python Network Programming 3rd Ed, CN=ca/emailAddress=ca@example.com Validity Not Before: Mar 8 16:58:12 2014 GMT Not After : Feb 12 16:58:12 2114 GMT Subject: C=us, ST=New York, O=Example from Apress Media LLC, OU=Foundations of Python Network Programming 3rd Ed, CN=www.example.com/emailAddress=root@example.com ... -----BEGIN CERTIFICATE----- MIIE+zCCA2MCAQMwDQYJKoZIhvcNAQEFBQAwgcUxCzAJBgNVBAYTAnVzMREwDwYD VQQIEwhOZXcgWW9yazERMA8GA1UEBxMITmV3IFlvcmsxKTAnBgNVBAoTIEV4YW1w I7Ahb1Dobi7EoK9tXFMrXutOTQkoFe ... pT7/ivFnx+ZaxE0mcR8qyzyQqWTDQ SBH14aSHQPSodSHC1AAAfB3B+CHII1TkAXUudh67swE2qvR/mFbFtHwuSVEbSHZ+ 2ukF5Z8mSgkNlr6QnikCDIYbBWDOSiTzmX/zPorqlw== -----END CERTIFICATE----- -----BEGIN RSA PRIVATE KEY----- MIIG5QIBAAKCAYEA3rM3H+kGaWhbbfqyKzoePLIiYBOLw3W+wuKigsU1qDPFJBKk JF4UqCo6OfZuJLpAHAIPwb/0ihA2hXK8/I9Rd75t3leiYER6Oefg9TRGuxloD0om 8ZFW8k3p4RA7uDBMjHF3tZqIGpHpY6 ... f8QJ7ZsdXLRsVmHM+95T1Sy6QgmW2 Worz0PhhWVzGT7MgSduY0c8efArdZC5aVo24Gvd3i+di2pRQa0g9rSL7VJrm4BdB NmdPSZN/rGhvwbWbPVQ5ofhFOMod1qgAp626ladmlublPtFt9sRJESU= -----END RSA PRIVATE KEY-----
存在比 CA 直接签署证书供服务器使用更复杂的安排。例如,一些组织希望他们的服务器只使用在过期前持续几天或几周的短期证书。如果服务器遭到破坏,其私钥被盗,这可以最大限度地减少损失。这样的组织可以让 CA 签署一个更长寿命的中间证书,该组织将该证书的私钥作为秘密持有,并用于签署实际放在服务器上的用户可见的证书,而不必每隔几天就联系(并支付)CA 组织进行更换。由此产生的证书链或信任链结合了拥有自己的 CA 的灵活性(因为您可以随时签署新证书)和公认的公共 CA 的优势(因为您不必在想要与您对话的每个浏览器或客户端中安装自定义 CA 证书)。只要您的 TLS 支持的服务器为客户端提供它自己的特定服务器证书以及中间证书,使加密链接返回到客户端知道可以信任的 CA 证书,客户端软件验证它们的身份应该没有问题。
如果您发现自己的任务是建立组织及其服务的加密标识,请查阅有关证书签名的书籍或文档。
卸载 TLS
在向您展示如何使用 Python 中的 TLS 之前——特别是如果您准备编写一个服务器的话——我应该注意到,许多专家会问为什么您首先要在 Python 应用中直接执行加密。毕竟,已经有许多工具已经仔细地实现了 TLS,如果您在另一个端口上运行应用,它们可以代表您负责应答客户端连接,并将未加密的数据转发到您的应用。
与您自己的服务器代码、Python 和底层 OpenSSL 库的组合相比,为您的 Python 应用提供 TLS 终止的独立守护程序或服务可能更容易升级和调整。此外,第三方工具通常会公开 Python ssl
模块甚至在 Python 3.4 下还不允许您定制的 TLS 特性。例如,普通的ssl
模块目前似乎无法使用 ECDSA 椭圆曲线签名或微调会话重新协商。会话重新协商是一个特别重要的主题。它可以显著降低提供 TLS 的 CPU 成本,但是如果配置不当,它会损害您承诺完美的前向安全性的能力(参见“精选的密码和完美的前向安全性”一节)。2013 年在https://www.imperialviolet.org/2013/06/27/botchingpfs.html
发表的旧博文“如何修补 TLS 转发保密”仍然是对该主题的最佳介绍之一。
前端 HTTPS 服务器是提供 TLS 终止的第三方守护程序的一个很好的例子。第三方工具包装 HTTP 特别容易,因为 HTTPS 标准规定,在任何特定于协议的消息通过通道传递之前,客户端和服务器应该先协商加密。无论您是在 Python web 服务前面部署 Apache、nginx 或其他一些反向代理作为额外的防御级别,还是订阅像 Fastly 这样的内容交付网络,将请求通过隧道传输到您自己的服务器,您都会发现 TLS 可以从您的 Python 代码中消失,进入周围的基础设施中。
但是,如果您设置一个简单的守护进程(如stunnel
)在您的公共 TCP 端口上运行,并私下将连接转发到您的服务,那么即使您自己的原始套接字协议(没有第三方工具可用)也可以接受第三方 TLS 保护。
如果您选择将 TLS 卸载到另一个工具,那么在开始阅读该工具的文档之前,您可能只需要浏览一下本章的其余部分(以便熟悉您将寻找的旋钮)。它将是那个工具,而不是 Python 本身,它将加载您的证书和私钥,并且它将需要被适当地配置以提供您所需要的针对弱密码的保护级别。唯一要问的问题是,您选择的前端将如何告诉您的 Python 服务远程 IP 地址和(如果您使用客户端证书)已连接的每个客户端的身份。对于 HTTP 连接,关于客户端的信息可以作为附加的头添加到请求中。对于更原始的工具,比如 stunnel 或 haproxy,它们实际上可能不会使用 HTTP,像客户端 IP 地址这样的额外信息必须作为额外的字节放在传入数据流的前面。无论哪种方式,工具本身都将提供 TLS 超能力,本章的其余部分将使用纯 Python 套接字来说明这一点。
Python 3.4 默认上下文
TLS 有几种开源实现。Python 标准库选择包装最流行的 OpenSSL 库,尽管最近发生了几起安全事故,但它似乎仍然被认为是大多数系统和语言的最佳选择。一些 Python 发行版自带 OpenSSL,而其他发行版只是包装了碰巧与您的操作系统捆绑在一起的 OpenSSL 版本。标准库模块有一个古老而怀旧的名字ssl
。尽管在本书中你会将注意力集中在ssl
上,但是请注意 Python 社区中正在进行其他的密码学项目,包括一个pyOpenSSL
项目,它揭示了更多的底层库的 API。
通过引入ssl.create_default_context()
函数 ,Python 3.4 比早期版本的 Python 更容易让 Python 应用安全地使用 TLS。这是大多数用户需要的“自以为是的 API”的一个很好的例子。我们应该感谢 Christian Heimes 为标准库添加了默认上下文的概念,也应该感谢 Donald Stufft 为标准库提供了有力而有用的观点。ssl
模块为建立 TLS 连接提供的其他机制被迫坚持使用旧的、不太安全的默认机制,因为它们已经承诺在新版本的 Python 出现时不会破坏向后兼容性。但是如果你一直使用的 TLS 密码或密钥长度现在被认为是不安全的,那么create_default_context()
很愿意在你下次升级 Python 时抛出一个异常。
通过放弃在不改变应用行为的情况下升级 Python 的承诺,create_default_context()
可以仔细选择它将支持的密码,这让您摆脱了困境——如果您只是依赖它的建议,然后在您的机器上更新 Python,您就不必成为 TLS 专家和阅读安全博客。每次升级后都要重新测试您的应用,以确保它们仍然可以连接到它们的 TLS 对等端。如果某个应用出现故障,那么就要调查有问题的连接的另一端的对等设备是否也可以升级,以支持更现代的密码或机制。
如何创建和使用默认上下文?清单 6-3 展示了一个简单的客户端和服务器如何使用 TLS 安全地保护一个 TCP 套接字。
清单 6-3 。在 Python 3.4 或更新版本中,使用 TLS 保护客户端和服务器的套接字
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter06/safe_tls.py # Simple TLS client and server using safe configuration defaults import argparse, socket, ssl def client(host, port, cafile=None): purpose = ssl.Purpose.SERVER_AUTH context = ssl.create_default_context(purpose, cafile=cafile) raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) raw_sock.connect((host, port)) print('Connected to host {!r} and port {}'.format(host, port)) ssl_sock = context.wrap_socket(raw_sock, server_hostname=host) while True: data = ssl_sock.recv(1024) if not data: break print(repr(data)) def server(host, port, certfile, cafile=None): purpose = ssl.Purpose.CLIENT_AUTH context = ssl.create_default_context(purpose, cafile=cafile) context.load_cert_chain(certfile) listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind((host, port)) listener.listen(1) print('Listening at interface {!r} and port {}'.format(host, port)) raw_sock, address = listener.accept() print('Connection from host {!r} and port {}'.format(*address)) ssl_sock = context.wrap_socket(raw_sock, server_side=True) ssl_sock.sendall('Simple is better than complex.'.encode('ascii')) ssl_sock.close() if __name__ == '__main__': parser = argparse.ArgumentParser(description='Safe TLS client and server') parser.add_argument('host', help='hostname or IP address') parser.add_argument('port', type=int, help='TCP port number') parser.add_argument('-a', metavar='cafile', default=None, help='authority: path to CA certificate PEM file') parser.add_argument('-s', metavar='certfile', default=None, help='run as server: path to server PEM file') args = parser.parse_args() if args.s: server(args.host, args.port, args.s, args.a) else: client(args.host, args.port, args.a)
您可以在清单中看到,保护套接字只需要三个步骤。首先,创建一个 TLS 上下文对象,它知道您关于证书验证和密码选择的所有偏好。第二,使用上下文的wrap_socket()
方法让 OpenSSL 库控制你的 TCP 连接,与另一端交换必要的问候,建立加密通道。最后,与已经返回给您的ssl_sock
进行所有进一步的通信,以便 TLS 层总是有机会在数据实际到达网络之前对其进行加密。你会注意到,这个包装器提供了所有与普通套接字相同的方法,例如send()
、recv()
和close()
,这是你在第三章中从普通 TCP 套接字的经验中学到的。
选择是为尝试验证其连接的服务器的客户端创建上下文(Purpose.SERVER_AUTH
)还是为需要接受客户端连接的服务器创建上下文(Purpose.CLIENT_AUTH
)会影响返回的新上下文中的几个设置。从标准库作者的角度来看,拥有两套不同设置背后的理论是,您希望 TLS 客户端对较旧的密码稍微宽容一些,因为它们有时会发现自己连接到不受您控制的服务器,并且可能有点过时。但是他们认为,你肯定会希望你的自己的服务器坚持使用现代安全的密码!虽然create_default_context()
选择的设置会随着 Python 的每个新版本而改变,但这里是它在 Python 3.4 下做出的一些选择,为您提供一个示例:
SSLContext
对象时create_default_context()
将协议设置为PROTOCOL_SSLv23
。verify_mode
被设置为ssl.CERT_NONE
),但是它坚持客户端总是验证远程证书,如果不能验证,则异常失败(ssl.CERT_REQUIRED
)。编译前面的列表很容易:我只需在标准库中打开ssl.py
并阅读create_default_context()
的源代码来了解它做出的选择。您可以自己这样做,特别是当新的 Python 版本出现时,以前的列表开始过时。ssl.py
源代码甚至包括了客户端和服务器操作的原始密码列表,目前命名为_DEFAULT_CIPHERS
和_RESTRICTED_SERVER_CIPHERS
,如果你有足够的好奇心想要回顾它们的话。您可以查阅最近的 OpenSSL 文档来了解每个字符串中的选项的含义。
在清单 6-3 中构建上下文时提供的 cafile
选项决定了您的脚本在验证远程证书时愿意信任哪些证书颁发机构。如果它的值是None
,如果您选择不指定cafile
关键字,这是默认值,那么create_default_context()
将在返回它之前自动调用您的新上下文的load_default_certs()
方法。这将尝试加载操作系统上的浏览器在连接到远程站点时信任的所有默认 CA 证书,并且应该足以验证已从知名公共证书颁发机构购买证书的公共网站和其他服务。如果cafile
是一个指定文件名的字符串,那么不会从操作系统加载任何证书,只有该文件中提供的 CA 证书才会被信任来验证 TLS 连接的远端。(请注意,如果您在创建上下文时将cafile
设置为None
,然后调用load_verify_locations()
来安装任何其他证书,那么您可以使这两种证书都可用。)
最后,在清单 6-3 中有两个关键选项提供给wrap_socket()
——一个用于服务器,另一个用于客户端。服务器被给予选项server_side=True
,仅仅是因为两端中的一端必须承担服务器的责任,否则协商将失败并出错。客户端调用需要更具体的东西:您认为您已经与connect()
连接的主机的名称,以便可以根据服务器提供的证书的主题字段进行检查。这个极其重要的检查是自动执行的,只要您始终向wrap_socket()
提供server_hostname
关键字,如清单所示。
为了保持代码简单,清单 6-3 中的客户端和服务器都不在循环中运行。取而代之的是,他们各自进行一次单独的对话尝试。一个简单的localhost
证书和一个已经签名的 CA 可以在列表在线的chapter06
目录中获得;如果你想用它们来测试脚本,可以通过访问以下网址并点击 Raw 按钮来下载:
https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter06/ca.crt
https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter06/localhost.pem
如果您已经检查了该书脚本的整个源代码库,那么您可以跳过单独下载它们,只需将cd
放入chapter06
目录,在那里您会发现脚本和证书已经彼此相邻。不管怎样,清单 6-3 可以成功运行,只要localhost
别名作为 127.0.0.1 IP 地址的同义词在您的系统上正常工作。首先用-s
运行服务器,并在一个终端窗口中显示服务器 PEM 文件的路径。
$ /usr/bin/python3.4 safe_tls.py -s localhost.pem '' 1060
还记得第二章和第三章中的吗,空主机名''
告诉 Python 你希望你的服务器监听所有可用的接口。现在打开另一个终端窗口,首先,使用您的正常系统 CA 证书列表运行客户端,该列表是您的浏览器在公共互联网上运行时使用的。
$ /usr/bin/python3.4 safe_tls.py localhost 1060
Connected to host 'localhost' and port 1060
Traceback (most recent call last):
...
ssl.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:598)
因为没有公共机构在localhost.pem
内签署证书,所以您的客户端拒绝信任该服务器。您还会看到服务器已经死亡,并显示一条消息,指示客户端开始尝试连接,但随后放弃了连接。接下来,重启服务器,然后使用-a
选项重新运行客户端,这告诉它信任任何由ca.crt
签署的证书。
$ /usr/bin/python3.4 safe_tls.py -a ca.crt localhost 1060
Connected to host 'localhost' and port 1060
b'Simple is better than complex.'
这一次,您可以看到对话取得了圆满成功,一条简单的消息从服务器传送到了客户端。如果你打开像tcpdump
这样的数据包嗅探器,你会发现不可能从你捕获的数据包内容中破译信息的纯文本。在我的系统上,我可以通过以 root 用户身份运行以下命令来监控对话(查看您的操作系统文档,了解如何在您自己的机器上使用tcpdump
或 WireShark 或其他工具执行数据包捕获):
# tcpdump -n port 1060 -i lo –X
前几个包将包含一些清晰的信息:证书和公钥,它们可以安全地发送,因为它毕竟是一个公钥。当数据包经过时,我的数据包转储向我显示清晰的公钥片段。
0x00e0: 5504 0a13 2045 7861 6d70 6c65 2043 4120 U....Example.CA.
0x00f0: 6672 6f6d 2041 7072 6573 7320 4d65 6469 from.Apress.Medi
0x0100: 6120 4c4c 4331 3930 3706 0355 040b 1330 a.LLC1907..U...0
0x0110: 466f 756e 6461 7469 6f6e 7320 6f66 2050 Foundations.of.P
0x0120: 7974 686f 6e20 4e65 7477 6f72 6b20 5072 ython.Network.Pr
0x0130: 6f67 7261 6d6d 696e 6720 3372 6420 4564 ogramming.3rd.Ed
但是一旦加密的密码投入使用,第三方就不可能再进行检查(假设加密中没有漏洞或弱点)。以下是刚刚从服务器向我的机器上的客户端传送字节'Simple is better than complex'
的数据包:
16:49:26.545897 IP 127.0.0.1.1060 > 127.0.0.1.40220:
Flags [P.], seq 2082:2141, ack 426, win 350, options
[nop,nop,TS val 51288448 ecr 51285953], length 59
0x0000: 4500 006f 645f 4000 4006 d827 7f00 0001 E..od_@.@..'....
0x0010: 7f00 0001 0424 9d1c dbbf f412 f4d0 24a3 .....$........$.
0x0020: 8018 015e fe63 0000 0101 080a 030e 9980 ...^.c..........
0x0030: 030e 8fc1 1703 0300 367f 9b5d e6c3 dfbd ........6..]....
0x0040: 8f21 d83f 8b61 569f 78a0 2ac3 090b bc9f .!.?.aV.x.*.....
0x0050: 101d 2cb1 1c07 ee08 f784 f277 b11e 9214 ..,........w....
0x0060: ce02 8e2b 1c0b b630 9c2d f323 3674 f5 ...+...0.-.#6t.
请再次注意我在本章前面警告过的内容:服务器和客户机的 IP 地址和端口号完全不受阻碍地通过。只有数据有效载荷本身受到保护,不会被任何外部观察者看到。
套接字包装的变化
本章中的所有脚本都给出了使用ssl
模块实现 TLS 的简单而通用的步骤:创建一个描述您的安全需求的已配置的SSLContext
对象,使用普通套接字自己建立从客户端到服务器的连接,然后调用上下文的wrap_socket()
方法来执行实际的 TLS 协商。我的例子总是使用这种模式的原因是因为它是使用模块 API 的健壮、高效和最灵活的方法。这是一种在 Python 应用中可以成功使用的模式,通过始终使用这种模式,您将制作出易于阅读的客户端和服务器,因为它们的方法是一致的,并且它们的代码易于与这里的示例进行比较以及相互比较。
然而,标准库ssl
模块提供了一些不同的快捷方式,你可能会在其他代码中看到,因此我应该提到它们。让我来描述一下它们以及它们的缺点。
您将遇到的第一种选择是调用模块级函数ssl.wrap_socket()
,而不首先创建上下文。在较旧的代码中,您会经常看到这种情况,因为在 Python 3.2 中添加上下文对象之前,这实际上是创建 TLS 连接的唯一方式!它至少有四个缺点。
match_hostname()
来跟进,否则您甚至不知道您的对等方提供的证书是否是针对您认为您所连接的同一个主机名。出于所有这些原因,您应该避免使用ssl.wrap_socket()
,并准备好从您可能维护的任何旧代码中迁移出来。相反,使用清单 6-3 中所示的实践。
您将看到的另一个主要快捷方式是在连接套接字之前对其进行包装,在运行connect()
之前包装客户端套接字,或者在运行accept()
之前包装服务器套接字。在这两种情况下,包装的套接字不能真正立即协商 TLS,但它会等到套接字连接后再执行 TLS 协商。显然,这只适用于像 HTTPS 这样的协议,它们在连接后的第一步就是激活 TLS。像 SMTP 这样需要用一些明文开始对话的协议不能使用这种方法,所以在包装时有一个关键字选项do_handshake_on_connect
可用,如果您想等到以后再用套接字的do_handshake()
方法触发 TLS 协商,可以将它设置为False
。
诚然,预先包装套接字本身并不会降低安全性,但我建议不要这样做,原因有三,涉及到代码的可读性:
connect()
或accept()
调用的人隐藏,甚至会涉及到 TLS 协议。connect()
和accept()
现在不仅会因为它们通常的套接字或 DNS 异常而失败,还会因为 TLS 错误而失败。任何包装这些调用的try...except
子句现在将不得不担心两个完全不同的错误类别,因为两个完全不同的操作将隐藏在一个方法调用的背后。SSLSocket
对象,事实上,它可能没有进行任何加密。只有当一个连接建立或者一个显式的do_handshake()
被调用时(如果你关闭了自动协商),所谓的SSLSocket
才会提供任何真正的加密!相比之下,本书程序清单中提供的模式只有在加密真正激活的时候才会转变为SSLSocket
,从而在当前 socket 对象的类和底层连接的状态之间建立更清晰的语义链接。我见过的唯一有趣的使用预包装的情况是当试图使用一个旧的、简单的、只支持明文通信的库时。通过提供一个预包装的套接字并将do_handshake_on_connect
关键字参数设置为其默认值True
,您可以在协议不知道的情况下为协议提供 TLS 保护。然而,这是一种特殊情况,最好(如果可能的话)通过使底层库支持 TLS 并能够接受 TLS 上下文作为参数来处理。
精选密码和完美前向安全性
如果您对数据安全性很挑剔,那么您可能会发现自己想要准确地指定 OpenSSL 可以使用的密码,而不是依赖于由create_default_context()
函数提供的默认值。
随着加密领域的不断发展,无疑会出现我们做梦也想不到的问题、漏洞和对策。但这本书即将出版的一个重要问题是完美前向安全性(PFS)的问题,也就是说,未来有人获得(或破解)你的旧私钥,是否能够读取他们捕获并存档以备将来解密的旧 TLS 对话。今天最流行的密码是那些通过使用短暂的(临时)密钥来执行每个新套接字的加密来防止这种可能性的密码。希望保证 PFS 是想要手工指定上下文对象属性的最流行的原因之一。
请注意,尽管ssl
模块的默认上下文不要求支持 PFS 的密码,但是如果您的客户端和服务器都运行的是最新版本的 OpenSSL,您可能会得到一个。例如,如果我在服务器模式下启动清单 6-3 中给出的safe_tls.py
脚本,并使用您将在清单 6-4 中遇到的test_tls.py
脚本连接到它,那么(给定我特定的笔记本电脑、操作系统和 OpenSSL 版本)我可以看到 Python 脚本已经优先考虑支持 PFS 的椭圆曲线 Diffie–Hellman exchange(ECDHE)密码,而无需我的询问。
$ python3.4 test_tls.py -a ca.crt localhost 1060
...
Cipher chosen for this connection... ECDHE-RSA-AES256-GCM-SHA384
Cipher defined in TLS version....... TLSv1/SSLv3
Cipher key has this many bits....... 256
Compression algorithm in use........ none
因此,Python 通常会做出好的选择,而不需要你做具体的说明。尽管如此,如果您想要保证特定的协议版本或算法投入使用,只需锁定您的特定选择的上下文。例如,在本书即将出版之际,一个好的服务器配置(对于不期望客户端提供 TLS 证书的服务器,因此可以选择CERT_NONE
作为其验证模式)如下:
context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
context.verify_mode = ssl.CERT_NONE
context.options |= ssl.OP_CIPHER_SERVER_PREFERENCE # choose *our* favorite cipher
context.options |= ssl.OP_NO_COMPRESSION # avoid CRIME exploit
context.options |= ssl.OP_SINGLE_DH_USE # for PFS
context.options |= ssl.OP_SINGLE_ECDH_USE # for PFS
context.set_ciphers('ECDH+AES128 ') # choose over AES256, says Schneier
每当创建服务器套接字时,您都可以将这些代码行替换到类似于清单 6-3 的程序中。在这里,确切的 TLS 版本和密码被锁定为只有几个显式选项。任何试图连接但不支持这些选项的客户端都将失败,而不是成功建立连接。如果您将前面的代码添加到清单 6-3 中来代替默认的上下文,那么一个试图使用稍旧一点的 TLS 版本(如 1.1)或稍弱一点的密码(如 3DES)进行连接的客户端将会被拒绝。
$ python3.4 test_tls.py -p TLSv1_1 -a ca.crt localhost 1060
Address we want to talk to.......... ('localhost', 1060)
Traceback (most recent call last):
...
ssl.SSLError: [SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:598)
$ python3.4 test_tls.py -C 'ECDH+3DES' -a ca.crt localhost 1060
Address we want to talk to.......... ('localhost', 1060)
Traceback (most recent call last):
...
ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:598)
在每种情况下,服务器还会引发一个 Python 异常,从自己的角度诊断故障。因此,如果成功的话,最终的连接保证使用 TLS (1.2)的最新和最有能力的版本,并具有可用于保护您的数据的最佳密码之一。
从ssl
模块的默认上下文切换到像这样的手动设置的问题是,当你第一次编写应用时,你不仅必须进行研究以确定你的需求并选择 TLS 版本和密码,而且你——或你将来维护软件的继任者——必须继续保持最新,以防你的选择后来被发现容易受到新的利用。TLS 版本 1.2 结合椭圆曲线 Diffie-Hellman 看起来很棒,至少这本书即将出版。然而,有一天这个选择可能会显得过时甚至古怪。或者看起来很不自信。你能很快学会这一点并把你的软件项目中的手动选择更新到更好的吗?
除非有一天获得一个选项,让你坚持完美的远期安全,否则你会发现自己卡在这两个选项之间。要么您必须信任默认上下文,并接受与您通信的一些客户端(或服务器)可能不会受到 PFS 保护,要么您必须锁定密码的选择,然后跟上来自加密社区的新闻。
请注意,PFS 只有在您的机制定期丢弃服务器维护的会话状态或会话票证密钥时才是“完美的”。在最简单的情况下,每天晚上简单地重新启动您的服务器进程应该可以确保生成新的密钥,但是如果您有一整队服务器要部署,并且希望它们能够有效地支持 TLS 客户端池(利用会话重新启动),则需要做进一步的研究。(然而,在这种情况下——希望整个集群的会话重启键得到协调,而不损害 PFS——可能开始更有意义地考虑使用 Python 之外的工具来执行 TLS 终止!)
最后要考虑的是,如果您是编写或至少配置客户机和服务器的人,锁定密码的选择要容易得多,如果您在自己的机房内或在自己的服务器之间建立加密通信,就可能是这种情况。当由其他方管理的其他软件开始发挥作用时,一个不太灵活的密码集可能会使其他人更难与您的服务进行互操作,特别是如果他们的工具使用 TLS 的其他实现。如果您确实只锁定了几个选项,请尝试为编写和配置客户端的人员清楚、突出地记录这些选项,以便他们可以诊断为什么旧客户端可能会出现连接问题。
TLS 的协议支持
到目前为止,大多数广泛使用的互联网协议都增加了 TLS 支持。当使用 Python 标准库模块或第三方库中的这些协议时,要搜索的重要特性是如何配置 TLS 密码和选项,以防止对等方使用弱协议版本、弱密码或削弱协议的选项(如压缩)进行连接。这种配置可能采取特定于库的 API 调用的形式,或者可能只是允许您传递一个带有配置选择的SSLContext
对象。
以下是 Python 标准库附带的 TLS 感知协议:
http.client
:当你构建一个HTTPSConnection
对象(见第九章)时,你可以使用构造函数的context
关键字传入一个带有你自己设置的SSLContext
。不幸的是,urllib.request
和第九章中记录的第三方请求库目前都不接受SSLContext
参数作为它们 API 的一部分。smtplib
:当你构建一个SMTP_SSL
对象(见第十三章)时,你可以使用构造函数的context
关键字传入一个带有你自己设置的SSLContext
。相反,如果您创建一个普通的SMTP
对象,然后调用它的starttls()
方法,那么您就为该方法调用提供了context
参数。poplib
:当你构建一个POP3_SSL
对象(见第十四章)的时候,你可以使用构造函数的context
关键字传入一个带有你自己设置的SSLContext
。相反,如果您创建一个普通的POP3
对象,然后调用它的stls()
方法,那么您将为该方法调用提供context
参数。imaplib
:当你构建一个IMAP4_SSL
对象(见第十五章)时,你可以使用构造函数的ssl_context
关键字传入一个带有你自己设置的SSLContext
。相反,如果您创建一个普通的IMAP4
对象,然后调用它的starttls()
方法,那么您将为该方法调用提供ssl_context
参数。ftplib
:当你构建一个FTP_TLS
对象(见第十七章)时,你可以使用构造函数的context
关键字传入一个带有你自己设置的SSLContext
。请注意,在您有机会打开加密之前,FTP 对话的前一行或前两行总是明文通过(例如“220”欢迎消息,通常包含服务器主机名)。在login()
方法发送用户名和密码之前,FTP_TLS
对象会自动打开加密。如果您没有登录到远程服务器,但是无论如何都想打开加密,您将不得不手动调用auth()
方法,作为您在连接后采取的第一个动作。NNTP_SSL
,你可以使用构造函数的ssl_context
关键字来传递一个带有你自己设置的SSLContext
。相反,如果您创建一个普通的NNTP
对象,然后调用它的starttls()
方法,那么您将为该方法调用提供context
参数。请注意,几乎所有这些协议都有一个共同的主题,那就是可以用两种不同的方式用 TLS 来扩展旧的纯文本协议。一种方法是,在使用协议的传统端口号进行老式的纯文本连接之后,在协议中添加一个新命令,允许在对话过程中升级到 TLS。另一种方法是让 Internet 标准专门为协议的 TLS 保护版本分配第二个明确定义的 TCP 端口号,在这种情况下,TLS 协商可以在连接时立即发生,而无需请求。前面提到的大多数协议都支持这两种选择,但是 HTTP 只选择了第二种,因为该协议在设计上是无状态的。
如果您连接到由另一个团队或组织配置的服务器,该服务器支持某个以前协议的 TLS 版本,那么您可能只需进行测试(在没有他们可能提供的任何文档的情况下),以确定他们是否打开了协议的新 TLS 端口,或者只支持基于旧纯文本协议的 TLS 升级。
如果您不是依赖标准库来进行网络通信,而是使用从本书或其他地方了解到的第三方包,那么您会希望查阅其文档来了解如何提供自己的SSLContext
。如果没有提供任何机制——在我输入本文时,甚至流行的第三方库通常也没有为 Python 3.4 和更新版本的用户提供这种能力——那么您将不得不尝试该包提供的任何旋钮和设置,并测试结果(可能使用下一节介绍的清单 6-4 ,以查看第三方库是否保证足够强大的协议和密码来保护您的数据所需的隐私。
学习细节
为了帮助您了解更多关于 TLS 协议版本和您的客户端和服务器可以做出的密码选择,清单 6-4 提供了一个 Python 3.4 脚本,它创建一个加密连接,然后报告它的特性。为此,它使用了标准库ssl
模块的SSLSocket
对象的几个最新特性,这些特性现在允许 Python 脚本自省其 OpenSSL 驱动的连接的状态,以查看它们是如何配置的。
它用来进行报告的方法如下:
getpeercert()
:这是SSLSocket
的一个长期特性,在以前的几个 Python 版本中都有,这个方法返回从 TLS 会话所连接的对等体的 X.509 证书中挑选出来的字段的 Python 字典。但是最近的 Python 版本已经扩展了公开的证书特性的范围。cipher()
:返回 OpenSSL 和对等体的 TLS 实现最终商定的、当前在连接上使用的密码的名称。compression(
)
:返回正在使用的压缩算法的名称,否则返回 Python singleton None
。为了使它的报告尽可能完整,清单 6-4 中的脚本还尝试使用ctypes
来学习正在使用的 TLS 协议(理想情况下,到 Python 3.5 发布时,这将成为ssl
模块的原生特性)。通过将这些部分组合在一起,清单 6-4 让您连接到您已经构建好的客户机或服务器,并了解它将协商或不协商什么样的密码和协议。
清单 6-4 。连接到任何 TLS 端点并报告协商的密码
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter06/test_tls.py # Attempt a TLS connection and, if successful, report its properties import argparse, socket, ssl, sys, textwrap import ctypes from pprint import pprint def open_tls(context, address, server=False): raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if server: raw_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) raw_sock.bind(address) raw_sock.listen(1) say('Interface where we are listening', address) raw_client_sock, address = raw_sock.accept() say('Client has connected from address', address) return context.wrap_socket(raw_client_sock, server_side=True) else: say('Address we want to talk to', address) raw_sock.connect(address) return context.wrap_socket(raw_sock) def describe(ssl_sock, hostname, server=False, debug=False): cert = ssl_sock.getpeercert() if cert is None: say('Peer certificate', 'none') else: say('Peer certificate', 'provided') subject = cert.get('subject', []) names = [name for names in subject for (key, name) in names if key == 'commonName'] if 'subjectAltName' in cert: names.extend(name for (key, name) in cert['subjectAltName'] if key == 'DNS') say('Name(s) on peer certificate', *names or ['none']) if (not server) and names: try: ssl.match_hostname(cert, hostname) except ssl.CertificateError as e: message = str(e) else: message = 'Yes' say('Whether name(s) match the hostname', message) for category, count in sorted(context.cert_store_stats().items()): say('Certificates loaded of type {}'.format(category), count) try: protocol_version = SSL_get_version(ssl_sock) except Exception: if debug: raise else: say('Protocol version negotiated', protocol_version) cipher, version, bits = ssl_sock.cipher() compression = ssl_sock.compression() say('Cipher chosen for this connection', cipher) say('Cipher defined in TLS version', version) say('Cipher key has this many bits', bits) say('Compression algorithm in use', compression or 'none') return cert class PySSLSocket(ctypes.Structure): """The first few fields of a PySSLSocket (see Python's Modules/_ssl.c).""" _fields_ = [('ob_refcnt', ctypes.c_ulong), ('ob_type', ctypes.c_void_p), ('Socket', ctypes.c_void_p), ('ssl', ctypes.c_void_p)] def SSL_get_version(ssl_sock): """Reach behind the scenes for a socket's TLS protocol version.""" lib = ctypes.CDLL(ssl._ssl.__file__) lib.SSL_get_version.restype = ctypes.c_char_p address = id(ssl_sock._sslobj) struct = ctypes.cast(address, ctypes.POINTER(PySSLSocket)).contents version_bytestring = lib.SSL_get_version(struct.ssl) return version_bytestring.decode('ascii') def lookup(prefix, name): if not name.startswith(prefix): name = prefix + name try: return getattr(ssl, name) except AttributeError: matching_names = (s for s in dir(ssl) if s.startswith(prefix)) message = 'Error: {!r} is not one of the available names:\n {}'.format( name, ' '.join(sorted(matching_names))) print(fill(message), file=sys.stderr) sys.exit(2) def say(title, *words): print(fill(title.ljust(36, '.') + ' ' + ' '.join(str(w) for w in words))) def fill(text): return textwrap.fill(text, subsequent_indent=' ', break_long_words=False, break_on_hyphens=False) if __name__ == '__main__': parser = argparse.ArgumentParser(description='Protect a socket with TLS') parser.add_argument('host', help='hostname or IP address') parser.add_argument('port', type=int, help='TCP port number') parser.add_argument('-a', metavar='cafile', default=None, help='authority: path to CA certificate PEM file') parser.add_argument('-c', metavar='certfile', default=None, help='path to PEM file with client certificate') parser.add_argument('-C', metavar='ciphers', default='ALL', help='list of ciphers, formatted per OpenSSL') parser.add_argument('-p', metavar='PROTOCOL', default='SSLv23', help='protocol version (default: "SSLv23")') parser.add_argument('-s', metavar='certfile', default=None, help='run as server: path to certificate PEM file') parser.add_argument('-d', action='store_true', default=False, help='debug mode: do not hide "ctypes" exceptions') parser.add_argument('-v', action='store_true', default=False, help='verbose: print out remote certificate') args = parser.parse_args() address = (args.host, args.port) protocol = lookup('PROTOCOL_', args.p) context = ssl.SSLContext(protocol) context.set_ciphers(args.C) context.check_hostname = False if (args.s is not None) and (args.c is not None): parser.error('you cannot specify both -c and -s') elif args.s is not None: context.verify_mode = ssl.CERT_OPTIONAL purpose = ssl.Purpose.CLIENT_AUTH context.load_cert_chain(args.s) else: context.verify_mode = ssl.CERT_REQUIRED purpose = ssl.Purpose.SERVER_AUTH if args.c is not None: context.load_cert_chain(args.c) if args.a is None: context.load_default_certs(purpose) else: context.load_verify_locations(args.a) print() ssl_sock = open_tls(context, address, args.s) cert = describe(ssl_sock, args.host, args.s, args.d) print() if args.v: pprint(cert)
通过使用标准的–h
帮助选项运行该工具,您可以很容易地了解该工具支持的命令行选项。它试图通过它的命令行选项来展示一个SSLContext
的所有主要特性,这样你就可以试验它们并了解它们是如何影响谈判的。例如,您可以调查使用 Python 3.4 的create_default_context()
的服务器的默认设置如何比使用它的客户端的设置更严格。在一个终端窗口中,作为服务器启动清单 6-3 中的脚本。我将再次假设您已经从本书的源代码库的chapter06
目录中获得了证书文件ca.crt
和localhost.pem
。
$ /usr/bin/python3.4 safe_tls.py -s localhost.pem '' 1060
此服务器乐于接受使用最新协议版本和密码的连接;事实上,如果有机会,它会协商一个启用了完美前向安全性的强配置。简单地使用 Python 的默认值,看看如果使用清单 6-4 中的连接会发生什么,如下所示:
$ /usr/bin/python3.4 test_tls.py -a ca.crt localhost 1060
Address we want to talk to.......... ('localhost', 1060)
Peer certificate.................... provided
Name(s) on peer certificate......... localhost
Whether name(s) match the hostname.. Yes
Certificates loaded of type crl..... 0
Certificates loaded of type x509.... 1
Certificates loaded of type x509_ca. 0
Protocol version negotiated......... TLSv1.2
Cipher chosen for this connection... ECDHE-RSA-AES128-GCM-SHA256
Cipher defined in TLS version....... TLSv1/SSLv3
Cipher key has this many bits....... 128
Compression algorithm in use........ none
The combination ECDHE-RSA-AES128-GCM-
SHA256
是 OpenSSL 目前提供的最好的之一!但是safe_tls.py
服务器将拒绝与只支持 Windows XP 加密级别的客户端对话。再次启动safe_tls.py
服务器进行另一次运行,这次使用以下选项进行连接:
$ /usr/bin/python3.4 test_tls.py -p SSLv3 -a ca.crt localhost 1060
Address we want to talk to.......... ('localhost', 1060)
Traceback (most recent call last):
...
ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:598)
旧的 SSLv3 协议被 Python 提供的谨慎的服务器设置断然拒绝。即使与现代协议结合使用,像 RC4 这样的旧的生命周期终结密码也将导致失败。
$ /usr/bin/python3.4 test_tls.py -C 'RC4' -a ca.crt localhost 1060
Address we want to talk to.......... ('localhost', 1060)
Traceback (most recent call last):
...
ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure (_ssl.c:598)
但是,如果您将“安全”脚本放在客户端角色中,它的行为会发生很大的变化,因为前面讨论过,实际上是服务器负责决定连接应该有多安全,而客户端作者通常只希望在不完全暴露数据的情况下工作。请记住,安全服务器在早期测试时会拒绝使用 RC4 语。看看当你尝试使用 RC4 的tls_safe.py
客户端时会发生什么。首先,关闭您已经运行的任何服务器,并作为服务器运行测试脚本,用-C
设置密码。
$ /usr/bin/python3.4 test_tls.py -C 'RC4' -s localhost.pem '' 1060
Interface where we are listening.... ('', 1060)
然后转到另一个终端窗口,尝试使用使用 Python 3.4 默认上下文的safe_tls.py
脚本进行连接。
$ /usr/bin/python3.4 safe_tls.py -a ca.crt localhost 1060
即使使用安全的默认上下文,连接也会成功!在服务器窗口,你会看到 RC4 确实被选为流密码。然而,通过为–C
选项提供不同的字符串,您可以确认 RC4 低到安全脚本愿意弯腰的程度。像MD5
这样的密码或算法将被彻底拒绝,因为对于试图确保与用户可能想要与之通信的任何服务器最大限度兼容的客户端来说,这甚至是不合理的。
查阅ssl
模块文档,然后查阅官方 OpenSSL 文档,了解更多关于定制协议和密码选择的信息。如果您的系统包含本机 OpenSSL 命令行,那么这是一个非常有用的工具,它可以打印出与特定密码字符串匹配的所有密码——这个字符串可能是您用其–C
选项提供给清单 6-3 的,或者在您自己的代码中用set_cipher()
方法指定的。另外,随着加密技术的不断进步和系统上 OpenSSL 的升级,命令行将允许您测试各种密码规则如何随着时间的推移而改变它们的效果。现在,为了展示其用法的一个例子,下面是在我输入的 Ubuntu 笔记本电脑上使用时与ECDH+AES128
密码字符串匹配的密码:
$ openssl ciphers -v 'ECDH+AES128'
ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH Au=RSA Enc=AESGCM(128) Mac=AEAD
ECDHE-ECDSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH Au=ECDSA Enc=AESGCM(128) Mac=AEAD
ECDHE-RSA-AES128-SHA256 TLSv1.2 Kx=ECDH Au=RSA Enc=AES(128) Mac=SHA256
ECDHE-ECDSA-AES128-SHA256 TLSv1.2 Kx=ECDH Au=ECDSA Enc=AES(128) Mac=SHA256
ECDHE-RSA-AES128-SHA SSLv3 Kx=ECDH Au=RSA Enc=AES(128) Mac=SHA1
ECDHE-ECDSA-AES128-SHA SSLv3 Kx=ECDH Au=ECDSA Enc=AES(128) Mac=SHA1
AECDH-AES128-SHA SSLv3 Kx=ECDH Au=None Enc=AES(128) Mac=SHA1
ECDH-RSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH/RSA Au=ECDH Enc=AESGCM(128) Mac=AEAD
ECDH-ECDSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH/ECDSA Au=ECDH Enc=AESGCM(128) Mac=AEAD
ECDH-RSA-AES128-SHA256 TLSv1.2 Kx=ECDH/RSA Au=ECDH Enc=AES(128) Mac=SHA256
ECDH-ECDSA-AES128-SHA256 TLSv1.2 Kx=ECDH/ECDSA Au=ECDH Enc=AES(128) Mac=SHA256
ECDH-RSA-AES128-SHA SSLv3 Kx=ECDH/RSA Au=ECDH Enc=AES(128) Mac=SHA1
ECDH-ECDSA-AES128-SHA SSLv3 Kx=ECDH/ECDSA Au=ECDH Enc=AES(128) Mac=SHA1
在设置set_cipher('ECDH+AES128')
下, OpenSSL 库将认为这些组合中的任何一个都是公平游戏。同样,我的建议是尽可能使用默认上下文,或者测试您希望使用的特定客户端和服务器,尝试选择一两个它们都支持的强密码。但是如果你最终做了更多的试验和调试,那么我希望清单 6-4 将成为你试验和缩小 OpenSSL 行为的有用工具。如果有机会的话,一定要从顶部评论中的 URL 下载清单 6-4 的新版本,因为书中的版本会过时;我将努力保持在线更新,更新密码学和 Python ssl
API 的最新进展。
摘要
本章讨论了一个很少有人真正精通的主题:使用加密技术来保护通过 TCP 套接字传输的数据,特别是使用 Python 中的 TLS 协议(曾被称为 SSL)。
在典型的 TLS 交换中,客户端要求服务器提供一个证书——一个声明身份的数字文档。客户机和服务器都信任的权威机构应该对它进行签名,它必须包含一个公钥,服务器需要用这个公钥来证明它确实拥有一个副本。客户端应该验证证书中声明的身份是否与它认为已经连接到的主机名相匹配。最后,客户机和服务器协商诸如密码、压缩和密钥等设置,然后使用这些设置来保护通过套接字双向传递的数据。
许多管理员甚至不想在他们的应用中支持 TLS。相反,他们将应用隐藏在 Apache、nginx 或 HAProxy 等工业级前端之后,这些前端可以代表他们执行 TLS。前面有内容交付网络的服务也必须卸载 TLS 责任,而不是将其嵌入到自己的应用中。
尽管网络搜索会显示可以在 Python 中执行 TLS 的第三方库,但该语言的内置功能来自标准库中 OpenSSL 驱动的ssl
模块。假设ssl
可用,并且在您的操作系统和 Python 版本上正常工作,那么就可以建立基本的加密通道,只需要一个服务器证书就可以运行。
为 Python 3.4 和更高版本编写的 Python 应用(如果您的应用要执行自己的 TLS,我强烈建议至少使用 3.4 版本)通常会遵循这样的模式:创建一个“上下文”对象,打开一个连接,然后调用上下文的wrap_socket()
方法将连接交给 TLS 协议控制。尽管ssl
模块确实提供了一两个在旧代码中使用的快捷函数,但是上下文连接包装模式是最通用和最灵活的。
许多 Python 客户端和服务器可以简单地接受由ssl.create_default_context()
返回的默认“上下文”对象提供的设置,该对象试图使服务器在它们将接受的设置方面稍微严格一些,但使客户端稍微宽松一些,以便它们可以成功地连接到只有旧版本 TLS 可用的服务器。其他 Python 应用会希望实例化自己的SSLContext
,以便根据自己的特定需求定制协议和密码。在任何情况下,本章中显示的测试脚本或另一个 TLS 工具都可以用来研究由设置导致的行为。
标准库支持许多协议,这些协议可以选择用 TLS 来保护,其中大部分将在本书后面的章节中探讨。如果你能提供一个对象,它们都支持一个SSLContext
对象。目前,第三方库对上下文的支持很差,因为 Python 3.4 最近才发布,而且大多数 Python 程序员仍然在使用 Python 2。理想情况下,这两种情况都会随着时间的推移而改善。
一旦在应用中实现了 TLS,使用工具测试它总是值得的,这些工具将使用不同的参数集尝试各种类型的连接。在 Python 之外,第三方工具和网站都可以用来测试 TLS 客户端和服务器,如果您想在 OpenSSL 上使用不同的设置来查看它如何协商和行为,那么清单 6-4 中显示的工具可以在您自己的机器上与 Python 3.4 一起使用。
网络服务的作者面临两个挑战。第一个是核心挑战,即编写代码来正确响应传入的请求并精心制作适当的响应。第二个任务是在 Windows 服务或 Unix 守护进程中安装该网络代码,该守护进程在系统启动时自动启动,将其活动记录到持久性存储中,如果无法连接到其数据库或后端数据存储,则发出警报,或者完全保护自己免受所有可能的故障模式的影响,或者在出现故障时可以快速重启。
本书关注这两个挑战中的第一个。第二个挑战是保持一个进程在您选择的操作系统上运行,这不仅是一个可以用一整本书来专门讨论的主题,而且是一个将使本书远离其网络编程中心主题的主题。因此,本章将只花一节介绍部署的主题,然后再讨论如何将网络服务器制作成软件的真正主题。
我们对网络服务器的处理将自然地分成三个主题。我将首先介绍一个简单的单线程服务器,类似于 UDP 服务器(在第二章中介绍)和 TCP 服务器(在第三章中介绍),并重点介绍它的局限性:它一次只能服务一个客户端,使任何其他客户端等待,甚至当与该客户端通信时,它可能会使系统 CPU 几乎完全空闲。一旦您理解了这一挑战,您将继续研究两种相互竞争的解决方案:要么在多个线程或进程中复制单线程服务器,要么将多路复用的任务从操作系统中分离出来,通过使用异步网络操作在您自己的代码中完成。
在研究线程和异步网络代码时,您将首先从头开始实现每个模式,然后您将看到代表您实现每个模式的框架。我举例说明的所有框架都将来自 Python 标准库,但本文也将指出标准库的主要第三方竞争对手。
本章中的大多数脚本也可以在 Python 2 下运行,但是引入的最先进的框架——新的asyncio
模块——是专门针对 Python 3 的,这是标准化方面的一大进步,只有准备升级的程序员才能享受到。
关于部署的几句话
您可以将网络服务部署到一台或多台机器上。客户只需连接到它的 IP 地址,就可以使用一台机器上的服务。在几台机器上运行的服务需要更复杂的方法。您可以为每个客户端提供服务的单个实例的地址或主机名,例如,与特定客户端在同一机房运行的实例,但是您不会获得冗余。如果该服务实例关闭,那么硬连线到其主机名或 IP 地址的客户端将无法连接。
更可靠的方法是让 DNS 服务器返回服务名称被访问时服务所在的每个 IP 地址,并在第一个 IP 地址失败的情况下,将客户端写回第二个或第三个 IP 地址。当今行业中可伸缩性最好的方法是将您的服务置于客户端直接连接的负载平衡器 之后,然后将每个传入连接转发给位于其后的实际服务器。如果一台服务器出现故障,负载均衡器就会停止转发请求,直到它恢复运行,这使得大型客户端几乎看不到服务器故障。最大的互联网服务结合了这些方法:每个机房中的负载平衡器和服务器群具有公共 DNS 名称,该名称返回负载平衡器的 IP 地址,该负载平衡器的机房在地理上似乎离你最近。
无论您的服务架构多么简单或宏伟,您都需要某种方式在物理或虚拟机上运行您的 Python 服务器代码,这个过程称为部署。关于部署有两个学派。老式的技术是为你编写的每一个服务器程序配备一个服务的所有特性:双分叉成为一个 Unix 守护进程(或者将自己注册为一个 Windows 服务),安排系统级日志,支持一个配置文件,并提供一个可以启动、关闭和重启它的机制。您可以通过使用已经解决了这些问题的第三方库或者在您自己的代码中从头再做一遍来做到这一点。
类似*、十二要素应用* 这样的宣言推广了一种竞争方式。他们提倡一种极简主义的方法,在这种方法中,每个服务都被编写成一个在前台运行的普通程序,并不努力成为一个守护进程。这样的程序从它的环境(Python 中的sys.environ
字典)中获取任何它需要的配置选项,而不是期望一个系统范围的配置文件。它连接到环境命名的任何后端服务。它将日志信息直接打印到屏幕上——甚至通过 Python 自己的print()
函数这样一种简单的机制。通过打开并监听环境配置指定的任何端口来接受网络请求。
以这种极简风格编写的服务对于开发人员来说很容易在 shell 提示符下运行以进行测试。然而,只需在应用周围搭建合适的支架,就可以将它变成守护程序或系统服务,或者部署到 web 级的服务器群。例如,脚手架可以从中央配置服务中提取环境变量设置,将应用的标准输出和标准错误连接到远程日志服务器,并在服务失败或似乎冻结时重新启动服务。因为程序本身并不知道这一点,只是像往常一样打印到标准输出,所以程序员有信心,服务代码在生产中的运行与在开发中的运行完全一样。
现在有大型平台即服务提供商将为您托管此类应用,在一个面向公众的域名和 TCP 负载平衡器的背后构建数十份甚至数百份应用副本,然后聚合所有生成的日志进行分析。有些提供商允许您直接提交 Python 应用代码。其他人更喜欢将您的代码、Python 解释器和您需要的任何依赖项打包在一个容器中(尤其是“Docker”容器正在成为一种流行的机制),该容器可以在您自己的笔记本电脑上进行测试,然后进行部署,从而确保您的 Python 代码将从与您在测试中使用的映像完全相同的映像中运行。无论哪种方式,您都不必编写一个本身会产生多个流程的服务;您的服务的所有冗余/重复都由平台处理。
在 Python 社区中,让程序员摆脱编写独立服务的工作已经存在很长时间了。流行的supervisord
工具就是一个很好的例子。它可以运行程序的一个或多个副本,将标准输出和错误转移到日志文件中,在失败时重新启动进程,甚至在服务开始频繁失败时发送警报。
尽管有这些诱惑,如果您决定编写一个知道如何将自己变成守护进程的进程,您应该在 Python 社区中找到这样做的好模式。PEP 3143(可从http://python.org
获得)是一个很好的起点,它的“其他守护进程实现”一节是一个关于所需步骤的资源列表。supervisord
源代码可能也是感兴趣的,还有 Python 的标准库模块logging
的文档。
无论您拥有独立的 Python 进程还是基于平台的 web 级服务,如何最有效地使用操作系统网络堆栈和操作系统进程来满足网络请求的问题都是一样的。在本章的其余部分,您将把注意力转向这个问题,目标是使系统尽可能忙碌,以便客户机在网络请求得到响应之前尽可能少地等待。
简单的协议
为了让您的注意力集中在服务器设计提供的各种选项上,本章中的示例采用了一种最简单的 TCP 协议,其中客户端从三个纯文本 ASCII 问题中选择一个进行提问,然后等待服务器完成回答。与 HTTP 一样,客户端可以在套接字保持打开的情况下问尽可能多的问题,然后在没有任何警告的情况下关闭连接。每个问题的结尾用 ASCII 问号字符分隔。
Beautiful is better than?
然后,以句点分隔的答案被发送回来。
Ugly.
三个问答对中的每一个都基于 Python 之禅的格言之一,这是一首关于 Python 语言内部一致性设计的诗。运行 Python,在你需要灵感并想重读这首诗的任何时候输入import this
。
为了围绕这个协议构建一个客户机和几个服务器,在清单 7-1 中定义了一些例程,你会注意到它没有自己的命令行界面。该模块的存在只是为了被后续清单作为支持模块导入,这样它们就可以重用它的模式,而不必重复它们。
清单 7-1 。支持 Toy Zen-of-Python 协议的数据和例程
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/zen_utils.py # Constants and routines for supporting a certain network conversation. import argparse, socket, time aphorisms = {b'Beautiful is better than?': b'Ugly.', b'Explicit is better than?': b'Implicit.', b'Simple is better than?': b'Complex.'} def get_answer(aphorism): """Return the string response to a particular Zen-of-Python aphorism.""" time.sleep(0.0) # increase to simulate an expensive operation return aphorisms.get(aphorism, b'Error: unknown aphorism.') def parse_command_line(description): """Parse command line and return a socket address.""" parser = argparse.ArgumentParser(description=description) parser.add_argument('host', help='IP or hostname') parser.add_argument('-p', metavar='port', type=int, default=1060, help='TCP port (default 1060)') args = parser.parse_args() address = (args.host, args.p) return address def create_srv_socket(address): """Build and return a listening server socket.""" listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(address) listener.listen(64) print('Listening at {}'.format(address)) return listener def accept_connections_forever(listener): """Forever answer incoming connections on a listening socket.""" while True: sock, address = listener.accept() print('Accepted connection from {}'.format(address)) handle_conversation(sock, address) def handle_conversation(sock, address): """Converse with a client over `sock` until they are done talking.""" try: while True: handle_request(sock) except EOFError: print('Client socket to {} has closed'.format(address)) except Exception as e: print('Client {} error: {}'.format(address, e)) finally: sock.close() def handle_request(sock): """Receive a single client request on `sock` and send the answer.""" aphorism = recv_until(sock, b'?') answer = get_answer(aphorism) sock.sendall(answer) def recv_until(sock, suffix): """Receive bytes over socket `sock` until we receive the `suffix`.""" message = sock.recv(4096) if not message: raise EOFError('socket closed') while not message.endswith(suffix): data = sock.recv(4096) if not data: raise IOError('received {!r} then socket closed'.format(message)) message += data return message
客户机期望服务器理解的三个问题在aphorisms
字典中被列为关键字,它们的答案被存储为值。get_answer()
函数是一种快捷的方式,可以在字典中安全地查找答案,如果警句未被识别,会返回一条简短的错误消息。请注意,客户端请求总是以问号结尾,而答案——即使是回退错误消息——也总是以句点结尾。这两个标点符号为这个微小的协议提供了框架。
接下来的两个函数提供了一些将在服务器之间共享的公共启动代码。parse_command_line()
函数提供了一个读取命令行参数的通用方案,而create_srv_socket()
可以构建一个监听 TCP 套接字,服务器需要这个套接字来接收传入的连接。
但是在最后四个例程中,清单开始展示服务器进程的核心模式。这一连串的四个功能只是重复了你已经在第三章和第五章中学到的手势,第三章是关于为监听套接字创建一个 TCP 服务器,第五章是关于构造数据和处理错误。
accept_connections_forever()
是一个简单的listen()
循环,它在将套接字传递给下一个函数进行操作之前,用print()
通知每个连接客户端。handle_conversation()
是一个错误捕捉例程,它封装了无限数量的请求-响应循环,使得客户端套接字的任何问题都不可能导致程序崩溃。异常EOFError
被捕获在它自己特定的except
子句中,因为它是最里面的数据接收循环如何发出客户端已经完成请求并最终挂断的信号——在这个特定的协议(如 HTTP)中,这是正常的,而不是真正的异常事件。但是所有其他异常都被视为错误,并在被捕获后用print()
报告。(回想一下,所有正常的 Python 错误都继承自Exception
,因此将被这个except
子句拦截!)子句确保客户端套接字总是关闭的,不管该函数退出的代码路径是什么。像这样运行close()
总是安全的,因为 Python 中已经关闭的文件和套接字对象允许close()
被再次调用,以达到程序想要的次数。handle_request()
与客户端来回执行一次,读取其问题,然后回复一个答案。注意小心使用send_all()
,因为send()
调用本身不能保证整个有效载荷的交付。recv_until()
使用第五章中概述的练习来执行框架。对套接字的recv()
进行重复调用,直到累积的字节串最终成为一个完整的问题。这些例程是构建几个服务器的工具箱。
为了练习本章中的各种服务器,您需要一个客户端程序。清单 7-2 中提供了一个简单的命令行工具。
清单 7-2 。客户端程序,例如 Zen-of-Python 协议
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/client.py # Simple Zen-of-Python client that asks three questions then disconnects. import argparse, random, socket, zen_utils def client(address, cause_error=False): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(address) aphorisms = list(zen_utils.aphorisms) if cause_error: sock.sendall(aphorisms[0][:-1]) return for aphorism in random.sample(aphorisms, 3): sock.sendall(aphorism) print(aphorism, zen_utils.recv_until(sock, b'.')) sock.close() if __name__ == '__main__': parser = argparse.ArgumentParser(description='Example client') parser.add_argument('host', help='IP or hostname') parser.add_argument('-e', action='store_true', help='cause an error') parser.add_argument('-p', metavar='port', type=int, default=1060, help='TCP port (default 1060)') args = parser.parse_args() address = (args.host, args.p) client(address, args.e)
在正常情况下,当cause_error
是False
时,这个客户端创建一个 TCP 套接字并传输三个格言,在每个格言之后等待服务器回复一个答案。但是如果你想知道本章中的任何一个服务器在出错时会做什么,这个客户端的-e
选项会让它发送一个不完整的问题,然后在服务器上突然挂断。否则,如果服务器启动并正常运行,您应该会看到三个问题及其答案。
$ python client.py 127.0.0.1
b'Beautiful is better than?' b'Ugly.'
b'Simple is better than?' b'Complex.'
b'Explicit is better than?' b'Implicit.'
与本书中的许多其他示例一样,本章中的客户端和服务器使用端口 1060,但是接受一个-p
选项,如果您的系统上没有该端口,该选项可以指定一个替代端口。
单线程服务器
清单 7-1 的模块中提供的丰富的工具集将编写一个简单的单线程服务器的任务——最简单的可能设计,你已经在第三章看到了——减少到只有清单 7-3 的的三行函数。
清单 7-3 。最简单的服务器可能是单线程的
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_single.py
# Single-threaded server that serves one client at a time; others must wait.
import zen_utils
if __name__ == '__main__':
address = zen_utils.parse_command_line('simple single-threaded server')
listener = zen_utils.create_srv_socket(address)
zen_utils.accept_connections_forever(listener)
和你在第二章和第三章中写的服务器程序一样,这个服务器需要一个命令行参数:服务器应该监听传入连接的接口。为了保护服务器免受局域网或网络中其他人的攻击,请指定标准本地主机 IP 地址。
$ python srv_single.py 127.0.0.1
Listening at ('127.0.0.1', 1060)
或者更大胆一点,通过指定空字符串在机器的所有接口上提供服务,Python 将空字符串解释为当前机器上的每个接口。
$ python srv_single.py ''
Listening at ('', 1060)
无论哪种方式,服务器都会打印一行来宣布它成功地打开了它的服务器端口,然后等待传入的连接。服务器还支持一个-h
帮助选项和一个-p
选项来选择 1060 以外的端口,如果你想玩这些的话。一旦它启动并运行,尝试执行上一节中记录的客户机脚本来查看服务器的运行情况。当您的客户端连接和断开连接时,您会看到服务器在其运行的终端窗口中报告客户端活动。
Accepted connection from ('127.0.0.1', 40765)
Client socket to ('127.0.0.1', 1060) has closed
Accepted connection from ('127.0.0.1', 40768)
Client socket to ('127.0.0.1', 1060) has closed
如果您的网络服务一次只有一个客户端建立一个连接,那么这种设计就是您所需要的。一旦前一个连接关闭,该服务器就为下一个连接做好准备。只要连接存在,这个服务器要么在一个recv()
调用中被阻塞,等待操作系统在更多数据到达时唤醒它,要么它尽可能快地收集一个答案并立即传输它。send()
或sendall()
可以阻塞的唯一情况是当客户端还没有准备好接收数据时,在这种情况下,一旦客户端准备好,数据将被发送——并且服务器将被解除阻塞以返回到它的recv()
。因此,在所有情况下,响应都是以计算和接收响应的速度提供给客户端的。
当服务器仍在与第一个客户端对话时,第二个客户端试图连接时,这种单线程设计的弱点就很明显了。如果listen()
的整数参数大于零,那么操作系统将至少愿意通过三次 TCP 握手来确认第二个进入的客户端以建立连接,这在服务器最终准备好对话时节省了一点时间。但是,该连接将一直处于操作系统的监听队列中,直到服务器与第一个客户机的对话完成。只有当第一个客户机对话完成并且服务器代码返回到它对accept()
的下一个调用时,第二个客户机的连接才可用于服务器,并且它通过该套接字的第一个请求才能够得到响应。
对这个单线程服务器执行拒绝服务攻击很简单:连接并且永远不要关闭连接。服务器将在recv()
中保持永久阻塞,等待您的数据。如果服务器作者变得聪明,试图用sock.settimeout()
设置超时以避免永远等待,那么调整您的拒绝服务工具,使它发送请求的频率足够高,以至于永远不会超时。任何其他客户端都不能使用该服务器。
最后,单线程设计没有充分利用服务器 CPU 和系统资源,因为它不能在等待客户端发送下一个请求时执行其他操作。您可以通过在标准库中的trace
模块的控制下运行单线程服务器的每一行来计时它需要多长时间。为了将输出限制在服务器代码本身,告诉跟踪器忽略标准库模块(在我的系统上,Python 3.4 安装在/usr
目录下)。
$ python3.4 -m trace -tg --ignore-dir=/usr srv_single.py ''
每一行输出都给出了一行 Python 代码开始执行的时刻,从服务器启动时开始计算,以秒为单位。您将看到,大多数行在前一行结束时就开始执行,或者在同一百分之一秒内执行,或者在下一百分之一秒内执行。但是每次服务器需要在客户端等待的时候,执行就会停止,不得不等待。下面是一个运行示例:
3.02 zen_utils.py(40): print('Accepted connection...'...) 3.02 zen_utils.py(41): handle_conversation(sock, address) 3.02 zen_utils.py(57): aphorism = recv_until(sock, b'?') 3.03 zen_utils.py(63): message = sock.recv(4096) 3.03 zen_utils.py(64): if not message: 3.03 zen_utils.py(66): while not message.endswith(suffix): 3.03 zen_utils.py(57): aphorism = recv_until(sock, b'?') 3.03 zen_utils.py(63): message = sock.recv(4096) 3.08 zen_utils.py(64): if not message: 3.08 zen_utils.py(66): while not message.endswith(suffix): 3.08 zen_utils.py(57): aphorism = recv_until(sock, b'?') 3.08 zen_utils.py(63): message = sock.recv(4096) 3.12 zen_utils.py(64): if not message: 3.12 zen_utils.py(66): while not message.endswith(suffix): 3.12 zen_utils.py(57): aphorism = recv_until(sock, b'?') 3.12 zen_utils.py(63): message = sock.recv(4096) 3.16 zen_utils.py(64): if not message: 3.16 zen_utils.py(65): raise EOFError('socket closed') 3.16 zen_utils.py(48): except EOFError: 3.16 zen_utils.py(49): print('Client socket...has closed'...) 3.16 zen_utils.py(53): sock.close() 3.16 zen_utils.py(39): sock, address = listener.accept()
这是与client.py
程序的整个对话——三个请求和响应。在该跟踪的第一行和最后一行之间总共 0.14 秒的处理时间内,它必须在客户机上等待三次,总共大约 0.05 + 0.04 + 0.04 = 0.13 秒的空闲时间!这意味着在这个交换过程中,CPU 的占用率只有 0.01/0.14 = 7%。当然,这只是一个粗略的数字。我们在trace
下运行的事实降低了服务器的速度,增加了它的 CPU 使用率,这些数字的分辨率首先是近似的。但是如果你使用更复杂的工具,你会发现这个结果得到了证实。除非单线程服务器在每个请求期间都要做大量的 CPU 内工作,否则单线程服务器在最大限度地利用服务器方面表现不佳。当其他客户端排队等待服务时,CPU 处于空闲状态。
有两个有趣的技术细节值得评论。一个是第一个recv()
立即返回的事实——只有第二个和第三个recv()
调用在返回数据之前显示延迟,最后一个recv()
在得知套接字已经关闭之前也是如此。这是因为操作系统的网络堆栈很聪明地将第一个请求的文本包含在建立 TCP 连接的三次握手中。因此,当连接正式存在并且accept()
可以返回时,已经有数据等待从recv()
立即返回!
另一个细节是send()
不会导致延迟。这是因为它在 POSIX 系统上的语义是,一旦输出数据被登记到操作系统网络堆栈的输出缓冲区中,它就返回。不能保证系统真的发送了任何数据,因为send()
已经返回!只有转过身来监听更多的客户端数据,程序才能迫使操作系统阻塞它的进程,等待看到发送的结果。
让我们回到正题。如何克服单线程服务器的这些局限性?本章的其余部分将探讨两种防止单个客户端独占服务器的竞争技术。这两种技术都允许服务器同时与几个客户机对话。首先,我将介绍线程的使用(进程也可以),让操作系统在不同的客户机之间切换服务器的注意力。然后我将转向异步服务器设计,在这里我将展示如何自己处理注意力的切换,以便在一个控制线程中同时与几个客户机进行对话。
线程和多处理服务器
如果您希望您的服务器同时与几个客户端进行对话,一个流行的解决方案是利用操作系统的内置支持,通过创建共享相同内存占用的线程或创建彼此独立运行的进程,允许几个控制线程独立处理同一段代码。
这种方法的优势在于它的简单性:使用运行单线程服务器的相同代码,并启动它的几个副本。
它的缺点是您可以与之对话的客户端数量受到您的操作系统并发机制的限制。即使一个空闲或缓慢的客户端也会占用整个线程或进程的注意力,即使在recv()
中被阻塞,也会占用系统 RAM 和进程表中的一个槽。操作系统很少能很好地适应成千上万或更多同时运行的线程,当系统的注意力从一个客户端转移到下一个客户端时,所需的上下文切换会随着服务变得繁忙而使服务陷入困境。
您可能认为多线程或多进程服务器需要由一个主控制线程组成,该线程运行一个紧密的accept()
循环,然后将新的客户端套接字交给某种等待队列的工作线程。令人高兴的是,操作系统让事情变得简单多了:完全允许每个线程拥有一个监听服务器套接字的副本,并运行自己的accept()
语句。操作系统会将每个新的客户端连接交给等待其accept()
完成的线程,或者如果所有线程当前都忙,则让连接排队,直到其中一个线程准备好。清单 7-4 显示了一个例子。
清单 7-4 。多线程服务器
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_threaded.py # Using multiple threads to serve several clients in parallel. import zen_utils from threading import Thread def start_threads(listener, workers=4): t = (listener,) for i in range(workers): Thread(target=zen_utils.accept_connections_forever, args=t).start() if __name__ == '__main__': address = zen_utils.parse_command_line('multi-threaded server') listener = zen_utils.create_srv_socket(address) start_threads(listener)
请注意,这只是多线程程序的一种可能的设计:主线程启动 n 个服务器线程,然后退出,确信那些 n 个线程将永远运行,从而保持进程存活。其他选择也是可能的。例如,主线程可以保持活动状态,并成为服务器线程本身。或者它可以充当一个监视器,定期检查以确保 n 个服务器线程仍然运行,并在其中任何一个线程死亡时重新启动替换线程。从threading.Thread
到multiprocessing.Process
的切换将为每个控制线程提供其自己独立的内存映像和文件描述符空间,从操作系统的角度来看,这增加了开销,但更好地隔离了线程,并使它们更难使主监视器线程崩溃。
然而,所有这些模式,你可以在threading
和multiprocessing
模块的文档中,以及 Python 并发性的书籍和指南中了解到,都有一个共同的基本特征:将一个有点昂贵的操作系统可见的控制线程分配给每一个连接的客户端,无论该客户端此刻是否忙于发出请求。但是,由于您的服务器代码可以在处于多个线程控制下时保持不变(假设每个线程都建立自己的数据库连接并打开文件,因此线程之间不需要资源协调),因此在您的服务器工作负载上尝试多线程方法就足够简单了。如果它被证明能够处理您的请求负载,那么它的简单性使它成为一种特别有吸引力的技术,用于不被公众接触的内部服务,在这种情况下,对手不能简单地打开空闲连接,直到您耗尽您的线程或进程池。
传统 SocketServer 框架
在上一节使用操作系统可见的控制线程同时处理多个客户端对话中建立的模式非常流行,以至于有一个框架实现了 Python 标准库中内置的模式。虽然现在它已经显示出它的年龄,90 年代的设计充满了面向对象和多种继承的混合,但它值得一个快速的例子来展示多线程模式如何被推广,并使您熟悉该模块,以防您需要维护使用它的旧代码。
socketserver
模块(在 Python 2 时代被称为SocketServer
)从处理程序模式中分解出服务器模式,该模式知道如何打开监听套接字并接受新的客户端连接,该模式知道如何通过打开的套接字进行对话。这两种模式通过实例化一个服务器对象来组合,这个服务器对象被赋予一个 handler 类作为它的参数之一,正如你在清单 7-5 中看到的。
清单 7-5 。构建在标准库服务器模式之上的线程服务器
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_legacy1.py # Uses the legacy "socketserver" Standard Library module to write a server. from socketserver import BaseRequestHandler, TCPServer, ThreadingMixIn import zen_utils class ZenHandler(BaseRequestHandler): def handle(self): zen_utils.handle_conversation(self.request, self.client_address) class ZenServer(ThreadingMixIn, TCPServer): allow_reuse_address = 1 # address_family = socket.AF_INET6 # uncomment if you need IPv6 if __name__ == '__main__': address = zen_utils.parse_command_line('legacy "SocketServer" server') server = ZenServer(address, ZenHandler) server.serve_forever()
通过用ForkingMixIn
代替ThreadingMixIn
,程序员可以用完全隔离的进程代替线程来服务进入的客户端。
通过与早期的清单 7-4 进行比较,这种方法的巨大缺点应该是显而易见的,早期的清单 7-4 启动了固定数量的线程,服务器管理员可以根据给定的服务器和操作系统在不显著降低性能的情况下可以轻松管理的控制线程的数量来选择这些线程。相比之下,清单 7-5 让连接的客户端池决定启动多少线程——对最终在服务器上运行的线程数量没有限制!这使得攻击者很容易使服务器瘫痪。因此,不建议将此标准库模块用于生产和面向客户的服务。
异步服务器
在向客户端发送应答和接收下一个请求之间的延迟期间,如何让 CPU 保持忙碌,而不产生操作系统开销——每个客户端的可见控制线程?答案是,你可以使用一种异步模式来编写你的服务器,在这种模式下,代码不是阻塞和等待数据到达或离开一个特定的客户端,而是愿意从一个完整的等待客户端套接字列表中进行监听,并在其中一个客户端准备好进行更多交互时做出响应。
现代操作系统网络堆栈的两个特性使得这种模式成为可能。首先,它们提供了一个系统调用,让一个进程块等待一个完整的客户端套接字列表,而不是只等待一个客户端套接字,这允许一个线程同时服务数百或数千个客户端套接字。第二个特性是套接字可以被配置为非阻塞的,它承诺永远不会在send()
或recv()
调用中阻塞调用线程,但无论对话是否有进一步的进展,它总是会立即从send()
或recv()
系统调用中返回。如果进度被延迟,那么当客户端看起来准备好进行进一步的交互时,由调用者决定是否再试一次。
名称 asynchronous 意味着客户端代码永远不会停止等待特定的客户端,并且运行代码的控制线程不会与任何一个特定客户端的会话同步,或者以锁步方式等待。相反,它在所有连接的客户端之间自由切换,以完成服务工作。
操作系统通过几个调用来支持异步模式。最古老的是 POSIX 调用select()
,但是它有几个低效之处,这启发了现代的替代品,如 Linux 上的poll()
和 BSD 上的epoll()
。W. Richard Stevens 的《UNIX 网络编程》一书(Prentice Hall,2003)是这方面的标准参考资料。这里我将关注poll()
并跳过其他的,因为本章的目的并不是让你实现自己的异步控制循环。相反,你只是拿一个poll()
驱动的循环作为例子,这样你就可以理解在一个完整的异步框架下会发生什么,这也是你真正想要在你的程序中实现异步的方式。几个框架将在下面的章节中阐述。
清单 7-6 显示了简单 Zen 协议的原始异步服务器的完整内部结构。
清单 7-6 。原始异步事件循环
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_async.py # Asynchronous I/O driven directly by the poll() system call. import select, zen_utils def all_events_forever(poll_object): while True: for fd, event in poll_object.poll(): yield fd, event def serve(listener): sockets = {listener.fileno(): listener} addresses = {} bytes_received = {} bytes_to_send = {} poll_object = select.poll() poll_object.register(listener, select.POLLIN) for fd, event in all_events_forever(poll_object): sock = sockets[fd] # Socket closed: remove it from our data structures. if event & (select.POLLHUP | select.POLLERR | select.POLLNVAL): address = addresses.pop(sock) rb = bytes_received.pop(sock, b'') sb = bytes_to_send.pop(sock, b'') if rb: print('Client {} sent {} but then closed'.format(address, rb)) elif sb: print('Client {} closed before we sent {}'.format(address, sb)) else: print('Client {} closed socket normally'.format(address)) poll_object.unregister(fd) del sockets[fd] # New socket: add it to our data structures. elif sock is listener: sock, address = sock.accept() print('Accepted connection from {}'.format(address)) sock.setblocking(False) # force socket.timeout if we blunder sockets[sock.fileno()] = sock addresses[sock] = address poll_object.register(sock, select.POLLIN) # Incoming data: keep receiving until we see the suffix. elif event & select.POLLIN: more_data = sock.recv(4096) if not more_data: # end-of-file sock.close() # next poll() will POLLNVAL, and thus clean up continue data = bytes_received.pop(sock, b'') + more_data if data.endswith(b'?'): bytes_to_send[sock] = zen_utils.get_answer(data) poll_object.modify(sock, select.POLLOUT) else: bytes_received[sock] = data # Socket ready to send: keep sending until all bytes are delivered. elif event & select.POLLOUT: data = bytes_to_send.pop(sock) n = sock.send(data) if n < len(data): bytes_to_send[sock] = data[n:] else: poll_object.modify(sock, select.POLLIN) if __name__ == '__main__': address = zen_utils.parse_command_line('low-level async server') listener = zen_utils.create_srv_socket(address) serve(listener)
这个事件循环的本质是,它负责在自己的数据结构中维护每个客户端对话的状态,而不是依赖操作系统在活动从一个客户端转移到另一个客户端时切换上下文。服务器实际上有两个深度循环:一个是反复调用poll()
的while
循环,另一个是处理poll()
返回的每个事件的内部循环,因为每个调用可以返回许多事件。您将这两级迭代隐藏在生成器中,以防止主服务器循环被不必要地隐藏两级缩进。
维护了一个sockets
的字典,这样当poll()
告诉你文件描述符 n 准备好进行更多的活动时,你可以找到相应的 Python 套接字。您还记得您的套接字的addresses
,以便您可以用正确的远程地址打印诊断消息,即使在套接字已经关闭并且操作系统不再提醒您它所连接的端点之后。
但是异步服务器的真正核心是它的缓冲区:在等待请求完成时,您可以将传入的数据填入其中的bytes_received
字典,以及在操作系统可以调度它们进行传输之前,传出的字节等待的bytes_to_send
字典。连同你告诉poll()
你在每个套接字上等待的事件,这些数据结构形成了一个完整的状态机,用于一次一小步地处理客户端对话。
POLLIN
(“轮询输入”)状态。您通过运行accept()
来响应这样的活动,将套接字及其地址保存在您的字典中,并告诉 poll 对象您已经准备好从新的客户端套接字接收数据。POLLIN
事件时,你recv()
了 4KB 的数据。如果请求还没有用一个结束问号框起来,那么您将数据保存到bytes_received
字典中,并继续返回到循环的顶部,进一步执行poll()
。否则,你就有了一个完整的问题,你可以根据客户的要求,通过查找相应的回复并将其放入你的bytes_to_send
字典中。这涉及到一个关键点:将套接字从POLLIN
模式切换到POLLOUT
模式,在POLLIN
模式下,您希望知道更多数据何时到达,在POLLOUT
模式下,您希望在传出缓冲区空闲时得到通知,因为您现在使用套接字发送而不是接收。poll()
调用就会立即用POLLOUT
通知您,您可以通过尝试对剩余的要传输的所有内容进行send()
并只保留send()
无法挤入传出缓冲区的字节来做出响应。POLLOUT
到达,它的send()
让您完成所有剩余外向数据的传输。此时,一个请求-响应循环就完成了,您将套接字转回POLLIN
模式以处理另一个请求。异步方法的关键在于,这种单一的控制线程可以处理数百个,甚至数千个客户端对话。当每个客户端套接字为下一个事件做好准备时,代码会进入该套接字的下一个操作,接收或发送它能够接收或发送的数据,然后立即返回到poll()
以观察更多的活动。不需要单个操作系统上下文切换(除了为poll()
、recv()
、send()
和close()
系统调用进入操作系统本身所涉及的特权模式升级和降级之外),这种单个控制线程可以通过将所有客户端对话状态保存在一组字典中(由客户端套接字索引)来处理大量客户端。本质上,您用 Python 字典支持的键查找代替了成熟的操作系统上下文切换,多线程或多进程服务器需要这种操作系统上下文切换来将其注意力从一个客户端切换到另一个客户端。
从技术上讲,即使没有用sock.setblocking(False)
将每个新的客户端套接字设置为非阻塞模式,前面的代码也可以正确运行。为什么呢?因为清单 7-6 除非有等待数据,否则从不调用recv()
,如果至少有一个字节的输入准备好了,recv()
从不阻塞;除非可以传输数据,否则它永远不会调用send()
,如果至少有一个字节可以写入操作系统的输出网络缓冲区,send()
永远不会阻塞。但是这个setblocking()
调用是谨慎的,以防你出错。在它不存在的情况下,对send()
或recv()
的错误调用将阻塞并使您对所有客户端都没有响应,除了您被阻塞的那个客户端。有了setblocking()
调用,您的一个混淆将引发socket.timeout
并提醒您,您已经设法进行了一个操作系统无法立即执行的调用。
如果您对这个服务器释放几个客户机,您会看到它的单线程泰然自若地处理所有同时进行的对话。但是你必须用清单 7-6 中的深入了解相当多的操作系统内部。如果您想专注于您的客户代码,而让其他人去担心select()
、poll()
或epoll()
的细节,该怎么办?
回调式异步
Python 3.4 向标准库引入了新的asyncio
框架,该框架部分由 Python 发明者吉多·范·罗苏姆设计。它为基于select()
、epoll()
和类似机制的事件循环提供了一个标准接口,试图统一一个在 Python 2 时代已经变得支离破碎的领域。
在考虑了清单 7-6 并注意到它的代码很少是专门针对你在本章中学习的示例问答协议的之后,你可能已经想象出了这样一个框架所承担的责任。它保持了一个中央选择风格的循环。它保存了一个关于预期 I/O 活动的套接字表,并在必要时从 select 循环中添加或删除它们。一旦套接字关闭,它就清理并放弃它们。最后,当实际数据到达时,它根据用户代码来确定正确的响应。
asyncio
框架支持两种编程风格。其中一个让程序员想起了 Python 2 下的老式 Twisted 框架,它让用户通过一个对象实例来保持与每个开放客户端的连接。在这种设计模式中,清单 7-6 推进客户端对话的步骤变成了对对象实例的方法调用。你可以在清单 7-7 中看到熟悉的读入问题并给出回答的步骤,它是以直接插入asyncio
框架的方式编写的。
清单 7-7 。回调风格的 asyncio 服务器
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_asyncio1.py # Asynchronous I/O inside "asyncio" callback methods. import asyncio, zen_utils class ZenServer(asyncio.Protocol): def connection_made(self, transport): self.transport = transport self.address = transport.get_extra_info('peername') self.data = b'' print('Accepted connection from {}'.format(self.address)) def data_received(self, data): self.data += data if self.data.endswith(b'?'): answer = zen_utils.get_answer(self.data) self.transport.write(answer) self.data = b'' def connection_lost(self, exc): if exc: print('Client {} error: {}'.format(self.address, exc)) elif self.data: print('Client {} sent {} but then closed' .format(self.address, self.data)) else: print('Client {} closed socket'.format(self.address)) if __name__ == '__main__': address = zen_utils.parse_command_line('asyncio server using callbacks') loop = asyncio.get_event_loop() coro = loop.create_server(ZenServer, *address) server = loop.run_until_complete(coro) print('Listening at {}'.format(address)) try: loop.run_forever() finally: server.close() loop.close()
在清单 7-7 的中,你可以看到实际的套接字对象被小心地保护起来,不受协议代码的影响。您向框架而不是套接字请求远程地址。数据是通过一个方法调用传递的,这个方法调用只显示已经到达的字符串。您想要传输的答案通过其transport.write()
方法调用被传递给框架,让您的代码不参与循环——确切地说——关于数据何时被传递给操作系统以传输回客户端。该框架向您保证它会尽快发生,只要它不会阻塞其他需要注意的客户端连接的进度。
异步工人通常会变得比这更复杂。一个常见的例子是,对客户端的响应不能像这里一样简单地编写,而是涉及读取文件系统上的文件或咨询后端服务,如数据库。在这种情况下,您的客户机代码将不得不面向两个不同的方向:在向客户机发送和接收数据时,以及在从文件系统或数据库发送和接收数据时,它都将遵从框架。在这种情况下,您的回调方法可能自己构建futures
对象,提供进一步的回调,在数据库或文件系统 I/O 最终完成时调用。详见官方asyncio
文档。
corroutine-style 异步
为asyncio
框架构建协议代码的另一种方法是构建一个协程,这是一个当它想要执行 I/O 时暂停的函数——将控制返回给它的调用者——而不是阻塞 I/O 例程本身。Python 语言支持协程的规范形式是通过生成器:内部有一个或多个yield
语句的函数,因此在被调用时,这些函数可以一口气说出一系列项目,而不是以一个返回值结束。
如果你以前编写过通用生成器,它的yield
语句只是提供消费项目,那么你会对asyncio
目标生成器的外观感到有点惊讶。它们利用了在 PEP 380 中开发的扩展的yield
语法。扩展的语法不仅允许一个正在运行的生成器用yield from
语句输出另一个生成器生成的所有项目,还允许yield
向协程内部返回值,甚至在用户需要时引发异常。这允许一种模式,在这种模式中,协程对一个对象执行一个result = yield
操作,描述它想要执行的一些操作——可能是对另一个套接字的读取或者对文件系统的访问——并且或者在result
中接收回成功操作的结果,或者就在协程中经历一个异常,指示操作失败。
清单 7-8 展示了作为协程实现的协议。
清单 7-8 。协程风格的 asyncio 服务器
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_asyncio2.py # Asynchronous I/O inside an "asyncio" coroutine. import asyncio, zen_utils @asyncio.coroutine def handle_conversation(reader, writer): address = writer.get_extra_info('peername') print('Accepted connection from {}'.format(address)) while True: data = b'' while not data.endswith(b'?'): more_data = yield from reader.read(4096) if not more_data: if data: print('Client {} sent {!r} but then closed' .format(address, data)) else: print('Client {} closed socket normally'.format(address)) return data += more_data answer = zen_utils.get_answer(data) writer.write(answer) if __name__ == '__main__': address = zen_utils.parse_command_line('asyncio server using coroutine') loop = asyncio.get_event_loop() coro = asyncio.start_server(handle_conversation, *address) server = loop.run_until_complete(coro) print('Listening at {}'.format(address)) try: loop.run_forever() finally: server.close() loop.close()
将这个清单与之前在服务器上的工作进行比较,您会发现所有的代码。重复调用recv()
的while
循环是一种老的帧策略,之后是写一个回复给等待的客户端,所有这些都包含在一个while
循环中,这个循环很乐意继续响应客户端想要的请求。但是有一个关键的区别,它阻止您简单地重用这个相同逻辑的早期实现。这里它采用了一个生成器的形式,在任何地方执行一个yield from
,早期的代码只是执行一个阻塞操作,然后等待操作系统响应。正是这种差异让这个生成器可以插入asyncio
子系统,而不会阻塞它,也不会阻止一个以上的工作人员同时取得进展。
PEP 380 推荐协程使用这种方法,因为它可以很容易地看出你的生成器可能会在哪里暂停。它可以在每次执行yield
时无限期地停止运行。一些程序员不喜欢用显式的yield
语句来修饰他们的代码,在 Python 2 中有像gevent
和eventlet
这样的框架,它们用正常的阻塞 I/O 调用来获取正常的网络代码,并专门拦截那些调用来执行真正的异步 I/O。在撰写本文时,这些框架还没有被移植到 Python 3,如果移植,它们仍然会面临竞争,因为asyncio
现在已经内置到标准库中了。如果它们真的出现了,那么程序员将不得不在冗长但显式的asyncio
协程方法和隐式但更紧凑的代码之间做出选择,前者在任何可能发生暂停的地方都可以看到“让步”,而后者在像recv()
这样的调用将控制返回到异步 I/O 循环时,代码本身看起来像是无辜的方法调用。
遗留模块 asyncore
如果您遇到任何针对asyncore
标准库模块编写的服务,清单 7-9 使用它来实现示例协议。
清单 7-9 。使用旧的 asyncore 框架
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/srv_legacy2.py # Uses the legacy "asyncore" Standard Library module to write a server. import asyncore, asynchat, zen_utils class ZenRequestHandler(asynchat.async_chat): def __init__(self, sock): asynchat.async_chat.__init__(self, sock) self.set_terminator(b'?') self.data = b'' def collect_incoming_data(self, more_data): self.data += more_data def found_terminator(self): answer = zen_utils.get_answer(self.data + b'?') self.push(answer) self.initiate_send() self.data = b'' class ZenServer(asyncore.dispatcher): def handle_accept(self): sock, address = self.accept() ZenRequestHandler(sock) if __name__ == '__main__': address = zen_utils.parse_command_line('legacy "asyncore" server') listener = zen_utils.create_srv_socket(address) server = ZenServer(listener) server.accepting = True # we already called listen() asyncore.loop()
如果您是一名经验丰富的 Python 程序员,这个清单将会引起您的警惕。ZenServer
对象从未被传递给asyncore.loop()
方法或以任何方式显式注册,然而控制循环似乎神奇地知道服务是可用的!显然,这个模块是在模块级的全局变量或其他一些恶意变量中进行交易,以在主控制循环、服务器对象和它所创建的请求处理程序之间建立链接,但这样做的方式你看不太清楚。
然而,你可以看到许多相同的步骤是在幕后完成的,这一点asyncio
已经公开了。每个新的客户端连接都会导致创建一个新的ZenRequestHandler
实例,您可以在它的实例变量中存储任何必要的状态,以跟上客户端对话的进展。此外,正如您一直在研究的这些异步框架一样,接收和发送之间存在不对称。接收数据包括将控制返回并移交给框架,然后针对作为输入到达的每个新字节块进行回调。但是发送数据是一个“一劳永逸”的操作——你将整个输出负载交给框架,然后可以返回控制权,相信框架会根据需要进行尽可能多的send()
调用来传输数据。
最后一次,你会看到异步框架,除非它们像gevent
或eventlet
(目前只有 Python 2 才有)一样有看不见的魔力,否则会迫使你使用不同的习惯用法来编写你的服务器代码,而不是像清单 7-3 所示的简单服务器那样。多线程和多处理只是不加修改地运行单线程代码,而异步方法迫使您将代码分成小块,每个小块都可以运行而不会阻塞。回调样式强制每个不可阻止的代码段存在于一个方法中;协程风格让你在yield
或yield from
语句之间插入每个基本的不可阻塞操作。
两全其美
这些异步服务器可以通过简单地从一个协议对象浏览到另一个协议对象,在一个客户端的流量和另一个客户端的流量之间灵活地切换(或者,在更原始的清单 7-6 的情况下,在一个字典条目和另一个字典条目之间切换)。与操作系统需要参与上下文切换相比,这可以用少得多的费用为客户端服务。
但是异步服务器有一个硬性限制。正是因为它在单个操作系统线程中完成所有工作,所以一旦它的 CPU 达到 100%的利用率,它就会碰壁,无法再处理更多的客户端工作。这是一种模式,至少在其原始形式中是如此,它总是被限制在单个处理器上,而不管您的服务器具有多少个内核。
幸运的是,解决方案就在眼前。当您需要高性能时,使用异步回调对象或协程编写您的服务,并在异步框架下启动它。然后后退一步,配置您的服务器操作系统,启动与您拥有的 CPU 内核一样多的事件循环进程!(请向您的服务器管理员咨询一个细节:您是否应该为操作系统留出一到两个内核,而不是占用所有内核?)你现在将拥有两个世界的精华。在给定的 CPU 上,异步框架可以快速运行,随心所欲地在活动的客户端套接字之间切换,而不会导致一个上下文切换到另一个进程。但是操作系统可以在所有活动的服务器进程之间分配新的连接,理想地平衡整个服务器的负载。
正如在“关于部署的一些话”一节中所讨论的,您可能希望将这些进程限制在一个守护进程中,该守护进程可以监视它们的健康状况并重新启动它们,或者在它们失败时通知工作人员。从supervisord
一直到完整的平台即服务容器化,这里讨论的任何机制都应该可以很好地用于异步服务。
在 inetd 下运行
在结束这一章之前,我应该提到古老的inetd
守护进程,它适用于几乎所有的 BSD 和 Linux 发行版。发明于互联网早期,它解决了当系统启动时,如果你想在给定的服务器上提供不同的网络服务,需要启动不同的守护进程的问题。在它的/etc/inetd.conf
文件中,您只需列出机器上您想要监听的每个端口。
inetd
守护进程对它们中的每一个执行bind()
和listen()
,但是只有当客户端真正连接时,它才会启动服务器进程。这种模式使得支持在普通用户帐户下运行的低端口号服务变得容易,因为inetd
本身就是打开低端口号端口的进程。对于像本章中这样的 TCP 服务(关于 UDP 数据报服务的更复杂的情况,请参见您的inetd(8)
文档),inetd
守护进程可以为每个客户端连接启动一个进程,或者在接受第一个连接后,期待您的服务器保持运行并继续监听新的连接。
为每个连接创建一个进程的成本更高,并且会给服务器带来更高的负载,但这也更简单。单次服务由服务的inetd.conf
条目的第四个字段中的字符串nowait
指定。
1060 stream tcp nowait brandon /usr/bin/python3 /usr/bin/python3 in_zen1.py
这样的服务将启动并发现它的标准输入、输出和错误已经连接到客户端套接字。该服务只需要与该客户端进行对话,然后退出。清单 7-10 给出了一个例子,可以和刚刚给出的inetd.conf
行结合使用。
清单 7-10 。回答一个客户端,它的套接字是 stdin/stdout/stderr
#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/in_zen1.py
# Single-shot server for the use of inetd(8).
import socket, sys, zen_utils
if __name__ == '__main__':
sock = socket.fromfd(0, socket.AF_INET, socket.SOCK_STREAM)
sys.stdin = open('/dev/null', 'r')
sys.stdout = sys.stderr = open('log.txt', 'a', buffering=1)
address = sock.getpeername()
print('Accepted connection from {}'.format(address))
zen_utils.handle_conversation(sock, address)
该脚本小心翼翼地用更合适的打开文件替换 Python 标准的输入、输出和错误对象,因为您很少希望原始的回溯和状态消息(Python 或其某个库可能会指向标准输出或特别是标准错误)中断您与客户端的对话。注意,这种策略只修复了 Python 内部的 I/O 尝试,因为它只触及到了sys
内部的文件对象,而不是真正的文件描述符。如果您的服务器调用任何执行自己的标准 I/O 的低级 C 库,那么您还需要关闭底层文件描述符 0、1 和 2。然而,在这种情况下,您开始着手进行沙箱化,这种沙箱化通过supervisord
(一种守护进程模块,或者平台风格的容器化,如前面“关于部署的一些话”一节中所述)来完成更好。
您可以在您的普通用户命令行中测试清单 7-10 ,只要您选择的端口不是一个低编号的端口,通过对一个包含前面给出的行的微小配置文件运行inetd -d inet.conf
,然后像往常一样用client.py
连接到端口。
另一种模式是在您的inetd.conf
条目的第四个字段中指定字符串wait
,这意味着您的脚本将被赋予监听器套接字本身。这给你的脚本一个任务,为当前正在等待的客户端调用accept()
。这样做的好处是,您的服务器可以选择保持活动状态,继续运行accept()
来接收更多的客户端连接,而不需要inetd
参与进来。这比为每个传入的连接启动一个全新的过程更有效。如果客户端停止连接一段时间,您的服务器可以自由地exit()
减少服务器机器的内存占用,直到客户端再次需要服务;inetd
将检测到您的服务已经退出,并再次接管监听工作。
清单 7-11 设计用于wait
模式。它能够永远接受新的连接,但如果几秒钟后没有任何新的客户端连接,它也可以超时并退出,使服务器不再需要将它保存在内存中。
清单 7-11 。回答一个或多个客户端连接,但最终会感到厌烦并超时
#!/usr/bin/env python3 # Foundations of Python Network Programming, Third Edition # https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter07/in_zen2.py # Multi-shot server for the use of inetd(8). import socket, sys, zen_utils if __name__ == '__main__': listener = socket.fromfd(0, socket.AF_INET, socket.SOCK_STREAM) sys.stdin = open('/dev/null', 'r') sys.stdout = sys.stderr = open('log.txt', 'a', buffering=1) listener.settimeout(8.0) try: zen_utils.accept_connections_forever(listener) except socket.timeout: print('Waited 8 seconds with no further connections; shutting down')
当然,这个服务器和我在本章开始时使用的是同一个原始的单线程设计。在生产中,你可能想要一个更健壮的设计,你可以使用本章讨论的任何方法。唯一的要求是他们能够接受一个已经监听的套接字,并在其上一遍又一遍地运行accept()
,直到永远。这很简单,如果你对你的服务器进程感到满意,一旦被inetd
启动,就永远不会退出。如果您希望服务器能够超时并在一段时间不活动后关闭,情况可能会变得更复杂一些(超出了本书的范围),因为对于一组线程或进程来说,确认它们中没有一个当前正在与客户端对话,并且它们中没有一个最近收到了足以保证服务器保持活动的客户端连接是很棘手的。
还有一个简单的基于 IP 地址和主机名的访问控制机制,它内置在某些版本的inetd
中。这个机制是一个名为tcpd
的老程序的派生,在被整合到同一个进程中之前,它曾经和inetd
一起工作。它的/etc/hosts.allow
和/etc/hosts.deny
文件可以根据它们的规则阻止部分(或全部!)IP 地址连接到您的服务之一。如果您正在调试客户端无法访问您的某个inetd
驱动的服务的问题,请务必阅读您的系统文档并查看您的系统管理员是如何配置这些文件的!
摘要
第二章的和第三章的的示例网络服务器一次只能与一个客户端交互,而所有其他的都必须等待,直到前一个客户端套接字关闭。有两种方法可以超越这个障碍。
从编程的角度来看,最简单的是多线程(或多处理),其中服务器代码通常可以保持不变,操作系统负责在工作线程之间进行不可见的切换,以便等待的客户端可以快速获得响应,而空闲的客户端不会消耗服务器 CPU。这种技术不仅允许几个客户端对话同时进行,而且还可以更好地利用服务器 CPU,否则服务器 CPU 可能会在大部分时间处于空闲状态,等待来自一个客户端的更多工作。
更复杂但更强大的方法是采用异步编程风格,通过向操作系统提供它当前正在与之对话的套接字的完整列表,让单个控制线程在任意多个客户端之间切换它的注意力。复杂的是,这需要读取客户端请求并构建响应的逻辑,将响应拆分为小的、非阻塞的代码片段,当需要再次等待客户端时,这些代码片段可以将控制权交还给异步框架。虽然可以使用类似于select()
或poll()
的机制手工编写异步服务器,但大多数程序员会希望依赖于一个框架,比如 Python 3.4 及更新版本中内置于标准库中的asyncio
框架。
安排您编写的服务安装在服务器上,并在系统启动时开始运行,这被称为部署,它可以使用许多现代机制实现自动化,或者使用supervisord
之类的工具,或者将控制权交给平台即服务容器。对于基线 Linux 服务器来说,最简单的部署可能是旧的inetd
守护进程,它提供了一种基本的方法来确保您的服务在客户端第一次需要它的时候启动。
在这本书里你会再次看到服务器的主题。在之后,第八章讨论了现代 Python 程序员所依赖的一些基本的基于网络的服务,第九章到第十一章将着眼于 HTTP 协议的设计和作为客户机和服务器的 Python 工具,在这里你将看到本章介绍的设计在诸如 Gunicorn 的分叉 web 服务器和诸如 Tornado 的异步框架之间的选择中再次可用。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。