Chapter 05

ロギングとエラー処理

Shotaro Tsuji
Shotaro Tsuji
2020.12.08に更新

ここまででリンクを抽出する機能は概ね完成しましたが、細かな機能としてロギングとエラー処理を追加します。

ロギング

シングルスレッド環境でのロギングにはlogクレートを使います。
logクレートは debug や warn といったログレベル付きのメッセージを表示するためのマクロを提供します。
ただし、実際に表示を行う機能は分離されており別のクレートを使います。今回はシンプルな実装であるenv_loggerクレートを使います。

src/lib.rsに記述した、LinkExtractor::get_linksにログを出力するコードを加えます。引数として渡されたURLと実際に取得したページのURLをログに記録しておくことにしましょう。ついでにステータスコードも記録しておきます。ログレベルは info でいいでしょう。ではget_linksメソッドでログを記録するためにlog::info!マクロにメッセージを渡すコードを書いておきましょう。

src/lib.rs
    pub fn get_links(&self, url: Url) -> Result<Vec<Url>, eyre::Report> {
        log::info!("GET \"{}\"", url);
        let response = self.client.get(url).send()?;
        let base_url = response.url().clone();
        let status = response.status();
        let body = response.text()?;
        let doc = Document::from(body.as_str());
        let mut links = Vec::new();
        log::info!("Retrieved {} \"{}\"", status, base_url);

また、ログの表示のためにmain関数でenv_logger::init関数を呼んでおく必要があります。

src/main.rs
use reqwest::blocking::ClientBuilder;
use url::Url;
use mini_crawler::LinkExtractor;

fn main() -> eyre::Result<()> {
    env_logger::init();

    let url = std::env::args()
        .nth(1)
        .unwrap_or("https://www.rust-lang.org".to_owned());
    let url = Url::parse(&url)?;
    let client = ClientBuilder::new()
        .build()?;
    let extractor = LinkExtractor::from_client(client);

    let links = extractor.get_links(url)?;
    for link in links.iter() {
        println!("{}", link);
    }

    Ok(())
}

さて、mini-crawlerを動かしてログが表示されるか確かめてみましょう。env_loggerクレートでログを表示するためには、RUST_LOG環境変数にログレベルを設定する必要があります。env_loggerクレートはRUST_LOG環境変数で指定されたログレベル以上のレベルが設定されたログを表示します。今回はRUST_LOG=infoと設定しておきます。

以下の例ではmini_crawlerに短縮URLを渡しました。2つ目のログでリダイレクト後のURLが表示されていることがわかります。

% RUST_LOG=info cargo run -- https://bit.ly/2J6BnlL
    Finished dev [unoptimized + debuginfo] target(s) in 3.04s
     Running `target/debug/mini-crawler 'https://bit.ly/2J6BnlL'`
[2020-11-24T01:44:06Z INFO  mini_crawler] GET "https://bit.ly/2J6BnlL"
[2020-11-24T01:44:07Z INFO  mini_crawler] Retrieved 200 OK "https://www.rust-lang.org/"
-- snip --

エラー処理

LinkExtractor::get_linksはその中で呼び出した関数から返ってきたエラーをeyre::Reportに詰めて返していました。簡単なプログラムであればこれでも問題はないと思いますが、練習のために独自のエラー型を定義しましょう。

とはいえ自力でErrorトレイトなどの必要なトレイトを実装するのは大変なので、thiserrorクレートを使って自動で実装しましょう。

エラーが返ってくる箇所を洗い出しましょう。?演算子を使った部分を探すことで見つけられます。まず、GETリクエストを送信してレスポンスを得るところでエラーが返ってくる可能性があります。

src/lib.rs
        let response = self.client.get(url).send()?;

次に、レスポンスからボディをStringにして取り出す部分です。

src/lib.rs
        let body = response.text()?;

そして、URLを絶対URLに変換するときにjoinを呼び出すところです。

src/lib.rs
                    let url = base_url.join(href)?;

それぞれのエラーの原因は、

  • リクエストの送信に起因するもの
  • レスポンスボディの取得に起因するもの
  • 絶対URLへの変換に起因するもの
    になります。これらの原因を表現するために次の列挙型を定義します。
src/lib.rs
pub enum GetLinksError {
    SendRequest,
    ResponseBody,
    AbsolutizeUrl,
}

まずはエラーメッセージを書きます。use宣言でthiserror::Errorを使うことを宣言してから#[derive(Error)]を使うことで、thiserrorクレートのマクロを使うことができるようになります。そして各ヴァリアントに#[error("...")]を付けることでDisplayトレイトを自動で実装することができます。

src/lib.rs
use thiserror::Error;

#[derive(Error,Debug)]
pub enum GetLinksError {
    #[error("Failed to send a request")]
    SendRequest,
    #[error("Failed to read the response body")]
    ResponseBody,
    #[error("Failed to make the link URL absolute")]
    AbsolutizeUrl,
}

次に、エラーの原因を保持できるように各ヴァリアントに#[source]属性をつけた値を持たせることにします。

src/lib.rs
#[derive(Error,Debug)]
pub enum GetLinksError {
    #[error("Failed to send a request")]
    SendRequest(#[source] reqwest::Error),
    #[error("Failed to read the response body")]
    ResponseBody(#[source] reqwest::Error),
    #[error("Failed to make the link URL absolute")]
    AbsolutizeUrl(#[source] url::ParseError),
}

そして、get_linksメソッドの戻り値の型をResult<Vec<Url>, GetLinksError>に変更して、エラーを返す箇所を

src/lib.rs
        let response = self.client.get(url).send()
            .map_err(|e| GetLinksError::SendRequest(e))?;

のように、map_errを使って型を変換してあげるようにすれば、コンパイルが通るようになります。

その他こまかな処理

ステータスコードの処理

現在の実装ではレスポンスのステータスコードが404や500などのエラーであってもリンクの抽出をしてしまいます。多くのウェブサイトではエラーを表すステータスコードを持つレスポンスにもHTMLのボディを付加します。

例えば、存在しないページのURLを引数に渡してmini-crawlerを実行すると次の結果が得られます。

% RUST_LOG=info cargo run -- https://example.com/xxx
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/mini-crawler 'https://example.com/xxx'`
[2020-11-24T10:24:11Z INFO  mini_crawler] GET "https://example.com/xxx"
[2020-11-24T10:24:12Z INFO  mini_crawler] Retrieved 404 Not Found "https://example.com/xxx"
https://www.iana.org/domains/example

https://example.com/xxx をブラウザで開いてみるとリンクが一つだけあります。なのでこの結果は正しいといえば正しいです。しかしながら、存在しないページにアクセスした場合はエラーを返してほしいと考えるのが自然でしょう。

レスポンスのステータスコードがエラーを表すものだったときに、get_linksメソッドがエラーを返すようにするために、ステータスコードを見て条件分岐するのも一つの手ですが、reqwestクレートにはそのような処理にお誂え向きのメソッドが用意されています。

Response構造体のerror_for_statusメソッドは、サーバーがエラーを返した場合にレスポンスをreqwest::Errorに変換します。早速このメソッドを使うように修正してみましょう。

まず、GetLinksErrorに新たなヴァリアントServerErrorを追加します。

src/lib.rs
#[derive(Error,Debug)]
pub enum GetLinksError {
    #[error("Failed to send a request")]
    SendRequest(#[source] reqwest::Error),
    #[error("Failed to read the response body")]
    ResponseBody(#[source] reqwest::Error),
    #[error("Failed to make the link URL absolute")]
    AbsolutizeUrl(#[source] url::ParseError),
    #[error("Server returned an error")]
    ServerError(#[source] reqwest::Error),
}

そして、受け取ったレスポンスを、error_for_statusを呼ぶことでエラーに変換します。

src/lib.rs
        let response = self.client.get(url).send()
            .map_err(|e| GetLinksError::SendRequest(e))?;
        let response = response.error_for_status()
            .map_err(|e| GetLinksError::ServerError(e))?;

以上の変更を加えてからmini-crawlerに存在しないページのURLを渡すと動作が変わります。

% RUST_LOG=info cargo run -- https://example.com/xxx
    Finished dev [unoptimized + debuginfo] target(s) in 3.07s
     Running `target/debug/mini-crawler 'https://example.com/xxx'`
[2020-11-24T10:49:56Z INFO  mini_crawler] GET "https://example.com/xxx"
Error: Server returned an error

Caused by:
    HTTP status client error (404 Not Found) for url (https://example.com/xxx)

Location:
    src/main.rs:16:41

フラグメントの削除

これまで忘れていたのですが、<a>要素から取得したリンクに付いたフラグメントを削除する処理を入れることにします。フラグメントとはURLの#以降の部分のことでした。この部分はHTMLに対してはフラグメントと同じ名前のid属性値を持つ要素を指します。ブラウザではフラグメント付きのURLを開いたときは、該当する要素の位置までページをスクロールします。

なのでフラグメントはあってもなくても同じページを指しますし、普通はフラグメントをサーバーに送ることはしません。また、あとで幅優先探索を行うときに同じページに違う名前が付いているのは困るのでフラグメントは消してしまいます。

フラグメントを削除するにはset_fragmentメソッドにNoneを渡すだけでできます。

フラグメントの削除をするのと同時に、URLの操作でエラーが起きたときにエラーを返さずにログを残すように書き換えます[1]。今回はlog::info!ではなくlog::warn!を使おうと思います。

src/lib.rs
        for href in doc.find(Name("a")).filter_map(|a| a.attr("href")) {
            match Url::parse(href) {
                Ok(mut url) => {
                    url.set_fragment(None);
                    links.push(url);
                },
                Err(UrlParseError::RelativeUrlWithoutBase) => {
                    match base_url.join(href) {
                        Ok(mut url) => {
                            url.set_fragment(None);
                            links.push(url);
                        },
                        Err(e) => {
                            log::warn!("URL join error: {}", e);
                        },
                    }
                },
                Err(e) => {
                    log::warn!("URL parse error: {}", e);
                },
            }
        }

この章で作成したファイル

演習

  1. HTTPレスポンスのヘッダをログに残すようにしてください。ログレベルはdebugにします。
脚注
  1. get_linksVec<Url>を返すためにエラーを握り潰す実装を採用してしまいました。ライブラリコードとしてきちんとエラーを報告するためにはVec<Result<Url>>を返す方がよいでしょう。この場合、エラーをどう処理するかは呼び出した側に委ねます。 ↩︎