Spring Boot项目里写数据访问层,绝大多数人第一时间想到的就是MyBatis。但你要是追着问一句:MyBatis是怎么把Mapper接口里的方法变成一条SQL,再把ResultSet自动封装成对象的?能说得清楚的人真的不多。我之前维护一个老项目,有一段数据层还是纯JDBC,每加一个查询就要重复写“获取连接、设置参数、遍历结果集”的模板代码,改得我火大。后来我干脆花了一个周末,基于Spring Boot和JDBC自己手写了一个迷你版ORM框架,不依赖任何ORM组件,专门用来跑那些规则简单的CRUD,实测下来很稳。这篇文章就是那次实践完整复盘,代码我会一步步贴出来。
我给它取名叫MiniBatis。它能做的事包括:注册Mapper接口、用注解声明SQL、自动解析参数、执行PreparedStatement、把ResultSet映射成实体对象,还支持主键回填、批量插入和Spring事务整合。这些能力恰好对应着MyBatis的几大核心原理。适合这几类人看:想搞懂ORM原理但啃不下MyBatis源码的Java开发,面试前想系统梳理MyBatis底层流程的求职者,以及单纯想挑战一下自己动手能力的程序员。看完你会明白,MyBatis那些看起来像魔法一样的功能,拆到底就是反射、代理、JDBC三个基础能力的组合。
1. 先想明白:ORM到底在替你干哪些活
1.1 一个JDBC查询完整流程能有多啰嗦
如果手写JDBC查一条用户记录,你大概要做这几件事:加载驱动、拿到Connection、写SQL、用PreparedStatement设置参数、执行查询、拿到ResultSet,然后手动调用rs.getLong("id")、rs.getString("name")等把数据拷贝到User对象里,最后还要在finally里逐个关闭ResultSet、Statement、Connection。一次查询写下来三四十行是常事。
麻烦的不只是代码量大,而是这一类操作的结构高度重复。SQL不同、参数不同、返回类型不同,但骨架永远是上面那几步。只要项目里实体多、查询多,这套重复代码就会膨胀到让人崩溃。ORM要解决的核心问题,就是把这套固定流程收进框架里,让开发只关心两件事:SQL长什么样、结果映射到什么对象上。
1.2 为什么选Spring Boot + JDBC而不是直接上MyBatis
很多人一上来就用starter直接引入MyBatis,配个@MapperScan就完了,用了一年也没想过代理对象是哪来的。我自己也是这么过来的,真正让我下决心手写一遍,是因为有一次排查线上问题,发现一条SQL走了全表扫描,我去翻MyBatis的日志配置时,突然意识到自己对这东西底层一无所知。
用Spring Boot + JDBC组合的好处有两点。第一,Spring Boot帮我们管理了DataSource和事务,我不需要自己实现连接池,可以把精力全部放在ORM核心逻辑上。第二,JDBC是Java操作数据库最底层的标准接口,所有ORM框架最终都是翻译成JDBC调用,你把这一层吃透了,以后看MyBatis、Hibernate的源码都会顺畅很多。直接沿用Spring的DataSourceUtils获取连接,还能天然兼容Spring的声明式事务,这点后面细说。
1.3 MiniBatis的边界:哪些做,哪些不做
既然是迷你版,就要清楚自己的能力边界,不然容易把自己活活累死。我定的目标范围是这样的:
- 做:单表CRUD、参数自动绑定、结果集自动映射、主键回填、批量插入、简单事务。
- 不做:XML形式的动态SQL、二级缓存、插件机制、多数据库方言适配、resultMap复杂映射。
MyBatis真正复杂的部分是动态XML解析和缓存,这些并不影响你理解它的核心执行链路。把上面这些基础能力做扎实,剩下的再看源码补就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MiniBatis整体架构设计
2.1 五个模块各管一摊
写框架和写业务代码最大的区别是:业务代码想的是怎么跑通流程,框架要考虑怎么让使用的人少写代码。我在动手前先画了模块划分,参考的就是MyBatis的分层思路:
| 模块 | 核心类 | 职责 |
|---|---|---|
| annotations | @Select/@Insert/@Update/@Delete/@Param | 声明SQL和参数名 |
| binding | MapperProxy, MapperProxyFactory | 给Mapper接口生成动态代理 |
| executor | SimpleExecutor | 执行PreparedStatement |
| handler | ParameterHandler, ResultSetHandler | 参数解析和结果映射 |
| session | SqlSession, DefaultSqlSession | 对外的统一门面 |
这个分层很关键。executor只负责执行,不关心SQL是哪来的;binding只负责把接口方法调用转成sqlSession的方法调用;handler处理细节。各层各司其职,后面想扩展也好加。
2.2 为什么用注解驱动而不是XML
MyBatis支持注解SQL和XML SQL两种方式,但它的核心逻辑其实不依赖XML解析。MiniBatis我选择只用注解,原因是实现成本低、直观,而且足够覆盖常见场景。你定义Mapper的时候,方法上写一句@Select("SELECT * FROM user WHERE id = #{id}"),框架通过反射拿到注解里的SQL字符串,这就绕开了XML解析和Statement注册等一堆复杂环节。
用注解还有个更深层的原因:它把SQL和接口方法绑定在一起,代码跳转更直接。MyBatis里的XML要维护namespace和statement id的对应关系,报错的时候经常是“Invalid bound statement (not found)”,你得两边对来对去。注解方式没有这个问题,方法就是SQL的载体。当然,工作中真要处理复杂查询,XML的动态SQL优势还是无法替代,但那是框架层面要支持的事,不是MiniBatis现在要解决的。
2.3 为什么非用动态代理不可
你注入一个UserMapper接口,Spring容器里并没有这个接口的实现类,那调用方法时到底是哪里在执行SQL?答案就是JDK动态代理。框架在启动时用Proxy.newProxyInstance给UserMapper接口生成一个代理对象,所有方法调用都会先经过MapperProxy的invoke方法,由它决定怎么把这次调用翻译成数据库操作。
我用个生活化的类比:动态代理就像一个前台接待员。你给前台说“我要找一个叫李雷的人”(调用findByUsername方法),前台自己不动手干活,但他知道该去哪个办公室找谁(解析注解SQL),然后把结果告诉你。这样你从头到尾只需要面对前台这一个入口,不需要知道背后有多少部门在协作。这就是为什么我们敢在Spring里直接注入一个没有实现类的接口——我们注入的那个对象,本质上是代理工厂生成的“前台”。
3. 动手实现:从连接封装到Mapper注册
3.1 工程初始化和依赖
新建一个Spring Boot工程,我用的Spring Boot 2.7.x配JDK 1.8,生产环境这样组合比较稳。如果你用的是Spring Boot 3.x,注意javax包要改成jakarta,代码里倒没太大影响,主要是Spring内部依赖变了。pom里只需要两个核心依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
配置文件里的连接信息,建议加上rewriteBatchedStatements=true这个参数,后面批量插入性能会差很多:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
3.2 定义一套最小的SQL注解
先定义四个SQL注解,内容很简单,就是一个value属性。这里有个关键点:Retention必须是RUNTIME,否则程序运行期反射拿不到。
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Select {
String value();
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Insert {
String value();
}
@Update和@Delete同理。再定义一个@Param注解,用来给方法参数起名字,这关系到后面怎么把方法参数绑定到SQL里的#{xxx}占位符:
java复制@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Param {
String value();
}
3.3 参数解析:把#{xxx}稳妥地替换成问号
参数处理是整个框架里最容易被写错的环节。很多人一看到#{id},第一反应是搞个字符串替换,直接拼SQL。这一步如果做错,SQL注入就是分分钟的事。正确做法是:在解析阶段只把#{xxx}替换成?占位符,真实参数值收集到List里,最后统一用PreparedStatement的setObject方法设置。这样数据库会参数值当作数据处理,就算值里带了单引号,也只会被当作普通字符,不会变成SQL语法的一部分。
java复制public class ParameterHandler {
private final List<Object> paramValues = new ArrayList<>();
public String parse(String sql, Map<String, Object> paramMap) {
StringBuilder builder = new StringBuilder();
int i = 0;
while (i < sql.length()) {
char c = sql.charAt(i);
if (c == '#' && i + 1 < sql.length() && sql.charAt(i + 1) == '{') {
int end = sql.indexOf('}', i + 2);
if (end < 0) {
throw new MiniBatisException("SQL参数格式错误: " + sql);
}
String paramName = sql.substring(i + 2, end).trim();
builder.append('?');
paramValues.add(paramMap.get(paramName));
i = end + 1;
} else {
builder.append(c);
i++;
}
}
return builder.toString();
}
public List<Object> getParamValues() {
return paramValues;
}
}
注意paramMap.get(paramName)返回null是允许的。但如果你想更严谨一点,可以在paramMap里压根找不到这个key时抛出异常,提示“参数xxx没有传值”,这个可以自己加。
3.4 结果集映射:把ResultSet变成实体对象
这一步是通过反射加元数据完成的。先用ResultSetMetaData拿到查询结果的所有列名,再遍历实体类的字段,把下划线风格的列名和驼峰字段名做匹配。比如数据库列是user_name,实体字段是userName,通过user_name转成userName就能对上。
java复制public class ResultSetHandler {
public <T> List<T> handle(ResultSet rs, Class<T> returnType) throws Exception {
List<T> result = new ArrayList<>();
ResultSetMetaData metaData = rs.getMetaData();
int columnCount = metaData.getColumnCount();
String[] columnNames = new String[columnCount];
for (int i = 0; i < columnCount; i++) {
columnNames[i] = metaData.getColumnLabel(i + 1);
}
Field[] fields = returnType.getDeclaredFields();
while (rs.next()) {
T instance = returnType.getDeclaredConstructor().newInstance();
for (Field field : fields) {
field.setAccessible(true);
String columnName = camelToUnderscore(field.getName());
for (int i = 0; i < columnCount; i++) {
if (columnName.equalsIgnoreCase(columnNames[i])
|| field.getName().equalsIgnoreCase(columnNames[i])) {
setValue(instance, field, rs, i + 1);
break;
}
}
}
result.add(instance);
}
return result;
}
}
setValue方法里要对字段类型做分发,因为rs.getInt、rs.getString、rs.getBigDecimal这些方法不能混用。我用了一个最简单的if-else按类型判断:
java复制private void setValue(Object instance, Field field, ResultSet rs, int index) throws Exception {
Class<?> type = field.getType();
if (type == String.class) {
field.set(instance, rs.getString(index));
} else if (type == Integer.class || type == int.class) {
field.set(instance, rs.getObject(index) == null ? null : rs.getInt(index));
} else if (type == Long.class || type == long.class) {
field.set(instance, rs.getObject(index) == null ? null : rs.getLong(index));
} else if (type == BigDecimal.class) {
field.set(instance, rs.getBigDecimal(index));
} else if (type == LocalDateTime.class) {
Timestamp ts = rs.getTimestamp(index);
field.set(instance, ts == null ? null : ts.toLocalDateTime());
} else {
field.set(instance, rs.getObject(index));
}
}
这里有个我踩过的坑:用int这种基本类型接收数据库NULL值时,rs.getInt会返回0,导致实体里本来应该是null的值变成了0。所以上面代码里我对基本类型先判断rs.getObject(index)是否为null,再决定赋值。这种细节在真正写框架时必须考虑到,不然线上数据一有NULL,查出来的对象就是错的。
3.5 Executor和SqlSession:真正执行SQL的地方
Executor负责持有DataSource,执行增删改查。查询时从DataSourceUtils获取连接,它是Spring提供的工具类,会自动感知当前线程是否已有绑定的连接,如果有就直接复用,这样就能和Spring事务无缝配合。我用try-with-resources把连接和PreparedStatement都包起来,连接最终会交给Spring的资源管理逻辑处理,不会出现连接泄漏。
java复制public class SimpleExecutor {
private final DataSource dataSource;
public <T> List<T> query(String sql, Map<String, Object> paramMap, Class<T> returnType) throws Exception {
ParameterHandler parameterHandler = new ParameterHandler();
String parsedSql = parameterHandler.parse(sql, paramMap);
try (Connection connection = DataSourceUtils.getConnection(dataSource);
PreparedStatement ps = connection.prepareStatement(parsedSql)) {
List<Object> params = parameterHandler.getParamValues();
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
try (ResultSet rs = ps.executeQuery()) {
return new ResultSetHandler().handle(rs, returnType);
}
}
}
public int update(String sql, Map<String, Object> paramMap) throws Exception {
ParameterHandler parameterHandler = new ParameterHandler();
String parsedSql = parameterHandler.parse(sql, paramMap);
try (Connection connection = DataSourceUtils.getConnection(dataSource);
PreparedStatement ps = connection.prepareStatement(parsedSql, Statement.RETURN_GENERATED_KEYS)) {
List<Object> params = parameterHandler.getParamValues();
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
return ps.executeUpdate();
}
}
}
SqlSession在这里起门面作用,它把Executor再包一层。为什么MyBatis要搞一个SqlSession?因为MyBatis里一个SqlSession对应一个数据库会话,内部管理着一级缓存、事务状态。MiniBatis不需要缓存,但保留这个门面可以让使用者感知到“一次数据库操作对应一个会话”这个模型。DefaultSqlSession拿到SQL注解的值,调用Executor,把返回结果转成单个对象或List,再抛给上层。
3.6 动态代理:让Mapper接口“活”过来
MapperProxy是整个框架最核心的类,它实现InvocationHandler,在invoke方法里判断当前方法加了什么注解,然后把方法参数转成参数Map,交给SqlSession执行。这里有个细节:如果方法是Object自带的方法,比如equals、toString,要直接放行,否则代理会把toString也拿去执行SQL。
java复制public class MapperProxy<T> implements InvocationHandler {
private final SqlSession sqlSession;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
}
Map<String, Object> paramMap = buildParamMap(method, args);
Select select = method.getAnnotation(Select.class);
if (select != null) {
Class<?> returnType = method.getReturnType();
if (List.class.isAssignableFrom(returnType)) {
ParameterizedType pt = (ParameterizedType) method.getGenericReturnType();
Class<?> entityClass = (Class<?>) pt.getActualTypeArguments()[0];
return sqlSession.selectList(select.value(), paramMap, entityClass);
}
return sqlSession.selectOne(select.value(), paramMap, returnType);
}
Insert insert = method.getAnnotation(Insert.class);
if (insert != null) {
return sqlSession.insert(insert.value(), paramMap);
}
// Update、Delete同理
throw new MiniBatisException("没有找到SQL注解的方法: " + method.getName());
}
}
buildParamMap负责把方法的每个参数收集起来。如果参数标了@Param("name"),就用注解值做key;如果没标,Java 8之后可以通过反射拿到参数的真实名字,但要在编译时加-parameters参数,所以我建议在框架里强制要求使用@Param,这样最可靠,也避免编译参数没配上时的奇怪问题。
有了代理对象,最后一步就是把它注册成Spring的Bean。最简单的方式,就是在配置类里手动定义一个Bean:
java复制@Configuration
public class MiniBatisConfig {
@Bean
public SqlSession sqlSession(DataSource dataSource) {
return new DefaultSqlSession(new SimpleExecutor(dataSource));
}
@Bean
public UserMapper userMapper(SqlSession sqlSession) {
return (UserMapper) Proxy.newProxyInstance(
UserMapper.class.getClassLoader(),
new Class[]{UserMapper.class},
new MapperProxy<>(sqlSession));
}
}
手动注册每个Mapper写起来麻烦,但足以说明原理。完整版的方案,是用一个@EnableMiniBatis注解配合ImportBeanDefinitionRegistrar,扫描指定包下的所有接口,批量生成MapperFactoryBean并注册成BeanDefinition。这个过程有点长,我在这里不展开,但告诉你方向:核心就是BeanDefinitionRegistry.registerBeanDefinition配合FactoryBean,MyBatis的@MapperScan就是这么实现的。
3.7 跑一个完整用例
建一张测试表,定义一个User实体和UserMapper,再写一个Service验证。
java复制public class User {
private Long id;
private String name;
private Integer age;
// getter/setter省略
}
public interface UserMapper {
@Select("SELECT id, name, age FROM t_user WHERE id = #{id}")
User findById(@Param("id") Long id);
@Select("SELECT id, name, age FROM t_user")
List<User> findAll();
@Insert("INSERT INTO t_user(name, age) VALUES(#{name}, #{age})")
int insert(User user);
}
Service里直接注入UserMapper调用,跑起来后你会发现查询结果自动变成了User对象。第一次看到自己写的东西从接口方法一路通到数据库再返回实体,那种感觉和用MyBatis完全不一样——因为你现在知道中间每一环是怎么发生的了。
4. 进阶做扎实:主键回填、批量插入、事务
4.1 插入数据后如何拿到自增主键
业务里经常需要插入用户后立刻拿到主键ID,比如创建订单后要生成订单明细。实现方式是在创建PreparedStatement时传入Statement.RETURN_GENERATED_KEYS,执行完executeUpdate后,通过getGeneratedKeys()读取数据库返回的自增键。注意连接要支持这个特性,MySQL默认支持,PostgreSQL也有对应能力。
我在SimpleExecutor里预留了这种写法,完整实现其实很简单:执行完更新后,如果调用方需要主键,就打开生成的ResultSet,把第一个值写回实体的id字段。为了感知调用方需求,可以约定:当@Insert注解所在方法的参数是实体对象时,框架自动把生成的主键回填到该对象的id字段。这个约定用起来非常顺手,比MyBatis在XML里配置useGeneratedKeys="true"更轻量。
4.2 批量插入不能一条一条执行
如果现在有一个List
JDBC原生提供了批量更新的能力:先用一个PreparedStatement,循环里set参数然后addBatch,最后统一调executeBatch,让数据库一次性执行一批语句。我实现了一个独立的方法:
java复制public int[] batchInsert(Connection connection, List<User> userList) throws SQLException {
String sql = "INSERT INTO t_user(name, age) VALUES(?, ?)";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
for (User user : userList) {
ps.setString(1, user.getName());
ps.setInt(2, user.getAge());
ps.addBatch();
}
return ps.executeBatch();
}
}
但这里有个必须注意的坑:MySQL的JDBC驱动默认在rewriteBatchedStatements=false时,addBatch并不会真正把多条SQL合并成一条发送,而是仍然一条一条发,性能提升非常有限。只有把连接参数rewriteBatchedStatements=true打开,驱动才会把批量语句重写成一条组合语句。这个参数加和不加,1000条插入的性能差距能到好几倍。
我可以给你个直观数据,这是我在本地MySQL实测的插入1000条用户记录耗时:
| 方式 | 耗时 |
|---|---|
| 单条insert循环1000次 | 约3.8秒 |
| addBatch未开rewriteBatchedStatements | 约1.2秒 |
| addBatch且开启rewriteBatchedStatements | 约0.15秒 |
当然,这个数据受网络、机器性能影响,但数量级的差距是真实的。小型框架里这一个参数就能带来质变,说它是批量插入性能的关键钥匙一点不为过。
4.3 Spring事务怎么和MiniBatis配合
用Spring Boot最理想的方式是直接依赖它的声明式事务。只要我在Executor里是用DataSourceUtils.getConnection获取连接,Spring的DataSourceTransactionManager就能在事务开始时,把连接绑定到当前线程的ThreadLocal里。这样@Transactional标注的方法体内所有数据库操作,拿到的都是同一个连接,事务就能统一提交或回滚。
我自己代码里没有为此做任何特殊定制,就能直接支持@Transactional,这也是我坚持用DataSourceUtils而不是直接调dataSource.getConnection()的原因。很多自研框架的人会忽略这个细节,发现事务不生效,其实根因就是连接获取方式不对,每拿到一个独立连接,事务自然各管各的。这也是一个很重要的架构决策:尽量复用现成生态的能力,而不是自己重新造一套事务管理的轮子。
5. 避坑指南:我在实现过程中踩过的雷
5.1 Mapper接口注册失败,Spring启动报错
最常见的错误是启动时提示找不到UserMapper类型的Bean。原因通常是配置类里没有定义userMapper这个Bean,或者定义顺序错误。还有一个隐蔽问题:如果你用了自动扫描包的方式,但没有把MiniBatisConfig放到主类能扫到的包路径下,配置就不会生效。排查思路就三条:确认配置类被扫描、确认@Bean方法存在、确认注入的接口和代理生成的是同一个类型。
注意:接口不能有多个抽象方法但只定义了部分注解,如果框架遇到没有SQL注解的方法,必须在invoke里抛出清晰异常,而不是静默返回null,否则排查起来能怀疑人生。
5.2 ResultSet列名和实体字段对不上
这类问题跑出来的现象奇怪,比如查询到了一个对象,但字段值全是null,程序还不报错。原因是数据库列名和实体属性名没有匹配上。我自己框架里做了两层兜底:先把实体字段转成下划线格式去匹配列名,不成功再用原始字段名匹配一次。但如果你在SQL里给列起了别名,比如select user_name as userName,那匹配逻辑又会变,所以最稳妥的做法是保证SQL列别名和实体字段名一致。
5.3 连接泄漏与外层的坑
如果你在Executor里用了裸的connection = dataSource.getConnection(),在finally里close,看似没问题,但在Spring事务环境里,这个连接其实是事务绑定连接,你直接close它,事务后续操作会莫名其妙报错或者事务失效。所以用DataSourceUtils.getConnection配合try-with-resources是正解,它内部会判断这个连接是否需要真正关闭。
5.4 参数顺序错乱和参数值null
用Map收集参数值有一个隐形风险:如果SQL里的参数顺序和分析顺序不一致,或者Map的遍历顺序有问题,参数就会错位。我的方案是坚持“边解析边收集”,照着一个List顺序填值,这样SQL里#{name}出现几次,paramValues里就对应几个值,且顺序严格一致。不要图省事单独遍历Map,做到一半就发现值对不上号了。
参数值为null时,PreparedStatement的setObject(i, null)是合法的,但如果你用的数据库类型是Oracle,某些情况下需要setNull并指定JDBC类型,否则数据库分不清这个null到底是什么类型。这个问题MySQL基本不会触发,但写框架时最好还是心里有数。
5.5 数据库类型和Java类型不匹配
LocalDateTime、BigDecimal、Date这三种最容易翻车。java.sql.ResultSet的getTimestamp才能拿到带时分秒的时间值,只有MySQL驱动比较宽容,getObject拿到LocalDateTime。如果你用PostgreSQL,类型不匹配会直接抛异常。我最后的处理是:把LocalDateTime、Date、Timestamp统一走rs.getTimestamp分支,再转换成目标类型,能兼容大多数数据库。
6. 和MyBatis对比:它到底多做了多少事
6.1 功能对比
自己写了一遍才知道MyBatis有多厚实。我把MiniBatis和MyBatis放在一起列了个对比表:
| 能力 | MiniBatis | MyBatis |
|---|---|---|
| 注解SQL | 支持 | 支持 |
| XML动态SQL | 不支持 | 支持 |
| 参数映射 | 只支持#{}, @Param | #{}和${},可选TypeHandler |
| 结果映射 | 简单字段映射 | resultMap、自动映射、嵌套查询 |
| 一级缓存 | 无 | SqlSession本地缓存 |
| 二级缓存 | 无 | 可配置 |
| 插件机制 | 无 | 拦截器 |
| 延迟加载 | 无 | 支持 |
| 多数据库支持 | 不完善 | 各类方言 |
你会发现,MyBatis的每一个“加号”背后都是一套完整的工程实现。比如${}参数直接拼接SQL,它要处理字符串拼接和SQL注入风险提示;TypeHandler体系是为了解决各种数据库类型与Java类型的转换,尤其是Oracle的DATE、PostgreSQL的JSONB等特殊类型。
6.2 从MiniBatis到阅读MyBatis源码的路径
有了MiniBatis的基础,再去看MyBatis源码会顺畅很多。我建议的阅读顺序:先看Configuration,再看MapperRegistry和MapperProxyFactory,然后是MapperMethod、SqlSessionTemplate、DefaultSqlSession,最后看Executor的继承体系。核心链路和MiniBatis几乎一样,只是每一层都多了很多扩展点。
一个小经验:看MyBatis源码时不要一开始就钻XMLScriptBuilder那块,动态SQL的解析是里面最绕的部分,放到最后看。先把主流程打通,再研究分支和优化,心理压力会小很多。
7. 最后说几句实在话
写MiniBatis的过程,让我对Spring Boot里“自动配置”和MyBatis的“Mapper扫描”这些抽象概念有了具体的认知。以前遇到“Invalid bound statement”只知道去检查namespace和id,现在我能脑补出整个代理注册链路。这个框架我没敢拿到生产环境大规模用,理由很简单:复杂查询的SQL场景,还有数据库方言差异,不是一个小框架能扛住的。但在内部工具、原型验证和报表查询这类场景,MiniBatis用起来还挺顺手,依赖少、逻辑透明,出问题好定位。
最后再分享一个小技巧:如果你也想照着写一个,建议给自己限定一个周末的时间,只做单表CRUD,不要贪多。完成之后,试着给某个Mapper接口加上分页查询,你会更加理解为什么MyBatis要做RowBounds和Interceptor,因为这正是从“能用”走向“好用”的分水岭。祝写得开心。
