# 外部库 JDBC 封装指南

适用场景:接收 HikariDataSource(或 javax.sql.DataSource),硬编码拼接 SQL 做增删改查。
目标:消除重复的 try-catch / 资源关闭样板,引入动态数据源、抽象 SQL 构建。

源码位置:

路径
JdbcExecutionUtil src/main/java/com/liang/learn/util/JdbcExecutionUtil.java
ExternalHikariPoolHolder src/main/java/com/liang/learn/service/jdbc/ExternalHikariPoolHolder.java

1. 设计原则

抽什么 / 不抽什么

不抽
Connection / PreparedStatement / ResultSet 生命周期 SQL 字符串(仍在业务方法里拼接)
catch → 可选 scene log → BizException.wrap 通用 CRUD 基类、反射实体映射
可选的外部库 Hikari 池缓存 @DS / AbstractRoutingDataSource

与项目异常规范对齐

  • 中间层:只 throw BizException.wrap(e),不打堆栈。
  • 场景 log:一行 log.warn,不带堆栈;scenenull 时跳过(复合编排内层使用)。
  • 明确业务校验new BizException(ErrorCode, msg) 无 cause;JdbcExecutionUtil 会原样透传,不重复打 scene log。
  • 详细堆栈:只在 GlobalExceptionHandler / BizExceptionLogUtil.logFailure 打一次。

架构

flowchart TB subgraph biz [业务层 XxxJdbcDao] SQL["硬编码 SQL + 参数绑定"] end subgraph util [JdbcExecutionUtil] RES["资源关闭"] EX["异常统一 wrap"] end subgraph pool [ExternalHikariPoolHolder] HIK["按 dataSourceId 缓存 HikariDataSource"] end biz --> util biz --> pool pool --> DS["HikariDataSource"]

2. 审查修复清单

问题 修复
Function<ResultSet,T> 无法传播 SQLException 新增 RowMapper<T>,声明 throws SQLException
query(DataSource) 嵌套双层 scene / 双 wrap 抽取 query0/update0/execute0,DataSource 入口只包一层 try-catch
复合任务内层 + 外层重复 scene log scene 允许 null;内层传 null,外层统一打 log
无返回值场景 return null 可读性差 新增 run() / runOnConnection()
缺少 DataSource overload queryList / queryForLong / execute 的 DataSource 重载
池配置更新后仍用旧池 文档约定 + evict(id);删除数据源时调用
应用关闭连接池泄漏 @PreDestroy destroy() 关闭全部池
disabled 数据源仍可建池 assertActive() 校验 status
无用 import、参数 NPE 去掉 Hikari import;Objects.requireNonNull 关键参数

3. JdbcExecutionUtil 完整实现

package com.liang.learn.util;

import com.liang.learn.exception.BizException;
import lombok.extern.slf4j.Slf4j;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
 * JDBC 执行模板:统一资源关闭与 {@link BizException#wrap(Throwable)}。
 * SQL 仍由调用方硬编码拼接;本类只消除 try-catch 与 Statement/ResultSet 关闭样板。
 */
@Slf4j
public final class JdbcExecutionUtil {

    private JdbcExecutionUtil() {
    }

    @FunctionalInterface
    public interface StatementBinder {
        void bind(PreparedStatement ps) throws SQLException;
    }

    @FunctionalInterface
    public interface ResultExtractor<T> {
        T extract(ResultSet rs) throws SQLException;
    }

    @FunctionalInterface
    public interface RowMapper<T> {
        T mapRow(ResultSet rs) throws SQLException;
    }

    @FunctionalInterface
    public interface ConnectionAction<T> {
        T execute(Connection connection) throws SQLException;
    }

    @FunctionalInterface
    public interface ConnectionVoidAction {
        void execute(Connection connection) throws SQLException;
    }

    // ---------- 连接入口 ----------

    public static <T> T withConnection(DataSource dataSource, String scene, ConnectionAction<T> action) {
        Objects.requireNonNull(dataSource, "dataSource");
        Objects.requireNonNull(action, "action");
        try (Connection connection = dataSource.getConnection()) {
            return action.execute(connection);
        } catch (Exception e) {
            return rethrow(scene, e);
        }
    }

    public static void run(DataSource dataSource, String scene, ConnectionVoidAction action) {
        withConnection(dataSource, scene, conn -> {
            action.execute(conn);
            return null;
        });
    }

    public static <T> T onConnection(Connection connection, String scene, ConnectionAction<T> action) {
        Objects.requireNonNull(connection, "connection");
        Objects.requireNonNull(action, "action");
        try {
            return action.execute(connection);
        } catch (Exception e) {
            return rethrow(scene, e);
        }
    }

    public static void runOnConnection(Connection connection, String scene, ConnectionVoidAction action) {
        onConnection(connection, scene, conn -> {
            action.execute(conn);
            return null;
        });
    }

    // ---------- 查询 ----------

    public static <T> T query(Connection connection, String sql, String scene,
                              StatementBinder binder, ResultExtractor<T> extractor) {
        return onConnection(connection, scene, conn -> query0(conn, sql, binder, extractor));
    }

    public static <T> T query(DataSource dataSource, String sql, String scene,
                              StatementBinder binder, ResultExtractor<T> extractor) {
        Objects.requireNonNull(dataSource, "dataSource");
        try (Connection connection = dataSource.getConnection()) {
            return query0(connection, sql, binder, extractor);
        } catch (Exception e) {
            return rethrow(scene, e);
        }
    }

    public static <T> List<T> queryList(Connection connection, String sql, String scene,
                                        StatementBinder binder, RowMapper<T> rowMapper) {
        Objects.requireNonNull(rowMapper, "rowMapper");
        return query(connection, sql, scene, binder, rs -> {
            List<T> rows = new ArrayList<>();
            while (rs.next()) {
                rows.add(rowMapper.mapRow(rs));
            }
            return rows;
        });
    }

    public static <T> List<T> queryList(DataSource dataSource, String sql, String scene,
                                        StatementBinder binder, RowMapper<T> rowMapper) {
        return withConnection(dataSource, scene,
                conn -> queryList(conn, sql, null, binder, rowMapper));
    }

    public static long queryForLong(Connection connection, String sql, String scene, StatementBinder binder) {
        return query(connection, sql, scene, binder, rs -> rs.next() ? rs.getLong(1) : 0L);
    }

    public static long queryForLong(DataSource dataSource, String sql, String scene, StatementBinder binder) {
        return query(dataSource, sql, scene, binder, rs -> rs.next() ? rs.getLong(1) : 0L);
    }

    // ---------- 增删改 / execute ----------

    public static int update(Connection connection, String sql, String scene, StatementBinder binder) {
        return onConnection(connection, scene, conn -> update0(conn, sql, binder));
    }

    public static int update(DataSource dataSource, String sql, String scene, StatementBinder binder) {
        return withConnection(dataSource, scene, conn -> update0(conn, sql, binder));
    }

    public static boolean execute(Connection connection, String sql, String scene, StatementBinder binder) {
        return onConnection(connection, scene, conn -> execute0(conn, sql, binder));
    }

    public static boolean execute(DataSource dataSource, String sql, String scene, StatementBinder binder) {
        return withConnection(dataSource, scene, conn -> execute0(conn, sql, binder));
    }

    // ---------- 无 try-catch 核心 ----------

    private static <T> T query0(Connection connection, String sql,
                                StatementBinder binder, ResultExtractor<T> extractor) throws SQLException {
        Objects.requireNonNull(sql, "sql");
        Objects.requireNonNull(extractor, "extractor");
        try (PreparedStatement ps = connection.prepareStatement(sql)) {
            if (binder != null) {
                binder.bind(ps);
            }
            try (ResultSet rs = ps.executeQuery()) {
                return extractor.extract(rs);
            }
        }
    }

    private static int update0(Connection connection, String sql, StatementBinder binder) throws SQLException {
        Objects.requireNonNull(sql, "sql");
        try (PreparedStatement ps = connection.prepareStatement(sql)) {
            if (binder != null) {
                binder.bind(ps);
            }
            return ps.executeUpdate();
        }
    }

    private static boolean execute0(Connection connection, String sql, StatementBinder binder) throws SQLException {
        Objects.requireNonNull(sql, "sql");
        try (PreparedStatement ps = connection.prepareStatement(sql)) {
            if (binder != null) {
                binder.bind(ps);
            }
            return ps.execute();
        }
    }

    private static <T> T rethrow(String scene, Exception e) {
        if (scene != null && !(e instanceof BizException)) {
            log.warn(scene);
        }
        throw BizException.wrap(e);
    }
}

API 速查

方法 连接来源 是否关闭连接 典型用途
withConnection DataSource 自动关闭 单次任务
run DataSource 自动关闭 无返回值单次任务
onConnection 外部 Connection 不关闭 长连接多句 SQL
runOnConnection 外部 Connection 不关闭 无返回值长连接
query / queryList / queryForLong 两者均有重载 见上 SELECT
update 两者均有重载 见上 INSERT/UPDATE/DELETE
execute 两者均有重载 见上 DDL 等

scene 约定

  • 最外层业务方法:传可读场景,如 "订单行数统计失败"
  • 被编排方法内层调用:传 null,避免与外层重复 warn。
  • 明确业务校验在 util 之外抛出 BizException,util 不会重复 log。

4. ExternalHikariPoolHolder 完整实现

package com.liang.learn.service.jdbc;

import com.liang.learn.exception.BizException;
import com.liang.learn.model.entity.DataSourceDO;
import com.liang.learn.model.enums.DataSourceStatusEnum;
import com.liang.learn.model.enums.DataSourceTypeEnum;
import com.liang.learn.model.enums.ErrorCode;
import com.liang.learn.service.DataSourceService;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Component
@RequiredArgsConstructor
public class ExternalHikariPoolHolder {

    private static final int DEFAULT_MAX_POOL_SIZE = 5;
    private static final int DEFAULT_MIN_IDLE = 1;
    private static final long CONNECTION_TIMEOUT_MS = 10_000L;

    private final DataSourceService dataSourceService;
    private final Map<Long, HikariDataSource> pools = new ConcurrentHashMap<>();

    public HikariDataSource getPool(DataSourceDO dataSource) {
        Objects.requireNonNull(dataSource, "dataSource");
        Objects.requireNonNull(dataSource.getId(), "dataSource.id");
        assertActive(dataSource);
        return pools.computeIfAbsent(dataSource.getId(), id -> createPool(dataSource));
    }

    public void evict(Long dataSourceId) {
        if (dataSourceId == null) {
            return;
        }
        HikariDataSource removed = pools.remove(dataSourceId);
        if (removed != null) {
            log.info("外部库连接池已驱逐, dataSourceId={}", dataSourceId);
            removed.close();
        }
    }

    @PreDestroy
    public void destroy() {
        pools.forEach((id, pool) -> {
            log.info("关闭外部库连接池, dataSourceId={}", id);
            pool.close();
        });
        pools.clear();
    }

    private void assertActive(DataSourceDO dataSource) {
        String status = dataSource.getStatus();
        if (status == null || DataSourceStatusEnum.ACTIVE.getCode().equalsIgnoreCase(status)) {
            return;
        }
        throw new BizException(ErrorCode.BUSINESS_ERROR, "数据源不可用: " + dataSource.getDataSourceCode());
    }

    private HikariDataSource createPool(DataSourceDO dataSource) {
        DataSourceTypeEnum type = DataSourceTypeEnum.fromCode(dataSource.getDataSourceType());
        HikariConfig config = new HikariConfig();
        config.setPoolName("ext-" + dataSource.getDataSourceCode());
        config.setDriverClassName(type.resolveDriverClassName(dataSource.getDriverClassName()));
        config.setJdbcUrl(type.buildJdbcUrl(
                dataSource.getHost(), dataSource.getPort(),
                dataSource.getDatabaseName(), dataSource.getSchema()));
        config.setUsername(dataSource.getUsername());
        config.setPassword(dataSourceService.getDecryptedPassword(dataSource.getId()));
        config.setMaximumPoolSize(DEFAULT_MAX_POOL_SIZE);
        config.setMinimumIdle(DEFAULT_MIN_IDLE);
        config.setConnectionTimeout(CONNECTION_TIMEOUT_MS);
        return new HikariDataSource(config);
    }
}

池生命周期集成点

删除数据源时驱逐池(待接入,示例):

// DataSourceServiceImpl.delete 成功落库后
poolHolder.evict(id);

若后续增加「更新连接信息」接口,更新成功后同样须 evict(id),否则会继续使用旧密码/主机。


5. 用法示例

5.1 重构前后:行数统计

重构前ExternalJdbcExecutor 现有写法):

public long countRows(Connection connection, String sql, String startTime, String endTime) {
    try (PreparedStatement ps = connection.prepareStatement(sql)) {
        ps.setTimestamp(1, Timestamp.valueOf(startTime));
        ps.setTimestamp(2, Timestamp.valueOf(endTime));
        try (ResultSet rs = ps.executeQuery()) {
            return rs.next() ? rs.getLong(1) : 0L;
        }
    } catch (Exception e) {
        log.warn("行数统计失败");
        throw BizException.wrap(e);
    }
}

重构后

public long countRows(Connection connection, String sql, String startTime, String endTime) {
    return JdbcExecutionUtil.queryForLong(connection, sql, "行数统计失败", ps -> {
        ps.setTimestamp(1, Timestamp.valueOf(startTime));
        ps.setTimestamp(2, Timestamp.valueOf(endTime));
    });
}

5.2 业务 Dao:单次查询(DataSource 入口)

@Component
@RequiredArgsConstructor
public class OrderSnapshotJdbcDao {

    private final ExternalHikariPoolHolder poolHolder;

    /**
     * 按时间窗统计订单数。
     */
    public long countByTimeRange(DataSourceDO ds, String startTime, String endTime) {
        HikariDataSource pool = poolHolder.getPool(ds);
        String sql = """
                SELECT COUNT(1) FROM "order".t_order
                WHERE create_time >= ? AND create_time < ?
                """;
        return JdbcExecutionUtil.queryForLong(pool, sql, "订单行数统计失败", ps -> {
            ps.setTimestamp(1, Timestamp.valueOf(startTime));
            ps.setTimestamp(2, Timestamp.valueOf(endTime));
        });
    }
}

5.3 多行查询 + 硬编码 SQL

public List<Map<String, Object>> fetchByIds(DataSourceDO ds, String schema,
                                             String table, List<String> ids) {
    if (ids.isEmpty()) {
        return List.of();
    }
    // schema/table 须在上层校验,防注入
    String sql = "SELECT * FROM \"" + schema.toLowerCase() + "\".\"" + table.toLowerCase()
            + "\" WHERE id::text = ANY(?)";

    return JdbcExecutionUtil.queryList(poolHolder.getPool(ds), sql, "订单全行查询失败", ps ->
            ps.setArray(1, ps.getConnection().createArrayOf("varchar", ids.toArray())), rs -> {
        Map<String, Object> row = new LinkedHashMap<>();
        int cols = rs.getMetaData().getColumnCount();
        for (int i = 1; i <= cols; i++) {
            row.put(rs.getMetaData().getColumnLabel(i), rs.getObject(i));
        }
        return row;
    });
}

注意:setArray 需要 Connection 时,优先在 withConnection 内绑定,或使用 conn.createArrayOf

return JdbcExecutionUtil.withConnection(poolHolder.getPool(ds), "订单全行查询失败", conn ->
        JdbcExecutionUtil.queryList(conn, sql, null, ps ->
                ps.setArray(1, conn.createArrayOf("varchar", ids.toArray())), rowMapper));

5.4 增删改

public int softDelete(DataSourceDO ds, Long id, String operator) {
    String sql = """
            UPDATE "manage".t_config
            SET deleted = 1, update_user = ?, update_time = CURRENT_TIMESTAMP
            WHERE id = ? AND deleted = 0
            """;
    return JdbcExecutionUtil.update(
            poolHolder.getPool(ds), sql, "配置软删除失败", ps -> {
                ps.setString(1, operator);
                ps.setLong(2, id);
            });
}

5.5 业务校验 + JDBC 异常分离

对齐 TableMetadataLoader 模式:

public List<ColumnMeta> loadColumns(Connection conn, String schema, String table) {
    String sql = """
            SELECT column_name, ordinal_position, udt_name
            FROM information_schema.columns
            WHERE table_schema = ? AND table_name = ?
            ORDER BY ordinal_position
            """;
    List<ColumnMeta> columns = JdbcExecutionUtil.queryList(
            conn, sql, "读取表结构失败, schema=" + schema + ", table=" + table,
            ps -> {
                ps.setString(1, schema);
                ps.setString(2, table.toLowerCase());
            },
            rs -> new ColumnMeta(
                    rs.getString("column_name"),
                    rs.getInt("ordinal_position"),
                    rs.getString("udt_name")));

    if (columns.isEmpty()) {
        log.warn("读取表结构失败:表不存在或无列, schema={}, table={}", schema, table);
        throw new BizException(ErrorCode.NOT_FOUND, "表不存在或无列: " + table);
    }
    return columns;
}

5.6 长连接复合任务(避免重复 scene log)

public void compareOnce(DataSourceDO pgDs, DataSourceDO ogDs, String table) {
    HikariDataSource pgPool = poolHolder.getPool(pgDs);
    HikariDataSource ogPool = poolHolder.getPool(ogDs);

    try (Connection pgConn = pgPool.getConnection();
         Connection ogConn = ogPool.getConnection()) {

        long pgCount = countOn(pgConn, table);   // 内层 scene=null
        long ogCount = countOn(ogConn, table);
        // ... 后续多条 SQL 复用 pgConn / ogConn

    } catch (Exception e) {
        if (!(e instanceof BizException)) {
            log.warn("[COMPARE] 比对失败, table={}", table);
        }
        throw BizException.wrap(e);
    }
}

/** 内层被编排方法:scene 传 null,由 compareOnce 统一打 log */
private long countOn(Connection conn, String table) {
    String sql = "SELECT COUNT(1) FROM \"" + table + "\"";
    return JdbcExecutionUtil.queryForLong(conn, sql, null, null);
}

5.7 小事务(不额外抽象 TransactionTemplate)

public void transfer(DataSourceDO ds, long fromId, long toId, BigDecimal amount) {
    JdbcExecutionUtil.run(poolHolder.getPool(ds), "转账失败", conn -> {
        boolean oldAuto = conn.getAutoCommit();
        conn.setAutoCommit(false);
        try {
            int debited = JdbcExecutionUtil.update(conn,
                    "UPDATE account SET balance = balance - ? WHERE id = ? AND deleted = 0",
                    null, ps -> {
                        ps.setBigDecimal(1, amount);
                        ps.setLong(2, fromId);
                    });
            if (debited != 1) {
                throw new BizException(ErrorCode.NOT_FOUND, "转出账户不存在");
            }
            int credited = JdbcExecutionUtil.update(conn,
                    "UPDATE account SET balance = balance + ? WHERE id = ? AND deleted = 0",
                    null, ps -> {
                        ps.setBigDecimal(1, amount);
                        ps.setLong(2, toId);
                    });
            if (credited != 1) {
                throw new BizException(ErrorCode.NOT_FOUND, "转入账户不存在");
            }
            conn.commit();
        } catch (Exception e) {
            try {
                conn.rollback();
            } catch (SQLException rollbackEx) {
                e.addSuppressed(rollbackEx);
            }
            throw e;
        } finally {
            conn.setAutoCommit(oldAuto);
        }
    });
}

6. 目录与职责

com.liang.learn.util
  └── JdbcExecutionUtil              # 纯模板,无 Spring

com.liang.learn.service.jdbc
  └── ExternalHikariPoolHolder       # 外部库 Hikari 池(可选)

com.liang.learn.service.xxx
  └── XxxJdbcDao                     # 硬编码 SQL,方法名表达业务语义
  • 一个外部库领域一个 Dao/Executor,方法名用业务语义(countByTimeRange),不用 selectOne 这类通用名。
  • 现有 ExternalJdbcExecutor 可逐步迁移;新代码直接使用本封装。

7. 刻意不做

不做 原因
通用 BaseJdbcDao<T> + 反射 过度封装,SQL 隐蔽
SQL Builder DSL 与「硬编码 SQL」前提冲突
Spring 动态数据源路由 用户明确要求不使用
util 内 log.error(..., e) 堆栈只在 Handler/异步边界打一次
在 util 内拼接对外 error message 违反 BizException 安全文案约定

8. 何时仍手写 try-catch

场景 建议
连接测试需固定文案 BizException.wrap(ErrorCode.CONNECTION_FAILED, msg, e),见 ConnectionTester
异步 Runnable 最外层 BizExceptionLogUtil.logFailure(e, scene, ...)
打开双连接编排 最外层一个 try-catch;内层 SQL 走 util 且 scene=null
posted @ 2026-06-03 07:37  景之1231  阅读(0)  评论(0)    收藏  举报