9.サービスとリポジトリ
学習目標
@Repository・@Serviceと DI(依存性の注入)で、Controller・Service・Repository を組み立てられる- 接続情報を
application.propertiesに集約できる - ビジネスロジックを Service に集約する理由を説明できる
- レイヤードアーキテクチャの依存の向きと、変更が 1 つの層に収まる利点を説明できる
データアクセスの分離 で、BookController の JDBC を BookRepository に移しました。ただし BookController は BookRepository を new で作り、接続情報は BookRepository のコードに直接書いたままです。本の登録で必要になるビジネスルール(入力の検証など)にも、置き場所がありません。
本章では、BookRepository を Spring Boot に生成・注入させ(DI)、接続情報を 1 か所にまとめます。さらに、ビジネスロジックを担う Service を加え、BookController・BookService・BookRepository の 3 層に整えます。
1. Repository
Section titled “1. Repository”データの取得・保存(データアクセス)を担うクラスを Repository(リポジトリ)と呼びます。データアクセスの分離 で切り出した BookRepository がそれにあたり、SQL や接続の詳細を閉じ込め、ほかのクラスは findAll のようなメソッドで呼び出します。
ただし、BookController はその BookRepository を new で自分で作っています。
@Controllerpublic class BookController { private final BookRepository bookRepository = new BookRepository(); // books メソッドは省略}new で作ると、BookController 自身が BookRepository の生成を担います。本節では、この生成を Spring Boot に任せて new をやめ、接続情報も 1 か所にまとめます。
1-1. @Repository と依存性の注入
Section titled “1-1. @Repository と依存性の注入”BookRepository の生成を Spring Boot に任せるため、クラスに @Repository を付けます。@Repository を付けたクラスは、Spring Boot が起動時にインスタンスを生成して管理します。
@Repositorypublic class BookRepository { // findAll は 8 章のまま}BookController は、new の代わりにコンストラクターの引数で BookRepository を受け取ります。
package com.example.bookstore;
import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.GetMapping;
@Controllerpublic class BookController { private final BookRepository bookRepository;
public BookController(BookRepository bookRepository) { this.bookRepository = bookRepository; }
@GetMapping("/books") public String books(Model model) { model.addAttribute("books", bookRepository.findAll()); return "books"; }}new BookRepository() はコードから消えました。@Controller の付いた BookController も Spring Boot が生成し、その際にコンストラクターの引数へ BookRepository のインスタンスを渡します。
Spring Boot を使わずに手で組み立てるなら、依存を new してコンストラクターに渡します。これは コンポジション の Main が行っていたことと同じです。
// Spring を使わずに手で組み立てる場合(実際には書かない)BookRepository repository = new BookRepository();BookController controller = new BookController(repository);Spring Boot のアプリケーションには、この組み立てを書く Main がありません。代わりに、Spring Boot が起動時に同じ組み立てを自動で行います。
-
クラスを見つける
@SpringBootApplicationに含まれるコンポーネントスキャンが、@Controller・@Repositoryの付いたクラスを探します。 -
依存をたどって生成する
BookControllerのコンストラクターの引数にBookRepositoryがあるため、Spring Boot はBookRepositoryを生成します。 -
注入する
生成した
BookRepositoryを、BookControllerのコンストラクターに渡します。
1-2. 接続設定の集約
Section titled “1-2. 接続設定の集約”BookController への注入と同じことを、BookRepository の接続にも適用します。これまでの BookRepository は、DriverManager.getConnection(url, "root", "root") で接続を作り、接続先の情報をコードに直接書いていました。この接続情報を application.properties にまとめ、接続そのものを Spring Boot から受け取る形にします。
まず、Spring Boot が JDBC の接続を組み立てられるよう、spring-boot-starter-jdbc を依存関係に追加します。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId></dependency>接続先の情報は application.properties に書きます。
spring.datasource.url=jdbc:mysql://127.0.0.1:3307/bookstorespring.datasource.username=rootspring.datasource.password=rootspring-boot-starter-jdbc を追加して接続情報を書くと、Spring Boot は接続を供給する DataSource を自動で組み立てます。BookRepository は、この DataSource をコンストラクターで受け取ります。
package com.example.bookstore;
import java.sql.Connection;import java.sql.PreparedStatement;import java.sql.ResultSet;import java.sql.SQLException;import java.util.ArrayList;import java.util.List;import javax.sql.DataSource;import org.springframework.stereotype.Repository;
@Repositorypublic class BookRepository { private final DataSource dataSource;
public BookRepository(DataSource dataSource) { this.dataSource = dataSource; }
public List<Book> findAll() { List<Book> books = new ArrayList<>(); String sql = "SELECT title, price, stock FROM books";
try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql); ResultSet rs = stmt.executeQuery()) {
while (rs.next()) { books.add(new Book(rs.getString("title"), rs.getInt("price"), rs.getInt("stock"))); } } catch (SQLException e) { throw new RuntimeException(e); } return books; }}Spring Boot は、BookController に BookRepository を注入したのと同じ仕組みで、BookRepository に DataSource を注入します。DriverManager で毎回接続を作る代わりに dataSource.getConnection() で接続を得て、接続先を変えるときに直すのは application.properties です。
2. Service
Section titled “2. Service”Repository で BookRepository を Spring Boot に組み込みました。次は本の登録です。登録には「価格は 100 以上」「同じタイトルは登録しない」というビジネスルールがあります。
登録のルールを担う層を、BookController と BookRepository の間に置きます。この層が Service(サービス)です。Service はルールを確認してから、BookRepository に保存を任せます。
2-1. ビジネスルールの実装
Section titled “2-1. ビジネスルールの実装”価格と重複の 2 つのルールを、BookService の register メソッドに実装します。
package com.example.bookstore;
import java.util.List;import org.springframework.stereotype.Service;
@Servicepublic class BookService { private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) { this.bookRepository = bookRepository; }
public List<Book> findAll() { return bookRepository.findAll(); }
public void register(BookForm form) { // ビジネスルール 1: 価格は 100 以上 if (form.getPrice() < 100) { throw new IllegalArgumentException("価格は 100 以上で指定してください"); } // ビジネスルール 2: 同じタイトルは登録しない if (bookRepository.existsByTitle(form.getTitle())) { throw new IllegalStateException("同じタイトルの本がすでに登録されています"); } bookRepository.save(new Book(form.getTitle(), form.getPrice(), 0)); }}BookService に付けた @Service は、@Repository と同じく Spring Boot に登録するアノテーションで、ビジネスロジックのクラスに付けます。BookService も BookRepository をコンストラクターで受け取ります。register は、価格を検証し、重複を確かめてから保存します。新規に登録する本の在庫は 0 から始めます。
register が呼ぶ existsByTitle(同名の本の有無)と save(1 冊の保存)は、まだ BookRepository にありません。どちらもデータベースへの操作なので、BookRepository に追加します。
public void save(Book book) { String sql = "INSERT INTO books (title, price, stock) VALUES (?, ?, ?)";
try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, book.getTitle()); stmt.setInt(2, book.getPrice()); stmt.setInt(3, book.getStock()); stmt.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); }}
public boolean existsByTitle(String title) { String sql = "SELECT COUNT(*) FROM books WHERE title = ?";
try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, title); try (ResultSet rs = stmt.executeQuery()) { rs.next(); return rs.getInt(1) > 0; } } catch (SQLException e) { throw new RuntimeException(e); }}save は INSERT で 1 冊を保存し、existsByTitle は SELECT COUNT(*) で同名の本の件数を数えて有無を返します。register は、この 2 つを使ってルールを確認し、保存します。
検証に失敗したときに送出する例外は、ルールの性質で使い分けます。
| ルール | 例外 | 理由 |
|---|---|---|
| 価格が 100 未満 | IllegalArgumentException | 引数の値が不正 |
| タイトルの重複 | IllegalStateException | すでに登録済みという状態 |
検証に失敗すると、例外がそのまま画面のエラーになります。入力の誤りを画面で利用者に伝え、フォームに差し戻す仕組みは、後の章で扱います。
2-2. Controller から Service への委譲
Section titled “2-2. Controller から Service への委譲”BookController は、ビジネスロジックを自分で持たず、BookService に依頼します。登録(create)は bookService.register を呼びます。一覧(books)も、これまで BookRepository を直接呼んでいましたが、データ操作の窓口を BookService に一本化します。
package com.example.bookstore;
import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.ModelAttribute;import org.springframework.web.bind.annotation.PostMapping;
@Controllerpublic class BookController { private final BookService bookService;
public BookController(BookService bookService) { this.bookService = bookService; }
@GetMapping("/books") public String books(Model model) { model.addAttribute("books", bookService.findAll()); return "books"; }
@PostMapping("/books") public String create(@ModelAttribute BookForm form) { bookService.register(form); return "redirect:/books"; }}1 節で BookController が注入していた依存は、BookRepository から BookService に変わりました。BookService も、@Repository と依存性の注入 と同じく Spring Boot が生成して注入します。登録後は リダイレクト で一覧(/books)に転送します。
3. レイヤードアーキテクチャ
Section titled “3. レイヤードアーキテクチャ”BookController・BookService・BookRepository は、リクエストの処理・ビジネスロジック・データアクセスを 1 つずつ担います。役割ごとに層を分けたこの構成を レイヤードアーキテクチャ(層構造)と呼びます。MVC で見ると、BookController が Controller、BookService と BookRepository が Model、テンプレート(books.html)が View にあたります。
3-1. 3 つの層と依存の向き
Section titled “3-1. 3 つの層と依存の向き”3 つのクラスは、リクエストの入口から順に層を成します。BookController が BookService を、BookService が BookRepository をフィールドに持って呼び出します。逆向きの参照はありません。
Spring Boot は、この依存を BookRepository が使う DataSource までたどり、DataSource から順に生成して注入します。層が 1 つ増えても、この仕組みは変わりません。
依存が一方向なので、BookRepository のコードには呼び出し元への参照がありません。データアクセスだけを行うため、Web のコントローラーからでも、定期実行のバッチ処理や別の API からでも、同じ BookService や BookRepository を使えます。
3-2. 変更の局所化
Section titled “3-2. 変更の局所化”層を分けた効果は、変更を加えるときに表れます。検索画面を追加しても価格のルールは変わらず、ルールを厳しくしても SQL は変わりません。Service で「同じタイトルは登録しない」ルールを足したとき、修正したのは BookService(ルール)と BookRepository(existsByTitle)だけでした。BookController も books.html も変えていません。
変更の種類によって、修正する層が決まります。
| 変えたいこと | 修正する層 |
|---|---|
| 取得・保存の SQL を変える | BookRepository |
| ビジネスロジックを変える | BookService |
| ルーティングや画面への受け渡しを変える | BookController |
| 画面の見た目を変える | books.html(MVC の View) |
1 つの変更が 1 つの層に収まり、ほかの層に影響しません。
4. 発展: インターフェイスと DI
Section titled “4. 発展: インターフェイスと DI”BookService が依存する BookRepository をインターフェイスにして実装(JdbcBookRepository)を分けると、利用側を変えずに実装を差し替えられます。Spring が Bean を生成・注入する DI コンテナー の仕組みとあわせて、付録で扱います。