提到耦合,必须先提依赖。依赖不可避免,而是尽可能地降低耦合。
依赖
模块依赖指模块之间发生了关系,如模块A调用了模块B的接口,则模块A依赖了模块B。依赖的英语是Dependency。
模块依赖是系统内不可避免的,复杂的系统都是分而治之,软件架构活动中最重要的事就是如何正确把系统分解,并定义他们之间关系。存在关系就会存在依赖,依赖是系统分解的必然产物。如果一个系统内的模块间不存在任何的关联,那他们应该划分为不同的系统;一个模块没有与其它的模块发生关联,那这个模块就应该不存在这个系统中。
模块的依赖关系,按生命周期阶段可分为:
- 开发态依赖:如开发模块A时,需要依赖其它模块提供的接口,数据结构等文件依赖;还有一种如测试依赖,仅仅发生在开发阶段,在测试时,需要依赖测试数据,测试框架等,测试完成就不需要了。
- 运行态依赖:在系统运行时,模块A必须依赖其它模块提供能力才能完成某种完整的功能或服务,依赖的形态可能是本地或远程接口,集中配置数据,模型数据信息等。
开发态依赖可能引发运行态依赖,但运行态依赖不一定需要在开发态就依赖。我们经常关注的是运行态依赖导致的问题,目前的微服务架构设计,减少了开发态的依赖,把依赖导致的问题后移到运行态。
模块之间最好还是单向的依赖,如果出现A依赖B,B也依赖A,那么要么是A、B应该属于一个模块,要么就是系统整体拆分有问题。一个完整的软件系统的模块依赖应该是一张有向无环图。
耦合
模块耦合是指去修改一个模块A,需要同时要求依赖它的模块也跟着修改,则他们发生耦合。耦合相比依赖强调的是变化及影响,耦合的英语是Coupling。
由于依赖关系必然存在,变化也不可能避免,当变化发生在某个模块时,影响可能会波开到其它模块,这是依赖带的危害:
- 过多依赖:如一个模块的变化,导致其它多个模块需要跟着发生变化,这是一种强耦合,也势必对系统带来非常多的修改,造成系统的不稳定。
- 依赖传递:如一个模块的变化,导致其多层的下游依赖需要跟着都发生变化,这也是一种耦合,带的的影响往往对系统难评估、不可控。
提到耦合,不得不提一下正交性。正交性是从几何学中借鉴过来的,从软件开发的角度来看,就是一个方法,类,模块的改动不对另一个方法,类,模块造成影响,那么它们就是正交的。正交性设计是有助于简化复杂度,因为任何操作均无副作用,也就能降低模块间的耦合。常说“高内聚,低耦合”,我理解的低耦合,其实是降低变化所带来的影响程度,尽可能地较小影响,甚至不感知变化而无影响。那依赖关系中,被依赖的模块需要设计为;
- 稳定:模块的功能,接口,模型尽可能是不经常变化的。
- 抽象:模块进行了抽象,屏蔽了实现具体细节,依赖看不到变化。
另外一种思路则是想办法控制和消除不必要的耦合,首先是减少不必要的依赖:
- 内聚:控制好模块划分粒度,一个模块跟另一个模块没有功能重叠,一个模型只做好份内事。
- 紧凑:模块暴露的接口,数据越少越好,他们之间越正交,一个模块的变动对另一个模块的影响就最少。
方法
降低耦合是软件界经常谈论的话题,软件大师们已给我们总结一些原则、方法论,下面是我的一些收集与整理。
依赖倒置–面向接口
依赖倒置原则是 Robert Martin
大师在 《Reduce Coupling》书中提出,一句总结就是将依赖关系倒置为依赖抽象,而抽象是往往建立在接口之上。
使用Java的同学,感受最深的是就是interface,在JDK代码中存在大量的设计是基于接口抽象,比如IO操作就抽象出InputStream与OutpuStream接口,定义了统一的Read与Write行为。而实现这些接口可能是本地文件,也可能是网络连接。又如JDBC抽象出核心的Connection与Statement,定义了统一的连接与SQL语句操作方法,可以达到不同的实现对接不同的数据库系统的目的。
抽象带来好处就是,以不变应万变,它隐藏了实现的细节,有效地隔离了变化,从而很大程度地避免了因变化带来更大的波及范围,因为抽象与具体相互完全分离。
同样,降低模块之间的耦合,首先是要面向接口来设计模块。领域驱动设计(DDD)告诉我们的怎么去定义模块的接口:
- 领域就是问题域,有边界,一个模块至少是在领域内,解决其问题
- 建立领域模型来解决领域中的核心问题
- 领域模型是抽象了领域内的核心概念,解决其核心问题
- 核心概念无关技术实现细节,基于接口定义概念
- 梳理领域内的核心概念之间的关系,形成接口的依赖关系
不同的层面的模块,接口形态也有多种,小到语言级的Interface/trait,大到与语言实现无关的RESTful与gPRC接口,他们本质还是DDD中所说的领域通用语言一种描述呈现。
控制反转–关注点分离
控制反转(IoC)是Spring发家秘籍,作为一个框架(Beans管理容器),它成功有效地消除了应用中不同类之间的显示依赖关系。一句总结就是不要让你来调用我,我来主动调用你。
我们的代码实现,大都是组装一个个对象,对象A调用对象B,一直调用下去来完成某种功能,这也是常见的面向过程编程,顺序地组装各类过程。对象A需要主动地创建与管理对象B的生命周期,这是一种正向控制。而反向控制则是由框架来帮忙创建及注入依赖对象,对象只是被动的接受依赖对象,依赖对象的获取被反转了。
反转给我带来启示是,主从地位的变化,把创建和查找依赖对象的控制权交给了框架,由框架进行注入组合对象,带来的好处就对象与对象之间是松散耦合,一是方便测试,二是利于功能复用,使得程序的整个体系结构变得非常灵活。
同样,降低模块之间的耦合,使用控制反转思想,把调用者与被调用者分开。调用者不关心谁是被调用者,只要知道存在一个具有某种特定接口,达到关注点分离:
- 一个关注点就是一个特定的目标或概念,一个模块只有一个关注点,聚集才能高内聚
- 分离的目的是保证模块之间没有功能上的重复,形成正交性
- 被分离的功能通过依赖注入完成逻辑组装
当然,控制反转的前提还是依赖倒置,依赖的对象变成是一个抽象(接口),并不关心接口的实现者是谁。
事件驱动–观察与订阅
经常会碰到这种困境: 模块之间常有一对多的依赖关系,当被依赖模块的状态变化时,其他所有依赖模块都要发生改变。需要维护这种具有依赖关系的对象之间的一致性,又不希望为了维护这种一致性导致模块之间紧密耦合。
撇清关系是降低彼此耦合最为直接手段,以事件的弱引用去解决模块边界的耦合。当模块A需要执行模块B中的业务逻辑,相比于直接调用,我们可以发送一个事件出来。模块B通过一种机制能够接收到这个事件,当这类事件被触发时再去执行它的逻辑。
事件也是的一种抽象,独立于这两个模块之外,这样使得模块之间相互独立,事件在模块之间也实现共享。事件驱动可能存在一个共享内核(事件分发器,事件总线),模块只依赖于这个共享内核,而无需知道彼此的存在,也就实现了解耦合。
事件驱动还有另外一个好处,可能降低模块间的时序耦合:
- 有些业务处理需要耗费相当长的执行时间,不想看到用户耗费时间去等待这些逻辑处理完成,则可以作为异步任务来执行。所要做的是触发一个事件,让Worker来调度执行。
- 有些业务逻辑不需要关注是否在同一个上下文环境中。例如在CQRS框架,命令与查询分离,面向查询优化,查询数据来源是事件的接收与记录。
对于事件的处理,通常有两种方式,他们的区别如下:
- 观察者模式:采用监听器(Listener),通过监听器来监听事件的发生,依据事件做出相应的处理,每个监听器一般小巧,专注于响应特定事件的单个职能。观察者模式常常用于对象或模块之间的一对多依赖,通过事件通知方式来达到解耦合的目的。
- 发布订阅模式:采用订阅者(Subscriber),发布订阅模式需要存一个共享内核,订阅者向这个内核订阅不同的主题(Topic),事件可能被这个内核过滤、缓存,甚至修改了。它更适当异步处理,发布订阅模式是观察者模式一种跨模块(不同的进程)间通讯的延伸。
像Vert.X框架是目前比较受欢迎的基于事件驱动的异步微服务框架,它最主要是把HTTP处理变成事件驱动,核心还是来源于Netty,搞Java的同学不妨多看看Netty源码。
结语
依赖不可避免,但可以降低耦合。降低耦合首先尽可能地是单向依赖;被依赖的模块是稳定的,面向抽象(接口)编程;模块接口操作尽可能无副作用,满足正交性;模块实现上关注点分离,聚集才能高内聚;事件的弱引用一定程度能解决边界与时序耦合。
最重要一点,随着需求的增加变化,依赖与耦合并不是一成不变的,需要不断地去重构才能达到某种平衡,没有绝对松耦合,只是在特定场景下一定程度的松耦合。松耦合目的是降低变化给系统带的危害,切莫本末倒置。