HTTP 协议详解(二)

前面一篇已经说过了 HTTP 的基本特性,HTTP 的发展史,前情回顾。这一篇就更详细的 HTTP 协议使用过程一些参数配置,缓存,Cookie设置相关的细节做一些梳理。

数据类型与编码

在 TCP/IP 协议栈里,传输数据基本上都是 header + body 的格式。但 TCP、UDP 因为是传输层的协议,它们不会关心 body 数据是什么,只要把数据发送到对方就算是完成了任务。

而 HTTP 协议则不同,它是应用层的协议,数据到达之后工作只能说是完成了一半,还必须要告诉上层应用这是什么数据才行,否则上层应用就会 不知所措 。

你可以设想一下,假如 HTTP 没有告知数据类型的功能,服务器把 一大坨 数据发给了浏览器,浏览器看到的是一个 黑盒子 ,这时候该怎么办呢?

当然,它可以 猜 。因为很多数据都是有固定格式的,所以通过检查数据的前几个字节也许就能知道这是个 GIF 图片、或者是个 MP3 音乐文件,但这种方式无疑十分低效,而且有很大几率会检查不出来文件类型。

幸运的是,早在 HTTP 协议诞生之前就已经有了针对这种问题的解决方案,不过它是用在电子邮件系统里的,让电子邮件可以发送 ASCII 码以外的任意数据,方案的名字叫做 多用途互联网邮件扩展 (Multipurpose Internet Mail Extensions),简称为 MIME。

MIME 是一个很大的标准规范,但 HTTP 只 顺手牵羊 取了其中的一部分,用来标记 body 的数据类型,这就是我们平常总能听到的 MIME type

MIME 把数据分成了八大类,每个大类下再细分出多个子类,形式是 type/subtype 的字符串,巧得很,刚好也符合了 HTTP 明文的特点,所以能够很容易地纳入 HTTP 头字段里。

这里简单列举一下在 HTTP 里经常遇到的几个类别:

  1. text:即文本格式的可读数据,我们最熟悉的应该就是 text/html 了,表示超文本文档,此外还有纯文本 text/plain、样式表 text/css 等。
  2. image:即图像文件,有 image/gifimage/jpegimage/png 等。
  3. audio/video:音频和视频数据,例如 audio/mpegvideo/mp4 等。
  4. application:数据格式不固定,可能是文本也可能是二进制,必须由上层应用程序来解释。常见的有 application/jsonapplication/javascriptapplication/pdf 等,另外,如果实在是不知道数据是什么类型,像刚才说的 黑盒 ,就会是 application/octet-stream,即不透明的二进制数据。

但仅有 MIME type 还不够,因为 HTTP 在传输时为了节约带宽,有时候还会压缩数据,为了不要让浏览器继续 猜 ,还需要有一个 Encoding type ,告诉数据是用的什么编码格式,这样对方才能正确解压缩,还原出原始的数据。

比起 MIME type 来说,Encoding type 就少了很多,常用的只有下面三种:

  1. gzip:GNU zip 压缩格式,也是互联网上最流行的压缩格式;
  2. deflate:zlib(deflate)压缩格式,流行程度仅次于 gzip;
  3. br:一种专门为 HTTP 优化的新压缩算法(Brotli)。

大文件传输问题

数据压缩

通常浏览器在发送请求时都会带着 Accept-Encoding 头字段,里面是浏览器支持的压缩格式列表,例如 gzip、deflate、br 等,这样服务器就可以从中选择一种压缩算法,放进 Content-Encoding 响应头里,再把原数据压缩后发给浏览器。

如果压缩率能有 50%,也就是说 100K 的数据能够压缩成 50K 的大小,那么就相当于在带宽不变的情况下网速提升了一倍,加速的效果是非常明显的。

不过这个解决方法也有个缺点,gzip 等压缩算法通常只对文本文件有较好的压缩率,而图片、音频视频等多媒体数据本身就已经是高度压缩的,再用 gzip 处理也不会变小(甚至还有可能会增大一点),所以它就失效了。

分块传输

压缩是把大文件整体变小,我们可以反过来思考,如果大文件整体不能变小,那就把它 拆开 ,分解成多个小块,把这些小块分批发给浏览器,浏览器收到后再组装复原。

这种 化整为零 的思路在 HTTP 协议里就是 chunked 分块传输编码,在响应报文里用头字段 Transfer-Encoding: chunked 来表示,意思是报文里的 body 部分不是一次性发过来的,而是分成了许多的块(chunk)逐个发送。

分块传输也可以用于 流式数据 ,例如由数据库动态生成的表单页面,这种情况下 body 数据的长度是未知的,无法在头字段 Content-Length 里给出确切的长度,所以也只能用 chunked 方式分块发送。

Transfer-Encoding: chunkedContent-Length 这两个字段是互斥的,也就是说响应报文里这两个字段不能同时出现,一个响应报文的传输要么是长度已知,要么是长度未知(chunked),这一点你一定要记住。

下面我们来看一下分块传输的编码规则,其实也很简单,同样采用了明文的方式,很类似响应头。

  1. 每个分块包含两个部分,长度头和数据块;
  2. 长度头是以 CRLF(回车换行,即\r\n)结尾的一行明文,用 16 进制数字表示长度;
  3. 数据块紧跟在长度头后,最后也用 CRLF 结尾,但数据不包含 CRLF;
  4. 最后用一个长度为 0 的块表示结束,即 0\r\n\r\n 。

1

范围请求

有了分块传输编码,服务器就可以轻松地收发大文件了,但对于上 G 的超大文件,还有一些问题需要考虑。

比如,你在看当下正热播的某穿越剧,想跳过片头,直接看正片,或者有段剧情很无聊,想拖动进度条快进几分钟,这实际上是想获取一个大文件其中的片段数据,而分块传输并没有这个能力。

HTTP 协议为了满足这样的需求,提出了 范围请求 (range requests)的概念,允许客户端在请求头里使用专用字段来表示只获取文件的一部分,相当于是**客户端的 化整为零 **。

范围请求不是 Web 服务器必备的功能,可以实现也可以不实现,所以服务器必须在响应头里使用字段 Accept-Ranges: bytes 明确告知客户端: 我是支持范围请求的 。

如果不支持的话该怎么办呢?服务器可以发送 Accept-Ranges: none,或者干脆不发送 Accept-Ranges 字段,这样客户端就认为服务器没有实现范围请求功能,只能老老实实地收发整块文件了。

请求头 Range 是 HTTP 范围请求的专用字段,格式是 bytes=x - y ,其中的 x 和 y 是以字节为单位的数据范围。

要注意 x、y 表示的是偏移量 ,范围必须从 0 计数,例如前 10 个字节表示为 0-9 ,第二个 10 字节表示为 10-19 ,而 0-10 实际上是前 11 个字节。

Range 的格式也很灵活,起点 x 和终点 y 可以省略,能够很方便地表示正数或者倒数的范围。假设文件是 100 个字节,那么:

  • 0- 表示从文档起点到文档终点,相当于 0-99 ,即整个文件;
  • 10- 是从第 10 个字节开始到文档末尾,相当于 10-99 ;
  • -1 是文档的最后一个字节,相当于 99-99 ;
  • -10 是从文档末尾倒数 10 个字节,相当于 90-99 。

服务器收到 Range 字段后,需要做四件事。

  1. 它必须检查范围是否合法,比如文件只有 100 个字节,但请求 200-300 ,这就是范围越界了。服务器就会返回状态码 416,意思是你的范围请求有误我无法处理,请再检查一下 。
  2. 如果范围正确,服务器就可以根据 Range 头计算偏移量,读取文件的片段了,返回状态码 206 Partial Content ,和 200 的意思差不多,但表示 body 只是原数据的一部分。
  3. 服务器要添加一个响应头字段 Content-Range,告诉片段的实际偏移量和资源的总大小,格式是 bytes x-y/length ,与 Range 头区别在没有 = ,范围后多了总长度。例如,对于 0-10 的范围请求,值就是 bytes 0-10/100 。
  4. 最后剩下的就是发送数据,直接把片段用 TCP 发给客户端,一个范围请求就算是处理完了。

多段数据

刚才说的范围请求一次只获取一个片段,其实它还支持在 Range 头里使用多个 x - y ,一次性获取多个片段数据。

这种情况需要使用一种特殊的 MIME 类型: multipart/byteranges ,表示报文的 body 是由多段字节序列组成的,并且还要用一个参数 boundary=xxx 给出段之间的分隔标记。

多段数据的格式与分块传输也比较类似,但它需要用分隔标记 boundary 来区分不同的片段,可以通过图来对比一下。

2

每一个分段必须以 - -boundary 开始(前面加两个 - ),之后要用 Content-TypeContent-Range 标记这段数据的类型和所在范围,然后就像普通的响应头一样以回车换行结束,再加上分段数据,最后用一个 - -boundary- - (前后各有两个 - )表示所有的分段结束。

要注意这四种方法不是互斥的,而是可以混合起来使用,例如压缩后再分块传输,或者分段后再分块。

HTTP的重定向和跳转

前面讲过 HTTP 状态码的时候说过:3×× 状态码,301 是 永久重定向 ,302 是 临时重定向 ,浏览器收到这两个状态码就会跳转到新的 URI。

那么,它们是怎么做到的呢?难道仅仅用这两个代码就能够实现跳转页面吗?

先在实验环境里看一下重定向的过程吧,用 Chrome 访问 URI /18-1 ,它会使用 302 立即跳转到 /index.html 。

3

从这个实验可以看到,这一次 重定向 实际上发送了两次 HTTP 请求,第一个请求返回了 302,然后第二个请求就被重定向到了 /index.html 。但如果不用开发者工具的话,你是完全看不到这个跳转过程的,也就是说,重定向是 用户无感知 的。

我们再来看看第一个请求返回的响应报文:

4

这里出现了一个新的头字段 Location: /index.html,它就是 301/302 重定向跳转的秘密所在。

Location 字段属于响应字段,必须出现在响应报文里。但只有配合 301/302 状态码才有意义,它标记了服务器要求重定向的 URI,这里就是要求浏览器跳转到 index.html 。

浏览器收到 301/302 报文,会检查响应头里有没有 Location 。如果有,就从字段值里提取出 URI,发出新的 HTTP 请求,相当于自动替我们点击了这个链接。

在 Location 里的 URI 既可以使用绝对 URI,也可以使用相对 URI。所谓 绝对 URI ,就是完整形式的 URI,包括 scheme、host:port、path 等。所谓 相对 URI ,就是省略了 scheme 和 host:port,只有 path 和 query 部分,是不完整的,但可以从请求上下文里计算得到。

HTTP 是 无状态 的,这既是优点也是缺点。优点是服务器没有状态差异,可以很容易地组成集群,而缺点就是无法支持需要记录状态的事务操作。

那该怎么样让原本无 记忆能力 的服务器拥有 记忆能力 呢?服务器记不住,那就在外部想办法记住。相当于是服务器给每个客户端都贴上一张小纸条,上面写了一些只有服务器才能理解的数据,需要的时候客户端把这些信息发给服务器,服务器看到 Cookie,就能够认出对方是谁了。

Cookie 的工作过程

那么,Cookie 这张小纸条是怎么传递的呢?

这要用到两个字段:响应头字段 Set-Cookie 和请求头字段 Cookie

当用户通过浏览器第一次访问服务器的时候,服务器肯定是不知道他的身份的。所以,就要创建一个独特的身份标识数据,格式是 key=value ,然后放进 Set-Cookie 字段里,随着响应报文一同发给浏览器。

浏览器收到响应报文,看到里面有 Set-Cookie,知道这是服务器给的身份标识,于是就保存起来,下次再请求的时候就自动把这个值放进 Cookie 字段里发给服务器。

因为第二次请求里面有了 Cookie 字段,服务器就知道这个用户不是新人,之前来过,就可以拿出 Cookie 里的值,识别出用户的身份,然后提供个性化的服务。

不过因为服务器的 记忆能力 实在是太差,一张小纸条经常不够用。所以,服务器有时会在响应头里添加多个 Set-Cookie,存储多个 key=value 。但浏览器这边发送时不需要用多个 Cookie 字段,只要在一行里用 ; 隔开就行。

Cookie 的属性

首先,我们应该设置 Cookie 的生存周期,也就是它的有效期,让它只能在一段时间内可用,就像是食品的 保鲜期 ,一旦超过这个期限浏览器就认为是 Cookie 失效,在存储里删除,也不会发送给服务器。

Cookie 的有效期可以使用 ExpiresMax-Age 两个属性来设置。

Expires 俗称 过期时间 ,用的是绝对时间点,可以理解为 截止日期 (deadline)。 Max-Age 用的是相对时间,单位是秒,浏览器用收到报文的时间点再加上 Max-Age,就可以得到失效的绝对时间。

ExpiresMax-Age 可以同时出现,两者的失效时间可以一致,也可以不一致,但浏览器会优先采用 Max-Age 计算失效期。

比如在这个例子里,Expires 标记的过期时间是 GMT 2019 年 6 月 7 号 8 点 19 分 ,而 Max-Age 则只有 10 秒,如果现在是 6 月 6 号零点,那么 Cookie 的实际有效期就是 6 月 6 号零点过 10 秒 。

其次,我们需要设置 Cookie 的作用域,让浏览器仅发送给特定的服务器和 URI,避免被其他网站盗用。

作用域的设置比较简单, DomainPath 指定了 Cookie 所属的域名和路径,浏览器在发送 Cookie 前会从 URI 中提取出 host 和 path 部分,对比 Cookie 的属性。如果不满足条件,就不会在请求头里发送 Cookie。

使用这两个属性可以为不同的域名和路径分别设置各自的 Cookie,比如 /19-1 用一个 Cookie, /19-2 再用另外一个 Cookie,两者互不干扰。不过现实中为了省事,通常 Path 就用一个 / 或者直接省略,表示域名下的任意路径都允许使用 Cookie,让服务器自己去挑。

最后要考虑的就是Cookie 的安全性了,尽量不要让服务器以外的人看到。

写过前端的同学一定知道,在 JS 脚本里可以用 document.cookie 来读写 Cookie 数据,这就带来了安全隐患,有可能会导致 跨站脚本 (XSS)攻击窃取数据。

属性 HttpOnly 会告诉浏览器,此 Cookie 只能通过浏览器 HTTP 协议传输,禁止其他方式访问,浏览器的 JS 引擎就会禁用 document.cookie 等一切相关的 API,脚本攻击也就无从谈起了。

另一个属性 SameSite 可以防范 跨站请求伪造 (XSRF)攻击,设置成SameSite=Strict 可以严格限定 Cookie 不能随着跳转链接跨站发送,而 SameSite=Lax 则略宽松一点,允许 GET/HEAD 等安全方法,但禁止 POST 跨站发送。

还有一个属性叫 Secure ,表示这个 Cookie 仅能用 HTTPS 协议加密传输,明文的 HTTP 协议会禁止发送。但 Cookie 本身不是加密的,浏览器里还是以明文的形式存在。

HTTP的缓存控制

由于链路漫长,网络时延不可控,浏览器使用 HTTP 获取资源的成本较高。所以,非常有必要把 来之不易 的数据缓存起来,下次再请求的时候尽可能地复用。这样,就可以避免多次请求 - 应答的通信成本,节约网络带宽,也可以加快响应速度。

服务器的缓存控制

  1. 浏览器发现缓存无数据,于是发送请求,向服务器获取资源;
  2. 服务器响应请求,返回资源,同时标记资源的有效期;
  3. 浏览器缓存资源,等待下次重用。

服务器标记资源有效期使用的头字段是 Cache-Control ,里面的值 max-age=30 就是资源的有效时间,相当于告诉浏览器, 这个页面只能缓存 30 秒,之后就算是过期,不能用。

你可能要问了,让浏览器直接缓存数据就好了,为什么要加个有效期呢?

这是因为网络上的数据随时都在变化,不能保证它稍后的一段时间还是原来的样子。就像生鲜超市给你快递的西瓜,只有 5 天的保鲜期,过了这个期限最好还是别吃,不然可能会闹肚子。

Cache-Control 字段里的 max-age 和上一讲里 Cookie 有点像,都是标记资源的有效期。

这里的 max-age生存时间 (又叫 新鲜度 缓存寿命 ,类似 TTL,Time-To-Live),时间的计算起点是响应报文的创建时刻(即 Date 字段,也就是离开服务器的时刻),而不是客户端收到报文的时刻,也就是说包含了在链路传输过程中所有节点所停留的时间。

比如,服务器设定 max-age=5,但因为网络质量很糟糕,等浏览器收到响应报文已经过去了 4 秒,那么这个资源在客户端就最多能够再存 1 秒钟,之后就会失效。

max-age 是 HTTP 缓存控制最常用的属性,此外在响应报文里还可以用其他的属性来更精确地指示浏览器应该如何使用缓存:

  • no_store:不允许缓存,用于某些变化非常频繁的数据,例如秒杀页面;
  • no_cache:它的字面含义容易与 no_store 搞混,实际的意思并不是不允许缓存,而是可以缓存,但在使用之前必须要去服务器验证是否过期,是否有最新的版本;
  • must-revalidate:又是一个和 no_cache 相似的词,它的意思是如果缓存不过期就可以继续使用,但过期了如果还想用就必须去服务器验证。

听的有点糊涂吧。没关系,我拿生鲜速递来举例说明一下:

  • no_store:买来的西瓜不允许放进冰箱,要么立刻吃,要么立刻扔掉;
  • no_cache:可以放进冰箱,但吃之前必须问超市有没有更新鲜的,有就吃超市里的;
  • must-revalidate:可以放进冰箱,保鲜期内可以吃,过期了就要问超市让不让吃。

我把服务器的缓存控制策略画了一个流程图,对照着它你就可以在今后的后台开发里明确 Cache-Control 的用法了。

5

客户端的缓存控制

现在冰箱里已经有了 缓存 的西瓜,是不是就可以直接开吃了呢?

你可以在 Chrome 里点几次 刷新 按钮,估计你会失望,页面上的 ID 一直在变,根本不是缓存的结果,明明说缓存 30 秒,怎么就不起作用呢?

其实不止服务器可以发 Cache-Control 头,浏览器也可以发 Cache-Control ,也就是说 请求 - 应答 的双方都可以用这个字段进行缓存控制,互相协商缓存的使用策略。

当你点 刷新 按钮的时候,浏览器会在请求头里加一个 Cache-Control: max-age=0 。因为 max-age 是 生存时间 ,max-age=0 的意思就是 我要一个最最新鲜的西瓜 ,而本地缓存里的数据至少保存了几秒钟,所以浏览器就不会使用缓存,而是向服务器发请求。服务器看到 max-age=0,也就会用一个最新生成的报文回应浏览器。

Ctrl+F5 的 强制刷新 又是什么样的呢?

它其实是发了一个 Cache-Control: no-cache ,含义和 max-age=0 基本一样,就看后台的服务器怎么理解,通常两者的效果是相同的。

条件请求

浏览器用 Cache-Control 做缓存控制只能是刷新数据,不能很好地利用缓存数据,又因为缓存会失效,使用前还必须要去服务器验证是否是最新版。

那么该怎么做呢?

浏览器可以用两个连续的请求组成 验证动作 :先是一个 HEAD,获取资源的修改时间等元信息,然后与缓存数据比较,如果没有改动就使用缓存,节省网络流量,否则就再发一个 GET 请求,获取最新的版本。

但这样的两个请求网络成本太高了,所以 HTTP 协议就定义了一系列 If 开头的 条件请求 字段,专门用来检查验证资源是否过期,把两个请求才能完成的工作合并在一个请求里做。而且,验证的责任也交给服务器,浏览器只需 坐享其成 。

条件请求一共有 5 个头字段,我们最常用的是 if-Modified-SinceIf-None-Match 这两个。需要第一次的响应报文预先提供 Last-modifiedETag ,然后第二次请求时就可以带上缓存里的原值,验证资源是否是最新的。

如果资源没有变,服务器就回应一个 304 Not Modified ,表示缓存依然有效,浏览器就可以更新一下有效期,然后放心大胆地使用缓存了。

Last-modified 很好理解,就是文件的最后修改时间。ETag 是什么呢?

ETag 是 实体标签 (Entity Tag)的缩写,是资源的一个唯一标识,主要是用来解决修改时间无法准确区分文件变化的问题。

比如,一个文件在一秒内修改了多次,但因为修改时间是秒级,所以这一秒内的新版本无法区分。

再比如,一个文件定期更新,但有时会是同样的内容,实际上没有变化,用修改时间就会误以为发生了变化,传送给浏览器就会浪费带宽。

使用 ETag 就可以精确地识别资源的变动情况,让浏览器能够更有效地利用缓存。

ETag 还有 之分。

强 ETag 要求资源在字节级别必须完全相符,弱 ETag 在值前有个 W/ 标记,只要求资源在语义上没有变化,但内部可能会有部分发生了改变(例如 HTML 里的标签顺序调整,或者多了几个空格)。

还是拿生鲜速递做比喻最容易理解:

你打电话给超市, 我这个西瓜是 3 天前买的,还有最新的吗? 。超市看了一下库存,说: 没有啊,我这里都是 3 天前的。 于是你就知道了,再让超市送货也没用,还是吃冰箱里的西瓜吧。这就是 if-Modified-SinceLast-modified

但你还是想要最新的,就又打电话: 有不是沙瓤的西瓜吗? ,超市告诉你都是沙瓤的(Match),于是你还是只能吃冰箱里的沙瓤西瓜。这就是 If-None-Match弱 ETag

第三次打电话,你说 有不是 8 斤的沙瓤西瓜吗? ,这回超市给了你满意的答复: 有个 10 斤的沙瓤西瓜 。于是,你就扔掉了冰箱里的存货,让超市重新送了一个新的大西瓜。这就是 If-None-Match强 ETag

条件请求里其他的三个头字段是 If-Unmodified-Since If-MatchIf-Range ,其实只要你掌握了 if-Modified-SinceIf-None-Match ,可以轻易地举一反三 。

posted @ 2020-06-18 23:53  rickiyang  阅读(1218)  评论(0编辑  收藏  举报