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

相关推荐
悟空码字18 小时前
Spring Boot 整合 MongoDB 最佳实践:CRUD、分页、事务、索引全覆盖
java·spring boot·后端
皮皮林5512 天前
拒绝写重复代码,试试这套开源的 SpringBoot 组件,效率翻倍~
java·spring boot
用户908324602735 天前
Spring AI 1.1.2 + Neo4j:用知识图谱增强 RAG 检索(上篇:图谱构建)
java·spring boot
用户8307196840826 天前
Spring Boot 集成 RabbitMQ :8 个最佳实践,杜绝消息丢失与队列阻塞
spring boot·后端·rabbitmq
Java水解6 天前
Spring Boot 视图层与模板引擎
spring boot·后端
Java水解6 天前
一文搞懂 Spring Boot 默认数据库连接池 HikariCP
spring boot·后端
洋洋技术笔记6 天前
Spring Boot Web MVC配置详解
spring boot·后端
初次攀爬者7 天前
Kafka 基础介绍
spring boot·kafka·消息队列
用户8307196840827 天前
spring ai alibaba + nacos +mcp 实现mcp服务负载均衡调用实战
spring boot·spring·mcp
Java水解7 天前
SpringBoot3全栈开发实战:从入门到精通的完整指南
spring boot·后端