使用「Spring AMQP」開發「Shared-Libaray」踩到「自動裝載」的坑

使用「Spring AMQP」開發「Shared-Libaray」踩到「自動裝載」的坑

更新於 發佈於 閱讀時間約 17 分鐘
翻車現場紀錄,在開發時,因忽略「自動裝載」的特性,導致所開發的「Shared-Library」產生臭蟲。

一、踩坑

說實在話,這次的坑,描述起來有些許複雜,且聽筆者娓娓道來。

最近筆者的工作項目是要開發一個基於「SpringBoot」的「Shared-Library」,說穿了就是開發一套僅限於公司內部使用的「第三方套件」;而其最主要的功能就是簡化「MQ」操作方式,而其作法其實就是對「MQ」的使用步驟進行封裝,讓導入該庫的開發者在開發時能夠透過簡單的「方法呼叫」就能將特定訊息放以「MQ」的方式進行傳遞,而不用去了解公司「MQ」的架構,或是那些繁瑣的連線配置、功能參數設定;附帶一提,筆者所使用的是「Rabbit MQ」。

那為什麼要基於「SpringBoot」呢?

事實上,如果只是簡單地與「Rabbit MQ」進行連線,並執行信息拋收的話,那麼的確使用官方所提供的「3th Library」為基底建置也行,而且還不需要與「SpringBoot」捆綁;不過由於公司目前的策略是以收斂技術棧為主,所以,除了許多現行的專案外,未來的專案也幾乎都會以「Spring Framework」作為開發框架。

但架不住「Spring」有提供一系列與基於「AMQP」協定通信相關的組件,其使用起來相當的容易且方便,詳細資訊請見「Messaging with RabbitMQ」,如果對於英文閱讀比較吃力的朋友,也可以參考其「中文文檔」,總之,就是一個字、兩個字的結論:香、真香。

所以在幾經思索後,便決定使用「SpringBoot」作為這個專案的基礎框架,當然,官方對於以「SpringBoot」構建「Shared-Library」的方式是支持的,詳細請見「Creating a Multi Module Project」。

不過人算不如天算,以為會是輕鬆簡單愉快寫意的開發,終究還是踩坑了。

關鍵就在於,敝專案實作的類型是「Shared-Library」;因爲「Shared-Library」的目的是提供給別的專案引入,這是什麼意思呢?

反過來說,若敝專案實作的類型是「Application」,筆者在開發「MQ」相關功能時,就能以「Spring AMQP」中比較建議的方式來配置「Rabbit MQ」的相關參數,包含伺服器網址、 連線信息、功能參數⋯等,也就是將該些參數的值依規定「Key」標示並配置在「application.yml」中。

但如果敝專案實作的類型是「Shared-Library」,我們就必須要考慮到引入專案,倘若筆者在「application.yml」配置了屬於我們「Library」所需要的,與「MQ」操作的必要參數,那麼,萬一引入專案也要操作「MQ」的話,該怎麼辦?

所以,我們得避開以「application.yml」的方式來配置「Rabbit MQ」相關連線參數,而這也就意味著,我們必須自行建立那些原本藉由「自動裝載」,與配置「Rabbit MQ」相關的「Bean」。

事實上,以「手動建立」取代「自動裝載」來產生「Bean」的這個行為並不困難,但必須要清楚的知道「Spring AMQP」利用「自動裝載」做了什麼,否則,一不小心就可能會翻車。

接著,我們就來復盤事故的經過吧。

範例程式碼如下:

@Configuration
@PropertySource("classpath:shared-lib.properties")
public class SharedLibConfig {

@Value("${lib.rabbitmq.hostname}")
private String hostname;
@Value("${lib.rabbitmq.port}")
private String port;
@Value("${lib.rabbitmq.vhost}")
private String vhost;
@Value("${lib.rabbitmq.username}")
private String username;
@Value("${lib.rabbitmq.password}")
public String password;

@Bean("lib-cf")
CachingConnectionFactory connectionFactory() {
CachingConnectionFactory cf = new CachingConnectionFactory();
cf.setHost(hostname);
cf.setPort(Integer.parseInt(port));
cf.setVirtualHost(vhost);
cf.setUsername(username);
connectionFactory.setPassword(password);
return connectionFactory;
}

@Bean("lib-rt")
RabbitTemplate rabbitTemplate(@Autowired @Qualifier("lib-cf") CachingConnectionFactory connectionFactory) {
return new RabbitTemplate(connectionFactory);
}

@Bean("lib-ra")
RabbitAdmin rabbitAdmin(@Autowired @Qualifier("lib-rt") RabbitTemplate rabbitTemplate) {
return new RabbitAdmin(rabbitTemplate);
}
}

在這個範例中,我們分為兩部份來說明,其一是「參數的餵入」,另一是「Bean」物件的建立。

在「參數的餵入」的部分;一如先前所述,我們應避免以「application.yml」的方式來配置參數;但這個說法其實不夠準確,更準確的說法是:我們應該避免以「Spring AMQP」中,其已定義的「Key」來配置參數;也就是說,即使我們用「application.yml」來配置參數,只要我們不是使用「Spring AMQP」中已經定義的「Key」,也是沒問題的;不過考慮到專案的獨立性,因此,筆者仍偏好使用自行建立的設定檔,也就是範例中的「shared-lib.properties」;也因為是自行建立的檔案,因此,在程式中,筆者使用「@PropertySource」來指向目標,而參數的餵入仍是以藉由「@Value」。

接著是在「Bean」物件的建立,在這部分,筆者會給予「指定名稱」,再搭配「@Qualifier」服用,以避免誤注入非目標「Bean」物件。

完成上述配置後,本以為萬無一失了,直到測試那時的無情翻車。

服務連啟動都無法,因為錯誤訊息包含敏感訊息,就不貼出來了,總之,其噴錯內容大意就是在連線到「Queue」時失敗,帳號密碼錯誤、權限不足等。

二、填坑

翻車總是來得這麼突然,雖然錯愕,但問題也不能不修,於是就追查了一下,然就就發現其原因是,由於引入筆者的「Library」的專案,其本身也有使用到「Rabbit MQ」,所以它就依照「Spring AMQP」的建議,在「application.yml」中配置參數,並產生相關的「Bean」物件;這件事本身沒有問題,但為什麼會噴錯呢?

其問題在於,因為筆者已經在「Library」中產生了「CachingConnectionFactory」,所以根據自動裝載的機制,它檢測到「CachingConnectionFactory」已經存在,它就不會再建立另一個,其源碼如下:

raw-image

關鍵在於「@ConditionalOnMissingBean(ConnectionFactory.class)」。

在查到問題後,我們第一個想到的方法是,在目標專案,也就是引入筆者的「Library」的專案中自行產生一個「CachingConnectionFactory」,為保險起見,我們還加上了「@Primary」,但其實這僅是一個「Workaround」的方式。

而且,僅是建立「CachingConnectionFactory」也不夠,因為在範例中,筆者一共建立了三個與「Rabbit MQ」相關的「Bean」,其分別為「CachingConnectionFactory」、「RabbitTemplate」,以及「RabbitAdmin」,但在它的程式中,它只建立了「CachingConnectionFactory」,此時,若程式要使用「RabbitTemplate」或是「RabbitAdmin」,也會如同先前一樣,程式會拿已經存在的「Bean」,而非是再建立一個,那麼就會依然拿到非目標的「Bean」;關於「Spring AMQP」中自動裝載的源碼請參考「RabbitAutoConfiguration」。

那怎麼辦呢?

其實方案很多,譬如剛才說的在「Library」中替專案建立該些「Bean」物件,在程式啟動時,判斷它是否有配置與「Rabbit MQ」有關的參數,有的話,則替它建立,但筆者私心討厭這個方式。

經過評估,筆者最後採用的方式是「簡單工廠模式」加上「單例模式」的方式來實作,建立一個「SharedLibCachingConnectionFactoryFactory」類別,在這個類別中,我們將「Library」所需的「CachingConnectionFactory」以「單例模式」封裝,其程式碼實作如下:

public class SharedLibCachingConnectionFactoryFactory {

private final SharedLibRabbitConnInfo.SharedLibRabbitConnInfo rabbitConnInfo;

private CachingConnectionFactory cachingConnectionFactory;

public SharedLibCachingConnectionFactoryFactory(SharedLibRabbitConnInfo.SharedLibRabbitConnInfo rabbitConnInfo) {
this.rabbitConnInfo = rabbitConnInfo;
}

public CachingConnectionFactory obtainCachingConnectionFactory() {
return Optional.ofNullable(cachingConnectionFactory).orElseGet(
() -> {
this.cachingConnectionFactory = new CachingConnectionFactory();

this.cachingConnectionFactory.setHost(rabbitConnInfo.hostname());
this.cachingConnectionFactory.setPort(Integer.parseInt(rabbitConnInfo.port()));
this.cachingConnectionFactory.setVirtualHost(rabbitConnInfo.vhost());
this.cachingConnectionFactory.setUsername(rabbitConnInfo.username());
this.cachingConnectionFactory.setPassword(rabbitConnInfo.password());

return this.cachingConnectionFactory;
}
);
}
}

接著,再將「SharedLibCachingConnectionFactoryFactory」交由「Spring」管理,如下:

@Configuration
@PropertySource("classpath:shared-lib.properties")
public class SharedLibConfig {

@Value("${lib.rabbitmq.hostname}")
private String hostname;
@Value("${lib.rabbitmq.port}")
private String port;
@Value("${lib.rabbitmq.vhost}")
private String vhost;
@Value("${lib.rabbitmq.username}")
private String username;
@Value("${lib.rabbitmq.password}")
public String password;

@Bean
SharedLibRabbitConnInfo sharedLibRabbitConnInfo() {
return new SharedLibRabbitConnInfo(this.hostname, this.port, this.vhost, this.username, this.password);
}

@Bean
SharedLibCachingConnectionFactoryFactory sharedLibCachingConnectionFactoryFactory(
SharedLibRabbitConnInfo sharedLibRabbitConnInfo) {
return new SharedLibCachingConnectionFactoryFactory(sharedLibRabbitConnInfo);
}

public record SharedLibRabbitConnInfo(String hostname, String port, String vhost, String username,
String password) {
}
}

如此一來,我們就可以完全避開與「Spring AMQP」的碰撞,也可以保留「Library」所需的「CachingConnectionFactory」,事實上,「Singleton」是必須的,我們不可能每次要連線時都去產生一個「CachingConnectionFactory」,這會拖垮服務的效能。

至於「RabbitTemplate」,以及「RabbitAdmin」,由於這兩個物件的建立並不會像建立「CachingConnectionFactory」一樣的吃資源,加上「RabbitAdmin」的使用其境並不多,因此,最後筆者選擇在使用當下時再以「CachingConnectionFactory」建立。

當然,「RabbitTemplate」與「RabbitAdmin」也可以如「CachingConnectionFactory」的方式來建立「Factory」,這部分就請依各自的使用情境評估了。

三、參考資料


avatar-img
碼猿的亂寫手札
2會員
9內容數
總之,這是一隻程序猿的手札;所以其中內容通常與軟體開發相關,但並不限於。
留言
avatar-img
留言分享你的想法!