ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JDBC、泛型、反射与集合:手写通用BaseDao的数据访问底层逻辑

JDBC、泛型、反射与集合:手写通用BaseDao的数据访问底层逻辑 如果让我从 Java 基础里挑几个“看着简单、组合起来要命”的知识点JDBC、集合、反射、泛型一定排在最前面。很多工作了两三年的人聊 JVM、并发、消息队列头头是道真让他写一个数据访问层却还是 ResultSet 里一个个 getString然后手动 new 对象、手动塞字段遇到字段一多就开始怀疑人生。这不是因为他能力不行而是大部分教程把这四样东西拆得太散了JDBC 讲连接集合讲数据结构反射讲 Class泛型讲类型擦除没人告诉你它们其实经常一起出现而且是同一道数据访问题的四个零件。这篇文章换个姿势我不准备用框架而是用一个手写的 BaseDao 把这四个点全部串起来拆清楚它们各自解决什么问题、怎么配合、在哪些位置最容易翻车。看完以后你可以照着写一份自己的通用数据访问工具也能顺便把面试最爱问的底层逻辑过一遍。目标不大但非常实在。1. 先理清这四兄弟的真实关系一个 JDBC 案例的距离1.1 从 JDBC 最原始的写法说起每人第一段代码的痛点我第一次用 JDBC 查用户表代码差不多长这样Class.forName(com.mysql.cj.jdbc.Driver); Connection conn DriverManager.getConnection(url, username, password); Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(select id, name, age from user where age 20); ListUser users new ArrayList(); while (rs.next()) { User u new User(); u.setId(rs.getInt(id)); u.setName(rs.getString(name)); u.setAge(rs.getInt(age)); users.add(u); }这段代码能跑但毛病清清楚楚每张表都要复制一套每个字段都要手动 set字段一变就要改 Java 代码SQL 里如果拼了用户输入还容易出注入问题。更难受的是你明明只是想把数据库里的行变成 Java 对象却要重复做体力活。后来用框架省事了可如果不理解框架底层在干嘛一旦遇到奇怪问题还是只能瞎猜。所以复习 JDBC我建议回到这段最原始的代码看清痛点在哪里再一步步优化。1.2 为什么复习 JDBC 时总是绕不开集合、反射和泛型这四个知识点不是四个孤立的考点而是一条完整的链路。JDBC 负责拿数据ResultSet 返回的是一行一行数据Java 端要保存多行结果最自然的就是用集合尤其是ListT。从一行数据到一个实体对象字段名和属性名本来是对应的但你不想在每个方法里写死u.setName(rs.getString(name))那就得让程序自己发现这个实体有哪些字段、对应的列叫什么这就是反射。如果每个实体都单独写一套 Dao代码就重复到崩溃于是你用泛型把“实体类型”抽象成一个参数 T一套逻辑通吃所有实体。你可以把这条链路记住JDBC 提供数据库访问能力集合装数据反射做动态映射泛型做通用化设计。面试官问“给你一个实体类怎么用 JDBC 查询并返回 List”的时候他其实期待的就是你能把这四样东西串起来回答。1.3 我们今天的落地目标写一个属于你自己的 BaseDao说了这么多直接定个目标写一个泛型BaseDaoT只依赖 JDBC不使用任何 ORM 框架。它需要提供这些方法insert(T obj)把对象插入数据库deleteById(Serializable id)根据主键删除selectById(Serializable id)根据主键返回一个 T 对象selectAll()返回ListTupdate(T obj)根据主键更新非空字段这个目标足够覆盖我们要复习的内容泛型用来定义 T反射用来解析实体字段和自动生成 SQL集合用来封装查询结果JDBC 负责连接和执行。等这个 BaseDao 能跑通你会发现以后再去看别人的 ORM 源码不是在读天书而像在看老朋友表演。2. JDBC 核心要点与连接管理没有它后面全是空中楼阁2.1 驱动加载、连接获取和 Statement 家族先复习 JDBC 最基础的三步。第一步是驱动加载最经典的写法是Class.forName(com.mysql.cj.jdbc.Driver)。MySQL 8 之后驱动有 SPI 自动注册机制你不写这行也能连上但建议保留因为一旦遇到老环境或者驱动冲突这行代码能让你少排查很多问题。第二步是DriverManager.getConnection(url, username, password)这里的 url 不只是写个地址那么简单像useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai这些参数都影响你后面读到的数据。时区参数是最容易坑新手的MySQL 8 不配 serverTimezone 启动直接报错所以这个细节到了面试里也会被拿出来当“基础牢不牢”的试金石。拿到 Connection 以后下一步是创建 Statement 或它的子接口。这里有一个基础对比接口适用场景特点Statement静态 SQL每次执行都要编译存在 SQL 注入风险PreparedStatement动态参数 SQL预编译占位符传参防注入适合批量CallableStatement存储过程基于 PreparedStatement扩展了调用过程的能力在通用 BaseDao 里我们只用 PreparedStatement。原因很简单SQL 骨架固定参数通过占位符传递既安全又高效后面不管拼什么查询条件都不用再和单引号较劲。2.2 PreparedStatement 为什么是“唯一正解”很多人知道 PreparedStatement 能防 SQL 注入但不知道它具体怎么防。我习惯打一个快递员式的比方Statement等于你每次给快递员口头描述一遍完整地址还得允许他把你说的内容当成路线的一部分PreparedStatement等于你先告诉快递员“去某某园区到了找保安登记”后面只是把具体的包裹信息递给他。放到 SQL 里就是先把语句结构select * from user where name ?发给数据库做预编译再把“张三”作为参数单独传。用户输入永远不会被当作 SQL 关键字去解析自然断掉了注入路径。还有两个容易被忽视的好处。第一是性能一条 SQL 需要反复执行时预编译后的执行计划可以被复用虽然 JDBC 这层的性能差异在小应用里不明显但批量操作时差别就出来了。第二是类型处理ps.setString(1, value)或ps.setInt(2, age)让驱动明确知道这个参数是什么类型不会出现前端传一个字符串然后 SQL 无意识做隐式转换的尴尬。String sql select id, name, age from user where name ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, 张三); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { // 处理结果 } } }2.3 事务与资源释放最容易翻车的三个细节第一件事是事务边界。默认情况 Connection 是自动提交的每次 executeUpdate 都会直接落库。如果需要多条 SQL 一起成功或一起失败必须手动conn.setAutoCommit(false)然后所有操作共享同一个连接成功就commit()异常就rollback()。很多人会把 commit 写在 finally 里这是一个隐蔽的坑如果业务代码抛了异常finally 里的 commit 还是会执行等于把 rollback 逻辑废掉了。正确的姿势是 catch 里 rollbackfinally 里只负责关闭连接和释放资源。第二件事是释放顺序。按规范应该 ResultSet → Statement → Connection比如先关 ResultSet再关 Statement最后关 Connection。如果开着 ResultSet 不关在高并发下连接池很容易被耗尽。现在有 try-with-resources 以后省了很多事但如果你还在用老代码记得最里层的结果集也不能漏。第三件事是连接池语义。连接池里的connection.close()不是真正断开数据库而是把连接归还给池子下次还能复用。这导致一个常见误区在同一个连接上开了事务执行结束关闭连接前没有 commit 或 rollback连接回到池子时还带着未完成的事务状态下个使用者可能莫名看到脏数据。所以抽象的数据访问层里事务边界必须清晰连接释放必须严格这是写 BaseDao 前就必须拿捏的基本功。3. 泛型把 DAO 从“传统方法”升级成“万能模板”的第一步3.1 泛型类/泛型方法怎么定义T 到底是什么类型泛型说白了就是一个“类型占位符”在你写类的时候先不决定具体操作谁等使用者传入或子类继承时才确定。比如BaseDaoT这里的 T 可以是 User、Order、Product但 BaseDao 的代码不用改。用优惠券来类比优惠券上写“满 100 减 20”没说你能买什么等你在超市结账时这张券贴在零食、日用品上都行结算逻辑是通用的。泛型方法也是一样的道理方法上单独声明T的写法经常出现在工具类里比如public T ListT parseRows(String sql, Object... params)这种情况下 T 和类上的 T 是两回事是方法调用时根据上下文推断出来的。面试里常问“泛型到底是编译期还是运行期”记住它主要存在于编译期用来帮编译器做类型检查一旦编译完很多类型信息就没了。3.2 拿泛型去做 BaseDao 设计时子类继承那一瞬间发生了什么如果只写一个BaseDaoT里面全是 extends T、List 这个类自己是不知道 T 具体是什么的。只有当别人写public class UserDao extends BaseDaoUser时子类才把 T 固定为 User。这时 BaseDao 内部如果想做User.class或者new User()就会遇到一个麻烦T 已经被擦除了运行时根本看不见。所以通用 BaseDao 通常有两种方式拿到真正的 Class 类型。一种是构造器显式传入ClassT简单但每个子类都要写一遍构造器另一种是用反射读取父类泛型参数SuppressWarnings(unchecked) private void resolveEntityClass() { ParameterizedType pt (ParameterizedType) getClass().getGenericSuperclass(); entityClass (ClassT) pt.getActualTypeArguments()[0]; }这段代码要在子类对象上执行才有意义。如果你直接new BaseDao()getClass().getGenericSuperclass()返回的不是参数化类型强转成ParameterizedType会报 ClassCastException。这个细节我在自己项目里踩过面试也喜欢把它包装成一个“为什么我的 Dao 启动就报错”的题目。3.3 泛型擦除复习时最容易忽略又特别喜欢考的点泛型擦除带来的限制在 BaseDao 里特别明显你不能new T()因为运行时的 T 只是 Object也不能obj instanceof T因为 T 的类型信息不在字节码里。解决办法是借助反射用entityClass.getDeclaredConstructor().newInstance()创建对象用entityClass.isInstance(obj)做类型判断。还有一个知识点静态方法不能直接使用类上的泛型参数 T因为静态成员不依赖某个具体实例它不知道这个类的 T 被绑定成了什么。所以那些把泛型和反射混在一起写的代码才那么容易报错。我建议你把“T 被擦除后需要补充一个 Class 对象”记成一条铁律这会贯穿整个 BaseDao 的实现。4. 反射所有“框架魔术”的地基也是结果集映射的万能钥匙4.1 拿到 Class 对象的三种方式以及为什么框架里更常用 forName反射的第一步是拿到 Class 对象。三种常规写法Class? c1 User.class; Class? c2 user.getClass(); Class? c3 Class.forName(com.example.entity.User);User.class适合静态场景编译期就能确定user.getClass()需要先有对象Class.forName接受字符串天然适合动态加载。框架里面最常见的是第三种因为框架经常从配置文件、注解、或者类路径扫描里拿到一串名字比如com.example.entity.User然后用 forName 去加载。BaseDao 场景直接用 entityClass 就好它就是我们在泛型解析阶段拿到的那个 Class 对象。4.2 通过反射解析实体类字段并自动生成 SQL拿到了ClassT下一步是解析实体类字段。Java 反射里有几个 API 要分清楚getFields()只能拿到 public 字段getDeclaredFields()能拿到当前类所有字段包括 private但是不包括父类字段。BaseDao 里我们主要用getDeclaredFields()。生成 insert 语句的大致思路Field[] fields entityClass.getDeclaredFields(); ListString columns new ArrayList(); ListObject values new ArrayList(); for (Field field : fields) { field.setAccessible(true); columns.add(field.getName()); values.add(field.get(obj)); } String sql insert into tableName ( String.join(,, columns) ) values ( columns.stream().map(c - ?).collect(Collectors.joining(,)) );这里默认实体属性名和数据库列名一致是新手最友好的约定。如果你想要更灵活可以自定义Column注解在反射时读取注解覆盖字段名。理解了这一层再看 MyBatis 的映射配置就不会觉得神秘了要么靠约定要么靠注解本质都是反射。4.3 反射性能的常见顾虑setAccessible 与缓存字段“反射慢”这个说法流传已久但实际业务里它不是瓶颈关键在于怎么用。第一field.setAccessible(true)可以跳过访问权限检查确实能提升调用速度但这不是让你公开调用私有字段它只是跳过 JVM 的权限校验不能破坏 Java 封装的安全模型。第二不要循环里反复 getDeclaredFields应该在 BaseDao 初始化时把字段数组缓存起来之后每次查询直接复用。这样反射成本被摊销到了无数次调用中。另外新版 JDK 对反射有一些限制比如模块化系统下如果目标包没有开放setAccessible可能抛出InaccessibleObjectException。这个属于踩过才知道的坑。通用做法是尽量用--add-opens解决框架场景但如果你只是个人项目保持实体类和 Dao 在同一个模块通常没这个问题。5. 集合查询结果怎么装从数组到 List 到 Map 的演进思路5.1 ListMapString,Object 和 List实体各自适合什么场景写 BaseDao 时结果集封装是绕不开的一环。最灵活的方式是用ResultSetMetaData动态读取列名然后每一行组装成一个 MapResultSetMetaData metaData rs.getMetaData(); int columnCount metaData.getColumnCount(); ListMapString, Object rows new ArrayList(); while (rs.next()) { MapString, Object row new LinkedHashMap(); for (int i 1; i columnCount; i) { row.put(metaData.getColumnLabel(i), rs.getObject(i)); } rows.add(row); }这种方式适合做报表、动态列查询、或者你根本不知道实体类是什么的场景用ListMap最稳。但它有个缺点拿出来的值是 Object业务代码还得自己强转。所以常规 CRUD 我们还是选择ListT让泛型帮我们保证类型安全。强类型适用于绝大多数业务逻辑Map 结构适合临时或动态数据处理两者并不矛盾。5.2 写出真正“极简且健壮”的泛型集合操作把 ResultSet 转成ListT的时候有几个习惯值得从一开始就养成。第一查不到数据时返回Collections.emptyList()不要返回 null调用方就能无脑 for-each。第二如果预先知道结果不会很多用new ArrayList(64)给个估算容量减少扩容但数据量大就不要硬塞内存了。第三在循环里做反射封装时字段数组一定提前缓存好不要每行数据都重新getDeclaredFields。第四返回给外部使用的集合如果没有特殊要求直接用不可变视图Collections.unmodifiableList(list)可以防止误改。还有一个和 JDBC 直接相关的坑rs.next()是行游标不能用 for 循环预先拿到行数所以 ArrayList 这种基于数组的集合自动扩容能力特别契合这里。这也是为什么我们不用数组存查询结果——数组长度在创建那一刻就定死了而查询结果多少行根本不可预知。5.3 集合与 JDBC 底层 ResultSet 的相似性为什么 List 比数组更香ResultSet 本质上是一个有状态的行游标它不会一次性把所有数据倒给你而是通过next()一格一格移动。你可以在游标移动过程中把每一行封装成对象放进 List这样做的好处是数据到了 Java 内存后连接可以尽快释放数据库资源压力也小。但要注意如果查询返回的数据量特别大比如全表几十万行全塞进 List内存照样挡不住。这时候需要分页查询或者流式读取MySQL 驱动里setFetchSize(Integer.MIN_VALUE)配合流式读取是另一个话题但你要记住集合是内存容器不是无限仓库。写通用工具的时候永远对大结果集保持一颗敬畏心。6. 把它们拧成一股绳BaseDao 完整示例与代码走读6.1 泛型 BaseDao 关键代码结构前面四点复习完现在把它们组装起来。核心代码不长但每一个点都踩在前面的知识上public abstract class BaseDaoT { private final Connection connection; private final ClassT entityClass; SuppressWarnings(unchecked) protected BaseDao(Connection connection) { this.connection connection; this.entityClass (ClassT) ((ParameterizedType) getClass().getGenericSuperclass()) .getActualTypeArguments()[0]; } public ListT queryList(String sql, Object... params) throws Exception { Field[] fields cacheFields(); ListT list new ArrayList(); try (PreparedStatement ps connection.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } try (ResultSet rs ps.executeQuery()) { while (rs.next()) { T obj entityClass.getDeclaredConstructor().newInstance(); for (Field field : fields) { field.setAccessible(true); Object value rs.getObject(field.getName()); if (value ! null) { field.set(obj, value); } } list.add(obj); } } } return list; } private Field[] cacheFields() { return entityClass.getDeclaredFields(); } }这段代码已经把 JDBC 连接管理、PreparedStatement、反射创建对象、反射给字段赋值、泛型集合返回串起来了。注意这里cacheFields()目前只是简单返回数组实际使用中我会把字段缓存到 Map避免每次调用都反射。6.2 关键方法通用查询和更新的实现细节上面的queryList有几个细节要展开。第一field.getName()被当作列名使用这是约定大于配置的方式实际列名如果用了下划线你可以写一个下划线转驼峰的方法或者在列名和字段名不一致时用注解。第二rs.getObject(field.getName())返回 Objectfield.set(obj, value)要求值为匹配字段类型的对象。所以实体类字段建议全部使用包装类型比如Integer而不是int否则数据库 NULL 值赋给基本类型会直接抛异常。update 的写法也比较固定大体是根据主键字段拼条件其他字段作为 set 子句。这里有一个我要特别提醒的点更新时要不要忽略 null 字段如果实体类里某个字段为 null而你执行 update 时把它也 set 进去数据库就会把它更新成 NULL。业务上很多场景期望“没传字段就不更新”所以完善的 BaseDao 应该判断 null 并跳过。这就是为什么简单的 BaseDao 容易写但写“健壮好用的 BaseDao”不简单。6.3 使用过程中的几个扩展点和小陷阱我自己的 BaseDao 做过几个扩展支持TableName注解指定表名、支持Column注解指定列映射、支持按主键存在与否决定 insert 还是 update。这些扩展并不难都是在反射解析字段的那一步做文章。再说几个小陷阱。第一getDeclaredFields()只返回当前类字段如果你的实体继承了一个BaseEntity并且 id 字段写在父类里直接遍历会拿不到 id解决方法是写一个递归遍历父类 Field 的工具方法。第二getClass().getGenericSuperclass()强转失败通常是因为new BaseDao()而不是通过子类实例调用。第三字段顺序是未定义的不要依赖它去匹配 SQL 中的列顺序StringBuilder 拼接字段时要用同一个字段数组来源。7. 常见问题与排查技巧实录7.1 反射获取字段时为什么拿不到父类字段/private 字段这个问题出现的频率极高。有人写了getFields()发现只能拿到 public 字段有人用了getDeclaredFields()发现子类私有字段拿到了但父类字段还是空的。原因前面提过getDeclaredFields()只扫描当前类。解决办法是自己写一个递归private ListField getAllFields(Class? clazz, ListField list) { if (clazz null || clazz Object.class) { return list; } list.addAll(Arrays.asList(clazz.getDeclaredFields())); return getAllFields(clazz.getSuperclass(), list); }拿到字段后field.setAccessible(true)也要对每个字段执行尤其是 private 字段。这样 BaseEntity 里的公共字段就能正确映射了。7.2 泛型类型无法强转、类型擦除导致的报错常见的报错是“ClassCastException: java.lang.Object cannot be cast to java.lang.String”这通常是因为使用了裸 List或者从某个返回 Object 的旧接口里取值没强转。排查方向很明确把你的List改成ListString把T在子类中明确绑定不要在运行时才做类型假设。还有一个稍微隐蔽的报错BaseDao 构造函数里(ParameterizedType) getClass().getGenericSuperclass()抛 ClassCastException。这往往是因为你的 Dao 类没有继承参数化类型或者你 new 的是一个父类对象。记住泛型解析必须在具体的子类实例上进行子类 extends BaseDao 的时候父类泛型参数才被固定下来。7.3 ResultSet 关闭后集合数据丢失、日期类型映射失败等有朋友问为什么 PreparedStatement 和 ResultSet 都关了List 里的数据还在答案很简单你在 ResultSet 关闭之前已经用 while 循环把每一行转成对象并 add 进集合数据是重新复制到 Java 内存的和 ResultSet 没关系了。真正要注意的是不要在 ResultSet 关闭之后再去调用rs.getObject那才是典型的异常来源。日期类型映射是另一个高频坑。数据库的datetime列通过rs.getObject通常返回java.sql.Timestamp你的实体字段如果是LocalDateTime直接field.set(obj, value)会类型不匹配。解决方案在反射赋值的逻辑里加一个类型转换分支把java.sql.Timestamp转成LocalDateTime或者实体字段直接使用java.util.Date。这个细节实际项目中一定会遇到。7.4 一套自测清单写完 BaseDao建议你用一套自测清单验证自己是否真的吃透了。插入用户时自增主键能不能回填到对象查询条件传 null 时 SQL 是否拼接正确更新对象时 null 字段会不会被意外覆盖删除一条不存在的记录时是返回 0 还是报错连接是否在所有异常分支中正常关闭事务回滚后数据库是否真的没有变化父类字段能否被正确映射日期、Boolean、BigDecimal 这些常见类型是否都能赋值成功。跑通这套清单比背十遍概念都管用。最后再分享一点我个人的复习习惯每隔一段时间我都会手写一遍这个 BaseDao不是为了造轮子而是通过这个过程校准自己对四个基础点的理解。尤其是反射获取泛型参数的那段代码每写一次都会提醒我“类型擦除”到底意味着什么。你可以拿自己项目里最常用的一张表试一次十分钟能跑通的代码远比看十遍教程有价值。如果中途卡住了别急那恰恰说明你已经知道自己的薄弱点在哪里了。
返回列表