从零到一搭建一个群聊系统
前言
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个人,这里就要涉及到对高并发的处理了。
聊天室