RustでWebアプリケーションを作る

はじめに

はじめまして、キャディでバックエンドエンジニアとして働いている高藤です。 キャディではRustを使ったバックエンドAPIを実装しています。業務ではgRPCサーバを実装していますが、今回はRustを利用した簡単なWebアプリケーションを作成し意外と簡単にAPIサーバが作れる事を紹介させていただきます。

今回はまだRustを触ったことない方でも記事を読み、ちょっとRustやってみようかなと思ってもらえたら幸いです。

前提

Rustの言語仕様など基本的な説明は省略させていただきます。Rust未経験であれば、是非公式のドキュメントを読んでください。

https://doc.rust-lang.org/book/

有志による日本語訳 https://doc.rust-jp.rs/

作るもの

今回はまず単純にHTTP RequestをするとJSONを返すサーバを実装を行います。

環境

❯ rustc --version
rustc 1.41.0 (5e1a79984 2020-01-27)

プロジェクトを作成する

❯ cargo new sample-web-app
     Created binary (application) `sample-web-app` package
❯ cd sample-web-app

依存するcrateの定義

今回のサンプルにはwarpというcrateを使って実装を行います。 warpGithubの冒頭にA super-easyと明記されているようにRustを触ったばかりでも比較的導入が楽だと思っています。

https://github.com/seanmonstar/warp

まずは依存関係を定義します。

sample-web-app/Cargo.toml

[package]
name = "sample_web_app"
version = "0.1.0"
authors = ["nrskt <norisuke_takafuji@caddi.jp>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
tokio = { version = "0.2", features = ["macros"] }
warp = "0.2"

[dependencies]配下に2行追加しました。1つは今回メインとなるwarp,もう1つはwarpが依存するtokioというcrateです。

まずはGithubのREADMEどおりに実装

sample-web-app/src/main.rs

// 今回のサンプルが必要とする`warp.Filter` traitをimportします。
use warp::Filter;

// 今回tokioのランタイムを利用する
// 非同期ランタイムの上で実行されるためmain関数はasyncをつけて定義します
#[tokio::main]
async fn main() {
    // GET /hello/warp => 200 OK with body "Hello, warp!"
    let hello = warp::path!("hello" / String).map(|name| format!("Hello, {}!", name));

    // Serverの起動
    warp::serve(hello).run(([127, 0, 0, 1], 3030)).await;
}

処理内容

  • warp::path!("hello" / String) の箇所で URL パスを定義し、 /hello/ 以下を String 型で受け取ることを宣言します。
  • map(|name| format!("Hello, {}!", name)) の箇所で前述のURLからString型で受け取った値と format!する処理をつなぐように宣言しています。

起動してみる

❯ cargo run
❯ curl localhost:3030/hello/nrskt
Hello, nrskt! 

URLの末尾にある文字列を利用したResponseが返る事を確認できました。

Filter を理解する

今回利用しているwarpFilter traitを実装したFilterと呼ばれる部品を組み合わせて1つの処理を作り上げる仕組みとなっています。

これらのFilterを使っていくつかサンプルを作ってみます。

#[tokio::main]
async fn main() {
    let hello = hello().and(name()).and_then(greet_handler);
    warp::serve(hello).run(([127, 0, 0, 1], 3030)).await;
}

fn hello() -> warp::filters::BoxedFilter<()> {
    warp::path("hello").boxed()
}

fn name() -> warp::filters::BoxedFilter<(String,)> {
    warp::path::param().boxed()
}

async fn greet_handler(name: String) -> Result<impl Reply, Rejection> {
    let reply = format!("hello {}", name);
    Ok(warp::reply::html(reply))
}

先程のpath!マクロで表現していたpathの処理を、hello(), name() Filterに分解し、組み合わせられる部品としました。

また最終的に処理を行うhandlerも関数をして表す事が可能です。

上記の例ではあまりメリットはありませんが、複雑な処理を小さく分解された部品を組み合わせて組み立てる仕組みが強く意識されています。

型安全

先程の例で名前を受け取る部分ではString型のパラメータを受け取るように処理を書いていました(fn name() -> warp::filters::BoxedFilter<(String,)>)。 このままだとどのような文字列が来ても処理を進めることが出来てしまうためhandler内で受け取った値が想定している値かValidationをする必要が発生します。

Rustでは独自の型を定義することが容易にできるため、名前を表す型を用意し、意図しない値がそもそもhandlerに渡ることを防ぐ事が出来ます。

ここでは例として名前の仕様を以下のように定義してみました。

  • [A-Za-z]の文字種を使い、10文字以内で表される

型の定義

/// 名前を表す型の定義
#[derive(Clone, Debug)]
struct Name(String);

impl Name {
    /// 値のチェックを行った上でNameを作成する
    /// 今回はサンプルのため作成の失敗をString型で表現している
    pub fn new(name: &str) -> Result<Self, String> {
        let size = name.chars().count();
        if size < 1 || size > 10 {
            return Err("名前は10文字以内です".to_string());
        }

        if name.chars().any(|c| !c.is_ascii_alphabetic()) {
            return Err("名前が使用できる文字種はA-Z, a-zです".to_string());
        }
        Ok(Name(name.to_string()))
    }
}

/// 文字列からの変換を表す
/// このtraitの実装をwarp::path::params()関数が要求する
impl std::str::FromStr for Name {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Name::new(s)
    }
}

/// handlerでformatを行うために要求される
impl std::fmt::Display for Name {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

#[test]
fn test_name() {
    let ok_value = "Nrskt";
    assert!(Name::new(ok_value).is_ok());

    let ok_value = "N";
    assert!(Name::new(ok_value).is_ok());

    let ok_value = "NrsktNrskt";
    assert!(Name::new(ok_value).is_ok());

    let ng_value = "0";
    assert!(Name::new(ng_value).is_err());

    let ng_value = "";
    assert!(Name::new(ng_value).is_err());

    let ng_value = "NrsktNrsktN";
    assert!(Name::new(ng_value).is_err());
}

これで新しくName型の定義が終わりました。 先程のコードを修正します。

fn name() -> warp::filters::BoxedFilter<(Name,)> {
    warp::path::param().boxed()
}

async fn greet_handler(name: Name) -> Result<impl Reply, Rejection> {
    let reply = format!("hello {}", name);
    Ok(warp::reply::html(reply))
}
  • Pathのパラメータを受け取る部分の戻り値の型をString -> Nameに変更します。
  • greet_handlerの引数の型をString -> Nameに変更します

これによりパラメータ部分から受け取った値がName型の範囲になることが保証されます。

❯ curl -D - localhost:3030/hello/0
HTTP/1.1 404 Not Found

上記の例のようにName型で利用できない文字種が使われた際にエラーを返すようになりました。

Userを取得,保存するAPIを書いてみる

ここからはもう少し実用的な例 としてユーザの取得と保存を行うAPIを実装します。 今回はRESTでよく使われるJSONを利用してRequest値とResponse値を表します。

なお、データの保存についてはHashMapを利用して実装を行います。 (メモリ上にデータが残るためサーバを停止するとデータは消えます。)

最終的にサンプルコードは以下のリポジトリに公開しているので併せて確認をして下さい。

https://github.com/nrskt/sample-web-app

依存関係の修正

JSONを扱うため依存するcrateを追加するためCargo.tomldependenciesに以下を追加します。

serde = { version ="1.0.104", features = ["derive"] }

[package]
name = "sample_web_app"
version = "0.1.0"
authors = ["nrskt <norisuke_takafuji@caddi.jp>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
tokio = { version = "0.2", features = ["macros"] }
warp = "0.2.1"
serde = { version ="1.0.104", features = ["derive"] } 

Userの定義

models.rs

#[derive(Clone, Debug)]
struct User {
    id: u64,
    name: Name,
}

このUser型はJSONとして入出力できなければならないため、Serialize, Deserializeの特性を導出します。

まずUser型の構成要素であるName型にSerialize, Deserializeの実装を行います。

models.rs

// Serializeを追加
#[derive(Clone, Debug, Serialize)]
struct Name(String);

// Deserializeの実装を行う
impl<'de> de::Deserialize<'de> for Name {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: de::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        Name::new(&s).map_err(de::Error::custom)
    }
}

#[derive]Deserializeを自動導出しなかったのは、型の制約が記述されているName::new()を呼び出す必要があったためです。

#[derive(Deserialize)]としてしまうとどのような文字列でもName型に変換できてしまうためこのような実装としています。

同様にUser型に対してSerialize, Deserializeの実装を行います。

models.rs

#[derive(Clone, Debug, Serialize, Deserialize)]
struct User {
    id: u64,
    name: Name,
}

Database(HashMap)の定義

今回のサンプルではUserの情報をHashMapに残すように実装します。併せてDBの初期化を行う関数init_dbを定義します。

db.rs

use std::collections::HashMap;
use std::sync::Arc;

use tokio::sync::Mutex;

use crate::User;

pub type Database = Arc<Mutex<HashMap<u64, User>>>;

pub fn init_db() -> Database {
    Arc::new(Mutex::new(HashMap::new()))
}

Handlerの実装

3つのHandlerを実装します。

  • ユーザを全件取得する処理
  • ユーザIdを指定して特定のユーザを取得する処理
  • ユーザを新規登録、更新する処理

handlers.rs

use warp::{Rejection, Reply};

use crate::{Database, User};

pub async fn list_users_handler(db: Database) -> Result<impl Reply, Rejection> {
    let db = db.lock().await;
    let users = db
        .clone()
        .into_iter()
        .map(|(_, v)| v)
        .collect::<Vec<User>>();
    Ok(warp::reply::json(&users))
}

pub async fn get_user_handler(db: Database, id: u64) -> Result<impl Reply, Rejection> {
    let db = db.lock().await;
    let user = db.get(&id);
    match user {
        None => Err(warp::reject::not_found()),
        Some(u) => Ok(warp::reply::json(&u)),
    }
}

pub async fn put_user_handler(db: Database, id: u64, user: User) -> Result<impl Reply, Rejection> {
    if id != user.id() {
        return Ok(warp::reply::with_status(
            warp::reply::json(&()),
            warp::http::StatusCode::BAD_REQUEST,
        ));
    }
    let mut db = db.lock().await;
    db.insert(user.id(), user.clone());
    Ok(warp::reply::with_status(
        warp::reply::json(&user),
        warp::http::StatusCode::OK,
    ))
}

Replyを作成する際にwarp::reply::json関数を使っています。

pub fn json<T>(val: &T) -> Json 
where
    T: Serialize, 

型定義の示すとおり、引数の型Tserde::Serializeを実装していれば与えたT型の値をJSONに変換したReplyを作成する関数です。

今回の実装ではJSONでの入出力を行うために利用しています。

Filterの定義

続いてFilterの定義を行います。 今回は各Handlerへのルーティングを表すFIlterを用意し、作成した3つのFilterをまとめたusers_apiというFilterを定義しました。

filters.rs

use warp::{Filter, Rejection, Reply};

use crate::{get_user_handler, list_users_handler, put_user_handler, Database};

/// 最終的に公開するFilter
/// 用意した部品を組み合わせて表現する
pub fn users_api(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
    get_user(db.clone()).or(list(db.clone())).or(put_user(db))
}

/// Path "users" を表す部品
fn users() -> warp::filters::BoxedFilter<()> {
    warp::path("users").boxed()
}

/// PathからUserIdを取り出す部品
fn user_id() -> warp::filters::BoxedFilter<(u64,)> {
    warp::path::param().boxed()
}

/// list_users_handlerを呼び出すための部品
fn list(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
    users()
        .and(warp::get())    // HTTP GETメソッドを指定
        .and_then(move || list_users_handler(db.clone()))    // Handlerを呼び出す
}

/// get_user_handlerを呼び出すための部品
fn get_user(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
    users()
        .and(user_id())    // User IdをPathから取得
        .and(warp::get())    // HTTP GETメソッドを指定
        .and_then(move |id| get_user_handler(db.clone(), id))    // Handlerを呼び出す
}

/// put_user_handlerを呼び出すための部品
fn put_user(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
    users()
        .and(user_id())    // User IdをPathから取得
        .and(warp::put())    // HTTP PUTメソッドを指定
        .and(warp::body::json())    // Request Bodyに含まれたJSONを取り出しUser型へ変換
        .and_then(move |id, body| put_user_handler(db.clone(), id, body))    // Handlerを呼び出す
}

かなりややこしい型になりますが、やっている処理自体はPathのマッチ、idを取り出す、Request BodyからJSONを取り出す事を行っています。

warp::body::json()関数はRequest Bodyに含まれるJSONからDeserializeを実装した特定の型への変換を行っています。どの型へ変換するかの指定を行う必要があります。型推論が正しく動かない場合はwarp::body::json::<User>()のようにUser型への変換を明示する必要があります。

今回の例ではput_user_handlerの引数で明示的にUser型を要求しているため省略して記述が可能です。

main関数の実装

最後に実装した部品をmain関数にまとめます。

main.rs

use sample_web_app::{init_db, users_api};

#[tokio::main]
async fn main() {
    // Database(HashMap)の初期化
    let database = init_db();

    // users_api filterにdatabaseを代入してサーバを起動
    warp::serve(users_api(database))
        .run(([127, 0, 0, 1], 3030))
        .await;
}

動作確認

実際にcargo runでサーバを起動して、いくつかテストを行います。

何も登録されていないことを確認する

❯ curl localhost:3030/users
[]  

ユーザの登録

❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/1 -d '{"id": 1, "name": "nrskt"}'
HTTP/1.1 200 OK
content-type: application/json
content-length: 23
date: Mon, 24 Feb 2020 09:10:20 GMT

{"id":1,"name":"nrskt"}      
❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/2 -d '{"id": 2, "name": "neko"}'
HTTP/1.1 200 OK
content-type: application/json
content-length: 22
date: Mon, 24 Feb 2020 09:12:48 GMT

{"id":2,"name":"neko"}   

登録ユーザの取得

❯ curl -D - localhost:3030/users
HTTP/1.1 200 OK
content-type: application/json
content-length: 48
date: Mon, 24 Feb 2020 09:14:03 GMT

[{"id":1,"name":"nrskt"},{"id":2,"name":"neko"}]   

登録した全ユーザを取得することが確認できました。

IDを指定したユーザの取得

❯ curl -D - localhost:3030/users/1
HTTP/1.1 200 OK
content-type: application/json
content-length: 23
date: Mon, 24 Feb 2020 09:19:22 GMT

{"id":1,"name":"nrskt"}

指定したIDのユーザを取得することを確認できました。

誤ったデータの登録

❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/3 -d '{"id": 2, "name": 1}'
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 96
date: Mon, 24 Feb 2020 09:20:52 GMT

Request body deserialize error: invalid type: integer `1`, expected a string at line 1 column 20

文字列を期待している部分に数値型を入れた場合、正しく400 Bad Requestが返る事を確認できました。

❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/3 -d '{"id": 2, "name": "0"}'
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 102
date: Mon, 24 Feb 2020 09:21:33 GMT

Request body deserialize error: 名前が使用できる文字種はA-Z, a-zです at line 1 column 22 

Name型の範囲外の値が指定された場合も正しく400 Bad Requestが返る事を確認できました。

まとめ

簡単な説明となってしまいましたが、warpを利用してRustでWebアプリケーションを実装する例を紹介させていただきました。もちろんwarp以外にも様々なライブラリ、フレームワークが存在するので、そちらも試していただければと思います。