羅美琪和春波特的故事...

頭圖.png

做者 | 遼天
來源 | 阿里巴巴雲原生公衆號html

導讀:rocketmq-spring 通過 6 個多月的孵化,做爲 Apache RocketMQ 的子項目正式畢業,發佈了第一個 Release 版本 2.0.1。這個項目是把 RocketMQ 的客戶端使用 Spring Boot 的方式進行了封裝,可讓用戶經過簡單的 annotation 和標準的 Spring Messaging API 編寫代碼來進行消息的發送和消費。java

在項目發佈階段咱們很榮幸的邀請了 Spring 社區的原創人員對咱們的代碼進行了 Review,經過幾輪 slack 上的深刻交流感覺到了 Spring 團隊對開源代碼質量的標準,對 SpringBoot 項目細節的要求。本文是對 Review 和代碼改進過程當中的經驗和技巧的總結,但願從事 Spring Boot 開發的同窗有幫助。咱們把這個過程整理成 RocketMQ 社區的貢獻者羅美琪和 Spring 社區的春波特(SpringBoot)的故事。git

故事的開始

故事的開始是這樣的,羅美琪美眉有一套 RocketMQ 的客戶端代碼,負責發送消息和消費消息。早早的據說春波特小哥哥的大名,經過 Spring Boot 能夠把本身客戶端調用變得很是簡單,只使用一些簡單的註解(annotation)和代碼就可使用獨立應用的方式啓動,省去了複雜的代碼編寫和參數配置。github

聰明的她參考了業界已經實現的消息組件的 Spring 實現了一個 RocketMQ Spring 客戶端:spring

  • 須要一個消息的發送客戶端,它是一個自動建立的 Spring Bean,而且相關屬性要可以根據配置文件的配置自動設置, 命名它爲:RocketMQTemplate, 同時讓它封裝發送消息的各類同步和異步的方法。
@Resourceprivate RocketMQTemplate rocketMQTemplate;
...
SendResult sendResult = rocketMQTemplate.syncSend(xxxTopic, "Hello, World!");
  • 須要消息的接收客戶端,它是一個可以被應用回調的 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 來標註,這個註解的做用有兩個:apache

  • 定義消息消費的配置參數(如: 消費的 topic, 是否順序消費,消費組等)。 
  • 可讓 spring-boot 在啓動過程當中發現標註了這個註解的全部 Listener, 並進行初始化,詳見 ListenerContainerConfiguration 類及其實現 SmartInitializingSingleton 的接口方法 afterSingletonsInstantiated()。

經過研究發現,Spring-Boot 最核心的實現是自動化配置(auto configuration),它須要分爲三個部分:app

  • 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, 首先他拋出了兩個連接:maven

而後解釋道:ide

「在 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-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

最後,春波特還至關專業地向羅美琪美眉提供了以下兩方面的意見:

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 轉變成構造函數設置的代碼示例。

1.png

轉換成:

2.png

故事的結局

羅美琪根據上述的要求調整了代碼,使代碼質量有了很大的提升,而且總結了 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。

相關文章
相關標籤/搜索