Redis实现聊天功能

Posted Qiao_Zhi

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis实现聊天功能相关的知识,希望对你有一定的参考价值。

  在学习了Redis做为消息队列之后研究 了redis聊天的功能。

  其实用关系型数据库也可以实现消息功能,自己就曾经用mysql写过一个简单的消息的功能。RDB中思路如下:

**
在实际中可以完全借助mysql数据库实现聊天功能,建立一个表,保存接收人的username、message、isConsumed等信息,用户登录之后采用心跳机制不停的检测数据库并消费消息。
心跳可以做好多事,比如检测检测当前用户是否已经登录,如果已经登录剔除之前已经登录的用户,实现一个用户一次登录的功能。
心跳可以采用JS的周期函数不停的向后台发起异步请求,后台查询未消息的消息
**

 

 

1.Redis实现一对一的聊天功能(基于lpush和brpop实现)

  简单的实现一个用户向另一个用户发送多条信息,实现的思路是:

一对一聊天的思路:(采用Lpush和Brpop实现)
1.消息生产者生产消息到redis中:生产消息的时候根据接收人的userName与消息的类型发送到对应的key,采用lpush发送消息(根据userName生成key)
2.消息的消费者根据userName,从userName的key中消费对应的消息。如果有必要可以将消息写到RDB中避免数据的丢失。(根据userName生成key的规则获取用户对应的消息)
3.消息的内容头部加入发送者,例如原来消息内容是:hello,为了知道消息的发送者可以改为:张三*-*hello(为了获取消息的发送者)

 

下面直接上代码:

User.java(只有一个userName有用)

package cn.xm.jwxt.bean.system;

import java.util.List;
import java.util.Set;

public class User {
  
    private String username;//用户姓名
public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username == null ? null : username.trim();
    }
}

 

 

 

redis-chat.properties

redis.url=127.0.0.1
redis.port=6379
redis.maxIdle=30
redis.minIdle=10
redis.maxTotal=100
redis.maxWait=20000

 

 

Jedis工具类:(返回Jedis连接)

package cn.xm.redisChat.util;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;

/**
 * @Author: qlq
 * @Description
 * @Date: 21:32 2018/10/9
 */
public class JedisPoolUtils {

    private static JedisPool pool = null;

    static {

        //加载配置文件
        InputStream in = JedisPoolUtils.class.getClassLoader().getResourceAsStream("redis-chat.properties");
        Properties pro = new Properties();
        try {
            pro.load(in);
        } catch (IOException e) {
            e.printStackTrace();
        }

        //获得池子对象
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        poolConfig.setMaxIdle(Integer.parseInt(pro.get("redis.maxIdle").toString()));//最大闲置个数
        poolConfig.setMaxWaitMillis(Integer.parseInt(pro.get("redis.maxWait").toString()));//最大闲置个数
        poolConfig.setMinIdle(Integer.parseInt(pro.get("redis.minIdle").toString()));//最小闲置个数
        poolConfig.setMaxTotal(Integer.parseInt(pro.get("redis.maxTotal").toString()));//最大连接数
        pool = new JedisPool(poolConfig, pro.getProperty("redis.url"), Integer.parseInt(pro.get("redis.port").toString()));
    }

    //获得jedis资源的方法
    public static Jedis getJedis() {
        return pool.getResource();
    }
}

 

 

 

消息生产者:(处理消息头部加上消息的发送者,并且根据接受者的userName生成key)

package cn.xm.redisChat.one2one;

import cn.xm.jwxt.bean.system.User;
import cn.xm.redisChat.util.JedisPoolUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;

/**
 * @Author: qlq
 * @Description 消息生产者(根据消息的)
 * @Date: 23:02 2018/10/13
 */
public class RedisMessageProducer {
    private static final Logger log = LoggerFactory.getLogger(RedisMessageProducer.class);

    /**
     * 发送消息的方法
     *
     * @param sendUser   发送消息的用户
     * @param sendToUser 接收消息的用户
     * @param messages   可变参数返送多条消息
     * @return
     */
    public static boolean sendMessage(User sendUser, User sendToUser, String... messages) {
        Jedis jedis = JedisPoolUtils.getJedis();
        try {
            String key = sendToUser.getUsername() + ":msg";
            //将消息的内容加上消息的发送人以 *-* 分割,不能用增强for循环
            for (int i = 0, length_1 = messages.length; i < length_1; i++) {
                messages[i] = sendUser.getUsername() + "*-*" + messages[i];
            }
            Long lpush = jedis.lpush(key, messages);//返回值是还有多少消息未消费
            log.debug("user {} send message [{}] to {}", sendUser.getUsername(), messages, sendToUser.getUsername());
            log.debug("user {} has {} messages ", sendToUser.getUsername(), lpush);
        } catch (Exception e) {
            log.error("sendMessage error", e);
        } finally {
            jedis.close();
        }
        return true;
    }
}

 

 

消息的消费者:(采用线程池获取消息,根据接收消息的userName从对应的key中获取对应的消息,并解析消息的key和发送者和内容)

package cn.xm.redisChat.one2one;

import cn.xm.jwxt.bean.system.User;
import cn.xm.redisChat.util.JedisPoolUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;

import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * @Author: qlq
 * @Description 消息的消费者
 * @Date: 23:44 2018/10/13
 */
public class RedisMessageConsumer {
    private static final Logger log = LoggerFactory.getLogger(RedisMessageConsumer.class);

    /**
     * 参数是初始化线程池子的大小
     */
    private static final ScheduledExecutorService batchTaskPool = Executors.newScheduledThreadPool(2);

    /**
     * 消费消息
     *
     * @param consumerUser 接收消息的用户
     */
    public static void consumerMessage(final User consumerUser) {
        final Jedis jedis = JedisPoolUtils.getJedis();

        //新建一个线程,线程池获取消息
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                while (true){
                    List<String> messages = jedis.brpop(0, consumerUser.getUsername() + ":msg");//0是timeout,返回的是一个集合,第一个是消息的key,第二个是消息的内容
                    String key = messages.get(0);//第一个是key
                    String message = messages.get(1);//第二个是消息
                    String sendUserName = message.substring(0, message.indexOf("*-*"));//获取消息的发送者
                    message = message.substring(message.indexOf("*-*")+3);//获取消息内容
                    log.debug("ThreadName is {},user {} consumer message {} ,sended by {}", Thread.currentThread().getName(),consumerUser.getUsername(), message, sendUserName);
                }
            }
        };
        //线程池中获取消息
        //第一个参数是需要执行的任务,第二个参数是第一次的延迟时间,第三个参数是两次执行的时间间隔,第四个参数是时间的单位
        batchTaskPool.scheduleWithFixedDelay(runnable, 3,5, TimeUnit.SECONDS);
    }
}

 

 

 

 

测试类:(lisi和wangwu消费消息)

package cn.xm.redisChat.one2one;

import cn.xm.jwxt.bean.system.User;

/**
 * @Author: qlq
 * @Description 消息消息
 * @Date: 0:04 2018/10/14
 */
public class ConsumerMessageApp {

    public static void main(String[] args) {
        User sndToUser = new User();
        sndToUser.setUsername("lisi");

        User sndToUser2 = new User();
        sndToUser2.setUsername("wangwu");

        RedisMessageConsumer.consumerMessage(sndToUser);
        RedisMessageConsumer.consumerMessage(sndToUser2);
    }
}

 

 

zhangsan给lisi和wangwu发送消息

package cn.xm.redisChat.one2one;

import cn.xm.jwxt.bean.system.User;

/**
 * @Author: qlq
 * @Description 生产消息测试
 * @Date: 23:59 2018/10/13
 */

public class ProducerMessageApp {
    public static void main(String[] args) {
        User sndUser = new User();
        sndUser.setUsername("zhangsan");

        User sndToUser = new User();
        sndToUser.setUsername("lisi");

        User sndToUser2 = new User();
        sndToUser2.setUsername("wangwu");

        RedisMessageProducer.sendMessage(sndUser, sndToUser, "给李四的消息一", "给李四的消息二");
        RedisMessageProducer.sendMessage(sndUser, sndToUser2, "给王五的消息一", "给王五的消息二");
    }
}

 

 

1.先启动消费者

2.启动消费者之后

消费者控制台如下:

 

生产者控制台如下:

 

 3.再次启动消费者之后

消费者控制台:

 

 

 

生产者控制台:

 

 

至此实现了简单的一对一聊天,实际上就是简单的一个用户给另一个用户发送消息。上面采用这种方式实现的即使用户上线也会接受之前未接受的消息。只有BRPOP之后消息才会消失。

实际中可以根据需求进行实际的开发,实际中有消息类型、内容等。

有时间的话可以用kindeditor实现一个简单的一对一web聊天系统,这个功能待完成。==============

 

2.群聊功能(基于发布/订阅实现)

群聊的思路:采用发布订阅模式实现(publish和subscribe实现)
1.每个channel都有一个频道,每一个channel代表一个群,用户每次订阅这个房间都代表进入这个群,可以发送与接收消息
2.发送者每次发送消息都需要先进入房间,也就是订阅channel,之后可以向该频道发送消息
3.接收者需要先进入房间,也就是订阅channel,然后接收消息

也就是不管发送消息与接收消息,都需要订阅channel进入房间。用户进入某个房间可以存入到zSet,每次进入某个房间和发送消息先判断是否已经进入某个房间。比如:
房间room1,则channel就是room1,保存其成员的set就是room1members。

 

总的来说:

    每个房间都是一个channel,进入房间的成员订阅该channel。每个房间的成员保存在一个zset中,key可以定义为roomName+"users"。

    用户退出房间的时候需要退出该房间,也就是退订该channel,同时在zset中移除该成员。(退订只能调用JedisPubSub的unsubscribe实现)。

 

也就是一个房间对应的信息如下:

  一个channel,名称为  roomName    发送的群消息到这个channel

  一个zset,对应的key为roomName+"users",保存的是进入该房间的用户,用户退出房间需要借助退出房间的消息以及退订实现

  发送群聊消息直接publish消息到该roomName即可。

 

 

 

下面上代码:

RoomUtil   用户进入某个房间与退出某个房间。退出房间需要发出退出房间的信号(也就是发送一条退订的消息)

package cn.xm.redisChat.groupChat;

import cn.xm.jwxt.bean.system.User;
import cn.xm.redisChat.util.JedisPoolUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;

/**
 * @Author: qlq
 * @Description 进入房间的工具类(订阅某个channel,代表进入房间)
 * @Date: 16:15 2018/10/14
 */
public class RoomUtil {

    private static final Logger log = LoggerFactory.getLogger(RoomUtil.class);

    /**
     * 进入房间
     *
     * @param user     用户
     * @param roomName
     */
    public static void enterRoom(User user, String roomName) {
        Jedis jedis = JedisPoolUtils.getJedis();
        String username = user.getUsername();
        Boolean sismember = jedis.sismember(roomName + "users", username);
        if (!sismember) {
            jedis.sadd(roomName + "users", username);
            log.info("{} 已经成功进入房间  {}!", username, roomName);
        } else {
            log.info("{} 已经进入房间,不能重复进入!", username);
        }
    }

    /**
     * 退出房间
     *
     * @param user     用户
     * @param roomName 房间名称
     */
    public static void exitRoom(User user, String roomName) {
        Jedis jedis = JedisPoolUtils.getJedis();
        String username = user.getUsername();
        Boolean sismember = jedis.sismember(roomName + "users", username);
        if (sismember) {
            //从成员组中移除
            jedis.srem(roomName + "users", username);
            //发送退订信号(房间内的成员收到该信号后退订)
            String exitSignal = user.getUsername() + ":exit:" + roomName;
            jedis.publish(roomName, exitSignal);
            log.info("{} 已经发出移除房间  {}的信号!", username, roomName);
        } else {
            log.info("{} 已经不在房间内!", username);
        }
    }

    /**
     * 判断用户是否在某个房间
     *
     * @param user
     * @param roomName
     * @return
     */
    public static boolean userIsInRoom(User user, String roomName) {
        Jedis jedis = JedisPoolUtils.getJedis();
        return jedis.sismember(roomName + "users", user.getUsername());
    }
}

 

 

消息生产者:(publish发布消息到指定的channe)

package cn.xm.redisChat.groupChat;

import cn.xm.jwxt.bean.system.User;
import cn.xm.redisChat.util.JedisPoolUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;

/**
 * @Author: qlq
 * @Description 消息生产者
 * @Date: 18:09 2018/10/14
 */
public class MessageProducer {
    private static final Logger log = LoggerFactory.getLogger(MessageProducer.class);

    public static void produceMsg(final User sendUser, String roomName, String... messages) {
        //发送消息
        Jedis jedis = JedisPoolUtils.getJedis();
        for (int i = 0, length_1 = messages.length; i < length_1; i++) {
            String msg = sendUser.getUsername() + "*-*" + messages[i];
            log.debug(msg);
            jedis.publish(roomName, msg);//发送消息
        }
    }
}

 

 

消息消费者:

  开启线程获取消息,如果收到的是自己退订的信号则自己退出房间(取消订阅该channel),订阅之后线程会一直阻塞,退订之后才会结束线程,也就是局部线程t会一直阻塞,直到收到退订信号之后才会结束线程(也就不再获取消息)

package cn.xm.redisChat.groupChat;

import cn.xm.jwxt.bean.system.User;
import cn.xm.redisChat.util.JedisPoolUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub;

/**
 * @Author: qlq
 * @Description 消息消费者(订阅频道消费消息)
 * @Date: 16:12 2018/10/14
 */
public class MessageConsumer {
    private static final Logger log = LoggerFactory.getLogger(MessageConsumer.class);

    public static void consumerMsg(final User user, final String roomName) {
        if (!RoomUtil.userIsInRoom(user, roomName)) {
            RoomUtil.enterRoom(user, roomName);
        }

        final Jedis jedis = JedisPoolUtils.getJedis();
        //新建一个线程,线程池获取消息
        Thread t = new Thread() {
            @Override
            public void run() {
                jedis.subscribe(new JedisPubSub() {
                    @Override
                    public void onMessage(String channel, String message) {
                        //如果收到退订信号就退订频道
                        String exitSignal = user.getUsername() + ":exit:" + roomName;
                        if (exitSignal.equals(message)) {
                            unsubscribe(channel);
                            log.info("============" + exitSignal + "============,channel->{}", channel);
                        } else if (!message.contains(":exit:")) {
                            log.info("{} consume msg:{},room is->{}", user.getUsername(), message, channel);
                        }
                    }

                    @Override
                    public void unsubscribe(String... channels) {
                        log.info("==============unsubscribe {}========", channels);
                        super.unsubscribe(channels);
                    }
                }, roomName);
            }
        };
        t.start();
    }
}

 

 

 

测试类:

package cn.xm.redisChat.groupChat;

import cn.xm.jwxt.bean.system.User;
import org.junit.Test;

/**
 * @Author: qlq
 * @Description
 * @Date: 21:46 2018/10/14
 */
public class MsgProduceApp {
    public static void main(String[] args) {
        String roomName1 = "room1", roomName2 = "room2";
        User sendUser = new User();
        sendUser.setUsername("zhangsan");

        MessageProducer.produceMsg(sendUser, roomName1, "消息一", "消息二");
        MessageProducer.produceMsg(sendUser, roomName2, "消息一一", "消息二二");
    }

    /**
     * 将用户lisi移除房间2
     */
    @Test
    public void fun2() {
        User user = new User();
        user.setUsername("lisi");
        RoomUtil.exitRoom(user, "room2");
    }
}

 

 

package cn.xm.redisChat.groupChat;

import cn.xm.jwxt.bean.system.User;

/**
 * @Author: qlq
 * @Description
 * @Date: 18:19 2018/10/14
 */
public class MsgConsumeApp {

    public static void main(String[] args) {
        String roomName1 = "room1", roomName2 = "room2";
        User sendUser = new User();
        sendUser.setUsername("lisi");
        User sendUser2 = new User();
        sendUser2.setUsername("wangwu");

        MessageConsumer.consumerMsg(sendUser, roomName1);
        MessageConsumer.consumerMsg(sendUser, roomName2);

        MessageConsumer.consumerMsg(sendUser2, roomName2);
    }
}

 

 

 

 

测试过程如下:

1.先调用MsgConsumeApp订阅房间

2.调用MsgProduceApp生产消息

生产者控制台:

 

消费者控制台:

 

 3.调用fun2 退出房间:

消费者控制台:

 

 4.再次调用生产者生产消息:(lisi不接收房间2的消息)

 

   至此完成了群聊功能,实际上群聊还可以用线程池处理接收消息的线程,暂时用远程的Thread处理。

 

以上是关于Redis实现聊天功能的主要内容,如果未能解决你的问题,请参考以下文章

Swoole+Redis+webSocket实现点对点即时聊天

Swoole+Redis+webSocket实现点对点即时聊天

如何从一组中继容器中组合片段?

到点提醒功能如何实现简单,不如试试Redis

基于swoole实现多人聊天室

ASP.NET SignalR 与 LayIM2.0 配合轻松实现Web聊天室 之 用 Redis 实现用户在线离线状态消息处理