コントローラからビューへの値の受け渡しとThymeleaf
この章では、Spring MVCでコントローラからビューに値を渡し、Thymeleafを使って動的にHTMLを生成する方法を学ぶ。 静的なHTMLだけではなく、リクエストごとに異なる値を画面に表示できるようになることで、動的にページを表示する仕組みを理解する。
学習のゴール
ModelとaddAttributeを使ってコントローラからビューに値を渡せる${...}/[[...]]/th:textによる動的な値の表示方法を理解するth:utextの危険性(XSS脆弱性)と安全な表示方法を理解するth:ifやth:switchによる条件分岐、th:eachによる繰り返し表示を実装できる- Javaオブジェクトのgetterを通じてフィールドを参照する仕組みを理解できる
th:objectを使ってオブジェクト参照を省略し、テンプレートを簡潔に書ける- ユーティリティオブジェクト(#strings, #numbers, #dates)を使って文字列・数値・日付を処理できる
- フラグメントとレイアウトの仕組みを使って、共通部分を再利用し保守性を高められる
Thymeleafの概要
Thymeleaf(タイムリーフ)は、Spring Bootのデフォルトビューエンジンである。 HTMLファイルに「動的な表記」を埋め込むことで、サーバ側の値をブラウザに表示する仕組みを提供する。
- 特徴
- 普通のHTMLとしてブラウザで開ける(デザインと開発を分けやすい)
${...}を使ってサーバから渡された値を埋め込める- if文やfor文のような制御も可能
サンプル:変数をHTMLに埋め込む
<p>こんにちは、[[${name}]] さん!</p>
${name}はコントローラから渡された変数を参照している
コントローラからどのように値を渡すのかを次で学ぶ
Modelと値の受け渡し
コントローラからビューへ値を渡すには、Model に値を追加する。
ここで扱う Model は「コントローラからビューに値を渡すための入れ物」を指す。
MVCの「Model層(ドメインモデルや業務ロジックを担う部分)」とは別物であることに注意。
@Controllerpublic class HelloController {@GetMapping("/hello")public String hello(Model model) { // Modelを引数に受け取ることでビューに値を渡せるmodel.addAttribute("name", "太郎");return "hello"; // templates/hello.html を返す}}
ビュー(hello.html)では ${name} を使って表示できる。
<p>こんにちは、[[${name}]] さん!</p>
model.addAttribute("name", "太郎")→${name}が "太郎" に置き換わる${}内の変数名とaddAttributeの第1引数が対応している
th:text と [[...]] の違い
Thymeleafには値を表示する方法が複数ある。
<p th:text="${msg}">dummy</p>
これは属性で値を出力する方法。公式の書き方に忠実。
もう一つはインライン式 [[...]] を使う方法。こちらの方がシンプルで推奨。
<p>[[${msg}]]</p>
<p>ようこそ [[${msg}]] さん!</p>
th:text… 属性に書く。プレースホルダのdummyが置き換わる[[...]]… 本文に直接埋め込めるため、直感的でシンプル
th:utext と XSS脆弱性
th:utext を使うと、値が「HTMLとして解釈」されて埋め込まれる。
そのため、タグがそのまま展開される。
model.addAttribute("msg", "<b>太郎</b>");
<p th:utext="${msg}"></p>
↓
<p><b>太郎</b></p>
→ 結果、太字で 太郎 と表示される。
しかし、もしユーザ入力をそのまま th:utext で表示すると、次のように 悪意のあるJavaScriptが埋め込まれる危険 がある。
model.addAttribute("msg", "<script>alert('XSS');</script>");
<p th:utext="${msg}"></p>
↓
<p><script>alert('XSS');</script></p>
→ ページを開いたときに alert が実行される。これが XSS(クロスサイトスクリプティング)攻撃。
th:utext はユーザー入力を含む値に使うと XSS攻撃の原因 になる。
- 通常は
th:textや[[...]]を使う- 値は自動的にHTMLエスケープ(サニタイズ)され、タグとして解釈されない
- 例:
<b>太郎</b>→<b>太郎</b>と変換され、ただの文字列として表示される
th:utext を使うのは、信頼できる静的なHTMLを埋め込みたい特別な場合に限る。
条件分岐
th:if
条件が成立した場合のみ、その要素を表示する。
<p th:if="${age >= 20}">あなたは成人です。</p>
<p th:if="${age < 20}">あなたは未成年です。</p>
- age が 20以上なら「成人です。」が表示される
- age が 20未満なら「未成年です。」が表示される
th:switch
Javaの switch 文のように、値に応じて分岐処理を行える。
<div th:switch="${role}">
<p th:case="'admin'">管理者です</p>
<p th:case="'user'">一般ユーザーです</p>
<p th:case="*">その他</p>
</div>
- role が "admin" なら「管理者です」が表示される
- role が "user" なら「一般ユーザーです」が表示される
- どちらでもなければ
th:case="*"が選ばれ「その他」が表示される
繰り返し(th:each)
リストや配列を繰り返し表示するには th:each を使う。
<ul>
<li th:each="user : ${users}" th:text="${user}">dummy</li>
</ul>
users が ["Taro", "Hanako"] の場合 → 出力結果は次のようになる。
<ul>
<li>Taro</li>
<li>Hanako</li>
</ul>
ループの状態を表すステータスも取得できる。
<li th:each="user, stat : ${users}">
[[${stat.index}]]: [[${user}]]
</li>
stat.index: 0から始まるインデックスstat.count: 1から始まるカウント
users が ["Taro", "Hanako"] の場合、出力結果は次のようになる。
<li>0: Taro</li>
<li>1: Hanako</li>
オブジェクトとフィールドアクセス
Thymeleafでは、Javaオブジェクトの getterメソッド を通じて値にアクセスできる。
public class User {
// フィールドは private で隠蔽
private String name;
private int age;
//getter
public String getName() { return name; }
public int getAge() { return age; }
//setter
public void setName(String name) { this.name = name; }
public void setAge(int age) { this.age = age; }
}
コントローラで model.addAttribute("user", user); と渡した場合、ビューでは次のように書ける。
user.name→getName()が呼び出されるuser.age→getAge()が呼び出される
つまり、user.フィールド名 の形で、getterを経由して値を参照できる。
<p>[[${user.name}]] ([[${user.age}]])</p>
th:object を使ったオブジェクト名の省略
th:object を使うと、そのブロック内で指定したオブジェクトを「基準」として扱える。
<div th:object="${user}">
<p>[[*{name}]] ([[*{age}]])</p>
</div>
th:object="${user}"で、このブロック内の参照対象をuserに設定*{name}→user.getName()を呼び出す*{age}→user.getAge()を呼び出す
user.name と毎回書かずに、*{フィールド名} の形で簡潔にアクセスできる。
フィールドが多いオブジェクトを扱う場合に特に効果的である。
ユーティリティオブジェクト
Thymeleafには便利なユーティリティが組み込まれており、# から始まる名前で呼び出せる。
文字列操作・数値フォーマット・日付処理などを簡単に行える。
文字列(#strings)
<p>[[${#strings.toUpperCase('spring')}]]</p>
- 文字列を大文字に変換する
- 出力例:
<p>SPRING</p>
<p th:if="${#strings.isEmpty(msg)}">空文字です</p>
- 文字列が空かどうかを判定する
- msg が空文字なら
<p>空文字です</p>が表示される
<p>[[${#strings.length('abc')}]]</p>
- 文字列の長さを返す
- 出力例:
<p>3</p>
数値(#numbers)
<p>[[${#numbers.formatDecimal(12345.678, 1, 'POINT', 2, 'COMMA')}]]</p>
- 数値をフォーマットする
- 第2引数: 整数部の最小桁数
- 第3引数: 小数点の区切り文字
- 第4引数: 小数部の桁数
- 第5引数: 整数部の区切り文字
- 出力例:
<p>12,345.68</p>
日付(#dates)
<p>[[${#dates.createNow()}]]</p>
- 現在時刻を返す
- 出力例:
<p>2025-10-04T10:15:30.123+09:00</p>のようなISO形式
<p>[[${#dates.format(today, 'yyyy/MM/dd')}]]</p>
- 日付を指定フォーマットで表示する
- today に 2000-01-01 が渡された場合 →
<p>2000/01/01</p>
staticリソースの参照
CSSやJavaScript、画像などの静的リソースを参照するには @{...} を使う。
<link rel="stylesheet" th:href="@{/css/styles.css}" />
<script th:src="@{/js/scripts.js}"></script>
<img th:src="@{/images/logo.png}" alt="Logo" />
staticディレクトリとは
Spring Bootでは、静的リソースは 必ず 以下の場所に配置する。
src/main/resources/static
@{/css/styles.css} と書くと、src/main/resources/static/css/styles.css を参照する。
コントローラを経由せずにブラウザから直接アクセス可能なファイルを置く場所である。
フラグメントとレイアウト
複数のHTMLファイルで同じヘッダーやフッターを繰り返し書いていると、修正時に管理が大変になる。 Thymeleafの「フラグメント」や「レイアウト」を使うと、共通部分を切り出して再利用できる。
- フラグメント : 部品ごと(ヘッダーやフッター単位)に共通化したいときに便利
- レイアウトのベース化 : ページ全体の枠組み(共通デザイン)を統一したいときに便利
👉 フラグメントは「部品の再利用」、レイアウトは「ページ全体の統一」という違いがある。
重複したデザインの例
<!-- page1.html -->
<html>
<body>
<header>共通ヘッダー</header>
<main>ページ1の内容</main>
<footer>共通フッター</footer>
</body>
</html>
<!-- page2.html -->
<html>
<body>
<header>共通ヘッダー</header>
<main>ページ2の内容</main>
<footer>共通フッター</footer>
</body>
</html>
上記のように <header> と <footer> が重複してしまっている。
これをフラグメントやレイアウトで共通化する。
フラグメントの定義と利用
定義:
<!-- templates/fragments/header.html -->
<div th:fragment="siteHeader">
共通ヘッダー
</div>
<!-- templates/fragments/footer.html -->
<div th:fragment="siteFooter">
共通フッター
</div>
利用:
<!-- templates/page.html -->
<html>
<body>
<div th:replace="fragments/header :: siteHeader"></div>
<main>ページごとの内容</main>
<div th:replace="fragments/footer :: siteFooter"></div>
</body>
</html>
th:fragment="siteHeader": フラグメントとして名前を付けるth:replace="fragments/header :: siteHeader": そのフラグメントを呼び出す
👉 フラグメントは「ヘッダーやフッターなどの共通パーツを複数のページで再利用したいとき」に使う。
レイアウトのベース化
定義:
<!-- templates/layout/base.html -->
<html>
<body>
<header>共通ヘッダー</header>
<main th:replace="${content}"></main>
<footer>共通フッター</footer>
</body>
</html>
利用:
<!-- templates/page.html -->
<div th:replace="layout/base :: layout (~{::main})">
<main>ページごとのコンテンツ</main>
</div>
- ベースレイアウトを1つ作り、
<main>の部分だけ各ページで差し替える - これにより「共通部分の保守性」が大きく向上する
👉 レイアウトは「サイト全体の枠組みを共通化したいとき」に使う。
まとめ
- フラグメント : 部分的な共通化(ヘッダー・フッター・メニューなど)に適している
- レイアウトのベース化 : ページ全体の統一デザインに適している
両方を組み合わせて使うと、保守性の高いビュー構造を作れる。 例えば、レイアウトの中でヘッダー・フッターをフラグメントとして呼び出すことも可能。
よくある質問
Q. [[${name}]] と th:text="${name}" はどちらを使えばよいですか?
A. どちらでもよいが、テキストの中にJavaの変数を自然に埋め込む場合は [[...]] の方がシンプルで読みやすい。
th:text はHTMLタグの属性として書くため、タグ内のテキスト全体を置き換えたい場合に向いている。
Q. [[${name}]] でnullを表示したらどうなりますか?
A. null の場合は何も表示されない(空文字として扱われる)。
null をデフォルト値に置き換えたい場合は Thymeleafのエルビス演算子が使える。
[[${name} ?: '名無し']]
Q. th:each でインデックスを1始まりで表示するにはどうすればよいですか?
A. stat.count を使う。
<li th:each="item, stat : ${items}">
[[${stat.count}]]: [[${item}]]
</li>
stat.index は0始まり、stat.count は1始まりである。
Q. Thymeleafテンプレートのファイルはどこに置きますか?
A. src/main/resources/templates/ 配下に置く。
コントローラで return "hello" と書くと、templates/hello.html が自動的に解決される。
サブディレクトリを使いたい場合は return "user/detail" のように書くと templates/user/detail.html が使われる。
本章のまとめ
- コントローラからビューへの値渡しは
Model#addAttributeを使う ${...}内の変数はコントローラから渡された値であり、MVCのModel層とは別物- 値の出力には
th:textや[[...]]を用いる。th:utextはXSSリスクがあるため原則使わない - 条件分岐は
th:if/th:switch、繰り返しはth:eachを使える th:eachのループにはstat.indexやstat.countなどのステータスが使える- Javaオブジェクトのフィールドは
obj.fieldと書くとgetterを通じて参照される th:objectを使うとオブジェクト名を省略して*{フィールド名}でアクセスできる- ユーティリティオブジェクト(#strings, #numbers, #dates)で文字列・数値・日付処理が簡単にできる
- フラグメント は部品の共通化(ヘッダーやフッター)に、レイアウトのベース化 はページ全体の共通化に適している
- 両者を組み合わせることで、保守性が高く再利用性のあるビュー構造を作れる
- 次章では、コントローラで受け取った入力値を 検証(バリデーション)する方法 を学ぶ