极客时间已完结课程限时免费阅读

12 | 软件设计的依赖倒置原则:如何不依赖代码却可以复用它的功能?

12 | 软件设计的依赖倒置原则:如何不依赖代码却可以复用它的功能?-极客时间

12 | 软件设计的依赖倒置原则:如何不依赖代码却可以复用它的功能?

讲述:李智慧

时长11:37大小10.64M

在软件开发过程中,我们经常会使用各种编程框架。如果你使用的是 Java,那么你会比较熟悉 Spring、MyBatis 等。事实上,Tomcat、Jetty 这类 Web 容器也可以归类为框架。框架的一个特点是,当开发者使用框架开发一个应用程序时,无需在程序中调用框架的代码,就可以使用框架的功能特性。比如程序不需要调用 Spring 的代码,就可以使用 Spring 的依赖注入,MVC 这些特性,开发出低耦合、高内聚的应用代码。我们的程序更不需要调用 Tomcat 的代码,就可以监听 HTTP 协议端口,处理 HTTP 请求。
这些框架我们每天都在使用,已经司空见惯,所以觉得这种实现理所当然,但是我们停下好好想一想,难道不觉得这很神奇吗?我们自己也写代码,能够做到让其他工程师不调用我们的代码就可以使用我们的代码的功能特性吗?就我观察,大多数开发者是做不到的。那么 Spring、Tomcat 这些框架是如何做到的呢?

依赖倒置原则

我们看下 Spring、Tomcat 这些框架设计的核心关键点,也就是面向对象的基本设计原则之一:依赖倒置原则。
依赖倒置原则是这样的:
高层模块不应该依赖低层模块,二者都应该依赖抽象。
抽象不应该依赖具体实现,具体实现应该依赖抽象。
软件分层设计已经是软件开发者的共识。事实上,最早引入软件分层设计,正是为了建立清晰的软件分层关系,便于高层模块依赖低层模块。一般的应用程序中,策略层会依赖方法层,业务逻辑层会依赖数据存储层。这正是我们日常软件设计开发的常规方式。
那么这种高层模块依赖低层模块的分层依赖方式有什么缺点呢?
一是维护困难,高层模块通常是业务逻辑和策略模型,是一个软件的核心所在。正是高层模块使一个软件区别于其他软件,而低层模块则更多的是技术细节。如果高层模块依赖低层模块,那么就是业务逻辑依赖技术细节,技术细节的改变将影响到业务逻辑,使业务逻辑也不得不做出改变。因为技术细节的改变而影响业务代码的改变,这是不合理的。
二是复用困难,通常越是高层模块,复用的价值越高。但如果高层模块依赖低层模块,那么对高层模块的依赖将会导致对底层模块的连带依赖,使复用变得困难。
事实上,在我们软件开发中,很多地方都使用了依赖倒置原则。我们在 Java 开发中访问数据库,代码并不直接依赖数据库的驱动,而是依赖 JDBC。各种数据库的驱动都实现了 JDBC,当应用程序需要更换数据库的时候,不需要修改任何代码。这正是因为应用代码,高层模块,不依赖数据库驱动,而是依赖抽象 JDBC,而数据库驱动,作为低层模块,也依赖 JDBC。
同样的,Java 开发的 Web 应用也不需要依赖 Tomcat 这样的 Web 容器,只需要依赖 J2EE 规范,Web 应用实现 J2EE 规范的 Servlet 接口,然后把应用程序打包通过 Web 容器启动就可以处理 HTTP 请求了。这个 Web 容器可以是 Tomcat,也可以是 Jetty,任何实现了 J2EE 规范的 Web 容器都可以。同样,高层模块不依赖低层模块,大家都依赖 J2EE 规范。
其他我们熟悉的 MVC 框架,ORM 框架,也都遵循依赖倒置原则。

依赖倒置的关键是接口所有权的倒置

下面,我们进一步了解下依赖倒置原则的设计原理,看看如何在我们的程序设计开发中也能利用依赖倒置原则,开发出更少依赖、更低耦合、更可复用的代码。
这是我们习惯上的层次依赖示例,策略层依赖方法层,方法层依赖工具层。
这样分层依赖的一个潜在问题是,策略层对方法层和工具层是传递依赖的,下面两层的任何改动都会导致策略层的改动,这种传递依赖导致的级联改动可能会导致软件维护过程非常糟糕。
解决办法是利用依赖倒置的设计原则,每个高层模块都为它所需要的服务声明一个抽象接口,而低层模块则实现这些抽象接口,高层模块通过抽象接口使用低层模块。
这样,高层模块就不需要直接依赖低层模块,而变成了低层模块依赖高层模块定义的抽象接口,从而实现了依赖倒置,解决了策略层、方法层、工具层的传递依赖问题。
我们日常的开发通常也要依赖抽象接口,而不是依赖具体实现。比如 Web 开发中,Service 层依赖 DAO 层,并不是直接依赖 DAO 的具体实现,而是依赖 DAO 提供的抽象接口。那么这种依赖是否是依赖倒置呢?其实并不是,依赖倒置原则中,除了具体实现要依赖抽象,最重要的是,抽象是属于谁的抽象。
通常的编程习惯中,低层模块拥有自己的接口,高层模块依赖低层模块提供的接口,比如方法层有自己的接口,策略层依赖方法层的接口;DAO 层定义自己的接口,Service 层依赖 DAO 层定义的接口。
但是按照依赖倒置原则,接口的所有权是被倒置的,也就是说,接口被高层模块定义,高层模块拥有接口,低层模块实现接口。不是高层模块依赖底层模块的接口,而是低层模块依赖高层模块的接口,从而实现依赖关系的倒置。
在上面的依赖层次中,每一层的接口都被高层模块定义,由低层模块实现,高层模块完全不依赖低层模块,即使是低层模块的接口。这样,低层模块的改动不会影响高层模块,高层模块的复用也不会依赖低层模块。对于 Service 和 DAO 这个例子来说,就是 Service 定义接口,DAO 实现接口,这样才符合依赖倒置原则。

使用依赖倒置实现高层模块复用

依赖倒置原则适用于一个类向另一个类发送消息的场景。我们再看一个例子。
Button 按钮控制 Lamp 灯泡,按钮按下的时候,灯泡点亮或者关闭。按照常规的设计思路,我们可能会设计出如下的类图关系,Button 类直接依赖 Lamp 类。
这样设计的问题在于,Button 依赖 Lamp,那么对 Lamp 的任何改动,都可能会使 Button 受到牵连,做出联动的改变。同时,我们也无法重用 Button 类,比如,我们期望通过 Button 控制一个电机的启动或者停止,这种设计显然难以重用 Button,因为我们的 Button 还依赖着 Lamp 呢。
解决之道就是将这个设计中的依赖于实现,重构为依赖于抽象。这里的抽象就是:打开关闭目标对象。至于具体的实现细节,比如开关指令如何产生,目标对象是什么,都不重要。这是重构后的设计。
由 Button 定义一个抽象接口 ButtonServer;在 ButtonServer 中描述抽象:打开、关闭目标对象。由具体的目标对象,比如 Lamp 实现这个接口,从而完成 Button 控制 Lamp 这一功能需求。
通过这样一种依赖倒置,Button 不再依赖 Lamp,而是依赖抽象 ButtonServer,而 Lamp 也依赖 ButtonServer,高层模块和低层模块都依赖抽象。Lamp 的改动不会再影响 Button,而 Button 可以复用控制其他目标对象,比如电机,或者任何由按钮控制的设备,只要这些设备实现 ButtonServer 接口就可以了。
这里再强调一次,抽象接口 ButtonServer 的所有权是倒置的,它不属于底层模块 Lamp,而是属于高层模块 Button。我们从命名上也能看的出来,这正是依赖倒置原则的精髓所在。
这也正好回答了开头提出的问题:如何使其他工程师不调用我们的代码,就能使用我们代码的功能特性?如果我们是 Button 的开发者,那么只要其他工程师的代码实现了我们定义的 ButtonServer 接口,Button 就可以调用他们开发的 Lamp 或者其他任何由按钮控制的设备,使设备代码拥有了按钮功能。设备的代码开发者不需要调用 Button 的代码,就拥有了 Button 的功能,而我们,也不需要关心 Button 会在什么样的设备代码中使用,所有实现 ButtonServer 的设备都可以使用 Button 功能。
所以依赖倒置原则也被称为好莱坞原则:Don’t call me,I will call you. 即不要来调用我,我会调用你。Tomcat、Spring 都是基于这一原则设计出来的,应用程序不需要调用 Tomcat 或者 Spring 这样的框架,而是框架调用应用程序。而实现这一特性的前提就是应用程序必须实现框架的接口规范,比如实现 Servlet 接口。

小结

依赖倒置原则通俗说就是,高层模块不依赖低层模块,而是都依赖抽象接口,这个抽象接口通常是由高层模块定义,低层模块实现。
遵循依赖倒置原则有这样几个编码守则:
应用代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。
不要继承具体类,如果一个类在设计之初不是抽象类,那么尽量不要去继承它。对具体类的继承是一种强依赖关系,维护的时候难以改变。
不要重写(override)包含具体实现的函数。
依赖倒置原则最典型的使用场景就是框架的设计。框架提供框架核心功能,比如 HTTP 处理,MVC 等,并提供一组接口规范,应用程序只需要遵循接口规范编程,就可以被框架调用。程序使用框架的功能,但是不调用框架的代码,而是实现框架的接口,被框架调用,从而框架有更高的可复用性,被应用于各种软件开发中。
我们的代码开发也可以按照依赖倒置原则,参考框架的设计理念,开发出灵活、低耦合、可复用的软件代码。
软件开发有时候像变魔术一样,常常表现出违反常识的特性,让人目眩神晕,而这正是软件编程这门艺术的魅力所在,感受到这种魅力,在自己的软件设计开发中体现出这种魅力,你就迈进了软件高手的大门。

思考题

除了文中的例子,还有哪些软件设计遵循了依赖倒置原则?这些软件中,底层模块和高层模块共同依赖的抽象是什么?
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 10

提建议

上一篇
11丨软件设计的开闭原则:如何不修改代码却能实现需求变更?
下一篇
13丨软件设计的里氏替换原则:正方形可以继承长方形吗?
unpreview
 写留言

精选留言(46)

  • 山猫
    2019-12-18
    依赖倒置这个东西懂得人是真懂,不懂的人是一点不懂。当初为了搞懂依赖倒置原则花了相当长时间去阅读大量的文章和书籍,看了很多代码事例,后来又看了面对对象设计原则才算基本理解。 之前给公司开发培训,他们仍旧听的一脸懵逼,觉得这是个噱头。直到我跟他们说:老板就是找个写代码的人,别把自己看的那么重。你们每天写那么多bug,别怪老板说要换人。他们才理解!
    展开

    作者回复: 这个解释精彩😁

    共 9 条评论
    61
  • Paul Shan
    2019-12-23
    电力系统用了依赖倒置原则,电力系统规定了用电的业务逻辑和插座接口,家用电器只要符合插座接口即可使用。
    27
  • 唐二毛
    2019-12-19
    疑问: 按照我的理解,上层定义接口,对应到项目(传统三层架构),就是controller 层定义 service 层的接口,service层定义 dao层的接口,是这样吗? DI的目的是减少功能变化时,对代码的修改,可是我发现一旦这么做的时候,不但不能减少,反而会增加,因为功能的变化一般都是最上层的变化,比如: 参数的增加,减少,参数中字段的增加,减少,修改,这样的每一个变化都会影响到每一层的接口,所以在每一次功能变化的时候,都需要将整个调用链路的代码修改。 如果每层之间都加了防腐层,那每次变化都陷入到繁琐的改字段,测试字段之中。所有的unit test也要跟着变。这样真就是完完全全的牵一发而动全身! 有时我甚至觉得,把一个功能缩在一个类中,反而会更简单! 希望老师务必解答我的疑问,这个问题困扰我很久了!
    展开

    作者回复: 首先,依赖倒置后Service定义接口,DAO实现接口,不会导致变化更频繁,至少不会比原来DAO定义接口更频繁,因为从纯粹代码角度看,这个接口所有权的变化,换汤不换药,跟以前一样的。当然建议你用DDD的一些设计思路去设计服务和仓储,才能真正体现依赖倒置的优势,设计更稳定。 其次,即使仅仅换汤不换药,也可以强迫设计者从Service的角度去设计接口,而不是从DAO角度设计接口,从而使接口的设计更加符合业务特点。

    共 2 条评论
    16
  • 台风骆骆
    2019-12-18
    我觉得linux的vfs也是这种依赖倒置的,vfs定义一组通用的函数,如read,write,open,close,具体的文件系统实现这些接口,系统调用的时候去调用file结构中的f_op(对应具体的文件系统操作)。
    11
  • 一步
    2019-12-18
    高层模块和底层模块是依据什么划分的?

    作者回复: 根据调用关系,高层模块调用低层模块。即使是设计的时候依赖关系被倒置了,调用的时候也还是高层调用低层。

    6
  • Knuth
    2020-01-08
    看了以后,最大的感觉是为了开闭原则而开闭原则,提出了一系列的设计模式,感觉在讲述的过程中缺少一些推理和比较,直接就给结论,觉得很生硬
    4
  • devilyaos
    2020-04-06
    我觉得这里要引用下我刚入行的时候师傅教导的,程序要自上而下设计,其实mvc三层都是高层先向下设计接口,可以理解成controller定义了要用哪些service功能,然后才针对这个接口建service实现,dao同理。所以其实不要把传统分层的接口当成该层强制规范的接口,其实是上层对下层的契约。
    3
  • 睡浴缸的人
    2020-01-18
    老师说MVC不满足依赖倒置原则,这个我去年写一年业务代码的时候真的感觉到了。发现都是先想怎么写好sql,然后从dao->service->controller。陷入MVC框架里出不来,感觉是披着OOP的皮在写面向过程的代码。后来看了DDD才知道这是一种贫血性模式导致的结果。现在写通用模块的时候,就特别注意这些设计原则,感觉抽象能力真的太重要了!
    共 1 条评论
    2
  • 王艺霖
    2021-07-23
    service层定义dao层的接口,老师能给个例子吗?思维固化了,不知道该怎么写了
    1
  • 池渊
    2020-02-05
    怎么觉得依赖倒置和面向接口编程说的是同一个东西,是哪里理解有误吗?老师帮解答下

    作者回复: 依赖倒置强调:接口使用者定义接口,而不是接口实现者定义接口。 面向接口编程的概念比较泛,通常不强调接口是谁定义的。

    1
  • QQ怪
    2019-12-30
    懂了懂了,看了3遍,听了3遍,基本理解,但是也不能太刻板的认为一定得是高层定义接口吧,只要都依赖抽象感觉就行了,不需要太计较这个抽象层在哪吧

    作者回复: 👍 建议看完模块二再回头看一遍,要计较的😁

    共 2 条评论
    1
  • 蓝魔丶
    2019-12-18
    请教老师一个问题,文中讲到我们平时使用的mvc开发模式是不满足依赖倒置原则的,因为不是高层定义接口抽象,但是即便是低层定义接口,只要不修改接口只修改实现,也是感觉没问题的,有必要强调这里的所属者吗?这个指导思想一般只是指导框架实践吧,平时编写代码好像都不会考虑这个
    共 3 条评论
    1
  • ghimi
    2022-12-07 来自浙江
    我觉得高层模块定义的抽象接口这种说法并不准确,由底层模块定义抽象接口提供给高层模块使用,然后自己实现这种理解应该也满足 "两者都应该依赖抽象"。 高层模块声明需要的接口然后由底层模块实现这种说法可以理解,但是由底层模块通过声明透出接口提供能力而不是直接提供能力这种说法更符合一般的逻辑思维。单纯的说应该由高层模块声明接口感觉太抽象,个人认为高层模块声明接口只是其中一种满足以来导致的方式。 这是我的理解
    展开
  • ghimi
    2022-12-07 来自浙江
    重新理解了依赖倒置👍🏻
  • Best of me
    2022-11-21 来自江苏
    这个有代码资料吗?
  • java小霸王
    2022-06-28
    不是高层模块依赖底层模块的接口,而是低层模块依赖高层模块的接口,从而实现依赖关系的倒置。
  • 老袁
    2022-05-15
    Service 定义接口,DAO 实现接口,这样才符合依赖倒置原则。 这句还是不太好理解,能贴上代码吗老师。
  • Geek_8c0618
    2022-02-12
    大量的策略层 方法层 的描述 很困惑
  • jackie
    2021-04-28
    上层确定要干啥,有不想管具体谁来做,虚拟一个具备这些技能“工具人”,当低层要做这个角色是自然而然变成“工具人”,不管你是谁你在上层都是一个“工具人”。
    1
  • kingcall
    2021-03-28
    小时候家里农田灌溉,由于夏天的时候水流有限,所以会统一将水收到水坝,需要灌溉的时候统一防水,每家每户只需要在自家地头打开`豁口`,统一开坝的时候水就进来了