Android 优雅地处理后台返回的骚数据

前言

Retrofit 是目前主流的网络请求框架,不少用过的小伙伴会遇到这样的问题,绝大部分接口测试都正常,就个别接口尤其是返回失败信息时报了个奇怪的错误信息,而看了自己的代码逻辑也没什么问题。别的接口都是一样的写,却没出现这样的情况,可是后台人员看了也说不关他们的事。刚遇到时会比较懵,有些人不知道什么原因也就无从下手。

问题原因

排查问题也很简单,把信息百度一下,会发现是解析异常。那就先看下后台返回了什么,用 PostMan 请求一下查看返回结果,发现是类似下面这样的:

{
  "code": 500,
  "msg": "登录失败",
  "data": ""
}

也可能是这样的:

{
  "code": 500,
  "msg": "登录失败",
  "data": 0
}

或者是这样的:

{
  "code": 500,
  "msg": "登录失败",
  "data": []
}

仔细观察后突然恍然大悟,这不是坑爹吗?后台这样返回解析肯定有问题呀,我要将 data 解析成一个对象,而后台返回的是一个空字符串、整形或空数组,肯定解析报错。

嗯,这就是后台的问题,是后台写得不“规范”,所以就跑过去和后台理论让他们改。如果后台是比较好说话,肯配合改还好说。但有些可能是比较“倔强”的性格,可能会说,“这很简单呀,知道是失败状态不解析 data 不就好了?”,或者说,“为什么 iOS 可以,你这边却不行?你们 Android 有问题就不能自己处理掉吗?”。如果遇到这样的同事就会比较尴尬。

其实就算后台能根据我们要求改,但也不是长远之计。后台人员变动或自己换个环境可能还是会遇到同样的情况,每次都和后台沟通配合改也麻烦,而且没准就刚好遇到“倔强”不肯改的。

是后台人员写得不规范吗?我个人认为并不是,因为并没有约定俗成的规范要这么写,其实只是后台人员不知道这么返回数据会对 Retrofit 的解析有影响,不知道这么写对 Android 不太友好。后台人员也没有错,我们所觉得的“规范”没人告诉过他呀。可以通过沟通解决问题,不过也建议自己把问题处理了,一劳永逸。

解决方案

既然是解析报错了,那么在 Gson 解析成对象之前,先验证状态码,判断是错误的情况就抛出异常,这样就不进行后续的 Gson 解析操作去解析 data,也就没问题了。

最先想到的当然是从解析的地方入手,而 Retrofit 能进行 Gson 解析是配置了一个 Gson 转换器。

retrofit = Retrofit.Builder()
  // 其它配置
  .addConverterFactory(GsonConverterFactory.create())
  .build()

所以我们修改 GsonConverterFactory 不就好了。

自定义 GsonConverterFactory 处理返回结果

试一下会发现并不能直接继承 GsonConverterFactory 重载修改相关方法,因为该类用了 final 修饰。所以只好把 GsonConverterFactory 源码复制出来改,其中关联的两个类 GsonRequestBodyConverter 和 GsonResponseBodyConverter 也要复制修改。下面给出的是 Kotlin 版本的示例。

class MyGsonConverterFactory private constructor(private val gson: Gson) : Converter.Factory() {

  override fun responseBodyConverter(
    type: Type, annotations: Array<Annotation>,
    retrofit: Retrofit
  ): Converter<ResponseBody, *> {
    val adapter = gson.getAdapter(TypeToken.get(type))
    return MyGsonResponseBodyConverter(gson, adapter)
  }

  override fun requestBodyConverter(
    type: Type,
    parameterAnnotations: Array<Annotation>,
    methodAnnotations: Array<Annotation>,
    retrofit: Retrofit
  ): Converter<*, RequestBody> {
    val adapter = gson.getAdapter(TypeToken.get(type))
    return MyGsonRequestBodyConverter(gson, adapter)
  }

  companion object {
    @JvmStatic
    fun create(): MyGsonConverterFactory {
      return create(Gson())
    }

    @JvmStatic
    fun create(gson: Gson?): MyGsonConverterFactory {
      if (gson == null) throw NullPointerException("gson == null")
      return MyGsonConverterFactory(gson)
    }
  }
}
class MyGsonRequestBodyConverter<T>(
  private val gson: Gson,
  private val adapter: TypeAdapter<T>
) :
  Converter<T, RequestBody> {

  @Throws(IOException::class)
  override fun convert(value: T): RequestBody {
    val buffer = Buffer()
    val writer = OutputStreamWriter(buffer.outputStream(), UTF_8)
    val jsonWriter = gson.newJsonWriter(writer)
    adapter.write(jsonWriter, value)
    jsonWriter.close()
    return buffer.readByteString().toRequestBody(MEDIA_TYPE)
  }

  companion object {
    private val MEDIA_TYPE = "application/json; charset=UTF-8".toMediaType()
    private val UTF_8 = Charset.forName("UTF-8")
  }
}
class MyGsonResponseBodyConverter<T>(
  private val gson: Gson,
  private val adapter: TypeAdapter<T>
) : Converter<ResponseBody, T> {

  @Throws(IOException::class)
  override fun convert(value: ResponseBody): T {
  
    // 在这里通过 value 拿到 json 字符串进行解析
    // 判断状态码是失败的情况,就抛出异常
    
    val jsonReader = gson.newJsonReader(value.charStream())
    value.use {
      val result = adapter.read(jsonReader)
      if (jsonReader.peek() != JsonToken.END_DOCUMENT) {
        throw JsonIOException("JSON document was not fully consumed.")
      }
      return result
    }
  }
}

上面三个类中只需要修改 GsonResponseBodyConverter 的代码,因为是在这个类解析数据。可以在上面有注释的地方加入自己的处理。到底加什么代码,看完后面的内容就知道了。

虽然得到了我们想要的效果,但总感觉并不是很优雅,因为这只是在 gson 解析之前增加一些判断,而为此多写了很多和源码重复的代码。还有这是针对 Retrofit 进行处理的,如果公司用的是自己封装的 OkHttp 请求工具,就没法用这个方案了。

观察一下发现其实只是对一个 ResponseBody 对象进行解析判断状态码,就是说只需要得到个 ResponseBody 对象而已。那么还有什么办法能在 gson 解析之前拿到 ResponseBody 呢?

自定义拦截器处理返回结果

很容易会想到用拦截器,按道理来说是应该是可行的,通过拦截器处理也不局限于使用 Retrofit,用 OkHttp 的也能处理。

想法很美好,但是实际操作起来并没有想象中的简单。刚开始可能会想到用 response.body().string() 读出 json 字符串。

public abstract class ResponseBodyInterceptor implements Interceptor {
  @NotNull
  @Override
  public Response intercept(@NotNull Chain chain) throws IOException {
    Response response = chain.proceed(chain.request());
    String json = response.body().string();
    // 对 json 进行解析判断状态码是失败的情况就抛出异常
    return response;
  }
}

看着好像没问题,但是尝试后发现,状态码是失败的情况确实没毛病,然而状态码是正确的情况却有问题了。

为什么会这样子?有兴趣的可以看下这篇文章《为何 response.body().string() 只能调用一次?》。简单总结一下就是考虑到应用重复读取数据的可能性很小,所以将其设计为一次性流,读取后即关闭并释放资源。我们在拦截器里用通常的 Response 使用方法会把资源释放了,后续解析没有资源了就会有问题。

那该怎么办呢?自己对 Response 的使用又不熟悉,怎么知道该怎么读数据不影响后续的操作。可以参考源码呀,OkHttp 也是用了一些拦截器处理响应数据,它却没有释放掉资源。

这里就不用大家去看源码研究怎么写的了,我直接封装好一个工具类提供大家使用,已经把响应数据的字符串得到了,大家可以直接编写自己的业务代码,拷贝下面的类使用即可。

abstract class ResponseBodyInterceptor : Interceptor {

  override fun intercept(chain: Interceptor.Chain): Response {
    val request = chain.request()
    val url = request.url.toString()
    val response = chain.proceed(request)
    response.body?.let { responseBody ->
      val contentLength = responseBody.contentLength()
      val source = responseBody.source()
      source.request(Long.MAX_VALUE)
      var buffer = source.buffer

      if ("gzip".equals(response.headers["Content-Encoding"], ignoreCase = true)) {
        GzipSource(buffer.clone()).use { gzippedResponseBody ->
          buffer = Buffer()
          buffer.writeAll(gzippedResponseBody)
        }
      }

      val contentType = responseBody.contentType()
      val charset: Charset =
        contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
      if (contentLength != 0L) {
        return intercept(response,url, buffer.clone().readString(charset))
      }
    }
    return response
  }

  abstract fun intercept(response: Response, url: String, body: String): Response
}

由于 OkHttp 源码已经用 Kotlin 语言重写了,所以只有个 Kotlin 版本的。但是可能还有很多人还没有用 Kotlin 写项目,所以个人又手动翻译了一个 Java 版本的,方便大家使用,同样拷贝使用即可。

public abstract class ResponseBodyInterceptor implements Interceptor {

  @NotNull
  @Override
  public Response intercept(@NotNull Chain chain) throws IOException {
    Request request = chain.request();
    String url = request.url().toString();
    Response response = chain.proceed(request);
    ResponseBody responseBody = response.body();
    if (responseBody != null) {
      long contentLength = responseBody.contentLength();
      BufferedSource source = responseBody.source();
      source.request(Long.MAX_VALUE);
      Buffer buffer = source.getBuffer();

      if ("gzip".equals(response.headers().get("Content-Encoding"))) {
        GzipSource gzippedResponseBody = new GzipSource(buffer.clone());
        buffer = new Buffer();
        buffer.writeAll(gzippedResponseBody);
      }

      MediaType contentType = responseBody.contentType();
      Charset charset;
      if (contentType == null || contentType.charset(StandardCharsets.UTF_8) == null) {
        charset = StandardCharsets.UTF_8;
      } else {
        charset = contentType.charset(StandardCharsets.UTF_8);
      }

      if (charset != null && contentLength != 0L) {
        return intercept(response,url, buffer.clone().readString(charset));
      }
    }
    return response;
  }

  abstract Response intercept(@NotNull Response response,String url, String body);
}

主要是拿到 source 再获得 buffer,然后通过 buffer 去读出字符串。说下其中的一段 gzip 相关的代码,为什么需要有这段代码的处理,自己看源码的话可能会漏掉。这是因为 OkHttp 请求时会添加支持 gzip 压缩的预处理,所以如果响应的数据是 gzip 编码的,需要对 gzip 压缩数据解包再去读数据。

好了废话不多说,到底这个工具类怎么用,其实和拦截器一样使用,继承我封装好的 ResponseBodyInterceptor 类,在重写方法里加上自己需要的业务处理代码,body 参数就是我们想要的 json 字符串数据,可以进行解析判断状态码是失败情况并抛出异常。下面给一个简单的解析例子参考,json 结构是文章开头给出的例子,这里假设状态码不是 200 都抛出一个自定义异常。

class HandleErrorInterceptor : ResponseBodyInterceptor() {

  override fun intercept(response: Response, body: String): Response {
    var jsonObject: JSONObject? = null
    try {
      jsonObject = JSONObject(body)
    } catch (e: Exception) {
      e.printStackTrace()
    }
    if (jsonObject != null) {
      if (jsonObject.optInt("code", -1) != 200 && jsonObject.has("msg")) {
        throw ApiException(jsonObject.getString("msg"))
      }
    }
    return response
  }
}

然后在 OkHttpClient 中添加该拦截器就可以了。

val okHttpClient = OkHttpClient.Builder()
  // 其它配置
  .addInterceptor(HandleErrorInterceptor())
  .build()

万一后台返回的是更骚的数据呢?

本人目前只遇到过失败时 data 类型不一致的情况,下面是一些小伙伴反馈的,如果大家有遇到类似或更骚的,都建议和后台沟通改成返回方便自己写业务逻辑代码的数据。实在沟通无果,再参考下面的案例看下是否有帮助。

后面所给出的参考方案都是缓兵之计,不能根治问题。想彻底地解决只能和后台人员沟通一套合适的规范。

数据需要去 msg 里取

有位小伙伴提到的:骚的时候数据还会去 msg 取。(大家都经历过了什么...)

还是强调一下建议让后台改,实在没办法必须要这么做的话,再往下看。

假设返回的数据是下面这样的:

{
  "code": 200,
  "msg": {
    "userId": 123456,
    "userName": "admin"
  }
}

通常 msg 返回的是个字符串,但这次居然是个对象,而且是我们需要得到的数据。我们解析的实体类已经定义了 msg 是字符串,当然不可能因为一个接口把 msg 改成泛型,所以我们需要偷偷地把数据改成我们想要得到的形式。

{
  "code": 200,
  "msg": "登录成功"
  "data": {
    "userId": 123456,
    "userName": "张三"
  }
}

那么该怎么操作呢?代码比较简单,就不啰嗦了,记得要把该拦截器配置了。

class HandleLoginInterceptor: ResponseBodyInterceptor() {

  override fun intercept(response: Response, url: String, body: String): Response {
    var jsonObject: JSONObject? = null
    try {
      jsonObject = JSONObject(body)
      if (url.contains("/login")) { // 当请求的是登录接口才处理
        if (jsonObject.getJSONObject("msg") != null) {
          jsonObject.put("data", jsonObject.getJSONObject("msg"))
          jsonObject.put("msg", "登录成功")
        }
      }
    } catch (e: Exception) {
      e.printStackTrace()
    }

    val contentType = response.body?.contentType()
    val responseBody = jsonObject.toString().toResponseBody(contentType)
    return response.newBuilder().body(responseBody).build() // 重新生成响应对象
  }
}

如果用 Java 的话,是这样来重新生成响应对象。

MediaType contentType = response.body().contentType();
ResponseBody responseBody = ResponseBody.create(jsonObject.toString(), contentType);
return response.newBuilder().body(responseBody).build(); 

数据多和数据少返回的类型不一样

又有位小伙伴说道:数据少给你返回 JSONObject,数据多给你返回 JSONArray,数据没有给你返回 “null”,null,“”。(这真的不会被打吗...)

再强调一次,建议让后台改。如果硬要这么做,再参考下面思路。

小伙伴没给具体的例子,这里我自己假设数据的几种情况。

{
  "code": 200,
  "msg": "",
  "data": "null"
}

{
  "code": 200,
  "msg": "",
  "data": {
    "key1": "value1",
    "key2": "value2"
  }
}

{
  "code": 200,
  "msg": "",
  "data": [
    {
      "key1": "value1",
      "key2": "value2"
    },
    {
      "key1": "value3",
      "key2": "value4"
    }
  ]
}

data 的类型会有多种,我们直接请求的话,应该只能将 data 定义成 String,然后解析判断到底是哪种情况,再写逻辑代码,这样处理起来麻烦很多。个人建议用拦截器手动将 data 统一转成 JSONArray 的形式,这样 data 类型只有一种,处理起来更加方便,代码逻辑也更清晰。

{
  "code": 200,
  "msg": "",
  "data": []
}

{
  "code": 200,
  "msg": "",
  "data": [
    {
      "key1": "value1",
      "key2": "value2"
    }
  ]
}

{
  "code": 200,
  "msg": "",
  "data": [
    {
      "key1": "value1",
      "key2": "value2"
    },
    {
      "key1": "value3",
      "key2": "value4"
    }
  ]
}

具体的代码就不给出了,实现是类似上一个例子,主要是提供思路给大家参考。

直接返回 http 状态码,响应报文可能没有或者不是 json

这是有两位小伙伴说的情况:后台直接返回 http 状态码,响应报文为空、null、"null"、""、[] 等这些数据。

还是那句话,建议让后台改。如果不肯改,其实这个处理起来也还好。

大概了解下后台返回的 http 状态码是一个 600 以上的数字,一个状态码对应着一个没有返回数据的操作。响应报文可能没有,可能不是 json。

看起来像是不同类型的响应报文,比数据类型不同更难处理。其实这比之前两个例子简单很多,因为不用考虑读数据。具体处理是判断一下状态码是多少,然后抛出对应的自定义异常,请求时对该的异常进行处理。响应报文都是些“空代表”处理起来好像挺麻烦,但我们没必要去管,抛了异常就不会进行解析。

class HandleHttpCodeInterceptor : ResponseBodyInterceptor() {

  override fun intercept(response: Response, url: String, body: String): Response {
    when (response.code) {
      600,601,602 -> {
        throw ApiException(response.code, "msg")
      }
      else -> {
      }
    }
    return response
  }
}

在 header 里取 data 数据

居然还有这种骚操作,涨见识了...

建议先让后台改。后台不改自己再手动把 header 里的数据提取出来,转成自己想要的 json 数据。

class ConvertDataInterceptor : ResponseBodyInterceptor() {

  override fun intercept(response: Response, url: String, body: String): Response {
    val json = "{\"code\": 200}" // 创建自己需要的数据结构
    val jsonObject = JSONObject(json)
    jsonObject.put("data", response.headers["Data"]) // 将 header 里的数据设置到 json 里
    
    val contentType = response.body?.contentType()
    val responseBody = jsonObject.toString().toResponseBody(contentType)
    return response.newBuilder().body(responseBody).build() // 重新生成响应对象
  }
}

总结

大家遇到这些情况建议先与后台人员沟通。刚开始说的失败时 data 类型不一致的情况有不少人遇到过,有需要的可以提前处理预防一下。至于那些更骚的操作最好还是和后台沟通一个合适的规范,实在沟通无果再参考文中部分案例的处理思路。

自定义 GsonConverter 与源码有不少冗余代码,并不推荐。而且如果想对某个接口的结果进行处理,不好拿到该地址。拦截器的方式难点主要是该怎么写,所以封装好了工具类供大家使用。

文中提到了用拦截器将数据转换成方便我们编写逻辑的结构,并不是鼓励大家帮后台擦屁股。这种用法或许对某些复杂的接口来说会有奇效。

刚开始只是打算分享自己封装好的类,说一下怎么使用来解决问题。不过后来还是花了很多篇幅详细描述了我解决问题的整个心路历程,主要是见过太多人求助这类问题,所以就写详细一点,后续如果还有人问就直接发文章过去,应该能有效解决他的疑惑。另外如果公司用的请求框架即不是 Retrofit 也不是基于 OkHttp 封装的框架的话,通过本文章的解决问题思路应该也能寻找到相应的解决方案。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343