Spring解決泛型擦除的思路詳解
你好呀,我是歪歪。
Spring 的事件監(jiān)聽機(jī)制,不知道你有沒有用過,實際開發(fā)過程中用來進(jìn)行代碼解耦簡直不要太爽。
但是我最近碰到了一個涉及到泛型的場景,常規(guī)套路下,在這個場景中使用該機(jī)制看起來會很傻,但是最終了解到 Spring 有一個優(yōu)雅的解決方案,然后去了解了一下,感覺有點意思。
和你一起盤一盤。
Demo
首先,第一步啥也別說,先搞一個 Demo 出來。
需求也很簡單,假設(shè)我們有一個 Person 表,每當(dāng) Person 表新增或者修改一條數(shù)據(jù)的時候,給指定服務(wù)同步一下。
偽代碼非常的簡單:
boolean success = addPerson(person) if(success){ //發(fā)送person,add代表新增 sendToServer(person,"add"); }
這代碼能用,完全沒有任何問題。
但是,你仔細(xì)想,“發(fā)給指定服務(wù)同步一下”這樣的動作按理來說,不應(yīng)該和用戶新增和更新的行為“耦合”在一起,他們應(yīng)該是兩個獨立的邏輯。
所以從優(yōu)雅實現(xiàn)的角度出發(fā),我們可以用 Spring 的事件機(jī)制進(jìn)行解耦。
比如改成這樣:
boolean success = addPerson(person) if(success){ publicAddPersonEvent(person,"add"); }
addPerson 成功之后,直接發(fā)布一個事件出去,然后“發(fā)給指定服務(wù)同步一下”這件事情就可以放在事件監(jiān)聽器去做。
對應(yīng)的代碼也很簡單,新建一個 SpringBoot 工程。
首先我們先搞一個 Person 對象:
@Data public class Person { private String name; public Person(String name) { this.name = name; } }
由于我們還要告知是新增還是修改,所以還需要搞個對象封裝一層:
@Data public class PersonEvent { private Person person; private String addOrUpdate; public PersonEvent(Person person, String addOrUpdate) { this.person = person; this.addOrUpdate = addOrUpdate; } }
然后搞一個事件發(fā)布器:
@Slf4j @RestController public class TestController { @Resource private ApplicationContext applicationContext; @GetMapping("/publishEvent") public void publishEvent() { applicationContext.publishEvent(new PersonEvent(new Person("why"), "add")); } }
最后來一個監(jiān)聽器:
@Slf4j @Component public class EventListenerService { @EventListener public void handlePersonEvent(PersonEvent personEvent) { log.info("監(jiān)聽到PersonEvent: {}", personEvent); } }
Demo 就算是齊活了,你把代碼粘過去,也用不了一分鐘吧。
啟動服務(wù)跑一把:
看起來沒有任何毛病,在監(jiān)聽器里面直接就監(jiān)聽到了。
這個時候假設(shè),我還有一個對象,叫做 Order,每當(dāng) Order 表新增或者修改一條數(shù)據(jù)的時候,也要給指定服務(wù)同步一下。
怎么辦?
這還不簡單?
照葫蘆畫瓢唄。
先來一個 Order 對象:
@Data public class Order { private String orderName; public Order(String orderName) { this.orderName = orderName; } }
再來一個 OrderEvent 封裝一層:
@Data public class OrderEvent { private Order order; private String addOrUpdate; public OrderEvent(Order order, String addOrUpdate) { this.order = order; this.addOrUpdate = addOrUpdate; } }
然后再發(fā)布一個對應(yīng)的事件:
新增一個對應(yīng)的事件監(jiān)聽:
發(fā)起調(diào)用:
完美,兩個事件都監(jiān)聽到了。
那么問題又來了,假設(shè)我還有一個對象,叫做 Account,每當(dāng) Account 表新增或者修改一條數(shù)據(jù)的時候,也要給指定服務(wù)同步一下。
或者說,我有幾十張表,對應(yīng)幾十個對象,都要做類似的同步。
請問閣下又該如何應(yīng)對?
你當(dāng)然可以按照前面處理 Order 的方式,繼續(xù)依葫蘆畫瓢。
但是這樣勢必會來帶的一個問題是對象的膨脹,你想啊,畢竟每一個對象都需要一個對應(yīng)的 xxxxEvent 封裝對象。
這樣的代碼過于冗余,丑,不優(yōu)雅。
怎么辦?
自然而然的我們能想到泛型,畢竟人家干這個事兒是專業(yè)的,放一個通配符,管你多少個對象,通通都是“T”,也就是這樣的:
@Data class BaseEvent<T> { private T data; private String addOrUpdate; public BaseEvent(T data, String addOrUpdate) { this.data = data; this.addOrUpdate = addOrUpdate; } }
對應(yīng)的事件發(fā)布的地方也可以用 BaseEvent 來代替:
這樣用一個 BaseEvent 就能代替無數(shù)的 xxxEvent,做到通用,這是它的好處。
同時對應(yīng)的監(jiān)聽器也需要修改:
啟動服務(wù),跑一把。
發(fā)起調(diào)用之后你會發(fā)現(xiàn)控制臺正常輸出:
但是,注意我要說但是了。
但是監(jiān)聽這一坨代碼我感覺不爽,全部都寫在一個方法里面了,需要用非常多的 if 分支去做判斷。
而且,假設(shè)某些對象在同步之前,還有一些個性化的加工需求,那么都會體現(xiàn)在這一坨代碼中,不夠優(yōu)雅。
怎么辦呢?
很簡單,拆開監(jiān)聽:
但是再次重啟服務(wù),發(fā)起調(diào)用你會發(fā)現(xiàn):控制臺沒有輸出了?怎么回事,怎么監(jiān)聽不到了呢?
官網(wǎng)怎么說?
在 Spring 的官方文檔中,關(guān)于泛型類型的事件通知只有寥寥數(shù)語,但是提到了兩個解決方案:
首先官網(wǎng)給出了這樣的一個泛型對象:EntityCreatedEvent
然后說比如我們要監(jiān)聽 Person 這個對象創(chuàng)建時的事件,那么對應(yīng)的監(jiān)聽器代碼就是這樣的:
@EventListener public void onPersonCreated(EntityCreatedEvent<Person> event) { // ... }
和我們 Demo 里面的代碼結(jié)構(gòu)是一樣的。
那么怎么才能觸發(fā)這個監(jiān)聽呢?
第一種方式是:
class PersonCreatedEvent extends EntityCreatedEvent<Person> { … }).
也就是給這個對象創(chuàng)造一個對應(yīng)的 xxxCreatedEvent,然后去監(jiān)聽這個 xxxCreatedEvent。
和我們前面提到的 xxxxEvent 封裝對象是一回事。
為什么我們必須要這樣做呢?
官網(wǎng)上提到了這幾個詞:
Due to type erasure
type erasure,泛型擦除。
因為泛型擦除,所以導(dǎo)致直接監(jiān)聽 EntityCreatedEvent 事件是不生效的,因為在泛型擦除之后,EntityCreatedEvent 變成了 EntityCreatedEvent<?>。
封裝一個對象繼承泛型對象,通過他們之間一一對應(yīng)的關(guān)系從而繞開泛型擦除這個問題,這個方案確實是可以解決問題。
但是,前面說了,不夠優(yōu)雅。
官網(wǎng)也覺得這個事情很傻:
它怎么說的呢?
In certain circumstances, this may become quite tedious if all events follow the same structure.
在某些情況下,如果所有事件都遵循相同的結(jié)構(gòu),這可能會變得相當(dāng) tedious。
好,那么 tedious,是什么意思?哪個同學(xué)舉手回答一下?
這是個四級詞匯,得認(rèn)識,以后考試的時候要考:
quite tedious,相當(dāng)啰嗦。
我們都不希望自己的程序看起來是 tedious 的。
所以,官方給出了另外一個解決方案:ResolvableTypeProvider。
我也不知道這是在干什么,反正我拿到了代碼樣例,那我們就白嫖一下嘛:
@Data class BaseEvent<T> implements ResolvableTypeProvider { private T data; private String addOrUpdate; public BaseEvent(T data, String addOrUpdate) { this.data = data; this.addOrUpdate = addOrUpdate; } @Override public ResolvableType getResolvableType() { return ResolvableType.forClassWithGenerics(getClass(), ResolvableType.forInstance(getData())); } }
再次啟動服務(wù),你會發(fā)現(xiàn),監(jiān)聽器又好使了:
那么問題又來了。
這是為什么呢?
為什么?
我也不知道為什么,但是我知道源碼之下無秘密。
所以,先打上斷點再說。
關(guān)于 @EventListener 注解的原理和源碼解析,我之前寫過一篇相關(guān)的文章:《扯下@EventListener這個注解的神秘面紗?!?/p>
有興趣的可以看看這篇文章,然后再試著按照文章中的方式去找對應(yīng)的源碼。
我這篇文章就不去抽絲剝繭的一點點找源碼了,直接就是一個大力出奇跡。
因為我們已知是 ResolvableTypeProvider 這個接口在搞事情,所以我只需要看看這個接口在代碼中被使用的地方有哪些:
除去一些注釋和包導(dǎo)入的地方,整個項目中只有 ResolvableType 和 MultipartHttpMessageWriter 這個兩個中用到了。
直覺告訴我,應(yīng)該是在 ResolvableType 用到的地方打斷點,因為另外一個類看起來是 Http 相關(guān)的,和我的 Demo 沒啥關(guān)系。
所以我直接在這里打上斷點,然后發(fā)起調(diào)用,程序果然就停在了斷點處:
org.springframework.core.ResolvableType#forInstance
我們觀察一下,發(fā)現(xiàn)這幾行代碼核心就干一個事兒:判斷 instance 是不是 ResolvableTypeProvider 的子類。
如果是則返回一個 type,如果不是則返回 forClass(instance.getClass())。
通過 Debug 我們發(fā)現(xiàn) instance 是 BaseEvent:
巧了,這就是 ResolvableTypeProvider 的子類,所以返回的 type 是這樣式兒的:
com.example.elasticjobtest.BaseEvent<com.example.elasticjobtest.Person>
是帶具體的類型的,而這個類型就是通過 getResolvableType 方法拿到的。
前面我們在實現(xiàn) ResolvableTypeProvider 的時候,就重寫了 getResolvableType 方法,調(diào)用了 ResolvableType.forClassWithGenerics,然后用 data 對應(yīng)的真正的 T 對象實例的類型,作為返回值,這樣泛型對應(yīng)的真正的對象類型,就在運(yùn)行期被動態(tài)的獲取到了,從而解決了編譯階段泛型擦除的問題。
如果沒有實現(xiàn) ResolvableTypeProvider 接口,那么這個方法返回的就是 BaseEvent<?>:
com.example.elasticjobtest.BaseEvent<?>
看到這里你也就猜到個七七八八了。
都已經(jīng)拿到具體的泛型對象了,后面再發(fā)起對應(yīng)的事件監(jiān)聽,那不是順理成章的事情嗎?
好,現(xiàn)在你在第一個斷點處就收獲到了一個這么關(guān)鍵的信息,接下來怎么辦呢?
接著斷點處往下調(diào)試,然后把整個鏈路都梳理清楚唄。
再往下走,你會來到這個地方:
org.springframework.context.event.AbstractApplicationEventMulticaster#getApplicationListeners
從 cache 里面獲取到了一個 null。
因為這個緩存里面放的就是在項目啟動過程中已經(jīng)觸發(fā)過的框架自帶的 listener 對象:
調(diào)用的時候,如果能從緩存中拿到對應(yīng)的 listener,則直接返回。而我們 Demo 中的自定義 listener 是第一次觸發(fā),所以肯定是沒有的。
因此關(guān)鍵邏輯就這個方法的最后一行:retrieveApplicationListeners 方法里面
org.springframework.context.event.AbstractApplicationEventMulticaster#retrieveApplicationListeners
這個地方再往下寫,就是我前面我提到的這篇文章中我寫過的內(nèi)容了《扯下@EventListener這個注解的神秘面紗。》。
和泛型擦除的關(guān)系已經(jīng)不大了,我就不再寫一次了。
只是給大家看一下這個方法在我們的 Demo 中,最終返回的 allListeners 就是我們自定義的這個事件監(jiān)聽器:
com.example.elasticjobtest.EventListenerService#handlePersonEvent
為什么是這個?
因為我當(dāng)前發(fā)布的事件的主角就是 Person 對象:
同理,當(dāng) Order 對象的事件過來的時候,這里肯定就是對應(yīng)的 handleOrderEvent 方法:
如果我們把 BaseEvent 的 ResolvableTypeProvider 接口拿掉,那么你再看對應(yīng)的 allListeners,你就會發(fā)現(xiàn)找不到我們對應(yīng)的自定義 Listener 了:
為什么?
因為當(dāng)前事件對應(yīng)的 ResolvableType 是這樣的:
org.springframework.context.PayloadApplicationEvent<com.example.elasticjobtest.BaseEvent<?>>
而我們并沒有自定義一個這樣的 Listener:
@EventListener public void handleAllEvent(BaseEvent<?> orderEvent) { log.info("監(jiān)聽到Event: {}", orderEvent); }
所以,這個事件發(fā)布了,但是沒有對應(yīng)的消費(fèi)。
大概就是這么個意思。
核心邏輯就在 ResolvableTypeProvider 接口里面,重寫了 getResolvableType 方法,在運(yùn)行期動態(tài)的獲取泛型對應(yīng)的真正的對象類型,從而解決了編譯階段泛型擦除的問題。
很好,現(xiàn)在摸清楚了,是個很簡單的思路,之前是 Spring 的,現(xiàn)在它是我的了。
為什么需要發(fā)布訂閱模式 ?
既然寫到 Spring 的事件通知機(jī)制了,那么就順便聊聊這個發(fā)布訂閱模式。
也許在看的過程中,你會冒出這樣一個問題:為什么要搞這么麻煩?把這些事件監(jiān)聽的業(yè)務(wù)邏輯直接寫在對應(yīng)的數(shù)據(jù)庫操作語句之后不行么?
要回答這個問題,我們可以先總結(jié)一下事件通知機(jī)制的使用場景。
數(shù)據(jù)變化之后同步清除緩存,這是一種簡單可靠的緩存更新方式。只有在清除失敗,或者數(shù)據(jù)庫主從同步間隙被臟讀才有可能出現(xiàn)緩存臟數(shù)據(jù),概率比較小,一般業(yè)務(wù)上也是可以接受的。
通過某種方式告訴下游系統(tǒng)數(shù)據(jù)變化,比如往消息隊列里面扔消息。
數(shù)據(jù)的統(tǒng)計、監(jiān)控、異步觸發(fā)等場景。當(dāng)然這動作似乎用 AOP 也可以做,但是實際上在某些業(yè)務(wù)場景下,做切面統(tǒng)計,反而沒有通過發(fā)布訂閱機(jī)制來得直接,靈活度也更好。
除了上面這些外,肯定還有一些其他的場景,但是這些場景都有一個共同點:與核心業(yè)務(wù)關(guān)系不大,但是又具備一定的普適性。
比如完成用戶注冊之后給用戶發(fā)一個短信,或者發(fā)個郵件啥的。這個事情用發(fā)布訂閱機(jī)制來做是再合適不過的了。
編碼過程中牢記單一職責(zé)原則,要知道一個類該干什么不該干什么,這是面向?qū)ο缶幊?的關(guān)鍵點之一。
當(dāng)你一個類中注入了大量的 Service 的時候,你就要考慮考慮,是不是有什么做的不合適的地方了,是不是有些 Service 其實不應(yīng)該注入進(jìn)來的。
是不是該用用發(fā)布訂閱了?
另外,當(dāng)你的項目中真的出現(xiàn)了文章最開始說的,各種各樣的 xxxEvent 事件對應(yīng)的封裝的時候,任何一個來開發(fā)的人都覺得這樣寫是不是有點冗余的時候,你就應(yīng)該考慮一下是不是有更加優(yōu)雅的解決方案。
假設(shè)這個方案由于某些原因不能使用或者不敢使用是一回事。
但是知不知道這個方案,是另一回事。
到此這篇關(guān)于Spring解決泛型擦除的思路不錯,現(xiàn)在它是我的了。的文章就介紹到這了,更多相關(guān)Spring泛型擦除內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
MyBatisPlus中@TableField注解的基本使用
這篇文章主要介紹了MyBatisPlus中@TableField注解的基本使用,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-07-07老生常談spring boot 1.5.4 日志管理(必看篇)
下面小編就為大家?guī)硪黄仙U剆pring boot 1.5.4 日志管理(必看篇)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-06-06解決idea2024版本創(chuàng)建項目時沒有java?8的版本選擇
這篇文章主要介紹了在使用IntelliJ?IDEA創(chuàng)建Spring?Boot項目時遇到的問題,包括Java版本選擇受限和項目結(jié)構(gòu)不完整,文中通過圖文介紹的非常詳細(xì),需要的朋友可以參考下2025-03-03Springboot Logback日志多文件輸出方式(按日期和大小分割)
這篇文章主要介紹了Springboot Logback日志多文件輸出方式(按日期和大小分割),具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-05-05