51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

SpringBoot 3.0 日志相关配置

Preface {#preface}

Hi,大家好!

鲁迅曾经说过:日志打得好,排查没烦恼。

启动日志 {#启动日志}

将 Spring Boot 启动类修改成如下形式:

@SpringBootApplication
public class MwApiApplication {

    private static final Logger LOG = LoggerFactory.getLogger(MwApiApplication.class);

    public static void main(String[] args) {
        // 创建一个 SpringApplication 对象,参数 MwApiApplication.class 是 Spring Boot 应用的启动类。
        SpringApplication app = new SpringApplication(MwApiApplication.class);
        // 使用 SpringApplication 对象运行 Spring Boot 应用,并获取应用运行后的环境对象。
        ConfigurableEnvironment env = app.run(args).getEnvironment();
        LOG.info("启动成功!!!");
        // env.getProperty("server.port") 是从环境对象中获取 "server.port" 这个属性的值,也就是应用的运行端口。
        LOG.info("地址:http://127.0.0.1:{}", env.getProperty("server.port"));
    }

}

Logback 配置 {#logback-配置}

Logback 是一个用于 Java 应用程序的日志框架。它旨在成为Log4j的替代品,并由 Ceki Gülcü 创建。Logback 提供了一个灵活且高效的日志解决方案,适用于各种不同的应用场景。

Logback 包含三个主要组件:

  1. logback-core: 提供了通用的日志功能,包括Logger、Appender 和 Layout 等基本构件。
  2. logback-classic: 构建在 logback-core 之上,是 Logback 的经典实现,同时也是 Log4j 的直接替代品。它提供了一个类似于 Log4j 的 API,并在内部使用 logback-core 的功能。
  3. logback-access: 用于 Web 应用程序的模块,提供对 HTTP 访问日志的支持。

Logback 的特点包括:

  • 性能: Logback 被设计为高性能的日志框架,能够处理大量的日志事件而不会对应用程序的性能产生显著影响。
  • 灵活性: Logback 提供了丰富的配置选项,允许开发人员根据应用程序的需求进行灵活配置。它支持 XML 和 Groovy 等多种配置方式。
  • 可插拔性: Logback 支持多种 Appender 和 Layout,使开发人员能够根据具体需求选择合适的组件。
  • 遗产兼容性: logback-classic API 的设计与 Log4j 相似,因此可以较为轻松地将现有的 Log4j 日志代码迁移到 Logback。

总体而言,Logback 是一个强大而灵活的日志框架,广泛用于 Java 应用程序的日志记录需求。

resources目录下新建logback-spring.xml文件,用来配置日志打印的格式,保存路径及保存方式等。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 定义一个名为 PATH 的变量,值为日志文件保存路径,为当前项目的 log 目录下。在下方使用 ${PATH} 引用此变量的值 -->
    <property name="PATH" value="./log" />

    <!-- 在日志系统中,appender 是一种组件,用于定义日志消息的输出目的地。它决定了日志消息将被发送到何处,例如控制台、文件、数据库等。appender 负责将日志事件记录到指定的目标。-->
    <!-- 定义名为 STDOUT 的控制台输出 appender -->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- 原始格式:<Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %blue(%-50logger{50}:%-4line) %thread %green(%-18X{LOG_ID}) %msg%n</Pattern> -->

            <!-- 定义控制台输出的日志格式 -->
            <!-- %d{mm:ss.SSS}:表示输出日期和时间,使用分钟、秒和毫秒的格式。例如,12:34.567 表示时间为12分钟34秒567毫秒。 -->
            <!-- %highlight(%-5level):表示输出日志级别,并使用颜色进行高亮显示。%-5level 表示左对齐并固定占用5个字符的空间,不足的部分在右侧填充空格。 -->
            <!-- %blue(%-40logger{40}:%-4line):表示输出记录器名称和代码所在的文件、行号,并使用蓝色进行着色。%-40logger{40} 表示左对齐并固定占用40个字符的空间,不足的部分在右侧填充空格;%-4line 表示左对齐并固定占用4个字符的空间,不足的部分在右侧填充空格。 -->
            <!-- %thread:表示输出线程名称。 -->
            <!-- %green(%-18X{LOG_ID}):表示输出名为 LOG_ID 的线程上下文变量,并使用绿色进行着色。%-18X{LOG_ID} 表示左对齐并固定占用18个字符的空间,不足的部分在右侧填充空格。 -->
            <!-- %msg%n:表示输出日志消息和换行符。%msg 表示输出日志事件的消息部分,%n 表示换行符。 -->
            <!-- 综合起来,该格式的日志输出包含了日期、时间、日志级别、记录器名称、代码所在的文件和行号、线程名称、线程上下文变量 LOG_ID 以及日志消息。各个部分通过特定的格式和颜色进行了美化和区分。 -->
            <Pattern>%d{mm:ss.SSS} %highlight(%-5level) %blue(%-40logger{40}:%-4line) %thread %green(%-18X{LOG_ID}) %msg%n</Pattern>
        </encoder>
    </appender>

    <!-- 定义名为 TRACE_FILE 的滚动文件 appender,包含整个项目的日志 -->
    <!-- 在日志系统中,滚动文件(RollingFile)是一种日志输出策略,用于处理日志文件的大小或时间滚动。这种策略旨在避免单个日志文件过大,或者按时间生成新的日志文件,以便更好地管理和维护日志记录。 -->
    <appender name="TRACE_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 定义日志文件路径 -->
        <file>${PATH}/trace.log</file>
        <!-- 定义滚动策略为基于时间的滚动 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 定义日志文件名的规则 -->
            <!-- trace:这是日志文件的基本名称,文件名将以此开始。 -->
            <!-- ${PATH}:这是一个占位符,表示引用之前定义的属性 PATH。在这个配置中,PATH 被设置为 ./log,所以 ${PATH} 将被解析为 ./log。 -->
            <!-- %d{yyyy-MM-dd}:这是一个日期格式化的占位符,表示在文件名中插入当前日期,格式为 yyyy-MM-dd。 -->
            <!-- %i:这是用于在文件名中插入滚动索引的占位符。当日志文件大小达到指定的限制时,会创建一个新的日志文件,索引会递增。 -->
            <!-- .log:这是日志文件的扩展名。 -->
            <FileNamePattern>${PATH}/trace.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
            <!-- 定义时间和大小的滚动触发策略 -->
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <!-- 定义最大文件大小,每 10MB 生成一个新的文件 -->
                <maxFileSize>10MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
        </rollingPolicy>
        <!-- 定义日志输出格式 -->
        <layout>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %-50logger{50}:%-4line %green(%-18X{LOG_ID}) %msg%n</pattern>
        </layout>
    </appender>

    <!-- 定义名为ERROR_FILE的滚动文件 appender,包含整个项目错误相关的日志 -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 定义日志文件路径 -->
        <file>${PATH}/error.log</file>
        <!-- 定义滚动策略为基于时间的滚动 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 定义日志文件名的规则 -->
            <FileNamePattern>${PATH}/error.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
            <!-- 定义时间和大小的滚动触发策略 -->
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <!-- 定义最大文件大小 -->
                <maxFileSize>10MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
        </rollingPolicy>
        <!-- 定义日志输出格式 -->
        <layout>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %-50logger{50}:%-4line %green(%-18X{LOG_ID}) %msg%n</pattern>
        </layout>
        <!-- 定义过滤器,只接受 ERROR 级别的日志 -->
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>

    <!-- 根日志设置,输出 ERROR 级别的日志到 ERROR_FILE appender -->
    <root level="ERROR">
        <appender-ref ref="ERROR_FILE" />
    </root>

    <!-- 根日志设置,输出 TRACE 级别的日志到 TRACE_FILE appender -->
    <root level="TRACE">
        <appender-ref ref="TRACE_FILE" />
    </root>

    <!-- 根日志设置,输出 INFO 级别的日志到 STDOUT appender -->
    <root level="INFO">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

配置完成之后,控制台打印的日志样式如下:

image-20231120094357384

请求参数和响应打印 {#请求参数和响应打印}

打印每次客户端发起请求所携带的参数和处理完毕后的响应,并统计处理本次请求所用的时间。

pox.xml中引入以下依赖:

<!-- hutool 工具包-->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.16</version>
</dependency>

<!-- fastjson 工具-->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>2.0.42</version>
</dependency>

<!-- AOP 依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

新建aspect包,并在包下新建LogAspect类:

import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.support.spring.PropertyPreFilters;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;

/**
 * 打印日志切面类
 *
 * @author ldwcool
 */
@Aspect
@Component
public class LogAspect {

    private final static Logger LOG = LoggerFactory.getLogger(LogAspect.class);

    /**
     * 定义一个切点
     * 注解@Pointcut: 这是一个 Spring AOP 注解,用于定义切点。
     * "execution(public * cool.ldw..*Controller.*(..))": 这是切点表达式,指定了切点的匹配规则。让我们逐步解释:
     *     execution: 表示切点的类型,即在方法执行时触发切面。
     *     public *: 表示方法的修饰符,这里指定了只匹配 public 修饰的方法。
     *     cool.ldw..: cool.ldw 是包名,..表示任意子包。这样的写法匹配了以 cool.ldw 开头的任何包。
     *     *Controller: 匹配以 Controller 结尾的类名。
     *     *(..): 匹配任意方法名,且方法参数任意。
     * 综合起来,这个切点表达式的意思是匹配任何以 cool.ldw 开头的包中,类名以 Controller 结尾且方法是 public 修饰的任意方法。
     * 这通常用于定义一个切面,以便在这些 Controller 的方法执行前后执行额外的逻辑,比如日志记录、性能监控等。
     */
    @Pointcut("execution(public * cool.ldw..*Controller.*(..))")
    public void controllerPointcut() {
    }

    /**
     * 使用@Before注解标注,会在目标方法执行前进行处理
     *
     * @param joinPoint 切点
     */
    @Before("controllerPointcut()")
    public void doBefore(JoinPoint joinPoint) {
        // 开始打印请求日志

        // 获取请求参数
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        assert attributes != null;
        HttpServletRequest request = attributes.getRequest();
        // 获取方法名
        Signature signature = joinPoint.getSignature();
        String name = signature.getName();

        // 打印请求信息
        LOG.info("------------- 请求开始处理 -------------");
        LOG.info("请求地址: {} {}", request.getRequestURL().toString(), request.getMethod());
        LOG.info("类名方法: {}.{}", signature.getDeclaringTypeName(), name);
        LOG.info("远程地址: {}", request.getRemoteAddr());

        // 打印请求参数
        Object[] args = joinPoint.getArgs();
        // LOG.info("请求参数: {}", JSONObject.toJSONString(args));

        // 排除特殊类型的参数,如文件类型
        Object[] arguments = new Object[args.length];
        for (int i = 0; i < args.length; i++) {
            if (args[i] instanceof ServletRequest
                    || args[i] instanceof ServletResponse
                    || args[i] instanceof MultipartFile) {
                continue;
            }
            arguments[i] = args[i];
        }

        // 排除字段,敏感字段或太长的字段不显示:身份证、手机号、邮箱、密码等
        String[] excludeProperties = {};
        // 创建 PropertyPreFilters 对象,用于过滤属性
        PropertyPreFilters filters = new PropertyPreFilters();
        // 创建 MySimplePropertyPreFilter 实例,并添加到过滤器中
        PropertyPreFilters.MySimplePropertyPreFilter excludeFilter = filters.addFilter();
        // 将要排除的属性添加到过滤器中
        excludeFilter.addExcludes(excludeProperties);
        // 使用 JSONObject.toJSONString 方法将结果对象转换为 JSON 字符串,并在日志中打印
        LOG.info("请求参数: {}", JSONObject.toJSONString(arguments, excludeFilter));
    }

    /**
     * 使用@Around注解标注,表示这是一个环绕通知,会在目标方法执行前后进行处理
     *
     * @param proceedingJoinPoint 提供了对目标方法执行过程的控制和访问的接口
     * @return 目标方法执行的结果
     * @throws Throwable  抛出异常,允许在环绕通知中处理异常
     */
    @Around("controllerPointcut()")
    public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        // 记录方法执行开始时间
        long startTime = System.currentTimeMillis();

        // 执行目标方法,proceed方法会继续执行目标方法,如果不调用,目标方法将不会执行
        Object result = proceedingJoinPoint.proceed();

        // 排除字段,敏感字段或太长的字段不显示:身份证、手机号、邮箱、密码等
        String[] excludeProperties = {};
        PropertyPreFilters filters = new PropertyPreFilters();
        PropertyPreFilters.MySimplePropertyPreFilter excludeFilter = filters.addFilter();
        excludeFilter.addExcludes(excludeProperties);
        LOG.info("返回结果: {}", JSONObject.toJSONString(result, excludeFilter));
        LOG.info("------------- 请求处理结束 耗时:{} ms -------------", System.currentTimeMillis() - startTime);

        // 返回目标方法执行的结果
        return result;
    }

}

接在再新建一个包filter,并创建LogIdFilter类。

由于过滤器的优先级高于 AOP 切面类,因此在请求刚被处理时,就由日志过滤器 LogIdFilter设置好了日志流水号。这确保了在处理请求过程中,日志流水号能够被及时打印,从而方便追踪和调试。

使用@Order(Ordered.HIGHEST_PRECEDENCE)确保此过滤器优先级最高,最先被执行。

import cn.hutool.core.util.RandomUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * 日志过滤器,设置日志流水号
 * 注解@Order(Ordered.HIGHEST_PRECEDENCE) 确保此过滤器最先被执行
 *
 * @author ldwcool
 */
@Order(Ordered.HIGHEST_PRECEDENCE)
@Component
public class LogIdIFilter extends OncePerRequestFilter {

    /**
     * 在请求处理过程中的过滤器方法,用于增加日志流水号和设置日志自定义参数 LOG_ID。
     * 为什么要在过滤器中设置 LogId 呢?因为过滤器的优先级高。
     * 在过滤器中设置 LogId 并将当前过滤器优先级设置为最高 可以在处理一次请求中尽可能早得设置好日志流水号,方便查询一次请求的所有日志。
     *
     * @param request      HTTP 请求对象
     * @param response     HTTP 响应对象
     * @param filterChain  过滤器链,用于继续处理请求
     * @throws ServletException 如果发生 Servlet 异常
     * @throws IOException      如果发生 I/O 异常
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 增加日志流水号,设置日志自定义参数 LOG_ID。
        // 这里的 LOG_ID 就是 resource 目录下 logback-spring.xml 日志文件配置中 <Pattern> 标签下配置的 LOG_ID 线程上下文变量。
        MDC.put("LOG_ID", System.currentTimeMillis() + RandomUtil.randomString(5));

        filterChain.doFilter(request, response);
    }
}

配置完毕之后重启应用,当客户端发起请求时将会打印如下日志:

image-20231121235017037

可以搜索日志流水号,如上图中的1700581649227m24qd找出某一次请求的日志打印记录。

参考资源 {#参考资源}

赞(2)
未经允许不得转载:工具盒子 » SpringBoot 3.0 日志相关配置