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 包含三个主要组件:
- logback-core: 提供了通用的日志功能,包括Logger、Appender 和 Layout 等基本构件。
- logback-classic: 构建在 logback-core 之上,是 Logback 的经典实现,同时也是 Log4j 的直接替代品。它提供了一个类似于 Log4j 的 API,并在内部使用 logback-core 的功能。
- 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>
配置完成之后,控制台打印的日志样式如下:
请求参数和响应打印 {#请求参数和响应打印}
打印每次客户端发起请求所携带的参数和处理完毕后的响应,并统计处理本次请求所用的时间。
在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);
}
}
配置完毕之后重启应用,当客户端发起请求时将会打印如下日志:
可以搜索日志流水号,如上图中的1700581649227m24qd
找出某一次请求的日志打印记录。
参考资源 {#参考资源}
- Springboot3+微服务实战12306高性能售票系统 - 慕课网 (imooc.com)
- ChatGPT 3.5