10秒鍾讓你的日志模塊化


一、背景

業務開發中常常有這樣的場景, 好幾個人在同一個項目中,負責不同的業務模塊,比如在一個商城系統中,老王負責會員(member)和返現(rebate)模塊;老李負責商品(item)和促銷(promotion)模塊;老呂負責活動(campaign)模塊。

業務剛起步,團隊很小,沒那么多預算去搞微服務, 大家只共享一個應用(同一套代碼)一塊寫代碼。這時就遇到個問題,他們都有輸出日志的需求,而且都不希望自己的日志不被別人輸出的日志干擾。怎么辦呢?

解決辦法自然是將日志按照業務模塊划分,每個業務模塊打日志都輸出到獨立的文件/文件夾中去,互不影響。

在此向大家介紹一款簡單配置即可非常快速完成日志模塊化的工具,基於spring-boot,支持logback和log4j2,要求java版本>=8。

二、工具介紹

2.1 配置接入

在項目中引入依賴:

<dependency>
   <groupId>io.github.lvyahui8</groupId>
   <artifactId>feego-common-logging-starter</artifactId>
   <version>1.0.0</version>
</dependency>

在任意一個類上增加注解 @ModuleLoggerAutoGeneration當然也可以分多次注解在不同模塊的不同類上

@Configuration
@ModuleLoggerAutoGeneration({"member","rebate","item","promotion","campaign"})
public class LoggingConfiguration {
}

2.2 用法

編譯時期將自動生成一個SystemLogger枚舉類,老王老李們可以通過這個枚舉類來記錄日志, 我們可以在注解所在模塊的編譯目錄target\generated-sources 下找到它,內容如下:

public enum SystemLogger implements ModuleLogger { 
  member,
  rebate,
  item,
  promotion,
  campaign,
  ;

  @Override
  public Logger getInnerLogger() {
    return ModuleLoggerRepository.getModuleLogger(this.name());
  }
}

它支持slf4j標准接口(org.slf4j.Logger)的所有方法,除此之外, 還擴展了下列方法:

void trace(LogSchema schema) 
void debug(LogSchema schema) 
void info(LogSchema schema) 
void warn(LogSchema schema) 
void error(LogSchema schema) 

使用方法非常簡單, 以活動(campaign)模塊要記錄一條info級別日志為例,代碼如下:

SystemLogger.campaign.info(
   LogSchema.empty().of("id",1).of("begin",20200307).of("end",20200310).of("status",1)
);

程序運行后, 首先日志目錄下會按照業務模塊生成相應的日志文件:

Campaign模塊會記錄了一條日志, 正是我們上面輸出的那條

日志分割符|#|和日志的輸出目錄是可以配置修改的

三、實現原理

工具的實現涉及到以下幾個知識點

  • Java編譯時注解處理器
  • 枚舉類實現接口
  • spring ApplicationReadyEvent 事件處理
  • 查找實現了的某個接口的所有類
  • 程序化動態配置logback或者log4j2
  • 工廠方法模式
  • spring-boot starter 寫法

下面來一一拆解工具的實現,並分別介紹上述知識點

3.1 枚舉類的生成原理

枚舉類是在編譯時生成的,這里實際靠的就是編譯時的注解處理器。

編譯時注解處理器,是jdk提供的在java程序編譯期間,掃碼代碼注解並進行處理的一種機制,此時程序還沒到運行的階段。我們可以通過這個機制去生成或者修改java代碼,生成的java代碼編譯后的class文件一般也會被IDE工具打包到jar包中去,著名的lombok框架就是基於此實現的。

具體怎么寫編譯時注解處理器就不展開了,感興趣的同學請自行查詢資料。

這里主要介紹我們的編譯處理器ModuleLoggerProcessor 做了什么

  1. 遍歷當前代碼模塊中,所有有@ModuleLoggerAutoGeneration注解的類所在的包名, 求出一個共同前綴, 作為將要生成的枚舉類的包名
  2. 給公共包名增加一個feego.common前綴(作用后面介紹)
  3. 創建一個java文件
  4. 遍歷@ModuleLoggerAutoGeneration注解的value, 以value作為枚舉值,輸出枚舉類代碼

具體代碼: https://github.com/lvyahui8/feego-common/blob/master/feego-common-configuration-processor/src/main/java/io/github/lvyahui8/configuration/processor/ModuleLoggerProcessor.java

3.2 如何讓枚舉類具備日志能力?

前面可以看到,工具生成的SystemLogger枚舉類,代碼非常簡單, 僅僅實現了一個ModuleLogger接口,並重寫了getInnerLogger方法

public enum SystemLogger implements ModuleLogger { 
  campaign,
  ;

  @Override
  public Logger getInnerLogger() {
    return ModuleLoggerRepository.getModuleLogger(this.name());
  }
}

簡簡單單的幾句代碼, 是怎么給枚舉插入了強大的日志能力呢?

我們來看看ModuleLogger的聲明:

https://github.com/lvyahui8/feego-common/blob/master/feego-common-logging-core/src/main/java/io/github/lvyahui8/core/logging/ModuleLogger.java

public interface ModuleLogger extends org.slf4j.Logger {

    default void info(LogSchema schema) {
        ((ModuleLogger) getInnerLogger()).info(schema);
    }
    // 省略另外4個入參為LogSchema的方法

    @Override
    default void debug(String msg) {
        getInnerLogger().debug(msg);
    }
    
    @Override
    default void info(String msg) {
        getInnerLogger().info(msg);
    }

    // 省略幾十個 org.slf4j.Logger 的其它方法
    
    /**
     * get actual logger
     * @return actual logger
     */
    Logger getInnerLogger() ;
}

首先,枚舉類實際也是類,而且是繼承java.lang.Enum的類, 我們知道java的多態,類只支持單繼承,但允許多實現接口,因此, 我們可以通過接口為枚舉類插上飛翔的翅膀

其次,java 8 之后, 接口支持了default實現, 我們可以在接口中寫default方法,子類可以不用實現, 這樣我們的枚舉類可以寫的很簡潔。

我們看接口中的default方法, 都是調用getInnerLogger后, 將方法調用轉發給了innerLogger, 而getInnerLogger方法本身並不是default方法, 實現類必須實現此方法才行。我們生成的枚舉類實現了這個方法

@Override
public Logger getInnerLogger() {
    return ModuleLoggerRepository.getModuleLogger(this.name());
}

它通過一個靜態方法,從ModuleLoggerRepository中獲取了一個Logger實例。這個實例難道就是SystemLogger枚舉實例嗎?那調用豈不是進入死遞歸?

機智的同學肯定猜到了, 一定還有一個類, 真正的實現了ModuleLogger接口, 而且ModuleLoggerRepository

存放的就是這個類的實例。

沒錯, 這個類就是DefaultModuleLoggerImpl

public class DefaultModuleLoggerImpl implements ModuleLogger {
    private org.slf4j.Logger logger;

    private String separator;


    public DefaultModuleLoggerImpl(org.slf4j.Logger logger, String separator) {
        this.logger = logger;
        this.separator = separator;
    }

    @Override
    public Logger getInnerLogger() {
        return logger;
    }

    @Override
    public void info(LogSchema schema) {
        LogSchema.Detail detail = schema.build(separator);
        getInnerLogger().info(detail.getPattern(),detail.getArgs());
    }
}

到此鏈路就清晰了, 總結一下:

  1. 枚舉類上的info、debug、error等等調用, 轉到了默認實現(default)方法,
  2. default方法進一步轉到了innerLogger
  3. 而枚舉類的innerLogger通過靜態的ModuleLoggerRepository再次轉發給了DefaultModuleLoggerImpl實例
  4. 最終DefaultModuleLoggerImpl使用入參中的org.slf4j.Logger實例來記錄日志

3.3 ModuleLoggerRepository如何初始化?

從上面的流程中, 我們可以看到一個很關鍵的東西:ModuleLoggerRepository, 我們在編譯時生成的枚舉類, 將方法調用層層轉發到了這里面的moduleLogger實例,可以看到, 它是一個非常非常關鍵的橋梁,那么,它是怎么初始化的呢?moduleLogger實例,又是怎么生成並放到這里面去的?

這里, 我們要看一個很重要的配置類 ModuleLoggerAutoConfiguration

https://github.com/lvyahui8/feego-common/blob/master/feego-common-logging-starter/src/main/java/io/github/lvyahui8/core/logging/autoconfigure/ModuleLoggerAutoConfiguration.java

它是一個spring-boot starter模塊的auto Configuration類,同時,它也實現了ApplicationListener<ApplicationReadyEvent>接口, 這樣, 當spring進程初始化完成后, ModuleLoggerAutoConfiguration#onApplicationEvent 被調用。

@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
    String storagePath = (loggingProperties.getStoragePath() == null ? System.getProperty("user.home") : loggingProperties.getStoragePath())
            + File.separator + "logs";

    Reflections reflections = new Reflections("feego.common.");
    Set<Class<? extends ModuleLogger>> allModuleLoggers = reflections.getSubTypesOf(ModuleLogger.class);

    String pattern = "%d{yyyy-MM-dd HH:mm:ss.SSS} : %m%n";

    for (Class<? extends ModuleLogger> moduleEnumClass : allModuleLoggers) {
        for (Object enumInstance : moduleEnumClass.getEnumConstants()) {
            Enum<?> em  = (Enum<?>) enumInstance;

            String loggerName = em.name();

            ILoggerFactory loggerFactory = LoggerFactory.getILoggerFactory();
            String fileName = storagePath + File.separator + loggerName + ".log";

            File file = new File(fileName);
            if (!file.getParentFile().exists() && !file.getParentFile().mkdirs()) {
                throw new RuntimeException("No permission to create log path!");
            }
            String fileNamePattern = fileName + ".%d{yyyy-MM-dd}.%i";
            ModuleLoggerFactory factory ;
            if ("ch.qos.logback.classic.LoggerContext".equals(loggerFactory.getClass().getName())) {
                factory = new LogbackModuleLoggerFactory(loggingProperties);
            } else if ("org.apache.logging.slf4j.Log4jLoggerFactory".equals(loggerFactory.getClass().getName())){
                factory = new Log4j2ModuleLoggerFactory(loggingProperties);
            } else {
                throw new UnsupportedOperationException("Only logback and log4j2 are supported");
            }
            /* 使用代理類替換代理枚舉實現 */
            ModuleLogger moduleLogger = new DefaultModuleLoggerImpl(factory.getLogger(pattern, loggerName, loggerFactory, fileName, fileNamePattern),
                    loggingProperties.getFieldSeparator());
            ModuleLoggerRepository.put(loggerName,moduleLogger);
        }
    }

}

在這個方法中,通過反射工具, 掃描了feego.common 包下的所有SystemLogger類,還記得嗎?我們的注解處理器生成的代碼,都在這個包前綴下,使用一個包前綴,是為了減少掃描類。實際上,你可以不使用注解,而是自己編寫一個SystemLogger枚舉類, 只要你保證放在feego.common包或者其子包下即可。

我們遍歷枚舉類, 以枚舉實例的name作為logger的name,通過判斷LoggerFactory.getILoggerFactory()的實現類(logback or log4j2)創建了不同的工廠類, 通過工廠方法模式,生成的真正的ModuleLogger實例, 並將實例加入到了 ModuleLoggerRepository

3.4 程序化配置logback 和 log4j2

logback和log4j2都支持動態創建logger和appender,這里使用工廠方法模式來生成具體的logger實例

工廠接口:

package io.github.lvyahui8.core.logging.factory;

import org.slf4j.ILoggerFactory;

public interface ModuleLoggerFactory {
    org.slf4j.Logger getLogger(String pattern, String loggerName, ILoggerFactory loggerFactory, String fileName, String fileNamePattern);
}

log4j2工廠實現類

public class Log4j2ModuleLoggerFactory implements ModuleLoggerFactory {
    private ModuleLoggerProperties loggingProperties;

    public Log4j2ModuleLoggerFactory(ModuleLoggerProperties loggingProperties) {
        this.loggingProperties = loggingProperties;
    }

    @Override
    public Logger getLogger(String pattern, String loggerName, ILoggerFactory loggerFactory, String fileName, String fileNamePattern) {
        /*省略程序化配置log4j2*/
    }
}

logback工廠實現類

public class LogbackModuleLoggerFactory implements ModuleLoggerFactory {

    private ModuleLoggerProperties loggingProperties;

    public LogbackModuleLoggerFactory(ModuleLoggerProperties loggingProperties) {
        this.loggingProperties = loggingProperties;
    }

    @Override
    public Logger getLogger(String pattern, String loggerName, ILoggerFactory loggerFactory, String fileName, String fileNamePattern) {
        /*省略程序化配置logback*/
    }
}

這里附上完整代碼以及logback和log4j2官方文檔, 感興趣的同學可以去了解下細節

四、總結

寫這工具也是臨時起意, 在網上尋找過類似的開源軟件,但並未找到,故自行實現了一個。也許功能還不夠完善,也有許多改進的地方,有空的話,我會持續優化改進。當然, 這離不開使用者的反饋與改進建議。

最后, 附上工具的github連接,歡迎star、提意見、共建、使用, 非常感謝。

https://github.com/lvyahui8/feego-common


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM