歡迎訪問我的GitHub
這裏分類和彙總了欣宸的全部原創(含配套源碼):https://github.com/zq2599/blog_demos
本篇概覽
- 本文是《quarkus依賴注入》系列的第六篇,主要內容是學習事件的發佈和接收
- 如果您用過Kafka、RabbitMQ等消息中間件,對消息的作用應該不會陌生,通過消息的訂閱和發佈可以降低系統之間的耦合性,這種方式也可以用在應用內部的多個模塊之間,在quarkus框架下就是事件的發佈和接收
- 本篇會演示quarkus應用中如何發佈事件、如何接收事件,全文由以下章節構成
- 同步事件
- 異步事件
- 同一種事件類,用在不同的業務場景
- 優化
- 事件元數據
同步事件
- 同步事件是指事件發佈後,事件接受者會在同一個線程處理事件,對事件發佈者來說,相當於發佈之後的代碼不會立即執行,要等到事件處理的代碼執行完畢後
- 同步事件發佈和接受的開發流程如下圖
- 接下來編碼實踐,先定義事件類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();
}
}
- 上述代碼有以下幾點要注意:
- 注入Event,用於發佈事件,通過泛型指定事件類型是MyEvent
- 發佈同步事件很簡單,調用fire即可
- 由於是同步事件,會等待事件的消費者將消費的代碼執行完畢後,fire方法纔會返回
- 如果消費者增加了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日誌之後的一百多毫秒,這也證明了順序執行的邏輯
- 以上就是同步事件的相關代碼,很多場景中,消費事件的操作是比較耗時或者不太重要(例如寫日誌),這時候讓發送事件的線程等待就不合適了,因爲發送事件後可能還有其他重要的事情需要立即去做,這就是接下來的異步事件
異步事件
- 爲了避免事件消費耗時過長對事件發送的線程造成影響,可以使用異步事件,還是用代碼來說明
- 發送事件的代碼還是寫在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();
}
- 上述代碼有以下兩點要注意:
- 發送異步事件的API是fireAsync
- 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);
}
- 上述代碼有以下兩點需要注意
- 異步事件的時候,發送事件的線程不會等待,所以myEvent實例的計數器在消費線程還沒來得及加一,myProducer.asyncProduce方法就已經執行結束了,返回值是0,所以單元測試的assertEquals位置,期望值應該是0
- testAsync方法要等待100毫秒以上才能結束,否則進程會立即結束,導致正在消費事件的子線程被打斷,拋出異常
- 執行單元測試,控制檯輸出如下圖,測試通過,有三個重要信息稍後會提到
- 上圖中有三個關鍵信息
- 事件發佈前後的兩個日誌是緊緊相連的,這證明發送事件之後不會等待消費,而是立即繼續執行發送線程的代碼
- 消費事件的日誌顯示,消費邏輯是在一個新的線程中執行的
- 消費結束後的回調代碼中也打印了日誌,顯示這端邏輯又在一個新的線程中執行,此線程與發送事件、消費事件都不在同一線程
- 以上就是基礎的異步消息發送和接受操作,接下來去看略爲複雜的場景
同一種事件類,用在不同的業務場景
- 設想這樣一個場景:管理員發送XXX類型的事件,消費者應該是處理管理員事件的方法,普通用戶也發送XXX類型的事件,消費者應該是處理普通用戶事件的方法,簡單的說就是同一個數據結構的事件可能用在不同場景,如下圖
- 從技術上分析,實現上述功能的關鍵點是:消息的消費者要精確過濾掉不該自己消費的消息
- 此刻,您是否回憶起前面文章中的一個場景:依賴注入時,如何從多個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();
}
}
- 上述代碼有以下兩點需要注意
- 注入了兩個Event實例adminEvent和normalEvent,它們的類型一模一樣,但是分別用Admin和Normal
註解修飾,相當於爲它們添加了不同的標籤,在消費的時候也可以用這兩個註解來過濾
- 發送代碼並無特別之處,用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();
}
}
- 上述代碼有以下兩處需要注意
- 消費事件的方法,除了Observes註解,再帶上Admin,這樣此方法只會消費Admin修飾的Event發出的事件
- allEvent只有Observes註解,這就意味着此方法不做過濾,只要是TwoChannelEvent類型的同步事件,它都會消費
- 爲了方便後面的驗證,在消費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"));
}
}
- 執行單元測試順利通過,如下圖
小優化,不需要注入多個Event實例
- 剛纔的代碼雖然可以正常工作,但是有一點小瑕疵:爲了發送不同事件,需要注入不同的Event實例,如下圖紅框,如果事件類型越來越多,注入的Event實例豈不是越來越多?
- 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();
}
}
- 上述發送消息的代碼,有以下兩處需要注意
- 不論是Admin事件還是Normal事件,都是用singleEvent發送的,如此避免了事件類型越多Event實例越多的情況發生
- 執行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"));
}
}
- 如下圖所示,單元測試通過,也就說從消費者的視角來看,兩種消息發送方式並無區別
事件元數據
- 在消費事件時,除了從事件對象中取得業務數據(例如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();
}
- 上述代碼中,以下幾處需要注意
- 給allEvent方法增加一個入參,類型是EventMetadata,bean容器會將事件的元數據設置到此參數
- EventMetadata的getType方法能取得事件類型
- EventMetadata的getType方法能取得事件的所有修飾註解,包括Admin或者Normal
- 運行剛纔的單元測試,看修改後的allEvent方法執行會有什麼輸出,如下圖,紅框1打印出事件是TwoChannelEvent實例,紅框2將修飾事件的註解打印出來了,包括髮送時修飾的Admin
- 至此,事件相關的學習和實戰就完成了,進程內用事件可以有效地解除模塊間的耦合,希望本文能給您一些參考