自研ORM框架:从JDBC模板到反射映射的核心实现

发布时间:2026/10/24 17:04:47
自研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 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.jdbc.Driver"); // 2、建立连接 con = DriverManager.getConnection( "jdbc:mysql://localhost:3306/db_demo?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; }

这段代码里,真正跟业务有关的只有三行: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 的设计核心,是模板方法模式加回调接口。模板方法把那些永远不变的动作,包括连接创建、语句创建、参数绑定、结果集遍历、资源关闭,全部收进框架;留给调用方的只有一个RowMapper<T>接口。

public interface RowMapper<T> { T mapRow(ResultSet rs, int rowNum) throws Exception; }

有了这个抽象,模板方法就可以把连接管理、语句创建、参数绑定、结果遍历全部接管,只把“如何把一行数据变成对象”留给调用方。手写一个最小可用版本如下:

public <T> List<T> executeQuery(String sql, RowMapper<T> rowMapper, Object... args) { List<T> 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; }

这段代码一眼就能看出模板方法的边界:

  1. 参数绑定用setObject(i + 1, args[i]),占位符顺序和参数顺序严格对应;
  2. rowMapper.mapRow(rs, rowNum++)只处理单行转换,行号由框架维护;
  3. 关闭连接、语句、结果集全部放在 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_name,Java 属性名是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:把查询条件做成链式 API

QueryRule 是这套框架里最能体现设计感的类。它允许开发者用链式声明描述查询条件,而不是直接拼 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 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 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 Page<T> 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 List<T> 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 ResultMsg<T> 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> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.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.java

4.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 1=1 "); List<Object> 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 List<Object> params; public SqlAndParams(String sql, List<Object> params) { this.sql = sql; this.params = params; } public String getSql() { return sql; } public List<Object> getParams() { return params; } }

这段逻辑里有几个细节值得注意:

  1. 占位符统一用?,不要直接把值拼进 SQL。早期版本代码里,字符串类型条件喜欢加单引号,比如name = '某用户',其他类型不加,一旦遇到包含引号的输入,SQL 就语法错乱或产生注入风险;
  2. field.get(condition)只能拿到当前对象的非空属性,null 属性不参与条件。这符合“条件对象只查有值字段”的设计直觉,但也要注意:想查询 NULL 条件时,这套逻辑做不到,需要另行扩展andIsNull;
  3. 只有查询条件字段才会出现在 where 部分,排序字段、分组字段不在这个环节处理。

4.2 基于 ResultSetMetaData 完成通用结果映射

生成 SQL 只是前半段,真正进阶的部分是 ResultSet 到对象的自动映射。课程的做法是:先拿到实体类的全部字段,建立“列名 -> 属性名”的映射关系,然后通过ResultSetMetaData动态读取列名和列数量,最后逐列反射赋值。

private <T> List<T> mapResultSet(ResultSet rs, Class<T> entityClass) throws Exception { List<T> result = new ArrayList<>(); ResultSetMetaData metaData = rs.getMetaData(); int columnCount = metaData.getColumnCount(); // 建立列名 -> 字段名的映射 Map<String, 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_name,Java 字段叫userName,也能正确赋值。这里我还额外做了.toLowerCase()归一化,避免驱动返回USER_NAME这种大小写形式时映射不上。

但也必须承认,这里的rs.getObject(columnName)是“偷懒”的写法。它会由数据库驱动决定返回 Java 类型,比如 MySQL 的 INT 可能返回 Integer,DATETIME 可能返回 Timestamp。如果实体字段类型是java.util.Date,就可能出现赋值失败。这个坑后面避坑章节会详细展开。

4.3 BaseDao:先定契约,再填实现

课程里最有架构味道的部分是 BaseDao 接口。它把 DAO 层的公共操作固定下来,上层业务只依赖接口,不依赖具体实现:

public interface BaseDao<T, PK> { // 面向对象的查询 List<T> select(QueryRule queryRule) throws Exception; // 面向对象的条件分页查询 Page<?> select(QueryRule queryRule, int pageNo, int pageSize) throws Exception; // 面向 SQL 的兜底查询 List<Map<String, Object>> selectBySql(String sql, Object... args) throws Exception; 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)、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 '1'='1,整条 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 连接串上加rewriteBatchedStatements=true,否则驱动不一定真的批量执行。这个参数是我踩过最大的坑,不加它,批处理性能提升非常有限。

6. 二次开发:给自研 ORM 加上批量插入与字段命名转换

课程版 ORM 已经把基础链路跑通了,但在实际项目里,还得再补齐两个高频能力。

6.1 用 batchUpdate 实现 insertAll 的批处理语义

BaseDao 接口里定义了insertAll(List<T> list),如果它只是循环调insert,那这个接口就没有存在的意义。我通常在具体实现里直接复用 JdbcTemplate 的批处理 API:

public int insertAll(List<Member> list) { String sql = "insert into t_member(name, addr, age) values(?, ?, ?)"; List<Object[]> 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 受影响的行数。注意连接串必须带rewriteBatchedStatements=true,否则 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转成userName,addr转成addr。把它放在列名映射的前置层,找不到字段时再退回@Column注解,这样大部分实体类都不用再写注解了。

从那以后,我每次扩展新的实体类,都会强制走一遍“字段映射自检”,把实体字段列表和数据库返回列名打印出来逐一对齐,确认列名转换规则没有漏掉字段。这个习惯帮我绕过了不少线上问题。希望这份手写 ORM 的拆解笔记,能让你在理解 JdbcTemplate 和 MyBatis 的路上少走一段弯路。

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