HTTP协议及发展历史
经典五层网络模型
物理层主要作用是定义物理设备如何传输数据,简单来说物理层就是电脑的硬件、网卡端口、网线以及光缆。
数据链路层在通信的实体间建立数据链路连接。
网络层为数据在结点之间传输创建逻辑链路,即客户端访问服务端如何寻找服务器所在地址的逻辑关系。
传输层向用户提供可靠的端到端服务,客户端和服务端如何传输数据,传输数据、组装数据的方式都是在传输层定义的。主要有TCP和UDP两种协议。
应用层为应用软件提供了很多服务,HTTP协议是在这个层上实现的,我们只要去使用HTTP相关的工具就可以帮我们传输数据。构建于TCP协议之上,所以传输方式都是要在TCP、IP协议基础上完成。
HTTP协议发展历史
HTTP/0.9
只有一个命令GET,没有HEADER等描述数据的信息。服务器发送完毕就关闭TCP连接。
HTTP/1.0
- 增加了POST、PUT等命令。
- 增加了status code(服务端处理请求的状态)和header相关的内容。
- 多字符集支持、多部分发送、权限、缓存等内容。
HTTP/1.1
- 支持了持久连接Keep Alive,在HTTP/1.0版本里1个http请求就要在客户端和服务端创建一个TCP连接,服务端返回内容之后会关闭掉TCP连接。如果建立TCP连接后不关闭那么性能会高很多。这就是长连接的作用。
- 增加了pipeline,可以在同一个TCP连接上发送多个请求,但是对于服务端进来的请求都是要按照顺序返回请求的内容。
- 增加了host,有了host后就可以在同一台物理服务器上同时跑多个web服务。
HTTPS
HTTPS其实就是一个安全版本的HTTP协议。实际使用内容跟HTTP/1.1没有特别大的区别。
HTTP2
- 所有数据以二进制进行传输,在HTTP/1.1大部分数据都是通过字符串传输的,HTTP2所有数据都是以帧进行传输的,同一个连接里面发送多个请求不再需要按照顺序来返回,可以同时返回多个请求的数据,只需要创建一个TCP连接,所有请求可以在这个TCP连接上并发进行。提高传输效率。
- 头信息压缩,在HTTP/1.1每一次发送请求和返回请求时很多HTTP头都是必须要完整的发送和返回,但其实这一部分头信息很多内容是以字符串形式保存的,因此占用带宽的量比较大,头信息压缩有效减少带宽使用。
- 推送功能,在HTTP/1.1只能客户端(主动方)发起请求,服务端(被动方)响应请求,返回内容。HTTP2增加的推送功能允许服务端主动发起数据传输的。
HTTP原理
HTTP是一个应用层协议,由请求和响应构成,是一个标准的客户端服务器模型,是一个无状态的协议。HTTP协议通常承载于TCP协议之上,有时也承载于TLS或SSL协议层之上即HTTPS。
HTTP有请求和响应两个概念,请求和响应都是数据包,它们之前需要经过一个传输通道,这个传输通道就是在TCP里面创建了一个从客户端发起和服务端接收的连接。HTTP请求是在这个连接上去发送的。在TCP连接上是可以发送多个HTTP请求的。在不同版本里面这个模式是不一样的,在HTTP/1.0里面http请求创建的时候就去创建TCP连接,服务器返回响应后就关闭这个TCP连接。在HTTP/1.1里面可以开启keep alive保持这个连接,第一个请求完成后,第二个请求还可以在同一个TCP连接上进行发送。在HTTP2中一个TCP连接上的请求是可以并发进行的,所以只需要创建一个TCP连接就可以了。
一次HTTP请求过程可分为以下四步:
1)首先客户端与服务器需要建立一个TCP connection。
2)建立TCP连接后,客户端发送一个请求给服务器,请求方式的格式为:统一资源标识符(URL)、协议版本号,后边是MIME信息包括请求修饰符、客户机信息和可能的内容。
3)服务器接到请求后,给予相应的响应信息,其格式为一个状态行,包括信息的协议版本号、一个成功或错误的代码,后边是MIME信息包括服务器信息、实体信息和可能的内容。
4)客户端接收服务器所返回的信息通过浏览器显示给用户,然后客户端与服务器断开连接。
请求过程后会有三次握手的过程。
首先客户端发起一个创建连接的数据包的请求,发送到服务端。这个数据包里面会有一个标识位SYN=1,表示创建请求的数据包,Seq=X(数字,一般是1)。服务端接收到这个数据包后就知道有一个客户要跟它创建连接了,服务端就会开启一个TCP socket的端口,然后返回给客户端一个数据包。这个数据包也包括SYN=1,ACK=X+1,X是客户端发送过来的Seq,Seq=Y。客户端拿到这个数据包后知道服务端已经允许它创建这个TCP连接了。客户端再去发送ACK=Y+1,Y是服务端的Seq,Seq=Z给服务端。这就是创建一个TCP连接的过程。那么为什么要去进行这样一个三次握手的过程呢?这是为了防止服务端开启一些无用的连接。因为网络传输是有延时的,中间可能隔了非常远的距离通过光纤以及各种代理服务器来进行传输。在传输过程中如果客户端发送SYN=1,Seq=X,服务端就直接创建了TCP连接,然后返回内容给客户端。当数据包因为网络传输丢失,那么客户端就接收不到服务端返回的信息。客户端有可能设置了超时时间,超过这个时间就关闭创建TCP连接的请求,再发起一个新的创建连接的请求。如果没有第三次握手的话此时服务端是不知道客户端到底有没有接收到它返回的信息,并且不知道是要去创建还是关闭TCP连接。那么TCP连接的端口就一直开着,等着客户端发送实际的http请求。那么服务端的连接就浪费了,因此需要3次握手来确认这个过程中客户端和服务端能及时察觉到因为网络或其他原因导致数据包没有接收到,这个连接就关闭了,而不需要等着。三次握手实际就是为了规避这些网络传输延时而导致的一些服务器开销的问题。下面使用网络抓包工具Wireshark看一下创建TCP连接过程中三次握手数据包的详细内容。
在上图中,可清晰的看到客户端浏览器(ip为180.97.163.244:13789)与服务器(端口80)的交互过程,红框圈出的就是客户端与服务端三次握手的过程(13789->80,80->13789,13789->80):
1)客户端向服务器发出连接请求。此为TCP三次握手第一步(13789->80),此时从图中可以看到标识位SYN,seq=X(X=0)。
2)服务器回应了客户端的请求,并要求确认,此时标识位为:(SYN,ACK),Seq=Y(Y为0),ACK=X+1(为1)。此为三次握手的第二步(80->13789)。
3)浏览器回应了服务器的确认,连接成功。为:ACK,此时Seq=Z(Z为1),ACK=Y+1(为1)。此为三次握手的第三步(13789->80)。
完成以上握手之后,客户端就可以向服务端发送真正的http请求了。
URI-URL和URN
URI -- 统一资源标识符,包含URL和URN。用来识别在互联网上一个固定位置资源所在的地方。我们可以通过一个链接找到这个资源。http协议主要的目的就是找到某个资源并且通过某种方式获得资源。
URL -- 统一资源定位器 user:pass@host.com:80/path?query=… ,用来定位web网站具体的页面。每部分的含义如下:
-
http:// 是scheme,定义用什么协议去访问这个资源。通过不同协议去访问服务,解析方式是不一样的。规定了服务和发送方如何去传输数据、解析数据。
-
user:pass@:代表资源需要特定的身份才能访问,需要加上用户和密码信息进行认证。但是现在web开发中基本用不到。
-
host.com:定位资源所在服务器的位置(ip)。既可以是ip也可以是域名,域名要通过DNS解析到对应ip。
-
80: 端口,每一台服务器都有很多端口,可以跑很多的web服务,web服务可以监听不同的端口。端口就是用来定位我们找到的那台服务器上面存放多个web服务中的某一个web服务。就是要先找到物理服务器再找到web服务器。不带端口默认会访问80端口。
-
/path:路由,web服务里存放了很多内容,通过路由定位我们要找的内容。
-
query=string: 搜索参数。
-
#hash:若资源内容是文档且内容非常多,hash代表文档里的某一个片段。开发是时候经常用hash作为锚点定位。
URN -- 永久统一资源定位符,在资源运动后还能被找到,目前还没有非常成熟的方案。
HTTP报文格式
请求报文首行:
-
HTTP方法:用来定义对于资源的操作。常用GET、POST、PUT、DELETE等。
-
url: 要请求的资源地址。
-
协议版本:HTTP/1.0
请求报文header: Accept、Accept-Language具体内容看后面。
响应报文:
-
协议版本:HTTP/1.0
-
HTTP code:定义服务器对请求的出来结果。各个区间的code有各自的语义。
- 100~199代表操作持续进行才能返回数据。
- 200~299代表操作是成功的。
- 300~399代表重定向。
- 400~499代表发送的请求有问题。
- 500~599代表服务器出现了问题。
-
HTTP header: Content-type、Content-lengt具体内容看后面。
-
主体部分
HTTP特性总览
CORS跨域请求的限制与解决
模拟跨域
server.js
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
console.log('req come', req.url);
const html = fs.readFileSync('test.html','utf8');
res.writeHead(200,{
'Content-type': 'text/html'
})
res.end(html);
}).listen(8888);
server2.js
const http = require('http');
http.createServer((req,res) => {
console.log('req come', req.url);
res.end('1122');
}).listen(8887);
test.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
<script>
var xhr = new XMLHttpRequest();
xhr.open('GET','http://127.0.0.1:8887/');
xhr.send();
</script>
</html>
访问localhost:8888浏览器报错,由于浏览器同源策略的限制,非同源下的请求,都会产生跨域问题提示没有设置Access-Control-Allow-Origin头,Access-Control-Allow-Origin是服务端允许跨域请求的头。
在server2.js中添加设置Access-Control-Allow-Origin头解决跨域问题。
res.writeHead(200,{
'Access-Control-Allow-Origin': '*'
})
再次访问localhost:8888浏览器不会报错了。请求发送了,服务器也返回数据了。
其实不管服务器有没有返回这个头,浏览器都会去发送请求并且接受服务器的返回内容,只不过浏览器在接收返回内容后发现没有Access-Control-Allow-Origin并且设置为允许跨域的话,浏览器会把请求返回的内容拦截掉了,并且在命令行报以上错误。因此跨域限制是浏览器做的,如果跨域了需要服务器同意跨域浏览器才会返回数据,Access-Control-Allow-Origin设置为‘*'表示任何域名都可以访问这个服务,这是不安全的,可以设置为允许某一个域名跨域。但是浏览器是允许 link、img、script标签在标签上写路径加载内容的时候是允许跨域的。所以jsonp能实现跨域的原理就是在script标签上加载了一个链接,这个链接访问了服务器,因为服务器返回的内容是可控的,所以可以在服务器返回内容里写的script标签代码是一段可执行的js代码,然后调用jsonp发起请求之前给我们设置的内容。需要注意的是,callback参数定义的方法是需要前后端定义好的,具体什么名字,商讨好就可以了。其实jsonp的整个过程就类似于前端声明好一个函数,后端返回执行函数。执行函数参数中携带所需的数据,整个过程实际非常简单易懂。下面举例jsonp的实现跨域的过程。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=<s>, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<button type="submit" id="btn">click me</button>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script>
$('#btn').click(function(){
var frame = document.createElement('script');
frame.src = 'http://localhost:8887/user-info?callback=func';
$('body').append(frame);
});
function func(res){
alert(res.message+res.name+'你已经'+res.age+'岁了');
}
</script>
</body>
</html>
server.js
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
console.log('req.url',req.url);
const html = fs.readFileSync('test.html','utf8');
if (req.url === '/') {
res.writeHead(200, {
'Content-Type': 'text/html',
})
res.end(html);
}
}).listen(8888);
server1.js
const http = require('http');
const fs = require('fs');
const url = require('url');
http.createServer((req,res) => {
var parseObj = url.parse(req.url, true);
var callback = parseObj.query.callback;
if (parseObj.pathname === '/user-info') {
res.writeHead(200, {
'Content-Type': 'text/javascript',
})
let data = {
message: 'success!',
name: 'the question',
age: 18
}
data = JSON.stringify(data)
res.end(`${callback}(` + data + `)`);
}
}).listen(8887);
服务端返回数据:
点击按钮获取到服务端返回的数据:
这样在localhost:8888页面访问localhost:8887就不会出现跨域了。
CORS跨域限制以及预请求验证
上面我们说到可以通过设置Access-Control-Allow-Origin头解决跨域问题,但是并不是所有情况都可以通过设置这个头来解决跨域。下面就来讨论一下浏览器跨域请求的其他限制。还是举例说明
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
<script>
fetch('http://localhost:8887', {
method: 'POST',
headers: {
'X-Test-Cors': '123',
}
})
</script>
</html>
server1.js
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
console.log('req come', req.url);
const html = fs.readFileSync('test.html','utf8');
res.writeHead(200,{
'Content-type': 'text/html',
})
res.end(html);
}).listen(8888);
server2.js
const http = require('http');
http.createServer((req,res) => {
console.log('req come', req.url);
res.writeHead(200,{
'Access-Control-Allow-Origin': 'http://127.0.0.1:8888'
})
res.end('1122');
}).listen(8887);
在fetch里面设置了一个自定义的请求header为X-Test-Cors,访问localhost:8888会报以下错误。意思是我们自定义的头在跨域请求的时候是不被允许的。在解决这个问题之前,我先讲一下CORS其他限制以及CORS预请求。
CORS其他的限制:
- 允许的方法:GET、HEAD、POST。其他方法比如PUT、DELETE都是不被允许的,浏览器会有一个预请求去验证的。具体预请求做了什么下面会讲到。
- 允许的Content-type:text/plain,multipart/form-data,application/x-www-form-urlencoded。其他的Content-type也要进行预请求验证后才能进行发送。
- 请求头限制:自定义的请求头是不被允许的,都需要服务端进行预请求验证。下面这些头是允许的。可以参考fetch.spec.whatwg.org/#cors-safel… 。
浏览器为什么要做这些限制呢,是因为它希望在进行跨域操作的时候可以保证服务端的安全。不允许随随便便的请求以及方法都能跨域,不希望某一个跨域请求导致服务器的数据被恶意篡改。提供这些限制后就可以进行判断这个请求是否应该进行响应。
CORS预请求:
在跨域的情况下,非简单请求(比如请求方法为 PUT 或 DELETE、Content-Type 字段类型为 application/json、添加额外的http header)会先发起一次空body的OPTIONS请求,称为"预检"请求,用于向服务器请求权限信息,等预检请求被成功响应后,才发起真正的http请求。从network中可以浏览器首先会发送一个请求,方法是OPTIONS以获得服务端允许发送请求的认可,然后再发送实际的POST请求。这就是浏览器对于跨域请求预请求的操作。
那么浏览器是具体根据什么来判断这个请求的返回是否允许。如果要允许自定义的头在请求里发送,那么服务器需要返回一个新的头Access-Control-Allow-Headers告诉浏览器这个自定义的头是被允许的。
const http = require('http');
http.createServer((req,res) => {
console.log('req come2', req.url);
res.writeHead(200,{
'Access-Control-Allow-Origin': 'http://127.0.0.1:8888',
'Access-Control-Allow-Headers': 'X-Test-Cors',
})
res.end('1122');
}).listen(8887);
把Access-Control-Allow-Headers设置为我们自定义的头后,请求就能正常发送了。
同样我们可以通过Access-Control-Allow-Methods设置允许的请求方法。设置之前是不允许PUT方法的。
res.writeHead(200,{
'Access-Control-Allow-Origin': 'http://127.0.0.1:8888',
'Access-Control-Allow-Headers': 'X-Test-Cors',
'Access-Control-Allow-Methods': 'POST,PUT,Delete'
})
设置之后PUT方法被允许
浏览器的预检请求结果可以通过设置Access-Control-Max-Age进行缓存,Access-Control-Max-Age表示允许跨域的最长时间,这个时间之内不需要再发送预请求验证。可以直接发起正式的请求。
缓存Cache-Control
Cache-Control有以下特性:
- 可缓存性:指定哪些地方可以去执行缓存
- public:指http请求返回过程中在Cache-Control设置了public值,代表这个http请求返回的请求经过的任何路径,包括中间的http代理服务器以及发送请求的浏览器,都可以进行返回内容的缓存操作。
- private:只有发起请求的浏览器才可以缓存。
- no-cache:可以在本地或者代理服务器进行缓存,但是每次都需要去服务器那边验证一下,如果服务器返回告知可以使用本地缓存才可以真正使用本地缓存。
- 到期:缓存到期时间。
- 最常用的是max-age。max-age = seconds(缓存多少s)。
- s-maxage=seconds专门为代理服务器设置的过期时间。
- max-stale=seconds,在max-age过期之后,如果返回的资源里有max-stale设置,发起请求方主动带的头,代表即使缓存过期了,只要在max-stale时间内就还可以使用过期的缓存。
- 重新验证
- must-revalidate:设置了max-age的缓存过期了,必须去源服务端发送请求重新获取这部分资源,再来验证这部分资源是否真的过期了,而不能直接使用本地的缓存。
- proxy-revalidate:与must-revalidate作用差不多,不过是用在缓存服务器。缓存过期后缓存服务器必须要去源服务器获取数据。
- no-store:本地或者代理服务器都不能存储缓存,永远都要去请求服务器请求数据。
- no-transform:用于代理服务器,因为某些代理服务器会对资源进行转换,配置no-transform就是告诉代理服务器不要随便改动返回的内容。
下面通过实例演示一下设置Cache-Control后的实际效果。主要演示设置max-age后浏览器如何从本地读取缓存。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
<script src="/script.js"></script>
</html>
server.js
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
console.log('req come', req.url);
if (req.url === '/') {
const html = fs.readFileSync('test.html','utf8');
res.writeHead(200,{
'Content-type': 'text/html'
})
res.end(html);
}else if (req.url === '/script.js') {
res.writeHead(200,{
'Content-type': 'text/javascript'
})
res.end('console.log("script loaded")');
}
}).listen(8888);
这个过程都是从网络传输过来的。重新刷新还是会从服务器去请求数据。现在设置'Cache-Control': 'max-age=20'。
res.writeHead(200,{
'Content-type': 'text/javascript',
'Cache-Control': 'max-age=20',
})
可以看到是从memory cache中获取资源的。同时获取时间是0,即没有网络延时,速度非常快。后台打印出内容。
为了验证浏览器是从缓存中获取的数据,在缓存过期之前我们修过一下服务端的返回。重启服务,访问页面控制台仍然输出的是script loaded而不是script loaded twice。说明浏览器并不是从服务端获取的数据,而是缓存。
res.end('console.log("script loaded twice")');
缓存验证Last-Modified和Etag使用
下图是缓存的操作过程。浏览器创建请求会先在本地缓存中找,如果本地缓存命中了,直接返回给浏览器。不经过任何网络的传输。如果本地缓存未命中就会往互联网上发送请求,经过代理服务器,代理服务器会查找相关的缓存设置以及查看这个资源是否有缓存。如果命中返回到本地缓存再返回给服务器。如果没有命中就去源服务器请求资源。
如果给Cache-Control设置了no-cache之后,浏览器每一次发起已经设置了Cache-Control资源的请求时,都会去服务器端进行资源的验证,如果确定这个资源可以使用缓存浏览器才会读取资源的缓存。那么如何进行验证呢?在http里主要有两个验证的http头Last-Modified和Etag。
Last-Modified:上次修改时间。配合If-Modified-Since或者If-Unmodified-Since使用。如果请求资源返回的header里面有Last-Modified头指定了一个时间,那么在下一次浏览器发起请求的时候就会带上Last-Modified的值,通过If-Modified-Since或者If-Unmodified-Since带到服务器上。服务器读取到If-Modified-Since这个值对比这个资源上次修改的时间,如果发现这两个时间是一样的,代表资源还没有被重新修改过。服务器告诉浏览器可以直接使用缓存。
Etag:是一个更加严格的验证,通过数据签名进行验证,最典型的方式就是对资源内容进行hash计算。一个资源其内容会生成唯一的签名。如果资源内容修改了其签名也会改变。配合If-Match或者If-Non-Match使用。浏览器发送请求的时候就会带上If-Match或者If-Non-Match,这个头的值就是服务端返回过来的Etag的值。服务器拿到浏览器传过来的头之后对比资源的签名判断是否使用缓存。
接下来就举例子来看下效果。设置头Cache-Control和Last-Modified、Etag
res.writeHead(200,{
'Content-type': 'text/javascript',
'Cache-Control': 'max-age=200000,no-cache',
'Last-Modified': '123',
'Etag': '777'
})
Cache-Control即使是设置了max-age,但是只要设置了no-cache,浏览器每一次发送请求都会发送给服务器。如下图所示每一次请求script资源都是经过了网络传输的,而不是从缓存中获取的。
可以看到Response header里面设置了两个验证的http头Last-Modified和Etag。
浏览器再次请求的时候就会带上对应的验证缓存的这两个头。
此时服务器会去验证请求头If-None-Match是否与自己设置的Etag相同。如果相同就设置状态码为304(Not Modified 资源没有修改)告诉浏览器可以去使用缓存。
const etag = req.headers['if-none-match'];
if (etag === '777') {
res.writeHead(304,{
'Content-type': 'text/javascript',
'Cache-Control': 'max-age=200000,no-cache',
'Last-Modified': '123',
'Etag': '777'
})
res.end('');
}
如果浏览器不是去使用的缓存那么请求的返回应该是空的。但是此时请求的返回有内容。说明浏览器是从缓存中去获取的资源。
如果把no-cache去掉,第二次发送的请求会直接从memory cache中获取,不需要验证。
如果设置no-store会忽略任何跟缓存有关的信息,会认为是一个全新的请求,从服务器获取数据。
'Cache-Control': 'max-age=200000,no-store',
Cookie和Session
Cookie
Cookie是服务端返回数据的时候通过Set-Cookie这个header保存到浏览器里面的一个内容。浏览器保存了这个cookie之后,下一次同域请求时就会带上这个cookie。这样可以实现用户在访问网站的会话当中通过cookie这个内容来保证返回的数据是用户的。浏览器关闭后cookie就没有了。cookie是键值对保存的,可以设置多个。cookie有以下属性。
- max-age和expires设置过期时间
- Secure只在https的时候发送cookie
- 设置HttpOnly后无法通过document.cookie访问cookie,保证用户数据安全
接下来演示一下cookie的使用方式。服务端设置cookie
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
console.log('request com', req.url);
if (req.url === '/') {
const html = fs.readFileSync('test.html','utf8');
res.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': 'id=123'
})
res.end(html);
}
}).listen(8888);
再次访问页面浏览器发送请求时会在请求头上带上cookie。
设置多个cookie:Set-Cookie这个头在Response headers里面是可以重复的。
'Set-Cookie': ['id=123','abc=456']
cookie设置过期时间:设置id=123这个cookie在2s后过期
'Set-Cookie': ['id=123;max-age=2','abc=456']
2s后访问页面浏览器请求头里面只带上来abc这个cookie。
cookie设置HttpOnly
'Set-Cookie': ['id=123;max-age=20','abc=456;HttpOnly']
通过document.cookie只能拿到id=123。
cookie设置domain
cookie可以设定访问域的权限,一般来说当前域写的cookie其他域是拿不到的。比如在a.com网站下面写了一个cookie,在b.com下时访问不到的。对于同一个域名其实cookie有更多的限制方案。比如a.com有一个二级域名test.a.com,可以让test.a.com访问到a.com设置的cookie。就是通过设置domain来实现。
http.createServer((req,res) => {
const host = req.headers.host;
if (req.url === '/') {
const html = fs.readFileSync('test.html','utf8');
if (host === 'a.test.com:8888') {
res.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': ['id=123;max-age=20','abc=456']
})
} else {
res.writeHead(200, {
'Content-Type': 'text/html',
})
}
res.end(html);
}
}).listen(8888);
将a.test.com和b.test.com通过hosts映射到127.0.0.1
访问a.test.com:8888,设置cookie。
访问b.test.com:8888,没有cookie。
这就说明不同域名之间cookie是不能共享的。
a.test.com和b.test.com都是test.com的二级域名,如果想要在test.com一级域名的二级域名下都能获取到cookie,可以设置domain。
http.createServer((req,res) => {
const host = req.headers.host;
if (req.url === '/') {
const html = fs.readFileSync('test.html','utf8');
if (host === 'test.com:8888') {
res.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': ['id=123;max-age=20','abc=456;domain=test.com']
})
} else {
res.writeHead(200, {
'Content-Type': 'text/html',
})
}
res.end(html);
}
}).listen(8888);
同样讲test.com映射到127.0.0.1,访问test.com:8888。可以看到cookie设置成功。
现在访问a.test.com:8888和b.test.com:8888,可以看到有cookie,abc=456。这是因为一级域名test.com下只有abc才设置了domain,因此只有abc这个cookie才是可以共享的。因此二级域名a.test.com和b.test.com都只能拿到一级域名test.com设置的abc这个cookie值。
session
由于HTTP协议是无状态的协议,所以服务端需要记录用户的状态时,就需要用某种机制来识具体的用户,这个机制就是Session.典型的场景比如购物车,当你点击下单按钮时,由于HTTP协议无状态,所以并不知道是哪个用户操作的,所以服务端要为特定的用户创建了特定的Session,用用于标识这个用户,并且跟踪用户,这样才知道购物车里面有几本书。这个Session是保存在服务端的,有一个唯一标识。在服务端保存Session的方法很多,内存、数据库、文件都有。集群的时候也要考虑Session的转移,在大型的网站,一般会有专门的Session服务器集群,用来保存用户会话,这个时候 Session 信息都是放在内存的,使用一些缓存服务比如Memcached之类的来放 Session。服务端如何识别特定的客户?这个时候Cookie就登场了。每次HTTP请求的时候,客户端都会发送相应的Cookie信息到服务端。实际上大多数的应用都是用 Cookie 来实现Session跟踪的,第一次创建Session的时候,服务端会在HTTP协议中告诉客户端,需要在 Cookie 里面记录一个Session ID,以后每次请求把这个会话ID发送到服务器,我就知道你是谁了。有人问,如果客户端的浏览器禁用了 Cookie 怎么办?一般这种情况下,会使用一种叫做URL重写的技术来进行会话跟踪,即每次HTTP交互,URL后面都会被附加上一个诸如 sid=xxxxx 这样的参数,服务端据此来识别用户。Session是在服务端保存的一个数据结构,用来跟踪用户的状态,这个数据可以保存在集群、数据库、文件中。Cookie是客户端保存用户信息的一种机制,用来记录用户的一些信息,也是实现Session的一种方式。
HTTP长连接 connection
HTTP请求是在TCP连接上进行发送的,TCP的连接分为长连接和短连接。HTTP请求发送的时候要先创建一个TCP连接,然后在TCP连接上把HTTP请求发送出去并接收服务器返回。这样一次HTTP请求就结束了,此时服务器会和服务端商量是否把这个TCP连接关掉。如果一直不关闭,那么这个TCP连接开着会有一定消耗,但是如果接下去还有HTTP请求也可以直接在这个TCP连接上进行发送,这样就不需要再经历三次握手的过程。如果直接关闭了,可以减小服务端和浏览器之间的并发连接数。不过下次再发送HTTP请求又要重新去建立TCP连接,那么就会有网络延迟上的开销。在实际情况中,网站的并发量比较大,如果每次HTTP请求都去重新创建TCP连接,创建TCP过程发生的次数太多导致的开销可能大于直接保持长连接的开销。而且长连接是可以设置连接时间的,即过多长时间没有HTTP请求,那么就自动关闭连接。因此一般都是默认保持长连接。可以来看下百度的网页,查看Network,在Name处右击选择Connection ID就能查看每个HTTP的Connection ID。Connection ID代表TCP连接的ID,这样我们就可以区分是否是同一个TCP连接。
可以看到很多请求的Connection ID都是一样的。比如116067,图片请求都是在这个TCP连接上进行发送的。
但是也有很多其他的连接如115692、115695等,域名不一样会去重新创建连接。但是都会尽量去复用之前创建的TCP连接。
这是因为HTTP/1.1的连接在TCP上去发送请求是有先后顺序的,如果有10个请求不能并发的在一个TCP连接上进行发送,在一个TCP上发送请求只会一个接着一个发送。其实我们是希望可以并发进行,这样的话效率会更高。因此浏览器可以允许产生并发的TCP连接,chrome允许一次性并发6个。当达到6个并发限制后,新来的请求要等前面6个完成。
如何让服务保证创建的是长连接而不是短连接呢?在Response Headers可以看到Connection:Keep-Alive就表示创建的TCP连接是长连接。
同时在Request Headers里面也有Connection:Keep-Alive,因为这是一个协商的过程,在发送请求的时候浏览器就希望服务端是保持长连接的。但是服务端返回的时候可以选择保持或者不保持长连接,如果服务器器端选择不保持,那么浏览器端还是会关掉。
下面演示一下如何Connection:Keep-Alive的效果。用node创建一个简单的服务,默认情况下服务器开启了Keep-Alive。
server.js
http.createServer((req,res) => {
console.log('request com', req.url);
const img = fs.readFileSync('test.jpg');
const html = fs.readFileSync('test.html','utf8');
if (req.url === '/') {
res.writeHead(200, {
'Content-Type': 'text/html',
})
res.end(html);
} else {
res.writeHead(200, {
'Content-type': 'image/jpg'
})
res.end(img);
}
}).listen(8881);
test.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<img src="/test1.jpg" alt=''>
<img src="/test2.jpg" alt=''>
<img src="/test3.jpg" alt=''>
<img src="/test4.jpg" alt=''>
<img src="/test5.jpg" alt=''>
<img src="/test6.jpg" alt=''>
<img src="/test7.jpg" alt=''>
</body>
</html>
启动服务,访问localhost:8881。可以看到去请求了7张图片,虽然说是同一种图片,但是请求的url是不一样的,所以浏览器也会去加载7次图片。查看ConnectionId
可以看到有些ConnectionId是一样的,即复用了之前创建的TCP连接。同时又有不同的ConnectionId,这是因为浏览器允许6个并发创建6个TCP连接,那么第七张照片会等待前六张图片请求结束后复用之前的TCP连接。我们可以把网络切换到Fast 3G,查看Waterfall(网络请求分时的过程)
可以看到前6张图片都是同时发送的请求,并且每个ConnectionId都是不同的。第7张图片请求之前会有很长时间的等待,等待有新的TCP连接空出来才发送请求。并且复用了第一张图片发送请求时建立的TCP连接20868。将鼠标放在20868对应的Waterfall上面,可以看到第一个20868有Initial connection(创建TCP连接消耗的时间),而第二个20868上没有Initial connection,说明第七张图片请求时候没有创建TCP连接,而是直接复用第一个。
如果我们关闭Keep-Alive会出现什么情况呢?Connection有两个值,一个是Keep-Alive,另一个时close。close代表一个请求完成之后,这个TCP连接就会被关闭。修改服务器response。
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
console.log('request com', req.url);
const img = fs.readFileSync('test.jpg');
const html = fs.readFileSync('test.html','utf8');
if (req.url === '/') {
res.writeHead(200, {
'Content-Type': 'text/html',
'Connection': 'close',
})
res.end(html);
} else {
res.writeHead(200, {
'Content-type': 'image/jpg',
'Connection': 'close',
})
res.end(img);
}
}).listen(8881);
可以看到关闭长连接以后,每个请求的ConnectionId都是不一样的。即没有重复利用TCP连接,一个请求发送完成后就关闭TCP连接,新的请求过来又重新建立新的TCP连接。正常情况下,现在开发的http服务都是默认开启长连接的,但是可以给keep-alive设置一个关闭时间。在http2增加了信道复用,就是在一个TCP连接上并发的发送http请求,这样就只需要一个TCP连接。大大降低了创建多个TCP连接的开销。goole就采用的http2,通域的请求只有一个TCP连接。
数据协商
所谓数据协商就是客户端发送数据给服务端时,客户端会声明希望拿到的数据格式以及数据相关的限制。服务端可能又会很多不同类型的数据,它会根据客户端发送过来的头信息进行区分返回相应格式的数据。在请求中通过Accept指定想要的数据类型,会根据MIME TYPE的声明来进行限制。Accept-Encoding表示数据以什么样的编码方式进行传输,主要用来限制服务端如何进行数据的压缩。Accept-Language表示返回的数据是什么语言的。User-Agent用来表示浏览器相关信息,可以根据User-Agent来判断返回的是pc端页面还是移动端页面。服务端通过设置Content-Type与Accept相对应,Accept可以接收好几种不同的数据格式,Content-Type可以选择其中一种作为服务器真正返回的数据格式。Content-Encoding对应Accept-Encoding,声明服务端用了哪种压缩,比如gzip。Content-Language表示是否根据请求返回了对应的语言。创建一个简单的服务
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
console.log('request com', req.url);
const html = fs.readFileSync('test.html','utf8');
res.writeHead(200, {
'Content-Type': 'text/html'
})
res.end(html);
}).listen(8888);
访问localhost:8888,查看Request Headers
可以看到Accept声明的信息非常多,那么浏览器就会知道这些格式的数据它都可以进行展示。Accept-Encoding声明了3种比较流行的Encoding方法。Accept-Language是浏览器根据你本系统的语言进行设置的,q表示权重,越大就优先选择该语言进行显示。
User-Agent也有很多信息,包含了系统相关和浏览器相关信息。因为浏览器最开始是网景公司出的,默认的头就是Mozilla/5.0,很多老的服务器都只支持Mozilla/5.0,所以在现在的浏览中还是会默认加上Mozilla/5.0以兼容老的服务器。AppleWetKit是浏览器内核,chrome和safari这些现代浏览器都使用webkit内核。KHTML是指渲染引擎的版本,类似于火狐浏览器渲染引擎Gecko。后面是Chrome版本号、safari版本号。这些信息就可以用来判断返回给用户的页面要不要去适应这些浏览器。
以上就是浏览器发送给服务端的数据协商的一些信息,服务端拿到这些信息之后进行判断,然后返回对应的浏览器想要拿到的信息。
再来看下Response Headers
服务器返回的数据是text/html格式的。服务端还可以返回一个头'X-Content-Type-Options':'nosniff',很早的时候IE浏览器会不接受服务端声明的Content-Type或者认为服务端声明的不对,或者服务端没有声明Content-Type时,它会去预测服务端返回的内容是什么格式的。这样会导致一些安全性问题,比如一些应该以文本显示的代码最后以脚本形式运行了,导致安全信息被泄露了。因此设计X-Content-Type-Options阻止浏览器主动去预测服务端返回的数据格式。
下面看一下Content-Encoding数据压缩方式,
看size栏,423B是数据在整个传输过程中的实际大小,这个大小会包含HTTP的headers和body还有首行的信息。下面258B是body里面的实际内容,是数据拿到之后并根据Content-Encoding解压之后的数据。接下来演示一下使用gzip压缩后,传输数据大小会发生变化,但是下面258B是不会有变化的。
const http = require('http');
const fs = require('fs');
const zlib = require('zlib');
http.createServer((req,res) => {
console.log('request com', req.url);
const html = fs.readFileSync('test.html');
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Encoding': 'gzip'
})
res.end(zlib.gzipSync(html));
}).listen(8888);
可以看到传输数据大小变为362B,因为在传输过程中讲body部分使用gizp压缩过了。可以看到传输数据size还是比body部分size大,这是因为现在传输的数据太小了。如果超过1KB压缩之后实际传输的大小会小于解压后body内容的大小。这就是压缩的好处,可以加快网络传输的速度。服务器可以根据浏览器发送的Accept-Encoding去选择最好的一种压缩方式。
最后讲一下发送请求时候的Content-Type,有时候发送请求会发送一些数据,如提交表单。那么这些数据肯定有对应的格式。如果不声明Content-Type,那么不知道以什么格式发送到服务器。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<form action="/form" method="post" enctype="application/x-www-form-urlencoded">
<input type="text" name="name">
<input type="password" name="password">
<input type="submit">
</form>
</body>
</html>
application/x-www-form-urlencoded是form默认的encType,form表单数据被编码为key/value格式发送到服务器。
multipart/form-data当需要在表单中进行文件上传时,就需要使用该格式,multipart代表这个请求是有多个部分的,这是因为通过表单上传文件的时候必须要把文件拆出来,因为文件是不能作为字符串进行传输的。要作为二进制数据进行传输,如果我们使用之前的方式去拼接字符串的话就不能把文件传输过去。boundary后面的字符串是用来分割提交表单里的每一项。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<form action="/form" id='form' method="post" enctype="multipart/form-data">
<input type="text" name="name">
<input type="password" name="password">
<input type="file" name="file">
<input type="submit">
</form>
<script>
var form = document.getElementById('form');
form.addEventListener('submit',(e) => {
e.preventDefault();
var formData = new FormData(form);
fetch('/form', {
method: 'POST',
body: formData
})
})
</script>
</body>
</html>
服务端在拿到提交的表单之后,会根据头信息以不同的方式去解析传过去的body的每一项的数据。更多Content-type值可以参考:www.runoob.com/http/http-c…
Redirect
请求资源的时候发现该资源已经不在指定的url的位置了。那么服务器就会告诉浏览器请求的资源在什么地方,然后浏览器再去请求新的url获取资源。服务端返回code 302,再在header里面添加Location头。
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
if (req.url === '/') {
res.writeHead(302,{
'Location': '/new'
})
res.end('');
}
if (req.url === '/new') {
res.writeHead(200,{
'Content-Type': 'text/html'
})
res.end('<div>this is content</div>');
}
}).listen(8888);
访问localhost:8888
服务器先返回302,代表浏览器要进行跳转(临时跳转,301永久跳转)。并在响应头中加上Location:/new。浏览器再去请求/new获取资源。
302暂时重定向和301永久重定向:
(1)302重定向每次访问‘/'都要经过服务器的指定跳转路径才能跳转到新的地址。每次都先访问'/'再访问‘/new'。
(2)301指定‘/’永久变成了‘/new',那么就会告诉浏览器下一次再访问‘/’时候直接让浏览器变为'/new',不需要再经过服务器指定跳转地址。之后访问'/'都是直接访问'/new‘。
(3)301对应的资源是from disk cache,从缓存里面去读取。也就是说'/'的请求已经被放到浏览器缓存里面了。因为301定义这个链接永久变成新的路由了,所以浏览器缓存301返回的时候会尽可能长的时间缓存。就算我们把301改成200,浏览器还是会重定向到'/new',因为它是从缓存中去取的。除非用户自己清除缓存。
CSP(Content-Security-Policy)
在http协议中为了让网站更加安全,会限制资源获取。资源从哪里获取以及请求发送到哪个地方,都可以通过CSP进行限制。如果获取了不该获取的资源,CSP会报告资源获取越权。它的限制方式是:首先通过default-src限制全局的所有跟链接请求有关的都可以去限制它的作用范围。然后可以根据特定的资源类型进行限制。
跟外链有关的都可以进行资源类型的限制。比如connect-src: 代表请求发向的目标可以进行限制。 img-src: 图片可以从哪几个网址进行加载。style-src/script-src:样式/脚本资源可以从哪几个网址进行加载。接下来看一下这个头是如何生效的。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=<s>, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script>
console.log('inline js');
</script>
</body>
</html>
在html里面直接写脚本,出于安全考虑我们不希望在页面直接执行写在页面的脚本。因为xss攻击就是通过某些方法在网站里面注入一些脚本导致页面出现问题甚至是窃取用户的信息。嵌入进来的script很可能就是通过网页中的富文本编辑器插入的。因此我们想要把在页面中直接写脚本的方式限制掉。只通过外链加载脚本。在服务端可以写入如下Content-Security-Policy头。
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
console.log('request com', req.url);
const html = fs.readFileSync('test.html','utf8');
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Security-Policy': 'default-src http: https:'
})
res.end(html);
}).listen(8888);
启动服务访问localhost:8888,会出现以下报错信息。
通过外链的方式加载js。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=<s>, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script src="/test.js"></script>
</body>
</html>
const http = require('http');
const fs = require('fs');
http.createServer((req,res) => {
console.log('request com', req.url);
const html = fs.readFileSync('test.html','utf8');
if (req.url === '/') {
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Security-Policy': 'default-src http: https:'
})
res.end(html);
} else {
res.writeHead(200, {
'Content-Type': 'application/javascript',
})
res.end('console.log("loading srcipt")');
}
}).listen(8888);
后台没有报错了,并打印出脚本执行结果。
处理限制通过外链来加载脚本,还可以限制外链的域名。比如只能假装本域名下的js可以把default-src设置为self。
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Security-Policy': 'default-src \'self\''
})
现在去加载一个非本域下的js,会报错,而且资源status是blocked:csp。浏览器端就限制了该请求。
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
还可以限制指定某一个网站。比如设置允许加载cdn.bootcdn.net/ 下面的内容。
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Security-Policy': 'default-src \'self\' https://cdn.bootcdn.net/'
})
现在加载cdn.bootcdn.net/ajax/libs/j… 后台就不会报错了。
但是设置default-src对表单提交不管用,可以按如下设置限制表单提交的action。
server.js:
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Security-Policy': 'default-src \'self\';form-action \'self\''
})
html:
<form action="http://www.baidu.com">
<button type="submit">click me</button>
</form>
点击按钮后台报错。
CSP更多设置可以参考:developer.mozilla.org/en-US/docs/…
如果出现了我们不希望加载的资源,可以让csp主动向服务器发送请求汇报。report uri /report(服务器路径)。可以看到浏览器向服务器发送了一个report请求。
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Security-Policy': 'default-src \'self\';form-action \'self\'; report-uri /report'
})
csp还可以通过在html中写来使用。效果跟服务端设置是一样的。
<meta http-equiv="Content-Security-Policy" content="default-src 'self';form-action 'self'; report-uri /report">
HTTPS
HTTPS其实就是HTTP+Security。HTTP是明文传输,在互联网中的每一层如果有人拦截到发送的数据包,就可以解析读取数据包里的任何信息,数据包相当于是裸奔。再使用抓包工具Wireshark抓包http网站。
可以看到Hypertext Transfer Protocol即HTTP,它里面所有信息都能看到,包括get请求的地址、Host、头信息。甚至可以看到cookie的值,cookie很多时候会用来存储用户的会话信息。如果这个数据包被人截取了,就可以用这个cookie去模仿用户的登录,请求用户的数据。
上面是服务器的响应,数据通过gzip压缩了。但是我们可以拿到Data chunk中的数据把它解压出来。所以http协议是没有安全属性的。那为什么https就是安全的呢?https有公钥和私钥,公钥是放在互联网上所有人都可以拿到的一串加密的字符串。这个加密字符串是用来加密我们传输的信息的。使用公钥加密的数据传送到服务器后,只有服务器通过私钥解密才能把数据解密出来。中间任何人都拿不到这个私钥,因为私钥是放在服务器的。所以即便是截取了数据包也解密不了,这样发送数据的过程就变得安全的。
公钥、私钥是用在握手的时候进行传输。https握手过程如下:
在这个握手的过程中客户端会发起一个Client Hello,生成一个随机数传输到服务端,中间会带上客户端支持的加密套件。
服务端拿到这个随机数之后会先存起来,服务端也会生成一个随机数。同时服务端会选择一种最好的加密套件(Cipher Suite)返回给客户端。
服务端还会继续发送一个返回,告诉客户端公钥(证书)。
客户端拿到这个证书之后就去生成一个预主秘钥传输给服务器,它也是一个加密后的随机数(Encrypted Handshake Message)。
这个过程就是没有办法被中间人解析的过程。服务端拿到这个随机数之后通过私钥解密才能拿到真正有用的东西。客户端和服务端同时使用一个加密套件对这三个随机数进行算法操作生成主秘钥。这个主秘钥也是中间人无法破解的。有了主秘钥之后,后期传输的数据都是通过这个主秘钥加密的。所以保证了数据传输的安全。使用抓包工具Wireshark抓包https网站。
抓取工具没有办法看到请求的地址是什么。拿到的只有一个Secure Sockets Layer,里面的内容是加密后的数据。因为没有公钥所以是没有办法解密的。只有客户端和服务端通过主秘钥解密后才能拿到传输的数据。这样就保证了数据的安全性。
写在最后
以上是我总结的一些我认为比较重要的http协议知识点。最后用一张图总结一下浏览器输入URL后HTTP请求返回的完整过程。
在地址栏输入url点击回车,有可能已经存在服务器返回过301让浏览器重定向到新页面的情况,浏览器已经记录过了。所以对于这样的请求一开始就会进行Redirect的操作,这是纯客户端的行为。因此图中第一个节点就是Redirect。第二步浏览器会根据这个资源是否被缓存,如果被缓存就应用缓存,否则到第三步。第三步是DNS解析,找到域名对应的服务器ip。第四步创建TCP链接。第五步发送请求。第六步接收服务器响应。