MyBatis 通过拦截修改SQL

假如我们想实现多租户,或者在某些SQL后面自动拼接查询条件。在开发过程中大部分场景可能都是一个查询写一个SQL去处理,我们如果想修改最终SQL可以通过修改各个mapper.xml中的SQL来处理。但实际过程中我们可能穿插着ORM和SQL的混合使用,隐藏在代码中不容易被发现,还有假如项目中有很多很多的SQL我们不可能一一的去修改解决。这个时候我们就需要通过mybatis拦截SQL并且最终修改SQL。

maven依赖

<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis-spring</artifactId>
    <version>2.0.4</version>
</dependency>

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
    <version>5.2.4.RELEASE</version>
    <scope>compile</scope>
</dependency>

<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.26</version>
</dependency>

<!-- 大家最好是使用jsqlparser去解析sql提供的功能很多 -->
<dependency>
    <groupId>com.github.jsqlparser</groupId>
    <artifactId>jsqlparser</artifactId>
    <version>4.2</version>
</dependency>

<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis-spring</artifactId>
    <version>2.0.4</version>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus</artifactId>
    <version>2.3.3</version>
</dependency>
<!-- 核心包 -->
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.4</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.6</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>

配置部分

这部分是传统mybatis的xml配置,如果是Springboot项目或者使用JavaConfig配置的请查看官方文档配置方式。无非就是Springboot封装了mybatis-xxx-stater包将部分配置都转为了参数控制以及部分autoconfig,大同小异这里不做过多讨论。

<?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="db.properties"></properties>
    <settings>
        <!-- 打印查询语句 -->
        <setting name="logImpl" value="STDOUT_LOGGING" />
        <!-- 控制全局缓存(二级缓存)-->
        <setting name="cacheEnabled" value="false"/>
        <!-- 延迟加载的全局开关。当开启时,所有关联对象都会延迟加载。默认 false  -->
        <setting name="lazyLoadingEnabled" value="true"/>
        <!-- 当开启时,任何方法的调用都会加载该对象的所有属性。默认 false,可通过select标签的 fetchType来覆盖-->
        <setting name="aggressiveLazyLoading" value="false"/>
        <!--  Mybatis 创建具有延迟加载能力的对象所用到的代理工具,默认JAVASSIST -->
        <!--<setting name="proxyFactory" value="CGLIB" />-->
        <!-- STATEMENT级别的缓存,使一级缓存,只针对当前执行的这一statement有效 -->
        <!-- <setting name="localCacheScope" value="STATEMENT"/>-->
        <setting name="localCacheScope" value="SESSION"/>
    </settings>

    <!--<typeAliases>-->
    <!--   <typeAlias alias="blog" type="com.allens.mybatis.model.model" />-->
    <!--</typeAliases>-->

    <!--    <typeHandlers>
            <typeHandler handler="com.wuzz.type.MyTypeHandler"></typeHandler>
        </typeHandlers>-->

    <!-- 对象工厂 -->
    <!--    <objectFactory type="com.wuzz.objectfactory.GPObjectFactory">
            <property name="wuzz" value="666"/>
        </objectFactory>-->

   <!-- 配置拦截器,本文的重点 -->
    <plugins>
        <plugin interceptor="com.allens.mybatis.interceptor.SQLInterceptor"/>
        <plugin interceptor="com.allens.mybatis.interceptor.SQLParamInterceptor"/>
    </plugins>

    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/><!-- 单独使用时配置成MANAGED没有事务 -->
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://localhost:3306/test?useUnicode=true&amp;characterEncoding=UTF-8"/>
                <property name="username" value="root"/>
                <property name="password" value=""/>
            </dataSource>
        </environment>
    </environments>

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

</configuration>

整个拦截调用链路流程图:

[图片上传失败...(image-fbe423-1656999539717)]

① mybatis在这一层包装了StatementHandler返回代理对象,下一步调用prepare的时候会先调用增强拦截器。
Configuration.newStatementHandler

public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
    StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
    // 对statementhandler进行代理
    statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
    return statementHandler;
}

InterceptorChain.pluginAll

public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
       target = interceptor.plugin(target);
     }
     return target;
}

Interceptor.plugin

default Object plugin(Object target) {
    return Plugin.wrap(target, this);
}

包装成代理对象
Plugin.wrap

public static Object wrap(Object target, Interceptor interceptor) {
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    Class<?> type = target.getClass();
    // 获取target对象的所有接口类型
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    if (interfaces.length > 0) {
    // 学习过JDK动态代理的同学对这段代码肯定很熟,这里是生成一个代理对象
    return Proxy.newProxyInstance(
           type.getClassLoader(), // 类加载器
           interfaces, // JDK动态代理必须要有接口
           new Plugin(target, interceptor, signatureMap));
     }
     return target;
}

如果大家觉得这篇文章写的还可以请关注我,我后续会出mybatis的源码解析。

调用代码

这部分代码负责调用mybatis,如果使用springboot这部分就是你的rest接口。

import com.allens.mybatis.model.User;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;

import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class TestMybatis {

    public static void main(String[] args) throws IOException {
        // 读取配置文件
        InputStream resourceAsStream = Resources.getResourceAsStream("sqlMapConfig.xml");
        // 通过SqlSessionFactoryBuilder创建SqlSessionFactory
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);
        // 获取到SqlSession
        SqlSession sqlSession = sqlSessionFactory.openSession();
        // 调用Mapper中的指定方法 com.wyh.mapper.UserMapper.queryAll是statementId
        Map<String, Object> map = new HashMap<>();
        map.put("pageSize", 1);
        map.put("desc", "desc");
        map.put("name", "Allens");
        List<User> userList = sqlSession.selectList("com.allens.mybatis.mappers.UserMapper.selectUsers", map);
        System.out.println("++++++++++++++++++++++");
        userList.forEach(System.out::println);
    }
}

拦截器的代码实现

这使用了Druid的SQLParser进行解析SQL,如果不想使用druid可以使用sqlparser包进行sql解析。不管用什么样的工具把SQL修改掉就行了,形式不限。

如果想使用sqlparser进行解析sql可以看我的下一篇文章

/**
 * MyBatis 允许你在映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:
 * <p>
 * Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
 * ParameterHandler (getParameterObject, setParameters)
 * ResultSetHandler (handleResultSets, handleOutputParameters)
 * StatementHandler (prepare, parameterize, batch, update, query)
 * 这些类中方法的细节可以通过查看每个方法的签名来发现,或者直接查看 MyBatis 发行包中的源代码。 如果你想做的不仅仅是监控方法的调用,那么你最好相当了解要重写的方法的行为。 因为在试图修改或重写已有方法的行为时,很可能会破坏 MyBatis 的核心模块。 这些都是更底层的类和方法,所以使用插件的时候要特别当心。
 * <p>
 * 通过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可
 */
//@Intercepts({@Signature(
//        type= Executor.class,
//        method = "query",
//        args = {StatementHandler.class, Object.class, RowBounds.class, ResultHandler.class})})
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class SQLInterceptor implements Interceptor {

    private Properties properties = new Properties();

    public List<String> getInsertPropertiesName(List<Map<String, Object>> properties) {
        List<String> list = new ArrayList<>();
        properties.forEach(data -> data.keySet()
                .stream()
                .filter(e -> e.equals("columnName"))
                .forEach(e -> list.add((String) data.get("columnName"))));
        return list;
    }

    public void setInsertProperties(List<Map<String, Object>> properties, String columnName, Object value) {
        properties.forEach(element -> element.forEach((k, v) -> {
            if (k.equals("columnName") && element.get(k).equals(columnName)) {
                element.put("columnValue", value);
                element.put("columnName", columnName);
            }
        }));
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // implement pre processing if need
        List<Map<String, Object>> insertProperties = new ArrayList<>();
        Map<String, Object> nameProperties = new HashMap<>();
        nameProperties.put("columnName", "name");
        nameProperties.put("columnValue", "baby");
        nameProperties.put("expr", "and");
        insertProperties.add(nameProperties);

        Map<String, Object> namePropertiesOr = new HashMap<>();
        namePropertiesOr.put("columnName", "id");
        namePropertiesOr.put("columnValue", "1");
        namePropertiesOr.put("expr", "or");
        insertProperties.add(namePropertiesOr);

        // BoundSql boundSql = ((MappedStatement)invocation.getArgs()[0]).getBoundSql(invocation.getArgs()[1]);

        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();

        // 获取传入的参数
        Object parameterMappings = statementHandler.getBoundSql().getParameterObject();
        if (parameterMappings instanceof Map) {
            Map parameterMappingsConvert = (Map) parameterMappings;
            getInsertPropertiesName(insertProperties).forEach(e -> {
                Object value = parameterMappingsConvert.get(e);
                if (value != null) { // 如果传参值不为空就覆盖配置值
                    setInsertProperties(insertProperties, e, value);
                }
            });
        }

        MetaObject metaObject = MetaObject
                .forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY,
                        new DefaultReflectorFactory());
        BoundSql boundSql = statementHandler.getBoundSql();
        SQLStatementParser sqlParser = SQLParserUtils.createSQLStatementParser(boundSql.getSql(), DbType.MYSQL.getValue());
        SQLStatement stmt = sqlParser.parseStatementList().get(0);


        System.out.println("Origin SQL is:" + boundSql.getSql());
        System.out.println("++++++++++++++++++++++");

        if (stmt instanceof SQLSelectStatement) {
            // ((SQLSelectStatement) stmt).addWhere(sqlExpr);
            // convert conditions to 'and' statement
            StringBuilder constraintsBuffer = new StringBuilder();

            boolean first = true;
            for (Map<String, Object> data : insertProperties) {
                if (String.valueOf(data.get("expr")).equalsIgnoreCase("and")) {
                    if (!first) {
                        constraintsBuffer.append(" AND ");
                    }
                    constraintsBuffer.append(String.format(" %s = '%s' ", data.get("columnName"), data.get("columnValue")));
                } else if (String.valueOf(data.get("expr")).equalsIgnoreCase("or")) {
                    if (!first) {
                        constraintsBuffer.append(" OR ");
                    }
                    constraintsBuffer.append(String.format(" %s = '%s' ", data.get("columnName"), data.get("columnValue")));
                }
                first = false;
            }

            SQLExprParser constraintsParser = SQLParserUtils.createExprParser(constraintsBuffer.toString(), JdbcUtils.MYSQL);
            SQLExpr constraintsExpr = constraintsParser.expr();

            SQLSelectStatement selectStmt = (SQLSelectStatement) stmt;
            // 拿到SQLSelect 通过在这里打断点看对象我们可以看出这是一个树的结构
            SQLSelect sqlselect = selectStmt.getSelect();
            SQLSelectQueryBlock query = (SQLSelectQueryBlock) sqlselect.getQuery();
            SQLExpr whereExpr = query.getWhere();

            // 修改where表达式
            if (whereExpr == null) {
                query.setWhere(constraintsExpr);
            } else {
                SQLBinaryOpExpr newWhereExpr = new SQLBinaryOpExpr(
                        whereExpr, SQLBinaryOperator.BooleanAnd, constraintsExpr);
                query.setWhere(newWhereExpr);
            }

            sqlselect.setQuery(query);
            String sql = sqlselect.toString();

            //通过反射修改sql语句
            Field field = boundSql.getClass().getDeclaredField("sql");
            field.setAccessible(true);
            field.set(boundSql, sql);
            System.out.println("modify sql is:" + sql);
        }
        // implement post processing if need
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Interceptor.super.plugin(target);
    }

    @Override
    public void setProperties(Properties properties) {
        this.properties = properties;
    }
}

可以看到上面使用了反射去设置boundsql.sql,有可能有同学会问这样会不会触发JVM优化修改不了这个final String属性。这里我下一个结论是可以的,为了解释这个问题我写了端代码帮助理解:

package base;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;

public class FinalPropertyModify {

    private final String a = "123";

    private final String ab;

    public FinalPropertyModify(String ab) {
        this.ab = ab;
    }

    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException, NoSuchFieldException {
        // 直接赋值初始化
        FinalPropertyModify finalPropertyModify = new FinalPropertyModify("123");
        Field a = FinalPropertyModify.class.getDeclaredField("a");
        a.setAccessible(true);
        a.set(finalPropertyModify, "12345");

        // 构造函数初始化
        Field ab = FinalPropertyModify.class.getDeclaredField("ab");
        ab.setAccessible(true);
        ab.set(finalPropertyModify, "123456");

        System.out.println("==================反射获取==================");
        System.out.println(a.get(finalPropertyModify));
        System.out.println(ab.get(finalPropertyModify));

        System.out.println("==================直接获取==================");
        System.out.println(finalPropertyModify.a);
        System.out.println(finalPropertyModify.ab);
    }
}

最终输出结果为:

[图片上传失败...(image-87672a-1656999539717)]

可以看到如果是直接赋值进行初始化final属性的话,会被JVM给优化掉,如果使用的是构造函数进行初始化属性是不是触发JVM优化的。我们再看一下boundsql类的属性定义,很显然sql属性是在构造函数中进行初始化的。我们可以大胆的去modify sql,但一定要注意不能修改成错误的SQL和一定要考虑安全问题,mybatis没有提供sql的修改方法也是考虑这一点,可能会不安全。

public class BoundSql {

  // 我们要修改的SQL属性
  private final String sql;
  private final List<ParameterMapping> parameterMappings;
  private final Object parameterObject;
  private final Map<String, Object> additionalParameters;
  private final MetaObject metaParameters;

  public BoundSql(Configuration configuration, String sql, List<ParameterMapping> parameterMappings, Object parameterObject) {
    this.sql = sql;
    this.parameterMappings = parameterMappings;
    this.parameterObject = parameterObject;
    this.additionalParameters = new HashMap<>();
    this.metaParameters = configuration.newMetaObject(additionalParameters);
  }

  public String getSql() {
    return sql;
  }

  public List<ParameterMapping> getParameterMappings() {
    return parameterMappings;
  }

  public Object getParameterObject() {
    return parameterObject;
  }

  public boolean hasAdditionalParameter(String name) {
    String paramName = new PropertyTokenizer(name).getName();
    return additionalParameters.containsKey(paramName);
  }

  public void setAdditionalParameter(String name, Object value) {
    metaParameters.setValue(name, value);
  }

  public Object getAdditionalParameter(String name) {
    return metaParameters.getValue(name);
  }
}

UserMapper.xml

很简单的一个SQL

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.allens.mybatis.mappers.UserMapper">
    <select id="selectUsers" parameterType="int" resultType="com.allens.mybatis.model.User">
        select * from user where id = 1 limit ${pageSize}
    </select>
</mapper>

测试

运行成功:

[图片上传失败...(image-5524a2-1656999539717)]

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,287评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,346评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,277评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,132评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,147评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,106评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,019评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,862评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,301评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,521评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,682评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,405评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,996评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,651评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,803评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,674评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,563评论 2 352

推荐阅读更多精彩内容