- 这里介绍一下我计算机网络课的课程设计,这篇文章是由当时的项目报告扩充而来。
- 本项目实现了一个基于socket的文件传输器,可以配置为服务器或客户端,支持多用户用时下载/上传多个文件。但没有实现单个文件多线程传输
- python+PyQt开发,总代码行数大概2300,去掉自动生成的UI和各种注释估计有1900到2000左右。高强度写了一周提交,得分为最高等4.5。但是感觉写的太乱了,断断续续精简代码,完善UI体验,排除小bug又折腾了一周多。虽然仍有一些bug,连接不太稳定,但达到了勉强可以实际使用的程度。此软件可以实现局域网中的文件传输,要实现外网传输,需要部署至云服务器上
- 下载链接如下(注意:仍有一些bug!!仅供交流学习使用):
- 可执行程序下载 https://pan.baidu.com/s/1NkmZd7FfCjdNtnfjxrJyuQ 密码6u0t
- 源码:https://github.com/wxc971231/file-helper
- 关键词:
python
、PyQt5
、多线程
、socket
文章目录
一、 实验任务和目的
-
实验名:传输文件
-
实验目的:要求学生掌握Socket编程中流套接字的技术
-
实验内容:
-
要求学生掌握利用Socket进行编程的技术
-
要求客户端可以罗列服务器文件列表,选择一个进行下载
-
对文件进行分割(每片256字节),分别打包传输
-
发送前,通过协商,发送端告诉接收端发送片数
-
报头为学号、姓名、本次分片在整个文件中的位置
-
报尾为校验和:设要发送n字节,bi为第i个字,
校验和s=(b0+b1+…+bn) mod 256
-
-
接收方进行合并
-
必须采用图形界面
-
发送端可以选择文件,本次片数
-
接收端显示总共的片数,目前已经接收到的文件片数,收完提示完全收到
-
-
-
扩展功能:
- 客户端加入上传功能
- 支持多个客户端同时连接一个服务器
- 支持每个连接的客户端同时上传/下载多个文件
二、开发运行环境
- 开环语言:python
- 图形界面:PyQt5
- 开发环境:vscode
- 运行环境:windows
三、主要功能分析及界面设计
1. 功能分析
- 可以配置为服务器或客户端
- 服务器文件浏览、本地文件浏览
- 服务器文件选择下载、本地文件选择上传
- 具有一定的通信协议
- 显示文件传输进度,能提示传输结果(成功或失败)
- 支持多个客户端同时连接服务器
- 支持每个客户端同时传输多个文件
2. 界面设计
(1)设计思路
- 尽量使用Qt designer图形化设计工具进行整体布局设计,然后再对生成的代码进行手动修改,从而最大限度减少工作量。
- 对于有自定义需求的控件,应该通过继承原生控件实现,并在Qt designer设计中为留下放置原生控件的空间
- 界面尽量简洁,但是也要有良好的用户提示(修改窗口状态栏、控件文本、控件使能状态等)
- 检测到连接断开、下载失败等异常状态时,界面要有相应的变化
- 界面要有良好的限制措施,禁止用户进行某些状态下不可进行的操作,禁止用户进行非法输入。
- 无论任何情况下,界面控制不能卡死,故应当将UI控制放在一个单独的线程中实现
(2)主窗口
1. UI设计
2. 说明
- 通过 “连接配置” 按钮将软件配置为server或client
- 只有在配置为client时,上传、下载按键才使能
- 状态栏在不同状态下给出不同提示:
- 没有配置时显示:
连接未建立
- 手动断开或异常断开时显示:
连接断开
- client尝试连接服务器时显示:
connecting server
- client连接成功时显示:
当前连接数xx
(这是client发起的client传输
socket连接数目) - server启动后显示:
正在监听port:xxxx,client连接xx
(这是server收到的所有socket连接的数目,包括每个client的client UI
、client心跳
和client传输
三类连接)
- 没有配置时显示:
(3)连接配置窗口
1. UI设计
-
这是没有进行配置时的界面
-
这是配置为server连接了一个client时的界面)
-
这是配置为client并连接了server时的界面)
2. 说明
- 在没有连接时,点击 “配置为server” 和 “配置为client” 将自动修改输入栏提示
- 点击 “配置为server”时,在ip栏自动写入本机ip,并ip栏失能禁止用户修改
- 连接启动前后,按键使能失能自动设置,避免用户非法操作
- port和ip输入均配置了正则表达式输入检查器,禁止非法输入
- 状态栏提示同主窗口
3. 部分关键代码截选
# 截选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
(4)文件选择窗口
1. UI设计
- 下载状态的文件选择窗口
2. 说明
- 不管上传还是下载,都会打开文件选择窗口
- 上传模式,这里显示的是本机的文件和文件夹;最上面的长按钮用于快速选择本地目录,默认上传目录是本机桌面
- 下载模式,这里显示的是服务器的文件和文件夹;最上面的长按钮用于快速选择保存路径,默认下面目录是服务器桌面
- 提供两个快捷按钮,可以直接转到本机/服务器的C盘或桌面目录
- 状态栏显示当前选择的路径
- 可以直接点左下文件树或右侧文件面板实现文件浏览
- 点击两边的文件夹可以进入子目录
- 点击文件树最上面的
<back>
可以返回上一级目录,当回退到磁盘根目录时会自动限制 - 当鼠标滑过右边文件面板的时候,鼠标指向的图标会显示一个框,提示当前指向的文件
- 经过实验,我发现python的文件目录查询函数会返回一些隐藏文件夹,其中有些是禁止访问的,这些文件夹也会显示在此文件选择窗口中,点击时会在状态栏提示没有权限
- 只有选中了一个文件(左右都行),确认文件按钮才会使能,点击就会开始上传/下载
3. UI实现思路
-
其的几个窗口都比较简单,直接使用
pyqt
提供的控件即可实现,但文件选择窗口就很麻烦了,因此专门再说一说 -
先用Qt designer进行框架设计,放好按钮和部件,左下放一个
QTreeWidget
,右边放一个QScrollArea
占位置。别忘了整体套一个网格布局,以实现界面大小拖动的自动适配 -
左下角的文件树继承自
QTreeWidget
控件,增加一些文件浏览的方法- 点击项目后,自动判断项目类型,如果是文件夹,就切换目录
- 点击
<back>
,目录回退 - 在加载目录下文件树的时候,一开始想使用递归的方式把整个文件树全部加载,这样在下载模式下浏览子目录就不用等数据传输了,会快很多。但是测试发现,如果访问了太顶层的目录(如C盘根目录),它递归展开后文件数非常多,导致传输数据量巨大,加载时间过长。所以最后改成了分目录加载,每次切换到一个新目录就进行加载,这样虽然每次都要请求并传输文件列表,但是不会出现太长的等待
-
右侧的文件面板继承自
QScrollArea
控件,这里想实现类似windows大图标浏览的效果-
QScrollArea
控件自带了滚轮滑动的功能 -
为了使面板上的内容可以点击,这个面板上的每个ICO图标都是我定义的ICO类对象,我先在
QScrollArea
上放一个 “内部容器”widget
,再在此widget
上放置Grid layout
网格布局,最后把ICO对象放到网格里。- ICO类的主要构成是一个
pushButton
(按钮改成文件ICO的图像)加一个用于显示文件名label
- 当鼠标移动到按钮上时,此按钮切换图片为我ps过的一个带边框的图片,实现指示效果
- 这里我用了一个工具,可以方便地直接获取本机各种文件的ico图标,链接如下:ICO工具
- ICO类还存储了 “文件路径” 等成员变量,并提供了点击事件,以实现目录的切换
- ICO类的主要构成是一个
-
因为水平有限,没能实现这种设计下的窗口拖动适配,windows的大图标显示,在窗口拉大或缩小时,可以自动调整每一行的图标数量。我尝试做的适配不能改每行图标数量,导致图标间距过大或过小,很难看。因此我把ICO面板的尺寸设置为固定的了,每行只能显示3个图标,一屏最多显示4行
-
为了提高加载效率,在每次目录切换的时候,不会删除ICO面板上的所有ICO对象后再重添加。而是让前12个按钮变成透明的,删除12个以外的其他ICO对象,这样只要新目录的文件/文件夹少于12个,就不需要创建新的ICO对象。如果超过12个,则需要创建新的ICO对象
-
选择12是因为这是ICO面板一页里最多显示的图标数,这样可以保证滚轮滑条正常。举例来说,假如我从一个图标很多的目录切换到一个图标很少的目录,如果直接把所有图标变成透明的,会导致图标面板可以向下滑很多,但都是空的,我想避免这种情况发生
-
4. 部分关键代码截选
# 截选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)
(5)文件传输进度窗口
1. UI设计
- 下载过程
- 上传过程
- 传输失败
- 多文件同时下载
2. 说明
- 开始传输后,会弹出出一个传输窗口,当进度跑满后,完成按钮才使能
- 文件名是正在传输的文件
- 分片数是此文件传输过程中分片的个数
- 点击使能的完成按钮,关闭此窗口
- 当检测到连接中断时,进行提示,且自动弹出连接配置窗口(写到这里时,我才发现传输窗口的断开提示只做了下载模式的,如果是上传只能弹出连接配置窗口…不过先不改了吧)
- 支持多个文件同时传输,可以同时下载多个,同时上传多个,一边上传一边下载也行,理论上数目无限
- 多个客户端,每个都同时上传下载也没问题
- 在下载任务的窗口状态栏显示了当前下载过程中的解码错误帧数目。因为水平有限,尽管对帧解码进行了比较复杂的处理,但依然不能保证数据帧传输100%正确,我不知道是网络的问题还是我解码算法的问题。虽然错误率很低,但是一旦出现一帧错误,就很可能导致收到的文件不能打开,只能重新下载。这里还有待优化
- 上传任务在client是看不到帧错误数量的,虽然在server统计了,但我没有做回传client显示,这里也可以改一改
- 还有一个要强调的,这里做进度显示的控件
QProgressBar
禁止在其他线程刷新,而我UI处理和文件传输是不再同一线程的,因此只能用信号的形式进行刷新。这里可以稍稍优化一下:每传输文件的1%大小就刷新一次,而不要每一帧都发送一个刷新信号。
四、架构、模块及接口设计
1. 程序组织如下
- 程序组成
file
:文件模块,提供了文件读写的方法main
:顶层模块myIcoWidget
:自定义ICO面板的相关方法myTreeWidget
:自定义文件树控件的相关方法myThread
:手动实现了一下子线程启动的重载net_client
:客户端的相关方法net_server
:服务器的相关方法protocol
:传输协议的设计UI_download
:下载窗口UIUI_file
:文件选择窗口UIUI_main
:主窗口UIUI_option
:连接配置窗口UI
2. 功能分析
-
实现一个socket文件传输非常简单,client和server的代码都不超过50行,可以参考我的这篇文章:python 网络编程socket,其中第四节就是一个下载器demo
-
这个课设的难度主要在于两点,一是多线程的实现,二是文件分片传输的通信协议。
-
多线程分析:
- 首先要保证UI不能卡,这是用户体验的核心,因此双方UI必须占一个线程(主线程)。
- 双方要能识别到连接断开,使用心跳检测机制实现(参考:Python3 Socket与Socket心跳机制简单实现),所以client要有一个心跳线程。
- 先考虑client - server一对一多文件传输,可以在简单下载器demo的基础上修改一下
- server端启动后要进行监听,为了避免UI卡顿,server要有一个监听线程
- client发起连接时,由于可能因网络问题导致连接不上,为了避免UI卡住,发起连接要放在子线程中,这个线程只负责建立UI和心跳的socket连接以及启动心跳子线程。所以client要有一个发起连接线程
- 此后就是文件传输了,所有文件传输都需在两边各开一个子线程,在这两个子线程上建立一个socket传递数据。
- 一旦心跳包超时,认定连接断开。client和server端直接关闭心跳和所有文件传输的子线程及socket连接,client端进行UI提示。
- 小结一下各个线程间的socket连接关系:
- server主线程 —— client主线程(UI通信)
- server主线程 —— client心跳线程(心跳通信)
- server监听线程 —— client发起连接线程(发起连接)
- server监听线程 —— client文件传输线程(文件传输请求,启动server端文件传输线程)
- server文件传输线程 —— client文件传输线程(文件传输)
- 进一步考虑client - server多对一文件传输,这个只要在一对一上基础修改一点。因为每个client都有自己的心跳线程,当检测到心跳超时时,server需要知道哪些socket和线程该断开。为解决此问题,我们可以在client发起连接线程中向server发送注册命令,申请一个唯一的client ID,今后此client的所有socket连接均要先发送此ID表明身份。这样当server检测到某个client的心跳超时后,就断开所有此ID的socket连接即可
-
通信协议分析:
- 通信协议的设计很简单,和我以前搞得嵌入式通信没啥区别,crc计算直接用累加取低8位的简单方式进行。
- 要注意的就是python中
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')
- 经过测试,python的socket有接受缓存,也不用担心数据接受因被线程调度打断而出现问题,我们只要不断用
recv()
从socket缓存拿数据就好了。这看起来很好,但问题的关键在于如何拆分出数据帧。我们知道,tcp传输是有分片的,每个分片的路由路径都可能不同,这导致我们每次从socket缓存拿出的数据不一定是完整帧,可能是一帧的一个片段,也可能是上一帧的尾部一截和下一帧的首部一截拼起来的。我们只能手动处理出完整的数据帧,这就非常非常麻烦了,我在这里设计了一个特别复杂的字节流处理方法。这个方法处理后的串crc检验失败概率大概不到千分之一,但因为tcp是可靠传输,我现在还不能确定到底是网络问题,还是我那个字节流串处理的有问题
3. 详细功能设计
-
client工作流程
- 在在连接配置窗口启动client后,立即创建一个连接子线程,在这个子线程上发起一条到server的socket连接。这期间,我们可以在主线程控制UI界面,不会出现因连接不上导致的程序卡死
- socket连接建立后,client向server请求一些信息(比如服务器桌面路径),并会申请一个client ID,接收这些数据后,连接子线程关闭。此后,主线程通过连接子线程建立的这个socket和服务器传输信息(比如刷新UI的文件列表等)
- 获取client ID后,客户端创建一个心跳子线程,在这个子线程上发起一条socket连接,注册为此client ID对应的心跳连接。
- 此后,每当用户在客户端下载一个文件时,都会建立一个子线程,并在这个子线程上发起一个新socket连接。(同样,要先注册为client ID所属的下载连接)。同一时刻,可能有多条下载连接
-
服务器端
-
在连接配置窗口启动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)
方法接收时,可能会接收多个粘连的帧- 可以限制max_size=256,强制一次从缓存取一个最大帧长解码,这能解决部分粘包问题,但仍不能解决帧截断和截断后粘连的问题。故需编写一个 “帧处理方法”
- 也可以一次从缓存取更多数据,解码时手动分为256字节一组送入 “帧处理方法”
-
-
关于文件选择窗口的刷新
-
上传模式
- 上传模式只需在本地进行处理,比较简单。有多种库方法可以直接获取目录中文件的列表,也可以很简单地判断某个路径是文件和文件夹,因此不赘述
-
下载模式
- 下载模式比较麻烦,我的处理流程如下
- 客户端第一次连接服务器时,服务器返回其桌面路径,这样客户端就可以通过修改此路径来得到服务器C盘各个目录的路径
- 当客户端请求某个目录下的文件列表时,直接发送路径到服务器,服务器在本地查出所有文件、所有文件夹、所有文件尺寸,做成3个列表,然后拼装成一个字符串,最后转二进制发送给客户端
- 客户端解码后,把文件信息还原,利用这些信息刷新文件选择面板,并判断某路径是否为目录
-
五、详细设计
1. 传输协议
# 帧构成:学号(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
2. 子线程启动时的重载函数
- 不同的子线程启动时需要不同的参数,但是python不支持重载函数,所以这里手动实现之
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)
3. 服务器的监听线程
- 监听线程不断执行这个循环
# 启动服务器,监听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
- accept原本是阻塞的,等待connect。设置
setblocking(False)
后, accept不再阻塞,它会(不断的轮询)要求必须有connect来连接, 不然就引发BlockingIOError
, 我们捕捉这个异常并pass掉。这样才能循环检测监听线程断开 - 主线程通过共享变量
self.__server_is_listening
和监听子线程 及 所有client子线程通信,以保证关闭server时可以同时结束所有子线程 - 监听子线程和所有client子线程中的socket都是非阻塞模式,否则无法
__server_is_listening
轮询
4. 服务器接收心跳
- 这是服务器接收socket数据的方法
# 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
- 心跳socket设置为阻塞式,超时时间为5s,如果记录两次超时(即10s没收到心跳),即认为此client断开
- 这里除了心跳,还检测了多种socket连接断开的异常
5. 服务器线程管理
-
因为允许多用户多文件并行下载,服务器这边的线程管理很麻烦,我设计了以下数据结构
self.__sub_thread
字典:这里有所有除了监听线程以外的子线程,主要用于通信- 元素构成 :(线程名:[Frame对象,thread对象,所属客户端ID])
self.__sub_thread_union
字典:这里按client为单位划分元素,每个元素中存储此client的所有子线程名,方便连接断开时断开所有连接- 元素构成 (所属客户端ID:[主线程名,心跳线程名,文件线程名…])
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列表
- client连接到server后的主线程(client的2个基础线程就是这个和心跳)
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'))
六、后记
- 网络课上学了很多网络相关的知识,但总感觉有些虚,这次课设我觉得是把理论和实践相结合的一个好示范,也对课本的知识有了更深的了解。
- 刚开始做的时候,第一版其实做的很快,毕竟python也比较简单,没写多少行就能下载了。但是总觉得想做好一点,正好当时操作系统课在讲多进程多线程什么的,就想着结合一下,最后效果还不错。
- 关于界面费了不少功夫,我以前做过简单的pyqt界面程序,感觉也不太难,但这次的文件选择窗口真的花了好长时间才做好,感觉界面这东西就是做出来简单,做好看就很难了,要是在仔细考虑各种非法操作限制和逻辑优化,简直有点无底洞的意思。
- 这次课设我也感觉到我对大型程序的掌控能力不足。开始的小实验写的还挺规整的,但随着代码越来越多,整个框架结构就开始乱了,很多一开始的写法,本来感觉挺不错的,但扩展性太差,导致后来又要重写。我想这些也是由于一开始没怎么设计就直接写了,从这里我更认识到软件工程的重要性,那些表什么的真的不能嫌麻烦,不然最后程序就是一团乱
七、参考和笔记
(1)我的学习记录
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