设计模式-观察者模式上

观察者模式可以说是非常贴近我们生活的一个设计模式,为什么这么说呢?哲学上有这么一种说法,叫做“万事万物皆有联系”,原意是说世上没有孤立存在的事物,但其实也可以理解为任何一个事件的发生必然由某个前置事件引起,也必然会导致另一个后置事件。我们的生活中,充斥着各种各样的相互联系的事件,而观察者模式,主要就是用于处理这种事件的一套解决方案。

示例

观察者模式在不同需求下,实现方式也不尽相同,我们还是举一个例子,然后通过逐步的改进来深刻感受一下它是如何工作的。

在中学阶段有一篇课文《口技》,其中有一句“遥闻深巷中犬吠,便有妇人惊觉欠伸,其夫呓语。既而儿醒,大啼。”应该不用翻译吧?我们接下来就是要通过程序模拟一下这个场景。

先看看他们之间的关系,如下图所示:

初版实现

一声狗叫引发了一系列的事件,需求很清晰,也很简单。于是,我们可以很容易的得到如下实现:

public class Wife
{
    public void Wakeup()
    {
        Console.WriteLine("便有妇人惊觉欠伸");
    }
}

public class Husband
{
    public void DreamTalk()
    {
        Console.WriteLine("其夫呓语");
    }
}

public class Son
{
    public void Wakeup()
    {
        Console.WriteLine("既而儿醒,大啼");
    }
}

public class Dog
{
    private readonly Wife _wife = new Wife();
    private readonly Husband _husband = new Husband();
    private readonly Son _son = new Son();
    public void Bark()
    {
        Console.WriteLine("遥闻深巷中犬吠");

        _wife.Wakeup();
        _husband.DreamTalk();
        _son.Wakeup();
    }
}

功能实现了,调用很简单,就不上代码了,从Dog类中可以看出,确实是狗叫触发了后续的一系列事件。但是,有一定经验的人一定很快就会发现,这里至少违反了开闭原则和迪米特原则,最终会导致扩展维护起来比较麻烦。因此,需要改进,而改进的方法也不难想到,无非就是抽象出一个基类或接口,让面向实现编程的部分变成面向抽象编程,而真正关键的是抽象什么的问题。难道是抽象一个基类,然后让Wife,Husband,Son继承自该基类吗?他们都是家庭成员,看似好像可行,但它们并没有公共的实现,而且如果后续再加入猫,老鼠或者其它什么的呢?就会变得更加风马牛不相及。面对这种未知的变化,显然很难抽象出一个公共的基类,而针对“观察事件发生”这个行为抽象出接口或许更合适。

演进一-简易观察者模式

根据这个思路,下面看看改进后的实现,先定义一个公共的接口:

public interface IObserver
{
    void Update();
}

这里定义了一个跟任何子类都无关的void Update()方法,这也是没办法的办法,因为我们不可能直接对Wakeup()或者DreamTalk()方法进行抽象,只能通过这种方式规范一个公共的行为接口,意思是当被观察的事件发生时,更新具体实例的某些状态。而具体实现类就简单了:

public class Wife : IObserver
{
    public void Update()
    {
        Wakeup();
    }

    public void Wakeup()
    {
        Console.WriteLine("便有妇人惊觉欠伸");
    }
}

public class Husband: IObserver
{
    public void DreamTalk()
    {
        Console.WriteLine("其夫呓语");
    }

    public void Update()
    {
        DreamTalk();
    }
}

public class Son : IObserver
{
    public void Update()
    {
        Wakeup();
    }

    public void Wakeup()
    {
        Console.WriteLine("既而儿醒,大啼");
    }
}

这里Update()仅仅相当于做了一次转发,当然,也可以加入自己的逻辑。改变较大的是Dog类,不过也都是前面组合模式,享元模式等中用过的常用手法,如下所示:

public class Dog
{
    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 Bark()
    {
        Console.WriteLine("遥闻深巷中犬吠");
        foreach (var observer in _observers)
        {
            observer.Update();
        }
    }
}

不难理解,由于Wife,Husband,Son都实现了IObserver接口,因此可以通过IList<IObserver>集合进行存储,同时通过AddObserver(IObserver observer)RemoveObserver(IObserver observer)对具体实例进行添加和删除管理。

再看看调用的代码:

static void Main(string[] args)
{
    Dog dog = new Dog();
    Wife wife = new Wife();
    Husband husband = new Husband();
    Son son = new Son();
    dog.AddObserver(wife);
    dog.AddObserver(husband);
    dog.AddObserver(son);
    dog.Bark();
    Console.WriteLine("----------------------");
    dog.RemoveObserver(son);
    dog.Bark();
}

其实,这就是需求最简单的观察者模式了,其中Dog是被观察者,也就是被观察的主题,而Wife,Husband,Son都是观察者,下面看看它的类图:

从这个类图上,我们可能会发现一个问题,既然观察者实现了一个抽象的接口,那么被观察者理所应当也应该实现一个抽象的接口啊,毕竟面向接口编程嘛!是的,但是该实现接口还是继承抽象类呢?我们暂且搁置,先叠加一个需求看看。

演进二

翻翻课本可以看到,“遥闻深巷中犬吠,便有妇人惊觉欠伸,其夫呓语。既而儿醒,大啼。”,后面还有三个字“夫亦醒。”(后面还有很多,为防止过于复杂,我们就不考虑了),我们再来看看他们之间的关系:

结合上下文可以知道,丈夫是被儿子哭声吵醒的,而不是狗叫。依据这些,我们可以分析出以下三点:

  1. 被观察者有两个,一个是狗,一个是儿子;
  2. 丈夫观察了两件事,一个是狗叫,一个是儿子哭;
  3. 儿子既是观察者,又是被观察者。

感觉一下子复杂了好多,不过好在有了前面的铺垫,实现起来,好像也并不是特别困难,WifeDog没有任何变化,主要需要修改的是HusbandSon,代码如下:

public class Husband : IObserver
{
    public void DreamTalk()
    {
        Console.WriteLine("其夫呓语");
    }

    public void Update()
    {
        DreamTalk();
    }

    public void Wakeup()
    {
        Console.WriteLine("夫亦醒");
    }
}

public class Son : IObserver
{
    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 Update()
    {
        Wakeup();
    }

    public void Wakeup()
    {
        Console.WriteLine("既而儿醒,大啼");
        foreach (var observer in _observers)
        {
            observer.Update();
        }
    }
}

可以看到,Husband多了一个Wakeup()方法,Son同时实现了观察者和被观察者的逻辑。

当然,调用的地方也有了一些变化,毕竟Son的地位不同了,代码如下:

static void Main(string[] args)
{
    Dog dog = new Dog();
    Wife wife = new Wife();
    Husband husband = new Husband();
    Son son = new Son();
    dog.AddObserver(wife);
    dog.AddObserver(husband);
    dog.AddObserver(son);
    son.AddObserver(husband);
    dog.Bark();
}

看到这里,细心的人会发现这段代码存在着很多问题,至少有以下两点:

  1. DogSon中存在着大量重复的代码;
  2. 运行一下会发现Husband的功能没有实现,因为Husband中没有标识事件的类型或来源,因此也就不知道是该说梦话还是该醒过来。

演进三-标准观察者模式

为了解决上述两个问题,我们需要再做一次改进,首先第一个代码重复的问题,很明显提取一个共同的基类就可以解决,而第二个问题必须通过传参来加以区分了,我们可以先定义一个携带事件参数的类,事件参数通常至少包含事件来源以及事件类型(当然也可以包含其它的属性),代码如下:

public class EventData
{
    public object Source { get; set; }

    public string EventType { get; set; }
}

改造的观察者接口和提取的被观察者基类如下:

public interface IObserver
{
    void Update(EventData eventData);
}

public class Subject
{
    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);
        }
    }
}

可以看到,观察者IObserver中加入了事件参数,被观察者Subject既没有使用接口,也没有使用抽象类,原则上,这样是不合适的,但是,这个类中实在是没有抽象方法,也不适合用抽象类,所有只能勉强使用普通类了。

其它代码如下:

public class Wife : IObserver
{
    public void Update(EventData eventData)
    {
        if (eventData.EventType == "DogBark")
        {
            Wakeup();
        }
    }

    public void Wakeup()
    {
        Console.WriteLine("便有妇人惊觉欠伸");
    }
}

public class Husband : IObserver
{
    public void DreamTalk()
    {
        Console.WriteLine("其夫呓语");
    }

    public void Update(EventData eventData)
    {
        if (eventData.EventType == "DogBark")
        {
            DreamTalk();
        }
        else if (eventData.EventType == "SonCry")
        {
            Wakeup();
        }
    }

    public void Wakeup()
    {
        Console.WriteLine("夫亦醒");
    }
}

public class Son : Subject, IObserver
{
    public void Update(EventData eventData)
    {
        if (eventData.EventType == "DogBark")
        {
            Wakeup();
        }
    }

    public void Wakeup()
    {
        Console.WriteLine("既而儿醒,大啼");
        Publish(new EventData { Source = this, EventType = "SonCry" });
    }
}

public class Dog : Subject
{
    public void Bark()
    {
        Console.WriteLine("遥闻深巷中犬吠");

        Publish(new EventData { Source = this, EventType = "DogBark" });
    }
}

可以看到,被观察者通过Publish(EventData eventData)方法将事件发出,而观察者通过参数中的事件类型来决定接下来该执行什么动作,下面是它的类图:

这其实就是GOF定义的观察者模式了。

定义

多个对象间存在一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

UML类图

将上述实例的类图简化一下,就可以得到如下观察者模式的类图了:

  • Subject:抽象主题角色,它是一个抽象类(而实际上我用的是普通类),提供了一个用于保存观察者对象的集合和增加、删除以及通知所有观察者的方法。
  • ConcreteSubject:具体主题角色。
  • IObserver:抽象观察者角色,它是一个接口,提供了一个更新自己的方法,当接到具体主题的更改通知时被调用。
  • Concrete Observer:具体观察者角色,实现抽象观察者中定义的接口,以便在得到主题的更改通知时更新自身的状态。

优缺点

优点

  1. 降低了主题与观察者之间的耦合关系;
  2. 主题与观察者之间建立了一套触发机制。

缺点

  1. 主题与观察者之间的依赖关系并没有完全解除,而且有可能出现循环引用;
  2. 当观察者对象很多时,事件通知会花费很多时间,影响程序的效率。

当然,这里的缺点指的是观察者模式的缺点,上述实例的缺点其实会更多,我们后续再想办法解决。

通知模式

其实观察者模式中,事件的通知无外乎两种模式-推模式拉模式,这里简单的解释一下。我们上述的实现使用的都是推模式,也就是由主题主动将事件消息推送给观察者,好处就是实时高效,这也是较为推荐的一种方式。

但是并非所有场景都适合使用推模式,例如,某主题有非常多的观察者,但是每个观察者都只关注主题的某个或某些状态,这时使用推模式就不太合适了,因为推模式会将主题的所有状态不加区分的推送给所有观察者,对观察者而言,得到的消息就过于臃肿驳杂了。这时就可以采用拉模式了,主题公开所有可以被观察的状态,由观察者主动拉取自己关注的部分。

而拉模式根据不同情况又可以有两种实现。一种方式是由观察者定时检查,并拉取数据,这种操作简单粗暴,但是,会给主题造成较大的性能负担,同时,也会因为检查频率的不同而带来不同程度的延时。而另一种方式还是由主题主动发出通知,不过通知不带任何参数,仅仅是告诉观察者主题有变化了,然后由观察者去拉取自己关注的部分,这正是拉模式中最常采用的一种手段。

总结

好了,GOF定义的观察者模式分析完了,但实际上,观察者模式还远远没有结束,限于篇幅,我们在下一篇中接着分析。不过在这之前,可以提前思考一下下面两个问题:

  1. dog.AddObserver(...)真的合适吗?实际生活中,狗真的有这种能力吗?
  2. 我们知道C#中不支持多继承,如果Dog本身继承自Animal的基类,如果同时作为被观察者,除了用上述演进一的实现,还能如何实现?因为这种场景太常见了。

想清楚这两个问题,观察者模式才可能真正的展现出它的威力。

源码链接

posted @ 2021-05-16 08:14  老朱独立开发  阅读(273)  评论(0编辑  收藏  举报