服務端收到普通的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-Match或If-Modified-Since一樣,服務端據此判斷客戶端要請求的文件在服務端是否發生了變化,若發現發生了變化則返回新整個文件,否則進行返回相應范圍的文件內容。實踐發現瀏覽器並不會自動帶該請求頭,故不用該請求頭,而是在響應頭寫Etag或Last-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 }
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