五周的课程设计课结束了。后两周我和同学一起基于 UDP 使用 C++ 语言实现了一个文件传输工具。这篇文章记述了项目相关的一些细节, 但不是一篇正式的软件工程文档。项目地址见 Github。
项目背景和需求分析
去年暑假时,有次我想把约 800GB 的硬盘备份文件通过网络传输给同学。 我们觉得自己算是“半个专业人士”,便不考虑快递硬盘的方式传送,想了很多办法。因为我所在的地方有公网地址,方案大体上是我在本地建立一个服务器,使用一般的文件传输工具传输。由于是跨省跨运营商,存在以下问题:
- 网络速度波动
- 每 48 小时动态公网 IP 出现 IP 变化,传输中断
- 丢包自动重传
我们需要以下特性:
- 将带宽跑满
- 支持断点续传
- 尽可能顺序读写,减少磁头移动
如果可能,还希望支持:
- 支持 UDP 减少传输量(虽然和 TCP 相似)
- 传输加密
- 支持差错校验和分块重传
否决掉 QQ 等工具,也排除 Windows 文件共享,最后选择使用 HTTP 协议(不记得为什么没使用 FTP 了)。后来才知道,类似需求可以使用 Linux 下常用的 scp
和 rsync
工具,scp
不支持但 rsync
支持断点续传。
我在本地架设一个 IIS,开放公网端口让客户端下载,带宽约能跑满,但在约两天的传输后,接收方(同学)文件校验失败。 我们总结了上述特性,尽管目前主流的 HTTP、FTP、SMB 等文件传输协议支持广、功能强大,但仍不支持上述的全部特性。我们想自己实现一个文件传输工具,我们希望它能支持断点续传、加密传输、校验等特性,如果不能做到创新与创造,那就当造个轮子吧!
趁这次课程设计的机会,实现一个简单版本的传输工具吧。项目标题就定为:基于 UDP 协议的文件传输工具。
概要设计
运行环境
由于项目使用了 Windows Socket 技术,当前仅支持 Windows XP 及以后的 Windows 操作系统。
编译器要求:支持 C++ 11 及以上。
系统结构
项目最终产品包括两个程序:发送端(Client)和接收端(Server)。每个程序均包含网络通信、界面控制、文件读写三大模块。
-
网络通信
封装网络请求,为控制层提供平台和协议无关的消息传输,简化程序开发、同时为支持跨平台打下基础。
如果可能,我们想动手实现 TCP 的差错校验和重传等,同时实现一个滑动窗口(Sliding window)。考虑到网络是耗时最长的因素,且 C++ 下异步实现复杂(看了下
asio
,决定以后再研究),因此使用同步方法实现。 -
输入和输出控制
输入主要是通过解析命令行参数控制程序流程、行为。
输出主要是更友好地显示文件传输过程,输出有好的错误提示和程序执行进度。
-
文件读写
封装文件操作,程序实现磁盘缓存,减少磁盘读写。
局限
根据需求,此项目不使用图形用户界面,相反,它基于命令行进行操作。项目的重点在数据结构、算法、计算机组成、计算机网络与程序设计上。
输入输出
服务端启动时,需要指定服务端监听的地址、端口号。如果服务端开启了自动保存,还需要指定自动保存文件的路径。
客户端启动时,也需要指定服务端监听的地址、端口号用于“连接”,同时客户端还需要指定客户端名称以及发往服务端文件的路径。
客户端和服务端交互时,输出友好的提示并展示任务进度。此外,若服务器未开启自动保存,应支持显示用户确认信息。
传输流程
客户端与服务端传输流程如下:
- 需要文件传输时,首先启动服务端(Server),服务端进入监听状态。随后,运行客户端。
- 客户端(Client)发送文件传送请求,包含客户端名称、文件名、文件大小等信息。
- 服务端响应接收或拒绝。
- 开始传输。
在当前版本的程序中,缺少 Server 端对 Client 端发送文件进行校验和重传的机制。
详细设计
说实话,在详细设计阶段,应该包含对类设计、类间关系的描述。但由于项目经验少,在项目编写中很难将接口写得“舒适”、简洁,或将模块层次划分得很细致。因此,实际情况往往是先编写代码然后再画图。使用 Jetbrains 有关工具甚至可以自动生成类图。
类设计
Protocol
Protocol 模块包括五个类。Frame
规范了每个数据包的字段格式,并支持序列化和反序列化。注意,每一个数据包还包括 crc32
字段,但这对上层是透明的,因此在结构中隐藏了。包的序列化和反序列化时会自动进行加校验头和验证校验操作。
UdpSocket
网络模块仅有 UdpSocket 一个类,他可以为程序提供一个平台无关的网络接口。类设计如下:
Buffer
文件读写模块有 ReadBuffer
和 WriteBuffer
两个类。
Controller
控制层有 AntClient
和 AntServer
两个类。由于类间关系复杂,这里简化了一下。
测试
项目里我们只基于 Google Test 做了一些单元测试,详情见代码,这里不多说。
一点感想
项目从6月23日初始化,到昨天(7月1日)答辩过去一周多,我思考最多的是如何写出可复用、可扩展、高质量的代码——这也正是软件工程所研究的。如,在编写 UdpSocket
类的时候,我参考了 Qt5 Network 模块的实现,定义了一个新的结构 UdpSocket
,从而简化代码开发。
在底层模块,尤其是序列化和反序列化代码编写时,我对函数基本编写了单元测试——这真是一个费力的活。在“软件测试”课程中我们学过对函数画出基本路径进行路径测试、构造边界值测试等等,在实际情况中这显得非常复杂:编写软件容易,保证软件质量不易。
在我写过的 bug 中,有这样一段代码:
1 | void push_u16(std::vector<uint8_t> &output_buffer, const uint16_t x) { |
这段代码是为序列化服务的,目的是将一个 u16
整数附加到 vector<uint8_t>
后面。然而该部分代码对应的测试用例并没有找出代码中的问题:第三行的 127
应该改为 255
(即右8位二进制位全为1),而是因为另一个测试用例才检查出的问题,但却耗费了我很多的时间出定位错误。这也给我一个深刻的体会:测试不能发现所有的错误,但好的测试用例能找到尽可能多的问题。
答辩的时候自豪地对老师展示我的项目,因为我们的项目是近期新做的,没有用其他课程所写的代码,老师的评价还不错。下来后做了个测试,单机传输 400M 的文件居然只能达到不到 1MB/s, CPU 占用在 5%~10%,同时还伴有内存泄漏的情况,着实给我和另一名同学泼了一盆冷水。在代码编写中不熟悉 C++11 的右值引用、所有权和存储管理导致函数调用时出现了大量拷贝,现在看来应该与此有一定关系。做项目如同做题,在这过程中能不断发现自己的问题、激发自己对计算机程序设计的兴趣,后续再优化这个问题吧。
我也因此,更加喜欢 Rust 了。原来不用手动管理指针、内存多么幸福!内存是个难关,值得深入研究,体会工程上的各种解决方案。
以上。
写于 2021 年 7 月 2 日。