3
3

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 第5回 ~エンタープライズアプリケーションの実装パターン~ (Spring Data JPAの基礎とリポジトリパターン)

Posted at

image.png

前回はSpring MVCを使った実践的なWebアプリケーション構築について解説しました。今回は、エンタープライズアプリケーション1に不可欠なデータアクセス層の実装について、Spring Data JPAの基礎を中心に解説します。

Spring Data JPAとは

Spring Data JPAは、JPA2ベースのリポジトリ層3を簡単に実装するためのフレームワークです。ボイラープレートコード4を大幅に削減し、データアクセス層の実装を効率化します。

データベースを使うアプリケーションでは、データの保存や取得のために多くのコードを書く必要があります。Spring Data JPAは、これらの定型的なコードを自動的に生成してくれるため、開発者は「商品を保存する」「IDで商品を探す」といった基本的な操作のコードを自分で書く必要がなくなります。

主な特徴

  • リポジトリの自動実装: インターフェースを定義するだけで基本的なCRUD5操作が可能
  • メソッド名からクエリを自動生成: findByNameのようなメソッド名からSQL6を生成
  • ページネーション7・ソート機能: 大量データの効率的な処理
  • カスタムクエリのサポート: 複雑なクエリも@Queryアノテーションで定義可能

プロジェクトのセットアップ

まず、Spring Data JPAを使うために必要な依存関係を追加します。

<!-- pom.xml -->
<dependencies>
    <!-- Spring Boot Starter Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Spring Boot Starter Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    
    <!-- MySQL Driver -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    
    <!-- H2 Database (開発・テスト用) -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    
    <!-- Spring Boot Starter Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

アプリケーション設定

# application.yml
spring:
  datasource:
    # 開発環境ではH2インメモリデータベースを使用
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password: 
    
  jpa:
    # Hibernateの設定
    hibernate:
      ddl-auto: create-drop  # 起動時にテーブルを作成、終了時に削除
    show-sql: true  # 実行されるSQLをコンソールに表示
    properties:
      hibernate:
        format_sql: true  # SQLを見やすく整形
        
  h2:
    console:
      enabled: true  # H2コンソールを有効化(http://localhost:8080/h2-console)

エンティティクラスの設計

JPAエンティティ8は、データベースのテーブルとマッピングされるJavaクラスです。実際の商品管理システムを例に、詳しく見ていきましょう。

エンティティクラスは、データベースのテーブルに保存するデータの設計図のようなものです。例えば「商品」テーブルには商品名、価格、在庫数などの情報が必要ですが、これらをJavaのクラスとして定義します。

package com.example.demo.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity  // このクラスがJPAエンティティであることを示す
@Table(name = "products")  // 対応するテーブル名を指定
@EntityListeners(AuditingEntityListener.class)  // 監査機能を有効化
@Getter  // Lombokでgetterメソッドを自動生成
@Setter  // Lombokでsetterメソッドを自動生成
public class Product {
    
    @Id  // 主キー(各商品を一意に識別するID)であることを示す
    @GeneratedValue(strategy = GenerationType.IDENTITY)  // DBの自動採番を使用
    private Long id;
    
    @Column(nullable = false, length = 100)  // NOT NULL制約、最大100文字
    private String name;  // 商品名
    
    @Column(nullable = false)  // NOT NULL制約(必須項目)
    private Integer price;  // 価格
    
    @Column(length = 500)  // 最大500文字(NULLは許可)
    private String description;  // 商品説明(任意項目)
    
    @Column(nullable = false)
    private Integer stockQuantity = 0;  // 在庫数(デフォルト値を0に設定)
    
    // 楽観的ロック用のバージョン番号
    // 更新時に自動的にインクリメントされ、同時更新を検出
    // 例:AさんとBさんが同時に在庫を更新しようとした時の衝突を防ぐ
    @Version
    private Long version;
    
    // 作成日時を自動記録(監査機能)
    // いつこの商品が登録されたかを自動的に記録
    @CreatedDate
    @Column(nullable = false, updatable = false)  // 更新不可
    private LocalDateTime createdAt;
    
    // 更新日時を自動記録(監査機能)
    // いつこの商品情報が最後に更新されたかを自動的に記録
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    // カテゴリとの多対1の関係
    // 複数の商品が1つのカテゴリに属する(例:「書籍」カテゴリに複数の本)
    // FetchType.LAZY: 必要になるまでカテゴリをロードしない(パフォーマンス対策)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")  // 外部キーカラム名
    private Category category;
    
    // ビジネスメソッドの例:在庫を減らす
    // エンティティに業務ロジックを含めることで、データの整合性を保つ
    public void decrementStock(int quantity) {
        if (this.stockQuantity < quantity) {
            throw new IllegalArgumentException("在庫が不足しています");
        }
        this.stockQuantity -= quantity;
    }
    
    // 在庫を増やす
    public void incrementStock(int quantity) {
        this.stockQuantity += quantity;
    }
}

カテゴリエンティティ

package com.example.demo.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "categories")
@Getter
@Setter
public class Category {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true, length = 50)
    private String name;  // カテゴリ名(重複不可)
    
    @Column(length = 200)
    private String description;  // カテゴリの説明
    
    // 商品との1対多の関係
    // 1つのカテゴリに複数の商品が属する
    @OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
    private List<Product> products = new ArrayList<>();
}

エンティティの関連マッピング

データベースでは、テーブル同士が関連を持つことがよくあります。例えば、商品はカテゴリに属し、注文には複数の商品が含まれます。このような関係をER図で表すと以下のようになります。

エンティティ設計のポイント

  1. @Versionによる楽観的ロック9: 同時更新による不整合を防ぐ仕組み。例えば、2人が同時に在庫を更新しようとした時、後から更新した人にエラーを返して、データの不整合を防ぎます。

  2. 監査機能10の活用: @CreatedDate@LastModifiedDateで自動的に日時を記録。「いつ誰が作成・更新したか」という履歴を自動で残せます。

  3. 適切なフェッチ戦略11: FetchType.LAZYで遅延ロード。必要な時だけ関連データを取得することで、パフォーマンスを向上させます。

  4. Lombok12の活用: @Getter@Setterで、getterやsetterメソッドを自動生成。手作業でこれらのメソッドを書く必要がなくなります。

監査機能を有効にする設定

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing  // JPA監査機能を有効化
public class JpaConfig {
    // Spring Bootが自動的に設定を行うため、通常は追加の設定は不要
    // この設定により、@CreatedDateや@LastModifiedDateが自動的に動作する
}

リポジトリインターフェースの実装

Spring Data JPAでは、JpaRepositoryを継承するだけで基本的なCRUD操作が可能になります。実装クラスは実行時に自動生成されます。

リポジトリは、データベースとのやり取りを担当する部分です。商品を倉庫(データベース)から取り出したり、新しい商品を倉庫に保管したりする「倉庫係」のような役割を持ちます。

基本的なリポジトリインターフェース

package com.example.demo.repository;

import com.example.demo.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    // JpaRepositoryを継承することで、以下のメソッドが自動的に利用可能:
    
    // ===== 基本的なCRUD操作(自動で提供される) =====
    // save(Product entity) - データの保存・更新
    // findById(Long id) - IDでデータを検索
    // findAll() - 全データを取得
    // deleteById(Long id) - IDでデータを削除
    // count() - データ件数を取得
    // existsById(Long id) - IDのデータが存在するか確認
    
    // ===== カスタムクエリメソッド(メソッド名から自動生成) =====
    
    // 商品名で検索
    // SQLが自動生成される: SELECT * FROM products WHERE name = ?
    Optional<Product> findByName(String name);
    
    // 商品名に特定の文字列を含む商品を検索
    // 例:findByNameContaining("パン") → "パン"を含む商品を検索
    // SQL: SELECT * FROM products WHERE name LIKE '%パン%'
    List<Product> findByNameContaining(String keyword);
    
    // 価格範囲で検索
    // SQL: SELECT * FROM products WHERE price >= ? AND price <= ?
    List<Product> findByPriceBetween(Integer minPrice, Integer maxPrice);
    
    // 在庫がある商品を価格の安い順で取得
    // SQL: SELECT * FROM products WHERE stock_quantity > 0 ORDER BY price ASC
    List<Product> findByStockQuantityGreaterThanOrderByPriceAsc(Integer stockQuantity);
    
    // カテゴリIDで商品を検索
    // SQL: SELECT * FROM products WHERE category_id = ?
    List<Product> findByCategoryId(Long categoryId);
}

カテゴリリポジトリ

package com.example.demo.repository;

import com.example.demo.entity.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
    // カテゴリ名で検索(一意制約があるため、Optionalで返す)
    Optional<Category> findByName(String name);
}

クエリメソッドの命名規則

Spring Data JPAでは、メソッド名から自動的にSQLを生成します。この命名規則を理解すると、様々な検索条件を簡単に実装できます。

よく使うクエリメソッドのパターン

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // ===== 基本的な検索 =====
    List<Product> findByName(String name);  // 完全一致
    List<Product> findByNameIgnoreCase(String name);  // 大文字小文字を無視
    
    // ===== 部分一致検索 =====
    List<Product> findByNameContaining(String keyword);  // 部分一致
    List<Product> findByNameStartingWith(String prefix);  // 前方一致
    List<Product> findByNameEndingWith(String suffix);  // 後方一致
    
    // ===== 比較演算子 =====
    List<Product> findByPriceLessThan(Integer price);  // より小さい
    List<Product> findByPriceLessThanEqual(Integer price);  // 以下
    List<Product> findByPriceGreaterThan(Integer price);  // より大きい
    List<Product> findByPriceGreaterThanEqual(Integer price);  // 以上
    
    // ===== 複数条件 =====
    List<Product> findByNameAndPrice(String name, Integer price);  // AND条件
    List<Product> findByNameOrPrice(String name, Integer price);  // OR条件
    
    // ===== NULL チェック =====
    List<Product> findByDescriptionIsNull();  // NULLのもの
    List<Product> findByDescriptionIsNotNull();  // NULLでないもの
    
    // ===== IN句 =====
    List<Product> findByNameIn(List<String> names);  // 複数の値のいずれか
    
    // ===== ソート =====
    List<Product> findByOrderByPriceAsc();  // 価格の昇順
    List<Product> findByOrderByPriceDesc();  // 価格の降順
    
    // ===== 複雑な条件 =====
    List<Product> findByNameContainingAndPriceLessThanOrderByPriceDesc(
        String keyword, Integer maxPrice
    );  // 名前に特定文字を含み、価格が指定値未満の商品を価格の高い順で取得
}

サービス層の実装

リポジトリを使用して、ビジネスロジックを実装するサービス層を作成します。

package com.example.demo.service;

import com.example.demo.entity.Product;
import com.example.demo.entity.Category;
import com.example.demo.repository.ProductRepository;
import com.example.demo.repository.CategoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor  // finalフィールドのコンストラクタを自動生成
public class ProductService {
    
    private final ProductRepository productRepository;
    private final CategoryRepository categoryRepository;
    
    // 商品を登録する
    @Transactional
    public Product createProduct(String name, Integer price, 
                               String description, Long categoryId) {
        // カテゴリの存在確認
        Category category = categoryRepository.findById(categoryId)
            .orElseThrow(() -> new IllegalArgumentException(
                "カテゴリが見つかりません: " + categoryId));
        
        // 商品名の重複チェック
        productRepository.findByName(name).ifPresent(existing -> {
            throw new IllegalArgumentException(
                "同名の商品が既に存在します: " + name);
        });
        
        // 新しい商品を作成
        Product product = new Product();
        product.setName(name);
        product.setPrice(price);
        product.setDescription(description);
        product.setCategory(category);
        product.setStockQuantity(0);  // 初期在庫は0
        
        // データベースに保存
        return productRepository.save(product);
    }
    
    // 全商品を取得
    @Transactional(readOnly = true)  // 読み取り専用トランザクション
    public List<Product> getAllProducts() {
        return productRepository.findAll();
    }
    
    // 商品をID検索
    @Transactional(readOnly = true)
    public Product getProductById(Long id) {
        return productRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException(
                "商品が見つかりません: " + id));
    }
    
    // 商品名で検索
    @Transactional(readOnly = true)
    public List<Product> searchProductsByName(String keyword) {
        return productRepository.findByNameContaining(keyword);
    }
    
    // 価格範囲で検索
    @Transactional(readOnly = true)
    public List<Product> searchProductsByPriceRange(Integer minPrice, 
                                                   Integer maxPrice) {
        return productRepository.findByPriceBetween(minPrice, maxPrice);
    }
    
    // 在庫を更新
    @Transactional
    public Product updateStock(Long productId, Integer quantity) {
        Product product = getProductById(productId);
        
        if (quantity > 0) {
            product.incrementStock(quantity);
        } else {
            product.decrementStock(-quantity);
        }
        
        return productRepository.save(product);
    }
    
    // 商品を削除
    @Transactional
    public void deleteProduct(Long productId) {
        // 存在確認
        if (!productRepository.existsById(productId)) {
            throw new IllegalArgumentException(
                "商品が見つかりません: " + productId);
        }
        
        productRepository.deleteById(productId);
    }
}

コントローラの実装

RESTful APIとして商品管理機能を公開します。

package com.example.demo.controller;

import com.example.demo.entity.Product;
import com.example.demo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
    
    private final ProductService productService;
    
    // 商品作成のリクエストDTO
    @lombok.Data
    public static class CreateProductRequest {
        private String name;
        private Integer price;
        private String description;
        private Long categoryId;
    }
    
    // 在庫更新のリクエストDTO
    @lombok.Data
    public static class UpdateStockRequest {
        private Integer quantity;
    }
    
    // 商品を作成
    @PostMapping
    public ResponseEntity<Product> createProduct(
            @RequestBody CreateProductRequest request) {
        Product product = productService.createProduct(
            request.getName(),
            request.getPrice(),
            request.getDescription(),
            request.getCategoryId()
        );
        return ResponseEntity.status(HttpStatus.CREATED).body(product);
    }
    
    // 全商品を取得
    @GetMapping
    public List<Product> getAllProducts() {
        return productService.getAllProducts();
    }
    
    // 商品をID検索
    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) {
        return productService.getProductById(id);
    }
    
    // 商品名で検索
    @GetMapping("/search")
    public List<Product> searchProducts(@RequestParam String keyword) {
        return productService.searchProductsByName(keyword);
    }
    
    // 価格範囲で検索
    @GetMapping("/search/price")
    public List<Product> searchProductsByPrice(
            @RequestParam Integer minPrice,
            @RequestParam Integer maxPrice) {
        return productService.searchProductsByPriceRange(minPrice, maxPrice);
    }
    
    // 在庫を更新
    @PatchMapping("/{id}/stock")
    public Product updateStock(
            @PathVariable Long id,
            @RequestBody UpdateStockRequest request) {
        return productService.updateStock(id, request.getQuantity());
    }
    
    // 商品を削除
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
    }
}

動作確認

アプリケーションを起動して、実際に動作を確認してみましょう。

初期データの投入

package com.example.demo;

import com.example.demo.entity.Category;
import com.example.demo.repository.CategoryRepository;
import com.example.demo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {
    
    private final CategoryRepository categoryRepository;
    private final ProductService productService;
    
    @Override
    public void run(String... args) throws Exception {
        // カテゴリを作成
        Category books = new Category();
        books.setName("書籍");
        books.setDescription("技術書、ビジネス書など");
        books = categoryRepository.save(books);
        
        Category electronics = new Category();
        electronics.setName("家電");
        electronics.setDescription("生活家電、PC周辺機器など");
        electronics = categoryRepository.save(electronics);
        
        // 商品を作成
        productService.createProduct(
            "Spring Boot入門", 3000, 
            "Spring Bootの基礎を学ぶ", books.getId()
        );
        
        productService.createProduct(
            "Java実践ガイド", 3500, 
            "Javaの実践的な使い方を解説", books.getId()
        );
        
        productService.createProduct(
            "ワイヤレスマウス", 2500, 
            "Bluetooth対応のマウス", electronics.getId()
        );
    }
}

APIの使用例

# 全商品を取得
curl http://localhost:8080/api/products

# 商品を作成
curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{
    "name": "キーボード",
    "price": 5000,
    "description": "メカニカルキーボード",
    "categoryId": 2
  }'

# 商品名で検索
curl http://localhost:8080/api/products/search?keyword=Spring

# 在庫を更新(10個追加)
curl -X PATCH http://localhost:8080/api/products/1/stock \
  -H "Content-Type: application/json" \
  -d '{"quantity": 10}'

H2コンソールでデータ確認

ブラウザで http://localhost:8080/h2-console にアクセスし、以下の情報でログイン:

  • JDBC URL: jdbc:h2:mem:testdb
  • User Name: sa
  • Password: (空欄)

SQLを実行してデータを確認できます:

-- 全商品を表示
SELECT * FROM PRODUCTS;

-- カテゴリ別の商品数
SELECT c.NAME, COUNT(p.ID) 
FROM CATEGORIES c 
LEFT JOIN PRODUCTS p ON c.ID = p.CATEGORY_ID 
GROUP BY c.NAME;

まとめ

今回は、Spring Data JPAの基礎について解説しました。

本記事で学んだこと

  • Spring Data JPAの基本概念: リポジトリパターンによるデータアクセスの抽象化
  • エンティティクラスの設計: データベーステーブルとJavaクラスのマッピング
  • リポジトリインターフェースの実装: インターフェースの定義だけでCRUD操作を実現
  • クエリメソッドの命名規則: メソッド名からSQLを自動生成する仕組み
  • 基本的なCRUD操作の実装: サービス層とコントローラ層の実装

Spring Data JPAを利用することで、データベース操作に関するコードを大幅に削減し、ビジネスロジックの実装により集中できるようになります。

  1. エンタープライズアプリケーション - 企業の業務システムなど、高い信頼性・拡張性・保守性が求められる大規模なアプリケーション。

  2. JPA (Java Persistence API) - JavaでO/Rマッピング(オブジェクト関係マッピング)を実現するための標準API。HibernateはJPAの代表的な実装。

  3. リポジトリ層 - データアクセスを担当する層。データの永続化と復元を担当し、ビジネスロジックからデータアクセスの詳細を隠蔽する。

  4. ボイラープレートコード - 定型的で繰り返し記述が必要なコード。Spring Data JPAはこれらを自動生成することで開発効率を向上させる。

  5. CRUD操作 - Create(作成)、Read(読取)、Update(更新)、Delete(削除)の基本的なデータ操作の総称。

  6. SQL (Structured Query Language) - リレーショナルデータベースを操作するための標準的なクエリ言語。

  7. ページネーション - 大量のデータを一定数ずつに分割して表示する仕組み。パフォーマンスとユーザビリティの向上に寄与。

  8. エンティティ - データベースのテーブルに対応するJavaオブジェクト。@Entityアノテーションで定義される。

  9. 楽観的ロック - データ更新時にバージョン番号を確認することで、他のトランザクションによる更新を検出する仕組み。

  10. 監査機能 - データの作成日時、更新日時などを自動的に記録する機能。

  11. フェッチ戦略 - 関連エンティティをいつ取得するかを決定する戦略。EAGER(即時取得)とLAZY(遅延取得)がある。

  12. Lombok - Javaのボイラープレートコードを削減するライブラリ。@Getter@Setterなどのアノテーションでコードを自動生成。

3
3
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
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?