Springの地味に便利な新機能: JdbcClientとRestClient #javado

8.9K Views

July 25, 25

スライド概要

profile-image

Java、Spring、IntelliJ IDEA

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

ダウンロード

関連スライド

各ページのテキスト
1.

Springの地味に便利な新機能 JdbcClientとRestClient JavaDo 2025年7月25日 多田真敏 1

2.

このセッションについて ◆以前からSpringではよく使われているJdbcTemplateと RestTemplate。 ◆Spring Framework 6.1では、これらと同じことを より洗練されたAPIで実現する JdbcClient・RestClientが導入されました。 ◆このセッションでは、 JdbcClient・RestClientの 基本的な使い方、およびJdbcTemplate・RestTemplateと 比較して何が嬉しいか、といった部分をお話しします。 2

3.

必要な前提知識 ◆このセッションを理解するには、以下の前提知識が必要です ◆Spring DI・Spring MVCの利用経験 ◆JdbcTemplate・RestTemplateの利用経験 3

4.

前提知識を学べる資料 https://gihyo.jp/book/2023/9 78-4-297-13613-0 4

5.

自己紹介 ◆多田真敏(@suke_masa) ◆JJUG・JSUGスタッフ ◆クレジットカード会社で 社内システムの内製化+AWS化 ◆OSSドキュメントの和訳 ◆Thymeleaf・Resilience4j ◆櫻坂46、北海道日本ハムファイターズ、 DB.スターマン、ゴジラ 5

6.

対象バージョン ◆JDK 21 ◆PostgreSQL 16 バージョンが異なる場合、 この資料の説明が正しくない 可能性があります ◆Spring Boot 3.5 ◆Spring Boot利用前提で解説しています ◆Spring Bootを利用していない場合、 この資料では解説していない設定を追加する必要があります 6

7.

目次 ① JdbcClientの使い方 ② RestClientの使い方 7

8.

目次 ① JdbcClientの使い方 ② RestClientの使い方 8

9.

質問:ORマッパーは何を使ってますか? ◆JdbcTemplate? ◆JdbcClient? ◆MyBatis? ◆その他? 9

10.

ORマッパーどれにする? 多田の独断と偏見です ◆安全を期すなら、JdbcTemplate・JdbcClient・MyBatis ◆既にノウハウがある or 十分な調査工数があるなら、 Doma・DBFlute・jOOQもいいでしょう ◆多田は次のプロジェクトはDomaにしようかなと考え中 ◆JPAは可能な限り避ける ◆どうしても使わざるを得ないなら、気合いを入れて勉強する ◆この資料も見てみてください! ◆https://www.docswell.com/s/MasatoshiTada/596WW5-how-to-choose-java-orm 10

11.

サンプルDB CREATE SEQUENCE seq_todo_id START WITH 1 INCREMENT BY 1; 主キーはシーケンスにより DB側で生成 CREATE TABLE todo ( id INTEGER PRIMARY KEY DEFAULT nextval('seq_todo_id'), description VARCHAR(255) NOT NULL, completed BOOLEAN DEFAULT FALSE, deadline TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); 11

12.

テーブルに対応するTodoクラス public record Todo(Integer id, String description, Boolean completed, LocalDateTime deadline, LocalDateTime createdAt) { public static Todo newTodo(String description, LocalDateTime deadline) { return new Todo(null, description, false, deadline, null); } public Todo withId(Integer newId) { return new Todo(newId, description, completed, deadline, createdAt); } } 12

13.

JdbcClientの利用 Auto Configurationで Bean定義済みなので DIするだけ! @Repository public class JdbcClientTodoRepository implements TodoRepository { private final JdbcClient jdbcClient; public JdbcClientTodoRepository(JdbcClient jdbcClient) { this.jdbcClient = jdbcClient; } private static final RowMapper<Todo> TODO_ROW_MAPPER = new DataClassRowMapper<>(Todo.class); ... 13

14.
[beta]
複数件検索
(NamedParameterJdbcTemplate)
public List<Todo> selectByKeyword(String keyword) {
MapSqlParameterSource params = new MapSqlParameterSource(
Map.of("searchPattern", "%" + keyword + "%"));
List<Todo> todoList = namedParameterJdbcTemplate.query("""
SELECT id, description, completed, deadline, created_at
FROM todo
WHERE description LIKE :searchPattern
ORDER BY created_at DESC
""", params, TODO_ROW_MAPPER
);
return todoList;
}

14

15.
[beta]
複数件検索(JdbcClient)
public List<Todo> selectByKeyword(String keyword) {
List<Todo> todoList = jdbcClient.sql("""
SELECT id, description, completed, deadline, created_at
FROM todo
メソッドチェーン WHERE description LIKE :searchPattern
で書ける!
ORDER BY created_at DESC
""")
.param("searchPattern", "%" + keyword + "%")
.query(TODO_ROW_MAPPER)
.list();
return todoList;
.stream()にすると戻り値がStreamになる
}
(使い終わったらclose()を忘れずに)

15

16.
[beta]
単一検索
(NamedParameterJdbcTemplate)
public Optional<Todo> selectById(Integer id) {
try {
MapSqlParameterSource params = new MapSqlParameterSource(Map.of(
"id", id));
Todo todo = namedParameterJdbcTemplate.queryForObject("""
SELECT id, description, completed, deadline, created_at
FROM todo
WHERE id = :id
try-catchが必要
""", params, TODO_ROW_MAPPER);
return Optional.of(todo);
} catch (EmptyResultDataAccessException e) {
// 指定されたIDが存在しない場合は空のOptionalを返す
return Optional.empty();
}
}

16

17.
[beta]
単一検索(JdbcClient)
public Optional<Todo> selectById(Integer id) {
Optional<Todo> todoOptional = jdbcClient.sql("""
SELECT id, description, completed, deadline, created_at
FROM todo
WHERE id = :id
""")
.param("id", id)
.query(TODO_ROW_MAPPER)
.optional();
try-catch不要!
return todoOptional;
}

17

18.
[beta]
更新
(NamedParameterJdbcTemplate)
public int update(Todo todo) {
MapSqlParameterSource params = new MapSqlParameterSource(Map.of(
"description", todo.description(),
"deadline", todo.deadline(),
"completed", todo.completed(),
"id", todo.id()
));
int rows = namedParameterJdbcTemplate.update("""
UPDATE todo SET description = :description,
deadline = :deadline,
completed = :completed
WHERE id = :id
""", params
);
return rows;
}
18

19.
[beta]
更新(JdbcClient)
public int update(Todo todo) {
int rows = jdbcClient.sql("""
UPDATE todo SET description = :description,
deadline = :deadline,
completed = :completed
WHERE id = :id
""")
.param("description", todo.description())
.param("deadline", todo.deadline())
.param("completed", todo.completed())
.param("id", todo.id())
.update();
return rows;
}

19

20.
[beta]
削除
(NamedParameterJdbcTemplate)
public int delete(Integer id) {
MapSqlParameterSource params = new MapSqlParameterSource(
Map.of("id", id));
int rows = namedParameterJdbcTemplate.update("""
DELETE FROM todo WHERE id = :id
""", params
);
return rows;
}

20

21.
[beta]
削除(JdbcClient)
public int delete(Integer id) {
int rows = jdbcClient.sql("""
DELETE FROM todo WHERE id = :id
""")
.param("id", id)
.update();
return rows;
}

21

22.
[beta]
追加
(NamedParameterJdbcTemplate)
public Todo insert(Todo newTodo) {
MapSqlParameterSource params = new MapSqlParameterSource(Map.of(
"description", newTodo.description(),
"deadline", newTodo.deadline()
));
配列形式が
KeyHolder keyHolder = new GeneratedKeyHolder();
ややメンドイ
namedParameterJdbcTemplate.update("""
INSERT INTO todo (description, deadline)
VALUES (:description, :deadline)
""", params, keyHolder, new String[] {"id"} // 主キー列名
);
// DBで生成された主キーの値を取得
Integer newId = keyHolder.getKey().intValue();
return newTodo.withId(newId);
}

22

23.
[beta]
追加(JdbcClient)
public Todo insert(Todo newTodo) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcClient.sql("""
INSERT INTO todo (description, deadline)
VALUES (:description, :deadline)
""")
.param("description", newTodo.description())
.param("deadline", newTodo.deadline())
.update(keyHolder, "id"); // 主キー列名
// DBで生成された主キーの値を取得
Integer newId = keyHolder.getKey().intValue();
return newTodo.withId(newId);
}

可変長引数で
書ける!

23

24.

JdbcClientで出来ないこと ◆batchUpdate()に対応するメソッドは無い! ◆これを使いたい場合はJdbcTemplateを使いましょう 24

25.

JdbcClientのまとめ ◆スッキリ書けて嬉しいですね! 25

26.

目次 ① JdbcClientの使い方 ② RestClientの使い方 26

27.

質問:HTTPクライアントは何を使ってますか? ◆RestTemplate? ◆RestClient? ◆WebClient? ◆その他? 27

28.

サンプルWeb API ◆複数件検索 ◆単一検索 GET /api/todos?keyword=a GET /api/todos/1 200 OK [ { "id": 2, "description": "Example 2", "completed": false, "deadline": "2025-10-02T12:00:00", "createdAt": "2025-09-02T12:00:00" }, { "id": 1, "description": "Example 1", "completed": true, "deadline": "2025-10-01T12:00:00", "createdAt": "2025-09-01T12:00:00" } ] 200 OK { "id": 1, "description": "Example 1", "completed": true, "deadline": "2025-10-01T12:00:00", "createdAt": "2025-09-01T12:00:00" } 28

29.

サンプルWeb API ◆追加 ◆更新 POST /api/todos { "description": "Example 1", "deadline": "2025-10-01T12:00:00", } PUT /api/todos/1 { "description": "Example 1", "completed": true, "deadline": "2025-10-01T12:00:00", } 201 Created Location: /api/todos/4 200 OK ◆削除 DELETE /api/todos/1 204 No Content 29

30.

準備 ◆RestClientがデフォルトで使うHttpUrlConnectionだと PATCHができないので、Apache HttpComponentsを 入れましょう ◆https://qiita.com/kazuki43zoo/items/ec5dd8d8ef0aab999b19 dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.apache.httpcomponents.client5:httpclient5") ... } 30

31.
[beta]
RestClientの利用
@Component
public class RestClientTodoClient implements TodoClient {
private final RestClient restClient;

ビルダーが
Auto Configurationで
Bean定義済み
(スコープはprototype)

public RestClientTodoClient(
RestClient.Builder restClientBuilder,
@Value("${todo-service.base-url}") String baseUrl,
@Value("${todo-service.connect-timeout}") Duration connectTimeout,
@Value("${todo-service.read-timeout}") Duration readTimeout) {
HttpComponentsClientHttpRequestFactory requestFactory =
ClientHttpRequestFactoryBuilder.httpComponents()
.withCustomizer(factory -> {
factory.setConnectTimeout(connectTimeout);
タイムアウト設定を
factory.setReadTimeout(readTimeout);
忘れずに
}).build();
this.restClient = restClientBuilder.baseUrl(baseUrl)
.requestFactory(requestFactory).build();
}

31

32.
[beta]
複数件検索(RestTemplate)
public List<TodoResponse> getByKeyword(String keyword) {
exchange()では
RequestEntity requestEntity = new RequestEntity<>(
baseUrlも必須
HttpMethod.GET, URI.create(
baseUrl + "/api/todos?keyword=" + keyword));
ResponseEntity<List<TodoResponse>> responseEntity =
restTemplate.exchange(
requestEntity, new ParameterizedTypeReference<>(){});
List<TodoResponse> todoResponseList = responseEntity.getBody();
return todoResponseList;
}

32

33.
[beta]
複数件検索(RestClient)
public List<TodoResponse> getByKeyword(String keyword) {
List<TodoResponse> todoResponseList = restClient.get()
.uri("/api/todos?keyword=" + keyword)
.retrieve()
.body(new ParameterizedTypeReference<>(){});
return todoResponseList;
}

メソッドチェーンで
書く!

baseUrl不要!

33

34.
[beta]
単一検索(RestTemplate)
public Optional<TodoResponse> getById(Integer id) {
try {
TodoResponse todoResponse = restTemplate.getForObject(
"/api/todos/" + id, TodoResponse.class);
return Optional.of(todoResponse);
} catch (HttpClientErrorException.NotFound e) {
return Optional.empty();
}
}

34

35.
[beta]
単一検索(RestClient)
public Optional<TodoResponse> getById(Integer id) {
try {
Optional<TodoResponse> todoResponseOptional = restClient.get()
.uri("/api/todos/" + id)
.retrieve()
Optionalも返せる!
.body(new ParameterizedTypeReference<>(){});
return todoResponseOptional;
} catch (HttpClientErrorException.NotFound e) {
return Optional.empty();
}
}

35

36.
[beta]
追加
◆RestTemplate
public URI register(TodoRequest request) {
URI location = restTemplate.postForLocation("/api/todos", request);
return location;
}

◆RestClient
public URI register(TodoRequest request) {
ResponseEntity<Void> responseEntity = restClient.post()
.uri("/api/todos").body(request)
.retrieve().toBodilessEntity();
URI location = responseEntity.getHeaders().getLocation();
return location;
}

36

37.

更新 ◆RestTemplate public void update(Integer id, TodoRequest request) { try { restTemplate.put("/api/todos/" + id, request); } catch (HttpClientErrorException.NotFound e) { throw new TodoNotFoundException(e);}} ◆RestClient public void update(Integer id, TodoRequest request) { try { restClient.put().uri("/api/todos/" + id).body(request) .retrieve().toBodilessEntity(); } catch (HttpClientErrorException.NotFound e) { throw new TodoNotFoundException(e);}} 37

38.

削除 ◆RestTemplate public void delete(Integer id) { try { restTemplate.delete("/api/todos/" + id); } catch (HttpClientErrorException.NotFound e) { throw new TodoNotFoundException(e);}} ◆RestClient public void delete(Integer id) { try { restClient.delete().uri("/api/todos/" + id) .retrieve().toBodilessEntity(); } catch (HttpClientErrorException.NotFound e) { throw new TodoNotFoundException(e);}} RestClientの方が コードは多少長い。 しかし全リクエストメソッドで ほぼ同じ書き方ができる 38

39.

テスト用モックサーバー、どれ使う? ◆MockRestServiceServer ◆Spring謹製。実際にサーバーは起動しない ◆RestClientと組み合わせるには工夫が必要(ドキュメントに記載なし) ◆ソースコードを読みまくって調べました https://qiita.com/suke_masa/items/057b71b03d359343b30d ◆WireMock ◆OSSのモックサーバー。Jettyを利用してテスト用サーバーを起動する ◆公式の@WireMockTestアノテーションを使うとうまく動かなかった(原因不明 ) → テストクラス内でWireMockServerを直接使うことで解決 ◆MockServer ◆開発がここ数年止まっている 39

40.

WireMockを使う dependencies { ... testImplementation("org.springframework.boot:spring-boot-starter-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.wiremock:wiremock-jetty12:3.13.1") } org.wiremock:wiremockにすると、 起動時に「Jettyが無いよ」と怒られる → org.wiremock:wiremock-jetty12を利用 40

41.

WireMockでテスト @SpringBootTest public class RestClientTodoClientTest { @Autowired RestClientTodoClient todoClient; static WireMockServer wireMockServer; @BeforeAll static void beforeAll() { wireMockServer = new WireMockServer(9999); wireMockServer.start(); } @AfterAll static void afterAll() { wireMockServer.stop(); } // 次のスライドへ ポート番号 41

42.
[beta]
WireMockでテスト
@Test @DisplayName("IDを指定すると、該当するTODOのOptionalを取得できる")
void success() {
// WireMockの設定
wireMockServer.stubFor(get("/api/todos/1")
.willReturn(okJson("""
{"id": 1, "description": "Example 1","completed": true,
"deadline": "2025-10-01T12:00:00",
"createdAt": "2025-09-01T12:00:00"}""")));
// テストの実行
Optional<TodoResponse> actual = todoClient.getById(1);
// 結果の検証
assertEquals(
new TodoResponse(1, "Example 1", true,
LocalDateTime.parse("2025-10-01T12:00:00"),
LocalDateTime.parse("2025-09-01T12:00:00")
), actual.get()
);
}

42

43.

RestClientのまとめ ◆全リクエストメソッドで同じ書き方ができて嬉しいですね! ◆モックサーバー、どれにしましょうね? ◆多田はMockRestServiceServerがメンドイ (他の人がメンテできる気があんまりしない)ので、 次はWireMockにしようかなと思ってます 43

44.

ご清聴ありがとうございました! ◆よいSpringライフを! 44