[ python ] 网络编程(2)
黏包问题
这样一个实例
import socket import subprocess sk_server = socket.socket() # 创建 socket对象 sk_server.bind(('localhost', 8080)) # 建立socket sk_server.listen(5) # 开启监听 conn, addr = sk_server.accept() # 接收客户端信息 while True: command = conn.recv(1024).decode() cmd_res = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) # 执行命令 stdout = cmd_res.stdout.read() stderr = cmd_res.stderr.read() result = stdout if stdout else stderr print('result:', result) conn.sendall(result) # 发送命令结果
import socket sk_client = socket.socket() sk_client.connect(('localhost', 8080)) while True: cmd = input('>>>').strip() if not cmd: continue sk_client.sendall(cmd.encode()) result = sk_client.recv(1024).decode('gbk') print(result)
运行起来,我们在客户端输入 tasklist (windows查看所有进程),然后在输入 dir(查看当前目录信息)
执行完 tasklist 后,再次执行 dir 时,发现输出结果是 tasklist 未显示出来的部分。这种情况,就称之为 黏包。
【注意:只有TCP有粘包现象,UDP永远不会粘包】
黏包成因
tcp协议的拆包机制:
当发送端缓冲区的长度大于网卡的MTU时,tcp会将这次发送的数据拆成几个数据包发送出去。
MTU是Maximum Transmission Unit的缩写。意思是网络上传送的最大数据包。MTU的单位是字节。 大部分网络设备的MTU都是1500。如果本机的MTU比网关的MTU大,大的数据包就会被拆开来传送,这样会产生很多数据包碎片,增加丢包率,降低网络速度。
面向流的通信特点和Nagle算法
TCP(transport control protocol,传输控制协议)是面向连接的,面向流的,提供高可靠性服务。
收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。
这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。 即面向流的通信是无消息保护边界的。
对于空消息:tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),也可以被发送,udp协议会帮你封装上消息头发送过去。
可靠黏包的tcp协议:tcp的协议数据不会丢,没有收完包,下次接收,会继续上次继续接收,己端总是在收到ack时才会清除缓冲区内容。数据是可靠的,但是会粘包。
udp和tcp一次发送数据长度的限制 用UDP协议发送时,用sendto函数最大能发送数据的长度为:65535- IP头(20) – UDP头(8)=65507字节。用sendto函数发送数据时,如果发送数据长度大于该值,则函数会返回错误。(丢弃这个包,不进行发送) 用TCP协议发送时,由于TCP是数据流协议,因此不存在包大小的限制(暂不考虑缓冲区的大小),这是指在用send函数时,数据长度参数不受限制。而实际上,所指定的这段数据并不一定会一次性发送出去,如果这段数据比较长,会被分段发送,如果比较短,可能会等待和下一次数据一起发送。
会发生黏包的两种情况:
1. 发送方的缓存机制:发送端需要等待缓冲区满才发送出去,造成黏包
2. 接收方的缓存机制:接收方不能及时接收缓冲区的包,造成多个包接收
总结:
黏包现象只发生在tcp协议中:
1. 从表面上看,黏包问题主要是因为发送方和接收方的缓存机制、tcp协议面向流通信特点;
2. 实际上,主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。
黏包的解决方案:
黏包的根源在于,接收端不知道发送端将要传送的字节流的长度,所以解决黏包的方法就是围绕,如何让发送端在发送数据前,把自己将要发送的字节流总大小让接收端知道,然后接收端来一个死循环收完所有数据。
解决方案一:
import socket import subprocess sk_server = socket.socket() sk_server.bind(('localhost', 8080)) sk_server.listen(5) conn, addr = sk_server.accept() while True: command = conn.recv(1024).decode() cmd_res = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) stdout = cmd_res.stdout.read() stderr = cmd_res.stderr.read() result = stdout if stdout else stderr res_size = len(result) conn.sendall(str(res_size).encode()) response = conn.recv(1024) conn.sendall(result)
import socket sk_client = socket.socket() sk_client.connect(('localhost', 8080)) while True: command = input('>>>').strip() if not command: continue sk_client.sendall(command.encode()) res_size = sk_client.recv(1024).decode() sk_client.sendall(b'000') revice_size = 0 while revice_size != int(res_size): data = sk_client.recv(1024) revice_size += len(data) print(data.decode('gbk'))
使用这种方式存在的问题:
程序的运行速度远快于网络传输速度,所以在发送一段字节前,先用 send 去发送该字节流长度,这种方式会放大网络延迟带来的性能损耗
解决方案二:
使用 struct模块,这个模块可以把要发送的数据长度转换成固定长度的字节。这样客户端每次接收消息之前只要先接受这个固定长度字节的内容看一看接下来要接收的信息大小,那么最终接收的数据只要达到这个值就停止,就能刚好不多不少的接收完整的数据了。
struct 模块
该模块可以把一个类型,如数字,转成固定长度的bytes
In [1]: import struct In [2]: s = struct.pack('i', 111111) # 使用 pack 方法将int类型转换为固定的 4 个字节 In [3]: s Out[3]: b'\x07\xb2\x01\x00' In [4]: len(s) # 固定的 4 个字节 Out[4]: 4 In [5]: struct.unpack('i', s) # 使用 unpack 方法将 4 个字节还原为字符,类型为元组 Out[5]: (111111,)
使用 struct 解决黏包
借助 struct 模块,我们知道长度数字可以被转换成一个标准大小的 4 个字节数字。因此可以利用这个特点来预先发送数据长度。
import socket, struct import subprocess sk_server = socket.socket() sk_server.bind(('localhost', 8080)) sk_server.listen(5) conn, addr = sk_server.accept() while True: command = conn.recv(1024).decode() res_cmd = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) stdout = res_cmd.stdout.read() stderr = res_cmd.stderr.read() result = stdout if stdout else stderr res_size = len(result) conn.sendall(struct.pack('i', res_size)) conn.sendall(result)
import socket import struct sk_client = socket.socket() sk_client.connect(('localhost', 8080)) while True: command = input('>>>').strip() if not command: continue sk_client.sendall(command.encode()) res = sk_client.recv(4) res_size = struct.unpack('i', res)[0] print(res_size) revice_size = 0 while revice_size != res_size: data = sk_client.recv(1024) revice_size += len(data) print(data.decode('gbk'))
这里还可以将报头做成字典字典里包含将要发送的真实数据的详细信息,然后json序列化,然后用 struct 将序列化后的数据长度打包成4个字节.
这种方式用于需要初始化较多的信息
import socket, struct, json import subprocess sk_server = socket.socket() sk_server.bind(('localhost', 8080)) sk_server.listen(5) conn, addr = sk_server.accept() while True: command = conn.recv(1024).decode() cmd_res = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) stdout = cmd_res.stdout.read() stderr = cmd_res.stderr.read() result = stdout if stdout else stderr headers = {'res_size': len(result)} # 将head信息组合成 字典类型 head_json = json.dumps(headers) # 转换为 json 类型 head_json_bytes = bytes(head_json, encoding='utf-8') conn.send(struct.pack('i', len(head_json_bytes))) # 首先发送 head 信息的大小 conn.send(head_json_bytes) # 再次发送 head 信息 conn.send(result) # 最后 发送 执行命令的结果集合
import socket, struct, json sk_client = socket.socket() sk_client.connect(('localhost', 8080)) while True: cmd = input('>>>').strip() if not cmd: continue sk_client.send(cmd.encode()) res_size = struct.unpack('i', sk_client.recv(1024))[0] # 首先获取 head 大小 head_json = sk_client.recv(res_size).decode() # 通过 head 大小获取 head 信息 head_dict = json.loads(head_json) # 转为 字典 类型 data_len = head_dict['res_size'] # 取出 结果集 大小 revice_size = 0 while revice_size != data_len: # 循环接收 结果集 data = sk_client.recv(1024) revice_size += len(data) print(data.decode('gbk'))
FTP小作业:上传下载文件
import socket import json import struct import os class MyServer: request_queue_size = 5 def __init__(self, ip_port, bind_activate=True): self.socket = socket.socket() self.ip_port = ip_port if bind_activate: try: self.activate() except: self.server_close() raise def activate(self): self.socket.bind(self.ip_port) self.socket.listen(self.request_queue_size) def get_resquest(self): return self.socket.accept() def server_close(self): self.socket.close() def run(self): while True: self.conn, self.client_addr = self.get_resquest() print('from client:', self.client_addr) while True: try: head_struct = self.conn.recv(4) head_len = struct.unpack('i', head_struct)[0] head_json = self.conn.recv(head_len).decode() head_dict = json.loads(head_json) command = head_dict['command'] if hasattr(self, command): func = getattr(self, command) func(head_dict) except Exception: break def put(self, args): filename = args['filename'] file_size = args['file_size'] file_path = os.path.join('file_upload', filename) recv_size = 0 with open(file_path, 'wb') as f: while recv_size != file_size: recv_data = self.conn.recv(1024) recv_size += len(recv_data) f.write(recv_data) if __name__ == '__main__': ftp_server = MyServer(('localhost', 8080)) ftp_server.run()
import socket import json, struct import os, sys class MyClient: def __init__(self, ip_port, connect=True): self.socket = socket.socket() self.ip_port = ip_port if connect: try: self.connect() except: self.client_close() raise def connect(self): self.socket.connect(self.ip_port) def client_close(self): self.socket.close() def run(self): while True: cmd = input('>>>').strip() if not cmd: continue cmd_str = cmd.split()[0] if hasattr(self, cmd_str): func = getattr(self, cmd_str) func(cmd) def put(self, command): if len(command) > 1: filename = command.split()[1] if os.path.isfile(filename): cmd_str = command.split()[0] file_size = os.path.getsize(filename) head_dict = {'command': cmd_str, 'filename': filename, 'file_size': file_size} head_json = json.dumps(head_dict) head_json_bytes = bytes(head_json, encoding='utf-8') head_json_strcut = struct.pack('i', len(head_json_bytes)) print(head_json_strcut) self.socket.send(head_json_strcut) self.socket.send(head_json_bytes) with open(filename, 'rb') as f: while True: data = f.read(1024) send_size = f.tell() if not data: print('upload successful.') break self.socket.send(data) self.__progress(send_size, file_size, '上传中') else: print('\033[31;1m文件不存在.\033[0m') else: print('\033[31;1m命令格式错误.\033[0m') def __progress(self, trans_size, file_size, mode): bar_length = 100 percent = float(trans_size) / float(file_size) hashes = '=' * int(percent * bar_length) spaces = ' ' * int(bar_length - len(hashes)) sys.stdout.write('\r%s %.2fM/%.2fM %d%% [%s]' %(mode, trans_size/1048576, file_size/1048576, percent*100, hashes+spaces)) if __name__ == '__main__': ftp_client = MyClient(('localhost', 8080)) ftp_client.run()
socketserver 模块
主要类型
该模块有4个比较主要的类,其中常用的是 TCPServer 和 UDPServer
- TCPServer
- UDPServer
- UnixStreamServer: 类似于TCPServer提供面向数据流的套接字连接,但是旨在UNIX平台上可用;
- UnixDatagramServer: 类似于UDPServer提供面向数据报的套接字连接,但是旨在UNIX平台上可用;
这四个类型同步地处理请求,也就是说一个请求没有完成之前是不会处理下一个请求的,这种模式当然不适合生产环境,一个客户端连接就可能拖延所有的执行。所以这个模块还提供了两种支持异步处理的类:
- ForkingMixIn: 为每一个客户端请求派生一个新的进程专门处理;
- ThreadingMixIn: 为每一个客户端请求派生一个新的线程专门处理;
继承自这两个类型的服务端在处理新的客户端连接时不会阻塞,而是创建新的进/线程专门处理客户端请求。
编程框架
首先从高层面介绍一下使用SocketServer模块开发多进程/线程 异步服务器的流程:
- 根据需要选择一个合适的服务类型,如,面向TCP连接的多进程服务器: ForkingTCPServer ;
- 创建一个请求处理器(request handler)类型,这个类型的 handle()(类似于回调函数)方法中定义如何处理到达的客户端连接。
- 实例化服务器,传入服务器绑定的地址和第2步定义的请求处理器类;
- 调用服务器实例的 handle_request() 或 serve_forever() 方法,一次或多次处理客户请求。
使用 socketserver 实例:
import socketserver class MyServer(socketserver.BaseRequestHandler): def handle(self): while True: self.data = self.request.recv(1024).decode() print('from client:', self.client_address) print(self.data) self.request.send(self.data.upper().encode()) if __name__ == '__main__': HOST, PORT = 'localhost', 8080 server = socketserver.ThreadingTCPServer((HOST, PORT), MyServer) server.serve_forever()
import socket class MyClient: def __init__(self, ip_port, connect=True): self.client = socket.socket() self.ip_port = ip_port if connect: try: self.connect() except: self.client_close() raise def connect(self): self.client.connect(self.ip_port) def client_close(self): self.client.close() def start(self): while True: cmd = input('>>>').strip() if not cmd: continue self.client.send(cmd.encode()) cmd_upper = self.client.recv(1024).decode() print(cmd_upper) if __name__ == '__main__': client = MyClient(('localhost', 8080)) client.start()