文章目录
- 一、前言
- 二、源码解析
- Appender
- UnsynchronizedAppenderBase
- OutputStreamAppender
- ConsoleAppender
- FileAppender
- RollingFileAppender
- FileNamePattern
- 三、总结
一、前言
前一篇文章介绍了appender、conversionRule、root和logger节点的解析, 为的是为本篇详细介绍它们的原理做铺垫, 日志打印也是主要围绕这几个对象开展的
二、源码解析
Appender
在 Logback 框架中,Appender 是用来将日志事件输出到目标(如文件、控制台、数据库等)的组件。而 UnsynchronizedAppenderBase 和 AppenderBase 是两种核心的抽象类,提供了实现日志输出的基础功能。
下面是UnsynchronizedAppenderBase 和 AppenderBase 的对比; 我们常用的ConsoleAppender和RollingFileAppender都是UnsynchronizedAppenderBase 的子类
特性 | UnsynchronizedAppenderBase | AppenderBase |
---|---|---|
线程安全性 | 不线程安全 | 线程安全 |
同步机制 | 无需同步(开发者需手动处理) | 内置同步机制 |
性能 | 性能更高,因为没有同步开销 | 性能稍低,因为引入了同步逻辑 |
适用场景 | 单线程或已外部同步的高性能需求场景 | 多线程环境下无需手动同步的场景 |
appender接口定义
实现了FilterAttachable
接口哦
java">public interface Appender<E> extends LifeCycle, ContextAware, FilterAttachable<E> {/*** 设置名称*/void setName(String name);/*** 获取appender的名称*/String getName();/*** 添加日志*/void doAppend(E event) throws LogbackException;
}public interface FilterAttachable<E> {/*** 添加过滤器*/void addFilter(Filter<E> newFilter);/** 清空过滤器*/void clearAllFilters();/** 获取复制所有的过滤器*/List<Filter<E>> getCopyOfAttachedFiltersList();/** 循环遍历链中的过滤器。一旦过滤器决定ACCEPT或DENY,则返回该值。如果所有过滤器都返回NEUTRAL,则返回NEUTRAL。*/FilterReply getFilterChainDecision(E event);
}
接口比较简单, 核心方法就是这个doAppend, 用于添加我们的日志。
appender继承了FilterAttachable
接口, 添加了对过滤器的支持, 允许我们根据过滤器判断是否需要打印日志
这里可以看出appender是支持filter标签的(因为addFilter方法)
UnsynchronizedAppenderBase
非同步Appender
java">abstract public class UnsynchronizedAppenderBase<E> extends ContextAwareBase implements Appender<E> {/*** 用来阻止当前线程递归调用doAppend方法*/private ThreadLocal<Boolean> guard = new ThreadLocal<Boolean>();/*** 它是FilterAttachable接口的实现类, 使用静态代理模式*/private FilterAttachableImpl<E> fai = new FilterAttachableImpl<E>();/** 模板方法 */public void doAppend(E eventObject) {// 阻止当前线程递归调用doAppend方法if (Boolean.TRUE.equals(guard.get())) {return;}try {// 设置当前线程已经进来的标识guard.set(Boolean.TRUE);// appender还未启动if (!this.started) {// 还未到允许重试此时, 输出警告信息if (statusRepeatCount++ < ALLOWED_REPEATS) {addStatus(new WarnStatus("Attempted to append to non started appender [" + name + "].", this));}return;}// 过滤器处理, 如果返回DENY则不处理if (getFilterChainDecision(eventObject) == FilterReply.DENY) {return;}// 子类的append方法, 去添加日志吧this.append(eventObject);} catch (Exception e) {// 异常次数不达最大重试次数, 输出异常信息if (exceptionCount++ < ALLOWED_REPEATS) {addError("Appender [" + name + "] failed to append.", e);}} finally {// 释放当前线程的标识guard.set(Boolean.FALSE);}}/** 需子类实现 */abstract protected void append(E eventObject);/** 下面这四个方法都是静态代理的体现 */public void addFilter(Filter<E> newFilter) {// 添加过滤器到代理对象FilterAttachableImpl中fai.addFilter(newFilter);}public void clearAllFilters() {fai.clearAllFilters();}public List<Filter<E>> getCopyOfAttachedFiltersList() {return fai.getCopyOfAttachedFiltersList();}public FilterReply getFilterChainDecision(E event) {return fai.getFilterChainDecision(event);}
}
方法小结
- 使用静态代理对象FilterAttachableImpl实现对FilterAttachable接口的实现, UnsynchronizedAppenderBase中实现的FilterAttachable接口中的方法都是由静态代理对象FilterAttachableImpl处理
- 模板方法doAppend用来处理公共逻辑
- 一个线程一次只能打印一条日志, 为了避免子类appender中递归doAppend方法, 所以这里使用ThreadLocal做一个拦截校验
- 使用过滤器先对日志事件做一次拦截, 如果拦截器返回了
FilterReply.DENY
, 该日志将会被丢弃 - 最后调用appender方法交给子类实现具体的日志打印逻辑
这里读者可以去了解下静态代理、动态代理、正向代理、方向代理的区别, 以及门面模式、包装模式和静态代理的区别; 看到源码里面出现奇怪的设计, 请不要慌, 肯定是有章法的, 要有一颗好奇的心。
我们看一下FilterAttachableImpl类的getFilterChainDecision方法
FilterAttachableImpl
java">public FilterReply getFilterChainDecision(E event) {final Filter<E>[] filterArrray = filterList.asTypedArray();final int len = filterArrray.length;// 遍历过滤器for (int i = 0; i < len; i++) {// 过滤器处理之后返回FilterReplyfinal FilterReply r = filterArrray[i].decide(event);// 只要是返回DENY或者ACCEPT类型, 就直接返回if (r == FilterReply.DENY || r == FilterReply.ACCEPT) {return r;}}// no decisionreturn FilterReply.NEUTRAL;
}
这里看到过滤器只要返回FilterReply.DENY
和FilterReply.ACCEPT
就直接返回了, FilterReply.DENY
代表了肯定拒绝, FilterReply.ACCEPT
代表了肯定通过, 只有FilterReply.NEUTRAL
属于模糊状态, 需要继续走下去。就像你去追一个人, 对方要是说yes那就成了, 对方说no那就拜拜, 对方要是说or, 那就完犊子了, 要打持久战了。
OutputStreamAppender
OutputStreamAppender 是一个基础组件,它负责将日志事件写入输出流,并提供了一些通用功能,比如流管理和布局支持。由于它的设计简单且功能集中,其他复杂的 Appender(如文件和滚动日志输出)都可以基于它构建
java">public class OutputStreamAppender<E> extends UnsynchronizedAppenderBase<E> {/*** encoder最终负责将事件写入OutputStream。*/// getter/setterprotected Encoder<E> encoder;/** 数据写入的目的地 */private OutputStream outputStream;/** 是否立即刷新数据 */boolean immediateFlush = true;public void start() {int errors = 0;// ... encoder 和 outputStream不能为空if (this.encoder == null) {addStatus(new ErrorStatus("No encoder set for the appender named \"" + name + "\".", this));errors++;}// ... 省略部分代码// only error free appenders should be activatedif (errors == 0) {// 标记为启动super.start();// 添加初始化数据, 每次loggerContext启动的时候(一般也是项目启动), 可以记录日志encoderInit();}}void encoderInit() {if (encoder != null && this.outputStream != null) {try {byte[] header = encoder.headerBytes();writeBytes(header);} catch (IOException ioe) {this.started = false;// ... }}}/** 核心方法, 实现父类的抽象方法 */protected void subAppend(E event) {// 未启动不处理if (!isStarted()) {return;}try {// loggingEvent默认是DeferredProcessingAwareif (event instanceof DeferredProcessingAware) {// 1.预处理消息; 包括填充消息中的占位符, 将mdc数据初始化到loggingEvent中((DeferredProcessingAware) event).prepareForDeferredProcessing();}// 2.写出数据writeOut(event);} catch (IOException ioe) {// 标记为启动失败this.started = false;addStatus(new ErrorStatus("IO failure in appender", this, ioe));}}protected void writeOut(E event) throws IOException {// 编码数据byte[] byteArray = this.encoder.encode(event);// 写出数据writeBytes(byteArray);}private void writeBytes(byte[] byteArray) throws IOException {if (byteArray == null || byteArray.length == 0)return;// 这里是加非公平锁streamWriteLock.lock();try {// 得启动成功后才能写数据if (isStarted()) {// 写出数据writeByteArrayToOutputStreamWithPossibleFlush(byteArray);// 更新写出数据量updateByteCount(byteArray);}} finally {streamWriteLock.unlock();}}protected final void writeByteArrayToOutputStreamWithPossibleFlush(byte[] byteArray) throws IOException {// 写出数据this.outputStream.write(byteArray);// 如果立即刷新(默认是true)if (immediateFlush) {// 数据刷出到目的地this.outputStream.flush();}}/** 设置layout */public void setLayout(Layout<E> layout) {addWarn("This appender no longer admits a layout as a sub-component, set an encoder instead.");addWarn("To ensure compatibility, wrapping your layout in LayoutWrappingEncoder.");addWarn("See also " + CODES_URL + "#layoutInsteadOfEncoder for details");// layout默认使用LayoutWrappingEncoder覆盖已有的encoderLayoutWrappingEncoder<E> lwe = new LayoutWrappingEncoder<E>();lwe.setLayout(layout);lwe.setContext(context);this.encoder = lwe;}
}
由于OutputStreamAppender类比较简单, 这里就不一个个方法详细看了
方法小结
- 如果配置了OutputStreamAppender类型的appender, 在logback框架启动的时候, 可以设置encoder的headerBytes参数, 打印在运行的开始
- OutputStreamAppender类实现了UnsynchronizedAppenderBase的subAppend方法
- 先处理日志信息, 例如填充日志的占位符(例如: log.info(“你好{}”, “uncelqiao”)), 这里会转换成"你好uncelqiao", 还会把mdc上下文放到日志事件LoggingEvent中
- 写出数据到outputStream中, 这里有一个immediateFlush的boolean字段, 用于控制是否立刻将内容刷出, 默认是true
- 更新写入的字节数, 可以用来记录总写入数,然后切割文件
- setLayout方法覆盖了encoder属性, 也就是说你先设置了encoder再设置layout标签的话, 前一个encoder会失效, 一般我们不这么用
这里可以看出OutputStreamAppender类型的appender是支持添加encoder
、OutputStream
、immediateFlush
、layout
标签的(因为对应的setter方法)
ConsoleAppender
用来将日志打到控制台的
java">public class ConsoleAppender<E> extends OutputStreamAppender<E> {/*** 日志除数目的地; 默认是System.out*/protected ConsoleTarget target = ConsoleTarget.SystemOut;/*** 是否使用jansi框架打印日志*/protected boolean withJansi = false;@Overridepublic void start() {// 这里提醒我们打印到控制台的速度是很慢的, 应该避免在生产环境开启打印到控制台addInfo("BEWARE: Writing to the console can be very slow. Avoid logging to the ");addInfo("console in production environments, especially in high volume systems.");addInfo("See also " + CONSOLE_APPENDER_WARNING_URL);OutputStream targetStream = target.getStream();// 开启了jansi日志打印, 就使用withJansi的OutputStreamif (withJansi) {targetStream = wrapWithJansi(targetStream);}// 设置日志打印目的地setOutputStream(targetStream);super.start();}/** 设置打印目的地, 这里可以选择System.out或者System.err */public void setTarget(String value) {ConsoleTarget t = ConsoleTarget.findByName(value.trim());if (t == null) {targetWarn(value);} else {target = t;}}
}
方法小结
- ConsoleAppender类主要是用来提供日志打印的位置OutputStream, 默认是OutputStream, 使用System.out.write写出数据, System.out.flush()刷新数据
- 可以在logback.xml的ConsoleAppender定义子标签target来设置使用
System.out
还是System.err
- 可以使用withJansi标签开启使用JANSI框架打印日志, 需要引入
org.fusesource.jansi:jansi:{version}
包, 可以在这里看版本信息 https://mvnrepository.com/artifact/org.fusesource.jansi/jansi
ConsoleAppender给appender标签提供了target
子节点来设置日志输出流
综上理解, 我们可以把ConsoleAppender配置成这样
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"><!-- ThresholdFilter用于控制当前appender允许打印的日志级别 --><filter class="ch.qos.logback.classic.filter.ThresholdFilter"><level>INFO</level></filter><encoder><pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %X{mdcKey} %-5level %logger{36} - %msg%n</pattern></encoder><immediateFlush>true</immediateFlush><target>System.out</target><withJansi>false</withJansi>
</appender>
关于其中的layout和encoder的配置, appender中encoder和layout有先后顺序, 后面的覆盖前面的encoder, 这里推荐下面的第2种配置
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"><!-- 1.不指定时class, 默认是PatternLayoutEncoder, 它使用默认的PatternLayout类 --><encoder><pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern></encoder><!-- 2.也可以使用可以指定layout的encoder; 推荐使用这种 --><encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"><layout class="ch.qos.logback.classic.PatternLayout"><pattern>%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}) %clr([%X{traceId}]){magenta} %clr([%thread]){blue} %clr(%-5level) %clr(%logger{50}){cyan} %clr(%file:%line){cyan} - %msg%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}</pattern></layout></encoder><!-- 3.单独用layout的话, 默认使用的encoder就是LayoutWrappingEncoder --><layout class="ch.qos.logback.classic.PatternLayout"><pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern></layout>
</appender>
FileAppender
fileAppender也是OutputStreamAppender的直接子类, 用来将日志输入到文件
java">public class FileAppender<E> extends OutputStreamAppender<E> {/** 缓存流可缓存大小 */public static final long DEFAULT_BUFFER_SIZE = 8192;/** 日志文件内容追加到末尾 */protected boolean append = true;/*** 日志文件名*/protected String fileName = null;/** 是否启用严格模式 */private boolean prudent = false;public void start() {int errors = 0;// 文件名, 获取的是fileName字段if (getFile() != null) {addInfo("File property is set to [" + fileName + "]");// 1.严格模式下必须设置为追加模式if (prudent) {if (!isAppend()) {setAppend(true);addWarn("Setting \"Append\" property to true on account of \"Prudent\" mode");}}// 2.判断当前文件是否在当前日志上下文中已经存在过if (checkForFileCollisionInPreviousFileAppenders()) {addError("Collisions detected with FileAppender/RollingAppender instances defined earlier. Aborting.");addError(MORE_INFO_PREFIX + COLLISION_WITH_EARLIER_APPENDER_URL);errors++;} else {// file should be opened only if collision freetry {// 3.打开文件, 设置outputStreamopenFile(getFile());} catch (java.io.IOException e) {errors++;addError("openFile(" + fileName + "," + append + ") call failed.", e);}}} else {errors++;addError("\"File\" property not set for appender named [" + name + "].");}if (errors == 0) {super.start();}}public void openFile(String file_name) throws IOException {// 非公平锁streamWriteLock.lock();try {File file = new File(file_name);// 判断文件目录是否存在boolean result = FileUtil.createMissingParentDirectories(file);if (!result) {addError("Failed to create parent directories for [" + file.getAbsolutePath() + "]");}// 得到文件的outputStreamResilientFileOutputStream resilientFos = new ResilientFileOutputStream(file, append, bufferSize.getSize());resilientFos.setContext(context);// 设置OutputStreamAppender中的outputStream属性值setOutputStream(resilientFos);} finally {streamWriteLock.unlock();}}
}
这个start方法有点东西
- 严格模式下, 必定开启文件追加模式(append=true)
- 当前日志框架启动过程中如果已经配置了当前日志文件在LoggerContext上下文中, 那么当前的appender将会失效(目前不知道什么场景下会有这种情况)
- fileName属性是全路径, 使用的是ResilientFileOutputStream包装了一下FileOutputStream类作为日志输出流
下面看看写入数据
java">@Override
protected void writeOut(E event) throws IOException {// 严格模式if (prudent) {// 安全写入safeWriteOut(event);} else {// 直接使用OutputStreamAppender的writeOut写出数据super.writeOut(event);}
}private void safeWriteOut(E event) {byte[] byteArray = this.encoder.encode(event);if (byteArray == null || byteArray.length == 0)return;streamWriteLock.lock();try {safeWriteBytes(byteArray);} finally {streamWriteLock.unlock();}
}private void safeWriteBytes(byte[] byteArray) {ResilientFileOutputStream resilientFOS = (ResilientFileOutputStream) getOutputStream();FileChannel fileChannel = resilientFOS.getChannel();if (fileChannel == null) {return;}// 1.清除当前线程的中断状态,并获取中断状态, 因为fileChannel.lock()有对当前线程是否中断的判断boolean interrupted = Thread.interrupted();FileLock fileLock = null;try {// 2.加个文件锁, 不是juc的类哦fileLock = fileChannel.lock();long position = fileChannel.position();long size = fileChannel.size();if (size != position) {// 3.移动写入点fileChannel.position(size);}// 4.调用OutputStreamAppender的writeByteArrayToOutputStreamWithPossibleFlush方法写出数据writeByteArrayToOutputStreamWithPossibleFlush(byteArray);} catch (IOException e) {// Mainly to catch FileLockInterruptionExceptions (see LOGBACK-875)resilientFOS.postIOFailure(e);} finally {releaseFileLock(fileLock);// 5.设置为线程本来的状态if (interrupted) {Thread.currentThread().interrupt();}}
}
方法小结
- 严格模式下, 会清除当前线程的中断状态
- 写数据之前加个文件锁, 当前文件只能我访问哦(降级效率的做法)
- 移动写入点到文件末尾
- 写入数据
- 还原线程原本的状态(这一趟清除中断状态 再到恢复状态 会不会有aba的问题呢??)
- 非严格模式下直接使用OutputStreamAppender的writeOut写出数据
要说明一点, 这里实际写入数据使用的是FileOutputStream, 但是移动指针是使用的是fileChannel, 其实底层FileOutputStream 和 FileChannel 使用同一个文件指针
做个对比
特性 | FileOutputStream | FileChannel |
---|---|---|
操作灵活性 | 简单易用,只支持顺序写入 | 支持随机访问,可移动文件指针、切片等操作 |
同步机制 | 不支持直接同步多个线程操作 | 支持多线程同步,线程安全 |
写入方式 | 只能直接写字节数据 | 支持直接写入和缓冲区操作 |
性能 | 相对较低 | 性能更高,特别是在处理大文件时 |
对FileAppender做一个小结
- FileAppender也是OutputStreamAppender的子类, 使用outputStream将日志写出到文件, 使用的是
BufferedOutputStream
- 使用appender属性设置日志文件以追加的形式记录
- 使用file属性设置日志记录的位置, file属性是文件的全路径
- 使用prudent=true/false 来开关严格模式, 建议用false或者直接不设置, 蛮耗性能的, 还要独占文件
RollingFileAppender
主要用于将日志信息写入文件,并支持 文件滚动(Rolling),以避免日志文件过大或超出存储限制。
java">public class RollingFileAppender<E> extends FileAppender<E> {/*** 当前激活的文件*/File currentlyActiveFile;/*** 触发策略*/TriggeringPolicy<E> triggeringPolicy;/*** 滚动策略*/RollingPolicy rollingPolicy;public void start() {// ...}@Overridepublic String getFile() {// file属性可以为空, 为空时默认是以fileNamePattern的格式生成文件return rollingPolicy.getActiveFileName();}public void rollover() {// ...}protected void subAppend(E event) {// ... }protected void updateByteCount(byte[] byteArray) {// ...}
}
RollingFileAppender的核心就是这几个属性和方法
- currentlyActiveFile: 当前记录日志的文件
- triggeringPolicy 触发滚动的策略
- rollingPolicy 滚动策略
- start方法: 校验并初始化一些核心内容
- getFile方法: 获取当前使用的日志文件
- rollover方法: 滚动文件
- subAppend: 添加日志
- updateByteCount: 更新写入的数量
start方法
java">public void start() {// 需要先设置触发策略if (triggeringPolicy == null) {addWarn("No TriggeringPolicy was set for the RollingFileAppender named " + getName());addWarn(MORE_INFO_PREFIX + RFA_NO_TP_URL);return;}// 需要先启动触发策略,if (!triggeringPolicy.isStarted()) {addWarn("TriggeringPolicy has not started. RollingFileAppender will not start");return;}// 判断是否有同一个格式的文件名, 不允许if (checkForCollisionsInPreviousRollingFileAppenders()) {addError("Collisions detected with FileAppender/RollingAppender instances defined earlier. Aborting.");addError(MORE_INFO_PREFIX + COLLISION_WITH_EARLIER_APPENDER_URL);return;}// we don't want to void existing log files// 默认是appendif (!append) {addWarn("Append mode is mandatory for RollingFileAppender. Defaulting to append=true.");append = true;}// 必须有滚动策略if (rollingPolicy == null) {addError("No RollingPolicy was set for the RollingFileAppender named " + getName());addError(MORE_INFO_PREFIX + RFA_NO_RP_URL);return;}// sanity check for http://jira.qos.ch/browse/LOGBACK-796// 校验文件名是否满足配置的文件格式, <file>标签和<fileNamePattern>标签格式不能相同if (checkForFileAndPatternCollisions()) {addError("File property collides with fileNamePattern. Aborting.");addError(MORE_INFO_PREFIX + COLLISION_URL);return;}// 严格模式if (isPrudent()) {if (rawFileProperty() != null) {addWarn("Setting \"File\" property to null on account of prudent mode");setFile(null);}if (rollingPolicy.getCompressionMode() != CompressionMode.NONE) {addError("Compression is not supported in prudent mode. Aborting");return;}}addInfo("Active log file name: " + getFile());currentlyActiveFile = new File(getFile());// 初始化-记录已有文件的长度initializeLengthCounter();// 父类启动, 设置outputStreamsuper.start();
}
方法小结
-
触发策略不能为空, 并且要先启动, 它在ImplicitModelHandler中会默认先启动
-
默认是文件追加模式
-
滚动策略不能为空
-
file标签和fileNamePattern标签格式不能相同, 例如下面的是不允许的
java"><file>${log.path}/2025-02/roller_test.2025-02-09.log.gz</file><fileNamePattern>${log.path}/%d{yyyy-MM, aux, Asia/Shanghai}/roller_test.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
-
file标签是允许为空的, 但是fileNamePattern标签不能为空
-
使用rollingPolicy获取当前激活的文件(当前写入日志的文件)
-
初始化文件大小记录器
RollingPolicy
滚动策略, 这里以TimeBasedRollingPolicy
为例介绍
java">public class TimeBasedRollingPolicy<E> extends RollingPolicyBase implements TriggeringPolicy<E> {/** 无压缩后缀的文件命名模式 */FileNamePattern fileNamePatternWithoutCompSuffix;/*** 压缩器*/private Compressor compressor;/*** 文件从命名工具类*/private RenameUtil renameUtil = new RenameUtil();/*** 存档删除器*/private ArchiveRemover archiveRemover;/*** 触发策略; 默认是DefaultTimeBasedFileNamingAndTriggeringPolicy; 使用静态代理*/TimeBasedFileNamingAndTriggeringPolicy<E> timeBasedFileNamingAndTriggeringPolicy;
}
TimeBasedRollingPolicy继承了RollingPolicyBase抽象类, 说明它是一个滚动策略, 实现了TriggeringPolicy, 也说明了它是一个触发策略。
从几个属性可以看得出来
- compressor: 它支持压缩日志文件
- archiveRemover: 支持删除存档过期的文件
- timeBasedFileNamingAndTriggeringPolicy: 静态代理触发策略
start方法
java"> public void start() {// set the LR for our utility objectrenameUtil.setContext(this.context);// 从文件名模式中找出周期; 由fileNamePattern标签配置的文件表达式if (fileNamePatternStr != null) {fileNamePattern = new FileNamePattern(fileNamePatternStr, this.context);// 根据后缀判断压缩模式, 并设置determineCompressionMode();} else {addWarn(FNP_NOT_SET);addWarn(CoreConstants.SEE_FNP_NOT_SET);throw new IllegalStateException(FNP_NOT_SET + CoreConstants.SEE_FNP_NOT_SET);}// 文件压缩对象compressor = new Compressor(compressionMode);compressor.setContext(context);// wcs : without compression suffix// 无压缩后缀的文件命名器fileNamePatternWithoutCompSuffix = new FileNamePattern(Compressor.computeFileNameStrWithoutCompSuffix(fileNamePatternStr, compressionMode), this.context);addInfo("Will use the pattern " + fileNamePatternWithoutCompSuffix + " for the active file");// 压缩模式if (compressionMode == CompressionMode.ZIP) {// 获取文件名String zipEntryFileNamePatternStr = transformFileNamePattern2ZipEntry(fileNamePatternStr);zipEntryFileNamePattern = new FileNamePattern(zipEntryFileNamePatternStr, context);}// 默认使用DefaultTimeBasedFileNamingAndTriggeringPolicyif (timeBasedFileNamingAndTriggeringPolicy == null) {timeBasedFileNamingAndTriggeringPolicy = new DefaultTimeBasedFileNamingAndTriggeringPolicy<>();}timeBasedFileNamingAndTriggeringPolicy.setContext(context);// 触发策略设置滚动策略timeBasedFileNamingAndTriggeringPolicy.setTimeBasedRollingPolicy(this);// 启动触发策略timeBasedFileNamingAndTriggeringPolicy.start();if (!timeBasedFileNamingAndTriggeringPolicy.isStarted()) {addWarn("Subcomponent did not start. TimeBasedRollingPolicy will not start.");return;}// the maxHistory property is given to TimeBasedRollingPolicy instead of to// the TimeBasedFileNamingAndTriggeringPolicy. This makes it more convenient// for the user at the cost of inconsistency here.// 最大保存历史记录if (maxHistory != UNBOUNDED_HISTORY) {archiveRemover = timeBasedFileNamingAndTriggeringPolicy.getArchiveRemover();// 保留的文件个数archiveRemover.setMaxHistory(maxHistory);// 保留的文件大小最大阈值archiveRemover.setTotalSizeCap(totalSizeCap.getSize());// 启动时删除过期文件if (cleanHistoryOnStart) {addInfo("Cleaning on start up");Instant now = Instant.ofEpochMilli(timeBasedFileNamingAndTriggeringPolicy.getCurrentTime());cleanUpFuture = archiveRemover.cleanAsynchronously(now);}} else if (!isUnboundedTotalSizeCap()) {addWarn("'maxHistory' is not set, ignoring 'totalSizeCap' option with value [" + totalSizeCap + "]");}super.start();
}
方法小结
- fileNamePattern标签必须配置, 并且fileNamePattern对象是通过fileNamePattern标签的值构建的
- 压缩模式是根据fileNamePattern标签的后缀决定的, 支持.zip和.gz格式压缩(没有压缩后缀也行, 那就不进行压缩)
- 默认使用
DefaultTimeBasedFileNamingAndTriggeringPolicy
作为滚动触发策略 - 可以通过maxHistory标签配置最多保存多少天的存档文件, 和保存的文件大小
触发滚动
添加日志时会先判断是否需要先滚动文件
java">public boolean isTriggeringEvent(File activeFile, final E event) {return timeBasedFileNamingAndTriggeringPolicy.isTriggeringEvent(activeFile, event);
}
这里默认借助的是DefaultTimeBasedFileNamingAndTriggeringPolicy
对象来判断是否需要滚动
DefaultTimeBasedFileNamingAndTriggeringPolicy
被@NoAutoStart
注解标识的LifeCycle对象不会在ImplicitModelHandler
中注入时自动启动
java">@NoAutoStart
public class DefaultTimeBasedFileNamingAndTriggeringPolicy<E> extends TimeBasedFileNamingAndTriggeringPolicyBase<E> {public void start() {// 启动父类super.start();// 异常退出if (!super.isErrorFree()) {return;}// 默认按照时间混动的日志不支持%i的拆分if (tbrp.fileNamePattern.hasIntegerTokenCOnverter()) {addError("Filename pattern [" + tbrp.fileNamePattern+ "] contains an integer token converter, i.e. %i, INCOMPATIBLE with this configuration. Please remove it.");return;}// 实例化存档删除器archiveRemover = new TimeBasedArchiveRemover(tbrp.fileNamePattern, rc);archiveRemover.setContext(context);started = true;}public boolean isTriggeringEvent(File activeFile, final E event) {// 当前时间, 也可以由用户设置long currentTime = getCurrentTime();// 下一次检查的时间long localNextCheck = atomicNextCheck.get();// 当前已经到了检查时间if (currentTime >= localNextCheck) {// 根据当前时间和滚动单位计算下一次的检查时间, 滚动单位根据fileNamePattern标签配置的时间计算long nextCheck = computeNextCheck(currentTime);atomicNextCheck.set(nextCheck);// 当前文件的时间Instant instantOfElapsedPeriod = dateInCurrentPeriod;addInfo("Elapsed period: " + instantOfElapsedPeriod.toString());// 滚动时, 将当前时间转换成存档文件名,// 例如当前是2025-02-11, 配置的fileNamePattern为/Users/xxx/%d{yyyy-MM, aux, Asia/Shanghai}/roller_test.%d{yyyy-MM-dd}.log// 那么这里就是/Users/xxx/2025-02/roller_test.2025-02-11.log// 那么这个文件名就会保存当天所有的日志, 然后新建一个日志作为当前活动的日志文件this.elapsedPeriodsFileName = tbrp.fileNamePatternWithoutCompSuffix.convert(instantOfElapsedPeriod);// 设置下一次滚动的基础时间; 也就是接下来记录日志的时间setDateInCurrentPeriod(currentTime);return true;} else {return false;}}
}
DefaultTimeBasedFileNamingAndTriggeringPolicy
继承自TimeBasedFileNamingAndTriggeringPolicyBase
, 覆写了start方法, 并实现了isTriggeringEvent
方法
start方法
- 启动父类
TimeBasedFileNamingAndTriggeringPolicyBase
- 不允许
fileNamePattern
标签有%i
标识,%i
是给按照文件大小策略滚动用的 - 实例化存档删除器
isTriggeringEvent
这个方法用来判断是否需要触发文件滚动的
- 判断当前时间是否到了触发滚动的时间; 默认当前时间是系统的当前时间, 也可以使用标签配置; 默认滚动时间是程序启动时根据滚动周期计算的下一个周期时间
- 如果当前需要滚动, 那么计算并设置下一次滚动的时间(根据当前时间和滚动周期计算)
- 计算当前记录日志的文件滚动时归档的文件名
这里我们再来看一下父类的start方法
TimeBasedFileNamingAndTriggeringPolicyBase#start
java">public void start() {// 日期转换器(primary标识的, 也就是日志格式上没有AUX标识的)DateTokenConverter<Object> dtc = tbrp.fileNamePattern.getPrimaryDateTokenConverter();if (dtc == null) {throw new IllegalStateException("FileNamePattern [" + tbrp.fileNamePattern.getPattern() + "] does not contain a valid DateToken");}// 日期转换器; %d{yyyy-MM, Asia/Shanghai}, 这种没有有aux的primary是true,时区就是Asia/Shanghaiif (dtc.getZoneId() != null) {TimeZone tz = TimeZone.getTimeZone(dtc.getZoneId());rc = new RollingCalendar(dtc.getDatePattern(), tz, Locale.getDefault());} else {// dtc.getDatePattern(): primary的日期格式, 如: %d{yyyy-MM} 中的yyyy-MM// 并且会设置滚动周期类型rc = new RollingCalendar(dtc.getDatePattern());}addInfo("The date pattern is '" + dtc.getDatePattern() + "' from file name pattern '"+ tbrp.fileNamePattern.getPattern() + "'.");// 打印滚动周期rc.printPeriodicity(this);// 判断滚动周期是否正确if (!rc.isCollisionFree()) {addError("The date format in FileNamePattern will result in collisions in the names of archived log files.");addError(CoreConstants.MORE_INFO_PREFIX + COLLIDING_DATE_FORMAT_URL);withErrors();return;}long timestamp = getCurrentTime();// 设置当前活动的日志时间, 也就是日志记录的一个周期时间,会根据这个时间判断是否需要滚动, 以及滚动后的文件名setDateInCurrentPeriod(timestamp);// file标签; appender的fileif (tbrp.getParentsRawFileProperty() != null) {File currentFile = new File(tbrp.getParentsRawFileProperty());// <file>标签配置的文件存在才会设置自定义的时间为当前周期的时间if (currentFile.exists() && currentFile.canRead()) {timestamp = currentFile.lastModified();// 文件的修改时间作为周期计算的起点setDateInCurrentPeriod(timestamp);}}addInfo("Setting initial period to " + dateInCurrentPeriod);// 根据滚动周期计算并设置下一个检查时间long nextCheck = computeNextCheck(timestamp);atomicNextCheck.set(nextCheck);
}
方法小结
- 根据
fileNamePattern
标签配置的滚动周期实例化时间滚动器RollingCalendar - 设置当前时间为当前的日志周期
- 如果当前记录日志的文件存在, 那么当文件的最后修改时间作为当前的日志周期
- 根据日志周期时间计算下一个周期时间
滚动rollover
java">public void rollover() throws RolloverFailure {// 当前日志周期归档文件名String elapsedPeriodsFileName = timeBasedFileNamingAndTriggeringPolicy.getElapsedPeriodsFileName();// 最后一个斜杆之后的内容, 也就是有后置的文件名String elapsedPeriodStem = FileFilterUtil.afterLastSlash(elapsedPeriodsFileName);// fileNamePattern标签不是.zip或者.gz后缀if (compressionMode == CompressionMode.NONE) {// file属性不为空if (getParentsRawFileProperty() != null) {// 当前文件重命名为归档文件的名称renameUtil.rename(getParentsRawFileProperty(), elapsedPeriodsFileName);}} else {// file属性为空if (getParentsRawFileProperty() == null) {compressionFuture = compressor.asyncCompress(elapsedPeriodsFileName, elapsedPeriodsFileName,elapsedPeriodStem);} else {// 重命名并压缩compressionFuture = renameRawAndAsyncCompress(elapsedPeriodsFileName, elapsedPeriodStem);}}// 清空归档文件if (archiveRemover != null) {Instant now = Instant.ofEpochMilli(timeBasedFileNamingAndTriggeringPolicy.getCurrentTime());this.cleanUpFuture = archiveRemover.cleanAsynchronously(now);}
}
方法小结
- fileNamePattern标签不是.zip或者.gz后缀, 并且存在file标签内容, 那么将当前日志文件重命名为归档文件的名称
- 如果fileNamePattern有压缩后缀, 并且file标签内容存在, 那么直接压缩
- 如果fileNamePattern有压缩后缀, 并且file标签内容不存在, 会借助临时文件来压缩
- 删除归档的过期文件
FileNamePattern
FileNamePattern作为<fileNamePattern>
标签的解析类, 它可以将一个字符串按照不同的部分生成一个转换链Converter
, 然后可以通过这个转换链根据提供的参数替换占位符生成正确的字符串内容。下面大致看一下它的内容。
java">public class FileNamePattern extends ContextAwareBase {/*** 允许转换的字符串与其对应的转换器*/static final Map<String, Supplier<DynamicConverter>> CONVERTER_MAP = new HashMap<>();static {// 对i的转换;数字转字符串, 然后补齐最小长度的转换器CONVERTER_MAP.put(IntegerTokenConverter.CONVERTER_KEY, IntegerTokenConverter::new);// 对d的转换;日期转字符串, 然后补齐最小长度的转换器CONVERTER_MAP.put(DateTokenConverter.CONVERTER_KEY, DateTokenConverter::new);}/*** 需要转换成转换链的模板字符串*/String pattern;/*** 根据pattern生成的转换链的头部节点*/Converter<Object> headTokenConverter;public FileNamePattern(String patternArg, Context contextArg) {// the pattern is slashifiedsetPattern(FileFilterUtil.slashify(patternArg));setContext(contextArg);// 解析fileNamePattern属性得到的转换链parse();// start converters的各个节点ConverterUtil.startConverters(this.headTokenConverter);}void parse() {try {// )转为\)String patternForParsing = escapeRightParantesis(pattern);// 实例化解析器并解析各个部分生成tokenParser<Object> p = new Parser<Object>(patternForParsing, new AlmostAsIsEscapeUtil());p.setContext(context);// 解析token生成语法树Node t = p.parse();// 根据树形节点生成树形转换器this.headTokenConverter = p.compile(t, CONVERTER_MAP);} catch (ScanException sce) {addError("Failed to parse pattern \"" + pattern + "\".", sce);}}
}
FileNamePattern将一个表达式字符串pattern
解析成转换器链headTokenConverter
, 并且默认支持对%i
和%d
的解析, 这里%i
是用来根据日志文件大小切割的序号, 是代表一个整型, %d
就是日期格式, 下面列举它两个比较重要的方法
java">// 将参数经过转换器处理后得到转换后的字符串
public String convertMultipleArguments(Object... objectList) {StringBuilder buf = new StringBuilder();Converter<Object> c = headTokenConverter;while (c != null) {if (c instanceof MonoTypedConverter) {// date和Integer的TokenConverter的转换器,需要判断是否是可转换的MonoTypedConverter monoTyped = (MonoTypedConverter) c;for (Object o : objectList) {if (monoTyped.isApplicable(o)) {buf.append(c.convert(o));}}} else {buf.append(c.convert(objectList));}c = c.getNext();}return buf.toString();
}
// 将数字转为正则, 将date转为字符串, 然后生成字符串
// 可以做到 根据文件格式, 生成对应的正则表达式, 注意只对%i和%d两种类型做处理
// 例如: /Users/uncleqiao/logs/%d{yyyy-MM, aux, Asia/Shanghai}/roller_test.%d{yyyy-MM-dd}.log.gz
// 转成: /Users/uncleqiao/logs/\d{4}-\d{2}/roller_test.\d{4}-\d{2}-\d{2}.log.gz
public String toRegex() {StringBuilder buf = new StringBuilder();Converter<Object> p = headTokenConverter;while (p != null) {// 普通文本if (p instanceof LiteralConverter) {buf.append(p.convert(null));} // %i, 将数字格式转为正则表达式else if (p instanceof IntegerTokenConverter) {buf.append("\\d+");} // %d, 将日期格式转为正则表达式else if (p instanceof DateTokenConverter) {DateTokenConverter<Object> dtc = (DateTokenConverter<Object>) p;buf.append(dtc.toRegex());}p = p.getNext();}return buf.toString();
}
三、总结
- appender主要分为UnsynchronizedAppenderBase和AppenderBase两大类, 前一种代表了异步添加日志, 后一种代表了同步添加日志, 一般我们使用的是UnsynchronizedAppenderBase, 效率更高, 同一个线程中日志还是有序的。
- appender可以通过
<filter>
标签设置打印日志前的过滤 - OutputStreamAppender类型的appender是支持添加
encoder
、OutputStream
、immediateFlush
、layout
标签 - ConsoleAppender通过
System.out
打印日志到控制台 - FileAppender使用的是
BufferedOutputStream
将日志写入到文件中; FileOutputStream和FileChannel共享一个文件指针, 可以使用FileChannel移动指针, FileOutputStream读写文件 - RollingFileAppender支持按照时间(或者大小或者时间大小)滚动日志,
- 它可以通过file指定当前活动的日志文件(当前写日志的文件)-非必须;
- 可以通过
rollingPolicy
标签指定滚动策略, - 按时间滚动的话一般是用
TimeBasedRollingPolicy
, 它既是滚动策略, 也是触发滚动策 - 可以通过
fileNamePattern
标签配置归档文件格式, 默认使用的是DefaultTimeBasedFileNamingAndTriggeringPolicy
作为触发策略 - 可以通过
maxHistory
设置日志归档文件最多保存的天数
- fileNamePattern标签支持
%i
占位数字对按照文件大小分割的文件进行编号;%d
对日期占位
- 一个fileNamePattern标签中
%d
占位的时间格式有一个必须不被aux修饰, 没有被aux修饰的第一个时间将会定义为primary, 用来定义文件滚动周期的, 例如${log.path}/%d{yyyy-MM, aux, Asia/Shanghai}/roller_test.%d{yyyy-MM-dd}.log
这里的yyyy-MM-dd就是约定按天滚动