设计原则:依赖反转

1,774 阅读8分钟

在程序设计领域, SOLID(单一功能、开闭原则、里氏替换、接口隔离以及依赖反转)是由 Robert Martin 在21世纪早期 引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则。

依赖反转原则,英文为:Dependency Inversion Principle,简称 DIP

面对这个原则,或许你心中有这些疑问:

  • 什么是依赖反转呢?反转了谁?
  • 依赖反转,经常和控制反转、依赖注入一起被提到,那么控制反转是什么?依赖注入是什么?
  • 如果你对Java比较熟悉,那么你肯定听过Spring,Spring自称是控制反转(IOC)框架,Spring和IOC又有什么关系呢?

等等...

每次面对这些问题的时候,心中总是很懵,我们一起来看看这些问题。

控制反转(Inversion Of Control, IOC)

我们先看一下下面的例子:

public class SendMsgDemo {

    public static boolean canSendMsg {...}
    
    public static void main(String[] args) {
        if(canSendMsg()) {
            // 发送消息处理
        } else {
            // 消息发送失败处理
        }
    }
}

上面的代码,实现了发送消息的功能,你可以发现,能否发送成功的是由方法 canSendMsg 控制,所以所有流程由程序员控制,那么我们可以改变一下,由框架去控制流程呢?

// 将消息发送器抽象
public abstract class MsgSender {

    public abstract boolean canSendMsg();
    
    public void doSend() {
        if(canSendMsg()) {
            // 发送消息处理
        } else {
            // 消息发送失败处理
        }
    }
}

// 实现一个消息发送器
public class UserMsgSender extends MsgSender {
    public boolean canSendMsg() {...}
}

public class SendMsgDemo {
    private static List<MsgSender> senders = new ArrayList<>();
    
    // 注册消息发送器
    public static void register(MsgSender sender) {
        if(sender != null) {
            senders.add(sender);
        }        
    }
   
    public static void main(String[] args) {
        // 注册流程,可以通过第三方配置类或者配置文件进行注册 MsgSender
        // 为了完整逻辑,当前在这里注册
        register(new UserMsgSender());
    
        // 执行消息发送逻辑
        for (MsgSender sender : senders) {
            sender.doSend();
        }
    }
}

在上面的例子中,我们在框架中预留了扩展点 MsgSender , 也就是,我们实现发送信息的逻辑,只需要自定义 MsgSender 即可实现功能,无需再次修改框架逻辑 main,这是典型利用模板设计模式实现控制反转的例子。

框架提供的是代码骨架,通过扩展点组装对象并控制执行流程。程序员在编写框架时,需要思考预留好扩展点,通过扩展点定制化自己的业务,自定义的业务逻辑借用框架,链接驱动整个程序流程的执行。

“控制”是指对程序执行流程的控制;“反转”是由程序员控制程序执行流程,到框架控制程序执行流程,控制权从程序员转移到框架上。控制反转并非实现技巧,而是一种设计思想,主要用于指引框架的设计。

依赖注入(Dependency Injection,DI)

控制反转是一种设计思想,而依赖注入则是一种编程技巧,同时依赖注入也是实现控制反转的一种方式。

了解清楚什么依赖注入,我们要先搞清楚几个概念:

  • 调用方:在编程语法角度,是对象或者类;在服务调用角度,是客户端;
  • 依赖:在编程语法角度,变量;在服务调用角度,是服务;
  • 注入:将“依赖”传递给“调用方”的过程,一般可以通过构造函数、setter 方法等等;

依赖注入,是通过注入给予调用方所需。

为了你能更充分理解依赖注入,我们看看这个例子:

public interface MsgSender {
    void sendMessage(String userId, String msg);
}

public class CustomizedMsgSender implements MsgSender {
    public void sendMessage(String userId, String msg) {...}
}


// 非依赖注入实现方式
public class UserService {
    private MsgSender msgSender;
    
    public UserService() {
        msgSender = new CustomizedMsgSender();
    }
    
    public void sendMessage(String userId, String msg) {
        msgSender.send(userId, msg);
    }
}

UserService userService = new UserService();
userService.sendMessage(...);

// 依赖注入实现方式
public class UserService {
    private MsgSender msgSender;
    // 通过构造器方式注入
    public UserService(MsgSender msgSender) {
        this.msgSender = msgSender;
    }
    
    public void sendMessage(String userId, String msg) {
        msgSender.send(userId, msg);
    }
}

MsgSender sender = new CustomizedMsgSender();
UserService userService = new UserService(sender);
userService.sendMessage(...);

依赖注入,实现起来很简单。上面的例子,通过依赖注入的方式,将依赖的类对象 MsgSender 注入进来,提高了代码的扩展性,也可以灵活地替换依赖的类。

依赖注入框架(DI Framework)

了解清楚什么是依赖注入后,我们看看依赖注入框架。

沿用上面的例子,采用依赖注入的方式,虽然我们不需要使用 hard code 的方式,在类内部通过 new 来创建 MsgSender 对象,但是创建、组装的工作只是移到上层,还是需要程序员自我实现,代码如下所示:

public class Demo {
    public static void main(String[] args) {
        MsgSender sender = new CustomizedMsgSender();
        UserService userService = new UserService(sender);
        userService.sendMessage(...);
    }
}

类似于 MsgSender 的对象,在生产项目中,或许存在成百上千个,如果每个都是以上面的方式依赖注入,依靠程序员写代码去完成依赖注入,一来容易出错,而来开发成本相对比较高。类对象构造和依赖注入的操作,与业务无关,我们是否可以交由框架去完成?我相信,你想到了,依赖注入框架(DI Framework)就应运而生了。

在编写逻辑时,借助依赖注入框架提供的扩展方式,预留扩展点,配置依赖(需要创建的类对象)、类间依赖关系,然后依赖注入框架自动创建所需依赖,管理依赖的生命周期,在扩展点注入依赖等等原本皆由程序员完成的事情。

如果你比较熟悉 Java ,你肯定听过 Spring 框架就是实现了依赖注入的框架。流行的依赖注入也会有很多,如Google Guice、Pico Container、Butterfly Container 等等。

依赖反转原则(Dependency Inversion Principle, DIP)

前面讲述了控制反转、依赖注入、依赖注入框架,我们接下来看看依赖反转原则。

依赖反转原则,有时候也被称作依赖倒置原则。那么,什么是依赖反转原则呢?

依赖反转原则由 Robert Martin 提出,并且在数篇公开著作中被表述,包括论文《面向对象设计质量标准:对于依赖的分析》,以及一篇1996年出现在C++报道中的名为《依赖反转原则》的文章,和《敏捷软件开发,原则,模式和实践》,《C#中的敏捷原则,模式和实践》两本书。

在1996年出现在C++报道中的名为《依赖反转原则》的文章中提到

A. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS. 
B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS.

翻译过来:

A. 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口。
B. 抽象接口不应该依赖于具体实现。而具体实现则应该依赖于抽象接口。

在传统方式,高层次的组件依赖于低层次的组件(从调用链上划分,调用方是高层,而被调用方式低层),一般来说不会有什么大问题,但是,在这种结构下,高层次的组件直接依赖于低层次的组件实现业务。对于低层次组件的依赖限制了高层次组件被重用的可行性。

依赖反转原则,是一种解耦形式,指引框架层面的设计,目的在于将高层次组件从低层次组件中解耦出来,便于不同层级的组件重用,促进了不同层级间的解耦。低层次组件是对高层次组件接口的具体实现,因此低层次包的编译依赖于高层次组件包,低层次组件依赖于高层次组件的需求抽象,这颠倒了传统的依赖关系。

我们通过下图,描述一下依赖反转的依赖关系:

Dependency_inversion.png

图1中,高层对象A依赖于底层对象B的实现;

图2中,把高层对象A对底层对象的需求抽象为一个接口A,底层对象B实现了接口A,这就是依赖反转。

在设计模式中,设备器模式应用了依赖反转原则,高层组件定义了设备器接口,高层组件和低层组件都依赖同一个抽象接口,高层组件使用多态来调用低层组件,而多态调用的设配对象则由低层组件具体实现。

同时,我们在 Tomcat 和 Jetty 中也能看到依赖反转原则的身影。比如Tomcat,Web 应用程序代码部署在 Tomcat 容器下,Tomcat 容器是调用方(高层次组件),而 Web 应用则是被调用方(低层次组件),容器和应用间则是通用 Servlet 抽象依赖。这就是 Servlet 规范,Servlet 并不依赖 Tomcat 容器,也不具有 Web 应用程序的实现细节。