
第九篇我們已經知道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”的關鍵一篇。