MyBatis XML 热更新实战:告别重启烦恼
1. 引言
在日常开发中,使用 MyBatis 进行数据库操作时,我们经常需要调试 SQL 语句。通常的做法是:
- 修改 Mapper XML 文件中的 SQL
- 重新编译项目
- 重启应用
- 再次测试
这个流程在频繁调试时非常耗时,每次修改 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│
└─────────────┘ └─────────────┘ └─────────────┘
- 文件监听模块:监控 Mapper XML 文件的变化,生产文件变更事件
- 热更新模块:监听文件变更事件,重新加载 MyBatis Configuration
- 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 目录。但是:
- IDE 编译后,target 目录的文件可能没有立即更新
- target 目录的文件时间戳可能与源码不同步
- 监听 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];
}
}
从源码可以看出:
- StrictMap 的 put 方法会检查 key 是否已存在:如果存在,直接抛出
IllegalArgumentException - Key 的格式:
namespace.statementId(如com.example.mapper.UserMapper.selectById) - 双重检查:不仅检查完整 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,只能通过反射访问私有字段 mappedStatements 和 resultMaps。
安全性说明:
- 反射操作在应用启动时只执行一次,性能影响可忽略
- 只在开发环境使用,生产环境不会执行
- 反射的是 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
局限性
- 不支持注解方式的 Mapper:仅支持 XML 方式的 Mapper
- MyBatis 版本兼容性:反射操作依赖于 MyBatis 内部实现,升级版本时需要测试
源码示例:mybatis-demo