[Python学习日记-79] socket 开发中的粘包现象(解决模拟 SSH 远程执行命令代码中的粘包问题)
简介
粘包问题底层原理分析
粘包问题的解决
简介
在Python学习日记-78我们留下了两个问题,一个是服务器端 send() 中使用加号的问题,另一个是收的 recv() 中接收长度导致的粘包现象。
上图就是粘包现象,就是指两次结果粘到一起了,它的发生主要是因为 socket 缓冲区导致的,粘包对于用户体验造成的影响是比较大,难度也相对较高,所以本篇的主角就是粘包现象,我们一起来看看有什么办法可以解决这个难搞的现象。
粘包问题底层原理分析
在了解什么是粘包之前我们必须知道一个前提,那就是粘包现象只会出现在 TCP 身上,而 UDP 是永远不会粘包的,要知道是什么原因我们要先掌握一个 socket 收发消息的原理先,下图为 sokcet 收发消息的原理图
在发送端和接收端之间怎么样为一条消息呢?可以认为一次 send() 和 recv() 就是一条消息,但要知道你的程序实际上无权直接操作网卡的,你操作网卡都是通过操作系统给用户程序暴露出来的接口,那每次你的程序要给远程发数据时,其实是先把数据从用户态复制到内核态,这样的操作是耗资源和时间的,频繁的在内核态和用户态之前交换数据势必会导致发送效率降低,因此 socket 为提高传输效率,发送方往往要收集到足够多的数据后才发送一次数据给对方(send() 的字节流是先放入应用程序所在计算机的缓存,然后由协议控制将缓存内容发往对端,如果待发送的字节流大小大于缓存剩余空间,那么数据丢失,用 sendall() 就会循环调用 send(),数据不会丢失),所以这条消息无论底层是如何分段分片的传输层协议都会把构成整条消息的数据段排序完成后才呈现在内核缓冲区,所以到达了缓冲区其实都是一条完整的消息,关键就在与传输协议 TCP 和 UDP 的传输方式不一样,导致两者的特性各不相同。
TCP 协议(流式协议)传输消息时发送端可能会一次性发送 1KB 的数据,而接收端可能会以 2KB、3KB、6KB、3Bytes 的形式来提取收到的数据,也就是说接收端所看到的数据是一个流(stream),即面向流的通信是无消息保护边界的协议,所以客户端是不能一下子看到一条消息是有多少字节的,例如基于 TCP 的套接字客户端往服务器端上传文件,发送时文件内容是按照一段一段的字节流发送的,在服务器端接收到后根本不知道该文件的字节流从何处开始,在何处结束。TCP 为提高传输效率,发送方往往要收集到足够多的数据后才发送一个 TCP 段,如果连续几次需要发送的数据都很少,通常 TCP 会根据优化算法(Nagle 算法)把这些数据合成一个 TCP 段后一次发送出去,当发送端缓冲区的长度大于网卡的 MTU 时会出现拆包情况的发生,届时 TCP 会将这次发送的数据拆成几个数据包发送出去,这样更加加重了 TCP 传输数据的粘包问题,这就是 TCP 为什么容易发生粘包问题的原因。但 TCP 的数据不会丢,在上一次传输没有收完的包,下次还会接收,发送端会在收到 ack 时才会清除缓冲区内容,所以数据是可靠传输的,缺点就是会粘包。
UDP 协议传输消息是必须以消息为单位提取数据的,不能一次提取任意字节的数据,即面向消息的通信是有消息保护边界的,它也不会使用块的合并优化算法来进行优化,并且由于 UDP 支持的是一对多的模式,所以接收端的 skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的 UDP 包,在每个 UDP 包中就有了消息头(消息来源地址,端口等信息),对于接收端来说就容易进行区分处理了,所以 UDP 协议传输消息永远不可能出现粘包现象。但 UDP 的 recvfrom() 是阻塞的,一个 recvfrom(x) 必须对唯一一个 sendinto(y),收完了 x 个字节的数据就算完成,若是 y>x 那么 y-x 的数据就会丢失,这意味着 UDP 根本不会粘包,但是会丢数据,并不可靠。
总的来说,所谓粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。
以下两种情况会发生粘包:
1、发送端需要等缓冲区满才发送出去,从而造成粘包(发送数据时间间隔很短,而且数据量很小,会合到一起产生粘包)
服务器端:
python">import socketip_port = ('127.0.0.1',8080)server = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
server.bind(ip_port)
server.listen(5)conn,client_addr = server.accept()data1 = conn.recv(10)
data2 = conn.recv(10)print('第一次------>', data1.decode('utf-8'))
print('第二次------>', data2.decode('utf-8'))conn.close()
客户端:
python">import socketip_port = ('127.0.0.1',8080)
info_size = 1024client = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
client.connect(ip_port)client.send('hello'.encode('utf-8'))
client.send('jove'.encode('utf-8'))
代码输出如下:
2、接收方不及时接收缓冲区的包,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生粘包)
服务器端:
python">import socket
import time
ip_port = ('127.0.0.1',8080)server = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
server.bind(ip_port)
server.listen(5)conn,client_addr = server.accept()data1 = conn.recv(2) # 第一次没接收完整
data2 = conn.recv(10) # 第二次接收的时候会先取出旧的数据,然后再取新的print('第一次------>', data1.decode('utf-8'))
time.sleep(1)
print('第二次------>', data2.decode('utf-8'))conn.close()
客户端:
python">import socketip_port = ('127.0.0.1',8080)
info_size = 1024client = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
client.connect(ip_port)client.send('hello'.encode('utf-8'))
client.send('jove'.encode('utf-8'))
代码输出如下:
粘包问题的解决
一、struct 模块
解决粘包问题的关键就是要何如提前告诉接收端我发送的信息长度,我们的解决办法就是为真正的数据封装一个固定长度的报头,然后让接收端按照固定长度来接受该报头从而获取到我接受数据的长度大小,而 struct 模块就是用于数据的打包和解包。
通过 struct 模块,可以将 Python 中的数据类型(如整数、浮点数等)转换为指定的二进制格式,或者将二进制数据解包成相应的 Python 对象。该模块提供了一些函数来执行这些转换,包括 pack()、unpack()、pack_into()、unpack_from() 等。其中,pack() 函数用于将数据打包为二进制字符串,unpack() 函数用于将二进制数据解包为 Python 对象。struct 模块定义了一些格式字符用于表示数据的布局、对齐方式和字节顺序。常用的格式字符包括:'i'(有符号整数)、'l'(有符号长整数)、'q'(有符号的长长整数)、'f'(浮点数)、's'(字符串)、'c'(单个字符)等。
代码演示:
python">import struct# 发送端打包,可以一次打包两个不同类型的数据,一个数据长度为4,两个数据长度为8,如此类推
res = struct.pack('if',12888,3.14) # 'i' == int 'f' == float
print(res,type(res),len(res))# 接收端固定长度接收,client.recv(4)
obj = struct.unpack('if',res)
print(obj) # 解包后是一个元组
print(obj[0])# res = struct.pack('i',12888888888) # 'i'会超过范围报错
代码输出如下:
二、简单版本
服务器端:
python">import socket
import subprocess
import structip_port = ('127.0.0.1',8080)
cmd_size = 8096server = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
server.bind(ip_port)
server.listen(5)print('starting...')
while True: # 链接循环conn, client_addr = server.accept()print(client_addr)while True: # 通讯循环try:# 1、收命令cmd = conn.recv(cmd_size) # 8096个字节的命令已经很好的保证了命令可以完整接收if not cmd: break# 2、执行命令,拿到结果obj = subprocess.Popen(cmd.decode('utf-8'), shell=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)stdout = obj.stdout.read()stderr = obj.stderr.read()# 3、把命令的结果返回给客户端# 第一步: 制作固定长度的报头total_size = len(stdout) + len(stderr)header = struct.pack('i', total_size)# 第二步: 把报头(固定长度)发送给客户端conn.send(header)# 第三步: 再发送真实的数据conn.send(stdout) # 这里不使用 +(加号) TCP/IP也会把两个包粘到一起conn.send(stderr)except ConnectionResetError:breakconn.close()
server.close()
客户端:
python">import socket
import structip_port = ('127.0.0.1',8080)
info_size = 1024client = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
client.connect(ip_port)while True:# 1、发命令cmd = input('>>: ').strip()if not cmd:continueclient.send(cmd.encode('utf-8'))# 2、拿到执行命令的结果,并打印# 第一步: 先收报头header = client.recv(4)# 第二步: 从报头中解析出对真实数据的描述信息(数据的长度)total_size = struct.unpack('i', header)[0]# 第三步: 接收真实的数据recv_size = 0recv_data = b''while recv_size < total_size:res = client.recv(info_size)recv_data += resrecv_size += len(res) # 计算真实的接收长度,如果以后增加打印进度条的时候就可以精确无误的表示print(recv_data.decode('gbk'))client.close()
代码输出如下:
很明显已经没有粘包现象了,虽然解决了粘包的问题,但是还是存在包头信息过少的问题,例如我想客户端接收到数据后验证一下数据的完整性,那目前就无法完成这一功能了,并且打包的数据长度还会受到数据格式的限制,而在终极版当中这一切将会得到解决。
三、终极版本
服务器端:
python">import socket
import subprocess
import struct
import jsonip_port = ('127.0.0.1',8080)
cmd_size = 8096server = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
server.bind(ip_port)
server.listen(5)print('starting...')
while True: # 链接循环conn, client_addr = server.accept()print(client_addr)while True: # 通讯循环try:# 1、收命令cmd = conn.recv(cmd_size) # 8096个字节的命令已经很好的保证了命令可以完整接收if not cmd: break# 2、执行命令,拿到结果obj = subprocess.Popen(cmd.decode('utf-8'), shell=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)stdout = obj.stdout.read()stderr = obj.stderr.read()# 3、把命令的结果返回给客户端# 第一步: 制作报头header_dic = { # 使用字典,解决了报头信息少的问题'filename': 'a.txt','md5': 'xxxxdxxx','total_size': len(stdout) + len(stderr)}header_json = json.dumps(header_dic)header_bytes = header_json.encode('utf-8')# 第二步: 先发送报头长度conn.send(struct.pack('i',len(header_bytes))) # 字典的bytes的长度很小,'i'已经足够使用了# 第三步: 再发报头conn.send(header_bytes)# 第四步: 再发送真实的数据conn.send(stdout) # 这里不使用+ TCP/IP也会把两个包粘到一起conn.send(stderr)except ConnectionResetError:breakconn.close()
server.close()
客户端:
python">import socket
import struct
import jsonip_port = ('127.0.0.1',8080)
info_size = 1024client = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
client.connect(ip_port)while True:# 1、发命令cmd = input('>>: ').strip()if not cmd:continueclient.send(cmd.encode('utf-8'))# 2、拿到执行命令的结果,并打印# 第一步: 先收报头的长度obj = client.recv(4)header_size = struct.unpack('i',obj)[0]# 第二步: 再收报头header_bytes = client.recv(header_size)# 第三步: 从报头中解析出对真实数据的描述信息header_json = header_bytes.decode('utf-8')header_dic = json.loads(header_json)total_size = header_dic['total_size']# 第四步: 接收真实的数据recv_size = 0recv_data = b''while recv_size < total_size:res = client.recv(info_size)recv_data += resrecv_size += len(res) # 计算真实的接收长度,如果以后增加打印进度条的时候就可以精确无误的表示print(recv_data.decode('gbk'))client.close()
代码输出如下:
终极版当中报头使用了字典的形式,并且用 json 模块进行格式化,然后再用 struct 模块进行打包,这样报头就能包含更多的数据,从而实现更多的功能了,并且打包时不会再受到数据格式的限制。