quarkus依賴注入之六:發佈和消費事件

歡迎訪問我的GitHub

這裏分類和彙總了欣宸的全部原創(含配套源碼):https://github.com/zq2599/blog_demos

本篇概覽

  • 本文是《quarkus依賴注入》系列的第六篇,主要內容是學習事件的發佈和接收
  • 如果您用過Kafka、RabbitMQ等消息中間件,對消息的作用應該不會陌生,通過消息的訂閱和發佈可以降低系統之間的耦合性,這種方式也可以用在應用內部的多個模塊之間,在quarkus框架下就是事件的發佈和接收
  • 本篇會演示quarkus應用中如何發佈事件、如何接收事件,全文由以下章節構成
  1. 同步事件
  2. 異步事件
  3. 同一種事件類,用在不同的業務場景
  4. 優化
  5. 事件元數據

同步事件

  • 同步事件是指事件發佈後,事件接受者會在同一個線程處理事件,對事件發佈者來說,相當於發佈之後的代碼不會立即執行,要等到事件處理的代碼執行完畢後
  • 同步事件發佈和接受的開發流程如下圖
流程图 (20)
  • 接下來編碼實踐,先定義事件類MyEvent.java,如下所示,該類有兩個字段,source表示來源,consumeNum作爲計數器可以累加
public class MyEvent {
    /**
     * 事件源
     */
    private String source;

    /**
     * 事件被消費的總次數
     */
    private AtomicInteger consumeNum;

    public MyEvent(String source) {
        this.source = source;
        consumeNum = new AtomicInteger();
    }

    /**
     * 事件被消費次數加一
     * @return
     */
    public int addNum() {
        return consumeNum.incrementAndGet();
    }

    /**
     * 獲取事件被消費次數
     * @return
     */
    public int getNum() {
        return consumeNum.get();
    }

    @Override
    public String toString() {
        return "MyEvent{" +
                "source='" + source + '\'' +
                ", consumeNum=" + getNum() +
                '}';
    }
}

  • 然後是發佈事件類,有幾處要注意的地方稍後會提到
package com.bolingcavalry.event.producer;

import com.bolingcavalry.event.bean.MyEvent;
import io.quarkus.logging.Log;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.event.Event;
import javax.inject.Inject;

@ApplicationScoped
public class MyProducer {

    @Inject
    Event<MyEvent> event;

    /**
     * 發送同步消息
     * @param source 消息源
     * @return 被消費次數
     */
    public int syncProduce(String source) {
        MyEvent myEvent = new MyEvent("syncEvent");
        Log.infov("before sync fire, {0}", myEvent);
        event.fire(myEvent);
        Log.infov("after sync fire, {0}", myEvent);
        return myEvent.getNum();
    }
}
  • 上述代碼有以下幾點要注意:
  1. 注入Event,用於發佈事件,通過泛型指定事件類型是MyEvent
  2. 發佈同步事件很簡單,調用fire即可
  3. 由於是同步事件,會等待事件的消費者將消費的代碼執行完畢後,fire方法纔會返回
  4. 如果消費者增加了myEvent的記數,那麼myEvent.getNum()應該等於計數的調用次數
  • 接下來是消費事件的代碼,如下所示,只要方法的入參是事件類MyEvent,並且用@Observes修飾該入參,即可成爲MyEvent事件的同步消費者,這裏用sleep來模擬執行了一個耗時的業務操作
package com.bolingcavalry.event.consumer;

import com.bolingcavalry.event.bean.MyEvent;
import io.quarkus.logging.Log;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.event.Observes;

@ApplicationScoped
public class MyConsumer {

    /**
     * 消費同步事件
     * @param myEvent
     */
    public void syncConsume(@Observes MyEvent myEvent) {
        Log.infov("receive sync event, {0}", myEvent);

        // 模擬業務執行,耗時100毫秒
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 計數加一
        myEvent.addNum();
    }
}
  • 最後,寫單元測試類驗證功能,在MyProducer的syncProduce方法中,由於是同步事件,MyConsumer.syncConsume方法執行完畢纔會繼續執行event.fire後面的代碼,所以syncProduce的返回值應該等於1
package com.bolingcavalry;

import com.bolingcavalry.event.consumer.MyConsumer;
import com.bolingcavalry.event.producer.MyProducer;
import com.bolingcavalry.service.HelloInstance;
import com.bolingcavalry.service.impl.HelloInstanceA;
import com.bolingcavalry.service.impl.HelloInstanceB;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import javax.enterprise.inject.Instance;
import javax.inject.Inject;

@QuarkusTest
public class EventTest {

    @Inject
    MyProducer myProducer;

    @Inject
    MyConsumer myConsumer;

    @Test
    public void testSync() {
        Assertions.assertEquals(1, myProducer.syncProduce("testSync"));
    }
}
  • 執行單元測試,如下所示,符合預期,事件的發送和消費在同一線程內順序執行,另外請關注日誌的時間戳,可見MyProducer的第二條日誌,是在MyConsumer日誌之後的一百多毫秒,這也證明了順序執行的邏輯

image-20220329082758369

  • 以上就是同步事件的相關代碼,很多場景中,消費事件的操作是比較耗時或者不太重要(例如寫日誌),這時候讓發送事件的線程等待就不合適了,因爲發送事件後可能還有其他重要的事情需要立即去做,這就是接下來的異步事件

異步事件

  • 爲了避免事件消費耗時過長對事件發送的線程造成影響,可以使用異步事件,還是用代碼來說明
  • 發送事件的代碼還是寫在MyPorducer.java,如下,有兩處要注意的地方稍後提到
    public int asyncProduce(String source) {
        MyEvent myEvent = new MyEvent(source);
        Log.infov("before async fire, {0}", myEvent);
        event.fireAsync(myEvent)
             .handleAsync((e, error) -> {
                 if (null!=error) {
                     Log.error("handle error", error);
                 } else {
                     Log.infov("finish handle, {0}", myEvent);
                 }

                 return null;
             });
        Log.infov("after async fire, {0}", myEvent);
        return myEvent.getNum();
    }
  • 上述代碼有以下兩點要注意:
  1. 發送異步事件的API是fireAsync
  2. fireAsync的返回值是CompletionStage,我們可以調用其handleAsync方法,將響應邏輯(對事件消費結果的處理)傳入,這段響應邏輯會在事件消費結束後被執行,上述代碼中的響應邏輯是檢查異常,若有就打印
  • 消費異步事件的代碼寫在MyConsumer,與同步的相比唯一的變化就是修飾入參的註解改成了ObservesAsync
    public void aSyncConsume(@ObservesAsync MyEvent myEvent) {
        Log.infov("receive async event, {0}", myEvent);

        // 模擬業務執行,耗時100毫秒
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 計數加一
        myEvent.addNum();
    }
  • 單元測試代碼,有兩點需要注意,稍後會提到
    @Test
    public void testAsync() throws InterruptedException {
        Assertions.assertEquals(0, myProducer.asyncProduce("testAsync"));
        // 如果不等待的話,主線程結束的時候會中斷正在消費事件的子線程,導致子線程報錯
        Thread.sleep(150);
    }
  • 上述代碼有以下兩點需要注意
  1. 異步事件的時候,發送事件的線程不會等待,所以myEvent實例的計數器在消費線程還沒來得及加一,myProducer.asyncProduce方法就已經執行結束了,返回值是0,所以單元測試的assertEquals位置,期望值應該是0
  2. testAsync方法要等待100毫秒以上才能結束,否則進程會立即結束,導致正在消費事件的子線程被打斷,拋出異常
  • 執行單元測試,控制檯輸出如下圖,測試通過,有三個重要信息稍後會提到

image-20220401083719850

  • 上圖中有三個關鍵信息
  1. 事件發佈前後的兩個日誌是緊緊相連的,這證明發送事件之後不會等待消費,而是立即繼續執行發送線程的代碼
  2. 消費事件的日誌顯示,消費邏輯是在一個新的線程中執行的
  3. 消費結束後的回調代碼中也打印了日誌,顯示這端邏輯又在一個新的線程中執行,此線程與發送事件、消費事件都不在同一線程
  • 以上就是基礎的異步消息發送和接受操作,接下來去看略爲複雜的場景

同一種事件類,用在不同的業務場景

  • 設想這樣一個場景:管理員發送XXX類型的事件,消費者應該是處理管理員事件的方法,普通用戶也發送XXX類型的事件,消費者應該是處理普通用戶事件的方法,簡單的說就是同一個數據結構的事件可能用在不同場景,如下圖
流程图 (21)
  • 從技術上分析,實現上述功能的關鍵點是:消息的消費者要精確過濾掉不該自己消費的消息
  • 此刻,您是否回憶起前面文章中的一個場景:依賴注入時,如何從多個bean中選擇自己所需的那個,這兩個問題何其相似,而依賴注入的選擇問題是用Qualifier註解解決的,今天的消息場景,依舊可以用Qualifier來對消息做精確過濾,接下來編碼實戰
  • 首先定義事件類ChannelEvent.java,管理員和普通用戶的消息數據都用這個類(和前面的MyEvent事件類的代碼一樣)
public class TwoChannelEvent {
    /**
     * 事件源
     */
    private String source;

    /**
     * 事件被消費的總次數
     */
    private AtomicInteger consumeNum;

    public TwoChannelEvent(String source) {
        this.source = source;
        consumeNum = new AtomicInteger();
    }

    /**
     * 事件被消費次數加一
     * @return
     */
    public int addNum() {
        return consumeNum.incrementAndGet();
    }

    /**
     * 獲取事件被消費次數
     * @return
     */
    public int getNum() {
        return consumeNum.get();
    }

    @Override
    public String toString() {
        return "TwoChannelEvent{" +
                "source='" + source + '\'' +
                ", consumeNum=" + getNum() +
                '}';
    }
}
  • 然後就是關鍵點:自定義註解Admin,這是管理員事件的過濾器,要用Qualifier修飾
package com.bolingcavalry.annonation;

import javax.inject.Qualifier;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Qualifier
@Retention(RUNTIME)
@Target({FIELD, PARAMETER})
public @interface Admin {
}
  • 自定義註解Normal,這是普通用戶事件的過濾器,要用Qualifier修飾
@Qualifier
@Retention(RUNTIME)
@Target({FIELD, PARAMETER})
public @interface Normal {
}
  • Admin和Normal先用在發送事件的代碼中,再用在消費事件的代碼中,這樣就完成了匹配,先寫發送代碼,有幾處要注意的地方稍後會提到
@ApplicationScoped
public class TwoChannelWithTwoEvent {

    @Inject
    @Admin
    Event<TwoChannelEvent> adminEvent;

    @Inject
    @Normal
    Event<TwoChannelEvent> normalEvent;

    /**
     * 管理員消息
     * @param source
     * @return
     */
    public int produceAdmin(String source) {
        TwoChannelEvent event = new TwoChannelEvent(source);
        adminEvent.fire(event);
        return event.getNum();
    }

    /**
     * 普通消息
     * @param source
     * @return
     */
    public int produceNormal(String source) {
        TwoChannelEvent event = new TwoChannelEvent(source);
        normalEvent.fire(event);
        return event.getNum();
    }
}
  • 上述代碼有以下兩點需要注意
  1. 注入了兩個Event實例adminEvent和normalEvent,它們的類型一模一樣,但是分別用AdminNormal

註解修飾,相當於爲它們添加了不同的標籤,在消費的時候也可以用這兩個註解來過濾

  1. 發送代碼並無特別之處,用adminEvent.fire發出的事件,在消費的時候不過濾、或者用Admin過濾,這兩種方式都能收到
  • 接下來看消費事件的代碼TwoChannelConsumer.java,有幾處要注意的地方稍後會提到
@ApplicationScoped
public class TwoChannelConsumer {

    /**
     * 消費管理員事件
     * @param event
     */
    public void adminEvent(@Observes @Admin TwoChannelEvent event) {
        Log.infov("receive admin event, {0}", event);
        // 管理員的計數加兩次,方便單元測試驗證
        event.addNum();
        event.addNum();
    }

    /**
     * 消費普通用戶事件
     * @param event
     */
    public void normalEvent(@Observes @Normal TwoChannelEvent event) {
        Log.infov("receive normal event, {0}", event);
        // 計數加一
        event.addNum();
    }

    /**
     * 如果不用註解修飾,所有TwoChannelEvent類型的事件都會在此被消費
     * @param event
     */
    public void allEvent(@Observes TwoChannelEvent event) {
        Log.infov("receive event (no Qualifier), {0}", event);
        // 計數加一
        event.addNum();
    }
}
  • 上述代碼有以下兩處需要注意
  1. 消費事件的方法,除了Observes註解,再帶上Admin,這樣此方法只會消費Admin修飾的Event發出的事件
  2. allEvent只有Observes註解,這就意味着此方法不做過濾,只要是TwoChannelEvent類型的同步事件,它都會消費
  3. 爲了方便後面的驗證,在消費Admin事件時,計數器執行了兩次,而Normal事件只有一次,這樣兩種事件的消費結果就不一樣了
  • 以上就是同一事件類在多個場景被同時使用的代碼了,接下來寫單元測試驗證
@QuarkusTest
public class EventTest {
  
    @Inject
    TwoChannelWithTwoEvent twoChannelWithTwoEvent;

    @Test
    public void testTwoChnnelWithTwoEvent() {
        // 對管理員來說,
        // TwoChannelConsumer.adminEvent消費時計數加2,
        // TwoChannelConsumer.allEvent消費時計數加1,
        // 所以最終計數是3
        Assertions.assertEquals(3, twoChannelWithTwoEvent.produceAdmin("admin"));

        // 對普通人員來說,
        // TwoChannelConsumer.normalEvent消費時計數加1,
        // TwoChannelConsumer.allEvent消費時計數加1,
        // 所以最終計數是2
        Assertions.assertEquals(2, twoChannelWithTwoEvent.produceNormal("normal"));
    }
}
  • 執行單元測試順利通過,如下圖

image-20220403164817905

小優化,不需要注入多個Event實例

  • 剛纔的代碼雖然可以正常工作,但是有一點小瑕疵:爲了發送不同事件,需要注入不同的Event實例,如下圖紅框,如果事件類型越來越多,注入的Event實例豈不是越來越多?
image-20220403170857712
  • quarkus提供了一種緩解上述問題的方式,再寫一個發送事件的類TwoChannelWithSingleEvent.java,代碼中有兩處要注意的地方稍後會提到
/**
 * @author will
 * @email [email protected]
 * @date 2022/4/3 10:16
 * @description 用同一個事件結構體TwoChannelEvent,分別發送不同業務類型的事件
 */
@ApplicationScoped
public class TwoChannelWithSingleEvent {

    @Inject
    Event<TwoChannelEvent> singleEvent;
    
    /**
     * 管理員消息
     * @param source
     * @return
     */
    public int produceAdmin(String source) {
        TwoChannelEvent event = new TwoChannelEvent(source);

        singleEvent.select(new AnnotationLiteral<Admin>() {})
                   .fire(event);

        return event.getNum();
    }

    /**
     * 普通消息
     * @param source
     * @return
     */
    public int produceNormal(String source) {
        TwoChannelEvent event = new TwoChannelEvent(source);

        singleEvent.select(new AnnotationLiteral<Normal>() {})
                .fire(event);

        return event.getNum();
    }
}
  • 上述發送消息的代碼,有以下兩處需要注意
  1. 不論是Admin事件還是Normal事件,都是用singleEvent發送的,如此避免了事件類型越多Event實例越多的情況發生
  2. 執行fire方法發送事件前,先執行select方法,入參是AnnotationLiteral的匿名子類,並且通過泛型指定事件類型,這和前面TwoChannelWithTwoEvent類發送兩種類型消息的效果是一樣的
  • 既然用select方法過濾和前面兩個Event實例的效果一樣,那麼消費事件的類就不改動了
  • 寫個單元測試來驗證效果
@QuarkusTest
public class EventTest {
    @Inject
    TwoChannelWithSingleEvent twoChannelWithSingleEvent;

    @Test
    public void testTwoChnnelWithSingleEvent() {
        // 對管理員來說,
        // TwoChannelConsumer.adminEvent消費時計數加2,
        // TwoChannelConsumer.allEvent消費時計數加1,
        // 所以最終計數是3
        Assertions.assertEquals(3, twoChannelWithSingleEvent.produceAdmin("admin"));

        // 對普通人員來說,
        // TwoChannelConsumer.normalEvent消費時計數加1,
        // TwoChannelConsumer.allEvent消費時計數加1,
        // 所以最終計數是2
        Assertions.assertEquals(2, twoChannelWithSingleEvent.produceNormal("normal"));
    }
}
  • 如下圖所示,單元測試通過,也就說從消費者的視角來看,兩種消息發送方式並無區別

image-20220403183222045

事件元數據

  • 在消費事件時,除了從事件對象中取得業務數據(例如MyEvent的source和consumeNum字段),有時還可能需要用到事件本身的信息,例如類型是Admin還是Normal、Event對象的注入點在哪裏等,這些都算是事件的元數據
  • 爲了演示消費者如何取得事件元數據,將TwoChannelConsumer.java的allEvent方法改成下面的樣子,需要注意的地方稍後會提到
public void allEvent(@Observes TwoChannelEvent event, EventMetadata eventMetadata) {
        Log.infov("receive event (no Qualifier), {0}", event);

        // 打印事件類型
        Log.infov("event type : {0}", eventMetadata.getType());

        // 獲取該事件的所有註解
        Set<Annotation> qualifiers = eventMetadata.getQualifiers();

        // 將事件的所有註解逐個打印
        if (null!=qualifiers) {
            qualifiers.forEach(annotation -> Log.infov("qualify : {0}", annotation));
        }

        // 計數加一
        event.addNum();
}
  • 上述代碼中,以下幾處需要注意
  1. allEvent方法增加一個入參,類型是EventMetadata,bean容器會將事件的元數據設置到此參數
  2. EventMetadata的getType方法能取得事件類型
  3. EventMetadata的getType方法能取得事件的所有修飾註解,包括Admin或者Normal
  • 運行剛纔的單元測試,看修改後的allEvent方法執行會有什麼輸出,如下圖,紅框1打印出事件是TwoChannelEvent實例,紅框2將修飾事件的註解打印出來了,包括髮送時修飾的Admin

image-20220403211044536

  • 至此,事件相關的學習和實戰就完成了,進程內用事件可以有效地解除模塊間的耦合,希望本文能給您一些參考

歡迎關注博客園:程序員欣宸

學習路上,你不孤單,欣宸原創一路相伴...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章