大师兄

11 | 期中实战:动手写一个简易版的IM系统

你好,我是袁武林。

到上一讲为止,IM的相关课程已经进行过半,在前面的课程中,我们讨论的大部分内容都比较偏理论,你理解起来可能会比较抽象。为了让你对前面讲到的知识有更深入的理解,今天我们就来回顾、梳理近期学习的内容,一起尝试搭建一个简单的IM聊天系统。

在开始之前呢,我先来说明一下IM课程的期中、期末实战的课程计划和设计思路。

期中和期末实战是希望你以自己动手实现为主,提供的Demo主要作为参考,在设计层面上,并不能让你直接用于线上使用。

本次期中实战Demo的主要关注点是:消息的存储、未读数的设计,并以“短轮询”的方式来实现消息的实时触达。希望你能从用户的使用场景出发,来理解消息存储设计的思路,以及未读数独立两套存储的必要性。

另外,在期末实战中,我会从“短轮询”方式调整为WebSocket长连接的方式,并且加上ACK机制、应用层心跳等特性。希望你能在两次实战中,通过对比,真正理解长连接方式相比“短轮询”方式的优势,并且通过ACK机制和应用层心跳,真正理解为什么它们能够解决“数据丢失”和“连接可靠性”的问题。

OK,下面我们说回本次实战。

这个聊天系统要求并不复杂,只需要构建简单的Web界面(没有界面通过命令行能实现也行)。我在这里写了一个简易版的Demo,供你参考。

示例代码你可以在GitHub上下载。

需求梳理

这个简易聊天系统的大概要求有以下几点:

  • 支持用户登录;
  • 双方支持简单的文本聊天;
  • 支持消息未读数(包括总未读和会话未读);
  • 支持联系人页和未读数有新消息的自动更新;
  • 支持聊天页有新消息时自动更新。

需求分析

我们先来分析一下整体需求。

首先,要支持用户登录,先要有“用户”。对应的数据底层需要有一个用户表,用户表的设计可以比较简单,能够支持唯一的UID和密码用于登录即可。当然,如果有用户头像信息,聊天时的体验会更好,所以这里我们也加一下。简单的库表设计可以是这样的:

CREATE TABLE IM_USER (
uid INT PRIMARY KEY,
username VARCHAR(500) NOT NULL,
password VARCHAR(500) NOT NULL,
email VARCHAR(250) DEFAULT NULL,
avatar VARCHAR(500) NOT NULL
);

对应的实体类User字段和库表一致,这里就不罗列了,我们需要设计用户通过邮箱和密码来登录。因为课程主要是涉及IM相关的知识,所以这里对用户信息的维护可以不做要求,启动时内置几个默认用户即可。

有了用户后,接下来就是互动了,这一期我们只需要关注简单的文本聊天即可。在设计中,我们需要对具体的聊天消息进行存储,便于在Web端使用,因此可以简单地按照“02 | 消息收发架构:为你的App,加上实时通信功能”中讲到的消息存储来实现此项功能。

消息的存储大概分为消息内容表、消息索引表、联系人列表,这里我用最基础的字段来给你演示一下。单库单表的设计如下:

消息内容表:
CREATE TABLE IM_MSG_CONTENT (
mid INT AUTO_INCREMENT PRIMARY KEY,
content VARCHAR(1000) NOT NULL,
sender_id INT NOT NULL,
recipient_id INT NOT NULL,
msg_type INT NOT NULL,
create_time TIMESTAMP NOT NUll
);
消息索引表:
CREATE TABLE IM_MSG_RELATION (
owner_uid INT NOT NULL,
other_uid INT NOT NULL,
mid INT NOT NULL,
type INT NOT NULL,
create_time TIMESTAMP NOT NULL,
PRIMARY KEY (`owner_uid`,`mid`)
);
CREATE INDEX `idx_owneruid_otheruid_msgid` ON IM_MSG_RELATION(`owner_uid`,`other_uid`,`mid`);
联系人列表:
CREATE TABLE IM_MSG_CONTACT (
owner_uid INT NOT NULL,
other_uid INT NOT NULL,
mid INT NOT NULL,
type INT NOT NULL,
create_time TIMESTAMP NOT NULL,
PRIMARY KEY (`owner_uid`,`other_uid`)
);
  • 消息内容表:由于只是单库单表,消息ID采用自增主键,主要包括消息ID和消息内容。
  • 消息索引表:使用了索引“归属人”和消息ID作为联合主键,可以避免重复写入,并增加了“归属人”和“关联人”及消息ID的索引,用于查询加速。
  • 联系人列表:字段和索引表一致,不同点在于采用“归属人”和“关联人”作为主键,可以避免同一个会话有超过一条的联系人记录。

消息相关实体层类的数据结构和库表的字段基本一致,这里不再列出,需要注意的是:为了演示的简单方便,这里并没有采用分库分表的设计,所以分库的Sharding规则你需要结合用户消息收发和查看的场景,多加考虑一下库表的设计。

OK,有了用户和消息存储功能,现在来看如何支持消息未读数。

在“07 | 分布式锁和原子性:你看到的未读消息提醒是真的吗?”一讲中,我讲到了消息未读数在聊天场景中的重要性,这里我们也把未读数相关的功能加上来。

未读数分为总未读和会话未读,总未读虽然是会话未读之和,但由于使用频率很高,会话很多时候聚合起来性能比较差,所以冗余了总未读来单独存储。比如,你可以采用Redis来进行存储,总未读可以使用简单的K-V(Key-Value)结构存储,会话未读使用Hash结构存储。大概的存储格式如下:

总未读:
owneruid_T, 2
会话未读:
owneruid_C, otheruid1, 1
owneruid_C, otheruid2, 1

最后,我们一起来看看如何支持消息和未读自动更新。

在“03 | 轮询与长连接:如何解决消息的实时到达问题?”一讲中,我讲到了保证消息实时性的三种常见方式:短轮询、长轮询、长连接。对于消息和未读的自动更新的设计,你可以采用其中任意一种,我实现的简版代码里就是采用的“短轮询”来在联系人页面和聊天页面轮询未读和新消息的。实现上比较简单,Web端核心代码和服务端核心代码如下。

Web端核心代码:
newMsgLoop = setInterval(queryNewcomingMsg, 3000);
$.get(
'/queryMsgSinceMid',
{
ownerUid: ownerUid,
otherUid: otherUid,
lastMid: lastMid
},
function (msgsJson) {
var jsonarray = $.parseJSON(msgsJson);
var ul_pane = $('.chat-thread');
var owner_uid_avatar, other_uid_avatar;
$.each(jsonarray, function (i, msg) {
var relation_type = msg.type;
owner_uid_avatar = msg.ownerUidAvatar;
other_uid_avatar = msg.otherUidAvatar;
var ul_pane = $('.chat-thread');
var li_current = $('<li></li>');//创建一个li
li_current.text(msg.content);
ul_pane.append(li_current);
});
});
);
服务端核心代码:
List<MessageVO> msgList = Lists.newArrayList();
List<MessageRelation> relationList = relationRepository.findAllByOwnerUidAndOtherUidAndMidIsGreaterThanOrderByMidAsc(ownerUid, otherUid, fromMid);
/** 先拼接消息索引和内容 */
User self = userRepository.findOne(ownerUid);
User other = userRepository.findOne(otherUid);
relationList.stream().forEach(relation -> {
Long mid = relation.getMid();
MessageContent contentVO = contentRepository.findOne(mid);
if (null != contentVO) {
String content = contentVO.getContent();
MessageVO messageVO = new MessageVO(mid, content, relation.getOwnerUid(), relation.getType(), relation.getOtherUid(), relation.getCreateTime(), self.getAvatar(), other.getAvatar());
msgList.add(messageVO);
}
});
/** 再变更未读 */
Object convUnreadObj = redisTemplate.opsForHash().get(ownerUid + Constants.CONVERSION_UNREAD_SUFFIX, otherUid);
if (null != convUnreadObj) {
long convUnread = Long.parseLong((String) convUnreadObj);
redisTemplate.opsForHash().delete(ownerUid + Constants.CONVERSION_UNREAD_SUFFIX, otherUid);
long afterCleanUnread = redisTemplate.opsForValue().increment(ownerUid + Constants.TOTAL_UNREAD_SUFFIX, -convUnread);
/** 修正总未读 */
if (afterCleanUnread <= 0) {
redisTemplate.delete(ownerUid + Constants.TOTAL_UNREAD_SUFFIX);
}
return msgList;

这里需要注意两个地方:

  • 其一,由于业务上使用的“消息对象”和存储层并不是一一对应关系,所以一般遇到这种情况,你可以为展现层在实体层基础上创建一些VO对象来承接展示需要的数据。比如,我在这里创建了一个用于消息展现的VO对象MessageVO。
  • 其二,在未读数的实现上,由于只是演示,这里并没有做到两个未读的原子变更,所以可能存在两个未读不一致的情况,因此上方代码最后部分我简单对总未读做了一个为负后的修正。

其他实现上的注意点

前面我们从需求梳理出发,把这个简易聊天系统核心部分的设计思路和核心代码讲了一下,也强调了一下这套代码实现中容易出现问题的地方。除此之外,我希望在代码实现中,你还能考虑以下这些问题,虽然这些不是IM系统实现中的核心问题,但对于代码的整洁和设计的习惯培养也是作为一个程序员非常重要的要求。

代码分层

首先是代码分层方面,我们在代码实现上,应该尽量让表现层、业务层和持久化层能分离清楚,做到每一层的职责清晰明确,也隔离了每一层的实现细节,便于多人协作开发。

表现层控制数据输入和输出的校验和格式,不涉及业务处理;业务层不涉及展现相关的代码,只负责业务逻辑的组合;持久化层负责和底层资源的交互,只负责数据的写入、变更和获取,不涉及具体的业务逻辑。

依赖资源

另外,对于代码中使用到的Web服务器、DB资源、Redis资源等,我们尽量通过Embedding的方式来提供,这样便于在IDE中执行和代码的迁移使用。

小结

这一节课主要是安排一次实战测试,让你来实现一个简易的聊天系统,涉及的IM相关知识包括:消息存储的设计、消息未读的设计、消息实时性的具体实现,等等。

在实现过程中,希望你能做到合理的代码分层规划,通过Embedding方式提升代码对IDE的友好性及代码的可迁移性。相信通过这一次理论与实践相结合的动手实战,能够帮助你进一步加深对IM系统核心技术的理解。

千里之行,积于跬步。通过脚踏实地的代码实现,将理论知识变成真正可用的系统,你的成就感也是会倍增的。

期中实战的演示代码并没有完全覆盖之前课程讲到的内容,所以如果你有兴趣,也非常欢迎你在代码实现时加入更多的特性,比如长连接、心跳、ACK机制、未读原子化变更等。如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。感谢你的收听,我们下期再见。