10.JdbcClient
学習目標
- JDBC の手続きが Repository のメソッドごとに繰り返されることを説明できる
JdbcClientを注入し、SELECT の結果をquery(...).list()で取得できる- 名前付きパラメーターで SQL に値を渡せる
query(型).single()で 1 つの値を取得できるupdate()で INSERT を実行できるSQLExceptionがDataAccessExceptionに変わり、try-catchが不要になる理由を説明できる
サービスとリポジトリ で、BookRepository がデータベースへのアクセスをまとめて担うようになりました。ただし中身は JDBC のままで、接続や例外の処理をメソッドごとに書いています。本章では、JDBC のコードを Spring の JdbcClient に置き換え、BookRepository を短く書き直します。
1. JdbcClient による書き換え
Section titled “1. JdbcClient による書き換え”まず、一覧を返す findAll を JdbcClient で書き直し、基本の書き方を確認します。
1-1. 繰り返される JDBC の手続き
Section titled “1-1. 繰り返される JDBC の手続き”サービスとリポジトリ の findAll は、次のように書いていました。
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;}findAll に固有なのは、実行する SQL と、1 行を Book に変換するコードの 2 か所だけです。残りの接続の開閉・ResultSet の操作・SQLException の処理は、save でも existsByTitle でも同じです。メソッドを増やすたびに、同じ手続きを書くことになります。
1-2. findAll の書き換え
Section titled “1-2. findAll の書き換え”この繰り返しを引き受けるのが JdbcClient です。Spring が用意する JDBC の API で、接続の開閉と SQLException の処理を引き受けます。同じ findAll が、JdbcClient では次の 3 行になります。
public List<Book> findAll() { return jdbcClient.sql("SELECT title, price, stock FROM books") .query(Book.class) .list();}sql("SELECT title, price, stock FROM books") で実行する SQL を渡し、query(Book.class) で 1 行を Book に変換し、list() で List<Book> として受け取ります。接続の開閉も SQLException の処理も書いていません。JdbcClient が内部で行うためです。
jdbcClient は、DataSource のときと同じくコンストラクターで受け取ります。Spring Boot が自動で用意するので、new も依存関係の追加も要りません。BookRepository の全体は次のとおりです。
package com.example.bookstore;
import java.util.List;import org.springframework.jdbc.core.simple.JdbcClient;import org.springframework.stereotype.Repository;
@Repositorypublic class BookRepository { private final JdbcClient jdbcClient;
public BookRepository(JdbcClient jdbcClient) { this.jdbcClient = jdbcClient; }
public List<Book> findAll() { return jdbcClient.sql("SELECT title, price, stock FROM books") .query(Book.class) .list(); }}java.sql の import はすべて消えました。この sql → query → list の並びが基本の形で、メソッドごとに変わるのは SQL と結果の受け取り方だけです。
jdbcClient.sql("実行する SQL") .query(結果のクラス) .list();1-3. 結果のマッピング
Section titled “1-3. 結果のマッピング”query(Book.class) は、結果の 1 行を 1 つの Book にします。このとき、Book のコンストラクターを呼び、引数と同じ名前の列の値を渡します。title 列の値は引数 title に、price 列の値は price、stock 列の値は stock に渡ります。列名と引数名がそろっているため、new Book(rs.getString("title"), rs.getInt("price"), rs.getInt("stock")) のような変換を、自分で書かずに済みます。
2. 検索とパラメーター
Section titled “2. 検索とパラメーター”2-1. 名前付きパラメーター
Section titled “2-1. 名前付きパラメーター”一覧に、タイトルでの検索を加えます。キーワードを含む本だけを取り出すには、SQL に検索語を渡す必要があります。
値は SQL に直接書かず、:名前 という目印を SQL に置き、param で渡します。この目印を 名前付きパラメーター と呼びます。タイトルを検索する searchByTitle です。
public List<Book> searchByTitle(String keyword) { return jdbcClient.sql("SELECT title, price, stock FROM books WHERE title LIKE :keyword") .param("keyword", "%" + keyword + "%") .query(Book.class) .list();}:keyword が、値の入る場所です。param("keyword", 値) の "keyword" がこの名前と一致し、渡した値が :keyword の位置に入ります。
たとえば検索語が 猫 のとき、param には "%猫%" が渡り、実行される SQL は次のようになります。
SELECT title, price, stock FROM books WHERE title LIKE '%猫%'LIKE '%猫%' は「猫」を含むタイトルに一致します(% は任意の文字列を表します)。結果は findAll と同じく query(Book.class).list() で List<Book> として受け取ります。
2-2. 検索を画面につなぐ
Section titled “2-2. 検索を画面につなぐ”searchByTitle を、検索フォームから使えるようにします。BookService・BookController・books.html を順に変えます。
BookService に search を加えます。キーワードが空なら全件、入力があれば絞り込みます。空かどうかの判断はビジネスルールなので、BookService に置きます。
public List<Book> search(String keyword) { if (keyword == null || keyword.isBlank()) { return bookRepository.findAll(); } return bookRepository.searchByTitle(keyword);}BookController の /books で検索語を受け取ります。検索語は省略できるので、省略可能なパラメーター の @RequestParam(required = false) を使います。
@GetMapping("/books")public String books(@RequestParam(required = false) String keyword, Model model) { model.addAttribute("books", bookService.search(keyword)); return "books";}org.springframework.web.bind.annotation.RequestParam の import が加わります。
books.html に検索フォームを置きます。検索語は URL に残してよいので、クエリパラメーター として GET で送ります。
<form action="/books" method="get"> <input type="text" name="keyword"> <button type="submit">検索</button></form>猫 と入力して検索すると、/books?keyword=猫 が送られ、一覧が『吾輩は猫である』だけに絞られます。
3. 件数の確認とデータの登録
Section titled “3. 件数の確認とデータの登録”3-1. 1 つの値の取得
Section titled “3-1. 1 つの値の取得”本の登録では、同じタイトルがすでにあるかを確かめます。existsByTitle は、SELECT COUNT(*) でタイトルの件数を数えます。
findAll は複数行を list() で受け取りました。件数のように 1 つの値を取り出すときは、query(値の型) で型を指定し、single() で受け取ります。
public boolean existsByTitle(String title) { Long count = jdbcClient.sql("SELECT COUNT(*) FROM books WHERE title = :title") .param("title", title) .query(Long.class) .single(); return count > 0;}query(Long.class) で件数を Long として取り出し、single() で 1 件として受け取ります。single() は、結果がちょうど 1 件のときに使います。0 件か 1 件かが決まらない取得には optional() があり、本を 1 冊だけ取り出す詳細画面で扱います。
3-2. データの登録
Section titled “3-2. データの登録”save は、本を 1 冊追加します。SELECT は query で実行しましたが、INSERT・UPDATE・DELETE は update() で実行します。
public void save(Book book) { jdbcClient.sql("INSERT INTO books (title, price, stock) VALUES (:title, :price, :stock)") .param("title", book.getTitle()) .param("price", book.getPrice()) .param("stock", book.getStock()) .update();}:title・:price・:stock に param で値を渡し、update() で INSERT を実行します。update() は 影響行数 を返しますが、save では使わないため受け取っていません。
受け取り方は、取り出すものに応じて選びます。
| 取り出すもの | 受け取り方 |
|---|---|
| 複数行(一覧) | .query(Book.class).list() |
| 1 件のオブジェクト | .query(Book.class).single() |
| 0 件か 1 件 | .query(Book.class).optional() |
| 1 つの値(件数など) | .query(Long.class).single() |
| 追加・更新・削除 | .update() |
sql で SQL、param で値、最後に受け取り方を指定する形はどのメソッドも同じです。本を ID で更新・削除する操作は、後の CRUD の章で扱います。
4. 例外の扱い
Section titled “4. 例外の扱い”書き直した findAll・searchByTitle・existsByTitle・save には、try-catch も throws もありません。サービスとリポジトリ では、どのメソッドも SQLException を catch し、投げ直していました。
SQLException は チェック例外 なので、JDBC を直接使うと catch か throws が必要です。JdbcClient は、これを内部で catch し、非チェック例外の DataAccessException に変えて投げます。非チェック例外は処理を強制されないため、呼び出す側は try-catch も throws も書きません。各メソッドにあった次の処理は、すべて消えました。
} catch (SQLException e) { throw new RuntimeException(e);}接続や SQL の失敗は DataAccessException として送出され、処理しなければそのまま画面のエラーになります。例外を捉えて利用者に分かりやすく伝える仕組みは、後の章で扱います。