本节目标:把图书、会员、借阅三类 SQL 收进仓储实现,让领域层只依赖接口与 record,并把数据库错误翻译成
libraryd已有的ErrorCode。
适用版本:JDK 25、PostgreSQL 17、PostgreSQL JDBC 42.7.14、HikariCP 7.1.0。
承接的问题
3.1 和 3.2 解决了「怎样连数据库」和「连接从哪里来」,但 SQL 还没有归属。如果让服务层直接拿 DataSource,LibraryService.borrow 里就会同时出现业务规则、SQL 字符串、ResultSet 映射和 SQLException 捕获。第 6 章要接 JSON、第 7 章要接 HTTP,那时再改就晚了。
第 1 章拆模块时已经定下方向:libraryd-domain 放领域类型和仓储端口,libraryd-persistence 放 JDBC 实现。本节把这个约定落到三个类上:
JdbcBookRepository实现BookRepository。JdbcMemberRepository实现MemberRepository。JdbcLoanRepository实现LoanRepository。
这里还有一次必须说明的模型演进。卷一的 Loan 只有 isbn、memberId、borrowedOn、dueOn、returnedOn 五个字段,因为内存仓储用 ISBN 就能找到活动借阅。卷二的借阅表有了 bigserial 主键,Loan 也在第一位加上 long id:
public record Loan(
long id,
String isbn,
String memberId,
LocalDate borrowedOn,
LocalDate dueOn,
LocalDate returnedOn,
int renewCount) {
id = 0 表示「还没有写入数据库」。Loan.open(...) 返回的就是这样的对象,仓储的 insert 负责写库并用数据库生成的值替换它。把 0 当哨兵不优雅,但它比 Optional<Long> 简单,也比「先查再插」少一次往返;只要约定写在一处,调用方不需要猜。
核心机制
端口在 domain,实现在 persistence
端口只描述领域需要什么,不描述数据库怎样实现。BookRepository 的签名如下:
package com.plume.library.domain;
import java.util.List;
import java.util.Optional;
public interface BookRepository {
Optional<Book> findByIsbn(String isbn);
List<Book> findPage(String keyword, int offset, int limit);
long count(String keyword);
void insert(Book book);
boolean update(Book book);
boolean deleteByIsbn(String isbn);
int[] insertBatch(List<Book> books);
}
返回类型是有意选的:
- 单条查询用
Optional<Book>,让「没找到」成为类型的一部分,而不是null。 - 条件更新用
boolean,true表示确实改了一行;false表示记录不存在或状态已经变了。 insert不返回boolean。主键冲突、外键缺失都是异常,不应该被调用方当成普通的false忽略。- 批量插入返回
int[],对应 JDBC 的executeBatch()。
LoanRepository 多几个面向借阅状态的方法:
public interface LoanRepository {
Loan insert(Loan loan);
Optional<Loan> findById(long id);
Optional<Loan> findActiveByIsbn(String isbn);
List<Loan> findActiveByMember(String memberId);
long countActiveByMember(String memberId);
boolean markReturned(long id, LocalDate returnedOn, long fineCents);
boolean renew(long id, LocalDate newDueOn);
}
findActiveByIsbn 的「活动」定义为 returned_on is null。这条定义同时出现在 SQL、部分唯一索引和领域方法 active() 里,三处必须一致;4.1 会把数据库侧的约束补齐。
依赖方向只有一条:persistence → domain。domain 不知道 DataSource,也不知道 SQL;persistence 的类可以实现端口,但不能反过来让领域类型依赖 JDBC。
行映射:列名只出现在一个地方
数据库列名和 Java 字段名不总是一样。Book.year 对应 published_year,Loan.memberId 对应 member_id。映射只写一次:
private static Book map(ResultSet rs) throws SQLException {
return new Book(
rs.getString("isbn"),
rs.getString("title"),
rs.getString("author"),
rs.getInt("published_year"));
}
private static Loan mapLoan(ResultSet rs) throws SQLException {
return new Loan(
rs.getLong("id"),
rs.getString("isbn"),
rs.getString("member_id"),
rs.getObject("borrowed_on", LocalDate.class),
rs.getObject("due_on", LocalDate.class),
rs.getObject("returned_on", LocalDate.class),
rs.getInt("renew_count"));
}
可空的 returned_on 用 getObject,避免 getLong 把 NULL 读成 0。映射方法都是 private static,ResultSet 不会越过仓储边界;上层拿到的是完整构造好的 record,不需要再关心游标是否关闭。
分页查询把「有没有关键词」收敛成两条 SQL,而不是在字符串里拼条件:
public List<Book> findPage(String keyword, int offset, int limit) {
boolean search = keyword != null && !keyword.isBlank();
String sql = search
? "select isbn, title, author, published_year from books"
+ " where title ilike ? or author ilike ? or isbn ilike ?"
+ " order by title, isbn limit ? offset ?"
: "select isbn, title, author, published_year from books"
+ " order by title, isbn limit ? offset ?";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
if (search) {
String pattern = likePattern(keyword);
statement.setString(index++, pattern);
statement.setString(index++, pattern);
statement.setString(index++, pattern);
}
statement.setInt(index++, limit);
statement.setInt(index, offset);
try (ResultSet rs = statement.executeQuery()) {
List<Book> books = new ArrayList<>();
while (rs.next()) {
books.add(map(rs));
}
return books;
}
} catch (SQLException e) {
throw SqlErrors.translate(e);
}
}
likePattern 会转义用户输入里的 %、_ 和反斜杠,否则搜索 % 会匹配整张表。这段代码在第 4 章还会调整:isbn 的模糊匹配会让索引失效,最终只保留 title 和 author 两个分支。
生成键与状态转换
JdbcLoanRepository.insert 是唯一需要把数据库生成值带回领域对象的方法:
public Loan insert(Loan loan) {
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(), Types.DATE);
statement.setInt(6, loan.renewCount());
try (ResultSet rs = statement.executeQuery()) {
rs.next();
return mapLoan(rs);
}
} catch (SQLException e) {
throw SqlErrors.translate(e);
}
}
returning 让插入和取回主键变成一次往返。返回的 Loan 和传入的 Loan 只差 id,调用方用返回值替换原来的对象。
主键来自序列,序列不承诺连续。本机验证时,前面几次故意失败的插入消耗了 nextval,所以第一次成功借阅的 id 是 1,归还后再次借阅的 id 是 5。中间缺的 2 到 4 不是丢数据,而是序列的正常行为。不要把 id 当作用户可见的计数器,也不要根据它推算借阅次数。
归还和续借都是条件更新:
public boolean markReturned(long id, LocalDate returnedOn, long fineCents) {
String sql = "update loans set returned_on = ?, fine_cents = ?"
+ " where id = ? and returned_on is null";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setObject(1, returnedOn);
statement.setLong(2, fineCents);
statement.setLong(3, id);
return statement.executeUpdate() == 1;
} catch (SQLException e) {
throw SqlErrors.translate(e);
}
}
where returned_on is null 是幂等性的第一层:第二次归还同一本书时更新 0 行,仓储返回 false,服务层可以据此决定返回「已归还」还是报 LOAN_NOT_FOUND。续借的 SQL 同样带条件:
String sql = "update loans set due_on = ?, renew_count = renew_count + 1"
+ " where id = ? and returned_on is null and renew_count < 1";
领域对象 Loan.renew 已经检查过逾期和次数,SQL 里的条件是为了防止两个并发请求同时通过 Java 侧检查。第 5 章会把这条思路展开成并发控制。
SQLState 到 ErrorCode
仓储不应该把 SQLException 原样抛给服务层,否则上层要认识 PostgreSQL 的错误码;也不应该捕获后返回空结果,否则数据库故障会伪装成「图书不存在」。正确做法是翻译成领域已有的 LibraryException:
static LibraryException translate(SQLException e) {
String state = e.getSQLState();
if (state == null) {
return new LibraryException(ErrorCode.INTERNAL, "数据库错误", e);
}
if (state.startsWith("08")) {
return new LibraryException(ErrorCode.DB_UNAVAILABLE, "数据库不可用", e);
}
String constraint = constraintName(e);
if ("23505".equals(state)) {
if ("ux_loans_active_isbn".equals(constraint)) {
return new LibraryException(ErrorCode.BOOK_ALREADY_BORROWED, "图书已借出", e);
}
return new LibraryException(ErrorCode.DUPLICATE_ISBN, "ISBN 已存在", e);
}
if ("23503".equals(state)) {
if ("loans_member_id_fkey".equals(constraint)) {
return new LibraryException(ErrorCode.MEMBER_NOT_FOUND, "会员不存在", e);
}
return new LibraryException(ErrorCode.BOOK_NOT_FOUND, "图书不存在", e);
}
if ("23514".equals(state) || "22P02".equals(state)) {
return new LibraryException(ErrorCode.VALIDATION_FAILED, "数据不满足约束", e);
}
return new LibraryException(ErrorCode.INTERNAL, "数据库错误", e);
}
约束名从 pgjdbc 的 PSQLException 里取,而不是匹配异常消息:
private static String constraintName(SQLException e) {
if (e instanceof PSQLException pg && pg.getServerErrorMessage() != null) {
return pg.getServerErrorMessage().getConstraint();
}
return null;
}
对应关系整理如下:
| SQLState | 约束或场景 | ErrorCode | HTTP |
|---|---|---|---|
23505 | ux_loans_active_isbn | BOOK_ALREADY_BORROWED | 409 |
23505 | books_pkey | DUPLICATE_ISBN | 400 |
23503 | loans_isbn_fkey | BOOK_NOT_FOUND | 404 |
23503 | loans_member_id_fkey | MEMBER_NOT_FOUND | 404 |
23514 | 检查约束 | VALIDATION_FAILED | 400 |
08xxx | 连接失败、连接中断 | DB_UNAVAILABLE | 503 |
| 其他 | 未预期 | INTERNAL | 500 |
23505 被两个业务含义共用,必须靠约束名区分。用 getConstraint() 比 e.getMessage().contains("ux_loans_active_isbn") 稳,因为消息会随 PostgreSQL 版本和本地化变化,约束名是 schema 的一部分。代价是 persistence 依赖 pgjdbc 的具体异常类型;既然实现本来就只支持 PostgreSQL,这个代价可以接受。
在 libraryd 里落地
JdbcMemberRepository.saveIfAbsent 体现会员自动建档的语义:
public void saveIfAbsent(Member member) {
String sql = "insert into members (id, name) values (?, ?)"
+ " on conflict (id) do nothing";
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, member.id());
statement.setString(2, member.name());
statement.executeUpdate();
} catch (SQLException e) {
throw SqlErrors.translate(e);
}
}
on conflict do nothing 而不是 do update,是为了让第一次写入的名字生效。借阅接口在会员不存在时会 saveIfAbsent(new Member(memberId, memberId));如果第二次借阅又用 id 当名字覆盖,会员之前设置的真实姓名就丢了。do nothing 让这条语句同时承担「检查存在」和「按需创建」两个职责,不需要先查一次。
三个实现类都只依赖构造器注入的 DataSource:
BookRepository books = new JdbcBookRepository(dataSource);
MemberRepository members = new JdbcMemberRepository(dataSource);
LoanRepository loans = new JdbcLoanRepository(dataSource);
它们不创建连接池,也不关闭连接池。池的创建和关闭属于应用装配层,仓储只负责借一条连接、用完还回去。这个边界让 3.2 的池参数可以独立调整,也让第 15 章的 Testcontainers 测试可以换成指向容器的数据源。
本机用真实 PostgreSQL 17 容器跑通的关键输出:
find=Book[isbn=9780132350884, title=Clean Code, author=Robert C. Martin, year=2008]
count all=3
count q=java=1
duplicate -> DUPLICATE_ISBN / ISBN 已存在
second active -> BOOK_ALREADY_BORROWED
missing book -> BOOK_NOT_FOUND
missing member -> MEMBER_NOT_FOUND
renew update=true row=Loan[id=1, isbn=9780132350884, memberId=m-001, borrowedOn=2026-10-17, dueOn=2026-11-14, returnedOn=null, renewCount=1]
second renew update=false
fine=150
markReturned=true
after return active=Optional.empty
can borrow again, id=5
这份输出同时验证了几件事:ISBN 冲突被翻译成 DUPLICATE_ISBN;同一本书第二次借出被部分唯一索引挡住并翻译成 BOOK_ALREADY_BORROWED;外键缺失能区分图书和会员;续借只能成功一次;归还后活动借阅消失,同一本书可以再次借出。
常见错误与边界
「捕获 SQLException 后返回 Optional.empty()。」 这会把数据库断线、权限不足、SQL 写错全部伪装成「没有这条记录」。上层返回 404,运维看到的是一次正常的未命中,真正的原因被吞掉。查询未命中返回 Optional.empty(),数据库错误必须抛出 LibraryException,DB_UNAVAILABLE 和 INTERNAL 各自有明确的状态码。
「在仓储方法里做跨方法事务。」 每个仓储方法自己借一条连接,loanRepository.insert(...) 和 memberRepository.saveIfAbsent(...) 是两条独立连接、两个自动提交事务。如果第二个失败,第一个已经落库。事务边界属于服务层,第 5 章会把连接和事务提升到服务方法外层;本节先把「每个方法一个原子操作」写清楚。
「把 ResultSet 或 Connection 返回给上层。」 try-with-resources 结束后连接已经还给池,ResultSet 也随之失效。上层如果延迟映射,会得到 SQLException: This connection has been closed 或读到不完整的数据。仓储的返回值必须是已经构造好的领域对象。
「用异常消息匹配约束名。」 消息文本会随 PostgreSQL 版本、lc_messages 和驱动版本变化。getConstraint() 读的是结构化字段,和 schema 里的约束名直接对应。只有拿不到 PSQLException 时,才退回 SQLState 的通用映射。
「把 renew 写成无条件 update。」 无条件更新会让第二次续借把 renew_count 加到 2,绕过领域规则和数据库检查约束,最终抛出一个本可以避免的 VALIDATION_FAILED。条件更新让「状态已经变了」表现为 false,由服务层决定怎样响应。
小结
- 仓储端口放在
domain,实现放在persistence,依赖方向只能是persistence → domain。 - 单条查询返回
Optional,条件更新返回boolean,插入失败用异常表达,不要用返回值吞掉错误。 - 列名到 record 的映射只写一次,
ResultSet不越过仓储边界。 - 卷二给
Loan加了long id,0表示未持久化;序列缺口是正常现象。 markReturned和renew都带状态条件,返回false表示这次转换没有发生。SQLState加约束名翻译成ErrorCode,数据库故障不能被伪装成 404。
数据访问层已经有了稳定的形状,接下来要回答「这些表为什么这样设计」。4.1 会把 V1__init.sql 定稿,逐条解释主键、外键、检查约束和部分唯一索引各自挡住什么错误。
阅读导航:上一节:3.2 HikariCP:连接池参数与泄漏排查 · 下一节:4.1 表设计:约束、范式与 libraryd 的 schema 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。