使用Log4j2代碼方式配置實現線程級動態控制

一 需求

最近平臺進行升級,要求日志工具從Log4j升級到Log4j2,以求性能上的提升。之前我寫過以代碼方式的配置Log4j,來實現線程級日志對象的管理,今天把版本升級到Log4j2,依然采用原有思路來做,但是實現上有諸多區別,這是因為Log4j2的實現較老版本改動太多。

至於以配置文件方式配置的方法,我不做介紹,對於Log4j2的詳細實現亦然,這些部分有興趣的朋友可以自己網絡搜索,也可以自行跟蹤源碼查閱。

主要需求為每個線程單獨一個日志對象處理,可動態控制日志輸出級別等參數,可控制的同步及異步輸出模式,標準的日志頭格式等。

大體思路同我之前寫的Log4j配置,隻不過日志對象的創建管理等功能實現上略有區別。

二 對外暴露的接口

設計的重中之重,這部分一旦設計完成便不可輕易改動,後續的維護隻能盡其可能的向前兼容。

首先我們需要提供對外可用的預設參數值,包括日志輸出等級、日志的輸出目標(文件、控制臺、網絡及數據庫等)等,這些值設定我們均以static final來修飾。

第二部分是全局參數的設置,這些參數不隨日志對象的狀態而發生變動,反而是作為日志對象構造的屬性值來源,比如說日志文件的路徑、日志文件的最大容量、日志文件的備份數量,以及每個線程的日志對象初始的日志輸出等級等等。這部分參數可以從外部配置讀取,當然也需要有默認值設定,屬全局參數配置,以static修飾。

第三部分為日志輸出的接口定義,這部分可有可無,但是對於大型項目來說極為重要,以規范的接口進行日志輸出,可以為日志的采集、分析帶來巨大便利,如通訊日志、異常日志等內容的格式化輸出。

所以,按總體思路預先定義日志工具的外對暴露接口,如下:

public class LogUtil
{
	public static final int LevelTrace = 0;
	public static final int LevelDebug = 1;
	public static final int LevelInfo = 2;
	public static final int LevelWarn = 3;
	public static final int LevelError = 4;
	public static final String[] LevelName = new String[]
	{ "TRACE", "DEBUG", "INFO", "WARN", "ERROR" };
	public static final String TypeCommunication = "comm";
	public static final String TypeProcess = "proc";
	public static final String TypeException = "exce";
	public static final int AppenderConsole = 1;
	public static final int AppenderFile = 2;
	private static int DefaultLogLevel = LevelDebug;
	private static String FilePath = null;
	private static String FileSize = "100MB";
	private static int BackupIndex = -1;
	private static int BufferSize = 0;
	private static String LinkChar = "-";
	private static int LogAppender = AppenderFile;	
	public static void log(int logLevel, String logType, String logText)
	{
		getThreadLogger().log(logLevel, logType, logText);
	}
	public static void logCommunication()
	{
		// TODO
	}
	
	public static void logException()
	{
		// TODO
	}	
	……
}

這裡我暫定瞭一個公用的日志輸出接口名為log(),參數列表為日志的輸出等級、日志的類型以及日志內容。而通訊日志的輸出接口為logCommunication(),其參數列表暫時空著,按各位讀者的需求自行填寫,異常日志的輸出亦然。後文我會在測試部分對其填寫。

為瞭描述方便,我僅定義瞭兩個日志輸出目標,一為控制臺,二為文件。

三 代碼方式配置Log4j2日志對象

接下來是重頭戲,如果不采取配置文件的方式進行Log4j2的配置,那麼Log4j2會自行采用默認的配置,這不是我們想要的。雖然這個過程我們無法選擇,也規避不瞭,但是我們最後都是使用Logger對象來進行日志輸出的,所以我們隻需要按需構造Logger對象,並將其管理起來即可。

這裡提供一個思路,首先LogUtil維護一組線程級日志對象,然後這一組線程級日志對象共同訪問同一組Logger對象。

跟蹤源碼我發現Log4j2對Logger對象的構造還是較為復雜的,使用瞭大量的Builder,其實較為早期的版本中也提供瞭構造函數方式來初始化對象,但是後期的版本卻都被標記瞭@depreciation。對於Builder模式大傢自己查閱其他信息瞭解吧。

好消息是Log4j2的核心組件依然是Appender、Logger,隻不過提供瞭更多的可配置內容,包括日志同時按日期和文件大小進行備份,這才是我想要的,之前寫Log4j的時候我可是自己派生瞭一個Appender類才實現的同時備份。

向控制臺輸出的Appender具體類型為ConsoleAppender,我使用默認的構造函數來處理,這是唯一一個被公開出來,且沒有被@depreciation修飾的構造函數,想來隻要是能通過默認配置實現的都是被Log4j2認可的吧,不然為啥要弄這麼多Builder嘞。

向文件輸出的Appender我采用RollingFileAppender,無他,就是為瞭能夠實現同時按日期及文件大小進行備份,而且該Appender可適用全局異步模式,性能較AsyncAppender高瞭不少。它的創建方式要麻煩許多,因為我需要設置觸發器來控制何時觸發備份,以及備份策略。

整體設計如下:

private Logger createLogger(String loggerName)
{
		Appender appender = null;
		PatternLayout.Builder layoutBuilder = PatternLayout.newBuilder();
		layoutBuilder.withCharset(Charset.forName(DefaultCharset));
		layoutBuilder.withConfiguration(loggerConfiguration);
		Layout<String> layout = layoutBuilder.build();
		if (LogUtil.AppenderConsole == LogUtil.getAppender())
		{
			appender = ConsoleAppender.createDefaultAppenderForLayout(layout);
		}
		if (LogUtil.AppenderFile == LogUtil.getAppender())
		{
			RollingFileAppender.Builder<?> loggerBuilder = RollingFileAppender.newBuilder();
			if (LogUtil.getBufferSize() > 0)
			{
				loggerBuilder.withImmediateFlush(false);
				loggerBuilder.withBufferedIo(true);
				loggerBuilder.withBufferSize(LogUtil.getBufferSize());
				System.setProperty(AsyncPropKey, AsyncPropVal);
			}
			else
			{
				loggerBuilder.withImmediateFlush(true);
				loggerBuilder.withBufferedIo(false);
				loggerBuilder.withBufferSize(0);
				System.getProperties().remove(AsyncPropKey);
			}
			loggerBuilder.withAppend(true);
			loggerBuilder.withFileName(getFilePath(loggerName));
			loggerBuilder.withFilePattern(spellBackupFileName(loggerName));
			loggerBuilder.withLayout(layout);
			loggerBuilder.withName(loggerName);
			loggerBuilder.withPolicy(CompositeTriggeringPolicy.createPolicy(
					SizeBasedTriggeringPolicy.createPolicy(LogUtil.getFileSize()),
					TimeBasedTriggeringPolicy.createPolicy("1", "true")));
			loggerBuilder.withStrategy(DefaultRolloverStrategy.createStrategy(
					LogUtil.getBackupIndex() > 0 ? String.valueOf(LogUtil.getBackupIndex()) : "-1", "1",
					LogUtil.getBackupIndex() > 0 ? null : "nomax", null, null, true, loggerConfiguration));
			appender = loggerBuilder.build();
		}
		appender.start();
		loggerConfiguration.addAppender(appender);
		AppenderRef appenderRef = AppenderRef.createAppenderRef(loggerName, Level.ALL, null);
		AppenderRef[] appenderRefs = new AppenderRef[]
		{ appenderRef };
		LoggerConfig loggerConfig = LoggerConfig.createLogger(false, Level.ALL, loggerName, "false", appenderRefs, null,
				loggerConfiguration, null);
		loggerConfig.addAppender(appender, null, null);
		loggerConfiguration.addLogger(loggerName, loggerConfig);
		loggerContext.updateLoggers();
		loggerConfiguration.start();
		Logger logger = LogManager.getLogger(loggerName);
		return logger;
}

註意!!我在初始化Logger對象的時候,是根據LogUtil是否開啟瞭異步輸出模式來判定是否需要開啟全局異步模式的,這裡簡單說一些Log4j2的異步模式。

Log4j2提供瞭兩種異步模式,第一種是使用AsyncAppender,這種方式跟我以前寫的Log4j的異步輸出模式一樣,都是單獨開啟一個線程來輸出日志。第二種方式是AsychLogger,這個就厲害瞭,官推,而且官方提供瞭兩種使用模式,一個是混合異步,一個是全局異步,全局異步時不需要對配置文件進行任何改動,盡在應用啟動時添加一個jvm參數即可,並且據壓測數據顯示,全局異步模式下性能飆升。

更多的配置信息建議讀者朋友自行查閱官方文檔。

最後還有一點需要註意的是,Log4j2的全局異步是要依賴隊列緩存的,其實現采用的是disruptor,所以需要依賴這個外部jar,不然在初始化Logger對象的時候,你會看到相關異常。

貼一下依賴的Maven:

<dependency>
	<groupId>org.apache.logging.log4j</groupId>
	<artifactId>log4j-core</artifactId>
	<version>2.8.2</version>
</dependency>
<dependency>
	<groupId>org.apache.logging.log4j</groupId>
	<artifactId>log4j-api</artifactId>
	<version>2.8.2</version>
</dependency>
<dependency>
	<groupId>com.lmax</groupId>
	<artifactId>disruptor</artifactId>
	<version>3.3.6</version>
</dependency>

嚴重強調:AsyncAppender、AsyncLogger以及全局異步的System.property設置,不要同時出現!!!

最最後,我特麼還是要嘴賤囉嗦一下,不見得“異步”就是好的,你想想看異步模式無非是額外線程或者緩存來實現,這些也是要吃資源的,日志量大的場景下其帶來的收益很高,但小量日志場景下其對性能資源的消耗很可能大於其帶來的性能收益,請酌情使用。

四 線程級日志對象的設計

按上文中的設計思路,LogUtil持有一組線程級日志對象,而這一組日志對象又共享一組Logger對象。延續Log4j版本的設計,線程級日志對象類型依然為ThreadLogger,其基礎屬性為線程ID、日志類型以及日志的輸出級別等。

為瞭解決多並發問題,使用ConcurrentHashMap來存儲Logger對象,其Key值為線程ID,這裡需要註意的是ConcurrentHashMap的put操作盡管能保證可見性,但是不能保證操作的原子性,所以在其put操作上需要額外加鎖。

class ThreadLogger
{
	private static final String AsyncPropKey = "log4j2.contextSelector";
	private static final String AsyncPropVal = "org.apache.logging.log4j.core.async.AsyncLoggerContextSelector";
	private static final String DefaultCharset = "UTF-8";
	private static final String DefaultDateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS";
	private static final Object LoggerMapLock = new Object();
	private static final ConcurrentHashMap<String, Logger> LoggerMap = new ConcurrentHashMap<>();
	private static LoggerContext loggerContext = (LoggerContext) LogManager.getContext(false);
	private static Configuration loggerConfiguration = loggerContext.getConfiguration();
	public static void cleanLoggerMap()
	{
		synchronized (LoggerMapLock)
		{
			LoggerMap.clear();
		}
	}
	
	private Logger getLogger(String loggerName)
	{
		if (StringUtil.isEmpty(loggerName))
			return null;
		Logger logger = LoggerMap.get(loggerName);
		if (logger == null)
		{
			synchronized (LoggerMapLock)
			{
				logger = createLogger(loggerName);
				LoggerMap.put(loggerName, logger);
			}
		}
		return logger;
	}
}

五 標準日志頭

頭部內容應包含當前的日志輸出級別,日志打印所在的類名、行號,當前的線程號、進程號等信息。這裡簡單介紹下進程號及類名行號的獲取。

每個應用進程的進程號唯一,所以僅在第一次獲取該信息時獲取即可,類名行號則通過方法棧的棧信息獲取,如下:

private static String ProcessID = null;
private String logLocation = null;
public static String getProcessID()
{
	if (ProcessID == null)
	{
		ProcessID = ManagementFactory.getRuntimeMXBean().getName().split("@")[0];
	}
	return ProcessID;
}
private static String getLocation()
{
	StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
	StringBuilder builder = new StringBuilder(stackTraceElements[3].getClassName());
	builder.append(":");
	builder.append(stackTraceElements[3].getLineNumber());
	return builder.toString();
}

這裡需要註意,類名行號信息對於每一條日志都不盡相同,所以不能聲明為static,並且需要在LogUtil中獲取,並通過ThreadLogger的setLocation()方法傳入到ThreadLogger對象,最後由ThreadLogger的日志頭拼裝函數拼接到日志頭部信息中:

private String spellLogText(int logLevel, String logText)
{
	StringBuilder builder = new StringBuilder();
	SimpleDateFormat sdf = new SimpleDateFormat(DefaultDateFormat);
	builder.append(sdf.format(Calendar.getInstance().getTime()));
	builder.append("|");
	builder.append(LogUtil.LevelName[logLevel]);
	builder.append("|");
	builder.append(getProcessID());
	builder.append("|");
	builder.append(this.threadID);
	builder.append("|");
	builder.append(this.threadUUID);
	builder.append("|");
	builder.append(this.logLocation);
	builder.append("|");
	builder.append(logText);
	return builder.toString();
}

六 異常日志的堆棧信息打印

需要註意咯,異常的日志輸出略微復雜些,這也是我經常被人問起的一個問題,很多從事400或大機開發的同事轉入java開發後,最常問的就是異常堆棧的問題,看不懂,不知道怎麼來的。

這裡我隻提一個事情,Exception的構造,其成員有兩個,其一為cause,Throwable類型,其二為message,String類型,構造函數提供較多,請大傢自己做一個測試,看看不同構造其輸出的內容有何不同,cause和message成員又有何關系。

如果你弄明白瞭Exception的構造,那麼下面的邏輯不難理解:

private static String getExceptionStackTrace(Exception e)
{
	StringBuilder builder = new StringBuilder();
	builder.append(e.toString());
	StackTraceElement[] stackTraceElements = e.getStackTrace();
	for (int i = 0; i < stackTraceElements.length; i++)
	{
		builder.append("\r\nat ");
		builder.append(stackTraceElements[i].toString());
	}
	Throwable throwable = e.getCause();
	while (throwable != null)
	{
		builder.append("\r\nCaused by:");
		builder.append(throwable.toString());
		stackTraceElements = throwable.getStackTrace();
		for (int i = 0; i < stackTraceElements.length; i++)
		{
			builder.append("\r\nat ");
			builder.append(stackTraceElements[i].toString());
		}
		throwable = throwable.getCause();
	}
	return builder.toString();
}

七 測試

補充一個邏輯實現,這裡我以異常日志的打印作為測試接口,並在多線程並發場景下實現異步輸出。

異常日志輸出接口補充完整:

public static void logException(String desc, Exception exception)
{
	getThreadLogger().setLogLocation(getLocation());
	StringBuilder builder = new StringBuilder("Description=");
	builder.append(StringUtil.isEmpty(desc) ? "" : desc);
	builder.append(",Exception=");
	builder.append(getExceptionStackTrace(exception));
	log(LevelError, TypeException, builder.toString());
}

測試代碼:

public static void main(String[] args)
{
	LogUtil.setAppender(LogUtil.AppenderFile);
	LogUtil.setBufferSize(1024); // 1KB緩存
	LogUtil.setFileSize("1MB");
	LogUtil.setBackupIndex(10);
	LogUtil.setFilePath("C:\\log");
	for (int i = 0; i < 4; i++)
	{
		Thread thread = new Thread(new Runnable()
		{
			@Override
			public void run()
			{
				LogUtil.setModule(Thread.currentThread().getId() + "");
				for (int j = 0; j < 100000; j++)
				{
					LogUtil.logException("test", new Exception("my test"));
				}
			}
		});
		thread.start();
	}
}

測試結果:

日志目錄

日志內容

以上為個人經驗,希望能給大傢一個參考,也希望大傢多多支持WalkonNet。

推薦閱讀: