AOP统一请求响应日志输出

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

引入依赖

首先引入apo依赖:

groovy 复制代码
dependencies {
    ...

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

切面类

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

java 复制代码
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单元测试看下,控制台的输出:

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

获取和输出请求参数

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

java 复制代码
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注解的参数,并找到对应的实参,然后对其序列化输出,实现如下:

java 复制代码
@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;
        }
    }
}

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

java 复制代码
@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单元测试,看下控制台输出的效果:

修复问题

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

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

java 复制代码
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单元测试,看到的输出:

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

java 复制代码
package com.xiaojuan.boot.common;

import ...

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

    ...
}

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

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

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

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

java 复制代码
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的拦截机制来实现,就留给小伙伴们当作作业了,大家学习扩展来实现下吧!

相关推荐
Jabes.yang1 分钟前
Java面试场景:从Spring Web到Kafka的音视频应用挑战
大数据·spring boot·kafka·spring security·java面试·spring webflux
程序员小凯3 小时前
Spring Boot性能优化详解
spring boot·后端·性能优化
tuine3 小时前
SpringBoot使用LocalDate接收参数解析问题
java·spring boot·后端
番茄Salad4 小时前
Spring Boot项目中Maven引入依赖常见报错问题解决
spring boot·后端·maven
摇滚侠5 小时前
Spring Boot 3零基础教程,yml配置文件,笔记13
spring boot·redis·笔记
!if5 小时前
springboot mybatisplus 配置SQL日志,但是没有日志输出
spring boot·sql·mybatis
阿挥的编程日记6 小时前
基于SpringBoot的影评管理系统
java·spring boot·后端
java坤坤6 小时前
Spring Boot 集成 SpringDoc OpenAPI(Swagger)实战:从配置到接口文档落地
java·spring boot·后端
摇滚侠7 小时前
Spring Boot 3零基础教程,整合Redis,笔记12
spring boot·redis·笔记
荣淘淘7 小时前
互联网大厂Java求职面试全景实战解析(涵盖Spring Boot、微服务及云原生技术)
java·spring boot·redis·jwt·cloud native·microservices·interview