本篇不是另起一个演示工程,而是直接在 D:\Projects\mini-mybatis-lab 的空骨架上敲代码。最终目标很小但完整:UserMapper.findById(1L) 能经过 JDK 动态代理、Session、Executor 和 JDBC,返回一个 User。 约束先说清楚:不新建项目,不新建 pom.xml,不建立 lab 包,也不把多个类拼在一个嵌套类示例里。每个代码块都对应一个独立 Java 文件,真实根包统一为 com.frank.mybatis。
1. 先确认空骨架,不要重写已有入口 为什么从这一步开始:在一个已有工程上增量施工,必须先确认起点本身可编译、入口可运行,否则后面任何测试失败都无法归因是新代码还是旧环境。下面的清单同时就是本篇全部施工的边界。
项目已经具备:
1 2 3 4 5 6 7 8 9 10 11 12 13 D:\Projects\mini-mybatis-lab ├── pom.xml ├── src/main/java/com/frank/mybatis/MiniMybatisApplication.java ├── src/main/java/com/frank/mybatis/ │ ├── annotations/.gitkeep │ ├── binding/.gitkeep │ ├── builder/.gitkeep │ ├── executor/.gitkeep │ ├── mapping/.gitkeep │ ├── session/.gitkeep │ └── transaction/.gitkeep ├── src/main/resources/schema.sql └── src/test/java/com/frank/mybatis/.gitkeep
pom.xml 已经提供 Java 17、H2 2.3.232、JUnit Jupiter 5.10.2、编译插件和 Surefire;不要复制一份新的 Maven 配置。MiniMybatisApplication 已经在 com.frank.mybatis 包中创建 H2 数据源、读取 classpath 的 schema.sql、执行脚本并检查 T_USER,它是可运行的现有入口,不是本篇重写目标。schema.sql 目前只有一张表:
1 2 3 4 5 CREATE TABLE t_user ( id BIGINT PRIMARY KEY , user_name VARCHAR (100 ) NOT NULL , age INTEGER );
因此本文的业务对象只需要 id、userName、age。先在项目目录执行:
1 2 cd /d D:\Projects\mini-mybatis-labmvn clean test
空骨架没有测试是正常的。后面每完成一个 checkpoint 都重新执行这条命令;最终还可以用 mvn compile exec:java 验证已有启动类仍能读取 schema.sql。
2. 第 01 篇的能力边界 为什么先划边界:教学框架最容易死在“顺手多支持一点”。这张表既是本篇的验收标准,也是全系列的路线图——每个“明确不做”的项都对应真实 MyBatis 的一个模块,将在后续四篇逐一补齐;边界之内,每多写一行没有测试支撑的代码都是负债。
本篇只实现一个可解释、可测试的闭环:
能力
本篇行为
SQL 来源
方法上的 @Select、@Insert、@Update、@Delete
占位符
#{name} 转成 JDBC ?,按 SQL 出现顺序绑定
参数名称
@Param("name"),未标注参数使用 arg0、arg1
查询结果
单个 POJO、List<POJO>;0 行单查返回 null
写操作
返回 JDBC 影响行数 int
列映射
user_name 与 userName 通过去下划线、小写匹配
事务
一个 SqlSession 持有一个 Connection,调用方显式 commit/rollback
明确不做
XML、动态 SQL、${}、缓存、插件、关联映射、TypeHandler、分页和 Spring 整合
最终调用链是:
1 2 3 4 5 6 7 8 UserMapper.findById(1L) -> MapperProxy.invoke -> Configuration.getMappedStatement -> DefaultSqlSession.selectOne -> SimpleExecutor.queryOne -> PreparedStatement / ResultSet -> ResultSetHandler -> User
配置和 MappedStatement 可以被多个 Session 只读共享;Connection、Transaction 和绑定了 Session 的 Mapper 代理绝不能做成全局对象。
章节测试的组织方式 以下补充测试使用已有 JUnit Jupiter 5.10.2;纯逻辑测试不连接数据库,涉及 JDBC、结果映射和事务的测试使用 H2。每个完整代码块按标注路径创建;每个 checkpoint 的组件测试自成文件、当节即可运行,不向后借夹具。第 1、2 节的环境与能力约束由 JDBC 基线和各 checkpoint 验证;第 11、12 节复盘时运行全部测试,不另建重复用例。
如果你的 mini-mybatis-lab 已经领先本篇(比如实体叫 TUser、测试类带 T1_ 序号前缀、用了 pom 里已有的 Lombok),保留自己的命名完全可以——本篇的文件名与“手写字段 + 访问器”的写法是教学基准,不是必须回退的硬性要求。
3. Checkpoint 1:先写 JDBC 基线 为什么需要这一步: 本篇接下来的每一层都在包装 JDBC。如果对底座行为的假设本来就是错的,错误会被代理、Session、Executor 层层包装,几乎不可能定位回数据库。先用裸 JDBC 把三件事钉死成基线:? 只能绑定值而不能拼接 SQL(后面所有 #{} 走预编译的依据)、SQL NULL 读出来就是 null 而不是 0 或空串、事务提交前其他连接不可见。此后任何一层出错,都能确信问题不在 H2。
目标: 在框架出现前证明 H2、预编译参数、SQL NULL 和事务行为都符合预期。新建目录: src/test/java/com/frank/mybatis/fixture 与 src/test/java/com/frank/mybatis/support。
3.1 fixture/User.java 业务对象放 fixture,不要放到 mapping。它只需要无参构造器、字段和访问器:
1 2 3 4 5 6 7 8 9 10 11 12 13 package com.frank.mybatis.fixture;public class User { private Long id; private String userName; private Integer age; public User () {} public Long getId () { return id; } public void setId (Long id) { this .id = id; } public String getUserName () { return userName; } public void setUserName (String userName) { this .userName = userName; } public Integer getAge () { return age; } public void setAge (Integer age) { this .age = age; } }
3.2 support/H2DatabaseSupport.java 每个测试拿到独立的内存库,并复用项目已有的 src/main/resources/schema.sql:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package com.frank.mybatis.support;import org.h2.jdbcx.JdbcDataSource;import javax.sql.DataSource;import java.io.InputStream;import java.nio.charset.StandardCharsets;import java.sql.Connection;import java.sql.Statement;import java.util.UUID;public final class H2DatabaseSupport { private H2DatabaseSupport () {} public static DataSource newDataSource () { JdbcDataSource ds = new JdbcDataSource (); ds.setURL("jdbc:h2:mem:chapter01_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1" ); ds.setUser("sa" ); ds.setPassword("" ); try (Connection c = ds.getConnection(); Statement s = c.createStatement()) { InputStream in = H2DatabaseSupport.class.getClassLoader() .getResourceAsStream("schema.sql" ); if (in == null ) throw new IllegalStateException ("找不到 schema.sql" ); try (in) { s.execute(new String (in.readAllBytes(), StandardCharsets.UTF_8)); } return ds; } catch (Exception e) { throw new IllegalStateException ("初始化 H2 失败" , e); } } }
3.3 chapter01/JdbcBaselineTest.java 先用 JDBC 写一条插入和查询,最后 rollback,再用新连接确认数据不可见:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 package com.frank.mybatis.chapter01;import com.frank.mybatis.support.H2DatabaseSupport;import org.junit.jupiter.api.Test;import javax.sql.DataSource;import java.sql.*;import static org.junit.jupiter.api.Assertions.*;class JdbcBaselineTest { @Test void bindQueryNullAndRollback () throws Exception { DataSource ds = H2DatabaseSupport.newDataSource(); try (Connection c = ds.getConnection()) { c.setAutoCommit(false ); try (PreparedStatement p = c.prepareStatement( "insert into t_user(id,user_name,age) values(?,?,?)" )) { p.setLong(1 , 1L ); p.setString(2 , "Frank" ); p.setNull(3 , Types.INTEGER); assertEquals(1 , p.executeUpdate()); } try (PreparedStatement p = c.prepareStatement( "select age from t_user where id=?" )) { p.setLong(1 , 1L ); try (ResultSet r = p.executeQuery()) { assertTrue(r.next()); assertNull(r.getObject(1 )); } } c.rollback(); } try (Connection c = ds.getConnection(); Statement s = c.createStatement(); ResultSet r = s.executeQuery("select count(*) from t_user" )) { assertTrue(r.next()); assertEquals(0 , r.getInt(1 )); } } }
验收: mvn test 通过。记住 ? 只能绑定值,不能绑定列名或 SQL 片段;后续所有 #{} 都必须走 PreparedStatement。
4. Checkpoint 2:annotations,5 个运行时注解 为什么需要这一步: SQL 总要写在某个地方。写在每个调用点会散落且重复;写进独立配置文件就要先引入一套解析器(那是第 02 篇的事)。写在方法签名上,SQL 就和它的名字(接口.方法)声明在一起,调用点零样板。注解只做声明、绝不做执行:RetentionPolicy.RUNTIME 是为了启动期能反射读到它,而解析、校验都推迟到注册期完成。
依赖: 只有 Java 17。目标: 注解描述 SQL,绝不在注解中打开数据库。 以下 5 个文件分别创建在 src/main/java/com/frank/mybatis/annotations。不要把它们合并为一个外壳类。
Select.java1 2 3 4 5 package com.frank.mybatis.annotations;import java.lang.annotation.*;@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Select { String value () ; }
Insert.java1 2 3 4 5 package com.frank.mybatis.annotations;import java.lang.annotation.*;@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Insert { String value () ; }
Update.java1 2 3 4 5 package com.frank.mybatis.annotations;import java.lang.annotation.*;@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Update { String value () ; }
Delete.java1 2 3 4 5 package com.frank.mybatis.annotations;import java.lang.annotation.*;@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Delete { String value () ; }
Param.java1 2 3 4 5 package com.frank.mybatis.annotations;import java.lang.annotation.*;@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) public @interface Param { String value () ; }
验收: 让测试用反射检查 4 个 SQL 注解的 RetentionPolicy.RUNTIME 和 @Param 的参数目标;然后运行 mvn test。此时还没有构建器,所以注解不会产生任何执行效果。
4.1 单元测试:注解可在运行期读取 文件:src/test/java/com/frank/mybatis/chapter01/AnnotationContractTest.java。此测试只依赖本节五个注解,可立即执行 mvn -Dtest=AnnotationContractTest test。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.frank.mybatis.chapter01;import com.frank.mybatis.annotations.*;import java.lang.annotation.*;import java.util.List;import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*;class AnnotationContractTest { @Test void sqlAnnotationsAreRuntimeMethodAnnotations () { for (Class<?> type : List.of(Select.class, Insert.class, Update.class, Delete.class)) { assertEquals(RetentionPolicy.RUNTIME, type.getAnnotation(Retention.class).value()); assertArrayEquals(new ElementType []{ElementType.METHOD}, type.getAnnotation(Target.class).value()); } assertEquals(RetentionPolicy.RUNTIME, Param.class.getAnnotation(Retention.class).value()); assertArrayEquals(new ElementType []{ElementType.PARAMETER}, Param.class.getAnnotation(Target.class).value()); } }
四个 SQL 注解与 Param 的分工用一张图钉住:SQL 注解只标在方法上,Param 只标在参数上,五个注解全部保留到运行期——但只负责声明,执行时它们一个字都不会被再次读取。
5. Checkpoint 3:mapping,把文本变成元数据 为什么需要这一步: PreparedStatement 只认 ? 加下标,而人写的是 #{name}。两者之间必须有一次转换,产物就是两份元数据:最终 SQL(交给 prepareStatement)和有序参数名列表(绑定实参时按下标取用)。关键是转换在启动期只做一次、结果不可变共享——MappedStatement 注册后会被多个 Session 只读使用;每调用一次就重新正则解析一遍,既慢又给“注册期校验”留下漏洞。这也是固定 SQL 在第 03 篇引入 SqlSource 之前最简单正确的形态。
依赖: Checkpoint 2。目录: src/main/java/com/frank/mybatis/mapping。
5.1 SqlCommandType.java 1 2 package com.frank.mybatis.mapping;public enum SqlCommandType { SELECT, INSERT, UPDATE, DELETE }
5.2 PreparedSql.java parameterNames 必须是列表,因为同一个名称可能出现两次:
1 2 3 4 5 6 7 8 package com.frank.mybatis.mapping;import java.util.List;public record PreparedSql (String sql, List<String> parameterNames) { public PreparedSql { if (sql == null || sql.isBlank()) throw new IllegalArgumentException ("SQL 为空" ); parameterNames = List.copyOf(parameterNames); } }
5.3 MappedStatement.java 1 2 3 4 5 6 7 8 9 10 11 12 13 package com.frank.mybatis.mapping;import java.util.Objects;public record MappedStatement (String id, SqlCommandType commandType, String rawSql, PreparedSql preparedSql, Class<?> resultType, boolean returnsMany) { public MappedStatement { Objects.requireNonNull(id); Objects.requireNonNull(commandType); Objects.requireNonNull(rawSql); Objects.requireNonNull(preparedSql); Objects.requireNonNull(resultType); } }
本篇的 id 固定为 Mapper 接口全限定名加方法名,例如 com.frank.mybatis.chapter01.UserMapper.findById。
5.4 SqlTemplateParser.java 这个解析器只接受简单参数名,拒绝 ${} 和非法的 #{}:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package com.frank.mybatis.mapping;import java.util.ArrayList;import java.util.List;import java.util.regex.*;public final class SqlTemplateParser { private static final Pattern TOKEN = Pattern.compile( "#\\{\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*}" ); private SqlTemplateParser () {} public static PreparedSql parse (String raw) { if (raw == null || raw.isBlank()) throw new IllegalArgumentException ("SQL 为空" ); if (raw.contains("${}" ) || raw.contains("${" )) throw new IllegalArgumentException ("第 01 篇不支持 ${}" ); Matcher m = TOKEN.matcher(raw); StringBuffer sql = new StringBuffer (); List <String> names = new ArrayList <>(); while (m.find()) { names.add(m.group(1 )); m.appendReplacement(sql, "?" ); } m.appendTail(sql); if (sql.indexOf("#{" ) >= 0 ) throw new IllegalArgumentException ("非法占位符" ); return new PreparedSql (sql.toString(), names); } }
验收: 为 select ... where id=#{ id } and age=#{age} 写测试,断言 SQL 是两个 ?,名称顺序是 [id, age];重复 #{id} 必须留下两个名称。再断言 ${column} 抛异常。
5.5 单元测试:参数顺序、重复参数与非法模板 文件:src/test/java/com/frank/mybatis/chapter01/SqlTemplateParserTest.java。修改原始名称列表不能改变已构造的元数据,这也是共享配置的前提。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package com.frank.mybatis.chapter01;import com.frank.mybatis.mapping.*;import java.util.ArrayList;import java.util.List;import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*;class SqlTemplateParserTest { @Test void preservesSqlOrderAndRepeatedNames () { PreparedSql sql = SqlTemplateParser.parse( "select * from t_user where id=#{ id } and age=#{age} or id=#{id}" ); assertEquals("select * from t_user where id=? and age=? or id=?" , sql.sql()); assertEquals(List.of("id" , "age" , "id" ), sql.parameterNames()); } @Test void rejectsUnsupportedAndMalformedTemplates () { for (String raw : List.of(" " , "select ${column}" , "select #{user.id}" , "select #{id" )) { assertThrows(IllegalArgumentException.class, () -> SqlTemplateParser.parse(raw), raw); } assertThrows(IllegalArgumentException.class, () -> SqlTemplateParser.parse(null )); } @Test void parameterNamesAreDefensivelyCopied () { var names = new ArrayList <>(List.of("id" )); PreparedSql sql = new PreparedSql ("select ?" , names); names.clear(); assertEquals(List.of("id" ), sql.parameterNames()); assertThrows(UnsupportedOperationException.class, () -> sql.parameterNames().clear()); } }
本节四个类型的关系一图流:解析器把模板文本变成 PreparedSql,MappedStatement 把它连同命令类型一起封装成不可变元数据——注册之后多个 Session 只读共享,谁也不会再改它。
6. Checkpoint 4:executor 的参数与结果处理 为什么需要这一步: 绑定与映射是 JDBC 里最容易写错的两段样板——占位符按它在 SQL 里的出现顺序对应下标,与 Java 形参顺序无关;列名要对上驼峰字段,NULL 不能落进基本类型。把它们收进一个组件并以 Executor 接口暴露有两个原因:其一,执行器只依赖 Connection、用完语句就关,事务边界完全留给上层;其二,有了接口,第 04 篇的缓存和第 05 篇的批处理才能换成别的实现而不动调用方。
依赖: PreparedSql、MappedStatement。目录: src/main/java/com/frank/mybatis/executor。
6.1 ParameterHandler.java 先将 Java 方法参数命名,再按 SQL 模板记录的顺序绑定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 package com.frank.mybatis.executor;import com.frank.mybatis.annotations.Param;import com.frank.mybatis.mapping.PreparedSql;import java.lang.reflect.*;import java.sql.PreparedStatement;import java.sql.SQLException;import java.util.*;public final class ParameterHandler { private ParameterHandler () {} public static Map <String,Object> resolve (Method method, Object[] args) { Parameter [] ps = method.getParameters(); Object [] actual = args == null ? new Object [0 ] : args; if (ps.length != actual.length) throw new IllegalArgumentException ("参数数量不匹配" ); Map <String,Object> values = new LinkedHashMap <>(); for (int i = 0 ; i < ps.length; i++) { Param p = ps[i].getAnnotation(Param.class); String name = p == null ? "arg" + i : p.value(); if (name.isBlank() || values.containsKey(name)) throw new IllegalArgumentException ("参数名为空或重复: " + name); values.put(name, actual[i]); } return values; } public static void bind (PreparedStatement ps, PreparedSql sql, Map<String,Object> values) throws SQLException { for (int i = 0 ; i < sql.parameterNames().size(); i++) { String name = sql.parameterNames().get(i); if (!values.containsKey(name)) throw new IllegalArgumentException ("未找到参数: " + name); ps.setObject(i + 1 , values.get(name)); } } }
containsKey 很重要:它区分“参数不存在”和“参数值就是 null”。
6.2 ResultSetHandler.java 用无参构造器创建对象,用列 label 映射字段;SQL NULL 写入包装类型时保持 null:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 package com.frank.mybatis.executor;import java.lang.reflect.*;import java.sql.*;import java.util.*;public final class ResultSetHandler { public <T> List <T> handle (ResultSet rs, Class<T> type) throws SQLException { Map <String,Field> fields = fieldsOf(type); ResultSetMetaData meta = rs.getMetaData(); List <T> result = new ArrayList <>(); while (rs.next()) { try { Constructor <T> c = type.getDeclaredConstructor(); c.setAccessible(true ); T target = c.newInstance(); for (int i = 1 ; i <= meta.getColumnCount(); i++) { Field f = fields.get(key(meta.getColumnLabel(i))); if (f == null ) throw new IllegalStateException ("没有对应字段: " + meta.getColumnLabel(i)); Object value = rs.getObject(i); if (value == null && f.getType().isPrimitive()) throw new IllegalStateException ("NULL 不能写入基本类型: " + f); f.set(target, value); } result.add(target); } catch (ReflectiveOperationException e) { throw new IllegalStateException ("创建结果对象失败: " + type.getName(), e); } } return result; } private Map <String,Field> fieldsOf (Class<?> type) { Map <String,Field> result = new HashMap <>(); for (Field f : type.getDeclaredFields()) { if (!Modifier.isStatic(f.getModifiers()) && !f.isSynthetic()) { f.setAccessible(true ); result.put(key(f.getName()), f); } } if (result.isEmpty()) throw new IllegalArgumentException ("结果类型没有字段" ); return result; } private String key (String value) { return value.replace("_" , "" ).toLowerCase(Locale.ROOT); } }
6.3 Executor.java Executor 只描述 JDBC 操作,不拥有事务边界:
1 2 3 4 5 6 7 8 9 package com.frank.mybatis.executor;import com.frank.mybatis.mapping.MappedStatement;import java.util.List;import java.util.Map;public interface Executor { <T> T queryOne (MappedStatement s, Map<String,Object> p, Class<T> type) ; <T> List <T> queryList (MappedStatement s, Map<String,Object> p, Class<T> type) ; int update (MappedStatement s, Map<String,Object> p) ; }
6.4 SimpleExecutor.java 核心实现可以先写成下面这样。它关闭 PreparedStatement 和 ResultSet,但绝不 commit 或 close Connection:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 package com.frank.mybatis.executor;import com.frank.mybatis.mapping.*;import java.sql.*;import java.util.*;public final class SimpleExecutor implements Executor { private final Connection connection; private final ResultSetHandler resultHandler = new ResultSetHandler (); public SimpleExecutor (Connection connection) { this .connection = connection; } public <T> T queryOne (MappedStatement s, Map<String,Object> p, Class<T> type) { List <T> rows = queryList(s, p, type); if (rows.isEmpty()) return null ; if (rows.size() > 1 ) throw new IllegalStateException ("selectOne 返回多行: " + s.id()); return rows.get(0 ); } public <T> List <T> queryList (MappedStatement s, Map<String,Object> p, Class<T> type) { require(s, SqlCommandType.SELECT); try (PreparedStatement ps = connection.prepareStatement(s.preparedSql().sql())) { ParameterHandler.bind(ps, s.preparedSql(), p); try (ResultSet rs = ps.executeQuery()) { return resultHandler.handle(rs, type); } } catch (SQLException e) { throw new IllegalStateException ("查询失败: " + s.id(), e); } } public int update (MappedStatement s, Map<String,Object> p) { if (s.commandType() == SqlCommandType.SELECT) throw new IllegalArgumentException ("SELECT 不能 update" ); try (PreparedStatement ps = connection.prepareStatement(s.preparedSql().sql())) { ParameterHandler.bind(ps, s.preparedSql(), p); return ps.executeUpdate(); } catch (SQLException e) { throw new IllegalStateException ("更新失败: " + s.id(), e); } } private void require (MappedStatement s, SqlCommandType expected) { if (s.commandType() != expected) throw new IllegalArgumentException ("命令类型不匹配" ); } }
验收: 给 rename(id,name) 配置 SQL set user_name=#{name} where id=#{id},确认第一个 JDBC 参数是 name 而不是 Java 参数下标 0;确认查询资源关闭且 Executor 没有提交事务。
6.5 组件测试:真实绑定、NULL 与单查边界 文件:src/test/java/com/frank/mybatis/chapter01/ExecutorContractTest.java。直接调用 Executor,失败时无需排查代理和 Session;H2 只作为 JDBC 适配边界。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package com.frank.mybatis.chapter01;import com.frank.mybatis.executor.*;import com.frank.mybatis.fixture.User;import com.frank.mybatis.mapping.*;import com.frank.mybatis.support.H2DatabaseSupport;import java.util.*;import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*;class ExecutorContractTest { private MappedStatement statement (String sql, SqlCommandType command) { return new MappedStatement ("test.statement" , command, sql, SqlTemplateParser.parse(sql), User.class, false ); } @Test void bindsBySqlOrderAndKeepsConnectionOpen () throws Exception { try (var c = H2DatabaseSupport.newDataSource().getConnection()) { c.setAutoCommit(false ); var executor = new SimpleExecutor (c); var insert = statement("insert into t_user values(#{id},#{name},#{age})" , SqlCommandType.INSERT); Map <String,Object> values = new LinkedHashMap <>(); values.put("age" , null ); values.put("name" , "Frank" ); values.put("id" , 1L ); assertEquals(1 , executor.update(insert, values)); var query = statement("select id,user_name,age from t_user where id=#{id}" , SqlCommandType.SELECT); User user = executor.queryOne(query, Map.of("id" , 1L ), User.class); assertEquals("Frank" , user.getUserName()); assertNull(user.getAge()); assertFalse(c.isClosed()); c.rollback(); assertNull(executor.queryOne(query, Map.of("id" , 1L ), User.class)); assertThrows(IllegalArgumentException.class, () -> executor.queryOne(query, Map.of(), User.class)); } } @Test void selectOneRejectsMultipleRows () throws Exception { try (var c = H2DatabaseSupport.newDataSource().getConnection(); var s = c.createStatement()) { s.executeUpdate("insert into t_user values(1,'A',20),(2,'B',21)" ); var query = statement("select id,user_name,age from t_user" , SqlCommandType.SELECT); var executor = new SimpleExecutor (c); assertEquals(2 , executor.queryList(query, Map.of(), User.class).size()); assertThrows(IllegalStateException.class, () -> executor.queryOne(query, Map.of(), User.class)); assertThrows(IllegalArgumentException.class, () -> executor.update(query, Map.of())); } } }
executor 包的分工收拢成一张图:SimpleExecutor 实现接口、指挥两个处理器干活,对 Connection 只用不拥有——语句开一条关一条,提交与归还都不过问。
7. Checkpoint 5:transaction,把 Connection 生命周期独立出来 为什么需要这一步: 谁借出连接,谁就要对提交、回滚、关闭负责。如果 Executor 直接持有 Connection 并随手 commit,连接池和外部事务管理就永远插不进来。把生命周期收进 Transaction 接口后,Executor 只“使用”连接而不“拥有”连接——第 05 篇把 new JdbcTransaction(dataSource) 换成事务工厂、连接池或 Spring 实现时,执行器一行都不用改。close() 做成幂等、关闭后拒绝访问,则是防止同一段业务代码把连接归还两次。
目录: src/main/java/com/frank/mybatis/transaction。依赖: JDK JDBC 与已有 H2。
7.1 Transaction.java 1 2 3 4 5 6 7 8 package com.frank.mybatis.transaction;import java.sql.Connection;public interface Transaction extends AutoCloseable { Connection getConnection () ; void commit () ; void rollback () ; void close () ; }
7.2 JdbcTransaction.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package com.frank.mybatis.transaction;import javax.sql.DataSource;import java.sql.*;public final class JdbcTransaction implements Transaction { private final Connection connection; private boolean closed; public JdbcTransaction (DataSource ds) { try { connection = ds.getConnection(); connection.setAutoCommit(false ); } catch (SQLException e) { throw new IllegalStateException ("打开事务失败" , e); } } public Connection getConnection () { requireOpen(); return connection; } public void commit () { execute("提交失败" , connection::commit); } public void rollback () { execute("回滚失败" , connection::rollback); } public void close () { if (!closed) { closed = true ; try { connection.close(); } catch (SQLException e) { throw new IllegalStateException ("关闭连接失败" , e); } } } private void execute (String message, SqlAction action) { requireOpen(); try { action.run(); } catch (SQLException e) { throw new IllegalStateException (message, e); } } private void requireOpen () { if (closed) throw new IllegalStateException ("事务已关闭" ); } @FunctionalInterface private interface SqlAction { void run () throws SQLException; } }
验收: 一个事务插入后 rollback,另一个连接查不到;commit 后能查到;close 后调用 getConnection() 抛 IllegalStateException。
7.3 组件测试:提交、回滚与重复关闭 文件:src/test/java/com/frank/mybatis/chapter01/JdbcTransactionTest.java。观察连接从事务外部读取,避免把“本连接可见”误当成提交成功。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package com.frank.mybatis.chapter01;import com.frank.mybatis.support.H2DatabaseSupport;import com.frank.mybatis.transaction.JdbcTransaction;import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*;class JdbcTransactionTest { @Test void commitPersistsAndRollbackDiscards () throws Exception { var ds = H2DatabaseSupport.newDataSource(); var tx = new JdbcTransaction (ds); var connection = tx.getConnection(); try (tx; var s = connection.createStatement()) { assertFalse(connection.getAutoCommit()); s.executeUpdate("insert into t_user values(1,'Committed',20)" ); tx.commit(); s.executeUpdate("insert into t_user values(2,'Rolled back',20)" ); tx.rollback(); } assertTrue(connection.isClosed()); assertDoesNotThrow(tx::close); assertThrows(IllegalStateException.class, tx::getConnection); assertThrows(IllegalStateException.class, tx::commit); try (var c = ds.getConnection(); var s = c.createStatement(); var rs = s.executeQuery("select id from t_user order by id" )) { assertTrue(rs.next()); assertEquals(1L , rs.getLong(1 )); assertFalse(rs.next()); } } }
transaction 包只有两个类型,关系也最简单:JdbcTransaction 构造时从 DataSource 借出连接并关掉自动提交,独占持有到 close() 归还为止——生命周期从头到尾都在它手里。
8. Checkpoint 6:session,组织一次业务会话 为什么需要这一步: 零件已经齐了,但还散着:Transaction 管一根连接的提交与归还,Executor 会执行语句却不知道“业务”为何物,MappedStatement 是一条条元数据却没人集中保管。而真实的一次业务操作往往是“多条语句 + 一个事务”——转账就是两条 update,要么都生效、要么都不生效。如果让调用方自己开连接、自己关自动提交、自己记得 commit、异常时自己回滚再关连接,样板和遗忘点会全部回来,框架就白写了。SqlSession 补上的正是这个“组织者”:打开它等于开始一次事务;执行语句时它按 statement id 查元数据、转交 Executor;关闭它时未提交的工作自动作废——忘 commit 顶多丢数据,绝不会留下半截事务弄脏数据库。
可以把这一层想象成一家银行网点,本节要写的三个角色各司其职:
类
扮演的角色
生命周期
Configuration
业务手册:数据库怎么连(DataSource)、每项业务的元数据(statement 注册表)
开店前编好,全店共用一份,营业中只读
SqlSessionFactory
网点大门:只做一件事——openSession() 发一个新窗口
长期存在
SqlSession
柜台窗口的一次办理:领连接开始,commit 确认或 close 作废结束
一次一换,用完即弃
一句话记住分工:手册全局一份、大门常开、窗口一次一换;Executor 只低头干活,Transaction 只管连接的生老病死,Session 是唯一对调用方说话的。 这也回答了“Configuration 为什么放在 session 包”:它是所有 Session 共享的注册表,数据源与元数据注册一次、只读共享,和短命的 Session 本来就是两种生命周期,不该搅在一个类里。
目录: src/main/java/com/frank/mybatis/session。本节新建 5 个文件,严格按“先造被依赖的”推进:注册表 Configuration → 会话契约 SqlSession → 实现 DefaultSqlSession → 大门 SqlSessionFactory 及其默认实现。上一节的 Transaction 与 Executor 只被组装,不被修改。
8.1 Configuration.java:先解决“元数据放哪” 第一个问题很具体:调用方手里只有一个字符串 id,而 Executor 只认 MappedStatement 对象,中间必须有一张查找表。所以第一个文件就是注册表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package com.frank.mybatis.session;import com.frank.mybatis.mapping.MappedStatement;import javax.sql.DataSource;import java.util.*;public final class Configuration { private final DataSource dataSource; private final Map <String, MappedStatement> statements = new HashMap <>(); public Configuration (DataSource dataSource) { this .dataSource = Objects.requireNonNull(dataSource); } public DataSource getDataSource () { return dataSource; } public void addMappedStatement (MappedStatement s) { if (statements.putIfAbsent(s.id(), s) != null ) throw new IllegalArgumentException ("重复 statement id: " + s.id()); } public MappedStatement getMappedStatement (String id) { MappedStatement s = statements.get(id); if (s == null ) throw new IllegalArgumentException ("未注册 statement: " + id); return s; } }
两个设计点用大白话说:注册用 putIfAbsent 而不是 put,重复 id 在注册期就报错——配置期发现问题永远比运行期便宜;getMappedStatement 查不到直接抛异常而不是返回 null——把错误拦在离原因最近的地方,别让 null 一路传到上层变成莫名其妙的空指针。
这里留一个口子:下一节引入 Mapper 动态代理时,会往这个文件追加 MapperRegistry 字段和 addMapper、getMapperRegistry 两个方法。本节先不写它们,保证你每敲完一个文件,项目都能编译。
8.2 SqlSession.java:再定会话对外“承诺”什么 注册表有了,接着定义组织者对调用方的承诺。接口方法分三组,读起来就是一次业务办理的动作清单——执行、确认或作废、离柜:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.frank.mybatis.session;import java.util.List;import java.util.Map;public interface SqlSession extends AutoCloseable { <T> T selectOne (String id, Map<String,Object> p, Class<T> type) ; <T> List <T> selectList (String id, Map<String,Object> p, Class<T> type) ; int insert (String id, Map<String,Object> p) ; int update (String id, Map<String,Object> p) ; int delete (String id, Map<String,Object> p) ; void commit () ; void rollback () ; void close () ; }
执行组的参数就是“statement id + 参数名到实参的 Map”,名字来自 @Param 或默认的 arg0,即第 6 节 ParameterHandler.resolve 的产出。你可能注意到接口里暂时没有 getMapper——那是下一节动态代理的门面,等 MapperRegistry 存在后再追加,本节不预留用不上的方法。
8.3 DefaultSqlSession.java:把已有零件编排成一次会话 本节的核心文件。它不发明新能力,只做编排:把前两步的 Configuration 和上一节的 Transaction、Executor 组装起来。构造函数一行就是整个故事——从 Transaction 手里拿到已经关掉自动提交的连接,交给 SimpleExecutor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 package com.frank.mybatis.session;import com.frank.mybatis.executor.*;import com.frank.mybatis.mapping.*;import com.frank.mybatis.transaction.Transaction;import java.util.*;public final class DefaultSqlSession implements SqlSession { private final Configuration configuration; private final Transaction transaction; private final Executor executor; private boolean closed; public DefaultSqlSession (Configuration c, Transaction t) { configuration = c; transaction = t; executor = new SimpleExecutor (t.getConnection()); } public <T> T selectOne (String id, Map<String,Object> p, Class<T> t) { return executor.queryOne(statement(id, SqlCommandType.SELECT), p, t); } public <T> List <T> selectList (String id, Map<String,Object> p, Class<T> t) { return executor.queryList(statement(id, SqlCommandType.SELECT), p, t); } public int insert (String id, Map<String,Object> p) { return executor.update(statement(id, SqlCommandType.INSERT), p); } public int update (String id, Map<String,Object> p) { return executor.update(statement(id, SqlCommandType.UPDATE), p); } public int delete (String id, Map<String,Object> p) { return executor.update(statement(id, SqlCommandType.DELETE), p); } public void commit () { requireOpen(); transaction.commit(); } public void rollback () { requireOpen(); transaction.rollback(); } public void close () { if (!closed) { closed = true ; try { transaction.rollback(); } finally { transaction.close(); } } } private MappedStatement statement (String id, SqlCommandType command) { requireOpen(); MappedStatement s = configuration.getMappedStatement(id); if (s.commandType() != command) throw new IllegalArgumentException ("命令类型不匹配: " + id); return s; } private void requireOpen () { if (closed) throw new IllegalStateException ("SqlSession 已关闭" ); } }
按方法组读一遍实现:
执行组的五个方法全部汇聚到私有方法 statement(id, command):先查注册表,再核对命令类型——拿 selectOne 的 id 指到一条 insert 上会立刻报错——然后才转交 Executor。Session 自己不碰 JDBC:开语句、绑参数、关资源都是 Executor 的职责,边界与第 6 节一致。insert、update、delete 在 Executor 眼里都是“影响行数的 update”,按注解语义区分是 Session 的事。
事务组只是把 Transaction 的 commit、rollback 透传出来,提交时机的决定权留给调用方。
close 的顺序固定为“先回滚、再关连接”:未 commit 的工作一律作废,这就是开头说的那道保险;回滚失败也要在 finally 里关连接,保证连接一定归还。requireOpen 让“关了还用”立刻失败,错误信息指向 Session 本身,而不是几层之下一个难懂的空指针。
8.4 SqlSessionFactory.java:谁负责造会话 没有它,调用方每次都要写 new DefaultSqlSession(configuration, new JdbcTransaction(dataSource))——Configuration 和 JdbcTransaction 都是不该暴露给业务代码的细节。先定义最小的造会话接口:
1 2 3 package com.frank.mybatis.session;public interface SqlSessionFactory { SqlSession openSession () ; }
8.5 DefaultSqlSessionFactory.java:长期持有手册,随开随发窗口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 package com.frank.mybatis.session;import com.frank.mybatis.transaction.JdbcTransaction;public final class DefaultSqlSessionFactory implements SqlSessionFactory { private final Configuration configuration; public DefaultSqlSessionFactory (Configuration configuration) { this .configuration = configuration; } public SqlSession openSession () { return new DefaultSqlSession (configuration, new JdbcTransaction (configuration.getDataSource())); } }
openSession() 一行就能读懂:用手册里的数据源新开一个事务(借连接 + 关自动提交),连同手册一起交给新窗口。Factory 长期持有 Configuration,每个 Session 只读引用它——正是“手册一份、窗口多个”的代码写照。
五个类全部就位,用一张图收拢它们的关系:深蓝是本节新建的 session 包,灰紫是被组装的已有零件;实线箭头是“创建 / 持有 / 调用”,空心三角是“实现接口”。对照代码看三条主干:Factory 开门时创建 Session(顺手 new JdbcTransaction);Session 构造时从 Transaction 拿连接、创建 Executor;Session 查手册、Executor 用连接,两条路径都汇回已有零件。
验收: mvn clean test 编译并通过;用工厂打开两个 Session,验证它们各自持有独立的 Connection;未 commit 就 close 的写入,换一个 Session 查不到。
8.6 组件测试:隔离、提交与关闭回滚(本节即可运行) 这个测试刻意不用下一节的 Mapper 接口:直接手工注册一条 insert、一条 select,用字符串 id 走完 Session → Executor → JDBC 的全链路。这顺便证明了一件事——Session 这层只关心“按 id 路由 + 管事务”,UserMapper 那样的接口代理是下一节才加上的糖。文件:src/test/java/com/frank/mybatis/chapter01/SessionContractTest.java。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 package com.frank.mybatis.chapter01;import com.frank.mybatis.fixture.User;import com.frank.mybatis.mapping.*;import com.frank.mybatis.session.*;import com.frank.mybatis.support.H2DatabaseSupport;import java.util.*;import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*;class SessionContractTest { private SqlSessionFactory factory () { Configuration c = new Configuration (H2DatabaseSupport.newDataSource()); c.addMappedStatement(stmt("chapter01.session.insert" , "insert into t_user(id,user_name,age) values(#{id},#{name},#{age})" , SqlCommandType.INSERT)); c.addMappedStatement(stmt("chapter01.session.findById" , "select id,user_name,age from t_user where id=#{id}" , SqlCommandType.SELECT)); return new DefaultSqlSessionFactory (c); } private MappedStatement stmt (String id, String sql, SqlCommandType command) { return new MappedStatement (id, command, sql, SqlTemplateParser.parse(sql), User.class, false ); } @Test void closeRollsBackUncommittedWork () { SqlSessionFactory f = factory(); try (SqlSession writer = f.openSession()) { assertEquals(1 , writer.insert("chapter01.session.insert" , Map.of("id" , 1L , "name" , "Pending" , "age" , 20 ))); } try (SqlSession reader = f.openSession()) { assertNull(reader.selectOne("chapter01.session.findById" , Map.of("id" , 1L ), User.class)); } } @Test void commitPersistsAcrossSessions () { SqlSessionFactory f = factory(); try (SqlSession writer = f.openSession()) { writer.insert("chapter01.session.insert" , Map.of("id" , 2L , "name" , "Kept" , "age" , 21 )); writer.commit(); } try (SqlSession reader = f.openSession()) { User u = reader.selectOne("chapter01.session.findById" , Map.of("id" , 2L ), User.class); assertEquals("Kept" , u.getUserName()); } } @Test void concurrentSessionsDoNotSeeUncommittedRows () { SqlSessionFactory f = factory(); try (SqlSession writer = f.openSession(); SqlSession observer = f.openSession()) { writer.insert("chapter01.session.insert" , Map.of("id" , 3L , "name" , "Hidden" , "age" , 22 )); assertNotNull(writer.selectOne("chapter01.session.findById" , Map.of("id" , 3L ), User.class)); assertNull(observer.selectOne("chapter01.session.findById" , Map.of("id" , 3L ), User.class)); } } @Test void closedSessionRejectsFurtherCalls () { SqlSession s = factory().openSession(); s.close(); assertThrows(IllegalStateException.class, () -> s.selectOne("chapter01.session.findById" , Map.of("id" , 1L ), User.class)); assertThrows(IllegalStateException.class, s::commit); } }
四个用例分别钉死四条会话语义:关闭即回滚(忘了 commit 也不脏库)、commit 之后跨会话可见、两个同时打开的会话互相看不见对方未提交的行(H2 默认 READ COMMITTED)、关闭后继续调用快速失败。
到这里,调用方的代码已经从“手工编排连接和事务”变成 factory.openSession() → session.insert(id, params) → session.close() 三句话。剩下的痛点是调用方还在手写字符串 id 和参数 Map——下一节 Checkpoint 7 用 JDK 动态代理把这层样板也吸收掉,getMapper 就是在那时回到 SqlSession 接口上的。
9. Checkpoint 7:binding 三件套和 builder 为什么需要这一步: 上一节结束时留下了一个痛点:调用方要写 session.selectOne("com.frank.mybatis.chapter01.UserMapper.findById", Map.of("id", 1L), User.class)——字符串 id、手工组 Map、结果强转,每一样都是出错点。调用方想要的是 userMapper.findById(1L)。接口加 JDK 动态代理把这层样板全部吸收掉。代理内不扫描注解、不拼 id、不创建连接,是因为这些反射成本只在注册期由 builder 付一次;运行期的每次调用只是一次查表转发。
依赖: 前面所有类型。目标: 注册 Mapper 时解析一次,运行时代理只路由,不扫描注解、不创建连接。 本节产物仍按“先造被依赖的”推进:先写代理本身(MapperProxy),再写造代理的工厂(MapperProxyFactory),然后是注册期的注解解析器(MapperAnnotationBuilder)、把两者串起来的注册表(MapperRegistry),接着回填上一节在 Configuration 与 SqlSession 留下的口子,最后用一个真实 Mapper 和测试验证整条链。
9.1 binding/MapperProxy.java:先写转发器本身 代理是“被别人用”的零件,而且只依赖已有类型,所以最先建。它的 invoke 只做三件事:兜住 Object 自带方法、按“接口全限定名 + 方法名”拼出 id 并查元数据、把实参交给 ParameterHandler.resolve 后按命令类型转发给 Session:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 package com.frank.mybatis.binding;import com.frank.mybatis.executor.ParameterHandler;import com.frank.mybatis.mapping.MappedStatement;import com.frank.mybatis.session.*;import java.lang.reflect.*;import java.util.Map;public final class MapperProxy implements InvocationHandler { private final SqlSession session; private final Configuration configuration; private final Class<?> mapperType; public MapperProxy (SqlSession s, Configuration c, Class<?> type) { session = s; configuration = c; mapperType = type; } public Object invoke (Object proxy, Method method, Object[] args) { if (method.getDeclaringClass() == Object.class) { return switch (method.getName()) { case "toString" -> "MapperProxy(" + mapperType.getName() + ")" ; case "hashCode" -> System.identityHashCode(proxy); case "equals" -> proxy == args[0 ]; default -> throw new IllegalStateException ("不支持 Object 方法" ); }; } String id = mapperType.getName() + "." + method.getName(); MappedStatement s = configuration.getMappedStatement(id); Map <String,Object> p = ParameterHandler.resolve(method, args); return switch (s.commandType()) { case SELECT -> s.returnsMany() ? session.selectList(id, p, s.resultType()) : session.selectOne(id, p, s.resultType()); case INSERT -> session.insert(id, p); case UPDATE -> session.update(id, p); case DELETE -> session.delete(id, p); }; } }
三件事之外它刻意什么都不做:不扫注解(注册期已解析完)、不创建连接(Session 管事务)、不碰 JDBC(Executor 的活)。toString、equals、hashCode 必须特殊处理——不拦截的话,对代理调用 equals 会再次进入 invoke,无限递归。
9.2 binding/MapperProxyFactory.java:把“造代理”收进工厂 Proxy.newProxyInstance 需要类加载器、接口数组和 handler 三样参数,对同一个 Mapper 接口来说每次都一样,收进泛型工厂:
1 2 3 4 5 6 7 8 9 10 11 12 13 package com.frank.mybatis.binding;import com.frank.mybatis.session.*;import java.lang.reflect.Proxy;public final class MapperProxyFactory <T> { private final Class<T> mapperType; private final Configuration configuration; public MapperProxyFactory (Class<T> type, Configuration c) { mapperType = type; configuration = c; } public T newInstance (SqlSession session) { Object proxy = Proxy.newProxyInstance(mapperType.getClassLoader(), new Class <?>[]{mapperType}, new MapperProxy (session, configuration, mapperType)); return mapperType.cast(proxy); } }
newInstance(session) 每次传入不同的 Session,产出绑定到该会话的代理——同一个 Mapper 接口在每个会话里各有一个实例。
9.3 builder/MapperAnnotationBuilder.java:注册期解析注解 代理在运行期不扫注解,前提是注册期有人把注解解析成 MappedStatement 存进 Configuration,这就是 builder 的职责。构建器负责唯一注解、SQL 解析、参数名称和返回形状检查。为保持本篇边界,它拒绝 default、static 和重载方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 package com.frank.mybatis.builder;import com.frank.mybatis.annotations.*;import com.frank.mybatis.executor.ParameterHandler;import com.frank.mybatis.mapping.*;import com.frank.mybatis.session.Configuration;import java.lang.reflect.*;import java.util.*;public final class MapperAnnotationBuilder { private final Configuration configuration; private final Class<?> mapperType; public MapperAnnotationBuilder (Configuration c, Class<?> type) { configuration = c; mapperType = type; } public void parse () { Set <String> names = new HashSet <>(); for (Method m : mapperType.getDeclaredMethods()) { if (m.isDefault() || Modifier.isStatic(m.getModifiers()) || !names.add(m.getName())) throw new IllegalArgumentException ("不支持 default、static 或重载: " + m); parseMethod(m); } } private void parseMethod (Method m) { SqlCommandType command = commandOf(m); String raw = switch (command) { case SELECT -> m.getAnnotation(Select.class).value(); case INSERT -> m.getAnnotation(Insert.class).value(); case UPDATE -> m.getAnnotation(Update.class).value(); case DELETE -> m.getAnnotation(Delete.class).value(); }; PreparedSql prepared = SqlTemplateParser.parse(raw); Map <String,Object> declared = ParameterHandler.resolve(m, new Object [m.getParameterCount()]); for (String name : prepared.parameterNames()) if (!declared.containsKey(name)) throw new IllegalArgumentException ("SQL 参数未声明: " + name); boolean many = false ; Class<?> resultType; if (command == SqlCommandType.SELECT) { if (m.getReturnType() == List.class) { Type t = m.getGenericReturnType(); if (!(t instanceof ParameterizedType p) || !(p.getActualTypeArguments()[0 ] instanceof Class<?>)) throw new IllegalArgumentException ("列表查询必须是 List<具体类型>" ); resultType = (Class<?>) ((ParameterizedType) t).getActualTypeArguments()[0 ]; many = true ; } else { resultType = m.getReturnType(); if (resultType.isPrimitive() || resultType.isInterface() || resultType == Object.class) throw new IllegalArgumentException ("查询必须返回具体 POJO" ); } } else { if (m.getReturnType() != int .class) throw new IllegalArgumentException ("写方法必须返回 int" ); resultType = Void.class; } String id = mapperType.getName() + "." + m.getName(); configuration.addMappedStatement(new MappedStatement (id, command, raw, prepared, resultType, many)); } private SqlCommandType commandOf (Method m) { int count = (m.isAnnotationPresent(Select.class) ? 1 : 0 ) + (m.isAnnotationPresent(Insert.class) ? 1 : 0 ) + (m.isAnnotationPresent(Update.class) ? 1 : 0 ) + (m.isAnnotationPresent(Delete.class) ? 1 : 0 ); if (count != 1 ) throw new IllegalArgumentException ("必须且只能有一个 SQL 注解: " + m); if (m.isAnnotationPresent(Select.class)) return SqlCommandType.SELECT; if (m.isAnnotationPresent(Insert.class)) return SqlCommandType.INSERT; if (m.isAnnotationPresent(Update.class)) return SqlCommandType.UPDATE; return SqlCommandType.DELETE; } }
9.4 binding/MapperRegistry.java:把解析和工厂串成注册表 它同时使用 9.3 的 builder 和 9.2 的工厂,所以放在两者之后。addMapper 的顺序是“先解析、解析通过才登记”,注册失败的接口不会留下半套元数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package com.frank.mybatis.binding;import com.frank.mybatis.builder.MapperAnnotationBuilder;import com.frank.mybatis.session.*;import java.util.*;public final class MapperRegistry { private final Configuration configuration; private final Map <Class<?>,MapperProxyFactory<?>> factories = new HashMap <>(); public MapperRegistry (Configuration c) { configuration = c; } public <T> void addMapper (Class<T> type) { if (!type.isInterface()) throw new IllegalArgumentException ("Mapper 必须是接口" ); if (factories.containsKey(type)) throw new IllegalArgumentException ("Mapper 已注册" ); new MapperAnnotationBuilder (configuration, type).parse(); factories.put(type, new MapperProxyFactory <>(type, configuration)); } public <T> T getMapper (Class<T> type, SqlSession session) { MapperProxyFactory <?> factory = factories.get(type); if (factory == null ) throw new IllegalArgumentException ("Mapper 未注册: " + type.getName()); @SuppressWarnings("unchecked") MapperProxyFactory <T> typed = (MapperProxyFactory<T>) factory; return typed.newInstance(session); } }
9.5 接线:回填上一节预留的口子 binding 四件套就位,现在把代理接进 Session。上一节的 Configuration 与 SqlSession 各留了口子,此刻回填,共改三个文件;接口和实现必须同一步改,否则项目编译不过。
Configuration.java 追加(新增 import com.frank.mybatis.binding.MapperRegistry;,字段放在 statements 旁边,两个方法加在类尾):
1 2 3 4 5 private final MapperRegistry mapperRegistry = new MapperRegistry (this );public <T> void addMapper (Class<T> type) { mapperRegistry.addMapper(type); }public MapperRegistry getMapperRegistry () { return mapperRegistry; }
SqlSession.java 接口追加一个方法,DefaultSqlSession.java 同步追加实现:
1 2 3 4 5 6 7 8 <T> T getMapper (Class<T> type) ; public <T> T getMapper (Class<T> type) { requireOpen(); return configuration.getMapperRegistry().getMapper(type, this ); }
回填之后,注册入口是 configuration.addMapper(...),取代理入口是 session.getMapper(...)。
验收: 完成下两小节后 mvn clean test 通过;configuration.addMapper(UserMapper.class) 能注册,而故意写错 #{naem}、加两个 SQL 注解、注册两次或声明重载时,错误应在注册期抛出,而不是等到请求期间才暴露。
9.6 chapter01/UserMapper.java:第一个真实 Mapper 验证注册与代理需要一个真实的 Mapper 接口。它放在测试目录(src/test/java/com/frank/mybatis/chapter01/UserMapper.java),第 10 节的端到端测试继续用它:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.frank.mybatis.chapter01;import com.frank.mybatis.annotations.*;import com.frank.mybatis.fixture.User;import java.util.List;public interface UserMapper { @Insert("insert into t_user(id,user_name,age) values(#{id},#{name},#{age})") int insert (@Param("id") Long id, @Param("name") String name, @Param("age") Integer age) ; @Select("select id,user_name,age from t_user where id=#{id}") User findById (@Param("id") Long id) ; @Select("select id,user_name,age from t_user where age >= #{minAge} order by id") List <User> findByMinimumAge (@Param("minAge") Integer minAge) ; @Update("update t_user set user_name=#{name} where id=#{id}") int rename (@Param("id") Long id, @Param("name") String name) ; @Delete("delete from t_user where id=#{arg0}") int deleteById (Long id) ; }
rename 刻意让 SQL 顺序与 Java 参数顺序不同;deleteById 刻意不写 @Param,验证默认名称 arg0。
9.7 组件测试:注册校验与代理行为(本节即可运行) 文件:src/test/java/com/frank/mybatis/chapter01/MapperBindingTest.java。不向后借夹具,注册边界与代理行为当场验证;注册失败的 Configuration 上不要继续装配,所以重复注册等用例各自新建 Configuration:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 package com.frank.mybatis.chapter01;import com.frank.mybatis.fixture.User;import com.frank.mybatis.session.*;import com.frank.mybatis.support.H2DatabaseSupport;import org.junit.jupiter.api.Test;import javax.sql.DataSource;import java.util.List;import static org.junit.jupiter.api.Assertions.*;class MapperBindingTest { @Test void registrationRejectsDuplicatesAndUnknownStatements () { Configuration c = new Configuration (H2DatabaseSupport.newDataSource()); c.addMapper(UserMapper.class); assertThrows(IllegalArgumentException.class, () -> c.addMapper(UserMapper.class)); assertThrows(IllegalArgumentException.class, () -> c.addMapper(User.class)); assertThrows(IllegalArgumentException.class, () -> c.getMappedStatement("missing.id" )); var statement = c.getMappedStatement(UserMapper.class.getName() + ".rename" ); assertEquals(List.of("name" , "id" ), statement.preparedSql().parameterNames()); } @Test void proxyObjectMethodsDoNotExecuteSql () { DataSource ds = H2DatabaseSupport.newDataSource(); Configuration c = new Configuration (ds); c.addMapper(UserMapper.class); try (SqlSession s = new DefaultSqlSessionFactory (c).openSession()) { UserMapper first = s.getMapper(UserMapper.class); UserMapper second = s.getMapper(UserMapper.class); assertTrue(first.equals(first)); assertFalse(first.equals(second)); assertFalse(first.equals(null )); assertEquals(System.identityHashCode(first), first.hashCode()); assertTrue(first.toString().contains(UserMapper.class.getName())); } } }
binding 与 builder 的协作一图收拢:注册期由 MapperRegistry 牵头——用 builder 解析注解、把生成的语句登记进 Configuration,再把代理工厂存起来;运行期 MapperProxy 只做查表转发,注解扫描、连接创建都和它无关。深蓝为本节新建,灰紫为引用的已有类。
10. Checkpoint 8:端到端测试,走通全链路 为什么需要这一步: 前面七个 checkpoint 验证的都是单个组件的契约,还没有任何代码证明这些层拼在一起真的能工作。端到端测试用上一节就位的 UserMapper 走完 代理 → Session → Executor → JDBC → 结果映射 的全链路,并专门覆盖最容易被边界条件坑掉的场景:参数乱序、NULL、commit/rollback 的可见性、关闭后的调用。它锁住的不是某个类,而是层与层之间的契约——这正是 MyBatis 相对于裸 JDBC 的全部价值所在。
目录: src/test/java/com/frank/mybatis/chapter01。这里不引入新依赖,直接使用项目 pom.xml 中已有的 H2 和 JUnit。
10.1 chapter01/MiniMybatisChapter01Test.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package com.frank.mybatis.chapter01;import com.frank.mybatis.fixture.User;import com.frank.mybatis.session.*;import com.frank.mybatis.support.H2DatabaseSupport;import org.junit.jupiter.api.*;import javax.sql.DataSource;import java.util.List;import static org.junit.jupiter.api.Assertions.*;class MiniMybatisChapter01Test { private SqlSessionFactory factory; @BeforeEach void setUp () { DataSource ds = H2DatabaseSupport.newDataSource(); Configuration c = new Configuration (ds); c.addMapper(UserMapper.class); factory = new DefaultSqlSessionFactory (c); } @Test void crudAndCommit () { try (SqlSession s = factory.openSession()) { UserMapper m = s.getMapper(UserMapper.class); assertEquals(1 , m.insert(1L , "Frank" , 25 )); assertEquals(1 , m.insert(2L , "Alice" , null )); assertEquals(1 , m.rename(1L , "Frank Updated" )); assertEquals("Frank Updated" , m.findById(1L ).getUserName()); assertNull(m.findById(2L ).getAge()); List <User> users = m.findByMinimumAge(18 ); assertEquals(1 , users.size()); assertEquals(1 , m.deleteById(2L )); s.commit(); } try (SqlSession s = factory.openSession()) { UserMapper m = s.getMapper(UserMapper.class); assertEquals("Frank Updated" , m.findById(1L ).getUserName()); assertNull(m.findById(2L )); } } @Test void rollbackHidesWriteFromNextSession () { try (SqlSession s = factory.openSession()) { assertEquals(1 , s.getMapper(UserMapper.class).insert(3L , "Rollback" , 20 )); s.rollback(); } try (SqlSession s = factory.openSession()) { assertNull(s.getMapper(UserMapper.class).findById(3L )); } } @Test void closedSessionRejectsMapperCall () { SqlSession s = factory.openSession(); UserMapper m = s.getMapper(UserMapper.class); s.close(); assertThrows(IllegalStateException.class, () -> m.findById(1L )); } }
验收: mvn clean test 全部通过。这个测试同时覆盖插入、单查、列表、更新、删除、user_name -> userName、NULL、参数乱序、commit、rollback 和 Session 关闭边界。
11. 完成后的目录树与调用复盘 为什么需要这一步: 写完不等于理解。把目录树和”注册期一次、调用期一次”的执行顺序各自串一遍,后面四篇在任意一层插入新能力(XML 解析、动态 SQL、缓存、批处理)时,你才判断得出改动落在哪个时机、会波及哪些边界。
完成本篇后,新增文件应是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 src/main/java/com/frank/mybatis/ ├── MiniMybatisApplication.java # 已有,保持不动 ├── annotations/{Select,Insert,Update,Delete,Param}.java ├── mapping/{SqlCommandType,PreparedSql,MappedStatement,SqlTemplateParser}.java ├── executor/{ParameterHandler,ResultSetHandler,Executor,SimpleExecutor}.java ├── transaction/{Transaction,JdbcTransaction}.java ├── session/{Configuration,SqlSession,DefaultSqlSession, │ SqlSessionFactory,DefaultSqlSessionFactory}.java ├── binding/{MapperProxy,MapperProxyFactory,MapperRegistry}.java └── builder/MapperAnnotationBuilder.java src/test/java/com/frank/mybatis/ ├── fixture/User.java ├── support/H2DatabaseSupport.java └── chapter01/ ├── JdbcBaselineTest.java # Checkpoint 1 ├── AnnotationContractTest.java # Checkpoint 2 ├── SqlTemplateParserTest.java # Checkpoint 3 ├── ExecutorContractTest.java # Checkpoint 4 ├── JdbcTransactionTest.java # Checkpoint 5 ├── SessionContractTest.java # Checkpoint 6 ├── MapperBindingTest.java # Checkpoint 7 ├── UserMapper.java # Checkpoint 7 引入,Checkpoint 8 复用 └── MiniMybatisChapter01Test.java # Checkpoint 8
一次 addMapper 的注册期顺序是:MapperRegistry 检查接口和重复注册,MapperAnnotationBuilder 读取方法注解,SqlTemplateParser 生成 PreparedSql,构建器校验参数和返回值,最后 Configuration 保存 MappedStatement。 一次方法调用的运行期顺序是:代理计算 statement id,ParameterHandler 把实参变成名称到值的 Map,Session 按命令类型调用 Executor,Executor 创建并关闭 JDBC 语句,结果交给 ResultSetHandler。Executor 不 commit,Session 不解析注解,代理不创建 Connection,各层边界因此清楚。 常见故障可以按层定位:
现象
先检查
注册时提示未声明参数
@Param 名称与 #{} 是否完全一致
H2 报参数数量错误
PreparedSql.parameterNames() 与 SQL ? 顺序
userName 为空
查询是否返回 user_name,POJO 是否有无参构造器
新 Session 查不到写入
是否调用 session.commit()
关闭后仍想调用 Mapper
代理绑定的 Session 已经失效
多行却调用单查
改用 List<User> 或收紧 SQL 条件
12. 与真实 MyBatis 的对照,以及第 01 篇的止步处 为什么需要这一步: 手写的意义不是替代 MyBatis,而是建立对照。知道每个手写类对应官方框架的哪个组件,将来读真实源码或排查线上问题时才有地图;同时明确本篇止步在哪里,”不做”的每一项才会在后续篇章里变成”为什么值得做”。
本篇文件
真实 MyBatis 中接近的概念
Configuration、MapperRegistry
全局配置与 Mapper 注册表
MapperAnnotationBuilder
注解 Mapper 构建器
MapperProxy
MapperProxy 与 MapperMethod
MappedStatement、PreparedSql
MappedStatement、SqlSource、BoundSql 的简化组合
ParameterHandler
DefaultParameterHandler 与 TypeHandlerRegistry 的很小子集
SimpleExecutor
Executor、StatementHandler 的合并教学实现
ResultSetHandler
DefaultResultSetHandler、ResultMap 的最小映射
JdbcTransaction
JDBC 事务实现
本篇故意不支持 XML、动态 SQL、${}、缓存、批量、关联对象、继承 Mapper、生成主键和外部事务。尤其不要把 ${} 当作更方便的 #{}:它是文本替换,若接收外部输入就会把 SQL 注入风险带进来。
到这里,读者已经亲手实现了“配置期失败、调用期路由、执行期绑定、结果期映射、会话期提交”的主干。下一篇解决的是配置的组织问题:SQL 全部堆在 Java 注解里并不好维护,MyBatis 的答案是 XML Mapper 与统一的 statement id——XML 与注解在启动期注册成同一种 MappedStatement,运行期代理只按字符串 id 路由。再往后(第 03 篇)才会把固定的 PreparedSql 拆成按调用生成的 SqlSource 与 BoundSql,届时 #{} 占位符、<if>/<foreach> 与 TypeHandler 会有各自清晰的职责边界。
参考:
系列导航:本篇是第 1 篇 | 下一篇:手写 MyBatis 02:XML、注解与 Statement ID,统一配置模型