• OkHttp踩坑记:为何 response.body().string() 只能调用一次?


    想必大家都用过或接触过 OkHttp,我最近在使用 Okhttp 时,就踩到一个坑,在这儿分享出来,以后大家遇到类似问题时就可以绕过去。

    只是解决问题是不够的,本文将 侧重从源码角度分析下问题的根本,干货满满。

    1.发现问题

    在开发时,我通过构造 OkHttpClient 对象发起一次请求并加入队列,待服务端响应后,回调 Callback 接口触发 onResponse() 方法,然后在该方法中通过 Response 对象处理返回结果、实现业务逻辑。代码大致如下:

    //注:为聚焦问题,删除了无关代码
    getHttpClient().newCall(request).enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) {}
        
        @Override
        public void onResponse(Call call, Response response) throws IOException {
            if (BuildConfig.DEBUG) {
                Log.d(TAG, "onResponse: " + response.body().toString());
            }
            //解析请求体
            parseResponseStr(response.body().string());
        }
    });

    在 onResponse() 中,为便于调试,我打印了返回体,然后通过 parseResponseStr() 方法解析返回体(注意:这儿两次调用了 response.body().string())。

    这段看起来没有任何问题的代码,实际运行后却出了问题:通过控制台看到成功打印了返回体数据(json),但紧接着抛出了异常:

    java.lang.IllegalStateException: closed

    2.解决问题

    检查代码后,发现问题出在调用 parseResponseStr() 时,再次使用了 response.body().string() 作为参数。由于当时赶时间,上网查阅后发现 response.body().string() 只能调用一次,于是修改 onResponse() 方法中的逻辑后解决了问题:

    getHttpClient().newCall(request).enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) {}
        
        @Override
        public void onResponse(Call call, Response response) throws IOException {
            //此处,先将响应体保存到内存中
            String responseStr = response.body().string();
            if (BuildConfig.DEBUG) {
                Log.d(TAG, "onResponse: " + responseStr);
            }
            //解析请求体
            parseReponseStr(responseStr);
        }
    });

    3.结合源码分析问题

    问题解决了,事后还是要分析的。由于之前对 OkHttp 的了解仅限于使用,没有仔细分析过其内部实现的细节,周末抽时间往下看了看,算是弄明白了问题发生的原因。

    先分析最直观的问题:为何 response.body().string() 只能调用一次?

    拆解来看,先通过 response.body() 得到 ResponseBody 对象(其是一个抽象类,在此我们不需要关心具体的实现类),然后调用 ResponseBody 的 string() 方法得到响应体的内容。

    分析后 body() 方法没有问题,我们往下看 string() 方法:

    public final String string() throws IOException {
      return new String(bytes(), charset().name());
    }

    很简单,通过指定字符集(charset)将 byte() 方法返回的 byte[] 数组转为 String 对象,构造没有问题,继续往下看 byte() 方法:

    public final byte[] bytes() throws IOException {
      //...
      BufferedSource source = source();
      byte[] bytes;
      try {
        bytes = source.readByteArray();
      } finally {
        Util.closeQuietly(source);
      }
      //...
      return bytes;
    }

    //... 表示删减了无关代码,下同。

    在 byte() 方法中,通过 BufferedSource 接口对象读取 byte[] 数组并返回。结合上面提到的异常,我注意到 finally 代码块中的 Util.closeQuietly() 方法。excuse me?默默地关闭???

    Excuse me

    这个方法看起来很诡异有木有,跟进去看看:

    public static void closeQuietly(Closeable closeable) {
      if (closeable != null) {
        try {
          closeable.close();
        } catch (RuntimeException rethrown) {
          throw rethrown;
        } catch (Exception ignored) {
        }
      }
    }

    原来,上面提到的 BufferedSource 接口,根据代码文档注释,可以理解为 资源缓冲区,其实现了 Closeable 接口,通过复写 close() 方法来 关闭并释放资源。接着往下看 close() 方法做了什么(在当前场景下,BufferedSource 实现类为 RealBufferedSource):

    //持有的 Source 对象
    public final Source source;
    
    @Override
    public void close() throws IOException {
      if (closed) return;
      closed = true;
      source.close();
      buffer.clear();
    }

    很明显,通过 source.close() 关闭并释放资源。说到这儿, closeQuietly() 方法的作用就不言而喻了,就是关闭 ResponseBody 子类所持有的 BufferedSource 接口对象。

    分析至此,我们恍然大悟:当我们第一次调用 response.body().string() 时,OkHttp 将响应体的缓冲资源返回的同时,调用 closeQuietly() 方法默默释放了资源。

    如此一来,当我们再次调用 string() 方法时,依然回到上面的 byte() 方法,这一次问题就出在了 bytes = source.readByteArray() 这行代码。一起来看看 RealBufferedSource 的 readByteArray() 方法:

    @Override
    public byte[] readByteArray() throws IOException {
      buffer.writeAll(source);
      return buffer.readByteArray();
    }

    继续往下看 writeAll() 方法:

    @Override
    public long writeAll(Source source) throws IOException {
        //...
        long totalBytesRead = 0;
        for (long readCount; (readCount = source.read(this, Segment.SIZE)) != -1; ) {
          totalBytesRead += readCount;
        }
        return totalBytesRead;
    }

    问题出在 for 循环的 source.read() 这儿。还记得在上面分析 close() 方法时,其调用了 source.close() 来关闭并释放资源。那么,再次调用 read() 方法会发生什么呢:

    @Override
    public long read(Buffer sink, long byteCount) throws IOException {
        //...
        if (closed) throw new IllegalStateException("closed");
        //...
        return buffer.read(sink, toRead);
    }

    至此,与我在前面遇到的崩溃对上了:

    java.lang.IllegalStateException: closed

    4.OkHttp 为什么要这么设计?

    通过 fuc*ing the source code,我们找到了问题的根本,但我还有一个疑问:OkHttp 为什么要这么设计

    其实,理解这个问题最好的方式就是查看 ResponseBody 的注释文档,正如 JakeWharton 在 issues 中给出的回复:

    reply of JakeWharton in okhttp issues

    JakeWharton

    就简单的一句话:**`It's documented on ResponseBody.
    `** 于是我跑去看类注释文档,最后梳理如下:

    在实际开发中,响应主体 RessponseBody 持有的资源可能会很大,所以 OkHttp 并不会将其直接保存到内存中,只是持有数据流连接。只有当我们需要时,才会从服务器获取数据并返回。同时,考虑到应用重复读取数据的可能性很小,所以将其设计为一次性流(one-shot),读取后即 '关闭并释放资源'

    5.总结

    最后,总结以下几点注意事项,划重点了:

    1. 响应体只能被使用一次;
    2. 响应体必须关闭:值得注意的是,在下载文件等场景下,当你以 response.body().byteStream() 形式获取输入流时,务必通过 Response.close() 来手动关闭响应体。
    3. 获取响应体数据的方法:使用 bytes() 或 string() 将整个响应读入内存;或者使用 source()byteStream()charStream() 方法以流的形式传输数据。
    4. 以下方法会触发关闭响应体:
    Response.close()
    Response.body().close()
    Response.body().source().close()
    Response.body().charStream().close()
    Response.body().byteString().close()
    Response.body().bytes()
    Response.body().string()
  • 相关阅读:
    c#参数传递几点小结
    c#线程初探(二)
    c#线程初探(一)
    c#:浅克隆和深克隆,序列化和反序列化
    c#冒泡、快速、选择和插入排序算法的项目应用
    c#运算符几点小结
    文件操作(无代码)
    不仅仅C#缺点(永远未完)
    《道德经》程序员版第五章
    《道德经》程序员版第四章
  • 原文地址:https://www.cnblogs.com/dongweiq/p/10373100.html
Copyright © 2020-2023  润新知