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

头图.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。

相关文章
相关标签/搜索