当前位置:   article > 正文

软件设计不是CRUD(3):降低模块间耦合性——设计实战_软件系统间模块间的耦合必须最小化

软件系统间模块间的耦合必须最小化

本系列文章专注于讨论在业务系统设计时,如何降低业务系统中各个模块的耦合度,已提供更好的业务扩展性。本系列文章还会具体演示设计模式如何被用于实际的业务模块设计过程中。(注意:本系列文章会假设读者已经知晓常用的设计模式,并已经有真实的业务系统开发经历)

========接上文《软件设计不是CRUD(2):降低模块间耦合性——需求场景

3、利用简单的设计模式进行解耦

以上文中提到的需求场景为例。如果将需求描述直接翻译成代码,那么到货单(以及其他单据)和入库单的调用关系如下图所示:

按照需求描述,能最直接形成的逻辑调用关系
注意,以上代码是按照业务需求直接翻译成代码逻辑的方式,所建立的模块和模块间的依赖关系。经过上文的分析我们知道了,这样的依赖关系不能适应需求场景的变化(详见上文,这里不再进行赘述)。那么我们怎样降低这些模块的依赖,使其能够适应上文所描述的需求变化呢?设计模式中,我们常用行为模式来降低模块调用间的耦合性,解除两个或者多个模块的循环依赖。根据上文末尾处的介绍,实际上读者很容易想到两种行为模式:监听器/观察者模式,以及责任链模式。

我们分别来看看这两种设计模式如何帮助我们适应上文提到的需求变化场景。

3.1、通过简单的事件监听模式进行解耦

当到货单被创建成功,就生成入库单。那么我们可以在到货单模块中定义一个到货单的监听器,并由入库单实现这个监听。
在这里插入图片描述
在这样的情况下,到货单模块压根就不知道上层有一个入库单模块,或者上层实现了该接口的任何模块。这就便于我们在到货单模块发生了创建单据事件后,能够在这个事件触发的基础上扩展出任何的后续业务。我们来看一下伪代码情况:

/**
 * 到货事件监听器
 * @author yinwenjie
 */
public interface ArrivalEventListener {
  /**
   * 当到货单据创建完成后,该事件将被触发。
   * 如果该方法抛出了异常,那么包括到货单创建过程在内的整个处理过程将会被回滚
   * @param arrivalInfo 已经创建好的到货信息
   */
  public void onCreate(ArrivalInfoDto arrivalInfo);
  /**
   * 当指定的到货单被删除后,该事件将被触发。
   * 如果该方法抛出了异常,那么包括到货单删除过程在内的整个处理过程将会被回滚
   * @param arrivalInfo 当前被删除的到货单信息
   */
  public void onDeleted(ArrivalInfoDto arrivalInfo);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

下面入库单模块实现该接口,以便监控到货单的事件,并根据这个事件完成对应的处理过程:

/**
 * 入库单模块实现该接口,以便监控到货单的事件,并根据这个事件完成对应的处理过程
 * @author yinwenjie
 */
// 如果有spring-boot,则增加注解。
@Component
public class WarehousingForArrivalEventListener implements ArrivalEventListener {
  // 入库单业务服务
  private WarehousingInfoService warehousingInfoService;
  
  @Override
  public void onCreate(ArrivalInfoDto arrivalInfo) {
    // 转换成入库单内部的对象
    WarehousingInfo warehousingInfo = new WarehousingInfo();
    // warehousingInfo.setField1(arrivalInfo.getField1());
    // ......
    
    // 现在创建入库单
    warehousingInfoService.create(warehousingInfo);
    // .....
    // 至此,入库单模块内部根据到货单创建事件,所需要完成的逻辑完成
    // 至于其它模块是否根据入库单创建事件,完成其他业务,不再是入库单模块所关心的了
  }

  @Override
  public void onDeleted(ArrivalInfoDto arrivalInfo) {
    // 这里的代码省略,大概就是当删除入库单时,对应的到货单也需要进行删除
    // ......
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

完成了到货单的实现后,我们再来看看在应用了监听模式后,到货单创建事件被触发时,到货单内部的逻辑有什么变化。

/**
 * 到货单服务的具体实现,修改后的到货单服务实现与之前按照需求翻译成代码的逻辑有明显不同
 * @author yinwenjie
 */
public class ArrivalInfoServiceImpl implements ArrivalInfoService {
  // 到货单内部的持久层服务
  @Autowired
  private ArrivalRepository arrivalRepository;
  // 这里不在具体依赖WarehousingService(入库单服务)
  // 具体来说,处于下层的到货单模块根本就不知道上层有一个入库单模块的存在
  // 而是有一个实现了到货事件接口的具体实现的接口
  @Autowired
  private List<ArrivalEventListener> arrivalEventListeners;
  
  /**
   * 创建到货单的逻辑
   * 使用spring-boot中的事务注解,保证所有逻辑动作的数据一致性
   */
  @Override
  @Transactional
  public void create(ArrivalInfo arrivalInfo) {
    // 首先进行到货单自身的验证和保存
    ArrivalInfo arrival = new ArrivalInfo();
    // 当然要进行相关属性的赋值,并进行验证
    // ......
    // validate(arrival);
    // ......
    this.arrivalRepository.save(arrival);
    
    // 到货单本身的业务处理完了,至于这个业务外其他模块要做什么逻辑,不再是到货模块本身关心的内容
    // 到货单只需要触发监听,把创建事件通知出去即可
    // 变成arrivalInfoDto,传输到其他模块
    ArrivalInfoDto arrivalInfoDto = new ArrivalInfoDto();
    arrivalInfoDto.setField1(arrival.getField1());
    // ......
    for (ArrivalEventListener arrivalEventListener : arrivalEventListeners) {
      arrivalEventListener.onCreate(arrivalInfoDto);
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40

注意:为了示例伪代码的准确性,我们可以通过某种创建模式建立模块和模块间的行为关系。但是在实际应用中,我们一般使用Spring/Spring Boot的IOC容器自动注入模块和模块间的行为关系,以便做到正真的下层模块完全不知道上层模块的存在。

/**
 * 这是专门为表达设计意义,创建的学习示例性质的工厂
 * 在真实使用Spring等注入式框架的情况下,是不会有这样的工厂类的
 * @author yinwenjie
 */
public abstract class ArrivalFactory {
  /**
   * 创建具体的ArrivalInfoService服务,再次强调,在使用诸如Spring等注入式框架的实际工作中
   * 不会这样“new”一个ArrivalInfoServiceImpl对象
   */
  public static <T extends ArrivalEventListener> ArrivalInfoService createArrival(List<T> eventListeners) {
    ArrivalInfoService arrivalInfoService = null;
    if(eventListeners != null && eventListeners.size() > 0) {
      arrivalInfoService = new ArrivalInfoServiceImpl(eventListeners);
    } else {
      arrivalInfoService = new ArrivalInfoServiceImpl();
    }
    return arrivalInfoService;
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

但使用事件监听的方式进行业务过程的驱动,也存在几个问题:

  • 事件涟漪
    事件涟漪是指当A模块的某种事件触发了B的逻辑,B的逻辑在完成后,由于也是使用的事件监听方式,所以又触发了上层的C模块的逻辑。但是C模块的逻辑过程又调用了A模块的逻辑,重新触发了A模块的相同事件(可能只是单据类型不一样)。如下图所示:
    在这里插入图片描述
    这种事件涟漪的出现并不违反单向依赖的模块间结构,但是从研发层面上讲由于每个模块的具体开发人员可能不一样,这种事件涟漪效果很难被排查出来。所以事件模式(或者观察者这类型的模式)只适合在简单的业务场景中使用,这种业务常见的连续触发最好不要超过一层。

  • 无法知晓上下文

事件的触发都是相对独立的,上一个实现事件的触发逻辑B,并不知道下一个实现事件的触发逻辑C;甚至由于各个实现逻辑并没有实现诸如org.springframework.core.Ordered这样的排序接口,那么各个实现实现的逻辑本身的顺序都无法确认。这导致各个具体实现事件的触发逻辑本身并没有上下文的支持,不能知道整个事件链条的全貌。实际上这也是产生上一个问题的原因——由于事件的响应逻辑无法知道事件链条的全貌,就无法分场景决定是否要继续传递事件涟漪。

那么可以得出一个结论:如果当前业务线的逻辑比较复杂,那么诸如事件模式(或诸如观察者这类的模式)是不适用的。它只适合一些简单的业务扩展场景,例如在模块A正式的处理过程开发前,触发诸如验证过程这个样的独立逻辑。那么我们可能需要寻找更适合处理复杂业务调用链的行为模式。

3.2、通过责任链模式进行解耦

责任链模式是一种更好的,用于处理复杂业务调用过程的行为模式。这种模式具备上下文存取、业务链条可控等特点,可以用来补充简单的事件模式的缺点。责任链模式有两种实现方式,一种是为了学习讲解所使用链式调用方式,它的特点是一个控制单元对各个实现者的连续调用。但实际工作中,一般不会采用这样的责任链实现方式,而是采用递归调用的方式实现责任链,这里我们就来进行介绍。
在这里插入图片描述
主要的实现代码参考如下:

  • 首先我们创建一个责任链控制器:ArrivalEventChain
/**
 * 这是用责任链实现事件触发的控制器
 * @author yinwenjie
 */
public class ArrivalEventChain {
  /**
   * 这是需要被控制的事件
   */
  private List<? extends ArrivalEventListener> eventListeners;
  /**
   * 这是已经完成创建/修改的到货单(模块间传输对象)
   */
  private ArrivalInfoDto arrivalInfo;
  /**
   * 这是当前正在被操作的事件实现
   */
  private int index = -1;
  /**
   * 这个控制器中,可以根据实际的业务形式,加上很多结构的信息存储方式
   * 这些被存储的信息,理论上可以有参与事件的各个实现类进行写入/读取
   * 这里使用的一个K-V结构的集合,来存取上下文信息
   */
  private Map<String , Object> params = new HashMap<>();
  
  public ArrivalEventChain(ArrivalInfoDto arrivalInfo , List<? extends ArrivalEventListener> eventListeners) {
    // 被控制的事件不能是一个空集合
    if(eventListeners == null || eventListeners.isEmpty()) {
      throw new IllegalArgumentException("event size not null");
    }
    if(arrivalInfo == null) {
      throw new IllegalArgumentException("arrival info not null");
    }
    this.eventListeners = eventListeners;
    this.arrivalInfo = arrivalInfo;
  }
  
  public void doNext(ArrivalEventChain eventChain) {
    // 如果传入的控制器对象不是
    if(eventChain != this) {
      throw new IllegalArgumentException("this not my eventChain");
    }
    // 如果条件成立,说明当前已经处理到最后一个事件触发实现了,不用再继续了
    if(index++ >= this.eventListeners.size()) {
      // .... 这里还可以根据实际情况加入一些特定的处理逻辑
      return;
    }
    // 取出下一个将要触发的事件实现
    ArrivalEventListener currentEventListener = this.eventListeners.get(index);
    currentEventListener.onUpdate(this.arrivalInfo, this);  
  }
  public Object get(String key) {
    // .....
    // 这里省略了实现代码
    return this.params.get(key);
  }
  public void set(String key , Object values) {
    // .....
    // 这里也省略了一些简单的验证、实现代码
    this.params.put(key, values);
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 接着我们看看当我们决定使用迭代方式进行责任连控制时,事件接口该如何定义:
/**
 * 到货事件监听器
 * @author yinwenjie
 */
public interface ArrivalEventListener {
  // ......
  /**
   * 当到货单完成修改后,该事件将被触发
   * @param eventChain 就是事件控制器
   */
  public void onUpdate(ArrivalInfoDto arrivalInfo , ArrivalEventChain eventChain);
  // ......
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 然后我们来看看实现ArrivalEventListener监听接口的入库单处理逻辑:
/**
 * 入库单模块实现该接口,以便监控到货单的事件,并根据这个事件完成对应的处理过程
 * @author yinwenjie
 */
public class WarehousingForArrivalEventListener implements ArrivalEventListener {
  // ......
  @Override
  public void onUpdate(ArrivalInfoDto arrivalInfo , ArrivalEventChain eventChain) {
    // 查询当前修改的到货单对应的入库单
    WarehousingInfo warehousingInfo = warehousingInfoService.findByArrivalId(arrivalInfo.getId());
    
    // ......
    // 这里进行相关逻辑处理
    // ......
    
    // 进行下一个处理递归(一定要调用,否则递归过程就会终止)
    // 就类似servlet-filter,再doFilter方法中一定要调用doFilter方法一样
    eventChain.doNext(eventChain);
  }
  // ......
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 最后我们看看到货单逻辑在update(更新)操作完成后,如何触发事件:
/**
 * 到货单服务的具体实现,修改后的到货单服务实现与之前按照需求翻译成代码的逻辑有明显不同
 * @author yinwenjie
 */
public class ArrivalInfoServiceImpl implements ArrivalInfoService {
  @Autowired
  private List<? extends ArrivalEventListener> arrivalEventListeners;
  // ......
  public void update(ArrivalInfo arrivalInfo) {
    // 首先进行需要修改的到货单的自身验证和保存
    // ......
    
    // 将当前的业务对象(PO)、转换成规范的模块间信息传输对象(DTO)
    // 当然实际工作中,该使用什么性质的对象进行传输,可以根据实际业务场景进行设计
    ArrivalInfoDto dto = this.transformDto(arrivalInfo);
    
    // 然后使用递归形式的责任链
    // 只有存在事件时,才进行责任链的调用
    if(this.arrivalEventListeners != null && !arrivalEventListeners.isEmpty()) {
      ArrivalEventChain arrivalEventChain = new ArrivalEventChain(dto, arrivalEventListeners);
      arrivalEventChain.doNext(arrivalEventChain);
    }
    
    // 当事件触发完成后,还可以在这里进行一些更新后的逻辑处理
    // ......
  }
  // ......
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

以上示例代码中,ArrivalEventChain是递归进行责任链中每个节点执行的关键控制器;另外,每一个节点在自己对事件响应逻辑处理过程中,如果要向下触发下一个责任链节点的处理逻辑,则调用ArrivalEventChain控制器的doNext方法。使用递归调用的方式实现责任链,至少有以下几个优点:

  • 由于递归调用的方式本质是将一个下一个调用嵌套在上一个调用的内部,所以上一个调用A可以根据下一个调用(和下面多个调用)处理结果决定调用过程A的后续处理过程

  • 由于存在递归性,所以每个调用过程都可以读写上下文对象,并被递归中的任意一个调用过程使用。

  • 责任链模式在面向B/S架构的设计过程中是常见使用的一种行为模式,例如Servlet的过滤器设计就是一个典型的责任链模式。

  • 最后,如果读者是需要自行研究责任链模式,则可以通过集合顺序遍历的方式进行研究学习;但如果时在正式的工作中使用责任连模式,则还是推荐本文中这种递归方式进行实现。

3.3、依赖被倒转了

无论是读者使用监听器模式还是责任链模式,亦或者其他的行为模式来降低模块的依赖耦合,我们都可以观察到一个现象,就是虽然从业务需求来看,到货单逻辑需要调用(依赖)入库单逻辑。但是在实际模块的实现设计中,可以设计成入库单业务依赖到货单业务。如下图所示:
在这里插入图片描述
实际上根据依赖反转的思想,模块和模块间的依赖关系,可由架构师自由进行变化。但是这两个模块或者多个模块间的依赖一定存在一下特点。

  • 不存在循环依赖:不存在循环依赖,是两个或多个模块进行低耦合设计解决的最基本问题。只有两个或多个模块不存在循环依赖了,这些模块的依赖关系才能进行自由变化,才能出现并稳定模块的分层。

  • 依赖可被设计:存在依赖的两个和多个模块,在进行解耦设计后,并不是代表模块间没有依赖,而是说依赖关系可以被设计,而这种依赖设计不基于业务本身的描述,而是基于设计师对业务变化的理解。将更易变化的业务模块上浮,将更不易变化的各种模块下沉,且下层模块可能都不知晓上层模块的存在。

  • 模块分层应按照模块的业务性行划定:应用系统中,一旦所有模块都稳定下来,那么这些模块形成的业务分层也就可以自行稳定下来。最终稳定的的分层一定又这样的特点:越和业务无关的、工具性质的模块,会更处于分层的下部(这个特点不以PPT或者某个人的主观理解为转移);越和业务相关的、越可能变化的业务模块,应更处于分层的上部。那么这样看,如果是二次开发团队的定制化业务模块,就应该处于整个应用的更上层。

  • 变化涟漪可控:在依赖较低、分层稳定的应用系统中,模块内部变化的涟漪效果可以被有效控制。也就是说,模块内部的处理逻辑发生了变化,那么这些变化对模块外部是不可见的;如果,模块的调用功能发生了变化,那么这些变化性质只限于新增、替换,不包括对接口的修改,且这些变化对上层依赖他的模块风险可控。

那么通过这以上内容,读者应该对如何围绕降低模块间耦合的目的进行应用系统设计有了一个初步的概念,但是很多概念性的内容在这里只是做了点到为止的介绍,例如系统分层、设计重用等。从下一篇专题文章开始,将采用先讲解理论再讲解实践的方式,和读者更深层次的剖析如何设计高效、稳定、易扩展的应用系统。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Gausst松鼠会/article/detail/67002
推荐阅读
相关标签
  

闽ICP备14008679号