一、前言

前面五篇blog分别讲解了redis的五种数据结构所对应的相关命令,基础命令操作起来比较简单,本文要开始讲解redis中发布与订阅的相关命令。

发布与订阅(pub/sub)的特点是
订阅者(subscriber)负责订阅频道(channel)
发布者(publisher)负责向频道发送消息(message)
每当有消息被发送至给定频道时,频道的所有订阅者都会收到消息。我们也可以把频道看作是电台。订阅者可以同时收听多个电台,而发送者则可以在任何电台发送消息

在阅读本文之前,你先要温习一下Java多线程的知识、还要了解Springboot项目如何操作。

二、多线程实现发布订阅的业务逻辑

2.1 发布与订阅的整体流程

首先要明白订阅、发布、频道之间的关系,如下图所示

redis pub sub 失效 redis的pubsub_redis pub sub 失效


发布者可以选择一个或几个频道发送消息,订阅者也可以订阅一个或几个频道监听消息。就像我发出这篇文章(文章就是消息,我就是发送者,账号就是频道),我既可以在这个账号上发这篇文章,也可以另起一个账号发。作为读者的你既可以关注我的账号也可以关注其他账号。这个是比较容易理解的。

那么在本次示例中,我们定义了“发布者1号”和“发布者2号”,

如上图所示,

“发布者1号”在频道1和频道2上发送消息;
“发布者2号”在频道1频道2频道3上发布消息。

我们还定义了“订阅者1号”和“订阅者2号”

“订阅者1号”订阅了频道1和频道3
“订阅者2号”订阅了频道1和频道2

如果看文字比较累就把上面的图好好记住吧。

显而易见,发布者1发布的时候,发布者2可能也在发布,并且订阅者也可能在读订阅者2的发布消息,就像你同时关注了人民日报、浙江日报、杭州日报这三个报纸发布消息是互不干涉的,他们一旦发布出消息订阅者都可以读到,为了实现这个功能就必须使用多线程。下面我们将一步步进行实现。

2.2 建立发布者线程

发布者要干的事情就是将给定的消息推送到给定的频道,至于谁关注了这个频道与发布者无关,这就完成了发布者与订阅者的解耦。因为多个发布者多次发布多条消息,所以需要使用Jedis连接池(JedisPool)来进行连接redis,有过数据库开发经验的朋友肯定很容易理解这一点。
那么,我们首先创建一个Publisher类,因为要使用多线程,所以要让Publisher类继承Thread或实现Runnable接口,这里因为没有其他的类要继承所以就选择直接继承Thread,重写run()方法。

public class Publisher extends Thread {
    private final JedisPool jedisPool;
    private  String[] channelName;
    /**
     * 连接池,频道名*/
    public Publisher(JedisPool jedisPool,String ...channelName) {
        this.jedisPool = jedisPool;
        this.channelName = channelName;
    }

    @Override
    public void run() {
        //发布消息
        System.out.println("发布消息:");
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
        Jedis jedis = jedisPool.getResource();   //连接池中取出一个连接
        while (true) {
            String line = null;
            try {
                line = reader.readLine();
                //输入quit即退出发布命令
                if (!"quit".equals(line)) {
                    jedis.publish(channelName[0], line+"---1号频道");   //从 myChannel01 的频道上推送消息
                    jedis.publish(channelName[1], line+"---2号频道");   //从 myChannel02 的频道上推送消息
                    jedis.publish(channelName[2], line+"---3号频道");   //从 myChannel02 的频道上推送消息
                } else {
                    System.out.println("退出发布消息");
                    break;
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

然后在Publisher内部创建连接池,因为有多个频道,每次每个发布者所订阅频道的数量是不可预测的,所以选用可变参数来实现。
run()方法内就是将消息推送到频道的主要逻辑,channelName[i]是要推送的频道,line是从控制台读取的要发送出去的信息,后面加的“—1号频道”这样的字符串是为了让我们在控制台查看时更加轻松。

其中的publish就是redis中将指定消息发送到指定频道的命令

2.2 建立订阅者线程

刚刚说到发布者与订阅者在程序里其实没有什么关系,因为他们之间已经实现了解耦。那么订阅者要干的事情就是从已订阅的频道里收听消息,就像你订阅了人民日报一样,人民日报的编辑发送文章时可能不知道你会读到这篇文章,但是只要他在你已经订阅了的人民日报上将这篇文章发送出来,你就理应接收到这篇文章。

这里比较难理解的概念叫做监听,为了让你能够顺利的读到人民日报,你的手机必须保证一直在监听人民日报的公众号或者客户端,所谓监听就是,出现了某种情况,就做某种动作。比如,人民日报发送了早间新闻,那么订阅者线程的相关方法就应该立马提示用户人民日报更新了。在订阅者类里,监听的相关方法是要对JedisPubSub父类中的相关方法进行复写的。

首先我们应该写一个订阅者的监听类,将频道和订阅者之间的关系捋清楚。那么创建SubscriberListener类继承JedisPubSub父类。因为每个订阅者都有名字进行区分,所以要有subName属性。

public class SubscriberListener extends JedisPubSub {
    private String subName;

    public SubscriberListener(String subName) {
        this.subName = subName;
    }

    @Override
    public void onMessage(String channel, String message) {       //收到消息会调用
        System.out.println(String.format("["+subName+"收到消息]\n 频道: %s:消息: %s", channel, message));
    }

    @Override
    public void onSubscribe(String channel, int subscribedChannels) {    //订阅了频道会调用
        System.out.println(String.format(subName+"订阅成功, channel %s, subscribedChannels %d",
                channel, subscribedChannels));
    }

    @Override
    public void onUnsubscribe(String channel, int subscribedChannels) {   //取消订阅 会调用
        System.out.println(String.format("取消订阅 channel %s, 已订阅频道数 %d",
                channel, subscribedChannels));

    }
}

上述代码中
onMessage()是监听收到消息时要调用的方法,
onSubscribe()是订阅者订阅了某个频道时要调用的方法,
onUnsubscribe()是订阅者退订某个频道时要调用的方法。

既然已经将订阅者和频道之间的关系捋清楚了,那么我们就需要一个线程来实现订阅的相关操作。
创建Subscriber类继承Thread类,同样的要复写run()方法

public class Subscriber extends Thread {
    private final JedisPool jedisPool;
    private final SubscriberListener subscriberListener;
    private String[] channelName;
    public Subscriber(JedisPool jedisPool,SubscriberListener subscriberListener,String ...channelName) {
        super("SubThread");
        this.jedisPool = jedisPool;
        this.subscriberListener = subscriberListener;
        this.channelName = channelName;
    }
    @Override
    public void run() {
//        System.out.println(String.format("subscribe redis, channel %s, thread will be blocked", subscriberListener.getSubName()));
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();   //取出一个连接
            System.out.println("开始订阅");
            jedis.subscribe(subscriberListener,channelName);    //通过subscribe 的api去订阅,入参是订阅者和频道名
            //线程阻塞,开始监听
            System.out.println("退订");
        } catch (Exception e) {
            System.out.println(String.format("subsrcibe channel error, %s", e));
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
    }
}

其中的subscribe,就是redis中订阅给定的一个或多个频道的命令

三、开启客户端进行发布订阅

在第二节中,已经初步实现了发布者和订阅者的相关逻辑,那么本节将开启发布者客户端和订阅者客户端进行发布订阅的相关操作。

3.1 发布者客户端

首先在连接池中取得连接,设定线程数量,这里虽然设置5,但是因为发布者客户端涉及消息发布,即控制台输入,为了不搞混,我们在每个发布者客户端内只实例化一个发布者。

public class PublishClient01 {
    public static void main(String[] args) {
        //创建连接池
        JedisPool jedisPool = new JedisPool("47.99.164.123");
        //创建线程池,并设定线程数量
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        //创建一个发布者
        Publisher publisher01 = new Publisher(jedisPool,"1号频道","3号频道");
        executorService.submit(publisher01);
        executorService.shutdown();
        try {
            executorService.awaitTermination(600, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

本文示例中写了两个发布者客户端,另外一个读者自己创建,将01改为02就可以啦~~

3.2 订阅者客户端

订阅者也是要搞两个的,但是因为订阅者客户端不需要输入,仅仅在控制台打印收到的消息就可以了,所以写一个客户端创建两个订阅者就可以了。

public class SubscriberClient {

    public static void main(String[] args) throws InterruptedException {
        //创建redis连接池
        JedisPool jedisPool = new JedisPool("47.99.164.123");
        //创建线程池
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        //创建订阅者
        final SubscriberListener subscriberListener01 = new SubscriberListener("订阅者一号");
        final SubscriberListener subscriberListener02 = new SubscriberListener("订阅者二号");
        //订阅频道
        Subscriber subscriber01 = new Subscriber(jedisPool,subscriberListener01,"1号频道","3号频道");
        Subscriber subscriber02 = new Subscriber(jedisPool,subscriberListener02,"1号频道","2号频道");
        executorService.submit(subscriber01);
        executorService.submit(subscriber02);
        executorService.shutdown();
        executorService.awaitTermination(60, TimeUnit.SECONDS);
        // 30s后取消订阅
        Thread.sleep(3000);
        subscriberListener01.onUnsubscribe("1号频道", 1);
    }
}

至此,整个发布订阅就已经写完了。接下来我们运行测试一下就好。

四、运行测试

开启两个发布者客户端和订阅者客户端

查看订阅者客户端,

订阅者一号订阅成功1号频道和3号频道

订阅者二号订阅成功1号频道和2号频道

redis pub sub 失效 redis的pubsub_System_02


通过发布者1号发布“秋天的第一杯奶茶”

发布者1号是发布频道1频道3

redis pub sub 失效 redis的pubsub_redis_03


发布者2号发布“春风十里不如你”

发布者2号是发布频道1频道2频道3

redis pub sub 失效 redis的pubsub_redis pub sub 失效_04


好的,现在发布者1号和发布者2号都已经发布消息,那么我们可以回头看下订阅者客户端

redis pub sub 失效 redis的pubsub_redis pub sub 失效_05


因为

发布者1号发布频道1频道2

订阅者1号订阅频道1频道3

订阅者2号订阅频道1频道2

所以

订阅者1号的频道1里面收到了“秋天的第一杯奶茶”

订阅者2号只有频道1里面收到了“秋天的第一杯奶茶”

因为
发布者2号发布频道1频道2频道3
订阅者1号订阅频道1频道3
订阅号2号订阅频道1频道2
所以
订阅者1号的频道1频道3都收到了“春风十里不如你”
订阅号2号的频道1频道2都收到了“春风十里不如你”

总结

发布–订阅模式虽然实现了解耦,但是如果客户端在执行订阅操作的过程中断线,那么客户端将丢失在断线期间发送的所有消息。如果你喜欢用简单的PUBLISH和SUBSCRIBE命令,并且能承担丧失一部分数据的风险,那么本文还算是一个非常不错的方案。
本文源码下载地址
http://47.99.164.123:8088/root/redisLearn/blob/master/redisDemo.rar

redis pub sub 失效 redis的pubsub_System_06