前言
Java開發者對於日誌框架,想必都不陌生。我自己使用過的有Log4j、logback。作爲Java開發者,應該都遇到因日誌包衝突導致的異常問題,排查過程也或多或少知曉 Java日誌接口包、橋接包、產品包的混亂關係,本篇目的是爲了對日誌框架的發展歷史,以及Logback的使用及異步調優進行簡單介紹,提供一類日誌問題排查思路。
日誌發展歷史
- 1996年初,E.U.SEMPER(歐洲安全電子市場)定義並實現了 Log4j,其一貢獻成員 Ceki Gülcü,也是後來Logback的設計者。
- 後來 Log4j 成爲 Apache 項目之一
- 2002年2月,Sun公司發佈Java1.4的同時,推出日誌產品 Java Util Logging(簡稱JUL)
- 2002年8月,Apache 定義日誌接口 Jakarta Commons Logging(簡稱JCL),Log4j便是 JCL 的實現產品之一
- 2005年,Ceki Gülcü 定義日誌接口 Simple Logging Facade for Java(簡稱Slf4j),爲了兼容歷史已存在的日誌產品包,Ceki Gülcü 分別對 JUL 和 Log4j 手寫了橋接包 slf4j-jdk14、slf4j-log4j12
- 2006年,Ceki Gülcü 基於 slf4j 接口定義,手寫了 Logback 日誌產品
- 2012年,Apache 針對 Slf4j 推出了新的日誌產品 Log4j2(與Log4j1.x不兼容)
先貼一張常用日誌包之間的關係圖,由接口包指向產品包的箭頭,意爲實現;由產品包指向接口包、以及接口包之間的箭頭,意爲橋接。目前主流方案是slf4j+logback 或 slf4j+log4j2。
橋接包實現
橋接包——使用了[橋接]設計模式打的jar包。橋接模式主要目標是解耦抽象與實現,使得兩者可以獨立地變化。以 log4j-to-slf4j 舉例,它存在的意義就是將log4j-api 的接口定義,與 log4j 的實現隔離開。舉例:項目A中使用了log4j-api(接口) + log4j-core(實現),如果中途期望將日誌產品 log4j 切換成 logback,但又不期望對項目中所有Logger的引用做調整,這時候需要做以下的步驟:移除 log4j-core(原實現)、引入 log4j-to-slf4j(橋接)、slf4j-api(橋接目標接口)、logback-core+logback-classic(橋接目標實現)。
首先看下 log4j-to-slf4j 包結構。
瞭解< SPI機制 >的話,看到 META-INF/services/org.apache.logging.log4j.spi.Provider 第一反應便是藉助 SPI 將 Log4j 實現類引入,實現類爲同包下的 org.apache.logging.slf4j.SLF4JProvider
package org.apache.logging.slf4j;
import org.apache.logging.log4j.spi.Provider;
public class SLF4JProvider extends Provider {
public SLF4JProvider() {
super(15, "2.6.0", SLF4JLoggerContextFactory.class, MDCContextMap.class);
}
}
對比 log4j-core 實現類 org.apache.logging.log4j.core.impl.Log4jProvider,優先級更高(15>10)。
package org.apache.logging.log4j.core.impl;
import org.apache.logging.log4j.spi.Provider;
public class Log4jProvider extends Provider {
public Log4jProvider() {
super(10, "2.6.0", Log4jContextFactory.class);
}
}
接下來看 log4j-to-slf4j 對 log4j-api 定義的 org.apache.logging.log4j.spi.LoggerContextFactory 和 org.apache.logging.log4j.spi.LoggerContext擴展實現
package org.apache.logging.slf4j;
import java.net.URI;
import org.apache.logging.log4j.spi.LoggerContext;
import org.apache.logging.log4j.spi.LoggerContextFactory;
import org.apache.logging.log4j.status.StatusLogger;
import org.apache.logging.log4j.util.LoaderUtil;
public class SLF4JLoggerContextFactory implements LoggerContextFactory {
private static final StatusLogger LOGGER = StatusLogger.getLogger();
private static final LoggerContext context = new SLF4JLoggerContext();
public SLF4JLoggerContextFactory() {
boolean misconfigured = false;
try {
LoaderUtil.loadClass("org.slf4j.helpers.Log4jLoggerFactory");
misconfigured = true;
} catch (ClassNotFoundException var3) {
LOGGER.debug("org.slf4j.helpers.Log4jLoggerFactory is not on classpath. Good!");
}
// log4j實現循環依賴攔截
if (misconfigured) {
throw new IllegalStateException("slf4j-impl jar is mutually exclusive with log4j-to-slf4j jar (the first routes calls from SLF4J to Log4j, the second from Log4j to SLF4J)");
}
}
public LoggerContext getContext(final String fqcn, final ClassLoader loader, final Object externalContext, final boolean currentContext) {
return context;
}
public LoggerContext getContext(final String fqcn, final ClassLoader loader, final Object externalContext, final boolean currentContext, final URI configLocation, final String name) {
return context;
}
}
package org.apache.logging.slf4j;
import org.apache.logging.log4j.message.MessageFactory;
import org.apache.logging.log4j.spi.ExtendedLogger;
import org.apache.logging.log4j.spi.LoggerContext;
import org.apache.logging.log4j.spi.LoggerRegistry;
import org.slf4j.LoggerFactory;
public class SLF4JLoggerContext implements LoggerContext {
private final LoggerRegistry<ExtendedLogger> loggerRegistry = new LoggerRegistry();
public ExtendedLogger getLogger(final String name) {
if (!this.loggerRegistry.hasLogger(name)) {
// 使用 slf4j-api 獲取Logger後,再借助 SLF4JLogger 包裝一層適配
this.loggerRegistry.putIfAbsent(name, (MessageFactory)null, new SLF4JLogger(name, LoggerFactory.getLogger(name)));
}
return this.loggerRegistry.getLogger(name);
}
}
通過擴展 Log4j 開放的 LoggerContext 的實現類,使用 SLF4JLogger 把原本 org.apache.logging.log4j.Logger 包裝了一層後註冊進去,後續對 Log4j Logger 的調用,就被橋接到 slf4j 的 Logger ,至此 log4j-api 到 slf4j-api 的橋接完成。
爲了避免橋接 slf4j 後,又引入實現了 slf4j 版的Log4j(無限循環),SLF4JLoggerContextFactory 在構造器內嘗試加載了 log4j-slf4j-impl(可以從最初的關係圖中看出實現關係) 中的一個類,如果加載成功(即classpath 存在了Log4j 對 slf4j 接口的實現包),則拋出異常阻斷項目啓動。
接下來再引入一個 slf4j 的日誌產品包即可,例如 logback。
Logback實踐
由於 slf4j + logback 的簡潔易用性,Spring-boot 默認集成了 Logback 日誌框架。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
Logback定義了Logger, Appender 和 Encoder(在0.9.19版本後,已經推薦使用Encoder,之前使用Layout) 3個主要的組件。開發者藉助日誌框架提供的多種實現類,通過自定義日誌級別、日誌格式等完成日誌的輸出。
從 Encoder 開始說起,從接口的定義可以看出,主要負責將日誌時間轉爲字節數據輸出。對應配置中<encoder>標籤, Logback默認的 Encoder 實現爲 ch.qos.logback.classic.encoder.PatternLayoutEncoder,所以如果不特殊指定class,默認便是 PatternLayoutEncoder。
package ch.qos.logback.core.encoder;
import ch.qos.logback.core.spi.ContextAware;
import ch.qos.logback.core.spi.LifeCycle;
public interface Encoder<E> extends ContextAware, LifeCycle {
byte[] headerBytes();
byte[] encode(E event);
byte[] footerBytes();
}
通過查看 PatternLayoutEncoder 源碼可以發現有一個 pattern 的成員變量,用於指定日誌的輸出格式,對應 <encoder> 配置的子標籤 <pattern>。Logback 的配置實質就是將標籤指定的class實例化,並藉助子標籤給實例對象賦值(setter);通過源碼還可以發現,PatternLayoutEncoder 的 Layout 默認使用 ch.qos.logback.classic.PatternLayout。
PatternLayout,通過組合"%+轉換符"來指定日誌輸出格式,以下爲常用的轉換符釋義,其餘轉換符可以看 PatternLayout 中成員變量 DEFAULT_CONVERTER_MAP(定義了轉換符-轉換處理類的映射關係)
轉換符 | 釋義 |
%s | 日期 |
%thread 或 %t | 線程名 |
%msg 或 %m | 開發者打印的日誌內容 |
%level | 日誌級別。%-5level表示從左輸出最多5個字符寬度,不足右側補空格 |
%n | 日誌換行 |
%logger | 輸出日誌所在類名,默認顯示全限定名,可追加 {數字} 來壓縮長度 |
%date 或 %d | 打印時機器時間,追加{yyyy-MM-dd HH:mm:ss.SSS}指定時間格式 |
%line 或 %L | 輸入日誌所在類的行數 |
接下來是 Appender ,它主要承擔了日誌輸出職責。除了日誌輸出,自身可以被定義一個 name,用於 Logger 引用。
package ch.qos.logback.core;
import ch.qos.logback.core.spi.ContextAware;
import ch.qos.logback.core.spi.FilterAttachable;
import ch.qos.logback.core.spi.LifeCycle;
public interface Appender<E> extends LifeCycle, ContextAware, FilterAttachable<E> {
String getName();
void doAppend(E event) throws LogbackException;
void setName(String name);
}
常用的有控制檯輸出、日誌文件輸出等,實現類如下。
類型 | 實現類 | 屬性設置 |
控制檯 | ch.qos.logback.core.ConsoleAppender | 一般只需要配置輸出格式即可 |
文件 | ch.qos.logback.core.FileAppender | 常用屬性如下
|
滾動文件 | ch.qos.logback.core.rolling.RollingFileAppender | 在FileAppender基礎上新增屬性如下
|
異步Appender | net.logstash.logback.appender.LoggingEventAsyncDisruptorAppender | 日誌異步化,需指定appender實例 |
考慮到大多Java應用都是在服務器上長期運行,日誌記錄也就幾乎伴隨了 Java 應用運行的整個生命週期。
考慮這樣一件事:日誌記錄的目的是爲了排查問題,但日誌輸出是持續的,如果不周期性的清理,機器的磁盤總會有100%的一天。所以日誌框架設計的時候,也要考慮到怎麼輔助開發者定期清理日誌。
比如可以通過週期性的將日誌歸檔,通過將歷史日誌的文件名打上日期的標識,這樣開發者便可以按照日期定時清理N天前的日誌文件。上述表格 ch.qos.logback.core.rolling.RollingFileAppender 也就成了最常用的 Appender 實現類,其中 rollingPolicy 屬性支持設置滾動策略,logback 提供的實現類如下:
實現類 | 作用 | 屬性設置 |
ch.qos.logback.core.rolling.TimeBasedRollingPolicy | 基於時間的滾動策略 |
|
ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy | 【最常用】基於時間和文件大小的滾動策略
|
在TimeBasedRollingPolicy基礎上增加
|
ch.qos.logback.core.rolling.FixedWindowRollingPolicy | 基於固定窗口的滾動策略 (異步調優會用到) |
|
經過上面的一個概念引入,接下來看下 一份 Logback 配置文件,應該比較好理解
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="APPLICATION-APPENDER"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<!--輸出的日誌文件名-->
<file>application.log</file>
<encoder>
<!--輸出的日誌格式-->
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<!--滾動策略-->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!--滾動生成的日誌文件名-->
<fileNamePattern>application.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxHistory>7</maxHistory>
<maxFileSize>50MB</maxFileSize>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
</appender>
<logger name="APPLICATION" level="debug" additivity="false">
<!--appender引用-->
<appender-ref ref="APPLICATION-APPENDER"/>
</logger>
<!--全局日誌級別-->
<root level="INFO">
<!--如果子logger additivity=true,父logger會疊加輸出日誌-->
<appender-ref ref="APPLICATION-APPENDER"/>
</root>
</configuration>
對配置中常用的標籤介紹下
標籤 | 屬性 |
<root> | 可包含0到多個<appender-ref>
|
<logger> |
|
<appender> |
|
<encoder> | 用於指定日誌輸出格式 |
<rollingPolicy> | RollingFileAppender實現特殊標籤,用於指定日誌滾動策略(例如基礎時間、存儲大小等) |
接下來開發者只要在業務代碼中,按照定義的 logger name 獲取到 Logger,調用API輸出日誌即可。
package com.example.LogDemo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogDemo {
private static Logger logger = LoggerFactory.getLogger("APPLICATION");
public static void main(String[] args) {
logger.info("this is one log event");
}
}
當然,你也看到過如下獲取 Logger 的方式,區別就在於一個是指定了 Logger name,另一個入參則是任意的 Class 對象,日誌框架是嘗試用類的全限定名,來獲取 Logger,但配置裏並沒有聲明這樣一個 <logger class="com.example.LogDemo">,那日誌框架的又是使用哪個 Logger 進行日誌輸出呢?
package com.example.LogDemo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogDemo {
private static Logger logger = LoggerFactory.getLogger(LogDemo.class);
public static void main(String[] args) {
logger.info("this is one log event");
}
}
Logback 的 Logger 命名是有層級結構(Log4j也有,其餘日誌框架沒有使用過,不太清楚) 。如果某個 Logger 的 name加上".",是另一個 Logger name的前綴,則前者是後者的父級,<root> 是所有 Logger 的頂級父,整體的結構類似 Java 的繼承關係(Logger未指定的屬性會繼承自父級Logger)。全局的 Logger 緩存在 ch.qos.logback.classic.LoggerContext 中。
如果在獲取 Logger 時,name未存在於 LoggerContext 緩存中,會將入參 name 按"."劃分層級自頂向下尋找 Logger,對應上述例子便是: com -> com.example -> com.example.LogDemo(本例底層會生成3個Logger)。如果不存在則用父級 Logger 屬性“復刻”一個子 Logger,並放置到緩存中,對應源碼如下
public class LoggerContext extends ContextBase implements ILoggerFactory, LifeCycle {
final Logger root;
private Map<String, Logger> loggerCache;
public LoggerContext() {
super();
this.loggerCache = new ConcurrentHashMap<String, Logger>();
this.root = new Logger(Logger.ROOT_LOGGER_NAME, null, this);
this.root.setLevel(Level.DEBUG);
loggerCache.put(Logger.ROOT_LOGGER_NAME, root);
// 省略其餘代碼
}
public final Logger getLogger(final Class<?> clazz) {
return getLogger(clazz.getName());
}
@Override
public final Logger getLogger(final String name) {
if (name == null) {
throw new IllegalArgumentException("name argument cannot be null");
}
// if we are asking for the root logger, then let us return it without
// wasting time
if (Logger.ROOT_LOGGER_NAME.equalsIgnoreCase(name)) {
return root;
}
int i = 0;
Logger logger = root;
// check if the desired logger exists, if it does, return it
// without further ado.
Logger childLogger = (Logger) loggerCache.get(name);
// if we have the child, then let us return it without wasting time
if (childLogger != null) {
return childLogger;
}
// if the desired logger does not exist, them create all the loggers
// in between as well (if they don't already exist)
String childName;
// 按照"."層級逐層獲取
while (true) {
int h = LoggerNameUtil.getSeparatorIndexOf(name, i);
if (h == -1) {
childName = name;
} else {
childName = name.substring(0, h);
}
// move i left of the last point
i = h + 1;
synchronized (logger) {
childLogger = logger.getChildByName(childName);
if (childLogger == null) {
childLogger = logger.createChildByName(childName);
loggerCache.put(childName, childLogger);
incSize();
}
}
logger = childLogger;
if (h == -1) {
return childLogger;
}
}
}
}
自此,Logback大致的架構以及使用已經介紹完畢,接下來看下日誌配置同步轉異步的配置。
Logback異步日誌調優
首先日誌記錄僅僅是爲了排查問題,真正服務於業務代碼,所以日誌的輸出不應該影響業務的運行,特別是不能拉上業務的執行耗時。所以日誌框架設計的時候便定義了異步 Appender 接口。這裏調優使用了 Logstash 提供的工具包。
通過配置發現,僅僅在原本的<appender>外又套了一層<appender>,也就是使用 net.logstash.logback.appender.LoggingEventAsyncDisruptorAppender 又包裝了一層(“裝飾器模式”的設計思想)。
日誌打印本質就是生產者-消費者模式,開發者負責生產日誌Event,日誌框架負責消費Event,將其轉化爲字節序列化輸出。LoggingEventAsyncDisruptorAppender 高性能的原理,就是內部藉助無鎖隊列 Distuptor ,開發者打印日誌本質就是往隊列中追加 Event,LoggingEventAsyncDisruptorAppender 再將隊列中的 Event 進行異步的輸出。
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="ASYNC-APPLICATION" class="net.logstash.logback.appender.LoggingEventAsyncDisruptorAppender">
<waitStrategyType>blocking</waitStrategyType>
<includeCallerData>false</includeCallerData>
<appender name="APPLICATION"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<!--輸出的日誌文件名-->
<file>application.log</file>
<encoder>
<!--輸出的日誌格式-->
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<!--滾動策略-->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!--滾動生成的日誌文件名-->
<fileNamePattern>application.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxHistory>7</maxHistory>
<maxFileSize>50MB</maxFileSize>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
</appender>
</appender>
<logger name="APPLICATION" level="debug" additivity="false">
<!--appender引用-->
<appender-ref ref="ASYNC-APPLICATION"/>
</logger>
<!--全局日誌級別-->
<root level="INFO">
<!--如果子logger additivity=true,父logger會疊加輸出日誌-->
<appender-ref ref="ASYNC-APPLICATION"/>
</root>
</configuration>
這是網上找到的性能測試對比(https://github.com/wsargent/slf4j-benchmark),從打印的 ops/ms指標,可直觀的發現日誌從同步->異步,提升是十分明顯的。