在 Java 后端开发这条路上,MyBatis 几乎是无处不在的标配。但很多人用了一阵子之后都会有同样的困惑:为什么定义一个接口,就能自动执行 SQL?为什么 XML 里的 SQL 能变成带占位符的预编译语句?带着这些疑问,我决定自己动手写一个迷你版的自定义 MyBatis——不依赖 Spring、不依赖任何 ORM 框架,只靠 JDK 原生能力加上数据库驱动,从零复刻 MyBatis 核心执行链路。这个过程让我彻底把 SqlSession、Mapper 代理、参数绑定、结果集映射这些概念串起来了。这篇内容就是我的完整实现记录,适合那些已经会用 MyBatis、但想深入理解底层原理的开发者,也适合想在公司内部做轻量级持久层封装的同学。
1. 设计一个自定义 MyBatis,先想清楚要解决什么问题
1.1 不是重复造轮子,而是把“黑盒”变成“白盒”
很多开发者对 MyBatis 的认知停留在“写接口、写 XML、注入调用”的层面,真正问到“接口没有实现类,为什么能调用?”时,往往答不上来。我一开始也是这样,直到有一天线上出现了一个诡异的问题:一条 SQL 在数据库客户端执行只需要 5 毫秒,通过 MyBatis 执行却要 200 毫秒。排查到最后发现是二级缓存没失效,某个查询结果被长事务一直持有。这种问题如果不懂框架内部的执行路径,定位起来非常痛苦。
正是这些经历让我决定亲手实现一个自定义 MyBatis。我不打算做一个生产级框架,而是把 MyBatis 最核心的那条执行链路完整复刻一遍:XML 解析、SQL 占位符替换、动态代理生成 Mapper、JDBC 参数绑定、结果集自动映射。把这些环节全部打通之后,再回头看 MyBatis 源码,基本几行就能猜到它下一步要做什么。这个过程不是重复造轮子,而是把黑盒变成白盒,是理解 ORM 底层原理最高效的方式。
1.2 一个迷你框架至少要具备哪些能力
既然是自定义 MyBatis,就不能只是写一个“JDBC 工具类”。工具类解决的是一次性操作,而框架解决的是“约定优于配置”的模式化问题。我的目标很明确:把“接口方法 + XML SQL”转换成“执行 SQL 并返回对象”,并且让调用方感知不到 JDBC 的存在。
我给自己定义了一个最小功能集,列出来供你参考:
| 模块 | 能力要求 | 对应原生 MyBatis 概念 |
|---|---|---|
| 配置加载 | 解析数据库连接信息和 Mapper 文件 | Configuration / XMLConfigBuilder |
| 映射定义 | 保存 SQL 文本、参数类型、返回类型 | MappedStatement / SqlSource |
| 代理工厂 | 为 Mapper 接口生成代理对象 | MapperProxyFactory / MapperProxy |
| SQL 解析 | 将#{}替换为?并记录参数名 | ParameterMapping / SqlSourceBuilder |
| 会话门面 | 提供查询、新增、修改、删除的统一入口 | SqlSession / DefaultSqlSession |
| 参数处理 | 把方法参数绑定到 PreparedStatement | ParameterHandler |
| 结果映射 | 把 ResultSet 数据反射到实体对象 | ResultSetHandler |
| 类型转换 | 在 JDBC 类型和 Java 类型之间转换 | TypeHandler |
这些模块看起来多,实际实现起来并不复杂。核心难点在于它们之间的协作顺序。只要按照“解析配置 -> 构建映射 -> 生成代理 -> 执行 SQL -> 映射结果”这条主线去组织代码,整个框架的结构会非常清晰。
2. 拆解原生执行链路,确定核心组件
2.1 从接口方法到 SQL 执行,中间隔了几层
先梳理一次普通查询的完整执行过程。你用userMapper.selectById(1)调用时,发生的事远比你想象得多:
第一层是 SqlSessionFactory。它读取配置文件,构建 Configuration 对象,这个对象是全局配置中心,保存了所有 Mapper 映射信息。
第二层是 SqlSession。它是面向开发者的门面,提供了selectOne、selectList、insert、update、delete等方法。真正的执行动作并不在 SqlSession 里,而是转发给 Executor。
第三层是 Executor。这是执行器,负责管理 StatementHandler、处理一级缓存、控制事务提交。不过在迷你框架里,我会把 Executor 和 StatementHandler 合并简化,重点突出流程。
第四层是动态代理。Mapper 接口没有实现类,但框架通过 JDK 动态代理在运行时生成一个代理对象。当你调用接口方法时,代理对象会拦截方法调用,解析出对应的方法签名和 SQL 映射关系,然后调用 SqlSession 的相应方法。
第五层是 JDBC 与数据库的交互。StatementHandler 会把 SQL 交给 PreparedStatement,通过 ParameterHandler 设置参数,执行后由 ResultSetHandler 处理结果集。
如果用一个生活类比来解释,SqlSession 就像餐厅的前台,Executor 是后厨的调度员,动态代理是服务员手上的点菜平板,JDBC 则是厨房里的锅灶。你只需要告诉服务员“我要吃什么”,后面所有环节都不用关心。
2.2 核心组件对照表
下面这张表是我在设计迷你框架时梳理出来的核心组件,每个组件都有明确的职责边界。对比原生 MyBatis,你会发现它的命名和职责其实非常直白:
| 组件 | 职责 | 原生对应 |
|---|---|---|
| Configuration | 全局配置中心,保存数据库连接信息、Mapper 注册表、MappedStatement 集合 | org.apache.ibatis.session.Configuration |
| MapperRegistry | 记录哪些接口需要生成代理对象 | org.apache.ibatis.binding.MapperRegistry |
| MappedStatement | 封装一条 SQL 的元数据,包括 id、sql 文本、参数类型、返回类型 | org.apache.ibatis.mapping.MappedStatement |
| SqlSession | 对外统一操作入口,持有 Configuration 和 Executor | org.apache.ibatis.session.SqlSession |
| Executor | 执行 SQL,管理 StatementHandler | org.apache.ibatis.executor.Executor |
| StatementHandler | 创建 PreparedStatement,执行 SQL | org.apache.ibatis.executor.statement.StatementHandler |
| ParameterHandler | 为 SQL 中的占位符赋值 | org.apache.ibatis.scripting.defaults.DefaultParameterHandler |
| ResultSetHandler | 将 ResultSet 转为对象列表 | org.apache.ibatis.executor.resultset.DefaultResultSetHandler |
刚开始设计时最容易犯的错误是“把所有逻辑都塞进 SqlSession”。这样虽然能跑通,但后续扩展会越来越痛苦。比如以后想加缓存,就得侵入所有方法;想支持插件,也无从下手。所以哪怕是一个迷你框架,也值得分出清晰的模块边界。
3. 配置解析:让 XML 成为可执行的 MappedStatement
3.1 定义配置中心和映射信息模型
第一步是定义 Configuration 类。它是整个框架的“大脑”,所有解析出来的配置信息都会放到这里。我保留了最核心的字段:数据库连接四要素、Mapper 接口注册表、以及从 XML 解析出的 MappedStatement 集合。
public class Configuration { private String driver; private String url; private String username; private String password; /** Mapper 接口 Class -> 代理工厂 */ private final Map<Class<?>, MapperProxyFactory<?>> mapperRegistry = new HashMap<>(); /** statement id -> MappedStatement */ private final Map<String, MappedStatement> mappedStatements = new HashMap<>(); private final List<TypeHandler<?>> typeHandlers = new ArrayList<>(); public void addMapper(Class<?> type) { mapperRegistry.put(type, new MapperProxyFactory<>(type)); } public void addMappedStatement(MappedStatement ms) { mappedStatements.put(ms.getId(), ms); } public MappedStatement getMappedStatement(String id) { return mappedStatements.get(id); } @SuppressWarnings("unchecked") public <T> T getMapper(Class<T> type, SqlSession sqlSession) { MapperProxyFactory<?> factory = mapperRegistry.get(type); if (factory == null) { throw new RuntimeException("Mapper not registered: " + type.getName()); } return (T) factory.newInstance(sqlSession); } // getter/setter 省略 }MappedStatement 是另一个关键模型。它不保存真正的连接信息,只保存一条 SQL 对应的元数据:
public class MappedStatement { private String id; // 全限定名: com.demo.mapper.UserMapper.selectById private String sqlText; // 原始 SQL,可能包含 #{xxx} private String parameterType; // 参数类型全限定名 private String resultType; // 返回类型全限定名 private List<ParameterMapping> parameterMappings; // 解析后的参数映射列表 public MappedStatement() {} public String getBoundSql() { return sqlText; } // getter/setter 省略 }ParameterMapping 用来记录 SQL 中每个#{}参数的位置和名称,后续参数绑定阶段会用到。
public class ParameterMapping { private int index; private String propertyName; private Class<?> javaType; public ParameterMapping(int index, String propertyName, Class<?> javaType) { this.index = index; this.propertyName = propertyName; this.javaType = javaType; } // getter/setter 省略 }3.2 用 JDK 原生的 DOM 解析 XML
既然叫自定义 MyBatis,我就尽量不引入第三方依赖。XML 解析直接用 JDK 自带的javax.xml.parsers.DocumentBuilderFactory,虽然代码风格上比 DOM4J 或 XPath 啰嗦,但好处是零依赖、逻辑可控。
解析流程分两步:先解析主配置文件,拿到数据库连接信息和 Mapper 文件路径;再逐个解析 Mapper XML 文件,提取 SQL 和参数信息。
public class XMLConfigBuilder { public Configuration parse(InputStream inputStream) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(inputStream); Configuration configuration = new Configuration(); // 读取数据源配置 NodeList envList = document.getElementsByTagName("dataSource"); if (envList.getLength() > 0) { Element dataSource = (Element) envList.item(0); NodeList props = dataSource.getElementsByTagName("property"); for (int i = 0; i < props.getLength(); i++) { Element prop = (Element) props.item(i); String name = prop.getAttribute("name"); String value = prop.getAttribute("value"); if ("driver".equals(name)) configuration.setDriver(value); if ("url".equals(name)) configuration.setUrl(value); if ("username".equals(name)) configuration.setUsername(value); if ("password".equals(name)) configuration.setPassword(value); } } // 读取 mapper 映射文件并解析 NodeList mapperList = document.getElementsByTagName("mapper"); for (int i = 0; i < mapperList.getLength(); i++) { Element mapper = (Element) mapperList.item(i); String resource = mapper.getAttribute("resource"); try (InputStream mapperStream = Resources.getResourceAsStream(resource)) { XMLMapperBuilder mapperBuilder = new XMLMapperBuilder(configuration); mapperBuilder.parse(mapperStream); } } return configuration; } }解析 Mapper XML 时,关键是把每个<select>、<insert>、<update>、<delete>节点解析成一个 MappedStatement,同时读取 namespace 作为 statement id 的一部分。
public class XMLMapperBuilder { private final Configuration configuration; public XMLMapperBuilder(Configuration configuration) { this.configuration = configuration; } public void parse(InputStream inputStream) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(inputStream); Element root = document.getDocumentElement(); String namespace = root.getAttribute("namespace"); NodeList selectNodes = root.getElementsByTagName("select"); for (int i = 0; i < selectNodes.getLength(); i++) { Element select = (Element) selectNodes.item(i); String id = namespace + "." + select.getAttribute("id"); String resultType = select.getAttribute("resultType"); String parameterType = select.getAttribute("parameterType"); String sqlText = select.getTextContent().trim(); MappedStatement ms = new MappedStatement(); ms.setId(id); ms.setSqlText(sqlText); ms.setResultType(resultType); ms.setParameterType(parameterType); ms.setParameterMappings(ParameterMappingTokenHandler.parse(sqlText)); configuration.addMappedStatement(ms); } // insert/update/delete 同理,只是 resultType 一般不需要 } }这段代码里有一个非常重要的类:ParameterMappingTokenHandler。它就是负责处理#{}占位符的解析器。
3.3 占位符解析:把#{}替换成?
MyBatis 里对#{}的处理非常精致:先把 SQL 中的#{}替换成 JDBC 的?,再从参数对象中取出对应属性,逐个绑定。这样既能防止 SQL 注入,又能享受预编译带来的性能收益。
在这个迷你框架里,我用一个简单的正则加手动替换来做占位符解析。思路如下:
public class ParameterMappingTokenHandler { public static List<ParameterMapping> parse(String sql) { List<ParameterMapping> mappings = new ArrayList<>(); StringBuilder newSql = new StringBuilder(); int index = 0; for (int i = 0; i < sql.length(); i++) { if (sql.charAt(i) == '#' && i + 1 < sql.length() && sql.charAt(i + 1) == '{') { int end = sql.indexOf('}', i + 2); if (end == -1) { throw new RuntimeException("Invalid placeholder: " + sql.substring(i)); } String property = sql.substring(i + 2, end).trim(); mappings.add(new ParameterMapping(index, property, Object.class)); newSql.append('?'); index++; i = end; } else { newSql.append(sql.charAt(i)); } } return mappings; } }这个解析器虽然简单,但有一个很实用的效果:它允许我们在 XML 里写`select * from user where id = #{id}`,解析后变成`select * from user where id = ?`,并且记住了第 0 个占位符对应属性id。
原生 MyBatis 的解析比这个要复杂很多,因为要支持${}、动态 if、foreach 等标签。迷你框架可以先只支持静态 SQL 和#{},把主干跑通后,再逐步往里加动态 SQL 支持。这也是我建议按阶段推进的原因:不要一开始就想做到和 MyBatis 一模一样,否则很容易淹没在细节里。
4. 核心执行链路:代理、JDBC、结果映射
4.1 通过 JDK 动态代理让接口“活”起来
这是自定义 MyBatis 里最有意思的部分。Java 的接口本身不能实例化,但 JDK 动态代理可以为接口生成一个代理对象,这个代理对象在运行时把方法调用转发给一个InvocationHandler。我们不需要实现接口,只需要实现InvocationHandler的invoke方法即可。
先定义 MapperProxyFactory,它负责创建代理对象:
public class MapperProxyFactory<T> { private final Class<T> mapperInterface; public MapperProxyFactory(Class<T> mapperInterface) { this.mapperInterface = mapperInterface; } public T newInstance(SqlSession sqlSession) { MapperProxy<T> proxy = new MapperProxy<>(sqlSession, mapperInterface); return (T) Proxy.newProxyInstance( mapperInterface.getClassLoader(), new Class[]{mapperInterface}, proxy); } }然后定义 MapperProxy,它实现InvocationHandler。当调用接口方法时,invoke方法会被触发,我们需要根据方法名和声明类找到对应的 MappedStatement,然后调用 SqlSession 执行。
public class MapperProxy<T> implements InvocationHandler { private final SqlSession sqlSession; private final Class<T> mapperInterface; public MapperProxy(SqlSession sqlSession, Class<T> mapperInterface) { this.sqlSession = sqlSession; this.mapperInterface = mapperInterface; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } String statementId = mapperInterface.getName() + "." + method.getName(); MappedStatement ms = sqlSession.getConfiguration().getMappedStatement(statementId); if (ms == null) { throw new RuntimeException("Unknown statement: " + statementId); } String sqlType = ms.getSqlType().toLowerCase(); Object param = args != null && args.length > 0 ? args[0] : null; switch (sqlType) { case "select": Class<?> resultType = resolveResultType(ms); return sqlSession.selectList(statementId, param).isEmpty() ? null : sqlSession.selectList(statementId, param).get(0); case "insert": return sqlSession.insert(statementId, param); case "update": return sqlSession.update(statementId, param); case "delete": return sqlSession.delete(statementId, param); default: throw new RuntimeException("Unsupported SQL type: " + sqlType); } } private Class<?> resolveResultType(MappedStatement ms) { try { return Class.forName(ms.getResultType()); } catch (ClassNotFoundException e) { throw new RuntimeException("Result type not found: " + ms.getResultType(), e); } } }注意这里判断 resultType 时,我先通过ms.getResultType()拿到类型的全限定名,再反射得到 Class 对象。这是为了在后面结果集映射时能够准确创建对象。
这里有一个容易忽略的细节:当方法没有参数时,args为 null,而不是空数组。很多人在写自定义框架时会在args.length上防空判断,结果把无参方法也当成有参方法处理,导致下标越界。这也是我踩过的一个坑。
4.2 封装 JDBC 操作,完成参数绑定与 SQL 执行
SqlSession 是门面,真正干活的还是底层 JDBC。我封装了一个 DefaultSqlSession,它持有 Configuration 和数据库连接。每次操作都通过 Configuration 找到 MappedStatement,然后执行 JDBC 流程。
public class DefaultSqlSession implements SqlSession { private final Configuration configuration; private Connection connection; public DefaultSqlSession(Configuration configuration) { this.configuration = configuration; initConnection(); } private void initConnection() { try { Class.forName(configuration.getDriver()); this.connection = DriverManager.getConnection( configuration.getUrl(), configuration.getUsername(), configuration.getPassword()); } catch (Exception e) { throw new RuntimeException("Open connection failed: " + e.getMessage(), e); } } @Override public <T> List<T> selectList(String statementId, Object param) { MappedStatement ms = configuration.getMappedStatement(statementId); String sql = ms.getBoundSql(); PreparedStatement ps = null; ResultSet rs = null; try { ps = connection.prepareStatement(sql); ParameterHandler.bindParameters(ps, ms, param); rs = ps.executeQuery(); return ResultSetHandler.handleResultSets(rs, ms); } catch (SQLException e) { throw new RuntimeException("Execute query failed: " + sql, e); } finally { closeQuietly(rs); closeQuietly(ps); } } @Override public int insert(String statementId, Object param) { return executeUpdate(statementId, param); } @Override public int update(String statementId, Object param) { return executeUpdate(statementId, param); } @Override public int delete(String statementId, Object param) { return executeUpdate(statementId, param); } private int executeUpdate(String statementId, Object param) { MappedStatement ms = configuration.getMappedStatement(statementId); String sql = ms.getBoundSql(); PreparedStatement ps = null; try { ps = connection.prepareStatement(sql); ParameterHandler.bindParameters(ps, ms, param); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("Execute update failed: " + sql, e); } finally { closeQuietly(ps); } } private void closeQuietly(AutoCloseable closeable) { if (closeable != null) { try { closeable.close(); } catch (Exception ignored) {} } } @Override public Configuration getConfiguration() { return configuration; } @Override public void close() { if (connection != null) { try { connection.close(); } catch (SQLException ignored) {} } } }这段代码看似不复杂,但 ParameterHandler 才是关键。参数绑定需要考虑两种情况:第一种是参数对象是基本类型、包装类型或 String,第二种是传入一个 POJO。前者直接 setObject,后者需要按照#{}中的属性名,反射取字段值。
public class ParameterHandler { public static void bindParameters(PreparedStatement ps, MappedStatement ms, Object param) throws SQLException { if (param == null) return; List<ParameterMapping> mappings = ms.getParameterMappings(); if (mappings.isEmpty()) return; // 简单类型:String、Integer、Long、基本类型等 if (isSimpleType(param.getClass())) { if (mappings.size() != 1) { throw new RuntimeException("Simple parameter has multiple placeholders: " + ms.getId()); } ps.setObject(1, param); return; } // POJO 类型:按属性名取值 for (ParameterMapping mapping : mappings) { Object value = getPropertyValue(param, mapping.getPropertyName()); ps.setObject(mapping.getIndex() + 1, value); } } private static boolean isSimpleType(Class<?> clazz) { return clazz.isPrimitive() || clazz == String.class || Number.class.isAssignableFrom(clazz) || clazz == Boolean.class || clazz == Character.class; } private static Object getPropertyValue(Object obj, String propertyName) { try { String getterName = "get" + Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1); Method method = obj.getClass().getMethod(getterName); return method.invoke(obj); } catch (Exception e) { throw new RuntimeException("Cannot get property: " + propertyName + " from " + obj.getClass().getName(), e); } } }这里我采用“getter 方法优先”的策略,因为 POJO 一般都有 getter。如果你的实体类没有 getter,直接用字段反射也是可以的,但规范 JavaBean 都推荐 getter,所以这里选择 getter。
4.3 结果集自动映射到 POJO
这是框架里最见功夫的地方。数据库返回的 ResultSet 是一个二维表,我们需要把它变成一个对象列表。实现思路是:通过ResultSetMetaData拿到每一列的列名和数据值,再反射找到实体类的 setter 方法进行赋值。
一个常见的坑是数据库列名一般是下划线风格,比如user_name,而 Java 属性是驼峰风格,比如userName。MyBatis 默认是关闭下划线转驼峰的,只有开启mapUnderscoreToCamelCase才生效。我在迷你框架里直接默认开启,因为这符合大多数人的使用习惯。
public class ResultSetHandler { public static <T> List<T> handleResultSets(ResultSet rs, MappedStatement ms) { List<T> result = new ArrayList<>(); try { Class<?> clazz = Class.forName(ms.getResultType()); ResultSetMetaData metaData = rs.getMetaData(); int columnCount = metaData.getColumnCount(); while (rs.next()) { T instance = (T) clazz.getDeclaredConstructor().newInstance(); for (int i = 1; i <= columnCount; i++) { String columnName = metaData.getColumnLabel(i); String fieldName = underscoreToCamelCase(columnName); Object value = rs.getObject(i); // 根据字段名找 setter String setterName = "set" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); try { Method setter = clazz.getMethod(setterName, value.getClass()); setter.invoke(instance, value); } catch (NoSuchMethodException e) { // 处理基本类型与包装类型不一致的问题 Method setter = findSetter(clazz, fieldName); if (setter != null) { value = convertValue(value, setter.getParameterTypes()[0]); setter.invoke(instance, value); } } } result.add(instance); } } catch (Exception e) { throw new RuntimeException("Result mapping failed: " + e.getMessage(), e); } return result; } private static String underscoreToCamelCase(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(); } private static Method findSetter(Class<?> clazz, String fieldName) { for (Method method : clazz.getMethods()) { if (method.getName().equalsIgnoreCase("set" + fieldName) && method.getParameterCount() == 1) { return method; } } return null; } private static Object convertValue(Object value, Class<?> targetType) { if (targetType == Integer.class && value instanceof Number) { return ((Number) value).intValue(); } if (targetType == Long.class && value instanceof Number) { return ((Number) value).longValue(); } if (targetType == Boolean.class && value instanceof Boolean) { return value; } if (targetType == String.class) { return String.valueOf(value); } return value; } }这里有一个很微妙的地方:rs.getObject(i)返回的可能是Long、BigDecimal、Timestamp,但你实体类中的属性可能是Integer、Long、String。如果直接setter.invoke(instance, value),会因为类型不匹配抛 IllegalArgumentException。所以在找不到完全匹配的 setter 时,我做了一层 convertValue 兜底转换。这种细节决定框架的鲁棒性。
4.4 用 TypeHandler 统一类型处理
TypeHandler 是 MyBatis 里一个非常重要的扩展点。它负责在 Java 类型和 JDBC 类型之间做转换。迷你框架里如果只是setObject也能跑,但遇到日期、枚举、BigDecimal 时,最好还是有一层统一的类型处理机制。
我先定义一个接口:
public interface TypeHandler<T> { void setParameter(PreparedStatement ps, int i, T value) throws SQLException; T getResult(ResultSet rs, String columnName) throws SQLException; }再实现几个常见的处理器:
public class IntegerTypeHandler implements TypeHandler<Integer> { @Override public void setParameter(PreparedStatement ps, int i, Integer value) throws SQLException { ps.setInt(i, value); } @Override public Integer getResult(ResultSet rs, String columnName) throws SQLException { return rs.getInt(columnName); } } public class StringTypeHandler implements TypeHandler<String> { @Override public void setParameter(PreparedStatement ps, int i, String value) throws SQLException { ps.setString(i, value); } @Override public String getResult(ResultSet rs, String columnName) throws SQLException { return rs.getString(columnName); } }然后在 Configuration 里维护一个 typeHandler 注册表,在参数绑定和结果集映射时根据 Java 类型或 JDBC 类型找到对应的 TypeHandler。这是一个很灵活的扩展点,以后你想支持自定义类型,只需要实现接口并注册进去。
不过说实话,在迷你框架阶段,TypeHandler 可以先不做那么完整。先用setObject跑通,再抽象 TypeHandler,会更容易理解它的价值。我是在第一版跑通后第二次重构时加进来的,效果非常好。
5. 把一个完整示例跑通
5.1 项目结构和依赖准备
我用的项目是一个普通的 Maven 工程,JDK 8 以上即可,不需要 Spring 等任何框架。依赖只有一个数据库驱动。以 MySQL 为例,pom.xml 里加上:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>当然,如果你用的是 H2 或 SQLite,替换驱动即可。这里的重点是验证框架,不涉及特定数据库的深度优化。
项目结构如下:
src/main/java/com/demo/framework/ ├── binding/MapperProxyFactory.java ├── binding/MapperProxy.java ├── builder/XMLConfigBuilder.java ├── builder/XMLMapperBuilder.java ├── mapping/MappedStatement.java ├── mapping/ParameterMapping.java ├── mapping/ParameterMappingTokenHandler.java ├── session/Configuration.java ├── session/DefaultSqlSession.java ├── session/SqlSession.java ├── type/TypeHandler.java └── type/IntegerTypeHandler.java src/main/java/com/demo/entity/User.java src/main/java/com/demo/mapper/UserMapper.java src/main/resources/mybatis-config.xml src/main/resources/mapper/UserMapper.xml5.2 编写配置文件和 Mapper XML
主配置文件 mybatis-config.xml:
<?xml version="1.0" encoding="UTF-8"?> <configuration> <dataSource> <property name="driver" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="123456"/> </dataSource> <mappers> <mapper resource="mapper/UserMapper.xml"/> </mappers> </configuration>UserMapper.xml:
<?xml version="1.0" encoding="UTF-8"?> <mapper namespace="com.demo.mapper.UserMapper"> <select id="selectById" resultType="com.demo.entity.User" parameterType="java.lang.Long"> select * from user where id = #{id} </select> <select id="selectAll" resultType="com.demo.entity.User"> select * from user </select> <insert id="insertUser" parameterType="com.demo.entity.User"> insert into user(name, age) values(#{name}, #{age}) </insert> <update id="updateName" parameterType="com.demo.entity.User"> update user set name = #{name} where id = #{id} </update> <delete id="deleteById" parameterType="java.lang.Long"> delete from user where id = #{id} </delete> </mapper>对应的实体类和 Mapper 接口:
public class User { private Long id; private String name; private Integer age; public User() {} public User(String name, Integer age) { this.name = name; this.age = age; } // getter/setter 省略 } public interface UserMapper { User selectById(Long id); List<User> selectAll(); int insertUser(User user); int updateName(User user); int deleteById(Long id); }5.3 核心代码集成
有了前面的所有类,最后只需要一个 SqlSessionFactoryBuilder 将这些串起来。这个类是入口,它负责解析配置、构建 Configuration 和 SqlSession:
public class SqlSessionFactoryBuilder { public DefaultSqlSession build(InputStream configStream) throws Exception { XMLConfigBuilder configBuilder = new XMLConfigBuilder(); Configuration configuration = configBuilder.parse(configStream); return new DefaultSqlSession(configuration); } }组装逻辑其实很简单,因为所有依赖都是构造时建立起来的。真正复杂的是让每个组件各司其职。
5.4 测试运行与结果
测试代码:
public class Main { public static void main(String[] args) throws Exception { try (InputStream is = Resources.getResourceAsStream("mybatis-config.xml")) { DefaultSqlSession sqlSession = new SqlSessionFactoryBuilder().build(is); UserMapper mapper = sqlSession.getMapper(UserMapper.class); // 新增 User newUser = new User("张三", 25); int insert = mapper.insertUser(newUser); System.out.println("insert rows = " + insert); // 查询 User user = mapper.selectById(1L); System.out.println("selectById: " + user); System.out.println("selectAll: " + mapper.selectAll()); // 更新 user.setName("李四"); int update = mapper.updateName(user); System.out.println("update rows = " + update); // 删除 int delete = mapper.deleteById(2L); System.out.println("delete rows = " + delete); sqlSession.close(); } } }运行后如果得到类似输出,就说明整个自定义 MyBatis 的链路已经通了:
insert rows = 1 selectById: User{id=1, name='张三', age=25} selectAll: [User{id=1, name='张三', age=25}, User{id=3, name='王五', age=30}] update rows = 1 delete rows = 1看到这行输出的那一刻,成就感是非常强烈的。因为你亲手把配置、代理、JDBC、反射、类型转换这些零散的 Java 知识,串成了一个有生命力的框架。
6. 常见问题与避坑实录
6.1 Mapper 代理对象生成失败
最常见的错误是Mapper not registered。这是因为在 Configuration 里没有调用addMapper注册接口。MyBatis 之所以能自动注册,是因为它在解析 Mapper XML 时会根据 namespace 反射加载接口类。在我的迷你框架里,我必须在 XMLMapperBuilder 中根据 namespace 把接口注册进去:
Class<?> mapperType = Class.forName(namespace); configuration.addMapper(mapperType);如果你偷懒不注册,那么getMapper一定返回空。这个问题的排查思路很直接:检查 namespace 是否对应真实存在的接口,检查 Configuration 的 mapperRegistry 是否包含目标接口。
6.2 列名和属性名不一致导致返回 null
数据库user_name映射不到 Java 的userName非常经典。解决办法有两个:
第一个是在查询 SQL 里写别名:select user_name as userName from user。第二个是开启下划线转驼峰。我的迷你框架里默认开启了underscoreToCamelCase,但如果你在阅读别人的代码时遇到这个坑,一定要先确认框架层是否做了转换。
如果映射后对象的某些字段还是 null,另一个原因是rs.getObject返回的数字类型和你实体字段类型不一致。比如数据库是BigInt,返回Long,但你的实体字段是Integer,那么setInteger找不到 setter,结果就是字段不变。我在ResultSetHandler里增加了convertValue兜底,否则这种情况非常隐蔽,程序不报错,数据却丢了。
6.3 占位符解析错位的坑
占位符解析最容易被忽略的是“SQL 注释中也有 #{}”的情况。如果你在 XML 里写了一行注释-- 查询 #{id},我的简易解析器会把它当成真实占位符,替换成?,最终导致 SQL 参数个数不匹配。MyBatis 官方对 SQL 词法做了更严谨的 token 处理,能识别注释和字符串字面量。迷你框架中处理思路至少有两条:要么在解析前剔除 SQL 中的注释行,要么继续完善解析器,遇到--或/*...*/时跳过。这不是必须的功能,但能帮你理解为什么一个简单的替换操作也会有很多边界情况。
6.4 连接和事务管理的细节
原生 MyBatis 中 SqlSession 默认不自动提交,需要手动commit()。我的第一个版本为了跑通简单,直接在 initConnection 时用默认的 autoCommit,导致每次写操作都会自动提交。这在实际项目中并不安全,尤其是多个写操作需要原子性时。
改进方法是显式设置:
connection.setAutoCommit(false);然后在SqlSession中增加commit()和rollback()方法。这样所有写操作只有在调用 commit 后才会生效。虽然迷你框架没有连接池,也没有嵌套事务的概念,但这个细节会直接影响框架的可信度。
6.5 进阶优化方向
跑通第一版后,剩下的就是持续打磨。我个人认为值得做的优化方向包括:
- 支持动态 SQL,比如 if、where、foreach。可以参考 MyBatis 的 SqlNode 设计,把 SQL 片段组合成树形结构。
- 增加一级缓存。在一个 SqlSession 内,对相同 statementId 和相同参数重复查询时,直接从缓存返回,省去一次数据库查询。
- 支持注解 SQL。在接口方法上加
@Select("select * from user"),省去 XML 文件。 - 增加插件机制。通过拦截器链在 Executor 执行前后插入自定义逻辑,这是 MyBatis 拦截器的基础。
- 集成连接池。用 HikariCP 或 Druid 替换每次新建连接的逻辑,提升执行效率。
每个方向都值得单独写一篇长文。但对于第一次接触自定义 MyBatis 的人来说,我强烈建议先把静态 SQL 和基础映射跑通,再逐步加动态能力和缓存机制。骨架对了,往上填充东西会非常自然。
最后再分享一个小技巧:在 Debug 时,给 MapperProxy 的invoke方法打个断点,你就能亲眼看到接口方法是如何被拦截的。顺着调用栈往下走,从代理到 SqlSession,再到 JDBC 驱动,每一个环节都清晰可见。这比任何源码分析文章都更有代入感。
手写一个自定义 MyBatis,不是要去替代成熟框架,而是为了让你在真正遇到框架问题时,不再手足无措。当你明白它的执行链路后,再去使用 MyBatis,你会发现自己对它多了一份掌控感。建议每个 Java 开发者都试一次,这个投入绝对值得。