本节目标:用 JDBC 直接连上 PostgreSQL,把一本书和一条借阅记录写进去再读出来,并看清
Connection、PreparedStatement、ResultSet的生命周期。
适用版本:JDK 25、PostgreSQL 17、PostgreSQL JDBC 42.7.14。
承接的问题
第 2 章结束时,libraryd 已经有了五模块骨架、统一的编译告警和静态分析门禁,但仓储仍然是卷一留下的内存实现。进程一重启,所有图书和借阅记录都会消失。第 3 章要把它接到 PostgreSQL 上。
真正动手前,先明确这一节只解决最底层的问题:Java 代码怎样和数据库说话。上层的 BookRepository、LoanRepository 接口长什么样,SQL 怎样组织,错误码怎样翻译,分别留给 3.3 和后续章节。现在只回答四个具体问题:
- 连接从哪里来,谁来关。
- SQL 里的参数怎样绑定,为什么不能拼字符串。
- 查询结果怎样从游标读成 Java 值。
- 插入借阅记录后,数据库生成的主键怎样拿回来。
这四个问题如果靠猜,后面每加一个查询就会多一处资源泄漏或类型错误。先把它们跑通,再谈抽象。
核心机制
驱动、URL 与 Connection
JDBC 4 之后,驱动不需要再写 Class.forName("org.postgresql.Driver")。只要 postgresql 依赖在运行时 classpath 上,DriverManager 会通过 META-INF/services/java.sql.Driver 自动发现它。卷一的工程如果还留着那行 Class.forName,删掉不会影响功能,但保留也无害;新代码不必再写。
连接由 URL 描述。PostgreSQL 的形状是:
jdbc:postgresql://主机:端口/数据库名
本节的本机验证容器把端口映射到 55433,所以 URL 是 jdbc:postgresql://127.0.0.1:55433/libraryd。生产环境从 LIBRARYD_DB_URL 读取,默认值才是 jdbc:postgresql://localhost:5432/libraryd。
最小连接代码如下:
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
try (Connection connection = DriverManager.getConnection(
"jdbc:postgresql://127.0.0.1:55433/libraryd",
"libraryd",
"libraryd")) {
DatabaseMetaData meta = connection.getMetaData();
System.out.println(meta.getDriverName() + " " + meta.getDriverVersion());
System.out.println(meta.getDatabaseProductName() + " " + meta.getDatabaseProductVersion());
}
本机输出:
PostgreSQL JDBC Driver 42.7.14
PostgreSQL 17.11
Connection 是 AutoCloseable,try-with-resources 会在代码块结束时调用 close()。但这里的关闭是关闭一条物理连接:下一次调用 DriverManager.getConnection 会重新做 TCP 握手、认证和会话初始化。3.2 会用 HikariCP 把这一步换成从池里借还。
还要记住两点。第一,Connection 不是线程安全的,不要把它存成单例字段让多个请求共用。第二,Connection 的创建成本远高于一条普通对象,所以它的生命周期应该覆盖一组数据库操作,而不是整个应用。
PreparedStatement 与参数绑定
把值直接拼进 SQL 是最容易犯的错。先看正确写法:
import java.sql.PreparedStatement;
import java.time.LocalDate;
String sql = "insert into loans (isbn, member_id, borrowed_on, due_on)"
+ " values (?, ?, ?, ?)";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
LocalDate today = LocalDate.of(2026, 10, 17);
statement.setString(1, "9780132350884");
statement.setString(2, "m-001");
statement.setObject(3, today);
statement.setObject(4, today.plusDays(14));
int rows = statement.executeUpdate();
System.out.println("rows=" + rows);
}
? 是占位符,序号从 1 开始。setObject(index, LocalDate) 会借助 JDBC 4.2 的映射把 LocalDate 写成 SQL date,不需要先转成 java.sql.Date。executeUpdate() 返回受影响的行数,插入、更新、删除都用它;查询用 executeQuery()。
拼字符串的危险可以用一个对照实验看清:
String input = "' or '1'='1";
String unsafeSql = "select count(*) from books where title = '" + input + "'";
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery(unsafeSql)) {
rs.next();
System.out.println("concatenated=" + rs.getLong(1));
}
传入的 input 把原本的等值条件改成了恒真条件,整张 books 表都被匹配。本机对照输出:
concatenated query count=1
prepared query count=0
两条查询的差别不在数据库,而在 Java 侧:拼接后的 SQL 已经是另一条语句;PreparedStatement 先把 SQL 结构发给数据库,参数只作为值传入,引号不会变成语法。参数只能绑定值,不能绑定表名、列名或 order by 的方向,这些位置如果需要动态拼接,必须来自白名单而不是用户输入。
插入借阅记录时,主键由 bigserial 生成。JDBC 的标准做法是让驱动回传生成键:
try (PreparedStatement statement = connection.prepareStatement(
"insert into loans (isbn, member_id, borrowed_on, due_on) values (?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, "9780132350884");
statement.setString(2, "m-001");
statement.setObject(3, today);
statement.setObject(4, today.plusDays(14));
statement.executeUpdate();
try (ResultSet keys = statement.getGeneratedKeys()) {
while (keys.next()) {
System.out.println("generated loan id=" + keys.getLong(1));
}
}
}
本机输出是 generated loan id=1。getGeneratedKeys() 返回的也是一张 ResultSet,通常只有一行一列,列名依驱动而定,所以这里用序号 1 读取,不依赖列标签。
PostgreSQL 还支持另一种写法:在 insert 末尾加 returning id,然后把它当查询执行。3.3 的仓储实现会采用 returning,因为它在同一条语句里可以返回整行,不必再查一次。
ResultSet 与行映射
ResultSet 是游标,初始位置在第一行之前。必须调用 next() 才能读到第一行;返回 false 表示没有更多行。读取列有两种方式:列序号或列标签。列标签可读性更好,但要注意 SQL 里有没有重名列。
try (PreparedStatement statement = connection.prepareStatement(
"select id, isbn, member_id, borrowed_on, due_on, returned_on"
+ " from loans where isbn = ?")) {
statement.setString(1, "9780132350884");
try (ResultSet rs = statement.executeQuery()) {
ResultSetMetaData md = rs.getMetaData();
System.out.println("columns=" + md.getColumnCount());
while (rs.next()) {
long id = rs.getLong("id");
String isbn = rs.getString("isbn");
LocalDate borrowedOn = rs.getObject("borrowed_on", LocalDate.class);
LocalDate returnedOn = rs.getObject("returned_on", LocalDate.class);
System.out.println(id + " " + isbn + " " + borrowedOn + " -> " + returnedOn);
}
}
}
本机读到的列类型是:
columns=id:bigserial, isbn:varchar, member_id:varchar, borrowed_on:date, due_on:date, returned_on:date
getObject("returned_on", LocalDate.class) 在列为 NULL 时返回 null,这正是 Loan.returnedOn 需要的语义。相反,getLong 和 getInt 在列为 NULL 时返回 0,调用方无法区分「没有值」和「值就是 0」。对可空列,优先用 getObject;如果只能用基本类型读取器,读完后调用 wasNull() 判断。
资源关闭顺序由 try-with-resources 自动处理:后声明的 ResultSet 先关,然后 PreparedStatement,最后 Connection。不要只关 Connection 而把 ResultSet 留在外面,驱动可能延迟释放游标占用的资源。
在 libraryd 里落地
libraryd 的模块边界是:libraryd-domain 放 Book、Loan 和仓储端口,libraryd-persistence 放 JDBC 实现。3.1 先写出 JdbcBookRepository 里最基础的两个方法,完整实现留到 3.3。
package com.plume.library.persistence;
import com.plume.library.domain.Book;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
public final class JdbcBookRepository {
private final DataSource dataSource;
public JdbcBookRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
public Optional<Book> findByIsbn(String isbn) {
String sql = "select isbn, title, author, published_year from books where isbn = ?";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, isbn);
try (ResultSet rs = statement.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
} catch (SQLException e) {
throw new IllegalStateException("查询图书失败: " + isbn, e);
}
}
public void insert(Book book) {
String sql = "insert into books (isbn, title, author, published_year)"
+ " values (?, ?, ?, ?)";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, book.isbn());
statement.setString(2, book.title());
statement.setString(3, book.author());
statement.setInt(4, book.year());
statement.executeUpdate();
} catch (SQLException e) {
throw new IllegalStateException("写入图书失败: " + book.isbn(), e);
}
}
private static Book map(ResultSet rs) throws SQLException {
return new Book(
rs.getString("isbn"),
rs.getString("title"),
rs.getString("author"),
rs.getInt("published_year"));
}
}
这里已经能看出数据库列名和领域字段的差异:Book.year 对应的列是 published_year。映射只发生在 map 一个地方,上层拿到的仍然是不可变 record,不需要知道 SQL 列叫什么。
第二个例子是借阅记录的插入。前面用 RETURN_GENERATED_KEYS 拿主键,仓储里用 PostgreSQL 的 returning 更直接:
String sql = "insert into loans (isbn, member_id, borrowed_on, due_on, returned_on, renew_count)"
+ " values (?, ?, ?, ?, ?, ?) returning id, isbn, member_id,"
+ " borrowed_on, due_on, returned_on, renew_count";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, loan.isbn());
statement.setString(2, loan.memberId());
statement.setObject(3, loan.borrowedOn());
statement.setObject(4, loan.dueOn());
statement.setObject(5, loan.returnedOn(), java.sql.Types.DATE);
statement.setInt(6, loan.renewCount());
try (ResultSet rs = statement.executeQuery()) {
rs.next();
return mapLoan(rs);
}
}
setObject(5, null, Types.DATE) 处理尚未归还的记录:普通 setObject(index, null) 在部分驱动上会因为无法推断类型而报错,显式传 Types.DATE 更稳。returning 返回的是一张完整结果集,所以用 executeQuery() 而不是 executeUpdate()。
本机跑通后的关键输出:
find=Book[isbn=9780132350884, title=Clean Code, author=Robert C. Martin, year=2008]
inserted loan=Loan[id=1, isbn=9780132350884, memberId=m-001, borrowedOn=2026-10-17, dueOn=2026-10-31, returnedOn=null, renewCount=0]
连接 URL 不要写死在类里。DataSource 由 3.2 的 DataSourceFactory 创建,URL、用户名、密码和池大小来自 LIBRARYD_DB_* 与 LIBRARYD_POOL_SIZE。这样测试可以指向 55433 的容器,部署时可以指向真实数据库,代码本身不改。
常见错误与边界
「URL 写错一个字母。」 本机把 postgresql 写成 postgresq 时,DriverManager 找不到匹配的驱动,直接抛出:
java.sql.SQLException: No suitable driver found for jdbc:postgresq://127.0.0.1:55433/libraryd
这个异常不代表驱动没打进包,也可能是 URL 前缀不合法。先核对 jdbc:postgresql://,再检查运行时依赖里有没有 postgresql-42.7.14.jar。
「用字符串拼接 SQL。」 前面的对照已经说明,用户输入会改变 SQL 结构。即使输入来自内部系统,也不要开这个口子;一旦某个字段后来接了外部数据,漏洞就出现了。
「把 LocalDate 先转成 java.sql.Date 再 setDate。」 java.sql.Date 继承自 java.util.Date,内部带毫秒时间戳,跨时区时容易偏移一天。列是 date 就用 setObject(LocalDate),列是 timestamptz 才需要 Instant 或 OffsetDateTime。不要用 java.util.Date 同时表达两者。
「用 getLong 读可空列。」 returned_on 为 NULL 时 getLong 返回 0,后续把 0 当成有效日期会得到 1970-01-01 之类的错误结果。可空列用 getObject(..., LocalDate.class),或者读完后立刻检查 wasNull()。
「只关 Connection,不关 Statement 和 ResultSet。」 try-with-resources 应该按「Connection → PreparedStatement → ResultSet」的顺序逐层声明。只关最外层可能让驱动延迟释放游标;在连接池里,这种延迟会表现为池连接迟迟不归还。
小结
- JDBC 4 会自动发现 classpath 上的 PostgreSQL 驱动,新代码不必写
Class.forName。 Connection是物理连接,创建成本高且不是线程安全的;3.2 会用连接池管理它的生命周期。PreparedStatement用?绑定值,既处理类型也隔离 SQL 结构,拼接字符串会把数据变成语法。LocalDate用setObject写入date列,可空列用getObject(..., LocalDate.class)读取,避免getLong的 0 与NULL混淆。- 插入后需要主键时,可以用
RETURN_GENERATED_KEYS,也可以在 PostgreSQL 里用returning直接返回整行。 - 资源关闭按
Connection、PreparedStatement、ResultSet逐层交给try-with-resources,不要依赖垃圾回收。
底层 API 跑通后,下一个问题是连接从哪里来。每条请求都新建物理连接会让延迟和数据库连接数同时失控,3.2 用 HikariCP 把这件事收进一个有界池。
阅读导航:上一节:2.3 质量门禁:JaCoCo 覆盖率与 CI 流水线 · 下一节:3.2 HikariCP:连接池参数与泄漏排查 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。