Preguntas de la entrevista | ¿Cómo escribir una biblioteca de registro buena y rápida? (dos)

Introducción

En el artículo anterior, se creó un marco de registro altamente escalable utilizando el patrón Cadena de responsabilidad y se introdujo una biblioteca de E/S de alto rendimiento para mejorar el rendimiento de la escritura de registros.

Bajo este marco, "archivo de escritura de registro" aparece como un interceptor:

// 日志拦截器
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!!
    }
}
复制代码

Este es el pseudocódigo esquemático para escribir un interceptor de registros de archivos. Puede hacer clic en el código completo aquí . Preguntas de la entrevista | ¿Cómo escribir una biblioteca de registros buena y rápida? (una)

Además de alta escalabilidad y E/S de alto rendimiento, ¿dónde más se puede optimizar?

registro comprimido

Comprimir el contenido diario del registro no solo puede mejorar aún más el rendimiento de E/S, reducir el tráfico de carga de registros del cliente, sino también ahorrar dinero a la empresa (el almacenamiento en la nube es bastante costoso).

Gzip es un formato de compresión de uso común. Linux usa este formato para comprimir archivos. Además de la compresión de archivos, Gzip también se usa para la compresión de red y es uno de los tres formatos de compresión HTTP estándar especificados en RFC 2016.

Puede encontrar una introducción detallada al formato de compresión Gzip aquí .

Con la ayuda del patrón decorador, el patrón adaptador y la sintaxis del método de extensión de Kotlin, agregar la funcionalidad Gzip al flujo de salida original es simple.

El código original para construir el flujo de salida es el siguiente:

val bufferedSink = logFile.appendingSink().buffer()
复制代码

donde appendingSink()y buffer()son ambos métodos de extensión:

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

Eso es construir FileOutputStream primero, luego adaptarlo a OutputStreamSink y luego decorarlo como RealBufferedSink, y finalmente formar RealBufferedSink( OutputStreamSink( FileOutputStream( File ) ) )una estructura de muñeca anidada.

La clase en Okio que implementa la salida de compresión Gzip se llama GzipSink, y también tiene un constructor decorativo similar:

inline fun Sink.gzip() = GzipSink(this)
复制代码

La compresión se puede lograr simplemente insertando gzip() en la cadena de llamadas original:

val bufferedSink = logFile.appendingSink().gzip().buffer()
复制代码

La estructura actual de la muñeca de anidación se convierte enRealBufferedSink( GzipSink( OutputStreamSink( FileOutputStream( File ) ) ) )

Ejecuté el programa de prueba. El método de prueba es generar continuamente 10 000 registros largos. Cuando no se usa Gzip, el tamaño del archivo de registro es de 251 MB. Después de agregar Gzip, solo tiene 2,1 MB. La reducción total es de más de 100 veces .

把日志文件的后缀改成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。

推荐阅读

面试系列文章如下:

面试题 | 怎么写一个又好又快的日志库? - 掘金 (juejin.cn)

面试题 | 徒手写一个 ConcurrentLinkedQueue? - 掘金 (juejin.cn)

来讨论下 Android 面试该问什么类型的题目? - 掘金 (juejin.cn)

RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池? - 掘金 (juejin.cn)

面试题 | 有用过并发容器吗?有!比如网络请求埋点 - 掘金 (juejin.cn)

Supongo que te gusta

Origin juejin.im/post/7083634028640731143
Recomendado
Clasificación