Java日誌框架學習

前言

  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.LoggerContextFactoryorg.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, AppenderEncoder(在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

常用屬性如下

  • append:是否追加
  • file:文件名
  • immediateFlush:是否每次日誌記錄都立即刷新到磁盤
滾動文件 ch.qos.logback.core.rolling.RollingFileAppender

在FileAppender基礎上新增屬性如下

  • rollingPolicy:日誌文件滾動策略
異步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 基於時間的滾動策略
  • fileNamePattern:滾動生成的文件名格式
  • maxHistory:文件最大留存時間
  • totalSizeCap:所有日誌文件總磁盤佔用大小上限,超過則從存在最長時間的日誌開始刪除
ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy

【最常用】基於時間和文件大小的滾動策略

 

在TimeBasedRollingPolicy基礎上增加

  • maxFileSize:每個文件最大的磁盤佔用
ch.qos.logback.core.rolling.FixedWindowRollingPolicy

基於固定窗口的滾動策略

(異步調優會用到)

  • fileNamePattern:滾動生成的文件名格式
  • minIndex:索引下限
  • maxIndex:索引上限

   經過上面的一個概念引入,接下來看下 一份 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> 

  • level:必填;日誌級別(TRACE, DEBUG, INFO, WARN, ERROR, ALL, OFF)
<logger>
  • name:必填;
  • level:可選;日誌級別,不填向上找父類logger級別
  • additivity:可選;true/false,不填默認爲true。用於指定是否向父類logger疊加輸出
<appender>
  • name:必填;
  • class: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指標,可直觀的發現日誌從同步->異步,提升是十分明顯的。

 

附錄

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章