第3章 自定义日志拦截器:日志格式设计、请求/响应头与体打印、避免敏感信息泄露

3.1 原理概述

OkHttp 的拦截器链(Interceptor Chain)在请求/响应处理过程中提供了一个可插拔的观察层。自定义日志拦截器本质上是实现 Interceptor 接口,在 intercept() 方法中拦截原始 Request 与最终 Response,并通过 chain.proceed() 传递处理。

日志拦截器的核心价值在于:

  • 开发阶段:完整记录 HTTP 报文,便于调试接口参数、Header 设置与响应结构。
  • 测试阶段:在后台任务中自动捕获请求/响应快照,无需人工抓包。
  • 生产风险:必须对敏感字段(如 、密码、身份证号)进行脱敏处理,防止日志泄露。
核心原则:拦截器应优先打印可控长度的文本体,对二进制/大文件体需限制截断或跳过;所有认证、支付等隐私数据必须使用掩码替换。
自定义日志拦截器执行流程 收到 Request 并进入拦截器 打印请求 URL + Headers(脱敏) 打印请求 Body(掩码敏感字段) chain.proceed(request) 打印响应状态码 + Headers(脱敏) 打印响应 Body(掩码敏感字段) 返回 Response 至后续拦截器 敏感字段自动掩码 val displayValue = if (name in SENSITIVE_HEADERS) "****" else value println("$TAG Header: $name: $displayValue") } // 打印 Body(脱敏 + 截断) request.body?.let { body -> val buffer = Buffer() body.writeTo(buffer) val bodyString = buffer.readUtf8() val maskedBody = maskSensitiveJson(bodyString) val truncatedBody = if (maskedBody.length > MAX_BODY_LENGTH) maskedBody.take(MAX_BODY_LENGTH) + "... [truncated]" else maskedBody println("$TAG Body: $truncatedBody") } ?: println("$TAG Body: (empty)") } private fun logResponse(response: Response) { val code = response.code val protocol = response.protocol println("$TAG ← $protocol $code") // 打印响应 Headers(脱敏) response.headers.forEach { (name, value) -> val displayValue = if (name in SENSITIVE_HEADERS) "****" else value println("$TAG Header: $name: $displayValue") } // 打印响应 Body(脱敏 + 截断,注意 body 只能消费一次) val source = response.body?.source() val buffer = Buffer() source?.request(Long.MAX_VALUE) source?.readAll(buffer) val bodyString = buffer.readUtf8() val maskedBody = maskSensitiveJson(bodyString) val truncatedBody = if (maskedBody.length > MAX_BODY_LENGTH) maskedBody.take(MAX_BODY_LENGTH) + "... [truncated]" else maskedBody println("$TAG Body: $truncatedBody") } private fun maskSensitiveJson(json: String): String { // 简单正则替换敏感键对应的值(支持 JSON 字符串/数字/布尔) return SENSITIVE_BODY_KEYS.fold(json) { acc, key -> acc.replace(Regex("\"$key\"\\s*:\\s*\"[^\"]+\"", RegexOption.IGNORE_CASE)) .replace(Regex("\"$key\"\\s*:\\s*[^,}\\]\"]+", RegexOption.IGNORE_CASE)) } } }

3.4 API 配置与调试

在构建 OkHttpClient 时添加拦截器:

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(LoggingInterceptor())
    .build()
日志拦截器配置参数对比
参数说明建议值
MAX_BODY_LENGTH打印体最大字符数,避免大文件撑爆日志4096(4KB)
SENSITIVE_HEADERS需要掩码的 Header 名称集合Authorization, Cookie, Set-Cookie, X-Auth-
SENSITIVE_BODY_KEYSJSON 体中需要掩码的键名password, secret, creditCard, ssn
日志输出目标自定义 Logger(如 Timber、Logcat、SLF4J)使用 Log.d(TAG, ...) 替换 println()

3.5 部署与性能优化

  • 生产环境关闭详细日志:通过 BuildConfig 或配置文件控制是否启用 LoggingInterceptor,避免外部打印。
  • 异步日志:将日志写入独立线程队列,避免阻塞网络请求线程。
  • 二进制体跳过:对于 MediaType 为 image/*、video/*、application/octet-stream 的请求/响应,只打印类型和大小,不打印内容。
  • 缓存 body 风险:响应体只能消费一次,必须使用 response.newBuilder().body(...).build() 重新包装后再返回,否则下游无法读取。
常见错误:body 被消费后返回空响应
在 logResponse() 中读取了 response.body 后直接返回原 response,导致调用方获取的 body 为 null。正确做法:将原 body 替换为 ResponseBody.create(mediaType, bodyString) 再构建新 Response。

3.6 调试与验证

启动测试后台任务,查看日志输出格式示例:

OkHttpLog → POST https://api.example.com/login
OkHttpLog   Header: Content-Type: application/json
OkHttpLog   Header: Authorization: ****
OkHttpLog   Body: {"username":"admin","password":"****"}    // 实际 password 值被掩码
OkHttpLog ← HTTP/2 200
OkHttpLog   Header: Set-Cookie: ****
OkHttpLog   Body: {"":"****","expiresIn":3600}       //  被掩码

通过对比原始报文(可用 Charles 或 Wireshark 抓包),确认脱敏逻辑未遗漏任何敏感字段,且未篡改实际请求/响应内容。

3.7 常见错误清单

错误类型现象解决方案
Body 二次读取异常调用方 body 为 null 或抛出 IllegalStateException重新构建 ResponseBody 并 set 到新 Response
敏感信息未脱敏日志中明文出现 、密码将新 Header/键名加入 SENSITIVE_HEADERS 或 SENSITIVE_BODY_KEYS
大文件日志撑爆内存OOM 或日志文件迅速增长设置 MAX_BODY_LENGTH 并跳过二进制类型
拦截器顺序错误日志中打印了已被其他拦截器修改的请求/响应确保日志拦截器位于拦截器链的最外层(先添加)或者最内层,根据需求决定

3.8 本章小结