基于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表达式实现日志的动态模板,使得我们可以优雅的记录操作日志。当然作者在实现的时候,也有瑕疵,也有一些东西没考虑到,欢迎小伙伴们留言,让其更加完善。

参考资料

相关推荐
IT_陈寒42 分钟前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码2 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo2 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志3 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..3 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects3 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰3 小时前
一些基础和问题解决
后端
陈随易3 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN3 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp
Imchendiana3 小时前
《狂人日记NO.9》— 前后端一把梭,我的全栈实录
前端·后端