提到日志,我们一般只会想到使用
@Slf4j
打印的日志,实际上我们系统中的日志还可以细分为系统日志、登录日志和操作日志。本文将讲述如何在SpringBoot中使用和开发这三种日志系统。关键词:Logback、Slf4j、p6spy、AspectJ、SpringEvent、线程池、Async
这里我根据业务将日志简单分为三类:
- 系统日志:打印到控制台和log文件中。
- 登录日志:记录用户登录信息。
- 操作日志:记录后台操作过程。
系统日志
Logback日志框架
logback依赖: spring-boot-dependencies会内置logback-core依赖,不需要手动引入。
logback配置:
yml
# 日志配置
logging:
level:
cn.cagurzhan: @logging.level@
org.springframework: warn
config: classpath:logback-plus.xml
- logback.level:配置每个包的日志级别。
- 如:
cn.cagurzhan: @logging.level@
(对应父pom的profile内的变量,对每一个级别如debug等配置logging.level标签,见下方)
- 如:
- logback.config:指定配置文件。
xml
<profile>
<id>dev</id>
<properties>
<!-- 环境标识,需要与配置文件的名称相对应 -->
<profiles.active>dev</profiles.active>
<logging.level>info</logging.level>
</properties>
<activation>
<!-- 默认环境 -->
<activeByDefault>true</activeByDefault>
</activation>
</profile>
logback配置文件解释:
- property:变量。
- log.path:日志持久化路径。
- thread:线程
- level:级别
- logger:类路径
- msg:打印消息
- console.log.pattern / log.pattern:格式化
- log.path:日志持久化路径。
- appender:输出目的地,声明日志输出组件
- name:标识
- class:实现类
- encoder:编码器
- file:持久化文件名
- rollingPolicy:文件名格式、日志保存记录数
- filter:阈值过滤器,设置过滤级别
- 异步调用:
- discardingThreshold:不丢失日志。默认>队列80%丢弃INFO及以下日志。
- queueSize:队列大小。
- root:引入日志输出组件
以下是我的logback配置文件分享。
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="log.path" value="./logs"/>
<property name="console.log.pattern"
value="%red(%d{yyyy-MM-dd HH:mm:ss}) %green([%thread]) %highlight(%-5level) %boldMagenta(%logger{36}%n) - %msg%n"/>
<property name="log.pattern" value="%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"/>
<!-- 控制台输出 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder> <pattern>${console.log.pattern}</pattern>
<charset>utf-8</charset>
</encoder> </appender>
<!-- 控制台输出 -->
<appender name="file_console" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-console.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-console.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大 1天 -->
<maxHistory>1</maxHistory>
</rollingPolicy> <encoder> <pattern>${log.pattern}</pattern>
<charset>utf-8</charset>
</encoder> <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
</filter> </appender>
<!-- 系统日志输出 -->
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-info.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy> <encoder> <pattern>${log.pattern}</pattern>
</encoder> <filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter> </appender>
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-error.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy> <encoder> <pattern>${log.pattern}</pattern>
</encoder> <filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>ERROR</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter> </appender>
<!-- info异步输出 -->
<appender name="async_info" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_info"/>
</appender>
<!-- error异步输出 -->
<appender name="async_error" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_error"/>
</appender>
<!--系统操作日志-->
<root level="info">
<appender-ref ref="console" />
<appender-ref ref="async_info" />
<appender-ref ref="async_error" />
<appender-ref ref="file_console" />
<!-- <appender-ref ref="sky_log"/>-->
</root>
</configuration>
Slf4j
日志打印方式一:Logger工厂(单例模式)
java
Logger logger = LoggerFactory.getLogger("要记录的类");
logger.trace("xxx");
logger.info("xxx");
logger.debug("xxx");
logger.warn("xxx");
logger.error("xxx");
日志级别:
- trace < debug < info < warn < error
- 只会打印级别大于等于已设置级别的日志
日志打印方式二:注解
- 类注解:
@Slf4j
@Slf4j
会自动生成一个log的logger单例。直接调用log.xxx即可。log.info("xxx,{},{}","xx","xx")
log.info("xxx",exception)
SQL性能分析:p6spy
依赖:
xml
<!-- sql性能分析插件 -->
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>${p6spy.version}</version>
</dependency>
配置:
- dev环境:
spring.datasource.dynamic.p6spy=true
- prod环境:不推荐开启
p6spy实现方式(了解) :代理多数据源,重写数据库connect方法,创建监听器,执行打印并统计时间。
- 旧模式(驱动代理):在数据源层级开启代理,监听SQL语句执行,统计时间。
- 将driveClassName的
com.mysql.cj.jdbc.Driver
替换为P6SpyDriver(实现了Driver接口),监听SQL,拦截SQL统计时间后转发给JDBC的驱动。 - url也要变成:
jdbc:p6spy:mysql
- 将driveClassName的
- 现在的模式:代理多数据源
配置文件解释:
- modulelist:配置MybatisPlusLogFactory的扩展,启用监听。
- logMessageFormat:也是Mp的实现,自定义日志打印
- appender:也是Mp的实现,输出到控制台(StdoutLogger)
- excludecategories:排除结果集
- outagedetection:开启慢SQL记录,超过2S就记录
- filter:开启过滤
- exclude:过滤掉SELECT 1(用于数据库存活检测)
配置文件分享:
properties
# p6spy 性能分析插件配置文件
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory
# 自定义日志打印
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
#日志输出到控制台
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# 使用日志系统记录 sql#appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 设置 p6spy driver 代理
#deregisterdrivers=true
# 取消JDBC URL前缀
useprefix=true
# 配置记录 Log 例外,可去掉的结果集有error,info,batch,debug,statement,commit,rollback,result,resultset.
excludecategories=info,debug,result,commit,resultset
# 日期格式
dateformat=yyyy-MM-dd HH:mm:ss
# SQL语句打印时间格式
databaseDialectTimestampFormat=yyyy-MM-dd HH:mm:ss
# 实际驱动可多个
#driverlist=org.h2.Driver
# 是否开启慢SQL记录
outagedetection=true
# 慢SQL记录标准 2 秒
outagedetectioninterval=2
# 是否过滤 Logfilter=true
# 过滤 Log 时所排除的 sql 关键字,以逗号分隔
exclude=SELECT 1
登录日志实战
SpringEvent事件监听
发布事件:
- 注入ApplicationContext
- 在domain内新建一个Event事件类
- 实例化事件类,调用context.publishEvent(event)发布。
当然,也可以用封装的SpringUtils来发布事件。
创建监听器:
- 新建EventListener监听器类,注入容器。
- 新增方法,参数接收事件类,并加上
@EventListener
注解
例如:
java
@Async
@EventListener
public void recordOper(OperLogEvent operLogEvent) {
SysOperLog operLog = BeanUtil.toBean(operLogEvent, SysOperLog.class);
// 远程查询操作地点
operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
insertOperlog(operLog);
}
SpringEvent默认是同步的,是同一个线程内进行发布和监听。 如果监听器抛出异常,那么发布事件后面的代码不会执行。
多个监听器:
- 在同一个线程中执行,但是执行顺序不固定。如果要增加顺序,需要加上
@Order(1)
注解,越小越靠前执行。同步才能控制顺序,如果前面顺序的监听器抛出异常,后面的都不会执行! - 缺点:同步监听用来记录日志等就会影响响应时间,影响用户体验!
异步监听:
- 实现 :给监听器的方法加上
@Async
注解 - 做个小实验,得到这个加了注解的线程名:
schedule-pool-1
,响应时间也减少了。而且抛出异常不会影响其它监听器。
实验2:给加了
@Async
注解的监听器,通过缓存获取登录用户。 实验结果:加了@Async
注解的监听器无法从缓存拿到登录用户。
所以,需要通过异步拿到登录用户,最好在发布前存到Event里面!
实验3:发布事件方法加
@Transactional
注解,发布事件前增删数据库,然后发布事件,然后抛出异常。 实验结果:事务会回滚,监听方法也执行了。
这并不意外,因为是同步执行的。 那如何做到事务提交了才执行监听呢?
- 将
@EventListener
注解改成:@TransactionalEventListener
- 默认phase=aftercommit
- 还有afterrollback等周期,可以根据不同需求来设计。
- 观察到实验结果符合预期,监听器的方法没有执行。
登录日志功能
登录日志功能: 记录了每个用户的访问编号、用户名、登录地址、登录地点、浏览器、操作系统、登录状态、操作信息和登录日期等。
具体实践:
- 创建发布登录事件方法 ,传入username、status、msg、request。
- 传入request是因为监听器是异步的,无法获取当前request。
- 在checkLogin、validated、login、logout等调用发布事件方法,记录登录成功、登录失败等事件的日志。
- 在服务层添加登录事件监听器,使用
@Async
注解保证异步调用。具体实现如下:- 从request拿到UserAgent、IP、address等信息。
- 用StringBuilder拼接,输出到日志。
- 创建SysLoginInfo对象,set属性,存入数据库。
操作日志实战
操作日志功能: 操作日志记录包含系统模块、操作类型、请求方式、操作人员、操作地址、操作状态、操作日期、请求参数、返回参数等信息。
自定义@Log注解
@Log
注解包含的内容:
- 操作的模块:如账户解锁、用户管理、字典管理等等。
- 操作的类型:如导入、导出、清空数据、其它等等。
- 操作人员
- 是否保存请求参数和响应参数
- 排除指定的请求参数,如password字段等。
java
@Target({ElementType.METHOD,ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
/**
* 模块
*/
String title() default "";
/**
* 操作类型
*/
BusinessType businessType() default BusinessType.OTHER;
/**
* 操作人类别
*/
OperatorType operatorType() default OperatorType.MANAGE;
/**
* 是否保存请求的参数
*/
boolean isSaveRequestData() default true;
/**
* 是否保存响应的参数
*/
boolean isSaveResponseData() default true;
/**
* 排除指定的请求参数
*/
String[] excludeParamNames() default {};
}
具体使用:
java
@Log(title = "测试单表", businessType = BusinessType.EXPORT)
LogAspect切面注解
定义一个切面 :给类加上@Aspect
注解,表示一个切面。切面可以包含多种类型通知。
java
@Slf4j
@Aspect
@Component
public class LogAspect {
}
定义一个后置通知 :使用@AfterReturning
指定方法执行后运行,使用pointcut定义切点为我们自定义的@Log注解,使用returning指定接收返回值。
java
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) {
handleLog(joinPoint, controllerLog, null, jsonResult);
}
定义拦截异常操作 :使用@AfterThrowing
注解,同样定义切点和抛出的异常
java
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) {
handleLog(joinPoint, controllerLog, e, null);
}
简单实现一个记录操作日志的功能handleLog:
- 创建日志对象OperLogEvent(具体的操作日志字段可以由业务而定义)
- 通过ServletUtils,获取客户端IP和其它请求信息,设置到OperLogEvent。
- 将异常状态和错误消息设置到OperLogEvent。
- 使用joinPoint连接点拿到方法名和类名
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
- 获取注解的描述信息,
- 使用SpringEvent发布事件,异步将OperLogEvent保存到数据库。
recordOper监听器:
- 使用了
@Async
和@EventListener
实现异步日志记录。 - 使用Bean工具类,将OperLogEvent转为SysOperLo,插入数据库
注意事项 : 如果我们在log切面的处理方法中,使用了Sa-Token上下文来获取操作用户,而加了@Log的接口却是放行的,无法获取用户,就会抛出NPE。所以,@Log注解只能加在需要鉴权的接口上。
ServletUtils的原理:从当前线程上下文中拿到HttpServletRequest和HttpSevletResponse,如果是异步的,就获取不到。
线程池和Async注解
JUC线程池执行器:ThreadPoolExecutor,有七个参数的构造方法,分别是
- 核心线程数
- 最大线程数
- 空闲时间
- 时间单位
- 队列
- 线程工厂
- 拒绝策略
线程池执行流程:
- 发布任务,提交到线程池。
- 判断核心线程数是否已满,如果不是,创建核心线程池执行任务。
- 如果是,判断线程池队列数是否已满,如果不是,就将任务放到队列等待处理。
- 如果是,判断最大线程数是否已满,如果不是,创建非核心线程处理任务。如果是,按照拒绝策略执行任务(如拒绝任务、丢弃任务、将任务返回给主线程执行)
- 如果非核心线程处理任务处理完成,且空闲时间达到设置的时间,非核心线程就会进行销毁。
线程的状态:
- RUNNING:正常接收任务
- SHUTDOWN:调用shutdown()方法,不接受新任务,可以处理阻塞队列任务。
- STOP:调用shutdownNow(),停止状态,不接受任务,不处理队列任务,终止正在工作的线程。
- TIDYING:过渡态,当队列为空且工作线程为空会进入。
- TERMINATED:terminated()会进入终止状态
线程池配置
ThreadPoolProperties配置类:设置是否开启线程池、队列最大数、线程池维护线程的空闲时间。
java
@Data
@Component
@ConfigurationProperties(prefix = "thread-pool")
public class ThreadPoolProperties {
/**
* 是否开启线程池
*/
private boolean enabled;
/**
* 队列最大长度
*/
private int queueCapacity;
/**
* 线程池维护线程所允许的空闲时间
*/
private int keepAliveSeconds;
}
线程池配置
- 配置线程池核心数为CPU核心数+1
- 注入ThreadPoolTaskExecutor (springframework包下的),里面封装了JUC的ThreadPoolExecutor
- 注意:配置了
@ConditionalOnProperty
,所以只有当配置了enable开启才会注入。 - 设置核心数、队列数、队列容量、空闲时间、拒绝策略
- 注意:配置了
- 注入ScheduledExecutorService ,用于执行周期性或定时任务
- 命名规则:
schedule-pool-%d
- 配置策略为调用者执行策略。
Async
注解就是使用这个线程池。
- 命名规则:
java
@Configuration
public class ThreadPoolConfig {
/**
* 核心线程数 = cpu 核心数 + 1
*/ private final int core = Runtime.getRuntime().availableProcessors() + 1;
@Autowired
private ThreadPoolProperties threadPoolProperties;
@Bean(name = "threadPoolTaskExecutor")
@ConditionalOnProperty(prefix = "thread-pool", name = "enabled", havingValue = "true")
public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(core);
executor.setMaxPoolSize(core * 2);
executor.setQueueCapacity(threadPoolProperties.getQueueCapacity());
executor.setKeepAliveSeconds(threadPoolProperties.getKeepAliveSeconds());
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
/**
* 执行周期性或定时任务
*/
@Bean(name = "scheduledExecutorService")
protected ScheduledExecutorService scheduledExecutorService() {
return new ScheduledThreadPoolExecutor(core,
new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build(),
new ThreadPoolExecutor.CallerRunsPolicy()) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
Threads.printException(r, t);
}
};
}
}
使用线程池
- 注入线程池:
private final ThreadPoolTaskExecutor executor
- 使用线程池执行方法:
executor.submit(()->{})
java
class ThreadPoolController{
// 注入线程池
private final ThreadPoolTaskExecutor executor
// 观察到,响应时间只有60ms,异步执行了method方法。
public String pool2(){
Future<String> submit = executor.submit(()->{
this->method();
return "hello";
})
String value = submit.get();
log.info(value);
}
private void method(){
ThreadUtils.sleep(3000);
}
}
Async异步调用注解
创建配置类:
- 开启Async调用
@EnableAsync(proxyTargetClass = true)
,并设置为cglib动态代理。 - 注入
ScheduledExecutorService
- 提供异步执行器:
scheduledExecutorService
,即计划执行器。 - 提供异步未捕获异常处理器:
AsyncUncaughtExceptionHandler
java
@EnableAsync(proxyTargetClass = true)
@Configuration
public class AsyncConfig extends AsyncConfigurerSupport {
@Autowired
@Qualifier("scheduledExecutorService")
private ScheduledExecutorService scheduledExecutorService;
/**
* 自定义 @Async 注解使用系统线程池
*/
@Override
public Executor getAsyncExecutor() {
return scheduledExecutorService;
}
/**
* 异步执行异常处理
*/
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, objects) -> {
throwable.printStackTrace();
StringBuilder sb = new StringBuilder();
sb.append("Exception message - ").append(throwable.getMessage())
.append(", Method name - ").append(method.getName());
if (ArrayUtil.isNotEmpty(objects)) {
sb.append(", Parameter value - ").append(Arrays.toString(objects));
}
throw new ServiceException(sb.toString());
};
}
}
使用 :给方法加上Async
注解。
注意事项:
- 直接使用内部调用方法无法生效,还是同一个线程。必须让方法交给Spring管理。因此,我们可以通过创建
AsyncService
,注入Spring容器,调用此类的@Async
方法即可。 - 如果我们调用的
AsyncService
内的非Async
方法, 即使该方法内部调用了@Async
方法(直接调用),那么仍然是通过单一线程进行执行的。所以,我们需要在主线程需要直接调用的方法上加入@Async
。
异常:
- 主线程抛出异常不会影响子线程的方法执行。
- 子线程抛出异常不会影响主线程的方法执行。
事务: 在不同线程中,事务管理是完全独立的。主线程的异常不影响子线程的事务提交,子线程的异常不影响主线程的事务提交。