Spring
依赖注入思想分析
文章目录
- `Spring` 依赖注入思想分析
- 一、前言
- 二、控制反转(`Inversion of Control`)
- 1. 代码依赖初始化问题
- 2. 匿名内部类解决方案
- 3. 创建接口实现类方案
- 4. 问题深入
- 5. 定义父类解决问题1方案
- 6. 控制反转解决问题2方案
- 三、依赖注入(`Dependency Injection`)
- 1、依赖关系树
- 2、 依赖注入框架
- 3、 Spring的依赖注入
- 4、 ApplicationContext
- 参考文章
背景:
大二期间一直很疑惑,为什么
Spring
要把创建对象弄的这么复杂? 从各种对象的创建,装配,到销毁,什么事情都是
Spring
给干了,感觉给我带来的无非就是少了几个new
对象的语句,以及使用@Autowired
把对象注入了就能用,没感觉太过于方便在哪里。 大三了之后,就慢慢开始对 依赖注入 这种思想有了一点点似是而非的认识,突然觉得,这么做好像真的还挺好!
一、前言
在维基百科中,依赖注入(dependency injection
)简称DI
,定义如下:
define
:在软件工程中,依赖注入(dependency injection
)的意思为,给予调用方它所需要的事物。“依赖”是指可被方法调用的事物。依赖注入形式下,调用方不再直接指使用“依赖”,取而代之是“注入” 。“注入”是指将“依赖”传递给调用方的过程。在“注入”之后,调用方才会调用该“依赖”。传递依赖给调用方,而不是让让调用方直接获得依赖,这个是该设计的根本需求。在编程语言角度下,“调用方”为对象和类,“依赖”为变量。在提供服务的角度下,“调用方”为客户端,“依赖”为服务。
看起来是不是有点阅读困难?
在我们理解它在编程之中的含义之前,我们来聊聊什么是依赖,依赖是指依靠某种东西来获得支持,比如我会说我们对手机的依赖程度过高。那么如果我们将依赖,放入在编程的世界中,又是什么表现形式呢?
example
:当 class A
使用 class B
的某些功能的时候,则表示 class A
具有 class B
的依赖,在Java
中,使用其他class
的方法前,我们需要创建对应的 class
的对象。(即 class A
需要创建一个 class B
的实例)
结论:因此,将创建对象的任务转移给其他 class,并直接使用依赖项的过程,被称为“依赖项注入”。
了解依赖注入之后,本文再来聊聊 Spring
框架中DI
,Spring Inversion of Control
(控制反转),ApplicationContext
(应用上下文)的故事。
二、控制反转(Inversion of Control
)
Tips
:控制反转(IOC
)是一种编程思想,它改变了传统程序的执行流程。想象一下,传统程序像是你亲自开车,而IOC
则像是坐公交。在IOC
中,你不再亲自控制每个步骤,而是把控制权交给框架公交车,框架会帮你处理很多琐碎的事情,你只需专注于自己的目的地业务逻辑。比如Spring
框架帮你管理对象、处理依赖关系,使代码更简洁、灵活。作者:阿妮亚学SqlSugar
链接:https://www.zhihu.com/question/381216328/answer/3366856030
让我们简单来了解学习一下控制反转在 Java
代码中是如何体现这一思想的,我会尽可能讲述的简单,请各位细细体会每一次我改变的意图,和我们的解决方案,这个对于理解什么是控制反转的思想很重要。
1. 代码依赖初始化问题
通常来说,我们实例化一个对象的方式一般是使用 new
这个关键字来进行实例化一个对象。如下代码展示
Car car = new Car();
这样,我们就实例化了一辆车,显然,这个车是有一定基本属性的,以及这辆车我们也可以将其分割成好几大类,车由轮胎,引擎,车身,电气设备等等组成。在这里为了类尽可能简单,我们定义 Engine
接口来模拟汽车引擎,然后将 engine
对象作为变量成员放入 Car
类中,这样,我们构建了一个依赖关系 Car
由 Engine
组成。
定义接口 Engine
,因为引擎可能有多个不同种类的引擎,但是只要符合有 trunOn()
方法的引擎,我们就都可以拿来用。
public interface Engine{
void turnOn();
}
定义类 class Car
。
package org.example.car;
/**
* @ClassName Car
* @Description TODO
* @Author 枫飘长安
* @Date 2024/3/30 11:42
* @Version 1.0
**/
public class Car {
private Engine engine;
public Car() {
}
public void start() {
engine.turnOn();
}
}
显然,这个时候当我们调用 car.start()
方法时,会抛出NullPointerException
空指针异常,为啥呢?因为你连引擎都没有啊,你引擎怎么启动?
这个时候你可能会好奇,为什么啊,在 Car
中的 start()
方法中,不是有 engine.turnOn();
吗?但是你可能忽略了一个实时,你没有在 Car
的构造函数中初始化 engine
,也就是说,此时你的 engine
为 null
。
package org.example;
import org.example.car.Car;
public class Main {
public static void main(String[] args) {
Car car = new Car();
car.start();
}
}
2. 匿名内部类解决方案
当然,你可以使用匿名内部类的方式进行如下改进,在匿名内部类中重写turnOn()
方法,并成功初始化 engine
。
package org.example.car;
/**
* @ClassName Car
* @Description TODO
* @Author 枫飘长安
* @Date 2024/3/30 11:42
* @Version 1.0
**/
public class Car {
private Engine engine;
public Car() {
}
public void start() {
engine = new Engine() {
@Override
public void turnOn() {
System.out.println("Engine started");
}
};
engine.turnOn();
}
}
但是在工程中,我们往往不会这样去实现,以下,是使用匿名内部类的优劣。在一个庞大的工程体系中,匿名内部类往往是不太明智的选择。
匿名内部类优劣性
优势:
- 简洁性:如果只是简单地需要一个具有特定行为的
Engine
实例,并且只需要在一个地方使用,匿名内部类能很好地满足需求,无需单独创建一个新的类文件。- 灵活性:这种方式允许你在使用时即时定义和覆盖父类或接口的方法,根据当前方法的需求提供定制化的行为。
劣势:
- 可读性和可维护性:如果这个匿名内部类实现的功能较为复杂,或者有多个方法需要重写,代码量就会变得很大,降低代码的可读性和可维护性。
- 复用性:如果同样的逻辑需要在多处使用,那么每次都需要重新定义匿名内部类,不如直接创建一个独立的、命名的类来得方便和高效。
- 测试难度:匿名内部类由于其匿名特性,不容易被单独提取出来进行单元测试。
3. 创建接口实现类方案
其次,我们可以创建新的类,来实现 Engine
接口
ElectricEngine
类
public class ElectricEngine implements Engine {
@Override
public void turnOn(){
System.out.println("电动引擎启动");
}
}
CombustionEngine
类
public class CombustionEngine implements Engine {
@Override
public void turnOn(){
System.out.println("燃油引擎启动");
}
}
好,此时我们修改 Car
的构造函数,比如说我用 ElectricEngine
实现,将我们的 engine
字段分配给一个实例化的 ElectricEngine
对象
public class Car {
private Engine engine;
public Car(){
this.engine = new ElectricEngine();
}
public void start(){
engine.turnOn();
}
}
完成上述操作后,启动我们的 Main
函数,这个时候控制台打印信息,我们也达成了我们的目的。
电动引擎启动
创建接口实现类优劣性
优势:
清晰的职责划分:接口定义了一组行为规范,接口实现类负责具体的实现细节,符合面向对象设计原则中的单一职责原则和接口隔离原则,有助于提高代码的可读性和可维护性。
高内聚低耦合:不同的类可以实现同一个接口,可以根据实际需求灵活替换不同的实现类,增强了系统的扩展性和解耦程度。
易于测试:可以通过
Mock
框架轻松模拟接口实现类的行为,便于单元测试和集成测试。代码复用:接口实现类可以在多个地方被引用和复用,避免代码重复编写。
劣势:
- 额外的类结构:相比于匿名内部类,创建接口及其实现类需要更多的类结构和代码组织,可能会增加项目的复杂度。
- 过度设计风险:如果不恰当的设计过多接口,可能会导致系统过于复杂,尤其是在需求不明确或变化频繁的情况下,可能导致接口难以适应后续需求变更。
- 运行时类型转换:在使用多态特性时,有时需要进行类型判断或类型转换,增加了程序的复杂性。
4. 问题深入
反思:我们解决了空指针异常,但是,我们胜利了吗?这就是完美的银弹了吗?
问题延伸:在解决问题的同时,我们又引入了另一个问题。尽管我们通过抽象
Engine
接口,然后通过不同的Engine
实现类来负责不同类型引擎的业务逻辑,的确是很好的设计策略。但是细心的伙伴可能已经发现了,我们Car
类的构造函数中将engine
声明为ElectricEngine
,这将导致所有车都有一个电动引擎。假如我们现在要创建不同的汽车对象,它有一个燃油引擎,我们将不得不改变我们的设计。比较常见的方法是创建两个独立里的类,各司其职,在他们的构造函数中将engine
分配给Engine
接口的不同实现;
例如:
创建 CombustionCar
燃油车类
public class CombustionCar {
private Engine engine;
public CombustionCar() {
this.engine = new CombustionEngine();
}
public void start() {
engine.turnOn();
}
}
创建 ElectricCar
电动车类
public class ElectricCar {
private Engine engine;
public ElectricCar() {
this.engine = new ElectricEngine();
}
public void start() {
engine.turnOn();
}
}
通过上述一顿操作,我们大大增加了我们的代码量,以后更是幸福,因为每多一种引擎,都得重新创建一个对应该引擎的车类,我们程序猿实在是太幸福了!
如果只是日常需求,我们已经可以不用动以上的代码了,因为这已经足够使用了,但是这种解决方案显然不是我写这篇文章的目的。
Tips
:从设计角度来说,当前代码是糟糕的,有两个问题始终没有得到解决。
- 在两个不同的类中,都有重复的
start()
方法,代码冗余。- 我们需要为每一个新的
Engine
实现类创建一个新的类。
随着整个工程的庞大化,第二个问题会越来越严重,成为**“屎山”**的一部分。
5. 定义父类解决问题1方案
带上上述两个问题继续思考,先聚焦到问题一,代码冗余问题。
这个时候Java
三大特性之一的继承知识就可以用上了!我们可以创建一个父类 Car
,把公共代码抽取到父类中,就解决了第一个问题。
由于 Engine
字段是私有的,我们在父类 Car
的构造函数中接受一下 Engine
对象,并进行赋值即可。
Car
类
public class Car {
private Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public void start() {
engine.turnOn();
}
}
CombustionCar
类
public class CombustionCar extends Car{
public CombustionCar() {
super(new CombustionEngine());
}
}
ElectricCar
类
public class ElectricCar extends Car {
public ElectricCar() {
super(new ElectricEngine());
}
}
通过使用父类并继承的方式,我们成功解决了代码重复的问题,测试一下我们的 Main
函数
package org.example;
import org.example.engine.CombustionCar;
import org.example.engine.ElectricCar;
public class Main {
public static void main(String[] args) {
CombustionCar combustionCar = new CombustionCar();
combustionCar.start();
ElectricCar electricCar = new ElectricCar();
electricCar.start();
}
}
能够得到输出
电动引擎启动
燃油引擎启动
此时,我们应该有的项目结构如下
6. 控制反转解决问题2方案
现在,我们换一个角度看待问题2,为什么我们需要去关注 CombustionCar
和 ElectricCar
呢?这些不都是 Car
吗?于是,我们把关注点重新回到我们的 Car
上面来。
我们现在已经允许在实例化 Car
对象的时候,将 Engine
对象作为构造函数的参数传入,其实这样已经消除了为每个 Engine
对象创建新的 Car
子类的问题。父类 Car
依赖于 Engine
接口,并不需要知道任何关于 Engine
的实现。
通过带有Engine
参数的构造函数,我们已将要使用哪个Engine
实现的决定从Car
类本身(最初由CombustionEngine
决定)更改为实例化Car
类的客户端。决策过程的这种逆转称为IoC原则
。现在,由客户端控制使用哪种实现,而不是由Car
类本身控制使用哪种Engine
实现。
Tips
:这个思想理解有点绕,大家可以结合示例代码进行理解
public static void main(String[] args) {
/**
* 老法子
* 为每一类型发送机的车创建类,然后实现父类car,然后在构造函数传入自己的引擎,然后调用start()
*/
CombustionCar combustionCar = new CombustionCar();
combustionCar.start();
ElectricCar electricCar = new ElectricCar();
electricCar.start();
/**
* 控制反转思想
* 把自己看作实例化car的客户端,需要什么引擎,直接传入相关对象
*/
CombustionEngine combustionEngine = new CombustionEngine();
Car combustionCar = new Car(combustionEngine);
combustionCar.start();
ElectricEngine electricEngine = new ElectricEngine();
Car electricCar = new Car(electricEngine);
electricCar.start();
}
从上面的例子我们可以看到,实例化 Car
类的客户端可以控制所使用的Engine
实现,并且取决于将哪个Engine
实现传递给Car
构造函数,Car
对象的行为发生巨大变化。接下来就是依赖注入的领域了,两者结合,妙不可言。
三、依赖注入(Dependency Injection
)
Tips
:在上面控制反转的知识点,解决了由谁决定使用哪种Engine
实现的问题,但是不可避免,我们也更改了实例化一个Car
对象的步骤;
Step-1
:最开始,我们实例化Car
不需要参数,因为在它的构造函数里面已经为我们new
了Engine
对象。
Step-2
:使用IoC
方法之后,我们要求在实例化一个Car
之前,我们需要先创建一个Engine
对象,并作为参数传递给Car
构造对象。
区别:
- 最初,我们首先实例化
Car
对象,然后实例化Engine
对象。但是,使用IoC
之后,我们首先实例化Engine
对象,然后实例化Car
对象;- 因此,我们在上面的过程中创建了一个依赖关系。
- 这种依赖关系不是指编译时候
Car
类对Engine
接口的依赖关系,相反,我们构建了一个运行时依赖关系。- 在运行时,实例化
Car
对象之前,必须首先实例化Engine
对象。
思考:我们构建的这种运行时依赖关系会给我们带来什么样的影响呢?
1、依赖关系树
某一个具体的依赖对象大家可以理解为Spring中的bean,对于两个有依赖关系的bean,其中被依赖的那个bean,我们把它称为依赖对象
我们用图形化的方式来看看它们之间的依赖关系,其中图形的节点代表对象,箭头代表依赖关系(箭头指向依赖对象)。对于我们我的Car
类,依赖关系树非常简单:
如果依赖关系树的终端结点还有自己的附加依赖关系,那么这个依赖关系树将变得更加复杂。
现在再看我们上面的例子,如果 CombustionEngine
还有其他依赖对象,我们首先需要创建CombustionEngine
的依赖对象,然后才能实例化一个CombustionEngine
对象。这样在创建Car
对象时候,才能将CombustionEngine
传递给Car
的构造函数;
//凸轮轴
public class Camshaft {}
//机轴
public class Crankshaft {}
public class CombustionEngine implements Engine {
//凸轮轴
private Camshaft camshaft;
//机轴
private Crankshaft crankshaft;
public CombustionEngine(Camshaft camshaft, Crankshaft crankshaft) {
this.camshaft = camshaft;
this.crankshaft = crankshaft;
}
@Override
public void turnOn() {
System.out.println("燃油引擎启动");
}
}
经过我们改造,我们现在的依赖关系树变为下面的样子
2、 依赖注入框架
随着我们不断引入更多的依赖关系,这种复杂性将继续增长。为了解决这个复杂问题,我们需要基于依赖关系树抽取对象的创建过程。这就是依赖注入框架。
一般来说,我们可以把这个过程分为三个部分:
- 声明需要创建的对象需要哪些依赖对象
- 注册创建这些依赖对象所需要的类
- 提供一种使用1和2两点思想创建对象的机制
通过反射,我们可以查看 Car
类的构造函数,并且知道它需要一个 Engine
参数。因此为了创建Car
对象,我们必须创建至少一个Engine
接口的实现类用作依赖项来使用。在这里,我们创建一个CombustionEngine
对象(为了方便,暂时当做只有一个实现类,bean冲突问题待会再说)来声明它作为依赖项来使用,就满足Car
对象创建时的需求。
其实,这个过程是递归的,因为CombustionEngine
依赖于其他对象,我们需要不断重复第一个过程,直到把所有依赖对象声明完毕,然后注册创建这些依赖对象所需要的类。
第三点其实就是将前面两点思想付诸实施,从而形成一种创建对象的机制
举个例子:
比如我们需要一个
Car
对象,我们必须遍历依赖关系树并检查是否存在至少一个符合条件的类来满足所有依赖关系。例如,声明
CombustionEngine
类可满足Engine
节点要求。如果存在这种依赖关系,我们将实例化该依赖关系,然后移至下一个节点。如果有一个以上的类满足所需的依赖关系,那么我们必须显式声明应该选择哪一种依赖关系。
一旦我们确定所有的依赖关系都准备好了,我们就可以从终端节点开始创建依赖对象。
对于
Car
对象,我们首先实例化Camshaft
和Crankshaft
ーー因为这些对象没有依赖关系ーー然后将这些对象传递给CombustionEngine
构造函数,以实例化CombunstionEngine
对象。最后,我们将CombunstionEngine
对象传递给Car
构造函数,以实例化所需的Car
对象。了解了
DI
的基本原理之后,我们现在可以继续讨论Spring
如何执行DI
。
3、 Spring的依赖注入
Tips
:Spring
的核心是一个DI
框架,它可以将DI
配置转换为Java
应用程序。
在这里我们要阐述一个问题:那就是库和框架的区别。库只是类定义的集合。
背后的原因仅仅是代码重用,即获取其他开发人员已经编写的代码。这些类和方法通常在域特定区域中定义特定操作。例如,有一些数学库可让开发人员仅调用函数而无需重做算法工作原理的实现。
框架通常被认为是一个骨架,我们在其中插入代码以创建应用程序。许多框架保留了特定于应用程序的部分,并要求我们开发人员提供适合框架的代码。在实践中,这意味着编写接口的实现,然后在框架中注册实现。
4、 ApplicationContext
在 Spring
中,框架围绕 ApplicationContext
接口实现上一节中概述的三个 DI
职责。通常这个接口代表了一个上下文。因此,我们通过基于 java
或基于 xml
的配置向 ApplicationContext
注册合适的类,并从 ApplicationContext
请求创建 bean
对象。然ApplicationContext
构建一个依赖关系树并遍历它以创建所需的 bean
对象。
Applicationcontext
中包含的逻辑通常被称为 Spring
容器。通常,一个 Spring
应用程序可以有多个 ApplicationContext
,每个 ApplicationContext
可以有单独的配置。例如,一个 ApplicationContext
可能被配置为使用 CombustionEngine
作为其引擎实现,而另一个容器可能被配置为使用 ElectricEngine
作为其实现。
参考文章
- 为什么我们需要依赖注入? - 知乎 (zhihu.com)
- 据说,80%的人没有真正理解了Spring的依赖注入 - 知乎 (zhihu.com)
- 依赖注入是什么?如何使用它? (freecodecamp.org)