实现springboot接口日志收集的一种方案

2023-10-22 09:31 孙水迪 708

本文基于AOP提供一个后端收集日志的实现方案

1.依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

在项目中引入springboot的aop包后,可使用aop相关的功能

2.日志注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface OperateLog {
    boolean enable() default true;
}

创建注解OperateLog ,该注解可设置在方法前,实现按需收集日志

基于大部分接口都会开启日志,enable默认值为true

基于给已有项目增加日志模块时,给每个接口都增加这个注解的话,改动过大,因此对没有添加该注解的接口,默认开启收集日志,即无需给每个接口加上该注解,只需给无需收集日志的接口加上注解并设置为false即可

扩展:如果需要对日志收集功能进行扩展,可在注解中配置更多属性,之后在收集日志的时候读取即可

比如下面注解扩展了explain属性

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface OperateLog {
    boolean enable() default true;
    String explain() default "";
}

除此之外,还可在接口方法上设置其他注解,在收集日志时获取其他注解的属性,也一样可以获取到更多信息

比如下面的接口上设置了ApiOperation注解,在日志收集时,即可直接读取ApiOperation的属性值,来获取该接口的名称

@ApiOperation("获取应用分页列表")
@GetMapping("/api/app/page")
public ResponseResult apiAppPage(@Validated ApiAppPageInVo inVo){
    return applicationService.apiAppPage(this, inVo);
}

3.日志收集

@Aspect
@Slf4j
@Component
public class OperateLogAspect {

    @Autowired
    private LogDOMapper logDOMapper;

    @Around("@annotation(io.swagger.annotations.ApiOperation)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        //获取方法上的注解
        ApiOperation apiOperation = getMethodAnnotation(joinPoint,ApiOperation.class);
        OperateLog operateLog = getMethodAnnotation(joinPoint,OperateLog.class);
        Date startTime = new Date();
        try {
            Object result = joinPoint.proceed();
            //没有设置operateLog注解或者 operateLog属性值为true则收集日志,如果有其他条件,也可以继续扩展
            if (operateLog==null||operateLog.enable()){
                this.log(joinPoint, apiOperation, operateLog, startTime, result, null);
            }
            return result;
        } catch (Throwable exception) {
            if (operateLog==null||operateLog.enable()){
                this.log(joinPoint, apiOperation, operateLog, startTime, null, exception);
            }
            throw exception;
        }
    }

    private static <T extends Annotation> T getMethodAnnotation(ProceedingJoinPoint joinPoint, Class<T> annotationClass) {
        return ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(annotationClass);
    }
    //result为接口返回值
    private void log(ProceedingJoinPoint joinPoint, ApiOperation apiOperation, OperateLog operateLog,
                     Date startTime, Object result, Throwable exception) {
        try {
            LogDO logDO=new LogDO();
            logDO.setApi(apiOperation.value());
            //todo 这里生成日志,可加入其他数据,比如用户id,ip,入参等信息
            logDOMapper.insert(logDO);
        } catch (Throwable ex) {
            log.error("[log][记录操作日志时,发生异常,其中参数是 joinPoint({}) operateLog({}) apiOperation({}) result({}) exception({}) ]",
                    joinPoint, operateLog, apiOperation, result, exception, ex);
        }
    }

}

上述代码实现了对拥有ApiOperation的注解的方法的增加操作

如果项目中没有使用ApiOperation作为接口的标志,也可以使用下面代码,对包名为com.xx.xxx.controller及其子包的所有方法进行增强,也可使用其他execution表达式更精确的执行

@Around("execution(* com.xx.xxx.controller..*.*(..))")

如果需要获取接口的入参和地址信息,可使用如下代码

//从HttpServletRequest 中获取地址
HttpServletRequest request = getRequest();
if (request!=null){
    logDO.setUrl(request.getRequestURI());
}

//获取接口的入参属性
Object[] joinPointArgs = joinPoint.getArgs();
if (joinPointArgs != null) {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    //获取接口的参数名称列表
    String[] parameterNames = signature.getParameterNames();
    Map<String, Object> parameterMap = new HashMap<>();
    if (parameterNames != null) {
        for (int i = 0; i < parameterNames.length; i++) {
            //获取参数
            Object parameterValue = joinPointArgs[i];
            //某些类型的参数不需要记录,如果统计日志时报错了,可能是记录了无法序列化的参数,需要在这里增加
            if (parameterValue != null && (parameterValue.getClass().isAssignableFrom(HttpServletRequest.class)
                    || parameterValue.getClass().isAssignableFrom(HttpServletResponse.class)
                    || parameterValue.getClass().isAssignableFrom(WebStatFilter.StatHttpServletResponseWrapper.class)
            )) {
                continue;
            }
            String parameterName = parameterNames[i];
            //文件只记录文件名
            if (parameterValue instanceof MultipartFile){
                parameterMap.put(parameterName, ((MultipartFile) parameterValue).getOriginalFilename());
            }
            else{
                parameterMap.put(parameterName, parameterValue);
            }
        }
    }
    logDO.setRequest(JSON.toJSONString(parameterMap));
}

getRequest方法代码为

public static HttpServletRequest getRequest() {
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    if (!(requestAttributes instanceof ServletRequestAttributes)) {
        return null;
    }
    return ((ServletRequestAttributes) requestAttributes).getRequest();
}

4.优点

使用该方案收集日志,对现有项目入侵性小的同时,具有较高的扩展性