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

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

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

总结下流程就是:

复制代码
文件修改 → 检测变化 → 重新加载 XML

2.2 模块划分

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

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

3. 文件监听模块实现

3.1 核心设计

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

核心接口

java 复制代码
public interface FileChangeListener {
    void onFileChange(FileChangeEvent event);
}

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

核心实现

java 复制代码
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 目录。

配置示例

properties 复制代码
mybatis.hotreload.monitor-dir=src/main/resources/mapper

4. 热更新模块实现

4.1 版本演进

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

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

java 复制代码
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 相同,会抛出异常:

rust 复制代码
MappedStatement collection already contains value for xxx

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

深入分析:StrictMap 源码

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

java 复制代码
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 冲突,需要在重新加载之前,先清理旧的配置。

清理逻辑

java 复制代码
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 完整实现

java 复制代码
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 自动配置,只需要在配置文件中启用即可。

配置属性

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

自动配置

java 复制代码
@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 中添加配置:

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 总结

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

properties 复制代码
# 开发环境
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

相关推荐
程途知微2 小时前
AQS 同步器——Java 并发框架的核心底座全解析
java·后端
sunwenjian8862 小时前
SpringBean的生命周期
java·开发语言
毕设源码-赖学姐2 小时前
【开题答辩全过程】以 基于Java的游泳馆会员管理系统的设计与实现为例,包含答辩的问题和答案
java·开发语言
侠客行03172 小时前
Tomcat 网络I/O模型浅析
java·tomcat·源码阅读
Yilena3 小时前
带你轻松学习LangChain4j
java·学习·langchain
皙然3 小时前
深入拆解MESI协议:从原理到实战,搞懂CPU缓存一致性的核心机制
java·缓存
愤豆3 小时前
02-Java语言核心-语法特性-注解体系详解
java·开发语言·python
x-cmd3 小时前
[x-cmd] 终端里的飞书:lark-cli,让 AI Agent 拥有“实体办公”能力
java·人工智能·ai·飞书·agent·x-cmd
吾日三省Java4 小时前
SpringBoot锁设计:让你的系统不再“抢”出问题!
java·spring boot·设计思路