简介:一份面向Spring开发者与Java后端学习者的PDF课件,围绕“基于Spring JDBC手写定制ORM框架”展开,帮助读者彻底理解JDBC底层操作原理,并掌握从零搭建自定义ORM的核心逻辑,为后续学习MyBatis等框架奠定基础。课件共1个PDF文件,压缩包大小约940KB,内容篇幅紧凑、重点突出,适合有一定Java基础并希望深入数据持久层实现的读者。该资源目前已有178人学习下载。课件从传统JDBC六步操作讲起,逐步分析如何抽取ResultSet映射逻辑、封装mapperRow方法,并结合Spring JdbcTemplate的设计思想进行二次开发,覆盖事务管理、参数绑定、结果集映射、批处理与缓存策略等关键知识点。内容预览节选展示了典型JDBC代码优化前后的对比,直观呈现重复代码的重构路径。学习后可清晰了解ORM框架的整体实现思路,提升数据库操作代码的复用性与可维护性。
1. 手写定制 ORM:为什么放着 MyBatis 不用,偏要自己造一个轮子
先讲一个真实的翻车场景:某跨平台系统从老版本升级,核心表二十多个,每个实体类配一个 XML Mapper,一个查询动辄上百行 SQL 和一堆<resultMap>标签。某开发者为了加一个字段,漏了<resultMap>里的一行映射,线上查出来全是 null,排查了三个小时才发现是映射字段没同步。这种问题在 MyBatis 里极其常见——不是框架不好,而是 XML 和实体类之间没有强校验,写错了只有运行时才暴露。基于 Spring JDBC 手写定制自己的 ORM 框架,解决的就是这一类痛点:把结果集到对象的映射自动化、把单表增删改查的 SQL 生成代码化,同时保留手写 SQL 的透明度和管控能力。适合谁?被 XML Mapper 维护成本折磨的 Java 后端开发者,想理解 ORM 底层原理的进阶学习者,以及对 SQL 生成、反射机制、Spring JdbcTemplate 二次封装有定制需求的团队。这个方向不是造一个 MyBatis 替代品,而是用最小的代价,把重复劳动交给代码,把 SQL 掌控留在手里。
2. 定制 ORM 的架构拆解:从 JdbcTemplate 到通用映射,三个抽象层缺一不可
自己写 ORM,第一件事不是写代码,而是想清楚要把哪些东西抽象出来。Spring JDBC 里的JdbcTemplate.query方法要求传一个RowMapper,每个实体类都要写一个 RowMapper 实现类,这段代码全是机械重复:rs.getLong("id")、rs.getString("name")……一个实体类写一次还能忍,几十个实体类就是纯体力劳动。定制 ORM 的核心,就是把这个 RowMapper 从具体类变成通用实现,让任何实体类都能通过反射自动完成结果集映射。
2.1 分层设计:注解定义映射规则,SqlBuilder 生成 SQL,ResultMapper 完成结果映射
常见做法是把整个框架拆成三个层:映射注解层、SQL 生成层、结果映射层。映射注解层负责告诉框架「这个类和哪张表对应、这个字段和哪个列对应」;SQL 生成层根据注解生成 insert、update、delete、selectById 等基础 SQL;结果映射层接收 ResultSet 元数据,利用反射把列值写入实体对象的属性。
核心分层(典型方案): 实体类(标注 @Table / @Id / @Column 注解) ↓ SqlBuilder(根据注解拼 SQL:INSERT / UPDATE / DELETE / SELECT) ↓ JdbcTemplate(执行 SQL、绑定参数、返回 ResultSet) ↓ ResultMapper(反射读取元数据,自动完成结果集 → 实体对象) ↓ 返回实体 / List<实体>这个分层的核心价值是职责单一:新增一个实体类时只需要写类本身和注解,不需要再写 SQL 和 RowMapper。框架从 Spring 容器里拿到JdbcTemplate,所有数据访问操作都委托给它,事务边界也直接复用 Spring 的事务管理——这是基于 Spring JDBC 定制 ORM 比純 JDBC 手写高出几个量级的原因,事务和连接管理都不用自己操心。运行期拿到实体类的 Class 对象,用反射读取注解、获取字段列表、调用 setter 写入值,三个层各司其职,互不耦合。
2.2 注解选型:为什么给实体类加 @Table / @Id / @Column 是最小可行方案
注解是映射规则的载体。完整 ORM 的注解可以很复杂,比如懒加载、级联查询、乐观锁版本号,但起步阶段三个注解足够。@Table标注表名,@Id标注主键字段(支持自增主键回填),@Column标注列名和字段的映射关系。
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface Table { String value(); } @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface Id { String value() default ""; } @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface Column { String value() default ""; }这里有个设计细节值得注意:@Id的value()默认值是空串而不是 String 类型默认的 null,因为注解属性不支持 null 默认值,需要用空串来判断「用户没有显式指定主键列名」的情况。如果@Id标注的字段名和数据库主键列名一致,就可以不写 value 值,框架内部自动用字段名作为列名。@Column同理,Java 字段名和数据库列名一致的场景不需要写 value,只有驼峰字段名和下滑线列名不一致时才显式指定。三个注解的语义边界非常清晰:类上只有 @Table,主键上只有 @Id,普通字段上只有 @Column。
2.3 SQL 生成策略:INSERT 和 UPDATE 必须动态排除 null 字段,用反射拼 SQL 有哪些坑
SQL 生成是这个框架里最容易出错的环节。以 INSERT 为例,如果实体类里十个字段只有五个被赋值,直接拼接十列的 INSERT 会把 null 写进数据库,破坏 NOT NULL 约束或者覆盖默认值。常见做法是动态判断字段值是否为 null,null 字段不参与生成 SQL。
public class InsertSqlBuilder<T> { public SqlWithParams build(T entity) { Class<?> clazz = entity.getClass(); Table table = clazz.getAnnotation(Table.class); if (table == null) { throw new MappingException("类 " + clazz.getName() + " 缺少 @Table 注解"); } List<String> columnsList = new ArrayList<>(); List<Object> paramsList = new ArrayList<>(); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); Column column = field.getAnnotation(Column.class); if (column == null) { continue; // 没有 @Column 注解的字段不参与持久化 } try { Object value = field.get(entity); if (value == null) { continue; // null 字段不插入,保持数据库默认值 } String columnName = column.value().isEmpty() ? field.getName() : column.value(); columnsList.add(columnName); paramsList.add(value); } catch (IllegalAccessException e) { throw new MappingException("读取字段失败: " + field.getName(), e); } } String sql = "INSERT INTO " + table.value() + " (" + String.join(", ", columnsList) + ") VALUES (" + String.join(", ", Collections.nCopies(columnsList.size(), "?")) + ")"; return new SqlWithParams(sql, paramsList.toArray()); } }常见的坑在getDeclaredFields()上:这个方法只返回当前类声明的字段,不包括父类字段。如果实体类继承了 BaseEntity(比如把 id 和 createTime 放在父类里),必须遍历父类获取字段。另一个坑是field.setAccessible(true)在 JDK 17 以下没问题,但 JDK 17 开启强封装后需要--add-opens参数,或者改用 Spring 的ReflectionUtils.makeAccessible(field),后者会自动处理模块访问权限。第三个坑是字段顺序问题:反射返回的字段顺序不保证和数据库建表顺序一致,INSERT 语句本身不依赖表结构顺序,但 UPDATE 和 SELECT 的列顺序如果依赖代码顺序,批量操作时容易出诡异问题——解决方案是每次拼 SQL 都以List<String>列表为准,而不是依赖反射顺序的稳定性。参数占位符统一用?,由 JdbcTemplate 负责参数绑定,避免手拼 SQL 的值注入风险。
3. 结果集映射的核心实现:用反射把 ResultSet 变成实体对象,通用 ResultMapper 这样写
映射层是整个 ORM 的「心脏」。JdbcTemplate 提供了RowMapper接口,我们只需要实现一个通用的RowMapper<T>,让它对任何实体类都能工作。核心步骤:获取实体类的所有字段,拿到 ResultSetMetaData 里每一列的列名,建立「列名 → 字段名」的映射关系,然后反射调用 setter 写值。这里有三个必须处理的细节:下划线转驼峰、类型转换、字段顺序。
3.1 通用 RowMapper 实现:根据 ResultSetMetaData 动态匹配列名和字段名
列名和字段名的匹配是映射的核心逻辑。数据库列名通常是user_name这种下划线风格,Java 字段名通常是userName这种驼峰风格,所以默认策略是:先尝试完全一致的列名,再尝试下划线转驼峰后的列名。先写一个工具方法做下划线转驼峰:
public class ColumnNameUtils { public static String toCamelCase(String columnName) { StringBuilder result = new StringBuilder(); boolean upper = false; for (char c : columnName.toCharArray()) { if (c == '_') { upper = true; } else { result.append(upper ? Character.toUpperCase(c) : c); upper = false; } } return result.toString(); } }这个工具虽然短,但边界场景要考虑清楚:全大写的列名USER_NAME会变成uSERNAME,不是我们想要的驼峰。常见做法是先将列名转小写再做驼峰转换,否则遇到 Oracle 返回全大写列名时映射直接失败。有了这个工具方法,通用 RowMapper 就能把列名统一转换成驼峰,再和字段名做匹配。匹配失败时不要直接抛异常提示数据库列不存在,而应该先检查字段上有没有 @Column 注解——因为注解显式指定的列名优先级最高,只有注解没写 value 时才做转换匹配。
public class GenericRowMapper<T> implements RowMapper<T> { private final Class<T> clazz; public GenericRowMapper(Class<T> clazz) { this.clazz = clazz; } @Override public T mapRow(ResultSet rs, int rowNum) throws SQLException { try { T entity = clazz.getDeclaredConstructor().newInstance(); ResultSetMetaData metaData = rs.getMetaData(); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); Id id = field.getAnnotation(Id.class); Column column = field.getAnnotation(Column.class); String columnName = null; if (column != null && !column.value().isEmpty()) { columnName = column.value(); } else if (id != null && !id.value().isEmpty()) { columnName = id.value(); } else { columnName = ColumnNameUtils.toCamelCase(metaData.getColumnLabel(rowNum)); } for (int i = 1; i <= metaData.getColumnCount(); i++) { String label = metaData.getColumnLabel(i); if (label.equalsIgnoreCase(columnName)) { Object value = rs.getObject(i); if (value != null) { field.set(entity, convertValue(value, field.getType())); } break; } } } return entity; } catch (Exception e) { throw new SQLException("结果集映射失败: " + e.getMessage(), e); } } }这段代码里有一个小玄学问题:newInstance()对无参构造是要求的,实体类如果没有无参构造会直接报错。这是所有基于反射的 ORM 的硬性约束,实体类必须提供无参构造。rs.getObject(i)获取的值是 JDBC 驱动返回的原始类型,比如数据库的DECIMAL返回的是BigDecimal,数据库的DATETIME返回的是Timestamp,直接field.set()时如果字段类型是LocalDateTime会抛 IllegalArgumentException。这就是下一步的类型转换要做的事。
3.2 类型转换:数据库类型和 Java 类型不一致时,如何处理 DATE、TIMESTAMP、BigDecimal
类型转换是映射层最容易翻车的地方。数据库TIMESTAMP驱动返回java.sql.Timestamp,实体字段如果是LocalDateTime,反射直接 set 会抛IllegalArgumentException。常见做法是在写入字段前做一层类型适配:
private Object convertValue(Object value, Class<?> targetType) { if (targetType.isInstance(value)) { return value; } if (targetType == LocalDateTime.class && value instanceof Timestamp) { return ((Timestamp) value).toLocalDateTime(); } if (targetType == LocalDate.class && value instanceof Date) { return ((Date) value).toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); } if (targetType == LocalTime.class && value instanceof Time) { return ((Time) value).toLocalTime(); } if (targetType == Integer.class && value instanceof Number) { return ((Number) value).intValue(); } if (targetType == Long.class && value instanceof Number) { return ((Number) value).longValue(); } if (targetType == String.class) { return value.toString(); } return value; }这里有个取舍问题:把类型转换写死在 RowMapper 里,框架轻量但对 Java 8 时间类型以外的类型覆盖不全。实际项目中如果需要支持更复杂的类型(比如Instant、BigInteger、byte[]),延续这个 switch 思路继续加分支即可。还有一个参数绑定方向的类型问题:INSERT 时实体字段是LocalDateTime,但 JDBC 驱动不认识这个类型,绑定参数时也需要做反向转换。Spring 的 JdbcTemplate 本身对 Java 8 时间类型做了支持,Spring 5 之后setParameterValue内部有类型转换,所以绑定方向一般不用自己处理。如果项目用的是 Spring 4 或自定义 JdbcTemplate 封装,反向转换也得自己写。类型转换逻辑放在映射层还有一个好处:SQL 执行层不关心类型,SQL 生成只管取字段值、绑定参数,职责边界清晰。
3.3 字段缓存设计:反射性能差是原罪,单次反射拿到 Field 列表缓存起来,避免每行都反射
第一次跑框架做百万行数据查询,如果每行数据都getDeclaredFields()一次,性能会很难看。反射获取 Field 数组和读取注解都是耗时操作,应该对同一个 Class 只做一次,缓存映射元数据。常见做法是定义一个元数据缓存 Map:
public class EntityMetaCache { // key: Class, value: 实体元数据 private static final Map<Class<?>, EntityMeta> CACHE = new ConcurrentHashMap<>(); public static EntityMeta get(Class<?> clazz) { return CACHE.computeIfAbsent(clazz, EntityMetaCache::buildMeta); } private static EntityMeta buildMeta(Class<?> clazz) { List<FieldMeta> fields = new ArrayList<>(); Field[] declaredFields = clazz.getDeclaredFields(); for (Field field : declaredFields) { field.setAccessible(true); Column column = field.getAnnotation(Column.class); Id id = field.getAnnotation(Id.class); if (column == null && id == null) { continue; // 非持久化字段 } String columnName = (column != null && !column.value().isEmpty()) ? column.value() : (id != null && !id.value().isEmpty()) ? id.value() : field.getName(); fields.add(new FieldMeta(field, columnName)); } return new EntityMeta(fields); } }注意这里用了ConcurrentHashMap,因为框架在 Spring 容器里通常以单例 Bean 暴露,多线程环境下元数据初始化同时发生时,computeIfAbsent能保证一个 Class 只 build 一次。这是 ConcurrentHashMap 的原子操作能力,普通 HashMap 在这里会有并发问题。构建好的FieldMeta列表缓存了字段对象和列名的映射关系,查询批量数据时每行只做field.get(entity)的取值或field.set(entity, value)的写值,不再重复解析注解。实际压测下来,缓存重构性能能提升一个大数量级——只写过一次反射代码的人会深有体会。还有一个小优化点:setAccessible(true)在字段缓存构建时一次完成,后续 set/get 不再检查访问权限,也省了一部分开销。
4. 用 Spring JDBC 驱动:写一个 BaseRepository 把增删改查变成一行调用,同时接住 Spring 事务
有了注解、SQL 生成器、通用 RowMapper 和元数据缓存,接下来就是把它们组装进一个可用的数据访问入口。常见做法是提供一个BaseRepository<T, ID>抽象类,子类继承后自动获得findById、save、updateById、deleteById、findAll等基础方法,特定联表查询在子类里用JdbcTemplate手写 SQL。这一层把 Spring JDBC 的能力完整暴露出来,同时把样板代码收敛到基类。
4.1 组装 BaseRepository:注入 JdbcTemplate,提供 findById / save / update / delete 四个核心方法
public abstract class BaseRepository<T, ID> { @Autowired protected JdbcTemplate jdbcTemplate; private final Class<T> entityClass; private final GenericRowMapper<T> rowMapper; @SuppressWarnings("unchecked") protected BaseRepository() { // 通过泛型反射获取实体类型 ParameterizedType type = (ParameterizedType) getClass().getGenericSuperclass(); this.entityClass = (Class<T>) type.getActualTypeArguments()[0]; this.rowMapper = new GenericRowMapper<>(entityClass); } public Optional<T> findById(ID id) { EntityMeta meta = EntityMetaCache.get(entityClass); String tableName = meta.getTableName(); String idColumn = meta.getIdColumnName(); String sql = "SELECT * FROM " + tableName + " WHERE " + idColumn + " = ?"; List<T> list = jdbcTemplate.query(sql, rowMapper, id); return list.stream().findFirst(); } public T save(T entity) { InsertSqlBuilder<T> builder = new InsertSqlBuilder<>(); SqlWithParams sqlWithParams = builder.build(entity); KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(connection -> { PreparedStatement ps = connection.prepareStatement(sqlWithParams.getSql(), Statement.RETURN_GENERATED_KEYS); Object[] params = sqlWithParams.getParams(); for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } return ps; }, keyHolder); // 主键回填 if (keyHolder.getKey() != null) { FieldMeta idField = EntityMetaCache.get(entityClass).getIdFieldMeta(); idField.set(entity, keyHolder.getKey()); } return entity; } public int updateById(T entity) { UpdateSqlBuilder<T> builder = new UpdateSqlBuilder<>(); SqlWithParams sqlWithParams = builder.build(entity); return jdbcTemplate.update(sqlWithParams.getSql(), sqlWithParams.getParams()); } public int deleteById(ID id) { EntityMeta meta = EntityMetaCache.get(entityClass); String sql = "DELETE FROM " + meta.getTableName() + " WHERE " + meta.getIdColumnName() + " = ?"; return jdbcTemplate.update(sql, id); } }几个重要设计决策在这里体现得很明显。第一,子类不需要写构造方法,基类的泛型反射自动拿到实体类型,子类只要写public class UserRepository extends BaseRepository<User, Long> {}就能用。第二,save方法用GeneratedKeyHolder回填自增主键,主键字段通过元数据缓存拿到 FieldMeta 对象后直接写入原实体,调用方在save(user)之后user.getId()就有值了。第三,findById返回Optional<T>,强迫调用方处理空值——这是和原生 JdbcTemplate 最舒服的差异点,不用if (user != null)到处判空。还有一点,所有方法都走 Spring 容器管理的JdbcTemplate实例,所以事务传播、连接回收、异常翻译全部由 Spring 托管,在 Service 层加@Transactional注解即可让多个操作共享一个数据库连接。这也意味着这个定制 ORM 和 Spring 的整合不是「适配层」,而是天然融合。
4.2 条件查询怎么支持:SqlBuilder 加 where 条件,参数化查询禁止字符串拼接
基础方法只能支撑主键操作,实际业务里findByUserIdAndStatus这类场景需求更大。有两种实现方式:一种是为命名方法生成 SQL,这需要做方法名约定解析,复杂度偏高;另一种是传入条件对象的通用查询,更符合手写 ORM 的渐进式开发节奏。常见做法是引入一个QueryCondition参数对象,内部维护字段名和值的列表:
public class QueryCriteria { private final List<String> conditions = new ArrayList<>(); private final List<Object> params = new ArrayList<>(); public QueryCriteria eq(String columnName, Object value) { conditions.add(columnName + " = ?"); params.add(value); return this; } public QueryCriteria like(String columnName, Object value) { conditions.add(columnName + " LIKE CONCAT('%', ?, '%')"); params.add(value); return this; } public QueryCriteria in(String columnName, Object... values) { conditions.add(columnName + " IN (" + String.join(", ", Collections.nCopies(values.length, "?")) + ")"); params.addAll(Arrays.asList(values)); return this; } public String getSql() { return conditions.isEmpty() ? "" : " WHERE " + String.join(" AND ", conditions); } public Object[] getParams() { return params.toArray(); } }这段代码的核心是「链式调用 + 参数列表分离」。SQL 片段里全部使用?占位符,任何值都不直接拼进 SQL 字符串,这是防注入的生命线。你可能已经注意到,in方法里Collections.nCopies(values.length, "?")用来生成和参数数量一致的占位符,因为 IN 子句的参数个数是动态的,SQL 必须先知道有几个问号才能动态绑定。LIKE写在 SQL 这边而不是在 Java 里拼好%value%再传进去,同样是为了避免把用户输入拼在查询参数里产生注入风险。条件查询写好后,BaseRepository 里加一个findByCriteria(QueryCriteria criteria)方法即可:
public List<T> findByCriteria(QueryCriteria criteria) { EntityMeta meta = EntityMetaCache.get(entityClass); String sql = "SELECT * FROM " + meta.getTableName() + criteria.getSql(); return jdbcTemplate.query(sql, rowMapper, criteria.getParams()); }用这个 API 的体验接近于 MyBatis 的QueryWrapper,但完全是自己控制的代码,想加gt、lt、orderBy、limit方法,只需要往 QueryCriteria 里继续加方法,不改底层结构。
4.3 分页查询的方言问题:MySQL 的 LIMIT 和 Oracle 的 ROWNUM 怎么分别适配到同一套接口
分页是 ORM 绕不开的另一个硬茬。MySQL 分页是LIMIT offset, size,Oracle 12c 之前用的 ROWNUM 外层嵌套写法,SQL Server 用的是 OFFSET FETCH 语法。不同数据库的方言差异决定了分页 SQL 必须抽象成一个策略接口:
public interface PageDialect { String buildPageSql(String originalSql, int offset, int limit); } public class MySqlPageDialect implements PageDialect { @Override public String buildPageSql(String originalSql, int offset, int limit) { return originalSql + " LIMIT " + offset + ", " + limit; } } public class OraclePageDialect implements PageDialect { @Override public String buildPageSql(String originalSql, int offset, int limit) { return "SELECT * FROM (SELECT TMP.*, ROWNUM ROW_ID FROM (" + originalSql + ") TMP WHERE ROWNUM <= " + (offset + limit) + ") WHERE ROW_ID > " + offset; } }这个接口上还可以继续加PostgresPageDialect(用 LIMIT ? OFFSET ? 语法)和SqlServerPageDialect,扩展方式是一样的。分页策略确定了,分页查询方法需要额外执行一条 count 查询来获取总记录数:
public PageResult<T> findPage(QueryCriteria criteria, int pageNum, int pageSize) { EntityMeta meta = EntityMetaCache.get(entityClass); String baseSql = "SELECT * FROM " + meta.getTableName() + criteria.getSql(); // count 查询 String countSql = "SELECT COUNT(*) FROM " + meta.getTableName() + criteria.getSql(); Long total = jdbcTemplate.queryForObject(countSql, Long.class, criteria.getParams()); // page 查询 int offset = (pageNum - 1) * pageSize; String pageSql = pageDialect.buildPageSql(baseSql, offset, pageSize); List<T> records = jdbcTemplate.query(pageSql, rowMapper, criteria.getParams()); return new PageResult<>(total, records, pageNum, pageSize); }这里有一个容易踩的细节:criteria.getParams()在 count 查询和 page 查询中各用了一次,但getParams()返回的是新数组而不是同一个数组对象,所以两边各自传给 JdbcTemplate 没有问题——如果你在 QueryCriteria 里缓存了一个 param 数组并且两次复用同一个数组对象,count 和 page 就会共享同一份数据,这倒不会出错,但要注意别在后面追加参数时破坏前一次的绑定。分页的最优策略是先写数据库无关的普通查询,再根据当前数据源方言做 SQL 包装,所以方言策略对象应该是全局单例,可以通过@Value("${spring.datasource.driver-class-name}")判断驱动类名来自动加载对应方言。
5. 必踩的 6 个坑:字段缓存失效、SQL 注入隐患、事务失效、类型匹配错乱、父子类字段遗漏、空值处理
任何框架在真实业务里都会暴露出各种边界问题,手写 ORM 尤其如此。这些坑不是偶发的玄学,而是反射机制、JDBC 规范、Spring 代理机制综合作用的必然结果。下面按踩坑频率排序,每条都按「现象 → 原因 → 解决」展开。
5.1 实体类继承父类字段时,查询结果永远少几个字段
现象是列表查询不报错,但BaseEntity里的createTime、updateTime永远查不出来。原因是getDeclaredFields()只返回当前类声明的字段,父类字段根本不在数组里,反射匹配时自然跳过。解决方式是递归遍历父类,把父类字段合并进元数据缓存:
private static List<Field> getAllFields(Class<?> clazz) { List<Field> fields = new ArrayList<>(); while (clazz != null && clazz != Object.class) { fields.addAll(Arrays.asList(clazz.getDeclaredFields())); clazz = clazz.getSuperclass(); } return fields; }这个工具方法在构建元数据缓存时使用,替换掉原来只取当前类字段的逻辑。注意循环的终止条件是clazz != Object.class,否则会尝试把 Object 类也扫描一遍。这是手写 ORM 第一坑,基本只要实体类做了继承就必踩。
5.2 ORDER BY 和排序字段被拼进 SQL 字符串导致注入
现象是排序字段是从前端传的,orderBy参数直接拼进 SQL 出现奇怪的报错或数据泄漏。原因是排序字段无法预编译,它只能拼在 SQL 文本里,这就给了注入可乘之机。解决方式是白名单机制:把排序字段限制在实体类的字段列表中,不是白名单里的字段直接忽略或抛异常。
public QueryCriteria orderBy(String columnName, boolean asc) { // 校验字段合法性,仅允许实体类的映射列名 Set<String> validColumns = EntityMetaCache.get(entityClass).getAllColumnNames(); if (!validColumns.contains(columnName)) { throw new IllegalArgumentException("非法排序字段: " + columnName); } conditions.add(" ORDER BY " + columnName + (asc ? " ASC" : " DESC")); return this; }这段代码的实际意义是:ORDER BY 的列名不是参数而是 SQL 片段,所以必须走白名单。单靠安全编码习惯不够,把校验写进框架层才稳妥。
5.3 save 方法执行后实体主键没回填,后续逻辑拿到的主键是 null
现象是user.getId()返回 null,但数据库里自增主键确实生成了。原因是keyHolder.getKey()取到的是Number,但框架里字段写入时没有做类型转换,如果主键字段是Long,直接 set Number 子类对象时反射会抛类型转换异常,异常被上层吞掉后导致回填静默失败。解决方式是复用之前写好的convertValue方法,把keyHolder.getKey()转成字段类型再写入:
Object value = keyHolder.getKey(); if (idField.getType() == Long.class) { value = ((Number) value).longValue(); } else if (idField.getType() == Integer.class) { value = ((Number) value).intValue(); } idField.set(entity, value);这个小代码块里藏着最容易被忽略的类型细节:MySQL 驱动返回的自增主键是 Long 类型,即使数据库主键定义的是 INT,JDBC 默认也返回 Long。如果实体主键字段是 Integer,直接赋值会类型不匹配,必须在框架层做一次转换。
5.4 字段名匹配时把user_id转成了userid,完全匹配不上
现象是@Column注解都没写,数据库列user_id、实体字段userId,反射映射后字段全是 null。原因是自己写的下划线转驼峰工具没有处理全大写列名,MySQL 返回的列标签user_id转成userId没问题,但 Oracle 返回的USER_ID或某些工具直接返回userid时,一转换就全体阵亡。解决方式是在转换前统一转小写:
public static String toCamelCase(String columnName) { String normalized = columnName.toLowerCase(Locale.ROOT); // 其余逻辑不变,处理 normalized }5.5@Transactional注解不起作用,多个数据库操作各自提交,半成功半失败
现象是 Service 方法加了@Transactional,但第二个操作失败后第一个操作没有回滚。原因是@Transactional是 Spring 的声明式事务,只有调用方通过代理对象进入方法时拦截器才生效。如果手写 ORM 的某个方法直接在同类里被另一个方法调用,代理不会触发,事务失效。验证和排查方式是看 Spring 启动日志里是否出现了事务拦截器的加载信息,或者在方法入口打日志确认当前对象是代理类还是原生类。解决方式很简单:把数据访问操作拆到单独的 Service Bean 里,跨 Bean 调用让 Spring 代理接管;或者在整个手写 ORM 的方法内部使用TransactionTemplate手动控制事务边界。
@Transactional(rollbackFor = Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { accountRepository.updateById(fromAccount.decrease(amount)); accountRepository.updateById(toAccount.increase(amount)); // 如果第二个 update 抛异常,第一个会自动回滚 }rollbackFor 是另一个容易踩的点:Spring 默认只回滚 RuntimeException 和 Error,如果业务方法抛出受检异常(比如Exception类型),事务不会回滚。很多项目就是因为没写rollbackFor = Exception.class,事务包了个寂寞。
5.6 批量查询性能低到不可接受,单条反射调用成了瓶颈
现象是十万行数据查询耗时几十秒,profile 后看到大量时间花在Field.set上。原因是每行每个字段都走一遍反射 API,JVM 无法内联优化。解决方式和 3.3 的元数据缓存是成套的:确保已经构建了FieldMeta缓存,避免重复解析注解;如果还想进一步压榨性能,在FieldMeta里预先缓存setter方法的Method对象,调用时直接method.invoke(entity, value)。更极端的做法是运行时用 Javassist 生成字节码存取器,但这一步对大多数项目没有必要,缓存后的反射性能已经足够日常使用。
6. 把框架写得更顺手:批量操作与自动填充扩展,以及用测试用例验证框架边界
走到这一步,基础的 ORM 已经能跑通了,但它还很「裸」——没有批量插入、没有审计字段自动填充、没有验证边界条件的测试。最后一章把这些补齐,让你的定制 ORM 真正变成敢接业务的框架。
6.1 批量插入的三种思路:循环单条、rewriteBatchedStatements 和 batchUpdate
批量插入是性能重灾区。常见思路有三种,按性能从低到高排序:第一种是 for 循环调用save,每一行一条 SQL,性能最差但代码最省;第二种是 JDBC 驱动开启批量重写——MySQL 连接串加rewriteBatchedStatements=true,驱动会缓存多条 insert 语句并合并成一次网络往返;第三种是走 JdbcTemplate 的batchUpdate,用同一个 SQL 绑定多组参数,框架直接广播执行。最省心的做法是第三种:
public int[] batchInsert(List<T> entities) { if (entities.isEmpty()) { return new int[0]; } T first = entities.get(0); SqlWithParams firstSql = new InsertSqlBuilder<T>().build(first); String sql = firstSql.getSql(); List<Object[]> batchArgs = new ArrayList<>(entities.size()); InsertSqlBuilder<T> builder = new InsertSqlBuilder<>(); for (T entity : entities) { SqlWithParams item = builder.build(entity); batchArgs.add(item.getParams()); } return jdbcTemplate.batchUpdate(sql, batchArgs); }注意这里有个前提:InsertSqlBuilder.build之前我们设计了跳过 null 字段,所以如果两个实体的 null 字段不一致,生成的 SQL 列名和参数数量会不同,批次就不是同一条 SQL 了。核心逻辑里 batch 必须保证所有实体的 SQL 结构一致,否则要改成按 SQL 分组再批量执行。batchUpdate的返回值是 int 数组,对应每条 SQL 的受影响行数,日志里可以用它核对是否有 0 更新。批量插入在自增主键回填上也有个限制:batchUpdate 不会回填每条记录的主键,业务上需要主键的话要在一开始就生成 UUID 或分布式 ID 手动写入。
6.2 审计字段自动填充:createTime / updateTime 不写死,交给框架统一维护
很多项目里每张表都有create_time、update_time,每次 save 之前手动setCreateTime(new Date())既不优雅又容易漏。给框架加一个自动填充扩展:在@Column注解上增加标记,或者在实体字段上增加专门的审计注解,框架在 save 和 update 时自动赋值。常规实现是定义两个注解,然后在 BaseRepository 里处理:
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface CreateTime { } @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface UpdateTime { }在 BaseRepository 的save方法里先扫描实体类字段,如果发现 @CreateTime 标注,就写入当前时间;在updateById方法里发现 @UpdateTime 标注,同样写入当前时间。这样业务代码里一行都不用写,时间字段自动维护,并且不会因为某个字段没赋值导致 null 覆盖数据库默认值。这里有一个设计决策:时间类型在字段里用 LocalDateTime 还是 Date?如果实体字段定义得比较现代,用 LocalDateTime,框架赋值时直接LocalDateTime.now();数据库端建表时时间类型用datetime,两条链路都顺畅。
6.3 怎么证明框架靠得住:用集成测试跑真实数据库,覆盖映射、主键回填、事务回滚、批量插入
框架写得好不好,不能靠「应该没问题」,要跑集成测试来验证。用 Spring Boot 的测试环境连接本地数据库,针对几个关键边界点各写一个用例:
@SpringBootTest class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test void saveShouldBackFillIdAndCreateTime() { User user = new User(); user.setUserName("test"); userRepository.save(user); assertNotNull(user.getId()); assertNotNull(user.getCreateTime()); } @Test void findByIdShouldMapAllFields() { User user = new User(); user.setUserName("mapping_test"); user.setAge(25); userRepository.save(user); Optional<User> loaded = userRepository.findById(user.getId()); assertTrue(loaded.isPresent()); assertEquals("mapping_test", loaded.get().getUserName()); assertEquals(25, loaded.get().getAge()); } @Test @Transactional void updateShouldOnlyChangeNonNullFields() { User user = new User(); user.setUserName("before"); user.setAge(30); userRepository.save(user); user.setAge(31); userRepository.updateById(user); Optional<User> reloaded = userRepository.findById(user.getId()); assertEquals("before", reloaded.get().getUserName()); // 未赋值的字段不能被覆盖 assertEquals(31, reloaded.get().getAge()); } }这些测试用例覆盖了框架的四个核心能力:主键回填、字段完整映射、null 字段不参与更新、事务回滚生效。真实项目里建议再用 H2 内存数据库做一次快速冒烟测试,针对 Oracle 方言和 MySQL 方言各跑一轮分页查询用例,确认 PageDialect 的选择逻辑和 SQL 拼装没有差异。测试环境跑完,把这个定制 ORM 接入业务模块,跑一遍真实的增删改查和分页链路,观察 SQL 日志确认没有全表扫描或缺失条件,就可以逐步把老代码迁移过来。框架这东西,宁可让测试先炸一遍,也别让线上第一跑就送个慢 SQL 大礼包。
最后说一个自己写 ORM 之后的习惯变化:我再也没在业务代码里见过if (rs.next()) { entity.setXXX(rs.getString("xxx")); }这种运算,手写一次框架会把 JDBC 的抽象层级看得清清楚楚。定制 ORM 不是高深的事情,它就是把 JdbcTemplate 的通用性往前推了一层。如果你正在被 XML Mapper 和重复的 RowMapper 折磨,花一两个晚上把这个精简版跑通,值回票价。希望帮到你。
本文还有配套的精品资源,点击获取