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

相关推荐
愿时间能学会宽恕32 分钟前
SpringBoot后端开发常用工具详细介绍——SpringSecurity认证用户保证安全
spring boot·后端·安全
向上的车轮1 小时前
基于Java Spring Boot的云原生TodoList Demo 项目,验证云原生核心特性
java·spring boot·云原生
逍遥德1 小时前
Java8 Comparator接口 和 List Steam 排序使用案例
java·spring boot·list·排序算法
皮皮林55110 小时前
SpringBoot 全局/局部双模式 Gzip 压缩实战:14MB GeoJSON 秒变 3MB
java·spring boot
weixin_4569042710 小时前
Spring Boot 用户管理系统
java·spring boot·后端
奔跑吧邓邓子11 小时前
【Java实战㉞】从0到1:Spring Boot Web开发与接口设计实战
java·spring boot·实战·web开发·接口设计
茶本无香11 小时前
深入理解Spring Boot的EnvironmentPostProcessor:环境处理的黑科技
spring boot
奔跑吧邓邓子11 小时前
【Java实战㉝】Spring Boot实战:从入门到自动配置的进阶之路
java·spring boot·实战·自动配置
ONLYOFFICE11 小时前
【技术教程】如何将ONLYOFFICE文档集成到使用Spring Boot框架编写的Java Web应用程序中
java·spring boot·编辑器
上官浩仁13 小时前
springboot redisson 缓存入门与实战
spring boot·redis·缓存