[Python学习日记-79] socket 开发中的粘包现象(解决模拟 SSH 远程执行命令代码中的粘包问题)

news/2025/1/26 17:59:22/

[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 模块进行打包,这样报头就能包含更多的数据,从而实现更多的功能了,并且打包时不会再受到数据格式的限制。


http://www.ppmy.cn/news/1566374.html

相关文章

【Git版本控制器--3】Git的远程操作

目录 理解分布式版本控制系统 创建远程仓库 仓库被创建后的配置信息 克隆远程仓库 https克隆仓库 ssh克隆仓库 向远程仓库推送 拉取远程仓库 忽略特殊文件 为什么要忽略特殊文件&#xff1f; 如何配置忽略特殊文件&#xff1f; 配置命令别名 标签管理 理…

性能优化案例:通过合理设置spark.shuffle.memoryFraction参数的值来优化PySpark程序的性能

在PySpark中&#xff0c;合理调整spark.shuffle.memoryFraction参数可以有效优化Shuffle阶段的性能&#xff0c;尤其是在存在大量磁盘溢出的场景下。 通过合理设置spark.shuffle.memoryFraction并结合其他优化手段&#xff0c;可显著减少Shuffle阶段的磁盘I/O&#xff0c;提升P…

用于牙科的多任务视频增强

Multi-task Video Enhancement for Dental Interventions 2022 miccai Abstract 微型照相机牢牢地固定在牙科手机上&#xff0c;这样牙医就可以持续地监测保守牙科手术的进展情况。但视频辅助牙科干预中的视频增强减轻了低光、噪音、模糊和相机握手等降低视觉舒适度的问题。…

二叉树的最大深度(C语言详解版)

一、摘要 嗨喽呀大家&#xff0c;leetcode每日一题又和大家见面啦&#xff0c;今天要讲的是104.二叉树的最大深度&#xff0c;思路互相学习&#xff0c;有什么不足的地方欢迎指正&#xff01;好啦让我们开始吧&#xff01;&#xff01;&#xff01; 二、题目简介 给定一个二…

spring cloud之gateway和JWT回顾

最开始学习时&#xff0c;没怎么用&#xff0c;只知道它是网关&#xff0c;当时因为经常使用Nginx做网关&#xff0c;慢慢就淡忘了&#xff0c;最近为了代码整合性&#xff0c;就使用它&#xff0c;非常棒。关于JWT以前也使用&#xff0c;后面调用基本以第三方接口开发的比较多…

如何使用Python爬虫获取微店商品详情:代码示例与实践指南

在电商领域&#xff0c;获取商品详情数据对于商家和开发者来说至关重要。微店作为国内知名的电商平台&#xff0c;提供了丰富的商品数据接口&#xff0c;方便开发者通过API调用获取商品详情。本文将详细介绍如何使用Python爬虫获取微店商品详情&#xff0c;并提供具体的代码示例…

TCP协议(网络)

目录 TCP协议 TCP协议段格式(报文首部) 原理图​编辑 确认应答(ACK)机制 报头介绍 超时重传机制​编辑 连接管理机制 为什么要三次握手 服务端状态转化: 客户端状态转化: 理解TIME_WAIT状态 解决TIME_WAIT状态引起的bind失败的方法(可以立即链接端口号) setsockop…

Lucene常用的字段类型lucene检索打分原理

在 Apache Lucene 中&#xff0c;Field 类是文档中存储数据的基础。不同类型的 Field 用于存储不同类型的数据&#xff08;如文本、数字、二进制数据等&#xff09;。以下是一些常用的 Field 类型及其底层存储结构&#xff1a; TextField&#xff1a; 用途&#xff1a;用于存储…