ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
[TOC] ## **粘包说明** 说明粘包问题,需要先看做一次下面的小实验,根据结果来看原理 ### **基于TCP的简单ssh程序** 写一个远程执行命令的程序,写一个socket client端在windows端发送指令,一个socket server在Linux端执行命令并返回结果给客户端 执行命令的话,肯定是用我们学过的subprocess模块啦,但要**注意操作系统的编码问题**: Windows用的GBK,linux用的utf-8 ~~~ res = subprocess.Popen(cmd.decode('utf-8'),shell=True,stderr=subprocess.PIPE,stdout=subprocess.PIPE) ~~~ **ssh server代码** 仅在源代码上加入了命令执行部分,其他结构都没有变化 ~~~ import socket,subprocess phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM) phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) phone.bind(('127.0.0.1',8800)) phone.listen(5) print('starting...') while True: # 链接循环 conn,client_addr=phone.accept() print(client_addr) while True: #通信循环 try: #1、收命令 cmd=conn.recv(1024) 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、把命令的结果返回给客户端 print(len(stdout)+len(stderr)) conn.send(stdout+stderr) #+是一个可以优化的点 except ConnectionResetError: break conn.close() phone.close() ~~~ **ssh client代码** client端代码无任何变化,仅添加部分注释 ~~~ import socket phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM) phone.connect(('127.0.0.1',8800)) while True: #1、发命令 cmd=input('>>: ').strip() #ls /etc if not cmd:continue phone.send(cmd.encode('utf-8')) #2、拿命令的结果,并打印 data=phone.recv(1024) print(data.decode('utf-8')) phone.close() ~~~ ### **粘包说明** 在上面的程序中,尝试执行ls、pwd等结果长度较少的命令时,拿到了正确的结果! 但执行一个结果比较长的命令,比如top -bn 1, 你发现依然可以拿到结果,再执行一条df -h的话,就发现拿到并不是df命令的结果,而是上一条top命令的部分结果。 这个现象叫做粘包,就是指两次结果粘到一起了 * 起因 op命令的结果比较长,但客户端只recv(1024), 可结果比1024长呀,那只好在服务器端的IO缓冲区里把客户端还没收走的暂时存下来,等客户端下次再来收,所以当客户端第2次调用recv(1024)就会首先把上次没收完的数据先收下来,再收df命令的结果。 而且有关部门建议recv不要超过8192,再大反而会出现影响收发速度和不稳定的情况,所以不能通过该带reve来解决 * 原因 所谓粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。 ![数据流转流程](https://images2018.cnblogs.com/blog/1396803/201805/1396803-20180518173834600-2040963435.png) 我们的应用程序实际上无权直接操作网卡的,操作网卡都是通过操作系统给用户程序暴露出来的接口,每次程序要给远程发数据时,其实是先把数据从用户态copy到内核态后由操作系统完成后续工作,而Nagle算法会将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包,这样接收方就收到了粘包数据。 ### **粘包总结** 1. TCP(transport control protocol,传输控制协议) 是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。 **即面向流的通信是无消息保护边界的。** 2. UDP(user datagram protocol,用户数据报协议) 是无连接的,面向消息的,提供高效率服务。不会使用块的合并优化算法,, 由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了。**即面向消息的通信是有消息保护边界的。** 3. 空消息的处理 tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住 而udp是基于数据报的,即便输入的是空内容,那也不是空消息,udp协议会帮你封装上消息头 ## **粘包问题解决** 解决粘包问题的问题的思路,就是先发送数据前,先统计一下当前需要发送的数据有多长,然后将数据长度告诉对端,让对端只接收指定长度的数据即可 ### **两种做法说明:** 1. low逼做法 手动统计数据长度,然后先发送数据长度给对端,待对端回复消息后再发送真实数据 2. 大牛做法 将数据长度作为数据报头封装到真实数据前面,只要能指定报头长度(如4bytes),即可让对端先接收指定长度的报头,在根据报头中写入的数据长度大小,通过for循环接收真实数据 ### **struct模块解决粘包** python中的struct模块正好可以解决报头定长的问题,通过len方法计算数据长度后,通过struct模块转换为定长数据,然后就可以用来做报头数据了 **server端代码** ``` import socket,subprocess,struct phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM) phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) phone.bind(('127.0.0.1',8800)) phone.listen(5) print('starting...') while True: # 链接循环 conn,client_addr=phone.accept() print(client_addr) while True: #通信循环 try: cmd=conn.recv(8096) if not cmd:break obj = subprocess.Popen(cmd.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout=obj.stdout.read() stderr=obj.stderr.read() #第一步:制作固定长度的报头 total_size = len(stdout) + len(stderr) header=struct.pack('i',total_size) #第二步:把报头发送给客户端 conn.send(header) #第三步:再发送真实的数据 conn.send(stdout) conn.send(stderr) except ConnectionResetError: break conn.close() phone.close() ``` **client端代码** ``` import socket,struct phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM) phone.connect(('127.0.0.1',9909)) while True: cmd=input('>>: ').strip() #ls /etc if not cmd:continue phone.send(cmd.encode('utf-8')) #第一步:先收报头 header=phone.recv(4) #报头长度是自己计划好的 #第二步:从报头中解析出对真实数据的描述信息(数据的长度) total_size=struct.unpack('i',header)[0] #第三步:接收真实的数据 recv_size=0 recv_data=b'' while recv_size < total_size: res=phone.recv(1024) recv_data+=res recv_size+=len(res) print(recv_data.decode('utf-8')) phone.close() ``` ### **struct+json终极解决粘包** 既然可以用struct来做定长报头,那就可以更进一步,使用json序列化模块,在报头中写入更多信息 **server代码** ``` import socket,subprocess,struct,json phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM) phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) phone.bind(('127.0.0.1',9909)) phone.listen(5) print('starting...') while True: # 链接循环 conn,client_addr=phone.accept() print(client_addr) while True: #通信循环 try: cmd=conn.recv(8096) if not cmd:break obj = subprocess.Popen(cmd.decode('utf-8'), shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) stdout=obj.stdout.read() stderr=obj.stderr.read() #第一步:制作固定长度的报头 header_dic={ 'filename':'a.txt', 'md5':'xxdxxx', '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))) #第三步:再发报头 conn.send(header_bytes) #第四步:再发送真实的数据 conn.send(stdout) conn.send(stderr) except ConnectionResetError: break conn.close() phone.close() ``` **client端代码** ``` import socket,struct,json phone=socket.socket(socket.AF_INET,socket.SOCK_STREAM) phone.connect(('127.0.0.1',9909)) while True: cmd=input('>>: ').strip() #ls /etc if not cmd:continue phone.send(cmd.encode('utf-8')) #第一步:先收报头的长度 obj=phone.recv(4) header_size=struct.unpack('i',obj)[0] #第二步:再收报头 header_bytes=phone.recv(header_size) #第三步:从报头中解析出对真实数据的描述信息 header_json=header_bytes.decode('utf-8') header_dic=json.loads(header_json) print(header_dic) total_size=header_dic['total_size'] #第四步:接收真实的数据 recv_size=0 recv_data=b'' while recv_size < total_size: res=phone.recv(1024) #按1024循环收取 recv_data+=res recv_size+=len(res) print(recv_data.decode('utf-8')) phone.close() ```