从零到一搭建一个群聊系统

 

前言

IM(Instant Messaging),也就是即时通讯。几乎所有对实时性要求高的应用场景都需要IM技术的运用。比如聊天、直播、弹幕、实时位置共享、协同编辑/在线文档、股票基金报价等。

本篇将带大家从零开始搭建实现一个轻量群聊的完整闭环。客户端用到的是vue+websocket通信,服务端用到是node的ws模块通信,redis用于便于快速读取在线状态等数据的存取,MongoDB聊天消息等持久数据存储。

已经实现的功能:进入聊天室,输入临时昵称用于聊天区分(前端是暂时是存在浏览器的sessionStorage里,后端作为唯一用户名存到了数据库,输入昵称保存会校验昵称是否存在,后面可以根据需要通过扩展加上登录等流程操作)

1. 正常群聊,保存消息记录到数据库

2. 用户进入离开以及发送消息都有广播提示

3. 消息发送失败的重试机制

 

了解Socket和Websocket

Socket 是IM技术的重要组成部分,Socket是为了方便使用TCP或UDP而抽象出来的一层,是对TCP/IP协议的封装,是位于应用层和传输控制层之间的一组接口

Websocket是为了满足基于Web 的日益增长的实时通信需求而产生的,模仿socket的通信能力。但是和Socket不同的是,Websocket是基于TCP的应用层协议

WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。

在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

传统http通信只能由客户端发起,做不到服务器主动推送消息,如果服务器有连续的状态变化,只能用轮询,而轮询非常低效,浪费资源,所以才有了websocket

它的的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。其他特点包括:

  • 建立在 TCP 协议之上,服务器端的实现比较容易。
  • 与 HTTP 协议有着良好的兼容性。默认端口也是 80 和 443 ,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
  • 数据格式比较轻量,性能开销小,通信高效。
  • 可以发送文本,也可以发送二进制数据。
  • 没有同源限制,客户端可以与任意服务器通信。
  • 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。

总结:Socket是抽象接口,WebSocket与HTTP都是一样基于TCP的应用层协议。WebSocket是双向通信协议,模拟Socket,可以双向发送或接受信息。HTTP是单向的。WebSocket在建立握手时,数据是通过HTTP传输的。但是建立之后,在真正传输时候不需要HTTP协议。

 

Websocket和Node ws的用法

WebSocket 的用法比较简单 Websocket Api

var ws = new WebSocket("ws://localhost:3000"); 

ws.onopen = function(evt) { // onopen 连接成功后的回调函数
  console.log("Connection open ..."); // socket连接
  ws.send("Hello WebSockets!"); // send()方法用于向服务器发送数据
};

ws.onmessage = function(evt) { // 指定收到服务器数据后的回调函数
  console.log( "Received Message: " + evt.data); // 接收服务端的信息
  ws.close(); // 主动断开和服务器的连接
};

ws.onclose = function(evt) {// onclose 连接关闭后的回调函数
console.log("Connection closed."); // socket连接断开 
};

ws.onerror = function(evt) {// onclose 连接错误的回调函数
console.log("Connection errored."); // socket因错误接断开连接,例如有些信息不能发送 
};

// 如果要指定多个回调函数可以用addEventListener
ws.addEventListener('open', function (event) {
  ws.send('Hello Server!');
});

readyState属性返回实例对象的当前状态,共有四种

  • CONNECTING:值为0,表示正在连接。
  • OPEN:值为1,表示连接成功,可以通信了。
  • CLOSING:值为2,表示连接正在关闭。
  • CLOSED:值为3,表示连接已经关闭,或者打开连接失败。
switch (ws.readyState) {
  case WebSocket.CONNECTING:
    // do something
    break;
  case WebSocket.OPEN:
    // do something
    break;
  case WebSocket.CLOSING:
    // do something
    break;
  case WebSocket.CLOSED:
    // do something
    break;
  default:
    // this never happens
    break;
}

 node ws 模块用起来也不难,参照 ws Api 文档 很快就能搭起server啦

/**
 * Create HTTP server.
 */
const server = http.createServer(app)

/**
 * new WebSocket.Server(options[, callback]) 
 * @param {Object} options
 * @param {String} options.host 要绑定的服务器主机名
 *  @param {Number} options.port 要绑定的服务器端口
 *  @param  {Number} options.backlog 挂起连接队列的最大长度.
 *  @param {http.Server|https.Server} options.server一个预创建的HTTP/S服务器  
 *  @param {Function} options.verifyClient 验证传入连接的函数。
 *  @param {Function} options.handleProtocols 处理子协议的函数。
 *  @param {String} options.path 只接受与此路径匹配的连接
 *  @param {Boolean} options.noServer 启用无服务器模式
 *  @param {Boolean} options.clientTracking 是否记录连接clients
 *  @param {Boolean|Object} options.perMessageDeflate 开启关闭zlib压缩(配置)
 *  @param {Number} options.maxPayload 最大消息载荷大小(bytes)
 *  @param callback {Function}
 * 1. 创建一个新的服务器实例。必须提供端口、服务器或NoServer中的一个,否则会引发错误。
 * 2. 如果端口被设置,则自动创建、启动和使用HTTP服务器。
 * 3. 要使用外部HTTP/S服务器,只指定服务器或NoServer。此时,必须手动启动HTTP/S服务器。
 * 4. NoSver模式允许WS服务器与HTTP/S服务器完全分离。这使得,可以在多个WS服务器之间共享一个HTTP/S服务器
 */
const WebSocket = require('ws')
const wss = new WebSocket.Server({
  server
})

 

IM系统

IM按场景一般分为单聊和群聊。

单聊 

一个靠谱的IM系统,其核心就是消息的可靠性,及时触达,以及系统安全性。安全性方面考虑的话需要对会话内容进行加密。

消息的可靠性,即消息的不丢失、不重复和不乱序,是IM系统中的一个难点。满足这三点,才能有一个良好的聊天体验。

1. 普通消息投递流程

举个例子: 用户A给用户B发送“你好”的文本消息,流程图如下

 

  • 用户A向服务端发送一个消息请求包msg: R
  • 服务端在成功处理后,回复用户A一个ack消息响应包msg:A
  • 如果此时用户B在线,则服务调研主动向用户B发送一个消息通知包msg:N,如果用户B不在线,则消息会存储离线

 

客户端发送消息的消息体一般包括几个部分:

  • 消息类型(必填)对于IM系统来说消息类型肯定不止一种,前后端可统一定下消息类型,便于通信接收和识别
  • 消息唯一id(必填)前端生成消息id,后端存储的时候以及接收去重等都要根据唯一id来判断
  • 消息内容(非必填)可以是用户通过输入框或者上传文件等用户交互的方式发送消息,也可以是对用户进入离开行为的监测
  • 发送时间(非必填)以服务端时间为准
  • 发送方唯一标识(必填)
  • 接收方唯一标识(如果是单聊必填)

消息体R的格式

{
  msg_type: 'TEXT', // 消息类型 文本消息
  msg_content: '你好', // 消息内容
  send_time: new Date().valueOf(),
  msg_id: uuid(), // 前端生成唯一id作为消息id
  from_id: 'liuyifei',
  to_id: 'pengyuyan'
}

服务端返回的消息体格式msg:A和通知给B的消息体格式msg:N

{
    code: 0,  // 状态码
    message: '接收成功',
    results: {
        msg_type: 'ACK',
        msg_content: '你好',
        msg_id: 'xxx',
        send_time:new Date().valueOf(),
        from_id: 'liuyifei',
        to_id: 'pengyuyan'
    } 
}

 

用户A收到msg:A的响应的时候,把响应的msg_id从待发送队列移除

 

2. 上述消息投递流程容易出现的问题

从流程图中可以看到,发送方用户A收到msg:A后,只能说明服务端成功接收到了消息,并不能说明用户B接收到了消息。在有些场景下,可能出现msg:N通知到B的包丢失,且发送方用户A完全不知道,例如:

  • 服务器崩溃,msg:N包未发出
  • 网络抖动,msg:N包被网络设备丢弃
  • 客户端B崩溃,msg:N包未接收

要想实现应用层的消息可靠投递,必须加入应用层的确认机制,即:要想让发送方用户A确保接收方用户B收到了消息,必须让接收方用户B给一个消息的确认,这个应用层的确认的流程,与消息的发送流程类似:

 

  • 客户端B向服务端发送一个ack请求包,即ack:R
  • 服务端在成功处理后,回复用户B一个ack响应包,即ack:A
  • 服务端主动向用户A发送一个ack通知包,即ack:N
  • 图中 msg:A是对msg:R的响应 ack:R是对ack:A的响应 ack:A是对ack:R的响应
用户A: “你好” (msg:R)
服务器:我收到消息了(msg:A),我转告给B (msg:N)
用户B: 收到了消息,并回应服务器说我知道了 (ack:R)
服务器:告诉B,好了我知道你收到消息了(ack:A)
服务器:通知A,B已经收到消息了(ack:N)
 

这样完成一个闭环,服务器作为中间人 确保发送方和接收方消息收发的正常,准确的触达。

这是用户B在线的情况,如果用户B不在线的话,服务器会假装B收到消息,返回ack给A,同时把离线消息存储,下次B上线的时候会拉取离线消息。

 

如果一切都正常,这样就完成一个完整的可靠的消息触达机制,但是现实总是有很多不稳定因素存在的,容易丢失各种消息

1. msg:R,msg:A 可能丢失:
此时直接提示“发送失败”即可,问题不大;

2. msg:N,ack:R,ack:A,ack:N这四个报文都可能丢失,此时用户A都收不到期待的ack:N报文,即用户A不能确认用户B是否收到“你好”。

一个解决办法:用户A发出了msg:R,收到了msg:A之后,在一个期待的时间内,如果没有收到ack:N,用户A会尝试将msg:R重发。可能用户A同时发出了很多消息,故用户A需要在本地维护一个等待ack队列,并配合超时机制,来记录哪些消息没有收到ack:N,以定时重发。一旦收到了ack:N,说明用户B收到了“你好”消息,对应的消息将从“等待ack队列”中移除。

或者是:让用户主动点击重发,重发的时候要确认匹配msg_id以便服务端识别去重,以免消息重复。也还是消息队列方式实现。收到msg:A的响应即从等待队列中移除。

 

群聊

一个im群聊的交互流程

 

 和单聊不同的是,群聊不存在一对一的关系,但要复杂的多,一个用户发送消息,要广播给其他所有用户。这就意味着对于一个千人群来说,一个人发送的消息要推送给群里的1000个人,这里就要涉及到对高并发的处理了。

 

聊天室🌰

 1. 流程图

 

临时聊天室/直播间 就不存在用户是否是离线,需要展示的是当前在线的人数,进入+1和离开/断开-1

 那么就会遇到

第一个问题:如何知道用户是在线还是离开了呢?

 一般来说都会实现心跳机制,客户端一定时间间隔(例如5s)一次向服务端发送一个心跳PING,来告诉服务器我还在线。服务器收到心跳也会返回一个PONG的消息,告诉客户端我收到了你的消息并且我服务器运转正常,如果是持续的有来有回,服务器就知道有哪些用户是在线的。

这个问题可以通过心跳机制配合Redis来解决,下面消息存储会说到。

第二个问题就是如果消息发送失败,该如何处理,也就是如何保证消息的可靠触达?

提示用户消息发送失败只是第一步,但如果只是提示用户发送消息失败了,没有一个重试机制的话,体验不够好。所以要解决的第二个问题就是如果消息发送失败,可以点击重发这条消息,这个时候消息id就起作用了,客户端根据id匹配重发,服务端需要根据消息id来去重,保证消息得到准确的触达。

 

2. 消息存储机制

(1)对于在线人数的存储

在线人数这种实时性较高,查询频率高的,就存到Redis缓存里。

本项目用到的是存储一个集合 在用户进入或者是刷新页面重新进入的时候把用户名存进去,离开检测到close事件后把该用户从集合中移除,这样就能够比较及时的获取到实时在线人数了。

const userEnter = (type) => {
    if (type === 'enter') { // 用户进入
      redisClient.sadd('users', user)
// 获取集合的成员数 赋值给全局变量onlineCount 在每次初始化和发心跳包等消息的时候把它带过去 客户端收到后展示
      redisClient.scard('users', (err, data) => { 
        if (!err) {
          onlineCount = data
        }
      })
    }
    if (type === 'leave') { // 用户离开
      redisClient.srem('users', user)
      redisClient.scard('users', (err, data) => {
        if (!err) {
          onlineCount = data
        }
      })
    }
  }

 (2) 在线离线状态存储--如何知道某个用户是离开的

 客户端的websocket断联触发onclose事件,服务端监测到的close事件,会有几种情况

  • 关闭窗口,正常断开
  • 页面刷新的时候也会断开重连
  • 因为网络和设备问题的异常断开

这三种情况下,关闭窗口的离开和网络异常的离开可以算作是用户离开通知到其他用户,但是页面刷新重连并不能算作用户离开的。

因为服务端是不能够区分是正常断联还是异常断联,所以如果直接只在服务端close事件里处理用户离开的话是不准确的

这里用到了时间戳比对,用的是redis hash来存储,根据客户端每次发来的心跳包记录用户最后连接的时间,如果最后连接时间大于某个时间间隔(比如6s)就判定该用户是离线的状态

const cb = (err, data) => {
  console.log('err: ', err, ' data: ', data, ' data type: ', typeof data)
}
redisClient.hset('myhash', user, new Date().valueOf(), cb)
    redisClient.hkeys('myhash', (err, replies) => {
      console.log(replies)
      replies.forEach(item => {
        redisClient.hget('myhash', item, (err, data) => {
          if (data) {
            const time = new Date().valueOf() - Number(data)
            if (time > 6000 && replies.indexOf(item) !== -1) {
              results = {
                msg_type: 'LEAVE',
                send_time: (new Date()).valueOf(),
                user_name: item
              }
              redisClient.hdel('myhash', item, cb)
              redisClient.hkeys('myhash', (err, res) => {
                if(res.indexOf(item)===-1){
                  boardCast(results)
                }
              })
              
            }
          }
        })
      })
    })

 

 (3)消息的永久存储

进入离开消息通知和用户发送的消息,会存储到MongoDB数据库,在刷新页面或者新用户进来的时候会拉取一遍历史消息接口,这样每个人都能够

看到这个聊天室的历史消息。(单聊群聊都可以用这种方式存储离线消息) 

const boardCast = (results) => {
  wss.clients.forEach((client) => { // 广播消息给所有客户端 断联了 主动发消息给客户端
    if (client.readyState === WebSocket.OPEN) {
      if (results && results.msg_type !== 'PING') {
        client.send(
          JSON.stringify({
            code: 0,
            message: '成功',
            results,
            onlineCount,
          })
        )
      }
    }
  })
  var message = new Message(results) // 历史消息存数据库
  message.save((err, res) => {
    if (err) {
      console.log("保存失败:" + err)
      results = null
    } else {
      console.log("保存成功:" + res)
      results = res
    }
  })
}

 

 

代码地址:

前端 https://github.com/leitingting08/im-vue

后端 https://github.com/leitingting08/im-node

彩蛋:后续想要继续通过Canvas和Socket实践一个类似B站视频弹幕的展现和存储,有兴趣的可以关注该项目,欢迎提出建议和意见。

 

 

参考:

阮一峰-Websocket教程

MDN-WebsocketAPI

IM消息送达保证机制实现(一):保证在线实时消息的可靠投递

IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?

 

posted @ 2020-04-14 00:39  c-137Summer  阅读(446)  评论(0编辑  收藏