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

原文地址:https://www.cnblogs.com/z-sm/p/12672126.html