在面试中,这类题目称为场景题,即就一个实际业务场景给出解决方案,难度较高,若无与之相关的实战经验,非常考验临场应变及综合运用储备知识的能力。这篇就来分析下“写一个 Log 需要考虑些哪些方面?”
先抛一个砖
简单是任何一个库设计都要考虑的首要问题。
接口设计
如果库接口设计不合理,造成理解、接入、使用的高复杂度。那。。。这个库就没人用呗~
先来看这样一个设计:
abstract class ULogBase : IULog { private val logger by lazy { ULogCore(initConfig()) } override fun v(tag: String, message: String) { logger.printLog(Log.VERBOSE, tag, message) } override fun v(tag: String, message: String, throwable: Throwable) { logger.printLog(Log.VERBOSE, tag, message) } override fun i(tag: String, message: String) { logger.printLog(Log.INFO, tag, message) } override fun i(tag: String, message: String, throwable: Throwable) { logger.printLog(Log.INFO, tag, message, throwable) } override fun d(tag: String, message: String) { logger.printLog(Log.DEBUG, tag, message) } override fun d(tag: String, message: String, throwable: Throwable) { logger.printLog(Log.DEBUG, tag, message, throwable) } override fun w(tag: String, message: String) { logger.printLog(Log.WARN, tag, message) } override fun w(tag: String, message: String, throwable: Throwable) { logger.printLog(Log.WARN, tag, message, throwable) } override fun e(tag: String, message: String) { logger.printLog(Log.ERROR, tag, message) } override fun e(tag: String, message: String, throwable: Throwable) { logger.printLog(Log.ERROR, tag, message,throwable) } override fun a(tag: String, message: String, tagReport: String) { logger.printLog(Log.ASSERT, tag, message, tagReport = tagReport) } override fun a(tag: String, message: String, throwable: Throwable, tagReport: String) { logger.printLog(Log.ASSERT, tag, message, throwable, tagReport = tagReport) } abstract fun initConfig() : ULogConfig }
这是 Log 库对外公布的接口。它尊重了 Android 端打 Log 的习俗。毕竟 android.util.Log 已经习以为常。使用新库时,平滑过渡,不会有陌生感。
但这个接口设计有优化的地方。
override fun v(tag: String, message: String) { logger.printLog(Log.VERBOSE, tag, message) } override fun v(tag: String, message: String, throwable: Throwable) { logger.printLog(Log.VERBOSE, tag, message, throwable) }
同级别的 Log 输出,声明了2个方法。在 Java 中不得不这样做,因参数不同而进行方法重载。
运用 Kotlin 的语法特性参数默认值和可空类型,就能降低接口复杂度:
// 声明参数 throwable 是可空类型,并默认赋予空值 override fun v(tag: String, message: String, throwable: Throwable? = null) { logger.printLog(Log.VERBOSE, tag, message, throwable) }
把2个接口变为1个接口,而调用效果保持不变:
ULog.v("test","log") ULog.v("test","log",Exception("error"))
当调用第一条语句时,Kotlin 默认给 throwable 参数传递 null 值。
接入难度
这个接口设计,第二个尴尬点是 ULogBase 是抽象的,业务层不得不实例化才能使用。。。
这无疑增加了使用负担。而这样做的目的仅仅是为了“配置”,即根据不同的业务场景传入不同的参数:
object NimLogger : ULogBase() { override fun initConfig(): ULogConfig { return ULogConfig.Builder(requireNotNull(NimConfig.context()))// 和 context 耦合 .module("nim") .debug(NimConfig.isDebug()) // 和编译类型耦合 .build() } }
业务端必须重写 initConfig() 方法生成一个 ULogConfig 对象,用来表示业务配置。
看看业务配置包括哪些:
class ULogConfig private constructor(builder: Builder) { val module = builder.mModule // 模块名 val isDebug = builder.mIsDebug // 编译类型 val context = builder.context // 上下文 val logModulePath = builder.mLogModulePath // 路径名 val logRootPath = builder.mLogRootPath // 根路径名 val enableToFile = builder.enableToFile // 是否写文件 val isValidPath : Boolean get() { return !TextUtils.isEmpty(logModulePath) && !TextUtils.isEmpty(logRootPath) } class Builder(val context: Context) { lateinit var mModule: String private set var mIsDebug: Boolean = true private set var enableToFile: Boolean = true private set var mLogModulePath: String = "" private set var mLogRootPath: String = "" private set fun module(name: String): Builder { mModule = name return this } fun debug(debug: Boolean): Builder { mIsDebug = debug return this } fun enableToFile(enable : Boolean): Builder { enableToFile = enable return this } fun build(): ULogConfig { if (mModule == null) { throw IllegalArgumentException("Be sure to set the module name") } if (TextUtils.isEmpty(mLogModulePath)) { mLogModulePath = getLogPath(mModule) } if (TextUtils.isEmpty(mLogRootPath)) { mLogRootPath = getLogPath() } return ULogConfig(this) } private fun getLogPath(module : String = "") : String { return if(mIsDebug) { File(context.filesDir, "uLog/debug/$module").absolutePath } else { File(context.filesDir, "uLog/release/$module").absolutePath } } } }
业务配置信息包括,模块名、编译类型、上下文、路径名、是否写文件。
其中前 4 个参数是为了给不同模块的日志生成不同的日志路径。这个设计,见仁见智~
优点是信息降噪,只关注想关注模块的日志。缺点是,大部分业务模块都和多个底层功能模块耦合,大部分问题是综合性问题,需要关注整个链路上所有模块的日志。
我偏好的策略是,将所有日志打入一个文件,通过 tag 来区分模块,如果只想关注某个模块的日志,sublime 可以方便地选中包含指定 tag 的所有行,一个复制粘贴就完成了信息降噪。毕竟想分开是轻而易举的事情,但是想合并就很难了~
复杂度(建造者模式)
Kotlin 相较于 Java 的最大优势就是降低复杂度。在库接口设计及内部实现时就要充分发挥 Kotlin 的优势,比如 Kotlin 的世界里已不需要 Builder 模式了。
Builder 模式有如下优势:
- 为参数标注语义:在Builder模式中,每个属性的赋值都是一个函数,函数名标注了属性语义。
- 可选参数&分批赋值:Builder模式中,除了必选参数,其他参数是可选的,可分批赋值。而直接使用构造函数必须一下子为所有参数赋值。
- 增加参数约束条件:可以在参数不符合要求时,抛出异常。
但 Builder 模式也有代价,新增了一个中间类Builder
。
使用 Kotlin 的命名参数
+参数默认值
+require()
语法,在没有任何副作用的情况下就能实现 Builder 模式:
class Person( val name: String, //'为以下可选参数设置默认值' val gender: Int = 1, val age: Int= 0, val height: Int = 0, val weight: Int = 0 ) //'使用命名参数构建 Person 实例' val p = Person(name = “taylor”,gender = 1,weight = 43)
命名参数为每个实参赋予了语义,而且不需要按构造方法中声明的顺序来赋值,可以跳着来。
如果想增加参数约束条件可以调用require()
方法:
data class Person( // 这个是必选参数 val name: String, val gender: Int = 1, val age: Int= 0, val height: Int = 0, val weight: Int = 0 ){ //'在构造函数被调用的时候执行参数合法检查' init { require(name.isNotEmpty()){”name cant be empty“} } }
此时如果像下面这样构造 Person,则会抛出异常:
val p = Person(name="",gender = 1) java.lang.IllegalArgumentException: name cant be empty
本来在 build() 方法中执行的额外初始化逻辑也可以全部写在init
代码块中。
最后这个库,在具体打印日志时的操作也及其复杂(不知道你能不能一下子看明白这 log 是怎么打的?):
internal class ULogCore(private val config: ULogConfig) { companion object { const val DELETE_DAY_NUMBER = 7 const val TAG_LOG = "ULogCore" const val SUFFIX = ".java" const val TAG_PARAM = "param" const val KEY_ASSET_MSG = "key_asset_message" const val STACK_TRACK_INDEX = 7 val tag_log_type = arrayOf("VERBOSE", "DEBUG", "INFO", "WARN", "ERROR", "ASSERT") var initOperation = AtomicBoolean(false) var logDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.CHINA) } fun printLog( type: Int, tagText: String, message: String, throwable: Throwable? = null, tagReport: String? = "" ) { val stackTrace = Thread.currentThread().stackTrace CoroutineScope(Dispatchers.Default).launch { val finalMessage = processMessage(type, message, stackTrace) val tagFinal = if (TextUtils.isEmpty(tagText)) TAG_LOG else tagText 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 -> { } } } val logDir = File(config.logModulePath) if (!logDir.exists() || logDir.isFile) { logDir.mkdirs() } if (!initOperation.getAndSet(true)) { val filterDates = ArrayList<String>().apply { for(i in 0..DELETE_DAY_NUMBER) { val calendar = Calendar.getInstance() calendar.add(Calendar.DAY_OF_MONTH, -i) val date = logDateFormat.format(calendar.time) Log.i(TAG_LOG, date) add(date) } } config.logRootPath.takeIf { !TextUtils.isEmpty(it) }?.run { val list = 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) { var formatReportTag = "" if (type == Log.ASSERT && tagReport != null && tagReport.isNotEmpty()) { var assetMsgSet = MMKV.defaultMMKV().getStringSet(KEY_ASSET_MSG, HashSet()) val rawMsg = message+tagReport Log.i(TAG_LOG, "asset md5 raw message $rawMsg") val key = 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") } } } private fun processMessage( type: Int, msg: String, stackTraceElement: Array<StackTraceElement> ): String { fun typeTag(logType: Int): String? { return if (logType >= Log.VERBOSE && logType <= Log.ASSERT) tag_log_type[logType - 2] else "LOG" } val targetElement = stackTraceElement[STACK_TRACK_INDEX] var fileName = targetElement.fileName if (TextUtils.isEmpty(fileName)) { var className = targetElement.className val classNameInfo = className.split("\.".toRegex()).toTypedArray() if (classNameInfo.isNotEmpty()) { className = classNameInfo[classNameInfo.size - 1] } if (className.contains("$")) { className = className.split("\$".toRegex()).toTypedArray()[0] } fileName = className + SUFFIX } val methodName = targetElement.methodName var lineNumber = targetElement.lineNumber if (lineNumber < 0) { lineNumber = 0 } val headString = String.format("[ (%s:%s) => %s ]", fileName, lineNumber, methodName) val threadInfo = String.format("thread - {{ %s }}", Thread.currentThread().name) val sb = StringBuilder(headString).apply { append("\n╔═════════════════════════════════════════════════════════════════════════════════════════") append("\n║ ").append(threadInfo) append("\n╟┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈") append("\n║ [").append(typeTag(type)).append("] - ").append(msg) append("\n╚═════════════════════════════════════════════════════════════════════════════════════════") } return sb.toString() } }
printLog() 是每次打日志都会调用的方法,在这个方法里面做了很多很多事情~,包括美化日志、向 Logcat 输出日志、创建日志文件夹、删除过期日志、写日志到文件、将日志上传云。
先不说违反了单一职责原则造成的高复杂度,高理解成本,同时也不能满足下面这些需求:
- 自定义我的日志美化样式。(毕竟我们不一样,有不同的审美)
- 在不改库的前提下,更换写文件方式(比如更高性能的I/O库)。
- 在不改库的前提下,更换上传云逻辑。(从阿里云换成亚马逊云)
- 在不改库的前提下,增加本地日志加密。
- 在不改库的前提下,修改日志文件的根目录。
这些动态的配置效果,应该是一个库具备的弹性。如果动不动就要改库,它还配叫库?
如果一定要给 “一个库的基本修养” 提两条纲领的话,我愿意把 “简单” 和 “弹性” 放在首位。
高可扩展
看完刚才的砖之后,我再抛一个砖。
object EasyLog { private const val VERBOSE = 2 private const val DEBUG = 3 private const val INFO = 4 private const val WARN = 5 private const val ERROR = 6 private const val ASSERT = 7 // 拦截器列表 private val logInterceptors = mutableListOf<LogInterceptor>() fun d(message: String, tag: String = "", vararg args: Any) { log(DEBUG, message, tag, *args) } fun e(message: String, tag: String = "", vararg args: Any, throwable: Throwable? = null) { log(ERROR, message, tag, *args, throwable = throwable) } fun w(message: String, tag: String = "", vararg args: Any) { log(WARN, message, tag, *args) } fun i(message: String, tag: String = "", vararg args: Any) { log(INFO, message, tag, *args) } fun v(message: String, tag: String = "", vararg args: Any) { log(VERBOSE, message, tag, *args) } fun wtf(message: String, tag: String = "", vararg args: Any) { log(ASSERT, message, tag, *args) } // 注入拦截器 fun addInterceptor(interceptor: LogInterceptor) { logInterceptors.add(interceptor) } // 从头部注入拦截器 fun addFirstInterceptor(interceptor: LogInterceptor) { logInterceptors.add(0, interceptor) } }
日志库对上层的接口封装在一个单例 EasyLog 里。
日志库提供了和 android.util.Log 几乎一样的打印接口,但增加了一个可变参数args
,这是为了方便地为字串的通配符赋值。
假责任链模式
日志库还提供了一个新接口addInterceptor()
,用于动态地注入日志拦截器:
interface LogInterceptor { // 进行日志 fun log(priority: Int, tag: String, log: String) // 是否允许进行日志 fun enable():Boolean }
日志拦截器是一个接口,定义了两个抽象的行,为分别是进行日志
和是否允许日志
。
所有的日志接口都将打印日志委托给了log()
方法:
object EasyLog { @Synchronized private fun log( priority: Int, message: String, tag: String, vararg args: Any, throwable: Throwable? = null ) { // 为日志通配符赋值 var logMessage = message.format(*args) // 如果有异常,则读取异常堆栈拼接在日志字串后面 if (throwable != null) { logMessage += getStackTraceString(throwable) } // 遍历日志拦截器,将日志打印分发给所有拦截器 logInterceptors.forEach { interceptor -> if (interceptor.enable()) interceptor.log(priority, tag, logMessage) } } // 对 String.format() 的封装,以求简洁 fun String.format(vararg args: Any) = if (args.isNullOrEmpty()) this else String.format(this, *args) // 读取堆栈 private fun getStackTraceString(tr: Throwable?): String { if (tr == null) { return "" } var t = tr while (t != null) { if (t is UnknownHostException) { return "" } t = t.cause } val sw = StringWriter() val pw = PrintWriter(sw) tr.printStackTrace(pw) pw.flush() return sw.toString() } }
这里用了同步方法,为了防止多线程调用时日志乱序。
这里还运用了责任链模式(假的),使得 EasyLog 和日志处理的具体逻辑解耦,它只是持有一组日志拦截器,当有日志请求时,就分发给所有的拦截器。
这样做的好处就是,可以在业务层动态地为日志组件提供新的功能。
当然日志库得提供一些基本的拦截器,比如将日志输出到 Logcat:
open class LogcatInterceptor : LogInterceptor { override fun log(priority: Int, tag: String, log: String){ Log.println(priority, tag, log) } override fun enable(): Boolean { return true } }
Log.println(priority, tag, log)
是 android.util.Log 提供的,按优先级将日志输出到 Logcat 的方法。使用这个方法可以降低复杂度,因为不用写类似下面的代码:
when(priority){ VERBOSE -> Log.v(...) ERROR -> Log.e(...) }
之所以要将 LogcatInterceptor 声明为 open,是因为业务层有动态重写enable()
方法的需求:
EasyLog.addInterceptor(object : LogcatInterceptor() { override fun enable(): Boolean { return BuildConfig.DEBUG } })
这样就把日志输出到 Logcat 的开关 和 build type 联系起来了,就不需要将 build type 作为 EasyLog 的一个配置字段了。
除了输出到 Logcat,另一个基本需求就是日志文件化,新建一个拦截器:
class FileWriterLogInterceptor private constructor(private var dir: String) : LogInterceptor { private val handlerThread = HandlerThread("log_to_file_thread") private val handler: Handler private val dispatcher: CoroutineDispatcher // 带参单例 companion object { @Volatile private var INSTANCE: FileWriterLogInterceptor? = null fun getInstance(dir: String): FileWriterLogInterceptor = INSTANCE ?: synchronized(this) { INSTANCE ?: FileWriterLogInterceptor(dir).apply { INSTANCE = this } } } init { // 启动日志线程 handlerThread.start() handler = Handler(handlerThread.looper) // 将 handler 转换成 Dispatcher dispatcher = handler.asCoroutineDispatcher("log_to_file_dispatcher") } override fun log(priority: Int, tag: String, log: String) { // 启动协程串行地将日志写入文件 if (!handlerThread.isAlive) handlerThread.start() GlobalScope.launch(dispatcher) { FileWriter(getFileName(), true).use { it.append("[$tag] $log") it.append("\n") it.flush() } } } override fun enable(): Boolean { return true } private fun getToday(): String = SimpleDateFormat("yyyy-MM-dd").format(Calendar.getInstance().time) private fun getFileName() = "$dir${File.separator}${getToday()}.log" }
日志写文件的思路是:“异步串行地将字串通过流输出到文件中”。
异步化是为了不阻塞主线程,串行化是为了保证日志顺序。HandlerThread 就很好地满足了异步化串行的要求。
为了简化“将日志作为消息发送到异步线程中”这段代码,使用了协程,这样代码就转变成:每次日志请求到来时,启动协程,在其中完成创建流、输出到流、关闭流。隐藏了收发消息的复杂度。
日志拦截器被设计为单例,目的是让 App 内存中只存在一个写日志的线程。
日志文件的路径由构造方法传入,这样就避免了日志拦截器和 Context 的耦合。
use()
是Closeable
的扩展方法,它隐藏了流操作的try-catch
,降低了复杂度,关于这方面的详细介绍可以点击
Kotlin 源码 | 降低代码复杂度的法宝 - 掘金 (juejin.cn)
然后业务层就可以像这样动态地为日志组件添加写文件功能:
class MyApplication : Application() { override fun onCreate() { super.onCreate() // 日志文件路径 val dir = this.filesDir.absolutePath // 构造日志拦截器单例 val interceptor = FileWriterLogInterceptor.getInstance(dir) // 注入日志拦截器单例 EasyLog.addInterceptor(interceptor) } }