楔子

HTTP 可以说是太常见了,甚至不搞编程的人也都会接触 HTTP,因为只要你用浏览器打开网页,就会发送 HTTP 请求。也正因为 HTTP 的普遍性,使得我们没有专门花时间深刻地去理解它内部的细节,只是简单停留在 请求 / 相应、GET / POST、Header、Body 的层面。

而 HTTP 只是表面上简单,但底层的运行机制、工作原理却并不简单,举几个例子:

  • 用 Nginx 搭建 Web 服务器,但是配置文件里面的指令不知道该怎么用,像 keepalive、rewrite、proxy_pass 之间到底有什么区别,搞不清楚
  • 用 Python 写爬虫,总是遭遇反爬,但却不知道该如何反反爬
  • 都说 HTTP 缓存很有用,可以大幅度的提升性能,可它是怎么做到的?又应该在什么时候使用呢?
  • 尽管知道 HTTPS 是安全的 HTTP,但是它的安全性是如何保证的呢?SSL/TLS/SNI/OCSP/ALPN 等等这些符号又代表什么呢?

下面我们就从零开始来学习 HTTP,并从各个维度来对其进行扩展补充。

HTTP 的前世今生

HTTP 协议在我们的生活中随处可见,只要你上网(不管你用什么工具),背后总会有 HTTP 在默默为你服务。目前全球至少有 16 亿个网站、2 亿多个独立域名,而支撑这个庞大网络世界的底层运转机制就是 HTTP。

那么 HTTP 协议到底是怎么诞生的呢?而且它肯定不是一下子就发展到今天这个样子,那么它最初是什么样子呢?以及它又是如何一步一步发展到今天、几乎统治了整个互联网世界呢?

正所谓:天上飞的理念必有落地的实现,或者说需求是推动一切的生产力。下面就来聊一聊 HTTP 的发展历程、它的历史局限性、以及为什么 HTTP 要被设计成现在这个样子。

史前时期

20 世纪 60 年代,美国国防部高等研究计划署(ARPA)建立了 ARPA 网,它有四个分布在各地的节点,被认为是如今互联网的始祖。

然后在 70 年代,基于对 ARPA 网的实践和思考,研究人员发明出了著名的 TCP / IP 协议。由于具有良好的分层结构和稳定的性能,TCP / IP 协议迅速战胜其他竞争对手流行起来,并在 80 年代中期进入了 UNIX 系统内核,促使更多的计算机接入了互联网。

创世纪

1989 年,任职于欧洲核子研究中心(CERN)的蒂姆·伯纳斯 – 李(Tim Berners-Lee)发表了一篇论文,提出了在互联网上构建超链接文档系统的构想。这篇论文中他确立了三项关键技术:

  • URI:即统一资源标识符,作为互联网上资源的唯一身份
  • HTML:即超文本标记语言,描述超文本文档
  • HTTP:即超文本传输协议,用来传输超文本

这三项技术在如今的我们看来已经是稀松平常,但在当时却是了不得的大发明。基于它们,就可以把超文本系统完美地运行在互联网上,让各地的人们能够自由地共享信息,蒂姆把这个系统称为万维网(World Wide Web),也就是我们现在所熟知的 Web。

所以在这一年,我们的英雄 HTTP 诞生了,从此开始了它伟大的征途。

HTTP/0.9

20 世纪 90 年代初期的互联网世界非常简陋,计算机处理能力低,存储容量小,网速很慢,还是一片信息荒漠。网络上绝大多数的资源都是纯文本,很多通信协议也都使用纯文本,所以 HTTP 的设计也不可避免地受到了时代的限制。

这一时期的 HTTP 被定义为 0.9 版,结构比较简单,为了便于服务器和客户端处理,它也采用了纯文本格式。蒂姆·伯纳斯 – 李最初设想的系统里的文档都是只读的,所以只允许用 GET 动作从服务器上获取 HTML 文档,并且在响应请求之后立即关闭连接,功能非常有限。

HTTP/0.9 虽然很简单,但它作为一个原型,充分验证了 Web 服务的可行性,而简单也正是它的优点,蕴含了进化和扩展的可能性。

把简单的系统变复杂 要比 把复杂的系统变简单 容易的多。

HTTP/1.0

1993 年,NCSA(美国国家超级计算应用中心)开发出了 Mosaic,是第一个可以图文混排的浏览器,随后又在 1995 年开发出了服务器软件 Apache,简化了 HTTP 服务器的搭建工作。

同一时期,计算机多媒体技术也有了新的发展:1992 年发明了 JPEG 图像格式,1995 年发明了 MP3 音乐格式。

这些新软件新技术一经推出立刻就吸引了广大网民的热情,更多的人开始使用互联网,研究 HTTP 并提出改进意见,甚至实验性地往协议里添加各种特性,从用户需求的角度促进了 HTTP 的发展。

于是在这些已有实践的基础上,经过一系列的草案,HTTP/1.0 版本在 1996 年正式发布。它在多方面增强了 0.9 版,形式上已经和我们现在的 HTTP 差别不大了,例如:

  • 增加了 HEAD、POST 等新方法
  • 增加了响应状态码,标记可能的错误原因
  • 引入了协议版本号概念
  • 引入了 HTTP Header(头部) 的概念,让 HTTP 处理请求和响应更加灵活
  • 传输的数据不再仅限于文本

但 HTTP/1.0 并不是一个标准,只是记录已有实践和模式的一份参考文档,不具有实际的约束力,相当于一个备忘录。所以 HTTP/1.0 的发布对于当时正在蓬勃发展的互联网来说并没有太大的实际意义,各方势力仍然按照自己的意图继续在市场上奋力拼杀。

HTTP/1.1

1995 年,网景的 Netscape Navigator 和微软的 Internet Explorer 开始了著名的浏览器大战,都希望在互联网上占据主导地位。但这场战争的结果你一定早就知道了,最终微软的 IE 取得了决定性的胜利,但网景后来凭借 Mozilla Firefox 又扳回一局。

浏览器大战的是非成败我们放在一边暂且不管,不可否认的是,它再一次极大地推动了 Web 的发展,HTTP/1.0 也在这个过程中经受了实践检验。于是在浏览器大战结束之后的 1999 年,HTTP/1.1 发布了 RFC 文档,编号为 2616,正式揭开了将要延续十余年的传奇序幕。

所以 HTTP/1.1 是对 HTTP/1.0 的进一步改进,并且这两者之间还有一个重要的区别:HTTP/1.1 是一个正式的标准,而 HTTP/1.0 是一份可有可无的参考文档。这意味着今后互联网上所有的浏览器、服务器、网关、代理等等,只要用到 HTTP 协议,就必须严格遵守 HTTP/1.1 这个标准,所以它相当于是互联网世界的一个立法。

那么 HTTP/1.1 相对 HTTP/1.0 都做了哪些改进呢?首先表述起来更加的严谨,其次:

  • 增加了 PUT、DELETE 等新的方法
  • 增加了缓存管理和控制
  • 明确了连接管理,允许持久连接
  • 允许响应数据分块(chunked),利于传输大文件
  • 强制要求 Host 头,让互联网主机托管成为可能

HTTP/1.1 的推出可谓是众望所归,互联网在它的保驾护航下迈开了大步,由此走上了康庄大道,开启了后续的 Web 1.0、Web 2.0 时代。现在许多的知名网站都是在这个时间点左右创立的,例如 Google、新浪、搜狐、网易、腾讯等。不过由于 HTTP/1.1 太过庞大和复杂,所以在 2014 年又做了一次修订,原来的一个大文档被拆分成了六份较小的文档,编号为 7230-7235,优化了一些细节,但此外没有任何实质性的改动。

HTTP/2

HTTP/1.1 发布之后,整个互联网世界呈现出了爆发式的增长,度过了十多年的蜜月期,更涌现出了 Facebook、Twitter、淘宝、京东等互联网新贵。

这期间也出现了一些对 HTTP 不满的意见,主要就是连接慢,无法跟上迅猛发展的互联网,但 HTTP/1.1 标准一直岿然不动,无奈之下人们只好发明各式各样的 “小花招” 来缓解这些问题,比如以前常见的切图、JS 合并等网页优化手段。

终于有一天,搜索巨头 Google 忍不住了,决定揭竿而起,那么它是怎么造反的呢?

Google 首先开发了自己的浏览器 Chrome,然后推出了新的 SPDY 协议,并在 Chrome 里应用于自家的服务器,如同十多年前的网景与微软一样,从实际的用户方来倒逼 HTTP 协议进行变革,这也开启了第二次的浏览器大战。历史再次重演,不过这次的胜利者是 Google,Chrome 目前的全球占有率超过了 60%。挟用户以号令天下,Google 借此顺势把 SPDY 推上了标准的宝座,互联网标准化组织以 SPDY 为基础开始制定新版本的 HTTP 协议,最终在 2015 年发布了 HTTP/2,RFC 编号 7540。

HTTP/2 的制定充分考虑了当今互联网的现状:宽带、移动、不安全,并在高度兼容 HTTP/1.1 的同时在性能改善方面做了很大努力,主要的特点有:

  • 二进制协议,不再是纯文本
  • 可发起多个请求,废弃了 1.1 里的管道
  • 使用专用算法压缩头部,减少数据传输量
  • 允许服务器主动向客户端推送数据
  • 增强了安全性,"事实上"要求加密通信

虽然 HTTP/2 发展到现在已经好几年了,也衍生出了 gRPC 等新协议,但由于 HTTP/1.1 实在是太过经典和强势,导致 HTTP/2 目前它的普及率还比较低,大多数网站使用的仍然还是 20 年前的 HTTP/1.1。

HTTP/3

看到这里,你可能会问了:HTTP/2 这么好,是不是就已经完美了呢?

答案是否定的,这一次还是 Google,而且它要革自己的命。在 HTTP/2 还处于草案之时,Google 又发明了一个新的协议,叫做 QUIC,而且还是相同的套路,继续在 Chrome 和自家服务器里使用,依托它的庞大用户量和数据量,持续地推动 QUIC 协议成为互联网上的既成事实。最终功夫不负有心人,当然也是因为 QUIC 确实自身素质过硬。

在 2018 年,互联网标准化组织 IETF 提议将 HTTP over QUIC 更名为 HTTP/3 并获得批准,HTTP/3 正式进入了标准化制订阶段,也许两三年后就会正式发布,到时候我们很可能会跳过 HTTP/2 直接进入 HTTP/3。

小结

HTTP是什么?HTTP又不是什么?

HTTP 我们都知道它是超文本传输协议,也就是 Hyper Text Transfer Protocol 这几个单词的首字母,但面试官肯定不会只问你 HTTP 是什么,他肯定会再追问你一些其它问题:

  • 你是怎么理解 HTTP 字面上的 "超文本" 和 "传输协议" 的?
  • 能否谈一下你对 HTTP 的认识?越多越好
  • HTTP 有什么特点?有什么优点和缺点?
  • HTTP 下层都有哪些协议?是如何工作的?
  • ......

要解决上面的那些问题,我们首先需要明白 HTTP 是什么?

HTTP 是什么?

先看一下 HTTP 的名字:”超文本传输协议”,它可以拆成三个部分,分别是:超文本、传输、协议。我们从后往前来逐个解析,理解了这三个词,我们也就明白了什么是 HTTP。

首先,HTTP 是一个协议,那么协议又是什么呢?简单来讲,协议就是参与者都必须遵守的一组约定,其实协议并不仅限于计算机世界,现实生活中也随处可见。

例如,你在刚毕业时可能会签一个三方协议,找房子时会签一个租房协议,公司入职时还可能会签一个保密协议,工作中使用的各种软件也都带着各自的许可协议。它们和 HTTP 本质上是相同的。

然后是第二部分,传输。计算机和网络世界里有数不清的各种角色:CPU、内存、总线、磁盘、操作系统、浏览器、网关、服务器……。这些角色之间相互通信也必然会有各式各样、五花八门的协议,用处也各不相同,例如广播协议、寻址协议、路由协议、隧道协议、选举协议等等。

而 HTTP 是一个传输协议,所谓的传输(Transfer)很好理解,说白了就是将东西从一点搬到另一点。但是注意,这其中隐含了两个重要信息。

第一点,HTTP 传输是双向的,也就是说有两个最基本的参与者 A 和 B,A 可以发送数据给 B,B 也可以返回数据给 A。通常我们把先发起传输动作的 A 叫做请求方,接收到请求然后进行响应的 B 叫做响应方。比如我们登陆 bilibili 观看动漫,浏览器就是请求方,bilibili 网站就是响应方。双方约定使用 HTTP 协议来通信,于是浏览器把数据发送给网站,网站再把数据返回给浏览器、然后浏览器将其展示在屏幕上,这样我们就能看到有趣的番剧了。

第二点,数据虽然是在 A 和 B 之间传输,但并没有限制只有 A 和 B 这两个角色,中间允许有中转或者接力。也就是说 A 到 B 的传输过程中可以存在任意多个中间人,这些中间人也遵循 HTTP 协议,只要不打扰到基本的数据传输,就可以添加任意的额外功能,例如:安全认证、数据压缩、编码转换等等,从而优化整个传输过程。像我们平时挂的 威批恩 就可以理解为中间人。

最后是第三部分,也就是我们的超文本,不用想也知道它啃腚是客户端和服务端之间传输的数据,但是这个超文本具体代表的含义是什么呢?

所谓文本,就表示 HTTP 传输的不是 TCP/UDP 这些底层协议里被切分的杂乱无章的二进制包(datagram),而是完整的、有意义的数据,可以被浏览器、服务器这样的上层应用程序处理。在互联网早期,文本只是简单的字符文字,但发展到现在,文本的涵义已经被大大地扩展了,图片、音频、视频、甚至是压缩包,在 HTTP 眼里都可以算做是文本。

所谓超文本,就是 “超越了普通文本的文本”,它是文字、图片、音频和视频等的混合体,最关键的是含有超链接,能够从一个超文本跳跃到另一个超文本,形成复杂的非线性、网状的结构关系。

对于超文本,我们最熟悉的就应该是 HTML 了,它本身只是纯文字文件,但内部用很多标签定义了对图片、音频、视频等的链接,再经过浏览器的解释,呈现在我们面前的就是一个含有多种视听信息的页面。

总结:HTTP 指的是超文本传输协议,它代表了客户端和服务端之间传输文字、图片、音频、视频等超文本数据的约定和规范,以及相关的各种控制和错误处理方式。

不要认为超文本只能传输文本,它也可以传输音频、图片等数据。

HTTP 不是什么?

现在我们知道了 HTTP 是什么?那么再来看看 HTTP 不是什么,也就是说 HTTP 不能干什么。

因为 HTTP 是一个协议,是一种计算机通信的规范,所以它不存在单独的实体。它不是浏览器、手机 APP 那样的应用程序,也不是 Windows、Linux 那样的操作系统,更不是 Apache、Nginx、Tomcat 那样的 web 服务器。但 HTTP 又与应用程序、操作系统、Web 服务器密切相关,在它们之间的通信过程中存在,而且是一种动态的存在,是发生在网络连接、传输超文本数据时的一个动态过程。

HTTP 不是互联网

互联网(Internet)是遍布于全球的许多网络互相连接而形成的一个巨大的国际网络,在它上面存放着各式各样的资源,也对应着各式各样的协议,例如超文本资源使用 HTTP,普通文件使用 FTP,电子邮件使用 SMTP 和 POP3 等。

但毫无疑问,HTTP 是构建互联网的一块重要拼图,而且是占比最大的那一块。

HTTP 不是编程语言

编程语言是人与计算机沟通交流所使用的语言,而 HTTP 是计算机与计算机沟通交流的语言,我们无法使用 HTTP 来编程,但可以反过来,用编程语言去实现 HTTP,告诉计算机如何用 HTTP 来与外界通信。

很多流行的编程语言都支持编写 HTTP 相关的服务或应用,例如使用 Python 编写 Web 服务,使用 JavaScript 在前端实现动态页面更新。

HTTP 不是 HTML

这个可能要特别强调一下,千万不要把 HTTP 与 HTML 混为一谈,虽然这两者经常是同时出现。HTML 是超文本的载体,是一种标记语言,使用各种标签描述文字、图片、超链接等资源,并且可以嵌入 CSS、JavaScript 等技术实现复杂的动态效果。单论次数,在互联网上 HTTP 传输最多的可能就是 HTML,但要是论数据量,HTML 可能要往后排了,图片、音频、视频这些类型的资源显然更大。

HTTP 不是一个孤立的协议

HTTP 不是一个能 “独善其身” 的协议,它也是构建在某些协议之上的。在互联网世界里,HTTP 通常跑在 TCP/IP 协议栈之上,依靠 IP 协议实现寻址和路由、TCP 协议实现可靠数据传输、DNS 协议实现域名查找、SSL/TLS 协议实现安全通信(HTTPS)。此外,还有一些协议依赖于 HTTP,例如 WebSocket、HTTPDNS 等。这些协议相互交织,构成了一个协议网,而 HTTP 则处于中心地位。

我们也可以把 HTTP 看成是 “与 HTTP 协议相关的所有应用层技术的总和。”

然后再来一张思维导图,基本上涵盖了与 HTTP 相关的的内容,后续慢慢介绍。

图的左半部分是与 HTTP 相关的各种协议,右半部分是与 HTTP 相关的各种应用技术。

与 HTTP 相关的各种概念

下面我们来了解一下与 HTTP 相关的各种概念和角色,能够在实际工作中清楚它们在链路中的位置和作用,知道发起一个 HTTP 请求会有哪些角色参与,会如何影响请求的处理。

网络世界

根据我们平时的生活经验,我们认为:一张平坦而且一望无际的巨大网络,每一台电脑就是网络上的一个节点,均匀地点缀在这张网上。这样的理解既对,又不对。从抽象的、虚拟的层面来看,网络世界确实是这样的,我们可以从一个节点毫无障碍地访问到另一个节点。

但现实世界的网络却远比这个抽象的模型要复杂得多。实际的互联网是由许许多多个规模略小的网络连接而成的,这些小网络可能是只有几百台电脑的局域网,可能是有几万、几十万台电脑的广域网,可能是用电缆、光纤构成的固定网络,也可能是用基站、热点构成的移动网络等等。

互联网世界更像是由数不清的大小岛屿组成的千岛之国。

互联网的正式名称是 Internet,里面存储着无穷无尽的信息资源,我们通常所说的上网实际上访问的只是互联网的一个子集:万维网(World Wide Web),它基于 HTTP 协议,传输 HTML 等超文本资源,能力也就被限制在 HTTP 协议之内。

互联网上还有许多万维网之外的资源,例如常用的电子邮件、BT 和 Magnet 点对点下载、FTP 文件下载、SSH 安全登录、各种即时通信服务等等,它们需要用各自的专有协议来访问。不过由于 HTTP 协议非常灵活、易于扩展,而且超文本的表述能力很强,所以很多其他原本不属于 HTTP 的资源也可以包装成 HTTP 来访问,这就是我们为什么能够总看到各种网页应用:例如 微信网页版、邮箱网页版。

综合起来看,现在的互联网 90% 以上的部分都被万维网,也就是 HTTP 所覆盖,所以把互联网约等于万维网或 HTTP 应该也不算大错。

浏览器

上网就要用到浏览器,常见的浏览器有 Google 的 Chrome、Mozilla 的 Firefox、Apple 的 Safari、Microsoft 的 IE 和 Edge,还有小众的 Opera 以及国内的各种换壳的极速、安全浏览器。

那么你想过没有,所谓的浏览器到底是个什么东西呢?

浏览器的正式名字叫 Web Browser,顾名思义,就是检索、查看互联网上网页资源的应用程序,名字里的 Web,实际上指的就是 World Wide Web,也就是万维网。

浏览器本质上是一个 HTTP 协议中的请求方,使用 HTTP 协议获取网络上的各种资源。当然,为了让我们更好地检索查看网页,它还集成了很多额外的功能。例如,HTML 排版引擎用来展示页面,JavaScript 引擎用来实现动态化效果,甚至还有开发者工具用来调试网页,以及五花八门的各种插件和扩展。

在 HTTP 协议里,浏览器的角色被称为 User Agent、即用户代理,意思是作为访问者的代理来发起 HTTP 请求。不过在不引起混淆的情况下,我们通常都简单地称之为客户端。

Web 服务器

刚才说的浏览器是 HTTP 里的请求方,那么在协议另一端的应答方(响应方)又是什么呢?

这个你一定也很熟悉,答案就是服务器:Web Server。

Web 服务器是一个很大也很重要的概念,它是 HTTP 协议里响应请求的主体,通常也把控着绝大多数的网络资源,在网络世界里处于强势地位。当我们谈到 Web 服务器时有两个层面的含义:硬件和软件。

硬件含义就是物理形式或云形式的机器,在大多数情况下它可能不是一台服务器,而是利用反向代理、负载均衡等技术组成的庞大集群。但从外界看来,它仍然表现为一台机器,但这个形象是虚拟的。

软件含义的 Web 服务器可能我们更为关心,它就是提供 Web 服务的应用程序(我们使用 Web 框架编写的服务会部署在上面),通常会运行在硬件含义的服务器上。它利用强大的硬件能力响应海量的客户端 HTTP 请求,返回相关信息。

如果是搞  Python 的,那么有人可能分不清 WSGI、uwsgi、uWSGI、Nginx 之间的区别,我们来总结一下:

  • WSGI 的全称是 Web Server Gateway Interface(Web服务器网关接口),它不是服务器、Python 模块、框架、API 或者任何软件,只是一种描述 Web 服务器和 Web 应用程序(使用 Web 框架,如 Django、Flask 编写的程序)进行通信的规范、协议。使用任何一个框架在编写完服务的时候都必须运行在 Web 服务器上,而这些框架本身自带了一个小型 Web 服务器,但只用于开发和测试
  • uWSGI 是一个 Web 服务器,它实现了 WSGI、uwsgi、HTTP 等协议,所以我们把使用 Web 框架编写好的服务部署在 uWSGI 服务器上是可以直接对外提供服务的
  • Nginx 同样是一个 Web 服务器,但它相比 uWSGI 可以提供更多的功能,比如反向代理、负载均衡、缓存静态资源、对 HTTP 请求更加友好,这些都是 uWSGI 所不具备、或者不擅长的。所以我们在将 Web 服务部署在 uWSGI 之后,还要在前面再搭一层 Nginx。此时 uWSGI 就不再暴露 HTTP 服务了,而是暴露 TCP 服务,因为它是和 Nginx 进行通信,Nginx 来对外暴露 HTTP 服务
  • uwsgi 是 Nginx 和 uWSGI 通信所采用的协议,我们说 uWSGI 是和 Nginx 对接,Nginx 接收到用户请求时,如果是请求的是静态资源、那么会直接返回;请求的是动态资源,那么会将请求转发给 uWSGI,然后再由 uWSGI 调用相应的 Web 服务进行处理,处理完毕之后将结果交给 Nginx,Nginx 再返回给客户端。而 uWSGI 和 Nginx 之所以能交互,也正是因为它们都支持 uwsgi 协议,Nginx 中 HttpUwsgiModule 的作用就是与 uWSGI 服务器进行交互。

CDN

浏览器和服务器是 HTTP 协议的两个端点,那么在这两者之间还有别的什么东西吗?很明显是有的,其中的一个重要角色就叫做 CDN。浏览器通常不会直接连到服务器,中间可能会经过重重关卡。

CDN 的全称是 Content Delivery Network,翻译过来就是 “内容分发网络”,它应用了 HTTP 协议里的缓存和代理技术,代替源站响应客户端的请求。说白了就是它可以缓存源站的数据,让浏览器的请求不用千里迢迢地到达源站服务器,直接在半路就可以获取响应。如果 CDN 的调度算法很优秀,更可以找到离用户最近的节点,大幅度缩短响应时间。

打个比方,就好像唐僧西天取经,刚出长安城,就看到阿难与迦叶把佛祖的真经递过来了,是不是很省事?

CDN 也是现在互联网中的一项重要基础设施,除了基本的网络加速外,还提供负载均衡、安全防护、边缘计算、跨运营商网络等功能,能够成倍地放大源站服务器的服务能力,很多云服务商都把 CDN 作为产品的一部分,我们后面也会单独介绍 CDN。

爬虫

前面说到过浏览器,它是一种用户代理,代替我们访问互联网。但 HTTP 协议并没有规定用户代理后面必须是真正的人类,它也完全可以是机器人,这些机器人的正式名称就叫做爬虫(crawler),实际上是一种可以自动访问 Web 资源的应用程序。

爬虫这个名字非常形象,它们就像是一只只不知疲倦的、辛勤的蚂蚁,在无边无际的网络上爬来爬去,不停地在网站间奔走,搜集抓取各种信息。据估计,互联网上至少有 50% 的流量都是由爬虫产生的,某些特定领域的比例还会更高,也就是说,如果你的网站今天的访问量是十万,那么里面至少有五六万是爬虫机器人,而不是真实的用户。

很明显爬虫也有不好的一面,它会过度消耗网络资源,占用服务器和带宽,影响网站对真实数据的分析,甚至导致敏感信息泄漏。所以,又出现了反爬虫技术,通过各种手段来限制爬虫。其中一项就是君子协定 robots.txt,约定哪些该爬,哪些不该爬。但无论是爬虫还是反爬虫,用到的基本技术都是两个,一个是 HTTP,另一个就是 HTML。

HTML/WebService/WAF

最后再来看几个概念。

HTML 是 HTTP 协议传输的主要内容之一,它描述了超文本页面,用各种标签定义文字、图片等资源和排版布局,最终由浏览器渲染出可视化页面。HTML 目前有两个主要的标准,HTML4 和 HTML5。广义上的 HTML 通常是指 HTML、JavaScript、CSS 等前端技术的组合,能够实现比传统静态页面更丰富的动态页面。

接下来是 WebService,它的名字与 Web Server 很像,但却是一个完全不同的东西。WebService 是一种由 W3C 定义的应用服务开发规范,使用 client-server 主从架构,通常使用 WSDL 定义服务接口,使用 HTTP 协议传输 XML 或 SOAP 消息,也就是说,它一个基于 Web(HTTP)的服务架构技术,既可以运行在内网,也可以在适当保护后运行在外网。因为采用了 HTTP 协议传输数据,所以在 WebService 架构里服务器和客户端可以采用不同的操作系统或编程语言开发。

最后是 WAF,这是近几年比较火的一个词,意思是 网络应用防火墙。与硬件防火墙类似,它是应用层面的防火墙,专门检测 HTTP 流量,是防护 Web 应用的安全技术。WAF 通常位于 Web 服务器之前,可以阻止如 SQL 注入、跨站脚本等攻击,目前应用较多的一个开源项目是 ModSecurity,它能够完全集成进 Apache 或 Nginx。

与 HTTP 相关的各种协议

下面来看看和 HTTP 相关的各种协议,重点是 TCP/IP,DNS、URI、HTTPS 等,以及它们和 HTTP 之间的关系。

TCP/IP

TCP/IP 协议是目前网络世界事实上的标准通信协议,即使你没有用过也一定听说过,因为它太著名了。TCP/IP 协议实际上是一系列网络通信协议的统称,其中最核心的两个协议是 TCP 和 IP,其他的还有 UDP、ICMP、ARP 等等,共同构成了一个复杂但有层次的协议栈。在 OSI 网络模型中,TCP 属于传输层,IP 属于网络层。

IP 协议是 Internet Protocol 的缩写,主要目的是解决寻址和路由问题,以及如何在两点间传送数据包。IP 协议使用 IP 地址 的概念来定位互联网上的每一台计算机,可以对比一下现实中的电话系统,你拿着的手机相当于互联网上的计算机,而要打电话就必须接入电话网,由通信公司给你分配一个号码,这个号码就相当于 IP 地址。

现在我们使用的 IP 协议大多数是 v4 版,地址是四个用 . 分隔的数字,例如 192.168.0.1,总共有 2^32 大约 42 亿个可以分配的地址。看上去好像很多,但互联网的快速发展让地址的分配管理很快就捉襟见肘。所以,就又出现了 v6 版,使用 8 组由 : 分隔的数字作为地址,容量扩大了很多,有 2^128 个,号称是能为地球上的每一粒沙子都分配一个地址。

TCP 协议是 Transmission Control Protocol 的缩写,意思是传输控制协议,它位于 IP 协议之上,基于 IP 协议提供可靠的、字节流形式的通信,是 HTTP 协议得以实现的基础。可靠是指保证数据不丢失,字节流是指保证数据完整,所以在 TCP 协议的两端可以如同操作文件一样访问传输的数据,就像是读写一个密闭的管道里流动的字节。

之前我们说过,HTTP 是一个传输协议,但它不关心寻址、路由、数据完整性等传输细节,而要求这些工作都由下层来处理。因为互联网上最流行的是 TCP/IP 协议,而它刚好满足 HTTP 的要求,所以互联网上的 HTTP 协议就运行在了 TCP/IP 上,HTTP 也就可以更准确地称为 HTTP over TCP/IP。

DNS

在 TCP/IP 协议中使用 IP 地址来标识计算机,数字形式的地址对于计算机来说是方便了,但对于人类来说却既难以记忆又难以输入。

于是域名系统(Domain Name System)出现了,用有意义的名字来作为 IP 地址的等价替代。设想一下,你是愿意记 95.211.80.227 这样枯燥的数字,还是 nginx.org 这样的词组呢?在 DNS 中,域名(Domain Name)又称为主机名(Host),为了更好地标记不同国家或组织的主机,让名字更好记,所以被设计成了一个有层次的结构。

域名用 . 分隔成多个单词,级别从左到右逐级升高,最右边的被称为顶级域名。对于顶级域名,可能你随口就能说出几个,例如表示商业公司的 com、表示教育机构的 edu,表示国家的 cn、uk 等,买火车票时的域名还记得吗?是 www.12306.cn 。

但想要使用 TCP/IP 协议来通信仍然要使用 IP 地址,所以需要把域名做一个转换,映射到它的真实 IP,这就是所谓的域名解析。

假设你想给一个叫魔理沙的女孩打电话,但不知道电话号码,就得在手机里的号码簿里一项一项地找,直到找到魔理沙那一条记录,然后才能查到号码。这里的 魔理沙 就相当于域名,而电话号码就相当于 IP 地址,这个查找的过程就是域名解析。

但域名解析的实际操作要比刚才的例子复杂很多,因为互联网上的电脑实在是太多了。目前全世界有 13 组根 DNS 服务器,下面再有许多的顶级 DNS、权威 DNS 和更小的本地 DNS,逐层递归地实现域名查询。HTTP 协议中并没有明确要求必须使用 DNS,但实际上为了方便访问互联网上的 Web 服务器,通常都会使用 DNS 来定位或标记主机名,间接地把 DNS 与 HTTP 绑在了一起。

URI/URL

有了 TCP/IP 和 DNS,是不是我们就可以任意访问网络上的资源了呢?其实还不行,DNS 和 IP 地址只是标记了互联网上的主机,但主机上有那么多文本、图片、页面,到底要找哪一个呢?就像魔理沙管理了一大堆文档,你怎么告诉他是哪个呢?

所以就出现了 URI(Uniform Resource Identifier),中文名称是 统一资源标识符,使用它就能够唯一地标记互联网上资源。URI 另一个更常用的表现形式是 URL(Uniform Resource Locator), 统一资源定位符,也就是我们俗称的网址,它实际上是 URI 的一个子集,不过因为这两者几乎是相同的,差异不大,所以通常不会做严格的区分。

我们就拿 Nginx 网站来举例,看一下 URI 是什么样子的。

http://nginx.org/en/download.html

你可以看到,URI 主要有三个基本的部分构成:

  • 协议名:即访问该资源应当使用的协议,在这里是 http
  • 主机名:即互联网上主机的标记,可以是域名或 IP 地址,在这里是 nginx.org
  • 路径:即资源在主机上的位置,使用 / 分隔多级目录,在这里是 /en/download.html

还是用打电话来做比喻,你通过电话簿找到了魔理沙,让她把魔法店里面扫把用快递寄过来。那么这个过程中你就完成了一次 URI 资源访问,魔理沙 就是 主机名,魔法店里面的扫把 就是 路径,而 快递 就是你要访问这个资源的协议名。

快递://魔理沙/魔法店/扫把

因此我们可以将 DNS 和 这里的 URI 组合起来,首先 URI 中的 IP 地址(域名)标记了服务器在万维网的位置,URI 中的路径标记了该服务器上具体资源的位置,而 DNS 就是负责将 URI 中的域名解析为 IP 地址。

HTTPS

在 TCP/IP、DNS 和 URI 的加持之下,HTTP 协议终于可以自由地穿梭在互联网世界里,顺利地访问任意的网页了。但且慢,互联网上不仅有美女,还有很多的野兽。

假设你打电话找魔理沙要一份广告创意,很不幸,电话被商业间谍给窃听了,他立刻动用种种手段偷窃了你的快递,就在你还在等包裹的时候,他抢先发布了这份广告,给你的公司造成了无形或有形的损失。有没有什么办法能够防止这种情况的发生呢?确实有,你可以使用加密的方法,比如这样打电话:

  • 你:魔理沙,跟你说个事情
  • 魔理沙:好啊,你说
  • 你:嘤嚷嚸嚹嚺嚻嚼嚽嚾嚿啭嗫嚣囃囄冁囆囇呓囊囋囍囎囏囐嘱
  • 魔理沙:噘噙噚噛噜咝噞噟哒噡噢噣噤哝哕噧噩噪噫噬噭噮嗳噰噱哙噳喷噵噶噷吨噺噻噼
  • 你:壁壂壃壄壅壆坛壈壉壊垱壌壍埙壏壐壑壒压壔壕壖壗垒圹垆壛壜壝垄壠壡坜壣壤壥壦壧壨坝塆
  • 魔理沙:嬣嬥嬦嬧嬨嬩嫔嬫嬬奶嬬嬮嬯婴嬱嬲嬳嬴嬵嬶嬷婶嬹嬺嬻嬼嬽嬾嬿

如果你们的对话只有你们能懂,那么即使谈话被窃听,窃听者也不会知道你们到底在说什么,想搞破坏也就无从下手。

HTTPS 就相当于这个例子中的乱七八糟的文字(假设它是基于正常内容加密后的结果),它的全称是 HTTP over SSL/TLS,也就是运行在 SSL/TLS 协议上的 HTTP。注意它的名字,这里是 SSL/TLS,而不是 TCP/IP,它是一个负责加密通信的安全协议,建立在 TCP/IP 之上,所以也是个可靠的传输协议,可以被用作 HTTP 的下层。因为 HTTPS 相当于 HTTP+SSL/TLS+TCP/IP,其中的 HTTP 和 TCP/IP 我们都已经明白了,只要再了解一下 SSL/TLS,HTTPS 也就能够轻松掌握。

SSL 的全称是 Secure Socket Layer,由网景公司发明,当发展到 3.0 时被标准化,改名为 TLS,即 Transport Layer Security,但由于历史的原因还是有很多人称之为 SSL/TLS,或者直接简称为 SSL。SSL 使用了许多密码学最先进的研究成果,综合了对称加密、非对称加密、摘要算法、数字签名、数字证书等技术,能够在不安全的环境中为通信的双方创建出一个秘密的、安全的传输通道,为 HTTP 套上一副坚固的盔甲。

你可以在今后上网时留心看一下浏览器地址栏,如果有一个小锁头标志,那就表明网站启用了安全的 HTTPS 协议,而 URI 里的协议名,也从 http 变成了 https。

代理

代理(Proxy)是 HTTP 协议中请求方和应答方中间的一个环节,作为中转站,既可以转发客户端的请求,也可以转发服务器的应答。代理有很多的种类,常见的有:

  • 匿名代理:完全隐匿了被代理的机器,外界看到的只是代理服务器
  • 透明代理:顾名思义,它在传输过程中是透明开放的,外界既知道代理,也知道客户端
  • 正向代理:靠近客户端,代表客户端向服务器发送请求
  • 反向代理:靠近服务器端,代表服务器响应客户端的请求

之前提到的 CDN,实际上就是一种代理,它代替源站服务器响应客户端的请求,通常扮演着透明代理和反向代理的角色。由于代理在传输过程中插入了一个中间层,所以可以在这个环节做很多有意思的事情,比如:

  • 负载均衡:把访问请求均匀分散到多台机器,实现访问集群化
  • 内容缓存:暂存上下行的数据,减轻后端的压力
  • 安全防护:隐匿 IP, 使用 WAF 等工具抵御网络攻击,保护被代理的机器
  • 数据处理:提供压缩、加密等额外的功能

这里再举个栗子解释一下正向代理和反向代理,假设你想找 B 借一样东西,但是 B 不同意,于是你拜托 A,然后 A 从 B 借、然后再交给你。这里的 A 就扮演了代理的角色,也是正向代理,因为真正找 B 借东西的是 A。如果 A 在找 B 借东西的时候没有说这是你想借的,那么 A 就是匿名代理,因为 B 不知道你的存在;如果 A 告诉了 B,其实是你拜托他来找 B 的,那么 A 就是透明代理,B 知道 A、但同时也知道你。其实我们平常挂的 威批恩 就是正向代理,假设你想访问谷歌惨遭拒绝,于是你可以让 威批恩 去帮你访问,但是对于谷歌而言,向它发请求的是 威批恩、不是你。

至于反向代理也很简单,比如我们访问百度,其背后可能有千千万万台服务器为我们服务,但我们并不知道具体是那一台。但是我们只需要知道反向代理服务器就好了,www.baidu.com 所在的就是代理服务器,它会帮我们把请求转发到真实的服务器那里去。像 Nginx 就是一个非常好的反向代理服务器,可以对背后所有真实的服务器进行一个权衡,将请求转发到一个合适的服务器上,也就是所谓的负载均衡,因为如果某个真实的服务器很繁忙的话,那么就不会转发到它那里去了。再比如你找到老鸨,希望她能提供一个小姐姐为你上门服务,这个老鸨就是反向代理,她会将你的请求转发到某一个小姐姐那里去。

所以正向代理和反向代理都属于代理,而核心区别就在于代理的对象不同:正向代理 代理的是客户端,负责向服务端发送请求;反向代理 代理的是服务端,负责向客户端返回响应。

关于 HTTP 的代理还有一个特殊的代理协议(proxy protocol),它由知名的代理软件 HAProxy 制订,但并不是 RFC 标准,我们后面介绍。

HTTP 一定要基于 TCP/IP 吗

如果使用 UNIX/Linux 操作系统,HTTP 可以运行在本机的 UNIX Domain Socket 上,它是一种进程间的通信机制,但也满足 HTTP 对下层的可靠传输要求,所以就成了 HTTP over UNIX Domain Socket。如果是在 Linux 上跑 Nginx,就可以指定用 HTTP over UNIX Domain Socket,HTTP 并不强制要求下层一定是 TCP/IP。

常说的四层和七层到底是什么?

上面简单提到了 TCP/IP 协议,它是 HTTP 协议的下层协议,负责具体的数据传输工作。并且还特别说了,TCP/IP 协议是一个有层次的协议栈。

在工作中你一定经常听别人谈起什么 四层负载均衡、七层负载均衡,什么 二层转发、三层路由,那么你真正理解这些层次的含义吗?下面我们就从 HTTP 应用的角度,来把这些模糊的概念弄清楚。

TCP/IP 网络分层模型

还是先从 TCP/IP 协议开始讲起,一是因为它非常经典,二是因为它是目前事实上的网络通信标准,研究它的实用价值最大。TCP/IP 当初的设计者真的是非常聪明,创造性地提出了分层的概念,把复杂的网络通信划分出多个层次,再给每一个层次分配不同的职责,层次内只专心做自己的事情就好,用 分而治之 的思想把一个大麻烦拆分成了数个小麻烦,从而解决了网络通信的难题。

TCP/IP 协议总共有四层,就像搭积木一样,每一层需要下层的支撑,同时又支撑着上层,任何一层被抽掉都可能会导致整个协议栈坍塌。我们来仔细地看一下这个精巧的积木架构,注意它的层次顺序是从下往上数的,所以第一层就是最下面的一层。

第一层叫数据链路层,负责在以太网、WiFi 这样的底层网络上发送原始数据包,工作在网卡这个层次,使用 MAC 地址来标记网络上的设备,所以有时候也叫 MAC 层。并且在这一层会通过 ARP 协议和 RARP 协议实现 MAC 地址和 IP 地址之间的转换。

MAC 地址也称为局域网地址,可以唯一地标识一个网卡,同时也就标识了此网卡所属的设备。

第二层叫网络层,IP 协议就处在这一层。因为 IP 协议定义了 IP 地址的概念,所以就能在链路层的基础上,用 IP 地址取代 MAC 地址,把许许多多的局域网、广域网连接成一个虚拟的巨大网络,在这个网络里找设备时只要把 IP 地址再翻译成 MAC 地址就可以了。

第三层叫传输层,这个层次协议的职责是保证数据在 IP 地址标记的两点之间可靠地传输,是 TCP 协议工作的层次,另外还有它的一个小伙伴UDP。

TCP 是一个有状态的协议,需要先与对方建立连接然后才能发送数据,而且保证数据不丢失不重复。而 UDP 则比较简单,它无状态,不用事先建立连接就可以任意发送数据,但不保证数据一定会发到对方。两个协议的另一个重要区别在于数据的形式:TCP 的数据是连续的字节流,有先后顺序,而 UDP 则是分散的小数据包,是顺序发,乱序收。

第四层叫应用层,由于下面的三层把基础打得非常好,所以在这一层就百花齐放了,有各种面向具体应用的协议。例如 Telnet、SSH、FTP、SMTP 等等,当然还有我们的 HTTP。

补充:MAC 层的传输单位是帧(frame),IP 层的传输单位是包(packet),TCP 层的传输单位是段(segment),HTTP 的传输单位则是消息或报文(message)。但这些名词并没有什么本质的区分,可以统称为数据包。

OSI 网络分层模型

然后来看看 OSI 网络分层模型,全称是:开放式系统互联通信参考模型(Open System Interconnection Reference Model)。

TCP/IP 发明于 1970 年代,当时除了它还有很多其他的网络协议,整个网络世界比较混乱。这个时候国际标准组织(ISO)注意到了这种现象,感觉野路子太多,就想要来个大一统。于是设计出了一个新的网络分层模型,想用这个新框架来统一既存的各种网络协议。OSI 模型分成了七层,部分层次与 TCP/IP 很像,从下到上分别是:

  • 第一层:物理层,网络的物理形式,例如电缆、光纤、网卡、集线器等等
  • 第二层:数据链路层,它基本相当于 TCP/IP 的数据链路层
  • 第三层:网络层,相当于 TCP/IP 的网络层
  • 第四层:传输层,相当于 TCP/IP 的传输层
  • 第五层:会话层,维护网络中的连接状态,即保持会话和同步
  • 第六层:表示层,把数据转换为合适、可理解的语法和语义
  • 第七层:应用层,面向具体的应用传输数据

至此,我们常说的四层、七层就出现了。不过国际标准组织心里也很清楚,TCP/IP 等协议已经在许多网络上实际运行,再推翻重来是不可能的。所以,OSI 分层模型在发布的时候就明确地表明是一个参考,不是强制标准,意思就是说:你们以后该干什么还干什么,我不管,但面子上还是要按照我说的来。

但 OSI 模型也是有优点的。对比一下就可以看出,TCP/IP 是一个纯软件的栈,没有网络应有的最根基的电缆、网卡等物理设备的位置。而 OSI 则补足了这个缺失,在理论层面上描述网络更加完整。综合以上几点,在 OSI 模型之后,四层、七层这样的说法就逐渐流行开了。不过在实际工作中你一定要注意,这种说法只是理论上的层次,并不是与现实完全对应。

两个分层模型的映射关系

现在我们有了两个网络分层模型:TCP/IP 和 OSI,新的问题又出现了,一个是四层模型,一个是七层模型,这两者应该如何互相映射或者说互相解释呢?好在 OSI 在设计之初就参考了 TCP/IP 等多个协议,可以比较容易但不是很精确地实现对应关系。

  • OSI 第一层,TCP/IP 里无对应
  • OSI 第二层,对应 TCP/IP 里的第一层
  • OSI 第三层,对应 TCP/IP 里的第二层
  • OSI 第四层,对应 TCP/IP 里的第三层
  • OSI 第五、六、七层,对应 TCP/IP 里的第四层

其实 OSI 的分层模型在四层以上分的太细,而 TCP/IP 实际应用时的会话管理、编码转换、压缩等和具体应用经常联系的很紧密,很难分开。例如,HTTP 协议就同时包含了连接管理和数据格式定义。因此有时也把 OSI 七层模型叫做 OSI 五层模型,也就是把 应用层、表示层、会话层 统一起来,整体叫做应用层。到这里,我们应该能够明白一开始那些某某层的概念了:

  • 所谓的 "四层负载均衡" 就是指工作在传输层上,基于 TCP/IP 协议的特性,例如 IP 地址、端口号等实现对后端服务器的负载均衡
  • 所谓的 "七层负载均衡" 就是指工作在应用层上,看到的是 HTTP 协议,解析 HTTP 报文里的 URI、主机名、资源类型等数据,再用适当的策略转发给后端服务器

TCP/IP 协议栈的工作方式

TCP/IP 协议栈是如何工作的呢?我们以发送快递为例,假设你想把一封信交给位于魔法森林里的少女魔理沙,那么这封信就相当于 HTTP 协议里要传输的内容。然后 HTTP 协议会为它添加一个 HTTP 头,就好比套上了一个信封。

然后你把信交给了快递员,快递员为了保护信不受破损、又套上了一层包装并贴了个标签,相当于在 TCP 层给数据再次打包,加上了 TCP 头。接着快递员把包裹放进了三轮车里,运到集散点,然后再装进更大的卡车里,相当于在 IP 层、MAC 层对 TCP 数据包加上了 IP 头、MAC 头。

之后经过漫长的运输,包裹到达目的地,要卸货再放进另一位快递员的三轮车,就是在 IP 层、MAC 层传输后拆包。快递员到了魔理沙的家门口,撕掉标签(话说现实中需要你自己撕),去除了 TCP 层的头,然后魔理沙再拆掉信封,也就是 HTTP 头,最后就拿到了信。

这个比喻里省略了很多 TCP/IP 协议里的细节,比如建连、路由、数据切分与重组、错误检查等,但核心的数据传输过程是差不多的。

HTTP 协议的传输过程就是这样通过协议栈逐层向下,每一层都添加本层的专有数据,层层打包,然后通过下层发送出去。接收数据是则是相反的操作,从下往上穿过协议栈,逐层拆包,每层去掉本层的专有头,上层就会拿到自己的数据。但下层的传输过程对于上层是完全透明的,上层也不需要关心下层的具体实现细节,所以就 HTTP 层次来看,它不管下层是不是 TCP/IP 协议,看到的只是一个可靠的传输链路,只要把数据加上自己的头,对方就能原样收到。

小问题

问题:解释一下二层转发和三层路由

二层转发:设备工作在链路层,数据帧在经过设备(例如交换机)时,检查帧的头部信息,拿到目标 MAC 地址,进行本地转发和广播。

三层路由:设备工作在 IP 层,报文经过有路由功能的设备(例如路由器)时,设备分析报文中的头部信息,拿到 IP 地址,根据网段范围,进行本地转发或选择下一个网关。

问题:DNS 协议位于哪一层?CDN 工作在哪一层?

有一个很好的记忆方式,在 OSI 七层模型中,凡是操作系统处理的都是第四层或第四层以下,凡是需要有应用程序(你写的代码)负责处理的都是第七层、也就是应用层。由此可以推出它们都是应用层。

当然,这个结论也不是绝对的,比如 dpdk 就把 tcp 协议栈都拿到了操作系统外实现,但绝大多数情况下传输层以下都由操作系统负责。因此你可以认为上面的记忆方式在 99% 的情况下都是成立的,不成立的情况很难遇到。

域名里面都有哪些门道?

我们上面了解了 HTTP 协议使用的 TCP/IP 协议栈,知道了 HTTP 协议是运行在 TCP/IP 上的。IP 协议的职责是网际互联,它在 MAC 层之上,使用 IP 地址把 MAC 编号转换成了用 . 分隔的四组数字,这就对物理网卡的 MAC 地址做了一层抽象,发展出了许多的新玩法。

例如 IP 地址分为 A、B、C、D、E 五种类型,还有公有地址、私有地址、子网掩码等等。只要每个小网络在 IP 地址这个概念上达成一致,不管它在 MAC 层有多大的差异,都可以接入 TCP/IP 协议栈,最终汇合进整个互联网。但接入互联网的计算机越来越多,IP 地址的缺点也就暴露出来了,最主要的是它对人不友好,虽然比 MAC 的 16 进制数要好一点,但还是难于记忆和输入。

而解决这个问题的办法就是以其人之道还治其人之身,在 IP 地址之上再来一次抽象,把数字形式的 IP 地址转换成更有意义更好记的名字,在字符串的层面上再增加新玩法。于是,DNS 域名系统就这么出现了。

域名的形式

我们说域名是一个有层次的结构,是一串用 . 分隔的多个单词,最右边的被称为顶级域名,然后是二级域名,层级关系向左依次降低。以 www.baidu.com 为例,首先它的域名是 baidu.com,然后:

  • 里面的 com 是顶级域名、或者一级域名,当然除了 com 之外也有 cn、net、org 等等
  • 里面的 baidu 是二级域名,关于二级域名也有人认为 baidu.com 整体是二级域名,不过个人从维基百科上专门查了一下,上面有这样一句话: "例如在域名 example.com 中,二级域名就是 example",所以这里个人就简单认为二级域名不包含顶级域名的内容,同理三级、四级域名也是一样的。比如有的网站是:www.xxx.com.cn 这种形式,那么 cn 就是顶级域名、com 就是二级域名、xxx 就是三级域名
  • www 表示子域名,用来表示访问万维网服务。子域名还可以是 mail 表示邮件服务、ftp 表示文件下载服务等等,像一些大型网站就包含很多的子域名。而访问时通过 www 就表示我们要访问的是万维网服务、不是邮件服务、也不是文件下载服务。只不过现在我们即便不加 www,默认也是万维网服务,因为基本上 99.99% 都是它,所以即使不加也默认是它。你可以随便访问一个熟悉的网站,不管你加不加 www,都能进入指定的网站。

域名不仅能够代替 IP 地址,还有许多其他的用途。在 Apache、Nginx 这样的 Web 服务器里,域名可以用来标识虚拟主机,决定由哪个虚拟主机来对外提供服务,比如在 Nginx 里就会使用 server_name 指令:

server {
    listen 80;                       # 监听 80 端口
    server_name  time.geekbang.org;  # 主机名是 time.geekbang.org
    ...
}

域名本质上还是个名字空间系统,使用多级域名就可以划分出不同的国家、地区、组织、公司、部门,每个域名都是独一无二的,可以作为一种身份的标识。

域名的解析

就像 IP 地址必须转换成 MAC 地址才能访问主机一样,域名也必须要转换成 IP 地址,这个过程就是域名解析。

目前全世界有几亿个站点,有几十亿网民,而每天网络上发生的 HTTP 流量更是天文数字。这些请求绝大多数都是基于域名来访问网站的,所以 DNS 就成了互联网的重要基础设施,必须要保证域名解析稳定可靠、快速高效。DNS 的核心系统是一个三层的树状、分布式服务,基本对应域名的结构:

  • 根域名服务器(Root DNS Server):管理顶级域名服务器,返回 com、net、cn 等顶级域名服务器的 IP 地址
  • 顶级域名服务器(Top-level DNS Server):管理各自域名下的权威域名服务器,比如 com 顶级域名服务器可以返回 apple.com 域名服务器的 IP 地址
  • 权威域名服务器(Authoritative DNS Server):管理自己域名下主机的 IP 地址,比如 apple.com 权威域名服务器可以返回 www.apple.com 的 IP 地址

在这里根域名服务器是关键,它必须是众所周知的,否则下面的各级服务器就无从谈起了。目前全世界共有 13 组根域名服务器,又有数百台的镜像,保证一定能够被访问到。有了这个系统以后,任何一个域名都可以在这个树形结构里从顶至下进行查询,就好像是把域名从右到左顺序走了一遍,最终就获得了域名对应的 IP 地址。例如,你要访问 www.apple.com ,就要进行下面的三次查询:

  • 访问根域名服务器,它会告诉你 com 顶级域名服务器的地址
  • 访问 com 顶级域名服务器,它再告诉你 apple.com 域名服务器的地址
  • 最后访问 apple.com 域名服务器,就得到了 www.apple.com 的地址

虽然核心的 DNS 系统遍布全球,服务能力很强也很稳定,但如果全世界的网民都往这个系统里挤,即使不挤瘫痪了,访问速度也会很慢。所以在核心 DNS 系统之外,还有两种手段用来减轻域名解析的压力,并且能够更快地获取结果,基本思路就是缓存。

首先,许多大公司、网络运行商都会建立自己的 DNS 服务器,作为用户 DNS 查询的代理,代替用户访问核心 DNS 系统。这些野生服务器被称为非权威域名服务器,可以缓存之前的查询结果,如果已经有了记录,就无需再向根服务器发起查询,直接返回对应的 IP 地址。

这些 DNS 服务器的数量要比核心系统的服务器多很多,而且大多部署在离用户很近的地方。比较知名的 DNS 有 Google 的 8.8.8.8,Microsoft 的 4.2.2.1,还有 CloudFlare 的 1.1.1.1 等等。其次,操作系统里也会对 DNS 解析结果做缓存,如果你之前访问过 www.apple.com ,那么下一次在浏览器里再输入这个网址的时候就不会再跑到 DNS 那里去问了,直接在操作系统里就可以拿到 IP 地址。

另外,操作系统里还有一个特殊的 主机映射 文件,通常是一个可编辑的文本,在 Linux 里是 /etc/hosts,在 Windows 里是 C:\WINDOWS\system32\drivers\etc\hosts,如果操作系统在缓存里找不到 DNS 记录,就会找这个文件。

有了上面的野生 DNS 服务器、操作系统缓存和 hosts 文件后,很多域名解析的工作就都不用跋山涉水了,直接在本地或本机就能解决,不仅方便了用户,也减轻了各级 DNS 服务器的压力,效率就大大提升了。

在 Nginx 里有这么一条配置指令 resolver,它就是用来配置 DNS 服务器的,如果没有它,那么 Nginx 就无法查询域名对应的 IP,也就无法反向代理到外部的网站。

resolver 8.8.8.8 valid=30s;  # 指定 Google 的 DNS,缓存 30 秒

对了,不要忘记浏览器缓存,所以最终的顺序是:浏览器缓存 -> 操作系统缓存 -> hosts 文件 -> DNS。

域名的新玩法

有了域名,又有了可以稳定工作的解析系统,于是我们就可以实现比 IP 地址更多的新玩法了。

第一种,也是最简单的:重定向。因为域名代替了 IP 地址,所以可以让对外服务的域名不变,而主机的 IP 地址任意变动。当主机有情况需要下线、迁移时,可以更改 DNS 记录,让域名指向其他的机器。

比如,你有一台 buy.tv 的服务器要临时停机维护,那你就可以通知 DNS 服务器:我这个 buy.tv 域名的地址变了啊,原先是 1.2.3.4,现在是 5.6.7.8,麻烦你改一下。于是 DNS 就修改内部的 IP 地址映射关系,之后再有访问 buy.tv 的请求就不走 1.2.3.4 这台主机,改由 5.6.7.8 来处理,这样就可以保证业务服务不中断。

第二种,因为域名是一个名字空间,所以可以使用 bind9 等开源软件搭建一个在内部使用的 DNS,作为名字服务器。这样我们开发的各种内部服务就都用域名来标记,比如数据库服务都用域名 mysql.inner.app,商品服务都用 goods.inner.app,发起网络通信时也就不必再使用写死的 IP 地址了,可以直接用域名,而且这种方式也兼具了第一种玩法的优势。

第三种玩法包含了前两种,也就是基于域名实现的负载均衡。这种玩法也有两种方式,两种方式可以混用:

  • 第一种方式,因为域名解析可以返回多个 IP 地址,所以一个域名可以对应多台主机,客户端收到多个 IP 地址后,就可以自己使用轮询算法依次向服务器发起请求,实现负载均衡
  • 第二种方式,域名解析可以配置内部的策略,返回离客户端最近的主机,或者返回当前服务质量最好的主机,这样在 DNS 端把请求分发到不同的服务器,实现负载均衡

前面我们说的都是可信的 DNS,如果有一些不怀好意的 DNS,那么它也可以在域名这方面做手脚,弄一些比较恶意的玩法,举两个例子:

  • 域名屏蔽,对域名直接不解析,返回错误,让你无法拿到 IP 地址,也就无法访问网站
  • 域名劫持,也叫域名污染,你要访问 A 网站,但 DNS 给了你 B 网站

好在互联网上还是好人多,而且 DNS 又是互联网的基础设施,这些恶意的 DNS 并不多见,你上网的时候不需要太过担心。

补充

早期的域名系统只支持英文,而且顶级域名被限制在三个字符以内,但随着互联网的发展,这些限制已被解除。可以使用中文做域名,除了 com、net、gov、cn 等等之外还增加了 asia、media、museum 等新类别的顶级域名。

域名的总长度限制在 253 个字符以内,而且每一级的域名长度不能超过 63 个字符。

域名是大小写无关的,但通常都使用小写的形式。

域名过长或者层次过多也会导致与 IP 地址一样难记忆,所以常见的域名大多都是二级( www.baidu.com )或 三级( www.xxx.com.cn ),四级以及以上的很少见。

HTTP报文是什么样子的?

我们知道 HTTP 协议基本工作流程就是 请求 / 应答、或者 发送 / 接收 的模式,所以 HTTP 的工作模式是非常简单的,由于 TCP/IP 协议负责底层的具体传输工作,HTTP 协议基本上不用在这方面操心太多。单从这一点上来看,所谓的超文本传输协议其实并不怎么管传输的事情,有点名不副实。那么 HTTP 协议的核心部分是什么呢?答案就是它传输的报文内容。

HTTP 协议在规范文档里详细定义了报文的格式,规定了组成部分,解析规则,还有处理策略,所以可以在 TCP/IP 层之上实现更灵活丰富的功能,例如连接控制,缓存管理、数据编码、内容协商等等。

报文结构

你也许对 TCP/UDP 的报文格式有所了解,拿 TCP 报文来举例,它在实际要传输的数据之前附加了一个 20 字节的头部数据,存储 TCP 协议必须的额外信息,例如发送方的端口号、接收方的端口号、包序号、标志位等等。有了这个附加的 TCP 头,数据包才能够正确传输,到了目的地后把头部去掉,就可以拿到真正的数据。

HTTP 协议也是与 TCP/UDP 类似,同样也需要在实际传输的数据前附加一些头数据,不过与 TCP/UDP 不同的是,它是一个 纯文本 的协议,所以头数据都是 ASCII 码的文本,可以很容易地用肉眼阅读,不用借助程序解析也能够看懂。HTTP 协议的请求报文和响应报文的结构基本相同,由三大部分组成:

  • 起始行(start line):描述请求或响应的基本信息
  • 头部字段(header):使用 key-value 形式更详细地说明报文
  • 消息正文(entity/body):实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据

其中起始行(start line)和头部字段(header)整体被称为请求头(响应头);消息正文被称为实体,或者请求体(响应体),英文是 body。HTTP 协议规定报文必须有请求头,但可以没有请求体,而且在请求头和请求体之间必须要有一个空行,也就是 CRLF,十六进制的 0D0A。即使报文没有请求体,最后一个空行也必须要有。

所以,一个完整的 HTTP 报文就像是下图的这个样子,注意在请求头(或者说里面的 header)和 请求体body 之间有一个空行。

我们随便访问一个网站,看看它的 HTTP 报文是怎么样的?

GET / HTTP/1.1
Accept: application/json, text/javascript, */*; q=0.01
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
Cookie: BIDUPSID=E26A422803F5BFE47D1D780162E8D110; PSTM=1564896524; BD_UPN=12314753;...
Host: www.baidu.com
Referer: https://www.baidu.com/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36

第一行的 GET / HTTP/1.1 就是请求行,表示你要通过 GET 请求根路径,如果你想访问的是 /v1/path,那么请求行就写 GET /v1/path HTTP/1.1 即可。但是光有这些还不够,你要访问哪个服务器呢?显然它通过 header 中的 Host 来指定,请求行下面的就是 header,以键值对的方式存在。

注意:header 下面要有一个空行,然后空行下面写 body,由于我们这里是 GET 请求,所以没有 body。我们使用 Python 的 socket 给百度发请求:

import socket

client = socket.socket()
# 建立 TCP 连接
client.connect(("www.baidu.com", 80))
# 发送请求,HTTP 基于 TCP,所以传输是交给 TCP 的,HTTP 只是负责报文内容
# 因此如果你懂得 HTTP 的报文,那么你完全可以使用 TCP 来实现 HTTP 请求
client.send(
    b"GET / HTTP/1.1\r\nHost: www.baidu.com\r\nConnection: close\r\n\r\n"
)
# 首先 GET / HTTP/1.1 表示我要访问 / 路径
# 然后 \r\n 进行换行,开始写 header,header 里面的每个键值对也是用换行符分隔的,当然我们这里只写了两个
# 由于 header 和 body 之间还有一个空行,所以 header 的最后一个键值对后面要写两个换行,即使没有 body 也必须要写
# 如果有 body,那么就在后面继续写 body 的内容

content = client.recv(4096)
print("百度一下".encode("utf-8") in content)  # True

在很多时候,特别是浏览器发送 GET 请求的时候都是这样,HTTP 报文经常是只有请求头而没有请求体。另外这个请求头(里面的 header)不能太大,虽然 HTTP 协议对请求头的大小没有做限制,但各个 Web 服务器都不允许过大的请求头,因为头部太大可能会占用大量的服务器资源,影响运行效率。

下面我们来逐步介绍报文里面的每一个组成部分。

请求行

了解了 HTTP 报文的基本结构后,我们来看看请求报文里的起始行、也就是请求行(request line),它简要地描述了客户端想要如何操作服务器端的资源

请求行由三部分构成:

  • 请求方法:是一个动词,如 GET/POST,表示对资源的操作
  • 请求目标:通常是一个 URI,标记了请求方法要操作的资源
  • 版本号:表示报文使用的 HTTP 协议版本

这三个部分通常使用空格(space)来分隔,最后要用 CRLF 换行表示结束。

GET / HTTP/1.1 为例,在这个请求行里,GET 是请求方法,/ 是请求目标,HTTP/1.1 是版本号,把这三部分连起来,意思就是:服务器你好,我想获取网站根目录下的默认文件,我用的协议版本号是 1.1,请不要用 1.0 或者 2.0 回复我。

看完了请求行,我们再看响应报文里的起始行。请求报文的起始行叫请求行,那么响应报文的起始行是不是叫响应行呢?答案不是的,在这里它不叫响应行,而是叫状态行(status line),意思是服务器响应的状态。比起请求行来说,状态行要简单一些,同样也是由三部分构成:

  • 版本号:表示报文使用的 HTTP 协议版本
  • 状态码:一个三位数,用代码的形式表示处理的结果,比如 200 是成功,500 是服务器错误
  • 原因:作为数字状态码补充,是更详细的解释文字,帮助人理解原因

如果正常返回,那么一般是:HTTP/1.1 200 OK,意思就是:浏览器你好,我已经处理完了你的请求,这个报文使用的协议版本号是 1.1,状态码是 200,一切 OK。

如果是资源不存在,那么一般是:HTTP/1.1 404 NOT FOUND,翻译成人话就是:抱歉啊浏览器,刚才你的请求收到了,但我没找到你要的资源,错误代码是 404,接下来的事情你就自己看着办吧。

头部字段

请求行或状态行再加上头部字段集合就构成了 HTTP 报文里完整的请求头或响应头:

请求头和响应头的结构是基本一样的,唯一的区别是起始行,所以这里把请求头和响应头里的字段放在一起介绍。头部字段是 key-value 的形式,key 和 value 之间用 : 分隔,最后用 CRLF 换行表示字段结束。比如在 Host: www.baidu.com 这一行里 key 就是 Host,value 就是 www.baidu.com 。当然别忘记 header 后面的换行,如果再和请求体或响应体组合起来,就得到了请求报文或响应报文。

HTTP 头字段非常灵活,不仅可以使用标准里的 Host、Connection 等已有头,也可以任意添加自定义头,这就给 HTTP 协议带来了无限的扩展可能。不过使用头字段需要注意下面几点:

  • 字段名不区分大小写,例如 Host 也可以写成 host,但首字母大写的可读性更好
  • 字段名里不允许出现空格,可以使用连字符 -,但不能使用下划线 _。例如,test-name 是合法的字段名,而 test name、test_name 是不正确的字段名
  • 字段名后面必须紧接着 :,不能有空格,而 : 后的字段值前可以有多个空格
  • 字段的顺序是没有意义的,可以任意排列不影响语义
  • 字段原则上不能重复,除非这个字段本身的语义允许,例如 Set-Cookie
  • 字段不一定要全部出现,可以只出现一部分

再用 Python 演示一下:

import socket

client = socket.socket()
client.connect(("www.baidu.com", 80))

client.send(
    b"GET / HTTP/1.1\r\nHost: www.baidu.com\r\nConnection: close\r\n\r\n"
)

content = client.recv(4096)
print(content.decode("utf-8"))
"""
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: no-cache
Content-Length: 14615
Content-Type: text/html
Date: Thu, 04 Mar 2021 04:04:13 GMT
P3p: CP=" OTI DSP COR IVA OUR IND COM "
P3p: CP=" OTI DSP COR IVA OUR IND COM "
Pragma: no-cache
Server: BWS/1.1
Set-Cookie: BAIDUID=7B6D687032A628D1171D61D840ECB404:FG=1; expires=Thu, 31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com
Set-Cookie: BIDUPSID=7B6D687032A628D1171D61D840ECB404; expires=Thu, 31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com
Set-Cookie: PSTM=1614830653; expires=Thu, 31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com
Set-Cookie: BAIDUID=7B6D687032A628D19B861327C75ACEF6:FG=1; max-age=31536000; expires=Fri, 04-Mar-22 04:04:13 GMT; domain=.baidu.com; path=/; version=1; comment=bd
Traceid: 161483065303579026026727986241446110370
Vary: Accept-Encoding
X-Ua-Compatible: IE=Edge,chrome=1
Connection: close

<!DOCTYPE html><!--STATUS OK-->
<html>
<head>
	<meta http-equiv="content-type" content="text/html;charset=utf-8">
	<meta http-equiv="X-UA-Compatible" content="IE=Edge">
	<link rel="dns-prefetch" href="//s1.bdstatic.com"/>
	<link rel="dns-prefetch" href="//t1.baidu.com"/>
	<link rel="dns-prefetch" href="//t2.baidu.com"/>
	<link rel="dns-prefetch" href="//t3.baidu.com"/>
	<link rel="dns-prefetch" href="//t10.baidu.com"/>
	<link rel="dns-prefetch" href="//t11.baidu.com"/>
	<link rel="dns-prefetch" href="//t12.baidu.com"/>
	<link rel="dns-prefetch" href="//b1.bdstatic.com"/>
	<title>百度一下,你就知道</title>
	......
	......
	......
"""

我们看一下返回的内容,跟我们之前说的是一样的,第一行是状态行、下面是 header,header 下面是一个空行,最后空行下面是响应体。

了解 HTTP 报文结构之后,我们也就能理解一些发送请求的网络库是怎么做的了。我们使用 Python 的 requests 发请求,得到 response,然后会将里面的内容分成两部分:相当于按照 \r\n\r\n 进行分隔,拿到请求头和请求体。然后把请求头中的头部字段解析成字典放在 headers 属性中,通过 response.headers 获取,请求体则是通过 response.content 获取;至于 response.text 和 response.json() 实际上就是根据 response.content 解析得到的。所以后续,我们完全可以自己实现一个简易版的 requests。

常用头字段

下面我们来介绍常用头字段,header 里面的字段非常多,可以实现各种各样的功能,但基本上可以分为四大类:

  • 通用字段:在请求头和响应头里都可以出现
  • 请求字段:仅能出现在请求头里,进一步说明请求信息或者额外的附加条件
  • 响应字段:仅能出现在响应头里,补充说明响应报文的信息
  • 实体字段:它实际上属于通用字段,但专门描述 body 的额外信息

对 HTTP 报文的解析和处理实际上主要就是对头字段的处理,理解了头字段也就理解了 HTTP 报文。先来介绍几个最基本的头,看完了它们就应该能够读懂大多数 HTTP 报文了。

首先要说的是 Host 字段,它属于请求字段,只能出现在请求头里,它同时也是唯一一个 HTTP/1.1 规范里要求必须出现的字段,也就是说,如果请求头里没有 Host,那这就是一个错误的报文,因为 Host 字段告诉服务器这个请求应该由哪个主机来处理。

User-Agent 是请求字段,只出现在请求头里。它使用一个字符串来描述发起 HTTP 请求的客户端,服务器可以依据它来返回最合适此浏览器显示的页面。但由于历史的原因,User-Agent 非常混乱,每个浏览器都自称是 Mozilla、Chrome、Safari,企图使用这个字段来互相伪装,导致 User-Agent 变得越来越长,最终变得毫无意义。不过有的比较诚实的爬虫会在 User-Agent 里标明自己是爬虫,所以可以利用这个字段实现简单的反爬虫策略,但是我们可以自定制 User-Agent 来避免被反爬。

Date 字段是一个通用字段,但通常出现在响应头里,表示 HTTP 报文创建的时间,客户端可以使用这个时间再搭配其他字段决定缓存策略。

Server 字段是响应字段,只能出现在响应头里。它告诉客户端当前正在提供 Web 服务的软件名称和版本号,比如 Nginx,百度是 BWS,阿里是 TEngine。Server 字段也不是必须要出现的,因为这会把服务器的一部分信息暴露给外界,如果这个版本恰好存在 bug,那么黑客就有可能利用 bug 攻陷服务器。所以,有的网站响应头里要么没有这个字段,要么就给出一个完全无关的描述信息。

实体字段里要说的一个是 Content-Length,它表示报文里 body 的长度,也就是请求头或响应头空行后面数据的长度。服务器看到这个字段,就知道了后续有多少数据,可以直接接收。如果没有这个字段,那么 body 就是不定长的,需要使用 chunked 方式分段传输。

补充

在 Nginx 中默认的请求头大小不能超过 8k,但可以使用指令 large_client_header_buffers 修改。

默认情况下 Nginx 是不允许头字段里使用 _ 的,但是可以通过配置指令 underscores_in_headers on 来解除限制,不过不推荐,因为最好还是按照标准来。

与 Server 类似的一个响应字段是 X-Powered-By,它是非标准字段,表示服务器使用的编程语言。

问题

如果拼 HTTP 报文的时候,在头字段后多加了一个空行,会发生什么?

很简单,我们说:HTTP 报文 = 起始行 + 头部字段 + 空行 + 请求体,如果多输入一个空行,那么很明显它会被当成请求体的一部分。

介绍 header 的时候,我们说 : 后面的空格可以都多个,那为什么我们只使用一个呢?

用多个会浪费资源,当然也可以一个都不用,只不过用一个已经是约定俗成的习惯了。

应该如何理解请求方法

上面说了 HTTP 的报文结构,它是由请求头 + 请求体构成,请求头里的起始行包含了请求方法和请求目标,响应头的起始行里有状态码和原因短语,下面要说的就是请求头里的请求方法。

标准请求方法

HTTP 协议里为什么要有 请求方法 这个东西呢?这就要从 HTTP 协议设计时的定位说起了。还记得吗?蒂姆·伯纳斯 – 李最初设想的是要用 HTTP 协议构建一个超链接文档系统,使用 URI 来定位这些文档,也就是资源。那么,该怎么在协议里操作这些资源呢?

很显然,需要有某种动作的指示,告诉操作这些资源的方式。所以,就这么出现了请求方法。它的实际含义就是客户端发出了一个动作指令,要求服务器端对 URI 定位的资源执行这个动作。

目前 HTTP/1.1 规定了八种方法,单词都必须是大写的形式,先简单地列把它们列出来,后面再详细讲解。

  • GET:获取资源,可以理解为读取或者下载数据
  • HEAD:获取资源的元信息
  • POST:向资源提交数据,相当于写入或上传数据
  • PUT:类似 POST
  • DELETE:删除资源
  • CONNECT:建立特殊的连接隧道
  • OPTIONS:列出可对资源实行的方法
  • TRACE:追踪请求 - 响应的传输路径

看看这些方法,是不是有点像对文件或数据库的增删改查操作,只不过这些动作操作的目标不是本地资源,而是远程服务器上的资源,所以只能由客户端请求或者指示服务器来完成。既然请求方法是一个指示,那么客户端自然就没有决定权,服务器掌控着所有资源,也就有绝对的决策权力。它收到 HTTP 请求报文后,看到里面的请求方法,可以执行也可以拒绝,或者改变动作的含义,毕竟 HTTP 是一个协议,两边都要商量着来。

比如,你发起了一个 GET 请求,想获取 /orders 这个文件,但这个文件保密级别比较高,不是谁都能看的,服务器就可以有如下的几种响应方式:

  • 假装这个文件不存在,直接返回一个 404 Not found 报文
  • 稍微友好一点,明确告诉你有这个文件,但不允许访问,返回一个 403 Forbidden
  • 再宽松一些,返回 405 Method Not Allowed,然后用 Allow 头告诉你可以用 HEAD 方法获取文件的元信息

虽然 HTTP/1.1 里规定了八种请求方法,但只有前四个是比较常用的,所以我们先来看一下这四个方法。

GET/HEAD

GET 方法应该是 HTTP 协议里最知名的请求方法了,也应该是用的最多的,自 0.9 版出现并一直被保留至今,是名副其实的元老。它的含义是请求 “从服务器获取资源”,这个资源既可以是静态的文本、页面、图片、视频,也可以是由 Go、Python 动态生成的页面或者其他格式的数据。GET 方法虽然基本动作比较简单,但搭配 URI 和其他头字段就能实现对资源更精细的操作。例如,在 URI 后使用 #,就可以在获取页面后直接定位到某个标签所在的位置;使用 If-Modified-Since 字段就变成了有条件的请求,仅当资源被修改时才会执行获取动作;使用 Range 字段就是范围请求,只获取资源的一部分数据。

HEAD 方法与 GET 方法类似,也是请求从服务器获取资源,服务器的处理机制也是一样的,但服务器不会返回请求的实体数据,只会传回响应头,也就是资源的元信息。HEAD 方法可以看做是 GET 方法的一个简化版或者轻量版。因为它的响应头与 GET 完全相同,所以可以用在很多并不真正需要资源的场合,避免传输 body 数据的浪费。比如,想要检查一个文件是否存在,只要发个 HEAD 请求就可以了,没有必要用 GET 把整个文件都取下来。再比如,要检查文件是否有最新版本,同样也应该用 HEAD,服务器会在响应头里把文件的修改时间传回来。

POST/PUT

接下来要说的是 POST 和 PUT 方法,这两个方法也很像。

GET 和 HEAD 方法是从服务器获取数据,而 POST 和 PUT 方法则是相反操作,向 URI 指定的资源提交数据,数据就放在报文的 body 里。

POST 也是一个经常用到的请求方法,使用频率应该是仅次于 GET,应用的场景也非常多,只要向服务器发送数据,用的大多数都是 POST。比如,你上论坛灌水,敲了一堆字后点击发帖按钮,浏览器就执行了一次 POST 请求,把你的文字放进报文的 body 里,然后拼好 POST 请求头,通过 TCP 协议发给服务器。又比如,你上购物网站,看到了一件心仪的商品,点击加入购物车,这时也会有 POST 请求,浏览器会把商品 ID 发给服务器,服务器再把 ID 写入你的购物车相关的数据库记录。

PUT 的作用与 POST 类似,也可以向服务器提交数据,但与 POST 存在微妙的不同,通常 POST 表示的是新建(create)的含义,而 PUT 则是修改(update)的含义。在实际应用中,PUT 用到的比较少。而且,因为它与 POST 的语义、功能太过近似,有的服务器甚至就直接禁止使用 PUT 方法,只用 POST 方法上传数据。

注意:HTTP 是一个协议,我们主要理解它的含义,不一定要强制遵守。比如:很多网站都是通过 POST 请求来获取资源的。

其他方法

介绍完了 GET/HEAD/POST/PUT,还剩下四个标准请求方法,它们属于比较冷僻的方法,应用的不是很多。

DELETE 方法指示服务器删除资源,因为这个动作危险性太大,所以通常服务器不会执行真正的删除操作,而是对资源做一个删除标记。当然,更多的时候服务器就直接不处理 DELETE 请求。

CONNECT 是一个比较特殊的方法,要求服务器为客户端和另一台远程服务器建立一条特殊的连接隧道,这时 Web 服务器在中间充当了代理的角色。

OPTIONS 方法要求服务器列出可对资源实行的操作方法,在响应头的 Allow 字段里返回。它的功能很有限,用处也不大,有的服务器(例如 Nginx)干脆就没有实现对它的支持。但是在 Nginx 中,可以通过配置指令、自定义模块或 Lua 脚本实现。

TRACE 方法多用于对 HTTP 链路的测试或诊断,可以显示出请求 – 响应的传输路径。它的本意是好的,但存在漏洞,会泄漏网站的信息,所以 Web 服务器通常也是禁止使用。

扩展方法

虽然 HTTP/1.1 里规定了八种请求方法,但它并没有限制我们只能用这八种方法,这也体现了 HTTP 协议良好的扩展性,我们可以任意添加请求动作,只要请求方和响应方都能理解就行。

此外,还有一些得到了实际应用的请求方法,例如 MKCOL、COPY、MOVE、LOCK、UNLOCK、PATCH 等。如果有合适的场景,你也可以把它们应用到自己的系统里,比如用 LOCK 方法锁定资源暂时不允许修改,或者使用 PATCH 方法给资源打个小补丁,部分更新数据。但因为这些方法是非标准的,所以需要为客户端和服务器编写额外的代码才能添加支持。当然了,你也完全可以根据实际需求,自己发明新的方法,比如 PULL 拉取某些资源到本地,PURGE 清理某个目录下的所有缓存数据。

安全与幂等

关于请求方法还有两个面试时有可能会问到、比较重要的概念:安全与幂等。

在 HTTP 协议里,所谓的安全是指请求方法不会破坏服务器上的资源,即不会对服务器上的资源造成实质的修改。按照这个定义,只有 GET 和 HEAD 方法是安全的,因为它们是只读操作,只要服务器不故意曲解请求方法的处理方式,无论 GET 和 HEAD 操作多少次,服务器上的数据都是安全的。而 POST/PUT/DELETE 操作会修改服务器上的资源,增加或删除数据,所以是不安全的。

所谓的幂等实际上是一个数学用语,被借用到了 HTTP 协议里,意思是多次执行相同的操作,结果也都是相同的,即多次幂操作后结果不变。很显然,GET 和 HEAD 既是安全的也是幂等的,DELETE 可以多次删除同一个资源,效果都是资源不存在,所以也是幂等的。

POST 和 PUT 的幂等性质就略费解一点。按照 RFC 里的语义,POST 是新增或提交数据,多次提交数据会创建多个资源,所以不是幂等的;而 PUT 是替换或更新数据,多次更新一个资源,资源还是会第一次更新的状态,所以是幂等的。我们可以对比一下 SQL 来加深理解:把 POST 理解成 INSERT,把 PUT 理解成 UPDATE,这样就很清楚了。多次 INSERT 会添加多条记录,而多次 UPDATE 只操作一条记录,而且效果相同。

响应状态码该怎么用?

我们已经学习了 HTTP 报文里请求行的组成部分,包括请求方法和 URI。有了请求行,加上后面的头字段就形成了请求头,可以通过 TCP/IP 协议发送给服务器。服务器收到请求报文,解析后需要进行处理,具体的业务逻辑多种多样,但最后必定是拼出一个响应报文发回客户端。响应报文由响应头加响应体数据组成,响应头又由状态行和头字段构成。而我们说状态行有三部分:

开头的 Version 部分是 HTTP 协议的版本号,通常是 HTTP/1.1,用处不是很大。后面的 Reason 部分是原因短语,是状态码的简短文字描述,例如 OK、Not Found 等等,也可以自定义。但它只是为了兼容早期的文本客户端而存在,提供的信息很有限,目前的大多数客户端都会忽略它。所以,状态行里有用的就只剩下中间的状态码(Status Code)了。它是一个十进制数字,以代码的形式表示服务器对请求的处理结果,就像我们通常编写程序时函数返回的错误码一样。

不过你要注意,它的名字是状态码、而不是错误码。也就是说,它的含义不仅是错误,更重要的意义在于表达 HTTP 数据处理的状态,客户端可以依据代码适时转换处理状态,例如继续发送请求、切换协议,重定向跳转等,有那么点 TCP 状态转换的意思。

状态码

目前 RFC 标准里规定的状态码是三位数,所以取值范围就是从 000 到 999。但如果把代码简单地从 000 开始顺序编下去就显得有点太 low,不灵活、不利于扩展,所以状态码也被设计成有一定的格式。RFC 标准把状态码分成了五类,用数字的第一位表示分类,而 0~99 不用,这样状态码的实际可用范围就大大缩小了,由 000~999 变成了 100~599。这五类的具体含义是:

1xx

1xx 类状态码属于提示信息,是协议处理中的一种中间状态,实际用到的比较少。

2xx

2xx 类状态码表示服务器成功处理了客户端的请求,也是我们最愿意看到的状态。

  • 「200 OK」是最常见的成功状态码,表示一切正常。如果是非 HEAD 请求,服务器返回的响应头都会有 body 数据
  • 「204 No Content」也是常见的成功状态码,与 200 OK 基本相同,但响应头没有 body 数据
  • 「206 Partial Content」是应用于 HTTP 分块下载或断电续传,表示响应返回的 body 数据并不是资源的全部,而是其中的一部分,也是服务器处理成功的状态

3xx

3xx 类状态码表示客户端请求的资源发送了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是重定向。

  • 「301 Moved Permanently」表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问
  • 「302 Found」表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问
  • 「301 和 302 都会在响应头里使用字段 Location,指明后续要跳转的 URL,浏览器会自动重定向新的 URL
  • 「304 Not Modified」不具有跳转的含义,表示资源未修改,重定向已存在的缓冲文件,也称缓存重定向,用于缓存控制

4xx

4xx 类状态码表示客户端发送的报文有误,服务器无法处理,也就是错误码的含义。

  • 「400 Bad Request」表示客户端请求的报文有错误,但只是个笼统的错误
  • 「403 Forbidden」表示服务器禁止访问资源,并不是客户端的请求出错
  • 「404 Not Found」表示请求的资源在服务器上不存在或未找到,所以无法提供给客户端

5xx

4xx 类状态码表示客户端请求报文正确,但是服务器处理时内部发生了错误,属于服务器端的错误码。

  • 「500 Internal Server Error」与 400 类型,是个笼统通用的错误码,服务器发生了什么错误,我们并不知道
  • 「501 Not Implemented」表示客户端请求的功能还不支持,类似 "即将开业,敬请期待" 的意思
  • 「502 Bad Gateway」通常是服务器作为网关或代理时返回的错误码,表示服务器自身工作正常,访问后端服务器发生了错误
  • 「503 Service Unavailable」表示服务器当前很忙,暂时无法响应服务器,类似 "网络服务正忙,请稍后重试" 的意思

在 HTTP 协议中,正确地理解并应用这些状态码不是客户端或服务器单方的责任,而是双方共同的责任。客户端作为请求的发起方,获取响应报文后,需要通过状态码知道请求是否被正确处理,是否要再次发送请求,如果出错了原因又是什么。这样才能进行下一步的动作,要么发送新请求,要么改正错误重发请求。服务器端作为请求的接收方,也应该很好地运用状态码。在处理请求时,选择最恰当的状态码回复客户端,告知客户端处理的结果,指示客户端下一步应该如何行动。特别是在出错的时候,尽量不要简单地返 400、500 这样意思含糊不清的状态码。

HTTP有哪些优点?又有哪些缺点?

下面来看看 HTTP 的优缺点,不过要事先说明,我们讨论的都是 HTTP/1.1,后续会介绍的 HTTPS 和 HTTP/2 都是对 HTTP/1.1 优点的发挥和缺点的完善。

优点:简单、灵活、易于扩展

首先,HTTP 最重要也是最突出的优点是:简单、灵活、易于扩展,因为它的报文格式就决定它难不倒哪里去。所以在简单这个最基本的设计理念之下,HTTP 协议又多出了灵活和易于扩展的优点。灵活和易于扩展实际上是一体的,它们互为表里、相互促进,因为灵活所以才会易于扩展,而易于扩展又反过来让 HTTP 更加灵活,拥有更强的表现能力。HTTP 协议里的请求方法、URI、状态码、原因短语、头字段等每一个核心组成要素都没有被写死,允许开发者任意定制、扩充或解释,缺什么功能自己加个字段或者错误码什么的补上就是了。此外,灵活、易于扩展的特性还表现在 HTTP 对可靠传输的定义上,它不限制具体的下层协议,不仅可以使用 TCP、UNIX Domain Socket,还可以使用 SSL/TLS,甚至是基于 UDP 的 QUIC,下层可以随意变化,而上层的语义则始终保持稳定。

优点:应用广泛、环境成熟

HTTP 协议的另一大优点是应用广泛,软硬件环境都非常成熟。随着互联网特别是移动互联网的普及,HTTP 的触角已经延伸到了世界的每一个角落:从简单的 Web 页面到复杂的 JSON、XML 数据,从台式机上的浏览器到手机上的各种 APP,你很难找到一个没有使用 HTTP 的地方。

不仅在应用领域,在开发领域 HTTP 协议也得到了广泛的支持。它并不限定某种编程语言或者操作系统,所以天然具有跨语言、跨平台的优越性。而且,因为本身的简单特性很容易实现,所以几乎所有的编程语言都有 HTTP 调用库和外围的开发测试工具。HTTP 广泛应用的背后还有许多硬件基础设施支持,各个互联网公司和传统行业公司都不遗余力地触网,购买服务器开办网站,建设数据中心、CDN 和高速光纤,持续地优化上网体验,让 HTTP 运行的越来越顺畅。应用广泛的这个优点也就决定了:无论是创业者还是求职者,无论是做网站服务器还是写应用客户端,HTTP 协议都是必须要掌握的基本技能。

无状态

看过了两个优点,我们再来看看一把双刃剑,也就是无状态,它对于 HTTP 来说既是优点也是缺点。

好处:因为服务器没有记忆能力,所以就不需要额外的资源来记录状态信息,不仅实现上会简单一些,而且还能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。而且无状态也表示服务器都是相同的,没有状态的差异,所以可以很容易地组成集群,让负载均衡把请求转发到任意一台服务器,不会因为状态不一致导致处理出错,使用堆机器的方式可以轻松实现高并发高可用。

坏处:既然服务器没有记忆能力,它就无法支持需要连续多个步骤的 “事务” 操作。例如电商购物,首先要登录,然后添加购物车,再下单、结算、支付,这一系列操作都需要知道用户的身份才行,但无状态服务器是不知道这些请求是相互关联的,每次都得问一遍身份信息,不仅麻烦,而且还增加了不必要的数据传输量。

所以,HTTP 协议最好是既 “无状态” 又 “有状态”,不过还真有鱼和熊掌两者兼得这样的好事,这就是 Cookie 技术(后面说)。

缺点:明文

明文意味着在传输过程中的信息,是可方便阅读的,通过浏览器的 F12 控制台或 Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于信息裸奔。在传输的漫长的过程中,不怀好意的人只要侵入了这个链路里的某个设备,就可以实现对通信的窥视,信息的内容都毫无隐私可言,很容易就能被窃取,如果里面有你的账号密码信息,那么可能会导致:你号没了。

比如免费 WiFi 陷阱,黑客就是利用了 HTTP 明文传输的缺点,在公共场所架设一个 WiFi 热点开始钓鱼,诱骗网民上网。一旦你连上了这个 WiFi 热点,所有的流量都会被截获保存,里面如果有银行卡号、网站密码等敏感信息的话那就危险了,黑客拿到了这些数据就可以冒充你为所欲为。

缺点:不安全

与明文缺点相关但不完全等同的另一个缺点是不安全,安全有很多的方面,明文只是其中之一,在身份认证和完整性校验这两方面 HTTP 也是欠缺的。

  • 通信使用明文(不加密),内容可能会被窃听。比如,账号信息容易泄漏,那你号没了
  • 不验证通信方的身份,因此有可能遭遇伪装。比如,访问假的淘宝、拼多多,那你钱没了
  • 无法证明报文的完整性,所以有可能已遭篡改。比如,网页上植入垃圾广告,视觉污染,眼没了

虽然银行可以用 MD5、SHA1 等算法给报文加上数字摘要,但还是因为明文这个致命缺点,黑客可以连同摘要一同修改,最终还是判断不出报文是否被窜改。HTTP 的安全问题,可以用 HTTPS 的方式解决,也就是通过引入 SSL/TLS 层,使得在安全上达到了极致。(后面说)

性能

最后我们来谈谈 HTTP 的性能,可以用六个字来概括:不算差,不够好。HTTP 协议基于 TCP/IP,并且使用了请求 – 应答的通信模式,所以性能的关键就在这两点上。

必须要说的是,TCP 的性能是不差的,否则也不会纵横互联网江湖四十余载了,而且它已经被研究的很透,集成在操作系统内核里经过了细致的优化,足以应付大多数的场景。

1. 长连接

早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP 连接(三次握手),而且是串行请求,做了无畏的 TCP 连接建立和断开,增加了通信开销。为了解决上述 TCP 连接问题,HTTP/1.1 提出了长连接的通信方式,也叫持久连接。这种方式的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。

2. 管道网络传输

HTTP/1.1 采用了长连接的方式,这使得管道(pipeline)网络传输成为了可能。可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。管道机制则是允许浏览器同时发出 A 请求和 B 请求。但是服务器还是按照顺序,先回应 A 请求,完成后再回应 B 请求。要是前面的回应特别慢,后面就会有许多请求排队等着。这称为「队头堵塞」

3. 队头阻塞

「请求 – 响应」的模式加剧了 HTTP 的性能问题,因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一同被阻塞了,会招致客户端一直请求不到数据,这也就是「队头阻塞」,好比上班的路上塞车。

可以看出, HTTP/1.1 的性能是一般般的,后续的 HTTP/2 和 HTTP/3 就是在优化 HTTP/1.1 的性能。

虽然 HTTP 免不了这样那样的缺点,但别忘了它有一个最重要的灵活可扩展的优点,所有的缺点都可以在这个基础上想办法解决,我们后面慢慢说。

补充

出于安全的原因,绝大部分网站都封禁了 80、443 意外的端口号,只允许 HTTP 协议穿透,这也是 HTTP 流行的一个体现之一。

HTTP/1.1 以文本格式传输 header,有严重的数据冗余,也影响了它的性能。

HTTP 的实体数据

我们上面介绍了 HTTP 报文的结构,知道一个 HTTP 报文是由 header + body 组成的。但那时我们主要研究的是 header,没有涉及到 body,下面我们就来聊一聊。

数据类型与编码

在 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 里经常遇到的几个类别:

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

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

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

数据类型使用的头字段

有了 MIME type 和 Encoding type,无论是浏览器还是服务器就都可以轻松识别出 body 的类型,也就能够正确处理数据了。HTTP 协议为此定义了 Accept 头字段和 Content 头字段,用于客户端和服务器进行内容协商。也就是说,客户端用 Accept 头告诉服务器希望接收什么样的数据,而服务器用 Content 头告诉客户端实际发送了什么样的数据。

----------- 请求头 -----------
GET / HTTP/1.1
Host: www.example.com
Accept: text/html, application/xml, image/webp, image/png
Accept-Encoding: gzip, deflate, br
......
......

----------- 响应头 -----------
HTTP/1.1 200 OK
Content-Type: text/html
Content-Encoding: gzip
......
......

Accept 字段标记的是客户端可理解的 MIME type,可以用 , 做分隔符列出多个类型,表示我只接受这几种类型的文件,让服务器返回自己能看得懂的文件类型。相应的,服务器会在响应报文里用头字段 Content-Type 告诉实体数据的真实类型,如果浏览器看到报文里的类型是 text/html 就知道是 HTML 文件,会调用排版引擎渲染出页面,看到 image/png 就知道是一个 PNG 文件,就会在页面上显示出图像。

当然 Content-Type 是一个通用字段,不仅可以出现在响应头中,也可以出现在请求头中(有的字段只能出现在请求头中、有的则只能出现在响应头中)。有的时候我们在上传文件时,需要指定文件类型,这个时候就需要通过 Content-Type 字段指定(以及字符集)。比如云存储,现在很多小文件都往云上存放了,所以一定要指定正确的 Content-Type,不然出错的话就会需要批量修改,而且还会影响终端的解析。

Accept-Encoding 字段标记的是客户端支持的压缩格式,例如上面说的 gzip、deflate 等,同样也可以用 , 列出多个,服务器可以选择其中一种来压缩数据。至于服务端实际使用的是哪一种压缩格式,则通过响应头字段 Content-Encoding 体现。

不过 Accept-Encoding 和 Content-Encoding 这两个字段是可以省略的,如果请求报文里没有 Accept-Encoding 字段,就表示客户端不支持压缩数据;如果响应报文里没有 Content-Encoding 字段,就表示响应数据没有被压缩。

语言类型与编码

MIME type 和 Encoding type 解决了计算机理解 body 数据的问题,但互联网遍布全球,不同国家不同地区的人使用了很多不同的语言,虽然都是 text/html,但如何让浏览器显示出每个人都可理解可阅读的语言文字呢?这实际上就是国际化的问题。HTTP 采用了与数据类型相似的解决方案,又引入了两个概念:语言类型与字符集。所谓的语言类型就是人类使用的自然语言,例如英语、汉语、日语等,而这些自然语言可能还有下属的地区性方言,所以在需要明确区分的时候也要使用 type-subtype 的形式,不过这里的格式与数据类型不同,分隔符不是 /,而是 -

举几个例子:en 表示任意的英语,en-US 表示美式英语,en-GB 表示英式英语,而 zh-CN 就表示我们最常使用的汉语。

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

语言类型使用的头字段

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

Accept-Language 字段标记了客户端可理解的自然语言,也允许用 , 做分隔符列出多个类型,例如:

Accept-Language: zh-CN, zh, en

这个请求头会告诉服务器:最好给我 zh-CN 的汉语文字,如果没有就用其他的汉语方言,如果还没有就给英文。

相应的,服务器应该在响应报文里用头字段 Content-Language 告诉客户端实体数据使用的实际语言类型:

Content-Language: zh-CN

同理还有字符集,字符集在 HTTP 里使用的请求头字段是 Accept-Charset,表示能接收的字符集种类。

Accept-Charset: gbk, utf-8

但响应头里却没有对应的 Content-Charset,对于响应头来说,字符集体现在 Content-Type 中。通过在后面加上 ;charset=xxx 来表示,这点需要特别注意。

Content-Type: text/html; charset=utf-8

不过现在的浏览器都支持多种字符集,通常不会发送 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。这里要提醒的是 ; 的用法,在大多数编程语言里 ; 的断句语气要强于 ,,而在 HTTP 的内容协商里却恰好反了过来,; 的意义是小于 , 的。例如下面的 Accept 字段:

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

这个 Vary 字段表示服务器依据了 Accept-Encoding、User-Agent 和 Accept 这三个头字段,然后决定了发回的响应报文。Vary 字段可以认为是响应报文的一个特殊的版本标记,每当 Accept 等请求头变化时,Vary 也会随着响应报文一起变化。也就是说,同一个 URI 可能会有多个不同的版本,主要用在传输链路中间的代理服务器实现缓存服务,这个之后在介绍 HTTP 缓存的时候还会再提到。

HTTP传输大文件的方法

上面我们谈到了 HTTP 报文里的 body,知道了 HTTP 可以传输很多种类的数据,不仅是文本,也能传输图片、音频和视频。早期互联网上传输的基本上都是只有几 K 大小的文本和小图片,现在的情况则大有不同。网页里包含的信息实在是太多了,随随便便一个主页 HTML 就有可能上百 K,高质量的图片都以 M 论,更不要说那些电影、电视剧了,几 G、几十 G 都有可能。相比之下,100M 的光纤固网或者 4G 移动网络在这些大文件的压力下都变成了小水管,无论是上传还是下载,都会把网络传输链路挤的满满当当。所以如何在有限的带宽下高效快捷地传输这些大文件就成了一个重要的课题,下面我们就一起看看 HTTP 协议里有哪些手段能解决这个问题。

数据压缩

首先我们想到的就是数据压缩,通常浏览器在发送请求时都会带着 Accept-Encoding 头字段,里面是浏览器支持的压缩格式列表,例如 gzip、deflate、br 等,这样服务器就可以从中选择一种压缩算法,放进 Content-Encoding 响应头里,再把原数据压缩后发给浏览器。如果压缩率能有 50%,也就是说 100K 的数据能够压缩成 50K 的大小,那么就相当于在带宽不变的情况下网速提升了一倍,加速的效果是非常明显的。

不过这个解决方法也有个缺点,gzip 等压缩算法通常只对文本文件有较好的压缩率,而图片、音频视频等多媒体数据本身就已经是高度压缩的,再用 gzip 处理也不会变小(甚至还有可能会增大一点),所以它就失效了。不过数据压缩在处理文本的时候效果还是很好的,所以各大网站的服务器都会使用这个手段作为保底。例如,在 Nginx 里就会使用 gzip on 指令,启用对 text/html 的压缩。

分块传输

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

这种化整为零的思路在 HTTP 协议里就是 chunked 分块传输编码,在响应报文里用头字段 Transfer-Encoding: chunked 来表示,意思是报文里的 body 部分不是一次性发过来的,而是分成了许多的块(chunk)逐个发送。分块传输也可以用于流式数据,例如由数据库动态生成的表单页面,这种情况下 body 数据的长度是未知的,无法在头字段 Content-Length 里给出确切的长度,所以也只能用 chunked 方式分块发送。

Transfer-Encoding: chunked 和 Content-Length 这两个字段是互斥的,也就是说响应报文里这两个字段不能同时出现,一个响应报文的传输要么是长度已知,要么是长度未知(chunked),这一点一定要记住。下面我们来看一下分块传输的编码规则,其实也很简单,同样采用了明文的方式,很类似响应头。

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

看一张图:

浏览器在收到分块传输的数据后会自动按照规则去掉分块编码,重新组装出内容。

范围请求

有了分块传输编码,服务器就可以轻松地收发大文件了,但对于上 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 字段后,需要做四件事。

第一,它必须检查范围是否合法,比如文件只有 100 个字节,但请求 200-300,这就是范围越界了。服务器就会返回状态码 416,意思是:你的范围请求有误,我无法处理,请再检查一下。

第二,如果范围正确,服务器就可以根据 Range 头计算偏移量,读取文件的片段了,返回状态码 206 Partial Content,和 200 的意思差不多,但表示 body 只是原数据的一部分。

第三,服务器要添加一个响应头字段 Content-Range,告诉片段的实际偏移量和资源的总大小,格式是 bytes x-y/length,与 Range 头区别在没有 =,范围后多了总长度。例如,对于 0-10 的范围请求,值就是 bytes 0-10/100。

最后剩下的就是发送数据了,直接把片段用 TCP 发给客户端,一个范围请求就算是处理完了。有了范围请求之后,HTTP 处理大文件就更加轻松了,看视频时可以根据时间点计算出文件的 Range,不用下载整个文件,直接精确获取片段所在的数据内容。当然现在不仅看视频的拖拽进度需要范围请求,常用的下载工具里的多段下载、断点续传也是基于它实现的,要点是:

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

多段数据

刚才说的范围请求一次只获取一个片段,其实它还支持在 Range 头里使用多个 x-y,一次性获取多个片段数据。这种情况需要使用一种特殊的 MIME 类型:multipart/byteranges,表示报文的 body 是由多段字节序列组成的,并且还要用一个参数 boundary=xxx 给出段之间的分隔标记。

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

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

补充

gzip 的压缩率通常能够超过 60%,而 br 算法是专门会 HTML 设计的,压缩效率和性能比 gzip 还要好,能够再提高 20% 的压缩密度。

Nginx 的 gzip on 非常智能,只会压缩文本数据,不会压缩图片、音频、视频。

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

分块传输在末尾还允许有拖尾数据,由响应头字段 Trailer 指定。

与 Range 有关的还有一个 If-Range,即条件范围请求,后面说。

问题

分块传输数据的时候,如果数据里含有换行(\r\n)是否会影响分块的处理呢?

答案是不会的,因为分块前面有数据长度。

如果对一个被 gzip 的文件执行范围请求,比如 Range: bytes=10-19,那么这个范围是应用于原文件还是压缩后的文件呢?

不用想,肯定是原文件。想象一下我们看视频拖动进度条,如果是应用于压缩后的文件,那么就会造成拖拽范围和响应范围不一致。

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 个 RTT。这么算下来,浪费的时间就是 3 ÷ 5 = 60%,有三分之二的时间被浪费掉了,传输效率低得惊人。

单纯地从理论上讲,TCP 协议可能还不太好理解,我们就拿打卡考勤机来做个形象的比喻吧。假设你的公司买了一台打卡机,放在前台,因为这台机器比较贵,所以专门做了一个保护罩盖着它,公司要求每次上下班打卡时都要先打开盖子,打卡后再盖上盖子。可是偏偏这个盖子非常牢固,打开关闭要费很大力气,打卡可能只要 1 秒钟,而开关盖子却需要四五秒钟,大部分时间都浪费在了毫无意义的开关盖子操作上了。可想而知,平常还好说,一到上下班的点在打卡机前就会排起长队,每个人都要重复 “开盖 – 打卡 – 关盖” 的三个步骤,你说着急不着急。在这个比喻里,打卡机就相当于服务器,盖子的开关就是 TCP 的连接与关闭,而每个打卡的人就是 HTTP 请求,很显然,短连接的缺点严重制约了服务器的服务能力,导致它无法处理更多的请求。

长连接

针对短连接暴露出的缺点,HTTP 协议就提出了长连接的通信方式,也叫持久连接(persistent connections)、连接保活(keep alive)、连接复用(connection reuse)。

其实解决办法也很简单,用的就是 成本均摊 的思路,既然 TCP 的连接和关闭非常耗时间,那么就把这个时间成本由原来的一个 请求 – 响应均摊到多个 请求 – 响应 上。这样虽然不能改善 TCP 的连接效率,但基于 分母效应,每个 请求 – 响应 的无效时间就会降低不少,整体传输效率也就提高了。假设在长连接的情况下发送三次请求,但只在第一次建立连接,在最后一次时关闭连接。显然,如果在这个长连接上发送的请求越多,利用率也就越高。因为它只浪费一次,而短连接是每次都要浪费。

继续用刚才的打卡机的比喻,公司也觉得这种反复 开盖 – 打卡 – 关盖 的操作太反人类了,于是颁布了新规定,早上打开盖子后就不用关上了,可以自由打卡,到下班后再关上盖子。这样打卡的效率(即服务能力)就大幅度提升了,原来一次打卡需要五六秒钟,现在只要一秒就可以了,上下班时排长队的景象一去不返,大家都开心。

连接相关的头字段

由于长连接对性能的改善效果非常显著,所以在 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,限定长连接的超时时间。但这个字段的约束力并不强,通信的双方可能并不会遵守,所以不太常见。比如配置了 keepalive_timeout 60 和 keepalive_requests 5,意思是空闲连接最多 60 秒,最多发送 5 个请求。所以,如果连续刷新五次页面,就能看到响应头里的 Connection: close 了。

队头阻塞

看完了短连接和长连接,接下来就要说到著名的 队头阻塞(Head-of-line blocking,也叫队首阻塞)了。队头阻塞与短连接和长连接无关,而是由 HTTP 基本的 请求 – 响应 模型所导致的。

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

还是用打卡机做个比喻,上班的时间点上,大家都在排队打卡,可这个时候偏偏最前面的那个人倒霉,怎么也不能打卡成功,但是他要是没有打卡成功,会导致后面的人也无法打卡。结果等他打上卡之后,后面排队的所有人全迟到了。

性能优化

因为 请求 – 响应 模型不能变,所以队头阻塞问题在 HTTP/1.1 里无法解决,只能缓解,有什么办法呢?公司里可以再多买几台打卡机放在前台,这样大家可以不用挤在一个队伍里,分散打卡,一个队伍偶尔阻塞也不要紧,可以改换到其他不阻塞的队伍。

这在 HTTP 里就是 并发连接(concurrent connections),也就是同时对一个域名发起多个长连接,用数量来解决质量的问题。

但这种方式也存在缺陷。如果每个客户端都想自己快,建立很多个连接,用户数 × 并发数 就会是个天文数字。服务器的资源根本就扛不住,或者被服务器认为是恶意攻击,反而会造成拒绝服务。所以,HTTP 协议建议客户端使用并发,但不能滥用并发。RFC2616 里明确限制每个客户端最多并发 2 个连接。不过实践证明这个数字实在是太小了,众多浏览器都无视标准,把这个上限提高到了 6~8。后来修订的 RFC7230 也就顺水推舟,取消了这个 2 的限制。但并发连接所压榨出的性能也跟不上高速发展的互联网无止境的需求,还有什么别的办法吗?

就比如公司发展的太快了,员工越来越多,上下班打卡成了迫在眉睫的大问题。前台空间有限,放不下更多的打卡机了,怎么办?那就多开几个打卡的地方,每个楼层、办公区的入口也放上三四台打卡机,把人进一步分流,不要都往前台挤。这个就是 域名分片(domain sharding)技术,还是用数量来解决质量的思路。

HTTP 协议和浏览器不是限制并发连接数量吗?好,那我就多开几个域名,比如 shard1.xxx.com、shard2.xxx.com,而这些域名都指向同一台服务器 www.xxx.com ,这样实际长连接的数量就又上去了,真是美滋滋,不过实在是有点 上有政策,下有对策 的味道。

补充

因为 TCP 协议还有慢启动、拥塞窗口等特性,通常新建立的冷连接会比打开了一段时间的热连接要慢一些,所以长连接比短连接还多了这一层优势。

在长连接中的一个重要问题是如何正确区分多个报文的开始和结束,所以最好总是使用 Content-Length 头来表明响应体的总长度,正确标记报文结束。如果是流式传输,body 长度不能立刻确定,就必须用分块传输编码。

利用 HTTP 的长连接特性对服务器发起大量请求,导致服务器最终资源耗尽、拒绝服务,这就是常说的 DDoS(分布式拒绝服务攻击)。

Connection 字段还有一个取值:Connection: upgrade,配合状态码 101 表示协议升级,例如从 HTTP 切换到 WebSocket。

HTTP的重定向和跳转

在最开始的时候我们说过,为了实现在互联网上构建超链接文档系统的设想,蒂姆·伯纳斯 – 李发明了万维网,使用 HTTP 协议传输超文本,让全世界的人都能够自由地共享信息。超文本里含有超链接,可以从一个超文本跳跃到另一个超文本,对线性结构的传统文档是一个根本性的变革。能够使用超链接在网络上任意地跳转也是万维网的一个关键特性,它把分散在世界各地的文档连接在一起,形成了复杂的网状结构,用户可以在查看时随意点击链接、转换页面。再加上浏览器又提供了前进、后退、书签等辅助功能,让用户在文档间跳转时更加方便,有了更多的主动性和交互性。

那么,点击页面链接时的跳转是怎样的呢?具体一点,比如在 Nginx 的主页上点了一下 download 链接,会发生什么呢?结合上面的内容,稍微思考一下就能得到答案:浏览器首先要解析链接文字里的 URI,再用这个 URI 发起一个新的 HTTP 请求,获取响应报文后就会切换显示内容,渲染出新 URI 指向的页面。

这样的跳转动作是由浏览器的使用者主动发起的,可以称为 主动跳转,但还有一类跳转是由服务器来发起的,浏览器使用者无法控制,相对地就可以称为 被动跳转,这在 HTTP 协议里有个专门的名词,叫做 重定向(Redirection)。

重定向的过程

其实之前我们就已经见过重定向了,在介绍 3×× 状态码时说过:301 是永久重定向,302 是临时重定向,浏览器收到这两个状态码就会跳转到新的 URI。也就是说一次重定向实际上发送了两次 HTTP 请求,但如果不用开发者工具的话,你是完全看不到这个跳转过程的,也就是说,重定向是用户无感知的。

如果发生了重定向,那么响应头中会多出一个 Location 字段,它就是 301/302 重定向跳转的秘密所在。Location 字段属于响应字段,必须出现在响应报文里,但只有配合 301/302 状态码才有意义,因为它标记了服务器要求重定向的 URI。浏览器收到 301/302 报文,会检查响应头里有没有 Location。如果有,就从字段值里提取出 URI,发出新的 HTTP 请求,相当于自动替我们点击了这个链接。

而在 Location 里的 URI 既可以使用绝对 URI,也可以使用相对 URI。所谓绝对 URI,就是完整形式的 URI,包括 scheme、host:port、path 等。所谓相对 URI,就是省略了 scheme 和 host:port,只有 path 和 query 部分,是不完整的,但可以从请求上下文里计算得到,比如这个报文是 https://www.baidu.com 返回的,那么就会将 https://www.baidu.com 和 相对 URI 拼接起来进行跳转。所以在重定向时如果只是在站内跳转,你可以放心地使用相对 URI;但如果要跳转到站外,就必须用绝对 URI。

重定向状态码

最常见的重定向状态码就是 301 和 302,另外还有几个不太常见的,例如 303、307、308 等。它们最终的效果都差不多,让浏览器跳转到新的 URI,但语义上有一些细微的差别,使用的时候要特别注意。

301 俗称永久重定向(Moved Permanently),意思是原 URI 已经永久性地废弃,今后的所有请求都必须改用新的 URI。浏览器看到 301,就知道原来的 URI 过时了,就会做适当的优化。比如历史记录、更新书签,下次可能就会直接用新的 URI 访问,省去了再次跳转的成本。搜索引擎的爬虫看到 301,也会更新索引库,不再使用老的 URI。

302俗称临时重定向(Moved Temporarily),意思是原 URI 处于临时维护状态,新的 URI 是起顶包作用的临时工。浏览器或者爬虫看到 302,会认为原来的 URI 仍然有效,但暂时不可用,所以只会执行简单的跳转页面,不记录新的 URI,也不会有其他的多余动作,下次访问还是用原 URI。

301/302 是最常用的重定向状态码,在 3×× 里剩下的几个还有:

  • 303 See Other:类似 302,但要求重定向后的请求改为 GET 方法,访问一个结果页面,避免 POST/PUT 重复操作
  • 307 Temporary Redirect:类似 302,但重定向后请求里的方法和实体不允许变动,含义比 302 更明确
  • 308 Permanent Redirect:类似 307,不允许重定向后的请求变动,但它是 301永久重定向 的含义

不过这三个状态码的接受程度较低,有的浏览器和服务器可能不支持,开发时应当慎重,测试确认浏览器的实际效果后才能使用。

重定向的应用场景

理解了重定向的工作原理和状态码的含义,我们就可以在服务器端拥有主动权,控制浏览器的行为,不过要怎么利用重定向才好呢?使用重定向跳转,核心是要理解重定向和 永久 / 临时 这两个关键词。先来看什么时候需要重定向:

一个最常见的原因就是 资源不可用,需要用另一个新的 URI 来代替。至于不可用的原因那就很多了,例如域名变更、服务器变更、网站改版、系统维护,这些都会导致原 URI 指向的资源无法访问,为了避免出现 404,就需要用重定向跳转到新的 URI,继续为用户提供服务。

另一个原因就是 避免重复,让多个网址都跳转到一个 URI,增加访问入口的同时还不会增加额外的工作量。

决定要实行重定向后接下来要考虑的就是永久和临时的问题了,也就是选择 301 还是 302。

301 的含义是永久的。如果域名、服务器、网站架构发生了大幅度的改变,比如启用了新域名、服务器切换到了新机房、网站目录层次重构,这些都算是永久性的改变。原来的 URI 已经不能用了,必须用 301 永久重定向,通知浏览器和搜索引擎更新到新地址,这也是搜索引擎优化(SEO)要考虑的因素之一。

302 的含义是临时的。原来的 URI 在将来的某个时间点还会恢复正常,常见的应用场景就是系统维护,把网站重定向到一个通知页面,告诉用户过一会儿再来访问。另一种用法就是服务降级,比如在双十一促销的时候,把订单查询、领积分等不重要的功能入口暂时关闭,保证核心服务能够正常运行。

重定向的相关问题

重定向的用途很多,掌握了重定向,就能够在架设网站时获得更多的灵活性,不过在使用时还需要注意两个问题。

第一个问题是 性能损耗。很明显,重定向的机制决定了一个跳转会有两次请求 – 响应,比正常的访问多了一次。虽然 301/302 报文很小,但大量的跳转对服务器的影响也是不可忽视的。站内重定向还好说,可以长连接复用,站外重定向就要开两个连接,如果网络连接质量差,那成本可就高多了,会严重影响用户的体验。所以重定向应当适度使用,决不能滥用。

第二个问题是 循环跳转。如果重定向的策略设置欠考虑,可能会出现 A=>B=>C=>A 的无限循环,不停地在这个链路里转圈圈,后果可想而知。所以 HTTP 协议特别规定,浏览器必须具有检测循环跳转的能力,在发现这种情况时应当停止发送请求并给出错误提示。

补充

网页的入链接和出链接也是标记网页重要性的关键指标,最著名的就是 Google 发明的 PageRank。

300 Multiple Choices 也是一个特殊的重定向状态码,它会返回一个有多个链接选项的页面,由用户自行选择要跳转的链接,用的较少。

重定向报文里还可以用 Refresh 字段,实现延时重定向,例如 Refresh: 5; url=xxx 告诉浏览器 5 秒钟后再跳转。

与跳转相关的还有一个 Referer 和 Referrer-Policy(前者是个拼写错误,但已经将错就错),表示浏览器跳转的来源(即引用地址),可用于统计分析和防盗链。比如点击页面,如果你在 A 页面点击某个链接进入了 B 页面,那么对于 B 页面而言 Refer 就是 A。之前在做爬虫的时候,总是被反爬,原因就在于没有指定 Referer。因为某些页面需要你点击才能进去,如果你是直接输入一个 URI 的话,那么服务器可能就认为你是爬虫,这个时候通过在 header 中指定 Referer 告诉服务器,我是从 xxx 页面过来的。

301/302 重定向是由浏览器执行的,第一次请求得到 301/302 时,浏览器知道这是个重定向,会找到 Location 字段指定的 URI,然后向该 URI 继续发请求。因此对于服务器而言是外部重定向,相应的也就有内部重定向,内部重定向是在服务器内部跳转 URI,此时则不会发出 HTTP 请求,从而也就没有性能损失。

之前我们曾说道 HTTP 是无状态的,这既是优点也是缺点。优点是服务器没有状态差异,可以很容易地组成集群,而缺点就是无法支持需要记录状态的事务操作。好在 HTTP 协议是可扩展的,后来发明的 Cookie 技术,给 HTTP 增加了记忆能力。

举个栗子,诺兰导演的一部经典电影《记忆碎片》(Memento),里面的主角患有短期失忆症,记不住最近发生的事情。比如,电影里有个场景,某人刚跟主角说完话,大闹了一通,过了几分钟再回来,主角却是一脸茫然,完全不记得这个人是谁,刚才又做了什么,只能任人摆布。这种情况就很像 HTTP 里无状态的 Web 服务器,只不过服务器的失忆症比他还要严重,连一分钟的记忆也保存不了,请求处理完立刻就忘得一干二净。即使这个请求会让服务器发生 500 的严重错误,下次来也会依旧热情招待。

如果 Web 服务器只是用来管理静态文件还好说,对方是谁并不重要,把文件从磁盘读出来发走就可以了。但随着 HTTP 应用领域的不断扩大,对记忆能力的需求也越来越强烈。比如网上论坛、电商购物,都需要看客下菜,只有记住用户的身份才能执行发帖子、下订单等一系列会话事务。那该怎么样让原本无记忆能力的服务器拥有记忆能力呢?

看看电影里的主角是怎么做的吧。他通过纹身、贴纸条、立拍得等手段,在外界留下了各种记录,一旦失忆,只要看到这些提示信息,就能够在头脑中快速重建起之前的记忆,从而把因失忆而耽误的事情继续做下去。HTTP 的 Cookie 机制也是一样的道理,既然服务器记不住,那就在外部想办法记住。相当于是服务器给每个客户端都贴上一张小纸条,上面写了一些只有服务器才能理解的数据,需要的时候客户端把这些信息发给服务器,服务器看到 Cookie,就能够认出对方是谁了。

那么,Cookie 这张小纸条是怎么传递的呢?这要用到两个字段:响应头字段 Set-Cookie 和请求头字段 Cookie。当用户通过浏览器第一次访问服务器的时候,服务器肯定是不知道他的身份的。所以,就要创建一个独特的身份标识数据,格式是 key=value,然后放进 Set-Cookie 字段里,随着响应报文一同发给浏览器。浏览器收到响应报文,看到里面有 Set-Cookie,知道这是服务器给的身份标识,于是就保存起来,下次再请求的时候就自动把这个值放进 Cookie 字段里发给服务器。

因为第二次请求里面有了 Cookie 字段,服务器就知道这个用户不是新人,之前来过,就可以拿出 Cookie 里的值,识别出用户的身份,然后提供个性化的服务。不过因为服务器的记忆能力实在是太差,一张小纸条经常不够用。所以,服务器有时会在响应头里添加多个 Set-Cookie,存储多个 key=value。但浏览器这边发送时不需要用多个 Cookie 字段,只要在一行里用 ; 隔开就行。

并且 Cookie 是由浏览器负责存储的,而不是操作系统。所以它是浏览器绑定的,只能在本浏览器内生效。如果你换个浏览器或者换台电脑,新的浏览器里没有服务器对应的 Cookie,就好像是脱掉了贴着纸条的衣服,健忘的服务器也就认不出来了,只能再走一遍 Set-Cookie 流程。

说到这里,你应该知道了,Cookie 就是服务器委托浏览器存储的一些数据,而这些数据通常都会记录用户的关键识别信息。所以,就需要在 key=value 外再用一些手段来保护,防止外泄或窃取,这些手段就是 Cookie 的属性。

首先,我们应该设置 Cookie 的生命周期,也就是它的有效期,让它只能在一段时间内可用,就像是食品的保鲜期,一旦超过这个期限浏览器就认为是 Cookie 失效,在存储里删除,也不会发送给服务器。Cookie 的有效期可以在设置 Cookie 的时候,通过在对应的 Value 中指定 Expires 和 Max-Age 这两个属性来实现。

  • Expires 俗称过期时间,用的是绝对时间点,可以理解为截止日期(deadline)。Max-Age 用的是相对时间,单位是秒,浏览器用收到报文的时间点再加上 Max-Age,就可以得到失效的绝对时间
  • Expires 和 Max-Age 可以同时出现,两者的失效时间可以一致,也可以不一致,但浏览器会优先采用 Max-Age 计算失效期

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

最后要考虑的就是 Cookie 的安全性 了,尽量不要让服务器以外的人看到。写过前端的同学一定知道,在 JS 脚本里可以用 document.cookie 来读写 Cookie 数据,这就带来了安全隐患,有可能会导致跨站脚本(XSS)攻击窃取数据。

解决办法就是通过 header 中的 HttpOnly 字段,HttpOnly 会告诉浏览器,此 Cookie 只能通过浏览器 HTTP 协议传输,禁止其他方式访问,浏览器的 JS 引擎就会禁用 document.cookie 等一切相关的 API,脚本攻击也就无从谈起了;另一个属性 SameSite 可以防范跨站请求伪造(XSRF)攻击,设置成 SameSite=Strict 可以严格限定 Cookie 不能随着跳转链接跨站发送,而 SameSite=Lax 则略宽松一点,允许 GET/HEAD 等安全方法,但禁止 POST 跨站发送;最后一个属性叫 Secure,表示这个 Cookie 仅能用 HTTPS 协议加密传输,明文的 HTTP 协议会禁止发送。但 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),但实际作用不大。

首先 Session 是我们人工引入的一个抽象概念,它的实现需要依赖 Cookie。因为 Cookie 是明文的,所以里面不能够放入任何的敏感信息,更不可能把用户名、密码之类都放在 Cookie 里面。这些敏感信息需要保存在服务端,然后服务端为每个客户信息生成一个 session id,建立两者之间的映射关系,然后再把这个 session id 设置在 Cookie 中、交给客户端的浏览器保存。

"396b6da288cc985ca730630ccf575b8f7fd06f89": {"user_name": "夏色祭", "password": "matsuri", ...: ...}

当再次发请求的时候,服务端看看客户端是否携带了 session id,有的话再看看该 session id 是否存在或者有效,如果有效那么再拿到对应的用户信息,从而实现记忆功能。

补充

Cookie 这个词来源于计算机编程里的术语 Magic Cookie,意思是不透明的数据,并不是小甜饼的意思。

早期 Cookie 直接就是磁盘上的一些小文本文件,现在基本上都是以数据库记录的形式存放的。浏览器对 Cookie 的数量和大小也都有限制,不允许无限存储,一般总大小也不允许超过 4K。

如果不指定 Expires 和 Max-Age 属性,那么 Cookie 尽在浏览器运行时有效,一旦关闭就会失效,这被称为会话 Cookie(session cookie)或内存 Cookie(in-memory cookie),在 Chrome 里面过期时间就会显示为 Session 或 N/A。

历史上还有 Set-Cookie2 和 Cookie2 这样的字段,但现在已经不再使用。

如果设置 Cookie 的 Max-Age 为 0,那么 Cookie 也会立即失效。

HTTP的缓存控制

缓存(Cache)是计算机领域里的一个重要概念,是优化系统性能的利器。由于链路漫长,网络时延不可控,浏览器使用 HTTP 获取资源的成本较高,所以非常有必要把来之不易的数据缓存起来,下次再请求的时候尽可能地复用。这样,就可以避免多次请求 – 响应的通信成本,节约网络带宽,也可以加快响应速度。试想一下,如果有几十 K 甚至几十 M 的数据,不是从网络而是从本地磁盘获取,那将是多么大的一笔节省,免去多少等待的时间。

实际上,HTTP 传输的每一个环节基本上都会有缓存,非常复杂。基于 请求 – 响应 模式的特点,可以大致分为客户端缓存和服务器端缓存,因为服务器端缓存经常与代理服务混搭在一起,所以我们来显看看客户端——也就是浏览器的缓存。

服务器的缓存控制

为了更好地说明缓存的运行机制,我们用生鲜速递作为比喻,看看缓存是如何工作的。

夏天到了,天气很热。你想吃西瓜消暑,于是打开冰箱,但很不巧,冰箱是空的。不过没事,现在物流很发达,给生鲜超市打个电话,不一会儿,就给你送来一个 8 斤的沙瓤大西瓜,上面还贴着标签:保鲜期 5 天。好了,你把它放进冰箱,想吃的时候随时拿出来。

在这个场景里,生鲜超市就是 Web 服务器,你就是浏览器,冰箱就是浏览器内部的缓存。整个流程翻译成 HTTP 就是:

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

服务器标记资源有效期使用的头字段是 Cache-Control,假设缓存只能维持 30 秒,那么就是 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 搞混,实际的意思并不是不允许缓存,而是可以缓存,但在使用之前必须要去服务器验证是否过期,是否有最新的版本。就是说西瓜可以放进冰箱,但吃之前必须问超市有没有更新鲜的,有就吃超市里的,没有就吃冰箱里的。虽然这也是多发送了一次请求,但如果服务器返回 304,那么这个过程就只会产生一个很小的报文
  • must-revalidate:又是一个和 no_cache 相似的词,它的意思是如果缓存不过期就可以继续使用,但过期了如果还想用就必须去服务器验证。就是说西瓜可以放进冰箱,保鲜期内可以吃,过期了就要问超市让不让吃

客户端的缓存控制

现在冰箱里已经有了缓存的西瓜,是不是就可以直接开吃了呢?其实还不行,因为不止服务器可以发 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,所以就会检查缓存,没有过期直接利用之前的资源,不再进行网络通信。而强制刷新的话,浏览器会夹带私货,在请求头中多加一个 Cache-Control:  no-cache,从而不走缓存。

条件请求

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

浏览器可以用两个连续的请求组成验证动作:先是一个 HEAD,获取资源的修改时间等元信息,然后与缓存数据比较,如果没有改动就使用缓存,节省网络流量,否则就再发一个 GET 请求,获取最新的版本。但这样的两个请求网络成本太高了,所以 HTTP 协议就定义了一系列 If 开头的条件请求字段,专门用来检查验证资源是否过期,把两个请求才能完成的工作合并在一个请求里做。而且,验证的责任也交给服务器,浏览器只需坐享其成。

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

另外 Last-modified 很好理解,就是文件的最后修改时间。那么ETag 是什么呢?ETag 是实体标签(Entity Tag)的缩写,是资源的一个唯一标识,主要是用来解决修改时间无法准确区分文件变化的问题。

比如,一个文件在一秒内修改了多次,但因为修改时间是秒级,所以这一秒内的新版本无法区分。再比如,一个文件定期更新,但有时会是同样的内容,实际上没有变化,用修改时间就会误以为发生了变化,传送给浏览器就会浪费带宽。使用 ETag 就可以精确地识别资源的变动情况,让浏览器能够更有效地利用缓存。

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

你打电话给超市:我这个西瓜是 3 天前买的,还有最新的吗?超市看了一下库存说:没有啊,我这里都是 3 天前的。于是你就知道了,再让超市送货也没用,还是吃冰箱里的西瓜吧,这就相当于缓存仍然有效。而这一问一回就是 If-Modified-Since 和 Last-modified。

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

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

HTTP 缓存看上去很复杂,但基本原理说白了就是一句话:没有消息就是好消息,没有请求的请求,才是最快的请求。

补充

较早版本的Chrome(66 之前)可以使用 URL:chrome://cache 检查本地缓存,但因为存在安全隐患,现在已经不能用了。

no-cache 属性可以理解为 max-age=0,must-revalidate

除了 Cache-Control 之外,服务器也可以使用 Expires 字段来标记资源的有效期,但是优先级低于 Cache-Control。

如果响应报文里提供了 Last-modified,但没有 Cache-Control 或 Expires,浏览器就不知道过期时间,那么它会采用启发算法计算一个缓存时间,在 RFC 里的建议是:(Date 减去 Last-modified)乘以 10%。

每个 Web 服务器对 ETag 的计算方法都不一样,只要保证数据变化后值不一样就行,但复杂的计算会增加服务器的负担。Nginx 的算法是:修改时间 + 长度,实际上和 Last-modified 基本等价。

问题

Cache 和 Cookie 都是服务器发给客户端并存储的数据,你能比较一下两者的异同吗?

Cookie 会随请求报文发送到服务器,而 Cache 不会,但可能会携带 If-Modified-Since(保存资源的最后修改时间)和 If-None-Match(保存资源唯一标识) 字段来验证资源是否过期。cookie 是服务端为了快速辨识客户端身份,保存在客户端的键值对缓存;cache 是客户端为了避免不必要网络请求,加快响应速度,所存储在本地的服务端内容。

cache 缓存的时候会缓存整个报文,并且和 Cookie 的作用范围是相似的,都位于当前浏览器内。

即使有 Last-modified 和 ETag,强制刷新(Ctrl+F5)也能够从服务器获取最新数据(返回 200 而不是 304),请解释原因。

强制刷新是因为请求头里指定了禁用缓存:Pragma: no-cacheCache-Control: no-cache,并且 If-Modified-Since 和 If-None-Match 会被清空,所以会返回最新数据。

HTTP的代理服务

前面介绍 HTTP 协议的时候,我们严格遵循了 HTTP 的请求 – 响应模型,协议中只有两个互相通信的角色,分别是作为请求方的浏览器(客户端)和作为响应方的服务器。今天,我们要在这个模型里引入一个新的角色,那就是 HTTP 代理。引入 HTTP 代理后,原来简单的双方通信就变复杂了一些,加入了一个或者多个中间人,但整体上来看,还是一个有顺序关系的链条,而且链条里相邻的两个角色仍然是简单的一对一通信,不会出现越级的情况。

浏览器 <---> 代理服务器 <---> 服务器(源)

链条的起点还是客户端(也就是浏览器),中间的角色被称为代理服务器(proxy server),链条的终点被称为源服务器(origin server),意思是数据的源头、起源。

代理服务

代理这个词听起来好像很神秘,有点高大上的感觉。但其实 HTTP 协议里对它并没有什么特别的描述,它就是在客户端和服务器原本的通信链路中插入的一个中间环节,也是一台服务器,但提供的是代理服务。

所谓的代理服务就是指:服务本身不生产内容,而是处于中间位置转发上下游的请求和响应,具有双重身份。面向下游的用户时,表现为服务器,代表源服务器响应客户端的请求;而面向上游的源服务器时,又表现为客户端,代表客户端发送请求。我们挂的 威批恩 就是典型的代理服务器,假设我们访问谷歌,真正给谷歌发请求的其实是代理服务器,给我们提供响应的也是代理服务器。并且根据代理的性质,我们可分为匿名代理、透明代理、正向代理、反向代理。而下面我们将要介绍的是反向代理,它在传输链路中更靠近源服务器,为源服务器提供代理服务。

代理的作用

为什么要有代理呢?换句话说,代理能干什么、带来什么好处呢?你也许听过这样一句至理名言:计算机科学领域里的任何问题,都可以通过引入一个中间层来解决(在这句话后面还可以再加上一句,如果一个中间层解决不了问题,那就再加一个中间层)。TCP/IP 协议栈是这样,而代理也是这样。

由于代理处在 HTTP 通信过程的中间位置,相应地就对上屏蔽了真实客户端,对下屏蔽了真实服务器,简单的说就是欺上瞒下。在这个中间层的小天地里就可以做很多的事情,为 HTTP 协议增加更多的灵活性,实现客户端和服务器的双赢。

代理最基本的一个功能是负载均衡。因为在面向客户端时屏蔽了源服务器,客户端看到的只是代理服务器,源服务器究竟有多少台、是哪些 IP 地址都不知道。于是代理服务器就可以掌握请求分发的大权,决定由后面的哪台服务器来响应请求。

代理中常用的负载均衡算法大家应该有所耳闻,比如轮询、一致性哈希等等,这些算法的目标都是尽量把外部的流量合理地分散到多台源服务器,提高系统的整体资源利用率和性能。在负载均衡的同时,代理服务还可以执行更多的功能,比如:

  • 健康检查:使用 心跳 等机制监控后端服务器,发现有故障就及时踢出集群,保证服务高可用
  • 安全防护:保护被代理的后端服务器,限制 IP 地址或流量,抵御网络攻击和过载
  • 加密卸载:对外网使用 SSL/TLS 加密通信认证,而在安全的内网不加密,消除加解密成本
  • 数据过滤:拦截上下行的数据,任意指定策略修改请求或者响应
  • 内容缓存:暂存、复用服务器响应,我们稍后再说

我们以便利店为例:因为便利店和超市之间是专车配送,所以有了便利店,以后你买东西就更省事了,打电话给便利店让它去帮你取货,不用关心超市是否停业休息、是否人满为患,而且总能买到最新鲜的。便利店同时也方便了超市,不用额外加大店面就可以增加客源和销量,货物集中装卸也节省了物流成本,由于便利店直接面对客户,所以也可以把恶意骚扰电话挡在外面。

代理相关头字段

代理的好处很多,但因为它欺上瞒下的特点,隐藏了真实客户端和服务器,如果双方想要获得这些丢失的原始信息,该怎么办呢?首先,代理服务器需要用字段 Via 标明代理的身份。

Via 是一个通用字段,请求头或响应头里都可以出现。每当报文经过一个代理节点,代理服务器就会把自身的信息追加到字段的末尾,就像是经手人盖了一个章。如果通信链路中有很多中间代理,就会在 Via 里形成一个链表,这样就可以知道报文究竟走过了多少个环节才到达了目的地。

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

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

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

例如上面这个请求,在 GET 请求行前多出了 PROXY 信息行,客户端的真实 IP 地址是 1.1.1.1,端口号是 55555。服务器看到这样的报文,只要解析第一行就可以拿到客户端地址,不需要再去理会后面的 HTTP 数据,省了很多事情。不过代理协议并不支持 X-Forwarded-For 的链式地址形式,所以拿到客户端地址后再如何处理就需要代理服务器与后端自行约定。

补充

指明的代理软件有 HAProxy、Squid、Varnish 等等,而 Nginx 虽然是 Web 服务器,但也可以作为代理服务器,而且功能毫不逊色。

Via 是 HTTP 协议里规定的标准头字段,但有的服务器返回的响应报文里会使用 X-Via,含义是相同的。

因为 HTTP 是明文传输,请求头很容易被篡改,因此 X-Forwarded-For 也不是完全可信的。

RFC7239 定义了字段 Forwarded,它可以代理 X-Forwarded-For 和 X-Forwarded-Host 等字段,但是应用的不多。

HTTP的缓存代理

我们了解 HTTP 的缓存控制以及 HTTP 的代理服务,那么把这两者结合起来就是下面所要说的缓存代理,也就是支持缓存控制的代理服务。之前谈到缓存时,主要说了客户端(浏览器)上的缓存控制,它能够减少响应时间、节约带宽,提升客户端的用户体验。但 HTTP 传输链路上,不只是客户端有缓存,服务器上的缓存也是非常有价值的,可以让请求不必走完整个后续处理流程,就近获得响应结果。

特别是对于那些读多写少的数据,例如突发热点新闻、爆款商品的详情页,一秒钟内可能有成千上万次的请求。即使仅仅缓存数秒钟,也能够把巨大的访问流量挡在外面,让 RPS(request per second)降低好几个数量级,减轻应用服务器的并发压力,对性能的改善是非常显著的。HTTP 的服务器缓存功能主要由代理服务器来实现(即缓存代理),而源服务器系统内部虽然也经常有各种缓存(如 Memcache、Redis、Varnish 等),但与 HTTP 没有太多关系,所以这里暂且不说。

缓存代理服务

我们 生鲜速递 + 便利店 做比喻,看看缓存代理是怎么回事。

便利店作为超市的代理,生意非常红火,顾客和超市双方都对现状非常满意。但时间一长,超市发现还有进一步提升的空间,因为每次便利店接到顾客的请求后都要专车跑一趟超市,还是挺麻烦的。干脆这样吧,给便利店配发一个大冰柜,水果海鲜什么的都可以放在冰柜里,只要产品在保鲜期内,就允许顾客直接从冰柜提货。这样便利店就可以一次进货多次出货,省去了超市之间的运输成本。

通过这个比喻,你可以看到:在没有缓存的时候,代理服务器每次都是直接转发客户端和服务器的报文,中间不会存储任何数据,只有最简单的中转功能。但加入了缓存后就不一样了,代理服务收到源服务器发来的响应数据后需要做两件事。第一个当然是把报文转发给客户端,而第二个就是把报文存入自己的 Cache 里。下一次再有相同的请求,代理服务器就可以直接发送 304 或者缓存数据,不必再从源服务器那里获取。这样就降低了客户端的等待时间,同时节约了源服务器的网络带宽。

在 HTTP 的缓存体系中,缓存代理的身份十分特殊,它既是客户端,又是服务器;同时也既不是客户端,又不是服务器。

说它即是客户端又是服务器,是因为它面向源服务器时是客户端,在面向客户端时又是服务器,所以它即可以用客户端的缓存控制策略也可以用服务器端的缓存控制策略,也就是说它可以同时使用之前的各种 Cache-Control 属性。

但缓存代理也即不是客户端又不是服务器,因为它只是一个数据的中转站,并不是真正的数据消费者和生产者,所以还需要有一些新的 Cache-Control 属性来对它做特别的约束。

源服务器的缓存控制

之前说了 4 种服务器端的 Cache-Control 属性:max-age、no_store、no_cache 和 must-revalidate,应该还有印象吧?这 4 种缓存属性可以约束客户端,也可以约束代理。

但客户端和代理是不一样的,客户端的缓存只是用户自己使用,而代理的缓存可能会为非常多的客户端提供服务。所以,需要对它的缓存再多一些限制条件。

首先,我们要区分客户端上的缓存和代理上的缓存,可以使用两个新属性 private 和 public。private 表示缓存只能在客户端保存,是用户私有的,不能放在代理上与别人共享。而 public 的意思就是缓存完全开放,谁都可以存,谁都可以用。

比如你登录论坛,返回的响应报文里用 Set-Cookie 添加了论坛 ID,这就属于私人数据,不能存在代理上。不然,别人访问代理获取了被缓存的响应就麻烦了。其次,缓存失效后的重新验证也要区分开(即使用条件请求 Last-modified 和 ETag),must-revalidate 只要过期就必须回源服务器验证,而新的 proxy-revalidate 只要求代理的缓存过期后必须验证,客户端不必回源,只验证到代理这个环节就行了。

再次,缓存的生存时间可以使用新的 s-maxage(s 是 share 的意思,注意 maxage 中间没有 -),只限定在代理上能够存多久,而客户端仍然使用 max-age。

还有一个代理专用的属性 no-transform,代理有时候会对缓存下来的数据做一些优化,比如把图片生成 png、webp 等几种格式,方便今后的请求处理,而 no-transform 就会禁止这样做,不允许偷偷摸摸搞小动作。

这些新的缓存控制属性比较复杂,还是用 便利店冷柜 来举例好理解一些。

水果上贴着标签 private, max-age=5,这就是说水果不能放进冷柜,必须直接给顾客,保鲜期 5 天,过期了还得去超市重新进货。

冻鱼上贴着标签 public, max-age=5, s-maxage=10,这个的意思就是可以在冰柜里存 10 天,但顾客那里只能存 5 天,过期了可以来便利店取,只要在 10 天之内就不必再找超市。

排骨上贴着标签 max-age=30, proxy-revalidate, no-transform,因为缓存默认是 public 的,那么它在便利店和顾客的冰箱里就都可以存 30 天,过期后便利店必须去超市进新货,而且不能擅自对数据进行改动。

但是注意,源服务器在设置完 Cache-Control 后必须要为报文加上 Last-modified 或 ETag 字段。否则,客户端和代理后面就无法使用条件请求来验证缓存是否有效,也就不会有 304 缓存重定向。

客户端的缓存控制

说完了服务器端的缓存控制策略,稍微歇一口气,我们再来看看客户端。

客户端在 HTTP 缓存体系里要面对的是代理和源服务器,也必须区别对待。

max-age、no_store、no_cache 这三个属性已经介绍过了,它们也是同样作用于代理和源服务器。

关于缓存的生存时间,多了两个新属性 max-stale 和 min-fresh。

max-stale 的意思是如果代理上的缓存过期了也可以接受,但不能过期太多,超过 x 秒也会不要;min-fresh 的意思是缓存必须有效,而且必须在 x 秒后依然有效。比如,草莓上贴着标签 max-age=5,现在已经在冰柜里存了 7 天。如果有请求 max-stale=2,意思是过期两天也能接受,所以刚好能卖出去。但要是 min-fresh=1,这是绝对不允许过期的,就不会买走。这时如果有另外一个菠萝是 max-age=10,那么 7+1<10,在一天之后还是新鲜的,所以就能卖出去。

有的时候客户端还会发出一个特别的 only-if-cached 属性,表示只接受代理缓存的数据,不接受源服务器的响应。如果代理上没有缓存或者缓存过期,就应该给客户端返回一个 504(Gateway Timeout)。

其他问题

缓存代理的知识就快讲完了,下面再简单说两个相关的问题。

第一个是 Vary 字段,曾经说过它是内容协商的结果,相当于报文的一个版本标记。同一个请求,经过内容协商后可能会有不同的字符集、编码、浏览器等版本。比如,Vary: Accept-Encoding、Vary: User-Agent,缓存代理必须要存储这些不同的版本。当再收到相同的请求时,代理就读取缓存里的 Vary,对比请求头里相应的 Accept-Encoding、User-Agent 等字段,如果和上一个请求的完全匹配,比如都是 gzip、Chrome,就表示版本一致,可以返回缓存的数据。

另一个问题是 Purge,也就是缓存清理,它对于代理也是非常重要的功能,例如:

  • 过期的数据应该及时淘汰,避免占用空间
  • 源站的资源有更新,需要删除旧版本,主动换成最新版(即刷新)
  • 有时候会缓存了一些本不该存储的信息,例如网络谣言或者危险链接,必须尽快把它们删除

清理缓存的方法有很多,比较常用的一种做法是使用自定义请求方法“PURGE”,发给代理服务器,要求删除 URI 对应的缓存数据。

补充

常见的缓存代理软件有 Squid、Varnish、ATS(Apache Traffic Server)等,而 Nginx 不仅是 Web 服务器、代理服务器,也是一个出色的缓存代理服务器,堪称全能。

有的缓存代理在 Cache Hit 的时候会在响应报文里加一个 Age 头字段,表示报文的生存时间,即已经缓存了多久,通常它会小于 Cache-Control 里的 max-age 值,如果大于就意味着数据是陈旧的(stale)。

判断缓存是否命中(Hit)类似于查询 hash 表,使用的 key 通常是 URI,在 Nginx 中可以用指令 proxy_cache_key 自定义。

Nginx 对 Vary 的处理实际上是做了 MD5,把 Vary 头摘要写入缓存,请求时不仅比较 URI,也会比较摘要。

HTTPS是什么?SSL/TLS又是什么?

下面来聊聊与安全相关的 HTTPS、SSL、TLS,我们曾经谈到过 HTTP 的一些缺点,其中的无状态在加入 Cookie 后得到了解决,而另两个缺点——明文 和 不安全 仅凭 HTTP 自身是无力解决的,需要引入新的 HTTPS 协议。

为什么要有 HTTPS?

简单的回答是 因为 HTTP 不安全,由于 HTTP 天生明文的特点,整个传输过程完全透明,任何人都能够在链路中截获、修改或者伪造请求 / 响应报文,数据不具有可信性。比如,上面说过的代理服务,它作为 HTTP 通信的中间人,在数据上下行的时候可以添加或删除部分头字段,也可以使用黑白名单过滤 body 里的关键字,甚至直接发送虚假的请求、响应,而浏览器和源服务器都没有办法判断报文的真伪。

这对于网络购物、网上银行、证券交易等需要高度信任的应用场景来说是非常致命的。如果没有基本的安全保护,使用互联网进行各种电子商务、电子政务就根本无从谈起。对于安全性要求不那么高的新闻、视频、搜索等网站来说,由于互联网上的恶意用户、恶意代理越来越多,也很容易遭到流量劫持的攻击,在页面里强行嵌入广告,或者分流用户,导致各种利益损失。对于普通网民来说,HTTP 不安全的隐患就更大了,上网的记录会被轻易截获,网站是否真实也无法验证,黑客可以伪装成银行网站,盗取真实姓名、密码、银行卡等敏感信息,威胁人身安全和财产安全。总的来说,今天的互联网已经不再是早期的田园牧歌时代,而是进入了黑暗森林状态。上网的时候必须步步为营、处处小心,否则就会被不知道埋伏在哪里的黑客所猎杀。

什么是安全?

既然 HTTP 不安全,那什么样的通信过程才是安全的呢?

通常认为,如果通信过程具备了四个特性,就可以认为是安全的,这四个特性是:机密性、完整性,身份认证和不可否认。

机密性(Secrecy/Confidentiality)是指对数据的保密,只能由可信的人访问,对其他人是不可见的秘密,简单来说就是不能让不相关的人看到不该看的东西。比如小明和小红私下聊天,但隔墙有耳,被小强在旁边的房间里全偷听到了,这就是没有机密性。抓包工具 Wireshark 实际上也是利用了 HTTP 的这个特点,捕获了传输过程中的所有数据。

完整性(Integrity,也叫一致性)是指数据在传输过程中没有被窜改,不多也不少,完完整整地保持着原状。机密性虽然可以让数据成为秘密,但不能防止黑客对数据的修改,黑客可以替换数据,调整数据的顺序,或者增加、删除部分数据,破坏通信过程。比如,小明给小红写了张纸条:明天公园见。小强把公园划掉,模仿小明的笔迹把这句话改成了明天广场见。小红收到后无法验证完整性,信以为真,第二天的约会就告吹了。

身份认证(Authentication)是指确认对方的真实身份,也就是 “证明你真的是你”,保证消息只能发送给可信的人。如果通信时另一方是假冒的网站,那么数据再保密也没有用,黑客完全可以使用冒充的身份套出各种信息,加密和没加密一样。比如,小明给小红写了封情书:我喜欢你,但不留心发给了小强。小强将错就错,假冒小红回复了一个白日做梦,小明不知道这其实是小强的话,误以为是小红的,后果可想而知。

第四个特性是不可否认(Non-repudiation/Undeniable),也叫不可抵赖,意思是不能否认已经发生过的行为,不能说话不算数、耍赖皮。使用前三个特性,可以解决安全通信的大部分问题,但如果缺了不可否认,那通信的事务真实性就得不到保证,有可能出现老赖。比如,小明借了小红一千元,没写借条,第二天矢口否认,小红也确实拿不出借钱的证据,只能认倒霉。另一种情况是小明借钱后还了小红,但没写收条,小红于是不承认小明还钱的事,说根本没还,要小明再掏出一千元。

所以,只有同时具备了机密性、完整性、身份认证、不可否认这四个特性,通信双方的利益才能有保障,才能算得上是真正的安全。

什么是 HTTPS?

说到这里,终于轮到今天的主角 HTTPS 出场了,它为 HTTP 增加了刚才所说的四大安全特性。

HTTPS 其实是一个非常简单的协议,RFC 文档很小,只有短短的 7 页,里面规定了新的协议名 https,默认端口号 443,至于其他的什么请求 – 响应模式、报文结构、请求方法、URI、头字段、连接管理等等都完全沿用 HTTP,没有任何新的东西。也就是说,除了协议名 http 和端口号 80 这两点不同,HTTPS 协议在语法、语义上和 HTTP 完全一样,优缺点也照单全收(当然要除去明文和不安全)。

你可能要问了,既然没有新东西,HTTPS 凭什么就能做到机密性、完整性这些安全特性呢?秘密就在于 HTTPS 名字里的 S,它把 HTTP 下层的传输协议由 TCP/IP 换成了 SSL/TLS,由 HTTP over TCP/IP 变成了 HTTP over SSL/TLS,让 HTTP 运行在了安全的 SSL/TLS 协议上,收发报文不再使用 Socket API,而是调用专门的安全接口。

所以说,HTTPS 本身并没有什么惊世骇俗的本事,全是靠着后面的 SSL/TLS 撑腰,只要学会了 SSL/TLS,HTTPS 自然就手到擒来。

SSL/TLS

现在我们就来看看 SSL/TLS,它到底是个什么来历。SSL 即安全套接层(Secure Sockets Layer),在 OSI 模型中处于第 5 层(会话层),由网景公司于 1994 年发明,有 v2 和 v3 两个版本,而 v1 因为有严重的缺陷从未公开过。

SSL 发展到 v3 时已经证明了它自身是一个非常好的安全通信协议,于是互联网工程组 IETF 在 1999 年把它改名为 TLS(传输层安全,Transport Layer Security),正式标准化,版本号从 1.0 重新算起,所以 TLS1.0 实际上就是 SSLv3.1。到今天 TLS 已经发展出了三个版本,分别是 2006 年的 1.1、2008 年的 1.2 和 2018 的 1.3,每个新版本都紧跟密码学的发展和互联网的现状,持续强化安全和性能,已经成为了信息安全领域中的权威标准。

TLS 由记录协议、握手协议、警告协议、变更密码规范协议、扩展协议等几个子协议组成,综合使用了对称加密、非对称加密、身份认证等许多密码学前沿技术。浏览器和服务器在使用 TLS 建立连接时需要选择一组恰当的加密算法来实现安全通信,这些算法的组合被称为密码套件(cipher suite,也叫加密套件)。

那么 SSL/TLS 协议是如何保证通信是安全的呢?

  • 混合加密 的方式实现信息的 机密性,解决了窃听的风险
  • 摘要算法 的方式来实现 完整性,它能够为数据生成独一无二的「指纹」,指纹用于校验数据的完整性,解决了篡改的风险
  • 将服务器公钥放入到 数字证书 中,解决了冒充的风险

1. 混合加密

通混合加密的方式可以保证信息的机密性,解决了窃听的风险。

HTTPS 采用的是对称加密和非对称加密结合的「混合加密」方式:

  • 在通信建立前采用非对称加密的方式交换「会话秘钥」,后续就不再使用非对称加密
  • 在通信过程中全部使用 对称加密 的「会话秘钥」的方式加密明文数据

采用「混合加密」的方式的原因:

  • 对称加密只使用一个密钥,运算速度快,密钥必须保密,无法做到安全的密钥交换
  • 非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,解决了密钥交换问题但速度慢

 

2. 摘要算法

摘要算法 用来实现完整性,能够为数据生成独一无二的「指纹」,用于校验数据的完整性,解决了篡改的风险。

客户端在发送明文之前会通过摘要算法算出明文的「指纹」,发送的时候把「指纹 + 明文」一同 加密成密文后,发送给服务器,服务器解密后,用相同的摘要算法算出发送过来的明文,通过比较客户端携带的「指纹」和当前算出的「指纹」做比较,若「指纹」相同,说明数据是完整的。

 

3. 数字证书

客户端先向服务器端索要公钥,然后用公钥加密信息,服务器收到密文后,用自己的私钥解密。这就存在些问题,如何保证公钥不被篡改和信任度?所以这里就需要借助第三方权威机构 CA(数字证书认证机构),将服务器公钥放在数字证书(由数字证书认证机构颁发)中,只要证书是可信的,公钥就是可信的。

通过数字证书的方式保证服务器公钥的身份,解决冒充的风险。

OpenSSL

说到 TLS,就不能不谈到 OpenSSL,它是一个著名的开源密码学程序库和工具包,几乎支持所有公开的加密算法和协议,已经成为了事实上的标准,许多应用软件都会使用它作为底层库来实现 TLS 功能,包括常用的 Web 服务器 Apache、Nginx 等。

OpenSSL 是从另一个开源库 SSLeay 发展出来的,曾经考虑命名为 OpenTLS,但当时(1998 年)TLS 还未正式确立,而 SSL 早已广为人知,所以最终使用了 OpenSSL 的名字。OpenSSL 目前有三个主要的分支,1.0.2 和 1.1.0 都将在今年(2019)年底不再维护,最新的长期支持版本是 1.1.1,我们的实验环境使用的 OpenSSL 是 1.1.0j。由于 OpenSSL 是开源的,所以它还有一些代码分支,比如 Google 的 BoringSSL、OpenBSD 的 LibreSSL,这些分支在 OpenSSL 的基础上删除了一些老旧代码,也增加了一些新特性,虽然背后有 大金主,但离取代 OpenSSL 还差得很远。

HTTPS 是如何建立连接的?其间交互了什么?

SSL/TLS 协议基本流程:

  • 客户端向服务器索要并验证服务器的公钥
  • 双方协商生产「会话秘钥」
  • 双方采用「会话秘钥」进行加密通信

前两步也就是 SSL/TLS 的建立过程,也就是握手阶段。SSL/TLS 的「握手阶段」涉及四次通信,可见下图:

SSL/TLS 协议建立的详细流程:

 

1. ClientHello

首先,由客户端向服务器发起加密通信请求,也就是 ClientHello 请求。在这一步,客户端主要向服务器发送以下信息:

  • 客户端支持的 SSL/TLS 协议版本,如 TLS 1.2 版本。
  • 客户端生产的随机数(Client Random),后面用于生产「会话秘钥」。
  • 客户端支持的密码套件列表,如 RSA 加密算法。

 

2. ServerHello

服务器收到客户端请求后,向客户端发出响应,也就是 SeverHello。服务器回应的内容有如下内容:

  • 确认 SSL/ TLS 协议版本,如果浏览器不支持,则关闭加密通信。
  • 服务器生产的随机数(Server Random),后面用于生产「会话秘钥」。
  • 确认的密码套件列表,如 RSA 加密算法。
  • 服务器的数字证书。

 

3. 客户端回应

客户端收到服务器的回应之后,首先通过浏览器或者操作系统中的 CA 公钥,确认服务器的数字证书的真实性。如果证书没有问题,客户端会从数字证书中取出服务器的公钥,然后使用它加密报文,向服务器发送如下信息:

  • 一个随机数(pre-master key)。该随机数会被服务器公钥加密。
  • 加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
  • 客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供服务端校验。

上面第一项的随机数是整个握手阶段的第三个随机数,这样服务器和客户端就同时有三个随机数,接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」。

 

4. 服务器的最后回应

服务器收到客户端的第三个随机数(pre-master key)之后,通过协商的加密算法,计算出本次通信的「会话秘钥」。然后,向客户端发生最后的信息:

  • 加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。
  • 服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供客户端校验。

至此,整个 SSL/TLS 的握手阶段全部结束。接下来,客户端与服务器进入加密通信,就完全是使用普通的 HTTP 协议,只不过用「会话秘钥」加密内容。

所以 HTTPS 连接大致上可以划分为两个部分:第一个是建立连接时的非对称加密握手,第二个是握手后的对称加密报文传输。

也正因为 HTTPS 比 HTTP 增加了一个 TLS 握手的步骤,这个步骤最长可以花费两个消息往返,也就是 2-RTT;以及产生用于密钥交换的临时公私钥对(ECDHE)、验证证书时访问 CA 获取 CRL 或者 OCSP、非对称加密解密处理 Pre-Master,导致 HTTPS 会比 HTTP 要慢一些,因为 HTTPS 为了保证安全要做一些额外的工作。但这些情况已经是过去式了,现在已经有了很多行之有效的 HTTPS 优化手段,运用得好可以把连接的额外耗时降低到几十毫秒甚至是零,比如:硬件优化、软件优化、协议优化、证书优化、会话复用、预共享密钥等等。

HTTP 与 HTTPS 有哪些区别?

  • HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
  • HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
  • HTTP 的端口号是 80,HTTPS 的端口号是 443。
  • HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。

我应该迁移到HTTPS吗?

我们已经了解了 HTTPS、TLS 相关的大部分知识,不过或许你心里还会有一些困惑:HTTPS 这么复杂,我是否应该迁移到 HTTPS 呢?它能带来哪些好处呢?具体又应该怎么实施迁移呢?那么下面我们就来聊一聊。

迁移的必要性

如果你做移动应用开发的话,那么就一定知道,Apple、Android、某信等开发平台在 2017 年就相继发出通知,要求所有的应用必须使用 HTTPS 连接,禁止不安全的 HTTP。在台式机上,主流的浏览器 Chrome、Firefox 等也早就开始强推 HTTPS,把 HTTP 站点打上不安全的标签,给用户以心理压力。

Google 等搜索巨头还利用自身的话语权优势,降低 HTTP 站点的排名,而给 HTTPS 更大的权重,力图让网民只访问到 HTTPS 网站。

这些手段都逐渐挤压了纯明文 HTTP 的生存空间,迁移到 HTTPS 已经不是要不要做的问题,而是要怎么做的问题了。HTTPS 的大潮无法阻挡,如果还是死守着 HTTP,那么无疑会被冲刷到互联网的角落里。目前国内外的许多知名大站都已经实现了全站 HTTPS,打开常用的某宝、某东、某浪,都可以在浏览器的地址栏里看到”小锁头”,如果你正在维护的网站还没有实施 HTTPS,那可要抓点紧了。

迁移的顾虑

阻碍 HTTPS 实施的因素还有一些这样、那样的顾虑,目前三个比较流行的观点是:慢、贵、难。

所谓慢,是指惯性思维,拿以前的数据来评估 HTTPS 的性能,认为 HTTPS 会增加服务器的成本,增加客户端的时延,影响用户体验。

其实现在服务器和客户端的运算能力都已经有了很大的提升,性能方面完全没有担心的必要,而且还可以应用很多的优化解决方案。根据 Google 等公司的评估,在经过适当优化之后,HTTPS 的额外 CPU 成本小于 1%,额外的网络成本小于 2%,可以说是与无加密的 HTTP 相差无几。

所谓贵,主要是指证书申请和维护的成本太高,网站难以承担。

这也属于惯性思维,在早几年的确是个问题,向 CA 申请证书的过程不仅麻烦,而且价格昂贵,每年要交几千甚至几万元。但现在就不一样了,为了推广 HTTPS,很多云服务厂商都提供了一键申请、价格低廉的证书,而且还出现了专门颁发免费证书的 CA,其中最著名的就是 Let’s Encrypt。

所谓难,是指 HTTPS 涉及的知识点太多、太复杂,有一定的技术门槛,不能很快上手。

这第三个顾虑比较现实,HTTPS 背后关联到了密码学、TLS、PKI 等许多领域,不是短短几周、几个月就能够精通的。但实施 HTTPS 也并不需要把这些完全掌握,只要抓住少数几个要点就好,下面就来逐个解决一些关键的难点。

申请证书

要把网站从 HTTP 切换到 HTTPS,首先要做的就是为网站申请一张证书。大型网站出于信誉、公司形象的考虑,通常会选择向传统的 CA 申请证书,例如 DigiCert、GlobalSign,而中小型网站完全可以选择使用 Let’s Encrypt 这样的免费证书,效果也完全不输于那些收费的证书。

Let’s Encrypt 一直在推动证书的自动化部署,为此还实现了专门的 ACME 协议(RFC8555)。有很多的客户端软件可以完成申请、验证、下载、更新的一条龙操作,比如 Certbot、acme.sh 等等,都可以在 Let’s Encrypt 网站上找到,用法很简单,相关的文档也很详细,几分钟就能完成申请,所以在这里就不细说了。但是有几个注意事项:

  • 第一,申请证书时应当同时申请 RSA 和 ECDSA 两种证书,在 Nginx 里配置成双证书验证,这样服务器可以自动选择快速的椭圆曲线证书,同时也兼容只支持 RSA 的客户端
  • 第二,如果申请 RSA 证书,私钥至少要 2048 位,摘要算法应该选用 SHA-2,例如 SHA256、SHA384 等
  • 第三,出于安全的考虑,Let’s Encrypt 证书的有效期很短,只有 90 天,时间一到就会过期失效,所以必须要定期更新。你可以在 crontab 里加个每周或每月任务,发送更新请求,不过很多 ACME 客户端会自动添加这样的定期任务,完全不用你操心

配置 HTTPS

搞定了证书,接下来就是配置 Web 服务器,在 443 端口上开启 HTTPS 服务了。这在 Nginx 上非常简单,只要在 listen 指令后面加上参数 ssl,再配上刚才的证书文件就可以实现最基本的 HTTPS。

listen                443 ssl;
 
ssl_certificate       xxx_rsa.crt;  #rsa2048 cert
ssl_certificate_key   xxx_rsa.key;  #rsa2048 private key
 
ssl_certificate       xxx_ecc.crt;  #ecdsa cert
ssl_certificate_key   xxx_ecc.key;  #ecdsa private ke

为了提高 HTTPS 的安全系数和性能,你还可以强制 Nginx 只支持 TLS1.2 以上的协议,打开 Session Ticket 会话复用:

ssl_protocols               TLSv1.2 TLSv1.3;
 
ssl_session_timeout         5m;
ssl_session_tickets         on;
ssl_session_ticket_key      ticket.key;

密码套件的选择方面,建议是以服务器的套件优先。这样可以避免恶意客户端故意选择较弱的套件、降低安全等级,然后密码套件向 TLS1.3 看齐,只使用 ECDHE、AES 和 ChaCha20,支持 False Start。

ssl_prefer_server_ciphers   on;
 
 
ssl_ciphers   ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE+AES128:!MD5:!SHA1;

服务器名称指示

配置 HTTPS 服务时还有一个 虚拟主机 的问题需要解决,在 HTTP 协议里,多个域名可以同时在一个 IP 地址上运行,这就是 虚拟主机,Web 服务器会使用请求头里的 Host 字段来选择。但在 HTTPS 里,因为请求头只有在 TLS 握手之后才能发送,在握手时就必须选择虚拟主机对应的证书,TLS 无法得知域名的信息,就只能用 IP 地址来区分。所以,最早的时候每个 HTTPS 域名必须使用独立的 IP 地址,非常不方便。

那么怎么解决这个问题呢?这还是得用到 TLS 的扩展,给协议加个 SNI(Server Name Indication)的补充条款。它的作用和 Host 字段差不多,客户端会在 Client Hello 时带上域名信息,这样服务器就可以根据名字而不是 IP 地址来选择证书。

Extension: server_name (len=19)
    Server Name Indication extension
        Server Name Type: host_name (0)
        Server Name: www.chrono.com

Nginx 很早就基于 SNI 特性支持了 HTTPS 的虚拟主机,但在 OpenResty 里可还以编写 Lua 脚本,利用 Redis、MySQL 等数据库更灵活快速地加载证书。

重定向跳转

现在有了 HTTPS 服务,但原来的 HTTP 站点也不能马上弃用,还是会有很多网民习惯在地址栏里直接敲域名(或者是旧的书签、超链接),默认使用 HTTP 协议访问。所以,我们就需要用到之前说的重定向跳转技术了,把不安全的 HTTP 网址用 301 或 302 重定向到新的 HTTPS 网站,这在 Nginx 里也很容易做到,使用 return 或 rewrite 都可以。

return 301 https://$host$request_uri;             # 永久重定向
rewrite ^  https://$host$request_uri permanent;   # 永久重定向

但这种方式有两个问题。一个是重定向增加了网络成本,多出了一次请求;另一个是存在安全隐患,重定向的响应可能会被中间人窜改,实现会话劫持,跳转到恶意网站。

不过有一种叫 HSTS(HTTP 严格传输安全,HTTP Strict Transport Security)的技术可以消除这种安全隐患。HTTPS 服务器需要在发出的响应头里添加一个 Strict-Transport-Security 的字段,再设定一个有效期,例如:

Strict-Transport-Security: max-age=15768000; includeSubDomains

这相当于告诉浏览器:我这个网站必须严格使用 HTTPS 协议,在半年之内(182.5 天)都不允许用 HTTP,你以后就自己做转换吧,不要再来麻烦我了。有了 HSTS 的指示,以后浏览器再访问同样的域名的时候就会自动把 URI 里的 http 改成 https,直接访问安全的 HTTPS 网站。这样中间人就失去了攻击的机会,而且对于客户端来说也免去了一次跳转,加快了连接速度。

HTTP/2 特性概览

我们之前说 HTTP 有两个主要的缺点:安全不足和性能不高。而 HTTPS 虽然通过引入 SSL/TLS 在安全上达到了极致,但在性能提升方面却是乏善可陈,只优化了握手加密的环节,对于整体的数据传输没有提出更好的改进方案,还只能依赖于长连接这种落后的技术。所以,在 HTTPS 逐渐成熟之后,HTTP 就向着性能方面开始发力,走出了另一条进化的道路。

在最开始介绍的 HTTP 历史中我们说了,Google 率先发明了 SPDY 协议,并应用于自家的浏览器 Chrome,打响了 HTTP 性能优化的第一枪。随后互联网标准化组织 IETF 以 SPDY 为基础,综合其他多方的意见,终于推出了 HTTP/1 的继任者,也就是今天的主角 HTTP/2,在性能方面有了一个大的飞跃。

为什么不是 HTTP/2.0

你一定很想知道,为什么 HTTP/2 不像之前的 1.0、1.1 那样叫 2.0 呢?这个也是很多初次接触 HTTP/2 的人问的最多的一个问题,对此 HTTP/2 工作组特别给出了解释。

他们认为以前的 1.0、1.1 造成了很多的混乱和误解,让人在实际的使用中难以区分差异,所以就决定 HTTP 协议不再使用小版本号(minor version),只使用大版本号(major version),从今往后 HTTP 协议不会出现 HTTP/2.0、2.1,只会有 HTTP/2、HTTP/3、……。这样就可以明确无误地辨别出协议版本的跃进程度,让协议在一段较长的时期内保持稳定,每当发布新版本的 HTTP 协议都会有本质的不同,绝不会有零敲碎打的小改良。

兼容 HTTP/1

由于 HTTPS 已经在安全方面做的非常好了,所以 HTTP/2 的唯一目标就是改进性能。但它不仅背负着众多的期待,同时还背负着 HTTP/1 庞大的历史包袱,所以协议的修改必须小心谨慎,兼容性是首要考虑的目标,否则就会破坏互联网上无数现有的资产,这方面 TLS 已经有了先例(为了兼容 TLS1.2 不得不进行伪装)。

那么,HTTP/2 是怎么做的呢?

因为必须要保持功能上的兼容,所以 HTTP/2 把 HTTP 分解成了 语义 和 语法 两个部分,语义层不做改动,与 HTTP/1 完全一致(即 RFC7231)。比如请求方法、URI、状态码、头字段等概念都保留不变,这样就消除了再学习的成本,基于 HTTP 的上层应用也不需要做任何修改,可以无缝转换到 HTTP/2。特别要说的是,与 HTTPS 不同,HTTP/2 没有在 URI 里引入新的协议名,仍然用 http 表示明文协议,用 https 表示加密协议。这是一个非常了不起的决定,可以让浏览器或者服务器去自动升级或降级协议,免去了选择的麻烦,让用户在上网的时候都意识不到协议的切换,实现平滑过渡。

在语义保持稳定之后,HTTP/2 在语法层做了天翻地覆的改造,完全变更了 HTTP 报文的传输格式。

头部压缩

首先,HTTP/2 对报文的头部做了一个大手术。首先 HTTP/1 里可以用头字段 Content-Encoding 指定 Body 的编码方式,比如用 gzip 压缩来节约带宽,但报文的另一个组成部分——Header 却被无视了,没有针对它的优化手段。

由于报文 Header 一般会携带 User Agent、Cookie、Accept、Server 等许多固定的头字段,多达几百字节甚至上千字节,但 Body 却经常只有几十字节(比如 GET 请求、204/301/304 响应),成了不折不扣的大头儿子。更要命的是,成千上万的请求响应报文里有很多字段值都是重复的,非常浪费,长尾效应导致大量带宽消耗在了这些冗余度极高的数据上。

所以,HTTP/2 把头部压缩作为性能改进的一个重点,优化的方式你也肯定能想到,还是压缩。不过 HTTP/2 并没有使用传统的压缩算法,而是开发了专门的 HPACK 算法,在客户端和服务器两端建立字典,用索引号表示重复的字符串,还釆用哈夫曼编码来压缩整数和字符串,可以达到 50%~90% 的高压缩率。

二进制格式

你可能已经很习惯于 HTTP/1 里纯文本形式的报文了,它的优点是一目了然,用最简单的工具就可以开发调试,非常方便。但 HTTP/2 在这方面没有妥协,决定改变延续了十多年的现状,不再使用肉眼可见的 ASCII 码,而是向下层的 TCP/IP 协议靠拢,全面采用二进制格式。这样虽然对人不友好,但却大大方便了计算机的解析。原来使用纯文本的时候容易出现多义性,比如大小写、空白字符、回车换行、多字少字等等,程序在处理时必须用复杂的状态机,效率低,还麻烦。

而二进制里只有 0 和 1,可以严格规定字段大小、顺序、标志位等格式,对就是对,错就是错,解析起来没有歧义,实现简单,而且体积小、速度快,做到内部提效。

以二进制格式为基础,HTTP/2 就开始了大刀阔斧的改革。

它把 TCP 协议的部分特性挪到了应用层,把原来的 Header+Body 的消息打散为数个小片的二进制帧(Frame),用 HEADERS 帧存放头数据、DATA 帧存放实体数据。

这种做法有点像是 Chunked 分块编码的方式,也是化整为零的思路,但 HTTP/2 数据分帧后 Header+Body 的报文结构就完全消失了,协议看到的只是一个个的碎片。

虚拟的流

消息碎片到达目的地后应该怎么组装起来呢?HTTP/2 为此定义了一个流(Stream)的概念,它是二进制帧的双向传输序列,同一个消息往返的帧会分配一个唯一的流 ID。你可以想象把它成是一个虚拟的数据流,在里面流动的是一串有先后顺序的数据帧,这些数据帧按照次序组装起来就是 HTTP/1 里的请求报文和响应报文。因为流是虚拟的,实际上并不存在,所以 HTTP/2 就可以在一个 TCP 连接上用流同时发送多个碎片化的消息,这就是常说的多路复用( Multiplexing)——多个往返通信都复用一个连接来处理。

在流的层面上看,消息是一些有序的帧序列,而在连接的层面上看,消息却是乱序收发的帧。多个请求 / 响应之间没有了顺序关系,不需要排队等待,也就不会再出现队头阻塞问题,降低了延迟,大幅度提高了连接的利用率。

为了更好地利用连接,加大吞吐量,HTTP/2 还添加了一些控制帧来管理虚拟的流,实现了优先级和流量控制,这些特性也和 TCP 协议非常相似。HTTP/2 还在一定程度上改变了传统的 请求 – 响应 工作模式,服务器不再是完全被动地响应请求,也可以新建流主动向客户端发送消息。比如,在浏览器刚请求 HTML 的时候就提前把可能会用到的 JS、CSS 文件发给客户端,减少等待的延迟,这被称为服务器推送(Server Push,也叫 Cache Push)。

强化安全

出于兼容的考虑,HTTP/2 延续了 HTTP/1 的明文特点,可以像以前一样使用明文传输数据,不强制使用加密通信,不过格式还是二进制,只是不需要解密。但由于 HTTPS 已经是大势所趋,而且主流的浏览器 Chrome、Firefox 等都公开宣布只支持加密的 HTTP/2,所以事实上的 HTTP/2 是加密的。也就是说,互联网上通常所能见到的 HTTP/2 都是使用 https 协议名,跑在 TLS 上面。

为了区分加密和明文这两个不同的版本,HTTP/2 协议定义了两个字符串标识符:h2 表示加密的 HTTP/2,h2c 表示明文的 HTTP/2,多出的那个字母 c 的意思是 clear text。

在 HTTP/2 标准制定的时候(2015 年)已经发现了很多 SSL/TLS 的弱点,而新的 TLS1.3 还未发布,所以加密版本的 HTTP/2 在安全方面做了强化,要求下层的通信协议必须是 TLS1.2 以上,还要支持前向安全和 SNI,并且把几百个弱密码套件列入了黑名单,比如 DES、RC4、CBC、SHA-1 都不能在 HTTP/2 里使用,相当于底层用的是 TLS1.25。

协议栈

下面的这张图对比了 HTTP/1、HTTPS 和 HTTP/2 的协议栈,你可以清晰地看到,HTTP/2 是建立在 HPack、Stream、TLS1.2 基础之上的,比 HTTP/1、HTTPS 复杂了一些。

虽然 HTTP/2 的底层实现很复杂,但它的 语义 还是简单的 HTTP/1,之前学习的知识不会过时,仍然能够用得上。

补充

HTTP/2 的前身 SPDY 在压缩头部时使用了 gzip,但发现会受到 CRIME 攻击,所以开发了专用的压缩算法 HPACK。

HTTP/2 的流可以实现 HTTP/1.1 里的管道功能,而且综合性能更好,所以管道在 HTTP/2 里就被废弃了。

如果你写过 Linux 程序,用过 epoll,那么应该知道 epoll 也是一种多路复用,只不过它是 I/O Multiplexing。

HTTP/3 展望

我们一起学习了 HTTP/2,你也应该看到了 HTTP/2 做出的许多努力,比如头部压缩、二进制分帧、虚拟的流与多路复用,性能方面比 HTTP/1 有了很大的提升,基本上解决了队头阻塞这个老大难的问题。

HTTP/2 的队头阻塞

等等,你可能要发出疑问了:为什么说是基本上,而不是完全解决了呢?这是因为 HTTP/2 虽然使用帧、流、多路复用,没有了队头阻塞,但这些手段都是在应用层里,而在下层,也就是 TCP 协议里,还是会发生队头阻塞。

让我们从协议栈的角度来仔细看一下。在 HTTP/2 把多个 请求 – 响应 分解成流,交给 TCP 后,TCP 会再拆成更小的包依次发送(其实在 TCP 里应该叫 segment,也就是 段)。在网络良好的情况下,包可以很快送达目的地。但如果网络质量比较差,像手机上网的时候,就有可能会丢包。而 TCP 为了保证可靠传输,有个特别的丢包重传机制,丢失的包必须要等待重新传输确认,其他的包即使已经收到了,也只能放在缓冲区里,上层的应用拿不出来,只能干着急。

举个栗子,客户端用 TCP 发送了三个包,但服务器所在的操作系统只收到了后两个包,第一个包丢了。那么内核里的 TCP 协议栈就只能把已经收到的包暂存起来,停下等着客户端重传那个丢失的包,这样就又出现了队头阻塞。

  • HTTP/1.1 中的管道( pipeline)传输中如果有一个请求阻塞了,那么队列后请求也统统被阻塞住了
  • HTTP/2 多请求复用一个 TCP 连接,一旦发生丢包,就会阻塞住所有的 HTTP 请求。

由于这种队头阻塞是 TCP 协议固有的,所以 HTTP/2 即使设计出再多的花样也无法解决。而 Google 在推 SPDY 的时候就已经意识到了这个问题,于是就又发明了一个新的 QUIC 协议,让 HTTP 跑在 QUIC 上而不是 TCP 上。而这个 HTTP over QUIC 就是 HTTP 协议的下一个大版本:HTTP/3。它在 HTTP/2 的基础上又实现了质的飞跃,真正完美地解决了队头阻塞问题。不过 HTTP/3 目前还处于草案阶段,正式发布前可能会有变动,所以今天我们尽量不谈那些不稳定的细节。这里先贴一下 HTTP/3 的协议栈图,对它有个大概的了解:

那么问题来了,为什么跑在 QUIC 协议上就能解决这个问题呢?其实图中已经给出答案了,因为 QUIC 协议是基于 UDP 的,而队头阻塞是 TCP 的问题。而 UDP 发生是不管顺序,也不管丢包的,所以不会出现 HTTP/1.1 的队头阻塞 和 HTTP/2 的一个丢包全部重传问题。但是问题又来了,这样不就不安全了吗?所以啊,没有直接采用 UDP,而是采用基于 UDP 之上的 QUIC,因为基于 UDP 的 QUIC 协议可以实现类似 TCP 的可靠性传输。

QUIC 协议

UDP 是一个简单、不可靠的传输协议,只是对 IP 协议的一层很薄的包装,和 TCP 相比,它实际应用的较少。不过正是因为它简单,不需要建连和断连,通信成本低,也就非常灵活、高效,可塑性很强。所以,QUIC 就选定了 UDP,在它之上把 TCP 的那一套连接管理、拥塞窗口、流量控制等搬了过来,去其糟粕,取其精华,打造出了一个全新的可靠传输协议,可以认为是新时代的 TCP。

QUIC 最早是由 Google 发明的,被称为 gQUIC。而当前正在由 IETF 标准化的 QUIC 被称为 iQUIC,两者的差异非常大,甚至比当年的 SPDY 与 HTTP/2 的差异还要大。gQUIC 混合了 UDP、TLS、HTTP,是一个应用层的协议。而 IETF 则对 gQUIC 做了清理,把应用部分分离出来,形成了 HTTP/3,原来的 UDP 部分下放到了传输层,所以 iQUIC 有时候也叫 QUIC-transport。

接下来要说的 QUIC 都是指 iQUIC,要记住,它与早期的 gQUIC 不同,是一个传输层的协议,和 TCP 是平级的。

QUIC 的特点

QUIC 基于 UDP,而 UDP 是无连接的,根本就不需要握手和挥手,所以天生就要比 TCP 快。就像 TCP 在 IP 的基础上实现了可靠传输一样,QUIC 也基于 UDP 实现了可靠传输,保证数据一定能够抵达目的地。它还引入了类似 HTTP/2 的流和多路复用,单个流是有序的,可能会因为丢包而阻塞,但其它流不会受到影响。

为了防止网络上的中间设备(Middle Box)识别协议的细节,QUIC 全面采用加密通信,可以很好地抵御窜改和协议僵化(ossification)。而且,因为 TLS1.3 已经 2018 年正式发布,所以 QUIC 就直接应用了 TLS1.3,顺便也就获得了 0-RTT、1-RTT 连接的好处。但 QUIC 并不是建立在 TLS 之上,而是内部包含了 TLS。它使用自己的帧接管了 TLS 里的记录,握手消息、警报消息都不使用 TLS 记录,直接封装成 QUIC 的帧发送,省掉了一次开销。

QUIC 内部细节

由于 QUIC 在协议栈里比较偏底层,所以这里只简略介绍两个内部的关键知识点。

QUIC 的基本数据传输单位是包(packet)和帧(frame),一个包由多个帧组成,包面向的是连接,帧面向的是流。QUIC 使用不透明的连接 ID 来标记通信的两个端点,客户端和服务器可以自行选择一组 ID 来标记自己,这样就解除了 TCP 里连接对 “IP 地址 + 端口”(即常说的四元组)的强绑定,支持连接迁移(Connection Migration)。

比如你下班回家,手机会自动由 4G 切换到 WiFi。这时 IP 地址会发生变化,TCP 就必须重新建立连接。而 QUIC 连接里的两端连接 ID 不会变,所以连接在逻辑上没有中断,它就可以在新的 IP 地址上继续使用之前的连接,消除重连的成本,实现连接的无缝迁移。

QUIC 的帧里有多种类型,PING、ACK 等帧用于管理连接,而 STREAM 帧专门用来实现流。QUIC 里的流与 HTTP/2 的流非常相似,也是帧的序列,你可以对比着来理解。但 HTTP/2 里的流都是双向的,而 QUIC 则分为双向流和单向流。

QUIC 帧普遍采用变长编码,最少只要 1 个字节,最多有 8 个字节。流 ID 的最大可用位数是 62,数量上比 HTTP/2 的 2^31 大大增加。流 ID 还保留了最低两位用作标志,第 1 位标记流的发起者,0 表示客户端,1 表示服务器;第 2 位标记流的方向,0 表示双向流,1 表示单向流。所以 QUIC 流 ID 的奇偶性质和 HTTP/2 刚好相反,客户端的 ID 是偶数,从 0 开始计数。

HTTP/3 协议

了解了 QUIC 之后,再来看 HTTP/3 就容易多了。因为 QUIC 本身就已经支持了加密、流和多路复用,所以 HTTP/3 的工作减轻了很多,把流控制都交给 QUIC 去做。调用的不再是 TLS 的安全接口,也不是 Socket API,而是专门的 QUIC 函数。不过这个 QUIC 函数还没有形成标准,必须要绑定到某一个具体的实现库。

HTTP/3 里仍然使用流来发送 请求 – 响应,但它自身不需要像 HTTP/2 那样再去定义流,而是直接使用 QUIC 的流,相当于做了一个概念映射。HTTP/3 里的双向流可以完全对应到 HTTP/2 的流,而单向流在 HTTP/3 里用来实现控制和推送,近似地对应 HTTP/2 的 0 号流。

由于流管理被下放到了 QUIC,所以 HTTP/3 里帧的结构也变简单了。帧头只有两个字段:类型和长度,而且同样都采用变长编码,最小只需要两个字节。

HTTP/3 里的帧仍然分成数据帧和控制帧两类,HEADERS 帧和 DATA 帧传输数据,但其他一些帧因为在下层的 QUIC 里有了替代,所以在 HTTP/3 里就都消失了,比如 RST_STREAM、WINDOW_UPDATE、PING 等。

头部压缩算法在 HTTP/3 里升级成了 QPACK,使用方式上也做了改变。虽然也分成静态表和动态表,但在流上发送 HEADERS 帧时不能更新字段,只能引用,索引表的更新需要在专门的单向流上发送指令来管理,解决了 HPACK 的队头阻塞问题。

另外,QPACK 的字典也做了优化,静态表由之前的 61 个增加到了 98 个,而且序号从 0 开始,也就是说:authority的编号是 0。

HTTP/3 服务发现

讲了这么多,不知道你注意到了没有:HTTP/3 没有指定默认的端口号,也就是说不一定非要在 UDP 的 80 或者 443 上提供 HTTP/3 服务。那么,该怎么发现 HTTP/3 呢?

这就要用到 HTTP/2 里的 扩展帧 了,浏览器需要先用 HTTP/2 协议连接服务器,然后服务器可以在启动 HTTP/2 连接后发送一个 Alt-Svc 帧,包含一个 h3=host:port 的字符串,告诉浏览器在另一个端点上提供等价的 HTTP/3 服务。

HTTP/3 综合了我们之前讲的所有技术(HTTP/1、SSL/TLS、HTTP/2),包含知识点很多,比如队头阻塞、0-RTT 握手、虚拟的流、多路复用,算得上是集大成之作,需要多下些功夫好好体会。

WebSocket:沙盒里的TCP

在之前讲 TCP/IP 协议栈的时候,我们说过有 TCP Socket,它实际上是一种功能接口,通过这些接口就可以使用 TCP/IP 协议栈在传输层收发数据。那么,你知道还有一种东西叫 WebSocket 吗?

单从名字上看,Web 指的是 HTTP,Socket 是套接字调用,那么这两个连起来又是什么意思呢?

WebSocket 就是运行在 Web、也就是 HTTP 上的 Socket 通信规范,提供与 TCP Socket 类似的功能,使用它就可以像 TCP Socket 一样调用下层协议栈,任意地收发数据。更准确地说,WebSocket 是一种基于 TCP 的轻量级网络通信协议,在地位上是与 HTTP 平级的。

为什么要有 WebSocket

不过,已经有了被广泛应用的 HTTP 协议,为什么要再出一个 WebSocket 呢?它有哪些好处呢?其实 WebSocket 与 HTTP/2 一样,都是为了解决 HTTP 某方面的缺陷而诞生的。HTTP/2 针对的是队头阻塞,而 WebSocket 针对的是 请求 – 响应 通信模式。那么,请求 – 响应 有什么不好的地方呢?

请求 – 响应 是一种半双工的通信模式,虽然可以双向收发数据,但同一时刻只能一个方向上有动作,传输效率低。更关键的一点,它是一种被动通信模式,服务器只能被动响应客户端的请求,无法主动向客户端发送数据。虽然后来的 HTTP/2、HTTP/3 新增了 Stream、Server Push 等特性,但 请求 – 响应 依然是主要的工作方式,这就导致 HTTP 难以应用在动态页面、即时消息、网络游戏等要求实时通信的领域。

在 WebSocket 出现之前,在浏览器环境里用 JavaScript 开发实时 Web 应用很麻烦。因为浏览器是一个受限的沙盒,不能用 TCP,只有 HTTP 协议可用,所以就出现了很多变通的技术,轮询(polling)就是比较常用的的一种。简单地说,轮询就是不停地向服务器发送 HTTP 请求,问有没有数据,有数据的话服务器就用响应报文回应。如果轮询的频率比较高,那么就可以近似地实现实时通信的效果;但轮询的缺点也很明显,反复发送无效查询请求耗费了大量的带宽和 CPU 资源,非常不经济。

所以,为了克服 HTTP 请求 – 响应 模式的缺点,WebSocket 就应运而生了。它原来是 HTML5 的一部分,后来自立门户,形成了一个单独的标准,RFC 文档编号是 6455。

WebSocket 的特点

WebSocket 是一个真正的全双工通信协议,与 TCP 一样,客户端和服务器都可以随时向对方发送数据,而不用像 HTTP 那么客套。于是,服务器就可以变得更加主动了,一旦后台有新的数据,就可以立即推送给客户端,不需要客户端轮询,实时通信的效率也就提高了。

WebSocket 采用了二进制帧结构,语法、语义与 HTTP 完全不兼容,但因为它的主要运行环境是浏览器,为了便于推广和应用,就不得不搭便车,在使用习惯上尽量向 HTTP 靠拢,这就是它名字里 Web 的含义。服务发现方面,WebSocket 没有使用 TCP 的 IP 地址 + 端口号,而是延用了 HTTP 的 URI 格式,但开头的协议名不是 http,引入的是两个新的名字:ws 和 wss,分别表示明文和加密的 WebSocket 协议。

WebSocket 的默认端口也选择了 80 和 443,因为现在互联网上的防火墙屏蔽了绝大多数的端口,只对 HTTP 的 80、443 端口放行,所以 WebSocket 就可以伪装成 HTTP 协议,比较容易地穿透防火墙,与服务器建立连接。具体是怎么伪装的,稍后再讲。下面举几个 WebSocket 服务的例子,你看看是不是和 HTTP 几乎一模一样:

ws://www.xxx.com
ws://www.xxx.com:8080/srv
wss://www.xxx.com:445/im?user_id=xxx

要注意的一点是,WebSocket 的名字容易让人产生误解,虽然大多数情况下我们会在浏览器里调用 API 来使用 WebSocket,但它不是一个调用接口的集合,而是一个通信协议,可以简单把它理解成 TCP over Web。

WebSocket 的帧结构

刚才说了,WebSocket 用的也是二进制帧,有之前 HTTP/2、HTTP/3 的经验,相信你这次也能很快掌握 WebSocket 的报文结构。不过 WebSocket 和 HTTP/2 的关注点不同,WebSocket 更侧重于实时通信,而 HTTP/2 更侧重于提高传输效率,所以两者的帧结构也有很大的区别。

WebSocket 虽然有帧,但却没有像 HTTP/2 那样定义流,也就不存在多路复用、优先级等复杂的特性,而它自身就是全双工的,也就不需要服务器推送。所以综合起来,WebSocket 的帧学习起来会简单一些。下图就是 WebSocket 的帧结构定义,长度不固定,最少 2 个字节,最多 14 字节,看着好像很复杂,实际非常简单。

开头的两个字节是必须的,也是最关键的。第一个字节的第一位 FIN 是消息结束的标志位,相当于 HTTP/2 里的 END_STREAM,表示数据发送完毕。一个消息可以拆成多个帧,接收方看到 FIN 后,就可以把前面的帧拼起来,组成完整的消息。FIN 后面的三个位是保留位,目前没有任何意义,但必须是 0。

第一个字节的后 4 位很重要,叫 Opcode(操作码),其实就是帧类型,比如 1 表示帧内容是纯文本,2 表示帧内容是二进制数据,8 是关闭连接,9 和 10 分别是连接保活的 PING 和 PONG。

第二个字节第一位是掩码标志位 MASK,表示帧内容是否使用异或操作(xor)做简单的加密。目前的 WebSocket 标准规定,客户端发送数据必须使用掩码,而服务器发送则必须不使用掩码。第二个字节后 7 位是 Payload len,表示帧内容的长度。它是另一种变长编码,最少 7 位,最多是 7+64 位,也就是额外增加 8 个字节,所以一个 WebSocket 帧最大是 2^64。

长度字段后面是 Masking-key(掩码密钥),它是由上面的标志位 MASK 决定的,如果使用掩码就是 4 个字节的随机数,否则就不存在。

这么分析下来,其实 WebSocket 的帧头就四个部分:结束标志位 + 操作码 + 帧长度 + 掩码,只是使用了变长编码的小花招,不像 HTTP/2 定长报文头那么简单明了。

WebSocket 的握手

和 TCP、TLS 一样,WebSocket 也要有一个握手过程,然后才能正式收发数据。这里它还是搭上了 HTTP 的便车,利用了 HTTP 本身的协议升级特性,伪装成 HTTP,这样就能绕过浏览器沙盒、网络防火墙等等限制,这也是 WebSocket 与 HTTP 的另一个重要关联点。WebSocket 的握手是一个标准的 HTTP GET 请求,但要带上两个协议升级的专用头字段:

  • Connection: Upgrade,表示要求协议升级
  • Upgrade: websocket,表示要升级成 WebSocket 协议

另外,为了防止普通的 HTTP 消息被意外识别成 WebSocket,握手消息还增加了两个额外的认证用头字段(所谓的挑战,Challenge):

  • Sec-WebSocket-Key:一个 Base64 编码的 16 字节随机数,作为简单的认证密钥
  • Sec-WebSocket-Version:协议的版本号,当前必须是 13

服务器收到 HTTP 请求报文,看到上面的四个字段,就知道这不是一个普通的 GET 请求,而是 WebSocket 的升级请求,于是就不走普通的 HTTP 处理流程,而是构造一个特殊的 101 Switching Protocols 响应报文,通知客户端,接下来就不用 HTTP 了,全改用 WebSocket 协议通信(有点像 TLS 的 Change Cipher Spec)。

WebSocket 的握手响应报文也是有特殊格式的,要用字段 Sec-WebSocket-Accept 验证客户端请求报文,同样也是为了防止误连接。具体的做法是把请求头里 Sec-WebSocket-Key 的值,加上一个专用的 UUID:258EAFA5-E914-47DA-95CA-C5AB0DC85B11(也被成为魔法字符串),再计算 SHA-1 摘要。

encode_base64(sha1(Sec-WebSocket-Key + b\'258EAFA5-E914-47DA-95CA-C5AB0DC85B11\'))

客户端收到响应报文,就可以用同样的算法,比对值是否相等,如果相等,就说明返回的报文确实是刚才握手时连接的服务器,认证成功。握手完成,后续传输的数据就不再是 HTTP 报文,而是 WebSocket 格式的二进制帧了。

使用 Python 的 socket 模块实现 WebSocket

HTTP/1.1 和 WebSocket 都是应用层协议,所以我们都可以通过传输层协议去实现它。

# -*- coding:utf-8 -*-
import socket
import struct
import hashlib
import base64


def get_headers(data: bytes):
    """
    我们说 HTTP 报文就由请求头和请求体组成,并且两者之间有一个换行
    那么如果我们用 \r\n\r\n 进行分隔的话,是不是就能分别得到请求头和请求体了呢
    然后再将请求头用 \r\n 分隔就能得到一个列表,里面第一个元素是起始行,剩余的元素是 header 的每一个部分
    :param data:
    :return:
    """
    # 拿到请求头
    headers = data.split(b"\r\n\r\n", 1)[0].split(b"\r\n")
    # 获取 header,变成字典
    headers = {k: v.strip() for k, v in map(lambda x: x.split(b":", 1), headers[1:])}
    return headers


def response_header(sec_websocket_key: bytes):
    """
    服务端在接收到之后也要生成特殊的响应报文,将请求头中的 sec_websocket_key 和 魔法字符串组合起来
    计算 SHA-1 摘要,服务端会进行同样的验证,如果一致那么证明连接建立成功
    :return:
    """
    digest = base64.b64encode(
        hashlib.sha1(sec_websocket_key + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest()
    )
    # 构造响应头
    header = (b"HTTP/1.1 101 Switching Protocols\r\n"
              b"Upgrade:websocket\r\n"
              b"Connection:Upgrade\r\n"
              b"Sec-WebSocket-Accept:%s\r\n\r\n") % digest
    return header


def send_msg(conn, msg: bytes):
    """
    服务端向客户端发送数据
    :param conn:
    :param msg:
    :return:
    """
    token = b"\x81"
    length = len(msg)
    if length < 126:
        token += struct.pack("B", length)
    elif length <= 0xFFFF:
        token += struct.pack("!BH", 126, length)
    else:
        token += struct.pack("!BQ", 126, length)
    msg = token + msg
    conn.send(msg)
    return True


def main():
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("localhost", 9999))
    s.listen(3)

    # 接收数据
    conn, addr = s.accept()
    data = conn.recv(1024)
    req_header = get_headers(data)
    resp_header = response_header(req_header[b"Sec-WebSocket-Key"])

    # 发送响应头,建立连接,WebSocket 只需要建立一次连接
    conn.send(resp_header )

    # 之后通过 TCP 连接发送数据
    while True:
        try:
            info = conn.recv(8096)
        except Exception:
            info = None
        if not info:
            break
        payload_len = info[1] & 127
        if payload_len == 126:
            extend_payload_len = info[2:4]
            mask = info[4:8]
            decoded = info[8:]
        elif payload_len == 127:
            extend_payload_len = info[2:10]
            mask = info[10:14]
            decoded = info[14:]
        else:
            extend_payload_len = None
            mask = info[2:6]
            decoded = info[6:]

        bytes_list = bytearray()
        for i in range(len(decoded)):
            chunk = decoded[i] ^ mask[i % 4]
            bytes_list.append(chunk)

        bytes_list.extend(" from server".encode("utf-8"))
        send_msg(conn, bytes(bytes_list))

    s.close()


if __name__ == \'__main__\':
    main()

然后我们通过浏览器去连接:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <script>
        ws = new WebSocket("ws://localhost:9999/ws");

        //如果连接成功, 会打印下面这句话, 否则不会打印
        ws.onopen = function () {
            console.log(\'连接成功\')
        };

        //接收数据, 服务端有数据过来, 会执行
        ws.onmessage = function (event) {
            console.log(event)
        };

        //服务端主动断开连接, 会执行.
        //客户端主动断开的话, 不执行
        ws.onclose = function () {  }

    </script>
</body>
</html>

浏览器是一个沙盒环境,有很多的限制,不允许建立 TCP 连接收发数据,而有了 WebSocket,我们就可以在浏览器里与服务器直接建立TCP 连接,获得更多的自由。只不过自由也是有代价的,WebSocket 虽然是在应用层,但使用方式却与 TCP Socket 差不多,过于原始,用户必须自己管理连接、缓存、状态,开发上比 HTTP 复杂的多,所以是否要在项目中引入 WebSocket 必须慎重考虑。

HTTP 的性能优化

既然要做性能优化,那么,我们就需要知道:什么是性能?它都有哪些指标,又应该如何度量,进而采取哪些手段去优化?

性能其实是一个复杂的概念,不同的人、不同的应用场景都会对它有不同的定义。对于 HTTP 来说,它又是一个非常复杂的系统,里面有非常多的角色,所以很难用一两个简单的词就能把性能描述清楚。

还是从 HTTP 最基本的 请求 – 响应 模型来着手吧。在这个模型里有两个角色:客户端和服务器,还有中间的传输链路,考查性能就可以看这三个部分。

HTTP 服务器

我们先来看看服务器,它一般运行在 Linux 操作系统上,用 Apache、Nginx 等 Web 服务器软件对外提供服务,所以性能的含义就是它的服务能力,也就是尽可能多、尽可能快地处理用户的请求。

衡量服务器性能的主要指标有三个:吞吐量(requests per second)、并发数(concurrency)和 响应时间(time per request)。

吞吐量就是我们常说的 RPS,每秒的请求次数,也有叫 TPS、QPS,它是服务器最基本的性能指标,RPS 越高就说明服务器的性能越好。

并发数反映的是服务器的负载能力,也就是服务器能够同时支持的客户端数量,当然也是越多越好,能够服务更多的用户。

响应时间反映的是服务器的处理能力,也就是快慢程度,响应时间越短,单位时间内服务器就能够给越多的用户提供服务,提高吞吐量和并发数。

除了上面的三个基本性能指标,服务器还要考虑 CPU、内存、硬盘和网卡等系统资源的占用程度,利用率过高或者过低都可能有问题。在 HTTP 多年的发展过程中,已经出现了很多成熟的工具来测量这些服务器的性能指标,开源的、商业的、命令行的、图形化的都有。在 Linux 上,最常用的性能测试工具可能就是 ab(Apache Bench)了,比如,下面的命令指定了并发数 100,总共发送 10000 个请求:

ab -c 100 -n 10000 \'http://www.xxx.com\'

系统资源监控方面,Linux 自带的工具也非常多,常用的有 uptime、top、vmstat、netstat、sar 等等,随便列几个简单的例子:

top             # 查看 CPU 和内存占用情况
vmstat  2       # 每 2 秒检查一次系统状态
sar -n DEV 2    # 看所有网卡的流量,定时 2 秒检查

理解了这些性能指标,我们就知道了服务器的性能优化方向:合理利用系统资源,提高服务器的吞吐量和并发数,降低响应时间。

HTTP 客户端

看完了服务器的性能指标,我们再来看看如何度量客户端的性能。客户端是信息的消费者,一切数据都要通过网络从服务器获取,所以它最基本的性能指标就是 延迟(latency)。

之前在介绍 HTTP/2 的时候就简单介绍过延迟。所谓的延迟其实就是等待,等待数据到达客户端时所花费的时间。但因为 HTTP 的传输链路很复杂,所以延迟的原因也就多种多样。

首先,我们必须谨记有一个不可逾越的障碍——光速,因为地理距离而导致的延迟是无法克服的,访问数千公里外的网站显然会有更大的延迟;其次,第二个因素是带宽,它又包括接入互联网时的电缆、WiFi、4G 和运营商内部网络、运营商之间网络的各种带宽,每一处都有可能成为数据传输的瓶颈,降低传输速度,增加延迟;第三个因素是 DNS 查询,如果域名在本地没有缓存,就必须向 DNS 系统发起查询,引发一连串的网络通信成本,而在获取 IP 地址之前客户端只能等待,无法访问网站;第四个因素是 TCP 握手,你应该对它比较熟悉了吧,必须要经过 SYN、SYN/ACK、ACK 三个包之后才能建立连接,它带来的延迟由光速和带宽共同决定。

建立 TCP 连接之后,就是正常的数据收发了,后面还有解析 HTML、执行 JavaScript、排版渲染等等,这些也会耗费一些时间。不过它们已经不属于 HTTP 了,所以不在今天的讨论范围之内。

HTTP 传输链路

服务器接入互联网的传输线路,它的带宽直接决定了网站对外的服务能力,也就是吞吐量等指标。显然,优化性能应该在这里加大投入,尽量购买大带宽,接入更多的运营商网络。

还有就是采用 CDN 进行加速。

总结

HTTP 是在是太常用了,以至于我们都忽略了它,因为平时一直在用。但当真仔细回过头来仔细审视 HTTP 的时候,发现里面的内容还真不少。

结束啦,最后还是以我女神作为结尾吧。

本文学习自:极客时间的《透视 HTTP 协议》,罗剑锋老师开设的专栏。

版权声明:本文为traditional原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/traditional/p/12694824.html