目前,vivo 平臺有不少的業務都涉及到文件的下載:譬如說應用商店、遊戲中心的C端用戶下載更新應用或遊戲;開放平臺B端用戶經過接口傳包能力更新應用或遊戲,須要從用戶服務器上下載apk、圖片等文件,來完成用戶的一次版本更新。java
針對上述C端用戶,平臺須要提供良好的下載環境,而且客戶端須要兼容手機上用戶的異常操做。編程
針對上述B端用戶,平臺亟需解決的問題就是從用戶服務器上,拉取各類資源文件。服務器
下載自己也是一個很複雜的問題,會涉及到網絡問題、URL重定向、超大文件、遠程服務器文件變動、本地文件被刪除等各類問題。這就須要咱們保證平臺具有快速下載文件的能力,同時兼具備有對異常場景的快速預警、容錯處理的機制。網絡
基於前面提到的挑戰,咱們設計實現方案的時候,引用了行業經常使用的解決方法:斷點下載。多線程
針對B端用戶場景,咱們的處理方案入下圖:併發
1、極速下載:經過分析文件大小,智能選擇是否採用直接下載、單線程斷點下載、多線程斷點下載的方案;在使用多線程下載方案時,對"多線程"的使用,有兩種方式:app
在兩者之間,咱們選擇了分組模式。dom
2、容錯處理:在咱們處理下載過程當中,會遇到下載過程當中網絡不穩定、本地文件刪除,遠程文件變動等各類場景,這就須要咱們可以兼容處理這些場景,失敗後的任務,會有定時任務自動從新調起執行,也有後臺管理系統界面,進行人工調起;curl
3、完整性校驗:文件下載完成以後,須要對文件的最終一致性作校驗,來確保文件的正確性;ide
4、異常預警:對於單次任務在嘗試屢次下載操做後仍然失敗的狀況,及時發起預警警告。
對於C端用戶,業務方案相對更簡單,由於文件服務器有vivo平臺提供,網絡環境相對可控,這裏就再也不贅述。接下來,咱們將對文件下載裏面的各類技術細節,進行詳盡的剖析。
在進行原理分析前,先給你們普及一下,什麼叫斷點下載?相信你們都有過使用迅雷下載網絡文件的經歷吧,有沒有注意到迅雷的下載任務欄裏面,有一個「暫停」和「開始下載」按鈕,會隨着任務的當前狀態顯示不一樣的按鈕。當你在下載一個100M的文件,下載到50M的時候,你點擊了「暫停」,而後點擊了「開始下載」,你會發現文件的下載居然是從已經下載好的50M之後接着下載的。沒錯,這就是斷點下載的真實應用。
在講解這個知識點前,你們有必要了解一下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的響應時,服務端返回了一大段文本信息,咱們要實現文件的斷點下載,就要從這些文本信息裏面獲取我們斷點下載須要的重要參數,有了這些參數後才能實現咱們想要達到的效果。
HTTP/1.1 中定義了一個 Range 的請求頭,來指定請求實體的範圍。它的範圍取值是在 0 - Content-Length 之間,使用 - 分割。
curl https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt -i -H "Range: bytes=0-100" HTTP/1.1 206 Partial Content Date: Sun, 20 Dec 2020 03:06:43 GMT Content-Type: text/plain Content-Length: 101 Connection: keep-alive Server: AliyunOSS x-oss-request-id: 5FDEBFC33243A938379F9410 Accept-Ranges: bytes ETag: "1FFD36BD1B06EB6C287AF8D788458808" Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT x-oss-object-type: Normal x-oss-hash-crc64ecma: 5148872045942545519 x-oss-storage-class: Standard Content-MD5: H/02vRsG62woevjXiEWICA== x-oss-server-time: 2 Content-Range: bytes 0-100/740 X-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-1719 Access-Control-Allow-Origin: *
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 Content Date: Sun, 20 Dec 2020 03:10:27 GMT Content-Type: multipart/byteranges; boundary="Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A" Content-Length: 506 Connection: keep-alive Server: AliyunOSS x-oss-request-id: 5FDEC030BDB66C33302A497E Accept-Ranges: bytes ETag: "1FFD36BD1B06EB6C287AF8D788458808" Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT x-oss-object-type: Normal x-oss-hash-crc64ecma: 5148872045942545519 x-oss-storage-class: Standard Content-MD5: H/02vRsG62woevjXiEWICA== x-oss-server-time: 2 Age: 1 X-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-8986 Access-Control-Allow-Origin: * --Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A Content-Type: text/plain Content-Range: bytes 0-100/740 --Cdn Cache Server V2.0:37E1D9B3B2B94DF2F1D84393694C7E8A Content-Type: text/plain Content-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,這個狀態碼很是重要,由於它標識着當次下載是否支持範圍請求。
有一種場景,不知道你們有沒有思考過,就是咱們在下載一個大文件的時候,在未下載完成的時候,遠程文件已經發生了變動,若是咱們繼續使用斷點下載,會出現什麼樣的問題?結果固然是文件與遠程文件不一致,會致使文件不可用。那麼咱們有什麼辦法可以在下載以前及時發現遠程文件已經變動,並及時進行調整下載方案呢?解決方法其實上面有給你們提到,遠程文件有沒有發生變化,有兩個標識:Etag和Last-Modified。兩者任意一個屬性都可反應出來,相比而言,Etag會更精準些,緣由以下:
若是咱們在進行範圍請求下載的時候,帶上了這兩個屬性中的一個或兩個,就能監控遠程文件發生了變化。若是發生了變化,那麼區間範圍請求的響應狀態就不是206而是200,說明它已經不支持該次請求的斷點下載了。接下來咱們驗證一下Etag的驗證信息,咱們的測試文件:ETag: "1FFD36BD1B06EB6C287AF8D788458808",而後咱們將最後一個數值8改爲9進行驗證,驗證以下:
文件未變動:
curl -I --header 'If-None-Match: "1FFD36BD1B06EB6C287AF8D788458808"' https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt HTTP/1.1 304 Not Modified Date: Sun, 20 Dec 2020 03:53:03 GMT Content-Type: text/plain Connection: keep-alive Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT ETag: "1FFD36BD1B06EB6C287AF8D788458808" Age: 1 X-Via: 1.1 PS-FOC-01vM6221:15 (Cdn Cache Server V2.0) X-Ws-Request-Id: 5fdeca9f_PS-FOC-01FMC220_2660-18267 Access-Control-Allow-Origin: *
文件已變動:
curl -I --header 'If-None-Match: "1FFD36BD1B06EB6C287AF8D788458809"' https://swsdl.vivo.com.cn/appstore/test-file-range-download.txt HTTP/1.1 200 OK Date: Sun, 20 Dec 2020 03:53:14 GMT Content-Type: text/plain Content-Length: 740 Connection: keep-alive Server: AliyunOSS x-oss-request-id: 5FDEC837E677A23037926897 Accept-Ranges: bytes ETag: "1FFD36BD1B06EB6C287AF8D788458808" Last-Modified: Sun, 20 Dec 2020 03:04:33 GMT x-oss-object-type: Normal x-oss-hash-crc64ecma: 5148872045942545519 x-oss-storage-class: Standard Content-MD5: H/02vRsG62woevjXiEWICA== x-oss-server-time: 17 X-Cache-Spec: Yes Age: 1 X-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-42392 Access-Control-Allow-Origin: *
結果顯示:當咱們使用跟遠程文件一致的Etag時,狀態碼返回:HTTP/1.1 304 Not Modified,而使用篡改後的Etag後,返回狀態200,而且也攜帶了正確的Etag返回。因此咱們在使用斷點下載過程當中,對於這種資源變動的場景也是須要兼顧考慮的,否則就會出現下載後文件沒法使用狀況。
文件在下載完成後,咱們是否是就能直接使用呢?答案:NO。由於咱們沒法確認文件是否跟遠程文件徹底一致,因此在使用前,必定要作一次文件的完整性驗證。驗證方法很簡單,就是我們前面提到過的屬性:Etag,資源版本的標識符,一般是消息摘要。帶雙引號的32位字符串,筆者驗證過,該屬性移除雙引號後,就是文件的MD5值,你們知道,文件MD5是能夠用來驗證文件惟一性的標識。經過這個校驗,就能很好的識別解決本地文件被刪除、遠程資源文件變動的各種很是規的業務場景。
假如咱們須要下載1000個字節大小的文件,那麼咱們在開始下載的時候,首先會獲取到文件的Content-Length,而後在第一次開始下載時,會使用參數:httpURLConnection.setRequestProperty("Range", "bytes=0-1000");
當下載到到150個字節大小的時候,由於網絡問題或者客戶端服務重啓等狀況,致使下載終止,那麼本地就存在一個大小爲150byte的不完整文件,當咱們服務重啓後從新下載該文件時,咱們不只須要從新獲取遠程文件的大小,還須要獲取本地已經下載的文件大小,此時使用參數:httpURLConnection.setRequestProperty("Range", "bytes=150-1000");
來保證咱們的下載是基於前一次的下載基礎之上的。圖示:
多線程斷點下載的原理,與上面提到的單線程相似,惟一的區別在於:多個線程並行下載,單線程是串行下載。
在下載前,咱們須要獲取遠程文件的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; }
在進行斷點下載開始前,咱們須要判斷該文件,是否支持範圍請求,支持的範圍請求,咱們才能實現斷點下載,以下:
/** * 判斷鏈接是否支持斷點下載 */ 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; }
當文件支持斷點下載,咱們須要獲取遠程文件的大小,來設置Range參數的範圍區間,固然,若是是單線程斷線下載,不獲取遠程文件大小,使用 Range: start- 也是能完成斷點下載的,以下:
/** * 獲取遠程文件大小 */ private static int getFileContentLength(String netUrl) throws Exception { HttpURLConnection httpUrlConnection = getHttpUrlConnection(netUrl); int contentLength = httpUrlConnection.getContentLength(); closeHttpUrlConnection(httpUrlConnection); return contentLength; }
無論是單線程斷點下載仍是多線程斷點下載,片斷文件下載完成後,都沒法繞開的一個問題,那就是文件合併。咱們使用範圍請求,拿到了文件中的某個區間片斷,最終仍是要將各個片斷合併成一個完整的文件,才能實現咱們最初的下載目的。
相較而言,單線程的合併會比較簡單,由於單線程斷點下載使用串行下載,在文件斷點寫入過程當中,都是基於已有片斷進行尾部追加,咱們使用commons-io-2.4.jar裏面的一個工具方法,來實現文件的尾部追加:
單線程-範圍分段
/** * 單線程串行下載 * * @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()); }
單線程-文件尾部追加
/** * 文件尾部追加 * @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"
多線程的文件合併方式與單線程不同,由於多線程是並行下載,每一個子線程下載完成的時間是不肯定的。這個時候,咱們須要使用到java一個核心類:RandomAccessFile。這個類能夠支持隨機的文件讀寫,其中有一個seek函數,能夠將指針指向文件任意位置,而後進行讀寫。什麼意思呢,舉個栗子:假如咱們開了10個線程,首先第一個下載完成的是線程X,它下載的數據範圍是300-400,那麼這時咱們調用seek函數將指針動到300,而後調用它的write函數將byte寫出,這時候300以前都是NULL,300-400以後就是咱們插入的數據。這樣就能夠實現多線程下載和本地寫入了。話很少說,咱們仍是以代碼的方式來呈現:
多線程-資源分組
/** * 多線程分組策略 * @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); }
多線程-文件合併
/** * 文件下載、合併 * @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類的效果。
完整性校驗
/** * 校驗文件一致性,咱們判斷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