HTTP

01-HTTP简史 阅读更多

0.1. HTTP的诞生 0.1.1. HTTP/0.9 0.1.2. HTTP/1.0 0.1.3. HTTP/1.1 0.1.4. HTTP/2 0.1.5. HTTP/3 0.2. HTTP的含义 0.2.1. 协议 0.2.2. 传输 0.2.2.1. 双向协议 0.2.2.2. 中转 0.2.3. 超文本 0.3. HTTP相关概念 0.3.1. 网络世界 0.3.2. 浏览器 0.3.3. Web服务器 0.3.4. CDN 0.3.5. 爬虫 0.3.6. HTML/WebService/WAF 0.4. HTTP相关协议 0.4.1. TCP/IP 0.4.2. DNS 0.4.2.1. 域名 0.4.2.2. 域名解析 0.4.2.3. 域名新玩法 0.4.3. URI/URL 0.4.4. HTTPS 0.4.5. 代理 0.5. 网络分层模型 0.5.1. TCP/IP网络分层模型 0.5.1.1. TCP/UDP 0.5.2. OSI网络分层模型 0.5.3. 两个分层模型的映射关系 0.5.4. TCP/IP协议栈的工作方式 0.1. HTTP的诞生 20 世纪 60 年代,美国国防部高等研究计划署(ARPA)建立了 ARPA 网,它有四个分布在各地的节点,被认为是如今互联网的“始祖”。 20 世纪 70 年代,基于对 ARPA 网的实践和思考,研究人员发明出了著名的 TCP/IP 协议。 20 世纪 80 年代中期,具有良好封层结构和稳定性的 TCP/IP 协议进入了 UNIX 系统内核,促使更多的计算机接入了互联网。 1989 年,Tim Berners-Lee 发表论文,提出在互联网上构建超链接文档系统的构想。这篇论文中确立了三项关键技术。 URI:即统一资源标识符,作为互联网上资源的唯一身份; HTML:即超文本标记语言,描述超文本文档; HTTP:即超文本传输协议,用来传输超文本。 基于此诞生了万维网(World Wide Web)。 0.1.1. HTTP/0.9 20 世纪 90 年代初期,互联网世界非常简陋,计算机处理能力低,存储容量小,网速很慢。网络上绝大多数的资源都是纯文本,很多通信协议也都使用纯文本,所以 HTTP 的设计也不可避免地受到了时代的限制。这一时期的 HTTP 被定义为 0.9 版,结构比较简单,为了便于服务器和客户端处理,它也采用了纯文本格式。 Tim Berners-Lee 最初设想的系统里的文档都是只读的,所以只允许用“GET”动作从服务器上获取 HTML 文档,并且在响应请求之后立即关闭连接,功能非常有限。 0.1.2. HTTP/1.0 1992 年发明了 JPEG 图像格式。 1993 年,NCSA开发出了 Mosaic,是第一个可以图文混排的浏览器。 1995 年开发出了服务器软件 Apache,简化了 HTTP 服务器的搭建工作。 1995 年发明了 MP3 音乐格式。 从用户需求的角度促进了 HTTP 的发展。 1996 年正式发布 HTTP/1.0 版本(HTTP/1.0 并不是一个“标准”,相当于一个“备忘录”),增强了0.9版本。 增加了 HEAD、POST 等新方法; 增加了响应状态码,标记可能的错误原因; 引入了协议版本号概念; 引入了 HTTP Header(头部)的概念,让 HTTP 处理请求和响应更加灵活; 传输的数据不再仅限于文本。 0.1.3. HTTP/1.1 1995 年,网景的 Netscape Navigator 和微软的 Internet Explorer 开始了著名的“浏览器大战”,这再一次极大地推动了 Web 的发展,HTTP/1.0 也在这个过程中经受了实践检验。 1999 年,HTTP/1.1 发布了 RFC 文档,编号为 2616(它是一个“正式的标准”,而不是一份可有可无的“参考文档”),正式确立了延续十余年的传奇。 这意味着今后互联网上所有的浏览器、服务器、网关、代理等等,只要用到 HTTP 协议,就必须严格遵守这个标准,相当于是互联网世界的一个“立法”。 HTTP/1.1 主要的变更点有: 增加了 PUT、DELETE 等新的方法; 增加了缓存管理和控制; 明确了连接管理,允许持久连接; 允许响应数据分块(chunked),利于传输大文件; 强制要求 Host 头,让互联网主机托管成为可能。 HTTP/1.1 的推出开启了后续的“Web 1.0”“Web 2.0”时代。 2014 年对庞大且复杂的 HTTP/1.1 做了一次修订,被拆分成了六份较小的文档,编号为 7230-7235,优化了一些细节,没有任何实质性的改动。 0.1.4. HTTP/2 HTTP/1.1 发布之后的十几年间也出现了一些对 HTTP 不满的意见,主要就是连接慢,无法跟上迅猛发展的互联网,但 HTTP/1.1 标准一直“岿然不动”,无奈之下只好发明各式各样的“小花招”来缓解这些问题,比如以前常见的切图、JS 合并等网页优化手段。 Google 开发了浏览器 Chrome,然后推出了新的 SPDY 协议,并在 Chrome 里应用于自家的服务器,从实际的用户方来“倒逼”HTTP 协议的变革,这也开启了第二次的“浏览器大战”,Chrome 目前的全球的占有率超过了 60%,借此顺势把 SPDY 推上了标准的宝座。 2015 年互联网标准化组织以 SPDY 为基础制定新版本 HTTP 协议 发布了 HTTP/2,RFC 编号 7540。 HTTP/2的制定充分考虑了现今互联网的现状:宽带、移动、不安全,在高度兼容 HTTP/1.1 的同时在性能改善方面做了很大努力,主要的特点有: 二进制协议,不再是纯文本; 可发起多个请求,废弃了 1.1 里的管道; 使用专用算法压缩头部,减少数据传输量; 允许服务器主动向客户端推送数据; 增强了安全性,“事实上”要求加密通信。 HTTP/2 衍生出了 gRPC 等新协议,目前普及率比较低,大多数网站使用的仍然还是 20 年前的 HTTP/1.1。 0.1.5. HTTP/3 在 HTTP/2 还处于草案之时,Google 又发明了一个新的协议,叫做 QUIC,依托它的庞大用户量和数据量,持续地推动 QUIC 协议成为互联网上的“既成事实”。 2018 年,互联网标准化组织提议将“HTTP over QUIC”更名为“HTTP/3”并获得批准,HTTP/3 正式进入了标准化制订阶段,也许两三年后就会正式发布,到时候很可能会跳过 HTTP/2 直接进入 HTTP/3。 0.2. HTTP的含义 HTTP(超文本传输协议): 0.2.1. 协议 首先,HTTP 是一个协议,必须要有两个或多个参与者,也就是“协”,协议是对参与者的一种行为约定和规范,也就是“议”。 协议意味着有多个参与者为了达成某个共同的目的而站在了一起,除了要无疑义地沟通交流之外,还必须明确地规定各方的“责、权、利”,约定该做什么不该做什么,先做什么后做什么,做错了怎么办,有没有补救措施等等。 HTTP 是一个用在计算机世界里的协议。它使用计算机能够理解的语言确立了一种计算机之间交流通信的规范,以及相关的各种控制和错误处理方式。 0.2.2. 传输 计算机和网络世界里有数不清的各种角色:CPU、内存、总线、磁盘、操作系统、浏览器、网关、服务器……这些角色之间相互通信也必然会有各式各样、五花八门的协议,用处也各不相同,例如广播协议、寻址协议、路由协议、隧道协议、选举协议等等。 HTTP 是一个“传输协议”,所谓的“传输”(Transfer),就是把一堆东西从 A 点搬到 B 点,或者从 B 点搬到 A 点,即“A<===>B”。 0.2.2.1. 双向协议 HTTP 协议是一个“双向协议”。有两个最基本的参与者 A 和 B,从 A 开始到 B 结束,数据在 A 和 B 之间双向而不是单向流动。 通常把先发起传输动作的 A 叫做请求方, 把后接到传输的 B 叫做应答方或者响应方。 0.2.2.2. 中转 数据虽然是在 A 和 B 之间传输,但并没有限制只有 A 和 B 这两个角色,允许中间有“中转”或者“接力”。 这样,传输方式就从“A<===>B”,变成了“A<=>X<=>Y<=>Z<=>B”,A 到 B 的传输过程中可以存在任意多个“中间人”,而这些中间人也都遵从 HTTP 协议,只要不打扰基本的数据传输,就可以添加任意的额外功能,例如安全认证、数据压缩、编码转换等等,优化整个传输过程。 HTTP 是一个在计算机世界里专门用来在两点之间传输数据的约定和规范。 0.2.3. 超文本 所谓“文本”(Text),就表示 HTTP 传输的不是 TCP/UDP 这些底层协议里被切分的杂乱无章的二进制包(datagram),而是完整的、有意义的数据,可以被浏览器、服务器这样的上层应用程序处理。 在互联网早期,“文本”只是简单的字符文字,但发展到现在,“文本”的涵义已经被大大地扩展了,图片、音频、视频、甚至是压缩包,在 HTTP 眼里都可以算做是“文本”。 所谓“超文本”,就是“超越了普通文本的文本”,它是文字、图片、音频和视频等的混合体,最关键的是含有“超链接”,能够从一个“超文本”跳跃到另一个“超文本”,形成复杂的非线性、网状的结构关系。 对于“超文本”,我们最熟悉的就应该是 HTML 了,它本身只是纯文字文件,但内部用很多标签定义了对图片、音频、视频等的链接,再经过浏览器的解释,呈现在我们面前的就是一个含有多种视听信息的页面。 HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范。 HTTP 是一个协议,是一种计算机间通信的规范,所以它不存在“单独的实体”,是一种“动态的存在”,是发生在网络连接、传输超文本数据时的一个“动态过程”。 编程语言是人与计算机沟通交流所使用的语言,而 HTTP 是计算机与计算机沟通交流的语言,我们无法使用 HTTP 来编程,但可以用编程语言去实现 HTTP,告诉计算机如何用 HTTP 来与外界通信。 在互联网世界里,HTTP 通常跑在 TCP/IP 协议栈之上, 依靠 IP 协议实现寻址和路由、 TCP 协议实现可靠数据传输、 DNS 协议实现域名查找、 SSL/TLS 协议实现安全通信。 此外,还有一些协议依赖于 HTTP,例如 WebSocket、HTTPDNS 等。这些协议相互交织,构成了一个协议网,而 HTTP 则处于中心地位。 0.3. HTTP相关概念 与 HTTP 相关的各种应用,着重介绍互联网、浏览器、Web 服务器等常见且重要的概念。 0.3.1. 网络世界 实际的互联网是由许许多多个规模略小的网络连接而成的,这些“小网络”可能是只有几百台电脑的局域网,可能是有几万、几十万台电脑的广域网,可能是用电缆、光纤构成的固定网络,也可能是用基站、热点构成的移动网络…… 互联网的正式名称是 Internet,里面存储着无穷无尽的信息资源,通常所说的“上网”实际上访问的只是互联网的一个子集“万维网”(World Wide Web),它基于 HTTP 协议,传输 HTML 等超文本资源,能力也就被限制在 HTTP 协议之内。 互联网上还有许多万维网之外的资源,例如常用的电子邮件、BT 和 Magnet 点对点下载、FTP 文件下载、SSH 安全登录、各种即时通信服务等等,它们需要用各自的专有协议来访问。 HTTP 协议灵活、易于扩展,且“超文本”的表述能力很强,很多其他原本不属于 HTTP 的资源也可以“包装”成 HTTP 来访问,这就是为什么能够总看到各种“网页应用”——例如“微信网页版”“邮箱网页版”——的原因。 0.3.2. 浏览器 浏览器的正式名字叫“Web Browser”,顾名思义,就是检索、查看互联网上网页资源的应用程序,名字里的 Web,实际上指的就是“World Wide Web”,也就是万维网。 浏览器本质上是一个 HTTP 协议中的请求方,使用 HTTP 协议获取网络上的各种资源。为了更好地检索查看网页,它还集成了很多额外的功能。例如: HTML 排版引擎用来展示页面, JavaScript 引擎用来实现动态化效果,甚至还有开发者工具用来调试网页, 五花八门的各种插件和扩展。 在 HTTP 协议里,浏览器的角色被称为“User Agent”即“用户代理”,意思是作为访问者的“代理”来发起 HTTP 请求。不过在不引起混淆的情况下,通常都简单地称之为“客户端”。 0.3.3. Web服务器 浏览器是 HTTP 里的请求方,那么在协议另一端的应答方(响应方)就是服务器,Web Server。Web 服务器是 HTTP 协议里响应请求的主体,通常把控着绝大多数的网络资源,在网络世界里处于强势地位。 “Web 服务器”有两个层面的含义:硬件和软件。 硬件含义:是物理形式或“云”形式的机器,在大多数情况下它可能不是一台服务器,而是利用反向代理、负载均衡等技术组成的庞大集群。但从外界看来,它仍然表现为一台机器,但这个形象是“虚拟的”。 软件含义:是提供 Web 服务的应用程序,通常会运行在硬件含义的服务器上。它利用强大的硬件能力响应海量的客户端 HTTP 请求,处理磁盘上的网页、图片等静态文件,或者把请求转发给后面的 Tomcat、Node.js 等业务应用,返回动态的信息。 比起层出不穷的各种 Web 浏览器,Web 服务器就要少很多了,一只手的手指头就可以数得过来。 Apache 是老牌的服务器,到今天已经快 25 年了,功能相当完善,相关的资料很多,学习门槛低,是许多创业者建站的入门产品。 Nginx 是 Web 服务器里的后起之秀,特点是高性能、高稳定,且易于扩展。自 2004 年推出后就不断蚕食 Apache 的市场份额,在高流量的网站里更是不二之选。 此外,还有 Windows 上的 IIS、Java 的 Jetty/Tomcat 等,因为性能不是很高,所以在互联网上应用得较少。 0.3.4. CDN 浏览器和服务器是 HTTP 协议的两个端点,浏览器通常不会直接连到服务器,中间会经过“重重关卡”,其中的一个重要角色就叫做 CDN。 CDN,全称是“Content Delivery Network(内容分发网络)”,它应用了 HTTP 协议里的缓存和代理技术,代替源站响应客户端的请求。CDN 可以缓存源站的数据,让浏览器的请求不用“千里迢迢”地到达源站服务器,直接在“半路”就可以获取响应。 如果 CDN 的调度算法很优秀,更可以找到离用户最近的节点,大幅度缩短响应时间。 CDN 也是现在互联网中的一项重要基础设施,除了基本的网络加速外,还提供负载均衡、安全防护、边缘计算、跨运营商网络等功能,能够成倍地“放大”源站服务器的服务能力,很多云服务商都把 CDN 作为产品的一部分。 0.3.5. 爬虫 浏览器是一种用户代理,代替我们访问互联网。HTTP 协议并没有规定用户代理后面必须是“真正的人类”,它也完全可以是“机器人”,这些“机器人”的正式名称就叫做“爬虫”(Crawler),实际上是一种可以自动访问 Web 资源的应用程序。 据估计,互联网上至少有 50% 的流量都是由爬虫产生的,某些特定领域的比例还会更高,也就是说,如果你的网站今天的访问量是十万,那么里面至少有五六万是爬虫机器人,而不是真实的用户。 爬虫绝大多数是由各大搜索引擎“放”出来的,抓取网页存入庞大的数据库,再建立关键字索引,这样我们才能够在搜索引擎中快速地搜索到互联网角落里的页面。 爬虫不好的一面,会过度消耗网络资源,占用服务器和带宽,影响网站对真实数据的分析,甚至导致敏感信息泄漏。 “反爬虫”技术,通过各种手段来限制爬虫。其中一项就是“君子协定”robots.txt,约定哪些该爬,哪些不该爬。无论是“爬虫”还是“反爬虫”,用到的基本技术都是两个,一个是 HTTP,另一个就是 HTML。 0.3.6. HTML/WebService/WAF HTML、WebService、WAF 等由于与 HTTP 技术上实质关联不太大。 HTML 是 HTTP 协议传输的主要内容之一,它描述了超文本页面,用各种“标签”定义文字、图片等资源和排版布局,最终由浏览器“渲染”出可视化页面。HTML 目前有两个主要的标准,HTML4 和 HTML5。广义上的 HTML 通常是指 HTML、JavaScript、CSS 等前端技术的组合,能够实现比传统静态页面更丰富的动态页面。 Web Service,它的名字与 Web Server 很像,但却是一个完全不同的东西。Web Service 是一种由 W3C 定义的应用服务开发规范,使用 client-server 主从架构,通常使用 WSDL 定义服务接口,使用 HTTP 协议传输 XML 或 SOAP 消息,也就是说,它是一个基于 Web(HTTP)的服务架构技术,既可以运行在内网,也可以在适当保护后运行在外网。因为采用了 HTTP 协议传输数据,所以在 Web Service 架构里服务器和客户端可以采用不同的操作系统或编程语言开发。例如服务器端用 Linux+Java,客户端用 Windows+C#,具有跨平台跨语言的优点。 WAF 近几年比较“火”是“网络应用防火墙”。与硬件“防火墙”类似,它是应用层面的“防火墙”,专门检测 HTTP 流量,是防护 Web 应用的安全技术。WAF 通常位于 Web 服务器之前,可以阻止如 SQL 注入、跨站脚本等攻击,目前应用较多的一个开源项目是 ModSecurity,它能够完全集成进 Apache 或 Nginx。 0.4. HTTP相关协议 各种 HTTP 相关协议,重点是 TCP/IP、DNS、URI、HTTPS 等。 0.4.1. TCP/IP TCP/IP 协议是目前网络世界“事实上”的标准通信协议,实际上它是一系列网络通信协议的统称,其中最核心的两个协议是 TCP 和 IP,还有 UDP、ICMP、ARP 等,共同构成了一个复杂但有层次的协议栈。这个协议栈有四层: 最上层是“应用层” TCP 属于“传输层” IP 属于“网际层” 最下层是“链接层” IP 协议是“Internet Protocol”的缩写,主要目的是解决寻址和路由问题,以及如何在两点间传送数据包。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”。 0.4.2. DNS 在 TCP/IP 协议中使用 IP 地址来标识计算机,数字形式的地址便于计算机但对于人类来说不便。于是“域名系统”(Domain Name System)出现用有意义的名字来作为 IP 地址的等价替代。 在 DNS 中,“域名”(Domain Name)又称为“主机名”(Host),为了更好地标记不同国家或组织的主机,所以被设计成了一个有层次的结构。域名用“.”分隔成多个单词,级别从左到右逐级升高,最右边的被称为“顶级域名”。 对于顶级域名,例如: 表示商业公司的“com” 表示教育机构的“edu” 表示国家的“cn”“uk”等 要使用 TCP/IP 协议来通信仍然要使用 IP 地址,所以需要把域名做一个转换,“映射”到它的真实 IP,这就是所谓的“域名解析”。域名解析的实际操作复杂很多,目前全世界有 13 组根 DNS 服务器,下面再有许多的顶级 DNS、权威 DNS 和更小的本地 DNS,逐层递归地实现域名查询。 HTTP 协议中并没有明确要求必须使用 DNS,但实际上为了方便访问互联网上的 Web 服务器,通常都会使用 DNS 来定位或标记主机名,间接地把 DNS 与 HTTP 绑在了一起。 0.4.2.1. 域名 域名是一个有层次的结构,是一串用“.”分隔的多个单词,最右边的被称为“顶级域名”,然后是“二级域名”,层级关系向左依次降低。最左边的是主机名,通常用来表明主机的用途,比如: “www”表示提供万维网服务 “mail”表示提供邮件服务 不过这也不是绝对的,名字的关键是要让我们容易记忆。域名不仅能够代替 IP 地址,还有许多其他的用途。在 Apache、Nginx 这样的 Web 服务器里,域名可以用来标识虚拟主机,决定由哪个虚拟主机来对外提供服务,比如在 Nginx 里就会使用“server_name”指令: server { listen 80; #监听80端口 server_name time.geekbang.org; #主机名是time.geekbang.org ... } 域名本质上还是个名字空间系统,使用多级域名就可以划分出不同的国家、地区、组织、公司、部门,每个域名都是独一无二的,可以作为一种身份的标识。因为这个特性,域名也被扩展到了其他应用领域,比如 Java 的包机制就采用域名作为命名空间,只是它使用了反序。而 XML 里使用 URI 作为名字空间,也是间接使用了域名。 域名的总长度限制在253个字符以内,而每一级域名长度不能超过63个字符,域名是大小写无关的,通常使用小写形式。 0.4.2.2. 域名解析 就像 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 系统。这些“野生”服务器被称为“非权威域名服务器”,可以缓存之前的查询结果,如果已经有了记录,就无需再向根服务器发起查询,直接返回对应的 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 服务器的压力,效率就大大提升了。 0.4.2.3. 域名新玩法 有了域名,又有了可以稳定工作的解析系统,就可以实现比 IP 地址更多的“新玩法”了。 第一种,“重定向”。域名代替了 IP 地址,可以让对外服务的域名不变,而主机的 IP 地址任意变动。当主机有情况需要下线、迁移时,可以更改 DNS 记录,让域名指向其他的机器,这样就可以保证业务服务不中断。 第二种,因为域名是一个名字空间,可以使用 bind9 等开源软件搭建一个内部DNS服务器。这样各种内部服务就都用域名来标记,发起网络通信时也就不必再使用写死的 IP 地址,可以直接用域名,比如: 数据库服务都用域名“mysql.inner.app” 商品服务都用“goods.inner.app” 第三种,基于域名实现的负载均衡。 第一种方式,因为域名解析可以返回多个 IP 地址,所以一个域名可以对应多台主机,客户端收到多个 IP 地址后,就可以自己使用轮询算法依次向服务器发起请求,实现负载均衡。 第二种方式,域名解析可以配置内部的策略,返回离客户端最近的主机,或者返回当前服务质量最好的主机,这样在 DNS 端把请求分发到不同的服务器,实现负载均衡。 前面说的都是可信的 DNS,如果有一些不怀好意的 DNS,那么它也可以在域名这方面“做手脚”,弄一些比较“恶意”的“玩法”,举两个例子: “域名屏蔽”,对域名直接不解析,返回错误,让你无法拿到 IP 地址,也就无法访问网站; “域名劫持”,也叫“域名污染”,你要访问 A 网站,但 DNS 给了你 B 网站。 好在互联网上还是好人多,而且 DNS 又是互联网的基础设施,这些“恶意 DNS”并不多见,你上网的时候不需要太过担心。 0.4.3. URI/URL DNS 和 IP 地址只是标记了互联网上的主机,但主机上有那么多文本、图片、页面,所以就出现了 URI(Uniform Resource Identifier,统一资源标识符),使用它就能够唯一地标记互联网上资源。 URI 另一个更常用的表现形式是 URL(Uniform Resource Locator,统一资源定位符), 也就是俗称的“网址”,它实际上是 URI 的一个子集,因为这两者几乎是相同的,差异不大,所以通常不会做严格的区分。 http://nginx.org/en/download.html URI 主要有三个基本的部分构成: 协议名:即访问该资源应当使用的协议,在这里是“http”; 主机名:即互联网上主机的标记,可以是域名或 IP 地址,在这里是“nginx.org”; 路径:即资源在主机上的位置,使用“/”分隔多级目录,在这里是“/en/download.html”。 举个例子,通过电话簿找到了小明,让他把昨天做好的宣传文案快递过来。那么这个过程中就完成了一次 URI 资源访问: “快递”,就是你要访问这个资源的“协议名” “小明”就是“主机名” “昨天做好的宣传文案”就是“路径” 0.4.4. HTTPS 在 TCP/IP、DNS 和 URI 的“加持”之下,HTTP 协议终于可以自由地穿梭在互联网世界里,顺利地访问任意的网页。 HTTPS 全称是“HTTP over SSL/TLS”,也就是运行在 SSL/TLS 协议上的 HTTP。这里 SSL/TLS是一个负责加密通信的安全协议,建立在 TCP/IP 之上,所以也是个可靠的传输协议,可以被用作 HTTP 的下层。 因此 HTTPS 相当于“HTTP+SSL/TLS+TCP/IP”。 SSL 的全称是“Secure Socket Layer”,由网景公司发明,当发展到 3.0 时被标准化,改名为 TLS,即“Transport Layer Security”,但由于历史的原因还是有很多人称之为 SSL/TLS,或者直接简称为 SSL。 SSL 使用了许多密码学最先进的研究成果,综合了对称加密、非对称加密、摘要算法、数字签名、数字证书等技术,能够在不安全的环境中为通信的双方创建出一个秘密的、安全的传输通道。 0.4.5. 代理 代理(Proxy)是 HTTP 协议中请求方和应答方中间的一个环节,作为“中转站”,既可以转发客户端的请求,也可以转发服务器的应答。 代理有很多的种类,常见的有: 匿名代理:完全“隐匿”了被代理的机器,外界看到的只是代理服务器; 透明代理:它在传输过程中是“透明开放”的,外界既知道代理,也知道客户端; 正向代理:靠近客户端,代表客户端向服务器发送请求; 反向代理:靠近服务器端,代表服务器响应客户端的请求; CDN,实际上就是一种代理,它代替源站服务器响应客户端的请求,通常扮演着透明代理和反向代理的角色。 由于代理在传输过程中插入了一个“中间层”,所以可以在这个环节做很多有意思的事情,比如: 负载均衡:把访问请求均匀分散到多台机器,实现访问集群化; 内容缓存:暂存上下行的数据,减轻后端的压力; 安全防护:隐匿 IP, 使用 WAF 等工具抵御网络攻击,保护被代理的机器; 数据处理:提供压缩、加密等额外的功能。 关于 HTTP 的代理还有一个特殊的“代理协议”(proxy protocol),它由知名的代理软件 HAProxy 制订,但并不是 RFC 标准。 HTTP可以应用在UNIX Domain Socket上,它是一种进程间通信机制,也满足HTTP对下层的“可靠传输”要求,所以就成了“HTTP over UNIX Domain Socket”。 0.5. 网络分层模型 0.5.1. TCP/IP网络分层模型 TCP/IP 设计时,创造性地提出了“分层”的概念,把复杂的网络通信划分出多个层次,再给每一个层次分配不同的职责,层次内只专心做自己的事情就好,用“分而治之”的思想把一个“大麻烦”拆分成了数个“小麻烦”,从而解决了网络通信的难题。 TCP/IP 协议总共有四层,每一层需要下层的支撑,同时又支撑着上层,任何一层被抽掉都可能会导致整个协议栈坍塌。它的层次顺序是“从下往上”数的,所以第一层就是最下面的一层。 第一层叫“链接层”(link layer),负责在以太网、WiFi 这样的底层网络上发送原始数据包,工作在网卡这个层次,使用 MAC 地址(也称为局域网地址,唯一的标识一个网卡)来标记网络上的设备,也叫 MAC 层。 第二层叫“网际层”或者“网络互连层”(internet layer),IP 协议就在这一层。 IP 协议定义了“IP 地址”的概念,所以在“链接层”的基础上,用 IP 地址取代 MAC 地址,把许许多多的局域网、广域网连接成一个虚拟的巨大网络,在这个网络里找设备时只要把 IP 地址再“翻译”成 MAC 地址就可以了。 第三层叫“传输层”(transport layer),这个层次协议的职责是保证数据在 IP 地址标记的两点之间“可靠”地传输,是 TCP 协议工作的层次,另外还有它的一个“小伙伴”UDP。 第四层叫“应用层”(application layer),由于下面的三层把基础打得非常好,所以在这一层有各种面向具体应用的协议。例如 Telnet、SSH、FTP、SMTP HTTP等。 MAC 层的传输单位是帧(frame), IP 层的传输单位是包(packet), TCP 层的传输单位是段(segment), HTTP 的传输单位则是消息或报文(message)。 这些名词并没有什么本质的区分,可以统称为数据包。 IP 协议的职责是“网际互连”,它在 MAC 层之上,使用 IP 地址把 MAC 编号转换成了四位数字,这就对物理网卡的 MAC 地址做了一层抽象,发展出了许多的“新玩法”。例如,分为 A、B、C、D、E 五种类型,公有地址和私有地址,掩码分割子网等。只要每个小网络在 IP 地址这个概念上达成一致,不管它在 MAC 层有多大的差异,都可以接入 TCP/IP 协议栈,最终汇合进整个互联网。 在 IP 地址之上再来一次抽象,把数字形式的 IP 地址转换成更有意义更好记的名字,在字符串的层面上再增加“新玩法”。于是,DNS 域名系统就这么出现了。 0.5.1.1. TCP/UDP TCP 是一个有状态的协议,需要先与对方建立连接然后才能发送数据,而且保证数据不丢失不重复。 UDP 比较简单无状态,不用事先建立连接就可以任意发送数据,但不保证数据一定会发到对方。 两个协议的另一个重要区别在于数据的形式。 TCP 的数据是连续的“字节流”,有先后顺序, UDP 是分散的小数据包,是顺序发,乱序收。 0.5.2. OSI网络分层模型 第一层:物理层,网络的物理形式,例如电缆、光纤、网卡、集线器等等; 第二层:数据链路层,它基本相当于 TCP/IP 的链接层; 第三层:网络层,相当于 TCP/IP 里的网际层; 第四层:传输层,相当于 TCP/IP 里的传输层; 第五层:会话层,维护网络中的连接状态,即保持会话和同步; 第六层:表示层,把数据转换为合适、可理解的语法和语义; 第七层:应用层,面向具体的应用传输数据。 TCP/IP 是一个纯软件的栈,没有网络应有的最根基的电缆、网卡等物理设备的位置。而 OSI 则补足了这个缺失,在理论层面上描述网络更加完整。 OSI 为每一层标记明确了编号,最底层是一层,最上层是七层,而 TCP/IP 的层次从来只有名字而没有编号。显然,在交流的时候说“七层”要比“应用层”更简单快捷,特别是英文,对比一下“Layer seven”与“application layer”。 0.5.3. 两个分层模型的映射关系 第一层:物理层,TCP/IP 里无对应; 第二层:数据链路层,对应 TCP/IP 的链接层; 第三层:网络层,对应 TCP/IP 的网际层; 第四层:传输层,对应 TCP/IP 的传输层; 第五、六、七层:统一对应到 TCP/IP 的应用层。 这就是“理想与现实”之间的矛盾。理想很美好,有七层,但现实很残酷,只有四层,“多余”的五层、六层就这样“消失”了。 OSI 的分层模型在四层以上分的太细,而 TCP/IP 实际应用时的会话管理、编码转换、压缩等和具体应用经常联系的很紧密,很难分开。例如,HTTP 协议就同时包含了连接管理和数据格式定义。 所谓的“四层负载均衡”就是指工作在传输层上,基于 TCP/IP 协议的特性,例如 IP 地址、端口号等实现对后端服务器的负载均衡。 所谓的“七层负载均衡”就是指工作在应用层上,基于 HTTP 协议解析 HTTP 报文里的 URI、主机名、资源类型等数据,再用适当的策略转发给后端服务器。 有一个辨别四层和七层比较好的(但不是绝对的)小窍门,“两个凡是”: 凡是由操作系统负责处理的就是四层或四层以下 凡是需要由应用程序(也就是你自己写代码)负责处理的就是七层 在TCP/TP协议栈之外,还有一些协议位于OSI的第五层和第六层,例如UNIX Domain Socket就可以认为是在第五层。 0.5.4. TCP/IP协议栈的工作方式 HTTP 协议的传输过程就是通过协议栈逐层向下,每一层都添加本层的专有数据,层层打包,然后通过下层发送出去。接收数据则是相反的操作,从下往上穿过协议栈,逐层拆包,每层去掉本层的专有头,上层就会拿到自己的数据。 下层的传输过程对于上层是完全“透明”的,上层也不需要关心下层的具体实现细节,所以就 HTTP 层次来看,它不管下层是不是 TCP/IP 协议,看到的只是一个可靠的传输链路,只要把数据加上自己的头,对方就能原样收到。

02-HTTP详解 阅读更多

0.1. 报文结构 0.1.1. 请求行 0.1.2. 状态行 0.1.3. 头部字段 0.1.4. 常用头字段 0.1.4.1. Host 字段 0.1.4.2. User-Agent 字段 0.1.4.3. Date 字段 0.1.4.4. Server 字段 0.1.4.5. Content-Length 字段 0.2. 请求方法 0.2.1. GET/HEAD 0.2.2. POST/PUT 0.2.3. 非常用方法 0.2.4. 扩展方法 0.2.5. 幂等与安全 0.3. URI 0.3.1. URI基本组成 0.3.1.1. 注意 0.3.2. URI查询参数 0.3.3. URI完整格式 0.3.4. URI编码 0.4. 状态码 0.4.1. 1.×× 0.4.2. 2.×× 0.4.3. 3.×× 0.4.4. 4.×× 0.4.5. 5.×× HTTP 协议基本工作流程,也就是“请求——应答”,“一发一收”的模式。 HTTP 的工作模式是由于 TCP/IP 协议负责底层的具体传输工作,HTTP 协议基本上不用操心。单从这一点上来看,所谓的“超文本传输协议”其实并不怎么管“传输”的事情,有点“名不副实”。 HTTP 协议的核心部分是它传输的报文内容。 HTTP 协议在规范文档里详细定义了报文的格式,规定了组成部分,解析规则,还有处理策略,所以可以在 TCP/IP 层之上实现更灵活丰富的功能,例如: 连接控制 缓存管理 数据编码 内容协商等 0.1. 报文结构 TCP 报文在实际要传输的数据之前附加了一个 20 字节的头部数据,存储 TCP 协议必须的额外信息,例如: 发送方的端口号 接收方的端口号 包序号 标志位等 有了这个附加的 TCP 头,数据包才能够正确传输,到了目的地后把头部去掉,就可以拿到真正的数据。 HTTP 协议也是与 TCP/UDP 类似,同样也需要在实际传输的数据前附加一些头数据, 不过与 TCP/UDP 不同的是,它是一个“纯文本”的协议,所以头数据都是 ASCII 码的文本,可以很容易地用肉眼阅读,不用借助程序解析也能够看懂。 HTTP 协议的请求报文和响应报文的结构基本相同,由三大部分组成: 起始行(start line):描述请求或响应的基本信息; 41 个状态码 头部字段集合(header):使用 key-value 形式更详细地说明报文; 消息正文(entity):实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据。 前两部分起始行和头部字段经常又合称为“请求头”或“响应头”,消息正文又称为“实体”,但与“header”对应,很多时候就直接称为“body”。 HTTP 协议规定报文必须有 header,但可以没有 body,而且在 header 之后必须要有一个“空行”,也就是“CRLF”,十六进制的“0D0A”。 下面是wireshark抓取的HTTP请求的报文: GET / HTTP/1.1 Host: 127.0.0.1 Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9 在这个浏览器发出的请求报文里: 第一行“GET / HTTP/1.1”就是请求行 而后面的“Host”“Connection”等等都属于 header 报文的最后是一个空白行结束,没有 body 在很多时候,特别是浏览器发送 GET 请求的时候都是这样,HTTP 报文经常是只有 header 而没 body。虽然 HTTP 协议对 header 的大小没有做限制,但各个 Web 服务器都不允许过大的请求头,因为头部太大可能会占用大量的服务器资源,影响运行效率。 0.1.1. 请求行 请求报文里的起始行也就是请求行(request line),它简要地描述了客户端想要如何操作服务器端的资源。请求行由三部分构成: 请求方法:是一个动词,如 GET/POST,表示对资源的操作; 请求目标:通常是一个 URI,标记了请求方法要操作的资源; 版本号:表示报文使用的 HTTP 协议版本。 这三个部分通常使用空格(space)来分隔,也可以是制表符(tab),最后要用 CRLF 换行表示结束。 GET / HTTP/1.1 在这个请求行里,“GET”是请求方法,“/”是请求目标,“HTTP/1.1”是版本号,把这三部分连起来,意思就是“服务器你好,我想获取网站根目录下的默认文件,我用的协议版本号是 1.1,请不要用 1.0 或者 2.0 回复我。” 0.1.2. 状态行 响应报文里的起始行,叫“状态行”(status line),意思是服务器响应的状态。 状态行要简单一些,同样也是由三部分构成: 版本号:表示报文使用的 HTTP 协议版本; 状态码:一个三位数,用代码的形式表示处理的结果; 原因:作为数字状态码补充,是更详细的解释文字,帮助人理解原因。 HTTP/1.1 200 OK 0.1.3. 头部字段 请求行或状态行再加上头部字段集合就构成了 HTTP 报文里完整的请求头或响应头,如下两个示意图。 请求头和响应头的结构是基本一样的,唯一的区别是起始行。 头部字段是 key-value 的形式,key 和 value 之间用“:”分隔,最后用 CRLF 换行表示字段结束。 比如在“Host: 127.0.0.1”这一行里 key 就是“Host”,value 就是“127.0.0.1”。 HTTP 头字段非常灵活,不仅可以使用标准里的 Host、Connection 等已有头,也可以任意添加自定义头,这就给 HTTP 协议带来了无限的扩展可能。不过使用头字段需要注意下面几点: 字段名不区分大小写,例如“Host”也可以写成“host”,但首字母大写的可读性更好; 字段名里不允许出现空格,可以使用连字符“-”,但不能使用下划线“_”。例如,“test-name”是合法的字段名,而“test name”“test_name”是不正确的字段名; 字段名后面必须紧接着“:”,不能有空格,而“:”后的字段值前可以有多个空格; 字段的顺序是没有意义的,可以任意排列不影响语义; 字段原则上不能重复,除非这个字段本身的语义允许,例如 Set-Cookie。 0.1.4. 常用头字段 详细的常用头字段介绍看这里。 HTTP 协议规定了非常多的头部字段,实现各种各样的功能,但基本上可以分为四大类: 通用字段:在请求头和响应头里都可以出现; 请求字段:仅能出现在请求头里,进一步说明请求信息或者额外的附加条件; 响应字段:仅能出现在响应头里,补充说明响应报文的信息; 实体字段:它实际上属于通用字段,但专门描述 body 的额外信息。 对 HTTP 报文的解析和处理实际上主要就是对头字段的处理,理解了头字段也就理解了 HTTP 报文。 0.1.4.1. Host 字段 属于请求字段,只能出现在请求头里,同时也是唯一一个 HTTP/1.1 规范里要求必须出现的字段,如果请求头里没有 Host,那这就是一个错误的报文。 Host 字段告诉服务器这个请求应该由哪个主机来处理,当一台计算机上托管了多个虚拟主机的时候,服务器端就需要用 Host 字段来选择,有点像是一个简单的“路由重定向”。 0.1.4.2. User-Agent 字段 属于请求字段,只出现在请求头里。它使用一个字符串来描述发起 HTTP 请求的客户端,服务器可以依据它来返回最合适此浏览器显示的页面。 由于历史的原因,User-Agent 非常混乱,每个浏览器都自称是“Mozilla”“Chrome”“Safari”,企图使用这个字段来互相“伪装”,导致 User-Agent 变得越来越长,最终变得毫无意义。 有的比较“诚实”的爬虫会在 User-Agent 里用“spider”标明自己是爬虫,所以可以利用这个字段实现简单的反爬虫策略。 0.1.4.3. Date 字段 属于通用字段,但通常出现在响应头里,表示 HTTP 报文创建的时间,客户端可以使用这个时间再搭配其他字段决定缓存策略。 0.1.4.4. Server 字段 属于响应字段,只能出现在响应头里。它告诉客户端当前正在提供 Web 服务的软件名称和版本号。 Server 字段不是必须要出现的,因为这会把服务器的一部分信息暴露给外界,如果这个版本恰好存在 bug,那么黑客就有可能利用 bug 攻陷服务器。所以,有的网站响应头里要么没有这个字段,要么就给出一个完全无关的描述信息。 0.1.4.5. Content-Length 字段 属于实体字段,表示报文里 body 的长度,也就是请求头或响应头空行后面数据的长度。 服务器看到这个字段,就知道了后续有多少数据,可以直接接收。 如果没有这个字段,那么 body 就是不定长的,需要使用 chunked 方式分段传输。 0.2. 请求方法 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 方法获取文件的元信息。 0.2.1. GET/HEAD GET 方法是 HTTP 协议里最知名,用的最多的请求方法,自 0.9 版出现并一直被保留至今。它的含义是请求从服务器获取资源,这个资源既可以是静态的文本、页面、图片、视频,也可以是由 PHP、Java 动态生成的页面或者其他格式的数据。 GET 方法虽然基本动作比较简单,但搭配 URI 和其他头字段就能实现对资源更精细的操作。例如: 在 URI 后使用“#”,就可以在获取页面后直接定位到某个标签所在的位置 使用 If-Modified-Since 字段就变成了“有条件的请求”,仅当资源被修改时才会执行获取动作 使用 Range 字段就是“范围请求”,只获取资源的一部分数据 HEAD 方法也是请求从服务器获取资源,但服务器不会返回请求的实体数据,只会传回响应头,也就是资源的“元信息”。 HEAD 方法可以看做是 GET 方法的一个“简化版”或者“轻量版”。 HEAD 的响应头与 GET 完全相同,所以可以用在很多并不真正需要资源的场合,避免传输 body 数据的浪费。例如: 检查一个文件是否存在,只要发个 HEAD 请求,没有必要用 GET 把整个文件都取下来 检查文件是否有最新版本,用 HEAD 请求,服务器会在响应头里把文件的修改时间传回来 0.2.2. POST/PUT GET 和 HEAD 方法是从服务器获取数据,而 POST 和 PUT 方法则是向 URI 指定的资源提交数据,数据就放在报文的 body 里。 POST 是一个常用到的请求方法,使用频率仅次于 GET,应用的场景非常多,只要向服务器发送数据,用的大多数都是 POST。例如: 在论坛输入文字后点击“发帖”按钮,浏览器就执行一次 POST 请求,把文字放进报文的 body 里,然后拼好 POST 请求头,通过 TCP 协议发给服务器。 在购物网站点击商品“加入购物车”,浏览器就执行一次 POST 请求,把商品 ID 发给服务器,服务器再把 ID 写入你的购物车相关的数据库记录。 PUT 方法也可以向服务器提交数据,但与 POST 存在微妙的不同,通常 POST 表示的是“新建”“create”的含义,而 PUT 则是“修改”“update”的含义。 在实际应用中,PUT 用到的比较少。而且,因为它与 POST 的语义、功能太过近似,有的服务器甚至就直接禁止使用 PUT 方法,只用 POST 方法上传数据。 0.2.3. 非常用方法 DELETE 方法指示服务器删除资源,这个动作危险性太大,所以通常服务器不会执行真正的删除操作,而是对资源做一个删除标记。当然,更多的时候服务器就直接不处理 DELETE 请求。 CONNECT 是一个比较特殊的方法,要求服务器为客户端和另一台远程服务器建立一条特殊的连接隧道,这时 Web 服务器在中间充当了代理的角色。 OPTIONS 方法要求服务器列出可对资源实行的操作方法,在响应头的 Allow 字段里返回。它的功能很有限,用处也不大,有的服务器(例如 Nginx)干脆就没有实现对它的支持。 TRACE 方法多用于对 HTTP 链路的测试或诊断,可以显示出请求——响应的传输路径。它的本意是好的,但存在漏洞,会泄漏网站的信息,所以 Web 服务器通常也是禁止使用。 0.2.4. 扩展方法 虽然 HTTP/1.1 里规定了八种请求方法,但它并没有限制只能用这八种方法,这也体现了 HTTP 协议良好的扩展性,我们可以任意添加请求动作,只要请求方和响应方都能理解就行。 例如著名的愚人节玩笑 RFC2324,它定义了协议 HTCPCP,即“超文本咖啡壶控制协议”,为 HTTP 协议增加了用来煮咖啡的 BREW 方法,要求添牛奶的 WHEN 方法。 还有一些得到了实际应用的请求方法(WebDAV),例如 MKCOL、COPY、MOVE、LOCK、UNLOCK、PATCH 等。 如果有合适的场景,可以把它们应用到系统里,例如: 用 LOCK 方法锁定资源暂时不允许修改, 用 PATCH 方法给资源打个小补丁,部分更新数据。 这些方法是非标准的,所以需要为客户端和服务器编写额外的代码才能添加支持。 也完全可以根据实际需求发明新的方法,例如: “PULL”拉取某些资源到本地, “PURGE”清理某个目录下的所有缓存数据。 0.2.5. 幂等与安全 关于请求方法还有两个比较重要的概念:安全与幂等。 在 HTTP 协议里,所谓的“安全”是指请求方法不会“破坏”服务器上的资源,即不会对服务器上的资源造成实质的修改。 按照这个定义,只有 GET 和 HEAD 方法是“安全”的,因为它们是“只读”操作,只要服务器不故意曲解请求方法的处理方式,无论 GET 和 HEAD 操作多少次,服务器上的数据都是“安全的”。 而 POST/PUT/DELETE 操作会修改服务器上的资源,增加或删除数据,所以是“不安全”的。 所谓的“幂等”意思是多次执行相同的操作,结果也都是相同的,即多次“幂”后结果“相等”。 GET 和 HEAD 既是安全的也是幂等的, DELETE 可以多次删除同一个资源,效果都是“资源不存在”,所以也是幂等的。 POST 是“新增或提交数据”,多次提交数据会创建多个资源,所以不是幂等的; PUT 是“替换或更新数据”,多次更新一个资源,资源还是会第一次更新的状态,所以是幂等的。 把 POST 理解成 INSERT,把 PUT 理解成 UPDATE。多次 INSERT 会添加多条记录,而多次 UPDATE 只操作一条记录,而且效果相同。 0.3. URI 严格地说,URI 不完全等同于网址,它包含有 URL 和 URN 两个部分,在 HTTP 世界里用的网址实际上是 URL,即统一资源定位符(Uniform Resource Locator)。但因为 URL 实在是太普及了,所以常常把这两者简单地视为相等。 URL有绝对URL和相对URL之分,多用在HTML页面标记应用的其他资源,而在HTTP请求行里则不会出现。 URI 本质上是一个字符串,这个字符串的作用是唯一地标记资源的位置或者名字。 注意:它不仅能够标记万维网的资源,也可以标记其他的,如邮件系统、本地文件系统等任意资源。 “资源”既可以是存在磁盘上的静态文本、页面数据,也可以是由 Java、PHP 提供的动态服务。 下面的这张图显示了 URI 最常用的形式,由 scheme、host:port、path 和 query 四个部分组成,有的部分可以视情况省略。 0.3.1. URI基本组成 URI 第一部分 scheme(“方案名”或者“协议名”),表示资源应该使用哪种协议来访问。 最常见的是“http”,表示使用 HTTP 协议。 还有“https”,表示使用经过加密、安全的 HTTPS 协议。 还有非常见的 scheme,例如 ftp、ldap、file、news 等。 浏览器或应用程序看到 URI 里的 scheme,下一步就会调用相应的 HTTP 或者 HTTPS 下层 API。如果一个 URI 没有提供 scheme,即使后面的地址再完善,也是无法处理的。 在 scheme 之后,必须是三个特定的字符“://”,它把 scheme 和后面的部分分离开。在“://”之后,是被称为“authority”的部分,表示资源所在的主机名,通常的形式是“host:port”,即主机名加端口号。 主机名可以是 IP 地址或者域名的形式,必须要有,否则浏览器就会找不到服务器。 端口号有时可以省略,浏览器等客户端会依据 scheme 使用默认的端口号,例如 HTTP 的默认端口号是 80,HTTPS 的默认端口号是 443。 有了协议名和主机地址、端口号,再加上后面标记资源所在位置的 path,浏览器就可以连接服务器访问资源了。 URI 里 path 采用了类似文件系统“目录”“路径”的表示方式,因为早期互联网上的计算机多是 UNIX 系统,所以采用了 UNIX 的“/”风格,path 部分必须以“/”开始,也就是必须包含“/”。 http://nginx.org #省略端口号,http默认为80,省略路径,默认为根目录/ http://www.chrono.com:8080/11-1 https://tools.ietf.org/html/rfc7230 #省略端口号,https默认为443 file:///D:/http_study/www/ # file协议,表示本地文件,省略authority部分,默认为localhost,后面直接path部分 0.3.1.1. 注意 客户端和服务器看到的 URI 是不一样的。 客户端看到的必须是完整的 URI,使用特定的协议去连接特定的主机 服务器看到的只是报文请求行里被删除了协议名和主机名的 URI 如 Nginx 作为一个 Web 服务器,它的 location、rewrite 等指令操作的 URI 其实指的是真正 URI 里的 path 和后续的部分。 0.3.2. URI查询参数 使用“协议名 + 主机名 + 路径”的方式,可以精确定位网络上的任何资源,但这还不够,很多时候还想在操作资源的时候附加一些额外的修饰参数,例如: 获取商品图片,但想要一个 32×32 的缩略图版本; 获取商品列表,但要按某种规则做分页和排序; 跳转页面,但想要标记跳转前的原始页面。 仅用“协议名 + 主机名 + 路径”的方式是无法适应这些场景的,所以 URI 后面还有一个“query”部分,它在 path 之后,用一个“?”开始,但不包含“?”,表示对资源附加的额外要求。这是个很形象的符号,比“://”要好的多,很明显地表示了“查询”的含义。 查询参数 query 的格式是多个“key=value”的字符串,这些 KV 值用字符“&”连接,浏览器和服务器都可以按照这个格式把长串的查询参数解析成可理解的字典或关联数组形式。 查询参数 query 也可以不适用 “key=value”的形式,只是单纯的“key”,这样“value”就是空字符串。 如果查询参数 query 太长,也可以使用GET方法,放在body里发送给服务器。 0.3.3. URI完整格式 URI “真正”的完整形态,如下图所示。 这个“真正”形态比基本形态多了两部分。 第一个多出的部分是协议名之后、主机名之前的身份信息“user:passwd@”,表示登录主机时的用户名和密码,但现在已经不推荐使用这种形式了(RFC7230),因为它把敏感信息以明文形式暴露出来,存在严重的安全隐患。 第二个多出的部分是查询参数后的片段标识符“#fragment”,它是 URI 所定位的资源内部的一个“锚点”或者说是“标签”,浏览器可以在获取资源后直接跳转到它指示的位置。 片段标识符仅能由浏览器这样的客户端使用,服务器是看不到的。浏览器永远不会把带“#fragment”的 URI 发送给服务器,服务器也永远不会用这种方式去处理资源的片段。 0.3.4. URI编码 在 URI 里只能使用 ASCII 码,如何在 URI 里使用英语以外的其他语言? 某些特殊的 URI,会在 path、query 里出现“@&?"等起界定符作用的字符,会导致 URI 解析错误,这时又该怎么办? URI 引入了编码机制,对于 ASCII 码以外的字符集和特殊字符做一个特殊的操作,把它们转换成与 URI 语义不冲突的形式。这在 RFC 规范里称为“escape”和“unescape”,俗称“转义”。 URI 转义的规则“简单粗暴”,直接把非 ASCII 码或特殊字符转换成十六进制字节值,然后前面再加上一个“%”。例如: 空格被转义成“%20” “?”被转义成“%3F” 中文、日文等则通常使用 UTF-8 编码后再转义,如“银河”会被转义成“%E9%93%B6%E6%B2%B3” 有了编码规则 URI 就可以支持任意的字符集用任何语言来标记资源。在浏览器的地址栏里通常是不会看到这些转义后的“乱码”的,这是浏览器一种“友好”表现,隐藏了 URI 编码后的“丑陋一面”。 注意,URI编码转义与HTML里的编码转义是完全不同的,URI的转义使用的是“%”,而HTML转义使用的是“&#”。 0.4. 状态码 响应报文由响应头加响应体数据组成,响应头又由状态行和头字段构成。状态行的结构,有三部分: 开头的 Version 部分是 HTTP 协议的版本号,通常是 HTTP/1.1,用处不是很大。 后面的 Reason 部分是原因短语,是状态码的简短文字描述,例如“OK”“Not Found”等等,也可以自定义。但它只是为了兼容早期的文本客户端而存在,提供的信息很有限,目前的大多数客户端都会忽略它。 状态行里有用的就只剩下中间的状态码(Status Code)。它是一个十进制数字,以代码的形式表示服务器对请求的处理结果,就像编写程序时函数返回的错误码一样。 它的名字是“状态码”不是“错误码”,它的含义不仅是错误,更重要的意义在于表达 HTTP 数据处理的“状态”,客户端可以依据代码适时转换处理状态,例如: 继续发送请求、 切换协议, 重定向跳转等 目前 RFC 标准里规定的状态码是三位数,所以取值范围就是从 000 到 999。如果把状态码简单地从 000 开始顺序编下去就显得有点太“low”,不灵活、不利于扩展,所以状态码也被设计成有一定的格式。 RFC 标准把状态码分成了五类,用数字的第一位表示分类,而 0~99 不用,这样状态码的实际可用范围就大大缩小了,由 000~999 变成了 100~599。这五类的具体含义是: 1××:提示信息,表示目前是协议处理的中间状态,还需要后续的操作; 2××:成功,报文已经收到并被正确处理; 3××:重定向,资源位置发生变动,需要客户端重新发送请求; 4××:客户端错误,请求报文有误,服务器无法处理; 5××:服务器错误,服务器在处理请求时内部发生了错误。 在 HTTP 协议中,正确地理解并应用这些状态码不是客户端或服务器单方的责任,而是双方共同的责任。 客户端作为请求的发起方,获取响应报文后,需要通过状态码知道请求是否被正确处理,是否要再次发送请求,如果出错了原因又是什么。这样才能进行下一步的动作,要么发送新请求,要么改正错误重发请求。 服务器端作为请求的接收方,应该很好地运用状态码。在处理请求时,选择最恰当的状态码回复客户端,告知客户端处理的结果,指示客户端下一步应该如何行动。特别是在出错的时候,尽量不要简单地返 400、500 这样意思含糊不清的状态码。 目前 RFC 标准里总共有 41 个状态码,但状态码的定义是开放的,允许自行扩展。所以 Apache、Nginx 等 Web 服务器都定义了一些专有的状态码。 如果开发 Web 应用,也完全可以在不冲突的前提下定义新的代码。 0.4.1. 1.×× 1××类状态码属于提示信息,是协议处理的中间状态,实际能够用到的时候很少。 “101 Switching Protocols”:意思是客户端使用 Upgrade 头字段,要求在 HTTP 协议的基础上改成其他的协议继续通信,比如 WebSocket。而如果服务器也同意变更协议,就会发送状态码 101,但这之后的数据传输就不会再使用 HTTP 了。 0.4.2. 2.×× 2××类状态码表示服务器收到并成功处理了客户端的请求,这也是客户端最愿意看到的状态码。 “200 OK”:是最常见的成功状态码,表示一切正常,服务器如客户端所期望的那样返回了处理结果,如果是非 HEAD 请求,通常在响应头后都会有 body 数据。 “204 No Content”:是很常见的成功状态码,它的含义与“200 OK”基本相同,但响应头后没有 body 数据。所以对于 Web 服务器来说,正确地区分 200 和 204 是很必要的。 “206 Partial Content”:是 HTTP 分块下载或断点续传的基础,在客户端发送“范围请求”、要求获取资源的部分数据时出现,它与 200 一样,也是服务器成功处理了请求,但 body 里的数据不是资源的全部,而是其中的一部分。 状态码 206 通常还会伴随着头字段“Content-Range”,表示响应报文里 body 数据的具体范围,供客户端确认,例如“Content-Range: bytes 0-99/2000”,意思是此次获取的是总计 2000 个字节的前 100 个字节。 0.4.3. 3.×× 3××类状态码表示客户端请求的资源发生了变动,客户端必须用新的 URI 重新发送请求获取资源,也就是通常所说的“重定向”,包括著名的 301、302 跳转。 “301 Moved Permanently”:俗称“永久重定向”,含义是此次请求的资源已经不存在了,需要改用新的 URI 再次访问。 “302 Found”:曾经的描述短语是“Moved Temporarily”,俗称“临时重定向”,意思是请求的资源还在,但需要暂时用另一个 URI 来访问。 “304 Not Modified”:是一个比较有意思的状态码,它用于 If-Modified-Since 等条件请求,表示资源未修改,用于缓存控制。它不具有通常的跳转含义,但可以理解成“重定向已到缓存的文件”(即“缓存重定向”)。 301、302 和 304 分别涉及了 HTTP 协议里重要的“重定向跳转”和“缓存控制”。 301 和 302 都会在响应头里使用字段 Location 指明后续要跳转的 URI,最终的效果很相似,浏览器都会重定向到新的 URI。两者的根本区别在于语义,一个是“永久”,一个是“临时”,所以在场景、用法上差距很大。比如: 网站升级到了 HTTPS,原来的 HTTP 不打算用了,这就是“永久”的,所以要配置 301 跳转,把所有的 HTTP 流量都切换到 HTTPS。 夜里网站后台要系统维护,服务暂时不可用,这就属于“临时”的,可以配置成 302 跳转,把流量临时切换到一个静态通知页面,浏览器看到这个 302 就知道这只是暂时的情况,不会做缓存优化,第二天还会访问原来的地址。 301和302还有另外两个等价的状态码“308 Permanent Redirect”和“307 Temporary Redirect”,但是这两个状态码不允许后续的请求更改请求方法。 0.4.4. 4.×× 4××类状态码表示客户端发送的请求报文有误,服务器无法处理,它就是真正的“错误码”含义了。 “400 Bad Request”:是一个通用的错误码,表示请求报文有错误,但具体是数据格式错误、缺少请求头还是 URI 超长它没有明确说,只是一个笼统的错误,客户端看到 400 只会是“一头雾水”“不知所措”。所以,在开发 Web 应用时应当尽量避免给客户端返回 400,而是要用其他更有明确含义的状态码。 “403 Forbidden”:实际上不是客户端的请求出错,而是表示服务器禁止访问资源。原因可能多种多样,例如信息敏感、法律禁止等,如果服务器友好一点,可以在 body 里详细说明拒绝请求的原因,不过现实中通常都是直接给一个“闭门羹”。 “404 Not Found”:可能是最常看见也是最不愿意看到的一个状态码,它的原意是资源在本服务器上未找到,所以无法提供给客户端。但现在已经被“用滥了”,只要服务器“不高兴”就可以给出个 404,而我们也无从得知后面到底是真的未找到,还是有什么别的原因,某种程度上它比 403 还要令人讨厌。 “405 Method Not Allowed”:不允许使用某些方法操作资源,例如不允许 POST 只能 GET; “406 Not Acceptable”:资源无法满足客户端请求的条件,例如请求中文但只有英文; “408 Request Timeout”:请求超时,服务器等待了过长的时间; “409 Conflict”:多个请求发生了冲突,可以理解为多线程并发时的竞态; “413 Request Entity Too Large”:请求报文里的 body 太大; “414 Request-URI Too Long”:请求行里的 URI 太大; “429 Too Many Requests”:客户端发送了太多的请求,通常是由于服务器的限连策略; “431 Request Header Fields Too Large”:请求头某个字段或总体太大; 0.4.5. 5.×× 5××类状态码表示客户端请求报文正确,但服务器在处理时内部发生了错误,无法返回应有的响应数据,是服务器端的“错误码”。 “500 Internal Server Error”:与 400 类似,是一个通用的错误码,服务器究竟发生了什么错误是不知道的。不过对于服务器来说这应该算是好事,通常不应该把服务器内部的详细信息,例如出错的函数调用栈告诉外界。虽然不利于调试,但能够防止黑客的窥探或者分析。 “501 Not Implemented”:表示客户端请求的功能还不支持,这个错误码比 500 要“温和”一些,和“即将开业,敬请期待”的意思差不多,不过具体什么时候“开业”就不好说了。 “502 Bad Gateway”:通常是服务器作为网关或者代理时返回的错误码,表示服务器自身工作正常,访问后端服务器时发生了错误,但具体的错误原因也是不知道的。 “503 Service Unavailable”:表示服务器当前很忙,暂时无法响应服务,上网时有时候遇到的“网络服务正忙,请稍后重试”的提示信息就是状态码 503。503 是一个“临时”的状态,很可能过几秒钟后服务器就不那么忙了,可以继续提供服务,所以 503 响应报文里通常还会有一个“Retry-After”字段,指示客户端可以在多久以后再次尝试发送请求。

03-HTTP特点 阅读更多

0.1. 特点 0.1.1. 灵活可扩展 0.1.2. 可靠传输 0.1.3. 应用层协议 0.1.4. 请求-应答 0.1.5. 无状态 0.1.6. 其他 0.2. 优缺点 0.2.1. 简单、灵活、易扩展 0.2.2. 应用广泛、环境成熟 0.2.3. 无状态 0.2.4. 明文 0.2.5. 不安全 0.2.6. 性能 0.1. 特点 0.1.1. 灵活可扩展 HTTP 协议是一个“灵活可扩展”的传输协议。HTTP 协议最初诞生的时候就比较简单,本着开放的精神只规定了报文的基本格式,比如: 用空格分隔单词, 用换行分隔字段, “header+body”等, 报文里的各个组成部分都没有做严格的语法语义限制,可以由开发者任意定制。在发展过程中,HTTP 协议逐渐增加了请求方法、版本号、状态码、头字段等特性。而 body 也不再限于文本形式的 TXT 或 HTML,而是能够传输图片、音频视频等任意数据。 0.1.2. 可靠传输 HTTP 协议是一个“可靠”的传输协议。因为 HTTP 协议是基于 TCP/IP 的,而 TCP 本身是一个“可靠”的传输协议,能够在请求方和应答方之间“可靠”地传输数据。它的具体做法与 TCP/UDP 差不多,都是对实际传输的数据(entity)做了一层包装,加上一个头,然后调用 Socket API,通过 TCP/IP 协议栈发送或者接收。 正确理解“可靠”的含义,HTTP 并不能 100% 保证数据一定能够发送到另一端,在网络繁忙、连接质量差等恶劣的环境下,也有可能收发失败。“可靠”只是向使用者提供了一个“承诺”,会在下层用多种手段“尽量”保证数据的完整送达。所以,“可靠”传输是指在网络基本正常的情况下数据收发必定成功,借用运维里的术语,大概就是“3 个 9”或者“4 个 9”的程度吧。 如果要100%保证数据收发成功就不能使用HTTP或者TCP协议,要使用各种消息中间件,如RabbitMQ、ZeroMQ、Kafka等。 0.1.3. 应用层协议 HTTP 协议是一个应用层的协议。在 TCP/IP 诞生后的几十年里,出现了许多的应用层协议,但它们都仅关注很小的应用领域,局限在很少的应用场景,例如: FTP 只能传输文件、 SMTP 只能发送邮件、 SSH 只能远程登录等, 在通用的数据传输方面“完全不能打”。 HTTP 凭借着可携带任意头字段和实体数据的报文结构,以及连接控制、缓存代理等方便易用的特性,一出现就“技压群雄”,迅速成为了应用层里的“明星”协议。只要不太苛求性能,HTTP 几乎可以传递一切东西,满足各种需求,称得上是一个“万能”的协议。 0.1.4. 请求-应答 HTTP 协议使用的是请求-应答通信模式。这个请求——应答模式是 HTTP 协议最根本的通信模型,通俗来讲就是“一发一收”“有来有去”,就像是写代码时的函数调用,只要填好请求头里的字段,“调用”后就会收到答复。 请求——应答模式也明确了 HTTP 协议里通信双方的定位: 永远是请求方先发起连接和请求,是主动的, 而应答方只有在收到请求后才能答复,是被动的,如果没有请求时不会有任何动作。 当然,请求方和应答方的角色也不是绝对的,在浏览器——服务器的场景里,通常服务器都是应答方,但如果将它用作代理连接后端服务器,那么它就可能同时扮演请求方和应答方的角色。 请求——应答模式恰好契合了传统的 C/S(Client/Server)系统架构,请求方作为客户端、应答方作为服务器。所以,随着互联网的发展就出现了 B/S(Browser/Server)架构,用轻量级的浏览器代替笨重的客户端应用,实现零维护的“瘦”客户端,而服务器则摈弃私有通信协议转而使用 HTTP 协议。 请求——应答模式也完全符合 RPC(Remote Procedure Call)的工作模式,可以把 HTTP 请求处理封装成远程函数调用,导致了 WebService、RESTful 和 gPRC 等的出现。 0.1.5. 无状态 HTTP 协议是无状态的。这个所谓的“状态”其实就是客户端或者服务器里保存的一些数据或者标志,记录了通信过程中的一些变化信息。 TCP 协议是有状态的: 一开始处于 CLOSED 状态, 连接成功后是 ESTABLISHED 状态, 断开连接后是 FIN-WAIT 状态, 最后又是 CLOSED 状态。 这些“状态”就需要 TCP 在内部用一些数据结构去维护,可以简单地想象成是个标志量,标记当前所处的状态,例如: 0 是 CLOSED, 2 是 ESTABLISHED 等等。 HTTP 在整个协议里没有规定任何的“状态”,客户端和服务器永远是处在一种“无知”的状态。 建立连接前两者互不知情,每次收发的报文也都是互相独立的,没有任何的联系。 收发报文也不会对客户端或服务器产生任何影响,连接后也不会要求保存任何信息。 “无状态”形象地来说就是“没有记忆能力”。 比如,浏览器发了一个请求,说“我是小明,请给我 A 文件。”,服务器收到报文后就会检查一下权限,看小明确实可以访问 A 文件,于是把文件发回给浏览器。 接着浏览器还想要 B 文件,但服务器不会记录刚才的请求状态,不知道第二个请求和第一个请求是同一个浏览器发来的,所以浏览器必须还得重复一次自己的身份才行:“我是刚才的小明,请再给我 B 文件。” 对比一下: UDP 协议,它是无连接也无状态的,顺序发包乱序收包,数据包发出去后就不管了,收到后也不会顺序整理。 HTTP 是有连接无状态,顺序发包顺序收包,按照收发的顺序管理报文。 HTTP 是“灵活可扩展”的,虽然标准里没有规定“状态”,但完全能够在协议的框架里给它“打个补丁”,增加这个特性。 以前HTTP协议还有“无连接”的特点,即协议不保持连接状态,每次请求应答后都会关闭连接,这就和UDP几乎一模一样了。这会影响性能,在HTTP/1.1里就改成了总是默认启用keepalive长连接机制,所以现在的HTTP已经不再是“无连接”的了。 0.1.6. 其他 传输的实体数据可缓存可压缩、可分段获取数据、支持身份认证、支持国际化语言等。这些并不能算是 HTTP 的基本特点,因为这都是由第一个“灵活可扩展”的特点所衍生出来的。 0.2. 优缺点 限定在HTTP/1.1版本。 0.2.1. 简单、灵活、易扩展 HTTP 最重要也是最突出的优点是“简单、灵活、易于扩展”。“简单”蕴含了进化和扩展的可能性,所谓“少即是多”,“灵活和易于扩展”实际上是一体的,它们互为表里、相互促进,因为“灵活”所以才会“易于扩展”,而“易于扩展”又反过来让 HTTP 更加灵活,拥有更强的表现能力。 HTTP 协议里的请求方法、URI、状态码、原因短语、头字段等每一个核心组成要素都没有被“写死”,允许开发者任意定制、扩充或解释,给予了浏览器和服务器最大程度的信任和自由,也正好符合了互联网“自由与平等”的精神——缺什么功能自己加个字段或者错误码什么的补上就是了。 “请勿跟踪”所使用的头字段 DNT(Do Not Track)就是一个很好的例子。它最早由 Mozilla 提出,用来保护用户隐私,防止网站监测追踪用户的偏好。不过可惜的是 DNT 从推出至今有差不多七八年的历史,但很多网站仍然选择“无视”DNT。虽然 DNT 基本失败了,但这也正说明 HTTP 协议是“灵活自由的”,不会受单方面势力的压制。类似的还有P3P(Platfrom for Privacy Preferences Project)字段,用来控制网站对用户的隐私访问,当然也失败了。 “灵活、易于扩展”的特性还表现在 HTTP 对“可靠传输”的定义上,它不限制具体的下层协议,不仅可以使用 TCP、UNIX Domain Socket,还可以使用 SSL/TLS,甚至是基于 UDP 的 QUIC,下层可以随意变化,而上层的语义则始终保持稳定。 0.2.2. 应用广泛、环境成熟 HTTP 协议的另一大优点是“应用广泛”,软硬件环境都非常成熟。 随着互联网特别是移动互联网的普及,HTTP 的触角已经延伸到了世界的每一个角落,在开发领域 HTTP 协议也得到了广泛的支持。它并不限定某种编程语言或者操作系统,所以天然具有“跨语言、跨平台”的优越性。而且,因为本身的简单特性很容易实现,所以几乎所有的编程语言都有 HTTP 调用库和外围的开发测试工具。 HTTP 广泛应用的背后还有许多硬件基础设施支持,各个互联网公司和传统行业公司都不遗余力地“触网”,购买服务器开办网站,建设数据中心、CDN 和高速光纤,持续地优化上网体验,让 HTTP 运行的越来越顺畅。 “应用广泛”的这个优点也就决定了:无论是创业者还是求职者,无论是做网站服务器还是写应用客户端,HTTP 协议都是必须要掌握的基本技能。 出于安全的原因,绝大多数网站都封禁了80/8080以外的端口,只允许HTTP协议“穿透”,这也是造成HTTP流行的客观原因只之一。 0.2.3. 无状态 “无状态”是一把“双刃剑”,它对于 HTTP 来说既是优点也是缺点。 优点: 因为服务器没有“记忆能力”,所以就不需要额外的资源来记录状态信息,不仅实现上会简单一些,而且还能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。 而且,“无状态”也表示服务器都是相同的,没有“状态”的差异,所以可以很容易地组成集群,让负载均衡把请求转发到任意一台服务器,不会因为状态不一致导致处理出错,使用“堆机器”的“笨办法”轻松实现高并发高可用。 缺点: 既然服务器没有“记忆能力”,它就无法支持需要连续多个步骤的“事务”操作。例如电商购物,首先要登录,然后添加购物车,再下单、结算、支付,这一系列操作都需要知道用户的身份才行,但“无状态”服务器是不知道这些请求是相互关联的,每次都得问一遍身份信息,不仅麻烦,而且还增加了不必要的数据传输量。 所以,HTTP 协议最好是既“无状态”又“有状态”,不过还真有“鱼和熊掌”两者兼得这样的好事,这就是“小甜饼”Cookie 技术。 0.2.4. 明文 明文传输也是一把“双刃剑”。 “明文”意思就是协议里的报文(准确地说是 header 部分)不使用二进制数据,而是用简单可阅读的文本形式。 HTTP/1.1以文本格式传输header,有严重的数据冗余,也影响了它的性能。 对比 TCP、UDP 这样的二进制协议,它的优点显而易见,不需要借助任何外部工具,用浏览器、Wireshark 或者 tcpdump 抓包后,直接用肉眼就可以很容易地查看或者修改,为我们的开发调试工作带来极大的便利。 明文的缺点也是一样显而易见,HTTP 报文的所有信息都会暴露在“光天化日之下”,在漫长的传输链路的每一个环节上都毫无隐私可言,不怀好意的人只要侵入了这个链路里的某个设备,简单地“旁路”一下流量,就可以实现对通信的窥视。 黑客就是利用了 HTTP 明文传输的缺点,在公共场所架设一个 WiFi 热点开始“钓鱼”,诱骗网民上网。一旦你连上了这个 WiFi 热点,所有的流量都会被截获保存,里面如果有银行卡号、网站密码等敏感信息的话那就危险了,黑客拿到了这些数据就可以冒充你为所欲为。 0.2.5. 不安全 与“明文”缺点相关但不完全等同的另一个缺点是“不安全”。 安全有很多的方面,明文只是“机密”方面的一个缺点,在“身份认证”和“完整性校验”这两方面 HTTP 也是欠缺的。 “身份认证”:HTTP 没有提供有效的手段来确认通信双方的真实身份。虽然协议里有一个基本的认证机制,但因为刚才所说的明文传输缺点,这个机制几乎可以说是“纸糊的”,非常容易被攻破。如果仅使用 HTTP 协议,很可能会访问一个页面一模一样但却是个假冒的网站,然后再被“钓”走各种私人信息。 HTTP 协议也不支持“完整性校验”,数据在传输过程中容易被窜改而无法验证真伪。虽然可以用 MD5、SHA1 等算法给报文加上数字摘要,但还是因为“明文”这个致命缺点,黑客可以连同摘要一同修改,最终还是判断不出报文是否被窜改。 为了解决 HTTP 不安全的缺点,所以就出现了 HTTPS。 0.2.6. 性能 HTTP 的性能,“不算差,不够好”。HTTP 协议基于 TCP/IP,并且使用了“请求 - 应答”的通信模式,所以性能的关键就在这两点上。 TCP 的性能是不差的,而且它已经被研究的很透,集成在操作系统内核里经过了细致的优化,足以应付大多数的场景。 在互联网的特点是移动和高并发,不能保证稳定的连接质量,所以在 TCP 层面上 HTTP 协议有时候就会表现的不够好。 而“请求 - 应答”模式则加剧了 HTTP 的性能问题,著名的“队头阻塞”(Head-of-line blocking),当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一并被阻塞,会导致客户端迟迟收不到数据。 为了解决这个问题,就诞生出了一个专门的研究课题“Web 性能优化”,HTTP 官方标准里就有“缓存”一章(RFC7234),非官方的“花招”就更多了,例如切图、数据内嵌与合并,域名分片、JavaScript“黑科技”等等。不过现在已经有了终极解决方案:HTTP/2 和 HTTP/3。

04- HTTP应用 阅读更多

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

05-HTTP安全 阅读更多

0.1. HTTPS与SSL/TLS 0.1.1. HTTPS 0.1.2. SSL/TLS 0.1.3. OpenSSL 0.2. 非/对称加密 0.2.1. 对称加密 0.2.2. 加密分组模式 0.2.3. 非对称加密 0.2.4. 混合加密 0.3. 数字签名与证书 0.3.1. 摘要算法 0.3.2. 完整性 0.3.3. 数字签名 0.3.4. 数字证书和CA 0.3.5. 证书体系的弱点 0.4. TLS1.2连接过程 0.4.1. HTTPS建立连接 0.4.2. TLS协议的组成 0.4.3. ECDHE握手过程 0.4.4. RSA握手过程 0.4.5. 双向认证 0.5. TLS1.3特性 0.5.1. 最大化兼容性 0.5.2. 强化安全 0.5.3. 提升性能 0.6. HTTPS优化 0.6.1. 硬件优化 0.6.2. 软件优化 0.6.3. 协议优化 0.6.4. 证书优化 0.6.5. 会话复用 0.6.6. 会话票证 0.6.7. 预共享密钥 0.7. 迁移HTTPS 0.7.1. 迁移的必要性 0.7.2. 迁移的顾虑 0.7.3. 申请证书 0.7.4. 配置HTTPS 0.7.5. 服务器名称指示 0.7.6. 重定向跳转 0.1. HTTPS与SSL/TLS HTTP 的一些缺点,其中的“无状态”在加入 Cookie 后得到了解决,而另两个缺点——“明文”和“不安全”仅凭 HTTP 自身是无力解决的,需要引入新的 HTTPS 协议。 由于 HTTP 天生“明文”的特点,整个传输过程完全透明,任何人都能够在链路中截获、修改或者伪造请求/响应报文,数据不具有可信性。 比如“代理服务”作为 HTTP 通信的中间人,在数据上下行的时候可以添加或删除部分头字段,也可以使用黑白名单过滤 body 里的关键字,甚至直接发送虚假的请求、响应,而浏览器和源服务器都没有办法判断报文的真伪。 如果通信过程具备了四个特性,就可以认为是“安全”的,这四个特性是:机密性、完整性,身份认证和不可否认。 机密性(Secrecy/Confidentiality)是指对数据的“保密”,只能由可信的人访问,对其他人是不可见的“秘密”。 完整性(Integrity,也叫一致性)是指数据在传输过程中没有被篡改,“完完整整”地保持着原状。 身份认证(Authentication)是指确认对方的真实身份,保证消息只能发送给可信的人。 不可否认(Non-repudiation/Undeniable)是不能否认已经发生过的行为。 0.1.1. HTTPS HTTPS 其实是一个“非常简单”的协议,RFC 文档很小,只有短短的 7 页,里面规定了新的协议名“https”,默认端口号 443,其他的像请求——应答模式、报文结构、请求方法、URI、头字段、连接管理等等都完全沿用 HTTP,没有任何新的东西。除了协议名“http”和端口号 80 这两点不同,HTTPS 协议在语法、语义上和 HTTP 完全一样,优缺点也“照单全收”(当然要除去“明文”和“不安全”)。 HTTPS 与 HTTP 最大的区别,它能够鉴别危险的网站,并且尽最大可能保证上网安全,防御黑客对信息的窃听、篡改或者“钓鱼”、伪造。 HTTPS 做到机密性、完整性的原理就在于,把 HTTP 下层的传输协议由 TCP/IP 换成了 SSL/TLS,由“HTTP over TCP/IP”变成了“HTTP over SSL/TLS”,让 HTTP 运行在了安全的 SSL/TLS 协议上,收发报文不再使用 Socket API,而是调用专门的安全接口。 HTTPS 本身并没有什么本事,全是靠着后面的 SSL/TLS“撑腰”。 0.1.2. SSL/TLS SSL 即安全套接层(Secure Sockets Layer),在 OSI 模型中处于第 5 层(会话层)。 年份 协议版本 1994 SSLV3/v2 1999 SSLV3.1 => TLS1.0 2006 TLS1.1 2008 TLS1.2 2018 TLS1.3 每个新版本都紧跟密码学的发展和互联网的现状,持续强化安全和性能,已经成为了信息安全领域中的权威标准。 目前应用的最广泛的 TLS(传输层安全,Transport Layer Security) 是 1.2,而之前的协议(TLS1.1/1.0、SSLv3/v2)都已经被认为是不安全的,各大浏览器在 2020 年左右停止支持。 TLS 由记录协议、握手协议、警告协议、变更密码规范协议、扩展协议等几个子协议组成,综合使用了对称加密、非对称加密、身份认证等许多密码学前沿技术。 浏览器和服务器在使用 TLS 建立连接时需要选择一组恰当的加密算法来实现安全通信,这些算法的组合被称为“密码套件”(cipher suite,也叫加密套件)。TLS 的密码套件命名非常规范,格式很固定。基本的形式是“密钥交换算法 + 签名算法 + 对称加密算法 + 摘要算法”。 比如“ECDHE-RSA-AES256-GCM-SHA384”密码套件的意思就是:握手时使用 ECDHE 算法进行密钥交换,用 RSA 签名和身份认证,握手后的通信使用 AES 对称算法,密钥长度 256 位,分组模式是 GCM,摘要算法 SHA384 用于消息认证和产生随机数。” 在OpenSSL里的密码套件定义与TLS略有不同,TLS里的形式是“TLS_ECDHE_RSA_WITH_AES256_GCM_SHA384”,增加前缀并用WITH分开了握手和通信的算法。 除了HTTP,SSL/TLS也可以承载其他的应用协议,例如FTP=>FTPS,LDAP=>LDAPS等。 0.1.3. OpenSSL 说到 TLS,就不能不谈到 OpenSSL,它是一个著名的开源密码学程序库和工具包,几乎支持所有公开的加密算法和协议,已经成为了事实上的标准,许多应用软件都会使用它作为底层库来实现 TLS 功能,包括常用的 Web 服务器 Apache、Nginx 等。 OpenSSL 是从另一个开源库 SSLeay 发展出来的,曾经考虑命名为“OpenTLS”,但当时(1998 年)TLS 还未正式确立,而 SSL 早已广为人知,所以最终使用了“OpenSSL”的名字。OpenSSL注明的“心脏出血”(Heart Bleed)漏洞,出现在1.0.1版本。 OpenSSL 目前有三个主要的分支,1.0.2 和 1.1.0 都在2019年底不再维护,最新的长期支持版本是 1.1.1。 由于 OpenSSL 是开源的,所以它还有一些代码分支,比如 Google 的 BoringSSL、OpenBSD 的 LibreSSL,这些分支在 OpenSSL 的基础上删除了一些老旧代码,也增加了一些新特性,虽然背后有“大金主”,但离取代 OpenSSL 还差得很远。 Mozilla开发了另一个著名的开源密码库NSS(Network Security Services)。 0.2. 非/对称加密 实现机密性最常用的手段是“加密”(encrypt),把消息用某种方式转换成乱码,只有掌握特殊“钥匙”的人才能再转换出原始文本。 这里的“钥匙”就叫做“密钥”(key),加密前的消息叫“明文”(plain text/clear text),加密后的乱码叫“密文”(cipher text),使用密钥还原明文的过程叫“解密”(decrypt),是加密的反操作,加密解密的操作过程就是“加密算法”。 所有的加密算法都是公开的,任何人都可以去分析研究,而算法使用的“密钥”则必须保密。 由于 HTTPS、TLS 都运行在计算机上,所以“密钥”就是一长串的数字,但约定俗成的度量单位是“位”(bit),而不是“字节”(byte)。比如: 密钥长度是 128,就是 16 字节的二进制串, 密钥长度 1024,就是 128 字节的二进制串。 按照密钥的使用方式,加密可以分为两大类:对称加密和非对称加密。 0.2.1. 对称加密 “对称加密”就是指加密和解密时使用密钥都是同一个,是“对称”的。只要保证了密钥的安全,那整个通信过程就可以说具有了机密性。 TLS 里有非常多的对称加密算法,比如 RC4、DES、3DES、AES、ChaCha20 等,但前三种算法都被认为是不安全的,通常都禁止使用,目前常用的只有 AES 和 ChaCha20。 AES 的意思是“高级加密标准”(Advanced Encryption Standard),密钥长度可以是 128、192 或 256。它是 DES 算法的替代者,安全强度很高,性能也很好,而且有的硬件还会做特殊优化,所以非常流行,是应用最广泛的对称加密算法。 ChaCha20 是 Google 设计的加密算法,密钥长度固定为 256 位,纯软件运行性能要超过 AES,曾经在移动客户端上比较流行,但 ARMv8 之后也加入了 AES 硬件优化,所以现在不再具有明显的优势。 0.2.2. 加密分组模式 对称算法还有一个“分组模式”的概念,它可以让算法用固定长度的密钥加密任意长度的明文。 最早有 ECB、CBC、CFB、OFB 等几种分组模式,但都陆续被发现有安全漏洞,所以现在基本都不用了。 最新的分组模式被称为 AEAD(Authenticated Encryption with Associated Data),在加密的同时增加了认证的功能,常用的是 GCM、CCM 和 Poly1305。 把这些组合起来,就可以得到 TLS 密码套件中定义的对称加密算法。比如: AES128-GCM,意思是密钥长度为 128 位的 AES 算法,使用的分组模式是 GCM; ChaCha20-Poly1305,意思是 ChaCha20 算法,使用的分组模式是 Poly1305。 0.2.3. 非对称加密 对称加密有一个很大的问题:如何把密钥安全地传递给对方,术语叫“密钥交换”。因为在对称加密算法中只要持有密钥就可以解密。 如果密钥在传递途中被黑客窃取,那他就可以在之后随意解密收发的数据,通信过程也就没有机密性可言了。 只用对称加密算法,是绝对无法解决密钥交换的问题的。所以,就出现了非对称加密(也叫公钥加密算法)。 它有两个密钥,一个叫“公钥”(public key),一个叫“私钥”(private key)。两个密钥是不同的,“不对称”,公钥可以公开给任何人使用,而私钥必须严格保密。 公钥和私钥有个特别的“单向”性,虽然都可以用来加密解密,但公钥加密后只能用私钥解密,反过来,私钥加密后也只能用公钥解密。 非对称加密可以解决“密钥交换”的问题。网站秘密保管私钥,在网上任意分发公钥,你想要登录网站只要用公钥加密就行了,密文只能由私钥持有者才能解密。而黑客因为没有私钥,所以就无法破解密文。 非对称加密算法的设计要比对称算法难得多,在 TLS 里只有很少的几种,比如 DH、DSA、RSA、ECC 等。 RSA 是其中最著名的,几乎可以说是非对称加密的代名词,它的安全性基于“整数分解”的数学难题,使用两个超大素数的乘积作为生成密钥的材料,想要从公钥推算出私钥是非常困难的。 10 年前 RSA 密钥的推荐长度是 1024,但随着计算机运算能力的提高,现在 1024 已经不安全,普遍认为至少要 2048 位。 ECC(Elliptic Curve Cryptography)是非对称加密里的“后起之秀”,它基于“椭圆曲线离散对数”的数学难题,使用特定的曲线方程和基点生成公钥和私钥,子算法 ECDHE 用于密钥交换,ECDSA 用于数字签名。 目前比较常用的两个曲线是 P-256(secp256r1,在 OpenSSL 称为 prime256v1)和 x25519。 P-256 是 NIST(美国国家标准技术研究所)和 NSA(美国国家安全局)推荐使用的曲线,而 x25519 被认为是最安全、最快速的曲线。 比起 RSA,ECC 在安全强度和性能上都有明显的优势。 160 位的 ECC 相当于 1024 位的 RSA, 224 位的 ECC 则相当于 2048 位的 RSA。 因为密钥短,所以相应的计算量、消耗的内存和带宽也就少,加密解密的性能就上去了,对于现在的移动互联网非常有吸引力。 0.2.4. 混合加密 虽然非对称加密没有“密钥交换”的问题,但因为它们都是基于复杂的数学难题,运算速度很慢,即使是 ECC 也要比 AES 差上好几个数量级。如果仅用非对称加密,虽然保证了安全,但通信速度有如乌龟、蜗牛,实用性就变成了零。 对比实验数据: aes_128_cbc enc/dec 1000 times : 0.97ms, 13.11MB/s rsa_1024 enc/dec 1000 times : 138.59ms, 93.80KB/s rsa_1024/aes ratio = 143.17 rsa_2048 enc/dec 1000 times : 840.35ms, 15.47KB/s rsa_2048/aes ratio = 868.13 RSA 的运算速度是非常慢的,2048 位的加解密大约是 15KB/S(微秒或毫秒级),而 AES128 则是 13MB/S(纳秒级),差了几百倍。 把对称加密和非对称加密结合起来,两者互相取长补短,即能高效地加密解密,又能安全地密钥交换。这就是现在 TLS 里使用的混合加密方式。 在通信刚开始的时候使用非对称算法,比如 RSA、ECDHE,解决密钥交换的问题。 然后用随机数产生对称算法使用的“会话密钥”(session key),再用公钥加密。因为会话密钥很短,通常只有 16 字节或 32 字节,所以慢一点也无所谓。 对方拿到密文后用私钥解密,取出会话密钥。这样,双方就实现了对称密钥的安全交换,后续就不再使用非对称加密,全都使用对称加密。 这样混合加密就解决了对称加密算法的密钥交换问题,而且安全和性能兼顾,完美地实现了机密性。后面还有完整性、身份认证、不可否认等特性没有实现,所以现在的通信还不是绝对安全。 0.3. 数字签名与证书 在机密性的基础上必须加上完整性、身份认证等特性,才能实现真正的安全。 0.3.1. 摘要算法 实现完整性的手段主要是摘要算法(Digest Algorithm),也就是常说的散列函数、哈希函数(Hash Function)。 把摘要算法近似地理解成一种特殊的压缩算法,它能够把任意长度的数据“压缩”成固定长度、而且独一无二的“摘要”字符串,就好像是给这段数据生成了一个数字“指纹”。 可以把摘要算法理解成特殊的“单向”加密算法,它只有算法,没有密钥,加密后的数据无法解密,不能从摘要逆推出原文。 摘要算法实际上是把数据从一个“大空间”映射到了“小空间”,所以就存在“冲突”(collision,也叫碰撞)的可能性,就如同现实中的指纹一样,可能会有两份不同的原文对应相同的摘要。好的摘要算法必须能够“抵抗冲突”,让这种可能性尽量地小。 因为摘要算法对输入具有“单向性”和“雪崩效应”,输入的微小不同会导致输出的剧烈变化,所以也被 TLS 用来生成伪随机数(PRF,pseudo random function)。 MD5(Message-Digest 5)、SHA-1(Secure Hash Algorithm 1),是最常用的两个摘要算法,能够生成 16 字节和 20 字节长度的数字摘要。但这两个算法的安全强度比较低,不够安全,在 TLS 里已经被禁止使用了。 目前 TLS 推荐使用SHA-2,它是一系列摘要算法的统称,总共有 6 种,常用的有 SHA224、SHA256、SHA384,分别能够生成 28 字节、32 字节、48 字节的摘要。 0.3.2. 完整性 摘要算法保证了“数字摘要”和原文是完全等价的。所以,只要在原文后附上它的摘要,就能够保证数据的完整性。 不过摘要算法不具有机密性,如果明文传输,那么黑客可以修改消息后把摘要也一起改了,网站还是鉴别不出完整性。 所以,真正的完整性必须要建立在机密性之上,在混合加密系统里用会话密钥加密消息和摘要,这样黑客无法得知明文,也就没有办法动手脚了。这有个术语,叫哈希消息认证码(HMAC)。 0.3.3. 数字签名 加密算法结合摘要算法的通信过程可以说是比较安全了。但这里还有漏洞,就是通信的两个端点(endpoint)。 黑客可以伪装成网站来窃取信息,可以伪装成用户,向网站发送支付、转账等消息。 在 TLS 的非对称加密里的“私钥”能够在数字世界里证明身份,使用私钥再加上摘要算法,就能够实现“数字签名”,同时实现“身份认证”和“不可否认”。 数字签名的原理,就是把公钥私钥的用法反过来,之前是公钥加密、私钥解密,现在是私钥加密、公钥解密。因为非对称加密效率太低,所以私钥只加密原文的摘要,这样运算量就小的多,而且得到的数字签名也很小,方便保管和传输。 签名和公钥一样完全公开,任何人都可以获取。但这个签名只有用私钥对应的公钥才能解开,拿到摘要后,再比对原文验证完整性。 只要和网站互相交换公钥,就可以用“签名”和“验签”来确认消息的真实性,因为私钥保密,黑客不能伪造签名,就能够保证通信双方的身份。 比如,你用自己的私钥签名一个消息“我是小明”。网站收到后用你的公钥验签,确认身份没问题,于是也用它的私钥签名消息“我是某宝”。你收到后再用它的公钥验一下,也没问题,这样你和网站就都知道对方不是假冒的,后面就可以用混合加密进行安全通信了。 0.3.4. 数字证书和CA 综合使用对称加密、非对称加密和摘要算法,实现了安全的四大特性,还有一个“公钥的信任”问题。 因为谁都可以发布公钥,我们还缺少防止黑客伪造公钥的手段,也就是说,怎么来判断这个公钥就是你或者某宝的公钥呢? 可以用类似密钥交换的方法来解决公钥认证问题,用别的私钥来给公钥签名,显然,这又会陷入“无穷递归”。 找一个公认的可信第三方,让它作为“信任的起点,递归的终点”,构建起公钥的信任链。这个“第三方”就是我们常说的 CA(Certificate Authority,证书认证机构),由它来给各个公钥签名,用自身的信誉来保证公钥无法伪造,是可信的。 CA 对公钥的签名认证也是有格式的,把序列号、用途、颁发者、有效时间等打成一个包再签名,完整地证明公钥关联的各种信息,形成“数字证书”(Certificate)。 知名的 CA 全世界就那么几家,比如 DigiCert、VeriSign、Entrust、Let’s Encrypt(著名免费CA,只颁发DV证书) 等,它们签发的证书分 DV、OV、EV 三种,区别在于可信程度。 DV 是最低的,只是域名级别的可信,背后是谁不知道。 EV 是最高的,经过了法律和审计的严格核查,可以证明网站拥有者的身份(在浏览器地址栏会显示出公司的名字,例如 Apple、GitHub 的网站)。 证书的格式遵循X509 v3标准,有两种编码方式,一种是二进制的DER,另一种是ASCII码的PEM。 CA 怎么证明自己,又是信任链的问题。小 CA 可以让大 CA 签名认证,但链条的最后 Root CA,就只能自己证明自己,这个就叫“自签名证书”(Self-Signed Certificate)或者“根证书”(Root Certificate)。你必须相信,否则整个证书信任链就走不下去了。 有了证书体系,操作系统和浏览器都内置了各大 CA 的根证书,上网的时候只要服务器发过来它的证书,就可以验证证书里的签名,顺着证书链(Certificate Chain)一层层地验证,直到找到根证书,就能够确定证书是可信的,从而里面的公钥也是可信的。 0.3.5. 证书体系的弱点 证书体系(PKI,Public Key Infrastructure)虽然是目前整个网络世界的安全基础设施,但绝对的安全是不存在的,它也有弱点,还是关键的“信任”二字。 如果 CA 失误或者被欺骗,签发了错误的证书,虽然证书是真的,可它代表的网站却是假的。 还有一种更危险的情况,CA 被黑客攻陷,或者 CA 有恶意,因为它(即根证书)是信任的源头,整个信任链里的所有证书也就都不可信了。 所以,需要再给证书体系打上一些补丁。 针对第一种,开发出了 CRL(证书吊销列表,Certificate revocation list)和 OCSP(在线证书状态协议,Online Certificate Status Protocol),及时废止有问题的证书。 对于第二种,因为涉及的证书太多,就只能操作系统或者浏览器从根上“下狠手”了,撤销对 CA 的信任,列入“黑名单”,这样它颁发的所有证书就都会被认为是不安全的。 0.4. TLS1.2连接过程 0.4.1. HTTPS建立连接 浏览器首先要从 URI 里提取出协议名和域名。 因为协议名是“https”,所以浏览器就知道了端口号是默认的 443, 它再用 DNS 解析域名,得到目标的 IP 地址,然后就可以使用三次握手与网站建立 TCP 连接了。 在 HTTP 协议里,建立连接后,浏览器会立即发送请求报文。 在 HTTPS 协议里,需要另外一个“握手”过程,在 TCP 上建立安全连接,之后才是收发 HTTP 报文。这个“握手”过程与 TCP 有些类似,是 HTTPS 和 TLS 协议里最重要、最核心的部分。 0.4.2. TLS协议的组成 TLS 包含几个子协议,由几个不同职责的模块组成,比较常用的有记录协议、警报协议、握手协议、变更密码规范协议等。 记录协议(Record Protocol)规定了 TLS 收发数据的基本单位:记录(record)。它有点像是 TCP 里的 segment,所有的其他子协议都需要通过记录协议发出。但多个记录数据可以在一个 TCP 包里一次性发出,也并不需要像 TCP 那样返回 ACK。 警报协议(Alert Protocol)的职责是向对方发出警报信息,有点像是 HTTP 协议里的状态码。比如,protocol_version 就是不支持旧版本,bad_certificate 就是证书有问题,收到警报后另一方可以选择继续,也可以立即终止连接。 握手协议(Handshake Protocol)是 TLS 里最复杂的子协议,要比 TCP 的 SYN/ACK 复杂的多,浏览器和服务器会在握手过程中协商 TLS 版本号、随机数、密码套件等信息,然后交换证书和密钥参数,最终双方协商得到会话密钥,用于后续的混合加密系统。 变更密码规范协议(Change Cipher Spec Protocol),它非常简单,就是一个“通知”,告诉对方,后续的数据都将使用加密保护。在它之前,数据都是明文的。 下面的这张图简要地描述了 TLS 的握手过程,其中每一个“框”都是一个记录,多个记录组合成一个 TCP 包发送。所以,最多经过两次消息往返(4 个消息)就可以完成握手,然后就可以在安全的通信环境里发送 HTTP 报文,实现 HTTPS 协议。 0.4.3. ECDHE握手过程 在 TCP 建立连接之后,浏览器会首先发一个“Client Hello”消息,也就是跟服务器“打招呼”。里面有客户端的版本号、支持的密码套件,还有一个随机数(Client Random),用于后续生成会话密钥。 Handshake Protocol: Client Hello Version: TLS 1.2 (0x0303) Random: 1cbf803321fd2623408dfe… Cipher Suites (17 suites) Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f) Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (0xc030) 服务器收到“Client Hello”后,会返回一个“Server Hello”消息。把版本号对一下,也给出一个随机数(Server Random),然后从客户端的列表里选一个作为本次通信使用的密码套件,在这里它选择了“TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384”。 Handshake Protocol: Server Hello Version: TLS 1.2 (0x0303) Random: 0e6320f21bae50842e96… Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (0xc030) 然后,服务器为了证明自己的身份,就把证书也发给了客户端(Server Certificate)。 接下来是一个关键的操作,因为服务器选择了 ECDHE 算法,所以它会在证书后发送“Server Key Exchange”消息,里面是公钥(Server Params),用来实现密钥交换算法,再加上自己的私钥签名认证。 Handshake Protocol: Server Key Exchange EC Diffie-Hellman Server Params Curve Type: named_curve (0x03) Named Curve: x25519 (0x001d) Pubkey: 3b39deaf00217894e... Signature Algorithm: rsa_pkcs1_sha512 (0x0601) Signature: 37141adac38ea4... 之后是“Server Hello Done”消息。 这样第一个消息往返就结束了(两个 TCP 包),结果是客户端和服务器通过明文共享了三个信息:Client Random、Server Random 和 Server Params。 客户端这时也拿到了服务器的证书,这就要开始走证书链逐级验证,确认证书的真实性,再用证书公钥验证签名,就确认了服务器的身份。 然后,客户端按照密码套件的要求,也生成一个公钥(Client Params),用“Client Key Exchange”消息发给服务器。 Handshake Protocol: Client Key Exchange EC Diffie-Hellman Client Params Pubkey: 8c674d0e08dc27b5eaa… 现在客户端和服务器手里都拿到了密钥交换算法的两个参数(Client Params、Server Params),就用 ECDHE 算法算出了“Pre-Master”,也是一个随机数。 ECDHE算法可以保证即使黑客截获了之前的参数,也是绝对算不出这个随机数的。 现在客户端和服务器手里有了三个随机数:Client Random、Server Random 和 Pre-Master。用这三个作为原始材料,就可以生成用于加密会话的主密钥,叫“Master Secret”。 而黑客因为拿不到“Pre-Master”,所以也就得不到主密钥。 为了保证真正的“完全随机”“不可预测”,把三个不可靠的随机数混合起来,那么“随机”的程度就非常高了,足够让黑客难以猜测。 “Master Secret”的计算公式: master_secret = PRF(pre_master_secret, "master secret", ClientHello.random + ServerHello.random) “PRF”是伪随机数函数,它基于密码套件里的最后一个参数,通过摘要算法来再一次强化“Master Secret”的随机性。 主密钥有 48 字节,但它也不是最终用于通信的会话密钥,还会再用 PRF 扩展出更多的密钥,比如客户端发送用的会话密钥(client_write_key)、服务器发送用的会话密钥(server_write_key)等,避免只用一个密钥带来的安全隐患。 有了主密钥和派生的会话密钥,握手就快结束了。客户端发一个“Change Cipher Spec”,然后再发一个“Finished”消息,把之前所有发送的数据做个摘要,再加密一下,让服务器做个验证。 服务器也是同样的操作,发“Change Cipher Spec”和“Finished”消息,双方都验证加密解密 OK,握手正式结束,后面就收发被加密的 HTTP 请求和响应了。 0.4.4. RSA握手过程 上面的握手是如今主流的 TLS 握手过程,这与传统的握手有两点不同。 第一个,使用 ECDHE 实现密钥交换,而不是 RSA,所以会在服务器端发出“Server Key Exchange”消息。 第二个,因为使用了 ECDHE,客户端可以不用等到服务器发回“Finished”确认握手完毕,立即就发出 HTTP 报文,省去了一个消息往返的时间浪费。这个叫“TLS False Start”和“TCP Fast Open”有点像,都是不等连接完全建立就提前发应用数据,提高传输的效率。 大体的流程没有变,只是“Pre-Master”不再需要用算法生成,而是客户端直接生成随机数,然后用服务器的公钥加密,通过“Client Key Exchange”消息发给服务器。服务器再用私钥解密,这样双方也实现了共享三个随机数,就可以生成主密钥。 0.4.5. 双向认证 上面过程是“单向认证”握手过程,只认证了服务器的身份,而没有认证客户端的身份。这是因为通常单向认证通过后已经建立了安全通信,用账号、密码等简单的手段就能够确认用户的真实身份。 但为了防止账号、密码被盗,有的时候(比如网上银行)还会使用 U 盾给用户颁发客户端证书,实现“双向认证”,这样会更加安全。 双向认证的流程也没有太多变化,只是在“Server Hello Done”之后,“Client Key Exchange”之前,客户端要发送“Client Certificate”消息,服务器收到后也把证书链走一遍,验证客户端的身份。 0.5. TLS1.3特性 TLS1.3 的三个主要改进目标:兼容、安全与性能。 0.5.1. 最大化兼容性 由于 1.1、1.2 等协议已经出现了很多年,很多应用软件、中间代理(官方称为“MiddleBox”)只认老的记录协议格式,更新改造很困难,甚至是不可行(设备僵化)。 在早期的试验中发现,一旦变更了记录头字段里的版本号,也就是由 0x303(TLS1.2)改为 0x304(TLS1.3)的话,大量的代理服务器、网关都无法正确处理,最终导致 TLS 握手失败。 为了保证这些被广泛部署的“老设备”能够继续使用,避免新协议带来的“冲击”,TLS1.3 不得不做出妥协,保持现有的记录格式不变,通过“伪装”来实现兼容,使得 TLS1.3 看上去“像是”TLS1.2。 区分 1.2 和 1.3 用到一个新的扩展协议(Extension Protocol),通过在记录末尾添加一系列的“扩展字段”来增加新的功能,老版本的 TLS 不认识它可以直接忽略,这就实现了“后向兼容”。 在记录头的 Version 字段被兼容性“固定”的情况下,只要是 TLS1.3 协议,握手的“Hello”消息后面就必须有“supported_versions”扩展,它标记了 TLS 的版本号,使用它就能区分新旧协议。 Handshake Protocol: Client Hello Version: TLS 1.2 (0x0303) Extension: supported_versions (len=11) Supported Version: TLS 1.3 (0x0304) Supported Version: TLS 1.2 (0x0303) TLS1.3 利用扩展实现了许多重要的功能,比如“supported_groups”“key_share”“signature_algorithms”“server_name”等。 0.5.2. 强化安全 TLS1.2 在十来年的应用中获得了许多宝贵的经验,陆续发现了很多的漏洞和加密算法的弱点,所以 TLS1.3 就在协议里修补了这些不安全因素。比如: 伪随机数函数由 PRF 升级为 HKDF(HMAC-based Extract-and-Expand Key Derivation Function); 明确禁止在记录协议里使用压缩; 废除了 RC4、DES 对称加密算法; 废除了 ECB、CBC 等传统分组模式; 废除了 MD5、SHA1、SHA-224 摘要算法; 废除了 RSA、DH 密钥交换算法和许多命名曲线。 TLS1.3 里只保留了: AES、ChaCha20 对称加密算法, 分组模式只能用 AEAD 的 GCM、CCM 和 Poly1305, 摘要算法只能用 SHA256、SHA384, 密钥交换算法只有 ECDHE 和 DHE, 椭圆曲线也被“砍”到只剩 P-256 和 x25519。 现在的 TLS1.3 里只有 5 个套件,无论是客户端还是服务器都不会再犯“选择困难症”了。 废除 RSA 和 DH 密钥交换算法的原因,浏览器默认会使用 ECDHE 而不是 RSA 做密钥交换,这是因为它不具有“前向安全”(Forward Secrecy)。 假设有这么一个很有耐心的黑客,一直在长期收集混合加密系统收发的所有报文。如果加密系统使用服务器证书里的 RSA 做密钥交换,一旦私钥泄露或被破解(使用社会工程学或者巨型计算机),那么黑客就能够使用私钥解密出之前所有报文的“Pre-Master”,再算出会话密钥,破解所有密文。这就是所谓的“今日截获,明日破解”。而 ECDHE 算法在每次握手时都会生成一对临时的公钥和私钥,每次通信的密钥对都是不同的,也就是“一次一密”,即使黑客花大力气破解了这一次的会话密钥,也只是这次通信被攻击,之前的历史消息不会受到影响,仍然是安全的。 所以现在主流的服务器和浏览器在握手阶段都已经不再使用 RSA,改用 ECDHE,而 TLS1.3 在协议里明确废除 RSA 和 DH 则在标准层面保证了“前向安全”。 0.5.3. 提升性能 HTTPS 建立连接时除了要做 TCP 握手,还要做 TLS 握手,在 1.2 中会多花两个消息往返(2-RTT),可能导致几十毫秒甚至上百毫秒的延迟,在移动网络中延迟还会更严重。 现在因为密码套件大幅度简化,也就没有必要再像以前那样走复杂的协商流程了。TLS1.3 压缩了以前的“Hello”协商过程,删除了“Key Exchange”消息,把握手时间减少到了“1-RTT”,效率提高了一倍。 具体的做法还是利用了扩展。 客户端在“Client Hello”消息里直接用“supported_groups”带上支持的曲线,比如 P-256、x25519,用“key_share”带上曲线对应的客户端公钥参数,用“signature_algorithms”带上签名算法。 服务器收到后在这些扩展里选定一个曲线和参数,再用“key_share”扩展返回服务器这边的公钥参数,就实现了双方的密钥交换,后面的流程就和 1.2 基本一样了。 除了标准的“1-RTT”握手,TLS1.3 还引入了“0-RTT”握手,用“pre_shared_key”和“early_data”扩展,在 TCP 连接后立即就建立安全连接发送加密消息,不过这需要有一些前提条件。 0.6. HTTPS优化 HTTPS 连接大致上可以划分为两个部分, 第一个是建立连接时的非对称加密握手, 第二个是握手后的对称加密报文传输。 目前流行的 AES、ChaCha20 性能都很好,还有硬件优化,报文传输的性能损耗小到几乎忽略不计。所以,通常所说的“HTTPS 连接慢”指的就是刚开始建立连接的那段时间。 在 TCP 建连之后,正式数据传输之前,HTTPS 比 HTTP 增加了一个 TLS 握手的步骤,这个步骤最长可以花费两个消息往返,也就是 2-RTT。而且在握手消息的网络耗时之外,还会有其他的一些“隐形”消耗,比如: 产生用于密钥交换的临时公私钥对(ECDHE); 验证证书时访问 CA 获取 CRL 或者 OCSP; 非对称加密解密处理“Pre-Master”。 在最差的情况下,也就是不做任何的优化措施,HTTPS 建立连接可能会比 HTTP 慢上几百毫秒甚至几秒,这其中既有网络耗时,也有计算耗时,就会让人产生“打开一个 HTTPS 网站好慢啊”的感觉。 现在已经有了很多行之有效的 HTTPS 优化手段,运用得好可以把连接的额外耗时降低到几十毫秒甚至是“零”。 把 TLS 握手过程中影响性能的部分都标记了出来,对照着它就可以“有的放矢”地来优化 HTTPS。 0.6.1. 硬件优化 在计算机世界里的“优化”可以分成“硬件优化”和“软件优化”两种方式。 HTTPS 连接是计算密集型: 首先,可以选择更快的 CPU,最好还内建 AES 优化,这样即可以加速握手,也可以加速传输。 其次,可以选择“SSL 加速卡”,加解密时调用它的 API,让专门的硬件来做非对称加解密,分担 CPU 的计算压力。 “SSL 加速卡”有一些缺点,比如升级慢、支持算法有限,不能灵活定制解决方案等。 第三种硬件加速方式:“SSL 加速服务器”,用专门的服务器集群来彻底“卸载”TLS 握手时的加密解密计算,性能自然要比单纯的“加速卡”要强大的多。 0.6.2. 软件优化 硬件优化方式中除了 CPU,其他的方式还要有一些开发适配工作,有一定的实施难度。 比如,“加速服务器”中关键的一点是通信必须是“异步”的,不能阻塞应用服务器,否则加速就没有意义了。所以,软件优化的方式相对来说更可行一些,性价比高。 软件方面的优化还可以再分成两部分:一个是软件升级,一个是协议优化。 软件升级实施起来比较简单,就是把现在正在使用的软件尽量升级到最新版本,比如把 Linux 内核由 2.x 升级到 4.x,把 Nginx 由 1.6 升级到 1.16,把 OpenSSL 由 1.0.1 升级到 1.1.0/1.1.1。 这些软件在更新版本的时候都会做性能优化、修复错误,只要运维能够主动配合,这种软件优化是最容易做的,也是最容易达成优化效果的。 0.6.3. 协议优化 从上面的 TLS 握手图中可以看到影响性能的一些环节,协议优化就要从这些方面着手。 密钥交换过程,如果有可能,应当尽量采用 TLS1.3,它大幅度简化了握手的过程,完全握手只要 1-RTT,而且更加安全。如果暂时不能升级到 1.3,只能用 1.2,那么握手时使用的密钥交换协议应当尽量选用椭圆曲线的 ECDHE 算法。它不仅运算速度快,安全性高,还支持“False Start”,能够把握手的消息往返由 2-RTT 减少到 1-RTT,达到与 TLS1.3 类似的效果。 椭圆曲线也要选择高性能的曲线,最好是 x25519,次优选择是 P-256。 对称加密算法方面,也可以选用“AES_128_GCM”,它能比“AES_256_GCM”略快一点点。 在 Nginx 里可以用“ssl_ciphers”“ssl_ecdh_curve”等指令配置服务器使用的密码套件和椭圆曲线,把优先使用的放在前面,例如: ssl_ciphers TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:EECDH+CHACHA20; ssl_ecdh_curve X25519:P-256; 0.6.4. 证书优化 握手过程中的证书验证也是一个比较耗时的操作,服务器需要把自己的证书链全发给客户端,然后客户端接收后再逐一验证。这里就有两个优化点: 一个是证书传输,服务器的证书可以选择椭圆曲线(ECDSA)证书而不是 RSA 证书,因为 224 位的 ECC 相当于 2048 位的 RSA,所以椭圆曲线证书的“个头”要比 RSA 小很多,即能够节约带宽也能减少客户端的运算量,可谓“一举两得”。 一个是证书验证。客户端的证书验证其实是个很复杂的操作,除了要公钥解密验证多个证书签名外,因为证书还有可能会被撤销失效,客户端有时还会再去访问 CA,下载 CRL 或者 OCSP 数据,这又会产生 DNS 查询、建立连接、收发数据等一系列网络通信,增加好几个 RTT。 CRL(Certificate revocation list,证书吊销列表)由 CA 定期发布,里面是所有被撤销信任的证书序号,查询这个列表就可以知道证书是否有效。但 CRL 因为是“定期”发布,就有“时间窗口”的安全隐患,而且随着吊销证书的增多,列表会越来越大,一个 CRL 经常会上 MB。 每次需要预先下载几 M 的“无用数据”才能连接网站,实用性实在是太低了。所以,现在 CRL 基本上不用了,取而代之的是 OCSP(在线证书状态协议,Online Certificate Status Protocol),向 CA 发送查询请求,让 CA 返回证书的有效状态。 但 OCSP 也要多出一次网络请求的消耗,而且还依赖于 CA 服务器,如果 CA 服务器很忙,那响应延迟也是等不起的。于是又出来了一个“补丁”,叫“OCSP Stapling”(OCSP 装订),它可以让服务器预先访问 CA 获取 OCSP 响应,然后在握手时随着证书一起发给客户端,免去了客户端连接 CA 服务器查询的时间。 0.6.5. 会话复用 HTTPS 建立连接的过程:先是 TCP 三次握手,然后是 TLS 一次握手。之后一次握手的重点是算出主密钥“Master Secret”,而主密钥每次连接都要重新计算,如果能够把主密钥缓存一下“重用”,不就可以免去了握手和计算的成本了。 这种做法就叫“会话复用”(TLS session resumption),和 HTTP Cache 一样,也是提高 HTTPS 性能的“大杀器”,被浏览器和服务器广泛应用。 会话复用分两种(TLS1.3中删除了): 第一种叫“Session ID”,就是客户端和服务器首次连接后各自保存一个会话的 ID 号,内存里存储主密钥和其他相关的信息。当客户端再次连接时发一个 ID 过来,服务器就在内存里找,找到就直接用主密钥恢复会话状态,跳过证书验证和密钥交换,只用一个消息往返就可以建立安全通信。 0.6.6. 会话票证 “Session ID”是最早出现的会话复用技术,也是应用最广的,但它也有缺点,服务器必须保存每一个客户端的会话数据,对于拥有百万、千万级别用户的网站来说存储量就成了大问题,加重了服务器的负担。 第二种“Session Ticket”方案。它有点类似 HTTP 的 Cookie,存储的责任由服务器转移到了客户端,服务器加密会话信息,用“New Session Ticket”消息发给客户端,让客户端保存。重连的时候,客户端使用扩展“session_ticket”发送“Ticket”而不是“Session ID”,服务器解密后验证有效期,就可以恢复会话,开始加密通信。 “Session Ticket”方案需要使用一个固定的密钥文件(ticket_key)来加密 Ticket,为了防止密钥被破解,保证“前向安全”,密钥文件需要定期轮换,比如设置为一小时或者一天。 0.6.7. 预共享密钥 “False Start”“Session ID”“Session Ticket”等方式只能实现 1-RTT,而 TLS1.3 更进一步实现了“0-RTT”,原理和“Session Ticket”差不多,但在发送 Ticket 的同时会带上应用数据(Early Data),免去了 1.2 里的服务器确认步骤,这种方式叫“Pre-shared Key”,简称为“PSK”。 但“PSK”也不是完美的,它为了追求效率而牺牲了一点安全性,容易受到“重放攻击”(Replay attack)的威胁。黑客可以截获“PSK”的数据,像复读机那样反复向服务器发送。 解决的办法是只允许安全的 GET/HEAD 方法,在消息里加入时间戳、“nonce”验证,或者“一次性票证”限制重放。 0.7. 迁移HTTPS 0.7.1. 迁移的必要性 移动应用开发中,Apple、Android、微信等开发平台在 2017 年就相继发出通知,要求所有的应用必须使用 HTTPS 连接,禁止不安全的 HTTP。 在台式机上,主流的浏览器 Chrome、Firefox 等也早就开始“强推”HTTPS,把 HTTP 站点打上“不安全”的标签,给用户以“心理压力”。 Google 等搜索巨头还利用自身的“话语权”优势,降低 HTTP 站点的排名,而给 HTTPS 更大的权重,力图让网民只访问到 HTTPS 网站。 这些手段都逐渐“挤压”了纯明文 HTTP 的生存空间,HTTPS 的大潮无法阻挡,目前国内外的许多知名大站都已经实现了“全站 HTTPS”。 0.7.2. 迁移的顾虑 阻碍 HTTPS 实施的因素有三个比较流行的观点:“慢、贵、难”。 所谓“慢”,是指惯性思维,认为 HTTPS 会增加服务器的成本,增加客户端的时延,影响用户体验。现在服务器和客户端的运算能力都已经有了很大的提升,性能方面完全没有担心的必要,而且还可以应用很多的优化解决方案。 根据 Google 等公司的评估,在经过适当优化之后,HTTPS 的额外 CPU 成本小于 1%,额外的网络成本小于 2%,可以说是与无加密的 HTTP 相差无几。 所谓“贵”,这也属于惯性思维,在早几年的确是个问题,向 CA 申请证书的过程不仅麻烦,而且价格昂贵,每年要交几千甚至几万元。但现在就不一样了,为了推广 HTTPS,很多云服务厂商都提供了一键申请、价格低廉的证书,而且还出现了专门颁发免费证书的 CA,其中最著名的就是“Let’s Encrypt”。 所谓的“难”,是指 HTTPS 涉及的知识点太多、太复杂,有一定的技术门槛,不能很快上手。HTTPS 背后关联到了密码学、TLS、PKI 等许多领域,但实施 HTTPS 也并不需要把这些完全掌握。 0.7.3. 申请证书 要把网站从 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 客户端会自动添加这样的定期任务,完全不用你操心。 0.7.4. 配置HTTPS 配置 Web 服务器,在 443 端口上开启 HTTPS 服务了。 这在 Nginx 上非常简单,只要在“listen”指令后面加上参数“ssl”,再配上刚才的证书文件就可以实现最基本的 HTTPS。 为了提高 HTTPS 的安全系数和性能,你还可以强制 Nginx 只支持 TLS1.2 以上的协议,打开“Session Ticket”会话复用。 密码套件的选择方面,我给你的建议是以服务器的套件优先。这样可以避免恶意客户端故意选择较弱的套件、降低安全等级,然后密码套件向 TLS1.3“看齐”,只使用 ECDHE、AES 和 ChaCha20,支持“False Start”。 如果客户端硬件没有 AES 优化,服务器就会顺着客户端的意思,优先选择与 AES“等价”的 ChaCha20 算法,让客户端能够快一点。 0.7.5. 服务器名称指示 配置 HTTPS 服务时还有一个“虚拟主机”的问题需要解决。 在 HTTP 协议里,多个域名可以同时在一个 IP 地址上运行,这就是“虚拟主机”,Web 服务器会使用请求头里的 Host 字段来选择。 但在 HTTPS 里,因为请求头只有在 TLS 握手之后才能发送,在握手时就必须选择“虚拟主机”对应的证书,TLS 无法得知域名的信息,就只能用 IP 地址来区分。 最早的时候每个 HTTPS 域名必须使用独立的 IP 地址,非常不方便。用 TLS 的“扩展”,给协议加个 SNI(Server Name Indication)的“补充条款”。它的作用和 Host 字段差不多,客户端会在“Client Hello”时带上域名信息,这样服务器就可以根据名字而不是 IP 地址来选择证书。 SNI使用明文表示域名,也就是提前暴露了一部分HTTPS的信息,有安全隐患,容易被“中间人”发起拒绝攻击,被认为是TLS盔甲上的最后一个缝隙,目前正在起草ESNI规范。 Nginx 很早就基于 SNI 特性支持了 HTTPS 的虚拟主机,但在 OpenResty 里可还以编写 Lua 脚本,利用 Redis、MySQL 等数据库更灵活快速地加载证书。 0.7.6. 重定向跳转 有了 HTTPS 服务,原来的 HTTP 站点也不能马上弃用,很多用户习惯在地址栏里直接敲域名(或者是旧的书签、超链接),默认使用 HTTP 协议访问。 所以,需要用“重定向跳转”技术,把不安全的 HTTP 网址用 301 或 302“重定向”到新的 HTTPS 网站,这在 Nginx 里也很容易做到,使用“return”或“rewrite”都可以。但这种方式有两个问题。 一个是重定向增加了网络成本,多出了一次请求; 另一个是存在安全隐患,重定向的响应可能会被“中间人”窜改,实现“会话劫持”,跳转到恶意网站。 “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 网站。这样“中间人”就失去了攻击的机会,而且对于客户端来说也免去了一次跳转,加快了连接速度。 “HSTS”无法防止黑客对第一次访问的攻击,所有Chrome等楼兰器还内置了一个“HSTS preload”的列表(chrome://net-internals/#hsts),只要域名在这个列表里,无论何时都会强制使用HTTPS访问。

06-HTTP未来 阅读更多

0.1. HTTP/2特性 0.1.1. 兼容HTTP/1 0.1.2. 头部压缩 0.1.3. 二进制格式 0.1.4. 虚拟的“流” 0.1.5. 强化安全 0.1.6. 协议栈 0.2. HTTP/2内核 0.2.1. 连接前言 0.2.2. 头部压缩 0.2.3. 二进制帧 0.2.4. 流与多路复用 0.2.5. 流状态转换 0.3. HTTP/3 0.3.1. HTTP/2的队头阻塞 0.3.2. QUIC协议 0.3.3. QUIC协议特点 0.3.4. QUIC内部细节 0.3.5. HTTP/3协议 0.3.6. HTTP/3 服务发现 HTTP 有两个主要的缺点:安全不足和性能不高。 通过引入 SSL/TLS 在安全上达到了“极致”,但在性能提升方面却是乏善可陈,只优化了握手加密的环节,对于整体的数据传输没有提出更好的改进方案,还只能依赖于“长连接”这种“落后”的技术。 在 HTTPS 逐渐成熟之后,HTTP 就向着性能方面开始“发力”,走出了另一条进化的道路。 Google 率先发明了 SPDY 协议,并应用于自家的浏览器 Chrome,打响了 HTTP 性能优化的“第一枪”。 随后互联网标准化组织 IETF 以 SPDY 为基础,综合其他多方的意见,终于推出了 HTTP/1 的继任者HTTP/2,在性能方面有了一个大的飞跃。 0.1. HTTP/2特性 HTTP/2 工作组认为以前的“1.0”“1.1”造成了很多的混乱和误解,让人在实际的使用中难以区分差异,所以就决定 HTTP 协议不再使用小版本号(minor version),只使用大版本号(major version),从今往后 HTTP 协议不会出现 HTTP/2.0、2.1,只会有“HTTP/2”“HTTP/3”,这样就可以明确无误地辨别出协议版本的“跃进程度”,让协议在一段较长的时期内保持稳定,每当发布新版本的 HTTP 协议都会有本质的不同,绝不会有“零敲碎打”的小改良。 0.1.1. 兼容HTTP/1 由于 HTTPS 已经在安全方面做的非常好了,所以 HTTP/2 的唯一目标就是改进性能。但它同时还背负着 HTTP/1 庞大的历史包袱,所以协议的修改必须小心谨慎,兼容性是首要考虑的目标。 HTTP/2 把 HTTP 分解成了“语义”和“语法”两个部分,“语义”层不做改动,与 HTTP/1 完全一致(即 RFC7231)。比如请求方法、URI、状态码、头字段等概念都保留不变,基于 HTTP 的上层应用也不需要做任何修改,可以无缝转换到 HTTP/2。 与 HTTPS 不同,HTTP/2 没有在 URI 里引入新的协议名,仍然用“http”表示明文协议,用“https”表示加密协议。这是一个非常了不起的决定,可以让浏览器或者服务器去自动升级或降级协议,免去了选择的麻烦,让用户在上网的时候都意识不到协议的切换,实现平滑过渡。 在“语义”保持稳定之后,HTTP/2 在“语法”层做了“天翻地覆”的改造,完全变更了 HTTP 报文的传输格式。 0.1.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/2的前身SPDY在压缩头部时使用了gzip,但发现会收到CRIME工攻击,所有开发了专用的压缩算法HPACK。 0.1.3. 二进制格式 HTTP/1 里纯文本形式的报文,它的优点是“一目了然”,用最简单的工具就可以开发调试,非常方便。 HTTP/2 在这方面没有“妥协”,不再使用肉眼可见的 ASCII 码,而是向下层的 TCP/IP 协议“靠拢”,全面采用二进制格式。这样虽然对人不友好,但却大大方便了计算机的解析。 原来使用纯文本的时候容易出现多义性,比如大小写、空白字符、回车换行、多字少字等等,程序在处理时必须用复杂的状态机,效率低,还麻烦。 二进制里只有“0”和“1”,可以严格规定字段大小、顺序、标志位等格式,解析起来没有歧义,实现简单,而且体积小、速度快,做到“内部提效”。 以二进制格式为基础,HTTP/2 把 TCP 协议的部分特性挪到了应用层,把原来的“Header+Body”的消息“打散”为数个小片的二进制“帧”(Frame),用“HEADERS”帧存放头数据、“DATA”帧存放实体数据。 这种做法有点像是“Chunked”分块编码的方式,也是“化整为零”的思路,但 HTTP/2 数据分帧后“Header+Body”的报文结构就完全消失了,协议看到的只是一个个的“碎片”。 0.1.4. 虚拟的“流” 消息的“碎片”到达目的地后应该怎么组装起来呢? HTTP/2 为此定义了一个“流”(Stream)的概念,它是二进制帧的双向传输序列,同一个消息往返的帧会分配一个唯一的流 ID。想象一个虚拟的“数据流”,在里面流动的是一串有先后顺序的数据帧,这些数据帧按照次序组装起来就是 HTTP/1 里的请求报文和响应报文。 “流”是虚拟的,实际上并不存在,所以 HTTP/2 就可以在一个 TCP 连接上用“流”同时发送多个“碎片化”的消息,这就是“多路复用”( Multiplexing),在多个往返通信都复用一个连接来处理。 在“流”的层面上看,消息是一些有序的“帧”序列,而在“连接”的层面上,消息却是乱序收发的“帧”。多个请求/响应之间没有了顺序关系,不需要排队等待,也就不会再出现“队头阻塞”问题,降低了延迟,大幅度提高了连接的利用率。 为了更好地利用连接,加大吞吐量,HTTP/2 还添加了一些控制帧来管理虚拟的“流”,实现了优先级和流量控制,这些特性也和 TCP 协议非常相似。 HTTP/2 还在一定程度上改变了传统的“请求——应答”工作模式,服务器不再是完全被动地响应请求,也可以新建“流”主动向客户端发送消息。 比如,在浏览器刚请求 HTML 的时候就提前把可能会用到的 JS、CSS 文件发给客户端,减少等待的延迟,这被称为“服务器推送”(Server Push,也叫 Cache Push)。 0.1.5. 强化安全 出于兼容的考虑,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”。 0.1.6. 协议栈 下图对比 HTTP/1、HTTPS 和 HTTP/2 的协议栈,HTTP/2 是建立在“HPack”“Stream”“TLS1.2”基础之上的,比 HTTP/1、HTTPS 复杂。 虽然 HTTP/2 的底层实现很复杂,但它的“语义”还是简单的 HTTP/1。 0.2. HTTP/2内核 0.2.1. 连接前言 HTTP/2“事实上”是基于 TLS,所以在正式收发数据之前,会有 TCP 握手和 TLS 握手。 TLS 握手成功之后,客户端必须要发送一个“连接前言”(connection preface),用来确认建立 HTTP/2 连接。 这个“连接前言”是标准的 HTTP/1 请求报文,使用纯文本的 ASCII 码格式,请求方法是特别注册的一个关键字“PRI”,全文只有 24 个字节: PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n 在 Wireshark 里,HTTP/2 的“连接前言”被称为“Magic”。 只要服务器收到这个“Magic”,就知道客户端在 TLS 上想要的是 HTTP/2 协议,而不是其他别的协议,后面就会都使用 HTTP/2 的数据格式。 0.2.2. 头部压缩 确立了连接之后,HTTP/2 就开始准备请求报文。 因为语义上它与 HTTP/1 兼容,所以报文还是由“Header+Body”构成的,但在请求发送前,必须要用“HPACK”算法来压缩头部数据。 “HPACK”算法是专门为压缩 HTTP 头部定制的算法,与 gzip、zlib 等压缩算法不同,它是一个“有状态”的算法,需要客户端和服务器各自维护一份“索引表”,也可以说是“字典”(这有点类似 brotli),压缩和解压缩就是查表和更新表的操作。 为了方便管理和压缩,HTTP/2 废除了原有的起始行概念,把起始行里面的请求方法、URI、状态码等统一转换成了头字段的形式,并且给这些“不是头字段的头字段”起了个特别的名字——“伪头字段”(pseudo-header fields)。而起始行里的版本号和错误原因短语因为没什么大用,顺便也给废除了。 为了与“真头字段”区分开来,这些“伪头字段”会在名字前加一个“:”,比如“:authority” “:method” “:status”,分别表示的是域名、请求方法和状态码。 现在 HTTP 报文头就简单了,全都是“Key-Value”形式的字段,于是 HTTP/2 就为一些最常用的头字段定义了一个只读的“静态表”(Static Table)。 在HTTP/1里头字段是不分区大写的,这在实践中造成了一些混乱,写法很随意,所有HTTP/2做出了明确的规定,要求所有的头字段必须全部小写,大写会任务是格式错误。 下面的这个表格列出了“静态表”的一部分,这样只要查表就可以知道字段名和对应的值,比如数字“2”代表“GET”,数字“8”代表状态码 200。 如果表里只有 Key 没有 Value,或者是自定义字段根本找不到,这就要用到“动态表”(Dynamic Table),它添加在静态表后面,结构相同,但会在编码解码的时候随时更新。 比如说,第一次发送请求时的“user-agent”字段长是一百多个字节,用哈夫曼压缩编码发送之后,客户端和服务器都更新自己的动态表,添加一个新的索引号“65”。那么下一次发送的时候就不用再重复发那么多字节了,只要用一个字节发送编号就好。 随着在 HTTP/2 连接上发送的报文越来越多,两边的“字典”也会越来越丰富,最终每次的头部字段都会变成一两个字节的代码,原来上千字节的头用几十个字节就可以表示了,压缩效果比 gzip 要好得多。 0.2.3. 二进制帧 头部数据压缩之后,HTTP/2 就要把报文拆成二进制的帧准备发送。HTTP/2 的帧结构有点类似 TCP 的段或者 TLS 里的记录,但报头很小,只有 9 字节,非常地节省( TCP 头最少是 20 个字节)。二进制的格式也保证了不会有歧义,而且使用位运算能够非常简单高效地解析。 帧开头是 3 个字节的长度(但不包括头的 9 个字节),默认上限是 2^14,最大是 2^24,也就是说 HTTP/2 的帧通常不超过 16K,最大是 16M。 长度后面的 1 个字节是帧类型,大致可以分成数据帧和控制帧两类: HEADERS 帧和 DATA 帧属于数据帧,存放的是 HTTP 报文, SETTINGS、PING、PRIORITY 等则是用来管理流的控制帧。 HTTP/2 总共定义了 10 种类型的帧,一个字节可以表示最多 256 种,所以也允许在标准之外定义其他类型实现功能扩展。比如 Google 的 gRPC 就利用了这个特点,定义了几种自用的新帧类型。 第 5 个字节是非常重要的帧标志信息,可以保存 8 个标志位,携带简单的控制信息。常用的标志位有 : END_HEADERS 表示头数据结束,相当于 HTTP/1 里头后的空行(“\r\n”), END_STREAM 表示单方向数据发送结束(即 EOS,End of Stream),相当于 HTTP/1 里 Chunked 分块结束标志(“0\r\n\r\n”)。 报文头里最后 4 个字节是流标识符,也就是帧所属的“流”,接收方使用它就可以从乱序的帧里识别出具有相同流 ID 的帧序列,按顺序组装起来就实现了虚拟的“流”。 流标识符虽然有 4 个字节,但最高位被保留不用,所以只有 31 位可以使用,也就是说,流标识符的上限是 2^31,大约是 21 亿。 Wireshark 抓包的帧实例: 查看最后一行红色方框部分,共9个字节是报文的头。 帧长度【前三个字节】:0x00010a,转换为十进制为266,表示数据长度是 266 字节。 帧类型【第四个字节】:0x01,转换为十进制为1,表示数据帧中的 HEADERS 帧,负载(payload)里面存放的是被 HPACK 算法压缩的头部信息。 帧标志信息【第五个字节】:0x25,转换成二进制为00100101,共有三个标志位为1,含义如下: PRIORITY 表示设置了流的优先级, END_HEADERS 表示这一个帧就是完整的头数据, END_STREAM 表示单方向数据发送结束,后续再不会有数据帧(即请求报文完毕,不会再有 DATA 帧 /Body 数据)。 流标识符【最后四个字节】:0x00000001,表示这是客户端发起的第一个流,后面的响应数据帧也会是这个 ID,也就是说在 stream[1]里完成这个请求响应。 0.2.4. 流与多路复用 流与多路复用是 HTTP/2 最核心的部分,流是二进制帧的双向传输序列,理解流的关键是要理解帧头里的流 ID。 在 HTTP/2 连接上,虽然帧是乱序收发的,但只要它们都拥有相同的流 ID,就都属于一个流,而且在这个流里帧不是无序的,而是有着严格的先后顺序。 在概念上,一个 HTTP/2 的流就等同于一个 HTTP/1 里的“请求——应答”。在 HTTP/1 里一个“请求——响应”报文来回是一次 HTTP 通信,在 HTTP/2 里一个流也承载了相同的功能。 对照 TCP 的概念,TCP 运行在 IP 之上,其实从 MAC 层、IP 层的角度来看,TCP 的“连接”概念也是“虚拟”的。 但从功能上看,无论是 HTTP/2 的流,还是 TCP 的连接,都是实际存在的,所以不必纠结于流的“虚拟”性,把它当做是一个真实存在的实体来理解就好。 HTTP/2 流的特点: 流是并发的,一个 HTTP/2 连接上可以同时发出多个流传输数据,也就是并发多请求,实现“多路复用”; 客户端和服务器都可以创建流,双方互不干扰; 流是双向的,一个流里面客户端和服务器都可以发送或接收数据帧,也就是一个“请求——应答”来回; 流之间没有固定关系,彼此独立,但流内部的帧是有严格顺序的; 流可以设置优先级,让服务器优先处理,比如先传 HTML/CSS,后传图片,优化用户体验; 流 ID 不能重用,只能顺序递增,客户端发起的 ID 是奇数,服务器端发起的 ID 是偶数; 在流上发送“RST_STREAM”帧可以随时终止流,取消接收或发送; 第 0 号流比较特殊,不能关闭,也不能发送数据帧,只能发送控制帧,用于流量控制。 如下图显示了连接中无序的帧是如何依据流 ID 重组成流的。 从这些特性中,还可以推理出一些深层次的知识点。 比如,HTTP/2 在一个连接上使用多个流收发数据,那么它本身默认是长连接,所以永远不需要“Connection”头字段(keepalive 或 close)。 比如,下载大文件的时候想取消接收,在 HTTP/1 里只能断开 TCP 连接重新“三次握手”,成本很高,而在 HTTP/2 里就可以简单地发送一个“RST_STREAM”中断流,而长连接会继续保持。 比如,客户端和服务器两端都可以创建流,而流 ID 有奇数偶数和上限的区分,所以大多数的流 ID 都会是奇数,而且客户端在一个连接里最多只能发出 2^30,也就是 10 亿个请求。所以 ID 用完了可以再发一个控制帧“GOAWAY”,真正关闭 TCP 连接。 0.2.5. 流状态转换 为了更好地描述运行机制,HTTP/2 借鉴了 TCP,根据帧的标志位实现流状态转换。当然,这些状态也是虚拟的,只是为了辅助理解。 HTTP/2 的流也有一个状态转换图,比 TCP 要简单一点,如下图,对应到一个标准的 HTTP“请求——应答”。 最开始流都是“空闲”(idle)状态,也就是“不存在”,可以理解成是待分配的“号段资源”。 当客户端发送 HEADERS 帧后,有了流 ID,流就进入了“打开”状态,两端都可以收发数据, 然后客户端发送一个带“END_STREAM”标志位的帧,流就进入了“半关闭”状态。这个“半关闭”状态很重要,意味着客户端的请求数据已经发送完了,需要接受响应数据,而服务器端也知道请求数据接收完毕,之后就要内部处理,再发送响应数据。 响应数据发完了之后,也要带上“END_STREAM”标志位,表示数据发送完毕,这样流两端就都进入了“关闭”状态,流就结束了。 流 ID 不能重用,所以流的生命周期就是 HTTP/1 里的一次完整的“请求——应答”,流关闭就是一次通信结束。 下一次再发请求就要开一个新流(而不是新连接),流 ID 不断增加,直到到达上限,发送“GOAWAY”帧开一个新的 TCP 连接,流 ID 就又可以重头计数。 这张图和 HTTP/1 里的标准“请求——应答”过程很像,只不过这是发生在虚拟的“流”上,而不是实际的 TCP 连接,又因为流可以并发,所以 HTTP/2 就可以实现无阻塞的多路复用。 服务器端发起推送流需要使用PUSH_PROMISE帧,状态转换与客户端基本类似,只是方向不同。 在RST_STREAM和GOAWAY帧里面可以携带32为的错误代码,表示终止流的原因,它是真正的错误,与状态码的含义是不同的。 0.3. HTTP/3 HTTP/2 做出许多努力,比如头部压缩、二进制分帧、虚拟的“流”与多路复用,性能方面比 HTTP/1 有了很大的提升,“基本上”解决了“队头阻塞”这个问题。 0.3.1. HTTP/2的队头阻塞 HTTP/2 虽然使用“帧”“流”“多路复用”,没有了“队头阻塞”,但这些手段都是在应用层里,而在下层,也就是 TCP 协议里,还是会发生“队头阻塞”。 在 HTTP/2 把多个“请求——响应”分解成流,交给 TCP 后,TCP 会再拆成更小的包依次发送(在 TCP 里应该叫 segment,也就是“段”)。 在网络良好的情况下,包可以很快送达目的地。但如果网络质量比较差,像手机上网的时候,就有可能会丢包。而 TCP 为了保证可靠传输,有个特别的“丢包重传”机制,丢失的包必须要等待重新传输确认,其他的包即使已经收到了,也只能放在缓冲区里,上层的应用拿不出来,只能“干着急”。 例如,客户端用 TCP 发送了三个包,但服务器所在的操作系统只收到了后两个包,第一个包丢了。那么内核里的 TCP 协议栈就只能把已经收到的包暂存起来,“停下”等着客户端重传那个丢失的包,这样就又出现了“队头阻塞”。这种“队头阻塞”是 TCP 协议固有的,所以 HTTP/2 即使设计出再多的“花样”也无法解决。 Google 在推 SPDY 的时候就已经意识到了这个问题,于是就又发明了一个新的“QUIC”协议,让 HTTP 跑在 QUIC 上而不是 TCP 上。 “HTTP over QUIC”就是 HTTP 协议的下一个大版本,HTTP/3。它在 HTTP/2 的基础上又实现了质的飞跃,真正“完美”地解决了“队头阻塞”问题。 不过 HTTP/3 目前还处于草案阶段,正式发布前可能会有变动,HTTP/3 的协议栈如下图。 0.3.2. QUIC协议 上图中 HTTP/3 有一个关键的改变,把下层的 TCP 换成了 UDP。因为 UDP 是无序的,包之间没有依赖关系,从根本上解决了“队头阻塞”。 UDP 是一个简单、不可靠的传输协议,只是对 IP 协议的一层很薄的包装,和 TCP 相比,它实际应用的较少。 正是因为它简单,不需要建连和断连,通信成本低,也就非常灵活、高效,“可塑性”很强。所以,QUIC 就选定了 UDP,在它之上把 TCP 的那一套连接管理、拥塞窗口、流量控制等“搬”了过来,打造出了一个全新的可靠传输协议,可以认为是“新时代的 TCP”。 QUIC 最早是由 Google 发明的,被称为 gQUIC,它混合了 UDP、TLS、HTTP,是一个应用层的协议。 当前正在由 IETF 标准化的 QUIC 被称为 iQUIC,它对 gQUIC 做了“清理”,把应用部分分离出来,形成了 HTTP/3,原来的 UDP 部分“下放”到了传输层,所以 iQUIC 也叫“QUIC-transport” 两者的差异非常大,甚至比当年的 SPDY 与 HTTP/2 的差异还要大。 0.3.3. QUIC协议特点 下面的 QUIC 都是指 iQUIC,它与早期的 gQUIC 不同,是一个传输层的协议,和 TCP 是平级的。 QUIC虽然是个传输层协议,但它并不由操作系统内核实现,而是运行在用户空间,所以能够不受操作系统的限制,快速迭代演化,有点像Intel的DPDK。 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 的帧发送,省掉了一次开销。 0.3.4. 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 开始计数。 0.3.5. 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。 0.3.6. HTTP/3 服务发现 HTTP/3 没有指定默认的端口号,也就是说不一定非要在 UDP 的 80 或者 443 上提供 HTTP/3 服务。 HTTP/3 用 HTTP/2 里的“扩展帧”进服务发现。 浏览器需要先用 HTTP/2 协议连接服务器, 然后服务器可以在启动 HTTP/2 连接后发送一个“Alt-Svc”帧,包含一个“h3=host:port”的字符串,告诉浏览器在另一个端点上提供等价的 HTTP/3 服务。 浏览器收到“Alt-Svc”帧,会使用 QUIC 异步连接指定的端口,如果连接成功,就会断开 HTTP/2 连接,改用新的 HTTP/3 收发数据。

HTTP-Protocol 阅读更多

0.1. HTTP/1.1 首部字段 0.1.1. 通用首部字段 0.1.2. 实体首部字段 0.2. HTTP请求 0.2.1. 请求方法 0.2.1.1. 浏览器对请求方法的支持 0.2.2. 请求首部 0.3. HTTP响应 0.3.1. 响应状态码 0.3.2. 响应首部 0.4. URI 0.1. HTTP/1.1 首部字段 HTTP首部字段类型: 通用首部字段(General Header Fields) 请求首部字段(Request Header Fields) 响应首部字段(Response Header Fields) 实体首部字段(Entity Header Fields) 0.1.1. 通用首部字段 首部字段 作用描述 Cache-Control 控制缓存行为 Connection 逐跳首部、连接的管理 Date 创建报文的日期时间 Pragma 报文指令 Trailer 报文末端的首部一览 Transfer-Encoding 指定报文主体的传输编码方式 Upgrade 升级为其他协议 Via 代理服务器的相关信息 Warning 错误通知 0.1.2. 实体首部字段 首部字段 作用描述 Allow 资源可支持的HTTP方法 Content-Encoding 实体主体适用的编码方式 Content-Language 实体主体的自然语言 Content-Length 实体主体的大小(单位:字节) Content-Location 替代对应资源的URI Content-MD5 实体主体的报文摘要 Content-Range 实体主体的位置范围 Content-Type 实体主体的媒体类型 Expires 实体主体的过期时间 Last-Modified 资源的最后修改时间 0.2. HTTP请求 HTTP是一种请求-相应协议,协议涉及的所有事情都是以一个请求开始。HTTP请求跟其他所有HTTP报文一样,都是由一系列文本行组成,这些文本行会按照以下顺序进行排列: 请求行(request-line) 零个或任意多个请求首部(header) 一个空行 可选的报文主体(body) 一个典型的HTTP请求,如下所示: GET /Protocols/rfc2616/rfc2616.html HTTP/1.1 // 请求行 Host: www.w3.org // 请求首部 User-Agent: Mozilla/5.0 // 请求首部 (empty line) // 空行,必须存在 // 可选报文主体部分为空 请求行: 第一个单词是请求方法(request method) 之后是统一资源标识符(Uniform Resource Identifier,URI) 以及所使用的HTTP版本 可选的报文主体:报文是否包含主体需要根据请求使用的方法而定 0.2.1. 请求方法 请求方法是请求行中的第一个单词,指明了客户端想要对资源执行的操作。 HTTP0.9:只有GET一个方法 HTTP1.0:添加了POST和HEAD方法 HTTP1.1:添加了PUT、DELETE、OPTIONS、TRACE和CONNECT,允许开发着自行添加更多方法 HTTP1.1要去必须实现的只有GET和HEAD方法,其他方法的实现是可选的。 各个HTTP方法的作用说明: 方法 描述 说明 是否安全 是否幂等 GET 命令服务器返回指定的资源 是 HEAD 与GET方法类似,唯一的不同在于这个方法不要求服务器返回报文的主体 通常用于在不获取报文主体的情况下,获取相应的首部 是 POST 命令服务器将报文主体中的数据传递给URI指定的资源,至于服务器具体会对这些数据执行什么动作取决于服务器本身 不是 PUT 命令服务器将报文主体中的数据设置为URI指定的资源 如果URI指定的位置上已经有数据存在,那么使用报文主体的数据去替代已有的数据,如果资源尚未存在,那么在URI指定的位置上新创建一个资源 不是 幂等 DELETE 命令服务器删除URI指定的资源 不是 幂等 TRACE 命令服务器返回请求本身 通过这个方法,客户端可以知道介于它和服务器之间的其他服务器是如何处理请求的 是 OPTIONS 命令服务器返回它支持的HTTP方法列表 是 CONNECT 命令服务器与客户端建立一个网络连接,这个方法通常用于设置SSL隧道已开启HTTPS功能 PATCH 命令服务器使用报文主体中的数据对URI指定的资源进行修改 安全的方法:如果一个HTTP方法只要求服务器提供信息而不会对服务器的状态做任何修改,那么这个方法是安全的。 幂等的方法:如果一个HTTP方法在使用相同的数据进行第二次调用的时候,不会对服务器的状态造成任何改变,那么这个方法是幂等的。 0.2.1.1. 浏览器对请求方法的支持 GET方法是最基本的HTTP方法,它负责从服务器获取内容,所有浏览器都支持这个方法。 POST方法从HTML2.0开始可以通过HTML表单来实现:HTML的form标签有一个名为method的属性,用户可以通过将这个属性的值设置为get或者post来指定要使用那种方法。 HTML不支持除了GET和POST之外的其他HTTP方法。 主流的浏览器通常都不会只支持HTML一种数据格式,用户可以使用XMLHttpREquest(XHR)来获得对PUT方法和DELETE方法的支持。 XHR是一系列浏览器API,这些API同化成那个有JS包裹(实际上XHR就是一个名为XML XMLHttpRequest的浏览器对象),XHR允许程序员向服务器发送HTTP请求,这项技术不仅仅局限于XML格式,包括JSON以及纯文本在内的任何格式的请求和相应都可以通过XHR发送。 0.2.2. 请求首部 HTTP请求方法定义了发送请求的客户端想要执行的动作,而HTTP请求的首部则记录了与请求本身以及客户端有关的信息。请求的首部由任意多个用冒号分隔的纯文本键值对组成,最后以回车(CR)和换行(LF)结尾。 大多数HTTP请求首部都是可选的,Host首部字段是HTTP1.1唯一强制要求的首部。根据请求使用的方法不同,如果请求的报文中包含有可选的主体,那么请求的首部还需要带有内容长度(Content-Length)字段或者传输编码(Transfer-Encoding)字段。 常见请求首部如下: 首部字段 作用描述 说明 Accept 客户端在HTTP响应中能够接收的内容类型 比如,客户端可以通过Accept:text/html这个首部,告知服务器自己希望在相应的主体中收到HTML类型的内容 Accept-Charset 客户端要求服务器使用的字符集编码 比如,客户端可以通过Accept-Charset:utf-8这个首部来告知服务器自己希望响应的主体使用UTF-8字符集 Accept-Encoding 优先的内容编码 Accept-Language 优先的语言(自然语言) Authorization 这个首部用于向服务器发送基本的身份验证证书 Cookie 客户端应该在这个首部中把服务器之前设置的所以Cookie回传给服务器 比如,如果服务器之前在浏览器上设置了3个Cookie,那么Cookie首部字段将在一个字符串里面包含这3个Cookie,并使用分号对这些Cookie进行分隔,如Cookie:my_first_cookie=hello;my_second_cookie=world Content-Length 请求主体的字节长度 Content-Type 当请求包含主体的时候,这个首部用于记录主体内容的类型 在发送POST或PUT请求时,内容的类型默认为x-www-form-urlen-coded,在上传文件时,内容的类型应该设置为multipart/form-data(上传文件这一操作可以通过将input标签的类型设置为file来实现) Expect 期待服务器的特定行为 From 用户的电子邮箱地址 Host 服务器的名字以及端口号 如果这个首部没有记录服务器的端口号,就表示服务器使用的是80端口 if-Match 比较实体标记(ETag) if-None-Match 比较实体标记(与if-Match相反) if-Modified-Since 比较资源的更新时间 if-Unmodified-Since 比较资源的更新时间(与if-Modified-Since相反) if-Range 资源未更新时发送实体Bytes的范围请求 Max-Forwards 最大传输逐跳数 Proxy-Authorization 代理服务器要求客户端的认证信息 Range 实体的字节范围请求 Referrer 发起请求的页面所在地址 TE 传输编码的优先级 User-Agent 对发起请求的客户端进行描述 0.3. HTTP响应 HTTP响应报文是对HTTP请求报文的回复。跟HTTP请求一样,HTTP相应也是有一系列文本行组成的,其中包括: 一个状态行 零个或任意数量的响应首部 一个空行 一个可选的报文主体 HTTP响应,如下: 200 OK // 状态行,包含状态码和响应的原因短语(对状态码进行简单的描述) Date: Sat, 22 Nov 2014 12:58:58 GMT Server: Apache/2 Last-Modified: Thu, 28 Aug 2014 21:01:33 GMT Content-Length: 33115 Content-Type: text/html; charset=iso-8859-1 // HTML格式的报文主体 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/ TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns='http://www.w3.org/1999/ xhtml'> <head><title>Hypertext Transfer Protocol -- HTTP/1.1</title></ head><body>...</body></html> 0.3.1. 响应状态码 HTTP响应中的状态码表明了响应的类型,HTTP响应状态码共有5种类型,分别以不同的数字作为前缀。 状态码类型 作用描述 说明 1XX 情报状态码 服务器通过这些状态码来告知客户端,自己已经接收到了客户端发送的请求,并且已经对请求进行了处理 2XX 成功状态码 这些状态码说明服务器已经接收到了客户端的请求,并且成功地对请求进行了处理,这类状态码的标准响应为“200 OK” 3XX 重定向状态码 这些状态码表示服务器已经收到了客户端发送的请求,并且已经成功处理了请求,但是为了完成请求指定的动作,客户端还需要再做一些其他的工作,这里状态码大多用于实现URL重定向。 4XX 客户端错误状态码 这类状态码说明客户端发送的请求出现了某些问题。在这一类型的状态码中,最常见的就是“404 Not Found”,这个状态码表示服务器无法从请求指定的URL中找到客户端想要的资源 5XX 服务器错误状态码 当服务器因为某些原因而无法正确地处理请求时,服务器就会使用这类状态码来通知客户端,这一类型的状态码中,最常见的就是“500 Internal Server Error”状态码。 0.3.2. 响应首部 响应首部跟请求首部一样,是由冒号分隔的纯文本键值对组成,并且同时以回车(CR)和换行(LF)结尾。 请求首部能够告诉服务器更多与请求相关或者与客户端诉求相关的信息 响应首部能够向客户端传达更多响应相关或者与服务器诉求相关的信息 常见响应首部如下: 首部字段 作用描述 Accept-Ranges 是否接收字节范围请求 Age 推算资源创建经过时间 Allow 告知客户端,服务器支持那些请求方法 Content-Length 响应主体的字节长度 Content-Type 如果响应包含可选的主体,那么这个首部记录的就是主体的内容的类型 Date 以格林尼治标准时间(GMT)格式记录的当前时间 ETag 资源的匹配信息 Location 这个首部仅在重定向的时候使用,它会告诉客户端接下来应该向哪个URL发送请求 Proxy-Authenticate 代理服务器对客户端的认证信息 Retry-After 对再次发起请求的时机要求 Server 响应的服务器的域名 Set-Cookie 在客户端里设置一个Cookie,一个响应里面可以包含多个Set-Cookie首部 Vary 代理服务器缓存的管理信息 WWW-Authenticate 服务器通过这个首部来告诉客户端,在Authorization请求首部中应该提供哪种类型的身份验证信息。服务器异常会把这个首部与“401 Unauthorized”状态行一同发送。除此之外,这个首部还会向服务器许可的认证授权模式(scheme)提供验证信息(challenge information) 0.4. URI 一种使用字符串表示资源名的方法:统一资源名称(Uniform Resource Name,URN) 一种使用字符串表示资源所在为止的方法:统一资源定位符(Uniform Resource Location,URL) URI是一个涵盖性术语,它包含了URN和URL,并且这两者也拥有相似的语法和格式。 URI一般格式为:<方案名称>:<分层部分>[?<查询参数>][#<片段>] URI的方案名称(scheme name)记录了URI正在使用的方案,它定义了URI其余部分的结构。因为URI是一种非常常用的资源标识方式,所以它拥有大量的方案可供使用,大部分时候使用HTTP方案。 URI的分层部分(hierarchical part)包含了资源的识别信息,这些信息以分层的方式进行组织。如果分层部分以双斜杠(//)开头,那么说明它包含了可选的用户信息,这些信息将以@符合结尾,后跟分层路径。不带用户信息的分层部分就是一个单纯的路径,每个路径都由一连串的分段(segment)组成,各个分段之间使用单斜线(/)分隔。 在URI的各个部分当中,只有“方案名称”和“分层部分”是必须的。以问号(?)为前缀的查询参数(query)是可选的,这些参数用于包含无法使用分层方式表示的其他信息。多个查询参数会被组织成一连串的键值对,,各个键值对之间使用&符号分隔。 URI的另一个可选部分为片段(fragment),片段使用井号(#)作为前缀,它可以对URI定义的资源中的次级资源进行表示(secondary resource)进行标识。当URI包含参数时,URI的片段将被放在查询参数之后。因为URI的片段是有客户端负责处理的,所以Web浏览器在将URI发送给服务器之前,一般都会先把URI中的片段移除。(如果想要取得URI片段,那么可以通过JS或某个HTTP客户端库,将URI片段包含在一个GET请求里面)。 使用HTTP方案的URI示例:http://sausheong:password@www.example.com/docs/file?name=sausheong&location=singapore#summary 这个URI使用的是HTTP方案 跟在方案之后的是一个冒号,位于@符号之前的分段sausheong:password 跟在用户信息之后的是www.example.com/docs/file是分层部分的其余部分,位于分层部分最高层的是服务器的域名wwwexample.com,之后跟着的两个层分别为doc和file。每个分层用单斜杠分隔。 跟在分层部分之后是以问号(?)为前缀的查询参数,这个部分包含了name=sausheong和location=singapore这两个键值对,键值对之间使用&符号连接 最后,这个URI的末尾还带有一个以井号(#)前缀的片段 因为每个URI都是一个单独的字符串,所以URI里面是不能够包含空格的,此外,因为问号(?)和井号(#)等符号在URL中具有特殊的含义,所以这些符号是不能够用于其他用途的,未来避开这些限制,需要使用URI编码(百分号编码)来对特殊符号进行转换。 所有保留字符需要进行百分号编码,把保留字符转换成该字符在ASCII编码中对应的字节值(byte value),接着把这个字节值表示为一个两位长的十六进制数组,最后再在这个十六进制数字的前面加上一个百分号(%)。 例如,空格在ASCII编码中的字节值为32,即十六进制中的20。因此,经过百分号编码处理的空格就成了%20,URI中所有空格都会被替换成这个值,如下所示:http://www.example.com/docs/file? name=sau%20sheong&location=singapore。