翻車現場紀錄,在開發時,因忽略「自動裝載」的特性,導致所開發的「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
」已經存在,它就不會再建立另一個,其源碼如下:

關鍵在於「@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」,這部分就請依各自的使用情境評估了。
三、參考資料
- Spring.IO Guides, Messaging with RabbitMQ
- Spring.IO Guides, Creating a Multi Module Project
- Spring AMQP 中文文檔
- spring-projects, spring-boot/.../RabbitAutoConfiguration.java