RustとActix Webで投稿アプリに状態管理を導入しよう:Webアプリ実装で学ぶ、現場で役立つRust入門(5)
第5回は、第4回の続きとして、テンプレート化した投稿アプリにセッションによる状態管理の仕組みを導入し、それを利用した入力情報の記憶やフラッシュメッセージの実装例を紹介します。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
複数ページでのデータ共有の手段
まずは、「状態管理」という複数ページでのデータ共有の手段について触れておきます。Webの通信手段であるHTTPはステートレス(連続したリクエスト/レスポンスの間で状態を引き継ぐ仕組みを持たないこと)なので、例えばログインユーザーの情報などは別の仕組みを用いて共有する必要があります。
状態管理の方法としては、「クッキー」や「セッション」を使うのが一般的です。以前は、安全性の面でクッキーよりもセッションの利用が推奨されていましたが、サーバ負荷の低減などを理由にセッションのデータストアにクッキーを利用することが多くなっています。
このため、クッキーとセッションの境界は曖昧なのですが、セッションにはデータが暗号化される、データストアに選択肢がある、保存形式にも柔軟性があるといったメリットがあるので、特に秘匿性のあるデータを扱う場合にはセッションを使うことをお勧めします。本稿では、投稿アプリにセッションを導入し、幾つかの活用事例を紹介していきます。
アプリへのセッション導入
ここからは、アプリにセッションを導入していきます。セッションの利用例として、新規作成時に投稿者の名前を記憶して、次回以降の入力を省く機能を追加してみます。連載第4回で作成したアプリactix-postsをベースに、セッションの導入を進めていきます。
セッションのクレートをプロジェクトに追加する
Actix Webのセッション管理クレートであるactix-sessionをプロジェクトに追加します。ただし、actix-sessionの新しいバージョン(例えば本稿作成時点の0.9.0)は後述するactix-web-flash-messagesとの組み合わせで正常に動作しないので、正常に動作するバージョン(ここでは0.6.2)を明示して追加しています。
% cargo add actix-session@0.6.2
actix-sessionは、既定ではデータ格納のためのバックエンドが無効になっているので、使いたいバックエンドを有効にする必要があります。バックエンドには、クッキーベースのセッションであるCookieSessionStore(cookie-session)、オンメモリデータベースRedisを使ったセッションであるRedisActorSessionStore(redis-actor-session)やRedisSessionStore(redis-rs-session)を利用することができます。ここではクッキーベースのセッションを使うことにします。Cargo.tomlファイルのactix-sessionエントリを、リスト1のようにcookie-sessionを使うように修正します。
actix-session = { version = "0.6.2", default-features = false, features = ["cookie-session"] } (1)
アプリでセッションを扱えるようにする
アプリがセッションを扱えるように、リスト2のコードをsrc/main.rsファイルに追記します。
…略… use actix_web::cookie::{Key}; (1) use actix_session::storage::CookieSessionStore; use actix_session::SessionMiddleware; …略… fn build_cookie_session_middleware(key: Key) -> SessionMiddleware<CookieSessionStore> { (2) SessionMiddleware::builder(CookieSessionStore::default(), key).build() } #[actix_rt::main] async fn main() -> Result<()> { env_logger::init_from_env(Env::default().default_filter_or("info")); let key = Key::generate(); (3) HttpServer::new(move || { (4) let tera = Tera::new("templates/**/*.html").unwrap(); App::new() …略… .wrap(build_cookie_session_middleware(key.clone())) (5) }) …略…
(1)で、クッキーのキーを使うためのKey構造体、クッキーベースのセッションを使うためのCookieSessionStore構造体と、セッションミドルウェアのSessionMiddleware構造体を使えるようにします。
(2)は、クッキーベースのセッションミドルウェアインスタンスを生成する関数です。戻り値であるインスタンスは、次で述べるクロージャ内のwrapメソッドに渡されて、ハンドラー関数が引数でセッション情報を受け取ることができるようになります。
(3)は、クッキーのキーの生成です。
(4)では、HttpServer構造体のインスタンスの生成時に指定するクロージャを定義していますが、ここではmoveキーワードを付与しています。moveという名前から想像が付くように、moveキーワードを付与されたクロージャから参照される外部変数に対しては、その所有権がクロージャ内に移動します(基本は参照なので、所有権は移動しません)。SessionMiddleware<CookieSessionStore>はcloneメソッドを持たないので、クロージャ内にインスタンスを直接置くことができません。そこで、関数呼び出しで得られるインスタンスを(5)で直接wrapメソッドに渡しています。
セッションから投稿者名を取り出す
新規投稿ページにおいて、セッションから投稿者名を取り出すようにハンドラー関数を修正します。src/handler.rsファイルをリスト3のように書き換えます。
…略… use actix_session::Session; (1) …略… #[get("/posts/new")] pub async fn new(tmpl: web::Data<tera::Tera>, session: Session) -> impl Responder { (2) info!("Called new"); let mut context = Context::new(); let mut sender = "".to_string(); (3) if let Some(s) = session.get::<String>("sender").unwrap() { (4) sender = s; } else { sender = "名無しさん".to_string(); } let post = data::Message {id:0, sender:sender, content:"".to_string(), posted:"".to_string()}; …略… }
(1)では、ハンドラー関数でセッションに必要なSession構造体を使えるようにしています。main関数でwrapメソッドによりセッションミドルウェアを追加したので、(2)のようにハンドラー関数でセッションであるSession型の引数を受け取ることができます。
(3)以降で、投稿者の名前をセッションから取り出すか、あるいは「名無しさん」で初期化するか選択しています。(4)はセッションからgetメソッドで値を取り出す処理であり、キー"sender"を持つ値が存在すればその値を使用し、そうでない場合は既定値(「名無しさん」)とします。
このような処理で、セッションから値を取り出し、ビューに反映することができます。
セッションに投稿者名を格納する
新規投稿の登録で、セッションに投稿者名を格納するようにハンドラー関数を修正します。src/handler.rsファイルをリスト4のように書き換えます。
#[post("/posts/create")] pub async fn create(params: web::Form<CreateForm>, session: Session) -> impl Responder { (1) info!("Called create"); let now: DateTime<Local> = Local::now(); …略… let _ = session.insert("sender", params.sender.clone()); (2) web::Redirect::to(format!("/posts/{}", message.id)).see_other() }
こちらも、(1)のように引数でセッションを受け取れます。追加した処理は、(2)のように投稿者名(sender)のinsertメソッドによるセッションへの格納のみです。
このような処理で、セッションに値を格納し、自身を含めた他のハンドラー関数でそれを利用することができます。ここで、アプリをビルド、実行して「http://localhost:8000/posts」にアクセスし、新規投稿を複数回試してみて図1のように投稿者名が変化すればセッションの導入は成功です。
アプリへのフラッシュメッセージ導入
状態管理の特殊例として、フラッシュメッセージを導入してみます。フラッシュメッセージとは、登録や削除といったユーザーの操作後に一時的に表示されるメッセージのことです。リダイレクト後のページに表示させる必要があるので、テンプレート変数という形ではメッセージを渡せないため、状態管理の仕組みを使います。フラッシュメッセージにより、投稿の新規作成、更新、削除などの実行後に、成否を分かりやすく表示できるようになります。状態管理の仕組みを通じてメッセージをページ間で受け渡すことでフラッシュメッセージが実現されていますが、通常の使い方とは異なり、参照後は削除されるという特殊な使い方になっています。
フラッシュメッセージのクレートをプロジェクトに追加する
Actix Webに対応したフラッシュメッセージのクレートには幾つかありますが、ここではActix Webのバージョン4に対応し、メッセージストアに柔軟性のあるactix-web-flash-messagesを使うことにします。まずは、actix-web-flash-messagesをプロジェクトに追加します。
% cargo add actix-web-flash-messages
メッセージストアにはクッキーベースのセッションをそのまま使うことにします(設定によってクッキーを使うこともできます)。Cargo.tomlファイルのactix-web-flash-messagesエントリをリスト5のように編集します(featuresに"sessions"を含める)。
actix-web-flash-messages = { version = "0.4.2", features = ["sessions"] }
アプリでフラッシュメッセージを扱えるようにする
アプリがフラッシュメッセージを扱えるように、リスト6のコードをsrc/main.rsファイルに追記します。
…略… use actix_web_flash_messages::FlashMessagesFramework; use actix_web_flash_messages::storage::SessionMessageStore; (1) …略… #[actix_rt::main] async fn main() -> Result<()> { let key = Key::generate(); let message_store = SessionMessageStore::default(); (2) let message_framework = FlashMessagesFramework::builder(message_store).build(); HttpServer::new(move || { …略… .wrap(Logger::default()) .wrap(message_framework.clone()) (3) .wrap(build_cookie_session_middleware(key.clone())) }) .bind("127.0.0.1:8000")?.run().await }
(1)で、フラッシュメッセージを扱うFlashMessagesFramework構造体と、セッションをメッセージストアとして利用するSessionMessageStore構造体を使えるようにします。
(2)からは、メッセージストアを生成、さらにメッセージストアからフレームワークインスタンスを生成しています。これは、actix_web_flash_messagesでセッションによるフラッシュメッセージを扱うときの定型的な記述です。
(3)では、フレームワークインスタンスをwrapメソッドに渡すことで、ハンドラー関数がフラッシュメッセージを受け取ることが可能になります。
テンプレートファイルを修正する
フラッシュメッセージのマークアップをテンプレートに追加します。フラッシュメッセージは全ページで共通であるとして、base.htmlファイルにマークアップをリスト7のように追記します。
…略… <div id="container"> {% block content %} {% if success %} (1) <div class="alert alert-info">{{success}}</div> {% endif %} {% if error %} (2) <div class="alert alert-danger">{{error}}</div> {% endif %} (1') {% endblock content %} </div> …略…
(1)〜(1')が追記部分です。(1)と(2)はそれぞれ、成功時と失敗時に表示するフラッシュメッセージのためのマークアップです。コンテキストでsuccessとerrorが渡されていれば、それはメッセージがあるものとしてマークアップを有効にします。マークアップの内容はBootstrapのアラートコンポーネントで、それぞれ情報(info)と警告(danger)のスタイルで表示されます。
フラッシュメッセージのマークアップは、blockタグのブロック内部にコンテンツとして埋め込んでいます。連載第4回で触れたように、フラッシュメッセージの表示を継承側が選択できるようにするためです。継承側のテンプレートでは、リスト8とリスト9のように{{ super() }}をフラッシュメッセージを表示したい場所に記述します。投稿一覧ページ(index.html)では投稿削除後のメッセージを、投稿表示ページ(show.html)では作成と更新後のメッセージを、それぞれ表示しています。
…略… {% block content %} {{ super() }} <div class="mb-3"> …略…
{% block content %} {{ super() }} {% if post.id == 0 %}
ハンドラー関数を修正する
フラッシュメッセージの送出と、フラッシュメッセージを受け取って展開するコードを、ハンドラー関数に追加していきます。まずは、フラッシュメッセージの送出コードを、新規作成のハンドラー関数にリスト10のように追加します。
…略… use actix_web_flash_messages::{ (1) FlashMessage, IncomingFlashMessages, Level, }; …略… #[post("/posts/create")] pub async fn create(params: web::Form<CreateForm, session: Session>) -> impl Responder { …略… message = data::create(message); if message.id == 0 { (2) FlashMessage::error("投稿でエラーが発生しました。").send(); } else { FlashMessage::success("投稿しました。").send(); } …略… }
基本的に、データ処理クレートdata.rsの関数を呼び出した後に、その結果に基づいて送出するメッセージを決める、といった構造になっています。
(1)では、actix_web_flash_messagesクレートの3つの構造体FlashMessage、IncomingFlashMessages、Levelを使えるようにします。このうちメッセージの送出に使うのはFlashMessage構造体です。IncomingFlashMessages、Levelはメッセージの受け取りと展開に使う構造体なので、後述します。
(2)からは、新規作成時のフラッシュメッセージの送出です。data::create関数の戻り値で、送出するメッセージを分けています。失敗時には、FlashMessage::errorメソッドでエラーメッセージを、成功時にはFlashMessage::successメソッドで成功のメッセージを送出しています。
このように、メッセージの送出はシンプルです。更新のupdateハンドラー関数、削除のdestroyハンドラー関数においても同様なので、配布サンプルを参照してください。
続けて、フラッシュメッセージを受け取って展開するコードを、ハンドラー関数にリスト11のように追加します。
#[get("/posts")] pub async fn index(tmpl: web::Data<tera::Tera>, messages: IncomingFlashMessages) -> impl Responder { (1) …略… for message in messages.iter() { (2) match message.level() { (3) Level::Success => context.insert("success", &message.content()), Level::Error => context.insert("error", &message.content()), _ => (), } } context.insert("posts", &posts); …略… }
フラッシュメッセージを受け取るために、ハンドラー関数に引数が追加され、それに基づいてメッセージを展開するコードになっています。
(1)はindex関数のシグネチャですが、Teraのテンプレートに加えてIncomingFlashMessagesを受け取るように修正されています。IncomingFlashMessagesはリスト10で使用を宣言されたフラッシュメッセージを受け取るための構造体です。
(2)は、引数messagesの全ての要素を取得して処理する処理です。送出のコードでは常に単一のメッセージを送出するのみでしたが、複数のメッセージが送出されてもここで処理できるわけです(テンプレートは、成功時メッセージ、失敗時メッセージを表示できるようになっています)。
(3)では、levelメソッドで取得したメッセージのレベル(actix_web_flash_messagesの定めたメッセージの重要度)に応じてコンテキストに設定する変数を振り分けています。以下の5段階のレベルが定められており、送出時のメソッドに対応しています。
- Debug(debugメソッド)、Info(infoメソッド)
- Success(successメソッド)、Warning(warningメソッド)、Error(errorメソッド)
投稿表示ページのshowハンドラー関数も同様なので、配布サンプルを参照してください。ここで、アプリをビルド、実行して、新規投稿や更新、削除によって図2〜3のように表示されれば、フラッシュメッセージの導入は成功です。
メッセージストアでのクッキーの利用
actix-web-flash-messagesではメッセージストアにセッションの他にクッキーを使うことができます。クッキーを使う場合、actix-sessionの追加などが不要で、利用は非常にシンプルになります。ここでは、クッキーを使う場合の設定を紹介します。
Cargo.tomlファイルをリスト12のように編集します(actix-web-flash-messagesエントリのfeaturesにクッキーを使うための"cookies"を追加)。
actix-web-flash-messages = { version = "0.4.2", features = ["sessions", "cookies"] }
src/main.rsファイルにて、リスト13のコードを追加、修正します。
…略… use actix_web_flash_messages::storage::CookieMessageStore; (1) …略… // メッセージストアにクッキーを使う場合 let message_store = CookieMessageStore::builder(key).build(); (2) // メッセージストアにクッキーベースのセッションを使う場合 // let message_store = SessionMessageStore::default(); (3) // 以降共通 let message_framework = FlashMessagesFramework::builder(message_store).build(); (4) …略…
(1)では、クッキーをメッセージストアに使うためのCookieMessageStore構造体を使えるようにします。
(2)では、クッキーのキーからメッセージストアを生成しています。(4)でメッセージストアからフレームワークインスタンスを生成するのはセッションを使うときと同じです。
(3)は、セッションをメッセージストアに使う場合の記述なのでコメントアウトします。このように、メッセージストアの生成はどちらか一方だけが有効になるようにします。
ここで、アプリをビルド、実行して、新規投稿や更新、削除によってセッション使用のときと同様に表示されることを確認してください。
まとめ
今回は、第4回の続きとして、投稿アプリにセッションによる状態管理の仕組みを導入し、それを利用した入力情報の記憶やフラッシュメッセージの実装例を紹介しました。
次回は、アプリにREST APIを導入し、アプリのフロントエンド部分を実装に備えます。
筆者紹介
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・X: @WingsPro_info(https://x.com/WingsPro_info)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.