手写迷你版MyBatis:从零复刻核心执行链路
在 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 / MapperProxySQL 解析将#{}替换为?并记录参数名ParameterMapping / SqlSourceBuilder会话门面提供查询、新增、修改、删除的统一入口SqlSession / DefaultSqlSession参数处理把方法参数绑定到 PreparedStatementParameterHandler结果映射把 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.ConfigurationMapperRegistry记录哪些接口需要生成代理对象org.apache.ibatis.binding.MapperRegistryMappedStatement封装一条 SQL 的元数据包括 id、sql 文本、参数类型、返回类型org.apache.ibatis.mapping.MappedStatementSqlSession对外统一操作入口持有 Configuration 和 Executororg.apache.ibatis.session.SqlSessionExecutor执行 SQL管理 StatementHandlerorg.apache.ibatis.executor.ExecutorStatementHandler创建 PreparedStatement执行 SQLorg.apache.ibatis.executor.statement.StatementHandlerParameterHandler为 SQL 中的占位符赋值org.apache.ibatis.scripting.defaults.DefaultParameterHandlerResultSetHandler将 ResultSet 转为对象列表org.apache.ibatis.executor.resultset.DefaultResultSetHandler刚开始设计时最容易犯的错误是“把所有逻辑都塞进 SqlSession”。这样虽然能跑通但后续扩展会越来越痛苦。比如以后想加缓存就得侵入所有方法想支持插件也无从下手。所以哪怕是一个迷你框架也值得分出清晰的模块边界。3. 配置解析让 XML 成为可执行的 MappedStatement3.1 定义配置中心和映射信息模型第一步是定义 Configuration 类。它是整个框架的“大脑”所有解析出来的配置信息都会放到这里。我保留了最核心的字段数据库连接四要素、Mapper 接口注册表、以及从 XML 解析出的 MappedStatement 集合。public class Configuration { private String driver; private String url; private String username; private String password; /** Mapper 接口 Class - 代理工厂 */ private final MapClass?, MapperProxyFactory? mapperRegistry new HashMap(); /** statement id - MappedStatement */ private final MapString, MappedStatement mappedStatements new HashMap(); private final ListTypeHandler? 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(ClassT 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 ListParameterMapping 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 ListParameterMapping parse(String sql) { ListParameterMapping 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 MapperProxyFactoryT { private final ClassT mapperInterface; public MapperProxyFactory(ClassT mapperInterface) { this.mapperInterface mapperInterface; } public T newInstance(SqlSession sqlSession) { MapperProxyT proxy new MapperProxy(sqlSession, mapperInterface); return (T) Proxy.newProxyInstance( mapperInterface.getClassLoader(), new Class[]{mapperInterface}, proxy); } }然后定义 MapperProxy它实现InvocationHandler。当调用接口方法时invoke方法会被触发我们需要根据方法名和声明类找到对应的 MappedStatement然后调用 SqlSession 执行。public class MapperProxyT implements InvocationHandler { private final SqlSession sqlSession; private final ClassT mapperInterface; public MapperProxy(SqlSession sqlSession, ClassT 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 ListT 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; ListParameterMapping 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 ListT handleResultSets(ResultSet rs, MappedStatement ms) { ListT 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 TypeHandlerT { void setParameter(PreparedStatement ps, int i, T value) throws SQLException; T getResult(ResultSet rs, String columnName) throws SQLException; }再实现几个常见的处理器public class IntegerTypeHandler implements TypeHandlerInteger { 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 TypeHandlerString { 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 groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.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 version1.0 encodingUTF-8? configuration dataSource property namedriver valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/demo_db?useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value123456/ /dataSource mappers mapper resourcemapper/UserMapper.xml/ /mappers /configurationUserMapper.xml?xml version1.0 encodingUTF-8? mapper namespacecom.demo.mapper.UserMapper select idselectById resultTypecom.demo.entity.User parameterTypejava.lang.Long select * from user where id #{id} /select select idselectAll resultTypecom.demo.entity.User select * from user /select insert idinsertUser parameterTypecom.demo.entity.User insert into user(name, age) values(#{name}, #{age}) /insert update idupdateName parameterTypecom.demo.entity.User update user set name #{name} where id #{id} /update delete iddeleteById parameterTypejava.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); ListUser selectAll(); int insertUser(User user); int updateName(User user); int deleteById(Long id); }5.3 核心代码集成有了前面的所有类最后只需要一个 SqlSessionFactoryBuilder 将这些串起来。这个类是入口它负责解析配置、构建 Configuration 和 SqlSessionpublic 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{id1, name张三, age25} selectAll: [User{id1, name张三, age25}, User{id3, name王五, age30}] 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 开发者都试一次这个投入绝对值得。

相关新闻

MQTT客户端C#实战:从协议原理到断线重连与工程避坑

MQTT客户端C#实战:从协议原理到断线重连与工程避坑

简介:这是一份基于C#实现的MQTT客户端完整工程源码,面向物联网开发者和上位机初学者,解决设备与MQTT服务器之间的连接、订阅、发布与心跳保活等通信问题。资源以VS解决方案形式组织,共35个文件,源码、配置、DLL依赖库与…

2026/10/16 17:45:07 阅读更多 →
C#控制Nikon相机:SDK架构与自动化拍摄实战指南

C#控制Nikon相机:SDK架构与自动化拍摄实战指南

简介:这是一套面向C#开发者的Nikon相机SDK资源包,借助桌面软件即可让电脑远程控制尼康相机,支持视频录制、连拍、单拍以及图片优化等核心功能,适合需要二次开发相机控制程序、批量拍摄或自动化工作流的开发者,也适合摄…

2026/10/16 17:44:05 阅读更多 →
JSP+Servlet旅游推荐系统:协同过滤与MVC架构落地解析

JSP+Servlet旅游推荐系统:协同过滤与MVC架构落地解析

简介:一份旅游景点个性化推荐系统的毕业设计论文与JSP开发参考文档,面向计算机相关专业学生及旅游平台开发者,用于解决旅游网站的个性化推荐与信息管理问题。系统基于B/S模式,采用JSPServlet技术和协同过滤算法,包含用…

2026/10/16 17:44:05 阅读更多 →

最新新闻

Surfer中文版从安装到出图:网格化插值与等值线图实战指南

Surfer中文版从安装到出图:网格化插值与等值线图实战指南

简介:sufer中文版是一款面向地质、环境、地球物理、水文地质等领域研究人员的地质建模与绘图工具,提供三维图绘制、等值线图生成、数据导入与处理、地图制作及报告输出等完整功能。该版本已完成汉化,并采用免安装便携设计,解压即可…

2026/10/16 18:14:57 阅读更多 →
Qwen3.8-27B-singprobe核心架构揭秘:[20, 41, 62]三层隐藏状态如何变成安全信号

Qwen3.8-27B-singprobe核心架构揭秘:[20, 41, 62]三层隐藏状态如何变成安全信号

【免费下载链接】Qwen3.8-27B-singprobe 项目地址: https://ai.gitcode.com/hf_mirrors/inclusionAI/Qwen3.8-27B-singprobe 点击查看 免费下载 Qwen3.8-27B-singprobe 是一个构建在 Qwen/Qwen3.8-27B 之上的流式安全探针(streaming guardrail&#xff…

2026/10/16 18:14:57 阅读更多 →
大模型落地必读:99个案例拆解RAG与Agent主流路线

大模型落地必读:99个案例拆解RAG与Agent主流路线

简介:这份PDF汇集了大模型在各行业中的典型示范应用案例,参编单位覆盖阿里云、百度、华为、腾讯、OPPO、蚂蚁、商汤、MiniMax等头部科技企业,以及高校、医院、金融机构、研究机构等,适合AI产品经理、算法工程师、企业数字化转型负…

2026/10/16 18:14:57 阅读更多 →
基于STM32F103的数字万用表设计与Proteus仿真实现

基于STM32F103的数字万用表设计与Proteus仿真实现

简介:基于Proteus仿真环境的STM32F103数字万用表完整工程,面向嵌入式系统学习者、电子设计爱好者及STM32入门开发者,可在虚拟环境中完成电路搭建、固件编写与功能验证。压缩包内共1020个文件,大小约14.29MB,汇集C/H源码…

2026/10/16 18:14:57 阅读更多 →
游戏运营核心指标解读:从DAU、留存率到LTV与ROI的实战指南

游戏运营核心指标解读:从DAU、留存率到LTV与ROI的实战指南

游戏运营这行,外人看着热闹,以为就是“发发礼包、写写公告”,真正干过的人才知道,这活儿一半是数据,一半是人性。新人入行,往往先被满屏的英文缩写砸晕——DAU、ARPU、LTV、K因子、留存、裂变……这些名词每…

2026/10/16 18:14:57 阅读更多 →
DeepSeek API调用避坑指南:Python环境、SDK版本与模型参数三重校准

DeepSeek API调用避坑指南:Python环境、SDK版本与模型参数三重校准

简介:本资源是一份面向Python开发者与AI应用工程师的DeepSeek API实战入门指南,聚焦从零开始调用大模型接口的核心流程,解决API注册、环境配置、代码集成、流式响应与多轮对话等关键问题。适用于需快速将DeepSeek语言能力嵌入聊天机器人、智能…

2026/10/16 18:13:56 阅读更多 →

日新闻

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

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

2026/10/16 0:00:05 阅读更多 →
信号与系统频域分析作业解题指南

信号与系统频域分析作业解题指南

1. 作业背景与核心要求解析信号与系统作为电子信息类专业的核心基础课,其作业设计往往直接关联课程的核心知识框架。2026年春季第六次作业的布置,通常对应着课程进展到频域分析的关键阶段。根据常规教学进度推测,本次作业可能涉及以下核心内容…

2026/10/16 0:00:05 阅读更多 →
Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

1. 从一个"四不像"的练手项目说起如果你已经看完了Django的官方教程前几章,跟着敲完了投票应用,大概会有一种"我好像会了,又好像什么都不会"的割裂感。教程里每个知识点都是拆开的——这一章讲模型,下一章讲视…

2026/10/16 0:00:05 阅读更多 →

周新闻

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

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

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

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

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

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

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

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

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

2026/10/16 15:04:24 阅读更多 →

月新闻

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

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

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

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

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

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

2026/10/16 3:49:43 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

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

2026/10/16 14:23:36 阅读更多 →