4. Ktor Client请求构建与响应处理

Ktor Client 是 Kotlin 多平台项目中处理 HTTP 通信的核心组件。它把一次网络调用拆成两个对称的过程:请求构建(把业务数据变成 HTTP 报文)和响应处理(把 HTTP 报文还原成业务对象)。理解这条链路上每个环节的职责,才能写出既正确又易维护的数据层代码。

Ktor Client 的请求构建遵循一个清晰的层次:HttpClient 负责引擎与插件装配,HttpRequestBuilder 负责描述单次请求,ContentNegotiation 负责序列化与反序列化。下面这张图展示了从业务调用到网络报文、再回到业务对象的完整数据流。

请求构建(业务 → HTTP 报文) 业务调用 getUser(id) / createOrder(dto) HttpRequestBuilder method / url / headers / body ContentNegotiation JSON 序列化 + Content-Type HTTP 引擎 OkHttp / Darwin / CIO 网络传输:GET / POST / PUT / DELETE → https://api.example.com/v1/users/42 响应处理(HTTP 报文 → 业务) HttpResponse status / headers / body 反序列化 body<UserDto>() 状态码校验 2xx 成功 / 4xx 客户端错误 业务结果 User / Result<T> 请求要素映射 路径参数:/users/{id} → /users/42  |  查询参数:?page=1&size=20  |  请求头:Authorization: Bearer xxx 请求体:POST/PUT 携带 JSON 序列化对象  |  响应体:body<T>() 按 Content-Type 自动反序列化

四种 HTTP 方法的语义与选择

Ktor Client 提供了 get、post、put、delete 等顶层函数,它们本质上都是对 HttpRequestBuilder 的封装。选择哪种方法,取决于操作的语义:

  • GET:读取资源,参数放在 URL 中,不应有请求体,天然幂等,可被缓存。
  • POST:创建资源或提交数据,请求体承载业务数据,非幂等,重复提交可能产生多条记录。
  • PUT:整体替换资源,请求体是完整的新状态,幂等,重复执行结果一致。
  • DELETE:删除资源,通常无请求体,幂等,重复删除应返回相同结果。

下面是一段典型的请求构建代码,覆盖了路径参数、查询参数、请求头和请求体四类要素:

val client = HttpClient {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
    defaultRequest {
        url("https://api.example.com/v1/")
        header(HttpHeaders.Authorization, "Bearer $token")
    }
}

// GET:路径参数 + 查询参数
val user: User = client.get("users/{id}") {
    url { parameters.append("id", "42") }
    parameter("include", "profile")
}.body()

// POST:请求体序列化
val created: User = client.post("users") {
    contentType(ContentType.Application.Json)
    setBody(CreateUserRequest(name = "Ada", age = 36))
}.body()

// PUT:整体替换
client.put("users/42") {
    contentType(ContentType.Application.Json)
    setBody(UpdateUserRequest(name = "Ada Lovelace"))
}

// DELETE:无请求体
client.delete("users/42")

路径参数与查询参数的区别

路径参数是 URL 路径的一部分,用于定位资源,例如 /users/42 中的 42。在 Ktor 中通过 url { parameters.append("id", "42") } 填充占位符,或直接拼接字符串。查询参数位于 ? 之后,用于过滤、分页、排序等修饰,例如 ?page=1&size=20,通过 parameter("page", 1) 添加。

一个常见的错误是把资源标识放进查询参数,例如 /users?id=42。这虽然能工作,但破坏了 REST 语义,也不利于缓存和日志分析。判断标准很简单:能唯一确定资源路径的用路径参数,用于筛选或可选的用查询参数。

请求头与认证

请求头承载元数据,最常用的是 Content-Type、Accept 和 Authorization。Ktor 的 defaultRequest 插件可以统一注入公共头,避免在每个请求里重复书写。对于需要动态刷新的 Token,推荐使用 Auth 插件配合 bearer 提供者,它会在 401 时自动触发刷新逻辑。

当请求体是 JSON 时,务必设置 contentType(ContentType.Application.Json),否则 ContentNegotiation 无法确定序列化器,服务端也可能因缺少 Content-Type 而拒绝解析。

序列化与反序列化的配置

ContentNegotiation 插件把序列化器与 Content-Type 绑定。安装 json() 后,setBody(obj) 会自动把对象序列化为 JSON 并写入请求体,body<T>() 会根据响应头的 Content-Type 选择反序列化器。实际项目中建议配置 ignoreUnknownKeys = true,这样服务端新增字段时客户端不会崩溃;同时开启 isLenient = true 以容忍部分非严格 JSON。

反序列化的目标类型必须与响应结构匹配。如果服务端返回 {"code":0,"data":{...}} 这类包装结构,应定义对应的 ApiResponse<T> 泛型类,而不是直接反序列化到业务对象。Kotlin 的 @Serializable 注解和默认值机制能显著简化这一过程。

响应状态码与错误处理

Ktor 默认不会因为 4xx/5xx 抛出异常,需要显式检查 response.status。推荐安装 HttpResponseValidator 或使用 expectSuccess = true,让非 2xx 响应自动抛出 ClientRequestException 或 ServerResponseException。在数据层中,通常把异常转换为 Result<T> 或自定义的 ApiResult,避免异常穿透到 UI 层。

suspend fun fetchUser(id: String): Result<User> = runCatching {
    client.get("users/$id").body<User>()
}

这段代码把网络异常、序列化异常和状态码异常统一收敛为 Result,调用方只需处理成功与失败两条路径。对于需要区分错误类型的场景,可以在 runCatching 之前捕获具体异常并映射为业务错误码。

不要在协程中直接使用 try/catch 吞掉所有异常而不记录日志。网络错误、序列化错误和业务错误应分别处理,否则排障时会丢失关键上下文。

一个可复用的请求封装示例

把上述要素组织成一个轻量的 API 客户端,可以显著减少重复代码。以下示例展示了如何用扩展函数统一处理路径拼接、错误映射和反序列化:

class UserApi(private val client: HttpClient) {
    suspend fun getUser(id: Long): Result<User> = runCatching {
        client.get("users/$id").body()
    }

    suspend fun createUser(req: CreateUserRequest): Result<User> = runCatching {
        client.post("users") {
            contentType(ContentType.Application.Json)
            setBody(req)
        }.body()
    }

    suspend fun deleteUser(id: Long): Result<Unit> = runCatching {
        client.delete("users/$id")
        Unit
    }
}

这个封装把 HTTP 方法、路径参数、请求体序列化和错误收敛集中在一处,上层业务只需调用 getUser(42) 并处理 Result。当接口路径或认证方式变化时,修改点也集中可控。

总结来看,Ktor Client 的请求构建与响应处理是一条对称的流水线:请求侧依次经过方法选择、URL 组装、头注入、体序列化;响应侧依次经过状态码校验、Content-Type 识别、体反序列化、错误映射。掌握这条链路上每个环节的职责与配置方式,就能在多平台项目中稳定地组织数据层通信。