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



作者:小傅哥
博客: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
  • 從測試結果上看滿足😌我們的預期,雖然結果是一樣的,但只有我們知道了設計模式的魅力所在。

七、總結

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

八、推薦閱讀


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM