开发札记:如何在业务中添加日志系统

提到日志,我们一般只会想到使用@Slf4j打印的日志,实际上我们系统中的日志还可以细分为系统日志、登录日志和操作日志。本文将讲述如何在SpringBoot中使用和开发这三种日志系统。

关键词:Logback、Slf4j、p6spy、AspectJ、SpringEvent、线程池、Async

这里我根据业务将日志简单分为三类:

  1. 系统日志:打印到控制台和log文件中。
  2. 登录日志:记录用户登录信息。
  3. 操作日志:记录后台操作过程。

系统日志

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:格式化
  • 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
  • 现在的模式:代理多数据源

配置文件解释

  • 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事件监听

发布事件

  1. 注入ApplicationContext
  2. 在domain内新建一个Event事件类
  3. 实例化事件类,调用context.publishEvent(event)发布。

当然,也可以用封装的SpringUtils来发布事件。

创建监听器

  1. 新建EventListener监听器类,注入容器。
  2. 新增方法,参数接收事件类,并加上@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等周期,可以根据不同需求来设计。
  • 观察到实验结果符合预期,监听器的方法没有执行。

登录日志功能

登录日志功能: 记录了每个用户的访问编号、用户名、登录地址、登录地点、浏览器、操作系统、登录状态、操作信息和登录日期等。

具体实践

  1. 创建发布登录事件方法 ,传入username、status、msg、request。
    1. 传入request是因为监听器是异步的,无法获取当前request。
  2. 在checkLogin、validated、login、logout等调用发布事件方法,记录登录成功、登录失败等事件的日志。
  3. 在服务层添加登录事件监听器,使用@Async注解保证异步调用。具体实现如下:
    1. 从request拿到UserAgent、IP、address等信息。
    2. 用StringBuilder拼接,输出到日志。
    3. 创建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,有七个参数的构造方法,分别是

  • 核心线程数
  • 最大线程数
  • 空闲时间
  • 时间单位
  • 队列
  • 线程工厂
  • 拒绝策略

线程池执行流程

  1. 发布任务,提交到线程池。
  2. 判断核心线程数是否已满,如果不是,创建核心线程池执行任务。
  3. 如果是,判断线程池队列数是否已满,如果不是,就将任务放到队列等待处理。
  4. 如果是,判断最大线程数是否已满,如果不是,创建非核心线程处理任务。如果是,按照拒绝策略执行任务(如拒绝任务、丢弃任务、将任务返回给主线程执行)
  5. 如果非核心线程处理任务处理完成,且空闲时间达到设置的时间,非核心线程就会进行销毁。

线程的状态

  • 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

异常

  • 主线程抛出异常不会影响子线程的方法执行。
  • 子线程抛出异常不会影响主线程的方法执行。

事务: 在不同线程中,事务管理是完全独立的。主线程的异常不影响子线程的事务提交,子线程的异常不影响主线程的事务提交。

参考文章

  1. 使用AspectJ进行面向切面编程(AOP) - 掘金 (juejin.cn)
相关推荐
嘻嘻嘻嘻嘻嘻ys7 分钟前
《Vue 3响应式革命:Proxy机制深度解析与性能优化实战》
前端·后端
工业互联网专业14 分钟前
基于springboot+vue的悠扬乐器管理系统
java·vue.js·spring boot·毕业设计·源码·课程设计
努力的搬砖人.19 分钟前
在springboot项目中,如何进行excel表格的导入导出功能?
spring boot·后端·excel
凭君语未可23 分钟前
VO包装类和实体类分别是什么?区别是什么?
后端
李菠菜24 分钟前
Spring Boot 集成 Jasypt 实现敏感信息加密保护
java·spring boot·后端
十分钟空间24 分钟前
如何调整Kestrel的端口
后端
写bug写bug24 分钟前
Spring事务失效的9大场景,你踩过几个?
java·后端·spring
whltaoin24 分钟前
基于Spring AI Alibaba + Spring Boot + Ollama搭建本地AI对话机器人API
spring boot·ollama
昕冉27 分钟前
node.js+MongoDB数据库实现学生数据管理系统
前端·后端
木昜先生33 分钟前
知识点:深入剖析 Java 并发工具类
java·后端