4. Ktor Client请求构建与响应处理
Ktor Client 是 Kotlin 多平台项目中处理 HTTP 通信的核心组件。它把一次网络调用拆成两个对称的过程:请求构建(把业务数据变成 HTTP 报文)和响应处理(把 HTTP 报文还原成业务对象)。理解这条链路上每个环节的职责,才能写出既正确又易维护的数据层代码。
Ktor Client 的请求构建遵循一个清晰的层次:HttpClient 负责引擎与插件装配,HttpRequestBuilder 负责描述单次请求,ContentNegotiation 负责序列化与反序列化。下面这张图展示了从业务调用到网络报文、再回到业务对象的完整数据流。
四种 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 时自动触发刷新逻辑。
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 识别、体反序列化、错误映射。掌握这条链路上每个环节的职责与配置方式,就能在多平台项目中稳定地组织数据层通信。