补充篇 9.1:Ktor Logging 深入:Header、Body 脱敏与自定义 Logger
第九篇我们已经知道Logging ↓ 负责记录 HTTP Request / Response最基础的配置可能只是install(Logging) { logger Logger.DEFAULT level LogLevel.ALL }但正式项目真正需要考虑的远不只是“把日志打印出来”而是哪些内容应该记录 哪些 Header 绝对不能打印 Request Body 中的 password 怎么办 Response Body 中的 token 怎么办 文件上传为什么不能打印 Body 10MB JSON 要全部打印吗 Logger.DEFAULT 和 Custom Logger 到底是什么关系 已经用了 Kermit / AppLogger Ktor 日志应该怎么接进去所以这一篇专门把 Ktor Logging 拆开。当前 Ktor 3.5.x 的LoggingConfig提供logger、level、filter()、sanitizeHeader()、bodyFilter、format等配置其中bodyFilter默认使用BinaryLogBodyFilter用于避免把二进制 Body 当普通文本输出。一、先建立最重要的 Logging 心智模型Ktor Logging 可以拆成三层Logging Plugin ↓ LoggingConfig │ ┌────────────────┼────────────────┐ ↓ ↓ ↓ level sanitizeHeader bodyFilter │ │ Header处理 Body处理 └────────────────┼────────────────┘ ↓ 生成日志内容 ↓ Logger / \ ↓ ↓ Logger.DEFAULT Custom Logger ↓ AppLogger / Kermit这里最重要的是LoggingConfig 决定“日志内容怎么处理”Logger 决定“处理后的日志往哪里输出”。不要把这两件事混在一起。二、Logging Plugin 是什么安装install(Logging)相当于HttpClient ↓ 获得 HTTP Logging 能力它负责观察Request Response Method URL Headers Body Status它不是你的App 全局日志框架它只是HTTP 网络日志的生产者。例如你的项目可能还有业务日志 数据库日志 WebSocket 日志 机器人通信日志 异常日志Ktor Logging 只是其中一个来源。三、LoggingConfig 是什么当我们写install(Logging) { logger Logger.DEFAULT level LogLevel.ALL sanitizeHeader { it HttpHeaders.Authorization } }大括号里的接收者就是LoggingConfig当前 3.5.x API 中它主要提供logger level format bodyFilter filter() sanitizeHeader()因此install(Logging) { ... }本质就是配置 Logging Plugin 的工作规则。四、Logger 又是什么Ktor 的Logger非常简单。核心就是interface Logger { fun log( message: String, ) }所以 Logger 根本不负责抓 Request 抓 Response 解析 Header 读取 Body它主要负责Ktor 已经生成了一段日志字符串我把它输出到哪里所以Logging Plugin ↓ 生成 message ↓ Logger.log(message)五、方案 A直接使用 Logger.DEFAULT最简单install(Logging) { logger Logger.DEFAULT level LogLevel.ALL }流程Request / Response ↓ Logging Plugin ↓ 生成日志 ↓ Logger.DEFAULT ↓ 平台日志系统Ktor 官方当前说明在 JVM 上Logger.DEFAULT使用 SLF4JAndroid 推荐提供slf4j-android。Multiplatform 项目则可以提供自己的 Logger。Android 例如androidMain.dependencies { implementation( org.slf4j:slf4j-android:$slf4jVersion ) }然后install(Logging) { logger Logger.DEFAULT level LogLevel.ALL }六、Ktor 3.5.x 还有 Logger.ANDROID当前 API 里还有 JVM/Android 专用Logger.ANDROID它会面向 Android Logcat 输出并处理 Android 单条日志长度限制如果存在 SLF4J Provider也会使用默认 Logger。所以 Android-only 项目里也可能看到install(Logging) { logger Logger.ANDROID level LogLevel.ALL }但是对于 KMP 项目如果已经有统一Kermit AppLogger后面介绍的 Custom Logger 通常更适合。七、LogLevel 到底控制什么当前 Ktor Logging 有NONE INFO HEADERS BODY ALL可以先这样理解NONE ↓ 完全不记录 INFO ↓ Request / Response 基础信息 HEADERS ↓ 基础信息 Header BODY ↓ 基础信息 Body ALL ↓ Header Body 完整 HTTP 日志开发环境常见level LogLevel.ALL但是正式环境不建议简单ALL 什么都不脱敏直接上线。八、为什么 Logging 会带来安全问题假设POST /login Authorization: Bearer abc123 Cookie: session987654 { username: tom, password: 123456 }如果level LogLevel.ALL完全不做处理那么Token Cookie Password全部可能进入Logcat 文件日志 日志上传平台 Crash 日志这就不是调试问题了而是数据安全问题所以 Logging 至少需要两层考虑Header ↓ sanitizeHeader Body ↓ bodyFilter九、sanitizeHeader 到底是什么例如sanitizeHeader { it HttpHeaders.Authorization }这句话第一次看容易晕。完整展开sanitizeHeader { headerName - headerName HttpHeaders.Authorization }Ktor 相当于不断问“这个 Header Name 需要隐藏吗”如果Content-Type ↓ false正常打印。如果Authorization ↓ true隐藏它的值。十、这里的 it 是 Header Name不是 Value例如真实 RequestAuthorization: Bearer abc123 Platform: Android Language: zh-CNKtor判断headerName Authorization ↓ true ↓ 脱敏 headerName Platform ↓ false ↓ 正常输出最终日志Authorization: *** Platform: Android Language: zh-CN当前sanitizeHeader()的签名本质是fun sanitizeHeader( placeholder: String ***, predicate: (String) - Boolean, )所以默认占位符就是***十一、sanitizeHeader 不修改真实 Request这个一定要分清楚。真实 RequestAuthorization: Bearer abc123经过 Logging真实 HTTP Request ↓ 仍然是 Authorization: Bearer abc123 ↓ 发送给服务器同时Logging ↓ Authorization 命中 sanitizeHeader ↓ 日志 Authorization: ***所以sanitizeHeader 只修改“日志展示”不修改真正发送的 Header。十二、多个敏感 Header 怎么处理例如Authorization Cookie X-Api-Key X-Access-Token可以sanitizeHeader { headerName - headerName HttpHeaders.Authorization || headerName HttpHeaders.Cookie || headerName X-Api-Key || headerName X-Access-Token }我更推荐集中管理private val sensitiveHeaders setOf( HttpHeaders.Authorization, HttpHeaders.Cookie, X-Api-Key, X-Access-Token, )然后sanitizeHeader { headerName - headerName in sensitiveHeaders }这样以后扩展更方便。十三、还可以修改脱敏占位符默认***也可以sanitizeHeader( placeholder redacted ) { headerName - headerName HttpHeaders.Authorization }日志Authorization: redacted十四、但 sanitizeHeader 有一个天然限制它只能处理Header不能处理Request Body Response Body例如{ username: tom, password: 123456, accessToken: abcdef }这里password accessToken根本不是 Header。所以sanitizeHeader { it HttpHeaders.Authorization }对它们完全没有作用。这就是bodyFilter存在的意义。十五、bodyFilter 是什么当前 Ktor 3.5.x 的LoggingConfig.bodyFilter类型是LogBodyFilter它可以决定 Body 日志正常记录 修改以后记录 只记录一部分 直接跳过官方 API 明确把隐藏敏感数据 截断过长 Body 修改日志格式列为其使用场景。十六、bodyFilter 不只可以处理 ResponseLogBodyFilter当前提供filterRequest(...)和filterResponse(...)也就是说Request Body ↓ 可以处理 Response Body ↓ 也可以处理所以可以形成Request JSON ↓ password 脱敏 Response JSON ↓ token 脱敏十七、CommonLogBodyFilter 又是什么如果 Request 和 Response 使用同一套 Body 处理规则没必要分别实现filterRequest filterResponseKtor 提供CommonLogBodyFilter它内部的filterAll(...)会同时用于 Request 和 Response。所以Request Body ─┐ ↓ 同一过滤器 ↑ Response Body ┘特别适合JSON统一脱敏 文本统一截断 二进制统一跳过十八、bodyFilter 返回什么核心返回BodyFilterResult当前 API 中主要有Content Empty Skip其中BufferContent可以返回修改后的可读 BodySkip表示这段 Body 不应该继续打印例如JSON ↓ 处理后 ↓ BufferContent 图片 ↓ Skip(binary body)十九、Ktor 默认其实已经帮你过滤二进制 Body当前bodyFilter默认BinaryLogBodyFilter它负责过滤二进制内容。这非常合理。否则image/jpeg application/pdf video/mp4 zip如果直接LogLevel.ALLLogcat 可能看到一大堆JFIF.....没有任何意义。二十、为什么 Multipart / 文件上传通常应该 Skip例如POST /upload Content-Type: multipart/form-dataBody 中可能包含20MB 图片 100MB 视频 PDF ZIP日志真正需要知道的是上传哪个接口 文件名 Content-Type 文件大小 Status 耗时而不是把 100MB 文件内容打印出来所以Multipart Binary ↓ Skip通常更合理。二十一、Body 脱敏的核心流程例如真实 Request{ username: tom, password: 123456, accessToken: abcdef }我们希望日志{ username: tom, password: ***, accessToken: *** }处理过程ByteReadChannel ↓ 读取日志 Body ↓ String ↓ 解析 JSON ↓ 遍历字段 ↓ 敏感 Key ↓ 替换 *** ↓ 重新生成 JSON ↓ BufferContent ↓ Logging 输出二十二、为什么不推荐正则作为最终 JSON 脱敏学习阶段可能写json.replace( Regex( (password\s*:\s*)[^]*() ), $1***$2, )简单 JSON 能工作。但是 JSON 还可能有嵌套对象 数组 转义字符 字段顺序变化 复杂字符串所以正式项目更推荐String ↓ JsonElement ↓ 递归遍历 ↓ 按 Key 脱敏这正好可以使用前面已经学过的kotlinx.serialization二十三、定义敏感 JSON Key例如private val sensitiveJsonKeys setOf( password, token, accessToken, refreshToken, secret, apiKey, )以后如果还需要phone idCard bankCard只需要继续增加。不过哪些字段应该完全隐藏、哪些应该部分掩码要根据实际业务和合规要求设计。二十四、递归脱敏 JsonElement例如private fun sanitizeJsonElement( element: JsonElement, ): JsonElement { return when (element) { is JsonObject - { JsonObject( element.mapValues { entry, - val key entry.key val value entry.value if ( key in sensitiveJsonKeys ) { JsonPrimitive( *** ) } else { sanitizeJsonElement( value ) } } ) } is JsonArray - { JsonArray( element.map( ::sanitizeJsonElement ) ) } else - { element } } }这样{ user: { name: Tom, password: 123456 }, tokens: { accessToken: abc } }也可以处理成{ user: { name: Tom, password: *** }, tokens: { accessToken: *** } }这比简单 Regex 稳定很多。二十五、封装 sanitizeJson()private val logJson Json { ignoreUnknownKeys true } private fun sanitizeJson( raw: String, ): String { return runCatching { val element logJson.parseToJsonElement( raw ) sanitizeJsonElement( element ).toString() }.getOrElse { // JSON 解析失败时 // 不做复杂处理 raw } }不过这里还有一个安全问题如果JSON 解析失败直接返回raw可能重新暴露敏感信息。所以正式项目更保守可以选择getOrElse { [body omitted: invalid json] }我更推荐后者。二十六、大 Body 为什么也应该限制假设GET /huge-data ↓ Response 15MB JSON即使没有任何敏感字段15MB 全打印仍然很糟糕。会产生巨大 Logcat 日志文件膨胀 CPU 消耗 内存压力 日志平台流量 真正重要日志被淹没所以 Body Logging 通常还应该有最大长度例如8KB 16KB 32KB根据项目决定。二十七、一个简单截断函数例如private fun truncateBody( value: String, maxChars: Int, ): String { if ( value.length maxChars ) { return value } return buildString { append( value.take(maxChars) ) append( \n...[truncated] ) } }注意这里控制的是字符数不是精确网络字节数对于日志限制通常已经够用。二十八、但是“大 Body”最好在读取之前就判断例如Content-Length 100MB如果你的逻辑先全部读取 100MB ↓ 再截断成 8KB显然还是浪费资源。所以更好的contentLength 已知且特别大 ↓ 直接 Skip例如 1MB ↓ 不记录 Body而不是读取以后再处理。二十九、一个完整 Safe BodyFilter下面把JSON 脱敏 文本截断 大 Body Skip Binary Skip组合起来。private fun createSafeBodyFilter( json: Json, maxBodyChars: Int 8 * 1024, maxReadableBytes: Long 1024 * 1024, ): LogBodyFilter { return CommonLogBodyFilter { contentLength, contentType, _, body, - // 1. 已知 Body 特别大 if ( contentLength ! null contentLength maxReadableBytes ) { returnCommonLogBodyFilter BodyFilterResult.Skip( reason body too large, byteSize contentLength, ) } // 2. 不知道 Content-Type if (contentType null) { returnCommonLogBodyFilter BodyFilterResult.Skip( reason unknown content type, byteSize contentLength, ) } val isJson contentType.contentSubtype .contains( json, ignoreCase true, ) val isText contentType.contentType .equals( text, ignoreCase true, ) // 3. Binary / Multipart 等直接跳过 if ( !isJson !isText ) { returnCommonLogBodyFilter BodyFilterResult.Skip( reason non-text body, byteSize contentLength, ) } // 4. 读取日志 Body val raw body .readBuffer() .readString() // 5. JSON 做字段级脱敏 val safe if (isJson) { sanitizeJson( json json, raw raw, ) } else { raw } // 6. 控制日志长度 val truncated truncateBody( value safe, maxChars maxBodyChars, ) // 7. 重新构造日志内容 val buffer Buffer().apply { writeString( truncated ) } BodyFilterResult .BufferContent( buffer buffer, charset Charsets.UTF_8, ) } }当前BodyFilterResult.BufferContent就是接收Buffer Charset把处理后的 Body 提供给 LoggingSkip则表示跳过 Body并可以携带 reason 和 byteSize。Buffer.readString()和writeString()则来自当前kotlinx-ioAPI。三十、sanitizeJson 完整版本private val sensitiveJsonKeys setOf( password, token, accessToken, refreshToken, secret, apiKey, ) private fun sanitizeJson( json: Json, raw: String, ): String { return runCatching { val element json.parseToJsonElement( raw ) sanitizeJsonElement( element ).toString() }.getOrElse { [body omitted: invalid json] } } private fun sanitizeJsonElement( element: JsonElement, ): JsonElement { return when (element) { is JsonObject - { JsonObject( element.mapValues { entry, - if ( entry.key in sensitiveJsonKeys ) { JsonPrimitive( *** ) } else { sanitizeJsonElement( entry.value ) } } ) } is JsonArray - { JsonArray( element.map { sanitizeJsonElement( it ) } ) } else - { element } } }三十一、为什么 BodyFilter 不应该修改真正的业务 Body这一点和sanitizeHeader一样。这里我们做的是日志副本 ↓ 脱敏 ↓ Logger真正网络 Requestpassword 123456如果服务器确实需要这个字段那么真正发送仍然是 123456日志password ***所以网络 Body 和 日志 Body一定分开理解。三十二、不要把 BodyFilter 当 Request 加密器例如业务要求请求 Body ↓ AES 加密 ↓ 发送服务器这不能靠Logging bodyFilter来完成。因为 bodyFilter 的职责是Body ↓ 怎么记录到日志而真正修改发送 BodyRequest Body ↓ 加密 ↓ 发送应该进入后面要讲的Custom Client Plugin transformRequestBody SendingRequest这是完全不同的生命周期。三十三、现在理解 Custom Logger如果项目已经有Kermit或者自己的AppLogger我们不希望业务日志 ↓ Kermit HTTP 日志 ↓ 另外一套 Logger而希望所有日志 ↓ 统一 AppLogger于是logger object : Logger { override fun log( message: String, ) { AppLogger.d( tag HTTP, message message, ) } }三十四、Custom Logger 的完整执行顺序例如真实Authorization: Bearer abc Body: { password: 123456 }流程Logging Plugin ↓ sanitizeHeader ↓ Authorization: *** ↓ bodyFilter ↓ password: *** ↓ 整理成 message ↓ Custom Logger.log(message) ↓ AppLogger所以Custom Logger 收到的已经可以是经过 Ktor 第一轮日志脱敏后的内容。三十五、那 Custom Logger 还要不要脱敏可以再做。比如你的 AppLogger 有手机号脱敏 身份证脱敏 邮箱脱敏 通用 Token 脱敏那就logger object : Logger { override fun log( message: String, ) { val safeMessage globalLogSanitizer .sanitize( message ) AppLogger.d( tag HTTP, message safeMessage, ) } }于是形成第一层 Ktor HTTP 专用脱敏 ↓ Header / Body 第二层 项目级日志脱敏 ↓ 手机号 / 账号 / 通用规则这属于Defense in Depth多一道保护。三十六、但不要把所有脱敏都拖到 Custom Logger比如你完全不使用sanitizeHeader bodyFilter然后把未经处理的整个 HTTP 日志Authorization Password Token全部交给Custom Logger再做字符串正则。虽然可以实现但职责不够清楚。更合理HTTP 已知结构 ↓ 优先 Ktor LoggingConfig 处理 整个 App 通用日志规则 ↓ AppLogger 再兜底所以推荐sanitizeHeader bodyFilter globalSanitize而不是everything ↓ global Regex三十七、方案 ALogger.DEFAULT 完整配置如果项目不需要自己的日志系统fun configureLogging( config: LoggingConfig, json: Json, ) { with(config) { logger Logger.DEFAULT level LogLevel.ALL sanitizeHeader { headerName - headerName in sensitiveHeaders } bodyFilter createSafeBodyFilter( json json, ) } }使用HttpClient { install(Logging) { logger Logger.DEFAULT level LogLevel.ALL sanitizeHeader { it in sensitiveHeaders } bodyFilter createSafeBodyFilter( json logJson, ) } }流程HTTP ↓ Ktor Logging ↓ Header 脱敏 ↓ Body 脱敏 / Skip / 截断 ↓ Logger.DEFAULT这已经可以是一套完整方案。三十八、方案 BCustom Logger 完整配置如果项目已经有统一日志框架HttpClient { install(Logging) { logger object : Logger { override fun log( message: String, ) { AppLogger.d( tag HTTP, message globalLogSanitizer .sanitize( message ), ) } } level LogLevel.ALL sanitizeHeader { it in sensitiveHeaders } bodyFilter createSafeBodyFilter( json logJson, ) } }流程HTTP ↓ LoggingConfig │ ├ sanitizeHeader └ bodyFilter ↓ HTTP 专用第一轮脱敏 ↓ Custom Logger ↓ 项目级第二轮脱敏 ↓ AppLogger / Kermit ↓ Console / File / Upload三十九、如果使用 Kermit可以怎么接概念非常简单install(Logging) { logger object : Logger { override fun log( message: String, ) { co.touchlab.kermit.Logger .withTag(HTTP) .d { message } } } level LogLevel.ALL sanitizeHeader { it in sensitiveHeaders } bodyFilter createSafeBodyFilter( json logJson, ) }也就是说Ktor Logger只是一个 AdapterKtor Logging ↓ Logger 接口 ↓ Kermit四十、filter() 又有什么用除了脱敏LoggingConfig 还能决定哪些 Request 根本不需要记录例如filter { request - request.url.host .contains( api.example.com ) }只有匹配api.example.com的请求进入日志。官方当前文档就是这样使用filter()对请求进行筛选。例如你还可以analytics 埋点 心跳如果请求频率特别高可以考虑不打印完整日志。四十一、Logging 也应该有环境策略推荐思想Debug ↓ 详细日志 Release ↓ 降低 Level 严格脱敏 必要时只记录错误元数据例如level if (isDebug) { LogLevel.ALL } else { LogLevel.INFO }不是说正式环境完全不能有网络日志。而是正式环境的日志目标应该从“方便开发”变成“可以诊断但不泄露数据”。四十二、完整生产思路应该是这样HTTP ↓ Logging ↓ LoggingConfig │ ┌──────────────┼──────────────┐ ↓ ↓ ↓ level filter sanitizeHeader ↓ Header 脱敏 │ ↓ bodyFilter │ ┌──────────┼──────────┐ ↓ ↓ ↓ JSON脱敏 大Body截断 Binary Skip └──────────┼──────────┘ ↓ 安全日志 message ↓ Custom Logger ↓ Global Sanitizer ↓ AppLogger/Kermit ↓ Console / File / Upload四十三、给一个最终完整示例下面把这一篇的核心组合起来。private val logJson Json { ignoreUnknownKeys true } private val sensitiveHeaders setOf( HttpHeaders.Authorization, HttpHeaders.Cookie, X-Api-Key, X-Access-Token, ) private val sensitiveJsonKeys setOf( password, token, accessToken, refreshToken, secret, apiKey, ) private fun sanitizeJsonElement( element: JsonElement, ): JsonElement { return when (element) { is JsonObject - { JsonObject( element.mapValues { entry, - if ( entry.key in sensitiveJsonKeys ) { JsonPrimitive( *** ) } else { sanitizeJsonElement( entry.value ) } } ) } is JsonArray - { JsonArray( element.map { sanitizeJsonElement( it ) } ) } else - { element } } } private fun sanitizeJson( json: Json, raw: String, ): String { return runCatching { val element json.parseToJsonElement( raw ) sanitizeJsonElement( element ).toString() }.getOrElse { [body omitted: invalid json] } } private fun truncateBody( value: String, maxChars: Int, ): String { if ( value.length maxChars ) { return value } return value.take( maxChars ) \n...[truncated] } private fun createSafeBodyFilter( json: Json, maxBodyChars: Int 8 * 1024, maxReadableBytes: Long 1024 * 1024, ): LogBodyFilter { return CommonLogBodyFilter { contentLength, contentType, _, body, - if ( contentLength ! null contentLength maxReadableBytes ) { returnCommonLogBodyFilter BodyFilterResult.Skip( reason body too large, byteSize contentLength, ) } if (contentType null) { returnCommonLogBodyFilter BodyFilterResult.Skip( reason unknown content type, byteSize contentLength, ) } val isJson contentType.contentSubtype .contains( json, ignoreCase true, ) val isText contentType.contentType .equals( text, ignoreCase true, ) if ( !isJson !isText ) { returnCommonLogBodyFilter BodyFilterResult.Skip( reason binary/non-text body, byteSize contentLength, ) } val raw body .readBuffer() .readString() val safe if (isJson) { sanitizeJson( json json, raw raw, ) } else { raw } val truncated truncateBody( value safe, maxChars maxBodyChars, ) val buffer Buffer().apply { writeString( truncated ) } BodyFilterResult .BufferContent( buffer buffer, charset Charsets.UTF_8, ) } }最后 HttpClientfun createHttpClient(): HttpClient { return HttpClient { install(Logging) { logger object : Logger { override fun log( message: String, ) { AppLogger.d( tag HTTP, message message, ) } } level LogLevel.ALL sanitizeHeader { headerName, - headerName in sensitiveHeaders } bodyFilter createSafeBodyFilter( json logJson, ) } } }这个示例表达的并不是所有项目必须照抄而是完整展示 Logging 的职责链Request / Response ↓ Ktor Logging ↓ Header 脱敏 ↓ Body 分类 ↓ JSON 敏感字段脱敏 ↓ 大 Body 控制 ↓ Binary Skip ↓ Custom Logger ↓ AppLogger四十四、如果项目已经有自己的全局脱敏怎么办那就在AppLogger继续加第二层override fun log( message: String, ) { val safeMessage globalSanitizer .sanitize( message ) AppLogger.d( tag HTTP, message safeMessage, ) }于是Ktor ↓ HTTP结构级脱敏 AppLogger ↓ 项目级通用脱敏这是比较完整的正式项目思路。四十五、最容易踩的几个坑坑一以为 Custom Logger 才能脱敏不对。sanitizeHeader bodyFilter属于LoggingConfig所以Logger.DEFAULT同样可以脱敏。坑二以为 sanitizeHeader 会修改 Request不对。它只是日志脱敏不会影响真实网络 Header。坑三以为 bodyFilter 是网络 Body 转换不对。它处理的是Body 如何进入日志不是Body 如何发送给服务器真正 Request/Response Body 转换属于后面的 Custom Plugin 生命周期。坑四只关注 Authorization不管 Body例如登录接口Authorization 已经 ***但password仍然明文输出。这仍然是不安全的。坑五把 Multipart / Binary 全打印通常没有意义而且可能造成巨大性能和日志问题。坑六Body 读完以后再截断巨大内容如果已经知道Content-Length 100MB应该尽早Skip而不是先读取 100MB 再take(8192)坑七把 Logging 当业务日志系统Ktor LoggingHTTP 日志来源而AppLogger / Kermit才更适合作为整个 App 的统一日志基础设施。四十六、本篇总结这一篇最重要的是搞清楚Logging Plugin LoggingConfig Logger三者不是一回事。完整关系HTTP Request / Response ↓ Logging Plugin ↓ LoggingConfig │ ├── level ├── filter ├── sanitizeHeader ├── bodyFilter └── format ↓ 安全日志内容 ↓ Logger / \ ↓ ↓ DEFAULT Custom ↓ AppLogger/Kermit其中sanitizeHeader ↓ 负责 Header 日志脱敏bodyFilter ↓ 负责 Request / Response Body 如何进入日志 ↓ 脱敏 / 截断 / SkipLogger ↓ 负责最终日志输出所以方案 ALogger.DEFAULT和方案 BCustom Logger 都可以使用 KtorLoggingConfig自带的 Header/Body 处理能力。Custom Logger 的真正价值不是“才能脱敏”而是把已经经过 Ktor 处理的 HTTP 日志接入自己的统一日志系统并且可以再增加第二层项目级处理。另外还要特别记住Logging Body 的脱敏和真正 Request/Response Body 的修改完全不是一回事。Logging bodyFilter ↓ 改的是日志 Custom Plugin transformRequestBody / transformResponseBody ↓ 改的才是网络生命周期里的 Body这也正好为下一篇最核心的内容铺路。下一篇第十篇《Ktor Custom Client Plugin从 OkHttp Interceptor 真正理解请求与响应生命周期》下一篇不再停留在install(Logging) install(HttpTimeout) install(HttpRequestRetry)这种“调用官方 API”的层面。而是回到真正的底层扩展模型OkHttp ↓ Application Interceptor RetryAndFollowUpInterceptor BridgeInterceptor CacheInterceptor ConnectInterceptor Network Interceptor CallServerInterceptor ↓ Custom Interceptor ↓ chain.proceed() VS Ktor ↓ HttpClient Pipeline ↓ createClientPlugin ↓ onRequest ↓ transformRequestBody ↓ SendingRequest ↓ Send ↓ Engine ↓ onResponse ↓ transformResponseBody真正解决一次 Ktor Request 到底经过哪些生命周期Plugin 又是如何插入这些生命周期并修改 Request、Response 和 Body 的这会是整个系列从“会使用 Ktor”进入“真正理解 Ktor”的关键一篇。