場景面試題:怎么寫一個 Android 日志庫?
原標(biāo)題:場景面試題:怎么寫一個 Android 日志庫?
安卓進(jìn)階漲薪訓(xùn)練營,讓一部分人先進(jìn)大廠
大家好,我是皇叔,最近開了一個安卓進(jìn)階漲薪訓(xùn)練營,可以幫助大家突破技術(shù)&職場瓶頸,從而度過難關(guān),進(jìn)入心儀的公司。
詳情見文章: 沒錯!皇叔開了個訓(xùn)練營
在面試中,這類題目稱為場景題,即就一個實際業(yè)務(wù)場景給出解決方案,難度較高,若無與之相關(guān)的實戰(zhàn)經(jīng)驗,非??简炁R場應(yīng)變及綜合運用儲備知識的能力。這篇就來分析下“寫一個 Log 需要考慮些哪些方面?”
先拋一個磚
簡單是任何一個庫設(shè)計都要考慮的首要問題。
接口設(shè)計
如果庫接口設(shè)計不合理,造成理解、接入、使用的高復(fù)雜度。那。。。這個庫就沒人用唄~
先來看這樣一個設(shè)計:
abstractclassULogBase: IULog {
privatevallogger bylazy {
ULogCore(initConfig)
}
overridefunv(tag: String, message: String) {
logger.printLog(Log.VERBOSE, tag, message)
}
overridefunv(tag: String, message: String, throwable: Throwable) {
logger.printLog(Log.VERBOSE, tag, message)
}
overridefuni(tag: String, message: String) {
logger.printLog(Log.INFO, tag, message)
}
overridefuni(tag: String, message: String, throwable: Throwable) {
logger.printLog(Log.INFO, tag, message, throwable)
}
overridefund(tag: String, message: String) {
logger.printLog(Log.DEBUG, tag, message)
}
overridefund(tag: String, message: String, throwable: Throwable) {
logger.printLog(Log.DEBUG, tag, message, throwable)
}
overridefunw(tag: String, message: String) {
logger.printLog(Log.WARN, tag, message)
}
overridefunw(tag: String, message: String, throwable: Throwable) {
logger.printLog(Log.WARN, tag, message, throwable)
}
overridefune(tag: String, message: String) {
logger.printLog(Log.ERROR, tag, message)
}
overridefune(tag: String, message: String, throwable: Throwable) {
logger.printLog(Log.ERROR, tag, message,throwable)
}
overridefuna(tag: String, message: String, tagReport: String) {
logger.printLog(Log.ASSERT, tag, message, tagReport = tagReport)
}
overridefuna(tag: String, message: String, throwable: Throwable, tagReport: String) {
logger.printLog(Log.ASSERT, tag, message, throwable, tagReport = tagReport)
}
abstractfuninitConfig: ULogConfig
}
這是 Log 庫對外公布的接口。它尊重了 Android 端打 Log 的習(xí)俗。畢竟 android.util.Log 已經(jīng)習(xí)以為常。使用新庫時,平滑過渡,不會有陌生感。
但這個接口設(shè)計有優(yōu)化的地方。
overridefunv(tag: String, message: String) {
logger.printLog(Log.VERBOSE, tag, message)
}
overridefunv(tag: String, message: String, throwable: Throwable) {
logger.printLog(Log.VERBOSE, tag, message, throwable)
}
同級別的 Log 輸出,聲明了2個方法。在 Java 中不得不這樣做,因參數(shù)不同而進(jìn)行 方法重載。
運用 Kotlin 的語法特性 參數(shù)默認(rèn)值和 可空類型,就能降低接口復(fù)雜度:
// 聲明參數(shù) throwable 是可空類型,并默認(rèn)賦予空值
overridefunv(tag: String, message: String, throwable: Throwable? = null) {
logger.printLog(Log.VERBOSE, tag, message, throwable)
}
把2個接口變?yōu)?個接口,而調(diào)用效果保持不變:
ULog.v( "test", "log")
ULog.v( "test", "log",Exception( "error"))
當(dāng)調(diào)用第一條語句時,Kotlin 默認(rèn)給 throwable 參數(shù)傳遞 null 值。
接入難度
這個接口設(shè)計,第二個尷尬點是 ULogBase 是抽象的,業(yè)務(wù)層不得不實例化才能使用。。。
這無疑增加了使用負(fù)擔(dān)。而這樣做的目的僅僅是為了“配置”,即根據(jù)不同的業(yè)務(wù)場景傳入不同的參數(shù):
objectNimLogger : ULogBase {
overridefuninitConfig: ULogConfig {
returnULogConfig.Builder(requireNotNull(NimConfig.context)) // 和 context 耦合
.module( "nim")
.debug(NimConfig.isDebug) // 和編譯類型耦合
.build
}
}
業(yè)務(wù)端必須重寫 initConfig 方法生成一個 ULogConfig 對象,用來表示業(yè)務(wù)配置。
看看業(yè)務(wù)配置包括哪些:
classULogConfigprivateconstructor(builder: Builder) {
valmodule = builder.mModule // 模塊名
valisDebug = builder.mIsDebug // 編譯類型
valcontext = builder.context // 上下文
vallogModulePath = builder.mLogModulePath // 路徑名
vallogRootPath = builder.mLogRootPath // 根路徑名
valenableToFile = builder.enableToFile // 是否寫文件
valisValidPath : Boolean
get{
return!TextUtils.isEmpty(logModulePath) && !TextUtils.isEmpty(logRootPath)
}
classBuilder( valcontext: Context) {
lateinitvarmModule: String
privateset
varmIsDebug: Boolean= true
privateset
varenableToFile: Boolean= true
privateset
varmLogModulePath: String = ""
privateset
varmLogRootPath: String = ""
privateset
funmodule(name: String) : Builder {
mModule = name
returnthis
}
fundebug(debug: Boolean) : Builder {
mIsDebug = debug
returnthis
}
funenableToFile(enable : Boolean) : Builder {
enableToFile = enable
returnthis
}
funbuild: ULogConfig {
if(mModule == null) {
throwIllegalArgumentException( "Be sure to set the module name")
}
if(TextUtils.isEmpty(mLogModulePath)) {
mLogModulePath = getLogPath(mModule)
}
if(TextUtils.isEmpty(mLogRootPath)) {
mLogRootPath = getLogPath
}
returnULogConfig( this)
}
privatefungetLogPath(module : String= "") : String {
returnif(mIsDebug) {
File(context.filesDir, "uLog/debug/ $module" ).absolutePath
} else{
File(context.filesDir, "uLog/release/ $module" ).absolutePath
}
}
}
}
業(yè)務(wù)配置信息包括,模塊名、編譯類型、上下文、路徑名、是否寫文件。
其中前 4 個參數(shù)是為了給不同模塊的日志生成不同的日志路徑。這個設(shè)計,見仁見智~
優(yōu)點是信息降噪,只關(guān)注想關(guān)注模塊的日志。缺點是,大部分業(yè)務(wù)模塊都和多個底層功能模塊耦合,大部分問題是綜合性問題,需要關(guān)注整個鏈路上所有模塊的日志。
我偏好的策略是,將所有日志打入一個文件,通過 tag 來區(qū)分模塊,如果只想關(guān)注某個模塊的日志,sublime 可以方便地選中包含指定 tag 的所有行,一個復(fù)制粘貼就完成了信息降噪。畢竟想分開是輕而易舉的事情,但是想合并就很難了~
復(fù)雜度(建造者模式)
Kotlin 相較于 Java 的最大優(yōu)勢就是降低復(fù)雜度。在庫接口設(shè)計及內(nèi)部實現(xiàn)時就要充分發(fā)揮 Kotlin 的優(yōu)勢,比如 Kotlin 的世界里已不需要 Builder 模式了。
Builder 模式有如下優(yōu)勢:
為參數(shù)標(biāo)注語義:在Builder模式中,每個屬性的賦值都是一個函數(shù),函數(shù)名標(biāo)注了屬性語義。
可選參數(shù)&分批賦值:Builder模式中,除了必選參數(shù),其他參數(shù)是可選的,可分批賦值。而直接使用構(gòu)造函數(shù)必須一下子為所有參數(shù)賦值。
增加參數(shù)約束條件:可以在參數(shù)不符合要求時,拋出異常。
但 Builder 模式也有代價,新增了一個中間類 Builder 。
使用 Kotlin 的命名參數(shù)+參數(shù)默認(rèn)值+require語法,在沒有任何副作用的情況下就能實現(xiàn) Builder 模式:
classPerson(
valname: String,
//為以下可選參數(shù)設(shè)置默認(rèn)值
valgender: Int= 1,
valage: Int= 0,
valheight: Int= 0,
valweight: Int= 0
)
//使用命名參數(shù)構(gòu)建 Person 實例
valp = Person(name = “taylor”,gender = 1,weight = 43)
命名參數(shù)為每個實參賦予了語義,而且不需要按構(gòu)造方法中聲明的順序來賦值,可以跳著來。
如果想增加參數(shù)約束條件可以調(diào)用 require 方法:
dataclassPerson(
// 這個是必選參數(shù)
valname: String,
valgender: Int= 1,
valage: Int= 0,
valheight: Int= 0,
valweight: Int= 0
){
//在構(gòu)造函數(shù)被調(diào)用的時候執(zhí)行參數(shù)合法檢查
init{
require(name.isNotEmpty){”name cant be empty“}
}
}
此時如果像下面這樣構(gòu)造 Person,則會拋出異常:
valp = Person(name= "",gender = 1)
java.lang.IllegalArgumentException: name cant be empty
本來在 build 方法中執(zhí)行的額外初始化邏輯也可以全部寫在 init 代碼塊中。
最后這個庫,在具體打印日志時的操作也及其復(fù)雜(不知道你能不能一下子看明白這 log 是怎么打的?):
internalclassULogCore( privatevalconfig: ULogConfig) {
companionobject{
constvalDELETE_DAY_NUMBER = 7
constvalTAG_LOG = "ULogCore"
constvalSUFFIX = ".java"
constvalTAG_PARAM = "param"
constvalKEY_ASSET_MSG = "key_asset_message"
constvalSTACK_TRACK_INDEX = 7
valtag_log_type = arrayOf( "VERBOSE", "DEBUG", "INFO", "WARN", "ERROR", "ASSERT")
varinitOperation = AtomicBoolean( false)
varlogDateFormat = SimpleDateFormat( "yyyy-MM-dd", Locale.CHINA)
}
funprintLog(
type: Int,
tagText: String,
message: String,
throwable: Throwable? = null,
tagReport: String? = ""
) {
valstackTrace = Thread.currentThread.stackTrace
CoroutineScope(Dispatchers.Default).launch {
valfinalMessage = processMessage(type, message, stackTrace)
valtagFinal = if(TextUtils.isEmpty(tagText)) TAG_LOG elsetagText
if(config.isDebug) {
when(type) {
Log.VERBOSE -> Log.v(tagFinal, finalMessage, throwable)
Log.DEBUG -> Log.d(tagFinal, finalMessage, throwable)
Log.INFO -> Log.i(tagFinal, finalMessage, throwable)
Log.WARN -> Log.w(tagFinal, finalMessage, throwable)
Log.ERROR -> Log.e(tagFinal, finalMessage, throwable)
Log.ASSERT -> Log.e(tagFinal, finalMessage, throwable)
else-> {
}
}
}
vallogDir = File(config.logModulePath)
if(!logDir.exists || logDir.isFile) {
logDir.mkdirs
}
if(!initOperation.getAndSet( true)) {
valfilterDates = ArrayList<String>.apply {
for(i in0..DELETE_DAY_NUMBER) {
valcalendar = Calendar.getInstance
calendar.add(Calendar.DAY_OF_MONTH, -i)
valdate = logDateFormat.format(calendar.time)
Log.i(TAG_LOG, date)
add(date)
}
}
config.logRootPath.takeIf { !TextUtils.isEmpty(it) }?.run {
vallist = ArrayList<File>
File( this).listFiles?.forEach { file ->
Log.i(TAG_LOG, "module $this" )
file.listFiles?.filter { child ->
!child.isMatchDateFile(filterDates)
}?.apply {
list.addAll( this)
}
}
list
}?.run {
forEach {
Log.i(TAG_LOG, "delete file ${it.name}" )
it.delete
}
}
if(!ULogFwHThread.isAlive) {
ULogFwHThread.start
}
}
if(config.enableToFile) {
if(config.isValidPath) {
varformatReportTag = ""
if(type == Log.ASSERT && tagReport != null&& tagReport.isNotEmpty) {
varassetMsgSet = MMKV.defaultMMKV.getStringSet(KEY_ASSET_MSG, HashSet)
valrawMsg = message+tagReport
Log.i(TAG_LOG, "asset md5 raw message $rawMsg" )
valkey = ULogUtils.getMD5(rawMsg, false)
Log.i(TAG_LOG, "assetMsgSet is $assetMsgSet, asset message key is $key" )
formatReportTag = "[# $tagReport#]"
if(assetMsgSet?.contains(key) == true) {
Log.i(TAG_LOG, "This log is asset mode and has save to file once, so ignore it.")
} else{
assetMsgSet?.add(key)
MMKV.defaultMMKV.putStringSet(KEY_ASSET_MSG, assetMsgSet)
EventBus.getDefault.post(
ULogTagEvent(
tagReport,
logDateFormat.format(Date(System.currentTimeMillis))
)
)
}
}
ULogFwHThread.addEntry(
ULogEntry(
config,
type,
"[ $tagFinal] $formatReportTag: $message" ,
throwable
)
)
} else{
Log.i(TAG_LOG, "printLog invalid log file path logRootPath : "+
" ${config.logRootPath}logModulePath : ${config.logModulePath}" )
}
} else{
Log.i(TAG_LOG, "enableToFile is closed")
}
}
}
privatefunprocessMessage(
type: Int,
msg: String,
stackTraceElement: Array< StackTraceElement>
) : String {
funtypeTag(logType: Int) : String? {
returnif(logType >= Log.VERBOSE && logType <= Log.ASSERT) tag_log_type[logType - 2] else"LOG"
}
valtargetElement = stackTraceElement[STACK_TRACK_INDEX]
varfileName = targetElement.fileName
if(TextUtils.isEmpty(fileName)) {
varclassName = targetElement.className
valclassNameInfo = className.split( "\.".toRegex).toTypedArray
if(classNameInfo.isNotEmpty) {
className = classNameInfo[classNameInfo.size - 1]
}
if(className.contains( "$")) {
className = className.split( "\$".toRegex).toTypedArray[ 0]
}
fileName = className + SUFFIX
}
valmethodName = targetElement.methodName
varlineNumber = targetElement.lineNumber
if(lineNumber < 0) {
lineNumber = 0
}
valheadString = String.format( "[ (%s:%s) => %s ]", fileName, lineNumber, methodName)
valthreadInfo = String.format( "thread - {{ %s }}", Thread.currentThread.name)
valsb = StringBuilder(headString).apply {
append( "\n╔═════════════════════════════════════════════════════════════════════════════════════════")
append( "\n║ ").append(threadInfo)
append( "\n╟┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈")
append( "\n║ [").append(typeTag(type)).append( "] - ").append(msg)
append( "\n╚═════════════════════════════════════════════════════════════════════════════════════════")
}
returnsb.toString
}
}
printLog 是每次打日志都會調(diào)用的方法,在這個方法里面做了很多很多事情~,包括美化日志、向 Logcat 輸出日志、創(chuàng)建日志文件夾、刪除過期日志、寫日志到文件、將日志上傳云。
先不說違反了單一職責(zé)原則造成的高復(fù)雜度,高理解成本,同時也不能滿足下面這些需求:
自定義我的日志美化樣式。(畢竟我們不一樣,有不同的審美)
在不改庫的前提下,更換寫文件方式(比如更高性能的I/O庫)。
在不改庫的前提下,更換上傳云邏輯。(從阿里云換成亞馬遜云)
在不改庫的前提下,增加本地日志加密。
在不改庫的前提下,修改日志文件的根目錄。
這些動態(tài)的配置效果,應(yīng)該是一個庫具備的彈性。如果動不動就要改庫,它還配叫庫?
如果一定要給 “一個庫的基本修養(yǎng)”提兩條綱領(lǐng)的話,我愿意把 “簡單”和 “彈性”放在首位。
高可擴(kuò)展看完剛才的磚之后,我再拋一個磚。
objectEasyLog {
privateconstvalVERBOSE = 2
privateconstvalDEBUG = 3
privateconstvalINFO = 4
privateconstvalWARN = 5
privateconstvalERROR = 6
privateconstvalASSERT = 7
// 攔截器列表
privatevallogInterceptors = mutableListOf<LogInterceptor>
fund(message: String, tag: String= "", varargargs: Any) {
log(DEBUG, message, tag, *args)
}
fune(message: String, tag: String= "", varargargs: Any, throwable: Throwable? = null) {
log(ERROR, message, tag, *args, throwable = throwable)
}
funw(message: String, tag: String= "", varargargs: Any) {
log(WARN, message, tag, *args)
}
funi(message: String, tag: String= "", varargargs: Any) {
log(INFO, message, tag, *args)
}
funv(message: String, tag: String= "", varargargs: Any) {
log(VERBOSE, message, tag, *args)
}
funwtf(message: String, tag: String= "", varargargs: Any) {
log(ASSERT, message, tag, *args)
}
// 注入攔截器
funaddInterceptor(interceptor: LogInterceptor) {
logInterceptors.add(interceptor)
}
// 從頭部注入攔截器
funaddFirstInterceptor(interceptor: LogInterceptor) {
logInterceptors.add( 0, interceptor)
}
}
日志庫對上層的接口封裝在一個單例 EasyLog 里。
日志庫提供了和 android.util.Log 幾乎一樣的打印接口,但增加了一個可變參數(shù) args ,這是為了方便地為字串的通配符賦值。
假責(zé)任鏈模式
日志庫還提供了一個新接口 addInterceptor ,用于動態(tài)地注入日志攔截器:
interfaceLogInterceptor{
// 進(jìn)行日志
funlog(priority: Int, tag: String, log: String)
// 是否允許進(jìn)行日志
funenable: Boolean
}
日志攔截器是一個接口,定義了兩個抽象的行,為分別是進(jìn)行日志和是否允許日志。
所有的日志接口都將打印日志委托給了 log 方法:
objectEasyLog {
@Synchronized
privatefunlog(
priority: Int,
message: String,
tag: String,
varargargs: Any,
throwable: Throwable? = null
) {
// 為日志通配符賦值
varlogMessage = message.format(*args)
// 如果有異常,則讀取異常堆棧拼接在日志字串后面
if(throwable != null) {
logMessage += getStackTraceString(throwable)
}
// 遍歷日志攔截器,將日志打印分發(fā)給所有攔截器
logInterceptors.forEach { interceptor ->
if(interceptor.enable) interceptor.log(priority, tag, logMessage)
}
}
// 對 String.format 的封裝,以求簡潔
funString. format( varargargs: Any) =
if(args.isNullOrEmpty) thiselseString.format( this, *args)
// 讀取堆棧
privatefungetStackTraceString(tr: Throwable?) : String {
if(tr == null) {
return""
}
vart = tr
while(t != null) {
if(t isUnknownHostException) {
return""
}
t = t.cause
}
valsw = StringWriter
valpw = PrintWriter(sw)
tr.printStackTrace(pw)
pw.flush
returnsw.toString
}
}
這里用了同步方法,為了防止多線程調(diào)用時日志亂序。
這里還運用了 責(zé)任鏈模式(假的),使得 EasyLog 和日志處理的具體邏輯解耦,它只是持有一組日志攔截器,當(dāng)有日志請求時,就分發(fā)給所有的攔截器。
這樣做的好處就是,可以在業(yè)務(wù)層動態(tài)地為日志組件提供新的功能。
當(dāng)然日志庫得提供一些基本的攔截器,比如將日志輸出到 Logcat:
openclassLogcatInterceptor: LogInterceptor {
overridefunlog(priority: Int, tag: String, log: String) {
Log.println(priority, tag, log)
}
overridefunenable: Boolean{
returntrue
}
}
Log.println(priority, tag, log) 是 android.util.Log 提供的,按優(yōu)先級將日志輸出到 Logcat 的方法。使用這個方法可以降低復(fù)雜度,因為不用寫類似下面的代碼:
when(priority){
VERBOSE -> Log.v(...)
ERROR -> Log.e(...)
}
之所以要將 LogcatInterceptor 聲明為 open,是因為業(yè)務(wù)層有動態(tài)重寫 enable 方法的需求:
EasyLog.addInterceptor( object: LogcatInterceptor {
overridefunenable: Boolean{
returnBuildConfig.DEBUG
}
})
這樣就把日志輸出到 Logcat 的開關(guān) 和 build type 聯(lián)系起來了,就不需要將 build type 作為 EasyLog 的一個配置字段了。
除了輸出到 Logcat,另一個基本需求就是日志文件化,新建一個攔截器:
classFileWriterLogInterceptor
privateconstructor( privatevardir: String) : LogInterceptor {
privatevalhandlerThread = HandlerThread( "log_to_file_thread")
privatevalhandler: Handler
privatevaldispatcher: CoroutineDispatcher
// 帶參單例
companionobject{
@Volatile
privatevarINSTANCE: FileWriterLogInterceptor? = null
fungetInstance(dir: String) : FileWriterLogInterceptor =
INSTANCE ?: synchronized( this) {
INSTANCE ?: FileWriterLogInterceptor(dir).apply { INSTANCE = this}
}
}
init{
// 啟動日志線程
handlerThread.start
handler = Handler(handlerThread.looper)
// 將 handler 轉(zhuǎn)換成 Dispatcher
dispatcher = handler.asCoroutineDispatcher( "log_to_file_dispatcher")
}
overridefunlog(priority: Int, tag: String, log: String) {
// 啟動協(xié)程串行地將日志寫入文件
if(!handlerThread.isAlive) handlerThread.start
GlobalScope.launch(dispatcher) {
FileWriter(getFileName, true).use {
it.append( "[ $tag] $log" )
it.append( "\n")
it.flush
}
}
}
overridefunenable: Boolean{
returntrue
}
privatefungetToday: String =
SimpleDateFormat( "yyyy-MM-dd").format(Calendar.getInstance.time)
privatefungetFileName= " $dir${File.separator}${getToday}.log"
}
日志寫文件的思路是: “異步串行地將字串通過流輸出到文件中”。
異步化是為了不阻塞主線程,串行化是為了保證日志順序。HandlerThread 就很好地滿足了異步化串行的要求。
為了簡化“將日志作為消息發(fā)送到異步線程中”這段代碼,使用了協(xié)程,這樣代碼就轉(zhuǎn)變成:每次日志請求到來時,啟動協(xié)程,在其中完成創(chuàng)建流、輸出到流、關(guān)閉流。隱藏了收發(fā)消息的復(fù)雜度。
日志攔截器被設(shè)計為單例,目的是讓 App 內(nèi)存中只存在一個寫日志的線程。
日志文件的路徑由構(gòu)造方法傳入,這樣就避免了日志攔截器和 Context 的耦合。
use 是 Closeable 的擴(kuò)展方法,它隱藏了流操作的 try-catch,降低了復(fù)雜度
然后業(yè)務(wù)層就可以像這樣動態(tài)地為日志組件添加寫文件功能:
classMyApplication: Application{
overridefunonCreate{
super.onCreate
// 日志文件路徑
valdir = this.filesDir.absolutePath
// 構(gòu)造日志攔截器單例
valinterceptor = FileWriterLogInterceptor.getInstance(dir)
// 注入日志攔截器單例
EasyLog.addInterceptor(interceptor)
}
}
真責(zé)任鏈模式
還有一個對日志庫的基本訴求就是“美化日志”。
還是重新定義一個攔截器:
// 調(diào)用堆棧攔截器
classCallStackLogInterceptor: LogInterceptor {
companionobject{
privateconstvalHEADER =
"┌──────────────────────────────────────────────────────────────────────────────────────────────────────"
privateconstvalFOOTER =
"└──────────────────────────────────────────────────────────────────────────────────────────────────────"
privateconstvalLEFT_BORDER = │
// 用于過濾日志調(diào)用棧(將EasyLog中的類過濾)
privatevalblackList = listOf(
CallStackLogInterceptor:: class. java. name,
EasyLog::class.java.name
)
}
overridefunlog(priority: Int, tag: String, log: String) {
// 打印頭部
Log.println(priority, tag, HEADER)
// 打印日志
Log.println(priority, tag, " $LEFT_BORDER$log" )
// 打印堆棧信息
getCallStack(blackList).forEach {
valcallStack = StringBuilder
.append(LEFT_BORDER)
.append( "\t ${it}" ).toString
Log.println(priority, tag, callStack)
}
// 打印尾部
Log.println(priority, tag, FOOTER)
}
overridefunenable: Boolean{
returntrue
}
}
對于業(yè)務(wù)層的一條日志,調(diào)用堆棧攔截器輸出了好幾條日志,依次是頭部、日志、堆棧、尾部。
為了降低復(fù)雜度,把獲取調(diào)用棧的邏輯單獨抽象為一個方法:
fungetCallStack(blackList: List< String>) : List<String> {
returnThread.currentThread
.stackTrace.drop( 3) // 獲取調(diào)用堆棧,過濾上面的3個,因為它們就是這里看到3個方法
.filter { it.className ! inblackList } // 過濾黑名單
.map { " ${it.className}. ${it.methodName}( ${it.fileName}: ${it.lineNumber})" }
}
按照上面 map 中那樣的格式,在 Logcat 中就能有點擊跳轉(zhuǎn)效果。
然后把調(diào)用堆棧攔截器插入到所有攔截器的頭部:
EasyLog.addFirstInterceptor(CallStackLogInterceptor)
打印效果是這樣的:
看上去還不錯~,但當(dāng)我打開日志文件后,卻只有 DemoActivity.onCreate 這一行日志,并沒有堆棧信息。。。
原因就在于我用了一個 假的責(zé)任鏈模式!!
責(zé)任鏈模式就好比“老師發(fā)考卷”:
真責(zé)任鏈?zhǔn)沁@樣發(fā)考卷的:老師將考卷遞給排頭同學(xué),排頭同學(xué)再傳遞給第二個同學(xué),如此往復(fù),直到考卷最終遞到我的手里。
假責(zé)任鏈?zhǔn)沁@樣發(fā)考卷的:老師站講臺不動,叫到誰的名字,誰就走上去拿自己的考卷。
學(xué)生時代當(dāng)然希望老師用假責(zé)任鏈模式發(fā)考卷,因為若用真責(zé)任鏈,前排的每一個同學(xué)都可以看到我的分?jǐn)?shù),還能在我的考卷上亂涂亂畫。
但在打日志這個場景中,用假責(zé)任鏈模式的后果就是, 后續(xù)攔截器拿不到前序攔截器的處理結(jié)果。
其實它根本稱不上責(zé)任鏈,因為就不存在“鏈”,至多是一個“策略模式的遍歷”
所以只能用真責(zé)任鏈重構(gòu),為了向后傳遞攔截器的處理結(jié)果, 攔截器就得持有其后續(xù)攔截器:
interfaceLogInterceptor{
// 后續(xù)攔截器
varnextInterceptor:LogInterceptor?
funlog(priority: Int, tag: String, log: String)
funenable: Boolean
}
然后在具體的攔截器實例中實現(xiàn)這個抽象屬性:
openclassLogcatInterceptor: LogInterceptor {
overridevarnextInterceptor: LogInterceptor? = null
get= field
set(value) {
field = value
}
overridefunlog(priority: Int, tag: String, log: String) {
if(enable) {
Log.println(priority, tag, log)
}
// 將log請求傳遞給下一個攔截器
nextInterceptor?.log(priority, tag, log)
}
overridefunenable: Boolean{
returntrue
}
}
因為所有的攔截器都通過鏈的方式連接,所以 EasyLog 就不需要再持有一組攔截器,而只需要持有頭攔截器就好了:
objectEasyLog {
// 頭攔截器
privatevallogInterceptor : LogInterceptor? = null
// 注入攔截器
funsetInterceptor(interceptor: LogInterceptor) {
logInterceptor = interceptor
}
}
注入攔截器的代碼也需要做相應(yīng)的變化:
classMyApplication: Application{
overridefunonCreate{
super.onCreate
// 構(gòu)建所有攔截器
valdir = this.filesDir.absolutePath
valfileInterceptor = FileWriterLogInterceptor.getInstance(dir)
vallogcatInterceptor = LogcatInterceptor
valcallStackLogInterceptor = CallStackLogInterceptor
// 安排攔截器鏈接順序
callStackLogInterceptor.nextInterceptor = logcatInterceptor
logcatInterceptor.nextInterceptor = fileInterceptor
// 將頭部攔截器注入
EasyLog.setInterceptor(callStackLogInterceptor)
}
}
這個設(shè)計能滿足功能要求,但是對于接入方來說,復(fù)雜度有點高,因為不得不手動安排攔截器的順序,而且如果想改動攔截器的順序也非常麻煩。
我想到了 OkHttp,它也采用了真攔截器模式,但并不需要手動安排攔截器的順序。它是怎么做到的?由于篇幅原因,源碼分析不展開了,感興趣的同學(xué)可以點開 okhttp3.internal.http.RealInterceptorChain 。
下面運用這個思想,重構(gòu)一下 EasyLog。
首先要新建一條鏈:
classChain(
// 持有一組攔截器
privatevalinterceptors: List<LogInterceptor>,
// 當(dāng)前攔截器索引
privatevalindex: Int= 0
) {
// 將日志請求在鏈上傳遞
funproceed(priority: Int, tag: String, log: String) {
// 用一條新的鏈包裹鏈上的下一個攔截器
valnext = Chain(interceptors, index + 1)
// 執(zhí)行鏈上當(dāng)前的攔截器
valinterceptor = interceptors.getOrNull(index)
// 執(zhí)行當(dāng)前攔截器邏輯,并傳入新建的鏈
interceptor?.log(priority, tag, log, next)
}
}
鏈持有一組攔截器和索引,其中索引表示當(dāng)前需要執(zhí)行的是哪個攔截器。
鏈包含一個 procee 方法,它是讓日志請求在鏈上傳遞起來的關(guān)鍵。每次執(zhí)行該方法都會新建一個鏈條并將索引+1,下次通過該鏈條獲取的攔截器就是“下一個攔截器”。緊接著根據(jù)當(dāng)前索引獲取當(dāng)前攔截器,將日志請求傳遞給它的同時,將“下一個攔截器”以鏈條的形式也傳遞給它。
重構(gòu)之后的調(diào)用棧攔截器如下:
classCallStackLogInterceptor: LogInterceptor {
companionobject{
privateconstvalHEADER =
"┌──────────────────────────────────────────────────────────────────────────────────────────────────────"
privateconstvalFOOTER =
"└──────────────────────────────────────────────────────────────────────────────────────────────────────"
privateconstvalLEFT_BORDER = │
privatevalblackList = listOf(
CallStackLogInterceptor:: class. java. name,
EasyLog::class.java.name,
Chain::class.java.name,
)
}
overridefunlog(priority: Int, tag: String, log: String, chain: Chain) {
// 將日志請求傳遞給下一個攔截器
chain.proceed(priority, tag, HEADER)
// 將日志請求傳遞給下一個攔截器
chain.proceed(priority, tag, " $LEFT_BORDER$log" )
getCallStack(blackList).forEach {
valcallStack = StringBuilder
.append(LEFT_BORDER)
.append( "\t ${it}" ).toString
// 將日志請求傳遞給下一個攔截器
chain.proceed(priority, tag, callStack)
}
// 將日志請求傳遞給下一個攔截器
chain.proceed(priority, tag, FOOTER)
}
overridefunenable: Boolean{
returntrue
}
}
和之前假責(zé)任鏈的區(qū)別在于,將已經(jīng)美化過的日志傳遞給了后續(xù)攔截器,其中就包括文件日志攔截器,這樣寫入文件的日志也被美化了。
EasyLog 也需要做相應(yīng)的改動:
objectEasyLog {
// 持有一組攔截器
privatevallogInterceptors = mutableListOf<LogInterceptor>
// 將所有日志攔截器傳遞給鏈條
privatevalinterceptorChain = Chain(logInterceptors)
funaddInterceptor(interceptor: LogInterceptor) {
logInterceptors.add(interceptor)
}
funaddFirstInterceptor(interceptor: LogInterceptor) {
logInterceptors.add( 0, interceptor)
}
funremoveInterceptor(interceptor: LogInterceptor) {
logInterceptors.remove(interceptor)
}
@Synchronized
privatefunlog(
priority: Int,
message: String,
tag: String,
varargargs: Any,
throwable: Throwable? = null
) {
varlogMessage = message.format(*args)
if(throwable != null) {
logMessage += getStackTraceString(throwable)
}
// 日志請求傳遞給鏈條
interceptorChain.proceed(priority, tag, logMessage)
}
}
這樣一來上層注入攔截的代碼不需要更改,還是按序 add 就好,將攔截器形成鏈條的復(fù)雜度被隱藏在 EasyLog 的內(nèi)部。
高性能 I/O
有時候為了排查線上偶現(xiàn)問題,會盡可能地在關(guān)鍵業(yè)務(wù)點打 Log 并文件化,再上傳到云以便排查。在一些高強(qiáng)度業(yè)務(wù)場景中的高頻模塊,比如直播間中的 IM,瞬間產(chǎn)生幾百上千條 Log 是家常便飯,這就對 Log 庫的性能提出了要求(CPU 和內(nèi)存)。
Okio
如何高性能地 I/O?
第一個想到的是Okio。關(guān)于為啥 Okio 的性能與好于 java.io 包的流,之后會專門寫一篇分析文章。
重新寫一個 Okio 版本的日志攔截器:
class OkioLogInterceptor private constructor(private var dir: String) : LogInterceptor {
private val handlerThread = HandlerThread( "log_to_file_thread")
private val handler: Handler
private val dispatcher: CoroutineDispatcher
// 寫日志的開始時間
var startTime = System.currentTimeMillis
companion object {
@Volatile
private var INSTANCE: OkioLogInterceptor? = null
fun getInstance(dir: String): OkioLogInterceptor =
INSTANCE ?: synchronized(this) {
INSTANCE ?: OkioLogInterceptor(dir).apply { INSTANCE = this }
}
}
init {
handlerThread.start
handler = Handler(handlerThread.looper)
dispatcher = handler.asCoroutineDispatcher( "log_to_file_dispatcher")
}
override fun log(priority: Int, tag: String, log: String){
if(!handlerThread.isAlive) handlerThread.start
GlobalScope.launch(dispatcher) {
// 使用 Okio 寫文件
val file = File(getFileName)
file.sink( true).buffer.use {
it.writeUtf8( "[ $tag] $log" )
it.writeUtf8( "\n")
}
// 寫日志結(jié)束時間
if( log== "work done") Log.v( "ttaylor1", "log work is done= ${System.currentTimeMillis - startTime}" )
}
returnfalse
}
private fun getToday: String =
SimpleDateFormat( "yyyy-MM-dd").format(Calendar.getInstance.time)
private fun getFileName = " $dir${File.separator}${getToday}.log"
}
FileWriter vs Okio 性能 PK
為了測試 Okio 和 FileWriter 的性能差異,編寫了如下測試代碼:
classLogActivity: AppCompatActivity{
overridefunonCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 添加 FileWriter 或者 Okio 日志攔截器
EasyLog.addInterceptor(OkioLogInterceptor.getInstance( this.filesDir.absolutePath))
// EasyLog.addInterceptor(FileWriterLogInterceptor.getInstance(this.filesDir.absolutePath))
// 重復(fù)輸出 1 萬條短 log
MainScope.launch(Dispatchers.Default) { count ->
repeat( 10000){
EasyLog.v( "test log count= $count" )
}
EasyLog.v( "work done")
}
}
}
測試方案:重復(fù)輸入 1 萬條短 log,并且從寫第一條 log 開始計時,直到最后一條 log 的 I/O 完成時輸出耗時。
Okio 和 FileWriter 三次測試的對比耗時如下:
// okio
ttaylor1: log work is done=9600
ttaylor1: log work is done=9822
ttaylor1: log work is done=9411
// FileWriter
ttaylor1: log work is done=10688
ttaylor1: log work is done=10816
ttaylor1: log work is done=11928
看上去 Okio 在耗時上有微弱的優(yōu)勢。但當(dāng)我把單條 Log 的長度增加 300 倍之后,測試結(jié)果出現(xiàn)了反轉(zhuǎn):
// FileWriter
ttaylor1: logwork is done=13569
ttaylor1: logwork is done=12654
ttaylor1: logwork is done=13152
// okio
ttaylor1: logwork is done=14136
ttaylor1: logwork is done=15451
ttaylor1: logwork is done=15292
也就是說 Okio 在高頻少量 I/O 場景性能好于 FileWriter,而高頻大量 I/O 場景下沒有性能優(yōu)勢。這個結(jié)果讓我很困惑,于是乎我在 Github 上提了 issue:
Okio is slower when writing long strings into file frequently compared with FileWriter · Issue #1098 · square/okio
沒想到瞬間就被回復(fù)了:
我的提問的本意是想確認(rèn)下使用 Okio 的姿勢是否得當(dāng),但沒想到官方回答卻是:“Okio 庫就是這樣的,你的測試數(shù)據(jù)是符合預(yù)期的。我們在“用自己的 UTF-8 編碼以減少大量垃圾回收”和性能上做了權(quán)衡。所以導(dǎo)致有些測試場景下性能好,有些場景下性能沒那么好。我們的期望是在真實的業(yè)務(wù)場景下 Okio 的性能會表現(xiàn)的好?!?/p>
不知道我翻譯的準(zhǔn)不準(zhǔn)確,若有誤翻望英語大佬指點~
這樣的結(jié)果不能讓我滿意,隱約覺得 Okio 的使用方式有優(yōu)化空間,于是我一直凝視下面這段代碼:
overridefunlog(priority: Int, tag: String, log: String) {
GlobalScope.launch(dispatcher) {
valfile = File(getFileName)
file.sink( true).buffer.use {
it.writeUtf8( "[ $tag] $log" )
it.writeUtf8( "\n")
}
}
returnfalse
}
代碼潔癖告訴我,這里有幾個可以優(yōu)化的地方:
不用每次打 Log 都新建 File 對象。不用每次打 Log 都新建輸出流,每次打完就關(guān)閉流。
于是我改造了 Okio 日志攔截器:
classOkioLogInterceptorprivateconstructor( privatevardir: String) : LogInterceptor {
privatevalhandlerThread = HandlerThread( "log_to_file_thread")
privatevalhandler: Handler
privatevaldispatcher: CoroutineDispatcher
privatevarbufferedSink: BufferedSink? = null
// 初始化時,只新建一次文件
privatevarlogFile = File(getFileName)
varstartTime = System.currentTimeMillis
companionobject{
@Volatile
privatevarINSTANCE: OkioLogInterceptor? = null
fungetInstance(dir: String) : OkioLogInterceptor =
INSTANCE ?: synchronized( this) {
INSTANCE ?: OkioLogInterceptor(dir).apply { INSTANCE = this}
}
}
init{
handlerThread.start
handler = Handler(handlerThread.looper)
dispatcher = handler.asCoroutineDispatcher( "log_to_file_dispatcher")
}
overridefunlog(priority: Int, tag: String, log: String) {
if(!handlerThread.isAlive) handlerThread.start
GlobalScope.launch(dispatcher) {
valsink = checkSink
sink.writeUtf8( "[ $tag] $log" )
sink.writeUtf8( "\n")
if(log == "work done") Log.v( "ttaylor1", "log work is ok done= ${System.currentTimeMillis - startTime}" )
}
returnfalse
}
privatefungetToday: String =
SimpleDateFormat( "yyyy-MM-dd").format(Calendar.getInstance.time)
privatefungetFileName= " $dir${File.separator}${getToday}.log"
// 不關(guān)流,復(fù)用 bufferedSink 對象
privatefuncheckSink: BufferedSink {
if(bufferedSink == null) {
bufferedSink = logFile.appendingSink.buffer
}
returnbufferedSink!!
}
}
新增了成員變量 logFile 和 bufferedSink,避免了每次打 Log 時重新構(gòu)建它們。而且我沒有在每次打完 Log 就把輸出流關(guān)閉掉。重新跑一下測試代碼,奇跡發(fā)生了:
ttaylor1: log work isdone= 832
1萬條 Log 寫入的時間從 9411 ms 縮短到 832 ms,整整縮短了 11 倍?。?/strong>
這個結(jié)果有點難以置信,我連忙從手機(jī)中拉取了日志文件,非常擔(dān)心是因為程序 bug 導(dǎo)致日志輸出不全。
果不其然,正確的日志文件應(yīng)該包含 1 萬行,而現(xiàn)在只有 9901 行。
轉(zhuǎn)念一下,不對啊,一次flush都沒有調(diào)用,為啥文件中會有日志?
哦~,肯定是因為內(nèi)存中的輸出緩沖區(qū)滿了之后自動進(jìn)行了 flush 操作。
然后我用同樣的思路寫了一個 FileWriter 的版本,跑了一下測試代碼:
ttaylor1: FileWriter log work isdone= 1439
這下可把 FileWriter 和 Okio 的差距顯現(xiàn)出來了,將近一倍的速度差距。算是為技術(shù)選型 Okio 提供了有利的數(shù)據(jù)支持!
感知日志打印結(jié)束?
但還有一個問題急需解決:Log 輸出不全。
這是因為當(dāng)最后一條日志寫入緩沖區(qū)時,若緩沖區(qū)未滿就不會執(zhí)行 flush 操作。這種情況下需要手動 flush。
如何感知到最后一條 Log?做不到!因為打 Log 是業(yè)務(wù)層行為,底層 Log 庫無法感知上層行為。
這個場景讓我聯(lián)想到 “搜索框防抖”,即當(dāng)你在鍵入關(guān)鍵詞后,自動發(fā)起搜索的行為。
用流的思想理解上面的場景:輸入框是流數(shù)據(jù)的生產(chǎn)者,其內(nèi)容每變化一次,就是在流上生產(chǎn)了一個新數(shù)據(jù)。但并不是每一個數(shù)據(jù)都需要被消費,所以得做“限流”,即丟棄一切發(fā)射間隔過短的數(shù)據(jù),直到生產(chǎn)出某個數(shù)據(jù)之后一段時間內(nèi)不再有新數(shù)據(jù)。
Flow 的操作符 debounce 就非常契合這個場景。
它的背后機(jī)制是:每當(dāng)流產(chǎn)生新數(shù)據(jù)時,開啟倒計時,如果在倒計時歸零之前沒有新數(shù)據(jù),則將最后那個數(shù)據(jù)發(fā)射出去,否則重新開啟倒計時。
知道了背后的機(jī)制,就不需要拘泥于具體的實現(xiàn)方式,使用 Android 中的消息機(jī)制也能實現(xiàn)同樣的效果(日志攔截器正好使用了 HandlerThread,現(xiàn)成的消息機(jī)制)。
每當(dāng)新日志到來時,將其封裝為一條消息發(fā)送給 Handler,緊接著再發(fā)送一條延遲消息,若有后續(xù)日志,則移除延遲消息,并重發(fā)一條新延遲消息。若無后續(xù)日志,Handler 終將收到延遲消息,此時就執(zhí)行 flush 操作。
(Android 中判定 Activity 生命周期超時也是用這套機(jī)制,感興趣的可以搜索 com.android.server.wm.ActivityStack.STOP_TIMEOUT_MSG)
改造后的日志攔截器如下:
classOkioLogInterceptorprivateconstructor( privatevardir: String) : LogInterceptor {
privatevalhandlerThread = HandlerThread( "log_to_file_thread")
privatevalhandler: Handler
privatevarstartTime = System.currentTimeMillis
privatevarbufferedSink: BufferedSink? = null
privatevarlogFile = File(getFileName)
// 日志消息處理器
valcallback = Handler.Callback { message ->
valsink = checkSink
when(message.what) {
// flush 日志
TYPE_FLUSH -> {
sink.use {
it.flush
bufferedSink = null
}
}
// 寫日志
TYPE_LOG -> {
vallog = message.obj asString
sink.writeUtf8(log)
sink.writeUtf8( "\n")
}
}
// 統(tǒng)計耗時
if(message.obj as? String == "work done") Log.v(
"ttaylor1",
"log work is done= ${System.currentTimeMillis - startTime}"
)
false
}
companionobject{
privateconstvalTYPE_FLUSH = - 1
privateconstvalTYPE_LOG = 1
// 若 3000 ms 內(nèi)沒有新日志請求,則執(zhí)行 flush
privateconstvalFLUSH_LOG_DELAY_MILLIS = 3000L
@Volatile
privatevarINSTANCE: OkioLogInterceptor? = null
fungetInstance(dir: String) : OkioLogInterceptor =
INSTANCE ?: synchronized( this) {
INSTANCE ?: OkioLogInterceptor(dir).apply { INSTANCE = this}
}
}
init{
handlerThread.start
handler = Handler(handlerThread.looper, callback)
}
overridefunlog(priority: Int, tag: String, log: String) {
if(!handlerThread.isAlive) handlerThread.start
handler.run {
// 移除上一個延遲消息
removeMessages(TYPE_FLUSH)
// 將日志作為一條消息發(fā)送出去
obtainMessage(TYPE_LOG, "[ $tag] log" ).sendToTarget
// 構(gòu)建延遲消息并發(fā)送
valflushMessage = handler.obtainMessage(TYPE_FLUSH)
sendMessageDelayed(flushMessage, FLUSH_LOG_DELAY_MILLIS)
}
returnfalse
}
privatefungetToday: String =
SimpleDateFormat( "yyyy-MM-dd").format(Calendar.getInstance.time)
privatefungetFileName= " $dir${File.separator}${getToday}.log"
privatefuncheckSink: BufferedSink {
if(bufferedSink == null) {
bufferedSink = logFile.appendingSink.buffer
}
returnbufferedSink!!
}
}
Talk is cheap, show me the code
EasyLog 已上傳 GitHub,鏈接 https://github.com/wisdomtl/EasyLog
總結(jié)
“簡單”和“彈性”是庫設(shè)計中首要關(guān)注的兩個方面。
Kotlin 有諸多語法特性能極大地降低代碼的復(fù)雜度。
真責(zé)任鏈模式非常適用于為日志庫提供彈性。它使得動態(tài)為日志庫新增功能成為可能,它使得每次的處理結(jié)果得以在傳遞給后續(xù)處理者。
用HandlerThread實現(xiàn)異步串行日志輸出。
用Okio實現(xiàn)高性能的日志文件化。
在高頻日志文件化的場景下,復(fù)用輸出流能極大地提高性能。同時需要延遲消息機(jī)制保證日志的完整性。
為了防止失聯(lián),歡迎關(guān)注我防備的小號
微信改了推送機(jī)制,真愛請星標(biāo)本公號?? 返回搜狐,查看更多
責(zé)任編輯:
掃描二維碼推送至手機(jī)訪問。
版權(quán)聲明:本文由財神資訊-領(lǐng)先的體育資訊互動媒體轉(zhuǎn)載發(fā)布,如需刪除請聯(lián)系。