之前在项目中调试部分上传附件的接口时会遇到unexpected end of stream
错误,在项目所使用的网络框架是我基于OkGo封装的一个网络请求库,而OkGo内部则其实是基于OkHttp封装的。网上查阅了一下发现遇到这个问题的人挺多,导致这个异常的原因可能不止一种,本文主要针对我遇到的这种情况下导致这个问题的原因分析,做个记录。
首先看一下错误日志:
看到这个错误的原因,起初我有点担心可能是我封装库或者OkGo库的原因,会不会是封装的过程有什么问题?因为在项目中使用的请求代码都是高度封装过的,为了排除这个担忧,我将请求代码换成原生的OkHttp的方式进行请求:
public void add(final String action) {
showProgressDialog(action);
RequestParams params = getParams(action);
String url = mApi.getApiByName(action);
File file = mFileList.get(0);
OkHttpClient okHttpClient = new OkHttpClient();
MultipartBody.Builder builder = new MultipartBody.Builder();
builder.setType(MultipartBody.FORM);
StringHashMap paramStrs = params.getAllParamStrs();
for (String key : paramStrs.getKeyList()) {
builder.addFormDataPart(key, paramStrs.get(key));
}
RequestBody fileBody = RequestBody.create(MediaType.parse("image/jpeg"), file);
builder.addFormDataPart("attachFiles", file.getName(), fileBody);
RequestBody requestBody = builder.build();
Request request = new Request.Builder()
.url(url)
.post(requestBody)
.build();
okHttpClient.newCall(request).enqueue(new okhttp3.Callback(){
@Override
public void onFailure(Call call, IOException e) {
TQLog.e("OkHttp", "onFailure: "+e.toString());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
ResponseBody body = response.body();
if (body != null) {
TQLog.e("OkHttp", "onResponse: " + body.string());
} else {
TQLog.e("OkHttp", "onResponse: null");
}
}
});
}
我原来的请求方式是这样的(封装后的):
HttpRequest request = new BaseHttpRequest(url, params, callback, tag)
HttpClient.getInstance().post(request );
原生的OkHttp请求方式还是很麻烦的,要写一大坨代码,封装后的方式使用比较简单,这里先不管这个,在换成原生的OkHttp请求方式后还是会报同样的错误,这下排除了封装库的原因,确定了这个问题是由OkHttp产生的。
直接去看OkHttp
源码(所用的版本为3.8.1),错误发生在Http1Codec
这个类的第387行:
可以看到是在内部类FixedLengthSource
当中,387这一行有个注释:The server didn’t supply the promised content length. 意思是说服务器没有返回承诺的content length(看到这句话其实能大概知道原因了跟content-length有关系)。继续看,这里的if
判断source.read(sink, Math.min(bytesRemaining, byteCount))
如果这个方法返回的值为-1就会直接抛出ProtocolException
异常,异常的描述就是我们看到的那句"unexpected end of stream"
,因此去看一下source.read
这个方法是干嘛的,source
这个对象是BufferedSource
接口类型,而BufferedSource
则继承了Source
接口,在Source
接口可以看到这个方法的注释:
意思是从当前Source中读取byteCount个字节到第一个参数Buffer当中,返回值是读取的字节数,如果返回-1则表示Source已经没有东西可以读取了。
这个看上去跟平时用的InputStrem.read
差不多,其实,这个Source
接口以及还有一个Sink
接口都来自Okio库,Okio同样是square公司开源的一个独立的开源库,同时它被用作OkHttp
底层的IO
读写库,Okio主要封装了对应java的InputStrem
和OutputStream
的读写功能,而Source
和Sink
则分别对应输入流和输出流,提供了更加易用高效的处理方式,是一个非常牛bility的IO
库(官方是这样描述的…)。
这里暂时先不去深入研究Okio,已经知道read返回-1的含义了,再回到调用source.read
的地方,source.read
方法第二个参数Math.min(bytesRemaining, byteCount)
取了两者的最小值,bytesRemaining
这个对象是什么意思呢,可以看到它是在FixedLengthSource
的构造函数中被赋值的,继续在Http1Codec
中搜索创建FixedLengthSource
的地方:
找到一个newFixedLengthSource
方法,继续:
可以看到最终是在openResponseBody
方法中调用的getTransferStream
方法中调用了这个newFixedLengthSource
方法,而参数值则是从Response
对象中取的响应头Header里的ContentLength
字段。因此bytesRemaining
变量的初始值就是ContentLength
,再回到原来报错的387行,source.read
方法第二个参数Math.min(bytesRemaining, byteCount)
这个bytesRemaining
已经清楚了,那这个byteCount
是什么呢,它是由read方法传进来的,我们回到开头看错误日志栈的第二行定位是在RealBufferedSource
类的67行:
从错误栈可以推测这个地方的source对象一定是跟Http1Codec
类中的FixedLengthSource
是相同类型,这个后面再分析,先看这里67行第二个参数为Segment.SIZE
,Segment.SIZE
这个值的大小为8192(也就是8k):
Segment
也是Okio库中的东西,
看到这里,就不难理解开头Http1Codec
类报错的第387行了,bytesRemaining
的初始大小值是ContentLength
, 每次将以bytesRemaining
和8192这两个值中较小的那个数值作为会从source中读取的字节数,如果读取没有报错则会从bytesRemaining
中减去已读取的长度,当下一次再调用这个方法时bytesRemaining
就是body中剩余正文的字节长度。如果读取报错返回-1则直接抛出unexpected end of stream
异常。那为什么会返回-1呢,肯定是在某一次读取的时候,bytesRemaining
还有值但是source已经读到流的末尾没有东西可读了,也就是source资源已经枯竭,如source.read方法所注释的:Returns the number of bytes read, or -1 if this source is exhausted
因此,在项目中出现这个错误的原因肯定是跟服务器返回值的响应头中Content-Length的长度有关,为了验证这个问题,我将报错接口的返数据通过抓包出来分析了一下:
可以看到响应头中返回的Content-Length的大小是1004,charset是UTF-8,那么正常情况下响应正文也就是body中的字符串按照UTF-8编码的字节长度应该等于Content-Length的大小1004,于是,我写了一下代码把正文按照UTF-8编码的字节长度打印出来,打印代码很简单,就一句话:
TQLog.e(TAG, "length = " + str.getBytes(StandardCharsets.UTF_8).length);
果然,打印出的长度值为length = 992,居然跟响应头中的Content-Length的值不一样!这就有问题了啊,按照OkHttp
中Http1Codec
类报错的方法的逻辑,source.read(sink, Math.min(bytesRemaining, byteCount))
这里第二个参数,将会在1004和8k之间取最小值为1004,也就是说会直接从body的输入源对象source读取1004个字节的长度,然而实际响应正文返回字符串的长度不足1004只有992。最终source必然会不够读取返回-1,从而报错。
当然实际debug发现过程跟这个有点出入,但是差别不大,实际当bytesRemaining比byteCount小时,source.read读取时不是一次性把source读完的,这主要是有个方法导致的,但是总的bytesRemaining跟Content-Length是一致的。
问题是清楚了,最后,把完整的流程梳理一遍,看一下整个过程是怎样的
首先看下Http1Codec
这个类是在哪里创建的, 搜索有两个地方,一个是创建https tunnel的地方,目前接口不是https的, 所以排除,就剩下RealConnection类中的:
public HttpCodec newCodec(
OkHttpClient client, StreamAllocation streamAllocation) throws SocketException {
if (http2Connection != null) {
return new Http2Codec(client, streamAllocation, http2Connection);
} else {
socket.setSoTimeout(client.readTimeoutMillis());
source.timeout().timeout(client.readTimeoutMillis(), MILLISECONDS);
sink.timeout().timeout(client.writeTimeoutMillis(), MILLISECONDS);
return new Http1Codec(client, streamAllocation, source, sink);
}
}
这个方法是在StreamAllocation类中的newStream方法调用的:
public HttpCodec newStream(OkHttpClient client, boolean doExtensiveHealthChecks) {
int connectTimeout = client.connectTimeoutMillis();
int readTimeout = client.readTimeoutMillis();
int writeTimeout = client.writeTimeoutMillis();
boolean connectionRetryEnabled = client.retryOnConnectionFailure();
try {
RealConnection resultConnection = findHealthyConnection(connectTimeout, readTimeout,
writeTimeout, connectionRetryEnabled, doExtensiveHealthChecks);
HttpCodec resultCodec = resultConnection.newCodec(client, this);
synchronized (connectionPool) {
codec = resultCodec;
return resultCodec;
}
} catch (IOException e) {
throw new RouteException(e);
}
}
而这个方法又是在ConnectInterceptor
中的intercept方法调用的:
@Override
public Response intercept(Chain chain) throws IOException {
RealInterceptorChain realChain = (RealInterceptorChain) chain;
Request request = realChain.request();
StreamAllocation streamAllocation = realChain.streamAllocation();
// We need the network to satisfy this request. Possibly for validating a conditional GET.
boolean doExtensiveHealthChecks = !request.method().equals("GET");
HttpCodec httpCodec = streamAllocation.newStream(client, doExtensiveHealthChecks);
RealConnection connection = streamAllocation.connection();
return realChain.proceed(request, streamAllocation, httpCodec, connection);
}
这个就是拦截器了,可以看到httpCodec最终被传到了RealInterceptorChain
对象中
我们在使用okHttpClient.newCall(request)
时创建了一个RealCall对象:
/**
* Prepares the {@code request} to be executed at some point in the future.
*/
@Override
public Call newCall(Request request) {
return new RealCall(this, request, false /* for web socket */);
}
在RealCall类中:
@Override
protected void execute() {
boolean signalledCallback = false;
try {
Response response = getResponseWithInterceptorChain();
if (retryAndFollowUpInterceptor.isCanceled()) {
signalledCallback = true;
responseCallback.onFailure(RealCall.this, new IOException("Canceled"));
} else {
signalledCallback = true;
responseCallback.onResponse(RealCall.this, response);
}
} catch (IOException e) {
if (signalledCallback) {
// Do not signal the callback twice!
Platform.get().log(INFO, "Callback failure for " + toLoggableString(), e);
} else {
responseCallback.onFailure(RealCall.this, e);
}
} finally {
client.dispatcher().finished(this);
}
}
}
execute()
方法中调用了getResponseWithInterceptorChain()
方法:
Response getResponseWithInterceptorChain() throws IOException {
// Build a full stack of interceptors.
List<Interceptor> interceptors = new ArrayList<>();
interceptors.addAll(client.interceptors());
interceptors.add(retryAndFollowUpInterceptor);
interceptors.add(new BridgeInterceptor(client.cookieJar()));
interceptors.add(new CacheInterceptor(client.internalCache()));
interceptors.add(new ConnectInterceptor(client));
if (!forWebSocket) {
interceptors.addAll(client.networkInterceptors());
}
interceptors.add(new CallServerInterceptor(forWebSocket));
Interceptor.Chain chain = new RealInterceptorChain(
interceptors, null, null, null, 0, originalRequest);
return chain.proceed(originalRequest);
}
在这里除了用户添加的拦截器以外,OKHttp内部添加了5个拦截器,这5个拦截器最终会被添加到一个RealInterceptorChain
对象中,在实际请求过程中会依次调用每个拦截器的intercept
方法,并在该方法中调用chain.proceed
方法,而在RealInterceptorChain类的proceed
方法中会调用下一个拦截器的intercept
方法:
// Call the next interceptor in the chain.
RealInterceptorChain next = new RealInterceptorChain(
interceptors, streamAllocation, httpCodec, connection, index + 1, request);
Interceptor interceptor = interceptors.get(index);
Response response = interceptor.intercept(next);
所以,httpCodec对象从ConnectInterceptor
开始会被带到最终的拦截器CallServerInterceptor
当中,CallServerInterceptor
是干嘛的呢:
注释说这个是拦截器链当中的最后一个拦截器了,用来向服务器发起一个network请求
因此我们代码在请求的回调中:
okHttpClient.newCall(request).enqueue(new okhttp3.Callback(){
@Override
public void onFailure(Call call, IOException e) {
TQLog.e("OkHttp", "onFailure: "+e.toString());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
ResponseBody body = response.body();
if (body != null) {
String string = body.string();
TQLog.e("OkHttp", "onResponse: " + string);
} else {
TQLog.e("OkHttp", "onResponse: null");
}
}
});
这里onResponse
方法中的body.string()
,这个body就是从最后一个拦截器CallServerInterceptor
返回的Response
中获取的:
可以看到CallServerInterceptor
中的Response
对象的body正是从httpCodec的openResponseBody
获取的,而httpCodec就是从chain中获取的:
所以我们代码onResponse
回调中拿到的body其实就是Http1Codec
类返回的openResponseBody
返回的对象:
@Override public ResponseBody openResponseBody(Response response) throws IOException {
Source source = getTransferStream(response);
return new RealResponseBody(response.headers(), Okio.buffer(source));
}
private Source getTransferStream(Response response) throws IOException {
if (!HttpHeaders.hasBody(response)) {
return newFixedLengthSource(0);
}
if ("chunked".equalsIgnoreCase(response.header("Transfer-Encoding"))) {
return newChunkedSource(response.request().url());
}
long contentLength = HttpHeaders.contentLength(response);
if (contentLength != -1) {
return newFixedLengthSource(contentLength);
}
// Wrap the input stream from the connection (rather than just returning
// "socketIn" directly here), so that we can control its use after the
// reference escapes.
return newUnknownLengthSource();
}
public Source newFixedLengthSource(long length) throws IOException {
if (state != STATE_OPEN_RESPONSE_BODY) throw new IllegalStateException("state: " + state);
state = STATE_READING_RESPONSE_BODY;
return new FixedLengthSource(length);
}
在openResponseBody
方法中先调用getTransferStream
方法生成了一个Source对象,而这个方法中其实就是调用newFixedLengthSource
方法直接new了一个FixedLengthSource
对象,FixedLengthSource
构造函数中传入的参数正是在文章开头分析的bytesRemaining
的值,没错,它就是header中的contentLength。
然后在openResponseBody
方法中将生成的这个Source对象经过Okio.buffer(source)
封装之后传递给了RealResponseBody
并返回该对象(也就是说onResponse
回调中的body
其实就是它),来看一下Okio.buffer(source)
干了什么:
public static BufferedSource buffer(Source source) {
return new RealBufferedSource(source);
}
这里直接根据传入的FixedLengthSource
对象new了一个RealBufferedSource
对象,再看RealBufferedSource
类:
可以看出这是一个装饰者对象,对读写操作进行了包装
到这里基本串起来了,再回去看onResponse
回调中的body.string()
:
public final String string() throws IOException {
BufferedSource source = source();
try {
Charset charset = Util.bomAwareCharset(source, charset());
return source.readString(charset);
} finally {
Util.closeQuietly(source);
}
}
string()
方法的第一行source()
,其实就是包装了FixedLengthSource
对象的RealBufferedSource
对象,我们看一下Util.bomAwareCharset
方法:
public static Charset bomAwareCharset(BufferedSource source, Charset charset) throws IOException {
if (source.rangeEquals(0, UTF_8_BOM)) {
source.skip(UTF_8_BOM.size());
return UTF_8;
}
if (source.rangeEquals(0, UTF_16_BE_BOM)) {
source.skip(UTF_16_BE_BOM.size());
return UTF_16_BE;
}
if (source.rangeEquals(0, UTF_16_LE_BOM)) {
source.skip(UTF_16_LE_BOM.size());
return UTF_16_LE;
}
if (source.rangeEquals(0, UTF_32_BE_BOM)) {
source.skip(UTF_32_BE_BOM.size());
return UTF_32_BE;
}
if (source.rangeEquals(0, UTF_32_LE_BOM)) {
source.skip(UTF_32_LE_BOM.size());
return UTF_32_LE;
}
return charset;
}
这个方法的代码大概是判断UTF字符编码集的作用,里面会调用到source.rangeEquals
,于是去看一下RealBufferedSource
的rangeEquals
方法:
@Override public boolean rangeEquals(long offset, ByteString bytes) throws IOException {
return rangeEquals(offset, bytes, 0, bytes.size());
}
@Override
public boolean rangeEquals(long offset, ByteString bytes, int bytesOffset, int byteCount)
throws IOException {
if (closed) throw new IllegalStateException("closed");
if (offset < 0
|| bytesOffset < 0
|| byteCount < 0
|| bytes.size() - bytesOffset < byteCount) {
return false;
}
for (int i = 0; i < byteCount; i++) {
long bufferOffset = offset + i;
if (!request(bufferOffset + 1)) return false;
if (buffer.getByte(bufferOffset) != bytes.getByte(bytesOffset + i)) return false;
}
return true;
}
在for循环里最终会调用一个request
方法:
@Override
public boolean request(long byteCount) throws IOException {
if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0: " + byteCount);
if (closed) throw new IllegalStateException("closed");
while (buffer.size < byteCount) {
if (source.read(buffer, Segment.SIZE) == -1) return false;
}
return true;
}
这个request
方法也正是我们开头错误日志栈中的第二行所处的方法,正如前面分析的,我们知道此时的这个RealBufferedSource
对象包装的source对象正是FixedLengthSource
对象,因此这个地方就会调用到FixedLengthSource
的read
方法:
@Override
public long read(Buffer sink, long byteCount) throws IOException {
if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0: " + byteCount);
if (closed) throw new IllegalStateException("closed");
if (bytesRemaining == 0) return -1;
long read = source.read(sink, Math.min(bytesRemaining, byteCount));
if (read == -1) {
endOfInput(false); // The server didn't supply the promised content length.
throw new ProtocolException("unexpected end of stream");
}
bytesRemaining -= read;
if (bytesRemaining == 0) {
endOfInput(true);
}
return read;
}
所以到这我们知道onResponse
回调中的body.string()
内部在判断UTF字符集编码的时候就会首先调用一次FixedLengthSource
的read
方法(实测中UTF-8编码的这里只会调用一次,经过这次read之后bytesRemaining
会减去读到的字节数)。再回到body.string()
方法中看一下source.readString(charset)
的实现(在RealBufferedSource
中):
@Override
public String readString(Charset charset) throws IOException {
if (charset == null) throw new IllegalArgumentException("charset == null");
buffer.writeAll(source);
return buffer.readString(charset);
}
再看buffer.writeAll(source)
:
@Override
public long writeAll(Source source) throws IOException {
if (source == null) throw new IllegalArgumentException("source == null");
long totalBytesRead = 0;
for (long readCount; (readCount = source.read(this, Segment.SIZE)) != -1; ) {
totalBytesRead += readCount;
}
return totalBytesRead;
}
so…这里又会调用到FixedLengthSource
的read
方法,而且这里会循环读,直到度完为止,经过这一步之后在执行readString
方法中的最后一行时,其实buffer中已经有字符串数据了,里面的代码就是new了String对象而已,就不看了。
所以,总结一下,onResponse中body.string()
会先调用一次FixedLengthSource
的read
方法,这是由于Util.bomAwareCharset
方法中判断UTF字符集的过程中会调用FixedLengthSource
的read
方法,之后就是for循环读取了,每次读取bytesRemaining
和8k之间较小的那个字节数。
在实测中,测试了一个返回4000多个字节(UTF-8)的接口,分两次读取完毕,第一次读取了1000多字节,第二次读取了3000多字节,假如你的返回数据超过8k, 则会按照读取8k大小来。
假设服务端返回的Content-Length为13000,但是实际返回的正文长度只有11000,那么这样会有什么后果呢:
(1)初始化FixedLengthSource
,bytesRemaining
= Content-Length = 13000
(2)首先,Util.bomAwareCharset
方法可能会读取一部分(暂时不知道读取的确切的数量,假设是1000),那么bytesRemaining
= 13000 - 1000 = 12000
(3)循环中读取剩余的字节,每次读bytesRemaining
和8192最少的那个字节数,所以这一步的结果是bytesRemaining
= 12000 - 8192 = 3808
(注意,到这一步已读:1000+8192 = 9192,实际长度11000,也就是实际还剩:11000-9192 = 1808)
(4)循环中再一次读取,读取3808 和8192最少的那个字节数,也就是准备从source中读3808个字节,但实际只剩下1808了,因此只能读取到1808个字节,这一步读取之后source中就没有东西了,因此这一步的结果是bytesRemaining
= 3808 - 1808 = 2000
(5)由于bytesRemaining
还是大于0的,所以循环读取还会继续,读取2000和和8192最少的那个字节数,也就是准备读2000个字节,但是这一次由于source已经枯竭,read只会返回-1,因此触发了开始的错误异常条件。
所以,此问题要想解决必须在服务器端修正响应头中的Content-Length字段,Content-Length必须严格等于响应正文【按照响应头中返回的charset编码】的字节数,或者不用Content-Length字段(如使用Transfer-Encoding:chunked)。
以上,FixedLengthSource
中的read方法中调用的source对象的read方法没有进去详细看,其实它实现是在Okio类的source(final InputStream in, final Timeout timeout)
方法中实现的,这个方法是对socket返回的输入流InputStream对象进行读取,详细的可以自己看看。