講講我和Spring創始級程序員共同review代碼的故事

RocketMQ-Spring畢業了。

作爲Apache RocketMQ的子項目,經過6個多月的孵化,RocketMQ-Spring發佈了第一個Release版本v2.0.1,通過使用Spring Boot的方式把RocketMQ的客戶端進行封裝,幫助用戶通過簡單的Annotation和標準的Spring Messaging API編寫代碼,來進行消息的發送和消費,以降低開發複雜度。

本文將以故事的形式,還原RocketMQ社區開發者和Spring社區創始工程師一同Review代碼以及對代碼進行改進的全過程,希望對做Spring Boot開發的同學有所幫助。

羅美琪:RocketMQ社區開發者

春波特小哥:Spring社區創始工程師

故事的開始

羅美琪有一套RocketMQ的客戶端代碼,負責發送消息和消費消息。聽說春波特小哥善於消息發送,通過Spring Boot可以把自己客戶端調用變得非常簡單,只需要一些簡單的註解(Annotation)和代碼就可以使用獨立應用的方式來啓動,省去了複雜的代碼編寫和參數配置。

羅美琪參考了業界已經實現的消息組件的Spring實現了一個RocketMQ Spring客戶端,它有兩部分組成:

  1. 消息的發送客戶端:這是一個自動創建的Spring Bean,相關屬性能夠根據配置文件的配置自動設置,命名它爲: RocketMQTemplate, 同時用來封裝發送消息的各種同步和異步的方法。

@Resourceprivate RocketMQTemplate rocketMQTemplate;

...

SendResult sendResult = rocketMQTemplate.syncSend(xxxTopic, "Hello, World!");

  1. 消息的接收客戶端:這是一個能夠被應用回調的Listener, 用於將消費消息回調給用戶進行相關的處理。

@Service@RocketMQMessageListener(topic = "xxx", consumerGroup = "xxx_consumer")

public class StringConsumer implements RocketMQListener<String> {

   @Override   public void onMessage(String message) {

       System.out.printf("------- StringConsumer received: %s \n", message);

   }

}

特別說明一下:這個消費客戶端Listener需要通過一個自定義的註解@RocketMQMessageListener來標註,這個註解的作用有兩個:

  • 定義消息消費的配置參數(如: 消費的Topic, 是否順序消費,消費組等);

  • 可以讓spring-boot在啓動過程中發現標註了這個註解的所有Listener,並進行初始化,詳見ListenerContainerConfiguration類及其實現SmartInitializingSingleton的接口方法afterSingletonsInstantiated()。

羅美琪發現,Spring-Boot最核心的實現是自動化配置(Auto Configuration),它分爲三個部分:

  • 由@Configuration標註,用來創建RocketMQ客戶端所需要的SpringBean,如上面所提到的RocketMQTemplate和能夠處理消費回調Listener的容器,每個Listener對應一個容器SpringBean,來啓動MQPushConsumer,並能將監聽到的消費消息推送給Listener進行回調。參考:RocketMQAutoConfiguration.java (編者注: 這個是最終發佈的類,沒有review的痕跡)

  • 實現“自動”配置,還需要由META-INF/spring.factories來聲明。參考:spring.factories。使用這個META配置的好處是上層用戶不需要關心自動配置類的細節和開關,只要classpath中有這個META-INF文件和Configuration類,就能實現自動配置。

  • 定義了@EnableConfiguraitonProperties註解,來引入ConfigurationProperties類,它的作用是定義自動配置的屬性。參考:RocketMQProperties.java。上層用戶可以根據這個類裏定義的屬性,配置相關的屬性文件(即 META-INF/application.properties 或 META-INF/application.yaml)

故事的發展

羅美琪按照這個思路完成了RocketMQ SpringBoot的封裝並形成了starter,提交給社區的小夥伴們試用,nice,大家使用後反饋效果不錯。但是還是想請教一下專業的春波特小哥哥,看看他的建議。

春波特小哥相當的負責地對羅美琪的代碼進行了Review, 首先他拋出了兩個鏈接:

然後解釋道:在Spring Boot中包含兩個概念: auto-configuration和starter-POMs, 它們之間相互關聯,但並非簡單綁定在一起的:

a. auto-configuration負責響應應用程序的當前狀態,並配置適當的Spring Bean。它放在用戶的CLASSPATH中,結合在CLASSPATH中的其它依賴,就可以提供相關的功能;

b. 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文件裏的一些標籤的用法並不正確,我來註釋一下,另外,考慮到明年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-server

   public 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類,如下示例:

import org.springframework.util.Assert;

...

Assert.hasText(nameServer, "[rocketmq.name-server] must not be null");

  • Auto Configuration單元測試使用Spring 2.0提供 ApplicationContextRunner

public class RocketMQAutoConfigurationTest {

   private ApplicationContextRunner runner = new ApplicationContextRunner()           .withConfiguration(AutoConfigurations.of(RocketMQAutoConfiguration.class));



   @Test(expected = NoSuchBeanDefinitionException.class)   public void testRocketMQAutoConfigurationNotCreatedByDefault() {

       runner.run(context -> context.getBean(RocketMQAutoConfiguration.class));   }

   @Test

   public void testDefaultMQProducerWithRelaxPropertyName() {

       runner.withPropertyValues("rocketmq.name-server=127.0.0.1:9876",               "rocketmq.producer.group=spring_rocketmq").

               run((context) -> {

                   assertThat(context).hasSingleBean(DefaultMQProducer.class);                   assertThat(context).hasSingleBean(RocketMQProperties.class);               });

   }

在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)。

最後,春波特還向羅美琪分享了一些實踐經驗:

通用的規範,好的代碼要易讀易於維護

a. 註釋與命名規範

我們常用的代碼註釋分爲多行(/** … */)和單行(// …)兩種類型,對於需要說明的成員變量,方法或者代碼邏輯應該提供多行註釋; 有些簡單的代碼邏輯註釋也可以使用單行註釋。在註釋時通用的要求是首字母大寫開頭,並且使用句號結尾;對於單行註釋,也要求首字母大寫開頭; 並且不建議行尾單行註釋。

在變量和方法命名時儘量用詞準確,並且儘量不要使用縮寫,如: sendMsgTimeout, 建議寫成sendMessageTimeout;包名supports,建議改成support。

**b. 是否需要使用Lombok **

使用Lombok的好處是代碼更加簡潔,只需要使用一些註釋就可省略constructor, setter和getter等諸多方法(bolierplate code);但是也有一個壞處就是需要開發者在自己的IDE環境配置Lombok插件來支持這一功能,所以Spring社區的推薦方式是不使用Lombok,以便新用戶可以直接查看和維護代碼,不依賴IDE的設置。

c. 對於包名(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類。

d. 不建議使用Static Import

雖然使用它的好處是更少的代碼,壞處是破壞程序的可讀性和易維護性。

效率,深入代碼的細節

a. static + final method,一個類的static方法不要結合final,除非這個這個類本身是final並且聲明private構造(ctor),如果兩者結合以爲這子類不能再(hiding)定義該方法,給將來的擴展和子類調用帶來麻煩。

b. 在配置文件聲明的Bean儘量使用構造函數或者Setter方法設置成員變量,而不要使用@Autowared,@Resource等方式注入。[4]

c. 不要額外初始化無用的成員變量。

d. 如果一個方法沒有任何地方調用,就應該刪除;如果一個接口方法不需要,就不要實現這個接口類

注[4]:下面的截圖是有 FieldInjection 轉變成構造函數設置的代碼示例:

image

轉換成

image

故事的結局

羅美琪按照春波特小哥的建議,進一步調整了代碼,大幅度提高了代碼質量,並且總結了Spring Boot開發的要點:

a. 編寫前參考成熟的spring boot實現代碼;

b. 要注意模塊的劃分,區分autoconfiguration 和 starter;

c. 在編寫autoconfiguration Bean的時候,注意@Conditional註解的使用;儘量使用構造器或者setter方法來設置變量,避免使用Field Injection方式;多個Configuration Bean可以使用@Import關聯;使用Spring 2.0提供的AutoConfigruation測試類;

d. 注意一些細節: static與BeanPostProcessor; Lifecycle的使用;不必要的成員屬性的初始化等;

後記

開源軟件不僅要關注產品的易用性,更要在乎代碼質量和代碼風格。

活躍的社區貢獻者羅美琪繼續在與RocketMQ社區的小夥伴們不斷完善Spring的代碼,並邀請春波特的Spring社區進行更多的技術分享。下一步他們將rocketmq-spring-starter推進到Spring Initializr,讓用戶可以在start.spring.io上像使用其它starter(如: Tomcat starter)一樣使用rocketmq-spring。

作者簡介

遼天,社區ID walking98,阿里巴巴技術專家,Apache RocketMQ內核控,擁有多年分佈式系統研發經驗,對Microsoft Messaging、Storage等領域有深刻理解。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章