荒腔走板
今天把自己家里的键盘卸下来洗了一下。键盘是去年买的,国产静电容,宁芝的。整体上来说使用体验挺好的,写代码和写文章非常舒服,用很长时间手也不累。静电容键盘用了就回不去了。关键是还便宜,只要七百来块,差不多只有国外静电容牌子的1/3价格,感兴趣的朋友可以考虑入手一波。
我买的是82键的,感觉如果是windows的电脑,还是应该买87的好一点,不然有些IDEA的快捷键用起来比较麻烦,还得多按一个Fn
键配合。
键盘还是要经常洗一洗的,代码也是要经常敲一敲的,文章也是要经常发一发的,对吧。
回到文章本身,做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
?如果某一天又想步行或者骑自行车呢?
依赖倒置原则
有了上面这个耦合的问题,于是业界的大佬们就想办法来解决这个问题。设计模式六大原则里面有一个依赖倒置原则(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方法注入
另一种方式是使用方法注入,一般是使用要依赖的对象对应的属性的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 - JSR250定义
- Inject - JSR330定义
- Autowired - 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的类,有两种设计思路:
- 不给Spring管理,如果要用到其它Bean,使用上面的applicationContext来直接获取Bean。
- 给Spring管理,做成多例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
- 这个类是不是单例的
如果这个类不依赖其它Bean,那其实不太需要交给IoC容器管理,POJO类就是一个很典型的例子。但如果这个类是一个单例的,那其实推荐交给IoC容器管理,因为要自己保证单例是比较麻烦的,而且不优雅。不信去看看单例模式的各种实现。
如果这个类依赖其它Bean,那推荐交给IoC容器管理,不然还得使用上面的那种applicationContext的getBean方法来获取依赖的Bean,这就与IoC框架耦合了,不太划算。