大家好,我是「云舒编程」,今天我们来聊聊计算机网络面试之-(应用层HTTP)工作原理。
文章首发于微信公众号:云舒编程
想必不少同学在面试过程中,会遇到「在浏览器中输入www.baidu.com后,到网页显示,其间发生了什么」类似的面试题。 本专栏将从该背景出发,详细介绍数据包从HTTP层->TCP层->IP层->网卡->互联网->目的地服务器 这中间涉及的知识。 本系列文章将采用自底向上的形式讲解每层的工作原理和数据在该层的处理方式。
HTTP 全称是HyperText Transfer Protocol,也叫超文本传输协议。 HTTP 于 1991 年提出的,主要用于学术交流,当时的目的也很简单,就是用来在网络之间传递 HTML文本的内容,所以被称为超文本传输协议。 一个简单的HTTP请求流程如下:
在互联网早期的时候,HTTP传输的数据只是简单的字符文字,但是现在HTTP协议经过长时间的发展已经支持了图片、视频、音频等传输,所以【文本】的涵义已经不仅仅是指文字字符了。
HTTP协议由以下四部分构成:
第一部分对应请求行,请求行又由三部分组成:
第二部分对应请求头:请求头由多个k:v结构组成 第三部分是空白行: 第四部分是请求体: 请求体可以接受form表单、json、xml、字符串等类型的参数,具体取决于Content-Type的设置。
第一部分对应响应行,响应行又由三部分组成:
Version:表示报文使用的HTTP协议版本; Status Code:一个三位数,用代码的形式表示处理的结果,比如200是成功,500是服务器错误; Reason:作为数字状态码补充,是更详细的解释文字,帮助人理解原因。 第二部分对应响应头:请求头由多个k:v结构组成 第三部分是空白行: 第四部分是响应体: 响应体可以接受form表单、json、xml、字符串等类型的结果,具体取决于Content-Type的设置。
在前文【什么是超文本传输协议】我们有提到HTTP最开始设计时,只是为了传输简单的字符文本,随着互联网的发展,HTTP也经过了几次优化设计,满足人们在数据类型传输、安全、性能等多方面的需求。 接下来我们会逐步讲解,HTTP的几次重大优化设计:
HTTP/0.9 是最开始的HTTP协议,就如前面说的,只支持简单的字符文本传输,安全,多样的数据、性能都没有做考虑。 并且他的请求/响应也不是我们前面提到的【HTTP协议格式构成】部分标准组成。而是如下:
GET /mypage.html
<html>
这是一个非常简单的 HTML 页面
</html>
只支持简单的GET 请求,响应结果也只包含文档本身。
1994 年底出现了拨号上网服务以及网景推出新浏览器后,人们开始对HTTP提出了更多的需求。
定义了前文提到的【HTTP协议格式构成】,后续的HTTP请求都必须按照标准格式请求/响应。 带来如下好处:
得益于HTTP标准协议格式的提出,HTTP/1.0 可以通过请求头和响应头来进行协商,在发起请求时候会通过 HTTP 请求头告诉服务器它期待服务器返回: 1、什么类型的文件; 2、采取什么形式的压缩; 3、提供什么语言的文件以及文件的具体编码。最终发送出来的请求头内容如下:
accept: text/html //期望服务器返回 html 类型的文件
accept-encoding: gzip, deflate, br //期望服务器可以采用 gzip、deflate 或者 br 其中的一种压缩方式
accept-Charset: ISO-8859-1,utf-8 //期望返回的文件编码是 UTF-8 或者 ISO-8859-1
accept-language: zh-CN,zh //期望页面的优先语言是中文
服务器接收到浏览器发送过来的请求头信息之后,会根据请求头的信息来准备响应数据。不过有时候会有一些意外情况发生,比如浏览器请求的压缩类型是 gzip,但是服务器不支持 gzip,只支持 br 压缩,那么它会通过响应头中的 content-encoding 字段告诉浏览器最终的压缩类型,也就是说最终浏览器需要根据响应头的信息来处理数据。下面是一段响应头的数据信息:
content-encoding: br //服务器采用了 br 的压缩方法
content-type: text/html; charset=UTF-8 //服务器返回的是 html 文件,并且该文件的编码类型是 UTF-8。
对于重复性的请求,HTTP会缓存结果,这样当一样的请求发起时直接从本地获取缓存,不需要请求服务端,节省了资源也提高了性能。 HTTP的缓存是通过在HTTP请求头/响应头增加字段实现的,具体又分为两种: 1、强制缓存 强缓存指的是只要浏览器判断缓存没有过期,则直接使用浏览器的本地缓存,主动权在浏览器这边。 类似这样的请求就是使用了强制缓存。
强制缓存由由响应请求设置的,通过:
两个头部字段控制。 流程如下:
2、协商缓存 协商缓存就是强制缓存过期后,浏览器继续请求服务器使用缓存的机制,主要分为以下两种情况:
协商缓存主要有两种实现方式:
其中Etag/If-None-Match优先级比Last-Modified/If-Modified-Since高。
Last-Modified是资源文件在服务器最后被修改的时间。 客户端请求服务端时会设置如下请求头:
If-Modified-Since:Last-Modified
//Last-Modified是客户端第一次请求服务端时,服务端返回的
服务器收到该请求,发现请求头含有If-Modified-Since字段,则会根据If-Modified-Since的字段值与该资源在服务器的最后被修改时间做对比,
Etag是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成)。 客户端请求服务端时会设置如下请求头:
If-None-Match:Etag
//Etag是客户端第一次请求服务端时,服务端返回的
服务端收到该请求后,发现该请求含有If-None-Match,则会根据If-None-Match的字段值与该资源在服务器的Etag值做对比,
Etag/If-None-Match优先级比Last-Modified/If-Modified-Since高的原因就是Last-Modified/If-Modified-Since依赖于时间,但是客户端和服务端的时间不一定一致,并且在分布式场景中,服务端各个机器的时间也不一定一致。
HTTP/1.0 每进行一次 HTTP 通信,都需要经历建立 TCP 连接、传输 HTTP 数据和断开 TCP 连接三个阶段。互联网发展到如今,一个页面的渲染会发起十几个HTTP请求,如果每个请求都经历三次握手四次挥手,那会增加很多无关的开销。 为了解决这个问题,HTTP/1.1 中增加了持久连接的方法,它的特点是在一个 TCP 连接上可以传输多个 HTTP 请求,只要浏览器或者服务器没有明确断开连接,那么该 TCP 连接会一直保持。
从上图可以看出,HTTP 的持久连接可以有效减少 TCP 建立连接和断开连接的次数,减少了资源的浪费。
Q:如果同一个域名的HTTP请求超过6个会怎么处理?
A: 如果在同一个域名下有超过6个HTTP请求,例如同时有10个请求发生,那么其中4个请求会进入排队等待状态,直至进行中的请求完成。当然,如果当前请求数量少于6,会直接进入下一步,建立TCP连接。
在浏览器中可以通过Connection ID判断HTTP请求是否复用了同一个TCP连接。
HTTP长连接默认是串行的,也就是后面的请求得等前面的请求响应了才能继续请求,这就会导致如果一个请求响应慢,就会拖累后面的请求,也就是著名的队头阻塞。HTTP/1.1 中试图通过管线化技术来解决队头阻塞的问题。 HTTP/1.1 中的管线化:将多个 HTTP 请求整批提交给服务器的技术,虽然可以整批发送请求,不过服务器依然需要根据请求顺序来回复浏览器的请求。 但是管线化技术依旧存在诸多限制,导致其未流行起来:
在设计 HTTP/1.0 时,必须要知道响应数据的大小,浏览器才可以根据设置的数据大小来接收数据。不过随着技术的发展,很多数据都是动态生成的,因此在传输数据之前并不知道最终的数据大小,这就导致了浏览器不知道何时会接收完所有的文件数据。 HTTP/1.1 通过引入Chunk transfer 机制来解决这个问题,服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。
在 HTTP/1.0 中,每个域名绑定了一个唯一的 IP 地址,因此一个服务器只能支持一个域名。但是随着虚拟主机技术的发展,需要实现在一台物理主机上绑定多个虚拟主机,每个虚拟主机都有自己的单独的域名,这些单独的域名都公用同一个 IP 地址。 因此,HTTP/1.1 的请求头中增加了Host 字段,用来表示当前的域名地址,这样服务器就可以根据不同的 Host 值做不同的处理。
HTTP/2 的设计思路是:一个域名只使用一个 TCP 长连接来传输数据,并且数据传输是并行的,请求之间不存在等待的情况,服务器也可以随时返回响应,不需要保证顺序。
HTTP2增加了一个HTTP分帧层,将上层的HTTP请求进行拆分。
HTTP/2 的请求和接收过程如下:
对比HTTP1.1,数据格式变为:
image.png
名称 | 长度 | 描述 |
---|---|---|
Length | 3 字节 | 帧的长度 |
Type | 1 字节 | 帧的类型 |
Flags | 1 字节 | 帧的标识 |
R | 1 字节 | 保留位,不需要设置 |
Stream Identifier | 31 位 | 每个流的唯一ID |
Frame Payload | 不固定 | 存放数据 |
Type字段的取值:
帧类型 | 类型编码 | 用途 |
---|---|---|
DATA | 0x0 | 传递HTTP包体 |
HEADERS | 0x1 | 传递HTTP头部 |
PRIORITY | 0x2 | 指定Stream流的优先级 |
RST_STREAM | 0x3 | 终止Stream流 |
SETTINGS | 0x4 | 修改连接或者Stream流的配置 |
PUSH_PROMISE | 0x5 | 服务端推送资源时描述请求的帧 |
PING | 0x6 | 心跳检测,兼具计算RTT往返时间的功能 |
GOAWAY | 0x7 | 优雅的终止连接或者通知错误 |
WINDOW_UPDATE | 0x8 | 实现流量控制 |
CONTINUATION | 0x9 | 传递较大HTTP头部时的持续帧 |
HTTP数据传输主要依赖两个概念:Stream和Frame。 一条TCP连接上有多个Stream,一个Stream上有多个Frame。 一个HTTP请求与响应对应一个Stream,请求报文和响应报文会被分割成为多个Frame。
例如下图所示:
其中:
❝ TCP 的队头阻塞 ❞
前面有提到,HTTP2同一个域名的请求是跑在一个TCP上的,不同的HTTP请求采用StreamID进行区分。这样可以实现并发。 但是HTTP请求报文被分割成为Frame后,最终还是以TCP报文形式发出的。根据前面每天5分钟玩转计算机网络-(传输层tcp)工作原理 我们知道TCP会保证报文可靠和顺序重组。 按照图中所示,假设在传输过程中5号报文丢失了,即使其余报文已经全部到达了,TCP依旧不会把3,2,6报文提交给HTTP层,就会导致请求1和请求3被请求2阻塞了。 随着丢包率的增加,HTTP/2 的传输效率也会越来越差。有测试数据表明,当系统达到了 2% 的丢包率时,HTTP/1.1 的传输效率反而比 HTTP/2 表现得更好。这是因为HTTP/1.1对于同一个域名会开启6个TCP连接,即使一个请求阻塞,其余的TCP连接还可以继续使用。
❝ TCP 建立连接的延时 ❞
由于TCP建立连接必须经历三次握手,并且有慢启动控制,导致初始请求无法弹射起步。
由于以上问题都是TCP的特性导致的,从HTTP设计已经无法再产生本质的改变,于是HTTP3就把目光放到了UDP。 UDP相比TCP有如下优点:
UDP的缺点也很明显:
UDP的有点部分可以解决HTTP2遇到的困境,但是简单的将TCP替换为UDP肯定也是不行的,毕竟没有人会想自己的请求没有任何保障,能不能达到服务端全靠缘分。 于是Google提出基于UDP设计新的可靠层,去弥补UDP的缺点,这个QUIC,于是整体架构变为:
相比于HTTP2,HTTP3使用了更加简单的帧结构。
HTTP最初设计时数据是明文在网络上传输的,也就是任何人只要拦截了网络,就可以不费吹灰之力获取到HTTP请求/响应内容,从而非法获取信息。 为了解决这个问题,提出了HTTPS 概念,通过加密的形式去保护请求/响应内容,这样即使报文被劫持,也无法获取其中的内容。
HTTP通过引入SSL/TLS层去加解密数据包,如图:
❝ 非对称加密:非对称加密 ❞
SSL/TLS协议的基本思路是采用 非对称加密+对称加密的综合模式。
❝ 第一步:客户端向服务端索要公钥(非对称加密) 第二步:基于非对称加密,生成一个随机秘钥 第三步:基于随机秘钥(对称加密)加密后续通话的报文 ❞
上面是一个粗略的执行过程,具体的执行细节类似TCP三次握手:
实际抓包HTTPS握手过程:
1、ClientHello:TCP连接建立后,Client会发出ClientHello请求,开始进行TLS握手。
2、ServerHello:服务端收到ClientHello请求后,会响应一个ServerHello请求。
可以看到服务端选择的是【TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384】,这是一系列加密算法的组合写法,含义如下:
3、Server Certificate:服务器把自己的证书发给客户端
4、Server Key Exchange:由于服务器选择了 ECDHE 算法,所以它会发送 Server Key Exchange 。
这一步服务器做了很多事情:
随后发送ServeHelloDone代表服务端消息发送结束。
5、Client Key Exchange:由于选择了ECDHE 算法,所以客户端会发送 Client Key Exchange
6、计算Pre-Master : 到目前为止客户端和服务器分别拿到了如下参数: 客户端:
- ClientRandom(客户端随机数)
- ServerRandom(服务端随机数)
- Server 椭圆曲线公钥
服务端:
- ClientRandom(客户端随机数)
- ServerRandom(服务端随机数)
- Client 椭圆曲线公钥
客户端和服务端分别使用自己的椭圆曲线私钥和对方的椭圆曲线公钥根据 ECDHE 算法一阵算,算出一个新的随机数:Pre-Master(ECDHE可以保证客户端和服务端算出来的Pre-Master值是一样的) 然后客户端和服务端在分别根据ClientRandom、ServerRandom、Pre-Master生成最终的对称加密秘钥:Master Secret。 后续的报文就通过之前协商的对称加密算法和Master Secret对报文进行加密。
其实最开始的TLS握手过程没有那么复杂,以前使用的是RSA传统的加密手段,但是由于无法保证前向安全所以逐渐淘汰了 握手过程如下:
可以看出TLS_RSA 跟TLS_ECDHE的主要区别在于Pre-Master的生成和交换过程: TLS_RSA的Pre_Master是客户端随机生成,然后服务器公钥加密,私钥解密。那么只要服务器的私钥泄漏了,那么所以的历史报文就有可能被破解。 TLS_ECDHE的Pre_Master是临时生成一对公钥私钥,然后根据ECDHE计算出来的,即使被破解了也只影响本次通话,不会影响历史报文。
TLS 1.2极大的解决了HTTP的安全问题,不过随着互联网的发展,TLS 1.2慢慢显露出来弊端,主要集中在性能和安全上。
❝ 性能问题 ❞
TLS 1.2握手过程中,需要耗费两次往返消息(2-RTT)才能完成加密前置准备。这可能导致几十毫秒甚至上百毫秒的延迟,这对注重性能的程序是影响比较大的。 TLS 1.3 优化:
相比于TLS 1.2,1.3只需要一次往返消息(1-RTT)就可以完成加密准备: 1、ClientHello:TCP连接建立后,Client会发出ClientHello请求,开始进行TLS握手。 相比于1.2,1.3主要多了以下几个参数。
这里的客户端主要传递几个意图:
2、ServerHello:服务端收到ClientHello请求后,会响应一个ServerHello请求。
这里的服务端主要传递几个意图:
通过这样的形式客户端和服务端只需要两条消息就分别拿到了如下数据: 客户端:
服务端:
接下来就可以计算出Pre-Master和Master Secret。而TLS.1.2需要交换5条消息才能做到。
❝ 安全问题 ❞
从TLS 1.2运行过程我们知道它支持很多加密算法,但是正是这些加密算法爆出了历史上很多安全漏洞:
TLS 1.3 优化: 在TLS 1.3中对支持的加密算法进行精简,只保留了如下几种:
HTTP最初设计时是无状态的,也就是请求之间没有关键性。这样的好处是方便扩展成集群,但是缺点也很明显:对于论坛,电商购物这类网站是需要知道用户是谁的场景,无状态HTTP就无法支持。 为了解决这个问题,HTTP就设计了Cookie,让HTTP有记忆能力。
我们前面说过HTTP头部是可以设置很多KV的数据的。Cookie正是利用了这一点。 在HTTP头部中以key=“cookie”,value=自定义kv(不同的kv使用;分割)的形式存在。如图:
当你第一次通过浏览器访问服务端时,服务端处理完业务逻辑后,为了标记你是谁就会生成一些kv的数据,然后在响应头里设置“Set-Cookie”=kv,如果存在多组kv那么就会有多个“Set-Cookie”=kv。如图:
然后客户端收到响应后就把“Set-Cookie”里的值全部取出来,并且用;分割组成一条记录然后存储在内存或者本地磁盘。等下次请求的时候就会通过在请求头里设置“Cookie”=kv再把数据带回去。 通过这样的形式,就完成了记忆能力。
为了增强Cookie的能力,围绕Cookie可以设置一系列属性。
Expires:用的是绝对时间点,例如上图的:expires=Sat, 10-Aug-2024 09:19:18 GMT (在 2024 年 8 月 10 日 09:19:18之后过期)
Max-Age:优先级更高,用的是相对时间,单位是秒,浏览器用收到报文的时间点再加上 Max-Age,就可以得到失效的绝对时间。
Domain:指定了 Cookie 所属的域名
Path:指定了 Cookie 所属的路径。
浏览器在发送 Cookie 前会从 URI 中提取出 host 和 path 部分,对比 Cookie 的属性。如果不满足条件,就不会在请求头里发送 Cookie。
HttpOnly:该设置会限制Cookie 只能通过浏览器 HTTP 协议传输,禁止其他方式访问。例如document.cookie 等一切相关的 API都将无法操作Cookie,可以避免脚本攻击。
SameSite:
SameSite=Strict可以严格限定 Cookie 不能随着跳转链接跨站发送;
SameSite=Lax允许 GET/HEAD 等安全方法,但禁止 POST 跨站发送;
通过上面两种形式可以防范“跨站请求伪造”(XSRF)攻击。
Secure:表示这个 Cookie 仅能用 HTTPS 协议加密传输,明文的 HTTP 协议会禁止发送。但 Cookie 本身不是加密的,浏览器里还是以明文的形式存在。
客户端如果禁用了Cookie的,那么围绕Cookie设计的功能都将无法正常使用。
为了解决Cookie的缺点,于是推出了Session。
Session依旧使用散列表的形式存储数据,例如Java中的HashMap。可以存储多个kv数据。
与Cookie不同,Session的kv数据是存储在服务端的,而Cookie的数据则是存储在客户端。 服务端会为每一个Session生成一个唯一id(sessionid),然后把该id通过Cookie的形式返回给客户端,如图:
等下次再发起请求时,客户端就会通过Cookie字段将该SessionId带上,服务端就可以根据该sessionId找到对应的kv数据,从而完成记忆能力。
如果客户端禁用了Cookie能力,依旧可以通过重写url的形式将sessionid带上(?sessionid=xxx)
HTTP协议是一种请求 - 应答的通信模式,同时还是一种“被动”通信模式,也就是说服务器只能“被动”响应客户端的请求,无法主动向客户端发送数据。 但是在互联网中,存在很多需要服务端主动向客户端推送数据的场景:即时消息、网络游戏以及飞书文档的协同编辑等。在没有WebSocket之前,只能通过客户端【轮询】的形式去不停地问”服务端是否有数据给我“,在请求量比较少的情况下这么做是没有问题的,但是在高并发的情况下非常容易导致服务端过载。 为了解决该问题,于是设计了WebSocket,即允许客户端主动向服务端推送数据,也允许服务端主动向客户端推送数据。
RFC文档中对WebSocket报文的格式定义如下所示:
1、FIN :消息结束的标志位,表示数据发送完毕。一个消息可以拆成多个帧,接收方看到 FIN 后,就可以把前面的帧拼起来,组成完整的消息。 2、RSV1、2、3 :三位是保留位,目前没有任何意义,但必须是 0。 3、Opcode :表示帧类型: 1:表示帧内容是纯文本 2:表示帧内容是二进制数据 8:是关闭连接 4、MASK :表示帧内容是否使用异或操作(xor)做简单的加密。目前的 WebSocket 标准规定,客户端发送数据必须使用掩码,而服务器发送则必须不使用掩码。 5、Payload len :表示帧内容的长度。 6、Masking-key :掩码密钥,由上面的标志位 MASK 决定的,如果使用掩码就是 4 个字节的随机数,否则就不存在。 7、Payload Data(continued):真正存放数据的地方。
WebSocket并没有从零开始设计,反而是站在HTTP协议的基础上进行设计。WebSocket也需要进行握手后,才能正式收发数据。
WebSocket 的握手是一个标准的 HTTP GET 请求,但要带上两个协议升级的专用头字段:
Connection: Upgrade(表示要求协议升级)
Upgrade: websocket(表示要升级成 WebSocket 协议)
同时还增加了两个额外的认证用头字段:
Sec-WebSocket-Key:一个 Base64 编码的 16 字节随机数,作为简单的认证密钥;
Sec-WebSocket-Version:协议的版本号,当前必须是 13。
最终报文如下:
服务器收到 HTTP 请求报文,根据上面的四个字段,意识到这是一个WebSocket 升级请求,于是采用WebSocket的方式进行处理。 1、构造一个特殊的 101 Switching Protocols 响应报文; 2、生成Sec-WebSocket-Accept:取出请求头里 Sec-WebSocket-Key 对应的值,加上专用的 UUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,然后计算对应的SHA-1摘要。 客户端收到响应报文,就可以用同样的算法,比对值是否相等,如果相等,则握手成功。 最终报文如下:
websocket报文帧