手写定制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 折磨花一两个晚上把这个精简版跑通值回票价。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

解决.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:03 阅读更多 →
GCN不是图上的CNN:从原理到工业落地的深度解析

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

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

2026/10/15 7:58:03 阅读更多 →
高企管理成熟度评价指南:从合规到卓越的创新管理工具

高企管理成熟度评价指南:从合规到卓越的创新管理工具

1. 高企管理成熟度评价指南的核心价值解析作为一位长期服务于高新技术企业的管理顾问,我见证过太多企业在申报高企和后续管理中的困惑与痛点。这套《高企管理成熟度评价指南》的出现,恰好填补了行业空白。不同于传统的高企认定标准,它更像是一…

2026/10/15 7:57:02 阅读更多 →

最新新闻

AI漫剧角色一致性工作流:从参考图、LoRA到算力成本优化的完整实践

AI漫剧角色一致性工作流:从参考图、LoRA到算力成本优化的完整实践

做AI漫剧做了快一年,踩过最大的坑不是模型不会跑,而是同一个角色在不同镜头里长得像两个人。眼睛、发型、服装细节一飘,观众立刻出戏,弹幕直接开喷。这一期聊聊我怎么把“角色一致性”这件事从玄学变成流程,再把“算力…

2026/10/15 9:25:23 阅读更多 →
Codex CLI实测:让AI在终端里真正干活,完成编码任务闭环

Codex CLI实测:让AI在终端里真正干活,完成编码任务闭环

DevDay 结束当晚,我照例把发布会信息刷了两遍。如果只看标题,这会是一场信息量极大的大会:模型、API、新能力、工具链,东一榔头西一棒子地铺开,一眼看去全是亮点。但作为一个每天都在终端里写代码的人,真正…

2026/10/15 9:25:23 阅读更多 →
文献综述简单罗列,缺乏述评与研究缺口提炼问题的学术分析

文献综述简单罗列,缺乏述评与研究缺口提炼问题的学术分析

文献综述是论文的重要组成部分,核心功能是梳理学术脉络、总结前人成果、提炼研究空白,为自身研究提供立论依据。然而大量研究生撰写文献综述时,陷入文献清单式写作误区,单纯按照作者、年份罗列文献观点,只做 “述” 而…

2026/10/15 9:25:23 阅读更多 →
鲲鹏+麒麟V10部署ClickHouse 21.8 ARM64安装包实战与避坑指南

鲲鹏+麒麟V10部署ClickHouse 21.8 ARM64安装包实战与避坑指南

简介:本资源为在华为鲲鹏920处理器(aarch64架构)与麒麟V10操作系统上编译完成的ClickHouse 21.8.14安装包,面向需要在国产化自主可控环境中搭建OLAP大数据分析平台的运维与开发人员。包内共20个文件,以可执行二进制程序…

2026/10/15 9:25:23 阅读更多 →
8周科学减脂计划:精准打击内脏脂肪的6个核心动作

8周科学减脂计划:精准打击内脏脂肪的6个核心动作

1. 项目概述:科学减脂的核心逻辑这个8周减脂计划的核心在于通过特定动作组合精准打击内脏脂肪。内脏脂肪不同于皮下脂肪,它包裹在腹腔器官周围,与代谢综合征、胰岛素抵抗等健康问题密切相关。我设计的这6个动作经过多年实践验证,能…

2026/10/15 9:25:23 阅读更多 →
每日跟踪ArXiv CV论文:筛选、精读与复现的完整方法论

每日跟踪ArXiv CV论文:筛选、精读与复现的完整方法论

1. 从"每日更新"这个动作说起:为什么盯ArXiv CV板块是性价比最高的事做视觉方向的人,几乎都有一个共同的焦虑:模型迭代太快,昨天刚复现完的backbone,今天可能就被新的结构按在地上摩擦。我自己的习惯是每天早…

2026/10/15 9:24:22 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/14 7:53:34 阅读更多 →