2019年2月17日日曜日

Rustのhandlebars-ironで、テンプレートから別のテンプレートを呼び出す方法


背景

Rustとはメモリ安全性に配慮して作られているプログラミング言語です。
ironとはRustのwebフレームワークの1つです。
この記事でのテンプレートとは、hanblebars-ironというミドルウェアを利用して配信する、htmlを含むファイルのことを指します。

このテンプレートを利用することでhtmlをrustのコード内に記述しなくて良くなるのでとても便利なのですが、1回のリクエストに対して1つのテンプレートしか使えないと、htmlやstyleなどのレイアウトに関する情報を全てのファイルに書く必要があり、冗長になってしまいます。

上記の冗長になる問題を回避するには、テンプレートからレイアウトとして定義した別のテンプレート呼び出せれば良いのですが、それを実現する方法が分かるまでに時間を取られました。
調べた末やり方がわかったので、方法を共有します。

使ったもの

Rustが使えるPC

Rustは、Install Rustに従ってコマンドを1行実行すると、ダウンロードとインストールを行ってくれます。
curl https://sh.rustup.rs -sSf | sh

ironでhandlebarsを通してテンプレートを利用しているプロジェクト

ironでwebアプリを作ったりパラメータを扱ったりする日本語の記事をいくつか見つけたので、ライブラリのreadmeに加えてそれらも参考にしつつ作成しました。

参考にした記事と共にレイアウトを適用する前のironプロジェクトを共有します。

全体像の把握
RustでWebプログラミング No.2 ~ Routerをつかって複数ルート~
RustでWebプログラミング No.3 ~ HandlebarでHTMLテンプレート~ IronでWebサービスを作るために必要だったもの

routerに関する情報
iron/router

テンプレートを管理するhandlebarsに関する情報
sunng87/handlebars-iron
handlebars-iron/examples/templates/index.hbs

静的ファイルの配信に関する情報
Rust’s Iron Framework: Serving Static Content
iron/staticfile

パラメータの処理に関する情報
iron/params

これらの記事を参考にしながら、下記のwebアプリを作成しました。

Cargo.tmol
[dependencies]
time = "0.1"
router = "0.6"
handlebars-iron = "0.27"
params = "0.8"
staticfile = "0.5"
mount = "0.4"
iron = "0.6.0"

src/main.rs
extern crate iron;
extern crate params;
#[macro_use] extern crate router;
extern crate time;

use handlebars_iron as hbs;
use hbs::{DirectorySource, HandlebarsEngine, Template};
use iron::prelude::*;
use iron::status;
use iron::{typemap, AfterMiddleware, BeforeMiddleware};
use mount::Mount;
use params::{Params, Value};
use router::Router;
use staticfile::Static;
use std::collections::HashMap;
use std::path::Path;
use time::precise_time_ns;

struct ResponseTime;

impl typemap::Key for ResponseTime { type Value = u64; }

impl BeforeMiddleware for ResponseTime {
    fn before(&self, req: &mut Request) -> IronResult<()> {
        req.extensions.insert::(precise_time_ns());
        Ok(())
    }
}

impl AfterMiddleware for ResponseTime {
    fn after(&self, req: &mut Request, res: Response) -> IronResult {
        let delta = precise_time_ns() - *req.extensions.get::().unwrap();
        println!("Request took: {} ms", (delta as f64) / 1000000.0);
        Ok(res)
    }
}

fn create_default_data() -> HashMap<String, String> {
    HashMap::new()
}

fn root_handler(req: &mut Request) -> IronResult {
    let mut resp = Response::new();
    let mut data = create_default_data();
    data.insert("hello_url".to_string(), url_for!(req, "hello").to_string());
    data.insert("hello_again_url".to_string(), url_for!(req, "hello_again").to_string());
    data.insert(
        "hello_again_bob_url".to_string(),
        url_for!(req, "hello_again", "name" => "Bob").to_string()
    );
    resp.set_mut(Template::new("index", data)).set_mut(status::Ok);
    Ok(resp)
}

fn hello_handler(_: &mut Request) -> IronResult {
    let mut resp = Response::new();
    let data = create_default_data();
    resp.set_mut(Template::new("hello", data)).set_mut(status::Ok);
    Ok(resp)
}

fn hello_again_handler(req: &mut Request) -> IronResult {
    let mut resp = Response::new();
    let mut data = create_default_data();
    let params = req.get_ref::().unwrap();
    match params.find(&["name"]) {
        Some(&Value::String(ref name)) => {
            data.insert("name".to_string(), name.to_string());
        },
        _ => {}
    };
    resp.set_mut(Template::new("hello_again", data)).set_mut(status::Ok);
    Ok(resp)
}

fn main() {
    let mut router = Router::new();
    let mut hbse = HandlebarsEngine::new();
    hbse.add(Box::new(DirectorySource::new("./templates", ".hbs")));
    if let Err(r) = hbse.reload() {
        panic!("{}", r);
    }

    router.get("/".to_string(), root_handler, "root");
    router.get("/hello".to_string(), hello_handler, "hello");
    router.get("/hello/again".to_string(), hello_again_handler, "hello_again");
    router.get("/error".to_string(), |_: &mut Request| {
        Ok(Response::with(status::BadRequest))
    }, "error");

    let mut mount = Mount::new();
    mount
        .mount("/", router)
        .mount("/public", Static::new(Path::new("public")));

    let mut chain = Chain::new(mount);
    chain.link_before(ResponseTime);
    chain.link_after(hbse);
    chain.link_after(ResponseTime);
    if let Err(r) = Iron::new(chain).http("localhost:3000") {
        panic!("{}", r);
    }
}

templates/index.hbs
<html>
  <head>
  </head>
  <body>
    <div>this is root page</div>
    <div><a href="{{hello_url}}">hello</a></div>
    <div><a href="{{hello_again_url}}">hello again</a></div>
    <div><a href="{{hello_again_bob_url}}">hello again Bob</a></div>
  </body>
</html>

templates/hello.hbs
<html>
  <head>
  </head>
  <body>
    <div>hello!</div>
  </body>
</html>

templates/hello_again.hbs
<html>
  <head>
  </head>
  <body>
    <div>hello!</div>
    {{#if name}}
      </div>{{name}}</div>
    {{/if}}
    <div>again</div>
  </body>
</html>

有効な各urlへブラウザでアクセスした場合の画面はこのようになっています。

http://localhost:3000


http://localhost:3000/hello


http://localhost:3000/hello/again


http://localhost:3000/hello/again?name=Bob


上記のプロジェクトにレイアウトを適用します。

レイアウトファイルを作成

各ページで共有レイアウトファイルを作成します。
ページの内容はbodyという名前で読み込む記述にしました。
また、テンプレートを使っていると分かるように「It is a Iron App」という文字をbodyの前に表示するようにしました。
templates/layouts/default.hbs
<html>
  <head>
  </head>
  <body>
    <div>It is a <a href="/">Iron App</a></div>
    {{> body}}
  </body>
</html>

templateに渡すhashにレイアウトファイルのパスを追加

レイアウトファイルのパスをテンプレートに渡すパラメータのHashMapにlayoutとして追加します。
こうすることで、各ページファイルで指定したパスのレイアウトファイルを呼べます。

src/main.rs
..
fn create_default_data() -> HashMap<String, String> {
    let mut data = HashMap::new();
    data.insert("layout".to_string(), "layouts/default".to_string());
    data
}
..

利用するレイアウトファイルを変更したい場合は、このHashMapのlayoutのパスを変えれば良いです。

各ページのファイルを変更

レイアウトファイルを利用してページの内容を記述するよう、既存のテンプレートファイルを変更します。
各ファイルの内容としては、「body」としてページの内容を定義し、パラメータとして渡ってくる「layout」 のパスを最後に描画して、レイアウトを通してbodyを描画する仕組みです。

「{{> ()layout}}」と書けば良い(ニョロ括弧2つの中に「>」を書いた後丸括弧1つで囲むと、変数として渡ってきたファイル名のテンプレートを展開する)と分かるのに苦労しました。

templates/index.hbs
{{#*inline "body"}}
<div>this is root page</div>
<div><a href="{{hello_url}}">hello</a></div>
<div><a href="{{hello_again_url}}">hello again</a></div>
<div><a href="{{hello_again_bob_url}}">hello again Bob</a></div>
{{/inline}}

{{> (layout)}}

templates/hello.hbs
{{#*inline "body"}}
<div>hello!</div>
{{/inline}}

{{> (layout)}}

templates/hello_again.hbs
{{#*inline "body"}}
  <div>hello!</div>
  {{#if name}}
    </div>{{name}}</div>
  {{/if}}
  <div>again</div>
{{/inline}}

{{> (layout)}}

実行

プログラムを実行して各urlにアクセスしてみます。

http://localhost:3000


http://localhost:3000/hello


http://localhost:3000/hello/again


http://localhost:3000/hello/again?name=Bob


各ページの先頭にレイアウトファイルに記述した「It is a Iron App」という文字が表示されているので、期待通りにレイアウトが機能していると分かりました。

まとめ

ironでテンプレートを利用できました。
Rustを利用したwebアプリ開発の障壁がまた1つ無くなりました。

何かの参考になれば嬉しいです。

参考

テンプレートファイル内で使える特殊な記述は下記のBuilt in Helpersとしてまとめられています。
https://docs.rs/handlebars/1.2.0/handlebars/#built-in-helpers

記述は若干は違いますが、レイアウトファイルの記述方法の参考にしました。
handlebars-rust/examples/partials/base0.hbs
handlebars-rust/examples/partials/template2.hbs
https://handlebarsjs.com/partials.html

今回の例で使ったコードはこちらのリポジトリでも共有しています。
https://github.com/asukiaaa/rust_iron_template_sample

参考履歴

2019.02..21
ハンドラーを実行する前に実行時間を計測していたようなので、hbaseとResponseTimeのchain.link_afterの順序を(hbaseが前になるように)入替えました。

0 件のコメント :