做JAVA的基本上都用过Spring,而IoC是Spring最核心的模块之一。那IoC具体有什么用,Spring又是如何做到IoC的呢?这是本文要探索的话题。
首先我们要明确“依赖”的概念。所谓依赖,说直白点就是:A用了B,那A就依赖B。换成程序世界的说法,如果A类里面出现了B类有关的代码(删除B类,编译A类会报错),那A就依赖B。
打个比方,如果员工小明上班需要乘坐公交车,从家里到公司,那小明就依赖了公交车。抽象成代码大概是这样:
public class Worker() {
private String name;
private String home;
private String office;
// 这里依赖了Bus类
private Bus bus = new Bus();
public void goToWork() {
bus.take(name, home, office);
}
}
复制代码
我们知道,依赖是一种耦合,而过多的耦合对程序是有害的,代码架构的本质,就是尽量去降低耦合。试想一下,如果有一天员工小明升职加薪了,自己买了一辆小轿车代步,那凡是用到公交车的地方(比如上班、下班、接孩子、去商场、回家等等)岂不是需要修改代码,把Bus换成Car?如果某一天又想步行或者骑自行车呢?
有了上面这个耦合的问题,于是业界的大佬们就想办法来解决这个问题。设计模式六大原则里面有一个依赖倒置原则(Dependence Inversion Principle)。
所谓依赖倒置原则,就是把原本耦合的A和B分开,中间加一个“抽象层”。这样A只需要依赖抽象层,并不需要关心具体实现,只要它能完成自己需要的功能就行了。而B也只依赖抽象层,实现这个功能就行了。
如果A依赖B,我们称A为“上层”,B为“下层”,依赖倒置原则强调上层模块不应该依赖下层模块,两者应依赖其抽象。
仍然是上面员工小明的例子,其实他上班需要的并不是一个公交车,而是一个“交通工具”,这个交通工具可以是自行车、电动车、汽车等等,只要它能够把小明从家里带到公司就可以了。我们改一下代码,变成了这样:
// 定义抽象类
public interface Vehicle {
void take(String name, String home, String office)
}
// 下层模块的细节,依赖抽象
public class Bus implements Vehicle {
@Override
public void take(String name, String home, String office) {
// 实现细节
}
}
public class Worker() {
private String name;
private String home;
private String office;
// 上层依赖了抽象类Vehicle
private Vehicle vehicle = new Bus();
public void goToWork() {
vehicle.take(name, home, office);
}
}
复制代码
看到这段代码也许你会问:那这里Worker类里面不是还是要new一个Bus吗?那还是依赖了呀,以后如果换成其它交通工具仍然需要改代码。
别急,这就是我们下面会讲到的控制反转要解决的问题。
控制反转(Inversion of Control)也就是我们说的IoC了。要理解IoC,需要弄清楚到底什么被反转了?如何反转的?
上面的示例代码我们可以看到,即使我们引入了一个抽象层,但当一个Worker对象实际要使用Vehicle的时候,它还是必须得创建一个具体的对象,它可能是一个Bus,也可能是一个Car等。但这样造成的问题是,依赖没有被彻底分离,两者还是存在耦合。
那如何把它们彻底分离呢?答案就是把创建具体的Vehicle对象交给第三方去做。这样Worker不用管如何创建的交通工具,而Bus也不用管自己是如何被创建的。
想想我们生活中就有这样的例子,员工小明要坐公交车,他不用每次都自己去造一辆公交车吧,只需要去公交车站,等公交车公司的调度就行了。而公交车工厂也跟小明没有任何关系,它的职责就是生产好公交车,交付给公交车公司。通过引入了“公交车公司”这个第三方,小明和公交车工厂就完全解耦了。
反转的是什么?对象如何获取它的依赖对象这件事情上,控制权反转了。从自己创建,反转成了第三方管理。
控制反转的进一步含义,不仅仅是获取,还有整个要依赖对象的生命周期(包括创建、维护、销毁等),控制权都被反转了。
从代码设计来看,一个简单的解决方式是,把具体的对象通过方法参数传进来,这样就不强依赖了:
public class Worker() {
private String name;
private String home;
private String office;
// 通过方法传进来
public void goToWork(Vehicle vehicle) {
vehicle.take(name, home, office);
}
}
复制代码
但这样会带来一个问题,就是给调用端带来了麻烦,相当于把对Bus的依赖,从Worker类转移到了它的调用端,那它的调用端也会强依赖Bus,这本不属于调用端的职责,所以没有从根本上解决问题。而且每次调用都要传一个Vehicle对象进来,很不合理,管理对象也比较麻烦。
那你可能会想,我搞个第三方容器不就行了吗,这样每次去第三方容器里面拿:
public class Worker() {
private String name;
private String home;
private String office;
// 第三方容器
private VehicleContainer container;
// 通过容器取
public void goToWork() {
container.getTodayVehicle().take(name, home, office);
}
}
复制代码
这样当然也能解决,但不是最优雅的解决方案,因为你每个Bean都需要依赖Container。那我们能不能Worker类不依赖任何东西,包括Container,实现上班这个功能呢?当然可以,且听下文分析。
更优雅的方案就是使用依赖注入(Dependency Injection)。我不想使用Container,每次还要主动去拿。我想在自己被创建的时候(或者创建后),我所依赖的对象就自动被设置好了。
同时,这还是一种“无侵入”的方式,我们的业务代码里面可以不用写任何关于IoC的代码。这样即使我们某一天换了IoC框架,我们的代码也不需要做任何修改。
实现依赖注入大概有三种方式:构造器注入,方法注入和属性注入。
顾名思义,就是通过构造器的方式,把依赖的对象注入进来。这样在new一个对象的时候,就完成了它依赖的对象的装配。
public class Worker() {
private String name;
private String home;
private String office;
private Vehicle vehicle;
// 通过构造器把要依赖的对象传进来
public Worker(Vehicle vehicle) {
this.vehicle = vehicle;
}
// 直接用
public void goToWork() {
vehicle.take(name, home, office);
}
}
复制代码
另一种方式是使用方法注入,一般是使用要依赖的对象对应的属性的setter方法来注入。比如:
public class Worker() {
private String name;
private String home;
private String office;
private Vehicle vehicle;
// 通过setter方法注入
public void setVehicle(Vehicle vehicle) {
this.vehicle = vehicle;
}
// 直接用
public void goToWork() {
vehicle.take(name, home, office);
}
}
复制代码
构造器和setter方法都有些麻烦,需要写额外的代码。要是容器可以通过反射直接注入进来就好了,这样代码看起来比较干净。比如:
public class Worker() {
private String name;
private String home;
private String office;
// 容器直接通过反射把相应的对象注入进来
private Vehicle vehicle;
// 直接用
public void goToWork() {
vehicle.take(name, home, office);
}
}
复制代码
前面反复提到的一个词,叫“第三方容器”,其实就是IoC容器。所谓IoC容器,就是可以生产和管理要依赖的对象,然后通过合适的时机注入进来。
IoC容器并不等于Spring。还有其它IoC容器框架,比如google开发的Guice等,甚至我们可以自己开发一个轻量级的IoC容器。其实IoC容器实现起来并不难。
只是我们平常用Spring比较多,它又提供了非常好用的IoC功能,所以大多数项目,我们都是用Spring的IoC了。Spring作为IoC容器还是非常成熟和稳定的。
2004年,Martin Fowler探讨了同一个问题,既然IOC是控制反转,那么到底是“哪些方面的控制被反转了呢?”,经过详细地分析和论证后,他得出了答案:“获得依赖对象的过程被反转了”。控制被反转之后,获得依赖对象的过程由自身管理变为了由IOC容器主动注入。于是,他给“控制反转”取了一个更合适的名字叫做“依赖注入(Dependency Injection)”。他的这个答案,实际上给出了实现IOC的方法:注入。所谓依赖注入,就是由IOC容器在运行期间,动态地将某种依赖关系注入到对象之中。
所以,依赖注入(DI)和控制反转(IOC)是从不同的角度的描述的同一件事情,就是指通过引入IOC容器,利用依赖关系注入的方式,实现对象之间的解耦。控制反转是解决问题的一种思路和方法论,依赖注入是它的具体实现方式。
首先要明确Bean的概念,Spring把需要纳入IoC容器观察的对象称为Bean。
一些对象是不用交给Spring管理的,比如POJO对象,类似DO、DTO等对象(包括DDD中的领域模型),它们都是可以在程序里面通过new或者builder来创建的,因为创建的时候要给它们的一些属性赋值,而且在使用这些类时,没法使用“依赖倒置原则”。
首先第一步要声明Bean,这样Bean才能被Spring的IoC容器管理。声明Bean有很多种方式,在一开始,Spring是使用XML的方式来声明一个Bean:
<bean id="myVehicle" class="test.spring.bean.Bus" />
<bean id="worker" class="test.spring.bean.Worker">
<property name="vehicle">
<ref bean="myVehicle" />
</property>
</bean>
复制代码
这样以后如果要依赖的Bean变了,只需要修改XML文件就行了。
后来由于XML文件难以阅读和维护,Spring开始支持用注解的方式定义Bean。我们在定义具体实现类的时候,可以在class上面加上@Component注解,然后配置好Spring的自动扫描路径,这样Spring就能够自己去扫描相应的类,纳入IoC容器中进行管理了。
@Component
public class A {}
复制代码
@Component的语义其实不是很明确,因为“万物皆可为组件”。它其实是一个元注解,也就是说,可以注解其它注解。Spring提供了@Controller、@Service、@Repository、@Aspect等注解来供特定功能的Bean上面使用。
我们自己也可以声明一些类似的注解,如果我们使用DDD,也可以用@Component声明一些诸如@ApplicationService、@DomainService之类的注解。
SpringBoot默认的扫描路径是启动类当前的包和子包。我们可以通过@ComponentScan和@ComponentScans来配置包的扫描路径。
另一种方式是通过在方法上声明@Bean注解来声明一个Bean。这个时候一般是会与@Configuration一起来配合使用的。
@Configuration
public class MyConfig {
@Bean
public B getB() {
return new B();
}
}
复制代码
一般只有在对框架提供的Bean有一些特殊配置的时候,才会使用@Bean注解。比如数据库配置等。
使用Bean也有很多种方式。XML就不说了,上面例子也展示了如何在XML里配置Bean的注入。
Spring比较推荐的是使用构造器注入,因为构造器注入能够在启动的时候就检查要依赖的对象是否存在,如果不存在,会启动失败并且抛出以下异常:
Parameter 0 of constructor in com.example.springbase.bean.A required a bean of type 'com.example.springbase.config.B' that could not be found.
The following candidates were found but could not be injected:
- User-defined bean method 'getB' in 'MyConfig' ignored as the bean value is null
Action:
Consider revisiting the entries above or defining a bean of type 'com.example.springbase.config.B' in your configuration.
复制代码
这样我们就可以更早地发现依赖问题,而不用在运行时才发现要依赖的对象没有被注入进来,发生一些空指针异常。
另一种方式是注解注入,注解注入的好处是代码简洁,不用专门写构造器。Spring支持三个注解:
其中@Resource和@Inject都是在JSR中定义的规范,主流的IoC框架都已经支持了这两个规范。这两个规范的区别在于,查找Bean的方式不同。
@Resource是先通过名称匹配,找不到再通过类型匹配,找不到再通过结合@Qualifier来匹配。
而@Inject是先通过类型匹配,找不到再通过Qualifier来匹配,找不到再通过名称匹配。如果要使用@Inject,需要引入额外的包:
<dependency>
<groupId>javax.inject</groupId>
<artifactId>javax.inject</artifactId>
<version>1</version>
</dependency>
复制代码
@Autowired和@Inject的用法一致,唯一区别就是@Autowired属于Spring框架提供的注解。
其实最推荐的是使用JSR-330的规范,这样可以做到与框架无关。但是笔者发现大多数项目还是使用@Autowired居多,而且很难真正做到与Spring框架无关,因为@Component就是Spring提供的注解。我们平时经常使用的@Controller、@Service、@Repository、@Aspect等注解也都是Spring提供的。
所以如果要说推荐一个注解的话,笔者更推荐Spring的@Autowired。
还有一种方式,可以从Spring的上下文中直接拿Bean。这种方式一般用于:从一个不受Spring管理的对象中获取一个Bean。比如说二方包里面的代码,就有可能会有这种情况。
// 定义一个aware,持有一个static的context对象
@Component
public class MySpringContextAware implements ApplicationContextAware {
public static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
MySpringContextAware.applicationContext = applicationContext;
}
}
// A是不受Spring管理的
public class A {
// B是受Spring管理的
private B b;
public A() {
System.out.println("a init");
// 这样就可以在不受Spring管理的对象里面,获取到Bean了
this.b = (B) MySpringContextAware.applicationContext.getBean(B.class);;
System.out.println(b.hashCode());
}
}
复制代码
Spring默认Bean是单例的。因为绝大多数Bean其实是“无状态的”,比如Controller、Service、Repository。所以多个线程去使用同一个Bean不会造成什么问题。本着节约成本的理念,使用单例Bean比较好。
但是有时候我们可能会需要一个“有状态”的类,它内部又依赖其它Bean。比如一个Context或者一个Processor之类的。对于这种有状态有依赖其它Bean的类,有两种设计思路:
第二种使用起来会更优雅一些,也比较好测试一点。这里有一个小问题,我们来考虑以下这种情况:如果我们使用了一个多例Bean,它可能会依赖一些单例Bean,这个很好解决,在多例Bean中正常地注入单例Bean就行了。但是,如果我们要在一个单例Bean中使用一个多例Bean,我们知道无论是构造器注入,还是方法注入,还是属性注入,都只会在Bean初始化的时候注入一次,那怎么能保证多个线程得到的是不同的多例Bean呢?
所以要在单例Bean中使用多例Bean,不能使用一般的自动注入。Spring提供了@Lookup注解来帮我们做这个事。它是方法级别的注解。
// 定义一个多例Bean
@Component
@Scope("prototype")
public class PrototypeBean {
public void say() {
System.out.println("say something...");
}
}
@Component
public class SingletonBean {
public void print() {
// 单例Bean中用多例Bean
PrototypeBean bean = methodInject();
System.out.println("Bean SingletonBean's HashCode " + bean.hashCode());
}
@Lookup
public PrototypeBean methodInject() {
return null;
}
}
复制代码
需要注意的是,用@Lookup修饰的方法,不能是private的。可以是包访问权限、protected或public的。这里推荐写成public的,这样在单元测试的时候比较方便mock。
循环依赖其实很好理解,就是A依赖B,而B又依赖A。这样就形成了循环依赖。那Spring是如何解决循环依赖的呢?
聪明你的肯定能够马上想到,如果两个Bean都是使用构造器注入,那是不能解决循环依赖的,一旦有循环依赖只能报错。而如果是属性注入或者方法注入,那可以先初始化两个Bean,然后分别延迟注入进去。这样就可以解决循环依赖的问题。
这也是为什么我们推荐使用构造器注入。循环依赖不是一个好设计,构造器注入可以提早发现这种循环依赖。
Spring使用了一个叫做三级缓存的东西来解决循环依赖,具体的实现细节本文不做讨论,感兴趣的读者可以自己去找找相关的文章。
又回到上面那个单例和多例的问题。如果一个类是多例的,那它一般是有状态的,我们有必要把它交给Spring管理吗?或者说,有必要交给IoC容器管理吗?
在回答这个问题之前,我们先假设一下,如果不给IoC容器管理,会怎样?我们从三个角度来考虑:
如果这个类不依赖其它Bean,那其实不太需要交给IoC容器管理,POJO类就是一个很典型的例子。但如果这个类是一个单例的,那其实推荐交给IoC容器管理,因为要自己保证单例是比较麻烦的,而且不优雅。不信去看看单例模式的各种实现。
如果这个类依赖其它Bean,那推荐交给IoC容器管理,不然还得使用上面的那种applicationContext的getBean方法来获取依赖的Bean,这就与IoC框架耦合了,不太划算。
我是Yasin,一个有颜有料又有趣的程序员。
微信公众号:编了个程
个人网站:yasinshaw.com