说实话,我刚开始接触 JDBC 那阵子,最头痛的不是 SQL 写不对,而是那套流程笔记繁琐——拿连接、建语句、执行、遍历结果集、关资源,每一步都是固定动作,却要一遍一遍重复。写一个最简单的查询,样板代码能占到八成,真正有价值的业务逻辑反而被淹没了。“JavaOOP JDBC”这个项目要解决的问题,恰恰就在这里:用面向对象的设计思路,把 JDBC 中那些重复、琐碎、容易出错的部分收拢到整洁的类结构里,让数据库访问代码变得清晰、好复用、好维护。它不依赖任何重量级框架,就是纯 JDBC + OOP,搭出一套可以直接塞进小项目里用的数据访问层。整套做下来,既能摸清底层原理,又能建立“写代码前先想结构”的习惯。适合刚学完 Java 基础、准备迈向真实项目的同学,也适合那些已经写了不少 JDBC 代码、但总觉得自己在抄来抄去的开发者。1. 核心思路拆解:为什么 JDBC 非要和 OOP 绑在一起1.1 裸写 JDBC 的三宗罪先把丑话放在前面——不用任何封装、直接裸写 JDBC,到底能跑吗?当然能跑。我刚开始练手时也是这么干的,一个测试类里写着连接、查询、打印结果,跑通一次还挺有成就感。但一旦你多写几个查询、多几张表、多几个业务流程,最初的“自由”就会立刻变成负担。裸写 JDBC 的第一宗罪,是样板代码的无限重复。随便看一段典型的查询代码:Class.forName("com.example.jdbc.Driver"); Connection conn = DriverManager.getConnection(url, user, password); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT id, name, age FROM user"); while (rs.next()) { System.out.println(rs.getInt("id") + " - " + rs.getString("name")); } rs.close(); stmt.close(); conn.close();这段代码本身没问题,问题在于它和另一张表的查询、另一个条件的查询之间,差的只有 SQL 字符串和结果集的处理方式,其余全是复制粘贴。Add 一个查询就往原来的类里粘一段?代码很快就会变成流水账。第二宗罪,是资源管理极易出漏洞。Connection、Statement、ResultSet三个资源,任何一个忘记 close,数据库连接数都可能悄悄涨满。尤其在一个方法里出现分支或者异常时,漏关资源的概率非常高。连接一多、异常一抛,测试阶段可能毫无感觉,上线后才发现数据库连不上了。第三宗罪,是 SQL 与业务逻辑完全纠缠在一起。写业务的人被迫直面对连接串、SQL 字符串、异常处理和资源回收的细节,数据表一改字段,所有相关代码都要翻一遍。这不仅仅是代码变丑的问题,更是维护成本的直线上升。1.2 OOP 在这里到底解决了什么问题很多人觉得 OOP 就是“把代码放进类里”,实际上没那么简单。OOP 在数据库访问层解决的,是三个层面的问题。第一个层面是封装。连接如何创建、资源如何释放、异常如何处理,这些属于“连接管理”的职责,应该藏进一个专门的类里。业务代码只需要说“给我一条连接”或者“帮我执行这条 SQL”,根本不需要知道底层是怎么拿到连接的。第二个层面是继承。既然几乎所有 DAO 都要做增删改查,那就设计一个通用父类,把通用的执行模板放在里面,子类只写属于自己的特殊逻辑。这就是典型的模板方法模式,代码量能直接砍掉一截。第三个层面是多态。有了接口和实现类的分离,业务层依赖的是一个“用户数据访问接口”,而不是某个具体的实现类。将来想换存储、想改实现,业务代码基本不需要动。这个思想在你以后接触框架时会反复出现,提前在 JDBC 阶段建立起来,性价比很高。1.3 演进路线:从过程式到分层设计从裸 JDBC 到一套像样的 OOP 访问层,我建议分三步走,不要一口气全做完。第一步,先做一个连接管理工具类,把所有获取连接和关闭资源的方法收进去。这一步做完,至少大量重复的连接代码消失了。第二步,为每张表建一个实体类,再为每个实体建对应的 DAO 接口和实现类,把 SQL 按照 CRUD 拆分到不同方法里。这时候代码已经有了清晰的边界。第三步,加一个 Service 层,把业务操作放到这一层组织,事务的处理也挪到这里。搞完这一步,你手里就已经是一个结构完整、可以直接用的小项目了。每个表对应一个实体类、一个 DAO 接口、一个 DAO 实现,业务层调用 DAO,DAO 依赖工具类,这已经是大多数项目在数据访问层面的标准结构。先按这个套路走,后面学框架时会发现很多东西你其实早就见过。2. 环境准备与项目骨架2.1 依赖、驱动与连接配置做这个项目之前,先把数据库驱动准备好。最简单的办法,是下载对应数据库的 JDBC 驱动 jar 包,放到项目的 lib 目录下,然后在构建工具里配置依赖;如果你用的是 Maven 或者 Gradle,直接在依赖声明里加一行驱动坐标即可,驱动会自动进入 classpath。驱动加载这一步,很多新手会踩坑。Class.forName("com.example.jdbc.Driver")这行代码看起来只是加载了一个类,实际上它触发的是驱动注册——驱动类的静态代码块里调用了DriverManager.registerDriver,让自己被注册到 JDBC 驱动管理器里。这样后面的DriverManager.getConnection()才能从一堆驱动中找到合适的那一个。所以这行不是可有可无的形式代码,而是整个连接过程的起始。连接配置建议放到一个配置文件中,不要硬编码在类里。我习惯建一个config.properties,写上连接地址、用户名、密码,然后在代码里读取。driver=com.example.jdbc.Driver url=jdbc:example://localhost:3306/java_oop_demo user=root password=123456在工具类的静态代码块里完成配置加载和驱动注册:static { try { // 读取config.properties并赋值给url、user、password Class.forName(driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } }注意,ClassNotFoundException属于受检异常,在静态代码块里不能直接抛,所以我把它包进了ExceptionInInitializerError,让 JVM 在类初始化失败时能第一时间反应出来。2.2 目录结构与职责边界推荐按功能分包,不要把所有类都放在一个包下面。我一般会建四个包:entity:实体类,一张表对应一个类。dao:数据访问接口和实现类。service:业务逻辑层,事务边界在这里。util:工具类,目前主要放连接管理。包结构一旦理清,每个类该干什么就很好判断了。实体类只负责“装数据”,不写 SQL;DAO 只负责“操作数据库”,不写业务逻辑;Service 只负责“组织业务”,不管 SQL 细节;工具类只负责“连接和释放”,不参与任何具体业务。这样的职责边界,说白了就是让每个变化点只影响一层。3. 五个核心类的设计与实操3.1 连接管理工具类:拿来即用且防泄漏工具类设计起来不难,但有三个细节值得注意。第一,getConnection()方法每次返回一个新的连接,所以它必须做足够的容错处理。我把SQLException直接抛出去,让上层决定怎么处理,因为这个异常往往意味着数据库不可用,在工具类里擅自拦截反而会丢失关键问题。第二,释放资源的方法要接收三个可能为空的资源对象,并在每个 close 里单独捕获异常。为什么单独捕获?因为依次关闭时,前面的异常不应该阻止后面的关闭动作。比如ResultSet关闭失败,还有没有关闭Statement;如果第一个异常直接抛出,后面的资源可能就永远不关了。完整的工具类大概是这样的:public class ConnectionUtil { private static String url; private static String user; private static String password; static { try { // 读取配置文件:driver / url / user / password Class.forName(driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }第三点要说的是关闭顺序。千万别小看这个顺序:先关ResultSet,再关Statement,最后关Connection。这和资源创建的次序是反过来的。如果一个应用把Connection先关了,底层的连接可能已经被归还,但结果集和语句还握着不放,数据库服务器收到的就是一堆悬挂的游标,时间一长必然堆积。3.2 通用 DAO 基类:模板方法干掉重复 CRUD连接管理解决的是“有连接可用”,接下来要考虑“怎么把重复的增删改查收起来”。这里我的方案是做一个泛型基类BaseDao<T>,把通用的更新和查询逻辑抽到父类中,子类只需要继承并复用即可。先定义一个RowMapper<T>接口,它负责把结果集里的一行转换为实体对象:@FunctionalInterface public interface RowMapper<T> { T mapRow(ResultSet rs) throws SQLException; }然后看基类的核心方法:public abstract class BaseDao<T> { protected int update(String sql, Object... params) { try (Connection conn = ConnectionUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { setParams(ps, params); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("数据库更新失败", e); } } protected List<T> queryList(String sql, RowMapper<T> mapper, Object... params) { try (Connection conn = ConnectionUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = executeQuery(ps, params)) { List<T> result = new ArrayList<>(); while (rs.next()) { result.add(mapper.mapRow(rs)); } return result; } catch (SQLException e) { throw new RuntimeException("数据库查询失败", e); } } protected T queryOne(String sql, RowMapper<T> mapper, Object... params) { List<T> list = queryList(sql, mapper, params); return list.isEmpty() ? null : list.get(0); } }注意这里用到了 try-with-resources,这是 Java 7 之后的语法,可以自动关闭实现AutoCloseable的资源,比手动在 finally 里写 close 干净得多。但有一点要说明:try-with-resources 关闭时,如果一个资源关闭失败,后续资源不会继续被关闭,所以如果你对资源安全性要求极高,仍然要回到工具类的显式关闭方式。setParams和executeQuery这两个辅助方法需要把可变参数一个个设置进去:private void setParams(PreparedStatement ps, Object... params) throws SQLException { if (params != null) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } } } private ResultSet executeQuery(PreparedStatement ps, Object... params) throws SQLException { setParams(ps, params); return ps.executeQuery(); }setObject是万能设置方法,它会根据参数的运行时类型自动调用对应的 setter,省去自己判断类型的麻烦。虽然性能上不是最优,但在通用基类这个层面,换来的是极大的简洁,项目体量不大时完全可以接受。3.3 结果集映射:三种姿势怎么选ResultSet本身是一个行游标,每一行都是数据库返回的原始字段,必须想办法把字段转成实体对象。这个映射过程,我见到的常用姿势有三种。第一种,手动取值,也就是在RowMapper里逐个rs.getXxx()并构造对象。优点是直观、好调试,缺点是每个查询都要写一遍 getter,字段多了很啰嗦。第二种,按列名反射映射。约定数据库列名和实体属性名一致,然后通过反射将字段值注入到对象的同名属性上。优点是通用性强,不需要为每张表写映射代码;缺点是对列名有约定要求,而且反射有一定性能损耗,出问题时不如手动取值好排查。第三种,配合泛型基类的回调式映射。BaseDao.queryList接收一个RowMapper,具体怎么从一行数据构造出对象,由调用方决定。这是我在中小项目里的首选方案——足够灵活,又不会像反射那样藏得太深。具体怎么选?如果你的表结构非常规整、项目周期紧张,反射映射可以帮你快速铺开;如果你希望每一行数据怎么转换成对象都能被一眼看懂,手动取值或者回调映射更适合。我自己的实践是,宁可多写几行,也要保证这段逻辑在任何时候都不会让人觉得“黑盒”。数据库访问层是整个系统的地基,这里故意留一点冗余,换取的是后续排查问题的确定性。3.4 事务处理:边界到底该放在哪一层这是整个项目里最容易翻车的设计点。新手最常见的错误,是在 DAO 的单个方法内部去开启事务。但一个 DAO 方法通常只对应一条 SQL,而一个业务往往需要执行好几条 SQL 才能算完成。比如用户注册,可能既要插入用户主表,又要往角色关联表里写数据,这两个操作必须同生共死。这时事务边界必须放在 Service 层。事务的基本流程是:关闭自动提交、执行多条 SQL、全部成功则 commit,任何一步异常则 rollback,最后恢复自动提交并释放连接。参考写法:public boolean register(User user) { Connection conn = null; try { conn = ConnectionUtil.getConnection(); conn.setAutoCommit(false); userDao.insert(conn, user); userRoleDao.insert(conn, user.getId(), defaultRoleId); conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { e.addSuppressed(ex); } } return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); } catch (SQLException ignored) {} ConnectionUtil.close(null, null, conn); } } }这里有一个很容易忽略的动作:finally 里要把setAutoCommit(true)恢复回来,否则连接池拿到一条还处于事务中的连接,后续使用者会陷入各种莫名其妙的“脏读”“不可重复读”问题。虽然这个小项目里每次拿的都是新连接,但养成这个习惯,将来接入连接池后能少踩很多坑。还有一个关于失败返回的逻辑:手写 JDBC 的 Service 方法里,异常业务往往需要把 SQLException 转成业务异常向上传递,而不是简单地返回 false。具体看你项目里的约定,我这里为了演示简洁,用返回布尔值代表成功,真实项目里一般会用一个统一的自定义异常。4. 完整实现演示:用户管理模块的一整套增删改查4.1 建表与实体类理论说得再多,不如完整跑一遍。我以用户管理模块为例,把整套实现串起来。先建一张最简单的用户表:CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT, email VARCHAR(100) );对应实体类的写法很基础,但有两个细节必须注意。第一,必须有无参构造方法。很多结果集映射工具在反射创建对象时,依赖的就是无参构造,虽然这里手动 mapping 用不上,但作为实体类的基本礼仪还是保留为好。第二,必须提供 getter/setter。这不仅仅是 JavaBean 的规范,还关系到后续你在 Controller、视图层用 EL 表达式或者某些序列化工具时的兼容性。实体类示例:public class User { private Integer id; private String name; private Integer age; private String email; public User() {} // getter/setter 省略 }id用包装类型Integer而不是基本类型int,这样在结果为 null 时可以区分“数据库里没有值”和“值就是 0”,避免业务判断出错。这是一个很细节、但经常被忽略的习惯。4.2 用户 DAO 接口与实现类按照分层习惯,先定义一个 DAO 接口:public interface UserDao { int insert(User user); int deleteById(Integer id); int update(User user); User findById(Integer id); List<User> findAll(); }实现类是核心。以插入为例,除了执行 INSERT,还要把数据库自动生成的主键回填到实体对象上。如果不回填,业务层连“刚插入的数据 id 是多少”都不知道,后续关联操作根本没法做。用getGeneratedKeys()可以拿到自动生成的主键:public int insert(User user) { String sql = "INSERT INTO user(name, age, email) VALUES(?, ?, ?)"; try (Connection conn = ConnectionUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.setString(3, user.getEmail()); int rows = ps.executeUpdate(); if (rows > 0) { try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { user.setId(keys.getInt(1)); } } } return rows; } catch (SQLException e) { throw new RuntimeException("插入用户失败", e); } }这里我特意用了Statement.RETURN_GENERATED_KEYS,如果不传这个参数,驱动不保证能返回生成的主键。百科上很容易漏这行,然后拿着getGeneratedKeys()一脸疑惑,其实是被这行坑了。查询实现要配合前面写的BaseDao。定义UserDaoImpl继承泛型基类,指定类型参数User:public class UserDaoImpl extends BaseDao<User> implements UserDao { @Override public User findById(Integer id) { String sql = "SELECT id, name, age, email FROM user WHERE id = ?"; return queryOne(sql, rs -> { User user = new User(); user.setId(rs.getInt("id")); user.setName(rs.getString("name")); user.setAge(rs.getInt("age")); user.setEmail(rs.getString("email")); return user; }, id); } @Override public List<User> findAll() { String sql = "SELECT id, name, age, email FROM user"; return queryList(sql, rs -> { User user = new User(); user.setId(rs.getInt("id")); user.setName(rs.getString("name")); user.setAge(rs.getInt("age")); user.setEmail(rs.getString("email")); return user; }); } }两个方法里的 lambda 内容完全相同,看起来确实有点冗余。实际项目里我会抽一个私有方法private User mapRow(ResultSet rs) throws SQLException,这样既保持代码整洁,又避免回调与通用模板冲突。这里为了把每一步都摊开让你看清,故意写重复了。4.3 Service 层编排与运行效果有了 DAO,Service 只做一件事:编排。以注册用户为例,Service 里先调用 DAO 插入用户,再把用户与默认角色关联,整个过程放到一个事务里。上面事务示例里的register方法已经演示了做法,这里不再重复。跑起来之后,最直观的验证方法是打印日志。我在练习时会给每个方法加上简单的输出,观察连接是否正常获取、SQL影响行数是否符合预期、主键有没有回填。实测下来,一次完整的 insert 流程会依次看到“获取连接”“执行插入”“回填主键”“关闭连接”,每步都有迹可循。这个过程听起来简单,却比很多高级框架里的黑盒操作更容易建立起体感。5. 高并发与性能细节:批处理、连接复用、SQL 注入5.1 PreparedStatement 为什么是防注入的基本盘很多新手听说过 SQL 注入,但没真正见过它长什么样。最简单的攻击方式,是拼 SQL 字符串:String sql = "SELECT * FROM user WHERE name = '" + name + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);如果 name 传入的是' OR '1'='1,拼出来的 SQL 就是:SELECT * FROM user WHERE name = '' OR '1'='1'这个查询会把整张表都查出来。要是碰上有删除操作的接口,攻击者完全可以构造更危险的参数。PreparedStatement之所以能防注入,关键在于预编译机制:SQL 结构在数据库端已经提前编译好了,参数通过占位符?传递,驱动会把参数当成纯粹的数据,而不是可执行的 SQL 片段。即使参数里有恶意的引号和关键字,也只会被当作字符串值处理。正确写法:String sql = "SELECT * FROM user WHERE name = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, name); ResultSet rs = ps.executeQuery();所以从这个项目开始,就定下一个死规矩:所有 SQL 一律使用PreparedStatement,永远不要用Statement去拼接字符串。这个习惯能帮你挡掉绝大多数注入问题。5.2 批处理与自动提交的配合如果需要一次性插入几千条数据,一条条执行会非常慢。PreparedStatement提供了一个addBatch和executeBatch机制,可以把多条 SQL 攒在一起,一次性提交给数据库执行。批处理配合事务使用效果最好:String sql = "INSERT INTO user(name, age, email) VALUES(?, ?, ?)"; try (Connection conn = ConnectionUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (User user : userList) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.setString(3, user.getEmail()); ps.addBatch(); } ps.executeBatch(); conn.commit(); } catch (SQLException e) { try { conn.rollback(); } catch (SQLException ex) { e.addSuppressed(ex); } }注意,addBatch只是把 SQL 加入待执行队列,真正执行发生在executeBatch调用时。如果不显式关闭自动提交,一批 SQL 可能被当成多个独立事务执行,中途一旦失败,前面已经执行的部分不会回滚,数据一致性就破了。所以“先关自动提交,再加批,再统一执行,再 commit,失败 rollback”这个顺序必须遵守。数据量很大的时候还要注意内存问题:如果一次性把几万条的批全部 add 进去再执行,内存会吃不消。实战中一般每攒 500 到 1000 条就调用一次executeBatch,清空一次缓冲。5.3 分页查询的通用写法分页是几乎所有列表功能都绕不开的需求。不同数据库的分页语法不一样,因此 JDBC 层的分页通常要预留扩展点。在一个不依赖特定数据库的项目里,我会写一个分页参数对象,再在 DAO 实现里按具体数据库语法拼 SQL。比如某关系型数据库的分页写法是LIMIT offset, count:public List<User> findByPage(int offset, int limit) { String sql = "SELECT id, name, age, email FROM user ORDER BY id LIMIT ?, ?"; return queryList(sql, rs -> { /* 映射 */ }, offset, limit); }调用时你需要计算 offset,最简单的公式是offset = (currentPage - 1) * pageSize。如果将来要换数据库,只需要把 DAO 实现里 LIMIT 这一段换成对应数据库的语法即可,上层 Service 完全无感知。这也是分层设计的典型受益点。6. 常见问题与排查技巧实录6.1 经典问题速查表做这个项目时,我踩过不少坑,有些坑到现在还会在别人的提问里反复出现。整理成一张速查表,方便你对照排查:现象可能原因排查方向启动时ClassNotFoundException驱动 jar 不在 classpath 中检查依赖配置,确认 jar 已引入连接超时连接串写错、数据库服务未启动、端口不通检查配置项,先用数据库客户端工具连一次试试查询结果中文乱码连接串未指定编码或表编码不一致连接串加上编码参数,统一表和连接字符集数据没查到但 SQL 没问题表名、列名与大小写不一致检查数据库对象名和 SQL 中引用是否完全一致数字显示为 0 而非空实体类用了基本类型改为包装类型,区分 null 和 0主键始终拿不到没传RETURN_GENERATED_KEYS补上参数,确认表的主键是自增列连接数持续上涨资源未关闭检查所有ResultSet/Statement/Connection是否都有 close多步操作只成功了一半事务边界放错了层确认事务在 Service 层,且 commit/rollback 都执行到这张表里的每一条,我都觉得是用真实代价换来的教训。比如“主键拿不到”那个问题,当时我调试了一整个下午,最后发现只是少了RETURN_GENERATED_KEYS这个参数,气到想摔键盘。所以把这表放在项目旁边,遇到问题先对照一遍。6.2 连接泄漏怎么定位连接泄漏是 JDBC 项目里最隐蔽、也最致命的问题。我说一个场景:某个后台任务定时批量更新数据,跑了一段时间之后,数据库的连接数从正常的十几个一路涨到几百个,最终报“too many connections”,整个应用瘫掉。这种问题的根源几乎都在于某个Connection没有正常关闭。定位的时候,我一般走三步。第一步,全局搜索getConnection,看看每次获取连接之后,是否都有对应的 close 路径。重点关注异常分支:如果只是 try 块里写了关闭,catch 或 return 之前抛出异常时,finally 里的 close 可能根本没执行到。第二步,查看是否有人把conn.close()写进了回调里,却又在某个外部类里重复调用ConnectionUtil.close,虽然重复关闭影响不大,但多了一重麻烦,反而容易掩盖真正的问题。第三步,如果已经接了连接池,可以打开连接池的监控日志,观察活跃连接数和空闲连接数。如果空闲数下降而活跃数持续上升,几乎就是有地方拿了连接没还回去。6.3 查不出数据的“幽灵”错误还有一种情况特别迷惑人:SQL 在数据库客户端里执行完全正常,但程序查询结果始终是空的。问题往往出在列名映射或者缓存上。我遇到过一次,是因为查询里给列起了别名,别名和实体类属性的映射关系没对上。比如SELECT user_name AS name FROM user,在rs.getString("name")是对的,但如果你写成了rs.getString("user_name"),当然拿不到值。还有一次,是因为连接串里带的时区参数不对,导致日期时间列的值被转换后发生了变化,排查了很久才发现不是取值逻辑的问题,而是驱动在解析时间戳时按照错误时区做了偏移。这类错误不会报异常,只会给你一个看起来“不太对但说不出哪里不对”的结果。遇到这种问题,建议先打印原始ResultSet里的所有列名和值,看看数据到底长什么样,再去怀疑映射逻辑。最后分享一点个人体会这个项目做完之后,我最大的感受不是“JDBC 不过如此”,而是“原来把代码整理成类,可以让工作量下降这么多”。刚开始我觉得写ConnectionUtil、BaseDao、RowMapper这一堆东西,是在绕远路,不如直接复制粘贴来得快。但真正遇到十几个表、几十个查询的时候,就会发现前期那点设计时间,会在后面无数次调用里连本带利地赚回来。还有一个小习惯值得专门说:练习时,在每个方法的入口和出口打印一行简洁的日志,记录连接获取、SQL 执行、资源释放的状态。一开始你会觉得日志多余,等你排查连接泄漏和查不出数据的问题时,这些日志就是最快定位问题的最好帮手。如果你刚写完这个项目,下一步建议找个框架级的数据访问层源码去看看,你会发现里面到处都能见到这套思路的影子。带着 OOP 的底子去学,你会比别人快很多。
说实话,我刚开始接触 JDBC 那阵子,最头痛的不是 SQL 写不对,而是那套流程笔记繁琐——拿连接、建语句、执行、遍历结果集、关资源,每一步都是固定动作,却要一遍一遍重复。写一个最简单的查询,样板代码能占到八成,真正有价值的业务逻辑反而被淹没了。“JavaOOP JDBC”这个项目要解决的问题,恰恰就在这里:用面向对象的设计思路,把 JDBC 中那些重复、琐碎、容易出错的部分收拢到整洁的类结构里,让数据库访问代码变得清晰、好复用、好维护。它不依赖任何重量级框架,就是纯 JDBC + OOP,搭出一套可以直接塞进小项目里用的数据访问层。整套做下来,既能摸清底层原理,又能建立“写代码前先想结构”的习惯。适合刚学完 Java 基础、准备迈向真实项目的同学,也适合那些已经写了不少 JDBC 代码、但总觉得自己在抄来抄去的开发者。1. 核心思路拆解:为什么 JDBC 非要和 OOP 绑在一起1.1 裸写 JDBC 的三宗罪先把丑话放在前面——不用任何封装、直接裸写 JDBC,到底能跑吗?当然能跑。我刚开始练手时也是这么干的,一个测试类里写着连接、查询、打印结果,跑通一次还挺有成就感。但一旦你多写几个查询、多几张表、多几个业务流程,最初的“自由”就会立刻变成负担。裸写 JDBC 的第一宗罪,是样板代码的无限重复。随便看一段典型的查询代码:Class.forName("com.example.jdbc.Driver"); Connection conn = DriverManager.getConnection(url, user, password); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT id, name, age FROM user"); while (rs.next()) { System.out.println(rs.getInt("id") + " - " + rs.getString("name")); } rs.close(); stmt.close(); conn.close();这段代码本身没问题,问题在于它和另一张表的查询、另一个条件的查询之间,差的只有 SQL 字符串和结果集的处理方式,其余全是复制粘贴。Add 一个查询就往原来的类里粘一段?代码很快就会变成流水账。第二宗罪,是资源管理极易出漏洞。Connection、Statement、ResultSet三个资源,任何一个忘记 close,数据库连接数都可能悄悄涨满。尤其在一个方法里出现分支或者异常时,漏关资源的概率非常高。连接一多、异常一抛,测试阶段可能毫无感觉,上线后才发现数据库连不上了。第三宗罪,是 SQL 与业务逻辑完全纠缠在一起。写业务的人被迫直面对连接串、SQL 字符串、异常处理和资源回收的细节,数据表一改字段,所有相关代码都要翻一遍。这不仅仅是代码变丑的问题,更是维护成本的直线上升。1.2 OOP 在这里到底解决了什么问题很多人觉得 OOP 就是“把代码放进类里”,实际上没那么简单。OOP 在数据库访问层解决的,是三个层面的问题。第一个层面是封装。连接如何创建、资源如何释放、异常如何处理,这些属于“连接管理”的职责,应该藏进一个专门的类里。业务代码只需要说“给我一条连接”或者“帮我执行这条 SQL”,根本不需要知道底层是怎么拿到连接的。第二个层面是继承。既然几乎所有 DAO 都要做增删改查,那就设计一个通用父类,把通用的执行模板放在里面,子类只写属于自己的特殊逻辑。这就是典型的模板方法模式,代码量能直接砍掉一截。第三个层面是多态。有了接口和实现类的分离,业务层依赖的是一个“用户数据访问接口”,而不是某个具体的实现类。将来想换存储、想改实现,业务代码基本不需要动。这个思想在你以后接触框架时会反复出现,提前在 JDBC 阶段建立起来,性价比很高。1.3 演进路线:从过程式到分层设计从裸 JDBC 到一套像样的 OOP 访问层,我建议分三步走,不要一口气全做完。第一步,先做一个连接管理工具类,把所有获取连接和关闭资源的方法收进去。这一步做完,至少大量重复的连接代码消失了。第二步,为每张表建一个实体类,再为每个实体建对应的 DAO 接口和实现类,把 SQL 按照 CRUD 拆分到不同方法里。这时候代码已经有了清晰的边界。第三步,加一个 Service 层,把业务操作放到这一层组织,事务的处理也挪到这里。搞完这一步,你手里就已经是一个结构完整、可以直接用的小项目了。每个表对应一个实体类、一个 DAO 接口、一个 DAO 实现,业务层调用 DAO,DAO 依赖工具类,这已经是大多数项目在数据访问层面的标准结构。先按这个套路走,后面学框架时会发现很多东西你其实早就见过。2. 环境准备与项目骨架2.1 依赖、驱动与连接配置做这个项目之前,先把数据库驱动准备好。最简单的办法,是下载对应数据库的 JDBC 驱动 jar 包,放到项目的 lib 目录下,然后在构建工具里配置依赖;如果你用的是 Maven 或者 Gradle,直接在依赖声明里加一行驱动坐标即可,驱动会自动进入 classpath。驱动加载这一步,很多新手会踩坑。Class.forName("com.example.jdbc.Driver")这行代码看起来只是加载了一个类,实际上它触发的是驱动注册——驱动类的静态代码块里调用了DriverManager.registerDriver,让自己被注册到 JDBC 驱动管理器里。这样后面的DriverManager.getConnection()才能从一堆驱动中找到合适的那一个。所以这行不是可有可无的形式代码,而是整个连接过程的起始。连接配置建议放到一个配置文件中,不要硬编码在类里。我习惯建一个config.properties,写上连接地址、用户名、密码,然后在代码里读取。driver=com.example.jdbc.Driver url=jdbc:example://localhost:3306/java_oop_demo user=root password=123456在工具类的静态代码块里完成配置加载和驱动注册:static { try { // 读取config.properties并赋值给url、user、password Class.forName(driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } }注意,ClassNotFoundException属于受检异常,在静态代码块里不能直接抛,所以我把它包进了ExceptionInInitializerError,让 JVM 在类初始化失败时能第一时间反应出来。2.2 目录结构与职责边界推荐按功能分包,不要把所有类都放在一个包下面。我一般会建四个包:entity:实体类,一张表对应一个类。dao:数据访问接口和实现类。service:业务逻辑层,事务边界在这里。util:工具类,目前主要放连接管理。包结构一旦理清,每个类该干什么就很好判断了。实体类只负责“装数据”,不写 SQL;DAO 只负责“操作数据库”,不写业务逻辑;Service 只负责“组织业务”,不管 SQL 细节;工具类只负责“连接和释放”,不参与任何具体业务。这样的职责边界,说白了就是让每个变化点只影响一层。3. 五个核心类的设计与实操3.1 连接管理工具类:拿来即用且防泄漏工具类设计起来不难,但有三个细节值得注意。第一,getConnection()方法每次返回一个新的连接,所以它必须做足够的容错处理。我把SQLException直接抛出去,让上层决定怎么处理,因为这个异常往往意味着数据库不可用,在工具类里擅自拦截反而会丢失关键问题。第二,释放资源的方法要接收三个可能为空的资源对象,并在每个 close 里单独捕获异常。为什么单独捕获?因为依次关闭时,前面的异常不应该阻止后面的关闭动作。比如ResultSet关闭失败,还有没有关闭Statement;如果第一个异常直接抛出,后面的资源可能就永远不关了。完整的工具类大概是这样的:public class ConnectionUtil { private static String url; private static String user; private static String password; static { try { // 读取配置文件:driver / url / user / password Class.forName(driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }第三点要说的是关闭顺序。千万别小看这个顺序:先关ResultSet,再关Statement,最后关Connection。这和资源创建的次序是反过来的。如果一个应用把Connection先关了,底层的连接可能已经被归还,但结果集和语句还握着不放,数据库服务器收到的就是一堆悬挂的游标,时间一长必然堆积。3.2 通用 DAO 基类:模板方法干掉重复 CRUD连接管理解决的是“有连接可用”,接下来要考虑“怎么把重复的增删改查收起来”。这里我的方案是做一个泛型基类BaseDao<T>,把通用的更新和查询逻辑抽到父类中,子类只需要继承并复用即可。先定义一个RowMapper<T>接口,它负责把结果集里的一行转换为实体对象:@FunctionalInterface public interface RowMapper<T> { T mapRow(ResultSet rs) throws SQLException; }然后看基类的核心方法:public abstract class BaseDao<T> { protected int update(String sql, Object... params) { try (Connection conn = ConnectionUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { setParams(ps, params); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("数据库更新失败", e); } } protected List<T> queryList(String sql, RowMapper<T> mapper, Object... params) { try (Connection conn = ConnectionUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = executeQuery(ps, params)) { List<T> result = new ArrayList<>(); while (rs.next()) { result.add(mapper.mapRow(rs)); } return result; } catch (SQLException e) { throw new RuntimeException("数据库查询失败", e); } } protected T queryOne(String sql, RowMapper<T> mapper, Object... params) { List<T> list = queryList(sql, mapper, params); return list.isEmpty() ? null : list.get(0); } }注意这里用到了 try-with-resources,这是 Java 7 之后的语法,可以自动关闭实现AutoCloseable的资源,比手动在 finally 里写 close 干净得多。但有一点要说明:try-with-resources 关闭时,如果一个资源关闭失败,后续资源不会继续被关闭,所以如果你对资源安全性要求极高,仍然要回到工具类的显式关闭方式。setParams和executeQuery这两个辅助方法需要把可变参数一个个设置进去:private void setParams(PreparedStatement ps, Object... params) throws SQLException { if (params != null) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } } } private ResultSet executeQuery(PreparedStatement ps, Object... params) throws SQLException { setParams(ps, params); return ps.executeQuery(); }setObject是万能设置方法,它会根据参数的运行时类型自动调用对应的 setter,省去自己判断类型的麻烦。虽然性能上不是最优,但在通用基类这个层面,换来的是极大的简洁,项目体量不大时完全可以接受。3.3 结果集映射:三种姿势怎么选ResultSet本身是一个行游标,每一行都是数据库返回的原始字段,必须想办法把字段转成实体对象。这个映射过程,我见到的常用姿势有三种。第一种,手动取值,也就是在RowMapper里逐个rs.getXxx()并构造对象。优点是直观、好调试,缺点是每个查询都要写一遍 getter,字段多了很啰嗦。第二种,按列名反射映射。约定数据库列名和实体属性名一致,然后通过反射将字段值注入到对象的同名属性上。优点是通用性强,不需要为每张表写映射代码;缺点是对列名有约定要求,而且反射有一定性能损耗,出问题时不如手动取值好排查。第三种,配合泛型基类的回调式映射。BaseDao.queryList接收一个RowMapper,具体怎么从一行数据构造出对象,由调用方决定。这是我在中小项目里的首选方案——足够灵活,又不会像反射那样藏得太深。具体怎么选?如果你的表结构非常规整、项目周期紧张,反射映射可以帮你快速铺开;如果你希望每一行数据怎么转换成对象都能被一眼看懂,手动取值或者回调映射更适合。我自己的实践是,宁可多写几行,也要保证这段逻辑在任何时候都不会让人觉得“黑盒”。数据库访问层是整个系统的地基,这里故意留一点冗余,换取的是后续排查问题的确定性。3.4 事务处理:边界到底该放在哪一层这是整个项目里最容易翻车的设计点。新手最常见的错误,是在 DAO 的单个方法内部去开启事务。但一个 DAO 方法通常只对应一条 SQL,而一个业务往往需要执行好几条 SQL 才能算完成。比如用户注册,可能既要插入用户主表,又要往角色关联表里写数据,这两个操作必须同生共死。这时事务边界必须放在 Service 层。事务的基本流程是:关闭自动提交、执行多条 SQL、全部成功则 commit,任何一步异常则 rollback,最后恢复自动提交并释放连接。参考写法:public boolean register(User user) { Connection conn = null; try { conn = ConnectionUtil.getConnection(); conn.setAutoCommit(false); userDao.insert(conn, user); userRoleDao.insert(conn, user.getId(), defaultRoleId); conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { e.addSuppressed(ex); } } return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); } catch (SQLException ignored) {} ConnectionUtil.close(null, null, conn); } } }这里有一个很容易忽略的动作:finally 里要把setAutoCommit(true)恢复回来,否则连接池拿到一条还处于事务中的连接,后续使用者会陷入各种莫名其妙的“脏读”“不可重复读”问题。虽然这个小项目里每次拿的都是新连接,但养成这个习惯,将来接入连接池后能少踩很多坑。还有一个关于失败返回的逻辑:手写 JDBC 的 Service 方法里,异常业务往往需要把 SQLException 转成业务异常向上传递,而不是简单地返回 false。具体看你项目里的约定,我这里为了演示简洁,用返回布尔值代表成功,真实项目里一般会用一个统一的自定义异常。4. 完整实现演示:用户管理模块的一整套增删改查4.1 建表与实体类理论说得再多,不如完整跑一遍。我以用户管理模块为例,把整套实现串起来。先建一张最简单的用户表:CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT, email VARCHAR(100) );对应实体类的写法很基础,但有两个细节必须注意。第一,必须有无参构造方法。很多结果集映射工具在反射创建对象时,依赖的就是无参构造,虽然这里手动 mapping 用不上,但作为实体类的基本礼仪还是保留为好。第二,必须提供 getter/setter。这不仅仅是 JavaBean 的规范,还关系到后续你在 Controller、视图层用 EL 表达式或者某些序列化工具时的兼容性。实体类示例:public class User { private Integer id; private String name; private Integer age; private String email; public User() {} // getter/setter 省略 }id用包装类型Integer而不是基本类型int,这样在结果为 null 时可以区分“数据库里没有值”和“值就是 0”,避免业务判断出错。这是一个很细节、但经常被忽略的习惯。4.2 用户 DAO 接口与实现类按照分层习惯,先定义一个 DAO 接口:public interface UserDao { int insert(User user); int deleteById(Integer id); int update(User user); User findById(Integer id); List<User> findAll(); }实现类是核心。以插入为例,除了执行 INSERT,还要把数据库自动生成的主键回填到实体对象上。如果不回填,业务层连“刚插入的数据 id 是多少”都不知道,后续关联操作根本没法做。用getGeneratedKeys()可以拿到自动生成的主键:public int insert(User user) { String sql = "INSERT INTO user(name, age, email) VALUES(?, ?, ?)"; try (Connection conn = ConnectionUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.setString(3, user.getEmail()); int rows = ps.executeUpdate(); if (rows > 0) { try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { user.setId(keys.getInt(1)); } } } return rows; } catch (SQLException e) { throw new RuntimeException("插入用户失败", e); } }这里我特意用了Statement.RETURN_GENERATED_KEYS,如果不传这个参数,驱动不保证能返回生成的主键。百科上很容易漏这行,然后拿着getGeneratedKeys()一脸疑惑,其实是被这行坑了。查询实现要配合前面写的BaseDao。定义UserDaoImpl继承泛型基类,指定类型参数User:public class UserDaoImpl extends BaseDao<User> implements UserDao { @Override public User findById(Integer id) { String sql = "SELECT id, name, age, email FROM user WHERE id = ?"; return queryOne(sql, rs -> { User user = new User(); user.setId(rs.getInt("id")); user.setName(rs.getString("name")); user.setAge(rs.getInt("age")); user.setEmail(rs.getString("email")); return user; }, id); } @Override public List<User> findAll() { String sql = "SELECT id, name, age, email FROM user"; return queryList(sql, rs -> { User user = new User(); user.setId(rs.getInt("id")); user.setName(rs.getString("name")); user.setAge(rs.getInt("age")); user.setEmail(rs.getString("email")); return user; }); } }两个方法里的 lambda 内容完全相同,看起来确实有点冗余。实际项目里我会抽一个私有方法private User mapRow(ResultSet rs) throws SQLException,这样既保持代码整洁,又避免回调与通用模板冲突。这里为了把每一步都摊开让你看清,故意写重复了。4.3 Service 层编排与运行效果有了 DAO,Service 只做一件事:编排。以注册用户为例,Service 里先调用 DAO 插入用户,再把用户与默认角色关联,整个过程放到一个事务里。上面事务示例里的register方法已经演示了做法,这里不再重复。跑起来之后,最直观的验证方法是打印日志。我在练习时会给每个方法加上简单的输出,观察连接是否正常获取、SQL影响行数是否符合预期、主键有没有回填。实测下来,一次完整的 insert 流程会依次看到“获取连接”“执行插入”“回填主键”“关闭连接”,每步都有迹可循。这个过程听起来简单,却比很多高级框架里的黑盒操作更容易建立起体感。5. 高并发与性能细节:批处理、连接复用、SQL 注入5.1 PreparedStatement 为什么是防注入的基本盘很多新手听说过 SQL 注入,但没真正见过它长什么样。最简单的攻击方式,是拼 SQL 字符串:String sql = "SELECT * FROM user WHERE name = '" + name + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);如果 name 传入的是' OR '1'='1,拼出来的 SQL 就是:SELECT * FROM user WHERE name = '' OR '1'='1'这个查询会把整张表都查出来。要是碰上有删除操作的接口,攻击者完全可以构造更危险的参数。PreparedStatement之所以能防注入,关键在于预编译机制:SQL 结构在数据库端已经提前编译好了,参数通过占位符?传递,驱动会把参数当成纯粹的数据,而不是可执行的 SQL 片段。即使参数里有恶意的引号和关键字,也只会被当作字符串值处理。正确写法:String sql = "SELECT * FROM user WHERE name = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, name); ResultSet rs = ps.executeQuery();所以从这个项目开始,就定下一个死规矩:所有 SQL 一律使用PreparedStatement,永远不要用Statement去拼接字符串。这个习惯能帮你挡掉绝大多数注入问题。5.2 批处理与自动提交的配合如果需要一次性插入几千条数据,一条条执行会非常慢。PreparedStatement提供了一个addBatch和executeBatch机制,可以把多条 SQL 攒在一起,一次性提交给数据库执行。批处理配合事务使用效果最好:String sql = "INSERT INTO user(name, age, email) VALUES(?, ?, ?)"; try (Connection conn = ConnectionUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (User user : userList) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.setString(3, user.getEmail()); ps.addBatch(); } ps.executeBatch(); conn.commit(); } catch (SQLException e) { try { conn.rollback(); } catch (SQLException ex) { e.addSuppressed(ex); } }注意,addBatch只是把 SQL 加入待执行队列,真正执行发生在executeBatch调用时。如果不显式关闭自动提交,一批 SQL 可能被当成多个独立事务执行,中途一旦失败,前面已经执行的部分不会回滚,数据一致性就破了。所以“先关自动提交,再加批,再统一执行,再 commit,失败 rollback”这个顺序必须遵守。数据量很大的时候还要注意内存问题:如果一次性把几万条的批全部 add 进去再执行,内存会吃不消。实战中一般每攒 500 到 1000 条就调用一次executeBatch,清空一次缓冲。5.3 分页查询的通用写法分页是几乎所有列表功能都绕不开的需求。不同数据库的分页语法不一样,因此 JDBC 层的分页通常要预留扩展点。在一个不依赖特定数据库的项目里,我会写一个分页参数对象,再在 DAO 实现里按具体数据库语法拼 SQL。比如某关系型数据库的分页写法是LIMIT offset, count:public List<User> findByPage(int offset, int limit) { String sql = "SELECT id, name, age, email FROM user ORDER BY id LIMIT ?, ?"; return queryList(sql, rs -> { /* 映射 */ }, offset, limit); }调用时你需要计算 offset,最简单的公式是offset = (currentPage - 1) * pageSize。如果将来要换数据库,只需要把 DAO 实现里 LIMIT 这一段换成对应数据库的语法即可,上层 Service 完全无感知。这也是分层设计的典型受益点。6. 常见问题与排查技巧实录6.1 经典问题速查表做这个项目时,我踩过不少坑,有些坑到现在还会在别人的提问里反复出现。整理成一张速查表,方便你对照排查:现象可能原因排查方向启动时ClassNotFoundException驱动 jar 不在 classpath 中检查依赖配置,确认 jar 已引入连接超时连接串写错、数据库服务未启动、端口不通检查配置项,先用数据库客户端工具连一次试试查询结果中文乱码连接串未指定编码或表编码不一致连接串加上编码参数,统一表和连接字符集数据没查到但 SQL 没问题表名、列名与大小写不一致检查数据库对象名和 SQL 中引用是否完全一致数字显示为 0 而非空实体类用了基本类型改为包装类型,区分 null 和 0主键始终拿不到没传RETURN_GENERATED_KEYS补上参数,确认表的主键是自增列连接数持续上涨资源未关闭检查所有ResultSet/Statement/Connection是否都有 close多步操作只成功了一半事务边界放错了层确认事务在 Service 层,且 commit/rollback 都执行到这张表里的每一条,我都觉得是用真实代价换来的教训。比如“主键拿不到”那个问题,当时我调试了一整个下午,最后发现只是少了RETURN_GENERATED_KEYS这个参数,气到想摔键盘。所以把这表放在项目旁边,遇到问题先对照一遍。6.2 连接泄漏怎么定位连接泄漏是 JDBC 项目里最隐蔽、也最致命的问题。我说一个场景:某个后台任务定时批量更新数据,跑了一段时间之后,数据库的连接数从正常的十几个一路涨到几百个,最终报“too many connections”,整个应用瘫掉。这种问题的根源几乎都在于某个Connection没有正常关闭。定位的时候,我一般走三步。第一步,全局搜索getConnection,看看每次获取连接之后,是否都有对应的 close 路径。重点关注异常分支:如果只是 try 块里写了关闭,catch 或 return 之前抛出异常时,finally 里的 close 可能根本没执行到。第二步,查看是否有人把conn.close()写进了回调里,却又在某个外部类里重复调用ConnectionUtil.close,虽然重复关闭影响不大,但多了一重麻烦,反而容易掩盖真正的问题。第三步,如果已经接了连接池,可以打开连接池的监控日志,观察活跃连接数和空闲连接数。如果空闲数下降而活跃数持续上升,几乎就是有地方拿了连接没还回去。6.3 查不出数据的“幽灵”错误还有一种情况特别迷惑人:SQL 在数据库客户端里执行完全正常,但程序查询结果始终是空的。问题往往出在列名映射或者缓存上。我遇到过一次,是因为查询里给列起了别名,别名和实体类属性的映射关系没对上。比如SELECT user_name AS name FROM user,在rs.getString("name")是对的,但如果你写成了rs.getString("user_name"),当然拿不到值。还有一次,是因为连接串里带的时区参数不对,导致日期时间列的值被转换后发生了变化,排查了很久才发现不是取值逻辑的问题,而是驱动在解析时间戳时按照错误时区做了偏移。这类错误不会报异常,只会给你一个看起来“不太对但说不出哪里不对”的结果。遇到这种问题,建议先打印原始ResultSet里的所有列名和值,看看数据到底长什么样,再去怀疑映射逻辑。最后分享一点个人体会这个项目做完之后,我最大的感受不是“JDBC 不过如此”,而是“原来把代码整理成类,可以让工作量下降这么多”。刚开始我觉得写ConnectionUtil、BaseDao、RowMapper这一堆东西,是在绕远路,不如直接复制粘贴来得快。但真正遇到十几个表、几十个查询的时候,就会发现前期那点设计时间,会在后面无数次调用里连本带利地赚回来。还有一个小习惯值得专门说:练习时,在每个方法的入口和出口打印一行简洁的日志,记录连接获取、SQL 执行、资源释放的状态。一开始你会觉得日志多余,等你排查连接泄漏和查不出数据的问题时,这些日志就是最快定位问题的最好帮手。如果你刚写完这个项目,下一步建议找个框架级的数据访问层源码去看看,你会发现里面到处都能见到这套思路的影子。带着 OOP 的底子去学,你会比别人快很多。