控制反转(Inversion of Control,缩写为IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。其中最常见的方式叫做依赖注入(Dependency Injection,简称DI),还有一种方式叫“依赖查找”(Dependency Lookup)。通过控制反转,对象在被创建的时候,由一个调控系统内所有对象的外界实体将其所依赖的对象的引用传递给它。也可以说,依赖被注入到对象中。
1. 原生Servlet时代的三层架构
下面咱实际动手搭建一个在原生 Servlet 时代的 MVC 三层架构的工程,以此为背景板。
(为方便后续内容演示,使用 IDEA 创建工程前,先创建一个空工程 spring-framework-projects
,用来存放接下来的所有工程)
1.1 构建基于Maven的原生Servlet工程
使用 Maven 构建项目那是最基本的能力了,咱使用 IDEA 快速搭建一个原生的 Servlet 工程。
pom 依赖中,只需要引入 Servlet 的 api 即可:(此处我使用了 Servlet3.1 ,这个倒是无所谓,只是用 Servlet3.0+ 的版本可以基于注解开发,效率较快)
<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
当然,为了使工程的编译级别在 1.8 级别,还需要加入 Maven 的编译插件:(版本不要太老就好,此处我选用 3.2 版本)
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.2</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
最后,不要忘记调整打包方式为 war 包:
<packaging>war</packaging>
1.2 将工程部署到Servlet容器
创建好工程后,下一步先不要着急写代码,咱先把工程部署到 Servlet 容器中,保证能正常运行。这里咱使用 Tomcat 作为 Servlet 容器来运行工程。
在 IDEA 中依次打开 “File -> Project Structure” ,选中 Artifacts 标签,并添加 Web Application: Exploded 类型的输出类型,配置好对应的路径与名称,即可设置好编译打包输出配置。如下图所示:
接下来,在 IDEA 的运行栏中选择 Add Configuration... ,并添加本地的 Tomcat :
接下来在新建的 Tomcat 中选择 Deployment ,并添加刚配置好的 Artifact :
添加完成后,即可保存确定。
1.3 编写Servlet测试可用
在 src/main/java
中新建一个 DemoServlet
,标注 @WebServlet
注解,并继承 HttpServlet
,重写 doGet
方法:
@WebServlet(urlPatterns = "/demo1")
public class DemoServlet1 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.getWriter().println("DemoServlet1 run ......");
}
}
编写完毕后直接启动 Tomcat ,此时 IDEA 会自动编译工程并部署到 Tomcat 中。
打开浏览器,地址栏输入 http://localhost:8080/spring_00_introduction_architecture/demo1 (每个人搭建的工程名可能不一致,context-path 记得修改),发现可以正常打印 DemoServlet1 run ......
的输出,证明工程搭建并配置成功。
1.4 编写Service与Dao
因为一开始 pom 中没有导入与数据库相关的依赖,故此处的 Dao 只是空壳,并没有实际的 jdbc 相关操作。
在工程目录下新建以下几个类和接口,这些都是老生常谈了,都很简单:
对应的三层架构中的组件及依赖就应该是这样:( Dao 连接数据库的部分不实现)
1.4.1 Dao与DaoImpl
简单定义一个 DemoDao
接口,并声明一个 findAll
方法模拟从数据库查询一组数据:
public interface DemoDao {
List<String> findAll();
}
编写它对应的实现类 DemoDaoImpl
,由于没有引入数据库的相关驱动,故这里只是用写死的临时数据模拟 Dao 与数据库的交互:
public class DemoDaoImpl implements DemoDao {
@Override
public List<String> findAll() {
// 此处应该是访问数据库的操作,用临时数据代替
return Arrays.asList("aaa", "bbb", "ccc");
}
}
至此,Dao 层的接口与实现类定义完成。
1.4.2 Service与ServiceImpl
编写一个 DemoService
接口,并声明 findAll
方法:
public interface DemoService {
List<String> findAll();
}
编写它对应的实现类 DemoServiceImpl
,并在内部依赖 DemoDao
接口:
public class DemoServiceImpl implements DemoService {
private DemoDao demoDao = new DemoDaoImpl();
@Override
public List<String> findAll() {
return demoDao.findAll();
}
}
至此,Service 层的接口与实现类定义完成。
1.5 修改DemoServlet
由于要模拟整体的三层架构,故 DemoServlet1
要依赖 DemoService
:
@WebServlet(urlPatterns = "/demo1")
public class DemoServlet1 extends HttpServlet {
DemoService demoService = new DemoServiceImpl();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.getWriter().println(demoService.findAll().toString());
}
}
1.6 重新运行应用并测试可用
重新部署到 Tomcat 并运行,访问 /demo1
路径,浏览器中会打印 ['aaa', 'bbb', 'ccc']
,说明编写正确且运行正常。
以上部分是咱在 JavaWeb 基础中最熟悉不过的东西了,好了到这里咱停下来,代入一个场景。
2. 【问题】需求变更
现在你的手头上已经基本上开发完成了,数据库用的 MySQL 很舒服,临近交付项目,客户一个电话打过来了:
哎呦我去这瞧谁不起啊?我可是大老板,给老子换 Oracle 的数据库!
挂掉电话的你内心一万只草泥马呼啸而去:
没招啊,客户是上帝啊,咱也是要恰饭的嘛,客户要啥咱就得改啥啊!那改吧:
2.1 修改数据库
咱都知道,对于 MySQL 跟 Oracle ,在有一些特定的 SQL 上是不一样的(比如分页),这样我还不能只把数据库连接池的相关配置改了就好使,每个 DaoImpl 也得改啊!于是乎,你开始修改起工程里所有的 DaoImpl :
public class DemoDaoImpl implements DemoDao {
@Override
public List<String> findAll() {
// 模拟修改SQL的动作
return Arrays.asList("oracle", "oracle", "oracle");
}
}
2.2 需求再次变更
你好不容易熬夜两个晚上,头发掉了一把又一把,终于要给客户部署工程了,客户笑眯眯的跟你说了一句话:
那个啥,最近炒股。。。呃不是,财务支出有点严重,这不有点囊中羞涩,数据库就换回 MySQL 吧!
此时的你一定是:
你已经受够了这种改过来改过去的破事了,毕竟狗命要紧(杀死程序猿最简单的办法:改三次需求),那这个时候你就得想啊,怎么解决这个问题呢?
2.3 【方案】引入静态工厂
苦思良久,你终于想到了一个好办法:如果我事先把这些 Dao 都写好了,之后用一个静态工厂来创建特定类型的实现类,这样万一发生需求变更,是不是就可以做到只改一次代码就可以了!
于是按照这个想法,有如下改造:
2.3.1 构造静态工厂
声明一个静态工厂,起个比较别致的名字吧:BeanFactory
(不要问我为什么这么别致,这是一个伏笔)
public class BeanFactory {
public static DemoDao getDemoDao() {
// return new DemoDaoImpl();
return new DemoOracleDao();
}
}
2.3.2 改造ServiceImpl
ServiceImpl 中引用的 Dao 不再是手动 new ,而是由 BeanFactory
的静态方法返回而得:
public class DemoServiceImpl implements DemoService {
DemoDao demoDao = BeanFactory.getDemoDao();
@Override
public List<String> findAll() {
return demoDao.findAll();
}
}
如此这般,即便 ServiceImpl 再多,Dao 再多,发生需求更改,我也只需要改动 BeanFactory 中的静态方法返回值即可!
问题解决,皆大欢喜,客户也很满意,项目交付完成。
3. 【问题】源码丢失
项目上线运行一段时间后,客户对系统中的一些功能提出了优化和扩展需求,那这个时候你就来维护呗,毕竟你最熟悉这个项目。不过之前好一段时间你都去负责别的项目去了,维护工作都是由你同事负责着。
当你重新打开工程时,想先拉起来看看要扩展的需求具体的位置,居然发现项目连编译都无法通过了!(为演示无法编译的现象,删除 DemoDaoImpl.java
)
此时的你肯定是一脸黑人问号啊!怎么之前好使的现在就不好使了?再仔细一看报错位置,BeanFactory
!哎不大对劲啊,我这之前封装好的静态工厂就是偷懒用的,怎么会编译出错呢?打开代码看了一眼才知道,合着少了一个 DemoDaoImpl
的源文件,导致代码根本无法编译了!
场景演绎到这里,咱先稍微暂停一下,体会一下这里面出现的问题。
3.1 【概念】类之间的依赖关系——紧耦合
public class BeanFactory {
public static DemoDao getDemoDao() {
return new DemoDaoImpl(); // DemoDaoImpl.java不存在导致编译失败
}
}
当前的代码中,因为源码中真的缺少这个 DemoDaoImpl
类,导致编译都无法通过,这种现象就可以描述为 “ BeanFactory
强依赖于 DemoDaoImpl
” ,也就是咱可能听过也可能常说的“紧耦合”。
3.2 【方案】解决紧耦合
回到刚才的场景中,你这直接懵逼了呀,没有这个 .java 文件,我没法编译,那我不用干活了呗?不行,咱可不能因为这个问题就耽误了整体呀!于是乎你开动脑筋,想一下在现有知识中,有没有一种办法能解决这个编译都没办法编译的问题?
反射!反射可以声明一个类的全限定名,来获取它的字节码描述,这样也能构造对象!
于是 BeanFactory
可以改造为:
public class BeanFactory {
public static DemoDao getDemoDao() {
try {
return (DemoDao) Class.forName("com.linkedbear.architecture.c_reflect.dao.impl.DemoDaoImpl").newInstance();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("DemoDao instantiation error, cause: " + e.getMessage());
}
}
}
照这样一写,是不是编译的问题就解决了?尽管在 DemoService
的初始化时还是会出现问题,但最起码可以把项目拉起来了啊!
于是这个问题就暂时解决了,先放一边了。。。
3.3 【概念对比】弱依赖
使用反射之后,错误现象不再是在编译器就出现,而是在工程启动后,由于 BeanFactory
要构造 DemoDaoImpl
时确实还没有该类,所以抛出 ClassNotFoundException
异常。这样 BeanFactory
对 DemoDaoImpl
的依赖程度就相当于降低了,也就可以算作“弱依赖”了。
4. 【问题】硬编码
躲得了初一躲不了十五,这个问题最终还是得解决,你费劲八道的终于把 DemoDaoImpl.java
找了回来,这下终于运行期也不报错了。但这样在切换 MySQL 和 Oracle 库时还是会出现一个问题:由于类的全限定名是写死在 BeanFactory
的源码中,导致每次切换数据库后还得重新编译工程才可以正常运行,这显得貌似很没必要,应该有更好的处理方案。
4.1 【改良】引入外部化配置文件
机智的你利用现有的 JavaSE 知识,立马能想到:哎,我可以借助 IO 来实现文件存储配置啊!这样每次 BeanFactory
被初始化时,让它去读配置文件,这样就不会出现硬编码的现象了!
于是可有如下改造:
4.1.1 加入factory.properties文件
在 src/main/resource
目录下新建 factory.properties
文件,并在其中声明如下内容:
demoService=com.linkedbear.architecture.d_properties.service.impl.DemoServiceImpl
demoDao=com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl
为了方便回头取这些类的全限定名,我给每一个类名都起一个“小名”(别名),这样我就可以根据小名来找到对应的全限定类名了。
4.1.2 改造BeanFactory
既然配置文件是 properties 类型,在 jdk 中刚好也有一个 API 叫 Properties
,它可以解析 .properties
文件。
于是可以在 BeanFactory
中加入一个静态变量:
public class BeanFactory {
private static Properties properties;
下面要在工程刚启动的时候就初始化 Properties
,这咱可以使用静态代码块实现吧:
private static Properties properties;
// 使用静态代码块初始化properties,加载factord.properties文件
static {
properties = new Properties();
try {
// 必须使用类加载器读取resource文件夹下的配置文件
properties.load(BeanFactory.class.getClassLoader().getResourceAsStream("factory.properties"));
} catch (IOException e) {
// BeanFactory类的静态初始化都失败了,那后续也没有必要继续执行了
throw new ExceptionInInitializerError("BeanFactory initialize error, cause: " + e.getMessage());
}
}
配置文件读取到之后,下面的 getDao
方法也可以进一步改了:
public static DemoDao getDemoDao() {
try {
Class<?> beanClazz = Class.forName(properties.getProperty("demoDao"));
return beanClazz.newInstance();
} catch (ClassNotFoundException e) {
throw new RuntimeException("BeanFactory have not [" + beanName + "] bean!", e);
} catch (IllegalAccessException | InstantiationException e) {
throw new RuntimeException("[" + beanName + "] instantiation error!", e);
}
}
写到这里,是不是感觉怪怪的。。。都抽象化到这种地步了,还有必要在这里面写死 “demoDao” 吗?肯定没必要了吧,干脆做一个通用得了,你传什么别名,BeanFactory
就从配置文件中找对应的全限定类名,反射构造对象返回:
public static Object getBean(String beanName) {
try {
// 从properties文件中读取指定name对应类的全限定名,并反射实例化
Class<?> beanClazz = Class.forName(properties.getProperty(beanName));
return beanClazz.newInstance();
} catch (ClassNotFoundException e) {
throw new RuntimeException("BeanFactory have not [" + beanName + "] bean!", e);
} catch (IllegalAccessException | InstantiationException e) {
throw new RuntimeException("[" + beanName + "] instantiation error!", e);
}
}
4.1.3 改造ServiceImpl
DemoServiceImpl
中不再需要调 getDao
方法了(因为被删了...),而是转用 getBean
方法,并指定需要获取的指定名称的类的对象:
public class DemoServiceImpl implements DemoService {
DemoDao demoDao = (DemoDao) BeanFactory.getBean("demoDao");
到这里,你突然发现一个现象:这下你可以把所有想抽取出来的组件都可以做成外部化配置了!
4.2 【思想】外部化配置
对于这种可能会变化的配置、属性等,通常不会直接硬编码在源代码中,而是抽取为一些配置文件的形式( properties 、xml 、json 、yml 等),配合程序对配置文件的加载和解析,从而达到动态配置、降低配置耦合的目的。
5. 【问题】多重构建
改到这里可能你会感觉,是不是哪里不对劲,是不是还有改进的空间呢?这样,咱在 ServiceImpl
的构造方法中连续多次获取 DemoDaoImpl
:
public class DemoServiceImpl implements DemoService {
DemoDao demoDao = (DemoDao) BeanFactory.getBean("demoDao");
public DemoServiceImpl() {
for (int i = 0; i < 10; i++) {
System.out.println(BeanFactory.getBean("demoDao"));
}
}
咱只来看打印的这些 DemoDao
的内存地址:
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@44548059
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@5cab632f
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@24943e59
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@3f66e016
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@5f50e9eb
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@58e55b35
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@5d06d086
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@55e8ed60
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@daf5987
com.linkedbear.architecture.d_properties.dao.impl.DemoDaoImpl@7f6187f4
可以发现每次打印的内存地址都不相同,证明是创建了10个不同的 DemoDaoImpl
!但是,真的有必要吗。。。
5.1 【改良】引入缓存
如果对于这些没必要创建多个对象的组件,如果能有一种机制保证整个工程运行过程中只存在一个对象,那就可以大大减少资源消耗。于是可以在 BeanFactory
中加入一个缓存区:
public class BeanFactory {
// 缓存区,保存已经创建好的对象
private static Map<String, Object> beanMap = new HashMap<>();
// ......
之后在 getBean
方法中,为了控制线程并发,需要引入双检锁保证对象只有一个:
public static Object getBean(String beanName) {
// 双检锁保证beanMap中确实没有beanName对应的对象
if (!beanMap.containsKey(beanName)) {
synchronized (BeanFactory.class) {
if (!beanMap.containsKey(beanName)) {
// 过了双检锁,证明确实没有,可以执行反射创建
try {
Class<?> beanClazz = Class.forName(properties.getProperty(beanName));
Object bean = beanClazz.newInstance();
// 反射创建后放入缓存再返回
beanMap.put(beanName, bean);
} catch (ClassNotFoundException e) {
throw new RuntimeException("BeanFactory have not [" + beanName + "] bean!", e);
} catch (IllegalAccessException | InstantiationException e) {
throw new RuntimeException("[" + beanName + "] instantiation error!", e);
}
}
}
}
return beanMap.get(beanName);
}
改良完成,重新测试,观察这一次打印的结果:
com.linkedbear.architecture.e_cachedfactory.dao.impl.DemoDaoImpl@4a667700
com.linkedbear.architecture.e_cachedfactory.dao.impl.DemoDaoImpl@4a667700
com.linkedbear.architecture.e_cachedfactory.dao.impl.DemoDaoImpl@4a667700
......
果然只会有一个对象了,最终目的达到。
到这里,整个场景的演绎就算结束了,下面咱来总结一下这里面出现的几个关键点。
- 静态工厂可将多处依赖抽取分离
- 外部化配置文件+反射可解决配置的硬编码问题
- 缓存可控制对象实例数
接下来,是时候引出这一章的主题了。
6. IOC的思想引入【重点】
对比上面的两种代码写法:
private DemoDao dao = new DemoDaoImpl();
private DemoDao dao = (DemoDao) BeanFactory.getBean("demoDao");
上面的是强依赖 / 紧耦合,在编译期就必须保证 DemoDaoImpl
存在;下面的是弱依赖 / 松散耦合,只有到运行期反射创建时才知道 DemoDaoImpl
是否存在。
再对比看,上面的写法是主动声明了 DemoDao
的实现类,只要编译通过,运行一定没错;而下面的写法没有指定实现类,而是由 BeanFactory
去帮咱查找一个 name 为 demoDao
的对象,倘若 factory.properties
中声明的全限定类名出现错误,则会出现强转失败的异常 ClassCastException
。
仔细体会下面这种对象获取的方式,本来咱开发者可以使用上面的方式,主动声明实现类,但如果选择下面的方式,那就不再是咱自己去声明,而是将获取对象的方式交给了 BeanFactory
。这种将控制权交给别人的思想,就可以称作:控制反转( Inverse of Control , IOC )。而 BeanFactory
根据指定的 beanName
去获取和创建对象的过程,就可以称作:依赖查找( Dependency Lookup , DL )。