SpringBoot 用的 spring-jcl 列印日誌,與 LoggingSystem 有雞毛關係?

青石路發表於2024-08-23

開心一刻

現實中,我有一個異性遊戲好友,昨天我心情不好,找她聊天

我:我們兩個都好久沒有坐下來好好聊天了

她:你不是有女朋友嗎

我:人家不需要我這種窮人啊

她:難道我需要嗎

難道我需要嗎

前情回顧

從原始碼分析 SpringBoot 的 LoggingSystem → 它是如何繫結日誌元件的 從原始碼的角度講述了 Spring Boot 的 LoggingSystem 與日誌元件的繫結,預設情況下繫結的是 Logback;但當我們具體去看 Spring Boot 的日誌列印,卻發現用的是 spring-jcl ,透過它適配了 slf4j,真正的日誌列印還得依賴具體的日誌元件,預設情況下使用的是 logback;那這麼說來,Spring Boot 的日誌列印與 Spring Boot 的 LoggingSystem 貌似沒關係呀?

到底有沒有關係,有何關係,我們慢慢往下看;先宣告下

後面的分析都是基於 Spring Boot 預設的 Logback,其他日誌元件可能有所不同,大家別帶入錯了

LoggerFactory

不管是我們用的 slf4j 方式

private static final Logger LOGGER = LoggerFactory.getLogger(TestWeb.class);

還是 Spring Boot 用的 spring-jcl 方式

private static final Log logger = LogFactory.getLog(SpringApplication.class);

都會透過 slf4j 的 org.slf4j.LoggerFactory#getLogger(java.lang.String) 方法來獲取 Logger

public static Logger getLogger(String name) {
    ILoggerFactory iLoggerFactory = getILoggerFactory();
    return iLoggerFactory.getLogger(name);
}

LoggerFactory 被 final 修飾,且其構造方法是 private,不能被繼承,也不能在其他地方 new,純純就是一個工具類;它 importStaticLoggerBinder

import org.slf4j.impl.StaticLoggerBinder;

但大家去看下 slf4j-api 的包結構

slf4j包結構

根本就沒有 StaticLoggerBinder 呀?這也可以?這裡其實涉及到一個細節

編譯後的 class,可以選擇性的打包進 jar,執行的時候只要保證依賴的 class 被正常載入了就行,至於是否在同個 jar 包下並沒有關係

slf4j 1.7 原始碼中其實是有 StaticLoggerBinder 的

slf4j_StaticLoggerBinder

只是打包的時候剔除了

slf4j_剔除StaticLogggerBinder

所以,如果使用 1.7.x 及以下的 slf4j ,必須還得結合有 org.slf4j.impl.StaticLoggerBinder 的日誌元件,比如 logback

logback1.2.12_StaticLoggerBinder

這是不是又是個細節,你們是不是又學到了?

又是個細節

StaticLoggerBinder

我們對它進行提煉下

/**
 * The unique instance of this class.
 */
private static StaticLoggerBinder SINGLETON = new StaticLoggerBinder();

static {
    SINGLETON.init();
}

private StaticLoggerBinder() {
    defaultLoggerContext.setName(CoreConstants.DEFAULT_CONTEXT_NAME);
}

private LoggerContext defaultLoggerContext = new LoggerContext();

public static StaticLoggerBinder getSingleton() {
    return SINGLETON;
}

這是不是 餓漢式單例 的實現?那麼 StaticLoggerBinder 的 LoggerContext defaultLoggerContext 是不是也可以當做單例來看待?

LoggerContext

同樣,我們對它進行精煉,重點關注 rootsizeloggerCacheLoggerContext()getLogger(final String name)

public class LoggerContext extends ContextBase implements ILoggerFactory, LifeCycle {

    final Logger root;
    private int size;

    private Map<String, Logger> loggerCache;

    public LoggerContext() {
        super();
        this.loggerCache = new ConcurrentHashMap<String, Logger>();

        this.loggerContextRemoteView = new LoggerContextVO(this);
        this.root = new Logger(Logger.ROOT_LOGGER_NAME, null, this);
        this.root.setLevel(Level.DEBUG);
        loggerCache.put(Logger.ROOT_LOGGER_NAME, root);
        initEvaluatorMap();
        size = 1;
        this.frameworkPackages = new ArrayList<String>();
    }

    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;
            }
        }
    }

    private void incSize() {
        size++;
    }

    int size() {
        return size;
    }
}
  1. root

    Logger root 定義了最頂層的日誌記錄規則,可以被視為所有其他Logger物件的父級,並且它的配置會應用於所有的日誌記錄,除非被特定的Logger配置所覆蓋

  2. size

    Logger 數量,也就是 loggerCache 的 size

  3. loggerCache

    Map<String, Logger> loggerCache 快取了應用中所有的 Logger 例項;Logger 例項之間存在父子關係,涉及到日誌規則的繼承與覆蓋

  4. LoggerContext()

    初始化 loggerCache,例項化 Logger root,並將 root 放到 loggerCache 中

  5. getLogger(final String name)

    先判斷是否是 root,是則直接返回,不是則從 loggerCache 獲取,獲取到則直接返回;若還是沒獲取到,則說明當前 Logger 還沒被建立,則透過 while(true) 按產品包逐層建立 Logger,繫結好 Logger 之間的父子關係,都 put 進 loggerCache 中

    Logger父子關係

當應用啟動完成後,所有的 Logger 例項都被建立並快取到 LoggerContext 的 loggerCache 中

logCache內容

配置檔案載入

private static final Logger LOGGER = LoggerFactory.getLogger(TestWeb.class);

@GetMapping("hello")
public String hello(@RequestParam("name") String name) {
    LOGGER.info("hello介面入參:{}", name);
    return "hello, " + name;
}

直接 debug 跟進 LOGGER.info,幾次跟進後會來到 ch.qos.logback.classic.Logger#buildLoggingEventAndAppend

private void buildLoggingEventAndAppend(final String localFQCN, final Marker marker, final Level level, final String msg, 
                final Object[] params, final Throwable t) {
    LoggingEvent le = new LoggingEvent(localFQCN, this, level, msg, t, params);
    le.setMarker(marker);
    callAppenders(le);
}

這裡涉及到事件機制,不細講,大家可以去看:設計模式之觀察者模式 → 事件機制的底層原理,我們把重點放到 callAppenders 上,直譯就是呼叫 appender,appender 在哪?是不是在配置檔案中

appender

配置檔案什麼時候載入的,在 StaticLoggerBinder 載入的時候就完成了

private static StaticLoggerBinder SINGLETON = new StaticLoggerBinder();

static {
    SINGLETON.init();
}

/**
 * Package access for testing purposes.
 */
void init() {
    try {
        try {
            new ContextInitializer(defaultLoggerContext).autoConfig();
        } catch (JoranException je) {
            Util.report("Failed to auto configure default logger context", je);
        }
        // logback-292
        if (!StatusUtil.contextHasStatusListener(defaultLoggerContext)) {
            StatusPrinter.printInCaseOfErrorsOrWarnings(defaultLoggerContext);
        }
        contextSelectorBinder.init(defaultLoggerContext, KEY);
        initialized = true;
    } catch (Exception t) { // see LOGBACK-1159
        Util.report("Failed to instantiate [" + LoggerContext.class.getName() + "]", t);
    }
}

autoConfig() 就不細跟了(感興趣的可以去看:從原始碼來理解slf4j的繫結,以及logback對配置檔案的載入),執行完之後,我們看下 LoggerContext 的 objectMap

LoggerContext

簡單來說,就是將日誌配置檔案 (logback.xml)載入到了 LoggerContext 的 objectMap 中;我們再回到 Spring Boot 的 LoggingSystem,以 LoggingApplicationListener#onApplicationEnvironmentPreparedEvent 方法作為起點(細節就不跟了,大家直接去看:從原始碼分析 SpringBoot 的 LoggingSystem → 它是如何繫結日誌元件的),我們直接來看 LogbackLoggingSystem#reinitialize

@Override
protected void reinitialize(LoggingInitializationContext initializationContext) {
    getLoggerContext().reset();
    getLoggerContext().getStatusManager().clear();
    loadConfiguration(initializationContext, getSelfInitializationConfig(), null);
}

getLoggerContext() 就不用多說了吧,就是獲取全域性唯一的 LoggerContext 例項,重點看它的 reset()

@Override
public void reset() {
    resetCount++;
    super.reset();
    initEvaluatorMap();
    initCollisionMaps();
    root.recursiveReset();
    resetTurboFilterList();
    cancelScheduledTasks();
    fireOnReset();
    resetListenersExceptResetResistant();
    resetStatusListeners();
}

super.reset()

public void reset() {

    removeShutdownHook();
    getLifeCycleManager().reset();
    propertyMap.clear();
    objectMap.clear();
}

reset 執行完之後,LoggerContext 的 objectMap 被置空了

reset

說白了就是 Spring Boot 把 Logback 載入的日誌配置給清空了,接下來就是 Spring Boot 載入日誌配置資訊到 LoggerContext 中,也就是如下程式碼完成的事

loadConfiguration(initializationContext, getSelfInitializationConfig(), null);

不繼續跟了,感興趣的自行去跟;該方法執行完之後,LoggerContext 的 objectMap 又有內容了

reset之後LoggerContext_objectMap

總結下

  1. StaticLoggerBinder 類載入的時候,會載入日誌配置檔案內容到 LoggerContext 的 objectMap 中
  2. Spring Boot 啟動過程中會重置 LoggerContext,其中包括 LoggerContext 的 objectMap,然後重新載入日誌配置檔案內容到 LoggerContext 的 objectMap中

所以甭管是使用 spring-jcl ,還是使用 slf4j 進行的日誌列印,用到的 Appenders 都是 Spring Boot 啟動過程中從日誌配置檔案中載入的,那麼 spring-jcl 與 LoggingSystem 有什麼關係,大家清楚了嗎?

補充個問題

將 logback.xml 重新命名成 logback-spring.xml,為什麼 Spring Boot 的日誌以及我們的業務日誌都能正常列印,並且與使用 logback.xml 時一樣?

這個問題要是答不上來,那你們肯定是沒仔細看 從原始碼分析 SpringBoot 的 LoggingSystem → 它是如何繫結日誌元件的,裡面詳細介紹了 Spring Boot 對日誌配置檔案的載入

總結

  1. StaticLoggerBinder 類載入的時候,會載入日誌配置檔案內容到 LoggerContext

    Logback 1.2.12 預設日誌配置檔案的優先順序

    logback.configurationFile > logback-test.xml > logback.xml

  2. Spring Boot 啟動過程中會重置 LoggerContext,然後重新載入日誌配置檔案內容到 LoggerContext

    Spring Boot 2.7.18 先按優先順序

    logback-test.groovy > logback-test.xml > logback.groovy > logback.xml

    如果如上四個都不存在,則繼續按優先順序

    logback-test-spring.groovy > logback-test-spring.xml > logback-spring.groovy > logback-spring.xml

    尋找日誌配置檔案

  3. 正因為 Spring Boot 啟動過程中會重新載入日誌配置檔案內容到 LoggerContext,所以不管是 spring-jcl 還是 slf4j 列印,日誌格式是一致的

    Spring Boot 擴充了日誌配置檔案的檔名

相關文章