株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
はじめに
前回(第2回)では、@Controllerと@RestControllerの違い、HTTPメソッドマッピング、@RequestBodyによるJSON受け取り、ResponseEntityによるレスポンス制御を学び、簡易TODO APIを構築しました。
その中で、@Controllerはビュー名(テンプレート名)を返すコントローラーであると紹介しました。第3回では、この@Controllerを本格的に使い、Thymeleaf(タイムリーフ) テンプレートエンジンでHTMLを動的に生成する方法を学びます。
今回学ぶこと
- Thymeleafの概要とJSPとの違い
-
@Controller+Modelでテンプレートにデータを渡す仕組み - Thymeleafの基本構文(
th:text、th:each、th:if、th:hrefなど) - フラグメントによるレイアウトの共通化
- 実践例としてユーザー一覧画面を構築
本記事のコードはすべて第1回で作成したhello-springプロジェクト(com.example.hellospringパッケージ)上で動作します。環境構築がまだの方は第1回を先にご覧ください。spring-boot-starter-thymeleafは第1回のpom.xmlに追加済みです。
1. Thymeleafとは
JSPに代わるテンプレートエンジン
Thymeleafは、Spring Bootにおける標準のテンプレートエンジンです。Servlet/JSP入門シリーズではJSP(JavaServer Pages)を使ってHTMLを動的に生成していましたが、Spring BootではJSPの代わりにThymeleafを使います。
Spring BootがJSPよりThymeleafを推奨する主な理由は以下の通りです。
| 観点 | JSP | Thymeleaf |
|---|---|---|
| 実行方法 | Servletにコンパイルして実行 | テンプレートとして直接処理 |
| JARパッケージング | 制約あり(WARが基本) | JARで問題なし |
| ブラウザでの直接表示 | 不可(<%= %>等がそのまま表示される) |
可能(後述の「自然テンプレート」) |
| Spring Bootの対応 | 限定的(追加設定が必要) | 公式スターター提供 |
自然テンプレート(Natural Templates)
Thymeleafの大きな特徴は、テンプレートファイルをブラウザでそのまま開けることです。
<!-- Thymeleaf テンプレート -->
<p th:text="${message}">デフォルトメッセージ</p>
-
サーバーで処理した場合 →
${message}の値(例:こんにちは)が表示される -
ブラウザでHTMLファイルを直接開いた場合 →
デフォルトメッセージが表示される
th:text等のThymeleaf属性はHTML標準の属性ではないため、ブラウザは無視します。結果として、<p>タグの中身(デフォルトメッセージ)がそのまま表示されます。これにより、デザイナーがサーバーなしでHTMLのデザインを確認できるメリットがあります。
一方、JSPでは<%= %>やJSTLタグがそのまま表示されてしまい、ブラウザだけでデザインを確認できません。
JSPとの対比表
| JSPの書き方 | Thymeleafの書き方 | 用途 |
|---|---|---|
<%= value %> |
th:text="${value}" |
値の出力 |
<c:forEach items="${list}" var="item"> |
th:each="item : ${list}" |
ループ |
<c:if test="${condition}"> |
th:if="${condition}" |
条件分岐 |
<c:choose> / <c:when>
|
th:switch / th:case
|
複数条件分岐 |
${pageContext.request.contextPath}/path |
@{/path} |
URL生成 |
<jsp:include page="header.jsp"> |
th:replace="~{fragments :: header}" |
部品の組み込み |
2. はじめてのThymeleafテンプレート
Controller側: Modelにデータを詰める
まず、コントローラーを作成します。@Controller(@RestControllerではない)を使い、Modelオブジェクトにデータを格納してビュー名を返します。
package com.example.hellospring;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ThymeleafHelloController {
@GetMapping("/thymeleaf/hello")
public String hello(Model model) {
model.addAttribute("name", "Spring Boot");
model.addAttribute("year", 2026);
return "hello";
}
}
ポイント:
-
Model modelを引数に宣言すると、Spring Bootが自動でModelオブジェクトを渡してくれる -
model.addAttribute("キー", 値)でテンプレートに渡すデータを格納する -
return "hello"はsrc/main/resources/templates/hello.htmlを返すという意味
テンプレート側: hello.html
src/main/resources/templates/hello.html を作成します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Hello Thymeleaf</title>
</head>
<body>
<h1>Hello, Thymeleaf!</h1>
<p>名前: <span th:text="${name}">デフォルト名</span></p>
<p>年: <span th:text="${year}">2000</span></p>
</body>
</html>
ポイント:
-
xmlns:th="http://www.thymeleaf.org"はThymeleafの名前空間宣言。IDEの補完を有効にするために記述する(なくても動作する) -
th:text="${name}"でModelに格納したnameの値を出力する -
デフォルト名や2000はブラウザで直接開いたときに表示される静的な値
アプリケーションを起動して http://localhost:8080/thymeleaf/hello にアクセスすると、以下のHTMLが描画されます。
<h1>Hello, Thymeleaf!</h1>
<p>名前: <span>Spring Boot</span></p>
<p>年: <span>2026</span></p>
Servlet/JSPとの処理フロー比較
この仕組みは、Servlet/JSPで行っていた以下の処理と本質的に同じです。
Servlet/JSP版:
// Servlet内
request.setAttribute("name", "Spring Boot");
request.setAttribute("year", 2026);
request.getRequestDispatcher("/WEB-INF/views/hello.jsp").forward(request, response);
Spring Boot版:
// Controller内
model.addAttribute("name", "Spring Boot");
model.addAttribute("year", 2026);
return "hello"; // templates/hello.html へフォワード
| Servlet/JSP | Spring Boot | 説明 |
|---|---|---|
request.setAttribute() |
model.addAttribute() |
データの格納方法 |
RequestDispatcher.forward() |
return "ビュー名" |
テンプレートへの転送 |
/WEB-INF/views/hello.jsp |
templates/hello.html |
テンプレートの配置場所 |
requestオブジェクトに直接格納 |
Modelオブジェクト経由 |
データの受け渡し手段 |
内部的には、Spring BootのModelに格納したデータは最終的にServletのHttpServletRequestの属性として設定されます。Servlet/JSPで学んだリクエスト属性の仕組みがそのまま活きています。
3. Thymeleafの基本構文
th:text ― テキスト出力(XSSエスケープ済み)
th:textは値をHTMLエスケープして出力します。XSS(クロスサイトスクリプティング)対策が自動的に行われます。
package com.example.hellospring;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class TextDemoController {
@GetMapping("/thymeleaf/text")
public String textDemo(Model model) {
model.addAttribute("safe", "Hello, World!");
model.addAttribute("dangerous", "<script>alert('XSS')</script>");
return "text-demo";
}
}
src/main/resources/templates/text-demo.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>th:text デモ</title>
</head>
<body>
<p th:text="${safe}">安全なテキスト</p>
<p th:text="${dangerous}">危険なテキスト</p>
</body>
</html>
描画結果:
<p>Hello, World!</p>
<p><script>alert('XSS')</script></p>
<script>タグがエスケープされ、JavaScriptは実行されません。
th:utext ― 非エスケープ出力
th:utext(unescaped text)はHTMLをエスケープせずに出力します。
<p th:utext="${dangerous}">危険なテキスト</p>
描画結果:
<p><script>alert('XSS')</script></p>
th:utextはXSS攻撃のリスクがあります。ユーザーが入力した値をth:utextで出力してはいけません。管理者が管理するHTMLコンテンツ(例:CMS記事の本文)など、信頼できるデータにのみ使用してください。
th:each ― ループ
th:eachは、コレクションの要素を繰り返し処理します。JSPの<c:forEach>に相当します。
package com.example.hellospring;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class EachDemoController {
@GetMapping("/thymeleaf/each")
public String eachDemo(Model model) {
List<String> fruits = List.of("りんご", "みかん", "ぶどう");
model.addAttribute("fruits", fruits);
return "each-demo";
}
}
src/main/resources/templates/each-demo.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>th:each デモ</title>
</head>
<body>
<h2>フルーツ一覧</h2>
<ul>
<li th:each="fruit : ${fruits}" th:text="${fruit}">フルーツ名</li>
</ul>
</body>
</html>
描画結果:
<ul>
<li>りんご</li>
<li>みかん</li>
<li>ぶどう</li>
</ul>
ステータス変数を使うと、インデックスや最初/最後の要素かどうかを判定できます。
<ul>
<li th:each="fruit, stat : ${fruits}">
<span th:text="${stat.index}">0</span>.
<span th:text="${fruit}">フルーツ名</span>
<span th:if="${stat.first}">(最初)</span>
<span th:if="${stat.last}">(最後)</span>
</li>
</ul>
描画結果:
<ul>
<li><span>0</span>. <span>りんご</span> <span>(最初)</span></li>
<li><span>1</span>. <span>みかん</span></li>
<li><span>2</span>. <span>ぶどう</span> <span>(最後)</span></li>
</ul>
ステータス変数で使えるプロパティは以下の通りです。
| プロパティ | 型 | 説明 |
|---|---|---|
index |
int |
0始まりのインデックス |
count |
int |
1始まりのカウント |
size |
int |
コレクションの要素数 |
current |
Object |
現在の要素 |
even |
boolean |
偶数番目か(0始まり) |
odd |
boolean |
奇数番目か(0始まり) |
first |
boolean |
最初の要素か |
last |
boolean |
最後の要素か |
th:if / th:unless ― 条件分岐
th:ifは条件が真のとき要素を表示します。th:unlessはその逆です。JSPの<c:if>に相当します。
<p th:if="${user.active}">アクティブユーザーです</p>
<p th:unless="${user.active}">非アクティブユーザーです</p>
th:ifが「真」と評価する値:
-
nullでない - boolean型の
true - 0でない数値
- 空でない文字列(ただし
"false"、"off"、"no"は偽) - boolean、Number、Character以外のオブジェクト
th:switch / th:case ― 複数条件分岐
th:switchは複数の条件を分岐します。Javaのswitch文と同様に、一致したth:caseの内容が表示されます。th:case="*"はデフォルトケースです。
<div th:switch="${user.role}">
<p th:case="'admin'">管理者です</p>
<p th:case="'editor'">編集者です</p>
<p th:case="*">一般ユーザーです</p>
</div>
th:caseで文字列リテラルを比較する場合は、シングルクォートで囲みます('admin')。数値の場合はそのまま書けます(th:case="1")。
th:href / th:src ― URL式(@{...})
Thymeleafでは、@{...}というURL式でリンクやリソースのパスを生成します。コンテキストパスが自動的に付加されるため、パスのハードコーディングを避けられます。
<!-- 静的パス -->
<a th:href="@{/thymeleaf/hello}">Hello画面へ</a>
<!-- パスパラメータ付き -->
<a th:href="@{/users/{id}(id=${user.id})}">ユーザー詳細</a>
<!-- クエリパラメータ付き -->
<a th:href="@{/search(keyword=${keyword}, page=${page})}">検索</a>
<!-- CSS / JavaScript -->
<link rel="stylesheet" th:href="@{/css/style.css}">
<script th:src="@{/js/app.js}"></script>
JSPでは${pageContext.request.contextPath}を毎回書く必要がありましたが、Thymeleafでは@{}が自動で処理します。
th:class / th:classappend ― CSSクラスの動的切り替え
th:classは既存のclass属性を上書きします。th:classappendは既存のclass属性に追記します。
<!-- th:class: クラスを上書き -->
<p th:class="${user.active} ? 'text-green' : 'text-red'">ステータス</p>
<!-- th:classappend: クラスを追記 -->
<p class="base-style" th:classappend="${user.active} ? 'active' : 'inactive'">ステータス</p>
th:classappendの場合、class="base-style"は維持されたまま、条件に応じてactiveまたはinactiveが追加されます。
描画結果(user.activeがtrueの場合):
<p class="base-style active">ステータス</p>
リテラル置換 |...|
文字列連結を簡潔に書くためのリテラル置換構文です。
<!-- 通常の文字列連結 -->
<p th:text="'こんにちは、' + ${name} + 'さん!'">挨拶</p>
<!-- リテラル置換(同じ結果) -->
<p th:text="|こんにちは、${name}さん!|">挨拶</p>
リテラル置換(|...|)の方が読みやすく、特に複数の変数を含む場合に便利です。
4. レイアウトの共通化
Webアプリケーションでは、ヘッダー、フッター、ナビゲーションなどの共通パーツが多くのページに存在します。Thymeleafではフラグメントの仕組みを使って共通パーツを部品化できます。
th:fragment ― 再利用可能な部品を定義する
src/main/resources/templates/fragments/common.html を作成します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!-- ヘッダー -->
<header th:fragment="header">
<nav style="background-color: #333; color: white; padding: 10px;">
<a href="/" style="color: white; margin-right: 20px;">ホーム</a>
<a href="/thymeleaf/users" style="color: white; margin-right: 20px;">ユーザー一覧</a>
</nav>
</header>
<!-- フッター -->
<footer th:fragment="footer">
<hr>
<p style="text-align: center; color: gray;">© 2026 Hello Spring App</p>
</footer>
</body>
</html>
th:replace / th:insert ― 部品を組み込む
定義したフラグメントを他のテンプレートに組み込むには、th:replaceまたはth:insertを使います。
| 属性 | 動作 |
|---|---|
th:replace |
ホスト要素をフラグメントで置換する |
th:insert |
ホスト要素の子要素としてフラグメントを挿入する |
具体例で違いを確認します。
<!-- th:replace の場合 -->
<div th:replace="~{fragments/common :: header}">ここが置換される</div>
<!-- th:insert の場合 -->
<div th:insert="~{fragments/common :: header}">ここに挿入される</div>
描画結果:
<!-- th:replace → divごと<header>に置き換わる -->
<header>
<nav style="background-color: #333; color: white; padding: 10px;">
<a href="/" style="color: white; margin-right: 20px;">ホーム</a>
<a href="/thymeleaf/users" style="color: white; margin-right: 20px;">ユーザー一覧</a>
</nav>
</header>
<!-- th:insert → divの中に<header>が入る -->
<div>
<header>
<nav style="background-color: #333; color: white; padding: 10px;">
<a href="/" style="color: white; margin-right: 20px;">ホーム</a>
<a href="/thymeleaf/users" style="color: white; margin-right: 20px;">ユーザー一覧</a>
</nav>
</header>
</div>
通常はth:replaceを使います。ホスト要素(<div>)を残す必要がない場合が多いためです。
Thymeleaf 3.1以降では、フラグメント式に~{...}構文を使用します。~{}を省略した旧構文(th:replace="fragments/common :: header")は非推奨です。
レイアウトの組み立て
フラグメントを使って共通レイアウトを組み立てた例です。
src/main/resources/templates/layout-demo.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>レイアウトデモ</title>
</head>
<body>
<!-- ヘッダー(フラグメントで置換) -->
<div th:replace="~{fragments/common :: header}">ヘッダー</div>
<!-- メインコンテンツ -->
<main style="padding: 20px;">
<h1>レイアウトデモページ</h1>
<p th:text="|このページのメッセージ: ${message}|">メッセージ</p>
</main>
<!-- フッター(フラグメントで置換) -->
<div th:replace="~{fragments/common :: footer}">フッター</div>
</body>
</html>
コントローラー:
package com.example.hellospring;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LayoutDemoController {
@GetMapping("/thymeleaf/layout")
public String layoutDemo(Model model) {
model.addAttribute("message", "フラグメントでレイアウトを共通化しています");
return "layout-demo";
}
}
Servlet/JSPとの対比
| JSP | Thymeleaf | 説明 |
|---|---|---|
<jsp:include page="header.jsp" /> |
th:replace="~{fragments/common :: header}" |
共通パーツの組み込み |
<%@ include file="footer.jsp" %> |
th:replace="~{fragments/common :: footer}" |
静的インクルード相当 |
別ファイル(header.jsp) |
同一ファイル内のth:fragmentで定義 |
部品の定義方法 |
JSPではheader.jspのようにファイル単位で分割していましたが、Thymeleafでは1つのファイルに複数のフラグメントを定義できます。もちろん、フラグメントを別ファイルに分けることも可能です。
5. 実践例: ユーザー一覧画面
ここまで学んだ構文を組み合わせて、実践的なユーザー一覧画面を構築します。
Userクラス
src/main/java/com/example/hellospring/User.java:
package com.example.hellospring;
public class User {
private int id;
private String name;
private String email;
private boolean active;
public User(int id, String name, String email, boolean active) {
this.id = id;
this.name = name;
this.email = email;
this.active = active;
}
public int getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public boolean isActive() {
return active;
}
}
Thymeleafは${user.active}のようにプロパティアクセスする際、JavaBeans規約に従いisActive()メソッドを呼び出します。boolean型のgetterはisプレフィックスが標準です。
UserController
src/main/java/com/example/hellospring/UserController.java:
package com.example.hellospring;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class UserController {
@GetMapping("/thymeleaf/users")
public String userList(Model model) {
List<User> users = List.of(
new User(1, "田中太郎", "tanaka@example.com", true),
new User(2, "佐藤花子", "sato@example.com", true),
new User(3, "鈴木一郎", "suzuki@example.com", false),
new User(4, "高橋美咲", "takahashi@example.com", true),
new User(5, "伊藤健太", "ito@example.com", false)
);
model.addAttribute("users", users);
return "users";
}
}
第2回で作成したTodoControllerのエンドポイントは/api/todosでした。今回は/thymeleaf/usersとすることで、パスの重複を避けています。
ユーザー一覧テンプレート
src/main/resources/templates/users.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ユーザー一覧</title>
<style>
body { font-family: sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #333; color: white; }
tr:nth-child(even) { background-color: #f9f9f9; }
.active { color: green; font-weight: bold; }
.inactive { color: red; font-weight: bold; }
.empty-message { color: gray; font-style: italic; padding: 20px; }
</style>
</head>
<body>
<!-- ヘッダー -->
<div th:replace="~{fragments/common :: header}">ヘッダー</div>
<main style="padding: 20px;">
<h1>ユーザー一覧</h1>
<!-- ユーザーが存在する場合 -->
<table th:if="${!users.isEmpty()}">
<thead>
<tr>
<th>ID</th>
<th>名前</th>
<th>メールアドレス</th>
<th>ステータス</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${users}">
<td th:text="${user.id}">1</td>
<td th:text="${user.name}">名前</td>
<td th:text="${user.email}">email@example.com</td>
<td th:classappend="${user.active} ? 'active' : 'inactive'"
th:text="${user.active} ? 'アクティブ' : '非アクティブ'">
ステータス
</td>
</tr>
</tbody>
</table>
<!-- ユーザーが存在しない場合 -->
<p th:if="${users.isEmpty()}" class="empty-message">
ユーザーが登録されていません。
</p>
</main>
<!-- フッター -->
<div th:replace="~{fragments/common :: footer}">フッター</div>
</body>
</html>
使用している構文の整理
この実践例で使用したThymeleaf構文を整理します。
| 構文 | 該当箇所 | 用途 |
|---|---|---|
th:replace |
ヘッダー・フッター | フラグメントの組み込み |
th:if |
${!users.isEmpty()} / ${users.isEmpty()}
|
ユーザー有無の条件分岐 |
th:each |
user : ${users} |
ユーザーリストのループ |
th:text |
${user.id}、${user.name} 等 |
テキスト出力 |
th:classappend |
${user.active} ? 'active' : 'inactive' |
CSSクラスの動的切り替え |
| 三項演算子 | ${user.active} ? 'アクティブ' : '非アクティブ' |
条件に応じた表示切り替え |
アプリケーションを起動して http://localhost:8080/thymeleaf/users にアクセスすると、ユーザー一覧がテーブル形式で表示されます。アクティブなユーザーは緑色、非アクティブなユーザーは赤色で表示されます。
練習問題
問題1: 商品一覧テーブル ⭐
以下の仕様で商品一覧画面を作成してください。
仕様:
- エンドポイント:
GET /thymeleaf/products -
Productクラスを作成(id、name、priceの3フィールド) - コントローラーで3つ以上の商品データを
Modelに格納する - テンプレート
products.htmlでテーブル表示する -
th:eachで商品をループ表示する
ヒント: UserクラスとUserControllerの構造を参考にしてください。
模範解答
src/main/java/com/example/hellospring/Product.java:
package com.example.hellospring;
public class Product {
private int id;
private String name;
private int price;
public Product(int id, String name, int price) {
this.id = id;
this.name = name;
this.price = price;
}
public int getId() {
return id;
}
public String getName() {
return name;
}
public int getPrice() {
return price;
}
}
src/main/java/com/example/hellospring/ProductController.java:
package com.example.hellospring;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ProductController {
@GetMapping("/thymeleaf/products")
public String productList(Model model) {
List<Product> products = List.of(
new Product(1, "ノートPC", 120000),
new Product(2, "ワイヤレスマウス", 3500),
new Product(3, "USBハブ", 2800),
new Product(4, "モニターアーム", 15000)
);
model.addAttribute("products", products);
return "products";
}
}
src/main/resources/templates/products.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>商品一覧</title>
<style>
body { font-family: sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #333; color: white; }
tr:nth-child(even) { background-color: #f9f9f9; }
</style>
</head>
<body>
<h1>商品一覧</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>商品名</th>
<th>価格(円)</th>
</tr>
</thead>
<tbody>
<tr th:each="product : ${products}">
<td th:text="${product.id}">1</td>
<td th:text="${product.name}">商品名</td>
<td th:text="|${product.price}円|">0円</td>
</tr>
</tbody>
</table>
</body>
</html>
http://localhost:8080/thymeleaf/products にアクセスすると、4つの商品がテーブル形式で表示されます。
問題2: 在庫あり/なし表示 + CSSクラス切り替え ⭐⭐
問題1のProductクラスを拡張し、在庫数(stock)フィールドを追加してください。
仕様:
- エンドポイント:
GET /thymeleaf/products-stock -
Productクラスにstock(int型)フィールドを追加する(問題1と別のクラスでも可) - 在庫が0のとき「在庫なし」を赤文字で表示する(
th:ifまたは三項演算子 +th:classappend) - 在庫が1以上のとき「在庫あり(N個)」を緑文字で表示する
- 商品がない場合は「商品が登録されていません。」と表示する(
th:if)
模範解答
src/main/java/com/example/hellospring/StockProduct.java:
package com.example.hellospring;
public class StockProduct {
private int id;
private String name;
private int price;
private int stock;
public StockProduct(int id, String name, int price, int stock) {
this.id = id;
this.name = name;
this.price = price;
this.stock = stock;
}
public int getId() {
return id;
}
public String getName() {
return name;
}
public int getPrice() {
return price;
}
public int getStock() {
return stock;
}
public boolean isInStock() {
return stock > 0;
}
}
src/main/java/com/example/hellospring/StockProductController.java:
package com.example.hellospring;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class StockProductController {
@GetMapping("/thymeleaf/products-stock")
public String productStockList(Model model) {
List<StockProduct> products = List.of(
new StockProduct(1, "ノートPC", 120000, 5),
new StockProduct(2, "ワイヤレスマウス", 3500, 0),
new StockProduct(3, "USBハブ", 2800, 12),
new StockProduct(4, "モニターアーム", 15000, 0)
);
model.addAttribute("products", products);
return "products-stock";
}
}
src/main/resources/templates/products-stock.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>商品一覧(在庫管理)</title>
<style>
body { font-family: sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #333; color: white; }
tr:nth-child(even) { background-color: #f9f9f9; }
.in-stock { color: green; font-weight: bold; }
.out-of-stock { color: red; font-weight: bold; }
.empty-message { color: gray; font-style: italic; padding: 20px; }
</style>
</head>
<body>
<h1>商品一覧(在庫管理)</h1>
<table th:if="${!products.isEmpty()}">
<thead>
<tr>
<th>ID</th>
<th>商品名</th>
<th>価格(円)</th>
<th>在庫状況</th>
</tr>
</thead>
<tbody>
<tr th:each="product : ${products}">
<td th:text="${product.id}">1</td>
<td th:text="${product.name}">商品名</td>
<td th:text="|${product.price}円|">0円</td>
<td th:classappend="${product.inStock} ? 'in-stock' : 'out-of-stock'"
th:text="${product.inStock} ? |在庫あり(${product.stock}個)| : '在庫なし'">
在庫状況
</td>
</tr>
</tbody>
</table>
<p th:if="${products.isEmpty()}" class="empty-message">
商品が登録されていません。
</p>
</body>
</html>
http://localhost:8080/thymeleaf/products-stock にアクセスすると、在庫がある商品は緑色で「在庫あり(N個)」、在庫がない商品は赤色で「在庫なし」と表示されます。
問題3: ヘッダー・フッターのフラグメント化 + 複数ページ構成 ⭐⭐⭐
問題1・問題2の画面に共通のヘッダー・フッターを適用し、ナビゲーションで画面を行き来できるようにしてください。
仕様:
-
src/main/resources/templates/fragments/shop-common.htmlにフラグメントを定義する-
headerフラグメント: ナビゲーションバー(「ホーム」「商品一覧」「在庫管理」へのリンク) -
footerフラグメント: コピーライト表示
-
- 問題1の
products.htmlと問題2のproducts-stock.htmlの両方にフラグメントを適用する - ナビゲーションのリンクには
th:href="@{/path}"を使う - 現在表示中のページのリンクを太字にする(ヒント: フラグメントに引数を渡す)
模範解答
src/main/resources/templates/fragments/shop-common.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<header th:fragment="header(currentPage)">
<nav style="background-color: #2c3e50; padding: 10px 20px;">
<a th:href="@{/}"
th:style="${currentPage == 'home'} ? 'color: white; margin-right: 20px; font-weight: bold;' : 'color: #bdc3c7; margin-right: 20px;'">
ホーム
</a>
<a th:href="@{/thymeleaf/products}"
th:style="${currentPage == 'products'} ? 'color: white; margin-right: 20px; font-weight: bold;' : 'color: #bdc3c7; margin-right: 20px;'">
商品一覧
</a>
<a th:href="@{/thymeleaf/products-stock}"
th:style="${currentPage == 'stock'} ? 'color: white; font-weight: bold;' : 'color: #bdc3c7;'">
在庫管理
</a>
</nav>
</header>
<footer th:fragment="footer">
<hr>
<p style="text-align: center; color: gray;">© 2026 Spring Boot Shop</p>
</footer>
</body>
</html>
問題1のproducts.htmlを修正して共通ヘッダー・フッターを適用します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>商品一覧</title>
<style>
body { font-family: sans-serif; margin: 0; }
main { padding: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #333; color: white; }
tr:nth-child(even) { background-color: #f9f9f9; }
</style>
</head>
<body>
<div th:replace="~{fragments/shop-common :: header('products')}">ヘッダー</div>
<main>
<h1>商品一覧</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>商品名</th>
<th>価格(円)</th>
</tr>
</thead>
<tbody>
<tr th:each="product : ${products}">
<td th:text="${product.id}">1</td>
<td th:text="${product.name}">商品名</td>
<td th:text="|${product.price}円|">0円</td>
</tr>
</tbody>
</table>
</main>
<div th:replace="~{fragments/shop-common :: footer}">フッター</div>
</body>
</html>
問題2のproducts-stock.htmlにも同様に適用します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>商品一覧(在庫管理)</title>
<style>
body { font-family: sans-serif; margin: 0; }
main { padding: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #333; color: white; }
tr:nth-child(even) { background-color: #f9f9f9; }
.in-stock { color: green; font-weight: bold; }
.out-of-stock { color: red; font-weight: bold; }
.empty-message { color: gray; font-style: italic; padding: 20px; }
</style>
</head>
<body>
<div th:replace="~{fragments/shop-common :: header('stock')}">ヘッダー</div>
<main>
<h1>商品一覧(在庫管理)</h1>
<table th:if="${!products.isEmpty()}">
<thead>
<tr>
<th>ID</th>
<th>商品名</th>
<th>価格(円)</th>
<th>在庫状況</th>
</tr>
</thead>
<tbody>
<tr th:each="product : ${products}">
<td th:text="${product.id}">1</td>
<td th:text="${product.name}">商品名</td>
<td th:text="|${product.price}円|">0円</td>
<td th:classappend="${product.inStock} ? 'in-stock' : 'out-of-stock'"
th:text="${product.inStock} ? |在庫あり(${product.stock}個)| : '在庫なし'">
在庫状況
</td>
</tr>
</tbody>
</table>
<p th:if="${products.isEmpty()}" class="empty-message">
商品が登録されていません。
</p>
</main>
<div th:replace="~{fragments/shop-common :: footer}">フッター</div>
</body>
</html>
ポイント:
- フラグメントに引数を渡すことで、現在のページを識別できる(
header('products')、header('stock')) - 引数を使って、現在のページのリンクスタイルを動的に変更している
-
@{/path}を使うことで、コンテキストパスが変わっても対応できる
まとめ
JSPとThymeleafの対比表(総まとめ)
| 機能 | JSP | Thymeleaf |
|---|---|---|
| 値の出力 |
<%= value %> / ${value}
|
th:text="${value}" |
| 非エスケープ出力 |
<%= value %>(デフォルトで非エスケープ) |
th:utext="${value}" |
| ループ | <c:forEach items="${list}" var="item"> |
th:each="item : ${list}" |
| 条件分岐 | <c:if test="${cond}"> |
th:if="${cond}" |
| 複数条件 |
<c:choose> / <c:when>
|
th:switch / th:case
|
| URL生成 | ${pageContext.request.contextPath}/path |
@{/path} |
| 部品化 | <jsp:include page="header.jsp"> |
th:fragment + th:replace
|
| XSS対策 |
<c:out value="${val}"> が必要 |
th:textがデフォルトでエスケープ |
| ブラウザ直接表示 | 不可 | 可(自然テンプレート) |
| ファイル拡張子 | .jsp |
.html |
今回学んだこと
| 学んだこと | キーワード |
|---|---|
| Thymeleafの概要 | 自然テンプレート、JSPとの違い |
| データの受け渡し |
Model.addAttribute()、return "ビュー名"
|
| テキスト出力 |
th:text(エスケープ)、th:utext(非エスケープ) |
| ループ |
th:each、ステータス変数 |
| 条件分岐 |
th:if、th:unless、th:switch / th:case
|
| URL式 |
@{/path}、パスパラメータ、クエリパラメータ |
| CSSクラス制御 |
th:class、th:classappend
|
| リテラル置換 |
|...| 構文 |
| レイアウト共通化 |
th:fragment、th:replace、th:insert
|
次回予告
次回(第4回)では、フォーム処理とバリデーションを扱います。th:actionやth:objectを使ったフォーム送信、@ModelAttributeによるフォームデータの受け取り、Bean Validationによる入力検証を学びます。
Spring Boot入門シリーズ 全10回(予定):
- Servlet/JSPからの移行と環境構築
- コントローラとルーティング
- 👉 Thymeleafによるビュー(本記事)
- フォーム処理とバリデーション
- Spring Data JPA(データベース連携)
- RESTful API設計
- Spring Security(認証・認可)
- 例外処理とエラーハンドリング
- テストの書き方(JUnit + MockMvc)
- 総合演習:掲示板アプリをSpring Bootで再構築
参考
- Thymeleaf 公式チュートリアル(Using Thymeleaf 3.1)
- Spring Boot 公式ドキュメント
- Spring MVC + Thymeleaf 連携チュートリアル
- Thymeleaf 3.1 移行ガイド
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!