从零构建你的Kotlin网络调试利器:一个深度定制的OkHttp日志拦截器

调试网络请求,大概是每个移动端开发者日常工作中最频繁也最让人头疼的环节之一。尤其是在开发初期,或者排查线上偶现问题时,你需要的不仅仅是知道请求成功与否,更需要清晰地看到请求的完整“生命轨迹”:它发出了什么、服务器返回了什么、中间耗时多少、甚至请求头里是否携带了正确的令牌。OkHttp自带的HttpLoggingInterceptor是个不错的起点,但它打印的日志往往过于冗长,格式也不够友好,难以在茫茫Logcat中快速定位关键信息。更重要的是,它缺乏一些对实际调试至关重要的定制化能力,比如请求耗时统计、特定信息的脱敏处理,或者将日志输出到文件以便后续分析。

今天,我们就来动手打造一个属于你自己的、功能强大且高度可定制的Kotlin网络请求日志拦截器。这不仅仅是替换几行日志那么简单,而是深入OkHttp拦截器机制,构建一个能适应复杂调试场景、提升开发效率的利器。无论你是刚开始接触OkHttp,还是已经使用了一段时间但对其拦截器原理感到好奇,这篇文章都将带你从原理到实践,一步步构建一个比官方更贴合实际需求的解决方案。

1. 理解基石:OkHttp拦截器机制与日志需求

在开始敲代码之前,我们有必要先搞清楚两件事:OkHttp的拦截器是如何工作的,以及一个理想的日志拦截器应该具备哪些能力。这能帮助我们在设计时做出更明智的选择,而不是盲目地复制粘贴代码。

OkHttp的拦截器(Interceptor)是其强大功能的核心设计之一。你可以把它想象成一个流水线上的处理站。当一个网络请求被发起时,它会依次经过你添加的所有拦截器,最终到达网络层;服务器返回的响应则会以相反的顺序再次流经这些拦截器,最终交付给调用方。这种设计模式被称为“责任链模式”。

提示:拦截器分为两类:应用拦截器(Application Interceptors)和网络拦截器(Network Interceptors)。应用拦截器在重定向和重试之前被调用,通常用于添加全局头部、记录请求日志;网络拦截器在链的末端、即将进行网络操作前被调用,可以看到更底层的网络信息(如实际发送的请求头,包括OkHttp自动添加的)。我们构建的日志拦截器通常作为应用拦截器添加。

那么,一个仅仅打印BODY级别的官方日志拦截器,为什么常常不能满足我们呢?原因在于其输出的“不可控性”和“信息缺失”:

  • 信息过载与格式混乱:它会完整打印出请求和响应的头部、体部,对于包含大量Cookie或长JSON的响应,日志会瞬间刷屏,淹没其他重要信息。
  • 缺乏关键上下文:它不自动计算并显示单个请求的总耗时,而这对于性能调优至关重要。
  • 安全性风险:它会明文打印所有信息,包括Authorization头中的令牌或请求体中的敏感数据(如密码)。
  • 输出目标单一:日志只能输出到Logcat,无法在需要时持久化到文件,以供测试人员分析或线上问题回溯。

基于这些痛点,我们自定义拦截器的目标就清晰了:

  1. 格式化与过滤:提供清晰、可读的日志格式,并能按需过滤掉冗余信息。
  2. 性能洞察:自动计算并记录请求从发起到收到响应所花费的时间。
  3. 安全脱敏:能够识别并对敏感信息(如Token、密码字段)进行掩码处理。
  4. 灵活输出:支持将日志输出到不同目的地(Logcat、文件、甚至网络)。
  5. 可配置性:通过简单的配置,开启或关闭不同级别的日志,适应开发、测试、生产等不同环境。

理解了这些,我们的构建就不再是盲目的,而是有明确目标的功能实现。

2. 搭建骨架:创建基础的可配置日志拦截器类

让我们从创建一个最基本的、可配置的拦截器类开始。我们将使用Kotlin来编写,充分利用其简洁的语法和强大的表达能力。

首先,在你的项目中确保已经引入了OkHttp库。然后,创建一个新的Kotlin类,例如命名为CustomLoggingInterceptor,并实现Interceptor接口。

import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException

class CustomLoggingInterceptor : Interceptor {

    // 定义一个日志级别枚举
    enum class Level {
        NONE,       // 不打印任何日志
        BASIC,      // 仅打印请求方法、URL、响应状态码和耗时
        HEADERS,    // 在BASIC基础上,打印请求和响应头
        BODY        // 打印所有信息,包括请求和响应体
    }

    // 使用属性委托来提供线程安全的懒初始化(如果需要复杂的构造器)
    private var level: Level = Level.BASIC

    // 用于设置日志级别的Builder风格方法
    fun setLevel(level: Level): CustomLoggingInterceptor {
        this.level = level
        return this
    }

    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
        // 如果级别为NONE,直接放行请求,不做任何记录
        if (level == Level.NONE) {
            return chain.proceed(chain.request())
        }

        val request = chain.request()
        val requestStartTime = System.nanoTime()

        // 在这里,我们将开始记录请求信息
        // 后续步骤会填充具体逻辑

        val response = chain.proceed(request)

        val requestEndTime = System.nanoTime()
        val durationMs = (requestEndTime - requestStartTime) / 1_000_000.0

        // 在这里,我们将记录响应信息和耗时
        // 后续步骤会填充具体逻辑

        return response
    }
}

这个骨架提供了几个关键点:

  • 可配置的日志级别:通过Level枚举,我们可以像使用官方拦截器一样控制输出量。
  • Builder模式:setLevel方法返回this,支持链式调用,方便在配置OkHttpClient时使用。
  • 性能计时:使用System.nanoTime()在请求开始前和结束后记录时间,计算出精确的耗时。
  • 空操作优化:当级别为NONE时,直接跳过所有日志逻辑,避免不必要的性能开销。

现在,我们有了一个可以运行但还不输出任何东西的拦截器。接下来,我们要为它注入“灵魂”——日志记录的逻辑。

3. 填充血肉:实现分级日志记录与信息格式化

日志的可读性至关重要。我们接下来实现不同级别下的日志记录细节,并设计一个清晰的输出格式。我们将创建一个内部的Logger接口来抽象日志输出,这样未来我们可以轻松替换底层的输出实现(例如从android.util.Log切换到Timber或自定义文件写入器)。

首先,在CustomLoggingInterceptor类内部或同级位置定义这个接口和一个默认实现:

// 在CustomLoggingInterceptor类内部或作为单独文件
interface Logger {
    fun log(message: String)
}

// 一个使用Android Logcat的默认实现
class DefaultLogger(private val tag: String = "OkHttp") : Logger {
    override fun log(message: String) {
        // 这里使用Log.d,你也可以根据消息类型选择Log.i, Log.w等
        android.util.Log.d(tag, message)
    }
}

然后,修改我们的拦截器类,引入Logger并填充intercept方法:

class CustomLoggingInterceptor : Interceptor {
    // ... 之前的Level枚举和level属性 ...

    private var logger: Logger = DefaultLogger()

    // 新增设置Logger的方法
    fun setLogger(logger: Logger): CustomLoggingInterceptor {
        this.logger = logger
        return this
    }

    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
        if (level == Level.NONE) {
            return chain.proceed(chain.request())
        }

        val request = chain.request()
        val requestStartTime = System.nanoTime()

        // 记录请求基本信息
        logRequest(request)

        val response: Response
        try {
            response = chain.proceed(request)
        } catch (e: Exception) {
            // 请求失败时(如网络异常),记录错误和耗时
            val failureTime = System.nanoTime()
            logFailure(request, e, durationMs = (failureTime - requestStartTime) / 1_000_000.0)
            throw e // 重新抛出异常
        }

        val requestEndTime = System.nanoTime()
        val durationMs = (requestEndTime - requestStartTime) / 1_000_000.0

        // 记录响应信息
        logResponse(response, durationMs)

        return response
    }

    private fun logRequest(request: Request) {
        val logMessage = StringBuilder()
        logMessage.append("--> ")
            .append(request.method)
            .append(' ')
            .append(request.url)
            .append(" HTTP/1.1")

        if (level >= Level.HEADERS) {
            val headers = request.headers
            if (headers.size > 0) {
                logMessage.append("\n--> Headers:")
                for (i in 0 until headers.size) {
                    logMessage.append("\n    ")
                        .append(headers.name(i))
                        .append(": ")
                        .append(maskSensitiveHeader(headers.name(i), headers.value(i)))
                }
            }
        }

        if (level >= Level.BODY && request.body != null) {
            // 注意:读取request.body会消耗它,需要谨慎处理。
            // 对于非重复读取的body,这里先简单记录有Body。
            // 更复杂的处理会在后面章节讨论。
            logMessage.append("\n--> Body: [Present (${request.body?.contentLength() ?: -1} bytes)]")
        }
        logger.log(logMessage.toString())
    }

    private fun logResponse(response: Response, durationMs: Double) {
        val logMessage = StringBuilder()
        logMessage.append("<-- ")
            .append(response.code)
            .append(' ')
            .append(response.message)
            .append(' ')
            .append(response.request.url)
            .append(" (${String.format("%.1f", durationMs)}ms)")

        if (level >= Level.HEADERS) {
            val headers = response.headers
            if (headers.size > 0) {
                logMessage.append("\n<-- Headers:")
                for (i in 0 until headers.size) {
                    logMessage.append("\n    ")
                        .append(headers.name(i))
                        .append(": ")
                        .append(maskSensitiveHeader(headers.name(i), headers.value(i)))
                }
            }
        }

        if (level >= Level.BODY) {
            // 关键:响应体只能被消费一次!我们需要小心地处理。
            // 这里先标记,具体Body内容读取在下一节实现。
            val body = response.body
            val contentLength = body?.contentLength() ?: -1
            logMessage.append("\n<-- Body: [${contentLength}-byte body]")
        }
        logger.log(logMessage.toString())
    }

    private fun logFailure(request: Request, e: Exception, durationMs: Double) {
        val logMessage = StringBuilder()
        logMessage.append("<-- HTTP FAILED: ")
            .append(e.javaClass.simpleName)
            .append(" @ ")
            .append(request.url)
            .append(" (${String.format("%.1f", durationMs)}ms)")
            .append("\n    Error Message: ")
            .append(e.message)
        logger.log(logMessage.toString())
    }

    // 简单的敏感信息脱敏函数
    private fun maskSensitiveHeader(name: String, value: String): String {
        val lowerCaseName = name.lowercase()
        return if (lowerCaseName.contains("auth") || lowerCaseName.contains("token") || lowerCaseName.contains("password")) {
            "***masked***"
        } else {
            value
        }
    }
}

现在,我们的拦截器已经能够输出结构清晰、分级显示的日志了。输出格式类似于:

--> GET https://api.example.com/users HTTP/1.1
--> Headers:
    Authorization: ***masked***
    User-Agent: MyApp/1.0
<-- 200 OK https://api.example.com/users (245.3ms)
<-- Headers:
    Content-Type: application/json
<-- Body: [128-byte body]

这比原始的、未经格式化的输出要友好得多。但我们还缺少最关键的一环:在BODY级别下,如何安全地读取并打印请求和响应的实际内容?

4. 攻克难点:安全读取与格式化请求/响应体

读取请求体和响应体是自定义日志拦截器中最需要小心处理的部分,因为它们通常是“一次性”的流。特别是响应体,一旦调用response.body()?.string()方法读取后,流就被关闭,后续的代码将无法再次读取,导致应用逻辑出错。

解决方案是“偷梁换柱”:我们读取原始的响应体内容,将其保存下来,然后为调用方创建一个包含相同内容的新响应体。这样,我们既记录了日志,又不影响应用的正常业务逻辑。对于请求体,如果它是可重复读取的(如FormBody、MultipartBody的部分情况),我们也可以类似处理;否则,我们只记录其元信息。

让我们实现一个更完善的logResponse的BODY部分,并新增一个处理请求体的方法:

// 在CustomLoggingInterceptor类中添加或修改以下方法

private fun logRequest(request: Request) {
    // ... 前面的代码不变 ...
    if (level >= Level.BODY && request.body != null) {
        val requestBody = request.body
        if (isPlaintext(requestBody.contentType())) {
            // 复制请求体内容以便读取
            val buffer = okio.Buffer()
            requestBody.writeTo(buffer)
            val contentType = requestBody.contentType()
            val bodyString = buffer.readUtf8()
            logMessage.append("\n--> Body (${contentType}):\n")
                .append(bodyString.takeIf { it.length < 5000 } ?: "${bodyString.substring(0, 5000)}... [truncated]")
        } else {
            logMessage.append("\n--> Body: [binary ${requestBody.contentLength()}-byte body omitted]")
        }
    }
    logger.log(logMessage.toString())
}

private fun logResponse(response: Response, durationMs: Double): Response {
    // ... 构建logMessage的前面部分不变 ...

    val newResponse = if (level >= Level.BODY) {
        val body = response.body
        if (body != null && isPlaintext(body.contentType())) {
            // 读取原始响应体内容
            val source = body.source()
            source.request(Long.MAX_VALUE) // 缓冲整个响应体
            val buffer = source.buffer
            val contentType = body.contentType()
            val bodyString = buffer.clone().readUtf8() // 克隆buffer以便后续读取

            // 将响应体内容记录到日志
            logMessage.append("\n<-- Body (${contentType}):\n")
                .append(bodyString.takeIf { it.length < 5000 } ?: "${bodyString.substring(0, 5000)}... [truncated]")

            // 使用读取的内容创建一个新的响应体,并构建新的Response返回
            val newBody = bodyString.toResponseBody(contentType)
            response.newBuilder().body(newBody).build()
        } else {
            // 非文本内容(如图片)不打印具体内容
            logMessage.append("\n<-- Body: [binary ${body?.contentLength() ?: -1}-byte body omitted]")
            response // 返回原始响应
        }
    } else {
        response // 非BODY级别,直接返回原始响应
    }

    logger.log(logMessage.toString())
    return newResponse // 返回处理后的响应
}

// 辅助函数:判断Content-Type是否为可读的文本类型
private fun isPlaintext(contentType: MediaType?): Boolean {
    if (contentType == null) return false
    val mediaType = contentType.toString()
    return mediaType.startsWith("text/") ||
            mediaType.contains("application/json") ||
            mediaType.contains("application/xml") ||
            mediaType.contains("application/x-www-form-urlencoded") ||
            mediaType.contains("charset=utf-8")
}

这里有几个关键点需要注意:

  1. 响应体处理:我们使用OkHttp的okio.Buffer来读取和克隆响应体数据。buffer.clone().readUtf8()确保了原始缓冲区的数据不被消耗。然后我们用读取到的字符串创建新的ResponseBody,并替换原响应中的body。
  2. 请求体处理:对于请求体,我们同样使用Buffer来复制内容。但要注意,某些请求体(如大文件流)可能不适合完全读入内存。在生产环境的日志拦截器中,你可能需要根据情况决定是否记录大请求体。
  3. 内容类型判断:isPlaintext函数帮助我们避免尝试去打印像图片、PDF这样的二进制内容,这既无意义也可能导致乱码或崩溃。
  4. 日志截断:通过.takeIf和substring,我们限制了单条日志的最大长度,防止超长的JSON响应撑爆Logcat缓冲区。

至此,一个功能完整、安全可靠的自定义日志拦截器核心已经构建完毕。它具备了分级日志、格式化输出、耗时统计、敏感信息脱敏和安全读写Body的能力。

5. 进阶扩展与实战集成

有了核心功能,我们可以进一步扩展,让它更加强大和易用。这里提供几个常见的进阶方向:

1. 环境感知的日志开关 你肯定不希望在生产环境打印详细的BODY日志。我们可以让拦截器根据构建类型或自定义标志自动调整级别。

class CustomLoggingInterceptor(
    private val defaultLevel: Level = Level.BASIC
) : Interceptor {
    // 在intercept方法开始时,可以动态决定使用的级别
    private fun getEffectiveLevel(): Level {
        return if (BuildConfig.DEBUG) {
            defaultLevel
        } else {
            Level.NONE // 或 Level.BASIC
        }
    }

    override fun intercept(chain: Interceptor.Chain): Response {
        val effectiveLevel = getEffectiveLevel()
        if (effectiveLevel == Level.NONE) {
            return chain.proceed(chain.request())
        }
        // ... 使用effectiveLevel进行后续逻辑 ...
    }
}

2. 日志持久化到文件 对于测试阶段或需要离线分析网络请求的场景,将日志写入文件非常有用。我们只需要实现一个不同的Logger即可。

class FileLogger(private val logFile: File) : Logger {
    private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())

    override fun log(message: String) {
        try {
            val timestamp = dateFormat.format(Date())
            val logEntry = "$timestamp - $message\n"
            logFile.appendText(logEntry, Charsets.UTF_8)
        } catch (e: IOException) {
            android.util.Log.e("FileLogger", "Failed to write log to file", e)
        }
    }
}

// 使用方式
val logFile = File(context.externalCacheDir, "network_logs.txt")
val fileLogger = FileLogger(logFile)
val interceptor = CustomLoggingInterceptor()
    .setLevel(CustomLoggingInterceptor.Level.BODY)
    .setLogger(fileLogger)

3. 与OkHttpClient集成 最后,让我们看看如何将这个精心打造的拦截器集成到你的OkHttpClient中,并对比一下使用效果。

// 创建OkHttpClient实例
val okHttpClient = OkHttpClient.Builder()
    .connectTimeout(30, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .writeTimeout(30, TimeUnit.SECONDS)
    // 添加我们自定义的日志拦截器
    .addInterceptor(
        CustomLoggingInterceptor()
            .setLevel(if (BuildConfig.DEBUG) CustomLoggingInterceptor.Level.BODY else CustomLoggingInterceptor.Level.NONE)
            .setLogger(DefaultLogger("Network"))
    )
    // 你还可以添加其他拦截器,如认证拦截器、重试拦截器等
    // .addInterceptor(AuthInterceptor())
    .build()

// 在Retrofit或直接使用OkHttp时传入这个client

现在,当你发起网络请求时,就能在Logcat中看到清晰、结构化、包含耗时和脱敏信息的日志了。相比于原生拦截器杂乱无章的输出,你的调试效率会得到显著提升。更重要的是,你拥有了一个可以根据项目需求随时调整和扩展的调试工具,而不再受限于库提供的固定功能。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐