Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

Rust の HTTP クライアント surf を試してみる

async/await に対応した HTTP サーバーの tide を先日紹介し、先日も記事を書きました。

同様に async/await に対応した HTTP クライアントの surf というライブラリがあるようなので、それを軽く紹介したいと思います。執筆時点での surf のバージョンは 1.0.3 です。

surf

github.com

async/await に対応した HTTP クライアントです。HTTP サーバー用のライブラリ tide と同様に、async_std を非同期処理ランタイムに使用しています。ちなみに、Rust の HTTP クライアントライブラリで扱いやすいものとしては、 reqwest が有名で、私自身もよく利用します。reqwest は非同期ランタイムに tokio を使用しています。

tide と合わせて「波乗り」になっているのがおもしろいですね。ネットサーフィンの surf から取ってきているのだと思います。

機能

対応している代表的な機能は、ドキュメントによると下記の通りです。

  • もちろんですが一通り HTTP メソッドに対応している。
  • TLS/SSL はデフォルトで対応している。
  • ストリーム処理に対応している。
  • Client インターフェースを介して接続を再利用できる。
  • Logger などのミドルウェアを拡張することができる。
  • HTTP/2 が標準で入っている。

今回は、下記の機能を試してみたいと思います。

準備

Cargo.toml に下記を追加します。

[dependencies]
surf = "1.0.3"
serde = { version = "1.0", features = ["derive"] }
async-std = { version = "1.5.0", features = ["attributes"] }

serde は Rust における JSON シリアライズ/デシリアライズ用のライブラリです。

また、async_std の attributes という features を今回は使用します。詳細は後ほど解説します。

GET リクエストを送る

README にしたがって、GET リクエストを送ってみましょう!

素直に書くと次のようになります。

use async_std::task;

fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
    task::block_on(async {
        let mut res = surf::get("https://httpbin.org/get").await?;
        dbg!(res.body_string().await?);
        Ok(())
    })
}

一方で、task::block_on は少しノイズが多いと感じるかもしれません。私は次のように記述するのが好きなので、以下では、task::block_on をすべて下記のコードのように読み替えていきます。

#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
    let mut res = surf::get("https://httpbin.org/get").await?;
    dbg!(res.body_string().await?);
    Ok(())
}

#[async_std::main] を利用して fn main()async fn main() に変更し、task::block_on ブロックをなくしました。

結果は次のようになりました。{deleted_by_author} は私が手を加えて消したものです。

❯❯❯ cargo run
   Compiling surf_example v0.1.0 (surf_example)
    Finished dev [unoptimized + debuginfo] target(s) in 1.64s
     Running `target/debug/surf_example`
[src/main.rs:4] res.body_string().await? = "{\n  \"args\": {}, \n  \"headers\": {\n    \"Accept\": \"*/*\", \n    \"Accept-Encoding\": \"deflate, gzip\", \n    \"Host\": \"httpbin.org\", \n    \"Transfer-Encoding\": \"chunked\", \n    \"User-Agent\": \"curl/7.64.1 isahc/0.7.6\", \n    \"X-Amzn-Trace-Id\": \"Root={deleted_by_author}\"\n  }, \n  \"origin\": \"{deleted_by_author}\", \n  \"url\": \"https://httpbin.org/get\"\n}\n"

JSON を送る POST リクエス

JSON を送ってみましょう。同様に README から拝借しています。

use serde::{Deserialize, Serialize};

#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
    #[derive(Deserialize, Serialize)]
    struct Ip {
        ip: String,
    }

    let uri = "https://httpbin.org/post";
    let data = &Ip {
        ip: "129.0.0.1".into(),
    };
    let res = surf::post(uri).body_json(data)?.await?;
    assert_eq!(res.status(), 200);

    let uri = "https://api.ipify.org?format=json";
    let Ip { ip } = surf::get(uri).recv_json().await?;
    assert!(ip.len() > 10);
    Ok(())
}

assertion が通れば、無事動作していると言えます。実際に動かしてみると、

~/dev/rust/surf_example via 🦀 v1.41.1
❯❯❯ cargo run
   Compiling surf_example v0.1.0 (surf_example)
    Finished dev [unoptimized + debuginfo] target(s) in 1.89s
     Running `target/debug/surf_example`
~/dev/rust/surf_example via 🦀 v1.41.1
❯❯❯

すごくわかりにくいかもしれませんが、assertion が落ちることなく確かに動いていました。

送った後にレスポンスボディを取り出すことも可能で、

use serde::{Deserialize, Serialize};

#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
    #[derive(Deserialize, Serialize, Debug)]
    struct Ip {
        ip: String,
    }

    let uri = "https://httpbin.org/post";
    let data = &Ip {
        ip: "129.0.0.1".into(),
    };
    let mut res = surf::post(uri).body_json(data)?.await?;
    let body = res.body_string().await?;
    println!("{}", body);

    Ok(())
}

上記のように書くと、実際のリクエストボディを取得することができます。今回はしませんでしたが、body_json() も実装されていて、serde を使って構造体に復元することも可能です。

❯❯❯ cargo run
   Compiling surf_example v0.1.0 (surf_example)
    Finished dev [unoptimized + debuginfo] target(s) in 1.83s
     Running `target/debug/surf_example`
{
  "args": {},
  "data": "{\"ip\":\"129.0.0.1\"}",
  "files": {},
  "form": {},
  "headers": {
    "Accept": "*/*",
    "Accept-Encoding": "deflate, gzip",
    "Content-Type": "application/json",
    "Host": "httpbin.org",
    "Transfer-Encoding": "chunked",
    "User-Agent": "curl/7.64.1 isahc/0.7.6",
    "X-Amzn-Trace-Id": "Root={deleted_by_author}"
  },
  "json": {
    "ip": "129.0.0.1"
  },
  "origin": "{deleted_by_author}",
  "url": "https://httpbin.org/post"
}

ミドルウェア

ミドルウェアは少し大変です。今回は、自分の好きなロギングライブラリを処理の途中にはさむということをやってみたいと思います。

まず、Cargo.toml に下記のクレートを追加します。

[dependencies]
surf = "1.0.3"
serde = { version = "1.0", features = ["derive"] }
async-std = { version = "1.5.0", features = ["attributes"] }
log = "0.4.8" # ロギング関係のもの。追加。
simple_logger = "1.6.0" # ロギング関係のもの。追加。
futures = "0.3" # ミドルウェアを記述するために必要。追加。

Middleware というトレイトに、リクエスト時にログを書いて、リクエストを送って、かかった時間をロギングして返すというミドルウェアの実装を追加したいと思います。Middleware というトレイトは handle という関数が実装可能な状態になっていますね。

/// Middleware that wraps around remaining middleware chain.
pub trait Middleware<C: HttpClient>: 'static + Send + Sync {
    /// Asynchronously handle the request, and return a response.
    fn handle<'a>(
        &'a self,
        req: Request,
        client: C,
        next: Next<'a, C>,
    ) -> BoxFuture<'a, Result<Response, Exception>>;
}

書いてみます。

use futures::future::BoxFuture;
use log::{info, Level};
use std::time::Instant;
use surf::middleware::{HttpClient, Middleware, Next, Request, Response};
use surf::Exception;

struct HttpReqLogger;

impl<C: HttpClient> Middleware<C> for HttpReqLogger {
    fn handle<'a>(
        &'a self,
        req: Request,
        client: C,
        next: Next<'a, C>,
    ) -> BoxFuture<'a, Result<Response, Exception>> {
        Box::pin(async move {
            info!("Request: {}", req.uri());
            let now = Instant::now();
            let res = next.run(req, client).await?;
            info!("Request completed: {:?}", now.elapsed());
            Ok(res)
        })
    }
}

#[async_std::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
    simple_logger::init_with_level(Level::Info).unwrap();
    surf::get("https://httpbin.org/get")
        .middleware(HttpReqLogger {})
        .recv_string()
        .await?;
    Ok(())
}

Box::pin[*1] の中に、ミドルウェアの処理を書いていきます。今回は simple_logger というクレートにロギングを行わせてみました。中でリクエストを投げてレスポンスを得るまでの時間経過をプリントしています。

これを実行してみました!

❯❯❯ cargo run
   Compiling surf_example v0.1.0 (surf_example)
    Finished dev [unoptimized + debuginfo] target(s) in 2.13s
     Running `target/debug/surf_example`
2020-02-29 15:09:33,558 INFO  [surf::middleware::logger::native] sending request
2020-02-29 15:09:33,559 INFO  [surf_example] Request: https://httpbin.org/get
2020-02-29 15:09:34,486 INFO  [surf_example] Request completed: 927.326608ms
2020-02-29 15:09:34,486 INFO  [surf::middleware::logger::native] request completed

期待通りの挙動を示していますね!

まとめ

  • surf という async_std ベースの HTTP クライアントライブラリがある。
  • HTTP クライアントとして使う際にやりたいことは、ドキュメントを読んだ感じでは一通り揃っている。

*1:Box::pin(std::pin::Pin)について本当に簡単にですが補足しておきましょう。async/await によって Rust にはジェネレータが投下されることになったのですが、その際自己参照する構造体を扱う必要が出てきました。しかし、Rust の move とは相性が悪く、普通に自己参照を move しようとすると、ポインタが古い領域を示してしまい、不正な状態に陥ることになるという問題がありました。それを解決するために導入されたのが、Pin という概念でした。詳しい解説はこちら