
Android 客户端下载服务器文件是每个应用的基本功能,下面我将与大家一起分享我在实际开发中遇到关于文件下载的问题。
背景
最近迭代需求要求,通过移动端访问一台在局域网下的电脑中的共享文件夹,移动端 → 服务器 → 局域网电脑。移动端这边我们打算通过 Ftp 协议去获取这个共享文件夹下的内容,但由于要加权限管理,并且涉及到电脑资料安全问题,最终方案定位后台做权限管理,然后通过获取共享文件夹流转发给移动端。
问题排查
项目中的文件下载库使用的是 ThinDownloadManager
在后台部署好模拟环境后,后台小伙伴给我一个链接让我请求下载文件。然后我就开始了一天的采坑之旅。ThinDownloadManager 库下载时出现了错误,错误信息如下:
Transfer-Encoding not found as well as can't know size of download
在 Github 这个库的 issue 中没有发现其他人出现过这个问题。在库的源码中定位到错误所在。
switch (responseCode) {
case HTTP_PARTIAL:
case HTTP_OK:
shouldAllowRedirects = false;
if (readResponseHeaders(request, conn, responseCode) == 1) {
Log.d(TAG, "Existing mDownloadedCacheSize: " + mDownloadedCacheSize);
Log.d(TAG, "File mContentLength: " + mContentLength);
if (mDownloadedCacheSize == mContentLength) { // Mark as success, If end of stream already reached
updateDownloadComplete(request);
Log.d(TAG, "Download Completed");
} else {
transferData(request, conn);
}
} else {
// 错误提示
updateDownloadFailed(request, DownloadManager.ERROR_DOWNLOAD_SIZE_UNKNOWN, "Transfer-Encoding not found as well as can't know size of download, giving up");
}
return;
private int readResponseHeaders(DownloadRequest request, HttpURLConnection conn, int responseCode) {
final String transferEncoding = conn.getHeaderField("Transfer-Encoding");
mContentLength = -1;
if (transferEncoding == null) {
if (responseCode == HTTP_OK) {
// If file download already completed, 200 HttpStatusCode will thrown by service.
mContentLength = getHeaderFieldLong(conn, "Content-Length", -1);
} else {
// If file download already partially completed, 206 HttpStatusCode will thrown by service and we can resume remaining chunks downloads.
mContentLength = getHeaderFieldLong(conn, "Content-Length", -1) + mDownloadedCacheSize;
}
} else {
Log.v("Ignoring Content-Length since Transfer-Encoding is also defined for Downloaded Id " + request.getDownloadId());
}
if (mContentLength != -1) {
return 1;
} else if (transferEncoding == null || !transferEncoding.equalsIgnoreCase("chunked")) {
return -1;
} else {
return 1;
}
}
通过代码分析我们可以看出在 Http 请求成功后,在响应头中没有获取到 Transfer-Encoding 、Content-Length 这两个字段导致的,接下来我们讨论的主角登场啦。
Persistent Connection
暂时把 Transfer-Encoding 放一边,我们来看 HTTP 协议中另外一个重要概念:Persistent Connection(持久连接,通俗说法长连接)。我们知道 HTTP 运行在 TCP 连接之上,自然也有着跟 TCP 一样的三次握手、慢启动等特性,为了尽可能的提高 HTTP 性能,使用持久连接就显得尤为重要了。为此,HTTP 协议引入了相应的机制。
HTTP/1.0 的持久连接机制是后来才引入的,通过 Connection: keep-alive 这个头部来实现,服务端和客户端都可以使用它告诉对方在发送完数据之后不需要断开 TCP 连接,以备后用。HTTP/1.1 则规定所有连接都必须是持久的,除非显式地在头部加上 Connection: close。所以实际上,HTTP/1.1 中 Connection 这个头部字段已经没有 keep-alive 这个取值了,但由于历史原因,很多 Web Server 和浏览器,还是保留着给 HTTP/1.1 长连接发送 Connection: keep-alive 的习惯。
浏览器重用已经打开的空闲持久连接,可以避开缓慢的三次握手,还可以避免遇上 TCP 慢启动的拥塞适应阶段,听起来十分美妙。为了深入研究持久连接的特性,我决定用 Node 写一个最简单的 Web Server 用于测试,Node 提供了 http 模块用于快速创建 HTTP Web Server,但我需要更多的控制,所以用 net 模块创建了一个 TCP Server:
require('net').createServer(function(sock) {
sock.on('data', function(data) {
sock.write('HTTP/1.1 200 OK\r\n');
sock.write('\r\n');
sock.write('hello world!');
sock.destroy();
});
}).listen(9090, '127.0.0.1');
启动服务后,在浏览器里访问 127.0.0.1:9090,正确输出了指定内容,一切正常。去掉 sock.destroy() 这一行,让它变成持久连接,重启服务后再访问一下。这次的结果就有点奇怪了:迟迟看不到输出,通过 Network 查看请求状态,一直是 pending。
这是因为,对于非持久连接,浏览器可以通过连接是否关闭来界定请求或响应实体的边界;而对于持久连接,这种方法显然不奏效。上例中,尽管我已经发送完所有数据,但浏览器并不知道这一点,它无法得知这个打开的连接上是否还会有新数据进来,只能傻傻地等了。
Content-Length
要解决上面这个问题,最容易想到的办法就是计算实体长度,并通过头部告诉对方。这就要用到 Content-Length 了,改造一下上面的例子:
require('net').createServer(function(sock) {
sock.on('data', function(data) {
sock.write('HTTP/1.1 200 OK\r\n');
sock.write('Content-Length: 12\r\n');
sock.write('\r\n');
sock.write('hello world!');
});
}).listen(9090, '127.0.0.1');
可以看到,这次发送完数据并没有关闭 TCP 连接,但浏览器能正常输出内容并结束请求,因为浏览器可以通过 Content-Length 的长度信息,判断出响应实体已结束。那如果 Content-Length 和实体实际长度不一致会怎样?有兴趣的同学可以自己试试,通常如果 Content-Length 比实际长度短,会造成内容被截断;如果比实体内容长,会造成 pending。
由于 Content-Length 字段必须真实反映实体长度,但实际应用中,有些时候实体长度并没那么好获得,例如实体来自于网络文件,或者由动态语言生成。这时候要想准确获取长度,只能开一个足够大的 buffer,等内容全部生成好再计算。但这样做一方面需要更大的内存开销,另一方面也会让客户端等更久。
我们在做 WEB 性能优化时,有一个重要的指标叫 TTFB(Time To First Byte),它代表的是从客户端发出请求到收到响应的第一个字节所花费的时间。大部分浏览器自带的 Network 面板都可以看到这个指标,越短的 TTFB 意味着用户可以越早看到页面内容,体验越好。可想而知,服务端为了计算响应实体长度而缓存所有内容,跟更短的 TTFB 理念背道而驰。但在 HTTP 报文中,实体一定要在头部之后,顺序不能颠倒,为此我们需要一个新的机制:不依赖头部的长度信息,也能知道实体的边界。
Transfer-Encoding
本文主角终于再次出现了,Transfer-Encoding 正是用来解决上面这个问题的。历史上 Transfer-Encoding 可以有多种取值,为此还引入了一个名为 TE 的头部用来协商采用何种传输编码。但是最新的 HTTP 规范里,只定义了一种传输编码:分块编码(chunked)。
分块编码相当简单,在头部加入 Transfer-Encoding: chunked 之后,就代表这个报文采用了分块编码。这时,报文中的实体需要改为用一系列分块来传输。每个分块包含十六进制的长度值和数据,长度值独占一行,长度不包括它结尾的 CRLF(\r\n),也不包括分块数据结尾的 CRLF。最后一个分块长度值必须为 0,对应的分块数据没有内容,表示实体结束。按照这个格式改造下之前的代码:
require('net').createServer(function(sock) {
sock.on('data', function(data) {
sock.write('HTTP/1.1 200 OK\r\n');
sock.write('Transfer-Encoding: chunked\r\n');
sock.write('\r\n');
sock.write('b\r\n');
sock.write('01234567890\r\n');
sock.write('5\r\n');
sock.write('12345\r\n');
sock.write('0\r\n');
sock.write('\r\n');
});
}).listen(9090, '127.0.0.1');
上面这个例子中,我在响应头中表明接下来的实体会采用分块编码,然后输出了 11 字节的分块,接着又输出了 5 字节的分块,最后用一个 0 长度的分块表明数据已经传完了。用浏览器访问这个服务,可以得到正确结果。可以看到,通过这种简单的分块策略,很好的解决了前面提出的问题。
解决方案
了解这些后,我让后台小伙伴添加上 Content-Length ,但是在 Android 端的 Response Header 中仍然没有这个字段。不管后台的小伙伴怎么配置依旧不可以呀 ε(┬┬﹏┬┬)3。老天不负有心人在 Github 找到一个下载库 FileDownloader
出现过同样的问题并避开了这个问题,这个库官方给出的解释是这样的

难道我要把项目里文件下载库替换成这个......  ̄□ ̄||
衡量一下这个改动量太多,迭代时间有限,首先要看看这个大神写的库是如何避开这个校验的。其实文件下载最终实现的核心代码都是一样的,只是在校验 Response Header 的地方有些差异。我们找到了处理方法,通过配置忽略响应头不规范校验。
如果你遇到了: 'can't know the size of the download file, and its Transfer-Encoding is not Chunked either', 但是你想要忽略类似的返回头不规范的错误,直接将该关键字参数设置为true即可,我们将会将其作为chunck进行处理
然后我回到自己的项目中找到响应头中校验代码并做了如下修改
if (readResponseHeaders(request, conn, responseCode) == 1) {
Log.d(TAG, "Existing mDownloadedCacheSize: " + mDownloadedCacheSize);
Log.d(TAG, "File mContentLength: " + mContentLength);
if (mDownloadedCacheSize == mContentLength) { // Mark as success, If end of stream already reached
updateDownloadComplete(request);
Log.d(TAG, "Download Completed");
} else {
transferData(request, conn);
}
} else {
// 直接读取数据
transferData(request, conn);
// 注释掉响应头不规范错误提示
// updateDownloadFailed(request, DownloadManager.ERROR_DOWNLOAD_SIZE_UNKNOWN, "Transfer-Encoding not found as well as can't know size of download, giving up");
}
果然下载成功是不是很坑,不规范我们还要兼容....好了我也要洗洗睡啦。
网友评论