依赖注入 (Dependency Injection, DI) 是 Spring 实现控制反转概念的重要手段。Spring 提供了数种 DI patterns,其中最方便、最常用的是 field injection,它应该是许多人第一次写 Spring 专案时所使用的 pattern,虽然这方式简单易用,却有不少缺点。
例如你会发现, IntelliJ IDEA 会很贴心地告诉我们:
Field Injection is not recommended.
Spring Team recommends: "Always use constructor based dependency injection in tour beans. Always use assertions for mandatory dependencies".
为何 constructor injection 优于 field injection 呢?接下来我会解析这两种 pattern. (虽然 Spring 还有其他种注入方式,但我比较不常用,所以就不在此介绍了)
Field Injection
这种注入方式顾名思义,就是直接在 field 加上 @Autowired
@Componentpublic class HelloBean { @Autowired private AnotherBean anotherBean; @Autowired private AnotherBean2 anotherBean2; // ...
优点
简单方便易用,只要短短一行即可完成。程式码最少,读起来真舒服缺点
不易维护,因为简单方便,更容易产生 code smell 而不自知,例如 God Object不好写单元测试,测试环境需要透过 DI container 并加上许多 @Annotation 来初始化,看起来更像整合测试了。而且编译、执行时会多一些 overhead。不好理解测试,以下程式为例@RunWith(MockitoJUnitRunner.class)public class HelloBeanTest { @Mock private AnotherBean anotherBean; @Mock private AnotherBean2 anotherBean2; ... @Mock private AnotherBean10 anotherBean10; @InjectMocks private HelloBean helloBean; @Before public void setup() { ... } // Test cases...}
这是相当常见的 Mockito+Junit 单元测试写法,但容易造成疑问:
@RunWith(MockitoJUnitRunner.class)
是什么意思 ?@InjectMocks
做了什么 ?是否需要将待测物件 HelloBean
实体化呢 ?如果有两个 AnotherBean
类型的依赖怎么办 ?只有短短几行就让人产生诸多疑问,因此理解成本较高。虽然这种注入方式很简单方便,但写单元测试时就得还债了。若使用 constructor injection 则不易产生此问题,我们接着看下去:
Constructor Injection
此方式最大的特点是: Bean 的建立与依赖的注入是同时发生的
@Componentpublic class HelloBean { private final AnotherBean anotherBean; private final AnotherBean2 anotherBean2; // ... @Autowired public HelloBean(AnotherBean anotherBean, AnotherBean2 anotherBean2, ...) { this.anotherBean = anotherBean; this.anotherBean2 = anotherBean2; // ... } // ...}
优点1. 容易发现 code smell
假设我们需要注入十几个 dependecies,对比 field injection 的方式,这种方式暴露了 constructor 中含有过多的参数 (Long Parameter List),这是个很好的臭味侦测器,正常的开发者看到这么多参数肯定是会头痛的,这就表示我们需要想办法重构它,尽可能使它符合单一职责原则 (Single Responsibility Principle)。
优点2. 容易釐清依赖关係
一看到 constructor 就可以让开发者釐清这个物件所需要的 dependency,且缺一不可,进而缩小该物件在专案中的使用範围,事物的範围越窄,就越容易理解与维护。另外,我们也可以透过 constructor 注入假的依赖,进而容易写单元测试。
优点3. 容易写单元测试
一个简单的範例:
public class HelloBeanTest { private HelloBean helloBean; @Before public void setup() { AnotherBean anotherBean = mock(AnotherBean.class); AnotherBean2 anotherBean2 = mock(AnotherBean2.class); // ... helloBean = new HelloBean(anotherBean, anotherBean2, ...); } // Test cases...}
相较前面的例子,这种注入方式不需要太多 @Annotation,让测试程式码看起来更乾净了,我们也能轻鬆的用 new
来实体化待测物件、注入假依赖,整体而言看起来更 清楚、好理解,就算是不熟 Java 或 Mockito 的开发人员应该也能看得懂七八成,对于新人也比较好上手,而且也比较不会有误用 @Annotation 所产生额外成本,优秀的单元测试就应该如此。
优点4. Immutable Object
意思是 Bean 在被创造之后,它的内部 state, field 就无法被改变了。不可变意味着唯读,因而具备执行绪安全 (Thread-safety) 的特性。此外,相较于可变物件,不可变物件在一些场合下也较合理、易于了解,而且提供较高的安全性,是个良好的设计。因此,透过 constructor injection,再把依赖宣都告成 final,就可以轻鬆建立 Immutable Object。
缺点:循环依赖
只有在使用 constructor injection 时才会造成此问题。
举个简单的例子,若依赖关係图: Bean C → Bean B → Bean A → Bean C ,则会造成造成此问题,程式在 Runtime 会抛出BeanCurrentlyInCreationException
,更白话来说,这就是鸡生蛋 / 蛋生鸡的问题,而 Spring 容器初始化时无法解决这样的窘境,因此抛出例外并中断程式。
但是,Circular dependency 其实算是一种 Anti-Pattern,所以如果能够即时发现它,提早让开发人员意识到该问题重新设计此 bean,我个人认为这点反而蛮好的。
总结
本文介绍了两种依赖注入模式,它们各有好坏,也都能达到同样的目的,而比较常见的是 field injection,但不幸的这种方式较可能会写出 code smell。另外,Spring 官方团队建议开发者使用 constructor injection,虽然可能会有循环依赖异常,但无论在开发、测试方面,总体而言都是利大于弊,我也一直遵循这个模式。
References
本文转录自 https://kaisheng714.github.io/articles/analyzing-dependency-injection-patterns-in-spring