透视 HTTP 协议(二)

实体结构

一个 HTTP 报文是由 header+body 组成的,本章主要研究 HTTP 的实体结构,也就是 body 部分。

数据类型与编码

MIME(Multipurpose Internet Mail Extensions,多用途互联网邮件扩展)也被称为 MIME 类型,是一个很大的标准规范,最早应用于电子邮件系统,用于描述多媒体数据的类型。HTTP 协议只取了其中的一部分,用来标记 body 的数据类型。

MIME 的组成结构非常简单,由类型与子类型两个字符串中间用 / 分隔而成,通用形式是 type/subtype

浏览器通常使用 MIME 类型(而不是文件扩展名)来确定如何处理 URL,因此 Web 服务器在响应头中添加正确的 MIME 类型非常重要。如果配置不正确,浏览器可能会曲解文件内容,网站将无法正常工作,并且下载的文件也会被错误处理。

在 HTTP 中常见的 MIME 类型有:

  1. text:文本格式的可读数据
    • text/plain:纯文本文件,文本文件默认值。
    • text/html:超文本文件,最常见的文本类型之一。
    • text/css:样式表文件。
    • text/javascript:JavaScript 文件。
  2. image:图像数据
    • image/gif:GIF 图片(支持动态图)。
    • image/jpeg:JPEG 图片(最常用的压缩图片)。
    • image/png:PNG 图片(无损压缩图片)。
    • image/svg+xml: SVG 图片(矢量图)。
  3. audio/video:音频和视频数据
    • audio/mpeg:mpeg 音频文件。
    • video/mp4:mp4 视频文件。
  4. application:数据格式不固定,可能是文本也可能是二进制,必须由上层应用程序来解释
    • application/json:json 数据格式。
    • application/xml:xml 数据格式。
    • application/x-www-form-urlencoded:表单的标准编码格式。
    • application/pdf:pdf 格式。
    • application/octet-stream:二进制数据流,表示未知的应用程序文件,浏览器一般不会自动执行或询问执行。

但仅有 MIME 类型还不够,因为 HTTP 在传输时为了节约带宽,有时候还会压缩数据,因此还需要有一个 编码类型,说明数据使用的是什么编码格式。常用编码类型有以下三种:

  1. gzip:GNU zip 压缩格式,也是互联网上最流行的压缩格式。
  2. deflate:zlib(deflate)压缩格式,流行程度仅次于 gzip。
  3. br:Brotli 压缩算法,专门为 HTTP 设计,压缩效率和性能更好。

数据类型使用的头字段

有了 MIME 类型和编码类型,无论是浏览器还是服务器就都可以轻松识别出 body 的数据类型,也就能够正确处理数据了。

HTTP 协议为此定义了两个 Accept 请求头字段和两个 Content 响应头字段,用于客户端和服务器进行 内容协商。也就是说,客户端用 Accept 头告诉服务器希望接收什么样的数据,而服务器用 Content 头告诉客户端实际发送了什么样的数据。

语言类型与编码

MIME 类型和编码类型解决了计算机理解 body 数据的问题,但互联网遍布全球,不同国家不同地区的人使用了很多不同的语言,虽然都是 text/html,但如何让浏览器显示出每个人都可理解可阅读的语言文字呢?

这实际上就是 国际化 的问题。HTTP 采用了与数据类型相似的解决方案,又引入了两个概念:语言类型与字符集

所谓语言类型就是人类使用的自然语言,例如汉语、英语、日语等,而这些自然语言可能还有下属的地区性方言,所以在需要明确区分的时候也要使用 type-subtype 的形式。比如 en 表示任意英语,en-US 表示美式英语,en-GB 表示英式英语,而 zh-CN 就表示我们最常使用的汉语。

而字符集是由于在计算机发展的早期,各个国家和地区的人们“各自为政”,发明了许多字符编码方式来处理自己的文字,比如英语世界用的 ASCII、汉语世界用的 GBK、日语世界用的 Shift_JIS 等。同样的一段文字,用一种编码显示正常,换另一种编码后可能就会变得一团糟。所以后来就出现了 Unicode 字符集UTF-8 编码方式,把世界上所有的语言都容纳在一种编码方案里,UTF-8 也成为了互联网上的标准字符编码。

语言类型使用的头字段

同样的,HTTP 协议也使用了 Accept 请求头字段和 Content 响应头字段,用于客户端和服务器就语言与编码进行内容协商。

需要注意的是,字符集在 HTTP 里使用的请求头字段是 Accept-Charset,但响应头里却没有对应的 Content-Charset,而是在 Content-Type 字段的数据类型后面用 charset=xxx 来表示。

而且由于现在的浏览器都支持多种字符集,因此通常不会发送 Accept-Charset。同时服务器也不会发送 Content-Language,因为使用的语言完全可以由字符集推断出来。所以在请求头里一般只会有 Accept-Language 字段,响应头里只会有 Content-Type 字段。

内容协商

内容协商的质量值

在 HTTP 协议里用 Accept、Accept-Encoding、Accept-Language 等请求头字段进行内容协商的时候,还可以用一种特殊的 q 参数表示权重来设定优先级,这里的 q 是 quality factor 的意思。

权重的最大值是 1,最小值是 0.01,默认值是 1,如果值是 0 就表示拒绝。具体的形式是在数据类型或语言代码后面加一个 ; 后面 q=value。例如下面的 Accept 字段,表示浏览器最希望使用的是 HTML 文件,权重是 1;其次是 XML 文件,权重是 0.9,最后是任意数据类型,权重是 0.8。服务器收到请求头后,就会计算权重,再根据自己的实际情况优先输出 HTML 或者 XML。

1
Accept: text/html,application/xml;q=0.9,*/*;q=0.8

内容协商的结果

内容协商的过程是不透明的,每个 Web 服务器使用的算法都不一样。但有的时候,服务器会在响应头里多加一个 Vary 字段,记录服务器在内容协商时参考的请求头字段,给出一点信息,例如:

1
Vary: Accept-Encoding,User-Agent,Accept

这个 Vary 字段表示服务器依据了 Accept-Encoding、User-Agent 和 Accept 这三个头字段,然后决定了发回的响应报文。

Vary 字段可以认为是响应报文的一个特殊的 版本标记,每当 Accept 等请求头变化时,Vary 也会随着响应报文一起变化。也就是说,同一个 URI 可能会有多个不同的版本,主要用于在传输链路中间的代理服务器上实现缓存服务。

小结

请求头字段 说明 响应头字段 说明
Accept 客户端支持的 MIME 类型 Content-Type 服务器选择的 MIME 类型
Accept-Charset ❌ 客户端支持的字符集类型 Content-Type 服务器选择的字符集类型
Accept-Encoding 客户端支持的编码类型 Content-Encoding 服务器选择的编码类型
Accept-Language 客户端支持的自然语言类型 Content-Language ❌ 服务器选择的自然语言类型

注:Content-* 字段也可以用在请求报文里,说明请求体的数据类型。比如使用 POST 方法向服务器提交 JSON 格式的数据,里面包含有中文,请求头应该形如:

1
2
3
POST /service/v1/user/auth HTTP/1.1
Content-type: application/json; charset=utf-8
Content-Language: zh-CN,zh

大文件传输

早期互联网上传输的基本上都是只有几 K 大小的文本和小图片,现在的情况则大有不同。网页里包含的信息实在是太多了,随随便便一个主页 HTML 就有可能上百 K,高质量的图片都以 M 论,更不要说那些电影、电视剧了,几 G、几十 G 都有可能。

相比之下,100M 的光纤固网或者 4G 移动网络在这些大文件的压力下都变成了“小水管”,无论是上传还是下载,都会把网络传输链路挤的“满满当当”。所以,如何在有限的带宽下高效快捷地传输这些大文件就成了一个重要的课题。

数据压缩

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

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

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

分块传输

在数据压缩之外,还能有什么办法来解决大文件的问题呢?

压缩是把大文件整体变小,我们可以反过来思考,如果大文件整体不能变小,那就把它“拆开”,分解成多个小块,把这些小块分批发给浏览器,浏览器收到后再组装复原。这样浏览器和服务器都不用在内存里保存文件的全部,每次只收发一小部分,网络也不会被大文件长时间占用,内存、带宽等资源也就节省下来了。

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

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

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

Transfer-Encoding 最常见的值是 chunked,但也可以用 gzip、deflate 等,表示传输时使用了压缩编码。但这与 Content-Encoding 不同,Transfer-Encoding 在传输后会被自动解码还原出原始数据,而 Content-Encoding 则必须由应用自己解码。

分块传输的编码规则,如图所示:

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

请求报文

1
2
3
4
GET /16-1 HTTP/1.1
Host: www.chrono.com
User-Agent: curl/7.55.1
Accept: */*

响应报文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HTTP/1.1 200 OK
Server: openresty/1.19.9.1
Date: Mon, 22 Nov 2021 08:01:52 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive

f
chunked data 1

f
chunked data 2

f
chunked data 3

0

范围请求

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

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

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

范围请求不是 Web 服务器必备的功能,可以实现也可以不实现,所以服务器必须在响应头里使用字段 Accept-Ranges: bytes 明确告知客户端:我支持范围请求。如果不支持的话,服务器可以发送 Accept-Ranges: none,或者干脆不发送 Accept-Ranges 字段,这样客户端就认为服务器没有实现范围请求功能,只能老老实实地收发整块文件了。

请求头 Range: bytes=x-y 是 HTTP 范围请求的专用字段,其中的 x 和 y 是以字节为单位的数据范围,表示数据的偏移量,从 0 开始计数。Range 的格式也很灵活,x 和 y 可以省略,能够很方便地表示正数或者倒数的范围。假设文件是 100 个字节,那么:

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

注:Range 请求的数据范围针对于压缩前的原文件。

服务器收到 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 发给客户端,一个范围请求就算是处理完了。

范围请求不仅可以用在看视频时的拖拽进度,常用的多段下载、断点续传也是基于它实现的,要点是:

  1. 先发个 HEAD 请求,看服务器是否支持范围请求,同时获取文件的大小;
  2. 开 n 个线程,每个线程使用 Range 字段划分出各自负责下载的片段,发送请求传输数据;
  3. 下载意外中断也不必重头再来一遍,只要根据上次的下载记录,用 Range 请求剩下的那一部分就可以了。

请求报文

1
2
3
4
5
GET /16-2 HTTP/1.1
Host:www.chrono.com
User-Agent: curl/7.55.1
Accept: */*
Range:bytes=0-31

响应报文

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 206 Partial Content
Server: openresty/1.19.9.1
Date: Mon, 22 Nov 2021 06:27:26 GMT
Content-Type: text/plain
Content-Length: 32
Last-Modified: Sat, 29 May 2021 11:07:40 GMT
Connection: keep-alive
Accept-Ranges: bytes
ETag: "60b2207c-5a"
Content-Range: bytes 0-31/90

// this is a plain text json doc

多段数据

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

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

多段数据的格式与范围请求也比较类似,但它需要用 分隔标记(boundary)来区分不同的片段,每一个分段必须以 “–boundary” 开始,之后用 Content-Type 和 Content-Range 标记这段数据的类型和所在范围,然后就像普通的响应头一样以回车换行结束,再加上分段数据,最后用一个 “–boundary–” 表示所有的分段结束。

多段数据的编码规则,如图所示:

请求报文

1
2
3
4
5
GET /16-2 HTTP/1.1
Host:www.chrono.com
User-Agent: curl/7.55.1
Accept: */*
Range:bytes=0-9,20-29

响应报文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
HTTP/1.1 206 Partial Content
Server: openresty/1.19.9.1
Date: Mon, 22 Nov 2021 06:13:04 GMT
Content-Type: multipart/byteranges; boundary=00000000001
Content-Length: 189
Last-Modified: Sat, 29 May 2021 11:07:40 GMT
Connection: keep-alive
Accept-Ranges: bytes
ETag: "60b2207c-5a"


--00000000001
Content-Type: text/plain
Content-Range: bytes 0-9/90

// this is
--00000000001
Content-Type: text/plain
Content-Range: bytes 20-29/90

ext json d
--00000000001--

小结

HTTP 处理大文件有四种方法,要注意这四种方法不是互斥的,而是可以混合起来使用。例如,压缩后再分块传输,或者分段后再分块。下面就模拟了后一种场景:

请求报文

1
2
3
4
5
6
7
8
GET /16-3 HTTP/1.1
Host: www.chrono.com
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9

响应报文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
HTTP/1.1 200 OK
Server: openresty/1.19.9.1
Date: Mon, 22 Nov 2021 08:07:26 GMT
Content-Type: multipart/byteranges; boundary=xyz
Transfer-Encoding: chunked
Connection: keep-alive
Accept-Ranges: bytes

47
--xyz
Content-Type: text/plain
Content-Range: bytes 0-9/90

// this is

49
--xyz
Content-Type: text/plain
Content-Range: bytes 20-29/90

ext json d

8
--xyz--

0

连接管理

HTTP 的性能问题可以说是:“不算差,不够好”,这次就来好好看看 HTTP 在连接这方面的表现。

短连接

HTTP 协议最初(0.9/1.0)是个非常简单的协议,通信过程也采用了简单的 “请求-应答” 方式。

底层的数据传输基于 TCP/IP,每次发送请求前需要先与服务器建立连接,收到响应报文后会立即关闭连接。因为客户端与服务器的整个连接过程很短暂,不会与服务器保持长时间的连接状态,所以就被称为 短连接(short-lived connections),早期的 HTTP 协议也被称为是 无连接 的协议。

短连接的缺点相当严重,因为在 TCP 协议里,建立连接和关闭连接都是非常昂贵的操作。TCP 建立连接要有三次握手,发送 3 个数据包,需要 1 个 RTT;关闭连接是四次挥手,4 个数据包需要 2 个 RTT。而 HTTP 的一次简单 “请求-应答” 通常只需要 4 个包,如果不算服务器内部的处理时间,最多是 2 个 RTT。这么算下来,浪费的时间就是 3÷5=60%,传输效率低得惊人。

RTT(Round-Trip Time):往返时延,为数据完全发送完成到收到确认信号的时间。

长连接

针对短连接暴露出的缺点,HTTP 协议提出了 长连接(persistent connections) 的通信方式。

其实解决办法也很简单,用的就是 成本均摊 的思路,既然 TCP 的连接和关闭非常耗时间,那么就把这个时间成本由原来的一个 “请求-应答” 均摊到多个 “请求-应答” 上。这样虽然不能改善 TCP 的连接效率,但基于 分母效应,每个 “请求-应答” 的无效时间就会降低不少,整体传输效率也就提高了。

连接字段

由于长连接对性能的改善效果非常显著,所以在 HTTP/1.1 中的连接都会 默认启用长连接。不需要用什么特殊的头字段指定,只要向服务器发送了第一次请求,后续的请求都会重复利用第一次打开的 TCP 连接,也就是长连接,在这个连接上收发数据。

当然,我们也可以明确要求使用长连接机制,只需要在请求头中加上 Connection: keep-alive 字段。不管客户端是否显式要求长连接,如果服务器支持长连接,它总会在响应报文里放一个 Connection: keep-alive 字段,告诉客户端:我支持长连接。

不过长连接也有一些小缺点,问题就出在它的 “长” 上。因为 TCP 连接长时间不关闭,服务器必须在内存里保存它的状态,这就占用了服务器的资源。如果有大量的空闲长连接只连不发,就会很快耗尽服务器的资源,导致服务器无法为真正有需要的用户提供服务。

所以,长连接也需要在恰当的时间关闭,不能永远保持与服务器的连接,这在客户端或者服务器都可以做到。

在客户端,可以在请求头里加上 Connection: close 字段,告诉服务器:这次通信后就关闭连接。服务器看到这个字段,就知道客户端要主动关闭连接,于是在响应报文里也加上这个字段,发送之后就调用 Socket API 关闭 TCP 连接。

而服务器端通常不会主动关闭连接,但也可以使用一些策略。拿 Nginx 来举例,它有两种方式:

  1. 使用 keepalive_timeout 指令,设置长连接的超时时间,如果在一段时间内连接上没有任何数据收发就主动断开连接,避免空闲连接占用系统资源。
  2. 使用 keepalive_requests 指令,设置长连接上可发送的最大请求次数。比如设置成 1000,那么当 Nginx 在这个连接上处理了 1000 个请求后,也会主动断开连接。

另外,客户端和服务器都可以在报文里附加通用头字段 Keep-Alive: timeout=value,限定长连接的超时时间。但这个字段的约束力并不强,通信的双方可能并不会遵守,所以不太常见。

队头阻塞

队头阻塞(head-of-line blocking)与短连接和长连接无关,而是由 HTTP 基本的 “请求-应答” 模型所导致的。

因为 HTTP 规定报文必须是 “一发一收”,这就形成了一个先进先出的 “串行” 队列。队列里的请求没有轻重缓急的优先级,只有入队的先后顺序,排在最前面的请求被最优先处理。如果队首的请求因为处理的太慢耽误了时间,那么队列后面的所有请求也不得不跟着一起等待,结果就是其他的请求承担了不应有的时间成本。

在性能优化上,由于 “请求-应答” 模型不能变,所以队头阻塞问题在 HTTP/1.1 里无法解决,只能通过 并发连接(concurrent connections)域名分片(domain sharding)技术进行缓解。

HTTP 协议是 无状态 的,这既是优点也是缺点。优点是服务器没有状态差异,可以很容易地组成集群,而缺点是无法支持需要记录状态的事务操作。好在 HTTP 协议是可扩展的,通过 Cookie 技术,就给 HTTP 增加了 “记忆能力”。

目前虽然已经出现了多种 Local Web Storage 技术,能够比 Cookie 存储更多的数据,但 Cookie 仍然是最通用、兼容性最强的客户端数据存储手段。

Cookie 的传递要用到两个字段,分别是响应头字段 Set-Cookie 和请求头字段 Cookie,这个过程如下所示:

  1. 当用户通过浏览器第一次访问服务器的时候,服务器不知道他的身份,会创建一个独特的身份标识数据,格式是 Set-Cookie: key=value,随着响应报文一同发给浏览器。
  2. 浏览器收到响应报文,看到里面有 Set-Cookie,知道这是服务器给的身份标识,于是就保存起来,下次再请求的时候就自动把这个值放进 Cookie 字段里发给服务器,格式是 Cookie: key=value
  3. 因为第二次请求里面有了 Cookie 字段,服务器就可以拿到 Cookie 里的值,识别出用户的身份,然后提供个性化的服务。

服务器有时会在响应头里添加多个 Set-Cookie,存储多个 “key=value”,但浏览器这边发送时不需要用多个 Cookie 字段,只要在一行中用 “;” 隔开就行。

Cookie 是由浏览器负责存储的,而不是操作系统,所以它只能在本浏览器内生效。

常见的 Cookie 属性可以分为三类:

  • 生存周期:即 Cookie 的有效期,限制 Cookie 只能在一段时间内可用,可以使用 ExpiresMax-Age 两个属性来设置。Expires 使用绝对时间,可以理解为是截止日期;Max-Age 使用相对时间,单位为秒,表示浏览器收到报文后经过的时间。

    如果不指定 Expires 或 Max-Age 属性,那么 Cookie 仅在浏览器运行时有效,一旦关闭浏览器就会失效,这也被称为 会话Cookie(session cookie)

  • 作用域:让浏览器仅把 Cookie 发送给特定的服务器和 URI,避免被其他网站盗用。作用域的设置比较简单,使用 DomainPath 指定 Cookie 所属的域名和路径,浏览器在发送 Cookie 前会从 URI 中提取出 host 和 path 部分,对比 Cookie 的属性。如果不满足条件,就不会在请求头里发送 Cookie。

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

  • 安全性:尽量不要让用户和服务器以外的人获取到 Cookie 的内容,在 Cookie 中有三个与安全相关的属性。

    • HttpOnly 属性会告诉浏览器,此 Cookie 只能通过浏览器 HTTP 协议传输,禁止其他方式访问,浏览器的 JS 引擎就会禁用 document.cookie 等一切相关的 API,可以缓解 XSS 跨站脚本攻击。
    • SameSite 属性可以防范 CSRF 跨站请求伪造攻击,设置成 SameSite=Strict 可以严格限定 Cookie 不能随着跳转链接跨站发送,而 SameSite=Lax 则略微宽松一点,允许 GET/HEAD 等安全方法,但禁止 POST 跨站发送。
    • Secure 属性表示这个 Cookie 仅能用 HTTPS 协议加密传输,明文的 HTTP 协议会禁止发送。但 Cookie 本身不是加密的,浏览器里还是以明文的形式存在。

Cookie 最基本的一个用途就是 身份识别,保存用户的登录信息,实现会话事务。

比如,你用账号和密码登录某电商,登录成功后网站服务器就会发给浏览器一个 Cookie,内容大概是 “name=yourid”,这样就成功地把身份标签贴在了你身上。

之后你在网站里随便访问哪件商品的页面,浏览器都会自动把身份 Cookie 发给服务器,所以服务器总会知道你的身份,一方面免去了重复登录的麻烦,另一方面也能够自动记录你的浏览记录和购物下单,实现了 “状态保持”。

Cookie 的另一个常见用途是 广告跟踪

比如,上网的时候肯定看过很多的广告图片,这些图片背后都是广告商网站(例如 Google),它会 “偷偷地” 给你贴上 Cookie 小纸条,这样你上其他的网站,别的广告就能用 Cookie 读出你的身份,然后做行为分析,再推给你广告。

这种 Cookie 不是由访问的主站存储的,所以又叫 第三方 Cookie(third-party cookie)。如果广告商势力很大,广告到处都是,那么就比较恐怖了,无论你走到哪里它都会通过 Cookie 认出你来,实现广告的 “精准打击”。

为了防止滥用 Cookie 搜集用户隐私,互联网组织相继提出了 DNT(Do Not Track)和 P3P(Platform for Privacy Preferences Project),但实际作用不大。

代理服务

代理(Proxy) 是 HTTP 协议中请求方和应答方中间的一个环节,所谓 “代理服务” 就是指 服务本身不生产内容,而是处于中间位置转发上下游的请求和响应,具有双重身份:面向下游的用户时,表现为服务器,代表源服务器进行响应;而面向上游的源服务器时,又表现为客户端,代表客户端发送请求。

代理有很多的种类,常见的有:

  1. 匿名代理:完全 “隐匿” 了被代理的机器,外界看到的只是代理服务器。
  2. 透明代理:在传输过程中是 “透明开放” 的,外界既知道代理,也知道客户端。
  3. 正向代理:靠近客户端,代表客户端向服务器发送请求。
  4. 反向代理:靠近服务器,代表服务器响应客户端的请求。

现在无处不在的 内容分发网络(CDN,Content Delivery Network),实际上就是一种代理(缓存代理),它代替源站服务器响应客户端的请求,通常扮演着透明代理和反向代理的角色。

代理的作用

由于代理处在 HTTP 通信过程的中间位置,对上屏蔽了真实客户端,对下屏蔽了真实服务器,所以在这个中间环节就可以做很多有意思的事情,为 HTTP 协议增加更多的灵活性。

代理最基本的一个功能就是 负载均衡:把访问请求均匀分散到多台机器,实现访问集群化。因为在面向客户端时屏蔽了源服务器,客户端看到的只是代理服务器,源服务器究竟有多少台、是哪些 IP 地址都不知道,真正由哪台服务器来响应请求完全由代理服务器决定。

在负载均衡的同时,代理服务还可以执行更多的功能,比如:

  • 健康检查:使用 “心跳” 等机制监控后端服务器,发现有故障就及时 “踢出” 集群,保证服务高可用。
  • 安全防护:隐匿源站 IP,使用 WAF 等工具抵御网络攻击,以及限制 IP 访问或流量过载,保护被代理的后端服务器。
  • 加密卸载:对外网使用 SSL/TLS 加密通信认证,而在安全的内网不加密,省去加解密成本。
  • 数据处理:提供压缩、加密、修改上下行数据等功能。
  • 内容缓存:暂存、复用服务器响应,减轻后端的压力。

代理字段

代理的好处很多,但因为它 “欺上瞒下” 的特点,隐藏了真实客户端和服务器,如果双方想要获得这些被隐藏的原始信息,该怎么办呢?

首先,代理服务器需要用 Via 字段 标明代理的身份。Via 是一个通用字段,请求头或响应头里都可以出现。每当报文经过一个代理节点,代理服务器就会把自身的信息追加到字段的末尾,就像是经手人盖了一个章。

例如下图中有两个代理:proxy1 和 proxy2,客户端发送请求会经过这两个代理,依次添加就是 Via: proxy1, proxy2,等到服务器返回响应报文的时候就要反过来,依次是 Via: proxy2, proxy1

Via 字段只解决了客户端和源服务器判断是否存在代理的问题,还不能知道对方的真实信息。但服务器的 IP 地址应该是保密的,关系到企业的内网安全,所以一般不会让客户端知道。不过反过来,通常服务器需要知道客户端的真实 IP 地址,方便做访问控制、用户画像、统计分析。

可惜的是 HTTP 标准里并没有为此定义头字段,但已经出现了很多 “事实上的标准”,最常用的两个头字段是 X-Forwarded-ForX-Real-IP

X-Forwarded-For(XFF)的字面意思是 “为谁而转发”,形式上和 Via 差不多,也是每经过一个代理节点就会在字段里追加一个信息。但 Via 追加的是 代理主机名(或者域名),而 X-Forwarded-For 追加的是 请求方的 IP 地址。所以,在 X-Forwarded-For 字段里最左边的 IP 就是客户端的地址。此外还有两个字段:X-Forwarded-Host 和 X-Forwarded-Proto,分别记录客户端请求的原始域名和原始协议名。

X-Real-IP 是另一种获取客户端真实 IP 的手段,它的作用很简单,就是记录 客户端 IP 地址,没有中间的代理信息,相当于是 X-Forwarded-For 的简化版。如果客户端和源服务器之间只有一个代理,那么这两个字段的值就是相同的。

代理协议

有了 X-Forwarded-For 等头字段,源站就可以拿到准确的客户端信息了,但对于代理服务器来说它并不是一个最佳的解决方案。

因为通过 X-Forwarded-For 操作代理信息必须要解析 HTTP 报文头,这对于代理来说成本比较高,原本只需要简单地转发消息就好,而现在却必须要费力解析数据再修改数据,会降低代理的转发性能;另一个问题是 X-Forwarded-For 等头必须要修改原始报文,而有些情况下是不允许甚至不可能的(比如使用 HTTPS 加密通信)。

所以就出现了一个专门的 代理协议(PROXY protocol),可以在不改动原始报文的情况下传递客户端的真实 IP。它由知名的代理软件 HAProxy 定义,也是一个被广泛采用的 “事实标准”(并不是 RFC),目前有 v1(明文格式)和 v2(二进制格式)两个版本。

v1 会在 HTTP 报文前增加了一行 ASCII 码文本,相当于又多了一个头,格式形如:PROXY TCP4 1.1.1.1 2.2.2.2 55555 80\r\n。服务器看到这样的报文,只要解析第一行就可以拿到客户端地址,不需要再去理会后面的 HTTP 数据,省了很多事情。

缓存控制

缓存(Cache)是计算机领域里的一个重要概念,是 优化系统性能 的重要手段。

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

实际上,HTTP 传输的 每一个环节 基本上都会有缓存,非常复杂。基于 “请求-应答” 模式的特点,可以大致分为客户端缓存和服务器端缓存。

没有请求的请求,才是最快的请求。

浏览器缓存

缓存控制的运行机制实际上和 Cookie 机制十分相似,在传递中使用了 Cache-Control 头字段,这个过程如下所示:

  1. 浏览器请求一个资源,发现缓存中没有数据,于是发送请求,向服务器获取资源;
  2. 服务器响应请求,返回资源,同时标记资源的有效期,格式是 Cache-Control: max-age=30
  3. 浏览器缓存资源,等待下次重用。

max-age 是 HTTP 缓存控制最常用的属性,作用和 Cookie 类似,不过时间的计算起点是响应报文的 创建时间(即 Date 字段),此外在 Cache-Control 里还可以设置其他属性来更精确地指示浏览器如何使用缓存:

  • no_store:不允许缓存,用于某些变化非常频繁的数据,例如秒杀页面。
  • no_cache:可以缓存,但在 使用缓存前 必须去服务器验证是否过期,是否有最新的版本(等同于 max-age=0,must-revalidate)。
  • must-revalidate:可以缓存,如果缓存不过期就可以继续使用,如果 缓存失效 则必须去服务器验证。

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

当使用 F5 刷新按钮的时候,浏览器会在请求头里加一个 Cache-Control: max-age=0,此时不使用缓存,直接向服务器发送请求。服务器看到 max-age=0,也会生成一个最新报文回应浏览器;使用 Ctrl+F5 强制刷新时,会发送 Cache-Control: no-cache,含义和 max-age=0 基本一样,依赖于后端服务器怎么理解,通常两者的效果是相同的。

只有在 “前进” “后退” “跳转” 这些重定向动作中,浏览器的缓存才会真正生效。此时请求头中不再有 Cache-Control,在响应码后会有 “from disk cache” 字样,表示没有发送网络请求,而是读取磁盘上的缓存。

条件请求

由于浏览器在使用缓存前往往需要去服务器验证缓存是否为最新,这个验证动作可以用两个连续的请求完成:先发一个 HEAD,获取资源的修改时间等元信息,然后与缓存数据比较,如果没有改动就使用缓存;否则就再发一个 GET 请求,获取最新的版本。

但这样的两个请求网络成本太高了,所以 HTTP 协议就定义了一系列 If 开头的 条件请求 字段,专门用来检查验证资源是否过期,把两个请求才能完成的工作合并为一个请求。并且,缓存是否过期的验证逻辑也在服务器中完成。

条件请求一共有 5 个头字段,我们最常用的是 If-Modified-SinceIf-None-Match 这两个。使用条件请求需要服务器在第一次的响应报文中提供 Last-modifiedETag 字段,然后浏览器在接下来的条件请求中就可以带上这个字段值,验证资源是否是最新的。如果资源没有变,服务器就回应一个 304 Not Modified,表示缓存依然有效,浏览器就可以更新一下有效期,然后放心大胆地使用缓存了。

Last-modified 很好理解,就是文件的最后修改时间。ETag 是 实体标签(Entity Tag)的缩写,是资源的一个唯一标识,用于精确地识别资源的变动情况,解决修改时间无法准确区分文件变化的问题。比如,一个文件在一秒内修改了多次,但因为修改时间是秒级,所以这一秒内的新版本无法区分;再比如,一个文件定期更新,但有时会是同样的内容,实际上没有变化,用修改时间就会误以为发生了变化,传送给浏览器就会浪费带宽。

缓存代理

缓存代理也就是支持缓存控制的代理服务,是服务器端的缓存技术。

在没有缓存的时候,代理服务器每次都是直接转发客户端和服务器的报文,中间不会存储任何数据,只有最简单的中转功能。

加入了缓存后,代理服务会把源服务器发来的响应报文转发给客户端,同时把报文存入自己的 Cache 里。下一次再有相同的请求,代理服务器就可以直接发送 304 或者缓存数据,不必再从源服务器那里获取。这样就降低了客户端的等待时间,同时节约了源服务器的网络带宽。