上一章已经让 Spring Boot 创建了池化 DataSource,并通过真实连接确认 PostgreSQL 可以访问。直接使用 JDBC 的 Connection、PreparedStatement 和 ResultSet 也能执行 SQL,但每个方法都要重复获取连接、绑定参数、遍历结果和处理异常。
JdbcClient 把这些固定步骤封装起来,同时保留手写 SQL 的控制力。本章先使用不依赖业务表的 PostgreSQL 查询学习它的基本 API,下一章创建 health_status 表后,再把相同写法用于 Repository。
1. JdbcClient
JdbcClient 是 Spring JDBC 提供的同步查询与更新接口。一次调用通常由四个阶段组成:
1jdbcClient.sql(SQL)2-> param(...) 绑定参数3-> query(...) 或 update() 选择操作4-> single() / optional() / list() 确定结果数量
它不会替代数据库、JDBC 驱动或连接池,而是位于这些组件之上:
1Repository2-> JdbcClient:组织 SQL、参数和结果映射3-> JdbcTemplate / NamedParameterJdbcTemplate:执行 JDBC 流程4-> DataSource:提供连接5-> PostgreSQL Driver6-> PostgreSQL
与 ORM 不同,JdbcClient 不会根据 Java 类自动推导整个数据模型。表名、列名、连接条件和排序仍然明确写在 SQL 中。它适合希望直接掌握 SQL,又不想重复编写底层 JDBC 样板代码的场景。
JdbcClient 使用阻塞式 JDBC。执行查询的线程会等待数据库返回结果,因此普通 Spring MVC 应用可以直接使用它;基于非阻塞事件循环的 WebFlux 应用则不能把 JDBC 查询直接放在事件循环线程中。
2. 注入与执行
上一章添加的 JDBC Starter 会让 Spring Boot 基于 DataSource 自动配置 JdbcClient Bean。业务组件只需要通过构造方法声明依赖:
01package cn.usehook.spring.database;0203import org.springframework.jdbc.core.simple.JdbcClient;04import org.springframework.stereotype.Component;0506@Component07public class DatabaseProbe {0809private final JdbcClient jdbcClient;1011public DatabaseProbe(JdbcClient jdbcClient) {12this.jdbcClient = jdbcClient;13}1415public boolean isAvailable() {16Integer result = jdbcClient.sql("select 1")17.query(Integer.class)18.single();19return result == 1;20}21}
sql() 只创建一份尚未执行的语句描述。调用末尾的 single() 时,JdbcClient 才会从 DataSource 取得连接、执行 SQL、读取结果并释放 JDBC 资源。
查询 select 1 应该恰好产生一行一列,所以使用 query(Integer.class) 把该列转换为 Integer,再用 single() 要求结果必须恰好有一条。如果数据库不可用或 SQL 无法执行,方法不会返回 false,而是抛出数据访问异常;false 只表示查询成功但结果不是 1。
这里使用 @Component 是因为 DatabaseProbe 只负责检查数据库连接并读取服务器信息,不表示业务数据集合。真正保存与查询 HealthStatus 的类仍然使用 @Repository。
3. 参数绑定
查询条件来自方法参数时,不应该使用字符串拼接。JdbcClient 同时支持 ? 位置参数和 :name 命名参数,本教程优先使用更容易对应含义的命名参数:
1public String echoService(String service) {2return jdbcClient.sql("select cast(:service as varchar)")3.param("service", service)4.query(String.class)5.single();6}
:service 是 SQL 中的参数占位符,.param("service", service) 把 Java 值交给 JDBC 绑定。值不会先拼进 SQL 文本,因此其中即使包含单引号,也只会被当作一个参数值,而不会改变语句结构。
下面这种写法不要用于外部输入:
1String sql = "select '" + service + "'";
字符串拼接会把数据和 SQL 结构混在一起,既容易遇到转义错误,也可能产生 SQL 注入。参数绑定还会帮助驱动使用正确的数据类型,因此不是只为省去引号。
占位符只能代表值,不能代表表名、列名或 ASC、DESC 等 SQL 关键字。需要动态排序时,应先把外部选项映射到代码中允许的固定列名,而不是把客户端传入的任意文本直接拼接到 SQL。
参数较多时,可以一次传入 Map,也可以让 record 作为参数源:
1jdbcClient.sql("select cast(:status as varchar), cast(:service as varchar)")2.params(Map.of("status", "UP", "service", "postgresql"));34jdbcClient.sql("select cast(:status as varchar), cast(:service as varchar)")5.paramSource(new HealthStatus("UP", "postgresql"));
使用 paramSource() 时,SQL 占位符名称要与 record 组件或 JavaBean 属性名称对应。语句仍然需要在末尾调用 query() 或 update() 才会执行。
4. 结果映射
单列结果可以直接指定目标类型,多列结果则需要说明每一行怎样变成 Java 对象。先创建一个普通 record 保存数据库信息:
1package cn.usehook.spring.database;23public record DatabaseInfo(String databaseName, String username) {}
然后给查询提供行映射函数:
01public DatabaseInfo readInfo() {02return jdbcClient.sql("""03select current_database() as database_name,04current_user as username05""")06.query((resultSet, rowNumber) -> new DatabaseInfo(07resultSet.getString("database_name"),08resultSet.getString("username")09))10.single();11}
SQL 使用别名确定结果列名。映射函数每读取一行就创建一个 DatabaseInfo,rowNumber 表示当前行号;这里不需要它,所以只读取 resultSet。
显式映射多写了几行代码,但字段对应关系非常清楚。JdbcClient 也支持按属性名称进行简化映射,字段较多时还可以提取可复用的 RowMapper。无论使用哪种方式,都要处理数据库命名与 Java 命名之间的差异,例如 database_name 与 databaseName。
5. 结果数量
映射完每一行以后,还要告诉 JdbcClient 调用方预期多少条结果:
| 结束方法 | 允许的结果数量 | 适合场景 |
|---|---|---|
single() | 恰好一条 | 聚合结果、主键查询且必须存在 |
optional() | 零条或一条 | 查询结果允许不存在 |
list() | 零条到多条 | 列表查询 |
single() 在没有结果或出现多条结果时都会抛出异常。optional() 在没有结果时返回 Optional.empty(),但出现多条结果仍会抛出异常。list() 总是返回列表,没有结果时得到空列表而不是 null。
选择哪个方法是 Repository 契约的一部分。例如「Repository」章节定义的 findByService() 允许状态不存在,数据库实现就应该使用 optional();findAll() 则使用 list()。不要先调用 list() 再随意取第一项,那会掩盖本应只有一条却返回多条的数据问题。
SQL 本身仍然决定列表顺序。只使用 list() 不会自动排序;需要稳定结果时必须在 SQL 中明确写出 ORDER BY。
6. 更新与异常
insert、update 和 delete 使用 update() 执行,返回值是受影响的行数。下一章创建表以后,Repository 会使用下面的形式更新状态:
1int affectedRows = jdbcClient.sql("""2update health_status3set status = :status4where service = :service5""")6.param("status", "UP")7.param("service", "postgresql")8.update();
affectedRows 为 0 通常表示没有任何记录符合条件,为 1 表示一条记录被更新。具体期望仍由当前方法语义决定,不能不加判断地认为任何数字都代表成功。
底层 JDBC API 通常抛出 SQLException。Spring JDBC 会把它转换为 DataAccessException 体系,例如语法错误、唯一约束冲突和无法取得连接会形成不同的数据访问异常。Repository 因而不需要在每个方法上声明 throws SQLException,Service 也不应该把所有数据访问失败误认为「查询结果不存在」。
DataAccessException 是运行时异常。需要记录、重试或转换时,应根据具体异常语义处理;没有恢复方案时让异常继续向上传播,由统一异常处理和日志设施处理,比捕获后返回一个虚假的空列表更可靠。
7. 运行验证
使用一个集成测试把前面的单值查询、参数绑定和行映射连接起来:
01package cn.usehook.spring.database;0203import org.junit.jupiter.api.Test;04import org.springframework.beans.factory.annotation.Autowired;05import org.springframework.boot.test.context.SpringBootTest;0607import static org.assertj.core.api.Assertions.assertThat;0809@SpringBootTest10class DatabaseProbeTests {1112private final DatabaseProbe databaseProbe;1314DatabaseProbeTests(@Autowired DatabaseProbe databaseProbe) {15this.databaseProbe = databaseProbe;16}1718@Test19void executesQueriesWithJdbcClient() {20assertThat(databaseProbe.isAvailable()).isTrue();21assertThat(databaseProbe.echoService("PostgreSQL")).isEqualTo("PostgreSQL");22assertThat(databaseProbe.readInfo())23.isEqualTo(new DatabaseInfo("spring", "spring"));24}25}
确保 PostgreSQL 正在运行,并提供上一章配置的密码:
1DB_PASSWORD='spring' ./mvnw -Dtest=DatabaseProbeTests test
测试通过说明 JdbcClient 已经能够借用连接、执行 SQL、绑定参数并映射结果。当前查询只读取 PostgreSQL 自身的信息,所以还没有应用数据可以保存。下一章会创建 health_status 表,并说明 Spring Boot 在什么时候执行建表和初始数据脚本。