一、业务背景
目前,vivo 平台有很多的业务都波及到文件的下载:譬如说利用商店、游戏核心的C端用户下载更新利用或游戏;开放平台B端用户通过接口传包能力更新利用或游戏,须要从用户服务器上下载apk、图片等文件,来实现用户的一次版本更新。
二、面临的挑战
针对上述C端用户,平台须要提供良好的下载环境,并且客户端须要兼容手机上用户的异样操作。
针对上述B端用户,平台亟需解决的问题就是从用户服务器上,拉取各种资源文件。
下载自身也是一个很简单的问题,会波及到网络问题、URL重定向、超大文件、近程服务器文件变更、本地文件被删除等各种问题。这就须要咱们保障平台具备疾速下载文件的能力,同时兼具备有对异样场景的疾速预警、容错解决的机制。
三、业务实现计划
基于后面提到的挑战,咱们设计实现计划的时候,援用了行业罕用的解决办法:断点下载。
针对B端用户场景,咱们的解决计划入下图:
一、极速下载:通过剖析文件大小,智能抉择是否采纳间接下载、单线程断点下载、多线程断点下载的计划;在应用多线程下载计划时,对"多线程"的应用,有两种形式:
- 分组模式:单个文件采纳固定最大N个线程来进行下载,分组的益处是能保障服务节点线程数量可控,劣势就是遇到大文件的时候,下载耗时绝对会比拟长;
- 分片模式:采纳单个线程,固定下载N个字节大小空间,分片的益处是遇到大文件的时候,下载耗时依然会绝对短,劣势是会导致服务器节点线程数量突增,对服务节点稳定性有烦扰;
在二者之间,咱们抉择了分组模式。
二、容错解决:在咱们解决下载过程中,会遇到下载过程中网络不稳固、本地文件删除,近程文件变更等各种场景,这就须要咱们可能兼容解决这些场景,失败后的工作,会有定时工作主动从新调起执行,也有后盾管理系统界面,进行人工调起;
三、完整性校验:文件下载实现之后,须要对文件的最终一致性做校验,来确保文件的正确性;
四、异样预警:对于单次工作在尝试屡次下载操作后依然失败的状况,及时发动预警正告。
对于C端用户,业务计划绝对更简略,因为文件服务器有vivo平台提供,网络环境绝对可控,这里就不再赘述。接下来,咱们将对文件下载外面的各种技术细节,进行详尽的分析。
四、断点下载原理分析
在进行原理剖析前,先给大家遍及一下,什么叫断点下载?置信大家都有过应用迅雷下载网络文件的经验吧,有没有留神到迅雷的下载任务栏外面,有一个“暂停”和“开始下载”按钮,会随着工作的以后状态显示不同的按钮。当你在下载一个100M的文件,下载到50M的时候,你点击了“暂停”,而后点击了“开始下载”,你会发现文件的下载居然是从曾经下载好的50M当前接着下载的。没错,这就是断点下载的实在利用。
4.1 HTTP 断点下载之机密:Range
在解说这个知识点前,大家有必要理解一下http的倒退历史,HTTP(HyperText Transfer Protocol),超文本传输协定,是目前万维网(World Wide Web)的根底协定,曾经经验四次的版本迭代:HTTP/0.9,HTTP/1.0,HTTP/1.1,HTTP/2.0。在HTTP/1.1(RFC2616)协定中,定义了HTTP1.1规范所蕴含的所有头字段的相干语法和含意,其中就包含咱们要讲到的Accept-Ranges,服务端反对范畴申请(range requests)。有了这个重要的属性,才使得咱们的断点下载成为可能。
基于HTTP不同版本之间的适配性,所以当咱们在决定是否须要应用断点下载能力的时候,须要提前辨认文件地址是否反对断点下载,怎么辨认呢?办法很多,如果采纳curl命令,命令为:curl -I url
CURL验证是否反对范畴申请:
如果服务端的响应信息外面蕴含了上图中Accept-Ranges: bytes,这个属性,那么说该URL是反对范畴申请的。如果URL返回音讯体外面,Accept-Ranges: none 或者压根就没有 Accept-Ranges这个属性,那么这个URL就是不反对范畴申请,也就是不反对断点下载。
后面咱们有看到,当应用curl命令获取URL的响应时,服务端返回了一大段文本信息,咱们要实现文件的断点下载,就要从这些文本信息外面获取咱们断点下载须要的重要参数,有了这些参数后能力实现咱们想要达到的成果。
4.2 HTTP 断点下载之Range语法阐明
HTTP/1.1 中定义了一个 Range 的申请头,来指定申请实体的范畴。它的范畴取值是在 0 - Content-Length 之间,应用 - 宰割。
4.2.1 单区间段范畴申请
curl https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt -i -H "Range: bytes=0-100"HTTP/1.1 206 Partial ContentDate: Sun, 20 Dec 2020 03:06:43 GMTContent-Type: text/plainContent-Length: 101Connection: keep-aliveServer: AliyunOSSx-oss-request-id: 5FDEBFC33243A938379F9410Accept-Ranges: bytesETag: "1FFD36BD1B06EB6C287AF8D788458808"Last-Modified: Sun, 20 Dec 2020 03:04:33 GMTx-oss-object-type: Normalx-oss-hash-crc64ecma: 5148872045942545519x-oss-storage-class: StandardContent-MD5: H/02vRsG62woevjXiEWICA==x-oss-server-time: 2Content-Range: bytes 0-100/740X-Via: 1.1 PShnzssxek171:14 (Cdn Cache Server V2.0), 1.1 x71:12 (Cdn Cache Server V2.0), 1.1 PS-FOC-01z6n168:27 (Cdn Cache Server V2.0)X-Ws-Request-Id: 5fdebfc3_PS-FOC-01z6n168_36519-1719Access-Control-Allow-Origin: *
4.2.2 多区间段范畴申请
curl https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt -i -H "Range: bytes=0-100,200-300"HTTP/1.1 206 Partial ContentDate: Sun, 20 Dec 2020 03:10:27 GMTContent-Type: multipart/byteranges; boundary="Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A"Content-Length: 506Connection: keep-aliveServer: AliyunOSSx-oss-request-id: 5FDEC030BDB66C33302A497EAccept-Ranges: bytesETag: "1FFD36BD1B06EB6C287AF8D788458808"Last-Modified: Sun, 20 Dec 2020 03:04:33 GMTx-oss-object-type: Normalx-oss-hash-crc64ecma: 5148872045942545519x-oss-storage-class: StandardContent-MD5: H/02vRsG62woevjXiEWICA==x-oss-server-time: 2Age: 1X-Via: 1.1 xian23:7 (Cdn Cache Server V2.0), 1.1 PS-NTG-01KKN43:8 (Cdn Cache Server V2.0), 1.1 PS-FOC-01z6n168:27 (Cdn Cache Server V2.0)X-Ws-Request-Id: 5fdec0a3_PS-FOC-01z6n168_36013-8986Access-Control-Allow-Origin: *--Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8AContent-Type: text/plainContent-Range: bytes 0-100/740--Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8AContent-Type: text/plainContent-Range: bytes 200-300/740
看完上述申请的响应后果信息,咱们发现应用单范畴区间申请时:Content-Type: text/plain,应用多范畴区间申请时:Content-Type: multipart/byteranges; boundary="Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A",并且在尾部信息外面,携带了单个区间片段的Content-Type和Content-Range。另外,不晓得大家有没有发现一个很重要的信息,咱们的HTTP响应的状态并非咱们料想中的200,而是HTTP/1.1 206 Partial Content,这个状态码十分重要,因为它标识着当次下载是否反对范畴申请。
4.3 异样场景之资源变更
有一种场景,不晓得大家有没有思考过,就是咱们在下载一个大文件的时候,在未下载实现的时候,近程文件曾经产生了变更,如果咱们持续应用断点下载,会呈现什么样的问题?后果当然是文件与近程文件不统一,会导致文件不可用。那么咱们有什么方法可能在下载之前及时发现近程文件曾经变更,并及时进行调整下载计划呢?解决办法其实下面有给大家提到,近程文件有没有发生变化,有两个标识:Etag和Last-Modified。二者任意一个属性均可反馈进去,相比而言,Etag会更精准些,起因如下:
- Last-Modified只能准确到秒级别,如果一秒内文件进行了屡次批改,工夫不会产生更新,然而文件的内容却曾经产生了变更,此时Etag会及时更新辨认到变更;
- 在不同的工夫节点(超过1秒),如果文件从A状态改成B状态,而后又重B状态改回了A状态,工夫会产生更新,然而相较于A状态文件内容,两次变更后并没又发生变化,此时Etag会变回最开始A状态值,有点相似咱们并发编程外面常说的ABA问题。
如果咱们在进行范畴申请下载的时候,带上了这两个属性中的一个或两个,就能监控近程文件产生了变动。如果产生了变动,那么区间范畴申请的响应状态就不是206而是200,阐明它曾经不反对该次申请的断点下载了。接下来咱们验证一下Etag的验证信息,咱们的测试文件:ETag: "1FFD36BD1B06EB6C287AF8D788458808",而后咱们将最初一个数值8改成9进行验证,验证如下:
文件未变更:
curl -I --header 'If-None-Match: "1FFD36BD1B06EB6C287AF8D788458808"' https://swsdl.vivo.com.cn/appstore/test-file-range-download.txtHTTP/1.1 304 Not ModifiedDate: Sun, 20 Dec 2020 03:53:03 GMTContent-Type: text/plainConnection: keep-aliveLast-Modified: Sun, 20 Dec 2020 03:04:33 GMTETag: "1FFD36BD1B06EB6C287AF8D788458808"Age: 1X-Via: 1.1 PS-FOC-01vM6221:15 (Cdn Cache Server V2.0)X-Ws-Request-Id: 5fdeca9f_PS-FOC-01FMC220_2660-18267Access-Control-Allow-Origin: *
文件已变更:
curl -I --header 'If-None-Match: "1FFD36BD1B06EB6C287AF8D788458809"' https://swsdl.vivo.com.cn/appstore/test-file-range-download.txtHTTP/1.1 200 OKDate: Sun, 20 Dec 2020 03:53:14 GMTContent-Type: text/plainContent-Length: 740Connection: keep-aliveServer: AliyunOSSx-oss-request-id: 5FDEC837E677A23037926897Accept-Ranges: bytesETag: "1FFD36BD1B06EB6C287AF8D788458808"Last-Modified: Sun, 20 Dec 2020 03:04:33 GMTx-oss-object-type: Normalx-oss-hash-crc64ecma: 5148872045942545519x-oss-storage-class: StandardContent-MD5: H/02vRsG62woevjXiEWICA==x-oss-server-time: 17X-Cache-Spec: YesAge: 1X-Via: 1.1 xian23:7 (Cdn Cache Server V2.0), 1.1 PS-NTG-01KKN43:8 (Cdn Cache Server V2.0), 1.1 PS-FOC-01vM6221:15 (Cdn Cache Server V2.0)X-Ws-Request-Id: 5fdecaaa_PS-FOC-01FMC220_4661-42392Access-Control-Allow-Origin: *
结果显示:当咱们应用跟近程文件统一的Etag时,状态码返回:HTTP/1.1 304 Not Modified,而应用篡改后的Etag后,返回状态200,并且也携带了正确的Etag返回。所以咱们在应用断点下载过程中,对于这种资源变更的场景也是须要兼顾思考的,不然就会呈现下载后文件无奈应用状况。
4.4 完整性验证
文件在下载实现后,咱们是不是就能间接应用呢?答案:NO。因为咱们无奈确认文件是否跟近程文件完全一致,所以在应用前,肯定要做一次文件的完整性验证。验证办法很简略,就是咱们后面提到过的属性:Etag,资源版本的标识符,通常是音讯摘要。带双引号的32位字符串,笔者验证过,该属性移除双引号后,就是文件的MD5值,大家晓得,文件MD5是能够用来验证文件唯一性的标识。通过这个校验,就能很好的辨认解决本地文件被删除、近程资源文件变更的各类非常规的业务场景。
五、实际局部
5.1 单线程断点下载
如果咱们须要下载1000个字节大小的文件,那么咱们在开始下载的时候,首先会获取到文件的Content-Length,而后在第一次开始下载时,会应用参数:httpURLConnection.setRequestProperty("Range", "bytes=0-1000");
当下载到到150个字节大小的时候,因为网络问题或者客户端服务重启等状况,导致下载终止,那么本地就存在一个大小为150byte的不残缺文件,当咱们服务重启后从新下载该文件时,咱们不仅须要从新获取近程文件的大小,还须要获取本地曾经下载的文件大小,此时应用参数:httpURLConnection.setRequestProperty("Range", "bytes=150-1000");
来保障咱们的下载是基于前一次的下载根底之上的。图示:
5.2 多线程断点下载
多线程断点下载的原理,与下面提到的单线程相似,惟一的区别在于:多个线程并行下载,单线程是串行下载。
5.3 代码示例
5.3.1 获取连贯
在下载前,咱们须要获取近程文件的HttpURLConnection 连贯,如下:
/** * 获取连贯 */private static HttpURLConnection getHttpUrlConnection(String netUrl) throws Exception { URL url = new URL(netUrl); HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); // 设置超工夫为3秒 httpURLConnection.setConnectTimeout(3 * 1000); // 避免屏蔽程序抓取而返回403谬误 httpURLConnection.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)"); return httpURLConnection;}
5.3.2 是否反对范畴申请
在进行断点下载开始前,咱们须要判断该文件,是否反对范畴申请,反对的范畴申请,咱们能力实现断点下载,如下:
/** * 判断连贯是否反对断点下载 */private static boolean isSupportRange(String netUrl) throws Exception { HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl); String acceptRanges = httpURLConnection.getHeaderField("Accept-Ranges"); if (StringUtils.isEmpty(acceptRanges)) { return false; } if ("bytes".equalsIgnoreCase(acceptRanges)) { return true; } return false;}
5.3.3 获取近程文件大小
当文件反对断点下载,咱们须要获取近程文件的大小,来设置Range参数的范畴区间,当然,如果是单线程断线下载,不获取近程文件大小,应用 Range: start- 也是能实现断点下载的,如下:
/** * 获取近程文件大小 */private static int getFileContentLength(String netUrl) throws Exception { HttpURLConnection httpUrlConnection = getHttpUrlConnection(netUrl); int contentLength = httpUrlConnection.getContentLength(); closeHttpUrlConnection(httpUrlConnection); return contentLength;}
5.3.4 单线程断点下载
不论是单线程断点下载还是多线程断点下载,片段文件下载实现后,都无奈绕开的一个问题,那就是文件合并。咱们应用范畴申请,拿到了文件中的某个区间片段,最终还是要将各个片段合并成一个残缺的文件,能力实现咱们最后的下载目标。
相较而言,单线程的合并会比较简单,因为单线程断点下载应用串行下载,在文件断点写入过程中,都是基于已有片段进行尾部追加,咱们应用commons-io-2.4.jar外面的一个工具办法,来实现文件的尾部追加:
5.3.4.1 文件分段
单线程-范畴分段
/** * 单线程串行下载 * * @param totalFileSize 文件总大小 * @param netUrl 文件地址 * @param N 串行下载分段次数 */private static void segmentDownload(int totalFileSize, String netUrl, int N) throws Exception { // 本地文件目录 String localFilePath = "F:\\test_single_thread.txt"; // 文件咱们分N次来下载 int eachFileSize = totalFileSize / N; for (int i = 1; i <= N; i++) { // 写入本地文件 File localFile = new File(localFilePath); // 获取本地文件,如果为空,则start=0,不为空则为该本地文件的大小作为断点下载开始地位 long start = localFile.length(); long end = 0; if (i == 1) { end = eachFileSize; } else if (i == N) { end = totalFileSize; } else { end = eachFileSize * i; } appendFile(netUrl, localFile, start, end); System.out.println(String.format("我是第%s次下载,下载片段范畴start=%s,end=%s", i, start, end)); } File localFile = new File(localFilePath); System.out.println("本地文件大小:" + localFile.length());}
5.3.4.2 文件追加
单线程-文件尾部追加
/** * 文件尾部追加 * @param netUrl 地址 * @param localFile 本地文件 * @param start 分段开始地位 * @param end 分段完结地位 */private static void appendFile(String netUrl, File localFile, long start, long end) throws Exception { HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl); httpURLConnection.setRequestProperty("Range", "bytes=" + start + "-" + end); // 获取近程文件流信息 InputStream inputStream = httpURLConnection.getInputStream(); // 本地文件写入流,反对文件追加 FileOutputStream fos = FileUtils.openOutputStream(localFile, true); IOUtils.copy(inputStream, fos); closeHttpUrlConnection(httpURLConnection);}
单线程下载后果
近程文件反对断点下载近程文件大小:740我是第1次下载,下载片段范畴start=0,end=246我是第2次下载,下载片段范畴start=247,end=492我是第3次下载,下载片段范畴start=493,end=740本地文件和近程文件统一,md5 = 1FFD36BD1B06EB6C287AF8D788458808, Etag = "1FFD36BD1B06EB6C287AF8D788458808"
5.3.5 多线程断点下载
多线程的文件合并形式与单线程不一样,因为多线程是并行下载,每个子线程下载实现的工夫是不确定的。这个时候,咱们须要应用到java一个外围类:RandomAccessFile。这个类能够反对随机的文件读写,其中有一个seek函数,能够将指针指向文件任意地位,而后进行读写。什么意思呢,举个栗子:如果咱们开了10个线程,首先第一个下载实现的是线程X,它下载的数据范畴是300-400,那么这时咱们调用seek函数将指针动到300,而后调用它的write函数将byte写出,这时候300之前都是NULL,300-400之后就是咱们插入的数据。这样就能够实现多线程下载和本地写入了。话不多说,咱们还是以代码的形式来出现:
5.3.5.1 资源分组
多线程-资源分组
/** * 多线程分组策略 * @param netUrl 网络地址 * @param totalFileSize 文件总大小 * @param N 线程池数量 */private static void groupDownload(String netUrl, int totalFileSize, int N) throws Exception { // 采纳闭锁个性来实现最初的文件校验事件 CountDownLatch countDownLatch = new CountDownLatch(N); // 本地文件目录 String localFilePath = "F:\\test_multiple_thread.txt"; int groupSize = totalFileSize / N; int start = 0; int end = 0; for (int i = 1; i <= N; i++) { if (i <= 1) { start = groupSize * (i - 1); end = groupSize * i; } else if (i > 1 && i < N) { start = groupSize * (i - 1) + 1; end = groupSize * i; } else { start = groupSize * (i - 1) + 1; end = totalFileSize; } System.out.println(String.format("线程%s调配区间范畴start=%s, end=%s", i, start, end)); downloadAndMerge(i, netUrl, localFilePath, start, end, countDownLatch); } // 校验文件一致性 countDownLatch.await(); validateCompleteness(localFilePath, netUrl);}
5.3.5.2 文件合并
多线程-文件合并
/** * 文件下载、合并 * @param threadNum 线程标识 * @param netUrl 网络文件地址 * @param localFilePath 本地文件门路 * @param start 范畴申请开始地位 * @param end 范畴申请完结地位 * @param countDownLatch 闭锁对象 */private static void downloadAndMerge(int threadNum, String netUrl, String localFilePath, int start, int end, CountDownLatch countDownLatch) { threadPoolExecutor.execute(() -> { try { HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl); httpURLConnection.setRequestProperty("Range", "bytes=" + start + "-" + end); // 获取近程文件流信息 InputStream inputStream = httpURLConnection.getInputStream(); RandomAccessFile randomAccessFile = new RandomAccessFile(localFilePath, "rw"); // 文件写入开始地位指针挪动到曾经下载地位 randomAccessFile.seek(start); byte[] buffer = new byte[1024 * 10]; int len = -1; while ((len = inputStream.read(buffer)) != -1) { randomAccessFile.write(buffer, 0, len); } closeHttpUrlConnection(httpURLConnection); System.out.println(String.format("下载实现工夫%s, 线程:%s, 下载实现: start=%s, end = %s", System.currentTimeMillis(), threadNum, start, end)); } catch (Exception e) { System.out.println(String.format("片段下载异样:线程:%s, start=%s, end = %s", threadNum, start, end)); e.printStackTrace(); } countDownLatch.countDown(); });}
多线程下载运行后果
近程文件反对断点下载近程文件大小:740线程1调配区间范畴start=0, end=74线程2调配区间范畴start=75, end=148线程3调配区间范畴start=149, end=222线程4调配区间范畴start=223, end=296线程5调配区间范畴start=297, end=370线程6调配区间范畴start=371, end=444线程7调配区间范畴start=445, end=518线程8调配区间范畴start=519, end=592线程9调配区间范畴start=593, end=666线程10调配区间范畴start=667, end=740下载实现工夫1608443874752, 线程:7, 下载实现: start=445, end = 518下载实现工夫1608443874757, 线程:2, 下载实现: start=75, end = 148下载实现工夫1608443874758, 线程:3, 下载实现: start=149, end = 222下载实现工夫1608443874759, 线程:5, 下载实现: start=297, end = 370下载实现工夫1608443874760, 线程:10, 下载实现: start=667, end = 740下载实现工夫1608443874760, 线程:1, 下载实现: start=0, end = 74下载实现工夫1608443874779, 线程:8, 下载实现: start=519, end = 592下载实现工夫1608443874781, 线程:6, 下载实现: start=371, end = 444下载实现工夫1608443874784, 线程:9, 下载实现: start=593, end = 666下载实现工夫1608443874788, 线程:4, 下载实现: start=223, end = 296本地文件和近程文件统一,md5 = 1FFD36BD1B06EB6C287AF8D788458808, Etag = "1FFD36BD1B06EB6C287AF8D788458808"
从运行后果能够出,子线程下载实现工夫并没有齐全按着咱们for循环指定的1-10线程标号程序实现,阐明子线程之间是并行在写入文件。其中还能够看到,子线程10和子线程1是在同一时间实现了文件的下载和写入,这也很好的验证了咱们下面提到的RandomAccessFile类的成果。
5.3.6 完整性判断
完整性校验
/** * 校验文件一致性,咱们判断Etag和本地文件的md5是否统一 * 注:Etag携带了双引号 * @param localFilePath * @param netUrl */private static void validateCompleteness(String localFilePath, String netUrl) throws Exception{ File file = new File(localFilePath); InputStream data = new FileInputStream(file); String md5 = DigestUtils.md5Hex(data); HttpURLConnection httpURLConnection = getHttpUrlConnection(netUrl); String etag = httpURLConnection.getHeaderField("Etag"); if (etag.toUpperCase().contains(md5.toUpperCase())) { System.out.println(String.format("本地文件和近程文件统一,md5 = %s, Etag = %s", md5.toUpperCase(), etag)); } else { System.out.println(String.format("本地文件和近程文件不统一,md5 = %s, Etag = %s", md5.toUpperCase(), etag)); }}
六、写在最初
文件断点下载的劣势在于晋升下载速度,然而也不是每种业务场景都适宜,比如说业务网络环境很好,下载的单个文件大小几十兆的状况下,应用断点下载也没有太大的劣势,反而减少了实现计划的复杂度。这就要求咱们开发人员在应用时酌情思考,而不是自觉应用。
作者:vivo-Tang Aibo