設計模式-觀察者模式上

煮詩君發表於2021-05-16

觀察者模式可以說是非常貼近我們生活的一個設計模式,為什麼這麼說呢?哲學上有這麼一種說法,叫做“萬事萬物皆有聯絡”,原意是說世上沒有孤立存在的事物,但其實也可以理解為任何一個事件的發生必然由某個前置事件引起,也必然會導致另一個後置事件。我們的生活中,充斥著各種各樣的相互聯絡的事件,而觀察者模式,主要就是用於處理這種事件的一套解決方案。

示例

觀察者模式在不同需求下,實現方式也不盡相同,我們還是舉一個例子,然後通過逐步的改進來深刻感受一下它是如何工作的。

在中學階段有一篇課文《口技》,其中有一句“遙聞深巷中犬吠,便有婦人驚覺欠伸,其夫囈語。既而兒醒,大啼。”應該不用翻譯吧?我們接下來就是要通過程式模擬一下這個場景。

先看看他們之間的關係,如下圖所示:

初版實現

一聲狗叫引發了一系列的事件,需求很清晰,也很簡單。於是,我們可以很容易的得到如下實現:

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的基類,如果同時作為被觀察者,除了用上述演進一的實現,還能如何實現?因為這種場景太常見了。

想清楚這兩個問題,觀察者模式才可能真正的展現出它的威力。

原始碼連結

相關文章