依赖倒置就是每一个实现都要抽一个接口出来吗?

依赖倒置就是每一个实现都要抽一个接口出来吗?

本文的标题实际上来自于一次与项目上同事中午吃饭时的讨论:

A: 我觉得我们现在的抽象有点多,infra 层里面每一个类都抽取了接口,这些被调用的类多半只有一个实现, 我们是不是做的太细了?

B: 从依赖倒置的角度讲,domain 层和 service 层并不应该直接调用 infra 层的实现,因此我们确实是需要每一个实现都抽一个接口出来。

A: 那依赖倒置就是每一个实现都要抽一个接口出来吗?

B: 这个...

看来小伙伴 A 不经意间触碰到了 S.O.L.I.D. 的深水区...

相比于单一职责、开闭、接口隔离等原则,依赖倒置与里氏替换类似,属于更偏向操作指导的一类原则,比如从依赖倒置的定义来看:

依赖倒置:高层模块不应直接依赖低层模块,他们都应该依赖于彼此间的抽象。

以开发的角度理解:高层不要直接调用低层,而是调用抽取出来的接口。

那这么说,依赖倒置就是每一个实现都要抽一个接口出来吗?

为了解释这个问题,我们尝试来提出一个新的问题:为啥要依赖倒置?

为啥要依赖倒置

先说结论:因为依赖倒置能隔离变化,使核心业务更稳定。

代码是由业务需求驱动出来的,而业务驱动路径一定是从高层(较为稳定的核心业务层)逐渐传递至低层(较为多变的外围支撑层)。高层不会去了解低层的实现细节,而只会对低层给出需求的定义。依赖倒置就是要明确需求的定义。

我们引入一个例子, > 业务需求如下: > > 对于某文档管理系统,业务上需要对用户创建的文档进行存取。现有若干 Document 文档对象,期望提供某种服务,对文档进行存储,要求存储成功后拿到一个该文档的唯一标识 DocId,并且可以通过该 DocId 再次取回该文档对象。

显然,该系统核心业务是对文档的管理与操作。而将文档存储至某种库,之后对其建立索引并关联唯一标识的工作,应该属于对核心业务的一种支撑。所以,将之设计为独立的低层模块比较合适。而高层模块只需要知道我能提供什么,以及我能得到什么即可。所以,高层业务可以抽象出如下 API 来描述这一需求:

1
2
3
id:DocId saveDocument(doc:Document)

doc:Document getDocument(id:DocId)

那么假如从低层即服务提供者角度来看呢?

作为服务提供者,也就是需求实现方,我最先想到的也许是:文档对象应该就是文件吧?如果要存储某个文件,存储在独立的文件服务器上会比较稳妥。所以,首先我需要知道文件所在主机的 IP(如有必要还需要相关认证信息),以及文件的绝对路径。之后的实现过程可以分为以下几步:

  1. 登录到主机上,根据路径找到文件;
  2. 远程复制该文件到独立的文件服务器;
  3. 生成一个唯一标识,并与文件服务器的真实路径关联;
  4. 将唯一标识以 String 的形式返回给调用者。

因此对于服务提供方,可能会提供如下 API:

1
2
3
id:String saveFile(ip:String, path:Path)

void getFile(id:String, ip:String, path:Path)

显然,提供方和消费方给出的 API 大相径庭。服务提供方甚至根本就默认把 ”文档“ 这样一个业务概念脑补成了 ”文件“ 。

说了半天,没提依赖倒置呀?

我们顺着上文的思路,来想一想,假如高低层模块都是由同一个团队来开发维护,并且按照业务驱动的模式来开发,上述需求会怎么一步步变成代码呢?

  1. 出现业务需求,期望对文档进行存取
  2. 团队认为,具体文档存取的实现应该不属于 domain 层,而是 infrastructure 层。
  3. 为了不影响业务卡的开发,团队根据讨论,提取出了文档存取所需的抽象,即:
    1
    2
    3
    id:DocId saveDocument(doc:Document)

    doc:Document getDocument(id:DocId)
  4. 某小伙伴领取实现文档存取的故事卡,先通过工具类获取本机 IP,之后从文档对象中拿到实际的 file,以及对应的元数据,之后存储至远端文件服务,元数据入库,返回唯一 id。
  5. 后来,由于部署环境变更,远程文件服务不可用,文件存储要改为存储在本地,对于这个需求,做卡的小伙伴只要遵循抽象,重新实现一套本地存储的方案即可,对高层业务完全透明。

可见,由于对需求进行了明确的定义,产出了需求的稳定抽象,基于此抽象的实现,不论如何变化,都不会影响到核心业务的稳定,这就是依赖倒置。由业务需求驱动的开发天然满足了依赖倒置的要求,层与层之间互相解耦,整个系统也就对变化表现出了更强的适应性。

不过实际当中,很多时候不同模块的开发是由不同团队完成的,我们也许没办法左右已经提供了 API 的基础设施,这时怎么办呢?

在实践 DDD 中,我们经常会听到六边形架构的概念,六边形架构内所有的业务逻辑与其他外部依赖之间,全部采用适配器(Adapter)进行适配,以尽可能的隔离业务边界,增加扩展性。

所以引申到上述例子,假如系统现有的文件服务提供给我们的 API 必须要以 IP 和文件路径作为参数,那么为了防止业务与外部服务产生依赖,我们仍旧以业务需求驱动的方式,提取文档抽象,之后新增适配器,适配器一端依赖抽象,另一端依赖外部文件服务。通过这种办法就可以很好的实现依赖倒置。

有了适配器,无论外部服务怎么变化,只要跟着改适配器,我们的业务仍然是高度内聚的。

回过头来

前文聊了聊为什么要依赖倒置以及怎么进行依赖倒置。现在,我们可以再回到最开始的问题本身:

依赖倒置就是每一个实现都要抽一个接口出来吗?

答案显而易见了:

恰恰相反,依赖倒置应该是先由业务消费方定义接口,再由服务提供方实现,只不过从最终产出物的角度看,的确是可能每个实现都抽取了一个接口而已。

因此假如作为服务提供方,为了满足依赖倒置,臆想消费方的需求来抽取接口,那不叫依赖倒置,叫本末倒置。

最后总结一下

  1. 什么是依赖倒置: 高层模块不应直接依赖低层模块,他们应该都依赖于彼此间的抽象。
  2. 为什么要依赖倒置: 因为依赖倒置能隔离变化,使核心业务更稳定。
  3. 怎么实现依赖倒置: 核心业务方定义需求抽象,服务提供方实现需求抽象。