RustとActix Webで投稿アプリにテンプレートエンジンを導入しようWebアプリ実装で学ぶ、現場で役立つRust入門(4)

第4回は、第3回の続きとして、投稿アプリにWebアプリケーションテンプレートエンジンの「Tera」を導入し、見た目を整えていきます。

» 2024年01月19日 05時00分 公開

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「Webアプリ実装で学ぶ、現場で役立つRust入門」のインデックス

連載:Webアプリ実装で学ぶ、現場で役立つRust入門

 本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。


Rustで使えるWebアプリケーションテンプレート

 今回は、連載第3回までで作成したWebアプリケーションにテンプレートを導入し、見た目を整えていきます。テンプレートとはひな型のことで、アプリの各ページをHTMLファイルなどであらかじめ用意しておき、アプリはその一部(プレースホルダ)を書き換えるのみにすることで、デザインのしやすさと保守性を向上させる仕組みです。

 連載第3回では一部にHTMLファイルを使ってはいましたが、各ページのコンテンツはコードによって生成していたので複雑なHTMLに対応するのは大変で、ミスも起きがちでした。テンプレートを導入することで、ページのデザインに幅を持たせて、かつコードはシンプルにしていくことができます。

 RustおよびActix Webに対応したテンプレートエンジンは幾つかあり、それぞれ以下のような特徴を持っています。テンプレートエンジンなので、プレースホルダや制御式といった基本的な機能は共通で備えており、拡張性やテンプレートファイルの読み込み方法で差異があるようです。

  • TinyTemplate:シンプルで軽量。ただし2年前から更新が止まっている
  • Handlebars:拡張可能なヘルパーシステムを備えている。更新も盛ん
  • Tera:PythonのJinja2/Djangoをベースとしている。テンプレートは実行時に読み込む
  • Askama:型安全でJinjaライクなテンプレート
  • Liquid:Shopifyによって開発されたRubyベースのテンプレート

 今回は、更新も盛んでGitHubでの人気も高いTeraを使ってみます。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}
リスト1:Cargo.toml

投稿一覧ページのテンプレート化

 では、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)
…略…
リスト2:src/main.rs

 アプリがTeraを使うための最低限の設定です。

 (1)は、Tera構造体を利用するための宣言です。Tera構造体は、teraクレートで定義されています。

 (2)は、Teraのインスタンス生成です。コンストラクタの引数はテンプレートファイルの置き場所で、この場合はtemplates下にある全てのフォルダの全ての.htmlファイルということになります。このように、テンプレートファイルはフォルダで階層を分けることが可能です。

 (3)では、生成したTeraのインスタンスをData構造体でラップして、app_dataメソッドでアプリに組み込んでいます。この文により、ハンドラー関数がTeraのインスタンスを引数で受け取ることができ、テンプレートを利用できるようになります。

【補足】Teraは自動エスケープが既定

 Teraは、XSS(Cross Site Scripting)脆弱(ぜいじゃく)性防止のためにHTMLの自動エスケープ処理を既定で備えています。このため、テンプレートに埋め込まれる変数や式の評価結果に「<」「>」などがが含まれる場合、それは&lt;などのエンティティに自動的に置き換えられます。

 既定では、.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の内容…
リスト3:templates/base.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:templates/index.html

 リスト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>
リスト5:templates/item.html

 (1)は投稿文の埋め込みですが、Teraの便利な構文が使われています。{{post.content|linebreaksbr|safe}}のパイプ記号(|)は文字通りパイプであり、フィルターとして働きます。組み込みフィルターlinebreaksbrは改行文字(\n、\r\n)を<br>タグに変換し、safeは入力が安全なHTMLであることをマークします(これによってエスケープ処理を回避します)。これにより、textarea要素で入力された改行文字をブラウザ画面にも反映できるわけです。

【補足】Teraの組み込みフィルター

 Teraには、linebreaksbrやsafeの他にも便利な組み込みフィルターがたくさんあります。主なものを以下に挙げます。

  • lower:英小文字に変換
  • upper:英大文字に変換
  • capitalize:単語の先頭文字のみ大文字、その他を小文字に
  • replace(from, to):fromで指定される文字列をtoに変換
  • addslashes:引用符をバックスラッシュでエスケープ
  • spaceless:HTMLでは無意味な空白や改行を除去

【補足】変数はコンテキストに必須

 テンプレート内で参照する変数は、コンテキストに存在している必要があります。存在しない場合には、実行時にエラーとなります。変数がコンテキストに存在するかどうかで処理を分けたい場合には、{{% 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)
}
リスト6:src/handler.rs(index関数)

 見ての通り、連載第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のように一覧表示されれば、テンプレートの利用は成功です。

図1 投稿一覧表示(テンプレート版) 図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 %}
リスト7:templates/show.html

 基本的な構造はリスト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)
}
リスト8:src/handler.rs(show関数)

 (1)でtmpl: web::Data<tera::Tera>を引数に追加しているのはindex関数と同じです。続く部分は特に説明は不要でしょう。

 ここで、投稿一覧から適当な投稿を選んで図2上のように一覧表示されれば成功です。「http://localhost:3000/posts/100」などのように存在しない投稿を表示させると図2下のようになるはずです。

図2 投稿表示(テンプレート版) 図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 %}
リスト9:templates/form.html

 <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)
}
リスト9:src/handler.rs(new関数)

 (1)でtmpl: web::Data<tera::Tera>を引数に加える他、(2)以降でプレースホルダに与える変数を生成してレンダリングを実行するだけです。

 ここで、投稿一覧から[作成]ボタンをクリックして、図3上のように投稿フォームが表示されれば成功です。実際に投稿して、図3下のように投稿表示ページに切り替わることも確認してください。

図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.

スポンサーからのお知らせPR

注目のテーマ

Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。