如何使用 Java8 實現觀察者模式?(下)

【編者按】本文做者是 BAE 系統公司的軟件工程師 Justin Albano。在本篇文章中,做者經過在 Java8 環境下實現觀察者模式的實例,進一步介紹了什麼是觀察者模式、專業化及其命名規則,供你們參考學習。本文系國內 ITOM 管理平臺 OneAPM 工程師編譯整理。 ##線程安全的實現html

前面章節介紹了在現代Java環境下的實現觀察者模式,雖然簡單但很完整,但這一實現忽略了一個關鍵性問題:線程安全。大多數開放的Java應用都是多線程的,並且觀察者模式也多用於多線程或異步系統。例如,若是外部服務更新其數據庫,那麼應用也會異步地收到消息,而後用觀察者模式通知內部組件更新,而不是內部組件直接註冊監聽外部服務。java

觀察者模式的線程安全主要集中在模式的主體上,由於修改註冊監聽器集合時極可能發生線程衝突,好比,一個線程試圖添加一個新的監聽器,而另外一線程又試圖添加一個新的animal對象,這將觸發對全部註冊監聽器的通知。鑑於前後順序,在已註冊的監聽器收到新增動物的通知前,第一個線程可能已經完成也可能還沒有完成新監聽器的註冊。這是一個經典的線程資源競爭案例,正是這一現象告訴開發者們須要一個機制來保證線程安全。數據庫

這一問題的最簡單的解決方案是:全部訪問或修改註冊監聽器list的操做都須遵循Java的同步機制,好比:設計模式

public synchronized AnimalAddedListener registerAnimalAddedListener (AnimalAddedListener listener) { /*...*/ }
public synchronized void unregisterAnimalAddedListener (AnimalAddedListener listener) { /*...*/ }
public synchronized void notifyAnimalAddedListeners (Animal animal) { /*...*/ }

這樣一來,同一時刻只有一個線程能夠修改或訪問已註冊的監聽器列表,能夠成功地避免資源競爭問題,可是新問題又出現了,這樣的約束太過嚴格(synchronized關鍵字和Java併發模型的更多信息,請參閱官方網頁)。經過方法同步,能夠時刻觀測對監聽器list的併發訪問,註冊和撤銷監聽器對監聽器list而言是寫操做,而通知監聽器訪問監聽器list是隻讀操做。因爲經過通知訪問是讀操做,所以是能夠多個通知操做同時進行的。安全

所以,只要沒有監聽器註冊或撤銷註冊,任意多的併發通知均可以同時執行,而不會引起對註冊的監聽器列表的資源爭奪。固然,其餘狀況下的資源爭奪現象存在已久,爲了解決這一問題,設計了ReadWriteLock用以分開管理讀寫操做的資源鎖定。Zoo類的線程安全ThreadSafeZoo實現代碼以下:服務器

public class ThreadSafeZoo {
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    protected final Lock readLock = readWriteLock.readLock();
    protected final Lock writeLock = readWriteLock.writeLock();
    private List<Animal> animals = new ArrayList<>();
    private List<AnimalAddedListener> listeners = new ArrayList<>();
    public void addAnimal (Animal animal) {
        // Add the animal to the list of animals
        this.animals.add(animal);
        // Notify the list of registered listeners
        this.notifyAnimalAddedListeners(animal);
    }
    public AnimalAddedListener registerAnimalAddedListener (AnimalAddedListener listener) {
        // Lock the list of listeners for writing
        this.writeLock.lock();
        try {
            // Add the listener to the list of registered listeners
            this.listeners.add(listener);
        }
        finally {
            // Unlock the writer lock
            this.writeLock.unlock();
        }
        return listener;
    }
    public void unregisterAnimalAddedListener (AnimalAddedListener listener) {
        // Lock the list of listeners for writing
        this.writeLock.lock();
        try {
            // Remove the listener from the list of the registered listeners
            this.listeners.remove(listener);
        }
        finally {
            // Unlock the writer lock
            this.writeLock.unlock();
        }
    }
    public void notifyAnimalAddedListeners (Animal animal) {
        // Lock the list of listeners for reading
        this.readLock.lock();
        try {
            // Notify each of the listeners in the list of registered listeners
            this.listeners.forEach(listener -> listener.updateAnimalAdded(animal));
        }
        finally {
            // Unlock the reader lock
            this.readLock.unlock();
        }
    }
}

經過這樣部署,Subject的實現能確保線程安全而且多個線程能夠同時發佈通知。但儘管如此,依舊存在兩個不容忽略的資源競爭問題:多線程

  1. 對每一個監聽器的併發訪問。多個線程能夠同時通知監聽器要新增動物了,這意味着一個監聽器可能會同時被多個線程同時調用。併發

  2. 對animal list的併發訪問。多個線程可能會同時向animal list添加對象,若是通知的前後順序存在影響,那就可能致使資源競爭,這就須要一個併發操做處理機制來避免這一問題。若是註冊的監聽器列表在收到通知添加animal2後,又收到通知添加animal1,此時就會產生資源競爭。可是若是animal1和animal2的添加由不一樣的線程執行,也是有可能在animal2前完成對animal1添加操做,具體來講就是線程1在通知監聽器前添加animal1並鎖定模塊,線程2添加animal2並通知監聽器,而後線程1通知監聽器animal1已經添加。雖然在不考慮前後順序時,能夠忽略資源競爭,但問題是真實存在的。框架

##對監聽器的併發訪問異步

併發訪問監聽器能夠經過保證監聽器的線程安全來實現。秉承着類的「責任自負」精神,監聽器有「義務」確保自身的線程安全。例如,對於前面計數的監聽器,多線程的遞增或遞減動物數量可能致使線程安全問題,要避免這一問題,動物數的計算必須是原子操做(原子變量或方法同步),具體解決代碼以下:

public class ThreadSafeCountingAnimalAddedListener implements AnimalAddedListener {
    private static AtomicLong animalsAddedCount = new AtomicLong(0);
    @Override
    public void updateAnimalAdded (Animal animal) {
        // Increment the number of animals
        animalsAddedCount.incrementAndGet();
        // Print the number of animals
        System.out.println("Total animals added: " + animalsAddedCount);
    }
}

方法同步解決方案代碼以下:

public class CountingAnimalAddedListener implements AnimalAddedListener {
    private static int animalsAddedCount = 0;
    @Override
    public synchronized void updateAnimalAdded (Animal animal) {
        // Increment the number of animals
        animalsAddedCount++;
        // Print the number of animals
        System.out.println("Total animals added: " + animalsAddedCount);
    }
}

要強調的是監聽器應該保證自身的線程安全,subject須要理解監聽器的內部邏輯,而不是簡單確保對監聽器的訪問和修改的線程安全。不然,若是多個subject共用同一個監聽器,那每一個subject類都要重寫一遍線程安全的代碼,顯然這樣的代碼不夠簡潔,所以須要在監聽器類內實現線程安全。

##監聽器的有序通知

當要求監聽器有序執行時,讀寫鎖就不能知足需求了,而須要引入一個新的機制,能夠保證notify函數的調用順序和animal添加到zoo的順序一致。有人嘗試過用方法同步來實現,然而根據Oracle文檔中的方法同步介紹,可知方法同步並不提供操做執行的順序管理。它只是保證原子操做,也就是說操做不會被打斷,並不能保證先來先執行(FIFO)的線程順序。ReentrantReadWriteLock能夠實現這樣的執行順序,代碼以下:

public class OrderedThreadSafeZoo {
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(true);
    protected final Lock readLock = readWriteLock.readLock();
    protected final Lock writeLock = readWriteLock.writeLock();
    private List<Animal> animals = new ArrayList<>();
    private List<AnimalAddedListener> listeners = new ArrayList<>();
    public void addAnimal (Animal animal) {
        // Add the animal to the list of animals
        this.animals.add(animal);
        // Notify the list of registered listeners
        this.notifyAnimalAddedListeners(animal);
    }
    public AnimalAddedListener registerAnimalAddedListener (AnimalAddedListener listener) {
        // Lock the list of listeners for writing
        this.writeLock.lock();
        try {
            // Add the listener to the list of registered listeners
            this.listeners.add(listener);
        }
        finally {
            // Unlock the writer lock
            this.writeLock.unlock();
        }
        return listener;
    }
    public void unregisterAnimalAddedListener (AnimalAddedListener listener) {
        // Lock the list of listeners for writing
        this.writeLock.lock();
        try {
            // Remove the listener from the list of the registered listeners
            this.listeners.remove(listener);
        }
        finally {
            // Unlock the writer lock
            this.writeLock.unlock();
        }
    }
    public void notifyAnimalAddedListeners (Animal animal) {
        // Lock the list of listeners for reading
        this.readLock.lock();
        try {
            // Notify each of the listeners in the list of registered listeners
            this.listeners.forEach(listener -> listener.updateAnimalAdded(animal));
        }
        finally {
            // Unlock the reader lock
            this.readLock.unlock();
        }
    }
}

這樣的實現方式,register, unregister和notify函數將按照先進先出(FIFO)的順序得到讀寫鎖權限。例如,線程1註冊一個監聽器,線程2在開始執行註冊操做後試圖通知已註冊的監聽器,線程3在線程2等待只讀鎖的時候也試圖通知已註冊的監聽器,採用fair-ordering方式,線程1先完成註冊操做,而後線程2能夠通知監聽器,最後線程3通知監聽器。這樣保證了action的執行順序和開始順序一致。

若是採用方法同步,雖然線程2先排隊等待佔用資源,線程3仍可能比線程2先得到資源鎖,並且不能保證線程2比線程3先通知監聽器。問題的關鍵所在:fair-ordering方式能夠保證線程按照申請資源的順序執行。讀寫鎖的順序機制很複雜,應參照ReentrantReadWriteLock的官方文檔以確保鎖的邏輯足夠解決問題。

截止目前實現了線程安全,在接下來的章節中將介紹提取主題的邏輯並將其mixin類封裝爲可重複代碼單元的方式優缺點。

##主題邏輯封裝到Mixin類

把上述的觀察者模式設計實現封裝到目標的mixin類中很具吸引力。一般來講,觀察者模式中的觀察者包含已註冊的監聽器的集合;負責註冊新的監聽器的register函數;負責撤銷註冊的unregister函數和負責通知監聽器的notify函數。對於上述的動物園的例子,zoo類除動物列表是問題所需外,其餘全部操做都是爲了實現主題的邏輯。

Mixin類的案例以下所示,須要說明的是爲使代碼更爲簡潔,此處去掉關於線程安全的代碼:

public abstract class ObservableSubjectMixin<ListenerType> {
    private List<ListenerType> listeners = new ArrayList<>();
    public ListenerType registerListener (ListenerType listener) {
        // Add the listener to the list of registered listeners
        this.listeners.add(listener);
        return listener;
    }
    public void unregisterAnimalAddedListener (ListenerType listener) {
        // Remove the listener from the list of the registered listeners
        this.listeners.remove(listener);
    }
    public void notifyListeners (Consumer<? super ListenerType> algorithm) {
        // Execute some function on each of the listeners
        this.listeners.forEach(algorithm);
    }
}

正由於沒有提供正在註冊的監聽器類型的接口信息,不能直接通知某個特定的監聽器,因此正須要保證通知功能的通用性,容許客戶端添加一些功能,如接受泛型參數類型的參數匹配,以適用於每一個監聽器,具體實現代碼以下:

public class ZooUsingMixin extends ObservableSubjectMixin<AnimalAddedListener> {
    private List<Animal> animals = new ArrayList<>();
    public void addAnimal (Animal animal) {
        // Add the animal to the list of animals
        this.animals.add(animal);
        // Notify the list of registered listeners
        this.notifyListeners((listener) -> listener.updateAnimalAdded(animal));
    }
}

Mixin類技術的最大優點是把觀察者模式的Subject封裝到一個可重複調用的類中,而不是在每一個subject類中都重複寫這些邏輯。此外,這一方法使得zoo類的實現更爲簡潔,只須要存儲動物信息,而不用再考慮如何存儲和通知監聽器。

然而,使用mixin類並不是只有優勢。好比,若是要存儲多個類型的監聽器怎麼辦?例如,還須要存儲監聽器類型AnimalRemovedListener。mixin類是抽象類,Java中不能同時繼承多個抽象類,並且mixin類不能改用接口實現,這是由於接口不包含state,而觀察者模式中state須要用來保存已經註冊的監聽器列表。

其中的一個解決方案是建立一個動物增長和減小時都會通知的監聽器類型ZooListener,代碼以下所示:

public interface ZooListener {
    public void onAnimalAdded (Animal animal);
    public void onAnimalRemoved (Animal animal);
}

這樣就可使用該接口實現利用一個監聽器類型對zoo狀態各類變化的監聽了:

public class ZooUsingMixin extends ObservableSubjectMixin<ZooListener> {
    private List<Animal> animals = new ArrayList<>();
    public void addAnimal (Animal animal) {
        // Add the animal to the list of animals
        this.animals.add(animal);
        // Notify the list of registered listeners
        this.notifyListeners((listener) -> listener.onAnimalAdded(animal));
    }
    public void removeAnimal (Animal animal) {
        // Remove the animal from the list of animals
        this.animals.remove(animal);
        // Notify the list of registered listeners
        this.notifyListeners((listener) -> listener.onAnimalRemoved(animal));
    }
}

將多個監聽器類型合併到一個監聽器接口中確實解決了上面提到的問題,但仍舊存在不足之處,接下來的章節會詳細討論。

##Multi-Method監聽器和適配器

在上述方法,監聽器的接口中實現的包含太多函數,接口就過於冗長,例如,Swing MouseListener就包含5個必要的函數。儘管可能只會用到其中一個,可是隻要用到鼠標點擊事件就必需要添加這5個函數,更多多是用空函數體來實現剩下的函數,這無疑會給代碼帶來沒必要要的混亂。

其中一種解決方案是建立適配器(概念來自GoF提出的適配器模式),適配器中以抽象函數的形式實現監聽器接口的操做,供具體監聽器類繼承。這樣一來,具體監聽器類就能夠選擇其須要的函數,對adapter不須要的函數採用默認操做便可。例如上面例子中的ZooListener類,建立ZooAdapter(Adapter的命名規則與監聽器一致,只須要把類名中的Listener改成Adapter便可),代碼以下:

public class ZooAdapter implements ZooListener {
    @Override
    public void onAnimalAdded (Animal animal) {}
    @Override
    public void onAnimalRemoved (Animal animal) {}
}

乍一看,這個適配器類微不足道,然而它所帶來的便利倒是不可小覷的。好比對於下面的具體類,只需選擇對其實現有用的函數便可:

public class NamePrinterZooAdapter extends ZooAdapter {
    @Override
    public void onAnimalAdded (Animal animal) {
        // Print the name of the animal that was added
        System.out.println("Added animal named " + animal.getName());
    }
}

有兩種替代方案一樣能夠實現適配器類的功能:一是使用默認函數;二是把監聽器接口和適配器類合併到一個具體類中。默認函數是Java8新提出的,在接口中容許開發者提供默認(防護)的實現方法。

Java庫的這一更新主要是方便開發者在不改變老版本代碼的狀況下,實現程序擴展,所以應該慎用這個方法。部分開發者屢次使用後,會感受這樣寫的代碼不夠專業,而又有開發者認爲這是Java8的特點,無論怎樣,須要明白這個技術提出的初衷是什麼,再結合具體問題決定是否要用。使用默認函數實現的ZooListener接口代碼以下示:

public interface ZooListener {
    default public void onAnimalAdded (Animal animal) {}
    default public void onAnimalRemoved (Animal animal) {}
}

經過使用默認函數,實現該接口的具體類,無需在接口中實現所有函數,而是選擇性實現所需函數。雖然這是接口膨脹問題一個較爲簡潔的解決方案,開發者在使用時還應多加註意。

第二種方案是簡化觀察者模式,省略了監聽器接口,而是用具體類實現監聽器的功能。好比ZooListener接口就變成了下面這樣:

public class ZooListener {
    public void onAnimalAdded (Animal animal) {}
    public void onAnimalRemoved (Animal animal) {}
}

這一方案簡化了觀察者模式的層次結構,但它並不是適用於全部狀況,由於若是把監聽器接口合併到具體類中,具體監聽器就不能夠實現多個監聽接口了。例如,若是AnimalAddedListener和AnimalRemovedListener接口寫在同一個具體類中,那麼單獨一個具體監聽器就不能夠同時實現這兩個接口了。此外,監聽器接口的意圖比具體類更顯而易見,很顯然前者就是爲其餘類提供接口,但後者就並不是那麼明顯了。

若是沒有合適的文檔說明,開發者並不會知道已經有一個類扮演着接口的角色,實現了其對應的全部函數。此外,類名不包含adapter,由於類並不適配於某一個接口,所以類名並無特別暗示此意圖。綜上所述,特定問題須要選擇特定的方法,並無哪一個方法是萬能的。

在開始下一章前,須要特別提一下,適配器在觀察模式中很常見,尤爲是在老版本的Java代碼中。Swing API正是以適配器爲基礎實現的,正如不少老應用在Java5和Java6中的觀察者模式中所使用的那樣。zoo案例中的監聽器或許並不須要適配器,但須要了解適配器提出的目的以及其應用,由於咱們能夠在現有的代碼中對其進行使用。下面的章節,將會介紹時間複雜的監聽器,該類監聽器可能會執行耗時的運算或進行異步調用,不能當即給出返回值。

##Complex & Blocking監聽器

關於觀察者模式的一個假設是:執行一個函數時,一系列監聽器會被調用,但假定這一過程對調用者而言是徹底透明的。例如,客戶端代碼在Zoo中添加animal時,在返回添加成功以前,並不知道會調用一系列監聽器。若是監聽器的執行須要時間較長(其時間受監聽器的數量、每一個監聽器執行時間影響),那麼客戶端代碼將會感知這一簡單增長動物操做的時間反作用。

本文不能面面俱到的討論這個話題,下面幾條是開發者調用複雜的監聽器時應該注意的事項:

  1. 監聽器啓動新線程。新線程啓動後,在新線程中執行監聽器邏輯的同時,返回監聽器函數的處理結果,並運行其餘監聽器執行。

  2. Subject啓動新線程。與傳統的線性迭代已註冊的監聽器列表不一樣,Subject的notify函數重啓一個新的線程,而後在新線程中迭代監聽器列表。這樣使得notify函數在執行其餘監聽器操做的同時能夠輸出其返回值。須要注意的是須要一個線程安全機制來確保監聽器列表不會進行併發修改。

  3. 隊列化監聽器調用並採用一組線程執行監聽功能。將監聽器操做封裝在一些函數中並隊列化這些函數,而非簡單的迭代調用監聽器列表。這些監聽器存儲到隊列中後,線程就能夠從隊列中彈出單個元素並執行其監聽邏輯。這相似於生產者-消費者問題,notify過程產生可執行函數隊列,而後線程依次從隊列中取出並執行這些函數,函數須要存儲被建立的時間而非執行的時間供監聽器函數調用。例如,監聽器被調用時建立的函數,那麼該函數就須要存儲該時間點,這一功能相似於Java中的以下操做:

public class AnimalAddedFunctor {
    private final AnimalAddedListener listener;
    private final Animal parameter;
    public AnimalAddedFunctor (AnimalAddedListener listener, Animal parameter) {
        this.listener = listener;
        this.parameter = parameter;
    }
    public void execute () {
        // Execute the listener with the parameter provided during creation
        this.listener.updateAnimalAdded(this.parameter);
    }
}

函數建立並保存在隊列中,能夠隨時調用,這樣一來就無需在遍歷監聽器列表時當即執行其對應操做了。一旦每一個激活監聽器的函數都壓入隊列中,「消費者線程」就會給客戶端代碼返回操做權。以後某個時間點「消費者線程」將會執行這些函數,就像在監聽器被notify函數激活時執行同樣。這項技術在其餘語言中被叫做參數綁定,恰好適合上面的例子,技術的實質是保存監聽器的參數,execute()函數再直接調用。若是監聽器接收多個參數,處理方法也相似。

須要注意的是若是要保存監聽器的執行順序,則須要引入綜合排序機制。方案一中,監聽器按照正常的順序激活新線程,這樣能夠確保監聽器按照註冊的順序執行。方案二中,隊列支持排序,其中的函數會按照進入隊列的順序執行。簡單來講就是,開發者須要重視監聽器多線程執行的複雜程度,加以當心處理以確保實現所需的功能。

##結束語

觀察者模式在1994年被寫進書中之前,就已是主流的軟件設計模式了,爲軟件設計中常常出現的問題提供了不少使人滿意的解決方案。Java一直是使用該模式的引領者,在其標準庫中封裝了這一模式,可是鑑於Java更新到了版本8,十分有必要從新考查經典模式在其中的使用。隨着lambda表達式和其餘新結構的出現,這一「古老的」模式又有了新的生機。不管是處理舊程序仍是使用這一歷史悠久的方法解決新問題,尤爲對經驗豐富的Java開發者來講,觀察者模式都是開發者的主要工具。

(編譯自:https://dzone.com/articles/the-observer-pattern-using-modern-java)

OneAPM 爲您提供端到端的 Java 應用性能解決方案,咱們支持全部常見的 Java 框架及應用服務器,助您快速發現系統瓶頸,定位異常根本緣由。分鐘級部署,即刻體驗,Java 監控歷來沒有如此簡單。想閱讀更多技術文章,請訪問 OneAPM 官方技術博客 本文轉自 OneAPM 官方博客

相關文章
相關標籤/搜索