MyBatis XML 热更新实战:告别重启烦恼

1. 引言

在日常开发中,使用 MyBatis 进行数据库操作时,我们经常需要调试 SQL 语句。通常的做法是:

  1. 修改 Mapper XML 文件中的 SQL
  2. 重新编译项目
  3. 重启应用
  4. 再次测试

这个流程在频繁调试时非常耗时,每次修改 SQL 都需要等待项目重启,严重影响开发效率。尤其是在复杂的业务场景下,可能需要反复调整 SQL,重启次数更是频繁。

本文将介绍如何实现 MyBatis Mapper XML 的热更新功能,让修改 SQL 后无需重启应用即可生效。

2. 整体设计思路

2.1 设计思路

回顾下10. Mybatis XML配置到SQL的转换之旅:

flowchart TD
subgraph 执行阶段
        G[调用Mapper接口方法<br/>如userMapper.getUserById] --> H[从Configuration获取<br/>对应MappedStatement]
        H --> I[Executor通过MappedStatement获取BoundSql<br/>含最终SQL+参数映射]
        I --> J[Executor使用MappedStatement+BoundSql+参数+分页生成缓存key<br/>管理一级&二级缓存]
        J --> K[StatementHandler使用BoundSql创建Statement]
        K --> L[ParameterHandler使用BoundSql的参数映射,设置参数]
        L --> M[执行SQL并处理结果<br/>ResultSetHandler映射为Java对象]
    end
    subgraph 解析阶段
        A[XML Mapper文件<br/>如UserMapper.xml] --> B[XML解析器<br/>XMLMapperBuilder]
        B --> C[解析SQL节点<br/>select/insert/update/delete]
        C --> D[构建SqlSource对象<br/>静态/动态SQL适配]
        D --> E[封装MappedStatement<br/>SQL元数据容器]
        E --> F[存入Configuration全局配置<br/>MyBatis核心配置中心]
    end

从上图可以看出来,执行的时,依赖MappedStatement生成SQL,因此,热更新,只要在文件修改后,重新更新MappedStatement就可以了。而更新这个MappedStatement,似乎没有办法mybatis插件、LanguageDriver等官方的方式扩展。因此,只能通过“野路子”,文件变化后更新Configuration的MappedStatement。

总结下流程就是:

文件修改 → 检测变化 → 重新加载 XML

2.2 模块划分

采用生产者-消费者模式,将热更新功能拆分为三个独立的模块:

┌─────────────┐         ┌─────────────┐         ┌─────────────┐
│   Monitor   │────────>│  Listener   │────────>│  MyBatis    │
│   (生产者)   │  事件   │  (消费者)   │  更新    │  Configuration│
└─────────────┘         └─────────────┘         └─────────────┘
  1. 文件模块:坚控 Mapper XML 文件的变化,生产文件变更事件
  2. 热更新模块:文件变更事件,重新加载 MyBatis Configuration
  3. Spring Boot 整合模块:提供自动配置,简化使用

3. 文件模块实现

3.1 核心设计

文件模块负责坚控指定目录下的 XML 文件变化,当文件被修改时,通知器。

核心接口

public interface FileChangeListener {
    void onFileChange(FileChangeEvent event);
}

public class FileChangeEvent {
    private String filePath;
    private long lastModified;
}

核心实现

public class DefaultFileMonitor implements FileMonitor {
    private String monitorDir;
    private String filePattern;
    private long pollIntervalMs = 1000;
    private List<FileChangeListener> listeners = new CopyOnWriteArrayList<>();
    private volatile boolean running = false;
    
    @Override
    public void start() {
        running = true;
        Thread monitorThread = new Thread(() -> {
            while (running) {
                checkFileChanges();
                try {
                    Thread.sleep(pollIntervalMs);
                } catch (InterruptedException e) {
                    break;
                }
            }
        });
        monitorThread.setDaemon(true);
        monitorThread.start();
    }
    
    private void checkFileChanges() {
        File dir = new File(monitorDir);
        File[] files = dir.listFiles((d, name) -> name.matches(filePattern));
        if (files != null) {
            for (File file : files) {
                long lastModified = file.lastModified();
                Long recorded = fileLastModifiedMap.get(file.getAbsolutePath());
                if (recorded == null || lastModified > recorded) {
                    FileChangeEvent event = new FileChangeEvent(file.getAbsolutePath(), lastModified);
                    notifyListeners(event);
                    fileLastModifiedMap.put(file.getAbsolutePath(), lastModified);
                }
            }
        }
    }
}

3.2 踩坑记录

问题: target 目录导致热更新不生效

现象:修改 XML 文件后,热更新没有触发。

原因:MyBatis 在运行时使用的是编译后的 classpath 资源,通常位于 target/classes 目录。但是:

  1. IDE 编译后,target 目录的文件可能没有立即更新
  2. target 目录的文件时间戳可能与源码不同步
  3. target 目录会导致检测不到源码的修改

解决方案:源码目录(如 src/main/resources/mapper),而不是 target 目录。

配置示例

mybatis.hotreload.monitor-dir=src/main/resources/mapper

4. 热更新模块实现

4.1 版本演进

版本 1:直接更新 Configuration(失败)

最初的想法很简单:直接调用 XMLMapperBuilder 重新解析 XML。

private void reloadXml(String filePath) throws Exception {
    try (InputStream inputStream = new FileInputStream(filePath)) {
        XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(
                inputStream,
                configuration,
                filePath,
                configuration.getSqlFragments()
        );
        xmlMapperBuilder.parse();
    }
}

问题:MyBatis 的 Configuration 使用 Map 存储 MappedStatement 和 ResultMap,当重新解析 XML 时,如果 namespace 相同,会抛出异常:

MappedStatement collection already contains value for xxx

原因:MyBatis 不允许同一个 ID 的 MappedStatement 存在,直接重新解析会导致 key 冲突。

深入分析:StrictMap 源码

MyBatis 内部使用 StrictMap 来存储 MappedStatement 和 ResultMap,StrictMap 是一个特殊的 HashMap,它重写了 put 方法,不允许 key 重复。

public class StrictMap<V> extends HashMap<String, V> {
    private String name;
    
    public StrictMap(String name) {
        this.name = name;
    }
    
    @Override
    public V put(String key, V value) {
        if (containsKey(key)) {
            throw new IllegalArgumentException(name + " already contains value for " + key);
        }
        if (key.contains(".")) {
            final String shortKey = getShortName(key);
            if (containsKey(shortKey)) {
                throw new IllegalArgumentException(name + " already contains value for " + shortKey);
            }
        }
        return super.put(key, value);
    }
    
    private String getShortName(String key) {
        final String[] keyParts = key.split("\.");
        return keyParts[keyParts.length - 1];
    }
}

从源码可以看出:

  1. StrictMap 的 put 方法会检查 key 是否已存在:如果存在,直接抛出 IllegalArgumentException
  2. Key 的格式namespace.statementId(如 com.example.mapper.UserMapper.selectById
  3. 双重检查:不仅检查完整 key,还会检查短 key(去掉 namespace 后的 statementId)

因此,当我们直接重新解析 XML 时,XMLMapperBuilder 会尝试将 MappedStatement 放入 StrictMap,但由于 key 已存在,StrictMap 的 put 方法会抛出异常,导致热更新失败。

版本 2:先清理再更新(成功)

为了避免 key 冲突,需要在重新加载之前,先清理旧的配置。

清理逻辑

public class ConfigurationCleaner {
    public static void cleanNamespace(Configuration configuration, String namespace) {
        cleanMappedStatements(configuration, namespace);
        cleanResultMaps(configuration, namespace);
        cleanCaches(configuration);
    }
    
    private static void cleanMappedStatements(Configuration configuration, String namespace) {
        try {
            Field field = Configuration.class.getDeclaredField("mappedStatements");
            field.setAccessible(true);
            @SuppressWarnings("unchecked")
            Map<String, MappedStatement> mappedStatements = (Map<String, MappedStatement>) field.get(configuration);
            
            List<String> idsToRemove = new ArrayList<>();
            for (String id : mappedStatements.keySet()) {
                if (id.startsWith(namespace + ".")) {
                    idsToRemove.add(id);
                }
            }
            
            for (String id : idsToRemove) {
                mappedStatements.remove(id);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

为什么使用反射

MyBatis 的 Configuration 类没有提供直接删除 MappedStatement 的公开 API,只能通过反射访问私有字段 mappedStatementsresultMaps

安全性说明

  • 反射操作在应用启动时只执行一次,性能影响可忽略
  • 只在开发环境使用,生产环境不会执行
  • 反射的是 MyBatis 的内部实现,升级 MyBatis 版本时需要测试

4.2 完整实现

public class MyBatisHotReloadHandler implements FileChangeListener {
    
    private Configuration configuration;
    
    public MyBatisHotReloadHandler(Configuration configuration) {
        this.configuration = configuration;
    }
    
    @Override
    public void onFileChange(FileChangeEvent event) {
        String filePath = event.getFilePath();
        
        if (!isXmlFile(filePath)) {
            return;
        }
        
        try {
            String namespace = extractNamespace(filePath);
            if (namespace == null || namespace.isEmpty()) {
                return;
            }
            
            ConfigurationCleaner.cleanNamespace(configuration, namespace);
            reloadXml(filePath);
            
            System.out.println("MyBatis XML 热更新成功: " + filePath);
        } catch (Exception e) {
            System.err.println("MyBatis XML 热更新失败: " + e.getMessage());
            e.printStackTrace();
        }
    }
    
    private String extractNamespace(String filePath) {
        try (InputStream inputStream = new FileInputStream(filePath)) {
            byte[] bytes = new byte[inputStream.available()];
            inputStream.read(bytes);
            String content = new String(bytes, "UTF-8");
            
            int namespaceStart = content.indexOf("namespace="");
            if (namespaceStart > 0) {
                namespaceStart += "namespace="".length();
                int namespaceEnd = content.indexOf(""", namespaceStart);
                if (namespaceEnd > namespaceStart) {
                    return content.substring(namespaceStart, namespaceEnd);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
    
    private void reloadXml(String filePath) throws Exception {
        try (InputStream inputStream = new FileInputStream(filePath)) {
            XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(
                    inputStream,
                    configuration,
                    filePath,
                    configuration.getSqlFragments()
            );
            xmlMapperBuilder.parse();
        }
    }
}

5. Spring Boot 整合

5.1 自动配置

为了简化使用,我们提供了 Spring Boot 自动配置,只需要在配置文件中启用即可。

配置属性

@ConfigurationProperties(prefix = "mybatis.hotreload")
public class MyBatisHotReloadProperties {
    private boolean enabled = false;
    private String monitorDir;
    private String filePattern = "*.xml";
    private long pollIntervalMs = 1000;
}

自动配置

@Configuration
@EnableConfigurationProperties(MyBatisHotReloadProperties.class)
@ConditionalOnProperty(prefix = "mybatis.hotreload", name = "enabled", havingValue = "true")
public class MyBatisHotReloadAutoConfiguration {
    
    @Bean
    @ConditionalOnMissingBean
    public MyBatisHotReloadManager myBatisHotReloadManager(
            SqlSessionFactory sqlSessionFactory,
            MyBatisHotReloadProperties properties) {
        
        Configuration configuration = sqlSessionFactory.getConfiguration();
        String monitorDir = properties.getMonitorDir();
        if (monitorDir == null || monitorDir.isEmpty()) {
            monitorDir = "src/main/resources/mapper";
        }
        
        return new MyBatisHotReloadManager(
                configuration,
                monitorDir,
                properties.getFilePattern(),
                properties.getPollIntervalMs()
        );
    }
}

5.2 使用配置

application.properties 中添加配置:

# 启用 MyBatis 热更新
mybatis.hotreload.enabled=true

# 坚控目录(源码目录,不是 target 目录)
mybatis.hotreload.monitor-dir=src/main/resources/mapper

# 文件匹配模式
mybatis.hotreload.file-pattern=*.xml

# 轮询间隔(毫秒)
mybatis.hotreload.poll-interval-ms=1000

6 总结

大功告成,现在可以实现热更新了。建议仅在开发环境开启,生产上关闭:

# 开发环境
spring.profiles.active=dev
mybatis.hotreload.enabled=true

# 生产环境
spring.profiles.active=prod
mybatis.hotreload.enabled=false
局限性
  1. 不支持注解方式的 Mapper:仅支持 XML 方式的 Mapper
  2. MyBatis 版本兼容性:反射操作依赖于 MyBatis 内部实现,升级版本时需要测试

源码示例:mybatis-demo

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com