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

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

引子


上一篇使用责任链模式搭了一个高可扩展的日志框架,并引入高性能的I/O库以提升写日志性能。


在这个框架下,“日志写文件”作为一个拦截器出现:


// 日志拦截器
class OkioLogInterceptor(private var dir: String) : LogInterceptor {
    private val handlerThread = HandlerThread("log_to_file_thread")
    private val handler: Handler
    private var bufferedSink: BufferedSink? = null
    private val dispatcher: CoroutineDispatcher
    init {
        // 开启线程串行地处理日志请求
        handlerThread.start()
        handler = Handler(handlerThread.looper, callback)
        dispatcher = handler.asCoroutineDispatcher("log_to_file_dispatcher")
    }
    override fun log(priority: Int, tag: String, log: String, chain: Chain) {
        GlobalScope.launch(dispatcher) {
            // 使用 Okio 将日志输出到文件
            checkSink().use {
                it.writeUtf8("[$tag] $log")
                it.writeUtf8("\n")
            }
        }
        chain.proceed(priority, tag, log)
    }
    // 构建缓冲输出流
    private fun checkSink(): BufferedSink {
        if (bufferedSink == null) {
            bufferedSink = logFile.appendingSink().buffer()
        }
        return bufferedSink!!
    }
}


这是写文件日志拦截器的示意伪代码,完整代码可以点击这里面试题 | 怎么写一个又好又快的日志库?(一)


除了高扩展性和高性能I/O,还有别的地方可以优化?


压缩日志


将日志日内容压缩不仅能进一步提升I/O性能,减少客户端日志上传的流量,还能为公司省钱(云存储都挺贵的)。


Gzip 是一种常用的压缩格式。Linux 就用这种格式压缩文件。除了用在文件压缩,Gzip 还用于网络压缩,它是在RFC 2016中规定的三种标准HTTP压缩格式之一。


关于 Gzip 压缩格式的详细介绍可以点击这里


借助于装饰者模式、适配器模式、以及 Kotlin 的扩展方法语法,为原先的输出流新增 Gzip 功能很简单。


原先构建输出流的代码如下:


val bufferedSink = logFile.appendingSink().buffer()


其中appendingSink()buffer()都是扩展方法:


fun File.appendingSink(): Sink = FileOutputStream(this, true).sink()
fun OutputStream.sink(): Sink = OutputStreamSink(this, Timeout())
fun Sink.buffer(): BufferedSink = RealBufferedSink(this)


即先构建了 FileOutputStream,然后将其适配成 OutputStreamSink,再装饰成 RealBufferedSink,最终形成 RealBufferedSink( OutputStreamSink( FileOutputStream( File ) ) )这样套娃的结构。


Okio 中实现 Gzip 压缩输出的类叫GzipSink,它也有类似的装饰构造方法:


inline fun Sink.gzip() = GzipSink(this)


只需在原有调用链上插入 gzip() 即可实现压缩:


val bufferedSink = logFile.appendingSink().gzip().buffer()


现在的套娃结构变成RealBufferedSink( GzipSink( OutputStreamSink( FileOutputStream( File ) ) ) )


跑了一下测试程序,测试方法为连续输出1万条长log,不使用 Gzip 时,日志文件大小为 251 MB,加了 Gzip 之后,只有 2.1 MB。整整缩小了是 100+ 倍


把日志文件的后缀改成gz,这样从云端下载之后就能直接只用压缩软件解压看到原始日志。


为了对比加入压缩后 Okio 和 java.io 的速度性能差异,重写了 java.io 版压缩输出流的代码:


val outputStream = logFile.outputStream().gzip().writer().buffered()


其中outputStream()buffered()是系统预定的装饰流扩展方法:


 // 构造 FileOutputStream 并持有 File 实例
public inline fun File.outputStream(): FileOutputStream {
    return FileOutputStream(this)
}
// 构造 BufferedWriter 并持有 Writer 实例
public inline fun Writer.buffered(bufferSize: Int = DEFAULT_BUFFER_SIZE): BufferedWriter =
    if (this is BufferedWriter) this else BufferedWriter(this, bufferSize)


gzip()writer()是自定义的装饰流扩展方法:


// 构建 GZIPOutputStream 并持有 OutputStream
fun OutputStream.gzip() = GZIPOutputStream(this)
// 构建 OutputStreamWriter 并持有 OutputStream
fun OutputStream.writer(charset: Charset = Charsets.UTF_8) = OutputStreamWriter(this, charset)


GZIPOutputStream是针对 OutputStream 的,所以不得不使用 OutputStreamWrite 将 Writer 接口适配成 OuptStream 接口。


完整的 java.io 版压缩日志拦截器代码如下:


class FileWriterLogInterceptor private constructor(private var dir: String) : LogInterceptor {
    private val handlerThread = HandlerThread("log_to_file_thread")
    private val handler: Handler
    // 统计耗时起点
    private var startTime = System.currentTimeMillis()
    // 用于记录平均内存的列表
    private val memorys = mutableListOf<Long>()
    private var fileWriter: Writer? = null
    private var logFile = File(getFileName())
    val callback = Handler.Callback { message ->
        val sink = checkFileWriter()
        // 每来一条日志,记录此时的内存占用
        memorys.add(Runtime.getRuntime().totalMemory()/(1024*1024))
        when (message.what) {
            // 输出日志结束的标记
            TYPE_FLUSH -> {
                sink.use {
                    it.flush()
                    fileWriter = null
                }
                // 统计耗时即内存终点
                Log.v(
                    "test",
                    "fileWriter work is ok done=${System.currentTimeMillis() - startTime, memory=${memorys.average()}"
                )
            }
            // 正常写日志
            TYPE_LOG -> {
                val log = message.obj as String
                sink.write(log)
                sink.write("\n")
            }
        }
        false
    }
    companion object {
        private const val TYPE_FLUSH = -1
        private const val TYPE_LOG = 1
        // 若 300 ms 无日志请求,则进行冲刷
        private const val FLUSH_LOG_DELAY_MILLIS = 300L
        // 设计单例,防止启动多个写日志线程
        @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, callback)
    }
    override fun log(priority: Int, tag: String, log: String, chain: Chain) {
        if (!handlerThread.isAlive) handlerThread.start()
        handler.run {
            removeMessages(TYPE_FLUSH)
            obtainMessage(TYPE_LOG, "[$tag] $log").sendToTarget()
            val flushMessage = handler.obtainMessage(TYPE_FLUSH)
            // 倒计时,用于判断“已经无新日志”
            sendMessageDelayed(flushMessage, FLUSH_LOG_DELAY_MILLIS)
        }
        chain.proceed(priority, tag, log)
    }
    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"
    // 构建 java.io 压缩文件输出流
    private fun checkFileWriter(): Writer {
        if (fileWriter == null) {
            fileWriter = logFile.outputStream().gzip().writer().buffered()
        }
        return fileWriter!!
    }
    // 自定义装饰流构造方法,以简化流构建代码
    private fun OutputStream.gzip() = GZIPOutputStream(this)
    private fun OutputStream.writer(charset: Charset = Charsets.UTF_8) = OutputStreamWriter(this, charset)
}


关于其中每一个细节的讲解可以点击面试题 | 怎么写一个又好又快的日志库?(一)


下面是测试代码:


// 分别给 EasyLog 配置 Okio 日志拦截器和 FileWriter 日志拦截器
//EasyLog.addInterceptor(FileWriterLogInterceptor.getInstance(this.filesDir.absolutePath))
EasyLog.addInterceptor(OkioLogInterceptor.getInstance(this.filesDir.absolutePath))
MainScope().launch(Dispatchers.Default) {
    // 连续输出1万条日志并压缩
    repeat(10_000) {
        EasyLog.v(str4, "test")
    }
}


输出日志如下:


fileWriter work is ok done=5130, memory=160.70305938812237
fileWriter work is ok done=5172, memory=157.5844831033793
fileWriter work is ok done=5155, memory=168.01649670065987
Okio work is ok done=4765, memory=130.96940611877625
Okio work is ok done=4752, memory=130.21985602879425
Okio work is ok done=4779, memory=135.28374325134973


Okio 有 8% 左右的速度优势,及 20% 左右的内存优势。


总结


  • 压缩日志是提升日志库性能的手段之一,常用的 Gzip 是压缩手段之一,Okio 和 java.io 都提供了对 Gzip 的支持,不过 Okio 在速度和内存上都稍好于 java.io。


talk is cheap, show me the code


wisdomtl/EasyLog: An easy way to customize your log in Android,including output to console, writing log to file in high performance way and so on (github.com)


推荐阅读


面试系列文章如下:


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


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


面试题 | 徒手写一个 ConcurrentLinkedQueue?


来讨论下 Android 面试该问什么类型的题目?


RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?


面试题 | 有用过并发容器吗?有!比如网络请求埋点


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