上一篇说到了观察者模式较为传统的用法,这篇准备分享点流行的,不过在开始新内容之前,我们不妨先思考一下两种场景,一个是报社订阅报纸,另一个是在黑板上发公告,都是典型观察者模式应用场景,二者有何不同?
- 报社订阅报纸,订阅者需要到报社登记交钱,然后报社才会每次有新报纸时通知到订阅者。
- 而在黑板上发公告,发布的人不知道谁会看到,看到的人也不知道是谁发出的,而事实上,看到公告的人也可能只是偶然的机会瞟了一眼黑板而已。
可以看到,二者有明显的区别。前者,观察者必须要注册到被观察者上才能接收通知;而后者,观察者和被观察者之间是相互完全陌生的。回顾一下我们在上一篇中举的例子,不难发现它其实类似第二种场景,狗叫并不知道谁会听见,而听的人也不是为了听狗叫,他仅仅是在关注外界的动静,恰好听到了狗叫而已。但我们采用的是类似第一种场景的处理方式,显然并不合适。因此,也就自然而然的留下了两个问题:
dog.AddObserver(...)
真的合适吗?实际生活中,狗真的有这种能力吗?- 我们知道
C#
中不支持多继承,如果Dog
本身继承自Animal
的基类,如果同时作为被观察者,除了用上述演进一的实现,还能如何实现?
针对这两个问题,该怎么解决了?不妨再回顾一下之前学过的设计原则,看看哪里可以寻找突破口。
一番思索不难发现,主题类违背了合成复用原则,也就是我们常说的,HAS A
比IS A
更好。既然知道HAS A
更好,我们为什么非得通过继承来实现功能的复用呢?更何况我们继承的还是个普通类。
演进四-事件总线
基于这种思路,我们可以试着把继承改成组合,不过在这之前,我们不妨一步到位,干脆再为Subject
类定义一个抽象的接口,免得看着不舒服,毕竟面向抽象编程嘛:
public interface ISubject
{
void AddObserver(IObserver observer);
void RemoveObserver(IObserver observer);
void Publish(EventData eventData);
}
public class Subject: ISubject
{
private readonly IList<IObserver> _observers = new List<IObserver>();
public void AddObserver(IObserver observer)
{
_observers.Add(observer);
}
public void RemoveObserver(IObserver observer)
{
_observers.Remove(observer);
}
public void Publish(EventData eventData)
{
foreach (var observer in _observers)
{
observer.Update(eventData);
}
}
}
逻辑并没有任何改动,仅仅是实现了一个接口而已,这一步不做其实也没有关系。接下来该做什么应该也很清楚了,没错,就是组合到被观察者中去,也就是Dog
和Son
,下面是具体的实现:
public class Dog
{
private readonly ISubject _subject;
public Dog(ISubject subject)
{
this._subject = subject;
}
public void Bark()
{
Console.WriteLine("遥闻深巷中犬吠");
_subject.Publish(new EventData { Source = this, EventType = "DogBark" });
}
}
public class Son : IObserver
{
private readonly ISubject _subject;
public Son(ISubject subject)
{
this._subject = subject;
}
public void Update(EventData eventData)
{
if (eventData.EventType == "DogBark")
{
Wakeup();
}
}
public void Wakeup()
{
Console.WriteLine("既而儿醒,大啼");
_subject.Publish(new EventData { Source = this, EventType = "SonCry" });
}
}
修改的仅仅是被观察者,观察者不需要做任何改变。看到上面的调用,不知道大家有没有一种熟悉的感觉呢?没错,这里的使用方式像极了微服务中常用的事件总线EventBus
,事实上,事件总线就是这么实现的,基本原理仅仅是观察者模式继承转组合而已。
再看看调用的地方:
static void Main(string[] args)
{
ISubject subject = new Subject();
Dog dog = new Dog(subject);
Wife wife = new Wife();
Husband husband = new Husband();
Son son = new Son(subject);
subject.AddObserver(wife);
subject.AddObserver(husband);
subject.AddObserver(son);
dog.Bark();
}
将Dog
与Subject
之间的关系改为HAS A
之后,实际的事件发出者和事件接收者之间多了一层,使得二者之间完全解耦了。这时,Dog
可以继承自己的Animal
基类了,并且也不用再做类似在Dog
类中管理Wife
、Husband
、Son
这么奇怪的事了,对观察者的管理交给总线来完成。
再来看看这时的类图长什么样子:
如果觉得复杂,可以不看Dog
和Sun
这两个节点,只看实线框中的部分,有没有发现就是前面简易版的观察者模式呢?被观察者还是Subject
,只不过和Dog
、Sun
已经没什么关系了,这是多一层必然会导致的结果。到这里,其实已经完美实现需求了,Subject
是原来的被观察者,但现在相当于事件总线,在程序启动的时候,将观察者全部注册到总线上就可以接收到总线上的事件消息了。
演进五-MQ
你以为这样就完了吗?其实并没有。再回到软件开发领域,我们知道,事件的触发可以发生在系统内部,也可以发生在系统之间。而前面无论哪种方式的实现,其实解决的都是内部问题,那如果需要跨系统该怎么办呢?直接调用的话,会像上篇当中的第一个实现一样,出现强耦合,只不过这时调用的不再是普通的方法,而是跨网络的API,而强耦合的也不再是类与类之间,而是系统与系统之间。并且随着事件数量的增多,也会使得调用链变得混乱不堪,难以管理。
为了解决这个问题,就需要在所有系统之外,加入一个中间代理的角色,所有发布者将事件消息按不同主题发送给代理,然后代理再根据观察者关注主题的不同,将消息分发给相应的观察者,当然,前提是发布者和观察者都提前在代理这里完成注册登记。
我们先模拟实现一个代理,当然,我这里只是通过单例模式实现一个简单的示例,真实情况会比这个复杂的多:
public class Broker
{
private static readonly Lazy<Broker> _instance
= new Lazy<Broker>(() => new Broker());
private readonly Queue<EventData> _eventDatas = new Queue<EventData>();
private readonly IList<IObserver> _observers = new List<IObserver>();
private readonly Thread _thread;
private Broker()
{
_thread = new Thread(Notify);
_thread.Start();
}
public static Broker Instance
{
get
{
return _instance.Value;
}
}
public void AddObserver(IObserver observer)
{
_observers.Add(observer);
}
public void RemoveObserver(IObserver observer)
{
_observers.Remove(observer);
}
private void Notify(object? state)
{
while (true)
{
if (_eventDatas.Count > 0)
{
var eventData = _eventDatas.Dequeue();
foreach (var observer in _observers)
{
observer.Update(eventData);
}
}
Thread.Sleep(1000);
}
}
public void Enqueue(EventData eventData)
{
_eventDatas.Enqueue(eventData);
}
}
这里通过单例模式定义了一个Broker
代理类,实际情况下,这部分是由一个永不停机的MQ服务承担,主要包括四个部分组成:
- 一个
Queue<EventData>
类型的队列,用于存放事件消息; - 一组注册和注销观察者的方法;
- 一个接收来自事件发布者的事件消息的方法;
- 最后就是事件消息的通知机制,这里用的是定时轮询的方式,实际应用中肯定不会这么简单。
事实上,上述四个部分都应该针对不同的主题实现,也就是我们常常会提到的Topic,几乎所有的MQ都会有Topic的概念,为了简单,我们这里就不考虑了。
再来看看Subject
的实现:
public interface ISubject
{
void Publish(EventData eventData);
}
public class Subject: ISubject
{
public void Publish(EventData eventData)
{
Broker.Instance.Enqueue(eventData);
}
}
由于对IObserver
的管理交给了Broker
代理,因此这里就不需要再关注具体的观察者是谁,也不需要管理观察者了,只需要负责发布事件就行了。需要注意的是,事件消息发布给了Broker
,后续的一切工作交给Broker
全权处理,观察者依然不需要做任何代码上的修改。
调用的地方涉及到的改变主要体现在观察者的注册上,毕竟管理者不再是Subject
,而是交由Broker
代理接管了:
static void Main(string[] args)
{
ISubject subject = new Subject();
Dog dog = new Dog(subject);
Wife wife = new Wife();
Husband husband = new Husband();
Son son = new Son(subject);
Broker.Instance.AddObserver(wife);
Broker.Instance.AddObserver(husband);
Broker.Instance.AddObserver(son);
dog.Bark();
}
乍一看,事情变得越来越复杂了,这里为了解决跨系统的问题,又套了一层,类图有点复杂,为避免混乱,我就不画了。不过好在思路的演进是清晰的,达到现在的结果,应该也不会觉得突兀,这个其实就是当前盛行的MQ的基本实现思路了。
演进过程
通过前面一系列的改造,我们解决了不同场景下的事件处理问题。接下来,我们再次梳理一下观察者模式的整个演进过程,先看一张图:
这张图显示了观察者模式演进的不同阶段,主题与观察者之间的调用关系:
- 第一阶段降低了主题与观察者之间的耦合度,但并没有完全解耦,这种情况主要应用在类似报纸订阅的场景;
- 第二阶段在主题与观察者之间加了一条总线,使得主题与观察者完全解耦,这种情况主要运用在类似黑板发布公告的场景,但该实现难以应对跨系统的事件处理;
- 第三阶段在总线与观察者之间又加了一个代理,使得存在于不同系统之间的主题与观察者也能够解耦并且正常通信了。
可以看出,他们都有各自的应用场景,并不能简单的说谁更先进,谁能替代谁。可以预见,观察者模式未来可能还会继续演进,去应对更多新的更复杂的场景。
.Net中的应用
既然观察者模式这么好用,那.Net框架中自然也会内置一些处理机制了。
- 在.Net项目中,委托(
delegate
)和事件(event
)就是观察者模式的很好的一种实践,不过需要注意的是,委托和事件,严格意义上讲,已经不能称之为设计模式了,因为它们针对的都是方法,跟面向对象设计无关,不过倒是可以称之为惯用法。不过不管怎么样,它们要解决的问题跟观察者模式是一致的。 - .Net中提供了一组泛型接口
IObserver<T>
和IObservable<T>
可用于实现事件通知机制,顾名思义,前者相当于观察者,后者相当于主题。
这里就不列代码,以免喧宾夺主了,因为这不是本文的重点。而且前者太常用了,应该没什么人不会。而后者呢,不知道大家用的多不多,但其实我自己没怎么用,我更愿意根据不同的场景来定义语义更明确的接口,如ISender
用于发送,IProducer
用于生产,IListener
用于监听,IConsumer
用于消费等。
总结
事件无处不在,毫不夸张的说,整个世界的运转都是由事件驱动的。因此观察者模式也是无处不在的。我们知道,设计模式经过这么多年的发展,已经有了很大的变化,有的下沉变成了某些语言的惯用法,例如后面会讲到的迭代器模式,有些上升更偏向于架构模式,例如前面讲过的外观模式。甚至有的被淘汰,例如备忘录模式。但是观察者模式却是唯一一个向上可用于架构设计,向下被实现为惯用法,中间还能重构代码,简直无处不在,无所不能。并且可以预见,未来也必然是经久不衰。
说的有点夸张了,不过也确实说明观察者模式再怎么重视也不为过了!