株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
はじめに
Spring Boot入門シリーズの 最終回 です。
Servlet/JSP入門⑩では、シリーズの総合演習として「掲示板アプリ」を作成しました。今回は 同じ掲示板アプリを Spring Boot で再構築 し、Servlet/JSP からの進化を体感します。
Spring Boot入門シリーズの第1回から第9回で学んだ知識を総動員します。
| 回 | 技術 | 使いどころ |
|---|---|---|
| ① | プロジェクト作成、起動 | Spring Initializr、組み込みTomcat |
| ② | コントローラー |
@Controller、@GetMapping、@PostMapping
|
| ③ | Thymeleaf | テンプレートエンジンによるHTMLの動的生成 |
| ④ | フォーム処理とバリデーション |
@Valid、BindingResult、@ModelAttribute
|
| ⑤ | Spring Data JPA | エンティティ、リポジトリ、3層アーキテクチャ |
本記事のコードはすべて第1回で作成したhello-springプロジェクト(com.example.hellospringパッケージ)上で動作します。環境構築がまだの方は第1回を先にご覧ください。
今回のゴール
- Servlet/JSP版の掲示板アプリと同じ機能をSpring Bootで実装する
- 投稿一覧(新しい順)、新規投稿、投稿の削除、ページネーション、入力バリデーション
- Servlet/JSP版とのコード比較で Spring Bootの生産性 を実感する
1. 掲示板アプリの仕様
機能一覧
| 機能 | 説明 |
|---|---|
| 投稿一覧表示 | 投稿を新しい順に表示(ページネーション付き) |
| 新規投稿 | 名前、タイトル、本文を入力して投稿 |
| 投稿の削除 | 投稿を個別に削除 |
| ページネーション | 1ページ10件で表示 |
| 入力バリデーション | 名前・タイトル・本文は必須、文字数制限あり |
Servlet/JSP版からの変更点
Servlet/JSP版ではメモリ上の ArrayList でデータを管理していましたが、Spring Boot版では第5回で学んだ Spring Data JPA + H2データベース を使ってデータを永続化します。また、Servlet/JSP版になかった タイトル フィールドと ページネーション を追加します。
完成イメージ
┌─────────────────────────────────────────────┐
│ 掲示板 │
│ 投稿数: 25件 │
├─────────────────────────────────────────────┤
│ [新規投稿] │
├─────────────────────────────────────────────┤
│ Spring Bootの感想 田中太郎 │
│ 2026/04/13 14:30 │
│ Spring Boot、便利ですね! │
│ [削除] │
│─────────────────────────────────────────────│
│ Java学習記録 鈴木花子 │
│ 2026/04/13 14:25 │
│ Javaの勉強を始めました。 │
│ [削除] │
├─────────────────────────────────────────────┤
│ ← 前へ 1 2 3 次へ → │
└─────────────────────────────────────────────┘
2. アーキテクチャ設計
3層アーキテクチャ
第5回で学んだ3層アーキテクチャを採用します。
ブラウザ
│
▼
[PostController] ← リクエスト受付、バリデーション
│
▼
[PostService] ← ビジネスロジック、ページネーション
│
▼
[PostRepository] ← データベースアクセス(Spring Data JPA)
│
▼
[H2 Database] ← インメモリDB
パッケージ構成
com.example.hellospring
├── entity/Post.java ← エンティティ(テーブルに対応)
├── repository/PostRepository.java ← データアクセス層
├── service/PostService.java ← ビジネスロジック層
├── controller/PostController.java ← コントローラー層
├── dto/PostForm.java ← フォーム入力用DTO
└── HelloSpringApplication.java ← メインクラス
Servlet/JSP版との構成比較
| 役割 | Servlet/JSP版 | Spring Boot版 |
|---|---|---|
| データの入れ物 |
Message.java(JavaBean) |
Post.java(@Entity) |
| データアクセス |
MessageDAO.java(ArrayList) |
PostRepository(インターフェースのみ) |
| リクエスト処理 | 3つのServlet(List/Post/Delete) |
PostController(1クラスに統合) |
| 画面 |
board.jsp(JSP + JSTL) |
post/list.html, post/form.html(Thymeleaf) |
| 入力値受け取り | request.getParameter() |
PostForm(フォームバッキングオブジェクト) |
| バリデーション | if文の羅列 | Bean Validation(@NotBlank、@Size) |
| 文字エンコーディング | EncodingFilter |
Spring Bootが自動設定 |
| アプリ設定 | web.xml |
application.properties |
Servlet/JSP版で必要だった EncodingFilter や web.xml は、Spring Bootでは 不要 です。フレームワークが自動で処理します。
application.properties
第5回で設定済みの内容に加え、ページネーションのデフォルト値を設定します。
# H2データベースの接続設定
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# H2コンソール
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# JPA/Hibernate 設定
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# ページネーションのデフォルト設定
spring.data.web.pageable.default-page-size=10
spring.jpa.hibernate.ddl-auto=update は開発時に便利ですが、本番環境では validate または none を使い、Flyway や Liquibase でスキーマを管理してください。
3. エンティティとリポジトリ
Post エンティティ
投稿データをデータベースのテーブルに対応させるエンティティクラスです。
package com.example.hellospring.entity;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 20)
private String name;
@Column(nullable = false, length = 50)
private String title;
@Column(nullable = false, length = 500)
private String content;
@Column(nullable = false)
private LocalDateTime createdAt;
// デフォルトコンストラクタ(JPA必須)
public Post() {
}
// パラメータ付きコンストラクタ
public Post(String name, String title, String content) {
this.name = name;
this.title = title;
this.content = content;
this.createdAt = LocalDateTime.now();
}
// getter / setter
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}
Servlet/JSP版との比較:
| 比較項目 | Servlet/JSP版(Message.java) |
Spring Boot版(Post.java) |
|---|---|---|
| データ保存先 | メモリ上のArrayList | H2データベース |
| ID管理 |
static int nextId で手動採番 |
@GeneratedValue で自動採番 |
| テーブル定義 | なし(メモリのみ) |
@Entity + @Column でJPAが自動生成 |
| 日時フォーマット |
getFormattedDate() メソッド |
Thymeleaf側で #temporals.format()
|
PostRepository
Spring Data JPA の JpaRepository を継承するだけで、CRUD操作のメソッドが自動的に使えます。
package com.example.hellospring.repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.example.hellospring.entity.Post;
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
// 新しい順に取得(ページネーション付き)
Page<Post> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
たった1メソッドの定義だけで、ページネーション付きの「新しい順に全件取得」が実現できます。
Servlet/JSP版では MessageDAO に findAll()、findById()、insert()、delete()、count() をすべて手書きしていました。Spring Data JPA では findById()、save()、deleteById()、count() は JpaRepository が自動提供するため、定義すら不要です。
4. フォーム入力用DTO
第4回で学んだフォームバッキングオブジェクトを作成します。Bean Validationのアノテーションでバリデーションルールを宣言します。
package com.example.hellospring.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class PostForm {
@NotBlank(message = "名前を入力してください")
@Size(max = 20, message = "名前は20文字以内で入力してください")
private String name;
@NotBlank(message = "タイトルを入力してください")
@Size(max = 50, message = "タイトルは50文字以内で入力してください")
private String title;
@NotBlank(message = "本文を入力してください")
@Size(max = 500, message = "本文は500文字以内で入力してください")
private String content;
// getter / setter
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
Servlet/JSP版との比較:
Servlet/JSP版では MessagePostServlet 内にif文を羅列してバリデーションしていました。
// Servlet/JSP版:if文によるバリデーション(25行以上)
if (author == null || author.trim().isEmpty()) {
session.setAttribute("error", "名前を入力してください。");
response.sendRedirect(request.getContextPath() + "/board");
return;
}
if (content == null || content.trim().isEmpty()) {
session.setAttribute("error", "メッセージを入力してください。");
response.sendRedirect(request.getContextPath() + "/board");
return;
}
if (author.trim().length() > 20) {
session.setAttribute("error", "名前は20文字以内で入力してください。");
response.sendRedirect(request.getContextPath() + "/board");
return;
}
// ... さらに続く
Spring Boot版では アノテーション3つ で同じバリデーションが実現できます。項目が増えても、フィールドにアノテーションを追加するだけです。
5. サービス層
ビジネスロジックを担当するサービスクラスです。
package com.example.hellospring.service;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.hellospring.dto.PostForm;
import com.example.hellospring.entity.Post;
import com.example.hellospring.repository.PostRepository;
@Service
@Transactional
public class PostService {
private final PostRepository postRepository;
// コンストラクタインジェクション
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
/**
* 投稿を新しい順に取得(ページネーション付き)
*/
@Transactional(readOnly = true)
public Page<Post> findAll(Pageable pageable) {
return postRepository.findAllByOrderByCreatedAtDesc(pageable);
}
/**
* 投稿を保存
*/
public Post save(PostForm form) {
Post post = new Post(
form.getName().trim(),
form.getTitle().trim(),
form.getContent().trim()
);
return postRepository.save(post);
}
/**
* 投稿を削除
*/
public void delete(Long id) {
postRepository.deleteById(id);
}
/**
* 投稿の総件数を取得
*/
@Transactional(readOnly = true)
public long count() {
return postRepository.count();
}
}
ポイント:
-
@ServiceでSpringのDIコンテナにBeanとして登録される -
@Transactionalでメソッド実行時にトランザクションが自動管理される -
@Transactional(readOnly = true)は参照系メソッドに付与することで、DBの最適化を促す -
PostForm→Postへの変換をサービス層で行い、コントローラーの責務を軽くしている
Servlet/JSP版では MessageDAO にデータアクセスとビジネスロジックが混在していましたが、Spring Boot版ではサービス層を挟むことで責務が明確に分離されています。
6. コントローラー
Servlet/JSP版では3つのServletクラス(MessageListServlet、MessagePostServlet、MessageDeleteServlet)に分かれていましたが、Spring Boot版では1つのコントローラークラスに統合できます。
package com.example.hellospring.controller;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.example.hellospring.dto.PostForm;
import com.example.hellospring.entity.Post;
import com.example.hellospring.service.PostService;
import jakarta.validation.Valid;
@Controller
@RequestMapping("/posts")
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
/**
* 投稿一覧画面(ページネーション付き)
* GET /posts?page=0&size=10
*/
@GetMapping
public String list(@PageableDefault(size = 10) Pageable pageable, Model model) {
Page<Post> postPage = postService.findAll(pageable);
model.addAttribute("postPage", postPage);
model.addAttribute("totalCount", postService.count());
return "post/list";
}
/**
* 新規投稿フォーム画面
* GET /posts/new
*/
@GetMapping("/new")
public String newPost(Model model) {
model.addAttribute("postForm", new PostForm());
return "post/form";
}
/**
* 投稿処理
* POST /posts
*/
@PostMapping
public String create(@Valid PostForm postForm,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// バリデーションエラーがある場合、フォーム画面に戻る
if (bindingResult.hasErrors()) {
return "post/form";
}
postService.save(postForm);
redirectAttributes.addFlashAttribute("successMessage", "投稿しました!");
// PRGパターン:POST後にリダイレクト
return "redirect:/posts";
}
/**
* 削除処理
* POST /posts/{id}/delete
*/
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id,
RedirectAttributes redirectAttributes) {
postService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "投稿を削除しました。");
return "redirect:/posts";
}
}
Servlet/JSP版との比較:
| 比較項目 | Servlet/JSP版 | Spring Boot版 |
|---|---|---|
| クラス数 | 3クラス(List/Post/Delete Servlet) | 1クラス(PostController) |
| URLマッピング | @WebServlet("/board") |
@GetMapping / @PostMapping
|
| パラメータ取得 | request.getParameter("author") |
メソッド引数に自動バインド |
| バリデーション | if文の羅列 |
@Valid + BindingResult
|
| リダイレクト | response.sendRedirect() |
return "redirect:/posts" |
| フラッシュメッセージ |
session.setAttribute() → 手動でremoveAttribute()
|
RedirectAttributes.addFlashAttribute()(自動で消える) |
特に フラッシュメッセージ の違いに注目してください。Servlet/JSP版ではセッションに値を入れて手動で削除していましたが、Spring Bootの RedirectAttributes はリダイレクト先で1回だけ表示され、自動的に消える仕組みです。
7. Thymeleaf テンプレート
7.1 共通レイアウト(fragments/layout.html)
ヘッダーとフッターを共通化するフラグメントファイルです。
ファイルパス: src/main/resources/templates/fragments/layout.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>レイアウト</title>
</head>
<body>
<!-- ヘッダーフラグメント -->
<header th:fragment="header" class="header">
<div class="header-content">
<h1><a th:href="@{/posts}">掲示板</a></h1>
</div>
</header>
<!-- フッターフラグメント -->
<footer th:fragment="footer" class="footer">
<p>Spring Boot 掲示板アプリ - Spring Boot入門 総合演習</p>
</footer>
</body>
</html>
7.2 投稿一覧画面(post/list.html)
ファイルパス: src/main/resources/templates/post/list.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>掲示板</title>
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<!-- ヘッダー -->
<header th:replace="~{fragments/layout :: header}"></header>
<div class="container">
<!-- 投稿数と新規投稿ボタン -->
<div class="toolbar">
<p class="post-count">投稿数: <span th:text="${totalCount}">0</span>件</p>
<a th:href="@{/posts/new}" class="btn btn-primary">新規投稿</a>
</div>
<!-- 成功メッセージ -->
<div th:if="${successMessage}" class="alert alert-success">
<p th:text="${successMessage}">成功メッセージ</p>
</div>
<!-- 投稿一覧 -->
<section class="post-list">
<!-- 投稿がない場合 -->
<div th:if="${postPage.isEmpty()}" class="no-posts">
<p>まだ投稿がありません。最初の投稿をしてみましょう!</p>
</div>
<!-- 投稿カード -->
<article th:each="post : ${postPage.content}" class="post-card">
<div class="post-header">
<h2 class="post-title" th:text="${post.title}">タイトル</h2>
<span class="post-author" th:text="${post.name}">投稿者</span>
</div>
<p class="post-date"
th:text="${#temporals.format(post.createdAt, 'yyyy/MM/dd HH:mm')}">
2026/01/01 00:00
</p>
<div class="post-content">
<p th:text="${post.content}">本文</p>
</div>
<div class="post-actions">
<form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post"
onsubmit="return confirm('この投稿を削除しますか?');">
<button type="submit" class="btn btn-danger btn-sm">削除</button>
</form>
</div>
</article>
</section>
<!-- ページネーション -->
<nav th:if="${postPage.totalPages > 1}" class="pagination">
<!-- 前へ -->
<a th:if="${postPage.hasPrevious()}"
th:href="@{/posts(page=${postPage.number - 1})}"
class="page-link">« 前へ</a>
<span th:unless="${postPage.hasPrevious()}"
class="page-link disabled">« 前へ</span>
<!-- ページ番号 -->
<span th:each="i : ${#numbers.sequence(0, postPage.totalPages - 1)}">
<a th:if="${i != postPage.number}"
th:href="@{/posts(page=${i})}"
th:text="${i + 1}"
class="page-link">1</a>
<span th:if="${i == postPage.number}"
th:text="${i + 1}"
class="page-link current">1</span>
</span>
<!-- 次へ -->
<a th:if="${postPage.hasNext()}"
th:href="@{/posts(page=${postPage.number + 1})}"
class="page-link">次へ »</a>
<span th:unless="${postPage.hasNext()}"
class="page-link disabled">次へ »</span>
</nav>
</div>
<!-- フッター -->
<footer th:replace="~{fragments/layout :: footer}"></footer>
</body>
</html>
Thymeleafのポイント:
-
th:replace="~{fragments/layout :: header}"で共通ヘッダーを読み込む -
th:each="post : ${postPage.content}"でページ内の投稿を繰り返し表示 -
${#temporals.format(post.createdAt, 'yyyy/MM/dd HH:mm')}で日時をフォーマット -
@{/posts(page=${postPage.number - 1})}でクエリパラメータ付きURLを生成
Servlet/JSP版との比較:
JSP版では <c:forEach> と <c:out> を使っていました。
<!-- JSP版 -->
<c:forEach var="msg" items="${messages}">
<span class="message-author"><c:out value="${msg.author}" /></span>
</c:forEach>
<!-- Thymeleaf版 -->
<span th:each="post : ${postPage.content}" th:text="${post.name}">投稿者</span>
Thymeleafは th:text で自動的にHTMLエスケープされるため、JSPで <c:out> を使い忘れるXSSリスクがありません。
7.3 投稿フォーム画面(post/form.html)
ファイルパス: src/main/resources/templates/post/form.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>新規投稿 - 掲示板</title>
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<!-- ヘッダー -->
<header th:replace="~{fragments/layout :: header}"></header>
<div class="container">
<h2>新規投稿</h2>
<form th:action="@{/posts}" th:object="${postForm}" method="post" class="post-form">
<!-- 名前 -->
<div class="form-group">
<label for="name">名前(20文字以内)</label>
<input type="text" id="name" th:field="*{name}"
maxlength="20" placeholder="名前を入力"
th:classappend="${#fields.hasErrors('name')} ? 'input-error'">
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="error-message">
エラーメッセージ
</p>
</div>
<!-- タイトル -->
<div class="form-group">
<label for="title">タイトル(50文字以内)</label>
<input type="text" id="title" th:field="*{title}"
maxlength="50" placeholder="タイトルを入力"
th:classappend="${#fields.hasErrors('title')} ? 'input-error'">
<p th:if="${#fields.hasErrors('title')}" th:errors="*{title}" class="error-message">
エラーメッセージ
</p>
</div>
<!-- 本文 -->
<div class="form-group">
<label for="content">本文(500文字以内)</label>
<textarea id="content" th:field="*{content}" rows="6"
maxlength="500" placeholder="本文を入力"
th:classappend="${#fields.hasErrors('content')} ? 'input-error'">
</textarea>
<p th:if="${#fields.hasErrors('content')}" th:errors="*{content}" class="error-message">
エラーメッセージ
</p>
</div>
<!-- ボタン -->
<div class="form-actions">
<button type="submit" class="btn btn-primary">投稿する</button>
<a th:href="@{/posts}" class="btn btn-secondary">キャンセル</a>
</div>
</form>
</div>
<!-- フッター -->
<footer th:replace="~{fragments/layout :: footer}"></footer>
</body>
</html>
ポイント:
-
th:object="${postForm}"でフォームバッキングオブジェクトをバインド -
th:field="*{name}"でid、name、value属性を自動設定 -
th:if="${#fields.hasErrors('name')}"でフィールド単位のエラー判定 -
th:errors="*{name}"でエラーメッセージを表示 -
th:classappendでエラー時にCSSクラスを追加
Servlet/JSP版ではバリデーションエラーを1つのメッセージにまとめてセッションで渡していましたが、Spring Boot版では フィールドごとにエラーメッセージを表示 できます。
7.4 CSSスタイル(static/css/style.css)
ファイルパス: src/main/resources/static/css/style.css
/* ===== リセット & ベース ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN',
'Hiragino Sans', Meiryo, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
/* ===== ヘッダー ===== */
.header {
background-color: #2196F3;
color: white;
padding: 16px 20px;
}
.header-content {
max-width: 800px;
margin: 0 auto;
}
.header h1 {
font-size: 24px;
}
.header a {
color: white;
text-decoration: none;
}
/* ===== コンテナ ===== */
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
/* ===== ツールバー ===== */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.post-count {
font-size: 14px;
color: #666;
}
/* ===== アラート ===== */
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
border: 1px solid;
}
.alert-success {
background-color: #e8f5e9;
color: #2e7d32;
border-color: #a5d6a7;
}
/* ===== 投稿カード ===== */
.post-list {
margin-top: 10px;
}
.post-card {
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 16px;
margin-bottom: 12px;
transition: box-shadow 0.3s;
}
.post-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.post-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.post-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.post-author {
font-size: 14px;
font-weight: bold;
color: #2196F3;
}
.post-date {
font-size: 12px;
color: #999;
margin-bottom: 8px;
}
.post-content {
font-size: 15px;
line-height: 1.7;
margin-bottom: 8px;
white-space: pre-wrap;
word-wrap: break-word;
}
.post-actions {
text-align: right;
}
.no-posts {
text-align: center;
color: #999;
padding: 40px;
font-size: 16px;
}
/* ===== フォーム ===== */
.post-form {
background-color: white;
padding: 24px;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 16px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-weight: bold;
margin-bottom: 4px;
font-size: 14px;
color: #555;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.3s;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.input-error {
border-color: #f44336 !important;
}
.error-message {
color: #f44336;
font-size: 12px;
margin-top: 4px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
/* ===== ボタン ===== */
.btn {
display: inline-block;
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #2196F3;
color: white;
}
.btn-primary:hover {
background-color: #1976D2;
}
.btn-secondary {
background-color: #9e9e9e;
color: white;
}
.btn-secondary:hover {
background-color: #757575;
}
.btn-danger {
background-color: #f44336;
color: white;
}
.btn-danger:hover {
background-color: #d32f2f;
}
.btn-sm {
padding: 4px 12px;
font-size: 12px;
}
/* ===== ページネーション ===== */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
margin-top: 24px;
flex-wrap: wrap;
}
.page-link {
display: inline-block;
padding: 8px 14px;
border: 1px solid #ddd;
border-radius: 4px;
color: #2196F3;
text-decoration: none;
font-size: 14px;
transition: background-color 0.3s;
}
.page-link:hover {
background-color: #e3f2fd;
}
.page-link.current {
background-color: #2196F3;
color: white;
border-color: #2196F3;
}
.page-link.disabled {
color: #ccc;
border-color: #eee;
cursor: default;
}
/* ===== フッター ===== */
.footer {
text-align: center;
margin-top: 40px;
padding: 20px;
color: #999;
font-size: 12px;
}
8. ファイル配置のまとめ
すべてのファイルを正しい場所に配置してください。
hello-spring/
└── src/main/
├── java/com/example/hellospring/
│ ├── entity/
│ │ └── Post.java
│ ├── repository/
│ │ └── PostRepository.java
│ ├── service/
│ │ └── PostService.java
│ ├── controller/
│ │ └── PostController.java
│ ├── dto/
│ │ └── PostForm.java
│ └── HelloSpringApplication.java
└── resources/
├── templates/
│ ├── fragments/
│ │ └── layout.html
│ └── post/
│ ├── list.html
│ └── form.html
├── static/
│ └── css/
│ └── style.css
└── application.properties
9. 動作確認
アプリの起動
プロジェクトルートで以下のコマンドを実行します。
./mvnw spring-boot:run
起動後、ブラウザで http://localhost:8080/posts にアクセスします。
動作チェックリスト
| チェック項目 | 確認方法 |
|---|---|
| 一覧画面が表示される |
http://localhost:8080/posts にアクセス |
| 「まだ投稿がありません」と表示される | 初回アクセス時 |
| 新規投稿フォームに遷移できる | 「新規投稿」ボタンをクリック |
| 空入力でバリデーションエラーが出る | 何も入力せず「投稿する」ボタンをクリック |
| エラーメッセージがフィールドごとに表示される | 名前・タイトル・本文それぞれにエラーが出る |
| 投稿が成功する | 名前・タイトル・本文を入力して「投稿する」 |
| 成功メッセージが表示される | 投稿後に「投稿しました!」と表示 |
| 投稿が一覧に表示される | 一覧画面に戻ると投稿が見える |
| 投稿が削除できる | 「削除」ボタンで確認ダイアログ後に削除 |
| ページネーションが動作する | 11件以上投稿するとページリンクが表示される |
H2コンソールでのデータ確認
http://localhost:8080/h2-console にアクセスし、以下のSQLで投稿データを確認できます。
SELECT * FROM posts ORDER BY created_at DESC;
10. Servlet/JSP版との比較
コード行数の比較
| ファイル種類 | Servlet/JSP版 | Spring Boot版 | 削減率 |
|---|---|---|---|
| データの入れ物 | Message.java(40行) | Post.java(70行) | 増加(JPA定義追加) |
| データアクセス | MessageDAO.java(45行) | PostRepository.java(12行) | 73%削減 |
| ビジネスロジック | DAO内に混在 | PostService.java(45行) | 層の分離 |
| コントローラー | 3 Servlet(計130行) | PostController.java(65行) | 50%削減 |
| バリデーション | Servlet内にif文(25行) | PostForm.java(30行) | 宣言的に変更 |
| 画面 | board.jsp(90行) | list.html + form.html(計130行) | 増加(2画面に分離) |
| 設定 | web.xml(20行) + Filter(25行) | application.properties(12行) | 73%削減 |
| CSS | style.css(100行) | style.css(240行) | 増加(ページネーション追加) |
コード行数だけを見ると大きな削減に見えない部分もありますが、削減されたのは「定型的で間違えやすいコード」 です。パラメータの手動取得、if文によるバリデーション、URLマッピングの設定---これらの「書くたびにバグを生みやすいコード」がフレームワークに任せられるようになりました。一般的なServlet/JSPアプリケーションで必要なJDBC接続管理やSQLの手書きも、Spring Data JPAが自動化してくれます。
機能比較
| 機能 | Servlet/JSP版 | Spring Boot版 |
|---|---|---|
| データ管理方式 | メモリ(ArrayList) | H2データベース(JPA管理、インメモリのため再起動で消える) |
| ページネーション | なし | あり(1ページ10件) |
| フィールド別バリデーション | 1つのエラーメッセージのみ | 各フィールドにエラー表示 |
| XSS対策 |
<c:out> を手動で記述 |
Thymeleafが自動エスケープ |
| CSRF対策 | なし | Thymeleafが自動でトークン付与 |
| トランザクション管理 | なし | @Transactional で自動管理 |
| 文字エンコーディング | Filterで手動設定 | フレームワークが自動設定 |
Thymeleafでフォームに th:action を使用すると、CSRFトークンが自動的に隠しフィールドとして挿入されます。Servlet/JSP版では存在しなかった CSRF対策が自動で有効 になっています。
11. 発展課題
この掲示板アプリをベースに、シリーズで学んだ知識を活用して機能を追加してみましょう。
課題1:投稿の編集機能 --- 難易度: ⭐
ヒント:
-
GET /posts/{id}/editでフォームに既存データを表示 -
POST /posts/{id}/editで更新処理 -
PostServiceにfindById()とupdate()メソッドを追加
課題2:検索機能 --- 難易度: ⭐⭐
ヒント:
-
PostRepositoryにクエリメソッドを追加:findByTitleContainingOrContentContaining() - 一覧画面に検索フォームを追加
-
GET /posts?keyword=xxxで検索結果を表示
課題3:REST APIの追加 --- 難易度: ⭐⭐
第2回で学んだ @RestController と ResponseEntity を使って、掲示板のREST APIを作成してください。
| エンドポイント | 説明 |
|---|---|
GET /api/posts |
投稿一覧をJSON形式で取得 |
POST /api/posts |
JSON形式で投稿を作成 |
DELETE /api/posts/{id} |
投稿を削除 |
課題4:テストの追加 --- 難易度: ⭐⭐⭐
第9回で学んだテスト技法を使って、以下のテストを作成してください。
-
PostServiceのユニットテスト(Mockito使用) -
PostControllerの統合テスト(@WebMvcTest使用) -
PostRepositoryのリポジトリテスト(@DataJpaTest使用)
まとめ
Servlet/JSPからSpring Bootへの進化
Servlet/JSP版と同じ機能をSpring Bootで再構築した結果、以下の進化を体験できました。
| 観点 | Servlet/JSP | Spring Boot |
|---|---|---|
| 設定 |
web.xml にXMLで記述 |
application.properties にキーバリューで記述 |
| コントローラー | 機能ごとにServletクラスを作成 | 1クラスにメソッドをまとめる |
| パラメータ取得 |
request.getParameter() で1つずつ手動 |
メソッド引数にオブジェクトとして自動バインド |
| バリデーション | if文を羅列 | アノテーションで宣言 |
| データアクセス | DAOに全CRUD操作を手書き | インターフェース定義のみで自動生成 |
| テンプレート | JSP + JSTL + EL式 | Thymeleaf(自然テンプレート) |
| セキュリティ | 手動対策(XSSは<c:out>) |
自動対策(XSS、CSRF) |
シリーズ全10回の振り返り
| 回 | テーマ | 主なスキル |
|---|---|---|
| ① | 環境構築 | Spring Initializr、組み込みTomcat、@SpringBootApplication
|
| ② | コントローラー |
@Controller、@RestController、HTTPメソッドマッピング |
| ③ | Thymeleaf | テンプレートエンジン、th:text、th:each、フラグメント |
| ④ | フォームとバリデーション | フォームバッキングオブジェクト、Bean Validation、PRGパターン |
| ⑤ | Spring Data JPA | エンティティ、リポジトリ、3層アーキテクチャ |
| ⑥ | REST API |
@RestController、ResponseEntity、JSON |
| ⑦ | Spring Security | 認証・認可、ログイン画面 |
| ⑧ | エラーハンドリング |
@ExceptionHandler、カスタムエラーページ |
| ⑨ | テスト | JUnit 5、Mockito、@WebMvcTest、@DataJpaTest
|
| ⑩ | 総合演習:掲示板アプリ(本記事) | 全技術を統合したWebアプリ開発 |
次のステップ
Spring Bootの基礎を習得した次のステップとして、以下の学習をおすすめします。
| ステップ | 内容 |
|---|---|
| Spring Security 実践 | OAuth2 / JWT によるAPI認証、権限ベースのアクセス制御 |
| Docker | アプリケーションのコンテナ化、Docker Compose での環境構築 |
| 本番DB連携 | MySQL / PostgreSQL への切り替え、Flyway によるマイグレーション |
| デプロイ | AWS / GCP / Heroku へのデプロイ |
| フロントエンド連携 | React / Vue.js + Spring Boot REST API のSPA構成 |
Servlet/JSP で「Webアプリケーションの仕組み」を理解し、Spring Boot で「実務レベルのアプリケーション開発」を体験しました。この2つのシリーズで身につけた知識は、どんなJava Webフレームワークを使う場合にも活きる土台になります。
シリーズ一覧:Spring Boot入門
- 環境構築とプロジェクト作成
- コントローラー
- Thymeleafによるビュー
- フォーム処理とバリデーション
- Spring Data JPA
- REST API
- Spring Security
- エラーハンドリング
- テスト
- 総合演習:掲示板アプリ(本記事)
参考
- Spring Boot 公式リファレンス
- Spring Data JPA 公式リファレンス
- Thymeleaf 公式ドキュメント
- Spring MVC 公式リファレンス
- Jakarta Bean Validation 仕様
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!