开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第8天,点击查看活动详情
有这样一个需求,暂且就叫它【网页聊天室】吧,需求很简单,用户通过网页进行登录并进入指定的聊天室里进行聊天;没有其他什么花里胡哨的,就是登录、聊天;这需求就你自己干,没有后端小伙伴帮你,因为为了历练你,帮你涨知识。
先分析一通
-
用户登录:这是为了拿到用户的信息并进入到相对应的聊天室;
-
聊天室聊天:我发的信息其他人要接收到,其他人发的信息我也要接收到;
也就是,我的客户端向服务端发送信息,服务端接收到我的信息之后给其他人的客户端也转发一遍,这群聊功能简单的很,可是我发现客户端给服务端发信息很简单,但服务端怎么给其他人的客户端发送消息,
http
走投无路啊,服务端不能给客户端发消息;难道要每个客户端都定时去问服务端,有没有人发信息了?有的话给我转发一下,谢谢。进行http轮询
是可以呀,但是它不是最佳方案,那有没有一种技术,可以让客户端主动给服务端发信息,服务端也可以主动向客户端发信息
呢?还真有,他就是WebSocket
WebSocket
websocket是什么?
WebSocket是HTML5提供的一种浏览器与服务器进行全双工通讯
的网络技术,属应用层
协议;它基于TC传输,并复用HTTP的握手通道。浏览器和服务器只需要完成一次握手,两者之间就可以创建持久的连接并进行双向数据传输;
websocket有什么特点?
- TCP连接,与HTTP协议兼容
- 双向通道,主动推送(服务端向客户端)
- 无同源限制,协议标识符是(ws加密是wss)
http和websocket对比
起步
连接
-
创建一个文件夹
WebSocketTest
,在下面创建client
和server
文件夹,分别用于存放客户端代码和服务端代码 -
分别在
client
和server
各自文件夹内执行npm init -y
和npm install ws
,用于初始化package.json
和安装ws,结果如下
-
我们先来写一下服务端的代码,创建一个
src
目录并在其下创建一个index.js
const WebSocket = require('ws') const wss = new WebSocket.Server({ port: 4399 }) wss.on('connection', function connection(ws) { console.log('有一个客户端连接了'); })
在终端执行
node server/src/index
-
在客户端代码下创建
test.js
,我们来测试一下连接const WebSocket = require('ws') const ws = new WebSocket('ws://127.0.0.1:4399') ws.on('open', function () { console.log('客户端连接服务端'); })
在另一个终端执行
node client/test.js
-
此时可以在终端看到打印的内容 证明我们就使用websocket连接成功了
通信
-
接着我们来测试一下,客户端给服务端发送消息
const WebSocket = require('ws') const ws = new WebSocket('ws://127.0.0.1:4399') ws.on('open', function () { console.log('客户端连接服务端'); ws.send('你好,服务端') ws.on('message', function (msg) { console.log('来自服务端的消息--->', msg); }) })
-
服务端接收消息,并主动给客户端发送消息
const WebSocket = require('ws') const wss = new WebSocket.Server({ port: 4399 }) wss.on('connection', function connection(ws) { console.log('有一个客户端连接了'); // 接收客户端的信息 ws.on('message', function (msg) { console.log('来自客户端的信息-->' + msg); // 主动向客服端发信息 ws.send('收到,你好客户端') }) })
在终端执行两段代码,如下
- 我们可以发现,服务端给客户端的消息,是buffer类型的,我们可以在接收的时候进行一下解码
console.log('来自服务端的消息--->', Buffer.from(msg,'base64').toString());
WebSocket 事件
事件 | 事件处理程序 | 描述 |
---|---|---|
open | Socket.onopen | 连接建立是触发 |
message | Socket.onmessage | 客户端接收服务端数据是触发 |
error | Socket.onerror | 通信发生错误时触发 |
close | Socket.onclose | 连接关闭时触发 |
WebSocket 方法
方法 | 描述 |
---|---|
Socket.send() | 使用连接发送数据 |
Socket.close() | 关闭连接 |
WebSocket 的状态
状态 | 状态码 | 描述 |
---|---|---|
WebSocket.CONNECTING | 0 | 连接还没开启 |
WebSocket.OPEN | 1 | 连接已开启并准备好进行通信 |
WebSocket.CLOSING | 3 | 连接正在关闭的过程中 |
WebSocket.CLOSED | 4 | 连接已经关闭,或者连接无法建立 |
WebSocket 的实例对象中提供了 readyState
属性来判断当前状态
挂挡
实践了一些websocket的简单的方法,我们开始实现聊天室的功能,首先前端我们使用我们熟悉的Vue框架来帮助我们完成;使用cdn
引入即可,因为我们主要还是用于学习,创建一个index.html;写我们的前端代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.staticfile.org/vue/2.6.14/vue.min.js"></script>
</head>
<body>
<div id="app">
<div v-if="isShow">
<p>昵称:<input type="text" v-model="name"></p>
<p>uid:<input type="text" v-model="uid"></p>
<p>房间号:<input type="text" v-model="roomid"></p>
<button type="button" @click="enter()">进入聊天室</button>
</div>
<div v-else>
<ul>
<li v-for="(item,index) in lists" :key="'message' + index">{{item}}</li>
<li>在线人数{{num}}</li>
</ul>
<div class="ctr">
<input type="text" id="msg" v-model="message">
<button type="button" id="send" @click="send()">发送</button>
</div>
</div>
</div>
<script>
const app = new Vue({
el: '#app',
data: {
isShow: true, // 是否登录
message: '', // 发送信息
lists: [], // 消息列表
ws: {}, // websocket实例
name: '', // 用户名
num: 0, // 在线人数
roomid: '', // 房间号
uid: '', // 用户ID
},
methods: {
enter() {
if (this.name.trim() === '') {
alert("用户名不得为空")
return
}
this.init()
this.isShow = false
},
init() {
this.ws = new WebSocket('ws://127.0.0.1:4399')
this.ws.onopen = this.onOpen
this.ws.onmessage = this.onMessage
this.ws.onclose = this.onClose
this.ws.onerror = this.onError
},
onOpen() {
console.log('open:' + this.ws.readyState);
const senMsg = {
event: 'enter',
message: this.name,
roomid: this.roomid,
uid: this.uid
}
this.ws.send(JSON.stringify(senMsg))
},
onMessage(event) {
// 当用户未进入聊天室,则不接收消息
if (this.isShow) {
return
}
// 接收服务端发送过来的消息
const obj = JSON.parse(event.data)
console.log(obj);
switch (obj.event) {
case 'enter':
// 当有一个新的用户进入聊天室
this.lists.push('欢迎:' + obj.message + '加入聊天室')
break;
case 'out':
this.lists.push(obj.name + '已经退出了聊天室')
break;
default:
if (obj.name !== this.name) {
// 接收正常的聊天
this.lists.push(obj.name + ':' + obj.message)
}
}
this.num = obj.num
},
onClose() {
console.log('close:' + this.ws.readyState);
console.log('已关闭websocket')
},
onError() {
// 当连接失败时,触发error事件
console.log('error:' + this.ws.readyState);
console.log('websocket连接失败!');
// 连接失败之后,1s进行断线重连!
setTimeout(() => {
this.init()
}, 1000);
},
send() {
this.lists.push(this.name + ':' + this.message)
this.ws.send(JSON.stringify({
event: 'message',
message: this.message,
name: this.name
}))
this.message = ''
}
}
})
</script>
</body>
</html>
服务端、我们将登陆的信息进行保存;并在接到消息的时候,把消息转发给其他客户端
const WebSocket = require('ws')
const wss = new WebSocket.Server({
port: 4399
})
let group = {}
wss.on('connection', function connection(ws) {
console.log('有一个客户端连接了');
// 接收客户端的信息
ws.on('message', function (msg) {
const msgObj = JSON.parse(msg.toString())
if (msgObj.event === 'enter') {
ws.name = msgObj.message
ws.roomid = msgObj.roomid
ws.uid = msgObj.uid
// 统计房间里的人数,用于后面的消息发送
if (typeof group[ws.roomid] === 'undefined') {
group[ws.roomid] = 1
} else {
group[ws.roomid]++
}
}
// 广播消息
wss.clients.forEach((client) => {
msgObj.name = ws.name
msgObj.num = group[ws.roomid]
client.send(JSON.stringify(msgObj))
})
})
})
至此,我们就实现了,基本的通讯功能了,但是还有很多问题需要解决?
-
没有房间的概念,我们每次发的信息,都会转发给其他客户端,我们想要的是发出的信息,只有在这个房间的用户可以接受到。
加入判断,只给在线的以及本房间ID的用户发送信息
-
当客户端断开连接之后的操作
用户断开,更新放假人数,并通知其他客户端
-
客户端与服务端的联通情况
使用,心跳检测,检查ws的联通
-
用户离线缓存信息
使用redis缓存服务,如果本房间内存在离线用户,则进行存储;并在每次广播信息时,查看用户在线,是否存在离线信息,如果有则发送离线消息,并清空存储
加速
心跳检测
websocket规范定义了心跳机制,一方可以通过发送ping
消息给另一方,另一方收到ping
后应该尽可能快的返回pong
。心跳机制是用于检测连接的对方在线状态,因此如果没有心跳,那么无法判断一方还在连接状态中,一些网络层比如 nginx 或者浏览器层会主动断开连接,在 JavaScript 中,WebSocket 并没有开放 ping/pong 的 API ,虽然浏览器自带了心跳处理,然而不同厂商的实现也不尽相同,因此需要在我们开发时候与服务端约定好一个自实现的心跳机制;比如浏览器中,检测到 open 事件后,启动一个定时任务,每次发送数据 ping
给服务端,而服务端返回 pong
作为响应;实践下来,心跳的定时任务一般是相隔 15-20 秒发送一次。
Websocket 是建立与 TCP 之上。Websocket 连接分为建连阶段与连接阶段,在建立连接阶段借助于 HTTP ,而在连接阶段则与 HTTP 无关。
Redis缓存
Redis缓存是一个开源的使用ANSIC语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。
- 安装
redis
,npm install redis
- 安装
bluebird
,使用promis异步方式
封装redis常用方法
const redis = require('redis')
const {
promisifyAll
} = require('bluebird')
const config = require('./index')
const option = {
host: config.REDIS.host,
port: config.REDIS.port,
password: config.REDIS.password,
detect_buffers: true, // 如果设置为true时callback中拿到的数据都会转换成Buffers
// 一个用于接收一个options对象作为参数的函数,options中包括attempt(重试次数)、total_retry_time(自上一次连接以来经过的重试总时间)、times_connected(连接的总次数)、error(连接断开的原因)。如果该函数返回一个Number类型,则重试将在该时间(单位ms)之后发生。如果返回的不是数字类型,则不会进行重试,所有未执行的命令都会抛出错误。
retry_strategy: function (options) {
if (options.error && option.error.code === 'ECONNREFUSED') { //ECONNREFUSED:尝试连接失败
//结束对特定错误的重新连接,并刷新所有命令
return new Error("The server refused the connection") //服务器拒绝了连接
}
if (options.total_retry_time > 1000 * 60 * 60) {
//在特定超时后结束重新连接并刷新所有命令
return new Error('Retry time exhausted') //重试时间已用尽
}
if (option.attempt > 10) {
// 结束重新连接,出现内置错误
return undefined
}
// 重新连接
return Math.min(options.attempt * 100, 3000)
}
}
const client = promisifyAll(redis.createClient(option))
client.on('error', (err) => {
console.log('Redis Client Error:' + err);
})
const setValue = (key, value, time) => {
if (typeof value === 'undefined' || value == null || value === '') {
return
}
if (typeof value === 'string') {
if (typeof time !== 'undefined') {
client.set(key, value, 'EX', time)
} else {
client.set(key, value)
}
} else if (typeof value === 'object') {
Object.keys(value).forEach((item) => {
client.hSet(key, item, value[item], redis.print)
})
}
}
const getValue = (key) => {
return client.getAsync(key)
}
const getHValue = (key) => {
return client.hgetallAsync(key)
}
const delValue = (key) => {
client.del(key, (err, res) => {
if (res === 1) {
console.log('delete successfully');
} else {
console.log('delete redis key error:' + key);
}
})
}
// 存在
const existKey = async function (key) {
const result = await client.existsAsync(key)
return result
}
const deleteKey = async function (key) {
const result = await client.delAsync(key)
return result
}
module.exports = {
client,
setValue,
getValue,
getHValue,
delValue,
existKey,
deleteKey
}
前端最终代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.staticfile.org/vue/2.6.14/vue.min.js"></script>
</head>
<body>
<div id="app">
<div v-if="isShow">
<p>昵称:<input type="text" v-model="name"></p>
<p>uid:<input type="text" v-model="uid"></p>
<p>房间号:<input type="text" v-model="roomid"></p>
<button type="button" @click="enter()">进入聊天室</button>
</div>
<div v-else>
<ul>
<li v-for="(item,index) in lists" :key="'message' + index">{{item}}</li>
<li>在线人数{{num}}</li>
</ul>
<div class="ctr">
<input type="text" id="msg" v-model="message">
<button type="button" id="send" @click="send()">发送</button>
</div>
</div>
</div>
<script>
const app = new Vue({
el: '#app',
data: {
isShow: true, // 是否登录
message: '', // 发送信息
lists: [], // 消息列表
ws: {}, // websocket实例
name: '', // 用户名
num: 0, // 在线人数
roomid: '', // 房间号
uid: '', // 用户ID
},
methods: {
enter() {
if (this.name.trim() === '') {
alert("用户名不得为空")
return
}
this.init()
this.isShow = false
},
init() {
this.ws = new WebSocket('ws://127.0.0.1:4399')
this.ws.onopen = this.onOpen
this.ws.onmessage = this.onMessage
this.ws.onclose = this.onClose
this.ws.onerror = this.onError
},
onOpen() {
console.log('open:' + this.ws.readyState);
const senMsg = {
event: 'enter',
message: this.name,
roomid: this.roomid,
uid: this.uid
}
this.ws.send(JSON.stringify(senMsg))
},
onMessage(event) {
// 当用户未进入聊天室,则不接收消息
if (this.isShow) {
return
}
// 接收服务端发送过来的消息
const obj = JSON.parse(event.data)
console.log(obj);
switch (obj.event) {
case 'enter':
// 当有一个新的用户进入聊天室
this.lists.push('欢迎:' + obj.message + '加入聊天室')
break;
// 当有一个的用户退出聊天室
case 'out':
this.lists.push(obj.name + '已经退出了聊天室')
break;
case 'heartbeat':
this.ws.send(JSON.stringify({
event: 'heartbeat',
message: 'pong'
}))
break
default:
if (obj.name !== this.name) {
// 接收正常的聊天
this.lists.push(obj.name + ':' + obj.message)
}
}
this.num = obj.num
},
onClose() {
console.log('close:' + this.ws.readyState);
console.log('已关闭websocket')
},
onError() {
// 当连接失败时,触发error事件
console.log('error:' + this.ws.readyState);
console.log('websocket连接失败!');
// 连接失败之后,1s进行断线重连!
setTimeout(() => {
this.init()
}, 1000);
},
send() {
this.lists.push(this.name + ':' + this.message)
this.ws.send(JSON.stringify({
event: 'message',
message: this.message,
name: this.name
}))
this.message = ''
}
}
})
</script>
</body>
</html>
后端最终代码
const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 4399 })
const {getValue, setValue, existKey} = require('./config/RedisConfig')
// 多聊天室的功能
// roomid -> 对应相同的roomid进行广播消息
let group = {}
const prefix = 'qq-'
wss.on('connection', function connection (ws) {
// 初始的心跳连接状态
ws.isAlive = true
console.log('one client is connected');
// 接收客户端的消息
ws.on('message', async function(msg) {
const msgObj = JSON.parse(msg)
const roomid = prefix + (msgObj.roomid ? msgObj.roomid : ws.roomid)
if (msgObj.event === 'enter') {
// 当用户进入之后,需要判断用户的房间是否存在
// 如果用户的房间不存在,则在redis中创建房间号,用户保存用户信息
// 主要是用于统计房间里的人数,用于后面进行消息发送
ws.name = msgObj.message
ws.roomid = msgObj.roomid
ws.uid = msgObj.uid
console.log('TCL: connection -> ws.uid', ws.uid)
// 判断redis中是否有对应的roomid的键值
const result = await existKey(roomid)
if (result === 0) {
// 初始化一个房间数据
setValue(roomid, ws.uid)
} else {
// 已经存在该房间缓存数据
const arrStr = await getValue(roomid)
let arr = arrStr.split(',')
if (arr.indexOf(ws.uid) === -1) {
setValue(roomid, arrStr + ',' + ws.uid)
}
}
if (typeof group[ws.roomid] === 'undefined') {
group[ws.roomid] = 1
} else {
group[ws.roomid] ++
}
}
// 心跳检测
if (msgObj.event === 'heartbeat' && msgObj.message === 'pong') {
ws.isAlive = true
return
}
// 广播消息
// 获取房间里所有的用户信息
const arrStr = await getValue(roomid)
let users = arrStr.split(',')
wss.clients.forEach(async (client) => {
// 判断非自己的客户端
if (client.readyState === WebSocket.OPEN && client.roomid === ws.roomid) {
msgObj.name = ws.name
msgObj.num = group[ws.roomid]
client.send(JSON.stringify(msgObj))
// 排队已经发送了消息了客户端 -> 在线
if (users.indexOf(client.uid) !== -1) {
users.splice(users.indexOf(client.uid), 1)
}
// 消息缓存信息:取redis中的uid数据
let result = await existKey(ws.uid)
if (result !== 0) {
// 存在未发送的离线消息数据
let tmpArr = await getValue(ws.uid)
let tmpObj = JSON.parse(tmpArr)
let uid = ws.uid
if (tmpObj.length > 0) {
let i = []
// 遍历该用户的离线缓存数据
// 判断用户的房间id是否与当前一致
tmpObj.forEach((item) => {
if (item.roomid === client.roomid && uid === client.uid) {
client.send(JSON.stringify(item))
i.push(item)
}
})
// 删除已经发送的缓存消息数据
if (i.length > 0) {
i.forEach((item) => {
tmpObj.splice(item, 1)
})
}
setValue(ws.uid, JSON.stringify(tmpObj))
}
}
}
})
// 断开了与服务端连接的用户的id,并且其他的客户端发送了消息
if (users.length> 0 && msgObj.event === 'message') {
users.forEach(async function(item) {
const result = await existKey(item)
if (result !== 0) {
// 说明已经存在其他房间该用户的离线消息数据
let userData = await getValue(item)
let msgs = JSON.parse(userData)
msgs.push({
roomid: ws.roomid,
...msgObj
})
setValue(item, JSON.stringify(msgs))
} else {
// 说明先前这个用户一直在线,并且无离线消息数据
setValue(item, JSON.stringify([{
roomid: ws.roomid,
...msgObj
}]))
}
})
}
})
// 当ws客户端断开链接的时候
ws.on('close', function() {
if (ws.name) {
group[ws.roomid] --
}
let msgObj = {}
// 广播消息
wss.clients.forEach((client) => {
// 判断非自己的客户端
if (client.readyState === WebSocket.OPEN && ws.roomid === client.roomid) {
msgObj.name = ws.name
msgObj.num = group[ws.roomid]
msgObj.event = 'out'
client.send(JSON.stringify(msgObj))
}
})
})
})
到这里,websocket的基本使用,就学会了,你也把这个需求干完了,没有被炒掉。
换挡
其他应用场景
- 社交 / 订阅
- 多玩家游戏
- 协同办公 / 编辑
- 股市基金报价
- 体育实况播放
- 音视频聊天 / 视频会议 / 在线教育
- 基于位置的应用
- .......