Android 優雅地處理後臺返回的騷資料

DylanCai發表於2019-10-21

前言

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) throws IOException;
}
複製程式碼

主要是拿到 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 封裝的框架的話,通過本文章的解決問題思路應該也能尋找到相應的解決方案。

相關文章