10秒鐘讓你的日誌模組化
阿新 • • 發佈:2020-03-08
## 一、背景
業務開發中常常有這樣的場景, 好幾個人在同一個專案中,負責不同的業務模組,比如在一個商城系統中,老王負責會員(member)和返現(rebate)模組;老李負責商品(item)和促銷(promotion)模組;老呂負責活動(campaign)模組。
業務剛起步,團隊很小,沒那麼多預算去搞微服務, 大家只共享一個應用(同一套程式碼)一塊寫程式碼。這時就遇到個問題,他們都有輸出日誌的需求,而且都不希望自己的日誌不被別人輸出的日誌干擾。怎麼辦呢?
解決辦法自然是將日誌按照業務模組劃分,每個業務模組打日誌都輸出到獨立的檔案/資料夾中去,互不影響。
在此向大家介紹一款簡單配置即可非常快速完成日誌模組化的工具,基於spring-boot,支援logback和log4j2,要求java版本>=8。
## 二、工具介紹
### 2.1 配置接入
在專案中引入依賴:
```xml
io.github.lvyahui8
feego-common-logging-starter
1.0.0
```
在任意一個類上增加註解 `@ModuleLoggerAutoGeneration`。**當然也可以分多次註解在不同模組的不同類上**。
```java
@Configuration
@ModuleLoggerAutoGeneration({"member","rebate","item","promotion","campaign"})
public class LoggingConfiguration {
}
```
### 2.2 用法
**編譯時期將自動生成一個SystemLogger列舉類,老王老李們可以通過這個列舉類來記錄日誌**, 我們可以在註解所在模組的編譯目錄`target\generated-sources` 下找到它,內容如下:
```java
public enum SystemLogger implements ModuleLogger {
member,
rebate,
item,
promotion,
campaign,
;
@Override
public Logger getInnerLogger() {
return ModuleLoggerRepository.getModuleLogger(this.name());
}
}
```
它支援slf4j標準介面(`org.slf4j.Logger`)的所有方法,除此之外, 還擴充套件了下列方法:
```java
void trace(LogSchema schema)
void debug(LogSchema schema)
void info(LogSchema schema)
void warn(LogSchema schema)
void error(LogSchema schema)
```
使用方法非常簡單, 以活動(campaign)模組要記錄一條`info`級別日誌為例,程式碼如下:
```java
SystemLogger.campaign.info(
LogSchema.empty().of("id",1).of("begin",20200307).of("end",20200310).of("status",1)
);
```
程式執行後, 首先日誌目錄下會按照業務模組生成相應的日誌檔案:
![](https://img2020.cnblogs.com/blog/635249/202003/635249-20200308103925319-387606997.png)
Campaign模組會記錄了一條日誌, 正是我們上面輸出的那條
![](https://img2020.cnblogs.com/blog/635249/202003/635249-20200308103933903-350575706.png)
日誌分割符`|#|`和日誌的輸出目錄是可以配置修改的
## 三、實現原理
工具的實現涉及到以下幾個知識點
- 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](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`方法
```java
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](https://github.com/lvyahui8/feego-common/blob/master/feego-common-logging-core/src/main/java/io/github/lvyahui8/core/logging/ModuleLogger.java)
```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方法, 實現類必須實現此方法才行。我們生成的列舉類實現了這個方法
```java
@Override
public Logger getInnerLogger() {
return ModuleLoggerRepository.getModuleLogger(this.name());
}
```
它通過一個靜態方法,從`ModuleLoggerRepository`中獲取了一個Logger例項。這個例項難道就是SystemLogger列舉例項嗎?那呼叫豈不是進入死遞迴?
機智的同學肯定猜到了, 一定還有一個類, 真正的實現了`ModuleLogger`介面, 而且`ModuleLoggerRepository`
存放的就是這個類的例項。
沒錯, 這個類就是`DefaultModuleLoggerImpl`
```java
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](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`介面, 這樣, 當spring程序初始化完成後, ModuleLoggerAutoConfiguration#onApplicationEvent 被呼叫。
```java
@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> 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例項
工廠介面:
```java
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工廠實現類
```java
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工廠實現類
```java
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官方文件, 感興趣的同學可以去了解下細節
- 完整程式碼: [https://github.com/lvyahui8/feego-common/tree/master/feego-common-logging-starter/src/main/java/io/github/lvyahui8/core/logging/factory ](https://github.com/lvyahui8/feego-common/tree/master/feego-common-logging-starter/src/main/java/io/github/lvyahui8/core/logging/factory )
- log4j2 ( Programmatically Modifying the Current Configuration after Initialization ): https://logging.apache.org/log4j/2.x/manual/customconfig.html
- logback : http://logback.qos.ch/manual/configuration.html
## 四、總結
寫這工具也是臨時起意, 在網上尋找過類似的開源軟體,但並未找到,故自行實現了一個。也許功能還不夠完善,也有許多改進的地方,有空的話,我會持續優化改進。當然, 這離不開使用者的反饋與改進建議。
最後, 附上工具的github連線,歡迎star、提意見、共建、使用, 非常感謝。
[https://github.com/lvyahui8/feego-common](https://github.com/lvyahui8/feego-