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指标,可直观的发现日志从同步->异步,提升是十分明显的。

 

附录

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