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

OneAPM官方技術部落格發表於2016-02-25

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

執行緒安全的實現

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

觀察者模式的執行緒安全主要集中在模式的主體上,因為修改註冊監聽器集合時很可能發生執行緒衝突,比如,一個執行緒試圖新增一個新的監聽器,而另一執行緒又試圖新增一個新的animal物件,這將觸發對所有註冊監聽器的通知。鑑於先後順序,在已註冊的監聽器收到新增動物的通知前,第一個執行緒可能已經完成也可能尚未完成新監聽器的註冊。這是一個經典的執行緒資源競爭案例,正是這一現象告訴開發者們需要一個機制來保證執行緒安全。

這一問題的最簡單的解決方案是:所有訪問或修改註冊監聽器list的操作都須遵循Java的同步機制,比如:

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實現程式碼如下:

java 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已經新增。雖然在不考慮先後順序時,可以忽略資源競爭,但問題是真實存在的。

對監聽器的併發訪問

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

java 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); } }

方法同步解決方案程式碼如下:

java 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可以實現這樣的執行順序,程式碼如下:

java 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類的案例如下所示,需要說明的是為使程式碼更為簡潔,此處去掉關於執行緒安全的程式碼:

java 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); } }

正因為沒有提供正在註冊的監聽器型別的介面資訊,不能直接通知某個特定的監聽器,所以正需要保證通知功能的通用性,允許客戶端新增一些功能,如接受泛型引數型別的引數匹配,以適用於每個監聽器,具體實現程式碼如下:

java 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,程式碼如下所示:

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

這樣就可以使用該介面實現利用一個監聽器型別對zoo狀態各種變化的監聽了:

java 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即可),程式碼如下:

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

乍一看,這個介面卡類微不足道,然而它所帶來的便利卻是不可小覷的。比如對於下面的具體類,只需選擇對其實現有用的函式即可:

java 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介面程式碼如下示:

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

通過使用預設函式,實現該介面的具體類,無需在介面中實現全部函式,而是選擇性實現所需函式。雖然這是介面膨脹問題一個較為簡潔的解決方案,開發者在使用時還應多加註意。

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

java 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中的如下操作:

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 官方部落格

相關文章