python
、PyQt5
、多线程
、socket
实验名:传输文件
实验目的:要求学生掌握Socket编程中流套接字的技术
实验内容:
要求学生掌握利用Socket进行编程的技术
要求客户端可以罗列服务器文件列表,选择一个进行下载
对文件进行分割(每片256字节),分别打包传输
发送前,通过协商,发送端告诉接收端发送片数
报头为学号、姓名、本次分片在整个文件中的位置
报尾为校验和:设要发送n字节,bi为第i个字,校验和s=(b0+b1+…+bn) mod 256
接收方进行合并
必须采用图形界面
发送端可以选择文件,本次片数
接收端显示总共的片数,目前已经接收到的文件片数,收完提示完全收到
扩展功能:
连接未建立
连接断开
connecting server
当前连接数xx
(这是client发起的client传输
socket连接数目)正在监听port:xxxx,client连接xx
(这是server收到的所有socket连接的数目,包括每个client的client UI
、client心跳
和client传输
三类连接)# 截选1: 在点击启动连接后再对ip输入进行判断,这是为了避免输入不完整的ip(输入检查器没法避免不完整输入)
import re
def IsIPV4(ip):
compile_ip = re.compile('^(1\d{2}|2[0-4]\d|25[0-5]|[1-9]\d|[1-9])\.(1\d{2}|2[0-4]\d|25[0-5]|[1-9]\d|\d)\.(1\d{2}|2[0-4]\d|25[0-5]|[1-9]\d|\d)\.(1\d{2}|2[0-4]\d|25[0-5]|[1-9]\d|\d)$')
return compile_ip.match(ip)
# 截选2: 在UI中配置输入检查器,这可以禁止大部分非法输入
regx = QtCore.QRegExp("^([0-9]|[1-9]\\d|[1-9]\\d{2}|[1-9]\\d{3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$");
validator_Port = QtGui.QRegExpValidator(regx)
self.portNum.setValidator(validator_Port) # 正则表达式限制prot输入
regx = QtCore.QRegExp("\\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\b")
validator_IP = QtGui.QRegExpValidator(regx)
self.IPNum.setValidator(validator_IP) # 正则表达式限制IP输入
# 截选3:构造一个UDP包但不发送,从中获取本机IP(这个函数要求联网,这里没做断网异常检查,有待改进)
def CheckIp():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
IP = s.getsockname()[0]
finally:
s.close()
return IP
可以返回上一级目录,当回退到磁盘根目录时会自动限制其的几个窗口都比较简单,直接使用pyqt
提供的控件即可实现,但文件选择窗口就很麻烦了,因此专门再说一说
先用Qt designer进行框架设计,放好按钮和部件,左下放一个QTreeWidget
,右边放一个QScrollArea
占位置。别忘了整体套一个网格布局,以实现界面大小拖动的自动适配
左下角的文件树继承自QTreeWidget
控件,增加一些文件浏览的方法
,目录回退右侧的文件面板继承自 QScrollArea
控件,这里想实现类似windows大图标浏览的效果
QScrollArea
控件自带了滚轮滑动的功能
为了使面板上的内容可以点击,这个面板上的每个ICO图标都是我定义的ICO类对象,我先在QScrollArea
上放一个 “内部容器”widget
,再在此widget
上放置 Grid layout
网格布局,最后把ICO对象放到网格里。
pushButton
(按钮改成文件ICO的图像)加一个用于显示文件名label
因为水平有限,没能实现这种设计下的窗口拖动适配,windows的大图标显示,在窗口拉大或缩小时,可以自动调整每一行的图标数量。我尝试做的适配不能改每行图标数量,导致图标间距过大或过小,很难看。因此我把ICO面板的尺寸设置为固定的了,每行只能显示3个图标,一屏最多显示4行
为了提高加载效率,在每次目录切换的时候,不会删除ICO面板上的所有ICO对象后再重添加。而是让前12个按钮变成透明的,删除12个以外的其他ICO对象,这样只要新目录的文件/文件夹少于12个,就不需要创建新的ICO对象。如果超过12个,则需要创建新的ICO对象
选择12是因为这是ICO面板一页里最多显示的图标数,这样可以保证滚轮滑条正常。举例来说,假如我从一个图标很多的目录切换到一个图标很少的目录,如果直接把所有图标变成透明的,会导致图标面板可以向下滑很多,但都是空的,我想避免这种情况发生
# 截选1:ICO图标类数据结构
class FileIco():
def __init__(self,widget,layout,size,num,name,UI,SA):
# 承载关系:fileUI -> SA -> widget -> layout -> ICO
self.__widget = widget # 承载ICO的widget
self.__layout = layout # 承载ICO的网格布局
self.__size = size # ICO尺寸 (fixed)
self.__name = name
self.__op = QtWidgets.QGraphicsOpacityEffect() #透明的设置
self.__ID = num # ICO编号
self.__UI = UI # 文件窗口整体UI
self.__SA = SA # 承载ICO的QScrollArea
self.setupUI()
# 建立UI
def setupUI(self):
self.__pbt = QtWidgets.QPushButton(self.__widget)
# ... pbt配置若干
self.__layout.addWidget(self.__pbt, 2*int((self.__ID-1)/3), (self.__ID-1)%3+2, 1, 1)
self.__pbt.clicked.connect(self.ClickdIco)
self.__label = QtWidgets.QLabel(self.__widget)
# ... label配置若干
self.__layout.addWidget(self.__label, 2*int((self.__ID-1)/3)+1,(self.__ID-1)%3+2, 1, 1)
# 截选2:ICO按钮的显示图控制(带_s的是我p过加框的图)
# 其他代码...
ImageDict = dict([ # 资源路径字典
('doc_s' , 'images/file_ICO/doc_s.png'),
('doc' , 'images/file_ICO/doc.png'),
('docx_s' , 'images/file_ICO/docx_s.png'),
('docx' , 'images/file_ICO/docx.png'),
('floder_s' , 'images/file_ICO/floder_s.png'),
('floder' , 'images/file_ICO/floder.png'),
('pdf_s' , 'images/file_ICO/pdf_s.png'),
# ...
# 其他代码...
self.__pbt.setStyleSheet('QPushButton{border-image:url(' +disImg+ ');}' # 直接显示图
'QPushButton:hover{border-image: url(' + hoverImg + ');}') # 鼠标移上去时显示的
# 其他代码...
# 截选3:ICO面板类数据结构
class MyIcoWidget(QtWidgets.QScrollArea):
ico_refresh_signal = QtCore.pyqtSignal(str)
def __init__(self,widget,ui):
super().__init__(widget)
self.__IcoNum = 0 # 当前图标数量
self.__VisibleIcoNum = 0 # 当前可见图标数量
self.__IcoList = list() # 管理ICO的列表
self.__widget = widget # 承载QScrollArea的widget
self.__UI = ui # 文件窗口UI
self.file_root_path = '' # 当前浏览的目录
self.setupUI()
def setupUI(self):
# 尺寸设为固定的
self.setWidgetResizable(True)
self.setMaximumHeight(373)
self.setMaximumWidth(320)
self.setMinimumHeight(373)
self.setMinimumWidth(320)
self.setObjectName("scrollArea")
# QScrollArea内部容器
self.scrollAreaWidgetContents = QtWidgets.QWidget()
self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 227, 457))
self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents")
self.setWidget(self.scrollAreaWidgetContents)
# QScrollArea内部容器的网格
self.gridLayout_ICO = QtWidgets.QGridLayout(self.scrollAreaWidgetContents) # 放Ico的网格布局
self.Init()
# 初始化Ico面板
def Init(self):
# 放12个透明图标占位
self.__IcoNum = 12
for i in range(1,13):
Ico = FileIco(self.scrollAreaWidgetContents,self.gridLayout_ICO,60,i,'new',self.__UI,self)
Ico.SetVisible(False)
self.__IcoList.append(Ico)
QProgressBar
禁止在其他线程刷新,而我UI处理和文件传输是不再同一线程的,因此只能用信号的形式进行刷新。这里可以稍稍优化一下:每传输文件的1%大小就刷新一次,而不要每一帧都发送一个刷新信号。file
:文件模块,提供了文件读写的方法main
:顶层模块myIcoWidget
:自定义ICO面板的相关方法myTreeWidget
:自定义文件树控件的相关方法myThread
:手动实现了一下子线程启动的重载net_client
:客户端的相关方法net_server
:服务器的相关方法protocol
:传输协议的设计UI_download
:下载窗口UIUI_file
:文件选择窗口UIUI_main
:主窗口UIUI_option
:连接配置窗口UI实现一个socket文件传输非常简单,client和server的代码都不超过50行,可以参考我的这篇文章:python 网络编程socket,其中第四节就是一个下载器demo
这个课设的难度主要在于两点,一是多线程的实现,二是文件分片传输的通信协议。
多线程分析:
通信协议分析:
str
及int
类型与bytes
类型的互相转换。前者可以用'12345'.encode('utf-8')
和b'\x01\x02'.decode('utf-8')
;后者可以用100.to_bytes(length=1 , byteorder="big")
和int.from_bytes(byte_flow[22:-1],'big')
recv()
从socket缓存拿数据就好了。这看起来很好,但问题的关键在于如何拆分出数据帧。我们知道,tcp传输是有分片的,每个分片的路由路径都可能不同,这导致我们每次从socket缓存拿出的数据不一定是完整帧,可能是一帧的一个片段,也可能是上一帧的尾部一截和下一帧的首部一截拼起来的。我们只能手动处理出完整的数据帧,这就非常非常麻烦了,我在这里设计了一个特别复杂的字节流处理方法。这个方法处理后的串crc检验失败概率大概不到千分之一,但因为tcp是可靠传输,我现在还不能确定到底是网络问题,还是我那个字节流串处理的有问题client工作流程
服务器端
在连接配置窗口启动server后,会创建一个监听子线程上,它不断循环检测服务器的socket,一旦检测到任何连接,就启动一个子线程,并在这个子线程上创建一个socket,用它来和发起连接的socket通信
服务器端的主线程只负责ui交互,在没有任何client连接时,server端有两个线程,1个socket
每接入一个客户端,服务器启动两个子线程,各自建立一条socket连接 (主连接、心跳连接)。主连接: 在client浏览server文件目录时发送文件和目录列表。心跳连接:client不断发送心跳包,报告自己仍在连接状态,超时时间为10秒,超时后这个client的所有连接将被关闭。这是为了避免客户端的意外断开
此后,每当client端发起一个下载请求,就新建一条连接。同样要先发来client ID,明确其所属关系。在下载文件发送完毕后,client发来关闭命令,结束这个连接
client关闭或断开后,server端利用ID清除其所有连接
通信细节
题目中要求通信前双方必须协商分片数,为此我设计了如下的通信过程
服务器端准备把一些数据(一个被下载文件/目录中文件列表/…)送给客户端
服务器端统计待发送数据大小,根据分片大小(每片256字节)计算本次通信的分片数
服务器端向客户端发送此次通信分片数
客户端收到后,发送应答,其中包含分片数信息
服务器收到应答,比对分片数,一致的话则开始发送数据
实验发现,socket有个缓存,如果数据接收快,消耗慢,会存入这个缓存中。因此用socket.recv(max_size)
方法接收时,可能会接收多个粘连的帧
关于文件选择窗口的刷新
上传模式
下载模式
# 帧构成:学号(9byte) - 姓名(9byte) - 帧位置(4byte) - 数据(233byte) - 校验(1byte)
# 最大容量:2^32B = 4 GB
class Frame():
def __init__(self):
self.__datalist = [] # 字节流列表
self.__loadMax = 256 - len('123456789哈哈哈'.encode('utf-8')) - 4 - 1 # 每一片的有效负载
self.__pos = 0 # 本帧首字节位置(4byte)
self.__buf = b'' # 缓存buf,长256
# frame head
self.__datalist.append('123456789哈哈哈'.encode('utf-8'))
# 返回有效负载
def GetLoadNum(self):
return self.__loadMax
# 填入校验字节
def PutCRC(self):
byte_cnt = 0
byte_sum = 0
for b in self.__datalist:
for i in list(b):
byte_sum += i
byte_cnt += 1
byte_sum %= 256
self.__datalist.append(byte_sum.to_bytes(length=1 , byteorder="big"))
# 编码一个帧,返回字节流
def Code(self,data):
# pos
self.__datalist.append(self.__pos.to_bytes(length=4 , byteorder="big"))
# data
self.__datalist.append(data)
self.__pos += len(data)
# crc
self.PutCRC()
# get frame
frame = '123456789哈哈哈'.encode('utf-8')
for b in self.__datalist[1:]:
frame += b
# clear
self.__datalist[1:] = []
return frame
# 重置帧(当一组数据发送完后需要重置)
def Reset(self):
self.__pos = 0
self.__buf = b''
self.__datalist[1:] = []
# 分片数帧解码(这个一定是一帧传完,不需要考虑帧拼接,单独写一个解码)
def DecodeFrameNum(self,connection_name,byte_flow):
byte_crc = 0
lst = list(byte_flow)
for i in lst[0:-1]:
byte_crc += i
byte_crc %= 256
if byte_crc == lst[-1]:
print(connection_name,"收到分片数据,分片数校验成功")
else:
print(connection_name,"收到分片数据,分片数校验失败")
return -1
value = int.from_bytes(byte_flow[22:-1],'big')
return value
# 数据解码(长数据往往分了多个帧传输,解码byte_flow和data拼接后返回。由于网络的分片路由,需要手动处理各种帧粘包或截断情况)
def Decode(self,connection_name,byte_flow,data = b''):
if byte_flow == b'':
if len(self.__buf) == 0:
print(connection_name,'空错误')
return data,1,1
else:
res,data = self.DecodeFrame(connection_name,self.__buf,data)
if res == 'crc error':
return data,1,1
else:
return data,1,0
errCnt = 0
n = 0
while len(byte_flow) > 256:
res,data = self.DecodeFrame(connection_name,byte_flow[:256],data)
if res == 'crc error':
errCnt += 1
elif res == 'ok':
n += 1
byte_flow = byte_flow[256:]
res,data = self.DecodeFrame(connection_name,byte_flow,data)
if res == 'crc error':
errCnt += 1
elif res == 'ok':
n += 1
return data,n,errCnt
# 解码一个数据帧,考虑各种粘包和截断情况(这个鬼方法我炸了)
def DecodeFrame(self,connection_name,byte_flow,data):
mode = 0
# 帧首不是协议头
if byte_flow[0:18] != '123456789哈哈哈'.encode('utf-8'):
headPos = byte_flow.find('123456789哈哈哈'.encode('utf-8'))
# 帧中部协议头没有出现,可能是帧的后半段
if headPos == -1:
# 拼接后长度不够最大帧长,连接到帧缓存后返回
if len(byte_flow) + len(self.__buf) < 256:
print(connection_name,'重装',len(self.__buf),len(byte_flow))
self.__buf += byte_flow
return 'reload',data
# 拼接后长度超过最大帧长,拼接出完整帧,清空帧缓存
else:
print(connection_name,'进行拼接1',len(self.__buf),len(byte_flow))
byte_flow = self.__buf + byte_flow
self.__buf = b''
mode = 1
# 帧中部出现协议头,前一半肯定是帧的后半段,拼接出完整帧;后一半可能是部分或完整帧,存入缓存
else:
print(connection_name,'进行拼接2',len(self.__buf),len(byte_flow[:headPos]),len(byte_flow[headPos:]))
temp = byte_flow[headPos:]
byte_flow = self.__buf + byte_flow[:headPos]
self.__buf = temp
mode = 2
# 是协议头
else:
# 帧中部出现协议头,前一半肯定完整帧;后一半可能是部分或完整帧,存入缓存
headPos = byte_flow.find('123456789哈哈哈'.encode('utf-8'),18)
if headPos != -1:
self.__buf = byte_flow[headPos:]
byte_flow = byte_flow[:headPos]
pos = int.from_bytes(byte_flow[18:22], 'big')
value = int.from_bytes(byte_flow[22:-1],'big')
byte_crc = 0
lst = list(byte_flow)
for i in lst[0:-1]:
byte_crc += i
byte_crc %= 256
# 效验成功
if byte_crc == lst[-1]:
data += byte_flow[22:-1]
return 'ok',data
# 效验失败
else:
# 如果长度不足最大帧长,可能是不完整,存入帧缓存
if len(byte_flow) < 256:
print(connection_name,'装载',len(self.__buf),len(byte_flow))
self.__buf = byte_flow
return 'load',data
# 长度已到最大帧长,一定是传输出错
else:
print(connection_name,"收到分片数据,校验失败",mode,len(byte_flow),byte_crc,lst[-1])
#print(byte_flow)
return "crc error",data
import threading
class MyThread (threading.Thread):
def __init__(self, name, process, args = None):
threading.Thread.__init__(self)
self.args = args
self.name = name
self.process = process
# 实现函数重载
def run(self):
print ("thread start:" + self.name)
if not self.args:
self.process()
elif type(self.args) == list:
L = len(self.args)
if L == 2:
self.process(self.args[0],self.args[1])
elif L == 3:
self.process(self.args[0],self.args[1],self.args[2])
else:
self.process(self.args[0],self.args[1],self.args[2],self.args[3])
else:
self.process(self.args)
print ("thread end:" + self.name)
# 启动服务器,监听socket开始监听,允许被动连接
# 监听线程中启动监听socket,允许被动连接
def Listen(self):
print("server:开始监听")
self.__server_socket.listen(128)
self.__server_is_listening = True
while self.__server_is_listening:
try:
client_socket,client_addr = self.__server_socket.accept() # 设置setblocking(False)后, accept不再阻塞
print("连接成功,客户端ip:{},port:{}".format(client_addr[0],client_addr[1]))
# 一旦连接成功,开一个子线程进行通信
client_socket.setblocking(False) # 子线程是非阻塞模式的(需要循环判断监听线程退出)
client_socket.settimeout(5) # 超时值设为5s
self.__running_client_cnt += 1
self.__thread_cnt += 1
self.new_client_signal.emit(self.__running_client_cnt) # 向ui发信号,更新ui
client_name = "client{}".format(self.__thread_cnt) # 创建子线程
client_thread = MyThread(client_name, self.SubClientThread, [client_socket, client_name])
client_thread.setDaemon(True) # 子线程配置为守护线程,主线程结束时强制结束
client_thread.start() # 子线程启动
except BlockingIOError:
pass
setblocking(False)
后, accept不再阻塞,它会(不断的轮询)要求必须有connect来连接, 不然就引发BlockingIOError
, 我们捕捉这个异常并pass掉。这样才能循环检测监听线程断开self.__server_is_listening
和监听子线程 及 所有client子线程通信,以保证关闭server时可以同时结束所有子线程__server_is_listening
轮询# socket接受
def BytesRecv(self,client_socket,client_name,max_size):
data = None
timeout = 0
ID = self.__sub_thread[client_name][2] # 此线程所属客户端ID
while data == None and self.__server_is_listening and not ID in self.__died_client:
try:
data = client_socket.recv(max_size)
except BlockingIOError: # 非阻塞socket,pass此异常以实现轮询
pass
except ConnectionAbortedError:
if client_name in self.__sub_thread_heart: # 客户端断开,可能出这个异常
self.HeartStop(client_name)
return '连接断开'
except ConnectionResetError: # 客户端断开,可能出这个异常
if client_name in self.__sub_thread_heart:
self.HeartStop(client_name)
return '连接断开'
except socket.timeout:
if client_name in self.__sub_thread_heart: # 只对心跳线程做超时判断
timeout += 5
print(client_name,'连接超时',timeout)
if timeout == 10:
self.HeartStop(client_name)
return '连接断开'
if not self.__server_is_listening or data == b'': # 客户端断开,data返回空串
return '连接断开'
return data
因为允许多用户多文件并行下载,服务器这边的线程管理很麻烦,我设计了以下数据结构
self.__sub_thread
字典:这里有所有除了监听线程以外的子线程,主要用于通信
self.__sub_thread_union
字典:这里按client为单位划分元素,每个元素中存储此client的所有子线程名,方便连接断开时断开所有连接
self.__sub_thread_heart
列表:这里存储所有心跳线程名,只对它们进行超时检测self.__died_client
列表:这里存储所有处于已检测到断开,但尚未断开所有连接的client的ID结束线程
# 结束子线程
def StopSubThread(self,client_socket,client_name):
# 从客户端线程集中清除此线程
thread_id = self.__sub_thread[client_name][2]
self.__sub_thread_union[thread_id].remove(client_name)
# 如果这是断开的客户端的线程,且此断开客户端线程集已清空,把这个客户端ID移除
if thread_id in self.__died_client and not self.__sub_thread_union[thread_id]:
del self.__sub_thread_union[thread_id]
self.__died_client.remove(thread_id)
# 从全体线程集中清除此线程记录
del self.__sub_thread[client_name]
client_socket.close()
self.__running_client_cnt -= 1
self.new_client_signal.emit(self.__running_client_cnt) # 向ui发信号,更新ui
# 心跳线程断开后的处理
def HeartStop(self,heart_name):
self.__sub_thread_heart.remove(heart_name) # 从心跳列表中移除此线程
ID = self.__sub_thread[heart_name][2] # 获取心跳超时的客户端ID
self.__died_client.append(ID) # 此心跳对应的客户端ID加入死亡client列表
def SubClientThread(self,client_socket,client_name):
print(client_name + ":线程启动")
# 给此线程一个Frame对象,用来构成帧
if not client_name in self.__sub_thread:
self.__sub_thread[client_name] = [Frame() , threading.currentThread(),''] #字典可自动添加
# 轮询处理客户端的命令
while self.__server_is_listening:
# 先检查此线程对应的客户端是不是已经断开连接,如果断开了,关闭连接
thread_id = self.__sub_thread[client_name][2]
if thread_id in self.__died_client:
break
data = self.DataRecv(client_socket,client_name)
if type(data) == bytes:
data = data.decode('utf-8')
print(client_name,"接收数据",data,'-----------------------------\n')
if data == '连接断开':
break
# client主线程发起注册
elif data == 'login new client':
self.Login(client_socket,client_name)
# ...其余代码省略
# 注册连接
def Login(self,client_socket,client_name):
print('注册新client')
# 生成一个唯一的key
key = ''.join(random.sample('abcdefghijklmnopqrstuvwxyz',10))
while key in self.__sub_thread_union:
key = ''.join(random.sample('abcdefghijklmnopqrstuvwxyz',10))
# 主线程加入字典
self.__sub_thread_union[key] = [client_name]
self.__sub_thread[client_name][2] = key
# 返回key
self.DataSend(client_socket,client_name,key.encode('utf-8'))
a='ab,cd,ef'
print(a.split(',')) # ['ab', 'cd', 'ef']
a=['a','b']
print(''.join(a)) # ab
n = 123
n_b = n.to_bytes(length=4,byteorder="big") # int -> bytes
print(int.from_bytes(n_b, 'big')) # bytes -> int
s = "12345"
s_b = s.encode('utf-8')
print(s_b,s_b.decode('utf-8')) # b'12345' 12345
经过测试,socket中应该有个缓存,当和多线程配合用的时候,如果在每掉度到当前线程的时候收到数据,这些数据会被累积在缓存中,当调度到此socket执行时一起取出来
GUI相关的对象不能在非GUI的线程创建和使用,是非线程安全的
QObject::setParent: Cannot set parent, new parent is in a different thread
以下是我在编写这个程序时参考的博客内容和一些笔记
pyqt5实现按钮添加背景图片以及背景图片的切换方法
pyqt删除控件
pyqt5设置按钮透明度
pyqt5 QscrollArea(滚动条)的使用
pyqt5 label文本对齐设置
python3 bytes拼接
python 读取大文件,按照字节读取
pyqt5 QLineEdit用正则限制
python 中 socket 的超时
python程序打包太大