如何用Ktor Client(KMM)拦截并修改HTTP请求体?
问题:Ktor Client POST请求体添加参数不生效
需求:给所有Ktor Client发送的HTTP POST请求的请求体添加特定参数,之前用Retrofit的Interceptor轻松实现,现在切换到KMM使用Ktor,当前用OkHttp引擎,计划换成CIO,不确定引擎切换对拦截器的影响。
尝试了自定义拦截器方案,但修改请求体后,日志显示拦截器内已成功添加参数,但最终发送的请求体还是初始参数,未生效。
NetworkModule
private fun provideKtorClient( config: AppConfig, networkParamsInterceptor: NetworkParamsInterceptor, json: Json, ): HttpClient { return HttpClient(OkHttp) { expectSuccess = false install(Logging) { logger = Logger.DEFAULT level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE } install(ContentNegotiation) { json(json) } defaultRequest { url(config.baseUrl) contentType(ContentType.Application.Json) } engine {} install(InterceptorImpl) { interceptor = networkParamsInterceptor } } } private fun provideJson(): Json { return Json { ignoreUnknownKeys = true isLenient = true coerceInputValues = true } }
自定义拦截器实现(InterceptorImpl)
interface HttpClientInterceptor { fun intercept(context: HttpRequestBuilder): HttpRequestBuilder } class InterceptorImpl(private var config: Config) { class Config { lateinit var interceptor: HttpClientInterceptor } companion object : HttpClientPlugin<Config, InterceptorImpl> { override val key: AttributeKey<InterceptorImpl> = AttributeKey("CustomInterceptors") override fun prepare(block: Config.() -> Unit): InterceptorImpl { val config = Config().apply(block) return InterceptorImpl(config) } override fun install(plugin: InterceptorImpl, scope: HttpClient) { scope.requestPipeline.intercept(HttpRequestPipeline.State) { plugin.config.interceptor.intercept(context) } } } }
NetworkParamsInterceptor
class NetworkParamsInterceptor( private val context: Context, private val userPreferences: UserPreferences, ) : HttpClientInterceptor { private val defaultParams: Map<String, String> by lazy { buildDefaultParams() } @OptIn(InternalAPI::class) override fun intercept(context: HttpRequestBuilder): HttpRequestBuilder { return context.apply { // Set default headers defaultParams.forEach { (key, value) -> header(key, value) } // For POST requests, add default parameters to the body if (method.value.equals("POST", ignoreCase = true)) { val contentType = headers["Content-Type"] if(body is FormDataContent){ val bod = body as FormDataContent bod.formData.forEach { s, strings -> println("XXX 2: $s, $strings")} val newBody = mergeParamsWithJsonBody(bod, defaultParams) body = newBody bodyType = typeInfo<FormDataContent>() } } } } private fun buildDefaultParams(): Map<String, String> { val map = mutableMapOf<String, String>( "os_version" to android.os.Build.VERSION.SDK_INT.toString(), "device_model" to android.os.Build.MODEL, "hl" to java.util.Locale.getDefault().toString() ) val apiKey = context.packageManager.getApplicationInfo( context.packageName, PackageManager.GET_META_DATA ).metaData?.getString("findpenguins.app-api.key") apiKey?.let { map["key"] = it } val appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName appVersion?.let { map["app_version"] = it } userPreferences.getAccessToken()?.let { map["access_token"] = it } return map } private fun mergeParamsWithJsonBody(oldBody: FormDataContent, params: Map<String, String>): JsonObject { return buildJsonObject { // Copy existing parameters oldBody.formData.forEach { s, strings -> put(s, JsonPrimitive(strings.first())) } // Add new parameters params.forEach { (key, value) -> put(key, JsonPrimitive(value)) } } }
TripCalendarApiService
class TripCalendarApiService(private val client: HttpClient, private val json: Json) { suspend fun getCalendarTrip(tripId: String): Result<TripCalendarResponse> { return try { val response = client.post { url { path("trip", "calendar") contentType(ContentType.Application.FormUrlEncoded) setBody(FormDataContent(Parameters.build { append("trip", tripId) })) } } val responseBody = response.bodyAsText() if (response.status == HttpStatusCode.OK) { val tripResponse = json.decodeFromString<TripCalendarResponse>(responseBody) Result.success(tripResponse) } else { Result.failure(DataError.ApiError(response.status.value, Throwable(responseBody))) } } catch (e: Exception) { Result.failure(DataError.UnknownError(e)) } } }
解决方案
问题根源分析
- 拦截阶段错误:当前拦截器在
HttpRequestPipeline.State阶段修改请求体,此阶段是请求准备的最后一步,请求体已经被转换为字节流,修改不会生效。 - 请求体类型不匹配:把
FormDataContent转成JsonObject后直接赋值给body,但原请求的Content-Type是application/x-www-form-urlencoded,类型不匹配导致序列化异常,最终还是使用原请求体。 - 自定义Plugin处理不当:没有正确在请求 pipeline 中传递修改后的请求上下文。
修正步骤
1. 调整拦截阶段
修改InterceptorImpl的拦截阶段为HttpRequestPipeline.Transform,此阶段是请求体转换前的关键节点,修改会被后续流程处理:
override fun install(plugin: InterceptorImpl, scope: HttpClient) { scope.requestPipeline.intercept(HttpRequestPipeline.Transform) { plugin.config.interceptor.intercept(context) proceed() // 继续执行后续pipeline流程 } }
2. 修正请求体合并逻辑
在NetworkParamsInterceptor中,合并参数后保持FormDataContent类型,不要转换为JsonObject:
// 替换原mergeParamsWithJsonBody方法 private fun mergeParamsWithFormBody(oldBody: FormDataContent, params: Map<String, String>): FormDataContent { val newParameters = Parameters.build { // 复制原有参数 oldBody.formData.forEach { key, values -> values.forEach { append(key, it) } } // 添加默认参数 params.forEach { (key, value) -> append(key, value) } } return FormDataContent(newParameters) } // 修改intercept方法中的body处理逻辑 override fun intercept(context: HttpRequestBuilder): HttpRequestBuilder { return context.apply { // 设置默认headers defaultParams.forEach { (key, value) -> header(key, value) } if (method.value.equals("POST", ignoreCase = true)) { if(body is FormDataContent){ val bod = body as FormDataContent val newBody = mergeParamsWithFormBody(bod, defaultParams) body = newBody // 不需要手动设置bodyType,Ktor会自动识别 } } } }
3. 处理JSON类型请求体(可选)
如果同时需要支持JSON类型的POST请求,可扩展拦截逻辑:
override fun intercept(context: HttpRequestBuilder): HttpRequestBuilder { return context.apply { defaultParams.forEach { (key, value) -> header(key, value) } if (method.value.equals("POST", ignoreCase = true)) { when(val currentBody = body) { is FormDataContent -> { body = mergeParamsWithFormBody(currentBody, defaultParams) } is TextContent -> { // 处理JSON请求体 val jsonObject = json.parseToJsonElement(currentBody.text).jsonObject val newJsonObject = buildJsonObject { jsonObject.forEach { put(it.key, it.value) } defaultParams.forEach { (k, v) -> put(k, JsonPrimitive(v)) } } body = TextContent(newJsonObject.toString(), ContentType.Application.Json) } } } } }
4. 引擎切换注意事项
切换到CIO引擎时,上述拦截逻辑无需大幅修改:
- CIO引擎完全支持Ktor的请求pipeline机制,自定义拦截器逻辑通用
- 注意CIO的配置差异(如超时、连接池),但不影响拦截器功能
内容的提问来源于stack exchange,提问作者Christian X
相关产品推荐
相关产品推荐

