Spring AOP实现记录mybatis-plus操作数据变化01

简介

通过spring aop记录接口的执行日志,或者基于spel表达式记录动态日志(提取),已经是非常常见技术实现。它们都是基于接口签名及入参的日志记录,主要在于请求一侧。 而对用户发起业务请求的过程, 产生了什么数据变化及其结果侧处理,目前缺少较好的技术实现方案。 本文记录通过spring aop实现记录mybatis-plus mapper接口,在执行删除及修改数据时操作日志,自动比对历史数据. 分析字段数据变化。 来探讨记录日志结果一种技术方案。

正文

现状

自动数据比对的功能。涉及到某一个接口执行过程中,新增/删除/修改

  • 新增: 大部分是入参触发的记录起来较为容易
  • 删除: 需要记录删除指定记录之前的数据
  • 修改: 查询修改前的数据,生成对比分析

目前看到大部分实现基于spel表达式加日志上下文中传递“旧”对象操作。(oldObject方法提到方法入参上也可以)
在这里插入图片描述
首先这种方式已经有了一定的封装性。相比完全手写方式有很大的改进。但是在笔者看来还是不够优雅。

  1. 方法内部入侵: 对于业务方法还是存在一定的入侵性,需要在上下文中传递旧的日志对象。
  2. 方法参数入侵:如果oldObject提到方法参数中, 业务方法要改变方法签名,而且调用方法者,也依然要补充查询逻辑。
  3. 查询成本不低: 一个业务操作触发可能不是一张简单的表, 而可能是多张。为了构造oldOject的编码成本不小。每使用一次比对功能更都得针对相关业务开发一次。

分析

产出上面问题得根本原因在于,有"业务含义"的日志一般在接口层或者业务层,就是我们说所得controller和service. 而数据变化的操作往往是在持久层(dao). 由于一般上层是不知道下层逻辑。那么谁知道呢? 只有研发人员知道,因此需要人为编写逻辑介入,比如正确获取旧数据并调用方法比对数据差异,这部分代码其实是业务无关的, 这样就导致了一定入侵性及编码成本。

解决之道: 让数据变化记录回到持久层处理, 不破分层的结构才能沿用aop方式无入侵处理

那么回到持久层后存在两个问题。

  1. 框架怎么知道什么时候记录日志,怎么知道dao操作的不同表,应该归属于某一次web接口请求过程种执行的数据变化
  2. 框架怎么知道它要该怎么记录日志?

首先第一问题可以根据当前得线程标记当前方法在上层日志切面范围,即日志上下文。不同表dao操作可以根据当前web请求生成的traceId关联,这个不在本次介绍重点,可以自己实现也可以利用springboot的slueth实现。第二个问题,就需要观察dao接口得特点,找出它们的共性, 现在大部分项目都是使用mybatis-plus作为持久层实现,先看一个普通的示例


//用户实体对象
@TableName("account")
public class Account implements Serializable {

    @TableId(type = IdType.AUTO)
    private Long id;
    private String name;
    private String username;
}

// 用户接口
public interface AccountMapper extends BaseMapper<Account> {
}

特点:

  1. 持久化的业务对象持久层都是继承BaseMapper对象。意味着AccountMapper接口增删改查都是BaseMapper实现的
  2. Account对象是有主键标识,并且使用@TableId 注解标记

思路:

  1. 因为有了公共父接口那么我们就可以对update/delete接口做统一增强,增强逻辑里正好还可以调用selelctOne接口。
  2. 有了统一主键标识就可以在运行时提取对象的的主键Id

有了这两点就可以实现

  • 对业务代码无入侵
  • 分析数据分析自动进行,无需额外编码.

功能实现

定义AOP拦截器MybatisPlusMethodInterceptor

MybatisPlusMethodInterceptor 实现MethodInterceptor接口,作为方法增强的入口.

public class MybatisPlusMethodInterceptor extends DataLogAspectSupport implements MethodInterceptor {
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        // 非BaseMapper类型跳过增强
        if (!(invocation.getThis() instanceof BaseMapper)) {
            return invocation.proceed();
        }
        Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
       return invoke(invocation.getThis(), targetClass, invocation.getMethod(), invocation.getArguments(), invocation::proceed);
    }
}

DataLogAspectSupport封装实现日志记录逻辑

实现DataLogAspectSupport数据记录支持

由于mybatis-plus的mapper接口都是实现BaseMapper

public interface AccountMapper extends BaseMapper<Account> {
}

利用这个特点可以实现统一逻辑处理. update和delete操作,结合反射获取mybatis-plus注解@TableId

@TableName("account")
public class Account {

    @TableId(type = IdType.AUTO)
    private Long id;
    private String name;
    private String username;
    //setter/getter省略
}

即可获取当前记录的主键,从而自动查询操作前数据,生成比对信息

public class DataLogAspectSupport {
    private static final Logger LOGGER = LoggerFactory.getLogger(DataLogAspectSupport.class);

    public Object invoke(Object target, Class<?> targetClass, Method method, Object[] args,
                            final InvocationCallback invocation) throws Throwable {
        LogInfo logInfo = this.recordLog(target, targetClass, method, args);
        long startTime = System.currentTimeMillis();
        Object result = null;
        try {
            result = invocation.proceedWithLog();
        } catch (Throwable t) {
            if (logInfo != null) {
                logInfo.setException(ExceptionUtils.getRootCause(t).getMessage());
            }
        } finally {
            long timeout = System.currentTimeMillis() - startTime;
            logInfo.setTimeout(timeout);
        }
        LOGGER.info("[easy-log]{}:\n{}", logInfo.getMethod(), JSON.toJSONString(logInfo, true));
        return result;
    }

    private LogInfo recordLog(Object target, Class<?> targetClass, Method method, Object[] args) {
        LogInfo logInfo = new LogInfo();
        BaseMapper baseMapper = (BaseMapper) target;
        String methodName = ((Class)targetClass.getGenericInterfaces()[0]).getSimpleName() + "." + method.getName();
        logInfo.setMethod(methodName);
        // TODO 后续支持更多方法
        if (methodName.contains("updateById") || methodName.contains("deleteById")) {
            LOGGER.debug("[easy-log][{}] 执行数据变化分析--开始", methodName);
            Serializable primaryKey = this.getPrimaryKey(args[0]);
            LOGGER.debug("[easy-log][{}] key:[{}] current:{}", methodName, primaryKey, JSON.toJSONString(args[0]));
            Object result = baseMapper.selectById(primaryKey);
            LOGGER.debug("[easy-log][{}] key:[{}] history:{}", methodName, primaryKey, JSON.toJSONString(result));
            if (methodName.contains("updateById")){
                try {
                    List<CompareResult> compareResultList = this.compareTowObject(result, args[0]);
                    logInfo.setDataSnapshot(JSON.toJSONString(compareResultList));
                    LOGGER.debug("[easy-log][{}] key:[{}] compareResult:" + JSON.toJSONString(compareResultList), methodName, primaryKey);
                    for (CompareResult compareResult : compareResultList) {
                        String report = compareResult.getFieldName() + "【" + compareResult.getFieldComment() + "】值:" + compareResult.getOldValue() + " => " + compareResult.getNewValue();
                        LOGGER.debug(report);
                    }
                    LOGGER.debug("[easy-log][{}] 执行数据变化分析--结束", methodName);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            } else {
                logInfo.setDataSnapshot(JSON.toJSONString(result));
            }

        }
        return logInfo;
    }


    /**
     * 对比两个对象
     *
     * @param oldObj 旧对象
     * @param newObj 新对象
     */
    protected List<CompareResult> compareTowObject(Object oldObj, Object newObj) throws IllegalAccessException {
        List<CompareResult> list = new ArrayList<>();
        //获取对象的class
        Class<?> clazz1 = oldObj.getClass();
        Class<?> clazz2 = newObj.getClass();
        //获取对象的属性列表
        Field[] field1 = clazz1.getDeclaredFields();
        Field[] field2 = clazz2.getDeclaredFields();
        //遍历属性列表field1
        for (int i = 0; i < field1.length; i++) {
            //遍历属性列表field2
            for (int j = 0; j < field2.length; j++) {
                //如果field1[i]属性名与field2[j]属性名内容相同
                if (field1[i].getName().equals(field2[j].getName())) {
                    field1[i].setAccessible(true);
                    field2[j].setAccessible(true);
                    if (field2[j].get(newObj) == null) {
                        continue;
                    }
                    //如果field1[i]属性值与field2[j]属性值内容不相同
                    if (!compareTwo(field1[i].get(oldObj), field2[j].get(newObj))) {
                        CompareResult r = new CompareResult();
                        r.setFieldName(field1[i].getName());
                        r.setOldValue(field1[i].get(oldObj));
                        r.setNewValue(field2[j].get(newObj));
                        // TODO 获取属性名称功能暴露出去
//                        ApiModelProperty apiModelProperty = field1[i].getAnnotation(ApiModelProperty.class);
//                        if (apiModelProperty != null) {
//                            r.setFieldComment(apiModelProperty.value());
//                        }

                        list.add(r);
                    }
                    break;
                }
            }
        }
        return list;
    }

    @FunctionalInterface
    protected interface InvocationCallback {

        @Nullable
        Object proceedWithLog() throws Throwable;
    }

    private Serializable getPrimaryKey(Object et) {
        // 反射获取实体类
        Class<?> clazz = et.getClass();
        // 不含有表名的实体就默认通过
        if (!clazz.isAnnotationPresent(TableName.class)) {
            return (Serializable) et;
        }
        // 获取表名
        TableName tableName = clazz.getAnnotation(TableName.class);
        String tbName = tableName.value();
        if (StringUtils.isBlank(tbName)) {
            return null;
        }
        String pkName = null;
        String pkValue = null;
        // 获取实体所有字段
        Field[] fields = clazz.getDeclaredFields();
        for (Field field : fields) {
            // 设置些属性是可以访问的
            field.setAccessible(true);
            if (field.isAnnotationPresent(TableId.class)) {
                // 获取主键
                pkName = field.getName();
                try {
                    // 获取主键值
                    pkValue = field.get(et).toString();
                } catch (Exception e) {
                    pkValue = null;
                }

            }
        }
        return pkValue;

    }

    /**
     * 对比两个数据是否内容相同
     *
     * @param object1,object2
     * @return boolean类型
     */
    private boolean compareTwo(Object object1, Object object2) {

        if (object1 == null && object2 == null) {
            return true;
        }
        if (object1 == null && object2 != null) {
            return false;
        }
        if (object1.equals(object2)) {
            return true;
        }
        return false;
    }
}
  • 日志对象
public class LogInfo {
    /** 日志主键*/
//    @TableId(type = IdType.UUID)
    private String logId;

    /** 日志类型*/
    private String type;

    /** 日志标题*/
    private String title;

    /** 日志摘要*/
    private String description;

    /** 请求IP*/
    private String ip;

    /** URI*/
    private String requestUri;

    /** 请求方式*/
    private String method;

    /** 提交参数*/
    private String params;

    /** 异常*/
    private String exception;

    /** 操作时间*/
    private Date operateDate;

    /** 请求时长*/
    private Long timeout;

    /** 用户登入名*/
    private String loginName;

    /** requestID*/
    private String requestId;

    /** 历史数据*/
    private String dataSnapshot;

    /** 日志状态*/
    private Integer status;

    //setter/getter省略
 }   

MybatisPlusDataLogConfiguration启用配置

@ConditionalOnClass(BaseMapper.class)
@Configuration
public class MybatisPlusDataLogConfiguration {
    private static final Logger LOGGER = LoggerFactory.getLogger(MybatisPlusDataLogConfiguration.class);

    @Bean
    public AspectJExpressionPointcutAdvisor mybatisPlusMethodAdvisor(MybatisPlusMethodInterceptor interceptor) {
        AspectJExpressionPointcutAdvisor advisor = new AspectJExpressionPointcutAdvisor();
        advisor.setExpression("execution(* com.easycode8.easylog.sample.mapper.*.*(..))");
        advisor.setAdvice(interceptor);
        LOGGER.info("[easy-log]启动mybatis-plus操作数据比对");
        return advisor;
    }


    @Bean
    public MybatisPlusMethodInterceptor mybatisPlusMethodInterceptor() {
        return new MybatisPlusMethodInterceptor();
    }
}

运行效果

修改信息: 记录新旧值
在这里插入图片描述
删除信息: 记录历史数据
在这里插入图片描述

总结

本文通过mybatis-plus作为持久层技术, 充分利用接口继承特点即注解特征结合aop技术,实现了零编码自动处理任意单表的数据变化功能。相比一些传统手写方案提升效率, 避免了入侵业务代码,保证解耦性,有了很大的改进。但是可以发现依然存在不足, 比如过于依赖一种持久层框架,mybatis-plus有注解有基类接口,那mybatis没有是不是就不能用。 还有些业务操作修改数据不是根据主键的进行的, 是非主键字段, 可能命中多条记录怎么处理,甚至是where多字段的怎么处理。 这些都是当前使用方案欠缺的地方, 需要继续挖掘共性特点。如何实现,后续文章继续介绍。

附录

记录操作前数据,可以有多种实现做法,mybatis的拦截器也可以实现类似逻辑.

public class RecordInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameterObject = args[1];
        BoundSql boundSql = ms.getBoundSql(parameterObject);
        String sql = boundSql.getSql();
        SqlCommandType sqlCommandType = ms.getSqlCommandType();

        if (sqlCommandType == SqlCommandType.DELETE || sqlCommandType == SqlCommandType.UPDATE) {
            // 记录信息的逻辑
            String tableName = getTableNameFromSql(sql);
            // 获取删除或修改前的记录信息
            List<Map<String, Object>> originalRecords = getOriginalRecords(tableName, parameterObject);
            // 将信息记录到日志文件或数据库中
            recordLog(tableName, originalRecords);
        }

        return invocation.proceed();
    }

    private String getTableNameFromSql(String sql) {
        // 根据 SQL 语句获取表名
        // ...
    }

    private List<Map<String, Object>> getOriginalRecords(String tableName, Object parameterObject) {
        // 获取删除或修改前的记录信息
        // ...
    }

    private void recordLog(String tableName, List<Map<String, Object>> originalRecords) {
        // 将信息记录到日志文件或数据库中
        // ...
    }
}

maven 依赖参考

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.3.2.RELEASE</version>
        </dependency>
        
        <!--mybatis-plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.2</version>
            <optional>true</optional>
        </dependency>

        <!-- 处理异常工具 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.8.1</version>
        </dependency>
        
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.79</version>
        </dependency>
  • 2
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值