株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
はじめに
前回(第3回)では、Thymeleafの基本構文(th:text、th:each、th:ifなど)とレイアウトの共通化を学び、ユーザー一覧画面を作成しました。
第4回では、フォーム処理と**バリデーション(入力値検証)**を扱います。
今回学ぶこと
- フォームバッキングオブジェクトを使ったフォームデータの送受信
- Thymeleafのフォーム関連属性(
th:object、th:field、th: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]
処理の流れは以下の通りです。
-
GETリクエスト:空のフォームオブジェクトを
Modelに追加し、フォーム画面を表示 - POSTリクエスト:送信されたデータがフォームオブジェクトに自動バインドされる
- バリデーション:アノテーションに基づいて自動で入力検証が実行される
-
結果の判定:
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つずつ呼ぶ必要がなくなります。
今回からクラスの役割ごとにサブパッケージ(controller、form)を分けています。第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";
}
}
処理の流れ:
-
GET /users/new→showForm()→ 空のUserFormをModelに追加 →user_form.htmlを表示 -
POST /users/new→createUser()→ フォームデータがUserFormに自動バインド → バリデーション実行- エラーあり →
user_form.htmlを再表示(入力値とエラーメッセージが保持される) - エラーなし →
redirect:/users/completeにリダイレクト
- エラーあり →
-
GET /users/complete→complete()→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>
ポイント:
- エラーでフォームを再表示する際、
categoriesをmodelに再度追加する必要がある(追加しないとセレクトボックスの選択肢が表示されない) - セレクトボックスの先頭に空の
<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:object、th:field、th:errors、th:errorclass
|
| Bean Validation |
@NotBlank、@Email、@Size、@Min、@Max、@Pattern
|
| バリデーション実行 |
@Valid、BindingResult(引数順序に注意) |
| エラーメッセージ |
message属性、messages.properties
|
| PRGパターン |
return "redirect:..."、RedirectAttributes
|
Servlet/JSPの知識はどう活きたか
| Servlet/JSPの知識 | Spring Bootでの活用 |
|---|---|
request.getParameter() |
@ModelAttributeが内部で同等の処理を行っていることが理解できる |
フォームのname属性とパラメータ名の対応 |
th:fieldがname属性を自動生成する仕組みが理解できる |
JSPの<form action="...">とPOST処理 |
th:actionと@PostMappingの対応関係が分かる |
| if文によるバリデーション | Bean Validationが何を自動化しているかが理解できる |
request.setAttribute()でエラーを画面に渡す処理 |
BindingResultがこの役割を担っていることが分かる |
次回は Spring Data JPA(データベース連携) を学びます。今回作成したフォームから受け取ったデータを、実際にデータベースに保存・取得する方法を扱います。
Spring Boot入門シリーズ 全10回(予定):
- Servlet/JSPからの移行と環境構築
- コントローラとルーティング
- Thymeleafによるビュー
- フォーム処理とバリデーション(本記事)
- Spring Data JPA(データベース連携)
- RESTful API設計
- Spring Security(認証・認可)
- 例外処理とエラーハンドリング
- テストの書き方(JUnit + MockMvc)
- 総合演習:掲示板アプリをSpring Bootで再構築
参考
- Spring Boot 公式ドキュメント
- Spring Framework - Java Bean Validation
- Jakarta Bean Validation API 3.0 - constraints パッケージ
- Thymeleaf 公式ドキュメント - フォーム処理
- Baeldung - Validation in Spring Boot
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!