一大组痛苦的maven循环依赖引发的重构危机

起因

将项目的不同模块切分后,发现有些抽象逻辑上并不是紧耦合的模块,由于出现了互相调用,引发了maven的循环依赖。

例如:

A模块依赖B模块,B模块同时依赖A。(多个模块同理)

一般情况下,如果不是使用一些代码分析工具嗅探代码坏味道

A.java中import B;
B.java中import A;

其实十分常见,也不会出现编译错误。

但是在maven中,由于maven是根据依赖关键build package,所以一旦出现循环依赖,maven只能build失败,抛出循环依赖的异常。

也正是这个异常的频繁出现,给了我一个思考模块化、依赖倒置、解耦的契机。

循环依赖

循环依赖(Circular dependency)是指在软件工程中两个或多个模块之间直接或间接地相互依赖,这样的模块组也称之为互递归。

循环依赖的问题

  • 从软件设计角度来说,最大的问题在于互相依赖的紧耦合模块降低甚至完全破坏了单个模块的可复用性。
  • 当局部模块发生的变化传播到与之耦合的其他模块,其多米诺效应很有可能会造成整体的错误(包括程序错误、编译错误)。
  • 循环依赖可能会阻止了使用引用计数(Reference counting)的垃圾回收器(garbage collectors)的自动释放无引用对象,造成内存泄漏。

非循环依赖原则

非循环依赖原则(Acyclic dependencies principle)作为一个软件开发原则其实一直存在(只是我没有知道这个概念),只是在maven这里由于事先机制引发了程序的构建错误。

依赖类型

软件依赖类型分为显式依赖和隐式依赖。

显式依赖的例子:

  • include表达式,如C/C++中的#include,java中的import
  • 构建系统中的依赖(如maven中的dependency

隐式依赖的例子:

  • 依靠暴露的接口中没有明确定义的行为(很常见,不能准确表达impl bean的interface方法名其实都在做隐式依赖)
  • 网络协议

总体来说,无论何时,best practice都是优先使用显式依赖,因为更好分析与定位。

破除循环依赖的策略

  • 依赖倒置原则
  • 将公用依赖放在一个新的package里

依赖倒置原则

依赖倒置原则是面向对象设计5大原则(SOLID))之一,专注于软件模块之间的解耦。

主要要求以下:

  • 高级模块不应依赖低级模块,而都应该依赖于其抽象。
  • 抽象不应依赖细节,而是细节依赖抽象。

在传统的应用架构中,在系统的复杂性不断增长中,低级模块按照被高级模块使用的目的来设计。在这种组合下,高级模块直接依赖于低级模块。这个对低级组件的依赖,大大限制了高级组件的可复用性。

traditional layers pattern

控制翻转

DIP layer pattern

增加了抽象层后,高级组件和低级组件都避免了传统的从顶到底的直接依赖。而是每层都依赖于高级组件根据需求所绘制的行为蓝图的抽象。

这里很容易让人想到一个设计模式————适配器模式。

抽象依赖

在面向对象编程过程中,通常有如下手段满足依赖倒置原则:

  • 一个类的成员变量必须是接口或者抽象类
  • 所有聚合多个类的包必须只和其他接口或抽象类的包连接
  • 没有类应该继承具体类
  • 没有方法应该复写一个已经被实现的方法
  • 所有的变量实例,都需要一个诸如工厂方法模式的创建设计模式的实现,或者使用依赖注入框架(如:spring IOC, angularjs)

maven模块切分

我使用maven的目的之一,就是希望通过maven时刻提醒开发者:解耦解耦。我希望达到的效果,就是当我做一个项目时,相似的模块可以直接从原项目中拔下来就用,查看一下pom.xml的依赖列表,分析一下要剔除的依赖模块,再将代码稍作清理就可以使用。而不是缺乏管理的项目那样,把代码复制出来,消除编译错误就已经是浩瀚的工程。

maven切分模块的意义不言而喻。

google了很久,看出主流的切分方式有2种:

  • 水平切分

    即基于业务切分,将项目切分为customer,order,admin,work等。我最初就是这样切分的,这样的缺点,上文已经充分说明,由于高层依赖底层,造成复用性极差,每个service,dao基本都是定制给controller使用的。一旦试图依赖其他模块,很容易造成循环依赖,也就是这个文章的来源。

  • 垂直切分

    即基于系统层级切分,分为controller,service,dao等,这个缺点也很明显,根本达不到我的拔下来就用的目的,每一层都分离,但是层内不同业务之间的纠缠与耦合,却没有管理。

遵循单一责任原则的水平与垂直共同切分

经过一次次模块设计失败后,我一定慢慢拨开了挡在眼前的迷雾,我需要的模块切分方式,应该以以下原则为指导:

  • 先将项目按照业务切分
  • 在将每个业务按照系统层级切分,在我的系统中,我认为dao层与其具体实现是紧耦合(肯定是使用hibernate访问mysql)的,所以分成了rest-api,service-interface,service-implementation,data四层
    • rest-api层:取名一律为业务名+’-api’,主要规定rest api的controller规则
    • service-interface层:取名一律为业务名+’-service’,主要规定该业务需要暴露的服务,这些服务的使用者,既有可能是上层rest-api层,也有可能是其他service-implementation
    • service-implementation层:取名一律为业务名+’-core’,显示了这一层负责了最主要的业务逻辑,是系统的核心组件,此层依赖于service-interface层,并实现其接口,并且根据需要依赖其他interface
    • dao层:取名一律为业务名本身,负责dao操作以及表结构的规定(PO),主要方法集成自通用类BaseDao(Hibernate的应用封装)
    • 其他穿插组件:
      • base-model:定义了模型层面的一些基础数据,如BaseEntity
      • base-exception:定义了通用的异常接口以及错误信息
      • base-dao:定义了基于泛型的通用的常见dao操作
      • base-util: 常用工具(base系列随便移植)
      • authorization-filter: 根据具体业务逻辑实现的访问权限控制,依赖于相应service-interface,并且被需要权限控制的rest-api层依赖
      • sdk: 各种第三方sdk,主要供service-implementation层依赖使用
  • 单一责任原则(SRP),从业务和技术上总和考虑,确实只完成了一件事的class,才聚合在一个模块中

这里需要画一个UML图,然而我不是巴哈我不会(ノಠ益ಠ)ノ彡┻━┻

至此,模块之间的依赖关系就趋于线形而不是环形,单个业务组内,rest-api依赖于service-interface,service-implementation实现service-interface,并依赖data层。

业务组之间,service-implementation可能依赖于其他service-interface形成交叉,data层可能由于PO的主外键关系注册需要单向依赖。

缺陷

  • 实际工作的webapp-module,其实需要依赖于rest-api,service-implementation的所有集合,也就是说必须成套出现,这种关系某种程度上说是一种隐性依赖
  • data层属于最低层级,没有接口隔离,造成跨模块之间只能单向依赖,典型场景就是PO之间的外键双向映射无法满足(直接循环依赖了)
  • module好多,IDE:宝宝心里苦