使用 epoll 开发一个文件传输的服务器和客户端
要求:
客户端申请指定的文件名,服务器返回对应的文件数据,客户端将收到的数据写入本地文件
异常情况处理:无指定文件则返回“not file”字符串
完成基础的文件传输
断点续传:见 FileHandler 类的
appendAndVerifyFile
和saveAndVerifyFile
方法,Client 类的transferFile
和resumeTransfer
方法文件校验:见 FileHandler 类的
calculateMD5
方法
目录结构#
.
├── README.md
├── assets
│ └── ...
├── include
│ ├── file_handler.h
│ ├── logger.h
│ └── md5.hpp
├── src
│ ├── client.cpp
│ └── server.cpp
└── xmake.lua
编译和运行#
使用 xmake
来编译项目:
xmake
运行服务器:
xmake run server
运行客户端:
xmake run client
其中,客户端需要携带服务器 IP 的参数,已经写在 xmake.lua
中,默认为 127.0.0.1
。由于我使用的是 macOS 系统,它缺少 epoll
,所以我使用了 #ifdef
来区分不同的操作系统,确保代码的可移植性。起初我是分不同的文件编写不同平台的代码,但后来发现只需要简单地使用 #ifdef
来区分即可。
所以在 xmake.lua
中,编译选项如下:
target("server")
set_kind("binary")
add_files("src/server.cpp")
set_languages("c++17")
set_rundir("$(projectdir)")
add_includedirs("include")
target("client")
set_kind("binary")
add_files("src/client.cpp")
set_languages("c++17")
set_runargs("127.0.0.1")
add_includedirs("include")
运行截图#
epoll 和 select/poll 的区别#
epoll
是 Linux 内核提供的一种高效的 I/O 事件通知机制,相较于 select
和 poll
,它有以下优点:
- 使用事件驱动模型
select
和poll
需要每次调用时都传递整个文件描述符集合,效率较低。epoll
使用事件通知机制,只需关注发生变化的文件描述符,在内核中维护了一个事件表,应用程序只需关注感兴趣的事件,提高了性能。
- 支持大规模文件描述符
select
限制了文件描述符的数量(通常为 1024),而poll
和epoll
没有这个限制。- 使用红黑树实现,
epoll
可以高效地管理大量文件描述符。
- 较低的内存消耗
epoll
内部使用内存映射文件(mmap)来减少内存拷贝,提高性能。
在 macOS 和 BSD 系统中,epoll
不可用,通常使用 kqueue
作为替代。
实现#
实现上参考了 aceld/libevent,但最终效果和它的代码实现是不一样的,也借助了一些 AI 的力量,最终实现了这个文件传输的服务端和客户端。有点坎坷的,但还是收获颇丰。
服务端#
服务端的核心是 NetworkServer 类,UML 类图如下:
进入主函数后,服务端会初始化一个 NetworkServer 对象,并调用其 run
方法开始监听客户端的连接请求。初始化过程包括创建 socket、绑定地址、监听端口等操作。
run
方法是一个死循环,不断地调用 epoll_wait
来等待事件的发生。
当有新的客户端连接时,服务端使用 handleNewConnection
方法会接受连接并将其添加到 epoll 监听列表中。当有数据可读时,服务端会使用 handleClientRead
方法读取数据并进行相应的处理。
handleNewConnection
方法的实现和 socket-sample 中的类似,主要是使用 accept
接受新的连接,并将其添加到 epoll 监听列表中。handleClientRead
方法会读取客户端发送的文件名,并根据文件名查找对应的文件。如果文件存在,服务端会将文件内容发送给客户端,其余错误处理由 FileHandler 类来完成。
FileHandler 类#
FileHandler 类其实只是把一系列文件操作的函数进行封装,提供了一个接口。
calculateMD5
:计算文件的 MD5 值使用的头文件来自 zaphoyd/websocketpp,它修改自 苹果的 md5.c 实现。
getFileSize
:获取文件大小sendFile
:发送文件内容主要的执行流程如下图所示:
如果文件不存在,或者文件无法打开,或者文件读取失败,都会返回相应的错误信息,比如
not file
。createSafeFilename
:创建安全的文件名,主要是因为带有路径/
的文件名并不一定能被正确处理,所以需要将其转换为安全的文件名。appendAndVerifyFile
:断点续传的实现,主要是通过在文件末尾追加数据来实现的,同时会校验文件的 MD5 值是否一致。saveAndVerifyFile
:保存文件并校验 MD5 值,主要用于第一次传输文件时使用。这两个方法其实很像,只是
appendAndVerifyFile
是在文件末尾追加数据,而saveAndVerifyFile
是直接覆盖文件内容。流程如下图所示:
客户端#
客户端的核心是 FileTransferClient 类和 NetworkClient 类,UML 类图如下:
至于为什么要分成两个类,主要是因为客户端的功能相对简单,FileTransferClient
只需要处理文件传输相关的逻辑,而 NetworkClient
只需要处理网络相关的逻辑即可。
进入主函数后,客户端会初始化一个 FileTransferClient
对象,并调用其 connect
方法开始与服务端进行通信。连接的方法同样是使用 socket
、connect
等操作,和服务端类似。
连接成功后,客户端可以不断地向服务端发送文件名,请求文件数据。客户端会使用 transferFile
或者 resumeTransfer
方法来请求文件传输。transferFile
方法用于第一次传输文件,而 resumeTransfer
方法用于断点续传,它会检查已经存在的文件,然后给 transferFile
方法传递一个 fileSize
参数,表示文件的大小即其偏移量,再交由 transferFile
方法来处理。
流程如下图所示:
在这里面调用了 NetworkClient
的 receiveFileData
方法来接收文件数据,并将其写入本地文件中。文件每次读取 1024 字节,直到文件传输完成。
文件接收后,交由 FileHandler 类来进行 MD5 校验,确保文件传输的正确性。
Logger#
实际上也只是定义了 LogLevel
枚举和一个简单的日志输出函数,方便在调试过程中查看日志信息。