在学习了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处理。