Spring BootでREST APIを作るのには慣れてきたけど、HTMLを返すWebアプリの作り方はよく分からない、という方は多いと思います。今回はそういった方に向けて、Thymeleafを使ったサーバーサイドレンダリングの基本から、フォーム処理・バリデーション・Spring Security連携まで順を追って説明します。

ThymeleafとSpring Bootの関係

ThymeleafはJava向けのテンプレートエンジンで、HTMLファイルにそのまま属性を書き足していくスタイルが特徴です。JSPと違ってブラウザで直接開いてもHTMLとして表示されるので、デザイナーとの協業がしやすいという利点があります。

Spring Bootでは spring-boot-starter-thymeleaf を追加するだけで ThymeleafViewResolver が自動設定されます。@Controller のハンドラがテンプレート名(文字列)を返すと、src/main/resources/templates/ 以下の対応する .html ファイルが自動的に解決されます。

@RestController との違いで迷うところですが、シンプルです。@RestController はレスポンスボディに直接値を書き込みます(JSON返却など)。@Controller はテンプレート名を返してビュー解決を行います。

依存関係の追加

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}

ディレクトリ構成はこんな感じになります。

src/main/resources/
├── templates/        # .html テンプレートを置く場所
│   ├── index.html
│   └── fragments/
│       └── layout.html
└── static/           # CSS・JS・画像など静的ファイル
    ├── css/
    └── js/

最初のHTMLレスポンスを返す

@Controller でモデルにデータを詰めてテンプレート名を返すだけです。

@Controller
public class HomeController {

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("message", "こんにちは、Thymeleaf!");
        model.addAttribute("items", List.of("りんご", "みかん", "ぶどう"));
        return "index"; // templates/index.html を解決する
    }
}

テンプレート側はこのように書きます。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>トップページ</title>
  <link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
  <h1 th:text="${message}">デフォルトメッセージ</h1>

  <ul>
    <li th:each="item : ${items}" th:text="${item}">サンプル</li>
  </ul>

  <p th:if="${items.size() > 2}">3件以上あります</p>
</body>
</html>

th:text は値をHTMLエスケープして出力します。HTMLタグを含む文字列をそのまま出力したい場合は th:utext を使いますが、XSSに注意が必要です。th:href@{...} はURLを生成する式で、コンテキストパスを自動的に考慮してくれます。

フォームオブジェクトとデータバインディング

フォーム処理には「コマンドオブジェクト」と呼ばれるPOJOを使います。

public class UserForm {
    @NotBlank(message = "名前は必須です")
    @Size(max = 50, message = "50文字以内で入力してください")
    private String name;

    @Email(message = "メールアドレスの形式が正しくありません")
    private String email;

    // getter / setter
}

GETハンドラで空のフォームオブジェクトをモデルに渡し、POSTハンドラで受け取ります。

@Controller
@RequestMapping("/users")
public class UserController {

    @GetMapping("/new")
    public String newUser(Model model) {
        model.addAttribute("userForm", new UserForm());
        return "users/new";
    }

    @PostMapping
    public String create(@Valid @ModelAttribute UserForm userForm,
                         BindingResult result) {
        if (result.hasErrors()) {
            return "users/new"; // バリデーションエラー時はフォームに戻る
        }
        // 保存処理...
        return "redirect:/users";
    }
}

バリデーションアノテーションの詳細は Spring Bootで@Validを使ったバリデーション を参照してください。

テンプレートでは th:objectth:field を使います。

<form th:action="@{/users}" th:object="${userForm}" method="post">
  <div>
    <label>名前</label>
    <input type="text" th:field="*{name}">
    <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" style="color:red"></span>
  </div>
  <div>
    <label>メール</label>
    <input type="email" th:field="*{email}">
    <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" style="color:red"></span>
  </div>
  <button type="submit">登録</button>
</form>

th:field="*{name}" と書くと id="name" name="name" value="..." が自動生成されます。th:errors はそのフィールドに紐づくエラーメッセージを全て表示してくれます。

GETハンドラで model.addAttribute("userForm", new UserForm()) を忘れると、テンプレートレンダリング時に th:object が null になってエラーになります。これが初学者がよく引っかかるポイントです。

テンプレートの断片化

ヘッダーやフッターを各ページで繰り返し書くのは辛いので、th:fragment で共通パーツを切り出しましょう。

<!-- fragments/layout.html -->
<header th:fragment="header">
  <nav>
    <a th:href="@{/}">ホーム</a>
    <a th:href="@{/users/new}">新規登録</a>
  </nav>
</header>

各ページテンプレートからはこのように埋め込みます。

<body>
  <div th:replace="~{fragments/layout :: header}"></div>
  <!-- ページ固有のコンテンツ -->
</body>

th:replace は対象要素ごと置き換え、th:insert は対象要素の中に挿入します。ヘッダーやフッターのような独立したパーツは th:replace の方が自然です。

Spring Securityとの連携

Spring Securityを使っている場合、thymeleaf-extras-springsecurity6 を追加すると sec:authorize などのカスタム属性が使えるようになります。

implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<body>
  <div sec:authorize="isAuthenticated()">
    <p>ようこそ、<span sec:authentication="name"></span> さん</p>
    <a th:href="@{/logout}">ログアウト</a>
  </div>
  <div sec:authorize="!isAuthenticated()">
    <a th:href="@{/login}">ログイン</a>
  </div>
</body>
</html>

sec:authorize の中にはSpring SecurityのSpEL式がそのまま使えます。Spring Security自体の設定については Spring Bootのセキュリティ基本認証チュートリアル を参照してください。

なお、Spring SecurityはデフォルトでCSRFトークンをフォームに自動挿入してくれます。Thymeleafと組み合わせると <input type="hidden" name="_csrf" ...> が自動で追加されるので、特に意識しなくて大丈夫です。

まとめ

ThymeleafはSpring Bootとの相性が非常に良く、スターターを追加するだけで動き始めるのが嬉しいところです。

  • @Controller でテンプレート名を返してHTMLレスポンスを作る
  • th:textth:eachth:if で動的なHTMLを組み立てる
  • th:objectth:field でフォームとオブジェクトをバインドする
  • th:errors でバリデーションエラーをテンプレートに表示する
  • th:fragmentth:replace でレイアウトを共通化する
  • thymeleaf-extras-springsecurity6 で認証状態に応じた表示切替ができる

REST APIとは違うアプローチですが、管理画面や社内ツールなど「バックエンドでHTMLを作りたい」場面では今でも十分実用的な選択肢です。ぜひ試してみてください。