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 Data JPA ― データベース連携でTODOアプリを永続化する

0
Posted at

自己紹介

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

はじめに

前回(第4回)では、フォーム処理とBean Validationを学び、ユーザー登録フォームを作成しました。しかし、フォームから送信したデータも、第2回で作ったTODO APIのデータも、すべてメモリ内のリストに保存しているだけでした。

つまり、アプリケーションを再起動するとデータはすべて消える状態です。

第5回では、Spring Data JPAH2データベース を使って、データをデータベースに永続化する方法を学びます。

今回学ぶこと

  • 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回の TodoControllercom.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.javacom.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.javacom.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.javacom.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 に以下のクエリメソッドを追加し、動作確認してください。

  1. 著者名で検索する findByAuthor(String author)
  2. 著者名に指定文字列を含む書籍を検索する findByAuthorContaining(String keyword)
  3. 出版年が指定範囲内の書籍を検索する findByPublishedYearBetween(int startYear, int endYear)
  4. タイトルに指定文字列を含み、出版年が指定年以降の書籍を検索する 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文字

要件

  1. ProductRepositoryProductServiceProductViewController@Controller)を作成する
  2. Thymeleaf画面で以下の機能を実装する
    • 商品一覧画面(GET /products
    • 商品追加フォーム(POST /products
    • 商品削除(POST /products/{id}/delete
  3. 一覧画面にカテゴリ名での絞り込みフォームを追加する
模範解答

Product.javacom.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.javacom.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.javacom.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.javacom.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.htmlsrc/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回(予定):

  1. Servlet/JSPからの移行と環境構築
  2. コントローラとルーティング
  3. Thymeleafによるビュー
  4. フォーム処理とバリデーション
  5. 👉 Spring Data JPA(データベース連携)(本記事)
  6. RESTful API設計
  7. Spring Security(認証・認可)
  8. 例外処理とエラーハンドリング
  9. テストの書き方(JUnit + MockMvc)
  10. 総合演習:掲示板アプリをSpring Bootで再構築

参考


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