“
A: 我觉得我们现在的抽象有点多,infra 层里面每一个类都抽取了接口,这些被调用的类多半只有一个实现,我们是不是做的太细了? B: 从依赖倒置的角度讲,domain 层和 service 层并不应该直接调用 infra 层的实现,因此我们确实是需要每一个实现都抽一个接口出来。 A: 那依赖倒置就是每一个实现都要抽一个接口出来吗? B: 这个...
”
看来小伙伴 A 不经意间触碰到了 S.O.L.I.D. 的深水区...
相比于单一职责、开闭、接口隔离等原则,依赖倒置与里氏替换类似,属于更偏向操作指导的一类原则,比如从依赖倒置的定义来看:
依赖倒置:高层模块不应直接依赖低层模块,他们都应该依赖于彼此间的抽象。
以开发的角度理解:高层不要直接调用低层,而是调用抽取出来的接口。
那这么说,依赖倒置就是每一个实现都要抽一个接口出来吗?
为了解释这个问题,我们尝试来提出一个新的问题:为啥要依赖倒置?
为啥要依赖倒置
先说结论:因为依赖倒置能隔离变化,使核心业务更稳定。代码是由业务需求驱动出来的,而业务驱动路径一定是从高层(较为稳定的核心业务层)逐渐传递至低层(较为多变的外围支撑层)。高层不会去了解低层的实现细节,而只会对低层给出需求的定义。依赖倒置就是要明确需求的定义。我们引入一个例子,业务需求如下:对于某文档管理系统,业务上需要对用户创建的文档进行存取。现有若干 Document 文档对象,期望提供某种服务,对文档进行存储,要求存储成功后拿到一个该文档的唯一标识 DocId,并且可以通过该 DocId 再次取回该文档对象。显然,该系统核心业务是对文档的管理与操作。而将文档存储至某种库,之后对其建立索引并关联唯一标识的工作,应该属于对核心业务的一种支撑。所以,将之设计为独立的低层模块比较合适。而高层模块只需要知道我能提供什么,以及我能得到什么即可。所以,高层业务可以抽象出如下 API 来描述这一需求:
id:DocId saveDocument(doc:Document)
doc:Document getDocument(id:DocId)
那么假如从低层即服务提供者角度来看呢?作为服务提供者,也就是需求实现方,我最先想到的也许是:文档对象应该就是文件吧?如果要存储某个文件,存储在独立的文件服务器上会比较稳妥。所以,首先我需要知道文件所在主机的 IP(如有必要还需要相关认证信息),以及文件的绝对路径。之后的实现过程可以分为以下几步:登录到主机上,根据路径找到文件;远程复制该文件到独立的文件服务器;生成一个唯一标识,并与文件服务器的真实路径关联;将唯一标识以 String 的形式返回给调用者。因此对于服务提供方,可能会提供如下 API:
id:String saveFile(ip:String, path:Path)
void getFile(id:String, ip:String, path:Path)
显然,提供方和消费方给出的 API 大相径庭。服务提供方甚至根本就默认把 ”文档“ 这样一个业务概念脑补成了 ”文件“ 。说了半天,没提依赖倒置呀?我们顺着上文的思路,来想一想,假如高低层模块都是由同一个团队来开发维护,并且按照业务驱动的模式来开发,上述需求会怎么一步步变成代码呢?出现业务需求,期望对文档进行存取团队认为,具体文档存取的实现应该不属于 domain 层,而是 infrastructure 层。为了不影响业务卡的开发,团队根据讨论,提取出了文档存取所需的抽象,即:
id:DocId saveDocument(doc:Document)
doc:Document getDocument(id:DocId)
某小伙伴领取实现文档存取的故事卡,先通过工具类获取本机 IP,之后从文档对象中拿到实际的 file,以及对应的元数据,之后存储至远端文件服务,元数据入库,返回唯一 id。后来,由于部署环境变更,远程文件服务不可用,文件存储要改为存储在本地,对于这个需求,做卡的小伙伴只要遵循抽象,重新实现一套本地存储的方案即可,对高层业务完全透明。可见,由于对需求进行了明确的定义,产出了需求的稳定抽象,基于此抽象的实现,不论如何变化,都不会影响到核心业务的稳定,这就是依赖倒置。由业务需求驱动的开发天然满足了依赖倒置的要求,层与层之间互相解耦,整个系统也就对变化表现出了更强的适应性。不过实际当中,很多时候不同模块的开发是由不同团队完成的,我们也许没办法左右已经提供了 API 的基础设施,这时怎么办呢?在实践 DDD 中,我们经常会听到六边形架构的概念,六边形架构内所有的业务逻辑与其他外部依赖之间,全部采用适配器(Adapter)进行适配,以尽可能的隔离业务边界,增加扩展性。所以引申到上述例子,假如系统现有的文件服务提供给我们的 API 必须要以 IP 和文件路径作为参数,那么为了防止业务与外部服务产生依赖,我们仍旧以业务需求驱动的方式,提取文档抽象,之后新增适配器,适配器一端依赖抽象,另一端依赖外部文件服务。通过这种办法就可以很好的实现依赖倒置。有了适配器,无论外部服务怎么变化,只要跟着改适配器,我们的业务仍然是高度内聚的。
回过头来
前文聊了聊为什么要依赖倒置以及怎么进行依赖倒置。现在,我们可以再回到最开始的问题本身:
依赖倒置就是每一个实现都要抽一个接口出来吗?答案显而易见了:恰恰相反,依赖倒置应该是先由业务消费方定义接口,再由服务提供方实现,只不过从最终产出物的角度看,的确是可能每个实现都抽取了一个接口而已。因此假如作为服务提供方,为了满足依赖倒置,臆想消费方的需求来抽取接口,那不叫依赖倒置,叫本末倒置。
总结一下
什么是依赖倒置:高层模块不应直接依赖低层模块,他们应该都依赖于彼此间的抽象。为什么要依赖倒置:因为依赖倒置能隔离变化,使核心业务更稳定。怎么实现依赖倒置:核心业务方定义需求抽象,服务提供方实现需求抽象。
本文分享自 ThoughtWorks洞见 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!