0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Spring Boot入門④】フォーム処理とバリデーション ― ThymeleafフォームバインディングとBean Validationで入力検証を自動化する

0
Last updated at Posted at 2026-04-18

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

はじめに

前回(第3回)では、Thymeleafの基本構文(th:textth:eachth:ifなど)とレイアウトの共通化を学び、ユーザー一覧画面を作成しました。

第4回では、フォーム処理と**バリデーション(入力値検証)**を扱います。

今回学ぶこと

  • フォームバッキングオブジェクトを使ったフォームデータの送受信
  • Thymeleafのフォーム関連属性(th:objectth:fieldth:errors
  • Bean Validation(@NotBlank@Email@Sizeなど)による宣言的な入力検証
  • エラーメッセージのカスタマイズ
  • PRG(Post-Redirect-Get)パターン
  • 実践例:ユーザー登録フォーム

本記事のコードはすべて第1回で作成したhello-springプロジェクト(com.example.hellospringパッケージ)上で動作します。環境構築がまだの方は第1回を先にご覧ください。


1. フォーム処理の基本

Servlet/JSPでのフォーム処理(従来の方法)

Servlet/JSPでは、フォームデータを以下の手順で処理していました。

// Servlet版:手動でパラメータを取得
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {

    String name = request.getParameter("name");
    String email = request.getParameter("email");
    String ageStr = request.getParameter("age");
    int age = Integer.parseInt(ageStr); // 型変換も手動

    // バリデーションも手動(if文の羅列)
    List<String> errors = new ArrayList<>();
    if (name == null || name.isBlank()) {
        errors.add("名前は必須です");
    }
    if (email == null || !email.contains("@")) {
        errors.add("メールアドレスの形式が正しくありません");
    }
    // ... 項目が増えるたびにif文が増える
}

この方法には以下の課題があります。

  • パラメータを1つずつ手動で取得する
  • 型変換も自分で行う(Integer.parseInt() など)
  • バリデーションがif文の羅列になり、項目が増えると肥大化する
  • フォーム画面にエラーを返すにはrequest.setAttribute()で値を詰め直す必要がある

Spring Bootでのフォーム処理

Spring Bootでは、フォームバッキングオブジェクトBean Validationを使って、これらの課題を解消します。

[フォーム画面] → POST → [コントローラー] → 自動バインド → [フォームオブジェクト]
                                          → 自動バリデーション → [BindingResult]

処理の流れは以下の通りです。

  1. GETリクエスト:空のフォームオブジェクトをModelに追加し、フォーム画面を表示
  2. POSTリクエスト:送信されたデータがフォームオブジェクトに自動バインドされる
  3. バリデーション:アノテーションに基づいて自動で入力検証が実行される
  4. 結果の判定BindingResultにエラーがあればフォーム再表示、なければ完了画面へリダイレクト

2. 依存関係の追加

Spring Boot 3.x では、バリデーション機能はspring-boot-starter-validationを明示的に追加する必要があります。pom.xml<dependencies>に以下を追加してください。

<!-- pom.xml に追加 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Spring Boot 2.2以前はspring-boot-starter-webにバリデーション機能が含まれていましたが、2.3以降は分離されました。追加しないと@NotBlank等のアノテーションが使えません。


3. フォームバッキングオブジェクト

フォームバッキングオブジェクトとは

フォームの入力項目をフィールドとして持つJavaクラスです。Thymeleafのフォームとこのオブジェクトを**バインド(紐付け)**することで、フォームの入力値が自動的にオブジェクトのフィールドに設定されます。

Servlet/JSPでの request.getParameter("name") を1つずつ呼ぶ必要がなくなります。

今回からクラスの役割ごとにサブパッケージ(controllerform)を分けています。第1〜3回で作成したクラスはそのままで構いません。Spring Bootはメインクラスのパッケージ(com.example.hellospring)以下のサブパッケージを自動でスキャンするため、サブパッケージに配置したクラスも問題なく認識されます。

基本的なフォームクラスの例

package com.example.hellospring.form;

public class UserForm {

    private String name;
    private String email;
    private Integer age;
    private String introduction;

    // --- getter / setter ---

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    public String getIntroduction() {
        return introduction;
    }

    public void setIntroduction(String introduction) {
        this.introduction = introduction;
    }
}

フォームバッキングオブジェクトには、各フィールドに対応するgetter / setterが必要です。Spring MVCがsetter経由でフォームの値をバインドするためです。


4. Thymeleafのフォーム関連属性

th:action ― フォーム送信先の指定

<form th:action="@{/users/new}" method="post">

@{...} はThymeleafのURL式です。コンテキストパスを自動補完してくれるため、ハードコードするより安全です。

th:object ― フォームバッキングオブジェクトの指定

<form th:action="@{/users/new}" th:object="${userForm}" method="post">

th:objectで指定したオブジェクトのフィールドに対して、フォーム内の各入力要素をバインドします。コントローラーでmodel.addAttribute("userForm", new UserForm())として渡したオブジェクトがここで使われます。

th:field ― フィールドとのバインド

<input type="text" th:field="*{name}" />

*{name}th:objectで指定したオブジェクトのフィールドを参照する選択変数式です。th:fieldは以下の3つのHTML属性を自動生成します。

生成される属性
id name
name name
value フィールドの現在値

つまり、th:field="*{name}"は以下と同等のHTMLを生成します。

<input type="text" id="name" name="name" value="" />

バリデーションエラーでフォームを再表示する際、入力済みの値が自動的に復元されるのが大きなメリットです。Servlet/JSPでは value="<%= request.getParameter("name") %>" のように手動で値を埋め込んでいました。

th:errors ― バリデーションエラーの表示

<span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="error"></span>

th:errors="*{name}"は、nameフィールドに関連するバリデーションエラーメッセージを表示します。th:if="${#fields.hasErrors('name')}"と組み合わせることで、エラーが存在する場合のみ表示します。

th:errorclass ― エラー時のCSSクラス追加

<input type="text" th:field="*{name}" th:errorclass="is-invalid" />

nameフィールドにバリデーションエラーがある場合、class="is-invalid"が自動的に追加されます。入力欄の枠を赤くするなどのスタイリングに使います。


5. Bean Validation(入力値検証)

Bean Validationとは

Bean Validation(Jakarta Bean Validation)は、Javaオブジェクトのフィールドにアノテーションを付けるだけでバリデーションルールを宣言できる仕組みです。Spring Bootではspring-boot-starter-validationを追加することで、その実装であるHibernate Validatorが使えるようになります。

Servlet/JSPのif文の羅列から、宣言的なバリデーションに進化します。

主要なバリデーションアノテーション

アノテーション パッケージ 検証内容
@NotNull jakarta.validation.constraints nullでないこと
@NotEmpty jakarta.validation.constraints nullでなく、空文字・空コレクションでないこと
@NotBlank jakarta.validation.constraints nullでなく、空文字でなく、空白のみでないこと(文字列専用)
@Size(min, max) jakarta.validation.constraints 文字列・コレクションの長さが指定範囲内であること
@Min(value) jakarta.validation.constraints 数値が指定値以上であること
@Max(value) jakarta.validation.constraints 数値が指定値以下であること
@Email jakarta.validation.constraints メールアドレス形式であること
@Pattern(regexp) jakarta.validation.constraints 正規表現に一致すること
@Past jakarta.validation.constraints 過去の日付であること
@Future jakarta.validation.constraints 未来の日付であること
@Positive jakarta.validation.constraints 正の数であること
@PositiveOrZero jakarta.validation.constraints 0以上であること

@NotNull / @NotEmpty / @NotBlank の違い

この3つは混同しやすいため、違いを明確にしておきます。

@NotNull @NotEmpty @NotBlank
null NG NG NG
"" (空文字) OK NG NG
" " (空白のみ) OK OK NG
"abc" OK OK OK

文字列の入力必須チェックには**@NotBlank**を使うのが最も適切です。

コントローラー側:@Valid と BindingResult

バリデーションを実行するには、コントローラーのメソッド引数に**@Validを付けます。バリデーション結果はBindingResult**で受け取ります。

@PostMapping("/users/new")
public String createUser(@Valid @ModelAttribute("userForm") UserForm form,
                         BindingResult bindingResult,
                         Model model) {
    if (bindingResult.hasErrors()) {
        // バリデーションエラーあり → フォーム再表示
        return "user_form";
    }
    // バリデーションOK → 完了画面へリダイレクト
    return "redirect:/users/complete";
}

BindingResult@Validを付けた引数の直後に置く必要があります。 順序を間違えると、バリデーションエラー時にBindingResultにエラーが格納されず、400エラー(Bad Request)が発生します。

// OK: @Valid の直後に BindingResult
public String createUser(@Valid UserForm form, BindingResult result) { ... }

// NG: 間に別の引数が挟まっている
public String createUser(@Valid UserForm form, Model model, BindingResult result) { ... }

Servlet/JSPのバリデーションとの対比

// Servlet版:if文の羅列
List<String> errors = new ArrayList<>();
if (name == null || name.isBlank()) {
    errors.add("名前は必須です");
}
if (name != null && name.length() > 50) {
    errors.add("名前は50文字以内で入力してください");
}
if (email == null || !email.matches("^[\\w.+-]+@[\\w-]+\\.[\\w.]+$")) {
    errors.add("メールアドレスの形式が正しくありません");
}
// ... 項目分だけ続く
// Spring Boot版:アノテーションで宣言
@NotBlank(message = "名前は必須です")
@Size(max = 50, message = "名前は50文字以内で入力してください")
private String name;

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

バリデーションルールがフォームクラスに集約されるため、コントローラーはビジネスロジックに集中できます。


6. エラーメッセージのカスタマイズ

アノテーションの message 属性

各バリデーションアノテーションにはmessage属性があり、エラーメッセージを個別に指定できます。

@NotBlank(message = "名前を入力してください")
private String name;

@Min(value = 0, message = "年齢は0以上で入力してください")
@Max(value = 150, message = "年齢は150以下で入力してください")
private Integer age;

messageを省略すると、Hibernate Validatorのデフォルトメッセージ(英語)が使われます。日本語アプリケーションでは必ずmessageを指定しましょう。

messages.properties によるメッセージ外部化

メッセージをプロパティファイルに外部化することもできます。src/main/resources/messages.properties を作成し、以下のように記述します。

# messages.properties
NotBlank.userForm.name=名前を入力してください
Email.userForm.email=メールアドレスの形式が正しくありません

命名規則は {アノテーション名}.{オブジェクト名}.{フィールド名} です。

本記事では簡潔さのためにmessage属性を直接指定する方法で統一しますが、大規模アプリケーションや多言語対応が必要な場合はmessages.propertiesの利用を検討してください。


7. 実践例:ユーザー登録フォーム

ここまでの知識を組み合わせて、バリデーション付きのユーザー登録フォームを作成します。

全体の構成

src/main/java/com/example/hellospring/
├── form/
│   └── UserForm.java              ← フォームクラス(バリデーション付き)
└── controller/
    └── UserFormController.java    ← コントローラー

src/main/resources/templates/
├── user_form.html                 ← 入力フォーム画面
└── user_complete.html             ← 登録完了画面

UserForm.java(バリデーション付きフォームクラス)

package com.example.hellospring.form;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public class UserForm {

    @NotBlank(message = "名前を入力してください")
    @Size(max = 50, message = "名前は50文字以内で入力してください")
    private String name;

    @NotBlank(message = "メールアドレスを入力してください")
    @Email(message = "メールアドレスの形式が正しくありません")
    private String email;

    @NotNull(message = "年齢を入力してください")
    @Min(value = 0, message = "年齢は0以上で入力してください")
    @Max(value = 150, message = "年齢は150以下で入力してください")
    private Integer age;

    @Size(max = 200, message = "自己紹介は200文字以内で入力してください")
    private String introduction;

    // --- getter / setter ---

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    public String getIntroduction() {
        return introduction;
    }

    public void setIntroduction(String introduction) {
        this.introduction = introduction;
    }
}

フィールドごとのバリデーションルール:

フィールド バリデーション
name String 必須、50文字以内
email String 必須、メールアドレス形式
age Integer 必須、0以上150以下
introduction String 任意、200文字以内

ageの型をint(プリミティブ型)ではなくInteger(ラッパー型)にしている理由は、@NotNullでnullチェックを行うためです。intはnullを取れないため、未入力の場合に0として扱われてしまい、「未入力」と「0を入力」の区別ができません。

UserFormController.java(コントローラー)

package com.example.hellospring.controller;

import com.example.hellospring.form.UserForm;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

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

    /**
     * フォーム表示(GET)
     */
    @GetMapping("/new")
    public String showForm(Model model) {
        model.addAttribute("userForm", new UserForm());
        return "user_form";
    }

    /**
     * フォーム送信処理(POST)
     */
    @PostMapping("/new")
    public String createUser(@Valid @ModelAttribute("userForm") UserForm form,
                             BindingResult bindingResult,
                             RedirectAttributes redirectAttributes) {

        // バリデーションエラーがあればフォーム再表示
        if (bindingResult.hasErrors()) {
            return "user_form";
        }

        // 本来はここでデータベースへの保存処理を行う(第5回で扱う)

        // 完了メッセージをリダイレクト先に渡す
        redirectAttributes.addFlashAttribute("completedName", form.getName());

        // PRGパターン:リダイレクトで完了画面へ
        return "redirect:/users/complete";
    }

    /**
     * 登録完了画面(GET)
     */
    @GetMapping("/complete")
    public String complete() {
        return "user_complete";
    }
}

処理の流れ:

  1. GET /users/newshowForm() → 空のUserFormModelに追加 → user_form.htmlを表示
  2. POST /users/newcreateUser() → フォームデータがUserFormに自動バインド → バリデーション実行
    • エラーあり → user_form.htmlを再表示(入力値とエラーメッセージが保持される)
    • エラーなし → redirect:/users/complete にリダイレクト
  3. GET /users/completecomplete()user_complete.htmlを表示

PRG(Post-Redirect-Get)パターン

return "redirect:/users/complete" に注目してください。POST処理の成功後にリダイレクトしています。これをPRGパターンと呼びます。

[ブラウザ] → POST /users/new → [サーバー] → 302 Redirect → [ブラウザ] → GET /users/complete

もしリダイレクトせずに直接ビュー名を返すと(return "user_complete")、ブラウザの「戻る」ボタンや「F5」キーでフォームが二重送信されてしまいます。PRGパターンはこれを防ぐための定石です。

RedirectAttributes.addFlashAttribute() を使うと、リダイレクト先に1回限りの値を渡せます。フラッシュ属性はリダイレクト後に自動で破棄されるため、ブラウザを再読み込みしても値は残りません。

user_form.html(入力フォーム画面)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>ユーザー登録</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        h1 { color: #333; }
        .form-group { margin-bottom: 16px; }
        label { display: block; margin-bottom: 4px; font-weight: bold; }
        input[type="text"], input[type="email"], input[type="number"], textarea {
            width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
            box-sizing: border-box; font-size: 14px;
        }
        textarea { height: 100px; resize: vertical; }
        .is-invalid { border-color: #dc3545; }
        .error { color: #dc3545; font-size: 13px; margin-top: 4px; display: block; }
        button { background: #007bff; color: #fff; border: none; padding: 10px 24px;
                 border-radius: 4px; font-size: 16px; cursor: pointer; }
        button:hover { background: #0056b3; }
    </style>
</head>
<body>
    <h1>ユーザー登録</h1>

    <form th:action="@{/users/new}" th:object="${userForm}" method="post">

        <!-- 名前 -->
        <div class="form-group">
            <label for="name">名前 <span style="color: red;">*</span></label>
            <input type="text" th:field="*{name}" th:errorclass="is-invalid" />
            <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="error"></span>
        </div>

        <!-- メールアドレス -->
        <div class="form-group">
            <label for="email">メールアドレス <span style="color: red;">*</span></label>
            <input type="email" th:field="*{email}" th:errorclass="is-invalid" />
            <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="error"></span>
        </div>

        <!-- 年齢 -->
        <div class="form-group">
            <label for="age">年齢 <span style="color: red;">*</span></label>
            <input type="number" th:field="*{age}" th:errorclass="is-invalid" />
            <span th:if="${#fields.hasErrors('age')}" th:errors="*{age}" class="error"></span>
        </div>

        <!-- 自己紹介 -->
        <div class="form-group">
            <label for="introduction">自己紹介(任意・200文字以内)</label>
            <textarea th:field="*{introduction}" th:errorclass="is-invalid"></textarea>
            <span th:if="${#fields.hasErrors('introduction')}" th:errors="*{introduction}" class="error"></span>
        </div>

        <button type="submit">登録</button>
    </form>
</body>
</html>

Thymeleafフォーム属性の使い方をまとめると:

<!-- ① th:object でフォームオブジェクトを指定 -->
<form th:object="${userForm}" method="post">

    <!-- ② th:field でフィールドをバインド(*{} は th:object の選択変数式) -->
    <input type="text" th:field="*{name}" />

    <!-- ③ th:errorclass でエラー時のCSSクラスを追加 -->
    <input type="text" th:field="*{name}" th:errorclass="is-invalid" />

    <!-- ④ th:errors でエラーメッセージを表示 -->
    <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="error"></span>
</form>

user_complete.html(登録完了画面)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>登録完了</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        h1 { color: #333; }
        .message { background: #d4edda; padding: 16px; border-radius: 4px; margin: 20px 0; }
        a { color: #007bff; text-decoration: none; }
        a:hover { text-decoration: underline; }
    </style>
</head>
<body>
    <h1>登録完了</h1>

    <div class="message">
        <p th:if="${completedName}">
            <span th:text="${completedName}"></span> さんの登録が完了しました。
        </p>
        <p th:unless="${completedName}">
            登録が完了しました。
        </p>
    </div>

    <a th:href="@{/users/new}">続けて登録する</a>
</body>
</html>

動作確認

アプリケーションを起動し、http://localhost:8080/users/new にアクセスします。

./mvnw spring-boot:run

正常系: すべての項目を正しく入力して「登録」ボタンを押すと、完了画面にリダイレクトされます。

エラー系: 名前を空欄にして送信すると、「名前を入力してください」というエラーメッセージが表示され、他の入力値は保持されたままフォームが再表示されます。


8. Servlet/JSP vs Spring Boot ― フォーム処理の対比

項目 Servlet/JSP Spring Boot
パラメータ取得 request.getParameter("name") @ModelAttribute で自動バインド
型変換 Integer.parseInt() を手動で実行 フォームクラスの型に自動変換
バリデーション if文を手動で記述 @NotBlank 等のアノテーションで宣言
エラーメッセージ表示 request.setAttribute() で手動設定 th:errors で自動表示
入力値の復元 value="<%= request.getParameter("name") %>" th:field で自動復元
フォーム二重送信対策 リダイレクトを手動実装 return "redirect:..."
エラー判定 List<String> 等で手動管理 BindingResult.hasErrors()

練習問題

問題1:お問い合わせフォーム ⭐

以下の仕様でお問い合わせフォームを作成してください。

フォームクラス:ContactForm

フィールド バリデーション
name String 必須、30文字以内
message String 必須、500文字以内

コントローラー:ContactController

メソッド パス 処理
GET /contact 空のフォームを表示
POST /contact バリデーション → エラーならフォーム再表示、成功なら完了画面へリダイレクト
GET /contact/complete 完了画面を表示

テンプレート:

  • contact_form.html ― お問い合わせフォーム
  • contact_complete.html ― 送信完了画面
模範解答

ContactForm.java

package com.example.hellospring.form;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class ContactForm {

    @NotBlank(message = "お名前を入力してください")
    @Size(max = 30, message = "お名前は30文字以内で入力してください")
    private String name;

    @NotBlank(message = "メッセージを入力してください")
    @Size(max = 500, message = "メッセージは500文字以内で入力してください")
    private String message;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

ContactController.java

package com.example.hellospring.controller;

import com.example.hellospring.form.ContactForm;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/contact")
public class ContactController {

    @GetMapping
    public String showForm(Model model) {
        model.addAttribute("contactForm", new ContactForm());
        return "contact_form";
    }

    @PostMapping
    public String submitContact(@Valid @ModelAttribute("contactForm") ContactForm form,
                                BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "contact_form";
        }
        return "redirect:/contact/complete";
    }

    @GetMapping("/complete")
    public String complete() {
        return "contact_complete";
    }
}

contact_form.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>お問い合わせ</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        .form-group { margin-bottom: 16px; }
        label { display: block; margin-bottom: 4px; font-weight: bold; }
        input[type="text"], textarea {
            width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
            box-sizing: border-box; font-size: 14px;
        }
        textarea { height: 120px; resize: vertical; }
        .is-invalid { border-color: #dc3545; }
        .error { color: #dc3545; font-size: 13px; margin-top: 4px; display: block; }
        button { background: #007bff; color: #fff; border: none; padding: 10px 24px;
                 border-radius: 4px; font-size: 16px; cursor: pointer; }
    </style>
</head>
<body>
    <h1>お問い合わせ</h1>

    <form th:action="@{/contact}" th:object="${contactForm}" method="post">
        <div class="form-group">
            <label for="name">お名前 <span style="color: red;">*</span></label>
            <input type="text" th:field="*{name}" th:errorclass="is-invalid" />
            <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="error"></span>
        </div>

        <div class="form-group">
            <label for="message">メッセージ <span style="color: red;">*</span></label>
            <textarea th:field="*{message}" th:errorclass="is-invalid"></textarea>
            <span th:if="${#fields.hasErrors('message')}" th:errors="*{message}" class="error"></span>
        </div>

        <button type="submit">送信</button>
    </form>
</body>
</html>

contact_complete.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>送信完了</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        .message { background: #d4edda; padding: 16px; border-radius: 4px; margin: 20px 0; }
    </style>
</head>
<body>
    <h1>送信完了</h1>
    <div class="message">
        <p>お問い合わせを受け付けました。ありがとうございます。</p>
    </div>
    <a th:href="@{/contact}">戻る</a>
</body>
</html>

問題2:商品登録フォーム ⭐⭐

以下の仕様で商品登録フォームを作成してください。

フォームクラス:ProductForm

フィールド バリデーション
productName String 必須、100文字以内
price Integer 必須、1以上、10,000,000以下
category String 必須

コントローラー:ProductController

メソッド パス 処理
GET /products/new 空のフォームを表示
POST /products/new バリデーション → エラーならフォーム再表示、成功なら完了画面へリダイレクト
GET /products/complete 完了画面を表示

ヒント:

  • categoryはセレクトボックス(<select>)で「食品」「電子機器」「衣類」「その他」の4つから選択させる
  • Thymeleafでセレクトボックスにバインドするには th:field="*{category}"<select> タグに指定する
模範解答

ProductForm.java

package com.example.hellospring.form;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public class ProductForm {

    @NotBlank(message = "商品名を入力してください")
    @Size(max = 100, message = "商品名は100文字以内で入力してください")
    private String productName;

    @NotNull(message = "価格を入力してください")
    @Min(value = 1, message = "価格は1円以上で入力してください")
    @Max(value = 10000000, message = "価格は10,000,000円以下で入力してください")
    private Integer price;

    @NotBlank(message = "カテゴリを選択してください")
    private String category;

    public String getProductName() {
        return productName;
    }

    public void setProductName(String productName) {
        this.productName = productName;
    }

    public Integer getPrice() {
        return price;
    }

    public void setPrice(Integer price) {
        this.price = price;
    }

    public String getCategory() {
        return category;
    }

    public void setCategory(String category) {
        this.category = category;
    }
}

ProductController.java

package com.example.hellospring.controller;

import com.example.hellospring.form.ProductForm;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.List;

@Controller
@RequestMapping("/products")
public class ProductController {

    private static final List<String> CATEGORIES =
            List.of("食品", "電子機器", "衣類", "その他");

    @GetMapping("/new")
    public String showForm(Model model) {
        model.addAttribute("productForm", new ProductForm());
        model.addAttribute("categories", CATEGORIES);
        return "product_form";
    }

    @PostMapping("/new")
    public String createProduct(@Valid @ModelAttribute("productForm") ProductForm form,
                                BindingResult bindingResult,
                                Model model) {
        if (bindingResult.hasErrors()) {
            model.addAttribute("categories", CATEGORIES);
            return "product_form";
        }
        return "redirect:/products/complete";
    }

    @GetMapping("/complete")
    public String complete() {
        return "product_complete";
    }
}

product_form.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>商品登録</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        .form-group { margin-bottom: 16px; }
        label { display: block; margin-bottom: 4px; font-weight: bold; }
        input[type="text"], input[type="number"], select {
            width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
            box-sizing: border-box; font-size: 14px;
        }
        .is-invalid { border-color: #dc3545; }
        .error { color: #dc3545; font-size: 13px; margin-top: 4px; display: block; }
        button { background: #28a745; color: #fff; border: none; padding: 10px 24px;
                 border-radius: 4px; font-size: 16px; cursor: pointer; }
    </style>
</head>
<body>
    <h1>商品登録</h1>

    <form th:action="@{/products/new}" th:object="${productForm}" method="post">
        <div class="form-group">
            <label for="productName">商品名 <span style="color: red;">*</span></label>
            <input type="text" th:field="*{productName}" th:errorclass="is-invalid" />
            <span th:if="${#fields.hasErrors('productName')}" th:errors="*{productName}" class="error"></span>
        </div>

        <div class="form-group">
            <label for="price">価格(円) <span style="color: red;">*</span></label>
            <input type="number" th:field="*{price}" th:errorclass="is-invalid" />
            <span th:if="${#fields.hasErrors('price')}" th:errors="*{price}" class="error"></span>
        </div>

        <div class="form-group">
            <label for="category">カテゴリ <span style="color: red;">*</span></label>
            <select th:field="*{category}" th:errorclass="is-invalid">
                <option value="">-- 選択してください --</option>
                <option th:each="cat : ${categories}" th:value="${cat}" th:text="${cat}"></option>
            </select>
            <span th:if="${#fields.hasErrors('category')}" th:errors="*{category}" class="error"></span>
        </div>

        <button type="submit">登録</button>
    </form>
</body>
</html>

product_complete.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>商品登録完了</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        .message { background: #d4edda; padding: 16px; border-radius: 4px; margin: 20px 0; }
    </style>
</head>
<body>
    <h1>商品登録完了</h1>
    <div class="message">
        <p>商品を登録しました。</p>
    </div>
    <a th:href="@{/products/new}">続けて登録する</a>
</body>
</html>

ポイント:

  • エラーでフォームを再表示する際、categoriesmodel に再度追加する必要がある(追加しないとセレクトボックスの選択肢が表示されない)
  • セレクトボックスの先頭に空の<option>を置き、@NotBlankで未選択を検出する

問題3:パスワード確認付きアカウント作成フォーム ⭐⭐⭐

以下の仕様でアカウント作成フォームを作成してください。

フォームクラス:AccountForm

フィールド バリデーション
username String 必須、3文字以上20文字以内、半角英数字とアンダースコアのみ(@Patternを使用)
email String 必須、メールアドレス形式
password String 必須、8文字以上
passwordConfirm String 必須

コントローラー:AccountController

メソッド パス 処理
GET /accounts/new 空のフォームを表示
POST /accounts/new バリデーション → パスワード一致チェック → エラーならフォーム再表示、成功なら完了画面へリダイレクト
GET /accounts/complete 完了画面を表示

追加要件:

  • パスワードとパスワード確認が一致しない場合、BindingResultにエラーを手動追加する
  • bindingResult.rejectValue("passwordConfirm", "error.passwordConfirm", "パスワードが一致しません") を使う

ヒント:

  • @Pattern(regexp = "^[a-zA-Z0-9_]+$") で半角英数字とアンダースコアのみを許可
  • Bean Validationの@Validによる自動検証の後に、パスワード一致チェックを手動で行う
  • パスワード入力欄は <input type="password"> を使う
模範解答

AccountForm.java

package com.example.hellospring.form;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public class AccountForm {

    @NotBlank(message = "ユーザー名を入力してください")
    @Size(min = 3, max = 20, message = "ユーザー名は3文字以上20文字以内で入力してください")
    @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "ユーザー名は半角英数字とアンダースコアのみ使用できます")
    private String username;

    @NotBlank(message = "メールアドレスを入力してください")
    @Email(message = "メールアドレスの形式が正しくありません")
    private String email;

    @NotBlank(message = "パスワードを入力してください")
    @Size(min = 8, message = "パスワードは8文字以上で入力してください")
    private String password;

    @NotBlank(message = "確認用パスワードを入力してください")
    private String passwordConfirm;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getPasswordConfirm() {
        return passwordConfirm;
    }

    public void setPasswordConfirm(String passwordConfirm) {
        this.passwordConfirm = passwordConfirm;
    }
}

AccountController.java

package com.example.hellospring.controller;

import com.example.hellospring.form.AccountForm;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/accounts")
public class AccountController {

    @GetMapping("/new")
    public String showForm(Model model) {
        model.addAttribute("accountForm", new AccountForm());
        return "account_form";
    }

    @PostMapping("/new")
    public String createAccount(@Valid @ModelAttribute("accountForm") AccountForm form,
                                BindingResult bindingResult) {

        // Bean Validationの後に、パスワード一致チェックを手動で行う
        if (!bindingResult.hasFieldErrors("password")
                && !bindingResult.hasFieldErrors("passwordConfirm")
                && !form.getPassword().equals(form.getPasswordConfirm())) {
            bindingResult.rejectValue("passwordConfirm", "error.passwordConfirm",
                    "パスワードが一致しません");
        }

        if (bindingResult.hasErrors()) {
            return "account_form";
        }

        return "redirect:/accounts/complete";
    }

    @GetMapping("/complete")
    public String complete() {
        return "account_complete";
    }
}

account_form.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>アカウント作成</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        .form-group { margin-bottom: 16px; }
        label { display: block; margin-bottom: 4px; font-weight: bold; }
        input[type="text"], input[type="email"], input[type="password"] {
            width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
            box-sizing: border-box; font-size: 14px;
        }
        .is-invalid { border-color: #dc3545; }
        .error { color: #dc3545; font-size: 13px; margin-top: 4px; display: block; }
        .hint { color: #666; font-size: 12px; margin-top: 2px; display: block; }
        button { background: #007bff; color: #fff; border: none; padding: 10px 24px;
                 border-radius: 4px; font-size: 16px; cursor: pointer; }
    </style>
</head>
<body>
    <h1>アカウント作成</h1>

    <form th:action="@{/accounts/new}" th:object="${accountForm}" method="post">
        <div class="form-group">
            <label for="username">ユーザー名 <span style="color: red;">*</span></label>
            <input type="text" th:field="*{username}" th:errorclass="is-invalid" />
            <span class="hint">半角英数字とアンダースコア、3〜20文字</span>
            <span th:if="${#fields.hasErrors('username')}" th:errors="*{username}" class="error"></span>
        </div>

        <div class="form-group">
            <label for="email">メールアドレス <span style="color: red;">*</span></label>
            <input type="email" th:field="*{email}" th:errorclass="is-invalid" />
            <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="error"></span>
        </div>

        <div class="form-group">
            <label for="password">パスワード <span style="color: red;">*</span></label>
            <input type="password" th:field="*{password}" th:errorclass="is-invalid" />
            <span class="hint">8文字以上</span>
            <span th:if="${#fields.hasErrors('password')}" th:errors="*{password}" class="error"></span>
        </div>

        <div class="form-group">
            <label for="passwordConfirm">パスワード(確認) <span style="color: red;">*</span></label>
            <input type="password" th:field="*{passwordConfirm}" th:errorclass="is-invalid" />
            <span th:if="${#fields.hasErrors('passwordConfirm')}" th:errors="*{passwordConfirm}" class="error"></span>
        </div>

        <button type="submit">アカウント作成</button>
    </form>
</body>
</html>

account_complete.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>アカウント作成完了</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        .message { background: #d4edda; padding: 16px; border-radius: 4px; margin: 20px 0; }
    </style>
</head>
<body>
    <h1>アカウント作成完了</h1>
    <div class="message">
        <p>アカウントを作成しました。</p>
    </div>
</body>
</html>

ポイント:

  • パスワード一致チェックはBean Validationだけでは実現できないため、コントローラーで手動チェックを追加している
  • bindingResult.hasFieldErrors("password")bindingResult.hasFieldErrors("passwordConfirm") で、パスワード・確認用パスワードそれぞれの@NotBlank@Sizeのエラーがない場合のみ一致チェックを行う(nullの状態でequals()を呼ぶとNullPointerExceptionになるため)
  • bindingResult.rejectValue() で特定のフィールドにエラーを手動追加できる
  • パスワード入力欄は type="password" にすることで、入力値がマスクされる

まとめ

学んだこと キーワード
フォームバッキングオブジェクト フォームクラス、getter / setter、自動バインド
Thymeleafフォーム属性 th:objectth:fieldth:errorsth:errorclass
Bean Validation @NotBlank@Email@Size@Min@Max@Pattern
バリデーション実行 @ValidBindingResult(引数順序に注意)
エラーメッセージ message属性、messages.properties
PRGパターン return "redirect:..."RedirectAttributes

Servlet/JSPの知識はどう活きたか

Servlet/JSPの知識 Spring Bootでの活用
request.getParameter() @ModelAttributeが内部で同等の処理を行っていることが理解できる
フォームのname属性とパラメータ名の対応 th:fieldname属性を自動生成する仕組みが理解できる
JSPの<form action="...">とPOST処理 th:action@PostMappingの対応関係が分かる
if文によるバリデーション Bean Validationが何を自動化しているかが理解できる
request.setAttribute()でエラーを画面に渡す処理 BindingResultがこの役割を担っていることが分かる

次回は Spring Data JPA(データベース連携) を学びます。今回作成したフォームから受け取ったデータを、実際にデータベースに保存・取得する方法を扱います。


Spring Boot入門シリーズ 全10回(予定):

  1. Servlet/JSPからの移行と環境構築
  2. コントローラとルーティング
  3. Thymeleafによるビュー
  4. フォーム処理とバリデーション(本記事)
  5. Spring Data JPA(データベース連携)
  6. RESTful API設計
  7. Spring Security(認証・認可)
  8. 例外処理とエラーハンドリング
  9. テストの書き方(JUnit + MockMvc)
  10. 総合演習:掲示板アプリをSpring Bootで再構築

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?