「这是我参与2022首次更文挑战的第17天,活动详情查看:2022首次更文挑战」
一、前言
简易网页聊天室有如下需求:
- 支持简单的文本发送
- 消息实时接收
- 支持消息未读数(总未读和会话未读)
先来回顾下,保证消息实时性的三种常见方式:
- 短轮询:客户端定时去请求服务端拉取消息
- 长轮询:当请求没有获取到新消息时,并不会马上结束返回,而是会在服务端 “悬挂(hang)”
- 长连接:当有新消息产生,服务端直接向客户端推送
简易版网页聊天室,使用短轮询来模拟实时接收消息:
- 每 3 秒查询聊天记录(按照最后一条会话
id) - 每 3 秒查询联系人(用于更新)
二、实战
技术实现:项目地址
- 数据表
- 接口
- 业务逻辑实现
(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
其中涉及操作:
- 发送消息:增加未读数
- 查询消息:减少未读数
例如:发送消息:增加未读数
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)查询消息
查询消息,可分为两种:
两种方式逻辑类似,只是查询的消息不同。
- 直接查询:可查询最新几条
- 指定查询:根据消息
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);
}