ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从JDBC到自研ORM:反射与注解构建可复用数据访问层

从JDBC到自研ORM:反射与注解构建可复用数据访问层

简介:一份面向Java开发者的PDF教程,聚焦利用Spring JDBC的设计理念手写定制ORM框架,适合有一定基础、想深入理解持久层原理的工程师,也适合为后续学习MyBatis做铺垫。教程从原生JDBC的典型操作入手,逐步拆解加载驱动、建立连接、创建语句集、执行语句、获取结果集、关闭资源六个步骤,并通过抽取mapperRow()方法,把结果集到实体的映射逻辑独立出来,减少重复代码。内容还延伸到Spring JdbcTemplate的二次开发,讲解事务管理、参数绑定、异常处理、结果集映射、批处理等关键机制,帮助读者理解ORM框架的设计思路,同时为自定义轻量级ORM组件提供可落地的代码参考。资源包内仅包含1个PDF文档,压缩后大小约940KB,轻量易查阅;目前已有178人学习下载,对于想要掌握数据库访问层封装技巧的开发者具有一定参考价值。

1. 基于 Spring JDBC 手写定制自己的 ORM 框架:从六步 JDBC 到可复用的数据访问层

基于 Spring JDBC 手写定制自己的 ORM 框架,听起来像是重复造轮子,但当你把 ResultSet 到对象的映射、SQL 拼接、分页这些散落代码收拢成统一接口后,再去看 MyBatis 的会话和结果映射,会突然通透很多。

这份资源从 JDBC 六步操作讲起,用反射和注解把表字段与实体属性绑定,再设计 BaseDao、QueryRule、Page、ResultMsg 这一套基础组件,沉淀成一个可以在小团队里直接用的轻量 ORM 骨架。它适合会用 MyBatis 但说不清底层原理的开发者,也适合想统一团队数据访问层、降低学习成本的场景。

跟着代码走一遍,你收获的不只是一套源码,更是一条从 JDBC 到 ORM 的完整推导路径。核心思路其实很朴素:把不变的部分收进模板,把变化的部分交给映射,剩下的技术细节全是围绕这两件事展开的。

2. 从 ResultSet 说起:JDBC 六步模板里的重复到底在哪

课程材料把第一步点得很直白:ResultSet 是最熟悉的陌生人。每个 Java 服务端开发都写过 JDBC 查询,但很少有人停下来数一数,每一次查询里哪些代码是必须变的。我数下来,其实只有两处:SQL 本身,以及 ResultSet 到对象的赋值。

剩下的加载驱动、建立连接、创建语句集、执行、关闭,全是固定的流程。Spring JdbcTemplate 的设计思想,就是把固定流程收进模板,把变化的部分交给 RowMapper 回调。

2.1 六步 JDBC 模板:哪一步是可以抽走的

先看材料里那段原生 JDBC 查询。它把六步走得非常完整,代码结构也代表了绝大多数老项目的真实状态:

private static List<Member> select(String sql) { List<Member> result = new ArrayList<>(); Connection con = null; PreparedStatement pstm = null; ResultSet rs = null; try { // 1、加载驱动类 Class.forName("com.mysql.cj.jdbc.Driver"); // 2、建立连接 con = DriverManager.getConnection( "jdbc:mysql://127.0.0.1:3306/demo_db?characterEncoding=UTF-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; }

这里有一个很多人没注意的细节:finally 块无条件调用 rs.close(),可一旦第 2 步连接失败,rs 还是 null,close 直接抛空指针。原生 JDBC 的重复不只是代码多,连异常处理都容易写错。把六步动作列成一张表,责任就一目了然:

步骤动作是否需要每个业务都写
1Class.forName 加载驱动否,连接池初始化一次即可
2DriverManager.getConnection否,交给 DataSource
3prepareStatement否,模板方法统一处理
4executeQuery否,模板方法统一处理
5ResultSet 取值并 set 到实体是,这是 ORM 的核心价值
6关闭 rs/pstm/con否,模板方法统一处理

结论很明确:第 5 步才是每个 DAO 之间真正不同的地方,也是后来所有 ORM 框架争夺的主战场。JdbcTemplate 的做法是,连接和语句由模板统一管理,映射交给 RowMapper 回调,使用者只写 SQL 和 mapRow 方法。

我一般会自己封装一个 JdbcTemplate 风格的基类,把 getConnection、prepareStatement、executeQuery、close 全部收进去,业务代码里永远看不到 Connection。这样即使不引入完整 ORM,日常开发也能少掉一半样板代码。

2.2 mapperRow() 分离:只解决了第一个问题

材料里的第一次改造是抽出一个 mapperRow() 方法,把 ResultSet 的取值动作从查询方法里挪走:

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 实体,你要再写一个 Order 的 mapperRow;换十张表,就写十个。真正的问题从写重复代码变成了写重复的映射代码。

Spring JdbcTemplate 用 RowMapper 接口解决这个问题,它的签名也是 mapRow(ResultSet rs, int rowNum),只不过把返回值从 Member 换成泛型 T。使用者仍然要写每一行的取值逻辑,但至少可以在不同 SQL 里共用同一个 RowMapper。再到 MyBatis,干脆把这段也自动化了,靠反射和结果映射规则完成赋值。

2.3 反射加注解驱动:让一张表变成一个实体映射

材料里的第二次改造是核心转折:不再为每个实体写 mapperRow,而是通过反射读取类上的注解,动态生成 SQL 和映射关系。先看实体类:

@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") 指定;字段名和列名一致时什么都不用加。这个约定在后面会对应到反射代码里。然后是一个通用的 select(Object condition):

private static List<?> select(Object condition) throws Exception { List<Object> result = new ArrayList<>(); Class<?> entityClass = condition.getClass(); Connection con = null; PreparedStatement pstm = null; ResultSet rs = null; try { con = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD); // 根据列名找属性名、根据属性名找列名,建立双向映射 Map<String, String> columnMapper = new HashMap<>(); Map<String, String> fieldMapper = new HashMap<>(); Field[] fields = entityClass.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); String fieldName = field.getName(); Column column = field.getAnnotation(Column.class); if (column != null) { columnMapper.put(column.name(), fieldName); fieldMapper.put(fieldName, column.name()); } else { columnMapper.put(fieldName, fieldName); fieldMapper.put(fieldName, fieldName); } } Table table = entityClass.getAnnotation(Table.class); StringBuilder sql = new StringBuilder("select * from " + table.name()); StringBuilder where = new StringBuilder(" where 1=1 "); // 用传入对象的非空属性拼动态 where for (Field field : fields) { Object value = field.get(condition); if (null == value) continue; if (String.class == field.getType()) { where.append(" and ").append(fieldMapper.get(field.getName())) .append(" = '").append(value).append("'"); } else { where.append(" and ").append(fieldMapper.get(field.getName())) .append(" = ").append(value); } } System.out.println(sql + where.toString()); pstm = con.prepareStatement(sql + where.toString()); // 执行查询 rs = pstm.executeQuery(); int columnCount = rs.getMetaData().getColumnCount(); // 按列名反射赋值,生成目标对象 while (rs.next()) { Object instance = entityClass.getDeclaredConstructor().newInstance(); for (int i = 1; i <= columnCount; i++) { String columnName = rs.getMetaData().getColumnName(i); Field field = entityClass.getDeclaredField(columnMapper.get(columnName)); field.setAccessible(true); field.set(instance, rs.getObject(columnName)); } result.add(instance); } } finally { // 关闭资源,生产环境建议 try-with-resources if (rs != null) rs.close(); if (pstm != null) pstm.close(); if (con != null) con.close(); } return result; }

提示:源码实体类用了 Lombok @Data,我这里为排版省略了 getter/setter。在 IDEA 里跑 Demo 前先确认 Lombok 插件已安装,否则编译会报找不到方法。

这段代码把 ORM 的三个核心动作串起来了。第一,读取实体 Class 上的 @Table 和 @Column 注解,维护 columnMapper 和 fieldMapper 两个 Map:columnMapper 负责数据库列名到实体字段名的正向查找,fieldMapper 负责实体字段名到列名的反向查找。生成 where 时需要 fieldMapper,映射结果时需要 columnMapper,少了任何一边都别扭。

第二,遍历传入对象的字段,取非空值拼进 where。注意这里为了演示直观,把值直接拼进了 SQL,String 加单引号、数值不加。这个写法有两个问题:一是单引号内容如果包含引号或特殊字符,SQL 直接崩;二是存在注入风险。课程下半部分会改成占位符方式,也就是 where 里只写 and column = ?,值统一放进参数列表。

第三,rs.getMetaData().getColumnCount() 是从数据库元数据拿到当前结果集有多少列,然后逐列取列名,再用 columnMapper 找到实体字段,反射赋值。这里的 getColumnName 有个隐藏坑,SQL 一旦用了别名,它返回的可能是原始列名而不是别名,映射就会落空,第 5 章会展开。

在我看来,这一段已经不是一个工具方法,而是一个最小可用的 ORM 引擎。它把“表结构信息”理解为 Class 上的注解,把“一行数据”理解为字段反射赋值,把“查询条件”理解为对象非空属性。后面所有框架的复杂设计,本质上都是从这条链路生长出来的。

3. 接口先行:BaseDao、QueryRule 与分页模型怎么定

反射版本跑通后,你会发现单表查询已经能做得很通用。但自己用可以,交给团队不行。材料里的自研框架没有一上来就写实现,而是先把数据访问层的接口模型定死:继承一个 BaseDao<T, PK>,查询条件用 QueryRule,分页用 Page,统一返回用 ResultMsg。

这个顺序很关键:先定契约,再写实现,团队里的人用起来才不需要猜。这份资源是系列第 1 部分,重点恰好就是这套接口模型。

3.1 BaseDao 接口:为什么查询返回 List ,原生 SQL 返回 List

BaseDao 的接口模型在材料里给了完整版本,核心方法可以压缩成下面这些:

public interface BaseDao<T, PK> { // 根据条件查询列表 List<T> select(QueryRule queryRule) throws Exception; // 根据条件查询分页 Page<?> select(QueryRule queryRule, int pageNo, int pageSize) throws Exception; // 原生 SQL 查询,返回 Map,适合 join 或报表类 SQL List<Map<String, Object>> selectBySql(String sql, Object... args) throws Exception; // 原生 SQL 分页 Page<Map<String, Object>> selectBySqlToPage(String sql, Object[] param, int pageNo, int pageSize) throws Exception; boolean delete(T entity) throws Exception; int deleteAll(List<T> list) throws Exception; PK insertAndReturnId(T entity) throws Exception; boolean insert(T entity) throws Exception; int insertAll(List<T> list) throws Exception; boolean update(T entity) throws Exception; }

注意几个设计细节。select(QueryRule) 返回 List ,语义是实体查询;selectBySql 返回 List<Map<String, Object>>,语义是任意 SQL 查询。当查询结果没有对应实体时,做成 Map 是最妥协也最灵活的方案。这比硬塞一个 T 要实在,因为 join 出来的列名、类型五花八门,实体很难覆盖。

insertAndReturnId 返回 PK 而不是 boolean,因为回填自增 ID 在很多业务里是刚需。delete 返回 boolean、deleteAll 返回 int,是在语义上区分“是否成功”和“影响了几行”。这套接口设计整体偏实用,没有过度抽象。

方法声明全部 throws Exception,作为教学演示可以接受,但生产环境我更建议模仿 Spring,把底层异常统一转换成 DataAccessException 之类的运行时异常,这样业务代码不用到处 try catch,事务回滚语义也更清晰。

3.2 QueryRule:用条件 DSL 代替散装字符串拼接

QueryRule 是这套框架里我最喜欢的一个设计。它不直接拼 SQL,而是把查询条件描述成结构化对象:

public final class QueryRule implements Serializable { public static final int ASC_ORDER = 101; public static final int LIKE = 1; public static final int IN = 2; public static final int BETWEEN = 4; public static final int EQ = 5; public static final int GT = 7; public static final int GE = 8; public static final int AND = 201; public static final int OR = 202; private List<Rule> 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 andIn(String propertyName, Object... values) { ruleList.add(new Rule(IN, propertyName, values).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; } }

每次调用 andXxx 都往 ruleList 里塞一个 Rule,Rule 里记录四样东西:操作符类型、属性名、参数值、以及它是 AND 还是 OR。之后由一个 SQLBuilder 遍历 ruleList,把属性名转成列名,把操作符转成 SQL 片段,把参数值收集进数组。业务层写代码时是链式调用,到执行层才真正面对 PreparedStatement。

这个设计最大的收益是把“条件要从哪来”和“条件怎么写进 SQL”解耦了。业务层不需要关心拼接顺序,也不容易漏掉 where 1=1 这种历史包袱。我后来在项目里用类似方式封装过多表查询,比在 service 里堆 if 拼字符串好维护太多。

看一眼就理解为什么 if 拼字符串不好:同样的 and name = '某个值',有人写单引号,有人写双引号,有人忘了转义。QueryRule 把操作符和参数分离,这些问题从根上消失了。当然它也有代价,复杂子查询比较难表达,材料里用 queryRuleList 支持嵌套,但太深的条件组合还是直接写 SQL 更稳。

3.3 Page 与 ResultMsg:统一分页和返回结构

分页对象 Page 的核心字段不多:start 代表当前页第一条数据在数据库中的偏移量,pageSize 每页条数,rows 当前页数据,total 总记录数。它的关键计算都围绕这四个字段展开:

public class Page<T> implements Serializable { private int pageSize = 20; private long start; private List<T> rows; private long total; public Page() { this(0, 0, 20, new ArrayList<T>()); } public Page(long start, long totalSize, int pageSize, List<T> rows) { this.pageSize = pageSize; this.start = start; this.total = totalSize; this.rows = rows; } public long getTotalPageCount() { return total % pageSize == 0 ? total / pageSize : total / pageSize + 1; } public long getPageNo() { return start / pageSize + 1; } public static int getStartOfPage(int pageNo, int pageSize) { return (pageNo - 1) * pageSize; } }

getStartOfPage 是分页 SQL 里 limit 偏移量的计算入口。比如第 2 页、每页 10 条,偏移量是 10。Page 没有直接暴露 start 给前端,而是通过 getPageNo 换算成页码,符合前端表格组件的使用习惯。

ResultMsg 就更简单了,status、msg、data 三个字段,是所有控制层返回的统一外壳:

public class ResultMsg<T> implements Serializable { private int status; // 状态码 private String msg; // 状态码解释 private T data; // 业务数据 // 多种构造函数与 getter/setter 省略 }

这套东西严格说不是 ORM 的一部分,但做框架时必须带上。后端接口如果没有统一返回结构,前端处理成功失败就得猜,监控也不好埋点。材料里专门提到 Page 能对接 Bootstrap Table,说明字段命名就是照着前端组件设计的,这个细节值得学。

4. 从原理到落地:注解解析、SQL 生成与结果集映射的完整链路

前面的接口模型是骨架,这一章把血和肉补上。以单表查询为目标,把反射 ORM 的实现拆成三个可独立测试的环节:字段映射、SQL 生成、结果集赋值。下面每个环节都是可以直接跑通的最小代码。

4.1 建立字段与列的映射:一张表对应两张 Map

不管是生成 SQL 还是映射结果,第一步都是把实体类信息读出来。常见做法是首次访问时解析一次,把结果缓存住,避免每次查询都反射一遍。核心解析逻辑如下:

Map<String, String> columnMapper = new HashMap<>(); Map<String, String> fieldMapper = new HashMap<>(); Field[] fields = entityClass.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); String fieldName = field.getName(); Column column = field.getAnnotation(Column.class); if (column != null) { columnMapper.put(column.name(), fieldName); fieldMapper.put(fieldName, column.name()); } else { columnMapper.put(fieldName, fieldName); fieldMapper.put(fieldName, fieldName); } }

columnMapper 的 key 是数据库列名,value 是实体属性名;fieldMapper 刚好相反。为什么要两份?因为 SQL 生成方向是属性名到列名,结果映射方向是列名到属性名,两个方向用同一个 Map 都要做一次反向查找,味道不对。

这里还有个约定问题。如果实体字段名是 userId,数据库列名是 user_id,默认没写 @Column 就映射不上。我一般会在框架里内置一个驼峰转下划线逻辑,字段名默认转成下划线再去找列,再放开 @Column 覆盖。这样既省注解,又能照顾特殊命名。

4.2 动态 where 与参数绑定:先能跑,再防注入

拿到映射关系后,根据传入条件对象生成动态 where。教学版写法如下:

StringBuilder sql = new StringBuilder("select * from " + table.name()); StringBuilder where = new StringBuilder(" where 1=1 "); List<Object> args = new ArrayList<>(); for (Field field : fields) { Object value = field.get(condition); if (value == null) { continue; } String dbColumn = fieldMapper.get(field.getName()); if (String.class == field.getType()) { where.append(" and ").append(dbColumn).append(" = ?"); } else { where.append(" and ").append(dbColumn).append(" >= ?"); } args.add(value); }

这段代码和材料里的版本有两点变化。一是把 String 拼单引号改成统一占位符 ?,值全部收进 args 列表;二是示例里把 int 类型当成 >= 条件,业务上通常是按年龄区间过滤。实际框架里应该由 QueryRule 来声明 EQ、GT、GE 这些操作符,而不是靠字段类型猜。

参数绑定是这套逻辑里不能省的一步:

pstm = con.prepareStatement(sql + where.toString()); for (int i = 0; i < args.size(); i++) { pstm.setObject(i + 1, args.get(i)); }

setObject 让 PreparedStatement 自己做类型适配,Integer、String、Date 都能处理,比自己拼字符串省心。如果后面做批量插入,连接串记得加上 rewriteBatchedStatements=true,MySQL 才能把多条 insert 合并成一条 multi-values 发送,性能差距很大。

这个环节最容易翻车的地方是条件顺序和占位符顺序不一致。建议用同一个 List 同时装值和 SQL 片段,保证 and column = ? 和 args.add(value) 总是成对出现,别在两个地方分别维护。

4.3 结果集映射:getMetaData、getColumnName 与反射赋值

查询执行完,结果集赋值是实现 ORM 的临门一脚。教学版代码很短,但包含两个关键点:

int columnCount = rs.getMetaData().getColumnCount(); while (rs.next()) { Object instance = entityClass.getDeclaredConstructor().newInstance(); for (int i = 1; i <= columnCount; i++) { String columnName = rs.getMetaData().getColumnName(i); Field field = entityClass.getDeclaredField(columnMapper.get(columnName)); field.setAccessible(true); field.set(instance, rs.getObject(columnName)); } result.add(instance); }

columnCount 是从元数据拿到的,不是写死的列数,所以 select * 返回多少列它就能处理多少列。columnMapper.get(columnName) 把数据库列名翻译成实体属性名,再 getDeclaredField 找到属性。这里有一个顺序容易搞反:是先根据列名找到属性,再给属性赋值,而不是反过来。

getObject 返回的类型取决于 JDBC 驱动。MySQL 的 bigint 可能返回 Long,numeric 可能返回 BigDecimal,如果实体属性是 Integer,某些驱动下会抛类型转换异常。成熟框架会把类型转换单独做一层,教学版就先不做。getColumnName 和 getColumnLabel 的区别,留到第 5 章展开。

5. 避坑指南:自写 ORM 时最容易忽略的五个工程细节

手写 ORM 最难受的不是写不出来,而是写出来之后在真实环境里各种玄学报错。下面五条都是我在类似 Demo 和项目里踩过的坑,按现象到原因到解决列出来,照着排查能少走很多弯路。

5.1 finally 块里 rs.close() 空指针

现象:连接或语句创建失败时,finally 块直接抛 NullPointerException,原始异常被掩盖,日志里看到的全是空指针。

原因:rs、pstm、con 三个变量只在 try 块里赋值,一旦前面某一步挂了,后面的变量还是 null。finally 里无条件调用 rs.close(),等于把空指针又提了一层。

解决:最省事的是用 try-with-resources,Java 7 之后 Connection、PreparedStatement、ResultSet 都实现了 AutoCloseable:

try (Connection con = dataSource.getConnection(); PreparedStatement pstm = con.prepareStatement(sql); ResultSet rs = pstm.executeQuery()) { while (rs.next()) { // 不需要手动 close } }

如果项目被迫用 Java 6,至少在 finally 里加空判断,关闭顺序按 rs、pstm、con 从里到外。

5.2 查询 SQL 用了别名,映射全部落空

现象:SQL 写成 select name as n from t_member,跑完发现实体里所有字段都是 null,日志没有任何报错。

原因:getColumnName() 从元数据拿的是底层列名,不是查询里的别名。select name as n 的结果集元数据里,列名仍然是 name,n 只能通过 getColumnLabel() 拿到。代码用 getColumnName 去 columnMapper 里找,找到的列名和 map 里的 key 对不上,getDeclaredField 传进去就是 null,映射自然失败。

解决:如果是自己的封装框架,统一约定 select 语句不使用别名,或者把取值逻辑改成 getColumnLabel()。大多数 RowMapper 实现用的是 getColumnLabel,因为要兼容别名场景。建议框架里直接从 getColumnLabel 取,这样两种情况都覆盖。

5.3 私有字段反射赋值报 IllegalAccessException

现象:运行到 field.set(instance, value) 时抛出 IllegalAccessException,提示无法访问私有属性。

原因:getDeclaredField 能拿到私有字段,但不代表可以直接读写。在 Java 8 和 Java 11 里,不调用 setAccessible(true) 就赋值必然报错;Java 17 强封装之后,普通反射连 setAccessible(true) 都可能被安全模块拦下来。

解决:每个 Field 在使用前都要执行 field.setAccessible(true),而且不要每次反射都从头查 Field。把 Field 对象缓存到 Map 里,按实体 Class 维度复用:

private static final Map<String, Field[]> FIELD_CACHE = new ConcurrentHashMap<>(); Field[] fields = FIELD_CACHE.computeIfAbsent( entityClass.getName(), k -> entityClass.getDeclaredFields());

批量插入十万条数据时,反射开销会被放大,缓存 Field 能有效降低耗时。另外,生产环境如果跑 JDK 17,框架启动参数里可能还要加 --add-opens java.base/java.lang=ALL-UNNAMED 这类配置,这是升级 JDK 时最容易踩的坑。

5.4 字符串拼接 where 导致 SQL 注入和单引号报错

现象:业务参数里带一个单引号,SQL 执行直接报语法错误;带 OR 1=1,数据被全表查出来。

原因:教学版为了展示 ORM 原理,把值直接拼进 SQL,也没有走 PreparedStatement。真实业务如果照抄,等于给攻击者开了一扇门。

解决:规则就一条,所有外部输入必须走占位符。where 片段只拼 and column = ?,值统一交给 setObject 绑定。Like 查询的 % 也作为参数传进去,不要拼在 SQL 里,否则参数本身包含 % 时语义会变。

我在项目里定过一条硬规矩:DAO 层禁止出现字符串形式的 SQL 拼接,统一走 QueryRule 或者参数数组。这条规矩帮我挡住过好几次线上事故,属于那种看着不起眼但能救命的规定。

5.5 自定义类放进 javax 包,升级 JDK 时直接翻车

现象:框架跑在 JDK 8 上没问题,升级到 JDK 11 或 17 后,编译或运行时频繁出现模块校验错误,查了很久才发现问题在自定义包名。

原因:材料里把 Page、ResultMsg 放在了 javax.core.common 这个包下面。javax 是 JDK 保留命名空间,和 javax.servlet、javax.persistence 这类模块冲突时,自定义类会被模块系统直接拒绝加载。

解决:立即把自定义类挪到自己的业务包名下面,比如 com.demo.core.common、com.demo.orm.framework。这是改一行就能解决的后悔药,越早改越省心。

如果你从这套源码里拿到工程,第一件事就是全局搜索 javax.core.common,批量替换包名和 import,再跑一遍测试确认没漏。

6. 验证方法:用一组测试把 SELECT 跑通,再看框架和 JdbcTemplate 的差距

6.1 最小可运行验证

拿到的源码如果只有接口和 QueryRule,实现类不全,可以先把第 2 章那套反射 select 抽出来,做一次最小验证。我习惯这样组织测试:

public static void main(String[] args) throws Exception { Member condition = new Member(); condition.setName("A同学"); condition.setAge(19); List<?> result = ReflectionOrmDemo.select(condition); System.out.println(Arrays.toString(result.toArray())); }

跑起来后重点看三件事:查询 SQL 是否按预期拼出了 where 条件;返回对象的 name、age、addr 是否都正确赋值;数据库连接的建立和关闭是否正常。

6.2 与 Spring JdbcTemplate 的差异对照

把自研版本和 JdbcTemplate 放在一起比,差距看得更清楚:

维度Spring JdbcTemplate自研反射 ORM
连接管理DataSource 统一接管需要自行接连接池
SQL 执行模板固定流程固定流程,但反射生成 SQL
结果映射RowMapper 回调,手写取值注解加反射自动赋值
异常处理转换成 DataAccessException直接抛 Exception
条件构造手写 SQL,参数数组QueryRule 链式 DSL

这张表就是后续改进的路线图:自研框架先把连接管理交给现成连接池,再把异常转成统一运行时异常,最后考虑缓存 Field 和 SQL,性能就能接近成熟框架。

从那以后,我每封装一层数据访问中间件,都会强制走一遍“接口先行、条件 DSL、参数绑定、统一返回、连接池接管”这套检查清单,尤其是 SQL 拼接那一步,宁可多写一个 QueryRule,也不让业务代码堆 if 拼字符串。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进