客户端日志&埋点&上报的柔性生产线

3,952 阅读16分钟

引子

有没有一个日志库除了可以打印字符串,还能打印列表、Map、异常,并自动添加 tag?

  • 有!比如 Timber,Logger

有没有一个日志库可以动态地追加 N 种日志处理方式?比如输出到 logcat 的同时写文件,上传服务器?

  • 有!比如 Timber,Logger

有没有一种日志库可以打印一切对象以及实现柔性生产线

  • 没有!

什么是柔性生产线?比如下图:

image.png 在该日志处理链路中,所有的日志都会被输出到 logcat,但 protobuf 日志是原样输出,其他日志会经过美化再输出。并且只有 protobuf 日志会被持久化并批量上传服务器。

上图的日志处理逻辑是串行的,即上一个处理逻辑的输出是下一个的输入。而 Timber 和 Logger 是并行的:

image.png

并行的处理方式,使得每个日志处理逻辑的输入都是原始日志,每个日志处理器的逻辑都得从头开始写,相同的日志处理逻辑无法复用。

除了串行日志处理外,柔性生产线的第二个特点是日志处理逻辑动态可拔插。比如可以轻松地在批量上传结点前插入日志压缩或加密。

为什么需要柔性生产线?

因为客户端日志其实存在多种多样的需求。

以日志输出目的地分类:

  1. 输出到控制台
  2. 输出到文件
  3. 输出到数据库
  4. 输出到服务器

以日志内容加工方式分类:

  1. 美化日志
  2. 日志加密
  3. 日志压缩
  4. 追加信息

以开发易用性分类:

  1. tag自动生成
  2. 一次性tag
  3. 打印异常
  4. 支持 format arguments
  5. 打印列表、Map

不同业务场景中,日志处理需求会以不同形式任意组合。柔性生产线是最灵活的组合方式。

这一篇会从如何实现柔性生产线以及日志库的易用性两个方面展开。

前情提要

为了实现柔性生产线式的日志处理,我写了EasyLog

上两篇介绍了 EasyLog 的第一个版本的设计思路,在此做一个简短的介绍。

每个日志处理逻辑被抽象为拦截器:

// 日志拦截器
interface Interceptor {
    // 日志处理逻辑
    fun log(tag: String, message: String, priority: Int, chain: Chain, vararg args: Any)
    // 是否开启当前拦截器
    fun enable():Boolean
}

把日志输出到 logcat 的实现如下:

class LogcatInterceptor : Interceptor<String>() {
    override fun log(tag: String, message: String, priority: Int, chain: Chain, vararg args: Any) {
        // 如果该拦截器开启,则输出到logcat,否则直接传递给下一个拦截器
        if (enable()) Log.d("test", message)
        else chain.proceed(tag, message, priority, args)
    }
}

这样的实现对于某个独立的日志需求,看上去非常脱裤子放屁。但这套接口的价值是多个日志处理逻辑的串联组合。责任链在其中扮演着重要角色:

// 责任链
class Chain(
    // 持有一组拦截器
    private val interceptors: List<Interceptor>,
    // 当前拦截器索引
    private val index: Int = 0
) {
    // 将日志请求在链上传递
    fun proceed(tag: String, message: String, priority: Int, vararg args: Any) {
        // 用一条新的链包裹链上的下一个拦截器
        val next = Chain(interceptors, index + 1)
        // 获取链上当前的拦截器
        val interceptor = interceptors.getOrNull(index)
        // 执行当前拦截器逻辑,并传入新建的链
        interceptor?.log(tag, message, priority, next, *args)
    }
}

所有日志拦截器被责任链持有,日志沿链向后传递通过索引值+1实现。

EasyLog是日志库的入口类,通过其addInterceptor()方法可动态地添加拦截器:

EasyLog.apply {
    addInterceptor(LogcatInterceptor())
    addInterceptor(FileInterceptor())
}

上述代码添加了 logcat 以及文件拦截器,这样通过EasyLog.i("test log")输出的日志就会被输出到 logcat 并持久化到文件。

日志库的灵活性表现在“可动态化插入拦截器”:

EasyLog.apply {
    addInterceptor(LogcatInterceptor())
    addInterceptor(GzipInterceptor())
    addInterceptor(EncryptInterceptor())
    addInterceptor(FileInterceptor())
}

这条责任链会先将日志输出到logcat再进行Gzip压缩并加密后最终持久化到文件。

日志的每一个处理步骤都被内聚到一个单独类中,使得它可无副作用地被插入或摘除(不会影响到其他日志处理逻辑),这增加了程序的弹性和健壮性。

柔性生产线

EasyLog 在上线了数个版本之后迎来了第一个挑战。因为它的接口设计限制了日志的类型只能是 String:

interface Interceptor {
    // 其中的 message 只能是 String 类型
    fun log(tag: String, message: String, priority: Int, chain: Chain, vararg args: Any)
    fun enable():Boolean
}

实际使用中日志可能是任何类型,比如客户端和服务器约定了上报日志的结构体(通常用 protobuf 定义,为简单起见用 data class 示意):

// 广告加载成功
data class AdLoadSuccessEvent (
    val slotId: String,
    val eventName: String,
    val eventId: String,
    val duration: Long
)
// 广告加载失败
data class AdLoadFail (
    val slotId: String,
    val eventName: String,
    val eventId: String,
    val cause: String,
)

如果日志库能够直接打印这样的结构体该多好(柔性生产线应该可以打印一切):

EasyLog.i(AdLoadFail(...))

总结一下,柔性生产线的特点:

  1. 串联日志处理,后续结点可以享受到前序结点的处理结果。
  2. 动态拔插日志处理逻辑。
  3. 可处理任何日志对象。

为了实现打印一切,拦截器接口应该设计成和类型无关:

// 处理泛型数据的日志拦截器
interface Interceptor<T> {
    // 日志处理逻辑
    fun log(tag: String, message: T, priority: Int, chain: Chain, vararg args: Any)
    // 是否启动当前拦截器
    fun enable():Boolean
}

为了将处理不同数据类型的拦截器串联在一起,需要重构一下责任链:

class Chain(
    // 责任链持有 star 投影的拦截器
    private val interceptors: List<Interceptor<*>>,
    private val index: Int = 0
) {
    // 责任链向后传递 Any 类型的日志
    fun proceed(tag: String, message: Any, priority: Int, vararg args: Any) {
        val next = Chain(interceptors, index + 1)
        try {
            // 将 star 投影的拦截器强转为 Any 类型
            (interceptors.getOrNull(index) as? Interceptor<Any>)?.log(tag, message, priority, next, *args)
        } catch (e: Exception) {
        }
    }
}

责任链持有的拦截器会处理不同类型的日志,为了将这些拦截器无差别地组织成列表,则需要将列表声明为List<Interceptor<*>>,因为Interceptor<任何类型>都是Interceptor<*>的子类型。但是在使用 star 投影的拦截器时需要强转成对应类型,为了让日志在拦截器上传递,需要将其强转为Any

强转是有可能报错的,假设下一个拦截器是Interceptor<String>但责任链上传递来的数据是protobuf,则会发生 ClassCastException,遂使用 try-catch 兜底。

下面就组装一条柔性生产线以实现上图日志处理流程:

1. Logcat 拦截器

柔性生产线上的第一个拦截器是 Logcat 拦截器:

class LogcatInterceptor : Interceptor<Any>() {
    override fun log(tag: String, message: Any, priority: Int, chain: Chain, vararg args: Any) {
        // 将日志输出到 logcat
        if (enable()) Log.println(priority, tag, getFormatLog(message, *args))
        chain.proceed(tag, message, priority, args)
    }
    // 格式化日志
    private fun getFormatLog(message: Any, vararg args: Any) =
        // 如果日志是 Throwable 则追加堆栈信息
        if (message is Throwable)
            getStackTraceString(message)
        else
            // 如果 format arguments 不为空则格式化
            if (args.isNotEmpty()) message.toString().format(args)
            // 否则直接将结构体转换为string
            else message.toString()

    // 获取堆栈信息
    private fun getStackTraceString(t: Throwable): String {
        val sw = StringWriter(256)
        val pw = PrintWriter(sw, false)
        t.printStackTrace(pw)
        pw.flush()
        return sw.toString()
    }

    // 支持 format arguments
    private fun String.format(args: Array<out Any>) = 
        if (args.isEmpty()) this else String.format(this, *args)
}

因为 Logcat 拦截器可以接受任何类型的日志,所以被定义为Interceptor<Any>

该拦截器对日志做了格式化:如果是日志是 Throwable 类型的,则在当前日志后追加调用栈,否则将其直接转换为 String。

线性拦截器

为了在多线程环境下安全地使用日志库,在柔性生产线上插入了一个线性拦截器:

class LinearInterceptor : Interceptor<Any>() {
    private val CHANNEL_CAPACITY = 50
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    // 日志处理Channel
    private val channel = Channel<Event>(CHANNEL_CAPACITY)

    init {
        // 启动协程消费日志
        scope.launch {
            channel.consumeEach { event ->
                // 防止 Channel 因一场关闭 的 try-catch
                try {
                    event.apply { chain.proceed(tag, message, priority) }
                } catch(e: Exception){
                }
            }
        }
    }

    override fun log(tag: String, message: Any, priority: Int, chain: Chain, vararg args: Any) {
        if (enable()) {
            //启动协程发送日志
            scope.launch { channel.send(Event(tag, message, priority, chain)) }
        } else {
            chain.proceed(tag, message, priority)
        }
    }
    // 日志包装类
    data class Event(val tag: String, val message: Any, val priority: Int, val chain: Chain)
}

实现线程安全的策略是“并行问题串行化”。Channel 是串行化的容器。线性拦截器初始化时启动了一个协程作为 Channel 的消费者(从队头取日志),而 log() 方法是 Channel 的生产者(从队尾插日志)。Channel 除了实现串行化还有背压策略,其默认大小是 50,表示若生产速度大于消费速度时,最多缓存 50 条日志,当超过该阈值时,生产者就会被挂起。

若消费 Channel 时抛出异常,则其会被关闭:

// 以挂起的方式消费 Channel 中元素
public suspend inline fun <E> ReceiveChannel<E>.consumeEach(action: (E) -> Unit): Unit =
    consume {
        for (e in this) action(e)
    }
    
public inline fun <E, R> ReceiveChannel<E>.consume(block: ReceiveChannel<E>.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    var cause: Throwable? = null
    try {
        return block()
    } catch (e: Throwable) {
        cause = e
        throw e
    } finally {
        cancelConsumed(cause)// 取消 Channel
    }
}

// 取消 Channel
internal fun ReceiveChannel<*>.cancelConsumed(cause: Throwable?) {
    cancel(cause?.let {
        it as? CancellationException ?: CancellationException("Channel was consumed, consumer had failed", it)
    })
}

为了避免后续日志处理逻辑抛出异常导致 Channel 取消,将消费逻辑 try-catch。

唯一标识拦截器

为了避免日志丢失,每一条日志都会被持久化,当成功上传服务器后再删除。

为了方便日志的增删,为每条日志生成唯一标识符,通过唯一标识符拦截器实现:

class LogInterceptor : Interceptor<Any>() {
    override fun log(tag: String, message: Any, priority: Int, chain: Chain, vararg args: Any) {
        if (enable()) {
            // 把每条日志包装成带唯一标识符的 Log
            val log = Log(UUID.randomUUID().toString(), message)
            // 将 Log 沿责任链传递
            chain.proceed(tag, log, priority, args)
        }
    }
}

// 带唯一标识符的日志
data class Log<T>(val id: String, val data: T)

唯一标识符拦截器接收 Any 类型的日志,把它包装 Log 再传递给下游责任链。

持久化拦截器

为了确保日志的完整性,每一条日志都会持久化到文件,通过持久化拦截器实现:

class SinkInterceptor : Interceptor<Log<Message>>() {
    companion object {
        val mmkv by lazy { MMKV.defaultMMKV() }
    }

    override fun log(tag: String, message: Log<Message>, priority: Int, chain: Chain, vararg args: Any) {
        // 将日志持久化为二进制
        if (enable()) mmkv.encode(message.id, message.data.toByteArray())
        // 将日志原样传递给下一个拦截器
        chain.proceed(tag, message, priority)
    }
}

持久化拦截器不再接受任何类型,而是只接收Log<Message>,其中 Message 是 com.google.protobuf.Message,protobuf 的基类,它有一个toByteArray()序列化方法。

每一条日志都会以键值对形式持久化。键是日志唯一标识符,值是被序列化为二进制的日志内容,它们通过 MMKV 持久化到文件。

之所以选择 MMKV 是因为其出色的写性能,MMKV写的是内存(内核空间),并有系统机制保证落到文件。但相较之下其读性能稍差,并且其读只能将文件中所有的 key 一次性读出,若 key 很多,则可能发生 ANR。写数据库应该是一个更中庸保险的方法。

批处理拦截器

每来一条日志都上传服务器,浪费流量。遂先将日志堆积在内存中,达到一定数量后再一并上报。

上报的埋点事件使用 protobuf 定义如下:

// 业务事件1
message Event1 {
    string eventName = 1;
    uint64 eventTime = 2;
    int duration = 3;
}

// 所有事件的泛化类型,protobuf中用 Any 表示
message Event {
  google.protobuf.Any event = 1;
}

// 事件批次,用于批量上传
message EventBatch {
  repeated Event event = 2;// 多个 Event 
}

之所以使用 protobuf 是因为它高效的序列化。

使用 protoc 命令生成的 kotlin 都使用类似 DSL 方式构建对象,比如:

val event = event1 {
    eventName = "load_success"
    eventTime = System.currentTimeMillis()
    duration = 20
}

将多条 protobuf 事件组织成批量事件的代码如下:

val logs = mutableListOf<Message>()
// 累加堆积日志为 EventBatch
logs.fold(EventBatch.newBuilder()) { acc, message -> 
    acc.addEvent(event { event = Any.pack(message) }) 
}.build()

批量日志拦截器定义如下:

class BatchInterceptor() : Interceptor<Log<Message>>() {
    companion object {
        var size: Int = 50
        var interval: Long = 10_000L
    }

    private val list = mutableListOf<Log<Message>>()
    private var lastFlushTime = 0L
    private val scope = CoroutineScope(SupervisorJob())
    private var flushJob: Job? = null

    override fun log(tag: String, log: Log<Message>, priority: Int, chain: Chain, vararg args: Any) {
        if (enable()) {
            list.add(log)
            flushJob?.cancel()
            if (isOkFlush()) {
                flush(chain, tag, priority)
            } else {
                flushJob = delayFlush(chain, tag, priority)
            }
        }
    }

    private fun isOkFlush() = lastFlushTime != 0L && SystemClock.elapsedRealtime() - lastFlushTime >= interval || list.size >= size

    private fun flush(chain: Chain, tag: String, priority: Int) {
        // 将多条日志打包
        val logs = logs.fold(EventBatch.newBuilder()) { acc, message -> acc.addEvent(event { event = Any.pack(message) }) }.build()
        // 使用 LogBatch 包装批量日志
        val logBatch = LogBatch(list.map { it.id }, logs)
        // 将 LogBatch 传递给下一个拦截器
        chain.proceed(tag, logBatch, priority)
        list.clear()
        lastFlushTime = SystemClock.elapsedRealtime()
    }

    private fun delayFlush(chain: Chain, tag: String, priority: Int) = scope.launch(singleLogDispatcher) {
        val delayTime = if (lastFlushTime == 0L) interval else interval - (SystemClock.elapsedRealtime() - lastFlushTime)
        delay(delayTime)
        flush(chain, tag, priority)
    }
}

// 批量日志包装类,包含一批日志,以及它们的唯一标识符
data class LogBatch<T>(val ids: List<String>, val data: T)

批量日志拦截器只接受带唯一标识符的Log<Message>,它会将单条日志打包成批量日志并包装成 LogBatch 传递给下一个拦截器。LogBatch 携带了批量日志中所有日志的键,用于上传成功后删除日志。

批量日志处理可能遇到“小尾巴”、“线程安全”问题。这些问题的详细讲解可以点击客户端日志&埋点&上报的线程安全问题

上报拦截器

柔性生产线的终点是上报拦截器:

class UploadInterceptor : Interceptor<LogBatch<EventBatch>>() {
    private val scope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
    private val trackApi by lazy { retrofit.create(TrackApi::class.java) }

    override fun log(tag: String, logs: LogBatch<EventBatch>, priority: Int, chain: Chain, vararg args: Any) {
        if (enable()) scope.launch {
            val success = trackApi.track(logs.data)
            // 若上传成功,则删除本地日志
            if (success) SinkInterceptor.mmkv.removeValuesForKeys(logs.ids.toTypedArray())
        }
    }
}

interface TrackApi {
    // 直接上传 protobuf 的 post 接口
    @POST("event/track")
    @Headers("Content-Type: application/protobuf")
    suspend fun track(@Body eventBatch: EventBatch): Boolean
}

上报拦截器接收LogBatch<EventBatch>并将其通过 POST 接口上报到服务器。若成功则删除本地日志。若不成功,留在本地的日志在下一次启动时再上传。

如果对日志在服务器落地的实时性&稳定性有要求,可以在这个拦截器实现长链接

断链

将上面 6 个拦截器组装成责任链:

EasyLog.apply {
    addInterceptor(LogcatInterceptor())
    addInterceptor(LinearInterceptor())
    addInterceptor(LogInterceptor())
    addInterceptor(SinkInterceptor())
    addInterceptor(BatchInterceptor(50, 10_000))
    addInterceptor(UploadInterceptor())
}

然后就可以使用 EasyLog 打印日志 & 埋点上报:

// 仅打印日志
EasyLog.log("load start")
// 日志 & 埋点 & 上报
EasyLog.log(
    event1 {
        eventName = "load_success"
        eventTime = System.currentTimeMillis()
        duration = 20
    }
)

为了实现责任链的分流,即 protobuf 日志执行所有拦截器,其它日志只执行 Logcat 拦截器。责任链需要断流功能。

为了更灵活地实现责任链的断流,需稍微重构下接口,之前拦截器接口定义如下:

// 处理泛型数据的日志拦截器
interface Interceptor<T> {
    // 日志处理逻辑
    fun log(tag: String, message: T, priority: Int, chain: Chain, vararg args: Any)
    // 是否启动当前拦截器
    fun enable():Boolean
}

是否启用拦截器被定义为一个方法enable(),这样定义就不能运行时动态修改,重构如下:

abstract class Interceptor<T> {
    abstract fun log(tag: String, message: T, priority: Int, chain: Chain, vararg args: Any)
    // 可动态赋值的 lambda,默认启动当前拦截器
    var isLoggable: (T) -> Boolean = { true }
}

将方法改为一个可在运行时动态赋值的 lambda,并为其添加了默认值为{ true }表示默认启用当前拦截器。接口不能包含默认值,遂把 interface 改为 abstract class。

同时修改addInterceptor()接口:

object EasyLog {
    private val interceptors = mutableListOf<Interceptor<in Nothing>>()
     // 新增 isLoggable  参数
    fun <T> addInterceptor(
        interceptor: Interceptor<T>, 
        isLoggable: (T) -> Boolean = { true }
    ) {
        addInterceptor(interceptors.size, interceptor, isLoggable)
    }

    fun <T> addInterceptor(
        index: Int, 
        interceptor: Interceptor<T>, 
        isLoggable: (T) -> Boolean = { true }
    ) {
        interceptors.add(index, interceptor.apply { this.isLoggable = isLoggable })
    }

然后只需要在添加唯一标识符拦截器时做一个断流操作就好了:

EasyLog.apply {
    addInterceptor(LogcatInterceptor())
    addInterceptor(LinearInterceptor())
    // 只有 protobuf 日志才能通过
    addInterceptor(LogInterceptor()) { log -> log is Message) }
    addInterceptor(SinkInterceptor())
    addInterceptor(BatchInterceptor(50, 10_000))
    addInterceptor(UploadInterceptor())
}

易用性

打印一切

EasyLog.log()不仅可以传入字符串,还可以传入任何对象:

EasyLog.log("test")
EasyLog.log(User("taylor", 187))

打印异常

EasyLog 支持直接打印异常:

EasyLog.log(IllegalArgumentException("wrong type"))

效果如下: image.png

format arg

EasyLog 支持 format arguments:

EasyLog.log("end %s", DEBUG, "ab") // 打印结构体 输出 "end ab"

自动tag/一次性tag

默认情况下 EasyLog 会自动选取所在类作为日志tag:

class UserManager {
    fun print() {
        // 打印字符串(自动选取tag=UserManager)
        EasyLog.log("start", ERROR) 
    }
}

EasyLog 还提供了一次性tag方法:

EasyLog.tag("test").log("end")

日志的tag被指定为”test“,该tag仅在此条日志中生效。

该特性适用于想通过另一种维度过滤日志。

一次性tag的实现逻辑如下:

object EasyLog {
    private val MAX_TAG_LENGTH = 23
    private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$")

    // 一次性tag
    private var onetimeTag = ThreadLocal<String>()
    // 一次性tag的存取逻辑
    private var tag: String?
        // 获取一次性tag,获取即销毁
        get() = onetimeTag.get()?.also { onetimeTag.remove() }
        // 设置一次性tag
        set(value) {
            onetimeTag.set(value)
        }
    // 日志tag黑名单(避免自动tag选自日志库内部类)
    private val blackList = listOf(
        EasyLog::class.java.name,
        Chain::class.java.name
    )
    // 输出日志
    fun log(message: Any, priority: Int = VERBOSE, vararg args: Any) {
        // 创建tag并输出日志
        chain.proceed(createTag(), message, priority, *args)
    }
    // 创建tag
    private fun createTag(): String {
        return tag ?: Throwable().stackTrace
            .first { it.className !in blackList }
            .let(::createStackElementTag)
    }
    // 获取当前日志所在类名
    private fun createStackElementTag(element: StackTraceElement): String {
        var tag = element.className.substringAfterLast('.')
        val m = ANONYMOUS_CLASS.matcher(tag)
        if (m.find()) {
            tag = m.replaceAll("")
        }
        return if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) {
            tag
        } else {
            tag.substring(0, MAX_TAG_LENGTH)
        }
    }
}

将一次性tag存储在 ThreadLocal 中,可以避免并发访问tag时出现的多线程问题(因为每个线程都会一个自己的一次性tag副本)。

每次输出日志都会调用createTag()创建tag,先获取一次性tag,若获取成功,则移除一次性tag,否则通过调用栈获取当前类名。

一次性拦截器

EasyLog 支持一次性拦截器,即动态地为当前日志添加一种日志处理逻辑且用完即丢:

EasyLog.interceptor(FrameInterceptor()).log("higlight")
EasyLog.log("after highlight")
// 输出
┌──────────────────────────────────────────────────────────────────────────
│ higlight                   
└──────────────────────────────────────────────────────────────────────────
after highlight

通过interceptor()方法为日志指定了一次性拦截器,使得这条日志带有边框。

一次性拦截器的实现逻辑如下:

object EasyLog {
    // 一次性拦截器
    private var onetimeInterceptor: ThreadLocal<Interceptor<*>>? = null

    fun log(message: Any, priority: Int = VERBOSE, vararg args: Any) {
        chain.proceed(createTag(), message, priority, *args)
        // 当前日志已流过所有拦截器,删除一次性拦截器
        onetimeInterceptor?.takeIf { it.get() != null }?.also { removeInterceptor(it.get()) }
    }
    // 添加一次性拦截器
    fun interceptor(interceptor: Interceptor<*>): EasyLog {
        // 总是将一次性拦截器添加在责任链头部
        interceptors.add(0, interceptor) 
        if (onetimeInterceptor == null) onetimeInterceptor = ThreadLocal()
        onetimeInterceptor?.set(interceptor)
        return this
    }
}

打印列表

EasyLog 支持打印列表,还可以选择性地打印复杂结构体中的字段:

val array = listOf(1,2,3)
EasyLog.list(array)// 输出 ”[1, 2, 3]“

val users = listOf(
    User(name = "peter", age = 30, company = "ali"),
    User(name = "joice", age = 23, company = "baidu"),
    User(name = "martin", age = 29, company = "tecent"),
)
EasyLog.list(users) { "${it.name}(${it.age}) in ${it.company}" } 
// 输出”[peter(30) in ali, joice(23) in baidu, martin(29) in tecent]“

打印列表是通过一次性拦截器实现的:

object EasyLog {
    fun <T> list(message: Iterable<T>, priority: Int = VERBOSE, map: ((T) -> String)? = null) {
        // 添加一次性拦截器ListInterceptor
        interceptor(ListInterceptor(map))
        chain.proceed(createTag(), message, priority)
        onetimeInterceptor?.takeIf { it.get() != null }?.also { removeInterceptor(it.get()) }
    }
}

其中 ListInterceptor 实现如下:

class ListInterceptor<T>(private val map: ((T) -> String)?) : Interceptor<Iterable<T>>() {
    override fun log(tag: String, message: Iterable<T>, priority: Int, chain: Chain, vararg args: Any) {
        // 将list折叠成 string
        val messageList = message.log { map?.invoke(it) ?: it.toString() }
        chain.proceed(tag, messageList, priority, args)
}

// 将遍历的元素折叠成一个 string
fun <T> Iterable<T>.log(map: (T) -> String) = 
    fold(StringBuilder("[")) { acc: StringBuilder, t: T -> acc.append("\t${map(t)},") }.append("]").toString()

ListInterceptor 的输入是一个 Iterable,通过遍历它,将其中每个元素根据指定的规则追加到字符串末尾。

打印Map

EasyLog 支持打印 Map 以及嵌套Map。

val map = mapOf(
    "a" to mapOf( "1" to true), 
    "b" to mapOf( "2" to false, "3" to true)
)
EasyLog.map(map)

上述代码输入如下:

        {
            [a] = { [1] = true },
            [b] =  {
                [2] = false,
                [3] = true
            }
        }

打印map依然通过一次性拦截器实现:

class MapInterceptor<K, V> : Interceptor<Map<K, V>>() {
    override fun log(tag: String, message: Map<K, V>, priority: Int, chain: Chain, vararg args: Any) {
         chain.proceed(tag, message.log(4), priority, args)
    }
}

fun <K, V> Map<K, V?>.log(space: Int = 0): String {
    val indent = StringBuilder().apply {
        repeat(space) { append(" ") }
    }.toString()
    return StringBuilder("\n${indent}{").also { sb ->
        this.iterator().forEach { entry ->
            val value = entry.value.let { v ->
                (v as? Map<*, *>)?.log("${indent}${entry.key} = ".length) ?: v.toString()
            }
            sb.append("\n\t${indent}[${entry.key}] = $value,")
        }
        sb.append("\n${indent}}")
    }.toString()
}

日志过滤

有些日志只希望在调试时输出,可以通过优先级开关在线上版本关闭之:

object EasyLog {
    const val VERBOSE = 2
    const val DEBUG = 3
    const val INFO = 4
    const val WARN = 5
    const val ERROR = 6
    const val ASSERT = 7
    const val NONE = 8
    // 当前优先级
    var curPriority = VERBOSE
}
class LogcatInterceptor : Interceptor<Any>() {
    override fun log(tag: String, message: Any, priority: Int, chain: Chain, vararg args: Any) {
        //为 logcat 拦截器添加优先级过滤逻辑
        if (enable() && 
            EasyLog.curPriority <= priority && 
            EasyLog.curPriority != EasyLog.NONE
        ) Log.println(priority, tag, getFormatLog(message, *args))
        chain.proceed(tag, message, priority, args)
    }
}

通过动态设置优先级可实现线上版本 logcat 日志过滤

EasyLog.priority = if(BuildConfig.DEBUG) EasyLog.VERBOSE else EasyLog.NONE

Talk is cheap, show me the code

EasyLog

推荐阅读

每次调试打印日志都很头痛

客户端日志&埋点&上报的接口设计

客户端日志&埋点&上报的性能优化