引言
基于PySide2开发可视化文件传输程序界面,用户可以选择TCP/UDP/FTP类型协议在局域网中进行传输文件,用户一端既可以接收文件,也可以发送文件。做这个项目的时候,用了差不多一个多月的时间,实现了TCP/UDP/FTP,暂不支持传输多个文件(不过拓展起来很方便)!
项目历程
第一个方案
UI在主线程运行,开启子线程来进行文件发送和接收。也就是说,子线程中既要等待客户端连接,又要负责文件接收,只能作为客户端或者服务端,因此用户一端只能发数据或者收数据。因为使用到可视化界面设计,在构思功能时,希望UI可以通过某个按钮主动关闭服务端或客户端(只结束子线程,不结束主线程)。
然而,子线程中socket的recv、accept方法默认是阻塞的,主线程这边没有办法让子线程结束(子线程在while循环工作,加了标记位,但是子线程没机会判断到),即使把子线程设为守护线程,结果还是关闭不了。进一步,主线程先开启子线程,子线程再开启子线程(孙线程,设为守护线程),由主线程关闭子线程,孙线程还是无法结束(守护线程并非跟随父线程)。
第二个方案
为了让子线程有机会判断到全局标记位,让子线程自己结束自己,那么可以把recv、accept方法设成非阻塞状态,即setblocking(0)。服务端accept等待客户端没有连接时,会一直抛出BlockingIOError异常,然后检查标记位,判断是否要结束子线程,直到有客户端连接才接着执行后面的程序;同样把recv方法放在while循环,在没有收到数据的时候,让它有机会判断全局标记位。
self.server_obj.setblocking(0) # socket套接字对象改为非阻塞
# self.server_obj.setblocking(1) # socket套接字对象改为阻塞
while self.thread_run_flag: # 表示线程在执行
try:
if not self.connect_client_flag: # 没有客户端连接
self.client_obj,self.client_addr = self.server_obj.accept()
except BlockingIOError: # 表示等待连接
pass
except ConnectionResetError:
pass
tmp_data = self.client_obj.recv((SEND_RECV_SIZE))
self.recv_data_buff += tmp_data
if len(tmp_data) == (SEND_RECV_SIZE): # 表示客户端还有数据
raise BlockingIOError
elif len(tmp_data) == 0:
return tmp_data
self.buff.data_from_client = self.recv_data_buff
self.recv_data_buff = b'' # 清空数据缓冲区
return self.buff.data_from_client # 返回接收到的总数据大小
最后确认这种方法有效,但是文件传输速度很慢(TCP),80M的文件居然耗时26s,后面经过调整socket的收发缓冲区大小(其实可以不用设置缓冲区大小,默认还是65536B,只是往send方法里传入50M的数据,recv(50M)这样子),从1024B到1M再到50M,花了0.2s的时间就传完了80M的文件,包括文件的读写过程。TCP是基于流通信的,应用端只管把数据提交到系统层,即使是100M也没影响,底层会把数据分成一段一段发过去(最大1500B,网卡的MTU限制),数据最终是能够达到目标地址。
在这个过程有很多东西要交互到UI,UI在主线程,而TCP在子线程,子线程想把打印显示到UI,基于这个需求,我自己封装了一个QT Signal类,当子线程有数据打印时,通过Signal信号触发UI对应的槽函数,就能把打印显示到UI了,这在以后文件传输的进度条显示上也使用到了。
# 自定义signal信号,用于子线程通知UI主线程显示消息
class Self_Signal(object):
def __init__(self):
self._signal = {}
# name: 用于标识所定义的信号
# slot:所定义的信号指向的槽函数
# type1:信号和槽之间传递的参数类型
# type2:默认不使用,预留着用于拓展参数个数
def create(self, name, slot, type1, type2=None):
class MySignals(QObject):
if type2 is not None:
obj = Signal(type1,type2)
else:
obj = Signal(type1)
signal_object = MySignals()
self._signal.update({name:signal_object})
signal_object.obj.connect(slot) # 注册信号和槽
return self._signal[name].obj
方案二运行起来唯一缺点就是耗性能,因为在非阻塞模式,如果没有客户端连接或没有数据发送过来,就一直会抛异常,这样并不好。
第三个方案
为了UI能结束TCP子线程,而不用把TCP Socket设为非阻塞,也就是说,我需要的只是结束子线程,主线程(UI)不用结束,经过想了想之后,决定用多进程解决这些问题;
用进程的好处,不用等待其他子线程或子进程执行完,就能立即结束掉(子进程结束后,所有的子进程资源都会被销毁),需要工作时再创建开启子进程。比方案二好就是不用再考虑阻不阻塞的问题。
程序运行起来后,只有一个UI主进程,在初始化的时候创建一个UI子线程,用于和TCP子进程交换进程的通信数据。TCP子进程只有在用户点击监听(作为服务端)或点击发送(作为客户端)时,才会被创建和开启。TCP子进程里又开启两个子线程(同时开启运行),一个作为服务端,立即开启等待客户端连接(等待客户端发送文件过来);另一个作为客户端,需要等待UI主进程的发送文件信号,才会开启连接对方的服务端。这两个子线程在接收或发送完文件后,又回到等待连接或发送状态,整个子进程随时都有可能被UI主进程主动结束。
- 打印消息队列
- 比如TCP子进程中的子线程,如果有消息要打印到UI,消息先从该子线程传递到TCP主线程(queue.Queue),然后TCP主线程在while循环判断到该队列不为空时,就取出信息放到用于进程间通信的打印消息队列(multiprocessing.Queue);
- UI子线程while循环判断到打印消息队列不为空,就取出消息并发送signal信号,通知UI主线程响应对应的槽函数,把消息加载到UI。
- 连接状态队列
- TCP子线程中关于socket的连接状态,比如服务端是否绑定端口失败,客户端是否连接对方服务端失败等,会把对应的状态放到该队列;
- 先是TCP主线程从queue.Queue中取出状态,放到multiprocessing.Queue;
- 然后UI子线程从多进程队列取出状态,发送signal信号,通知UI主线程响应对应的槽函数去处理;
- 比如如果是绑定端口失败,则结束TCP子进程。
- 文件进度队列
- 在文件传输过程中,更新文件传输的速度状态放到该队列;
- 先是TCP主线程从queue.Queue中取出状态,放到multiprocessing.Queue;
- 然后UI子线程从多进程队列取出状态,发送signal信号,通知UI主线程响应对应的槽函数更新进度条的状态。
- 发送文件信号队列
- 在UI主进程中,设置发送目标文件的路径,UI子线程把路径信息放到multiprocessing.Queue;
- TCP主线程在while循环判断到该队列不为空时,取出路径信息放到queue.Queue用于和子线程间进行通信;
当用户点击发送按钮时,发送时,先连接对方的服务端(另一端服务是开启监听状态下),接着计算发送的文件MD5; 客户端开始发送文件的头部信息(文件名、文件大小、文件的MD5)过去给服务端;等待服务端的响应后(确认收到文件头部信息后),接着发送文件的内容;
服务端监听到有客户端连接了,就准备接收文件头部信息,收到文件头部信息后,答应给客户端确认收到了,接着准备收文件内容;接收文件保存完成后,开始计算保存的文件MD5;
file_hash = hashlib.md5()
with open(path, "rb") as f:
while chunk := f.read(g_SEND_RECV_SIZE): # chunk表示表达式中的变量
file_hash.update(chunk)
return file_hash.hexdigest()
如果保存的文件MD5值和接收文件头部信息里MD5的值一样,则该文件是安全、有效的。
TCP一次收发数据的大小(可修改的),是服务端和客户端先固定好的,TCP协议数据不会丢,没有收完包,下次接收,会继续上次继续接收,因为TCP是面向流的协议,是可靠传输的,但是会有粘包的现象;
而UDP一次收发数据大小是有限制(65507B)的,不能一次提取任意字节的数据,这一点和TCP是很不同的,因为UDP传输是面向报文的,udp本身就是不可靠协议,用数据包(消息来源地址,端口等信息)的形式发送,底层会把数据分成一段一段发过去(最大1500B,网卡的MTU限制),一旦被拆包后,有的数据会被分成没有报头信息,所以就会发生丢包现象。
为了UDP在传输文件的过程中,另一端收到的文件是完整的、有效的;所以让UDP通过建立传输收发确认机制,因为UDP服务端收到客户端发来的第一次数据后,还要进行写入文件,过程还要花点时间,客户端可能已经发送第二次数据过去了,服务端还没来的及接收第二次数据,这样就会有大量的数据丢失,所以收发一次就应答一次,保证了每一次发送和接收的数据都能达到。UDP通过这种机制来传输文件,文件传输的速度也受到影响,UDP和TCP传输速度相比,都是差不多。
# 作为客户端
def send_file_content(file_path, dest_addr, udp_socket):
tmp_len = 0
with open(file_path,'rb') as f:
while True:
recv_data = f.read(SEND_RECV_SIZE)
tmp_len += len(recv_data)
if not recv_data:
udp_socket.sendto(''.encode(),dest_addr)
print('发送完成-->')
udp_socket.close()
break
udp_socket.sendto(recv_data,dest_addr)
# 等待服务端应答一次,客户端再接着发送第二次
recv_data = udp_socket.recvfrom(2)
print('发送出去的总文件大小:{}'.format(get_file_size(tmp_len)))
# 作为服务端
def save_file(file_name, udp_socket, client_addr):
tmp_len = 0
with open(f'save_file/{file_name}','wb') as f:
while True:
recv_data = udp_socket.recvfrom(SEND_RECV_SIZE)
tmp_len += len(recv_data[0])
if len(recv_data[0]) == 0:
print('保存文件成功!')
udp_socket.sendto(''.encode(),client_addr)
udp_socket.close()
break
f.write(recv_data[0])
# 接收到客户端发来的数据后,就向客户端回复一次
udp_socket.sendto(''.encode(),client_addr)
print('接收后的文件大小:{}'.format(get_file_size(tmp_len)))
当时新增FTP协议功能的时候,由于服务端log无法显示到UI上,只显示在终端上,为了能让log显示在UI上,在pyftpdlib里sevrers.py和handlers.py重写一个类,方便以后修改不动第三方库的源码,最后这个方法还是行不通。
用”傻瓜式“的妙招,拷贝第三方pyftpdlib在个人项目里,直接在sevrers.py和handlers.py修改里一些打印信息(文件名、传输速度、文件大小信息等),把debug消息放在queue.Queue,主线程再把debug消息取出放在multiprocessing.Queue,UI子线程判断multiprocessing.Queue不为空,取出debug消息,加载显示到UI上。
子进程有事务,需要先放到队列里,由 UI主进程里的 子线程去取出事务,子线程再通过 signal 通知 UI 主线程处理。
self.log_signal = self.signal.create('log', self.slot_print_log, str)
父进程让子进程结束。
self.child_process_obj.terminate()
使用multiprocessing的队列进程间进行通信。
multiprocessing.Queue(10)
考虑有些情况下,发送端发送的文件名过于太长,超过给定固定的文件名长度(60个Byte长度),文件头部信息解析就有问题(收发文件都是转用Byte类型),因为中文encode()的长度和len(str)的长度不一样
a = '你好'
print(len(a)) # 2
print(len(a.encode())) # 6
所以发送文件头部信息前,先判断文件名是否含有中文,含用中文的情况,以下代码实现
# 文件名含有中文,并且超过60个Byte固定长度
if len(param_1) < 60 and len(param_1.encode())>60:
tmp = param_1 # 用一个临时变量接收
p_len = int(len(param_1)/2) # 以中间的元素下标为基准
while True:
tmp = tmp[:p_len] + tmp[p_len+1:] # 开始从中间的元素下标开始取
if len(tmp.encode()) <= 60: # 转成Byte类型,不超过60个Byte长度
break
# print(len(tmp.encode()))
p_len = int(len(tmp)/2)
param_1 = tmp
运行程序
前提准备:电脑有安装python3.9+,支持Windows/Linux
依赖库:pip install PySide2
同时可以多开几个终端,运行脚本
运行脚本:python ui_main.py
文件传输效果图
TCP协议传输
UDP协议传输
FTP协议传输
具体代码
链接:https://pan.baidu.com/s/1_RoUWpsiN7_hZwc7PhYcPw
提取码:linv
今天就先到这里啦,如果对你有帮助的,赶紧收藏下来吧!觉得代码哪里有问题或者有建议的,都可以打在评论上,我会留意的,互相学习,互相探讨,你我皆黑马。