重學 Java 設計模式:實戰觀察者模式「模擬類似小客車指標搖號過程,監聽訊息通知使用者中籤場景」

小傅哥發表於2020-07-01


作者:小傅哥
部落格:https://bugstack.cn - 原創系列專題文章

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

知道的越多不知道的就越多

程式設計開發這條路上的知識是無窮無盡的,就像以前你敢說精通Java,到後來學到越來越多隻想寫了解Java,過了幾年現在可能想說懂一點點Java。當視野和格局的擴大,會讓我們越來越發現原來的看法是多麼淺顯,這就像站在地球看地球和站在宇宙看地球一樣。但正因為胸懷和眼界的提升讓我們有了更多的認識,也逐漸學會了更多的技能。雖然不知道的越來越多,但也因此給自己填充了更多的技術棧,讓自己越來越強大。

拒絕學習的惰性很可怕

現在與以前不一樣,資料多、途徑廣,在這中間夾雜的廣告也非常多。這就讓很多初學者很難找到自己要的知識,最後看到有人推薦相關學習資料立刻遮蔽、刪除,但同時技術優秀的資料也不能讓需要的人看見了。久而久之把更多的時間精力都放在遊戲、娛樂、影音上,適當的放鬆是可以的,但往往沉迷以後就很難出來,因此需要做好一些可以讓自己成長的計劃,稍有剋制。

平衡好軟體設計和實現成本的度°

有時候一個軟體的架構設計需要符合當前條件下的各項因素,往往不能因為心中想當然的有某個藍圖,就去開始執行。也許雖然你的設計是非常優秀的,但是放在當前環境下很難滿足業務的時間要求,當一個業務的基本訴求不能滿足後,就很難拉動市場。沒有產品的DAU支撐,最後整個研發的專案也會因此停滯。但研發又不能一團亂麻的寫程式碼,因此需要找好一個適合的度,比如可以搭建良好的地基,實現上可擴充套件。但在具體的功能上可以先簡化實現,隨著活下來了再繼續完善迭代。

二、開發環境

  1. JDK 1.8
  2. Idea + Maven
  3. 涉及工程三個,可以通過關注公眾號bugstack蟲洞棧,回覆原始碼下載獲取(開啟獲取的連結,找到序號18)
工程 描述
itstack-demo-design-18-00 場景模擬工程;模擬一個小客車搖號介面
itstack-demo-design-18-01 使用一坨程式碼實現業務需求
itstack-demo-design-18-02 通過設計模式優化改造程式碼,產生對比性從而學習

三、觀察者模式介紹

觀察者模式,圖片來自 refactoringguru.cn

簡單來講觀察者?模式,就是當一個行為發生時傳遞資訊給另外一個使用者接收做出相應的處理,兩者之間沒有直接的耦合關聯。例如;狙擊手、李雲龍。

李雲龍給你豎大拇指

除了生活中的場景外,在我們程式設計開發中也會常用到一些觀察者的模式或者元件,例如我們經常使用的MQ服務,雖然MQ服務是有一個通知中心並不是每一個類服務進行通知,但整體上也可以算作是觀察者模式的思路設計。再比如可能有做過的一些類似事件監聽匯流排,讓主線服務與其他輔線業務服務分離,為了使系統降低耦合和增強擴充套件性,也會使用觀察者模式進行處理。

四、案例場景模擬

場景模擬;小客車指標搖號通知場景

在本案例中我們模擬每次小客車指標搖號事件通知場景(真實的不會由官網給你發訊息)

可能大部分人看到這個案例一定會想到自己每次搖號都不中的場景,收到一個遺憾的簡訊通知。當然目前的搖號系統並不會給你發簡訊,而是由百度或者一些其他外掛發的簡訊。那麼假如這個類似的搖號功能如果由你來開發,並且需要對外部的使用者做一些事件通知以及需要在主流程外再新增一些額外的輔助流程時該如何處理呢?

基本很多人對於這樣的通知事件類的實現往往比較粗獷,直接在類裡面就新增了。1是考慮?這可能不會怎麼擴充套件,2是壓根就沒考慮?過。但如果你有仔細思考過你的核心類功能會發現,這裡面有一些核心主鏈路,還有一部分是輔助功能。比如完成了某個行為後需要觸發MQ給外部,以及做一些訊息PUSH給使用者等,這些都不算做是核心流程鏈路,是可以通過事件通知的方式進行處理。

那麼接下來我們就使用這樣的設計模式來優化重構此場景下的程式碼。

1. 場景模擬工程

itstack-demo-design-18-00
└── src
    └── main
        └── java
            └── org.itstack.demo.design
                └── MinibusTargetService.java
  • 這裡提供的是一個模擬小客車搖號的服務介面。

2. 場景簡述

2.1 搖號服務介面

public class MinibusTargetService {

    /**
     * 模擬搖號,但不是搖號演算法
     *
     * @param uId 使用者編號
     * @return 結果
     */
    public String lottery(String uId) {
        return Math.abs(uId.hashCode()) % 2 == 0 ? "恭喜你,編碼".concat(uId).concat("在本次搖號中籤") : "很遺憾,編碼".concat(uId).concat("在本次搖號未中籤或搖號資格已過期");
    }

}
  • 非常簡單的一個模擬搖號介面,與真實公平的搖號是有差別的。

五、用一坨坨程式碼實現

這裡我們先使用最粗暴的方式來實現功能

按照需求需要在原有的搖號介面中新增MQ訊息傳送以及短訊息通知功能,如果是最直接的方式那麼可以直接在方法中補充功能即可。

1. 工程結構

itstack-demo-design-18-01
└── src
    └── main
        └── java
            └── org.itstack.demo.design
                ├── LotteryResult.java
                ├── LotteryService.java
                └── LotteryServiceImpl.java
  • 這段程式碼介面中包括了三部分內容;返回物件(LotteryResult)、定義介面(LotteryService)、具體實現(LotteryServiceImpl)。

2. 程式碼實現

public class LotteryServiceImpl implements LotteryService {

    private Logger logger = LoggerFactory.getLogger(LotteryServiceImpl.class);

    private MinibusTargetService minibusTargetService = new MinibusTargetService();

    public LotteryResult doDraw(String uId) {
        // 搖號
        String lottery = minibusTargetService.lottery(uId);
        // 發簡訊
        logger.info("給使用者 {} 傳送簡訊通知(簡訊):{}", uId, lottery);
        // 發MQ訊息
        logger.info("記錄使用者 {} 搖號結果(MQ):{}", uId, lottery);
        // 結果
        return new LotteryResult(uId, lottery, new Date());
    }

}
  • 從以上的方法實現中可以看到,整體過程包括三部分;搖號、發簡訊、發MQ訊息,而這部分都是順序呼叫的。
  • 除了搖號介面呼叫外,後面的兩部分都是非核心主鏈路功能,而且會隨著後續的業務需求發展而不斷的調整和擴充,在這樣的開發方式下就非常不利於維護。

3. 測試驗證

3.1 編寫測試類

@Test
public void test() {
    LotteryService lotteryService = new LotteryServiceImpl();
    LotteryResult result = lotteryService.doDraw("2765789109876");
    logger.info("測試結果:{}", JSON.toJSONString(result));
}
  • 測試過程中提供對搖號服務介面的呼叫。

3.2 測試結果

22:02:24.520 [main] INFO  o.i.demo.design.LotteryServiceImpl - 給使用者 2765789109876 傳送簡訊通知(簡訊):很遺憾,編碼2765789109876在本次搖號未中籤或搖號資格已過期
22:02:24.523 [main] INFO  o.i.demo.design.LotteryServiceImpl - 記錄使用者 2765789109876 搖號結果(MQ):很遺憾,編碼2765789109876在本次搖號未中籤或搖號資格已過期
22:02:24.606 [main] INFO  org.itstack.demo.design.ApiTest - 測試結果:{"dateTime":1598764144524,"msg":"很遺憾,編碼2765789109876在本次搖號未中籤或搖號資格已過期","uId":"2765789109876"}

Process finished with exit code 0
  • 從測試結果上是符合預期的,也是平常開發程式碼的方式,還是非常簡單的。

六、觀察者模式重構程式碼

接下來使用觀察者模式來進行程式碼優化,也算是一次很小的重構。

1. 工程結構

itstack-demo-design-18-02
└── src
    └── main
        └── java
            └── org.itstack.demo.design
                ├── event
                │    ├── listener
                │    │    ├── EventListener.java
                │    │    ├── MessageEventListener.java
                │    │    └── MQEventListener.java
                │    └── EventManager.java
                ├── LotteryResult.java
                ├── LotteryService.java
                └── LotteryServiceImpl.java

觀察者模式模型結構

觀察者模式模型結構

  • 從上圖可以分為三大塊看;事件監聽事件處理具體的業務流程,另外在業務流程中 LotteryService 定義的是抽象類,因為這樣可以通過抽象類將事件功能遮蔽,外部業務流程開發者不需要知道具體的通知操作。
  • 右下角圓圈圖表示的是核心流程與非核心流程的結構,一般在開發中會把主線流程開發完成後,再使用通知的方式處理輔助流程。他們可以是非同步的,在MQ以及定時任務的處理下,保證最終一致性。

2. 程式碼實現

2.1 事件監聽介面定義

public interface EventListener {

    void doEvent(LotteryResult result);

}
  • 介面中定義了基本的事件類,這裡如果方法的入參資訊型別是變化的可以使用泛型<T>

2.2 兩個監聽事件的實現

短訊息事件

public class MessageEventListener implements EventListener {

    private Logger logger = LoggerFactory.getLogger(MessageEventListener.class);

    @Override
    public void doEvent(LotteryResult result) {
        logger.info("給使用者 {} 傳送簡訊通知(簡訊):{}", result.getuId(), result.getMsg());
    }

}

MQ傳送事件

public class MQEventListener implements EventListener {

    private Logger logger = LoggerFactory.getLogger(MQEventListener.class);

    @Override
    public void doEvent(LotteryResult result) {
        logger.info("記錄使用者 {} 搖號結果(MQ):{}", result.getuId(), result.getMsg());
    }

}
  • 以上是兩個事件的具體實現,相對來說都比較簡單。如果是實際的業務開發那麼會需要呼叫外部介面以及控制異常的處理。
  • 同時我們上面提到事件介面新增泛型,如果有需要那麼在事件的實現中就可以按照不同的型別進行包裝事件內容。

2.3 事件處理類

public class EventManager {

    Map<Enum<EventType>, List<EventListener>> listeners = new HashMap<>();

    public EventManager(Enum<EventType>... operations) {
        for (Enum<EventType> operation : operations) {
            this.listeners.put(operation, new ArrayList<>());
        }
    }

    public enum EventType {
        MQ, Message
    }

    /**
     * 訂閱
     * @param eventType 事件型別
     * @param listener  監聽
     */
    public void subscribe(Enum<EventType> eventType, EventListener listener) {
        List<EventListener> users = listeners.get(eventType);
        users.add(listener);
    }

    /**
     * 取消訂閱
     * @param eventType 事件型別
     * @param listener  監聽
     */
    public void unsubscribe(Enum<EventType> eventType, EventListener listener) {
        List<EventListener> users = listeners.get(eventType);
        users.remove(listener);
    }

    /**
     * 通知
     * @param eventType 事件型別
     * @param result    結果
     */
    public void notify(Enum<EventType> eventType, LotteryResult result) {
        List<EventListener> users = listeners.get(eventType);
        for (EventListener listener : users) {
            listener.doEvent(result);
        }
    }

}
  • 整個處理的實現上提供了三個主要方法;訂閱(subscribe)、取消訂閱(unsubscribe)、通知(notify)。這三個方法分別用於對監聽時間的新增和使用。
  • 另外因為事件有不同的型別,這裡使用了列舉的方式進行處理,也方便讓外部在規定下使用事件,而不至於亂傳資訊(EventType.MQEventType.Message)。

2.4 業務抽象類介面

public abstract class LotteryService {

    private EventManager eventManager;

    public LotteryService() {
        eventManager = new EventManager(EventManager.EventType.MQ, EventManager.EventType.Message);
        eventManager.subscribe(EventManager.EventType.MQ, new MQEventListener());
        eventManager.subscribe(EventManager.EventType.Message, new MessageEventListener());
    }

    public LotteryResult draw(String uId) {
        LotteryResult lotteryResult = doDraw(uId);
        // 需要什麼通知就給呼叫什麼方法
        eventManager.notify(EventManager.EventType.MQ, lotteryResult);
        eventManager.notify(EventManager.EventType.Message, lotteryResult);
        return lotteryResult;
    }

    protected abstract LotteryResult doDraw(String uId);

}
  • 這種使用抽象類的方式定義實現方法,可以在方法中擴充套件需要的額外呼叫。並提供抽象類abstract LotteryResult doDraw(String uId),讓類的繼承者實現。
  • 同時方法的定義使用的是protected,也就是保證將來外部的呼叫方不會呼叫到此方法,只有呼叫到draw(String uId),才能讓我們完成事件通知。
  • 此種方式的實現就是在抽象類中寫好一個基本的方法,在方法中完成新增邏輯的同時,再增加抽象類的使用。而這個抽象類的定義會有繼承者實現。
  • 另外在建構函式中提供了對事件的定義;eventManager.subscribe(EventManager.EventType.MQ, new MQEventListener())
  • 在使用的時候也是使用列舉的方式進行通知使用,傳了什麼型別EventManager.EventType.MQ,就會執行什麼事件通知,按需新增。

2.5 業務介面實現類

public class LotteryServiceImpl extends LotteryService {

    private MinibusTargetService minibusTargetService = new MinibusTargetService();

    @Override
    protected LotteryResult doDraw(String uId) {
        // 搖號
        String lottery = minibusTargetService.lottery(uId);
        // 結果
        return new LotteryResult(uId, lottery, new Date());
    }

}
  • 現在再看業務流程的實現中可以看到已經非常簡單了,沒有額外的輔助流程,只有核心流程的處理。

3. 測試驗證

3.1 編寫測試類

@Test
public void test() {
    LotteryService lotteryService = new LotteryServiceImpl();
    LotteryResult result = lotteryService.draw("2765789109876");
    logger.info("測試結果:{}", JSON.toJSONString(result));
}
  • 從呼叫上來看幾乎沒有區別,但是這樣的實現方式就可以非常方便的維護程式碼以及擴充套件新的需求。

3.2 測試結果

23:56:07.597 [main] INFO  o.i.d.d.e.listener.MQEventListener - 記錄使用者 2765789109876 搖號結果(MQ):很遺憾,編碼2765789109876在本次搖號未中籤或搖號資格已過期
23:56:07.600 [main] INFO  o.i.d.d.e.l.MessageEventListener - 給使用者 2765789109876 傳送簡訊通知(簡訊):很遺憾,編碼2765789109876在本次搖號未中籤或搖號資格已過期
23:56:07.698 [main] INFO  org.itstack.demo.design.test.ApiTest - 測試結果:{"dateTime":1599737367591,"msg":"很遺憾,編碼2765789109876在本次搖號未中籤或搖號資格已過期","uId":"2765789109876"}

Process finished with exit code 0
  • 從測試結果上看滿足?我們的預期,雖然結果是一樣的,但只有我們知道了設計模式的魅力所在。

七、總結

  • 從我們最基本的過程式開發以及後來使用觀察者模式物件導向開發,可以看到設計模式改造後,拆分出了核心流程與輔助流程的程式碼。一般程式碼中的核心流程不會經常變化。但輔助流程會隨著業務的各種變化而變化,包括;營銷裂變促活等等,因此使用設計模式架設程式碼就顯得非常有必要。
  • 此種設計模式從結構上是滿足開閉原則的,當你需要新增其他的監聽事件或者修改監聽邏輯,是不需要改動事件處理類的。但是可能你不能控制呼叫順序以及需要做一些事件結果的返回繼續操作,所以使用的過程時需要考慮場景的合理性。
  • 任何一種設計模式有時候都不是單獨使用的,需要結合其他模式共同建設。另外設計模式的使用是為了讓程式碼更加易於擴充套件和維護,不能因為新增設計模式而把結構處理更加複雜以及難以維護。這樣的合理使用的經驗需要大量的實際操作練習而來。

八、推薦閱讀

相關文章