封面来源:碧蓝航线 星光下的余晖 活动 CG

阅读建议:

0. 我的近况

半个月前,出租房到期,原来的室友在年前就已经离开,那时摆在我面前的头等大事是找一个离公司不远的套一出租房。一番打听并在爸妈的帮助下,以一个勉强接受的价格租下当前居住的房屋。

单纯从地图上来看,现在的通勤距离和先前区别不大,出门转个弯,一路向上就能直达公司,甚至更加方便。

只不过周围的交通设施就没那么方便了,但仔细一想,自己以前使用它们的次数也屈指可数,这点并不重要。

粗略算来,自己在新租的房子里也住了两周,与以前那套房子相比,这间房子的日照时间更长,整个人似乎也变得阳光起来。不再合租之后,家里的任何事情都可以自己做主,生活也变得轻松起来。新任房东似乎也更好说话,不用再小心翼翼地生活,担心家里设施坏了还要自己赔偿。

过去的糟心事就让它过去,复盘再多也不会有什么改变,勇敢面对接下来的生活吧。

1. 背景与需求

毕业后一直在做低代码平台与代码生成的相关工作,我负责的是平台中数据处理与逻辑编排的功能。简单来说就是可视化编程,将一些常用操作进行封装后,用户通过类似流程图的配置就可以自行完成数据处理与逻辑编排。

Java 生态完善是因为千奇百怪的需求总能找到处理的类库,我封装的操作很可能会满足不了用户的需求,此时就不得不需要客开的介入,但客开也面临着「该怎么做」的疑问,而我最近一两周的任务就是改善这个问题。

任务中最重要是实现「默认服务」。

不知道我叽里咕噜地在说什么?

没关系,直接上代码。🤪

比如当前有一个 MyService 接口,内部定义了 getStr() 方法:

1
2
3
public interface MyService {
String getStr();
}

该接口由平台生成,客开工程中能够使用该接口,之后客开可以对该方法进行实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@DubboService
@Component(BeanNameConstant.CUSTOM_BEAN)
public class CustomServiceImpl implements MyService {

private InjectComponent injectComponent;

@Autowired(required = false)
public void setInjectComponent(InjectComponent injectComponent) {
this.injectComponent = injectComponent;
}

@Override
public String getStr() {
return "custom " + injectComponent.getStr();
}
}

public interface InjectComponent {
String getStr();
}

@Service
public class InjectComponentImpl implements InjectComponent {
@Override
public String getStr() {
return "Inject";
}
}

CustomServiceImpl 实现类上存在两个注解:

  • @DubboService:Dubbo 中的注解。作用在类上时能够将一个类注册为 Dubbo 服务,并同时将它作为 Spring 的 Bean 注册到容器中,此时容器中会增加两种类型的 Bean,一种是标记的类对应的 Bean,另一种是 Dubbo 提供的 ServiceBean 类型的 Bean;
  • @Component:Spring 中的注解,作用在类上时能够将该类作为 Spring 的 Bean 注册到 Spring 容器中。

看到这,或许又有疑问了,@DubboService 不是具有 @Component 的功能吗?再使用一个 @Component 是否冗余了?

其实在实际客开工程中并不是使用的是 @Component,而是一个将 Spring 中的 @Service 注解封装后并具有我司特色的注解,这里用 @Component 作为示例。

不必纠结同时使用 @Component@DubboService 是否合理,反正现状是 @DubboService@Component 必须成对出现。

说句题外话,它俩同时出现后,Spring 容器中难道会出现两个 CustomServiceImpl 类型的 Bean 吗?

当然不会。

解析 @Component 会在执行 ConfigurationClassPostProcessor 时完成,解析 @DubboService 会在执行 ServiceAnnotationPostProcessor 时完成,它们都是 BeanDefinitionRegistryPostProcessor 的实现类,前者还实现了 PriorityOrdered 接口,因此会先一步执行,即先解析 @Component 注解。后续执行 ServiceAnnotationPostProcessor 时也会做文件扫描,得到被 @DubboService 标记的类,并将其转换成 BeanDefinition 注册到 Sping 容器中,如果容器中已经存在同名的 BeanDefinition,则会跳过注册(详细逻辑在 ServiceAnnotationPostProcessor#scanServiceBeans() 方法中调用 scanner.scan(packageToScan) 方法)。

总结一下现状:

  • 存在一个平台生成的 MyService 接口
  • 客开自行对 MyService 进行了实现,得到 CustomServiceImpl,并且在这个类上 @DubboService@Component 成对出现

我作为平台的维护者:

  • 可以修改平台生成的代码,但必须保证兼容性,比如类路径是万万不可的
  • 可以再额外生成的代码
  • 但无法干预自定义实现,自定义实现完全由客开手动编写

现在的需求是:

  • 生成一个 MyService 的默认实现 DefaultServiceImpl
  • 当存在 MyService 的自定义实现时,以自定义为准,默认实现 DefaultServiceImpl 不生效;如果没有自定义实现,则默认实现 DefaultServiceImpl 生效
  • 默认实现上必须要有 @Component 注解(准确地说应该是我司封装的 @Service 注解,这里以 @Component 注解代替)
  • 支持远程调用
  • 必须保证兼容性,这是重中之重

2. 尝试与解决

2.1 第一次尝试

先来一个默认实现的「骨架」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@DubboService
@Component("defaultServiceImpl")
public class DefaultServiceImpl implements MyService {

private InjectComponent injectComponent;

@Autowired(required = false)
public void setInjectComponent(InjectComponent injectComponent) {
this.injectComponent = injectComponent;
}

@Override
public String getStr() {
return "default " + injectComponent.getStr();
}
}

如果项目中同时存在默认实现和自定义实现,它们都被 @Component 注解标记,那么 Spring 容器中一定会存在这两种类型的 Bean,这显然不符合先前的需求。

存在自定义,就用自定义;没有自定义,就用默认。

如果了解 SpringBoot 的自动配置,会想到一个名为 @ConditionalOnMissingBean 的注解。

这个注解似乎就是「为当前的需求而生的」,它是 Spring Boot 中用于条件化注册 Bean 的注解,其核心作用是仅在容器中不存在指定 Bean 时才创建被注解的 Bean。

@ConditionalOnMissingBean 注解能够作用在类上,也就是直接像下面这样就完成需求了?

1
2
3
4
5
6
@DubboService
@ConditionalOnMissingBean(MyService.class)
@Component("defaultServiceImpl")
public class DefaultServiceImpl implements MyService {
// --snip--
}

然而这种方式并不能解决问题,并且与默认实现是否被 @DubboService 注解标记有关系。可以从以下情况分类讨论:

  1. 如果默认实现 没有@DubboService 注解标记:
    • 同时存在自定义实现和默认实现时,此时与预期一致,Spring 容器中只有自定义实现类型的 Bean。默认实现类型的 BeanDefinition 会在执行 ConfigurationClassBeanDefinitionReader#loadBeanDefinitionsForConfigurationClass() 方法被移除掉,更多细节可以参考 Spring 配置类的解析 一文;
    • 只有默认实现时,Spring 容器中连默认实现类型的 Bean 都没有。在扫描类时,能够扫描到默认实现,并将其转换为 BeanDefinition 添加到容器中。由于默认实现上的 @ConditionalOnMissingBean 注解的 value 值是默认实现类实现的接口,此时 Spring 容器中存在的默认实现类型的 BeanDefinition 恰好能满足这个条件,因此后续执行 ConfigurationClassBeanDefinitionReader#loadBeanDefinitionsForConfigurationClass() 方法时又会移除默认实现类型对应的 BeanDefinition
  2. 如果默认实现被 @DubboService 注解标记:
    • 同时存在自定义实现和默认实现时,首先会解析 @Component 注解,但由于默认实现上存在 @ConditionalOnMissingBean 注解,默认实现类型的 BeanDefinition 会在统一解析后被移除。之后会解析 @DubboService 注解,解析过程中会将被标记的类转换成 BeanDefinition 并注册到 Spring 容器中(此时 Spring 容器中又有默认实现类型的 BeanDefinition 了),并且还会额外注册一个 ServiceBean 类型的 BeanDefinition,这种 BeanDefinitionname 与实现的接口的全限定类名有强关联关系。由于默认实现和自定义实现实现了相同的接口,并且使用的 @DubboService 注解也没有做更多的配置,它们对应的 ServiceBean 类型的 BeanDefinitionname 相等,而 Dubbo 不允许向 Spring 容器中注册多个相同 nameServiceBean 类型的 BeanDefinition,否则抛出 BeanDefinitionStoreException 并终止应用启动(见 ServiceAnnotationPostProcessor#registerServiceBeanDefinition() 方法)。
    • 只有默认实现时,Spring 容器中会存在默认实现类型对应的 Bean。由于 @ConditionalOnMissingBean 注解的存在,解析 @Component 注解得到的 BeanDefinition 会被移除掉,但之后解析 @DubboService 注解时又会向 Spring 容器中添加该类型的 Bean,因此最终 Spring 容器中依旧有该类型的 Bean。

除此之外,@ConditionalOnMissingBean 注解的使用还有诸多限制。

@ConditionalOnMissingBean 注解的类注释上有这样一段话:

The condition can only match the bean definitions that have been processed by the 
application context so far and, as such, it is strongly recommended to use this
condition on auto-configuration classes only. If a candidate bean may be created by
another auto-configuration, make sure that the one using this condition runs after.

翻译一下:

  • @ConditionalOnMissingBean 只能匹配应用程序上下文迄今为止已处理过的 BeanDefinition
  • 强烈建议 在自动配置类中使用 @ConditionalOnMissingBean
  • 如果候选 Bean (参与判断的 Bean,也就是判断容器中是否存在的 Bean)可能由另一个自动配置类创建,需要保证当前自动配置类在那一个自动配置类之后运行

也就是说,@ConditionalOnMissingBean 是否有用还取决于 Bean 的加载顺序。

如果默认实现先一步被加载,显然此时 Spring 容器中不存在其实现接口对应类型的 Bean,满足 @ConditionalOnMissingBean 的条件,成功向 Spring 容器中添加默认实现类型对应的 Bean,而后加载自定义实现时,默认实现上的条件无法影响自定义实现,又会向 Spring 容器中添加自定义实现类型对应的 Bean,这显然不符合设定的需求。

基于以上种种原因,使用 @ConditionalOnMissingBean 注解的想法很固然美好,但却无法达到想要的效果。

2.2 第二次尝试

经过先前的尝试,已经明确 @ConditionalOnMissingBean 无法满足需求,因此先移除默认实现上的 @ConditionalOnMissingBean 注解:

1
2
3
4
5
@Component
@DubboService
public class DefaultServiceImpl implements MyService {
// --snip--
}

那要怎么实现「存在自定义,就用自定义;没有自定义,就用默认」的需求呢?

参考 SpringBoot 扫描组件类的统一处理,如果没满足条件,就移除对应的 BeanDefinition

ConfigurationClassBeanDefinitionReader#loadBeanDefinitionsForConfigurationClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void loadBeanDefinitionsForConfigurationClass(
ConfigurationClass configClass, TrackedConditionEvaluator trackedConditionEvaluator) {

// 判断是否需要跳过
if (trackedConditionEvaluator.shouldSkip(configClass)) {
String beanName = configClass.getBeanName();
// 移除对应的 BeanDefinition
if (StringUtils.hasLength(beanName) && this.registry.containsBeanDefinition(beanName)) {
this.registry.removeBeanDefinition(beanName);
}
this.importRegistry.removeImportingClass(configClass.getMetadata().getClassName());
return;
}

// --snip--
}

要想删除一个 BeanDefinition,要使用到 BeanDefinitionRegistry 对象。

SpringBoot 常用拓展点 中,通过实现 BeanDefinitionRegistryPostProcessor 接口就能拿到 BeanDefinitionRegistry 对象。

添加自定义 BeanDefinitionRegistryPostProcessor 实现类:

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MyBeanDefinitionRegistryPostProcessor implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
ListableBeanFactory beanFactory = (ListableBeanFactory) registry;
String[] beanNames = beanFactory.getBeanNamesForType(MyService.class);
if (beanNames.length > 1 && ArrayUtils.contains(beanNames, "defaultServiceImpl")) {
registry.removeBeanDefinition("defaultServiceImpl");
}
}
}

逻辑很简单,当容器中存在多个 MyService 类型的 BeanDefinition 时,按名称移除默认实现类型的 BeanDefinition

OK,现在再来启动应用。

然后就又会遇到熟悉的 BeanDefinitionStoreException,依旧提示向 Spring 容器中注册了相同名称的 BeanDefinition

这个 BeanDefinition 对应的类型是 ServiceBean,只不过我当时并没注意到,认为解析 @DubboService 时只会向容器中注册标记类对应类型的 Bean。

使用 ConfigurationClassPostProcessor 解析 @Component 注解,使用 ServiceAnnotationPostProcessor 解析 @DubboService 注解,这两个类也是 BeanDefinitionRegistryPostProcessor 的实现类。

按照当时的理解,我认为是它们三者的执行顺序导致了应用启动过程中出现异常:

flowchart TD
 A(ConfigurationClassPostProcessor)
 B(MyBeanDefinitionRegistryPostProcessor)
 C(ServiceAnnotationPostProcessor)
 A --> B --> C

这倒没错,实际情况也确实按这个顺序执行。

我认为 MyBeanDefinitionRegistryPostProcessor 处于中间被执行时,确实能够移除掉容器中默认实现类型的 BeanDefinition,但是最后执行的 ServiceAnnotationPostProcessor 又会把默认实现类型的 BeanDefinition 添加回去。

这也没错,只不过把默认实现类型的 BeanDefinition 添加回去 并不会导致 出现 BeanDefinitionStoreException 异常。

基于这对了但没完全对的想法,我决定调整它们三者的执行顺序。

交换 ServiceAnnotationPostProcessorMyBeanDefinitionRegistryPostProcessor 的执行顺序,让 ServiceAnnotationPostProcessor 先一步执行,使得最后执行的 MyBeanDefinitionRegistryPostProcessor 总是能删除默认实现类型的 BeanDefinition

flowchart TD
 A(ConfigurationClassPostProcessor)
 B(ServiceAnnotationPostProcessor)
 C(MyBeanDefinitionRegistryPostProcessor)
 A --> B --> C

在调整执行顺序后,我认为会有如下执行流程:

  1. 首先执行 ConfigurationClassPostProcessor 解析 @Component 注解,此时容器中既存在默认实现类型的 BeanDefinition,又存在自定义实现类型的 BeanDefinition
  2. 然后再执行 ServiceAnnotationPostProcessor 解析 @DubboService 注解,由于容器中已经存在默认实现类型和自定义实现类型的 BeanDefinition 了,那么就不再向容器中添加这两种 BeanDefinition,自然就不会抛出 BeanDefinitionStoreException 异常;
  3. 最后执行 MyBeanDefinitionRegistryPostProcessor 移除默认实现类型的 BeanDefinition,这时容器中只剩下自定义实现类型的 BeanDefinition

按照这个方案调整三者的执行顺序后,再次启动应用,依旧出现 BeanDefinitionStoreException

这又是为什么呢?

在第二步解析 @DubboService 注解时,确实不会再向容器中添加那两种类型的 BeanDefinition,只不过并不是因为不添加它们了,就不会抛出 BeanDefinitionStoreException。抛出 BeanDefinitionStoreException 的原因是向容器中注册了相同名称、ServiceBean 类型的 BeanDefinition

在这次报错后,我又重新阅读了 ServiceAnnotationPostProcessor 的源码,但依旧没发现出现 BeanDefinitionStoreException 的根本原因。

本次尝试仅仅完成了:

  • 在不使用 @ConditionalOnMissingBean 注解的情况下,实现「存在自定义实现,就添加自定义实现的 Bean;不存在自定义实现,就添加默认实现的 Bean」;

如何控制多个 BeanDefinitionRegistryPostProcessor 的执行顺序

前面提到将自定义 BeanDefinitionRegistryPostProcessor 的执行时机调整到 Dubbo 的 ServiceAnnotationPostProcessor 之后,但说明是怎么实现的,这里稍微补充下,如果知道,可以跳过。

在 Dubbo 中,通过自动配置将 ServiceAnnotationPostProcessor 添加到 Spring 容器中。

在 SpringBoot 中,多个 BeanDefinitionRegistryPostProcessor 的执行顺序按照以下优先级:

flowchart TD
 A(实现 PriorityOrdered 接口)
 B(实现 Ordered 接口)
 C(未实现任何接口)
 A --> B --> C

ConfigurationClassPostProcessor 实现了 PriorityOrdered 接口,因此它会最先执行。

ServiceAnnotationPostProcessor 和自定义 BeanDefinitionRegistryPostProcessor 未实现任何接口,它们会按照 Bean 的注册顺序进行执行。

首先移除自定义 BeanDefinitionRegistryPostProcessor 上的 @Component 注解,后续将使用自动配置将其注册到 Spring 容器中。

新建 MyConfig 配置类,内部以 @Bean 方法的形式注册自定义 BeanDefinitionRegistryPostProcessor

1
2
3
4
5
6
7
8
@Configuration
@AutoConfigureAfter(DubboAutoConfiguration.class)
public class MyConfig {
@Bean
public MyBeanDefinitionRegistryPostProcessor myBeanDefinitionRegistryPostProcessor() {
return new MyBeanDefinitionRegistryPostProcessor();
}
}

MyConfig 配置类中有两个注意点:

  1. 使用 @AutoConfigureAfter 注解,指定当前配置类的自动配置在 DubboAutoConfiguration 之后,而 DubboAutoConfiguration 就是用来自动配置 ServiceAnnotationPostProcessor 的;
  2. MyConfig 放在一个不会被 SpringBoot 扫描到的包路径下,这点非常重要!

resources 目录下新建 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件,指定需要自动配置的配置类:

1
indi.mofan.autoconfig.MyConfig

2.3 第三次尝试

在第二次尝试中实现了默认 Bean 与自定义 Bean 的注册,因此自定义 BeanDefinitionRegistryPostProcessor 需要保留。

当前的问题是如何处理 @DubboService 注解,在这时,我已经知道 @DubboService 既会注册 Bean,又会注册服务。

那能不能让 @DubboService 只注册服务,不注册 Bean 呢?

参考 @DubboService 的文档注释,推荐在配置类中使用 @Bean 方法,于是去掉默认实现上的 @DubboService 注解,并增加一个配置类:

1
2
3
4
5
6
7
8
9
@Configuration
public class MyDefaultServiceConfig {
@Bean
@DubboService
@ConditionalOnMissingBean(MyService.class)
public DefaultServiceImpl defaultServiceImpl() {
return new DefaultServiceImpl();
}
}

这看起来很不错…

吗?

当不存在自定义实现时,SpringBoot 通过解析 @Component 注解向 Spring 容器中添加默认实现类型的 Bean,而后解析 @Bean 方法时,由于容器中默认实现类型的 Bean 也是 MyService 类型,不满足 @ConditionalOnMissingBean 的条件,因此跳过该方法的解析,用于注册服务的 @DubboService 也会失效。

于是我又移除 @Bean 方法上的 @ConditionalOnMissingBean 方法,采用原生的 @Conditional 注解进行判断。

最终成了吗?

没有。

@Conditional 的判断逻辑中,我错误地使用了 ListableBeanFactory#getBeansOfType() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Configuration
public class MyDefaultServiceConfig {
@Bean
@DubboService
@Conditional(DefaultComponentCondition.class)
public DefaultServiceImpl defaultServiceImpl() {
return new DefaultServiceImpl();
}

public static class DefaultComponentCondition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return matchCondition(context, metadata, MyService.class, "defaultServiceImpl");
}
}

static <T> boolean matchCondition(ConditionContext context,
AnnotatedTypeMetadata metadata,
Class<T> clazz,
String defaultBeanName) {
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
if (beanFactory == null) {
return false;
}
Map<String, T> beanMap = beanFactory.getBeansOfType(clazz);
long beanTypeCnt = beanMap.values().stream()
.map(i -> i.getClass())
.distinct()
.count();
return beanTypeCnt <= 1 || !beanMap.containsKey(defaultBeanName);
}
}

在重写 Condition 接口中的 matches() 方法时,调用了 matchCondition() 方法,内部又调用了 getBeansOfType() 方法获取指定类型的所有 Bean。

调用 getBeansOfType() 获取 Bean 时,如果这个 Bean 还有被实例化,则会在此创建 Bean,相当于把 Bean 的实例化提前,这可能会让 Bean 没有经过完整的生命周期而被添加到 Spring 容器中,比如没有进行依赖注入、没有经过初始化阶段等等。

在准备解析 @Bean 方法、执行 matches() 方法进行判断时,自定义实现类型的 Bean 的实例化被提前,如果这个 Bean 还需要进行依赖注入,则会跳过依赖注入阶段,导致添加到 Spring 容器中的 Bean 不完整。

后续我又尝试了各种方案来解决依赖注入不完整的问题,但总有一些场景无法覆盖。这个过程花费将近三天,用三天的时间从头开始完成一个任务项都绰绰有余,而从第一天开始这个任务项,时间已经过去了将近两周。

2.4 解决方案

在那段时间,我总是在思考应该如何实现这个需求,走路时、吃饭时,甚至是上厕所无不思考着解决方案,然而在尝试了多种方案都无法实现需求时,放弃的念头也开始慢慢涌现。

那天是周五,晚上洗澡时突然灵光乍现:@DubboService 既能注册 Bean 到 Spring 容器中,又能注册服务,注解本身肯定没有这样的能力,那一定有其他方式来实现这些功能,只要我找到 Dubbo 中注册服务的实现,不就能只注册服务而不注册 Bean 了吗?

这就像 Spring 中的事务管理一样,使用 @Transactional 注解可以实现声明式事务管理,但不使用它,还可以使用 TransactionTemplate 实现编程式事务管理。

有了这个想法后,再次阅读 ServiceAnnotationPostProcessor 的源码,这次我终于明白了先前产生 BeanDefinitionStoreException 的根本原因是向 Spring 容器中注册了两个相同名称、ServiceBean 类型的 Bean。

ServiceBean 就是 Dubbo 用来注册服务的类。

也就是说,我只需要使用在默认实现生效的情况下使用 ServiceBean 来注册默认实现的服务即可,那怎么使用 ServiceBean 来注册服务呢?

这种事情交给 DeepSeek 吧。

删除「第三次尝试」中编写的配置类,新建配置类 DefaultServiceConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class DefaultServiceConfig {

@Autowired
private ModuleModel moduleModel;

@Autowired
private ApplicationContext applicationContext;

@Bean
@ConditionalOnBean(DefaultServiceImpl.class)
public ServiceConfig<MyService> myServiceExport() {
ServiceConfig<MyService> service = new ServiceConfig<>();
service.setScopeModel(moduleModel);

service.setInterface(MyService.class);
service.setRef(applicationContext.getBean(MyService.class));

// 手动注册
service.export();
return service;
}
}

进行以下测试,验证是否符合需求:

  1. 同时存在默认实现和自定义实现时:
    • 当前应用中,自定义实现生效,依赖注入成功,内部调用正常;
    • 其他应用使用接口进行远程调用时,自定义实现生效,并成功调用;
  2. 仅存在默认实现时:
    • 当前应用中,默认实现生效,依赖注入成功,内部调用正常;
    • 其他应用使用接口进行远程调用时,默认实现生效,并成功调用。

3. 总结与心得

在 Java 中,注解只是一个标记作用,本身不实现功能,其功能实现依赖于其他代码实现。如果一个注解具备 A 功能,就算不用这个注解,也一定有办法实现 A 功能。

在工作中处理不熟悉的代码逻辑时,应该在定位到代码范围后仔细阅读源码,充分理解其中的具体实现与细节,这跟在学习中阅读源码有很大的区别,不能用自觉对代码逻辑进行猜测,对细节也不能不求甚解,否则可能会花费许多无用功。