图解:HTTP 范围请求,助力断点续传,多线程下载的核心原理 | 实用HTTP

一、序

Hi,大家好,我是承香墨影!

HTTP 协议在网络知识中占据了重要的地位,HTTP 协议最基础的就是请求和响应的报文,而报文又是由报文头(Header)和实体组成。大多数 HTTP
协议的使用方式,都是依赖设置不同的 HTTP 请求/响应 的 Header 来实现的。

本系列《实用 HTTP》就抛开常规的 Header 讲解式的表述方式,从实际问题出发,来分析这些 HTTP
协议的使用方式,到底是为了解决什么问题?同时讲解它是如何设计的和它实现原理。

HTTP
协议是一种无状态的“松散协议”,它不会记录不同请求的状态,并且因为它本身包含了两端(客户端和服务端),根据请求和响应来区分,它大部分的内容都只是一个建议,其实双边是可以不遵守此建议的。

“这里写了建议零售价 2 元…”

“哦,不接受建议!”

文本是本系列的第五篇,前四篇传送门:

今天再来介绍一下 HTTP 的范围请求。范围请求主要是针对较大的文件的请求或者上传,可以仅操作它的某一段。

一个比较常见的场景,就是断点续传/下载,在网络情况不好的时候,可以在断开连接以后,仅继续获取部分内容。例如在网上下载软件,已经下载了 95%
了,此时网络断了,如果不支持范围请求,那就只有被迫重头开始下载。但是如果有范围请求的加持,就只需要下载最后 5% 的资源,避免重新下载。

另一个场景就是多线程下载,对大型文件,开启多个线程,每个线程下载其中的某一段,最后下载完成之后,在本地拼接成一个完整的文件,可以更有效的利用资源。

这算是两个比较常见的场景,接下来我们来看看范围请求的 HTTP 协议支持的技术细节。

二、HTTP 的范围请求

2.1 是否支持范围请求

HTTP 本身是一种无状态的“松散”协议,而在经历了很多版本的迭代之后,只在 HTTP/1.1(RFC2616)
之上,才支持范围请求。所以如果客户端或者服务端两端的某一端低于 HTTP/1.1,我们就不应该使用范围请求的功能。

而在 HTTP/1.1 中,很明确的声明了一个响应头部 Access-Ranges 来标记是否支持范围请求,它只有一个可选参数 bytes。

例如这里给了一个 MP4 的响应头,可以看到它是有 Accept-Ranges:bytes 来标记的,有此标记标识当前资源支持范围请求。

2.2 使用范围请求

如果已经确定双端都支持范围请求,我们就可以在请求资源的时候使用它。

所有的文件最终都是存储在磁盘或者内存中的字节,对于待操作的文件可以将其以字节为单位分割。这样只需要 HTTP 支持请求该文件从 n 到 n+x
这个范围内的资源,就可以实现范围请求了。

HTTP/1.1 中定义了一个 Ranges 的请求头,来指定请求实体的范围。它的范围取值是在 0 - Content-Length 之间,使用 -
分割。。

例如已经下载了 1000 bytes 的资源内容,想接着继续下载之后的资源内容,只要在 HTTP 请求头部,增加 Ranges:bytes=1000-
就可以了。

Range 还有几种不同的方式来限定范围,可以根据需要灵活定制:

  1. 500-1000:指定开始和结束的范围,一般用于多线程下载。

  2. 500- :指定开始区间,一直传递到结束。这个就比较适用于断点续传、或者在线播放等等。

  3. -500:无开始区间,只意思是需要最后 500 bytes 的内容实体。

  4. 100-300,1000-3000:指定多个范围,这种方式使用的场景很少,了解一下就好了。

HTTP 协议是一种双边协商的协议,既然请求头部已经确定是使用 Ranges 了,还有响应头部中,也需要使用 Content-Ragne
这个响应头来标记响应的实体内容范围。

Content-Range 的格式也很清晰,首先标记它的单位是 bytes 然后标记当前传递的内容实体范围和总长度。

Content-Range: bytes 100-999/1000

在这个例子中,会传递 100 ~ 999 范围的内容实体,而该资源文件的总大小是 1000 bytes。并且此时的 HTTP 响应状态码为 206
Partial Content 。

HTTP 206 Partial Content 成功状态响应代码表示请求已成功,并且主体包含所请求的数据区间,该数据区间是在请求的 Range
首部指定的。

有关 206 状态码的解释可以参考:https://developer.mozilla.org/zh-
CN/docs/Web/HTTP/Status/206

所以一个正常的流程应该如下图所示:

注意这里的每个 HTTP 事务中的响应头里,都是会包含 Content-Length
的,只是它包含的是当前范围请求响应的内容实体长度,而非此资源完整的长度。

到这里基本上算是讲清楚 HTTP 范围请求的正确流程了,接下来看看一些特殊的情况。

2.3 资源变化

当我们在一些下载工具中,下载大尺寸资源的时候,偶尔中间暂停过再重新下载,可能会遇见它又重头开始下载的情况。

这看似是 HTTP 的范围请求失效了,但是实际上并不一定如此,很可能是因为请求的资源,在请求的这个过程中,发生了改变。

假如你下载的过程中,下载的源资源文件发生了变化,但是 URL
没有改变,此时文件长度可能已经变化了(这是非常容易发现的),极端情况下就算没有长度没有变化,你再继续下载,很可能最终下载完成之后,无法将下载的内容拼接成我们需要的文件。

如果我们需要从服务器上下载某个资源,一定要预防此资源可能发生的变动。在之前讲 HTTP
缓存
的时候讲到,在
HTTP 协议中,可以通过 ETag 或者 Last-Modified 来标识当前资源是否变化。

  • ETag:当前文件的一个验证令牌指纹,用于标识文件的唯一性。

  • Last-Modified:标记当前文件最后被修改的时间。

在 HTTP 的范围请求中,也可以使用这两个字段来区分分段请求的资源,是否有修改过,只需要在请求头中,将它放在 If-Range 这个请求报文头中即可
。If-Range 使用 ETag 或者 Last-Modified 两个参数任意一个,原样填入即可。

此时,如果两次操作的都是同一个资源文件,就会继续返回 206 状态码,开始后续的操作,反之则会返回 200 状态码,表示文件发生改变,要从头下载。

需要注意的是 If-Range 需要和 Range 配合起来使用,否则会被服务端忽略。

再额外提一点,如果客户端请求报文头中,对 Range 填入的范围错误,会返回 416 状态码。

HTTP 416 Range Not Satisfiable
错误状态码意味着服务器无法处理所请求的数据区间。最常见的情况是所请求的数据区间不在文件范围之内,也就是说,Range
首部的值,虽然从语法上来说是没问题的,但是从语义上来说却没有意义。

有关 416 状态码,可以参考:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/416

三、范围请求的例子

3.1 用 Chrome 播放一个适配

前面介绍的概念,很多技术点其实描述的都是某一个请求片段,接下来我们以一个实际的例子来说明范围请求的具体细节。

在这个例子中,我找了一个视频的播放地址,直接在 Chrome 中进行播放。正常播放之后,再随手拖动视频进度,之后无操作让其自动播放一段时间,来看看 HTTP
的事务报文。

简单描述一下情况,自然播放的时候,会首先想资源的 URL 发送请求,返回 200 的响应码,可以判断出当前资源支持 Accept-
Ranges,接下来会去使用 Range 发送范围请求,得到的响应码就是
206,并返回对应范围的实体内容。而在每次拖动进度的时候,都会去重新发送一个范围请求,依照拖动的进度来计算请求范围。此处不存在资源被修改的情况,所以不会出现重新请求下载的情况。

就不一个一个对 HTTP 事务截图了,大概抽象了一下流程,如下图所示:

可以看到,一次资源下载其实包含了很多次的请求过程,我们需要站在全局的角度来看到它。

四、范围请求小结

到这里我们就已经把 HTTP 范围请求的整个流程都说明清楚了。

再重新整理一下关键点:

  1. HTTP 范围请求,需要 HTTP/1.1 及之上支持,如果双端某一段低于此版本,则认为不支持。

  2. 通过响应头中的 Accept-Ranges 来确定是否支持范围请求。

  3. 通过在请求头中添加 Range 这个请求头,来指定请求的内容实体的字节范围。

  4. 在响应头中,通过 Content-Range 来标识当前返回的内容实体范围,并使用 Content-Length 来标识当前返回的内容实体范围长度。

  5. 在请求过程中,可以通过 If-Range 来区分资源文件是否变动,它的值来自 ETag 或者 Last-Modifled。如果资源文件有改动,会重新走下载流程。

再配一张流程图,就更清晰了。

到此 HTTP 范围请求的所有关键技术点,就已经讲解清楚。范围请求被用在诸如:断点续传、多线程下载等场景下,大部分 CDN
上的资源都是支持范围请求的,具体你能在什么场景下应用,就看你的想象力了。

HTTP 传输编码增大了传输量,只为解决这一个问题 | 实用 HTTP

Hi,大家好,我是承香墨影!

HTTP 协议在网络知识中占据了重要的地位,HTTP 协议最基础的就是请求和响应的报文,而报文又是由报文头(Header)和实体组成。大多数 HTTP
协议的使用方式,都是依赖设置不同的 HTTP 请求/响应 的 Header 来实现的。

本系列《实用 HTTP》就抛开常规的 Header 讲解式的表述方式,从实际问题出发,来分析这些 HTTP
协议的使用方式,到底是为了解决什么问题?同时讲解它是如何设计的和它实现原理。

HTTP
协议是一种无状态的“松散协议”,它不会记录不同请求的状态,并且因为它本身包含了两端(客户端和服务端),根据请求和响应来区分,它大部分的内容都只是一个建议,其实双边是可以不遵守此建议的。

“这里写了建议零售价 2 元…”

“哦,不接受建议!”

前两篇文章中,我们分别聊了 HTTP
的缓存机制

HTTP
内容实体编码压缩机制

,在说到实体编码压缩的时候,还提到了一个传输编码,让我们优化传输的方式。实体编码和传输编码二者是相辅相成的,一般我们会配合使用。

本文就来聊聊 HTTP 的传输编码机制。

二、HTTP 的传输编码

2.1 什么是传输编码?

传输编码在 HTTP 的报文头中,使用 Transfer-Encoding 首部进行标记,它就是指明当前使用的传输编码。

Transfer-Encoding
会改变报文的格式和传输的方式,使用它不但不会减少内容传输的大小,甚至还有可能会使传输变大,看似是一个不环保的做法,但是其实是为了解决一些特殊问题。

简单来说,传输编码必须配合持久连接去使用,为了在一个持久连接中,将数据分块传输,并标记传输结束而设计的,后面会详细讲解。

在早年间的设计里,和内容编码使用 Accept-Encoding 来标记客户端接收的压缩编码类型一样,传输编码还需要配合 TE
这个请求报文头来使用,用于指定支持的传输编码。但是在最新的 HTTP/1.1
协议规范中,只定义了一种传输编码:分块编码(chunked),所以并不需要再依赖 TE 这个头部。

这些细节,后面都会讲到。既然传输编码和持久连接是息息相关的,那我们就先来了解一下什么是持久连接。

2.2 持久连接(Persistent Connection)

持久连接通俗来讲,就是长连接,英文叫 Persistent Connection,其实按字面意思理解就好了。

在早期的 HTTP
协议中,传输数据的顺序大致分为发起请求、建立连接、传输数据、关闭连接等步骤,而持久连接,就是去掉关闭连接这个步骤,让客户端和服务端可以继续通过此次连接传输内容。

这其实也是为了提高传输效率,我们知道 HTTP 协议是建立在 TCP 协议之上的,自然有 TCP
一样的三次握手、慢启动等特性,这样每一次连接其实都是一次宝贵的资源。为了尽可能的提高 HTTP 的性能,使用持久连接就显得很重要了。为此在 HTTP
协议中,就引入了相关的机制。

在早期的 HTTP/1.0 协议中并没有持久连接,持久连接的概念是在后期才引入的,当时是通过 Connection:Keep-Alive
这个头部来标记实现,用于通知客户端或服务端相对的另一端,在发送完数据之后,不要断开 TCP 连接,之后还需要再次使用。

而在 HTTP/1.1 协议中,发现持久连接的重要性了,它规定所有的连接必须都是持久的,除非显式的在报文头里,通过 Connection:close
这个首部,指定在传输结束之后会关闭此连接。

实际上在 HTTP/1.1 中Connect 这个头部已经没有 Keep-Alive 这个取值了,由于历史原因,很多客户端和服务端,依然保留了这个报文头。

长连接带来了另外一个问题,如何判定当前数据发送完成。

2.3 判断传输完成

在早期不支持持久连接的时候,其实是可以依靠连接断开来判定当前传输已经结束,大部分浏览器也是这么干的,但这并不是规范的操作。应该使用 Content-
Length 这个头部,来指定当前传输的实体内容长度。

下面举个例子,在保持持久连接的情况下,依赖 Content-Length 来确定数据发送完毕。

Content-Length 在这里起到了一个响应实体已经发送结束的判断依据。这样的情况下,我们就要求 Content-Length
必须和内容实体的长度一致,如果不一致,就会出现各种问题。

如上图所示,如果 Content-Length 小于内容实体的长度,则会截断,反之则无法判定当前响应已经结束,会将请求持续挂起造成 Padding 状态。

理想情况下,我们在响应一个请求的时候,就需要知道它的内容实体的大小。但是在实际应用中,有些时候内容实体的长度并没有那么容易获得。例如内容实体来自网络文件、或者是动态生成的。这个时候如果依然想要提前获取到内容实体的长度,只能开一个足够大的
Buffer,等内容全部缓存好了再计算。

但这并不是一个好的方案,全部缓存到 Buffer 里,第一会消耗更多的内存,第二也会更耗时,让客户端等待过久。

此时就需要一个新的机制,不依赖 Content-Length 的值,来判定当前内容实体是否传输完成,此时就需要 Transfer-Encoding
这个头部来判定。

2.4 Transfer-Encoding:chunked

前面也提到,Transfer-Encoding 在最新的 HTTP/1.1 协议里,就只有 chunked 这个参数,标识当前为分块编码传输。

分块编码传输既然只有一个可选的参数,我们就只需要指定它为 Transfer-Encoding:chunked
,后续我们就可以将内容实体包装一个个块进行传输。

分块传输的规则:

  1. 每个分块包含一个 16 进制的数据长度值和真实数据。

  2. 数据长度值独占一行,和真实数据通过 CRLF(\r\n) 分割。

  3. 数据长度值,不计算真实数据末尾的 CRLF,只计算当前传输块的数据长度。

  4. 最后通过一个数据长度值为 0 的分块,来标记当前内容实体传输结束。

在这个例子中,首先在响应头部里标记了 Transfer-Encoding: chunked,后续先传递了第一个分块 “0123456780”,长度为
b(11 的十六进制),之后分别传输了 “Hello CxmyDev” 和 “123”,最后以一个长度为 0 的分块标记当前响应结束。

2.5 chunked 的拖挂

当我们使用 chunked 进行分块编码传输的时候,传输结束之后,还有机会在分块报文的末尾,再追加一段数据,此数据称为拖挂(Trailer)。

拖挂的数据,可以是服务端在末尾需要传递的数据,客户端其实是可以忽略并丢弃拖挂的内容的,这就需要双方协商好传输的内容了。

在拖挂中可以包含附带的首部字段,除了 Transfer-Encoding、Trailer 以及 Content-Length 首部之外,其他 HTTP
首部都可以作为拖挂发送。

一般我们会使用拖挂来传递一些在响应报文开始的时候,无法确定的某些值,例如:Content-MD5
首部就是一个常见的在拖挂中追加发送的首部。和长度一样,对于需要分块编码传输的内容实体,在开始响应的时候,我们也很难算出它的 MD5 值。

注意这里在头部增加了 Trailder,用以指定末尾还会传递一个 Content-MD5 的拖挂首部,如果有多个拖挂的数据,可以使用逗号进行分割。

三、内容编码和传输编码结合

内容编码和传输编码一般都是配合使用的。我们会先使用内容编码,将内容实体进行压缩,然后再通过传输编码分块发送出去。客户端接收到分块的数据,再将数据进行重新整合,还原成最初的数据。

四、传输编码小结

我们对传输编码应该有一定的了解了。这里简单总结一下:

  1. 传输编码使用 Transfer-Encoding 首部进行标记,在最新的 HTTP/1.1 协议里,它只有 chunked 这一个取值,表示分块编码。

  2. 传输编码主要是为了解决持久连接里将数据分块传输之后,判定内容实体传输结束。

  3. 分块的格式:数据长度(16进制)+ 分块数据。

  4. 如果还有额外的数据,可以在结束之后,使用 Trailer 进行拖挂传输额外的数据。

  5. 传输编码通常会配合内容编码一起使用。

此外,传输编码应该是所有 HTTP/1.1 的标准实现,应该都有支持,如果收到无法理解的经过传输编码的报文,应该直接返回 501 Unimplemented
这个状态码来回复即可。

参考连接:


Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×