[譯]使用 Rust 開發一個簡單的 Web 應用,第 4 部分 —— CLI 選項解析

LeopPro發表於2018-02-21

使用 Rust 開發一個簡單的 Web 應用,第 4 部分 —— CLI 選項解析

1 剛剛回到正軌

哈嘍!這兩天抱歉了哈。我和妻子剛買了房子,這兩天都在忙這個。感謝你的耐心等待。

2 簡介

在之前的文章中,我們構建了一個“能跑起來”的應用;這證明了我們的計劃可行。為了使它真正用起來,我們還需要關心比如說命令列選項之類的一些事情。

所以,我要去做命令解析。但首先,我們先將現存的程式碼移出,以挪出空間我們可以做 CLI 解析實驗。但在此之前,我們通常只需要移除舊檔案,建立新 main.rs

$ ls
Cargo.lock      Cargo.toml      log.txt         src             target
$ cd src/
$ ls
main.rs                 main_file_writing.rs    web_main.rs
複製程式碼

main_file_writing.rsweb_main.rs 都是舊檔案,所以我移除它們。然後我將 main.rs 重新命名為 main_logging_server.rs,然後建立新的 main.rs

$ git rm main_file_writing.rs web_main.rs
rm 'src/main_file_writing.rs'
rm 'src/web_main.rs'
$ git commit -m 'remove old files'
[master 771380b] remove old files
 2 files changed, 35 deletions(-)
 delete mode 100644 src/main_file_writing.rs
 delete mode 100644 src/web_main.rs
$ git mv main.rs main_logging_server.rs
$ git commit -m 'move main out of the way for cli parsing experiment'
[master 4d24206] move main out of the way for cli parsing experiment
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename src/{main.rs => main_logging_server.rs} (100%)
$ touch main.rs
複製程式碼

著眼於引數解析。在之前的帖子的評論部分,Stephan Sokolow 問我是否考慮過使用這個用於命令列解析的軟體包 clap。Clap 看起來很有趣,所以我打算試試。

3 需求

以下服務需要能被引數配置:

  1. 日誌檔案的位置。
  2. 用來進行身份驗證的私鑰。
  3. (可能)設定時間記錄使用的時區。

我剛剛檢視了一下我打算用的 Digital Ocean 虛擬機器,它是東部標準時間,也正是我的時區,所以我或許會暫時跳過第三條。

4 實現

據我所知,設定 clap 依賴的方式是 clap = "*";。我更願意指定一個具體的版本,但是現在“*”可以工作。

我新的 Cargo.toml 檔案:

[package]
name = "simple-log"
version = "0.1.0"
authors = ["Joel McCracken <mccracken.joel@gmail.com>"]

[dependencies]

chrono = "0.2"
clap   = "*"

[dependencies.nickel]

git = "https://github.com/nickel-org/nickel.rs.git"
複製程式碼

安裝依賴:

$ cargo run
    Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading ansi_term v0.6.3
 Downloading strsim v0.4.0
 Downloading clap v1.0.0-beta
   Compiling strsim v0.4.0
   Compiling ansi_term v0.6.3
   Compiling clap v1.0.0-beta
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
error: main function not found
error: aborting due to previous error
Could not compile `simple-log`.

To learn more, run the command again with --verbose.
複製程式碼

這個錯誤只是因為我的 main.rs 還是空的;重要的是“編譯 clap”已經成功。

根據 README 檔案,我會先嚐試一個非常簡單的版本:

extern crate clap;
use clap::App;

fn main() {
  let _ = App::new("fake").version("v1.0-beta").get_matches();
}
複製程式碼

執行:

$ cargo run
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
     Running `target/debug/simple-log`
$ cargo run
     Running `target/debug/simple-log`
$ cargo build --release
   Compiling lazy_static v0.1.10
   Compiling matches v0.1.2
   Compiling bitflags v0.1.1
   Compiling httparse v0.1.2
   Compiling strsim v0.4.0
   Compiling rustc-serialize v0.3.14
   Compiling modifier v0.1.0
   Compiling libc v0.1.8
   Compiling unicase v0.1.0
   Compiling groupable v0.2.0
   Compiling regex v0.1.30
   Compiling traitobject v0.0.3
   Compiling pkg-config v0.3.4
   Compiling ansi_term v0.6.3
   Compiling gcc v0.3.5
   Compiling typeable v0.1.1
   Compiling unsafe-any v0.4.1
   Compiling num_cpus v0.2.5
   Compiling rand v0.3.8
   Compiling log v0.3.1
   Compiling typemap v0.3.2
   Compiling clap v1.0.0-beta
   Compiling plugin v0.2.6
   Compiling mime v0.0.11
   Compiling time v0.1.25
   Compiling openssl-sys v0.6.2
   Compiling openssl v0.6.2
   Compiling url v0.2.34
   Compiling mustache v0.6.1
   Compiling num v0.1.25
   Compiling cookie v0.1.20
   Compiling hyper v0.4.0
   Compiling chrono v0.2.14
   Compiling nickel v0.5.0 (https://github.com/nickel-org/nickel.rs.git#69546f58)
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)

$ target/debug/simple-log --help
simple-log v1.0-beta

USAGE:
        simple-log [FLAGS]

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

$ target/release/simple-log --help
simple-log v1.0-beta

USAGE:
        simple-log [FLAGS]

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information
複製程式碼

我不知道為什麼自述檔案告訴我要使用 --release 編譯 —— 似乎 debug 也一樣能工作。而我並不清楚將會發生什麼。我們刪除掉 target 目錄,不加--release 再編譯一次:

$ rm -rf target
$ ls
Cargo.lock      Cargo.toml      log.txt         src
$ cargo build
   Compiling gcc v0.3.5
   Compiling strsim v0.4.0
   Compiling typeable v0.1.1
   Compiling unicase v0.1.0
   Compiling ansi_term v0.6.3
   Compiling modifier v0.1.0
   Compiling httparse v0.1.2
   Compiling regex v0.1.30
   Compiling matches v0.1.2
   Compiling pkg-config v0.3.4
   Compiling lazy_static v0.1.10
   Compiling traitobject v0.0.3
   Compiling rustc-serialize v0.3.14
   Compiling libc v0.1.8
   Compiling groupable v0.2.0
   Compiling bitflags v0.1.1
   Compiling unsafe-any v0.4.1
   Compiling clap v1.0.0-beta
   Compiling typemap v0.3.2
   Compiling rand v0.3.8
   Compiling num_cpus v0.2.5
   Compiling log v0.3.1
   Compiling time v0.1.25
   Compiling openssl-sys v0.6.2
   Compiling plugin v0.2.6
   Compiling mime v0.0.11
   Compiling openssl v0.6.2
   Compiling url v0.2.34
   Compiling num v0.1.25
   Compiling mustache v0.6.1
   Compiling cookie v0.1.20
   Compiling hyper v0.4.0
   Compiling chrono v0.2.14
   Compiling nickel v0.5.0 (https://github.com/nickel-org/nickel.rs.git#69546f58)
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
$ target/release/simple-log --help
bash: target/release/simple-log: No such file or directory
$ target/debug/simple-log --help
simple-log v1.0-beta

USAGE:
        simple-log [FLAGS]

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information
$
複製程式碼

所以,我猜你並不需要加 --release。耶,每天學點新東西。

我們再回過頭來看 main 程式碼,我注意到變數以 _ 命名;我們假定這是必須的,為了防止警告,表示廢棄。使用 _ 表示“故意未使用”真是漂亮的標準,我喜歡 Rust 對此支援。

好了,根據 clap 自述檔案和上面的小實驗,我首次嘗試寫一個引數解析器:

extern crate clap;
use clap::{App,Arg};

fn main() {
    let matches = App::new("simple-log").version("v0.0.1")
        .arg(Arg::with_name("LOG FILE")
             .short("l")
             .long("logfile")
             .takes_value(true))
        .get_matches();

    println!("Logfile path: {}", matches.value_of("LOG FILE").unwrap());

}
複製程式碼

=>

$ cargo run -- --logfile whodat
     Running `target/debug/simple-log --logfile whodat`
Logfile path: whodat
$ cargo run -- -l whodat
     Running `target/debug/simple-log -l whodat`
Logfile path: whodat
複製程式碼

很棒,正常工作!但這有一個問題:

$ cargo run
     Running `target/debug/simple-log`
thread '<main>' panicked at 'called `Option::unwrap()` on a `None` value', /private/tmp/rust2015051
6-38954-h579wb/rustc-1.0.0/src/libcore/option.rs:362
An unknown error occurred

To learn more, run the command again with --verbose.
複製程式碼

看起來,在這呼叫 unwrap() 不是一個好主意,因為引數不一定被傳入!

我不清楚大型的 Rust 社群對 unwrap 的建議是什麼,但我總能看見社群裡提到為什麼它應該可以在這裡使用。然而我覺得這說得通,在應用規模增長的過程中,某位置失效是“喜聞樂見的”。錯誤發生在執行期。這不是編譯器可以檢測的出的!

unwrap 的基本思想是類似空指標異常麼?我想是的。但是,它確實讓你停下來思考你在做什麼,如果 unwrap 意味著程式碼異味,這還不錯。這導致我有點想法想倒出來:

5 雜言

我堅信開發者的編碼質量不是語言層面能解決的問題。各類靜態語言社群總是花言巧語:“這些語言能使碼農遠離糟糕的編碼。”好啊,你猜怎麼樣:這是不可能的。

首先,你沒法使用任何明確的方式定義“優秀的程式碼”。確實,使程式碼優秀的絕大多數原因是高內聚。舉一個非常簡單的例子,麵條程式碼在原型期往往是工作良好的,但在生產質量下,麵條程式碼是可怕的。

最近的 OpenSSL 漏洞就是最好的例證。在新聞中,我沒有得到多少資訊,但我收集的資料表示,漏洞是由於錯誤的業務邏輯導致的。在某些極端情況下,攻擊者可以冒充 CA(可信第三方)。你如何通過編譯器預防此類問題呢?

確實,這將我帶回了 Charles Babbage 中的一箇舊內容:

On two occasions I have been asked, "Pray, Mr. Babbage, if you put into the machine wrong figures, will the right answers come out?" In one case a member of the Upper, and in the other a member of the Lower, House put this question. I am not able rightly to apprehend the kind of confusion of ideas that could provoke such a question.

對此最好的辦法就是讓開發者更容易程式設計,讓正確的事情符合常規,容易達成。

當你認為靜態型別系統使程式設計更易的時候,我認為這件事又開始有意義了。說到底,開發者有責任保證程式行為正確,我們必須相信他們,賦予他們權利。

總而言之:程式設計師總是可以實現一個小的 Scheme 直譯器,並在其中編寫所有的應用程式邏輯。如果你試圖通過型別檢查器來防止這樣的事情,那麼祝你好運咯。

好了,我說完了,我將放下我的話匣子。謝謝你容忍我喋喋不休。

6 繼續

回到主題上,我注意到有一個 Arg 的選項用來指定引數是否可選。我覺得我需要指定這個:

extern crate clap;
use clap::{App,Arg};

fn main() {
    let matches = App::new("simple-log").version("v0.0.1")
        .arg(Arg::with_name("LOG FILE")
             .short("l")
             .long("logfile")
             .required(true)
             .takes_value(true))
        .get_matches();

    println!("Logfile path: {}", matches.value_of("LOG FILE").unwrap());

}
複製程式碼

=>

$ cargo run
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
     Running `target/debug/simple-log`
error: The following required arguments were not supplied:
        '--logfile <LOG FILE>'

USAGE:
        simple-log --logfile <LOG FILE>

For more information try --help
An unknown error occurred

To learn more, run the command again with --verbose.
$ cargo run -- -l whodat
     Running `target/debug/simple-log -l whodat`
Logfile path: whodat
複製程式碼

奏效了!我們需要的下一個選項是通過命令列指定一個私鑰。讓我們新增它,但使其可選,因為,嗯,為什麼不呢?我可能要搭建一個公開版本供人們預覽。

我這樣寫:

extern crate clap;
use clap::{App,Arg};

fn main() {
    let matches = App::new("simple-log").version("v0.0.1")
        .arg(Arg::with_name("LOG FILE")
             .short("l")
             .long("logfile")
             .required(true)
             .takes_value(true))
        .arg(Arg::with_name("AUTH TOKEN")
             .short("t")
             .long("token")
             .takes_value(true))
        .get_matches();

    let logfile_path = matches.value_of("LOG FILE").unwrap();
    let auth_token   = matches.value_of("AUTH TOKEN");
}
複製程式碼

=>

$ cargo run -- -l whodat
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
src/main.rs:17:9: 17:21 warning: unused variable: `logfile_path`, #[warn(unused_variables)] on by d
efault
src/main.rs:17     let logfile_path = matches.value_of("LOG FILE").unwrap();
                       ^~~~~~~~~~~~
src/main.rs:18:9: 18:19 warning: unused variable: `auth_token`, #[warn(unused_variables)] on by default
src/main.rs:18     let auth_token   = matches.value_of("AUTH TOKEN");
                       ^~~~~~~~~~
     Running `target/debug/simple-log -l whodat`
複製程式碼

這有很多(預料中的)警告,無妨,它成功編譯執行。我只是想檢查一下型別問題。現在讓我們真正開始編寫程式。我們以下面的程式碼開始:

use std::io::prelude::*;
use std::fs::OpenOptions;
use std::io;

#[macro_use] extern crate nickel;
use nickel::Nickel;

extern crate chrono;
use chrono::{DateTime,Local};

extern crate clap;
use clap::{App,Arg};

fn formatted_time_entry() -> String {
    let local: DateTime<Local> = Local::now();
    let formatted = local.format("%a, %b %d %Y %I:%M:%S %p\n").to_string();
    formatted
}

fn record_entry_in_log(filename: &str, bytes: &[u8]) -> io::Result<()> {
    let mut file = try!(OpenOptions::new().
                        append(true).
                        write(true).
                        create(true).
                        open(filename));
    try!(file.write_all(bytes));
    Ok(())
}

fn log_time(filename: &'static str) -> io::Result<String> {
    let entry = formatted_time_entry();
    {
        let bytes = entry.as_bytes();

        try!(record_entry_in_log(filename, &bytes));
    }
    Ok(entry)
}

fn do_log_time(logfile_path: &'static str, auth_token: Option<&str>) -> String {
    match log_time(logfile_path) {
        Ok(entry) => format!("Entry Logged: {}", entry),
        Err(e) => format!("Error: {}", e)
    }
}

fn main() {
    let matches = App::new("simple-log").version("v0.0.1")
        .arg(Arg::with_name("LOG FILE")
             .short("l")
             .long("logfile")
             .required(true)
             .takes_value(true))
        .arg(Arg::with_name("AUTH TOKEN")
             .short("t")
             .long("token")
             .takes_value(true))
        .get_matches();

    let logfile_path = matches.value_of("LOG FILE").unwrap();
    let auth_token   = matches.value_of("AUTH TOKEN");

    let mut server = Nickel::new();

    server.utilize(router! {
        get "**" => |_req, _res| {
            do_log_time(logfile_path, auth_token)
        }
    });

    server.listen("127.0.0.1:6767");
}
複製程式碼

=>

$ cargo run -- -l whodat
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
src/main.rs:60:24: 60:31 error: `matches` does not live long enough
src/main.rs:60     let logfile_path = matches.value_of("LOG FILE").unwrap();
                                      ^~~~~~~
note: reference must be valid for the static lifetime...
src/main.rs:58:24: 72:2 note: ...but borrowed value is only valid for the block suffix following st
atement 0 at 58:23
src/main.rs:58         .get_matches();
src/main.rs:59
src/main.rs:60     let logfile_path = matches.value_of("LOG FILE").unwrap();
src/main.rs:61     let auth_token   = matches.value_of("AUTH TOKEN");
src/main.rs:62
src/main.rs:63     let mut server = Nickel::new();
               ...
src/main.rs:61:24: 61:31 error: `matches` does not live long enough
src/main.rs:61     let auth_token   = matches.value_of("AUTH TOKEN");
                                      ^~~~~~~
note: reference must be valid for the static lifetime...
src/main.rs:58:24: 72:2 note: ...but borrowed value is only valid for the block suffix following st
atement 0 at 58:23
src/main.rs:58         .get_matches();
src/main.rs:59
src/main.rs:60     let logfile_path = matches.value_of("LOG FILE").unwrap();
src/main.rs:61     let auth_token   = matches.value_of("AUTH TOKEN");
src/main.rs:62
src/main.rs:63     let mut server = Nickel::new();
               ...
error: aborting due to 2 previous errors
Could not compile `simple-log`.

To learn more, run the command again with --verbose.
複製程式碼

我不理解哪錯了 —— 這和例子實質上是一樣的。我嘗試註釋掉一堆程式碼,直到它等效於下面的程式碼:

fn main() {
    let matches = App::new("simple-log").version("v0.0.1")
        .arg(Arg::with_name("LOG FILE")
             .short("l")
             .long("logfile")
             .required(true)
             .takes_value(true))
        .arg(Arg::with_name("AUTH TOKEN")
             .short("t")
             .long("token")
             .takes_value(true))
        .get_matches();

    let logfile_path = matches.value_of("LOG FILE").unwrap();
    let auth_token   = matches.value_of("AUTH TOKEN");
}
複製程式碼

…… 現在它可以編譯了。報了很多警告,但無妨。

上面的錯誤資訊都不是被註釋掉的行產生的。現在我直到錯誤資訊不一定指造成問題的程式碼,我知道要去別處看看。

我做的第一件事是去掉對兩個引數的引用。程式碼變成了這樣:

fn main() {
    let matches = App::new("simple-log").version("v0.0.1")
        .arg(Arg::with_name("LOG FILE")
             .short("l")
             .long("logfile")
             .required(true)
             .takes_value(true))
        .arg(Arg::with_name("AUTH TOKEN")
             .short("t")
             .long("token")
             .takes_value(true))
        .get_matches();

    let logfile_path = matches.value_of("LOG FILE").unwrap();
    let auth_token   = matches.value_of("AUTH TOKEN");

    let mut server = Nickel::new();
    server.utilize(router! {
        get "**" => |_req, _res| {
            do_log_time("", Some(""))
        }
    });

    server.listen("127.0.0.1:6767");
}
複製程式碼

程式碼成功的編譯執行。現在我瞭解了問題所在,我懷疑是GET請求被對映到 get ** 閉包中,而將這些變數傳入該閉包中引起了生命週期衝突。

我和我的朋友 Carol Nichols 討論了這個問題,她給我的建議使得我離解決問題更進一步:將 logfile_pathauth_token 轉換成 String 型別。

在這我能確信的是,logfile_pathauth_token 都是對於 matches 資料結構中某處的 str 型別的一個假借,它們在某一時間被傳出作用域。在 main 函式結尾?由於在閉包結束時 main 函式仍然在執行,似乎 matches 仍然存在。

另外,可能閉包不適用於假借變數。我覺得這似乎不太可能。似乎是編譯器無法肯定當閉包被呼叫時 matches 會仍然存在。即便如此,現在的情況仍然難以令人理解,因為閉包在 server 之中,將與 matches 同時結束作用域!

不管如何,我們這樣修改程式碼:

// ...
let logfile_path = matches.value_of("LOG FILE").unwrap();
let auth_token   = matches.value_of("AUTH TOKEN");

let mut server = Nickel::new();
server.utilize(router! {
    get "**" => |_req, _res| {
        do_log_time(logfile_path, auth_token)
    }
});
// ...
複製程式碼

改成這樣:

// ...
let logfile_path = matches.value_of("LOG FILE").unwrap().to_string();
let auth_token = match matches.value_of("AUTH TOKEN") {
    Some(str) => Some(str.to_string()),
    None => None
};

let mut server = Nickel::new();
server.utilize(router! {
    get "**" => |_req, _res| {
        do_log_time(logfile_path, auth_token)
    }
});

server.listen("127.0.0.1:6767");
// ...
複製程式碼

…… 解決了問題。我也令各個函式引數中的 &str 型別改為 String 型別。

當然,這揭示了一個問題:

$ cargo build
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
src/main.rs:69:25: 69:37 error: cannot move out of captured outer variable in an `Fn` closure
src/main.rs:69             do_log_time(logfile_path, auth_token)
                                       ^~~~~~~~~~~~
<nickel macros>:1:1: 1:27 note: in expansion of as_block!
<nickel macros>:10:12: 10:42 note: expansion site
note: in expansion of closure expansion
<nickel macros>:9:6: 10:54 note: expansion site
<nickel macros>:1:1: 10:62 note: in expansion of _middleware_inner!
<nickel macros>:4:1: 4:60 note: expansion site
<nickel macros>:1:1: 7:46 note: in expansion of middleware!
<nickel macros>:11:32: 11:78 note: expansion site
<nickel macros>:1:1: 21:78 note: in expansion of _router_inner!
<nickel macros>:4:1: 4:43 note: expansion site
<nickel macros>:1:1: 4:47 note: in expansion of router!
src/main.rs:67:20: 71:6 note: expansion site
src/main.rs:69:39: 69:49 error: cannot move out of captured outer variable in an `Fn` closure
src/main.rs:69             do_log_time(logfile_path, auth_token)
                                                     ^~~~~~~~~~
<nickel macros>:1:1: 1:27 note: in expansion of as_block!
<nickel macros>:10:12: 10:42 note: expansion site
note: in expansion of closure expansion
<nickel macros>:9:6: 10:54 note: expansion site
<nickel macros>:1:1: 10:62 note: in expansion of _middleware_inner!
<nickel macros>:4:1: 4:60 note: expansion site
<nickel macros>:1:1: 7:46 note: in expansion of middleware!
<nickel macros>:11:32: 11:78 note: expansion site
<nickel macros>:1:1: 21:78 note: in expansion of _router_inner!
<nickel macros>:4:1: 4:43 note: expansion site
<nickel macros>:1:1: 4:47 note: in expansion of router!
src/main.rs:67:20: 71:6 note: expansion site
error: aborting due to 2 previous errors
Could not compile `simple-log`.

To learn more, run the command again with --verbose.
複製程式碼

乍一看,我完全不能理解這個錯誤:

src/main.rs:69:25: 69:37 error: cannot move out of captured outer variable in an `Fn` closure
src/main.rs:69             do_log_time(logfile_path, auth_token)
複製程式碼

它說的“移出”一個被捕獲的變數是什麼意思?我不記得有哪個語言有這種移入、移出變數這樣的概念,那個錯誤資訊對我來說難以理解。

錯誤資訊也告訴了我一些其他奇怪的事情;什麼是閉包必須擁有其中的物件?

我又上網查了查這個錯誤資訊,有一些結果,但看起來沒有對我有用的。所以,我們接著玩耍。

7 更多的除錯

首先,我先使用 --verbose 編譯看看能不能顯示一些有用的,但這並沒有列印任何關於此錯誤的額外資訊,只是一些關於一般命令的。

我依稀記得 Rust 文件中具體談到了閉包,所以我決定去看看。根據文件,我猜測我需要一個“move”閉包。但當我嘗試的時候:

server.utilize(router! {
    get "**" => move |_req, _res| {
        do_log_time(logfile_path, auth_token)
    }
});
複製程式碼

…… 提示了一個新的錯誤資訊:

$ cargo run -- -l whodat
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
src/main.rs:66:21: 66:25 error: no rules expected the token `move`
src/main.rs:66         get "**" => move |_req, _res| {
                                   ^~~~
Could not compile `simple-log`.

To learn more, run the command again with --verbose.
複製程式碼

這是我困惑,所以我決定試試把它移動到外面去:

foo = move |_req, _res| {
    do_log_time(logfile_path, auth_token)
};

server.utilize(router! {
    get "**" => foo
});
複製程式碼

=>

$ cargo run -- -l whodat
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
src/main.rs:70:21: 70:24 error: no rules expected the token `foo`
src/main.rs:70         get "**" => foo
                                   ^~~
Could not compile `simple-log`.

To learn more, run the command again with --verbose.
複製程式碼

出現了相同的錯誤資訊。

這次我注意到,關於模式匹配巨集系統的錯誤資訊用詞看起來十分奇怪,我記得 router! 巨集在這裡被使用。一些巨集很奇怪!我知道如何解決這個問題,因為我之前處理過。

$ rustc src/main.rs --pretty=expanded -Z unstable-options
src/main.rs:5:14: 5:34 error: can't find crate for `nickel`
src/main.rs:5 #[macro_use] extern crate nickel;
複製程式碼

據此,我猜,或許我需要給 cargo 傳遞這個引數So?查閱 cargo 文件,沒有發現任何能傳遞引數給 rustc 的方式。

在網上搜尋一波,我發現了一些 GitHub issues 提出傳遞任意引數是不被支援的,除非建立一個自定義 cargo 命令,這似乎從我現在要解決的問題轉移到了另一個可怕的問題,所以我不想接著這個思路走。

突然,一個瘋狂的想法浮現在我的腦海:當使用 cargo run --verbose時,我去看輸出中 rustc 命令是怎樣執行的:

# ...
Caused by:
  Process didn't exit successfully: `rustc src/main.rs --crate-name simple_log --crate-type bin -g -
-out-dir /Users/joel/Projects/simple-log/target/debug --emit=dep-info,link -L dependency=/Users/joel
/Projects/simple-log/target/debug -L dependency=/Users/joel/Projects/simple-log/target/debug/deps --
extern nickel=/Users/joel/Projects/simple-log/target/debug/deps/libnickel-0a4cb77ee6c08a8b.rlib --ex
tern chrono=/Users/joel/Projects/simple-log/target/debug/deps/libchrono-a9b06d7e3a59ae0d.rlib --exte
rn clap=/Users/joel/Projects/simple-log/target/debug/deps/libclap-01156bdabdb6927f.rlib -L native=/U
sers/joel/Projects/simple-log/target/debug/build/openssl-sys-9c1a0f13b3d0a12d/out -L native=/Users/j
oel/Projects/simple-log/target/debug/build/time-30c208bd835b525d/out` (exit code: 101)
# ...
複製程式碼

…… 我這個騷操作:我能否修改 rustc 的編譯指令,輸出巨集擴充套件程式碼呢?我們試一下:

$ rustc src/main.rs --crate-name simple_log --crate-type bin -g --out-dir /Users/joel/Projects/simple-log/target/debug --emit=dep-info,link -L dependency=/Users/joel/Projects/simple-log/target/debug -L
dependency=/Users/joel/Projects/simple-log/target/debug/deps --extern nickel=/Users/joel/Projects/simple-log/target/debug/deps/libnickel-0a4cb77ee6c08a8b.rlib --extern chrono=/Users/joel/Projects/simple
-log/target/debug/deps/libchrono-a9b06d7e3a59ae0d.rlib --extern clap=/Users/joel/Projects/simple-log/target/debug/deps/libclap-01156bdabdb6927f.rlib -L native=/Users/joel/Projects/simple-log/target/debu
g/build/openssl-sys-9c1a0f13b3d0a12d/out -L native=/Users/joel/Projects/simple-log/target/debug/build/time-30c208bd835b525d/out --pretty=expanded -Z unstable-options > macro-expanded.rs
$ cat macro-expanded.rs
#![feature(no_std)]
#![no_std]
#[prelude_import]
use std::prelude::v1::*;
#[macro_use]
extern crate std as std;
use std::io::prelude::*;
...
複製程式碼

它奏效了!這種操作登不得大雅之堂,但有時就是偏方才奏效,我至少弄明白了。這也讓我弄清了 cargo 是怎樣呼叫 rustc 的。

對我們有用的輸出部分是這樣的:

server.utilize({
    use nickel::HttpRouter;
    let mut router = ::nickel::Router::new();
    {
        router.get("**",{
            use nickel::{MiddlewareResult, Responder, 
                        Response, Request};
            #[inline(always)]
            fn restrict<'a, R: Responder>(r: R, res: Response<'a>) 
                                            -> MiddlewareResult<'a> {
                res.send(r)
            }
            #[inline(always)]
            fn restrict_closure<F>(f: F) -> F 
                    where F: for<'r, 'b, 'a>Fn(&'r mut Request<'b, 'a, 'b>, 
                        Response<'a>) -> MiddlewareResult<'a> + Send + Sync {
                f
            }
            restrict_closure(
                move |_req, _res| { 
                    restrict({ 
                        do_log_time(logfile_path, auth_token)
                    }, _res)
            })
        });
        router
    }
});
複製程式碼

好吧,資訊量很大。我們來抽絲剝繭。

有兩個函式,restrictrestrict_closure,這令我驚訝。我認為它們的存在是為了提供更好的關於這些請求處理閉包的型別 / 錯誤資訊。

然而,這還有許多有趣的事情:

restrict_closure(move |_req, _res| { ... })
複製程式碼

…… 這告訴我,巨集指定了閉包是 move 閉包。從理論上,是這樣的。

8 重構

我們重構,並且重新審視一下這個問題。這一次,main 函式是這樣的:

fn main() {
    let matches = App::new("simple-log").version("v0.0.1")
        .arg(Arg::with_name("LOG FILE")
             .short("l")
             .long("logfile")
             .required(true)
             .takes_value(true))
        .arg(Arg::with_name("AUTH TOKEN")
             .short("t")
             .long("token")
             .takes_value(true))
        .get_matches();

    let logfile_path = matches.value_of("LOG FILE").unwrap().to_string();
    let auth_token = match matches.value_of("AUTH TOKEN") {
        Some(str) => Some(str.to_string()),
        None => None
    };

    let mut server = Nickel::new();
    server.utilize(router! {
        get "**" => |_req, _res| {
            do_log_time(logfile_path, auth_token)
        }
    });

    server.listen("127.0.0.1:6767");
}
複製程式碼

編譯時輸出為:

$ cargo build
   Compiling simple-log v0.1.0 (file:///Users/joel/Projects/simple-log)
src/main.rs:69:25: 69:37 error: cannot move out of captured outer variable in an `Fn` closure
src/main.rs:69             do_log_time(logfile_path, auth_token)
                                       ^~~~~~~~~~~~
<nickel macros>:1:1: 1:27 note: in expansion of as_block!
<nickel macros>:10:12: 10:42 note: expansion site
note: in expansion of closure expansion
<nickel macros>:9:6: 10:54 note: expansion site
<nickel macros>:1:1: 10:62 note: in expansion of _middleware_inner!
<nickel macros>:4:1: 4:60 note: expansion site
<nickel macros>:1:1: 7:46 note: in expansion of middleware!
<nickel macros>:11:32: 11:78 note: expansion site
<nickel macros>:1:1: 21:78 note: in expansion of _router_inner!
<nickel macros>:4:1: 4:43 note: expansion site
<nickel macros>:1:1: 4:47 note: in expansion of router!
src/main.rs:67:20: 71:6 note: expansion site
src/main.rs:69:39: 69:49 error: cannot move out of captured outer variable in an `Fn` closure
src/main.rs:69             do_log_time(logfile_path, auth_token)
                                                     ^~~~~~~~~~
<nickel macros>:1:1: 1:27 note: in expansion of as_block!
<nickel macros>:10:12: 10:42 note: expansion site
note: in expansion of closure expansion
<nickel macros>:9:6: 10:54 note: expansion site
<nickel macros>:1:1: 10:62 note: in expansion of _middleware_inner!
<nickel macros>:4:1: 4:60 note: expansion site
<nickel macros>:1:1: 7:46 note: in expansion of middleware!
<nickel macros>:11:32: 11:78 note: expansion site
<nickel macros>:1:1: 21:78 note: in expansion of _router_inner!
<nickel macros>:4:1: 4:43 note: expansion site
<nickel macros>:1:1: 4:47 note: in expansion of router!
src/main.rs:67:20: 71:6 note: expansion site
error: aborting due to 2 previous errors
Could not compile `simple-log`.

To learn more, run the command again with --verbose.
複製程式碼

我在 IRC(一種即時通訊系統) 中問了這個問題,但是沒有得到迴應。按道理講,我應該多花費一些耐心在 IRC 上提問,但沒有就是沒有。

我在 nickel.rs 專案上提交了一個 Issue,認為該問題是由巨集導致的。這是我最終的想法 —— 我知道我可能是錯的,但是我沒有看到別的方法,我也不想放棄。

我的 Issue 在 github.com/nickel-org/…。Ryman 很快看到了我的錯誤,並且非常友好的幫助我解決了問題。顯然,他是對的 —— 如果你能看到這篇文章,Ryman,我欠你一個人情。

問題發生在以下具體的閉包中。我們檢查一下看看我們能發現什麼:

get "**" => |_req, _res| {
    do_log_time(logfile_path, auth_token)
}
複製程式碼

你注意到沒,這裡,對 do_log_time 的呼叫轉移了 logfile_pathauth_token 的所有權到呼叫的函式。這是問題的所在。

我未經訓練時,我認為這是“正常”的,是程式碼最自然的表現方式。我忽略了一個重要的警告:在當前情況下,這個 lambda 表示式不能被呼叫一次以上。當它被第一次呼叫時,logfile_pathauth_token 的所有權被轉移到了 do_log_time 的呼叫者。這就是說:如果這個函式再次被呼叫,它不能再轉移所有權給 do_log_time,因為它不再擁有這兩個變數。

因此,我們得到錯誤資訊:

src/main.rs:69:39: 69:49 error: cannot move out of captured outer variable in an `Fn` closure
複製程式碼

我仍然認為這沒有任何意義,但是現在至少我明白,它是將所有權從閉包中“移出”。

無論如何,解決這個問題最簡單的方法是這樣:

let mut server = Nickel::new();
server.utilize(router! {
    get "**" => |_req, _res| {
        do_log_time(logfile_path.clone(), auth_token.clone())
    }
});
複製程式碼

現在,在每次呼叫中,logfile_pathauth_token 仍然被擁有,克隆體被建立了,其所有權被轉移了。

然而,我想指出,我仍然認為這是一個次優的解決方案。因為轉移所有權的過程不夠透明,我現在傾向於儘可能使用引用。

如果使用顯式的符號來代表假借的引用用另一種顯式符號代表擁有,Rust 會更好,* 起這個作用嗎?我不知道,但是這的確是一個有趣的問題。

9 重構

我將嘗試一個快速重構,看看我是否可以使用引用。這將是有趣的,因為我可能會出現一些不可預見的問題 —— 我們來看看吧!

我一直在閱讀 Martin Fowler 寫的關於重構的書,這重新整理了我的價值觀,做事情要從一小步開始。第一步,我只想將所有權轉化為假借;我們從 logfile_path 開始:

fn do_log_time(logfile_path: String, auth_token: Option<String>) -> String {
    match log_time(logfile_path) {
        Ok(entry) => format!("Entry Logged: {}", entry),
        Err(e) => format!("Error: {}", e)
    }
}

// ...

fn main() {
    // ...
    server.utilize(router! {
        get "**" => |_req, _res| {
            do_log_time(logfile_path.clone(), auth_token.clone())
        }
    });
   // ...
}
複製程式碼

改為:

fn do_log_time(logfile_path: &String, auth_token: Option<String>) -> String {
    match log_time(logfile_path.clone()) {
        Ok(entry) => format!("Entry Logged: {}", entry),
        Err(e) => format!("Error: {}", e)
    }
}

// ...

fn main() {
    // ...
    server.utilize(router! {
        get "**" => |_req, _res| {
            do_log_time(&logfile_path, auth_token.clone())
        }
    });
   // ...
}
複製程式碼

這次重構一定要實現:用假借替代所有權和克隆。如果我擁有一個物件,並且我要將其轉化為假借,而且我還想在其他地方轉移其所有權,我必須先在內部建立自己的副本。這使我可以將我的所有權變成假借,在必要的時候我仍然可以轉移所有權。當然,這涉及克隆假借的物件,這會重複佔用記憶體以及產生效能開銷,但如此一來我可以安全地更改這行程式碼。然後,我可以持續使用假借取代所有權,而不會破壞任何東西。

嘗試了多次之後我得到如下程式碼:

use std::io::prelude::*;
use std::fs::OpenOptions;
use std::io;

#[macro_use] extern crate nickel;
use nickel::Nickel;

extern crate chrono;
use chrono::{DateTime,Local};

extern crate clap;
use clap::{App,Arg};

fn formatted_time_entry() -> String {
    let local: DateTime<Local> = Local::now();
    let formatted = local.format("%a, %b %d %Y %I:%M:%S %p\n").to_string();
    formatted
}

fn record_entry_in_log(filename: &String, bytes: &[u8]) -> io::Result<()> {
    let mut file = try!(OpenOptions::new().
                        append(true).
                        write(true).
                        create(true).
                        open(filename));
    try!(file.write_all(bytes));
    Ok(())
}

fn log_time(filename: &String) -> io::Result<String> {
    let entry = formatted_time_entry();
    {
        let bytes = entry.as_bytes();

        try!(record_entry_in_log(filename, &bytes));
    }
    Ok(entry)
}

fn do_log_time(logfile_path: &String, auth_token: &Option<String>) -> String {
    match log_time(logfile_path) {
        Ok(entry) => format!("Entry Logged: {}", entry),
        Err(e) => format!("Error: {}", e)
    }
}

fn main() {
    let matches = App::new("simple-log").version("v0.0.1")
        .arg(Arg::with_name("LOG FILE")
             .short("l")
             .long("logfile")
             .required(true)
             .takes_value(true))
        .arg(Arg::with_name("AUTH TOKEN")
             .short("t")
             .long("token")
             .takes_value(true))
        .get_matches();

    let logfile_path = matches.value_of("LOG FILE").unwrap().to_string();
    let auth_token = match matches.value_of("AUTH TOKEN") {
        Some(str) => Some(str.to_string()),
        None => None
    };

    let mut server = Nickel::new();
    server.utilize(router! {
        get "**" => |_req, _res| {
            do_log_time(&logfile_path, &auth_token)
        }
    });

    server.listen("127.0.0.1:6767");

}
複製程式碼

我馬上需要處理 auth_token,但現在應該暫告一段落。

10 對第四部分的結論與回顧

應用程式現在具有解析選項的功能了。然而,這是非常困難的。在嘗試解決我的問題時,我差點走投無路。如果我在 nickel.rs 提出的 Issue 沒有這麼有幫助的迴應的話,我會非常受挫。

一些教訓:

  • 轉讓所有權是一件棘手的事情。我認為對我來說,一個新的經驗之談是,如果不必使用所有權,儘量通過不可變的假借來傳遞引數。
  • Cargo 真應該提供一個直接傳參給 rustc 的方法。
  • 一些 Rust 錯誤提示不那麼太好。
  • 即使錯誤資訊很不怎麼好,Rust 還是對的 —— 向我的閉包中轉移所有權是錯誤的,因為網頁每被請求一次,該函式就被呼叫一次。這裡給我的一個教訓是:如果我不明白錯誤資訊,那麼以程式碼為切入點來思考問題是個好辦法,尤其是思考什麼與 Rust 保證記憶體安全的思想相左。

這個經驗也加強了我對強型別程式語言編譯失敗的承受能力。有時,你真的要去了解內部發生的事情以清楚正在發生什麼。在本例中,很難去建立一個最小可重現錯誤來說明問題。

當錯誤訊息沒有給你你需要的資訊時,你下一步最好的選擇是開始在網際網路上搜尋與錯誤訊息相關的資訊。這並不能真正幫助你自己調查,理解和解決問題。

我認為這可以通過增加一些在多次不同狀態下詢問編譯器結果來優化,以找到關於該問題的更多資訊。就像在編譯錯誤中開啟一個互動式提示一樣,這真是太好了,但即使是註釋程式碼以從編譯器請求詳細資訊也是非常有用的。

我在大約一個月的時間裡寫了這篇文章,主要是因為我忙於處理房子購置物品。有時候,我對此感到非常沮喪。我以為整合選項解析是最簡單的任務!

但是,意識到 Rust 揭示了我程式的問題時,緩解了我的心情。即使錯誤資訊不如我所希望的那樣好,我還是喜歡它能合理的分割錯誤,這使我從中被拯救出來。

我希望隨著Rust的成熟,錯誤資訊會變得更好。如隨我願,我想我所有的擔心都會消失。

系列文章:使用 Rust 開發一個簡單的 Web 應用


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章