MIME type
,相关的头字段是 Accept
和 Content-Type
;Accept-Encoding
和 Content-Encoding
;Accept-Language
和 Content-Language
;Accept-Charset
和 Content-Type
;客户端需要在请求头里使用 Accept
等头字段与服务器进行“内容协商”,要求服务器返回最合适的数据;
Accept 等头字段可以用“,
”顺序列出多个可能的选项,还可以用“;q=
”参数来精确指定权重。
在 TCP/IP
协议栈里,传输数据基本上都是“header+body”的格式。
假如 HTTP 没有告知数据类型的功能,服务器把数据发给了浏览器,浏览器看到的是一个“黑盒子”,此时,浏览器可以猜测数据的类型。
很多数据都是有固定格式的,所以通过检查数据的前几个字节也许就能知道这是个 GIF 图片、或者是个 MP3 音乐文件,但这种方式无疑十分低效,而且有很大几率会检查不出来文件类型。
在 HTTP 协议诞生之前就已经有了针对这种问题的解决方案,它是用在电子邮件系统里的,让电子邮件可以发送 ASCII 码以外的任意数据,方案的名字叫做“多用途互联网邮件扩展”(Multipurpose Internet Mail Extensions),简称为 MIME。
MIME 是一个很大的标准规范,但 HTTP 只取了其中的一部分,用来标记 body 的数据类型,这就是“MIME type
”。
MIME 把数据分成了八大类,每个大类下再细分出多个子类,形式是“type/subtype
”的字符串,刚好也符合了 HTTP 明文的特点,所以能够很容易地纳入 HTTP 头字段里。
在 HTTP 里经常遇到的几个类别:
text/html
(超文本文档),text/plain
(纯文本 )、 text/css
(样式表)等。image/gif
、image/jpeg
、image/png
等。audio/mpeg
、video/mp4
等。application/json
,application/javascript
、application/pdf
等,如果实在是不知道数据是什么类型”,就是 application/octet-stream
(不透明的二进制数据)。仅有 MIME type 还不够,因为 HTTP 在传输时为了节约带宽,有时候会压缩数据,为了不让浏览器“猜”,还需要有一个“Encoding type
”,告诉数据的编码格式,这样对方才能正确解压缩,还原出原始的数据。
Encoding type
常用的只有下面三种:
有了 MIME type
和 Encoding type
,无论是浏览器还是服务器就都可以轻松识别出 body 的类型,也就能够正确处理数据了。
HTTP 协议为此定义了两个 Accept
请求头字段和两个 Content
实体头字段,用于客户端和服务器进行“内容协商”。
Accept
头告诉服务器希望接收什么样的数据,Accept,Accept-Encoding
Content
头告诉客户端实际发送什么样的数据,Content-Type,Content-Encoding
Accept-Encoding
字段,就表示客户端不支持压缩数据;Content-Encoding
字段,就表示响应数据没有被压缩。MIME type
和 Encoding type
解决了计算机理解 body 数据的问题,但“国际化”问题还没解决。HTTP 采用了与数据类型相似的解决方案,又引入了两个概念:语言类型与字符集。
所谓的“语言类型”就是人类使用的自然语言,例如英语、汉语、日语等,而这些自然语言可能还有下属的地区性方言,所以在需要明确区分的时候也要使用“type-subtype
”的形式,不过这里的格式与数据类型不同,分隔符不是“/
”,而是“-
”。例如:
关于自然语言的处理计算机使用“字符集”。在计算机发展的早期,各个国家和地区的人们“各自为政”,发明了许多字符编码方式来处理文字,例如:
同样的一段文字,用一种编码显示正常,换另一种编码后可能就会变得一团糟。所以就出现了 Unicode 和 UTF-8,把世界上所有的语言都容纳在一种编码方案里,遵循 UTF-8 字符编码方式的 Unicode 字符集也成为了互联网上的标准字符集。
HTTP 协议也使用 Accept 请求头字段和 Content 实体头字段,用于客户端和服务器就语言与编码进行“内容协商”。
Accept-Language
字段标记了客户端可理解的自然语言,用“,
”做分隔符列出多个类型。Content-Language
告诉客户端实体数据使用的实际语言类型。字符集在 HTTP 里使用的请求头字段是 Accept-Charset
,但响应头里却没有对应的 Content-Charset
,而是在 Content-Type
字段的数据类型后面用“charset=xxx
”来表示,这点需要特别注意。
现在的浏览器都支持多种字符集,通常不会发送 Accept-Charset
,而服务器也不会发送 Content-Language
,因为使用的语言完全可以由字符集推断出来,所以在请求头里一般只会有 Accept-Language
字段,响应头里只会有 Content-Type
字段。
在 HTTP 协议里用 Accept
、Accept-Encoding
、Accept-Language
等请求头字段进行内容协商的时候,还可以用一种特殊的“q(quality factor)
”参数表示权重来设定优先级。
权重的最大值是 1,最小值是 0.01,默认值是 1,如果值是 0 就表示拒绝。
具体的形式是在数据类型或语言代码后面加一个“;
”,然后是“q=value
”。
这里要提醒的是“;
”的用法,在大多数编程语言里“;
”的断句语气要强于“,
”,而在 HTTP 的内容协商里却恰好反了过来,“;
”的意义是小于“,
”。
Accept: text/html,application/xml;q=0.9,*/*;q=0.8
表示浏览器最希望使用的是 HTML 文件,权重是 1,其次是 XML 文件,权重是 0.9,最后是任意数据类型,权重是 0.8。服务器收到请求头后,就会计算权重,再根据自己的实际情况优先输出 HTML 或者 XML。
内容协商的过程是不透明的,每个 Web 服务器使用的算法都不一样。
有时候服务器会在响应头里多加一个 Vary
字段,记录服务器在内容协商时参考的请求头字段,例如:
Vary: Accept-Encoding,User-Agent,Accept
表示服务器依据了 Accept-Encoding
、User-Agent
和 Accept
这三个头字段,然后决定了发回的响应报文。
Vary 字段可以认为是响应报文的一个特殊的“版本标记”。每当 Accept 等请求头变化时,Vary 也会随着响应报文一起变化。也就是说,同一个 URI 可能会有多个不同的“版本”,主要用在传输链路中间的代理服务器实现缓存服务。
通常浏览器在发送请求时都会带着“Accept-Encoding
”头字段,里面是浏览器支持的压缩格式列表,例如 gzip、deflate、br 等,这样服务器就可以从中选择一种压缩算法,放进“Content-Encoding
”响应头里,再把原数据压缩后发给浏览器。
如果压缩率能有 50%,也就是说 100K 的数据能够压缩成 50K 的大小,那么就相当于在带宽不变的情况下网速提升了一倍,加速的效果是非常明显的。
gzip的压缩上通常能够超过60%,而br算法是专为HTML设计的,压缩效率和性能比gzip更好,能够在提高20%的压缩密度。
这个解决方法的缺点,gzip 等压缩算法通常只对文本文件有较好的压缩率,而图片、音频视频等多媒体数据本身就已经是高度压缩的,再用 gzip 处理也不会变小(甚至还有可能会增大一点),所以它就失效了。数据压缩在处理文本的时候效果还是很好的,所以各大网站的服务器都会使用这个手段作为“保底”。
例如,在 Nginx 里就会使用“gzip on”指令,启用对“
text/html
”的压缩,不会压缩图片、音频和视频。
压缩是把大文件整体变小,如果大文件整体不能变小,那就把它“拆开”,分解成多个小块,把这些小块分批发给浏览器,浏览器收到后再组装复原。这样浏览器和服务器都不用在内存里保存文件的全部,每次只收发一小部分,网络也不会被大文件长时间占用,内存、带宽等资源也就节省下来了。
这种“化整为零”的思路在 HTTP 协议里就是“chunked
”分块传输编码,在响应报文里用头字段“Transfer-Encoding: chunked”
来表示,意思是报文里的 body 部分不是一次性发过来的,而是分成了许多的块(chunk)逐个发送。
Transfer-Encoding
字段常见的值是chunked
,也可以同gzip
、deflate
等,表示传输时使用了压缩编码。这与Content-Encoding
不同,Transfer-Encoding
在传输后被自动解码还原出原始数据,而Content-Encoding
则必须由应用自行解码。
分块传输也可以用于“流式数据”,例如由数据库动态生成的表单页面,这种情况下 body 数据的长度是未知的,无法在头字段“Content-Length
”里给出确切的长度,所以也只能用 chunked
方式分块发送。
“Transfer-Encoding: chunked
”和“Content-Length
”这两个字段是互斥的,响应报文里这两个字段不能同时出现,一个响应报文的传输要么是长度已知,要么是长度未知(chunked)。
分块传输的编码规则,同样采用了明文的方式,类似响应头:
浏览器在收到分块传输的数据后会自动按照规则去掉分块编码,重新组装出内容。
分块传输在末尾还允许有“拖尾数据”,有响应头字段Trialer
指定。
有了分块传输编码,服务器就可以轻松地收发大文件了,但对于上 G 的超大文件,还有一些问题需要考虑。
例如:在看当下正热播的某穿越剧,想跳过片头,直接看正片,或者想拖动进度条快进几分钟,这实际上是想获取一个大文件其中的片段数据,而分块传输并没有这个能力。
HTTP 协议为了满足这样的需求,提出了“范围请求”(range requests)的概念,允许客户端在请求头里使用专用字段来表示只获取文件的一部分,相当于是客户端的“化整为零”。
Accept-Ranges: bytes
”明确告知客户端:“我是支持范围请求的”。Accept-Ranges: none
”,或者干脆不发送“Accept-Ranges
”字段,这样客户端就认为服务器没有实现范围请求功能,只能收发整块文件。请求头 Range
是 HTTP 范围请求的专用字段,格式是“bytes=x-y
”,其中的 x 和 y 是以字节为单位的数据范围。要注意 x、y 表示的是“偏移量”,范围必须从 0 计数,例如:
Range
的格式很灵活,起点 x 和终点 y 可以省略,能够很方便地表示正数或者倒数的范围。假设文件是 100 个字节,那么:
服务器收到 Range
字段后,需要做四件事。
416
,意思是“你的范围请求有误,我无法处理,请再检查一下”。206 Partial Content
”,和 200
的意思差不多,但表示 body 只是原数据的一部分。Content-Range
,告诉片段的实际偏移量和资源的总大小,格式是“bytes x-y/length
”,与 Range
头区别在没有“=”,范围后多了总长度。例如,对于“0-10”的范围请求,值就是“bytes 0-10/100”。有了范围请求之后,HTTP 处理大文件就更加轻松了,看视频时可以根据时间点计算出文件的 Range,不用下载整个文件,直接精确获取片段所在的数据内容。
不仅看视频的拖拽进度需要范围请求,常用的下载工具里的多段下载、断点续传也是基于它实现的,要点是:
与请求头
Range
有关的还有一个If-Range
表示范围请求。
范围请求一次还支持在 Range
头里使用多个“x-y
”,一次性获取多个片段数据。
这种情况需要使用一种特殊的 MIME 类型:“multipart/byteranges
”,表示报文的 body 是由多段字节序列组成的,并且还要用一个参数“boundary=xxx
”给出段之间的分隔标记。多段数据的格式与分块传输也比较类似,但它需要用分隔标记 boundary 来区分不同的片段。
每一个分段必须以“-- boundary
”开始(前面加两个“-”),之后要用“Content-Type
”和“Content-Range
”标记这段数据的类型和所在范围,然后就像普通的响应头一样以回车换行结束,再加上分段数据,最后用一个“-- boundary --
”(前后各有两个“-”)表示所有的分段结束。
HTTP的性能“不算差、不够好”。HTTP的连接管理分为短连接和长连接。
HTTP 协议最初(0.9/1.0)是个非常简单的协议,通信过程也采用了简单的“请求——应答”方式。
它底层的数据传输基于 TCP/IP
,每次发送请求前需要先与服务器建立连接,收到响应报文后会立即关闭连接。因为客户端与服务器的整个连接过程很短暂,不会与服务器保持长时间的连接状态,所以就被称为“短连接”(short-lived connections)。早期的 HTTP 协议也被称为是“无连接”的协议。
短连接的缺点相当严重,因为在 TCP 协议里,建立连接和关闭连接都是非常“昂贵”的操作。
而 HTTP 的一次简单“请求——响应”通常只需要 4 个包,如果不算服务器内部的处理时间,最多是 2 个 RTT。
这么算下来,浪费的时间就是“3÷5=60%”,有三分之二的时间被浪费掉了,传输效率低得惊人。
针对短连接暴露出的缺点,HTTP 协议就提出了“长连接”的通信方式,也叫
解决办法很简单,用“成本均摊”的思路,既然 TCP 的连接和关闭非常耗时间,那么就把这个时间成本由原来的一个“请求——应答”均摊到多个“请求——应答”上。
这样虽然不能改善 TCP 的连接效率,但基于“分母效应”,每个“请求——应答”的无效时间就会降低不少,整体传输效率也就提高了。
短连接和长连接的对比如下图所示:
在短连接里发送了三次 HTTP“请求——应答”,每次都会浪费 60% 的 RTT 时间。而在长连接的情况下,同样发送三次请求,因为只在第一次时建立连接,在最后一次时关闭连接,所以浪费率就是“3÷9≈33%”,降低了差不多一半的时间损耗。
如果在这个长连接上发送的请求越多,分母就越大,利用率也就越高。
因为TCP协议有“慢启动”和“拥塞窗口”等特性,通常新建立的“冷连接”会比打开了一段时间的“热连接”要慢一些,所以长连接比短连接还多了这一层优势。
在长连接中的一个重要问题是如何正确地区分多个报文的开始和结束,所以最好总是使用”Content-Length
“头明确响应实体的长度,正确标记报文结束。如果是流式传输,body长度不能立即确定,就必须用分块传输编码。
HTTP连接管理的第三种方式pipeline(管道或流水线),它在长连接的基础上又进了一步,可以批量发送请求批量接收响应,但是因为存在一些问题,Chrome和Firefox等浏览器都没有实现它,已经被事实上废弃了。
由于长连接对性能的改善效果非常显著,所以在 HTTP/1.1
中的连接都会默认启用长连接。不需要用什么特殊的头字段指定,只要向服务器发送了第一次请求,后续的请求都会重复利用第一次打开的 TCP 连接,在这个连接上收发数据。
也可以在请求头里明确地要求使用长连接机制,使用的字段是 Connection
,值是“keep-alive
”。不管客户端是否显式要求长连接,如果服务器支持长连接,它总会在响应报文里放一个“Connection: keep-alive
”字段,告诉客户端:“我是支持长连接的,接下来就用这个 TCP 一直收发数据吧”。
长连接也有一些小缺点,问题就出在它的“长”字上。
因为 TCP 连接长时间不关闭,服务器必须在内存里保存它的状态,这就占用了服务器的资源。如果有大量的空闲长连接只连不发,就会很快耗尽服务器的资源,导致服务器无法为真正有需要的用户提供服务。所以,长连接也需要在恰当的时间关闭,不能永远保持与服务器的连接,这在客户端或者服务器都可以做到。
Connection: close
”字段,告诉服务器:“这次通信后就关闭连接”。服务器看到这个字段,就知道客户端要主动关闭连接,于是在响应报文里也加上这个字段,发送之后就调用 Socket API 关闭 TCP 连接。拿 Nginx 来举例,它有两种方式:
keepalive_timeout
”指令,设置长连接的超时时间,如果在一段时间内连接上没有任何数据收发就主动断开连接,避免空闲连接占用系统资源。keepalive_requests
”指令,设置长连接上可发送的最大请求次数。比如设置成 1000,那么当 Nginx 在这个连接上处理了 1000 个请求后,也会主动断开连接。客户端和服务器都可以在报文里附加通用头字段“Keep-Alive: timeout=value
”,限定长连接的超时时间。但这个字段的约束力并不强,通信的双方可能并不会遵守,所以不太常见。
Connection
字段还有一个取值:“Connection: Upgrade
”,配合状态码101表示协议升级,例如:从HTTP切换到WebSocket。
“队头阻塞”与短连接和长连接无关,而是由 HTTP 基本的“请求——应答”模型所导致的。
因为 HTTP 规定报文必须是“一发一收”,这就形成了一个先进先出的“串行”队列。队列里的请求没有轻重缓急的优先级,只有入队的先后顺序,排在最前面的请求被最优先处理。如果队首的请求因为处理的太慢耽误了时间,那么队列里后面的所有请求也不得不跟着一起等待,结果就是其他的请求承担了不应有的时间成本。
因为“请求——应答”模型不能变,所以“队头阻塞”问题在 HTTP/1.1
里无法解决,只能缓解。在 HTTP 里就是“并发连接”(concurrent connections),也就是同时对一个域名发起多个长连接,用数量来解决质量的问题。
但这种方式也存在缺陷。如果每个客户端都想自己快,建立很多个连接,用户数×并发数
就会是个天文数字。服务器的资源根本就扛不住,或者被服务器认为是恶意攻击,反而会造成“拒绝服务”。所以,HTTP 协议建议客户端使用并发,但不能“滥用”并发。
利用HTTP的长连接特性,对服务发起大量请求,导致服务器最终耗尽资源“拒绝服务”,就是DoS攻击。RFC2616 里明确限制每个客户端最多并发 2 个连接。
不过实践证明这个数字实在是太小,无止境的需求,需要其他办法,这个就是“域名分片”(domain sharding)技术,还是用数量来解决质量的思路。
HTTP 协议和浏览器限制并发连接数量,那就多开几个域名,比如 shard1.chrono.com
、shard2.chrono.com
,而这些域名都指向 www.chrono.com
域名对应的IP地址,这样实际长连接的数量就又上去了。
“超文本”里含有“超链接”,可以从一个“超文本”跳跃到另一个“超文本”,对线性结构的传统文档是一个根本性的变革。
能够使用“超链接”在网络上任意地跳转也是万维网的一个关键特性。它把分散在世界各地的文档连接在一起,形成了复杂的网状结构,用户可以在查看时随意点击链接、转换页面。再加上浏览器又提供了“前进”“后退”“书签”等辅助功能,让用户在文档间跳转时更加方便,有了更多的主动性和交互性。
由浏览器的使用者主动发起的,称为“主动跳转”,还有一类跳转是由服务器来发起的,浏览器使用者无法控制,称为“被动跳转”,这在 HTTP 协议里有个专门的名词,叫做“重定向”(Redirection)。
3××状态码中,301 是“永久重定向”,302 是“临时重定向”,浏览器收到这两个状态码就会跳转到新的 URI,重定向是用户无感知的。头字段“Location: /index.html
”,它就是 301/302 重定向跳转的秘密所在。
“Location
”字段属于响应字段,必须出现在响应报文里。但只有配合 301/302 状态码才有意义,它标记了服务器要求重定向的 URI,这里就是要求浏览器跳转到“index.html”。
浏览器收到 301/302 报文,会检查响应头里有没有“Location
”。如果有,就从字段值里提取出 URI,发出新的 HTTP 请求,相当于自动点击了这个链接。在“Location
”里的 URI 既可以使用绝对 URI,也可以使用相对 URI。
注意,在重定向时如果只是在站内跳转,可以放心地使用相对 URI。但如果要跳转到站外,就必须用绝对 URI。
最常见的重定向状态码就是 301 和 302,另外还有几个不太常见的,例如 303、307、308 等。它们最终的效果都差不多,让浏览器跳转到新的 URI,但语义上有一些细微的差别,使用的时候要特别注意。
在 3×× 里还有:
不过这三个状态码的接受程度较低,有的浏览器和服务器可能不支持,开发时应当慎重,测试确认浏览器的实际效果后才能使用。
重定向报文里还可以用Refresh
字段,实现延时重定向,例如“Refresh: 5; url=xxx
”,告诉浏览器5秒后再跳转。
与跳转有关的字段还有Referer
和Referrer-Policy
,表示浏览器跳转你的来源(即引用地址),可用于统计和防盗链。
301/302重定向是浏览器执行的,对于服务器来说是“外部重定向”,相应的有服务器的“内部重定向”,直接在服务器内部跳转URI,因为不会发出HTTP请求,所以没有额外的性能损失。
可以在服务器端拥有主动权,控制浏览器的行为,使用重定向跳转,核心是要理解“重定向”和“永久/临时”这两个关键词。
什么时候需要重定向。
决定要实行重定向后接下来要考虑的就是“永久”和“临时”的问题了,也就是选择 301 还是 302。
重定向的用途很多,掌握了重定向,就能够在架设网站时获得更多的灵活性,不过在使用时还需要注意两个问题。
HTTP 的 Cookie 机制,就是服务器记不住状态,就在外部想办法记住。相当于是服务器给每个客户端都贴上一张小纸条,上面写了一些只有服务器才能理解的数据,需要的时候客户端把这些信息发给服务器,服务器看到 Cookie,就能够认出对方是谁了。
用到两个字段:响应头字段 Set-Cookie
和请求头字段 Cookie
。
key=value
”,放进 Set-Cookie
字段里,随着响应报文一同发给浏览器Set-Cookie
字段,知道这是服务器给的身份标识,于是就保存起来,下次再请求的时候就自动把这个值放进 Cookie
字段里发给服务器Cookie
字段,服务器就读出 Cookie
的值来识别出用户的身份,然后提供个性化的服务。有时,服务器会在响应头里添加多个 Set-Cookie
,存储多个“key=value
”。浏览器这边发送时不需要用多个 Cookie
字段,只要在一行里用“;
”隔开就行。
Cookie 是由浏览器负责存储的,而不是操作系统。所以,它是“浏览器绑定”的,只能在本浏览器内生效。浏览器对Cookie的数量和大小都有限制,不允许无限存储,一般总大小不能超过4K
。
Cookie 就是服务器委托浏览器存储在客户端里的一些数据,而这些数据通常都会记录用户的关键识别信息。所以,就需要在“key=value
”外再用一些手段来保护,防止外泄或窃取,这些手段就是 Cookie 的属性。
设置 Cookie 的生存周期,就是它的有效期,一旦超过这个期限浏览器就认为是 Cookie 失效,在存储里删除,也不会发送给服务器。Cookie 的有效期可以使用 Expires
和 Max-Age
两个属性来设置。
Expires
”俗称“过期时间”,用的是绝对时间点,可以理解为“截止日期
”(deadline)。Max-Age
”用的是相对时间,单位是秒,浏览器用收到报文的时间点再加上 Max-Age,就可以得到失效的绝对时间。Expires
和 Max-Age
可以同时出现,两者的失效时间可以一致,也可以不一致,但浏览器会优先采用 Max-Age
计算失效期。
如果不制定
Expires
或Max-Age
属性,那么Cookie仅在浏览器运行时有效,一旦浏览器关闭就会失效,这被称为会话Cookie(Session Cookie)或者内存Cookie(in-memory Cookie),在Chrome里过期时间会显示为Session
或N/A
。
设置 Cookie 的作用域,让浏览器仅发送给特定的服务器和 URI,避免被其他网站盗用。
“Domain
”和“Path
”指定了 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 本身不是加密的,浏览器里还是以明文的形式存在。
Chrome 开发者工具是查看 Cookie 的有力工具,在“Network-Cookies”里可以看到单个页面 Cookie 的各种属性,另一个“Application”面板里则能够方便地看到全站的所有 Cookie。
有了 Cookie,服务器就能够保存“状态”,那么应该如何使用 Cookie 呢?
Cookie 最基本的一个用途就是身份识别,保存用户的登录信息,实现会话事务。
比如,你用账号和密码登录某电商,登录成功后网站服务器就会发给浏览器一个 Cookie,内容大概是“name=yourid”,这样就成功地把身份标签贴在了你身上。之后你在网站里随便访问哪件商品的页面,浏览器都会自动把身份 Cookie 发给服务器,所以服务器总会知道你的身份,一方面免去了重复登录的麻烦,另一方面也能够自动记录你的浏览记录和购物下单(在后台数据库或者也用 Cookie),实现了“状态保持”。
Cookie 的另一个常见用途是广告跟踪。
你上网的时候肯定看过很多的广告图片,这些图片背后都是广告商网站(例如 Google),它会“偷偷地”给你贴上 Cookie 小纸条,这样你上其他的网站,别的广告就能用 Cookie 读出你的身份,然后做行为分析,再推给你广告。
这种 Cookie 不是由访问的主站存储的,所以又叫“第三方 Cookie”(third-party cookie)。如果广告商势力很大,广告到处都是,那么就比较“恐怖”了,无论你走到哪里它都会通过 Cookie 认出你来,实现广告“精准打击”。
为了防止滥用 Cookie 搜集用户隐私,互联网组织相继提出了 DNT
(Do Not Track)和 P3P
(Platform for Privacy Preferences Project),但实际作用不大。
缓存(Cache)是计算机领域里的一个重要概念,是优化系统性能的利器。
由于链路漫长,网络时延不可控,浏览器使用 HTTP 获取资源的成本较高。所以,非常有必要把“来之不易”的数据缓存起来,下次再请求的时候尽可能地复用。这样,就可以避免多次请求——应答的通信成本,节约网络带宽,也可以加快响应速度。
实际上,HTTP 传输的每一个环节基本上都会有缓存,非常复杂。
基于“请求——应答”模式的特点,可以大致分为客户端缓存和服务器端缓存,服务器端缓存经常与代理服务“混搭”在一起,客户端就是浏览器的缓存。
服务器标记资源有效期使用的头字段是“Cache-Control
”,里面的值“max-age=30
”就是资源的有效时间,相当于告诉浏览器,“这个页面只能缓存 30 秒,之后就算是过期,不能用。”
因为网络上的数据随时都在变化,不能保证它稍后的一段时间还是原来的样子,所以要加个有效期。
“Cache-Control
”字段里的“max-age
”和Cookie 有点像,都是标记资源的有效期。但必须注意,这里的 max-age
是“生存时间”(又叫“新鲜度”“缓存寿命”,类似 TTL
,Time-To-Live),时间的计算起点是响应报文的创建时刻(即 Date 字段,也就是离开服务器的时刻),而不是客户端收到报文的时刻,也就是说包含了在链路传输过程中所有节点所停留的时间。
“max-age
”是 HTTP 缓存控制最常用的属性,此外在响应报文里还可以用其他的属性来更精确地指示浏览器应该如何使用缓存:
no-store
:不允许缓存,用于某些变化非常频繁的数据,例如秒杀页面;no-cache
:它的字面含义容易与 no-store
搞混,实际的意思并不是不允许缓存,而是可以缓存,但在使用之前必须要去服务器验证是否过期,是否有最新的版本;no-cache
可以理解为max-age=0,must-revalidate
。must-revalidate
:又是一个和 no-cache
相似的词,它的意思是如果缓存不过期就可以继续使用,但过期了如果还想用就必须去服务器验证。除了
Cache-Control
,服务器也可以用Expires
字段来标记资源的有效期,它的形式和Cookie差不多,都属于“过时”的属性,优先级低于Cache-Control
。还有一个历史遗留字段Pragma: no-cache
,除非为了兼容HTTP/1.0
否则不建议使用。
不止服务器可以发“Cache-Control
”头,浏览器也可以发“Cache-Control
”,也就是说请求——应答的双方都可以用这个字段进行缓存控制,互相协商缓存的使用策略。
当点“刷新”按钮的时候,浏览器会在请求头里加一个“Cache-Control: max-age=0
”。
因为
max-age
是“生存时间”,max-age=0
的意思就是生存时间为0,而本地缓存里的数据至少保存了几秒钟,所以浏览器就不会使用缓存,而是向服务器发请求。服务器看到max-age=0
,也就会用一个最新生成的报文回应浏览器。
Ctrl+F5
的“强制刷新”(请求头里的If-Modified-Since
和If-None-Match
会被清空)其实是发了一个“Cache-Control: no-cache
”,含义和“max-age=0
”基本一样,就看后台的服务器怎么理解,通常两者的效果是相同的。
试着点一下浏览器的“前进”“后退”按钮,再看开发者工具,就会惊喜地发现“from disk cache
”的字样,意思是没有发送网络请求,而是读取的磁盘上的缓存。
“前进”“后退”“跳转”这些重定向动作中浏览器只用最基本的请求头,没有“
Cache-Control
”,所以就会检查缓存,直接利用之前的资源,不再进行网络通信。
浏览器用“Cache-Control
”做缓存控制只能是刷新数据,不能很好地利用缓存数据,因为缓存会失效,使用前还必须要去服务器验证是否是最新版。
浏览器可以用两个连续的请求组成“验证动作”:
但这样的两个请求网络成本太高了,所以 HTTP 协议就定义了一系列“If
”开头的“条件请求”字段,专门用来检查验证资源是否过期,把两个请求才能完成的工作合并在一个请求里做。而且,验证的责任也交给服务器,浏览器只需“坐享其成”。
条件请求一共有 5 个头字段,最常用的是“If-Modified-Since
”和“If-None-Match
”这两个。
Last-modified
”和“ETag
”,304 Not Modified
”,表示缓存依然有效,浏览器就可以更新一下有效期,然后放心大胆地使用缓存了。如果响应报文里提供了
Last-modified
,但没有Cache-Control
或Expires
,浏览器会使用启发算法计算一个缓存时间,在RFC中建议的是:(Data - Last-modified) × 10%
。
Last-modified
”就是文件的最后修改时间。ETag
” 是“实体标签”(Entity Tag)的缩写,是资源的一个唯一标识,主要是用来解决修改时间无法准确区分文件变化的问题。比如,一个文件在一秒内修改了多次,但因为修改时间是秒级,所以这一秒内的新版本无法区分。
再如,一个文件定期更新,但有时会是同样的内容,实际上没有变化,用修改时间就会误以为发生了变化,传送给浏览器就会浪费带宽。
使用 ETag
就可以精确地识别资源的变动情况,让浏览器能够更有效地利用缓存。ETag 还有“强”“弱”之分。
每个Web服务器对
ETag
的计算方法都不一样,只要保证数据变化后值不一样就好,复杂的计算会增加服务器的负担。Nginx的算法是修改时间+长度,实际上和Last-modified
基本等价。
ETag
要求资源在字节级别必须完全相符,ETag
在值前有个“W/
”标记,只要求资源在语义上没有变化,但内部可能会有部分发生了改变(例如 HTML 里的标签顺序调整,或者多了几个空格)。打电话给超市,“我这个西瓜是 3 天前买的,还有最新的吗?”。超市看了一下库存,说:“没有啊,我这里都是 3 天前的。”于是你就知道了,再让超市送货也没用,还是吃冰箱里的西瓜吧。这就是“if-Modified-Since”和“Last-modified”。
但你还是想要最新的,就又打电话:“有不是沙瓤的西瓜吗?”,超市告诉你都是沙瓤的(Match),于是你还是只能吃冰箱里的沙瓤西瓜。这就是“If-None-Match”和“弱 ETag”。
第三次打电话,你说“有不是 8 斤的沙瓤西瓜吗?”,这回超市给了你满意的答复:“有个 10 斤的沙瓤西瓜”。于是,你就扔掉了冰箱里的存货,让超市重新送了一个新的大西瓜。这就是“If-None-Match”和“强 ETag”。
为资源增加 ETag
字段,刷新页面时浏览器就会同时发送缓存控制头“max-age=0”和条件请求头“If-None-Match”,如果缓存有效服务器就会返回 304。
条件请求里还有其他的三个头字段是“If-Unmodified-Since
”“If-Match
”和“If-Range
”。
在 HTTP 的“请求——应答”模型,中只有两个互相通信的角色,分别是“请求方”浏览器(客户端)和“应答方”服务器。在这个模型中引入 HTTP 代理。原来简单的双方通信加入了一个或者多个中间人,但整体上还是一个有顺序关系的链条,而且链条里相邻的两个角色仍然是简单的一对一通信,不会出现越级的情况。
链条的起点还是客户端(也就是浏览器),中间的角色被称为代理服务器(proxy server),链条的终点被称为源服务器(origin server),意思是数据的“源头”“起源”。
所谓的“代理服务”就是指服务本身不生产内容,而是处于中间位置转发上下游的请求和响应,具有双重身份:
代理有很多的种类,例如匿名代理、透明代理、正向代理和反向代理。这里主要讲的是实际工作中最常见的反向代理,它在传输链路中更靠近源服务器,为源服务器提供代理服务。
计算机科学领域里的任何问题,都可以通过引入一个中间层来解决”,“如果一个中间层解决不了问题,那就再加一个中间层”。
TCP/IP
协议栈是这样,而代理也是这样。
代理最基本的功能是负载均衡,常用的负载均衡算法,比如轮询、一致性哈希等,这些算法的目标都是尽量把外部的流量合理地分散到多台源服务器,提高系统的整体资源利用率和性能。
在负载均衡的同时,代理服务还可以执行更多的功能,比如:
SSL/TLS
加密通信认证,而在安全的内网不加密,消除加解密成本;代理的好处很多,但因为它“欺上瞒下”的特点,隐藏了真实客户端和服务器,通信双方要如何获得这些“丢失”的原始信息。
首先,代理服务器需要用字段“Via
”标明代理的身份。Via
是一个通用字段,请求头或响应头里都可以出现。每当报文经过一个代理节点,代理服务器就会把自身的信息追加到字段的末尾。
如果通信链路中有很多中间代理,就会在 Via
里形成一个链表,这样就可以知道报文究竟走过了多少个环节才到达了目的地。
例如,下图中有两个代理:proxy1 和 proxy2,客户端发送请求会经过这两个代理,依次添加就是“Via: proxy1, proxy2”,等到服务器返回响应报文的时候就要反过来走,头字段就是“Via: proxy2, proxy1”。
Via
字段(有的服务器响应报文中是X-Via
)只解决了客户端和源服务器判断是否存在代理的问题,还不能知道对方的真实信息。
HTTP 标准里并没有为此定义头字段,但已经出现了很多“事实上的标准”,最常用的两个头字段是“X-Forwarded-For
”和“X-Real-IP
”。
X-Forwarded-For
”的字面意思是“为谁而转发”,形式上和“Via
”差不多,也是每经过一个代理节点就会在字段里追加一个信息。但“Via
”追加的是代理主机名(或者域名),而“X-Forwarded-For
”追加的是请求方的 IP 地址。所以,在字段里最左边的 IP 地址就客户端的地址。X-Real-IP
”是另一种获取客户端真实 IP 的手段,它的作用很简单,就是记录客户端 IP 地址,没有中间的代理信息,相当于是“X-Forwarded-For
”的简化版。如果客户端和源服务器之间只有一个代理,那么这两个字段的值就是相同的。
“X-Forwarded-Host
”和“X-Forwarded-Proto
”,它们的作用与“X-Real-IP
”类似,只记录客户端的信息,分别是客户端请求的原始域名和原始协议名。
有了“X-Forwarded-For
”等头字段,源服务器就可以拿到准确的客户端信息了。但对于代理服务器来说它并不是一个最佳的解决方案。
因为通过“X-Forwarded-For
”操作代理信息必须要解析 HTTP 报文头,这对于代理来说成本比较高,原本只需要简单地转发消息就好,而现在却必须要费力解析数据再修改数据,会降低代理的转发性能。
另一个问题是“X-Forwarded-For
”等头必须要修改原始报文,而有些情况下是不允许甚至不可能的(比如使用 HTTPS 通信被加密)。
所以就出现了一个专门的“代理协议”(The PROXY protocol),它由知名的代理软件 HAProxy 所定义,也是一个“事实标准”,被广泛采用(注意并不是 RFC)。
“代理协议”有 v1 和 v2 两个版本,v1 和 HTTP 差不多,也是明文,而 v2 是二进制格式。
v1 比较好理解的 ,它在 HTTP 报文前增加了一行 ASCII 码文本,相当于又多了一个头。这一行文本其实非常简单,开头必须是“PROXY
”五个大写字母,然后是“TCP4
”或者“TCP6
”,表示客户端的 IP 地址类型,再后面是请求方地址、应答方地址、请求方端口号、应答方端口号,最后用一个回车换行(\r\n)结束。
例如,下面的这个例子,在 GET 请求行前多出了 PROXY 信息行,客户端的真实 IP 地址是“1.1.1.1”,端口号是 55555。
PROXY TCP4 1.1.1.1 2.2.2.2 55555 80\r\n
GET / HTTP/1.1\r\n
Host: www.xxx.com\r\n
\r\n
服务器看到这样的报文,只要解析第一行就可以拿到客户端地址,不需要再去理会后面的 HTTP 数据,省了很多事情。
不过代理协议并不支持“X-Forwarded-For
”的链式地址形式,所以拿到客户端地址后再如何处理就需要代理服务器与后端自行约定。
把HTTP的缓存控制和HTTP的代理服务两者结合起来就是“缓存代理”,也就是支持缓存控制的代理服务。
客户端(浏览器)上的缓存控制,它能够减少响应时间、节约带宽,提升客户端的用户体验。
但 HTTP 传输链路上,不只是客户端有缓存,服务器上的缓存也是非常有价值的,可以让请求不必走完整个后续处理流程,“就近”获得响应结果。
特别是对于那些“读多写少”的数据,例如突发热点新闻、爆款商品的详情页,一秒钟内可能有成千上万次的请求。即使仅仅缓存数秒钟,也能够把巨大的访问流量挡在外面,让 RPS(request per second)降低好几个数量级,减轻应用服务器的并发压力,对性能的改善是非常显著的。
HTTP 的服务器缓存功能主要由代理服务器来实现(即缓存代理),而源服务器系统内部虽然也经常有各种缓存(如 Memcache、Redis、Varnish 等),但与 HTTP 没有太多关系。
在没有缓存的时候,代理服务器每次都是直接转发客户端和服务器的报文,中间不会存储任何数据,只有最简单的中转功能。
加入缓存后,代理服务收到源服务器发来的响应数据后需要做两件事。
下一次再有相同的请求,代理服务器就可以直接发送 304 或者缓存数据,不必再从源服务器那里获取。这样就降低了客户端的等待时间,同时节约了源服务器的网络带宽。
在 HTTP 的缓存体系中,缓存代理的身份十分特殊,
Cache-Control
”属性。Cache-Control
”属性来对它做特别的约束。4 种服务器端的“Cache-Control
”属性:max-age
、no-store
、no-cache
和 must-revalidate
,这 4 种缓存属性可以约束客户端,也可以约束代理。
但客户端和代理是不一样的,客户端的缓存只是用户自己使用,而代理的缓存可能会为非常多的客户端提供服务。所以,需要对它的缓存再多一些限制条件。
首先,要区分客户端上的缓存和代理上的缓存,可以使用两个新属性“private
”和“public
”。
private
”表示缓存只能在客户端保存,是用户“私有”的,不能放在代理上与别人共享。public
”的意思就是缓存完全开放,谁都可以存,谁都可以用。其次,缓存失效后的重新验证也要区分开(即使用条件请求“Last-modified
”和“ETag
”),“must-revalidate
”是只要过期就必须回源服务器验证,而新的“proxy-revalidate
”只要求代理的缓存过期后必须验证,客户端不必回源,只验证到代理这个环节就行了。
再次,缓存的生存时间可以使用新的“s-maxage
”(s 是 share 的意思,注意 maxage 中间没有“-
”),只限定在代理上能够存多久,而客户端仍然使用“max-age
”。
还有一个代理专用的属性“no-transform
”。代理有时候会对缓存下来的数据做一些优化,比如把图片生成 png、webp 等几种格式,方便今后的请求处理,而“no-transform
”就会禁止这样做。
下面的流程图是完整的服务器端缓存控制策略,可以同时控制客户端和代理。
源服务器在设置完“Cache-Control
”后必须要为报文加上“Last-modified
”或“ETag
”字段。否则,客户端和代理后面就无法使用条件请求来验证缓存是否有效,也就不会有 304 缓存重定向。
客户端在 HTTP 缓存体系里要面对的是代理和源服务器,也必须区别对待,如下图所示。
max-age、no-store、no-cache 这三个属性也同样作用于代理和源服务器。
关于缓存的生存时间,多了两个新属性“max-stale
”和“min-fresh
”。
max-stale
”的意思是如果代理上的缓存过期了也可以接受,但不能过期太多,超过 x 秒也会不要。min-fresh
”的意思是缓存必须有效,而且必须在 x 秒后依然有效。有时客户端还会发出一个特别的“only-if-cached
”属性,表示只接受代理缓存的数据,不接受源服务器的响应。如果代理上没有缓存或者缓存过期,就应该给客户端返回一个 504(Gateway Timeout)。
“Vary
”字段,它是内容协商的结果,相当于报文的一个版本标记。同一个请求,经过内容协商后可能会有不同的字符集、编码、浏览器等版本。比如,“Vary: Accept-Encoding
”“Vary: User-Agent
”,缓存代理必须要存储这些不同的版本。当再收到相同的请求时,代理就读取缓存里的“Vary
”,对比请求头里相应的“Accept-Encoding
”“User-Agent
”等字段,如果和上一个请求的完全匹配,比如都是“gzip
”“Chrome
”,就表示版本一致,可以返回缓存的数据。
“Purge
”,也就是“缓存清理”,它对于代理也是非常重要的功能,例如:过期的数据应该及时淘汰,避免占用空间;源站的资源有更新,需要删除旧版本,主动换成最新版(即刷新);有时候会缓存了一些本不该存储的信息,例如网络谣言或者危险链接,必须尽快把它们删除。清理缓存的方法有很多,比较常用的一种做法是使用自定义请求方法“PURGE
”,发给代理服务器,要求删除 URI 对应的缓存数据。