自己紹介
株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
はじめに
前回(第4回)では、フォーム処理とBean Validationを学び、ユーザー登録フォームを作成しました。しかし、フォームから送信したデータも、第2回で作ったTODO APIのデータも、すべてメモリ内のリストに保存しているだけでした。
つまり、アプリケーションを再起動するとデータはすべて消える状態です。
第5回では、Spring Data JPA と H2データベース を使って、データをデータベースに永続化する方法を学びます。
今回学ぶこと
- JPA(Jakarta Persistence API)の概要
- JDBC直接操作 → JPA → Spring Data JPA の進化の流れ
- エンティティクラスの作成(
@Entity、@Id、@Column) - リポジトリインターフェース(
JpaRepository) - サービス層の導入(
@Service、@Transactional) - 3層アーキテクチャ(Controller → Service → Repository)
- 実践例:第2回のメモリ内TODO APIをDB対応に書き換え
- Thymeleaf画面との統合
本記事のコードはすべて第1回で作成したhello-springプロジェクト(com.example.hellospringパッケージ)上で動作します。環境構築がまだの方は第1回を先にご覧ください。
1. Spring Data JPA とは
JPA の概要
JPA(Jakarta Persistence API) は、Javaオブジェクトとリレーショナルデータベースのテーブルをマッピングするための標準仕様です。
JPA はもともと Java Persistence API という名称でしたが、Java EE から Jakarta EE への移行に伴い Jakarta Persistence API に改称されました。略称の「JPA」はそのままです。
JPA を使うと、SQLを直接書かずに、Javaオブジェクトの操作を通じてデータベースを操作できます。この仕組みを ORM(Object-Relational Mapping) と呼びます。
Javaオブジェクト(Todo) ←→ テーブル(todos)
フィールド(title) ←→ カラム(title)
インスタンス ←→ 行(レコード)
JDBC → JPA → Spring Data JPA の進化
Servlet/JSP入門⑦で学んだJDBCでのDB操作を振り返り、どう進化してきたかを見ましょう。
第1世代:JDBC直接操作
// JDBC(Servlet/JSP入門⑦で学んだ方法)
Connection conn = DriverManager.getConnection(url, user, pass);
PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM todos WHERE id = ?");
pstmt.setLong(1, id);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
Todo todo = new Todo();
todo.setId(rs.getLong("id"));
todo.setTitle(rs.getString("title"));
todo.setCompleted(rs.getBoolean("completed"));
}
rs.close();
pstmt.close();
conn.close();
第2世代:JPA(EntityManager)
// JPA(EntityManagerを直接使用)
EntityManager em = entityManagerFactory.createEntityManager();
Todo todo = em.find(Todo.class, id); // SQLを書かずにオブジェクトを取得
第3世代:Spring Data JPA
// Spring Data JPA(インターフェースを定義するだけ)
public interface TodoRepository extends JpaRepository<Todo, Long> {
}
// 使う側
Optional<Todo> todo = todoRepository.findById(id);
Spring Data JPA は、JPA をさらに抽象化したものです。インターフェースを定義するだけで、CRUD操作のメソッドが自動的に使えるようになります。
Servlet/JSP の DAO パターンとの対比
Servlet/JSP入門⑦では、DAOパターンでデータベース操作を集約しました。
Servlet/JSP: Servlet(Controller) → DAO → Database
Spring Boot: Controller → Service → Repository → Database
Spring Data JPA の Repository は、DAOパターンの DAO に相当します。違いは、DAO はCRUD操作をすべて手書きしましたが、Repository はインターフェースを定義するだけで自動生成される点です。
| 比較項目 | JDBC + DAO パターン | Spring Data JPA |
|---|---|---|
| SQL | 手書き | 自動生成(必要に応じて手書きも可) |
| Connection管理 | 手動で取得・解放 | フレームワークが自動管理 |
| ResultSet → オブジェクト変換 | 手動でマッピング | 自動マッピング |
| CRUD操作 | DAOにすべて実装 | インターフェース定義のみ |
| トランザクション | 手動でcommit()/rollback()
|
@Transactionalで宣言的に管理 |
2. 環境構築
pom.xml に依存関係を追加
第1回で作成した pom.xml の <dependencies> セクションに、以下の2つを追加します。
<!-- JPA(データベースアクセス) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 Database(開発用インメモリDB) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
spring-boot-starter-data-jpa を追加するだけで、Spring Data JPA、Hibernate(JPA実装)、HikariCP(コネクションプール)がすべて含まれます。Servlet/JSPのようにJDBCドライバやコネクションプールを個別に設定する必要はありません。
H2 Database は、Java製の軽量なリレーショナルデータベースです。インメモリモード(メモリ上にDBを作成)で動作するため、追加のDBサーバーのインストールが不要です。開発・学習用に最適です。
application.properties の設定
src/main/resources/application.properties に以下を追加します。
# H2データベースの接続設定
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# H2コンソール(ブラウザからDBを確認するツール)
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.datasource.url |
jdbc:h2:mem:testdb |
インメモリモードでDB名testdbを使用 |
spring.datasource.username |
sa |
H2のデフォルトユーザー名 |
spring.h2.console.enabled |
true |
ブラウザからH2コンソールにアクセス可能にする |
spring.jpa.hibernate.ddl-auto |
update |
エンティティの定義に基づいてテーブルを自動作成・更新する |
spring.jpa.show-sql |
true |
実行されたSQLをコンソールに表示する(学習時に便利) |
spring.jpa.properties.hibernate.format_sql |
true |
表示するSQLを見やすく整形する |
spring.jpa.hibernate.ddl-auto=update は開発時に便利ですが、本番環境では使わないでください。本番では validate(スキーマ検証のみ)または none(何もしない)を設定し、Flyway や Liquibase などのマイグレーションツールでスキーマを管理します。
H2コンソールの使い方
アプリケーションを起動した状態で、ブラウザから http://localhost:8080/h2-console にアクセスします。
以下の情報を入力して Connect をクリックします。
| 項目 | 値 |
|---|---|
| JDBC URL | jdbc:h2:mem:testdb |
| User Name | sa |
| Password | (空欄のまま) |
接続すると、テーブルの内容をSQLで確認できます。学習中に「データが正しく保存されているか」を確認する際に活用してください。
3. エンティティクラスの作成
エンティティとは
エンティティは、データベースのテーブルに対応するJavaクラスです。JPA のアノテーションを付けることで、クラスとテーブルのマッピングが定義されます。
Servlet/JSP での CREATE TABLE との対比
Servlet/JSP入門⑦では、まずSQLでテーブルを作成し、それに対応するJavaBeanを手動で作っていました。
-- Servlet/JSP: 手動でCREATE TABLE
CREATE TABLE todos (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(100) NOT NULL,
completed BOOLEAN DEFAULT FALSE
);
Spring Data JPA では、Javaクラスを定義するだけでテーブルが自動生成されます(ddl-auto=updateの場合)。
Todo エンティティ
src/main/java/com/example/hellospring/entity/Todo.java を作成します。
package com.example.hellospring.entity;
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 = "todos")
public class Todo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(nullable = false)
private boolean completed;
// JPAはデフォルトコンストラクタを必要とする
public Todo() {
}
public Todo(String title, boolean completed) {
this.title = title;
this.completed = completed;
}
// getter
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public boolean isCompleted() {
return completed;
}
// setter
public void setId(Long id) {
this.id = id;
}
public void setTitle(String title) {
this.title = title;
}
public void setCompleted(boolean completed) {
this.completed = completed;
}
}
アノテーションの解説
| アノテーション | 説明 |
|---|---|
@Entity |
このクラスがJPAエンティティ(テーブルに対応するクラス)であることを示す |
@Table(name = "todos") |
対応するテーブル名を指定する。省略するとクラス名がテーブル名になる |
@Id |
主キーを示す |
@GeneratedValue(strategy = GenerationType.IDENTITY) |
主キーの値をデータベースの自動採番(AUTO_INCREMENT)に任せる |
@Column(nullable = false, length = 100) |
カラムの制約を指定する。nullable = falseはNOT NULL制約、length = 100は最大長 |
@Table を省略した場合、クラス名 Todo がそのままテーブル名になります。テーブル名を明示的に指定する方が可読性が高いため、本記事では @Table を付けています。
JPAのエンティティクラスには引数なしのデフォルトコンストラクタが必須です。JPA(Hibernate)がリフレクションでインスタンスを生成する際に使用します。publicまたはprotectedで宣言してください。
第2回の Todo クラスとの変更点
第2回で作成したメモリ内のTodoクラスとの主な違いは以下の通りです。
| 項目 | 第2回(メモリ内) | 第5回(JPA) |
|---|---|---|
| パッケージ | com.example.hellospring |
com.example.hellospring.entity |
| IDの型 | int |
Long(JPAでは参照型が推奨) |
| ID管理 |
AtomicIntegerで手動採番 |
@GeneratedValueでDB側が自動採番 |
| テーブル定義 | なし |
@Entity + @Table + @Column
|
| コンストラクタ | 全フィールド引数 | デフォルト + 部分引数 |
4. リポジトリインターフェース
JpaRepository を継承するだけ
src/main/java/com/example/hellospring/repository/TodoRepository.java を作成します。
package com.example.hellospring.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import com.example.hellospring.entity.Todo;
public interface TodoRepository extends JpaRepository<Todo, Long> {
// メソッド名からSQLが自動生成される(クエリメソッド)
List<Todo> findByCompleted(boolean completed);
// タイトルに指定文字列を含むTodoを検索
List<Todo> findByTitleContaining(String keyword);
// @Queryによるカスタムクエリ(JPQL)
@Query("SELECT t FROM Todo t WHERE t.completed = false ORDER BY t.id DESC")
List<Todo> findIncompleteTodos();
}
たったこれだけで、以下のメソッドがすべて使えるようになります。
JpaRepository が提供する主なメソッド
| メソッド | 説明 | 対応するSQL(イメージ) |
|---|---|---|
save(entity) |
保存(新規作成 or 更新) |
INSERT INTO ... / UPDATE ...
|
findById(id) |
IDで1件取得 | SELECT * FROM todos WHERE id = ? |
findAll() |
全件取得 | SELECT * FROM todos |
deleteById(id) |
IDで1件削除 | DELETE FROM todos WHERE id = ? |
count() |
件数を取得 | SELECT COUNT(*) FROM todos |
existsById(id) |
指定IDが存在するか | SELECT COUNT(*) FROM todos WHERE id = ? |
save() メソッドは、エンティティのIDが null の場合は INSERT(新規作成)、IDが既に設定されている場合は UPDATE(更新)として動作します。新規作成と更新を1つのメソッドで兼ねています。
クエリメソッド ― メソッド名からSQLを自動生成
Spring Data JPA の強力な機能の1つがクエリメソッドです。メソッド名の命名規則に従うだけで、対応するSQLが自動生成されます。
// メソッド名 → 自動生成されるSQL(イメージ)
findByCompleted(boolean completed)
// → SELECT * FROM todos WHERE completed = ?
findByTitleContaining(String keyword)
// → SELECT * FROM todos WHERE title LIKE '%keyword%'
findByTitleAndCompleted(String title, boolean completed)
// → SELECT * FROM todos WHERE title = ? AND completed = ?
クエリメソッドの命名規則は以下の通りです。
| キーワード | 例 | 意味 |
|---|---|---|
findBy |
findByTitle(String title) |
WHERE title = ? |
Containing |
findByTitleContaining(String kw) |
WHERE title LIKE '%kw%' |
StartingWith |
findByTitleStartingWith(String prefix) |
WHERE title LIKE 'prefix%' |
LessThan |
findByPriceLessThan(int price) |
WHERE price < ? |
Between |
findByAgeBetween(int min, int max) |
WHERE age BETWEEN ? AND ? |
OrderBy |
findByCompletedOrderByIdDesc(boolean c) |
WHERE completed = ? ORDER BY id DESC |
And / Or
|
findByTitleAndCompleted(...) |
WHERE title = ? AND completed = ? |
@Query によるカスタムクエリ
メソッド名では表現しにくい複雑なクエリは、@Query アノテーションで JPQL を直接記述できます。
// JPQL(Java Persistence Query Language)
@Query("SELECT t FROM Todo t WHERE t.completed = false ORDER BY t.id DESC")
List<Todo> findIncompleteTodos();
JPQL はSQLに似ていますが、テーブル名ではなくエンティティ名、カラム名ではなくフィールド名を使って記述します。
ネイティブSQL(データベース固有のSQL)を使いたい場合は nativeQuery = true を指定します。
@Query(value = "SELECT * FROM todos WHERE completed = false ORDER BY id DESC",
nativeQuery = true)
List<Todo> findIncompleteTodosNative();
Servlet/JSP の DAO との対比
Servlet/JSP入門⑦の UserDAO と、Spring Data JPA の Repository を比較します。
UserDAO(JDBC)― 全件取得だけで約20行
public List<User> findAll() {
List<User> users = new ArrayList<>();
String sql = "SELECT id, name, email, age, created_at FROM users ORDER BY id";
try (Connection conn = getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
User user = new User();
user.setId(rs.getInt("id"));
user.setName(rs.getString("name"));
user.setEmail(rs.getString("email"));
user.setAge(rs.getInt("age"));
user.setCreatedAt(rs.getTimestamp("created_at"));
users.add(user);
}
} catch (SQLException e) {
throw new RuntimeException("ユーザー一覧の取得に失敗しました", e);
}
return users;
}
TodoRepository(Spring Data JPA)― 0行(インターフェース定義のみ)
public interface TodoRepository extends JpaRepository<Todo, Long> {
// findAll() はJpaRepositoryが提供するため、宣言すら不要
}
CRUD操作のコードが数十行から0行になりました。
5. サービス層の導入
Controller → Service → Repository の3層構造
ここまでのシリーズでは、Controller が直接データを操作していました。Spring Boot の実務では、Controller → Service → Repository の3層構造を採用します。
┌──────────────────┐
│ Controller │ リクエストの受付・レスポンスの返却
│ (Presentation) │ ビジネスロジックは書かない
└────────┬─────────┘
↓
┌──────────────────┐
│ Service │ ビジネスロジック(業務処理)
│ (Business Logic) │ トランザクション管理
└────────┬─────────┘
↓
┌──────────────────┐
│ Repository │ データベースアクセス
│ (Data Access) │ CRUD操作
└────────┬─────────┘
↓
┌──────────────────┐
│ Database │
└──────────────────┘
なぜサービス層が必要か
Controller に直接ビジネスロジックを書くと、以下の問題が起きます。
- 再利用できない:同じロジックをREST APIとThymeleaf画面の両方で使いたい場合、コードが重複する
- テストしにくい:HTTPリクエストを模擬しないとロジックのテストができない
- 責務が不明確:Controller が肥大化し、どこに何が書いてあるか分からなくなる
サービス層を導入することで、ビジネスロジックを1か所に集約し、Controller からも Thymeleaf画面からも呼び出せるようになります。
TodoService の作成
src/main/java/com/example/hellospring/service/TodoService.java を作成します。
package com.example.hellospring.service;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.repository.TodoRepository;
@Service
public class TodoService {
private final TodoRepository todoRepository;
// コンストラクタインジェクション
public TodoService(TodoRepository todoRepository) {
this.todoRepository = todoRepository;
}
// 全件取得
public List<Todo> findAll() {
return todoRepository.findAll();
}
// IDで1件取得
public Todo findById(Long id) {
return todoRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Todo not found: id=" + id));
}
// 新規作成
@Transactional
public Todo create(String title) {
Todo todo = new Todo(title, false);
return todoRepository.save(todo);
}
// 更新
@Transactional
public Todo update(Long id, String title, boolean completed) {
Todo todo = findById(id);
todo.setTitle(title);
todo.setCompleted(completed);
return todoRepository.save(todo);
}
// 削除
@Transactional
public void delete(Long id) {
if (!todoRepository.existsById(id)) {
throw new RuntimeException("Todo not found: id=" + id);
}
todoRepository.deleteById(id);
}
// 未完了のTodoを取得
public List<Todo> findIncomplete() {
return todoRepository.findByCompleted(false);
}
// タイトルで検索
public List<Todo> searchByTitle(String keyword) {
return todoRepository.findByTitleContaining(keyword);
}
}
アノテーションの解説
@Service
@Service
public class TodoService {
このクラスがサービス層のコンポーネントであることを示します。Spring が自動的にインスタンスを生成し、DI(依存性の注入)の対象にします。機能的には @Component と同じですが、「このクラスはビジネスロジックを担当する」 という意図を明確にする役割があります。
@Transactional
@Transactional
public Todo create(String title) {
このメソッドをトランザクション内で実行することを宣言します。メソッド内の処理がすべて成功すればコミット、例外が発生すればロールバックされます。
Servlet/JSP では以下のように手動で管理していたトランザクションが、アノテーション1つで済みます。
// Servlet/JSP: 手動のトランザクション管理
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false);
// 処理...
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.close();
}
コンストラクタインジェクション
private final TodoRepository todoRepository;
public TodoService(TodoRepository todoRepository) {
this.todoRepository = todoRepository;
}
Spring が TodoRepository のインスタンスを自動的にこのコンストラクタに渡します。@Autowired アノテーションを書くこともできますが、コンストラクタが1つだけの場合は省略可能です。final を付けることで、フィールドが後から書き換えられないことを保証しています。
6. 実践例: TODO アプリを DB に対応させる
第2回で作成したメモリ内の TodoController(/api/todos)を、Spring Data JPA を使ったDB対応版に書き換えます。
第2回の TodoController(com.example.hellospring パッケージ)は削除するか、クラス名を変更してください。同じパス /api/todos にマッピングされるコントローラーが2つあると起動時にエラーになります。本記事では com.example.hellospring.controller パッケージに新しいコントローラーを作成します。
プロジェクト構造
src/main/java/com/example/hellospring/
├── HelloSpringApplication.java ← メインクラス(既存)
├── controller/
│ └── TodoApiController.java ← REST APIコントローラー(新規)
├── entity/
│ └── Todo.java ← エンティティ(新規)
├── repository/
│ └── TodoRepository.java ← リポジトリ(新規)
└── service/
└── TodoService.java ← サービス(新規)
リクエスト用 DTO
クライアントからのリクエストを受け取るためのDTOです。第2回と同様に record を使います。
src/main/java/com/example/hellospring/controller/TodoRequest.java
package com.example.hellospring.controller;
public record TodoRequest(String title, boolean completed) {
}
TodoApiController(REST API)
src/main/java/com/example/hellospring/controller/TodoApiController.java を作成します。
package com.example.hellospring.controller;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.service.TodoService;
@RestController
@RequestMapping("/api/todos")
public class TodoApiController {
private final TodoService todoService;
public TodoApiController(TodoService todoService) {
this.todoService = todoService;
}
// GET /api/todos ― 全件取得
@GetMapping
public ResponseEntity<List<Todo>> getAll() {
return ResponseEntity.ok(todoService.findAll());
}
// GET /api/todos/{id} ― 個別取得
@GetMapping("/{id}")
public ResponseEntity<Todo> getById(@PathVariable Long id) {
Todo todo = todoService.findById(id);
return ResponseEntity.ok(todo);
}
// POST /api/todos ― 新規作成
@PostMapping
public ResponseEntity<Todo> create(@RequestBody TodoRequest request) {
Todo created = todoService.create(request.title());
URI location = URI.create("/api/todos/" + created.getId());
return ResponseEntity.created(location).body(created);
}
// PUT /api/todos/{id} ― 更新
@PutMapping("/{id}")
public ResponseEntity<Todo> update(@PathVariable Long id,
@RequestBody TodoRequest request) {
Todo updated = todoService.update(id, request.title(), request.completed());
return ResponseEntity.ok(updated);
}
// DELETE /api/todos/{id} ― 削除
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
todoService.delete(id);
return ResponseEntity.noContent().build();
}
}
第2回のコントローラーとの比較
| 項目 | 第2回(メモリ内) | 第5回(DB対応) |
|---|---|---|
| データの保存先 |
List<Todo>(メモリ) |
H2 Database |
| ID管理 |
AtomicIntegerで手動管理 |
@GeneratedValueでDB側が自動管理 |
| データ操作 |
todos.add(), todos.stream() 等 |
TodoService に委譲 |
| 再起動後のデータ | 消える | 保持される(永続化) |
| トランザクション | なし |
@Transactional で管理 |
Controller自体のAPIインターフェース(URL、HTTPメソッド、レスポンス形式)は変わっていません。データの保存先が変わっただけです。これが3層アーキテクチャの利点です。
curl での動作確認
アプリケーションを起動して確認します。
./mvnw spring-boot:run
1. TODO を作成する(POST)
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Spring Data JPAを学ぶ", "completed": false}'
{"id":1,"title":"Spring Data JPAを学ぶ","completed":false}
2. もう1件作成する
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "H2コンソールを試す", "completed": false}'
{"id":2,"title":"H2コンソールを試す","completed":false}
3. 全件取得する(GET)
curl http://localhost:8080/api/todos
[
{"id":1,"title":"Spring Data JPAを学ぶ","completed":false},
{"id":2,"title":"H2コンソールを試す","completed":false}
]
4. 1件更新する(PUT)
curl -X PUT http://localhost:8080/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"title": "Spring Data JPAを学ぶ", "completed": true}'
{"id":1,"title":"Spring Data JPAを学ぶ","completed":true}
5. 1件削除する(DELETE)
curl -X DELETE http://localhost:8080/api/todos/2
レスポンスボディなし(ステータスコード 204 No Content)。
6. H2コンソールでデータを確認する
ブラウザで http://localhost:8080/h2-console にアクセスし、以下のSQLを実行します。
SELECT * FROM TODOS;
IDが1のTODO(completed = TRUE)だけが残っていることが確認できます。
コンソールに spring.jpa.show-sql=true のおかげでSQLが表示されます。例えば findAll() を呼ぶと以下のようなSQLが出力されます。
Hibernate:
select
t1_0.id,
t1_0.completed,
t1_0.title
from
todos t1_0
学習中はこのSQLログを確認することで、Spring Data JPA が裏で何をしているかを理解できます。
7. Thymeleaf 画面との統合
REST API だけでなく、ブラウザから操作できるThymeleaf画面も作成します。
画面用コントローラー
src/main/java/com/example/hellospring/controller/TodoViewController.java を作成します。
package com.example.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.example.hellospring.service.TodoService;
@Controller
@RequestMapping("/todos")
public class TodoViewController {
private final TodoService todoService;
public TodoViewController(TodoService todoService) {
this.todoService = todoService;
}
// TODO一覧画面
@GetMapping
public String list(Model model) {
model.addAttribute("todos", todoService.findAll());
return "todo/list";
}
// TODO追加(フォームからPOST)
@PostMapping
public String create(@RequestParam String title,
RedirectAttributes redirectAttributes) {
todoService.create(title);
redirectAttributes.addFlashAttribute("message", "TODOを追加しました");
return "redirect:/todos";
}
// TODO削除
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id,
RedirectAttributes redirectAttributes) {
todoService.delete(id);
redirectAttributes.addFlashAttribute("message", "TODOを削除しました");
return "redirect:/todos";
}
// TODO完了/未完了の切り替え
@PostMapping("/{id}/toggle")
public String toggle(@PathVariable Long id,
RedirectAttributes redirectAttributes) {
var todo = todoService.findById(id);
todoService.update(id, todo.getTitle(), !todo.isCompleted());
redirectAttributes.addFlashAttribute("message", "ステータスを更新しました");
return "redirect:/todos";
}
}
REST API用のコントローラー(TodoApiController)と画面用のコントローラー(TodoViewController)は、パスが異なるため共存できます。
- REST API:
/api/todos(@RestController) - 画面:
/todos(@Controller)
どちらも同じ TodoService を利用しています。サービス層を分離した恩恵がここで活きます。
Thymeleaf テンプレート
src/main/resources/templates/todo/list.html を作成します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>TODO リスト</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
h1 { color: #333; }
.message { background: #d4edda; color: #155724; padding: 10px; border-radius: 4px; margin-bottom: 20px; }
.add-form { display: flex; gap: 8px; margin-bottom: 20px; }
.add-form input[type="text"] { flex: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
.add-form button { padding: 8px 16px; background: #007bff; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.todo-list { list-style: none; padding: 0; }
.todo-item { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #eee; }
.todo-item.completed span { text-decoration: line-through; color: #999; }
.btn-toggle { padding: 4px 8px; font-size: 12px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #fff; }
.btn-delete { padding: 4px 8px; font-size: 12px; cursor: pointer; border: none; border-radius: 4px; background: #dc3545; color: #fff; }
.empty { color: #999; font-style: italic; }
</style>
</head>
<body>
<h1>TODO リスト</h1>
<!-- フラッシュメッセージ -->
<div class="message" th:if="${message}" th:text="${message}"></div>
<!-- 追加フォーム -->
<form class="add-form" th:action="@{/todos}" method="post">
<input type="text" name="title" placeholder="新しいTODOを入力..." required>
<button type="submit">追加</button>
</form>
<!-- TODO一覧 -->
<ul class="todo-list">
<li th:each="todo : ${todos}"
th:classappend="${todo.completed} ? 'todo-item completed' : 'todo-item'">
<!-- 完了/未完了の切り替え -->
<form th:action="@{/todos/{id}/toggle(id=${todo.id})}" method="post" style="display:inline;">
<button type="submit" class="btn-toggle"
th:text="${todo.completed} ? '完了' : '未完了'"></button>
</form>
<!-- タイトル -->
<span th:text="${todo.title}" style="flex:1;"></span>
<!-- 削除ボタン -->
<form th:action="@{/todos/{id}/delete(id=${todo.id})}" method="post" style="display:inline;">
<button type="submit" class="btn-delete">削除</button>
</form>
</li>
</ul>
<p class="empty" th:if="${#lists.isEmpty(todos)}">TODOはまだありません。</p>
</body>
</html>
アプリケーションを起動して http://localhost:8080/todos にアクセスすると、TODO一覧画面が表示されます。フォームからTODOを追加し、完了/未完了の切り替えや削除を試してみてください。
練習問題
問題1:書籍管理 API ⭐
以下の仕様で書籍管理のエンティティ、リポジトリ、REST APIを作成してください。
Book エンティティ
| フィールド | 型 | 制約 |
|---|---|---|
| id | Long | 主キー、自動採番 |
| title | String | NOT NULL、最大200文字 |
| author | String | NOT NULL、最大100文字 |
| publishedYear | int | ― |
REST API
| メソッド | パス | 動作 |
|---|---|---|
| GET | /api/books |
全件取得 |
| GET | /api/books/{id} |
1件取得 |
| POST | /api/books |
新規作成 |
模範解答
Book.java(com.example.hellospring.entityパッケージ)
package com.example.hellospring.entity;
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 = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, length = 100)
private String author;
@Column(name = "published_year")
private int publishedYear;
public Book() {
}
public Book(String title, String author, int publishedYear) {
this.title = title;
this.author = author;
this.publishedYear = publishedYear;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public int getPublishedYear() {
return publishedYear;
}
public void setPublishedYear(int publishedYear) {
this.publishedYear = publishedYear;
}
}
BookRepository.java(com.example.hellospring.repositoryパッケージ)
package com.example.hellospring.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.hellospring.entity.Book;
public interface BookRepository extends JpaRepository<Book, Long> {
}
BookApiController.java(com.example.hellospring.controllerパッケージ)
package com.example.hellospring.controller;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.hellospring.entity.Book;
import com.example.hellospring.repository.BookRepository;
@RestController
@RequestMapping("/api/books")
public class BookApiController {
private final BookRepository bookRepository;
public BookApiController(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@GetMapping
public ResponseEntity<List<Book>> getAll() {
return ResponseEntity.ok(bookRepository.findAll());
}
@GetMapping("/{id}")
public ResponseEntity<Book> getById(@PathVariable Long id) {
return bookRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Book> create(@RequestBody Book book) {
Book saved = bookRepository.save(book);
URI location = URI.create("/api/books/" + saved.getId());
return ResponseEntity.created(location).body(saved);
}
}
動作確認:
# 書籍を作成
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title": "Effective Java", "author": "Joshua Bloch", "publishedYear": 2018}'
# 全件取得
curl http://localhost:8080/api/books
この問題ではService層を省略し、ControllerからRepositoryを直接呼んでいます。小規模なアプリや学習目的ではこの構成でも問題ありませんが、実務ではService層を挟むのが一般的です。
問題2:クエリメソッド ⭐⭐
問題1の BookRepository に以下のクエリメソッドを追加し、動作確認してください。
- 著者名で検索する
findByAuthor(String author) - 著者名に指定文字列を含む書籍を検索する
findByAuthorContaining(String keyword) - 出版年が指定範囲内の書籍を検索する
findByPublishedYearBetween(int startYear, int endYear) - タイトルに指定文字列を含み、出版年が指定年以降の書籍を検索する
findByTitleContainingAndPublishedYearGreaterThanEqual(String keyword, int year)
模範解答
BookRepository.java
package com.example.hellospring.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.hellospring.entity.Book;
public interface BookRepository extends JpaRepository<Book, Long> {
// 著者名で完全一致検索
List<Book> findByAuthor(String author);
// 著者名の部分一致検索
List<Book> findByAuthorContaining(String keyword);
// 出版年の範囲検索
List<Book> findByPublishedYearBetween(int startYear, int endYear);
// タイトル部分一致 AND 出版年以降
List<Book> findByTitleContainingAndPublishedYearGreaterThanEqual(
String keyword, int year);
}
BookApiController.java に検索エンドポイントを追加(抜粋):
// GET /api/books/search?author=Joshua
@GetMapping("/search")
public ResponseEntity<List<Book>> searchByAuthor(
@RequestParam String author) {
return ResponseEntity.ok(bookRepository.findByAuthorContaining(author));
}
// GET /api/books/search/year?start=2010&end=2020
@GetMapping("/search/year")
public ResponseEntity<List<Book>> searchByYear(
@RequestParam int start,
@RequestParam int end) {
return ResponseEntity.ok(bookRepository.findByPublishedYearBetween(start, end));
}
動作確認:
# テストデータを登録
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title": "Effective Java", "author": "Joshua Bloch", "publishedYear": 2018}'
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title": "Java Concurrency in Practice", "author": "Brian Goetz", "publishedYear": 2006}'
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title": "Clean Code", "author": "Robert C. Martin", "publishedYear": 2008}'
# 著者名で検索
curl "http://localhost:8080/api/books/search?author=Joshua"
# 出版年の範囲検索
curl "http://localhost:8080/api/books/search/year?start=2005&end=2010"
問題3:商品管理アプリ ⭐⭐⭐
以下の仕様で、3層アーキテクチャ(Controller → Service → Repository)の商品管理アプリを作成してください。
Product エンティティ
| フィールド | 型 | 制約 |
|---|---|---|
| id | Long | 主キー、自動採番 |
| name | String | NOT NULL、最大100文字 |
| price | int | NOT NULL |
| category | String | 最大50文字 |
要件
-
ProductRepository、ProductService、ProductViewController(@Controller)を作成する - Thymeleaf画面で以下の機能を実装する
- 商品一覧画面(
GET /products) - 商品追加フォーム(
POST /products) - 商品削除(
POST /products/{id}/delete)
- 商品一覧画面(
- 一覧画面にカテゴリ名での絞り込みフォームを追加する
模範解答
Product.java(com.example.hellospring.entityパッケージ)
package com.example.hellospring.entity;
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 = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false)
private int price;
@Column(length = 50)
private String category;
public Product() {
}
public Product(String name, int price, String category) {
this.name = name;
this.price = price;
this.category = category;
}
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 int getPrice() {
return price;
}
public void setPrice(int price) {
this.price = price;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
}
ProductRepository.java(com.example.hellospring.repositoryパッケージ)
package com.example.hellospring.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.hellospring.entity.Product;
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByCategory(String category);
}
ProductService.java(com.example.hellospring.serviceパッケージ)
package com.example.hellospring.service;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.hellospring.entity.Product;
import com.example.hellospring.repository.ProductRepository;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<Product> findAll() {
return productRepository.findAll();
}
public List<Product> findByCategory(String category) {
return productRepository.findByCategory(category);
}
@Transactional
public Product create(String name, int price, String category) {
Product product = new Product(name, price, category);
return productRepository.save(product);
}
@Transactional
public void delete(Long id) {
productRepository.deleteById(id);
}
}
ProductViewController.java(com.example.hellospring.controllerパッケージ)
package com.example.hellospring.controller;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.example.hellospring.entity.Product;
import com.example.hellospring.service.ProductService;
@Controller
@RequestMapping("/products")
public class ProductViewController {
private final ProductService productService;
public ProductViewController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public String list(@RequestParam(required = false) String category,
Model model) {
List<Product> products;
if (category != null && !category.isBlank()) {
products = productService.findByCategory(category);
model.addAttribute("selectedCategory", category);
} else {
products = productService.findAll();
}
model.addAttribute("products", products);
return "product/list";
}
@PostMapping
public String create(@RequestParam String name,
@RequestParam int price,
@RequestParam(required = false, defaultValue = "") String category,
RedirectAttributes redirectAttributes) {
productService.create(name, price, category);
redirectAttributes.addFlashAttribute("message", "商品を追加しました");
return "redirect:/products";
}
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id,
RedirectAttributes redirectAttributes) {
productService.delete(id);
redirectAttributes.addFlashAttribute("message", "商品を削除しました");
return "redirect:/products";
}
}
product/list.html(src/main/resources/templates/product/list.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: 700px; margin: 40px auto; padding: 0 20px; }
h1 { color: #333; }
.message { background: #d4edda; color: #155724; padding: 10px; border-radius: 4px; margin-bottom: 20px; }
.add-form { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.add-form input, .add-form button { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
.add-form button { background: #007bff; color: #fff; border: none; cursor: pointer; }
.filter-form { margin-bottom: 20px; display: flex; gap: 8px; }
.filter-form input { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
.filter-form button { padding: 8px 16px; border: 1px solid #ccc; border-radius: 4px; background: #f8f9fa; cursor: pointer; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px 12px; border-bottom: 1px solid #eee; text-align: left; }
th { background: #f8f9fa; }
.btn-delete { padding: 4px 8px; font-size: 12px; border: none; border-radius: 4px; background: #dc3545; color: #fff; cursor: pointer; }
.empty { color: #999; font-style: italic; }
</style>
</head>
<body>
<h1>商品管理</h1>
<div class="message" th:if="${message}" th:text="${message}"></div>
<!-- 商品追加フォーム -->
<form class="add-form" th:action="@{/products}" method="post">
<input type="text" name="name" placeholder="商品名" required>
<input type="number" name="price" placeholder="価格" required min="0">
<input type="text" name="category" placeholder="カテゴリ">
<button type="submit">追加</button>
</form>
<!-- カテゴリ絞り込み -->
<form class="filter-form" th:action="@{/products}" method="get">
<input type="text" name="category" placeholder="カテゴリで絞り込み"
th:value="${selectedCategory}">
<button type="submit">絞り込み</button>
<a th:href="@{/products}" style="padding:8px;">クリア</a>
</form>
<!-- 商品一覧 -->
<table th:if="${!#lists.isEmpty(products)}">
<thead>
<tr>
<th>ID</th>
<th>商品名</th>
<th>価格</th>
<th>カテゴリ</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="product : ${products}">
<td th:text="${product.id}"></td>
<td th:text="${product.name}"></td>
<td th:text="'¥' + ${#numbers.formatInteger(product.price, 1, 'COMMA')}"></td>
<td th:text="${product.category}"></td>
<td>
<form th:action="@{/products/{id}/delete(id=${product.id})}" method="post" style="display:inline;">
<button type="submit" class="btn-delete">削除</button>
</form>
</td>
</tr>
</tbody>
</table>
<p class="empty" th:if="${#lists.isEmpty(products)}">商品はまだ登録されていません。</p>
</body>
</html>
まとめ
JDBC vs Spring Data JPA の対比表
| 比較項目 | JDBC(Servlet/JSP) | Spring Data JPA(Spring Boot) |
|---|---|---|
| テーブル定義 |
CREATE TABLE を手書き |
@Entity からテーブル自動生成 |
| 接続管理 |
DriverManager.getConnection() + close()
|
フレームワークが自動管理 |
| CRUD操作 | SQL手書き + PreparedStatement + ResultSet
|
JpaRepository のメソッドを呼ぶだけ |
| オブジェクト変換 |
rs.getString() 等で1フィールドずつ手動マッピング |
自動マッピング(ORM) |
| トランザクション |
conn.setAutoCommit(false) + commit() / rollback()
|
@Transactional |
| 検索クエリ | SQL手書き | クエリメソッド(メソッド名から自動生成) |
| 設計パターン | DAO パターン | Repository パターン |
| コード量(CRUD全体) | 約100~200行 | 約10行(インターフェース定義のみ) |
3層アーキテクチャの整理
┌─────────────────────────────────────────────────┐
│ Controller層(Presentation) │
│ - リクエストの受付・レスポンスの返却 │
│ - @RestController(API)/ @Controller(画面) │
│ - ビジネスロジックは書かない │
├─────────────────────────────────────────────────┤
│ Service層(Business Logic) │
│ - ビジネスロジックの集約 │
│ - @Service / @Transactional │
│ - 複数のRepositoryを組み合わせた処理 │
├─────────────────────────────────────────────────┤
│ Repository層(Data Access) │
│ - データベースアクセス │
│ - JpaRepository を継承したインターフェース │
│ - クエリメソッド / @Query │
└─────────────────────────────────────────────────┘
次回予告
次回(第6回)は RESTful API設計 を学びます。REST の設計原則、ステータスコードの使い分け、例外ハンドリング、DTO パターンなどを扱い、今回作成した TODO API をより実践的な形にリファクタリングします。
Spring Boot入門シリーズ 全10回(予定):
- Servlet/JSPからの移行と環境構築
- コントローラとルーティング
- Thymeleafによるビュー
- フォーム処理とバリデーション
- 👉 Spring Data JPA(データベース連携)(本記事)
- RESTful API設計
- Spring Security(認証・認可)
- 例外処理とエラーハンドリング
- テストの書き方(JUnit + MockMvc)
- 総合演習:掲示板アプリをSpring Bootで再構築
参考
- Spring Data JPA 公式リファレンス
- Spring Boot 公式ドキュメント - データアクセス
- Spring Boot 公式ガイド - Accessing Data with JPA
- Jakarta Persistence 仕様(Eclipse Foundation)
- H2 Database Engine
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!