文章已經收錄在 Github.com/niumoo/JavaNotes ,更有 Java 程式設計師所需要掌握的核心知識,歡迎Star和指教。
歡迎關注我的公眾號,文章每週更新。
感謝看客老爺點進來了,週末閒來無事,想起同事強哥的那句話:“你有沒有玩過斷點續傳?” 當時轉念一想,斷點續傳下載用的確實不少,具體細節嘛,真的沒有去思考過啊。這不,思考過後有了這篇文章。感謝強哥,讓我有了一篇可以水的文章,下面會用純 Java 無依賴實現一個簡單的多執行緒斷點續傳下載器。
這篇水文章到底有什麼內容呢?先簡單列舉一下,順便思考幾個問題。
- 斷點續傳的原理。
- 重啟續傳檔案時,怎麼保證檔案的一致性?
- 同一個檔案多執行緒下載如何實現?
- 網速頻寬固定,為什麼多執行緒下載可以提速?
多執行緒斷點續傳會用到哪些知識呢?上面已經丟擲了幾個問題,不放思考一下。下面會針對上面的四個問題一一進行解釋,現在大多數的服務都可以線上提供,下載使用的場景越來越少,不過這不妨礙我們對原理的探求。
斷點續傳的原理
想要了解斷點續傳是如何實現的,那麼肯定是要了解一下 HTTP 協議了。HTTP 協議是網際網路上應用最廣泛網路傳輸協議之一,它基於 TCP/IP 通訊協議來傳遞資料。所以斷點續傳的奧祕也就隱藏在這 HTTP 協議中了。
我們都知道 HTTP 請求會有一個 Request header 和 Response header ,就在這請求頭和響應頭裡,有一個和 Range 相關的引數。下面通過百度網盤的 pc 客戶端下載連結進行測試。
使用 cURL 檢視 response header. 如果你想知道更多關於 cURL 的用法,可以看我之前的一篇文章 :進來領略下cURL的獨門絕技。
$ curl -I http://wppkg.baidupcs.com/issue/netdisk/yunguanjia/BaiduYunGuanjia_7.0.1.1.exe
HTTP/1.1 200 OK
Server: JSP3/2.0.14
Date: Sat, 25 Jul 2020 13:41:55 GMT
Content-Type: application/x-msdownload
Content-Length: 65804256
Connection: keep-alive
ETag: dcd0bfef7d90dbb3de50a26b875143fc
Last-Modified: Tue, 07 Jul 2020 13:19:46 GMT
Expires: Sat, 25 Jul 2020 14:05:19 GMT
Age: 257796
Accept-Ranges: bytes
Cache-Control: max-age=259200
Content-Disposition: attachment;filename="BaiduYunGuanjia_7.0.1.1.exe"
x-bs-client-ip: MTgwLjc2LjIyLjU0
x-bs-file-size: 65804256
x-bs-request-id: MTAuMTM0LjM0LjU2Ojg2NDM6NDM4MTUzMTE4NTU3ODc5MTIxNzoyMDIwLTA3LTA3IDIyOjAxOjE1
x-bs-meta-crc32: 3545941535
Content-MD5: dcd0bfef7d90dbb3de50a26b875143fc
superfile: 2
Ohc-Response-Time: 1 0 0 0 0 0
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, PUT, POST, DELETE, OPTIONS, HEAD
Ohc-Cache-HIT: bj2pbs54 [2], bjbgpcache54 [4]
可以看到百度 pc 客戶端的 response header 資訊有很多,我們只需要重點關注幾個。
Content-Length: 65804256 // 請求的檔案的大小,單位 byte
Accept-Ranges: bytes // 是否允許指定傳輸範圍,bytes:範圍請求的單位是 bytes (位元組),none:不支援任何範圍請求單位,
Last-Modified: Tue, 07 Jul 2020 13:19:46 GMT // 服務端檔案最後修改時間,可以用於校驗檔案是否更改過
x-bs-meta-crc32: 3545941535 // crc32,可以用於校驗檔案是否更改過
ETag: dcd0bfef7d90dbb3de50a26b875143fc //Etag 標籤,可以用於校驗檔案是否更改過
可見並不見得所有下載都支援斷點續傳,只有在 response header 中有 Accept-Ranges: bytes
欄位時才可以斷點續傳。如果有這個資訊,該怎麼斷點續傳呢?其實只需要在 response header 中指定 Content-Range 值就可以了。
Content-Range 使用格式有下面幾種。
Content-Range: <unit>=<range-start>-<range-end>/<size> // size 為檔案總大小,如果不知道可以用 *
Content-Range: <unit>=<range-start>-<range-end>/*
Content-Range: <unit>=<range-start>-
Content-Range: <unit>=*/<size>
舉例:
單位 bytes,從第 10 個 bytes 開始下載:Content-Range: bytes=10-
.
單位 bytes,從第 10 個 bytes 開始下載,下載到第100個 bytes:Content-Range: bytes=10-100
.
這就是斷點續傳實現的原理了,你可以能已經發現了,Content-Range 的 start 和 end 已經讓分段下載有了可能。
怎麼保證檔案的一致性?
這裡要說的檔案完整性有兩個方面,一個是下載階段的,一個是寫入階段的。
因為我們要寫的下載器是支援斷點續傳的,那麼在進行續傳時,怎麼確定檔案自從我們上次下載時沒有進行過更新呢?其實可以通過 response header 中的幾個屬性值進行判斷。
Last-Modified: Tue, 07 Jul 2020 13:19:46 GMT // 服務端檔案最後修改時間,可以用於校驗檔案是否更改過
ETag: dcd0bfef7d90dbb3de50a26b875143fc //Etag 標籤,可以用於校驗檔案是否更改過
x-bs-meta-crc32: 3545941535 // crc32,可以用於校驗檔案是否更改過
Last-Modified
和 ETag
都可以用來檢驗檔案是否更新過,根據 HTTP 協議的規定,當檔案更新時,是會生成新的 ETag
值的,它類似於檔案的指紋資訊,而 Last-Modified
只是上次修改時間,有時可能並不能夠證明檔案內容被修改過。
上面是下載階段的檔案一致性校驗,那麼在寫入階段呢?不管單執行緒還是多執行緒,由於要斷點續傳,在寫入時都要在指定位置進行字元追加。在 Java 中有沒有好的實現方式?
答案是一定的,使用 RandomAccessFile
類即可,RandomAccessFile
不同於其他的流操作。它可以在使用時指定讀寫模式,使用 seek
方法隨意的移動要操作的檔案指標位置。很適合斷點續傳的寫入場景。
比如在 test.txt 的位置 0 開始寫入字元 abc,在位置 100 開始寫入字元 ddd.
try (RandomAccessFile rw = new RandomAccessFile("test.txt", "rw")){ // rw 為讀寫模式
rw.seek(0); // 移動檔案內容指標位置
rw.writeChars("abc");
rw.seek(100);
rw.writeChars("ddd");
}
斷點續傳的寫入就靠它了,在續傳時只需要移動檔案內容指標到要續傳的位置即可。
seek
方法還有很多妙用,比如使用它你可以快速定位到已知的位置,進行快速檢索;也可以在同一個檔案的不同位置進行併發讀寫。
多執行緒下載如何實現?
多執行緒下載必然要每個執行緒下載檔案中的一部分,然後把每個執行緒下載到的檔案內容組裝成一個完整的檔案,在這個過程中肯定是一個 byte 都不能出錯的,不然你組裝起來的檔案是肯定執行不起來的。那麼怎麼實現下載檔案的一部分呢?其實在斷點續傳的部分已經介紹過了,還是 Content-Range
引數,只要計算好每個部分要下載的 bytes 範圍就可以了。
比如:單位 bytes,第二部分從第 10 個 bytes 開始下載,下載到第100個 bytes:Content-Range: bytes=10-100
.
網速頻寬固定,為什麼多執行緒下載可以提速?
這是一個比較有意思的問題了,最大網速是固定的,運營商給你 100Mbs 的網速,不管你怎麼使用,速度最大也就是 100/8=12.5MB/S. 既然瓶頸在這裡,為什麼多執行緒下載可以提速呢?其實理論上來說,單執行緒下載就可以達到最大網速。但是往往事實是網路不是那麼通暢,十分擁堵,很難達到理想的最大速度。也就是說只有在網路不那麼通暢的時候,多執行緒下載才能提速。否則,單執行緒即可。不過最大速度永遠都是網路頻寬。
那為什麼多執行緒下載可以提速呢?HTTP 協議在傳輸時候是基於 TCP 協議傳輸資料的,為了弄明白這個問題需要了解一下 TCP 協議的擁塞控制機制。擁塞控制 是TCP 的一個避免網路擁塞的演算法,它是基於和性增長/乘性降低這樣的控制方法來控制擁塞的。
簡單來說就是在 TCP 開始傳輸資料時,服務端會不斷的探測可用頻寬。在一個傳輸內容段被成功接收後,會加倍傳輸兩倍段內容,如果再次被成功接收,就繼續加倍,直到發生了丟包,這是這也被叫做慢啟動。當達到慢啟動閥值(ssthresh)時,滿啟動演算法就會轉換為線性增長的階段,每次只增加一個分段,放緩增加速度。我覺得其實慢啟動的加倍增速過程並不慢,只是一種叫法。
但是當發生了丟包,也就是檢測到擁塞時,傳送方就會將傳送段大小降低一個乘數,比如二分之一,慢啟動閾值降為超時前擁塞視窗的一半大小、擁塞視窗會降為1個MSS,並且重新回到慢啟動階段。這時多執行緒的優勢就體現出來了,因為你的多執行緒會讓這個速度減速沒有那麼猛烈,畢竟這時可能有另一個執行緒正處在慢啟動的在最終加速階段,這樣總體的下載速度就優於單執行緒了。
多執行緒斷點續傳程式碼實現
基於上面的原理介紹,心裡應該有了具體的實現思路了。我們只需要使用多執行緒,結合 Content-Range
引數分段請求檔案內容儲存到臨時檔案,下載完畢後使用 RandomAccessFile
把下載的檔案合併成一個檔案即可。而在需要斷點續傳時,只需要讀取一下當前臨時檔案大小,然後調整 Content-Range
,就可以進行續傳下載。
程式碼不多,下面是部分核心程式碼,完整程式碼可以直接點開文章最後的 Github 倉庫。
Content-Range
請求指定檔案的區間內容。
URL httpUrl = new URL(url);
HttpURLConnection httpConnection = (HttpURLConnection)httpUrl.openConnection();
httpConnection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36");
httpConnection.setRequestProperty("RANGE", "bytes=" + start + "-" + end + "/*");
InputStream inputStream = httpConnection.getInputStream();
- 獲取檔案的 ETag.
Map<String, List<String>> headerFields = httpConnection.getHeaderFields();
List<String> eTagList = headerFields.get("ETag");
System.out.println(eTagList.get(0));
- 使用
RandomAccessFile
續傳寫入檔案。
RandomAccessFile oSavedFile = new RandomAccessFile(httpFileName, "rw");
oSavedFile.seek(localFileContentLength); // 檔案寫入開始位置指標移動到已經下載位置
byte[] buffer = new byte[1024 * 10];
int len = -1;
while ((len = inputStream.read(buffer)) != -1) {
oSavedFile.write(buffer, 0, len);
}
斷點續傳測試,下載一部分之後關閉程式再次啟動。
完整程式碼已經上傳到 github.com/niumoo/down-bit.
參考:
[1] HTTP headers
[4] 維基百科 - TCP擁塞控制
[5] 維基百科 - 和性增長/乘性降低
最後的話
文章已經收錄在 Github.com/niumoo/JavaNotes ,歡迎Star和指教。更有一線大廠面試點,Java程式設計師需要掌握的核心知識等文章,也整理了很多我的文字,歡迎 Star 和完善,希望我們一起變得優秀。
文章有幫助可以點個「贊」或「分享」,都是支援,我都喜歡!
文章每週持續更新,要實時關注我更新的文章以及分享的乾貨,可以關注「 未讀程式碼 」公眾號或者我的部落格。