Spring AOP 切面打印日志完整版

我的项目使用的是 SpringBoot 3。
要在 Spring Boot 3 项目中使用 AOP(面向切面编程)来打印接收和响应的参数,如 URL、参数、头部信息、请求体等,可以按照以下步骤操作:

步骤 1: 添加依赖

确保你的 pom.xml 文件中包含 spring-boot-starter-aop 依赖。如果你创建的是一个标准的 Spring Boot 项目,这个依赖通常已经包含在内了。如果没有,请手动添加:

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

步骤 2: 创建切面类

创建一个新的类用于定义你的切面逻辑。例如,可以命名为 LoggingAspect.java

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import jakarta.servlet.http.HttpServletRequest;

@Aspect
@Component
public class LoggingAspect {

    @Before("execution(* your.package.name.controller..*(..))")
    public void logRequest(JoinPoint joinPoint) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        // 打印URL
        System.out.println("URL : " + request.getRequestURL().toString());

        // 打印HTTP方法
        System.out.println("HTTP Method : " + request.getMethod());

        // 打印Header信息
        Enumeration<String> headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            String headerName = headerNames.nextElement();
            System.out.println(headerName + " : " + request.getHeader(headerName));
        }

        // 打印请求参数
        System.out.println("Params : " + request.getQueryString());

        // 注意:直接从request对象获取body内容比较复杂,因为一旦读取就无法再次读取。
        // 对于需要读取body的情况,你可能需要使用HttpServletRequestWrapper或者Filter来实现。
    }

    @AfterReturning(pointcut = "execution(* your.package.name.controller..*(..))", returning = "result")
    public void logResponse(Object result) {
        System.out.println("Response Body : " + result);
    }
}

请注意,直接从 HttpServletRequest 中读取 body 内容可能会遇到挑战,因为一旦读取后,流将不可再次读取。对于需要访问 body 的场景,你可能需要使用 HttpServletRequestWrapper 或者 Filter 来缓存请求体,以便多次读取。

步骤 3: 配置类(如果需要)
对于某些高级需求,比如需要读取请求体,你可能还需要创建一个配置类或使用过滤器(Filter)来处理请求体的重复读取问题。

以上就是如何在 Spring Boot 3 项目中使用 AOP 打印接收和响应参数的基本步骤。根据实际需要调整包名和类名,并考虑是否需要处理请求体的特殊情形。


没了吗?
别急, 继续挑战 body 输出。接来下要做改造了。


为了在 Spring Boot 应用中使用 AOP 记录请求体(body),同时避免由于 InputStream 只能被读取一次而导致的问题,你可以通过创建一个自定义的 HttpServletRequestWrapper 来缓存请求体。这样,在你的切面逻辑中就可以多次访问请求体了。下面是一个具体的实现示例:

步骤 1: 创建 CachedBodyHttpServletRequest 类

首先,创建一个类继承 HttpServletRequestWrapper,用于缓存请求体。

import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;

/**
 * 自定义 HttpServletRequestWrapper 来缓存请求体。
 */
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
    private byte[] cachedBody;
    private boolean isMultipart;

    /**
     * 构造函数,读取并缓存原始请求体。
     */
    public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);
        this.isMultipart  = request.getContentType()  != null && request.getContentType().startsWith("multipart/"); 
        if (!isMultipart) {
            InputStream requestInputStream = request.getInputStream(); 
            this.cachedBody  = requestInputStream.readAllBytes(); 
        }
    }

    /**
     * 获取缓存的请求体。
     */
    public byte[] getCachedBody() {
        return cachedBody;
    }
    
    public boolean isMultipart() {
        return this.isMultipart;
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }

    @Override
    public ServletInputStream getInputStream() {
        return new CachedBodyServletInputStream(this.cachedBody);
    }
}

步骤 2: 创建 CachedBodyServletInputStream 类

然后,创建一个类来包装原始的 ServletInputStream,以便提供缓存的请求体数据。


import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;

/**
 * 自定义 ServletInputStream 来包装缓存的请求体。
 */
public class CachedBodyServletInputStream extends ServletInputStream {
    private final ByteArrayInputStream cachedBodyInputStream;

    /**
     * 使用缓存的请求体构造实例。
     */
    public CachedBodyServletInputStream(byte[] cachedBody) {
        this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody);
    }

    @Override
    public boolean isFinished() {
        return this.cachedBodyInputStream.available() == 0;
    }

    @Override
    public boolean isReady() {
        return true;
    }

    @Override
    public void setReadListener(ReadListener listener) {
        throw new UnsupportedOperationException();
    }

    @Override
    public int read() throws IOException {
        return this.cachedBodyInputStream.read();
    }
}

步骤 3: 创建并注册 Filter

接下来,创建一个过滤器来替换原始请求为你的 CachedBodyHttpServletRequest 实例。


import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
 * 过滤器用于将原始请求替换为自定义的 CachedBodyHttpServletRequest。
 */
@Component
@Slf4j
public class CachingRequestBodyFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
//        String contentType = httpRequest.getContentType();
//        // 检查是否为multipart请求, multipart 文件不读取,不然会有问题
//        if (contentType != null && contentType.startsWith("multipart/")) {
//            log.info(" 请求体: 此为 multipart 请求,不读取请求体");
//            // 直接传递,不进行包装
//            chain.doFilter(request, response);
//        } else {
//            // 非multipart请求时缓存请求体
//            CachedBodyHttpServletRequest cachedBodyRequest = new CachedBodyHttpServletRequest(httpRequest);
//            chain.doFilter(cachedBodyRequest, response);
//        }

        // 非multipart请求时缓存请求体
        CachedBodyHttpServletRequest cachedBodyRequest = new CachedBodyHttpServletRequest(httpRequest);
        chain.doFilter(cachedBodyRequest, response);
    }

    @Override
    public void destroy() {}
}

注意:确保你在 Spring Boot 配置中注册这个过滤器,例如通过添加 @Component 注解或者在配置类中进行注册。

步骤 4: 修改 LoggingAspect 类

最后,修改你的 LoggingAspect 类以利用缓存请求体的功能:


import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

/**
 * 使用 AOP 记录请求的 URL、参数、头部信息以及 body 内容。
 */
@Aspect
@Component
@Slf4j
public class LoggingAspect {

    @Before("execution(* com.tylerzhong.web.controller..*(..))")
    public void logRequest(JoinPoint joinPoint) throws Exception {
        log.info("=======================请求数据start=======================");
        // 获取当前请求属性
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes != null) {
            // 从请求属性中获取 HttpServletRequest
            HttpServletRequest request = attributes.getRequest();
            if (request instanceof CachedBodyHttpServletRequest) {
                CachedBodyHttpServletRequest cachedBodyRequest = (CachedBodyHttpServletRequest) request;
                // 打印URL
                log.info("请求地址:{}", request.getRequestURL().toString());
                // 打印HTTP方法
                log.info("请求方式:{}", request.getMethod());
                // 打印Header信息
                var headerNames = request.getHeaderNames();
                log.info("请求头:");
                while (headerNames.hasMoreElements()) {
                    String headerName = headerNames.nextElement();
                    System.out.println(headerName + ":" + request.getHeader(headerName));
                }
                // 打印请求参数
                String queryString = request.getQueryString();
                // 此处进行解码处理
                String decodedQueryString = URLDecoder.decode(queryString, StandardCharsets.UTF_8);
                log.info("请求参数:{}", decodedQueryString);
                // 获取请求体字节数组
                byte[] requestBodyBytes = cachedBodyRequest.getCachedBody();
                String rawRequestBody = new String(requestBodyBytes, StandardCharsets.UTF_8);
                log.info("请求体:{}", rawRequestBody);
                log.info("=======================请求数据end=======================");
            }
        }
    }

    @AfterReturning(pointcut = "execution(* com.tylerzhong.web.controller..*(..))", returning = "result")
    public void logResponse(Object result) {
        log.info("-----------------------响应数据start-----------------------");
        log.info("响应体:{}", result);
        log.info("-----------------------响应数据end-----------------------");
    }
}

代码中的下面这段代码进行解码打印是因为请求参数在传输过程中被进行了 URL 编码(也称为百分号编码)。URL 编码是一种用于将字符转换为可以在 URL 中安全传输的格式的编码方式。例如,中文字符“姓名”和“年龄”会被编码为 %E5%A7%93%E5%90%8D 和 %E5%B9%B4%E9%BE%84。
如果你希望打印出原始的、未经过 URL 编码的请求参数,你需要对这些参数进行解码。Java 提供了 java.net.URLDecoder 类来帮助你完成这个任务。

String queryString = request.getQueryString();
// 此处进行解码处理
String decodedQueryString = URLDecoder.decode(queryString, StandardCharsets.UTF_8);

到上面为止,基本可以说完成了AOP切面打印日志的所有内容。
但是我的项目中会上传大量的文件,并且是通过二进制流传递的,所以打印出来的都是二进制数据,输出到控制台会很长,所以我这里需要对它进行 base64 编码输出,但是如果传的 json 数据,我需要输出原始数据,即不进行编码。所以我就想了一个折中的办法,一般上传文件的话,字节数据大小都是几百KB,所以我就判断,如果字节数据的大于100KB就进行 base64 编码输出,小于 100KB 就输出原始数据。这样既可以节省空间,也简化了的输出内容。

所以再进行改造一下:


import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

/**
 * 使用 AOP 记录请求的 URL、参数、头部信息以及 body 内容。
 */
@Aspect
@Component
@Slf4j
public class LoggingAspect {

    private static final int MAX_RAW_BODY_SIZE = 5120; // 5KB

    @Before("execution(* com.tylerzhong.web.controller..*(..))")
    public void logRequest(JoinPoint joinPoint) throws Exception {
        log.info("=======================请求数据start=======================");
        // 获取当前请求属性
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes != null) {
            // 从请求属性中获取 HttpServletRequest
            HttpServletRequest request = attributes.getRequest();
            if (attributes != null) {
            // 从请求属性中获取 HttpServletRequest
            HttpServletRequest request = attributes.getRequest();
            if (request instanceof CachedBodyHttpServletRequest) {
                CachedBodyHttpServletRequest cachedBodyRequest = (CachedBodyHttpServletRequest) request;
                // 打印URL
                log.info("请求地址:{}", request.getRequestURL().toString());
                // 打印HTTP方法
                log.info("请求方式:{}", request.getMethod());
                // 打印Header信息
                var headerNames = request.getHeaderNames();
                log.info("请求头:");
                while (headerNames.hasMoreElements()) {
                    String headerName = headerNames.nextElement();
                    log.info(headerName+":"+request.getHeader(headerName));
                }
                // 打印请求参数
                String queryString = request.getQueryString();
                if (queryString != null){
                    String decodedQueryString = URLDecoder.decode(queryString, StandardCharsets.UTF_8);
                    log.info("请求参数:{}", decodedQueryString);
                } else {
                    log.info("请求参数:{}", "无");
                }
                // 判断是否为 multipart 请求
                if (!cachedBodyRequest.isMultipart())  {
                    // 获取请求体字节数组
                    byte[] requestBodyBytes = cachedBodyRequest.getCachedBody();
                    if (requestBodyBytes != null) {
                        if (requestBodyBytes.length  > MAX_RAW_BODY_SIZE) {
                            // 如果字节数组长度超过阈值,进行Base64编码并打印
                            String base64EncodedRequestBody = Base64.getEncoder().encodeToString(requestBodyBytes);
                            log.info(" 请求体:{}", base64EncodedRequestBody);
                        } else {
                            // 否则直接打印原始内容
                            String rawRequestBody = new String(requestBodyBytes, StandardCharsets.UTF_8);
                            log.info(" 请求体:{}", rawRequestBody);
                        }
                    }
                } else {
                    log.info(" 请求体: 此为 multipart 请求,不读取请求体");
                }
                log.info("======================= 请求数据end=======================");
            }
        }
    }

    @AfterReturning(pointcut = "execution(* com.tylerzhong.web.controller..*(..))", returning = "result")
    public void logResponse(Object result) {
        log.info("-----------------------响应数据start-----------------------");
        log.info("响应体:{}", result);
        log.info("-----------------------响应数据end-----------------------");
    }
}

请确保替换 com.tylerzhong.web 和 com.tylerzhong.web.controller 为你实际的应用包名和控制器所在的包路径。

注意事项:
上面代码跳过了对 multipart/ 开头的类型参数的日志输出,因为经过我的测试,如果日志输出后,在 controller 层就获取不到该参数了。没有找到既可以输出日志又可以在 controller 层获取到该参数的解决方案。所以只能舍弃该类型参数的日志输出了。

@PostMapping("/upload")
    public FileStatus upload(MultipartFile file){
    
    }

代码解释

  1. CachedBodyHttpServletRequest 类:在构造函数中,通过判断请求的 Content-Type 是否以 multipart/form-data 开头,来确定是否为 multipart 请求。如果是,就不读取请求体;如果不是,就正常读取并缓存请求体。在 getInputStream 方法中,对于 multipart 请求,直接返回父类的输入流。

  2. LoggingAspect 类:在打印请求体时,先判断是否为 multipart 请求。如果是,就不读取请求体,直接记录提示信息;如果不是,就正常读取并打印请求体。

  3. CachingRequestBodyFilter类:在过滤器中跳过对Multipart请求的包装。原因:直接处理multipart/form-data请求会提前消费输入流,导致文件解析不到,不过这里注释掉了,不进行跳过,因为后面再次做了判断,如果是 multipart 请求类型,就不读取请求提,但是其他的请求日志还是会输出的,比如 url, method 等等。

以上所有代码提供了一个完整的解决方案,用于在 Spring Boot 3 应用中使用 AOP 来记录请求的详细信息,包括 URL、参数、头部信息以及 body 内容。注意,这里假设你已经在项目中正确配置了 Spring AOP 相关依赖,并且你的应用是基于 Spring Boot 构建的。


更新:

由于上面的代码有点乱,所以我使用了 deepseek 对这 LoggingAspect 的代码进行优化,并且使用了配置文件里面配置参数的方式来决定是否输出请求体和响应体,以及字节多大的时候才进行编码输出。这样的话会更方便灵活,而且可以在启动的时候通过注入变量来控制,就更灵活了。

下面请看完整代码:

application.yml 的配置文件内容:

logging:
  # 支持环境变量覆盖
  maxBase64Size: ${MAX_BASE64_SIZE:5120} # 5KB
  requestBodyEnable: ${REQUEST_BODY_ENABLE:true}
  responseBodyEnable: ${RESPONSE_BODY_ENABLE:true}

LoggingAspect 的内容:


import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collections;
import java.util.Enumeration;

/**
 * 使用 AOP 记录请求的 URL、参数、头部信息以及 body 内容。
 */
@Aspect
@Component
@Slf4j
@EnableConfigurationProperties // 确保配置加载
public class LoggingAspect {

    private static final String BANNER_TEMPLATE = "===== {} =====";
    private static final String REQUEST_BANNER = "请求数据";
    private static final String RESPONSE_BANNER = "响应数据";

    // 注入配置属性(带默认值)
    @Value("${logging.requestBodyEnable:true}")
    private boolean logRequestBodyEnabled;

    @Value("${logging.responseBodyEnable:true}")
    private boolean logResponseBodyEnabled;

    @Value("${logging.maxBase64Size:5120}")
    private int maxBase64Size; // 可选:动态配置阈值, 默认 5KB

    @Before("execution(* com.tylerzhong.web.controller..*(..))")
    public void logRequest(JoinPoint joinPoint) throws Exception {
        log.info(BANNER_TEMPLATE,  REQUEST_BANNER + "start");
        // 获取当前请求属性
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes != null) {
            // 从请求属性中获取 HttpServletRequest
            HttpServletRequest request = attributes.getRequest();
            if (request instanceof CachedBodyHttpServletRequest) {
                CachedBodyHttpServletRequest cachedBodyRequest = (CachedBodyHttpServletRequest) request;
                // 调用封装方法
                logBasicRequestInfo(request);
                logHeaders(request);
                logQueryParams(request);
                if (logRequestBodyEnabled) {
                    logRequestBody(cachedBodyRequest); // 核心逻辑封装
                }
                log.info(BANNER_TEMPLATE,  REQUEST_BANNER + "end");
            }
        }
    }

    @AfterReturning(pointcut = "execution(* com.tylerzhong.web.controller..*(..))", returning = "result")
    public void logResponse(Object result) {
        log.info(BANNER_TEMPLATE,  RESPONSE_BANNER + "start");
        if (logResponseBodyEnabled) {
            log.info("响应体:{}", result);
        }
        log.info(BANNER_TEMPLATE,  RESPONSE_BANNER + "end");
    }

    private void logBasicRequestInfo(HttpServletRequest request) {
        log.info(" 请求地址:{}", request.getRequestURL());
        log.info(" 请求方式:{}", request.getMethod());
    }

    private void logHeaders(HttpServletRequest request) {
        Enumeration<String> headerNames = request.getHeaderNames()  != null ?
                request.getHeaderNames()  : Collections.emptyEnumeration();
        StringBuilder headersBuilder = new StringBuilder("\n");
        while (headerNames.hasMoreElements())  {
            String headerName = headerNames.nextElement();
            headersBuilder.append("   ")
                    .append(headerName)
                    .append(": ")
                    .append(request.getHeader(headerName))
                    .append("\n");
        }
        log.info(" 请求头: {}", headersBuilder.toString());
    }

    /**
     * 记录请求的查询参数(Query Parameters)
     * @param request HTTP请求对象
     */
    private void logQueryParams(HttpServletRequest request) {
        String queryString = request.getQueryString();
        if (queryString != null) {
            try {
                // 解码 URL 编码的查询参数
                String decodedQueryString = URLDecoder.decode(queryString,  StandardCharsets.UTF_8);
                log.info(" 请求参数: {}", decodedQueryString);
            } catch (IllegalArgumentException e) {
                // 防御性处理:避免因非法字符导致切面崩溃
                log.warn(" 请求参数解码失败(非法字符): {}", queryString);
            }
        } else {
            log.info(" 请求参数: 无");
        }
    }

    /**
     * 记录请求体(Body)内容
     * @param cachedBodyRequest 可重复读取Body的请求对象
     */
    private void logRequestBody(CachedBodyHttpServletRequest cachedBodyRequest) {
        // 排除 multipart 请求(如文件上传)
        if (cachedBodyRequest.isMultipart())  {
            log.info(" 请求体: 此为 multipart 请求,不读取请求体");
            return;
        }

        // 获取缓存的请求体字节数组
        byte[] requestBodyBytes = cachedBodyRequest.getCachedBody();
        if (requestBodyBytes == null) {
            log.info(" 请求体: 空");
            return;
        }

        // 动态判断请求体大小(根据配置的 maxBodySize)
        if (requestBodyBytes.length  > maxBase64Size) {
            // 大文件场景:Base64 编码后截断显示(避免日志膨胀)
            String base64EncodedRequestBody = Base64.getEncoder().encodeToString(requestBodyBytes);
            log.info(" 请求体(Base64 编码): {}", base64EncodedRequestBody);
        } else {
            // 小文本场景:直接输出原始内容
            String rawRequestBody = new String(requestBodyBytes, StandardCharsets.UTF_8);
            log.info(" 请求体: {}", rawRequestBody);
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值