HTTP断点续传(断点下载、断点上传)协议

1 断点下载

服务端收到普通的HTTP请求时会将整个文件返回给请求者,HTTP响应码为200。对于音频、视频等多媒体文件来说,往往文件内容较大,如果每次都返回整个文件,则不论对服务端还是浏览器来说速度都很慢。此时可以采用断点下载(Partial Content)功能,它也是HTTP标准的一部分,HTTP响应码为206(正常返回时)或416(范围错误时)。

相关HTTP状态码:200、206、416

相关HTTP请求头和响应头:Range、If-Ranges、If-None-Match、If-Modified-Since;Accept-Range、Content-Range

1.1 用途

适用于音视频文件加载。网页上的音频或视频若采用普通的加载方式,则每次访问都会返回整个文件,既耗内存又耗带宽,更不好的是点击进度条时没反应(总是重头开始)。此时若使用断点下载,则进度条功能可生效,且会按需去加载需要的文件片段。在拖动进度条时若数据已缓冲完成则不会发请求,否则会再发partial请求。

适用于断点下载。若服务端支持断点下载,则即使在文件下载过程中因网络等问题中断了,客户端仍可在网络恢复后紧接之前的下载进度下载剩余内容。

1.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头,图片等不会带。

 

1.3 实践

1.3.1 交互流程

客户端:浏览器(或其他HTTP Client)发送请求,通过请求头 Range指定期望的文件范围,如Range: bytes=0-20 ; 此外,最好也带上Etag以免文件发生了变化却仍返回所要的范围的文件内容。

服务端:

服务端若发现请求中 没有Range头 或 通过Etag头对比发现资源发生了变化 则直接返回整个文件,HTTP响应码为200

否则,从Range中提取出范围。若范围合法(不超越文件总大小、非负等)则把对应范围的文件内容返回给客户端;否则返回HTTP响应码416,表示范围不合法。

1.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 

1.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

 

1.4 参考资料

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

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

 

2 断点上传

有专门的断点上传协议 tus ,基于HTTP协议。

tus is a project aiming to make resumable file uploads easily usable and widely available. The most interesting parts of this project are the protocol specification and the many freely available client and server implementations.

When we say "resumable file uploads", we refer to the ability that uploads can be interrupted at any time and afterward be resumed from the state where the failure began. This interruption can be accidentally (e.g., a connection drop or a server crash) or voluntarily if the user decides to pause the upload. In traditional uploading implementations, your progress would be lost in such a situation, but tus enables you to recover from these interruptions and continue where the upload was stopped.

On a more technical note, the tus protocol is built upon HTTP/HTTPS to make it available on every platform including browsers, desktop software, and mobile applications. Furthermore, it also allows us to build upon the massive ecosystem and best practices that HTTP provides.

详情可参阅:

tus guide:协议的详细介绍(实际上很简单,就是规定了几个http header来实现断点上传)。下面列的链接都在guide中找到或提到。

online demo:在线示例。

implementations:列举了 tus 协议的很多开源实现,包括服务的、客户端、各种编程语言下的等。java的可参阅这个github repository

 

不仅 HTTP 中的断点上传,很多CS架构中的分片上传(例如对象存储服务中的文件分块上传)也差不多的原理。 

 

总结

从实际的交互流程看,不管是断点下载还是断点上传,其背后的原理类似,都分为两个阶段的请求:

第一阶段是客户端先与服务端协商确定文件的总大小、文件名等元数据信息。

断点下载中:文件在服务端,浏览器会先发一个 Range: bytes=0- 的请求并在响应后立马关闭连接而不接收数据,从而从响应头中知道服务端文件的总大小。

断点上传中:文件在客户端,客户端通过构造一个 CreateMultipartUploadRequest 预请求来告诉服务端该文件的 名字、大小、md5、分片大小等信息。

第二阶段是分片下载或上传数据,通过约定好的请求头和响应头来告诉对方分片信息。

断点下载中:客户端每次请求时通过 Range 请求头指定此次请求的范围数据、服务端返回数据的同时还返回 Content-Range 响应头指定此次返回的数据的范围,这样客户端可得知下个范围的起点。

断点上传中:客户端每次请求时通过 分片号、分片体 来传输该次分片数据、服务端返回此次接收到的分片数据的分片号,这样客户端可得知下个分片号。

 整体原理是这样,实际上对于自己实现的断点上传(断点下载也类似,自己实现而不是直接用HTTP自身的),可能会与上述有些不同:

多一个阶段:告诉服务端已经传完,此时服务端将之前的分片合并成一个大文件并根据第一阶段的元数据进行md5校验、然后删除分片文件。当然,此阶段也可通过在第二阶段中加元数据告诉服务端是否传完成来实现。

第二阶段的不同:不同分块可并发传输、可在传输过程中发请求取消上传或下载任务、可查询已传输的分块等

 

 

 

posted @ 2020-04-10 11:14  March On  阅读(4854)  评论(0编辑  收藏  举报
top last
Welcome user from
(since 2020.6.1)