项目Demo —— socket下载器
- 这里介绍一下我计算机网络课的课程设计,这篇文章是由当时的项目报告扩充而来。
- 本项目实现了一个基于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
文章目录
- 一、 实验任务和目的
- 二、开发运行环境
- 三、主要功能分析及界面设计
- 1. 功能分析
- 2. 界面设计
- (1)设计思路
- (2)主窗口
- 1. UI设计
- 2. 说明
- (3)连接配置窗口
- 1. UI设计
- 2. 说明
- 3. 部分关键代码截选
- (4)文件选择窗口
- 1. UI设计
- 2. 说明
- 3. UI实现思路
- 4. 部分关键代码截选
- (5)文件传输进度窗口
- 1. UI设计
- 2. 说明
- 四、架构、模块及接口设计
- 1. 程序组织如下
- 2. 功能分析
- 3. 详细功能设计
- 五、详细设计
- 1. 传输协议
- 2. 子线程启动时的重载函数
- 3. 服务器的监听线程
- 4. 服务器接收心跳
- 5. 服务器线程管理
- 六、后记
- 七、参考和笔记
- (1)我的学习记录
- (2)参考
一、 实验任务和目的
实验名:传输文件
实验目的:要求学生掌握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 -> ICOself.__widget = widget # 承载ICO的widgetself.__layout = layout # 承载ICO的网格布局self.__size = size # ICO尺寸 (fixed)self.__name = name self.__op = QtWidgets.QGraphicsOpacityEffect() #透明的设置self.__ID = num # ICO编号self.__UI = UI # 文件窗口整体UIself.__SA = SA # 承载ICO的QScrollAreaself.setupUI()# 建立UIdef 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的widgetself.__UI = ui # 文件窗口UIself.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 = 12for 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 GBclass 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 headself.__datalist.append('123456789哈哈哈'.encode('utf-8'))# 返回有效负载def GetLoadNum(self):return self.__loadMax # 填入校验字节def PutCRC(self):byte_cnt = 0byte_sum = 0for b in self.__datalist:for i in list(b):byte_sum += ibyte_cnt += 1byte_sum %= 256self.__datalist.append(byte_sum.to_bytes(length=1 , byteorder="big"))# 编码一个帧,返回字节流def Code(self,data):# posself.__datalist.append(self.__pos.to_bytes(length=4 , byteorder="big"))# dataself.__datalist.append(data)self.__pos += len(data)# crcself.PutCRC()# get frameframe = '123456789哈哈哈'.encode('utf-8')for b in self.__datalist[1:]:frame += b# clearself.__datalist[1:] = []return frame# 重置帧(当一组数据发送完后需要重置)def Reset(self):self.__pos = 0self.__buf = b''self.__datalist[1:] = []# 分片数帧解码(这个一定是一帧传完,不需要考虑帧拼接,单独写一个解码)def DecodeFrameNum(self,connection_name,byte_flow):byte_crc = 0lst = list(byte_flow)for i in lst[0:-1]:byte_crc += ibyte_crc %= 256if byte_crc == lst[-1]:print(connection_name,"收到分片数据,分片数校验成功")else:print(connection_name,"收到分片数据,分片数校验失败")return -1value = 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,1else:res,data = self.DecodeFrame(connection_name,self.__buf,data)if res == 'crc error':return data,1,1else:return data,1,0errCnt = 0n = 0while len(byte_flow) > 256:res,data = self.DecodeFrame(connection_name,byte_flow[:256],data)if res == 'crc error':errCnt += 1elif res == 'ok':n += 1byte_flow = byte_flow[256:]res,data = self.DecodeFrame(connection_name,byte_flow,data)if res == 'crc error':errCnt += 1elif res == 'ok':n += 1return 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_flowreturn 'reload',data# 拼接后长度超过最大帧长,拼接出完整帧,清空帧缓存else:print(connection_name,'进行拼接1',len(self.__buf),len(byte_flow))byte_flow = self.__buf + byte_flowself.__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 = tempmode = 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 = 0lst = list(byte_flow)for i in lst[0:-1]:byte_crc += ibyte_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_flowreturn 'load',data# 长度已到最大帧长,一定是传输出错else:print(connection_name,"收到分片数据,校验失败",mode,len(byte_flow),byte_crc,lst[-1])#print(byte_flow)return "crc error",data
2. 子线程启动时的重载函数
- 不同的子线程启动时需要不同的参数,但是python不支持重载函数,所以这里手动实现之
import threadingclass MyThread (threading.Thread):def __init__(self, name, process, args = None):threading.Thread.__init__(self)self.args = argsself.name = nameself.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 = Truewhile 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 += 1self.__thread_cnt += 1self.new_client_signal.emit(self.__running_client_cnt) # 向ui发信号,更新uiclient_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 = Nonetimeout = 0ID = self.__sub_thread[client_name][2] # 此线程所属客户端IDwhile 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此异常以实现轮询passexcept 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 += 5print(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 -= 1self.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] # 获取心跳超时的客户端IDself.__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:breakdata = 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')# 生成一个唯一的keykey = ''.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# 返回keyself.DataSend(client_socket,client_name,key.encode('utf-8'))
六、后记
- 网络课上学了很多网络相关的知识,但总感觉有些虚,这次课设我觉得是把理论和实践相结合的一个好示范,也对课本的知识有了更深的了解。
- 刚开始做的时候,第一版其实做的很快,毕竟python也比较简单,没写多少行就能下载了。但是总觉得想做好一点,正好当时操作系统课在讲多进程多线程什么的,就想着结合一下,最后效果还不错。
- 关于界面费了不少功夫,我以前做过简单的pyqt界面程序,感觉也不太难,但这次的文件选择窗口真的花了好长时间才做好,感觉界面这东西就是做出来简单,做好看就很难了,要是在仔细考虑各种非法操作限制和逻辑优化,简直有点无底洞的意思。
- 这次课设我也感觉到我对大型程序的掌控能力不足。开始的小实验写的还挺规整的,但随着代码越来越多,整个框架结构就开始乱了,很多一开始的写法,本来感觉挺不错的,但扩展性太差,导致后来又要重写。我想这些也是由于一开始没怎么设计就直接写了,从这里我更认识到软件工程的重要性,那些表什么的真的不能嫌麻烦,不然最后程序就是一团乱
七、参考和笔记
(1)我的学习记录
- python 网络编程socket
- python 多线程threading模块
- 字符串和列表的转换
a='ab,cd,ef'print(a.split(',')) # ['ab', 'cd', 'ef']a=['a','b']print(''.join(a)) # ab
- 整数和字节流的转换
n = 123n_b = n.to_bytes(length=4,byteorder="big") # int -> bytesprint(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
(2)参考
以下是我在编写这个程序时参考的博客内容和一些笔记
pyqt5实现按钮添加背景图片以及背景图片的切换方法
pyqt删除控件
pyqt5设置按钮透明度
pyqt5 QscrollArea(滚动条)的使用
pyqt5 label文本对齐设置
python3 bytes拼接
python 读取大文件,按照字节读取
pyqt5 QLineEdit用正则限制
python 中 socket 的超时
python程序打包太大
项目Demo —— socket下载器相关推荐
- 【实战项目】---P2P下载器
P2P下载器 1.引言 2.项目简介 3.整体框架 4.服务端设计 5.客户端设计 6.主要功能端口 7.httplib的处理流程: 8.源码 1.引言 在校期间经常需要进行给学委,班长拷贝文件.互传 ...
- 【Linux项目】 --P2P下载器的详细介绍
P2P下载器 一.P2P下载器功能简介 二.客户端功能细分 1.获取在线主机 1.1 获取网卡信息,得到局域网中的所有IP地址列表 1.2 逐个对IP地址列表的主机发送配对请求 1.3 配对得到响应, ...
- 我的第六个项目:实现一个任意图片下载器
点击上方蓝色字体,关注程序员zhenguo 你好,我是 zhenguo 这是我的第498篇原创 这是第六个Python小项目,做一个图片下载器. 之前项目: 我的第五个项目:实现一个文本定位器 我的第 ...
- 用python爬虫制作图片下载器(超有趣!)
这几天小菌给大家分享的大部分都是关于大数据,linux方面的"干货".有粉丝私聊小菌,希望能分享一些有趣的爬虫小程序.O(∩_∩)O哈哈,是时候露一手了.今天给大家分享的是一个适合 ...
- MTK Http Socket GPRS以及解析器和下载器概述
MTK App网络应用介绍 MTK App网络开发步骤 GPRS网络注册模块 Socket联网API接口 HTTP功能部件 使用HTTP封装解析器和下载器 Parser Downloader 调试功能 ...
- P2P下载器(Linux下C++项目实战)
P2P下载器:即点对点下载器,服务端与客户端.服务端共享文件列表,客户端配对相应服务端,下载所需要的文件. 一.项目介绍 1.项目功能 搜索附近(局域网内)在线用户, 此处不足(只能在局域网内获取,需 ...
- 【Python项目】Python基于tkinter实现笔趣阁小说下载器(附源码)
前言 hello,大家好呀~ 笔趣阁小说应该很多小伙伴都知道 但是用Python实现一个笔趣阁小说下载器 那岂不是爽歪歪呀 基于tkinter实现的Python版本的笔趣阁小说下载器今天小编给大家实现 ...
- 基于iOS 10、realm封装的下载器
代码地址如下: http://www.demodashi.com/demo/11653.html 概要 在决定自己封装一个下载器前,我本以为没有那么复杂,可在实际开发过程中困难重重,再加上iOS10和 ...
- 基于iOS 10封装的下载器(支持存储读取、断点续传、后台下载、杀死APP重启后的断点续传等功能)
原文 资源来自:http://www.cocoachina.com/ios/20170316/18901.html 概要 在决定自己封装一个下载器前,我本以为没有那么复杂,可在实际开发过程中困难重重, ...
最新文章
- POJ 3169 差分约束
- CSS层叠样式表进阶
- mq同步mysql数据 duplicate entry_MySQL数据同步之otter
- OpenCV支持向量机SVM简介
- 8张图告诉你如何运营微信公众号
- linux 下查看应用版本信息,Linux下查看版本信息
- docker配置 nacos_Nacos - 阿里开源配置中心
- 微信个人名片H5生成器
- 品致高频电流探头的主要特点和连接示波器
- 碎片化知识管理工具Memos
- java 写服务器向客户端发送消息,java服务器向客户端发送消息
- Verilog加法器设计
- 转换接头PL8000V-B 0-70MPa
- 深入理解WKWebView白屏
- 5 年京东后端研发程序员,从开始的3k到现在的36k,我终于熬出头
- SAP MM批次管理(2)批次主数据--大海
- php 屏蔽搜索机器人,php实现判断访问来路是否为搜索引擎机器人的方法
- IRIS平台部署手册及基本操作
- xxl-job优雅停止执行器即客户端tomcat
- MC7805多路稳压模块7V12V30V降压模块5V输出3.3V输出