构造器注入与循环依赖

哈德韦

共 6652字,需浏览 14分钟

 ·

2021-06-23 11:41

接上一篇《邪恶的字段注入》,再谈谈构造器注入。

上一篇《邪恶的字段注入》主要是针对随处可见的字段注入现状提出了批评,并强烈推荐使用构造器注入。在历数各种字段注入的缺点,也就是相对来说使用构造器注入的优点后,只发现了 java 世界里构造器注入的一个缺点,那就是得多写点代码(当然也以 js 工程师的角度,对 java 工程师进行了劝退,因为 js 的构造器注入连这个仅有的缺点都没有)。

但是实际上,即便在 java 的世界里需要多写点代码,也根本不是问题,因为有 IDE 来帮忙。那还等什么,快来改造吧!

怎么做

将光标定位到字段注入代码处,按下 Alt + Enter:


在弹出的 popup 里选择第一个,回车,就自动完成了字段注入到构造器注入的改造:

可以看到,使用构造器注入,连 @Autowired 关键字都可以省略。

构造器注入还有什么缺点?

上面用 IDE 的强大功能抵消了《邪恶的字段注入》中提到的构造器注入的唯一缺点,不过仍然有人不喜欢构造器注入,说原因是构造器注入在极端情况下有循环依赖异常问题。但是对于循环依赖,就可以使用字段注入,并且是 Spring 官方文档推荐的方式。似乎 ——

构造器注入不如字段注入强大。

这其实也是误解了。首先,对于循环依赖,这是明显的代码坏味道,应该首先反思设计,是不是有明显的 BUG?这里涉及到一个价值观问题,对于缺陷或者异常,是尽早暴露出来 fail fast,还是尽量把问题隐藏得越久越好?我站在 fail fast 这一边,所以我认为,构造器注入即使不如字段注入强大也不要紧,因为这个时候首先应该反思代码设计。

但是,构造器注入并非不如字段注入强大,通过在构造器注入里使用 @Lazy 注解,也可以解决循环依赖异常问题。

总结:无论什么场景,构造器注入都优于字段注入。


以下详细说说循环依赖的异常问题:

什么是循环依赖?

当 bean A 依赖 bean B,并且 bean B 也依赖 bean A 时,就产生了循环依赖:

Bean A -> Bean B -> Bean A

当然,有时候这种循环依赖比较含蓄(隐藏得比较深):

Bean A -> Bean B -> Bean C -> Bean D -> Bean E -> Bean A

Spring 中发生了什么?

当 Spring 上下文加载所有 bean 时,会依照一定的顺序创建 bean 从而使得他们可以完全工作。对于没有循环依赖的情况,比如这样的依赖关系:

Bean A -> Bean B -> Bean C

Spring 会先创建 bean C,然后是 bean B(同时将 bean C 注入 B),最后创建 bean A(同时把 bean B 注入 A)。

但是当有循环依赖时,Spring 无法决定那个 bean 应该最先被创建出来,因为它们相互依赖。在这样的情况下,Spring 只好在加载上下文时抛出 BeanCurrentlyInCreationException 异常。

当然,这种异常只会在你使用构造器注入时抛出;如果使用别的注入方式的话,由于依赖只会在它们实际被使用时而非在上下文加载时被注入,所以不会有异常抛出。

举个例子

定义两个互相依赖的 bean,并且使用构造器注入:

@Component
public class CircularDependencyA {

private CircularDependencyB circB;

@Autowired
public CircularDependencyA(CircularDependencyB circB) {
this.circB = circB;
}

}

@Component
public class CircularDependencyB {

private CircularDependencyA circA;

@Autowired
public CircularDependencyB(CircularDependencyA circA) {
this.circA = circA;
}
}

接着我们写一个配置类用来测试,就命名为 TestConfig 吧,它指定了扫描组件的基准包名。假设上面的 bean 定义在包“com.hardway.circular”里:

@Configuration
@ComponentScan(basePackages = { "com.hardway.circular" })
public class TestConfig {
}

最后我们写一个 JUnit 测试来检查循环依赖。测试可以是空的,因为我们只要触发上下文加载即可检测到循环依赖。

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { TestConfig.class })
public class CircularDependencyTest {

@Test
public void givenCircularDependency_whenConstructorInjection_thenItFails() {
// 测试可以是空的,因为我们只要触发上下文加载即可检测到循环依赖。
}
}


尝试运行测试,得到如下异常:

BeanCurrentlyInCreationException: Error creating bean with name 'circularDependencyA':
Requested bean is currently in creation: Is there an unresolvable circular reference?


绕过的方法


前面说了,发生循环依赖,首先应该重新思考代码设计。不过这里还是完整地列出所有可以绕过异常的办法。

重新设计

如果发生循环依赖,很有可能出现了设计问题:职责划分不合理。这时候应该重新设计组件让它们的层次结构变得合理和良好,从而不需要循环依赖。

如果真的因为某些原因不能重新设计组件(比如遗留代码、不允许被修改的代码、没时间没资源做完整的重新设计等……),那么你可以尝试:

使用 @Lazy

打破环的简单办法是让 Spring 对 bean 进行懒惰的初始化,即:不要完全初始化 bean,而是创建一个代理来将它注入到其他 bean 中。被注入的 bean 仅仅在第一次被使用时才会完全初始化。

比如你可以把前面的组件 CircularDependencyA 改造成这样:

@Component
public class CircularDependencyA {

private CircularDependencyB circB;

@Autowired
public CircularDependencyA(@Lazy CircularDependencyB circB) {
this.circB = circB;
}
}

如果你再次运行测试,就会看到错误不见了。

使用设置器/字段注入

Spring 官方文档提出的,也是最流行的(但是我不推荐)绕过方式是使用设置器注入。简单来说就是你将 bean 们的缠绕方式从构造器注入改成设置器注入(或者字段注入),就能搞定问题。通过这个方式 Spring 只创建 bean,但是在依赖被真正用到之前都不会事先注入。

我们可以把前面的例子中的类改成使用设置器注入,然后添加另一个 message 字段到 CircularDependencyB 中以便于写一个合适的单元测试:

@Component
public class CircularDependencyA {

private CircularDependencyB circB;

@Autowired
public void setCircB(CircularDependencyB circB) {
this.circB = circB;
}

public CircularDependencyB getCircB() {
return circB;
}
}

@Component
public class CircularDependencyB {

private CircularDependencyA circA;

private String message = "Hi!";

@Autowired
public void setCircA(CircularDependencyA circA) {
this.circA = circA;
}

public String getMessage() {
return message;
}
}


单元测试改成这样:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { TestConfig.class })
public class CircularDependencyTest {

@Autowired
ApplicationContext context;

@Bean
public CircularDependencyA getCircularDependencyA() {
return new CircularDependencyA();
}

@Bean
public CircularDependencyB getCircularDependencyB() {
return new CircularDependencyB();
}

@Test
public void givenCircularDependency_whenSetterInjection_thenItWorks() {
CircularDependencyA circA = context.getBean(CircularDependencyA.class);

Assert.assertEquals("Hi!", circA.getCircB().getMessage());
}
}


上面用到一些注解,解释如下:

@Bean:告诉 Spring 框架必须使用这些方法来获取需要被注入的 bean 的实现。

@Test: 测试要从上下文中获取 CircularDependencyA 这个 bean,然后断言它的 CircularDependencyB 已经被稳妥地注入了,并检查它的 message 属性的值。

使用 @PostConstruct


另一个打破环的方式是通过在众多 bean 中的其中一个上面应用 @Autowired 来注入依赖,然后在一个方法上面应用 @PostConstruct 注解来设置其他的依赖。

比如有这样的 bean 相关的代码:

@Component
public class CircularDependencyA {

@Autowired
private CircularDependencyB circB;

@PostConstruct
public void init() {
circB.setCircA(this);
}

public CircularDependencyB getCircB() {
return circB;
}
}
@Component
public class CircularDependencyB {

private CircularDependencyA circA;

private String message = "Hi!";

public void setCircA(CircularDependencyA circA) {
this.circA = circA;
}

public String getMessage() {
return message;
}
}


然后可以运行之前写好的同样的测试,你可以看到依赖能够被稳妥地注入并且不会抛出循环依赖异常。

实现 ApplicationContextAware 和 InitializingBean

如果有 bean 实现了 ApplicationContextAware,则这个 bean 就能够访问 Spring 上下文并能够从中抽取到其他的 bean。而通过实现 InitializingBean 可以指示这个 bean 在当其所有的属性都被设置好后必须执行一些动作;在这个场景下我们手动设置依赖。

bean 相关的代码像这样:

@Component
public class CircularDependencyA implements ApplicationContextAware, InitializingBean {

private CircularDependencyB circB;

private ApplicationContext context;

public CircularDependencyB getCircB() {
return circB;
}

@Override
public void afterPropertiesSet() throws Exception {
circB = context.getBean(CircularDependencyB.class);
}

@Override
public void setApplicationContext(final ApplicationContext ctx) throws BeansException {
context = ctx;
}
}
@Component
public class CircularDependencyB {

private CircularDependencyA circA;

private String message = "Hi!";

@Autowired
public void setCircA(CircularDependencyA circA) {
this.circA = circA;
}

public String getMessage() {
return message;
}
}

再一次运行之前的测试可以验证仍然能够通过并且没有异常。

总结

本文接续《邪恶的字段注入》,强烈推荐使用构造器注入,并且手把手地讲解了如何将字段注入改造成构造器注入,以及反驳了构造器注入不如字段注入的观点。同时对循环依赖这个极端场景进行了举例说明,列举了所有可能的绕过方法,并展示了通过 @Lazy 注解,构造器注入仍然优于字段注入,但是最优的方案是重新设计代码,因为出现循环依赖是一个设计缺陷的表征。

尽管 Spring 官方文档更喜欢用设置器注入和字段注入,但是我仍然更加推崇构造器注入。因为设置器/字段注入以及其他的几种绕过方法基本上都是阻拦了 Spring 对 bean 的初始化和注入的管理,然后手动来进行,这违背了使用 Spring 的初衷。


浏览 25
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报