面试题 | 怎么写一个又好又快的日志库?(一)

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 面试题 | 怎么写一个又好又快的日志库?(一)

在面试中,这类题目称为场景题,即就一个实际业务场景给出解决方案,难度较高,若无与之相关的实战经验,非常考验临场应变及综合运用储备知识的能力。这篇就来分析下“写一个 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 模式有如下优势:


  1. 为参数标注语义:在Builder模式中,每个属性的赋值都是一个函数,函数名标注了属性语义。


  1. 可选参数&分批赋值:Builder模式中,除了必选参数,其他参数是可选的,可分批赋值。而直接使用构造函数必须一下子为所有参数赋值。


  1. 增加参数约束条件:可以在参数不符合要求时,抛出异常。


但 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 输出日志、创建日志文件夹、删除过期日志、写日志到文件、将日志上传云。


先不说违反了单一职责原则造成的高复杂度,高理解成本,同时也不能满足下面这些需求:


  1. 自定义我的日志美化样式。(毕竟我们不一样,有不同的审美)


  1. 在不改库的前提下,更换写文件方式(比如更高性能的I/O库)。


  1. 在不改库的前提下,更换上传云逻辑。(从阿里云换成亚马逊云)


  1. 在不改库的前提下,增加本地日志加密。


  1. 在不改库的前提下,修改日志文件的根目录。


这些动态的配置效果,应该是一个库具备的弹性。如果动不动就要改库,它还配叫库?


如果一定要给 “一个库的基本修养” 提两条纲领的话,我愿意把 “简单”“弹性” 放在首位。


高可扩展


看完刚才的砖之后,我再抛一个砖。


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)
    }
}


相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
目录
相关文章
|
1月前
|
前端开发 C语言 开发者
领导被我的花式console.log吸引了!直接写入公司公共库!
【8月更文挑战第23天】领导被我的花式console.log吸引了!直接写入公司公共库!
37 2
领导被我的花式console.log吸引了!直接写入公司公共库!
|
1月前
|
JSON 中间件 Go
go语言后端开发学习(四) —— 在go项目中使用Zap日志库
本文详细介绍了如何在Go项目中集成并配置Zap日志库。首先通过`go get -u go.uber.org/zap`命令安装Zap,接着展示了`Logger`与`Sugared Logger`两种日志记录器的基本用法。随后深入探讨了Zap的高级配置,包括如何将日志输出至文件、调整时间格式、记录调用者信息以及日志分割等。最后,文章演示了如何在gin框架中集成Zap,通过自定义中间件实现了日志记录和异常恢复功能。通过这些步骤,读者可以掌握Zap在实际项目中的应用与定制方法
go语言后端开发学习(四) —— 在go项目中使用Zap日志库
|
1月前
|
Linux API
在Linux中,程序产生了库日志虽然删除了,但磁盘空间未更新是什么原因?
在Linux中,程序产生了库日志虽然删除了,但磁盘空间未更新是什么原因?
|
1月前
|
存储 JSON 前端开发
一文搞懂 Go 1.21 的日志标准库 - slog
一文搞懂 Go 1.21 的日志标准库 - slog
54 2
|
1月前
|
JSON Go API
一文搞懂 Golang 高性能日志库 - Zap
一文搞懂 Golang 高性能日志库 - Zap
56 2
|
1月前
|
存储 安全 Python
[python]使用标准库logging实现多进程安全的日志模块
[python]使用标准库logging实现多进程安全的日志模块
|
2月前
|
测试技术 UED 存储
SLS Prometheus存储问题之在使用内置降采样时,SLS自动选择适配的指标库该如何解决
SLS Prometheus存储问题之在使用内置降采样时,SLS自动选择适配的指标库该如何解决
|
1月前
|
存储 JSON Go
一文搞懂 Golang 高性能日志库 Zerolog
一文搞懂 Golang 高性能日志库 Zerolog
79 0
|
3月前
spdlog 日志库部分源码说明——让你可以自定义的指定自动切换日志时间
spdlog 日志库部分源码说明——让你可以自定义的指定自动切换日志时间
94 7
|
3月前
|
C++
spdlog 日志库部分源码说明——日志格式设定,DIY你自己喜欢的调试信息,你能调试的远比你想象的还要丰富
spdlog 日志库部分源码说明——日志格式设定,DIY你自己喜欢的调试信息,你能调试的远比你想象的还要丰富
183 6