AOP+注解+SpringEL表达式记录操作日志

在后台管理系统中,日志是不可或缺的,通常,在开发的时候我们会设置日志级别为debuginfo,上线后再设置为error,这些系统日志在帮助我们排查系统问题时作用非常大。但还有一类日志也是非常重要的,那就是操作日志,也就是记录用户在某个时间点干了什么事的日志。这篇文章就简单聊聊如何更好的记录操作日志。

在参与过不少项目的开发后,大概总结出了下面这么几种记录日志的方式。

第一种就是直接调用日志方法,类似下面这种,这类代码在以前的老系统中非常常见,好处是代码清晰,但太冗余了,总不可能每次要记录日志都复制一遍,如果不需要记录日志了,那又得挨个删除,特别麻烦。

void doSomething() {
    String userName = WebUtil.getUserName();
    logService.saveLog(userName, "${userName} did something at ${date}...");
}

第二种就是目前非常流行的注解加切面的方式,也是今天要介绍的。但我发现,在不少项目中,日志的功能都比较单一,记录的结果大都是“[xxx]执行了[xxx]操作”之类的,但在执行这个操作的时候,参数以及目标数据的信息等,日志中是看不出来的,这时候可能又会怀念上面的第一种方式,但实则大可不必。

用过spring缓存的应该都记得spring缓存中的非常常用的几个注解,如@Cacheable@Cacheput等,在使用这些注解的时候,我们都会活用SpringEL表达式来操作缓存,如下面的代码,通过key = #id来动态设置key的值

@Cacheable(value = "user", key = "#id")
public UserInfo getById(Long id) {
    return userMapper.selectById(id);
}

那么在日志记录的时候,我们也可以模仿spring缓存,让日志注解支持SpringEL表达式,如此一来就可以非常方便的记录我们需要的日志了。

首先,新建日志注解LogRecord,代码如下

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface LogRecord {
    /**
     * 日志内容,支持SpEL表达式
     */
    String content();
    /**
     * 业务标识,支持SpEL表达式
     */
    String bizNo();
    /**
     * 类别
     */
    String category() default "";
    /**
     * 日志记录条件,支持SpEL表达式
     */
    String condition() default "true";
    /**
     * 是否保存入参
     */
    String saveParams() default "true";
}

然后是最重要的切面

@RequiredArgsConstructor
@Aspect
@Component
public class LogRecordAspect {

    private final ExpressionParser parser = new SpelExpressionParser();
    private final String spElFlag = "#";

    @Pointcut("@annotation(com.ygr.modules.sys.aspect.LogRecord)")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object proceed = joinPoint.proceed();
        if (proceed instanceof ApiResult) {
            if (((ApiResult) proceed).isOk()) {
                recordLog(joinPoint, (ApiResult) proceed);
            }
        }
        return proceed;
    }


    private void recordLog(ProceedingJoinPoint joinPoint, ApiResult apiResult) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        LogRecord logRecord = method.getAnnotation(LogRecord.class);
	    // 获取方法入参,key为参数名,value为参数值
        LinkedHashMap<String, Object> params = resolveParams(joinPoint);
        // 求值上下文
        StandardEvaluationContext context = new StandardEvaluationContext();
        if (params.size() == 1) {
            // 当参数只有一个时,设置根对象,例如入参为对象,则此时可以使用 #root.id 来获取对象的id
            params.forEach((k, v) -> context.setRootObject(v));
        }
        int i = 0;
        for (Map.Entry<String, Object> entry : params.entrySet()) {
            context.setVariable(entry.getKey(), entry.getValue());
            // 设置参数别名,按顺序,可使用 #a0 或 #p0 来获取第一个入参
            context.setVariable("a" + i, entry.getValue());
            context.setVariable("p" + i, entry.getValue());
            i++;
        }
        // ApiResult是自定义的web返回格式类,将真正执行结果也放入上下文
        // 因为有些日志是需要获取执行结果的,比如新增数据,新增成功之后才会有id
        context.setVariable("result", apiResult);

        String condition = resolveValue(logRecord.condition(), context);
        // 如果不满足记录日志条件,则返回
        if (!Boolean.parseBoolean(condition)) {
            return;
        }
        boolean saveParams = Boolean.parseBoolean(resolveValue(logRecord.saveParams(), context));
	    if (saveParams) {
            // TODO 如果需要保存日志到数据库
        }
        
    }

    private LinkedHashMap<String, Object> resolveParams(ProceedingJoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Object[] arguments = joinPoint.getArgs();
        String[] paramNames = getParameterNames(method);
        
        LinkedHashMap<String, Object> params = new LinkedHashMap<>();
        for (int i = 0; i < arguments.length; i++) {
            params.put(paramNames[i], arguments[i]);
        }
        return params;
    }

    private String[] getParameterNames(Method method) {
        ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
        return parameterNameDiscoverer.getParameterNames(method);
    }

    private String resolveValue(String exp, EvaluationContext context) {
        String value;
        // 如果包含#字符,则使用SpringEL表达式进行解析
        if (exp.contains(spElFlag)) {
            value = resolveValueByExpression(exp, context);
        } else {
            // 否则不处理
            value = exp;
        }
        return value;
    }

    private String resolveValueByExpression(String spELString, EvaluationContext context) {
        // 构建表达式
        Expression expression = parser.parseExpression(spELString);
        // 解析
        return expression.getValue(context, String.class);
    }
}

代码很简单,注释很详细了,就不再多说了。使用也很简单,直接加注解就行,比如新增配置参数

@LogRecord(category = "SysConfigParam", bizNo = "#result.data.id", content = "'新增配置参数,code:' + #entity.code + ',name:' + #entity.name")
@PostMapping("/sys-config-param")
public ApiResult<SysConfigParam> insert(@RequestBody SysConfigParam entity) {
    this.service.save(entity);
    return ApiResult.ok(entity);
}

这其实还算比较简单的类型了,对于一些更新操作,有些时候可能希望日志记录得更详细。客户管理员在查看日志的时候,相比于[xxx]修改手机号为[15888888888],肯定是更希望看到[xxx]将手机号由[15877777777]修改为了[15888888888]。这就要求在记录日志时,不仅需要入参数据,还需要查询修改前的数据,最后才拼接出需要的日志。

这一点,SpringEL也能很好的支持,因为SpringEL是支持方法作为表达式,比如最常见的SpringSecurity中权限校验注解@PreAuthorize("hasRole('ROLE_xxx')" ),就是一个很好的例子。当然也可以借助线程上下文,将参数设置到上下文中进行参数传递。

至于自定义方法的解析,等下次有空了再做记录。

最后一种就是使用Canal监听数据库的增量日志,虽说解耦,但局限性太大,目前只支持mysql,且一旦操作涉及RPC,这种方式就几乎没用了。

  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
首先,我们需要定义一个自定义注解 `@RequiresPermissions`,用于标识需要授权访问的方法,例如: ```java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface RequiresPermissions { String[] value(); // 权限值 } ``` 然后,我们需要实现一个切面,用于拦截被 `@RequiresPermissions` 标识的方法,并进行权限校验,例如: ```java @Component @Aspect public class PermissionCheckAspect { @Autowired private AuthService authService; @Around("@annotation(requiresPermissions)") public Object checkPermission(ProceedingJoinPoint joinPoint, RequiresPermissions requiresPermissions) throws Throwable { // 获取当前用户 User user = authService.getCurrentUser(); if (user == null) { throw new UnauthorizedException("用户未登录"); } // 获取当前用户的权限列表 List<String> permissions = authService.getUserPermissions(user); // 校验权限 for (String permission : requiresPermissions.value()) { if (!permissions.contains(permission)) { throw new ForbiddenException("没有访问权限:" + permission); } } // 执行目标方法 return joinPoint.proceed(); } } ``` 在切面中,我们首先通过 `AuthService` 获取当前用户及其权限列表,然后校验当前用户是否拥有被 `@RequiresPermissions` 标识的方法所需的所有权限,如果没有则抛出 `ForbiddenException` 异常,如果有则继续执行目标方法。 最后,我们需要在 Spring 配置文件中启用 AOP 自动代理,并扫描切面所在的包,例如: ```xml <aop:aspectj-autoproxy /> <context:component-scan base-package="com.example.aspect" /> ``` 这样,我们就通过 Spring AOP 和自定义注解模拟实现了类似 Shiro 权限校验的功能。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值