设计思路
首先理清思路:
对于私聊,有两种情况:对方在线,对方不在线
因为我们是分布式是netty服务,彼此之间不建立通信的情况下需要使用消息队列对多有netty进行通知,让他们去寻找接收方对应的channel并发送数据。
如果有一个服务器寻到了,并且数据发送成功,这时候其他服务器不用再执行,那么我们就需要用到redis进行服务间通信,如果服务器发现数据不在redis中,则直接跳出操作
如果没有服务器寻到,那么数据会一直缓存在redis中,我们在此用户登录时进行数据拉取即可
如此则引出一个问题:当用户登录时,有人刚好发送了消息给此用户,那么缓存在redis的数据有可能被两次发送到客户端:
用户登录时拉取一次、消息队列通知拉取一次
这时候我们就需要在这两次操作前加上分布式锁,并且前端验证时针对消息的id进行去重匹配即可
说干就干,上代码!
ChatMessage和ChatMsg改造
ChatMessage:
/**
* @author 14501
*/
@EqualsAndHashCode(callSuper = true)
@Data
@ToString(callSuper = true)
public class ChatRequestMessage extends Message {
private String content;
private Long to;
private Long from;
public ChatRequestMessage() {
}
public ChatRequestMessage(Long from, Long to, String content) {
this.from = from;
this.to = to;
this.content = content;
}
@Override
public int getMessageType() {
return ChatRequestMessage;
}
}
ChatMsg,新增一个构造函数:
public ChatMsg(ChatRequestMessage chatMsg) {
this.receiveUser = chatMsg.getTo();
this.sendUser = chatMsg.getFrom();
this.text = chatMsg.getContent();
}
ChatMessageHandler设计
负责将消息存储到数据库,然后调用消息队列的消息发布即可:
/**
* @author 14501
*/
@Slf4j
@Component
@ChannelHandler.Sharable
public class ChatRequestMessageHandler extends SimpleChannelInboundHandler<ChatRequestMessage> {
@Resource
private RabbitMqPublisher publisher;
@Resource
private ChatMsgService chatMsgService;
@Resource
private Session session;
@Override
protected void channelRead0(ChannelHandlerContext ctx, ChatRequestMessage msg) throws Exception {
Long to = msg.getTo();
msg.setFrom(session.getUserId(ctx.channel()));
ChatMsg chatMsg = new ChatMsg(msg);
chatMsgService.save(chatMsg);
//发布消息
publisher.sendSingleMsg(to,chatMsg);
ctx.writeAndFlush(new TextWebSocketFrame(new ResponseResult<>(Code.SUCCESS,chatMsg).toString()));
log.info("msg:{},已提交发送",chatMsg);
}
}
消息队列单聊设计
RabbitMqPublisher:
/**
* @author 14501
*/
@Component
public class RabbitMqPublisher {
@Resource
private RabbitTemplate rabbitTemplate;
@Resource
private RedisCache redisCache;
public void sendSingleMsg(Long userId, ChatMsg msg){
JSONObject json = new JSONObject();
json.put("userId", userId);
rabbitTemplate.convertAndSend("msg.topic","single.1", JSON.toJSONString(json));
json = new JSONObject();
json.put("msg",msg);
redisCache.setCacheObject(RedisKey.userMsgKey(userId), json);
}
}
这里采用了topic类型的exchange,可以保证每个订阅同类型queue的netty获取到消息
RabbitMqConsumer:
/**
* @author 14501
*/
@Slf4j
@Configuration
public class RabbitMqConsumer {
@Resource
private Session session;
@Resource
private RedisCache redisCache;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "msg.single.queue"),
exchange = @Exchange(name = "msg.topic",type = ExchangeTypes.TOPIC),
key = "single.#")
)
public void listenerSingleMsg(String msg) {
log.info("接收到私聊消息");
//TODO 分布式锁 -》 消息拉取
JSONObject json = JSON.parseObject(msg);
long userId = Long.parseLong(json.get("userId") + "");
Object object = redisCache.getCacheObject(RedisKey.userMsgKey(userId));
if(object == null){
return;
}
Channel channel = session.getChannel(userId);
if(channel != null){
channel.writeAndFlush(new TextWebSocketFrame(new ResponseResult<>(Code.SUCCESS.code,object).toString()));
redisCache.deleteObject(RedisKey.userMsgKey(userId));
}
}
}
目前只是保存一条缓存数据,后续我们会改造为redis的list结构(适配任意类型的合法消息),这里不用过多在意
LoginMessageHandler拉取离线消息
新增一个checkMsgCache:
/**
* @author 14501
*/
@Slf4j
@Component
@ChannelHandler.Sharable
public class LoginRequestMessageHandler extends SimpleChannelInboundHandler<LoginRequestMessage> {
@Resource
private ChatUserService chatUserService;
@Resource
private Session session;
@Resource
private RedisCache redisCache;
@Override
protected void channelRead0(ChannelHandlerContext ctx, LoginRequestMessage msg) throws Exception {
Long userId = msg.getUserId();
boolean login = chatUserService.canLogin(userId);
LoginResponseMessage message;
if (login) {
String bind = session.bind(ctx.channel(), userId);
log.info("登陆成功");
message = new LoginResponseMessage(true, "登录成功");
message.setToken(bind);
//拉取离线消息
checkMsgCache(ctx.channel(),userId);
} else {
log.warn("用户不合法或已登录");
message = new LoginResponseMessage(false, "用户不合法或已登录");
}
ctx.writeAndFlush(new TextWebSocketFrame(new ResponseResult<>(Code.SUCCESS.code,message).toString()));
}
private void checkMsgCache(Channel channel,Long userId) {
//TODO 分布式锁 -》 消息拉取
JSONObject object = JSON.parseObject(JSON.toJSONString(redisCache.getCacheObject(RedisKey.userMsgKey(userId))));
if(object == null){
return;
}
channel.writeAndFlush(new TextWebSocketFrame(new ResponseResult<>(Code.SUCCESS.code,object).toString()));
redisCache.deleteObject(RedisKey.userMsgKey(userId));
}
}
如此一来离线发送和在线发送都实现了
调试
使用ApiFox保持双方登录,切记登录之后的所以消息都要加上登录成功时回调的token,如下:
接着登录zs,userId为2,我们开始演示:
在线发送:两人都连接到Netty集群了
离线发送:接收方不在线
在线发送
ccx发送给zs的服务器响应:
zs接收到的数据: 这里其实引出一个问题,当前端发送数据时,是要等到服务器响应再渲染消息,还是直接渲染?
如果直接渲染,实际上为了匹配到数据,我们还需要添加对聊天数据添加一个由前端来生成的唯一值(针对同一个用户即可,可以使用userId + 时间戳的形式),sql数据库中不需要保存,只用于ws服务端和客户端的交互即可,这里可以在message中新增一个tempId,然后在ChatMsg实体类中新增一个同类型tempId即可
此项目仅为示例项目,没有采用这个方案,一切从简,思路也只是写出来供大家参考,有更好方案的同学可以在评论区留言或者私信交流一下
离线发送
下线张三:
此时zs已经下线,我们使用ccx这边继续给zs发送消息试试:
可以看到消息队列已经检测到消息:
但是redis的缓存没有被清空,说明消息没有发送出去:
此时我们上线张三,接受到离线消息: