Skip to content

HTTP 条件请求

在 HTTP 中有一个 条件请求 的概念,在这类请求中,请求的结果,甚至请求成功的状态,都会随着验证器与受影响资源的比较结果的变化而变化。这类请求可以用来验证缓存的有效性,省去不必要的控制手段,以及验证文件的完整性,例如在断点续传的场景下或者在上传或者修改服务器端的文件的时候避免更新丢失问题。

基本原理

在 HTTP 协议中,条件请求指的是请求的执行结果会因特定首部的值不同而不同。这些首部规定了请求的前置条件,请求结果则视条件匹配与否而有所不同。

请求引发的不同的反应取决于请求所使用的方法,以及组成前置条件首部集合:

  • 对于安全方法来说,例如 GET,通常用来获取文件,条件请求可以被用来限定仅在满足条件的情况下返回文件。这样可以节省带宽。
  • 对于非安全方法来说,例如 PUT 方法,通常用来上传文件,条件请求可以被用来限定仅在满足文件的初始版本与服务器上的版本相同的条件下才会将其上传。

验证器

所有的条件请求首部都是试图去检测服务器上存储的资源是否与某一特定版本相匹配。为了达到这个目的,条件请求需要指明资源的版本。由于逐个字节去比较完整资源是不切实际的,况且这也并非总是想要的结果,所以在请求中会传递一个描述资源版本的值。这些值称为“验证器”,并且分为两大类:

  • 文件的最后修改时间,即 last-modified(最后修改)时间。
  • 一个意义模糊的字符串,指代一个独一无二的版本,称为“实体标签”,或者 etag。

比较同一份资源的不同版本有一定的技巧性:取决于上下文环境的不同,有两种不同的等值检查(equality checks)类型:

  • 强验证类型(Strong validation)应用于需要逐个字节相对应的情况,例如需要进行断点续传的时候。
  • 弱验证类型(Weak validation)应用于用户代理只需要确认资源内容相同即可。即便是有细微差别也可以接受,比如显示的广告不同,或者是页脚的时间不同。

验证类型与验证器的类型是相互独立的。 Last-Modified 和 ETag 首部均可应用于两种验证类型,尽管在服务器端实现的复杂程度可能会有所不同。HTTP 协议默认使用强验证类型,可以指定何时使用弱验证类型。

强验证类型

强验证类型的作用在于确保要比较的资源与其相比较的对象之间每一个字节都相同。对于有些首部来说需要明确指定该验证类型,而对于另外一些来说则是默认值就是强验证类型。强验证类型的要求相当严格,在服务器层面来说可能较难保证。但是它确保了数据在任何时候都没有缺损,有时候则需要以牺牲性能为代价。

使用 Last-Modified 首部很难为强验证类型提供一个唯一标识。通常这是由 ETag 首部来完成的,该首部可以提供使用 MD5 算法获取的资源(或其衍生品)的散列值。

弱验证类型

弱验证类型与强验证类型不同,因为它会把内容相同的两份文件看做是一样的。例如,使用弱验证类型,一个页面与另外一个页面只是在页脚显示的时间上有所不同,或者是展示的广告不相同,那么就会被认为是相同的。但是在使用强验证的情况下,二者是不同的。构建应用于弱验证类型的标签(etag)体系可能会比较复杂,因为这会涉及到对页面上不同的元素的重要性进行排序,但是会对缓存性能优化相当有帮助。

条件首部

一些被称为条件首部的 HTTP 首部,可以引发条件请求。它们是:

If-Match 如果远端资源的实体标签与在 ETag 这个首部中列出的值相同的话,表示条件匹配成功。默认地,除非实体标签带有 'W/' 前缀,否者它将会执行强验证。

If-None-Match 如果远端资源的实体标签与在 ETag 这个首部中列出的值都不相同的话,表示条件匹配成功。默认地,除非实体标签带有 'W/' 前缀,否者它将会执行强验证。

If-Modified-Since 如果远端资源的 Last-Modified 首部标识的日期比在该首部中列出的值要更晚,表示条件匹配成功。

If-Unmodified-Since 如果远端资源的 HTTPHeader("Last-Modified")}} 首部标识的日期比在该首部中列出的值要更早或相同,表示条件匹配成功。

If-Range 与 If-Match 或 If-Unmodified-Since 相似,但是只能含有一个实体标签或者日期值。如果匹配失败,则条件请求宣告失败,此时将不会返回 206 Partial Content 响应码,而是返回 200 OK 响应码,以及完整的资源。

应用场景

缓存更新

条件式请求最常见的应用场景是更新缓存。假如缓存为空,或者是没有缓存的话,被请求资源会以状态码 200 OK 返回。

当缓存为空时发出的请求会触发资源下载,并将两个验证器值作为标头发送。然后缓存会被填充。

验证器会同资源一起返回,它们出现在首部字段中。在这个例子中, Last-Modified 与 ETag 都被返回,不过如果只返回其中的一个也是可以的。这些验证器会同资源一起被缓存起来(与所有的首部一样),并在在缓存失效的时候用来发起条件式请求。

只要缓存未失效,就不会发起任何请求。但是一旦失效——主要是由 Cache-Control 首部控制——客户端就不会采用缓存值而是发起条件式请求。验证器的值会用作 If-Modified-Since 和 If-None-Match 首部字段的参数。

假如资源未发生变化,服务器就返回状态码为 304 Not Modified 的响应。这样相当于对缓存资源进行了刷新,而客户端则采用被缓存的资源。尽管这里有一次请求/响应往返会消耗一定的资源,但是这样做比将整个资源通过网络再传输一遍更高效。

对于过期的缓存,将发送条件请求。服务器可以确定资源是否发生了变化,在本例中,因为资源相同,所以服务器决定不再发送资源。

假如资源发生了变化,服务器就直接返回 200 OK 响应码,连同新版本的资源,就像是没有应用条件式请求一样;客户端则采用新版本资源(并将其缓存起来)。

如果资源发生了变化,则会像非条件请求那样返回资源。

除了需要在服务器端对验证器进行设置以外,该机制是透明的:所有的浏览器都会对缓存资源进行管理,在不需要 Web 开发者进行任何特殊处理的情况下发送条件式请求。

增量下载的完整性

文件的增量下载是 HTTP 协议规定的一项功能,它允许恢复先前的操作,通过保存先前已经获得的信息来节省带宽和时间:

下载已停止且只检索来部分内容。

支持增量下载的服务器会通过 Accept-Ranges 首部来广播这项能力。此后客户端就可以通过发送 Ranges 首部字段以及缺失的范围值来进行断点续传了:

客户端通过指定范围值并检查部分获取请求的验证器来恢复请求。

基本原理很简单,但是这里有一个潜在的问题:如果要下载的资源在两次下载之间进行了修改,得到的数据范围就会对应该资源的两个不同的版本,那么最终获得的文件是损坏的。

为了防止这种情况的发生,需要使用条件式请求。对于范围请求来说,有两种方法可以实现这个目的。更灵活一些的方法是使用 If-Modified-Since 和 If-Match 首部,假如前置条件失败,服务器端会返回错误提示,然后客户端可以从头开始重新下载资源:

当部分下载的资源被修改时,前置条件失败,将必须重新下载完整的资源。

尽管这种方法行得通,但是它在文件发生变化的情况下增加了一次额外的请求/响应往返。这一点会影响性能。为此 HTTP 协议规定了一个特定的首部——If-Range——来避免这种情况的发生:

If-Range 标头允许服务器在资源被修改时直接发送完整资源,而不需要发送 412 错误并等待客户端重新开始下载。

该方法更高效,但是缺乏一定的灵活性,因为条件值只能是实体标签。不过这种额外的灵活性很少会需要。

使用乐观锁避免更新丢失问题

对于 Web 应用的一项常见操作是远程更新文件。这在各种类型的文件系统以及版本控制软件中都很常见,但是任何允许远程存储资源的软件也都需要这样一个乐观锁机制。常见的 Web 站点,例如 wiki 系统或其他类型的内容管理系统(CMS),都存在这样的需求。

使用 PUT 方法可以实现上述机制。客户端首先读取原始文件,然后进行修改,最后将它们推送到服务器上:

当不涉及并发时,使用 PUT 更新文件非常简单。

不幸的是,当把并发情况考虑在内的时候,事情变得有些不那么确定。当一个客户端在本地修改它新获得的资源副本的时候,另外一个客户端同样可以获取一份资源副本并进行同样的操作。接下来要发生的事情就不那么顺利了:当二者提交回服务器的时候,前一个客户端作出的修改会被第二个客户端的推送所覆盖,因为第二个客户端对于第一个客户端做出的修改一无所知。最终的结果则取决于获胜的一方,但是该结果不会通知给另一方。哪一个客户端作出的修改将会被保存下来,会由于它们提交的速度而有所不同;提交的速度则依赖于客户端及服务器端的性能,甚至是使用客户端进行编辑的人的表现。每一次的胜出者都会有所不同。这种情况被称作 竞态条件,会导致难以捉摸的情况的发生,并且难以探测和除错:

当多个客户端并行更新同一资源时,我们就会面临竞态条件:最慢的胜出,而其他人甚至不知道他们输了。有问题!

不存在解决这一问题而不打扰双方某一方的办法。然而,更新丢失问题以及竞态条件是需要避免的。我们希望获得可预测的结果,并且希望在更新操作被拒绝的时候客户端可以得到反馈。

条件式请求可以被用在乐观锁算法(大多数 wiki 系统和版本管理系统采用的是该算法)的实现上。其思路是,允许所有的客户端获得资源的副本,然后在本地进行编辑,通过只允许第一个客户端成功提交的方式来控制并发操作。其余的基于现今已过期版本的更新操作都会被拒绝:

条件请求允许实现乐观锁:现在最快的胜出,而其他人会收到错误。

这种方式的实现需要用到 If-Match 或 If-Unmodified-Since 首部。假如实体标签与源头文件的实体标签不一致,或者源头文件在被获取副本之后经过了修改,那么此次变更请求就会被拒绝,收到 412 Precondition Failed 的错误提示。之后就需要依靠客户端来处理该错误了:或者通知用户重新开始(基于最新的版本),或者是给用户展示两个版本之间的差异,辅助他们决定要保留哪些变更。

处理资源的首次上传问题

资源的首次上传问题是前面所描述的情况的一个极端情况。与任何资源更新问题一样,当两个客户端在大致相同的时间进行上传操作的时候,就会遇到竞态条件。为了防止这种情况的发生,可以使用条件式请求:添加 If-None-Match 首部,并将其值设置为 '*', 表示任意实体标签。当且仅当资源先前并不存在的情况下请求的操作才会成功执行:

与常规的上传类似,资源的首次上传也会遇到竞态条件:If-None-Match 可以防止这种情况的发生。

If-None-Match 首部只可应用于兼容 HTTP/1.1(及后续版本)的服务器。假如不确定所访问的服务器是否兼容,需要首先向要访问的资源发送一次 HEAD 请求来进行确认。

总结

条件式请求是 HTTP 协议中一项非常重要的特性,它使高效复杂的应用系统的构建得以实现。对于缓存或断点续传功能来说,站点管理员只需要正确配置服务器就可以了;在某些环境中设置正确的实体标签可能需要一些技巧。但一旦设置成功,浏览器就能够按照预期地发送条件式请求。

而对于锁机制,则恰恰相反:Web 开发者需要使用合适的消息首部生成请求,另一方面,站点管理员在大多时候可以依赖应用来检查这些首部信息。

显然在两种情况下,条件式请求都发挥着基础性作用,成为 Web 应用的有力支撑。

HTTP 范围请求

HTTP 的 Range 请求使客户端能够要求服务器仅向其回传 HTTP 消息的一部分。范围请求对于支持随机访问的媒体播放器、明确只需大型文件某部分的数据处理工具,以及允许用户暂停及恢复下载的下载管理器等客户端尤其有用。

检测服务器端是否支持范围请求

如果 HTTP 响应中存在 Accept-Ranges 标头,并且其值不是“none”,那么该服务器支持范围请求。你可以通过使用像 cURL 这样的工具发出一个 HEAD 请求来进行手动检查。

curl -I http://i.imgur.com/z4d4kWk.jpg
HTTP/1.1 200 OK

Accept-Ranges: bytes
Content-Length: 146515

在此响应中,Accept-Ranges: bytes 表示可以使用字节作为单位来定义范围。在此处的 Content-Length 标头也很有用,因为它指示了要检索的图像的完整大小。

如果网站省略了 Accept-Ranges 标头,那么它们很可能不支持部分请求。有些网站会包含这个标头,但明确将其值设为“none”,以表明它们不支持这一特性:

curl -I https://www.youtube.com/watch?v=EwTZ2xpQwpA
HTTP/1.1 200 OK

Accept-Ranges: none

在这种情况下,下载管理器可能会禁用暂停按钮。

从服务器端请求特定的范围

如果服务器支持范围请求,那么只需在 HTTP 请求中包含 Range 标头,你可以指定希望服务器返回文档的哪一部分或哪些部分。

单一范围

我们能够向资源请求单一的数据范围。同样,我们可以通过使用 cURL 来测试请求。这里的“-H”选项会向请求中添加一个标头,即 Range 标头,用于请求前 1024 个字节。

curl http://i.imgur.com/z4d4kWk.jpg -i -H "Range: bytes=0-1023"

发出的请求像是这样:

GET /z4d4kWk.jpg HTTP/1.1
Host: i.imgur.com
Range: bytes=0-1023

服务器将会返回 206 Partial Content 状态;

HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/146515
Content-Length: 1024

(二进制内容)

Content-Length 标头现在表示所请求范围的大小(而非图像的完整大小)。Content-Range 响应标头则表示此部分消息在完整资源中的位置。

多重范围

Range 标头还允许在文档中支持一次性获取多重范围。这些范围使用逗号分隔。

curl http://www.example.com -i -H "Range: bytes=0-50, 100-150"

服务器以 206 Partial Content 状态码以及 Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5 标头响应,表明随后的数据将采用多部分字节范围格式。每个部分都携带自己的 Content-Type 和 Content-Range 字段,而必须的 boundary 参数定义了用于分割每个消息体部分的边界字符串。

HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5
Content-Length: 282

--3d6b6a416f9b5
Content-Type: text/html
Content-Range: bytes 0-50/1270

<!DOCTYPE html>
<html lang="en-US">
<head>
    <title>Example Do
--3d6b6a416f9b5
Content-Type: text/html
Content-Range: bytes 100-150/1270

eta http-equiv="Content-type" content="text/html; c
--3d6b6a416f9b5--

条件式范围请求

当重新开始请求资源的更多部分时,你需要保证存储资源在收到上一个片段后未被修改。

If-Range HTTP 请求标头使范围请求具有条件性:如果条件兑现,则会发起范围请求,服务器将以包含适当内容主体的 206 Partial Content 响应进行回复。如果条件不兑现,会被发送完整的资源回来,并带有 200 OK 状态。该标头可与 Last-Modified 验证器或 ETag 一起使用,但不能同时使用。

If-Range: Wed, 21 Oct 2015 07:28:00 GMT

范围请求的响应

与范围请求相关的有三种状态:

  • 范围请求成功后,服务器会发出 206 Partial Content 状态。
  • 超出范围的范围请求将导致 416 Requested Range Not Satisfiable 状态,这意味着没有任何范围值与资源的范围重叠。例如,每个范围的第一个字节位置可能大于资源的长度。
  • 如果不支持范围请求,则返回 200 OK 状态,并传输完整的响应体。

与分块 Transfer-Encoding 的对比

Transfer-Encoding 标头支持分块编码,这对于向客户端发送大量数据特别有用,尤其是在完全处理请求之前无法知道响应的总大小的情况下。服务器直接向客户端发送数据,无需确定缓冲响应或确切长度,从而减少延迟提高响应速度。范围请求和分块传输是兼容的,可以独立使用或结合使用。

参见

评论区

欢迎留言、补充或勘误。

xiaoba.blog