likes
comments
collection
share

AOP统一请求响应日志输出

作者站长头像
站长
· 阅读数 37

前面,我们完成了spring boot与log4j2日志框架的整合,我们得到了漂亮的日志格式的输出。我们还不满足于此,很多时候为了排查问题,我们需要得到请求的入参和出参,通过AOP拦截机制我们可以实现请求与响应的统一日志输出,废话不多说,开干!

引入依赖

首先引入apo依赖:

dependencies {
    ...

    implementation 'org.springframework.boot:spring-boot-starter-aop'
}

切面类

接下来我们定义切面类,在其中通过表达式形式来声明切入点,这里我们对controller包下的所有controller方法都进行切入拦截

package com.xiaojuan.boot.aop.log;

import ...

@Slf4j
@Aspect
@Component
public class WebLogAspect {

    @Pointcut("execution(public * com.xiaojuan.boot.web.controller.*.*(..)))")
    public void webLog() {}

    @Before("webLog()")
    public void doBefore(JoinPoint joinPoint) {
        // 获取请求
        HttpServletRequest request = WebRequestUtil.getRequest();
        Signature signature = joinPoint.getSignature();
        log.info("========== {}: {}", request.getMethod(), request.getRequestURL());
        log.info("========== 所在类方法: {}.{}", signature.getDeclaringTypeName(), signature.getName());
    }

    @AfterReturning(returning = "resp", pointcut = "webLog()")
    public void doAfterReturning(Object resp) throws JsonProcessingException {
        
    }
}

注意,切面类它本身也是一个spring组件,需要加@Component注解,同时要用@Aspect修饰。

然后我们运行下web单元测试看下,控制台的输出:

AOP统一请求响应日志输出

说明我们的切面实现类凑效了,接下来就是完善输出了。

获取和输出请求参数

下面我们实现一个私有的方法来获取和输出请求参数等信息:

private void logParams(HttpServletRequest request) {
    Map<String, String[]> parameterMap = request.getParameterMap();
    int size = parameterMap.size();
    if (size == 0) return;
    StringBuilder paramBuilder = new StringBuilder();
    Set<String> paramNames = parameterMap.keySet();
    int index = 0;
    for (String paramName : paramNames) {
        paramBuilder.append(paramName).append("=").append(StringUtils.join(parameterMap.get(paramName)));
        if (index < size - 1) {
            paramBuilder.append(", ");
        }
        index++;
    }
    log.info("========== params: {}", paramBuilder);
}

这里的StringUtils类我们需要引入org.apache.commons:commons-lang3依赖。

注意,这里我们是以表单形式提交的数据,可以直接用类似request.getParamter的方法来获取。如果是post的json体内容,则我们采用另外的形式,这里的思路是,我们获取方法签名中增加了@RequestBody注解的参数,并找到对应的实参,然后对其序列化输出,实现如下:

@SneakyThrows
private void logRequestBody(JoinPoint joinPoint) {
    Signature signature = joinPoint.getSignature();
    MethodSignature methodSignature = (MethodSignature) signature;
    Method method = methodSignature.getMethod();
    Parameter[] parameters = method.getParameters();
    for (int i = 0; i < parameters.length; i++) {
        Parameter parameter = parameters[i];
        if (parameter.getAnnotation(RequestBody.class) != null) {
            Object body = joinPoint.getArgs()[i];
            log.info("========== body: {}", objectMapper.writeValueAsString(body));
            break;
        }
    }
}

实现最后,看下两个切面拦截方法的实现:

@Before("webLog()")
public void doBefore(JoinPoint joinPoint) {
    // 获取请求
    ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    HttpServletRequest request = attributes.getRequest();
    Signature signature = joinPoint.getSignature();
    log.info("====================================================================================================");
    log.info("========== {}: {}", request.getMethod(), request.getRequestURL());
    log.info("========== 所在类方法: {}.{}", signature.getDeclaringTypeName(), signature.getName());
    logParams(request);
    logRequestBody(joinPoint);
}

@AfterReturning(returning = "resp", pointcut = "webLog()")
public void doAfterReturning(Object resp) throws JsonProcessingException {
    log.info("========== 响应结果: {}", objectMapper.writeValueAsString(resp));
    log.info("====================================================================================================");
}

这里我们输出了一些分割线,让我们的日志看起来更漂亮。

输出效果

最后,我们来运行web单元测试,看下控制台输出的效果:

AOP统一请求响应日志输出

修复问题

这里我们输出的是响应的body内容,我们应该输出完整的格式;如果发生异常,最终我们应该响应全局异常处理后的内容,为此我们这里的doAfterReturning(resp)方法就显得无能为力了。比如,这里判断没有管理员角色操作权限的错误响应就没有输出:

AOP统一请求响应日志输出

不用担心,还记得我们之前在讲全局响应输出时用的RestBodyAdvice类吗,不管是正常响应还是抛异常的错误输出,最终都会经过我们的beforeBodyWrite方法,为此,我们把doAfterReturning(resp)方法中的代码移植过去,并把这个方法删掉。看下我们调整后的RestBodyAdvice类:

package com.xiaojuan.boot.common.web.support;

import ...

@Slf4j
@RestControllerAdvice
public class RestBodyAdvice implements ResponseBodyAdvice<Object> {

    @Resource
    private ObjectMapper objectMapper;

    @Override
    public boolean supports...

    @SneakyThrows
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        Object resp = null;
        if (body instanceof String) {
            // 字符串需要手动序列化为json
            response.getHeaders().set("Content-Type", MediaType.APPLICATION_JSON_UTF8_VALUE);
            resp = Response.ok(body);
            logResponse(resp);
            return objectMapper.writeValueAsString(resp);
        }
        // 如果是Response类型说明被包装为全局错误响应
        resp = body instanceof Response ? body : Response.ok(body);
        logResponse(resp);
        return resp;
    }

    private void logResponse(Object resp) throws JsonProcessingException {
        log.info("========== 响应结果: {}", objectMapper.writeValueAsString(resp));
        log.info("====================================================================================================");
    }
}

这样我们再来执行下前面的web单元测试,看到的输出:

AOP统一请求响应日志输出

响应日志慢慢变成我们想要的样子了,我们可以再优化下,把响应是null的字段不参与json序列化:这里有一种快捷的做法:

package com.xiaojuan.boot.common;

import ...

@JsonInclude(JsonInclude.Include.NON_NULL)
...
public class Response<T> {

    ...
}

再来看效果,确实达到了预期:

AOP统一请求响应日志输出

但是,这是全局影响的,就是说我们给前端响应的通用结构的字段如果有null值,也不会输出了,这可能并不是前端mm所希望的;我们这里只希望把影响控制在日志输出这个范围内,为此我们取消这种做法,而把解决方案改为如下实现:

private void logResponse(Object resp) throws JsonProcessingException {
    log.info("========== 响应结果: {}", objectMapper.writeValueAsString(ObjectMapUtil.bean2Map(resp, true)));
    ...
}

这里我们写了一个工具类实现bean(对象)到map结构的转换,并提供是否排除null值的字段的复制,用到了spring框架提供的BeanMap类:

package com.xiaojuan.boot.util;

import ...

public class ObjectMapUtil {

    public static <T> Map<String, Object> bean2Map(T bean, boolean excludeNull) {
        Map<String, Object> map = null;
        if (bean != null) {
            map = new HashMap<>();
            BeanMap beanMap = BeanMap.create(bean);
            for (Object key : beanMap.keySet()) {
                Object value = beanMap.get(key);
                if (value == null && excludeNull) continue;
                map.put(String.valueOf(key), value);
            }
        }
        return map;
    }

}

再来执行下web单元测试,输出没有问题!

待完善:日志脱敏

最后的问题只剩下一个——日志脱敏,这个问题在生产环境特别要重视,不能泄露用户的个人信息!同样我们可以利用AOP的拦截机制来实现,就留给小伙伴们当作作业了,大家学习扩展来实现下吧!

AOP统一请求响应日志输出