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处理。