連載:Webアプリ実装で学ぶ、現場で役立つRust入門
前回は、Actix Webによる開発例として、シンプルな投稿アプリを作成し、投稿の一覧表示機能を実装しました。今回は、このアプリを拡張し、投稿の表示、作成、更新、削除といった定番の機能を実装していきます。その過程で、Actix Webにおけるリクエストデータの解釈やレスポンスデータ生成の詳細を紹介していきます。
投稿アプリで使うルートとハンドラー関数の対応は以下の通りとします(図1)。RESTfulに近い成り立ちとなりますが、Webブラウザからの呼び出しになるので、基本的にHTTP GETとPOSTを使用したものとなります。
本稿では指定投稿の表示ページ、新規投稿の作成ページを説明します。編集ページについては作成ページとほぼ同じ流れになるので、削除機能ともども配布サンプルを参照してください。
これから多数のページを作成していきますが、ルーティングで対応するハンドラー関数がない場合に表示するデフォルトページを用意しておきます。Actix Webの既定では、該当するページがない場合にはHTTPステータス404を返すだけです。これを、デフォルトページを返すようにしておきましょう。アプリケーションオブジェクト生成後にdefault_serviceメソッドを呼び出すコードを、src/main.rsファイルにリスト1のように追加します。
…略… use actix_web::{App, HttpServer, Responder, HttpResponse, get, web, (1) middleware::Logger}; …略… App::new() .service(index) .default_service(web::to(handler::not_found)) (2) .wrap(Logger::default()) }) …略…
(1)は、(2)のためのweb名前空間の追加、(2)は呼び出すnot_found関数をdefault_serviceメソッドで追加しています。not_found関数は、src/handler.rsファイルにリスト2の内容を追記します。
pub async fn not_found() -> impl Responder { HttpResponse::NotFound().body("Page Not found!") }
処理としては、HTTPステータス404を返すとともに、"Page Not found!"と表示するだけのシンプルなものです。後述するヘッダやフッタも含めるなど内容を拡張してもよいでしょう。アプリケーションをビルド、実行して、/posts/notexistなどの存在しないパスに対して"Page Not found!"と表示されればデフォルトページの登録は成功です。
準備が整ったところで、投稿表示機能を作ります。投稿表示機能は、投稿一覧ページから個別の投稿をクリックして呼び出されるページです。この投稿表示ページから、投稿の編集、削除をできるようにします。
投稿表示機能は、URLパス/posts/{id}(GET)に対応するハンドラー関数を作成してルートを登録します。src/handler.rsファイルに、投稿表示のハンドラー関数showをリスト3のように追加します。
#[get("/posts/{id}")] (1) pub async fn show(info: web::Path<i32>) -> impl Responder { (2) info!("Called show"); let info = info.into_inner(); (3) let post = data::get(info); (4) let mut body_str: String = "".to_string(); (5) body_str += include_str!("../static/header.html"); body_str += "<div>"; if post.id != 0 { body_str += &format!("<div>投稿者:{}</div>", post.sender); body_str += &format!("<div>投稿日時:{}</div>", post.posted); body_str += &format!("<div>投稿内容:<br />{}</div>", post.content.replace("\n", "<br />")); body_str += &format!("<div><a href=\"/posts/{}/edit\">編集</a> ", info); body_str += &format!("<a href=\"/posts/{}/delete\">削除</a><div>", info); } else { body_str += "見つかりません。"; } body_str += "</div>"; body_str += "<div><a href=\"/posts\">一覧へ</a></div>"; body_str += include_str!("../static/footer.html"); HttpResponse::Ok().content_type("text/html; charset=utf-8").body(body_str) }
リスト3は、取得した投稿データに対してHTMLを組み立て、必要なリンクを含ませるといった処理となっています。
(1)はルーティングルールの注釈です。URLパスに変数として{id}を指定しています。このように、URLパスに埋め込んだパラメーターをマッチさせて取り出すことができます。
(2)はハンドラー関数の定義です。ここでは引数にweb::Path<i32>を指定しています。web::Pathは、パスパラメーターを受け取るための構造体で(下記NOTEも参照)、型パラメーターにi32とあるようにi32型のパラメーターを1個だけ受け取ります。
(3)では、info_innerメソッドでパラメーターを取り出します。(4)でデータ処理クレートのget関数(あとで作成)を呼び出し、(5)以降でデータの有無に応じてボディー文字列を生成しています。
show関数には、web::Path<i32>型の引数を指定しています。この引数はパスパラメーターにアクセスするためのもので、型パラメーターにはパスパラメーターのパターンをタプルで指定します。この場合はパスパラメーターが1つだけなのでタプルではなく型をそのまま与えていますが、2つ以上になる場合にはweb::Path<(String, i32)>のようにタプルとして与えます。この場合、into_innerメソッドの戻り値もタプルとなるので、info.0のように各パスパラメーターにアクセスします。
ここでは、ハンドラー関数の引数としてweb::Pathを紹介しましたが、これを含めた主な構造体(型)を以下に挙げます。なお、引数は複数指定可能で(最大12個)、パスパラメーターとクエリパラメーターの双方を持つ関数も定義できます。
作成したハンドラー関数をアプリケーションに登録するコードを、src/main.rsファイルにリスト4のように追記します。特別な解説は不要でしょう。
…略… App::new() …略… .service(handler::index) .service(handler::show) .default_service(web::to(handler::not_found)) .wrap(Logger::default()) …略…
show関数から呼び出すデータ取得関数getを、src/handler/data.rsファイルにリスト5のように追記します。
pub fn get(id: i32) -> Message { let file = fs::read_to_string(DATA_FILENAME).unwrap(); let mut json_data: Vec<Message> = serde_json::from_str(&file).unwrap(); let mut message = Message {id: 0, posted: "".to_string(), sender: "".to_string(), content: "".to_string()}; if let Some(index) = json_data.iter().position(|item| item.id == id) { (1) message = json_data[index].clone(); } message }
データ取得関数では、ファイルから読み込んだJSONデータであるベクターから、(1)のようにidフィールドが一致する要素を検索しています。ベクターのiterメソッドで取得したイテレータからpositionメソッドを実行し、検索条件を指定するクロージャによってインデックスを取得しています。インデックスが取得できれば、戻り値は該当データとなりますが、そうでない場合は空(idフィールドが0)のデータとなります。
ここでビルドして実行し、投稿一覧ページで適当な投稿をクリックしてみましょう。図2のように個別の投稿やリンクが表示されれば成功です。
次は、更新系として新規投稿の作成機能を作りましょう。新規投稿の作成機能は、投稿一覧ページから[作成]リンクをクリックして呼び出されるページです。
新規投稿の作成や既存投稿の編集のためのフォームを作成しておきます。ヘッダやフッタのHTMLファイルを置いたstaticフォルダに、form.htmlファイルをリスト6のように作成します。
<form method="POST" action="/posts/{{action}}"> (1) <div><label for="sender">名前:</label><br /> <input type="text" id="sender" name="sender" size="20" value="{{sender}}" required /></div> (2) <div><label for="content">内容:</label><br /> <textarea id="content" name="content">{{content}}</textarea></div> <div><button type="submit">{{button}}</button> <a href="/posts">一覧へ</a></div> (3) <input type="hidden" id="id" name="id" value="{{id}}" /> (4) <input type="hidden" id="posted" name="posted" value="{{posted}}" /> </form>
このファイルは、新規投稿の作成と既存投稿の編集を兼ねているので、(1)と(3)のようにform要素のaction属性、ボタンのテキストを外部から設定するようにしています(新規作成:「create」「登録」、投稿編集:「update」「更新」)。また、(2)のようにinput要素のvalue属性にプレースホルダとしての{{...}}を与えています(Actix Webとは直接の関係はないですが、次回で使用するテンプレートに準じた形式としています)。プレースホルダに対しては、投稿作成時や投稿編集時に適切な値を設定してあげます(後述)。また、(4)以降の2つのinput要素は隠しフィールドであり、投稿編集時に投稿IDと投稿日時を保持するために使用しています。
新規投稿の作成は、投稿フォームの呼び出しと、投稿フォームからの登録実行という2段構えで実行されます。このため、/posts/new(GET)と/posts/create(POST)に対応するハンドラー関数を作成して登録します。src/handler.rsファイルに、投稿フォーム表示のハンドラー関数newと登録実行のハンドラー関数createを、リスト7のように追加します。
…略… use serde::{Serialize, Deserialize}; (1) use chrono::{DateTime, Local, Duration}; …略… #[get("/posts/new")] (2) pub async fn new() -> impl Responder { let mut body_str: String = "".to_string(); body_str += include_str!("../static/header.html"); body_str += include_str!("../static/form.html"); (3) body_str += include_str!("../static/footer.html"); body_str = body_str.replace("{{action}}", "create"); (4) body_str = body_str.replace("{{id}}", "0"); body_str = body_str.replace("{{posted}}", ""); body_str = body_str.replace("{{sender}}", ""); body_str = body_str.replace("{{content}}", ""); body_str = body_str.replace("{{button}}", "登録"); HttpResponse::Ok().content_type("text/html; charset=utf-8").body(body_str) } #[derive(Deserialize, Debug)] (5) pub struct CreateForm { id: i32, posted: String, sender: String, content: String, } #[post("/posts/create")] (6) pub async fn create(params: web::Form<CreateForm>) -> impl Responder { (7) let now: DateTime<Local> = Local::now(); (8) let mut message = data::Message { id: 0, posted: now.format("%Y-%m-%d %H:%M:%S").to_string(), sender: params.sender.clone(), content: params.content.clone() }; message = data::create(message); (9) web::Redirect::to(format!("/posts/{}", message.id)).see_other() (10) }
(1)では2つのクレートへの参照を指定しています。saedeクレートはフォームデータをJSONデータにデシリアライズして使用するため(saedeクレートについては第2回を参照)、chronoクレートは日付時刻の処理のためです。Rustは、標準ライブラリではUNIX時間のみをサポートしますが、これでは使い勝手が悪いので、外部クレートchronoを利用します。そのために、以下のコマンドでchronoをプロジェクトの依存関係に加えておいてください。
% cargo add chrono
(2)からは、投稿作成ページを呼び出す/posts/new(GET)に対応するハンドラー関数の定義です。基本的な構造はindex関数と同じなので難しいところはないと思いますが、(3)でフォームファイルform.htmlを読み込んでいるところ、新規作成なので(4)でform要素のaction属性を"create"に、全てのプレースホルダを"0"もしくは空にしている点がポイントです。
(5)からは、フォームからデータを受け取るための構造体を定義しています。フォームの各フィールドのname属性を構造体のフィールドと同じ名前にすることで、(6)から定義される関数にて構造体を経由したフォームデータの受け取りが可能になります。derive注釈によって、serdeクレートのDeserializeトレイトを構造体に実装する必要があることに注意してください。
(6)は、投稿の登録ハンドラー関数です。indexやnewと異なるのは、HTTP POSTメソッドに対するものであるので、post注釈となっていることです。また、(7)は関数のシグネチャですが、引数がweb::Form<CreateForm>となっています。これは、フォームデータを受け取る場合の引数で、CreateForm型の構造体が型パラメーターとなっているweb::Form構造体となります。
(8)からは、フォームデータに基づき投稿データを保存する構造体を作成しています(ここで、投稿日時を現在日時とするために、chronoクレートのLocal::nowメソッドなどを使用しています)。値は、関数の引数paramから構造体のフィールドとして簡単に参照できます。(9)でデータ処理クレートのcreate関数(あとで作成)を呼び出し、(10)で投稿表示機能にRedirect::toメソッドでリダイレクトしています。チェインしているsee_otherメソッドの呼び出しは、HTTPステータス303(See Other)を返すためです。
作成したハンドラー関数をアプリケーションに登録するコードを、リスト8のようにsrc/main.rsファイルに追記します。なお、show関数の前にnew関数を登録しているのは、/posts/newを/posts/{id}より先にマッチさせるためです。
…略… App::new() .service(handler::index) .service(handler::new) .service(handler::create) .service(handler::show) .default_service(web::to(handler::not_found)) .wrap(Logger::default()) …略…
createハンドラー関数から呼び出すcreate関数を、src/handler/data.rsファイルにリスト9のように追記します。
pub fn create(mut message: Message) -> Message { (1) let file = fs::read_to_string(DATA_FILENAME).unwrap(); let mut json_data: Vec<Message> = serde_json::from_str(&file).unwrap(); let mut max = 0; (2) for item in &json_data { max = std::cmp::max(item.id, max); } message.id = max + 1; json_data.push(message); (3) let json_str = serde_json::to_string(&json_data).unwrap(); let _ = fs::write(DATA_FILENAME, json_str); json_data.pop().unwrap() (4) }
ポイントは、登録したMessage構造体を(1)で返すようにしていること(必要な場合には再検索せずに登録データを利用できる)、(2)以降でID値の最大値を求めていること、(3)以降でMessage構造体をJSONデータに追加してファイルに書き戻していること、最後にJSONデータに追加したMessage構造体を取り出して関数の戻り値にしている点です。
ここでビルドして実行し、投稿一覧ページから[作成]をクリックして新規投稿してみましょう。投稿後、図3のように新規投稿が表示されれば成功です。
投稿の作成や編集、削除といった更新系の処理では、処理後に成功や失敗をユーザーに伝えるフラッシュメッセージが使われます。今回は基本的な流れに集中するために省略しましたが、次回以降でテンプレートの導入後にフラッシュメッセージも導入する予定です。
今回は、第2回で作成した投稿アプリに投稿表示、投稿作成などの機能を追加する過程で、リクエストパラメーターの受け取り方法やレスポンスデータの生成方法の詳細を紹介しました。
次回は、今回の続きとしてアプリにテンプレートエンジンを導入して、各ページの見た目を整えます。