谈谈 MyBatis 的插件,除了分页你可能还有这些使用场景
扩展性是衡量软件质量的重要标准,MyBatis 作为一款优秀的持久层框架自然也提供了扩展点,那就是我们今天谈到的插件。MyBaits 的插件拦截内部组件方法的执行,利用插件可以插入自定义的逻辑,例如常用的支持物理分页的 PageHelper 插件。
使用 MyBatis 插件
插件在 MyBatis 中使用接口 Interceptor 表示,MyBatis 本身并未提供任何插件的实现,自定义的插件需要实现接口 Interceptor,示例如下:
public class MyBatisInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// ... 方法执行前插入自定义逻辑
Object result = invocation.proceed();
// ... 方法执行后处理结果
return result;
}
}
Interceptor 会拦截某些方法的执行,当 MyBatis 内部执行这些方法时就会调用 #intercept 方法,那么 MyBatis 怎么知道调用哪些方法时执行插件的方法呢?这需要用户告诉 MyBatis,使用如下的方式可以指定要拦截的方法。
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class MyBatisInterceptor implements Interceptor {
...
}
通过在自定义的 Interceptor 类上添加 @Intercepts 注解指定要拦截的方法,@Intercepts 的 value 属性是一个 @Signature 注解类型的数组,这表明同一个插件可以拦截多个方法的执行。@Signature 表示要拦截的方法的签名,需要分别指定要拦截的接口类型、方法名、方法参数。Java 8 开始已经支持重复注解,然而到目前为止 MyBatis 并未进行更新支持。
那么 Interceptor 可以拦截所有的接口方法调用?显然不太可能。Interceptor 可以拦截的接口包括 Executor、StatementHandler、ParameterHandler、ResultSetHandler,这四个接口贯穿 SQL 执行的整个过程。
定义了插件之后还要告诉 MyBatis如何使用我们的插件,这需要向 MyBatis 中的 Configuration 进行注册,如果使用 xml 定义 MyBatis 的配置,可以使用如下的方式进行注册:
<configuration>
<plugins>
<plugin interceptor="com.zzuhkp.blog.mybatis.MyBatisInterceptor">
<property name="customProperty" value="propertyValue"/>
</plugin>
</plugins>
</configuration>
其中property用来指定 Interceptor 中可以使用的属性,至此我们定义的插件就会在 MyBatis 执行 SQL 时执行。
理解 MyBatis 插件
上面主要是从使用方的角度说明如何自定义插件并向 MyBatis 中注册,下面对 MyBatis 插件的内部实现进行分析。先看插件 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
}
}
Interceptor 接口中只有一个 #intercept 方法需要重写,该方法有一个 Invocation 类型的参数用于获取拦截的方法信息,包括接口、方法、参数值。
#setProperties 方法则可以接收 xml 中配置的属性。
Interceptor 中还有一个重要的#plugin方法,该方法调用 Plugin 的方法,生成要拦截的接口的代理。查看其实现如下:
public class Plugin implements InvocationHandler {
// 代理的目标对象
private final Object target;
// 插件
private final Interceptor interceptor;
// 代理的方法
private final Map<Class<?>, Set<Method>> signatureMap;
private Plugin(Object target, Interceptor interceptor, Map<Class<?>, Set<Method>> signatureMap) {
this.target = target;
this.interceptor = interceptor;
this.signatureMap = signatureMap;
}
// 获取目标对象的代理
public static Object wrap(Object target, Interceptor interceptor) {
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
Class<?> type = target.getClass();
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
if (interfaces.length > 0) {
return Proxy.newProxyInstance(
type.getClassLoader(),
interfaces,
new Plugin(target, interceptor, signatureMap));
}
return target;
}
}
可以看到 Plugin 本身就是一个 InvocationHandler,#wrap 方法会获取目标类型的接口,实例化 Plugin 并生成目标类型的代理,当目标类型的方法被调用时就会调用 Plugin 的相关方法,具体如下:
public class Plugin implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
Set<Method> methods = signatureMap.get(method.getDeclaringClass());
if (methods != null && methods.contains(method)) {
// 执行拦截方法
return interceptor.intercept(new Invocation(target, method, args));
}
return method.invoke(target, args);
} catch (Exception e) {
throw ExceptionUtil.unwrapThrowable(e);
}
}
}
当调用目标类型的方法,如 Executor#query 方法时,会转而调用Plugin#invoke 方法,#invoke 方法把参数封装到 Invocation,然后调用我们定义的插件方法。
那么什么时候会生成目标类型的代理?具体又有哪些目标类型会被代理呢?跟踪源码,我们发现 Interceptor#plugin 方法会被如下的地方调用:
public class InterceptorChain {
// 插件列表
private final List<Interceptor> interceptors = new ArrayList<>();
// 生成目标类型的代理,目标方法调用时调用 Interceptor 中的方法
public Object pluginAll(Object target) {
for (Interceptor interceptor : interceptors) {
target = interceptor.plugin(target);
}
return target;
}
}
我们发现 InterceptorChain 内部保存了插件的列表,并调用 #pluginAll 方法生成目标类型的代理对象,这正是责任链设计模式的一种实现,调用目标方法时,各个插件中的方法会被依次调用。那插件什么时候被添加到 InterceptorChain 中,又什么时候生成哪些目标类型的代理呢?
public class Configuration {
protected final InterceptorChain interceptorChain = new InterceptorChain();
public void addInterceptor(Interceptor interceptor) {
interceptorChain.addInterceptor(interceptor);
}
}
Configuration 中保存了 InterceptorChain 的实例,并提供了添加插件的方法,当解析 xml 配置或手动添加插件时就会保存插件到 InterceptorChain 中。再看什么时候创建代理对象。
public class Configuration {
public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql);
// 创建 ParameterHandler 的代理对象
parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
return parameterHandler;
}
public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler,
ResultHandler resultHandler, BoundSql boundSql) {
ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds);
// 创建 ResultSetHandler 的代理对象
resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
return resultSetHandler;
}
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;
}
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
... 省略实例化 Executor 的代码
// 创建 Executor 的代理对象
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
}
Confuguration 提供了实例化 Executor、StatementHandler、ParameterHandler、ResultSetHandler 接口实例的方法,创建实例后会创建这些实例的代理对象。
总结插件的执行流程如下:
- 用户定义插件,并在 xml 配置中注册。
- MyBatis 解析 xml 配置,并将插件到 Configuration 中的 PluginChain 实例中。
- MyBatis 执行 SQL 时利用 Configuration 中的 PluginChain 创建 Executor、StatementHandler、ParameterHandler、ResultSetHandler 接口实例的代理对象。
- Executor、StatementHandler、ParameterHandler、ResultSetHandler 接口方法执行时执行代理对象的方法。
- 代理对象执行插件中的方法。
自定义 MyBatis 插件
MyBatis 中的插件常用的是分页,分页有开源框架 PageHelper,MyBatis-Plus 中可以使用 PaginationInterceptor 或 MybatisPlusInterceptor 作为分页插件。除了分页在日常开发中可能还有下面的场景需要使用 MyBatis 插件。
自动设置字段值到数据库记录
通常,我们会记录某一条数据库记录的创建人、创建时间、修改人、修改时间。如果手动在插入或者更新前设置,那么设置这些字段的代码将遍布项目中的各个地方。这个时候很容易考虑到的是使用 AOP 处理,因为我们使用的是 MyBatis 作为持久层框架,我们可以通过插件设置当前登录人及时间到记录中。
假定所有的数据库表对应的实体类都有如下的父类:
public class BaseEntity {
// 创建时间
private Date gmtCreate;
// 修改时间
private Date gmtModified;
// 创建人
private String createBy;
// 修改人
private String updateBy;
}
设置登录人及时间到数据库记录的插件的实现如下:
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
public class MybatisFiledSetInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement statement = (MappedStatement) args[0];
SqlCommandType sqlCommandType = statement.getSqlCommandType();
Object param = args[1];
if (sqlCommandType == SqlCommandType.INSERT) {
// insert 语句设置创建人、创建时间
this.setCreateProperty(param);
} else if (sqlCommandType == SqlCommandType.UPDATE) {
// update 语句设置更新人、更新时间
this.setUpdateProperty(param);
}
return invocation.proceed();
}
// 设置创建人及创建时间
private void setCreateProperty(Object param) {
if (param instanceof Map) {
for (Object value : ((Map<String, Object>) param).values()) {
this.doSetCreateProperty(value);
}
}
this.doSetCreateProperty(param);
}
private void doSetCreateProperty(Object obj) {
if (obj instanceof BaseEntity) {
BaseEntity entity = (BaseEntity) obj;
if (entity.getGmtCreate() == null) {
Date now = new Date();
entity.setGmtCreate(now);
}
if (StringUtils.isBlank(entity.getCreateBy())) {
entity.setCreateBy(RequestHolderUtil.getCurrentUser() == null ? "System" : RequestHolderUtil.getCurrentUser().getAccountName());
}
}
}
// 设置更新人及更新时间
private void setUpdateProperty(Object param) {
if (param instanceof Map) {
for (Object value : ((Map<String, Object>) param).values()) {
this.doSetUpdateProperty(value);
}
}
this.doSetUpdateProperty(param);
}
private void doSetUpdateProperty(Object obj) {
if (obj instanceof BaseEntity) {
BaseEntity entity = (BaseEntity) obj;
if (entity.getGmtModified() == null) {
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
entity.setGmtModified(now);
}
if (StringUtils.isBlank(entity.getUpdateBy())) {
entity.setUpdateBy(RequestHolderUtil.getCurrentUser() == null ? "System" : RequestHolderUtil.getCurrentUser().getAccountName());
}
}
}
}
这里拦截了 Executor#update 方法的执行,当使用 MyBatis 执行插入或更新语句时会调用该方法,MyBatis 有可能把参数封装到 Map 中,因此对 Map 做了特殊处理。如果参数为 BaseEntity ,则设置相应的字段到 BaseEntity 中。另外由于 BaseEntity 包含了创建和更新信息,有的数据库记录可能并不需要更新,或只需要记录创建时间,遵循接口隔离原则,可以把 BaseEntity 拆分成接口处理。
数据库字段加密
另一种场景是数据库字段加密,如用户的密码、姓名、手机号、地址等敏感信息,为了避免数据库密码泄露时暴露这些信息,存入数据库时需要进行加密,从数据库取数据时需要解密。可以在操作数据库前后手动加密或者解密,然而更省力的自然是通过 Mybatis 插件自动加密或解密。
我们可以创建一个用于字段的注解 @SensitiveField,当实体类字段上存在这个注解时,在插入或者更新数据库前使用自定义的 Encryptor 类对这个字段进行加密,查询数据库后使用自定义的 Decryptor 对包含这个注解的字段进行解密。
用于加密的 MyBatis 插件如下:
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
public class MyBatisEncryptionInterceptor implements Interceptor {
private Encryptor encryptor = new Encryptor();
private Decryptor decryptor = new Decryptor();
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object param = invocation.getArgs()[1];
// 执行更新前加密参数
this.handleEncrypt(param);
Object result = invocation.proceed();
// 执行更新后解密参数,避免后续使用
this.handleDecrypt(param);
return result;
}
// 处理数据库字段加密
private void handleEncrypt(Object param) {
if (param == null) {
return;
}
if (param instanceof Map) {
// 去重,避免重复加密
for (Object item : new HashSet<>(((Map<String, Object>) param).values())) {
encryptor.encrypt(item);
}
return;
}
encryptor.encrypt(param);
}
// 处理字段解密
private void handleDecrypt(Object param) {
if (param == null) {
return;
}
if (param instanceof Map) {
// 去重,避免重复解密
for (Object item : new HashSet<>(((Map<String, Object>) param).values())) {
decryptor.decrypt(item);
}
return;
}
decryptor.decrypt(param);
}
}
加密插件拦截 Executor#update 方法,当插入或更新时会执行该方法,需要留意的是 Map 中的值可能是重复的,这是因为 MyBatis 会把不同 key 存入同一个对象,因此需要去重,避免重复加密,另外加密之后还要进行解密,避免后续使用未加密的字段。
解密插件如下:
@Intercepts({@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})})
public class MyBatisDecryptionInterceptor implements Interceptor {
private Decryptor decryptor = new Decryptor();
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object result = invocation.proceed();
// 执行数据库字段解密
List<?> list = (List<?>) result;
if (!CollectionUtils.isEmpty(list)) {
for (Object item : list) {
decryptor.decrypt(item);
}
}
return result;
}
}
解密插件拦截了 Executor#query 方法,该方法会返回一个 List,我们直接对 List 中需要解密的字段即可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)