如何使用 AOP + SpEL 实现详细的操作日志记录

1. 业务场景概述

对于一个软件系统来说,日志的记录很有必要。当系统运行出现问题时,开发人员可以通过记录的日志信息去快速定位问题。其中,业务操作日志主要记录和跟踪用户对业务数据执行的操作,操作日志对可读性 的要求较高,所以需要重点关注操作日志的记录格式

2. 实现思路分析

2.1 嵌入业务逻辑代码记录

  • 优点:可以自定义操作日志的格式与信息,可以详细记录操作细节;
  • 缺点:操作日志与业务逻辑代码耦合性太高,重复代码太多【如果后续需要修改,简直就是灾难性的存在】

2.2 采用AOP实现

为了代码后续的可拓展性和可维护性,可以采用AOP 思想,利用注解切面类将记录操作日志的相关代码独立出来。

  • 优点:业务逻辑与操作日志解耦;
  • 缺点:日志粒度比较粗 ,而且日志内容是静态,不能将具体的操作细节体现出来,只能实现如:xxx删除了用户,xxx新增了用户等。

2.3 采用AOP + SpEL实现

为了使日志格式更加灵活,可以利用 AOP + SpEL 可以实现更细粒度的日志记录,体现更多的细节,最终实现如:xxx删除了用户ID = xxx的记录。

3. 技术介绍

3.1 AOP

3.1.1 什么是AOP

AOP (Aspect Oriented Programming,面向切面编程),是一种编程思想。主要的作用:不改变原有代码的情况下添加新功能。这样有利于减少系统的重复代码,降低模块之间的耦合度,有利于未来的拓展和维护。

在编码过程中,如果遇到代码重复的问题,一般来说有两种方式:

  • 纵向抽取:将重复代码抽象成公共类/公共方法
  • 横向抽取:利用AOP 思想,横向切割代码,将重复代码抽取出来形成一个独立模块。这种方式适用于重复代码耦合在业务逻辑代码中,有逻辑顺序的情况。

3.1.2 AOP中的核心概念

  • Advice(通知)增强 的那一部分逻辑代码,用于定义共性功能方法 ;通知的类型
    • 前置 通知(@Before):增强部分代码在原代码之
    • 后置 通知(@After):增强部分代码在原代码之
    • 环绕 通知(@Around):增强部分代码在原代码前,也有在原代码后;
    • 异常 通知(@AfterThrowing):原代码抛出异常后才会执行;
    • 返回 通知(@AfterReturning):原代码成功执行后执行。

上述所有的通知类型写到一个切面中,执行顺序如下图所示:

  • Joinpoint(连接点)可能执行通知方法
  • Aspect(切面):用于建立通知与切入点之间的绑定关系;
  • PointCut(切点):执行通知的方法。

3.2 SpEL

3.2.1 什么是SpEL

Spring Expression Language,由Spring提供的表达式语言,用于实现动态可配置 的行为,支持常见的表达式操作,如:算术运算,逻辑运算,条件运算等。可以通过xml或者注解的配置方式进行使用。

Java 复制代码
// SpEL支持条件运算
@Value("#{2 > 1 ? 'a' : 'b'}") // "a"
private String ternary;

3.2.2 解析表达式

Java 复制代码
CompanyDTO companyDTO = new CompanyDTO();
companyDTO.setCompanyName("elTest");

// 解析表达式
ExpressionParser expressionParser = new SpelExpressionParser();
// 计算表达式字符串的值
Expression expression = expressionParser.parseExpression("companyName");
// 根据上下文自动对类型进行转换
StandardEvaluationContext context = new StandardEvaluationContext(companyDTO);
String result = (String) expression.getValue(context);
log.info("结果:{}",result);

4. 核心代码实现

4.1 注解@OperationLogAnnotation定义

Java 复制代码
    @Target({ ElementType.PARAMETER, ElementType.METHOD }) // 注解放置的目标位置,PARAMETER: 可用在参数上  METHOD:可用在方法级别上
    @Retention(RetentionPolicy.RUNTIME)    // 指明修饰的注解的生存周期  RUNTIME:运行级别保留
    @Documented
    public @interface OperationLogAnnotation {

        /**
         * 日志内容,支持SpEL表达式
         * @return
         */
        String content() default "";
    }

4.2 切面类OperationLogAspect

Java 复制代码
@Component
@Aspect
@Slf4j
public class OperationLogAspect  {

    private static final ExpressionEvaluator<String> EVALUATOR = new ExpressionEvaluator<>();

    @Autowired
    private LogRepository operationLogRepository;

    @Autowired
    private SysUserRepository sysUserRepository;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    /**
     * 被执行方法上添加OperationLogAnnotation注解的才会执行这个方法
     * @param joinPoint
     * @param jsonResult
     */
    @AfterReturning(pointcut = "@annotation(com.ocrweb.annotation.OperationLogAnnotation)", returning = "jsonResult")
    public void doAfterReturning (JoinPoint joinPoint, Object jsonResult) {
        handleOperationLog(joinPoint, jsonResult);
    }


    /**
     * 操作日志处理逻辑,包括:格式处理,持久化
     * @param joinPoint
     * @param jsonResult 返回结果
     */
    protected void handleOperationLog(final JoinPoint joinPoint, Object jsonResult) {

        OperationLogAnnotation operationLogAnnotation = ((MethodSignature) joinPoint.getSignature()).getMethod().
                getAnnotation(OperationLogAnnotation.class);

        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        // 获取方法名
        String methodName = joinPoint.getSignature().getName().toLowerCase();
        // 根据方法名判定方法类型
        String methodType = FunctionTypeEnum.getMessageByCode(methodName);

        // 获取类名
        String className = joinPoint.getTarget().getClass().getName().toLowerCase();
        // 根据类名判断所属模块
        String moduleType = ModuleTypeEnum.getMessageByCode(className);

        // 获取方法签名
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
        // 参数名称列表
        String[] parameterNames = methodSignature.getParameterNames();
        // 获取accessToken参数下标
        int accessTokenIndex = ArrayUtils.indexOf(parameterNames, "accessToken");
        log.info("accessTokenIndex:{}",accessTokenIndex);
        Object[] args = joinPoint.getArgs();
        if (accessTokenIndex < 0){
            return;
        }
        // 根据参数accessToken获取操作人相关信息
        String accessToken = String.valueOf(args[accessTokenIndex]);
        log.info("accessToken:{}",accessToken);
        Integer userId = jwtTokenUtil.getUserIdFromToken(accessToken);
        SysUser sysUser = sysUserRepository.findByUserId(userId);

        // content的默认值为返回结果
        String content = new String();

        // 获取operationLogAnnotation注解的的content内容
        if (StringUtils.isNotBlank(operationLogAnnotation.content())) {
            // 解析EL表达式
            content = this.evalExpression(joinPoint, operationLogAnnotation.content() );
            log.info("expression:{}", content);
            if (StringUtils.isNotBlank(methodType) && !methodType.equals(FunctionTypeEnum.SAVE.message())){
                content = methodType + " " + moduleType +  content;
            } else {
                content = moduleType + " " + content;
            }
        }

        JsonResult result = (JsonResult) jsonResult;
        if (result.getCode() == 500){
            content = result.toString();
        }

        // 操作日志保存到数据库内
        OperationLog operationLog = new OperationLog();
        if (StringUtils.isNotBlank(moduleType) && StringUtils.isNotBlank(methodType)){
            operationLog.setModuleType(moduleType);
            operationLog.setOperationType(methodType);
            operationLog.setContent(content);
            operationLog.setClientIp(IpAddrUtil.getLocalIp(request));
            operationLog.setServerIp(request.getRemoteAddr());
            operationLog.setLoginName(sysUser.getLoginName());
            operationLog.setUserName(sysUser.getUserName());
            operationLog.setUserId(sysUser.getUserId());
            operationLogRepository.save(operationLog);
        }
    }

    /**
     * 解析EL表达式
     * @param point 切入点
     * @param expression 需要解析的EL表达式
     * @return 解析出的值
     */
    private String evalExpression(JoinPoint point, String expression) {
        MethodSignature ms = (MethodSignature) point.getSignature();
        Method method = ms.getMethod();
        Object[] args = point.getArgs();
        Object target = point.getTarget();
        Class<?> targetClass = target.getClass();
        EvaluationContext context = EVALUATOR.createEvaluationContext(target, target.getClass(), method, args);
        AnnotatedElementKey elementKey = new AnnotatedElementKey(method, targetClass);
        return EVALUATOR.condition(expression, elementKey, context, String.class);
    }
}

4.3 解析EL表达式相关类

  • ExpressionEvaluator
Java 复制代码
    public class ExpressionEvaluator<T> extends CachedExpressionEvaluator {
        private final ParameterNameDiscoverer paramNameDiscoverer = new DefaultParameterNameDiscoverer();
        private final Map<ExpressionKey, Expression> conditionCache = new ConcurrentHashMap<>(64);
        private final Map<AnnotatedElementKey, Method> targetMethodCache = new ConcurrentHashMap<>(64);

        public EvaluationContext createEvaluationContext(Object object, Class<?> targetClass, Method method, Object[] args) {
            Method targetMethod = getTargetMethod(targetClass, method);
            ExpressionRootObject root = new ExpressionRootObject(object, args);
            return new MethodBasedEvaluationContext(root, targetMethod, args, this.paramNameDiscoverer);
        }

        public T condition(String conditionExpression, AnnotatedElementKey elementKey, EvaluationContext evalContext, Class<T> clazz) {
            return getExpression(this.conditionCache, elementKey, conditionExpression).getValue(evalContext, clazz);
        }

        private Method getTargetMethod(Class<?> targetClass, Method method) {
            AnnotatedElementKey methodKey = new AnnotatedElementKey(method, targetClass);
            Method targetMethod = this.targetMethodCache.get(methodKey);
            if (targetMethod == null) {
                targetMethod = AopUtils.getMostSpecificMethod(method, targetClass);
                if (targetMethod == null) {
                    targetMethod = method;
                }
                this.targetMethodCache.put(methodKey, targetMethod);
            }
            return targetMethod;
        }
    }
  • ExpressionRootObject
Java 复制代码
public class ExpressionRootObject {
    private final Object object;
    private final Object[] args;
    public ExpressionRootObject(Object object, Object[] args) {
        this.object = object;
        this.args = args;
    }
    public Object getObject() {
        return object;
    }
    public Object[] getArgs() {
        return args;
    }
}

4.4 Controller层调用

Java 复制代码
    @OperationLogAnnotation(content = " #companyDTO.companyId == null ? '添加 名称:' + #companyDTO.companyName: '编辑  ID:' +  #companyDTO.companyId")
    @PostMapping("/saveCompany")
    public JsonResult saveCompany(@RequestBody CompanyDTO companyDTO, @RequestHeader String accessToken, HttpServletRequest request){
      // ...
     }

5. 实现效果

参考资料

相关推荐
大梦百万秋2 分钟前
Spring Boot实战:构建一个简单的RESTful API
spring boot·后端·restful
忒可君15 分钟前
C# winform 报错:类型“System.Int32”的对象无法转换为类型“System.Int16”。
java·开发语言
斌斌_____31 分钟前
Spring Boot 配置文件的加载顺序
java·spring boot·后端
路在脚下@39 分钟前
Spring如何处理循环依赖
java·后端·spring
一个不秃头的 程序员1 小时前
代码加入SFTP JAVA ---(小白篇3)
java·python·github
丁总学Java1 小时前
--spring.profiles.active=prod
java·spring
上等猿1 小时前
集合stream
java
java1234_小锋1 小时前
MyBatis如何处理延迟加载?
java·开发语言
菠萝咕噜肉i1 小时前
MyBatis是什么?为什么有全自动ORM框架还是MyBatis比较受欢迎?
java·mybatis·框架·半自动
海绵波波1072 小时前
flask后端开发(1):第一个Flask项目
后端·python·flask