Skip to content
Playground

10.JdbcClient

学習目標

  • JDBC の手続きが Repository のメソッドごとに繰り返されることを説明できる
  • JdbcClient を注入し、SELECT の結果を query(...).list() で取得できる
  • 名前付きパラメーターで SQL に値を渡せる
  • query(型).single() で 1 つの値を取得できる
  • update() で INSERT を実行できる
  • SQLExceptionDataAccessException に変わり、try-catch が不要になる理由を説明できる

サービスとリポジトリ で、BookRepository がデータベースへのアクセスをまとめて担うようになりました。ただし中身は JDBC のままで、接続や例外の処理をメソッドごとに書いています。本章では、JDBC のコードを Spring の JdbcClient に置き換え、BookRepository を短く書き直します。

まず、一覧を返す findAllJdbcClient で書き直し、基本の書き方を確認します。

サービスとリポジトリfindAll は、次のように書いていました。

BookRepository.java(書き換える前の 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 でも同じです。メソッドを増やすたびに、同じ手続きを書くことになります。

この繰り返しを引き受けるのが JdbcClient です。Spring が用意する JDBC の API で、接続の開閉と SQLException の処理を引き受けます。同じ findAll が、JdbcClient では次の 3 行になります。

BookRepository.java(書き換えた findAll)
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 の全体は次のとおりです。

src/main/java/com/example/bookstore/BookRepository.java
package com.example.bookstore;
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@Repository
public 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.sqlimport はすべて消えました。この sqlquerylist の並びが基本の形で、メソッドごとに変わるのは SQL と結果の受け取り方だけです。

jdbcClient.sql("実行する SQL")
.query(結果のクラス)
.list();

query(Book.class) は、結果の 1 行を 1 つの Book にします。このとき、Book のコンストラクターを呼び、引数と同じ名前の列の値を渡します。title 列の値は引数 title に、price 列の値は pricestock 列の値は stock に渡ります。列名と引数名がそろっているため、new Book(rs.getString("title"), rs.getInt("price"), rs.getInt("stock")) のような変換を、自分で書かずに済みます。

一覧に、タイトルでの検索を加えます。キーワードを含む本だけを取り出すには、SQL に検索語を渡す必要があります。

値は SQL に直接書かず、:名前 という目印を SQL に置き、param で渡します。この目印を 名前付きパラメーター と呼びます。タイトルを検索する searchByTitle です。

BookRepository.java(メソッドを追加)
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> として受け取ります。

searchByTitle を、検索フォームから使えるようにします。BookServiceBookControllerbooks.html を順に変えます。

BookServicesearch を加えます。キーワードが空なら全件、入力があれば絞り込みます。空かどうかの判断はビジネスルールなので、BookService に置きます。

BookService.java(メソッドを追加)
public List<Book> search(String keyword) {
if (keyword == null || keyword.isBlank()) {
return bookRepository.findAll();
}
return bookRepository.searchByTitle(keyword);
}

BookController/books で検索語を受け取ります。検索語は省略できるので、省略可能なパラメーター@RequestParam(required = false) を使います。

BookController.java(books メソッド)
@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.RequestParamimport が加わります。

books.html に検索フォームを置きます。検索語は URL に残してよいので、クエリパラメーター として GET で送ります。

templates/books.html(検索フォームを追加)
<form action="/books" method="get">
<input type="text" name="keyword">
<button type="submit">検索</button>
</form>

と入力して検索すると、/books?keyword=猫 が送られ、一覧が『吾輩は猫である』だけに絞られます。

本の登録では、同じタイトルがすでにあるかを確かめます。existsByTitle は、SELECT COUNT(*) でタイトルの件数を数えます。

findAll は複数行を list() で受け取りました。件数のように 1 つの値を取り出すときは、query(値の型) で型を指定し、single() で受け取ります。

BookRepository.java(existsByTitle)
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 冊だけ取り出す詳細画面で扱います。

save は、本を 1 冊追加します。SELECT は query で実行しましたが、INSERT・UPDATE・DELETE は update() で実行します。

BookRepository.java(save)
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:stockparam で値を渡し、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 の章で扱います。

書き直した findAllsearchByTitleexistsByTitlesave には、try-catchthrows もありません。サービスとリポジトリ では、どのメソッドも SQLExceptioncatch し、投げ直していました。

SQLExceptionチェック例外 なので、JDBC を直接使うと catchthrows が必要です。JdbcClient は、これを内部で catch し、非チェック例外の DataAccessException に変えて投げます。非チェック例外は処理を強制されないため、呼び出す側は try-catchthrows も書きません。各メソッドにあった次の処理は、すべて消えました。

書き換える前に各メソッドへ書いていた処理
} catch (SQLException e) {
throw new RuntimeException(e);
}

接続や SQL の失敗は DataAccessException として送出され、処理しなければそのまま画面のエラーになります。例外を捉えて利用者に分かりやすく伝える仕組みは、後の章で扱います。