什么是 MyBatis 插件?
MyBatis 插件是 MyBatis 框架提供的一个扩展机制,它允许开发者在 MyBatis 的执行过程中插入自定义的逻辑。
MyBatis 插件的实现原理
MyBatis 插件的实现原理是通过拦截 MyBatis 的 Executor、StatementHandler、ParameterHandler 和 ResultSetHandler 等组件中的方法来实现的。开发者可以通过实现 MyBatis 提供的 Interceptor 接口,并重写其中的 intercept() 方法,在该方法中实现自定义的逻辑,然后将插件注册到 MyBatis 的 Configuration 中即可。
如何开发 MyBatis 插件?
MyBatis 插件的开发流程如下:
- 实现 Interceptor 接口,重写其中的 intercept() 方法,并在该方法中实现自定义的逻辑。
- 在 MyBatis 的配置文件中,通过
标签将插件注册到 MyBatis 中。 - 配置插件的属性,例如是否启用插件、插件的顺序等。
MyBatis 插件的应用场景是什么?
在实际应用中,MyBatis 插件可以帮助开发者实现许多自定义的功能。例如,通过拦截 Executor 中的 update() 方法,可以实现记录 SQL 执行时间、加解密数据等功能;通过拦截 ResultSetHandler 中的 handleResultSets() 方法,可以实现对查询结果进行拦截和修改,例如分页功能等。同时,MyBatis 插件也可以提高 MyBatis 的性能和扩展性,例如通过缓存查询结果等方式来减少数据库的访问次数,提高系统的性能。
插件开发案例
以下四个例子包括了拦截Executor、StatementHandler、ParameterHandler 和 ResultSetHandler这四个组件
打印sql执行时间插件
如果想要在执行 SQL 语句之前或之后做一些操作,比如修改 SQL 语句,添加日志,统计性能等,可以拦截 Executor 的 update 或 query 方法
例如我要打印sql语句的执行时间:
package cc.oolo.imgcdn.Plugin;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class SqlTimeInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取SQL语句
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
String sql = mappedStatement.getBoundSql(invocation.getArgs()[1]).getSql();
// 记录开始时间
long startTime = System.currentTimeMillis();
// 执行原始方法
Object result = invocation.proceed();
// 记录结束时间
long endTime = System.currentTimeMillis();
// 计算并打印执行时间
long time = endTime - startTime;
System.out.println("SQL: " + sql + ", Time: " + time + " ms");
return result;
}
}
然后再mybatis-config.xml中添加
<plugins>
<plugin interceptor="cc.oolo.imgcdn.Plugin.SqlTimeInterceptor" />
</plugins>
分页插件
如果想要在 SQL 语句生成之前或之后做一些操作,比如修改 SQL 语句,添加分页,分表等,可以拦截 StatementHandler 的 prepare 或 parameterize 方法
以分页为例子:
流程:如果是id为ByPage结尾的方法,则获取方法参数中的PageUtil的page和limit字段进行分页,并且将总数赋值给PageUtil的count字段,然后对原sql进行修改,添加limit字段和参数。
定义一个PageUtil类:
package cc.oolo.imgcdn.Plugin.Pojo;
import cc.oolo.imgcdn.Plugin.Anno.Encrypted;
public class PageUtil {
private int pageNum;
private int pageSize;
private int count;
public PageUtil() {
}
public PageUtil(int pageNum, int pageSize) {
this.pageNum = pageNum;
this.pageSize = pageSize;
}
public int getPageNum() {
return pageNum;
}
public void setPageNum(int pageNum) {
this.pageNum = pageNum;
}
public int getPageSize() {
return pageSize;
}
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
}
创建 PaginationInterceptor
package cc.oolo.imgcdn.Plugin;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Map;
import java.util.Properties;
import cc.oolo.imgcdn.Plugin.Pojo.PageUtil;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class,Integer.class})})
public class PaginationInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 从调用目标中获取语句处理器
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// MetaObject是一个用于访问对象属性的工具类,它基于Java的反射机制实现。
MetaObject metaObject = MetaObject.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY,SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY,new DefaultReflectorFactory());
// 获取映射语句的id
String value = (String) metaObject.getValue("delegate.mappedStatement.id");
// 如果id以ByPage结尾,说明是一个分页查询
if (value.endsWith("ByPage")) {
// 从调用参数中获取连接
Connection connection = (Connection) invocation.getArgs()[0];
// 获取原始sql
String sql = statementHandler.getBoundSql().getSql();
// 在执行原始sql之前,执行一个count语句来获取记录的总数
String count = "select count(0) from ("+ sql +") a";
// 使用count sql创建一个预处理语句
PreparedStatement preparedStatement = connection.prepareStatement(count);
// 获取参数处理器
ParameterHandler paramHandler = (ParameterHandler) metaObject.getValue("delegate.parameterHandler");
// 使用参数处理器为预处理语句设置参数
paramHandler.setParameters(preparedStatement);
// 执行count查询并获取结果集
ResultSet resultSet = preparedStatement.executeQuery();
// 如果mapper方法有多个参数,paramHandler.getParameterObject()是一个Map
if (paramHandler.getParameterObject() instanceof Map){
Map<String, Object> o = (Map<String,Object>)paramHandler.getParameterObject();
PageUtil pageUtil = null;
// 遍历参数,找到PageUtil对象(如果存在)
for (Object v : o.values()) {
if (v instanceof PageUtil){
pageUtil = (PageUtil) v;
// 如果有PageUtil对象,从结果集中获取count值并设置到PageUtil对象中
if (resultSet.next()) {
pageUtil.setCount(resultSet.getInt(1));
}
}
}
// 在原始sql后面添加一个limit子句来进行分页
String pageSql = sql + " limit " + (pageUtil.getPageNum()-1) * pageUtil.getPageSize() + ", " + pageUtil.getPageNum() * pageUtil.getPageSize();
System.out.println("处理前sql:" + sql);
System.out.println("处理后sql:" + pageSql);
// 将修改后的sql覆盖原sql语句
metaObject.setValue("delegate.boundSql.sql",pageSql);
}else{
//如果mapper方法只有一个参数,paramHandler.getParameterObject()就是该对象
PageUtil pageUtil = (PageUtil) paramHandler.getParameterObject();
pageUtil.setCount(resultSet.getInt(1));
// 在原始sql后面添加一个limit子句来进行分页
String pageSql = sql + " limit " + (pageUtil.getPageNum()-1) * pageUtil.getPageSize() + ", " + pageUtil.getPageNum() * pageUtil.getPageSize();
// 将修改后的sql覆盖原sql语句
metaObject.setValue("delegate.boundSql.sql",pageSql);
}
// 关闭结果集和预处理语句
resultSet.close();
preparedStatement.close();
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target,this);
}
@Override
public void setProperties(Properties properties) {
}
}
在使用时,只需要在mapper的抽象方法添加PageUtil参数,然后创建PageUtil对象,给pageNum和pageSize赋值,然后将pageUtil对象作为参数即可:
返回结果:
{
"success": true,
"code": 200,
"message": "成功",
"data": {
"agent": [
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "123456"
},
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "123456"
},
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "123456"
},
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "e10adc3949ba59abbe56e057f20f883e"
},
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "e10adc3949ba59abbe56e057f20f883e"
},
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "e10adc3949ba59abbe56e057f20f883e"
},
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "e10adc3949ba59abbe56e057f20f883e"
},
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "e10adc3949ba59abbe56e057f20f883e"
},
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "e10adc3949ba59abbe56e057f20f883e"
},
{
"date": "2023-01-01",
"name": "张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email": "812257301@qq.com",
"password": "e10adc3949ba59abbe56e057f20f883e"
}
],
"page": {
"pageNum": 1,
"pageSize": 10,
"count": 13
}
}
}
加密插件
如果想要在 SQL 语句的参数设置之前或之后做一些操作,比如修改参数值,添加类型转换等,可以拦截 ParameterHandler 的 setParameters 方法
我们以加密的场景为例,定义一个注解 @Encrypted,参数为type,表示使用的加密方式
package cc.oolo.imgcdn.Plugin.Anno;
import java.lang.annotation.*;
@Documented
@Retention(RetentionPolicy.RUNTIME)
//表示注解是标注在字段上面
@Target({ElementType.FIELD})
public @interface Encrypted {
String type() default "md5";
}
因为需要根据注解的值选择使用不同的加密方法,这里采用了 策略模式+工厂模式
定义一个 EncryptionStrategy 接口
package cc.oolo.imgcdn.Plugin.Encrypt;
public interface EncryptionStrategy {
String encrypt(String text);
}
创建 Md5EncryptionStrategy 类,实现 EncryptionStrategy 接口
package cc.oolo.imgcdn.Plugin.Encrypt;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class Md5EncryptionStrategy implements EncryptionStrategy{
@Override
public String encrypt(String text) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(text.getBytes());
byte[] digest = md.digest();
StringBuffer sb = new StringBuffer();
for (byte b : digest) {
sb.append(Integer.toHexString((b & 0xFF) | 0x100).substring(1, 3));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return null;
}
}
}
创建一个工厂类 EncryptionFactory,使用map,将对应的实现类注册到工厂中
package cc.oolo.imgcdn.Plugin.Encrypt;
import java.util.HashMap;
import java.util.Map;
public class EncryptionFactory {
public static Map<String,EncryptionStrategy> map = new HashMap<>();
static {
map.put("md5",new Md5EncryptionStrategy());
}
public static String encode(String type,String text){
return map.get(type).encrypt(text);
}
}
编写插件:
package cc.oolo.imgcdn.Plugin;
import cc.oolo.imgcdn.Plugin.Anno.Encrypted;
import cc.oolo.imgcdn.Plugin.Encrypt.EncryptionFactory;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import java.lang.reflect.Field;
import java.sql.PreparedStatement;
import java.util.Map;
@Intercepts(
@Signature(type = ParameterHandler.class,method = "setParameters",args = {PreparedStatement.class})
)
public class EncryptedPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
ParameterHandler paramHandler = (ParameterHandler) invocation.getTarget();
// 如果 mapper 中抽象方法的参数有多个,那么paramHandler.getParameterObject()拿到的对象是一个Map,如果只有一个,那么拿到的对象就是该对象,所以这里需要进行判断
if (paramHandler.getParameterObject() instanceof Map){
Map<String, Object> map = (Map<String, Object>) paramHandler.getParameterObject();
for (Map.Entry<String, Object> entry : map.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// 如果有多个参数,那么每个参数都会对应有一个param,其value跟原有参数是相同的,所以会重复处理
// 跳过以"param"开头的键
if (key.startsWith("param")){
continue;
}
// 根据加密注解设置值对象的字段
setFields(value);
}
}else{
// 如果参数对象不是一个map,直接获取它
Object parameterObject = paramHandler.getParameterObject();
setFields(parameterObject);
}
// 继续执行调用
return invocation.proceed();
}
/**
* 根据加密注解设置参数对象的字段
* @param parameterObject
* @throws IllegalAccessException
*/
private void setFields(Object parameterObject) throws IllegalAccessException {
for (Field field : parameterObject.getClass().getDeclaredFields()) {
if (field.isAnnotationPresent(Encrypted.class)){
// 如果字段有加密注解,获取它的类型
Encrypted encrypted = field.getAnnotation(Encrypted.class);
String type = encrypted.type();
field.setAccessible(true);
// type 为@Encrypted注解的值,通过工厂类选择不同的加密方式
String encode = EncryptionFactory.encode(type, String.valueOf(field.get(parameterObject)));
// 将字段值设置为编码后的值
field.set(parameterObject,encode);
}
}
}
}
在实体类的字段上添加 @Encrypeted(type = "md5") 即可对该字段进行加密
package cc.oolo.imgcdn.Entity;
import cc.oolo.imgcdn.Plugin.Anno.Encrypted;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AgentEntity {
// private String id;
private String date;
private String name;
private int age;
private String address;
private String phone;
private String email;
@Encrypted(type = "md5")
private String password;
}
测试:使用postman插入一条数据
http://localhost:8090/insertAgent
ResultBody(json);
{
"date":"2023-01-01",
"name":"张三",
"age": 1,
"address": "重庆市",
"phone": "1008611",
"email":"812257301@qq.com",
"password":"123456"
}
数据脱敏插件
如果想要在 SQL 语句的结果集处理之前或之后做一些操作,比如修改结果集,添加数据过滤等,可以拦截 ResultSetHandler 的 handleResultSets 或 handleResult 方法
以数据脱敏为例:有时候我们不希望将用户完整的姓名或者手机号展示给前端,通常都会在手机号或者名字部分字符以*
代替,例如 133****4654,张*,或 张*三
定义一个接口,并且继承 Function<String, Object> ,表示该接口是一个函数式接口
package cc.oolo.imgcdn.Plugin.Desensitization;
import java.util.function.Function;
public interface SensitiveInterface extends Function<String, Object> {
}
创建一个枚举类,每一个枚举实例使用了lambda表达式,也就是说每个枚举实例都可以看作是一个匿名内部类
package cc.oolo.imgcdn.Plugin.Desensitization;
public enum SensitiveEnum {
USERNAME(s -> s.replaceAll("(\\S)\\S(\\S*)","$1*$2")),
ID_CARD(s -> s.replaceAll("(\\d{4})\\d{10}(\\w{4})","$1****$2")),
PHONE(s -> s.replaceAll("(\\d{3})\\d{4}(\\d{4})","$1****$2")),
ADDRESS(s -> s.replaceAll("(\\S{8})\\S{4}(\\S*)\\S{4}","$1****$2****")),
;
private final SensitiveInterface sensitiveInterface;
SensitiveEnum(SensitiveInterface sensitiveInterface) {
this.sensitiveInterface = sensitiveInterface;
}
public SensitiveInterface getSensitiveInterface() {
return sensitiveInterface;
}
}
自定义注解 @Sensitive,该注解应用在字段上
package cc.oolo.imgcdn.Plugin.Desensitization;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Sensitive {
SensitiveEnum strategy();
}
创建 mybatis 拦截器类,先执行,获取结果,然后通过反射遍历结果,判断字段是否存在 @Sensitive 注解,如果存在,则获取注解中指定的脱敏策略,并使用该策略对该字段的值进行脱敏处理。
package cc.oolo.imgcdn.Plugin;
import cc.oolo.imgcdn.Plugin.Desensitization.Sensitive;
import cc.oolo.imgcdn.Plugin.Desensitization.SensitiveEnum;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import java.lang.reflect.Field;
import java.sql.Statement;
import java.util.List;
@Intercepts(
@Signature(type = ResultSetHandler.class,method = "handleResultSets",args = {Statement.class})
)
public class DesensitizationPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
List<Object> proceed = (List<Object>)invocation.proceed();
for (Object o : proceed) {
masking(o);
}
return proceed;
}
private void masking(Object source) throws IllegalAccessException {
Class<?> aClass = source.getClass();
Field[] declaredFields = aClass.getDeclaredFields();
for (Field declaredField : declaredFields) {
if (declaredField.isAnnotationPresent(Sensitive.class)) {
Sensitive annotation = declaredField.getAnnotation(Sensitive.class);
SensitiveEnum strategy = annotation.strategy();
declaredField.setAccessible(true);
Object apply = strategy.getSensitiveInterface().apply(String.valueOf(declaredField.get(source)));
declaredField.set(source,apply);
}
}
}
}
测试:
package cc.oolo.imgcdn.Entity;
import cc.oolo.imgcdn.Plugin.Anno.Encrypted;
import cc.oolo.imgcdn.Plugin.Desensitization.Sensitive;
import cc.oolo.imgcdn.Plugin.Desensitization.SensitiveEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AgentEntity {
// private String id;
private String date;
@Sensitive(strategy = SensitiveEnum.USERNAME)
private String name;
private int age;
private String address;
@Sensitive(strategy = SensitiveEnum.PHONE)
private String phone;
private String email;
@Encrypted(type = "md5")
private String password;
}
结果:
{
"success": true,
"code": 200,
"message": "成功",
"data": {
"agent": [
{
"date": "2023-01-01",
"name": "张*",
"age": 1,
"address": "重庆市",
"phone": "133****9874",
"email": "812257301@qq.com",
"password": "e10adc3949ba59abbe56e057f20f883e"
},
{
"date": "2023-01-01",
"name": "王*",
"age": 1,
"address": "重庆市",
"phone": "183****9876",
"email": "812257301@qq.com",
"password": "fcea920f7412b5da7be0cf42b8c93759"
},
{
"date": "2023-01-01",
"name": "王*五",
"age": 1,
"address": "重庆市",
"phone": "143****9876",
"email": "812257301@qq.com",
"password": "95d47be0d380a7cd3bb5bbe78e8bed49"
},
{
"date": "2023-01-01",
"name": "王*六",
"age": 1,
"address": "重庆市",
"phone": "143****9526",
"email": "812257301@qq.com",
"password": "b3c690dd568e6d8b5f82053d47356581"
}
],
"page": {
"pageNum": 1,
"pageSize": 10,
"count": 4
}
}
}