前面我们已经把请求链路逐渐搭起来了:
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
和:
ApiResponse<T>
混在一起。
但它们完全不是一个层级。
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 = JSON
ApiResponse
这是我们自己根据后端协议定义的数据模型。
例如:
@Serializable
data class ApiResponse<T>(
val code: Int,
val msg: String,
val data: T,
)
它对应的是 HTTP Body:
{
"code": 0,
"msg": "success",
"data": {}
}
所以关系是:
HTTP Response
│
├── status
├── headers
└── body
↓
ApiResponse<T>
也就是说:
HttpResponse是 HTTP 层对象,ApiResponse<T>是业务协议层对象。
二、一次完整响应其实有两层状态
假设接口:
GET /users/1001
服务器返回:
HTTP/1.1 200 OK
Body:
{
"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 ApiResponse<T>(
val code: Int,
val msg: String = "",
val data: T,
)
如果后端:
{
"code": 0,
"msg": "",
"data": {
"id": 1001,
"name": "Tom"
}
}
那么:
val response =
client
.get("users/1001")
.body<ApiResponse<User>>()
得到:
ApiResponse<User>
其中:
code = 0
msg = ""
data = User(...)
五、泛型 T 的价值就在这里
同一个结构:
ApiResponse<T>
可以表示很多接口。
用户:
ApiResponse<User>
订单:
ApiResponse<Order>
列表:
ApiResponse<List<User>>
分页:
ApiResponse<Page<User>>
所以:
ApiResponse<T>
真正解决的是:
后端统一外层结构固定,但
data类型变化的问题。
结构:
ApiResponse
│
├── code
├── msg
└── data
↓
T
六、最直接的写法是什么?
假设现在 UserApi:
suspend fun getUser(
id: Long,
): User {
val response =
client
.get("users/$id")
.body<ApiResponse<User>>()
if (response.code != 0) {
throw RuntimeException(
response.msg
)
}
return response.data
}
功能上完全没问题。
过程:
GET
↓
HttpResponse
↓
ApiResponse<User>
↓
检查 code
↓
返回 data
↓
User
但是很快就会发现问题。
七、如果每个 ApiService 都判断 code,会发生什么?
UserApi:
if (response.code != 0) {
throw ...
}
OrderApi:
if (response.code != 0) {
throw ...
}
RepairApi:
if (response.code != 0) {
throw ...
}
所有接口都开始重复:
body<ApiResponse<T>>()
↓
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)
.body<ApiResponse<T>>()
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 已经完全不需要知道:
ApiResponse<User>
code
msg
data
九、这一步叫"data 解包"
后端:
{
"code": 0,
"msg": "",
"data": {
"id": 1001,
"name": "Tom"
}
}
NetworkClient:
ApiResponse<User>
↓
检查 code
↓
取 data
↓
User
所以业务层:
val user: User
拿到的不是:
ApiResponse<User>
而是:
User
这就是:
统一响应解包。
十、为什么业务层最好不要长期持有 ApiResponse?
假设 Repository:
suspend fun getUser(): ApiResponse<User>
然后 ViewModel:
val response =
repository.getUser()
if (response.code == 0) {
val user = response.data
}
这意味着:
ViewModel
↓
知道后端 code 协议
甚至 UI 可能也开始知道:
code == 0
code == 10001
这样后端协议就逐渐向上泄漏。
更理想的是:
后端协议
↓
NetworkClient 消化掉
↓
Repository 只得到 User / AppError
所以:
ApiResponse<T>
最好主要存在于:
网络基础设施层
而不是扩散到整个 App。
十一、理想结构是什么?
可以理解成:
Server
↓
HTTP Response
↓
ApiResponse<T>
↓
NetworkClient
↓
T
↓
Repository
↓
ViewModel
业务层看到:
成功
↓
T
失败
↓
AppError
而不是:
成功?
↓
自己检查 code
失败?
↓
自己解析 msg
十二、但是 data 一定有值吗?
这里开始进入真实项目问题。
假设:
{
"code": 0,
"msg": "success",
"data": null
}
有些接口确实可能:
没有 data
例如:
删除成功
提交成功
退出登录成功
后端可能只返回:
{
"code": 0,
"msg": "success",
"data": null
}
那么:
data class ApiResponse<T>(
val code: Int,
val msg: String,
val data: T,
)
就可能不够灵活。
十三、可以设计成 T?
例如:
@Serializable
data class ApiResponse<T>(
val code: Int,
val msg: String = "",
val data: T? = null,
)
这样:
data
↓
允许 null
但是这又带来一个问题:
val data: T?
那么普通成功接口:
getUser(): User
NetworkClient 要怎么从:
T?
安全变成:
T
所以这里要根据后端协议设计。
十四、一种做法:成功必须有 data
如果项目后端规范非常明确:
只要
code == 0且接口声明返回数据,data一定存在。
那么可以:
Kotlin
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.delete<Unit>(
path = "users/$id",
)
}
后端:
{
"code": 0,
"msg": "success",
"data": null
}
这类接口可以根据项目协议单独处理。
也可以定义:
@Serializable
object EmptyData
然后:
ApiResponse<EmptyData>
具体采用哪种方式,取决于后端协议。
关键不是死记一个固定答案。
而是:
统一响应模型必须和真实后端协议匹配。
十六、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 200
Body:
{
"code": 10001,
"msg": "用户不存在",
"data": null
}
属于:
Business Error
所以完整链路:
Request
↓
HTTP
↓
HTTP Status 是否成功?
↓
是
↓
解析 ApiResponse<T>
↓
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 200
Body:
{
"code": 0,
"msg": "success",
"data": {
"id": 1001,
"name": "Tom"
}
}
流程:
HttpClient
↓
HTTP 200
↓
expectSuccess 通过
↓
ContentNegotiation
↓
ApiResponse<User>
↓
code == 0
↓
data
↓
User
最终:
val user: User
二十一、HTTP 失败链路
例如:
HTTP 500
流程:
HttpClient
↓
500
↓
expectSuccess = true
↓
抛 HTTP 相关异常
↓
异常体系处理
此时甚至可能还没有进入正常:
ApiResponse<T> data 解包
流程。
二十二、业务失败链路
例如:
HTTP 200
Body:
{
"code": 10001,
"msg": "用户不存在",
"data": null
}
流程:
HTTP 200
↓
expectSuccess 通过
↓
解析 ApiResponse<User>
↓
code != 0
↓
ApiException
这就是:
HTTP 成功
业务失败
非常典型的场景。
二十三、JSON 解析失败又属于第三层
假设:
HTTP 200
Body 本来应该:
{
"code": 0,
"msg": "",
"data": {}
}
结果后端返回了错误格式:
<html>502 Bad Gateway</html>
或者字段类型不匹配:
{
"code": "abc"
}
那么:
HTTP
↓
200
↓
但是 JSON 解析失败
这又不是:
HTTP Error
也不是:
Business Error
而是:
Serialization / Parse Error
所以完整网络错误已经逐渐出现:
网络连接错误
HTTP 错误
JSON 解析错误
业务 code 错误
未知错误
这就是下一阶段异常体系要处理的内容。
二十四、NetworkClient 可以先写成什么样?
一个基础版本:
Kotlin
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()
}
.body<ApiResponse<T>>()
if (response.code != ApiCode.SUCCESS) {
throw ApiException(
code = response.code,
message = response.msg,
)
}
return response.data
}
}
现在 get() 已经不再只是:
HttpClient.get()
↓
body<T>()
而变成:
GET
↓
ApiResponse<T>
↓
检查业务 code
↓
data
↓
T
二十五、POST 也是一样
Kotlin
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()
}
.body<ApiResponse<T>>()
if (response.code != ApiCode.SUCCESS) {
throw ApiException(
code = response.code,
message = response.msg,
)
}
return response.data
}
可以看到:
get
post
put
delete
马上又会重复:
body<ApiResponse<T>>()
↓
检查 code
↓
return data
这说明还可以继续抽。
二十六、可以抽一个统一解析函数
例如:
Kotlin
private fun <T> unwrap(
response: ApiResponse<T>,
): T {
if (response.code != ApiCode.SUCCESS) {
throw ApiException(
code = response.code,
message = response.msg,
)
}
return response.data
}
然后 GET:
Kotlin
suspend inline fun <reified T> get(
path: String,
): T {
val response =
client
.get(path)
.body<ApiResponse<T>>()
return unwrap(response)
}
POST:
Kotlin
suspend inline fun <
reified T,
reified B,
> post(
path: String,
body: B,
): T {
val response =
client
.post(path) {
setBody(body)
}
.body<ApiResponse<T>>()
return unwrap(response)
}
于是:
HTTP Method
↓
负责发送
unwrap()
↓
负责业务响应解包
职责更清楚。
二十七、还可以进一步抽成 request()
后面你甚至会发现:
GET
POST
PUT
DELETE
区别主要在:
怎么构建 HttpRequest
而后面的:
ApiResponse<T>
↓
code
↓
data
完全一样。
所以可以进一步做成:
Kotlin
suspend inline fun <reified T> request(
crossinline request: suspend () -> HttpResponse,
): T {
val response =
request()
.body<ApiResponse<T>>()
if (response.code != ApiCode.SUCCESS) {
throw ApiException(
code = response.code,
message = response.msg,
)
}
return response.data
}
GET:
Kotlin
suspend inline fun <reified T> get(
path: String,
): T {
return request {
client.get(path)
}
}
POST:
Kotlin
suspend inline fun <
reified T,
reified B,
> post(
path: String,
body: B,
): T {
return request {
client.post(path) {
setBody(body)
}
}
}
这样统一处理链路更明显:
GET / POST / PUT / DELETE
↓
request<T>()
↓
ApiResponse<T>
↓
code
↓
data
二十八、但不要为了抽象而抽象
如果项目很简单:
get()
post()
put()
delete()
各自只有十几行。
完全没必要一开始就做:
request()
requestInternal()
execute()
unwrap()
map()
parse()
层层套娃。
所以还是前面的原则:
先出现重复,再抽公共逻辑。
不要为了"看起来像框架"而提前设计复杂层次。
二十九、ApiService 现在会变成什么样?
例如:
Kotlin
class UserApi(
private val client: NetworkClient,
) {
suspend fun getUser(
id: Long,
): User {
return client.get(
path = "users/$id",
)
}
}
业务层完全不知道:
HTTP 200
ApiResponse<User>
code == 0
data
它只知道:
调用成功
↓
User
失败:
抛出统一错误
这就是我们想要的边界。
三十、Repository 会更加干净
例如:
Kotlin
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
这是很重要的边界。
错误做法:
Kotlin
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
}
]
}
直接:
ApiResponse<List<User>>
分页:
{
"code": 0,
"msg": "",
"data": {
"page": 1,
"pageSize": 20,
"total": 100,
"items": []
}
}
可以:
@Serializable
data class Page<T>(
val page: Int,
val pageSize: Int,
val total: Long,
val items: List<T>,
)
然后:
ApiResponse<Page<User>>
所以泛型可以继续嵌套:
ApiResponse
↓
Page
↓
List
↓
User
三十四、这也是 kotlinx.serialization 泛型能力的实际价值
前面单独讲 kotlinx.serialization 时,泛型看起来比较抽象。
现在就真正落地了。
例如:
body<ApiResponse<Page<User>>>()
整个类型关系:
ApiResponse<T>
↓
T = Page<User>
↓
Page<T>
↓
T = User
最终:
JSON
↓
ApiResponse<Page<User>>
这就是统一网络模型能够成立的基础。
三十五、如果不同接口返回结构不统一怎么办?
真实项目可能存在:
大部分接口
↓
{ code, msg, data }
第三方接口
↓
直接返回对象
下载接口
↓
ByteReadChannel
某个旧接口
↓
另一套 Response 格式
这时候不要强行所有 Client 都:
body<ApiResponse<T>>()
因为前面已经讲过:
不同网络职责可以使用不同 NetworkClient。
例如:
apiClient
↓
统一 ApiResponse<T>
weatherClient
↓
第三方 WeatherResponse
downloadClient
↓
流式 Response
所以统一响应也是有作用域的。
三十六、这再次说明多 Client 的价值
假设:
apiClient
所有接口:
{
"code": 0,
"msg": "",
"data": {}
}
那么:
apiClient
↓
统一 unwrap
完全合理。
但是:
thirdPartyClient
可能:
{
"status": "ok",
"result": {}
}
就不应该硬套:
ApiResponse<T>
所以:
统一
仍然应该理解成:
在正确的网络协议作用域内统一。
三十七、HttpResponse 和 ApiResponse 再对比一次
这个地方非常值得最后再强化。
HttpResponse
↓
Ktor / HTTP 层
包含:
status
headers
body
而:
ApiResponse<T>
↓
项目 / 后端协议层
包含:
code
msg
data
关系:
HttpResponse
│
├── status = 200
├── headers
└── body
↓
ApiResponse<T>
所以千万别把:
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 请求
├── 解析 ApiResponse<T>
├── 检查业务 code
└── 解包 data
业务得到:
T
所以它开始真正成为:
项目级网络请求边界。
四十、本篇总结
后端统一响应:
{
"code": 0,
"msg": "",
"data": T
}
客户端可以定义:
@Serializable
data class ApiResponse<T>(
val code: Int,
val msg: String,
val data: T,
)
但一定要区分:
HttpResponse
↓
HTTP 层
ApiResponse<T>
↓
业务协议层
请求成功需要至少经历:
HTTP 成功
↓
JSON 解析成功
↓
code 成功
↓
data
所以:
HTTP 200
≠
业务一定成功
NetworkClient 可以统一:
HttpResponse
↓
ApiResponse<T>
↓
检查 code
↓
解包 data
↓
T
于是 ApiService:
suspend 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 网络层才真正开始具备正式项目的完整形态。