尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
手写定制ORM:基于Spring JDBC与反射的SQL生成与结果映射实践
简介一份面向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 生成 SQLResultMapper 完成结果映射常见做法是把整个框架拆成三个层映射注解层、SQL 生成层、结果映射层。映射注解层负责告诉框架「这个类和哪张表对应、这个字段和哪个列对应」SQL 生成层根据注解生成 insert、update、delete、selectById 等基础 SQL结果映射层接收 ResultSet 元数据利用反射把列值写入实体对象的属性。核心分层典型方案 实体类标注 Table / Id / Column 注解 ↓ SqlBuilder根据注解拼 SQLINSERT / 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 约束或者覆盖默认值。常见做法是动态判断字段值是否为 nullnull 字段不参与生成 SQL。public class InsertSqlBuilderT { public SqlWithParams build(T entity) { Class? clazz entity.getClass(); Table table clazz.getAnnotation(Table.class); if (table null) { throw new MappingException(类 clazz.getName() 缺少 Table 注解); } ListString columnsList new ArrayList(); ListObject 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 都以ListString列表为准而不是依赖反射顺序的稳定性。参数占位符统一用?由 JdbcTemplate 负责参数绑定避免手拼 SQL 的值注入风险。3. 结果集映射的核心实现用反射把 ResultSet 变成实体对象通用 ResultMapper 这样写映射层是整个 ORM 的「心脏」。JdbcTemplate 提供了RowMapper接口我们只需要实现一个通用的RowMapperT让它对任何实体类都能工作。核心步骤获取实体类的所有字段拿到 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 GenericRowMapperT implements RowMapperT { private final ClassT clazz; public GenericRowMapper(ClassT 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 只做一次缓存映射元数据。常见做法是定义一个元数据缓存 Mappublic class EntityMetaCache { // key: Class, value: 实体元数据 private static final MapClass?, EntityMeta CACHE new ConcurrentHashMap(); public static EntityMeta get(Class? clazz) { return CACHE.computeIfAbsent(clazz, EntityMetaCache::buildMeta); } private static EntityMeta buildMeta(Class? clazz) { ListFieldMeta 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 和元数据缓存接下来就是把它们组装进一个可用的数据访问入口。常见做法是提供一个BaseRepositoryT, ID抽象类子类继承后自动获得findById、save、updateById、deleteById、findAll等基础方法特定联表查询在子类里用JdbcTemplate手写 SQL。这一层把 Spring JDBC 的能力完整暴露出来同时把样板代码收敛到基类。4.1 组装 BaseRepository注入 JdbcTemplate提供 findById / save / update / delete 四个核心方法public abstract class BaseRepositoryT, ID { Autowired protected JdbcTemplate jdbcTemplate; private final ClassT entityClass; private final GenericRowMapperT rowMapper; SuppressWarnings(unchecked) protected BaseRepository() { // 通过泛型反射获取实体类型 ParameterizedType type (ParameterizedType) getClass().getGenericSuperclass(); this.entityClass (ClassT) type.getActualTypeArguments()[0]; this.rowMapper new GenericRowMapper(entityClass); } public OptionalT findById(ID id) { EntityMeta meta EntityMetaCache.get(entityClass); String tableName meta.getTableName(); String idColumn meta.getIdColumnName(); String sql SELECT * FROM tableName WHERE idColumn ?; ListT list jdbcTemplate.query(sql, rowMapper, id); return list.stream().findFirst(); } public T save(T entity) { InsertSqlBuilderT 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) { UpdateSqlBuilderT 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 BaseRepositoryUser, Long {}就能用。第二save方法用GeneratedKeyHolder回填自增主键主键字段通过元数据缓存拿到 FieldMeta 对象后直接写入原实体调用方在save(user)之后user.getId()就有值了。第三findById返回OptionalT强迫调用方处理空值——这是和原生 JdbcTemplate 最舒服的差异点不用if (user ! null)到处判空。还有一点所有方法都走 Spring 容器管理的JdbcTemplate实例所以事务传播、连接回收、异常翻译全部由 Spring 托管在 Service 层加Transactional注解即可让多个操作共享一个数据库连接。这也意味着这个定制 ORM 和 Spring 的整合不是「适配层」而是天然融合。4.2 条件查询怎么支持SqlBuilder 加 where 条件参数化查询禁止字符串拼接基础方法只能支撑主键操作实际业务里findByUserIdAndStatus这类场景需求更大。有两种实现方式一种是为命名方法生成 SQL这需要做方法名约定解析复杂度偏高另一种是传入条件对象的通用查询更符合手写 ORM 的渐进式开发节奏。常见做法是引入一个QueryCondition参数对象内部维护字段名和值的列表public class QueryCriteria { private final ListString conditions new ArrayList(); private final ListObject 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 ListT 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, sizeOracle 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 PageResultT 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); ListT 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 ListField getAllFields(Class? clazz) { ListField 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) { // 校验字段合法性仅允许实体类的映射列名 SetString 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 类型即使数据库主键定义的是 INTJDBC 默认也返回 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.5Transactional注解不起作用多个数据库操作各自提交半成功半失败现象是 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上。原因是每行每个字段都走一遍反射 APIJVM 无法内联优化。解决方式和 3.3 的元数据缓存是成套的确保已经构建了FieldMeta缓存避免重复解析注解如果还想进一步压榨性能在FieldMeta里预先缓存setter方法的Method对象调用时直接method.invoke(entity, value)。更极端的做法是运行时用 Javassist 生成字节码存取器但这一步对大多数项目没有必要缓存后的反射性能已经足够日常使用。6. 把框架写得更顺手批量操作与自动填充扩展以及用测试用例验证框架边界走到这一步基础的 ORM 已经能跑通了但它还很「裸」——没有批量插入、没有审计字段自动填充、没有验证边界条件的测试。最后一章把这些补齐让你的定制 ORM 真正变成敢接业务的框架。6.1 批量插入的三种思路循环单条、rewriteBatchedStatements 和 batchUpdate批量插入是性能重灾区。常见思路有三种按性能从低到高排序第一种是 for 循环调用save每一行一条 SQL性能最差但代码最省第二种是 JDBC 驱动开启批量重写——MySQL 连接串加rewriteBatchedStatementstrue驱动会缓存多条 insert 语句并合并成一次网络往返第三种是走 JdbcTemplate 的batchUpdate用同一个 SQL 绑定多组参数框架直接广播执行。最省心的做法是第三种public int[] batchInsert(ListT entities) { if (entities.isEmpty()) { return new int[0]; } T first entities.get(0); SqlWithParams firstSql new InsertSqlBuilderT().build(first); String sql firstSql.getSql(); ListObject[] batchArgs new ArrayList(entities.size()); InsertSqlBuilderT 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); OptionalUser 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); OptionalUser 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 折磨花一两个晚上把这个精简版跑通值回票价。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

解决.NET MAUI中‘Unable to find main thread‘的线程问题

解决.NET MAUI中‘Unable to find main thread‘的线程问题

1. 问题现象与背景分析最近在Windows平台使用.NET MAUI开发跨平台应用时,遇到了一个棘手的运行时错误:"System.InvalidOperationException: Unable to find main thread"。这个异常通常发生在尝试访问UI线程时,框架无法正确识别应用…

📅 2026/10/15 7:58:02
GCN不是图上的CNN:从原理到工业落地的深度解析

GCN不是图上的CNN:从原理到工业落地的深度解析

1. 为什么GCN不是“把CNN搬到图上”那么简单?刚接触图卷积神经网络(GCN)时,我下意识地把它理解成“图上的CNN”——毕竟名字里都带“卷积”,又都是处理结构化数据的深度模型。但真正动手复现第一版GCN、调试邻居聚合结…

📅 2026/10/15 7:58:02
DeepSeek从入门到精通:模型选型、Prompt设计、API与本地部署避坑

DeepSeek从入门到精通:模型选型、Prompt设计、API与本地部署避坑

简介:这份《DeepSeek:从入门到精通》由清华大学新闻与传播学院新媒体研究中心团队出品,面向大模型爱好者、开发者及企业AI应用人员。教程系统梳理DeepSeek是什么、能做什么以及如何使用,重点对比推理模型与通用模型在数学推导、代…

📅 2026/10/15 7:53:02
MORE NEWS

更多资讯

📰

C++反射机制实现与应用深度解析

1. 反射机制的本质与C实现困境反射(Reflection)在编程领域指的是程序在运行时能够检视、修改自身结构和行为的能力。想象你面前有一面魔镜,不仅能照出你的外表,还能告诉你今天穿了几层衣服、每件衣服的材质、口袋里装了什么——这…

📰

Stable Diffusion底层原理:CLIP、潜在扩散与UNet协同机制解析

1. 这不是“调用API”——Stable Diffusion 的本质是一场精密的数学逆向工程很多人第一次接触 Stable Diffusion,以为它是个黑箱:输入文字,点击生成,等几秒,一张图就出来了。我刚接触时也这么想,直到我把它…

📰

算法竞赛中的概率期望与动态规划解题技巧

1. 题目背景与核心考察点解析P14968 "Hoping that one Day"是一道典型的算法竞赛题目,通常出现在ICPC、CCPC等程序设计竞赛中。这类题目往往需要选手通过数学建模和算法设计解决特定场景下的计算问题。从标题风格判断,这很可能是一道关于概率期…

📰

掌握技能开发:用 skill-creator 与 SKILL.md 把工具制造工具的艺术落到 TaoToken 实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

每天300万沙箱:DeepSeek Agent训练场架构拆解与反作弊实践

Agent训练圈这两天最抓眼球的消息,是DeepSeek公开的一套Agent训练场架构思路。单日跑300万个沙箱环境,这个数字真不是拿来做公关的——它大概率就是训练线上真实跑出来的量。300万/天意味着什么?平摊下来每秒大约要创建35个沙箱,如…

📰

Java外卖平台架构设计与高并发实践

1. 项目概述:从零搭建餐饮外卖平台的技术实践 去年接手公司餐饮系统重构项目时,我选择了"苍穹外卖"作为内部代号。这个基于Java技术栈的外卖平台,完整实现了从用户下单到骑手配送的全流程数字化管理。不同于简单的CRUD练习项目&…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬