项目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
  • 关键词:pythonPyQt5多线程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编程中流套接字的技术

  • 实验内容:

    1. 要求学生掌握利用Socket进行编程的技术

    2. 要求客户端可以罗列服务器文件列表,选择一个进行下载

    3. 对文件进行分割(每片256字节),分别打包传输

      • 发送前,通过协商,发送端告诉接收端发送片数

      • 报头为学号、姓名、本次分片在整个文件中的位置

      • 报尾为校验和:设要发送n字节,bi为第i个字,校验和s=(b0+b1+…+bn) mod 256

    4. 接收方进行合并

    5. 必须采用图形界面

      • 发送端可以选择文件,本次片数

      • 接收端显示总共的片数,目前已经接收到的文件片数,收完提示完全收到

  • 扩展功能:

    1. 客户端加入上传功能
    2. 支持多个客户端同时连接一个服务器
    3. 支持每个连接的客户端同时上传/下载多个文件

二、开发运行环境

  • 开环语言:python
  • 图形界面:PyQt5
  • 开发环境:vscode
  • 运行环境:windows

三、主要功能分析及界面设计

1. 功能分析

  1. 可以配置为服务器或客户端
  2. 服务器文件浏览、本地文件浏览
  3. 服务器文件选择下载、本地文件选择上传
  4. 具有一定的通信协议
  5. 显示文件传输进度,能提示传输结果(成功或失败)
  6. 支持多个客户端同时连接服务器
  7. 支持每个客户端同时传输多个文件

2. 界面设计

(1)设计思路

  • 尽量使用Qt designer图形化设计工具进行整体布局设计,然后再对生成的代码进行手动修改,从而最大限度减少工作量。
  • 对于有自定义需求的控件,应该通过继承原生控件实现,并在Qt designer设计中为留下放置原生控件的空间
  • 界面尽量简洁,但是也要有良好的用户提示(修改窗口状态栏、控件文本、控件使能状态等)
  • 检测到连接断开、下载失败等异常状态时,界面要有相应的变化
  • 界面要有良好的限制措施,禁止用户进行某些状态下不可进行的操作,禁止用户进行非法输入。
  • 无论任何情况下,界面控制不能卡死,故应当将UI控制放在一个单独的线程中实现

(2)主窗口

1. UI设计

项目Demo —— socket下载器_第1张图片

2. 说明

  • 通过 “连接配置” 按钮将软件配置为server或client
  • 只有在配置为client时,上传、下载按键才使能
  • 状态栏在不同状态下给出不同提示:
    • 没有配置时显示:连接未建立
    • 手动断开或异常断开时显示:连接断开
    • client尝试连接服务器时显示:connecting server
    • client连接成功时显示:当前连接数xx(这是client发起的client传输socket连接数目)
    • server启动后显示:正在监听port:xxxx,client连接xx(这是server收到的所有socket连接的数目,包括每个client的client UIclient心跳client传输三类连接)

(3)连接配置窗口

1. UI设计

  • 这是没有进行配置时的界面
    项目Demo —— socket下载器_第2张图片

  • 这是配置为server连接了一个client时的界面)
    项目Demo —— socket下载器_第3张图片

  • 这是配置为client并连接了server时的界面)
    项目Demo —— socket下载器_第4张图片

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设计

  • 下载状态的文件选择窗口
    项目Demo —— socket下载器_第5张图片
    项目Demo —— socket下载器_第6张图片

2. 说明

  • 不管上传还是下载,都会打开文件选择窗口
    • 上传模式,这里显示的是本机的文件和文件夹;最上面的长按钮用于快速选择本地目录,默认上传目录是本机桌面
    • 下载模式,这里显示的是服务器的文件和文件夹;最上面的长按钮用于快速选择保存路径,默认下面目录是服务器桌面
  • 提供两个快捷按钮,可以直接转到本机/服务器的C盘或桌面目录
  • 状态栏显示当前选择的路径
  • 可以直接点左下文件树右侧文件面板实现文件浏览
    • 点击两边的文件夹可以进入子目录
    • 点击文件树最上面的 可以返回上一级目录,当回退到磁盘根目录时会自动限制
    • 当鼠标滑过右边文件面板的时候,鼠标指向的图标会显示一个框,提示当前指向的文件
    • 经过实验,我发现python的文件目录查询函数会返回一些隐藏文件夹,其中有些是禁止访问的,这些文件夹也会显示在此文件选择窗口中,点击时会在状态栏提示没有权限
  • 只有选中了一个文件(左右都行),确认文件按钮才会使能,点击就会开始上传/下载

3. UI实现思路

  • 其的几个窗口都比较简单,直接使用pyqt提供的控件即可实现,但文件选择窗口就很麻烦了,因此专门再说一说

  • 先用Qt designer进行框架设计,放好按钮和部件,左下放一个QTreeWidget,右边放一个QScrollArea 占位置。别忘了整体套一个网格布局,以实现界面大小拖动的自动适配

  • 左下角的文件树继承自QTreeWidget控件,增加一些文件浏览的方法

    • 点击项目后,自动判断项目类型,如果是文件夹,就切换目录
    • 点击,目录回退
    • 在加载目录下文件树的时候,一开始想使用递归的方式把整个文件树全部加载,这样在下载模式下浏览子目录就不用等数据传输了,会快很多。但是测试发现,如果访问了太顶层的目录(如C盘根目录),它递归展开后文件数非常多,导致传输数据量巨大,加载时间过长。所以最后改成了分目录加载,每次切换到一个新目录就进行加载,这样虽然每次都要请求并传输文件列表,但是不会出现太长的等待
  • 右侧的文件面板继承自 QScrollArea 控件,这里想实现类似windows大图标浏览的效果

    • QScrollArea 控件自带了滚轮滑动的功能

    • 为了使面板上的内容可以点击,这个面板上的每个ICO图标都是我定义的ICO类对象,我先在QScrollArea 上放一个 “内部容器”widget,再在此widget上放置 Grid layout 网格布局,最后把ICO对象放到网格里。

      • ICO类的主要构成是一个pushButton(按钮改成文件ICO的图像)加一个用于显示文件名label
      • 当鼠标移动到按钮上时,此按钮切换图片为我ps过的一个带边框的图片,实现指示效果
      • 这里我用了一个工具,可以方便地直接获取本机各种文件的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设计

  1. 下载过程
    项目Demo —— socket下载器_第7张图片
  2. 上传过程
    项目Demo —— socket下载器_第8张图片
  3. 传输失败
    项目Demo —— socket下载器_第9张图片
  4. 多文件同时下载
    项目Demo —— socket下载器_第10张图片

2. 说明

  • 开始传输后,会弹出出一个传输窗口,当进度跑满后,完成按钮才使能
    • 文件名是正在传输的文件
    • 分片数是此文件传输过程中分片的个数
  • 点击使能的完成按钮,关闭此窗口
  • 当检测到连接中断时,进行提示,且自动弹出连接配置窗口(写到这里时,我才发现传输窗口的断开提示只做了下载模式的,如果是上传只能弹出连接配置窗口…不过先不改了吧)
  • 支持多个文件同时传输,可以同时下载多个,同时上传多个,一边上传一边下载也行,理论上数目无限
  • 多个客户端,每个都同时上传下载也没问题
  • 在下载任务的窗口状态栏显示了当前下载过程中的解码错误帧数目。因为水平有限,尽管对帧解码进行了比较复杂的处理,但依然不能保证数据帧传输100%正确,我不知道是网络的问题还是我解码算法的问题。虽然错误率很低,但是一旦出现一帧错误,就很可能导致收到的文件不能打开,只能重新下载。这里还有待优化
  • 上传任务在client是看不到帧错误数量的,虽然在server统计了,但我没有做回传client显示,这里也可以改一改
  • 还有一个要强调的,这里做进度显示的控件 QProgressBar 禁止在其他线程刷新,而我UI处理和文件传输是不再同一线程的,因此只能用信号的形式进行刷新。这里可以稍稍优化一下:每传输文件的1%大小就刷新一次,而不要每一帧都发送一个刷新信号。

四、架构、模块及接口设计

1. 程序组织如下

  • 程序组成
    项目Demo —— socket下载器_第11张图片
    • file:文件模块,提供了文件读写的方法
    • main:顶层模块
    • myIcoWidget:自定义ICO面板的相关方法
    • myTreeWidget:自定义文件树控件的相关方法
    • myThread:手动实现了一下子线程启动的重载
    • net_client:客户端的相关方法
    • net_server:服务器的相关方法
    • protocol:传输协议的设计
    • UI_download:下载窗口UI
    • UI_file:文件选择窗口UI
    • UI_main:主窗口UI
    • UI_option:连接配置窗口UI

2. 功能分析

  • 实现一个socket文件传输非常简单,client和server的代码都不超过50行,可以参考我的这篇文章:python 网络编程socket,其中第四节就是一个下载器demo

  • 这个课设的难度主要在于两点,一是多线程的实现,二是文件分片传输的通信协议。

  • 多线程分析:

    • 首先要保证UI不能卡,这是用户体验的核心,因此双方UI必须占一个线程(主线程)
    • 双方要能识别到连接断开,使用心跳检测机制实现(参考:Python3 Socket与Socket心跳机制简单实现),所以client要有一个心跳线程
    • 先考虑client - server一对一多文件传输,可以在简单下载器demo的基础上修改一下
      1. server端启动后要进行监听,为了避免UI卡顿,server要有一个监听线程
      2. client发起连接时,由于可能因网络问题导致连接不上,为了避免UI卡住,发起连接要放在子线程中,这个线程只负责建立UI和心跳的socket连接以及启动心跳子线程。所以client要有一个发起连接线程
      3. 此后就是文件传输了,所有文件传输都需在两边各开一个子线程,在这两个子线程上建立一个socket传递数据。
      4. 一旦心跳包超时,认定连接断开。client和server端直接关闭心跳和所有文件传输的子线程及socket连接,client端进行UI提示。
      5. 小结一下各个线程间的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中strint类型与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. 详细功能设计

  1. client工作流程

    • 在在连接配置窗口启动client后,立即创建一个连接子线程,在这个子线程上发起一条到server的socket连接。这期间,我们可以在主线程控制UI界面,不会出现因连接不上导致的程序卡死
    • socket连接建立后,client向server请求一些信息(比如服务器桌面路径),并会申请一个client ID,接收这些数据后,连接子线程关闭。此后,主线程通过连接子线程建立的这个socket和服务器传输信息(比如刷新UI的文件列表等)
    • 获取client ID后,客户端创建一个心跳子线程,在这个子线程上发起一条socket连接,注册为此client ID对应的心跳连接。
    • 此后,每当用户在客户端下载一个文件时,都会建立一个子线程,并在这个子线程上发起一个新socket连接。(同样,要先注册为client ID所属的下载连接)。同一时刻,可能有多条下载连接
  2. 服务器端

    • 在连接配置窗口启动server后,会创建一个监听子线程上,它不断循环检测服务器的socket,一旦检测到任何连接,就启动一个子线程,并在这个子线程上创建一个socket,用它来和发起连接的socket通信

    • 服务器端的主线程只负责ui交互,在没有任何client连接时,server端有两个线程,1个socket

    • 每接入一个客户端,服务器启动两个子线程,各自建立一条socket连接 (主连接、心跳连接)。主连接: 在client浏览server文件目录时发送文件和目录列表。心跳连接:client不断发送心跳包,报告自己仍在连接状态,超时时间为10秒,超时后这个client的所有连接将被关闭。这是为了避免客户端的意外断开

    • 此后,每当client端发起一个下载请求,就新建一条连接。同样要先发来client ID,明确其所属关系。在下载文件发送完毕后,client发来关闭命令,结束这个连接

    • client关闭或断开后,server端利用ID清除其所有连接

  3. 通信细节

    • 题目中要求通信前双方必须协商分片数,为此我设计了如下的通信过程

    • 服务器端准备把一些数据(一个被下载文件/目录中文件列表/…)送给客户端

      • 服务器端统计待发送数据大小,根据分片大小(每片256字节)计算本次通信的分片数

      • 服务器端向客户端发送此次通信分片数

      • 客户端收到后,发送应答,其中包含分片数信息

      • 服务器收到应答,比对分片数,一致的话则开始发送数据

    • 实验发现,socket有个缓存,如果数据接收快,消耗慢,会存入这个缓存中。因此用socket.recv(max_size)方法接收时,可能会接收多个粘连的帧

      • 可以限制max_size=256,强制一次从缓存取一个最大帧长解码,这能解决部分粘包问题,但仍不能解决帧截断和截断后粘连的问题。故需编写一个 “帧处理方法”
      • 也可以一次从缓存取更多数据,解码时手动分为256字节一组送入 “帧处理方法”
  4. 关于文件选择窗口的刷新

    • 上传模式

      • 上传模式只需在本地进行处理,比较简单。有多种库方法可以直接获取目录中文件的列表,也可以很简单地判断某个路径是文件和文件夹,因此不赘述
    • 下载模式

      • 下载模式比较麻烦,我的处理流程如下
      • 客户端第一次连接服务器时,服务器返回其桌面路径,这样客户端就可以通过修改此路径来得到服务器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)我的学习记录

  • python 网络编程socket
  • python 多线程threading模块
  • 字符串和列表的转换
  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

(2)参考

  • 以下是我在编写这个程序时参考的博客内容和一些笔记

  • pyqt5实现按钮添加背景图片以及背景图片的切换方法

  • pyqt删除控件

  • pyqt5设置按钮透明度

  • pyqt5 QscrollArea(滚动条)的使用

  • pyqt5 label文本对齐设置

  • python3 bytes拼接

  • python 读取大文件,按照字节读取

  • pyqt5 QLineEdit用正则限制

  • python 中 socket 的超时

  • python程序打包太大

你可能感兴趣的:(#,我的Demo,Demo,python,socket,文件传输)