码个蛋(codeegg)第 791 次推文
作者:DylanCai
博客:https://juejin.im/post/5dadac2ae51d4524c3745219
码妞看世界
有谁认识它嘛?
前言
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 {@JvmStaticfun create(): MyGsonConverterFactory {return create(Gson())}@JvmStaticfun create(gson: Gson?): MyGsonConverterFactory {if (gson == null) throw NullPointerException("gson == null")return MyGsonConverterFactory(gson)}}}
MyGsonRequestBodyConverter
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
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@Overridepublic Response intercept(@NotNull Chain chain) throws IOException {Response response = chain.proceed(chain.request());String json = response.body().string();// 对 json 进行解析判断状态码是失败的情况就抛出异常return response;}}
看着好像没问题,但是尝试后发现,状态码是失败的情况确实没毛病,然而状态码是正确的情况却有问题了。
为什么会这样子?有兴趣的可以看下这篇文章《为何 response.body().string() 只能调用一次?》(https://juejin.im/post/5a524eef518825732c536025)。简单总结一下就是考虑到应用重复读取数据的可能性很小,所以将其设计为一次性流,读取后即关闭并释放资源。我们在拦截器里用通常的 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.bufferif ("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_8if (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@Overridepublic 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? = nulltry {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? = nulltry {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 封装的框架的话,通过本文章的解决问题思路应该也能寻找到相应的解决方案。
我打赌你一定没搞明白的Activity启动模式
码仔:学了就忘怎么办?
热更新再牛,也少不了Android 增量更新
今日问题:
你遇到过什么奇葩数据?
专属升级社区:《这件事情,我终于想明白了》
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.