1. 为什么面向对象和JDBC会扯到一起1.1 一个让我重新审视JDBC的场景先说我自己的经历。早年写JDBC程序我是典型的面向过程写法写一个工具类里面堆满Connection、PreparedStatement、ResultSet复制粘贴各种try-catch-finally处理完一个查询需求就CtrlC、CtrlV到下一个需求里。代码能跑但每次需求一变改起来就跟拆炸弹一样。最夸张的一次一张数据表从5个字段加到9个字段我为了改一个查询方法连带改了三个类、两个页面还漏掉了一处字段映射到了线上才发现查询结果里多了一列空的。后来我才意识到问题不在JDBC本身而在我的组织方式。JDBC是Java访问数据库的标准接口它天生就是面向对象的——DriverManager负责拿连接Connection代表一个会话PreparedStatement封装一条预编译语句ResultSet是一个游标式的结果集对象。每一个关键环节都是对象都有状态、有行为。可我却一直拿它当工具函数用把所有的逻辑都塞进静态方法里面向对象的好处一点没吃到。1.2 面向过程写法和面向对象写法到底差在哪举一个最直观的例子。用面向过程的方式写用户查询大概是这个样子public static ListUser findUsers() throws SQLException { Connection conn null; PreparedStatement ps null; ResultSet rs null; ListUser list new ArrayList(); try { conn DriverManager.getConnection(URL, USER, PASSWORD); ps conn.prepareStatement(select * from t_user where status 1); rs ps.executeQuery(); while (rs.next()) { User u new User(); u.setId(rs.getLong(id)); u.setUsername(rs.getString(username)); list.add(u); } } finally { if (rs ! null) rs.close(); if (ps ! null) ps.close(); if (conn ! null) conn.close(); } return list; }再看面向对象的写法。同样的功能但职责被拆分到不同的对象里public class UserDao { private final JdbcTemplate jdbcTemplate new JdbcTemplate(dataSource); public ListUser findEnabledUsers() { String sql select id, username from t_user where status 1; return jdbcTemplate.query(sql, (rs, rowNum) - new User( rs.getLong(id), rs.getString(username) )); } }两者的差别不是代码量大不大而是谁负责什么这件事被重新定义了。面向过程的写法里DAO方法既是SQL的编写者又是连接的创造者还是资源的释放者所有事情混在一起面向对象的写法里UserDao只关心业务数据怎么映射JdbcTemplate关心SQL执行和结果集转换DataSource关心连接的获取和池化。每一层都是独立的、可替换的、可测试的。这篇文章就是想把我在实际项目中沉淀下来的一套面向对象JDBC写法分享出来。它适合正在学JDBC、觉得写起来很繁琐的人也适合已经在用DAO模式但代码仍然混乱的人。我不打算讲太多理论重点放在一步步怎么从面向过程转为面向对象以及这个过程中会遇到哪些坑。2. JDBC底层对象模型先看清JDBC自己是怎么面向对象的2.1 JDBC四个核心接口的本质想用面向对象思想写JDBC第一步不是急着封装而是先把JDBC自身提供的对象模型看清楚。JDBC规范里一共有四个最核心的接口它们各司其职又互相协作。DriverManager是驱动管理器它本身是个工具类但它做的事情是选择驱动返回连接。Connection接口代表的是数据库会话——一个连接对象内部有自己的状态比如事务是否开启、是否只读等它对应的是一次与数据库的对话。PreparedStatement则是预编译的SQL语句对象它比Statement多了一个能力把参数和SQL模板分离。ResultSet是执行查询后返回的结果集游标它内部维护了一个指向当前行的指针每调用一次next()指针就向下移动一行。这四者的关系很像生活中的订外卖流程DriverManager是外卖平台的派单中心它不关心谁来接单只负责把订单推给合适的骑手Connection是骑手本人一次对话就是一次配送PreparedStatement是配送订单的详情单上面写清楚了用户要什么、地址在哪但真正送的时候才填充具体内容ResultSet是你拿到手的外卖袋子你得一层层拆开才能看到里面是什么。明白了这层关系你就能理解为什么JDBC官方文档反复强调Connection、Statement、ResultSet都实现了AutoCloseable接口。因为这个模型本身就是对象生命周期管理的典型示范——该开的开该关的关每一个对象都要在合适的时机释放资源。2.2 为什么PreparedStatement比Statement更适合面向对象很多新手写JDBC时用的是Statement然后拼字符串Statement stmt conn.createStatement(); stmt.executeUpdate(insert into t_user(username, password) values( username , password ));这种写法代码看着简单但它有两个致命的面向对象敌人一是SQL注入因为在Java里拼字符串username如果传入abc or 11整个SQL语义就被篡改了二是参数和SQL模板耦合在一起想复用模板、想改造映射逻辑都得把整个字符串重新来一遍。PreparedStatement把参数从SQL模板里拆出来了String sql insert into t_user(username, password) values(?, ?); PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password);这里?占位符就是面向对象的参数对象SQL模板可以被反复预编译、复用每次执行只换参数值不需要重新解析SQL。更重要的是PreparedStatement的内部实现通常会走预编译协议性能更好。从面向对象思想的角度看PreparedStatement的价值是把SQL当作对象来处理。SQL不再是一段需要手工拼接的字符串而是一个可以被填充、被复用的结构化对象。这也是我在封装DAO层时始终坚持以PreparedStatement为基础的原因。2.3 资源关闭的面向对象解法JDBC里最烦人的事情之一就是资源关闭。早期写法里每个方法都要手工close()漏一次就可能造成连接泄漏。后来Java 7引入了try-with-resources语法这其实是对象管理思想在语言层面的落地try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { // 处理结果 } }因为三个资源都实现了AutoCloseabletry块结束时JVM会按创建顺序的逆序自动关闭它们。这样资源释放的责任从每个方法自己负责变成了语言机制统一负责。这个转变本身就是一种面向对象的设计——对象的生命周期交给容器去管调用方只关心业务逻辑。理解了JDBC自身的对象模型再来看我们自己的封装思路就会清晰很多。我们要做的不是推翻JDBC而是站在它的对象模型之上再抽象出一层符合业务需求的对象。3. 封装思路从数据访问层到实体映射的完整设计3.1 第一个封装JdbcTemplate工具类在我自己的项目里第一步不是直接写DAO而是先封装一个通用的JdbcTemplate。它做的事情很简单接收SQL和参数执行SQL把ResultSet转成业务对象最后统一释放资源。public class JdbcTemplate { private final DataSource dataSource; public JdbcTemplate(DataSource dataSource) { this.dataSource dataSource; } public T ListT query(String sql, RowMapperT rowMapper, Object... args) { try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i args.length; i) { ps.setObject(i 1, args[i]); } try (ResultSet rs ps.executeQuery()) { ListT result new ArrayList(); int rowNum 0; while (rs.next()) { result.add(rowMapper.mapRow(rs, rowNum)); } return result; } } catch (SQLException e) { throw new DataAccessException(查询失败: sql, e); } } }这里面有一个关键设计RowMapper接口。它把从ResultSet读取数据并转换成业务对象这件事从JdbcTemplate里彻底解耦出去。模板类只负责执行SQL、遍历结果集具体怎么把一行数据变成一个User对象由调用方通过实现RowMapper来决定。FunctionalInterface public interface RowMapperT { T mapRow(ResultSet rs, int rowNum) throws SQLException; }这样设计的好处是JdbcTemplate不用知道User类长什么样DAO也不用关心连接怎么获取、语句怎么执行。两头都面向接口编程中间的变化点字段映射规则被单独抽出来将来无论是字段改名、表结构调整还是多个表复用同一套查询逻辑都只需要动对应的RowMapper。3.2 第二个封装实体类的设计原则面向对象的JDBC程序里实体类Entity是数据流动的载体。很多人把实体类写成了纯粹的字段容器这没错但有几个原则值得注意。第一字段类型要跟数据库类型对齐。比如MySQL的datetime类型对应Java的LocalDateTime就比对应java.util.Date更合理因为LocalDateTime是不带时区的本地时间语义清晰JSON序列化也更友好。第二实体类里不要放数据库无关的业务逻辑。一个User实体就只管id、username、password、createdAt这些数据字段。登录时校验密码、计算用户等级这种事应该放在Service层或者实体领域方法里而不是塞进DAO层的实体类中。第三构造器和Builder模式。我的习惯是提供全参构造器配合Builder或者静态工厂方法减少setter的滥用public class User { private final Long id; private final String username; private final String password; private final LocalDateTime createdAt; public User(Long id, String username, String password, LocalDateTime createdAt) { this.id id; this.username username; this.password password; this.createdAt createdAt; } }这样设计有一个实际好处在RowMapper里可以用一行代码构造实体不需要逐个setreturn userRowMapper (rs, rowNum) - new User( rs.getLong(id), rs.getString(username), rs.getString(password), rs.getTimestamp(created_at).toLocalDateTime() );从面向对象的角度看实体类应该是领域的最小状态单元。它不依赖JDBC不依赖Spring任何时候都可以单独拿出来测试。这为后面做单元测试省了大力气。3.3 第三个封装DAO层的职责边界有了JdbcTemplate和实体类DAO层就变得非常薄。它的职责只有两个定义SQL、定义RowMapper。这两件事都是纯粹的数据映射逻辑不掺任何资源管理也不需要理解数据库连接池。public class UserDao { private final JdbcTemplate jdbcTemplate; public UserDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } public ListUser findEnabledUsers() { String sql select id, username, password, created_at from t_user where status 1; return jdbcTemplate.query(sql, (rs, rowNum) - new User( rs.getLong(id), rs.getString(username), rs.getString(password), rs.getTimestamp(created_at).toLocalDateTime() )); } }注意DAO是构造器注入JdbcTemplate而不是自己去new一个。这背后是面向对象设计里的依赖注入思想对象所需的依赖由外部提供而不是自己在内部创建。这样做的好处是测试时可以注入一个假的JdbcTemplate或者直接注入一个mock对象不需要连真数据库。我实际测试时用H2内存数据库启动测试容器连配置都能省掉。3.4 事务边界的对象化处理数据库事务是很多JDBC程序绕不开的坎。面向过程的写法通常是这样的conn.setAutoCommit(false); try { // 执行多个SQL conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); }这段代码本身没错但它把事务控制和业务逻辑搅在一起。每一次要用事务都得重复这套模板而且如果业务方法内部还有嵌套调用事务边界就很难控制。面向对象的方式把事务抽象成一个TransactionTemplate。它接收一个回调对象在回调执行前开启事务执行成功后提交异常时回滚public class TransactionTemplate { private final DataSource dataSource; public TransactionTemplate(DataSource dataSource) { this.dataSource dataSource; } public T T execute(TransactionCallbackT callback) { try (Connection conn dataSource.getConnection()) { conn.setAutoCommit(false); try { T result callback.doInTransaction(conn); conn.commit(); return result; } catch (Exception e) { conn.rollback(); throw e; } } catch (SQLException e) { throw new DataAccessException(事务执行失败, e); } } }然后在业务层调用transactionTemplate.execute(conn - { userDao.insert(conn, user); accountDao.updateBalance(conn, userId, amount); return null; });这个设计的关键在于DAO的方法签名里接收一个Connection参数事务和业务逻辑通过这个连接对象绑定在一起。TransactionTemplate管连接、管事务生命周期业务层只管把多个DAO方法按顺序组合。这样事务边界就从散落在每个方法里变成了集中在TransactionTemplate里。4. 逐步重构把一段面向过程的JDBC代码改成面向对象4.1 改造前的真实代码我从一个教学项目里摘一段典型的面向过程代码这个例子很具有代表性几乎能概括大家写JDBC时最常见的样子public class UserService { public User findUserById(Long id) throws SQLException { Connection conn null; PreparedStatement ps null; ResultSet rs null; User user null; try { Class.forName(com.mysql.cj.jdbc.Driver); conn DriverManager.getConnection(jdbc:mysql://localhost:3306/test, root, 123456); ps conn.prepareStatement(select * from t_user where id ?); ps.setLong(1, id); rs ps.executeQuery(); if (rs.next()) { user new User(); user.setId(rs.getLong(id)); user.setUsername(rs.getString(username)); user.setPassword(rs.getString(password)); user.setCreatedAt(rs.getTimestamp(created_at)); } } finally { if (rs ! null) rs.close(); if (ps ! null) ps.close(); if (conn ! null) conn.close(); } return user; } }这段代码的问题前面已经说过一部分驱动加载、获取连接、创建语句、映射结果、关闭资源全部挤在一个方法里方法职责严重超载。如果以后要换连接池直接改这里如果表加了字段也要改这里如果有第二个查询方法又得复制一份几乎一样的模板。这就是面向过程的典型特征——逻辑是顺着时间线铺开的而不是按对象职责划分的。4.2 第一次重构把可变点拆出来我的第一步永远是把变化的和不变的分离开。上面代码里哪些是不变的获取连接的方式、创建PreparedStatement、遍历ResultSet、finally里关资源——这些每个查询方法都一样。哪些是可变的SQL语句本身、绑定参数、结果集到实体的映射规则。于是我先抽出一个简化版的JdbcTemplate只支持查询单条记录public T T queryForObject(String sql, RowMapperT rowMapper, Object... args) { try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i args.length; i) { ps.setObject(i 1, args[i]); } try (ResultSet rs ps.executeQuery()) { if (rs.next()) { return rowMapper.mapRow(rs, 0); } return null; } } catch (SQLException e) { throw new DataAccessException(查询失败, e); } }然后把原来UserService里的查询逻辑改成public User findUserById(Long id) { String sql select id, username, password, created_at from t_user where id ?; return jdbcTemplate.queryForObject(sql, (rs, rowNum) - new User( rs.getLong(id), rs.getString(username), rs.getString(password), rs.getTimestamp(created_at) ), id); }到这一步方法已经从13行缩到了6行而且不再管连接、语句、结果集的生死。可变化的SQL和映射规则依然在自己的视野里一眼就能看懂而不变的部分被封装进了模板。4.3 第二次重构把数据访问对象和业务对象分离第一次重构后UserService仍然承担了SQL定义和数据映射的职责。对于一个简单项目来说这样还能接受但如果业务逻辑复杂起来Service层会越来越臃肿它既要处理业务规则又要维护SQL最终很难维护。所以第二步是把数据访问这一整块职责从Service里抽出去单独成立一个UserDao。这就是经典的DAO模式。抽出去的DAO只做数据访问Service只做业务逻辑。Repository public class UserDao { private final JdbcTemplate jdbcTemplate; public UserDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } public User findById(Long id) { String sql select id, username, password, created_at from t_user where id ?; return jdbcTemplate.queryForObject(sql, (rs, rowNum) - new User( rs.getLong(id), rs.getString(username), rs.getString(password), rs.getTimestamp(created_at) ), id); } public ListUser findEnabledUsers() { String sql select id, username, password, created_at from t_user where status 1; return jdbcTemplate.query(sql, (rs, rowNum) - new User( rs.getLong(id), rs.getString(username), rs.getString(password), rs.getTimestamp(created_at) )); } }这一抽Service层就得到解放public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao userDao; } public User getUserProfile(Long id) { User user userDao.findById(id); if (user null) { throw new UserNotFoundException(用户不存在: id); } return user; } }面向对象设计里的单一职责原则在这里落地了UserDao负责跟数据库打交道UserService负责组织业务规则。两者各管一摊互不越界。4.4 第三次重构引入连接归属让事务变成对象查询逻辑改造完之后增删改的方法也需要处理。修改类操作的接口天然需要事务而事务的绑定对象就是Connection。在我前面的设计里JdbcTemplate每次都是自己去dataSource.getConnection()这在单条SQL执行时没问题但一旦需要跨多条SQL的事务就没有办法保证它们用同一个Connection了。所以第三次重构我调整了JdbcTemplate的execute方法让它支持传入外部的Connectionpublic int update(Connection conn, String sql, Object... args) { try (PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i args.length; i) { ps.setObject(i 1, args[i]); } return ps.executeUpdate(); } }然后在TransactionTemplate里这样使用transactionTemplate.execute(conn - { userDao.updateUsername(conn, userId, newUsername); auditLogDao.insert(conn, update_username, userId); return null; });这样DAO方法里接收Connection参数显得多余——但正是这个多余的参数才让多个DAO能共享同一个数据库会话才让事务边界清晰可测。如果你不想在每个DAO方法上都加Connection参数也可以把Connection放在ThreadLocal里用一个ConnectionHolder对象来管理但那套方案更复杂我建议初学者先从显式传参开始。4.5 重构之后的测试体验一次完整的重构是否成功我一般看测试体验。面向过程的JDBC代码测试最麻烦因为它直接嵌了数据库连接要么连真库要么很难去掉这个依赖。重构后JdbcTemplate依赖DataSource我可以用一个极轻量的连接池比如HikariCP指向H2内存库启动时执行一遍建表SQL测试完后数据自动消失。BeforeEach void setUp() throws Exception { JdbcDataSource ds new JdbcDataSource(); ds.setURL(jdbc:h2:mem:test;DB_CLOSE_DELAY-1); ds.setUser(sa); ds.setPassword(); try (Connection conn ds.getConnection(); Statement stmt conn.createStatement()) { stmt.execute(create table t_user (id bigint primary key, username varchar(50), password varchar(50), created_at timestamp)); } jdbcTemplate new JdbcTemplate(ds); userDao new UserDao(jdbcTemplate); }这时候你会发现面向对象的封装带来的红利不只是代码短了而是整个系统变得可测试、可替换、可组合。这比省几行代码重要得多。5. 从JDBC到连接池面向对象思想在资源层的外延5.1 为什么推荐使用连接池而不是DriverManager很多教程还在用DriverManager.getConnection()包括不少学校教材。但真实生产中几乎不会这么写原因很简单DriverManager每次都是新建物理连接连接建立涉及网络握手、身份认证开销很大。如果每个请求都新建连接数据库服务器会被频繁的连接创建和销毁压垮。面向对象的思路在这里作用于连接资源池化——把连接这个对象的创建和销毁成本分摊到多次使用上。DataSource接口就是连接池的面向对象抽象它屏蔽了连接从哪里来的细节只约定调getConnection就能拿到一个可用的连接。常用的连接池有HikariCP、Druid、dbcp2我现在用HikariCP最多因为它性能好、配置简单。HikariCP在背后维护了一个连接对象的集合你从池里拿走一个连接、用完还回去池自身负责校验连接是否健康空闲的连接会被定期回收。5.2 数据源选型与配置实战以HikariCP为例一个最小可用的配置大概是这样HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/test); config.setUsername(root); config.setPassword(123456); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(30000); DataSource dataSource new HikariDataSource(config);这里有几个参数要特别注意maximumPoolSize最大连接数。这个值不是越大越好要根据应用的并发量和数据库自身的承受能力来定。我曾把连接池调到50结果数据库端出现大量线程等待性能反而下降。一般中小系统10~20就够用了。minimumIdle最小空闲连接数保持2~5个空闲连接可以避免突发流量时临时建连的延迟。connectionTimeout从池中获取连接的超时时间30秒是一个相对保守的值如果设太短可能误伤慢查询场景设太长则会让请求卡死。把这些配置封装到一个DataSourceFactory对象里也是面向对象思想的一部分——让数据源如何创建成为一个独立的关注点。业务代码不关心数据源来自Hikari还是Druid只面向DataSource接口编程。5.3 连接池与传统JDBC资源模型的对比维度DriverManager直连连接池DataSource连接创建每次新建物理连接复用池中已建连接资源开销高频繁握手认证低连接复用对上层代码的接口静态工具方法标准DataSource接口事务支持需要显式控制池分配连接事务一致生产环境适用性基本不适用主流选择从面向对象设计的角度理解连接池本质上是一个对象池它管理的是Connection对象的生命周期。上层代码只依赖DataSource这个抽象实际连接来源是HikariCP还是Tomcat JDBC Pool对业务代码完全透明。这也是面向接口编程的典型案例。5.4 连接泄漏的发现与处理资源泄漏是JDBC程序里最常见的线上问题。连接池虽然帮忙管理连接但如果我们自己的代码拿了连接后没有关闭池里的连接就会被慢慢耗尽。我遇到过好几次这种情况最典型的表现是系统刚开始正常运行几小时后接口突然全部超时检查日志发现报错Connection is not available, request timed out。排查思路一般是先看连接池监控指标确认active连接数是否持续上涨idle是否掉到0检查DAO方法和业务代码看有没有在异常路径上漏掉close排查是不是有ThreadLocal或静态map故意存了Connection,造成引用无法释放最后用连接泄漏检测工具比如HikariCP自带的leakDetectionThreshold配置抓取具体泄漏点。config.setLeakDetectionThreshold(60000); // 连接借出超过60秒就告警这个配置在开发环境非常有价值它会在日志里打出借出连接的调用栈能帮你快速定位到是哪个方法拿着连接不还。我通常只在开发环境开启生产环境如果性能要求极苛刻可以关闭但排查问题时会重新打开。6. 踩坑记录面向对象JDBC实践中常见的五个坑6.1 字段映射错位的坑实体字段和数据库字段的映射是整个面向对象封装中最容易出bug的地方。尤其是下划线命名和驼峰命名混用的时候。例如数据库字段叫created_atJava实体字段叫createdAt如果RowMapper里写的是rs.getTimestamp(createdAt)MySQL会报Column createdAt not found。这类错误编译期完全发现不了只有运行到这一行才会炸。我的解决办法是建立一个集中的列名常量类或者直接在RowMapper里统一遵守规范比如public static final String COL_CREATED_AT created_at;然后rs.getTimestamp(UserTable.COL_CREATED_AT).toLocalDateTime()这样虽然代码写起来啰嗦一点但至少把数据库字段名和Java字段名的映射关系集中管理不会因为某次字段重命名导致多处修改时漏掉一处。6.2 数据库类型与Java类型不匹配的坑这是另一个容易踩的坑。MySQL的datetime映射到Java的java.sql.Timestamp没问题但如果我把实体字段类型定义为LocalDateTimeRowMapper里却忘记转换直接rs.getTimestamp(...)赋给LocalDateTime编译就不通过。反过来如果定义了LocalDate而数据库是date类型用rs.getDate(...).toLocalDate()就要小心里面有坑——java.sql.Date转LocalDate很简单但如果这个列是datetime呢我的建议是尽量让数据库类型和Java类型一一对应不要跨类型映射除非特别需要。例如DATETIME→LocalDateTime用rs.getTimestamp().toLocalDateTime()DATE→LocalDate用rs.getDate().toLocalDate()TINYINT→Boolean或Integer不要混用DECIMAL→BigDecimal不要用Double否则精度会丢6.3 事务失效的坑前面提到我让DAO方法直接接收Connection参数这样事务才能生效。但如果你在DAO方法内部又重新调了jdbcTemplate的默认方法而那个方法用的是dataSource.getConnection()自动获取的连接那么两条SQL很可能不在同一个事务里——因为它们用了不同的连接。解决办法很简单事务边界内的所有DAO操作都必须显式传递同一个Connection对象。或者更彻底一点使用独立的TransactionManager统一管理连接绑定就像Spring那样。如果你的项目已经引入了Spring直接使用Transactional和TransactionAwareDataSourceProxy可以把Connection绑定在事务上下文中省去手动传参的麻烦如果你的项目是纯JDBC那么显式传Connection是最直接的方式。6.4 SQL注入漏网的坑面向对象的封装里最容易让人放松警惕的地方就是已经使用了PreparedStatement还拼字符串。String sql select * from t_user where username username ;如果你图省事在DAO里把参数拼进SQL那PreparedStatement的防注入效果就完全失效了。我的习惯是所有DAO方法里的SQL绝对不能拼接用户输入哪怕是排序字段名也要用过滤白名单的方式处理不能直接拼进去。6.5 查询结果集关闭顺序的坑在try-with-resources里资源关闭顺序是反序的——先关闭后打开的资源再关闭先打开的资源。所以如果你写成try (Connection conn ...; PreparedStatement ps ...; ResultSet rs ...) { }JVM会先关rs再关ps最后关conn这完全没问题。但如果你只在try块里写了Connection和PreparedStatement而把ResultSet的关闭交给PreparedStatement隐式完成部分数据库驱动可能会在rs未完全消费时执行关闭行为导致后续next()抛异常。所以我还是建议ResultSet也纳入try-with-resources显式管理三层生命周期。7. 用这种写法做个小项目用户管理系统JDBC版7.1 项目结构与代码框架最后用一个完整的小项目把这套思路过一遍。假设我们做一个极简的用户管理系统功能包括新增用户、按ID查询、查询启用的用户列表、修改用户密码、删除用户。项目结构如下src/main/java ├── demo │ ├── entity │ │ └── User.java │ ├── dao │ │ ├── UserDao.java │ │ └── AuditLogDao.java │ ├── jdbc │ │ ├── DataSourceFactory.java │ │ ├── JdbcTemplate.java │ │ ├── RowMapper.java │ │ ├── TransactionTemplate.java │ │ └── DataAccessException.java │ └── service │ └── UserService.java └── resources └── db └── schema.sqlJdbcTemplate和TransactionTemplate是通用组件理论上任何项目都能复用。User是实体类UserDao和AuditLogDao是数据访问对象UserService是业务层。7.2 核心代码实现User实体类public class User { private final Long id; private final String username; private final String password; private final Boolean enabled; private final LocalDateTime createdAt; public User(Long id, String username, String password, Boolean enabled, LocalDateTime createdAt) { this.id id; this.username username; this.password password; this.enabled enabled; this.createdAt createdAt; } public Long getId() { return id; } public String getUsername() { return username; } public String getPassword() { return password; } public Boolean getEnabled() { return enabled; } public LocalDateTime getCreatedAt() { return createdAt; } }UserDaopublic class UserDao { private final JdbcTemplate jdbcTemplate; public UserDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } public User findById(Long id) { String sql select id, username, password, enabled, created_at from t_user where id ?; return jdbcTemplate.queryForObject(sql, this::mapRow, id); } public ListUser findEnabled() { String sql select id, username, password, enabled, created_at from t_user where enabled ?; return jdbcTemplate.query(sql, this::mapRow, true); } public int insert(Connection conn, User user) { String sql insert into t_user(id, username, password, enabled, created_at) values(?, ?, ?, ?, ?); return jdbcTemplate.update(conn, sql, user.getId(), user.getUsername(), user.getPassword(), user.getEnabled(), user.getCreatedAt()); } public int updatePassword(Connection conn, Long userId, String newPassword) { String sql update t_user set password ? where id ?; return jdbcTemplate.update(conn, sql, newPassword, userId); } public int delete(Connection conn, Long userId) { String sql delete from t_user where id ?; return jdbcTemplate.update(conn, sql, userId); } private User mapRow(ResultSet rs, int rowNum) throws SQLException { return new User( rs.getLong(id), rs.getString(username), rs.getString(password), rs.getBoolean(enabled), rs.getTimestamp(created_at).toLocalDateTime() ); } }UserService里注册新用户时会同时写用户表和审计日志表这个操作必须在一个事务里完成public class UserService { private final UserDao userDao; private final AuditLogDao auditLogDao; private final TransactionTemplate transactionTemplate; public UserService(UserDao userDao, AuditLogDao auditLogDao, TransactionTemplate transactionTemplate) { this.userDao userDao; this.auditLogDao auditLogDao; this.transactionTemplate transactionTemplate; } public void registerUser(User user) { transactionTemplate.execute(conn - { userDao.insert(conn, user); auditLogDao.insert(conn, register_user, user.getId()); return null; }); } public void changePassword(Long userId, String newPassword) { transactionTemplate.execute(conn - { userDao.updatePassword(conn, userId, newPassword); auditLogDao.insert(conn, change_password, userId); return null; }); } }7.3 这个项目的可扩展方向这套结构跑通之后它能承载的扩展方向很多。比如你想加一个分页查询只需要在JdbcTemplate里增加一个queryForPage方法返回一个包含数据列表和总条数的分页对象你想加缓存DAO层不用动Service层加一个CacheManager即可你想换ORM框架核心结构也可以保留只需要把JdbcTemplate内部的SQL执行替换成MyBatis或JPA的接口。我个人觉得面向对象JDBC编程的价值不在于用JDBC代替框架而在于让人理解数据访问层该怎么设计。即便后来你用了MyBatis、Spring Data JPA你也会发现它们底层思想的骨架——实体类、DAO、事务模板、RowMapper——和我们手写的这一套是高度一致的。先把JDBC和面向对象思想搞透再看任何ORM框架都会有一种原来如此的通透感。8. 我的一些实践心得8.1 写代码的顺序先画对象关系再写SQL我见过很多同事拿到需求后第一件事是打开MySQL写SQL写完SQL再回来想Java类怎么设计。但面向对象的方式应该反过来先理清楚这个业务里有哪几个关键对象、它们之间的关系是什么一对一、一对多、多对多然后才去设计表结构和SQL。当然表设计往往先行但代码的组织方式一定要从领域对象出发而不是从SQL语句出发。比如用户注册这个需求。从对象角度看涉及User和AuditLog两个实体以及一个注册的动作。那么代码里就自然会有UserDao.insert、AuditLogDao.insert、UserService.register三个方法。SQL只是这些方法内部的实现细节。这样的结构无论需求怎么加代码都不会乱。8.2 不要过度设计面向对象的封装很容易走向另一个极端——为了抽象而抽象。我见过有些团队每个DAO都要配一个接口、一个实现类、一个工厂类再配一个抽象基类最后连写一个查询都要跨四个类。这种复杂度在业务没有明确扩展需求时纯粹是负担。我自己的衡量标准是这个抽象能不能被直接测试能不能减少重复代码能不能让新需求只在一个地方变化如果三个问题的答案都是否那这个抽象就是多余的。JdbcTemplate、RowMapper、TransactionTemplate这三个组件是经过很长时间验证、被无数项目反复使用过的成熟抽象直接拿来用就好而DAO接口和实现类在小项目里完全没必要拆开等真有多实现需求时再拆不迟。8.3 排查问题的顺序先看RowMapper再看SQL最后看连接面向对象JDBC程序出bug我排错有个固定顺序。第一优先级永远是RowMapper因为字段映射错误最隐蔽编译期根本发现不了第二优先级是SQL本身拷到数据库客户端里执行一遍看能不能出结果最后才查连接和池配置因为连接问题通常现象明显超时、连接数耗尽不会是查询结果少了几个字段这类问题。有一次我排查用户列表少了一列数据最后发现是RowMapper里字段名和数据库实际列名不一致——rs.getTimestamp(create_time)而表里这一列叫created_at。这种问题如果写在常量类里一眼就能看出来如果散落在各个RowMapper里排查成本就很高。所以我建议把常用的列名常量集中管理。8.4 给新手的最后建议如果你刚开始学JDBC我的建议是先老老实实手写一遍面向过程的代码——DriverManager、PreparedStatement、ResultSet、手工close每一个环节都亲手体会一遍然后按这篇文章的步骤重构成面向对象版本。不要一上来就MyBatis、Spring Data JPA那样你永远搞不清底层在做什么。当你把这一套手写JDBC的面向对象封装跑通你会发现几个明显的变化一是代码结构清晰了二是查错变快了三是测试变容易了。更重要的是你对对象接口职责生命周期这些面向对象概念的理解从背定义变成了有切身体会。这层理解才是后面学任何框架都拿不走的东西。