前端网络

137 阅读1小时+

基本概念

客户端与服务器

在网络的世界中,两个应用程序之间时常会发生通信

通信总是从一方发出一个消息开始,到另一方回复一个消息结束

发出消息的一方称之为客户端Client,发出消息的过程称之为请求Request

回复消息的一方称之为服务器Server,回复消息的过程称之为响应Response

image.png

注意:

  • 这里的客户端和服务器,指的不是计算机,而是计算机中的某个应用程序

    客户端和服务器可以分布在不同的计算机上,也可以处于同一台计算机上

  • 客户端和服务器的这种交互方式称为C/S模式

    如果客户端是浏览器,则也可称为B/S模式

  • 一次完整的通信,一定是客户端先向服务器发出请求,服务器再对该请求进行响应

url

url(uniform resource locator),统一资源定位符,它用于表示互联网中某个资源的具体位置

一个完整的url由以下几个部分构成:

  1. 协议
  2. 主机
  3. 端口号
  4. 路径
  5. 参数
  6. hash

注意:

  • 前4个部分是url中必须存在的,后2个是可以没有的

  • url中只允许出现ASCII字符,若出现了非ASCII字符,则客户端会自动将其用某种编码进行替换

    如:在浏览器的地址栏中输入https://www.baidu.com/s?wd=前端开发,则其会被浏览器转换为https://www.baidu.com/s?wd=%E5%89%8D%E7%AB%AF%E5%BC%80%E5%8F%91

协议(Protocal / Schema)

HTTP协议定义了客户端怎样向服务器请求资源,以及服务器怎样把资源传送给客户端

扩展:

  1. 如果在浏览器的地址栏中省略了协议,浏览器会自动补全协议

  2. https协议比http协议更加安全

    https协议往往出现在线上,本地服务器通常不会使用https协议

主机(Host)

它表示客户端希望在哪台计算机上寻找资源

主机部分有两种表示方法:

  1. IP地址

    特殊的IP地址:127.0.0.1,表示本机

  2. 域名

    域名是IP地址的别名,它把不容易记忆的数字变为了容易记忆的单词

    当使用域名进行访问时,会自动转换为IP地址

    特殊的域名:localhost,表示本机

端口号(Port)

它表示客户端希望在哪个应用程序中寻找资源

若url中不填写端口号,则使用的是协议的默认端口:

  • http协议的默认端口号为80
  • https协议的默认端口号为443
路径(Path)

服务器程序往往有许多的资源,每个资源都有自己的访问路径

若url中不填写路径,则路径为/

参数(Query)

url中可以没有参数

某些资源可以根据参数的不同提供不同的内容

例如:获取某个新闻列表的第5页中的10条新闻时,可以在url提供如下参数:

http://www.mysite.com/news?page=5&limit=10
Hash

hash往往作为浏览器的锚链接出现

url中可以没有hash

http协议

http是基于请求-响应的方式完成通信的,每一次通信都是由客户端向服务器发出请求,传递一些消息过去,然后经过服务器程序的处理后,响应给客户端一些消息

http协议规定:

  1. 每一次请求-响应都是独立的,相互之间互不干扰,因此http协议是一种无状态协议
  2. 每次请求-响应传递的消息都是纯文本,而且文本格式必须按照http协议规定的格式书写

请求的消息格式

请求消息往往由三个部分组成:

  1. 请求行

  2. 请求头

  3. 请求体

    请求体是可以省略的

POST /api/user/login HTTP/1.1
HOST: www.mysite.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/79.0.88 Safari/537.36
Origin: http://localhost:5500
Content-Type: application/json

{"loginId": "admin", "loginPwd": "123123"}
请求行

请求行是整个http请求报文的第一行字符串,它包含三个部分:请求方法 路径+参数+hash 协议/版本

请求方法是一个单词,它表达了客户端所要进行的动作,例如:

  • GET:获取
  • POST:提交
  • PUT:修改
  • DELETE:删除
  • ...

http协议中没有规定在实际进行某种动作时,一定要使用对应的请求方法

比如想要提交某个数据时,仍将请求消息中的请求方法设置为GET也是允许的

不过在实际开发中,一般会按根据动作的含义使用相应的请求方法

当请求行中的请求方法为GET或DELETE时,一般不会附带请求体,当需要传递某些参数时,应该将参数作为请求的url中的query字符串存在

当请求行中的请求方法为POST或PUT时,通常就会将要传递的参数通过请求体进行传递,因此一般存在请求体

请求头

请求头由一系列的键值对组成

浏览器诶次请求服务器时都会自动在请求消息中添加很多请求头

常见的请求头:

  1. Host

    值为url的主机+端口部分

    当端口号使用的是默认端口时,端口将被省略

  2. User-Agent

    值为发送请求的客户端的名称

    对于浏览器客户端,该字段一般都是不准确的(是固定这么做的)

  3. Content-type

    值为请求体的格式类型

    如果请求消息中没有请求消息,则该字段无意义

    常见的字段值有:

    ① application/x-www-form-urlencoded

    表示请求体的数据格式与url地址中那个的参数的格式一样,例如:

    loginId=admin&loginPwd=123123
    

    ② application/json

    ​ 表示请求体的数据格式是JSON格式,例如:

    {"loginId": "admin", "loginPwd": "123123"}
    
请求体

请求体的格式应该与请求头中的Content-type字段的值保持一致,这样服务器才能正确解析该请求体

当在HTML中使用form元素提交数据时

若form元素的method属性设置为"get",则form元素内的input元素的name属性将会作为url中query的键名,input元素的value将会作为query中对应键的值

<form action="https://www.baidu.com" method="get">
     <input type="text" name="id" value="111">
     <input type="text" name="pwd" value="222">
     <button>发送</button>
</form>

点击发送按钮后,会向服务器发送如下的请求消息:

GET /?id=111&pwd=222 HTTP/1.1
Host: www.baidu.com

若form元素的method属性设置为"post",则form元素内的input元素的name属性将会作为请求消息中请求体的一个键值对中的键,input元素的value将会作为该键值对的值

<form action="https://www.baidu.com" method="post">
 <input type="text" name="id" value="111">
 <input type="text" name="pwd" value="222">
 <button>发送</button>
</form>

点击发送按钮后,会向服务器发送如下的请求消息:

POST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Host: www.baidu.com

id=111&pwd=222

响应的消息格式

响应消息往往由三个部分组成:

  1. 响应行

  2. 响应头

  3. 响应体

HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Server: BWS/1.1

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Document</title>
</head>
<body>
	<p>Hello World</p>
</body>
</html>
响应行

响应行是整个响应字符串的第一行

响应行由三个部分组成:协议/版本 状态码 状态消息

通常情况下,状态码和状态消息是一一对应的,如状态码为200时状态消息就是OK

状态码分为5大类:

  1. 以1开头:100 ~ 199

    通知信息,表示服务器已经收到当前的请求并正在处理该请求中

  2. 以2开头:200 ~ 299

    成功,请求被服务器接受并处理完成

  3. 以3开头:300 ~ 399

    重定向,需要客户端进一步的操作才能完成请求

  4. 以4开头:400 ~ 499

    客户端错误,请求消息中存在错误的语法或请求根本不能完成

  5. 以5开头:500 ~599

    服务器错误,服务器在处理请求的过程中发生了错误

常见的状态码:

  1. 200 OK

    请求成功

  2. ‌301 Moved Permanently

    资源的URL已永久更改

    资源的新URL会在响应头中的Location字段中进行指明

    当浏览器第一次收到了针对请求某个URL的状态码为301的响应,浏览器会自动从该响应消息的响应头中读取Location字段的内容,即永久重定向后的URL,并重新对该URL进行请求,同时将该URL缓存起来,之后浏览器若再次针对之前的旧URL进行请求,都会直接转变为请求缓存起来的永久重定向后的URL

  3. 302 Found

    资源的URL临时被更改

    资源的新URL会在响应头中的Location字段中进行指明

    每当浏览器收到了一个状态码为302的响应消息时,浏览器都会自动从该响应消息的响应头中读取Location字段的内容,即临时重定向后的URL,并重新对该URL进行请求

  4. ‌304 Not Modified‌

    资源内容未改变,客户端可继续使用之前缓存起来的结果

  5. ‌400 Bad Request‌

    请求语法错误或包含服务器无法理解的内容

  6. 403 Forbidden

    服务器理解请求但拒绝执行

  7. ‌404 Not Found‌

    请求的资源不存在‌

  8. ‌500 Internal Server Error‌

    服务器内部错误,无法完成请求

响应头

和请求头一样,响应头也是由很多键值对组成的

响应头中也包含一个很重要的字段Content-Type,它表示响应体的内容是什么形式的数据,浏览器会根据该字段的值对响应体进行不同的处理

  • text/plain

    普通的纯文本,浏览器会原封不同地将响应体中的内容显示在页面上

  • text/html

    html文档,浏览器会将响应体中的内容作为页面进行渲染

  • text/javascript或application/javascript

    js代码,浏览器会使用JS执行引擎来解析并执行响应体中的内容

  • text/css

    css代码,浏览器会将它视为样式

  • image/jpeg

    浏览器会将它视为jpg图片,并显示在页面上

响应体

响应的主体内容,内容的形式应该与响应头中Content-Type字段的值保持一致

浏览器的页面处理流程

问:当在浏览器地址栏中输入一个url地址,并按下回车后,会发生什么

答:

  1. 浏览器自动补全url

    若url中没有协议字段,则浏览器会自动为其添加协议

  2. 浏览器会对url中的非ASCII码字符进行编码

    url字符串中只能出现ASCII字符

  3. 浏览器根据url地址查找本地缓存,若缓存命中,就直接使用缓存的结果,否则进入下面的流程

    具体的缓存匹配规则请看《HTTP缓存》小节

  4. 通过DNS解析找到服务器的IP地址

  5. 浏览器根据解析得到的IP地址向服务器发出TCP连接的申请,经过三次握手后,成功建立TCP连接

  6. 浏览器决定要附带哪些cookie到请求消息中

  7. 浏览器组装好http请求报文,将报文通过TCP连接发送给服务器

    请求是GET请求

  8. 服务器处理收到的请求,处理完成后,向浏览器发送一个http响应报文

  9. 浏览器收到响应消息后,根据协议的版本或Connection响应头决定是否保留此TCP连接

  10. 浏览器根据响应状态码决定如何处理该响应消息

    如果是404,则显示错误页面

    如果是302,则进行重定向

    如果是200,则进行下面的流程

  11. 浏览器根据响应头中的缓存指令完成响应结果的缓存,如果有cookie就将cookie保存下来

  12. 浏览器根据响应头的Content-Type字段判断出响应体内容为什么类型的数据,如果是text/html,则浏览器会将旧页面丢弃,然后开始从上到下对HTML文档进行解析

  13. 解析过程中发现了type属性为css的link元素,则根据link元素中的href属性,生成出对应的绝对路径形式的完整url地址,并对该url地址发出GET请求

    假设页面的url为oss.mysite.com/test/index.…

    若link元素的href属性中的路径是相对路径,如"./css/index.css",则生成出对应的绝对路径形式的url地址为oss.mysite.com/test/css/in…

  14. 浏览器收到来自服务器响应的css文件,之后浏览器会对该css进行解析和应用

    浏览器在请求css的同时,不会停止对页面的继续解析,而是将解析与请求同时进行

  15. 解析过程中发现img元素,则根据img元素中的src属性,生成出对应的绝对路径形式的完整url地址,并对该url地址发出GET请求

    浏览器在请求图片的同时,不会停止对页面的继续解析,而是将解析与请求同时进行

    图片响应完成后,浏览器就将图片绘制进页面中

  16. 解析过程中发现a元素,浏览器不会立即发送请求到a元素对应的href地址,只有当用户点击了a元素后,才会发送请求到href地址

    如果a元素的href地址使用的是相对路径,则在解析到该a元素时就会将地址替换为完整的绝对路径

  17. 解析过程中发现使用外链接的script元素,则根据script元素中的src属性,生成出对应的绝对路径形式的完整url地址,并对该url地址发出GET请求

    浏览器在请求JS的同时,会立即停止对页面的解析,当浏览器得到响应的JS代码并将其执行完成后,才会继续解析HTML文档

  18. 浏览器继续解析HTML文档,直至文档解析完成,于是开始等待用户对页面进行操作或等待计时器到时并执行相应的回调函数或等待使用AJAX发送新请求

    当用户点击了一个a元素后,浏览器就重新完整地进行一遍该过程,于是新页面就可以被渲染出来了

AJAX

AJAX是浏览器赋予JS的一套API,通过这套API使得JS具备网络通信的能力

历史

浏览器本身就具备网络通信的能力,但在早期,浏览器并没有把这个能力开放给 JS

最早是微软在 IE 浏览器中把这一能力向 JS 开放,让 JS 可以在代码中实现发送请求,这项技术在 2005 年被正式命名为 AJAX(Asynchronous Javascript And XML)

IE 使用了一套 API 来完成请求的发送,这套 API 主要依靠一个构造函数完成。该构造函数的名称为XMLHttpRequest,简称为XHR,所以这套 API 又称之为XHR API

由于XHR API有着诸多缺陷,于是在 HTML5 和 ES6 发布之后,产生了一套更完善的 API 来发送请求,这套 API 主要使用的是一个叫做fetch的函数,因此这套 API 又称之为Fetch API

无论是XHR还是Fetch,它们都是实现 ajax 的技术手段,只是 API 不同

注意:在浏览器的地址栏中输入url获取资源,或者通过link元素或script元素请求外部资源文件的方式,这些请求都属于GET请求,但均不属于AJAX

XHR

使用XHR发送请求,需要严格按照下面的顺序进行:

  1. 创建XHR对象

    var xhr = new XMLHttpRequest();
    
  2. 监听事件

    xhr.onreadychange = function(){}
    

    该函数会在xhr.readyState发送变化时触发运行

    xhr.readyState的值反映了当前发送的请求到达了哪个阶段:

    • 值为0:XHR对象被创建
    • 值为1:XHR对象的open方法已调用
    • 值为2:XHR对象的send方法已调用
    • 值为3:已接收完响应行和响应头,但响应体仍正在接收,此时可以使用xhr.getResponseHeader("键名")来获取对应的响应头
    • 值为4:已接收完响应体,此时可以使用xhr.responseText属性来获取响应体数据
  3. 配置请求行

    xhr.open("请求方法", "url地址")
    
  4. 设置请求头

    xhr.setRequestHeader("Content-Type", "application/json");
    

    每设置一个请求头,都需要调用一次该方法

  5. 设置请求体,并发送请求

    xhr.send("请求体");
    

Fetch

发送请求:

fetch("url", {
    method: "请求方法",
    headers: {							// 请求头
        "Content-Type": "application/json"
    },
    body: JSON.stringify(obj)			// 请求体
});

直接书写fetch相当于发送一个GET请求

fetch("url地址");

fetch会返回一个Promise,当响应行和响应头接收完成后,该Promise完成,此时响应体还没有接收完成

Promise完成时会传递过来一个响应数据resp,通过resp就可以获取到响应行和响应头信息

fetch("url地址").then((resp)=>{
    resp.status;				 // 获取响应状态码
    resp.headers.get("键名");		// 获取某个响应头
    resp.headers.has("键名");		// 是否存在某个响应头
    resp.headers.keys();		 // 获取响应头的键构成的迭代器
});

要想继续获取响应体,则需要等待resp对象的json()或text()返回的Promise完成

  • resp.json():返回一个Promise,当响应体接收完成后,该Promise完成,Promise的相关数据就是响应体通过JSON.parse()解析过后的对象
  • resp.text():返回一个Promise,当响应体接收完成后,该Promise完成,Promise的相关数据就是响应体字符串本身
fetch("url地址").then((resp)=>{
    return resp.json();
}).then((body)=>{
    console.log(body);
});

Axios

官网:axios-http.com/zh/

axios是一个请求库,它可以在浏览器环境以及node环境下发送请求

在浏览器环境中,axios对XHR进行了封装,并提供了更为便捷的API来发送请求

基本使用

使用axios(请求配置)来发送请求:

axios({
    method: "get",			// 设置请求方法
    url: "/user/123",		// 设置url
    params: {				// 为url添加的query,axios会自动将query进行编码,避免出现歧义
        page: 1,
        size: 10
    }
});

axios({
    method: "post",
    url: "/user/123",
    data: {					// 设置请求体,axios可以将请求体自动转换为JSON字符串形式,并在请求消息中设置Content-Type: application-json 
    	loginId: "admin",
        loginPwd: "123123"
	}
});

还可以使用请求方式别名:

axios.get(url[, 剩余配置]);
axios.post(url, 请求体[, 剩余配置]);
axios.put(url, 请求体[, 剩余配置]);
axios.delete(url[, 剩余配置]);
...

已经通过其他形式出现的配置,在剩余配置中就不要再次出现了

axios会返回一个Promise,Promise完成的数据就是完整的响应信息,包括响应头和响应体信息

axios(config).then((resp)=>{
    console.log(resp.headers);		// 响应头
    console.log(resp.data);			// 响应体,axios会自动将其解析为JSON对象格式
})

Axios实例

axios允许开发者创建一个实例,实例中可以添加一些通用的请求配置,并将后续的请求通过该实例来完成

var instance = axios.create({
    baseURL: "https://www.mysite.com"
});

// 发送get请求到https://www.mysite.com/api/herolist
instance.get("/api/herolist").then((resp)=>{
    console.log(resp);
});

拦截器

// 请求拦截器
axios.interceptors.request.use(function(config){
    // config为当前的请求配置对象
    var token = localStorage.getItem("token");
    if(token){
        config.headers.authorzation = token;
    }
    // 返回的config才是最终的请求配置对象,它会应用到真正发出请求的axios函数中
    return config;
});

// 响应拦截器
axios.interceptors.response.use(function(resp){
    // 响应状态码以2开头会调用该回调函数
    // 之后axios(...).then(resp=>{})中的resp就是这里返回的内容
    return resp;
}, function(error){
    // 若响应状态码不以2开头,则会调用该回调函数
    console.log(error);
});

常见问题

跨域错误

image.png

浏览器为了安全制定了一个规则,即页面的源和请求目标的源应该保持一致,如果不一致,就会产生跨域

源 = 协议 + 主机 + 端口

浏览器对img、link、script元素等发出的请求限制比较宽松,一般允许跨域,但对AJAX比较严格,一般不允许跨域

如果服务器明确告知浏览器允许跨域,则浏览器就允许AJAX跨域请求

其他问题

image.png

网络断开,检查你的网络连接,或者检查你是否在调试工具中进行了网络断开调试

image.png

访问的域名不存在,无法连接到服务器

常见请求方法

请求方法的本质

请求方法是请求消息中的请求行部分的第一个单词,它向服务器描述了客户端发出请求的动作的类型

在HTTP协议中,不同的请求方法只是表达了不同的语义,只不过服务器和浏览器之间一些约定俗成的规范造成了它们具体的区别

image.png

在实践中,客户端和服务器慢慢地达成了某个共识,就规定出了下面这些拥有不同含义的请求方法:

  • GET:表示向服务器获取资源,业务数据应该通过请求行进行传递,而不应该使用请求体
  • POST:表示向服务器提交数据,业务数据应该通过请求体进行传递
  • PUT:表示修改服务器的数据,业务数据应该通过请求体进行传递
  • DELETE:表示希望删除服务器的数据,业务数据应该通过请求行进行传递,而不应该使用请求体
  • OPTIONS:发生在跨域的预检请求中,表示客户端向服务器申请跨域提交
  • TRACE:回显服务器收到的请求,主要用于测试和诊断
  • CONNECT:用于建立连接通道,通常在代理场景中使用,网页中很少使用

HTTP协议允许你使用自定义请求方法,不过在使用时应该事先与服务器端商量好,例如:

fetch("https://www.baidu.com", {
	method: "heiheihei"
});

GET和POST的区别

由于服务器和浏览器之间约定俗成的规范,造就了GET与POST请求的众多区别:

  1. 浏览器在发送GET请求时,不会附带请求体;POST请求则允许附带请求体

  2. 浏览器对请求消息的请求行和请求头的长度是有限制的(并非HTTP协议的规定),因此GET请求所能传递信息量十分有限,它适合传递少量数据;POST请求传递信息是通过请求体进行传递的,因此可传递的信息量要比GET请求大得多,适合传输大量数据

  3. 浏览器规定了请求消息的请求行和请求体只能出现ASCII字符,请求体中可以携带非ASCII字符,因此GET请求只能传递ASCII数据,当遇到非ASCII数据时浏览器会自动对其进行编码,编码为ASCII字符;POST请求通过请求体传递数据时就没有限制

    在JS中使用encodeURIComponent()对非ASCII字符进行编码转换

    var encode = encodeURIComponent("中文");
    console.log(encode);		// "%E4%B8%AD%E6%96%87"
    

    fetch方法默认会对传入的URL中的非ASCII字符进行编码,而无需手动进行转换

  4. 刷新页面时,如果页面是通过POST请求得到的,则浏览器会提示用户是否重新提交表单,若页面是GET请求得到的则没有提示

  5. 由于参数在URL中可见,而请求体在URL中不可见,因此GET请求相对于POST请求更容易暴露信息

Cookie

http协议是一种无状态协议,这就导致了即使客户端连续发送了多个请求给同一个服务器,服务器也无法根据这几个请求判断出这些请求是否来自于同一个客户端

image.png

image.png

而Cookie就提供了一种机制使得服务器能够“记住”客户端

Cookie的组成

  1. key:键

  2. value:值

  3. domain:域,表示该cookie是属于哪个网站的,如:.zhangsan.tech

  4. path:路径,表示该cookie是属于网站的哪个基路径的

  5. secure:是否使用安全传输

  6. expire:过期时间,表示该cookie何时过期

请求消息中的Cookie

当浏览器向服务器发送请求消息时,浏览器自动将满足下面所有条件的cookie附加到请求头中:

  1. cookie没有过期

  2. cookie中的domain字段与本次请求的域是匹配的

    假设cookie中的domain为zhangsan.tech,则可以匹配的域有:zhangsan.tech、xxx.zhangsan.tech、xxx.xxx.zhangsan.tech...

    匹配域时,是忽略端口号的

  3. cookie中的path字段与本次请求的path是匹配的

    假设cookie的path为/news,则可以匹配的路径有:/news、/news/xxx、/news/xxx/xxx、...

    若cookie的path为/,则可以匹配所有路径

  4. 验证cookie的secure字段

    如果secure字段为true,则请求使用的协议必须是https,否则不会发送cookie

    如果secure字段为false,则请求使用的协议可以是http,也可以是https

当满足以上所有条件时,浏览器会自动将该cookie添加至请求头中

具体为在请求消息中添加一个字段名称为Cookie的请求头,值为任意个Cookie的键值对映射:

Cookie: name1=value1; name2=value2; name3=value3

响应消息中的Cookie

服务器会在响应消息中加入set-cookie响应头,其值就是一个cookie

一个响应消息中可能存在多个set-cookie响应头

当浏览器接收到来自服务器的请求后,就会根据响应消息中的set-cookie响应头,自动创建出对应的Cookie

set-cookie响应头的内容格式如下:

set-cookie: key=value; path=?; domain=?; expire=?; max-age=?; secure; httponly

其中key=value是必须存在的,其它都是可选的,有默认的值

path的默认值为所请求url中的完整path,domain的默认值为所请求url的完整域(即主机号)

expire和max-age都是cookie的过期时间参数,一个cookie响应头中只需出现两者之一即可:

  • expire:绝对有效期,值为一个GMT时间,若当前GMT时间超过该值时cookie就会过期

  • max-age:相对有效期,表示从浏览器添加cookie时的GMT时间开始,经过多少秒后cookie过期

    比如设置max-age为1000,则浏览器在添加cookie时,会自动设置cookie的expire为当前GMT时间加上1000秒后的时间

如果两个过期时间参数都没有设置,则cookie会在会话结束时过期,会话结束通常是指浏览器关闭时

secure只要出现在某个set-cookie响应头中,就说明该Cookie使用的是安全传输方式,之后浏览器在发送请求时,只有使用https协议的请求才可能将此cookie附带过去

若cookie中出现了httponly,则表示该cookie只能用于传输,而不允许客户端通过JS获取该cookie的内容

例如:浏览器通过post请求服务器http://zhangsan.tech/login,并在消息体中给予了账号和密码,服务器验证登录成功后,在响应头中加入了以下内容:

set-cookie: token=123456; path=/; max-age=3600; httponly

当该响应到达浏览器后,浏览器会创建下面的cookie:

name: token
value: 123456
domain: zhangsan.tech
path: /
expire: 2020-04-17 18:55:00			# 假设当前时间是2020-04-17 17:55:00
secure: false						# 任何请求都可以附带这个cookie,只要满足其他要求
httponly: true						# 不允许JS获取该cookie

于是,随着浏览器后续对服务器的请求,只要满足要求,这个cookie就会被附带到请求头中传给服务器

Cookie: token=123456; 其他cookie...

浏览器对Cookie增删改查

浏览器中通过document.cookie来增删改查Cookie

  • 通过给document.cookie赋值来添加一个cookie,赋值的格式和响应头中set-cookie的格式一致

    document.cookie = "name=value; path=/; secure";
    
  • 通过给document.cookie赋值来删除一个cookie,只需要把相应的Cookie修改为已过期即可,赋值的格式和响应头中set-cookie的格式一致

    例如:

    document.cookie = "name=...; path=/; max-age=-1";
    

    max-age=-1表示该Cookie在上一秒就过期了,因此浏览器会立即将该Cookie删除

  • 通过给document.cookie赋值来修改一个cookie,赋值的格式和响应头中set-cookie的格式一致

    document.cookie = "name=新value; path=/; secure";
    
  • 在浏览器中可以使用document.cookie来查看当前能够展示的所有cookie

    console.log(document.cookie);
    
    // "name1=value1; name2=value2"
    

注意:虽然cookie中的key叫做键,但是它并不具有唯一性,无法仅通过key来确定某一个cookie,因此对于删改cookie,应该保证赋值内容中的key、domain、path是有cookie对应的,否则就变为了增加一个cookie

补充

cookie的存储大小是有限制的,通常情况下,浏览器会限制一个域下的所有cookie总量不超过4KB

并非任何类型的客户端都存在cookie的这些默认行为,本节仅仅探讨客户端为浏览器的情况

加密

未加密的内容:明文

加密后的内容:密文

对称加密

image.png

密钥本质上就是一个字符串

对称加密是指可以通过密钥配合某个算法将明文加密为密文,也可以通过密钥配合相同的算法将密文解密为明文

服务器为了使客户端能够通过解密密文来得到明文,需要将密钥也发送过去,这增加了数据被窃取的风险

常见的对称加密算法:DES、3DES、TDEA、Blowfish、RC5、IDEA

优点:加密、解密速度快,适合对大数据量进行加密

缺点:在网络中需要分发密钥,增加了密钥被窃取的风险

非对称加密

image.png

非对称加密涉及两个密钥,分别是公钥和私钥,在传输数据时使用公钥或私钥中的一个配合某个算法进行加密,并使用另一个密钥配合相同算法进行解密

私钥只会保存在服务器之中,不会用于网络传输,而公钥可以用于网络传输

例如:使用公钥对数据进行传输,此时服务器就可以将公钥发送给客户端,客户端之后在给服务器发送数据时就可以通过公钥对数据进行加密,服务器收到后就可以通过私钥对数据进行解密

由于私钥只会保存在服务器之中,因此服务器向客户端发送的密文,客户端是解密不了的,这是非对称加密的缺陷

常见算法:RSA、Rabin、DSA、ECC、Elgamal、D-H

优点:安全

缺点:仅能一方进行解密

哈希

哈希.png

哈希加密方式(也称摘要、散列)是直接使用某个算法将一个明文加密为密文,但加密过后,(理论上)是无法将密文还原为明文的

哈希加密算法的特点:

  • 生成的密文长度固定,而无论被加密的明文的长度如何
  • 一个明文唯一且固定对应着一个密文

常见算法:MD4、MD5、SHA1

优点:密文占用空间小;(理论上)无法被破解

缺点:无法解密

JSON Web Token

JWT定义了在互联网上使用的一种统一的,安全的令牌格式

JWT令牌的组成

jwt令牌由三个部分组成:

  1. header:令牌头部,记录了整个令牌的类型和签名算法

  2. payload:令牌负荷,记录了令牌中保存的主体信息

  3. signature:令牌签名,按照header中记录的签名算法对整个令牌进行签名,签名的作用是防止令牌被伪造和篡改

一个完整的jwt令牌形式如下所示:

							header.payload.signature

例如:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE1ODc1NDgyMTV9.BCwUy3jnUQ_E6TqCayc7rCHkx-vxxdagUwPOWqwYCFc
header

header部分其实就是一个使用base64 url编码过后的JSON对象

例如,将上面举例的JWT令牌的header进行解码后,将得到下面的JSON对象的字符串形式:

{
    "alg": "HS256",
    "typ": "JWT"
}
  • "alg"中记录的是signature部分所使用的签名算法

    常见的签名算法有下面两个:

    ① HS256:一种对称加密算法

    ② RS256:一种非对称加密算法

  • "typ"字段的取值固定为"JWT"

在JS中使用atob对数据进行base64 url解码,使用btoa对数据进行base64 url编码

payload

payload部分其实也是一个使用base64 url编码过后的JSON对象

该部分可以包含以下属性:

{
    "ss": "发行者",
    "iat": "发布时间",
    "exp": "到期时间",
    "sub": "主题",
    "aud": "听众",
    "nbf": "在此之前不可用",
    "jti": "JWT ID"
}
  • ss:发行该jwt的是谁,可以写公司名字,也可以写服务名称
  • iat:该jwt的发放时间,通常写当前时间的时间戳
  • exp:该jwt的到期时间,通常写时间戳
  • sub:该jwt是用于干嘛的
  • aud:该jwt是发放给哪个终端的,可以是终端类型,也可以是用户名称,随意一点
  • nbf:一个时间点,在该时间点到达之前,这个令牌是不可用的
  • jti:jwt的唯一编号,设置此项的目的,主要是为了防止重放攻击(重放攻击是在某些场景下,用户使用之前的令牌发送到服务器,被服务器正确的识别,从而导致不可预期的行为发生)

上面属性全都是可选的,并且payload中也可以添加自定义属性

例如,将上面举例的JWT令牌的payload进行解码后,将得到下面的JSON对象的字符串形式:

{
    "foo": "bar",
    "iat": 1587548215
}
signature

该部分是jwt令牌的签名,由于它的存在,保证了整个令牌无法被伪造

签名就是将令牌中经过base64 url编码过后的header和payload部分,使用header中记录的"alg"算法进行加密后的结果

对于上面举例的JWT令牌,其signature的生成可以表示为:

signature = HS256("header.signature", "密钥")

最终,将两个部分使用.拼接起来就得到了完整的JWT令牌

JWT = "header.payload" + "." + signature;

同源策略

浏览器有一个重要的安全策略,称之为同源策略

其中 源 = 协议 + 主机 + 端口,两个源如果相同,则称之为同源,否则就是跨源或跨域

同源的要求十分严格,只有源长得一样,才能算是同源

例如:http://localhost:5500http://127.0.0.1:5500仍然是不同源的

同源策略是指:若页面的源和页面运行过程中所请求的源不一致时,出于安全考虑,浏览器会对跨域的资源访问进行一些限制

同源策略对不同资源限制的程度不同,例如对请求css和js资源时所发送的请求限制较为宽松,一般允许跨源,但是对使用ajax发出的请求限制最为严格,一般情况下,是不允许ajax访问跨域资源的

注意:在跨域访问资源时,请求确实是发出去了,并且请求消息也成功被服务器接收到了,并且服务器也将响应结果发送给浏览器了,只不过浏览器在收到响应消息时会事先检查其中的内容,若没有找到允许跨域访问的字段,就不会将该响应消息真正传送给页面,因此页面才收不到响应

image.png

常见的解决跨域问题的方法有3种:

  1. 代理,常用
  2. CORS,常用
  3. JSONP

代理

由于跨域问题通常出现在开发环境中,因此使用代理解决跨域问题在开发阶段十分常见

代理不适用于处理生产环境跨域的情况,它只能解决开发环境的跨域问题

代理即将开发服务器配置成为代理服务器,让其帮忙转发请求消息和响应相应

以在Vue中配置代理服务器为例:

假设页面的源为http://localhost:5500,需要在页面中请求http://dev.taobao.com/api/...

则只需要在vue.config.js中进行如下配置:

// vue.config.js

module.exports = {
    devServer: { 		// 将开发服务器配成为代理服务器
        proxy: { 		// 具体配置
            "/api": { 	// 若请求的url的路径是以 /api 开头
                target: "http://dev.taobao.com", 	// 将其转发到 http://dev.taobao.com
            }
        },
    },
};

页面在真正请求时,应该将请求的url的源修改为页面的源,即应该去请求http://localhost:5500/api/...,也就是让开发服务器来接收该请求,而不是让真实的后端服务器接收该请求

发送请求时可以省略请求的url的源,这样浏览器会自动将页面的源补全到url中

当代理服务器收到该请求后,就会转去请求dev.taobao.com/api/...

之后真实服务器会响应数据给代理服务器,代理服务器收到响应数据后,就会将数据响应给浏览器

由于同源策略只存在于浏览器与服务器之间,因此代理服务器能接收到来自真实的服务器的响应,并将响应返回给浏览器,浏览器也检查到代理服务器与页面是同源的,因此也会允许页面接收该响应数据

image.png

CORS

CORS是基于http1.1的一种跨域解决方案,它的全称是Cross-Origin Resource Sharing,跨域资源共享

它的总体思路是:如果浏览器要跨域访问服务器的资源,需要获得服务器的允许

image.png

针对不同的请求,CORS 规定了三种不同的交互模式,分别是:

  • 简单请求
  • 需要预检的请求
  • 附带身份凭证的请求

这三种模式从上到下层层递进,请求可以做的事越来越多,要求也越来越严格

当浏览器运行了一段ajax代码(XHR或Fetch API),浏览器首先会判断该ajax发出的请求属于哪一种模式

简单请求

当请求同时满足以下条件时,浏览器会认为它是一个简单请求:

  1. 请求方法属于下面的一种:

    get

    post

    head

  2. 请求头中仅包含安全的字段,常见的安全的字段如下:

    Accept

    Accept-Language

    Content-Language

    Content-Type

    DPR

    Downlink

    Save-Data

    Viewport-Width

    Width

  3. 请求头Content-Type仅限下面的值之一:

    text/plain

    multipart/form-data

    application/x-www-form-urlencoded

    Content-Type请求头的默认值为application/x-www-form-urlencoded

当请求是一个简单请求时,浏览器会给该请求添加一个请求头Origin,其值为发送请求的页面的源

服务器收到请求后,若允许请求头中标明的Origin源,则服务器就会在响应消息中添加一个响应头Access-Control-Allow-Origin字段,该字段的值就是请求头Origin的内容

除设置为具体的Origin外,服务器如果允许任何源来访问某个资源,则也可以在响应消息中将响应头Access-Control-Allow-Origin字段的值设置为*

若浏览器收到响应后发现服务器允许页面访问该资源,则浏览器就不会对该资源做出限制,于是页面就可以访问到该资源了

需要预检的请求

若浏览器发现请求不是一个简单请求,则浏览器会按照下面的流程与服务器交互:

  1. 浏览器发送预检请求,询问服务器是否允许
  2. 服务器允许
  3. 浏览器发送真实请求
  4. 服务器完成真实的响应

假设在页面http://my.com/index.html中有以下代码造成了跨域:

fetch("http://crossdomain.com/api/user", {
  method: "POST",
  headers: {
    a: 1,
    b: 2,
    "content-type": "application/json"
  },
  body: JSON.stringify({ name: "张小三", age: 18 }),
});

浏览器发现它不是一个简单请求,则会按照下面的流程与服务器交互:

  1. 浏览器发送预检请求,询问服务器是否允许

    OPTIONS /api/user HTTP/1.1
    Host: crossdomain.com
    ...
    Origin: http://my.com
    Access-Control-Request-Method: POST
    Access-Control-Request-Headers: a, b, content-type
    
    

    该请求并非真实请求,请求中不包含真实请求的请求头,也没有请求体

    这是一个预检请求,它的目的是询问服务器,是否允许后续的真实请求

    预检请求没有请求体,它仅包含后续真实请求要做的事情

    预检请求有以下特征:

    • 请求方法为OPTIONS

    • 没有请求体

    • 请求头中包含

      Origin:请求的源,和简单请求的含义一致

      Access-Control-Request-Method:后续的真实请求将使用到的请求方法

      Access-Control-Request-Headers:后续的真实请求会使用到的请求头

  2. 服务器允许

    服务器收到预检请求后,可以检查预检请求中包含的信息

    服务器如果允许这样的请求,则需要响应下面的消息格式

    HTTP/1.1 200 OK
    Date: Tue, 21 Apr 2020 08:03:35 GMT
    ...
    Access-Control-Allow-Origin: http://my.com
    Access-Control-Allow-Methods: POST
    Access-Control-Allow-Headers: a, b, content-type
    Access-Control-Max-Age: 86400
    ...
    
    

    对于预检请求,不需要响应任何的消息体,只需要在响应头中添加:

    • Access-Control-Allow-Origin:和简单请求一样,表示允许的源

    • Access-Control-Allow-Methods:表示允许的后续真实请求的请求方法

    • Access-Control-Allow-Headers:表示允许的后续真实请求的请求头

    • Access-Control-Max-Age:告诉浏览器,多少秒内,对于拥有同样源、方法、请求头的请求,都不需要再发送预检请求了

      该字段不一定需要有,若没有,则浏览器对于需要预检的请求都会事先发送预检请求询问服务器是否允许

    若服务器针对预检请求的响应与预检请求中的字段不对应或缺少,则表示服务器拒绝页面进行跨域访问

  3. 浏览器发送真实请求

    从这里开始浏览器的处理流程就和简单请求的处理流程一致

    预检被服务器允许后,浏览器就会发送真实请求了,上面的代码会发送下面的请求数据

    POST /api/user HTTP/1.1
    Host: crossdomain.com
    Connection: keep-alive
    ...
    Referer: http://my.com/index.html
    Origin: http://my.com
    
    {"name": "张小三", "age": 18 }
    
  4. 服务器响应真实请求

    HTTP/1.1 200 OK
    Date: Tue, 21 Apr 2020 08:03:35 GMT
    ...
    Access-Control-Allow-Origin: http://my.com
    ...
    
    添加用户成功
    

image.png

附带Cookie的请求

默认情况下,对于跨域的ajax请求,浏览器并不会向请求中附带Cookie过去

可以在JS代码中强制设置请求必须附带Cookie而无论是否跨域

var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
fetch(url, {
  credentials: 'include',
});

对于一个附带Cookie的请求,浏览器对其的处理和需要预检的请求类似,仍然是先给服务器发送一个预检请求,服务器若允许,就给浏览器发送一个允许的响应

在附带Cookie的请求的预检请求中,Access-Control-Allow-Headers请求头中会包含Cookie,表示真实请求中会附带Cookie请求头

服务器如果允许,其所针对预检请求的响应中,就必须包含Access-Control-Allow-Credentials: true请求头,若没有明确指明该请求头,则说明服务器不允许跨域请求

对于附带Cookie的请求,浏览器不允许服务器在响应消息中设置Access-Control-Allow-Origin响应头的值为*

补充

对于跨域成功的请求,在JS代码中只能获取到响应消息中最基本的一些响应头,例如:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma,而其他的响应头JS代码默认是获取不到的

如果需要访问其他的响应头,则需要让服务器在响应消息中添加Access-Control-Expose-Headers响应头,例如:

Access-Control-Expose-Headers: authorization, a, b

之后JS代码就能够从响应消息中获取到这些指定的响应头了

JSONP

在浏览器端事先准备好一个函数,用于处理接收到的响应数据

之后浏览器与服务器端商量好,让其响应一段JS代码,这段代码实际上是一个函数调用,调用的函数就是浏览器事先准备好的函数,调用时传递的参数就是浏览器真正想要得到的数据

当需要跨域请求时,不使用AJAX,而是去生成一个script元素,并让其的src为要请求的url,之后将其添加至页面

包含了外部引用的script元素添加到页面中后,浏览器便会立即发送一个GET请求,也就是请求script元素中的url

由于浏览器的同源策略对script元素的限制比较宽松,因此通常使用script元素来发送请求是能够得到结果的

script元素接收到响应结果后,就会将其作为JS代码执行,于是浏览器就可以获取到想要的数据了

image.png

JSONP存在着明显的缺点,就是只能支持GET请求

文件上传

文件上传的本质就是一次数据提交

具体流程:

  1. 客户端将文件数据发送给服务器
  2. 服务器保存上传的文件数据到服务器端
  3. 服务器响应给客户端一个文件访问地址

在实践中,前后端就逐渐达成了一种共识,于是文件上传就有了如下规定:

  1. 请求方法为POST
  2. 请求头Content-Type为multipart/form-data
  3. 请求体中必须包含一个键值对,键的名称是服务器要求的名称,值是文件的二进制数据

multipart/form-data格式的请求体仍然是一个个的键值对

form-data的请求体中的一个键值对的基本形式如下:

Content-Disposition: form-data; name="keyname"

value

其中name后面的就是该键值对的键的名称,而键对应的值会在最后一行出现

在这种请求的请求头中的Content-Type字段,其字段值中会包含一个boundary属性,该属性的值是用于划分每一个键值对的分隔符

下面是一个更加全面的使用multipat/form-data请求体格式的请求消息:

POST 上传地址 HTTP/1.1
其他请求头
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="img"; filename="小仙女.jpg"
Content-Type: image/jpeg

(文件的完整二进制数据...)
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"

admin
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="password"

123123
----WebKitFormBoundary7MA4YWxkTrZu0gW

实战

键的名称:imagefile

第一种方式:使用form表单

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <form action="xxx" method="POST" enctype="multipart/form-data">
        <!-- 添加multiple属性后可以通过一个input:file来选择多个文件 -->
        <!-- input的name属性值就是form-data请求体中一个键值对的name值 -->
    	<input type="file" name="imagefile">
        <!-- 提交表单时,浏览器会自动自动生成分隔符以及构建请求体格式 -->
        <button>提交</button>
    </form>
</body>
</html>

第二种方式:利用fetch API

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <!-- 通过type为file的input元素类选择文件 -->
    <input type="file" id="upload">
    <script>
        var input = document.getElementById("upload");
        input.onchange = function(){
            // input.files属性为所选择的文件的集合
            var file = input.files[0];
            // 可以使用浏览器提供的FormData构造函数来构建form-data格式的请求体
            var formData = new FormData();
            // 通过append方法给请求体中增加一个键值对
            formData.append("imagefile", file);
            // 当第一次选中文件,或选中新文件时,都会触发该方法
            fetch("xxx", {
                method: "POST",
                body: formData      // 设置了FormData对象的请求体的请求,其Content-Type请求头会自动变为multipart/form-data; boundary=...
            }).then((resp)=>{
                return resp.json();
            }).then((body)=>{
                console.log(body);
            });
        }
    </script>
</body>
</html>

文件下载

服务器只要在响应消息中加入Content-Disposition: attachment; filename="默认文件名称"响应头,之后浏览器收到该响应消息后就会触发下载功能

迅雷下载

用户可能安装了某些下载工具,这些下载工具在安装时,都会自动往浏览器中安装相应的下载插件

在浏览器中,如果需要通过a元素触发使用某个下载工具的辅助下载,就需要对下载的地址进行修改

不同插件的地址规则不同,以迅雷下载为例,它要求下载地址为如下格式:

thunder://base64(AA地址ZZ)

代码实例:

<!-- 假设这是一个需要使用迅雷下载的超链接 -->
<a data-resrole="thunder" href="http://localhost:8000/download/xxx.jpg"></a>

<script>
    const links = document.querySelectorAll("[data-resrole=thunder]");
    for(var link of links){
        var href = `AA${link.href}ZZ`;
        var base64 = btoa(href);		// 将href转换为base64编码的形式
        link.href = `thunder://${base64}`;
    }
</script>

断点续传

断点续传分为下载和上传,无论是断点下载还是断点上传,都需要浏览器和服务器双方进行配合

之所以需要断点续传,是因为有一些文件,它的尺寸比较大,无论是下载还是上传,都需要花费不少时间,如果在这段时间内发生了一些特殊的事情,例如:电脑死机、断电、断网等等,就会导致之前已经传输完成的文件部分失效,下次不得不重新从头开始传输,这个时间代价用户是无法接受的

下载

触发客户端(如浏览器)的下载行为需要依赖于服务器响应下面的响应头:

Content-Disposition: attachment; filaname="image.jpg"

而要支持断点下载,则还需要使用到另一个响应头:

Accept-Ranges: bytes

该响应头表示服务器支持部分文件数据的传输,并且这个“文件的部分数据”是以字节为单位进行划分的

完成文件断点下载的完整流程如下:

  1. 客户端向服务器发送一个head请求,获取一些响应头信息

    head请求是一种特殊的get请求,不过get请求的响应数据可能会包含响应体,但head请求的响应数据不会有,head请求只会得到get请求对应的响应消息的行和头,它是为之后要发送的真实请求做准备的

  2. 服务器收到head请求后,会响应过去一个响应消息,其中会包含以下头字段

    Content-Disposition:表示该文件会触发客户端的下载行为

    Accept-Ranges:表示服务器支持断点下载

    Content-Length:表示文件数据的总大小,单位为字节

  3. 客户端收到响应消息后,根据其中的头字段,决定要不要使用断点下载,假设客户端决定使用断点下载方式下载文件,则会在请求消息中加入以下头字段

    Range:bytes=0-n:表示本次请求响应中我想要获取文件的0到n字节的数据

  4. 服务器收到请求后根据Range请求头响应相应的文件数据片段给客户端

    对于该响应消息,它的状态码为206,状态消息为Partial Content,表示响应体中的数据是部分的文件数据

    响应消息中会包含下面的响应头:

    Content-Range: bytes 0-n:表示响应数据为0-n字节的文件数据

    Content-Length: n+1:表示响应数据的长度为n + 1个字节大小

  5. 客户端收到响应消息后后将数据保存到一个临时文件之中,并向服务器发送下一个文件片段的请求...

    有些客户端为了提高下载速度,会同时发送多个请求向服务器,以同时获取不同的文件片段

    不断重复上面的过程,直至文件数据全部获取完成,客户端就会将临时文件中的文件数据组装为一个完整的文件,至此断点下载流程结束

sequenceDiagram
客户端->>服务器: head请求,询问文件信息
服务器->>客户端: Content-Disposition,Accept-Ranges,Content-Length
客户端->>服务器: Range:bytes=0-500
服务器->>客户端: 0-500字节的数据
客户端->>服务器: Range:bytes=501-1000
服务器->>客户端: 501-1000字节的数据
客户端->>服务器: ...
服务器->>客户端: ...
客户端->>客户端: 将碎片信息组装成完整文件

对于断点下载,客户端和服务器会自动完成上面的过程,并且前端开发者无法干预这个过程,因为JS无法生成一个临时文件来保存获取到的文件数据片段

上传

断点下载是将文件数据在服务器进行分片,然后依次响应给客户端,最后由客户端对片段进行组装

而断点上传刚好相反,是在客户端对文件进行分片,因此依次发送服务器,服务器最后将它们组装形成完整的文件

image.png

但相比于断点下载,断点上传并没有一个明确的流程,因此断点上传的实现方式也是五花八门,但核心原理一定还是“分片 + 组装”的方式

下面是一个简易的分片上传流程:

  1. 用户选择文件,选择完成后将文件数据切分为多个片段,并为文件完整数据以及每个分片都生成一个hash加密字符串(根据文件内容或片段内容生成)

  2. 将完整文件的hash字符串、每个分片的hash字符串、文件后缀、分片顺序等数据发送服务器

  3. 服务器收到后,根据完整的文件hash判断文件是否已经完整保存过了

    如果是,就响应一个文件访问地址给客户端

    如果不是,就响应一个需要上传的片段hash序列给客户端

  4. 假设客户端还没有将文件片段全部上传完成,此时客户端收到响应的hash序列后,就会将其中一个hash对应的片段发送给服务器,发送时该将片段的hash、以及将片段所属文件的hash、文件后缀名也附带过去

  5. 服务器收到文件片段后,将片段保存起来,然后响应一个还需要上传的分片的hash序列给客户端

  6. 重复上面的过程,直至服务器接收到所有的文件片段,将它们组件起来,并给客户端响应文件的访问地址,至此断点上传流程结束

sequenceDiagram
Note left of 客户端: 用户选择文件
Note left of 客户端: 将文件分割为多个分片
Note left of 客户端: 得到文件的MD5和每个分片的MD5
客户端->>服务器: 文件MD5、后缀、分片顺序、每个分片MD5
Note right of 服务器: 文件还剩哪些分片没有上传?
服务器->>客户端: 还需要上传的分片MD5
客户端->>服务器: 上传一个分片
Note right of 服务器: 文件还剩哪些分片没有上传?
服务器->>客户端: 还需要上传的分片MD5
客户端->>服务器: 上传一个分片
Note right of 服务器: 文件还剩哪些分片没有上传?
服务器->>客户端: 还需要上传的分片MD5
客户端->>服务器: 上传一个分片
Note right of 服务器: 所有分片已上传
Note right of 服务器: 合并所有分片成完整的文件
服务器->>客户端: 文件的访问地址

上面的断点上传流程只是提供了一个最简单的上传逻辑,实际开发中可能并不会向上面一样串行地一个分片一个分片上传,而可能是多开几个线程同时上传不同的分片,以提高上传速度

Session

cookie的缺陷

cookie是保存在浏览器端的,虽然能为服务器减少压力(减少存储空间消耗),但在某些场景下会出现麻烦,比如,验证码场景

为了确保之后能够对验证码的正确性进行验证,需要在生成验证码后将验证码记录下来

如果使用cookie记录正确的验证码,则意味着服务器将验证码生成出来后,是需要传递给客户端的

保存在客户端的数据始终是有机会被用户获取到的,此时用户就可以在不填写自己手机号的情况下获取到该手机号的正确的验证码,这就让验证码的作用失效了

image.png

正确的做法是在服务器端将验证码记录下来,此时就可以使用session

一个session其实就是保存在服务器的一个表格中的一行信息(一个记录),每个记录都会有一个唯一的id,叫做sessionID,每个记录中保存着某个客户端的若干信息

利用session后,验证码的检验流程就变为了:

  1. 生成验证码,并将验证码在对应的session中记录下来
  2. 将验证码发送至手机,发送相应的sessionID给客户端
  3. 用户提交包含验证码的表单,提交时将sessionID附带过去
  4. 服务器根据sessionID找到相应的session,并判断session中记录的正确验证码与用户提交的验证码是否匹配

image.png

与cookie的区别

  • cookie的数据保存在浏览器端,session的数据保存在服务器端
  • cookie的存储空间有限,session的存储空间理论上无限
  • cookie只能保存字符串,session可以存储任何类型的数据
  • cookie的数据容易被获取,session中的用户难以被获取

删除session

持久的数据通常是使用数据库技术存储的,因此session通常保存一些临时的数据,并且由于服务器需要服务众多的客户端,因此为了避免session占用空间过大,需要及时地删除session

最常见的做法是在客户端离开网站时将session删除,但客户端下线时服务器是收不到通知的,因此很难判断应该在何时删除对应的session

常见的处理方法有两种:

  1. 设置一个过期时间,若客户端长时间没有将sessionID发送过来,则可能说明用户已经离开了,此时就可以删除session
  2. 使用JS监听客户端的页面关闭或其他退出操作,在用户正常离开网站时向服务器发出通知,服务器收到后将session删除

CSRF攻击

CSRF(Cross-site request forgery,跨站请求伪造),是指攻击者利用了用户的身份信息,执行了用户非本意的操作

CSRF攻击的流程:

  1. 用户登录正常站点,登陆成功后站点给客户端发送包含身份信息的cookie,客户端收到cookie后自动将cookie保存下来
  2. 用户访问恶意页面,页面中包含了某些会发出请求元素(这种请求能够不被浏览器的同源策略所限制),模拟用户行为去请求用户之前登录成功的正常站点,请求时由于浏览器检查到cookie匹配,于是将包含身份信息的cookie附带到请求之中
  3. 请求消息被正常站点接收到后,发现包含身份信息的cookie,于是按照正常流程处理该请求,至此完成了攻击

image.png

防御方式

  • 不使用cookie,身份信息使用webstorage进行储存和传递

    缺点:兼容性差,cookie则很早就出现,而webstorage是随着H5一起诞生的

  • 为cookie设置sameSite

    sameSite有下面三种取值:

    Strict:严格,所有跨站请求都不允许附带cookie,可能会导致用户体验不好

    Lax:宽松,所有跨站的超链接、GET请求的表单、预加载的链接允许附带cookie,其他情况不允许发送

    None:无限制,相当于没写

    sameSite属性是最新才出现的,因此兼用性差

  • 使用csrf token

    用户进行敏感操作时,需要先获取一个包含一次性的随机验证信息的表单页面,服务器会将该验证信息记录在session中

    之后用户在页面中提交表单时,需要将验证信息一起传递给服务器,服务器收到后需要验证该验证信息

    这能够判断用户是否是在指定页面中提交的表单,很好地阻止了CSRF攻击

    这种方法也存在漏动,就是用户在获取到表单页面但还未提交表单的期间进入了恶意网站,此时就可能遭到攻击

  • 服务器检查referer

    目前已发现该防御方式的漏洞,建议不要使用

XSS攻击

XSS(Cross Site Scripting,跨站脚本攻击),是指攻击者利用站点的漏洞,在表单提交时,在表单内容中加入一些恶意脚本,服务器由于检查不当将脚本直接保存到了数据库中,之后当其他正常用户浏览页面,可能会从数据库中获取到这段恶意脚本,并且脚本可能还会被执行,导致正常用户的页面遭到篡改,或者用户信息被窃取

image.png

防御方式

服务器端对用户提交的内容进行过滤或编码

  • 过滤:去掉一些危险的标签,去掉一些危险的属性
  • 编码:对危险的标签进行HTML实体编码

网络性能优化

  • 优化打包体积

    现代模式、代码压缩、使用CDN、tree shaking

  • 多目标打包

    打包结果体积大的原因很大一部分是包含了兼容性代码

    而兼容性代码对新版本的浏览器而言往往是没必要的,因此可以针对不同版本的浏览器使用具有不同兼容性的打包结果

  • 压缩

    例如对打包结果文件进行gzip压缩,提升传输效率

  • CDN

    利用CDN可以有效缩短静态资源的访问时间,特别是对公共库的访问

  • 缓存

    利用缓存,可以有效提高资源的加载速度

  • 使用http 2.0

    http 2.0的多路复用、头部压缩等策略都能大幅度减少资源的传输时间

  • 雪碧图

    对于不支持http 2.0的场景,可以将多个图片合并为雪碧图,以减少请求的数量,减少队头阻塞的发生概率

  • defer、async

    defer和async都可以让js文件尽早地被加载,并且不阻塞HTML的解析

  • preload、prefetch

    使用preload和prefetch都可以让资源尽早地被加载,之后在需要时就可以直接使用

  • 多个静态资源域

    对于不支持http 2.0的场景,可以将静态资源分到多个不同的域中,这可以让浏览器开辟更多个TCP连接,提高并行下载的能力

  • 图片懒加载

    通过延迟加载图片,减少初始页面加载时的请求数量,加快页面的加载速度

    只加载用户当前可见区域的图片,减少不必要的流量消耗

    减少初始请求数量,减轻服务器的压力

HTTP缓存

HTTP缓存是指:当浏览器第一次发送get请求给服务器时,服务器若觉得本次所请求的资源基本上不会发生变化,就会在响应消息中增加几个特殊的响应头,浏览器收到响应消息后将其缓存起来,并根据服务器提供的这些信息,在接下来的一段时间内,不再向服务器发送请求,而是直接使用缓存的结果

例如,浏览器首次请求服务器时,服务器可能会为响应消息设置下面几个响应头:

Cache-Control: max-age=3600
ETag: W/"121-171ca289ebf"
Date: Thu, 30 Apr 2020 12:39:56 GMT
Last-Modified: Thu, 30 Apr 2020 08:16:31 GMT

这四个响应头表明了几个信息:

  • Cache-Control: max-age=3600:表示服务器希望浏览器将该资源缓存起来,并在接下来的3600秒内,使用缓存的结果,而无需发送请求过来

  • ETag: W/"121-171ca289ebf":资源的唯一标识,资源一旦更新,ETag也将发生改变

  • Date: Thu, 30 Apr 2020 12:39:56 GMT:表示服务器希望以浏览器该时间作为起始时间进行记时,记时结束后再尝试发送请求过来

    之所以让服务器来规定起始时间是考虑到客户端的系统时间可能是经过更改的

  • Last-Modified: Thu, 30 Apr 2020 08:16:31 GMT:资源上一次修改的时间,它记录的是一个绝对时间(格林威治时间)

当包含上面响应头信息的消息被浏览器接收到后,浏览器就会进行下面动作:

  1. 把本次请求得到的响应体缓存到本地文件中
  2. 标识本次请求的请求方法和请求的路径
  3. 标记本资源缓存的时间3600秒
  4. 记录服务器返回的响应时间Date
  5. 记录服务器返回的资源标记ETag
  6. 记录服务器返回的资源上一次修改时间Last-Modified

客户端分为很多种,并非任何客户端都会按照这种方式进行缓存(甚至不缓存),但浏览器通常会认响应消息中的这些响应头,它能够对响应结果进行缓存

image.png

之后,浏览器如果又要向服务器发送请求,就会先检查缓存中是否有对应的资源并且资源是否还有效

具体过程如下:

  1. 浏览器根据所要请求的路径和请求方法在缓存中进行匹配
  2. 若在缓存中有命中,则对资源的有效性进行判断

若浏览器发现缓存资源有效,则就不会发送请求给服务器,而是直接使用缓存的结果

若浏览器发现缓存已失效(过期),则浏览器并不会简单地把缓存资源删除,而是再询问服务器当前缓存的资源是否真的已经失效了,其发送的请求称之为协商请求或带缓存的请求

image.png

image.png

带缓存的请求中会包含下面两个请求头:

If-Modified-Since: Thu, 30 Apr 2020 08:16:31 GMT
If-None-Match: W/"121-171ca289ebf"

其中If-Modified-Since字段的值就是浏览器之前所记录的Last-Modified

If-None-Match字段的值就是浏览器之前所记录的ETag

服务器收到带缓存的请求后,根据这两个请求头中的信息,与自己所记录的最新的Last-Modified和ETag进行对比

若不同就表明资源已更改,此时服务器给浏览器返回一个带有新的资源以及新的ETag、Last-Modified、Date、Cache-Control响应头的响应消息(响应码为200),浏览器收到该响应消息后,就会删除原来缓存的资源以及旧的响应头信息,并缓存新的资源和新的响应头信息

实际应用中,大部分服务器并不会同时使用这两个请求头,而是只使用其中一个即可

之所以要发两个信息,是为了兼容不同的服务器,因为有些服务器只认If-Modified-Since,有些服务器只认If-None-Match,有些服务器两个都认

目前的很多服务器,只要发现If-None-Match存在,就不会去看``If-Modified-Since`

If-Modified-Sincehttp1.0版本的规范,If-None-Matchhttp1.1的规范

否则表明资源未更改,服务器就返回一个提示缓存资源依然有效的响应,响应消息的形式具体如下:

  • 状态码和状态消息:304 Not Modified
  • 新的ETag、Last-Modified、Cache-Control、Date响应头
  • 无响应体

浏览器收到该响应消息后,就会更新记录的响应头信息,并直接使用之前所缓存的资源

image.png

扩展

Cache-Control

Cache-Control中除了可以写入max-age,还可以写入下面这些关键词,或者同时写入多个值:

  • public:指示该服务器资源是公开的
  • private:指示该服务器资源是私有的
  • no-cache:告知客户端将该资源缓存下来,但是不管什么情况,在使用该资源时,需要先向服务器发送一个缓存确认请求,只有服务器响应304消息码的消息后,客户端才能够使用该资源
  • no-store:告知客户端不要对该资源进行缓存,当之后需要再次使用该资源时,都需要向服务器发送普通请求来获取资源
Cache-Control: public, max-age=3600
Expires

在http 1.0版本,是通过Expires响应头来指定缓存资源的过期时间的,它记录的是一个GMT时间

Expires: Thu, 30 Apr 2020 23:38:38 GMT

到了http 1.1版本,就使用Cache-Control: max-age来记录过期时间

缓存资源过期时间的确定

image.png

当响应头中的max-age为0时,客户端仍然会把资源缓存下来,不过在缓存时资源就已经过期了,后续使用该缓存资源时,就需要发送缓存确认请求给服务器,因此,Cache-Control: max-age=0等价于Cache-Control: no-cache

Pragma

Pragma是http 1.0版本中使用的响应头,值通常为no-cache

该请求头的作用是向服务器表达,请不要考虑缓存,请每次都给我正常的结果

在http1.1中,在请求头中加入Cache-Control: no-cache字段请求头等价于加入了Pragme: no-cache

Vary

有的时候,是否有缓存,不仅仅是判断请求方法和请求路径是否匹配,可能还要判断头部信息是否匹配

比如,当使用GET /personal.html请求服务器时,请求头中cookie的值不一样,得到的页面的具体内容也不一样

Vary响应头中指定的字段也会作为缓存资源的匹配条件存在,若请求方法和请求路径匹配但Vary中指定的字段不匹配,则还是无法使用缓存

image.png

HTTP各版本的差异

HTTP 1.0

无法复用连接

HTTP 1.0规定每一个请求响应需要使用单独的TCP连接,响应完成后连接就需要立即释放

sequenceDiagram
rect rgb(191,155,248)
客户端->服务器: TCP三次握手,建立连接
end
rect rgb(196,223,252)
客户端->>服务器: 请求
服务器->>客户端: 响应
end
rect rgb(191,155,248)
客户端->服务器: TCP四次挥手,销毁连接
end
rect rgb(191,155,248)
客户端->服务器: TCP三次握手,建立连接
end
rect rgb(196,223,252)
客户端->>服务器: 请求
服务器->>客户端: 响应
end
rect rgb(191,155,248)
客户端->服务器: TCP四次挥手,销毁连接
end

由于每个请求的连接都是独立的,因此会带来下面的问题:

  1. 连接的建立和销毁都需要时间,造成响应时间增加

  2. 连接的建立和销毁都需要占用服务器和客户端的资源,造成资源的浪费

  3. 无法充分利用带宽,造成带宽资源的浪费

    TCP需要进行拥塞控制,而实现拥塞控制的特点之一是使用慢开始,即一开始所能传输的数据量很少,然后每隔一段时间将数据传输量增加一些

    而非持续连接的方式就导致每次请求都需要以慢开始为初始状态进行传输,造成带宽资源的浪费

队头阻塞

HTTP 1.0的队头阻塞是指只有收到上一个请求的响应结果后,下一个请求才能发送

image.png

HTTP 1.1

持续连接

HTTP 1.1默认开始了持续连接模式,所谓持续连接,是指在服务器发送完响应内容后仍然保持这条TCP连接,使客户端和服务器可以继续在这条连接上传送请求和响应消息

sequenceDiagram
rect rgb(191,155,248)
客户端->服务器: TCP三次握手,建立连接
end
rect rgb(196,223,252)
客户端->>服务器: 请求
服务器->>客户端: 响应
客户端->>服务器: 请求
服务器->>客户端: 响应
end
rect rgb(191,155,248)
客户端->服务器: TCP四次挥手,销毁连接
end

在这种模式下,多次请求响应都可以使用同一个TCP连接,这有效减少了TCP握手和挥手的时间,同时带宽的利用率也得到了提高

实际上,在HTTP 1.0阶段的后期,也可以实现持续连接方式

实现方式是客户端在请求时向请求消息中加入Connection: keep-alive请求头,服务器收到请求后,检查到包含该请求头,若同意该行为,则就不会释放TCP连接

若http的协议版本为1.1,则即使请求中不包含Connection: keep-alive请求头,服务器也不会释放TCP连接(但加了它能够兼容不支持http 1.1的浏览器)

当在有需要的时候,客户端和服务器的任意一方都可以关闭TCP连接,大部分情况是服务器主动关闭:

  1. 客户端在某次请求中设置了Connection: close请求头,服务器收到该请求后,便关闭TCP连接
  2. 在客户端没有必要的请求要发送给服务器时,就会每个一定时间向服务器发送提示服务器不要断开连接的请求消息,服务器收到后就会保持这个连接,当客户端长时间什么都没有发送时,服务器便会主动关闭TCP连接
  3. 当客户端长时间没有必要的请求发送给服务器时,服务器可能就会关闭TCP连接
管道化和队头阻塞

管道化是指浏览器在收到HTTP的响应报文之前就能够连续发送多个请求报文

需要注意的是,对于同一个TCP连接中的请求响应,服务器响应数据的顺序必须和请求到达服务器的顺序保持一致,即使后面到来的请求先被处理成功,这就造成了队头阻塞问题

浏览器发出请求的顺序不一定和请求到达服务器的顺序一致,这就给HTTP 1.1的请求响应带来了一些问题

image.png

http 1.1的队头阻塞问题产生的原因都有两个:

  1. 在一个TCP连接中,浏览器需要按照请求发出的顺序来接收对应的响应结果,否则就无法确定此次响应结果是针对哪个请求的
  2. HTTP请求和响应消息的最基本传输单位是完整的HTTP报文

可以利用以下手段来缓解队头阻塞问题:

  1. 减少请求的数量,例如:使用雪碧图,将外部文件代码书写到页面的内部元素之中

  2. 同时创建多个TCP连接,进行并行的资源传输

    浏览器对请求同一个域名下的资源,最多为其并行建立6个TCP连接

虽然在表面上HTTP 1.1的传输效率比HTTP 1.0的传输效率要好得多,但所带来的问题要比HTTP 1.0带来的问题复杂和严重得多,因此现代浏览器默认是关闭管道化模式的

HTTP 2.0

二进制分帧

在HTTP 2.0中,可以以更小的单元传输数据,该传输单元称之为帧

每个帧实际上一段二进制数据,即HTTP 2.0以二进制数据代替了传统的ASCII字符串进行传输

此外,HTTP 2.0中还出现了“流”的概念,属于一次请求响应过程的若干个帧就组成了一个流,通过流就可以判断出请求或响应帧是属于哪一次请求响应过程的

当客户端需要向服务器发送请求时,客户端可以将请求消息划分为若干个二进制帧来发送,每个帧都会有个流编号,从一个请求消息中划分出来的帧具有相同的流编号,不同请求消息中划分出来的帧的流编号不同

服务器接收完来自于一个请求消息的所有帧时,经过处理就可以向客户端发送响应消息,响应消息也可以被划分为多个帧,并且这些帧的流编号和请求帧的流编号相同,客户端收到后就可以根据帧的流编号判断出帧是对哪个请求的响应

通过分帧机制,客户端或服务器可以在一个TCP连接中交替发送属于不同流的帧,实现多个请求或响应消息的并行传输(也称多路复用),HTTP 2.0在实践中,针对一个域名的所有请求响应,也确实是在一个TCP连接中进行的

由于客户端能够区分出帧所属的流,因此即便响应顺序没有按照请求顺序进行,也不会存在HTTP协议的队头阻塞问题

HTTP 2.0解决了HTTP的队头阻塞问题,但无法解决TCP的队头阻塞问题,这是HTTP 3.0需要处理的问题

需要注意的是,不同流下的帧可以交替传输,但属于一个流下的帧需要按顺序传输,否则将无法组装成正确的消息

每一个帧都会有以个帧首部,帧所属流的编号就是在首部中记录的

image.png

帧首部中存在帧优先级字段,因此可以对帧设置优先级,优先级高的帧可以被优先传输

分帧机制出现后,请求和响应消息的格式也发生了变化,即不再具备首部行了,原本的首部行信息都会作为消息头字段存在

头部压缩

在HTTP 2.0之前,所有的消息头(请求头和响应头)都是完整地进行传输的

但消息头中难免会存在冗余信息,这些冗余的消息头就会占据一定的网络资源

HTTP 2.0使用了头部压缩策略来减少消息头的体积

客户端和服务器都会维护两种表:静态表和动态表,并且两者维护的两张表的内容是完全一致的

image.png

静态表中的内容是固定的且不会变化,当需要加入某个消息头中,并且静态表中有该消息头对应的项目时,只需要传输该项目的编号即可

接收方收到消息后,只需要根据编号找到对应的项目即可,这可以有效减少消息的体积

对于动态表,它用于记录一些自定义的头字段,动态表一开始是空表,但会在双方后续请求响应过程中逐渐往其中增加内容,之后当某次传输所包含的消息头中也出现在动态表中时,同样也将消息头替换为项目的编号

对于两张表中目前都还没有的头部,HTTP 2.0还会使用哈夫曼编码进行压缩后再传输,之后记录在动态表中的就是压缩后的结果

服务器推

在http 2.0中,服务器可以将自己认为客户端之后可能马上会用到的资源提前响应过去,即使客户端没有主动向服务器请求资源

例如:浏览器请求服务器一个HTML页面,服务器就可以将HTML页面响应给浏览器,同时HTML页面中会使用到的CSS资源、JS资源、图片资源等也可以响应给浏览器,这就可以减少浏览器发送请求的次数

WebSocket

http协议采用的是请求响应模式,即服务器在没有收到来自浏览器发来的请求时,是不能主动向浏览器发送响应消息的,这就给一些实时性要求很高的页面带来了麻烦

image.png

例如两个用户使用网页进行实时聊天,当其中一个用户发送了聊天内容后,浏览器将内容封装成请求发送给服务器,服务器收到后给该用户发出一个响应,表示“收到,没问题”

但此时另一个用户并没有向服务器发送获取聊天内容的请求,而服务器又不能主动给其发送响应,就导致实时聊天没有办法进行

为了应对这种场景,开发者们提出了许多方案,例如短轮询和长轮询,但最好解决方案是使用WebSocket

短轮询 short polling

浏览器每隔一小段时间就向服务器请求一次,询问有没有新消息

sequenceDiagram
客户端->>服务器: 有新消息吗?
服务器->>客户端: 没
Note over 客户端,服务器: 一段时间后...
客户端->>服务器: 有新消息吗?
服务器->>客户端: 没
Note over 客户端,服务器: 一段时间后...
客户端->>服务器: 有新消息吗?
服务器->>客户端: 有,user1对你说:你好
Note over 客户端,服务器: 一段时间后...
客户端->>服务器: 有新消息吗?
服务器->>客户端: 没
Note over 客户端,服务器: 一段时间后...
客户端->>服务器: 有新消息吗?
服务器->>客户端: 没

短轮询的实现是非常简单的,浏览器只需要设置一个计时器不断发送请求即可,但这种方案的缺陷也是非常明显:

  • 会产生大量无意义的请求
  • 会频繁地打开和关闭TCP连接
  • 时间间隔不好确定,可能会导致实时性并不高

长轮询 long polling

长轮询方式解决了短轮询的无意义请求过多的问题

长轮询的实现原理是:一开始浏览器向服务器发送一个请求,但服务器并不会立即响应内容给浏览器,而是当服务器接收到了来自第三方的新消息后,再将该消息响应给浏览器。当浏览器收到来自服务器的响应后,就立即再次发送请求给服务器...

sequenceDiagram
客户端->>+服务器: 有新消息吗?
Note right of 服务器: 没有新消息不会响应
Note right of 服务器: 一段时间后...
服务器->>-客户端: user1对你说:你好
客户端->>+服务器: 有新消息吗?
Note right of 服务器: 没有消息不会响应
Note right of 服务器: 一段时间后...
服务器->>-客户端: user1对你说:你吃了没
客户端->>服务器: ......

长轮询也存在一些问题:

  • 浏览器可能长时间收不到响应,导致超时,从而主动断开和服务器的连接

  • 由于浏览器可能过早的请求了服务器,使得服务器不得不挂起这个请求直到新消息的出现,这会让服务器长时间的占用资源却没什么实际的事情可做

WebSocket

伴随着HTML5出现的WebSocket协议,从根源上赋予了服务器主动推送消息给浏览器的能力

WebSocket是建立在TCP协议之上的,WebSocket是一个支持全双工通信和持久连接的协议

当浏览器和服务器的TCP连接建立完成后,还需要进行WebSocket握手

TCP本身就支持全双工通信,因此WebSocket可以直接利用建立的TCP连接实现全双工通信

完成WebSocket握手之后,浏览器或服务器就可以利用TCP建立的连接向另一方主动发送消息

image.png

虽然WebSocket优于短轮询和长轮询,但也是存在缺点的:

  • 兼容性问题
  • 维持的TCP连接需要消耗资源
WebSocket握手

当浏览器希望与服务器之间使用WebSocket进行通信时,浏览器和服务器之间首先会使用HTTP或HTTPS协议完成一次特殊的请求-响应,这一次请求-响应称之为WebSocket握手,具体过程如下:

首先会由浏览器向服务器发送一个请求,请求地址的协议部分固定为ws(对应HTTP协议)或wss(对应HTTPS协议),地址中的域名和路径部分需要和服务器端协商确定

例如:

ws://mysite.com/path
wss://mysite.com/path

该请求的请求头中会包含如下字段:

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: YWJzZmFkZmFzZmRhYw==
  • Connection: Upgrade

    表示希望将后续通信时使用的协议进行升级,升级为Upgrade请求头中指定的协议

  • Upgrade: websocket

    希望将后续通信时使用的协议升级为websocket协议

  • Sec-WebSocket-Version

    希望使用的WebSocket协议的版本

  • Sec-WebSocket-Key

    该请求头用于和服务器之间“对暗号”,其值为一个随机字符串

    当服务器同意与浏览器之间使用WebSocket协议进行通信时,会根据该随机字符串生成另一个字符串,并作为响应消息的Sec-WebSocket-Accept响应头的内容

    浏览器收到响应消息后根据该响应头就可以判断服务器是否同意本次的合作

服务器如果同意,就会响应下面的消息:

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: ZzIzMzQ1Z2V3NDUyMzIzNGVy
...

  • 状态码固定为101,状态消息为Switching Protocols(切换协议)
  • Sec-WebSocket-Accept响应头的内容是根据请求头中的Sec-WebSocket-Key生成的

握手完成后,后续消息的收发就不再使用HTTP或HTTPS协议,而是使用ws或wss协议,在这期间双方中的任意一方都可以主动发消息给对方

WebSocket API

浏览器提供了一个构造函数WebSocket,用该构造函数创建一个对象,浏览器即可向服务器进行WebSocket握手

构造函数中需要传入服务器的连接地址(地址中使用的是ws还是wss协议要看页面的协议是http还是https)

var ws = new WebSocket("ws://mysite.com:port/path");

当WebSocket握手完成后,会运行WebSocket实例的open事件:

ws.onopen = function () {
      console.log('连接到了服务器');
};

当浏览器收到来自服务器的消息后会触发WebSocket实例的message事件:

ws.onmessage = function (e) {
    console.log(e.data);		// e.data就是服务器发过来的消息
};

当TCP连接关闭时会触发WebSocket实例的close事件:

ws.onclose = function () {
    console.log('连接关闭了');
};

可以使用send方法向服务器发送数据:

ws.send(data);

注意:WebSocket所使用的并不是请求响应模式,浏览器给服务器发送消息,服务器并不一定会回应浏览器

使用readyState属性获取当前的连接状态:

// 连接状态:0-正在连接中  1-已连接  2-正在关闭中  3-已关闭
ws.readyState

使用close方法来主动关闭TCP连接:

ws.close();
Socket.io

浏览器提供的WebSocket API中,所有服务器发来的消息都只能在onmessage的事件处理函数中接收,因此浏览器很难判断收到的数据是要拿来干嘛的,因此通常使用第三方库来处理websocket消息,例如Socket.io

Socket.io提供了一种机制,它需要浏览器和服务器之间事先约定一些自定义事件,之后通过监听这些自定义事件来接收不同作用的数据,并对这些数据进行不同的处理

Socket.io为了能够实现这个功能,对传输消息的格式进行了特殊处理,因此只要一方使用Socket.io,另一方也必须使用Socket.io

Socket.io在内部预定义了一些事件名,比如:message、connect等,因此浏览器和服务器在约定自定义事件的名称时,不能使用这些名称,常见的做法是在自定义事件名称前加上$

<script src="./socket.io.js"></script>
<script>
const socket = io("ws://localhost:9527");

// 向服务器发送消息
socket.emit("$msg", "hello");

// 接收来自于服务器的消息
socket.on("$test", (data)=>{
    console.log("收到来自服务器的消息", data);
});

// 主动断开TCP连接
socket.disconnect();
</script>