第4回は、第3回の続きとして、投稿アプリにWebアプリケーションテンプレートエンジンの「Tera」を導入し、見た目を整えていきます。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
今回は、連載第3回までで作成したWebアプリケーションにテンプレートを導入し、見た目を整えていきます。テンプレートとはひな型のことで、アプリの各ページをHTMLファイルなどであらかじめ用意しておき、アプリはその一部(プレースホルダ)を書き換えるのみにすることで、デザインのしやすさと保守性を向上させる仕組みです。
連載第3回では一部にHTMLファイルを使ってはいましたが、各ページのコンテンツはコードによって生成していたので複雑なHTMLに対応するのは大変で、ミスも起きがちでした。テンプレートを導入することで、ページのデザインに幅を持たせて、かつコードはシンプルにしていくことができます。
RustおよびActix Webに対応したテンプレートエンジンは幾つかあり、それぞれ以下のような特徴を持っています。テンプレートエンジンなので、プレースホルダや制御式といった基本的な機能は共通で備えており、拡張性やテンプレートファイルの読み込み方法で差異があるようです。
今回は、更新も盛んでGitHubでの人気も高いTeraを使ってみます。Teraは、テンプレートファイルを実行時に読み込むため、テンプレートの変更に伴うアプリケーションのビルドが不要で、高い開発効率が期待できます。
ここからは、連載第3回で作成したアプリactix-postsをベースに、テンプレート化を進めていきます。まずは、Teraをプロジェクトに追加します。
% cargo add tera
今回のサンプルには、Teraの備える全ての機能は必要ないので、Cargo.tomlファイルの当該行をリスト1のように編集します(default-featuresをfalseにする)。除外される機能とは、truncate、date、filesizeformatなどのフィルターと、now関数です。これらの機能には多くの依存関係にあるクレートが必要になるので、バイナリファイルが大きくなるのを防ぐために不要な場合にはこのように除外することができます。
tera = {version = "1.19.1", default-features = false}
では、Teraによるテンプレート機能の概略を理解するために、投稿一覧ページからテンプレート化しましょう。
本稿では連載第3回までと同様に、投稿一覧ページ、投稿表示ページ、新規投稿ページを説明します。投稿編集ページについては作成ページとほぼ同じ流れになるので、削除機能ともども配布サンプルを参照してください。また、独自テンプレート用に用意していたHTMLファイルは不要になるので、staticフォルダごと削除してしまって問題ありません。
全てのページのテンプレート化に先立って、アプリがテンプレートを使うようにコードを追加しておきましょう。リスト2のように、src/main.rsファイルに追記します。
…略… use tera::Tera; (1) …略… #[actix_rt::main] async fn main() -> Result<()> { env_logger::init_from_env(Env::default().default_filter_or("info")); HttpServer::new(|| { let mut tera = Tera::new("templates/**/*.html").unwrap(); (2) App::new() .app_data(web::Data::new(tera)) (3) .service(handler::index) …略…
アプリがTeraを使うための最低限の設定です。
(1)は、Tera構造体を利用するための宣言です。Tera構造体は、teraクレートで定義されています。
(2)は、Teraのインスタンス生成です。コンストラクタの引数はテンプレートファイルの置き場所で、この場合はtemplates下にある全てのフォルダの全ての.htmlファイルということになります。このように、テンプレートファイルはフォルダで階層を分けることが可能です。
(3)では、生成したTeraのインスタンスをData構造体でラップして、app_dataメソッドでアプリに組み込んでいます。この文により、ハンドラー関数がTeraのインスタンスを引数で受け取ることができ、テンプレートを利用できるようになります。
Teraは、XSS(Cross Site Scripting)脆弱(ぜいじゃく)性防止のためにHTMLの自動エスケープ処理を既定で備えています。このため、テンプレートに埋め込まれる変数や式の評価結果に「<」「>」などがが含まれる場合、それは<などのエンティティに自動的に置き換えられます。
既定では、.html、.htm、.xmlを拡張子に持つファイルが対象となっていますが、Teraインスタンスの生成後にtera.autoescape_on(vec![".php.html"])のように呼び出すことで、自動エスケープ処理の対象とするファイルを指定できます。ベクターを空にすれば、自動エスケープ処理の対象となるファイルがなくなり実質的に無効になりますが、これはおすすめできません。エスケープ処理の対象としない「安全な」HTMLをテンプレートに渡す方法については後述します。
テンプレートファイルは、一般的なHTMLファイルです。連載第3回でヘッダやフッタ、フォーム部分のHTMLファイルを用意しましたが、それに相当するものです。Teraには、このような複数のテンプレートを結合する方法が幾つか用意されていますが、ここではblockタグによる「継承」と、includeタグによる「取り込み」を紹介します。
継承では、アプリで共通のテンプレートを用意して、それを継承するテンプレートをページごとに用意するといった使い方ができます。まずは共通のテンプレートを、templatesフォルダを作成してリスト3のように配置します。前半にheader.html、後半にfooter.htmlの内容をそのまま使います。
…header.htmlの内容… <div id="container"> (1) {% block content %} (2) {% endblock content %} </div> …footer.htmlの内容…
(1)から始まるdiv要素が追記部分です。(2)のような{% 〜 %}はTeraのテンプレート構文の一つで、文を埋め込むときに使用します。これを含めたテンプレート構文は以下の通りで非常にシンプルです。
{% 〜 %}の中にあるblockタグとendblockタグがTeraの文で、この場合はブロックの開始と終了を意味します。blockタグの引数contentはブロック名で、これは継承したテンプレートから参照されます。ブロック内部にコンテンツがある場合には、それは継承側のテンプレートから{{ super() }}とすることで参照できます。例えばフラッシュメッセージの表示を継承側が選択できるようにする場合、フラッシュメッセージをブロック内部に置いておきます。この例は次回の連載でフラッシュメッセージを実装する際に詳しく紹介します。
継承側のテンプレートをリスト4に示します。
{% extends "base.html" %} (1) {% block content %} (2) <div class="mb-3"> <a class="btn btn-primary" href="/posts/new">作成</a> </div> {% for post in posts %} (3) {% include "item.html" %} (4) {% endfor %} {% endblock content %}
リスト4は、繰り返し構造を持ったテンプレートの例にもなっています。[作成]リンクをページ上部に移動し、Bootstrapでボタンの外観をカスタマイズしています。このような少々複雑なマークアップも、テンプレートによってHTMLがロジックから分離されているからこそです。
(1)のextendsタグは、継承元のテンプレートの指定で、この場合はリスト3のbase.htmlとなります。
(2)のblockタグは、継承元のテンプレート(base.html)で場所を指定されているブロックの指定です。base.htmlの対応する箇所を、このブロックの内容で置き換えるということになります。
(3)以降は、変数postsの内容からHTMLを生成する処理で、postsはテンプレートに対する「コンテキスト」という形で渡されます。繰り返しの構文自体はよくあるものなので、特に説明は不要でしょう。このとき、個別投稿は後述の投稿表示ページと共有したいので、別のテンプレートに置いて取り込むことにします。これが(4)のincludeタグです。includeタグは、引数のファイルをその場に展開します。コンテキストも引き渡されます。
(4)で取り込んでいるitem.htmlの内容をリスト5に示します。個別投稿は、ボタンと同様にBootstrapのカード機能で投稿がカード風に見えるようにしました。
<div class="card mb-3"> <div class="card-header">{{post.sender}} {{post.posted}}</div> <div class="card-body"> <p class="card-text">{{post.content|linebreaksbr|safe}}</p> (1) </div> <a href="/posts/{{post.id}}" class="stretched-link"></a> </div>
(1)は投稿文の埋め込みですが、Teraの便利な構文が使われています。{{post.content|linebreaksbr|safe}}のパイプ記号(|)は文字通りパイプであり、フィルターとして働きます。組み込みフィルターlinebreaksbrは改行文字(\n、\r\n)を<br>タグに変換し、safeは入力が安全なHTMLであることをマークします(これによってエスケープ処理を回避します)。これにより、textarea要素で入力された改行文字をブラウザ画面にも反映できるわけです。
Teraには、linebreaksbrやsafeの他にも便利な組み込みフィルターがたくさんあります。主なものを以下に挙げます。
テンプレート内で参照する変数は、コンテキストに存在している必要があります。存在しない場合には、実行時にエラーとなります。変数がコンテキストに存在するかどうかで処理を分けたい場合には、{{% if var %}}〜のようにif文を使用します。
HTML生成のための繰り返し処理をテンプレートに移動したので、ハンドラー関数indexは非常にリスト6のようにシンプルになります。
…略… use tera::Context; (1) …略… #[get("/posts")] pub async fn index(tmpl: web::Data<tera::Tera>) -> impl Responder { (2) info!("Called index"); let posts = data::get_all(); let mut context = Context::new(); (3) context.insert("posts", &posts); let body_str = tmpl.render("index.html", &context).unwrap(); (4) HttpResponse::Ok().content_type("text/html; charset=utf-8").body(body_str) }
見ての通り、連載第2回のindex関数に比べてはるかにシンプルになっています。
(1)は、teraクレートのContext構造体を使う宣言です。Context構造体は文字通りコンテキストという意味で、テンプレートに渡すデータを意味しています。
(2)は、index関数の定義になりますが、今回は引数があります。引数web::Data<tera::Tera>はTera構造体のインスタンスであり、main関数で生成されたものです。リスト2で示したように、アプリの生成時にadd_dataメソッドでインスタンスを登録しているので、ここで引数としてそれを受け取ることができるというわけです。
(3)からは、コンテキストの生成と、取得した全投稿データをinsertメソッドで追加しています。この場合、データはベクターであるので、"posts"という名前の配列としてテンプレートに渡されます。このため、テンプレート側ではfor〜in文により要素を取り出してHTMLの生成ができたわけです。もちろん、スカラー変数も渡すことができます。
(4)は、テンプレートのrenderメソッドで、指定するテンプレートにコンテキストを渡してレンダリングし、結果の文字列を受け取っています。最後に、これまで通りレスポンスを返せば終了です。
ここで、アプリをビルド、実行して「http://localhost:8000/posts」にアクセスし、図1のように一覧表示されれば、テンプレートの利用は成功です。
このあともそうですが、data.rs内の関数には全く手を入れる必要はありません。handler.rs内のcreate関数、update関数、destroy関数もビューを持たないので同様です。
次に、連載第3回に倣って投稿表示ページをテンプレート化します。ここまでの手順を踏まえれば、テンプレート化は非常に簡単です。
アプリ共通のテンプレートは作成済みなので、投稿表示ページ(継承側)のみを作成します。テンプレートをリスト7に示します。
{% extends "base.html" %} {% block content %} {% if post.id == 0 %} (1) <div class="alert alert-danger">見つかりません。</div> {% else %} {% include "item.html" %} (2) <div class="mb-3"> <a class="btn btn-primary" href="/posts/{{post.id}}/edit">編集</a> (3) <a class="btn btn-danger" href="/posts/{{post.id}}/delete">削除</a> </div> {% endif %} <div><a href="/posts">一覧へ</a></div> {% endblock content %}
基本的な構造はリスト4と同じですが、単一の投稿の表示なので繰り返し構造を持ちません。また、渡された投稿データ(post)が有効なidを持つか判定し(1)、持たない場合には該当データがないものとして、メッセージを表示するだけとしています。ここで使っているのが、Teraのif文です。
(2)では、index.htmlと同様に個別投稿のテンプレートitem.htmlを取り込んでいます。このように、includeタグを使うとテンプレートの継承とは別の方法で共通部分をまとめることができて便利です。(3)の[編集]と[削除]のリンクも、Bootstrapで異なる色のボタンにカスタマイズしています。
ハンドラー関数showも、リスト8のように非常にシンプルになります。
…略… #[get("/posts/{id}")] pub async fn show(tmpl: web::Data<tera::Tera>, info: web::Path<i32>) -> impl Responder { (1) info!("Called show"); let info = info.into_inner(); let post = data::get(info); let mut context = Context::new(); context.insert("post", &post); let body_str = tmpl.render("show.html", &context).unwrap(); HttpResponse::Ok().content_type("text/html; charset=utf-8").body(body_str) }
(1)でtmpl: web::Data<tera::Tera>を引数に追加しているのはindex関数と同じです。続く部分は特に説明は不要でしょう。
ここで、投稿一覧から適当な投稿を選んで図2上のように一覧表示されれば成功です。「http://localhost:3000/posts/100」などのように存在しない投稿を表示させると図2下のようになるはずです。
最後に、更新系として新規投稿ページをテンプレート化しましょう。
テンプレートファイルは、新規投稿と投稿編集で共有するのは非テンプレート版と同じです。テンプレートをリスト9に示しますが、ここではTeraのマクロ機能も紹介します。
{% extends "base.html" %} {% macro label(label, for) %} (1) <label class="form-label" for="{{for}}">{{label}}</label> {% endmacro label %} {% block content %} <form method="POST" action="/posts/{{action}}"> <div class="mb-3">{{ self::label(label="名前", for="sender") }}<br /> (2) <input type="text" class="form-control" id="sender" name="sender" size="20" value="{{post.sender}}" placeholder="名前を入力(必須)" required /> </div> <div class="mb-3">{{ self::label(label="内容", for="content") }}<br /> (3) <textarea class="form-control" id="content" name="content" rows="5">{{post.content}}</textarea></div> <div> <button class="btn btn-primary" type="submit">{{button}}</button> <a href="/posts">一覧へ</a> </div> <input type="hidden" id="id" name="id" value="{{post.id}}" /> <input type="hidden" id="posted" name="posted" value="{{post.posted}}" /> </form> {% endblock content %}
<form>タグ全体をブロックで囲むのは同様ですが、Bootstrapでフォームの見た目を整えています。基本的な構造は連載第3回のform.htmlと同じですが、(1)(2)(3)でmacroタグによるマクロ機能を使っています。Bootstrapを使うと、どうしてもclass属性の指定が多くなり、マークアップも冗長になりがちです。このような場合にマクロ機能を使うことで、要素固有の部分だけを引数にすることで、記述を簡略化できます。ここでは、<label>タグの生成のみをマクロ化しましたが、入力要素が多くなる場合にはそれもマクロ化すると、各入力要素の記述を簡略化できるでしょう。
なお、マクロの呼び出しはキーワード引数が強制されることに注意してください。ここでは省略しましたが、マクロ定義で引数のデフォルト値を指定することもできます。
ハンドラー関数newもリスト9のように非常にシンプルになります。ハンドラー関数createは登録処理を実行するだけなので、変更は不要です。
#[get("/posts/new")] pub async fn new(tmpl: web::Data<tera::Tera>) -> impl Responder { (1) info!("Called new"); let mut context = Context::new(); let post = data::Message {id:0, sender:"".to_string(), content:"".to_string(), posted:"".to_string()}; context.insert("action", "create"); (2) context.insert("post", &post); context.insert("button", "投稿"); let body_str = tmpl.render("form.html", &context).unwrap(); HttpResponse::Ok().content_type("text/html; charset=utf-8").body(body_str) }
(1)でtmpl: web::Data<tera::Tera>を引数に加える他、(2)以降でプレースホルダに与える変数を生成してレンダリングを実行するだけです。
ここで、投稿一覧から[作成]ボタンをクリックして、図3上のように投稿フォームが表示されれば成功です。実際に投稿して、図3下のように投稿表示ページに切り替わることも確認してください。
今回は、投稿アプリにWebアプリケーションテンプレートエンジンのTeraを導入し見た目を整えていく過程を紹介しました。
次回は、今回の続きとしてアプリにフラッシュメッセージを導入して実用性を高める他、後続の回で紹介するフロントエンド実装に備えての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.