拓冰建站拓冰建站
首页 / 资讯中心 / 正文

第七篇:Ktor 统一响应模型:ApiResponse、业务 code 与 data 解包

前面我们已经把请求链路逐渐搭起来了ApiService ↓ NetworkClient ↓ HttpClient ↓ Plugin ↓ Engine现在已经可以写val user: User networkClient.get( path users/1001, )看起来很干净。但正式项目里后端通常不会直接返回{ id: 1001, name: Tom }更常见的是统一包一层{ code: 0, msg: success, data: { id: 1001, name: Tom } }于是就出现了一个非常重要的问题Ktor 收到的是 HTTP Response而业务真正关心的是data。这两层到底应该怎么处理这就是这一篇要解决的问题。一、先区分两个 Response很多人第一次写网络层时很容易把HttpResponse和ApiResponseT混在一起。但它们完全不是一个层级。HttpResponse这是 HTTP 协议层的响应。例如服务器返回HTTP/1.1 200 OK Content-Type: application/json以及{ code: 0, msg: success, data: { id: 1001, name: Tom } }Ktor 拿到val response: HttpResponse client.get(users/1001)这里HttpResponse包含的是HTTP Status Header Body Content-Type比如status 200 body JSONApiResponse这是我们自己根据后端协议定义的数据模型。例如Serializable data class ApiResponseT( val code: Int, val msg: String, val data: T, )它对应的是 HTTP Body{ code: 0, msg: success, data: {} }所以关系是HTTP Response │ ├── status ├── headers └── body ↓ ApiResponseT也就是说HttpResponse是 HTTP 层对象ApiResponseT是业务协议层对象。二、一次完整响应其实有两层状态假设接口GET /users/1001服务器返回HTTP/1.1 200 OKBody{ code: 0, msg: success, data: { id: 1001, name: Tom } }这里其实存在两个成功条件。第一层HTTP 200表示HTTP 请求本身成功完成。第二层code 0表示业务执行成功。所以HTTP 成功 ≠ 业务一定成功这点非常重要。三、HTTP 200 也可能是业务失败例如服务器HTTP/1.1 200 OK但是 Body{ code: 10001, msg: 用户不存在, data: null }从 HTTP 看200 OK请求是成功返回了。但业务上code 10001明显失败。所以如果你只判断response.status HttpStatusCode.OK是不够的。正式网络层通常需要处理第一层HTTP 状态 ↓ 第二层业务 code四、定义统一 ApiResponse例如Serializable data class ApiResponseT( val code: Int, val msg: String , val data: T, )如果后端{ code: 0, msg: , data: { id: 1001, name: Tom } }那么val response client .get(users/1001) .bodyApiResponseUser()得到ApiResponseUser其中code 0 msg data User(...)五、泛型 T 的价值就在这里同一个结构ApiResponseT可以表示很多接口。用户ApiResponseUser订单ApiResponseOrder列表ApiResponseListUser分页ApiResponsePageUser所以ApiResponseT真正解决的是后端统一外层结构固定但data类型变化的问题。结构ApiResponse │ ├── code ├── msg └── data ↓ T六、最直接的写法是什么假设现在 UserApisuspend fun getUser( id: Long, ): User { val response client .get(users/$id) .bodyApiResponseUser() if (response.code ! 0) { throw RuntimeException( response.msg ) } return response.data }功能上完全没问题。过程GET ↓ HttpResponse ↓ ApiResponseUser ↓ 检查 code ↓ 返回 data ↓ User但是很快就会发现问题。七、如果每个 ApiService 都判断 code会发生什么UserApiif (response.code ! 0) { throw ... }OrderApiif (response.code ! 0) { throw ... }RepairApiif (response.code ! 0) { throw ... }所有接口都开始重复bodyApiResponseT() ↓ if (code ! 0) ↓ throw ↓ return data这和前面每个接口都写BaseUrl Header其实是同一种问题公共逻辑放错层了。业务 ApiService 不应该反复处理统一响应协议。八、所以应该放到 NetworkClient前面我们已经建立ApiService ↓ NetworkClient ↓ HttpClient那么统一响应处理正好适合放到NetworkClient比如suspend inline fun reified T get( path: String, ): T { val response client .get(path) .bodyApiResponseT() if (response.code ! 0) { throw ApiException( code response.code, message response.msg, ) } return response.data }于是业务suspend fun getUser( id: Long, ): User { return networkClient.get( path users/$id, ) }UserApi 已经完全不需要知道ApiResponseUser code msg data九、这一步叫“data 解包”后端{ code: 0, msg: , data: { id: 1001, name: Tom } }NetworkClientApiResponseUser ↓ 检查 code ↓ 取 data ↓ User所以业务层val user: User拿到的不是ApiResponseUser而是User这就是统一响应解包。十、为什么业务层最好不要长期持有 ApiResponse假设 Repositorysuspend fun getUser(): ApiResponseUser然后 ViewModelval response repository.getUser() if (response.code 0) { val user response.data }这意味着ViewModel ↓ 知道后端 code 协议甚至 UI 可能也开始知道code 0 code 10001这样后端协议就逐渐向上泄漏。更理想的是后端协议 ↓ NetworkClient 消化掉 ↓ Repository 只得到 User / AppError所以ApiResponseT最好主要存在于网络基础设施层而不是扩散到整个 App。十一、理想结构是什么可以理解成Server ↓ HTTP Response ↓ ApiResponseT ↓ NetworkClient ↓ T ↓ Repository ↓ ViewModel业务层看到成功 ↓ T 失败 ↓ AppError而不是成功 ↓ 自己检查 code 失败 ↓ 自己解析 msg十二、但是 data 一定有值吗这里开始进入真实项目问题。假设{ code: 0, msg: success, data: null }有些接口确实可能没有 data例如删除成功 提交成功 退出登录成功后端可能只返回{ code: 0, msg: success, data: null }那么data class ApiResponseT( val code: Int, val msg: String, val data: T, )就可能不够灵活。十三、可以设计成 T?例如Serializable data class ApiResponseT( val code: Int, val msg: String , val data: T? null, )这样data ↓ 允许 null但是这又带来一个问题val data: T?那么普通成功接口getUser(): UserNetworkClient 要怎么从T?安全变成T所以这里要根据后端协议设计。十四、一种做法成功必须有 data如果项目后端规范非常明确只要code 0且接口声明返回数据data一定存在。那么可以val data response.data ?: throw ApiException( code response.code, message Response data is null, )然后return data这就把后端返回成功但 data 异常为空也归到网络协议异常里。十五、无 data 接口可以单独用 Unit比如删除suspend fun deleteUser( id: Long, ) { networkClient.deleteUnit( path users/$id, ) }后端{ code: 0, msg: success, data: null }这类接口可以根据项目协议单独处理。也可以定义Serializable object EmptyData然后ApiResponseEmptyData具体采用哪种方式取决于后端协议。关键不是死记一个固定答案。而是统一响应模型必须和真实后端协议匹配。十六、code 0 为什么应该统一定义不要在网络层到处if (response.code 0)可以统一private const val SUCCESS_CODE 0或者object ApiCode { const val SUCCESS 0 }然后if (response.code ! ApiCode.SUCCESS) { ... }这样以后后端如果改成功码 0 → 200至少修改点更集中。十七、业务失败应该直接 RuntimeException 吗前面为了演示我们写throw RuntimeException( response.msg )正式项目显然不够。因为code ! 0是一个明确的业务 API 错误所以可以定义class ApiException( val code: Int, override val message: String, ) : Exception(message)然后if (response.code ! ApiCode.SUCCESS) { throw ApiException( code response.code, message response.msg, ) }这样上层至少知道这是业务协议错误而不是普通未知异常。后面异常篇还会进一步把它转换成AppError十八、HTTP 失败和业务失败完全不同例如HTTP 500这属于HTTP Error而HTTP 200Body{ code: 10001, msg: 用户不存在, data: null }属于Business Error所以完整链路Request ↓ HTTP ↓ HTTP Status 是否成功 ↓ 是 ↓ 解析 ApiResponseT ↓ code 是否成功 ↓ 是 ↓ 返回 data失败分支HTTP Status 失败 ↓ HTTP Exception code ! 0 ↓ ApiException这就是为什么后面一定要建立完整异常体系。十九、expectSuccess true 在这里负责哪一层前面我们已经接触过HttpClient { expectSuccess true }它主要处理的是HTTP Status例如401 404 500也就是HTTP 层错误它并不知道你后端 Body 里面{ code: 10001 }是什么意思。所以expectSuccess ↓ 处理 HTTP Status ApiResponse.code ↓ 处理业务协议状态这两个一定不要混。二十、完整成功链路例如HTTP 200Body{ code: 0, msg: success, data: { id: 1001, name: Tom } }流程HttpClient ↓ HTTP 200 ↓ expectSuccess 通过 ↓ ContentNegotiation ↓ ApiResponseUser ↓ code 0 ↓ data ↓ User最终val user: User二十一、HTTP 失败链路例如HTTP 500流程HttpClient ↓ 500 ↓ expectSuccess true ↓ 抛 HTTP 相关异常 ↓ 异常体系处理此时甚至可能还没有进入正常ApiResponseT data 解包流程。二十二、业务失败链路例如HTTP 200Body{ code: 10001, msg: 用户不存在, data: null }流程HTTP 200 ↓ expectSuccess 通过 ↓ 解析 ApiResponseUser ↓ code ! 0 ↓ ApiException这就是HTTP 成功 业务失败非常典型的场景。二十三、JSON 解析失败又属于第三层假设HTTP 200Body 本来应该{ code: 0, msg: , data: {} }结果后端返回了错误格式html502 Bad Gateway/html或者字段类型不匹配{ code: abc }那么HTTP ↓ 200 ↓ 但是 JSON 解析失败这又不是HTTP Error也不是Business Error而是Serialization / Parse Error所以完整网络错误已经逐渐出现网络连接错误 HTTP 错误 JSON 解析错误 业务 code 错误 未知错误这就是下一阶段异常体系要处理的内容。二十四、NetworkClient 可以先写成什么样一个基础版本class NetworkClient( private val client: HttpClient, ) { suspend inline fun reified T get( path: String, noinline block: HttpRequestBuilder.() - Unit {}, ): T { val response client .get(path) { block() } .bodyApiResponseT() if (response.code ! ApiCode.SUCCESS) { throw ApiException( code response.code, message response.msg, ) } return response.data } }现在get()已经不再只是HttpClient.get() ↓ bodyT()而变成GET ↓ ApiResponseT ↓ 检查业务 code ↓ data ↓ T二十五、POST 也是一样suspend inline fun reified T, reified B, post( path: String, body: B, noinline block: HttpRequestBuilder.() - Unit {}, ): T { val response client .post(path) { contentType( ContentType.Application.Json ) setBody(body) block() } .bodyApiResponseT() if (response.code ! ApiCode.SUCCESS) { throw ApiException( code response.code, message response.msg, ) } return response.data }可以看到get post put delete马上又会重复bodyApiResponseT() ↓ 检查 code ↓ return data这说明还可以继续抽。二十六、可以抽一个统一解析函数例如private fun T unwrap( response: ApiResponseT, ): T { if (response.code ! ApiCode.SUCCESS) { throw ApiException( code response.code, message response.msg, ) } return response.data }然后 GETsuspend inline fun reified T get( path: String, ): T { val response client .get(path) .bodyApiResponseT() return unwrap(response) }POSTsuspend inline fun reified T, reified B, post( path: String, body: B, ): T { val response client .post(path) { setBody(body) } .bodyApiResponseT() return unwrap(response) }于是HTTP Method ↓ 负责发送 unwrap() ↓ 负责业务响应解包职责更清楚。二十七、还可以进一步抽成 request()后面你甚至会发现GET POST PUT DELETE区别主要在怎么构建 HttpRequest而后面的ApiResponseT ↓ code ↓ data完全一样。所以可以进一步做成suspend inline fun reified T request( crossinline request: suspend () - HttpResponse, ): T { val response request() .bodyApiResponseT() if (response.code ! ApiCode.SUCCESS) { throw ApiException( code response.code, message response.msg, ) } return response.data }GETsuspend inline fun reified T get( path: String, ): T { return request { client.get(path) } }POSTsuspend inline fun reified T, reified B, post( path: String, body: B, ): T { return request { client.post(path) { setBody(body) } } }这样统一处理链路更明显GET / POST / PUT / DELETE ↓ requestT() ↓ ApiResponseT ↓ code ↓ data二十八、但不要为了抽象而抽象如果项目很简单get() post() put() delete()各自只有十几行。完全没必要一开始就做request() requestInternal() execute() unwrap() map() parse()层层套娃。所以还是前面的原则先出现重复再抽公共逻辑。不要为了“看起来像框架”而提前设计复杂层次。二十九、ApiService 现在会变成什么样例如class UserApi( private val client: NetworkClient, ) { suspend fun getUser( id: Long, ): User { return client.get( path users/$id, ) } }业务层完全不知道HTTP 200 ApiResponseUser code 0 data它只知道调用成功 ↓ User失败抛出统一错误这就是我们想要的边界。三十、Repository 会更加干净例如class UserRepository( private val userApi: UserApi, ) { suspend fun getUser( id: Long, ): User { return userApi.getUser(id) } }Repository 也不需要if (response.code 0)因为业务协议处理已经被网络层消化掉。三十一、业务 code 应该全部在 NetworkClient 处理吗这里要区分通用业务协议错误和具体业务分支例如code 10001 用户未登录可以统一映射Unauthorized但是code 23001 优惠券已被领取这种可能是订单业务自己要做特殊 UI。所以网络层适合负责把 code 转成结构化错误而不是决定所有业务 UI 怎么处理比如ApiException( code 23001, message 优惠券已领取, )然后更上层根据业务决定Toast Dialog 跳转 刷新页面网络层只负责提供正确的信息。三十二、不要让 NetworkClient 直接弹 Toast这是很重要的边界。错误做法if (response.code ! 0) { Toast.makeText( context, response.msg, Toast.LENGTH_SHORT, ).show() }网络层不应该知道Android Context Toast UI尤其 KMP 中commonMain更不能这么做。正确思路NetworkClient ↓ ApiException / AppError ViewModel ↓ 决定 UI 行为这也是为什么错误模型需要结构化。三十三、统一响应也要考虑 List / Page例如{ code: 0, msg: , data: [ { id: 1 }, { id: 2 } ] }直接ApiResponseListUser分页{ code: 0, msg: , data: { page: 1, pageSize: 20, total: 100, items: [] } }可以Serializable data class PageT( val page: Int, val pageSize: Int, val total: Long, val items: ListT, )然后ApiResponsePageUser所以泛型可以继续嵌套ApiResponse ↓ Page ↓ List ↓ User三十四、这也是 kotlinx.serialization 泛型能力的实际价值前面单独讲 kotlinx.serialization 时泛型看起来比较抽象。现在就真正落地了。例如bodyApiResponsePageUser()整个类型关系ApiResponseT ↓ T PageUser ↓ PageT ↓ T User最终JSON ↓ ApiResponsePageUser这就是统一网络模型能够成立的基础。三十五、如果不同接口返回结构不统一怎么办真实项目可能存在大部分接口 ↓ { code, msg, data } 第三方接口 ↓ 直接返回对象 下载接口 ↓ ByteReadChannel 某个旧接口 ↓ 另一套 Response 格式这时候不要强行所有 Client 都bodyApiResponseT()因为前面已经讲过不同网络职责可以使用不同 NetworkClient。例如apiClient ↓ 统一 ApiResponseT weatherClient ↓ 第三方 WeatherResponse downloadClient ↓ 流式 Response所以统一响应也是有作用域的。三十六、这再次说明多 Client 的价值假设apiClient所有接口{ code: 0, msg: , data: {} }那么apiClient ↓ 统一 unwrap完全合理。但是thirdPartyClient可能{ status: ok, result: {} }就不应该硬套ApiResponseT所以统一仍然应该理解成在正确的网络协议作用域内统一。三十七、HttpResponse 和 ApiResponse 再对比一次这个地方非常值得最后再强化。HttpResponse ↓ Ktor / HTTP 层 包含 status headers body而ApiResponseT ↓ 项目 / 后端协议层 包含 code msg data关系HttpResponse │ ├── status 200 ├── headers └── body ↓ ApiResponseT所以千万别把HTTP status和业务 code当成同一个东西。三十八、可以把一次请求理解成四层结果正式项目中一次请求至少可能经过第一层 网络连接 ↓ 有没有断网 / Timeout 第二层 HTTP ↓ 401 / 404 / 500 第三层 数据格式 ↓ JSON 能不能解析 第四层 业务协议 ↓ code 0 ?只有都通过Network OK ↓ HTTP OK ↓ Parse OK ↓ Business OK最后才是data T这张图其实就是后面异常体系的基础。三十九、现在 NetworkClient 的职责更完整了上一篇NetworkClient ↓ 统一 get/post/put/delete这一篇以后NetworkClient │ ├── 发送 HTTP 请求 ├── 解析 ApiResponseT ├── 检查业务 code └── 解包 data业务得到T所以它开始真正成为项目级网络请求边界。四十、本篇总结后端统一响应{ code: 0, msg: , data: T }客户端可以定义Serializable data class ApiResponseT( val code: Int, val msg: String, val data: T, )但一定要区分HttpResponse ↓ HTTP 层 ApiResponseT ↓ 业务协议层请求成功需要至少经历HTTP 成功 ↓ JSON 解析成功 ↓ code 成功 ↓ data所以HTTP 200 ≠ 业务一定成功NetworkClient 可以统一HttpResponse ↓ ApiResponseT ↓ 检查 code ↓ 解包 data ↓ T于是 ApiServicesuspend fun getUser( id: Long, ): User { return networkClient.get( path users/$id, ) }不再知道code msg data这些后端协议细节。最终可以记住一句话HttpResponse 负责表达 HTTP 层结果ApiResponse 负责表达后端业务协议NetworkClient 的职责之一就是把后端统一响应解析、校验并解包让业务层最终只拿到真正需要的 T。但到这里还有一个非常大的问题没有解决断网怎么办 Timeout 怎么办 401 / 404 / 500 怎么办 JSON 解析失败怎么办 code ! 0 怎么统一 未知异常怎么办所以接下来必须进入整个Ktor 网络层最重要的一篇之一。下一篇《Ktor 异常体系断网、Timeout、HTTP、JSON 与业务错误如何统一成 AppError》下一篇会把一次网络请求完整拆成Network Error ↓ 断网 / DNS / Socket Timeout ↓ Request / Connect / Socket HTTP Error ↓ 401 / 403 / 404 / 500 Serialization Error ↓ JSON 解析失败 Business Error ↓ code ! 0 Unknown Error ↓ 兜底最终统一成Throwable ↓ ExceptionMapper ↓ AppError让NetworkClient ↓ 不再把各种底层异常直接暴露给 Repository / ViewModel这一步完成以后整个 Ktor 网络层才真正开始具备正式项目的完整形态。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门