Rust の HTML テンプレートエンジンを選ぶ

Rust で使える HTML テンプレートエンジンを探してみました。

「HTML テンプレートエンジン」というのは、ブログサイトとかの生成ツールが使っている便利なしくみです。 HTML ファイルを生成するときに全部プログラムで生成するのは大変なので、あらかじめテキストファイルで HTML ファイルの「ひな形」(テンプレート)を作っておいて、その中に、プログラムから値を埋め込むための印(プレースホルダー)を書いておきます。テンプレートエンジンは、そのひな形を読み込んで、プログラムから渡されたデータでプレースホルダーを書き替えます。

テンプレートの書式もいろいろあって、基本的にはテンプレートエンジンによって異なります。もちろん、複数のプログラミング言語でのエンジン実装が作られているテンプレート書式もあります。基本的な書式は一緒だけど、エンジン実装ごとに微妙に仕様が違う、というのもあります。

ぱっと思いつくだけでも、テンプレートエンジンはたくさんあります:

  • Pug – 旧称「Jade」。JavaScript がメインっぽいけど、いろんな言語での実装がある。
  • Nunjucks – JavaScript 製エンジン。
  • EJS – これも JavaScript 製エンジン。
  • Jinja – Python 製エンジン。
  • Django – これも Python 製エンジン。
  • Handlebars – JavaScript と Rust の実装があるみたい。Rust の公式ドキュメントとかでも使われているらしい。
  • Tera – Rust 製エンジン。Jinja2 と Django からの派生とのこと。

今回は、Rust で作っているプログラムで使いたいので、Rust 製エンジンを選びます。

とりあえず、crate.io で人気のあるクレートを探してみる。
https://crates.io/search?q=template html&sort=downloads

tinytemplate とか handlebars とか tera とか Askama とか liquid とか出てきますね。ほかにも、Seilfish とか Dojang とかありました。

今回は、可能であれば、JavaScript 製エンジンもあるテンプレート書式を選びたいと思っています。というのも、もともと Node.js で動くブログ構築アプリケーション Hexo を利用しているので、テンプレートファイルも共通化できたらうれしいのです。

ついでに言うと、

  • オリジナルのヘルパー関数を定義して使いたい(できればクロージャーが使えるとうれしい)
  • テンプレートはコンパイル時ではなく実行時に読み込みたい(テンプレートの作成が楽になるので)

とかいうリクエストもあります。

今回は、Tera と Handlebars を試してみることにしました。

Tera を試す

とりあえず、テンプレートエンジンにデータを渡す方法と、オリジナルのヘルパー関数を定義して使う方法を調べました。

テンプレートファイルを書いてみると、こんなかんじ:

This is a sample for {{ person.first_name }}.

list:
{% for x in people -%}
  {{loop.index}}. {{x.first_name}} / {{x.last_name}}
{% endfor %}

helper:
{{ my_helper(name="Alice") }}
{{ my_helper(name="Bob") }}

んで、書いてみた Rust コードが以下のもの:

use std::collections::HashMap;

use serde::Serialize;
use tera::{Context, Tera};

#[derive(Debug, Clone, Serialize)]
struct Person {
    first_name: String,
    last_name: String,
}

#[derive(Debug, Clone, Serialize)]
struct Locals {
    people: Vec<Person>,
}

fn main() {
    println!("Hello, world!");

    let mut tera = match Tera::new("templates/**/*.html") {
        Ok(t) => t,
        Err(e) => {
            eprintln!("Tera parsing error: {:?}", e);
            std::process::exit(1);
        }
    };
    tera.autoescape_on(vec![]); // disable auto-escaping

    let locals = Locals {
        people: vec![
            Person {
                first_name: "Alice".into(),
                last_name: "1990".into(),
            },
            Person {
                first_name: "Bob".into(),
                last_name: "1980".into(),
            },
        ],
    };
    let mut context = Context::new();
    context.insert("person", &locals.people[0]);
    context.insert("people", &locals.people);

    {
        let target = locals.people[0].clone();
        let unknown = Person {
            first_name: "Unknown".into(),
            last_name: "0".into(),
        };
        tera.register_function("my_helper", Box::new(move |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
            let arg = match args.get("name") {
                Some(v) => match tera::from_value::<String>(v.clone()) {
                    Ok(v) => v,
                    Err(_) => return Err("my_helper: invalid argument".into()),
                },
                None => return Err("my_helper: one argument should be specified".into()),
            };
            let person = if arg == target.first_name {
                &target
            } else {
                &unknown
            };
            Ok(tera::to_value(format!("{:?}", person)).unwrap())
        }));
    }

    let output = tera.render("index.html", &context).expect("Tera render error");
    println!("{}", output);
}

ちなみに、Rust の Cargo.toml には以下の依存クレート指定が必要です:

serde = { version = "1.0.134", features = ["derive"] }
serde_json = "1.0.75"
tera = "1"

やりたかったことは、ひととおりできそうな感じです。ただ、ヘルパー関数を呼び出すときの書式がちょっと好きじゃないかも。 my_helper(name="Bob") という「名前付き引数」指定が必須なのが、ちょっと冗長な気がする。

Handlebars を試す

テンプレートファイルを書いてみる:

This is a sample for {{ person.first_name }}.

list:
{{#each people }}
  {{@index}}. {{this.first_name}} / {{this.last_name}}
{{/each }}

helper:
{{my_helper "Alice" }}
{{my_helper "Bob" }}

Rust のコード:

use anyhow::Result;
use handlebars::{Context, Handlebars, Helper, JsonRender, HelperResult, RenderContext, RenderError, Output};
use serde::Serialize;

#[derive(Debug, Clone, Serialize)]
struct Person {
    first_name: String,
    last_name: String,
}

#[derive(Debug, Clone, Serialize)]
struct Locals {
    person: Person,
    people: Vec<Person>,
}

fn main() -> Result<()> {
    println!("Hello, world!");

    let mut handlebars = Handlebars::new();
    handlebars.set_strict_mode(true);
    handlebars.set_dev_mode(true);
    handlebars.register_escape_fn(handlebars::no_escape); // disable HTML escaping

    handlebars.register_templates_directory(".html", "templates/")?;

    let locals = Locals {
        person:
            Person {
                first_name: "Charlie".into(),
                last_name: "2000".into(),
            },
        people: vec![
            Person {
                first_name: "Alice".into(),
                last_name: "1990".into(),
            },
            Person {
                first_name: "Bob".into(),
                last_name: "1980".into(),
            },
        ],
    };

    {
        let target = locals.people[0].clone();
        let unknown = Person {
            first_name: "Unknown".into(),
            last_name: "0".into(),
        };
        handlebars.register_helper("my_helper", Box::new(move |h: &Helper, _: &Handlebars, context: &Context, _: &mut RenderContext, out: &mut dyn Output| -> HelperResult {
            let arg = h.param(0).ok_or(RenderError::new("my_helper: one argument should be specified"))?;
            let arg = arg.value().render();
            let person = if arg == target.first_name {
                &target
            } else {
                &unknown
            };
            let val = context.data().get("person").unwrap().get("last_name").unwrap().render().parse::<usize>()?; // check a variable in the context
            out.write(&format!("{:?} and {:?}", person, val))?;
            Ok(())
        }));
    }

    let output = handlebars.render("index", &locals)?;
    println!("{}", output);
    Ok(())
}

Rust の Cargo.toml に書く依存クレートの指定:

[dependencies]
anyhow = "1.0.52"
handlebars = { version = "4.2.1", features = ["dir_source"] }
serde = { version = "1.0.134", features = ["derive"] }
serde_json = "1.0.75"

こちらも、とりあえずやりたかったことはできました。独自のヘルパー関数に渡される情報(引数)が多いですね。テンプレートの書式はちょっと Lisp っぽいところがありますが、キライではない。

まとめ

今回は Handlebars を使ってみようと思います。

Let's Encrypt の ACMEv2 対応

以前、記事にしたように、このサイトでは Let’s Encrypt の SSL 証明書を利用させていただいています。

Let's Encrypt の導入(root 権限なし)

ところが、先日、Let’s Encrypt 運営から通知メールが届きました。今年6月でサービス提供を終える古い認証プロトコルをまだ使っているよ、とのこと。具体的に言うと、ACMEv1 というプロトコルのサポートが廃止になり、今後は ACMEv2 のみ提供されるそうです。

とりあえず証明書の取得に利用しているツールを最新版にアップデートすれば、自動的に ACMEv2 プロトコルに切り替わってくれるみたい。もちろん、「せっかく ACMEv2 を使うならワイルドカード証明書がほしい」とかいう場合はいろいろ作業が必要となりますが、ACMEv1 から ACMEv2 への切り替えだけならば簡単そう。

証明書の取得に利用させていただいている simp_le という Python 製ツールは、しらばく前から ACMEv2 に対応してくれていました! 感謝!!

https://github.com/zenhack/simp_le

以下、作業ログです。


simp_le 最新版を使うためには、Python を 2.7 系から 3.6 系にアップデートしなければならない。

古い pyenv 設定を消しておく

$ rm ~/run/simp_le/.python-version

python3 系の最新版 (3.6.1) を入れる:

$ pyenv install -l
$ pyenv install 3.6.1
$ pyenv rehash
$ pyenv global 3.6.1
$ pyenv exec pip install --upgrade pip
$ python -V

GitHub から simp_le の最新版を取得:

$ cd ~/run/simp_le
$ git pull
$ git tag
$ git checkout 0.18.0

venv.sh 相当のことを手動でやる:

$ pyenv virtualenv venv-simp_le
$ pyenv virtualenvs
$ pyenv versions
$ pyenv local venv-simp_le
$ pyenv virtualenvs
$ pyenv versions

$ pyenv exec pip list
$ pyenv exec pip install -U setuptools
$ pyenv exec pip install -U pip
$ pyenv exec pip install -U wheel
$ pyenv exec pip install -e .
$ pyenv exec pip list

証明書ファイルのあるディレクトリで、お試し実行:

$ MAIL='foo@example.jp'
$ FQDN='bar.example.jp'
$ DOCROOT='/var/www/bar.example.jp/letsencrypt'
$ simp_le -v --email "$MAIL" -f account_reg.json -f account_key.json -f cert.pem -f chain.pem -f fullchain.pem -f key.pem -d "$FQDN:$DOCROOT"

あとしまつ(自作の自動更新スクリプトがエラーになるのでお掃除):

$ rmdir /var/www/bar.example.com/letsencrypt/.well-known/acme-challenge

simp_le を実行すると、一発目は「更新する必要がありません」になってファイルは更新されず。全部のファイル *.pem *.json を削除して再実行したら、証明書を再取得してくれました。 simp_le の ver. 0.15.0 で追加されたファイル account_reg.json も自動生成されました。

取得した証明書ファイルを Web サーバに配置したところ、正しくブラウザからアクセスできました。ちゃんと発行日が今日の日付になっている SSL 証明書もブラウザ上で確認できました。

完了!