rFTP - 用 Rust 实现简单的 FTP Server (1)

rFTP - 用 Rust 实现简单的 FTP Server (1)

开发动机

核心动力是我有学会一门系统级编程语言的梦想。所以计划用 Rust 为开发语言(手段)完成本科三年级计算机网络专业课上的 FTP 大作业(目标),学习 Rust 的同时巩固计网的基础知识。

虽然大一刚入学就开始接触 C/C++,但是对于当时没有任计算机知识何积累的我来说用这样的方式开始我的编程入门实在是颇为残忍。或许我当时连内存大小和磁盘容量都分不清,不知堆栈为何物,也搞不懂什么编译链接,让我去理解指针实在是有点为难。现在回过头来看,当时的教学顺序对零基础的学生来说是不太友好的:老师在讲指针结构的内存优化时我甚至还写不出像样的符合语法的程序,课程内容就自然也就无法很好地消化了。

如果由我来制定教学计划,我一定将最开始的编程入门课定为使用 Python 教学而不是 C/C++,在知道如何写出鲁棒、高效、优雅的代码前,先要做到能写代码,就好似学会跑步之前需要先学会走路;等学生们了解了计算机组成原理、操作系统等计算机基础知识之后(或同时),再教授 C/C++ 等较低层的、系统的编程语言了。话扯太远了,就此打住。

因为当时的无知无能,我在大学前两年的专业课学习中并没有把基础打牢,现在希望可以通过恶补计算机知识来抢救一下,而与此同时我也希望选择一门系统级语言来作为接触计算机底层的抓手,我对 C/C++ 有些 PTSD,Golang/Java 又太面向应用构建了,所以选择了社区里比较火的 Rust 来入门,可能本质上这也是一种跟风吧。

Rust 基础

Rust 的所有权生命周期机制是它很重要的亮点,同时也是难以上手的概念;Rust 拥有强大的枚举值机制,配合上全面的模式匹配,使得程序控制流和错误处理变得灵活优雅,Golang 没有枚举类型的情况有时显得捉襟见肘;另外和 C++ 不同,Rust 没有继承的概念,而是和 Golang 类似采取“组合大于继承”“组合而非继承”的方式来达到面向对象的目标,以取得更高的编码灵活度

所有权

Rust 所有权三条基本规则:

  1. 每个值都有一个所有者;
  2. 同一时间只允许存在一个所有者;
  3. 当所有者离开作用域,值会被抛弃(drop)。

离开作用域之后,变量会被释放掉(drop);直接赋值会导致右值的所有权被剥夺;如果只希望借用变量的值,可以使用 & 符号进行借用,如 &var 表示变量的只读借用,而 &mut var 表示变量的可写借用,同一个作用域中只允许存在一个可写借用;可写借用创建出来且仍在存活时,后续再创建的只读借用就会失效(或者编译器编译失败)。

存放在栈上的基本类型可以通过 Copy 特征自动的进行复制,而不是转移所有权。Rust 不允许在实现了 Drop 特征的类型上标注 Copy 特征,编译器会提示编译错误。也就是说 Copy 特征只复制栈上的数据

除了完全存放于栈上的基本数据类型以外,大部分类型都是主体数据存放于堆上,索引(或者叫指针)在栈上,此时通过 Copy 只会实现指针的复制,堆上的主体数据不会有变化。这时就要借助 Clone 特征来实现堆栈上数据的完全克隆。

利用切片机制可以引用一个连续元素集合当中的部分内容,需要注意的是切片是引用,并没有值的所有权。特别地,String 类型的切片类型写作 &str

生命周期

Rust 生命周期基本三条准则:

  1. Rust 编译器会给函数所有的输入变量赋予一个生命周期
  2. 如果只有一个输入变量,Rust 编译器会给输出的值赋予这个输入变量的生命周期
  3. 如果函数的参数当中有 &self 或者 &mut self,说明这个函数是个方法,此时会给所有的输出值赋予 &self 相同生命周期,以方便编写方法函数,因为这样可以少写很多生命周期符号

结构体与特征

在 Rust 中没有 class 关键字,而是不约而同地和 Golang 一样选用了 struct 结构体作为数据整合与面向对象的主要载体。通过 struct typeName {} 代码块可以定义一个结构体,通过 impl typeName {} 代码块可以为该结构体定义和实现内部的方法(methods)。在方法中定义第一个参数为 &self 表示只读引用实例,如果定义为 &mut self 则表示可编辑地引用实例,如果没有传入 self 参数则表示为类方法,需要用 typeName::methodName 的形式调用。

为了将多个结构体之间可能存在的共有方法进行抽象,Rust 提供了特征(trait)机制。Rust 的 trait 与 Java/Golang 当中的 interface 类似,结构体通过实现特征,在某些函数中能够当作该特征的实例变量使用,为结构体实现特征的代码片段为 **impl traitName for typeName {}**。值得注意的是,在定义 trait 时可以给方法提供默认实现,结构体需要把特征当中的不包含默认实现的方法全都实现才能算作实现了该特征,不能部分实现。

在入参和返回值类型中标记实现某个特征的语法如下,配合泛型一起食用口感更佳:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 使用 impl 关键字说明特征
pub fn notify(item: &impl Summary) {
}
// 使用泛型约束
pub fn notify<T: Summary>(item: &T) {
}
// 多个特征的约束
pub fn notify(item: &(impl Summary + Display)) {
}
// 使用 where 关键字说明类型特征
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{}
// 泛型结构体针对特定特征的实现
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
// some implementations
}

枚举类型及模式匹配

Rust 有很强的枚举类型机制,感觉是另一类的结构体,配合 match, if let 等关键字可以优雅地实现控制流和错误处理。定义枚举类型的方式和定义结构体几乎完全一致,使用枚举可以使得相同用途的类型在逻辑上和空间上两个维度上更加紧凑。从各种角度来看枚举和结构体都非常相似,我们甚至可以给枚举类型实现方法。

模式匹配主要依赖 matchif let 两个关键字。其中 match 和其他语言中的 switch/case 语法是类似的,只不过 Rust 要求 match 必须穷尽所有的枚举情况,即强制写出 default 分支。这也合理,以编译严格著称的 Rust 不允许潜在的不可达状态。

1
2
3
4
5
6
7
8
9
10
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(), // 抛弃默认值
}
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
other => move_player(other), // 使用默认值
}

if let 是单分支、更精准的 match,如果匹配的枚举值符合预期就会进入到相应的语句块里执行相应的语句,其代码块为 **if let Some(var) = some_option {}**。

rFTP 开发

开发计划

因为自己还有课题组的开发任务,业余时间也不多,所以 rFTP 的开发计划也比较宽松随性,突出一个“重在参与”。计划迭代两期,其中第一期包括基本的 FTP 指令如:

  • USER/PASS,指定用户名和密码登录
  • PORT/PASV,主动和被动模式的数据端口指定
  • RETR/STOR,存取文件
  • ABOR/QUIT,断开连接
  • SYST/TYPE,获取服务器信息
  • RNFR/RNTO,文件重命名
  • PWD/CWD/MKD/RMD,切换当前会话的所在目录
  • LIST,列出当前目录文件列表

迭代的第二期计划加入相对高级的指令:

  • REST,断点续传
  • DELE,删除文件
  • STOU,唯一存储
  • APPE,追加写
  • ALLO,预留存储空间

I/O 多路复用

本科期间用 C 写 FTP Server 时跟风使用 epoll 来达到 I/O 多路复用的效果,当时对于 I/O 多路复用处于完全不明白的状态。现在对其一知半解,也打算在 rFTP 中引入这样的机制。因为是在 macOS 上开发,尝试引入 epoll 后代码无法正常编译运行,查阅相关资料才发现 macOS 并不支持 epoll,而是单独开发维护一个文件系统事件库叫 kqueue 来实现类似的功能。

为了 rFTP 的可移植性,同时也为了省心,我自然地选择引入了 Rust 的异步运行时 Tokio 来达到 I/O 多路复用、异步编程、多线程的实现目标。目前对 Tokio 的认识还停留在上手阶段,没有做深入了解和 Benchmark,不太敢说其性能如何如何,以下是 Tokio 文档中的介绍,看起来让人安心。

A runtime for writing reliable network applications without compromising speed.
Tokio is an event-driven, non-blocking I/O platform for writing asynchronous applications with the Rust programming language. At a high level, it provides a few major components:

  • Tools for working with asynchronous tasks, including synchronization primitives and channels and timeouts, sleeps, and intervals.
  • APIs for performing asynchronous I/O, including TCP and UDP sockets, filesystem operations, and process and signal management.
  • A runtime for executing asynchronous code, including a task scheduler, an I/O driver backed by the operating system’s event queue (epoll, kqueue, IOCP, etc…), and a high performance timer.

一个疑难问题

Rust 如何实现 TcpStream 在生产者、消费者不同的作用域之间的传输?

预期状态:服务器 listen 进入 accept loop 之后,每次接收到一个 socket,我希望可以通过 mpsc::channel 将这个 socketaddr 信息传输到另外一个事件循环中进行处理,但在把 socket 传到 Task 当中时,客户端出现断连的现象,暂时还没发解决。大致的代码如下:

main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
fn main() {
let (tx, rx) = mpsc::channel(N);
for i in 0..N {
tokio::spawn(async move {
loop {
let task = rx.recv();
let mut socket = task.socket;
let mut buf = vec![0u8; 1024];
loop {
let n = socket.read(&buf);
if n == 0 {
break;
}
// do something with buf and respond to peer socket
}
}
});
}

loop {
let (socket, addr) = listener.accept();
tx.send(Task{
socket,
// socket closed here
// Client meet error: Connection reset by peer
addr,
});
}
}

疑难问题的权宜解决方法

经过一两天的思考,觉得跨作用域传递 socket 变量是不好的实践方式,于是将上述代码改为了下面的样子:直接在服务器监听方法当中对来到的 socket 进行读取和相应处理,不再跨作用域转移变量。

main.rs
1
2
3
4
5
6
7
8
9
10
11
fn main() {
loop {
let (mut socket, addr) = listener.accept();
tokio::spawn(async move {
loop {
// do something with socket and addr
}
})
}
}

参考资料

rFTP - 用 Rust 实现简单的 FTP Server (1)

https://powerfooi.github.io/2023/01/20/RFTP-1/

作者

PowerfooI

发布于

2023-01-20

更新于

2024-08-04

许可协议

评论