读书笔记_python网络编程3(5)

时间:2023-03-08 16:32:07
读书笔记_python网络编程3(5)

5. 网络数据与网络错误

应该如何准备需要传输的数据?
应该如何对数据进行编码与格式化?
Py程序需要提供哪些类型的错误?

5.1. 字节与字符串

PC与网卡都支持将字节作为通用传输单元。字节将8比特的信息封装起来,作为信息存储的通用单位。

但是,RAM芯片与网卡不同,程序运行中,Py能隐藏RAM中的int、str、list、dict的具体实现细节,除非使用特殊调试工具,否则无法查看存储的字节,只能看到外部表现。

5.1.0. 网络通信的不同在于,socket接口将字节暴露了出来,字节无论对程序员还是程序都是可见的。在进行网络编程时,通常无法避免要考虑在传输过程中表示数据的方式,会带来一些问题,Py可以避免这类问题

5.1.0.1. 字节的特性:

1)位(bit)是信息的最小单元。每位可以是0/1,位一般通过高电压和低电压来实现

2)8位组成1字节(byte)

5.1.0.2. 组成字节的位需要按顺序排列,以便进行区分。一种解释字节的方法是将其看做一个介于00000000和11111111之间的数,十进制就是0255,也可把这个介于0255的数的最高位看成符号位,就可以表示负数了。从0开始反过来数,就可得到这些负数。10000000到11111111本来看做128255,但由于将最高位看做指示数字是否为负数的符号位,故这一区间就变成了-128-1(二进制补码运算)

5.1.0.3. 以前,不同PC上字节的的长度是不同的。网络标准使用8位字节(octet)表示8个二进制位组成的字节。

5.1.0.4. 在Py中,通常有两种表示字节的方法:

1)使用一个正好介于0~255的整数

2)使用一个字节字符串,字符串的唯一内容就是改字节本省,可使用Py源代码支持的任何常用进制来输入字节表示的数字

0b1100010
98
0b1100010 == 0o142 == 98 == 0x62
True

5.1.0.5. 可以把一个包含这些数字的list作为参数传给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字符对应的元素。对于没有对应可打印ASCII字符的元素,使用显示的十六进制格式\xNN来表示

b
b'\x00\x01bcd'

字节字符串在语义上并不表示ASCII字符,只用来表示8个二进制位组成的字节。

5.1.1. 字符串

通过socket传输一个符号串,需要使用ASCII编码方法,为每个符号分配一个确切的字节值。

ASCII定义了0到127的字符代码,可对应7个二进制位。因此,使用字节存储ASCII字符时,最高位始终是0。

5.1.1.0.以下是表示实际图像的3类ASCII字符,每类包含32个字符:

1)标点符号与各位数字

2)包含大写字母

3)包含小写字母

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, 右下角是DEl,不可见,代码为127的删除符。

位是按顺序排列的,要得到某个位的算术值,只需将字符代码的该位设为0,用原来的字符代码值减去置零后的值即可。另外,通过翻转表示十进制32的二进制位,可以完成大小写字母的转换,也可通过把str中所有字母的字符值中,表示十进制数32的二进制位设为1或0,将所有字母转换为大写或小写。

5.1.1.1.Unicode标准,Py将str看做是由Unicode字符组成的序列,隐藏str在RAM中的实际实现,在使用Py时无需考虑str的内部实现。不过,处理文件/网络撒花姑娘的数据时,必须考虑字符的外部表示。

5.1.1.2. 编码和解码会帮助我们在传输/存储信息的同时,保持信息意义的直观性

1)对字符进行编码(encoding):将真正的Unicode字符串转换为字节字符串。Py程序会将这些字节发送给外部的真实世界

2)对字节进行解码(decoding):将字节字符转换为真正的Unicode字符

可以认为外部世界由字节组成,这些字节通过某种密码保存。要在Py程序中的数据传输到外部世界,就需将这种密码翻译/破解。要将Py程序中的数据传输到外部世界,需将数据编码成外部世界理解的字节编码;把数据从外部世界转移到Py程序中,必须将其解码

5.1.1.3. 世界上的编码方式可分为两大类:

  1. 最简单的编码方式:单字节编码,最多可表示256个独立的字符,不过可保证每个字符都能唯一映射到一个单独的字节。在编写网络代码时,这种编码方式用起来是很简单的。如,事先知道从一个socket读取n个字节,会生成n个字符。同时,当一个流被分割为多个部分时,每个字节就是一个单独的字符,不需知道后续的字节,就能正确解释该字节。可以通过第n个字节,马上找到输入中的第n个字符
  2. 多字节编码,不具备上面提到的这些优势。有些多字节编码方式会使用固定的字节数表示一个字符,如UTF-32.如果数据中大多数都是ASCII字符的话,这种方法将相当浪费空间。优势是,每个字符的字节数都是相同的。在其他一些编码方式中,表示不同字符的字节数不同,操作起来要小心,如果数据流被分割为多个部分,不可能事先知道某个字符是否,由于位于分割边界而从中间被分开。如果想找到第n个字符,就必须从头开始顺序扫描,直到遇到第n个字符为止。

5.1.1.4. 想获取Py支持的所有编码方式列表,可查阅codecs模块的文档

Py内置的多数单字节编码都是ASCII的扩展,把剩下的128个值用于特定地域的字母/符号。

5.1.1.5. 标准库列出的许多Win编码方式也是如此,也有一些单字节编码与ASCII毫无管理,基于以前的IBM大型机的一些标准。

5.1.1.6. 最可能碰到的多字节编码方式是以前的UTF-16(Unicode支持的数字还较小,且适用于16位时,UTF-16曾短暂流行过)、现代UTF-32,非常流行的变长编码方式UTF-8。UTF-8看上去和ASCII差不多,唯一的区别是:包含的字符是从大于127的代码开始的

ASCII字母N、a、m、r、i分布在表示非ASCII字符的字节值之间。

5.1.1.7.每个多字节编码都包含了一个额外的字符,使得UTF-16编码的字节数为(8x2)+2,UTF-32编码的字节数为(8x4)+4,这个特殊的字符是/xfeff,是表示字节顺序的标记(BOM),可以通过该标记自动检测出Unicode字符串是先存储高位字节,还是低位字节。

操作已编码的文本时,会遇到两种字符错误:

1)要尝试载入的已编码字节字符串,不符合提供的用于解释的编码规则;

2)字符无法使用,提供的编码方式表示

b'\x80'.decode('ascii')
Traceback (most recent call last):
File "<input>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0x80 in position 0: ordinal not in range(128)

一般从两个方面来处理这样的错误:

1)确认是否使用了错误的编码方式

2)弄清楚为什么数据没有想我们预期的那样符合提供的编码方式,

如果这两种方式都么解决问题,代码必须从程序逻辑上兼容编码方式与实际字符串或数据不能匹配的情况。

需要阅读标准库文档,来了解一些处理错误的替代方法,而不使用异常处理机制。

b'ab\x80def'.decode('ascii', 'replace')
'ab�def'
b'ab\x80def'.decode('ascii', 'ignore')
'abdef'

codecs模块的标准库文档对此做出了描述,如果编码时对某些字符使用了多字节编码方式,那么对部分接收的信息进行解码是很危险的。因为,已经接收到的信息,与尚未传达的数据包之间的某个字符,可能已经被分隔了。

5.1.2 二进制数与网络字节顺序

如果只想通过网络发送文本,只需要考虑编码与封帧问题就可以。
有时,可能希望用一种更紧凑的格式来表示数据,使用文本无法达到该目的。另外,编写的Py代码可能会,与某个已经使用原始二进制数据的服务进行交互,需担心一个新问题---网络字节顺序

5.1.2.1. 网络上发送一个整数的过程,以发送整数4253的过程作为一个具体的例子

许多协议会简单把这个整数当做str'4253'来传输,当做4个单独的字符来传输。无论使用任何一种常见的文本编码方式,4个字符至少需要4个字节来传输,十进制还会引入一些计算开销,数字并不是以十进制存储的,程序会使用反复的除法运算来检查余数,这个程序中,会对要发送的值进行反复除法,发现它其实是由4个1000、2个100、5个10、3个1构成的。当接收方接收到长度为4的字符串'4253'时,需要进行反复的加法与10的幂的乘法,把收到的文本转换为数字。

使用纯文本表示数字是如今web上最流行的技术。如,当获取一个网页时,HTTP就会使用一个包含十进制的字符串(如'4253')表示结果的内容长度,虽然要付出一定的花销,但是网络Serv和Cli都会完成该str与十进制数的准换。

过去的20年,网络的发展其实就是把二进制替换成了更容易阅读的协议,尽管要比以前付出更多的计算花销

Py使用一个int来表示'4253'这个数值,PC将它存储为一个二进制数,使用多个连续字节中的位来表示1、2、4;

可以在Py使用内置的hex()函数来查看int的存储方式

hex(4253)
'0x109d'

每个十六进制位都对应4个二进制位,因此每2个十六进制位表示1个字节的数据。这个数字没有通过4个十进制来存储(4、2、5、3),如果使用十进制,第一个4就是最高位,3就是最低位。然而该数字是按16进制存储的,0x10是最高位字节,0x9d是最低位字节,两个字节在内存中直接相邻

5.1.2.2.大端法(以前的SPARC处理器),将最高位字节存储在前面,和书写十进制一样

5.1.2.3.小端法(x86架构),将最低位字节存储在前面(前面指内存低地址字节)

只要使用Py的struct模块,可以方便简单地看到两种方法的区别,提供了用于将数据与流行的二进制格式进行相互转换所需的各种操作,先用小端法表示4253,然后是大端法的表示形式

import struct
struct.pack('<i',4253) # 小端法
b'\x9d\x10\x00\x00'
struct.pack('>i', 4253) # 大端法
b'\x00\x00\x10\x9d'

使用了struct模块的格式化代码i,表示使用4字节存储一个整数。对于4253的这样较小的数字,前两个高位字节为0。可把struct表示端模式的两个符号<、>看成是与两种字节排列顺序对应的,箭头指向的方向就是字节str中的低位字节。

也支持unpack()操作,可以将二进制转换回Py数字。

>>> import struct
>>> struct.unpack('>i', b'\x00\x00\x10\x9d')
(4253,)

5.1.2.4.struct模块提供了另一个符号!,在pack()与unpack()时,表示与>相同的含义,会告诉其他程序员,对这个数据进行编码,是为了通过网络将其发送出去。用于网络socket传输的二进制数据,建议:

1)使用struct模块用于网络传输的二进制数据,接收方收到数据后,使用struct模块进行解码

2)要自己控制网络传输的数据格式的话,在选择网络字节顺序时使用!前缀

3)如果其他人设计了协议并使用小端法,必须使用<

5.1.2.5.使用struct一定要进行测试,将数据的存放方式与要使用的协议说明进行比较。编码后的格式化字符串汇总的x字符,可用来插入填充字节

5.2. 封帧与引用

使用UDP数据报进行通信,协议本身就会使用独立的,可识别的块进行数据传输,不过,如果网络出现问题,就必须自己重新排列并发送这些数据块。

5.2.0.选用TCP进行通信,就要应对封帧(framing)问题:如何分割消息,使得接收方能够识别消息的开始与结束。传递给sendall()的数据可能在实际网络传输时,被分割成多个数据包,接收消息的程序需要进行多个recv()调用才能读取完整的消息。如果每个数据包传达时,OS都能再次运行recv()的进程,可能不需要进行多个recv()调用。

封帧,需要考虑的问题是:接收方何时最终停止调用recv()才是安全的?
整个消息/数据何时才能完整无缺的传达?
何时才能将接收到的信息作为一个整体来解析/处理?

5.2.1.解决方法:

5.2.1.1.用于极其简单的网络协议,只涉及数据的发送,不关注响应。接收方永远不会认为"数据已经够了",向发送方发送响应。可使用:发送方循环发送数据,直到所有数据都被传递给sendall()为止,然后使用close()关闭socket。接收方只需不断调用recv(),直到recv()最后返回一个空字符串(发送方已经关闭socket)为止。

# 5-1 streamer.py
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)
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)
socket.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))

使用脚本运行Serv、Cli,数据完好无损地发送给了服务器,Cli关闭socket后,生成了文件结束符,表示这次通信唯一需要的帧。

$ 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', 54458)
Received 98 bytes
Received zero bytes - end of file
Message: Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.

这个socket没有准备接收任何数据,当Cli和Serv不再进行某一方向的通信时,会立即关闭该方向的连接。避免了另一方向上使用socket,否则可能会在缓冲区队列中,填入太多未读取的数据,造成死锁。

Cli和Serv,其中之一调用socket的shutdown()方法也是相当必要的,在Cli和Serv的socket上都调用shutdown,不仅会对称,也能提高冗余性。

5.2.1.2. 是5-1的变体,即在两个方向上都通过流发送信息。socket最开始在两个方向上都是打开的。

1)通过流在一个方向上发送信息,然后关闭该方向。

2)在另一方向上通过流发送数据。

3)关闭socket。

4)一定要先完成一个方向上的数据传输,再反过来在另一方向上通过流发送数据,否则,可能使Serv和Cli发生死锁。

5.2.1.3. 使用定长消息,可使用sendall()方法发送字节字符串,然后使用自己设计的recv()循环,确保接收完整的消息。

def recvall(sock, length):
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格式),可能会发现,定长消息在某些情况下是不错的选择

5.2.1.4. 通过使用特殊字符来划分消息的边界。

1)接收方会进入recv()循环并不断等待,直到不断累加的返回str包含表示消息结束的定界符为止。

2)确保消息汇总的字节或字符,在特定的有限范围内,自然就可以选择该范围外的某个符号作为消息的结束符。如,正在发送的ASCII字符串,可以选择空字符'\o'作为定界符,也可选择像'\xff'处于ASCII字符范围外的字符。

3)如果消息可以包含任意数据,那么定界符的使用就是一个问题了:

要是用作定界符的字符,作为数据的一部分出现了,该怎么办?

答案:引用,和Py-str中使用'来表示单引号类似,'All's well taht ends well.'

只有在消息使用的字母表有限,才能使用定界符机制。

5.2.1.5. 在每个消息前加上其长度作为前缀。使用该模式,无需进行分析,引用、插入,就能够一字不差地发送二进制数据块。对于高性能协议来说,是一个很流行的选择。消息长度本身需要使用帧封装。封帧时可使用一个定长的二进制int/在变长的int-str后,加上一个文本定界符来表示长度。只要接收方读取并解码了长度,就能进入循环,重复调用recv(),直到整个消息都传达为止。把3-1中的循环差不多,但要把数字16替换成表示长度的变量。

既想利用5.2.1.5的简洁高效,又无法事先得知每个消息的长度(发送者无法从数据源中事先得到消息长度),该怎么办?
5.2.1.6. 并非只发送单个消息,会发送多个数据块,在每个数据块前加上数据块长度作为前缀。意味着,每个新的消息块对发送者来说都是可见的,可使用数据块的长度为其打上标签,将数据块置入发送流中。抵达信息结尾,发送方可以发送一个与接收方事先约定好的信号(如,0表示的长度字段),告知接收方,所有数据块已经发送完毕。

5-2展示了5.2.1.6.的想法,只在一个方向上发送数据--从Cli像Serv发送。但使用的数据结构:每个消息前面都加上了一个struct作为前缀。struct包含了使用4B表示的长度,由于I表示使用32位的无符号整数,因此每个帧的长度最大为4GB,像Serv发送3个连续的数据块,然后发送一个长度为0的消息,由长度字段0+空消息数据组成,表示所有数据块已经发送完成。

# 5-2 blocks.py

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)
sc, sockname = sock.accept()
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)
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: %(defaults)s)')
parser.add_argument('-c', action='store_true', help='run as the client')
parser.add_argument('-p', type=int, metavar='port', default=1060)
args = parser.parse_args()
function = client if args.c else server
function((args.hostname, args,p))

只有在一个循环中调用recv(),该代码才是正确的,能够不断接受更多数据(以防万一),直至所有4B的数据都被接收为止。

5.2.2. 为了让Cli和Serv能知道消息何时传输完成,并进而做出响应,需要将连续的数据流分割为多个能用于传输的数据块。此时至少有6中模式可供选择,先代协议将多种模式混合使用,自己也可做这种尝试。

5.2.3. HTTP就是混合使用多种不同封帧技术的好例子,使用定界符---空行'\r\n\r\n',来表示头信息的结束。因为头信息是文本,用空行表示的结束符,可以被安全地用作特殊字符。由于实际的数据负载可以是纯二进制数据(图像/压缩文件),头信息提供了Content-Length,单位为字节。这一字段决定在头信息结束后,还要从socket读取的数据量。HTTP混合使用了模式4和模式5,也可以使用模式6,。如Serv要使用流发送长度未知的响应,那么HTTP可以使用"分块编码",来发送一系列包含长度前缀的数据块,使用长度为0的字段表示传输结束。

5.3. pickle与自定义定界符的格式

5.3.0. 通过网络发送的某些数据可能已经包含某种形式的内置定界符。如果要传输这样的数据,就不必在数据已有定界符的基础上,再勉强设计自己的封帧方案了。

5.3.1. 可以考虑使用Py标准库提供的原生序列化形式pickle:将文本命令与数据混合使用,将Py数据结构的内容存储起来,供后续在另一台机器上重组该数据结构。

>>> import pickle
>>> pickle.dumps([5, 6, 7])
b'\x80\x03]q\x00(K\x05K\x06K\x07e.'

输出数据中的字符串末尾的.字符,用于标记一个pickle的结束。遇到.后,加载器将停止读取,并立刻返回数据。在上述pickle的末尾加上一些奇怪的数据,可以发现loads()会完全忽略加上的数据,并返回原始的列表。

>>> pickle.loads(b'\x80\x03]q\x00(K\x05K\x06K\x07e.blahahahah')
[5, 6, 7]

这种使用loads()的方式在处理网络数据时是无用的,我们并不知道重新加载这个pickle时,需要处理多少字节的数据,也不知道str中有多少属于pickle数据。

5.3.2. 如果转而从文件中读取pickle数据,并使用pickle的load()函数,那么文件指针就会停留在pickle数据结尾,可从结尾处开始,读取该pickle后面的数据。

>>> from io import BytesIO
>>> f = BytesIO(b'\x80\x03]q\x00(K\x05K\x06K\x07e.hahahahhahaha')
>>> pickle.load(f)
[5, 6, 7]
>>> f.tell()
14
>>> f.read()
b'hahahahhahaha'

5.3.3. 还有另一个方案可选择。可创建一个协议,协议的唯一内容就是在两个Py程序间来回发送pickle。因为pickle库会处理所有与文件读取有关的操作(在遇到空pickle前,该如何进行重复读取),因此不需要5-2中的recvall()函数中的循环,有些程序需要Py文件对象作为参数(pickle的load()函数)。要把socket封装成Py文件对象,可使用socket的makefile()方法。

使用pickle处理大型数据结构时,涉及很多细节,尤其是数据结构中包含除了int、str、list、dict等简单内置类型外的Py对象时,更是如此。

5.4. XML与JSON

如果要涉及支持其他编程语言的协议/只是希望使用通用标准,而不是特定于Py的格式,JSON/XML两种数据格式都是很流行的选择。这两种格式本身都不支持封帧,在处理网络数据前,先要使用某种方法,提取出完整的文本str

JSON是用于两种不同PC语言间,发送数据的最佳选择之一,Py2.6开始,标准库提供了对JSON的支持,封装在名为json的模块中,该模块提供了用于对简单数据结构进行序列化的通用技术

>>> import json
>>> json.dumps([51, 'Namarie!'])
'[51, "Namarie!"]'
>>> json.dumps([51, 'Namarie!'], ensure_ascii=False)
'[51, "Namarie!"]'
>>> json.loads('{"name": "Lancelot", "quest": "grail"}')
{'name': 'Lancelot', 'quest': 'grail'}

5.4.1. JSON不仅在str中支持Unicode,如果告诉Py的json模块,不需将输出字符限制在ASCII字符表的话,可以在数据中包含Unicode字符的字面值。

5.4.2. JSON是通过一个str来表示的,这就是为什么使用完整的str,而不直接使用Py字节对象作为json模块的输入与输出原因。按照JSON标准,需要使用UTF-8对JSON-str进行编码,用于网络传输

5.4.3. 对于文档,XML格式更为适用,原因在于它的基本结构是将str封装为包含在尖括号中的元素,并为他们打上标签。不必把XML的应用局限在使用HTTP协议中,可能会发现需要对文本进行标记,而XML在与一些其他协议结合使用时,是非常有用的。

5.4.4. 众多其他格式中,二进制格式,也是开发者会考虑使用的,如果Thrift和GoogleProtocol buffers。无论是Serv还是Cli,都需能够访问对每条消息包含的内容进行定义的代码。生产环境中的机器可能仍在使用老版本的协议进行通信,这些服务都需要针对不同的协议版本进行配置,在没有对生产环境的协议进行升级前,也要能将新Serv引入到生产环境中。这些格式很高效,能正确处理二进制数据。

5.5. 压缩

数据在网络传输中所需的时间,通常远远多于CPU准备所用的时间。在发送前对数据进行压缩,通常是非常值得的,HTTP协议会让Serv和Cli自己来确认它们是否支持压缩。

5.5.1. GNU的zlib是web最普遍的压缩形式之一。Py标准库提供了对zlib的支持。能自己进行封帧是zlib的一个特点。在传递一个压缩过的数据流时,zlib能识别出压缩数据何时到达结尾,如果后面还有未经压缩的数据,用户也可直接访问。

5.5.2. 大多数协议会自行封帧,在需要时将结果数据块,传递给zlib进行解压缩。经常在利用zlib压缩过的str后,附加一些未经压缩的数据(使用单个字节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

大多数压缩机制,在接收的数据量极小时,得到的结果都比原始数据更长,而不是更短。由于为了进行压缩而额外需要的数据量,反而超过了压缩掉的数据量。

假设这28B是以每个数据包8B的形式,发送至接收方。在处理完第一个数据包后,解压缩对象的unused_data槽仍然是空的,表示还有数据尚未处理。

>>> d = zlib.decompressobj()
>>> d.decompress(data[0:8]), d.unused_data
(b'Pytho', b'')

希望再次运行socket的recv(),当把第二个包含8个字符的数据块,传递给解压缩对象时,除了会返回想要的压缩数据外,还会返回一个非空的unused_data值,表示已经接收到了b'.'这一字节

>>> d.decompress(data[8:16]), d.unused_data
(b'n', b'.x')

无论第一部分压缩数据后,还有什么数据,接下来的一个字符一定是第二部分数据的第一个字节。

由于正在等待更多压缩数据,会把'x'传递给一个新的解压缩对象,再把后面两个模拟的8B"数据包"传递给该压缩对象。

>>> 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再次变得非空,表示已经读取到了第二部分压缩数据的结尾。由于已经知道数据完整无缺地传达到了,边可以对数据内容进行处理了。

5.5.3. 大多数协议设计者会把压缩设计为可选项,且自行为其设计封帧策略。如果实现知道终究会使用zlib的话,类似于例子中的惯例用法,则能让我们充分利用zlib提供的流终止信息,自动探测每个压缩流的结尾。

5.6. 网络异常

示例中,一般只捕捉可能会发生的异常,2-2说明socket超时的时候,捕捉了socket.timeout异常,因为socket发出超时通知时,使用的就是socket.timeout。忽略了其他所有异常,如命令行提供非法hostname;调用bind()时提供远程IP;bind()要使用的port已经被占用;无法连接通信对方/对方停止响应

正在运行中的socket会引发哪些错误?

5.6.0. 使用网络连接时,可能发生的错误数量相当大(TCP/IP协议相当复杂,每个步骤都可能出错)。程序在进行socket操作时,抛出的实际异常数量并不多,针对socket操作而发生的异常如下:

5.6.0.1. OSError: socket模块的可能抛出的主要错误。网络传输的所有阶段可能发生的任何问题,几乎都会抛出该异常。OSError会在任何socket调用时出现。如,如果之前的send()调用使远程主机发出了一个重置(RST)数据包,那么无论接下来在socket上进行那种socket操作,都会引发该错误

5.6.0.2. socket.gaierror: 在getaddrinfo()无法找到提供的名称/服务时被抛出。错误名称中的字母g、a、i就是getaddrinfo()的缩写。除了显示调用getaddrinfo时可能抛出该异常外,我们向bind()、connect()的调用传入一个hostname而不是IP的话,该异常也会在hostname查询失败时被抛出。如果捕捉到这个异常的话,可以仔细查看异常对应的信息,获取错误编号及错误信息。

>>> 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'

5.6.0.3. socket.timeout: 有时会为socket设定超时参数,不希望永远等待send()/recv()操作的完成。只有此时,或设定的库设定了socket超时参数时,才可能抛出socket.timeout异常,表示等待操作正常完成的时间已经超过了超时参数的值。

使用Py提供的基于socket的高层协议有一个重要问题:是否允许在代码中直接处理原始socket错误?
是否要捕捉原始socket错误,将它们转换为协议特定的错误类型?
Py标准库本身的实现,就存在这两种方法。如,httplib认为自己是相对底层的,在连接到未知hostname时能够看到底层socket错误
>>> import http.client
>>> h = http.client.HTTPConnection('nonexistent.hostname.foo.bar')
>>> h.request('GET', '/')
Traceback (most recent call last):
...
socket.gaierror: [Error -2] Name or service not known

urlib2把相同的错误隐藏了起来,并抛出一个URLError。可能是因为urllib2认为自己是一个用于将URL解析为文档的系统,所以希望保持相应的语义。

>>> 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

故,根据使用协议的不同,有时只需处理协议特定的异常,有时则可能既需要处理协议特定的异常,又需处理原始socket错误。

5.6.0.4. 在编写网络程序时,该怎样处理所有可能发生的错误?

1)有时会将异常封装,提供给其他调用我们API的程序员使用;

2)有时会中途拦截某些异常,把合适的信息提供给终端用户。

5.6.1. 抛出更具体的异常

将异常传递给使用API的用户时,有两种方法。使用简明的方法处理异常是很有帮助的。

5.6.1.1. 完全不处理网络异常。此时这些异常对调用者来说是可见的。调用者负责处理异常,可以捕捉异常,也可直接把异常输出值报告。该方法使用与比较底层的网络程序。底层程序的调用者能了解建立socket的原因,以及配置/使用socket可能引发错误的原因。只有在可调用的API与底层网络操作之间的映射关系非常明确时,才可让开发者在调用API的代码中处理网络错误。

5.6.1.2. 将网络错误封装成自己的异常。开发者对程序的实现细节知之甚少。他们的程序只需专门捕捉代码操作中的异常即可,无需了解使用socket的细节。使用自定义异常的另一个优点是,能够在发生网络错误时,构造出更清晰的错误信息,明确地解释导致错误的库操作。

如,编写一个小型的mycopy()方法,用于再远程机器间复制文件。如只使用socket.error,调用方就无从得知错误是源于源机器的连接问题,还是目标机器的连接问题/任何其他问题。自己定义了与API语义有紧密联系的异常(如SourceError和DestinationError),就好多了。可以使用raise...from语句在异常链中,包含原始socket错误,即使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只封装继承自OSError的异常(如socket.error),否则异常原因包含的文本信息,除了strerror外还有别的属性,那么__str__()函数就更复杂了。

此例说明了这一模式的原理,即调用者捕捉DestinationError后,可通过__cause__来获取它们实际捕捉到的,包含丰富语义的异常背后的网络错误。

5.6.2. 捕捉与报告网络异常

捕捉异常,有两种基本方法:granular异常处理程序、blanket异常处理程序

5.6.2.1. granular就是针对每个网络调用,都使用try...except语句,然后在except从句汇总打印简洁的错误信息,对短小的程序非常适用,但在大型程序中就显得重复了,且没有给用户提供更多必要的信息。

5.6.2.2. 使用blanket异常处理程序。需要重新审视我们的代码,识别出进行特定操作的代码段。如:

1)整个程序都用于连接许可证Serv

2)这个函数中的所有socket都用于从SQL获取响应

3)最后一部分代码都用来进行清理与关闭操作

外部程序(收集输入、命令行参数、配置信息,调用代码段的程序)使用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异常,并打印出错误信息。如果有一天,希望增加一个命令行选项,把严重错误发送到系统的错误日志,而不是直接打印到屏幕,只需修改一处的代码即可,无需到处修改。

5.6.2. 需要指定在网络程序中,添加异常处理程序的位置。可能希望在操作失败时,用某种智能的方法进行重试。在长时间运行的程序中,这是很常见的。

如,一个工具程序,每隔一段时间就将它的状态通过电子邮件发送出去,不想关闭这个程序。相反,可能会让发送电子邮件的线程,把错误输出到日志,等待几分钟,重新尝试发送。

这种情况下,将特定的多个连续网络操作结合起来,将其看做单个操作。可能成功,也可能失败。把它看成一个整体,使用try...except为其编写异常处理程序。"如果发生任何问题,先暂停,等待10min,重新尝试发送电子邮件",进行的网络操作的结构和逻辑,将会决定如何部署try...except从句

5.7. 小结

5.7.0. 把机器信息存放到网络上,必须先进行相应的装换。无论我们的机器用的是那种私有特定存储机制,转换后的数据,都要使用公共且可重现的表示方式,这样,其他系统和程序,甚至其他编程语言才能读取这些数据

5.7.1. 对于文本,最重要的问题就是选择一种编码方式,将想传输的字符转换为字节。因为,包含8个二进制位的字节,是IP网络上的通用传输单元。需小心处理二进制数据,以确保字节顺序能兼容不同的机器。Py的struct模块就是来解决这个问题的。最好使用JSON/XML来发送数据结构和文档,这两种格式提供了不同机器间,共享结构化数据的通用方法。

5.7.2. 使用TCP/IP流时,会面临封帧问题:长数据流中,如何判定一个特定消息的开始与结束。recv()每次可能只返回传输的部分信息,无论使用哪种技术,都需小心处理。

5.7.2.1. 为了识别不同的数据块,可使用pickle为单独的流消息封帧。

5.7.2.2. 压缩模块zlib通常会和HTTP一起使用功能,也可识别压缩的数据段何时结束,提供了一种花销不高的封帧方法

5.7.3. 与代码使用的网络协议一样,socket也可抛出各种异常。何时使用try...except从句,取决于代码的用户--是为其他开发者编写,还是为终端用户编写工具?此外,这一选择也取决于代码的语义。如果从调用者/终端用户的角度来看,某个代码进行的是同一个较为宏观的操作,就可将整个代码段放在一个try...except从句中。

5.7.4. 如果某个操作引发的错误只是暂时的,调用晚些可能会成功,并且希望该操作能自动重试,就应将其单独包含在一个try...except从句中。