<返回更多

关于Spring IoC的那些事

2020-08-18    
加入收藏

JAVA的基本上都用过Spring,而IoC是Spring最核心的模块之一。那IoC具体有什么用,Spring又是如何做到IoC的呢?这是本文要探索的话题。

关于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?如果某一天又想步行或者骑自行车呢?

关于Spring IoC的那些事

 

依赖倒置原则

有了上面这个耦合的问题,于是业界的大佬们就想办法来解决这个问题。设计模式六大原则里面有一个依赖倒置原则(Dependence Inversion Principle)。

所谓依赖倒置原则,就是把原本耦合的A和B分开,中间加一个“抽象层”。这样A只需要依赖抽象层,并不需要关心具体实现,只要它能完成自己需要的功能就行了。而B也只依赖抽象层,实现这个功能就行了。

如果A依赖B,我们称A为“上层”,B为“下层”,依赖倒置原则强调上层模块不应该依赖下层模块,两者应依赖其抽象

关于Spring IoC的那些事

 

仍然是上面员工小明的例子,其实他上班需要的并不是一个公交车,而是一个“交通工具”,这个交通工具可以是自行车、电动车、汽车等等,只要它能够把小明从家里带到公司就可以了。我们改一下代码,变成了这样:

// 定义抽象类
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);
    }
} 
复制代码
关于Spring IoC的那些事

 

这样当然也能解决,但不是最优雅的解决方案,因为你每个Bean都需要依赖Container。那我们能不能Worker类不依赖任何东西,包括Container,实现上班这个功能呢?当然可以,且听下文分析。

依赖注入

更优雅的方案就是使用依赖注入(Dependency Injection)。我不想使用Container,每次还要主动去拿。我想在自己被创建的时候(或者创建后),我所依赖的对象就自动被设置好了。

关于Spring IoC的那些事

 

同时,这还是一种“无侵入”的方式,我们的业务代码里面可以不用写任何关于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方法注入

另一种方式是使用方法注入,一般是使用要依赖的对象对应的属性的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容器,利用依赖关系注入的方式,实现对象之间的解耦。控制反转是解决问题的一种思路和方法论,依赖注入是它的具体实现方式

在Spring中使用IoC

首先要明确Bean的概念,Spring把需要纳入IoC容器观察的对象称为Bean。

一些对象是不用交给Spring管理的,比如POJO对象,类似DO、DTO等对象(包括DDD中的领域模型),它们都是可以在程序里面通过new或者builder来创建的,因为创建的时候要给它们的一些属性赋值,而且在使用这些类时,没法使用“依赖倒置原则”。

声明Bean

首先第一步要声明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

使用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管理?

又回到上面那个单例和多例的问题。如果一个类是多例的,那它一般是有状态的,我们有必要把它交给Spring管理吗?或者说,有必要交给IoC容器管理吗?

在回答这个问题之前,我们先假设一下,如果不给IoC容器管理,会怎样?我们从三个角度来考虑:

如果这个类不依赖其它Bean,那其实不太需要交给IoC容器管理,POJO类就是一个很典型的例子。但如果这个类是一个单例的,那其实推荐交给IoC容器管理,因为要自己保证单例是比较麻烦的,而且不优雅。不信去看看单例模式的各种实现。

如果这个类依赖其它Bean,那推荐交给IoC容器管理,不然还得使用上面的那种applicationContext的getBean方法来获取依赖的Bean,这就与IoC框架耦合了,不太划算。

关于作者

我是Yasin,一个有颜有料又有趣的程序员

微信公众号:编了个程

个人网站:yasinshaw.com

声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>