はじめに はじめまして、キャディでバックエンドエンジニアとして働いている高藤です。 キャディでは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 を使って実装を行います。 warp は Github の冒頭に 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 を理解する 今回利用している warp は Filter 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.toml の dependencies に以下を追加します。 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, 型定義の示すとおり、引数の型 T が serde::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 以外にも様々なライブラリ、 フレームワーク が存在するので、そちらも試していただければと思います。