zoukankan      html  css  js  c++  java
  • 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

  • 相关阅读:
    【转】ON_COMMAND ON_MESSAGE ON_NOTIFY区别与联系
    Eureka
    application.yml-mysql8
    sprigcloud
    springboot
    maven
    排序算法之基数排序
    排序算法之桶排序
    排序算法之计数排序
    排序算法之堆排序
  • 原文地址:https://www.cnblogs.com/z-sm/p/12672126.html
Copyright © 2011-2022 走看看