一个类实现Mybatis的SQL热更新

发布于:2024-05-06 ⋅ 阅读:(21) ⋅ 点赞:(0)

引言

平时用SpringBoot+Mybatis开发项目,如果项目比较大启动时间很长的话,每次修改Mybatis在Xml中的SQL就需要重启一次。假设项目重启一次需要5分钟,那修改10次SQL就过去了一个小时,成本有点太高了。关键是每次修改完代码之后再重启服务,我们的代码思路也会被中断,这样更会降低我们的开发效率。有没有一种方法可以让我们修改完SQL之后不用重启呢?答案是肯定的,我自己亲测有效。以后开发修改了SQL可以自动更新Mybatis的配置,如果是修改了Java代码可以使用idea自带的Hot Swap进行Class的Recompile,快捷键是CTRL+SHIFT+F9。你也可以装一个JRebel插件,这个插件同样只能更新Class不能更新Mybatis SQL。

先思考三个问题,文中会给出回答。

  • Mybatis动态SQL的实现原理是什么?
  • Mybatis是在什么时候读取的XML配置?
  • 读取的配置放在了哪里?

源码

Mybatis SQL 热更新的实现流程如下图。

话不多说,先上完整代码,只需要一个类即可实现,文末我会将代码拆解分析其原理。大家可以直接拿去项目上使用,记得上线的时候把热更新的开关关闭,以免影响线上性能。

package com.ITGuoGuo.springtemplate.config;

import com.baomidou.mybatisplus.autoconfigure.MybatisPlusProperties;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

@Slf4j
@Component
public class MapperHotSwap {
    //@Value("${mybatis.mapper-locations}")
    //private String packageSerchPath;
    //@Autowired
    //private MybatisProperties mybatisProperties;

    @Autowired
    private MybatisPlusProperties mybatisPlusProperties;
    @Autowired
    private SqlSessionFactory sqlSessionFactory;
    private Resource[] mapperLocations;
    private Configuration config;
    private HashMap<String, Long> fileChange = new HashMap<String, Long>();// 记录文件是否变化

    @org.springframework.context.annotation.Configuration
    @ConfigurationProperties(prefix = MapperHotSwapProperties.PREFIX)
    @Data
    public static class MapperHotSwapProperties {
        public final static String PREFIX = "mybatis.mapper";
        private Boolean reload = false;
    }

    @Autowired
    private MapperHotSwapProperties hotSwapProperties;

    @PostConstruct
    public void init() {
        try {
            if (!hotSwapProperties.getReload()) return;
            prepareEnv();
            Runnable runnable = new Runnable() {
                public void run() {
                    changeCompare();
                }
            };
            ScheduledExecutorService schedule = Executors.newSingleThreadScheduledExecutor();
            //首次执行1秒以后,定时执行时间间隔10秒
            schedule.scheduleAtFixedRate(runnable, 1, 10, TimeUnit.SECONDS);
            log.info("============Mybatis Mapper 热更新生效=============");
        } catch (Exception e) {
            log.error("包路径配置扫描错误", e);
        }
    }

    /**
     * 初始化 Mybatis Mapper 配置
     */
    public void prepareEnv() throws Exception {
        this.config = sqlSessionFactory.getConfiguration();
        this.mapperLocations = new PathMatchingResourcePatternResolver().getResources(mybatisPlusProperties.getMapperLocations()[0]);
        for (Resource resource : mapperLocations) {
            // 文件内容帧值
            long lastFrame = resource.contentLength() + resource.lastModified();
            fileChange.put(resource.getFilename(), Long.valueOf(lastFrame));
        }
    }

    /**
     * xml文件已修改则重载配置;否则不处理
     */
    public void changeCompare() {
        try {
            if (!isChanged()) return;
            // 清理
            removeConfig(config);
            // 重载
            for (Resource loc : mapperLocations) {
                try {
                    XMLMapperBuilder builder = new XMLMapperBuilder(loc.getInputStream(), config, loc.toString(), config.getSqlFragments());
                    builder.parse();
                } catch (IOException e) {
                    log.error("mapper文件[" + loc.getFilename() + "]不存在或内容格式不对");
                }
            }
            log.info("------- mapper文件已全部更新 -------");
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
    }

    /**
     * 判断文件是否变化
     */
    boolean isChanged() throws IOException {
        boolean flag = false;
        for (Resource resource : mapperLocations) {
            String resourceName = resource.getFilename();
            Long lastFrame = fileChange.get(resourceName);
            long newFrame = resource.contentLength() + resource.lastModified();
            fileChange.put(resourceName, Long.valueOf(newFrame));
            // 新增或是修改,保存文件最新帧
            boolean addFlag = !fileChange.isEmpty() && !fileChange.containsKey(resourceName);
            boolean modifyFlag = null != lastFrame && lastFrame != newFrame;
            if (addFlag || modifyFlag) {
                flag = true;
                log.info("-------[" + resourceName + "]文件 已修改-------");
            }
        }
        return flag;
    }

    /**
     * 清空Configuration中几个重要的缓存
     */
    private void removeConfig(Configuration configuration) throws Exception {
        Class<?> classConfig = configuration.getClass();
        clearMap(classConfig, configuration, "mappedStatements");
        clearMap(classConfig, configuration, "caches");
        clearMap(classConfig, configuration, "resultMaps");
        clearMap(classConfig, configuration, "parameterMaps");
        clearMap(classConfig, configuration, "keyGenerators");
        clearMap(classConfig, configuration, "sqlFragments");
        // 因为是使用的是Mybatis Plus,Mybatis Plus 使用的配置类是 Configuration 的子类 MybatisConfiguration。
        // 所以要去其父类 Configuration 中找 loadedResources 这个属性
        for (; Objects.nonNull(classConfig); classConfig = classConfig.getSuperclass()) {
            clearSet(classConfig, configuration, "loadedResources");
        }
    }

    private void clearMap(Class<?> classConfig, Configuration configuration, String fieldName) {
        Field field = getDeclaredField(classConfig, fieldName);
        if (Objects.isNull(field)) {
            return;
        }
        field.setAccessible(true);
        Map mapConfig = getFieldValue(field, configuration);
        if (Objects.nonNull(mapConfig)) {
            mapConfig.clear();
        }
    }

    private void clearSet(Class<?> classConfig, Configuration configuration, String fieldName) {
        Field field = getDeclaredField(classConfig, fieldName);
        if (Objects.isNull(field)) {
            return;
        }
        field.setAccessible(true);
        Set setConfig = getFieldValue(field, configuration);
        if (Objects.nonNull(setConfig)) {
            setConfig.clear();
        }
    }

    private <T> T getFieldValue(Field field, Object obj) {
        T value = null;
        try {
            value = (T) field.get(obj);
        } catch (IllegalAccessException e) {
        }
        return value;
    }

    private Field getDeclaredField(Class aClass, String fieldName) {
        Field field = null;
        try {
            field = aClass.getDeclaredField(fieldName);
        } catch (NoSuchFieldException e) {
        }
        return field;
    }
}

使用

在application.properties配置文件里添加如下配置,就会开启Mybatis Mapper的热更新。如果不配置或者配置值为false,则不会开启热更新。

由于此工具的原理是定时10秒一次比较文件是否变化,而判断文件变化的标准是编译路径target目录下的xml文件长度和最新一次修改时间是否发生变化,所以如果只是在idea里修改xml文件内容是不会触发Mybatis Mapper重载的,需要对resources包下的xml文件进行Recompile,这样target下的xml文件才会产生变化,从而触发Mybatis Mapper的重载。

mybatis.mapper.reload=true

原理

首先我们要知道Mybatis动态SQL的实现原理是什么?Mybatis是通过XML里的配置,利用JDK动态代理技术对Mapper接口增强,实现了写接口+写SQL就能直接操作数据库的功能,其他的JDBC所需要的加载驱动、建立连接、获取实体等都在Mybatis的增强逻辑里统一处理了,业务开发人员可以完全复用。

知道了这一点以后,我们需要搞清楚Mybatis是在什么时候读取的XML配置?读取的配置放在了哪里?要想实现Mybatis的SQL热更新,我们只要重新加载一次XML配置是不是就行了?

如何重载配置

Mybatis所有的配置都会加载到Configuration这个类里,在项目启动时 Mybatis 的 SqlSessionFactoryBuilder 就会读取 Mybatis XML 的配置。

其中的 MappedStatements 就是用来保存 Mapper XML 中的 SQL 语句的。

项目启动时,除了会加载 Mybatis XML 配置文件 mappers 标签的配置,还会加载 properties、settings、plugins 和 environments 等标签的配置,当前我们只需要关心 mappers 标签是如何加载的。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <properties resource="dbconfig.properties"/>

    <settings>
        <setting name="logImpl" value="org.apache.ibatis.logging.stdout.StdOutImpl"/>
    </settings>

    <plugins>
        <plugin interceptor="com.github.pagehelper.PageInterceptor">
            <property name="helperDialect" value="org.apache.ibatis.page.MyMySqlDialect"/>
        </plugin>
    </plugins>

    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="${jdbc.driver}"/>
                <property name="url" value="${jdbc.url}"/>
                <property name="username" value="${jdbc.username}"/>
                <property name="password" value="${jdbc.password}"/>
            </dataSource>
        </environment>
    </environments>

    <mappers>
        <!--resource-->
        <mapper resource="UserMapper.xml"/>

        <!--class-->
        <!-- <mapper class="org.apache.ibatis.mapper.UserMapper"/> -->

        <!--url-->
        <!-- <mapper url="D:\coder_soft\idea_workspace\ecard_bus\spring-boot-analyze\target\classes\UserMapper.xml"/> -->

        <!--package-->
        <!-- <package name="org.apache.ibatis.mapper" />-->
    </mappers>
</configuration>

从 Mybatis XML 配置文件中我们可以看到 mappers 配置支持四种类型:

  • resource。从资源包下的 XML 配置加载。
  • class。从 Mapper 的 Class 接口的全限定名加载。
  • url。从 XML 配置的绝对路径加载。
  • package。从包的全限定名加载。

承接上图中的源码,mapperElement() 方法其实就是做了这一件事情,即根据配置中的 mappers 加载类型来加载 Mapper XML 配置。本文的 SQL 热更新类采用的是其中的resource方式。

总结一下,Mybatis 会将我们写的业务 SQL 通过 XML 配置里指定的路径加载到 Configuration 的 mappedStatements 这个 Map 类型的变量里。所以我们在重载 Mybatis 配置的时候,只需要更新 mappedStatements 相关的数据即可。如何重载配置呢?使用和 Mybatis 源代码加载时一样的方法即可。

 // 获取文件的输入流
InputStream inputStream = Resources.getResourceAsStream(resource);
// 使用XMLMapperBuilder解析Mapper文件
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
mapperParser.parse();

建议点赞+收藏+关注,方便以后复习查阅。

重载哪些配置

要想弄清楚需要重载哪些配置,我们可以看看 Mybatis 源码里看看加载 mappers 都做了什么事情?

首先判断 resources 有没有被解析过,如果已经被解析过则不再重新解析。我们现在要重载,所以肯定是要重新解析 Mapper XML 这些资源文件的,所以 Configuration 的 loadedResources 需要被重载

看下图,接下来进入 if 判断里,重点关注第114行代码,即 Mybatis 如何处理 Mapper 节点。第116行是将解析后的资源加到 Configuration 的 loadedResources 里。第118行是将 mapper 注册到 Configuration 里。

Mybatis 处理 Mapper 节点,实际上就是处理 Mapper XML 的各种标签。

Mapper XML 里的标签如下图,这是我上 Mybatis 官网截取的,结合 Mybatis 的源代码一目了然。

每个标签的处理流程都大相径庭,最终都会以 Configuration 的某个属性作为处理结果保存起来,下面我仅以 Cache 举例介绍一下。进入 cacheElement 方法,利用 XNode 读取 Cache 标签的各种属性,并作为参数调用 builderAssistant#useNewCache() 方法。

找到 builderAssistant#useNewCache() 方法最下面的一行代码,发现在构建了 Cache 对象之后,将改缓存对象加入到了 Configuration 里。

所以我们最终要重载的配置如下图,都在 Configuration 里了,它们除了 loadedResources 是 Set 集合以外,其他都是 Map 类型。

MapperHotSwap 解析

再一次贴上文章开头的流程图,对照着给大家讲解 MapperHotSwap 的实现原理。

热更新初始化

  • 56行:判断热更新是否开启;
  • 57行:读取 Mybatis Mapper 配置;
  • 58~65行:开启异步线程定时执行 SQL 热更新。

读取 Mybatis Mapper 配置

  • 76行:从 SqlSessionFactory 里获取 Configuration 配置;
  • 77行:从 MybatisPlus 配置项里获取 mapperLocations,MybatisPlus 默认配置的路径是 "classpath*:/mapper/**/*.xml"。也可以从 Mybatis 的配置项里读取,但需要手动在 application.properties 配置文件中添加 mybatis.mapper-locations 的配置。
  • 78~82行:遍历 mapperLocations ,将 Mapper XML 资源配置的初始帧值保存到 fileChange 这个 Map 对象里。帧值是由文件长度和文件最后一次修改时间之和组成的。

开启异步线程定时执行 SQL 热更新

  • 90行:判断 Mapper XML 是否变化;
  • 92行:清理上一次加载的 Mapper XML 配置;
  • 94~101行:遍历 mapperLocations ,调用 Mybatis 源码重载配置,这个在前文已经提到过了,不再赘述。

清除上一次加载的 Mapper XML 配置项。前文已经介绍过需要重载的配置项有哪些,这里需要清除的就是前文提到的几个配置,它们都是 Mapper XML 的标签在 Configuration 里的映射属性。除了 loadedResources 不是 Map 类型以外,因为只有 loadedResources 属性不是 XML 标签。我这里是从父类中遍历查找 loadedResources 属性,因为我用的是 MybatisPlus,MybatisPlus的配置类是 Configuration 的子类 MybatisConfiguration ,如果不从父类中查找会找不到,loadedResources 属性不会被清除,Mybatis 会认为 XML 已经被加载过,从而不会重载 XML 资源。

怎么样?对 Mybatis 这样介绍一番之后,是不是顿时觉得非常的简单了。“IT果果日记”会定期更新技术文章,欢迎大家多多关注。

建议点赞+收藏+关注,方便以后复习查阅。