导语

一屏长文,更深入的了解HTTP协议。对于入门前端不久的同学来说,可能学习前端,就从HTML,CSS,JS学起,然后再入手一个框架,但对于http的理解可能还仅在知道一些面试中关于http的考题或比较少在代码层面去真正理解一些理论的知识,看完本篇希望你能对http有一个较为深入的理解,并且能在开发中对你有所帮助

进入HTTP

http经典图

 浏览器输入URL后HTTP请求返回的完整过程

网络协议分层

经典五层模型
模型图

后续小节我们会涉及到的知识点就是应用层和传输层。

  • 物理层:主要作用就是定义物理设备如何传输数据
  • 数据链路层:在通信的实体间建立数据链路连接
  • 网络层:在节点之间传输创建逻辑链路

传输层

它旨在向用户提供可靠的端到端的服务,数据传输过程可能涉及到分片分包等,以及传输过去如何组装等,这个无需让开发者来做,因此传输层向高层屏蔽了下层数据通信的细节。正因为如此,理解传输层的细节能够让我们实现一个性能更高HTTP实现方式

应用层

它帮我们实现了http协议,为应用层提供了很多服务,并且构建与TCP协议之上,屏蔽网络传输相关细节

http的三次握手

http只有请求和响应的概念,创建连接是属于TCP的操作,而连接的请求和响应是在tcp连接之上的。这是新手很容搞混的一点。在http1.1中连接可以保持,这样的好处是因为http三次握手是有开销的。http2.0中请求可以在同一个tcp连接中并发,也是大大节省了建立连接的开销。具体后续将详讲,现在说回http三次握手,如下图

首先客户端发送一个要创建连接的数据包请求到服务端,包含一个标志位SYN=1和seq=Y。
然后服务端会开启一个TCP的socket端口,返回一个标志位SYN=1,确认位ACK=x+1和seq=y的数据包
最后客户端再发送一个ACK=Y+1,Seq=Z的数据包到服务端

这就是HTTP的三次握手全过程,三次握手的原因是防止服务端开启一些无用连接,因为网络连接是有延迟的,如果没有第三次连接,由于网络延迟,客户端关闭了连接,而服务端一直在等待客户端请求发送过来,这就造成了资源浪费,有了三次握手,就能确认请求发送和响应请求没有问题。

HTTP报文

HTTP报文格式图

请求报文中首行包括一些请求方法 请求资源地址和http协议版本。
响应报文中首行包括协议版本、http状态码和状态码含义等

HTTP方法

用来定义对于资源的操作

  • HTTP方法:GET, POST,HEAD,OPTIONS,PUT,DELETE,TRACE和CONNECT
  • GET: 通常用于请求服务器发送某些资源
  • HEAD: 请求资源的头部信息, 并且这些头部与 HTTP GET 方法请求时返回的一致. 该请求方法的一个使用场景是在下载一个大文件前先获取其大小再决定是否要下载, 以此可以节约带宽资源
  • OPTIONS: 用于获取目的资源所支持的通信选项
  • POST: 发送数据给服务器
  • PUT: 用于新增资源或者使用请求中的有效负载替换目标资源的表现形式
  • DELETE: 用于删除指定的资源
  • PATCH: 用于对资源进行部分修改
  • CONNECT: HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器
  • TRACE: 回显服务器收到的请求,主要用于测试或诊断

参考:面试官(9):可能是全网最全的http面试答案

Http Code码

2XX 成功

  • 200 OK,表示从客户端发来的请求在服务器端被正确处理
  • 201 Created 请求已经被实现,而且有一个新的资源已经依据请求的需要而建立
  • 202 Accepted 请求已接受,但是还没执行,不保证完成请求
  • 204 No content,表示请求成功,但响应报文不含实体的主体部分
  • 206 Partial Content,进行范围请求

3XX 重定向

  • 301 moved permanently,永久性重定向,表示资源已被分配了新的 URL
  • 302 found,临时性重定向,表示资源临时被分配了新的 URL
  • 303 see other,表示资源存在着另一个 URL,应使用 GET 方法丁香获取资源
  • 304 not modified,表示服务器允许访问资源,但因发生请求未满足条件的情况
  • 307 temporary redirect,临时重定向,和302含义相同

4XX 客户端错误

  • 400 bad request,请求报文存在语法错误
  • 401 unauthorized,表示发送的请求需要有通过 HTTP 认证的认证信息
  • 403 forbidden,表示对请求资源的访问被服务器拒绝
  • 404 not found,表示在服务器上没有找到请求的资源
  • 408 Request timeout, 客户端请求超时
  • 409 Confict, 请求的资源可能引起冲突

5XX 服务器错误

  • 500 internal sever error,表示服务器端在执行请求时发生了错误
  • 501 Not Implemented 请求超出服务器能力范围,例如服务器不支持当前请求所需要的某个功能,或者请求是服务器不支持的某个方法
  • 503 service unavailable,表明服务器暂时处于超负载或正在停机维护,无法处理请求
  • 505 http version not supported 服务器不支持,或者拒绝支持在请求中使用的 HTTP 版本

通过node创建一个简单的node服务

server.js

const http = require('http')

http.createServer(function(request, response) {
    console.log('request come',request.url)

    response.end('hello world')
}).listen(8888)

console.log('server.listening on 8888')

终端进入到server.js文件下,执行node server.js 浏览器输入localhost:8888,即可看见'hello world'

HTTP特性总览

浏览器就是最常见的客户端,浏览器为了保证数据传输的安全性,具有同源策略,所谓同源是指:域名、协议、端口相同

同源策略又可以分为以下两种:

  • DOM同源策略:禁止对不同源页面DOM进行操作。这里主要场景就是iframe跨域的情况,不同域名的iframe是限制互相访问的
  • XMLHttpRequest同源策略: 静止使用XHR对象向不同源的服务器发起HTTP请求

了解了浏览器同源策略的作用,如果不同源发出请求,就会产生跨域。但是在实际开发中,我们很多时候需要突破这样的限制,方法有以下几种(后面会有方法实践):

  • JSONP: 利用script的src标签不受同源限制,动态创建script标签
  • CORS: 服务端设置access-allow-origin
  • 通过window.name跨域
  • 通过document.domain
  • 通过Html5的postMessage

跨域知识详细可参考前端跨域整理
通过代码来看下具体是怎么样的

cors跨域

创建server.js

const http = require('http')
const fs = require('fs')

http.createServer(function (request, response) {
    console.log('request come', request.url)

    const html = fs.readFileSync('test.html','utf8')
    response.writeHead(200, {
        'Content-Type': 'text/html'
    })
    response.end(html)

}).listen(8888)

server.js同目录下创建hello.html,js代码如下(地址换成自己电脑ip地址)

var xhr = new XMLHttpRequest()

xhr.open('GET','http://0.0.0.0:8887')

xhr.send()

同目录下创建server2.js

const http = require('http')

http.createServer(function (request, response) {
    console.log('request come',request.url)
    response.end('hello world')
}).listen(8887)

console.log('server listening on 8887')

分别启动server.js和server2.js,并在浏览器输入localhost:8888

跨域

解决方案:在server2.js中加入

response.writeHead(200, {
    'Access-Control-Allow-Origin': '*'
})

跨域请求成功
<font color='red'> 注意 </font>:当我们没有加跨域请求头的时候,可以发现服务端(也就是运行server2.js的终端)依然能收到请求,只是返回的内容在浏览器端没有接收到,因此跨域并不是发不出请求,只是返回的内容被浏览器拦截了而已

CORS跨域限制以及预检请求验证

修改hello.html,js改为

fetch('http://192.168.0.106:8887/', {
    method: 'POST',
    headers: {
        'Test-Cors': '123'
    }
})

浏览器访问localhost:8888,出现

请求头不允许

原因是什么呢,且听我慢慢道来
浏览器的请求在跨域的时候默认允许的方法为
GET、HEAD、POST,其他方法不允许,需要有预检请求

  • 允许的Content-Type为
  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

其他Type也需要预检请求
其他限制包括header 详见[默认允许header](),XMLHttpRequestUpload对象均没有注册任何事件监听器以及请求中没有使用ReadableStream对象。后两个实际接触不多,可以不深究
说回预检请求,先看下图

注意:新版chorme浏览器改了,在network里面看不到了,换个浏览器

如果我们需要这个请求头,在server2.js中的response.writeHead里面添加
'Access-Control-Allow-Headers': 'X-Test-Cors'
同理,如果需要添加允许的方法,可以添加
'Access-Control-Allow-Headers': 'Delete,PUT'
如果我们希望在某一段时间内发送的跨域请求不再发送预检请求,可以在response.writeHead中设置
'Access-Control-Max-Age': '100'

JSONP跨域

去掉server.js中的请求头,并修改hello.html中js为
<script src="http://192.168.0.107:8887/"></script>
这就是一个简单的jsonp跨域,具体的可以参考上面的跨域文章

浏览器的缓存

为了减少请求,加快页面访问速度。开发者可以根据需要对资源进行缓存。分为强缓存和协商缓存,通过http首部字段进行设置

强缓存

Expires是一个绝对时间,即服务器时间。浏览器检查当前时间,如果还没到失效时间就直接使用缓存
但是该方法存在一个问题:服务器时间与客户端时间可能不一致。因此该字段已经很少使用

cache-control中的max-age保存一个相对时间。例如Cache-Control: max-age = 484200,表示浏览器收到文件,缓存在484200S内均有效。如果同时存在cache-control和Expires,浏览器总是优先使用cache-control

协商缓存

last-modified是第一次请求资源时,服务器返回的字段,表示最后一段更新的时间。下一次浏览器
请求资源时就发送if-modified-since字段。服务器用本地last-modified时间与if-modified-since
时间比较,如果不一致则认为缓存已过期并返回新资源给浏览器;如果时间一致则发送304状态码,让浏览器
继续使用缓存

Etag 资源的实体标识(哈希字符串),当资源内容更新时,Etag会改变。服务器会判断Etag是否发送变化
如果变化则返回新资源,否则返回304

接下来我们详细看下Cache-Control

  1. 可缓存性

public、private、no-cache、no-store

  • public指的是http返回的内容所经过的任何路径(包括代理服务器和客户端浏览器)当中都可以被缓存
  • private指的是只有发起请求的浏览器才可以缓存
  • no-cache可以在本地缓存,可以在代理服务器缓存,但是这个缓存要服务器验证才可以使用
  • no-store 彻底得禁用缓冲,本地和代理服务器都不缓冲,每次都从服务器获取
  1. 到期

指的缓存时间,最常用的就是max-age,单位是秒,指的就是缓存的有效期是多长时间
s-max-age这个是代理服务器的缓存时间,只在代理服务器生效

  1. 重新验证

must-revalidate如果设置的缓存已经过期了,必须去原服务端请求,然后重新验证数据是否已经过期
proxy-revalidate应用于代理服务器缓存
理论说完了,接下来我们通过实战看看
修改test.html,js部分修改为
<script src="./script.js"></script>

修改server.js

const http = require('http')
const fs = require('fs')

http.createServer(function (request, response) {
    console.log('request come',request.url)
    if (request.url == '/') {
        const html = fs.readFileSync('test.html', 'utf8')
        response.writeHead(200, {
            'Content-Type': 'text/html'
        })
        response.end(html)
    }

    if (request.url == '/script.js') {
            response.writeHead(200, {
                'Content-Type': 'text/javascript',
                'Cache-Control':'max-age=2020',
               // 'Last-Modified': '2020',
                //'Etag': '20200217'
            })
        response.end('console.log("script loaded")')
    }
    
}).listen(8888)

console.log('server start on the 8888')

打开开发者工具,我们可以看到scripts第一次加载之后,再请求就会从缓存中获取,看下图黄色圈中部分,注意需要把红色勾选去掉
缓存加载图

再看下响应的header

响应header图

如果没有设置缓存,每次请求都会从服务器获取。需要验证可以自行测试下

缓存命中可以查看这张图

缓存命中图

协商缓存验证头(Last-Modified,Etag)

现在我们并不是真正需要验证资源,而是为了验证浏览器是否会把验证头带过来,因此我们可以随便设个Last-Modified和Etag,在server.js中修改response.writeHead

 response.writeHead(200, {
    'Content-Type': 'text/javascript',
    'Cache-Control':'max-age=2020, no-cache',
    'Last-Modified': '2020',
    'Etag': '20200217'
})

启动服务,下图是第一次请求,可以看到响应头里面有Last-Modify和Etag

第一次请求带有Last-modify和Etag

再发送请求,可以看到在Request Headers中出现,if-Modified-since和if-None-Match

第二次请求带有if-Modified-since和if-None-Match

到这里还没有结束,当我们验证缓存完,如果还没有过期,我们希望直接拿缓存,但是我们再看下我们的response

由图发现response中还是有资源返回,并且code码是200,这是为啥呢,原因很简单,我们在服务端还没有对if-Modified-since和if-None-Match进行处理,我们把server.jshttp.createServer修改为

http.createServer(function (request, response) {
    console.log('request come',request.url)
    if (request.url == '/') {
        const html = fs.readFileSync('test.html', 'utf8')
        response.writeHead(200, {
            'Content-Type': 'text/html'
        })
        response.end(html)
    }

    if (request.url == '/script.js') {
        const etag = request.headers['if-none-match']
        if (etag === '20200217') {
            response.writeHead(304, {
                'Content-Type': 'text/javascript',
                'Cache-Control': 'max-age=2020,no-cache',
                'Last-Modified': '2020',
                'Etag': '20200217'
            })
            response.end('')
        } else {
            response.writeHead(200, {
                'Content-Type': 'text/javascript',
                'Cache-Control': 'max-age=2020,no-cache',
                'Last-Modified': '2020',
                'Etag': '20200217'
            })
            response.end('console.log("script loaded twice")')
        }
    }
    
}).listen(8888)

不管是否需要传资源,我们都要在最后response.end,不然本次请求一直没有结束。修改完之后,我们可以看到请求code码变成了304,时间缩短了,但是在response中还是有资源,这又是什么情况,这时候我们确实成功验证了缓存,并拿取的是缓存资源,在浏览器的response中,浏览器会自动把拿到的缓存资源显示出来,并没有在服务器获取。如果需要验证,可以自行在第一个response.end中添加其他内容,再看浏览器接口的response

刚才让浏览器去做协商缓存,是因为我们设置了no-cahce,我们把no-cache删除,浏览器应该是直接拿缓存(因为我们设置的max-age=2020),验证之前,我们得在刚才打开的页面去清楚浏览器的缓存,然后删除代码中的no-cache,重复刷新,都可以看到script.js 是from mermory cache。no-store也可再自行验证下

最后再提一下关于last-modify和Etag,last-modify我们可以在把数据库取出的时候,拿取一个时间,最为数据的update time.Etag的话,数据取出的时候做个数据签名,存入Etag

cookie和session

http是不保存状态的协议,因此我们需要一个身份能来证明访问服务器的是谁,这里我们用到的就是cookie和session

  • cookie的特性:通过Set-Cookie设置、下次请求会自动带上、键值对形式,可以设置多个
  • cookie的属性:max-age和expires设置过期时间、HttpOnly无法通过document.cookie访问

接下来通过代码看下cookie,在server.js中修改response.writeHead

{
    'Content-Type': 'text/html',
    'Set-Cookie': ['id=123;max-age=2','time=2020']
}

启动服务后,可以在application中的cookie看到两个cookie或者network中的接口中。id=123这个cookie设置了过期时间,过一会儿再刷新可以看到id=123这个cookie消失了

前面说过,cookie跨域不共享,但是如果我想一级域名下的二级域名共享cookie,这时候我可以通过设置document.domain来实现,具体如下


{
    'Content-Type': 'text/html',
    'Set-Cookie': ['id=123;max-age=2','time=2020;domain=test.com']
}

修改后,可添加host自行验证下

HTTP长连接

长连接指的是在一次请求完成后,是否要关闭TCP连接。如果TCP连接一直开着,会有一定资源消耗,但是如果还有请求,就可以继续在本次TCP连接上发送,这样可以不用再三次握手,节省了时间。实际情况中,网站并发量比较大,因此是保持长连接的,并且长连接是可以设置超时时间的,如果在这个时间里都没有发送请求了,那么连接就会关闭

接下来我们可以分析下实际场景,以百度首页为例,打开开发者面板,然后network中,右击name属性,勾选Connection ID
我们看到大部分连接都有复用,在http1.1中,一个域名下最大TCP连接数为6个(Chorme),因此刚开始的时候会一下创建6个连接,后面的请求会复用这些连接。

通过代码来验证下这部分内容,首先创建一个test.html

<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="">
    <img src="/test8.jpg" alt="">
</body>  

新建server.js

const http = require('http')
const fs = require('fs')

http.createServer(function (request, response) {
    console.log('request come',request.url)
    const html = fs.readFileSync('test.html', 'utf8')
    const img = fs.readFileSync('timg.jpg')
    if (request.url === '/') {
        response.writeHead(200, {
            'Content-Type': 'text/html',
            // 'Connection': 'close'
        })
        response.end(html)
    } else {
        response.writeHead(200, {
            'Content-Type': 'image/jpg',
            // 'Connection': 'close'
        })
        response.end(img)
    }
    
}).listen(8888)

console.log('server start on the 8888')  

启动服务
加载时序图

可以看下Waterfall,网络请求分时过程。如果需要关闭长连接,Connection的值可以写为close
这里再简单提下http2.0现在使用信道复用技术,只需要创建一个TCP连接,所有同域下请求都可以并发。如果要使用http2.0,需要保证请求时https协议,并且后端需要做较大的改变,因此现在http2.0的使用目前还没大面积

Redirect

当我们通过url去访问一个资源的时候,该资源已经不再url指定的位置了,服务器应通知客户端该资源现在所处的位置,浏览器再去请求该资源。
通过代码来看下,新建一个server.js

const http = require('http')
const fs = require('fs')

http.createServer(function (request, response) {
    console.log('request comme', request.url)
    if (request.url === '/') {
        response.writeHead(302, {
            'Location': '/new'
        })
        response.end()
    }
    if (request.url === '/new') {
        response.writeHead(200, {
            'Content-Type': 'text/html'
        })
        response.end('<div>hello world</div>')
    }
}).listen(8888)

console.log('server listening on 8888')

此处测试是在同域的情况下,所以只写了一个路由,如果不相同,则把真正的地址替换/new.启动服务,输入localhost:8888之后,会直接跳转到资源真正的位置,并且在network中也可查看发现,除了图标,有两个请求。

代码中我们写的code码是302,如果我们改成200,就会发现没有办法重定向。302是临时重定向,301是永久重定向,前面我们已经说过。如果我们把上面的302code码改成301,我们会在终端中发现,除了第一次,不管我们后面再输入localhost:8888多少次,终端打印请求都只有重定向后的请求,只是因为浏览器记住了原地址被永久重定向了,所以,不会向原路径发起请求。在实际开发中,应当谨慎使用永久重定向,因为一旦永久重定向了,会在浏览器尽可能长的时间保留定向后的资源路径而不会请求原路径

结束语

本次分享目的是通过代码来把原来我们知道的一些知识点可以再深入一些,梳理好Http知识的来龙去脉。希望能对一些小伙伴有所帮助,如果大家喜欢我的行文风格的话,我接下来将带入web 服务器Nginx的一些实战,在实际开发中我们会用nginx做代理和一些cache,因此作为一个http服务,掌握它当然也不可或缺


想象你的身影
21 声望1 粉丝

学生,js全栈学习者