在学习Spring框架时,我们遇到的第一个也是最核心的概念就是容器。什么是容器?
容器是一种为某个特定组件的运行提供必要支持的一个软件环境。例如,Tomcat就是一个Servlet容器,它可以为Servlet的运行提供运行环境。类似Docker这样的软件也是一个容器,它提供了必要的Linux环境以便运行一个特定的Linux进程。
通常来说,使用容器运行组件,除了提供一个组件的运行环境之外,容器还提供了许多底层服务。例如,Servlet容器底层实现了TCP链接,解析HTTP协议等非常复杂的任务,如果没有容器来提供这些服务,我们就无法编写像Servlet这样代码简单、功能强大的组件。早起的Java EE服务器提供的EJB容器最重要的功能就是通过声明式事务服务,使得EJB组件的开发人员不必编写冗长的事务处理代码,所以极大的简化了事务处理。
Spring的核心就是提供了一个IoC容器,它可以管理所有轻量级的JavaBean组件,提供的底层服务包括组件的生命周期管理、配置和组装服务、AOP支持,以及建立在AOP基础上的声明式服务等。本节我们介绍的IoC容器,主要介绍Spring容器如何对组件进行声明周期管理和配置组装服务。
IoC原理
IoC全称是Inversion of Control,直译为控制反转。在理解IoC之前,我们先看看通常的Java组件是如何协作的。
我们假定一个在线书店,通过BookService获取书籍,为了从数据库查询书籍,BookService持有一个DataSource;为了实例化一个HikariDataSource,又不得不实例化一个HikariConfig。现在我们继续编写UserService获取用户,因为UserService也要访问数据库,因此我们不得不也实例化一个HikariDataSource。在处理用户购买的CartServlet中,我们需要实例化UserService和BookService。类似的,在购买历史HistoryServlet中,也需要实例化UserService和BookService。
上述每个组件都采用了一种简单的通过new
创建实例并持有的方式。仔细观察,会发现以下缺点:
- 实例化一个组件其实很难。例如,BookService和UserService要创建HikariDataSource,实际上需要读取配置,才能先实例化HikariConfig,再实例化HikariDataSource。
- 没有必要让BookService和UserService分别创建DataSource实例,完全可以共享同一个DataSource,但谁负责创建DataSource,谁负责获取其他组件已经创建的DataSource,不好处理。类似的,CartServlet和HistoryServlet也应当共享BookService实例和UserService实例,但也不好处理。
- 很多组件需要销毁以便释放资源,例如DataSource,但如果该组件被多个组件共享,如何确保它的使用方都已经全部被销毁?
- 随着更多组件被引入,例如书籍评论,需要共享的组件写起来会更困难,这些组件的依赖关系会越来越复杂。
- 测试某个组件,例如BookService,是复杂的,因为必须要在真实的数据库环境下执行。
从上面的例子可以看出,如果一个系统有大量的组件,其生命周期和相互之间的依赖关系如果由组件自身来维护,不但大大增加了系统的复杂度,而且会导致组件之间极为紧密的耦合,继而给测试和维护带来了极大的困难。
因此,核心问题是:
- 谁负责创建组件?
- 谁负责根据依赖关系组装组件?
- 销毁时,如何按依赖顺序正确销毁?
解决这一问题的核心方案就是IoC。
传统的应用程序中,控制权在程序本身,程序的控制流程完全由开发者控制。例如:CartServlet创建了BookService,在创建BookService的过程中,又创建了DataSource组件。这种模式的缺点是,一个组件如果要使用另一个组件,必须先知道如何正确地创建它。
在IoC模式下,控制权发生了反转,即从应用程序转移到IoC容器,所有组件不再由应用程序自己创建和配置,而是由IoC容器负责,这样,应用程序只需要直接使用已经创建好并且配置好的组件。为了能让组件在IoC容器中被“装配”出来,需要某种“注入”机制。例如,BookService自己并不会创建DataSource,而是等待外部通过setDataSource()方法来注入一个DataSource。
不直接new一个DataSource,而是注入一个DataSource,这个小小的改动虽然简单,却带来了一系列好处:
- BookService不再关心如何创建DataSource,因此不必编写数据库配置之类的代码
- DataSource实例被注入到BookService,同样也可以注入到UserService,因此共享一个组件非常简单
- 测试BookService更容易,因为注入的是DataSource,可以使用内存数据库,而不是真实的MySQL配置
因此,IoC又称为依赖注入(DI:Dependency Injection),它解决了一个最主要的问题:将组件的创建+配置和组件的使用相分离,并且由IoC容器负责管理组件的生命周期。
因为IoC容器要负责实例化所有的组件,因此有必要告诉容器如何创建组件,以及各组件的依赖关系。一种最简单的配置是通过XML文件来实现的,例如:
1 |
|
上述XML配置文件指示IoC容器创建3个JavaBean组件,并把id为dataSource的组件通过属性dataSource注入到另外两个组件中。
在Spring的IoC容器中,我们把所有的组件统称为JavaBean,即配置一个组件就是配置一个Bean。
依赖注入方式
我们从上面的代码可以看到,依赖注入可以通过set()
方法实现。但依赖注入也可以通过构造方法实现。
很多Java类都具有带参数的构造方法,如果我们把BookService改造为通过构造方法注入,那么实现代码如下:
1 |
|
Spring的IoC容器同时支持属性注入和构造方法注入,并允许混合使用。
无侵入容器
在设计上,Spring的IoC容器是一个高度可扩展的无侵入容器。所谓无侵入,是指应用程序的组件无需实现Spring的特定接口,或者说,组件根本不知道自己在Spring的容器中运行。这种无侵入的设计有以下好处:
- 应用程序组件既可以在Spring的IoC容器中运行,也可以自己编写代码自行组装配置;
- 测试的时候并不依赖Spring容器,可单独进行测试,大大提高了开发效率。
装配Bean
上一节我们讨论了为什么要使用Spring的IoC容器,那么到底如何使用IoC容器呢?装配好的Bean又如何使用?
我们看一个具体的用户注册登录的例子,下面是他的工程结构。首先,我们用Maven创建工程并引入spring-context
依赖。
1 |
|
先编写一个MailService,用于在用户登录和注册成功后发送邮件通知。再编写一个UserService,实现用户登录和注册。
1 |
|
注意到UserService通过setMailService()注入了一个MailService。
然后,我们要编写一个特定的application.xml配置文件,告诉Sring的IoC容器应该如何创建并组装Bean。
1 |
|
注意观察上述配置文件,其中与XML Schema相关的部分格式是固定的,我们只关注两个<bean ...>
的配置。
- 每个
<bean ...>
都有一个id标识,相当于Bean的唯一ID; - 在
userService
Bean中,通过<property name="..." ref="..." />
注入了另一个Bean; - Bean的顺序不重要,Spring根据依赖关系会自动正确初始化。
把上述XML配置文件用Java代码写出来,就像这样:
1 |
|
只不过Spring容器是通过读取XML文件后使用反射完成的。
如果注入的不是Bean,而是boolean
、int
、String
这样的数据类型,则通过value
注入,例如,创建一个HikariDataSource
:
1 |
|
最后一步,我们需要创建一个Spring的IoC容器实例,然后加载配置文件,让Spring容器为我们创建并装配好配置文件中指定的所有Bean,这只需要一行代码:
1 |
|
接下来,我们就可以从Spring容器中取出装配好的Bean,然后使用它。
1 |
|
完整的main()方法如下:
1 |
|
ApplicationContext
我们从创建Spring容器的代码:
1 |
|
可以看到,Spring容器就是ApplicationContext
,它是一个接口,有很多实现类,这里我们选择ClassPathXmlApplicationContext
,表示它会自动从classpath中查找指定的XML配置文件。
获得了ApplicationContext
的实例,就获得了IoC容器的引用。从ApplicationContext
中我们可以根据Bean的ID获取Bean,但更多的时候我们根据Bean的类型获取Bean的引用:
1 |
|
Spring还提供另一种IoC容器叫BeanFactory
,使用方式和ApplicationContext
类似:
1 |
|
BeanFactory
和ApplicationContext
的区别在于,BeanFactory
的实现是按需创建,即第一次获取Bean时才创建这个Bean,而ApplicationContext
会一次性创建所有的Bean。实际上,ApplicationContext
接口是从BeanFactory
接口继承而来的,并且,ApplicationContext
提供了一些额外的功能,包括国际化支持、事件和通知机制等。通常情况下,我们总是使用ApplicationContext
,很少会考虑使用BeanFactory
。
使用Annotation配置
使用Spring的IoC容器,实际上就是通过类似XML这样的配置文件,把我们自己的Bean的依赖关系描述出来,然后让容器来创建和装配Bean。一旦容器初始化完毕,我们就直接从容器中获取Bean使用他们。
使用XML配置的优点是所有的Bean都能一目了然地列出来了,并通过配置注入能直观的看到每个Bean的依赖。它的缺点是写起来非常繁琐,每增加一个组件,就必须把新的Bean配置到XML中。
有没有其他更简洁的配置方式呢?
有的。我们可以使用Annotation配置,可以完全不需要XML,让Spring自动扫描Bean并组装它们。
我们把上一节的示例改造一下。先删除XML配置文件,然后给UserService和MailService添加几个注解。首先,给MailService添加一个@Component
注解。
1 |
|
这个@Component
注解就相当于定义了一个Bean,它有一个可选的名称,默认是mailService
,即小写开头的类名。然后,我们给UserService添加一个@Component
注解和@Autowired
注解。
1 |
|
使用@Autowired
就相当于把指定类型的Bean注入到指定的字段中。和XML配置相比,@Autowired
大幅简化了注入,因为它不但可以写在set()
方法上,还可以直接写在字段上,甚至可以写在构造方法中。
1 |
|
我们一般把@Autowired
写在字段上,通常使用package权限的字段,便于测试。
最后编写一个AppConfig
类启动容器:
1 |
|
除了main()
方法外,AppConfig
标注了@Configuration
,表示它是一个配置类,因为我们创建ApplicationContext时,
1 |
|
使用的实现类是AnnotationConfigApplicationContext
,必须传入一个标注了@Configuration
的类名。
此外,AppConfig还标注了@ComponentScan
,它告诉容器,自动搜索当前类所在的包以及子包,把所有标注为@Component
的Bean自动创建出来,并根据@Autowired
进行装配。
整个工程的结构如下:
1 |
|
使用Annotation配合自动扫描能大幅简化Spring的配置,我们只需要保证:
- 每个Bean被标注为@Component并正确使用@Autowired注入
- 配置类被标注为@Configuration和@ComponentScan
- 所有Bean均在指定包以及子包内
使用@ComponentScan非常方便,但是我们也要特别注意包的层次结构。通常来说,启动配置AppConfig位于自定义的顶层包,其他Bean按类别放入子包。
思考
如果我们想给UserService
注入HikariDataSource
,但是这个类位于com.zaxxer.hikari
包中,并且HikariDataSource
也不可能有@Component
注解,如何告诉IoC容器创建并配置HikariDataSource
?或者换个说法,如何创建并配置一个第三方Bean?
定制Bean
Scope
对于Spring容器来说,当我们把一个Bean标记为@Component
,它就会自动为我们创建一个单例(Singleton),即容器初始化时创建Bean,容器关闭前销毁Bean。在容器运行期间,我们调用getBean(Class)
获取到的Bean总是同一个实例。
还有一种Bean,我们每次调用getBean(Class)
,容器都会返回一个新的实例,这种Bean称为Prototype(原型),它的生命周期显然和Singleton不同。声明一个Prototype的Bean时,需要添加一个额外的@Scope
注解。
1 |
|
注入List
有些时候,我们会有一系列接口相同,不同实现类的Bean。例如,注册用户时,我们要对email、password和name这3个变量进行验证。为了便于扩展,我们先定义验证接口:
1 |
|
然后,分别使用3个Validator
对用户参数进行验证:
1 |
|
最后,我们通过一个Validators
作为入口进行验证:
1 |
|
注意到Validators
被注入了一个List<Validator>
,Spring会自动把所有类型为Validator
的Bean装配为一个List
注入进来,这样一来,我们每新增一个Validator
类型,就自动被Spring装配到Validators
中了,非常方便。
因为Spring是通过扫描classpath获取到所有的Bean,而List是有序的,要指定List中Bean的顺序,可以加上@Order
注解。
1 |
|
可选注入
默认情况下,当我们标记了一个@Autowired
后,Spring如果没有找到对应类型的Bean,它会抛出NoSuchBeanDefinitionException
异常。
可以给@Autowired
增加一个required = false
的参数:
1 |
|
这个参数告诉Spring容器,如果找到一个类型为ZoneId
的Bean,就注入,如果找不到,就忽略。
这种方式非常适合有定义就使用定义,没有就使用默认值的情况。
创建第三方Bean
如果一个Bean不在我们自己的package管理之内,例如ZoneId
,如何创建它?
答案是我们自己在@Configuration
类中编写一个Java方法创建并返回它,注意给方法标记一个@Bean
注解:
1 |
|
Spring对标记为@Bean
的方法只调用一次,因此返回的Bean仍然是单例。
初始化和销毁
有些时候,一个Bean在注入必要的依赖后,需要进行初始化(监听消息等)。在容器关闭时,有时候还需要清理资源(关闭连接池等)。我们通常会定义一个init()
方法进行初始化,定义一个shutdown()
方法进行清理,然后,引入JSR-250定义的Annotation:
1 |
|
在Bean的初始化和清理方法上标记@PostConstruct
和@PreDestroy
:
1 |
|
Spring容器会对上述Bean做如下初始化流程:
- 调用构造方法创建
MailService
实例; - 根据
@Autowired
进行注入; - 调用标记有
@PostConstruct
的init()
方法进行初始化。
而销毁时,容器会首先调用标记有@PreDestroy
的shutdown()
方法。
Spring只根据Annotation查找无参数方法,对方法名不作要求。
使用别名
默认情况下,对一种类型的Bean,容器只创建一个实例。但有些时候,我们需要对一种类型的Bean创建多个实例。例如,同时连接多个数据库,就必须创建多个DataSource
实例。
如果我们在@Configuration
类中创建了多个同类型的Bean:
1 |
|
Spring会报NoUniqueBeanDefinitionException
异常,意思是出现了重复的Bean定义。
这个时候,需要给每个Bean添加不同的名字:
1 |
|
可以用@Bean("name")
指定别名,也可以用@Bean
+@Qualifier("name")
指定别名。
存在多个同类型的Bean时,注入ZoneId
又会报错:
1 |
|
意思是期待找到唯一的ZoneId
类型Bean,但是找到两。因此,注入时,要指定Bean的名称:
1 |
|
还有一种方法是把其中某个Bean指定为@Primary
:
1 |
|
这样,在注入时,如果没有指出Bean的名字,Spring会注入标记有@Primary
的Bean。这种方式也很常用。例如,对于主从两个数据源,通常将主数据源定义为@Primary
:
1 |
|
其他Bean默认注入的就是主数据源。如果要注入从数据源,那么只需要指定名称即可。
使用FactoryBean
我们在设计模式的工厂方法中讲到,很多时候,可以通过工厂模式创建对象。Spring也提供了工厂模式,允许定义一个工厂,然后由工厂创建真正的Bean。
用工厂模式创建Bean需要实现FactoryBean
接口。我们观察下面的代码:
1 |
|
当一个Bean实现了FactoryBean
接口后,Spring会先实例化这个工厂,然后调用getObject()
创建真正的Bean。getObjectType()
可以指定创建的Bean的类型,因为指定类型不一定与实际类型一致,可以是接口或抽象类。
因此,如果定义了一个FactoryBean
,要注意Spring创建的Bean实际上是这个FactoryBean
的getObject()
方法返回的Bean。为了和普通Bean区分,我们通常都以XxxFactoryBean
命名。
使用Resource
在Java程序中,我们经常会读取配置文件、资源文件等。使用Spring容器时,我们也可以把“文件”注入进来,方便程序读取。
例如,AppService需要读取logo.txt
这个文件,通常情况下,我们需要写很多繁琐的代码,主要是为了定位文件,打开InputStream。
Spring提供了一个org.springframework.core.io.Resource
(注意不是javax.annotation.Resource
),它可以像String
、int
一样使用@Value
注入:
1 |
|
注入Resource
最常用的方式是通过classpath,即类似classpath:/logo.txt
表示在classpath中搜索logo.txt
文件,然后,我们直接调用Resource.getInputStream()
就可以获取到输入流,避免了自己搜索文件的代码。
也可以直接指定文件的路径,例如:
1 |
|
但使用classpath是最简单的方式。上述工程结构如下:
1 |
|
使用Maven的标准目录结构,所有资源文件放入src/main/resources
即可。
注入配置
在开发应用程序时,经常需要读取配置文件。最常用的配置方法是以key=value
的形式写在.properties
文件中。
例如,MailService
根据配置的app.zone=Asia/Shanghai
来决定使用哪个时区。要读取配置文件,我们可以使用上一节讲到的Resource
来读取位于classpath下的一个app.properties
文件。但是,这样仍然比较繁琐。
Spring容器还提供了一个更简单的@PropertySource
来自动读取配置文件。我们只需要在@Configuration
配置类上再添加一个注解:
1 |
|
Spring容器看到@PropertySource("app.properties")
注解后,自动读取这个配置文件,然后,我们使用@Value
正常注入:
1 |
|
注意注入的字符串语法,它的格式如下:
"${app.zone}"
表示读取key为app.zone
的value,如果key不存在,启动将报错;"${app.zone:Z}"
表示读取key为app.zone
的value,但如果key不存在,就使用默认值Z
。
这样一来,我们就可以根据app.zone
的配置来创建ZoneId
。
还可以把注入的注解写到方法参数中:
1 |
|
可见,先使用@PropertySource
读取配置文件,然后通过@Value
以${key:defaultValue}
的形式注入,可以极大地简化读取配置的麻烦。
另一种注入配置的方式是先通过一个简单的JavaBean持有所有的配置,例如,一个SmtpConfig
:
1 |
|
然后,在需要读取的地方,使用#{smtpConfig.host}
注入:
1 |
|
注意观察#{}
这种注入语法,它和${key}
不同的是,#{}
表示从JavaBean读取属性。"#{smtpConfig.host}"
的意思是,从名称为smtpConfig
的Bean读取host
属性,即调用getHost()
方法。一个Class名为SmtpConfig
的Bean,它在Spring容器中的默认名称就是smtpConfig
,除非用@Qualifier
指定了名称。
使用一个独立的JavaBean持有所有属性,然后在其他Bean中以#{bean.property}
注入的好处是,多个Bean都可以引用同一个Bean的某个属性。例如,如果SmtpConfig
决定从数据库中读取相关配置项,那么MailService
注入的@Value("#{smtpConfig.host}")
仍然可以不修改正常运行。
使用条件装配
开发应用程序时,我们会使用开发环境,例如,使用内存数据库以便快速启动。而运行在生产环境时,我们会使用生产环境,例如,使用MySQL数据库。如果应用程序可以根据自身的环境做一些适配,无疑会更加灵活。
Spring为应用程序准备了Profile这一概念,用来表示不同的环境。例如,我们分别定义开发、测试和生产这3个环境:
- native
- test
- production
创建某个Bean时,Spring容器可以根据注解@Profile
来决定是否创建。例如,以下配置:
1 |
|
如果当前的Profile设置为test
,则Spring容器会调用createZoneIdForTest()
创建ZoneId
,否则,调用createZoneId()
创建ZoneId
。注意到@Profile("!test")
表示非test环境。
在运行程序时,加上JVM参数-Dspring.profiles.active=test
就可以指定以test
环境启动。
实际上,Spring允许指定多个Profile,例如:
1 |
|
可以表示test
环境,并使用master
分支代码。
要满足多个Profile条件,可以这样写:
1 |
|
使用Conditional
除了根据@Profile
条件来决定是否创建某个Bean外,Spring还可以根据@Conditional
决定是否创建某个Bean。
例如,我们对SmtpMailService
添加如下注解:
1 |
|
它的意思是,如果满足OnSmtpEnvCondition
的条件,才会创建SmtpMailService
这个Bean。OnSmtpEnvCondition
的条件是什么呢?我们看一下代码:
1 |
|
因此,OnSmtpEnvCondition
的条件是存在环境变量smtp
,值为true
。这样,我们就可以通过环境变量来控制是否创建SmtpMailService
。
Spring只提供了@Conditional
注解,具体判断逻辑还需要我们自己实现。Spring Boot提供了更多使用起来更简单的条件注解,例如,如果配置文件中存在app.smtp=true
,则创建MailService
:
1 |
|
如果当前classpath中存在类javax.mail.Transport
,则创建MailService
:
1 |
|
后续我们会介绍Spring Boot的条件装配。我们以文件存储为例,假设我们需要保存用户上传的头像,并返回存储路径,在本地开发运行时,我们总是存储到文件:
1 |
|
在生产环境运行时,我们会把文件存储到类似AWS S3上:
1 |
|
其他需要存储的服务则注入Uploader
:
1 |
|
当应用程序检测到配置文件存在app.storage=s3
时,自动使用S3Uploader
,如果存在配置app.storage=file
,或者配置app.storage
不存在,则使用FileUploader
。
可见,使用条件注解,能更灵活地装配Bean。