Rust-编程示例-全-

Rust 编程示例(全)

原文:annas-archive.org/md5/9e80f00e099c85274a07170cf7996855

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的目标是简要介绍一些 Rust 的基础(与 GUI 玩耍)和高级(异步编程)功能。因为有趣的项目总是语言学习过程中的巨大加分项,所以我们以这个焦点编写了这本书。我们认为这门语言很棒,并希望给你提供动力和知识,以便未来能有更多的 Rustaceans!

本书面向的对象

如果读者想最大限度地享受本书,他们只需要对 Rust 语言有基本了解,尽管建议始终打开文档以回答本书可能未提供的问题(我们,作者,并非无所不能,这很遗憾,我们知道)。对于完全不了解 Rust 的读者,我们建议他们首先阅读这里可以找到的 Rust 书籍doc.rust-lang.org/stable/book/,然后再回来阅读这本书!

本书涵盖的内容

第一章, Rust 基础,涵盖 Rust 的安装并教授语言的语法和基本原理,以便你准备好使用它进行项目编码。

第二章, 从 SDL 开始,展示了如何开始使用 SDL 及其主要功能,如事件和绘图。一旦项目创建,我们将创建一个显示图像的窗口。

第三章, 事件和基本游戏机制,深入讲解如何处理事件。我们将编写 tetrimino 对象,并使它们根据接收的事件进行变化。

第四章, 添加所有游戏机制,完成游戏机制。在本章结束时,我们将拥有一个完全运行的俄罗斯方块游戏。

第五章, 创建音乐播放器,帮助你开始构建图形化音乐播放器。本章将仅涵盖用户界面。

第六章, 实现音乐播放器的引擎,将音乐播放器引擎添加到图形应用程序中。

第七章, 使用 Relm 以更 Rust 的方式实现音乐播放器,改进音乐播放器以添加播放功能,允许处理列表中的音乐以去除人声。

第八章, 理解 FTP,通过实现同步 FTP 服务器来介绍 FTP 协议,为你在下一章编写异步版本做准备。

第九章, 实现异步 FTP 服务器,使用 Tokio 实现了 FTP 协议。

第十章,实现异步文件传输,实现了 FTP 服务本身。这是应用程序能够上传和下载文件的地方。

附录,Rust 最佳实践,展示了如何编写优秀的 Rust API 以及如何使它们尽可能容易和愉快地使用。

要充分利用本书

您需要的并不多。此外,Rust 在所有操作系统上都得到了很好的支持。在这里,Linux 是支持最好的操作系统。您也可以在 Windows 和 macOS 上使用 Rust,您需要一个相当新的计算机;对于本书的目的,1GB 的 RAM 应该足够了。

下载示例代码文件

您可以从 www.packtpub.com 的账户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com 登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

下载完文件后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • 适用于 Windows 的 WinRAR/7-Zip

  • 适用于 Mac 的 Zipeg/iZip/UnRarX

  • 适用于 Linux 的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Rust-Programming-By-Example。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/RustProgrammingByExample_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”

代码块设置如下:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都按以下方式编写:

$ mkdir css
$ cd css

粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要提示看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 请通过 feedback@packtpub.com 发送电子邮件,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送电子邮件给我们。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式发现了我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 联系我们,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问 packtpub.com

第一章:Rust 基础

本章向您介绍了 Rust 的基础知识,这是一种旨在安全且快速的系统编程语言。Rust 是编写并发软件的好选择,并有助于防止错误。阅读本章后,你将准备好在后续章节中编写酷炫的项目。在了解语言本身之后,你将安装其编译器和包管理器,并立即开始编程。你还将学习以下概念:

  • 变量

  • 内置数据类型

  • 控制流(条件和循环)

  • 函数

  • 自定义数据类型

  • 引用

  • 模式匹配

  • 特性和泛型

  • 数组和切片

了解 Rust

Rust 是由 Mozilla 开发的一种系统编程语言,其 1.0 版本于 2015 年发布。系统语言意味着你可以控制程序使用的内存——你可以决定是否在栈上或堆上分配内存,以及何时释放内存。但不用担心;在 Rust 中,编译器非常有帮助,可以防止你犯在 C 和 C++ 中容易犯的错误,这些错误会导致段错误。当程序员尝试访问其进程无法访问的内存时,就会发生段错误。内存不安全会导致错误和安全漏洞。

此外,编译器足够智能,知道在哪里插入内存释放指令,这样你就不需要手动释放内存,所有这些都不需要垃圾回收器,这是其最伟大的特性之一。由于 Rust 安全且快速,它是编写操作系统、嵌入式程序、服务器和游戏的理想选择,但你也可以用它来开发桌面应用程序和网站。这个强大功能的绝佳例子是 Servo 网络引擎,它也是由 Mozilla 开发的。

Rust 是多范式的:它可以以命令式或函数式的方式使用,你甚至可以安全地编写并发应用程序。它是静态类型的,这意味着每个类型必须在编译时已知,但由于它使用类型推断,我们可以省略大多数局部变量的类型。它也是强类型的,这意味着其类型系统可以防止程序员犯某些类型的错误,例如为函数参数使用错误类型。而且 Rust 在编写并发软件方面非常出色,因为它可以防止数据竞争,这是对变量的并发访问,其中一个是在写入;这在其他语言中是未定义的行为。在阅读这本书时,要记住的一件事是 Rust 可以防止你自作自受。例如,Rust 没有如下内容:

  • 空指针

  • 数据竞争

  • use after free

  • use before initialization

  • goto

  • 自动转换布尔值、数字和枚举

此外,Rust 还有助于防止内存泄漏。然而,所有这些都可以通过 unsafe 代码实现,这在第三章事件和基本游戏机制中有解释。

不再拖延,让我们安装本书中需要的工具。

安装 Rust

在本节中,我们将安装rustup,它允许我们安装不同版本的编译器和包管理器。

Windows

前往rustup.rs,按照说明下载rustup-init.exe,然后运行它。

Linux/Mac

如果你的发行版没有提供rustup的包,你需要在终端中键入以下命令来安装rustup

$ curl https://sh.rustup.rs -sSf | sh
info: downloading installer

Welcome to Rust!

[...]

Current installation options:

   default host triple: x86_64-unknown-linux-gnu
     default toolchain: stable
  modify PATH variable: yes

1) Proceed with installation (default)
2) Customize installation
3) Cancel installation

这将下载rustup并询问你是否想要自定义安装。除非你有特殊需求,否则默认设置就足够了。

注意:$代表你的 shell 提示符,不应键入;你必须键入它后面的文本。另外,不以$开头的文本行代表程序的文本输出。

要继续安装,请输入1并按Enter键。这将安装rustc编译器和cargo包管理器,以及其他工具:

info: syncing channel updates for 'stable-x86_64-unknown-linux-gnu'
info: latest update on 2017-07-20, rust version 1.19.0 (0ade33941 2017-07-17)
info: downloading component 'rustc'

[...]

  stable installed - rustc 1.19.0 (0ade33941 2017-07-17)

Rust is installed now. Great!

To get started you need Cargo's bin directory ($HOME/.cargo/bin) in your PATH
environment variable. Next time you log in this will be done automatically.

To configure your current shell run source $HOME/.cargo/env

如安装程序所指出的,你需要执行以下命令以将包含这些工具的目录添加到你的PATH中:

$ source $HOME/.cargo/env
# Which is the same as executing the following:
$ export PATH="$HOME/.cargo/bin:$PATH"

(这只需要做一次,因为 rustup 安装程序已经将其添加到你的~/.profile文件中。)

现在,测试你是否已经安装了cargorustc,因为你很快就会需要它们:

$ cargo -V
cargo 0.23.0 (61fa02415 2017-11-22)
$ rustc -V
rustc 1.22.1 (05e2e1c41 2017-11-22)

Cargo 是 Rust 的包管理器和构建工具:它允许你编译和运行你的项目,以及管理它们的依赖关系。

在撰写本书时,稳定的 Rust 版本是 1.22.0。

测试你的安装

让我们尝试构建一个 Rust 程序。首先,使用cargo创建一个新的项目:

$ cargo new --bin hello_world
     Created binary (application) `hello_world` project

--bin标志表示我们想要创建一个可执行项目,而不是库(默认情况下没有这个标志)。在 Rust 的世界里,一个crate是一个包含库和/或可执行二进制的包。

这将创建一个包含以下文件和目录的hello_world目录:

$ tree hello_world/
hello_world/
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

Cargo.toml文件是存储你的项目元数据(名称、版本等)以及其依赖关系的地方。你的项目源文件位于src目录中。现在是时候运行这个项目了:

$ cd hello_world/
$ cargo run
   Compiling hello_world v0.1.0 (file:///home/packtpub/projects/hello_world)
    Finished dev [unoptimized + debuginfo] target(s) in 0.39 secs
     Running `target/debug/hello_world`
Hello, world!

cargo run之后打印出的前三行是cargo打印的,表示它做了什么:编译了项目并运行了它。最后一行Hello, world!是我们项目打印的行。正如你所看到的,cargo生成一个 Rust 文件,该文件将文本打印到stdout(标准输出)。

$ cat src/main.rs
fn main() {
    println!("Hello, world!");
}

如果你只想编译项目而不运行它,请键入以下命令代替:

$ cargo build
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs

这次,我们没有看到Compiling hello_world,因为cargo没有看到项目文件的任何更改,因此不需要再次编译。

文档和参考

你可以在这里找到 API 文档:doc.rust-lang.org/stable/std/。参考文档可以在这里找到:doc.rust-lang.org/stable/reference/

主函数

让我们再次看看我们的第一个项目源代码:

fn main() {
    println!("Hello, world!");
}

它只包含一个 main 函数——这是程序执行开始的地方。这是一个不接受任何参数(因此是空括号)并返回单元(也写作 ())的函数。函数体,在花括号之间,包含对 println!() 宏的调用——我们可以看到这是一个宏,因为它以 ! 结尾,而不是函数。这个宏打印括号内的文本,然后换行。我们将在宏部分看到什么是宏。

变量

我们现在将之前的程序修改一下,添加一个变量:

fn main() {
    let name = "world";
    println!("Hello, {}!", name);
}

字符串字面量中的 {} 部分被 name 变量的内容替换。在这里,我们看到类型推断的作用——我们不必指定 name 变量的类型,编译器会为我们推断。我们也可以自己写出类型:

let name: &str = "world";

(从现在开始,我将省略 main 函数,但此代码应写在函数内部。)

在 Rust 中,变量默认是不可变的。因此,编写以下内容将导致编译时错误:

let age = 42;
age += 1;

编译器会给出一个非常有用的错误信息:

error[E0384]: cannot assign twice to immutable variable `age`
  --> src/main.rs:16:5
   |
15 |     let age = 42;
   |         --- first assignment to `age`
16 |     age += 1;
   |     ^^^^^^^^ cannot assign twice to immutable variable

要使变量可变,我们需要使用 mut 关键字:

let mut age = 42;
age += 1;

内置数据类型

让我们看看语言提供的基本类型,例如整数、浮点数、布尔值和字符。

整数类型

Rust 中提供了以下整数类型:

无符号 有符号
u8 i8
u16 i16
u32 i32
u64 i64
usize isize

u 表示无符号,而 i 表示有符号,其后的数字表示位数。例如,u8 类型的数字可以在 0 到 255(包含)之间。i16 类型的数字可以在 -32768 到 32767(包含)之间。大小变体是指针大小的整数类型:usizeisize 在 64 位 CPU 上是 64 位。默认整数类型是 i32,这意味着当类型推断无法选择更具体的类型时,将使用此类型。

浮点数类型

有两种浮点数类型:f32f64,后者是默认类型。f 后面的数字表示该类型的位数。一个示例值是 0.31415e1

布尔类型

bool 类型接受两个值:truefalse

字符类型

char 类型表示一个 Unicode 字符。一个示例 Unicode 标量值是 '€'

控制流

我们现在将看看如何在 Rust 中编写条件和循环。条件在特定情况发生时执行代码块非常有用,而循环允许你重复执行代码块多次,直到满足条件。

编写条件

与其他语言类似,Rust 使用 ifelse 关键字表达条件:

let number1 = 24;
let number2 = 42;
if number1 > number2 {
    println!("{} > {}", number1, number2);
} else {
    println!("{} <= {}", number1, number2);
}

然而,它们不需要在条件表达式中使用括号。此外,此表达式必须是 bool 类型:你不能像在其他语言中那样使用数字。

Rust 的条件语句有一个特性,就像许多其他构造一样,即它们是表达式。每个分支的最后表达式是这个分支的值。不过要小心,每个分支的类型必须相同。例如,我们可以获取两个数字中的最小值并将其放入变量中:

let minimum =
    if number1 < number2 {
        number1
    } else {
        number2
    }; // Don't forget the semi-colon here.

创建 while 循环

Rust 中有多种循环类型。其中之一是while循环。

让我们看看如何使用欧几里得算法计算最大公约数:

let mut a = 15;
let mut b = 40;
while b != 0 {
    let temp = b;
    b = a % b;
    a = temp;
}
println!("Greatest common divisor of 15 and 40 is: {}", a);

此代码执行连续的除法,当余数为0时停止。

创建函数

当我们看到main函数时,我们对函数有一个简要的介绍。让我们看看如何创建具有参数和返回值的函数。

这里是如何编写一个返回两个数字中最大值的函数:

fn max(a: i32, b: i32) -> i32 {
    if a > b {
        a
    } else {
        b
    }
}

参数位于括号内,并且必须显式指定类型,因为类型推断只能推断局部变量的类型。这是好事,因为这充当了文档。此外,这可以防止我们在更改参数的使用方式或更改返回的值时出现错误。函数可以在使用后定义,而不会出现任何问题。返回类型位于->之后。当我们返回()时,我们可以省略->和类型。

函数体中的最后一个表达式是函数返回的值。你不需要使用return。只有当你想提前返回时才需要return关键字。

创建结构体

有时候,我们有多组值,这些值只有在一起才有意义,例如一个点的两个坐标。结构体是一种创建包含多个成员的新类型的方法。

这里是我们创建上述Point结构体的方法:

struct Point {
    x: i32,
    y: i32,
}

要创建一个新点并访问其成员,我们使用以下语法:

let point = Point {
    x: 24,
    y: 42,
};
println!("({}, {})", point.x, point.y);

如果我们想打印整个point呢?

让我们尝试以下代码:

println!("{}", point);

编译器不接受以下内容:

error[E0277]: the trait bound `Point: std::fmt::Display` is not satisfied
 --> src/main.rs:7:20
  |
7 |     println!("{}", point);
  |                    ^^^^^ `Point` cannot be formatted with the default formatter; try using `:?` instead if you are using a format string
  |
  = help: the trait `std::fmt::Display` is not implemented for `Point`
  = note: required by `std::fmt::Display::fmt`

{}语法用于向应用程序的最终用户显示值。尽管如此,没有标准的方式来显示任意结构。我们可以做编译器建议的事情:使用{:?}语法。这需要你向结构添加一个属性,所以让我们改变它:

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

println!("{:?}", point);

#[derive(Debug)]属性告诉编译器自动生成代码,以便能够打印出结构的调试表示。我们将在关于特质的章节中看到它是如何工作的。它打印以下内容:

Point { x: 24, y: 42 }

有时候,结构体包含很多嵌套字段,这种表示难以阅读。为了解决这个问题,我们可以使用{:#?}语法来美化打印值:

println!("{:#?}", point);

这会产生以下输出:

Point {
    x: 24,
    y: 42
}

文档描述了可以使用哪些其他格式化语法:doc.rust-lang.org/stable/std/fmt/

参考资料

让我们尝试以下代码,这在其他编程语言中是可行的:

let p1 = Point { x: 1, y: 2 };
let p2 = p1;
println!("{}", p1.x);

我们可以看到,Rust 不接受这个。它给出了以下错误:

error[E0382]: use of moved value: `p1.x`
 --> src/main.rs:4:20
  |
3 |     let p2 = p1;
  |         -- value moved here
4 |     println!("{}", p1.x);
  |                    ^^^^ value used here after move
  |
  = note: move occurs because `p1` has type `Point`, which does not implement the `Copy` trait

这意味着在值被移动之后,我们无法再使用它。在 Rust 中,默认情况下值是通过移动而不是复制来传递的,除非在某些情况下,我们将在下一小节中看到。

为了避免移动一个值,我们可以通过在它前面加上&来获取它的引用:

let p1 = Point { x: 1, y: 2 };
let p2 = &p1;
println!("{}", p1.x);

此代码可以编译,在这种情况下,p2p1的引用,这意味着它指向相同的内存位置。Rust 确保始终可以使用引用,因为引用不是指针,它们不能是NULL

引用也可以用于函数参数的类型。这是一个打印point的函数,它不会移动值:

fn print_point(point: &Point) {
    println!("x: {}, y: {}", point.x, point.y);
}

我们可以这样使用它:

print_point(&p1);
println!("{}", p1.x);

在调用print_point之后,我们仍然可以使用point,因为我们向函数发送了一个引用而不是将point移动到函数中。

克隆类型

使用引用的另一种方法是克隆值。通过克隆一个值,我们不会移动它。要能够克隆一个point,我们可以在它上面添加derive

#[derive(Clone, Debug)]
struct Point {
    x: i32,
    y: i32,
}

我们现在可以调用clone()方法来避免移动我们的p1点:

fn print_point(point: Point) {
    println!("x: {}, y: {}", point.x, point.y);
}

let p1 = Point { x: 1, y: 2 };
let p2 = p1.clone();
print_point(p1.clone());
println!("{}", p1.x);

复制类型

当我们将这些类型的值赋给另一个变量时,有些类型不会被移动。这对于基本类型,如整数来说就是这种情况。例如,以下代码是完全有效的:

let num1 = 42;
let num2 = num1;
println!("{}", num1);

即使我们将它赋值给了num2,我们仍然可以使用num1。这是因为基本类型实现了一个特殊的标记:Copy。复制类型是复制的而不是移动的。

我们可以通过添加derive来使我们的自定义类型成为Copy类型:

#[derive(Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

由于Copy需要Clone,我们也为我们的Point类型实现了后者。我们不能为包含不实现Copy的值的类型推导出Copy。现在,我们可以使用Point而无需担心引用:

fn print_point(point: Point) {
    println!("x: {}, y: {}", point.x, point.y);
}

let p1 = Point { x: 1, y: 2 };
let p2 = p1;
print_point(p1);
println!("{}", p1.x);

可变引用

如果我们想要能够对引用进行可变操作,我们需要一个可变引用,因为在 Rust 中默认情况下一切都是不可变的。要获取一个可变引用,只需将&替换为&mut。让我们编写一个函数,该函数将增加Pointx字段:

fn inc_x(point: &mut Point) {
    point.x += 1;
}

在这里,我们看到Point类型现在是&mut,这允许我们在方法中更新点。要使用此方法,我们的p1变量需要是mut,并且我们还需要为此变量提供一个可变引用:

let mut p1 = Point { x: 1, y: 2 };
inc_x(&mut p1);

方法

我们可以在自定义类型上添加方法。让我们编写一个计算point到原点距离的方法:

impl Point {
    fn dist_from_origin(&self) -> f64 {
        let sum_of_squares = self.x.pow(2) + self.y.pow(2);
        (sum_of_squares as f64).sqrt()
    }
}

这里有很多新的语法(impl Pointas.method()),所以让我们解释所有这些。首先,类型的方法定义在impl Type {}构造中。此方法接受一个特殊参数:&self。此参数是方法被调用的实例,就像其他编程语言中的this。在self之前的&运算符表示实例是通过不可变引用传递的。正如我们所见,在 Rust 中可以在基本类型上调用方法——self.x.pow(2)计算x字段的平方。我们可以在文档中找到此方法以及许多其他方法,请参阅doc.rust-lang.org/stable/std/primitive.i32.html#method.pow 。在方法的最后一个表达式中,我们在计算平方根之前将sum_of_squares整数转换为f64,因为sqrt()方法仅在浮点数上定义。

让我们创建一个方法来更新结构体的字段:

impl Point {
    fn translate(&mut self, dx: i32, dy: i32) {
        self.x += dx;
        self.y += dy;
    }
}

与之前的方法不同的是,现在self是一个可变引用,&mut

构造函数

Rust 不提供构造函数,但一个常见的习惯是创建一个new()静态方法,也称为关联函数:

impl Point {
    fn new(x: i32, y: i32) -> Self {
        Self { x: x, y: y }
    }
}

与普通方法的不同之处在于它不取&self(或其变体)作为参数。

Selfself值的类型;我们本可以使用Point代替Self

当字段名与分配的值相同时,可以省略值,作为缩写:

fn new(x: i32, y: i32) -> Self {
    Self { x, y }
}

当我们通过调用其构造函数(let point = Point::new();)创建Point的一个实例时,这将在栈上分配值。

我们可以提供多个构造函数:

impl Point {
    fn origin() -> Self {
        Point { x: 0, y: 0 }
    }
}

元组

元组和结构体相似,但元组的字段是无名的。元组在括号内声明,元素之间用逗号分隔:

let tuple = (24, 42);
println!("({}, {})", tuple.0, tuple.1);

如您在第二行所见,我们可以使用.index访问元组的元素,其中index是一个常量,此索引从 0 开始。

元组可以用来从函数中返回多个值。例如,str::split_at()方法返回两个字符串:

let (hello, world) = "helloworld".split_at(5);
println!("{}, {}!", hello, world);

在这里,我们将元组的两个元素赋值给helloworld变量。我们将在模式匹配部分看到这是为什么。

枚举

当结构体允许我们在同一个变量下获取多个值时,枚举允许我们从不同类型的值中选择一个。

例如,让我们编写一个表示表达式的类型:

enum Expr {
    Null,
    Add(i32, i32),
    Sub(i32, i32),
    Mul(i32, i32),
    Div { dividend: i32, divisor: i32 },
    Val(i32),
}

let quotient = Expr::Div { dividend: 10, divisor: 2 };
let sum = Expr::Add(40, 2);

Null变体没有与之关联的值,Val有一个关联值,而Add有两个。Div也有两个关联值,但它们是有名称的,类似于我们定义结构体的方式。

模式匹配

那么,我们如何知道一个枚举类型的变量中包含哪个变体,以及如何从中获取值呢?为此,我们需要使用模式匹配。match 表达式是进行模式匹配的一种方式。让我们看看如何使用它来计算表达式的结果:

fn print_expr(expr: Expr) {
    match expr {
        Expr::Null => println!("No value"),
        Expr::Add(x, y) => println!("{}", x + y),
        Expr::Sub(x, y) => println!("{}", x - y),
        Expr::Mul(x, y) => println!("{}", x * y),
        Expr::Div { dividend: x, divisor: 0 } => println!("Divisor 
         is zero"),
        Expr::Div { dividend: x, divisor: y } => println!("{}",  
        x/y),
        Expr::Val(x) => println!("{}", x),
    }
}

match 表达式是一种检查值是否遵循某种模式并针对不同模式执行不同代码的方式。在这种情况下,我们匹配枚举类型,因此检查每个变体。如果表达式是 Expr::Add,则执行 => 右侧的代码:println!("{}", x + y)。通过在 Expr::Add 旁边括号内的括号中写入变量名,我们指定这个变体的实际值绑定到这些名称上。通过这样做,我们可以在 => 右侧使用这些变量名。

1.1 展示了模式匹配的工作原理:

图 1.1图 1.1

match 也可以用来检查一个数字是否在某个范围内。这个函数将 ASCII 字符(在 Rust 中用 u8 表示)转换为大写:

fn uppercase(c: u8) -> u8 {
    match c {
        b'a'...b'z' => c - 32,
        _ => c,
    }
}

这里,... 语法表示一个包含范围。而下划线 (_) 用于表示字面意义上的所有其他内容,这在 Rust 中非常有用,因为 match 需要穷尽所有可能。

您可以使用 as 语法将 u8 转换为 char,如前所述:

println!("{}", uppercase(b'a') as char);

match 中,也可以通过使用 | 运算符来匹配不同的模式:

fn is_alphanumeric(c: char) -> bool {
    match c {
        'a'...'z' | 'A'...'Z' | '0'...'9' => true,
        _ => false,
    }
}

进行模式匹配还有其他语法。其中之一是 if let 构造。让我们用 if let 重新编写我们的 uppercase 函数:

fn uppercase(c: u8) -> u8 {
    if let b'a'...b'z' = c {
        c - 32
    } else {
        c
    }
}

match 不同,if let 不需要穷尽所有可能。它甚至不需要 else 分支,用于正常 if 表达式的规则也适用于 if let。当您只想匹配一个或两个模式时,这个构造可能比 match 更合适。

不可反驳的模式

另一种模式匹配的形式是不可反驳模式。当一个模式只有一种匹配方式并且总是成功时,它就是不可反驳的。例如,获取元组元素的一种方式是使用不可反驳模式:

let tuple = (24, 42);
let (a, b) = tuple;
println!("{}, {}", a, b);

在第二行,我们将元组的第一个元素赋值给 a,第二个元素赋值给 b

特质

特质是一种指定类型必须实现某些方法以及/或某些类型的方式。它们与 Java 中的接口类似。我们可以在类型上实现一个特质,只要这个特质被导入,我们就可以使用这个特质的这些方法。这就是我们可以在其他包或标准库中定义的类型上添加方法的方式。

让我们编写一个表示位集的特质:

trait BitSet {
    fn clear(&mut self, index: usize);
    fn is_set(&self, index: usize) -> bool;
    fn set(&mut self, index: usize);
}

在这里,我们不需要编写方法的主体,因为它们将在为类型实现这个特质时定义。

现在,让我们为 u64 类型实现这个特质:

impl BitSet for u64 {
    fn clear(&mut self, index: usize) {
        *self &= !(1 << index);
    }

    fn is_set(&self, index: usize) -> bool {
        (*self >> index) & 1 == 1
    }

    fn set(&mut self, index: usize) {
        *self |= 1 << index;
    }
}

如您所见,Rust 中的按位非运算符是 !,与其他语言的 ~ 相反。使用这段代码,我们可以在 u64 上调用这些方法:

let mut num = 0;
num.set(15);
println!("{}", num.is_set(15));
num.clear(15);

记得#[derive(Debug)]属性吗?这实际上是在以下类型上实现了Debug特质。如果我们默认的实现不适合我们的用例,我们也可以手动使用相同的impl语法在我们的类型上实现Debug特质。

默认方法

特质可以包含默认方法,这对于特质的实现者来说很方便,因为不需要实现的方法更少。让我们在特质中添加一个toggle()默认方法:

trait BitSet {
    fn clear(&mut self, index: usize);
    fn is_set(&self, index: usize) -> bool;
    fn set(&mut self, index: usize);

    fn toggle(&mut self, index: usize) {
        if self.is_set(index) {
            self.clear(index);
        } else {
            self.set(index);
        }
    }
}

由于新方法有一个主体,我们不需要更新我们之前的实现。然而,我们可以这样做以提供更高效的实现,例如:

impl BitSet for u64 {
    // The other methods are the same as before.

    fn toggle(&mut self, index: usize) {
        *self ^= 1 << index;
    }
}

关联类型

我们还可以在特质中有需要指定的类型。例如,让我们在我们的之前声明的Point类型上实现标准库中的Add特质,这允许我们在自己的类型上使用+运算符:

use std::ops::Add;

impl Add<Point> for Point {
    type Output = Point;

    fn add(self, point: Point) -> Self::Output {
        Point {
            x: self.x + point.x,
            y: self.y + point.y,
        }
    }
}

第一行是导入标准库中的Add特质,这样我们就可以在我们的类型上实现它。这里我们指定关联的Output类型是Point。关联类型对于返回类型最有用。在这里,add()方法的Output是关联的Self::Output类型。

现在,我们可以在Points 上使用+运算符:

let p1 = Point { x: 1, y: 2 };
let p2 = Point { x: 3, y: 4 };
let p3 = p1 + p2;

必须使用关联类型(而不是将其设置为Self)来指定输出参数,这为我们提供了更多的灵活性。例如,我们可以为*运算符实现标量积,它接受两个Point并返回一个数字。

你可以在本页面上找到所有可以重载的运算符,链接为doc.rust-lang.org/stable/std/ops/index.html

自从 Rust 1.20 以来,Rust 还支持关联常量,除了关联类型。

规则

为了使用特质,必须遵循一些规则。如果它们不被尊重,编译器将抛出错误:

  • 为了使用特质的方 法,必须导入该特质

  • 特质的实现必须在特质或类型的同一 crate 中

第二条规则是避免在使用多个库时可能发生的冲突。当两个导入的特质为同一类型提供相同的方法时,我们可能会遇到这样的冲突。

泛型

泛型是一种使函数或类型能够为多种类型工作以避免代码重复的方法。让我们重写我们的max函数以使其泛型化:

fn max<T: PartialOrd>(a: T, b: T) -> T {
    if a > b {
        a
    } else {
        b
    }
}

首先要注意的是,在函数名之后有一个新的部分:这是我们声明泛型类型的地方。我们声明一个泛型T类型,在它后面: PartialOrd意味着这个T类型必须实现PartialOrd特质。这被称为特质界限。然后我们使用这个T类型作为我们的两个参数和返回类型。然后,我们看到与我们的非泛型函数相同的函数体。我们需要添加特质界限,因为默认情况下,不允许在泛型类型上进行任何操作。PartialOrd特质允许我们使用比较运算符。

我们可以使用此函数与任何实现了 PartialOrd 的类型:

println!("{}", max('a', 'z'));

这是在使用静态分派而不是动态分派,这意味着编译器将在生成的二进制文件中生成一个针对 char 的特定 max 函数。动态分派是另一种在运行时解决要调用哪个函数的方法,这效率较低。

选项类型

泛型也可以用于类型。标准库中的 Option 类型是一个泛型类型,定义为如下:

enum Option<T> {
    Some(T),
    None,
}

这种类型用于编码值不存在的情况。None 表示没有值,而 Some(value) 用于存在值的情况。

数组

数组是一个固定大小的相同类型元素的集合。我们用方括号声明它们:

let array = [1, 2, 3, 4];
let array: [i16; 4] = [1, 2, 3, 4];

第二行显示了如何指定数组的类型。另一种方法是使用文字后缀:

let array = [1u8, 2, 3, 4];

文字后缀是由一个文字(即,一个常量)和一个类型后缀组成的,因此,对于 1 常量和 u8 类型,我们得到 1u8。文字后缀只能用于数字。这声明了一个包含 4u8 类型元素的数组。数组索引从 0 开始,边界检查在运行时进行。边界检查用于防止访问超出范围的内存,例如,尝试访问数组末尾之后的元素。虽然这可能会稍微减慢软件的速度,但在许多情况下可以进行优化。以下代码将触发一个恐慌,因为 4 索引超出了数组的末尾:

println!("{}", array[4]);

在运行时,我们看到以下消息:

thread 'main' panicked at 'index out of bounds: the len is 4 but the index is 4', src/main.rs:5:20
note: Run with `RUST_BACKTRACE=1` for a backtrace.

另一种声明数组的方法是:

let array = [0u8; 100];

这声明了一个包含 100 个元素且所有元素都是 0 的数组。

切片

数组是固定大小的,但如果我们想创建一个可以与任何大小的数组一起工作的函数,我们需要使用另一种类型:切片。

切片是连续序列的视图:它可以是对整个数组的视图,也可以是其一部分。切片是胖指针,除了指向数据的指针外,它们还包含一个大小。以下是一个返回切片第一个元素引用的函数:

fn first<T>(slice: &[T]) -> &T {
    &slice[0]
}

这里,我们使用一个未加界泛型类型,因为我们没有在 T 类型的值上使用任何操作。&[T] 参数类型是 T 的切片。返回类型是 &T,它是对 T 类型值的引用。函数体是 &slice[0],它返回切片的第一个元素的引用。以下是如何使用数组调用此函数的方法:

println!("{}", first(&array));

我们可以只为数组的一部分创建 slice,如下面的示例所示:

println!("{}", first(&array[2..]));

&array[2..] 创建了一个从 2 索引开始直到数组末尾的切片(因此 .. 后没有索引)。两个索引都是可选的,因此我们也可以写 &array[..10] 以获取数组的前 10 个元素,&array[5..10] 以获取索引为 5 到 9 的元素(包括),或者 &array[..] 以获取所有元素。

循环

for 循环是 Rust 中可以使用的一种循环形式。它用于遍历迭代器的元素。迭代器是一个结构,它产生一系列值:它可以无限期地产生相同的值,或者产生集合的元素。我们可以从切片中获取迭代器,所以让我们这样做来计算切片中元素的总和:

let array = [1, 2, 3, 4];
let mut sum = 0;
for element in &array {
    sum += *element;
}
println!("Sum: {}", sum);

这里唯一令人惊讶的部分是 sum += *element 中的 *。由于我们得到了切片元素的引用,我们需要解引用它们才能访问整数。我们在 array 前面使用了 & 来避免移动它,实际上,我们可能仍然想在循环之后使用这个变量。

让我们编写一个函数,它返回切片中元素的索引,或者如果它不在切片中则返回 None

fn index<T: PartialEq>(slice: &[T], target: &T) -> Option<usize> {
    for (index, element) in slice.iter().enumerate() {
        if element == target {
            return Some(index);
        }
    }
    None
}

注意:部分等价关系既是对称的也是传递的,但不是自反的。当这三个属性都满足时,使用 Eq 特征。

在这里,我们再次使用了一个泛型类型,但这次我们使用了 PartialEq 特征约束,以便能够在 T 类型的值上使用 == 操作符。这个函数返回 Option<usize>,这意味着它可以返回没有值(None)或索引(Some(index))。在主体第一行中,我们使用 slice.iter().enumerate() 来获取索引以及切片的元素。我们使用 for 关键字后面的模式匹配来将索引和元素分配给变量。在条件内部,我们使用 return 关键字来提前返回一个值。所以如果找到了值,它将返回索引;否则,循环将结束,随后返回 None 值。

让我们再写一个函数,它使用 for 循环。它返回切片的最小值和最大值,如果切片为空则返回 None

fn min_max(slice: &[i32]) -> Option<(i32, i32)> {
    if slice.is_empty() {
        return None;
    }
    let mut min = slice[0];
    let mut max = slice[0];
    for &element in slice {
        if element < min {
            min = element;
        }
        if element > max {
            max = element;
        }
    }
    Some((min, max))
}

在这里,我们通过使用元组从函数中返回多个值。这次,&in 的左侧,而之前它在右侧;这是因为这个 for 循环通过使用 &element 来对引用进行模式匹配。这是我们可以在 Rust 中做到的,因此我们不再需要使用 * 来解引用元素。

宏规则,也称为示例宏,是一种通过在编译时生成代码来避免代码重复的方法。我们将实现一个简单的宏来为整数类型实现我们的 BitSet 特征:

macro_rules! int_bitset {
    ($ty:ty) => {
        impl BitSet for $ty {
            fn clear(&mut self, index: usize) {
                *self &= !(1 << index);
            }

            fn is_set(&self, index: usize) -> bool {
                (*self >> index) & 1 == 1
            }

            fn set(&mut self, index: usize) {
                *self |= 1 << index;
            }
        }
    };
}

int_bitset 宏的名称写在 macro_rules! 之后。宏可以有多个规则,类似于匹配臂,但它匹配 Rust 语法元素,而不是类型、表达式、代码块等。这里我们只有一个规则,并且它匹配单个类型,因为我们使用了 :ty:ty 之前的部分($ty)是匹配的元素的名称。在 => 符号后面的花括号内,我们看到将生成实际代码。它与我们对 u64BitSet 的先前实现相同,只是它使用元变量 $ty 而不是 u64

为了避免大量的样板代码,我们可以使用此宏如下:

int_bitset!(i32);
int_bitset!(u8);
int_bitset!(u64);

多个模式规则

让我们编写一个宏,它将简化重载运算符的特质的实现。这个宏将有两个规则:一个用于 + 运算符,另一个用于 - 运算符。以下是宏的第一个规则:

macro_rules! op {
    (+ $_self:ident : $self_type:ty, $other:ident $expr:expr) => {
        impl ::std::ops::Add for $self_type {
            type Output = $self_type;

            fn add($_self, $other: $self_type) -> $self_type {
                $expr
            }
        }
    };
    // …

在这个模式中,我们使用其他类型的语法元素:ident,它是一个标识符,以及 <span>expr,它是一个表达式。特质(::std::ops::Add)是全称的,这样使用此宏的代码就不需要导入 Add 特质。

以下是宏的其余部分:

    (- $_self:ident : $self_type:ty, $other:ident $expr:expr) => {
        impl ::std::ops::Sub for $self_type {
            type Output = $self_type;

            fn sub($_self, $other: $self_type) -> $self_type {
                $expr
            }
        }
    };
}

然后,我们可以使用这个宏与我们的 Point 类型一起使用,如下所示:

op!(+ self:Point, other {
    Point {
        x: self.x + other.x,
        y: self.y + other.y,
    }
});

op!(- self:Point, other {
    Point {
        x: self.x - other.x,
        y: self.y - other.y,
    }
});

让我们看看匹配是如何工作的:

对于第一个宏调用,我们以 + 开始,因此第一个分支被选中,因为它匹配 +,这是该分支的开始。接下来是 self,它是一个标识符,因此它匹配 ident 模式,并将其分配给 $_self 元变量。然后,我们有 :,它匹配模式中的冒号。之后,我们有 Point,它匹配 ty 类型的 $self_type 元变量(用于类型匹配)。然后我们有 ,,它匹配模式中的逗号。接下来是 other,它匹配模式中的下一个项目,即 ident 类型的 $other 元变量。最后,我们有 { Point { … } },它匹配模式末尾所需的表达式。这就是为什么这些宏被称为示例宏,我们编写调用应该看起来像什么,用户必须匹配示例(或模式)。

作为对读者的练习,尝试以下操作:

  • 添加缺失的运算符:*/

  • 在模式中添加指定 $other 类型以及返回类型的能力

  • 如果在前一点中还没有这样做,请添加更多标记,使其看起来更像一个函数声明:+(self: Point, other: Point) -> Point { … }

  • 尝试将模式中的运算符移动到 $self_type 元变量之后,以查看 macro_rules 的局限性

重复

在宏模式中,也可以使用重复运算符 +* 来匹配无限数量的模式。它们的行为与正则表达式中的相同运算符完全一样:

  • + 匹配 1 次或更多次。

  • * 匹配 0、1 或多次。

让我们编写一个非常有用的宏,一个用于提供创建 HashMap 语法糖的宏:

注意:HashMap 是 Rust 标准库中的一个数据结构,它将键映射到值。

macro_rules! hash {
    ($( $key:expr => $value:expr ),*) => {{
        let mut hashmap = ::std::collections::HashMap::new();
        $(hashmap.insert($key, $value);)*
        hashmap
    }};
}

如我们所见,我们在这里使用*运算符。它前面的逗号指定了分隔符:这个分隔符必须存在于括号中每个模式出现的间隔(这是可以重复的模式)。不要忘记在括号前的开头的$;如果没有它,宏将匹配字面量(。在括号内,我们看到一个正常的模式,一个表达式,后面跟着=>运算符,然后是另一个表达式。这个规则的正文是特别的,因为它使用了两对花括号而不是一对。

首先,让我们看看我们如何使用这个宏,然后我们将在稍后回到这个特殊性:

let hashmap = hash! {
    "one" => 1,
    "two" => 2
};

如果我们只使用一对花括号,就像这样:

macro_rules! hash {
    ($( $key:expr => $value:expr ),*) => {
        let mut hashmap = ::std::collections::HashMap::new();
        $(hashmap.insert($key, $value);)*
        hashmap
    };
}

编译器将尝试生成以下代码,但无法编译:

let hashmap = let mut hashmap = ::std::collections::HashMap::new();
    hashmap.insert("one", 1);
    hashmap.insert("two", 2);
    hashmap;

它无法编译,因为 Rust 希望在=的右侧有一个表达式。为了将此代码转换为表达式,我们只需添加花括号:

let hashmap = {
    let mut hashmap = ::std::collections::HashMap::new();
    hashmap.insert("one", 1);
    hashmap.insert("two", 2);
    hashmap
};

因此,需要第二对花括号。

在宏的正文部分还有一行需要解释:

$(hashmap.insert($key, $value);)*

这意味着该语句将被重复,直到有键/值对的配对。注意,;在括号内;并且*之前没有分隔符,因为每个语句都需要以分号结束。但仍然可以在此处指定分隔符,如下面的示例所示:

let keys = [$($key),*];

这将展开所有的$key,用逗号分隔。例如,使用如下调用:

hash! {
    "one" => 1,
    "two" => 2
}

它将产生:

let keys = ["one", "two"];

可选量词

macro_rules系统中,没有办法指定一个模式是可选的,就像正则表达式中的?量词一样。如果我们想让我们的hash宏的用户使用尾随逗号,我们可以通过将逗号移动到括号内来更改规则:($( $key:expr => $value:expr,)* $(,)* )

然而,这将强制用户编写尾随宏。如果我们想允许两种变体,我们可以使用以下技巧,它使用*运算符:($( $key:expr => $value:expr ),* $(,)* )

这意味着每个模式之间必须使用逗号分隔,我们可以在最后一个模式之后使用任意数量的逗号,包括一个逗号都不用。

概述

这章通过向你展示如何使用变量、函数、控制流结构和类型来介绍 Rust 的基础知识。你还学习了更高级的概念,如引用和所有权来管理内存,以及你看到了如何使用特性、泛型和宏来避免代码重复。

在下一章中,你将通过创建一个视频游戏来练习你刚刚学到的知识。

第二章:从 SDL 开始

在开始编写俄罗斯方块之前,还有一些事情需要讨论,比如库,我们将大量使用(一旦您开始在自己的项目上使用 Rust,您也会大量使用!)。让我们从库开始吧!

理解 Rust 库

在 Rust 中,包(包括二进制和库)被称为 crate。您可以在crates.io上找到很多。今天,我们将使用 SDL2 crate 来制作我们的俄罗斯方块,但在考虑这一点之前,我们需要安装SDL2库,这是由SDL2 crate 使用的!

安装 SDL2

在继续之前,我们需要安装 SDL 库。

在 Linux 上安装 SDL2

根据您的包管理工具,运行以下命令在 Linux 上安装 SDL2:

apt 包管理器

$ sudo apt-get install libsdl2-dev

dnf 包管理器

$ sudo dnf install SDL2-devel

yum 包管理器

$ yum install SDL2-devel

完成后,您的 SDL2 安装就绪了!

在 Mac 上安装 SDL2

要在 Mac 上安装 SDL2,只需运行以下命令:

$ brew install sdl2

您可以开始了!

在 Windows 上安装 SDL2

所有这些安装说明都直接来自 Rust SDL2 crate。

带有构建脚本的 Windows

为了使所有这些工作,需要执行几个步骤。请遵循指南!

  1. www.libsdl.org/下载mingwmsvc开发库(SDL2-devel-2.0.x-mingw.tar.gzSDL2-devel-2.0.x-VC.zip)。

  2. 解压缩到您选择的文件夹。 (之后您可以删除它。)

  3. 在与Cargo.toml文件相同的文件夹中创建以下文件夹结构:

        gnu-mingw\dll\32
        gnu-mingw\dll\64
        gnu-mingw\lib\32
        gnu-mingw\lib\64
        msvc\dll\32
        msvc\dll\64
        msvc\lib\32
        msvc\lib\64
  1. 将源存档中的libdll文件复制到步骤 3 中创建的目录中,如下所示:
SDL2-devel-2.0.x-mingw.tar.gz\SDL2-2.0.x\i686-w64-mingw32\bin    ->     gnu-mingw\dll\32
SDL2-devel-2.0.x-mingw.tar.gz\SDL2-2.0.x\x86_64-w64-mingw32\bin  ->     gnu-mingw\dll\64
SDL2-devel-2.0.x-mingw.tar.gz\SDL2-2.0.x\i686-w64-mingw32\lib    ->     gnu-mingw\lib\32
SDL2-devel-2.0.x-mingw.tar.gz\SDL2-2.0.x\x86_64-w64-mingw32\lib  ->     gnu-mingw\lib\64
SDL2-devel-2.0.5-VC.zip\SDL2-2.0.x\lib\x86\*.dll                 ->     msvc\dll\32
SDL2-devel-2.0.5-VC.zip\SDL2-2.0.x\lib\x64\*.dll                 ->     msvc\dll\64
SDL2-devel-2.0.5-VC.zip\SDL2-2.0.x\lib\x86\*.lib                 ->     msvc\lib\32
SDL2-devel-2.0.5-VC.zip\SDL2-2.0.x\lib\x64\*.lib                 ->     msvc\lib\64
  1. 创建一个构建脚本。如果您还没有,请将以下内容放入您的Cargo.toml文件中的[package]部分:
        build = "build.rs"
  1. 在与Cargo.toml文件相同的目录下创建一个名为build.rs的文件,并将以下内容写入其中:
      use std::env;
      use std::path::PathBuf;

      fn main() {
        let target = env::var("TARGET").unwrap();
        if target.contains("pc-windows") {
          let manifest_dir = 
            PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
          let mut lib_dir = manifest_dir.clone();
        let mut dll_dir = manifest_dir.clone();
        if target.contains("msvc") {
            lib_dir.push("msvc");
            dll_dir.push("msvc");
        } else {
            lib_dir.push("gnu-mingw");
            dll_dir.push("gnu-mingw");
        }
        lib_dir.push("lib");
        dll_dir.push("dll");
        if target.contains("x86_64") {
            lib_dir.push("64");
            dll_dir.push("64");
        } else {
            lib_dir.push("32");
            dll_dir.push("32");
        }
        println!("cargo:rustc-link-search=all={}", 
          lib_dir.display());
        for entry in std::fs::read_dir(dll_dir).expect("Can't
          read DLL dir")  {
         let entry_path = entry.expect("Invalid fs entry").path();
         let file_name_result = entry_path.file_name();
         let mut new_file_path = manifest_dir.clone();
         if let Some(file_name) = file_name_result {
           let file_name = file_name.to_str().unwrap();
           if file_name.ends_with(".dll") {
             new_file_path.push(file_name);
           std::fs::copy(&entry_path,
           new_file_path.as_path()).expect("Can't copy 
             from DLL dir");
           }
         }
        }
        }
      }
  1. 在构建过程中,构建脚本会将所需的 DLL 文件复制到与您的Cargo.toml文件相同的目录中。但是,您可能不希望将这些文件提交到任何 Git 仓库中,因此请将以下行添加到您的.gitignore文件中:
 /*.dll
  1. 当您发布游戏时,请确保将相应的SDL2.dll复制到与您的编译exe文件相同的目录中;否则,游戏将无法启动。

现在您的项目应该可以在任何 Windows 计算机上构建和运行了!

Windows(MinGW)

为了使所有这些工作,需要执行几个步骤。请遵循指南!

  1. www.libsdl.org/下载mingw开发库(SDL2-devel-2.0.x-mingw.tar.gz)。

  2. 解压缩到您选择的文件夹。 (之后您可以删除它。)

  3. 从以下路径复制所有lib文件:

 SDL2-devel-2.0.x-mingw\SDL2-2.0.x\x86_64-w64-mingw32\lib

接下来,将其复制到以下路径:

 C:\Program Files\Rust\lib\rustlib\x86_64-pc-windows-gnu\lib

或者,您可以将它复制到您选择的库文件夹中,并确保您有一个如下所示的系统环境变量:

 LIBRARY_PATH = C:\your\rust\library\folder

对于 Rustup 用户,此文件夹位于以下位置:

 C:\Users\{Your Username}.multirust\toolchains\{current
         toolchain}\lib\rustlib\{current toolchain}\lib

在这里,当前的工具链可能是stable-x86_64-pc-windows-gnu

  1. 从以下位置复制SDL2.dll
 SDL2-devel-2.0.x-mingw\SDL2-2.0.x\x86_64-w64-mingw32\bin

复制的SDL2.dll被粘贴到您的 Cargo 项目中,紧挨着您的Cargo.toml文件。

  1. 当你发布你的游戏时,确保将 SDL2.dll 复制到与你的编译 exe 文件相同的目录中;否则,游戏将无法启动。

Windows (MSVC)

为了使所有这些工作,你需要进行几个步骤。请遵循指南!

  1. www.libsdl.org/ 下载 MSVC 开发库 SDL2-devel-2.0.x-VC.zip

  2. SDL2-devel-2.0.x-VC.zip 解压到你的选择文件夹中。(之后你可以删除它。)

  3. 从以下路径复制所有 lib 文件:

 SDL2-devel-2.0.x-VC\SDL2-2.0.x\lib\x64\

lib 文件将被粘贴在这里:

 C:\Program Files\Rust\lib\rustlib\x86_64-pc-windows-msvc\lib

或者,它们将被粘贴到你的选择库文件夹中。确保你有一个包含以下内容的系统环境变量:

 LIB = C:\your\rust\library\folder

在这里,当前的工具链可能是 stable-x86_64-pc-windows-msvc

  1. 从以下代码片段复制 SDL2.dll
 SDL2-devel-2.0.x-VC\SDL2-2.0.x\lib\x64\

复制的 SDL2.dll 被粘贴到你的 cargo 项目中,紧挨着你的 Cargo.toml 文件。

  1. 当你发布你的游戏时,确保将 SDL2.dll 复制到与你的编译 exe 文件相同的目录中;否则,游戏将无法启动。

设置你的 Rust 项目

Rust 的包管理器 cargo 允许我们通过一个命令 cargo new 非常容易地创建一个新的项目。让我们按照以下步骤运行它:

 cargo new tetris --bin

你应该有一个名为 tetris 的新文件夹,其中包含以下内容:

     tetris/
     |
     |- Cargo.toml
     |- src/
         |
         |- main.rs

注意,如果你在运行 cargo new 命令时没有使用 --bin 标志,那么你将会有一个 lib.rs 文件而不是 main.rs

现在将以下内容写入你的 Cargo.toml 文件:

    [package]
    name = "tetris"
    version = "0.0.1"

    [dependencies]
    sdl2 = "0.30.0"

在这里,我们声明我们的项目名称是 tetris,版本是 0.0.1(目前这并不重要),并且它依赖于 sdl2 crate。

对于版本控制,Cargo 遵循 SemVer(语义版本控制)。它的工作方式如下:

[major].[minor].[path]

所以这里就是每个部分的确切含义:

  • 当你进行不兼容的 API 变更时,更新 [major] 版本号。

  • 当你添加不破坏向后兼容性的新功能时,更新 [minor] 版本号。

  • 当你进行不破坏向后兼容性的错误修复时,更新 [patch] 版本号。

虽然知道这一点不是必须的,但如果你打算将来编写 crate,了解这一点总是很好的。

Cargo 和 crates.io

在 Rust 的生态系统中有一些非常重要的事情需要注意,那就是 Cargo 非常重要,如果不是核心的话。它使事情变得容易得多,并且所有 Rust 项目都在使用它。

cargo 不仅是一个构建工具,也是 Rust 的默认包管理器。如果你需要下载依赖项,Cargo 会为你做这件事。你可以在 crates.io/ 找到所有可用的已发布 crate。考虑以下截图:

图 2.1

对于 sdl2 crate,我们可以在其页面 (crates.io/crates/sdl2) 上看到一些有趣和有用的信息:

图 2.2

在右侧,你可以看到版本历史。检查你是否拥有最新版本以及 crate 是否仍在维护中可能很有用。

在中间,你有 crate 的依赖项。如果缺少某些东西,了解你需要安装什么总是很有趣。

最后,在左侧,有几个可能非常有用的链接(不总是那些链接,这取决于 Cargo.toml 文件中放入了什么):

  • 文档:这是文档的托管位置(尽管我通常推荐 docs.rs,我稍后会谈到)

  • 仓库:这是这个 crate 的仓库托管位置

  • 依赖的 crate:这是依赖于这个 crate 的 crate 列表

  • 主页:如果 crate 有一个网站,你可以访问它的链接

是时候回到 docs.rs 上看看了。

docs.rs 文档

crates.io 上发布的每个 crate 都会生成其文档并托管在 docs.rs/。如果 crate 的文档没有在任何地方在线发布,只要它已经发布,你就能在那里找到它。与 crates.iorust-lang.org 一样,它是 Rust 生态系统中最知名的地方之一,所以请将其添加到书签,不要丢失它!

下面是 docs.rs 的截图:

图 2.3图 2.3

回到我们的 Cargo.toml 文件

要回到我们的 Cargo.toml 文件,你也可以直接从它们的仓库中添加 crates;你只需在添加依赖时在 Cargo.toml 文件中指定这一点。通常,发布的版本不如对应仓库中的版本先进,但会更稳定。

例如,如果我们想使用 sdl2 crate 的仓库版本,我们需要在 Cargo.toml 文件中写下:

[dependencies]
sdl2 = { git = "https://github.com/Rust-SDL2/rust-sdl2" }

很简单吧?Cargo 还可以启动测试或基准测试,安装二进制文件,通过构建文件(默认在 build.rs)处理特殊构建,或处理功能(我们稍后会回到这一点)。

简单来说,它是一个完整的工具,解释其大部分功能需要花费很多时间和空间,所以我们现在就只关注基础部分。

你可以在 doc.crates.io/index.html 找到关于 Cargo 非常好的文档/教程。

Rust 的模块

在继续之前,我们需要谈谈 Rust 中通过其模块如何处理文件层次结构。

首先要知道的是,在 Rust 中,文件和文件夹被当作模块处理。考虑以下情况:

|- src/
    |
    |- main.rs
    |- another_file.rs

如果你想声明一个模块位于 another_file.rs 文件中,你需要在你的 main.rs 文件中添加:

    mod another_file;

现在,你将能够访问 another_file.rs 中包含的所有内容(只要它是公开的)。

另一件需要知道的事情:你只能声明与你的当前模块/文件处于同一级别的模块。以下是一个简短的例子来总结这一点:

|- src/
    |
    |- main.rs
    |- subfolder/
        |- another_file.rs

如果你尝试直接在main.rs中声明一个引用another_file.rs的模块,就像前面展示的那样,它将会失败,因为src/中没有another_file.rs。在这种情况下,你需要做三件事:

  1. subfolder文件夹中添加一个mod.rs文件。

  2. mod.rs中声明another_file

  3. main.rs中声明subfolder

你肯定想知道,为什么是mod.rs?这是 Rust 的标准——当你导入一个模块,即一个文件夹时,编译器会查找其中的mod.rs文件。mod.rs文件主要用于将模块的内容重新导出。

现在我们写下实现这个功能的代码:

mod.rs内部:

    pub mod another_file;

main.rs内部:

    mod subfolder;

现在,你可以使用another_file中的所有内容(只要它是公共的!)!考虑以下示例:

    use subfolder::another_file::some_function;

你肯定已经注意到我们在mod.rs中公开声明了another_file。这仅仅是因为main.rs否则无法访问其内容,因为它不在同一模块级别。然而,子模块可以访问父模块的私有项。

为了总结这个小部分,让我们谈谈第三种类型的模块:模块块(是的,就这么简单)。

就像导入文件或文件夹一样,你可以通过使用相同的关键字来创建模块块:

    mod a_module {
      pub struct Foo;
   }

现在你已经创建了一个名为a_module的新模块,其中包含一个公共结构。之前描述的规则以相同的方式应用于这种类型的最后一个模块。

你现在知道如何使用模块来导入文件和文件夹。让我们开始写下我们的游戏!

沙罗曼蛇

好的,我们现在准备好开始写下我们的沙罗曼蛇游戏了!

首先,让我们完善我们的main.rs文件,以检查一切是否按预期工作:

    extern crate sdl2;

    use sdl2::pixels::Color;
    use sdl2::event::Event;
    use sdl2::keyboard::Keycode;
    use std::time::Duration;
    use std::thread::sleep;

    pub fn main() {
      let sdl_context = sdl2::init().expect("SDL initialization   
      failed");
      let video_subsystem = sdl_context.video().expect("Couldn't get 
       SDL video subsystem");

      let window = video_subsystem.window("rust-sdl2 demo: Video", 800,
            600)
        .position_centered()
        .opengl()
        .build()
        .expect("Failed to create window");

      let mut canvas = window.into_canvas().build().expect("Failed to
        convert window into canvas");

      canvas.set_draw_color(Color::RGB(255, 0, 0));
      canvas.clear();
      canvas.present();
      let mut event_pump = sdl_context.event_pump().expect("Failed to
        get SDL event pump");

      'running: loop {
         for event in event_pump.poll_iter() {
            match event {
              Event::Quit { .. } |
              Event::KeyDown { keycode: Some(Keycode::Escape), .. } =>  
              {
                break 'running
              },
              _ => {}
            }
         }
         sleep(Duration::new(0, 1_000_000_000u32 / 60));
      }
    }

你会注意到以下行:

    ::std::thread::sleep(Duration::new(0, 1_000_000_000u32 / 60));

它允许你避免无谓地使用所有计算机 CPU 时间,并且最多每秒渲染 60 次。

现在在你的终端中运行以下命令:

$ cargo run

如果你看到一个充满红色的窗口(就像以下截图所示),那么一切正常!

图 2.4

创建窗口

之前的示例创建了一个窗口并在其中绘制。现在让我们看看它是如何做到的!

在继续之前,我们需要导入 SDL2 包,如下所示:

    extern crate sdl2;

这样,我们现在可以访问它包含的所有内容。

现在我们已经导入了sdl2,我们需要初始化一个 SDL 上下文:

    let sdl_context = sdl2::init().expect("SDL initialization failed");

一旦完成,我们需要获取视频子系统:

    let video_subsystem = sdl_context.video().expect("Couldn't get SDL 
      video subsystem");

我们现在可以创建窗口了:

    let window = video_subsystem.window("Tetris", 800, 600)
                            .position_centered()
                            .opengl()
                            .build()
                            .expect("Failed to create window");

关于这些方法的一些注意事项:

  • window方法的参数是标题、宽度和高度

  • .position_centered()将窗口放置在屏幕中间

  • .opengl()使 SDL 使用opengl进行渲染

  • .build()通过应用所有之前接收到的参数来创建窗口

  • 如果发生错误,.expect会使用给定的消息引发恐慌

如果你尝试运行这段代码示例,它将显示一个窗口并迅速关闭。我们现在需要添加一个事件循环来保持其运行(然后管理用户输入)。

在文件顶部,你需要添加以下内容:

    use sdl2::event::Event;
    use sdl2::keyboard::Keycode;

    use std::thread::sleep;
    use std::time::Duration;

现在,让我们实际编写我们的事件管理器。首先,我们需要获取事件处理器,如下所示:

    let mut event_pump = sdl_context.event_pump().expect("Failed to
       get SDL event pump");

然后,我们创建一个无限循环来遍历事件:

    'running: loop {
      for event in event_pump.poll_iter() {
        match event {
            Event::Quit { .. } |
            Event::KeyDown { keycode: Some(Keycode::Escape), .. } => {
                break 'running // We "break" the infinite loop.
            },
            _ => {}
        }
      }
    sleep(Duration::new(0, 1_000_000_000u32 / 60));
    }

要回到这两行:

    'running: loop {
      break 'running

loop是一个关键字,允许在 Rust 中创建无限循环。虽然这是一个有趣的功能,但你也可以给你的循环添加标签(所以whilefor也可以)。在这种情况下,我们给主循环添加了标签running。目的是能够直接跳出上层循环,而无需设置变量。

现在,如果我们收到一个quit事件(点击窗口的叉号)或者如果你按下Esc键,程序将退出。

现在你可以运行这段代码,你将有一个窗口。

绘制

我们现在有一个可以工作的窗口;将其绘制进去会很好。首先,在开始主循环之前,我们需要获取窗口的画布:

    let mut canvas = window.into_canvas()
                       .target_texture()
                       .present_vsync()
                       .build()
                       .expect("Couldn't get window's canvas");

对前面代码的一些解释:

  • into_canvas将窗口转换成画布,这样我们就可以更容易地操作它

  • target_texture激活纹理渲染支持

  • present_vsync启用 v 同步(也称为垂直同步)限制

  • build通过应用所有之前设置的参数创建画布

然后,我们将创建一个纹理,并将其粘贴到窗口的画布上。首先,让我们获取纹理创建器,但在那之前,在文件顶部添加以下包含:

    use sdl2::render::{Canvas, Texture, TextureCreator};

现在,我们可以获取纹理创建器:

    let texture_creator: TextureCreator<_> = canvas.texture_creator();

好的!现在我们需要创建一个矩形。为了使代码更易于阅读,我们将创建一个常量,它将是纹理的大小(最好将其放在文件头部,紧随导入之后,以提高可读性):

    const TEXTURE_SIZE: u32 = 32;

让我们创建一个大小为32x32的纹理:

    let mut square_texture: Texture =
        texture_creator.create_texture_target(None, TEXTURE_SIZE,
          TEXTURE_SIZE)
        .expect("Failed to create a texture");

好的!现在让我们给它上色。首先,在文件顶部添加以下导入:

    use sdl2::pixels::Color;

我们使用画布来绘制我们的方形纹理:

    canvas.with_texture_canvas(&mut square_texture, |texture| {
      texture.set_draw_color(Color::RGB(0, 255, 0));
      texture.clear();
    });

对前面代码的解释如下:

  • set_draw_color设置绘制时使用的颜色。在我们的例子中,它是绿色。

  • clear清洗/清除纹理,使其被绿色填充。

现在,我们只需要将这个方形纹理绘制到我们的窗口上。为了使其工作,我们需要在主循环中绘制,但要在事件循环之后。

在我们继续之前,有一点需要注意:在使用SDL2绘制时,(0, 0)坐标位于窗口的左上角,而不是左下角。对于所有形状都一样。

在文件顶部添加以下导入:

    use sdl2::rect::Rect;

现在我们来绘制。为了能够更新窗口的渲染,你需要在主循环中(并且在事件循环之后)进行绘制。所以首先,让我们用红色填充我们的窗口:

    canvas.set_draw_color(Color::RGB(255, 0, 0));
    canvas.clear();

接下来,我们将我们的纹理以 32x32 的大小复制到窗口的左上角:

    canvas.copy(&square_texture,
            None,
            Rect::new(0, 0, TEXTURE_SIZE, TEXTURE_SIZE))
        .expect("Couldn't copy texture into window");

最后,我们更新窗口的显示:

     canvas.present();

因此,如果我们看一下完整的代码,我们现在有以下内容:

    extern crate sdl2;

    use sdl2::event::Event;
    use sdl2::keyboard::Keycode;
    use sdl2::pixels::Color;
    use sdl2::rect::Rect;
    use sdl2::render::{Texture, TextureCreator};

    use std::thread::sleep;
    use std::time::Duration;

    fn main() {
      let sdl_context = sdl2::init().expect("SDL initialization  
       failed");
      let video_subsystem = sdl_context.video().expect("Couldn't get
         SDL video subsystem");

      // Parameters are: title, width, height
      let window = video_subsystem.window("Tetris", 800, 600)
        .position_centered() // to put it in the middle of the screen
        .build() // to create the window
        .expect("Failed to create window");

      let mut canvas = window.into_canvas()
        .target_texture()
        .present_vsync() // To enable v-sync.
        .build()
        .expect("Couldn't get window's canvas");

      let texture_creator: TextureCreator<_> =  
       canvas.texture_creator();
       // To make things easier to read, we'll create a constant 
          which will be the texture's size.
      const TEXTURE_SIZE: u32 = 32;

      // We create a texture with a 32x32 size.
      let mut square_texture: Texture =
        texture_creator.create_texture_target(None, TEXTURE_SIZE,
            TEXTURE_SIZE)
          .expect("Failed to create a texture");

      // We use the canvas to draw into our square texture.
      canvas.with_texture_canvas(&mut square_texture, |texture| {
        // We set the draw color to green.
        texture.set_draw_color(Color::RGB(0, 255, 0));
        // We "clear" our texture so it'll be fulfilled with green.
        texture.clear();
      }).expect("Failed to color a texture");

      // First we get the event handler:
      let mut event_pump = sdl_context.event_pump().expect("Failed 
        to get SDL event pump");

      // Then we create an infinite loop to loop over events:
      'running: loop {
        for event in event_pump.poll_iter() {
          match event {
          // If we receive a 'quit' event or if the user press the
              'ESC' key, we quit.
          Event::Quit { .. } |
          Event::KeyDown { keycode: Some(Keycode::Escape), .. } => {
              break 'running // We "break" the infinite loop.
          },
          _ => {}
        }
      }

      // We set fulfill our window with red.
      canvas.set_draw_color(Color::RGB(255, 0, 0));
      // We draw it.
      canvas.clear();
      // Copy our texture into the window.
      canvas.copy(&square_texture,
        None,
        // We copy it at the top-left of the window with a 32x32 size.
        Rect::new(0, 0, TEXTURE_SIZE, TEXTURE_SIZE))
        .expect("Couldn't copy texture into window");
        // We update window's display.
        canvas.present();

        // We sleep enough to get ~60 fps. If we don't call this, 
           the program will take
        // 100% of a CPU time.
        sleep(Duration::new(0, 1_000_000_000u32 / 60));
      }
    }

如果你运行这段代码,你应该有一个红色的窗口,在左上角有一个小绿色的矩形(正如以下截图所示):

图 2.5图 2.5

现在,关于每秒切换我们小矩形的颜色,首先,我们需要创建另一个矩形。为了使事情更简单,我们将编写一个小的函数来创建纹理。

如往常一样,在文件顶部添加以下导入:

    use sdl2::video::{Window, WindowContext};

为了方便,我们将创建一个小枚举来指示颜色:

    #[derive(Clone, Copy)]
    enum TextureColor {
      Green,
      Blue,
    }

为了让我们的生活更简单,我们将在下一个函数外部处理错误,因此在这里无需直接处理它们:

    fn create_texture_rect<'a>(canvas: &mut Canvas<Window>,
       texture_creator: &'a TextureCreator<WindowContext>,
       color: TextureColor, size: u32) -> Option<Texture<'a>> {
       // We'll want to handle failures outside of this function.
      if let Ok(mut square_texture) =
         texture_creator.create_texture_target(None, size, size) {
           canvas.with_texture_canvas(&mut square_texture, |texture| {
             match color {
                TextureColor::Green => 
                  texture.set_draw_color(Color::RGB(0, 255, 0)),
                TextureColor::Blue => 
                  texture.set_draw_color(Color::RGB(0, 0, 255)),
             }
             texture.clear();
           }).expect("Failed to color a texture");
            Some(square_texture)
         } else {
             None
           }
       }

你会注意到该函数返回一个包裹纹理的 Option 类型。Option 是一个包含两个变体的枚举:SomeNone

玩转选项

简要解释一下它是如何工作的,当 Option 类型是 Some 时,它仅仅意味着它包含一个值,而 None 则不包含。这已经在 第一章 的 Rust 基础知识 中解释过了,但这里有一个简短的回顾以防万一你需要它。我们可以将这种机制与 C 语言类似的指针进行比较;当指针为空时,没有数据可以访问。对于 None 也是如此。

这里有一个简短的例子:

    fn divide(nb: u32, divider: u32) -> Option<u32> {
      if divider == 0 {
        None
      } else {
          Some(nb / divider)
        }
    }  

因此,在这里,如果分隔符是 0,我们无法进行分割,否则会出错。与其设置错误或返回一个复杂的数据类型,我们只需返回一个 Option

    let x = divide(10, 3);
    let y = divide(10, 0);

在这里,x 等于 Some(3),而 y 等于 None

null 相比,这种类型最大的优点是,如果我们有 Some,你就可以确信数据是有效的。此外,当它是 None 时,你无法意外地读取其内容,在 Rust 中这是不可能的(如果你尝试 unwrap 它,你的程序将立即崩溃,但至少,你会知道是什么失败了以及为什么——没有神奇的段错误)。

你可以查看其文档doc.rust-lang.org/std/option/enum.Option.html

让我们解释一下这里发生了什么:

  1. 如果创建失败,我们创建纹理或返回 None

  2. 我们设置颜色,然后用它填充纹理。

  3. 我们返回纹理。

如果我们返回 None,这仅仅意味着发生了错误。目前,这个函数只处理两种颜色,但如果你想添加更多,那也很容易。

目前它可能看起来有点复杂,但之后它会让我们更容易生活。现在,让我们通过创建一个 32x32 像素的蓝色正方形来调用这个函数:

    let mut blue_square = create_texture_rect(&mut canvas,
        &texture_creator,
        TextureColor::Blue,
        TEXTURE_SIZE).expect("Failed to create a texture");

简单,对吧?

现在我们可以将各个部分组合起来。我会让你尝试处理颜色切换。一个小提示:看看 SystemTime 结构体。你可以参考其文档doc.rust-lang.org/stable/std/time/struct.SystemTime.html

解决方案

我想你没有遇到任何问题,但无论如何,这里是有问题的代码:

    extern crate sdl2;

    use sdl2::event::Event;
    use sdl2::keyboard::Keycode;
    use sdl2::pixels::Color;
    use sdl2::rect::Rect;
    use sdl2::render::{Canvas, Texture, TextureCreator};
    use sdl2::video::{Window, WindowContext};

    use std::thread::sleep;
    use std::time::{Duration, SystemTime};

    // To make things easier to read, we'll create a constant which
       will be the texture's size.
    const TEXTURE_SIZE: u32 = 32;

    #[derive(Clone, Copy)]
    enum TextureColor {
      Green,
      Blue,
   }

   fn create_texture_rect<'a>(canvas: &mut Canvas<Window>,
     texture_creator: &'a TextureCreator<WindowContext>,
     color: TextureColor,
     size: u32) -> Option<Texture<'a>> {
      // We'll want to handle failures outside of this function.
    if let Ok(mut square_texture) =
      texture_creator.create_texture_target(None, size, size) {
        canvas.with_texture_canvas(&mut square_texture, |texture| {
          match color {
            // For now, TextureColor only handles two colors.
            TextureColor::Green => texture.set_draw_color(Color::RGB(0,
                255, 0)),
            TextureColor::Blue => texture.set_draw_color(Color::RGB(0,
                0, 255)),
          }
          texture.clear();
        }).expect("Failed to color a texture");
        Some(square_texture)
      } 
      else {
       // An error occured so we return nothing and let the function
           caller handle the error.
       None
      }
    }

    fn main() {
      let sdl_context = sdl2::init().expect("SDL initialization  
       failed");
      let video_subsystem = sdl_context.video().expect("Couldn't get 
          SDL video subsystem");

      // Parameters are: title, width, height
      let window = video_subsystem.window("Tetris", 800, 600)
        .position_centered() // to put it in the middle of the screen
        .build() // to create the window
        .expect("Failed to create window");

      let mut canvas = window.into_canvas()
        .target_texture()
        .present_vsync() // To enable v-sync.
        .build()
        .expect("Couldn't get window's canvas");

      let texture_creator: TextureCreator<_> =  
       canvas.texture_creator();

      // We create a texture with a 32x32 size.
      let green_square = create_texture_rect(&mut canvas,
         &texture_creator,
         TextureColor::Green,
         TEXTURE_SIZE).expect("Failed to create a texture");
      let blue_square = create_texture_rect(&mut canvas,
          &texture_creator,
          TextureColor::Blue,
          TEXTURE_SIZE).expect("Failed to create a texture");

      let timer = SystemTime::now();

      // First we get the event handler:
      let mut event_pump = sdl_context.event_pump().expect("Failed
         to get SDL event pump");

      // Then we create an infinite loop to loop over events:
      'running: loop {
        for event in event_pump.poll_iter() {
          match event {
             // If we receive a 'quit' event or if the user press the
                    'ESC' key, we quit.
             Event::Quit { .. } |
             Event::KeyDown { keycode: Some(Keycode::Escape), .. } => {
                break 'running // We "break" the infinite loop.
             },
             _ => {}
          }
        }

        // We fill our window with red.
        canvas.set_draw_color(Color::RGB(255, 0, 0));
        // We draw it.
        canvas.clear();

        // The rectangle switch happens here:
        let display_green = match timer.elapsed() {
            Ok(elapsed) => elapsed.as_secs() % 2 == 0,
            Err(_) => {
                // In case of error, we do nothing...
                true
            }
        };
        let square_texture = if display_green {
            &green_square
        } else {
            &blue_square
        };
        // Copy our texture into the window.
        canvas.copy(square_texture,
           None,
            // We copy it at the top-left of the window with a 32x32  
               size.
            Rect::new(0, 0, TEXTURE_SIZE, TEXTURE_SIZE))
            .expect("Couldn't copy texture into window");
           // We update window's display.
           canvas.present();

        // We sleep enough to get ~60 fps. If we don't call this, 
            the program will take
        // 100% of a CPU time.
        sleep(Duration::new(0, 1_000_000_000u32 / 60));
      }
    }

现在,你可以看到左上角的小矩形每秒切换颜色。

加载图像

到目前为止,我们只创建了简单的纹理,但如果我们加载图像会怎样呢?

在尝试进行此操作之前,检查你是否已经安装了SDL2_image库(它默认不包括在 SDL2 库中!)如果没有,你可以通过接下来的部分进行安装。

在 Mac 上安装 SDL2_image

只需运行以下命令:

$ brew install SDL2_image

现在你就可以开始了!

在 Linux 上安装 SDL2_image

根据你的包管理工具,在 Linux 上运行以下命令来安装SDL2_image

对于apt 包管理器,使用以下命令:

 $ sudo apt-get install libsdl2-image-2.0-0-dev

对于dnf 包管理器,使用以下命令:

 $ sudo dnf install SDL2_image-devel

对于yum 包管理器,使用以下命令:

 $ yum install SDL2_image-devel

现在你就可以开始了!

在 Windows 上安装 SDL2_image

对于 Windows 平台,最简单的方法是访问www.libsdl.org/projects/SDL_image/并下载它。

玩转功能

默认情况下,你不能使用image模块与sdl2一起,我们需要激活它。为此,我们需要更新我们的Cargo.toml文件,添加一个新的部分,如下所示:

    [features]
    default = ["sdl2/image"]

default表示默认情况下,以下功能("sdl2/image")将被启用。现在,让我们解释一下"sdl2/image"的含义;sdl2指的是我们想要启用功能的 crate,而image是我们想要启用的功能。

当然,如果你想在一个当前项目中启用一个功能,你不需要sdl2/部分。考虑以下示例:

    [features]
    network = []
    default = ["network"]

如我肯定你已经理解的那样,链式激活功能甚至一次激活多个功能是完全可能的!如果你想根据版本号启用功能,例如:

    [features]
    network_v1 = []
    network_v2 = ["network_v1"]
    network_v3 = ["network_v2"]
    v1 = ["network_v1"]
    v2 = ["v1", "network_v2"]
    v3 = ["v2", "network_v3"]

所以如果你启用v3功能,所有其他功能也将被激活!当你需要同时处理多个版本时,这可能非常有用。

现在让我们回到我们的图像。

玩转图像

就像纹理一样,我们需要初始化图像上下文。现在我们已经激活了image功能,我们可以调用链接的函数并导入它们。让我们添加一些新的导入:

    use sdl2::image::{LoadTexture, INIT_PNG, INIT_JPG};

然后我们创建图像上下文:

    sdl2::image::init(INIT_PNG | INIT_JPG).expect("Couldn't initialize
         image context");

现在上下文已经初始化,让我们实际加载图像:

    let image_texture =  
     texture_creator.load_texture("assets/my_image.png")
         .expect("Couldn't load image");

对前面代码的一些解释:

load_texture函数接受一个文件路径作为参数。对路径要非常小心,尤其是当它们是相对路径时!

之后,它就像我们处理其他纹理一样。让我们把我们的图像放入窗口的背景中:

    canvas.copy(&Image_texture, None, None).expect("Render failed");

总结一下,现在你的项目文件夹应该看起来像这样:

|- your_project/
    |
    |- Cargo.toml
    |- src/
    |   |
    |   |- main.rs
    |- assets/
        |
        |- my_image.png

就这样!

这里是完整的代码,以防你错过了某个步骤:

    extern crate sdl2;
    use sdl2::pixels::Color;
    use sdl2::event::Event;
    use sdl2::keyboard::Keycode;
    use sdl2::render::TextureCreator;
    use sdl2::image::{LoadTexture, INIT_PNG, INIT_JPG};
    use std::time::Duration;

    pub fn main() {
      let sdl_context = sdl2::init().expect("SDL initialization 
       failed");
      let video_subsystem = sdl_context.video().expect("Couldn't 
         get SDL video subsystem");

      sdl2::image::init(INIT_PNG | INIT_JPG).expect("Couldn't  
      initialize
        image context");

      let window = video_subsystem.window("rust-sdl2 image demo", 800,  
        600)
        .position_centered()
        .opengl()
        .build()
        .expect("Failed to create window");

      let mut canvas = window.into_canvas().build().expect("Failed to 
        convert window into canvas");

      let texture_creator: TextureCreator<_> = 
       canvas.texture_creator();
      let image_texture = 
        texture_creator.load_texture("assets/my_image.png")
         .expect("Couldn't load image");

      let mut event_pump = sdl_context.event_pump().expect("Failed to
         get SDL event pump");

     'running: loop {
        for event in event_pump.poll_iter() {
            match event {
                Event::Quit { .. } |
                Event::KeyDown { keycode: Some(Keycode::Escape), .. } 
             => {
                    break 'running
                },
                _ => {}
            }
        }
        canvas.set_draw_color(Color::RGB(0, 0, 0));
        canvas.clear();
        canvas.copy(&image_texture, None, None).expect("Render 
         failed");
        canvas.present();
        ::std::thread::sleep(Duration::new(0, 1_000_000_000u32 / 60));
     }
   }

在我的情况下,它给出了以下输出:

图 2.6图 2.6

现在我们知道了如何制作 Windows 和玩转事件以及纹理,让我们看看如何从文件中保存和加载高分!

处理文件

让我们从基础开始。首先,让我们打开并写入一个文件:

    use std::fs::File;
    use std::io::{self, Write};

    fn write_into_file(content: &str, file_name: &str) -> io::Result<()> {
      let mut f = File::create(file_name)?;
      f.write_all(content.as_bytes())
    }

现在我们来解释这段代码:

    use std::fs::File;

没有什么花哨的,我们只是导入File类型:

   use std::io::{self, Write};

这组导入更有趣:我们导入io模块(self)和Write特质。对于第二个,如果我们没有导入它,我们就无法使用write_all方法(因为你需要导入一个特质来使用它的方法):

   fn write_into_file(content: &str, file_name: &str) -> io::Result<()> {

我们声明了一个名为write_into_file的函数,它接受一个文件名和要写入文件的内容作为参数。(注意,文件将被此内容覆盖!)它返回一个io::Result类型。它是对正常Result类型的别名(其文档在doc.rust-lang.org/stable/std/result/enum.Result.html)声明如下:

    type Result<T> = Result<T, Error>;

唯一的不同之处在于,在出错的情况下,错误类型已经定义。

我建议你查看其文档,以防你想进一步了解,在doc.rust-lang.org/stable/std/io/type.Result.html

所以如果我们的函数在没有错误的情况下工作,它将返回Ok(());这是包含空元组的Ok变体,被认为是 Rust 中void类型的等价物。在出错的情况下,它将包含一个io::Error,处理它(或不处理)将取决于你。我们稍后会回到错误处理。

现在我们来看下一行:

    let mut f = File::create(file_name)?;

这里,我们调用File类型的静态方法create。如果文件存在,它将被截断;如果不存在,它将被创建。关于这个方法的更多信息可以在doc.rust-lang.org/stable/std/fs/struct.File.html#method.create找到。

现在我们来看这个奇怪的?符号。它是try!宏的语法糖。try!宏非常容易理解,其代码可以总结如下:

    match result {
      Ok(value) => value,
     Err(error) => return Err(error),
    }

所以这很简单,但反复重写很烦人,所以 Rust 团队决定首先引入try!宏,然后在经过长时间的共识后,决定在它上面添加?语法糖(它也适用于Option类型)。然而,这两个代码片段仍然有效,所以你可以做得很好:

    use std::fs::File;
    use std::io::{self, Write};

    fn write_into_file(content: &str, file_name: &str) -> 
    io::Result<()> {
      let mut f = try!(File::create(file_name));
      f.write_all(content.as_bytes())
    }

它完全一样。或者,你也可以写完整的版本:

    use std::fs::File;
    use std::io::{self, Write};

   fn write_into_file(content: &str, file_name: &str) -> io::Result<()>   
   {
     let mut f = match File::create(file_name) {
        Ok(value) => value,
        Err(error) => return Err(error),
     };
     f.write_all(content.as_bytes())
   }

这取决于你,但现在你知道你有哪些选项了!

现在我们来检查最后一行:

    f.write_all(content.as_bytes())

这里没有什么特别的地方;我们将所有数据写入文件。我们只需要将我们的&str转换为u8切片(所以是&[u8])(在这种情况下,这并不是真正的转换,更像是获取内部数据)。

现在我们有了写文件的函数,能够从文件中读取也很好:

    use std::fs::File;
    use std::io::{self, Read};

    fn read_from_file(file_name: &str) -> io::Result<String> {
      let mut f = File::open(file_name)?;
      let mut content = String::new();
      f.read_to_string(&mut content)?;
      Ok(content)
    }

现在我们快速了解一下这个函数的功能:

    fn read_from_file(file_name: &str) -> io::Result<String> {

这次,它只接受一个文件名作为参数,如果读取成功,则返回一个String

    let mut f = File::open(file_name)?;
    let mut content = String::new();
    f.read_to_string(&mut content)?;

就像之前一样,我们打开文件。然后我们创建一个可变的String,其中将存储文件内容,最后我们使用read_to_string方法一次性读取所有文件内容(String根据需要重新分配)。如果字符串不是正确的 UTF-8,这个方法将失败。

最后,如果一切顺利,我们返回我们的内容:

    Ok(content)

那么,现在让我们看看我们如何在未来的俄罗斯方块中使用它。

保存/加载高分

为了保持简单,我们将有一个非常简单的文件格式:

  • 在第一行,我们存储最佳分数

  • 在第二行,我们存储最高行数

让我们先从编写save函数开始:

    fn slice_to_string(slice: &[u32]) -> String {
      slice.iter().map(|highscore| highscore.to_string()).
        collect::<Vec<String>>().join(" ")
    }

    fn save_highscores_and_lines(highscores: &[u32], 
       number_of_lines: &[u32]) -> bool {
      let s_highscores = slice_to_string(highscores);
      let s_number_of_lines = slice_to_string(number_of_lines);
      write_into_file(format!("{}\n{}\n", s_highscores, 
         s_number_of_lines)).is_ok()
    }

这其实是一个小谎言:实际上有两个函数。第一个函数只是在这里使代码更小、更容易阅读,尽管我们需要解释它所做的工作,因为我们即将讨论 Rust 的一个大特性——迭代器

迭代器

Rust 文档将迭代器描述为可组合的外部迭代器

它们在 Rust 代码中用于集合类型(sliceVecHashMap等)非常普遍,因此学习掌握它们非常重要。这段代码将为我们提供一个很好的入门。现在让我们看看代码:

    slice.iter().map(|highscore| highscore.to_string()).
      collect::<Vec<String>>().join(" ")

目前这个代码很难读和理解,所以让我们按照以下方式重写它:

    slice.iter()
       .map(|highscore| highscore.to_string())
       .collect::<Vec<String>>()
       .join(" ")

更好(或者至少更易读!)现在让我们一步一步地来做,如下所示:

     slice.iter()

在这里,我们从切片创建一个迭代器。关于 Rust 中的迭代器有一个非常重要和基本的事情需要注意;它们是惰性的。创建迭代器并不需要比类型大小更多的开销(通常是一个包含指针和索引的结构)。直到调用next()方法之前,什么都不会发生。

现在我们有一个迭代器,太棒了!让我们检查下一步:

    .map(|highscore| highscore.to_string())

我们调用迭代器的map方法。它所做的是简单的:将当前类型转换为另一种类型。所以在这里,我们将u32转换为String

非常重要的是要注意:在这个阶段,迭代器还没有做任何事情。请记住,直到调用next()方法之前,什么都不会发生!

    .collect::<Vec<String>>()

现在我们调用collect()方法。只要迭代器没有获取到所有元素并将它们存储到Vec中,它就会调用迭代器的next()方法。这就是map()方法将在迭代器的每个元素上被调用的地方。

最后,最后一步:

    .join(" ")

这个方法(正如其名称所示)将Vec的所有元素连接成一个由给定&str(在我们的例子中是" ")分隔的String

最后,如果我们给slice_to_string函数传递&[1, 14, 5],它将返回一个包含"1 14 5"String。非常方便,对吧?

如果你想对迭代器有更深入的了解,可以查看博客文章 blog.guillaume-gomez.fr/articles/2017-03-09+Little+tour+of+multiple+iterators+implementation+in+Rust,或者直接查看迭代器官方文档 doc.rust-lang.org/stable/std/iter/index.html

现在是时候回到我们的保存函数了:

    fn save_highscores_and_lines(highscores: &[u32], 
        number_of_lines: &[u32]) -> bool {
      let s_highscores = slice_to_string(highscores);
      let s_number_of_lines = slice_to_string(number_of_lines);
      write_into_file(format!("{}\n{}\n", s_highscores, 
         s_number_of_lines), "scores.txt").is_ok()
    }

一旦我们将我们的切片转换为 String,我们就将它们写入 scores.txt 文件。is_ok() 方法调用只是通知 save_highscores_and_lines() 函数是否已按预期保存一切。

现在我们能够保存分数,当俄罗斯方块游戏开始时能够获取它们会很好!

从文件中读取格式化数据

如你此刻肯定已经猜到的,我们还会再次使用迭代器。这就是加载函数的样子:

fn line_to_slice(line: &str) -> Vec<u32> {
    line.split(" ").filter_map(|nb| nb.parse::<u32>().ok()).collect()
}

fn load_highscores_and_lines() -> Option<(Vec<u32>, Vec<u32>)> {
    if let Ok(content) = read_from_file("scores.txt") {
        let mut lines = content.splitn(2, "\n").map(|line| 
           line_to_slice(line)).collect::<Vec<_>>();
        if lines.len() == 2 {
            let (number_lines, highscores) = (lines.pop().unwrap(), 
             lines.pop().unwrap());
            Some((highscores, number_lines))
        } else {
         None
        }
    } else {
        None
    }
}

同样,一开始并不容易理解。所以让我们来解释一下所有这些!

fn line_to_slice(line: &str) -> Vec<u32> {

我们的 line_to_slice() 函数是 slice_to_string() 的反操作;它将 &str 转换为 u32 的切片(或 &[u32])。现在让我们看看迭代器:

    line.split(" ").filter_map(|nb| nb.parse::<u32>().ok()).collect()

就像上次一样,让我们分解一下调用:

    line.split(" ")
      .filter_map(|nb| nb.parse::<u32>().ok())
      .collect()

稍微好一点!现在让我们来解释一下:

     line.split(" ")

我们创建一个迭代器,它将包含所有空格之间的字符串。所以 a b 将包含 ab

    .filter_map(|nb| nb.parse::<u32>().ok())

这种方法特别有趣,因为它融合了两种其他方法:filter()map()。我们已经知道了 map(),那么 filter() 呢?如果条件没有得到验证(也就是说,如果闭包返回的值是 false),迭代器不会将值传递给下一个方法调用。在这个点上,filter_map() 的工作方式相同:如果闭包返回 None,则值不会传递给下一个方法调用。

现在,让我们专注于这部分:

    nb.parse::<u32>().ok()

在这里,我们尝试将 &str 转换为 u32parse() 方法返回一个 Result,但 filter_map() 期望一个 Option,因此我们需要进行转换。这就是 ok() 方法的作用!如果你的 ResultOk(value),那么它将转换为 Some(value)。然而,如果它是 Err(err),它将转换为 None(但你会丢失错误值)。

总结一下,这一行尝试将 &str 转换为数字,如果转换失败则忽略它,所以它不会被添加到我们的最终 Vec 中。代码如此之小,我们却能做这么多的事情真是令人惊讶!

最后:

    .collect()

我们将所有成功的转换收集到一个 Vec 中并返回它。

这个函数就到这里,现在让我们看看另一个:

    fn load_highscores_and_lines() -> Option<(Vec<u32>, Vec<u32>)> {

在这里,如果一切顺利(如果文件存在并且有两行),我们将返回一个 Option,其中第一个位置包含最高分数,第二个位置包含最高行数:

    if let Ok(content) = read_from_file("scores.txt") {

因此,如果文件存在并且我们可以获取其内容,我们将解析数据:

    let mut lines = content.splitn(2, "\n").map(|line| 
       line_to_slice(line)).collect::<Vec<_>>();

另一个迭代器!像往常一样,让我们稍微重写一下:

    let mut lines = content.splitn(2, "\n")
         .map(|line| line_to_slice(line))
         .collect::<Vec<_>>();

我想你已经开始了解它们是如何工作的了,但以防你不知道,这里是如何做的:

    content.splitn(2, "\n")

我们创建一个最多包含两个条目的迭代器(因为第一个参数是2),分割行:

    .map(|line| line_to_slice(line))

我们通过使用前面代码中描述的函数将每一行转换成Vec<u32>

    .collect::<Vec<_>>();

最后,我们将这些Vec收集到一个Vec<Vec<u32>>中,它应该只包含两个条目。

现在我们来看下一行:

    if lines.len() == 2 {

如前所述,如果我们Vec中没有两个条目,这意味着文件有问题:

    let (number_lines, highscores) = (lines.pop().unwrap(), 
        lines.pop().unwrap());

如果我们的Vec有两个条目,我们可以获取相应的值。由于pop方法移除了Vec的最后一个条目,所以我们以相反的顺序获取它们(即使我们首先返回高分然后是最高行数):

    Some((highscores, number_lines))

然后其余的只是错误处理。正如我们之前所说的,如果发生任何错误,我们返回None。在这种情况下,处理错误并不是很重要,因为这只是高分。如果我们有sdl库的错误,什么都不会按预期工作,所以我们需要处理它们以避免恐慌。

现在是真正开始游戏的时候了!

概述

在本章中,我们看到了很多重要的事情,比如如何使用Cargo(通过Cargo.toml文件),如何通过Cargo将新的 crate 导入到项目中,以及 Rust 模块处理的基础。我们甚至涵盖了如何使用迭代器、读取和写入文件,SDL2的基础,比如如何创建窗口并用颜色填充它,以及加载/创建新的纹理和图像(多亏了SDL2-image库!)。

在第三章,“事件和基本游戏机制”中,我们将开始实现俄罗斯方块游戏,所以请确保在开始下一章之前掌握本章中解释的所有内容!

第三章:事件和基本游戏机制

在上一章中,我们看到了如何通过 CargoSDL2 库的基础知识将依赖项添加到项目中。

现在我们有了编写俄罗斯方块游戏所需的 Rust 基础知识。现在是时候看看我们如何实际编写俄罗斯方块了。

在本章中,我们将涵盖以下主题:

  • Tetrimino

  • 创建 tetriminos

  • 生成 tetrimino

  • Tetris 结构体

  • 与游戏地图交互

  • SDL 事件

  • 得分、等级、送出的行数

编写俄罗斯方块

首先,让我们回顾一下俄罗斯方块的规则(以防万一):

  • 有一个高度为 16 块、宽度为 10 块的网格。

  • 你有七种不同的 tetrimino(俄罗斯方块部件),它们都是由四个块组成的。

  • 每当上一个 tetrimino 无法再下降时(因为下面的块已经被占据,或者因为你已经到达了游戏的地板),游戏中的网格顶部就会出现一个新的 tetrimino

  • 当一个新的 tetrimino 无法再出现时(因为网格顶部已经有了一个 tetrimino),游戏结束。

  • 每当一行 (所有块都被 tetrimino 部分占据)时,它就会消失,并且上面的所有行都会下降一行。

既然我们都同意了游戏规则,让我们看看如何实际编写这些机制。

首先,我们需要实际创建那些 tetriminos。

Tetrimino

如前所述,每个 tetrimino 有四个块。另一个需要注意的事情是它们可以旋转。所以例如,你有一个这样的 tetrimino

图 3.1

它还可以在以下三个位置旋转:

图 3.2

理论上,每个 tetrimino 都应该有四个状态,但现实中并非所有 tetrimino 都是这样。例如,这个 tetrimino 没有任何变换:

图 3.3

这三个只有两种状态:

图 3.4

我们有两种处理这些旋转的方法:使用矩阵旋转或存储不同的状态。为了使代码易于阅读和更新,我选择了第二种方法,但请不要犹豫,尝试自己使用矩阵,这可能会帮助你学到很多新东西!

因此,首先,让我们为 tetriminos 编写一个 struct

struct Tetrimino {
    states: Vec<Vec<Vec<u8>>>,
    x: isize,
    y: usize,
    current_state: u8,
}

除了这一行之外,一切看起来都很正常:

states: Vec<Vec<Vec<u8>>>,

真的很丑,对吧?让我们通过使用类型别名来让它看起来更好一些!

那么,我们的 states 字段代表什么呢?简单来说,是一个状态列表。每个状态代表一个部件的变换。我想这有点难以理解。让我们写一个例子:

vec![vec![1, 1, 0, 0],
     vec![1, 1, 0, 0],
     vec![0, 0, 0, 0],
     vec![0, 0, 0, 0]]

在这里,0 表示该块为空,否则,它是一个 tetrimino 块。所以从阅读这段代码来看,我猜你可以猜出我们正在表示正方形:

图 3.5

如果你有所疑问,我们这里有四行,每行有四个块,因为 最大tetrimino 的高度(或宽度,取决于变换)为四:

图 3.6

这不是强制性的(我们可以让它适合每个 tetrimino 的形状),但它使我们的工作更简单,所以为什么不这样做呢?

回到我们的类型别名:一个方块基本上是一个数字向量或数字向量。每次写下来都很长,所以让我们给它取一个别名如下:

type Piece = Vec<Vec<u8>>;

现在,我们可以将 states 字段声明重写如下:

states: Vec<Piece>,

更好且更明确,对吧?但是既然我们也会使用这些状态,为什么不也给它们取别名呢?

type States = Vec<Piece>;

现在我们的 states 字段声明变成了:

states: States,

让我们解释一下其他字段(以防万一):

struct Tetrimino {
    states: States,
    x: isize,
    y: usize,
    current_state: u8,
}

对这个结构体的一点点解释:

  • states(如果你还没有理解)是 tetrimino 可能的状态列表

  • xtetriminox 位置

  • ytetriminoy 位置

  • current_statetetrimino 当前所在的状态

好的,到目前为止一切顺利。现在我们如何泛型地处理这个类型的创建呢?我们不想为每个 tetrimino 重新编写这个。这就是 traits 发挥作用的地方!

创建 tetrimino

我们编写了将在我们的游戏中使用的类型,但我们还没有编写它的初始化/创建。这就是 Rust trait 将会派上用场的地方。

让我们先写一个生成器 trait,它将在所有的 tetrimino 上实现:

trait TetriminoGenerator {
    fn new() -> Tetrimino;
}

就这样。这个 trait 只提供了一个创建新的 Tetrimino 实例的函数。它可能不太喜欢这样,但多亏了这个 trait,我们将能够轻松地创建所有的 tetrimino

是时候写我们的第一个 tetrimino 了:

struct TetriminoI;

没有必要寻找更多的代码,这就是 tetrimino 真正的样子。它是一个空的结构体。有趣的部分就在后面:

impl TetriminoGenerator for TetriminoI {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![1, 1, 1, 1],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 1, 0, 0],
                              vec![0, 1, 0, 0],
                              vec![0, 1, 0, 0],
                              vec![0, 1, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

这是:

图 3.7

在这里,一个数字代表一种颜色,0 表示没有颜色(因为没有方块)。

就这样。现在你只需调用以下命令就可以创建这个 tetrimino

let tetrimino = TetriminoI::new();

它将返回 Tetrimino 结构体的一个实例,这就是你将在游戏中使用的实例。其他 tetrimino 结构体(如这里的 TetriminoI)只是用来泛型地创建带有相关信息的 Tetrimino 结构体。

现在,我们需要创建所有其他的 tetrimino,让我们来做这件事:

struct TetriminoJ;

impl TetriminoGenerator for TetriminoJ {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![2, 2, 2, 0],
                              vec![2, 0, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![2, 2, 0, 0],
                              vec![0, 2, 0, 0],
                              vec![0, 2, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 0, 2, 0],
                              vec![2, 2, 2, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![2, 0, 0, 0],
                              vec![2, 0, 0, 0],
                              vec![2, 2, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

如果你想知道为什么方块有 2 这个值,那只是为了在显示时能够区分它们(如果所有的 tetrimino 都有相同的颜色,看起来会很丑...)。它没有其他含义。

这个 tetrimino 看起来是这样的:

图 3.8

让我们继续下一个:

struct TetriminoL;

impl TetriminoGenerator for TetriminoL {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![3, 3, 3, 0],
                              vec![0, 0, 3, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 3, 0, 0],
                              vec![0, 3, 0, 0],
                              vec![3, 3, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![3, 0, 0, 0],
                              vec![3, 3, 3, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![3, 3, 0, 0],
                              vec![3, 0, 0, 0],
                              vec![3, 0, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

这个 tetrimino 看起来是这样的:

图 3.9

另一个 tetrimino

struct TetriminoO;

impl TetriminoGenerator for TetriminoO {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![4, 4, 0, 0],
                              vec![4, 4, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 5,
            y: 0,
            current_state: 0,
        }
    }
}

这个 tetrimino 看起来是这样的:

图 3.10

另一个 tetrimino(难道它永远不会结束吗?!):

struct TetriminoS;

impl TetriminoGenerator for TetriminoS {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![0, 5, 5, 0],
                              vec![5, 5, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 5, 0, 0],
                              vec![0, 5, 5, 0],
                              vec![0, 0, 5, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

这个 tetrimino 看起来是这样的:

图 3.11

猜猜看?另一个 tetrimino

struct TetriminoZ;

impl TetriminoGenerator for TetriminoZ {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![6, 6, 0, 0],
                              vec![0, 6, 6, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 0, 6, 0],
                              vec![0, 6, 6, 0],
                              vec![0, 6, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

这个 tetrimino 看起来是这样的:

图 3.12

最后一个(终于!):

struct TetriminoT;

impl TetriminoGenerator for TetriminoT {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![7, 7, 7, 0],
                              vec![0, 7, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 7, 0, 0],
                              vec![7, 7, 0, 0],
                              vec![0, 7, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 7, 0, 0],
                              vec![7, 7, 7, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 7, 0, 0],
                              vec![0, 7, 7, 0],
                              vec![0, 7, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

最后,这个 tetrimino 看起来是这样的:

图 3.13

呼...这相当多的代码!虽然简单,但仍然很多!

现在是时候看看我们如何随机生成一个新的 tetrimino 了。

生成 tetrimino

为了做到这一点,我们需要导入另一个crate——rand。这个crate用于生成随机数,这正是我们在这里需要的。

首先,将以下行添加到你的Cargo.toml文件中(在[dependencies]部分):

rand = "0.3"

接下来,将以下行添加到你的main.rs文件中:

extern crate rand;

我们已经完成了!现在我们可以编写tetrimino的生成函数:

fn create_new_tetrimino() -> Tetrimino {
    let rand_nb = rand::random::<u8>() % 7;
    match rand_nb {
        0 => TetriminoI::new(),
        1 => TetriminoJ::new(),
        2 => TetriminoL::new(),
        3 => TetriminoO::new(),
        4 => TetriminoS::new(),
        5 => TetriminoZ::new(),
        6 => TetriminoT::new(),
        _ => unreachable!(),
    }
}

确实很简单,对吧?尽管如此,请注意,这有点太随机了。如果我们连续生成两次以上的相同tetrimino(这已经很多了),那就会有问题,所以让我们通过添加一个static变量来稍微改进这个函数:

fn create_new_tetrimino() -> Tetrimino {
    static mut PREV: u8 = 7;
    let mut rand_nb = rand::random::<u8>() % 7;
    if unsafe { PREV } == rand_nb {
        rand_nb = rand::random::<u8>() % 7;
    }
    unsafe { PREV = rand_nb; }
    match rand_nb {
        0 => TetriminoI::new(),
        1 => TetriminoJ::new(),
        2 => TetriminoL::new(),
        3 => TetriminoO::new(),
        4 => TetriminoS::new(),
        5 => TetriminoZ::new(),
        6 => TetriminoT::new(),
        _ => unreachable!(),
    }
}

这里有一些解释可能是有帮助的。首先,什么是static变量?它是一个在创建它的作用域离开后仍然保持其值的变量。一个例子:

fn foo() -> u32 {
    static mut VALUE: u32 = 12;
    unsafe {
        VALUE += 1;
        VALUE
    }
}

for _ in 0..5 {
    println!("{}", foo());
}

如果你执行这段代码,它会打印出:

13
14
15
16
17

这里是static变量的其他属性:

  • 它不能有析构函数(虽然可以通过使用lazy_static crate 来避免这种限制,但在这里我们不会讨论它),所以只能使用不实现Drop trait 的simple类型作为static

  • 改变static变量的值是不安全的(这就是为什么有unsafe块),因为static在程序的所有线程之间是共享的,并且可以同时被修改和读取

  • 读取可变static的值是不安全的(原因如前所述)

我们现在有一个可以生成tetrimino的函数。我们现在需要添加以下功能:

  • 旋转

  • 改变位置

让我们从旋转部分开始!

旋转 tetrimino

由于我们创建的Tetrimino类型,这样做相当简单:

impl Tetrimino {
    fn rotate(&mut self) {
        self.current_state += 1;
        if self.current_state as usize >= self.states.len() {
            self.current_state = 0;
        }
    }
}

我们已经完成了。然而,我们没有进行检查:如果已经有其他tetrimino使用了某个块会发生什么?我们只是简单地覆盖它。这种事情是不能接受的!

为了执行这个检查,我们需要游戏地图。它只是一个向量行,一行是一个u8的向量。或者,更简单地说:

Vec<Vec<u8>>

考虑到它并不难读,我们就保持这种方式。现在让我们编写这个方法:

fn test_position(&self, game_map: &[Vec<u8>],
                 tmp_state: usize, x: isize, y: usize) -> bool {
    for decal_y in 0..4 {
      for decal_x in 0..4 {
        let x = x + decal_x;
        if self.states[tmp_state][decal_y][decal_x as usize] != 0 
            &&
                (y + decal_y >= game_map.len() ||
                 x < 0 ||
                 x as usize >= game_map[y + decal_y].len() ||
                 game_map[y + decal_y][x as usize] != 0) {
                return false;
            }
        }
    }
    return true;
}

在解释这个函数之前,似乎有必要解释一下为什么游戏地图变成了&[Vec<u8>]。当你将一个非可变引用传递给一个向量(Vec<T>)时,它就会被解引用为一个&[T]切片,这是一个对向量内容的常量视图。

我们已经完成了(这个方法)!现在轮到解释了:我们遍历我们的tetrimino的每一个块,检查这个块在游戏地图中是否空闲(通过检查它是否等于0),并且如果没有超出游戏地图的范围。

现在我们有了test_position方法,我们可以更新rotate方法:

fn rotate(&mut self, game_map: &[Vec<u8>]) {
    let mut tmp_state = self.current_state + 1;
    if tmp_state as usize >= self.states.len() {
        tmp_state = 0;
    }
    let x_pos = [0, -1, 1, -2, 2, -3];
    for x in x_pos.iter() {
        if self.test_position(game_map, tmp_state as usize,
                              self.x + x, self.y) == true {
            self.current_state = tmp_state;
            self.x += *x;
            break
        }
    }
}

确实更长一些。由于我们无法确定这个部件会被放置在我们想要的位置,我们需要创建临时变量并检查可能性。让我们来看看代码:

let mut tmp_state = self.current_state + 1;
if tmp_state as usize >= self.states.len() {
    tmp_state = 0;
}

这正是我们的rotate方法之前所做的事情,但现在,我们在进一步之前使用临时变量:

let x_pos = [0, -1, 1, -2, 2, -3];

这一行单独来看并没有什么意义,但它将非常有用:如果我们不能把碎片放在我们想要的地方,我们尝试在x轴上移动它,看看它是否在其他地方可以工作。这使你能够拥有一个更加灵活、更易于游玩的 Tetris:

for x in x_pos.iter() {
    if self.test_position(game_map, tmp_state as usize,
                          self.x + x, self.y) == true {
        self.current_state = tmp_state;
        self.x += *x;
        break
    }
}

根据之前给出的解释,这个循环应该很容易理解。对于每个x位移,我们检查碎片是否可以放置在那里。如果它起作用,我们就改变tetrimino的值,否则我们继续。

如果没有x位移起作用,我们只是让函数什么也不做。

现在我们能够旋转和测试tetrimino的位置,实际上移动tetrimino也会很好(例如,当计时器归零且tetrimino需要下降时)。与rotate方法的主要区别将是,如果tetrimino不能移动,我们将返回一个布尔值,以便调用者知道这一点。

所以这个方法看起来是这样的:

fn change_position(&mut self, game_map: &[Vec<u8>], new_x: isize, new_y: usize) -> bool {
    if self.test_position(game_map, self.current_state as usize,  
    new_x, new_y) == true {
        self.x = new_x as isize;
        self.y = new_y;
        true
    } else {
        false
    }
}

你肯定已经注意到的另一个区别是我们不检查多个可能的位置,只检查接收到的那个。原因是简单的;与旋转不同,当tetrimino收到移动指令时,我们不能移动tetrimino。想象一下要求tetrimino向右移动,但它没有移动,或者更糟,它向左移动!我们不能允许这样的事情发生,所以我们不做这件事。

现在关于方法的代码:它非常简单。如果我们能把tetrimino放在某个位置,我们就更新tetrimino的位置并返回 true,否则,我们除了返回 false 之外什么都不做。

大部分工作都在test_position方法中完成,这使得我们的方法真正小巧。

现在我们有了这三个方法,我们几乎拥有了所有需要的东西。但为了将来的更多简洁性,让我们再添加一个:

fn test_current_position(&self, game_map: &[Vec<u8>]) -> bool {
    self.test_position(game_map, self.current_state as usize,  
    self.x, self.y)
}

当我们生成一个新的tetrimino时,我们会使用它:如果tetrimino不能放在它出现的位置,因为另一个tetrimino已经在那里,这意味着游戏结束了。

我们现在可以说,我们的Tetrimino类型已经完全实现。恭喜!现在是时候开始游戏类型了!

Tetris 结构

这个类型将包含所有游戏信息:

  • 游戏地图

  • 当前等级

  • 分数

  • 行数

  • 当前的tetrimino

  • 一些潜在的其他信息(例如幽灵或下一个tetrimino的预览!)

让我们写下这个类型:

struct Tetris {
    game_map: Vec<Vec<u8>>,
    current_level: u32,
    score: u32,
    nb_lines: u32,
    current_piece: Option<Tetrimino>,
}

再次强调,很简单。我认为不需要任何额外的信息,所以我们继续!

让我们从为这个新类型编写new方法开始:

impl Tetris {
    fn new() -> Tetris {
        let mut game_map = Vec::new();
        for _ in 0..16 {
            game_map.push(vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
        }
        Tetris {
            game_map: game_map,
            current_level: 1,
            score: 0,
            nb_lines: 0,
            current_piece: None,
        }
    }
}

除了可能是一个循环之外,没有真正复杂的东西。让我们看看它是如何工作的:

let mut game_map = Vec::new();
for _ in 0..16 {
    game_map.push(vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
}

我们知道tetris地图的宽度是 10 个方块,高度是 16 个方块。这个循环通过遍历行数来创建我们的游戏地图,生成一个 10 个方块的空向量,这将是一行。

除了这个之外,其他一切都是非常直接的:

  • 你从等级 1 开始

  • 你的得分为 0

  • 没有发送任何行

  • 当前没有 tetrimino

让我们先随机生成一个新的 tetrimino。为此,你需要 rand crate。将以下内容添加到你的 Cargo.toml 文件中:

rand = "0.3"

然后在你的 main 文件顶部添加以下内容:

extern crate rand;

然后我们可以编写这个方法:

fn create_new_tetrimino(&self) -> Tetrimino {
    static mut PREV: u8 = 7;
    let mut rand_nb = rand::random::<u8>() % 7;
    if unsafe { PREV } == rand_nb {
        rand_nb = rand::random::<u8>() % 7;
    }
    unsafe { PREV = rand_nb; }
    match rand_nb {
        0 => TetriminoI::new(),
        1 => TetriminoJ::new(),
        2 => TetriminoL::new(),
        3 => TetriminoO::new(),
        4 => TetriminoS::new(),
        5 => TetriminoZ::new(),
        6 => TetriminoT::new(),
        _ => unreachable!(),
    }
}

说明:

static mut PREV: u8 = 7;

static 关键字在 Rust 中与在 CC++ 中的变量相同:变量的值将在函数调用之间保持。所以例如,如果你编写以下函数:

fn incr() -> u32 {
    static mut NB: u32 = 0;
    unsafe {
        NB += 1;
        NB
    }
}

然后你按照以下方式调用它:

for _ in 0..5 {
    println!("{}", incr());
}

你将得到以下输出:

1
2
3
4
5

因此,现在,为什么我们有这些 unsafe 块?原因很简单:如果在不同的线程中访问和修改静态变量,你无法确保不会出现数据竞争、并发错误,甚至内存错误。

在这种情况下,因为我们没有线程,所以没问题。然而,请记住,你应该始终尽量避免使用 unsafe,并且只有在没有其他选择的情况下才使用它。

然而,如果我们的静态变量不是可变的,那么我们就不需要 unsafe 块就能访问它的值。原因再次很简单:即使多个线程同时尝试访问它的值,由于这个值不能改变,所以不可能有数据竞争,因此它是安全的。

让我们继续解释我们函数的代码:

let mut rand_nb = rand::random::<u8>() % 7;

这行代码生成一个随机的 u8 并将其值限制在 0(包含)和 6(包含)之间,因为我们有七个不同的 tetrimino

if unsafe { PREV } == rand_nb {
    rand_nb = rand::random::<u8>() % 7;
}

如果生成的 tetrimino 与上一个相同,我们将生成另一个。这可以防止你一次性出现太多的相同 tetrimino。这并不是最好的方法,为每个 tetrimino 进行特定的平衡会更好,但这个解决方案足够可行(而且更容易编写!):

unsafe { PREV = rand_nb; }

我们现在将生成的 tetrimino ID 设置到我们的 static 变量中:

match rand_nb {
    0 => TetriminoI::new(),
    1 => TetriminoJ::new(),
    2 => TetriminoL::new(),
    3 => TetriminoO::new(),
    4 => TetriminoS::new(),
    5 => TetriminoZ::new(),
    6 => TetriminoT::new(),
    _ => unreachable!(),
}

对于这种模式匹配没有太多可说的。每个 ID 都匹配一个 tetrimino,然后我们调用相应的构造函数。这个构造过程中唯一真正有趣的地方是以下这一行:

_ => unreachable!(),

这个宏非常有用。它允许我们在匹配的值上添加一层安全性。如果代码进入这个模式匹配,它将立即崩溃(因为,正如宏的名字所暗示的,这种情况不应该发生)。

与游戏地图交互

好的,我们现在可以移动所有的 tetrimino 并生成它们。还有两个机制尚未实现:检查行以查看是否可以发送(即,移除因为完成)以及使 tetrimino 永久(即,不能再移动)。

让我们从检查这一行开始:

fn check_lines(&mut self) {
    let mut y = 0;

    while y < self.game_map.len() {
        let mut complete = true;

        for x in &self.game_map[y] {
            if *x == 0 {
                complete = false;
                break
            }
        }
        if complete == true {
            self.game_map.remove(y);
            y -= 1;
            // increase the number of self.lines
        }
        y += 1;
    }
    while self.game_map.len() < 16 {
        self.game_map.insert(0, vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
    }
}

目前,我没有添加得分、发送行数的计数以及等级处理,但这里将是以后完成这些的地方。

现在是解释这个方法的时候了。它的目的是在行填满时(即,当每个方块都被 tetrimino 方块占据时)删除行。所以我们只是逐行遍历游戏地图并对每一行进行检查。

代码本身并没有使用很多 Rust 特定的语法,但你可能会想知道为什么我们这样做。我指的是这个循环:

while y < self.game_map.len() {

当我们本可以使用:

for line in self.game_map {

这实际上是一个好问题,答案很简单,但如果你习惯了 Rust 的所有权工作方式,可能难以理解。

所有的问题实际上都来自这一行:

self.game_map.remove(y);

在这里,我们为了删除一行而可变地借用了 self.game_map。然而,self.game_map 已经被 for 循环非可变地借用了!快速回顾一下借用规则是如何工作的:

  • 你可以无数次非可变地借用一个变量

  • 你只能在没有其他借用(无论是可变还是不可变)的情况下可变地借用一个变量

因此,在我们的例子中,for 循环会违反第二条规则,因为我们试图获取对 self.game_map 的可变访问时会有一个非可变的借用。

在这种情况下,我们有两个解决方案:

  • 手动遍历游戏地图(使用索引变量)

  • 将要删除的行存储到第二个向量中,然后在我们退出循环后删除它们

在这个情况下,这两种解决方案在某种程度上是等价的,所以我只是选择了第一个。

第一轮循环完成后,我们已经用空行填充了游戏地图,以替换我们删除的行:

while self.game_map.len() < 16 {
    self.game_map.insert(0, vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
}

我们已经完成了这个方法!让我们写另一个。

所以现在是我们编写 make_permanent 方法的时候了。就像之前的那个一样,它不会是一个完整的版本,但将来,这就是我们更新分数的地方(我们在 tetrimino 被永久化时更新分数)。

所以让我们来写它:

fn make_permanent(&mut self) {
    if let Some(ref mut piece) = self.current_piece {
        let mut shift_y = 0;

        while shift_y < piece.states[piece.current_state as  
         usize].len() &&
              piece.y + shift_y < self.game_map.len() {
            let mut shift_x = 0;

            while shift_x < piece.states[piece.current_state as  
             usize][shift_y].len() &&
                  (piece.x + shift_x as isize) < 
                   self.game_map[piece.y +  
                   shift_y].len() as isize {
                if piece.states[piece.current_state as usize] 
                [shift_y][shift_x] != 0 {
                    let x = piece.x + shift_x as isize;
                    self.game_map[piece.y + shift_y][x as usize] =
                        piece.states[piece.current_state as usize] 
                         [shift_y][shift_x];
                }
                shift_x += 1;
            }
            shift_y += 1;
        }
    }
    self.check_lines();
    self.current_piece = None;
}

这段代码看起来并不鼓舞人心... 准备好,解释即将到来:

if let Some(ref mut piece) = self.current_piece {

这是一个简单的模式匹配。如果 self.current_pieceSome,那么我们就进入条件,并将 Some 中包含的值绑定到 piece 变量:

while shift_y < piece.states[piece.current_state as usize].len() &&
      piece.y + shift_y < self.game_map.len() {

这个循环及其条件允许我们通过检查当前旋转(即 self.current_state)是否超出游戏地图限制来避免缓冲区溢出错误。

内部循环(遍历行的块)也是一样:

while shift_x < piece.states[piece.current_state as usize][shift_y].len() &&
      (piece.x + shift_x as isize) < self.game_map[piece.y + shift_y].len() as isize {

正是这个循环,我们将当前 tetrimino 的块写入游戏地图:

if piece.states[piece.current_state as usize][shift_y][shift_x] != 0 {
    let x = piece.x + shift_x as isize;
    self.game_map[piece.y + shift_y][x as usize] =
        piece.states[piece.current_state as usize][shift_y][shift_x];
}

如果当前 tetrimino 的当前块不为空,我们就将其放入游戏地图(就这么简单)。

完成后,这就是我们调用 check_lines 方法的位置。但现在你肯定会想知道为什么我们不在 if let 条件中直接调用它。嗯,原因和我们在 check_lines 方法中不使用 for 循环的原因一样,self 已经被下一行可变地借用了:

if let Some(ref mut piece) = self.current_piece {

对的:如果一个类型的元素被借用,那么它的父元素也会被可变借用!

使用这两种方法,我们的 Tetris 类型现在已经完全实现(除了稍后需要的小修改)。现在是时候添加 SDL 事件处理了!

SDL 事件

没有多少不同的事件需要处理:

  • 方向键用来将 tetrimino 移动到左边或右边

  • 向上箭头 键使 tetrimino 旋转

  • 向下箭头 键使 tetrimino 下降一个方块

  • 空格键 使 tetrimino 瞬间下降到最底部

  • Escape 键退出游戏

仍然可以在稍后添加一些(例如,使用 回车 键暂停游戏等),但现在,让我们专注于这些。为此,回到游戏的 main 函数中的主循环内部,并用以下函数替换当前的事件处理:

fn handle_events(tetris: &mut Tetris, quit: &mut bool, timer: &mut SystemTime,
                 event_pump: &mut sdl2::EventPump) -> bool {
    let mut make_permanent = false;
    if let Some(ref mut piece) = tetris.current_piece {
        let mut tmp_x = piece.x;
        let mut tmp_y = piece.y;

        for event in event_pump.poll_iter() {
          match event {
          Event::Quit { .. } |
          Event::KeyDown { keycode: Some(Keycode::Escape), .. } => 
                {
                    *quit = true;
                    break
                }
           Event::KeyDown { keycode: Some(Keycode::Down), .. } =>
                {
                    *timer = SystemTime::now();
                    tmp_y += 1;
                }
           Event::KeyDown { keycode: Some(Keycode::Right), .. } => 
                {
                    tmp_x += 1;
                }
            Event::KeyDown { keycode: Some(Keycode::Left), .. } => 
                {
                    tmp_x -= 1;
                }
            Event::KeyDown { keycode: Some(Keycode::Up), .. } => 
                {
                    piece.rotate(&tetris.game_map);
                }
           Event::KeyDown { keycode: Some(Keycode::Space), .. } => 
               {
                  let x = piece.x;
                  let mut y = piece.y;
           while piece.change_position(&tetris.game_map, x, y + 1) 
           == true {
                        y += 1;
                   }
                    make_permanent = true;
                }
                _ => {}
            }
        }
        if !make_permanent {
         if piece.change_position(&tetris.game_map, tmp_x, tmp_y)
           == 
            false &&
               tmp_y != piece.y {
                make_permanent = true;
            }
        }
    }
    if make_permanent {
        tetris.make_permanent();
        *timer = SystemTime::now();
    }
    make_permanent
}

这是一个相当大的函数:

let mut make_permanent = false;

这个变量将告诉我们当前的 tetrimino 是否仍在下落。如果不是,那么它变为 truetetrimino 被放入游戏地图,并生成一个新的 tetrimino。幸运的是,我们已经编写了所有执行这些操作所需的功能:

if let Some(ref mut piece) = tetris.current_piece {

这是一种简单的模式绑定。如果我们的游戏没有当前碎片(由于某种原因),那么我们不做任何事情,直接离开:

let mut tmp_x = piece.x;
let mut tmp_y = piece.y;

如果在 xy 轴上有移动,我们将将其写入这些变量,然后测试 tetrimino 是否可以真正移动到那里:

for event in event_pump.poll_iter() {

由于自上次我们进入这个函数以来可能发生了多个事件,我们需要遍历它们所有。

现在我们正到达有趣的部分:

match event {
    Event::Quit { .. } |
    Event::KeyDown { keycode: Some(Keycode::Escape), .. } => {
        *quit = true;
        break
    }
    Event::KeyDown { keycode: Some(Keycode::Down), .. } => {
        *timer = SystemTime::now();
        tmp_y += 1;
    }
    Event::KeyDown { keycode: Some(Keycode::Right), .. } => {
        tmp_x += 1;
    }
    Event::KeyDown { keycode: Some(Keycode::Left), .. } => {
        tmp_x -= 1;
    }
    Event::KeyDown { keycode: Some(Keycode::Up), .. } => {
        piece.rotate(&tetris.game_map);
    }
    Event::KeyDown { keycode: Some(Keycode::Space), .. } => {
      let x = piece.x;
      let mut y = piece.y;
      while piece.change_position(&tetris.game_map, x, y + 1) ==  
      true {
            y += 1;
        }
        make_permanent = true;
    }
    _ => {}
}

我们几乎可以将这段小代码视为我们应用程序的核心,没有它,无法与程序进行交互。如果你想添加更多交互,这就是你应该添加它们的地方:

Event::Quit { .. } |
Event::KeyDown { keycode: Some(Keycode::Escape), .. } => {
    *quit = true;
    break
}

如果我们从 sdl 接收到一个 退出 事件,或者我们接收到一个 EscapeKeyDown 事件,我们将 quit 变量设置为 true。它将在函数外部使用,以便退出主循环——从而退出程序本身。然后我们 break; 没有必要再继续,因为我们知道我们要离开游戏:

Event::KeyDown { keycode: Some(Keycode::Down), .. } => {
    *timer = SystemTime::now();
    tmp_y += 1;
}

如果按下 向下箭头,我们需要使 tetrimino 下降一个方块,并将 timer 值设置为当前时间。timer 用于知道 tetrimino 块下落的速度。时间越短,下落速度越快。

目前,这个函数中没有使用它,所以我们将看看如何在函数外部处理它:

Event::KeyDown { keycode: Some(Keycode::Right), .. } => {
    tmp_x += 1;
}
Event::KeyDown { keycode: Some(Keycode::Left), .. } => {
    tmp_x -= 1;
}

在这里,我们处理 右箭头左箭头 键。这就像 向下箭头 键一样,只是我们不需要更改 timer 变量:

Event::KeyDown { keycode: Some(Keycode::Up), .. } => {
    piece.rotate(&tetris.game_map);
}

如果我们接收到一个 向上箭头 键按下的事件,我们将旋转 tetrimino

Event::KeyDown { keycode: Some(Keycode::Space), .. } => {
   let x = piece.x;
   let mut y = piece.y;
  while piece.change_position(&tetris.game_map, x, y + 1) == true {
        y += 1;
    }
    make_permanent = true;
}

最后是我们的最后一个事件:按下 空格键 的事件。在这里,我们将 tetrimino 向下移动尽可能远,并将 make_permanent 变量设置为 true

使用这个,我们的事件就到这里了。然而,正如我们之前所说的,如果你想添加更多的事件,这就是你应该放置它们的地方。

是时候将这些放入我们的主循环中:

fn print_game_information(tetris: &Tetris) {
    println!("Game over...");
    println!("Score:           {}", tetris.score);
    // println!("Number of lines: {}", tetris.nb_lines);
    println!("Current level:   {}", tetris.current_level);
    // Check highscores here and update if needed
}

let mut tetris = Tetris::new();
let mut timer = SystemTime::now();

loop {
    if match timer.elapsed() {
        Ok(elapsed) => elapsed.as_secs() >= 1,
        Err(_) => false,
    } {
        let mut make_permanent = false;
        if let Some(ref mut piece) = tetris.current_piece {
            let x = piece.x;
            let y = piece.y + 1;
            make_permanent =  
             !piece.change_position(&tetris.game_map,  
             x, y);
        }
        if make_permanent {
            tetris.make_permanent();
        }
        timer = SystemTime::now();
    }

    // We need to draw the tetris "grid" in here.

    if tetris.current_piece.is_none() {
        let current_piece = tetris.create_new_tetrimino();
        if !current_piece.test_current_position(&tetris.game_map) {
            print_game_information(&tetris);
            break
        }
        tetris.current_piece = Some(current_piece);
    }
    let mut quit = false;
    if !handle_events(&mut tetris, &mut quit, &mut timer, &mut 
     event_pump) {
        if let Some(ref mut piece) = tetris.current_piece {
            // We need to draw our current tetrimino in here.
        }
    }
    if quit {
        print_game_information(&tetris);
        break
    }

    // We need to draw the game map in here.

    sleep(Duration::new(0, 1_000_000_000u32 / 60));
}

这看起来并不长,对吧?只是几个应该绘制我们的 Tetris 的注释,但除此之外,一切都在其中,这意味着我们的 Tetris 现在已经完全可用(即使它还没有显示)。

让我们解释一下那里发生了什么:

let mut tetris = Tetris::new();
let mut timer = SystemTime::now();

在这里,我们初始化了我们的Tetris对象和timer。计时器将用来告诉我们tetrimino应该下降一个方块的时间:

if match timer.elapsed() {
    Ok(elapsed) => elapsed.as_secs() >= 1,
    Err(_) => false,
} {
    let mut make_permanent = false;
    if let Some(ref mut piece) = tetris.current_piece {
        let x = piece.x;
        let y = piece.y + 1;
        make_permanent = !piece.change_position(&tetris.game_map,
         x, y);
    }
    if make_permanent {
        tetris.make_permanent();
    }
    timer = SystemTime::now();
}

此代码检查自上次tetrimino下降一个方块以来是否已过去一秒或更长时间。如果我们想处理级别,我们需要替换以下行:

Ok(elapsed) => elapsed.as_secs() >= 1,

其替代品需要更通用的东西,我们将添加一个数组来存储不同级别的下降速度。

所以回到代码,如果已经过去了一秒或更长时间,我们就尝试让tetrimino下降一个方块。如果它不能下降,那么我们就将其放入游戏地图并重新初始化timer变量。

再次,你可能想知道为什么我们不得不创建make_permanent变量而不是直接检查以下内容的输出:

!piece.change_position(&tetris.game_map, x, y)

它有一个if条件,对吗?好吧,就像之前几次一样,这是因为借用检查器。我们在这里借用tetris

if let Some(ref mut piece) = tetris.current_piece {

所以只要我们处于这种状态,我们就不能可变地使用tetris,这就是为什么我们将条件的结果存储在make_permanent中,这样我们就可以在之后使用make_permanent方法:

if tetris.current_piece.is_none() {
    let current_piece = tetris.create_new_tetrimino();
    if !current_piece.test_current_position(&tetris.game_map) {
        print_game_information(&tetris);
        return
    }
    tetris.current_piece = Some(current_piece);
}

如果没有当前的tetrimino,我们需要生成一个新的,我们通过调用create_new_tetrimino方法来实现。然后我们通过调用test_current_position方法检查它是否可以放在顶部行上。如果不能,那么这意味着游戏结束了,我们退出。否则,我们将新生成的tetrimino存储在tetris.current_piece中,然后继续。

这里缺少两件事:

  • 由于我们不处理发送的行数、得分或级别的增加,因此不需要打印它们

  • 我们还没有添加高分加载/覆盖

当然,我们稍后会添加所有这些:

let mut quit = false;
if !handle_events(&mut tetris, &mut quit, &mut timer, &mut event_pump) {
    if let Some(ref mut piece) = tetris.current_piece {
        // We need to draw our current tetrimino in here.
    }
}
if quit {
    print_game_information(&tetris);
    break
}

此代码调用handle_events函数并根据其输出执行操作。它返回当前tetrimino是否已放入游戏地图。如果是这样,则不需要绘制它。

我们现在需要完成以下剩余的事情:

  • 添加得分、级别和发送的行数

  • 如果需要,加载/覆盖高分

  • 实际上绘制Tetris

看起来我们离结束已经很近了!让我们先添加得分、发送的行数和级别!

得分、级别、发送的行数

最大的需求更改将是级别处理。你需要创建一个数组,包含不同的时间来增加tetrimino的下降速度,并检查级别是否需要更改(基于行数)。

在以下情况下将更新得分:

  • tetrimino被永久固定时

  • 当发送一行时

  • 当玩家完成Tetris(游戏地图中不再有方块)

让我们从最简单的更改开始——得分。

首先,让我们将以下方法添加到我们的Tetris类型中:

fn update_score(&mut self, to_add: u32) {
    self.score += to_add;
}

我们可以假设这里不需要额外的解释。

接下来,让我们更新几个方法:

fn check_lines(&mut self) {
    let mut y = 0;
    let mut score_add = 0;

    while y < self.game_map.len() {
        let mut complete = true;

        for x in &self.game_map[y] {
            if *x == 0 {
                complete = false;
                break
            }
        }
        if complete == true {
            score_add += self.current_level;
            self.game_map.remove(y);
            y -= 1;
        }
        y += 1;
    }
    if self.game_map.len() == 0 {
        // A "tetris"!
        score_add += 1000;
    }
    self.update_score(score_add);
    while self.game_map.len() < 16 {
        // we'll add this method just after!
        self.increase_line();
        self.game_map.insert(0, vec![0, 0, 0, 0, 0, 0, 0, 0, 0,
         0]);
    }
}

如往常一样,我们创建一个临时变量(这里,score_add),一旦self的借用结束,我们就调用update_score方法。还有increase_line方法的用法。我们还没有定义它;它将在之后出现。

第二个方法是make_permanent

fn make_permanent(&mut self) {
    let mut to_add = 0;
    if let Some(ref mut piece) = self.current_piece {
        let mut shift_y = 0;

        while shift_y < piece.states[piece.current_state as 
         usize].len() &&
              piece.y + shift_y < self.game_map.len() {
            let mut shift_x = 0;

            while shift_x < piece.states[piece.current_state as usize] 
             [shift_y].len() &&
                  (piece.x + shift_x as isize) < self.game_map[piece.y  
                   + shift_y].len() as isize {
                if piece.states[piece.current_state as usize][shift_y] 
                 [shift_x] != 0 {
                    let x = piece.x + shift_x as isize;
                    self.game_map[piece.y + shift_y][x as usize] =
                        piece.states[piece.current_state as usize]    
                         [shift_y][shift_x];
                }
                shift_x += 1;
            }
            shift_y += 1;
        }
        to_add += self.current_level;
    }
    self.update_score(to_add);
    self.check_lines();
    self.current_piece = None;
}

self.check_lines调用之前包含这个。

通过更新这两个方法,我们现在已经完全实现了得分处理。

发送等级和行数

下两个是紧密相连的(等级直接取决于发送的行数),我们将同时实现它们。

在做任何其他事情之前,让我们定义以下两个const

const LEVEL_TIMES: [u32; 10] = [1000, 850, 700, 600, 500, 400, 300, 250, 221, 190];
const LEVEL_LINES: [u32; 10] = [20,   40,  60,  80,  100, 120, 140, 160, 180, 200];

第一个对应于当前tetrimino下降一个方块之前的时间。每个情况对应不同的等级。

第二个对应于玩家达到下一等级之前需要消除的行数。

接下来,让我们在我们的Tetris类型中添加以下方法:

fn increase_line(&mut self) {
    self.nb_lines += 1;
    if self.nb_lines > LEVEL_LINES[self.current_level as usize - 1] {
        self.current_level += 1;
    }
}

没有什么复杂的。只是在读取LEVEL_LINES常量时要小心,因为我们的current_level变量从1开始,而不是0

接下来,我们需要更新我们确定时间是否到的方法。为了做到这一点,让我们再写一个函数:

fn is_time_over() {
    match timer.elapsed() {
        Ok(elapsed) => {
            let millis = elapsed.as_secs() as u32 * 1000 + 
             elapsed.subsec_nanos() / 1_000_000;
            millis > LEVEL_TIMES[tetris.current_level as usize - 1]
        }
        Err(_) => false,
    }
}

一个小但棘手的问题。问题是timer.elapsed返回的类型(Duration)不提供获取毫秒数的方法,所以我们需要自己获取它。

首先,我们获取经过的秒数,然后乘以 1,000(因为 1 秒=1,000 毫秒)。最后,我们获取当前秒内的纳秒数,然后除以 1,000,000(因为 1 毫秒=1 百万纳秒)。

现在我们可以比较结果,看看tetrimino是否应该下降,并返回结果:

if is_time_over(&tetris, &timer) {
    let mut make_permanent = false;
    if let Some(ref mut piece) = tetris.current_piece {
        let x = piece.x;
        let y = piece.y + 1;
        make_permanent = !piece.change_position(&tetris.game_map,
         x, y);
    }
    if make_permanent {
        tetris.make_permanent();
    }
    timer = SystemTime::now();
}

通过这个,我们已经完成了这一部分。现在让我们来做最后一个:高分加载/覆盖!

高分加载/覆盖

我们已经在上一章中看到了如何执行 I/O 操作,所以这将非常快:

const NB_HIGHSCORES: usize = 5;

fn update_vec(v: &mut Vec<u32>, value: u32) -> bool {
    if v.len() < NB_HIGHSCORES {
        v.push(value);
        v.sort();
        true
    } else {
        for entry in v.iter_mut() {
            if value > *entry {
                *entry = value;
                return true;
            }
        }
        false
    }
}

fn print_game_information(tetris: &Tetris) {
    let mut new_highest_highscore = true;
    let mut new_highest_lines_sent = true;
    if let Some((mut highscores, mut lines_sent)) = 
     load_highscores_and_lines() {
        new_highest_highscore = update_vec(&mut highscores,  
         tetris.score);
        new_highest_lines_sent = update_vec(&mut lines_sent,  
         tetris.nb_lines);
        if new_highest_highscore || new_highest_lines_sent {
            save_highscores_and_lines(&highscores, &lines_sent);
        }
    } else {
        save_highscores_and_lines(&[tetris.score], &
         [tetris.nb_lines]);
    }
    println!("Game over...");
    println!("Score:           {}{}",
             tetris.score,
             if new_highest_highscore { " [NEW HIGHSCORE]"} else {   
              "" });
    println!("Number of lines: {}{}",
             tetris.nb_lines,
             if new_highest_lines_sent { " [NEW HIGHSCORE]"} else {  
              "" });
    println!("Current level:   {}", tetris.current_level);
}

这段代码没有太多要解释的。目前,我们限制了每个高分记录的数量为5。只需按需更新即可。

通过这段代码,所有机制都已实现。剩下的只是实际绘制游戏!

这是本章的完整代码:

extern crate rand;
extern crate sdl2;

use sdl2::event::Event;
use sdl2::keyboard::Keycode;
use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, Texture, TextureCreator};
use sdl2::video::{Window, WindowContext};

use std::fs::File;
use std::io::{self, Read, Write};
use std::thread::sleep;
use std::time::{Duration, SystemTime};

const TETRIS_HEIGHT: usize = 40;
const HIGHSCORE_FILE: &'static str = "scores.txt";
const LEVEL_TIMES: [u32; 10] = [1000, 850, 700, 600, 500, 400, 300, 250, 221, 190];
const LEVEL_LINES: [u32; 10] = [20,   40,  60,  80,  100, 120, 140, 160, 180, 200];
const NB_HIGHSCORES: usize = 5;

type Piece = Vec<Vec<u8>>;
type States = Vec<Piece>;

trait TetriminoGenerator {
    fn new() -> Tetrimino;
}

struct TetriminoI;

impl TetriminoGenerator for TetriminoI {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![1, 1, 1, 1],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 1, 0, 0],
                              vec![0, 1, 0, 0],
                              vec![0, 1, 0, 0],
                              vec![0, 1, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

struct TetriminoJ;

impl TetriminoGenerator for TetriminoJ {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![2, 2, 2, 0],
                              vec![2, 0, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![2, 2, 0, 0],
                              vec![0, 2, 0, 0],
                              vec![0, 2, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 0, 2, 0],
                              vec![2, 2, 2, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![2, 0, 0, 0],
                              vec![2, 0, 0, 0],
                              vec![2, 2, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

struct TetriminoL;

impl TetriminoGenerator for TetriminoL {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![3, 3, 3, 0],
                              vec![0, 0, 3, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 3, 0, 0],
                              vec![0, 3, 0, 0],
                              vec![3, 3, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![3, 0, 0, 0],
                              vec![3, 3, 3, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![3, 3, 0, 0],
                              vec![3, 0, 0, 0],
                              vec![3, 0, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

struct TetriminoO;

impl TetriminoGenerator for TetriminoO {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![4, 4, 0, 0],
                              vec![4, 4, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 5,
            y: 0,
            current_state: 0,
        }
    }
}

struct TetriminoS;

impl TetriminoGenerator for TetriminoS {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![0, 5, 5, 0],
                              vec![5, 5, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 5, 0, 0],
                              vec![0, 5, 5, 0],
                              vec![0, 0, 5, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

struct TetriminoZ;

impl TetriminoGenerator for TetriminoZ {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![6, 6, 0, 0],
                              vec![0, 6, 6, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 0, 6, 0],
                              vec![0, 6, 6, 0],
                              vec![0, 6, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

struct TetriminoT;

impl TetriminoGenerator for TetriminoT {
    fn new() -> Tetrimino {
        Tetrimino {
            states: vec![vec![vec![7, 7, 7, 0],
                              vec![0, 7, 0, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 7, 0, 0],
                              vec![7, 7, 0, 0],
                              vec![0, 7, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 7, 0, 0],
                              vec![7, 7, 7, 0],
                              vec![0, 0, 0, 0],
                              vec![0, 0, 0, 0]],
                         vec![vec![0, 7, 0, 0],
                              vec![0, 7, 7, 0],
                              vec![0, 7, 0, 0],
                              vec![0, 0, 0, 0]]],
            x: 4,
            y: 0,
            current_state: 0,
        }
    }
}

struct Tetrimino {
    states: States,
    x: isize,
    y: usize,
    current_state: u8,
}

impl Tetrimino {
    fn rotate(&mut self, game_map: &[Vec<u8>]) {
        let mut tmp_state = self.current_state + 1;
        if tmp_state as usize >= self.states.len() {
            tmp_state = 0;
        }
        let x_pos = [0, -1, 1, -2, 2, -3];
        for x in x_pos.iter() {
            if self.test_position(game_map, tmp_state as usize,
                                  self.x + x, self.y) == true {
                self.current_state = tmp_state;
                self.x += *x;
                break
            }
        }
    }

    fn test_position(&self, game_map: &[Vec<u8>],
                     tmp_state: usize, x: isize, y: usize) -> bool {
        for shift_y in 0..4 {
            for shift_x in 0..4 {
                let x = x + shift_x;
                if self.states[tmp_state][shift_y][shift_x as usize] != 0 &&
                    (y + shift_y >= game_map.len() ||
                     x < 0 ||
                     x as usize >= game_map[y + shift_y].len() ||
                     game_map[y + shift_y][x as usize] != 0) {
                    return false;
                }
            }
        }
        return true;
    }

    fn test_current_position(&self, game_map: &[Vec<u8>]) -> bool {
        self.test_position(game_map, self.current_state as usize, self.x, self.y)
    }

    fn change_position(&mut self, game_map: &[Vec<u8>], new_x: isize, new_y: usize) -> bool {
        if self.test_position(game_map, self.current_state as usize, new_x, new_y) == true {
            self.x = new_x as isize;
            self.y = new_y;
            true
        } else {
            false
        }
    }
}

struct Tetris {
    game_map: Vec<Vec<u8>>,
    current_level: u32,
    score: u32,
    nb_lines: u32,
    current_piece: Option<Tetrimino>,
}

impl Tetris {
    fn new() -> Tetris {
        let mut game_map = Vec::new();
        for _ in 0..16 {
            game_map.push(vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
        }
        Tetris {
            game_map: game_map,
            current_level: 1,
            score: 0,
            nb_lines: 0,
            current_piece: None,
        }
    }

    fn update_score(&mut self, to_add: u32) {
        self.score += to_add;
    }

    fn increase_level(&mut self) {
        self.current_level += 1;
    }

    fn increase_line(&mut self) {
        self.nb_lines += 1;
        if self.nb_lines > LEVEL_LINES[self.current_level as usize - 1] {
            self.increase_level();
        }
    }

    fn check_lines(&mut self) {
        let mut y = 0;
        let mut score_add = 0;

        while y < self.game_map.len() {
            let mut complete = true;

            for x in &self.game_map[y] {
                if *x == 0 {
                    complete = false;
                    break
                }
            }
            if complete == true {
                score_add += self.current_level;
                self.game_map.remove(y);
                y -= 1;
            }
            y += 1;
        }
        if self.game_map.len() == 0 {
            // A "tetris"!
            score_add += 1000;
        }
        self.update_score(score_add);
        while self.game_map.len() < 16 {
            self.increase_line();
            self.game_map.insert(0, vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
        }
    }

    fn create_new_tetrimino(&self) -> Tetrimino {
        static mut PREV: u8 = 7;
        let mut rand_nb = rand::random::<u8>() % 7;
        if unsafe { PREV } == rand_nb {
            rand_nb = rand::random::<u8>() % 7;
        }
        unsafe { PREV = rand_nb; }
        match rand_nb {
            0 => TetriminoI::new(),
            1 => TetriminoJ::new(),
            2 => TetriminoL::new(),
            3 => TetriminoO::new(),
            4 => TetriminoS::new(),
            5 => TetriminoZ::new(),
            6 => TetriminoT::new(),
            _ => unreachable!(),
        }
    }

    fn make_permanent(&mut self) {
        let mut to_add = 0;
        if let Some(ref mut piece) = self.current_piece {
            let mut shift_y = 0;

            while shift_y < piece.states[piece.current_state as usize].len() &&
                  piece.y + shift_y < self.game_map.len() {
                let mut shift_x = 0;

                while shift_x < piece.states[piece.current_state as usize] 
                  [shift_y].len() &&
                      (piece.x + shift_x as isize) < self.game_map[piece.y + 
                       shift_y].len() as isize {
                    if piece.states[piece.current_state as usize][shift_y][shift_x]  
                    != 0 {
                        let x = piece.x + shift_x as isize;
                        self.game_map[piece.y + shift_y][x as usize] =
                            piece.states[piece.current_state as usize][shift_y]
                            [shift_x];
                    }
                    shift_x += 1;
                }
                shift_y += 1;
            }
            to_add += self.current_level;
        }
        self.update_score(to_add);
        self.check_lines();
        self.current_piece = None;
    }
}

fn handle_events(tetris: &mut Tetris, quit: &mut bool, timer: &mut SystemTime,
                 event_pump: &mut sdl2::EventPump) -> bool {
    let mut make_permanent = false;
    if let Some(ref mut piece) = tetris.current_piece {
        let mut tmp_x = piece.x;
        let mut tmp_y = piece.y;

        for event in event_pump.poll_iter() {
            match event {
                Event::Quit { .. } |
                Event::KeyDown { keycode: Some(Keycode::Escape), .. } => {
                    *quit = true;
                    break
                }
                Event::KeyDown { keycode: Some(Keycode::Down), .. } => {
                    *timer = SystemTime::now();
                    tmp_y += 1;
                }
                Event::KeyDown { keycode: Some(Keycode::Right), .. } => {
                    tmp_x += 1;
                }
                Event::KeyDown { keycode: Some(Keycode::Left), .. } => {
                    tmp_x -= 1;
                }
                Event::KeyDown { keycode: Some(Keycode::Up), .. } => {
                    piece.rotate(&tetris.game_map);
                }
                Event::KeyDown { keycode: Some(Keycode::Space), .. } => {
                    let x = piece.x;
                    let mut y = piece.y;
                    while piece.change_position(&tetris.game_map, x, y + 1) == true 
                    {
                        y += 1;
                    }
                    make_permanent = true;
                }
                _ => {}
            }
        }
        if !make_permanent {
            if piece.change_position(&tetris.game_map, tmp_x, tmp_y) == false &&
               tmp_y != piece.y {
                make_permanent = true;
            }
        }
    }
    if make_permanent {
        tetris.make_permanent();
        *timer = SystemTime::now();
    }
    make_permanent
}

fn write_into_file(content: &str, file_name: &str) -> io::Result<()> {
    let mut f = File::create(file_name)?;
    f.write_all(content.as_bytes())
}

fn read_from_file(file_name: &str) -> io::Result<String> {
    let mut f = File::open(file_name)?;
    let mut content = String::new();
    f.read_to_string(&mut content)?;
    Ok(content)
}

fn slice_to_string(slice: &[u32]) -> String {
    slice.iter().map(|highscore| highscore.to_string()).collect::<Vec<String>>().join(" ")
}

fn save_highscores_and_lines(highscores: &[u32], number_of_lines: &[u32]) -> bool {
    let s_highscores = slice_to_string(highscores);
    let s_number_of_lines = slice_to_string(number_of_lines);
    write_into_file(&format!("{}\n{}\n", s_highscores, s_number_of_lines), HIGHSCORE_FILE).is_ok()
}

fn line_to_slice(line: &str) -> Vec<u32> {
    line.split(" ").filter_map(|nb| nb.parse::<u32>().ok()).collect()
}

fn load_highscores_and_lines() -> Option<(Vec<u32>, Vec<u32>)> {
    if let Ok(content) = read_from_file(HIGHSCORE_FILE) {
        let mut lines = content.splitn(2, "\n").map(|line| line_to_slice(line)).collect::<Vec<_>>();
        if lines.len() == 2 {
            let (lines_sent, highscores) = (lines.pop().unwrap(), lines.pop().unwrap());
            Some((highscores, lines_sent))
        } else {
            None
        }
    } else {
        None
    }
}

fn update_vec(v: &mut Vec<u32>, value: u32) -> bool {
    if v.len() < NB_HIGHSCORES {
        v.push(value);
        true
    } else {
        for entry in v.iter_mut() {
            if value > *entry {
                *entry = value;
                return true;
            }
        }
        false
    }
}

fn print_game_information(tetris: &Tetris) {
    let mut new_highest_highscore = true;
    let mut new_highest_lines_sent = true;
    if let Some((mut highscores, mut lines_sent)) = load_highscores_and_lines() {
        new_highest_highscore = update_vec(&mut highscores, tetris.score);
        new_highest_lines_sent = update_vec(&mut lines_sent, tetris.nb_lines);
        if new_highest_highscore || new_highest_lines_sent {
            save_highscores_and_lines(&highscores, &lines_sent);
        }
    } else {
        save_highscores_and_lines(&[tetris.score], &[tetris.nb_lines]);
    }
    println!("Game over...");
    println!("Score:           {}{}",
             tetris.score,
             if new_highest_highscore { " [NEW HIGHSCORE]"} else { "" });
    println!("Number of lines: {}{}",
             tetris.nb_lines,
             if new_highest_lines_sent { " [NEW HIGHSCORE]"} else { "" });
    println!("Current level:   {}", tetris.current_level);
}

fn is_time_over(tetris: &Tetris, timer: &SystemTime) -> bool {
    match timer.elapsed() {
        Ok(elapsed) => {
            let millis = elapsed.as_secs() as u32 * 1000 + elapsed.subsec_nanos() /  
             1_000_000;
            millis > LEVEL_TIMES[tetris.current_level as usize - 1]
        }
        Err(_) => false,
    }
}

fn main() {
    let sdl_context = sdl2::init().expect("SDL initialization failed");
    let mut tetris = Tetris::new();
    let mut timer = SystemTime::now();

    let mut event_pump = sdl_context.event_pump().expect("Failed to get SDL event 
     pump");

    let grid_x = (width - TETRIS_HEIGHT as u32 * 10) as i32 / 2;
    let grid_y = (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2;

    loop {
        if is_time_over(&tetris, &timer) {
            let mut make_permanent = false;
            if let Some(ref mut piece) = tetris.current_piece {
                let x = piece.x;
                let y = piece.y + 1;
                make_permanent = !piece.change_position(&tetris.game_map, x, y);
            }
            if make_permanent {
                tetris.make_permanent();
            }
            timer = SystemTime::now();
        }

        // We need to draw the tetris "grid" in here.

        if tetris.current_piece.is_none() {
            let current_piece = tetris.create_new_tetrimino();
            if !current_piece.test_current_position(&tetris.game_map) {
                print_game_information(&tetris);
                break
            }
            tetris.current_piece = Some(current_piece);
        }
        let mut quit = false;
        if !handle_events(&mut tetris, &mut quit, &mut timer, &mut event_pump) {
            if let Some(ref mut piece) = tetris.current_piece {
                // We need to draw our current tetrimino in here.
            }
        }
        if quit {
            print_game_information(&tetris);
            break
        }

        // We need to draw the game map in here.

        sleep(Duration::new(0, 1_000_000_000u32 / 60));
    }
}

概述

呼!这一章真的很长!但现在,所有的游戏机制都已经到位,所以添加最后剩下的部分(比如绘图)将变得轻而易举。

再次提醒,在开始阅读下一章之前,一定要理解这一章。

第四章:添加所有游戏机制

在前面的章节中,第一章,Rust 基础,第二章,从 SDL 开始,和 第三章,事件和基本游戏机制,我们编写了所有需要的机制。唯一缺少的部分是 UI 渲染和字体管理。简而言之,是容易的部分。所以在本章中,我们将添加游戏的绘制和一些字体处理。

让我们开始吧!

开始游戏机制

让我们先从 UI 渲染开始,然后添加字体管理,以便实时显示游戏信息。

渲染 UI

在当前的代码库中,为了能够拥有一个完全工作的俄罗斯方块,需要做的改动非常少。

渲染初始化

目前,main 函数非常小。首先,让我们在函数顶部添加以下几行:

    let sdl_context = sdl2::init().expect("SDL initialization 
      failed");
    let video_subsystem = sdl_context.video().expect("Couldn't get
       SDL video subsystem");
    let width = 600;
    let height = 800;

没有必要解释,我们已经在前面的章节中解释了一切,所以让我们继续。

在以下几行之后:

    let sdl_context = sdl2::init().expect("SDL initialization
      failed");
    let mut tetris = Tetris::new();
    let mut timer = SystemTime::now();

    let mut event_pump = sdl_context.event_pump().expect("Failed to
      get SDL event pump");

    let grid_x = (width - TETRIS_HEIGHT as u32 * 10) as i32 / 2;
    let grid_y = (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2;

让我们添加以下几行:

    let window = video_subsystem.window("Tetris", width, height)
        .position_centered() // to put it in the middle of the screen
        .build() // to create the window
        .expect("Failed to create window");

    let mut canvas = window.into_canvas()
        .target_texture()
        .present_vsync() // To enable v-sync.
        .build()
        .expect("Couldn't get window's canvas");

    let texture_creator: TextureCreator<_> = canvas.texture_creator();

    let grid = create_texture_rect(&mut canvas,
        &texture_creator,
        0, 0, 0,
        TETRIS_HEIGHT as u32 * 10,
        TETRIS_HEIGHT as u32 * 16).expect("Failed to create
           a texture");

    let border = create_texture_rect(&mut canvas,
        &texture_creator,
        255, 255, 255,
        TETRIS_HEIGHT as u32 * 10 + 20,
        TETRIS_HEIGHT as u32 * 16 + 20).expect("Failed to create 
          a texture");

    macro_rules! texture {
      ($r:expr, $g:expr, $b:expr) => (
        create_texture_rect(&mut canvas,
          &texture_creator,
          $r, $g, $b,
          TETRIS_HEIGHT as u32,
          TETRIS_HEIGHT as u32).unwrap()
      )
    }

    let textures = [texture!(255, 69, 69), texture!(255, 220, 69), 
        texture!(237, 150, 37),texture!(171, 99, 237), texture!(77, 149, 
        239), texture!(39, 218, 225), texture!(45, 216, 47)];

中间甚至有一个宏,所以,确实需要一些解释!

    let window = video_subsystem.window("Tetris", width, height)
       .position_centered()
       .build()
       .expect("Failed to create window");

    let mut canvas = window.into_canvas()
       .target_texture()
       .present_vsync()
       .build()
       .expect("Couldn't get window's canvas");

    let texture_creator: TextureCreator<_> = canvas.texture_creator();

我们已经看到了所有这些,所以我们将快速浏览每个:

  1. 我们创建窗口。

  2. 我们初始化我们将要绘制的区域。

  3. 我们初始化纹理引擎。

接下来的两个调用更有趣,是实际 UI 渲染的开始:

    let grid = create_texture_rect(&mut canvas,
       &texture_creator,
       0, 0, 0,
       TETRIS_HEIGHT as u32 * 10,
       TETRIS_HEIGHT as u32 * 16).expect("Failed to create a texture");

    let border = create_texture_rect(&mut canvas,
       &texture_creator,
       255, 255, 255,
       TETRIS_HEIGHT as u32 * 10 + 20,
       TETRIS_HEIGHT as u32 * 16 + 20).expect("Failed to create a texture");

它们都调用在第二章从 SDL 开始中定义的函数。grid 是我们将要绘制俄罗斯方块和边框以表示游戏区域边界的地方。第一个是黑色,而另一个是白色。以下是他们将看起来像的截图:

图 4.1

所以现在让我们写下代码以便更容易地加载:

    macro_rules! texture {
      ($r:expr, $g:expr, $b:expr) => (
        create_texture_rect(&mut canvas,
           &texture_creator,
           $r, $g, $b,
           TETRIS_HEIGHT as u32,
           TETRIS_HEIGHT as u32).unwrap()
      )
    }

我们已经在第一章,Rust 基础中介绍了宏,所以我们将假设你很容易理解这个宏的作用。(它使用 $r$g$b 作为我们想要纹理的颜色来调用 create_texture_rect 函数。)

    let textures = [texture!(255, 69, 69), texture!(255, 220, 69), 
         texture!(237, 150, 37), texture!(171, 99, 237), texture!(77, 149,
          239), texture!(39, 218, 225), texture!(45, 216, 47)];

在这里,我们为我们的俄罗斯方块块创建纹理。所以七种类型的俄罗斯方块块有七个纹理。

我们初始化了所有需要的渲染。所以现在,让我们开始渲染!

渲染

仍然在 main 函数中,但这次我们要进入主循环(没有文字游戏!)。就在 is_time_over 条件之后,让我们添加:

    canvas.set_draw_color(Color::RGB(255, 0, 0));
    canvas.clear();
    canvas.copy(&border,
        None,
        Rect::new((width - TETRIS_HEIGHT as u32 * 10) as i32 / 2 - 10,
        (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2 - 10,
        TETRIS_HEIGHT as u32 * 10 + 20, TETRIS_HEIGHT as u32 * 16 + 20))
        .expect("Couldn't copy texture into window");
         canvas.copy(&grid,
         None,
         Rect::new((width - TETRIS_HEIGHT as u32 * 10) as i32 / 2,
         (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2,
         TETRIS_HEIGHT as u32 * 10, TETRIS_HEIGHT as u32 * 16))
         .expect("Couldn't copy texture into window");

如果我们想要根据玩家的实际等级更改背景,我们只需更改第一行即可。没问题。

关于以下公式:

    Rect::new((width - TETRIS_HEIGHT as u32 * 10) as i32 / 2 - 10,
       (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2 - 10,
       TETRIS_HEIGHT as u32 * 10 + 20, TETRIS_HEIGHT as u32 * 16 + 20)

我想这里可能需要一个小解释。正如你肯定记得,Rect::new 接受以下四个参数:

  • x 位置

  • y 位置

  • 宽度

  • 高度

对于前两个,我们将游戏地图居中。例如,对于 x 位置,我们需要首先计算它将占用多少宽度(因此是 10 个俄罗斯方块):

    TETRIS_HEIGHT as u32 * 10

然后我们从总宽度中减去这个值:

    width - TETRIS_HEIGHT as u32 * 10

剩下的就是不是游戏地图的部分。所以如果我们用它作为 x 位置,游戏地图将完全位于左侧。看起来不太美观。幸运的是,居中相当容易,我们只需要将这个结果除以 2,如下所示:

    (width - TETRIS_HEIGHT as u32 * 10) as i32 / 2

好的,现在,关于减去 10 的原因;这是因为边框。它的宽度是 10,所以我们也需要减去它以实现真正的居中:

    (width - TETRIS_HEIGHT as u32 * 10) as i32 / 2 - 10

并不是很复杂,但第一次阅读时可能难以理解。高度也是如此,所以我们不会重复相同的解释。现在是时候讨论宽度和高度的计算了!我认为你已经从之前的解释中得到了它,但以防万一:

    TETRIS_HEIGHT as u32 * 10

一个 Tetris 有十块宽度。因此,我们的游戏地图也必须有相同的宽度。

    TETRIS_HEIGHT as u32 * 10 + 20

我们现在还添加了总边框的宽度(因为每边都有一个边框,而边框的宽度为 10 像素,10 * 2 = 20)。

高度也是如此。

一旦你了解了这些公式的原理,你也会了解其他所有公式的原理。

由于我们已经绘制了游戏环境,现在是时候绘制俄罗斯方块了。首先,让我们绘制当前的方块!为了做到这一点,我们需要更新 handle_events 条件内的 for 循环:

    if !handle_events(&mut tetris, &mut quit, &mut timer, &mut
      event_pump) {
      if let Some(ref mut piece) = tetris.current_piece {
        for (line_nb, line) in piece.states[piece.current_state
           as usize].iter().enumerate() {
          for (case_nb, case) in line.iter().enumerate() {
            if *case == 0 {
              continue
            }
            // The new part is here:
              canvas.copy(&textures[*case as usize - 1],
                None,
                Rect::new(grid_x + (piece.x + case_nb as isize) as 
                  i32 * TETRIS_HEIGHT as i32, grid_y + (piece.y + 
                  line_nb) as i32 * TETRIS_HEIGHT as i32, TETRIS_HEIGHT
                  as u32, TETRIS_HEIGHT as u32))
                  .expect("Couldn't copy texture into window");
          }
        }
      }
    }

对于当前俄罗斯方块中的每一块,我们粘贴一个与其 ID 对应的纹理。从前面公式的解释中,我们可以假设没有必要回到那些 的公式上。

这样,只剩下最后一部分了;绘制所有其他俄罗斯方块块:

    for (line_nb, line) in tetris.game_map.iter().enumerate() {
      for (case_nb, case) in line.iter().enumerate() {
        if *case == 0 {
            continue
        }
        canvas.copy(&textures[*case as usize - 1],
          None, Rect::new(grid_x + case_nb as i32 * TETRIS_HEIGHT
          as i32, grid_y + line_nb as i32 * TETRIS_HEIGHT as i32,
          TETRIS_HEIGHT as u32, TETRIS_HEIGHT as u32))
          .expect("Couldn't copy texture into window");
      }
    }
    canvas.present();

在这个代码中,我们遍历游戏地图每一行的每一块,并粘贴相应的纹理,如果游戏地图的 占用

完成后,我们通过以下方式将所有更改应用到显示上:

    canvas.present();

这样,我们的 Tetris 现在就完成了!你现在可以通过运行命令来玩游戏:

cargo run --release

--release 是用于以非调试模式启动程序。

main 函数的完整代码现在如下所示:

    fn main() {
      let sdl_context = sdl2::init().expect("SDL initialization failed");
      let video_subsystem = sdl_context.video().expect("Couldn't get 
          SDL video subsystem");
      let width = 600;
      let height = 800;
      let mut timer = SystemTime::now();
      let mut event_pump = sdl_context.event_pump().expect("Failed to get
          SDL event pump");

      let grid_x = (width - TETRIS_HEIGHT as u32 * 10) as i32 / 2;
      let grid_y = (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2;
      let mut tetris = Tetris::new();

      let window = video_subsystem.window("Tetris", width, height)
                                .position_centered()
                                .build()
                                .expect("Failed to create window");

      let mut canvas = window.into_canvas()
                           .target_texture()
                           .present_vsync()
                           .build()
                           .expect("Couldn't get window's canvas");

      let texture_creator: TextureCreator<_> = canvas.texture_creator();

      let grid = create_texture_rect(&mut canvas,
               &texture_creator,
               0, 0, 0,
               TETRIS_HEIGHT as u32 * 10,
               TETRIS_HEIGHT as u32 * 16).expect("Failed to create
                 a texture");

      let border = create_texture_rect(&mut canvas,
               &texture_creator,
               255, 255, 255,
               TETRIS_HEIGHT as u32 * 10 + 20,
               TETRIS_HEIGHT as u32 * 16 + 20).expect("Failed to create
                 a texture");

      macro_rules! texture {
        ($r:expr, $g:expr, $b:expr) => (
            create_texture_rect(&mut canvas,
                                &texture_creator,
                                $r, $g, $b,
                                TETRIS_HEIGHT as u32,
                                TETRIS_HEIGHT as u32).unwrap()
        )
      }

      let textures = [texture!(255, 69, 69), texture!(255, 220, 69),
         texture!(237, 150, 37), texture!(171, 99, 237), 
         texture!(77, 149, 239), texture!(39, 218, 225),
         texture!(45, 216, 47)];

      loop {
        if is_time_over(&tetris, &timer) {
          let mut make_permanent = false;
          if let Some(ref mut piece) = tetris.current_piece {
            let x = piece.x;
            let y = piece.y + 1;
            make_permanent = !piece.change_position(&tetris.game_map,
               x, y);
          }
          if make_permanent {
            tetris.make_permanent();
          }
          timer = SystemTime::now();
        }

        canvas.set_draw_color(Color::RGB(255, 0, 0));
        canvas.clear();

        canvas.copy(&border,
           None,
           Rect::new((width - TETRIS_HEIGHT as u32 * 10) as i32 / 2 - 10,
           (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2 - 10,
           TETRIS_HEIGHT as u32 * 10 + 20, TETRIS_HEIGHT as u32 * 16 + 20))
           .expect("Couldn't copy texture into window");
        canvas.copy(&grid,
           None,
           Rect::new((width - TETRIS_HEIGHT as u32 * 10) as i32 / 2,
           (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2,
           TETRIS_HEIGHT as u32 * 10, TETRIS_HEIGHT as u32 * 16))
           .expect("Couldn't copy texture into window");

        if tetris.current_piece.is_none() {
            let current_piece = tetris.create_new_tetrimino();
            if !current_piece.test_current_position(&tetris.game_map) {
                print_game_information(&tetris);
                break
            }
            tetris.current_piece = Some(current_piece);
        }
        let mut quit = false;
        if !handle_events(&mut tetris, &mut quit, &mut timer,
           &mut event_pump) {
         if let Some(ref mut piece) = tetris.current_piece {
           for (line_nb, line) in piece.states[piece.current_state 
               as usize].iter().enumerate() {
             for (case_nb, case) in line.iter().enumerate() {
               if *case == 0 {
                 continue
               }
               canvas.copy(&textures[*case as usize - 1],
                  None,
                  Rect::new(grid_x + (piece.x + case_nb as isize) 
                    as i32 * TETRIS_HEIGHT as i32,
                  grid_y + (piece.y + line_nb) as i32 * TETRIS_HEIGHT 
                    as i32,
                    TETRIS_HEIGHT as u32, TETRIS_HEIGHT as u32))
                  .expect("Couldn't copy texture into window");
             }
           }
         }
        }
        if quit {
          print_game_information(&tetris);
            break
        }

        for (line_nb, line) in tetris.game_map.iter().enumerate() {
          for (case_nb, case) in line.iter().enumerate() {
             if *case == 0 {
                continue
             }
             canvas.copy(&textures[*case as usize - 1],
                None,
                Rect::new(grid_x + case_nb as i32 * TETRIS_HEIGHT as i32,
                grid_y + line_nb as i32 * TETRIS_HEIGHT as i32,
                TETRIS_HEIGHT as u32, TETRIS_HEIGHT as u32))
                .expect("Couldn't copy texture into window");
          }
        }
        canvas.present();

        sleep(Duration::new(0, 1_000_000_000u32 / 60));
      }
    }

下面是这个代码当前输出的一个示例:

图 4.2

现在它正在工作,但关于显示游戏信息,比如当前分数、等级或发送的行数,怎么办?

玩转字体

为了显示这些信息,我们需要使用字体。然而,不需要额外的外部依赖,但我们需要使用一个功能,因此我们需要更新我们的 Cargo.toml

    [features]
    default = ["sdl2/ttf"]

默认情况下,sdl2 包不提供 ttf 模块,你需要通过在编译过程中添加 ttf 功能来启用它。这就是我们通过告诉 cargo:“默认情况下,我想启用 sdl2 包的 ttf 功能”。你可以尝试添加和不添加这个新上下文初始化后的差异。

    let ttf_context = sdl2::ttf::init().expect("SDL TTF initialization
        failed");

如果你遇到缺少库的编译错误,这意味着你没有安装相应的库。要解决这个问题,你需要通过你喜欢的包管理器安装它。

在 OS X 上安装

运行以下命令:

brew install sdl2_ttf

在 Linux 上安装

运行以下命令(当然,取决于你的包管理器):

sudo apt-get install libsdl2-ttf-dev

其他系统/包管理器

你可以在www.libsdl.org/projects/SDL_ttf/下载库。

按照说明在你的系统上安装它,然后只需运行项目。如果没有错误出现,那么这意味着你已经正确安装了它。

是时候开始真正的事情了!

加载字体

在继续之前,我们实际上需要一个字体。我选择了 Lucida console,但选择你喜欢的字体,这并不重要。下载后,将其也放入 assets 文件夹中。现在,是时候实际加载字体了:

     let font = ttf_context.load_font("assets/lucida.ttf", 128).expect("
       Couldn't load the font");

注意,如果你想给你的字体应用样式(例如粗体、斜体、删除线或下划线),那么你需要应用的对象就是它。以下是一个例子:

    font.set_style(sdl2::ttf::STYLE_BOLD);

现在,为了实际显示文本,我们还需要完成两个步骤:

  1. 渲染文本。

  2. 从它创建一个纹理。

让我们编写一个函数来完成这个任务:

    fn create_texture_from_text<'a>(texture_creator: &'a 
       TextureCreator<WindowContext>,
       font: &sdl2::ttf::Font,
       text: &str,
       r: u8, g: u8, b: u8,
       ) -> Option<Texture<'a>> {
         if let Ok(surface) = font.render(text)
           .blended(Color::RGB(r, g, b)) {
          texture_creator.create_texture_from_surface(&surface).ok()
         } else {
               None
           }
       }

看起来很像 create_texture_rect,对吧?

为什么不测试一下呢?让我们调用这个函数并将纹理粘贴到屏幕上看看:

     let rendered_text = create_texture_from_text(&texture_creator,
        &font, "test", 255, 255, 255).expect("Cannot render text");
     canvas.copy(&rendered_text, None, Some(Rect::new(width as i32 - 
        40, 0, 40, 30)))
    .expect("Couldn't copy text");

它看起来是这样的:

图片 4.3图 4.3

对于纹理矩形,我使用以下规则:一个字符是一个 10 x 30 像素的块。所以在这个例子中,因为 test 有 4 个字母,我们需要一个 40 x 30 像素的块。让我们写一个函数来简化这个过程:

     fn get_rect_from_text(text: &str, x: i32, y: i32) -> Option<Rect> {
        Some(Rect::new(x, y, text.len() as u32 * 20, 30))
     }

好的,所以现在是我们渲染游戏信息并编写一个新函数来执行此操作的时候了:

    fn display_game_information<'a>(tetris: &Tetris,
       canvas: &mut Canvas<Window>,
       texture_creator: &'a TextureCreator<WindowContext>,
       font: &sdl2::ttf::Font,
       start_x_point: i32) {
     let score_text = format!("Score: {}", tetris.score);
     let lines_sent_text = format!("Lines sent: {}", tetris.nb_lines);
     let level_text = format!("Level: {}", tetris.current_level);

     let score = create_texture_from_text(&texture_creator, &font,
        &score_text, 255, 255, 255)
        .expect("Cannot render text");
     let lines_sent = create_texture_from_text(&texture_creator, &font,
        &lines_sent_text, 255, 255, 255)
        .expect("Cannot render text");
     let level = create_texture_from_text(&texture_creator, &font,
        &level_text, 255, 255, 255)
        .expect("Cannot render text");

     canvas.copy(&score, None, get_rect_from_text(&score_text, 
       start_x_point, 90))
          .expect("Couldn't copy text");
    canvas.copy(&lines_sent, None, get_rect_from_text(&score_text,
       start_x_point, 125))
          .expect("Couldn't copy text");
    canvas.copy(&level, None, get_rect_from_text(&score_text, 
       start_x_point, 160))
          .expect("Couldn't copy text");
    }

然后我们将其称为如下:

    display_game_information(&tetris, &mut canvas, &texture_creator, &font,
       width as i32 - grid_x - 10);

现在看起来是这样的:

图片 4.4图 4.4

太棒了,我们现在有了实时游戏信息!不是吗?什么?它看起来很丑,还与游戏重叠?让我们移动游戏!我们不会将其居中,而是给它一个固定的 x 位置(这将使我们的公式变得非常简单)。

首先,让我们更新我们的 grid_x 变量:

    let grid_x = 20;

然后,让我们更新 canvas.copy 调用:

     canvas.copy(&border,
            None,
            Rect::new(10,
                      (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2 - 10,
                      TETRIS_HEIGHT as u32 * 10 + 20, TETRIS_HEIGHT as u32 * 16 + 20))
      .expect("Couldn't copy texture into window");
     canvas.copy(&grid,
       None,
       Rect::new(20,
       (height - TETRIS_HEIGHT as u32 * 16) as i32 / 2,
        TETRIS_HEIGHT as u32 * 10, TETRIS_HEIGHT as u32 * 16))
      .expect("Couldn't copy texture into window");

就这样。你现在有一个不错的俄罗斯方块可以玩:

图片 4.5图 4.5

我们可以通过添加文本周围的边框来稍微改进显示效果,或者甚至显示下一个部件的预览,或者甚至添加一个 幽灵,但我认为从这个点开始,你可以轻松地添加它们。

就这样,这个俄罗斯方块就完成了,玩得开心,享受与 sdl2 的互动!

摘要

现在,我们有一个完全工作的俄罗斯方块。在过去的三个章节中,我们看到了如何使用 sdl2 crate,如何向 Rust 项目添加依赖项,如何处理 I/O(使用文件),以及模块是如何工作的。

即使我们现在停止这个俄罗斯方块项目,你仍然可以继续这个项目(而且提高自己在 sdl2 方面的技能也是一个好主意!)一些你可以添加的缺失功能的想法:

  • 根据当前级别更改背景

  • 在游戏结束后询问玩家是否想要开始新游戏

  • 添加下一个四元形的预览

  • 添加一个幽灵(以查看四元形将落在何处)

  • 以及更多。在添加新功能的同时,尽情享受乐趣吧!

正如你所见,有很多事情是可能的。尽情享受吧!

第五章:创建音乐播放器

在前面的章节中,你创建了一个很棒的游戏,现在让我们继续到另一个令人兴奋的主题——桌面应用程序。我们将使用 GTK+ 库的 Rust 绑定来编写一个 MP3 音乐播放器。在下一章中,我们将有机会学习线程来编写音乐播放器本身。但是,在这一章中,我们将专注于图形界面,如何管理界面的布局,以及如何管理用户事件。

在这一章中,我们将涵盖以下主题:

  • Windows

  • 小部件

  • 事件

  • 闭包

  • 事件循环

  • 容器

安装先决条件

由于 GTK+ 是一个 C 库,我们首先需要安装它。Rust 绑定使用 GTK+ 版本 3,所以请确保你没有安装旧版本 2。

在 Linux 上安装 GTK+

在 Linux 上,GTK+ 可以通过你的发行版的包管理器安装。

在 Ubuntu(或其他 Debian 衍生版)上:

sudo apt-get install libgtk-3-dev

在 Mac 上安装 GTK+

在 OSX 上,你只需要运行以下命令:

brew install gtk+3 gnome-icon-theme

在 Windows 上安装 GTK+

在 Windows 上,你首先需要下载并安装 MSYS2,它为 Windows 提供了一个类 Unix 环境。安装完成后,在 MSYS2 终端中运行以下命令:

pacman -S mingw-w64-x86_64-gtk3

创建你的第一个窗口

现在,我们已经准备好从 Rust 开始使用 GTK+。让我们为我们的音乐播放器创建一个新的项目:

cargo new rusic --bin

在你的 Cargo.toml 文件中添加对 giogtk 依赖项:

gio = "⁰.3.0"
gtk = "⁰.3.0"

src/main.rs 文件的内容替换为以下内容:

extern crate gio;
extern crate gtk;

use std::env;

use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags};
use gtk::{
    Application,
    ApplicationWindow,
    WidgetExt,
    GtkWindowExt,
};

fn main() {
    let application = Application::new("com.github.rust-by-
     example", ApplicationFlags::empty())
        .expect("Application initialization failed");
    application.connect_startup(|application| {
        let window = ApplicationWindow::new(&application);
        window.set_title("Rusic");
        window.show();
    });
    application.connect_activate(|_| {});
    application.run(&env::args().collect::<Vec<_>>());
}

然后,使用 cargo run 运行应用程序。你应该看到一个小的空窗口:

图 5.1图 5.1

如果你看到了这个窗口,这意味着你已经正确安装了 GTK+。

让我们分块解释这段代码:

extern crate gio;
extern crate gtk;

如同往常一样,当使用外部 crate 时,我们需要声明它。

然后,我们导入从标准库、giogtk 中我们将使用的类型和模块:

use std::env;

use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags};
use gtk::{
    Application,
    ApplicationWindow,
    WidgetExt,
    GtkWindowExt,
};

之后,我们开始 main 函数:

fn main() {
    let application = Application::new("com.github.rust-by-
     example", 
     ApplicationFlags::empty())
        .expect("Application initialization failed");

这个函数的第一行创建了一个新的 gio 应用程序。我们提供了一个应用程序 ID,可以用来确保应用程序只运行一次。Application 使得管理应用程序及其窗口变得更加容易。

接下来,我们创建窗口,设置其标题,并将其显示到屏幕上:

    application.connect_startup(|application| {
        let window = ApplicationWindow::new(&application);
        window.set_title("Rusic");
        window.show();
    });
    application.connect_activate(|_| {});

在创建新窗口后,我们设置其标题并显示它。

在这里,我们实际上正在处理一个事件;startup 是当应用程序注册时发出的一个信号,所以,当它准备好使用时。正如你在 GTK+ 文档中可以看到的(developer.gnome.org/gio/stable/GApplication.html#GApplication-startup),信号由字符串表示。这个信号实际上叫做 startup,但我们用来连接这个信号的 Rust 方法是 connect_startup。因此,我们需要在信号名称前添加 connect_ 并将破折号改为下划线。

闭包

这个方法的参数有些特殊:

|application| {
    let window = ApplicationWindow::new(&application);
    window.set_title("Rusic");
    window.show();
}

这就是我们所说的闭包。闭包是一种简洁地声明没有名称且可以捕获环境的函数的方法。捕获环境意味着它可以访问闭包外部的变量,这是普通函数无法做到的。连接信号的函数将运行作为参数传递的函数(在这种情况下,一个闭包)。在这里,创建窗口。

我们本可以决定创建一个普通函数,如下面的代码所示:

fn startup_handler(application: &Application) {
    let window = ApplicationWindow::new(&application);
    window.set_title("Rusic");
    window.show();
}

// In the main function:

    application.connect_startup(startup_handler);

但这比使用闭包不太方便。除了你可能需要导入其他 crate 和类型之外,你还需要指定参数的类型和返回类型。实际上,闭包有类型推断,但函数没有。此外,函数必须在其他地方声明,因此它可能没有使用闭包那么易于阅读。

main函数的其余部分是:

    application.run(&env::args().collect::<Vec<_>>());
}

这启动了gtk事件循环。这是一个无限循环,它处理用户事件,如按钮点击或请求关闭窗口。它还管理其他事情,如超时和异步、类似 IO 的网络请求。

一些事件处理器要求你返回一个值,例如对于delete_event信号,我们需要返回Inhibit(false)

阻止事件默认行为

Inhibit类型只是bool类型的包装。它用于指示我们是否应该停止将事件传播到默认处理器。为了了解这意味着什么,让我们为窗口添加一个事件处理器:

window.connect_delete_event(|_, _| {
    Inhibit(true)
});

如果你运行它,你会注意到我们不能再关闭窗口了。这是因为我们返回了Inhibit(true)来表示我们想要阻止delete_event信号的默认行为,即关闭窗口。

现在我们尝试对之前的代码进行轻微的变体:

window.connect_delete_event(|_, _| {
    Inhibit(false)
});

在这种情况下,我们不阻止默认处理器运行,因此窗口将被关闭。

创建工具栏

我们将通过添加一个带有所需按钮的工具栏来开始我们的音乐播放器:

  • 打开文件

  • 播放

  • 暂停

  • 停止

  • 上一首/下一首歌曲

  • 从播放列表中移除歌曲

这将是我们的第一个非空窗口的好开始。

首先,我们需要一些额外的导入语句:

use gtk::{
    ContainerExt,
    SeparatorToolItem,
    Toolbar,
    ToolButton,
};

然后,我们将声明一个常量,因为我们将在其他地方使用这个值:

const PLAY_STOCK: &str = "gtk-media-play";

我们很快就会解释这是怎么回事。

现在,我们将创建一个工具栏并将其添加到窗口中:

fn main() {
    // Same code to initialize gtk, create the window.
    application.connect_startup(|application| {
        // …

        let toolbar = Toolbar::new();
        window.add(&toolbar);

注意:不要调用window.show(),因为我们将在之后使用另一种方法。

这段代码相当直接。唯一需要注意的是,gtk-rs API 在大多数情况下需要值的引用;在这种情况下,我们将工具栏的引用作为参数发送到add()方法。

你会看到这个add()方法几乎被到处调用。它允许你将小部件添加到另一个小部件中。小部件是用户界面(视觉或非视觉)的组件。它可以是一个按钮、一个菜单、一个分隔符,但它也可以是一个不可见组件,例如一个盒子,允许你水平放置小部件。我们将在本章后面讨论像gtk::Box这样的容器以及如何布局我们的小部件。

让我们在工具栏中添加一个按钮:

    let open_button = ToolButton::new_from_stock("gtk-open");
    toolbar.add(&open_button);

这创建了一个工具栏按钮并将其添加到工具栏中。

库存项

我们没有使用常规的new()构造函数,而是决定在这里使用new_from_stock()。这个函数需要一个字符串作为参数。这个字符串是表示内置菜单或工具栏项的标识符,例如OpenSave。这些项有一个图标和一个标签,该标签根据用户的区域设置进行翻译。通过使用库存项,您可以快速创建一个看起来与其他使用 GTK+构建的应用程序相同的应用程序。

让我们展示包含工具栏的此窗口:

        window.show_all();
    });

这直接位于启动事件处理器的末尾。在这里,我们使用show_all()而不是仅使用show(),因为我们有更多的小部件需要显示。我们本可以调用每个单独的小部件上的show(),但这可能会变得繁琐;这就是为什么有show_all()的原因。

如果你运行这个应用程序,你会看到一个带有打开按钮的以下窗口:

图 5.2图 5.2

让我们添加我们需要的打开按钮:

    toolbar.add(&SeparatorToolItem::new());

    let previous_button = ToolButton::new_from_stock("gtk-media-previous");
    toolbar.add(&previous_button);

    let play_button = ToolButton::new_from_stock(PLAY_STOCK);
    toolbar.add(&play_button);

    let stop_button = ToolButton::new_from_stock("gtk-media-stop");
    toolbar.add(&stop_button);

    let next_button = ToolButton::new_from_stock("gtk-media-next");
    toolbar.add(&next_button);

    toolbar.add(&SeparatorToolItem::new());

    let remove_button = ToolButton::new_from_stock("gtk-remove");
    toolbar.add(&remove_button);

    toolbar.add(&SeparatorToolItem::new());

    let quit_button = ToolButton::new_from_stock("gtk-quit");
    toolbar.add(&quit_button);

这段代码应该放在调用window.show_all()之前。SeparatorToolItem,它被添加了几次,以逻辑上分隔按钮,使得类似操作的按钮可以分组在一起。

现在我们有一个开始看起来像音乐播放器的应用程序,如下所示:

图 5.3图 5.3

改善应用程序的组织结构

main函数开始变得更大,所以我们将稍微重构我们的代码,以便在接下来的章节中更容易更新。

首先,我们将创建一个名为toolbar的新模块。作为一个提醒,以下是这样做的方法:

  1. 创建一个新文件:src/toolbar.rs

  2. main.rs文件的顶部添加一个语句,mod toolbar;

这个新的toolbar模块将从导入语句和const声明开始:

use gtk::{
    ContainerExt,
    SeparatorToolItem,
    Toolbar,
    ToolButton,
};

const PLAY_STOCK: &str = "gtk-media-play";

然后,我们将创建一个新的结构,它包含组成工具栏的所有小部件:

pub struct MusicToolbar {
    open_button: ToolButton,
    next_button: ToolButton,
    play_button: ToolButton,
    previous_button: ToolButton,
    quit_button: ToolButton,
    remove_button: ToolButton,
    stop_button: ToolButton,
    toolbar: Toolbar,
}

我们在这里使用pub关键字,因为我们希望能够从其他模块中使用这个类型。

然后,我们将为这个struct创建一个构造函数,它将创建所有按钮,就像我们之前做的那样:

impl MusicToolbar {
    pub fn new() -> Self {
        let toolbar = Toolbar::new();

        let open_button = ToolButton::new_from_stock("gtk-open");
        toolbar.add(&open_button);

        // ...

        let quit_button = ToolButton::new_from_stock("gtk-quit");
        toolbar.add(&quit_button);

        MusicToolbar {
            open_button,
            next_button,
            play_button,
            previous_button,
            quit_button,
            remove_button,
            stop_button,
            toolbar
        }
    }
}

与之前的代码相比,唯一的区别是我们现在返回一个struct MusicToolbar。我们还会在这个impl中添加一个方法,以便从外部访问gtk::Toolbar小部件:

    pub fn toolbar(&self) -> &Toolbar {
        &self.toolbar
    }

现在关于这个工具栏模块的内容就到这里。让我们回到模块。首先,我们需要导入我们新的MusicToolbar类型:

use toolbar::MusicToolbar;

接下来,我们将创建一个类似于我们为工具栏创建的结构:

struct App {
    toolbar: MusicToolbar,
    window: ApplicationWindow,
}

我们还将为它创建一个构造函数:

impl App {
    fn new(application: Application) -> Self {
        let window = ApplicationWindow::new(&application);
        window.set_title("Rusic");

        let toolbar = MusicToolbar::new();
        window.add(toolbar.toolbar());

        window.show_all();

        let app = App {
            toolbar,
            window,
        };

        app.connect_events();

        app
    }
}

这里,我们像之前一样创建了窗口,然后创建了我们的 MusicToolbar 结构。我们通过将 toolbar() 方法(它返回 gtk 小部件)的结果传递给 add() 方法来添加包装的工具栏小部件。

之后,我们使用了一个小技巧,使我们能够在尚未创建的 struct 上调用方法;我们首先将 struct 赋值给一个变量,然后调用方法并返回该变量。这个方法定义在同一个 impl 块中:

    fn connect_events(&self) {
    }

我们将在下一章中填充这个方法。

添加工具按钮事件

我们将继续添加一些按钮的事件处理器。

首先,我们需要新的 use 语句:

use gtk::{
    ToolButtonExt,
    WidgetExt,
};

use App;

我们导入 ToolButtonExt,它提供了从 main 模块调用 ToolButtonApp 的方法,因为我们将为这个类型添加一个新方法:

impl App {
    pub fn connect_toolbar_events(&self) {
        let window = self.window.clone();
        self.toolbar.quit_button.connect_clicked(move |_| {
            window.destroy();
        });
    }
}

在 Rust 中,在创建类型不同的模块中声明方法是完全有效的。在这里,我们说点击退出按钮将销毁窗口,这将有效地退出应用程序。

让我们添加另一个事件,该事件将切换播放按钮图像和暂停图像:

    let play_button = self.toolbar.play_button.clone();
    self.toolbar.play_button.connect_clicked(move |_| {
        if play_button.get_stock_id() == Some(PLAY_STOCK.to_string()) {
            play_button.set_stock_id(PAUSE_STOCK);
        } else {
            play_button.set_stock_id(PLAY_STOCK);
       }
    });

这段代码需要在 PLAY_STOCK 旁边添加一个新的常量:

const PAUSE_STOCK: &str = "gtk-media-pause";

在查看这段代码的奇特之处之前,我们先看看闭包的主体。在这里,我们使用一个条件来检查按钮是否显示播放图像——如果是,我们切换到暂停库存项。否则,我们切换回播放图标。

但为什么我们需要克隆按钮并在闭包之前使用这个 move 关键字呢?让我们尝试正常的方式,即在大多数编程语言中你会怎么做:

self.toolbar.play_button.connect_clicked(|_| {
    if self.toolbar.play_button.get_stock_id() == Some(PLAY_STOCK.to_string()) {
        self.toolbar.play_button.set_stock_id(PAUSE_STOCK);
    } else {
        self.toolbar.play_button.set_stock_id(PLAY_STOCK);
    }
});

如果我们这样做,我们会得到以下编译错误:

error[E0477]: the type `[closure@src/toolbar.rs:79:50: 85:10 self:&&App]` does not fulfill the required lifetime
  --> src/toolbar.rs:79:34
   |
79 |         self.toolbar.play_button.connect_clicked(|_| {
   |                                  ^^^^^^^^^^^^^^^
   |
   = note: type must satisfy the static lifetime

error[E0495]: cannot infer an appropriate lifetime for capture of `self` by closure due to conflicting requirements
  --> src/toolbar.rs:79:50
   |
79 |           self.toolbar.play_button.connect_clicked(|_| {
   |  __________________________________________________^
80 | |             if self.toolbar.play_button.get_stock_id() == Some(PLAY_STOCK.to_string()) {
81 | |                 self.toolbar.play_button.set_stock_id(PAUSE_STOCK);
82 | |             } else {
83 | |                 self.toolbar.play_button.set_stock_id(PLAY_STOCK);
84 | |             }
85 | |         });
   | |_________^

它甚至进一步解释了为什么无法推断生命周期。

让我们看看 connect_clicked() 方法的签名,以了解发生了什么:

fn connect_clicked<F: Fn(&Self) + 'static>(&self, f: F) -> u64

Fn(&Self) 部分表示这个函数需要类似函数的东西,它接受一个参数,该参数是 Self(在这种情况下是 ToolButton)的引用。`'static' 部分是一个生命周期注解。

生命周期

生命周期是 Rust 编译器用来确保内存安全的一个特性。生命周期指定了一个对象必须存在的最小持续时间,以便安全地使用。让我们尝试在某些编程语言中允许做某事,但实际上这样做是错误的:

fn get_element_inc(elements: &[i32], index: usize) -> &i32 {
    let element = elements[index] + 1;
    &element
}

这里,我们尝试从一个栈分配的值返回一个引用。问题是这个值在函数返回时将被回收,调用者将尝试访问这个已回收的值。在其他编程语言中,这段代码可以编译并产生(希望是)运行时的段错误。但 Rust 是一种安全的编程语言,拒绝编译这样的代码:

error[E0597]: `element` does not live long enough
 --> src/main.rs:3:6
  |
3 |     &element
  |      ^^^^^^^ does not live long enough
4 | }
  | - borrowed value only lives until here

编译器注意到element值将在函数结束时被销毁;这就是最后一行句子所表达的意思。这是正确的,因为element的生命周期从其声明开始,直到其声明的范围结束;在这里,范围是函数。以下是element生命周期的示意图:

图 5.4

但编译器是如何知道返回值所需的生命周期的呢?为了回答这个问题,让我们添加编译器添加的生命周期注释:

fn get_element_inc<'a>(elements: &'a [i32], index: usize) -> &'a i32 {
    let element = elements[index] + 1;
    &element
}

正如你所见,生命周期的语法与用于标签的语法相同——'label'。当我们想要指定生命周期时,我们需要在尖括号内声明生命周期名称,这与声明泛型类型的方式类似。在这种情况下,我们指定了返回值的生命周期必须与参数elements的生命周期相同。

让我们再次用生命周期来注释代码:

图 5.5

在这里,我们可以清楚地看到返回值的生命周期小于所需的生命周期;这就是为什么编译器拒绝了我们的代码。

在这种情况下,有两种方法可以修复这段代码(而不改变签名)。获得满足生命周期'a'的值的一种方法是通过引用相同生命周期的值;参数elements也有生命周期'a',因此我们可以编写以下代码:

fn get_element<'a>(elements: &'a [i32], index: usize) -> &'a i32 {
    &elements[index]
}

另一种方法是返回一个生命周期为'static'的值的引用。这个特殊的生命周期等于程序的持续时间,也就是说,值必须持续到程序结束。获得这种生命周期的 一种方法是用字面量:

fn get_element<'a>(elements: &'a [i32], index: usize) -> &'a i32 {
    &42
}

'static'生命周期满足'a'约束,因为'static'的生命周期比后者长。

在这两个例子中,生命周期注释不是必需的。由于一个叫做生命周期省略的特性,我们一开始就不需要指定生命周期;编译器可以通过遵循这些简单的规则在大多数情况下推断出所需的生命周期:

  • 每个参数被分配不同的生命周期

  • 如果只有一个参数需要生命周期,那么这个生命周期会被分配给返回值中的每一个生命周期(就像我们的get_element函数一样)

  • 如果有多个参数需要生命周期,但其中一个是用于&self的,则self的生命周期会被分配给返回值中的每一个生命周期

让我们回到方法签名:

fn connect_clicked<F: Fn(&Self) + 'static>(&self, f: F) -> u64

在这里,我们注意到参数f具有'static'生命周期。我们现在知道这意味着这个参数必须持续到程序结束。这就是为什么我们不能使用普通版本的闭包:因为self的生命周期不是'static',这意味着app将在main函数结束时被销毁。为了使这可行,我们克隆了play_button变量:

let play_button = self.toolbar.play_button.clone();

现在我们可以在这个闭包中使用这个新变量。

注意:请注意,克隆 GTK+小部件是非常便宜的;只需克隆一个指针。

然而,尝试执行以下操作仍然会导致编译错误:

let play_button = self.toolbar.play_button.clone();
self.toolbar.play_button.connect_clicked(|_| {
    if play_button.get_stock_id() == Some(PLAY_STOCK.to_string()) {
        play_button.set_stock_id(PAUSE_STOCK);
    } else {
        play_button.set_stock_id(PLAY_STOCK);
    }
});

这里是错误信息:

error[E0373]: closure may outlive the current function, but it borrows `play_button`, which is owned by the current function
  --> src/toolbar.rs:80:50
   |
80 |         self.toolbar.play_button.connect_clicked(|_| {
   |                                                  ^^^ may outlive borrowed value `play_button`
81 |             if play_button.get_stock_id() == Some(PLAY_STOCK.to_string()) {
   |                ----------- `play_button` is borrowed here
   |
help: to force the closure to take ownership of `play_button` (and any other referenced variables), use the `move` keyword
   |
80 |         self.toolbar.play_button.connect_clicked(move |_| {
   |                                                  ^^^^^^^^

这段代码的问题在于闭包可以在函数返回之后被调用,但是变量 buttonconnect_toolbar_events() 方法中被声明,并在返回时被释放。再次强调,Rust 通过检查我们是否正确使用引用来防止我们出现段错误。编译器谈论所有权的概念;让我们看看这是什么。

所有权的概念

在 Rust 中,没有垃圾回收器来处理不再需要的内存的释放。此外,程序员也不需要指定内存应该在哪里释放。但是,这怎么可能工作呢?编译器能够通过所有权的概念来确定何时释放内存;只有一个变量可以拥有一个值。通过这个简单的规则,何时释放值的处理变得简单:当所有者超出作用域时,值就会被释放。

让我们看看一个例子,看看释放与作用域的关系:

let mut vec = vec!["string".to_string()];
if !vec.is_empty() {
    let element = vec.remove(0);
    // element is deallocated at the end of this scope.
}

在这里,我们在新的作用域中从向量中移除一个元素——条件块的块。变量 element 将拥有从向量中移除的值(我们也可以说值从向量移动到变量 element)。由于它拥有这个值,变量在超出作用域时不需要负责释放它。因此,在条件之后,值 "string" 将被释放并且不能再被访问。

让我们回到我们的代码:

self.toolbar.play_button.connect_clicked(move |_| {
    if play_button.get_stock_id() == Some(PLAY_STOCK.to_string()) {
        play_button.set_stock_id(PAUSE_STOCK);
    } else {
        play_button.set_stock_id(PLAY_STOCK);
    }
});

我们在闭包中添加了关键字 move 来表示值必须被移动到闭包中。(如果你记得错误信息,这就是编译器告诉我们要做的事情。)通过这样做,我们满足了借用检查器的条件,因为值不再被借用。这之前导致生命周期错误,但现在已经被移动到闭包中,因此它将像闭包本身一样长时间存在。

不要忘记在 App::new() 方法中调用此方法,在调用 connect_events() 之后:

app.connect_events();
app.connect_toolbar_events();

容器

现在,我们将向我们的窗口添加其他小部件:一个显示当前播放歌曲封面的图像和一个光标来查看音乐的进度。然而,不可能向窗口添加多个小部件。要做到这一点,我们需要使用容器。

容器是一种管理多个小部件如何显示的方式。

容器的类型

这里有一些简单的非视觉容器:

  • gtk::Box:水平或垂直地处理小部件

  • gtk::Grid:像表格一样按行和列处理小部件

  • gtk::Fixed:在像素级别的特定位置显示小部件

  • gtk::Stack:一次只显示一个小部件

所有这些小部件,除了 gtk::Fixed ,在窗口大小调整时都会自动重新排列小部件。这就是为什么你应该避免使用这个的原因。

这里有一些更复杂的容器:

  • gtk::Notebook:一次只显示一个小部件,但用户可以通过点击标签来选择显示哪个

  • gtk::Paned:显示两个小部件,由用户可以拖动的手柄分隔,以调整小部件之间的分隔

盒子容器

我们将使用gtk::Box来排列我们的小部件。首先,删除我们之前添加的Window::add()调用:

window.add(toolbar.toolbar());

我们删除这个调用,因为我们将向盒子中添加工具栏,并将盒子添加到窗口中。让我们这样做,但在我们这样做之前,我们将添加一些新的导入:

use gtk::{
    Adjustment,
    Image,
    ImageExt,
    Scale,
    ScaleExt,
};
use gtk::Orientation::{Horizontal, Vertical};

然后,创建盒子:

let vbox = gtk::Box::new(Vertical, 0);
window.add(&vbox);

(此代码将放入App::new()方法中。)

在这里,我们完全限定了gtk::Box,因为Box是标准库中的一个类型,它会被自动导入。我们指定了盒子的方向是垂直的,并且容器的小部件之间没有间隔(0)。

现在我们准备好向这个盒子添加小部件:

let toolbar = MusicToolbar::new();
vbox.add(toolbar.toolbar());

let cover = Image::new();
cover.set_from_file("cover.jpg");
vbox.add(&cover);

我们首先添加我们的工具栏,然后添加一个图片并从静态文件中加载封面,因为我们还没有编写从 MP3 文件中提取封面的代码。

让我们再添加一个光标小部件:

let adjustment = Adjustment::new(0.0, 0.0, 10.0, 0.0, 0.0, 0.0);
let scale = Scale::new(Horizontal, &adjustment);
scale.set_draw_value(false);
vbox.add(&scale);

光标小部件命名为Scale。这个小部件需要一个Adjustment,它是一个表示光标可以取哪些值的对象,并且包含当前值和增量值。同样,由于我们不知道如何从 MP3 文件中获取歌曲的持续时间,我们为Adjustment硬编码了值。我们还通过调用set_draw_value(false)禁用了显示光标实际值的特性。

如果你运行应用程序,你会看到以下内容:

图 5.6图 5.6

(我们几乎可以听到音乐,当我们看着它的时候。)

为了结束本节,我们将在App结构中添加几个字段,使其变为:

struct App {
    adjustment: Adjustment,
    cover: Image,
    toolbar: MusicToolbar,
    window: ApplicationWindow,
}

App构造函数的末尾更新为:

impl App {
    fn new(application: Application) -> Self {
        // ...

        window.show_all();

        let app = App {
            adjustment,
            cover,
            toolbar,
            window,
        };

        app.connect_events();
        app.connect_toolbar_events();

        app
    }
}

添加播放列表

我们现在准备好将播放列表小部件添加到我们的音乐播放器中。

我们将使用新的 crate,所以请在main.rs文件中添加以下内容:

extern crate gdk_pixbuf;
extern crate id3;

将使用gdk_pixbuf crate 来显示和操作封面,以及id3 crate 来从 MP3 文件中获取元数据。

此外,将以下内容添加到Cargo.toml

gdk-pixbuf = "⁰.3.0"
id3 = "⁰.2.0"

然后,我们将创建一个新的模块来包含这个新小部件:

mod playlist;

我们将从这个模块开始,添加一些use语句:

use std::path::Path;

use gdk_pixbuf::{InterpType, Pixbuf, PixbufLoader};
use gtk::{
    CellLayoutExt,
    CellRendererPixbuf,
    CellRendererText,
    ListStore,
    ListStoreExt,
    ListStoreExtManual,
    StaticType,
    ToValue,
    TreeIter,
    TreeModelExt,
    TreeSelectionExt,
    TreeView,
    TreeViewColumn,
    TreeViewColumnExt,
    TreeViewExt,
    Type,
    WidgetExt,
};
use id3::Tag;

接下来,我们将添加一些常量:

const THUMBNAIL_COLUMN: u32 = 0;
const TITLE_COLUMN: u32 = 1;
const ARTIST_COLUMN: u32 = 2;
const ALBUM_COLUMN: u32 = 3;
const GENRE_COLUMN: u32 = 4;
const YEAR_COLUMN: u32 = 5;
const TRACK_COLUMN: u32 = 6;
const PATH_COLUMN: u32 = 7;
const PIXBUF_COLUMN: u32 = 8;

const IMAGE_SIZE: i32 = 256;
const THUMBNAIL_SIZE: i32 = 64;

*_COLUMN 常量代表我们将在播放列表中显示的列。最后一个,PIXBUF_COLUMN,有点特别:它将是一个隐藏的列,用于存储更大尺寸的封面,这样我们就可以在我们之前创建的cover小部件中显示这张图片。

接下来,我们将创建一个新的结构来存放小部件及其模型:

pub struct Playlist {
    model: ListStore,
    treeview: TreeView,
}

MVC 模式

对于列表和树小部件,GTK+遵循 MVC 模式。MVC 代表模型-视图-控制器。

现在我们可以为我们的播放列表添加一个构造函数:

impl Playlist {
    pub fn new() -> Self {
        let model = ListStore::new(&[
            Pixbuf::static_type(),
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Pixbuf::static_type(),
        ]);
        let treeview = TreeView::new_with_model(&model);
        treeview.set_hexpand(true);
        treeview.set_vexpand(true);

        Self::create_columns(&treeview);

        Playlist {
            model,
            treeview,
        }
    }
}

gtk::ListStore类型是一个将数据表示为列表的模型。它的构造函数需要列的类型;在这种情况下,大多数类型都是字符串,用于 MP3 文件的元数据,例如歌曲标题和作者姓名。第一个Pixbuf用于缩略图图像,最后一个用于仅显示当前播放音乐的较大图像。

接下来,我们创建一个TreeView,它实际上是一个列表的视图,因为我们用我们的列表模型初始化它。然后我们修改 widget,使其在垂直和水平方向上扩展,这意味着 widget 将尽可能使用空间。最后,在返回struct Playlist之前,我们调用create_columns()方法,该方法将创建在此视图中显示的列。让我们看看这个新方法:

    fn create_columns(treeview: &TreeView) {
        Self::add_pixbuf_column(treeview, THUMBNAIL_COLUMN as i32, 
         Visible);
        Self::add_text_column(treeview, "Title", TITLE_COLUMN as i32);
        Self::add_text_column(treeview, "Artist", ARTIST_COLUMN as i32);
        Self::add_text_column(treeview, "Album", ALBUM_COLUMN as i32);
        Self::add_text_column(treeview, "Genre", GENRE_COLUMN as i32);
        Self::add_text_column(treeview, "Year", YEAR_COLUMN as i32);
        Self::add_text_column(treeview, "Track", TRACK_COLUMN as i32);
        Self::add_pixbuf_column(treeview, PIXBUF_COLUMN as i32, Invisible);
    }

在这里,我们调用两个方法来创建不同类型的列——我们指定每个列的表头标签和列号。至于add_pixbuf_column()方法的最后一个参数,它表示列是否可见。这个参数是一个自定义类型,所以让我们声明它:

use self::Visibility::*;

#[derive(PartialEq)]
enum Visibility {
    Invisible,
    Visible,
}

我们还添加了一个use语句,以便能够直接使用Visible而不是必须完全限定它(Visibility::Visible)。

让我们编写add_text_column()方法:

    fn add_text_column(treeview: &TreeView, title: &str, column: i32) {
        let view_column = TreeViewColumn::new();
        view_column.set_title(title);
        let cell = CellRendererText::new();
        view_column.set_expand(true);
        view_column.pack_start(&cell, true);
        view_column.add_attribute(&cell, "text", column);
        treeview.append_column(&view_column);
    }

我们首先创建列本身,并通过调用set_title()设置表头标签。然后,我们创建一个CellRenderer,它指示模型数据在视图中的渲染方式;在这里,我们只想显示一些文本,所以我们选择了CellRendererText,将其设置为尽可能占用空间,并将渲染器添加到列中。接下来是一行非常重要的代码:

view_column.add_attribute(&cell, "text", column);

这行代码指定视图将从模型在指定列的数据中设置text属性。

最后,我们将列添加到视图中。

现在,我们将为pixbuf编写一个类似的功能:

    fn add_pixbuf_column(treeview: &TreeView, column: i32, visibility:
    Visibility) {
        let view_column = TreeViewColumn::new();
        if visibility == Visible {
            let cell = CellRendererPixbuf::new();
            view_column.pack_start(&cell, true);
            view_column.add_attribute(&cell, "pixbuf", column);
        }
        treeview.append_column(&view_column);
    }

在这里,我们创建了一种新的渲染器类型(CellRendererPixbuf),它将显示图像而不是文本。这次,我们设置了pixbuf属性,因为我们想显示一个图像。只有当列可见时,才会创建渲染器。

现在,剩下的只是编写一个函数来获取实际的 widget,以便能够在main模块中添加 widget:

    pub fn view(&self) -> &TreeView {
        &self.treeview
    }

让我们回到App::new()方法,并创建播放列表:

let playlist = Playlist::new();
vbox.add(playlist.view());

(在创建Image之前添加此代码。)

我们还会在结构中添加一个playlist属性:

struct App {
    adjustment: Adjustment,
    cover: Image,
    playlist: Playlist,
    toolbar: MusicToolbar,
    window: Window,
}

此外,别忘了编辑结构的创建以包括以下新字段:

let app = App {
    adjustment,
    cover,
    playlist,
    toolbar,
    window,
};

现在,我们准备再次启动我们的应用程序,以查看一个空白的播放列表:

图 5.7图 5.7

打开 MP3 文件

让我们通过添加打开 MP3 文件并显示其元数据在刚刚创建的播放列表小部件中的功能来完成这一章。

首先,我们将删除这一行:

cover.set_from_file("cover.jpg");

这是因为图像将从我们播放的 MP3 文件的数据中设置。

我们将使用一个新的 crate,所以请在你的Cargo.toml[dependencies]部分添加这一行:

gtk-sys = "⁰.5.0"

此外,还需要在您的main.rs中添加以下行:

extern crate gtk_sys;

gtk-rs生态系统中的*-syscrate 是低级 crate,它们直接绑定到 GTK+ C 库。由于它们非常低级且需要使用 unsafe 代码,因此创建了包装器;这些是没有-sys后缀的 crate,例如gtkgdk

引用计数指针

在我们继续之前,我们还将更改一些代码。由于我们希望将我们的Playlist小部件与代码的不同部分共享,包括一些事件处理程序,我们需要一种方法来共享一个持续时间足够长的引用(记住我们遇到的寿命问题)。这样做的一个简单方法是使用引用计数指针类型——Rc。因此,在我们的App结构中,让我们将playlist字段更改为使用Rc

struct App {
    adjustment: Adjustment,
    cover: Image,
    playlist: Rc<Playlist>,
    toolbar: MusicToolbar,
    window: Window,
}

这需要在main模块的顶部添加一个新的导入:

use std::rc::Rc;

此外,还需要更新播放列表的创建:

let playlist = Rc::new(Playlist::new());

现在,我们将Playlist包装在一个Rc中。只要我们调用不可变方法,即那些接受&self但不接受&mut self的方法,我们仍然可以使用播放列表,就像以前一样。所以,下一行仍然有效:

vbox.add(playlist.view());

在我们创建将 MP3 文件添加到播放列表的方法之前,我们需要另一个方法来从 MP3 元数据中设置模型中的pixbuf值。在impl Playlist中添加以下方法:

const INTERP_HYPER: InterpType = 3;

    fn set_pixbuf(&self, row: &TreeIter, tag: &Tag) {
        if let Some(picture) = tag.pictures().next() {
            let pixbuf_loader = PixbufLoader::new();
            pixbuf_loader.set_size(IMAGE_SIZE, IMAGE_SIZE);
            pixbuf_loader.loader_write(&picture.data).unwrap();
            if let Some(pixbuf) = pixbuf_loader.get_pixbuf() {
                let thumbnail = pixbuf.scale_simple(THUMBNAIL_SIZE, 
                THUMBNAIL_SIZE, INTERP_HYPER).unwrap();
                self.model.set_value(row, THUMBNAIL_COLUMN,
                 &thumbnail.to_value());
                self.model.set_value(row, PIXBUF_COLUMN, 
                 &pixbuf.to_value());
            }
            pixbuf_loader.close().unwrap();
        }
    }

类型Tag表示 MP3 文件的元数据。我们获取文件中包含的第一张图片并将其加载。如果加载成功,我们将它调整大小以获取缩略图,然后设置模型中的值。

ID3—MP3 元数据

现在我们已经准备好从 MP3 文件中获取所有相关元数据并将其添加到播放列表中。让我们通过获取元数据来开始Playlist::add()方法:

    pub fn add(&self, path: &Path) {
        let filename =  
         path.file_stem().unwrap_or_default().to_str().unwrap_or_default();

        let row = self.model.append();

        if let Ok(tag) = Tag::read_from_path(path) {
            let title = tag.title().unwrap_or(filename);
            let artist = tag.artist().unwrap_or("(no artist)");
            let album = tag.album().unwrap_or("(no album)");
            let genre = tag.genre().unwrap_or("(no genre)");
            let year = tag.year().map(|year| 
            year.to_string()).unwrap_or("(no  
            year)".to_string());
            let track = tag.track().map(|track| 
            track.to_string()).unwrap_or("??".to_string());
            let total_tracks = tag.total_tracks().map(|total_tracks|  
            total_tracks.to_string()).unwrap_or("??".to_string());
            let track_value = format!("{} / {}", track, total_tracks);

我们首先获取没有扩展名的文件名并将其转换为字符串;如果没有歌曲标题在文件中,我们将显示这个。然后,我们从文件中读取元数据,并在调用unwrap_or()的情况下分配一个默认值,例如"(no artist)",如果值缺失。unwrap_or()Option获取值,如果值是None,则返回参数。

现在让我们看看方法的其余部分:

            self.set_pixbuf(&row, &tag);

            self.model.set_value(&row, TITLE_COLUMN, &title.to_value());
            self.model.set_value(&row, ARTIST_COLUMN, &artist.to_value());
            self.model.set_value(&row, ALBUM_COLUMN, &album.to_value());
            self.model.set_value(&row, GENRE_COLUMN, &genre.to_value());
            self.model.set_value(&row, YEAR_COLUMN, &year.to_value());
            self.model.set_value(&row, TRACK_COLUMN, 
             &track_value.to_value());
        }
        else {
            self.model.set_value(&row, TITLE_COLUMN, &filename.to_value());
        }

        let path = path.to_str().unwrap_or_default();
        self.model.set_value(&row, PATH_COLUMN, &path.to_value());
    }

在这里,我们在模型中创建一个新的行,并调用我们刚才创建的set_pixbuf()。之后,我们在新行中设置值。一个特殊的值是路径,这在稍后我们想要从播放列表中播放所选歌曲时将很有用;我们只需要获取路径然后播放它。

使用文件对话框打开文件

在我们可以处理打开按钮的点击事件之前,我们还需要另一个函数。我们需要一个函数来显示文件对话框,允许用户选择文件:

use std::path::PathBuf;

use gtk::{FileChooserAction, FileChooserDialog, FileFilter};

fn show_open_dialog(parent: &ApplicationWindow) -> Option<PathBuf> {
    let mut file = None;
    let dialog = FileChooserDialog::new(Some("Select an MP3 audio
     file"), 
    Some(parent), FileChooserAction::Open);
    let filter = FileFilter::new();
    filter.add_mime_type("audio/mp3");
    filter.set_name("MP3 audio file");
    dialog.add_filter(&filter);
    dialog.add_button("Cancel", RESPONSE_CANCEL);
    dialog.add_button("Accept", RESPONSE_ACCEPT);
    let result = dialog.run();
    if result == RESPONSE_ACCEPT {
        file = dialog.get_filename();
    }
    dialog.destroy();
    file
}

此函数首先创建一个类型为 open 的新文件对话框。之后,它向此对话框添加一个过滤器,以便只显示 MP3 文件。然后,我们使用我们稍后定义的一些常量添加两个按钮。目前,我们可以通过调用 run() 来显示对话框;此函数会阻塞,直到对话框关闭,并返回被点击的按钮。之后,我们检查是否点击了接受按钮以保存用户选择的文件名,并返回该文件名。

这是之前函数所需的常量:

use gtk_sys::{GTK_RESPONSE_ACCEPT, GTK_RESPONSE_CANCEL};

const RESPONSE_ACCEPT: i32 = GTK_RESPONSE_ACCEPT as i32;
const RESPONSE_CANCEL: i32 = GTK_RESPONSE_CANCEL as i32;

我们现在已准备好处理打开按钮的点击事件。在方法 App::connect_toolbar_events() 中添加以下内容:

        let parent = self.window.clone();
        let playlist = self.playlist.clone();
        self.toolbar.open_button.connect_clicked(move |_| {
            let file = show_open_dialog(&parent);
            if let Some(file) = file {
                playlist.add(&file);
            }
        });

在事件处理程序中,我们调用我们刚刚定义的函数,如果选择了文件,我们调用播放列表的 add() 方法。

您现在可以尝试应用程序并打开一个 MP3 文件。您将看到以下内容:

图 5.8

在结束本章之前,让我们添加两个更多功能。第一个是从播放列表中删除一首歌曲。

删除歌曲

我们需要向 Playlist 结构中添加一个方法来删除所选项:

    pub fn remove_selection(&self) {
        let selection = self.treeview.get_selection();
        if let Some((_, iter)) = selection.get_selected() {
            self.model.remove(&iter);
        }
    }

这首先从获取选择项开始,如果有,我们从模型中移除它。现在我们可以在 App::connect_toolbar_events() 方法中为删除按钮添加事件处理程序:

     let playlist = self.playlist.clone();
     self.toolbar.remove_button.connect_clicked(move |_| {
       playlist.remove_selection();
     });

这段代码没有新内容;我们只是在按钮点击时简单地克隆引用计数的播放列表并调用其上的一个方法。

播放歌曲时显示封面

另一个要添加的功能是在点击播放按钮时显示更大的封面。我们将首先添加一个从播放列表中的选择项获取图像的函数:

    pub fn pixbuf(&self) -> Option<Pixbuf> {
        let selection = self.treeview.get_selection();
        if let Some((_, iter)) = selection.get_selected() {
            let value = self.model.get_value(&iter, PIXBUF_COLUMN as i32);
            return value.get::<Pixbuf>();
        }
        None
    }

要添加到 Playlist 结构中的此方法首先获取选择项;如果有,它从模型中获取 pixbuf 并返回它。否则,它返回 None

我们现在可以编写一个函数,该函数将从播放列表中获取封面并显示图像:

use gtk::Image;

use playlist::Playlist;

fn set_cover(cover: &Image, playlist: &Playlist) {
    cover.set_from_pixbuf(playlist.pixbuf().as_ref());
    cover.show();
}

toolbar 模块中添加此函数。最后,我们可以从播放按钮的点击事件处理程序中调用此函数:

        let playlist = self.playlist.clone();
        let cover = self.cover.clone();
        self.toolbar.play_button.connect_clicked(move |_| {
            if play_button.get_stock_id() == Some(PLAY_STOCK.to_string()) {
                play_button.set_stock_id(PAUSE_STOCK);
                set_cover(&cover, &playlist);
            } else {
                play_button.set_stock_id(PLAY_STOCK);
            }
        });

添加歌曲并点击播放后的结果:

图 5.9

摘要

本章首先向您展示了如何在您的机器上安装 GTK+。然后您学习了如何使用 gtk-rs 创建窗口,管理用户事件如鼠标点击,向窗口添加不同类型的控件,使用容器排列控件,以及使用库存项显示漂亮的图标。您还了解了如何使用遵循 MVC 模式的复杂 GTK+控件。

您在 Rust 的闭包、生命周期和所有权等领域也获得了更多的知识,这些是该语言的关键概念。

最后,您学习了如何通过获取 ID3 标签来提取 MP3 文件的元数据。

在下一章中,我们将改进音乐播放器,使其能够真正播放一首歌曲。

第六章:实现音乐播放器引擎

在上一章中,我们实现了音乐播放器的用户界面,但它无法播放任何音乐。我们将在这个章节中解决这个挑战。我们将创建音乐播放器的引擎,使其能够播放 MP3 文件。为此,我们需要使用线程,以确保播放歌曲不会冻结界面,这将是一个学习 Rust 并发的好机会。

在本章中,我们将涵盖以下主题:

  • MP3 解码器

  • 线程

  • 互斥锁和互斥锁保护

  • Send/Sync 特质

  • RAII

  • 线程安全性

  • 内部可变性

安装依赖项

对于本章,我们需要两个库:pulseaudiolibmad

前者将用于播放音乐,而后者用于解码 MP3 文件。

在 Linux 上安装依赖项

在 Linux 上,这些依赖项可以通过您发行版的包管理器安装。

在 Ubuntu(或其他 Debian 衍生版)上:

sudo apt-get install libmad0-dev libpulse-dev

在 Mac 上安装依赖项

在 OSX 上,可以通过系统包管理器安装所需的依赖项,如下所示:

brew install libmad pulseaudio

在 Windows 上安装依赖项

在 Windows 上,在 MSYS2 壳中运行以下命令:

pacman -S mingw-w64-libmad

思考,点击此页面的链接下载 zip 文件:www.freedesktop.org/wiki/Software/PulseAudio/Ports/Windows/Support/(当本书编写时,当前版本的链接为bosmans.ch/pulseaudio/pulseaudio-1.1.zip)。然后,按照第二章中“从 SDL 开始”的相同说明,使用 Rust 库。

解码 MP3 文件

我们将从这个章节开始学习如何使用simplemadcrate(libmad的绑定)将 MP3 文件解码为操作系统可播放的格式,这将帮助我们理解如何解码 MP3 文件。

添加依赖项

让我们在Cargo.toml中添加以下内容:

crossbeam = "⁰.3.0"
pulse-simple = "¹.0.0"
simplemad = "⁰.8.1"

我们还添加了pulse-simplecrossbeamcrate,因为我们在以后需要它们。前者将用于使用pulseaudio播放歌曲,后者将用于实现音乐播放器引擎的事件循环。

我们还需要在main.rs中添加以下语句:

extern crate crossbeam;
extern crate pulse_simple;
extern crate simplemad;

mod mp3;

除了extern crate语句外,我们还有一个mod语句,因为我们将为 MP3 解码器创建一个新的模块。

实现 MP3 解码器

现在我们已经准备好创建这个新模块了。创建一个名为mp3.rs的新文件,内容如下:

use std::io::{Read, Seek, SeekFrom};
use std::time::Duration;

use simplemad;

我们像往常一样从这个模块开始,添加一些导入语句。其中重要的是simplemad,它将被用来解码 MP3 文件的帧:

pub struct Mp3Decoder<R> where R: Read {
    reader: simplemad::Decoder<R>,
    current_frame: simplemad::Frame,
    current_frame_channel: usize,
    current_frame_sample_pos: usize,
    current_time: u64,
}

在第一章,“Rust 基础”中,我们了解到我们可以在函数的泛型参数中添加特质界限。我们也可以将它们添加到类型的泛型参数中。这里我们看到使用where子句的替代语法。前面的结构声明与以下相同:

pub struct Mp3Decoder<R: Read> {
    // …
}

where子句在有很多泛型参数时很有用。

这个结构包含有关当前帧和时间以及解码器本身的信息,解码器来自simplemadcrate。这个Decoder还需要一个实现Read特质的泛型参数,所以我们只需使用自己的R参数,因为我们指定它必须实现这个特质。

在我们继续到这个类型的构造函数之前,我们将实现几个utility函数。让我们从一个将Duration转换为毫秒数的函数开始(这个函数将放在main.rs文件中,因为我们将在另一个模块中使用它):

fn to_millis(duration: Duration) -> u64 {
    duration.as_secs() * 1000 + duration.subsec_nanos() as u64 / 1_000_000
}

在这里,我们简单地乘以秒数乘以1,000,并将纳秒数除以1,000,000。这个函数需要您添加对Duration的导入语句:

use std::time::Duration;

接下来,我们将编写一个函数来检查数据流是否为 MP3 文件:

fn is_mp3<R>(mut data: R) -> bool where R: Read + Seek {
    let stream_pos = data.seek(SeekFrom::Current(0)).unwrap();
    let is_mp3 = simplemad::Decoder::decode(data.by_ref()).is_ok();
    data.seek(SeekFrom::Start(stream_pos)).unwrap();
    is_mp3
}

为了做到这一点,我们尝试解码流,如果结果是Ok,则数据是 MP3 文件。然后我们回到文件的开头,在返回是否是 MP3 文件之前。

下一个我们需要的功能是解码 MP3 文件的下一个帧:

fn next_frame<R: Read>(decoder: &mut simplemad::Decoder<R>) -> simplemad::Frame {
    decoder.filter_map(|f| f.ok()).next()
        .unwrap_or_else(|| {
            simplemad::Frame {
                bit_rate: 0,
                layer: Default::default(),
                mode: Default::default(),
                sample_rate: 44100,
                samples: vec![Vec::new()],
                position: Duration::from_secs(0),
                duration: Duration::from_secs(0),
            }
        })
}

在这里,我们简单地从解码器获取下一个帧,并通过调用and_then(Result::ok)Option<Result<Frame>>扁平化为Option<Frame>。如果没有帧,我们返回一个默认帧。

现在,让我们实现我们的 MP3 解码器的构造函数:

impl<R> Mp3Decoder<R> where R: Read + Seek {
    pub fn new(mut data: R) -> Result<Mp3Decoder<R>, R> {
        if !is_mp3(data.by_ref()) {
            return Err(data);
        }

        let mut reader = simplemad::Decoder::decode(data).unwrap();

        let current_frame = next_frame(&mut reader);
        let current_time = to_millis(current_frame.duration);

        Ok(Mp3Decoder {
            reader,
            current_frame,
            current_frame_channel: 0,
            current_frame_sample_pos: 0,
            current_time,
        })
    }
}

您需要在文件顶部添加一个导入语句,以便能够使用to_millis函数,该函数位于main模块中:

use to_millis;

由于use语句相对于 crate 的根目录,我们只需写出函数名,因为这个函数在 crate 的根目录。

构造函数首先检查流是否包含 MP3 数据,如果不是,我们返回一个错误。否则,我们从simplemadcrate 创建一个Decoder。然后,我们读取第一个帧并获取其毫秒时间。

接下来,我们编写两个方法来获取 MP3 文件当前的时间和速率:

    pub fn current_time(&self) -> u64 {
        self.current_time
    }

    pub fn samples_rate(&self) -> u32 {
        self.current_frame.sample_rate
    }

这些方法需要添加到impl Mp3Decoder块中。要添加到这个结构中的最后一个方法是计算歌曲的持续时间:

    pub fn compute_duration(mut data: R) -> Option<Duration> {
        if !is_mp3(data.by_ref()) {
            return None;
        }

        let decoder = simplemad::Decoder::decode_headers(data).unwrap();
        Some(decoder.filter_map(|frame| {
            match frame {
                Ok(frame) => Some(frame.duration),
                Err(_) => None,
            }
        })
            .sum())
    }

在这里,我们创建一个关联函数:它首先检查是否是 MP3 数据。在这里,我们不是使用Decoder::decode(),而是使用Decoder::decode_headers(),因为我们只需要帧持续时间,只解码头部更快。decoder是一个迭代器,我们在它上面调用filter_map()。正如你在第二章中看到的,从 SDL 开始filter_map()会转换和过滤迭代器的元素。通过返回Some(new_value)来转换值,而通过返回None来过滤掉值。之后,我们在结果迭代器上调用sum()以获取所有持续时间的总和。

获取帧样本

我们 MP3 解码器所需的功能仅剩下能够遍历样本。我们首先编写一个获取下一个样本的函数:

fn next_sample<R: Read>(decoder: &mut Mp3Decoder<R>) -> Option<i16> {
    if decoder.current_frame.samples[0].len() == 0 {
        return None;
    }

    // getting the sample and converting it from fixed step to i16
    let sample = decoder.current_frame.samples[decoder.current_frame_channel] 
    [decoder.current_frame_sample_pos];
    let sample = sample.to_i32() + (1 << (28 - 16));
    let sample = if sample >= 0x10000000 { 0x10000000 - 1 } else if sample <=  
    -0x10000000 { -0x10000000 } else { sample };
    let sample = sample >> (28 + 1 - 16);
    let sample = sample as i16;

    decoder.current_frame_channel += 1;

    if decoder.current_frame_channel < decoder.current_frame.samples.len() {
        return Some(sample);
    }

    decoder.current_frame_channel = 0;
    decoder.current_frame_sample_pos += 1;

    if decoder.current_frame_sample_pos < decoder.current_frame.samples[0].len() {
        return Some(sample);
    }

    decoder.current_frame = next_frame(&mut decoder.reader);
    decoder.current_frame_channel = 0;
    decoder.current_frame_sample_pos = 0;
    decoder.current_time += to_millis(decoder.current_frame.duration);

    return Some(sample);
}

这个函数正在进行一些位移动以获取样本,然后获取下一个帧。我们现在准备好实现一个将使用此函数的迭代器:

impl<R> Iterator for Mp3Decoder<R> where R: Read {
    type Item = i16;

    fn next(&mut self) -> Option<i16> {
        next_sample(self)
    }

    fn size_hint(&self) -> (usize, Option<usize>) {
        (self.current_frame.samples[0].len(), None)
    }
}

如你所见,我们可以通过实现Iterator特质来创建自己的迭代器。唯一需要实现的方法是next()。通过实现这个简单的方法,我们获得了一大批功能,因为这个特质有很多默认方法。type Item也是必需的。我们实现了size_hint()方法,尽管它是可选的。

播放音乐

MP3 解码器完成之后,我们现在可以播放音乐了。我们将创建一个新的模块,命名为 player,并将其添加到main.rs的顶部:

mod player;

我们将从这个模块开始,创建一个包含以下导入语句的player.rs新文件:

use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;

use crossbeam::sync::SegQueue;
use pulse_simple::Playback;

use mp3::Mp3Decoder;
use self::Action::*;

我们还将创建一些常量:

const BUFFER_SIZE: usize = 1000;
const DEFAULT_RATE: u32 = 44100;

缓冲区大小是我们将解码和播放的样本数量,以避免在播放歌曲时出现减速,同时也避免通过同时读取和解码数据来使用 100%的 CPU。当我们在 MP3 文件中找不到时,将使用默认速率。

事件循环

为了简化播放引擎的开发,我们将使用事件循环的概念。一些操作将通过这个事件循环发送到播放音乐的线程。例如,我们将能够发出一个Load("file.mp3")事件,线程将解码这个 MP3 文件,并开始播放。另一个事件示例是Stop,它将停止播放并卸载数据。

让我们创建一个枚举来表示可能的操作:

enum Action {
    Load(PathBuf),
    Stop,
}

我们现在准备好创建事件循环的结构:

#[derive(Clone)]
struct EventLoop {
    queue: Arc<SegQueue<Action>>,
    playing: Arc<Mutex<bool>>,
}

这个结构中有许多未知的内容,让我们来分解它。

原子引用计数

首先,我们使用Arc类型。它与我们之前章节中使用的Rc类型类似,因为它是一个提供引用计数的类型。这两个类型之间的区别在于Arc使用原子操作来增加其计数器。由于是原子的,它可以在多个线程中安全使用,而Rc则不能在多个线程中安全使用(编译器会阻止我们这样做)。标准库提供这两种类型,以便你可以选择你愿意付出的代价。如果你不需要与多个线程共享引用计数值,请选择Rc,因为它比Arc更高效。如果你尝试将Rc发送到另一个线程,编译器将触发一个错误:

error[E0277]: the trait bound `std::rc::Rc<i32>: std::marker::Send` is not satisfied in `[closure@src/main.rs:6:19: 8:6 rc:std::rc::Rc<i32>]`
 --> src/main.rs:6:5
  |
6 |     thread::spawn(move || {
  |     ^^^^^^^^^^^^^ `std::rc::Rc<i32>` cannot be sent between threads safely
  |
  = help: within `[closure@src/main.rs:6:19: 8:6 rc:std::rc::Rc<i32>]`, the trait `std::marker::Send` is not implemented for `std::rc::Rc<i32>`
  = note: required because it appears within the type `[closure@src/main.rs:6:19: 8:6 rc:std::rc::Rc<i32>]`
  = note: required by `std::thread::spawn`

在这种情况下,你需要切换到Arc。当我们看到Send特质是什么时,这个错误将更有意义。

互斥

play区域,Arc中包含一个Mutex。互斥锁提供互斥性,意味着它允许我们锁定其内部值(在这种情况下,一个bool),防止其他线程同时操作相同的值。它通过防止对值的并发读写来防止数据竞争,这是未定义行为的原因。

发送特质

但编译器是如何防止我们发生数据竞争的呢?这要归功于SendSync标记特质。实现了Send特质的数据类型可以安全地发送到另一个线程。正如你可能猜到的,Rc没有实现Send。由于它没有使用原子操作来增加其计数器,如果两个线程同时增加它,那将是一个数据竞争。

同步特质

让我们讨论第二个标记特质:Sync。实现了Sync特质的数据类型可以安全地与多个线程共享。一个Sync类型的例子是Mutex。它是安全的,因为从Mutex获取值的唯一方式是锁定它,这是互斥的(另一个线程不能同时访问相同的值)。

无锁数据结构

剩下要解释的唯一类型是来自crossbeam包的SegQueue。这个类型是一个无锁队列,意味着它可以由多个线程并发使用而不需要锁。无锁数据结构的实现超出了本书的范围,但可以说它使用原子操作在幕后,这样我们就不需要使用Mutex来在可变线程中同时修改这个值。我们仍然需要用Arc包装这个队列,以便能够与多个线程共享。

我们使用无锁数据结构,因为我们将在队列中不断检查是否有新元素,同时可能从另一个线程向这个队列添加新元素。如果我们使用Mutex<VecDeque<Action>>,它将效率更低,因为在对Mutex调用lock()时,如果锁被另一个线程持有,它将等待。

让我们回到我们的事件循环。让我们为EventLoop添加一个构造函数:

impl EventLoop {
    fn new() -> Self {
        EventLoop {
            queue: Arc::new(SegQueue::new()),
            playing: Arc::new(Mutex::new(false)),
        }
    }
}

此构造函数简单地创建队列和布尔值,并将其包裹在Mutex中。

在使用它之前,我们将创建一个State结构,该结构将包含 GUI 线程和音乐播放器线程之间共享的各种数据,将此代码放在main模块中:

struct State {
    stopped: bool,
}

还需要在App结构中添加一个state字段:

struct App {
    adjustment: Adjustment,
    cover: Image,
    playlist: Rc<Playlist>,
    state: Arc<Mutex<State>>,
    toolbar: MusicToolbar,
    window: Window,
}

这需要一个新的导入语句:

use std::sync::{Arc, Mutex};

由于这个值将被另一个线程共享,我们需要将其包裹在Arc<Mutex>中。然后在构造函数中,创建这个值并将其分配给这个新字段,同时将其发送到Playlist构造函数:

impl App {
    fn new() -> Self {
        // …

        let state = Arc::new(Mutex::new(State {
            stopped: true,
        }));

        let playlist = Rc::new(Playlist::new(state.clone()));

        // …

        let app = App {
            adjustment,
            cover,
            playlist,
            state,
            toolbar,
            window,
        };

        // …
    }
}

让我们更新Playlist构造函数:

impl Playlist {
    pub(crate) fn new(state: Arc<Mutex<State>>) -> Self {
        let model = ListStore::new(&[
            Pixbuf::static_type(),
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Pixbuf::static_type(),
        ]);
        let treeview = TreeView::new_with_model(&model);
        treeview.set_hexpand(true);
        treeview.set_vexpand(true);

        Self::create_columns(&treeview);

        Playlist {
            model,
            player: Player::new(state.clone()),
            treeview,
        }
    }
}

结构需要一个新的字段,所以让我们添加它:

pub struct Playlist {
    model: ListStore,
    player: Player,
    treeview: TreeView,
}

这也需要新的导入语句:

use std::sync::{Arc, Mutex};

use State;
use player::Player;

我们使用pub(crate)语法来抑制错误。由于我们在公共方法中使用私有类型(State),编译器会抛出一个错误。这个语法意味着该函数对 crate 的其他模块是公共的,但其他 crate 无法访问它。在这里,我们只向Player构造函数发送state,我们将立即实现它。

播放音乐

我们将创建一个新的Player结构来包裹事件循环。播放器将从主线程中可用,以控制音乐。以下是该结构的本身:

pub struct Player {
    app_state: Arc<Mutex<super::State>>,
    event_loop: EventLoop,
}

这里是其构造函数的开始:

impl Player {
    pub(crate) fn new(app_state: Arc<Mutex<super::State>>) -> Self {
        let event_loop = EventLoop::new();

        {
            let app_state = app_state.clone();
            let event_loop = event_loop.clone();
            thread::spawn(move || {
                // …
            });
        }

        Player {
            app_state,
            event_loop,
        }
    }
}

我们首先创建一个新的事件循环。然后,我们启动一个新的线程。我们使用一个新的作用域来避免必须重命名将被发送到线程的变量,因为这些变量在构造函数末尾的结构初始化中使用。再次,我们需要使用move闭包,因为我们正在向线程发送事件循环和应用程序状态的副本。

让我们看看线程闭包的第一部分:

thread::spawn(move || {
    let mut buffer = [[0; 2]; BUFFER_SIZE];
    let mut playback = Playback::new("MP3", "MP3 Playback", None,
    DEFAULT_RATE);
    let mut source = None;
    loop {
        if let Some(action) = event_loop.queue.try_pop() {
            match action {
                Load(path) => {
                    let file = File::open(path).unwrap();
                    source = Some(Mp3Decoder::new(BufReader::new(file)).unwrap());
                    let rate = source.as_ref().map(|source| 
                     source.samples_rate()).unwrap_or(DEFAULT_RATE);
                    playback = Playback::new("MP3", "MP3 Playback", 
                     None, rate);
                    app_state.lock().unwrap().stopped = false;
                },
                Stop => {},
            }
        }
        // …
    }
});

我们首先创建一个缓冲区来包含要播放的样本。然后我们将创建一个Playback,这是一个对象,它将允许我们在硬件上播放音乐。我们还将创建一个source变量,它将包含一个Mp3Decoder。然后我们启动一个无限循环并尝试获取队列中的第一个元素:如果队列中有元素,则返回Some(action)。这就是为什么我们使用了if let来对方法调用的结果进行模式匹配。然后我们对动作进行匹配以查看它是哪种动作:如果是Load动作,我们使用指定的路径打开文件,并使用该文件的缓冲读取器创建一个Mp3Decoder。然后我们尝试获取歌曲的采样率并使用这个率创建一个新的Playback。我们稍后会处理Stop动作。

最后,我们看到我们第一次使用Mutex

app_state.lock().unwrap().stopped = false;

让我们以另一种方式重写它来看看发生了什么:

let mut guard = app_state.lock().unwrap();
guard.stopped = false;

我们首先调用lock(),它返回一个Result<MutexGuard<T>, PoisonError<MutexGuard<T>>>

Mutex guard

锁定器(mutex guard)是一个作用域锁:这意味着当超出作用域时,互斥锁将被自动解锁。这是一种确保用户会使用Mutex并且不会忘记解锁它的好方法。

RAII

但它背后是如何工作的呢?Rust 使用资源获取即初始化RAII)的惯用用法。使用这个惯用用法,资源在构造函数中分配,并在析构函数中释放。在 Rust 中,析构函数是通过Drop特质实现的。所以,回到互斥锁守卫,当MutexGuard的析构函数被调用时,互斥锁会被解锁,所以,就像之前的例子一样,当guard变量超出作用域时。

让我们回到我们的无限循环:

loop {
    if let Some(action) = event_loop.queue.try_pop() {
        // …
    } else if *event_loop.playing.lock().unwrap() {
        let mut written = false;
        if let Some(ref mut source) = source {
            let size = iter_to_buffer(source, &mut buffer);
            if size > 0 {
                playback.write(&buffer[..size]);
                written = true;
            }
        }

        if !written {
            app_state.lock().unwrap().stopped = true;
            *event_loop.playing.lock().unwrap() = false;
            source = None;
        }
    }
}

在这里,我们检查播放值是否为真(再次使用lock().unwrap()技巧)。我们必须使用*来访问MutexGuard的值,因为它实现了Deref。这意味着我们没有直接访问底层值。但由于它实现了Deref特质,我们可以通过解引用守卫(使用*)来访问它。我们之前不需要这个技巧,因为我们访问了一个字段,Rust 会自动解引用字段。

我们随后创建一个written变量,如果播放器能够播放一个样本,它将是true。如果它无法播放一个,这意味着歌曲已经结束。在这种情况下,我们将stopped值设置为true,将playing设置为false

要播放样本,我们调用iter_to_buffer,它将从解码器(它是一个Iterator)中获取值并将它们写入缓冲区。之后,它将缓冲区写入playback以在您的声卡上播放样本。

让我们看看这个iter_to_buffer函数:

fn iter_to_buffer<I: Iterator<Item=i16>>(iter: &mut I, buffer: &mut [[i16; 2]; BUFFER_SIZE]) -> usize {
    let mut iter = iter.take(BUFFER_SIZE);
    let mut index = 0;
    while let Some(sample1) = iter.next() {
        if let Some(sample2) = iter.next() {
            buffer[index][0] = sample1;
            buffer[index][1] = sample2;
        }
        index += 1;
    }
    index
}

我们首先从迭代器中取出BUFFER_SIZE个元素,并将它们两次(对于两个通道)添加到缓冲区中。然后我们返回写入缓冲区的元素数量。

使用音乐播放器

我们现在准备好使用我们的音乐引擎了。让我们给Playlist添加几个新方法。

让我们从获取选择项路径的方法开始:

    fn selected_path(&self) -> Option<String> {
        let selection = self.treeview.get_selection();
        if let Some((_, iter)) = selection.get_selected() {
            let value = self.model.get_value(&iter, PATH_COLUMN as i32);
            return value.get::<String>();
        }
        None
    }

我们首先获取选择项,然后获取选择项的迭代器。从迭代器中,我们可以获取指定列的值以获取路径。我们现在可以添加一个方法来加载所选歌曲:

    pub fn play(&self) -> bool {
        if let Some(path) = self.selected_path() {
            self.player.load(&path);
            true
        } else {
            false
        }
    }

如果有选定的歌曲,我们将它加载到音乐引擎中。如果歌曲被加载,我们返回true

我们现在将在播放按钮的事件处理程序中使用这个方法:

impl App {
    pub fn connect_toolbar_events(&self) {
        // …

        let playlist = self.playlist.clone();
        let play_image = self.toolbar.play_image.clone();
        let cover = self.cover.clone();
        let state = self.state.clone();
        self.toolbar.play_button.connect_clicked(move |_| {
            if state.lock().unwrap().stopped {
                if playlist.play() {
                    set_image_icon(&play_image, PAUSE_ICON);
                    set_cover(&cover, &playlist);
                }
            } else {
                set_image_icon(&play_image, PLAY_ICON);
            }
        });

        // …
    }
}

我们创建playlist变量的一个副本,因为它被移动到了闭包中。在后者中,我们随后调用了我们刚才创建的play()方法。我们只更改按钮的图像,并在歌曲开始播放时显示封面。

你现在可以尝试音乐播放器了:打开一个 MP3 文件,点击播放,你应该能听到歌曲。让我们继续开发软件,因为还有很多功能缺失。

暂停和恢复歌曲

我们将首先添加一个字段来指示播放器是否处于暂停状态。此字段将由 playresume 等方法更改。然而,请记住,我们的 Playlist 被包装在一个 Rc 中,这样我们就可以在不同的地方使用它,即在事件处理程序中。还要记住,Rust 禁止在存在对值的可变引用时进行修改。我们如何使用引用计数指针来更新此字段?一种方法是通过内部可变性。

内部可变性

内部可变性是一个概念,它允许具有不可变引用的类型具有可变内部值。这样做安全吗?是的,绝对安全,因为我们需要遵守某些约束。实现内部可变性的方法之一是包装我们的 Cell 类型。此类型的约束是,如果我们想从不可变引用获取 Cell 的值,则包装的类型必须实现 Copy 特性。我们将在本章后面看到其他常用的内部可变性类型。现在,让我们将我们的字段添加到 Player 类型中:

use std::cell::Cell;

pub struct Player {
    app_state: Arc<Mutex<super::State>>,
    event_loop: EventLoop,
    paused: Cell<bool>,
}

让我们更新结构的构造:

impl Player {
    pub(crate) fn new(app_state: Arc<Mutex<super::State>>) -> Self {
        // …

        Player {
            app_state,
            event_loop,
            paused: Cell::new(false),
        }
    }
}

我们现在可以添加一个方法来检查音乐是否已暂停:

    pub fn is_paused(&self) -> bool {
        self.paused.get()
    }

这里,我们需要调用 Cell::get() 来获取内部值的副本。现在我们可以添加播放和恢复歌曲的方法:

    pub fn pause(&self) {
        self.paused.set(true);
        self.app_state.lock().unwrap().stopped = true;
        self.set_playing(false);
    }

    pub fn resume(&self) {
        self.paused.set(false);
        self.app_state.lock().unwrap().stopped = false;
        self.set_playing(true);
    }

在这里,我们看到我们需要调用 Cell::set() 来更新 Cell 的值。尽管我们只有一个不可变引用,但我们仍然可以这样做,而且这样做是完全安全的。然后,我们更新应用程序状态的 stopped 字段,因为播放按钮的点击处理程序将使用它来决定我们是否想要播放或继续音乐。我们还调用 set_playing() 来向播放器线程指示它是否需要继续播放歌曲。此方法定义如下:

    fn set_playing(&self, playing: bool) {
        *self.event_loop.playing.lock().unwrap() = playing;
        let (ref lock, ref condition_variable) = *self.event_loop.condition_variable;
        let mut started = lock.lock().unwrap();
        *started = playing;
        if playing {
            condition_variable.notify_one();
        }
    }

它设置 playing 变量,然后通知播放器线程如果 playingtrue,则唤醒它。

现在我们将向我们的 Playlist 类型添加一个 pause() 方法,当用户点击暂停时,将调用我们刚刚创建的 pause() 方法:

    pub fn pause(&self) {
        self.player.pause();
    }

要使用它,我们将更新播放按钮的点击处理程序:

self.toolbar.play_button.connect_clicked(move |_| {
    if state.lock().unwrap().stopped {
        if playlist.play() {
            set_image_icon(&play_image, PAUSE_ICON);
            set_cover(&cover, &playlist);
        }
    } else {
        playlist.pause();
        set_image_icon(&play_image, PLAY_ICON);
    }
});

我们在 else 块中添加了暂停的调用。

我们现在想更新 play() 方法。现在我们可以暂停歌曲了,因此对于此方法需要考虑两种新情况:

  • 如果歌曲正在播放,我们希望暂停它。

  • 如果歌曲已暂停,我们希望根据是否选择了相同的歌曲来恢复歌曲,或者如果选择了另一首歌曲,则开始播放新歌曲。

正因如此,我们需要在 Playlist 结构中添加一个新的字段:

pub struct Playlist {
    current_song: RefCell<Option<String>>,
    model: ListStore,
    player: Player,
    treeview: TreeView,
}

我们添加了一个字段,它将包含当前播放歌曲的路径。在这里,我们将Option<String>包装在RefCell中,这是另一种实现内部可变性的方法。我们不能使用Cell,因为String类型没有实现Copy特质。那么,CellRefCell之间的区别是什么?RefCell类型将在运行时检查借用规则:如果同时发生两个借用,它将引发 panic。在使用RefCell时,我们必须要小心:如果可能的话,最好有编译时借用检查。但是,当使用gtk-rs时,我们有时需要与事件处理器共享可变状态,而最好的方法就是使用RefCell

在下一章中,我们将学习如何使用一个抽象状态管理的库,这样你就不需要使用RefCell,并且在运行时不会遇到任何panic。这需要一个新的导入语句:

use std::cell::RefCell;

我们需要更新构造函数来初始化这个值:

impl Playlist {
    pub(crate) fn new(state: Arc<Mutex<State>>) -> Self {
        // …

        Playlist {
            current_song: RefCell::new(None),
            model,
            player: Player::new(state.clone()),
            treeview,
        }
    }
}

在我们更新play()方法之前,我们还需要在Playlist中添加一个方法:

    pub fn path(&self) -> Option<String> {
        self.current_song.borrow().clone()
    }

此方法返回当前歌曲路径的副本。由于字段是RefCell,我们需要调用borrow()来访问内部值。此方法返回相当于不可变引用的值。我们很快就会看到如何获得可变引用。与Mutex一样,借用是词法的,借用将在函数结束时结束。我们现在准备好更新play()方法:

    pub fn play(&self) -> bool {
        if let Some(path) = self.selected_path() {
            if self.player.is_paused() && Some(&path) == 
             self.path().as_ref() {
                self.player.resume();
            } else {
                self.player.load(&path);
                *self.current_song.borrow_mut() = Some(path.into());
            }
            true
        } else {
            false
        }
    }

如果歌曲被暂停并且选定的路径与当前播放的歌曲路径相同,我们调用resume()。如果这个条件是false,我们加载指定的路径并将这个路径保存在我们的字段中。为此,我们调用borrow_mut()来获取一个可变引用。再一次,我们需要在表达式前加上*,这样DerefMut::deref_mut()就会被调用。运行项目,你会看到你可以暂停和恢复歌曲。

现在我们添加一个停止歌曲的方法。我们像往常一样,首先向Player添加一个方法:

    pub fn stop(&self) {
        self.paused.set(false);
        self.app_state.lock().unwrap().stopped = true;
        self.emit(Stop);
        self.set_playing(false);
    }

我们首先将paused字段设置为false,这样播放列表就不会在下次点击播放按钮时尝试恢复歌曲。然后我们将stopped字段设置为true,这将导致下一次点击此按钮播放歌曲而不是尝试暂停它。然后我们向事件循环发出Stop动作,并指示引擎线程不再播放音乐。

emit方法非常简单:

fn emit(&self, action: Action) {
    self.event_loop.queue.push(action);
}

它只是将action推送到事件循环的队列中。

现在我们来处理这个Stop事件:

Stop => {
    source = None;
},

我们只将源重置为None,因为我们不再需要它了。

然后,我们准备好向Playlist添加一个stop()方法:

    pub fn stop(&self) {
        *self.current_song.borrow_mut() = None;
        self.player.stop();
    }

我们首先将current_song字段重置为None,这样下一次调用play()就不会尝试恢复歌曲。然后我们调用我们之前创建的stop()方法。

现在我们准备好通过创建一个新的用于停止按钮的事件处理器来使用这个新方法,将此代码添加到connect_toolbar_events()方法中:

    let playlist = self.playlist.clone();
    let play_image = self.toolbar.play_image.clone();
    let cover = self.cover.clone();
    self.toolbar.stop_button.connect_clicked(move |_| {
        playlist.stop();
        cover.hide();
        set_image_icon(&play_image, PLAY_ICON);
    });

因此,当我们点击停止时,我们调用Playlist::stop()方法来停止播放音乐。我们还隐藏封面并将播放按钮设置回显示播放图标。你现在可以在音乐播放器中再次尝试,以查看这个新功能的效果。

现在,让我们为剩下的两个按钮添加动作:上一个和下一个。

我们首先需要在Playlist中创建一个新的方法:

    pub fn next(&self) -> bool {
        let selection = self.treeview.get_selection();
        let next_iter =
            if let Some((_, iter)) = selection.get_selected() {
                if !self.model.iter_next(&iter) {
                    return false;
                }
                Some(iter)
            }
            else {
                self.model.get_iter_first()
            };
        if let Some(ref iter) = next_iter {
            selection.select_iter(iter);
            self.play();
        }
        next_iter.is_some()
    }

我们首先获取选择项。然后检查是否有项目被选中:在这种情况下,我们尝试获取选择项之后的项。如果没有选中任何项,我们就获取列表中的第一个项。然后,如果我们能够获取到一个项,我们就选中它并开始播放歌曲。我们返回是否更改了选择项。

previous()方法类似:

    pub fn previous(&self) -> bool {
        let selection = self.treeview.get_selection();
        let previous_iter =
            if let Some((_, iter)) = selection.get_selected() {
                if !self.model.iter_previous(&iter) {
                    return false;
                }
                Some(iter)
            }
            else {
                self.model.iter_nth_child(None, max(0, 
                 self.model.iter_n_children(None) 
                - 1))
            };
        if let Some(ref iter) = previous_iter {
            selection.select_iter(iter);
            self.play();
        }
        previous_iter.is_some()
    }

然而,没有get_iter_last()方法,所以我们使用iter_nth_child()来获取最后一个元素。

这需要添加一个新的导入语句在文件顶部:

use std::cmp::max;

使用这些新方法,我们准备好处理按钮的点击事件。让我们从下一个按钮开始:

let playlist = self.playlist.clone();
let play_image = self.toolbar.play_image.clone();
let cover = self.cover.clone();
self.toolbar.next_button.connect_clicked(move |_| {
    if playlist.next() {
        set_image_icon(&play_image, PAUSE_ICON);
        set_cover(&cover, &playlist);
    }
});

我们简单地调用我们刚刚创建的next()方法,如果选定了新歌曲,我们更新播放按钮的图标并显示新的封面。上一个按钮的处理程序完全相同,只是我们调用previous()而不是next()

let playlist = self.playlist.clone();
let play_image = self.toolbar.play_image.clone();
let cover = self.cover.clone();
self.toolbar.previous_button.connect_clicked(move |_| {
    if playlist.previous() {
        set_image_icon(&play_image, PAUSE_ICON);
        set_cover(&cover, &playlist);
    }
});

显示歌曲的进度

当歌曲播放时,看到光标移动会很好。让我们立即解决这个问题。

我们将在我们的Player中添加一个方法来获取歌曲的持续时间:

use std::time::Duration;

    pub fn compute_duration<P: AsRef<Path>>(path: P) -> 
     Option<Duration> {
        let file = File::open(path).unwrap();
        Mp3Decoder::compute_duration(BufReader::new(file))
    }

我们简单地调用我们之前创建的compute_duration()方法。接下来,我们将修改Playlist以调用这个函数。但在我们这样做之前,我们将修改main模块中的State类型以包含额外的信息:

use std::collections::HashMap;

struct State {
    current_time: u64,
    durations: HashMap<String, u64>,
    stopped: bool,
}

我们添加了一个current_time字段,它将包含自歌曲开始播放以来经过的时间。我们还把歌曲的持续时间存储在一个HashMap中,这样我们只为每个路径计算一次。现在我们需要更新App构造函数中State的初始化:

let current_time = 0;
let durations = HashMap::new();
let state = Arc::new(Mutex::new(State {
    current_time,
    durations,
    stopped: true,
}));

让我们回到Playlist。现在它将包含其结构中的State

pub struct Playlist {
    current_song: RefCell<Option<String>>,
    model: ListStore,
    player: Player,
    state: Arc<Mutex<State>>,
    treeview: TreeView,
}

这应该在它的构造函数中反映出来:

Playlist {
    current_song: RefCell::new(None),
    model,
    player: Player::new(state.clone()),
    state,
    treeview,
}

在这里,我们添加了state字段。我们现在将添加一个方法,它将在另一个线程中计算持续时间:

use std::thread;
use to_millis;

    fn compute_duration(&self, path: &Path) {
        let state = self.state.clone();
        let path = path.to_string_lossy().to_string();
        thread::spawn(move || {
            if let Some(duration) = Player::compute_duration(&path)
            {
                let mut state = state.lock().unwrap();
                state.durations.insert(path, to_millis(duration));
            }
        });
    }

在线程的闭包中,我们计算持续时间,当它完成时,我们锁定状态以在HashMap中插入持续时间。我们因为在计算可能需要时间,我们不希望在这个计算期间阻塞用户界面,所以我们现在在Playlist::add()中调用这个方法:

    pub fn add(&self, path: &Path) {
        self.compute_duration(path);

        // …
    }

我们将更新Adjustment,使其初始值是0.0

let adjustment = Adjustment::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0);

这是为了避免在持续时间尚未计算时看到光标移动得太快。

最后,我们在App::connect_events()方法中添加更新 UI 的代码:

use gtk::{AdjustmentExt, Continue};
use toolbar::{set_image_icon, PAUSE_ICON, PLAY_ICON};

    fn connect_events(&self) {
        let playlist = self.playlist.clone();
        let adjustment = self.adjustment.clone();
        let state = self.state.clone();
        let play_image = self.toolbar.play_image.clone();
        gtk::timeout_add(100, move || {
            let state = state.lock().unwrap();
            if let Some(path) = playlist.path() {
                if let Some(&duration) = state.durations.get(&path) 
                {
                    adjustment.set_upper(duration as f64);
                }
            }
            if state.stopped {
                set_image_icon(&play_image, PLAY_ICON);
            } else {
                set_image_icon(&play_image, PAUSE_ICON);
            }
            adjustment.set_value(state.current_time as f64);
            Continue(true)
        });
    }

gtk::timeout_add()方法的闭包返回Continue(false)时,该方法将每 100 毫秒运行一次。这个闭包首先检查持续时间是否在HashMap中,并将光标的上限值设置为这个持续时间。如果值不在HashMap中,这意味着它还没有被计算。之后,我们检查stopped字段是否为真,这意味着歌曲已经结束,引擎线程不再播放它。在这种情况下,我们想显示播放图标。如果歌曲仍在播放,我们显示暂停图标。最后,我们将光标当前值从current_time字段设置。

光标现在将随着歌曲的播放自动移动。以下是现在播放器的外观:

图 6.1

提高 CPU 使用率

你可能注意到的一个问题是,当没有歌曲播放时,软件将使用 100%的 CPU。这是因为音乐引擎线程中的无限循环。当歌曲暂停或没有歌曲可播放时,它将什么也不做,只是循环。现在让我们解决这个问题。

条件变量

我们想要做的是当线程没有事情可做时让它休眠。我们还想能够从主线程唤醒线程。这正是条件变量的作用。所以,让我们在我们的引擎中添加一个。我们将首先在EventLoop中添加一个condition_variable字段:

struct EventLoop {
    condition_variable: Arc<(Mutex<bool>, Condvar)>,
    queue: Arc<SegQueue<Action>>,
    playing: Arc<Mutex<bool>>,
}

条件变量通常与布尔值(包装在Mutex中)一起使用。我们需要重写EventLoop的构造函数来初始化这个新字段:

impl EventLoop {
    fn new() -> Self {
        EventLoop {
            condition_variable: Arc::new((Mutex::new(false), Condvar::new())),
            queue: Arc::new(SegQueue::new()),
            playing: Arc::new(Mutex::new(false)),
        }
    }
}

接下来,我们需要在线程没有事情可做时阻塞它。以下是Player::new()中线程新代码的开始:

{
    let app_state = app_state.clone();
    let event_loop = event_loop.clone();
    let condition_variable = event_loop.condition_variable.clone();
    thread::spawn(move || {
        let block = || {
            let (ref lock, ref condition_variable) = 
             *condition_variable;
            let mut started = lock.lock().unwrap();
            *started = false;
            while !*started {
                started =
                 condition_variable.wait(started).unwrap();
            }
        };

我们创建条件变量的一个副本,并将这个副本移动到线程中。然后在闭包的开始处,我们锁定与条件变量相关联的布尔值并将其设置为false。之后,我们循环:当这个值是false时,我们阻塞当前线程。我们创建了一个闭包而不是一个普通函数,因为普通函数不能捕获值。以下代码与之前相同:

        let mut buffer = [[0; 2]; BUFFER_SIZE];
        let mut playback = Playback::new("MP3", "MP3 Playback", None, 
         DEFAULT_RATE);
        let mut source = None;
        loop {
            if let Some(action) = event_loop.queue.try_pop() {
                match action {
                    Load(path) => {
                        let file = File::open(path).unwrap();
                        source = 
                         Some(Mp3Decoder::new(BufReader::new(file)).unwrap());
                        let rate = source.as_ref().map(|source| 
                         source.samples_rate()).unwrap_or(DEFAULT_RATE);
                        playback = Playback::new("MP3", "MP3 Playback", 
                         None, rate);
                        app_state.lock().unwrap().stopped = false;
                    },
                    Stop => {
                        source = None;
                    },
                }
            } else if *event_loop.playing.lock().unwrap() {
                let mut written = false;
                if let Some(ref mut source) = source {
                    let size = iter_to_buffer(source, &mut buffer);
                    if size > 0 {
                        app_state.lock().unwrap().current_time = 
                         source.current_time();
                        playback.write(&buffer[..size]);
                        written = true;
                    }
                }

但闭包的其余部分略有不同:

                if !written {
                    app_state.lock().unwrap().stopped = true;
                    *event_loop.playing.lock().unwrap() = false;
                    source = None;
                    block();
                }
            } else {
                block();
            }
        }
    });
}

如果播放器无法播放歌曲(即歌曲已经结束),我们调用闭包来阻塞线程。如果播放器暂停,我们也会阻塞线程。有了条件变量,软件就不再使用 100%的 CPU。

显示歌曲的当前时间

目前,我们只显示歌曲的进度。用户无法知道歌曲的持续时间以及歌曲已经播放了多少秒。让我们通过添加显示当前时间和持续时间的标签来解决这个问题。

我们需要在main模块中添加两个新的导入语句:

use gtk::{Label, LabelExt};

我们还会在我们的App结构中添加两个label

struct App {
    adjustment: Adjustment,
    cover: Image,
    current_time_label: Label,
    duration_label: Label,
    playlist: Rc<Playlist>,
    state: Arc<Mutex<State>>,
    toolbar: MusicToolbar,
    window: Window,
}

一个用于当前时间的label,另一个用于持续时间。由于我们想在光标右侧显示不同的label,我们将创建一个水平框,这段代码应该在App::new()中添加:

let hbox = gtk::Box::new(Horizontal, 10);
vbox.add(&hbox);

let adjustment = Adjustment::new(0.0, 0.0, 10.0, 0.0, 0.0, 0.0);
let scale = Scale::new(Horizontal, &adjustment);
scale.set_draw_value(false);
scale.set_hexpand(true);
hbox.add(&scale);

Scale小部件现在被添加到hbox而不是vbox中。我们调用set_hexpand()以便小部件尽可能多地占用水平空间。

我们现在准备好创建我们的label

let current_time_label = Label::new(None);
hbox.add(&current_time_label);

let slash_label = Label::new("/");
hbox.add(&slash_label);

let duration_label = Label::new(None);
duration_label.set_margin_right(10);
hbox.add(&duration_label);

我们创建了三个label;第三个是一个分隔符。我们给最后一个label设置一个右外边距,以便它不会太靠近窗口的边缘。此外,在App构造函数中,我们需要更新结构的初始化:

let app = App {
    adjustment,
    cover,
    current_time_label,
    duration_label,
    playlist,
    state,
    toolbar,
    window,
};

我们添加了两个label

我们将创建一个函数将毫秒数转换为minute:second格式的String

fn millis_to_minutes(millis: u64) -> String {
    let mut seconds = millis / 1_000;
    let minutes = seconds / 60;
    seconds %= 60;
    format!("{}:{:02}", minutes, seconds)
}

在这个函数中,我们首先通过除以一千将毫秒数转换为秒。然后我们通过除以60得到分钟数。之后,我们使用模运算符计算不包括在分钟中的秒数。最后,我们将分钟和秒格式化为String。如您所见,我们使用了特殊的{:02}格式化器。2表示我们希望以两个字符打印数字,即使数字小于 0。冒号后面的0表示我们希望用0而不是空格来填充。

使用这个新函数,我们可以重写计时器以更新(在App::connect_events()方法中)label

let current_time_label = self.current_time_label.clone();
let duration_label = self.duration_label.clone();
let playlist = self.playlist.clone();
let adjustment = self.adjustment.clone();
let state = self.state.clone();
let play_image = self.toolbar.play_image.clone();
gtk::timeout_add(100, move || {
    let state = state.lock().unwrap();
    if let Some(path) = playlist.path() {
        if let Some(&duration) = state.durations.get(&path) {
            adjustment.set_upper(duration as f64);
            duration_label.set_text(&millis_to_minutes(duration));
        }
    }
    if state.stopped {
        set_image_icon(&play_image, PLAY_ICON);
    } else {
        set_image_icon(&play_image, PAUSE_ICON);
        current_time_label.set_text(&millis_to_minutes(state.current_time));
    }
    adjustment.set_value(state.current_time as f64);
    Continue(true)
});

这里是前一个版本的更改。当我们获取持续时间时,我们更新持续时间label。当歌曲没有停止(即正在播放时),我们更新当前时间label

我们需要更改停止按钮处理程序,以便重置这些label的文本。

最后,我们可以更新处理程序:

let current_time_label = self.current_time_label.clone();
let duration_label = self.duration_label.clone();
let playlist = self.playlist.clone();
let play_image = self.toolbar.play_image.clone();
let cover = self.cover.clone();
self.toolbar.stop_button.connect_clicked(move |_| {
    current_time_label.set_text("");
    duration_label.set_text("");
    playlist.stop();
    cover.hide();
    set_image_icon(&play_image, PLAY_ICON);
});

我们克隆小部件以将它们移动到闭包中,并将label的文本设置为空字符串。

当运行应用程序时,你应该看到以下结果:

图 6.2

加载和保存播放列表

我们在我们的音乐播放器中可以创建播放列表,但我们不能将播放列表保存到文件中以便稍后加载。让我们将此功能添加到我们的项目中。

我们将以m3u文件格式保存播放列表,并为了处理此格式,我们将使用m3ucrate。所以让我们将其添加到我们的Cargo.toml文件中:

m3u = "¹.0.0"

将此行添加到main模块中:

extern crate m3u;

保存播放列表

我们首先添加一个按钮来保存播放列表。首先,我们在MusicToolbar结构中添加一个字段用于按钮:

pub struct MusicToolbar {
    open_button: ToolButton,
    next_button: ToolButton,
    play_button: ToolButton,
    pub play_image: Image,
    previous_button: ToolButton,
    quit_button: ToolButton,
    remove_button: ToolButton,
    save_button: ToolButton,
    stop_button: ToolButton,
    toolbar: Toolbar,
}

在构造函数中,我们将创建此按钮:

impl MusicToolbar {
    pub fn new() -> Self {
        let toolbar = Toolbar::new();

        let (open_button, _) = new_tool_button("document-open");
        toolbar.add(&open_button);

        let (save_button, _) = new_tool_button("document-save");
        toolbar.add(&save_button);

        toolbar.add(&SeparatorToolItem::new());

        // …

        let toolbar = MusicToolbar {
            open_button,
            next_button,
            play_button,
            play_image,
            previous_button,
            quit_button,
            remove_button,
            save_button,
            stop_button,
            toolbar
        };

        toolbar
    }
}

接下来,我们在Playlist结构中添加一个save方法:

use std::fs::File;

use m3u;

    pub fn save(&self, path: &Path) {
        let mut file = File::create(path).unwrap();
        let mut writer = m3u::Writer::new(&mut file);

        let mut write_iter = |iter: &TreeIter| {
            let value = self.model.get_value(&iter, PATH_COLUMN as
              i32);
            let path = value.get::<String>().unwrap();
            writer.write_entry(&m3u::path_entry(path)).unwrap();
        };

        if let Some(iter) = self.model.get_iter_first() {
            write_iter(&iter);
            while self.model.iter_next(&iter) {
                write_iter(&iter);
            }
        }
    }

在这里,我们首先使用我们创建的File创建一个m3u::Writer。这个写入器将被用来将条目写入文件。我们创建一个闭包,它从我们的TreeView的迭代器中获取路径并将此路径写入文件。我们选择创建闭包以避免重复代码,因为我们需要使用此代码两次。之后,我们获取第一个迭代器并写入其内容,然后循环直到视图中没有更多行。

我们现在准备好调用这段代码。首先,我们在模块工具栏中创建一个函数来显示保存文件对话框。它与我们在上一章中编写的show_open_dialog()函数类似:

fn show_save_dialog(parent: &ApplicationWindow) -> Option<PathBuf> {
    let mut file = None;
    let dialog = FileChooserDialog::new(Some("Choose a destination 
     M3U playlist  
    file"), Some(parent), FileChooserAct    ion::Save);
    let filter = FileFilter::new();
    filter.add_mime_type("audio/x-mpegurl");
    filter.set_name("M3U playlist file");
    dialog.set_do_overwrite_confirmation(true);
    dialog.add_filter(&filter);
    dialog.add_button("Cancel", RESPONSE_CANCEL);
    dialog.add_button("Save", RESPONSE_ACCEPT);
    let result = dialog.run();
    if result == RESPONSE_ACCEPT {
        file = dialog.get_filename();
    }
    dialog.destroy();
    file
}

在这里,我们使用FileChooserAction::Save类型而不是FileChooserAction::Open。我们使用不同的过滤器以及 MIME 类型。我们还调用了set_do_overwrite_confirmation(),这是非常重要的。如果用户请求覆盖文件,它将要求确认。函数的其余部分与打开文件的函数完全相同,只是按钮的label现在改为Save

我们现在可以在保存按钮的事件处理程序中使用这个函数:

let parent = self.window.clone();
let playlist = self.playlist.clone();
self.toolbar.save_button.connect_clicked(move |_| {
    let file = show_save_dialog(&parent);
    if let Some(file) = file {
        playlist.save(&file);
    }
});

我们简单地调用show_save_dialog()函数,并将结果文件传递给Playlist::save()方法。你现在可以尝试在应用程序中保存播放列表:

图 6.3图 6.3

加载播放列表

我们可以保存播放列表,但仍然无法加载它们。让我们首先在Playlist中添加一个load()方法:

    pub fn load(&self, path: &Path) {
        let mut reader = m3u::Reader::open(path).unwrap();
        for entry in reader.entries() {
            if let Ok(m3u::Entry::Path(path)) = entry {
                self.add(&path);
            }
        }
    }

在这里,我们使用指定的路径创建一个m3u::Reader。我们遍历条目,如果我们能够检索到m3u::Entry::Path,我们就将其添加到播放列表小部件中。

我们现在将修改打开对话框,允许选择M3U文件:

fn show_open_dialog(parent: &ApplicationWindow) -> Option<PathBuf> {
    let mut file = None;
    let dialog = FileChooserDialog::new(Some("Select an MP3 audio file"), 
    Some(parent), FileChooserAction::Open);

    let mp3_filter = FileFilter::new();
    mp3_filter.add_mime_type("audio/mp3");
    mp3_filter.set_name("MP3 audio file");
    dialog.add_filter(&mp3_filter);

    let m3u_filter = FileFilter::new();
    m3u_filter.add_mime_type("audio/x-mpegurl");
    m3u_filter.set_name("M3U playlist file");
    dialog.add_filter(&m3u_filter);

    dialog.add_button("Cancel", RESPONSE_CANCEL);
    dialog.add_button("Accept", RESPONSE_ACCEPT);
    let result = dialog.run();
    if result == RESPONSE_ACCEPT {
        file = dialog.get_filename();
    }
    dialog.destroy();
    file
}

我们现在将更改打开按钮的事件处理程序,根据文件类型选择要执行的操作:

impl App {
    pub fn connect_toolbar_events(&self) {
        let parent = self.window.clone();
        let playlist = self.playlist.clone();
        self.toolbar.open_button.connect_clicked(move |_| {
            let file = show_open_dialog(&parent);
            if let Some(file) = file {
                if let Some(ext) = file.extension() {
                    match ext.to_str().unwrap() {
                        "mp3" => playlist.add(&file),
                        "m3u" => playlist.load(&file),
                        extension => {
                            let dialog = 
                             MessageDialog::new(Some(&parent),  
                             DialogFlags::empty(), MessageType::Error,
                                ButtonsType::Ok, &format!("Cannot open 
                                 file with 
                                 extension .{}", extension));
                            dialog.run();
                            dialog.destroy();
                        },
                    }
                }
            }
        });

        // …
    }
}

这需要添加几个新的导入语句:

use gtk::{
    ButtonsType,
    DialogFlags,
    MessageDialog,
    MessageType,
};

这个新的事件处理程序现在会检查文件扩展名,如果是mp3,它将调用我们之前所做的那样调用Playlist::add()方法。如果是m3u,我们调用我们新的Playlist::load()方法。否则,我们向用户显示一个错误消息:

图 6.4图 6.4

现在,你可以尝试在我们的音乐播放器中加载播放列表,别忘了更改过滤器以便在对话框中看到 M3U 文件。

使用 gstreamer 进行播放

实现音乐播放引擎是一个学习线程的好练习。然而,对于真正的程序,你可以简单地使用gstreamer进行音乐播放。所以,让我们看看如何在我们的音乐播放器中使用这个库。

在你的Cargo.toml中移除以下依赖项:

crossbeam = "⁰.3.0"
pulse-simple = "¹.0.0"
simplemad = "⁰.8.1"

并移除相应的extern crate语句。我们还可以移除mp3player模块,因为我们将会使用gstreamer。现在,我们可以添加我们的gstreamer依赖项:

gstreamer = "⁰.9.1"
gstreamer-player = "⁰.9.0"

并添加相应的extern crate语句:

extern crate gstreamer as gst;
extern crate gstreamer_player as gst_player;

main函数的开始处,我们需要初始化gstreamer

gst::init().expect("gstreamer initialization failed");

我们不再需要我们的State结构,因此我们移除了它以及App结构中的state字段。现在,我们可以更新我们的playlist模块。首先,让我们添加一些use语句:

use gst::{ClockTime, ElementExt};
use gst_player;
use gtk::Cast;

我们移除了state字段,并在Playlist结构中更新了player字段:

pub struct Playlist {
    current_song: RefCell<Option<String>>,
    model: ListStore,
    player: gst_player::Player,
    treeview: TreeView,
}

Playlist构造函数不再需要state

impl Playlist {
    pub(crate) fn new() -> Self {
        let model = ListStore::new(&[
            Pixbuf::static_type(),
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Pixbuf::static_type(),
        ]);
        let treeview = TreeView::new_with_model(&model);
        treeview.set_hexpand(true);
        treeview.set_vexpand(true);

        Self::create_columns(&treeview);

        let dispatcher = gst_player::PlayerGMainContextSignalDispatcher::new(None);
        let player = gst_player::Player::new(None,
        Some(&dispatcher.upcast:: 
        <gst_player::PlayerSignalDispatcher>
        ()));

        Playlist {
            current_song: RefCell::new(None),
            model,
            player,
            treeview,
        }
    }
}

在这里,我们从gstreamer包中创建Player。我们需要移除compute_duration()方法和所有其使用,因为我们将会使用gstreamer来获取歌曲的时长:

    pub fn get_duration(&self) -> ClockTime {
        self.player.get_duration()
    }

    pub fn get_current_time(&self) -> ClockTime {
        self.player.get_position()
    }

    pub fn is_playing(&self) -> bool {
        self.player.get_pipeline()
            .map(|element| element.get_state(gst::CLOCK_TIME_NONE).1 == 
             gst::State::Playing)
            .unwrap_or(false)
    }

在这里,我们创建了一些将有助于显示时间和歌曲播放状态的方法。最后,我们可以更新play()方法以使用gstreamer

    pub fn play(&self) -> bool {
        if self.selected_path() == self.player.get_uri() {
            self.player.play();
            return false;
        }
        if let Some(path) = self.selected_path() {
            let uri = format!("file://{}", path);
            self.player.set_uri(&uri);
            self.player.play();
            true
        } else {
            false
        }
    }

让我们回到main模块来更新播放列表的创建:

let playlist = Rc::new(Playlist::new());

还有一件事需要更新,那就是显示当前时间的代码:

gtk::timeout_add(100, move || {
    let duration = playlist.get_duration();
    adjustment.set_upper(duration.nanoseconds().unwrap_or(0) as
    f64);
    duration_label.set_text(&format!("{:.0}", duration));

    let current_time = playlist.get_current_time();
    if !playlist.is_playing() {
        set_image_icon(&play_image, PLAY_ICON);
    } else {
        set_image_icon(&play_image, PAUSE_ICON);
        current_time_label.set_text(&format!("{:.0}", 
         current_time));
    }
    adjustment.set_value(current_time.nanoseconds().unwrap_or(0) as f64);
    Continue(true)
});

我们现在使用我们之前创建的一些方法和来自gstreamer的一些方法。

最后,我们更新toolbar模块。首先,是play_button事件处理器:

self.toolbar.play_button.connect_clicked(move |_| {
    if !playlist.is_playing() {
        if playlist.play() {
            set_image_icon(&play_image, PAUSE_ICON);
            set_cover(&cover, &playlist);
        }
    } else {
        playlist.pause();
        set_image_icon(&play_image, PLAY_ICON);
    }
});

我们现在使用is_playing()方法代替state。同时,我们也从show_open_dialog()函数中移除FileFilter,因为gstreamer支持的格式不仅仅是MP3。为了能够打开这些格式,我们需要更新open_button事件处理器:

self.toolbar.open_button.connect_clicked(move |_| {
    let file = show_open_dialog(&parent);
    if let Some(file) = file {
        if let Some(ext) = file.extension() {
            match ext.to_str().unwrap() {
                "mp3" | "ogg" => playlist.add(&file),
                "m3u" => playlist.load(&file),
                extension => {
                    let dialog = MessageDialog::new(Some(&parent), 
                     DialogFlags::empty(), MessageType::Error,
                      ButtonsType::Ok, &format!("Cannot open file 
                       with extension . {}", extension));
                    dialog.run();
                    dialog.destroy();
                },
            }
        }
    }
});

在这里,我们只添加了ogg格式,但你也可以添加其他格式。

摘要

本章一开始就向您展示了如何使用simplemadcrate 解码 MP3 数据。然后,您学习了如何编写音乐引擎,这展示了如何使用线程和不同的线程对象,如Mutex、无锁数据结构和条件变量。您还学习了 Rust 如何确保线程安全。您还看到了当您有一个不可变的引用和内部可变性时,如何可变地修改值的字段。在整个章节中,我们添加了音乐播放器缺失的功能,如播放、暂停、上一曲和下一曲。

在下一章中,我们将通过使用relmcrate 重写音乐播放器来提高其模块化程度。

第七章:使用 Relm 以更 Rust 风格的方式创建音乐播放器

在上一章中,我们完成了我们的音乐播放器。这完全没问题,但直接在 Rust 中使用 gtk-rs 可能会导致错误。这就是为什么我们将使用 relm,这是一个 Rust 的惯用 GUI 库,来重写我们的音乐播放器。Relm 基于 gtk-rs,所以最终的应用程序看起来是一样的。然而,代码将更简洁、更具声明性。

在本章中,我们将涵盖以下主题:

  • Relm

  • Relm 小部件

  • 模型-视图-控制器

  • 声明性视图

  • 消息传递

使用 relm 而不是直接使用 gtk-rs 的原因

正如你在前面的章节中看到的,我们使用了并不真正明显的一些概念,并且在使用 GTK+ 和 Rust 时,做一些通常很容易做的事情并不那么容易。这些都是使用 relm 的许多原因之一。

状态修改

从上一章可能不清楚,但我们间接使用了 Rc<RefCell<T>> 来进行状态修改。确实,我们的 Playlist 类型包含一个 RefCell<Option<String>>,并且我们将 Playlist 包裹在一个引用计数指针中。这是为了能够根据事件对状态进行修改,例如在点击播放按钮时播放歌曲:

let playlist = self.playlist.clone();
let play_image = self.toolbar.play_image.clone();
let cover = self.cover.clone();
let state = self.state.clone();
self.toolbar.play_button.connect_clicked(move |_| {
    if state.lock().unwrap().stopped {
        if playlist.play() {
            set_image_icon(&play_image, PAUSE_ICON);
            set_cover(&cover, &playlist);
        }
    } else {
        playlist.pause();
        set_image_icon(&play_image, PLAY_ICON);
    }
});

必须使用所有这些 clone() 调用是非常繁琐的,使用 RefCell<T> 类型可能会导致在复杂应用程序中难以调试的问题。这个类型的问题在于借用检查是在运行时发生的。例如,以下应用程序:

use std::cell::RefCell;
use std::collections::HashMap;

fn main() {
    let cell = RefCell::new(HashMap::new());
    cell.borrow_mut().insert("one", 1);
    let borrowed_cell = cell.borrow();
    if let Some(key) = borrowed_cell.get("one") {
        cell.borrow_mut().insert("two", 2);
    }
}

将会引发恐慌:

thread 'main' panicked at 'already borrowed: BorrowMutError', /checkout/src/libcore/result.rs:906:4

尽管在这个例子中为什么它会引发恐慌(我们在 borrowed_cell 中的借用仍然有效时调用了 borrow_mut())是显而易见的,但在更复杂的应用程序中,理解为什么会发生恐慌会更困难,尤其是如果我们将 RefCell<T> 包裹在 Rc 中并在每个地方克隆它。这使我们来到了这个类型的第二个问题:使用 Rc<T> 鼓励我们过度克隆和共享我们的数据,这增加了我们模块之间的耦合度。

relm 包采用了一种不同的方法:小部件拥有自己的数据,不同的小部件之间通过消息传递进行通信。

异步用户界面

在创建用户界面时,另一个常见问题是,我们可能想要执行可能需要花费时间(例如网络请求)的操作,而不冻结用户界面。由于基于 tokio,这是一个为 Rust 设计的异步 I/O 框架,relm 允许你轻松地编写可以执行网络请求而不冻结界面的图形用户界面。

创建自定义小部件

在面向对象的语言中,创建新小部件并像内置小部件一样使用它们非常容易。在这个范例中,你只需要创建一个新的类,它继承自小部件,然后就可以了。

在 第五章 创建音乐播放器 中,我们创建了自定义小部件,例如 PlaylistMusicToolbar,但我们需要创建一个函数来获取真实的 GTK+ 小部件:

pub fn view(&self) -> &TreeView {
    &self.treeview
}

另一个选择是实现 Deref 特性:

use std::ops::Deref;

impl Deref for Playlist {
    type Target = TreeView;

    fn deref(&self) -> &TreeView {
        &self.treeview
    }
}

这种实现将允许我们像这样将小部件添加到其 parent 中:

parent.add(&*playlist);

(注意 playlist 前面的前导 *,这是对 deref() 的调用。)

而不是以下这种方式添加:

parent.add(playlist.view());

但这仍然与使用正常的 gtk 小部件不同。

Relm 解决了所有这些问题。让我们开始使用这个包。

使用 relm 创建窗口

首先,我们将使用 Rust 编译器的 Nightly 版本。

虽然使用这个 Nightly 版本不是使用 relm 的严格必要条件,但它提供了一种语法,使用这个版本上仅有的功能,语法会更好一些。

这将是一个学习如何安装不同版本的编译器的良好机会。Nightly 是 Rust 的不稳定版本;这是一个几乎每天都会编译的版本。Rust 的一些不稳定特性仅在 Nightly 版本中可用。但是,不用担心,我们也会看看如何在 Rust 的稳定版本上使用 relm

安装 Rust Nightly

使用 rustup,我们在 第一章 “Rust 基础”中安装的工具,安装 Nightly 非常容易:

rustup default nightly

运行此命令将安装工具的 Nightly 版本(cargorustc 等)。同时,它还将切换相应的命令以使用 Nightly 版本。

如果你想要回到稳定版本,请发出以下命令:

rustup default stable

Nightly 版本更新非常频繁,所以你可能希望每周或更频繁地更新它。为此,你需要运行以下命令:

rustup update

如果发布了新版本,这将也会更新稳定版本(每 6 周发布一个稳定版本)。

现在我们正在使用 Rust Nightly,我们准备好创建一个 new 项目:

cargo new rusic-relm --bin

Cargo.toml 文件中添加以下依赖项:

[dependencies]
gtk = "⁰.3.0"
gtk-sys = "⁰.5.0"
relm = "⁰.11.0"
relm-attributes = "⁰.11.0"
relm-derive = "⁰.11.0"

我们仍然需要 gtk,因为 relm 是基于它的。让我们添加相应的 extern crate 语句:

#![feature(proc_macro)]

extern crate gtk;
extern crate gtk_sys;
#[macro_use]
extern crate relm;
extern crate relm_attributes;
#[macro_use]
extern crate relm_derive;

relm 提供了一些宏,这就是为什么我们需要添加 #[macro_use]。我们将通过使用 relm 创建一个简单的窗口来慢慢开始。

小部件

这个包围绕小部件的概念构建,这与 gtk 小部件不同。在 relm 中,小部件由视图、模型和用于响应事件更新模型的方法组成。小部件的概念通过 relm 中的一个 trait 实现:Widget trait。

模型

我们将从空模型开始,并在本章的后面部分填充它:

pub struct Model {
}

正如你所见,一个模型可以是一个简单的结构。如果你的小部件不需要模型,它也可以是 ()。实际上,它可以是你想要的任何类型。

除了模型,小部件还需要知道其模型的初始值。为了指定它是什么,我们需要实现 Widget trait 的 model() 方法:

#[widget]
impl Widget for App {
    fn model() -> Model {
        Model {
        }
    }

    // …
}

这里,我们使用了由 relm_attributes 包提供的 #[widget] 属性。属性目前是语言的一个不稳定特性,这就是为什么我们使用 nightly。我们将在关于声明视图的部分看到为什么这个属性是必需的。所以,让我们回到我们的 model() 模型,我们现在只返回 Model {} 作为我们的模型不包含任何数据。这个特性还需要其他方法,所以这个实现现在是不完整的。

消息

Relm 小部件通过向其他小部件以及自身发送消息来进行通信。例如,当 delete_event 信号被触发时,我们可以向我们的小部件发送 Quit 消息,并在接收到此消息时采取适当的行动。消息被建模为一个使用特定于 relm 的自定义派生 Msgenum

#[derive(Msg)]
pub enum Msg {
    Quit,
}

这个自定义派生是由 relm_derive 包提供的。

视图

relm 中,视图以声明方式创建,作为 Widget 特性的一个部分:

use gtk::{
    GtkWindowExt,
    Inhibit,
    WidgetExt,
};
use relm::Widget;
use relm_attributes::widget;

use self::Msg::*;

#[widget]
impl Widget for App {
    // …

    view! {
        gtk::Window {
            title: "Rusic",
            delete_event(_, _) => (Quit, Inhibit(false)),
        }
    }
}

我们首先从 gtk 包中导入了一些内容。然后,我们导入了 relm 中的 Widget 特性和 widget 属性。稍后,我们导入了我们的 enum Msg 的变体,因为我们在这段代码中使用它。为了声明视图,我们使用了 view! 宏。这个宏非常特别,它不是一个像我们在 第一章 中看到的 macro_rules! 声明的宏。相反,它是通过实现 #[widget] 属性的进程宏来解析的,以便提供在 Rust 中不允许的语法。

为了声明我们的视图,我们首先指定 gtk::Window 小部件的名称。

我们不能导入 gtk::Window 以便在视图声明中只使用 Window

之后,我们使用花括号,并在其中指定小部件处理的属性和事件。

属性

在这里,我们声明 title 属性为 "Rusic"。因此,我们将 set_title() 调用从 gtk 转换为 title 属性,只需要 set_ 之后的部分。实际上,relm 会将属性 (title: "Rusic") 转换为 set_title("Rusic") 调用,正如我们稍后将会看到的。

事件

事件处理器的语法有点特殊:

delete_event(_, _) => (Quit, Inhibit(false)),

首先,我们只需要写 delete_event(_, _) => 而不是 connect_delete_event(move |_, _| { })。如果我们需要信号的参数,我们可以写一个标识符的名字而不是使用下划线(_)。在粗箭头(=>)的右侧,我们指定两个用括号括起来并用逗号分隔的东西。首先,是 Quit,这是当事件被触发时将发送到当前小部件的消息。其次是要返回给 gtk 回调的值。在这里,我们返回 Inhibit(false) 以指定我们不想阻止默认事件处理器运行。

代码生成

由属性生成的代码是一个看起来像正常的 Rust 方法的代码:

fn view(relm: &Relm<Self>, model: Self::Model) -> Self {
   // This method does not actually exist, but relm directly create a window using the functions from the sys crates.
    let window = gtk::Window::new();
    window.set_title("Rusic");

    window.show();

    connect!(relm, window, connect_delete_event(_, _), return 
     (Some(Quit), Inhibit(false)));

    Win {
        model,
        window: window,
    }
}

更新函数

Widget特质唯一剩余的必需方法是update()。在这个方法中,我们将管理Quit消息:

#[widget]
impl Widget for App {
    fn update(&mut self, event: Msg) {
        match event {
            Quit => gtk::main_quit(),
        }
    }

    // …
}

在这里,我们指定当接收到Quit消息时,我们调用gtk::main_quit(),这是一个类似于我们在第五章中使用的Application::quit()的函数。

应该注意的是,#[widget]属性也会生成包含小部件和模型的App结构。

我们可以通过在main函数中调用其run()方法来最终显示这个窗口:

fn main() {
    App::run(()).unwrap();
}

之后,我们将看到为什么需要指定()作为run()的参数。

添加子小部件

我们看到了如何使用 relm 创建小部件的基本方法。现在,让我们继续创建我们的用户界面。我们将从添加工具栏开始。除了在view!宏中指定属性和信号外,我们还可以嵌套小部件,以便将子小部件添加到容器中。因此,要将gtk::Box作为窗口的子小部件添加,我们只需将前者嵌套在后者内部:

view! {
    gtk::Window {
        title: "Rusic",
        delete_event(_, _) => (Quit, Inhibit(false)),
        gtk::Box {
        },
    }
}

要将工具栏添加到gtk::Box中,我们创建一个新的嵌套层级:

view! {
    gtk::Window {
        title: "Rusic",
        delete_event(_, _) => (Quit, Inhibit(false)),
        gtk::Box {
            orientation: Vertical,
            #[name="toolbar"]
            gtk::Toolbar {
            },
        },
    }
}

在这里,我们可以看到一个属性:#[name]属性给一个小部件命名,这将允许我们通过指定的标识符访问这个小部件,就像我们稍后将要看到的那样。在本章的其余部分,我们还会遇到其他属性。

我们将在我们的模型中添加一个属性来保持播放/暂停按钮上要显示的图像:

use gtk::Image;

pub const PAUSE_ICON: &str = "gtk-media-pause";
pub const PLAY_ICON: &str = "gtk-media-play";

pub struct Model {
    play_image: Image,
}

我们还添加了表示按钮状态的图像名称的常量。我们需要更新model()方法来指定这个新字段:

fn model() -> Model {
    Model {
        play_image: new_icon(PLAY_ICON),
    }
}

这使用以下函数来创建一个图像:

fn new_icon(icon: &str) -> Image {
    Image::new_from_file(format!("assets/{}.png", icon))
}

让我们向工具栏添加项目:

use gtk::{
    OrientableExt,
    ToolButtonExt,
};
use gtk::Orientation::Vertical;

view! {
    gtk::Window {
        title: "Rusic",
        delete_event(_, _) => (Quit, Inhibit(false)),
        gtk::Box {
            orientation: Vertical,
            #[name="toolbar"]
            gtk::Toolbar {
                gtk::ToolButton {
                    icon_widget: &new_icon("document-open"),
                    clicked => Open,
                },
                gtk::ToolButton {
                    icon_widget: &new_icon("document-save"),
                    clicked => Save,
                },
                gtk::SeparatorToolItem {
                },
                gtk::ToolButton {
                    icon_widget: &new_icon("gtk-media-previous"),
                },
                gtk::ToolButton {
                    icon_widget: &self.model.play_image,
                    clicked => PlayPause,
                },
                gtk::ToolButton {
                    icon_widget: &new_icon("gtk-media-stop"),
                    clicked => Stop,
                },
                gtk::ToolButton {
                    icon_widget: &new_icon("gtk-media-next"),
                },
                gtk::SeparatorToolItem {
                },
                gtk::ToolButton {
                    icon_widget: &new_icon("remove"),
                },
                gtk::SeparatorToolItem {
                },
                gtk::ToolButton {
                    icon_widget: &new_icon("gtk-quit"),
                    clicked => Quit,
                },
            },
        },
    }
}

这里没有展示新的语法。请注意,我们可以在属性的值中指定函数调用以及模型属性。我们需要在new_icon()之前放置一个&,因为代码被这样翻译:

tool_button.set_icon_widget(&new_icon("gtk-quit"));

这个set_icon_widget()方法需要可以转换成Option<&P>的东西,其中P是一个小部件。它需要一个引用,所以我们给它一个引用。

单向数据绑定

在 relm 中,从模型属性设置属性是非常频繁的,实际上它创建了一个模型属性和属性之间的单向绑定。这意味着当属性被更新时,小部件属性也会被更新。尽管这个特性有一些限制:

  • 只有对模型属性的赋值才会更新属性。

  • 这个赋值必须在一个带有#[widget]属性的实现中。

这些限制来自于relm只分析由这个属性装饰的源代码。它只考虑赋值是对模型数据的更新。

这可能需要更改一些代码。例如,以下代码不会触发属性更新:

self.model.string.push_str("string");

你可以这样重写它,以便relm将其视为更新:

self.model.string += "string";

如你所见,relm 不仅识别 = 赋值,还识别使用运算符(如 +=)的赋值。

在之前的代码中,我们使用了许多新的消息,所以让我们相应地更新我们的枚举:

#[derive(Msg)]
pub enum Msg {
    Open,
    PlayPause,
    Quit,
    Save,
    Stop,
}

我们还需要更改 update() 方法以考虑这些新消息:

    fn update(&mut self, event: Msg) {
        match event {
            Open => (),
            PlayPause => (),
            Quit => gtk::main_quit(),
            Save => (),
            Stop => (),
        }
    }

目前,因为我们只编写了界面,所以当我们收到这些消息时,我们不做任何事情。

视图的后续初始化

如果你运行应用程序,你会看到图像没有显示在工具栏按钮上。这是因为 relm 的工作方式。当它生成代码时,它会在每个小部件上调用 show() 方法,而不是 show_all()。所以,工具栏和工具按钮会显示,但图像不会显示,因为它们只是按钮的属性,它们不是使用小部件语法创建的。为了解决这个问题,我们将在 init_view() 方法中调用 show_all()

#[widget]
impl Widget for App {
    fn init_view(&mut self) {
        self.toolbar.show_all();
    }

    // …
}

这就是为什么我们之前给工具栏小部件命名的原因:我们需要在这个小部件上调用一个方法。init_view() 方法在创建 view 之后被调用。这在无法使用 view! 语法进行操作时,执行一些代码来自定义视图是有用的。如果你再次运行应用程序,你会看到按钮现在有一个图像。

现在让我们添加封面图像小部件和光标小部件。对于图像,我们需要在 Cargo.toml 中添加一个新的 crate:

[dependencies]
gdk-pixbuf = "⁰.3.0"

让我们再添加相应的 extern crate 语句:

extern crate gdk_pixbuf;

我们还需要新的导入语句:

use gdk_pixbuf::Pixbuf;
use gtk::{
    Adjustment,
    BoxExt,
    ImageExt,
    LabelExt,
    ScaleExt,
};
use gtk::Orientation::Horizontal;

让我们在我们的 Model 中添加几个新字段:

pub struct Model {
    adjustment: Adjustment,
    cover_pixbuf: Option<Pixbuf>,
    cover_visible: bool,
    current_duration: u64,
    current_time: u64,
    play_image: Image,
}

大多数新字段都存在于我们在前两章中开发的应用程序中。不过,cover_visible 属性是新的。我们将使用它来知道是否应该显示封面图像。别忘了更新模型的初始化:

fn model() -> Model {
    Model {
        adjustment: Adjustment::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
        cover_pixbuf: None,
        cover_visible: false,
        current_duration: 0,
        current_time: 0,
        play_image: new_icon(PLAY_ICON),
    }
}

我们现在可以在 Toolbar 小部件之后添加 Image

gtk::Image {
    from_pixbuf: self.model.cover_pixbuf.as_ref(),
    visible: self.model.cover_visible,
},

在这里,我们在 cover_pixbuf 属性上调用 as_ref(),因为,又一次,该方法(set_from_pixbuf())需要可以被转换成 Option<&Pixbuf> 的东西。我们还指定了图像的 visible 属性绑定到 cover_visible 模型属性。这意味着我们可以通过将此属性设置为 false 来隐藏图像。

然后,我们将添加光标,这将给我们以下视图:

view! {
    gtk::Window {
        title: "Rusic",
        delete_event(_, _) => (Quit, Inhibit(false)),
        gtk::Box {
            orientation: Vertical,
            #[name="toolbar"]
            gtk::Toolbar {
                // …
            },
            gtk::Image {
                from_pixbuf: self.model.cover_pixbuf.as_ref(),
                visible: self.model.cover_visible,
            },
            gtk::Box {
                orientation: Horizontal,
                spacing: 10,
                gtk::Scale(Horizontal, &self.model.adjustment) {
                    draw_value: false,
                    hexpand: true,
                },
                gtk::Label {
                    text: &millis_to_minutes(self.model.current_time),
                },
                gtk::Label {
                    text: "/",
                },
                gtk::Label {
                    margin_right: 10,
                    text: &millis_to_minutes(self.model.current_duration),
                },
            },
        },
    }
}

这需要我们在上一章中看到的方法:

fn millis_to_minutes(millis: u64) -> String {
    let mut seconds = millis / 1_000;
    let minutes = seconds / 60;
    seconds %= 60;
    format!("{}:{:02}", minutes, seconds)
}

我们使用了另一种创建小部件的方法:

gtk::Scale(Horizontal, &self.model.adjustment) {
    draw_value: false,
    hexpand: true,
}

这个语法将调用小部件的构造函数,如下所示:

gtk::Scale::new(Horizontal, &self.model.adjustment);

我们也可以使用传统的语法来创建小部件:

use gtk::RangeExt;

gtk::Scale {
    adjustment: &self.model.adjustment,
    orientation: Horizontal,
    draw_value: false,
    hexpand: true,
}

这只是做同样事情的两个方法。

对话框

对于打开和保存对话框,我们将使用与上一章相同的功能:

use std::path::PathBuf;

use gtk::{FileChooserAction, FileChooserDialog, FileFilter};
use gtk_sys::{GTK_RESPONSE_ACCEPT, GTK_RESPONSE_CANCEL};

const RESPONSE_ACCEPT: i32 = GTK_RESPONSE_ACCEPT as i32;
const RESPONSE_CANCEL: i32 = GTK_RESPONSE_CANCEL as i32;

fn show_open_dialog(parent: &Window) -> Option<PathBuf> {
    let mut file = None;
    let dialog = FileChooserDialog::new(Some("Select an MP3 audio file"), 
    Some(parent), FileChooserAction::Open);

    let mp3_filter = FileFilter::new();
    mp3_filter.add_mime_type("audio/mp3");
    mp3_filter.set_name("MP3 audio file");
    dialog.add_filter(&mp3_filter);

    let m3u_filter = FileFilter::new();
    m3u_filter.add_mime_type("audio/x-mpegurl");
    m3u_filter.set_name("M3U playlist file");
    dialog.add_filter(&m3u_filter);

    dialog.add_button("Cancel", RESPONSE_CANCEL);
    dialog.add_button("Accept", RESPONSE_ACCEPT);
    let result = dialog.run();
    if result == RESPONSE_ACCEPT {
        file = dialog.get_filename();
    }
    dialog.destroy();
    file
}

fn show_save_dialog(parent: &Window) -> Option<PathBuf> {
    let mut file = None;
    let dialog = FileChooserDialog::new(Some("Choose a destination M3U playlist 
    file"), Some(parent), FileChooserAction::Save);
    let filter = FileFilter::new();
    filter.add_mime_type("audio/x-mpegurl");
    filter.set_name("M3U playlist file");
    dialog.set_do_overwrite_confirmation(true);
    dialog.add_filter(&filter);
    dialog.add_button("Cancel", RESPONSE_CANCEL);
    dialog.add_button("Save", RESPONSE_ACCEPT);
    let result = dialog.run();
    if result == RESPONSE_ACCEPT {
        file = dialog.get_filename();
    }
    dialog.destroy();
    file
}

但这次,我们将打开操作的代码放在 App 小部件的方法中:

use gtk::{ButtonsType, DialogFlags, MessageDialog, MessageType};

impl App {
    fn open(&self) {
        let file = show_open_dialog(&self.window);
        if let Some(file) = file {
            let ext = file.extension().map(|ext| 
             ext.to_str().unwrap().to_string());
            if let Some(ext) = ext {
                match ext.as_str() {
                    "mp3" => (),
                    "m3u" => (),
                    extension => {
                        let dialog = 
                        MessageDialog::new(Some(&self.window),  
                        DialogFlags::empty(), MessageType::Error,
                        ButtonsType::Ok, &format!("Cannot open file 
                         with extension . {}", extension));
                        dialog.run();
                        dialog.destroy();
                    },
                }
            }
        }
    }
}

我们可以在 update() 方法中调用这些函数:

fn update(&mut self, event: Msg) {
    match event {
        Open => self.open(),
        PlayPause =>  (),
        Quit => gtk::main_quit(),
        Save => show_save_dialog(&self.window),
        Stop => (),
    }
}

让我们管理一些其他操作。

其他方法

这将需要在 impl Widget 中添加两个新方法:

#[widget]
impl Widget for App {
    // …

    fn set_current_time(&mut self, time: u64) {
        self.model.current_time = time;
        self.model.adjustment.set_value(time as f64);
    }

    fn set_play_icon(&self, icon: &str) {
        self.model.play_image.set_from_file(format!("assets/{}.png", icon));
    }
}

但这些方法与Widget没有任何关系,那么为什么我们可以在特质实现中添加custom方法呢?嗯,#[widget]属性会将这些方法移动到属于它们的单独的impl App中。但为什么我们想要这样做而不是自己放置它们呢?那是因为relm会分析由#[widget]属性装饰的实现中的方法对模型属性的赋值。正如我们之前看到的,对模型字段的赋值将自动更新视图。如果我们将这些方法放在单独的impl App中,relm将无法分析这些方法并生成自动更新视图的代码。

这是一个常见的错误,如果你在分配给模型属性时视图没有更新,那么可能是因为你的分配不在由#[widget]属性装饰的实现中。

我们还需要为我们的模型添加一个新属性:

pub struct Model {
    adjustment: Adjustment,
    cover_pixbuf: Option<Pixbuf>,
    cover_visible: bool,
    current_duration: u64,
    current_time: u64,
    play_image: Image,
    stopped: bool,
}

我们添加了一个stopped属性,我们还需要在模型初始化中添加它:

fn model() -> Model {
    Model {
        adjustment: Adjustment::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
        cover_pixbuf: None,
        cover_visible: false,
        current_duration: 0,
        current_time: 0,
        play_image: new_icon(PLAY_ICON),
        stopped: true,
    }
}

我们现在可以将update()方法改为使用这些新方法:

fn update(&mut self, event: Msg) {
    match event {
        Open => self.open(),
        PlayPause =>  {
            if !self.model.stopped {
                self.set_play_icon(PLAY_ICON);
            }
        },
        Quit => gtk::main_quit(),
        Save => show_save_dialog(&self.window),
        Stop => {
            self.set_current_time(0);
            self.model.current_duration = 0;
            self.model.cover_visible = false;
            self.set_play_icon(PLAY_ICON);
        },
    }
}

update()方法通过可变引用接收self,这允许我们更新模型属性。

播放列表

现在我们已经准备好创建一个新的小部件:播放列表。我们需要以下新的dependencies

[dependencies]
id3 = "⁰.2.0"
m3u = "¹.0.0"

添加它们相应的extern crate语句:

extern crate id3;
extern crate m3u;

让我们为我们的playlist创建一个新的模块:

mod playlist;

src/playlist.rs文件中,我们首先创建我们的模型:

use gtk::ListStore;

pub struct Model {
    current_song: Option<String>,
    model: ListStore,
    relm: Relm<Playlist>,
}

Relm类型来自relm包:

use relm::Relm;

向小部件发送消息是有用的。我们将在关于小部件通信的部分了解更多。让我们添加模型初始化函数:

use gdk_pixbuf::Pixbuf;
use gtk::{StaticType, Type};

#[widget]
impl Widget for Playlist {
    fn model(relm: &Relm<Self>, _: ()) -> Model {
        Model {
            current_song: None,
            model: ListStore::new(&[
                Pixbuf::static_type(),
                Type::String,
                Type::String,
                Type::String,
                Type::String,
                Type::String,
                Type::String,
                Type::String,
                Pixbuf::static_type(),
            ]),
            relm: relm.clone(),
        }
    }
}

这里,我们注意到我们使用了model()方法的不同的签名。这是怎么可能的?特质的方法定义不能改变,对吧?这是#[widget]包带来的另一个便利。在许多情况下,我们不需要这些参数,所以如果需要,它们会自动添加。第一个参数是relm,我们将其副本保存在模型中。第二个参数是模型初始化参数。ListStore与第五章中的相同,创建音乐播放器,我们只将其保存在我们的模型中,因为我们以后会用到它。

模型参数

让我们更详细地谈谈这个第二个参数。它可以在创建小部件时用来向小部件发送数据。记得我们调用run()时:

App::run(()).unwrap();

这里,我们指定()作为模型参数,因为我们不需要它。但我们可以使用不同的值,例如42,这个值将会在model()方法的第二个参数中被接收。

现在我们已经准备好创建视图:

use gtk;
use gtk::{TreeViewExt, WidgetExt};
use relm::Widget;
use relm_attributes::widget;

#[widget]
impl Widget for Playlist {
    // …

    view! {
        #[name="treeview"]
        gtk::TreeView {
            hexpand: true,
            model: &self.model.model,
            vexpand: true,
        }
    }
}

它真的很简单:我们给它一个名字,并将hexpandvexpand属性都设置为true,并将model属性绑定到我们的ListStore

让我们现在创建一个空的update()方法:

#[widget]
impl Widget for Playlist {
    // …

    fn update(&mut self, event: Msg) {
    }
}

我们将在稍后看到 Msg 类型。现在,我们将添加列,就像我们在 第五章创建音乐播放器 所做的那样。让我们复制以下枚举和常量:

use self::Visibility::*;

#[derive(PartialEq)]
enum Visibility {
    Invisible,
    Visible,
}

const THUMBNAIL_COLUMN: u32 = 0;
const TITLE_COLUMN: u32 = 1;
const ARTIST_COLUMN: u32 = 2;
const ALBUM_COLUMN: u32 = 3;
const GENRE_COLUMN: u32 = 4;
const YEAR_COLUMN: u32 = 5;
const TRACK_COLUMN: u32 = 6;
const PATH_COLUMN: u32 = 7;
const PIXBUF_COLUMN: u32 = 8;

然后让我们向 Paylist 添加新方法:

impl Playlist {
    fn add_pixbuf_column(&self, column: i32, visibility: Visibility) {
        let view_column = TreeViewColumn::new();
        if visibility == Visible {
            let cell = CellRendererPixbuf::new();
            view_column.pack_start(&cell, true);
            view_column.add_attribute(&cell, "pixbuf", column);
        }
        self.treeview.append_column(&view_column);

    }

    fn add_text_column(&self, title: &str, column: i32) {
        let view_column = TreeViewColumn::new();
        view_column.set_title(title);
        let cell = CellRendererText::new();
        view_column.set_expand(true);
        view_column.pack_start(&cell, true);
        view_column.add_attribute(&cell, "text", column);
        self.treeview.append_column(&view_column);
    }

    fn create_columns(&self) {
        self.add_pixbuf_column(THUMBNAIL_COLUMN as i32, Visible);
        self.add_text_column("Title", TITLE_COLUMN as i32);
        self.add_text_column("Artist", ARTIST_COLUMN as i32);
        self.add_text_column("Album", ALBUM_COLUMN as i32);
        self.add_text_column("Genre", GENRE_COLUMN as i32);
        self.add_text_column("Year", YEAR_COLUMN as i32);
        self.add_text_column("Track", TRACK_COLUMN as i32);
        self.add_pixbuf_column(PIXBUF_COLUMN as i32, Invisible);
    }
}

第五章创建音乐播放器 的这些函数相比,这里的区别在于,我们直接通过属性访问 treeview。这需要新的导入语句:

use gtk::{
    CellLayoutExt,
    CellRendererPixbuf,
    CellRendererText,
    TreeViewColumn,
    TreeViewColumnExt,
    TreeViewExt,
};

现在,在 init_view() 方法中调用 create_columns() 方法:

#[widget]
impl Widget for Playlist {
    fn init_view(&mut self) {
        self.create_columns();
    }

    // …
}

让我们从与播放列表的交互开始。我们将创建一个将歌曲添加到播放列表的方法:

use std::path::Path;

use gtk::{ListStoreExt, ListStoreExtManual, ToValue};
use id3::Tag;

impl Playlist {
    fn add(&self, path: &Path) {
        let filename =  
         path.file_stem().unwrap_or_default().to_str().unwrap_or_default();

        let row = self.model.model.append();

        if let Ok(tag) = Tag::read_from_path(path) {
            let title = tag.title().unwrap_or(filename);
            let artist = tag.artist().unwrap_or("(no artist)");
            let album = tag.album().unwrap_or("(no album)");
            let genre = tag.genre().unwrap_or("(no genre)");
            let year = tag.year().map(|year|
            year.to_string()).unwrap_or("(no year)".to_string());
            let track = tag.track().map(|track|  
             track.to_string()).unwrap_or("??".to_string());
            let total_tracks = 
             tag.total_tracks().map(|total_tracks|  
             total_tracks.to_string()).unwrap_or("??".to_string());
            let track_value = format!("{} / {}", track, 
             total_tracks);

            self.set_pixbuf(&row, &tag);

            self.model.model.set_value(&row, TITLE_COLUMN, 
            &title.to_value());
            self.model.model.set_value(&row, ARTIST_COLUMN,
            &artist.to_value());
            self.model.model.set_value(&row, ALBUM_COLUMN, 
            &album.to_value());
            self.model.model.set_value(&row, GENRE_COLUMN, 
            &genre.to_value());
            self.model.model.set_value(&row, YEAR_COLUMN,
            &year.to_value());
            self.model.model.set_value(&row, TRACK_COLUMN,
            &track_value.to_value());
        }
        else {
            self.model.model.set_value(&row, TITLE_COLUMN, 
             &filename.to_value());
        }

        let path = path.to_str().unwrap_or_default();
        self.model.model.set_value(&row, PATH_COLUMN,
         &path.to_value());
    }
}

调用 set_pixbuf() 方法,因此让我们来定义它:

use gdk_pixbuf::{InterpType, PixbufLoader};
use gtk::TreeIter;

const INTERP_HYPER: InterpType = 3;

const IMAGE_SIZE: i32 = 256;
const THUMBNAIL_SIZE: i32 = 64;

fn set_pixbuf(&self, row: &TreeIter, tag: &Tag) {
    if let Some(picture) = tag.pictures().next() {
        let pixbuf_loader = PixbufLoader::new();
        pixbuf_loader.set_size(IMAGE_SIZE, IMAGE_SIZE);
        pixbuf_loader.loader_write(&picture.data).unwrap();
        if let Some(pixbuf) = pixbuf_loader.get_pixbuf() {
            let thumbnail = pixbuf.scale_simple(THUMBNAIL_SIZE, 
             THUMBNAIL_SIZE, INTERP_HYPER).unwrap();
            self.model.model.set_value(row, THUMBNAIL_COLUMN, 
             &thumbnail.to_value());
            self.model.model.set_value(row, PIXBUF_COLUMN, 
             &pixbuf.to_value());
        }
        pixbuf_loader.close().unwrap();
    }
}

它与 第五章创建音乐播放器 创建的方法非常相似。当接收到 AddSong(path) 消息时,将调用此方法,因此我们现在创建我们的消息类型:

use std::path::PathBuf;

use self::Msg::*;

#[derive(Msg)]
pub enum Msg {
    AddSong(PathBuf),
    LoadSong(PathBuf),
    NextSong,
    PauseSong,
    PlaySong,
    PreviousSong,
    RemoveSong,
    SaveSong(PathBuf),
    SongStarted(Option<Pixbuf>),
    StopSong,
}

然后,相应地修改 update() 方法:

   fn update(&mut self, event: Msg) {
      match event {
          AddSong(path) => self.add(&path),
          LoadSong(path) => (),
          NextSong => (),
          PauseSong => (),
          PlaySong => (),
          PreviousSong => (),
          RemoveSong => (),
          SaveSong(path) => (),
          SongStarted(_) => (),
          StopSong => (),
        }
    }

在这里,当接收到 AddSong 消息时,我们调用 add() 方法。但这条消息是从哪里发出的?嗯,它将由 App 类型发出,当用户请求打开文件时。现在是时候回到主模块并使用这个新的 relm 小部件了。

添加 relm 小部件

首先,我们需要这些新的导入语句:

use playlist::Playlist;
use playlist::Msg::{
    AddSong,
    LoadSong,
    NextSong,
    PlaySong,
    PauseSong,
    PreviousSong,
    RemoveSong,
    SaveSong,
    SongStarted,
    StopSong,
};

然后,在工具栏下方添加 Playlist 小部件:

view! {
    #[name="window"]
    gtk::Window {
        title: "Rusic",
        gtk::Box {
            orientation: Vertical,
            #[name="toolbar"]
            gtk::Toolbar {
                // …
            },
            #[name="playlist"]
            Playlist {
            },
            gtk::Image {
                from_pixbuf: self.model.cover_pixbuf.as_ref(),
                visible: self.model.cover_visible,
            },
            gtk::Box {
                // …
            },
        },
        delete_event(_, _) => (Quit, Inhibit(false)),
    }
}

使用 relm 小部件和 gtk 小部件时有一些不同。Relm 小部件不得包含模块前缀,而 gtk 小部件必须包含一个。这就是为什么我们导入了 Playlist,但现在导入了 gtk::Toolbar。但为什么需要它呢?嗯,relm 小部件与 gtk 小部件不同,因此它们不是以相同的方式创建或添加到其他小部件中的。因此,relm 可以通过这种方式区分它们:如果有前缀,这是一个内置的 gtk 小部件,否则它是一个自定义的 relm 小部件。当我说 gtk 小部件时,这甚至包括来自其他包的 gtk 小部件,例如 webkit2gtk::WebView

小部件之间的通信

现在,我们将在小部件之间进行通信,以表示我们想要将歌曲添加到播放列表中。但在这样做之前,我们将更详细地了解小部件如何与自身通信。

与同一小部件的通信

我们之前看到了如何与同一小部件通信。要从视图中的事件处理器向同一小部件发送消息,我们只需在 => 的右侧指定要发送的消息,如下例所示:

gtk::ToolButton {
    icon_widget: &new_icon("gtk-quit"),
    clicked => Quit,
}

在这里,当用户点击此工具按钮时,将向同一小部件(即 App)发送 Quit 消息。但这是对 relm 小部件事件流上的 emit() 方法的调用的一种语法糖。

发射

因此,让我们看看如何在不使用这种语法的情况下向同一个小部件发送消息:这在更复杂的情况下很有用,例如当我们想要有条件地发送消息时。让我们回到我们的Playlist并添加一个play()方法:

impl Playlist {
    fn play(&mut self) {
        if let Some(path) = self.selected_path() {
            self.model.current_song = Some(path.into());
            self.model.relm.stream().emit(SongStarted(self.pixbuf()));
        }
    }
}

这行代码向当前小部件发送一个消息:

self.model.relm.stream().emit(SongStarted(self.pixbuf()));

我们首先从relm小部件获取事件流,然后在上面调用emit()方法并传递一个消息。这个play()方法需要两个新方法:

use gtk::{
    TreeModelExt,
    TreeSelectionExt,
};

impl Playlist {
    fn pixbuf(&self) -> Option<Pixbuf> {
        let selection = self.treeview.get_selection();
        if let Some((_, iter)) = selection.get_selected() {
            let value = self.model.model.get_value(&iter, 
             PIXBUF_COLUMN as i32);
            return value.get::<Pixbuf>();
        }
        None
    }

    fn selected_path(&self) -> Option<String> {
        let selection = self.treeview.get_selection();
        if let Some((_, iter)) = selection.get_selected() {
            let value = self.model.model.get_value(&iter, PATH_COLUMN as i32);
            return value.get::<String>();
        }
        None
    }
}

这些与我们在前几章中编写的非常相似。我们现在可以在update()方法中调用play()方法:

    fn update(&mut self, event: Msg) {
        match event {
            AddSong(path) => self.add(&path),
            LoadSong(path) => (),
            NextSong => (),
            PauseSong => (),
            PlaySong => self.play(),
            PreviousSong => (),
            RemoveSong => (),
            SaveSong(path) => (),
            // To be listened by App.
            SongStarted(_) => (),
            StopSong => (),
        }
    }

我还在SongStarted之前添加了一个注释,表明这个消息不会被Paylist小部件处理,而是由App小部件处理。现在,让我们看看如何在不同的小部件之间进行通信。

使用不同的小部件

让我们更新open()方法以与播放列表进行通信:

impl App {
    fn open(&self) {
        let file = show_open_dialog(&self.window);
        if let Some(file) = file {
            let ext = file.extension().map(|ext| ext.to_str().unwrap().to_string());
            if let Some(ext) = ext {
                match ext.as_str() {
                    "mp3" => self.playlist.emit(AddSong(file)),
                    "m3u" => self.playlist.emit(LoadSong(file)),
                    extension => {
                        let dialog = MessageDialog::new(Some(&self.window),  
                        DialogFlags::empty(), MessageType::Error,
                        ButtonsType::Ok, &format!("Cannot open file with 
                         extension . {}", extension));
                        dialog.run();
                        dialog.destroy();
                    },
                }
            }
        }
    }
}

因此,我们调用相同的emit()方法向另一个小部件发送消息:

self.playlist.emit(AddSong(file))

这里,我们发送了一个尚未由PlaylistLoadSong)处理的消息,所以让我们修复这个问题:

use m3u;

impl Playlist {
    fn load(&self, path: &Path) {
        let mut reader = m3u::Reader::open(path).unwrap();
        for entry in reader.entries() {
            if let Ok(m3u::Entry::Path(path)) = entry {
                self.add(&path);
            }
        }
    }
}

这个方法在update()方法中被调用:

fn update(&mut self, event: Msg) {
    match event {
        AddSong(path) => self.add(&path),
        LoadSong(path) => self.load(&path),
        NextSong => (),
        PauseSong => (),
        PlaySong => self.play(),
        PreviousSong => (),
        RemoveSong => (),
        SaveSong(path) => (),
        // To be listened by App.
        SongStarted(_) => (),
        StopSong => (),
    }
}

处理来自 relm 小部件的消息

现在,让我们看看如何处理SongStarted消息。为了做到这一点,我们使用与处理gtk事件类似的语法。消息位于=>的左侧,而处理程序位于其右侧:

#[widget]
impl Widget for App {
    // …

    view! {
        // …
        #[name="playlist"]
        Playlist {
            SongStarted(ref pixbuf) => Started(pixbuf.clone()),
        }
    }
}

我们可以看到,当我们从播放列表接收到SongStarted消息时,我们在同一个小部件(App)上发出Started消息。我们需要在这里使用ref然后clone()消息中包含的值,因为我们不拥有这个消息。确实,多个小部件可以监听同一个消息,即发出消息的小部件及其父小部件。在我们处理这个新消息之前,我们将它添加到我们的Msg枚举中:

#[derive(Msg)]
pub enum Msg {
    Open,
    PlayPause,
    Quit,
    Save,
    Started(Option<Pixbuf>),
    Stop,
}

这个变体接受一个可选的pixbuf,因为一些 MP3 文件内部没有封面图像。以下是处理这个消息的方法:

fn update(&mut self, event: Msg) {
    match event {
        // …
        Started(pixbuf) => {
            self.set_play_icon(PAUSE_ICON);
            self.model.cover_visible = true;
            self.model.cover_pixbuf = pixbuf;
        },
    }
}

当歌曲开始播放时,我们显示暂停图标和封面。

向另一个 relm 小部件发送消息的语法糖

使用emit()向另一个小部件发送消息有点冗长,所以relm为此情况提供了语法糖。让我们在用户点击删除按钮时向播放列表发送消息:

gtk::ToolButton {
    icon_widget: &new_icon("remove"),
    clicked => playlist@RemoveSong,
}

在这里,我们使用了@语法来指定消息将被发送到另一个小部件。@之前的部分是接收器小部件,而@之后的部分是消息。因此,这段代码的意思是,每当用户点击删除按钮时,将RemoveSong消息发送到playlist小部件。

让我们在Paylist::update()方法中处理这个消息:

#[widget]
impl Widget for Playlist {
    fn update(&mut self, event: Msg) {
        match event {
            AddSong(path) => self.add(&path),
            LoadSong(path) => self.load(&path),
            NextSong => (),
            PauseSong => (),
            PlaySong => self.play(),
            PreviousSong => (),
            RemoveSong => self.remove_selection(),
            SaveSong(path) => (),
            // To be listened by App.
            SongStarted(_) => (),
            StopSong => (),
        }
    }

    // …
}

这将调用remove_selection()方法,如下所示:

fn remove_selection(&self) {
    let selection = self.treeview.get_selection();
    if let Some((_, iter)) = selection.get_selected() {
        self.model.model.remove(&iter);
    }
}

这与第五章中提到的相同方法,创建音乐播放器。现在,让我们发送剩余的消息。PlaySongPauseSongSaveSongStopSong消息在update()方法中发出:

#[widget]
impl Widget for App {
    fn update(&mut self, event: Msg) {
        match event {
            PlayPause =>  {
                if self.model.stopped {
                    self.playlist.emit(PlaySong);
                } else {
                    self.playlist.emit(PauseSong);
                    self.set_play_icon(PLAY_ICON);
                }
            },
            Save => {
                let file = show_save_dialog(&self.window);
                if let Some(file) = file {
                    self.playlist.emit(SaveSong(file));
                }
            },
            Stop => {
                self.set_current_time(0);
                self.model.current_duration = 0;
                self.playlist.emit(StopSong);
                self.model.cover_visible = false;
                self.set_play_icon(PLAY_ICON);
            },
            // …
        }
    }
}

其他消息使用视图中的 @ 语法发送:

view! {
    #[name="window"]
    gtk::Window {
        title: "Rusic",
        gtk::Box {
            orientation: Vertical,
            #[name="toolbar"]
            gtk::Toolbar {
                // …
                gtk::ToolButton {
                    icon_widget: &new_icon("gtk-media-previous"),
                    clicked => playlist@PreviousSong,
                },
                // …
                gtk::ToolButton {
                    icon_widget: &new_icon("gtk-media-next"),
                    clicked => playlist@NextSong,
                },
            },
            // …
        },
        delete_event(_, _) => (Quit, Inhibit(false)),
    }
}

我们将在 Paylist::update() 方法中处理这些消息:

fn update(&mut self, event: Msg) {
    match event {
        AddSong(path) => self.add(&path),
        LoadSong(path) => self.load(&path),
        NextSong => self.next(),
        PauseSong => (),
        PlaySong => self.play(),
        PreviousSong => self.previous(),
        RemoveSong => self.remove_selection(),
        SaveSong(path) => self.save(&path),
        // To be listened by App.
        SongStarted(_) => (),
        StopSong => self.stop(),
    }
}

这需要一些新方法:

fn next(&mut self) {
    let selection = self.treeview.get_selection();
    let next_iter =
        if let Some((_, iter)) = selection.get_selected() {
            if !self.model.model.iter_next(&iter) {
                return;
            }
            Some(iter)
        }
        else {
            self.model.model.get_iter_first()
        };
    if let Some(ref iter) = next_iter {
        selection.select_iter(iter);
        self.play();
    }
}
fn previous(&mut self) {
    let selection = self.treeview.get_selection();
    let previous_iter =
        if let Some((_, iter)) = selection.get_selected() {
            if !self.model.model.iter_previous(&iter) {
                return;
            }
            Some(iter)
        }
        else {
            self.model.model.iter_nth_child(None, max(0,  
            self.model.model.iter_n_children(None) - 1))
        };
    if let Some(ref iter) = previous_iter {
        selection.select_iter(iter);
        self.play();
    }
}
use std::fs::File;

fn save(&self, path: &Path) {
    let mut file = File::create(path).unwrap();
    let mut writer = m3u::Writer::new(&mut file);

    let mut write_iter = |iter: &TreeIter| {
        let value = self.model.model.get_value(&iter, PATH_COLUMN as i32);
        let path = value.get::<String>().unwrap();
        writer.write_entry(&m3u::path_entry(path)).unwrap();
    };

    if let Some(iter) = self.model.model.get_iter_first() {
        write_iter(&iter);
        while self.model.model.iter_next(&iter) {
            write_iter(&iter);
        }
    }
}

并且函数 stop:

fn stop(&mut self) {
    self.model.current_song = None;
}

这些方法都与我们在上一章中创建的方法相似。你可以运行应用程序以查看我们可以打开和删除歌曲,但我们还不能播放它们。所以让我们解决这个问题。

播放音乐

首先,添加 mp3 模块:

mod mp3;

将上一章的 src/mp3.rs 文件复制过来。

我们还需要以下依赖项:

[dependencies]
crossbeam = "⁰.3.0"
futures = "⁰.1.16"
pulse-simple = "¹.0.0"
simplemad = "⁰.8.1"

然后将这些语句添加到 main 模块中:

extern crate crossbeam;
extern crate futures;
extern crate pulse_simple;
extern crate simplemad;

现在,我们将添加一个 player 模块:

mod player;

这个新模块将开始于一系列导入语句:

use std::cell::Cell;
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::Duration;

use crossbeam::sync::SegQueue;
use futures::{AsyncSink, Sink};
use futures::sync::mpsc::UnboundedSender;
use pulse_simple::Playback;

use mp3::Mp3Decoder;
use playlist::PlayerMsg::{
    self,
    PlayerPlay,
    PlayerStop,
    PlayerTime,
};
use self::Action::*;

我们从 playlist 模块导入了新的 PlayerMsg 类型,所以让我们添加它:

#[derive(Clone)]
pub enum PlayerMsg {
    PlayerPlay,
    PlayerStop,
    PlayerTime(u64),
}

我们将定义一些常量:

const BUFFER_SIZE: usize = 1000;
const DEFAULT_RATE: u32 = 44100;

然后让我们创建我们需要的类型:

enum Action {
    Load(PathBuf),
    Stop,
}

#[derive(Clone)]
struct EventLoop {
    condition_variable: Arc<(Mutex<bool>, Condvar)>,
    queue: Arc<SegQueue<Action>>,
    playing: Arc<Mutex<bool>>,
}

pub struct Player {
    event_loop: EventLoop,
    paused: Cell<bool>,
    tx: UnboundedSender<PlayerMsg>,
}

ActionEventLoop 与上一章相同,但 Player 类型略有不同。它不包含表示应用程序状态的字段,而是包含一个用于向播放列表和最终向应用程序本身发送消息的发送者。因此,我们不会像上一章那样使用共享状态和超时,而是使用消息传递,这更有效率。

我们需要一个 EventLoop 的构造函数:

impl EventLoop {
    fn new() -> Self {
        EventLoop {
            condition_variable: Arc::new((Mutex::new(false), Condvar::new())),
            queue: Arc::new(SegQueue::new()),
            playing: Arc::new(Mutex::new(false)),
        }
    }
}

让我们为 Player 创建构造函数:

impl Player {
    pub(crate) fn new(tx: UnboundedSender<PlayerMsg>) -> Self {
        let event_loop = EventLoop::new();

        {
            let mut tx = tx.clone();
            let event_loop = event_loop.clone();
            let condition_variable = event_loop.condition_variable.clone();
            thread::spawn(move || {
                let block = || {
                    let (ref lock, ref condition_variable) = *condition_variable;
                    let mut started = lock.lock().unwrap();
                    *started = false;
                    while !*started {
                        started = condition_variable.wait(started).unwrap();
                    }
                };

                let mut buffer = [[0; 2]; BUFFER_SIZE];
                let mut playback = Playback::new("MP3", "MP3 Playback", None,  
                DEFAULT_RATE);
                let mut source = None;
                loop {
                    if let Some(action) = event_loop.queue.try_pop() {
                        match action {
                            Load(path) => {
                                let file = File::open(path).unwrap();
                                source = 
                Some(Mp3Decoder::new(BufReader::new(file)).unwrap());
                                let rate = source.as_ref().map(|source|  
                                source.samples_rate()).unwrap_or(DEFAULT_RATE);
                                playback = Playback::new("MP3", "MP3 Playback",  
                                 None, rate);
                                send(&mut tx, PlayerPlay);
                            },
                            Stop => {
                                source = None;
                            },
                        }
                    } else if *event_loop.playing.lock().unwrap() {
                        let mut written = false;
                        if let Some(ref mut source) = source {
                            let size = iter_to_buffer(source, &mut buffer);
                            if size > 0 {
                                send(&mut tx, PlayerTime(source.current_time()));
                                playback.write(&buffer[..size]);
                                written = true;
                            }
                        }

                        if !written {
                            send(&mut tx, PlayerStop);
                            *event_loop.playing.lock().unwrap() = false;
                            source = None;
                            block();
                        }
                    } else {
                        block();
                    }
                }
            });
        }

        Player {
            event_loop,
            paused: Cell::new(false),
            tx,
        }
    }
}

它与我们在上一章中编写的类似,但不是使用共享状态,而是将消息发送回播放列表。以下是如何发送这些消息的示例:

send(&mut tx, PlayerTime(source.current_time()));

这会将当前时间发送回 UI,以便它可以显示它。这需要定义 send() 函数:

fn send(tx: &mut UnboundedSender<PlayerMsg>, msg: PlayerMsg) {
    if let Ok(AsyncSink::Ready) = tx.start_send(msg) {
        tx.poll_complete().unwrap();
    } else {
        eprintln!("Unable to send message to sender");
    }
}

此代码使用 future crate 发送消息,并在失败时显示错误。iter_to_buffer() 函数与上一章中的相同:

fn iter_to_buffer<I: Iterator<Item=i16>>(iter: &mut I, buffer: &mut [[i16; 2]; BUFFER_SIZE]) -> usize {
    let mut iter = iter.take(BUFFER_SIZE);
    let mut index = 0;
    while let Some(sample1) = iter.next() {
        if let Some(sample2) = iter.next() {
            buffer[index][0] = sample1;
            buffer[index][1] = sample2;
        }
        index += 1;
    }
    index
}

现在,我们将添加播放和暂停歌曲的方法:

pub fn load<P: AsRef<Path>>(&self, path: P) {
    let pathbuf = path.as_ref().to_path_buf();
    self.emit(Load(pathbuf));
    self.set_playing(true);
}

pub fn pause(&mut self) {
    self.paused.set(true);
    self.send(PlayerStop);
    self.set_playing(false);
}

pub fn resume(&mut self) {
    self.paused.set(false);
    self.send(PlayerPlay);
    self.set_playing(true);
}

它们与上一章的非常相似,但我们发送一个消息而不是修改状态。它们需要以下方法:

fn emit(&self, action: Action) {
    self.event_loop.queue.push(action);
}

fn send(&mut self, msg: PlayerMsg) {
    send(&mut self.tx, msg);
}

fn set_playing(&self, playing: bool) {
    *self.event_loop.playing.lock().unwrap() = playing;
    let (ref lock, ref condition_variable) = *self.event_loop.condition_variable;
    let mut started = lock.lock().unwrap();
    *started = playing;
    if playing {
        condition_variable.notify_one();
    }
}

emit()set_playing() 方法与上一章相同。send() 方法只是调用我们之前定义的 send() 函数。

我们还需要这两个方法:

pub fn is_paused(&self) -> bool {
    self.paused.get()
}

pub fn stop(&mut self) {
    self.paused.set(false);
    self.send(PlayerTime(0));
    self.send(PlayerStop);
    self.emit(Stop);
    self.set_playing(false);
}

is_paused() 方法没有变化。而 stop() 方法类似,但同样,它发送消息而不是直接更新应用程序状态。让我们回到我们的 Paylist 来使用这个新的播放器。现在模型将包含播放器本身:

use player::Player;

pub struct Model {
    current_song: Option<String>,
    player: Player,
    model: ListStore,
    relm: Relm<Playlist>,
}

Msg 类型将包含一个名为 PlayerMsgRecv 的新变体,每当播放器发送消息时都会发出:

#[derive(Msg)]
pub enum Msg {
    AddSong(PathBuf),
    LoadSong(PathBuf),
    NextSong,
    PauseSong,
    PlayerMsgRecv(PlayerMsg),
    PlaySong,
    PreviousSong,
    RemoveSong,
    SaveSong(PathBuf),
    SongStarted(Option<Pixbuf>),
    StopSong,
}

现在,我们已经准备好更新模型初始化:

use futures::sync::mpsc;

fn model(relm: &Relm<Self>, _: ()) -> Model {
    let (tx, rx) = mpsc::unbounded();
    relm.connect_exec_ignore_err(rx, PlayerMsgRecv);
    Model {
        current_song: None,
        player: Player::new(tx),
        model: ListStore::new(&[
            Pixbuf::static_type(),
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Type::String,
            Pixbuf::static_type(),
        ]),
        relm: relm.clone(),
    }
}

现在它从future crate 的mpsc类型创建一个发送者和接收者对。MPSC代表Multiple-Producers-Single-Consumer。我们现在调用Relm::connect_exec_ignore_err()方法,这个方法将一个Future或一个Stream连接到一个消息。这意味着每当Stream中产生一个值时,就会发出一个消息。这个消息需要接受一个与Stream产生的值相同类型的参数。一个Future代表一个可能尚未可用,但将来会可用,除非发生错误。一个Stream类似,但可以在未来不同时间产生多个值。与connect_exec_ignore_err()方法类似,还有一个connect_exec()方法,它接受另一个消息变体作为参数,当发生错误时,将发出第二个消息。在这里,我们简单地忽略错误。

update()方法中:

fn update(&mut self, event: Msg) {
    match event {
        // To be listened by App.
        PlayerMsgRecv(_) => (),
        // …
    }
}

我们与这个消息无关,因为它将由App小部件处理。我们现在将添加一个暂停播放器的函数:

fn pause(&mut self) {
    self.model.player.pause();
}

接下来,我们需要更新play()stop()方法:

fn play(&mut self) {
    if let Some(path) = self.selected_path() {
        if self.model.player.is_paused() && Some(&path) == self.path().as_ref() {
            self.model.player.resume();
        } else {
            self.model.player.load(&path);
            self.model.current_song = Some(path.into());
            self.model.relm.stream().emit(SongStarted(self.pixbuf()));
        }
    }
}

fn stop(&mut self) {
    self.model.current_song = None;
    self.model.player.stop();
}

stop()方法与之前相同,只是我们可以直接更新模型,因为我们不再需要使用RefCell类型。play()方法现在将根据播放器的状态加载或恢复歌曲。

play()方法需要一个path()方法:

fn path(&self) -> Option<String> {
    self.model.current_song.clone()
}

让我们回到main模块来管理播放器发送的消息。首先,我们需要为我们的enum Msg添加一个新的变体:

#[derive(Msg)]
pub enum Msg {
    MsgRecv(PlayerMsg),
    // …
}

我们将在update()方法中处理这个问题:

fn update(&mut self, event: Msg) {
    match event {
        MsgRecv(player_msg) => self.player_message(player_msg),
        // …
    }
}

这需要在impl Widget for App中添加一个新的方法:

#[widget]
impl Widget for App {
    fn player_message(&mut self, player_msg: PlayerMsg) {
        match player_msg {
            PlayerPlay => {
                self.model.stopped = false;
                self.set_play_icon(PAUSE_ICON);
            },
            PlayerStop => {
                self.set_play_icon(PLAY_ICON);
                self.model.stopped = true;
            },
            PlayerTime(time) => self.set_current_time(time),
        }
    }
}

这也是一个custom方法,即不是Widget特质的组成部分,但由#[widget]属性分析的方法。我们将其放在那里而不是单独的impl App中,因为我们更新了模型。在这个方法中,我们要么更新图标以显示播放按钮,要么显示当前时间。

计算歌曲时长

为了与上一章的音乐播放器相匹配,唯一需要实现的功能是计算并显示歌曲时长。首先,我们将从上一章复制compute_duration()方法并将其粘贴到我们的Player中:

pub fn compute_duration<P: AsRef<Path>>(path: P) -> Option<Duration> {
    let file = File::open(path).unwrap();
    Mp3Decoder::compute_duration(BufReader::new(file))
}

我们现在将在Playlist中调用这个方法:

use std::thread;
use futures::sync::oneshot;

fn compute_duration(&self, path: &Path) {
    let path = path.to_path_buf();
    let (tx, rx) = oneshot::channel();
    thread::spawn(move || {
        if let Some(duration) = Player::compute_duration(&path) {
            tx.send((path, duration))
                .expect("Cannot send computed duration");
        }
    });
    self.model.relm.connect_exec_ignore_err(rx, |(path, duration)| DurationComputed(path, duration));
}

这里,我们使用oneshot,它也是一个通道,类似于mpsc,但oneshot只能发送一次消息。发送的消息是一个元组,因此我们通过使用一个新添加的DurationComputed变体将其转换为我们的Msg类型:

use std::time::Duration;

#[derive(Msg)]
pub enum Msg {
    AddSong(PathBuf),
    DurationComputed(PathBuf, Duration),
    SongDuration(u64),
    // …
}

我们还添加了一个即将使用的SongDuration消息。

我们需要在Playlist::add()中调用这个方法:

impl Playlist {
    fn add(&self, path: &Path) {
        self.compute_duration(path);
        // …
    }
}

然后,我们需要在Playlist::update()中处理新的DurationComputed消息:

use to_millis;

fn update(&mut self, event: Msg) {
    match event {
        DurationComputed(path, duration) => {
            let path = path.to_string_lossy().to_string();
            if self.model.current_song.as_ref() == Some(&path) {
                self.model.relm.stream().emit(SongDuration(to_millis(duration)));
            }
            self.model.durations.insert(path, to_millis(duration));
        },
        // To be listened by App.
        SongDuration(_) => (),
        // …
    }
}

这里,我们将计算出的时长插入到模型中。如果歌曲是当前正在播放的,我们将发送SongDuration消息,以便App小部件可以更新自己。

这需要在模型中的时长添加一个新的字段:

use std::collections::HashMap;

pub struct Model {
    current_song: Option<String>,
    durations: HashMap<String, u64>,
    player: Player,
    model: ListStore,
    relm: Relm<Playlist>,
}

添加新的模型初始化:

fn model(relm: &Relm<Self>, _: ()) -> Model {
    // …
    Model {
        durations: HashMap::new(),
        // …
    }
}

这也要求在 main 模块中添加 to_millis() 函数,这与上一章相同:

use std::time::Duration;

fn to_millis(duration: Duration) -> u64 {
    duration.as_secs() * 1000 + duration.subsec_nanos() as u64 / 1_000_000
}

由于持续时间只计算一次,因此在我们开始播放歌曲时也需要发送它,所以让我们更新 Playlist::play() 方法:

fn play(&mut self) {
    if let Some(path) = self.selected_path() {
        if self.model.player.is_paused() && Some(&path) == self.path().as_ref() {
            self.model.player.resume();
        } else {
            self.model.player.load(&path);
            if let Some(&duration) = self.model.durations.get(&path) {
                self.model.relm.stream().emit(SongDuration(duration));
            }
            self.model.current_song = Some(path.into());
            self.model.relm.stream().emit(SongStarted(self.pixbuf()));
        }
    }
}

如果我们在 HashMap 中找到了 SongDuration 消息(歌曲可能在计算持续时间之前开始播放),我们将发送 SongDuration 消息。

最后,我们需要在 App 视图中处理以下消息:

view! {
    Playlist {
        PlayerMsgRecv(ref player_msg) => MsgRecv(player_msg.clone()),
        SongDuration(duration) => Duration(duration),
        SongStarted(ref pixbuf) => Started(pixbuf.clone()),
    }
    // …
}

当我们从播放列表接收到 SongDuration 消息时,我们会向 App 发送 Duration 消息,因此我们需要将这个变体添加到它的 Msg 类型中:

#[derive(Msg)]
pub enum Msg {
    Duration(u64),
    // …
}

我们将在 update() 方法中简单地处理它:

fn update(&mut self, event: Msg) {
    match event {
        Duration(duration) => {
            self.model.current_duration = duration;
            self.model.adjustment.set_upper(duration as f64);
        },
        // …
    }
}

现在,你可以运行应用程序并看到它的工作方式与上一章中的完全相同。

在稳定版 Rust 上使用 relm

在整个这一章中,我们使用了 Rust 夜间版,以便能够使用当前不稳定的 custom 属性。relm 提供的 #[widget] 属性提供了许多优势:

  • 声明性视图

  • 数据绑定

  • 输入更少

因此,能够在稳定版上使用类似的语法并且提供相同优势将会很棒。通过使用 relm_widget! 宏,我们可以做到这一点。我们将重写 App 小部件以使用这个宏:

relm_widget! {
    impl Widget for App {
        fn init_view(&mut self) {
            self.toolbar.show_all();
        }

        fn model() -> Model {
            Model {
                adjustment: Adjustment::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
                cover_pixbuf: None,
                cover_visible: false,
                current_duration: 0,
                current_time: 0,
                play_image: new_icon(PLAY_ICON),
                stopped: true,
            }
        }

        fn open(&self) {
            // …
        }

        // …

        fn update(&mut self, event: Msg) {
            // …
        }

        view! {
            #[name="window"]
            gtk::Window {
                title: "Rusic",
                // …
            }
        }
    }
}

如你所见,我们将外部 open() 方法移动到了由 relm_widget! 宏装饰的实现内部。这是由于这个宏的限制,虽然它允许我们在稳定版 Rust 上使用 relm 提供的漂亮语法,但我们无法从宏外部访问模型字段。其余部分与之前的版本完全相同。

Relm 小部件数据绑定

relm 中还有许多其他功能可用,我想向你展示其中最重要的:提供的用于模拟属性绑定的语法。正如你可能已经注意到的,relm 小部件中没有属性,但你可以使用消息传递来更新 relm 小部件的内部状态。为了使其更方便,#[widget] 属性还允许你将模型属性绑定到消息,这意味着每当属性更新时,都会发出带有这个新值的消息。

我们将添加一个切换按钮,以便能够在播放列表的简单视图和详细视图之间切换。简单视图将只显示封面和标题,而详细视图将显示所有列。首先,让我们向 App 模型添加一个属性:

pub struct Model {
    detailed_view: bool,
    // …
}

    fn model() -> Model {
        Model {
            detailed_view: false,
            // …
        }
    }

此字段指定我们是否处于详细视图模式。我们还需要一个在点击切换按钮时将发出的消息:

#[derive(Msg)]
pub enum Msg {
    ViewToggle,
    // …
}

然后,我们将切换按钮添加到工具栏中:

#[name="toggle_button"]
gtk::ToggleToolButton {
    label: "Detailed view",
    toggled => ViewToggle,
}

当我们收到这个消息时,我们将相应地设置 model 属性:

fn update(&mut self, event: Msg) {
    match event {
        ViewToggle => self.model.detailed_view = self.toggle_button.get_active(),
        // …
    }
}

现在,让我们向 Playlist 发送一个消息:

#[derive(Msg)]
pub enum Msg {
    DetailedView(bool),
    // …
}

这是我们将用于绑定的消息。让我们来处理它:

fn update(&mut self, event: Msg) {
    match event {
        DetailedView(detailed) => self.set_detailed_view(detailed),
        // …
    }
}

fn set_detailed_view(&self, detailed: bool) {
    for column in self.treeview.get_columns().iter().skip(2) {
        column.set_visible(detailed);
    }
}

后者方法切换除前两个之外的所有列的可见性。现在我们可以在 App 视图中创建绑定:

use playlist::Msg::DetailedView;

view! {
    // …
    #[name="playlist"]
    Playlist {
        // …
        DetailedView: self.model.detailed_view,
    }
}

此代码将在指定的属性发生变化时发送 DetailedView 消息。

摘要

在本章中,我们使用 relm 创建了一个音乐播放器。我们看到了如何使用 rustup 与 rust nightly 版本结合使用是多么简单。我们学习了如何声明式地创建视图,并使用消息传递在部件之间进行通信。我们还学习了如何通过分离模型、视图以及更新模型的函数来结构化 GUI 应用程序。在下一章中,我们将切换到另一个项目:一个 FTP 服务器。

第八章:理解 FTP

本章全部关于 Rust 中的异步编程。为了向您展示它是如何工作的,我们将编写一个 FTP 服务器。然而,为了尽可能让您容易理解,我们将把主题分解为以下几个部分:

  • 展示 FTP 协议

  • 实现 FTP 服务器

  • 在 Rust 中展示异步编程

  • 异步实现 FTP 服务器

这些步骤都非常重要,以便让您在 Rust 异步编程中感到自信。

现在,让我们先简单谈谈 FTP 协议!

文件传输协议

文件传输协议(FTP)于 1971 年创建。其最终 RFC 为 959。如果你对此感兴趣,可以在 tools.ietf.org/html/rfc959 上了解更多信息。

作为一种旧协议,一些命令没有明确的规范,因此已经编写了一些替代规范(或多或少是官方的),以填补这些空白。在编写服务器时,我们会回到它们。

另一个需要注意的重要点是,FTP 使用 TCP 连接。

现在我们已经快速介绍了 FTP,让我们看看它是如何工作的。

FTP 简介

客户端连接到服务器并向服务器发送命令。每个命令都会收到来自服务器的成功或失败回答。

例如,客户端会向服务器发送 PWD 命令:

=> PWD\r\n
<= 257 "/"\r\n

在这里,服务器回答了 257(字面上意味着 路径名已创建),然后给出了客户端当前所在的当前工作目录(在这种情况下是 "/")。

如您所见,每个命令都以 "" 结尾。这是 FTP 的另一个标准——每个命令都必须以 "" 结尾。如果您不知道,"" 代表回车符,"" 代表换行符。

另一件事需要注意——服务器的回答 总是"" 前包含一个字符串。考虑以下示例:

=> NOOP\r\n
<= 250 Doing nothing\r\n

如果客户端的命令不需要精确的输出(除了返回代码),那就完全取决于服务器。通常只是一个简短的句子,提供更多关于服务器所做(或失败)的信息。在另一个服务器上,NOOP 命令可能会给出以下内容:

=> NOOP\r\n
<= 250 42 is life\r\n

最后,FTP 使用两个通道:

  • 第一个通道用于发送小命令,例如更新状态

  • 第二个通道用于发送大量数据,例如文件传输或列出目录

关于第二个通道的一个有趣之处在于,它取决于客户端决定是服务器连接到客户端还是反过来。但在几乎所有情况下,客户端都会要求服务器再次连接到它,服务器选择一个端口,然后就可以开始了。

现在我们可以说,我们已经完成了对 FTP 的快速介绍。如果到现在为止仍然不太清楚,无需担心:随着我们逐步实现服务器,它将变得更加明显。

因此,让我们从同步服务器实现开始。

实现简单的命令块

让我们先从创建一个非常简单的服务器开始,这个服务器向新客户端发送 "hello" 然后关闭连接:

use std::net::TcpListener;
use std::io::Write;

fn main() {
    let listener = TcpListener::bind("0.0.0.0:1234").expect("Couldn't bind this 
    address...");

    println!("Waiting for clients to connect...");
    for stream in listener.incoming() {
        Ok(stream) => {
            println!("New client!);
            if let Err(_) = stream.write(b"hello") {
                println!("Failed to send hello... :'(");
            }
        }
        _ => {
            println!("A client tried to connect...")
        }
    }
}

很简单,对吧?像往常一样,让我们解释一下代码的功能:

let listener = TcpListener::bind("0.0.0.0:1234").expect("Couldn't bind this address...");

对于那些不太了解网络的人来说,前面的代码行对于任何服务器来说都是最重要的。

它试图只为你的服务器“预订”端口。如果其他软件正在使用它,那么 bind 调用将失败。给定的字符串表示我们想要“预订”的地址和端口。参数的作用如下:[IP]:[PORT]。在这里,我们输入了 0.0.0.0:1234,这意味着我们想要在地址 0.0.0.0 上使用端口 1234

允许服务器选择要使用的 IP 地址听起来可能很奇怪,但实际上并非如此。你只能在这两个选项之间选择:localhost(别名 127.0.0.1)和 0.0.0.0。这两个地址之间的唯一区别是,0.0.0.0 允许其他计算机连接到你的计算机(如果端口可以通过你的互联网接入提供商提供的盒子从外部访问),而 127.0.0.1 只能从启动它的计算机访问。但是,关于网络的解释就到这里吧——这不是这本书的重点,所以让我们继续前进!

需要解释的另外一段代码如下:

for stream in listener.incoming() {

incoming 方法调用允许我们通过返回一个迭代器无限迭代新接收到的连接。然后,for 循环只是调用迭代器的 next 方法。

这个简短代码示例就到这里。现在,是时候改进所有这些了!

很好,我们想要分别处理每个客户端,而不是在收到新连接后立即关闭连接,不是吗?所以,我们只需更新一下之前的代码即可:

use std::net::{TcpListener, TcpStream};
use std::thread;

fn handle_client(mut stream: TcpStream) {
    println!("new client connected!");
    // put client code handling here
}

fn main() {
    let listener = TcpListener::bind("0.0.0.0:1234").expect("Couldn't bind this 
    address...");

    println!("Waiting for clients to connect...");
    for stream in listener.incoming() {
        Ok(stream) => {
            thread::spawn(move || {
                handle_client(stream);
            });
        }
        _ => {
            println!("A client tried to connect...")
        }
    }
}

每当新客户端连接到服务器时,我们会创建一个新的线程并将客户端的套接字发送给它。这样,我们现在可以单独处理每个客户端。

现在我们能够连接新客户端了,是时候真正开始实现服务器中的 FTP 部分。

从基础知识开始

当然,由于我们需要在套接字上进行读写操作,如果在每个函数中都要重复这样做,那就不会很高效。因此,我们将首先实现执行这些操作的函数。目前,我们不会优雅地处理错误(是的,unwrap 是邪恶的)。

让我们从 write 函数开始:

use use std::net::TcpStream;
use std::io::Write;

fn send_cmd(stream: &mut TcpStream, code: ResultCode, message: &str) {
    let msg = if message.is_empty() { CommandNotImplemented = 502,
        format!("{}\r\n", code as u32)
    } else {
        format!("{} {}\r\n", code as u32, message)
    };
    println!("<==== {}", msg);
    write!(stream, "{}", msg).unwrap()
}

好吧,这里没有什么花哨的,也没有什么难以理解的。然而,看看这个:

  • 在 FTP 中,每条消息都以 "" 结尾

  • 如果你想添加参数或信息,每条消息后面都必须跟一个空格。

当客户端发送命令给我们时,这也以完全相同的方式工作。

什么?我忘记提供 ResultCode 类型了吗?确实如此。这里就是:

#[derive(Debug, Clone, Copy)]
#[repr(u32)]
#[allow(dead_code)]
enum ResultCode {
    RestartMarkerReply = 110,
    ServiceReadInXXXMinutes = 120,
    DataConnectionAlreadyOpen = 125,
    FileStatusOk = 150,
    Ok = 200,
    CommandNotImplementedSuperfluousAtThisSite = 202,
    SystemStatus = 211,
    DirectoryStatus = 212,
    FileStatus = 213,
    HelpMessage = 214,
    SystemType = 215,
    ServiceReadyForNewUser = 220,
    ServiceClosingControlConnection = 221,
    DataConnectionOpen = 225,
    ClosingDataConnection = 226,
    EnteringPassiveMode = 227,
    UserLoggedIn = 230,
    RequestedFileActionOkay = 250,
    PATHNAMECreated = 257,
    UserNameOkayNeedPassword = 331,
    NeedAccountForLogin = 332,
    RequestedFileActionPendingFurtherInformation = 350,
    ServiceNotAvailable = 421,
    CantOpenDataConnection = 425,
    ConnectionClosed = 426,
    FileBusy = 450,
    LocalErrorInProcessing = 451,
    InsufficientStorageSpace = 452,
    UnknownCommand = 500,
    InvalidParameterOrArgument = 501,
    CommandNotImplemented = 502,
    BadSequenceOfCommands = 503,
    CommandNotImplementedForThatParameter = 504,
    NotLoggedIn = 530,
    NeedAccountForStoringFiles = 532,
    FileNotFound = 550,
    PageTypeUnknown = 551,
    ExceededStorageAllocation = 552,
    FileNameNotAllowed = 553,
}

嗯,不是非常美观... 这正是所有 FTP 代码类型(错误、信息、警告等)的确切表示。在这里我们无法做得更好;我们必须重写所有代码,以便在接收到它时能够理解,并且能够给出与客户端命令相对应的正确代码。

现在,我想,你可以猜到接下来会发生什么。当然,是 enum Command!这次,我们在前进到命令实现的过程中来完成它:

use std::io;
use std::str;

#[derive(Clone, Copy, Debug)]
enum Command {
    Auth,
    Unknown(String),
}

impl AsRef<str> for Command {
    fn as_ref(&self) -> &str {
        match *self {
            Command::Auth => "AUTH",
            Command::Unknown(_) => "UNKN",
        }
    }
}

impl Command {
    pub fn new(input: Vec<u8>) -> io::Result<Self> {
        let mut iter = input.split(|&byte| byte == b' ');
        let mut command = iter.next().expect("command in 
         input").to_vec();
        to_uppercase(&mut command);
        let data = iter.next();
        let command =
            match command.as_slice() {
             b"AUTH" => Command::Auth,
             s => Command::Unknown(str::from_utf8(s).unwrap_or("").to_owned()),
            };
        Ok(command)
    }
}

好的,让我们通过这段代码:

enum Command {
    Auth,
    Unknown(String),
}

每次我们添加一个新的命令处理,我们都需要在这个 enum 中添加一个新的变体。如果命令不存在(或者我们还没有实现它),将返回带有命令名称的 Unknown。如果命令接受参数,它将被添加,就像我们为 Unknown 所见的那样。以 Cwd 为例:

enum Command {
    Auth,
    Cwd(PathBuf),
    Unknown(String),
}

如你所见,Cwd 包含一个 PathBufCwd 代表 更改工作目录,并接受客户端想要进入的目录路径。

当然,你需要通过在 match 块中添加以下行来更新 as_ref

Command::Cwd(_) => "CWD",

你还需要通过在 match 块中添加以下行来更新 new 方法的实现:

b"CWD" => Command::Cwd(data.map(|bytes| Path::new(str::from_utf8(bytes).unwrap()).to_path_buf()).unwrap()),

现在我们来解释 AsRef 特性的实现。当你想编写一个泛型函数时,这非常方便。看看下面的例子:

fn foo<S: AsRef<str>>(f: S) {
    println!("{}", f.as_ref());
}

多亏了这个特性,只要类型实现了它,我们就可以调用 as_ref。在我们的情况下,当向客户端发送消息时,这非常有用,因为我们只需取一个实现了 AsRef 的类型。

现在让我们来谈谈 Command 类型的 new 方法:

pub fn new(input: Vec<u8>) -> io::Result<Self> {
    let mut iter = input.split(|&byte| byte == b' ');
    let mut command = iter.next().expect("command in input").to_vec();
    to_uppercase(&mut command);
    let data = iter.next();
    let command =
        match command.as_slice() {
         b"AUTH" => Command::Auth,
         s => Command::Unknown(str::from_utf8(s).unwrap_or("").to_owned()),
        };
    Ok(command)
}

这里的目的是将客户端接收到的消息进行转换。我们需要做两件事:

  • 获取命令

  • 获取命令的参数(如果有)

首先,我们创建一个迭代器来分割我们的向量,这样我们就可以将命令与参数分开:

let mut iter = input.split(|&byte| byte == b' ');

然后,我们获取命令:

let mut command = iter.next().expect("command in input").to_vec();

在这个阶段,command 是一个 Vec<u8>。为了使匹配更简单(因为 FTP 的 RFC 中没有提到命令应该大写,也没有提到 authAUTHAuTh 相同),我们调用 uppercase 函数,其代码如下:

fn to_uppercase(data: &mut [u8]) {
    for byte in data {
        if *byte >= 'a' as u8 && *byte <= 'z' as u8 {
            *byte -= 32;
        }
    }
}

接下来,我们通过在迭代器 iter 上调用 next 来获取参数:

let data = iter.next();

如果没有参数,没问题!我们只需获取 None

最后,我们匹配命令:

match command.as_slice() {
    b"AUTH" => Command::Auth,
    s => Command::Unknown(str::from_utf8(s).unwrap_or("").to_owned()),
}

要做到这一点,我们将我们的 Vec<u8> 转换为 &[u8](一个 u8 的切片)。为了将 &str(如 AUTH)也转换为 &[u8],我们使用 b 操作符(这更像是告诉编译器,“嘿!别担心,就把它说成是一个切片,而不是 &str!”)以允许匹配。

好的!我们现在可以编写从客户端实际读取数据的函数了:

fn read_all_message(stream: &mut TcpStream) -> Vec<u8> {
    let buf = &mut [0; 1];
    let mut out = Vec::with_capacity(100);

    loop {
        match stream.read(buf) {
            Ok(received) if received > 0 => {
                if out.is_empty() && buf[0] == b' ' {
                    continue
                }
                out.push(buf[0]);
            }
            _ => return Vec::new(),
        }
        let len = out.len();
        if len > 1 && out[len - 2] == b'\r' && out[len - 1] == 
         b'\n' {
            out.pop();
            out.pop();
            return out;
        }
    }
}

在这里,我们一次读取一个字节(这不是一个非常有效的方法;我们稍后会回到这个函数上)并在我们得到 "" 时返回。我们通过删除任何可能出现在命令之前的空白字符增加了一点点 安全性(只要我们的向量中没有数据,我们就不会添加任何空白字符)。

如果有任何错误,我们返回一个空向量并停止读取客户端输入。

就像我之前说的那样,逐字节读取并不高效,但更容易展示它是如何工作的。所以,现在,让我们坚持这个。一旦异步编程开始,这将被完全不同地完成。

现在,既然我们可以读取和写入 FTP 输入,是时候真正开始实现命令了!

让我们先创建一个新的结构:

#[allow(dead_code)]
struct Client {
    cwd: PathBuf,
    stream: TcpStream,
    name: Option<String>,
}

下面是对前面代码的一些简要说明:

  • cwd 代表当前工作目录

  • stream 是客户端的套接字

  • name 是从用户认证中获得的用户名(实际上这并不重要,因为我们不会在第一步处理认证)

现在是时候更新 handle_client 函数了:

fn handle_client(mut stream: TcpStream) {
    println!("new client connected!");
    send_cmd(&mut stream, ResultCode::ServiceReadyForNewUser, "Welcome to this FTP 
    server!");
    let client = Client::new(stream);
    loop {
        let data = read_all_message(&mut client.stream);
        if data.is_empty() {
            println!("client disconnected...");
            break;
        }
        client.handle_cmd(command::new(data));
    }
}

当一个新客户端连接到服务器时,我们向他们发送一条消息,告知服务器已准备好。然后我们创建一个新的 Client 实例,监听客户端套接字,并处理其命令。简单,对吧?

这段代码还缺少两个东西:

  • Client::new 方法

  • Client::handle_cmd 方法

让我们从第一个开始:

impl Client {
    fn new(stream: TcpStream) -> Client {
        Client {
            cwd: PathBuf::from("/"),
            stream: stream,
            name: None,
        }
    }
}

这里没有什么特别的地方;当前路径是 "/"(它对应于服务器的根目录,而不是文件系统的根目录!)我们设置了客户端的流,而名称尚未定义。

现在让我们看看 Client::handle_cmd 方法(不用说,这将是这个 FTP 服务器的核心):

fn handle_cmd(&mut self, cmd: Command) {
    println!("====> {:?}", cmd);
    match cmd {
        Command::Auth => send_cmd(&mut self.stream, 
        ResultCode::CommandNotImplemented,
                                  "Not implemented"),
        Command::Unknown(s) => send_cmd(&mut self.stream, 
         ResultCode::UnknownCommand,
                                        "Not implemented"),
    }
}

就这样!好吧,这还不是真正的 it。我们还有很多要添加。但我的观点是,我们现在只需要在这里添加其他命令,让一切都能正常工作。

命令实现

在之前的代码中,我们只处理了一个命令;任何其他命令都会从服务器收到一个 unknown command 的回答。此外,我们的 Auth 实现表示它尚未实现。所以,总结一下,我们处理了一个回答它尚未实现的命令。疯狂,对吧?对于 Auth 命令,我们稍后会看看。

现在,让我们真正实现一些命令。让我们从一个简单的命令开始:Syst。这个命令本应返回这个 FTP 服务器正在运行的系统。出于某种原因,我们不会回答这个问题,我们只会发送一个没有用处的回答。

实现 SYST 命令

首先,让我们在 Command 枚举中添加一个新的条目(我不会每次都这样做,但步骤将是相同的):

enum Command {
    Auth,
    Syst,
    Unknown(String),
}

然后,让我们更新 as_ref 实现:

impl AsRef<str> for Command {
    fn as_ref(&self) -> &str {
        match *self {
            Command::Auth => "AUTH",
            Command::Syst => "SYST",
            Command::Unknown(_) => "UNKN",
        }
    }
}

最后,让我们更新 Command::new 方法:

impl Command {
    pub fn new(input: Vec<u8>) -> io::Result<Self> {
        let mut iter = input.split(|&byte| byte == b' ');
        let mut command = iter.next().expect("command in 
         input").to_vec();
        to_uppercase(&mut command);
        let data = iter.next();
        let command =
            match command.as_slice() {
                b"AUTH" => Command::Auth,
                b"SYST" => Command::Syst,
                s => 
                Command::Unknown(str::from_utf8(s).unwrap_or("").to_owned()),
            };
        Ok(command)
    }
}

就这样!就像我之前说的那样,每次添加新命令时,只要记住这三个步骤,一切应该都会顺利。

现在,让我们实现这个命令:

fn handle_cmd(&mut self, cmd: Command) {
    println!("====> {:?}", cmd);
    match cmd {
        Command::Auth => send_cmd(&mut self.stream, 
        ResultCode::CommandNotImplemented,
                                  "Not implemented"),
        Command::Syst => send_cmd(&mut self.stream, ResultCode::Ok, "I won't tell"),
        Command::Unknown(s) => send_cmd(&mut self.stream, 
        ResultCode::UnknownCommand,
                                        "Not implemented"),
    }
}

就这样!我们实现了一个新的命令(它并不做什么,但这不是重点)!

实现 USER 命令

由于我们在Client结构中有name,那么让它有点用会很好,对吧?所以,正如标题所说,让我们实现USER命令。因为这个命令接受一个参数,所以我将再次通过命令实现步骤,这样你就有了一个接受参数的命令的例子。

首先,让我们更新enum Command

enum Command {
    Auth,
    Syst,
    User(String),
    Unknown(String),
}

然后,我们更新as_ref实现:

impl AsRef<str> for Command {
    fn as_ref(&self) -> &str {
        match *self {
            Command::Auth => "AUTH",
            Command::Syst => "SYST",
            Command::User => "USER",
            Command::Unknown(_) => "UNKN",
        }
    }
}

最后,我们更新Command::new方法:

impl Command {
    pub fn new(input: Vec<u8>) -> io::Result<Self> {
        let mut iter = input.split(|&byte| byte == b' ');
        let mut command = iter.next().expect("command in input").to_vec();
        to_uppercase(&mut command);
        let data = iter.next();
        let command =
            match command.as_slice() {
                b"AUTH" => Command::Auth,
                b"SYST" => Command::Syst,
                b"USER" => Command::User(data.map(|bytes| 
                String::from_utf8(bytes.to_vec()).expect("cannot
                 convert bytes to String")).unwrap_or_default()),
                s => Command::Unknown(str::from_utf8(s).unwrap_or("").to_owned()),
            };
        Ok(command)
    }
}

呼呼,一切都完成了!现在我们只需要实现这个函数(我保证它很简单):

fn handle_cmd(&mut self, cmd: Command) {
    println!("====> {:?}", cmd);
    match cmd {
        Command::Auth => send_cmd(&mut self.stream, 
         ResultCode::CommandNotImplemented,
                                  "Not implemented"),
        Command::Syst => send_cmd(&mut self.stream, ResultCode::Ok, 
        "I won't tell"),
        Command::User(username) => {
            if username.is_empty() {
                send_cmd(&mut self.stream, ResultCode::InvalidParameterOrArgument,
                         "Invalid username")
            } else {
                self.name = username.to_owned();
                send_cmd(&mut self.stream, ResultCode::UserLoggedIn,
                         &format!("Welcome {}!", username)),
            }
        }
        Command::Unknown(s) => send_cmd(&mut self.stream,  
        ResultCode::UnknownCommand,
                                        "Not implemented"),
    }
}

这里有一个简单的解释,以防你需要;如果我们收到一个空的用户名(或者完全没有用户名),我们将其视为无效参数并返回InvalidParameterOrArgument。否则,一切正常,我们返回UserLoggedIn

如果你想知道为什么我们没有返回ResultCode::Ok,那是因为 RFC 是这样规定的。再次强调,每个命令、它做什么以及它应该返回什么都在那里描述。如果你感到困惑,不要犹豫,再读一遍!

实现 NOOP 命令

这个主题相当简单。NOOP代表无操作。它不接受任何参数也不做任何事情。因为我是个好人,所以这里提供了Client::handle_cmd方法中NOOP命令的代码:

Command::NoOp => send_cmd(&mut self.stream, ResultCode::Ok, "Doing nothing..."),

是的,我知道,你被这样的代码惊呆了。但别担心,当你长大的时候,你也能写出这样好的代码!

现在是时候实现下一个命令了!

实现 PWD 命令

这个命令也很简单。PWD代表打印工作目录。再一次,它不是来自你的系统,而是来自你的服务器(所以,"/"对应于你启动服务器时的文件夹)。

这个命令不接受任何参数,所以没有必要再次展示所有内容。让我们只关注命令处理:

Command::Pwd => {
    let msg = format!("{}", self.cwd.to_str().unwrap_or(""));
    if !msg.is_empty() {
        let message = format!("\"/{}\" ", msg);
        send_cmd(&mut self.stream, ResultCode::PATHNAMECreated,
         &format!("\"/{}\" ", 
         msg))
    } else {
        send_cmd(&mut self.stream, ResultCode::FileNotFound, "No 
         such file or directory")
    }
}

没有什么复杂的;我们尝试显示路径,如果失败,我们返回一个错误。唯一奇怪的是,如果一切顺利,我们必须返回PATHNAMECreated。这个 RFC 真的很奇怪...

对不起,这是最后一个简单的命令。现在我们将更深入地探讨 FTP 及其奇怪的 RFC。接下来的命令是对接下来要发生的事情的一个很好的介绍。(我希望我没有吓到你!)

实现 TYPE 命令

目前,我们将实现一个不做什么的TYPE命令。我们将在接下来的章节中回到它。然而,一些解释可能会很有用,我想。

TYPE代表表示类型。当你通过数据连接(与命令连接不同,我们直到现在只使用命令连接)传输数据时,你可以以不同的方式传输数据。

默认情况下,传输类型是 ASCII(主要区别在于所有的""都必须转换为"")。我们将使用图像类型(其中你发送数据就像你拥有它一样)来简化我们的工作。

再次,我们将在后面的章节中回到这个实现。

现在,让我们只添加一个不接受任何参数的Type命令:

Command::Type => send_cmd(&mut self.stream, ResultCode::Ok, "Transfer type changed successfully"),

好吧,我们有点撒谎,但我们现在不得不处理它。

我们几乎完成了基础知识,但在你能够尝试使用 FTP 客户端访问服务器之前,还有一个命令需要实现。

实现 LIST 命令

LIST 命令返回当前文件夹或给定参数路径下的当前文件和文件夹列表。这本身就已经非常困难,因为你需要检查用户是否有权访问最终路径(例如,如果你在"/"时收到foo/../../,那么会有问题)。但这还不是全部!当你传输文件和文件夹列表时,没有官方的方式来格式化它!有趣,对吧?幸运的是,大多数 FTP 客户端都会遵循某种非官方的 RFC 来处理这种情况,我们将使用它。

除了所有这些之外,这个命令是我们将实现的第一条使用数据连接的命令。这需要你添加另一个命令:PASV

实现 PASV 命令

为了使这个命令能够工作,我们需要在我们的Client结构体中添加一些新的字段:

struct Client {
    cwd: PathBuf,
    stream: TcpStream,
    name: Option<String>,
    data_writer: Option<TcpStream>,
}

我们现在需要更新Client::new方法:

fn new(stream: TcpStream) -> Client {
    Client {
        cwd: PathBuf::from("/"),
        stream: stream,
        name: None,
        data_writer: None,
    }
}

PASV命令不接受任何参数,所以我会让你把它添加到结构和所有内容中。让我们关注有趣的部分:

// Adding some new imports:
use std::net::{IpAddr, Ipv4Addr, SocketAddr};

Command::Pasv => {
    if self.data_writer.is_some() {
        send_cmd(&mut self.stream, ResultCode::DataConnectionAlreadyOpen, "Already 
        listening...")
    } else {
        let port = 43210;
        send_cmd(&mut self.stream, ResultCode::EnteringPassiveMode,
           &format!("127,0,0,1,{},{}", port >> 8, port & 0xFF));
        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0,
         0, 1)), port);
        let listener = TcpListener::bind(&addr).unwrap();
        match listener.incoming().next() {
            Some(Ok(client)) => {
                self.data_writer = Some(client);
            }
            _ => {
                send_cmd(&mut self.stream, ResultCode::ServiceNotAvailable, "issues  
                happen...");
            }
        }
    }
}

呼……让我们解释一下这一切:

if self.data_writer.is_some() {
    send_cmd(&mut self.stream, ResultCode::DataConnectionAlreadyOpen, "Already listening...")
}

如果我们与这个客户端已经有了数据连接,就没有必要打开一个新的,所以我们不做任何事情:

let port: u16 = 43210;
send_cmd(&mut self.stream, ResultCode::EnteringPassiveMode,
         &format!("127,0,0,1,{},{}", port >> 8, port & 0xFF));

这部分有点棘手。首先,我们选择一个端口(最好的方式是先检查端口是否可用;我们将在后面的章节中这样做)。然后,我们必须告诉客户端它应该连接到哪

这里事情变得有点复杂。我们必须按照以下方式传输地址:

ip1,ip2,ip3,ip4,port1,port2

每个ip部分都必须是 8 位长(所以是 1 字节长),而每个port部分都必须是 16 位长(所以是 2 字节)。第一部分很简单;我们只需要打印 localhost。然而,第二部分需要你执行一些二进制操作。

只获取第一个字节很简单;我们只需要将 8 位向右移动。总结一下,看看这个:

1010 1010 1111 1111

这是我们的u16。我们现在将 8 位向右移动:

0000 0000 1010 1010

哇!

对于第二部分,我们可以将 8 位向左移动然后向右移动 8 位,或者我们可以直接使用and二进制运算符。这里有一个小方案来解释这一点:

1 & 1 == 1
1 & 0 == 0

现在,让我们使用一个漂亮的二进制到十六进制的转换器来检查结果:

0000 0000 1111 1111 == 0xFF

现在我们执行这个操作,会得到以下结果:

1111 1111 1010 1010 & 0xFF
=>
0000 0000 1010 1010

现在,我们只有最后 8 位。太好了!命令处理的最后一部分非常简单:

let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port);
let listener = TcpListener::bind(&addr).unwrap();
match listener.incoming().next() {
    Some(Ok(client)) => {
        self.data_writer = Some(client);
    }
    _ => {
        send_cmd(&mut self.stream, ResultCode::ServiceNotAvailable, "issues 
        happen...");
    }
}

我们绑定地址和端口,等待客户端连接,然后将它分配给我们的数据写入器。这里没有问题。

回到LIST命令

现在我们能够处理数据连接了,让我们来实现LIST命令!目前,我们先不添加参数来实现它(就像之前一样,我们将在后面的章节中看到如何处理LIST参数)。像往常一样,我会让你在需要的地方添加所有内容,我们只关注命令处理:

Command::List => {
    if let Some(ref mut data_writer) = self.data_writer {
        let mut tmp = PathBuf::from(".");
        send_cmd(&mut self.stream, ResultCode::DataConnectionAlreadyOpen,
                 "Starting to list directory...");
        let mut out = String::new();
        for entry in read_dir(tmp).unwrap() {
            for entry in dir {
                if let Ok(entry) = entry {
                    add_file_info(entry.path(), &mut out);
                }
            }
            send_data(data_writer, &out)
        }
    } else {
        send_cmd(&mut self.stream, ResultCode::ConnectionClosed, 
         "No opened data connection");
    }
    if self.data_writer.is_some() {
        self.data_writer = None;
        send_cmd(&mut self.stream, ResultCode::ClosingDataConnection, "Transfer 
        done");
    }
}

这里也没有什么复杂的。一旦传输完成,我们就关闭客户端套接字并继续。需要添加的是send_dataadd_file_info函数。让我们从第一个开始:

fn send_data(stream: &mut TcpStream, s: &str) {
    write!(stream, "{}", s).unwrap();
}

很简单,没有错误处理,所以它只占一行。现在让我们看看add_file_info函数:

fn add_file_info(path: PathBuf, out: &mut String) {
    let extra = if path.is_dir() { "/" } else { "" };
    let is_dir = if path.is_dir() { "d" } else { "-" };

    let meta = match ::std::fs::metadata(&path) {
        Ok(meta) => meta,
        _ => return,
    };
    let (time, file_size) = get_file_info(&meta);
    let path = match path.to_str() {
        Some(path) => match path.split("/").last() {
            Some(path) => path,
            _ => return,
        },
        _ => return,
    };
    let rights = if meta.permissions().readonly() {
        "r--r--r--"
    } else {
        "rw-rw-rw-"
    };
    let file_str = format!("{is_dir}{rights} {links} {owner} {group} {size} {month} 
    {day} {hour}:{min} {path}{extra}\r\n",
                           is_dir=is_dir,
                           rights=rights,
                           links=1, // number of links
                           owner="anonymous", // owner name
                           group="anonymous", // group name
                           size=file_size,
                           month=MONTHS[time.tm_mon as usize],
                           day=time.tm_mday,
                           hour=time.tm_hour,
                           min=time.tm_min,
                           path=path,
                           extra=extra);
    out.push_str(&file_str);
    println!("==> {:?}", &file_str);
}

要使这段代码工作,你还需要以下内容:

#[macro_use]
extern crate cfg_if;

cfg_if! {
    if #[cfg(windows)] {
        fn get_file_info(meta: &Metadata) -> (time::Tm, u64) {
            use std::os::windows::prelude::*;
            (time::at(time::Timespec::new(meta.last_write_time())), 
             meta.file_size())
        }
    } else {
        fn get_file_info(meta: &Metadata) -> (time::Tm, u64) {
            use std::os::unix::prelude::*;
            (time::at(time::Timespec::new(meta.mtime(), 0)),
             meta.size())
        }
    }
}

不要忘记在你的Cargo.toml中添加cfg_if

cfg-if = "0.1.2"

cfg-if非常擅长以更易于阅读的方式帮助你进行条件编译。现在要注意关于get_file_info函数的一个点——这是在所有系统上都不能以相同方式执行的一件稀少的事情。

这里,Windows 有自己的版本,Unix 有另一个。然而,这两个函数接受相同的参数(导入),一个函数调用就改变了。现在让我们回到add_file_info函数:

我想你已经认出了ls命令的输出,对吧?显然,非官方的 RFC 是这样工作的:

dr--r--r-- 1 me me 1024 Jan 7 12:42 foo/
-rw-rw-rw- 1 me me 4 Mar 3 23:42 some_file

首先,如果是目录则用d表示,如果不是则用-表示。然后是权限(就像在 Unix 平台上一样):

[rwx][rwx][rwx]

第一个rwx是关于所有者的,第二个是关于组的,最后一个是关于所有人的。在这里,r代表读取权限,w代表写入权限,x代表执行权限。

其余的部分似乎已经很明确了,所以没有必要解释它。

实现CWD命令

CWD命令允许用户更改其当前文件夹位置。然而,这并不容易做到。

在进入这个命令的实现之前,我们需要讨论一个潜在的安全问题:路径。

想象一下用户在"/"位置(这相当于,比如说,/home/someone/somewhere)并请求foo/../../。如果我们只是接受路径并将用户移动到这个位置,它将结束在/home/someone。这意味着用户可以无障碍地访问你的电脑的所有内容。你现在看到问题了吗?

幸运的是,RustPath上有一个很好的方法,允许我们修复这个巨大的安全问题。我说的就是Path::canonicalize(它是fs::canonicalize函数的别名)。

那么,这个函数做什么呢?让我们举一个例子:

let path = Path::new("/foo/test/../bar.rs");
assert_eq!(path.canonicalize().unwrap(), PathBuf::from("/foo/bar.rs"));

如你所见,它解释了路径,规范化了一切(..移除了文件夹组件),并且还解析了符号链接。相当神奇,对吧?

当然,所有美好的事物都有其缺点,canonicalize也不例外:它只能作用于真实路径。如果路径的一部分不存在,函数将直接失败。当你了解这一点时,处理起来很简单,但一开始可能会感到惊讶。

那么,我们如何解决这个问题呢?嗯,我们需要处理一个真实路径。所以首先,我们需要将用户的服务器路径追加到真实服务器路径(它在计算机上的那个路径)。一旦完成,我们只需追加用户请求的路径并调用canonicalize

这并不复杂,但一开始操作起来有点烦人。不过,别担心,代码马上就来!

如果你想知道为什么我们不直接使用chroot函数(这将解决所有问题),请记住,这个 FTP 服务器应该在每个平台上都能工作。

所以首先,让我们向enum Command添加一个新的命令条目:

Cwd(PathBuf),

好的,现在让我们将其添加到Command::new方法的匹配中:

b"CWD" => Command::Cwd(data.map(|bytes| Path::new(str::from_utf8(bytes).unwrap()).to_path_buf()).unwrap()),

完美!我将让你也把它添加到AsRef实现中。现在是我们进入真正实现的时候了:

Command::Cwd(directory) => self.cwd(directory),

一次,为了使我们的工作更简单,我们将在我们的Client中创建一个新的方法,这样CWD命令的所有代码就不会填充enum

fn complete_path(&self, path: PathBuf, server_root: &PathBuf) -> Result<PathBuf, io::Error> {
    let directory = server_root.join(if path.has_root() {
        path.iter().skip(1).collect()
    } else {
        path
    });
    let dir = directory.canonicalize();
    if let Ok(ref dir) = dir {
        if !dir.starts_with(&server_root) {
            return Err(io::ErrorKind::PermissionDenied.into());
        }
    }
    dir
}

fn cwd(mut self, directory: PathBuf) {
    let server_root = env::current_dir().unwrap();
    let path = self.cwd.join(&directory);
    if let Ok(dir) = self.complete_path(path, &server_root) {
        if let Ok(prefix) = dir.strip_prefix(&server_root)
                               .map(|p| p.to_path_buf()) {
            self.cwd = prefix.to_path_buf();
            send_cmd(&mut self.stream, ResultCode::Ok,
                     &format!("Directory changed to \"{}\"", directory.display()));
            return
        }
    }
    send_cmd(&mut self.stream, ResultCode::FileNotFound, "No such file or directory");
}

好吧,代码有点多。现在让我们看看执行流程:

let server_root = env::current_dir().unwrap();

目前,你无法设置服务器运行在哪个文件夹;这将在以后更改:

let path = self.cwd.join(&directory);

首先,我们将请求的目录与用户的当前目录连接起来:

if let Ok(dir) = self.complete_path(path, &server_root) {

这里开始变得有趣。整个规范化过程都在这里。

现在让我们将用户路径追加到(真实)服务器路径上:

let directory = server_root.join(if path.has_root() {
    path.iter().skip(1).collect()
} else {
    path
});

所以,如果路径是绝对路径(在 Unix 中以"/"开头或在 Windows 中以如c:这样的前缀开头),我们需要移除路径的第一个组件,否则我们只需追加它。

我们现在有一个完整且可能存在的路径。让我们将其规范化:

let dir = directory.canonicalize();

现在我们还有一件事要检查——如果路径不以服务器根目录开头,那么这意味着用户试图欺骗我们,试图访问不可访问的文件夹。下面是我们的做法:

if let Ok(ref dir) = dir {
    if !dir.starts_with(&server_root) {
        return Err(io::ErrorKind::PermissionDenied.into());
    }
}

如果canonicalize返回了错误,没有必要检查它是否已经是一个错误(因为它已经是一个错误了)。如果它成功了但不是以server_root开头,那么我们返回一个错误。

这个函数就到这里。现在,我们将结果返回给调用者,并可以回到cwd方法:

if let Ok(dir) = self.complete_path(path, &server_root) {
    if let Ok(prefix) = dir.strip_prefix(&server_root)
                           .map(|p| p.to_path_buf()) {
        // ...
    }
}

一旦我们获得了完整的目录路径并确认它是正确的,我们需要移除server_root前缀以从我们的服务器根目录获取路径:

self.cwd = prefix.to_path_buf();
send_cmd(&mut self.stream, ResultCode::Ok,
         &format!("Directory changed to \"{}\"", directory.display()));
return

最后,一旦完成这些,我们只需将路径设置给用户,并发送一条消息表示命令成功(并返回以避免发送失败的消息!)。

如果有任何问题发生,我们发送以下内容:

send_cmd(&mut self.stream, ResultCode::FileNotFound, "No such file or directory");

这个命令就到这里了!你现在知道如何通过检查客户端提供的接收路径来避免安全问题。

实现 CDUP 命令

CDUP用于进入父目录。与CWD命令实现相比,这将是一件轻而易举的事情!CDUP命令不需要参数,所以我会让你将它添加到enums中。现在,让我们专注于命令实现:

Command::CdUp => {
    if let Some(path) = self.cwd.parent().map(Path::to_path_buf) {
        self.cwd = path;
    }
    send_cmd(&mut self.stream, ResultCode::Ok, "Done");
}

就这样。没有必要检查父文件夹是否存在,因为它确实需要。如果我们已经在根目录,那么就没有必要做任何事情。这不是很棒吗?

LIST 命令的完整实现

现在我们已经知道如何优雅地处理路径,不完整实现LIST命令真是太遗憾了,对吧?

为了完成它,你需要更新Command::List变体,以便它能够接受PathBuf作为参数。

所以,我们现在有以下代码:

Command::List => {
    if let Some(ref mut data_writer) = self.data_writer {
        let mut tmp = PathBuf::from(".");
        send_cmd(&mut self.stream, ResultCode::DataConnectionAlreadyOpen,
                 "Starting to list directory...");
        let mut out = String::new();
        for entry in read_dir(tmp).unwrap() {
            for entry in dir {
                if let Ok(entry) = entry {
                    add_file_info(entry.path(), &mut out);
                }
            }
            send_data(data_writer, &out)
        }
    } else {
        send_cmd(&mut self.stream, ResultCode::ConnectionClosed, "No opened data  
         connection");
    }
    if self.data_writer.is_some() {
        self.data_writer = None;
        send_cmd(&mut self.stream, ResultCode::ClosingDataConnection, "Transfer 
         done");
    }
}

让我们按照以下方式更新它:

Command::List(path) => {
    if let Some(ref mut data_writer) = self.data_writer {
        let server_root = env::current_dir().unwrap();
        let path = self.cwd.join(path.unwrap_or_default());
        let directory = PathBuf::from(&path);
        if let Ok(path) = self.complete_path(directory, 
         &server_root) {
            send_cmd(&mut self.stream, 
             ResultCode::DataConnectionAlreadyOpen,
             "Starting to list directory...");
            let mut out = String::new();
            for entry in read_dir(path).unwrap() {
                for entry in dir {
                    if let Ok(entry) = entry {
                        add_file_info(entry.path(), &mut out);
                    }
                }
                send_data(data_writer, &out)
            }
        } else {
            send_cmd(&mut self.stream, ResultCode::InvalidParameterOrArgument,
                     "No such file or directory...");
        }
    } else {
        send_cmd(&mut self.stream, ResultCode::ConnectionClosed, 
        "No opened data connection");
    }
    if self.data_writer.is_some() {
        self.data_writer = None;
        send_cmd(&mut self.stream,
         ResultCode::ClosingDataConnection, "Transfer done");
    }
}

简单来说,我们只是添加了以下这一行:

let path = self.cwd.join(path.unwrap_or_default());
let directory = PathBuf::from(&path);
if let Ok(path) = self.complete_path(directory, &server_root) {
    // ...
} else {
    send_cmd(&mut self.stream, ResultCode::InvalidParameterOrArgument,
             "No such file or directory...");
}

多亏了Client::complete_path方法,路径操作变得相当简单。那么,如果给定的路径是一个文件会发生什么呢?我们没有检查这种情况,但我们应该检查!让我们替换以下这些行:

for entry in read_dir(path).unwrap() {
    for entry in dir {
        if let Ok(entry) = entry {
            add_file_info(entry.path(), &mut out);
        }
    }
    send_data(data_writer, &out)
}

使用:

if path.is_dir() {
    for entry in read_dir(path).unwrap() {
        for entry in dir {
            if let Ok(entry) = entry {
                add_file_info(entry.path(), &mut out);
            }
        }
        send_data(data_writer, &out)
    }
} else {
    add_file_info(path, &mut out);
}

就这样!幸运的是,我们第一次就做对了,所以它就是那么简单有效

实现 MKD 命令

MKD代表创建目录(是的,确实就像 Unix 命令一样,但更短)。就像LISTCWD一样,它需要一个PathBuf作为参数。我会让你像往常一样处理其他添加,并专注于命令实现:

Command::Mkd(path) => self.mkd(path),

就像上次一样,我们将创建一个新的方法:

use std::fs::create_dir;

fn mkd(&self, path: PathBuf) {
    let server_root = env::current_dir().unwrap();
    let path = self.cwd.join(&path);
    if let Some(parent) = path.parent().map(|p| p.to_path_buf()) {
        if let Ok(mut dir) = self.complete_path(parent,
         &server_root) {
            if dir.is_dir() {
                if let Some(filename) = path.file_name().map(|p| 
                 p.to_os_string()) {
                    dir.push(filename);
                    if create_dir(dir).is_ok() {
                        send_cmd(&mut self.stream,
                         ResultCode::PATHNAMECreated,
                          "Folder successfully created!");
                        return
                    }
                }
            }
        }
    }
    send_cmd(&mut self.stream, ResultCode::FileNotFound,
             "Couldn't create folder");
}

再次,在真正尝试创建目录之前,还有一些事情要做。

首先,我们需要检查给定路径的所有元素是否都是文件夹(实际上,只有最后一个元素是,否则Client::complete_path方法将失败)。

然后,我们需要再次,通过调用Client::complete_path方法来规范化这个路径。最后,我们将文件名推送到接收到的路径。

这里的主要区别是我们没有从Client::complete_path返回的路径中去除server_root路径。

一旦完成所有这些,我们就可以尝试使用create_dir函数来创建文件夹。如果一切顺利,我们就返回ResultCode::PATHNAMECreated(而且这一次它确实有道理!)。

如果在任何级别发生错误,我们只需发送路径不正确的信息。

这个命令就到这里了!

实现 RMD 命令

现在我们能够创建文件夹了,能够删除它们会更好,对吧?这就是RMD(代表删除目录)应该做的!

就像MKD(和其他命令)一样,RMD需要一个PathBuf作为参数。再一次,像往常一样,我会让你处理Command部分,这样我们就可以专注于命令实现:

Command::Rmd(path) => self.rmd(path),

是的,这又是一个新方法。我想这已经变成了一种习惯?

use std::fs::remove_dir_all;

fn rmd(&self, path: PathBuf) {
    let server_root = env::current_dir().unwrap();
    if let Ok(path) = self.complete_path(path, &server_root) {
        if remove_dir_all(path).is_ok() {
            send_cmd(&mut self.stream, 
                     ResultCode::RequestedFileActionOkay,
                     "Folder successfully removed!");
            return
        }
    }
    send_cmd(&mut self.stream, ResultCode::FileNotFound, 
      "Couldn't remove folder!");
}

就这样!这比MKD还要简单,因为我们不需要检查最后一个可能的父文件夹是否是文件夹。一旦我们确认路径是授权的,我们就可以直接删除它。

使用所有这些命令,我认为我们可以这样说,我们有一个非常好的基础来构建一个完整的 FTP 服务器。

测试它

现在,您有一个(非常)基本的 FTP 服务器实现。您可以连接到服务器并列出当前文件夹中的文件和文件夹。

使用 cargo run 启动它并尝试一下!我建议您使用 FileZilla。这是一个优秀的 FTP 客户端。在端口 1234 上连接到 localhost,并使用 anonymous 用户名(或无),你应该已经可以有点乐趣了:

图 8.1图 8.1

文件传输和附加命令的信息将在后续章节中介绍。

摘要

在本章中,我们探讨了 FTP 的基础知识。我们现在有一个简单的(同步)服务器实现,你应该对这一切是如何工作的有一个很好的了解。我们还探讨了一个潜在的安全问题以及如何修复它。

以下章节将向您介绍 Rust 中的异步编程。多亏了本章,FTP RFC 方面的学习将会更快,这样我们就可以专注于异步部分。

第九章:实现异步 FTP 服务器

在上一章中,我们编写了一个同步 FTP 服务器。现在,我们将使用tokio,Rust 的异步 IO(输入/输出)库,编写一个异步版本。我们将涵盖以下主题:

  • 异步服务器

  • 未来

  • Tokio

  • Async/await

  • 错误处理

异步 IO 的优点

异步 IO 允许我们在不等待其结果的情况下发送请求,我们将在稍后以某种方式收到响应时得到通知。这使得我们的程序更加并发,并且可以更好地扩展。

在上一章中,我们使用了线程来避免在等待响应时阻塞其他客户端。虽然使用线程有成本,除了线程需要更多内存的事实之外,它们还因为代码从一条线程切换到另一条线程时需要上下文切换而带来性能成本。

异步 IO 的缺点

然而,使用异步 IO 并非没有缺点。使用异步 IO 比使用同步 IO 更难。在使用异步 IO 的情况下,我们还需要一种方式来知道何时一个事件已经终止。因此,我们需要学习一种新的方式来管理 IO 事件,这将需要更多的时间来实现我们在上一章中编写的相同软件。

创建新项目

让我们像往常一样先创建一个新的二进制项目:

cargo new --bin ftp-server

我们将在Cargo.toml文件中添加以下依赖项:

[dependencies]
bytes = "⁰.4.5"
tokio-core = "⁰.1.10"
tokio-io = "⁰.1.3"

[dependencies.futures-await]
git = "https://github.com/alexcrichton/futures-await"

如您所见,我们通过 Git URL 指定了一个依赖项。这个依赖项使用仅限 nightly 的功能,所以请确保您正在使用 nightly 编译器,通过运行此命令来确保:

rustup default nightly

让我们从添加所需的extern crate语句开始我们的main模块:

#![feature(proc_macro, conservative_impl_trait, generators)]

extern crate bytes;
extern crate futures_await as futures;
extern crate tokio_core;
extern crate tokio_io;

如您所见,我们使用了一些 nightly 功能。这些功能是futures-awaitcrate 所需要的。我们还决定以另一个名称导入这个 crate,即futures,因为它导出了与futurescrate 本身相同的类型和函数。

我们将从上一章复制一些代码并将它们放入新的模块中,以获得更好的组织。以下是新模块:

mod cmd;
mod ftp;

在一个名为src/cmd.rs的新文件中,放置以下代码:

use std::path::{Path, PathBuf};
use std::str::{self, FromStr};

use error::{Error, Result};

#[derive(Clone, Debug)]
pub enum Command {
    Auth,
    Cwd(PathBuf),
    List(Option<PathBuf>),
    Mkd(PathBuf),
    NoOp,
    Port(u16),
    Pasv,
    Pwd,
    Quit,
    Retr(PathBuf),
    Rmd(PathBuf),
    Stor(PathBuf),
    Syst,
    Type(TransferType),
    CdUp,
    Unknown(String),
    User(String),
}

我们首先有一个表示不同命令及其参数的枚举:

impl AsRef<str> for Command {
    fn as_ref(&self) -> &str {
        match *self {
            Command::Auth => "AUTH",
            Command::Cwd(_) => "CWD",
            Command::List(_) => "LIST",
            Command::Pasv => "PASV",
            Command::Port(_) => "PORT",
            Command::Pwd => "PWD",
            Command::Quit => "QUIT",
            Command::Retr(_) => "RETR",
            Command::Stor(_) => "STOR",
            Command::Syst => "SYST",
            Command::Type(_) => "TYPE",
            Command::User(_) => "USER",
            Command::CdUp => "CDUP",
            Command::Mkd(_) => "MKD",
            Command::Rmd(_) => "RMD",
            Command::NoOp => "NOOP",
            Command::Unknown(_) => "UNKN", // doesn't exist
        }
    }
}

在这里,我们创建了一个获取命令字符串表示的方法:

impl Command {
    pub fn new(input: Vec<u8>) -> Result<Self> {
        let mut iter = input.split(|&byte| byte == b' ');
        let mut command = iter.next().ok_or_else(
         || Error::Msg("empty command".to_string()))?.to_vec();
        to_uppercase(&mut command);
        let data = iter.next().ok_or_else(|| Error::Msg("no command  
         parameter".to_string()));
        let command =
            match command.as_slice() {
                b"AUTH" => Command::Auth,
                b"CWD" => Command::Cwd(data.and_then(|bytes|  
                Ok(Path::new(str::from_utf8(bytes)?).to_path_buf()))?),
                b"LIST" => Command::List(data.and_then(|bytes|  
                 Ok(Path::new(str::from_utf8(bytes)?).to_path_buf())).ok()),
                b"PASV" => Command::Pasv,
                b"PORT" => {
                    let addr = data?.split(|&byte| byte == b',')
                        .filter_map(|bytes| 
                         str::from_utf8(bytes).ok()
                         .and_then(|string| u8::from_str(string).ok()))
                        .collect::<Vec<u8>>();
                    if addr.len() != 6 {
                        return Err("Invalid address/port".into());
                    }

                    let port = (addr[4] as u16) << 8 | (addr[5] as 
                     u16);
                    if port <= 1024 {
                        return Err("Port can't be less than
                      10025".into());
                    }
                    Command::Port(port)
                },
                b"PWD" => Command::Pwd,
                b"QUIT" => Command::Quit,
                b"RETR" => Command::Retr(data.and_then(|bytes|  
                Ok(Path::new(str::from_utf8(bytes)?).to_path_buf()))?),
                b"STOR" => Command::Stor(data.and_then(|bytes|   
                Ok(Path::new(str::from_utf8(bytes)?).to_path_buf()))?),
                b"SYST" => Command::Syst,
                b"TYPE" => {
                    match TransferType::from(data?[0]) {
                        TransferType::Unknown => return 
                         Err("command not implemented 
                        for that parameter".into()),
                        typ => {
                            Command::Type(typ)
                        },
                    }
                },
                b"CDUP" => Command::CdUp,
                b"MKD" => Command::Mkd(data.and_then(|bytes|  
                Ok(Path::new(str::from_utf8(bytes)?).to_path_buf()))?),
                b"RMD" => Command::Rmd(data.and_then(|bytes|  
                Ok(Path::new(str::from_utf8(bytes)?).to_path_buf()))?),
                b"USER" => Command::User(data.and_then(|bytes|  
                String::from_utf8(bytes.to_vec()).map_err(Into::into))?),
                b"NOOP" => Command::NoOp,
                s => 
                 Command::Unknown(str::from_utf8(s).unwrap_or("").to_owned()),
            };
        Ok(command)
    }
}

这个构造函数将字节字符串解析为Command。这需要一个将字节字符串转换为 uppercase 的函数:

fn to_uppercase(data: &mut [u8]) {
    for byte in data {
        if *byte >= 'a' as u8 && *byte <= 'z' as u8 {
            *byte -= 32;
        }
    }
}

我们只需将所有小写字母减去 32 来将它们转换为 uppercase:

#[derive(Clone, Copy, Debug)]
pub enum TransferType {
    Ascii,
    Image,
    Unknown,
}

impl From<u8> for TransferType {
    fn from(c: u8) -> TransferType {
        match c {
            b'A' => TransferType::Ascii,
            b'I' => TransferType::Image,
            _ => TransferType::Unknown,
        }
    }
}

在这里,我们有一个表示传输类型的枚举和一个将字节字符解析为该类型的函数。在另一个文件src/ftp.rs中,让我们写下以下内容:

pub struct Answer {
    pub code: ResultCode,
    pub message: String,
}

impl Answer {
    pub fn new(code: ResultCode, message: &str) -> Self {
        Answer {
            code,
            message: message.to_string(),
        }
    }
}

#[derive(Debug, Clone, Copy)]
#[repr(u32)]
#[allow(dead_code)]
pub enum ResultCode {
    RestartMarkerReply = 110,
    ServiceReadInXXXMinutes = 120,
    DataConnectionAlreadyOpen = 125,
    FileStatusOk = 150,
    Ok = 200,
    CommandNotImplementedSuperfluousAtThisSite = 202,
    SystemStatus = 211,
    DirectoryStatus = 212,
    FileStatus = 213,
    HelpMessage = 214,
    SystemType = 215,
    ServiceReadyForNewUser = 220,
    ServiceClosingControlConnection = 221,
    DataConnectionOpen = 225,
    ClosingDataConnection = 226,
    EnteringPassiveMode = 227,
    UserLoggedIn = 230,
    RequestedFileActionOkay = 250,
    PATHNAMECreated = 257,
    UserNameOkayNeedPassword = 331,
    NeedAccountForLogin = 332,
    RequestedFileActionPendingFurtherInformation = 350,
    ServiceNotAvailable = 421,
    CantOpenDataConnection = 425,
    ConnectionClosed = 426,
    FileBusy = 450,
    LocalErrorInProcessing = 451,
    InsufficientStorageSpace = 452,
    UnknownCommand = 500,
    InvalidParameterOrArgument = 501,
    CommandNotImplemented = 502,
    BadSequenceOfCommands = 503,
    CommandNotImplementedForThatParameter = 504,
    NotLoggedIn = 530,
    NeedAccountForStoringFiles = 532,
    FileNotFound = 550,
    PageTypeUnknown = 551,
    ExceededStorageAllocation = 552,
    FileNameNotAllowed = 553,
}

现在我们已经准备好开始着手处理 FTP 服务器本身了。

使用 Tokio

Tokio 基于较低级别的 crate mio,而 mio 本身直接基于系统调用,如epoll(Linux)、kqueue(FreeBSD)和 IOCP(Windows)。这个 crate 还基于futures crate,它提供了关于稍后可用的值(或多个值)进行推理的抽象。正如我告诉你的,在使用异步 I/O 时,调用不会阻塞,因此我们需要一种方式来知道读取的结果何时可用。这就是FutureStream两个来自futures crate 的抽象发挥作用的地方。

Tokio 事件循环

Tokio 还提供了一个事件循环,我们可以在其上执行一些代码(使用futures),当某些 I/O 事件发生时,例如当套接字读取的结果准备好时,这些代码将被执行。为此,事件循环将在表示套接字的具体文件描述符上注册事件。它使用上述系统调用注册这些事件,然后等待任何已注册的事件发生。文件描述符和系统调用是低级内容,我们不需要了解它们来使用tokio,但了解它在低级别上是如何工作的是很重要的。例如,epoll不支持常规文件,所以如果你尝试在常规文件上等待事件发生,即使我们使用异步 I/O,它也可能阻塞。

使用 future

一个future代表一个稍后可用的值,或者一个错误,类似于Result类型。一个stream代表多个值(或错误),这些值将在future中的不同时间可用,类似于Iterator<Result<T>>。这个 crate 提供了许多组合器,例如and_then()map()等,类似于在Result类型上可用的组合器。但是,我们不会使用它们,更喜欢稍后我们将看到的async/await语法。

处理错误

在我们开始编写 FTP 服务器代码之前,让我们谈谈我们将如何处理错误。

解包

在之前的项目中,我们大量使用了unwrap()expect()方法。这些方法对于快速原型设计很有用,但当我们想要编写高质量的软件时,我们应该在大多数情况下避免使用它们。由于我们正在编写一个 FTP 服务器,这是一个必须长时间运行的软件,我们不希望它因为调用unwrap()而崩溃,并且客户端发送了一个错误的命令。因此,我们将进行适当的错误处理。

自定义错误类型

由于我们可以得到不同类型的错误,并且我们希望跟踪所有这些错误,我们将创建一个自定义错误类型。让我们创建一个新的模块,我们将在这个模块中放置这个新类型:

mod error;

将其添加到src/error.rs文件中:

use std::io;
use std::str::Utf8Error;
use std::string::FromUtf8Error;

pub enum Error {
    FromUtf8(FromUtf8Error),
    Io(io::Error),
    Msg(String),
    Utf8(Utf8Error),
}

在这里,我们有一个枚举,表示我们 FTP 服务器可能发生的不同错误,需要实现。由于 FTP 是基于字符串的协议,所以存在 UTF-8 错误;由于我们通过网络进行通信,所以存在 I/O 错误,因为通信问题可能会发生。我们为来自标准库的错误类型创建了变体,这将在我们想要组合不同类型的错误时很有帮助。我们还创建了一个 Msg 变体来表示我们自己的错误,我们用 String 来表示它们,因为我们只想在终端中显示它们(例如,我们也可以将它们记录到 syslog 中)。

这是 Rust 中表示错误类型的标准方式。如果你的 crate 是一个库,创建此类类型是一种良好的实践,这样 crate 的用户可以确切地知道错误发生的原因。

显示错误

由于我们想要将错误打印到终端,我们将为我们的 Error 类型实现 Display 特性:

use std::fmt::{self, Display, Formatter};

use self::Error::*;

impl Display for Error {
    fn fmt(&self, formatter: &mut Formatter) -> fmt::Result {
        match *self {
            FromUtf8(ref error) => error.fmt(formatter),
            Io(ref error) => error.fmt(formatter),
            Utf8(ref error) => error.fmt(formatter),
            Msg(ref msg) => write!(formatter, "{}", msg),
        }
    }
}

对于我们包装来自其他类型的错误的三种情况,我们只需调用这些错误的相应 fmt() 方法。如果是 Msg,我们使用 write! 宏来写入字符串。这个宏与 print! 类似,但需要一个参数来指定格式化数据写入的位置。

在我们的情况下,这并不很有帮助,但建议也实现自定义错误类型的 Error 特性:

use std::error;

impl error::Error for Error {
    fn description(&self) -> &str {
        match *self {
            FromUtf8(ref error) => error.description(),
            Io(ref error) => error.description(),
            Utf8(ref error) => error.description(),
            Msg(ref msg) => msg,
        }
    }

    fn cause(&self) -> Option<&error::Error> {
        let cause: &error::Error =
            match *self {
                FromUtf8(ref error) => error,
                Io(ref error) => error,
                Utf8(ref error) => error,
                Msg(_) => return None,
            };
        Some(cause)
    }
}

这个特质的唯一必需方法是 description(),它返回错误的简短描述。同样,在三种情况下,我们只是从包装类型本身调用 description() 方法。对于我们的 Msg 变体,我们返回包装的消息。

可能我们从这个方法中返回的字符串不存在。如果是这种情况,我们只需返回 &'static str,如下所示:

Io(_) => "IO error",

cause() 方法是可选的,用于返回错误的起因。在这里,当变体中有内部错误时,我们返回内部错误;对于我们的 Msg 变体,则返回 None

Error 特性要求 Self 类型实现 DisplayDebug。我们之前实现了 Display,但还没有实现 Debug。让我们通过在类型声明前添加一个属性来修复这个问题:

#[derive(Debug)]
pub enum Error {
    FromUtf8(FromUtf8Error),
    Io(io::Error),
    Msg(String),
    Utf8(Utf8Error),
}

提供一个名为 Result 的类型别名,该别名专门针对我们的错误类型,这是一种良好的实践。让我们来写一个:

use std::result;

pub type Result<T> = result::Result<T, Error>;

通过这样做,我们隐藏了标准库中的原始 Result 类型。这就是为什么我们要指定这个类型的限定版本。否则,编译器会假设它是一个递归类型,但在这里并不是这样。当我们需要在其他模块中导入此类型时,我们必须小心,因为它隐藏了 Result 类型。如果我们想使用原始的 Result 类型,我们必须使用同样的技巧;限定它。

组合错误类型

为了在 Rust 中使用所有关于错误类型的良好实践,我们需要做的是使它们易于组合,因为,目前,如果我们有另一个错误类型,例如 io::Error,每次我们遇到另一个类型时,我们都需要使用以下代码:

let val =
    match result {
        Ok(val) => val,
        Err(error) => return Err(Error::Io(error)),
    };

这很快就会变得繁琐。为了改进这一点,我们将为不同的错误类型实现 From 特性:

impl From<io::Error> for Error {
    fn from(error: io::Error) -> Self {
        Io(error)
    }
}

impl<'a> From<&'a str> for Error {
    fn from(message: &'a str) -> Self {
        Msg(message.to_string())
    }
}

impl From<Utf8Error> for Error {
    fn from(error: Utf8Error) -> Self {
        Utf8(error)
    }
}

impl From<FromUtf8Error> for Error {
    fn from(error: FromUtf8Error) -> Self {
        FromUtf8(error)
    }
}

这些实现很容易理解:如果我们有一个 io::Error,我们只需将其包装在相应的变体中。我们还添加了从 &str 类型到方便的转换。

这将允许我们使用以下内容,这并不真正更好,但古老的 ? 操作符将帮助我们减少样板代码:

let val =
    match result {
        Ok(val) => val,
        Err(error) => return Err(error.into()),
    };

重新审视 ? 操作符

此操作符不仅会在有错误时返回错误,还会将其转换为所需类型。它通过调用 Into::into() 来转换,其中 Into 是一个特性。但为什么我们实现了 From 特性,而不是 Into?因为有一个基于 FromInto 的泛型实现:

impl<T, U> Into<U> for T
where U: From<T>,

由于这个实现,我们很少需要自己实现 Into 特性。我们只需要实现 From 特性。

这意味着我们可以将之前的代码重写如下:

let val = result?;

它的行为将完全与之前相同。

启动 Tokio 事件循环

tokio 中,我们需要用来管理事件循环的对象是 Core。以下是使用 tokio(在 main 模块中)启动事件循环的方法:

use tokio_core::reactor::Core;

fn main() {
    let mut core = Core::new().expect("Cannot create tokio Core");
    if let Err(error) = core.run(server()) {
        println!("Error running the server: {}", error);
    }
}

我们首先创建一个新的 Core 对象,然后调用 run() 方法来启动事件循环。后者方法将在提供的 future 结束时返回。在这里,我们调用 server() 来获取 future,所以让我们编写这个函数:

use std::io;

use futures::prelude::async;

#[async]
fn server() -> io::Result<()> {
    Ok(())
}

如您所见,我们使用了 #[async] 属性。由于属性在 Rust 中目前是不稳定的,我们必须指定我们正在使用 proc_macro 功能。我们还从 futures_await 包(在名称 futures 下导入)中导入 async 属性。所以不要忘记在顶部添加 #![feature] 属性和 extern crate 语句。

此属性允许我们编写一个返回 Result 的普通函数,并将此函数转换为实际上返回 Future 的函数。此函数不做任何事情并返回 Ok(()),因此当你运行程序时,它将立即结束。

我们还可以使用由 futures-await 包提供的另一种语法:

use futures::prelude::async_block;

fn main() {
    let mut core = Core::new().expect("Cannot create tokio Core");
    let server = async_block! {
        Ok(())
    };
    let result: Result<_, io::Error> = core.run(server);
    if let Err(error) = result {
        println!("Error running the server: {}", error);
    }
}

在我们的 FTP 服务器中,我们不会使用这种语法,但了解它是有价值的。通过使用 async_block,我们不需要创建一个新的函数。

启动服务器

我们刚才编写的程序实际上什么也没做,所以让我们更新它,至少让它启动一个服务器,使用 tokio。让我们为我们的 server() 函数编写一个实际的主体:

use std::net::{IpAddr, Ipv4Addr, SocketAddr};

use tokio_core::reactor::Handle;
use tokio_core::net::TcpListener;

#[async]
fn server(handle: Handle) -> io::Result<()> {
    let port = 1234;
    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port);
    let listener = TcpListener::bind(&addr, &handle)?;

    println!("Waiting clients on port {}...", port);
    #[async]
    for (stream, addr) in listener.incoming() {
        let address = format!("[address : {}]", addr);
        println!("New client: {}", address);
        handle.spawn(handle_client(stream));
        println!("Waiting another client...");
    }
    Ok(())
}

函数现在接受一个Handle,这将有助于指定服务器必须在哪个事件循环上运行。我们通过创建SocketAddr来指定我们想要在哪个端口上启动服务器。然后,我们以类似于从标准库创建同步TcpListener的方式创建一个TcpListener。这里的区别是,我们还发送handle作为参数来指定我们想要服务器在哪个事件循环上运行。之后,我们再次使用#[async]属性,但这次是在一个for循环上。

异步for循环用于遍历Stream,如果存在错误则返回错误。这些异步循环只能在#[async]函数中使用。在循环中,我们生成handle_client()返回的未来。生成一个未来意味着它将被事件循环执行和处理。与Core::run()的区别是,未来必须返回(),错误也应该也是()

现在这个函数接受一个参数,我们需要更新main函数:

fn main() {
    let mut core = Core::new().expect("Cannot create tokio Core");
    let handle = core.handle();
    if let Err(error) = core.run(server(handle)) {
        println!("Error running the server: {}", error);
    }
}

处理客户端

现在我们来看看我们刚才提到的handle_client()函数:

use std::result;

use futures::prelude::await;

#[async]
use tokio_core::net::TcpStream;

fn handle_client(stream: TcpStream) -> result::Result<(), ()> {
    await!(client(stream))
        .map_err(|error| println!("Error handling client: {}", error))
}

这是一个简单的client未来的包装器。在这里,我们使用了一个新的宏await!,它允许我们以异步的方式编写异步代码。当await!()内部的未来结果未准备好时,事件循环将执行其他操作,当它准备好时,它将继续执行await!()之后的代码。在这种情况下,我们打印出client未来返回的错误。这就是为什么我们需要一个包装器的原因。

现在,让我们编写这个client未来:

use futures::{Sink, Stream};
use futures::stream::SplitSink;
use tokio_io::AsyncRead;
use tokio_io::codec::Framed;

use codec::FtpCodec;
use error::Result;
use ftp::{Answer, ResultCode};

#[async]
fn client(stream: TcpStream) -> Result<()> {
    let (writer, reader) = stream.framed(FtpCodec).split();
    let writer = await!(writer.send(Answer::new(ResultCode::ServiceReadyForNewUser, 
    "Welcome to this FTP server!")))?;
    let mut client = Client::new(writer);
    #[async]
    for cmd in reader {
        client = await!(client.handle_cmd(cmd))?;
    }
    println!("Client closed");
    Ok(())
}

在这里,我们指定stream将由FtpCodec处理,这意味着我们将能够对结构化数据进行编码和解码,而不是直接处理字节。我们将很快编写这个FtpCodec。然后,我们将流分割成readerwriter。在 Rust 中,这个split()方法非常有用,因为它是关于所有权的:我们不能有两个所有者,一个将写入套接字,另一个将读取它。为了解决这个问题,我们分割了流,现在我们可以有一个reader的所有者,另一个writer的所有者。

然后,我们使用writer发送欢迎消息。同样,我们使用await!宏来指定在消息发送后,代码将会被执行(但不会阻塞整个程序,多亏了异步 I/O)。接下来,我们创建一个Client对象,它将负责管理客户端,通过在接收到命令时执行适当的操作并发送正确的响应。

之后,我们再次使用#[async] for循环来遍历一个流;在这里,我们遍历由该特定客户端接收到的数据流。在for循环中,我们调用即将编写的handle_cmd()方法。这个方法,正如其名称所示,将处理从该 FTP 客户端接收到的命令,相应地执行操作,并发送响应。在这里,我们使用await!()?并在末尾加上问号。futures-await crate 允许我们这样做;这意味着如果未来返回了错误,这个错误将传播到client未来,这与在返回Result的函数中使用的正常?运算符的语义相同。我们将在编写handle_cmd()方法时看到为什么我们将结果重新分配给client

处理命令

要处理 FTP 服务器接收到的命令,我们将有一个Client结构体:

type Writer = SplitSink<Framed<TcpStream, FtpCodec>>;

struct Client {
    writer: Writer,
}

客户端包含一个Writer对象,该对象将用于向客户端发送消息。Writer类型代表一个已分割的Sink,并在TcpStream上使用FtpCodecSinkStream的相反面:它不表示接收到的值的序列,而是表示发送的值的序列。

我们在Client上使用了两个方法,所以让我们编写它们:

use cmd::Command;

impl Client {
    fn new(writer: Writer) -> Client {
        Client {
            writer,
        }
    }

    #[async]
    fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
        Ok(self)
    }
}

构造函数非常简单,它使用提供的参数创建structhandle_cmd()接收由该特定客户端发送到 FTP 服务器的命令,并将处理它们;我们将在本章和下一章中逐步编写处理它们的代码。目前,它只返回self。请注意,此方法通过移动而不是通过引用接收self。这是由于futures-await crate 当前的一个限制:目前,异步函数不能接收引用。这个问题可能会在以后得到解决,这将使代码更加完善。这就是为什么我们在client函数中将它重新分配给client变量的原因:

client = await!(client.handle_cmd(cmd))?;

FTP 编解码器

在我们可以尝试我们的 FTP 服务器之前,唯一剩下要编写的代码是codec。因此,让我们为codec创建一个新的模块:

mod codec;

src/codec.rs文件中,我们将创建我们的 FTP codec

pub struct FtpCodec;

要创建一个codec,我们必须实现DecoderEncoder这两个特质。这些特质来自tokio-io crate:

use tokio_io::codec::{Decoder, Encoder};

解码 FTP 命令

让我们先编写解码器:

use std::io;

use bytes::BytesMut;

use cmd::Command;
use error::Error;

impl Decoder for FtpCodec {
    type Item = Command;
    type Error = io::Error;

    fn decode(&mut self, buf: &mut BytesMut) ->
     io::Result<Option<Command>> {
        if let Some(index) = find_crlf(buf) {
            let line = buf.split_to(index);
            buf.split_to(2); // Remove \r\n.
            Command::new(line.to_vec())
                .map(|command| Some(command))
                .map_err(Error::to_io_error)
        } else {
            Ok(None)
        }
    }
}

Decoder特质有两个关联类型,ItemError。前者是在我们能够解码一系列字节时产生的类型。后者是错误类型。我们首先检查是否存在字节CRLF。如果我们找不到它们,我们返回Ok(None)以指示我们需要更多的字节来解析命令。如果我们找到了它们,我们就获取命令的行,排除这些字节。然后,我们跳过这些字节,这样下一次解析就不会看到它们。最后,我们使用Command::new()解析这一行。

我们在这里使用了两个必须实现的新函数。第一个是添加到error模块的Error::to_io_error()方法:

impl Error {
    pub fn to_io_error(self) -> io::Error {
        match self {
            Io(error) => error,
            FromUtf8(_) | Msg(_) | Utf8(_) => 
             io::ErrorKind::Other.into(),
        }
    }
}

如果我们遇到 Io 错误,我们返回它。否则,我们返回 Other 类型的 I/O 错误。

decode() 方法也使用了以下函数:

fn find_crlf(buf: &mut BytesMut) -> Option<usize> {
    buf.windows(2)
        .position(|bytes| bytes == b"\r\n")
}

如果存在字节字符串 "\r\n",则返回其位置。请记住,这个字符串是 FTP 协议中的分隔符。

编码 FTP 命令

我们仍然需要编写一个 Encoder,以便有一个可以发送命令到 FTP 客户端的 codec

use ftp::Answer;

impl Encoder for FtpCodec {
    type Item = Answer;
    type Error = io::Error;

    fn encode(&mut self, answer: Answer, buf: &mut BytesMut) -> io::Result<()> {
        let answer =
            if answer.message.is_empty() {
                format!("{}\r\n", answer.code as u32)
            } else {
                format!("{} {}\r\n", answer.code as u32, 
                 answer.message)
            };
        buf.extend(answer.as_bytes());
        Ok(())
    }
}

在这里,如果我们有一个非空的消息,我们将其推送到缓冲区,前面是 FTP 代码号。如果没有消息,我们只推送这个代码号到缓冲区。

现在,我们可以尝试在 FileZilla 中运行 FTP 服务器以查看以下结果:

图 9.1图 9.1

处理命令

我们的 handle_cmd() 方法目前什么也不做,所以让我们更新它。首先,我们需要一个方法向客户端发送响应:

impl Client {
    #[async]
    fn send(mut self, answer: Answer) -> Result<Self> {
        self.writer = await!(self.writer.send(answer))?;
        Ok(self)
    }
}

这只是调用了 writersend() 方法。由于它消耗了它,我们将结果重新分配给属性。

现在,我们将处理 USER FTP 命令:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    println!("Received command: {:?}", cmd);
    match cmd {
        Command::User(content) => {
            if content.is_empty() {
                self = await! 
          (self.send(Answer::new(ResultCode::InvalidParameterOrArgument, "Invalid  
           username")))?;
            } else {
                self = await!(self.send(Answer::new(ResultCode::UserLoggedIn,  
                &format!("Welcome {}!", content))))?;
            }
        }
        Command::Unknown(s) =>
            self = await!(self.send(Answer::new(ResultCode::UnknownCommand,
            &format!("\"{}\": Not implemented", s))))? ,
        _ => self = await!(self.send(Answer::new(ResultCode::CommandNotImplemented,  
       "Not implemented")))?,
    }
    Ok(self)
}

在这里,我们通过模式匹配来了解客户端发送了哪个命令。如果不是 User,我们发送一个响应来说明该命令尚未实现。如果是 User,我们检查内容,如果内容良好,我们发送欢迎信息。这与我们在上一章中做的非常相似。

如果我们再次运行服务器,我们会看到以下内容:

图 9.2图 9.2

管理当前工作目录

在我们能够在 FTP 客户端中看到文件之前,还有一些命令缺失。现在让我们添加打印当前目录和更改目录的命令。

打印当前目录

首先,我们需要为我们的 Client 结构添加一个新的属性来指定当前目录:

use std::path::PathBuf;

struct Client {
    cwd: PathBuf,
    writer: Writer,
}

cwd 属性代表当前工作目录。我们还需要相应地更新 Client 构造函数:

impl Client {
    fn new(writer: Writer) -> Client {
        Client {
            cwd: PathBuf::from("/"),
            writer,
        }
    }
}

现在,我们可以添加 PWD 命令的处理程序:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    println!("Received command: {:?}", cmd);
    match cmd {
        Command::Pwd => {
            let msg = format!("{}", self.cwd.to_str().unwrap_or(""));
            if !msg.is_empty() {
                let message = format!("\"/{}\" ", msg);
                self = await!(self.send(Answer::new(ResultCode::PATHNAMECreated,  
                &message)))?;
            } else {
                self = await!(self.send(Answer::new(ResultCode::FileNotFound, "No  
                such file or directory")))?;
            }
        }
        // …
    }
}

因此,再次强调,我们有一个与上一章类似的代码。

更改当前目录

让我们在 handle_cmd() 方法中的 match 表达式中添加另一个情况:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    match cmd {
        Command::Cwd(directory) => self = await!(self.cwd(directory))?,
        // …
    }
}

它只是调用了以下方法:

#[async]
fn cwd(mut self, directory: PathBuf) -> Result<Self> {
    let path = self.cwd.join(&directory);
    let (new_self, res) = self.complete_path(path);
    self = new_self;
    if let Ok(dir) = res {
        let (new_self, res) = self.strip_prefix(dir);
        self = new_self;
        if let Ok(prefix) = res {
            self.cwd = prefix.to_path_buf();
            self = await!(self.send(Answer::new(ResultCode::Ok,
                                                &format!("Directory changed to \" 
             {}\"", directory.display()))))?;
            return Ok(self)
        }
    }
    self = await!(self.send(Answer::new(ResultCode::FileNotFound,
                                        "No such file or directory")))?;
    Ok(self)
}

此代码使用了以下两个方法,它们与上一章中的类似:

use std::path::StripPrefixError;

fn complete_path(self, path: PathBuf) -> (Self, result::Result<PathBuf, io::Error>) {
    let directory = self.server_root.join(if path.has_root() {
        path.iter().skip(1).collect()
    } else {
        path
    });
    let dir = directory.canonicalize();
    if let Ok(ref dir) = dir {
        if !dir.starts_with(&self.server_root) {
            return (self, 
             Err(io::ErrorKind::PermissionDenied.into()));
        }
    }
    (self, dir)
}

fn strip_prefix(self, dir: PathBuf) -> (Self, result::Result<PathBuf, StripPrefixError>) {
    let res = dir.strip_prefix(&self.server_root).map(|p| p.to_path_buf());
    (self, res)
}

由于它使用了一个新的属性,让我们将其添加到 Client 结构中:

struct Client {
    cwd: PathBuf,
    server_root: PathBuf,
    writer: Writer,
}

我们还添加了其构造函数:

impl Client {
    fn new(writer: Writer, server_root: PathBuf) -> Client {
        Client {
            cwd: PathBuf::from("/"),
            server_root,
            writer,
        }
    }
}

我们还需要在几个地方传递这个值,首先是在 client 函数及其包装器中:

#[async]
fn client(stream: TcpStream, server_root: PathBuf) -> Result<()> {
    // …
    let mut client = Client::new(writer, server_root);
    // …
}

#[async]
fn handle_client(stream: TcpStream, server_root: PathBuf) -> result::Result<(), ()> {
    await!(client(stream, server_root))
        .map_err(|error| println!("Error handling client: {}", 
         error))
}

然后,我们需要更新 server 函数:

#[async]
fn server(handle: Handle, server_root: PathBuf) -> io::Result<()> {
    // …
    #[async]
    for (stream, addr) in listener.incoming() {
        let address = format!("[address : {}]", addr);
        println!("New client: {}", address);
        handle.spawn(handle_client(stream, server_root.clone()));
        println!("Waiting another client...");
    }
    Ok(())
}

将服务器根目录发送到 handle_client 函数调用。

最后,我们将更新主函数以将其发送到 server 函数:

use std::env;

fn main() {
    let mut core = Core::new().expect("Cannot create tokio Core");
    let handle = core.handle();

    match env::current_dir() {
        Ok(server_root) => {
            if let Err(error) = core.run(server(handle, 
             server_root)) {
                println!("Error running the server: {}", error);
            }
        }
        Err(e) => println!("Couldn't start server: {:?}", e),
    }
}

在这里,我们将当前目录作为服务器根目录发送。

设置传输类型

在我们再次测试服务器之前,让我们添加一个新的命令:

use cmd::TransferType;

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    match cmd {
        // …
        Command::Type(typ) => {
            self.transfer_type = typ;
            self = await!(self.send(Answer::new(ResultCode::Ok, "Transfer type 
            changed successfully")))?;
        }
        // …
    }
}

这需要为我们的 Client 结构添加一个新的属性:

struct Client {
    cwd: PathBuf,
    server_root: PathBuf,
    transfer_type: TransferType,
    writer: Writer,
}

我们还需要更新构造函数:

impl Client {
    fn new(writer: Writer, server_root: PathBuf) -> Client {
        Client {
            cwd: PathBuf::from("/"),
            server_root,
            transfer_type: TransferType::Ascii,
            writer,
        }
    }
}

如果我们运行这个新的服务器并通过 FileZilla 连接到它,我们会看到以下内容:

图 9.3 图 9.3

进入被动模式

现在我们来编写处理 PASV 命令的代码。在 handle_cmd() 中添加以下情况:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    match cmd {
        // …
        Command::Pasv => self = await!(self.pasv())?,
        // …
    }
}

对于以下内容,我们需要在 Client 结构中添加四个新字段:

use futures::stream::SplitStream;

use codec::BytesCodec;

type DataReader = SplitStream<Framed<TcpStream, BytesCodec>>;
type DataWriter = SplitSink<Framed<TcpStream, BytesCodec>>;

struct Client {
    data_port: Option<u16>,
    data_reader: Option<DataReader>,
    data_writer: Option<DataWriter>,
    handle: Handle,
    // …
}

所有这些都被初始化为 None

impl Client {
    fn new(handle: Handle, writer: Writer, server_root: PathBuf) -> Client {
        Client {
            data_port: None,
            data_reader: None,
            data_writer: None,
            handle,
            // …
        }
    }
}

这需要更改几个其他函数以将 Handle 发送到 Client 构造函数。首先,client 函数现在需要一个新的 handle 参数:

#[async]
fn client(stream: TcpStream, handle: Handle, server_root: PathBuf) -> Result<()> {
    let (writer, reader) = stream.framed(FtpCodec).split();
    let writer = await!(writer.send(Answer::new(ResultCode::ServiceReadyForNewUser,  
    "Welcome to this FTP server!")))?;
    let mut client = Client::new(handle, writer, server_root);
    // …
}

handle_client() 方法也需要一个新的参数:

#[async]
fn handle_client(stream: TcpStream, handle: Handle, server_root: PathBuf) -> result::Result<(), ()> {
    await!(client(stream, handle, server_root))
        .map_err(|error| println!("Error handling client: {}", error))
}

server() 函数中,你需要将 handler 发送到 handle_client() 函数:

#[async]
fn server(handle: Handle, server_root: PathBuf) -> io::Result<()> {
    // …
    #[async]
    for (stream, addr) in listener.incoming() {
        // …
        handle.spawn(handle_client(stream, handle.clone(), server_root.clone()));
    }
}

这里是执行 PASV 命令实际功能的方法的开始:

#[async]
fn pasv(mut self) -> Result<Self> {
    let port =
        if let Some(port) = self.data_port {
            port
        } else {
            0
        };
    if self.data_writer.is_some() {
        self = await!(self.send(Answer::new(ResultCode::DataConnectionAlreadyOpen,  
       "Already listening...")))?;
        return Ok(self);
    }

    // …

如果之前有命令设置了端口,我们使用它,否则我们使用零以请求系统选择一个。如您从上一章所知,FTP 有两个通道——命令通道和数据通道。因此,这里我们检查数据通道是否已经打开。如果是这种情况,我们发送适当的响应并通过返回结束函数。以下是方法的其余部分:

    // …

    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port);
    let listener = TcpListener::bind(&addr, &self.handle)?;
    let port = listener.local_addr()?.port();

    self = await!(self.send(Answer::new(ResultCode::EnteringPassiveMode,
                          &format!("127,0,0,1,{},{}", port >> 8, port & 0xFF))))?;

    println!("Waiting clients on port {}...", port);
    #[async]
    for (stream, _rest) in listener.incoming() {
        let (writer, reader) = stream.framed(BytesCodec).split();
        self.data_writer = Some(writer);
        self.data_reader = Some(reader);
        break;
    }
    Ok(self)
}

我们首先启动数据通道的监听器。看以下行:

let port = listener.local_addr()?.port();

这用于获取系统选择的端口,如果我们指定了端口 0 以让操作系统选择一个端口。然后,我们使用一个 async for 循环,在第一次迭代后立即中断,因为我们只有一个客户端将连接到这个新通道。在循环中,我们再次使用相同的拆分技巧;在说我们的流使用 BytesCodec 之后,我们在 writerreader 之间拆分流。我们很快就会描述这个新的 codec。然后我们保存数据 writerreader

字节编解码器

我们首先为 codec 创建一个空的结构:

pub struct BytesCodec;

解码数据字节

然后我们像为 FtpCodec 实现的那样实现 Decoder 特性:

impl Decoder for BytesCodec {
    type Item = Vec<u8>;
    type Error = io::Error;

    fn decode(&mut self, buf: &mut BytesMut) -> io::Result<Option<Vec<u8>>> {
        if buf.len() == 0 {
            return Ok(None);
        }
        let data = buf.to_vec();
        buf.clear();
        Ok(Some(data))
    }
}

由于传输的文件数据可以是二进制,我们不能使用类型为 StringItem。我们改用 Vec<u8>,它可以包含每一个可能的字节。如果缓冲区为空,我们返回 Ok(None) 以指示 tokio 我们需要更多数据。否则,我们将它转换为向量,清空缓冲区并返回该向量。

编码数据字节

现在我们来看看如何编码数据;这甚至更简单:

impl Encoder for BytesCodec {
    type Item = Vec<u8>;
    type Error = io::Error;

    fn encode(&mut self, data: Vec<u8>, buf: &mut BytesMut) -> io::Result<()> {
        buf.extend(data);
        Ok(())
    }
}

我们只需将数据扩展到缓冲区。

退出

现在我们来实现 QUIT 命令。像往常一样,我们需要在 handle_cmd() 方法中添加一个情况:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    match cmd {
        Command::Quit => self = await!(self.quit())?,
        // …
    }
}

这是 quit() 方法的代码:

#[async]
fn quit(mut self) -> Result<Self> {
    if self.data_writer.is_some() {
        unimplemented!();
    } else {
        self = await!(self.send(Answer::new(ResultCode::ServiceClosingControlConnection, "Closing connection...")))?;
        self.writer.close()?;
    }
    Ok(self)
}

因此,我们向客户端发送响应并 close() writer

为了完成这一章,让我们实现创建和删除目录的命令。

创建目录

我们将开始处理创建新目录的命令。因此,我们在 handle_cmd() 中添加一个情况:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    match cmd {
        Command::Mkd(path) => self = await!(self.mkd(path))?,
        // …
    }
}

处理此命令的函数是:

use std::fs::create_dir;

#[async]
fn mkd(mut self, path: PathBuf) -> Result<Self> {
    let path = self.cwd.join(&path);
    let parent = get_parent(path.clone());
    if let Some(parent) = parent {
        let parent = parent.to_path_buf();
        let (new_self, res) = self.complete_path(parent);
        self = new_self;
        if let Ok(mut dir) = res {

我们首先检查父目录是否有效并且位于服务器根目录下:

            if dir.is_dir() {
                let filename = get_filename(path);
                if let Some(filename) = filename {
                    dir.push(filename);
                    if create_dir(dir).is_ok() {
                        self = await! 
               (self.send(Answer::new(ResultCode::PATHNAMECreated,
               "Folder successfully created!")))?;
                        return Ok(self);
                    }
                }
            }
        }
    }
    self = await!(self.send(Answer::new(ResultCode::FileNotFound,
                                        "Couldn't create folder")))?;
    Ok(self)
}

如果是,我们创建目录。否则,我们发送错误。

这需要两个新函数:

use std::ffi::OsString;

fn get_parent(path: PathBuf) -> Option<PathBuf> {
    path.parent().map(|p| p.to_path_buf())
}

fn get_filename(path: PathBuf) -> Option<OsString> {
    path.file_name().map(|p| p.to_os_string())
}

这些是标准库方法的简单包装,执行类型转换。

删除目录

最后,让我们看看删除目录的代码:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    match cmd {
        Command::Rmd(path) => self = await!(self.rmd(path))?,
        // …
    }
}

与之前的命令类似,我们添加了一个新的情况,调用处理它的方法:

use std::fs::remove_dir_all;

#[async]
fn rmd(mut self, directory: PathBuf) -> Result<Self> {
    let path = self.cwd.join(&directory);
    let (new_self, res) = self.complete_path(path);
    self = new_self;
    if let Ok(dir) = res {
        if remove_dir_all(dir).is_ok() {
            self = await!(self.send(Answer::new(ResultCode::RequestedFileActionOkay,
                                                "Folder successfully removed")))?;
            return Ok(self);
        }
    }
    self = await!(self.send(Answer::new(ResultCode::FileNotFound,
                                        "Couldn't remove folder")))?;
    Ok(self)
}

在这里,我们再次检查目录是否有效且位于服务器根目录下,如果是这种情况,则删除它。否则,我们发送一个错误消息。

摘要

在本章中,我们为我们的异步 FTP 服务器实现了很多命令,并学习了使用tokio。我们还更详细地了解了异步 I/O 是什么,以及它的优缺点。我们使用新的async/await语法简化了使用tokio的代码。我们学习了什么是 futures 和 streams,以及它们如何与tokio交互。我们还看到了如何进行适当的错误处理,以及如何简洁地处理。在下一章,我们将完成 FTP 服务器的实现,并了解如何对其进行测试。

第十章:实现异步文件传输

在上一章中,我们开始编写使用tokio的异步 FTP 服务器。现在,我们将开始使用 FTP 协议中使用的第二个通道:数据通道。我们将涵盖以下主题:

  • 单元测试

  • 集成测试

  • 回溯

  • 文档

  • 文档测试

  • 模糊测试

列出文件

我们将从这个章节开始实现列出文件的命令。这将使我们能够在 FTP 客户端中真正看到文件,并且我们可以通过在目录中导航来测试上一章的一些命令。所以,让我们在Client::handle_cmd()方法中添加一个用例:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    match cmd {
        Command::List(path) => self = await!(self.list(path))?,
        // …
    }
}

这只是调用list()方法,它开始如下:

use std::fs::read_dir;

#[async]
fn list(mut self, path: Option<PathBuf>) -> Result<Self> {
    if self.data_writer.is_some() {
        let path = self.cwd.join(path.unwrap_or_default());
        let directory = PathBuf::from(&path);
        let (new_self, res) = self.complete_path(directory);
        self = new_self;
        if let Ok(path) = res {
            self = await!
            (self.send(Answer::new(ResultCode::DataConnectionAlreadyOpen,
                                                "Starting to list directory...")))?;

我们首先检查数据通道是否打开,如果是这样,我们检查提供的可选路径是否有效。如果是,我们发送一个响应,指示客户端我们将发送数据。方法的后半部分如下:

            let mut out = vec![];
            if path.is_dir() {
                if let Ok(dir) = read_dir(path) {
                    for entry in dir {
                        if let Ok(entry) = entry {
                            add_file_info(entry.path(), &mut out);
                        }
                    }
                } else {
                    self = await!
                    (self.send(Answer::new(ResultCode::InvalidParameterOrArgument,
                                                        "No such file or 
                                                         directory")))?;
                    return Ok(self);
                }
            } else {
                add_file_info(path, &mut out);
            }

我们首先创建一个变量out,它将包含要发送给客户端的数据。如果指定的路径是目录,我们使用标准库中的read_dir()函数。然后,我们遍历目录中的所有文件以收集每个文件的信息。如果我们无法打开目录,我们将向客户端发送错误。如果路径不是目录,例如,如果它是一个文件,我们只为这个单个文件获取信息。以下是该方法的结尾:

            self = await!(self.send_data(out))?;
            println!("-> and done!");
        } else {
            self = await!
         (self.send(Answer::new(ResultCode::InvalidParameterOrArgument,
                                                "No such file or directory")))?;
        }
    } else {
        self = await!(self.send(Answer::new(ResultCode::ConnectionClosed, "No opened 
         data connection")))?;
    }
    if self.data_writer.is_some() {
        self.close_data_connection();
        self = await!(self.send(Answer::new(ResultCode::ClosingDataConnection, 
                      "Transfer done")))?;
    }
    Ok(self)
}

然后,我们使用稍后将要看到的send_data()方法在正确的通道发送数据。如果出现其他错误,我们向客户端发送适当的响应。如果我们成功发送了数据,我们将关闭连接并向客户端指示这一动作。这段代码使用了几个新方法,所以让我们来实现它们。

首先,这是在数据通道中发送数据的步骤:

#[async]
fn send_data(mut self, data: Vec<u8>) -> Result<Self> {
    if let Some(writer) = self.data_writer {
        self.data_writer = Some(await!(writer.send(data))?);
    }
    Ok(self)
}

它与send()方法非常相似,但这个方法只有在数据套接字打开时才会发送数据。另一个需要的方法是关闭连接的方法:

fn close_data_connection(&mut self) {
    self.data_reader = None;
    self.data_writer = None;
}

我们需要实现一个方法来收集有关文件的信息。以下是它的开始:

const MONTHS: [&'static str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
                                    "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

fn add_file_info(path: PathBuf, out: &mut Vec<u8>) {
    let extra = if path.is_dir() { "/" } else { "" };
    let is_dir = if path.is_dir() { "d" } else { "-" };

    let meta = match ::std::fs::metadata(&path) {
        Ok(meta) => meta,
        _ => return,
    };
    let (time, file_size) = get_file_info(&meta);
    let path = match path.to_str() {
        Some(path) => match path.split("/").last() {
            Some(path) => path,
            _ => return,
        },
        _ => return,
    };
    let rights = if meta.permissions().readonly() {
        "r--r--r--"
    } else {
        "rw-rw-rw-"
    };

参数out是一个可变引用,因为我们将在此变量中追加信息。然后,我们收集文件的不同所需信息和权限。以下是函数的其余部分:

    let file_str = format!("{is_dir}{rights} {links} {owner} {group} {size} {month}  
    {day} {hour}:{min} {path}{extra}\r\n",
                           is_dir=is_dir,
                           rights=rights,
                           links=1, // number of links
                           owner="anonymous", // owner name
                           group="anonymous", // group name
                           size=file_size,
                           month=MONTHS[time.tm_mon as usize],
                           day=time.tm_mday,
                           hour=time.tm_hour,
                           min=time.tm_min,
                           path=path,
                           extra=extra);
    out.extend(file_str.as_bytes());
    println!("==> {:?}", &file_str);
}

它格式化信息并将其追加到变量out中。

这个函数使用了另一个函数:

extern crate time;

use std::fs::Metadata;

#[cfg(windows)]
fn get_file_info(meta: &Metadata) -> (time::Tm, u64) {
    use std::os::windows::prelude::*;
    (time::at(time::Timespec::new(meta.last_write_time())), meta.file_size())
}

#[cfg(not(windows))]
fn get_file_info(meta: &Metadata) -> (time::Tm, u64) {
    use std::os::unix::prelude::*;
    (time::at(time::Timespec::new(meta.mtime(), 0)), meta.size())
}

这里,我们有get_file_info()函数的两个版本:一个用于 Windows,另一个用于所有非 Windows 操作系统。由于我们使用了一个新的 crate,我们需要在Cargo.toml中添加这一行:

time = "0.1.38"

我们现在可以在 FTP 客户端中测试文件是否确实被列出(在右侧):

图 10.1图 10.1

例如,如果我们双击目录,比如 src,FTP 客户端将更新其内容:

图 10.2图 10.2

下载文件

FTP 服务器的一个非常有用的功能是能够下载文件。因此,现在是添加执行此操作命令的时候了。

首先,我们在handle_cmd()方法中添加一个情况:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    match cmd {
        Command::Retr(file) => self = await!(self.retr(file))?,
        // …
    }
}

这是retr()函数的开始:

use std::fs::File;
use std::io::Read;

use error::Error;

#[async]
fn retr(mut self, path: PathBuf) -> Result<Self> {
    if self.data_writer.is_some() {
        let path = self.cwd.join(path);
        let (new_self, res) = self.complete_path(path.clone());
        self = new_self;
        if let Ok(path) = res {
            if path.is_file() {
                self = await!(self.send(Answer::new(ResultCode::DataConnectionAlreadyOpen, "Starting to send file...")))?;
                let mut file = File::open(path)?;
                let mut out = vec![];
                file.read_to_end(&mut out)?;
                self = await!(self.send_data(out))?;
                println!("-> file transfer done!");

再次检查数据通道是否已打开,并检查路径。如果是文件,我们打开它,读取其内容,并发送给客户端。否则,我们发送适当的错误:

            } else {
                self = await!(self.send(Answer::new(ResultCode::LocalErrorInProcessing,
                              &format!("\"{}\" doesn't exist",
                              path.to_str().ok_or_else(|| Error::Msg("No 
                              path".to_string()))?))))?;
            }
        } else {
            self = await!(self.send(Answer::new(ResultCode::LocalErrorInProcessing,
                          &format!("\"{}\" doesn't exist",
                          path.to_str().ok_or_else(|| Error::Msg("No  
                          path".to_string()))?))))?;
        }
    } else {
        self = await!
(self.send(Answer::new(ResultCode::ConnectionClosed, "No opened  
         data connection")))?;
    }

这里,我们使用这个模式:

.ok_or_else(|| Error::Msg("No path".to_string()))?

这将Option转换为Result,并在有错误时返回错误。

最后,如果我们成功发送了文件,我们将关闭数据套接字:

    if self.data_writer.is_some() {
        self.close_data_connection();
        self = await!(self.send(Answer::new(ResultCode::ClosingDataConnection, 
         "Transfer done")))?;
    }
    Ok(self)
}

让我们在 FileZilla 中下载一个文件来检查它是否工作:

图 10.3图 10.3

上传文件

现在,让我们执行相反的命令:STOR在服务器上上传文件。

和往常一样,我们将在handle_cmd()方法中添加一个情况:

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    match cmd {
        Command::Stor(file) => self = await!(self.stor(file))?,
        // …
    }
}

这是相应方法的开始:

use std::io::Write;

#[async]
fn stor(mut self, path: PathBuf) -> Result<Self> {
    if self.data_reader.is_some() {
        if invalid_path(&path) {
            let error: io::Error = io::ErrorKind::PermissionDenied.into();
            return Err(error.into());
        }
        let path = self.cwd.join(path);
        self = await!(self.send(Answer::new(ResultCode::DataConnectionAlreadyOpen, 
         "Starting to send file...")))?;

再次检查数据通道是否已打开。然后,我们使用一个新的函数来检查路径是否有效,这意味着它不包含..。在其他情况下,我们使用了另一个方法canonicalize(),并检查路径是否位于服务器根目录下,但在这里我们无法这样做,因为没有文件可以上传。这是方法的结尾:

        let (data, new_self) = await!(self.receive_data())?;
        self = new_self;
        let mut file = File::create(path)?;
        file.write_all(&data)?;
        println!("-> file transfer done!");
        self.close_data_connection();
        self = await!(self.send(Answer::new(ResultCode::ClosingDataConnection, 
        "Transfer done")))?;
    } else {
        self = await!(self.send(Answer::new(ResultCode::ConnectionClosed, 
         "No opened data connection")))?;
    }
    Ok(self)
}

这里,我们调用receive_data(),这是一个Future,它将解析为从客户端接收到的数据。然后,我们将这些内容写入一个新文件。最后,我们关闭连接并发送响应以指示传输完成。

这是从数据套接字读取数据的方法:

#[async]
fn receive_data(mut self) -> Result<(Vec<u8>, Self)> {
    let mut file_data = vec![];
    if self.data_reader.is_none() {
        return Ok((vec![], self));
    }
    let reader = self.data_reader.take().ok_or_else(|| Error::Msg("No data 
     reader".to_string()))?;
    #[async]
    for data in reader {
        file_data.extend(&data);
    }
    Ok((file_data, self))
}

这里,我们获取data_reader属性,这意味着在此语句之后它将是None。然后我们使用async for循环迭代读取器流。在每次迭代中,我们将数据添加到最终返回的向量中。

这是检查路径是否有效的方法:

use std::path::Component;

fn invalid_path(path: &Path) -> bool {
    for component in path.components() {
        if let Component::ParentDir = component {
            return true;
        }
    }
    false
}

让我们检查上传是否真的工作:

图 10.4图 10.4

进一步!

添加一些配置会很好,不是吗?添加用户认证也会很好。让我们从配置开始!

配置

首先,让我们在src/中创建一个名为config.rs的新文件。为了使事情更简单,我们将使用 TOML 格式来处理我们的配置文件。幸运的是,有一个 Rust 中处理 TOML 文件的 crate,名为toml。除了这个之外,我们还将使用serde来处理序列化和反序列化(非常有用!)。

好的,让我们首先将依赖项添加到我们的Cargo.toml文件中:

toml = "0.4"
serde = "1.0"
serde_derive = "1.0"

好的,现在让我们编写我们的Config结构体:

pub struct Config {
    // fields...
}

那我们应该放些什么呢?服务器启动时应监听的端口和地址,也许?

pub struct Config {
    pub server_port: Option<u16>,
    pub server_addr: Option<String>,
}

完成了。我们也讨论了处理认证。为什么不添加它呢?我们需要一个新的struct来处理用户。让我们称它为User(原创性真好!):

pub struct User {
    pub name: String,
    pub password: String,
}

现在让我们将用户添加到Config结构体中:

pub struct Config {
    pub server_port: Option<u16>,
    pub server_addr: Option<String>,
    pub users: Vec<User>,
    pub admin: Option<User>,
}

为了使这两个structserde一起工作,我们必须添加以下标签:

#[derive(Deserialize, Serialize)]

由于我们需要克隆Config,我们将添加Debug到标签中,这给了我们:

#[derive(Clone, Deserialize, Serialize)]
pub struct Config {
    pub server_port: Option<u16>,
    pub server_addr: Option<String>,
    pub admin: Option<User>,
    pub users: Vec<User>,
}

#[derive(Clone, Deserialize, Serialize)]
pub struct User {
    pub name: String,
    pub password: String,
}

好的,我们现在准备好实现读取:

use std::fs::File;
use std::path::Path;
use std::io::{Read, Write};

use toml;

fn get_content<P: AsRef<Path>>(file_path: &P) -> Option<String> {
    let mut file = File::open(file_path).ok()?;
    let mut content = String::new();
    file.read_to_string(&mut content).ok()?;
    Some(content)
}

impl Config {
    pub fn new<P: AsRef<Path>>(file_path: P) -> Option<Config> {
        if let Some(content) = get_content(&file_path) {
            toml::from_str(&content).ok()
        } else {
            println!("No config file found so creating a new one in 
                     {}",file_path.as_ref().display());
            // In case we didn't find the config file, 
                we just build a new one.
            let config = Config {
                server_port: Some(DEFAULT_PORT),
                server_addr: Some("127.0.0.1".to_owned()),
                admin: None,
                users: vec![User {
                    name: "anonymous".to_owned(),
                    password: "".to_owned(),
                }],
            };
            let content = toml::to_string(&config).expect("serialization failed");
            let mut file = File::create(file_path.as_ref()).expect("couldn't create 
             file...");
            writeln!(file, "{}", content).expect("couldn't fulfill config file...");
            Some(config)
        }
    }
}

让我们通过Config::new方法的代码来了解:

if let Some(content) = get_content(&file_path) {
    toml::from_str(&content).ok()
}

多亏了 serde,我们可以直接从 &str 加载配置文件,并返回完全设置的 Config 结构体。太棒了,对吧?

关于信息,get_content 函数只是一个 utility 函数,允许返回文件的内容,如果该文件存在的话。

此外,别忘了添加 DEFAULT_PORT 常量:

pub const DEFAULT_PORT: u16 = 1234;

如果文件不存在,我们可以使用一些默认值创建一个新的文件:

else {
    println!("No config file found so creating a new one in {}",
             file_path.as_ref().display());
    // In case we didn't find the config file, we just build a new one.
    let config = Config {
        server_port: Some(DEFAULT_PORT),
        server_addr: Some("127.0.0.1".to_owned()),
        admin: None,
        users: vec![User {
            name: "anonymous".to_owned(),
            password: "".to_owned(),
        }],
    };
    let content = toml::to_string(&config).expect("serialization failed");
    let mut file = File::create(file_path.as_ref()).expect("couldn't create 
    file...");
    writeln!(file, "{}", content).expect("couldn't fulfill config file...");
    Some(config)
}

现在你可能会想知道,我们如何能够使用这段代码从我们的 Config 结构体生成 TOML?再次使用 serde 的魔法!

有了这个,我们的 config 文件现在就完整了。让我们回到 main.rs。首先,我们需要定义一个新的常量:

const CONFIG_FILE: &'static str = "config.toml";

然后,我们需要更新相当多的方法/函数。让我们从 main 函数开始。在开始处添加此行:

let config = Config::new(CONFIG_FILE).expect("Error while loading config...");

现在将 config 变量传递给 server 函数:

if let Err(error) = core.run(server(handle, server_root, config)) {

接下来,让我们更新 server 函数:

#[async]
fn server(handle: Handle, server_root: PathBuf, config: Config) -> io::Result<()> {
    let port = config.server_port.unwrap_or(DEFAULT_PORT);
    let addr = SocketAddr::new(IpAddr::V4(config.server_addr.as_ref()
                                                .unwrap_or(&"127.0.0.1".to_owned())
                                                .parse()
                                                .expect("Invalid IpV4 address...")),
                                                 port);
    let listener = TcpListener::bind(&addr, &handle)?;

    println!("Waiting clients on port {}...", port);
    #[async]
    for (stream, addr) in listener.incoming() {
        let address = format!("[address : {}]", addr);
        println!("New client: {}", address);
        handle.spawn(handle_client(stream, handle.clone(), server_root.clone()));
        handle.spawn(handle_client(stream, handle.clone(), server_root.clone(),  
        config.clone()));
        println!("Waiting another client...");
    }
    Ok(())
}

现在,服务器使用 Config 结构体的值启动。然而,我们仍然需要每个客户端的用户列表来处理身份验证。为此,我们需要给每个 Client 一个 Config 实例。在这里,为了使事情更简单,我们只需 clone

现在是时候更新 handle_client 函数了:

#[async]
fn handle_client(stream: TcpStream, handle: Handle, server_root: PathBuf,
                 config: Config) -> result::Result<(), ()> {
    await!(client(stream, handle, server_root, config))
        .map_err(|error| println!("Error handling client: {}", error))
}

现在让我们更新 client 函数:

#[async]
fn client(stream: TcpStream, handle: Handle, server_root: PathBuf, config: Config) -> Result<()> {
    let (writer, reader) = stream.framed(FtpCodec).split();
    let writer = await!(writer.send(Answer::new(ResultCode::ServiceReadyForNewUser,
                                    "Welcome to this FTP server!")))?;
    let mut client = Client::new(handle, writer, server_root, config);
    #[async]
    for cmd in reader {
        client = await!(client.handle_cmd(cmd))?;
    }
    println!("Client closed");
    Ok(())
}

最后一步是更新 Client 结构体:

struct Client {
    cwd: PathBuf,
    data_port: Option<u16>,
    data_reader: Option<DataReader>,
    data_writer: Option<DataWriter>,
    handle: Handle,
    name: Option<String>,
    server_root: PathBuf,
    transfer_type: TransferType,
    writer: Writer,
    is_admin: bool,
    config: Config,
    waiting_password: bool,
}

新增的 config 字段看起来很合理,然而 is_adminwaiting_password 呢?第一个将用于能够列出/下载/覆盖 config.toml 文件,而第二个将在使用 USER 命令且服务器现在期待用户密码时使用。

让我们在 Client 结构体中添加另一个方法:

fn is_logged(&self) -> bool {
    self.name.is_some() && !self.waiting_password
}

不要忘记更新 Config::new 方法:

fn new(handle: Handle, writer: Writer, server_root: PathBuf, config: Config) -> Client {
    Client {
        cwd: PathBuf::from("/"),
        data_port: None,
        data_reader: None,
        data_writer: None,
        handle,
        name: None,
        server_root,
        transfer_type: TransferType::Ascii,
        writer,
        is_admin: false,
        config,
        waiting_password: false,
    }
}

好的,现在有一个巨大的更新!但是首先,别忘了添加 Pass 命令:

pub enum Command {
    // variants...
    Pass(String),
    // variants...
}

现在是 Command::new 匹配:

b"PASS" => Command::Pass(data.and_then(|bytes| String::from_utf8(bytes.to_vec()).map_err(Into::into))?),

不要忘记更新 AsRef 的实现!

好的,我们已经准备好最后(而且非常庞大)的一步。让我们转到 Client::handle_cmd 方法:

use config::{DEFAULT_PORT, Config};
use std::path::Path;

fn prefix_slash(path: &mut PathBuf) {
    if !path.is_absolute() {
        *path = Path::new("/").join(&path);
    }
}

#[async]
fn handle_cmd(mut self, cmd: Command) -> Result<Self> {
    println!("Received command: {:?}", cmd);
    if self.is_logged() {
        match cmd {
            Command::Cwd(directory) => return Ok(await!(self.cwd(directory))?),
            Command::List(path) => return Ok(await!(self.list(path))?),
            Command::Pasv => return Ok(await!(self.pasv())?),
            Command::Port(port) => {
                self.data_port = Some(port);
                return Ok(await!(self.send(Answer::new(ResultCode::Ok,
                          &format!("Data port is now {}", 
                           port))))?);
            }
            Command::Pwd => {
                let msg = format!("{}", self.cwd.to_str().unwrap_or("")); // small   
                 trick
                if !msg.is_empty() {
                    let message = format!("\"{}\" ", msg);

                    return Ok(await!
                    (self.send(Answer::new(ResultCode::PATHNAMECreated,
                      &message)))?);
                } else {
                    return Ok(await!(self.send(Answer::new(ResultCode::FileNotFound,
                              "No such file or directory")))?);
                }
            }
            Command::Retr(file) => return Ok(await!(self.retr(file))?),
            Command::Stor(file) => return Ok(await!(self.stor(file))?),
            Command::CdUp => {
                if let Some(path) = self.cwd.parent().map(Path::to_path_buf) {
                    self.cwd = path;
                    prefix_slash(&mut self.cwd);
                }
                return Ok(await!(self.send(Answer::new(ResultCode::Ok, "Done")))?);
            }
            Command::Mkd(path) => return Ok(await!(self.mkd(path))?),
            Command::Rmd(path) => return Ok(await!(self.rmd(path))?),
            _ => (),
        }
    } else if self.name.is_some() && self.waiting_password {
        if let Command::Pass(content) = cmd {
            let mut ok = false;
            if self.is_admin {
                ok = content == self.config.admin.as_ref().unwrap().password;
            } else {
                for user in &self.config.users {
                    if Some(&user.name) == self.name.as_ref() {
                        if user.password == content {
                            ok = true;
                            break;
                        }
                    }
                }
            }
            if ok {
                self.waiting_password = false;
                let name = self.name.clone().unwrap_or(String::new());
                self = await!(
                    self.send(Answer::new(ResultCode::UserLoggedIn,
                                          &format!("Welcome {}", name))))?;
            } else {
                self = await!(self.send(Answer::new(ResultCode::NotLoggedIn,
                               "Invalid password")))?;
            }
            return Ok(self);
        }
    }
    match cmd {
        Command::Auth =>
            self = await!(self.send(Answer::new(ResultCode::CommandNotImplemented,
                          "Not implemented")))?,
        Command::Quit => self = await!(self.quit())?,
        Command::Syst => {
            self = await!(self.send(Answer::new(ResultCode::Ok, "I won't tell!")))?;
        }
        Command::Type(typ) => {
            self.transfer_type = typ;
            self = await!(self.send(Answer::new(ResultCode::Ok,
                           "Transfer type changed successfully")))?;
        }
        Command::User(content) => {
            if content.is_empty() {
                self = await!
               (self.send(Answer::new(ResultCode::InvalidParameterOrArgument,
                                       "Invalid username")))?;
            } else {
                let mut name = None;
                let mut pass_required = true;

                self.is_admin = false;
                if let Some(ref admin) = self.config.admin {
                    if admin.name == content {
                        name = Some(content.clone());
                        pass_required = admin.password.is_empty() == false;
                        self.is_admin = true;
                    }
                }
                // In case the user isn't the admin.
                if name.is_none() {
                    for user in &self.config.users {
                        if user.name == content {
                            name = Some(content.clone());
                            pass_required = user.password.is_empty() == false;
                            break;
                        }
                    }
                }
                // In case this is an unknown user.
                if name.is_none() {
                    self = await!(self.send(Answer::new(ResultCode::NotLoggedIn,
                                     "Unknown user...")))?;
                } else {
                    self.name = name.clone();
                    if pass_required {
                        self.waiting_password = true;
                        self = await!(

                       self.send(Answer::new(ResultCode::UserNameOkayNeedPassword,
                                      &format!("Login OK, password needed for {}",
                                               name.unwrap()))))?;
                    } else {
                        self.waiting_password = false;
                        self = await! 
                         (self.send(Answer::new(ResultCode::UserLoggedIn,
                              &format!("Welcome {}!", content))))?;
                    }
                }
            }
        }
        Command::NoOp => self = await!(self.send(Answer::new(ResultCode::Ok,
                                                             "Doing nothing")))?,
        Command::Unknown(s) =>
            self = await!(self.send(Answer::new(ResultCode::UnknownCommand,
                           &format!("\"{}\": Not implemented", s))))?,
        _ => {
            // It means that the user tried to send a command while they weren't  
             logged yet.
            self = await!(self.send(Answer::new(ResultCode::NotLoggedIn,
                           "Please log first")))?;
        }
    }
    Ok(self)
}

我已经告诉你它有多么巨大了!这里的主要点是流程重构。以下命令仅在您登录时才有效:

  • Cwd

  • List

  • Pasv

  • Port

  • Pwd

  • Retr

  • Stor

  • CdUp

  • Mkd

  • Rmd

此命令仅在您尚未登录且服务器正在等待密码时才有效:

  • Pass

其余的命令在任何情况下都有效。我们在这里几乎完成了。记得我提到过的安全性吗?你不希望任何人都能访问包含所有用户列表的配置文件,我想。

保护 config.toml 访问

这次,没有太多的事情要做!我们只需要在用户想要列出、下载或覆盖文件时添加一个检查。这意味着以下三个命令必须更新:

  • List

  • Retr

  • Stor

让我们从 List 开始更新。在第一个 add_file_info 函数调用之前,只需将 add_file_info 函数调用包裹在这个块中:

if self.is_admin || entry.path() != self.server_root.join(CONFIG_FILE) {

在第二个之前,添加以下内容:

if self.is_admin || path != self.server_root.join(CONFIG_FILE)

现在让我们更新retr函数。考虑以下条件:

if path.is_file() {

用以下内容替换它:

if path.is_file() && (self.is_admin || path != self.server_root.join(CONFIG_FILE)) {

最后,让我们更新stor函数。考虑以下条件:

if invalid_path(&path) {

用以下内容替换它:

if invalid_path(&path) || (!self.is_admin && path == self.server_root.join(CONFIG_FILE)) {

现在我们完成了!您现在有一个可配置的服务器,您可以轻松地根据您的需求进行扩展。

单元测试

一个好的软件需要测试来确保它在大多数情况下都能正常工作。因此,我们将通过开始为 FTP 的codec编写单元测试来为我们的 FTP 服务器添加测试。

单元测试仅验证程序的一个单元,这可能是一个函数。它们与稍后我们将看到的集成测试不同,集成测试测试整个软件。

让我们进入codec模块并为其添加一个新的内部模块:

#[cfg(test)]
mod tests {
}

我们再次使用#[cfg]属性;这次,只有在运行测试时才会编译以下模块。这是为了避免在最终二进制文件中添加无用的代码。

在这个新模块中,我们将添加一些我们稍后编写测试时需要的导入语句:

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use ftp::ResultCode;
    use super::{Answer, BytesMut, Command, Decoder, Encoder, FtpCodec};
}

如您所见,我们使用super来访问父模块(codec)中的某些类型:这在单元测试中非常常见,因为我们通常测试同一文件中的代码。

现在让我们添加一个test函数:

#[cfg(test)]
mod tests {
    // …

    #[test]
    fn test_encoder() {
    }
}

test_encoder()函数中,我们将编写测试FtpCodecEncoder实现的代码,以验证它是否按预期工作。

我们首先检查带有消息的Answer是否产生正确的输出:

#[cfg(test)]
mod tests {
    // …

    #[test]
    fn test_encoder() {
        let mut codec = FtpCodec;
        let message = "bad sequence of commands";
        let answer = Answer::new(ResultCode::BadSequenceOfCommands, message);
        let mut buf = BytesMut::new();
        let result = codec.encode(answer, &mut buf);
        assert!(result.is_ok());
        assert_eq!(buf, format!("503 {}\r\n", message));
    }
}

在这里,我们首先创建调用Encode::encode所需的对象,例如,一个codec和一个缓冲区。然后,我们调用codec.encode(),因为我们实际上想测试的是这个方法。之后,我们检查结果是否为Ok,并检查缓冲区是否相应地被填充。为此,我们使用一些宏:

  • assert!:这检查值是否为true。如果是false,它将引发恐慌并使测试失败。

  • assert_eq!:这检查两个值是否相等。

这是一个相当简单且有效的测试,但它并不测试函数的每一条路径。因此,让我们在这个函数中添加更多行来测试其他可能的路径:

#[cfg(test)]
mod tests {
    // …

    #[test]
    fn test_encoder() {
        // …
        let answer = Answer::new(ResultCode::CantOpenDataConnection, "");
        let mut buf = BytesMut::new();
        let result = codec.encode(answer, &mut buf);
        assert!(result.is_ok(), "Result is ok");
        assert_eq!(buf, format!("425\r\n"), "Buffer contains 425");
    }
}

在这里,我们使用空消息进行测试。其余部分基本上相同:我们创建必要的对象并使用 assert 宏。但这次,我们向 assert 宏添加了一个新参数;这是一个可选的消息,当测试失败时显示。

如果我们使用cargo test运行测试,我们会得到以下结果:

 Compiling ftp-server v0.0.1 (file:///path/to/FTP-server-rs)
    Finished dev [unoptimized + debuginfo] target(s) in 1.29 secs
     Running target/debug/deps/ftp_server-452667ddc2d724e8

running 1 test
test codec::tests::test_encoder ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

这显示了运行的测试以及它通过了。

让我们编写一个失败的test函数:

    #[test]
    fn test_dummy() {
        assert!(false, "Always fail");
    }

当我们运行cargo test时,我们看到以下内容:

 Finished dev [unoptimized + debuginfo] target(s) in 1.30 secs
     Running target/debug/deps/ftp_server-452667ddc2d724e8

running 2 tests
test codec::tests::test_encoder ... ok
test codec::tests::test_dummy ... FAILED

failures:

---- codec::tests::test_dummy stdout ----
    thread 'codec::tests::test_dummy' panicked at 'Always fail', src/codec.rs:102:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    codec::tests::test_dummy
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--bin ftp-server'

我们可以看到我们指定的消息(Always fail)被显示出来。我们还看到有1个测试失败了。

回溯

如输出中所述,我们可以设置环境变量RUST_BACKTRACE1以获取更多有关测试失败位置的信息。让我们这样做:

export RUST_BACKTRACE=1
 Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/ftp_server-452667ddc2d724e8

running 2 tests
test codec::tests::test_encoder ... ok
test codec::tests::test_dummy ... FAILED

failures:

---- codec::tests::test_dummy stdout ----
    thread 'codec::tests::test_dummy' panicked at 'Always fail', src/codec.rs:102:8
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
stack backtrace:
   0: std::sys::imp::backtrace::tracing::imp::unwind_backtrace
             at /checkout/src/libstd/sys/unix/backtrace/tracing/gcc_s.rs:49
   1: std::sys_common::backtrace::_print
             at /checkout/src/libstd/sys_common/backtrace.rs:68
   2: std::panicking::default_hook::{{closure}}
             at /checkout/src/libstd/sys_common/backtrace.rs:57
             at /checkout/src/libstd/panicking.rs:381
   3: std::panicking::default_hook
             at /checkout/src/libstd/panicking.rs:391
   4: std::panicking::rust_panic_with_hook
             at /checkout/src/libstd/panicking.rs:577
   5: std::panicking::begin_panic
             at /checkout/src/libstd/panicking.rs:538
   6: ftp_server::codec::tests::test_dummy
             at src/codec.rs:102
   7: <F as test::FnBox<T>>::call_box
             at /checkout/src/libtest/lib.rs:1491
             at /checkout/src/libcore/ops/function.rs:223
             at /checkout/src/libtest/lib.rs:142
   8: __rust_maybe_catch_panic
             at /checkout/src/libpanic_unwind/lib.rs:99

failures:
    codec::tests::test_dummy  test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--bin ftp-server'

这里的重要部分如下:

6: ftp_server::codec::tests::test_dummy
             at src/codec.rs:102

这显示了代码恐慌的文件、函数和行。

这个变量即使在测试代码之外也很有用:当调试引发恐慌的代码时,我们也可以使用这个变量。

测试失败

有时,我们想要测试一个函数是否会引发恐慌。为此,我们可以在 test 函数的顶部简单地添加 #[should_panic] 属性:

    #[should_panic]
    #[test]
    fn test_dummy() {
        assert!(false, "Always fail");
    }

这样做时,test 现在通过了:

 Finished dev [unoptimized + debuginfo] target(s) in 1.30 secs
     Running target/debug/deps/ftp_server-452667ddc2d724e8

running 2 tests
test codec::tests::test_dummy ... ok
test codec::tests::test_encoder ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

忽略测试

有时,我们有需要花费很多时间或我们想要避免始终运行特定测试的测试。为了避免默认运行测试,我们可以在函数上方添加 #[ignore] 属性:

    #[ignore]
    #[test]
    fn test_dummy() {
        assert!(false, "Always fail");
    }

当我们运行 test 时,我们会看到 test 函数没有运行:

 Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/ftp_server-452667ddc2d724e8

running 2 tests
test codec::tests::test_dummy ... ignored
test codec::tests::test_encoder ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out

如您所见,test_dummy() 测试函数被忽略了。要运行它,我们需要向运行测试的程序(而不是 cargo 本身)指定一个命令行参数:

cargo test -- --ignored

注意: 我们在 --ignored 前指定了 --,以便将后者发送给运行测试的程序(这并不是 cargo)。

使用该参数,我们看到测试确实运行了:

 Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/ftp_server-452667ddc2d724e8

running 1 test
test codec::tests::test_dummy ... FAILED

failures:

---- codec::tests::test_dummy stdout ----
    thread 'codec::tests::test_dummy' panicked at 'Always fail', src/codec.rs:102:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    codec::tests::test_dummy

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 1 filtered out

error: test failed, to rerun pass '--bin ftp-server'

为了结束这一节,让我们为解码器编写一个单元测试:

#[cfg(test)]
mod tests {
    // …

    #[test]
    fn test_decoder() {
        let mut codec = FtpCodec;
        let mut buf = BytesMut::new();
        buf.extend(b"PWD");
        let result = codec.decode(&mut buf);
        assert!(result.is_ok());
        let command = result.unwrap();
        assert!(command.is_none());

这里,我们测试在需要更多输入的情况下返回 None

        buf.extend(b"\r\n");
        let result = codec.decode(&mut buf);
        assert!(result.is_ok());
        let command = result.unwrap();
        assert_eq!(command, Some(Command::Pwd));

在这里,我们添加缺失的输出以检查命令是否正确解析:

        let mut buf = BytesMut::new();
        buf.extend(b"LIST /tmp\r\n");
        let result = codec.decode(&mut buf);
        assert!(result.is_ok());
        let command = result.unwrap();
        assert_eq!(command, Some(Command::List(Some(PathBuf::from("/tmp")))));
    }
}

最后,我们测试解析带有参数的命令是否有效。如果我们再次运行 cargo test,我们得到以下输出:

 Finished dev [unoptimized + debuginfo] target(s) in 1.70 secs
     Running target/debug/deps/ftp_server-452667ddc2d724e8

running 2 tests
test codec::tests::test_encoder ... ok
test codec::tests::test_decoder ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

集成测试

在上一节中,我们检查了我们的代码的一部分是否工作:现在,我们将通过编写集成测试来检查程序作为一个整体是否工作。这些测试位于 tests/ 目录中,所以我们首先创建它:

mkdir tests

在这个目录中,我们可以创建一个新的文件,tests/server.rs,我们将放入以下内容:

extern crate ftp;

use std::process::Command;
use std::thread;
use std::time::Duration;

use ftp::FtpStream;

我们导入 ftp 包,这是一个 FTP 客户端;它将有助于测试我们的 FTP 服务器。我们还需要在 Cargo.toml 中添加它:

[dev-dependencies]
ftp = "².2.1"

这里我们看到一个新的部分,dev-dependencies:它包含在主包本身之外需要的依赖项,例如在集成测试中。通过在这里放置依赖项而不是 [dependencies] 中,它将不会在主包中可用,这正是我们想要的。

让我们回到文件 tests/server.rs 并添加一个 test 函数:

#[test]
fn test_pwd() {
    let child =
        Command::new("./target/debug/ftp-server")
            .spawn().unwrap();
    let mut controller = ProcessController::new(child);

    thread::sleep(Duration::from_millis(100));
    assert!(controller.is_running(), "Server was aborted");

    let mut ftp = FtpStream::connect("127.0.0.1:1234").unwrap();

    ftp.quit().unwrap();
}

这里,我们不需要将代码放入内嵌的 tests 模块,因为集成测试是单独编译的。由于我们的包是一个二进制文件,我们需要使用 Command 对象来运行它。我们将子进程交给稍后创建的 ProcessController

注意:如果我们的包是一个库,我们会为它添加一个 extern crate,然后我们可以直接调用它的函数。

然后,我们调用 thread::sleep() 给我们的服务器一些启动时间。之后,我们使用 ftp 包连接到我们的服务器,然后退出。

拆卸

在 Rust 测试框架中,没有像许多其他语言的测试框架那样的setup()teardown()函数。在这里,我们需要在测试完成后运行一些代码:我们需要关闭我们的 FTP 服务器。所以,我们需要某种teardown函数。我们不能简单地在函数末尾说child.kill(),因为如果测试在那时崩溃,FTP 服务器将在测试结束后继续运行。为了确保清理代码始终被调用,无论函数如何结束,我们不得不使用我们在第六章,实现音乐播放器的引擎中发现的RAII模式。

让我们编写一个简单的teardown结构:

struct ProcessController {
    child: Child,
}

该结构包含将在析构函数中杀死的子进程。所以,如果测试崩溃,这个析构函数将被调用。如果函数正常结束,它也会被调用。

我们还将创建一个构造函数和utility方法,这些方法我们在test函数中使用:

impl ProcessController {
    fn new(child: Child) -> Self {
        ProcessController {
            child,
        }
    }

    fn is_running(&mut self) -> bool {
        let status = self.child.try_wait().unwrap();
        status.is_none()
    }
}

is_running()函数用于确保我们启动的 FTP 服务器实际上正在运行;如果应用程序的另一个实例已经运行,我们的实例将不会运行。这就是为什么我们在测试函数中使用了断言。

最后,我们需要创建一个析构函数:

impl Drop for ProcessController {
    fn drop(&mut self) {
        let _ = self.child.kill();
    }
}

我们现在准备好编写test函数:

#[test]
fn test_pwd() {
    // …

    let mut ftp = FtpStream::connect("127.0.0.1:1234").unwrap();

    let pwd = ftp.pwd().unwrap();
    assert_eq!("/", pwd);

    ftp.login("ferris", "").unwrap();

    ftp.cwd("src").unwrap();
    let pwd = ftp.pwd().unwrap();
    assert_eq!("/src", pwd);

    let _ = ftp.cdup();
    let pwd = ftp.pwd().unwrap();
    assert_eq!("/", pwd);

    ftp.quit().unwrap();
}

在这个函数中,我们发出一些 FTP 命令,并通过调用assert_eq!()宏来确保服务器状态正确。当我们运行cargo test时,我们看到以下输出:

 Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/ftp_server-47386d9089111729

running 2 tests
test codec::tests::test_decoder ... ok
test codec::tests::test_encoder ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/server-1b5cda64792f5f82

running 1 test
Waiting clients on port 1234...
New client: [address : 127.0.0.1:43280]
Waiting another client...
Received command: Pwd
Received command: User("ferris")
Received command: Cwd("src")
Received command: Pwd
Received command: CdUp
Received command: Pwd
Received command: Quit
test test_pwd ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

为我们的集成测试添加了一个新部分。

打印输出到 stdout

让我们看看当我们向测试中添加对println!()的调用会发生什么(例如,用于调试目的):

#[test]
fn test_pwd() {
    println!("Running FTP server");

    // …
}

它不会被打印到终端。为了看到它,我们需要向测试运行器传递另一个参数。让我们这样运行cargo test来查看输出到stdout

cargo run -- --nocapture

这次,我们看到以下输出:

…

     Running target/debug/deps/server-1b5cda64792f5f82

running 1 test
Running FTP server
Waiting clients on port 1234...
New client: [address : 127.0.0.1:43304]
Waiting another client...
Received command: Pwd
Received command: User("ferris")
Received command: Cwd("src")
Received command: Pwd
Received command: CdUp
Received command: Pwd
Received command: Quit
test test_pwd ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

文档

软件的一个重要方面是文档。描述如何使用项目,给出一些示例并详细说明完整的公共 API 很有用:让我们看看我们如何在 Rust 中文档化一个 crate。

文档化 crate

文档是用注释编写的,但这些文档注释以特殊符号开头。我们使用///来注释注释后的项,使用//!来注释此项内部的项。让我们先看看后者的一个例子。

在我们的 crate 根目录的顶部(具体来说,在main.rs文件中),我们将添加以下注释:

//! An FTP server, written using tokio and futures-await.

这里,我们使用//!形式,因为我们不能在 crate 之前写注释;我们只能从 crate 内部写注释。

模块文档化

模块文档化非常相似:我们在模块文件的顶部添加一个//!形式的注释。让我们在codec.rs中添加以下文档注释:

//! FTP codecs to encode and decode FTP commands and raw bytes.

标题

文档注释是用 Markdown 编写的,所以让我们看看一些 Markdown 格式化语法。我们可以通过在一行开头使用 # 来写标题。# 的数量越多,标题越小。

例如:

/// Some introduction text.
///
/// # Big Title
///
/// ## Less big title
///
/// ### Even less big title.
///
/// #### Small title
///
/// ...

我想你到这里应该明白了!

这里是一些常见的标题:

  • 示例

  • Panics

  • 失败

代码块

我们在文档注释中编写的代码必须在 rs` ```rs. Usually, the code blocks are written under an Examples` header. Let's see an example using all of these syntactic elements for a function that convert bytes to uppercase:


/// 将字节序列转换为大写。

///

/// # 示例

///

/// ```rs
/// let mut data = b"test";
/// to_uppercase(&mut data);
/// ```

fn to_uppercase(data: &mut [u8]) {

    for byte in data {

        if *byte >= 'a' as u8 && *byte <= 'z' as u8 {

            *byte -= 32;

        }

    }

}

```rs

Here, we start with a short description of the function. Then, we show a code example.

It's recommended to add comments in the code if needed, to help users understand it more easily, so don't hesitate to add some!

# Documenting an enumeration (or any type with public fields)

When we want to document an enumeration, we want not only to document the type, but also each variant. To do so, we can simply add a doc-comment before each variant. The same applies for a structure, for its fields.

Let's see an example for the `Command` type:

/// 由解析器解析的 FTP 命令。

[derive(Clone, Debug, PartialEq)]

pub enum Command {

Auth,

/// 将工作目录更改为作为参数指定的目录。

Cwd(PathBuf),

/// 获取文件列表。

List(Option<PathBuf>),

/// 创建一个新目录。

Mkd(PathBuf),

/// 无操作。

NoOp,

/// 指定用于数据通道的端口号。

Port(u16),

/// 进入被动模式。

Pasv,

/// 打印当前目录。

Pwd,

/// 终止连接。

Quit,

/// 获取文件。

Retr(PathBuf),

/// 删除一个目录。

Rmd(PathBuf),

/// 在服务器上存储一个文件。

存储路径(PathBuf),

Syst,

/// 指定传输类型。

Type(TransferType),

/// 进入父目录。

CdUp,

未知(String),

用户(String),

}


We see that the `enum` itself has a doc-comment and most of the variants also have documentation.

# Generating the documentation

We can easily generate the documentation by running the following command:

cargo doc


This will generate the documentation in the directory `target/doc/ftp_server`. Here is how it looks:

![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-prog-ex/img/00050.jpeg)*Figure 10.5*

# Warning about public items without documentation

When writing a library, it is very easy to forget to write the documentation about every item. But, we can use the help of the tools at our disposal. By adding the `#[warn(missing_docs)]` attribute in our crate's root, the compiler will warn us when public items do not have a doc-comment. In such a case, it will print something like this:

警告:缺少对包的文档

--> src/main.rs:9:1

|

9 | / #![feature(proc_macro, conservative_impl_trait, generators)]

10 | | #![warn(missing_docs)]

11 | |

12 | | extern crate bytes;

... |

528 | | }

529 | | }

| |_^

|

注意:这里定义了 lint 级别

--> src/main.rs:10:9

|

10 | #![warn(missing_docs)]

|         ^^^^^^^^^^^^

# Hiding items from the documentation

Sometimes, we intentionally do not want to have a public item show up in the documentation. In this case, we can use the `#[doc(hidden)]` attribute:

[doc(hidden)]

[derive(Clone, Copy, Debug, PartialEq)]

pub enum TransferType {

Ascii,

图片

未知,

}


For instance, this can be useful for something that is used by a macro of the crate but is not intended to be used directly by the user.

# Documentation tests

Writing documentation is a great thing. Showing code in your documentation is even better. However, how can you be sure that the code you're showing is still up to date? That it won't break when users copy/paste it to test it out? Here comes another wonderful feature from Rust: `doc tests`.

# Tags

First, any code blocks in documentation comments will be tested by default if they don't have `ignore` or any non-recognized tag. So, for example:

/// rsignore /// let x = 12; /// x += 1; ///


This block code won't be tested (luckily, because it wouldn't compile!). A few other examples:

/// # 一些文本

///

/// rstext /// this is just some text /// but it's rendered inside a code block /// nice, right? ///

///

/// # 为什么不是 C?

///

/// rsc-language /// int strlen(const char *s) { /// char *c = s; /// /// for (; *c; ++c); /// return c - s; /// } ///

///

/// # 或者一种未知语言?

///

/// rswhatever /// 010010000110100100100001 ///


A few other instructions might come in handy for you. Let's start with `ignore`!

# ignore

Just like this flag name states, `ignore` makes the block code ignored. As simple as that. It'll still get the Rust syntax color once rendered in the documentation. For example:

/// rsignore /// let x = 0; ///


However, once rendered, it'll have a graphical notification about the fact that this block code isn't tested:

![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-prog-ex/img/00051.jpeg)*Figure 10.6*

And when you hover over the ![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-prog-ex/img/00052.jpeg) sign:

![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-prog-ex/img/00053.jpeg)*Figure 10.7*

Now let's continue with `compile_fail`!

# compile_fail

The `compile_fail` flag ensures that the given code blocks don't compile. As simple as that. It's mostly used when you're showing bad code and demonstrating why it is bad. For example:

/// rscompile_fail /// let x = 0; /// x += 2; // Damn! `x` isn't mutable so you cannot update it... ///


Then you just write a small explanation about what went wrong and show a working example. It's very common in tutorials, to help users understand why it's wrong and how to fix it.

In addition to this, please note that there will be a graphical indication that this block is supposed to fail at compilation:

![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-prog-ex/img/00054.jpeg)*Figure 10.8*

And when you hover over the ![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-prog-ex/img/00055.jpeg) sign:

![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-prog-ex/img/00056.jpeg)*Figure 10.9*

Let's continue with `no_run`!

# no_run

The `no_run` flag tells `rustdoc` to only check if the code block compiles (and therefore, not to run it). It's mostly used in cases involving external resources (such as files). For example:

/// rsno_run /// use std::fs::File; /// /// let mut f = File::open("some-file.txt").expect("file not found..."); ///


If you run this test, it's very likely (but not certain, since there is a possibility that some funny user decided to suddenly add a `some-file.txt` file) to fail at execution. However, the code is perfectly fine so it'd be a shame to just `ignore` it, right?

Now, let's see what to do if you *want* the test to fail:

# should_panic

The `should_panic` flag ensures that your block code panics at execution. If it doesn't, then the test fails. Let's take the previous code block:

/// rsshould_panic /// use std::fs::File; /// /// let mut f = File::open("some-file.txt").expect("file not found..."); ///


Once again, the test should succeed (unless, again, you have a funny user who added the file). Quite useful if you want to show some *bad* behavior.

# Combining flags?

It's actually possible to combine flags, although it's not really useful. For example:

/// rsrust,ignore /// let x = 0; ///


You could just have written this as follows:

/// rsignore /// let x = 0; ///


For now, it's not really useful, but who knows what will happen in the future? At least now you know!

# About the doc blocks themselves

I suppose you noticed that we never added a function or anything. So how does it actually work?

Well first, it checks if the `main` function is defined. If not, it'll wrap the code into one. Observe the following code:

/// rs /// let x = 0; ///


When you write the preceding code, it gets transformed into this:

/// rs /// fn main() { /// let x = 0; /// } ///


Also, you can use all the public items defined in your crate in your code blocks. No need to import the crate with an `extern crate` (however, you still have to import the item!).

One last (very) important point remains to be talked about: hiding code blocks lines.

# Hiding code blocks lines

If you want to use `?`, you'll have to do it inside a function returning an `Option` or a `Result`. But still, inside a function. However, you don't necessarily want to show those lines to the user in order to focus on what you're trying to explain.

To put it simply, you just need to add a `#` at the beginning of the line. As simple as that. As always, let's show it with a small example:

/// rs /// # fn foo() -> std::io::Result<()> { /// let mut file = File::open("some-file.txt")?; /// write!(file, "Hello world!")?; /// # Ok(()) /// # } ///


The user will only see the following:

let mut file = File::open("some-file.txt")?;

write!(file, "Hello world!")?;


However, if they click on the Run button, they'll see the following:

fn main() {

use std::fs::File;

use std::io::prelude:😗;

fn foo() -> std::io::Result<()> {

let mut file = File::open("some-file.txt")?;

write!(file, "Hello world!")?;

Ok(())

}

}


(Don't forget that the `main` function is added as well!).

That's it for the doc tests. With all this knowledge, you should be able to write a nice API documentation which will always be up to date and tested (hopefully)!

# Fuzzing tests

There is another type of test that is very useful but is not integrated into the Rust standard library: fuzzing tests.

A fuzzing test will test a function's automatically generated input with the sole purpose of crashing this function or making it behave incorrectly. Fuzzing tests can be used to complement tests that are written manually because they can generate way more input than we can possibly write by hand. We will use `cargo-fuzz` to test our command parser.

First, we need to install it:

cargo install cargo-fuzz


Next, we will use the new `cargo fuzz` command to create a new fuzz test crate in our FTP server crate:

cargo fuzz init


This generated a few files. The most important of them and the one we will modify, is `fuzz/fuzz_targets/fuzz_target_1.rs`. Let's replace its content with the following:

![no_main]

[macro_use] extern crate libfuzzer_sys;

模块错误 {

include!("../../src/error.rs");

}

include!("../../src/cmd.rs");

fuzz_target!(|data: &[u8]| {

let _ = Command::new(data.to_vec());

});


Since our crate is a binary instead of a library, we cannot directly import functions from it. So, we use this little trick to get access to the functions we want:

模块错误 {

include!("../../src/error.rs");

}

include!("../../src/cmd.rs");


The `mod error` is needed because our `cmd` module depends on it. With that sorted, we include the `cmd` module with a macro. This macro will expand to the content of the file, similarly to the `#include` preprocessor directive in `C`. Finally, we have our `test` function:

fuzz_target!(|data: &[u8]| {

let _ = Command::new(data.to_vec());

});


Here, we just create a new command from the random input we receive. We ignore the result since there's no way we can possibly check if it is right, except by listing all possibilities (which would make a great unit test). So, if there's a bug in our command parser that causes a panic, the fuzzer could find it.

To run the fuzzer, issue the following command:

cargo fuzz run fuzz_target_1


Here's the output:

Fresh arbitrary v0.1.0

Fresh cc v1.0.3

Fresh libfuzzer-sys v0.1.0 (https://github.com/rust-fuzz/libfuzzer-sys.git#737524f7)

编译 ftp-server-fuzz v0.0.1 (file:///path/to/FTP-server-rs/fuzz)

Running `rustc --crate-name fuzz_target_1 fuzz/fuzz_targets/fuzz_target_1.rs --crate-type bin --emit=dep-info,link -C debuginfo=2 -C metadata=7eb012a2948092cc -C extra-filename=-7eb012a2948092cc --out-dir /path/to/FTP-server-rs/fuzz/target/x86_64-unknown-linux-gnu/debug/deps --target x86_64-unknown-linux-gnu -L dependency=/path/to/FTP-server-rs/fuzz/target/x86_64-unknown-linux-gnu/debug/deps -L dependency=/path/to/FTP-server-rs/fuzz/target/debug/deps --extern libfuzzer_sys=/path/to/FTP-server-rs/fuzz/target/x86_64-unknown-linux-gnu/debug/deps/liblibfuzzer_sys-44f07aaa9fd00b00.rlib --cfg fuzzing -Cpasses=sancov -Cllvm-args=-sanitizer-coverage-level=3 -Zsanitizer=address -Cpanic=abort -L native=/path/to/FTP-server-rs/fuzz/target/x86_64-unknown-linux-gnu/debug/build/libfuzzer-sys-b260d147c5e0139d/out`

Finished dev [unoptimized + debuginfo] target(s) in 1.57 secs

Fresh arbitrary v0.1.0

Fresh cc v1.0.3

Fresh libfuzzer-sys v0.1.0 (https://github.com/rust-fuzz/libfuzzer-sys.git#737524f7)

Fresh ftp-server-fuzz v0.0.1 (file:///path/to/FTP-server-rs/fuzz)

Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs

Running `fuzz/target/x86_64-unknown-linux-gnu/debug/fuzz_target_1 -artifact_prefix=/path/to/FTP-server-rs/fuzz/artifacts/fuzz_target_1/ /path/to/FTP-server-rs/fuzz/corpus/fuzz_target_1/`

INFO: Seed: 1369551667

INFO: Loaded 0 modules (0 guards):

Loading corpus dir: /path/to/FTP-server-rs/fuzz/corpus/fuzz_target_1

INFO: -max_len is not provided, using 64

INFO: A corpus is not provided, starting from an empty corpus

0 READ units: 1

1 INITED cov: 389 corp: 1/1b exec/s: 0 rss: 23Mb

4 NEW cov: 393 corp: 2/4b exec/s: 0 rss: 23Mb L: 3 MS: 3 ShuffleBytes-InsertByte-InsertByte-

5 NEW cov: 412 corp: 3/62b exec/s: 0 rss: 23Mb L: 58 MS: 4 ShuffleBytes-InsertByte-InsertByte-InsertRepeatedBytes-

7 NEW cov: 415 corp: 4/121b exec/s: 0 rss: 23Mb L: 59 MS: 1 InsertByte-

21 NEW cov: 416 corp: 5/181b exec/s: 0 rss: 23Mb L: 60 MS: 5 ChangeBit-InsertByte-ChangeBinInt-ChangeByte-InsertByte-

707 NEW cov: 446 corp: 6/241b exec/s: 0 rss: 23Mb L: 60 MS: 1 ChangeBit-

710 NEW cov: 447 corp: 7/295b exec/s: 0 rss: 23Mb L: 54 MS: 4 ChangeBit-InsertByte-EraseBytes-InsertByte-

767 NEW cov: 448 corp: 8/357b exec/s: 0 rss: 23Mb L: 62 MS: 1 CMP- DE: "\x01\x00"-

780 NEW cov: 449 corp: 9/421b exec/s: 0 rss: 23Mb L: 64 MS: 4 CopyPart-InsertByte-ChangeByte-CrossOver-

852 NEW cov: 450 corp: 10/439b exec/s: 0 rss: 23Mb L: 18 MS: 1 CrossOver-

1072 NEW cov: 452 corp: 11/483b exec/s: 0 rss: 23Mb L: 44 MS: 1 InsertRepeatedBytes-

85826 NEW cov: 454 corp: 12/487b exec/s: 85826 rss: 41Mb L: 4 MS: 5 ChangeBit-InsertByte-InsertByte-EraseBytes-CMP- DE: "NOOP"-

92732 NEW cov: 456 corp: 13/491b exec/s: 92732 rss: 43Mb L: 4 MS: 1 CMP- DE: "PASV"-

101858 NEW cov: 477 corp: 14/495b exec/s: 50929 rss: 46Mb L: 4 MS: 2 ChangeByte-CMP- DE: "STOR"-

105338 NEW cov: 497 corp: 15/499b exec/s: 52669 rss: 47Mb L: 4 MS: 2 ShuffleBytes-CMP- DE: "LIST"-

108617 NEW cov: 499 corp: 16/503b exec/s: 54308 rss: 48Mb L: 4 MS: 1 CMP- DE: "AUTH"-

108867 NEW cov: 501 corp: 17/507b exec/s: 54433 rss: 48Mb L: 4 MS: 1 CMP- DE: "QUIT"-

115442 NEW cov: 503 corp: 18/511b exec/s: 57721 rss: 50Mb L: 4 MS: 1 CMP- DE: "SYST"-

115533 NEW cov: 505 corp: 19/515b exec/s: 57766 rss: 50Mb L: 4 MS: 2 ChangeBinInt-CMP- DE: "CDUP"-

123001 NEW cov: 513 corp: 20/518b exec/s: 61500 rss: 52Mb L: 3 MS: 5 PersAutoDict-EraseBytes-ChangeByte-ChangeBinInt-CMP- DE: "\x01\x00"-"RMD"-

127270 NEW cov: 515 corp: 21/521b exec/s: 63635 rss: 54Mb L: 3 MS: 4 EraseBytes-ChangeByte-InsertByte-CMP- DE: "PWD"-

131072 pulse cov: 515 corp: 21/521b exec/s: 65536 rss: 55Mb

148469 NEW cov: 527 corp: 22/525b exec/s: 49489 rss: 59Mb L: 4 MS: 3 ChangeBit-ChangeBit-CMP- DE: "USER"-

151237 NEW cov: 528 corp: 23/529b exec/s: 50412 rss: 60Mb L: 4 MS: 1 CMP- DE: "TYPE"-

169842 NEW cov: 536 corp: 24/532b exec/s: 56614 rss: 65Mb L: 3 MS: 1 ChangeByte-

262144 pulse cov: 536 corp: 24/532b exec/s: 52428 rss: 90Mb

274258 NEW cov: 544 corp: 25/535b exec/s: 54851 rss: 94Mb L: 3 MS: 2 ChangeBit-CMP- DE: "MKD"-

355992 NEW cov: 566 corp: 26/539b exec/s: 50856 rss: 116Mb L: 4 MS: 1 InsertByte-

356837 NEW cov: 575 corp: 27/558b exec/s: 50976 rss: 116Mb L: 19 MS: 1 InsertRepeatedBytes-

361667 NEW cov: 586 corp: 28/562b exec/s: 51666 rss: 117Mb L: 4 MS: 1 PersAutoDict- DE: "MKD"-

线程 '' 在 fuzz/fuzz_targets/../../src/cmd.rs:85:46 处崩溃,错误信息:'index out of bounds: the len is 0 but the index is 0'

注意:使用RUST_BACKTRACE=1运行以获取回溯信息。

10969 ERROR: libFuzzer: 致命信号

#0 0x55e90764cf73  (/path/to/FTP-server-rs/fuzz/target/x86_64-unknown-linux-gnu/debug/fuzz_target_1+0x110f73)

#1 0x55e9076aa701  (/path/to/FTP-server-rs/fuzz/target/x86_64-unknown-linux-gnu/debug/fuzz_target_1+0x16e701)

#2 0x55e9076aa64b  (/path/to/FTP-server-rs/fuzz/target/x86_64-unknown-linux-gnu/debug/fuzz_target_1+0x16e64b)

#3 0x55e907683059  (/path/to/FTP-server-rs/fuzz/target/x86_64-unknown-linux-gnu/debug/fuzz_target_1+0x147059)

#4 0x7f4bda433d9f  (/usr/lib/libpthread.so.0+0x11d9f)

#5 0x7f4bd9e8789f  (/usr/lib/libc.so.6+0x3489f)

#6 0x7f4bd9e88f08  (/usr/lib/libc.so.6+0x35f08)

#7 0x55e9076c2b18  (/path/to/FTP-server-rs/fuzz/target/x86_64-unknown-linux-gnu/debug/fuzz_target_1+0x186b18)

NOTE: libFuzzer 具有基本的信号处理器。

将 libFuzzer 与 AddressSanitizer 或类似工具结合使用,以获得更好的崩溃报告。

SUMMARY: libFuzzer: 致命信号

MS: 2 CopyPart-InsertByte-; base unit: 6e9816a8e9d0388eecdb52866188c04e75e4b1b3

0x54,0x59,0x50,0x45,0x20,

TYPE

artifact_prefix='/path/to/FTP-server-rs/fuzz/artifacts/fuzz_target_1/'; 测试单元写入到/path/to/FTP-server-rs/fuzz/artifacts/fuzz_target_1/crash-601e8dbb61bd6c7d63cff0bd3f749f7cb53922bc

Base64: VFlQRSA=

10969LeakSanitizer has encountered a fatal error.

10969HINT:对于调试,尝试设置环境变量 LSAN_OPTIONS=verbosity=1:log_threads=1

10969HINT:LeakSanitizer 在 ptrace(strace、gdb 等)下不工作

MS:2 CopyPart-InsertByte-; 基础单元:6e9816a8e9d0388eecdb52866188c04e75e4b1b3

0x54,0x59,0x50,0x45,0x20,

TYPE

artifact_prefix='/path/to/FTP-server-rs/fuzz/artifacts/fuzz_target_1/'; 测试单元写入到 /path/to/FTP-server-rs/fuzz/artifacts/fuzz_target_1/crash-601e8dbb61bd6c7d63cff0bd3f749f7cb53922bc

Base64:VFlQRSA=


There's actually a bug in our parser! We can see where, thanks to this line:

线程 '' 在 fuzz/fuzz_targets/../../src/cmd.rs:85:46 处恐慌:索引越界:长度为 0 但索引为 0


The corresponding line in the source code is:

match TransferType::from(data?[0]) {


And indeed, if the data is empty, this will panic. Let's fix that:

impl Command {

pub fn new(input: Vec<u8>) -> Result<Self> {

    // …

    let command =

        match command.as_slice() {

            // …

            b"TYPE" => {

                let error = Err("command not implemented for that

                parameter".into());

                let data = data?;

                if data.is_empty() {

                    return error;

                }

                match TransferType::from(data[0]) {

                    TransferType::Unknown => return error,

                    typ => {

                        Command::Type(typ)

                    },

                }

            },

            // …

        };

    Ok(command)

}

}


The fix is simple: we check if the data is empty, in which case we return an error.

Let's try the fuzzer to see if it can find another bug. Here's the output:

INFO:种子:81554194

INFO:加载了 0 个模块(0 个守卫):

加载语料库目录:/home/bouanto/Ordinateur/Programmation/Rust/Projets/FTP-server-rs/fuzz/corpus/fuzz_target_1

INFO:未提供-max_len,使用 64

0 READ units:27

27 INITED cov:595 corp:23/330b exec/s:0 rss:28Mb

21494 NEW cov:602 corp:24/349b exec/s:0 rss:28Mb L:19 MS:2 ShuffleBytes-CMP- DE:“STOR”-

21504 NEW cov:606 corp:25/354b exec/s:0 rss:28Mb L:5 MS:2 InsertByte-PersAutoDict- DE:“STOR”-

24893 NEW cov:616 corp:26/359b exec/s:0 rss:29Mb L:5 MS:1 CMP- DE:“TYPE”-

25619 NEW cov:620 corp:27/365b exec/s:0 rss:29Mb L:6 MS:2 PersAutoDict-InsertByte- DE:“TYPE”-

25620 NEW cov:621 corp:28/379b exec/s:0 rss:29Mb L:14 MS:3 PersAutoDict-InsertByte-CMP- DE:“TYPE”-"\x00\x00\x00\x00\x00\x00\x00\x00"-

32193 NEW cov:628 corp:29/398b exec/s:0 rss:31Mb L:19 MS:1 CMP- DE:“CWD”-

34108 NEW cov:662 corp:30/417b exec/s:0 rss:31Mb L:19 MS:1 CMP- DE:“USER”-

35745 NEW cov:666 corp:31/421b exec/s:0 rss:31Mb L:4 MS:3 ShuffleBytes-EraseBytes-PersAutoDict- DE:“CWD”-

36518 NEW cov:673 corp:32/426b exec/s:0 rss:32Mb L:5 MS:1 PersAutoDict- DE:“USER”-

36634 NEW cov:685 corp:33/433b exec/s:0 rss:32Mb L:7 MS:2 CMP-CMP- DE:"\xff\xff"-"RETR"-

37172 NEW cov:688 corp:34/437b exec/s:0 rss:32Mb L:4 MS:5 EraseBytes-ChangeBinInt-InsertByte-ChangeBit-CMP- DE:“RETR”-

39248 NEW cov:692 corp:35/442b exec/s:0 rss:32Mb L:5 MS:1 PersAutoDict- DE:“RETR”-

65735 NEW cov:699 corp:36/492b exec/s:65735 rss:39Mb L:50 MS:3 InsertRepeatedBytes-ChangeBit-CMP- DE:“LIST”-

69797 NEW cov:703 corp:37/497b exec/s:69797 rss:40Mb L:5 MS:5 ChangeByte-CopyPart-CopyPart-EraseBytes-PersAutoDict- DE:“LIST”-

131072 pulse cov:703 corp:37/497b exec/s:65536 rss:55Mb

217284 NEW cov: 707 corp: 38/511b exec/s: 54321 rss: 75Mb L: 14 MS: 2 CMP-ShuffleBytes- DE: "LIST"-

219879 NEW cov: 708 corp: 39/525b exec/s: 54969 rss: 76Mb L: 14 MS: 2 ChangeByte-ChangeBit-

262144 脉冲 cov: 708 corp: 39/525b exec/s: 52428 rss: 86Mb

524288 脉冲 cov: 708 corp: 39/525b exec/s: 52428 rss: 148Mb

1048576 pulse cov: 708 corp: 39/525b exec/s: 52428 rss: 273Mb

2097152 pulse cov: 708 corp: 39/525b exec/s: 51150 rss: 522Mb

4194304 pulse cov: 708 corp: 39/525b exec/s: 50533 rss: 569Mb

8388608 pulse cov: 708 corp: 39/525b exec/s: 50533 rss: 569Mb

12628080 NEW cov: 835 corp: 40/530b exec/s: 50311 rss: 570Mb L: 5 MS: 3 ChangeBit-ChangeBinInt-ShuffleBytes-

12628883 NEW cov: 859 corp: 41/540b exec/s: 50314 rss: 570Mb L: 10 MS: 1 CopyPart-

12628893 NEW cov: 867 corp: 42/604b exec/s: 50314 rss: 570Mb L: 64 MS: 1 CrossOver-

12643279 NEW cov: 868 corp: 43/608b exec/s: 50371 rss: 570Mb L: 4 MS: 2 EraseBytes-EraseBytes-

12670956 NEW cov: 871 corp: 44/652b exec/s: 50281 rss: 570Mb L: 44 MS: 4 EraseBytes-InsertByte-ChangeBinInt-ChangeBinInt-

12671130 NEW cov: 872 corp: 45/697b exec/s: 50282 rss: 570Mb L: 45 MS: 3 ChangeBit-CMP-InsertByte- DE: "\xff\xff\xff\xff"-

12671140 NEW cov: 873 corp: 46/750b exec/s: 50282 rss: 570Mb L: 53 MS: 3 ChangeBinInt-CMP-CopyPart- DE: "\x00\x00\x00\x00\x00\x00\x00\x00"-

12671906 NEW cov: 874 corp: 47/803b exec/s: 50285 rss: 570Mb L: 53 MS: 4 ChangeBit-ChangeByte-PersAutoDict-ShuffleBytes- DE: "CWD"-

12687428 NEW cov: 875 corp: 48/856b exec/s: 50346 rss: 574Mb L: 53 MS: 1 ShuffleBytes-

12699014 NEW cov: 945 corp: 49/862b exec/s: 50392 rss: 574Mb L: 6 MS: 2 InsertByte-ChangeBit-

13319888 NEW cov: 946 corp: 50/869b exec/s: 50074 rss: 579Mb L: 7 MS: 1 InsertByte-

13424473 NEW cov: 1015 corp: 51/878b exec/s: 50091 rss: 580Mb L: 9 MS: 1 CopyPart-

13432333 NEW cov: 1018 corp: 52/888b exec/s: 50120 rss: 580Mb L: 10 MS: 1 CopyPart-

13651748 NEW cov: 1019 corp: 53/901b exec/s: 50006 rss: 582Mb L: 13 MS: 1 CopyPart-

13652268 NEW cov: 1020 corp: 54/920b exec/s: 50008 rss: 582Mb L: 19 MS: 1 CopyPart-

13652535 NEW cov: 1025 corp: 55/978b exec/s: 50009 rss: 582Mb L: 58 MS: 3 InsertRepeatedBytes-ChangeBit-InsertByte-

13662779 NEW cov: 1028 corp: 56/997b exec/s: 50046 rss: 582Mb L: 19 MS: 2 ChangeBit-ShuffleBytes-

16777216 pulse cov: 1028 corp: 56/997b exec/s: 48913 rss: 589Mb

33554432 pulse cov: 1028 corp: 56/997b exec/s: 46154 rss: 589Mb

67108864 pulse cov: 1028 corp: 56/997b exec/s: 45343 rss: 589Mb

134217728 pulse cov: 1028 corp: 56/997b exec/s: 44325 rss: 589Mb

268435456 pulse cov: 1028 corp: 56/997b exec/s: 43819 rss: 589Mb

^C16792 libFuzzer: 运行中断;退出


因此,我们运行了很长时间的 fuzzer,但没有发现 panic,所以我们用*Ctrl* + *C*结束它。我们无法确定是否还有 bug 遗留,但所有这些测试使我们更加自信。

# 摘要

在本章中,我们完成了我们的 FTP 服务器。然后,我们学习了如何进行不同类型的测试。我们看到了如何通过编写单元测试来测试单个函数或类型。我们学习了如何通过编写集成测试来整体测试一个程序。我们还学习了关于文档和模糊测试的内容,以确保我们的示例是最新的,并找到我们应用程序中的更多错误。

在下一章和最终章节中,我们将学习 Rust 的良好实践和常见惯用法。


# 第十一章:Rust 最佳实践

Rust 是一种强大的语言,但通过实践可以轻松避免的一些事情,在开始时可能会让你的生活变得非常困难。本章旨在向你展示一些良好的实践和技巧。

在本章中,我们将涵盖以下主题:

+   最佳实践

+   API 小贴士和改进

+   使用小贴士

+   代码可读性

现在让我们开始吧!

# Rust 最佳实践

让我们从一些基本(也许很明显)的事情开始。

# 切片

首先,让我们回顾一下;切片是对数组的常量视图,`&[T]` 是 `Vec<T>` 的常量视图,而 `&str` 是 `String` 的常量视图(就像 `Path` 是 `PathBuf` 的常量视图,`OsStr` 是 `OsString` 的常量视图)。现在你有了这个概念,让我们继续吧!

当一个函数期望 `Vec` 或 `String` 类型的常量参数时,总是这样写:

```rs
fn some_func(v: &[u8]) {
    // some code...
}

而不是:

fn some_code(v: &Vec<u8>) {
    // some code
}

然后:

fn some_func(s: &str) {
    // some code...
}

而不是:

fn some_func(s: &String) {
    // some code...
}

你可能想知道为什么会这样。所以,让我们想象一下你的函数以 ASCII 字符显示你的 Vec

fn print_as_ascii(v: &[u8]) {
    for c in v {
        print!("{}", *c as char);
    }
    println!("");
}

现在你只想打印出你的 Vec 的一部分:

let v = b"salut!";

print_as_ascii(&v[2..]);

现在,如果 print_as_ascii 只接受 Vec 的引用,你就必须进行一个(无用的)分配,如下所示:

let v = b"salut!";

print_as_ascii(&v[2..].to_vec());

API 小贴士和改进

当编写公共 API(无论是为你还是其他用户)时,一些小贴士真的可以让每个人的生活变得更轻松。这正是泛型发挥作用的地方。让我们从 Option 参数开始:

解释 Some 函数

通常,当一个函数期望一个 Option 参数时,它看起来是这样的:

fn some_func(arg: Option<&str>) {
    // some code
}

你可以这样调用它:

some_func(Some("ratatouille"));
some_func(None);

现在,如果我说你可以去掉 Some,怎么样?不错,对吧?实际上,这实际上非常简单:

fn some_func<'a, T: Into<Option<&'a str>>>(arg: T) {
    // some code
}

你现在可以这样调用它:

some_func(Some("ratatouille")); // If you *really* like to write "Some"...
some_func("ratatouille");
some_func(None);

更好!然而,为了让用户的生活变得更轻松,编写函数的人需要写更多的代码。你不能直接使用 arg;你需要添加一个额外的步骤。以前,你只是这样做:

fn some_func(arg: Option<&str>) {
    if let Some(a) = arg {
        println!("{}", a);
    } else {
        println!("nothing...");
    }
}

现在,你需要在能够使用 arg 之前添加一个 .into 调用:

fn some_func<'a, T: Into<Option<&'a str>>>(arg: T) {
    let arg = arg.into();
    if let Some(a) = arg {
        println!("{}", a);
    } else {
        println!("nothing...");
    }
}

就这样。正如我们之前所说的,这不需要太多,而且让用户的生活变得更轻松,所以为什么不这样做呢?

使用 Path 函数

就像上一个部分一样,这将向你展示一些小贴士,使你的 API 通过 自动转换Path 来使用起来更舒适。

那么,让我们用一个接收 Path 作为参数的函数的例子来说明:

use std::path::Path;

fn some_func(p: &Path) {
    // some code...
}

这里没有什么新的。你可以像这样调用这个函数:

some_func(Path::new("tortuga.txt"));

这里令人烦恼的是,你必须自己构建 Path 然后才能将其发送到函数。这太烦人了,但我们能做得更好!

fn some_func<P: AsRef<Path>>(p: P) {
    // some code...
}

就这样...你现在可以这样调用函数:

some_func(Path::new("tortuga.txt")); // If you *really* like to build the "Path" by yourself...
some_func("tortuga.txt");

就像对于 Into 特质一样,你需要添加一行代码才能使其工作:

fn some_func<P: AsRef<Path>>(p: P) {
    let p: &Path = p.as_ref();
    // some code...
}

就这样!现在,只要给定的类型实现了 AsRef<Path>,你就可以这样发送。为了信息,这里是一个(非详尽的)实现了此特质的类型列表:

  • OsStr / OsString

  • &str / String

  • Path(是的,Path 也实现了 AsRef<Path>!)/ PathBuf

  • Iter

这已经很多了,所以你应该能够很容易地做到这一点!

使用技巧

现在你已经看到了一些关于如何通过一些小技巧使用户的代码更美观的例子,那么我们来看看可能使 你的 代码更好的其他一些事情。

构建者模式

构建者模式旨在能够通过多个可以链式调用的调用来 构建 一个最终对象。Rust 标准库中的 OpenOptions 类型是一个很好的例子。

当你需要玩转 File 时,强烈建议你使用 OpenOptions

use std::fs::OpenOptions;

let file = OpenOptions::new()
                       .read(true)
                       .write(true)
                       .create(true)
                       .open("foo.txt");

要创建这样的 API,你有两种方法:

  • 玩转可变借用

  • 玩转移动语义

让我们从可变借用开始!

玩转可变借用

第一个例子工作方式与 OpenOptions 相同:

struct Number(u32);

impl Number {
    fn new(nb: u32) -> Number {
        Number(nb)
    }

    fn add(&mut self, other: u32) -> &mut Number {
        self.0 += other;
        self
    }

    fn sub(&mut self, other: u32) -> &mut Number {
        self.0 -= other;
        self
    }

    fn compute(&self) -> u32 {
        self.0
    }
}

如果你想知道 self.0,只需记住这是你访问元组字段的方式。

然后,你可以按照以下方式调用它:

let nb = Number::new(0).add(10).sub(5).add(12).compute();
assert_eq!(nb, 17);

这是做这件事的第一种方法。

你会注意到你需要添加一个 结束 方法,这样你就可以将你的可变借用转换为对象(否则,你将有一个借用问题)。

让我们现在看看做这件事的第二种方法!

玩转移动语义

我们不再每次都取 &mut,而是每次直接获取对象的拥有权:

struct Number(u32);

impl Number {
    fn new(nb: u32) -> Number {
        Number(nb)
    }

    fn add(mut self, other: u32) -> Number {
        self.0 += other;
        self
    }

    fn sub(mut self, other: u32) -> Number {
        self.0 -= other;
        self
    }
}

然后,就不再需要 结束 方法了:

let nb = Number::new(0).add(10).sub(5).add(12);
assert_eq!(nb.0, 17);

我通常更喜欢这种方式来做构建者模式,但这更多的是个人意见,而不是经过深思熟虑的决定。选择在你所处的情境中看起来最合适的方式!

代码可读性

现在,我们将讨论 Rust 的语法本身。一些事情可以提高代码的可读性,并且很重要。让我们从大数字开始。

大数字格式化

在代码中看到巨大的常量数字并不罕见,例如:

let x = 1000000000;

然而,这对我们来说(人类大脑在解析这样的数字方面效率不高)相当难以阅读。在 Rust 中,你可以在数字中插入 _ 字符而不会出现任何问题:

let x = 1_000_000_000;

这已经很好了,对吧?

指定类型

Rust 编译器在大多数情况下可以自动检测变量的类型。然而,对于阅读代码的人来说,并不总是很明显代码返回了什么。举个例子?当然可以!

let x = "a 10 11 coucou 12 14".split(' ')
                              .filter_map(|e| e.parse::<u32>().ok())
                              .filter(|x| x % 2 == 0)
                              .map(|s| format!("{}", s))
                              .collect::<Vec<_>>()
                              .join("::");

仔细阅读代码后,你会猜到 x 是一个 String。然而,你需要阅读所有那些闭包才能得到它,即使如此,你真的确定类型吗?

在这种情况下,强烈建议你只添加类型注解:

let x: String = "a 10 11 coucou 12 14".split(' ')
                                      .filter_map(|e| e.parse::<u32>().ok())
                                      .filter(|x| x % 2 == 0)
                                      .map(|s| format!("{}", s))
                                      .collect::<Vec<_>>()
                                      .join("::");

这并不花费太多,并且允许读者(包括你自己)更快地阅读代码。

匹配

在 Rust 中,通常使用 match 块通过模式匹配。然而,使用 if let 条件通常是一个更好的解决方案。让我们用一个简单的例子来说明:

enum SomeEnum {
    Ok,
    Err,
    Unknown,
}

现在假设你想在得到 Ok 时执行一个动作。使用 match,你会这样做:

let x = SomeEnum::Err;

match x {
    SomeEnum::Ok => {
        // Huge code doing a lot of things...
    }
    _ => {}
}

这并不是一个问题,对吧?现在让我们用 if let 来看看:

let x = SomeEnum::Err;

if let SomeEnum::Ok = x {
    // Huge code doing a lot of things...
}

就这样。它基本上使代码变得更短,同时大大提高了可读性。当你只需要获取一个值时,通常使用 if let 而不是 match 是更好的解决方案。

摘要

通过这一章的最后部分,你应该对 Rust 中的良好实践有一个全面的了解。请记住,良好的代码易于阅读且注释详尽。即使复杂的特性,在制作良好的文档后也会变得容易理解得多。

posted @ 2025-09-06 13:42  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报