解耦并不难 – 单体系统中的解耦

我们先从简单开始,以Java语言为例,看看一个单体应用中如何做到类和类之间,模块和模块之间的低耦合的设计的。下一篇文章中我们会讨论系统和系统间的低耦合设计。

什么是依赖

不理解依赖就无法理解耦合,管理好依赖就能实现合理的解耦。

A使用了B,那么就可以说A依赖了B。这很好理解。依赖会产生耦合,比如:

public class CalendarEventService {
  public List readEvents(File f){
    //从文件系统里读取日程安排
  }
}

这里方法的参数是一个 File 类型,它只能从文件系统读取数据返回日程列表,无法从网络上或数据库中读取数据。这时,我们就可以说这个类和本地文件系统耦合了。

类之间依赖

上面的例子怎么写更好呢?

public class CalendarEventService {
  public List readEvents(
                InputStream input){
    //从InputStream读取日程安排
  }
}

这样依赖更抽象的 InputStream ,就不和本地文件系统耦合了。

这符合DIP(Dependence Inversion Principle,依赖倒置原则):

  • 高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象

  • 抽象不应该依赖于具体,具体应该依赖于抽象

我们对上面的例子解读一下:

  • CalendarEventService 是高层次模块, File 是低层次模块,他们都依赖于一个抽象InputStream

  • 要实现从本地文件系统读取数据以 InputStream 的形式传递给 CalendarEventService 的方法,则需要实现一个 FileInputStream 这个具体,抽象 InputStream 不依赖于这个具体 FileInputStream , 但具体 FileInputStream 要实现 InputStream ,所以具体应该依赖于抽象

这里的A依赖B可以简单理解为要编译A的话classpath中必须要有B

如何定义什么是高层次模块,什么是低层次模块,什么是具体,什么是抽象, DIP 并没有给你明确的指导,比如,上面的方法改成下面这样:

public class CalendarEventService {
  public List readEvents(
      CalendarEventRepository repo){
    //用Repository读取日程安排
  }
}

我不喜欢Dao,更喜欢Repository,Repository比Dao更接近本质

是不是更好些呢? CalendarEventRepository 作为上面说的那个”抽象”,它比 InputStream 更抽象,按我们的经验感觉效果确实也更好了。

为什么更好呢?总不能说越抽象越好吧,那就会落入无休止的抽象黑洞之中。

更好背后的原因是这个 抽象的层次和 高层次模块的层次更接近。有人给DIP做了一点补充:” 代码只应该依赖相同层次的或更高层次的抽象 “。

但是, 相同层次的或更高层次的抽象 难以界定,也容易让人和分层架构的原则混淆而产生误解——在大部分人中的观念里,上层依赖于下层,下层自然低于上层,怎么可能被依赖的层反而抽象层次更高呢?

另一种解释是” 代码应该依赖稳定的抽象,而不是依赖容易变化的抽象 “。道理很简单,但所依赖的抽象是否足够稳定,这个就需要经验来判断了。如果我们的Service依赖JDBC,从技术角度上说也是依赖了抽象,JDBC是对数据库访问的抽象,但是我们要判断这个抽象是否足够稳定,要判断读取数据的方式未来是否可能变化,比如不是从关系型数据库中读取了,或依然从关系型数据库读取但之前从一个表读取,后来由于非功能需求改为从两个表读取了,考虑到这些,对于Service来说,JDBC这个抽象就太过具体了,所以Service应该依赖一个层次更高的抽象,这就是 Repository/Dao 产生的原因。

抽象的成本和变化的概率这两个因素要平衡考虑。另外,合理的抽象除了更容易应对变化外也有其它收益,比如代码的可读性带来的可维护性。

如果一个依赖足够抽象,就会接近它所关注的领域的本质,这是它不容易变化的原因。比如,我们记日志时使用 SLF4JAPI ,它已经是一个在日志领域高度抽象后形成的一个 Logging Facade ,屏蔽了很多的具体日志类库,它足够抽象,也足够稳定,所以我们的代码中直接使用它就可以了。

如上面的例子所示,方法参数会导致依赖。局部的代码或类的 Field 也可能引起依赖,比如:

public class CalendarEventService {
  public List readEvents(){
    CalendarEventRepository repo 
         = new CalendarEventJdbcRepository();
    //用Repository读取日程安排
  }
}

public class CalendarEventService {
  CalendarEventRepository repo;
  public CalendarEventService(CalendarEventRepository repo;) {
    this.repo = new CalendarEventJdbcRepository();
  }
  public List readEvents(){
    //用Repository读取日程安排
  }
}

上面的两段代码都违反了DIP,因为它即依赖了 CalendarEventRepository 这个抽象,又依赖了具体的 CalendarEventJdbcRepository 实现。

那这时候就需要其他一些技术来解决这个问题了,比如:

  • 工厂模式

  • Java SPI

  • DI( Dependency injection,依赖注入 )框架,比如Spring Framework

注意这里的DI中的I指的是injection,即注入,而DIP中的I指的是Inversion,即,倒置

模块间依赖

依赖发生在类与类之间,也发生在模块和模块之间,在Java语言的层面,并没有模块这个语义,我们一般可以通过包来表达模块的含义,比如下面按照DDD的思想常用的一种包结构:

├── applicationservice
│   └── impl
├── domain
│   ├── model
│   ├── repository
│   └── service
│       └── impl
├── infrastructure
│   └── repository
|         └── mybatis
├── web
|    └── controller
└── bootstrap

注意 domain.repository 下放置的是接口,真正的实现类是在 infrastructure.repository.mybatis 下面的。对于 service 来说,他依赖 domain.repository 这个抽象,而不依赖 infrastructure.repository.mybatis 这个具体。这完全符合DIP原则。那真正运行时他们是怎么协作的呢?这时一个IoC容器就很重要了,比如上图中的 bootstrap 模块正是利用Spring把它们装配起来并运行的。

这种模块的划分,符合整洁架构的要求,体现了DIP原则。

为了保证在编写代码的时候这种规则不被破坏,可以使用 ArchUnit 来定义自动化检查规则。详细可以参见我之前写的一篇关于ArchUnit的文章 《 少年,休想破坏朕的架构 》。

如果想观察一下包间的依赖,以审视他们间的依赖是否合理,可以使用 IntelliJ IDEA 的一个叫 Analyze Dependency Matrix 的功能,可以通过菜单 Analyze → Analyze Dependency Matrix... 找到这个功能。

这个功能只有商业版里有,社区版里没有。

图中绿色的标记是依赖当前选中包的包,黄色标记是当前选中包依赖的包。

还可以更进一步,用二进制发行包隔离各个模块,比如,上面的目录结构中,可以把 domain.repository 发行为 repository-api.jar ,把 infrastructure.repository.mybatis 发行为 repository-mybatis.jar 。可以使用maven或gradle这样的依赖管理和构建工具来组织代码,通过依赖关系来审视依赖是否合理。

更多

上面的例子都是 编译时依赖 ,在运行时上层模块是会依赖下层模块的,比如上层模块一定会调用 CalendarEventJdbcRepository 的方法,而不是 CalendarEventRepository 的方法,这使得在运行时也间接依赖了 JDBC ,甚至某个具体厂商(比如 MySQL )的 JDBC 实现,这非常合理,没有这种运行时的依赖,各个组件之间就没有办法协作来完成真正的工作。恰恰是面向对象语言提供的这种运行时绑定机制实现的多态性,让我们的 DIP 这个原则在可运行的软件中落地成为可能。

有一种说法比较容易理解这个问题,代码层面的功能单元叫做模块,运行期的叫做组件,我们这里所说的解耦是指模块间的解耦,而不是组件的解耦。

但是我们不能狭义地理解 编译时依赖运行时依赖 ,试想,我们使用一种不用编译的动态语言,或者我们采用微服务架构,服务间通过HTTP通信,这样就不会产生 编译时依赖 了,那是不是我们做出的系统就是足够低耦合的呢?

显然不是。

下一篇文章中我们会探讨比模块更大的粒度的分布式系统之间的解耦设计。