最近朋友分享了一个面试题:mybatis中的xml文件如何实现热加载,而不用去重新启动应用。
刚开始我的想法就是:在应用打包成jar的时候,这些xml已经被打包进了jar里面了,如果要替换的话这些xml肯定是不能打包进去。
只能说放在jar之外的目录,然后应用在启动的时候专门去这个目录读取xml加载到mybatis的内存中,这样就实现了mybatis的基本功能。如果说要热加载的话就需要有专门的程序去监听这个目录下文件的变化,哪个发生了变化就重新加载到内存中,使之生效。
需要实现的地方
首先就是加载目录中的xml到mybatis中的内存。在mybatis中xml中的sql都会被存放在Map
变量中。
在使用mybatis中我们加载xml文件使用的配置是这样子的
mybatis:
mapper-locations: classpath:mapper/*.xml
我们能不能把这个目录修改下呢?改成jar包之外的目录
从源码中看看这个变量的使用的地方
public Resource[] resolveMapperLocations() {
return Stream.of(Optional.ofNullable(this.mapperLocations).orElse(new String[0]))
.flatMap(location -> Stream.of(getResources(location))).toArray(Resource[]::new);
}
private Resource[] getResources(String location) {
try {
return resourceResolver.getResources(location);
} catch (IOException e) {
return new Resource[0];
}
}
解析classpath:mapper/*.xml就在getResources方法里面了
@Override
public Resource[] getResources(String locationPattern) throws IOException {
Assert.notNull(locationPattern, "Location pattern must not be null");
//如果locationPattern是classpath*:开头
if (locationPattern.startsWith(CLASSPATH_ALL_URL_PREFIX)) {
// a class path resource (multiple resources for same name possible)
if (getPathMatcher().isPattern(locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length()))) {
// 这里通过通配符返回Resource[]
return findPathMatchingResources(locationPattern);
}
else {
// 通过给定的路径,找到所有匹配的资源
return findAllClassPathResources(locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length()));
}
}
// 不以"classpath*:"
else {
// 通常在这里只是通过前缀后面进行查找,并且在tomcat中只有在"*/"分隔符之后才是其"war:"协议
// #1.如果是以"war:"开头,定位其前缀位置
// #2.如果不是以"war:"开头,则prefixEnd=0
int prefixEnd = (locationPattern.startsWith("war:") ? locationPattern.indexOf("*/") + 1 :
locationPattern.indexOf(':') + 1);
// 判断路径中是否含有通配符否含有通配符
if (getPathMatcher().isPattern(locationPattern.substring(prefixEnd))) {
// a file pattern
return findPathMatchingResources(locationPattern);
}
else {
// 单个资源加载成Resource的方式
return new Resource[] {getResourceLoader().getResource(locationPattern)};
}
}
}
通过上面的源码发现mapper-locations
的方式不支持从外面的目录加载xml。那么我们能不能自己把xml文件变成Resource的方式然后去调用mybatis中的方法去解析呢?
通过断点查看需要把xml封装成FileSystemResource
。封装完成后在使用mybatis的工具类XMLMapperBuilder
去解析xml。
这个问题就算是解决了。
在Java 7中新增了java.nio.file.WatchService,通过它可以实现文件变动的监听。
@SneakyThrows
public static void main(String[] args) {
Set<String> catalogue = new HashSet<>();
// 这里的监听必须是目录
Path path = Paths.get("D:\\mapper");
WatchService watcher = FileSystems.getDefault().newWatchService();
path.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY);
// 创建一个线程,等待目录下的文件发生变化
try {
while (true) {
//有变化的文件路径
Set<String> filePaths = new HashSet<>();
WatchKey key = watcher.take();
// 处理文件变化事件:
// key.pollEvents()用于获取文件变化事件,只能获取一次,不能重复获取,类似队列的形式。
for (WatchEvent<?> event : key.pollEvents()) {
// event.kind():事件类型
if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
//事件可能lost or discarded
continue;
}
filePaths.add(event.context().toString());
System.out.println(event.context().toString());
}
//重新加载xml
//reloadXml(filePaths);
// 每次调用WatchService的take()或poll()方法时需要通过本方法重置
if (!key.reset()) {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
我们只要对有变化的xml重新加载即可
首先项目启动的时候读取xml中的sql到mybatis。这里是从D盘中的mapper中加载xml文件,加载完后启动一个线程去监听文件的变化。
/**
* 热加载
*/
@Component
public class MybatisHotLoaded implements ApplicationContextAware {
private static final String FILE_PATH = "D:/mapper/";
private Configuration configuration;
@SneakyThrows
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SqlSessionFactory bean = applicationContext.getBean(SqlSessionFactory.class);
Configuration configuration = bean.getConfiguration();
this.configuration = configuration;
//加载mapper里面的sql
File[] files = FileUtil.ls(FILE_PATH);
for (File file : files) {
Resource resource = new FileSystemResource(file.getAbsoluteFile());
try {
XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(resource.getInputStream(),
configuration, resource.toString(), configuration.getSqlFragments());
xmlMapperBuilder.parse();
} catch (Exception e) {
throw new NestedIOException("Failed to parse mapping resource: '" + resource + "'", e);
} finally {
ErrorContext.instance().reset();
}
}
//启动一个线程去监听目录的变化
FileChangeThread thread = new FileChangeThread(configuration, FILE_PATH);
thread.start();
}
}
通过查看mbatis的源码,我们发现mybatis经过解析会把xml的数据缓存在以下的变量中。Configuration类中
protected final Map<String, MappedStatement> mappedStatements = new StrictMap<MappedStatement>("Mapped Statements collection")
.conflictMessageProducer((savedValue, targetValue) ->
". please check " + savedValue.getResource() + " and " + targetValue.getResource());
protected final Map<String, Cache> caches = new StrictMap<>("Caches collection");
protected final Map<String, ResultMap> resultMaps = new StrictMap<>("Result Maps collection");
protected final Map<String, ParameterMap> parameterMaps = new StrictMap<>("Parameter Maps collection");
protected final Map<String, KeyGenerator> keyGenerators = new StrictMap<>("Key Generators collection");
protected final Set<String> loadedResources = new HashSet<>();
protected final Map<String, XNode> sqlFragments = new StrictMap<>("XML fragments parsed from previous mappers");
所以我们在重新加载的时候需要先清除这些map中有关于变更xml中的信息。
完整的代码
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.builder.xml.XMLMapperEntityResolver;
import org.apache.ibatis.executor.ErrorContext;
import org.apache.ibatis.parsing.XPathParser;
import org.apache.ibatis.session.Configuration;
import org.springframework.core.NestedIOException;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
public class FileChangeThread extends Thread {
private Configuration configuration;
/**
* 监听的文件目录
*/
private String filePath;
public FileChangeThread(Configuration configuration, String filePath) {
this.configuration = configuration;
this.filePath = filePath;
}
@SneakyThrows
@Override
public void run() {
// 这里的监听必须是目录
Path path = Paths.get(filePath);
// 创建WatchService,它是对操作系统的文件监视器的封装,相对之前,不需要遍历文件目录,效率要高很多
WatchService watcher = FileSystems.getDefault().newWatchService();
// 注册指定目录使用的监听器,监视目录下文件的变化;
// StandardWatchEventKinds.ENTRY_MODIFY,表示监视文件的修改事件
try {
path.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY);
} catch (IOException e) {
log.error("注册xml监听失败", e);
}
try {
while (true) {
// 获取目录的变化:
// take()是一个阻塞方法,会等待监视器发出的信号才返回。
WatchKey watchKey = watcher.take();
Set<String> set = new HashSet<>();
// 处理文件变化事件:
// key.pollEvents()用于获取文件变化事件,只能获取一次,不能重复获取,类似队列的形式。
for (WatchEvent<?> event: watchKey.pollEvents()) {
// event.kind():事件类型
if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
//事件可能lost or discarded
continue;
}
log.info("change file is {}", event.context().toString());
set.add(event.context().toString());
}
// 重新加载xml
reloadXml(set);
boolean valid = watchKey.reset();
if (!valid) {
break;
}
}
} catch (Exception e) {
log.info("Mybatis的xml监控失败!", e);
}
}
@SneakyThrows
private void reloadXml(Set<String> filePaths) {
for (String path : filePaths) {
String absoluteFile = filePath + path;
Resource resource = new FileSystemResource(absoluteFile);
//获取xml中的namespace
String namespace = getNamespace(resource);
//删除xml元素的节点缓存
clearMap(namespace);
clearSet(resource.toString());
try {
XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(resource.getInputStream(),
configuration, resource.toString(), configuration.getSqlFragments());
xmlMapperBuilder.parse();
} catch (Exception e) {
throw new NestedIOException("Failed to parse mapping resource: '" + resource + "'", e);
} finally {
ErrorContext.instance().reset();
}
log.info("load xml success {}", absoluteFile);
}
}
/**
* 获取xml的namespace
* @param resource
* @return
*/
private String getNamespace(Resource resource){
log.info("从{}获取namespace", resource.toString());
try{
XPathParser parser = new XPathParser(resource.getInputStream(), true, null, new XMLMapperEntityResolver());
return parser.evalNode("/mapper").getStringAttribute("namespace");
}catch(Exception e){
log.error("解析xml中namespace失败", e);
throw new RuntimeException("ERROR: 解析xml中namespace失败", e);
}
}
/**
* 删除xml元素的节点缓存
* @param nameSpace xml中命名空间
*/
private void clearMap(String nameSpace) {
Arrays.asList("mappedStatements", "caches", "resultMaps", "parameterMaps", "keyGenerators", "sqlFragments").forEach(fieldName -> {
Object value = getFieldValue(configuration, fieldName);
if (value instanceof Map) {
Map<?, ?> map = (Map)value;
List<Object> list = map.keySet().stream().filter(o -> o.toString().startsWith(nameSpace + ".")).collect(Collectors.toList());
list.forEach(k -> map.remove((Object)k));
}
});
}
/**
* 清除文件记录缓存
* @param resource xml文件路径
*/
private void clearSet(String resource) {
log.info("清理mybatis的资源{}在容器中的缓存", resource);
Object value = getFieldValue(configuration, "loadedResources");
if (value instanceof Set) {
Set<?> set = (Set)value;
set.remove(resource);
set.remove("namespace:" + resource);
}
}
/**
* 获取对象指定属性
* @param obj 对象信息
* @param fieldName 属性名称
* @return
*/
private Object getFieldValue(Object obj, String fieldName){
log.info("从{}中加载{}属性", obj, fieldName);
try{
Field field = obj.getClass().getDeclaredField(fieldName);
boolean accessible = field.isAccessible();
field.setAccessible(true);
Object value = field.get(obj);
field.setAccessible(accessible);
return value;
}catch(Exception e){
log.error("获取属性失败对象中[{}]", fieldName, e);
throw new RuntimeException("加载对象中[" + fieldName + "]", e);
}
}
}
接口访问调用的是这个方法。我们通过修改select的字段返回不一样的数据(从select id, name, job变成select id, name)。程序启动的时候从D:\mapper中加载xml文件
<select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
select id, name, job
from demo
where id = #{id,jdbcType=BIGINT}
select>
不通过重启应用就达到了xml直接生效的效果了
程序还有许多可以优化的地方,如这里只是监听了mapper目录下的文件,如果mapper下面还有目录的话就监听不到。WatchService如果是直接修改的话会触发两次修改的事件。