从前端开发看HTTP协议的应用

 

 

 

一、Chrome Developer Network Tab

Cheome Developer作为现在前端开发者最常用的开发调试工具,其具有前端可以涉及到的各方面的强大功能,为我们的开发和定位问题提供了极大地便利。其中Network Tab是相当常用的一个功能板块。通过它的XHR、JS、CSS、Img等子Tab我们可以捕获到所有基于应用层的HTTP/HTTPS协议的网络请求,可以查看到该次请求和响应的所有头信息和内容。

Network Tab 

展示了针对每一个HTTP请求的所有属性,包括:

 

其中Connection ID为传输层TCP协议的连接ID。关于这点会在下一个章节提到。

 

Headers主要展示了此次请求的状态,还有请求和响应的头部信息,头部信息是HTTP交互双方进行作业的依据:

Headers中的大多数Key对于有经验开发者来说并不陌生,不需要在这里介绍了。但还是需要提到两个key:

content-type作为描述交互内容数据MIME格式的key意义相当重大,我们在实际开发中发出请求缺接收到不到任何东西,如果请求其他部分没问题的话,很可能就是因为前后端的content-type不匹配的原因导致的。

referer作为描述请求发起者所属域的key,也是非常有用的。

1.通过它我们可以对网站进行访问量统计;

2.可以对任何资源的访问做域的限制(防盗链),比如说:我引用一个QQ空间的图片URL放到我自己HTTP服务器serve的网页的<img />上,当我访问该页面的时候并没有拿到这个图片,取而代之的是一个访问受限制的站位图片。也就是说QQ空间的服务器在接收到资源请求的时候,是对referer做了检测的,如果非QQ空间的页面发起的请求是无法正常获取到目标图片的。referer本身是个错误的单词,正确写法应该为referrer,译为介绍人,描述了是在哪个域下进行请求资源或者跳转到某个URL的操作。后来为了向下兼容HTTP协议,这个错误的单词一直没有被修改。

需要注意的是:当我们直接从浏览器地址栏访问某资源时,此时referer为空,因为此时并不存在有真正的介绍人,这是一个凭空产生的请求,并不是从其他任何地方链过去的。

 

 

Response展示了服务端响应的内容,Preview是根据Headers中的双方的Content-Type的MIME类型加工后的方便开发者浏览的带格式的数据内容:

Cookie展示了在此次请求中浏览器Headers中所带Cookie,以及HTTP服务器端对浏览器端Cookie的设置:

 

Timing 整个请求从准备发出到结束的生命周期时序:

对于有经验的开发者来,从Headers、Preview与Response、Cookie中能获取到相当有用的信息。对于Timing Tab,它更接近底层,展示了浏览器端发起一个HTTP请求的全过程,按照Chrome官方解释,Timing中各阶段描述如下:

1. Queuing(排队中)

如果一个请求排队,则表明:

1)请求被渲染引擎推迟,因为它被认为比关键资源(如脚本/样式)的优先级低。这经常发生在 images(图像) 上。
2)这个请求被搁置,在等待一个即将被释放的不可用的TCP socket。
3)这个请求被搁置,因为浏览器限制。在HTTP 1协议中,每个源上只能有6个TCP连接,这个问题将在下一面的章节中提到。
4)正在生成磁盘缓存条目(通常非常快)。

2.Stalled/Blocking (停止/阻塞)
发送请求之前等待的时间。它可能因为进入队列的任何原因而被阻塞。这个时间包括代理协商的时间。

3.Proxy Negotiation (代理协商)
与代理服务器连接协商花费的时间

4.DNS Lookup (DNS查找)

执行DNS查找所用的时间。 页面上的每个新域都需要完整的往返(roundtrip)才能进行DNS查找。当本地DNS缓存没有的时候,这个时间可能是有一段长度的,但是比如你一旦在host中设置了DNS,或者第二次访问,由于浏览器的DNS缓存还在,这个时间就为0了。

5.Initial Connection / Connecting (初始连接/连接)
建立连接所需的时间, 包括TCP握手/重试和协商SSL。

6.SSL
完成SSL握手所用的时间,如果是HTTPS的话

7.Request Sent / Sending (请求已发送/正在发送)
发出网络请求所花费的时间。 通常是几分之一毫秒。

8.Waiting (TTFB) (等待)
等待初始响应所花费的时间,也称为`Time To First Byte`(接收到第一个字节所花费的时间)。这个时间除了等待服务器传递响应所花费的时间之外,还捕获到服务器发送数据的延迟时间。这些情况可能会导致高TTFB:1.客户端和服务器之间的网络条件差;2.服务器端程序响应很慢。

9.Content Download / Downloading (内容下载/下载)
接收响应数据所花费的时间。从接收到第一个字节开始,到下载完最后一个字节结束。

通过对请求发出和响应的每个阶段的理解,我们就能分析出当前HTTP请求存在的问题,并据此解决问题。

 

 

二、客户端与服务端通过HTTP协议的交互过程

在HTTP协议RFC2616的描述中,HTTP作为应用层协议,推荐并默认使用TCP/IP作为传输层协议,且其他任何可靠的传输层协议也都可以被HTTP协议采用和使用。也就是说假如UDP是"可靠"的,HTTP也可以走在UDP上面。目前市面上流行的浏览器的HTTP请求普遍遵守这个原则并采用TCP/IP作为传输层协议。

下面是捕获的一个对通过XMLHttpRequest对https://localhost:3000/api/syncsystemstatus发起的HTTPS GET请求:

 

在上个章节中有提到Connection ID是TCP连接的ID, 表明了此次资源的请求是通过哪一个TCP连接完成的。

通常情况下我们使用Fiddler、Charles或者Chome Developer工具只能对HTTP/HTTPS请求抓包,这里我们使用WireShark对更底层的协议连接进行封包抓取,并分析上面所提到的这个连接从建立到结束的整个过程。WireShark抓包截图如下:

 

说明:由于笔者使用Webpack的dev-server给localhost:3000做了正向代理,并开启了HTTPS,由于服务器并未开启HTTPS,所以dev-server到服务器并不是HTTPS而是HTTP1.1,192.168.11.94就是dev-server的IP,可以将其看作localhost:3000,也就是客户端浏览器。192.168.100.101为dev-server正向代理到的目的地,也是请求要发送到的HTTP服务器。简单来讲该例子就是从浏览器(192.168.11.14)通过XMLHttpRequest对象发起了一个到服务器(192.168.100.101)的HTTP1.1请求。

客户端和服务器交互过程如下:

No.x号为WireShark封包列表中最左侧的列,记录每个封包在该次抓取中的编号,并依次递增。

No.1:浏览器(192.168.11.94)向服务器(192.168.100.101)发出连接请求,并发送SYN包,进入SYN_SEND状态,等待服务器确认。这是TCP三次握手的第一次。

 No.2:服务器(192.168.100.101)响应了浏览器(192.168.11.94)的请求,确认浏览器的SYN(ACK=J+1),并且自己也发送SYN包也就是SYN+ACK包,要求浏览器进行确认,此时了服务器进入SYN_RECV状态。这是TCP三次握手的第二次。

No.3:浏览器(192.168.11.94)响应了服务器(192.168.100.101)的SYN+ACK包,向服务器发送确认包ACK(ACK=K+1),此包发送完毕,浏览器和服务器进入ESTABLISHED状态,这是TCP三次握手的第三次,握手完成,TCP连接成功建立。

 

No.4:浏览器(192.168.11.94)发出一个HTTP请求到服务器(192.168.100.101)。

No.5:服务器(192.168.100.101)收到浏览器(192.168.11.94)发出的请求,并确认,然后开始发送数据。

No.6:服务器(192.168.100.101)发送状态响应码200到浏览器(192.168.11.94),表示数据传输成功并且完毕,content-type表明响应的内容文本需要被解析为JSON格式, OK结束。此时我们开发者通过判断XHR的readyState为4以及status为200就可以得到服务器完整的返回数据并应用在前端逻辑或页面展示上了。

对应第一章节中提到的Chrome Developer Network的请求时序图:

1.发起第一个请求并完成连接的建立:No.1No.4 对应时序图中的第5步至第7步。XHR的readyState为0-2,初始化请求、发送请求并建立连接,

2.基于TCP连接的建立,通过HTTP协议进行数据传输:No.5对应时序图中的第8步至第9步,XHR的readyState为3,正在交互中,开始数据。数据传输完毕后,readyState为4,status为200。

对于Fetch对象发起的请求也是如此的,只不过Fetch基于Promise封装,readyState和status可以理解为是内部控制的,来决定resolve和reject的情况。笔者的项目其实是使用Fetch的,只是这里用XMLHttpRequest对象也就是Ajax来说明,容易理解一些。

针对No.1No.3的TCP的三次握手示意图:

SYN:Synchronize Sequence Numbers 同步序列编号。

SYN_SEND:请求连接,当你要访问其它的计算机的服务时首先要发个同步信号给该端口,此时状态为SYN_SENT,如果连接成功了就变为ESTABLISHED。

ACK:Acknowledgement 确认字符。在数据通信中,接收站发给发送站的一种传输类控制字符。表示发来的数据已确认接收无误。在TCP/IP协议中,如果接收方成功的接收到数据,那么会回复一个ACK数据。通常ACK信号有自己固定的格式,长度大小,由接收方回复给发送方。

No.4才是是HTTP的包,这表明HTTP连接是基于TCP连接建立的。

其他标识符比如FIN、PSH、RST等可以参考这里

而断开连接时需要4次挥手,多1次是因为在主动要求断开连接的那一方不知道被动断开连接的那一方是否还有数据没有传输完毕,被动断开连接的那一方需要把回复已接收断开消息和数据已经发送完毕分到2步内进行,无论真实情况下,在接收到主动方要断开连接的消息时,还有没有数据需要发送,这2步都必须分开。主动方会一直等待被动方发送FIN码。其实也可以用3步完成,但是数据完整性就得不到保证了。有兴趣的同学可以自行了解下,这里不做详细解释了。

 

三、HTTP因前序请求阻塞而导致后续请求没法发起的问题

笔者目前开发的这个项目早期底层和服务端没有做缓存优化的时候,从底层Go的接口返回数据给Node.js层,Node.js层再返回给前端界面。在底层接口没优化的时候,一些操作是现场调用脚本,如果脚本执行耗时长,或者因为网络抖动原因导致底层分布式集群各节点之间通信及慢,接口响应速度从几十毫秒、几百毫秒到几秒甚至更长时间不等。前端是基于React.j的SPA应用,每个界面为了数据的准确性,在进入界面后会立即请求数据,并且后台还根据了当前路由维持了一个每15s更新数据的CronJob,定时刷新这个界面的数据。如果暴力的切换路由改变界面可以在短时间内创建大量的HTTP请求。在HTTP1.1下的性能表现极为糟糕,阻塞情况严重。在Chrome等浏览器中,针对同一个域下的HTTP1.1请求同时创建6条TCP连接,每条连接结束以后才能释放出来给对另外一个资源的请求来使用。虽然和HTTP1.0相比,在性能上已有较大提升,但是并没有本质的改变。以本项目为例,如果当瞬间发起满10个请求后,只有前6个请求能够分配6个不同的HTTP连接进行处理,后续4个请求只有等待这6个请求有任何一个释放HTTP连接资源以后,才能继续。也就是说前6个请求中如果最少耗时都在1s,那么后4个请求的最少Pending时间都在1s。而且接口的请求都是同一个域,走同一个API网关,无法通过像类似于请求CDN资源一样,来把资源请求分散到不同的域下。在笔者暴力的操作下,这简直是噩梦:

 

以getsnapshot这个接口为例,在不阻塞的情况下,其大致需要84ms来完成请求:

然而在发生阻塞后:

 

在串行响应的加持下,额...好恐怖。 

 

开启了webpack-dev-server的HTTPS(通过spdy模块启服务)后,浏览器默认启用HTTP/2(HTTP2.0)协议:

 

依旧是暴力操作,浏览器在短时间内发起大量的请求。可以看到在ID为2693483的这个TCP连接上,并发处理了的所有的HTTP资源请求,而且它们的开始时间点并不是依赖上一个请求的,而且可以并行响应,即后面的请求响应不会等待前面请求响应完成。

而对于HTPP1.X的keep-alive带来的优化:在一定时间内,一个域下只要第一次建立HTTP连接成功,也就是3次握手成功,那么后面的HTTP不再新建立TCP连接管道,均使用该次连接建立成功以后的管道。避免了不必要的连接建立的握手过程以及断开连接的挥手过程的耗时。实现了HTTP的持久连接和管道流水线(pipelining)。再加上浏览器对于HTTP1.1协议处理,都会针对同一个域开辟6个TCP连接,一定程度上缓解了浏览器端的资源加载压力。

HTTP1.X虽然解决了HTTP0.X的一些问题,但它在效率上还存在有一些问题:

1. 串行的响应:即便浏览器能够同时在一个连接的HTTP流水线管道里发起多个请求,服务器也能够在这个管道里响应多个请求,但是请求在服务器端依旧是按照顺序给出响应的,也就是说浏览器端接收数据的时候,必须是按照发起时的顺序来接收。而且浏览器对管道流水线的的支持并不是太好,要么不支持,要么默认关闭的,需要手动设置开启。对于请求性能和带宽的利用率提高并未带来实质性变化。对于这一点浏览器提供的单域名6个TCP/HTTP连接的优化还可以一定程度上缓解压力,但是短时间内针对同一域名的请求发起了太多,响应也较慢,阻塞还是注定会发生的。

2. 请求-响应的数量太多的限制:大多数浏览器HTTP1.x对同一个域一个时间段内的请求-响应数量是有限制的,一般为6个,这导致浏览器对网络带宽和服务器资源的利用无法最大化。

既然服务器不能并行响应,那么仅仅在浏览器上能够并行发起请求还有什么意义呢?也就是说HTTP1.X即便有了keep-alive的加持,它不能算作是全双工的协议,只能算半双工的协议。

3.对客户端和服务端性能消耗大:浏览器在HTTP1.X上提供的单域名6个TCP/HTTP连接优化,为了维持这6条连接,会导致在请求两方的机器上都会有额外的性能开销。

请看以下通过HTTP1.1协议发情请求的截图:

瞬间并行发起5个请求,浏览器在HTTP1.1协议下做了最大优化:即很对同一的请求同时开启多个(6个)TCP连接,并在它们上面创建了对应数量的可复用并且是持久化的HTTP连接来处理并发的多个HTTP请求,避免因串行响应而造成的队头阻塞。

当我们间隔一定时间去发起请求,每次都复用的是同一个TCP连接:

这已经是在HTTP1.1协议下,不修改业务逻辑的条件下能达到的最大优化,还想要性能提高,恐怕就必须使用到一些合并资源请求、CDN资源分发等等方法了。

而HTTP2.0协议改变了上述问题1的串行方式,允许多个请求并行和并发。允许在一个HTTP连接内发起多个的请求-响应,并且是多流并行的,却又不依赖建立多个TCP连接。数据通过TCP层进行传输的时候,引入了二进制数据帧、流的概念,抛弃了HTTP1.X的基于文本格式的数据传输方式,因为这种方式必须按照顺序进行请求-响应。转而使用二进制帧来对数据进行归类,在帧的头部注入流的标识符,这样浏览器收到数据之后,通过标识符再将不同流的数据合并在一起,可以并行错乱或者分级优先地发送(比如遇到图片和JS都一起请求的时候,可以给JS资源请求一个较高的有限值,使它被优先处理)。在服务端通过流的标识符进行重新归类和组装。极大地提高了发送效率,实现了并行且非阻塞的多路复用。说直白一点:无论客户端还是服务器端,都可以一边并行发送数据一边并行接收数据。这也就是解决了上面提到的问题1.

对于数据帧内部来说,HTTP1.X的请求-响应首部被放在了HEADERS帧里面,内容被放到了DATA帧里面。

对于问题2解决:HTTP2.0对同一域名下所有请求都是基于流的,就是说在同一域名下,不管在客户端上存在有多少资源的访问,从理论上讲也可以只建立一个HTTP连接的(实际上就是这样的),通过流来区分不同的请求的数据,所以这一个连接就能完成整个页面的资源加载和后期的数据请求,而不用担心并发请求-响应的时候会不会出现数据错乱,笔者认为这是相对于HTTP1.X的本质改变。服务器的开销得以减少,处理能力得到大幅提升。

既然只有一个连接的,那么对资源的开销问题也会得到大大的缓解,也就解决了问题3

但HTTP2.0并不是使传输层TCP变成了并行的连接,TCP传输层本身的因串行传输而带来的阻塞是没解决的,仅仅是在应用层HTTP协议上进行了优化,但也正是这些重要的优化使HTTP2.0成为了全双工的协议,单连接多资源的方式克服了TCP慢启动带来的负面影响,更加有效地利用了TCP连接,使连接性能得到了极大的提升。也充分地利用了TCP协议的带宽来降低HTTP延迟,并且减少了连接的内存占用,单个连接的吞吐量增大,网略阻塞和丢包的恢复速度增快等。

对通过传统方式进行资源请求优化的影响:一旦HTTP2.0启用后,我们可能会根据它的特点去改变一些我们之前对于静态资源的处理,可以减少之前的前端方面在资源请求上的优化工作,特别是资源合并的以减少请求的手段,比如:压缩到一个js文件以减少HTTP请求、精灵图片、CSS合并等,这些完全都可以放开了,这样做不会再有太大的实际意义。

 

四、SPDY的出现

上个段落我们提到了HTTP2.0协议针对HTTP1.X版本的优化,但是HTTP2.0在2015年年中才定稿,在这之前要实现HTTP2.0的一些特性通常使用由Google进行推广的SPDY协议,该协议经历了四个草案,大量的基于HTTP1.X和SSL/TLS上的优化被IETF采用,作为HTTP2.0的重要功能点,可以说SPDY协议是HTTP2.0出现的关键前奏。但其终归是基于HTTP1.X的扩展,除了有类似HTTP2.0的性能提升外,也会有一些HTTP1.X不可克服的问题:比如因队头阻塞而使传输速度受限制。对于资源请求量小的网站性能提升并不明显。在安全性方面,SPDY建立在TLS之上,URL scheme也是https,这点和HTTP2.0相同。随着HTTP2.0的定稿很多浏览器也都开始抛弃了SPDY,改为支持HTTP2.0,包括Chrome。笔者曾经遇到一个有意思的问题,就是本文中前个段落提到的在webpack-dev-server开启了HTTPS,增加了请求并发的性能。但在最新版本的FireFox下访问,webpack-dev-server的进程会报错,导致npm start进程死掉。看报错日志是由底层stream包报上来的一直到spy包,也就是浏览器端和Server端的传输协议不太匹配,导致对在Server端对流的操作失败了。看了webpack-dev-server的源码他是用express起的server服务,如果配置项HTTPS被设置为Ture,就生成一些fake的证书之类的,用spdy起服务,否则直接用普通的http模块起服务。这个脚手架是很久以前的了,它使用的spdy模块的协议草案版本可能和当前最新的FireFox已经不能适配了。但在Chrome中是OK的,可能是自家支持地比较好。找到一个13年老版本的FirFox,进入参数配置界面,关于SPDY部分有以下参数,可供参考:

 

对于HTTP2.0、SPDY、HTTPS在实际实施上,有些方面需要注意:

1. HTTP2.0是可以不基于HTTPS的,也就是可以明文传输,但是在目前几大厂商的浏览器的实现里面,都是基于TLS来支持HTTP2.0,所以要实施HTTP2.0,必须先部署HTTPS。

PS:这里体现出了协议(标准)和具体应用的实现上的差异。就拿HTTP协议本身来说,协议是无状态的,但应用在使用协议的时候,可以在不改变协议的前提下,利用协议预留的增强方式增强协议的功能,比如在客户端请求头引入cookie,在服务端响应头引入session,搭配使用,多个HTTP请求的请求之间就有状态了,突破了协议本身。所以协议与真实应用上的效果,是有差异的。

2. HTTPS不依赖HTTP2.0,可以通过HTTP1.1建立连接,但是后续或切换协议为HTTP2.0。

3. SPDY依赖HTTPS,所以如果使用SPDY模块启用服务,需要HTTPS相关的准备。webpack-dev-sever要启动HTTP2.0支持,也只能通过设置env文件HTTPS=true来通过spdy模块来启动Express服务,否则直接通过http模块启动。

4. 对于不兼容HTTP2.0的浏览器,像Nginx这类服务器,会自动降级为HTTP1.1,以适配浏览器端。

这里不再做SPDY的详细介绍,有兴趣的同学可以搜搜相关资料。

 

五、WebSocket

在实际项目中webscoket协议的出现频率也是很高的,这里顺带说一句。websocket连接的建立也是先进行TCP握手建立TCP连接,再发起HTTP1.x请求进行握手,最后升级(切换)HTTP协议成websocket协议,进而建立websocket连接:

 

而且发起websocket连接的客户端的html静态文件不需要放在HTTP的服务器下面,可以直接和websocket服务器建立连接。也就是说可以直接通过file://协议在浏览器打开一个html文件,在该html内部的<script>脚本里面可以直接请求server端建立websocket连接。

但是按照IETF的websocket协议规范RFC6455,握手请求必须由HTTP协议发出,再进行协议的upgrade,比如:

GET /chat HTTP/1.1

Host: 192.168.12.67:8001

Upgrade: websocket

Connection: Upgrade

Sec-WebSocket-Key: dFhlIXNhbXBsZSBub22jZM==

Origin: http://example.com

Sec-WebSocket-Protocol: chat, superchat

Sec-WebSocket-Version: 13

 

注意:13版本的握手协议和旧版本是有区别的。

 

what?这个html文件没有被HTTP服务器serve,是通过file://协议打开的,居然还能发出HTTP请求而且没有跨域?为什么跨域了呢,因为file://协议访问是没有域的,location.host为空。所以后面连着跟了一个/,导致3斜线连在一起了。

从上面的wireshark抓包结果来看,TCP握手成功以后确实是成功发起了HTTP请求的,再升级(切换)HTTP协议成websocket协议的。这个可能是浏览器对websocket API做了特殊处理吧,浏览器似乎代为发出了到websocket服务器的HTTP跨域请求用于websocket建立前的握手,或者是说websocket服务器在握手阶段是支持HTTP的跨域请求?不管是啥原因,应该都是为了遵守IETF的RFC6455协议规范。

可以看出,websocket协议和HTTP处于平级,都是应用层的协议,并不是websocket是基于HTTP的,或者HTTP是基于websocket的。只是websocket借鉴了HTTP协议的规范用来建立连接,websocket连接一旦通过HTTP协议建立成功后,HTTP协议即被抛弃掉了,后续的数据传输都是通过websocket协议了,它的握手可以被HTTP服务器解释为一个升级请求。因此它们之间有一定交集。并且在传输层上都默认依赖TCP/IP协议。

 

六、HTTP协议与TCP/IP协议之间是什么关系呢

就如同上面提到的一样,在WEB通信中,HTTP协议默认使用TCP/IP协议作为其在底层依赖的传输层协议,当然使用TCP/IP协议并不绝对的,依据协议规范,任何可靠的传输层内协议都可以被使用。如果能保证UDP的"可靠",它也可被作为HTTP协议的传输层依赖。如果TCP/IP被比喻成发动机或者底盘之类的底层模块的话,那么HTTP协议就是基于这些底层模块而构建出来的可以方便使用的具备功能联合的汽车。通过使用HTTP协议进行网络通信的时候,我们不需要再关注底层的协议栈,只需要按照HTTP协议的请求-响应的约定进行通信即可。而我们使用XHR(Ajax)相当于在HTTP协议更上层的API封装,它提供了create, send 以及状态变化回调的各种功能函数,而不再需要自己从头摸索HTTP协议。就相当于汽车驾校一样,我们可以直接学习到一套标准的开车(=.=)流程轻松通过考试,而不需要自己摸索怎么考过关。

 

好了到此结束吧。

HTTP1.0、HTTP1.1、HTTP2.0之间还有很多的区别,每个版本之间的变化也很大,包括header压缩、keep-alive优化、二进制格式、多路复用等。本文只是对在实际项目中遇到的一些应用案例进行介绍,如果对协议本身感兴趣,可以直接阅读协议规范。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章