为什么需要 WebSocket
HTTP 协议限制
在目前 Web 应用架构中,连接通常由 HTTP/1.0 和 HTTP/1.1 处理(部分开始使用 HTTP/2.0)。
HTTP 是客户端/服务器模式中 请求一响应 所用的协议,在这种模式中,客户端(一般是浏览器)向服务器提交 HTTP 请求,服务器响应请求的资源(例如 HTTP 页面)。
HTTP 是无状态的,也就是说,它将每个请求当成唯一的、独立的。无状态协议具有一些优势,例如,服务器不需要保存有关会话的信息,从而不需要存储数据。但是,这也意味着每次 HTTP 请求和响应中都会发送关于请求的冗余信息,比如使用 Cookie 进行用户状态的验证。
随着客户端和服务器之间交互的增加,HTTP 协议在客户端和服务器之间通信所需要的信息量快速增加,重复发送相同的信息造成了额外的花销。
另外,HTTP协议是半双工单向通讯,通信只能由客户端发起,一个请求对应一个响应,服务器无法向客户端主动发送消息。
随着 Web 2.0 时代的到来,有越来越多的应用需要 实时通讯 这样的场景,例如 天气变化通知、设备告警、在线客服等。
解决方案的历程
为了能够及时获取服务器的变化,开发者尝试过各种各样的方式:
轮询(polling)
起初使用的方案是 Ajax 轮询:开个定时器,定时请求服务器数据,达到一个类似实时的效果。
缺点:
- 资源消耗过大,服务器需要处理每个客户端定时发送的请求
- 大部分处理无意义,数据并没有发生变化,但是仍要定时通讯
- 即使获取到有效数据,也不是准确失效的,仍存在延时问题
长轮询(lone polling)
客户端向服务器请求信息,并在设定的时间端内保持连接。直到服务器有新消息响应,或者连接超时。
这种技术常常称作 “挂起 GET” 或 “搁置 POST”。
缺点:占用服务器资源,相对轮询并没有优势,没有标准化,每个人的实现不一样。
流化技术
在流化技术中,客户端发送一个请求,服务器发送并维护一个持续更新和保持打开(可以是无限或规定的时间段)的开放响应。
每当服务器有需要交付给客户端的信息时,它就更新响应。服务器从不发出完整的 HTTP 响应。
如文件上传和下载就是一定时间内的流化技术,将数据拆分成多个包一个一个传递。
但是所谓的流化技术,在 HTTP 中是不可能出现长时间的连接建立的,代理和防火墙可能会缓存响应,导致信息交付的延时,以及数据的不稳定性和不安全性。
所以流化技术也不是合适的方案,最终也被淘汰了。
WebSocket 的诞生
以上三种技术都是基于 HTTP 协议来实现的,无论什么情况,它们都会涉及 HTTP 的请求和响应,包含附加很多不必要的 Header 数据和延时。
无论哪个技术,客户端都需要等待请求的返回,才能发出后续的请求,这显著增加了延时,并且大大加大了服务器的压力。
于是2008年 HTML5 定义了 WebSocket 协议,并于2011年成为国际标准,现已被绝大多数浏览器支持。
WebSocket 最大的特点就是服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息。
另外它没有同源限制,客户端可以与任意服务器通信,也是 跨域 的一个方式。
什么是 WebSocket
介绍
WebSocket 是一种自然的全双工、双向、单套接字连接协议。解决了 HTTP 协议中不适合于实时通信的不足。
WebSocket 协议能够通过 Web 进行客户端和服务器之间的全双工通信,并支持二进制数据和文本字符串的传输。
这个协议由开始的握手和之后的基本消息框架组成,是建立在 TCP 协议上的。相比于 HTTP 协议,WebSocket 连接一旦建立,即可进行双向的实时通信。
特点
- 建立在 TCP 协议之上,服务器端的实现比较容易。
- 与 HTTP 协议有着良好的兼容性。默认端口也是 80 和 443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
- 数据格式比较轻量,性能开销小,通信高效。
- 可以发送文本,也可以发送二进制数据。
- 没有同源限制,客户端可以与任意服务器通信。
相似技术
-
Server-sent Events(SSE):服务端推送技术,现在用的不多了
-
https://www.ruanyifeng.com/blog/2017/05/server-sent_events.html
-
https://www.cnblogs.com/goloving/p/9196066.html
-
-
SPDY : Google 开发,已不再维护,由 HTTP/2 取代
- https://baike.baidu.com/item/SPDY/3399551#7
-
WebRTC: 可以更好的处理流数据,例如直播
- https://baike.baidu.com/item/WebRTC/5522744
WebSocket 通信原理
WebSocket 连接是如何建立的?
前面说过,WebSocket 在握手阶段采用的是 HTTP 协议,Websocket 借用了 HTTP 的一部分协议来完成一次握手。(HTTP的三次握手,此处只完成一次)
HTTP 请求与响应首部
WebSocket 请求与响应首部
Connection: keep-alive, Upgrade
的 Upgrade
标识表示,询问是否可以将已建立的连接升级为Upgrade
请求头指定的一个或多个协议(按降序排列)。
如果服务器允许升级连接,就会:
- 返回 101 响应状态
- 返回
Upgrade
响应头:包含指定要切换到的协议 - 返回
Sec-Websocket-Accept
:根据客户端发送的Sec_WebSocket-Key
密钥(哈希值)生成的标识,用于唯一标识和服务器建立连接的不同客户端 - 使用新协议发送对原始请求的响应
服务器响应以上内容就表示连接建立成功了。
连接通信模拟
HTTP 轮询
首先是 ajax 轮询 ,ajax 轮询的原理非常简单,让浏览器隔个几秒就发送一次请求,询问服务器是否有新信息
场景再现:
客户端:啦啦啦,有没有新信息(Request)
服务端:没有(Response)
客户端:啦啦啦,有没有新信息(Request)
服务端:没有。。(Response)
客户端:啦啦啦,有没有新信息(Request)
服务端:你好烦啊,没有啊。。(Response)
客户端:啦啦啦,有没有新消息(Request)
服务端:好啦好啦,有啦给你 ‘xxxxxxx’ 。(Response)
客户端:啦啦啦,有没有新消息(Request)
服务端:。。。没。。。。没。。没有
从上面可以看出,轮询其实就是在不断地建立HTTP连接,然后等待服务端处理,可以体现 HTTP 协议的另外一个特点,被动性。同时,HTTP 的每一次请求与响应结束后,服务器将客户端信息全部丢弃,下次请求,必须携带身份信息(cookie),无状态性;
WebSocket
客户端通过 HTTP (骑马) 带着信请求服务器,但同时,携带了Upgrade:websocket
和 Connection:Upgrade
(两根管子),服务器如果支持WebSocket 协议(有两根管子的接口),使用 WebSocket 协议返回可用信息(丢弃马匹),此后信息的传递,均使用这两个管子,除非有一方人为的将管子切断;若服务器不支持,客户端请求链接失败,返回错误信息。
上面的情景变为:
客户端:啦啦啦,我要建立 WebSocket 协议,需要的服务:chat,Websocket 协议版本:13(HTTP Request)
服务端:ok,确认,已升级为 WebSocket 协议(HTTP Protocols Switched)
客户端:麻烦你有信息的时候推送给我噢。。
服务端:ok,有的时候会告诉你的。
客户端:balab开始自己耍alabala
服务端:A 设备报警了
客户端:赶紧探个提示窗口
服务端:B 设备报警了
服务端:C 设备报警处理了
WebSocket 使用
原生使用
浏览器提供一个原生的 WebSocket 对象,类似 HTTP 协议的 XMLHttpRequest。
// HTTP 协议使用 XMHttpRequest 进行通信
// WebSocket 协议使用 WebSocket 进行通信
// 1. 建立连接
const socket = new WebSocket('ws://localhost:8080')
// 2. 连接成功触发 open 事件
socket.addEventListener('open', function (event) {
// 向服务器发送消息
socket.send('Hello Server!')
})
// 3. 接收服务器的消息
socket.addEventListener('message', function (event) {
console.log('Message from server ', event.data)
})
第三方库
由于原生的 WebSocket 满足不了快速简单的开发需求,所以有一个第三方库 socket.io。
socket.io 提供了服务端和客户端的实现,所以必须配套使用。
案例实践
使用 WebSocket 实现一个简单的聊天室
参考
初始化项目
# 创建项目根目录
mkdir socketio-demo
cd socketio-demo
# 初始化 package.json
npm init -y
# 安装 express 开启一个服务器
npm install express
创建 app.js
,初始化服务:
// /app.js
// 后台
const express = require('express')
const app = express()
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(3000, () => {
console.log(`Server is running at http://localhost:3000`)
})
nodemon ./app.js
启动服务。
创建 HTML 模板
创建 HTML 模板 /public/index.html
<!DOCTYPE html>
<html>
<head>
<title>Socket.IO chat</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font: 13px Helvetica, Arial;
}
form {
background: #000;
padding: 3px;
position: fixed;
bottom: 0;
width: 100%;
}
form input {
border: 0;
padding: 10px;
width: 90%;
margin-right: 0.5%;
}
form button {
width: 9%;
background: rgb(130, 224, 255);
border: none;
padding: 10px;
}
#messages {
list-style-type: none;
margin: 0;
padding: 0;
}
#messages li {
padding: 5px 10px;
}
#messages li:nth-child(odd) {
background: #eee;
}
</style>
</head>
<body>
<!-- 消息列表 -->
<ul id="messages"></ul>
<!-- 发送消息的表单 -->
<form action="">
<input id="m" autocomplete="off" />
<button>Send</button>
</form>
</body>
</html>
把模板内容响应给客户端
// /app.js
// 后台
const express = require('express')
const app = express()
// 托管 public 目录中的静态资源
app.use(express.static('./public'))
// app.get('/', (req, res) => {
// res.send('Hello World!')
// })
app.listen(3000, () => {
console.log(`Server is running at http://localhost:3000`)
})
刷新 http://localhost:3000
展示的就是 /public/index.html
的内容。
集成 Socket.IO
安装 socket.io
npm install socket.io
修改服务端代码:
// /app.js
// 后台
const express = require('express')
const http = require('http')
const app = express()
// 创建服务实例
const server = http.createServer(app)
// 在 HTTP 服务中集成 socket.io, 用来提供 WebSocket 资源服务
const io = require('socket.io')(server)
// 通过 app 设置的都是 HTTP 资源请求处理
// 托管 public 目录中的静态资源
app.use(express.static('./public'))
// 通过 io 设置的都是 WebSocket 资源请求处理
io.on('connection', socket => {
// socket 是客户端的请求相关信息对象
console.log('a user connected')
// 监听连接断开事件
socket.on('disconnect', () => {
console.log('user disconnected')
})
})
// app.get('/', (req, res) => {
// res.send('Hello World!')
// })
server.listen(3000, () => {
console.log(`Server is running at http://localhost:3000`)
})
修改 HTML 模板:
<body>
<!-- 消息列表 -->
<ul id="messages"></ul>
<!-- 发送消息的表单 -->
<form action="">
<input id="m" autocomplete="off" />
<button>Send</button>
</form>
<!-- 加载 socket.io 客户端 -->
<script src="/socket.io/socket.io.js"></script>
<script>
// 调用 io() 默认尝试连接到当前页面提供服务的主机
// 这也是服务器通过 on 监听事件触发的回调函数中传入的 socket 对象
const socket = io()
</script>
</body>
刷新 http://localhost:3000
服务端控制台会触发 connection
打印 a user connected
通信日志
WebSocket 通过 HTTP 协议建立连接。
所以可以在 Network(网络) 中找到建立连接的请求记录。
不过显示的内容与 HTTP 请求有些不同。
HTTP 请求日志直接显示在 Network 中。
WebSocket 在建立连接后,使用 WebSocket 进行通信,通信日志全部显示在这一个连接的 **Message(消息)**中。
绿色箭头的是客户端发送信息的记录,红色箭头的是接收信息的记录。
这里定时增加的2
3
5
之类的消息是 socket.io
内部进行的心跳检测,检测连接是否有效,如果使用原生的 WebSocket,就需要自己实现这个功能。
收发事件
Socket.IO 的主要思想就是可以发送和接收所需的任何事件以及所需的任何数据。
下面创建一个 chat message
(聊天消息)事件,让客户端提交输入的消息时,发送给服务器,服务器接收这个事件传过来的数据。
客户端可以使用 :
socket.emit(EventName, data)
向当前客户端发送数据socket.on(EventName, callback)
监听当前客户端的事件,接收数据
服务器在建立连接的事件中可以获取客户端的 socket
对象,调用上面的方法可以对这个客户端发送消息,或监听这个客户端的事件。
服务器可以使用:
io.emit(EventName, data)
向所有连接中的客户端发送数据io.on(EventName, callback)
监听所有连接中的客户端的事件,接收数据
修改 HTML 模板,使用 Vue 实现交互功能:
<body>
<div id="app">
<!-- 消息列表 -->
<ul id="messages"></ul>
<!-- 发送消息的表单 -->
<form action="">
<input id="m" autocomplete="off" v-model="message" />
<button @click.prevent="handleSend">Send</button>
</form>
</div>
<!-- 加载 socket.io 客户端 -->
<script src="/socket.io/socket.io.js"></script>
<!-- 加载 vue -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js"></script>
<script>
// 调用 io() 默认尝试连接到当前页面提供服务的主机
// 这也是服务器通过 on 监听事件触发的回调函数中传入的 socket 对象
const socket = io()
new Vue({
el: '#app',
data: {
message: ''
},
methods: {
// 发送消息
handleSend() {
console.log(this.message)
}
}
})
</script>
</body>
修改 handleSend
,向服务器发送消息:
// 发送消息
handleSend() {
socket.emit('chat message', this.message)
this.message = ''
}
服务器监听单个(当前)客户端的 chat message
事件:
// 通过 io 设置的都是 WebSocket 资源请求处理
// 监听连接建立事件
io.on('connection', socket => {
// socket 是客户端的请求相关信息对象
console.log('a user connected')
// 监听连接断开事件
socket.on('disconnect', () => {
console.log('user disconnected')
})
// 接收消息
socket.on('chat message', console.log)
})
发送几个聊天记录:
广播
广播给所有客户端 io.emit
Socket.IO 提供了 io.emit()
方法向所有连接中的客户端发送信息,例如聊天室有人发送了一条 message,每个客户端的聊天记录中都要添加这条 message。
修改 HTML 模板:
<body>
<div id="app">
<!-- 消息列表 -->
<ul id="messages">
<li v-for="(message, index) in messageList" :key="index">{{message}}</li>
</ul>
<!-- 发送消息的表单 -->
<form action="">
<input id="m" autocomplete="off" v-model="message" />
<button @click.prevent="handleSend">Send</button>
</form>
</div>
<!-- 加载 socket.io 客户端 -->
<script src="/socket.io/socket.io.js"></script>
<!-- 加载 vue -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js"></script>
<script>
// 调用 io() 默认尝试连接到当前页面提供服务的主机
// 这也是服务器通过 on 监听事件触发的回调函数中传入的 socket 对象
const socket = io()
new Vue({
el: '#app',
data: {
message: '',
messageList: []
},
created() {
// 接收服务器发来的信息
socket.on('chat message', data => {
this.messageList.push(data)
})
},
methods: {
// 发送消息
handleSend() {
socket.emit('chat message', this.message)
this.message = ''
}
}
})
</script>
</body>
修改服务端:
// 通过 io 设置的都是 WebSocket 资源请求处理
// 监听连接建立事件
io.on('connection', socket => {
// socket 是客户端的请求相关信息对象
console.log('a user connected')
// 监听连接断开事件
socket.on('disconnect', () => {
console.log('user disconnected')
})
// 接收消息
socket.on('chat message', data => {
// 广播给所有的聊天室客户端
io.emit('chat message', data)
})
})
可以打开多个标签页,每个标签页就是一个客户端,任意一个发送消息,其他客户端都会展示。
为了区分客户端,可以在服务器手动存储客户端,给它们起个名字,并维护:
// /app.js
// 后台
const express = require('express')
const http = require('http')
const app = express()
// 创建服务实例
const server = http.createServer(app)
// 在 HTTP 服务中集成 socket.io, 用来提供 WebSocket 资源服务
const io = require('socket.io')(server)
// 存储所有的客户端连接 socket
const clients = []
let clientIndex = 1
// 通过 app 设置的都是 HTTP 资源请求处理
// 托管 public 目录中的静态资源
app.use(express.static('./public'))
// 通过 io 设置的都是 WebSocket 资源请求处理
// 监听连接建立事件
io.on('connection', socket => {
// 记录客户端
socket.nickName = `用户${clientIndex++}`
clients.push(socket)
// socket 是客户端的请求相关信息对象
console.log('a user connected')
// 监听连接断开事件
socket.on('disconnect', () => {
console.log('user disconnected')
// 移除客户端
const index = clients.findIndex(c => c === socket)
if (index !== -1) {
clients.splice(index, 1)
}
})
// 接收消息
socket.on('chat message', data => {
// 广播给所有的聊天室客户端
io.emit('chat message', `${socket.nickName} 说:${data}`)
})
})
server.listen(3000, () => {
console.log(`Server is running at http://localhost:3000`)
})
广播给其它客户端 socket.broadcast.emit
如果想要给某个客户端以外的所有客户端发送信息,可以使用 socket.broadcast.emit
例如通知其他客户端,有新的用户进入/离开聊天室(建立/断开连接)
修改服务器:
// 通过 io 设置的都是 WebSocket 资源请求处理
// 监听连接建立事件
io.on('connection', socket => {
// 记录客户端
socket.nickName = `用户${clientIndex++}`
clients.push(socket)
// socket 是客户端的请求相关信息对象
console.log('a user connected')
// 通知其他用户有新用户进入
socket.broadcast.emit('users change', `${socket.nickName} 进入聊天室`)
// 监听连接断开事件
socket.on('disconnect', () => {
console.log('user disconnected')
// 通知其他用户有用户离开
socket.broadcast.emit('users change', `${socket.nickName} 离开聊天室`)
// 移除客户端
const index = clients.findIndex(c => c === socket)
if (index !== -1) {
clients.splice(index, 1)
}
})
// 接收消息
socket.on('chat message', data => {
// 广播给所有的聊天室客户端
io.emit('chat message', `${socket.nickName} 说:${data}`)
})
})
修改 HTML 模板:
new Vue({
el: '#app',
data: {
message: '',
messageList: []
},
created() {
// 接收服务器发来的信息
socket.on('chat message', data => {
this.messageList.push(data)
})
// 监听聊天室成员进出信息
socket.on('users change', data => {
console.log(data)
this.messageList.push(data)
})
},
methods: {
// 发送消息
handleSend() {
socket.emit('chat message', this.message)
this.message = ''
}
}
})