罗美琪和春波特的故事...
作者 | 遼天
來源 | 阿里巴巴云原生公眾號
**導讀:**rocketmq-spring 經過 6 個多月的孵化,作為 Apache RocketMQ 的子項目正式畢業,發布了第一個 Release 版本 2.0.1。這個項目是把 RocketMQ 的客戶端使用 Spring Boot 的方式進行了封裝,可以讓用戶通過簡單的 annotation 和標準的 Spring Messaging API 編寫代碼來進行消息的發送和消費。
在項目發布階段我們很榮幸的邀請了 Spring 社區的原創人員對我們的代碼進行了 Review,通過幾輪 slack 上的深入交流感受到了 Spring 團隊對開源代碼質量的標準,對 SpringBoot 項目細節的要求。本文是對 Review 和代碼改進過程中的經驗和技巧的總結,希望從事 Spring Boot 開發的同學有幫助。我們把這個過程整理成 RocketMQ 社區的貢獻者羅美琪和 Spring 社區的春波特(SpringBoot)的故事。
故事的開始
故事的開始是這樣的,羅美琪美眉有一套 RocketMQ 的客戶端代碼,負責發送消息和消費消息。早早的聽說春波特小哥哥的大名,通過 Spring Boot 可以把自己客戶端調用變得非常簡單,只使用一些簡單的注解(annotation)和代碼就可以使用獨立應用的方式啟動,省去了復雜的代碼編寫和參數配置。
聰明的她參考了業界已經實現的消息組件的 Spring 實現了一個 RocketMQ Spring 客戶端:
- 需要一個消息的發送客戶端,它是一個自動創建的 Spring Bean,并且相關屬性要能夠根據配置文件的配置自動設置, 命名它為:RocketMQTemplate, 同時讓它封裝發送消息的各種同步和異步的方法。
- 需要消息的接收客戶端,它是一個能夠被應用回調的 Listener, 來將消費消息回調給用戶進行相關的處理。
特別說明一下:這個消費客戶端 Listener 需要通過一個自定義的注解@RocketMQMessageListener 來標注,這個注解的作用有兩個:
- 定義消息消費的配置參數(如: 消費的 topic, 是否順序消費,消費組等)。
- 可以讓 spring-boot 在啟動過程中發現標注了這個注解的所有 Listener, 并進行初始化,詳見 ListenerContainerConfiguration 類及其實現 SmartInitializingSingleton 的接口方法 afterSingletonsInstantiated()。
通過研究發現,Spring-Boot 最核心的實現是自動化配置(auto configuration),它需要分為三個部分:
- AutoConfiguration 類,它由 @Configuration 標注,用來創建 RocketMQ 客戶端所需要的 SpringBean,如上面所提到的 RocketMQTemplate 和能夠處理消費回調 Listener 的容器,每個 Listener 對應一個容器 SpringBean 來啟動 MQPushConsumer,并將來將監聽到的消費消息并推送給 Listener 進行回調。可參考 RocketMQAutoConfiguration.java ?(編者注: 這個是最終發布的類,沒有 review 的痕跡啦)。
- 上面定義的 Configuration 類,它本身并不會“自動”配置,需要由 META-INF/spring.factories 來聲明,可參考 spring.factories 使用這個 META 配置的好處是上層用戶不需要關心自動配置類的細節和開關,只要 classpath 中有這個 META-INF 文件和 Configuration 類,即可自動配置。
- 另外,上面定義的 Configuration 類,還定義了 @EnableConfiguraitonProperties 注解來引入 ConfigurationProperties 類,它的作用是定義自動配置的屬性,可參考 RocketMQProperties.java,上層用戶可以根據這個類里定義的屬性來配置相關的屬性文件(即 META-INF/application.properties 或 META-INF/application.yaml)。
故事的發展
羅美琪美眉按照這個思路開發完成了 RocketMQ SpringBoot 封裝并形成了 starter 交給社區的小伙伴們試用,nice~大家使用后反饋效果不錯。但是還是想請教一下專業的春波特小哥哥,看看他的意見。
春波特小哥哥相當負責地對羅美琪的代碼進行了 Review, 首先他拋出了兩個鏈接:
- https://github.com/spring-projects/spring-boot/wiki/Building-On-Spring-Boot
- https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-developing-auto-configuration.html
然后解釋道:
“在 Spring Boot 中包含兩個概念 - auto-configuration 和 starter-POMs,它們之間相互關聯,但是不是簡單綁定在一起的:
- auto-configuration 負責響應應用程序的當前狀態并配置適當的 Spring Bean。它放在用戶的 CLASSPATH 中結合在 CLASSPATH 中的其它依賴就可以提供相關的功能。
- Starter-POM 負責把 auto-configuration 和一些附加的依賴組織在一起,提供開箱即用的功能,它通常是一個 maven project,里面只是一個 POM 文件,不需要包含任何附加的 classes 或 resources。
換句話說,starter-POM 負責配置全量的 classpath,而 auto-configuration 負責具體的響應(實現);前者是 total-solution,后者可以按需使用。
你現在的系統是單一的一個 module 把 auto-configuration 和 starter-POM 混在了一起,這個不利于以后的擴展和模塊的單獨使用。”
羅美琪了解到了區分確實對日后的項目維護很重要,于是將代碼進行了模塊化:
|— rocketmq-spring-boot-parent ?父 POM
|— rocketmq-spring-boot ?????????????auto-configuraiton 模塊
|— rocketmq-spring-stater ??????????starter 模塊(實際上只包含一個 pom.xml 文件)
|— rocketmq-spring-samples ?????? ?調用 starter 的示例樣本
“很好,這樣的模塊結構就清晰多了”,春波特小哥哥點頭,“但是這個 AutoConfiguration 文件里的一些標簽的用法并不正確,幫你注釋一下,另外,考慮到 Spring 官方到 2020 年 8 月 Spring Boot 1.X 不再提供支持,所以建議實現直接支持 Spring Boot 2.X。”
@Configuration @EnableConfigurationProperties(RocketMQProperties.class) @ConditionalOnClass(MQClientAPIImpl.class) @Order ~~春波特: 這個類里使用Order很不合理呵,不建議使用,完全可以通過其他方式控制runtime是Bean的構建順序 @Slf4j public class RocketMQAutoConfiguration {@Bean@ConditionalOnClass(DefaultMQProducer.class) ~~春波特: 屬性直接使用類是不科學的,需要用(name="類全名") 方式,這樣在類不在classpath時,不會拋出CNFE@ConditionalOnMissingBean(DefaultMQProducer.class)@ConditionalOnProperty(prefix = "spring.rocketmq", value = {"nameServer", "producer.group"}) ~~春波特: nameServer屬性名要寫成name-server [1]@Order(1) ~~春波特: 刪掉呵 public DefaultMQProducer mqProducer(RocketMQProperties rocketMQProperties) {...}@Bean@ConditionalOnClass(ObjectMapper.class)@ConditionalOnMissingBean(name = "rocketMQMessageObjectMapper") ~~春波特: 不建議與具體的實例名綁定,設計的意圖是使用系統中已經存在的ObjectMapper, 如果沒有,則在這里實例化一個,需要改成@ConditionalOnMissingBean(ObjectMapper.class)public ObjectMapper rocketMQMessageObjectMapper() {return new ObjectMapper();}@Bean(destroyMethod = "destroy")@ConditionalOnBean(DefaultMQProducer.class)@ConditionalOnMissingBean(name = "rocketMQTemplate") ~~春波特: 與上面一樣@Order(2) ~~春波特: 刪掉呵 public RocketMQTemplate rocketMQTemplate(DefaultMQProducer mqProducer,@Autowired(required = false) ~~春波特: 刪掉@Qualifier("rocketMQMessageObjectMapper") ~~春波特: 刪掉,不要與具體實例綁定 ObjectMapper objectMapper) {RocketMQTemplate rocketMQTemplate = new RocketMQTemplate();rocketMQTemplate.setProducer(mqProducer);if (Objects.nonNull(objectMapper)) {rocketMQTemplate.setObjectMapper(objectMapper);}return rocketMQTemplate;}@Bean(name = RocketMQConfigUtils.ROCKETMQ_TRANSACTION_ANNOTATION_PROCESSOR_BEAN_NAME)@ConditionalOnBean(TransactionHandlerRegistry.class)@Role(BeanDefinition.ROLE_INFRASTRUCTURE) ~~春波特: 這個bean(RocketMQTransactionAnnotationProcessor)建議聲明成static的,因為這個RocketMQTransactionAnnotationProcessor實現了BeanPostProcessor接口,接口里方法在調用的時候(創建Transaction相關的Bean的時候)可以直接使用這個static實例,而不要等到這個Configuration類的其他的Bean都構建好 [2]public RocketMQTransactionAnnotationProcessor transactionAnnotationProcessor( TransactionHandlerRegistry transactionHandlerRegistry) {return new RocketMQTransactionAnnotationProcessor(transactionHandlerRegistry);}@Configuration ~~春波特: 這個內嵌的Configuration類比較復雜,建議獨立成一個頂級類,并且使用@Import在主Configuration類中引入 @ConditionalOnClass(DefaultMQPushConsumer.class)@EnableConfigurationProperties(RocketMQProperties.class)@ConditionalOnProperty(prefix = "spring.rocketmq", value = "nameServer") ~~春波特: name-serverpublic static class ListenerContainerConfiguration implements ApplicationContextAware, InitializingBean {...@Resource ~~春波特: 刪掉這個annotation, 這個field injection的方式不推薦,建議使用setter或者構造參數的方式初始化成員變量private StandardEnvironment environment;@Autowired(required = false) ~~春波特: 這個注解是不需要的public ListenerContainerConfiguration(@Qualifier("rocketMQMessageObjectMapper") ObjectMapper objectMapper) { ~~春波特: @Qualifier 不需要this.objectMapper = objectMapper;}注[1]:在聲明屬性的時候不要使用駝峰命名法,要使用-橫線分隔,這樣才能支持屬性名的松散規則(relaxed rules)。
注[2]:BeanPostProcessor 接口作用是:如果需要在 Spring 容器完成 Bean 的實例化、配置和其他的初始化的前后添加一些自己的邏輯處理,就可以定義一個或者多個 BeanPostProcessor 接口的實現,然后注冊到容器中。為什么建議聲明成 static的,春波特的英文原文:
If?they?don’t?we?basically?register?the?post-processor?at?the?same?“time”?as?all?the?other?beans?in?that?class?and?the?contract?of?BPP?is?that?it?must?be?registered?very?early?on.?This?may?not?make?a?difference?for?this?particular?class?but?flagging ?it as static as the side effect to make clear your BPP implementation is not supposed to drag other beans via dependency injection.
AutoConfiguration 里果真很有學問,羅美琪迅速的調整了代碼,一下看起來清爽了許多。不過還是被春波特提出了兩點建議:
@Configuration public class ListenerContainerConfiguration implements ApplicationContextAware, SmartInitializingSingleton {private ObjectMapper objectMapper = new ObjectMapper(); ~~春波特: 性能上考慮,不要初始化這個成員變量,既然這個成員是在構造/setter方法里設置的,就不要在這里初始化,尤其是當它的構造成本很高的時候。private void registerContainer(String beanName, Object bean) { Class<?> clazz = AopUtils.getTargetClass(bean);if(!RocketMQListener.class.isAssignableFrom(bean.getClass())){throw new IllegalStateException(clazz + " is not instance of " + RocketMQListener.class.getName());}RocketMQListener rocketMQListener = (RocketMQListener) bean; RocketMQMessageListener annotation = clazz.getAnnotation(RocketMQMessageListener.class);validate(annotation); ~~春波特: 下面的這種手工注冊Bean的方式是Spring 4.x里提供能,可以考慮使用Spring5.0 里提供的 GenericApplicationContext.registerBean的方法,通過supplier調用new來構造Bean實例 [3]BeanDefinitionBuilder beanBuilder = BeanDefinitionBuilder.rootBeanDefinition(DefaultRocketMQListenerContainer.class);beanBuilder.addPropertyValue(PROP_NAMESERVER, rocketMQProperties.getNameServer());...beanBuilder.setDestroyMethodName(METHOD_DESTROY);String containerBeanName = String.format("%s_%s", DefaultRocketMQListenerContainer.class.getName(), counter.incrementAndGet());DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getBeanFactory();beanFactory.registerBeanDefinition(containerBeanName, beanBuilder.getBeanDefinition());DefaultRocketMQListenerContainer container = beanFactory.getBean(containerBeanName, DefaultRocketMQListenerContainer.class); ~~春波特: 你這里的啟動方法是通過 afterPropertiesSet() 調用的,這個是不建議的,應該實現SmartLifecycle來定義啟停方法,這樣在ApplicationContext刷新時能夠自動啟動;并且避免了context初始化時由于底層資源問題導致的掛住(stuck)的危險if (!container.isStarted()) {try {container.start();} catch (Exception e) {log.error("started container failed. {}", container, e); throw new RuntimeException(e);}}...} }注[3]:使用 GenericApplicationContext.registerBean 的方式。
public final < T > void registerBean(
?Class< T > beanClass, Supplier< T > supplier, BeanDefinitionCustomizer… ustomizers)
“還有,還有”,在羅美琪采納了春波特的意見比較大地調整了代碼之后,春波特哥哥又提出了 Spring Boot 特有的幾個要求:
- 使用 Spring 的 Assert 在傳統的 Java 代碼中我們使用 assert 進行斷言,Spring Boot 中斷言需要使用它自有的 Assert 類,如下示例:
- Auto Configuration 單元測試使用 Spring 2.0 提供的 ApplicationContextRunner:
- 在 auto-configuration 模塊的 pom.xml 文件里,加入 spring-boot-configuration-processor 注解處理器,這樣它能夠生成輔助元數據文件,加快啟動時間。
詳情見這里:
https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-developing-auto-configuration.html#boot-features-custom-starter-module-autoconfigure
最后,春波特還相當專業地向羅美琪美眉提供了如下兩方面的意見:
1. 通用的規范,好的代碼要易讀易于維護
1)注釋與命名規范
我們常用的代碼注釋分為多行(/** … */)和單行(// …)兩種類型,對于需要說明的成員變量,方法或者代碼邏輯應該提供多行注釋; 有些簡單的代碼邏輯注釋也可以使用單行注釋。在注釋時通用的要求是首字母大寫開頭,并且使用句號結尾;對于單行注釋,也要求首字母大寫開頭;并且不建議行尾單行注釋。
在變量和方法命名時盡量用詞準確,并且盡量不要使用縮寫,如: sendMsgTimeout,建議寫成 sendMessageTimeout;包名 supports,建議改成 support。
2)是否需要使用?Lombok
使用 Lombok 的好處是代碼更加簡潔,只需要使用一些注釋就可省略 constructor,setter 和 getter 等諸多方法(bolierplate code);但是也有一個壞處就是需要開發者在自己的 IDE 環境配置 Lombok 插件來支持這一功能,所以 Spring 社區的推薦方式是不使用 Lombok,以便新用戶可以直接查看和維護代碼,不依賴 IDE 的設置。
3)對于包名(package)的控制
如果一個包目錄下沒有任何 class,建議要去掉這個包目錄。例如:org.apache.rocketmq.spring.starter 在 spring 目錄下沒有具體的 class 定義,那么應該去掉這層目錄(編者注: 我們最終把 package 改為 org.apache.rocketmq.spring,將 starter 下的目錄和 classes 上移一層)。我們把所有 Enum 類放在包 org.apache.rocketmq.spring.enums 下,這個包命名并不規范,需要把 Enum 類調整到具體的包中,去掉 enums 包;類的隱藏,對于有些類,它只被包中的其它類使用,而不需要把具體的使用細節暴漏給最終用戶,建議使用 package private 約束,例如:TransactionHandler 類。
4)不建議使用 Static Import, 雖然使用它的好處是更少的代碼,壞處是破壞程序的可讀性和易維護性。
2. 效率,深入代碼的細節
- static + final method:一個類的 static 方法不要結合 final,除非這個類本身是 final 并且聲明 private 構造(ctor),如果兩者結合以為這子類不能再(hiding)定義該方法,給將來的擴展和子類調用帶來麻煩。
- 在配置文件聲明的 Bean 盡量使用構造函數或者 Setter 方法設置成員變量,而不要使用@Autowared,@Resource等方式注入。
- 不要額外初始化無用的成員變量。
- 如果一個方法沒有任何地方調用,就應該刪除;如果一個接口方法不需要,就不要實現這個接口類。
注[4]:下面的截圖是有 FieldInjection 轉變成構造函數設置的代碼示例。
轉換成:
故事的結局
羅美琪根據上述的要求調整了代碼,使代碼質量有了很大的提高,并且總結了 Spring Boot 開發的要點:
- 編寫前參考成熟的 spring boot 實現代碼。
- 要注意模塊的劃分,區分 autoconfiguration 和 starter。
- 在編寫 autoconfiguration Bean 的時候,注意 @Conditional 注解的使用;盡量使用構造器或者 setter 方法來設置變量,避免使用 Field Injection 方式;多個 Configuration Bean 可以使用 @Import 關聯;使用 Spring 2.0 提供的 AutoConfigruation 測試類。
- 注意一些細節:static 與 BeanPostProcessor;Lifecycle 的使用;不必要的成員屬性的初始化等。
通過本次的 Review 工作了解到了 spring-boot 及 auto-configuration 所需要的一些約束條件,信心滿滿地提交了最終的代碼,又可以邀請 RocketMQ 社區的小伙伴們一起使用 rocketmq-spring 功能了,廣大讀者可以在參考代碼庫查看到最后修復代碼,也希望有更多的寶貴意見反饋和加強,加油!
后記
開源軟件不僅僅是提供一個好用的產品,代碼質量和風格也會影響到廣大的開發者,活躍的社區貢獻者羅美琪還在與 RocketMQ 社區的小伙伴們不斷完善 spring 的代碼,并邀請春波特的 Spring 社區進行宣講和介紹,下一步將 rocketmq-spring-starter 推進到 Spring Initializr,讓用戶可以直接在 start.spring.io 網站上像使用其它 starter(如: Tomcat starter)一樣使用 rocketmq-spring。
釘釘搜索群號:21982288,即可加入 Apache RocketMQ 中國開發者官方釘釘群!
在 PC 端登錄 start.aliyun.com 知行動手實驗室,沉浸式體驗在線交互教程。
總結
以上是生活随笔為你收集整理的罗美琪和春波特的故事...的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【全球年青人召集令】Hello Worl
- 下一篇: Fluid 给数据弹性一双隐形的翅膀 -