自研ORM框架:从JDBC模板到反射映射的核心实现
简介一份面向Java开发者的原创PDF教程围绕基于Spring JDBC手写定制自己的ORM框架这一主题重点解决传统JDBC操作中连接管理、语句执行、结果集映射等环节的重复编码问题。资源以PDF形式整理共1个文件整体大小约940KB内容紧凑适合有一定Java基础、想要深入理解JdbcTemplate设计思想或为学习MyBatis打基础的读者。课程从加载驱动、建立连接、创建语句集、执行语句集、获取结果集、关闭资源六大步骤讲起先抽取mapperRow()方法完成数据封装再基于Spring JdbcTemplate进行二次开发逐步打造出通用化的自定义ORM组件同时涵盖事务管理、参数绑定、异常处理、结果集映射、批处理与缓存策略等关键知识点。已有178人学习通过教程可直观掌握数据库访问层封装思路提升代码复用性与可维护性为后续阅读MyBatis源码打下坚实基础。1. 从 JDBC 模板到自研 ORM这份手写框架在解决什么问题基于 Spring JDBC 二次开发手工定制一个 ORM 框架这是很多 Java 工程师迟早会想做的事。这份编号 02-01-11 的课程资源恰恰就是一套从 JDBC 裸代码一路推到 BaseDao 通用接口的实现笔记。它的主线非常清楚先用最原始的 JDBC 查一次数据库让所有人感受到样板代码的重复然后把结果映射抽成 mapperRow 方法再用反射和注解把映射变成通用逻辑最后给出 QueryRule、Page、ResultMsg、BaseDao 这一整套基础架构。读完你会明白MyBatis 的 ResultMap 也好JdbcTemplate 的 RowMapper 也好都不是什么黑匣子不过是把“列名到属性的映射”做得更顺手而已。适合正在学 Spring、准备看 MyBatis 源码的开发者。2. 先看懂 Spring JdbcTemplate 的设计模板方法 回调到底拆了什么在写自己的 ORM 之前得先弄明白 JdbcTemplate 到底解决了什么。2.1 传统 JDBC 的六步操作问题不在 SQL 而在样板代码课程里给的第一段代码是绝大多数 Java 开发者的数据库启蒙写法// 每次查一张表都要把这六步重复一遍 private static ListMember select(String sql) { ListMember result new ArrayList(); Connection con null; PreparedStatement pstm null; ResultSet rs null; try { // 1、加载驱动类 Class.forName(com.mysql.jdbc.Driver); // 2、建立连接 con DriverManager.getConnection( jdbc:mysql://localhost:3306/db_demo?characterEncodingUTF-8, root, 123456); // 3、创建语句集 pstm con.prepareStatement(sql); // 4、执行语句集 rs pstm.executeQuery(); // 5、遍历结果集逐列取值 while (rs.next()) { Member instance new Member(); instance.setId(rs.getLong(id)); instance.setName(rs.getString(name)); instance.setAge(rs.getInt(age)); instance.setAddr(rs.getString(addr)); result.add(instance); } } catch (Exception e) { e.printStackTrace(); } finally { // 6、按顺序关闭结果集、语句集、连接 try { rs.close(); pstm.close(); con.close(); } catch (Exception e) { e.printStackTrace(); } } return result; }这段代码里真正跟业务有关的只有三行pstm.executeQuery()、while (rs.next())和中间那段instance.setXxx(rs.getXxx())。其余的加载驱动、建立连接、关闭资源、异常处理每张表、每个查询都要重复写一遍。代码写得越多问题越明显换一个实体类ResultSet 的取值代码就要重写一遍列名硬编码最容易写错连接、语句、结果集的关闭散落在 finally 里一旦漏掉一个数据库连接就会泄漏where 条件稍微多一点字符串拼接就开始失控单引号、空格、编码问题轮番出现。课程在这里做了一个很自然的改进把结果集的封装逻辑抽出来单独放一个mapperRow(ResultSet rs, int rowNum)方法。private static Member mapperRow(ResultSet rs, int i) throws Exception { Member instance new Member(); instance.setId(rs.getLong(id)); instance.setName(rs.getString(name)); instance.setAge(rs.getInt(age)); instance.setAddr(rs.getString(addr)); return instance; }这让代码好看了一些但问题没有根治。换一个 Order 实体类mapperRow方法照样要再写一遍本质上还是手工劳动。要真正解决重复需要的是“把共同流程变成模板把差异部分变成回调”的思路这正是 JdbcTemplate 的核心。2.2 JdbcTemplate 的核心抽象固定流程交给模板业务差异交给回调JdbcTemplate 的设计核心是模板方法模式加回调接口。模板方法把那些永远不变的动作包括连接创建、语句创建、参数绑定、结果集遍历、资源关闭全部收进框架留给调用方的只有一个RowMapperT接口。public interface RowMapperT { T mapRow(ResultSet rs, int rowNum) throws Exception; }有了这个抽象模板方法就可以把连接管理、语句创建、参数绑定、结果遍历全部接管只把“如何把一行数据变成对象”留给调用方。手写一个最小可用版本如下public T ListT executeQuery(String sql, RowMapperT rowMapper, Object... args) { ListT result new ArrayList(); Connection con null; PreparedStatement pstm null; ResultSet rs null; try { con DriverManager.getConnection(URL, USER, PASSWORD); pstm con.prepareStatement(sql); // 参数绑定统一用占位符防止 SQL 注入 for (int i 0; i args.length; i) { pstm.setObject(i 1, args[i]); } rs pstm.executeQuery(); int rowNum 0; while (rs.next()) { // 每一行的转换逻辑交给回调模板只负责遍历 result.add(rowMapper.mapRow(rs, rowNum)); } } catch (Exception e) { throw new RuntimeException(e); } finally { // 资源关闭收口到模板里 closeQuietly(rs); closeQuietly(pstm); closeQuietly(con); } return result; }这段代码一眼就能看出模板方法的边界参数绑定用setObject(i 1, args[i])占位符顺序和参数顺序严格对应rowMapper.mapRow(rs, rowNum)只处理单行转换行号由框架维护关闭连接、语句、结果集全部放在 finally 中调用方不用再碰资源管理。把这段代码和传统 JDBC 对比JdbcTemplate 的价值就很清楚了模板方法负责“连接从哪来、SQL 怎么执行、结果怎么遍历、资源怎么关闭”RowMapper 只负责“这一行记录转成什么对象”。两者一结合CRUD 的重复代码量直接下降一个量级。课程后续的内容其实是把这个思路往更深的层次推了一步既然每一行记录都是“列名到属性的映射”那能不能让框架根据实体类自动生成 RowMapper答案是可以做法就是第 4 章要讲的反射加注解。当你看到 MyBatis 里的 ResultMap、自动映射、下划线转驼峰时会发现它们都是在解决同一件事只是换了一套更成熟的外壳。3. 核心模块拆解注解映射、QueryRule 与统一返回模型自研 ORM 的第一步不是急着写 SQL而是先把基础类型定义清楚。课程在这一部分给出了三个关键组件注解实体、QueryRule 条件规则、Page 与 ResultMsg 返回模型。3.1 用注解给实体类补充表结构信息为了让一个通用 DAO 能处理任意实体实体类不能只是纯 POJO至少要告诉框架三件事对应哪张表、哪个字段是主键、属性名和列名不一致时怎么映射。Entity Table(name t_member) public class Member implements Serializable { Id private Long id; private String name; private String addr; private Integer age; // 省略 getter/setter }这里Table(name t_member)解决了“类名与表名不一致”的问题。实际项目里表名经常带前缀Java 类名却很少带注解就是用来补上这两个信息之间的桥。Id标记主键字段后续的 update、delete、insertAndReturnId 逻辑里主键是定位记录的最关键依据。如果某个字段的列名和属性名完全不同比如数据库列名是user_nameJava 属性名是userName可以在字段上加Column注解Entity Table(name t_user) public class User implements Serializable { Id private Long id; Column(name user_name) private String userName; }用注解而不是 XML 配置的好处是类型安全。字段名写错时虽然编译器不一定能发现但反射处理时可以抛出明确异常XML 配置只能等运行期查不到列名时才暴露排查成本高得多。3.2 QueryRule把查询条件做成链式 APIQueryRule 是这套框架里最能体现设计感的类。它允许开发者用链式声明描述查询条件而不是直接拼 SQL 字符串。public final class QueryRule implements Serializable { public static final int LIKE 1; public static final int IN 2; public static final int BETWEEN 3; public static final int EQ 4; public static final int AND 201; public static final int OR 202; private ListRule ruleList new ArrayList(); private QueryRule() {} public static QueryRule getInstance() { return new QueryRule(); } public QueryRule andEqual(String propertyName, Object value) { ruleList.add(new Rule(EQ, propertyName, new Object[] { value }).setAndOr(AND)); return this; } public QueryRule andLike(String propertyName, Object value) { ruleList.add(new Rule(LIKE, propertyName, new Object[] { value }).setAndOr(AND)); return this; } public QueryRule andBetween(String propertyName, Object... values) { ruleList.add(new Rule(BETWEEN, propertyName, values).setAndOr(AND)); return this; } public QueryRule addAscOrder(String propertyName) { ruleList.add(new Rule(ASC_ORDER, propertyName)); return this; } }这段代码隐藏了一个关键点andEqual(name, 某用户)执行后并没有生成任何 SQL只是往ruleList里加了一条规则。真正把规则翻译成 SQL 的是后面 SQL 生成器的事。这样设计有两个好处第一条件规则可以被复用。同一个 QueryRule 对象既可以传给select(QueryRule)做列表查询也可以传给select(QueryRule, pageNo, pageSize)做分页查询后者只是额外拼接分页语句。第二条件合法性校验可以集中做。比如只允许查询映射表中存在的字段在规则解析时检查一遍即可避免业务代码里到处埋雷。一个andEqual(name, 某用户).andLike(addr, 北京)的条件最终会被解析成类似name ? and addr like ?的 SQL 片段参数列表是[某用户, %北京%]。这个转换过程里最容易出错的是参数顺序没有跟上条件顺序后文避坑章节会专门讲。3.3 Page 与 ResultMsg统一翻页和返回结构分页是 DAO 层绕不开的需求。课程里的 Page 对象把“当前页数据、总记录数、页码、每页条数”都装在一起public class PageT implements Serializable { private static final long serialVersionUID 1L; private static final int DEFAULT_PAGE_SIZE 20; private int pageSize DEFAULT_PAGE_SIZE; private long start; private ListT rows; private long total; public long getTotalPageCount() { return total % pageSize 0 ? total / pageSize : total / pageSize 1; } public long getPageNo() { return start / pageSize 1; } public boolean hasNextPage() { return this.getPageNo() this.getTotalPageCount() - 1; } public boolean hasPreviousPage() { return this.getPageNo() 1; } public static int getStartOfPage(int pageNo, int pageSize) { return (pageNo - 1) * pageSize; } }分页对象里最容易忽略的是start和pageNo的关系。start是当前页第一条数据在结果集中的起始位置pageNo从 1 开始两者换算公式是(pageNo - 1) * pageSize。Page 里提供的getStartOfPage(pageNo, pageSize)静态方法底层实现就是这个公式。数据库分页 SQL 在 MySQL 里用的是LIMIT offset, pageSize数值一样但语义容易绕晕尤其是limit参数写反的时候。ResultMsg 的定位更简单统一 Controller 的返回模型public class ResultMsgT implements Serializable { private int status; private String msg; private T data; public ResultMsg() {} public ResultMsg(int status, String msg, T data) { this.status status; this.msg msg; this.data data; } // getter/setter 省略 }在自研框架里先统一返回模型比到处返回裸 List 要稳妥。后面接 Redis、MongoDB 甚至 Elasticsearch 时调用方只感知同一个 ResultMsg 结构内部实现随便换。这其实也是做基础框架的一个习惯先把边界接口定死再填实现团队协作时不会互相踩到。4. 核心实现从反射拼 SQL 到通用 DAO 的完整链路基础类型定义好之后就到了最硬核的部分反射解析、ResultSet 映射、BaseDao 实现骨架。先把最小工程结构搭起来课程资料虽然是手写代码但要跑通项目依赖其实很简单dependencies dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.3.20/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.29/version /dependency /dependencies最小工程目录大致是这样的src/main/java ├── com/demo/orm/framework/ │ ├── QueryRule.java │ ├── Rule.java │ └── BaseDao.java ├── com/demo/orm/demo/ │ ├── entity/Member.java │ └── dao/MemberDaoImpl.java └── javax/core/common/ ├── Page.java └── ResultMsg.java4.1 基于反射解析实体注解并生成动态 SQL通用 DAO 的第一步是把“根据实体类知道表名和字段列表”这件事做成通用逻辑。以查询为例SQL 生成器需要完成三个动作取表名、遍历字段、把非空属性拼成 where 条件。private SqlAndParams buildSelectSql(Object condition) throws IllegalAccessException { Class? entityClass condition.getClass(); Table table entityClass.getAnnotation(Table.class); if (table null) { throw new IllegalArgumentException(实体类缺少 Table 注解); } StringBuilder sql new StringBuilder(select * from ); sql.append(table.name()); StringBuilder where new StringBuilder( where 11 ); ListObject params new ArrayList(); Field[] fields entityClass.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); Object value field.get(condition); if (value null) { continue; } String columnName resolveColumnName(field); if (String.class field.getType()) { where.append( and ).append(columnName).append( ?); params.add(value); } else { where.append( and ).append(columnName).append( ?); params.add(value); } } sql.append(where); return new SqlAndParams(sql.toString(), params); }我建议把“SQL 片段”和“参数列表”捆绑成一个小对象而不是分别返回。不然很容易出现条件拼接顺序和参数顺序不一致的问题public class SqlAndParams { private final String sql; private final ListObject params; public SqlAndParams(String sql, ListObject params) { this.sql sql; this.params params; } public String getSql() { return sql; } public ListObject getParams() { return params; } }这段逻辑里有几个细节值得注意占位符统一用?不要直接把值拼进 SQL。早期版本代码里字符串类型条件喜欢加单引号比如name 某用户其他类型不加一旦遇到包含引号的输入SQL 就语法错乱或产生注入风险field.get(condition)只能拿到当前对象的非空属性null 属性不参与条件。这符合“条件对象只查有值字段”的设计直觉但也要注意想查询 NULL 条件时这套逻辑做不到需要另行扩展andIsNull只有查询条件字段才会出现在 where 部分排序字段、分组字段不在这个环节处理。4.2 基于 ResultSetMetaData 完成通用结果映射生成 SQL 只是前半段真正进阶的部分是 ResultSet 到对象的自动映射。课程的做法是先拿到实体类的全部字段建立“列名 - 属性名”的映射关系然后通过ResultSetMetaData动态读取列名和列数量最后逐列反射赋值。private T ListT mapResultSet(ResultSet rs, ClassT entityClass) throws Exception { ListT result new ArrayList(); ResultSetMetaData metaData rs.getMetaData(); int columnCount metaData.getColumnCount(); // 建立列名 - 字段名的映射 MapString, String columnToField new HashMap(); for (Field field : entityClass.getDeclaredFields()) { field.setAccessible(true); Column column field.getAnnotation(Column.class); String columnName column ! null ? column.name() : field.getName(); columnToField.put(columnName.toLowerCase(), field.getName()); } while (rs.next()) { T instance entityClass.getDeclaredConstructor().newInstance(); for (int i 1; i columnCount; i) { String columnName metaData.getColumnName(i); Field field entityClass.getDeclaredField(columnToField.get(columnName.toLowerCase())); field.setAccessible(true); field.set(instance, rs.getObject(columnName)); } result.add(instance); } return result; }这段代码里最核心的是metaData.getColumnName(i)这一行。它把数据库返回的列名和 Java 字段名解耦了只要映射关系对得上数据库列叫user_nameJava 字段叫userName也能正确赋值。这里我还额外做了.toLowerCase()归一化避免驱动返回USER_NAME这种大小写形式时映射不上。但也必须承认这里的rs.getObject(columnName)是“偷懒”的写法。它会由数据库驱动决定返回 Java 类型比如 MySQL 的 INT 可能返回 IntegerDATETIME 可能返回 Timestamp。如果实体字段类型是java.util.Date就可能出现赋值失败。这个坑后面避坑章节会详细展开。4.3 BaseDao先定契约再填实现课程里最有架构味道的部分是 BaseDao 接口。它把 DAO 层的公共操作固定下来上层业务只依赖接口不依赖具体实现public interface BaseDaoT, PK { // 面向对象的查询 ListT select(QueryRule queryRule) throws Exception; // 面向对象的条件分页查询 Page? select(QueryRule queryRule, int pageNo, int pageSize) throws Exception; // 面向 SQL 的兜底查询 ListMapString, Object selectBySql(String sql, Object... args) throws Exception; PageMapString, Object selectBySqlToPage(String sql, Object[] param, int pageNo, int pageSize) throws Exception; // 单条删除 boolean delete(T entity) throws Exception; // 批量删除返回受影响行数 int deleteAll(ListT list) throws Exception; // 插入并返回主键 PK insertAndReturnId(T entity) throws Exception; // 单条插入 boolean insert(T entity) throws Exception; // 批量插入返回受影响行数 int insertAll(ListT list) throws Exception; // 单条更新 boolean update(T entity) throws Exception; }分成三组来理解查询组select(QueryRule)、select(QueryRule, pageNo, pageSize)是面向对象的查询入口selectBySql、selectBySqlToPage是面向 SQL 的兜底入口。两条线并存既保证简单场景不需要写 SQL又不至于让复杂 JOIN 查询被框架卡死。写入组insert、insertAndReturnId、update、delete、deleteAll覆盖单条与批量的完整写路径。insertAndReturnId返回主键是自增主键场景下最常用的方法。实现时可以利用PreparedStatement.RETURN_GENERATED_KEYS执行完 insert 后从getGeneratedKeys()里取自增 ID。批量组insertAll、deleteAll返回受影响行数int类型能直接反馈执行结果方便调用方判断是否需要回滚。批量方法的性能问题第 5 章和第 6 章都会涉及。实际的通用实现类可以继承或持有 JdbcTemplate 完成底层操作。模板方法负责连接与事务框架代码只写 SQL 生成和参数绑定这比完全手写 JDBC 要稳很多也符合课程里“基于 Spring JDBC 二次开发”的定位。5. 避坑记录手写 ORM 最容易翻车的五个地方这部分是血泪经验。自己写 ORM比用 MyBatis 踩的坑要多得多因为所有细节都暴露在眼前。5.1 现象查出来的 List 长度正确对象属性全是 null第一次跑通查询发现 List 里的对象数量完全正确但每个属性都是 null控制台还不报错。这种问题最坑因为它不抛异常只是数据错。原因列名和实体属性名没有对应上。比如数据库列名是user_name实体属性是userName如果直接用属性名去rs.getObject(columnName)拿到的是 null。解决在ResultSetMetaData拿到列名后先做一层“列名 - 字段名”转换。要么用Column注解显式指定要么统一做下划线转驼峰不能裸靠属性名。框架里最怕的就是“不报错但结果错”所以映射失败时建议抛异常而不是静默返回 null。5.2 现象SQL 注入告警或者单引号导致 SQL 语法错乱手写 ORM 最容易爆的问题就是用字符串拼接条件。反射拿到属性值后where name 某用户这种写法一旦遇到输入某用户 or 11整条 SQL 的语义就变了。原因课程早期版本为了演示方便确实使用了字符串拼接。这个实现单独跑没有问题但放进通用框架就是漏洞。解决所有动态条件必须改造成?占位符。参数值统一收集到 List 中PreparedStatement按顺序绑定。这样既杜绝注入也规避了字符串拼接导致的多余引号问题。order by 后面的内容不能用占位符也要做白名单校验只允许映射表里的合法字段名。5.3 现象接口越跑越慢数据库连接数被打满查询功能都正常但跑一段时间后数据库连接池爆满后端服务直接无法获取连接。原因手写 JDBC 的 finally 里只要 close 顺序不对或异常被吞掉连接就不会真正释放。如果用了连接池断掉的连接会一直占据活跃连接名额。解决写一个统一关闭方法按rs - pstm - con的顺序关闭每个 close 单独 try-catch。生产级别建议直接用连接池并把连接获取放在 try-with-resources 或 JdbcTemplate 的模板方法里。我自己的习惯是每写完一个 DAO 方法就复查一遍 finally 块不能让关闭逻辑散落各处。5.4 现象Date 类型字段赋值时报 Argument type mismatch通用映射用rs.getObject(columnName)取值INT、VARCHAR 都没问题一到DATETIME字段就报类型不匹配。原因MySQL 驱动对 DATETIME 默认返回java.sql.Timestamp而实体字段如果声明成java.util.Date反射的field.set做类型检查时发现类型不一致直接抛异常。解决在字段赋值前判断类型。如果是日期时间字段先取Timestamp再做转换。更通用的做法是引入一个类型转换器把 JDBC 类型转成目标字段类型。不要指望驱动帮你全自动完成部分 MySQL 驱动对 LOCALDATE 的支持并不一致。5.5 现象批量插入一万条数据耗时几十秒insertAll方法看似写好了数据量一上来就原形毕露。每条数据都走一次完整 SQL 执行网络往返和事务开销完全扛不住。原因实现里循环调单条insert根本没有发挥数据库的批处理能力。BaseDao 接口里定义了批量方法但实现里偷懒套用了单条逻辑。解决改成 JdbcTemplate 的batchUpdate或者手写PreparedStatement.addBatch()。注意 MySQL 连接串上加rewriteBatchedStatementstrue否则驱动不一定真的批量执行。这个参数是我踩过最大的坑不加它批处理性能提升非常有限。6. 二次开发给自研 ORM 加上批量插入与字段命名转换课程版 ORM 已经把基础链路跑通了但在实际项目里还得再补齐两个高频能力。6.1 用 batchUpdate 实现 insertAll 的批处理语义BaseDao 接口里定义了insertAll(ListT list)如果它只是循环调insert那这个接口就没有存在的意义。我通常在具体实现里直接复用 JdbcTemplate 的批处理 APIpublic int insertAll(ListMember list) { String sql insert into t_member(name, addr, age) values(?, ?, ?); ListObject[] batchArgs new ArrayList(); for (Member m : list) { batchArgs.add(new Object[]{m.getName(), m.getAddr(), m.getAge()}); } int[] results jdbcTemplate.batchUpdate(sql, batchArgs); int count 0; for (int r : results) { count r; } return count; }batchUpdate返回 int 数组每个元素代表一条 SQL 受影响的行数。注意连接串必须带rewriteBatchedStatementstrue否则 MySQL 驱动可能把批量语句拆回单条执行。6.2 列名下划线自动转驼峰课程第一版里映射依赖Column注解字段少还能忍表一多就写到手软。我给框架加了一个默认转换逻辑private String columnToFieldName(String columnName) { StringBuilder sb new StringBuilder(); boolean upper false; for (char c : columnName.toCharArray()) { if (c _) { upper true; } else if (upper) { sb.append(Character.toUpperCase(c)); upper false; } else { sb.append(c); } } return sb.toString(); }规则是遇到_就跳过下一个字母转大写其余原样保留。user_name转成userNameaddr转成addr。把它放在列名映射的前置层找不到字段时再退回Column注解这样大部分实体类都不用再写注解了。从那以后我每次扩展新的实体类都会强制走一遍“字段映射自检”把实体字段列表和数据库返回列名打印出来逐一对齐确认列名转换规则没有漏掉字段。这个习惯帮我绕过了不少线上问题。希望这份手写 ORM 的拆解笔记能让你在理解 JdbcTemplate 和 MyBatis 的路上少走一段弯路。本文还有配套的精品资源点击获取