エンタープライズギークス (Enterprise Geeks)

企業システムの企画・開発に携わる技術者集団のブログです。開発言語やフレームワークなどアプリケーション開発に関する各種情報を発信しています。ウルシステムズのエンジニア有志が運営しています。

Thymeleaf3 の fragment expressions で View の共通化を促進する

2016年5月に、 主に Spring MVC で使われるテンプレートエンジンである Thymeleaf のバージョン3がリリースされた。
(Thymeleafの本家サイトはこちらを、変更点の一覧はこちらを参照のこと。)

このバージョンの主な変更点としては、以下のものが挙げられる。

  • HTML5 マークアップの完全サポートにより、整形式XMLで記述する必要がなくなった。
  • テキストモードサポートにより、CSSJavaScript やメールなどの任意のテキストフォーマットに適用しやすくなった。
  • fragment expressions により、共通化したテンプレートに任意の要素を引き渡せるようになった。

本記事では、最後の fragment expressions について解説する。
その理由は、この機能が、筆者が個人的に Thymeleaf の弱点と思っていた部分を大きく改善するものだからである。

fragment expressions とは

Thymeleaf における fragment とは、View のヘッダやフッタなどの共通部分をテンプレート化して一元化する仕組みを指す。

fragment には静的な内容だけでなく、ある程度動的な内容を含めることもできる。
そのためには、fragment 指定時に引数を与える必要があるが、Thymeleaf の前バージョンでは、この引数に指定できるのは文字列などの値のみだった。

しかし、新バージョンで提供される fragment expressions の構文では、fragment の引数にレイアウト内の任意の要素そのものを指定できるようになった。

fragment expressions の例

具体例で説明しよう。ここでは、先ほどの 変更点一覧 に記載されているサンプルを一部を修正して説明する。この例では、View のheader要素に対する共通化を行う。

fragmentの定義

まずは、テンプレート化したヘッダの内容である。この内容は、base.html という名前で保持しておく。

<!-- base.html -->
<!--/* common_header というfragment を定義。引数の title, links は fragment expressionsである */-->
<head th:fragment="common_header(title,links)">
  <!-- 各Viewのタイトル -->
  <title th:replace="${title}">The awesome application</title>

  <!-- どのViewでも必ず読み込むファイル -->
  <link rel="stylesheet" type="text/css" media="all" th:href="@{/css/awesomeapp.css}">
  <link rel="shortcut icon" th:href="@{/images/favicon.ico}">
  <script type="text/javascript" th:src="@{/sh/scripts/codebase.js}"></script>

  <!-- 各View固有で読み込むリンク -->
  <th:block th:replace="${links}" />
</head>

3行目で、head要素に th:fragment 属性を指定して、head要素が外部から呼び出し可能なテンプレートであることを示している。また、引数としてtitlelinksを受け取ることを示している。 ここでは、これらの引数は fragments expressions で引き渡された要素であることを想定している。

テンプレート内のth:replace属性は、その属性が指定された要素そのものを引数の要素で置き換えることを示している。 上記の例では、title要素とth:block要素を引数の内容で置き換えている。

fragmentの呼び出し

この fragment は次のように呼び出す。

<!-- main.html -->
<html>
 <head th:replace="base :: common_header(~{::title},~{::link})">
  <title>Awesome - Main</title>
  <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
  <link rel="stylesheet" th:href="@{/themes/smoothness/jquery-ui.css}">
 </head>
 <body><!-- 割愛 -->
 </body>
</html>

3行目に記述したhead要素のth:replace="base :: common_header(~{::title},~{::link})"が、fragment の呼び出しである。

fragment を特定する構文は次の通りである。

<テンプレート名> :: <DOMセレクタ>

ここではbase :: common_headerと記述することで、base.html というレイアウトにあるcommon_headerという fragment を呼び出している。

引数の~{::title}~{::link}は、fragment expressions で新しく追加された構文である。

~{<レイアウト名>:: <DOMセレクタ>}

:: の左のレイアウト名を省略すると、自分自身のレイアウト(main.html)から要素を探すことを意味する。

~{::title} は main.html のtitle要素 を示す。 つまりは、

<title>Awesome - Main</title>

である。

同様に~{::link}は main.html にあるlink要素を示す。 つまりは、

  <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
  <link rel="stylesheet" th:href="@{/themes/smoothness/jquery-ui.css}">

である。

出力する HTML

これらの要素を fragment に渡すと、最終的に以下の HTML を出力する。

<head>
  <!-- 各Viewのタイトル -->
  <title>Awesome - Main</title>

  <!-- どのViewでも必ず読み込むファイル -->
  <link rel="stylesheet" type="text/css" media="all" href="/context/css/awesomeapp.css">
  <link rel="shortcut icon" href="/context/images/favicon.ico">
  <script type="text/javascript" src="context/sh/scripts/codebase.js"></script>

  <!-- 各View固有で読み込むリンク -->
  <link rel="stylesheet" href="/context/css/bootstrap.min.css">
  <link rel="stylesheet" href="/context/themes/smoothness/jquery-ui.css">
</head>

このように、fragment expressions を利用することで、共通化したテンプレートと各 View 固有の要素が組み合わせた HTML を生成できる。

前バージョンとの比較

前バージョンの Thymeleaf では、title要素の名前くらいであれば、以下のようにtitle属性のテキストを置換することで解決できた。

<!-- 引数のタイトルは文字列想定 -->
<head th:fragment="common_header_prev(title)">
  <!--各Viewのタイトル -->
  <title th:text="${title}">The awesome application</title>

  <!-- どのViewでも必ず読み込むファイル -->
  <!-- 割愛 -->
</head>

しかし、複数のlink要素をまとめて fragment に渡す方法は無かった。

fragment expressions により、 Thymeleaf のテンプレート化機能は、再利用性と柔軟性が向上したといえる。

fragment expressions の活用例

フォームの入力要素の記述では、ラベルを付与したり、バリデーションエラーのメッセージを横に表示したりといった定型的な記述を何度も行うことが多い。

こうした定型的な記述は fragment expression によって改善できる。

以下のような、ラベル、入力要素、エラーメッセージをワンセットにした HTML を fragment 化してみよう。

<div th:classappend="${#fields.hasErrors('name')}? 'error'"> <!-- /* name 属性にエラーが合った場合、 divに class="error"を出力する */-->
  <label for="name">名前</label>
  <input id="name" type="text" th:feild="*{name}" praceholder="名前" />
  <span class="text-danger" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Error</span>
</div>

ラベルの名前やフィールド名などは、fragment に文字列として渡せばよい。input要素については、テキストのほかに テキストエリアやセレクトボックスなどさまざまな形式がありえるし、classplaceholderなど任意の要素を使用する場合もあるため、fragment expressions を利用して共通化する。

その場合、次のような fragment を作ればよい。

<!-- helper.html -->
<div th:fragment="input(field_id, label, input_element)"
     th:classappend="${#fields.hasErrors('__${field_id}__')}? 'has-error'">
    <label th:for="${field_id}" th:text="${label}" >label name</label>
    <th:block th:replace="${input_element}"/>
    <span th:if="${#fields.hasErrors('__${feild_id}__')}" th:errors="*{__${field_id}__}">Error</span>
</div>

1つめの引数のfield_idは、ラベルと入力要素の対応付けや、エラー情報の判定などに使用する。

上記定義の中の __${field_id}__ という記述は、プリプロセッシングの記述であり、動的に式の内容を変化させる場合に使用する。

たとえば、上記のspan要素のth:if属性にある、#fields.hasErrorsという関数はフィールド名を引数に取り、当該フィールドにエラーがあるかを判断するもので、${#fields.hasErrors('name')}のように 文字列でフィールド名を指定しなければならない。

今回の場合、#fields.hasErrors に渡す文字列は、fragment の引数 field_id の値を ${field_id} という式を評価して取得する必要がある。 ただし、${#fields.hasErrors('${field_id}')} のような式の中に式を書く記述は Thymeleaf では許されない。

そこで、 __式__ というプリプロセッシング記述を使用する。 ${#fields.hasErrors('__${field_id}__')} という式は、プリプロセッシング記述を先に評価して、${#fields.hasErrors('name')} という内容に式を書き換える。

2つめの引数のlabelはラベルの名称に使用する。

最後の引数のinput_elementは fragment expressions で任意のinput要素として使用する。

fragment の引数には、文字列などの値と fragment expressions の両方を指定できるし、混在も可能だ。 引数名を工夫したり、ドキュメントを作成するなどして、引数に値か fragment expressions のどちらを渡せばよいのかをわかるようにしておくと良いだろう。

この fragment は以下のように使用する。

<form th:action="@{/register}">
  <div th:replace="helper::input('name', '名前', ~{::#name})>
    <label>名前</label>
    <input id="name" type="text" th:feild="*{name}" praceholder="名前" />
  </div>

  <div th:replace="helper::input('password', 'パスワード', ~{::#password})>
    <!-- label は 実際には記載しなくてもfragment から出力される -->
    <input id="password" type="password" th:feild="*{password}" praceholder="8文字以上" />
  </div>
  
  <div th:replace="helper::input('name', '名前', ~{::#description})>
    <textarea id="description" th:feild="*{description}"></textarea>
  </div>
</form>

こうすることで、3つの入力要素それぞれについて、label、メッセージ用のspan、入力要素 が出力される。

ここでは、 fragment expressions に ~{::#name} のように DOM セレクタに ID 属性を指定していることに注意してほしい。このように指定した理由は、DOM セレクタがレイアウトファイルの全要素から条件に合致する要素を取得するためだ。もしここで、~{::input}のように記述すると、すべてのinput要素を渡すことになってしまう。

DOM セレクタXPathCSS セレクタから発想を得ていて、さまざまな条件で要素を抽出できる。詳細については、Thymeleafのドキュメントを参照して欲しい。

上のサンプルの HTML のコメントにも記述したが、label要素は記載しなくても fragment から出力されるし、記載してあっても fragment の内容で置換される。ただし、labelを書いておくことで、レイアウトの HTML をそのままブラウザで出力した結果が、実際の見た目と近くなるので、書いておいたほうがよいだろう。

まとめ

Thymeleaf 3 の fragment expressions について説明した。

筆者はこれまで、素の HTML を可能な限り保つという Thymeleaf の特徴は良いとは思うものの、レイアウトの共通化などの機能は、JSPJSF に比較すると弱いと考えていた。

しかし、 fragment expressions の登場によってその弱点を克服し、隙の無いテンプレートエンジンになったと言える。レイアウトのテンプレート化や、よく使う構文の一元化など、さまざまな箇所に適用できるだろう。

[前多 賢太郎]