https://juejin.im/post/5d73af31f265da0394022e45
好消息:IM1.0.0 版本已经上线啦,支持特性:
私聊发送文本 / 文件
已发送 / 已送达 / 已读回执
支持使用 ldap 登录
支持接入外部的登录认证系统
提供客户端 jar 包,方便客户端开发
github 链接: github.com/yuanrw/IM
本篇将带大家从零开始搭建一个轻量级的 IM 服务端,IM 的整体设计思路和架构在我的上篇博客中已经讲过了,没看过的同学请点击从零开始开发 IM(即时通讯)服务端 。
这篇将给大家带来更多的细节实现。我将从三个方面来阐述如何构建一个完整可靠的 IM 系统。
可靠性
安全性
存储设计
可靠性
什么是可靠性?对于一个 IM 系统来说,可靠的定义至少是不丢消息、消息不重复、不乱序,满足这三点,才能说有一个好的聊天体验。
不丢消息
我们先从不丢消息开始讲起。
首先复习一下上一篇设计的服务端架构:
我们先从一个简单例子开始思考:当 Alice 给 Bob 发送一条消息时,可能要经过这样一条链路:
client-->connecter
connector-->transfer
transfer-->connector
connector-->client
在这整个链路中的每个环节都有可能出问题,虽然 tcp 协议是可靠的,但是它只能保证链路层的可靠,无法保证应用层的可靠。
例如在第一步中, connector
收到了从 client
发出的消息,但是转发给 transfer
失败,那么这条消息 Bob 就无法收到,而 Alice 也不会意识到消息发送失败了。
如果 Bob 状态是离线,那么消息链路就是:
client-->connector
connector-->transfer
transfer-->mq
如果在第三步中, transfer
收到了来自 connector
的消息,但是离线消息入库失败, 那么这个消息也是传递失败了。
为了保证应用层的可靠,我们必须要有一个 ack 机制,使发送方能够确认对方收到了这条消息。
具体的实现,我们模仿 tcp 协议做一个应用层的 ack 机制。
tcp 的报文是以 字节(byte)
为单位的,而我们以 message
单位。
发送方每次发送一个消息,就要等待对方的 ack 回应,在 ack 确认消息中应该带有收到的 id 以便发送方识别。
其次,发送方需要维护一个等待 ack 的队列。 每次发送一个消息之后,就将消息和一个计时器入队。
另外存在一个线程一直轮询队列,如果有超时未收到 ack 的,就取出消息重发。
超时未收到 ack 的消息有两种处理方式:
和 tcp 一样不断发送直到收到 ack 为止。
设定一个最大重试次数,超过这个次数还没收到 ack,就使用失败机制处理,节约资源。例如如果是
connector
长时间未收到client
的 ack,那么可以主动断开和客户端的连接,剩下未发送的消息就作为离线消息入库,客户端断连后尝试重连服务器即可。
不重复、不乱序
有的时候因为网络原因可能导致 ack 收到较慢,发送方就会重复发送,那么接收方必须有一个去重机制。
去重的方式是给每个消息增加一个唯一 id。这个唯一 id 并不一定是全局的,只需要在一个会话中唯一即可。例如某两个人的会话,或者某一个群。如果网络断连了,重新连接后,就是新的会话了,id 会重新从 0 开始。
接收方需要在当前会话中维护收到的最后一个消息的 id,叫做 lastId
。
每次收到一个新消息, 就将 id 与 lastId
作比较看是否连续,如果不连续,就放入一个暂存队列 queue 中稍后处理。
例如:
当前会话的
lastId
=1,接着服务器收到了消息msg(id=2)
,可以判断收到的消息是连续的,就处理消息,将lastId
修改为 2。但是如果服务器收到消息
msg(id=3)
,就说明消息乱序到达了,那么就将这个消息入队,等待lastId
变为 2 后,(即服务器收到消息msg(id=2)
并处理完了),再取出这个消息处理。
因此,判断消息是否重复只需要判断 msgId>lastId&&!queue.contains(msgId)
即可。如果收到重复的消息,可以判断是 ack 未送达,就再发送一次 ack。
接收方收到消息后完整的处理流程如下:
伪代码如下:
class ProcessMsgNode{
/**
* 接收到的消息
*/
private Message message;
/**
* 处理消息的方法
*/
private Consumer<Message> consumer;
}
public CompletableFuture<Void> offer(Long id,Message message,Consumer<Message> consumer) {
if (isRepeat(id)) {
//消息重复
sendAck(id);
return null;
}
if (!isConsist(id)) {
//消息不连续
notConsistMsgMap.put(id, new ProcessMsgNode(message, consumer));
return null;
}
//处理消息
return process(id, message, consumer);
}
private CompletableFuture<Void> process(Long id, Message message, Consumer<Message> consumer) {
return CompletableFuture
.runAsync(() -> consumer.accept(message))
.thenAccept(v -> sendAck(id))
.thenAccept(v -> lastId.set(id))
.thenComposeAsync(v -> {
Long nextId = nextId(id);
if (notConsistMsgMap.containsKey(nextId)) {
//队列中有下个消息
ProcessMsgNode node = notConsistMsgMap.get(nextId);
return process(nextId, node.getMessage(), consumer);
} else {
//队列中没有下个消息
CompletableFuture<Void> future = new CompletableFuture<>();
future.complete(null);
return future;
}
})
.exceptionally(e -> {
logger.error("[process received msg] has error", e);
return null;
});
}
复制代码
安全性
无论是聊天记录还是离线消息,肯定都会在服务端存储备份,那么消息的安全性,保护客户的隐私也至关重要。
因此所有的消息都必须要加密处理。
在存储模块里,维护用户信息和关系链有两张基础表,分别是 im_user
用户表和 im_relation
关系链表。
im_user
表用于存放用户常规信息,例如用户名密码等,结构比较简单。im_relation
表用于记录好友关系,结构如下:
CREATE TABLE `im_relation` (
`id` bigint(20) COMMENT '关系id',
`user_id1` varchar(100) COMMENT '用户1id',
`user_id2` varchar(100) COMMENT '用户2id',
`encrypt_key` char(33) COMMENT 'aes密钥',
`gmt_create` timestamp DEFAULT CURRENT_TIMESTAMP,
`gmt_update` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `USERID1_USERID2` (`user_id1`,`user_id2`)
);
复制代码
user_id1
和user_id2
是互为好友的用户 id,为了避免重复,存储时按照user_id1
<user_id2
的顺序存,并且加上联合索引。encrypt_key
是随机生成的密钥。当客户端登录时,就会从数据库中获取该用户的所有的relation
,存在内存中,以便后续加密解密。当客户端给某个好友发送消息时,取出内存中该关系的密钥,加密后发送。同样,当收到一条消息时,取出相应的密钥解密。
客户端完整登录流程如下:
client 调用 rest 接口登录。
client 调用 rest 接口获取该用户所有
relation
。client 向 connector 发送 greet 消息,通知上线。
connector 拉取离线消息推送给 client。
connector 更新用户 session。
那为什么 connector 要先推送离线消息再更新 session 呢?我们思考一下如果顺序倒过来会发生什么:
用户
Alice
登录服务器connector
更新 session推送离线消息
此时 Bob 发送了一条消息给 Alice
如果离线消息还在推送的过程中,Bob 发送了新消息给 Alice,服务器获取到 Alice 的 session,就会立刻推送。这时新消息就有可能夹在一堆离线消息当中推过去了,那这时,Alice 收到的消息就乱序了。
而我们必须保证离线消息的顺序在新消息之前。
那么如果先推送离线消息,之后才更新 session。在离线消息推送的过程中,Alice 的状态就是 “未上线”,这时 Bob 新发送的消息只会入库 im_offline
, im_offline
表中的数据被读完之后才会 “上线” 开始接受新消息。这也就避免了乱序。
存储设计
存储离线消息
当用户不在线时,离线消息必然要存储在服务端,等待用户上线再推送。理解了上一个小节后,离线消息的存储就非常容易了。增加一张离线消息表 im_offline
,表结构如下:
CREATE TABLE `im_offline` (
`id` int(11) COMMENT '主键',
`msg_id` bigint(20) COMMENT '消息id',
`msg_type` int(2) COMMENT '消息类型',
`content` varbinary(5000) COMMENT '消息内容',
`to_user_id` varchar(100) COMMENT '收件人id',
`has_read` tinyint(1) COMMENT '是否阅读',
`gmt_create` timestamp COMMENT '创建时间',
PRIMARY KEY (`id`)
);
复制代码
msg_type
用于区分消息类型( chat
, ack
), content
加密后的消息内容以 byte 数组的形式存储。
用户上线时按照条件 to_user_id=用户id
拉取记录即可。
防止离线消息重复推送
我们思考一下多端登录的情况,Alice 有两台设备同时登陆,在这种并发的情况下,我们就需要某种机制来保证离线消息只被读取一次。
这里利用 CAS 机制来实现:
首先取出所有
has_read=false
的字段。检查每条消息的
has_read
值是否为 false,如果是,则改为 true。这是原子操作。
update im_offline set has_read = true where id = ${msg_id} and has_read = false
复制代码
修改成功则推送,失败则不推送。
相信到这里,同学们已经可以自己动手搭建一个完整可用的 IM 服务端了。更多问题欢迎评论区留言~~
IM1.0.0 版本已上线,github 链接: github.com/yuanrw/IM
觉得对你有帮助请点个 star 吧~!
(完)
推荐阅读
瞬间几千次的重复提交,我用 SpringBoot+Redis 扛住了
好文!必须点赞