• 这里介绍一下我计算机网络课的课程设计,这篇文章是由当时的项目报告扩充而来。
  • 本项目实现了一个基于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设计

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

  • 这是没有进行配置时的界面

  • 这是配置为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类还存储了 “文件路径” 等成员变量,并提供了点击事件,以实现目录的切换
    • 因为水平有限,没能实现这种设计下的窗口拖动适配,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设计

  1. 下载过程
  2. 上传过程
  3. 传输失败
  4. 多文件同时下载

2. 说明

  • 开始传输后,会弹出出一个传输窗口,当进度跑满后,完成按钮才使能

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

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

1. 程序组织如下

  • 程序组成

    • 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 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下载器相关推荐

  1. 【实战项目】---P2P下载器

    P2P下载器 1.引言 2.项目简介 3.整体框架 4.服务端设计 5.客户端设计 6.主要功能端口 7.httplib的处理流程: 8.源码 1.引言 在校期间经常需要进行给学委,班长拷贝文件.互传 ...

  2. 【Linux项目】 --P2P下载器的详细介绍

    P2P下载器 一.P2P下载器功能简介 二.客户端功能细分 1.获取在线主机 1.1 获取网卡信息,得到局域网中的所有IP地址列表 1.2 逐个对IP地址列表的主机发送配对请求 1.3 配对得到响应, ...

  3. 我的第六个项目:实现一个任意图片下载器

    点击上方蓝色字体,关注程序员zhenguo 你好,我是 zhenguo 这是我的第498篇原创 这是第六个Python小项目,做一个图片下载器. 之前项目: 我的第五个项目:实现一个文本定位器 我的第 ...

  4. 用python爬虫制作图片下载器(超有趣!)

    这几天小菌给大家分享的大部分都是关于大数据,linux方面的"干货".有粉丝私聊小菌,希望能分享一些有趣的爬虫小程序.O(∩_∩)O哈哈,是时候露一手了.今天给大家分享的是一个适合 ...

  5. MTK Http Socket GPRS以及解析器和下载器概述

    MTK App网络应用介绍 MTK App网络开发步骤 GPRS网络注册模块 Socket联网API接口 HTTP功能部件 使用HTTP封装解析器和下载器 Parser Downloader 调试功能 ...

  6. P2P下载器(Linux下C++项目实战)

    P2P下载器:即点对点下载器,服务端与客户端.服务端共享文件列表,客户端配对相应服务端,下载所需要的文件. 一.项目介绍 1.项目功能 搜索附近(局域网内)在线用户, 此处不足(只能在局域网内获取,需 ...

  7. 【Python项目】Python基于tkinter实现笔趣阁小说下载器(附源码)

    前言 hello,大家好呀~ 笔趣阁小说应该很多小伙伴都知道 但是用Python实现一个笔趣阁小说下载器 那岂不是爽歪歪呀 基于tkinter实现的Python版本的笔趣阁小说下载器今天小编给大家实现 ...

  8. 基于iOS 10、realm封装的下载器

    代码地址如下: http://www.demodashi.com/demo/11653.html 概要 在决定自己封装一个下载器前,我本以为没有那么复杂,可在实际开发过程中困难重重,再加上iOS10和 ...

  9. 基于iOS 10封装的下载器(支持存储读取、断点续传、后台下载、杀死APP重启后的断点续传等功能)

    原文 资源来自:http://www.cocoachina.com/ios/20170316/18901.html 概要 在决定自己封装一个下载器前,我本以为没有那么复杂,可在实际开发过程中困难重重, ...

最新文章

  1. POJ 3169 差分约束
  2. CSS层叠样式表进阶
  3. mq同步mysql数据 duplicate entry_MySQL数据同步之otter
  4. OpenCV支持向量机SVM简介
  5. 8张图告诉你如何运营微信公众号
  6. linux 下查看应用版本信息,Linux下查看版本信息
  7. docker配置 nacos_Nacos - 阿里开源配置中心
  8. 微信个人名片H5生成器
  9. 品致高频电流探头的主要特点和连接示波器
  10. 碎片化知识管理工具Memos
  11. java 写服务器向客户端发送消息,java服务器向客户端发送消息
  12. Verilog加法器设计
  13. 转换接头PL8000V-B 0-70MPa
  14. 深入理解WKWebView白屏
  15. 5 年京东后端研发程序员,从开始的3k到现在的36k,我终于熬出头
  16. SAP MM批次管理(2)批次主数据--大海
  17. php 屏蔽搜索机器人,php实现判断访问来路是否为搜索引擎机器人的方法
  18. IRIS平台部署手册及基本操作
  19. xxl-job优雅停止执行器即客户端tomcat
  20. MC7805多路稳压模块7V12V30V降压模块5V输出3.3V输出

热门文章

  1. Java(老白再次入门) - 语言概述
  2. 读aroundall的回复有感
  3. Selenium自动化测试网页加载太慢怎么办
  4. 读书笔记 - 《天局》
  5. 【Caffeine入门】浅谈缓存框架Caffeine
  6. hmc查看服务器信息,IBM HMC 10个常用的操作
  7. python 中的 chr ord和repr
  8. SQLyog 自动完成
  9. 程序员眼中的中国传统文化-王阳明《传习录》5
  10. 下列哪项不是python中对文件的读取操作-Python—文件读写操作