序章
前回の記事では、Chain of Responsibilityパターンを使用して拡張性の高いロギングフレームワークを構築し、ログ書き込みのパフォーマンスを向上させるために高性能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は、ファイル圧縮に加えて、ネットワーク圧縮にも使用され、RFC2016で指定されている3つの標準HTTP圧縮形式の1つです。
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 ) ) )
そのようます。
Gzip圧縮出力を実装するOkioのクラスはと呼ばれGzipSink
、同様の装飾コンストラクターもあります。
inline fun Sink.gzip() = GzipSink(this)
复制代码
圧縮は、元の呼び出しチェーンにgzip()を挿入するだけで実現できます。
val bufferedSink = logFile.appendingSink().gzip().buffer()
复制代码
現在の入れ子人形の構造は次のようになりますRealBufferedSink( GzipSink( OutputStreamSink( FileOutputStream( File ) ) ) )
テストプログラムを実行しました。テスト方法は、10,000個の長いログを継続的に出力することです。Gzipを使用しない場合、ログファイルのサイズは251 MBです。Gzipを追加すると、わずか2.1MBになります。完全な削減は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。
推荐阅读
面试系列文章如下:
面试题 | 怎么写一个又好又快的日志库? - 掘金 (juejin.cn)
面试题 | 徒手写一个 ConcurrentLinkedQueue? - 掘金 (juejin.cn)
来讨论下 Android 面试该问什么类型的题目? - 掘金 (juejin.cn)