前言

记录HTTP前端常考考点,关联计算机网络。

1.0 1.x 2.0

HTTP 1.0

任何格式的内容都可以发送,这使得互联网不仅可以传输文字,还能传输图像、视频、二进制等文件。

除了GET命令,还引入了POST命令和HEAD命令。

http请求和回应的格式改变,除了数据部分,每次通信都必须包括头信息(HTTP header),用来描述一些元数据。

只使用 header 中的 If-Modified-Since 和 Expires 作为缓存失效的标准。

不支持断点续传,也就是说,每次都会传送全部的页面和数据。

通常每台计算机只能绑定一个 IP,所以请求消息中的 URL 并没有传递主机名(hostname)

HTTP 1.1

http1.1是目前最为主流的http协议版本,从1999年发布至今,仍是主流的http协议版本。

引入了持久连接( persistent connection),即TCP连接默认不关闭,可以被多个请求复用,不用声明Connection: keep-alive。长连接的连接时长可以通过请求头中的 keep-alive 来设置

引入了管道机制( pipelining),即在同一个TCP连接里,客户端可以同时发送多个 请求,进一步改进了HTTP协议的效率。

HTTP 1.1 中新增加了 E-tag,If-Unmodified-Since, If-Match, If-None-Match 等缓存控制标头来控制缓存失效。

支持断点续传,通过使用请求头中的 Range 来实现。

使用了虚拟网络,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个IP地址。

新增方法:PUT、 PATCH、 OPTIONS、 DELETE。

http1.x版本问题

在传输数据过程中,所有内容都是明文,客户端和服务器端都无法验证对方的身份,无法保证数据的安全性。

HTTP/1.1 版本默认允许复用TCP连接,但是在同一个TCP连接里,所有数据通信是按次序进行的,服务器通常在处理完一个回应后,才会继续去处理下一个,这样子就会造成队头阻塞。

http/1.x 版本支持Keep-alive,用此方案来弥补创建多次连接产生的延迟,但是同样会给服务器带来压力,并且的话,对于单文件被不断请求的服务,Keep-alive会极大影响性能,因为它在文件被请求之后还保持了不必要的连接很长时间。

HTTP 2.0

二进制分帧 这是一次彻底的二进制协议,头信息和数据体都是二进制,并且统称为”帧”:头信息帧和数据帧。

头部压缩 HTTP 1.1版本会出现 User-Agent、Cookie、Accept、Server、Range 等字段可能会占用几百甚至几千字节,而 Body 却经常只有几十字节,所以导致头部偏重。HTTP 2.0 使用 HPACK 算法进行压缩。

多路复用 复用TCP连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,且不用按顺序一一对应,这样子解决了队头阻塞的问题。

服务器推送 允许服务器未经请求,主动向客户端发送资源,即服务器推送。

请求优先级 可以设置数据帧的优先级,让服务端先处理重要资源,优化用户体验。

头部压缩

在HTTP1.1之前的时代,请求体一般有响应的压缩编码过程,通过Content-Encoding头部字段来指定,但你有没有想过头部字段本身的压缩呢?当请求字段非常复杂的时候,尤其对于GET请求,请求报文几乎全是请求头,这个时候还是存在非常大的优化空间的。HTTP2针对头部字段采用了压缩算法HPACK对请求头进行压缩。

HPACK算法是专门为HTTP2.0设计的,它的主要亮点有两个:

  • 首先是在服务器和客户端之间建立哈希表,将用到的字段存放在这张表中,那么在传输的过程中对于之前出现过的值,只需要把索引传给对方即可,对方拿到索引表查即可。这种传索引的方式,可以让请求头的字段得到极大程度的精简和复用。

HTTP2当中废除了起始行的概念,将起始行中的请求方法,URI,状态码转换成了头字段,不过这些字段都有个:前缀,用来和其他请求区分开来。

  • 其次是对于整数和字符串进行哈夫曼编码,哈夫曼编码的原理是先将所有出现的字符建立一张索引表,然后让出现次数多的字符对应的索引尽可能短,传输的时候也是传输这样的索引序列,可以达到非常高的压缩效率。

多路复用

复用TCP连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,且不用按顺序一一对应,这样子解决了队头阻塞的问题。

HTTP队头阻塞

我们之前讨论了HTTP队头阻塞的问题,其根本原因在于HTTP基于请求响应模型,在同一个TCP长连接中,前面的请求没有得到响应,后面的请求就会被阻塞。

后面我们又讨论到并发连接和域名分片的方式解决队头阻塞的问题,但实际上治标不治本,因为只是单纯的增加了TCP连接,分摊风险而已。而且这么做也有弊端,多条TCP连接会竞争有限的带宽,让真正优先级高的请求不能优先处理。

而HTTP2便从HTTP协议本身解决了这个问题。注意,这里并不是TCP阻塞,而是HTTP队头阻塞,两者并不是一回事。TCP的队头阻塞是在数据包层面,单位是数据包,前一个报文没有收到便不会将后面的报文上传给HTTP,而HTTP的队头阻塞是在HTTP请求响应的层面,前一个请求没有处理完,后一个请求就会被阻塞住。两者所在的层次不一样。

二进制分帧

首先HTTP2认为明文传输对机器而言太麻烦了,不方便计算机的解析,因为对于文本而言会有多义性的字符,比如回车换行到底是内容还是分隔符,在内部需要用到状态机去识别,效率比较低。于是HTTP2干脆把报文全部换成二进制,全部传输01串,方便机器解析。

原来Headers+Body的报文格式如今被拆分成一个个二进制的帧,用Headers帧存放头部字段,用Data帧存放请求体数据。分帧之后,服务器看到的不再是一个个完整的HTTP请求报文,而是一堆乱序的二进制帧。这些二进制帧不存在先后关系,因此也就不会排队等待,也就没有了HTTP队头阻塞问题。

通信双方都可以给对方发送二进制帧,这种二进制的双向传输的序列,也叫做流Stream。HTTP2用流来在一个TCP连接上来进行多个数据帧的通信,这就是多路复用的概念。

可能你会有一个疑问,既然是乱序收发,那么如何来处理这些乱序的数据帧呢?

首先要声明的是,所谓的乱序指的是不同ID的Stream是乱序的,但同一个Stream ID的数据帧一定是按顺序传输的。二进制到达后对方会将Stream ID相同的二进制帧组装成完整的请求报文和响应报文。当然,在二进制帧中还有一些其他字段,实现了优先级和流量控制。下一节再介绍。

服务器推送

另外值得一说的是HTTP2的服务器推送Server Push。在HTTP2中,服务器已经不再是被动的接收请求,响应请求,他也能新建Stream来给客户端发送信息,当TCP连接建立之后,比如浏览器请求一个HTML文件,服务器就可以在返回HTML的基础上,将HTML中引用到的一些其他资源文件一起返回给客户端,减少客户端等待。

总结

当然,HTTP/2 新增那么多的特性,是不是 HTTP 的语法要重新学呢?不需要,HTTP/2 完全兼容之前 HTTP 的语法和语义,如请求头、URI、状态码、头部字段都没有改变,完全不用担心。同时,在安全方面,HTTP 也支持 TLS,并且现在主流的浏览器都公开只支持加密的 HTTP/2, 因此你现在能看到的 HTTP/2 也基本上都是跑在 TLS 上面的了。最后放一张分层图给大家参考:

HTTP2缺点

  • TCP 以及 TCP+TLS建立连接的延时,HTTP/2使用TCP协议来传输的,而如果使用HTTPS的话,还需要使用TLS协议进行安全传输,而使用TLS也需要一个握手过程,在传输数据之前,导致我们需要花掉 3~4 个 RTT。
  • TCP的队头阻塞并没有彻底解决。在HTTP/2中,多个请求是跑在一个TCP管道中的。但当HTTP/2出现丢包时,整个 TCP 都要开始等待重传,那么就会阻塞该TCP连接中的所有请求。

HTTP/2 中的二进制帧是如何设计的?

帧结构

每个帧分为帧头和帧体。先是三个字节的帧长度,这个长度表示是帧体的长度。

然后是帧类型,分为数据帧和控制帧。数据帧用来存放HTTP报文,控制帧用来管理流的传输。

接下来的一个字节是帧标志,里面一共有8个标志位,常用的有END_HEADERS表示头数据结束,END_STREAM表示单方向数据发送结束。

后四个字节是Stream ID,也就是流标识符,有了他就能从乱序的二进制帧中选出ID相同的帧,按顺序组装成请求响应报文。

流的状态变化

从前面可以知道,在HTTP2中,所谓的流,其实就是二进制的双向传输的序列,那么在HTTP2请求和响应的过程中,流的状态是如何改变的呢?

HTTP2其实也是借鉴了TCP状态变化的思想,根据帧的标志位来实现具体的状态改变,这里我们以一个普通的请求响应过程为例子来说明。

最开始两者都是空闲状态,当客户端发送Header帧后,开始分配Stream ID,此时客户端的流打开,服务端接收之后服务端的流也打开,两端的流打开后,就可以互相传递数据帧和控制帧了。

当客户端要关闭的时候,向服务端发送END_STREAM帧,进入半关闭状态,这个时候客户端只能接收数据,而不能发送数据。

服务端接收到这个END_STREAM帧之后,也进入半关闭状态,不过此时服务端只能发送不能接收。随后服务端也向客户端发送END_STREAM帧,表示数据发送完毕,双方进入关闭状态。

如果下次要开启新的流,流ID需要自增,直到上限为止,到达上限之后开一个新的TCP连接重头开始计数。由于流ID字段字节长度为4个子节,最高位又被保留,因此范围是0~2的31次方,大约21亿个。

流的特性

刚刚讲到了流的状态变化的过程,这里顺便来总结一下流传输的特性。

  • 并发性。一个HTTP2连接上可以同时发送多个帧,这一点和HTTP1不一样。这是实现多路复用的基础。
  • 自增性。流ID是不可重用的,而会按顺序递增,达到上限后新开TCP连接从头开始。
  • 双向性。客户端和服务端都可以创建流,互不干扰,双方都可以作为发送方和接收方。
  • 可设置优先级。可以设置数据帧的优先级,让服务器优先处理重要资源,优化用户体验。

HTTP3

Google 在推SPDY的时候就已经意识到了这些问题,于是就另起炉灶搞了一个基于 UDP 协议的“QUIC”协议,让HTTP跑在QUIC上而不是TCP上。主要特性如下:

  • 实现了类似TCP的流量控制、传输可靠性的功能。虽然UDP不提供可靠性的传输,但QUIC在UDP的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些TCP中存在的特性
  • 实现了快速握手功能。由于QUIC是基于UDP的,所以QUIC可以实现使用0-RTT或者1-RTT来建立连接,这意味着QUIC可以用最快的速度来发送和接收数据。
  • 集成了TLS加密功能。目前QUIC使用的是TLS1.3,相较于早期版本TLS1.3有更多的优点,其中最重要的一点是减少了握手所花费的RTT个数。
  • 多路复用,彻底解决TCP中队头阻塞的问题。

http报文的组成部分

请求报文

  • 请求行(http方法+页面地址+http协议+版本)
  • 请求头(key+value值)
  • 空行(服务端通过空行来判断下一部分不再是请求头,而当作请求体来解析)
  • 请求体(数据部分)
    响应报文
  • 状态行+响应头+空行+响应体

起始行

对于请求报文来说 起始行类似下面这样

1
GET /home HTTP/1.1

也就是方法加路径加版本
对于响应报文来说,起始行类似于下面这样
1
HTTP/1.1 200 OK

响应报文的起始行也叫状态行 由http版本 状态码和原因三部分组成
值得注意的是 在起始行中,每两个部分之间用空格隔开,最后一个部分后面应该接个换行,严格遵循ABNF语法规范

头部

展示一下请求头和响应头在报文中的位置:


不管是请求头还是响应头,其中的字段是相当多的,而且牵扯到http非常多的特性,这里就不一一列举的,重点看看这些头部字段的格式:

  • 字段名不区分大小写
  • 字段名不允许出现空格
  • 字段名后面必须紧跟着:

空行

用来区分头部和实体

问:如果在头部的中间故意加一个空行会怎么样?
后面的全部被当成实体。

实体

具体的数据,也就是body部分,请求报文对应请求体,响应报文对应响应体

请求方法

http/1.1规定了以下方法

  • GET: 通常用来获取资源
  • HEAD: 获取资源的元信息
  • POST: 提交数据,即上传数据
  • PUT: 修改数据
  • DELETE: 删除资源(几乎用不到)
  • CONNECT: 建立连接隧道,用于代理服务器
  • OPTIONS: 列出可对资源实行的请求方法,用来跨域请求
  • TRACE: 追踪请求-响应的传输路径

GET/POST 区别

首先从直观上是语义的区别,而后又有些具体的差异:

  • 从缓存的角度,GET请求会被浏览器主动缓存下来,留下历史记录,而POST默认不会
  • 从编码的角度,GET请求只能进行url编码,只能接收ASCII符,而POST没有限制。
  • 从参数的角度,GET一般在URL,因此不安全,POST放在请求体中,更适合传输敏感信息。
  • 从幂等性的角度,GET是幂等的,而POST不是。(幂等表示执行同样的操作,结果也是相同的)
  • 从TCP的角度,GET请求会一次性把请求报文发出去,而POST会分为两个TCP数据包,首先发送header部分,如果服务器响应100,然后发body部分。(火狐除外,它的浏览器只发送一个TCP包)

URI

URI, 全称为(Uniform Resource Identifier), 也就是统一资源标识符,它的作用很简单,就是区分互联网上不同的资源。

但是,她并不是我们常说的网址,网址指的是URL,实际上URI包含了URN和URI两个部分,由于URL过于普及,就默认将URI视为URL了。

URI的结构

如下:

  • scheme表示协议名,比如http、https、file等,特点是后面都要跟://
  • user:passwd@ 表示登录主机时的用户信息,不过很不安全,不推荐使用,也不常用。
  • host:port表示主机名和端口。
  • path表示请求路径,标记资源所在位置。
  • query表示查询参数,为key=val这种形式,多个键值对之间用&隔开。
  • fragment表示 URI 所定位的资源内的一个锚点,浏览器可以根据这个锚点跳转到对应的位置。

eg

1
https://www.baidu.com/s?wd=HTTP&rsv_spt=1

这个 URI 中,httpsscheme部分,www.baidu.comhost:port部分(注意,httphttps 的默认端口分别为80、443),/s为path部分,而wd=HTTP&rsv_spt=1就是query部分。

URI编码

URI 只能使用ASCII, ASCII 之外的字符是不支持显示的,而且还有一部分符号是界定符,如果不加以处理就会导致解析出错。
因此,URI 引入了编码机制,将所有非 ASCII 码字符和界定符转为十六进制字节值,然后在前面加个%
如,空格被转义成了%20,三元被转义成了%E4%B8%89%E5%85%83

状态码

RFC规定HTTP的状态码为三位数,被分为五类:

  • 1xx:表示目前是协议处理的中间状态,还需要后续操作。
  • 2xx:表示成功状态。
  • 3xx:重定向状态,资源位置发生变动,需要重新请求。
  • 4xx:请求报文有误
  • 5xx:服务器端发生错误。
    接下来就一一分析状态码:

1xx

101 Switching Protocols 在http升级为websocket的时候,如果服务器同意变更,就会发送状态码101

2xx

200 OK 是见的最多的成功状态码,通常在响应体中放有数据。
204 No Content 含义与200相同,但响应头后没有body数据。
206 Partial Content顾名思义,表示部分内容,他的使用场景是为HTTP分块下载和断点续传,

3xx

301 Moved Permanently永久重定向,对应302 Found 即临时重定向

比如你的网站升级到httos了,以前的站点不用了,应当返回301,这时候浏览器默认做缓存优化,在第二次访问的时候自动访问重定向的地址。
而如果只是暂时不用,那么直接返回302即可,和301不同的是,浏览器不会做缓存优化


304 Not Modified 当协商缓存命中时会返回这个状态码

4xx

400 Bad Request 开发者看到经常一头雾水,只是笼统的提示了一下错误,并不知道哪里出错了。
403 Forbidden 这实际上并不是请求报文出错,而是服务器禁止访问,原因有很多,比如法律禁止,信息敏感…
404 Not Found 资源未找到,表示服务器上面没有这个资源。
405 Method Not Allow 该请求方法服务器不允许
406 Not Acceptable 资源无法满足客户端条件。
408 Request Timeout 服务器等待太久
409 Conflict 多个请求发生了冲突
413 Request Entity Too Large: 请求体的数据过大。
414 Request-URI Too Long: 请求行里的 URI 太大。
429 Too Many Request: 客户端发送的请求过多。
431 Request Header Fields Too Large请求头的字段内容太大。

5xx

500 Internal Server Error: 仅仅告诉你服务器出错了,出了啥错咱也不知道。
501 Not Implemented: 表示客户端请求的功能还不支持。
502 Bad Gateway: 服务器自身是正常的,但访问的时候出错了,啥错误咱也不知道。
503 Service Unavailable: 表示服务器当前很忙,暂时无法响应服务。

特点

HTTP的特点概括如下:

  • 灵活可扩展,主要体现在两个方面。一个是语义上的自由,只规定了基本格式,比如空格分隔单词,换行分隔字段,其他的各个部分都没有严格的语法限制。另一个是传输形式的多样性,不仅仅可以传输文本,还能传输图片、视频等任意数据,非常方便。
  • 可靠传输。HTTP 基于 TCP/IP,因此把这一特性继承了下来。这属于 TCP 的特性,不具体介绍了。
  • 请求-应答。也就是一发一收、有来有回, 当然这个请求方和应答方不单单指客户端和服务器之间,如果某台服务器作为代理来连接后端的服务端,那么这台服务器也会扮演请求方的角色。
  • 无状态。这里的状态是指通信过程的上下文信息,而每次 http 请求都是独立、无关的,默认不需要保留状态信息。

缺点

无状态

对需要长连接的场景来说,需要保存大量的上下文信息,以免传输大量的重复信息,这时候无状态就是缺点
但对于一些应用仅仅只需要获取一些数据来说,不需要保存上下文信息,无状态反而减小了网络开销

明文传输

即协议里面的报文不使用二进制数据,而是使用明文
对于调试来说是一种便利,但是同时也把HTTP报文信息传输给了外界,给攻击者提供了便利。WIFI陷阱就是利用HTTP明文传输的特点,诱导你连上热点,然后疯狂抓取你的流量,从而拿到你的敏感信息。

队头阻塞问题

当http开启长连接的时候,共用一个TCP连接,同一时刻只能处理一个请求,那么当前请求耗时过长的情况下,其他的请求只能处于阻塞状态,也就是著名的队头阻塞问题,下面会有一小节讨论他。

Accept系列字段

对于Accept系列字段分为四个部分,数据格式,压缩方式,支持语言和字符集

数据格式

HTTP支持非常多的数据格式,那么这么多格式的数据一下子到客户端,客户端怎么知道他的格式呢?

介绍一个标准:MIME(Multipurpose Internet Mail Extensions, 多用途互联网邮件扩展)
他首用在电子邮件系统中,让邮件可以发送任意类型的数据,这对于HTTP来说也是通用的。
因此 HTTP从MIME type取了一部分来标记报文的body数据类型,这些类型体现在Content-type这个字段,当然这是针对于发送端而言的。接收端想要接收指定类型的数据,也可以用Accept字段
具体而言,这两个字段的取值可以分为下面几类:

  • 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

压缩方式

当然这些数据都是会进行压缩的,采用什么压缩方式在于发送方的Content-Encoding字段上,同样的,接收什么样的压缩方式体现在接收方的Accept-Encoding上,取值如下:

  • gzip 当今最流行的压缩格式
  • deflate 另外一种著名的压缩格式
  • br 一种专门为HTTP发明的压缩算法。
1
2
3
4
// 发送端
Content-Encoding: gzip
// 接收端
Accept-Encoding: gzip

支持语言

对于发送方而言,还有一个Content-Language字段,在需要实现国际化的方案当中,可以用来指定支持的语言,在接受方对应的字段为Accept-Language。如:

1
2
3
4
// 发送端
Content-Language: zh-CN, zh, en
// 接收端
Accept-Language: zh-CN, zh, en

字符集

最后是一个比较特殊的字段, 在接收端对应为Accept-Charset,指定可以接受的字符集,而在发送端并没有对应的Content-Charset, 而是直接放在了Content-Type中,以charset属性指定。如:

1
2
3
4
// 发送端
Content-Type: text/html; charset=utf-8
// 接收端
Accept-Charset: charset=utf-8

对于定长和不定长的数据,HTTP 是怎么传输的?

定长包体

对于定长包体而言,发送端在传输的时候一般会带上 Content-Length, 来指明包体的长度。

我们用一个nodejs服务器来模拟一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const http = require('http');

const server = http.createServer();

server.on('request', (req, res) => {
if(req.url === '/') {
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Length', 10);
res.write("helloworld");
}
})

server.listen(8081, () => {
console.log("成功启动");
})

启动后访问: localhost:8081。

浏览器中显示如下:
//helloworld
这是长度正确的情况,那不正确的情况是如何处理的呢?

我们试着把这个长度设置的小一些:

1
res.setHeader('Content-Length', 8);

重启服务,再次访问,现在浏览器中内容如下:
//hellowor
那后面的ld哪里去了呢?实际上在 http 的响应体中直接被截去了。

然后我们试着将这个长度设置得大一些:

1
res.setHeader('Content-Length', 12);

直接无法显示了。可以看到Content-Length对于 http 传输过程起到了十分关键的作用,如果设置不当可以直接导致传输失败。

不定长包体

上述是针对于定长包体,那么对于不定长包体而言是如何传输的呢?

这里就必须介绍另外一个 http 头部字段了:

1
Transfer-Encoding: chunked

表示分块传输数据,设置这个字段后会自动产生两个效果:

  1. Content-Length 字段会被忽略
  2. 基于长连接持续推送动态内容
    我们依然以一个实际的例子来模拟分块传输,nodejs 程序如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    const http = require('http');

    const server = http.createServer();

    server.on('request', (req, res) => {
    if(req.url === '/') {
    res.setHeader('Content-Type', 'text/html; charset=utf8');
    res.setHeader('Content-Length', 10);
    res.setHeader('Transfer-Encoding', 'chunked');
    res.write("<p>来啦</p>");
    setTimeout(() => {
    res.write("第一次传输<br/>");
    }, 1000);
    setTimeout(() => {
    res.write("第二次传输");
    res.end()
    }, 2000);
    }
    })

    server.listen(8009, () => {
    console.log("成功启动");
    })

    用 telnet 抓到的响应如下:

    注意,Connection: keep-alive及之前的为响应行和响应头,后面的内容为响应体,这两部分用换行符隔开。

响应体的结构比较有意思,如下所示:

1
2
3
4
5
6
chunk长度(16进制的数)
第一个chunk的内容
chunk长度(16进制的数)
第二个chunk的内容
......
0

最后是留有有一个空行的,这一点请大家注意。

以上便是 http 对于定长数据和不定长数据的传输方式。

HTTP 如何处理大文件的传输?

对于几百 M 甚至上 G 的大文件来说,如果要一口气全部传输过来显然是不现实的,会有大量的等待时间,严重影响用户体验。因此,HTTP 针对这一场景,采取了范围请求的解决方案,允许客户端仅仅请求一个资源的一部分。

如何支持

当然,前提是服务器要支持范围请求,要支持这个功能,就必须加上这样一个响应头:

1
Accept-Ranges: none

Range 字段拆解

而对于客户端而言,它需要指定请求哪一部分,通过Range这个请求头字段确定,格式为bytes=x-y。接下来就来讨论一下这个 Range 的书写格式:

  • 0-499表示从开始到第 499 个字节。
  • 500- 表示从第 500 字节到文件终点。
  • -100表示文件的最后100个字节。
    服务器收到请求之后,首先验证范围是否合法,如果越界了那么返回416错误码,否则读取相应片段,返回206状态码。
    同时,服务器需要添加Content-Range字段,这个字段的格式根据请求头中Range字段的不同而有所差异。
    具体来说,请求单段数据和请求多段数据,响应头是不一样的。
    1
    2
    3
    4
    // 单段数据
    Range: bytes=0-9
    // 多段数据
    Range: bytes=0-9, 30-39
    接下来我们就分别来讨论着两种情况。

单段数据

对于单段数据的请求,返回的响应如下:

1
2
3
4
5
6
HTTP/1.1 206 Partial Content
Content-Length: 10
Accept-Ranges: bytes
Content-Range: bytes 0-9/100

i am xxxxx

值得注意的是Content-Range字段,0-9表示请求的返回,100表示资源的总大小,很好理解。

多段数据

接下来我们看看多段请求的情况。得到的响应会是下面这个形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=00000010101
Content-Length: 189
Connection: keep-alive
Accept-Ranges: bytes


--00000010101
Content-Type: text/plain
Content-Range: bytes 0-9/96

i am xxxxx
--00000010101
Content-Type: text/plain
Content-Range: bytes 20-29/96

eex jspy e
--00000010101--

这个时候出现了一个非常关键的字段Content-Type: multipart/byteranges;boundary=00000010101,它代表了信息量是这样的:

  • 请求一定是多段数据请求
  • 响应体中的分隔符是 00000010101
    因此,在响应体中各段数据之间会由这里指定的分隔符分开,而且在最后的分隔末尾添上--表示结束。

以上就是 http 针对大文件传输所采用的手段。

HTTP 中如何处理表单数据的提交?

在 http 中,有两种主要的表单提交的方式,体现在两种不同的Content-Type取值:

  • application/x-www-form-urlencoded
  • multipart/form-data
    由于表单提交一般是POST请求,很少考虑GET,因此这里我们将默认提交的数据放在请求体中。

application/x-www-form-urlencoded

特点:

  • 数据会被编码为&分割的键值对
  • 字符以URL形式编码
1
2
// 转换过程: {a: 1, b: 2} -> a=1&b=2 -> 如下(最终形式)
"a%3D1%26b%3D2"

multipart/form-data

  • 请求头中的Content-Type字段会包含boundary,且boundary的值有浏览器默认指定。例: Content-Type: multipart/form-data;boundary=----WebkitFormBoundaryRRJKeWfHPGrS4LKe
    8 数据会分为多个部分,每两个部分之间通过分隔符来分隔,每部分表述均有 HTTP 头部描述子包体,如Content-Type,在最后的分隔符会加上--表示结束。
1
2
3
4
5
6
7
8
Content-Disposition: form-data;name="data1";
Content-Type: text/plain
data1
----WebkitFormBoundaryRRJKeWfHPGrS4LKe
Content-Disposition: form-data;name="data2";
Content-Type: text/plain
data2
----WebkitFormBoundaryRRJKeWfHPGrS4LKe--

总结

值得一提的是,multipart/form-data 格式最大的特点在于:每一个表单元素都是独立的资源表述。另外,你可能在写业务的过程中,并没有注意到其中还有boundary的存在,如果你打开抓包工具,确实可以看到不同的表单元素被拆分开了,之所以在平时感觉不到,是以为浏览器和 HTTP 给你封装了这一系列操作。
而且,在实际的场景中,对于图片等文件的上传,基本采用multipart/form-data而不用application/x-www-form-urlencoded,因为没有必要做 URL 编码,带来巨大耗时的同时也占用了更多的空间。

队头阻塞

什么是队头阻塞?
HTTP的传输是请求应答模式的,即报文必须一发一收,但值得注意的是里面的任务被放到一个任务队列中串行执行,一旦队首的请求处理太慢,就会阻塞后面请求的处理,这就是队头阻塞问题。

并发连接

对于同一个域名允许分配多个长连接,那么相当于增加了任务队列,不至于一个任务阻塞了其他所有任务。在RFC2616规定过客户端最多并发两个连接,不过事实现在的浏览器标准中,上限要高很多,比如Chrome是6个
但其实,即使提高了并发连接,还是不能满足人们对性能的要求。

域名分片

一个域名不是可以并发六个长连接吗?那就多分几个域名
比如content1.sanyuan.com 、content2.sanyuan.com。
这样一个sanyuan.com域名下可以分出非常多的二级域名,而它们都指向同样的一台服务器,能够并发的长连接数更多了,事实上也更好地解决了队头阻塞的问题。

Cookie

简介
我们知道HTTP是无状态的协议,每次HTTP请求都是独立,无关的,默认不需要保留任何信息,但有时候要保存一些状态。所以就诞生了Cookie
本质是浏览器里存储的一个很小的文本文件,内部以键值对的方式来存储。想同一个域名发送请求,都会携带相同的Cookie,服务器拿到Cookie进行解析,便能拿到客户端的状态,而服务端就可以通过响应头中的Set-Cookie字段来给客户端写入Cookie。
eg

1
2
3
4
5
// 请求头
Cookie: a=xxx;b=xxx
// 响应头
Set-Cookie: a=xxx
set-Cookie: b=xxx

Cookie属性

生命周期

设置有效期用以下两个属性

  • Expires过期时间
  • Max-Age用的是一段时间间隔,单位是s,从浏览器收到报文开始计算。
    若Cookie过期,则Cookie会被删除并不会发送给服务端。

作用域

两个属性

  • Domain域名
  • Path路径
    在发送请求之前,发现域名和路径两者不匹配,则不会带上Cookie。值得注意的是,对于路径来说,/表示域名下的任意路径都允许使用Cookie

安全相关

如果带上Secure。说明只能通过HTTPS传输Cookie
如果带上HttpOnly,说明只能通过HTTP协议传输,不能通过JS访问,这也是预防XSS攻击的重要手段。
对应的,对于CSRF攻击的预防,也有SameSite属性。
三个值 Strict,Lax,None

  • 在strict模式下,浏览器完全禁止第三方请求携带Cookie,比如请求aaa.com的网站只能在aaa.com的域名当中请求才能携带Cookie,在其他网站请求都不行。
  • 在lax模式下,就宽松一点,但是只能在GET方法提交表单或者a标签发送get请求的情况下可以携带Cookie,其他情况都不能。
  • 在None,也就是默认模式下,请求会自动携带上Cookie

Cookie的缺点

  1. 容量缺陷 4kb
  2. 性能缺陷,cookie紧跟域名,不管域名下面某个地址需不需要这个cookie,都会携带上完整的cookie,这样随着请求的增多,会造成巨大的性能浪费,但是可以通过domain和path来指定作用域解决。
  3. 安全缺陷,cookie是以纯文本的形式存储在浏览器中的,很容易被用户非法获取,然后进行一系列的篡改,在cookie有效期内发给服务器,这是很危险的。另外,在httponly为false的情况下,cookie信息能直接通过js脚本获取。

HTTP代理

我们知道在HTTP是基于请求响应模型的协议,一般由客户端请求,服务器来进行响应。
当然也有特殊情况,就是代理服务器的情况。引入代理之后,作为代理服务器相当于一个中间人的角色,对于客户端而言,表现为服务器进行响应。而对于源服务器,表现为客户端发起请求,具有双重身份,那代理服务器是用来做什么的呢?

功能

  1. 负载均衡。客户端的请求只会先到达代理服务器,后面到底有多少源服务器,IP都是多少,客户端是不知道的。因此这个代理服务器可以拿到这个请求之后,可以通过特定的算法分发给不同的源服务器,让各个源服务器的负载尽量平均。当然这样的算法有很多,包括随机算法,轮询,一致性,hash,LRU等等,不过这些算法并不是重点。
  2. 保障安全。利用心跳机制监控后台的服务器,一旦发现故障就将其踢出集群。并且对于上下行的数据进行过滤,对非法IP限流,这些都是代理服务器的工作。
  3. 缓存代理。将内容缓存到代理服务器,使得客户端可以直接从代理服务器获得而不用到源服务器那里,下一节详细拆解。

相关头部字段

Via

代理服务器需要表明自己的身份,在HTTP传输中留下自己的痕迹,怎么办?
通过Via字段来记录,举个例子,现在中间有两台代理服务器,在客户端发送请求后会经历这样一个过程:

1
客户端 -> 代理1 -> 代理2 -> 源服务器

在源服务器收到请求之后,会在请求头拿到这个字段
1
Via: proxy_server1, proxy_server2

可以看到Via的代理顺序即为在HTTP传输中报文传达的顺序。

X-Forwarded-For

字面意思就是为谁转发,它记录的是请求方的IP地址,注意和Via区分开,它记录的是请求方的。

X-Real-IP

是一种获取用户真实IP的字段,不管中间经过多少代理,这个字段始终记录最初客户端的IP。
相应的,还有X-Forwarded-HostX-Forwarded-Proto,分别记录着客户端的域名和协议名

X-Forwarded-For产生的问题

这个字段记录的是请求方的IP,所以说每次经过代理它都会发生改变。
所以产生了一下两个问题

  1. 意味着代理必须解析HTTP请求头,然后修改,比直接转发数据性能下降。
  2. 在HTTPS通信加密的过程中,原始报文是不允许被修改的。
    由此产生了代理协议,一般使用明文版本,只需要在HTTP请求行上加上这样格式的文本即可。
    1
    2
    3
    4
    // PROXY + TCP4/TCP6 + 请求方地址 + 接收方地址 + 请求端口 + 接收端口
    PROXY TCP4 0.0.0.1 0.0.0.2 1111 2222
    GET / HTTP/1.1
    ...
    就可以解决X-Forwarded-For带来的问题了。

缓存

浏览器缓存分为强缓存和协商缓存,当客户端请求某个资源的时候,获取缓存的流程如下:

  • 先根据这个资源的一些http header判断它是否命中强缓存,如果命中,则直接从本地获取缓存资源,不会发送请求到服务器。
  • 当强缓存没有命中的时候,客户端会发送请求到服务器,服务器通过一些request header验证这个资源是否命中协商缓存,称为http再验证,如果命中,服务器将请求返回,但不返回资源,而是告诉客户端直接从缓存中获取,客户端收到返回后就会从缓存中获取资源。
  • 强缓存和协商缓存的共同之处在于,如果命中缓存,服务器都不会返回资源;区别是,强缓存不会发送请求到服务器,协商缓存会。
  • 当协商缓存也没有命中的时候,服务器将资源发送到客户端。
  • ctrl+f5强制刷新网页的时候,直接从服务器加载,则跳过强缓存和协商缓存。
  • 当F5刷新网页的时候,跳过强缓存,但是会检查协商缓存。

强缓存

强缓存的两种方式如下:

  • Expires(该字段是http1.0时的规范,值为一个绝对是件绝对时间的GMT格式的时间字符串,代表缓存资源的过期时间)
1
Expires: Wed, 22 Oct 2018 08:41:00 GMT
  • Cache-Control:max-age该字段是http1.1的规范,强缓存利用其max-age值来判断缓存资源的最大声明周期,它的值单位为秒。
  • private:客户端可以缓存
  • public:客户端和代理服务器都可以缓存
  • max-age=t:缓存内容将在t秒后失效
  • no-cache:需要使用协商缓存来验证缓存数据
  • no-store:所有内容都不会缓存
    1
    Cache-control: max-age=30

强缓存表示在缓存存在的期间不需要请求,会返回状态码200
Expires受限于本地时间,如果修改了本地时间,可能导致缓存失效。
Cache-Control:max-age优先级高于Expires

协商缓存

如果缓存过期了,我们就可以使用协商缓存来解决问题。协商缓存需要请求,如果缓存有效会返回304
协商缓存也有两种方式:Last-Modified,If-Modified-Since

  • Last-Modified值为资源最后更新时间。
  • If-Modified-Since会将Last-Modified的值发送给服务器,询问服务器在该日期后资源是否有更新,有更新的话就把新的资源发送过来,否则命中协商缓存。

    但是如果在本地打开缓存文件,就会造成Last-Modified被修改,所以http1.1出现了ETagIf-None-Match

  • ETag类似于文件指纹,表示资源的唯一性。
  • If-None-Match会将当前的Etag发送给服务器,询问服务器该资源的Etag是否有变动,如果有变动则发送新的资源回来,如果没有修改则命中协商缓存。且注意Etag的优先级大于Last-Modified

选择合适的缓存策略

对于大部分场景可以使用强缓存配合协商缓存解决问题。但在一些特殊的情况下选择特殊的缓存策略更为重要。

  • 对于某些不需要缓存的资源,可以使用Cache-control:no-store表示该资源不需要缓存。
  • 对于频繁变动的资源,可以使用Cache-Control:no-cache配合Etag使用,表示该资源已被缓存,但是每次都会发送请求询问该资源是否更新。
  • 对于代码文件来说,通常使用Cache-Control:max-age=31536000并配合策略缓存使用,然后对文件进行指纹处理,一旦文件名变动就会下载新的文件。

缓存新鲜度

缓存新鲜度 = max-age || (expires - date)

  • date表示创建报文的时间,可以理解为服务器返回新资源的时间。
  • 过期时间-创建时间就能计算缓存时间。默认转为秒

启发式缓存

我们上述已经知道强缓存的新鲜度公式了,那么如果此时没有max-age,expires这两个关键字的时候,新鲜度怎么计算呢?
此时没有了强缓存的必要字段,但服务器还是会走强缓存

1
2
3
4
date: Thu, 02 Sep 2021 13:28:56 GMT
age: 10467792
cache-control: public
last-modified: Mon, 26 Apr 2021 09:56:06 GMT

浏览器会触发启发式缓存,公式如下:
1
缓存新鲜度 = max(0,(date - last-modified)) * 10%

根据响应报头中 date 与 last-modified 值之差与 0 取最大值后取其值的百分之十作为缓存时间。

Last-Modified弊端

它是一个时间,最小单位是秒,但如果资源修改得非常快,快到毫秒级别,那么服务器就无法识别,导致浏览器资源没有即使更新。
二 当服务器资源被修改了 但实质上的东西并没有改变 此时也会发送最新的Last-Modified,但我们不希望它这样做。
于是就产生了Etag

HTTP 缓存及缓存代理?

强缓存和协商缓存
首先通过 Cache-Control 验证强缓存是否可用
如果强缓存可用,直接使用
否则进入协商缓存,即发送 HTTP 请求,服务器通过请求头中的If-Modified-Since或者If-None-Match这些条件请求字段检查资源是否更新
若资源更新,返回资源和200状态码
否则,返回304,告诉浏览器直接从缓存获取资源
这一节我们主要来说说另外一种缓存方式: 代理缓存。

为什么产生代理缓存

对于源服务器来说,他也是有缓存的,比如Redis,Memcache,但对于HTTP缓存来说,如果每次客户端缓存失效都要到源服务器获取,那么源服务器的压力是非常大的。
由此引入了缓存代理机制,让代理服务器承担一部分HTTP缓存,客户端缓存过期之后就到就近的代理服务器上面获取,代理缓存过期了才请求源服务器,这样流量巨大的时候才能明显降低源服务器的压力。
那缓存代理是如何做到的呢?
总的来说,代理缓存分为两个部分,一部分是源服务器端的控制,一部分是客户端的控制。

源服务器的缓存控制

private和public

在源服务器的响应头中,在Cache-Control这个字段中可以加入private或者public是否允许代理服务器进行缓存,前者禁止,后者允许。
比如一些比较敏感的数据,就不要缓存到代理服务器了,这样别人访问代理服务器就能获取了,这部分就private设置。

proxy-revalidate

must-revalidate的意思是客户端缓存过期就直接到源服务器获取,proxy-revalidate表示客户端缓存过期到代理服务器获取。

s-maxage

s是share的意思,限制了缓存在代理服务器中可以存放多久,和限制客户端缓存时间的max-age并不冲突
eg

1
Cache-Control: public, max-age=1000, s-maxage=2000

这个响应是允许代理服务器缓存的,在本地缓存的时间是1000s,在代理服务器的缓存时间是2000s

客户端的缓存控制

max-stale和min-fresh

在客户端的请求头中,可以加入这两个字段,来对代理服务器上的缓存进行宽容和限制操作。
比如:

1
max-stale = 5

表示客户端到代理服务器上拿缓存的时候,即使代理缓存过期了也没关系,只要在过期的五秒内还是可以从代理缓存中获取的。
1
min-fresh = 5

表示代理缓存需要一定的新鲜度,不要等缓存刚好过期才去拿,一定要在到期的前5s内拿,否则拿不到。

only-if-cached

这个字段加上后表示客户端只会接收代理缓存,而不会接收源服务器的缓存。如果代理缓存无效直接返回504(Gateway Timeout)

跨域

回顾一下url的组成

违背同源策略(协议scheme,主机host,端口port三者一致)即为跨域
非同源有以下限制:

  • 不能读取和修改对方的DOM
  • 不能访问对方的Cookie,indexDB和LocalStorage
  • 限制XMLHttpRequest请求。
    当浏览器向目标URI发送AJAX请求的时候,如果当前URL和目标URL不同源,则产生跨域,这就是跨域请求。

跨域请求的响应一般会被浏览器拦截,注意:是被浏览器拦截,响应其实是已经发送到客户端了,那么这个拦截是如何发生的呢?

首先要知道浏览器是多进程的,以Chrome为例子,进程组成如下:

以下内容是CV
WebKit 渲染引擎和V8 引擎都在渲染进程当中。
xhr.send被调用,即Ajax请求准备发送的时候,其实还只是在渲染进程的处理。为了防止黑客通过脚本触碰到系统资源,浏览器将每一个渲染进程装进了沙箱,并且为了防止 CPU 芯片一直存在的Spectre 和 Meltdown漏洞,采取了站点隔离的手段,给每一个不同的站点(一级域名不同)分配了沙箱,互不干扰。具体见YouTube上Chromium安全团队的演讲视频。
在沙箱当中的渲染进程是没有办法发送网络请求的,那怎么办?只能通过网络进程来发送。那这样就涉及到进程间通信(IPC,Inter Process Communication)了。接下来我们看看 chromium 当中进程间通信是如何完成的,在 chromium 源码中调用顺序如下:

可能看了你会比较懵,如果想深入了解可以去看看 chromiu 最新的源代码,IPC源码地址及Chromium IPC源码解析文章。
总的来说就是利用Unix Domain Socket套接字,配合事件驱动的高性能网络并发库libevent完成进程的 IPC 过程。
好,现在数据传递给了浏览器主进程,主进程接收到后,才真正地发出相应的网络请求。
在服务端处理完数据后,将响应返回,主进程检查到跨域,且没有cors(后面会详细说)响应头,将响应体全部丢掉,并不会发送给渲染进程。这就达到了拦截数据的目的。
接下来我们来说一说解决跨域问题的几种方案。

CORS

CORS是W3C规定的一个标准,全称是跨域资源共享,他需要浏览器和服务器的共同支持,具体来说,非 IE 和 IE10 以上支持CORS,服务器需要附加特定的响应头,后面具体拆解。不过在弄清楚 CORS 的原理之前,我们需要清楚两个概念: 简单请求和非简单请求。

浏览器根据请求方法和请求头的特定字段,将请求做了个分类,具体来说规则是这样的,凡是满足以下条件的为简单请求:

  • 请求方法为GET POST 或者HEAD
  • 请求头的取值范围为:Accept,Accept-Language,Content-Language,Content-Type(仅限三个值:application/x-www-form-urlencodedmultipart/form-datatext/plain
    除此之外的就是非简单请求.

    简单请求

    请求发出去之前,浏览器在做什么?

    他会自动在请求头中,添加一个Origin字段,用来说明请求来自哪个源。服务器拿到请求之后,在回应时对应的添加Access-Control-Allow-Origin字段,如果Origin不在这个字段的范围内,浏览器就会将响应拦截。
    因此Access-Control-Allow-Origin字段是服务器用来决定浏览器是否拦截这个响应,这是必须的字段。与此同时,其他一些可选的功能性字段,用来描述如果不会被拦截,这些字段将发挥各自的作用。

Access-Control-Allow-Credentials这个字段是一个布尔值,表示是否允许发送Cookie,对于跨域请求,浏览器对这个字段的默认值设为false,而如果需要拿到浏览器的Cookie,需要添加这个响应头并且设置为true,并且前端也需要设置withCredentials属性。

1
2
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;

Access-Control-Expose-Headers这个字段是给XMLHttpRequest对象赋能,让它不仅能拿到基本的六个响应头字段,包括Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma), 还能拿到这个字段声明的响应头字段。比如这样设置:
1
Access-Control-Expose-Headers: aaa

那么前端就能通过
1
XMLHttpRequest.getResponseHeader('aaa') 

拿到该字段的值。

非简单请求

非简单请求相对而言会有些不同,体现在两个方面,预检请求和响应字段。
以PUT方法为例

1
2
3
4
5
var url = 'http://xxx.com';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'xxx');
xhr.send();

当这段代码执行的时候会发送预检请求,这个预检请求的请求行和请求体如下:
1
2
3
4
5
OPTIONS / HTTP/1.1
Origin: 当前地址
Host: xxx.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header

预检请求的方法是OPTIONS,同时会加上Origin源地址和Host目标地址,这很简单。同时也会加上两个关键的字段:

  • Access-Control-Request-Method, 列出 CORS 请求用到哪个HTTP方法
  • Access-Control-Request-Headers,指定 CORS 请求将要加上什么请求头
    这是预检请求。接下来是响应字段,响应字段也分为两部分,一部分是对于预检请求的响应,一部分是对于 CORS 请求的响应。
    预检请求的响应。如下面的格式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    HTTP/1.1 200 OK
    Access-Control-Allow-Origin: *
    Access-Control-Allow-Methods: GET, POST, PUT
    Access-Control-Allow-Headers: X-Custom-Header
    Access-Control-Allow-Credentials: true
    Access-Control-Max-Age: 1728000
    Content-Type: text/html; charset=utf-8
    Content-Encoding: gzip
    Content-Length: 0

    其中有这样几个关键的响应头字段:

  • Access-Control-Allow-Origin: 表示可以允许请求的源,可以填具体的源名,也可以填*表示允许任意源请求。

  • Access-Control-Allow-Methods: 表示允许的请求方法列表。
  • Access-Control-Allow-Credentials: 简单请求中已经介绍。
  • Access-Control-Allow-Headers: 表示允许发送的请求头字段
  • Access-Control-Max-Age: 预检请求的有效期,在此期间,不用发出另外一条预检请求。

在预检请求的响应返回后,如果请求不满足响应头的条件,则触发XMLHttpRequest的onerror方法,当然后面真正的CORS请求也不会发出去了。
CORS 请求的响应。绕了这么一大转,到了真正的 CORS 请求就容易多了,现在它和简单请求的情况是一样的。浏览器自动加上Origin字段,服务端响应头返回Access-Control-Allow-Origin。可以参考以上简单请求部分的内容。

JSONP

虽然XMLHttpRequest对象遵循同源政策,但是script标签不一样,它可以通过 src 填上目标地址从而发出 GET 请求,实现跨域请求并拿到响应。这也就是 JSONP 的原理,接下来我们就来封装一个 JSONP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const jsonp = ({ url, params, callbackName }) => {
const generateURL = () => {
let dataStr = '';
for(let key in params) {
dataStr += `${key}=${params[key]}&`;
}
dataStr += `callback=${callbackName}`;
return `${url}?${dataStr}`;
};
return new Promise((resolve, reject) => {
// 初始化回调函数名称
callbackName = callbackName || Math.random().toString.replace(',', '');
// 创建 script 元素并加入到当前文档中
let scriptEle = document.createElement('script');
scriptEle.src = generateURL();
document.body.appendChild(scriptEle);
// 绑定到 window 上,为了后面调用
window[callbackName] = (data) => {
resolve(data);
// script 执行完了,成为无用元素,需要清除
document.body.removeChild(scriptEle);
}
});
}

当然在服务端也会有响应的操作, 以 express 为例:
1
2
3
4
5
6
7
8
9
10
let express = require('express')
let app = express()
app.get('/', function(req, res) {
let { a, b, callback } = req.query
console.log(a); // 1
console.log(b); // 2
// 注意哦,返回给script标签,浏览器直接把这部分字符串执行
res.end(`${callback}('数据包')`);
})
app.listen(3000)

前端这样简单地调用一下就好了:
1
2
3
4
5
6
7
8
9
10
jsonp({
url: 'http://localhost:3000',
params: {
a: 1,
b: 2
}
}).then(data => {
// 拿到数据进行处理
console.log(data); // 数据包
})

和CORS相比,JSONP 最大的优势在于兼容性好,IE 低版本不能使用 CORS 但可以使用 JSONP,缺点也很明显,请求方法单一,只支持 GET 请求。

Nginx

Nginx是一种高效的反向代理服务器,可以用来轻松解决跨域问题。

下面关于正向代理和反向代理的区别
正向代理:帮助客户端访问自己访问不到的服务器,然后将结果返回给客户端。
反向代理:拿到客户端的请求,将请求转发给其他服务器,主要的场景是维持服务器集群的负载均衡,换句话说,反向代理帮其他服务器拿到请求,然后选择一个合适的服务器转发给他。
因此两者的区别就很明显了,正向代理是帮客户端做事情,反向代理是帮其他服务器做事情。
那么Nginx是如何解决跨域的呢?
比如说现在客户端的域名为client.com,服务器的域名为server.com,客户端向服务器发送 Ajax 请求,当然会跨域了,那这个时候让 Nginx 登场了,通过下面这个配置:

1
2
3
4
5
6
7
server {
listen 80;
server_name client.com;
location /api {
proxy_pass server.com;
}
}

Nginx 相当于起了一个跳板机,这个跳板机的域名也是client.com,让客户端首先访问 client.com/api,这当然没有跨域,然后 Nginx 服务器作为反向代理,将请求转发给server.com,当响应返回时又将响应给到客户端,这就完成整个跨域请求的过程。
其实还有一些不太常用的方式,大家了解即可,比如postMessage,当然WebSocket也是一种方式,但是已经不属于 HTTP 的范畴,另外一些奇技淫巧就不建议大家去死记硬背了,一方面从来不用,名字都难得记住,另一方面临时背下来,面试官也不会对你印象加分,因为看得出来是背的。当然没有背并不代表减分,把跨域原理和前面三种主要的跨域方式理解清楚,经得起更深一步的推敲,反而会让别人觉得你是一个靠谱的人。

https

和http的区别:

  1. https协议需要ca证书 费用比较高
  2. http的信息是明文传输,https是经过ssl协议加密传输的
  3. 端口不同 一般来讲http是80端口 https是443端口

SSL/TLS

通信不容易收到拦截,ssl是tls的前身,现在绝大部分的浏览器不支持ssl而是支持tls,但是ssl的名气很大,所以人们经常会说ssl。

SSL安全套接层(Secure Socket Layer),在OSI七层模型中处于会话层(第五层)。之前SSL出过三个大版本,当他发展到第三个大版本的时候才被标准化,成为TLS(传输层安全,Transport Layer Security)并被当作TLS1.0版本,准确的说TLS1.0 = SSL3.1

现在的主流版本是TLS/1.2,之前的TLS1.0,1.1都被认为是不安全的,在不久的将来会被完全淘汰,因此我们下面要讨论的是TLS/1.2

传统RSA握手

TLS1.2握手

step1:Client Hello

首先浏览器发送client_random,TLS版本,加密套件列表。

client_random是什么?用来形成最终secret的一个参数

加密套件列表是什么?eg

1
TLS_ECDHE_WITH_AES_128_GCM_SHA256

意思是在TLS握手的过程中,使用ECDHE算法pre_random,128位的AES算法进行对称加密,在对称加密的过程中使用主流的GCM分组模式,因为对称加密中很重要的一个问题就是如何分组,最后一个是采用哈希摘要算法,采用SHA256算法。

其中值得解释的是这个哈希摘要算法,试想一下这个场景,服务端现在给客户端发信息来了,而客户端不知道此时的消息是服务端的还是中间人的,现在引入这个哈希摘要算法,将服务端的证书信息通过这个算法生成一个摘要,用来标识这个服务器的身份,然后用CA私钥加密后把加密后的标识和CA公钥发给客户端。客户端拿到这个CA公钥来解密,生成另外一份摘要。两个摘要进行对比,如果相同则能确认服务器的身份。这也就是所谓数字签名的原理。其中除了哈希摘要算法,最重要的是私钥加密,公钥解密。

step2 Server Hello

server_random发送这个随机数,确认TLS版本,需要使用的加密套件和自己的证书,那么这个server_params是做什么的呢?

step3 Client验证证书,生成secret

客户端验证服务器传过来的证书和签名是否通过,如果验证通过,则将client_params传递给服务器。

接着客户端通过ECDHE算法计算出了pre_random,其中传入两个参数:server_params和client_params。现在你应该清楚这两个参数的作用了吧,由于ECDHE算法是基于椭圆曲线离散对数,这两个参数也称作椭圆曲线的公钥。

客户端现在拥有了client_random,server_random和pre_random,接下来这三个数通过一个伪随机数函数计算出最终的secret

step4 Server生成secret

客户端传了client_params过来了,服务端开始用ECDHE算法生成pre_random,接着用和客户端同样的伪随机函数生成最后的secret

注意事项

TLS握手实际上是一个双向认证的过程,从step1可以看出客户端有能力验证服务器的身份(数字签名),那么服务器能不能验证客户端的身份呢?

当然是可以的。具体来说,在step3中,客户端发送client_params,实际上是给服务器一个验证消息,让服务器走相同的流程(哈希摘要,私钥加密,公钥解密),确认客户端的身份

在客户端生成secret后,会给服务器发送一个收尾的信息,告诉服务器之后都要用对称加密,对称加密的算法就是用第一次约定的,服务器生成完secret后也会向客户端发送一个收尾信息,告诉客户端以后用对称加密来通信。

这个收尾信息包括两个部分,一部分是Change Cipher Spec,意味着后面加密传输了,另外一个是Finished消息,这个消息是对之前发送的所有数据做的摘要,对摘要进行加密,让对方验证一下。

当双方都验证通过之后,握手才正式结束,后面的HTTP正式开始传输加密报文。

RSA和ECDHE有什么区别?

  1. ECDHE握手,也就是主流的TLS1.2握手,使用ECHDE算法实现pre_random的加密解密,没有用到RSA
  2. 使用ECDHE还有一个特点,就是客户端发送完收尾信息后可以提前抢跑,直接发送HTTP报文,节省一个RTT,不必等到收尾信息到到服务器,然后等服务器返回收尾信息告诉自己,直接开始发送请求,这也叫TLS False Start

    数字签名

    数字签名是公钥加一些个人信息用哈希算法生成摘要之后,再用私钥加密生成的东西。

    数字证书

    数字证书应用了数字签名的技术,将个人信息和公钥用哈希算法生成摘要之后,再用CA的私钥加密,这整个就是数字证书。

    TLS1.3做了什么改进?

    TLS 1.2 虽然存在了 10 多年,经历了无数的考验,但历史的车轮总是不断向前的,为了获得更强的安全、更优秀的性能,在2018年就推出了 TLS1.3,对于TLS1.2做了一系列的改进,主要分为这几个部分:强化安全、提高性能。

强化安全

TLS1.3废除了很多加密算法,最后只保留了五个加密套件。

  • TLS_AES_128_GCM_SHA256
  • TLS_AES_256_GCM_SHA384
  • TLS_CHACHA20_POLY1305_SHA256
  • TLS_AES_128_GCM_SHA256
  • TLS_AES_128_GCM_8_SHA256

可以看到,最后剩下的对称加密算法是AES和CHACHA20,之前主流的也会这两种。分组模式也只剩GCM和POLY1305,哈希摘要算法只剩下SHA256和SHA384了

那你可能会问了,之前RSA这么重要的非对称加密算法怎么不在了?

两方面原因:

  1. 2015年发现了FREAK攻击,就是说有人发现了RSA漏洞,能够进行破解了。
  2. 一旦私钥泄漏,那么中间人可以通过私钥计算出之前所有的报文secret,破解之前所有的密文。

为什么?回到RSA握手的过程中,客户端拿到服务器的证书后,提取出服务器的公钥,然后生成pre_random并用公钥加密传送给服务器,服务器通过私钥解密,从而拿到真实的pre_random。当中间人拿到了服务器的私钥,并且截获之前的所有报文的时候, 那么就能拿到pre_random,server_random和client_random并根据对应的随机数函数生成secret,也就是拿到了TLS的最终会话密钥,每一个历史报文都能通过这样的方式破解。

但ECDHE在每次握手的时候都生成临时性的密钥对,即使私钥被破解,之前的历史信息也不会收到影响。这种一次性破解并不影响历史信息的性质也叫前向安全性

RSA算法不具备前向安全性,所以被ECDHE取代了。

提升性能

握手改进


大体上看和TLS1.2差不多,不过和TLS1.2相比少了一个RTT,服务器不需要在等待对方证书验证完毕之后再拿到client_params,而是直接在第一次握手的时候就能拿到,拿到之后立刻计算出secret,节省了之前不必要的等待时间。同时也意味着第一次握手的时候客户端需要传送更多的消息,一口气传完。

这种TLS1.3的握手方式也被称为1-RTT握手,但其实这种握手方式还是有一定的优化空间的,接下里介绍这种优化方式。

会话复用

会话复用有两种方式:Session ID和Session Ticket

先说说最早出现的Session ID,具体做法是客户端和服务端连接的时候会保存各自的会话ID,并存储会话密钥,当再次连接的时候,客户端发送ID过来,服务器查找这个ID是否存在,如果找到了就直接复用之前的会话状态,会话密钥就不用重新生成,直接用原来的那个部分。

但这个方式也存在一定的弊端,就是当客户端数量庞大的时候,对服务端的存储压力非常大。

因此出现了第二种方式—Session Ticket,服务端压力大那就给客户端保存呗。具体来说,双方连接成功后,服务器加密会话信息,用Session Ticket消息发送给客户端,让客户端保存下来。下次重连的时候,就把这个ticket进行解密,验证它有没有过期,如果没过期就恢复之前的会话状态。

这种方式虽然减小了服务器的压力,但是带来了安全问题,即每次用一个固定的密钥来解密ticket数据,一旦黑客拿到这个密钥,之前所有的历史记录也会被破解了。因此为了避免这样的问题,密钥需要定期更换。

总的来说,这些会话复用的技术在保证1-RTT的时候,也节省了生成会话密钥这些算法所消耗的时间,是一笔客观的性能提升。

PSK

刚刚说的是1-RTT的情况,能不能优化到0-RTT呢?

答案是可以的,方法也很简单,在发送Session Ticket的同时带上数据,不用等服务器确认,这种方式被称为Pre-Shared Key即PSK。

这种方法虽然方便,但是也带来了安全问题,中间人截获PSK数据,不断向服务器重复发,类似于TCP第一次握手携带数据,增加了服务器被攻击的风险。

总结

TLS1.3在1.2的基础上废除了大量的算法,提升了安全性,同时利用会话复用节省了重新生成密钥的时间,利用PSK做到了0-RTT连接。

客户端是如何对比数字签名?

  • 浏览器会安装一些比较权威的第三方认证机构的公钥,比如VeriSign、Symantec以及GlobalSign等等
  • 验证数字签名的时候,直接从本地拿到第三方公钥,对私钥加密后的数字签名进行解密得到真正的签名。
  • 然后客户端利用签名生成规则进行签名生成,看两个签名是否匹配,如果匹配认证通过,不匹配则获取证书失败。

混合加密

https就是利用上面的对称加密和非对称加密进行一个混合加密。兼顾了对称加密传输快和非对称消耗资源大的问题。

如何把http请求换成https

用axios进行封装的时候域名最好也定义一个baseURL 方便在后面将它改为https的的请求
or
使用meta标签

1
<meta http-equiv ="Content-Security-Policy" content="upgrade-insecure-requests">

为什么说HTTPS比HTTP安全呢

  • 通过混合加密保证传输的数据不被窃听
  • 通过数字签名的方式保证数据不会被篡改
  • 通过数字证书保证服务器身份的真实性

什么是中间人攻击?

中间人攻击(Man-in-the-MiddleAttack,简称“MITM攻击”)是指攻击者与通讯的两端分别创建独立的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过一个私密的连接与对方 直接对话,但事实上整个会话都被攻击者完全控制。在中间人攻 击中,攻击者可以拦截通讯双方的通话并插入新的内容。中间人攻击是一个(缺乏)相互认证的攻击。大多数的加密协议都专门加入了一些特殊的认证方法以阻止中间人攻击。例如,SSL协议可以验证参与通讯的一方或双方使用的证书是否是由权威的受信 任的数字证书认证机构颁发,并且能执行双向身份认证。
中间人攻击过程 1客户端发送请求到服务端,请求被中间人截获。
2)服务器向客户端发送公钥。
3)中间人截获公钥,保留在自己手上。然后自己生成一个【伪 造的】公钥,发给客户端。
4)客户端收到伪造的公钥后,生成加密hash值发给服务器。
5)中间人获得加密hash值,用自己的私钥解密获得真秘钥。同时生成假的加密hash值,发给服务器。
6 ) 服务器用私钥解密获得假密钥。然后加密数据传输给客户端。