【Spring Boot】簡単な掲示板アプリの作り方を解説|初心者がアプリ作成をしながらCRUD機能を学ぶのにおすすめ
Spring Boot を使い、簡単な掲示板アプリを開発する手順を解説します。Webアプリの基本となるCRUD(データの登録、表示、更新、削除)機能を実装する過程で、Spring Data JPA を使ったデータベース操作や、Thymeleaf を用いた画面作成の基礎を実践的に学ぶことができます。
開発環境
- OS: Windows10
- Visual Studio Code: 1.73.0
- Java: OpenJDK 23
- Spring Boot: 3.4.0
- Lombok: 1.18.30
- Thymeleaf Layout Dialect: 3.3.0
サンプルコード
/pom.xml
/pom.xml1 <artifactId>spring-boot-starter-test</artifactId> 2 <scope>test</scope> 3 </dependency> 4+ <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok --> 5+ <dependency> 6+ <groupId>org.projectlombok</groupId> 7+ <artifactId>lombok</artifactId> 8+ <version>1.18.30</version> 9+ <scope>provided</scope> 10+ </dependency> 11+ <!-- https://mvnrepository.com/artifact/nz.net.ultraq.thymeleaf/thymeleaf-layout-dialect --> 12+ <dependency> 13+ <groupId>nz.net.ultraq.thymeleaf</groupId> 14+ <artifactId>thymeleaf-layout-dialect</artifactId> 15+ <version>3.3.0</version> 16+ </dependency> 17 </dependencies> 18 19 <build> 20 <groupId>org.springframework.boot</groupId> 21 <artifactId>spring-boot-maven-plugin</artifactId> 22 </plugin> 23+ <plugin> 24+ <groupId>org.apache.maven.plugins</groupId> 25+ <artifactId>maven-compiler-plugin</artifactId> 26+ <version>3.8.1</version> 27+ <configuration> 28+ <annotationProcessorPaths> 29+ <path> 30+ <groupId>org.projectlombok</groupId> 31+ <artifactId>lombok</artifactId> 32+ <version>1.18.30</version> 33+ </path> 34+ </annotationProcessorPaths> 35+ </configuration> 36+ </plugin> 37 </plugins> 38 </build> 39 40
/src/main/java/com/example/bbs/controller/PostController.java
/src/main/java/com/example/bbs/controller/PostController.java1+package com.example.bbs.controller; 2+ 3+import com.example.bbs.model.Post; 4+import com.example.bbs.service.PostService; 5+import org.springframework.stereotype.Controller; 6+import org.springframework.ui.Model; 7+import org.springframework.web.bind.annotation.*; 8+ 9+@Controller 10+@RequestMapping("/posts") 11+public class PostController { 12+ private final PostService postService; 13+ 14+ public PostController(PostService postService) { 15+ this.postService = postService; 16+ } 17+ 18+ // 一覧表示 19+ @GetMapping 20+ public String listPosts(Model model) { 21+ model.addAttribute("posts", postService.findAll()); 22+ return "posts/list"; 23+ } 24+ 25+ // 詳細表示 26+ @GetMapping("/{id}") 27+ public String viewPost(@PathVariable Long id, Model model) { 28+ model.addAttribute("post", postService.findById(id).orElseThrow()); 29+ return "posts/detail"; 30+ } 31+ 32+ // 新規投稿フォーム 33+ @GetMapping("/new") 34+ public String newPostForm(Model model) { 35+ model.addAttribute("post", new Post()); 36+ return "posts/new"; 37+ } 38+ 39+ // 編集フォーム 40+ @GetMapping("{id}/edit") 41+ public String editPostFrom(@PathVariable Long id, Model model) { 42+ model.addAttribute("post", postService.findById(id).orElseThrow()); 43+ return "posts/edit"; 44+ } 45+ 46+ // 投稿更新 47+ @PostMapping("/{id}") 48+ public String updatePost(@PathVariable Long id, @ModelAttribute Post post) { 49+ System.out.println("test"); 50+ Post existingPost = postService.findById(id).orElseThrow(); 51+ existingPost.setTitle(post.getTitle()); 52+ existingPost.setContent(post.getContent()); 53+ postService.save(existingPost); 54+ return "redirect:/posts"; 55+ } 56+ 57+ // 投稿作成 58+ @PostMapping 59+ public String createPost(@ModelAttribute Post post) { 60+ postService.save(post); 61+ System.out.println(post); 62+ return "redirect:/posts"; 63+ } 64+ 65+ // 投稿削除 66+ @PostMapping("{id}/delete") 67+ public String deletePost(@PathVariable Long id) { 68+ postService.deleteById(id); 69+ return "redirect:/posts"; 70+ } 71+} 72
/src/main/java/com/example/bbs/model/Post.java
/src/main/java/com/example/bbs/model/Post.java1+package com.example.bbs.model; 2+ 3+import jakarta.persistence.*; 4+import lombok.Data; 5+ 6+import java.time.LocalDateTime; 7+ 8+@Entity 9+@Data 10+public class Post { 11+ @Id 12+ @GeneratedValue(strategy = GenerationType.IDENTITY) 13+ private Long id; 14+ 15+ private String title; 16+ private String content; 17+ 18+ private LocalDateTime createdAt = LocalDateTime.now(); 19+ private LocalDateTime updatedAt = LocalDateTime.now(); 20+ 21+ @PrePersist 22+ public void onCreate() { 23+ createdAt = LocalDateTime.now(); 24+ } 25+ 26+ @PreUpdate 27+ public void onUpdate() { 28+ updatedAt = LocalDateTime.now(); 29+ } 30+} 31
/src/main/java/com/example/bbs/repository/PostRepository.java
/src/main/java/com/example/bbs/repository/PostRepository.java1+package com.example.bbs.repository; 2+ 3+import com.example.bbs.model.Post; 4+import org.springframework.data.jpa.repository.JpaRepository; 5+ 6+public interface PostRepository extends JpaRepository<Post, Long> { 7+} 8
/src/main/java/com/example/bbs/service/PostService.java
/src/main/java/com/example/bbs/service/PostService.java1+package com.example.bbs.service; 2+ 3+import com.example.bbs.model.Post; 4+import com.example.bbs.repository.PostRepository; 5+import org.springframework.stereotype.Service; 6+ 7+import java.util.List; 8+import java.util.Optional; 9+ 10+@Service 11+public class PostService { 12+ private final PostRepository postRepository; 13+ 14+ public PostService(PostRepository postRepository) { 15+ this.postRepository = postRepository; 16+ } 17+ 18+ public List<Post> findAll() { 19+ return postRepository.findAll(); 20+ } 21+ 22+ public Optional<Post> findById(Long id) { 23+ return postRepository.findById(id); 24+ } 25+ 26+ public Post save(Post post) { 27+ System.out.println(post); 28+ return postRepository.save(post); 29+ } 30+ 31+ public void deleteById(Long id) { 32+ postRepository.deleteById(id); 33+ } 34+} 35
/src/main/resources/application.properties
/src/main/resources/application.properties1 spring.application.name=bbs 2+ 3+# MySQLの設定 4+spring.datasource.url=jdbc:mysql://localhost:3306/board 5+spring.datasource.username=root 6+spring.datasource.password=root 7+spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver 8+ 9+# JPAの設定 10+spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect 11+ 12+spring.jpa.hibernate.ddl-auto=update 13+spring.jpa.show-sql=true 14+spring.jpa.properties.hibernate.format_sql=true 15+ 16+# ログの設定 17+logging.level.org.hibernate.SQL=DEBUG 18+logging.level.org.hibernate.type.descriptor.sql=TRACE 19+ 20+# ホットリロードを有効にする 21+spring.devtools.restart.enabled=true 22+spring.devtools.livereload.enabled=true 23+ 24+# Thymeleafログを有効化 25+spring.thymeleaf.cache=false 26+logging.level.org.thymeleaf=DEBUG 27
/src/main/resources/templates/layout/layout.html
/src/main/resources/templates/layout/layout.html1+<!DOCTYPE html> 2+<html xmlns:th="http://www.thymeleaf.org" 3+ xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout"> 4+ 5+<head> 6+ <meta charset="UTF-8"> 7+ <meta name="viewport" content="width=device-width, initial-scale=1.0"> 8+ <title layout:fragment="title">掲示板</title> 9+ <!-- Bootstrap CSS --> 10+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous"> 11+</head> 12+<body> 13+ <!-- Header --> 14+ <header> 15+ <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> 16+ <div class="container-fluid"> 17+ <a class="navbar-brand" href="#">掲示板アプリ</a> 18+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> 19+ <span class="navbar-toggler-icon"></span> 20+ </button> 21+ <div class="collapse navbar-collapse" id="navbarNav"> 22+ <ul class="navbar-nav"> 23+ <li class="nav-item"> 24+ <a class="nav-link active" href="/">Home</a> 25+ </li> 26+ </ul> 27+ </div> 28+ </div> 29+ </nav> 30+ </header> 31+ <!-- Main Content --> 32+ <main class="container mt-4" layout:fragment="content"> 33+ 34+ </main> 35+ 36+ <!-- Footer --> 37+ <footer class="bg-dark text-white text-center mt-4 py-3"> 38+ <p>© 2024 掲示板アプリ</p> 39+ </footer> 40+ 41+ <!-- Bootstrap JS --> 42+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script> 43+</body> 44+</html> 45
/src/main/resources/templates/posts/detail.html
/src/main/resources/templates/posts/detail.html1+<!DOCTYPE html> 2+<html xmlns:th="http://www.thymeleaf.org" 3+ xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout" 4+ layout:decorate="layout/layout"> 5+<head> 6+ <title layout:fragment="title">Post Datail</title> 7+</head> 8+ 9+<main layout:fragment="content"> 10+ <h1 th:text="${post.title}"></h1> 11+ <p th:text="${post.content}"></p> 12+ <a th:href="@{/posts}" class="btn btn-secondary">Back To List</a> 13+ <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary">Edit</a> 14+ <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 15+ <button type="submit" class="btn btn-danger">Delete</button> 16+ </form> 17+</main> 18
/src/main/resources/templates/posts/edit.html
/src/main/resources/templates/posts/edit.html1+<!DOCTYPE html> 2+<html xmlns:th="http://www.thymeleaf.org" 3+ xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout" 4+ layout:decorate="layout/layout"> 5+<head> 6+ <title layout:fragment="title">Edit Post</title> 7+</head> 8+ 9+<main layout:fragment="content"> 10+ <h1>Edit Post</h1> 11+ <form th:action="@{/posts/{id}(id=${post.id})}" method="post"> 12+ <div class="mb-3"> 13+ <label for="title" class="form-label">Title</label> 14+ <input type="text" id="title" name="title" class="form-control" th:value="${post.title}"> 15+ </div> 16+ <div class="mb-3"> 17+ <label for="content" class="form-label">Content</label> 18+ <textarea id="content" name="content" class="form-control" rows="5" th:text="${post.content}"></textarea> 19+ </div> 20+ <button type="submit" class="btn btn-success">Upadate</button> 21+ <a th:href="@{/posts/{id}(id=${post.id})}" class="btn btn-secondary">Cancel</a> 22+ </form> 23+</main> 24+</html> 25
/src/main/resources/templates/posts/list.html
/src/main/resources/templates/posts/list.html1+<!DOCTYPE html> 2+<html xmlns:th="http://www.thymeleaf.org" 3+ xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout" 4+ layout:decorate="layout/layout"> 5+<head> 6+ <title layout:fragment="title">Post List</title> 7+</head> 8+ 9+<main layout:fragment="content"> 10+ <h1>Post List</h1> 11+ <h2>新規投稿</h2> 12+ <a href="/posts/new" class="btn btn-primary mb-3">New Post</a> 13+ <h2>掲示板一覧</h2> 14+ <table class="table table-striped"> 15+ <thead> 16+ <tr> 17+ <th scope="col" style="width: 30%;">Title</th> 18+ <th scope="col" style="width: 50%;">Content</th> 19+ <th scope="col" style="width: 20%;" class="text-end">Actions</th> 20+ </tr> 21+ </thead> 22+ <tbody> 23+ <tr th:each="post : ${posts}"> 24+ <td> 25+ <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}"></a> 26+ </td> 27+ <td> 28+ <span th:text="${post.content}"></span> 29+ </td> 30+ <td class="text-end"> 31+ <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary btn-sm">Edit</a> 32+ <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 33+ <button type="submit" class="btn btn-danger btn-sm">Delete</button> 34+ </form> 35+ </td> 36+ </tr> 37+ </tbody> 38+ </table> 39+</main> 40+</html> 41
/src/main/resources/templates/posts/new.html
/src/main/resources/templates/posts/new.html1+<!DOCTYPE html> 2+<html xmlns:th="http://www.thymeleaf.org" 3+ xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout" 4+ layout:decorate="layout/layout"> 5+<head> 6+ <title layout:fragment="title">New Post</title> 7+</head> 8+ 9+<main layout:fragment="content"> 10+ <h1>New Post</h1> 11+ <form th:action="@{/posts}" method="post"> 12+ <div class="mb-3"> 13+ <label for="title" class="form-label">Title</label> 14+ <input type="text" id="title" name="title" class="form-control" required> 15+ </div> 16+ <div class="mb-3"> 17+ <label for="content" class="form-label">Content</label> 18+ <textarea id="content" name="content" class="form-control" rows="5" required></textarea> 19+ </div> 20+ <button type="submit" class="btn btn-success">Submit</button> 21+ </form> 22+</main> 23+</html> 24
コード解説
変更点: 必要なライブラリの追加
/pom.xml1+ <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok --> 2+ <dependency> 3+ <groupId>org.projectlombok</groupId> 4+ <artifactId>lombok</artifactId> 5+ <version>1.18.30</version> 6+ <scope>provided</scope> 7+ </dependency> 8+ <!-- https://mvnrepository.com/artifact/nz.net.ultraq.thymeleaf/thymeleaf-layout-dialect --> 9+ <dependency> 10+ <groupId>nz.net.ultraq.thymeleaf</groupId> 11+ <artifactId>thymeleaf-layout-dialect</artifactId> 12+ <version>3.3.0</version> 13+ </dependency>
pom.xmlファイルに、プロジェクトで利用するライブラリ(依存関係)を2つ追加しています。
- Lombok: Javaのコード記述を簡略化するライブラリです。
@Dataのようなアノテーションをクラスに付けるだけで、ゲッターやセッターといった定型的なメソッドをコンパイル時に自動で生成してくれます。 - Thymeleaf Layout Dialect: 画面テンプレートエンジンThymeleafの拡張機能です。ヘッダーやフッターなど、複数のページで共通する部分をレイアウトとして定義し、簡単に使い回せるようにします。
変更点: Lombokを動作させるためのMavenプラグイン設定
/pom.xml1+ <plugin> 2+ <groupId>org.apache.maven.plugins</groupId> 3+ <artifactId>maven-compiler-plugin</artifactId> 4+ <version>3.8.1</version> 5+ <configuration> 6+ <annotationProcessorPaths> 7+ <path> 8+ <groupId>org.projectlombok</groupId> 9+ <artifactId>lombok</artifactId> 10+ <version>1.18.30</version> 11+ </path> 12+ </annotationProcessorPaths> 13+ </configuration> 14+ </plugin>
Mavenがプログラムをビルド(実行可能な形式に変換)する際に、Lombokのアノテーションを正しく解釈してコードを自動生成させるための設定を追加しています。この設定がないと、Lombokが生成するはずのメソッドが見つからず、ビルドエラーが発生してしまいます。
変更点: 掲示板アプリの全体的な設定
/src/main/resources/application.properties1+# MySQLの設定 2+spring.datasource.url=jdbc:mysql://localhost:3306/board 3+spring.datasource.username=root 4+spring.datasource.password=root 5+spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver 6+ 7+# JPAの設定 8+spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect 9+spring.jpa.hibernate.ddl-auto=update 10+spring.jpa.show-sql=true 11+spring.jpa.properties.hibernate.format_sql=true 12+ 13+# ログの設定 14+logging.level.org.hibernate.SQL=DEBUG 15+logging.level.org.hibernate.type.descriptor.sql=TRACE 16+ 17+# ホットリロードを有効にする 18+spring.devtools.restart.enabled=true 19+spring.devtools.livereload.enabled=true 20+ 21+# Thymeleafログを有効化 22+spring.thymeleaf.cache=false 23+logging.level.org.thymeleaf=DEBUG
application.propertiesファイルに、アプリケーションの動作に関する様々な設定を追加しています。
- MySQLの設定: アプリケーションが接続するMySQLデータベースのURL、ユーザー名、パスワードなどを指定します。
- JPAの設定: Spring Data JPAがデータベースを操作するための設定です。
ddl-auto=updateは、Javaのモデルクラス(後述)に合わせてデータベースのテーブル構造を自動で更新する便利な設定です。show-sql=trueは、実行されたSQLをコンソールに表示する設定で、開発時のデバッグに役立ちます。 - 開発効率化の設定:
spring.devtoolsは、コードを変更した際にアプリケーションを自動で再起動したり、ブラウザをリロードしたりする機能(ホットリロード)を有効にします。spring.thymeleaf.cache=falseは、HTMLファイルの変更を即座に画面に反映させるための設定です。
変更点: 投稿データを表すモデルクラスの作成
/src/main/java/com/example/bbs/model/Post.java1+package com.example.bbs.model; 2+ 3+import jakarta.persistence.*; 4+import lombok.Data; 5+ 6+import java.time.LocalDateTime; 7+ 8+@Entity 9+@Data 10+public class Post { 11+ @Id 12+ @GeneratedValue(strategy = GenerationType.IDENTITY) 13+ private Long id; 14+ 15+ private String title; 16+ private String content; 17+ 18+ private LocalDateTime createdAt = LocalDateTime.now(); 19+ private LocalDateTime updatedAt = LocalDateTime.now(); 20+ 21+ @PrePersist 22+ public void onCreate() { 23+ createdAt = LocalDateTime.now(); 24+ } 25+ 26+ @PreUpdate 27+ public void onUpdate() { 28+ updatedAt = LocalDateTime.now(); 29+ } 30+}
データベース上の投稿データをJavaのオブジェクトとして扱うためのPostクラスを作成しています。これは「エンティティクラス」と呼ばれます。
@Entity: このクラスがデータベースのテーブルに対応することを示します。@Data: Lombokのアノテーションで、各フィールド(変数)に対するgetter/setterメソッドなどを自動生成します。@Id,@GeneratedValue:idフィールドがテーブルの主キーであり、値が自動で採番されることを示します。@PrePersist,@PreUpdate: データが新規保存される直前や更新される直前に、それぞれonCreateメソッドとonUpdateメソッドが自動的に実行されます。これにより、作成日時と更新日時を自動で記録することができます。
変更点: データベース操作を行うリポジトリの作成
/src/main/java/com/example/bbs/repository/PostRepository.java1+package com.example.bbs.repository; 2+ 3+import com.example.bbs.model.Post; 4+import org.springframework.data.jpa.repository.JpaRepository; 5+ 6+public interface PostRepository extends JpaRepository<Post, Long> { 7+}
データベースへのアクセスを担うPostRepositoryインターフェースを作成しています。Spring Data JPAのJpaRepository<Post, Long>を継承するだけで、findAll()(全件取得)やsave()(保存)といった基本的なCRUD操作を行うメソッドが自動的に実装され、利用できるようになります。<Post, Long>は、このリポジトリがPostエンティティを扱い、その主キーの型がLongであることを示しています。
変更点: ビジネスロジックを担うサービスクラスの作成
/src/main/java/com/example/bbs/service/PostService.java1+package com.example.bbs.service; 2+ 3+import com.example.bbs.model.Post; 4+import com.example.bbs.repository.PostRepository; 5+import org.springframework.stereotype.Service; 6+ 7+import java.util.List; 8+import java.util.Optional; 9+ 10+@Service 11+public class PostService { 12+ private final PostRepository postRepository; 13+ 14+ public PostService(PostRepository postRepository) { 15+ this.postRepository = postRepository; 16+ } 17+ 18+ public List<Post> findAll() { return postRepository.findAll(); } 19+ public Optional<Post> findById(Long id) { return postRepository.findById(id); } 20+ public Post save(Post post) { return postRepository.save(post); } 21+ public void deleteById(Long id) { postRepository.deleteById(id); } 22+}
アプリケーションの主要な処理(ビジネスロジック)をまとめるPostServiceクラスを作成しています。
@Service: このクラスがビジネスロジックを担うサービスクラスであることをSpring Frameworkに伝えます。- コンストラクタで
PostRepositoryを受け取り、その機能を利用してデータベース操作を行います。 - Controller(後述)からの依頼に応じて、
findAll(全件取得)、findById(IDで1件取得)、save(保存・更新)、deleteById(IDで削除)といった具体的な処理を実行します。ControllerとRepositoryの橋渡し役を担う重要な層です。
変更点: Webリクエストを処理するコントローラクラスの作成
/src/main/java/com/example/bbs/controller/PostController.java1+package com.example.bbs.controller; 2+ 3+import com.example.bbs.model.Post; 4+import com.example.bbs.service.PostService; 5+import org.springframework.stereotype.Controller; 6+import org.springframework.ui.Model; 7+import org.springframework.web.bind.annotation.*; 8+ 9+@Controller 10+@RequestMapping("/posts") 11+public class PostController { 12+ private final PostService postService; 13+ 14+ public PostController(PostService postService) { 15+ this.postService = postService; 16+ } 17+ // ... 各メソッド 18+}
ユーザーのブラウザからのリクエストを受け取り、適切な処理をPostServiceに依頼し、最終的に表示するHTMLを決定するPostControllerクラスを作成しています。
@Controller: このクラスがWebリクエストを処理するコントローラであることを示します。@RequestMapping("/posts"): このクラス内のメソッドは、/postsから始まるURLへのリクエストを処理することを示します。- コンストラクタで
PostServiceを受け取ることで、サービスクラスの機能を利用できるようになります。これをDI(Dependency Injection)と呼びます。
変更点: 投稿一覧表示機能の実装 (Read)
/src/main/java/com/example/bbs/controller/PostController.java1+ // 一覧表示 2+ @GetMapping 3+ public String listPosts(Model model) { 4+ model.addAttribute("posts", postService.findAll()); 5+ return "posts/list"; 6+ }
@GetMappingは、/postsというURLへのGETリクエストをこのlistPostsメソッドに紐づけます。
処理の流れは以下の通りです。
postService.findAll()を呼び出し、全ての投稿データをデータベースから取得します。model.addAttribute("posts", ...)を使って、取得した投稿データのリストを"posts"という名前でModelオブジェクトに格納します。Modelに格納されたデータは、次に表示されるHTMLテンプレート側で利用できます。"posts/list"という文字列を返すことで、resources/templates/posts/list.htmlファイルを表示するように指示します。
変更点: 新規投稿機能の実装 (Create)
/src/main/java/com/example/bbs/controller/PostController.java1+ // 新規投稿フォーム 2+ @GetMapping("/new") 3+ public String newPostForm(Model model) { 4+ model.addAttribute("post", new Post()); 5+ return "posts/new"; 6+ } 7+ 8+ // 投稿作成 9+ @PostMapping 10+ public String createPost(@ModelAttribute Post post) { 11+ postService.save(post); 12+ return "redirect:/posts"; 13+ }
新規投稿機能は、フォーム表示とデータ保存の2つのメソッドで実現されます。
- フォーム表示 (
newPostForm):/posts/newへのGETリクエストで呼び出されます。空のPostオブジェクトをModelに格納し、posts/new.html(新規投稿フォーム画面)を表示します。 - データ保存 (
createPost): フォームからmethod="post"で/postsにデータが送信されると呼び出されます。@ModelAttributeアノテーションが、フォームの入力内容(title,content)を自動的にPostオブジェクトに詰めてくれます。そのpostオブジェクトをpostService.save()でデータベースに保存し、処理後はredirect:/postsで一覧ページにリダイレクトします。
変更点: 投稿の更新機能の実装 (Update)
/src/main/java/com/example/bbs/controller/PostController.java1+ // 編集フォーム 2+ @GetMapping("{id}/edit") 3+ public String editPostFrom(@PathVariable Long id, Model model) { 4+ model.addAttribute("post", postService.findById(id).orElseThrow()); 5+ return "posts/edit"; 6+ } 7+ 8+ // 投稿更新 9+ @PostMapping("/{id}") 10+ public String updatePost(@PathVariable Long id, @ModelAttribute Post post) { 11+ Post existingPost = postService.findById(id).orElseThrow(); 12+ existingPost.setTitle(post.getTitle()); 13+ existingPost.setContent(post.getContent()); 14+ postService.save(existingPost); 15+ return "redirect:/posts"; 16+ }
投稿の更新も、編集フォーム表示とデータ更新の2段階で行います。
- 編集フォーム表示 (
editPostFrom):/posts/{id}/edit(例:/posts/1/edit)へのGETリクエストで呼び出されます。@PathVariableでURLからIDを取得し、そのIDに対応する既存の投稿データをpostService.findById()で取得します。取得したデータをModelに格納し、posts/edit.html(編集フォーム画面)に渡して表示します。 - データ更新 (
updatePost): 編集フォームから/posts/{id}へPOSTリクエストでデータが送信されると呼び出されます。IDで既存のデータを取得し、フォームから送られてきた新しいtitleとcontentで内容を上書きした後、postService.save()でデータベースを更新します。
変更点: 投稿の削除機能の実装 (Delete)
/src/main/java/com/example/bbs/controller/PostController.java1+ // 投稿削除 2+ @PostMapping("{id}/delete") 3+ public String deletePost(@PathVariable Long id) { 4+ postService.deleteById(id); 5+ return "redirect:/posts"; 6+ }
/posts/{id}/deleteへのPOSTリクエストで呼び出されます。@PathVariableでURLから削除対象のIDを取得し、postService.deleteById(id)を呼び出してデータベースから該当データを削除します。処理完了後、一覧ページにリダイレクトします。
変更点: 投稿詳細表示機能の実装 (Read)
/src/main/java/com/example/bbs/controller/PostController.java1+ // 詳細表示 2+ @GetMapping("/{id}") 3+ public String viewPost(@PathVariable Long id, Model model) { 4+ model.addAttribute("post", postService.findById(id).orElseThrow()); 5+ return "posts/detail"; 6+ }
/posts/{id}(例: /posts/1)のような一覧以外のURLへのGETリクエストで呼び出されます。@PathVariableでURLからIDを取得し、postService.findById()で特定の投稿データを1件だけ取得します。そのデータをModelに格納し、posts/detail.html(詳細表示画面)に渡して表示します。
変更点: 全ページ共通のレイアウトテンプレート作成
/src/main/resources/templates/layout/layout.html1+<!DOCTYPE html> 2+<html xmlns:th="http://www.thymeleaf.org" 3+ xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout"> 4+<head> 5+ <title layout:fragment="title">掲示板</title> 6+</head> 7+<body> 8+ <header>...</header> 9+ <main class="container mt-4" layout:fragment="content"> 10+ 11+ </main> 12+ <footer>...</footer> 13+</body> 14+</html>
Thymeleaf Layout Dialectを使い、アプリケーション全体の共通レイアウト(ヘッダー、フッター、CSSの読み込みなど)を定義しています。
xmlns:layoutでLayout Dialectの機能を使うことを宣言しています。layout:fragment="content"と指定されたmainタグの部分が、各個別ページ(一覧、詳細など)の固有コンテンツに置き換えられます。これにより、各ページで共通部分を何度も書く必要がなくなります。
変更点: 投稿一覧ページのHTML作成
/src/main/resources/templates/posts/list.html1+<html layout:decorate="layout/layout"> 2+<main layout:fragment="content"> 3+ <table class="table table-striped"> 4+ <tbody> 5+ <tr th:each="post : ${posts}"> 6+ <td> 7+ <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}"></a> 8+ </td> 9+ ... 10+ </tr> 11+ </tbody> 12+ </table> 13+</main> 14+</html>
layout:decorate="layout/layout"で共通レイアウトを適用することを指定しています。layout:fragment="content"で囲まれた部分が、レイアウトのメインコンテンツ部分に挿入されます。
th:each="post : ${posts}": Controllerから渡された投稿リスト(posts)を1件ずつ取り出し、<tr>タグ(テーブルの行)を繰り返し生成します。th:text="${post.title}":postオブジェクトのtitleプロパティの値でテキストを置き換えます。th:href="@{/posts/{id}(id=${post.id})}":postオブジェクトのIDを使って、詳細ページへの動的なURL(例:/posts/1)を生成します。
変更点: 新規投稿ページのHTML作成
/src/main/resources/templates/posts/new.html1+<html layout:decorate="layout/layout"> 2+<main layout:fragment="content"> 3+ <h1>New Post</h1> 4+ <form th:action="@{/posts}" method="post"> 5+ <div class="mb-3"> 6+ <label for="title" class="form-label">Title</label> 7+ <input type="text" id="title" name="title" class="form-control" required> 8+ </div> 9+ <div class="mb-3"> 10+ <label for="content" class="form-label">Content</label> 11+ <textarea id="content" name="content" class="form-control" rows="5" required></textarea> 12+ </div> 13+ <button type="submit" class="btn btn-success">Submit</button> 14+ </form> 15+</main> 16+</html>
新しい投稿内容を入力するためのフォーム画面です。<form>タグのth:action="@{/posts}"とmethod="post"により、このフォームの送信ボタンが押されると、入力内容が/postsというURLにPOSTメソッドで送信され、ControllerのcreatePostメソッドが処理を実行します。inputタグやtextareaタグのname属性が、Controller側で受け取るPostオブジェクトのフィールド名に対応しています。
変更点: 投稿詳細ページのHTML作成
/src/main/resources/templates/posts/detail.html1+<html layout:decorate="layout/layout"> 2+<main layout:fragment="content"> 3+ <h1 th:text="${post.title}"></h1> 4+ <p th:text="${post.content}"></p> 5+ <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary">Edit</a> 6+ <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 7+ <button type="submit" class="btn btn-danger">Delete</button> 8+ </form> 9+</main>
Controllerから渡された単一の投稿データ(post)を表示する画面です。th:text="${post.title}"のようにして、postオブジェクトのプロパティを画面に表示します。また、編集ページへのリンクや、削除を実行するためのフォーム付きボタンも配置しています。
変更点: 投稿編集ページのHTML作成
/src/main/resources/templates/posts/edit.html1+<html layout:decorate="layout/layout"> 2+<main layout:fragment="content"> 3+ <h1>Edit Post</h1> 4+ <form th:action="@{/posts/{id}(id=${post.id})}" method="post"> 5+ <div class="mb-3"> 6+ <label for="title" class="form-label">Title</label> 7+ <input type="text" id="title" name="title" class="form-control" th:value="${post.title}"> 8+ </div> 9+ <div class="mb-3"> 10+ <label for="content" class="form-label">Content</label> 11+ <textarea id="content" name="content" class="form-control" rows="5" th:text="${post.content}"></textarea> 12+ </div> 13+ <button type="submit" class="btn btn-success">Upadate</button> 14+ </form> 15+</main> 16+</html>
既存の投稿を編集するためのフォーム画面です。新規投稿画面と似ていますが、Controllerから渡された既存の投稿データ(post)をフォームの初期値として表示している点が異なります。
th:value="${post.title}":inputタグの初期値を設定します。th:text="${post.content}":textareaタグ内の初期テキストを設定します。th:action="@{/posts/{id}(id=${post.id})}": フォームの送信先URLに更新対象のIDを含めています。これにより、どの投稿を更新するのかをControllerに伝えることができます。
おわりに
今回はSpring Bootを使い、掲示板アプリの基本的なCRUD機能(登録、表示、更新、削除)を一通り実装しました。Controller、Service、Repositoryが連携してリクエスト処理からデータベース操作までを行う流れや、Spring Data JPAによって少ないコードでデータを扱える便利さを体験できたはずです。また、Thymeleafを用いてJava側で用意したデータをHTMLに埋め込み、動的に画面を生成する仕組みも学びました。ここで実装したCRUDはWebアプリケーション開発の基礎となりますので、ぜひ何度もコードを見返して理解を深めてください。