前回は「セッション管理」について学びました。セッションにより複数のHTMLページでデータを保持することができ、ログインや買い物かごの機能が実現できました。

今回はThymeleafを使いデータをHTMLテンプレートで動的に表示する方法を学びましょう。

今回の学習の重点をオレンジの枠線で図示します。

Thymeleafを学び、データをHTMLテンプレートで動的に表示する

1. なぜThymeleaf(テンプレートエンジン)が必要なのか?

Thymeleafを使うと、HTMLファイルの中にStandard Expression(標準式)や制御構文を書くことで、Javaのオブジェクトの値を動的に埋め込んだり、条件分岐や繰り返し表示ができます。

なぜThymeleaf?

  1. デザイナーとの協業がしやすい:HTMLファイルをそのままブラウザでプレビューしやすい
  2. Spring Bootとの相性が良い

2. Thymeleafの基本

ThymeleafではHTMLタグ属性に対してth:*を使います。

2.1. 変数展開の基本

HTMLの中に以下のように記述します。

<p th:text="${msg}">ここにメッセージが入ります</p>

${msg} → コントローラからmodel.addAttribute("msg", "Hello") された文字列などを埋め込みます。

Controller側

package com.example.demo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class MainController{
	@GetMapping("/")
	public String sample(Model model) {
		model.addAttribute("msg", "Hello World");
	
		return "sample";
	}
}

sample.html(抜粋)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"></head>
<body>
  <p th:text="${msg}">ここにメッセージ</p>
</body>
</html>

変数展開のイメージ

これで / にアクセスすると <p>Hello World</p> が表示されます。

"ここにメッセージ" は、通常 表示されない 文字列です。なぜなら、Thymeleafの th:text 属性は、指定された変数 ${msg} の値でタグの内容を置き換えるからです。つまり、msg に値がある場合は、<p> の中身が msg の値に置き換えられ、元の "ここにメッセージ" は消えてしまいます。

実験

変数と文字列を組み合わせる場合

変数と文字列を組み合わせて表示したい場合、文字列部分は シングルクォートで囲む 必要があります。

例:変数と文字列の結合

<p th:text="${name} + 'さん、こんにちは!'"></p>

この場合、name の値が "田中" だったとすると、以下のHTMLに変換されます:

<p>田中さん、こんにちは!</p>

ダブルクォートを使うとエラーになる

th:text はHTMLの属性なので、通常は ダブルクォート(")で囲まれた属性値 の中に記述します。そのため、文字列の開始・終了にダブルクォートを使うと、HTMLの属性と衝突する ため、エラーが発生します。

間違った例(エラー)

<p th:text="${name} + "さん、こんにちは!""></p>  <!-- NG -->

このようにダブルクォートを使うと、th:text の属性値が "Hello, で終了したと解釈され、正しく動作しません。

2.2 [[${msg}]]を使った場合

次に、[[${msg}]]記法を使った例を示します。

<p>[[${msg}]]</p>

この書き方では、[[${msg}]]の位置に${msg}の値が挿入されます。

HTMLエスケープ処理が行われ、安全に変数を表示できます。

sample.html(抜粋)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
</head>
<body>
  <p>[[${msg}]]</p>
</body>
</html>

この場合も / にアクセスすると、以下のように表示されます。

<p>Hello World</p>

このように、th:text属性と[[${msg}]]は同じ結果になりますが、[[${msg}]]記法の場合は、HTMLタグの中のプレースホルダーの位置に変数の値を展開する形になります。

th:text → タグ内の内容を丸ごと置換する

[[${msg}]] → タグ内の指定位置に挿入する

本研修では、th:text を優先して使っていきます。

実験

以下は、Thymeleafテンプレートで悪意あるJavaScriptが挿入される例(XSS攻撃の例)です。

th:utext は、「unescaped text」(エスケープされていないテキスト)を意味するものです。HTMLをそのまま出力したいときに使います。以下を実験してみましょう。

Controller側の例(安全ではない文字列を渡す場合)
@GetMapping("/")
public String sample(Model model) {
    model.addAttribute("msg", "<script>alert('攻撃!');</script>");
    return "sample";
}
安全でない書き方(HTMLタグをそのまま出力する場合)
th:utext属性を使用する場合(危険)
<p th:utext="${msg}">ここにメッセージ</p>

結果(ブラウザのレンダリング)

<p><script>alert('攻撃!');</script></p>

このコードがブラウザで実行されると、JavaScriptのダイアログが表示され、攻撃が成立してしまいます。

安全な書き方(自動エスケープで安全に表示される)
th:text属性を使用した場合(安全)
<p th:text="${msg}">ここにメッセージ</p>

結果(エスケープされて安全)

<p>&lt;script&gt;alert(&#39;攻撃!&#39;);&lt;/script&gt;</p>


HTMLタグとして認識されず、ブラウザでそのままテキストとして表示されます。

[[${msg}]]記法を使用した場合(安全)
<p>[[${msg}]]</p>

結果(こちらもエスケープされ安全)

<p>&lt;script&gt;alert(&#39;攻撃!&#39;);&lt;/script&gt;</p>


同様にHTMLタグはテキスト扱いとなり、攻撃は成功しません。本研修でも安全な書き方を推奨します。

2.4 ==で文字列の比較ができる

Thymeleafの式内で、文字列を比較する場合は、JavaSEとは異なり、==で比較可能です。なお文字列リテラルは '(シングルクォーテーション)で囲む必要があります。

※ここから先はThymeleafと対応するHTMLを上下に並べて配置しますので見比べて下さい。

 <p th:text="${msg == 'Hello World' }">メッセージがHello Worldかどうか?</p>
<p>true</p> <!--msgがHello Worldの場合 -->

ポイント:

  1. Thymeleafでは内部で equals() による比較を自動で行うため、
    Javaのように equals()を呼ぶ必要はありません。
  2. ただし、equals()を明示的に呼ぶことも可能です。
<p th:text="${msg.equals('Hello World') }">メッセージがHello Worldかどうか?</p>
<p>true</p> <!--msgがHello Worldの場合 -->

2.5 数値の比較 (==)

数値についても同様に比較可能です。

<p th:text="${age == 20 }">ageが20かどうか?</p>
<p>false</p> <!--ageが19の場合 -->

数値の場合、以下のような大小比較も簡単に行えます。

<p th:text="${age <= 20 }">ageが20以上かどうか?</p>
<p>true</p> <!--ageが19の場合 -->

3. Thymeleafでの条件分岐・繰り返し


3.1 条件分岐

th:if, th:unless

Thymeleafで単純なif/elseを実現するには、th:ifth:unless を使うのが最も簡単です。

<p th:if="${age >= 20}">お酒が飲めます</p>
<p th:unless="${age >= 20}">まだ飲めません</p>
<p>まだ飲めません</p> <!--ageが19の場合 -->

  1. th:if="${条件式}" → 条件式がtrueならタグを表示、falseなら非表示
  2. th:unless="${条件式}"th:ifの逆で、条件式がfalseなら表示、trueなら非表示

参考(JavaSE)

int age = 19; // 任意の年齢を設定

if (age >= 20) {
    System.out.println("お酒が飲めます");
} else {
    System.out.println("まだ飲めません");
}

3.2 null の扱いについて

変数がnullかどうかは、Thymeleafでは以下のように記述します。

nullの場合を判定

<p th:if="${user == null}">ユーザーが存在しません。</p>
<p>ユーザーが存在しません。</p> <!--userがnullの場合 -->

空文字の扱いについて

文字列が空文字かどうかの判定もよく行います。

<p th:if="${name == ''}">名前が入力されていません</p>
<p>名前が入力されていません</p> <!--nameが空文字の場合 -->

また、空文字やnullをまとめて判定するには以下の書き方が便利です。

<!-- 文字列がnullまたは空文字の場合 -->
<p th:if="${#strings.isEmpty(name)}">名前が未入力です</p>

#stringsはThymeleafが提供する便利なユーティリティオブジェクトです。

3.3 th:switch / th:case

JavaSEのswitchと同じことをするには、Thymeleafの th:switch / th:case を使います。

<div th:switch="${bloodType}">
  <p th:case="'A'">慎重な性格</p>
  <p th:case="'B'">大胆な性格</p>
  <p th:case="'O'">おおらかな性格</p>
  <p th:case="*">不思議な性格</p> <!-- default相当 -->
</div>
<div>
		
		
		
    <p>不思議な性格</p> <!-- default相当 -->
</div>

  • th:switch="${変数}"
  • th:case="'A'"Aに一致する場合のみ表示
  • th:case="*" → マッチしない場合(デフォルト)

参考(JavaSE)

String bloodType = "A"; // 任意の血液型を設定

switch (bloodType) {
    case "A":
        System.out.println("慎重な性格");
        break;
    case "B":
        System.out.println("大胆な性格");
        break;
    case "O":
        System.out.println("おおらかな性格");
        break;
    default:
        System.out.println("不思議な性格");
        break;
}

3.4 繰り返し

JavaSEの拡張for文に相当するのは、th:each 属性です。

<ul>
  <li th:each="name : ${names}" th:text="${name}">名前</li>
</ul>
<ul>
	<li>imai</li>
	<li>matsuda</li>
	<li>tabuchi</li>
	<li>hashi</li>
</ul>
<!-- namesが4人の名前の場合 -->

  1. ${names} は、Controller側で List<String> などをmodel.addAttribute("names", namesList) しておく。
  2. name : ${names} → 変数nameにリストの要素が順番に入る
  3. th:text="${name}" → 文字列を表示

例)オブジェクトのリストをテーブル表示

<table>
  <thead>
    <tr><th>ID</th><th>名前</th><th>価格</th></tr>
  </thead>
  <tbody>
    <tr th:each="car : ${cars}">
      <td th:text="${car.id}"></td>
      <td th:text="${car.name}"></td>
      <td th:text="${car.price}"></td>
    </tr>
  </tbody>
</table>

参考(JavaSE)

  for (Car car : cars) {
     System.out.printf(car.getId(), car.getName(), car.getPrice());
  }

3.5 th:eachとth:ifの組み合わせ

例えば、名前が「tabuchi」の場合のみ表示する、というケースを考えてみます。

<ul>
    <li th:each="name : ${names}" th:if="${name == 'tabuchi'}">
        [[${name}]]
    </li>
</ul>
<ul>
    <li>tabuchi</li>
</ul>
<!-- namesが4人の名前の場合 -->

参考(JavaSE)

for (String name : names) {
     if ("tabuchi".equals(name)) {
           System.out.println(name);
     }
}

4. 文字列のフォーマット(金額、日付など)

Thymeleafでは#numbers#temporals (Springのユーティリティオブジェクト)などを使って書式制御ができます。

4.1 金額の書式

<p th:text="${#numbers.formatCurrency(price)}"></p>
<p>¥2,590,000</p>

金額の表示方法は複数ありますが、ここでは整数表示し、3桁区切りはLocale設定による#numbers.formatCurrency(value) を使いました。

#numbers.formatCurrency(value)はロケール設定といって、「表示形式や言語・地域に応じたデータの表示ルールを決めるための設定」に左右されます。

ロケール設定は特に数字・通貨・日付などを適切なフォーマットで表示したい場合に重要になります。

例えば、ロケールが日本語なら、日本円として表示されます(¥1,000)、ロケールが米国なら、ドル表記 $1,000 のようになります。

ロケールの設定は本研修の範囲を超えるため、ここでは割愛します。

4.2 日付の書式

<p th:text="${#temporals.format(date, 'yyyy年MM月dd日(E)')}"></p>

Thymeleaf の #temporals.format(date, 'yyyy年MM月dd日(E)') を使うと、2025年03月16日(日) のように、「年」「月」「日」「曜日」を日本語表記 で表示できます。

例)Controllerで渡す

@GetMapping("/dateSample")
public String dateSample(Model model) {
    model.addAttribute("today", LocalDate.now());
    return "dateSample";
}

dateSample.html

<p th:text="${#temporals.format(today, 'yyyy年MM月dd日(E)')}"></p>
<p>202X年03月16日(日)</p>

各フォーマット要素の意味

フォーマット意味出力例(2025年3月16日)
yyyy西暦4桁202X
MM月(2桁)03
dd日(2桁)16
E曜日(省略形)
EEEE曜日(フル表記)日曜日

具体例

フォーマット出力例(202X年3月16日)
yyyy/MM/dd202X/03/16
yyyy年M月d日202X年3月16日(先頭ゼロなし)
yyyy-MM-dd (E)202X-03-16 (日)
yyyy/MM/dd (EEEE)202X/03/16 (日曜日)

5. CSS・JavaScriptに変数の値を渡す

5.1 CSSに変数を渡す

CSSに変数を渡す最もシンプルな方法はThymeleafで以下のようにHTMLの属性として「th:style=」を設定する方法です。

<body th:style="'background-color: ' + ${bgColor}">

以前フォームのところで学んだ天気予報プログラムのプロトタイプを例にCSSに変数の値を渡す例を説明します。天気によって背景色を変えたいとします。晴れなら"yellow"、雨なら"blue"、曇りなら"gray"とします。

背景色が変わる天気予報プログラム

入力フォーム

    <a th:href="@{/weather(weather='晴れ')}">晴れ</a>
    <a th:href="@{/weather(weather='曇り')}">曇り</a>
    <a th:href="@{/weather(weather='雨')}">雨</a>

Controller

package com.example.demo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class WeatherController {

	@GetMapping("/weather")
	public String weather(@RequestParam(name = "weather") String weather, Model model) {
		// 天気に応じて背景色を設定
		String backgroundColor;
		switch (weather) {
		case "曇り":
			backgroundColor = "gray";
			break;
		case "雨":
			backgroundColor = "blue";
			break;
		default: // 晴れ
			backgroundColor = "yellow";
			break;
		}

		// Modelに値をセット
		model.addAttribute("weather", weather);
		model.addAttribute("bgColor", backgroundColor);

		return "weather";
	}

	@GetMapping("/link")
	public String showLink() {
		return "link";
	}
}

HTML内で変数を埋め込む

<body th:style="'background-color: ' + ${bgColor}">
  今は <span th:text="${weather}"></span> なんですね。
</body>

5.2 JavaScriptに変数を渡す

JavaScriptのconfirm()メソッドを使ってログアウトの確認をする例で説明します。

confirm()メソッドを使ってログアウトの確認をする

Controller

    @GetMapping("/home")
    public String home(Model model) {
        String username = "yusei"; // ここでは仮のユーザー名
        model.addAttribute("username", username);
        return "home"; // home.html を表示
    }

    @GetMapping("/logout")
    public String logout() {
        return "redirect:/home"; // ログアウト後、ホームへリダイレクト
    }

コメントアウトしたうえで変数を埋め込む

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Home</title>
</head>
<body>

    <h1>ようこそ、<span th:text="${username}"></span> さん!</h1>
    
    <!-- ログアウトリンク -->
    <a href="#" onclick="confirmLogout()">ログアウト</a>

    <!-- JavaScript に変数を渡す -->
    <script th:inline="javascript">
        var username = /*[[${username}]]*/ 'ゲスト';

        function confirmLogout() {
            var isConfirmed = confirm(username + " さん、本当にログアウトしますか?");
            if (isConfirmed) {
                window.location.href = "/logout";
            }
        }
    </script>

</body>
</html>

解説
    <script th:inline="javascript">

Thymeleaf で th:inline="javascript" を指定すると、Thymeleaf の式 (${}) を JavaScript のコード内で適切に埋め込むための処理 が行われます。

var username = /*[[${username}]]*/ 'ゲスト';
Thymeleaf の JavaScript 内埋め込みの仕組み
  1. /*[[${username}]]*/ は、Thymeleaf が HTML をレンダリングするときに、${username} の値を JavaScript の適切な形式で埋め込むための構文です。
  2. /*[[${username}]]*/ のように コメントアウトの中に Thymeleaf の式を書いている理由 は、Thymeleaf を処理しない環境(ローカルのHTMLファイルとして開いた場合など)でもエラーが発生しないようにするためです。
  3. 'ゲスト' は、デフォルト値として設定されています。${username}null空文字 だった場合に、適切なデフォルト値として「ゲスト」を使うため です。

6.Thymeleaf を使った共通レイアウトの作成

th:fragment を使うことで、ヘッダーやフッターなどの共通部分を別のテンプレートとして定義し、複数のページで再利用できます。

以下に、main.html(メインレイアウト)、header.html(ヘッダー)、footer.html(フッター)の3つのファイルを用いた実装方法を説明します。

ヘッダーやフッターなどの共通部分を別のテンプレートとして定義

※サンプルは「http://localhost:8080/replace」で実行できます。
※メニューの「会社概要」や「お問い合わせ」のリンク先は未作成です。

ヘッダーファイル

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
</head>
<body>
    <header th:fragment="header">
        <h1>サイトのタイトル</h1>
        <nav>
            <ul>
                <li><a th:href="@{/}">ホーム</a></li>
                <li><a th:href="@{/about}">会社概要</a></li>
                <li><a th:href="@{/contact}">お問い合わせ</a></li>
            </ul>
        </nav>
    </header>
</body>
</html>

th:fragment="header" で、この <header> を再利用可能な部品(フラグメント)として定義しています。英語の【fragment】には「断片」といった意味があります。

フッターファイル

<!-- src/main/resources/templates/footer.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
</head>
<body>
    <footer th:fragment="footer">
        <p>Copyright © Say consulting group, Inc All Rights Reserved.</p>
    </footer>
</body>
</html>

th:fragment="footer" で、フッターを再利用可能な部品として定義しています。

メインレイアウト

<!-- src/main/resources/templates/main.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>メインページ</title>
</head>
<body>

    <!-- ヘッダーの挿入 -->
    <header th:replace="header :: header"></header>

    <!-- メインコンテンツ -->
    <main>
        <p>ここにメインコンテンツが入ります。</p>
    </main>

    <!-- フッターの挿入 -->
    <footer th:replace="footer :: footer"></footer>

</body>
</html>
  1. th:replace="header :: header"header.htmlth:fragment="header" を挿入。
  2. th:replace="footer :: footer"footer.htmlth:fragment="footer" を挿入。
  3. ヘッダー・フッターを共通化し、メンテナンスしやすくなる。

結合後のHTML

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>メインページ</title>
</head>
<body>

    <!-- ヘッダーの挿入 -->
    <header>
        <h1>サイトのタイトル</h1>
        <nav>
            <ul>
                <li><a href="/">ホーム</a></li>
                <li><a href="/about">会社概要</a></li>
                <li><a href="/contact">お問い合わせ</a></li>
            </ul>
        </nav>
    </header>

    <!-- メインコンテンツ -->
    <main>
        <p>ここにメインコンテンツが入ります。</p>
    </main>

    <!-- フッターの挿入 -->
    <footer>
        <p>Copyright © Say consulting group, Inc All Rights Reserved.</p>
    </footer>

</body>
</html>

穴埋め問題

次のControllerクラスとThymeleafテンプレートを見て、空欄①〜⑪を適切に埋めてください。

コントローラー側のコード

package com.example.demo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class Main {

	@GetMapping("/sample")
	public String sample(Model model) {
		model.addAttribute("msg", "Hello World");
		model.addAttribute("age", 20);
		model.addAttribute("name", null);
		return "sample"; // ①(         )ファイルを表示
	}
}

Thymeleafテンプレート

<!DOCTYPE html>
<html xmlns:th="http://www.②(        )">
<head>
	<meta charset="UTF-8">
	<title>サンプルページ</title>
</head>
<body>

  <!-- メッセージを表示 -->
  <p ③(        )="${msg}">ここにメッセージが入ります</p>

  <!-- age が 18以上なら成人と表示 -->
  <p th:④(     )="${age >= 18}">成人です</p>

  <!-- name が null の場合に「名前がありません」を表示 -->
  <p th:if="${name ⑤(         ) null}">名前が入力されていません。</p>

  <!-- nameがnullでない場合に名前を表示(nullの場合は表示しない) -->
  <p th:⑥(        )="${name != null}" th:text="${name}">名前</p>

  <!-- 数値の比較で間違えやすい点 -->
  <p th:if="${age ⑦(    )20}">年齢は20歳です</p>

  <!-- 文字列の空判定で間違えやすい点 -->
  <p th:if="${name == ⑧(        )}">名前が入力されていません</p>

  <!-- 条件分岐(switch-case構文)-->
  <div th:⑨(        )="${age}">
    <p th:case="20">20歳です</p>
    <p th:case="*">20歳ではありません</p>
  </div>

  <!-- 繰り返し構文 -->
  <ul>
    <li th:⑩(         )="item : ${items}" th:text="${item}">アイテム名</li>
  </ul>

  <!-- JavaScriptへの変数展開で間違えやすい点 -->
  <script th:inline="⑪(        )">
    let message = /*[[${msg}]]*/ "デフォルトのメッセージ";
    alert(message);
  </script>

</body>
</html>

<まとめ:隣の人に正しく説明できたらチェックを付けましょう>

□ th:text を使うと、HTMLタグの中身を変数で置き換えられ、${変数名} で コントローラーから渡された値を使える。

□ 単純分岐を実現するにはth:if, th:unlessを使う

== で 文字列やnullの比較が可能(equals不要)

th:each="item : ${items}" で リストをループ表示できる。item には各要素が順番に入る。(拡張for文に似る)

□ 金額のフォーマット:#numbers.formatCurrency(price) 日付のフォーマット:#temporals.format(date, 'yyyy年MM月dd日(E)')

□ th:style="'background-color: ' + ${bgColor}" のようにして CSSに動的値を渡せる

<script th:inline="javascript"> を使えば JavaScript内に変数を展開可能(/*[[${変数}]]*/ で埋め込み)

□ HTMLを共通パーツにできる。(例:th:replace="header :: header"header.htmlth:fragment="header" を挿入できる)

第6章の今回はHTMLに動きを与える方法について学びました。

第7章は「DTOとは?データの受け渡しを整理し、コードをシンプルにする」です。