基于Websocket学习项目之网页聊天室

403 阅读10分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第8天,点击查看活动详情

有这样一个需求,暂且就叫它【网页聊天室】吧,需求很简单,用户通过网页进行登录并进入指定的聊天室里进行聊天;没有其他什么花里胡哨的,就是登录、聊天;这需求就你自己干,没有后端小伙伴帮你,因为为了历练你,帮你涨知识。

先分析一通

  • 用户登录:这是为了拿到用户的信息并进入到相对应的聊天室;

  • 聊天室聊天:我发的信息其他人要接收到,其他人发的信息我也要接收到;

    也就是,我的客户端向服务端发送信息,服务端接收到我的信息之后给其他人的客户端也转发一遍,这群聊功能简单的很,可是我发现客户端给服务端发信息很简单,但服务端怎么给其他人的客户端发送消息,http走投无路啊,服务端不能给客户端发消息;难道要每个客户端都定时去问服务端,有没有人发信息了?有的话给我转发一下,谢谢。进行http轮询是可以呀,但是它不是最佳方案,那有没有一种技术,可以让客户端主动给服务端发信息,服务端也可以主动向客户端发信息呢?还真有,他就是WebSocket

WebSocket

websocket是什么?

WebSocket是HTML5提供的一种浏览器与服务器进行全双工通讯的网络技术,属应用层协议;它基于TC传输,并复用HTTP的握手通道。浏览器和服务器只需要完成一次握手,两者之间就可以创建持久的连接并进行双向数据传输;

websocket有什么特点?

  • TCP连接,与HTTP协议兼容
  • 双向通道,主动推送(服务端向客户端)
  • 无同源限制,协议标识符是(ws加密是wss)

http和websocket对比

http.png

起步

连接

  • 创建一个文件夹WebSocketTest,在下面创建clientserver文件夹,分别用于存放客户端代码和服务端代码

  • 分别在clientserver各自文件夹内执行npm init -ynpm install ws,用于初始化package.json和安装ws,结果如下

image-20221207172012948.png

  • 我们先来写一下服务端的代码,创建一个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连接成功了

image-20221208092400980.png

通信

  • 接着我们来测试一下,客户端给服务端发送消息

    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('收到,你好客户端')
      })
    })
    

    在终端执行两段代码,如下

image-20221208100959970.png

  • 我们可以发现,服务端给客户端的消息,是buffer类型的,我们可以在接收的时候进行一下解码
  • console.log('来自服务端的消息--->', Buffer.from(msg,'base64').toString());

WebSocket 事件

事件事件处理程序描述
openSocket.onopen连接建立是触发
messageSocket.onmessage客户端接收服务端数据是触发
errorSocket.onerror通信发生错误时触发
closeSocket.onclose连接关闭时触发

WebSocket 方法

方法描述
Socket.send()使用连接发送数据
Socket.close()关闭连接

WebSocket 的状态

状态状态码描述
WebSocket.CONNECTING0连接还没开启
WebSocket.OPEN1连接已开启并准备好进行通信
WebSocket.CLOSING3连接正在关闭的过程中
WebSocket.CLOSED4连接已经关闭,或者连接无法建立

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))
    })      
  })
​
})

image-20221208145142539.png

image-20221208145332018.png 至此,我们就实现了,基本的通讯功能了,但是还有很多问题需要解决?

  1. 没有房间的概念,我们每次发的信息,都会转发给其他客户端,我们想要的是发出的信息,只有在这个房间的用户可以接受到。

    加入判断,只给在线的以及本房间ID的用户发送信息

  2. 当客户端断开连接之后的操作

    用户断开,更新放假人数,并通知其他客户端

  3. 客户端与服务端的联通情况

    使用,心跳检测,检查ws的联通

  4. 用户离线缓存信息

    使用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的基本使用,就学会了,你也把这个需求干完了,没有被炒掉。

换挡

其他应用场景

  • 社交 / 订阅
  • 多玩家游戏
  • 协同办公 / 编辑
  • 股市基金报价
  • 体育实况播放
  • 音视频聊天 / 视频会议 / 在线教育
  • 基于位置的应用
  • .......

git地址

gitee.com/zhuang_quan…