Spring Boot 中的新 JDBC 客户端: JdbcClient
Spring 6.1 引入了新的 JdbcClient
API,它是 JdbcTemplate
的封装,可使用 fluent 风格的 API 执行数据库操作。
本文将会带你学习如何使用 JdbcClient
以简化的方式实现各种数据库操作。
首先,访问 https://start.springboot.io,选择 Spring JDBC
、PostgreSQL Driver
、Flyway Migration
和 Testcontainers
starter,创建一个 Spring Boot 应用程序。
注:在撰写本文时,Spring Boot 3.2.0-M2 已发布,因此我们将选择 3.2.0 (M2) 作为 Spring Boot 版本。
创建 Bookmark 类
先创建一个表示书签的 Java record,如下所示:
import java.time.Instant;
public record Bookmark(Long id, String title, String url, Instant createdAt) {}
创建 Flyway 迁移脚本
在 src/main/resources/db/migration
目录下添加以下迁移脚本。
V1__create_tables.sql
:
create table bookmarks
(
id bigserial primary key,
title varchar not null,
url varchar not null,
created_at timestamp
);
使用 JdbClient 执行 CRUD 操作
使用 JdbcClient
API 在 Bookmark
类上实现 CRUD 操作。
@Repository
@Transactional(readOnly = true)
public class BookmarkRepository {
private final JdbcClient jdbcClient;
public BookmarkRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
...
...
...
}
检索所有书签
使用 JdbClient
获取所有书签,如下所示:
public List<Bookmark> findAll() {
String sql = "select id, title, url, created_at from bookmarks";
return jdbcClient.sql(sql).query(Bookmark.class).list();
}
JdbcClient
API 使用 SimplePropertyRowMapper
来动态创建 RowMapper
。它通过将驼峰命名转换为下划线,来进行 bean 属性名与表列名之间的映射。
如果需要对映射进行更多控制,可以自己创建一个 RowMapper
,使用方法如下:
public List<Bookmark> findAll() {
String sql = "select id, title, url, created_at from bookmarks";
return jdbcClient.sql(sql).query(new BookmarkRowMapper()).list();
}
static class BookmarkRowMapper implements RowMapper<Bookmark> {
@Override
public Bookmark mapRow(ResultSet rs, int rowNum) throws SQLException {
return new Bookmark(
rs.getLong("id"),
rs.getString("title"),
rs.getString("url"),
rs.getTimestamp("created_at").toInstant()
);
}
}
根据 id 检索书签
使用 JdbcClient
根据 id 检索书签,如下所示:
public Optional<Bookmark> findById(Long id) {
String sql = "select id, title, url, created_at from bookmarks where id = :id";
return jdbcClient.sql(sql).param("id", id).query(Bookmark.class).optional();
// 使用自定义的 RowMapper
//return jdbcClient.sql(sql).param("id", id).query(new BookmarkRowMapper()).optional();
}
插入新的书签
可以使用 PostgreSQL INSERT INTO ... RETURNING COL1, COL2
语法,然后使用 KeyHolder
获取生成的主键值。
在 bookmarks
表中插入新记录,并获取生成的主键值,如下:
@Transactional
public Long save(Bookmark bookmark) {
String sql = "insert into bookmarks(title, url, created_at) values(:title,:url,:createdAt) returning id";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcClient.sql(sql)
.param("title", bookmark.title())
.param("url", bookmark.url())
.param("createdAt", Timestamp.from(bookmark.createdAt()))
.update(keyHolder);
return keyHolder.getKeyAs(Long.class);
}
更新书签
更新书签记录,如下:
@Transactional
public void update(Bookmark bookmark) {
String sql = "update bookmarks set title = ?, url = ? where id = ?";
int count = jdbcClient.sql(sql)
.param(1, bookmark.title())
.param(2, bookmark.url())
.param(3, bookmark.id())
.update();
if (count == 0) {
throw new RuntimeException("Bookmark not found");
}
}
在 update(...)
方法中,我使用了位置参数(?
)而不是命名参数(:title
)进行演示。我强烈建议使用命名参数而不是位置参数。
删除书签
删除书签,如下:
@Transactional
public void delete(Long id) {
String sql = "delete from bookmarks where id = ?";
int count = jdbcClient.sql(sql).param(1, id).update();
if (count == 0) {
throw new RuntimeException("Bookmark not found");
}
}
使用 Testcontainers 测试 Repository
创建 src/test/resources/test_data.sql
文件,内容如下:
TRUNCATE TABLE bookmarks;
ALTER SEQUENCE bookmarks_id_seq RESTART WITH 1;
INSERT INTO bookmarks(title, url, created_at) VALUES
('How (not) to ask for Technical Help?','https://sivalabs.in/how-to-not-to-ask-for-technical-help', CURRENT_TIMESTAMP),
('Getting Started with Kubernetes','https://sivalabs.in/getting-started-with-kubernetes', CURRENT_TIMESTAMP),
('Few Things I learned in the HardWay in 15 years of my career','https://sivalabs.in/few-things-i-learned-the-hardway-in-15-years-of-my-career', CURRENT_TIMESTAMP),
('All the resources you ever need as a Java & Spring application developer','https://sivalabs.in/all-the-resources-you-ever-need-as-a-java-spring-application-developer', CURRENT_TIMESTAMP),
('SpringBoot Integration Testing using Testcontainers Starter','https://sivalabs.in/spring-boot-integration-testing-using-testcontainers-starter', CURRENT_TIMESTAMP),
('Testing SpringBoot Applications','https://sivalabs.in/spring-boot-testing', CURRENT_TIMESTAMP)
;
现在,我们可以在测试类中添加注解 @Sql("/test-data.sql")
,这样在运行每个测试之前,都会执行指定的 SQL 脚本。
接下来,使用 Testcontainers 测试 BookmarkRepository
,如下:
package com.sivalabs.bookmarks.domain;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.JdbcClientAutoConfiguration;
import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.jdbc.Sql;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@JdbcTest(properties = {
"spring.test.database.replace=none",
"spring.datasource.url=jdbc:tc:postgresql:15.4-alpine:///db"
})
@ImportAutoConfiguration(JdbcClientAutoConfiguration.class)
@Sql("/test-data.sql")
class BookmarkRepositoryTest {
@Autowired
JdbcClient jdbcClient;
BookmarkRepository bookmarkRepository;
@BeforeEach
void setUp() {
bookmarkRepository = new BookmarkRepository(jdbcClient);
}
@Test
void shouldFindAllBookmarks() {
List<Bookmark> bookmarks = bookmarkRepository.findAll();
assertThat(bookmarks).isNotEmpty();
assertThat(bookmarks).hasSize(6);
}
@Test
void shouldCreateBookmark() {
Bookmark bookmark = new Bookmark(null, "My Title", "https://sivalabs.in", Instant.now());
Long id = bookmarkRepository.save(bookmark);
assertThat(id).isNotNull();
}
@Test
void shouldGetBookmarkById() {
Bookmark bookmark = new Bookmark(null, "My Title", "https://sivalabs.in", Instant.now());
Long id = bookmarkRepository.save(bookmark);
Optional<Bookmark> bookmarkOptional = bookmarkRepository.findById(id);
assertThat(bookmarkOptional).isPresent();
assertThat(bookmarkOptional.get().id()).isEqualTo(id);
assertThat(bookmarkOptional.get().title()).isEqualTo(bookmark.title());
assertThat(bookmarkOptional.get().url()).isEqualTo(bookmark.url());
}
@Test
void shouldEmptyWhenBookmarkNotFound() {
Optional<Bookmark> bookmarkOptional = bookmarkRepository.findById(9999L);
assertThat(bookmarkOptional).isEmpty();
}
@Test
void shouldUpdateBookmark() {
Bookmark bookmark = new Bookmark(null, "My Title", "https://sivalabs.in", Instant.now());
Long id = bookmarkRepository.save(bookmark);
Bookmark changedBookmark = new Bookmark(id, "My Updated Title", "https://www.sivalabs.in", bookmark.createdAt());
bookmarkRepository.update(changedBookmark);
Bookmark updatedBookmark = bookmarkRepository.findById(id).orElseThrow();
assertThat(updatedBookmark.id()).isEqualTo(changedBookmark.id());
assertThat(updatedBookmark.title()).isEqualTo(changedBookmark.title());
assertThat(updatedBookmark.url()).isEqualTo(changedBookmark.url());
}
@Test
void shouldDeleteBookmark() {
Bookmark bookmark = new Bookmark(null, "My Title", "https://sivalabs.in", Instant.now());
Long id = bookmarkRepository.save(bookmark);
bookmarkRepository.delete(id);
Optional<Bookmark> optionalBookmark = bookmarkRepository.findById(id);
assertThat(optionalBookmark).isEmpty();
}
}
我们使用 Testcontainers
特殊的 JDBC URL 来启动 PostgreSQL 数据库并使用它运行测试。
总结
新的 JdbcClient
API 提供了 fluent 风格的 API,推荐使用它来代替 JdbcTemplate
。
参考:https://www.sivalabs.in/spring-boot-jdbcclient-tutorial/