控制反转,依赖注入,依赖倒置傻傻分不清楚?

语言: CN / TW / HK

通过这篇文章,你将了解到

  • 控制反转(IoC)是什么?「反转」到底反转了什么?
  • Spring和IOC之间是什么关系?
  • 依赖注入(DI)和依赖倒置原则(DIP)又是什么?
  • IOC、DI和DIP有什么关系?

1. 控制反转(IoC)

1.1 一个典型案例

介绍「控制反转」之前,我们先看一段代码

public class UserServiceTest {
    public static boolean doTest() {
        //此处编写自己的判断逻辑
        return false;
    }

    public static void main(String[] args) {

        if (doTest()) {
            System.out.println("Test succeed.");
        } else {
            System.out.println("Test failed.");
        }
    }
}

如上,我们为一个方法写了一个测试用例,包括main方法的创建,所有的流程都是我们自己来控制的。

现在有这么一个框架,代码如下:

public abstract class TestCase {
    public void run() {
        if (doTest()) {
            System.out.println("Test succeed.");
        } else {
            System.out.println("Test failed.");
        }
    }

    public abstract boolean doTest();
}


public class JunitApplication {
    private static final List<TestCase> cases = new ArrayList();

    public static void register(TestCase testCase){
        cases.add(testCase);
    }

    public static void main(String[] args) {
        for(TestCase testCase : cases){
            testCase.run();
        }
    }
}

利用这么框架,我们如果再为UserServiceTest写一个测试用例,只需要继承TestCase,并重写其中的doTest方法即可。

public class UserServiceTestCase extends TestCase{
    @Override
    public boolean doTest() {
        //此处编写自己的判断逻辑
        return false;
    }
    
}

//注册测试用例
JunitApplication.register();

看完这里例子,相信读者朋友已经明白了这个框架给我们带来了怎样的便利。一开始我们需要为每个测试方法添加一个main方法,一旦待测试的方法多起来会非常的不方便。现在框架给我们制定了程序运行的基本骨架,并为我们预设了埋点,我们只需要设置好框架的埋点,剩下的执行流程就交给框架来完成就可以了。

这就是「框架实现控制反转」的典型例子。这里的「控制」指的是对执行流程的控制,「反转」指的是在框架产生之前我们需要手动控制全部流程的执行,而框架产生之后,有框架来执行整个大流程的执行,流程控制由我们「反转」给了框架。

1.2 IoC概念的提出

早在1988年,Ralph E. Johnson与Brian Foote在文章Designing Reusable Classes中提出了inversion of control的概念,他们怎么也没想到,这几个单词会在未来给中国的编程者造成多大的麻烦!

image.png

虽然Spring框架把IoC的概念发扬光大,但IoC的诞生远远早于Spring,并且IoC的概念正是在讨论框架设计的时候被提出来的。至于框架和IoC是先有鸡还是先有蛋,这个问题对我们并没有什么意义。​

当IoC概念模糊不清的时候,追本溯源或许是让我们彻底理解这个概念的好想法。至于概念之外的延伸不过是细枝末节罢了。接下来我们体会一下文章中比较重要两段话,我进行了意译。

One important characteristic of a framework is that the methods defined by the user to tailor the framework will often be called from within the framework itself, rather than from the user's application code.

「框架」的一个重要特征是,框架本身定义的方法常常由框架自己调用,而非用户的应用程序代码调用。

This inversion of control gives frameworks the power to serve as extensible skeletons. The methods supplied by the user tailor the generic algorithms defined in the framework for a particular application. ​这种「控制反转」使框架作为一个程序运行的骨架,具有了可扩展的能力。用户可以自定义框架中预设好的埋点。

IoC就是一种思想,而不是某种具体编程技术的落地。应用了「控制反转」思想的框架允许用户在一定程度上「填空」即可,其余的运行都交给框架。

1.3 为什么提出IoC

几乎所有编程思想的提出都是基于一个目的——解耦。Ioc是怎么解决耦合问题的呢?

假设我们有四个对象,彼此之间的依赖关系如图 image.png 翻译成代码大致如下:

class A{
    Object b = new B();
    ...
}

class B{
    Object c = new C();
    Object d = new D();
    ...
}

class C{
    Object d = new D();
}

但是A对象就是实实在在地需要B对象啊,这种依赖关系无法被抹除,就意味着耦合关系不可能完全解除,但是可以减弱!IoC的思想是引入一个IoC容器来处理对象之间的依赖关系,由主动依赖转为被动依赖,减轻耦合关系,从强耦合变为弱耦合。 image.png 关于IoC容器的作用,给大家举个生活中的例子。

假设有3个顾客分别从4个店铺购买了商品,好巧不巧,所有人都碰到了质量问题,在第三方购物平台诞生之前,每个顾客都只能分别与每家店铺协商理赔问题,此时顾客和店铺之间是强耦合关系。 image.png 有了第三方购物平台之后,顾客可以直接和平台投诉,让平台和各个店铺进行协商,平台对每位顾客进行统一理赔,此时顾客和店铺之间就是松耦合的关系,因为最累的工作被平台承担了,此时平台的作用就类似IoC容器。 image.png 最后拿Spring再举个例子。

从大粒度上看,使用Spring之后我们不需要再写Servlet,其中调用Servlet的流程全部交给Spring处理,这是IoC。

从小粒度上看,在Spring中我们可以用以下两种方式创建对象

// 方式1
private MySQLDao dao = new MySQLDaoImpl();

// 方式2
private MySQLDao dao = (MySQLDao) BeanFactory.getBean("mySQLDao");

使用方式1,dao对象的调用者和dao对象之间就是强耦合关系,一旦MySQLDaoImpl源码丢失,整个项目就会在编译时期报错。

使用方式2,如果我们在xml文件中配置了mySQLDao这个bean,如果源码丢失,最多报一个运行时异常(ClassNotFound错误),不至于影响项目的启动。

Spring提供了方式2这样的方式,自动给你查找对象,这也是IoC,而且这是IoC的常用实现方法之一,依赖查找。另一种是依赖注入,我们一会儿再介绍。

1.4 Spring和IoC的关系

Spring是将IoC思想落地的框架之一,并将之发扬光大的最著名的框架(没有之一)。

1.5 面试中被问到IoC怎么回答

「控制反转」是应用于软件工程领域的,在运行时被装配器对象用来绑定耦合对象的一种编程思想,对象之间的耦合关系在编译时通常是未知的。

在传统的编程方式中,业务逻辑的流程是由应用程序中早已设定好关联关系的对象来决定的。在使用「控制反转」的情况下,业务逻辑的流程是由对象关系图来决定的,该对象关系图由IoC容器来负责实例化,这种实现方式还可以将对象之间关联关系的定义抽象化。绑定的过程是由“依赖注入”实现的。

控制反转是一种以给予应用程序中目标组件更多控制为目的的设计范式,并在实际工作中起到了有效作用。

2. 依赖注入(DI)

依赖注入的英文翻译是 Dependency Injection,缩写为 DI。

依赖注入不等于控制反转!依赖注入只是实现控制反转的一种方式! 依赖注入不等于控制反转!依赖注入只是实现控制反转的一种方式! 依赖注入不等于控制反转!依赖注入只是实现控制反转的一种方式!

这个概念披着“高大上”的外衣,但是实质却非常单纯。用人话解释就是:不通过new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。

举一个平时编码常用的一个例子,我们在Controller中调用Service服务的时候一般会这么写

@Api(tags = {"报警联系人接口"})
@RestController
@RequestMapping("/iot/contact")
public class AlarmContactController extends BaseController {
    
    // 这就是大名鼎鼎的DI啊,是不是非常简单!
    @Autowired
    private IAlarmContactService alarmContactService;

    ...

}

这就是大名鼎鼎的DI啊,是不是非常简单!

2.1 面试中被问到「依赖注入」怎么回答

依赖注入是在编译阶段尚不知道所需功能是来自哪个类的情况下,将其他对象所依赖的功能对象实例化的手段。有三种实现方式:构造器注入、setter方法注入、接口注入。

3. 依赖倒置原则(DIP)

3.1 定义

「依赖倒置」原则的英文翻译是 Dependency Inversion Principle,缩写为 DIP。中文翻译有时候也叫「依赖反转」原则。

「依赖倒置」是本文要讲述的主要内容,是七大设计原则之二,在生产实际中应用的非常广泛,主要内容为

  1. 高层模块(high-level modules)不要直接依赖低层模块(low-level);
  2. 高层模块和低层模块应该通过抽象(abstractions)来互相依赖
  3. 抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

暂时看不懂没关系,我们先看个代码案例。

3.2 代码示例

陀螺研发了一套自动驾驶系统,在积极谈判之下和本田以及福特达成了合作协议,两个厂商各自提供汽车启动、转弯和停止的api供自动驾驶调用,系统就能实现自动驾驶,代码如下

/**
 * @author 公众号【蝉沐风】
 * @desc 福特汽车厂商提供的接口
 */
public class FordCar{
    public void run(){
        System.out.println("福特开始启动了");
    }

    public void turn(){
        System.out.println("福特开始转弯了");
    }

    public void stop(){
        System.out.println("福特开始停车了");
    }
}

/**
 * @author 公众号【蝉沐风】
 * @desc 本田汽车厂商提供的接口
 */
public class HondaCar {
    public void run() {
        System.out.println("本田开始启动了");
    }

    public void turn() {
        System.out.println("本田开始转弯了");
    }

    public void stop() {
        System.out.println("本田开始停车了");
    }
}

/**
 * @author 公众号【蝉沐风】
 * @desc 自动驾驶系统
 */
public class AutoDriver {
    public enum CarType {
        Ford, Honda
    }

    private CarType type;
    
    private HondaCar hcar = new HondaCar();
    private FordCar fcar = new FordCar();

    public AutoDriver(CarType type) {
        this.type = type;
    }

    public void runCar() {
        if (type == CarType.Ford) {
            fcar.run();
        } else {
            hcar.run();
        }
    }

    public void turnCar() {
        if (type == CarType.Ford) {
            fcar.turn();
        } else {
            hcar.turn();
        }
    }

    public void stopCar() {
        if (type == CarType.Ford) {
            fcar.stop();
        } else {
            hcar.stop();
        }
    }

}

自动驾驶系统运转良好,很快,奥迪和奔驰以及宝马纷纷找到陀螺寻求合作,陀螺不得不把代码改成这个样子。

/**
 * @author 公众号【蝉沐风】
 * @desc 自动驾驶系统
 */
public class AutoDriver {
    public enum CarType {
        Ford, Honda, Audi, Benz, Bmw
    }

    private CarType type;

    private HondaCar hcar = new HondaCar();
    private FordCar fcar = new FordCar();
    private AudiCar audicar = new AudiCar();
    private BenzCar benzcar = new BenzCar();
    private BmwCar bmwcar = new BmwCar();

    public AutoDriver(CarType type) {
        this.type = type;
    }

    public void runCar() {
        if (type == CarType.Ford) {
            fcar.run();
        } else if (type == CarType.Honda) {
            hcar.run();
        } else if (type == CarType.Audi) {
            audicar.run();
        } else if (type == CarType.Benz) {
            benzcar.run();
        } else {
            bmwcar.run();
        }
    }

    public void turnCar() {
        if (type == CarType.Ford) {
            fcar.turn();
        } else if (type == CarType.Honda) {
            hcar.turn();
        } else if (type == CarType.Audi) {
            audicar.turn();
        } else if (type == CarType.Benz) {
            benzcar.turn();
        } else {
            bmwcar.turn();
        }
    }

    public void stopCar() {
        if (type == CarType.Ford) {
            fcar.stop();
        } else if (type == CarType.Honda) {
            hcar.stop();
        } else if (type == CarType.Audi) {
            audicar.stop();
        } else if (type == CarType.Benz) {
            benzcar.stop();
        } else {
            bmwcar.stop();
        }
    }

}

如果看过我上一篇开闭原则的文章,你会马上意识到这段代码不符合开闭原则。没错,一段代码可能同时不符合多种设计原则,那针对今天的「依赖倒置」原则,这段代码问题出现在哪里呢?

我们再来看一下「依赖倒置」原则的要求:

  1. 高层模块(high-level modules)不要直接依赖低层模块(low-level);
  2. 高层模块和低层模块应该通过抽象(abstractions)来互相依赖
  3. 抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

针对第1点,高层模块AutoDriver直接依赖了底层模块XXCar,体现就是在AutoDriver中直接new了具体的汽车对象。因此也就没有做到第2点和第3点。UML类图如下: image.png 那我们就在上层模块和低层模块之间加一层抽象吧,定义一个接口ICar,表示抽象的汽车,这样AutoDriver直接依赖的就是抽象ICar,看代码:

/**
 * @author 公众号【蝉沐风】
 * @desc 汽车的抽象接口
 */
public interface ICar {
    void run();
    void turn();
    void stop();
}

public class FordCar implements ICar{
    @Override
    public void run(){
        System.out.println("福特开始启动了");
    }
    
    @Override
    public void turn(){
        System.out.println("福特开始转弯了");
    }
    
    @Override
    public void stop(){
        System.out.println("福特开始停车了");
    }
}

public class HondaCar implements ICar{
    @Override
    public void run() {
        System.out.println("本田开始启动了");
    }

    @Override
    public void turn() {
        System.out.println("本田开始转弯了");
    }

    @Override
    public void stop() {
        System.out.println("本田开始停车了");
    }
}

public class AudiCar implements ICar{
    @Override
    public void run() {
        System.out.println("奥迪开始启动了");
    }

    @Override
    public void turn() {
        System.out.println("奥迪开始转弯了");
    }

    @Override
    public void stop() {
        System.out.println("奥迪开始停车了");
    }
}

public class BenzCar implements ICar{
    @Override
    public void run() {
        System.out.println("奔驰开始启动了");
    }

    @Override
    public void turn() {
        System.out.println("奔驰开始转弯了");
    }

    @Override
    public void stop() {
        System.out.println("奔驰开始停车了");
    }
}

public class BmwCar implements ICar {
    @Override
    public void run() {
        System.out.println("宝马开始启动了");
    }

    @Override
    public void turn() {
        System.out.println("宝马开始转弯了");
    }

    @Override
    public void stop() {
        System.out.println("宝马开始停车了");
    }
}

/**
 * @author 公众号【蝉沐风】
 * @desc 自动驾驶系统
 */
public class AutoDriver {

    private ICar car;

    public AutoDriver(ICar car) {
        this.car = car;
    }

    public void runCar() {
        car.run();
    }

    public void turnCar() {
        car.turn();
    }

    public void stopCar() {
        car.stop();
    }

}

重构之后我们发现高层模块AutoDriver直接依赖于抽象ICar,而不是直接依赖XXXCar,这样即使有更多的汽车厂家加入合作也不需要修改AutoDriver。这就是高层模块和低层模块之间通过抽象进行依赖。

此外,ICar也不依赖于XXXCar,因为ICar是高层模块定义的抽象,汽车厂家如果想达成合作,就必须遵循AutoDriver定义的标准,即需要实现ICar的接口,这就是第3条所说的具体细节依赖于抽象!

我们看一下重构之后的UML图

image-20220211082552756

可以看到,原本是AutoDriver直接指向XXXCar,现在是AutoDriver直接指向抽象ICar,而各种XXXCar对象反过来指向ICar,这就是所谓的「依赖倒置(反转)」。

看到这里,不知道你是不是对「依赖倒置」原则有了深刻的理解。其实这种中间添加抽象层的思想应用非常广泛,再举两个例子。

3.3 无所不在的抽象

3.3.1 JVM的抽象

JVM虽然被称为Java虚拟机,但是其底层代码的运行并不直接依赖于Java语言,而是定义了一个字节码抽象(行业标准),只要实现字节码的标准,任何语言都可以运行在JVM之上。

3.3.2 货币的诞生

回到物物交换的时代,王二想用自己多余的鸡换一双草鞋,李四想用自己多余的草鞋换一条裤子,赵五想用自己多余的裤子换个帽子。。。如果用物物交换的方式进行下去,这个圈子可就绕到姥姥家了。然后人们就抽象出了中间层——货币,货币作为购买力的标准使得物物交换变得更加方便。 ​

4. 推荐阅读

5. 参考资料