基于Spring Aop+SpEL优雅地记录操作日志

在我们工作的日常开发中,我们经常会遇到需要记录用户的操作日志(如上图所示)的需求,这个需求本身不是很难,但是如何让操作日志不和业务逻辑耦合?如何优雅的记录操作日志?这就是本文分享的主要内容:优雅的记录操作日志。

与业务系统解耦,相信很多小伙伴已经想到了Spring AOP ,没错,本文分享的方案就是使用 AOP 生成动态的操作日志。(ps:如果有小伙伴对Spring AOP有疑问,可以先去看看这篇文章 彻底搞懂Spring AOP

基于Spring AOP+SpEL 生成动态的操作日志

这里先给出本文实现的一个效果,然后再看是怎么实现这样功能的 从图上看,通过 SpEL (Spring Expression Language,Spring表达式语言)表达式实现了动态模板,SpEL表达式引用方法上的参数,可以让变量填充到模板中达到动态的操作日志文本内容。

这里说明一下,正常情况下像业务单号这类数据应该是从入参或者其它地方获取,写固定是为了演示方便,操作人也是不应该从入参中获取,而是从登陆信息里获取,这里作者就偷懒了...

代码实现

AOP拦截

  • 定义注解@LogRecordAnnotation
java 复制代码
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecordAnnotation {

    //操作是否成功
    boolean succeed() default true;

    //操作人
    String operator() default "";

    //业务单号
    String bizNo() default "";

    //操作日志的种类
    String category() default "";

    //扩展参数,记录操作日志的修改详情
    String detail() default "";

    //记录日志的条件
    String condition() default "";
}

注解中的参数,应该满足大部分的使用场景,在需要记录操作日志的方法上加上该注解,以便后续进行拦截处理。

  • 切面增强逻辑LogRecordAspect
java 复制代码
@Slf4j
@Aspect
@Component
public class LogRecordAspect {

    @Pointcut("@annotation(com.tx.operating.annotation.LogRecordAnnotation)")
    private void method() {
    }

    @Around("method()")
    public Object divAround(ProceedingJoinPoint joinPoint) throws Throwable {
        Map<String, Object> resultMap = new HashMap<>();
        Object proceed = null;

        try {
            //获取注解信息
            LogRecordAnnotation annotation = LogRecordOperationSource.getAnnotation(joinPoint);
            //获取SPEL表达式
            Map<String, Object> spelMap = LogRecordOperationSource.getBeforeExecuteFunctionTemplate(annotation);
            //执行SPEL表达式和执行自定义函数
            AnnotatedElementKey methodKey=new AnnotatedElementKey(((MethodSignature) joinPoint.getSignature()).getMethod(),joinPoint.getTarget().getClass());
            resultMap = LogRecordOperationSource.processBeforeExecuteFunctionTemplate(spelMap,methodKey,joinPoint.getArgs()[0]);
        } catch (Exception e) {
           log.info("/// log record exec error",e);
        }

        try {
            proceed = joinPoint.proceed();
        } catch (Exception e) {
            //目标方法执行异常,设置操作状态为失败
            resultMap.put(CommonConstants.SUCCEED,false);
            throw new OrsRuntimeException("//// 目标方法执行异常,"+ e.getMessage());
        }finally {
            //通过SPI加载自定义持久化日志的方法进行日志持久化
            ServiceLoader<KeepOperatingRecordSpi> loader = ServiceLoader.load(KeepOperatingRecordSpi.class);
            Iterator<KeepOperatingRecordSpi> it = loader.iterator();
            if (it.hasNext()) {
                KeepOperatingRecordSpi keepOperatingRecordSpi = it.next();
                keepOperatingRecordSpi.keepRecord(resultMap);
            }
        }

        return proceed;
    }
    
}

核心逻辑:在业务的方法执行之前,会提前解析的自定义函数和SpEL表达式求值,操作日志的记录持久化是在方法执行完之后执行的,当方法抛出异常时,继续将操作日志持久化完成。

解析模板

LogRecordOperationSource里面封装了自定义函数和 SpEL 解析OperateRecordExpressionParse

java 复制代码
public class OperateRecordExpressionParse extends CachedExpressionEvaluator {

    private final static OperateRecordExpressionParse operateRecordExpressionParse=new OperateRecordExpressionParse();

    public OperateRecordExpressionParse(){}

    private Map<ExpressionKey, Expression> expressionCache = new ConcurrentHashMap<>(64);

    public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) {
        return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
    }

    public static OperateRecordExpressionParse getInstance(){
        return operateRecordExpressionParse;
    }
}

OperateRecordExpressionParse 继承自 CachedExpressionEvaluator 类,这个类里面有一个Map是 expressionCache。在上面的例子中可以看到,SpEL 会解析成一个 Expression 表达式,然后根据传入的 Object 获取到对应的值,所以 expressionCache 是为了缓存方法、表达式和 SpEL 的 Expression 的对应关系,让方法注解上添加的 SpEL 表达式只解析一次。

java 复制代码
getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);

getExpression 方法会从 expressionCache 中获取到 @LogRecordAnnotation 注解上的表达式的解析 Expression 的实例,然后调用 getValue 方法,getValue 传入一个 evalContext 上下文对象。在上面的例子中方法的入参就是上下文。

自定义函数

为什么会有自定义函数呢?

在上面的例子中可以看到有这样一条操作日志记录:用户A将地址从云南修改为深圳,新地址深圳从入参中很容易就获取到了,旧地址云南似乎是也可以,但是在我之前所想到的方法都没那么优雅。而通过自定义函数,我们可以根据数据Id转化为我们需要的对象信息,当然通过自定义函数我们还能做很多事,这里就不一一列举了。

定义IParseFunction 接口

java 复制代码
public interface IParseFunction {
    
    //自定义函数名 用于@LogRecordAnnotation中进行模板配置
    String functionName();

    // 执行逻辑
    String apply(Object value);
}

ParseFunctionFactory 代码比较简单,它的功能是把所有的 IParseFunction 注入到函数工厂中。

java 复制代码
public class ParseFunctionFactory {

    private Map<String, IParseFunction> allFunctionMap;

    public ParseFunctionFactory() {
    // 通过反射获取所有IParseFunction接口的实现类
        List<Object> parseFunctions = FindImplementationsUtil.findImplementations(IParseFunction.class);
        if (CollectionUtils.isEmpty(parseFunctions)) {
            return;
        }
        allFunctionMap = new HashMap<>();
        for (Object obj : parseFunctions) {
            if (!(obj instanceof IParseFunction)) {
                continue;
            }
            IParseFunction parseFunction = (IParseFunction) obj;
            if (StringUtils.isEmpty(parseFunction.functionName())) {
                continue;
            }
            allFunctionMap.put(parseFunction.functionName(), parseFunction);
        }
    }

    public IParseFunction getFunction(String functionName) {
        return allFunctionMap.get(functionName);
    }
}
日志持久化

KeepOperatingRecordSpiSPI接口,业务可以实现这个保存接口,然后把日志保存在任何存储介质上(mysql、redis、mongoDB等等)

java 复制代码
public interface KeepOperatingRecordSpi {

   <T> void keepRecord(T t);
}

对于java SPI机制不了解的小伙伴,可以看看这篇文章彻底搞懂JAVA SPI,这里就不做太多介绍了。

至此,本文实现操作日志记录的人核心方法与逻辑就介绍完啦,如果有小伙伴需要源码的可以去GitHub上获取opreating-record,也可以私信我。

总结

这篇文章主要分享了基于Spring Aop+SpEL实现操作日志记录,通过AOP与业务逻辑解耦,通过自定义函数、SpEL表达式实现日志的动态模板,使得我们可以优雅的记录操作日志。当然作者在实现的时候,也有瑕疵,也有一些东西没考虑到,欢迎小伙伴们留言,让其更加完善。

参考资料

相关推荐
计算机学长felix3 分钟前
基于SpringBoot的“校园交友网站”的设计与实现(源码+数据库+文档+PPT)
数据库·spring boot·毕业设计·交友
码农派大星。16 分钟前
Spring Boot 配置文件
java·spring boot·后端
江深竹静,一苇以航25 分钟前
springboot3项目整合Mybatis-plus启动项目报错:Invalid bean definition with name ‘xxxMapper‘
java·spring boot
杜杜的man1 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*1 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu1 小时前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s1 小时前
Golang--协程和管道
开发语言·后端·golang
为什么这亚子1 小时前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
豪宇刘1 小时前
SpringBoot+Shiro权限管理
java·spring boot·spring
想进大厂的小王1 小时前
项目架构介绍以及Spring cloud、redis、mq 等组件的基本认识
redis·分布式·后端·spring cloud·微服务·架构