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入門⑩】総合演習:掲示板アプリをSpring Bootで再構築する

0
Posted at

株式会社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の動的生成
フォーム処理とバリデーション @ValidBindingResult@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版で必要だった EncodingFilterweb.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版では MessageDAOfindAll()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の最適化を促す
  • PostFormPost への変換をサービス層で行い、コントローラーの責務を軽くしている

Servlet/JSP版では MessageDAO にデータアクセスとビジネスロジックが混在していましたが、Spring Boot版ではサービス層を挟むことで責務が明確に分離されています。


6. コントローラー

Servlet/JSP版では3つのServletクラス(MessageListServletMessagePostServletMessageDeleteServlet)に分かれていましたが、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">&laquo; 前へ</a>
        <span th:unless="${postPage.hasPrevious()}"
              class="page-link disabled">&laquo; 前へ</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">次へ &raquo;</a>
        <span th:unless="${postPage.hasNext()}"
              class="page-link disabled">次へ &raquo;</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}"idnamevalue 属性を自動設定
  • 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 で更新処理
  • PostServicefindById()update() メソッドを追加

課題2:検索機能 --- 難易度: ⭐⭐

ヒント:

  • PostRepository にクエリメソッドを追加: findByTitleContainingOrContentContaining()
  • 一覧画面に検索フォームを追加
  • GET /posts?keyword=xxx で検索結果を表示

課題3:REST APIの追加 --- 難易度: ⭐⭐

第2回で学んだ @RestControllerResponseEntity を使って、掲示板の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:textth:each、フラグメント
フォームとバリデーション フォームバッキングオブジェクト、Bean Validation、PRGパターン
Spring Data JPA エンティティ、リポジトリ、3層アーキテクチャ
REST API @RestControllerResponseEntity、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入門

  1. 環境構築とプロジェクト作成
  2. コントローラー
  3. Thymeleafによるビュー
  4. フォーム処理とバリデーション
  5. Spring Data JPA
  6. REST API
  7. Spring Security
  8. エラーハンドリング
  9. テスト
  10. 総合演習:掲示板アプリ(本記事)

参考


@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?