各位朋友,大家好,我是 Payne,欢迎大家关注我的博客,我的博客地址是http://qinyuanpei.com。最近这段时间的天气可谓是变幻莫测,常常是周一到周五像夏天般热烈,而周六和周天像秋天般冷清。你不知道它到底会在何时下雨,即使你可以一直带着伞等雨落下来。但是对于没有伞的我来说,学会努力奔跑以至于不那么狼狈,或许是在这个世界上我唯一可以去做的事情。可是你知道一个人孤独的时候,即使是下雨这种再平常不过的事情,他都可以从雨声里听出孤独的感觉来,所以这个周末我决定继续研究 Redis 缓存技术,而今天我想和大家讨论的话题是 Redis 中的发布-订阅(Pub-Sub),希望大家喜欢!
从观察者模式说起
如果你熟悉常见的设计模式,就应该会知道在 24 种设计模式中,有一种称为观察者模式的设计模式,该模式又被称为发布-订阅模式。在正式讨论 Redis 中的发布-订阅特性前,我想先花点时间来为大家讲解下这种设计模式。观察者模式定义了一种一对多的依赖关系,让多个观察者同时监听同一个主题对象,当该主题对象在状态发生变化时,会通知所有观察者对象并使其自动更新自己。下面是该模式的 UML 类图:
通常我们提到设计模式的时候,都认为实际模式是非常抽象而晦涩的概念,事实上设计模式是一种经过反复验证的编程经验。我们每天面对这个世界对其进行抽象并认识它,所以设计模式本质上是根植自生活的一种编程思想。以观察者模式为例,我们或许会在微信里订阅各种各样感兴趣的公众号,当这些公众号的内容发生更新时,就会主动向我们推送新的内容。在这里,我们订阅的公众号称为"主题",而我们则称为"观察者"或者"订阅者",而这正是观察者模式又被称为"发布-订阅模式"的原因所在,这种定义了一种一对多的依赖关系,让多个观察者同时监听同一个主题对象,当该主题对象在状态发生变化时,会通知所有观察者对象并使其自动更新自己的设计模式就被称为"观察者模式"。而通过这张图我们可以了解到,观察者模式试图解决的问题是,在不同的实例对象间相互协作的时候,如果在降低其各自耦合度的同时,维持这些示例对象间的一致性。在该模式中,主要存在四种角色,即:
- 抽象主题(Subject):抽象主题将所有观察者对象的引用保存到一个集合里,每个主题都可以有任何数量的观察者。抽象主题提供一个接口,可以增加(Attach 方法)和删除(Detach 方法)观察者对象。
- 具体主题(ConcreteSubject):具体主题将在其内部定义相关状态,并将相关状态存入具体观察者对象。在具体主题内部状态发生变化时,通知所有注册过的观察者发出通知,即 UML 类图中定义的 Notify()方法。
- 抽象观察者(Observer):抽象观察者将为所有具体的观察者定义一个接口,在获得主题更新通知时更新自己,即 UML 类图中定义的 Update()方法,执行该方法后观察者与主题的状态实现同步。
- 具体观察者(ConcreteObserver):具体观察者将实现抽象观察者所定义的更新接口,来使得观察者自身的状态与主题状态协调,即具体观察者需要重写 Update()方法并维护其内部状态同主题保持一致。
至此我们就从思想上理解了观察者模式,观察者模式本质上是在维护一种一对多的依赖关系,因为观察者与主题都是依赖于抽象而非具体,两者分别属于两个不同层次上的抽象,因此观察者和主题两者间是解耦的。可是当你去实现一个具体的主题或者具体的观察者的时候,你会发现这两者间依然存在一定的依赖,因为观察者和主题在接口设计上需要协调,因为两者分别作为消息的"接收方"和"发送方"存在。观察者模式虽然在解耦上效果显著,可这并不代表它就是完美的。事实上,当观察者数目特别多的时候,为了通知所有的观察者将花费大量的时间;其次,当观察者间存在依赖关系时,观察者模式将导致这些观察者出现循环调用;再者,当主题通过异步的方式来通知观察者时,需要考虑通知本身是以自洽的方式进行的;最后,观察者模式可以确保观察者捕捉到主题的变化,可是观察者模式机制本身不具备知晓主题如何变化的能力。好了,下面我们来讲解如何实现一个基本的观察者模式。
观察者模式的实现
现在,我们已然了解到在观察者模式中主要有四类角色,即抽象主题、抽象观察者、具体主题和具体观察者。因此,要实现观察者模式,实际上就是要实现这四种不同的角色。回到我们最初讨论过的场景,即微信用户订阅公众号,假设博主希望在博客更新的时候,以邮件或者公众号的形式来通知读者朋友博客更新的内容,这是一个典型的一对多的依赖关系维护的问题,显然此时观察者模式是一个最佳的设计思路。在这个设计中,邮件和公众号是两个具体的观察者,而博客是一个具体的主题。参照观察者模式的 UML 类图,我们应该首先提取出来两个抽象类,即 Subject 和 Observer。
对 Subject 类而言,首先它需要提供一个订阅(Subscribe 的方法和取消订阅(Unsubscribe)方法,这和我们在日常生活中订阅报纸是完全一样的;其次,它需要有一个更新(Update)的方法,该方法负责向所有的订阅者广播消息。为什么叫做广播呢?因为所有的订阅者都会收到这条消息,这种订阅者被动接受主题推送消息的方式我们称为"推送模式",即在 Update 的时候,主题会主动推送"参数"给订阅者;而订阅者主动拉取主题消息的方式我们称为"拉取模式",即在 Update 的时候,主题并不主动推送"参数"给订阅者,而是由订阅者通过注入的主题来获取消息。这两种方式我们都可以称之为观察者模式,在这里我们选择"推送模式",代码实现如下:
public abstract class Subject
{
private IList<Observer> observers = new List<Observer>();
public void Attach(Observer observer)
{
observers.Add(observer);
}
public void Deatch(Observer observer)
{
observers.Remove(observer);
}
public void Notify(string message)
{
observers.ToList().ForEach(o => o.Update(message));
}
}
对 Observer 而言,它在观察者模式中承担着消息接收者的角色,所以我们需要为其定义好接收消息的接口,需要注意的是该接口必须与具体主题保持一致,这便是我在文章中提到的,主题和观察者存在一定程度依赖的问题。考虑到不同的观察者所做的事情是完全不同的,例如邮件和公众号采取两种不同的方式来推送消息,因此 Update 方法应该被声明为虚方法,以为不同的观察者提供重写的扩展能力。它的代码实现如下:
public abstract class Observer
{
public virtual void Update(string message)
{
}
}
对具体观察者而言,我们需要做的就是继承 Observer 类然后重写 Update 方法,在这里我们需要实现两个不同的类 EmailObserver 和 WechatObserver,它们分别来实现邮件和公众号接收到主题推送消息以后的逻辑,这里以 EmailObserver 为例,代码实现如下:
public class EmailObserver:Observer
{
public override void Update(string message)
{
Console.WriteLine("邮箱接收到订阅消息:{0}", message);
}
}
对具体主题而言,我们不再关心如何向所有的观察者发送消息,该功能在 Subject 父类中已然完成。我们可以为新的主题类添加更多的属性来描述其内部发生变化时的状态,例如文章数目、评论数目或者是内容更改等等。在这个例子中我们选择最简单的方式,即简单通知这两个观察者,因此我们直接继承 Subject 类即可。此时,完整的调用代码如下:
BlogSubject blog = new BlogSubject();
blog.Attach(new EmailObserver());
blog.Attach(new WechatObserver());
blog.Notify("Payne更新了Redis缓存技术学习系列文章");
好了,现在通过下面的截图,我们就可以看到两个观察者 EmailObserver 和 WechatObserver,都接收到了来自主题 Blog 的消息推送。这就是观察者模式啦,看起来是不是非常简单。可是相信大家使用公众号以后就会发现一个问题,随着你订阅的内容越来越多,你的微信消息列表里出现的消息推送就越来越多,这个时候如果你不想接收消息推送该怎么办呢?答案好像只有一个,那就是取消订阅。这个场景可以看出"推送模式"让订阅者饱受消息骚扰,而为了解决这个问题,我们就有了"拉取模式",此时主题仅仅是告诉观察者博客内容有更新,而更新的内容需要观察者自己去处理,这种模式大同小异,大家可以参照"推送模式"来自己实现。
这些就是观察者模式的核心内容啦,观察者模式的优点是它解除了主题和观察者间的耦合,并且使得这两者各自都依赖于抽象而非具体,观察者模式适用的场景是当一个对象的改变需要给变其它对象时,而且它不知道具体有多少个对象有待改变时。在 C#中我们可以通过委托、事件以及 Observable 接口这三种方式来更好、更快的实现观察者模式,自然这些都是后话啦,如果以后有机会我们可以继续进行探讨。
Hey Redis Pub-Sub
好了,了解完观察者模式即发布-订阅模式以后,我们现在就可以开始学习 Redis 中的发布-订阅模式啦。为什么我们要在开始学习 Redis 中的发布-订阅模式前,了解设计模式相关的概念呢?这是因为 Redis 中的发布-订阅模式和 Gof 设计模式一脉相承,譬如事件机制、消息机制等概念其实都是观察者模式的一种实际应用,一旦我们掌握了观察者模式的核心思想,即使这个世界充满了套路,可是这对你我而言又有什么不同呢?我们学习设计模式不是为了记住这些类图,而是能在最恰当的场景中合理使用这些模式来解决问题,这是我们学习的最终目的。
Redis 中的发布-订阅模式是一种消息通信模式,即发布者发布消息,订阅者接收消息。在 Redis 中客户端可以订阅任意个频道,当该频道内接收到一个新消息时,所有订阅该频道的客户端都会收到这条新消息。我们可以这样理解这种消息通信模式,我们每个微信账号都是一个客户端,每个客户端都可以订阅任意个微信公众号,当微信的后台服务上接收到某个微信公众号的请求消息时,所有订阅了该微信公众号的客户端都会收到该推送。一个简单的图示如下:
我们可以注意到这和我们在文章中提到的"观察者模式"非常相似,在这个通信模式下,客户端作为消息的订阅者,即观察者。而频道作为消息的发布者,即主题。在 Redis 中频道是一个字符串类型的值,你可以将其理解为一个 Id。虽然我们在这篇文章中花费大量时间来讲观察者模式,事实上 Redis 中的发布-订阅是非常轻量并且强大的,下面是常见的命令:
PSUBSCRIBE:该命令用于订阅一个或者多个符合模式匹配的频道
PUBSUB:该命令用于返回由活跃频道组成的列表,即可以查询订阅与发布系统的状态
PUBLISH:该命令用于发送消息到指定的频道
PUNSUBSCRIBE:该命令用于退订所有符合模式匹配的频道
SUBSCRIBE:该命令用于订阅一个或多个频道
UNSUBSCRIBE:该命令用于退订一个或多个频道
以上这些就是和发布-订阅相关的命令啦,从整体上而言它是相当简洁和紧凑的。在这篇文章中我们通篇都在说观察者模式,事实上 Redis 的发布-订阅从本质上来讲还是观察者模式,Redis 内部会维护一个频道的字典,首先它会从频道字典中查找所有的客户端,如果字典中不存在该频道,则将订阅该频道的客户端列表添加到字典中,否则它会返回字典中已经存在的客户端列表。在获取到所有客户端列表以后,Redis 将会遍历客户端列表中的客户端,然后给每个客户端发送消息,这部分代码的解读可以参考这篇文章:15 天玩转 redis —— 第九篇 发布/订阅模式。好了,这篇文章暂时就是这样子啦,为什么感觉最近学习 Redis 没有动力了呢?这篇文章没有实际的命令演示,这是因为我是在 Windows 系统下写完的这篇文章,深夜啦,睡吧!
现在我们一起来看一个简单的示例,在这个示例中我们让两个客户端 A 和 B,订阅同一个频道 News,然后由客户端 C 来向这个频道 News 广播一条消息,理论上客户端 A 和客户端 B 都将会收到这条消息,需要注意此时服务端是开启的。首先,对于客户端 A 和客户端 B,我们在两个不同的终端窗口中打开 redis-cli,然后输入命令:
> SUBSCRIBE News
在按下回车后,我们可以看到下面的信息:
1) "subscribe"
2) "News"
3) (integer) 1
好了,现在我们在客户端 C 中来广播一条消息:
> PUBLISH News "This is a message sent by 127.0.0.1:6379"
此时我们可以看到下图中所示的结果: