基于TCP/UDP/FTP实现的PySide2文件传输工具

引言

        基于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主进程主动结束。

  1. 打印消息队列
    1. 比如TCP子进程中的子线程,如果有消息要打印到UI,消息先从该子线程传递到TCP主线程(queue.Queue),然后TCP主线程在while循环判断到该队列不为空时,就取出信息放到用于进程间通信的打印消息队列(multiprocessing.Queue);
    2. UI子线程while循环判断到打印消息队列不为空,就取出消息并发送signal信号,通知UI主线程响应对应的槽函数,把消息加载到UI。
  2. 连接状态队列
    1. TCP子线程中关于socket的连接状态,比如服务端是否绑定端口失败,客户端是否连接对方服务端失败等,会把对应的状态放到该队列;
    2. 先是TCP主线程从queue.Queue中取出状态,放到multiprocessing.Queue;
    3. 然后UI子线程从多进程队列取出状态,发送signal信号,通知UI主线程响应对应的槽函数去处理;
    4. 比如如果是绑定端口失败,则结束TCP子进程。
  3. 文件进度队列
    1. 在文件传输过程中,更新文件传输的速度状态放到该队列;
    2. 先是TCP主线程从queue.Queue中取出状态,放到multiprocessing.Queue;
    3. 然后UI子线程从多进程队列取出状态,发送signal信号,通知UI主线程响应对应的槽函数更新进度条的状态。
  4. 发送文件信号队列
    1. 在UI主进程中,设置发送目标文件的路径,UI子线程把路径信息放到multiprocessing.Queue;
    2. 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

今天就先到这里啦,如果对你有帮助的,赶紧收藏下来吧!觉得代码哪里有问题或者有建议的,都可以打在评论上,我会留意的,互相学习,互相探讨,你我皆黑马。

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值