【译】使用rust来写一个容器(二)

  1. 1. 开始项目
    1. 1.1. 创建项目
    2. 1.2. 解析参数
      1. 1.2.1. structopt库介绍
      2. 1.2.2. 创建我们自己的参数解析
      3. 1.2.3. 测试我们的代码
        1. 1.2.3.1. Patch for this step
    3. 1.3. 添加日志记录
      1. 1.3.1. 添加日志记录功能
      2. 1.3.2. 记录日志信息
        1. 1.3.2.1. Patch for this step
    4. 1.4. 准备错误处理
      1. 1.4.1. Errcode 枚举
      2. 1.4.2. Linux返回码
      3. 1.4.3. Rust中的Result
        1. 1.4.3.1. Patch for this step
    5. 1.5. 校验参数
      1. 1.5.0.1. Patch for this step

本文章为对 Litchi Pi《Writing a container in Rust》的翻译转载,不享受任何著作权利,不用于任何商业目的,不以任何许可证进行授权,不对任何转载行为尤其是商业转载行为负责。一切权利均由原作者 Litchi Pi 保有。个人翻译能力有限,如有疑问可查看原文。

开始项目

在这篇文章中,我们将为我们的实现做好准备。
每个程序员都各自有不同的习惯,一些人可能会直接通过Linux的系统调用来创建一个隔离的box。而我则更喜欢创建一个干净的基础环境来做我自己的实现,我发现这么做更有利于以后的阅读和理解,并且做干净的实现总是好事。
同样的,这也能提供一些rust新手感兴趣的额外的一些tips和工具,比如参数解析错误处理日志记录等等.

在阅读这篇文章之前,我假定你们已经安装好了rustc以及cargo,如果你没有安装这两个工具,请按照本书说明进行操作。

创建项目

我猜你已经听说过Rust的吉祥物是Ferris这只又小又可爱的螃蟹了。
好,让我们吧Ferris放进容器里吧!:D

我们将创建一个名为Crabcan的Rust binary项目,目标是尽可能对这个项目的不同部分进行拆分,以便于我们在代码中进行搜索和调整,以及在数月的暂停后也能对项目很好的重新理解。

首先我们运行cargo new --bin crabcan来创建这个项目。
这个命令会生成一个Cargo.toml文件,我们可以通过这个文件来为项目添加描述、添加依赖项、调整Rust编译器配置。它能避免我们手动在Makefile中创建rustc命令,是一个很方便的文件。你现在可以在这个文件中修改作者的名字和邮箱、版本等,不过目前我们还不会在这个文件中添加任何依赖项。
在文件夹src/中,您将放置所有源代码。不过目前只有一个写了Hello World!代码的main.rs文件在这个文件夹里。

解析参数

OK,现在我们直接打开我们的项目。首先我们要从命令行获取参数。
目标是在调用我们的工具时从给出的文本形式的的flag中获取配置。

命令执行例子

1
crabcan --mount ./mountdir/ --uid 0 --debug --command "bash"

这个命令会调用crabcan,以root身份将mountdir挂载到容器根目录,UID为0,输出所有debug消息,并将在容器内执行命令bash

structopt库介绍

structopt库是一个用于解析来自命令行的参数非常有用的工具(底层使用了clap库)。 使用方法非常简单,我们只需要定义一个包含所有参数的结构体

1
2
3
4
5
6
7
8
9
10
#[derive(Debug, StructOpt)]
#[structopt(name = "example", about = "An example of StructOpt usage.")]
struct Opt {
/// Activate debug mode
// short and long flags (-d, --debug) will be deduced from the field's name
#[structopt(short, long)]
debug: bool

// etc ...
}

structopt的详细用法和所有功能可在其文档中找到。值得注意的是,结构体中定义的参数上方的/// text部分将用作帮助信息(例:当输入crabcan --help时出现的信息)。

创建我们自己的参数解析

我们需要先创建一个新文件src/cli.rs,这个文件中包含了与命令行相关的全部内容。为了在我们的项目中使用它,我们必须先将他作为一个模块来引入到我们的项目中。

我们先在src/main.rs中把文本替换为以下内容:

1
2
3
4
5
mod cli;

fn main() {
let args = cli::parse_args();
}

我们期望src/cli.rs能提供一个基础的parse_args函数,这个函数会返回包含所有我们通过命令行定义的配置的结构体。
注意:由于args并没有被使用,你会看见编译器出现的对应warning

现在让我们在src/cli.rs中实现这个parse_args函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use std::path::PathBuf;
use structopt::StructOpt;

#[derive(Debug, StructOpt)]
#[structopt(name = "crabcan", about = "A simple container in Rust.")]
pub struct Args {
/// Activate debug mode
#[structopt(short, long)]
debug: bool,

/// Command to execute inside the container
#[structopt(short, long)]
pub command: String,

/// User ID to create inside the container
#[structopt(short, long)]
pub uid: u32,

/// Directory to mount as root of the container
#[structopt(parse(from_os_str), short = "m", long = "mount")]
pub mount_dir: PathBuf,
}

pub fn parse_args() -> Args {
let args = Args::from_args();

// If args.debug: Setup log at debug level
// Else: Setup log at info level

// Validate arguments

args
}

在这里 我们必须导入我们必需的依赖库structopt以及在标准库中的PathBuf
接下来我们定义Args结构体,它包含了所有参数以及用于解析参数的信息,让我们先来看看我们需要哪些参数:

  • debug: 用于显示调试消息或仅显示正常日志
  • command: 在容器内执行的命令(带参数)
  • uid: 在容器内作为应用运行用户的userID
  • mount_dir: 用作容器内根目录/的文件夹。

注意:这个参数会以命令行中mount的形式来传递

这些加上了宏属性(macro attribute)structopt(short, long)定义的参数,会根据字段名称来自动创建对应的short命令行参数和long命令行参数。(如字段 toto 将被定义为参数 -t 和 –toto)。

终于,我们创建了parse_args函数,它通过结构体的from_args函数(由derive(StructOpt)宏生成)中来获取命令行参数。

在为参数验证和日志初始化设置一些占位符后,我们将参数返回。

最后我们把刚刚引入的包在Cargo.toml文件中作为依赖引入:

1
2
3
# ...
[dependencies]
structopt = "0.3.23"

测试我们的代码

让我们使用cargo run来运行我们的代码

1
2
3
4
5
6
7
8
9
error: The following required arguments were not provided:
--command <command>
--mount <mount-dir>
--uid <uid>

USAGE:
crabcan [FLAGS] --command <command> --mount <mount-dir> --uid <uid>

For more information try --help

就是这样,我们的参数解析功能运行成功了!现在如果我们尝试运行cargo run -- --mount ./ --uid 0 --command "bash" --debug的话,不会出现任何错误,你也可以在我们的src/main.rs中添加println!("{:?}", args)来得到还不错的输出:

1
Args { debug: true, command: "bash", uid: 0, mount_dir: "./" }

Patch for this step

这一步的代码可以在github litchipi/crabcan branch “step1”中找到.
应用于cargo new --bin创建的新项目的原始补丁可以在此处找到

添加日志记录

现在我们已经成功获得了用户的输入信息,让我们用某些方式来为他提供输出。其实需要简单的文本就足够满足需求,但是我希望能将调试信息与基础的输出信息以及错误信息分开。因此,尽管有很多工具可供选择,但我选择了log库和env_logger库来实现这个功能。

log库是一个非常流行的日志工具。 它提供了一个Logtrait(请参阅此处来获取trait的解释),它定义了日志记录工具必须具有的所有功能,并允许任何其他crate实现这些功能。我选择了env_logger库来实现这些功能。

我们在Cargo.toml添加下面的依赖:

1
2
3
# ...
log = "0.4.14"
env_logger = "0.9.0"

添加日志记录功能

日志记录工具必须设置一个详细程度等级来进行初始化,这个等级定义了是否显示调试信息、仅显示错误信息或者是完全不显示日志信息。在我们的例子中,我们希望它在默认的情况下显示普通信息,并在我们通过命令行传递了--debug标志的情况下提高debug信息的详细程度。

让我们在src/cli.rs中初始化我们的日志记录器:

1
2
3
4
5
6
pub fn setup_log(level: log::LevelFilter){
env_logger::Builder::from_default_env()
.format_timestamp_secs()
.filter(None, level)
.init();
}

诚然,将它单独作为一个函数可能并不是很有必要,但是这么做的话代码会更有可读性,不是吗?
如果你对Rust代码优化感兴趣的话,你可能想内联这个函数

译者注

关于内联函数,也可查看这篇中文翻译[Rust 中的内联](https://nihil.cc/posts/translate_rust_inline/

OK,现在我们需要在从命令行获取到参数后立刻初始化日志记录,让我们在parse_args函数中 用这段代码替换之前我们保留的占位符:

1
2
3
4
5
if args.debug{
setup_log(log::LevelFilter::Debug);
} else {
setup_log(log::LevelFilter::Info);
}

记录日志信息

现在一切都就绪l,让我们在终端中记录一些内容!在src/main.rsmain函数中,我们可以将获取的参数输出到info信息中。这可以通过log::info!来实现

1
log::info!("{:?}", args);

log库给我们提供了error!warn!info!debug!trace!五个级别。

测试后我们将得到输出:

1
[2021-09-30T10:17:46Z INFO  crabcan] Args { debug: true, command: "/bin/bash", uid: 0, mount_dir: "./mountdir/" }

Patch for this step

这一步的代码可以在github litchipi/crabcan branch “step2”中找到.
从前一步到这一步的原始补丁可以在此处找到。

准备错误处理

作为通常的练习,注意错误处理会是一个好习惯。Rust这门语言在错误处理方面非常强大,以至于我们无法在错误处理上忽略或者遗漏这个特点。

Errcode 枚举

让我们创建一个定义了如下enum(枚举)src/errors.rs文件:

1
2
3
4
5
// Allows to display a variant with the format {:?}
#[derive(Debug)]
// Contains all possible errors in our tool
pub enum Errcode{
}

每次我们添加新的错误类型时,我们都会向该enum添加一个可变体(variant)。derive(Debug)允许使用{:?}来格式化显示enum。

我们可能希望为每个可变体显示更完整的消息,以防我们在项目中的代码和数字使我们晕头转向。为此,让我们来实现std::fmt::Display特性,定义该对象尝试在常规{}格式中显示时的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::fmt;

#[allow(unreachable_patterns)]
// trait Display, allows Errcode enum to be displayed by:
// println!("{}", error);
// in this case, it calls the function "fmt", which we define the behaviour below
impl fmt::Display for Errcode {

fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Define what behaviour for each variant of the enum
match &self{
_ => write!(f, "{:?}", self) // For any variant not previously covered
}
}
}

unreachable_patterns 属性确保在match语句描述了所有可变体时,不会收到编译器的任何警告信息。

Linux返回码

Linux可执行文件在退出时会返回一个数字,它描述了一切的进展情况。 返回代码 0 表示没有错误,任何其他数字都用于描述错误以及该错误的详情(基于返回代码值)。 您可以在此处找到有关特殊返回代码及其含义的表。

我们并不追求通过我们的工具来执行bash自动化脚本,只要设置一个如果有错误则返回1,没有错误则返回0的方式就可以了。

让我们在src/errors.rs文件中定义一个exit_with_errcode函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::process::exit;

// Get the result from a function, and exit the process with the correct error code
pub fn exit_with_retcode(res: Result<(), Errcode>) {
match res {
// If it's a success, return 0
Ok(_) => {
log::debug!("Exit without any error, returning 0");
exit(0);
},

// If there's an error, print an error message and return the retcode
Err(e) => {
let retcode = e.get_retcode();
log::error!("Error on exit:\n\t{}\n\tReturning {}", e, retcode);
exit(retcode);
}
}
}

这个函数会使用我们在Errcode这个enum中实现的get_retcode函数提供的返回状态码来退出进程,让我们先用最简单夜色最笨的办法来实现它:

1
2
3
4
5
6
impl Errcode{
// Translate an Errcode::X into a number to return (the Unix way)
pub fn get_retcode(&self) -> i32 {
1 // Everything != 0 will be treated as an error
}
}

Rust中的Result

当一段代码无法正常工作时,我们可以通过Rust中的Result来进行错误处理(详情请参阅此处)
Result<T, U>需要两种类型,一种是成功时返回的T类型,一种是发生错误时返回的U类型。在我们的例子中,如果出现错误,我们希望返回Errcode,如果一切顺利,则返回我们想要的任何内容。
让我们看看我们怎么在parse_args函数中进行设置:

1
2
3
4
5
pub fn parse_args() -> Result<Args, Errcode> {
// ...
Ok(args)
}

如果在程序运行过程中出现了某种错误,我们只要简单写上:

1
return Err(Errcode::MyErrorType);

Rust中的Result非常有用而且强大,一般来说。通常在任何你想要错误处理的地方使用都是个好主意,因为这是Rust中错误处理的标准方式。

OK,现在我们需要在main函数中对函数成功或者失败两种状态分别做不同的处理,让我们使用match语句来定义这两种情况下要做哪些事:

1
2
3
4
5
6
7
8
9
10
match cli::parse_args(){
Ok(args) => {
log::info!("{:?}", args);
exit_with_retcode(Ok(()))
},
Err(e) => {
log::error!("Error while parsing arguments:\n\t{}", e);
exit(e.get_retcode());
}
};

在这段代码中,如果参数解析成功,那么我们在日志中打印参数并使用OK()作为参数来调用exit_with_retcode(这个函数会简单的返回0),我们稍后会将这里作为我们放置容器的起点。
如果出现了错误,我们会在日志中打印它(注意Errcode上的{}格式化方式 ,它将调用我们之前实现的Display trait中的 fmt 函数)然后简单地退出并返回相关的返回代码。
最后一步,我们必须将src/errors.rs设置本为项目的一个模块,并在src/main.rs文件中导入exit_with_retcode函数。

1
2
3
mod errors;

use errors::exit_with_retcode;

在测试完成后,我们可以得到如下输出:

1
2
[2021-09-30T13:47:45Z INFO  crabcan] Args { debug: true, command: "/bin/bash", uid: 0, mount_dir: "./mountdir/" }
[2021-09-30T13:47:45Z DEBUG crabcan::errors] Exit without any error, returning 0

Patch for this step

这一步的代码可以在github litchipi/crabcan branch “step3”中找到.
前一步到这一步的原始补丁可以在此处找到

感谢filtoid为修复此步骤中出现的错误所提的PR

校验参数

在我们深入实际工作之前,我们先校验一下从命令行传入的参数,此处我们仅仅检查一下mount_dir是否存在,不过如果有我们添加了更多选项等情况,可以通过额外的检查来对这一步进行拓展。
让我们用的实际参数验证代码替换src/cli.rs中的占位符:

1
2
3
4
5
6
7
pub fn parse_args() -> Result<Args, Errcode> {
// ...
if !args.mount_dir.exists() || !args.mount_dir.is_dir(){
return Err(Errcode::ArgumentInvalid("mount"));
}
// ...
}

这个条件语句检验了路径(我们在Args结构体中定义的PathBuf类型)是否存在以及这个路径是否为目录。
如果不满足这个条件,我们会返回Result::Err以及带有自定义可变体ArgumentInvalidErrcode enum ,指明错误发生在参数mount部分。
我们先在src/errors.rs中定义这个可变体:

1
2
3
pub enum Errcode{
ArgumentInvalid(&'static str),
}

接下来我们可以在fmt函数的match语句下添加以下内容:

1
2
3
4
5
6
match &self{
// Message to display when an argument is invalid
Errcode::ArgumentInvalid(element) => write!(f, "ArgumentInvalid: {}", element),

// ...
}

Patch for this step

这一步的代码可以在github litchipi/crabcan branch “step4”中找到.
前一步到这一步的原始补丁可以在此处找到

特别感谢 @kevinji指出的此步骤中的错误