【IM】网页聊天室(简易版)

430 阅读3分钟

「这是我参与2022首次更文挑战的第17天,活动详情查看:2022首次更文挑战

一、前言

简易网页聊天室有如下需求:

  1. 支持简单的文本发送
  2. 消息实时接收
  3. 支持消息未读数(总未读和会话未读)

先来回顾下,保证消息实时性的三种常见方式:

  1. 短轮询:客户端定时去请求服务端拉取消息
  2. 长轮询:当请求没有获取到新消息时,并不会马上结束返回,而是会在服务端 “悬挂(hang)”
  3. 长连接:当有新消息产生,服务端直接向客户端推送

简易版网页聊天室,使用短轮询来模拟实时接收消息:

  • 每 3 秒查询聊天记录(按照最后一条会话 id
  • 每 3 秒查询联系人(用于更新)

2022-02-0416-36-33.png



二、实战

技术实现:项目地址

  • 数据表
  • 接口
  • 业务逻辑实现

(1)表设计

  • 用户存储:用户表
  • 消息存储分为:消息内容表、消息索引表
-- 用户表
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
);

-- 消息内容表
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`);

(2)具体技术实现


1)发送消息

对应后端代码:

@Controller
public class MessageController {
    
    @PostMapping(path = "/sendMsg")
    @ResponseBody
    public String sendMsg(@RequestParam Long senderUid, @RequestParam Long recipientUid, 
        String content, Integer msgType) {
        
        // 1. 存储消息内容
        contentRepository.saveAndFlush(messageContent);
        
        // 2. 存发件人的发件箱
        relationRepository.save(messageRelationSender);
        
        // 3. 存收件人的收件箱
        relationRepository.save(messageRelationRecipient);
        
        // 4. 更新最近联系人
        contactRepository.save(messageContactRecipient);
        
        // 5. 更新未读数
        
        // 6. TODO: 将消息推送到 redis
    }
}

2)消息未读数

未读数分为:总未读和会话未读(总未读虽然是会话未读之和)。

但由于使用频率很高,会话很多时候聚合起来性能比较差,所以冗余了总未读来单独存储。

可以采用 Redis 来进行存储:

  • 总未读:可以使用简单的 K-V(Key-Value)结构存储
  • 会话未读使用 Hash 结构存储

大概的存储格式如下:

# 总未读:
owneruid_T, 2

# 会话未读:
owneruid_C, otheruid1, 1
owneruid_C, otheruid2, 1

其中涉及操作:

  1. 发送消息:增加未读数
  2. 查询消息:减少未读数

例如:发送消息:增加未读数

public MessageVO sendNewMsg(
    // 加总未读
    redisTemplate.opsForValue().increment(recipientUid + "_T", 1);
    // 加会话未读
    redisTemplate.opsForHash().increment(recipientUid + "_C", senderUid, 1);
}

查询消息:减少未读数

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

3)查询消息

查询消息,可分为两种:

两种方式逻辑类似,只是查询的消息不同。

  1. 直接查询:可查询最新几条
  2. 指定查询:根据消息 Id,查询之后消息
@GetMapping(path = "/queryMsg")
@ResponseBody
public String queryMsg(@RequestParam Long ownerUid, @RequestParam Long otherUid) {
    // 1. 从数据库中查询消息
    
    // 2. 更新会话未读数和总未读数
}

对应前端代码:

// 1. 查询联系人和未读数
// 每 3s 查询一次
setInterval(queryContactsAndUnread, 3000);

function queryContactsAndUnread() {

    $.get(
        '/queryContacts',
        {
            ownerUid: $("#sender_id").val()
        },
        function (returnContacts) {
            if (returnContacts != "") {
                // ... ...
                });
                $("#contactsBody").html(contactsTR);
            }
        }
    );
}

// 2. 查询消息
// 每 3s 查询一次,按照消息Id
function queryMsg(event) {
    $.get(
        '/queryMsg',
        {
            ownerUid: sender_id,
            otherUid: recipient_id
        },
        function (msgsJson) {
            if (msgsJson != "") {
                // ... ...
            }
        }
    );

    newMsgLoop = setInterval(queryNewcomingMsg, 3000);
}