Netty分布式聊天室 - 私聊功能实现

设计思路

        首先理清思路:

                对于私聊,有两种情况:对方在线,对方不在线

                因为我们是分布式是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的缓存没有被清空,说明消息没有发送出去:

         此时我们上线张三,接受到离线消息:

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
首先,需要了解什么是NettyNetty是一个异步事件驱动的网络应用框架,用于快速开发可维护的高性能协议服务器和客户端。 接下来,我们可以开始实现聊天室功能。一个简单的聊天室应该具备以下功能: 1. 用户连接和断开连接的处理; 2. 用户发送消息和接收消息的处理; 3. 消息广播给所有在线用户。 下面是一个简单的实现: 1. 用户连接和断开连接的处理 Netty提供了ChannelHandlerAdapter和ChannelInboundHandlerAdapter两个抽象类,我们可以继承其中一个来实现自己的Handler。这里我们使用ChannelInboundHandlerAdapter。 ```java public class ChatServerHandler extends ChannelInboundHandlerAdapter { // 用户列表,用于保存所有连接的用户 private static List<Channel> channels = new ArrayList<>(); // 新用户连接时,将连接加入用户列表 @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { channels.add(ctx.channel()); System.out.println(ctx.channel().remoteAddress() + " 上线了"); } // 用户断开连接时,将连接从用户列表中移除 @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { channels.remove(ctx.channel()); System.out.println(ctx.channel().remoteAddress() + " 下线了"); } } ``` 2. 用户发送消息和接收消息的处理 Netty的数据传输是通过ByteBuf来实现的,因此我们需要将ByteBuf转换为字符串进行处理。 ```java public class ChatServerHandler extends ChannelInboundHandlerAdapter { // 用户列表,用于保存所有连接的用户 private static List<Channel> channels = new ArrayList<>(); // 新用户连接时,将连接加入用户列表 @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { channels.add(ctx.channel()); System.out.println(ctx.channel().remoteAddress() + " 上线了"); } // 用户断开连接时,将连接从用户列表中移除 @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { channels.remove(ctx.channel()); System.out.println(ctx.channel().remoteAddress() + " 下线了"); } // 接收用户发送的消息并处理 @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf buf = (ByteBuf) msg; String received = buf.toString(CharsetUtil.UTF_8); System.out.println(ctx.channel().remoteAddress() + ": " + received); broadcast(ctx, received); } // 将消息广播给所有在线用户 private void broadcast(ChannelHandlerContext ctx, String msg) { for (Channel channel : channels) { if (channel != ctx.channel()) { channel.writeAndFlush(Unpooled.copiedBuffer(msg, CharsetUtil.UTF_8)); } } } } ``` 3. 消息广播给所有在线用户 我们可以使用broadcast方法将接收到的消息广播给所有在线用户。 ```java public class ChatServerHandler extends ChannelInboundHandlerAdapter { // 用户列表,用于保存所有连接的用户 private static List<Channel> channels = new ArrayList<>(); // 新用户连接时,将连接加入用户列表 @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { channels.add(ctx.channel()); System.out.println(ctx.channel().remoteAddress() + " 上线了"); } // 用户断开连接时,将连接从用户列表中移除 @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { channels.remove(ctx.channel()); System.out.println(ctx.channel().remoteAddress() + " 下线了"); } // 接收用户发送的消息并处理 @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf buf = (ByteBuf) msg; String received = buf.toString(CharsetUtil.UTF_8); System.out.println(ctx.channel().remoteAddress() + ": " + received); broadcast(ctx, received); } // 将消息广播给所有在线用户 private void broadcast(ChannelHandlerContext ctx, String msg) { for (Channel channel : channels) { if (channel != ctx.channel()) { channel.writeAndFlush(Unpooled.copiedBuffer(msg, CharsetUtil.UTF_8)); } } } } ``` 接下来我们需要编写一个启动类,用于启动聊天室服务器。 ```java public class ChatServer { public static void main(String[] args) throws Exception { // 创建EventLoopGroup EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { // 创建ServerBootstrap ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new ChatServerHandler()); } }); // 启动服务器 ChannelFuture channelFuture = serverBootstrap.bind(8888).sync(); System.out.println("服务器启动成功"); // 关闭服务器 channelFuture.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } } ``` 现在,我们就完成了一个简单的聊天室服务器。可以通过运行ChatServer类启动服务器,然后使用telnet命令连接服务器进行聊天。 ```sh telnet localhost 8888 ``` 输入发送的消息,即可将消息广播给所有在线用户。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱飞的男孩

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值