控制反转、依赖反转和依赖注入

开始之前,我们先思考一下以下这几个问题:

带着以上这几个问题,我们来逐一学习一下其中的区别:

控制反转(IOC)

IOC的英文翻译是“Inversion Of Controller”,此处需要注意的是这里的IOC和Spring框架的IOC还是有一定的区别的。

我们先通过一个例子看一下,什么是控制反转。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class UserServiceTest {
  public static boolean doTest() {
    // ... 
  }
  
  public static void main(String[] args) {//这部分逻辑可以放到框架中
    if (doTest()) {
      System.out.println("Test succeed.");
    } else {
      System.out.println("Test failed.");
    }
  }
}

在上面的代码中,所有的流程都由程序员来控制。如果我们抽象出一个下面这样的框架,我们再来看,如何利用框架来实现同样的功能,具体的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public abstract class TestCase {
    public void run() {
        if (doTest()) {
            System.out.println("Test succeed.");
        } else {
            System.out.println("Test failed.");
        }
    }

    public abstract boolean doTest();
}

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

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

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

    }
}

把这个简化版本的测试框架引入到工程中之后,我们只需要在框架预留的扩展点,也就是 TestCase 类中的 doTest() 抽象函数中,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的 main() 函数了。 具体的代码如下所示:

1
2
3
4
5
6
7
8
public class UserServiceTest extends TestCase {
  @Override
  public boolean doTest() {
    // ... 
  }
}
// 注册操作还可以通过配置的方式来实现,不需要程序员显示调用register()
JunitApplication.register(new UserServiceTest();

刚刚举的这个例子,就是典型的通过框架来实现“控制反转”的例子。框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。

这里的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架。

实际上,实现控制反转的方法有很多,除了刚才例子中所示的类似于模板设计模式的方法之外,还有马上要讲到的依赖注入等方法,所以,控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。

依赖注入(DI)

依赖注入跟控制反转恰恰相反,它是一种具体的编码技巧。依赖注入的英文翻译是“Dependency Injection”,缩写为DI。

那到底什么是依赖注入呢?我们用一句话来概括就是:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。

我们还是通过一个例子来解释一下。在这个例子中,Notification 类负责消息推送,依赖 MessageSender 类实现推送商品促销、验证码等消息给用户。我们分别用依赖注入和非依赖注入两种方式来实现一下。具体的实现代码如下所示:

  • 非依赖注入的方式

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    
    /**
     * 非依赖注入实现方式
     */
    public class Notification {
        private MessageSender messageSender;
    
        public Notification() {
            //此处有点像hardcode
            this.messageSender = new MessageSender();
        }
    
        public void sendMessage(String cellphone, String message) {
            //...省略校验逻辑等...
            this.messageSender.send(cellphone, message);
        }
    }
    
    class MessageSender {
        public void send(String cellphone, String message) {
            //....
        }
    }
    
    /**
     * 程序员使用时需要new对象,来发送消息
     */
    class TestCode {
        public static void main(String[] args) {
            Notification notification = new Notification();
            notification.sendMessage("cellphone", "message");
        }
    }
    
  • 依赖注入的实现方式

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    /**
     * 依赖注入的实现方式
     */
    public class NotificationDI {
        private MessageSender messageSender;
    
        /**
         * 通过构造函数将messageSender传递进来
         */
        public NotificationDI(MessageSender messageSender) {
            this.messageSender = messageSender;
        }
    
        public void sendMessage(String cellphone, String message) {
            //...省略校验逻辑等...
            this.messageSender.send(cellphone, message);
        }
    }
    
    /**
     * 使用NotificationDI
     */
    class TestCodeDI {
        public static void main(String[] args) {
    
            MessageSender messageSender = new MessageSender();
            NotificationDI NotificationDI = new NotificationDI(messageSender);
            NotificationDI.sendMessage("cellphone", "message");
        }
    }
    

通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类。这一点在我们之前讲“开闭原则”的时候也提到过。当然,上面代码还有继续优化的空间,我们还可以把 MessageSender 定义成接口,基于接口而非实现编程。改造后的代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Notification {
    private MessageSender messageSender;

    public Notification(MessageSender messageSender) {
        this.messageSender = messageSender;
    }

    public void sendMessage(String cellphone, String message) {
        this.messageSender.send(cellphone, message);
    }
}

interface MessageSender {
    void send(String cellphone, String message);
}

// 短信发送类
class SmsSender implements MessageSender {
    @Override
    public void send(String cellphone, String message) {
        //....
    }
}

// 站内信发送类
class InboxSender implements MessageSender {
    @Override
    public void send(String cellphone, String message) {
        //....
    }
}

class TestCode {
    public static void main(String[] args) {
        //使用Notification
        MessageSender messageSender = new SmsSender();
        Notification notification = new Notification(messageSender);
    }
}

实际上,你只需要掌握刚刚举的这个例子,就等于完全掌握了依赖注入。尽管依赖注入非常简单,但却非常有用,在后面的章节中,我们会讲到,它是编写可测试性代码最有效的手段。

依赖注入框架(DI Framework)

弄懂了什么是“依赖注入”,我们再来看一下,什么是“依赖注入框架”。我们还是借用刚刚的例子来解释。

在采用依赖注入实现的 Notification 类中,虽然我们不需要用类似 hard code 的方式,在类内部通过 new 来创建 MessageSender 对象,但是,这个创建对象、组装(或注入)对象的工作仅仅是被移动到了更上层代码而已,还是需要我们程序员自己来实现。具体代码如下所示:

1
2
3
4
5
6
7
public class Demo {
  public static final void main(String args[]) {
    MessageSender sender = new SmsSender(); //创建对象
    Notification notification = new Notification(sender);//依赖注入
    notification.sendMessage("13918942177", "短信验证码:2346");
  }
}

在实际的软件开发中,一些项目可能会涉及几十、上百、甚至几百个类,类对象的创建和依赖注入会变得非常复杂。如果这部分工作都是靠程序员自己写代码来完成,容易出错且开发成本也比较高。而对象创建和依赖注入的工作,本身跟具体的业务无关,我们完全可以抽象成框架来自动完成。

你可能已经猜到,这个框架就是“依赖注入框架”。我们只需要通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。

实际上,现成的依赖注入框架有很多,比如 Google Guice、Java Spring、Pico Container、Butterfly Container 等。不过,如果你熟悉 Java Spring 框架,你可能会说,Spring 框架自己声称是控制反转容器(Inversion Of Control Container)

实际上,这两种说法都没错。只是控制反转容器这种表述是一种非常宽泛的描述,DI 依赖注入框架的表述更具体、更有针对性。因为我们前面讲到实现控制反转的方式有很多,除了依赖注入,还有模板模式等,而 Spring 框架的控制反转主要是通过依赖注入来实现的。不过这点区分并不是很明显,也不是很重要,你稍微了解一下就可以了。

依赖反转原则

前面讲了控制反转、依赖注入、依赖注入框架,现在,我们来讲一讲今天的主角:依赖反转原则。依赖反转原则的英文翻译是 Dependency Inversion Principle,缩写为 DIP。中文翻译有时候也叫依赖倒置原则。

为了追本溯源,我先给出这条原则最原汁原味的英文描述:

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

我们将它翻译成中文,大概意思就是:高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。实际上,这条原则主要还是用来指导框架层面的设计,跟前面讲到的控制反转类似。我们拿 Tomcat 这个 Servlet 容器作为例子来解释一下。

Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。

updatedupdated2023-06-032023-06-03
加载评论