HTTP斷點下載協議


服務端收到普通的HTTP請求時會將整個文件返回給請求者,HTTP響應碼為200。對於音頻、視頻等多媒體文件來說,往往文件內容較大,如果每次都返回整個文件,則不論對服務端還是瀏覽器來說速度都很慢。此時可以采用斷點下載(Partial Content)功能,它也是HTTP標准的一部分,HTTP響應碼為206。

 

1 用途

適用於音視頻文件加載。網頁上的音頻或視頻若采用普通的加載方式,則每次訪問都會返回整個文件,既耗內存又耗帶寬,更不好的是點擊進度條時沒反應(總是重頭開始)。此時若使用斷點下載,則進度條功能可生效,且會按需去加載需要的文件片段。在拖動進度條時若數據已緩沖完成則不會發請求,否則會再發partial請求。

適用於斷點下載。若服務端支持斷點下載,則即使在文件下載過程中因網絡等問題中斷了,客戶端仍可在網絡恢復后緊接之前的下載進度下載剩余內容。

2 原理

利用請求頭和響應頭

Range:請求頭,表示期望的下載范圍,值的格式為"bytes=范圍或范圍列表"。如:"1-2"、"3-"、"-3"、"1-2,3-4"、"1-2,3-"、"1-2,-3",閉區間、至少須有一個范圍、允許指定多個范圍、左右邊界未成對出現的范圍最多只能有一個且只能在末尾

If-Range:請求頭,作用與If-None-MatchIf-Modified-Since一樣,服務端據此判斷客戶端要請求的文件在服務端是否發生了變化,若發現發生了變化則返回新整個文件,否則進行返回相應范圍的文件內容。實踐發現瀏覽器並不會自動帶該請求頭,故不用該請求頭,而是在響應頭寫EtagLast-Modified,可參閱 HTTP緩存-判斷資源是否發生改變-marchon

Accept-Ranges:響應頭,標識數據的單位,通常為"bytes"

Content-Range:響應頭,表示響應的數據范圍,與Range對應。值示例:"bytes 98304-4715963/4715964" ,三個數字分別為范圍 起、止、文件總大小

 

請求頭何時帶?瀏覽器默認對視、音頻(audio、video標簽里的資源)才會帶range頭,圖片等不會帶。

 

3 實踐

3.1 交互流程

客戶端:瀏覽器(或其他HTTP Client)發送請求,通過請求頭 Range指定期望的文件范圍,如Range: bytes=0-20 ; 此外,最好也帶上Etag以免文件發生了變化卻仍返回所要的范圍的文件內容。

服務端:

服務端若發現請求中 沒有Range頭 或 通過Etag頭對比發現資源發生了變化 則直接返回整個文件,HTTP響應碼為200

否則,從Range中提取出范圍。若范圍合法(不超越文件總大小、非負等)則把對應范圍的文件內容返回給客戶端;否則返回HTTP響應碼416,表示范圍不合法。

3.2 代碼示例

  1 /** in、out由調用者負責關閉 */
  2     private void downloadWithResum(InputStream in, OutputStream out, long fileTotalLength, String newEtagStr)
  3             throws Exception {
  4         // 借助Etag判斷斷點續傳前后資源是否發生變化
  5         String oldEtag = request.getHeader(HttpHeaders.IF_NONE_MATCH);
  6         response.setHeader(HttpHeaders.ETAG, newEtagStr);
  7 
  8         String rangeHeaderVal = request.getHeader(HttpHeaders.RANGE);
  9         // 不啟用斷點續傳 或 啟用了但沒有Range頭 或 啟用了但是資源發生了變化,則直接下載完整數據
 10         if (!resumeDownloadEnabled || null == rangeHeaderVal || (null != oldEtag && !newEtagStr.equals(oldEtag))) {
 11             {
 12                 response.setStatus(HttpServletResponse.SC_OK);
 13                 response.setContentLengthLong(fileTotalLength);
 14 
 15                 // buffer write背后的實現就是循環調單字節的write、buffer read同理。所以用buffer 讀寫的意義是?
 16                 byte[] buffer = new byte[20 * 1024];
 17                 int length = 0;
 18                 while ((length = in.read(buffer)) != -1) {
 19                     out.write(buffer, 0, length);
 20                 }
 21             }
 22         }
 23         // 斷點續傳,見https://tools.ietf.org/html/rfc7233、https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Range_requests
 24         else {
 25 
 26             // 有傳范圍,開始解析請求的范圍。請求范圍格式:bytes= 范圍或范圍列表
 27             // bytes后的范圍示例:"1-2"、"3-"、"-3"、"1-2,3-4"、"1-2,3-"、"1-2,-3"。至少須有一個范圍;允許指定多個范圍;左右邊界未成對出現的范圍最多只能有一個且只能在末尾
 28             // 相應的pattern正則為 ^bytes=(?=[-0-9])(,?(\d+)-(\d+))*?(,?(\d+)-|,?-(\d+))?$
 29             // 第二個問號表示惰性匹配、其他問號表示元素(逗號或區間)為0或1個;第一個斷言用於防止""被當成合法范圍
 30             String rangeHeaderValPatternStr = "^bytes=(?=[-0-9])(,?(\\d+)-(\\d+))*?(,?(\\d+)-|,?-(\\d+))?$";
 31             Matcher m = Pattern.compile(rangeHeaderValPatternStr).matcher(rangeHeaderVal);
 32             if (!m.matches()) {// 不符合范圍或范圍列表格式,結束
 33                 response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
 34                 return;
 35             }
 36 
 37             // 以下表示所傳范圍或范圍列表符合格式,故開始處理每個范圍段
 38             String rangeSegmentPatternStr = "((\\d+)-(\\d+))|(\\d+)-|-(\\d+)";// 與上面的rangeHeaderValPatternStr對應,獲取其中的每個范圍
 39             m = Pattern.compile(rangeSegmentPatternStr).matcher(rangeHeaderVal);
 40             List<Long[]> rangeSegmengs = new ArrayList<>();// 每個元素為包含兩個元素的數組,分別為起、止位置
 41             while (m.find()) {
 42                 long startBytePos = -1, endBytePos = -1;
 43                 if (m.group(1) != null) {// 類似"1-2"這種范圍
 44                     startBytePos = Long.parseLong(m.group(2));
 45                     endBytePos = Long.parseLong(m.group(3));
 46                 } else if (m.group(4) != null) {// 類似"3-"這種范圍
 47                     startBytePos = Long.parseLong(m.group(4));
 48                     endBytePos = fileTotalLength - 1;
 49                 } else if (m.group(5) != null) {// 類似"-3"這種范圍
 50                     startBytePos = fileTotalLength - Long.parseLong(m.group(5));
 51                     endBytePos = fileTotalLength - 1;
 52                 }
 53 
 54                 // 范圍越界
 55                 if (startBytePos > endBytePos || startBytePos < 0 || endBytePos >= fileTotalLength) {
 56                     response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
 57                     return;
 58                 } else {
 59                     rangeSegmengs.add(new Long[] { startBytePos, endBytePos });
 60                 }
 61             }
 62 
 63             // 以下表示各范圍均合法,故先進行區間合並再對根據合並后的各區間下載文件 TODO 改為借助本地文件緩存,避免每次訪問遠程文件
 64             mergeOverlapRange(rangeSegmengs);
 65             if (rangeSegmengs.size() == 0) {
 66                 return;
 67             }
 68 
 69             // 瀏覽器貌似不支持multipart/byteranges,故傳多范圍時只考慮最后一個范圍
 70             long startBytePos = rangeSegmengs.get(rangeSegmengs.size() - 1)[0];
 71             long endBytePos = rangeSegmengs.get(rangeSegmengs.size() - 1)[1];
 72 
 73             response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
 74             response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
 75             response.setContentLengthLong(endBytePos - startBytePos + 1);
 76             response.setHeader(HttpHeaders.CONTENT_RANGE,
 77                     String.format("bytes %s-%s/%s", startBytePos, endBytePos, fileTotalLength));
 78 
 79             // 略過不要的內容
 80             in.skip(startBytePos);
 81             // 返回目標內容
 82             try {
 83                 byte[] buffer = new byte[20 * 1024];
 84                 int bfNextPosIndex = 0;
 85                 for (long i = startBytePos; i <= endBytePos; i++) {
 86                     if (bfNextPosIndex == buffer.length) {
 87                         out.write(buffer, 0, buffer.length);
 88                         bfNextPosIndex = 0;
 89                     }
 90 
 91                     buffer[bfNextPosIndex++] = (byte) in.read();
 92 
 93                 }
 94                 out.write(buffer, 0, bfNextPosIndex);
 95             } catch (IOException e) {
 96                 // 瀏覽器加載音視頻時,為獲取總數據大小,第一次會發"bytes=0-"的請求且收到響應頭后立馬關閉連接,導致服務端寫數據出現Broken
 97                 // pipe,故忽略之,其他拋到上層
 98                 if ("Broken pipe".equals(e.getMessage())) {
 99                     log.error("'Broken pipe' when writing partial content to OutputStream");
100                 } else {
101                     log.error(e.getMessage(), e);
102                 }
103             }
104 
105         }
106     }
107 
108     /** 區間合並的算法 */
109     private List<Long[]> mergeOverlapRange(List<Long[]> ranges) {
110         if (null == ranges || ranges.size() == 0) {
111             return null;
112         }
113         // 區間按左值排序
114         ranges = ranges.stream().sorted((range1, range2) -> (int) (range1[0] - range2[0])).collect(Collectors.toList());
115         // 遍歷並合並區間
116         for (int i = 1; i < ranges.size(); i++) {
117             Long[] curRange = ranges.get(i);
118             Long[] preRange = ranges.get(i - 1);
119             // 說明有交集,則更新前區間的右值並移除當前區間
120             if (curRange[0] <= preRange[1]) {
121                 if (preRange[1] < curRange[1]) {
122                     preRange[1] = curRange[1];
123                 }
124                 ranges.remove(i);
125                 i--;
126             }
127         }
128         return ranges;
129 
130     }
downloadWithResum

 

3.3 趟坑

理想很豐滿,現實很骨感

瀏覽器實際工作工程:斷點下載的初衷是用於瀏覽器分片請求音視頻內容,而不用一次把整個文件下載下來。

但實踐發現瀏覽器第一次總是會請求整個文件(即Range: bytes=0-),然后才分片請求。第一次請求返回的數據瀏覽器並沒完全保存。如果查看瀏覽器的請求信息,會發現雖然response了所有數據但瀏覽器的f12 network tool 里resource size的大小遠小於返回的數據大小。

原因:為了獲得數據量大小,第一次發bytes=0-的請求,在獲得響應頭后瀏覽器立即主動關閉tcp連接;知道了總數據量后,接下來才從第一次已接收的數據開始按需分片請求剩下的部分數據。由於服務端在往客戶端寫回數據的過程中瀏覽器主動關閉了連接,故此時服務端會報Broken Pipe錯誤。參閱:https://support.google.com/chrome/thread/25510119?hl=en

 

4 參考資料

https://tools.ietf.org/html/rfc7233

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Range_requests


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM