一、前言
关于Mybatis插件,大部分人都知道,也都使用过,但很多时候,我们仅仅是停留在表面上,知道Mybatis插件可以在DAO层进行拦截,如打印执行的SQL语句日志,做一些权限控制,分页等功能;但对其内部实现机制,涉及的软件设计模式,编程思想往往没有深入的理解。
本篇案例将帮助读者对Mybatis插件的使用场景,实现机制,以及其中涉及的编程思想进行一个小结,希望对以后的编程开发工作有所帮助。
注:本案例以mybatis 3.4.7-SNAPSHOT版本为例。
二、Mybatis插件典型适用场景
分页功能
mybatis的分页默认是基于内存分页的(查出所有,再截取),数据量大的情况下效率较低,不过使用mybatis插件可以改变该行为,只需要拦截StatementHandler类的prepare方法,改变要执行的SQL语句为分页语句即可;
公共字段统一赋值
一般业务系统都会有创建者,创建时间,修改者,修改时间四个字段,对于这四个字段的赋值,实际上可以在DAO层统一拦截处理,可以用mybatis插件拦截Executor类的update方法,对相关参数进行统一赋值即可;
性能监控
对于SQL语句执行的性能监控,可以通过拦截Executor类的update, query等方法,用日志记录每个方法执行的时间;
其它
其实mybatis扩展性还是很强的,基于插件机制,基本上可以控制SQL执行的各个阶段,如执行阶段,参数处理阶段,语法构建阶段,结果集处理阶段,具体可以根据项目业务来实现对应业务逻辑。
三、Mybatis插件介绍
插件接口
mybatis 中使用插件,需要实现接口
org.apache.ibatis.plugin.Interceptor
,如下所示:
public interface Interceptor {
Object intercept(Invocation invocation) throws Throwable;
default Object plugin(Object target) {
return Plugin.wrap(target, this);
}
default void setProperties(Properties properties) {
// NOP
}
}
这里其实最核心的是 Object intercept(Invocation invocation)
方法,这是我们需要实现的方法。
Invocation 对象
那么核心方法中的 Invocation
是个什么概念呢?
public class Invocation {
private final Object target;
private final Method method;
private final Object[] args;
public Invocation(Object target, Method method, Object[] args) {
this.target = target;
this.method = method;
this.args = args;
}
public Object getTarget() {
return target;
}
public Method getMethod() {
return method;
}
public Object[] getArgs() {
return args;
}
public Object proceed() throws InvocationTargetException, IllegalAccessException {
return method.invoke(target, args);
}
}
这个东西包含了四个概念:
- target 拦截的对象
- method 拦截 target 中的具体方法,也就是说 Mybatis 插件的粒度是精确到方法级别的。
- args 拦截到的参数。
- proceed 执行被拦截到的方法,你可以在执行的前后做一些事情。
拦截签名
既然我们知道了 Mybatis 插件的粒度是精确到方法级别的,那么疑问来了,插件如何知道轮到它工作了呢?
所以 Mybatis 设计了签名机制来解决这个问题,通过在插件接口上使用注解 @Intercepts
标注来解决这个问题。
@Intercepts(
@Signature( type = ResultSetHandler.class,
method = "handleResultSets",
args = {Statement.class}
)
)
就像上面一样,事实上就等于配置了一个 Invocation
。
插件的作用域
那么问题又来了,Mybatis 插件能拦截哪些对象,或者说插件能在哪个生命周期阶段起作用呢?它可以拦截以下四大对象:
- Executor 是 SQL 执行器,包含了组装参数,组装结果集到返回值以及执行 SQL 的过程,粒度比较粗。
- StatementHandler 用来处理 SQL 的执行过程,我们可以在这里重写 SQL 非常常用。
- ParameterHandler 用来处理传入 SQL 的参数,我们可以重写参数的处理规则。
- ResultSetHandler 用于处理结果集,我们可以重写结果集的组装规则。
你需要做的就是明确的你的业务需要在上面四个对象的哪个处理阶段拦截处理即可。
MetaObject
Mybatis 提供了一个工具类 org.apache.ibatis.reflection.MetaObject
。它通过反射来读取和修改一些重要对象的属性。我们可以利用它来处理四大对象的一些属性,这是 Mybatis 插件开发的一个常用工具类。
- Object getValue(String name) 根据名称获取对象的属性值,支持 OGNL 表达式。
- void setValue(String name, Object value) 设置某个属性的值。
- Class<?> getSetterType(String name) 获取 setter 方法的入参类型。
- Class<?> getGetterType(String name) 获取 getter 方法的返回值类型。
通常我们使用 SystemMetaObject.forObject(Object object)
来实例化 MetaObject
对象。你会在接下来的实战 DEMO 中看到我使用它。
脱敏插件实战
接下来脱敏需求实现一下。首先需要对脱敏字段进行标记并确定使用的脱敏策略。
编写脱敏函数:
/**
* 具体策略的函数
**/
public interface Desensitizer extends Function<String,String> {
}
编写脱敏策略枚举:
/**
* 脱敏策略.
*
*/
public enum SensitiveStrategy {
/**
* Username sensitive strategy.
*/
USERNAME(s -> s.replaceAll("(\\S)\\S(\\S*)", "$1*$2")),
/**
* Id card sensitive type.
*/
ID_CARD(s -> s.replaceAll("(\\d{4})\\d{10}(\\w{4})", "$1****$2")),
/**
* Phone sensitive type.
*/
PHONE(s -> s.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2")),
/**
* Address sensitive type.
*/
ADDRESS(s -> s.replaceAll("(\\S{8})\\S{4}(\\S*)\\S{4}", "$1****$2****"));
private final Desensitizer desensitizer;
SensitiveStrategy(Desensitizer desensitizer) {
this.desensitizer = desensitizer;
}
/**
* Gets desensitizer.
*
* @return the desensitizer
*/
public Desensitizer getDesensitizer() {
return desensitizer;
}
}
编写脱敏字段的标记注解:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Sensitive {
SensitiveStrategy strategy();
}
返回对象中如果某个字段需要脱敏,只需要通过标记就可以了。例如下面这样:
@Data
public class UserInfo {
private static final long serialVersionUID = -8938650956516110149L;
private Long userId;
@Sensitive(strategy = SensitiveStrategy.USERNAME)
private String name;
private Integer age;
}
然后就是编写插件了,我可以确定的是需要拦截的是 ResultSetHandler
对象的 handleResultSets
方法,我们只需要实现插件接口 Interceptor
并添加签名就可以了。全部逻辑如下:
@Slf4j
@Intercepts(@Signature(type = ResultSetHandler.class,
method = "handleResultSets",
args = {Statement.class}))
public class SensitivePlugin implements Interceptor {
@SuppressWarnings("unchecked")
@Override
public Object intercept(Invocation invocation) throws Throwable {
List<Object> records = (List<Object>) invocation.proceed();
// 对结果集脱敏
records.forEach(this::sensitive);
return records;
}
private void sensitive(Object source) {
// 拿到返回值类型
Class<?> sourceClass = source.getClass();
// 初始化返回值类型的 MetaObject
MetaObject metaObject = SystemMetaObject.forObject(source);
// 捕捉到属性上的标记注解 @Sensitive 并进行对应的脱敏处理
Stream.of(sourceClass.getDeclaredFields())
.filter(field -> field.isAnnotationPresent(Sensitive.class))
.forEach(field -> doSensitive(metaObject, field));
}
private void doSensitive(MetaObject metaObject, Field field) {
// 拿到属性名
String name = field.getName();
// 获取属性值
Object value = metaObject.getValue(name);
// 只有字符串类型才能脱敏 而且不能为null
if (String.class == metaObject.getGetterType(name) && value != null) {
Sensitive annotation = field.getAnnotation(Sensitive.class);
// 获取对应的脱敏策略 并进行脱敏
SensitiveStrategy type = annotation.strategy();
Object o = type.getDesensitizer().apply((String) value);
// 把脱敏后的值塞回去
metaObject.setValue(name, o);
}
}
}
然后配置脱敏插件使之生效:
@Bean
public SensitivePlugin sensitivePlugin(){
return new SensitivePlugin();
}
操作查询获得结果 UserInfo(userId=123123, name=李*龙, age=28)
,成功将指定字段进行了脱敏。
注: 请注意一定要熟悉四大对象的生命周期,否则自写插件可能会造成意想不到的结果。
与其称为Mybatis插件,不如叫Mybatis拦截器,更加符合其功能定位,实际上它就是一个拦截器,应用代理模式,在方法级别上进行拦截。
支持拦截的方法
- 执行器Executor(update、query、commit、rollback等方法);
- 参数处理器ParameterHandler(getParameterObject、setParameters方法);
- 结果集处理器ResultSetHandler(handleResultSets、handleOutputParameters等方法);
- SQL语法构建器StatementHandler(prepare、parameterize、batch、update、query等方法);
拦截阶段
那么这些类上的方法都是在什么阶段被拦截的呢?为理解这个问题,我们先看段简单的代码(摘自mybatis源码中的单元测试SqlSessionTest类),来了解下典型的mybatis执行流程,如下代码所示:
以上代码主要完成以下功能:
- 读取mybatis的xml配置文件信息
- 通过SqlSessionFactoryBuilder创建SqlSessionFactory对象
- 通过SqlSessionFactory获取SqlSession对象
- 执行SqlSession对象的selectList方法,查询结果
- 关闭SqlSession
如下是时序图,在整个时序图中,涉及到mybatis插件部分已标红,基本上就是体现在上文中提到的四个类上,对这些类上的方法进行拦截。
四、Mybatis插件实现机制
插件配置信息的加载
先来看下mybatis是如何加载插件配置的,对应的xml配置信息如下:
对应的解析代码如下,主要做以下工作:
- 根据解析到的类信息创建Interceptor对象;
- 调用setProperties方法设置属性变量;
- 添加到Configuration的interceptorChain拦截器链中;
以上逻辑对应的时序图如下:
代理对象的生成
Mybatis插件的实现机制主要是基于动态代理实现的,其中最为关键的就是代理对象的生成,所以有必要来了解下这些代理对象是如何生成的。
Executor代理对象:
ParameterHandler代理对象
ResultSetHandler代理对象
StatementHandler代理对象
观察源码,发现这些可拦截的类对应的对象生成都是通过InterceptorChain的pluginAll方法来创建的,进一步观察pluginAll方法,如下:
遍历所有拦截器,调用拦截器的plugin方法生成代理对象,注意生成代理对象重新赋值给target,所以如果有多个拦截器的话,生成的代理对象会被另一个代理对象代理,从而形成一个代理链条,执行的时候,依次执行所有拦截器的拦截逻辑代码;
接下来看一下我们在编写拦截器的时候,一个典型的plugin方法实现方式,如下:
再进一步查看wrap方法,如下:
典型的动态代理实现,调用的是Proxy.newProxyInstance方法来生成代理对象。
以上逻辑对应的时序图如下,这里我们假设声明了两个拦截器,那么在创建target代理对象的时候,最终返回的代理对象proxy2,实际上代理了proxy1,而proxy1又代理了target,:
拦截逻辑的执行
由于真正去执行Executor、ParameterHandler、ResultSetHandler和StatementHandler类中的方法的对象是代理对象(建议将代理对象转为class文件,反编译查看其结构,帮助理解),所以在执行方法时,首先调用的是Plugin类(实现了InvocationHandler接口)的invoke方法,如下:
首先根据执行方法所属类获取拦截器中声明需要拦截的方法集合;
判断当前方法需不需要执行拦截逻辑,需要的话,执行拦截逻辑方法(即Interceptor接口的intercept方法实现),不需要则直接执行原方法。
可以关注下Interceptor接口的intercept方法实现,一般需要用户自定义实现逻辑,其中有一个重要参数,即Invocation类,通过改参数我们可以获取执行对象,执行方法,以及执行方法上的参数,从而进行各种业务逻辑实现,一般在该方法的最后一句代码都是invocation.proceed()(内部执行method.invoke方法),否则将无法执行下一个拦截器的intercept方法。
以上逻辑对应的时序图如下,这里我们以执行executor对象的query方法为例,且假设有两个拦截器存在:
五、Mybatis插件开发例子
这里以分页插件为例,来了解下一般mybatis插件的编写规则,如下所示:
主要需要实现三个方法:
- intercept:在此实现自己的拦截逻辑,可从Invocation参数中拿到执行方法的对象,方法,方法参数,从而实现各种业务逻辑, 如下代码所示,从invocation中获取的statementHandler对象即为被代理对象,基于该对象,我们获取到了执行的原始SQL语句,以及prepare方法上的分页参数,并更改SQL语句为新的分页语句,最后调用invocation.proceed()返回结果。
- plugin:生成代理对象;
- setProperties:设置一些属性变量;
六、小结
简单的说,mybatis插件就是对ParameterHandler、ResultSetHandler、StatementHandler、Executor这四个接口上的方法进行拦截,利用JDK动态代理机制,为这些接口的实现类创建代理对象,在执行方法时,先去执行代理对象的方法,从而执行自己编写的拦截逻辑,所以真正要用好mybatis插件,主要还是要熟悉这四个接口的方法以及这些方法上的参数的含义;
另外,如果配置了多个拦截器的话,会出现层层代理的情况,即代理对象代理了另外一个代理对象,形成一个代理链条,执行的时候,也是层层执行;
关于mybatis插件涉及到的设计模式和软件思想如下:
- 设计模式:代理模式、责任链模式;
- 软件思想:AOP编程思想,降低模块间的耦合度,使业务模块更加独立;
一些注意事项:
- 不要定义过多的插件,代理嵌套过多,执行方法的时候,比较耗性能;
- 拦截器实现类的intercept方法里最后不要忘了执行invocation.proceed()方法,否则多个拦截器情况下,执行链条会断掉;