《Java 编程实战》3.3 数据访问层:把 SQL 装进 Repository

SQL 散落在服务层会让事务、错误码和测试都变得难以收口。本节把图书、会员、借阅三类 SQL 收进 JdbcBookRepository、JdbcMemberRepository 与 JdbcLoanRepository,用行映射还原 record,并把 SQLState 翻译成 libraryd 的 ErrorCode。

本节目标:把图书、会员、借阅三类 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约束或场景ErrorCodeHTTP
23505ux_loans_active_isbnBOOK_ALREADY_BORROWED409
23505books_pkeyDUPLICATE_ISBN400
23503loans_isbn_fkeyBOOK_NOT_FOUND404
23503loans_member_id_fkeyMEMBER_NOT_FOUND404
23514检查约束VALIDATION_FAILED400
08xxx连接失败、连接中断DB_UNAVAILABLE503
其他未预期INTERNAL500

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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发