はじめに
はじめまして、キャディでバックエンドエンジニアとして働いている高藤です。 キャディでは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 <[email protected]>"]
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 <[email protected]>"]
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
以外にも様々なライブラリ、フレームワークが存在するので、そちらも試していただければと思います。