Save 37% off PRO during our Black Friday Sale! »

Rustハンズオン第4回 Webバックエンド編

68dad178ea4fa6aa86862b3a66a15306?s=47 Yuki Toyoda
May 15, 2021
260

Rustハンズオン第4回 Webバックエンド編

2021/04/15に開催した社内向けハンズオンの資料です。

68dad178ea4fa6aa86862b3a66a15306?s=128

Yuki Toyoda

May 15, 2021
Tweet

Transcript

  1. Rust ハンズオン第 4 回 Web バックエンド実装編 1

  2. 今日のゴール Rust による Web バックエンド開発を体感する。 actix-web の基本的な使い方を理解する。 アプリケーションを作る際に便利なクレートを知ってもらう。 2

  3. 免責事項 1 時間しかないので、すべてを完璧に説明し切ることはできません。 文法を復習したりはしません。適宜キーワードをいうので、手元で調べてもらえると助 かります。 ただ応用的で他の言語にはない文法がある場合には解説します。 Rust でも Web アプリケーションが書ける(しかも結構楽に)というのを知ってもらう

    ことと、 どういったライブラリがあるのか、エコシステムを知ってもらうことが目的です。 説明の便宜上、 unwrap を多用します。 3
  4. 目次 今回作るアプリケーションの仕様について ヘルスチェックできるエンドポイントを作る データベースと接続して、タスクの保存や取得を行えるようにする 4

  5. 今回作るアプリケーションの仕様について 5

  6. 仕様 よくある Todo アプリのバックエンドを作成します。 POST するとデータベースに新しいタスクを作成できるものとします。 6

  7. ソースコード https://github.com/yuk1ty/rust-webapp-tutorial 7

  8. 事前準備 Rust のインストール SQLite のインストール: brew install sqlite3 8

  9. ヘルスチェックできるエンドポイントを作る 9

  10. 作るもののゴール health というエンドポイントに GET リクエストを投げると、HTTP ステータスコードが 200 OK でかつ body

    に OK という文字列を含むレスポンスを返すエンドポイントを用 意します。 ❯ curl localhost:8080/health OK 10
  11. Rust と Web Rust にはいくつか HTTP サーバーを作るためのライブラリが存在します。 現在最も利用例が多いのは actix-web というクレートです。

    HTTP サーバーを本番環境で使用できるレベルで一通り機能が揃っているので、今回は これを利用します。 https://actix.rs/ 11
  12. 新しいプロジェクトを用意する cargo new webapp-handson 12

  13. Cargo.toml に actix-web への依存を追加する [dependencies] actix-web = "3.3.2" あるいは、 cargo-edit

    というツールを使い、下記を実行すると追加できます。 $ cargo install cargo-edit $ cargo add actix-web 13
  14. ヘルスチェックするエンドポイントを書く 14

  15. コードはこちら https://github.com/yuk1ty/rust-webapp-tutorial/tree/master/health-check 15

  16. 実際にヘルスチェックエンドポイントを書いてみる use actix_web::{get, App, HttpResponse, HttpServer, Responder}; #[get("/health")] async fn

    hc() -> impl Responder { HttpResponse::Ok().body("OK") } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| App::new().service(hc)) .bind("127.0.0.1:8080")? .run() .await } 16
  17. ん? かなり見慣れない感じのコードになっているような…… 1 つ 1 つ解説していきます。 17

  18. サーバーの起動とエンドポイントの定義 HttpServer という構造体が HTTP サーバーの大本。 new するタイミングで投げ込まれる App によって、エンドポイントを登録している。 bind

    によって、どのアドレス・ポートで開くかを決定している。 ? は Result 型がエラーだったときにエラーの伝播を自動でしてくれる。 run で起動する。 async fn main() -> std::io::Result<()> { HttpServer::new(|| App::new().service(hc)) .bind("127.0.0.1:8080")? .run() .await } 18
  19. async / .await Rust では、非同期処理は async と await というキーワードを使って行う。 意味合いは

    JavaScript や C# などで登場する同名のキーワードと同じ。 19
  20. 非同期ランタイム Rust の async / .await はあくまでただのシンタックスシュガーになっている。 コンパイラは、裏では Future に変換して処理する。

    そのため、 Future のスケジューリングや実行を行う基盤が必要になる。 tokio , async-std などが有力。 エコシステムの関係で tokio を選んでおくのが今は無難。 20
  21. impl Trait Trait が実装された型を返す、という意味になる。 このケースだと Responder トレイトが実装された型なら返せる、という意味になる。 返す型が複雑になりすぎるケースで、型を簡略化するために使用する。 async fn

    hc() -> impl Responder { ... 21
  22. マクロ 似たような処理をまとめるもの。 関数とは少し違い、プログラムのメタ的な解析をしてより強力に共通化できる。 C/C++ のマクロとは異なり衛生的なマクロになっている。 Rust のマクロには 2 種類ある。 宣言的マクロ:

    ! が名前の後ろにあるもの。DSL を作るのに使われる。 手続きマクロ: 関数とかを解析してコードを自動生成できる機能をもつ。 22
  23. マクロ actix-web のエンドポイントの定義は手続きマクロを用いて行うことができる。 エンドポイントが GET メソッドであることを付与する エンドポイントのパスが /health であることを付与する コンパイル時に実装が展開されて、補完されるイメージ。

    #[get("/health")] async fn hc() -> impl Responder { ... 23
  24. マクロ cargo expand というツールを使用すると、マクロの展開結果を見ることができる。 これを使って先ほどのマクロを展開してみた結果は下記のようになっている。 #[allow(non_camel_case_types, missing_docs)] pub struct hc;

    impl actix_web::dev::HttpServiceFactory for hc { fn register(self, __config: &mut actix_web::dev::AppService) { async fn hc() -> impl Responder { HttpResponse::Ok().body("OK") } let __resource = actix_web::Resource::new("/health") .name("hc") .guard(actix_web::guard::Get()) .to(hc); actix_web::dev::HttpServiceFactory::register(__resource, __config) } } 24
  25. マクロ 非同期ランタイムのメイン関数であることを示す下記のコードも… #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(||

    App::new().service(hc)) .bind("127.0.0.1:8080")? .run() .await } 25
  26. マクロ 実は裏側では下記のように展開されていることがわかる。 fn main() -> std::io::Result<()> { actix_web::rt::System::new("main").block_on(async move {

    { HttpServer::new(|| App::new().service(hc)) .bind("127.0.0.1:8080")? .run() .await } }) } 26
  27. DSL が厳しすぎる…? 気持ちはわかる。 27

  28. クロージャー 他の言語でいうところのラムダ式とか高階関数とかそういう感じのやつ。 Fn , FnMut , FnOnce といった高階関数トレイトを引数とするとクロージャーとなる。 || は引数をとくに使用しないことを示す。

    ... HttpServer::new(|| App::new().service(hc)) ... 28
  29. この節のまとめ actix-web を使ったエンドポイントの設定方法を学んだ。 いくつかの Rust の応用的な文法について学んだ。 async/await impl Trait マクロ

    クロージャー 29
  30. データベースに接続して、タスクの保存や取得を行えるよ うにする 30

  31. 作るもののゴール 下記の JSON を使った HTTP リクエストを送ると、sqlite にタスクを登録できるように します。 $ curl

    -X POST -H "Content-Type: application/json" \ -d '{"description":" タスク1"}' localhost:8080/todo 31
  32. コードはこちら 一部チュートリアルでは解説しなかったものも含まれます。 https://github.com/yuk1ty/rust-webapp-tutorial/tree/master/second-todo-list 32

  33. Cargo.toml に必要なクレートの設定を追加する [dependencies] actix-web = "3.3.2" serde = { version

    = "1.0.125", features = ["derive"] } chrono = { version = "0.4.19", features = ["serde"] } uuid = { version = "0.8.2", features = ["serde", "v4"] } r2d2 = "0.8.9" r2d2_sqlite = "0.18.0" rusqlite = "0.25.0" 33
  34. データベースへの接続をする 今回は sqlite を使ってデータベースへの接続を行ってみます。 接続を確立できるように、いくつか設定をメイン関数に用意してみましょう。 うまくいくとエラーは出ることなく起動できるはず。 34

  35. use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; #[actix_web::main] async fn main() -> std::io::Result<()>

    { let manager = SqliteConnectionManager::file("test.db"); let pool = Pool::new(manager).unwrap(); HttpServer::new(move || { App::new() .data(pool.clone()) .service(hc) }) .bind("127.0.0.1:8080")? .run() .await } 35
  36. 接続の確立とコネクションプールの生成 r2de_sqlite というクレートを使って ConnectionManager を用意する。 r2d2 というクレートを使って、コネクションプールを生成する。 ... let manager

    = SqliteConnectionManager::file("test.db"); let pool = Pool::new(manager).unwrap(); ... 36
  37. コネクションプールを actix-web にセットする App のもつ data メソッドを利用してコネクションプールを他の箇所でも利用できる ようにする。 後々 Data<T>

    という構造体を使って取り出す。 move キーワードは、クロージャ内で使用した外部の値の所有権をクロージャー内に移 すという意味合いをもつ。 ... HttpServer::new(move || { App::new() .data(pool.clone()) .service(hc) }) ... 37
  38. Todo 構造体を用意する 先ほどのコードに Todo 構造体を追加してみましょう。 TaskId は後述しますが New Type パターンと呼ばれる手法を使用しています。

    use serde::Serialize; use uuid::Uuid; #[derive(Serialize)] struct TaskId(Uuid); #[derive(Serialize)] struct Todo { id: TaskId, description: String, done: bool, datetime: DateTime<Utc>, } 38
  39. JSON 化できる仕組み #[derive(Serialize)] がキーです。これが serde というクレートの機能を呼び出し ています。 #[] ではじまるものをアトリビュートと呼びます。 親の構造体で

    Serialize を継承させた場合、内部に持つフィールドの構造体やデータ 型もすべて Serialize を実装している必要があります。 39
  40. Todo をリストで返せる構造体を実装する Todo 構造体をリストで返せる構造体をさらに用意します。 後述しますが New Type パターンというものを使用して定義します。 #[derive(Serialize)] struct

    TodoList(Vec<Todo>); 40
  41. New Type パターン 型システム上区別される名前をつける。 コンパイルタイムでは TaskId と Uuid は完全に区別される。 そういった意味で型エイリアスとは別物。

    DDD でいう値クラスなどを作ることができる。 TaskId がいい例。 41
  42. serde serde はデータ構造をシリアライズ/デシリアライズできるクレート。 JSON だけでなく、他のいろんな構造にシリアライズ/デシリアライズできる。 よく使う。 42

  43. その他の便利クレート uuid : UUID を生成できる。 chrono : DateTime 型がそれ。Rust の日付操作のデファクト。

    43
  44. Todo を登録するエンドポイントを作りたい /todo というエンドポイントに対して下記の JSON を POST すると、Todo が中に作 成されるようにしたい。

    { "description": "Rust ハンズオンに出席する" } 44
  45. HTTP リクエストの JSON ボディをデシリアライズする 下記構造体を用意し、HTTP リクエストの JSON ボディのデシリアライズに使用する。 id や

    datetime は後ほど、リクエストを受け取ったタイミングで生成して埋める。 #[derive(Deserialize)] struct RegisterTodo { description: String, } 45
  46. POST エンドポイントを作って JSON を受け取れるようにする Json<RegisterTodo> から構造体を取り出すには .0 と書く。 これはタプルを取り出す記法で、タプルの 0

    番目を取り出すという意味。 // 関数を書く #[post("/todo")] async fn register_todo( req: Json<RegisterTodo>, db: Data<Pool<SqliteConnectionManager>>, ) -> impl Responder { println!("{}", req.0.description); HttpResponse::Ok() } 46
  47. エンドポイントを作ったら登録する 登録後、リクエストを送ってみると、送った description の内容が出力されるはず。 // main 関数内: App に登録する HttpServer::new(move

    || { App::new() .data(pool.clone()) .service(hc) .service(register_todo) }) 47
  48. sqlite に登録するための下準備を開始する sqlite は UUID 型や datetime 型が存在しない(らしい)。 それらはすべて文字列で入れる必要がある。 bool

    もないので、integer で入れ直す必要がある。 変換するための構造体を用意する。 struct SqliteTodo { id: String, description: String, done: u8, datetime: String, } 48
  49. From トレイト データ変換を行き来するための特別な From<T> というトレイトが存在する。 コンパイラから見た意義についてはこの記事が詳しい: https://qiita.com/hadashiA/items/d0c34a4ba74564337d2f Todo と SqliteTodo

    を行き来することができるようになる。 後ほど実例を紹介する。 impl From<SqliteTodo> for Todo { fn from(st: SqliteTodo) -> Self { Todo { id: Uuid::parse_str(st.id.as_str()).unwrap(), description: st.description, done: matches!(st.done, 1), datetime: Utc .from_local_datetime( &NaiveDateTime::parse_from_str(st.datetime.as_str(), "%Y-%m-%dT%H:%M:%S") .unwrap(), ) .unwrap(), } } } 49
  50. トレイト 共通する振る舞いを定義するために使用する。 トレイト (trait) = 束 (たば ≠ Lattice)。メソッドが束として定義されているイメージ。 他言語でいう

    interface みたいなものだが、それより若干機能が多い。 50
  51. matches! マクロ done: matches!(st.done, 1) 左に判定したい対象、右に true にしたい値を入れる。 右の値に左の値が一致すれば true

    が返る。 51
  52. POST エンドポイント内で sqlite に登録をかけたい #[post("/todo")] async fn register_todo( req: Json<RegisterTodo>,

    db: Data<Pool<SqliteConnectionManager>>, ) -> impl Responder { let id = Uuid::new_v4(); let todo = SqliteTodo { id: id.to_string(), description: req.0.description, done: 0, datetime: Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(), }; let conn = db.get().unwrap(); conn.execute( "insert into todo (id, description, done, datetime) values(?1, ?2, ?3, ?4)", params![todo.id, todo.description, todo.done, todo.datetime], ) .unwrap(); // ... 続く 52
  53. // ... 続き let mut stmt = conn .prepare("select id,

    description, done, datetime from todo where id = ?") .unwrap(); let results: Vec<Todo> = stmt .query_map(params![id.to_string()], |row| { Ok(SqliteTodo { id: row.get_unwrap(0), description: row.get_unwrap(1), done: row.get_unwrap(2), datetime: row.get_unwrap(3), }) }) .unwrap() .into_iter() .map(|r| Todo::from(r.unwrap())) .collect(); HttpResponse::Ok().json(TodoList(results)) } 53
  54. POST エンドポイント内で sqlite に登録をかけたい Data<Pool<SqliteConnectionManager>> : 少し前に設定した actix-web の App

    に登 録されているコネクションプールを持っている。 下記でコネクションを取り出している。 let conn = db.get().unwrap(); 54
  55. POST エンドポイント内で sqlite に登録をかけたい insert クエリを発行している。 conn.execute( "insert into todo

    (id, description, done, datetime) values(?1, ?2, ?3, ?4)", params![todo.id, todo.description, todo.done, todo.datetime], ) .unwrap(); 55
  56. POST エンドポイント内で sqlite に登録をかけたい Prepared Statement を発行している。 let mut stmt

    = conn .prepare("select id, description, done, datetime from todo where id = ?") .unwrap(); 56
  57. POST エンドポイント内で sqlite に登録をかけたい 発行した UUID でテーブルを検索し、合致するレコードを取り出す。 最終的にクライアントに JSON で登録したレコードを返したいので、

    SqliteTodo から Todo に変換をかけている。 変換をかける部分で Todo::from を呼んでいる。これが先ほど実装した From トレイ トを裏で呼んでいる。 57
  58. POST エンドポイント内で sqlite に登録をかけたい let results: Vec<Todo> = stmt .query_map(params![id.to_string()],

    |row| { Ok(SqliteTodo { id: row.get_unwrap(0), description: row.get_unwrap(1), done: row.get_unwrap(2), datetime: row.get_unwrap(3), }) }) .unwrap() .into_iter() .map(|r| Todo::from(r.unwrap())) .collect(); 58
  59. 動かして確認してみる POST リクエストを送る。 SQLite のクライアントを起動して、レコードが入ったことを確かめる。 59

  60. この節のまとめ 今回は SQLite に対する操作なので、お手軽な r2d2 系のクレートを使用してデータベー ス接続をしてみた。 Rust でよく使用される New

    Type というワークアラウンドを学んだ。 データ構造をシリアライズ/デシリアライズできる serde について学んだ。 From<T> トレイトを実装しておくと変換処理を抽象化して呼び出しできることを紹介 した。 60
  61. フルバージョンのチュートリアルに記載したもの RDS からすべてのタスクを取り出して返すエンドポイントを作ってあります。 ロギングについても解説しています。 https://github.com/yuk1ty/rust-webapp-tutorial 61

  62. 紹介できなかったもの rusoto: プロダクトでは AWS と組み合わせて使用することが多いと思いますが、AWS SDK です。tokio ベース。 tide: async-std

    をベースとした HTTP サーバーフレームワーク。 62
  63. 紹介できなかったもの 他の RDS への接続系クレート diesel: ORM。MySQL、Postgres など幅広く対応している。 sqlx: Go の

    sqlx と同じ。ORM ではないので軽量だと思う。MySQL、Postgres な ど幅広く対応している。 63