服务端收到普通的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