应用软件设计不是CRUD:如何进行应用系统功能模块的耦合性设计

Posted 说好不能打脸

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了应用软件设计不是CRUD:如何进行应用系统功能模块的耦合性设计相关的知识,希望对你有一定的参考价值。

应用软件开发,就是对数据库进行增删改查操作?软件架构选型,就是选择几款流行的中间件?软件架构设计,就是把几个中间件串在一起?如果真的这么认为,那么对应用系统设计可能还存在理解深度。本篇文章从应用软件的模块设计层面讲述软件设计的真正要求。

功能模块拆分是在全面了解业务需求后,以寻找大量内聚性调用确定模块边界为目的,以寻求应用软件中易变变性和不易不变性的边界为目的应用系统设计过程。但是讨论功能模块拆分的前提是弄清楚什么是功能模块,然后才能讨论功能模块的拆分原则和设计方案

1、功能模块的概念

功能模块从业务层面的理解很简单,就是一个名词:如用户模块、订单模块、支付模块、合同模块;但隐含在这个名词下“功能模块”的技术意义,就至少需要具备以下特点:

  • 单一业务性:功能模块一定只处理单一业务,功能模块本身可能不能完成业务闭环,但业务闭环中关于某个业务点的处理,都应该由一个模块完成。

  • 闭合性和开放性:闭合性是指功能模块内部实现细节应该对外关闭,任何调用者不能进行修改。外部调用者要么使用要么不使用、要么直接使用要么整体替换;开放性是指业务模块本身的扩展是开放的,开发人员在不改变模块现有功能的情况下,可以对模块功能进行新增。

  • 抽象性:实际上该特点与上一个特点是紧密联系的,抽象性来源对业务需求的提取,通俗的来讲就是确定的业务边界有利于功能模块自身的扩展;例如车销订单和电商订单,虽然两种订单用于不同的业务闭环,但其业务的关键信息和对后续业务的驱动作用是一致的,所以两种类型的订单都应该归纳为一个订单模块。抽象性保证了功能模块的闭合性。

  • 接口规范性:接口规范性是指功能模块提供给外部调用者的接口调用方式、事件订阅方式是有边界的、稳定的。模块的接口可以进行规范且边界可控的原因,主要有赖于功能模块的单一业务性。如果开发人员发现功能模块提供给外部调用者的接口随时都在变化,那么说明模块的拆分存在问题。规范性同时保证了功能模块的开放性和闭合性。

  • 单向依赖的定位性:模块和外部模块的依赖一定是单向的,也就是说A模块如果直接或者间接依赖于B模块,那么B模块就一定不会“察觉”A模块的存在。由于依赖的单向性,所以模块在整个系统/子系统中一定可以找到清晰的层次定位。如果开发者发现模块无法在系统/子系统明确定位,那么说明模块的拆分存在问题。

除了功能模块自身需具备的特点外,循环依赖问题也和功能模块的划分存在联系。那么什么叫循环依赖呢?循环依赖就是:两个或多个功能模块在接口层面出现相互依赖(包括直接和间接)的情况,例如岗位功能直接调用了用户功能的接口,用户功能在实现过程中又同时调用了岗位功能的接口(实际工作中,那些间接产生的循环调用,也会形成循环依赖),示例代码如下:

// 岗位模块逻辑实现中依赖了用户模块的接口
// ……
public class PositionServiceImpl implements PositionService 
  @Autowired
  private UserService userService;
  public void doSomething()  

// ============
// 用户模块的逻辑实现中依赖了岗位模块的接口
// ……
public class UserServiceImpl implements UserService 
  @Autowired
  private PositionService positionService;
  public void doOtherthing()  

循环依赖本身不是绝症,在编程技巧上来说循环依赖还可以减小功能逻辑的实现难度,提高单位时间内代码的编写效率(不需要关注设计模式的应用,只需要按照业务流程撸出代码)。但是如果将循环依赖状态和模块设计联系在一起,那么循环依赖将会对功能模块设计产生较大负面影响。

简单来说,如果功能模块间出现循环依赖,那么功能模块就无法形成单向依赖,无法稳定在系统/子系统上的某个固定层级;另外,如果功能模块内部出现循环依赖,就代表这个功能模块无法继续向下进行更细粒度的拆分。

为什么模块间存在循环依赖就表明模块拆分失败呢?这是因为一旦存在循环依赖,将直接导致功能模块不满足单向依赖的特点要求,也就无法稳定存在于系统/子系统上的某个层级。也就是说,功能模块内部是否存在循环依赖是进行模块边界辨识的重要依据。边界以外的功能和本模块只存在标准的接口调用和事件订阅;边界以内的功能由于存在循环依赖,所以不能再继续向下进行更细粒度的拆分。如果边界以内的功能不存在循环依赖,那么说明模块还可以继续向下进行更细粒度的拆分(虽然不一定要这样做)。

2、模块拆分原则

2.1、高内聚性

高内聚性用于描述模块内各功能的调用关系。高内聚性是指模块内所有接口、接口层级调用的紧密程度。这些被紧密集合在一起的工作逻辑对外是透明的,且只为一个目标而存在,就是从模块内所处不同层次出发,共同完成业务模块所负责的单一业务任务。

例如,业务模块中的数据层只是为了完成和业务相关的数据的持久化存取存在的,不会在数据层去存取和另外业务相关的全部数据。业务模块中的业务逻辑层,只是为了完成和本业务逻辑相关的计算,其余周边业务的处理要么调用其它模块的接口完成,要么通过事件机制将自身处理情况通知出去,再由其它模块的订阅者负责完成……。

高内聚性在帮助开发人员提高开发效率的前提下,还可能导致循环依赖。循环依赖不一定完全是坏事,这要看技术团队对业务拆分的理解和要求。例如如果研发团队根据需求调研决定将用户、岗位、职位、职级归纳于一个模块,那么用户、岗位、职位、职级的逻辑关联实现就不必考虑避免四个功能需要单独形成四个模块的问题,也无需考虑循环依赖的问题(但实际情况来看这种模块粒度的设计显然太过粗放)。

2.2、低耦合性

耦合性用于描述模块和模块间的关联紧密程度。模块依赖的外部模块越多、需要关注的其它模块的事件越多,则模块的扩展难度越大、替换成本越高。从系统设计的角度来看,降低功能模块的耦合性比提高模块内的聚合性更为重要。这个原因很容易理解,模块内的聚合性是否紧密仅仅涉及到该模块本身设计的好坏,而模块间的低耦合性将保证模块内不好的设计所影响的范围被限制在模块内,而不会被传递到其它模块。

2.2.1、为了达到低耦合性的要求,有几类模块间的关联方式是绝对需要避免的:

  • A、直接跨过其它模块的标准接口,对其它模块的数据进行读写:这个原因很好理解,这种修改方式将直接取决于其它模块的业务实现细节,如果其它模块的业务逻辑在内部被修改或者其他模块的实现方式直接被替换,那么本模块内的相关处理逻辑则不得不进行修改。这种处理方式也违背了面向对象设计的基本原则——依赖接口而非依赖实现。以下是一个错误示例:
  • B、两个或多个模块都对同样的业务进行操作:这种场景常见于两个或者多个模块进行数据绑定的情况。例如,物流模块中货运单负责人和用户模块中人员进行绑定关联的操作场景。一个非常明显的问题是:到底应该由哪个业务模块来控制这个绑定关系?

注意:本文一直讨论的是功能模块的设计问题,而不是用户UE交互问题。在UE层面,从方便用户操作的角度来看当然可以在用户模块提供一个直接绑定货运单的操作界面,但是在功能模块设计层面,对于绑定信息的操作当然不能设计成两个模块都可以管理绑定数据。正确的设计方式是,只能由上层模块完成绑定数据的维护(也就是归纳到上层功能模块中进行管理)。如下图所示:

这是为什么呢?首先讨论这个问题的前提是,功能模块的拆分满足要求(即上文以讨论过的功能模块应该具备的所有特性)。在这种情况下,具有这种数据绑定关系的功能模块一定具有单向依赖特点。

将两个业务模块的绑定关系(特别是多对多关系)放置于上层模块,可以使下层模块减少关注规模、保证下层模块的稳定性,还可以增加上层模块的扩展性。例如如后续需要增加订单创建者的数据绑定关系,则无需修改用户模块,只需要增加新的订单模块。

如果设计者发现无法确定某种绑定关系不知道应该放置在哪个功能模块中,则最可能的原因是:这两个或者多个功能模块拆分失败,需要重新进行功能模块设计。或者这些关联数据是功能模块内具有内聚性的绑定信息。那么有的读者会问,如果将绑定关系交给上层模块维护,当时又需要在查询用户信息时一起关联出指定用户和物流单绑定,该怎么办呢?不要急,后续讲解如何进行模块拆分时会进行解决方式的讲解。

2.2.2、另一种场景的模块间依赖方式,是应该尽可能减少或被限制的:

将两个或者多个设计存在瑕疵的功能模块中存在循环依赖的部分提取出来下沉为一个诸如common-XXXX的公共功能模块。如下图所示:

这里的common模块当然是一种解决多个模块中依赖冲突的办法,但是作为下沉的功能模块,该模块存在一些问题:首先该模块一定会涉及应该由其它功能关注或操作的业务逻辑,所以该办法治标不能治本。而最根本的原因是这个办法将功能模块间的循环依赖问题迁移到这个公共模块的内部,让循环依赖以内聚性的方式继续存在,而不是解决这一系列循环依赖。

另外,上层功能模块依赖该公共模块后,会将上层功能模块无需关注的接口、模型、事件暴露出来,增加上层模块的开发难度。最后,增加了一个无法归入任何特定功能模块的所谓公共功能模块,一定会增加业务系统本身的维护难度,而在后续的二次开发环节中,开发人员对于是否需要引入、修改这个公共模块一定会存在疑惑。

2.2.3、好的低耦合设计将直接帮助系统设计达到一下几个效果:

  • A、更容易的二次开发实施
    这个效果用一个通俗易懂的方式进行描述,就是:好的功能模块可以在二次开发阶段由二次开发团队按照自己的需求思路和技术思路进行完全重构,且这样的二次开发是有明确边界的,这个边界应该和被二次开发替换模块的功能边界一致。二次开发团队在进行功能模块重写时,无需关注这个模块以外的模块工作原理,因为其他模块不会因为这次重写而发生改变。

  • B、功能模块层级明确
    在产品团队在进行前期产品设计或者对客户的售前工作中,经常会向团队或者目标客户出示产品的功能架构图,类似如下:

    那么技术团队能否在产品/项目研发阶段真正按照这样给客户宣讲(忽悠)的功能架构图完成系统中各个功能模块的构建呢?答案是肯定的,只要按照上文提到的构建功能模块的基本原则进行系统设计,那么系统中的各个模块就可以呈现“漂亮”的顺序依赖结构。但很多时候,由于错误的功能模块拆分方式,技术团队往往无法达到产品前期设计的或者给客户承诺的功能模块拆分目标。

  • C、具有单向依赖特点和层次特点的功能模块,自身是稳定的

    这种稳定性体现在代码修改、替换的边界控制上。举个例子:当开发人员出于某些目的,需要将某个模块的实现代码删除/剪切(除暴露的接口),那么开发人员可以观察到的效果是当开发人员剪切了模块的所有代码到另外的地方,该模块本身不会报错;同样该模块原始存在的应用工程也不会报错。

    这种稳定性还体现在模块内错误的可控性——系统内的虫子被有效限制:系统研发过程中难免出现技战术问题和失误,例如边界校验问题、性能问题、需求理解问题、数值适配问题等等(bug)。但是由于好的功能模块设计的内聚性和隔离性,这些虫子活动范围只会限于各个功能模块内部。

3、如何进行模块拆分

那么如何进行功能模块的拆分呢?上文已经提到,功能模块拆分的原则是提搞功能模块的内聚性,降低功能模块间的耦合性。其中更重要的原则是降低模块间的耦合性,高内聚性的形成则降低模块间耦合性后的必然产物。

3.1、基于不同业务场景,使用规范的设计模式,降低依赖:

模块间的耦合可通过多种设计模式(主要是行为模式)进行降低(但需要注意,采用设计模式的最大原则是,同一类型问题采用相同的设计模式进行设计),最好各个模块只存在最少方法调用、最小对象传参这样的依赖方式,最小限度来说必须解决功能模块间的循环依赖问题。请看如下示例:岗位模块和用户模块由于设计问题被耦合在一起,两者存在循环依赖——这是两个坏的模块设计:

// ……
// 岗位模块逻辑实现中依赖了用户模块的接口
public class PositionServiceImpl implements PositionService 
  @Autowired
  private UserService userService;
  public void doSomething()  

// ==============
// 用户模块的逻辑实现中依赖了岗位模块的接口
// ……
public class UserServiceImpl implements UserService 
  @Autowired
  private PositionService positionService;
  public void doOtherthing()  

在没有解决循环依赖问题前,两个模块是分不出来业务层级的。如下图所示:

为了将两个模块进行最低限度的解耦,分出两个模块的层次,技术人员需要让两个模块的依赖关系变成单向的,如下图所示:

由于用户模块被其他功能模块调用的可能性要高于岗位模块,而且用户模块更需要进行抽象,所以一般认为用户模块应该位于岗位模块的下层(但这也不一定,决定于实际场景下的需求情况)。换句话说,岗位模块可以依赖用户模块,可以使用用户模块的SDK层(接口层)接口、模型;但是用户模块不应该依赖岗位模块,甚至不应该知晓用户模块上层的任何模块(包括岗位模块)的存在。

从技战术的角度讲,我们只需要一些很简单的办法,就可以解决这个问题:即在用户模块提供事件通知,将用户模块工作逻辑中需要由上层模块协作完成的事件触发点公布出去,然后由上层模块按照相关需求进行实现即可。这个过程可以使用监听器模式、观察者模式等等,另外spring框架本身提供的事件订阅机制,技术人员也可以使用(这里就不再铺展开讲解了,感兴趣的读者可以参看本专题后续文章,也可以参考其他第三方资料)。示例代码如下:

/**
 * 用户模块中定义的事件信息,注意用户模块只是定义事件,而事件的实现交由上层模块进行
 * @author yinwenjie
 */
public interface UserEventListener<T> 
  /**
   * 当用户模块完成新的用户信息创建时,该事件会被触发
   */
  public void onUserCreated();
  /**
   * 当用户模块由于某些原因,需要知晓上层功能模块中,业务信息和指定用户(多用户)的绑定情况时,
   * 该事件会被触发 
   */
  public List<T> onUserBandingInfoRequest(String account);

进行依赖倒转的本质是将本模块和其他模块逻辑相关的所有实现,由下层模块迁移到上层模块。例如以上示例中,将原有用户模块中直接调用的岗位实现逻辑迁移到岗位模块内部。这样,上层业务模块的逻辑情况就对下层模块透明了。岗位功能模块可以根据自身的情况,对这些事件进行实现(订阅),示例代码如下:

// 岗位模块逻辑实现中依赖了用户模块的接口
public class PositionServiceImpl implements PositionService , UserEventListener<YourBusiness> 
  // 用户模块不需要知晓岗位模块的存在
  // 只需要岗位模块依赖用户模块
  private UserService userService;
  public void doSomething() 
  
  @Override
  public void onUserCreated() 
    // ..... 
  
  @Override
  public List<YourBusiness> onUserBandingInfoRequest(String account) 
    // 根据岗位模块中的具体逻辑进行该事件实现
    return null;
  

3.2、为功能模块规划标准的调用边界

所有上层模块对其的调用,只能通过边界进入该功能模块。为了适应各种调用场景,支持功能模块的单向依赖,并统一功能模块边界的数据描述,功能模块的调用边界至少应该包括:标准的调用接口,标准的调用接口只有处于功能模块的上级模块才能直接使用,换句换说一旦外部功能模块直接调用了本功能提供的调用接口,那么外部功能模块一定处于该功能模块的上级;标准的模型定义,标准的模型定义规范了外部功能模块向该模块传递信息的统一要求,也规范了该模块向外部功能模块返回的处理结果(这类模型一般包括枚举信息,包括VO模型或DTO模型,一般不使用Entity进行描述);标准的事件定义,为了保证该模块能向上层模块通知自身的数据变化和逻辑处理要求,功能模块必须定义进行事件定义。

3.3、模块实现应于模块边界分离

功能模块有了明确的功能边界后,就为功能模块建立了一堵墙将模块外部和模块内部进行隔离,并且在墙上开了一道门。门外不需要知道门内的具体逻辑实现,而门内可以有若干种具体实现。换句话说,门内的具体实现也应该和这堵墙进行分离,以便应用系统可以在构建时选择需要哪种实现,如下所示:

Spring Boot中提供的组件开发模式(注意是组件开发模式,而不是插件开发模式),可以帮助设计人员快速实现这种分离场景的要求(这里不再展开,有兴趣的读者可参看本专题后续文章)。并且任意第三方对于功能模块的调用都需要通过门进入,如下图所示:

3.4、数据耦合和参数耦合是最低的耦合形态,应该尽可能使用

降低模块间的耦合,并不是全面去除模块间的耦合,后者的理解是不科学的也不现实。需求要求的功能,需要两个或者多个模块配合完成,这些模块间当然就一定会有耦合。脱耦的关键目标在于明确功能模块的边界,在于将“你”要处理的内容和“我”要处理的内容分割清楚,在于可以达到就算没有“你”或者换一个“你”那么“我”也可以完整处理“我”负责内容的目的。

要达到这个目标,就需要考虑如何进行耦合,很明显直接进行接口调用或者处理逻辑调用是不行的,而使用关键数据进行模块关联(数据耦合)并利用局部参数传递数据(参数耦合),可以有效减少两个模块的耦合程度。

用一句很好理解的话来解释:模块和另一个模块耦合时,只在本模块中记录另一个模块的关键数据而不是全部数据,尽可能少记录另一个模块在本模块中的冗余数据,这样做的目的是保证就算另一个模块的具体工作逻辑被替换掉,本模块也可以根据这些关联数据精确驱动另一个模块的处理过程;而数据的传递和驱动要求的传递,通过局部参数或者对象属性完成,这保证了另一模块的处理逻辑不会牵扯另一模块中的其他逻辑处理过程。

3.5、文档支持

为了便于研发团队内部进行模块开发级别的交流,也便于二次开发团队了解模块的具体作用、使用方式、注意事项,软件研发过程特别是产品级别的软件研发过程,研发团队必须使用文档进行功能模块层面的描述。

注意,这里说的是功能模块级别的描述,而不是具体业务实现逻辑的描述过程。这两份文档的区别主要体现在对不同技术层级的描述。具体业务实现的描述可以撰写成功独立文档,更推荐直接使用规范化的代码注释进行描述;而功能模块级别的文档必须独立成文,并使用利于团队交流的知识库系统进行管理。

功能模块级别的文档至少应该描述以下事实:该模块在整个应用系统中的位置、该模块下层(直接)依赖了哪些模块以及原因、该模块提供了暴露的调用接口和事件订阅方式、该模块的在应用系统级别的引入方式等等。

注意,相当一部分技术人员不习惯于写文档,或者说不知道如何写文档。为了在研发团队的磨合期帮助这部分技术人员上手文档写作,研发团队可以出具一份切实可行的文档模块,将文档分为几个段落并明确每个段落的写作要点、要求和示例,帮助引导技术人员的写作思路。以下为某产品研发过程中使用的模块描述文档的模板范例:

4、实际功能模块拆分举例

下面以一个实际例子进行举例,两者相关联的业务需求点是:创建一个新的订货信息时,需要验证订货者是否还有未完成的退货单,如果有则不允许进行订货单创建。另外退货单创建时必须有关联的订货单,且订货单的状态必须是“已完成”,退货单创建过程中必须将对应的订货单置为“失效退货”状态。

从需求层面上看,这两个模块的功能就应该是耦合在一起的,但这里要明确的是,作为研发团队我们不可能要求客户修改需求,而业务需求间的耦合并不是技术层面的耦合,研发设计的目的就是将业务需求解耦,转变为方面维护的一个一个独立功能模块。

在没有进行好的模块化功能设计前,本系统中的订货功能和退货功能确实也是强耦合的方式撸(码)在了一起,如下所示:

// 订货模块有如下代码
// 退货单服务
@Autowired
private ChargebackService chargebackService;

@Transactional
public void create(OrderInfo orderinfo) 
  // ......
  // 验证订货者是否有未完成的退货单
  String account = orderinfo.getAccount();
  Set<ChargebackInfo> chargebackInfos = this.chargebackService.findByAccountAndStatus(account , Status.Enable);
  Validate.isTrue(CollectionUtils.isEmpty(chargebackInfos) , "订货者还有未完成的退单,不允许新建订货单!");
  // ......


// =====================

// 退货单模块有如下代码
// 退货服务
@Autowired
private OrderInfoService orderInfoService;
@Transactional
public void create(ChargebackInfo chargebackInfo) 
  // ......
  // 验证退货单的订单关联信息
  String relationCode = chargebackInfo.getRelationCode();
  OrderInfo exsitOrderinfo = orderInfoService.findByCodeAndStatus(relationCode);
  Validate.notNull(exsitOrderinfo , "未发现指定的订单信息!!");
  Validate.isTrue(exsitOrderinfo.getStatus() != Status.DONE  , "指定订单还未完成处理,不能进行退货!");
  
  // ...... 继续做退货单的其他处理
  // 然后在退货单模块,直接调用订单模块的接口,修改订货单状态
  this.orderInfoService.updateStatus(relationCode , Status.DONE);
  // ......

以上的示例代码是开发人员在实际系统开发过程中,编写的再简单不过的业务代码了。从需求的角度看这段代码没有问题,可以这样理解以上业务代码:就是开发人员按照需求人员对需求的描述,直接翻译成代码“贴”到应用系统中。从编码规范来看以上代码也没有问题:使用统一的命名规范、格式规范,有统一的边界校验控制,甚至使用统一的工具和编写技巧尽可能减少代码规模(这里特别说明一下,一些开发人员喜欢在开发过程中编写许多通用工具,例如字符串处理工具、日期处理工具、数值计算工具,并设想开发团队中的其他开发人员会使用这些工具,形成所谓的规范。这种做法是不科学、有危害的,原因会在本专题的后续文章中进行说明)。

但是,以上代码从系统设计的角度看就存在问题了:订货单模块和退货单模块出现了强依赖,两个模块被循环依赖在了一起。如果产品团队根据需求分析最终决定订货模块和退货模块就应该是一个模块,那么这样做当然也没有什么大问题(就是后续要拆分成更细粒度的模块,会耗费大量工作),因为按照本文所述循环依赖只能存在于功能模块以内,如果循环依赖出现在模块间那么就证明模块拆分失败。


但是在本示例中,退货模块和订货模块显然属于两个需要独立工作的模块,那么必须通过系统设计的方式,降低这两个模块的耦合性,至少需要将两个模块的依赖方式变成单向依赖,将两个模块的耦合度降低到只有数据耦合和参数耦合。

在进行设计前,我们先来确定一下这两个模块更科学的依赖方向:显然订货模块放置到更下层,可以使系统的依赖关系更科学,因为按照业务需求订货模块还将被除了退货模块以外的多个模块所依赖。如下图所示:

最终我们确认的模块拆分方案是:订单模块不应该引入任何退货模块的接口,甚至订单模块就不应该知道有一个退货模块。那么如何去掉订单模块中关于相关退货逻辑的处理呢?如何反转订单所依赖的退货单接口?

设计模式中多种行为模式可以解决这个问题,最简单的方式就是为订单模块设计规范的事件接口,然后由上层模块根据自身业务需求实现这些事件(监听器模式/观察者模式)。我们先为订单模块定义标准的事件:

/**
 * 订单事件,这个事件的定义在订单模块中
 * @author yinwenjie
 */
public interface OrderEventListener 
  /**
   * 当订单被创建时(但本地事务还没有提交前),该事件被触发
   * @param orderInfo 本次新建的订单信息,通过参数方式进行传入
   */
  public void onCreated(OrderInfo orderInfo);

但是订单模块并不负责实现这些事件。接着,订单在自身的创建动作完成后,进行事件的触发,代码如下所示:

// 订单模块的代码如下
/**
 * 订单事件监听,之所以是集合,是因为可能有多个监听器的实现
 */
@Autowired(required = false)
private List<OrderEventListener> orderEventListeners;
@Transactional
public void create(OrderInfo orderinfo) 
  // ......
  // 在订单边界校验、自身处理过程完成后,触发事件
  if(!CollectionUtils.isEmpty(this.orderEventListeners)) 
    this.orderEventListeners.forEach(item -> item.onCreated(orderinfo));
  
  // ......

这样的设计也基本能满足上文提到的进行模块设计的规范要求,特别是进行两个模块耦合的要求:首先采用监听器模式解决两个模块的循环依赖问题;然后退货单无论是直接调用订单的接口,还是实现订货模块的事件订阅都遵循订单模块向外暴露的标准边界(门),完全避免了和订单模块内的任何具体实现逻辑产生关系;最后退货模块和订单模块的耦合仅限于调用方法时传递的参数(事件中传递了订单对象信息),且退货单模块仅关联订单模块中的订单业务编号(在退货单模块中,该属性称为“第三方业务单据relationCode”),关联的目的是帮助订单模块的处理过程精确定位到相关的单据信息。

这样一来,订单模块只需要将自身发生的变动的情况或者需要获取数据的事件发布出去,无需知道有哪些模块会订阅这些事件(订单模块除了数据层面、参数层面和各个上层模块有耦合以外,订单模块压根不知道有哪些上层模块会订阅事件,更谈不上知晓这些模块的作用)。

关键代码如下所示:

// 此段代码是模块改造后,退货模块的代码示例
// 该服务实现了订单模块的OrderEventListener监听接口
public class ChargebackServiceImpl implements ChargebackService ,  OrderEventListener 
  
  @Override
  public void onCreated(OrderInfo orderInfo) 
    // 之前退货单放置在订单模块的代码放到了这里
    // 具体来说就是,验证订货者是否有未完成的退货单
    String account = orderinfo.getAccount();
    Set<ChargebackInfo> chargebackInfos = this.findByAccountAndStatus(account , Status.Enable);
    Validate.isTrue(CollectionUtils.isEmpty(chargebackInfos) , "订货者还有未完成的退单,不允许新建订货单!");
    // ...... 其它处理逻辑过程
  

这是最简单的一种设计模式的应用方式。在这里如何进行事件的发布或者如何进行实现者行为的控制,完全取决于技术人员对需求的抽象能力,以及将抽象需求转换为设计思路的能力。再例如,当事件发生时系统中会有多个实现,但是只能按照条件选择一个最合适的实现进行调用,那么可以使用策略模式进行设计,如下所示:


/**
 * 订单事件处理策略定义
 * @author yinwenjie
 */
public interface OrderCreateEventStrategy 
  /**
   * 该方法将在订单创建事件发生后,首先被触发,
   * 系统将根据该方法的返回情况,确定是否使用该策略匹配本次订单创建后的处理逻辑
   * @param orderInfo 本次进行创建的订单
   * @return 如果返回true,则表示该处理策略逻辑将被正式执行;其他值,不执行该策略实现逻辑
   */
  public boolean isHandler(OrderInfo orderInfo);
  /**
   * 只有当本策略实现的isHandler方法返回true,该方法才会执行
   * @param orderInfo 本次进行创建的订单
   */
  public void onCeated(OrderInfo orderInfo);

接着本文再举一个例子:如果需要将事件的实现行为串起来执行,且需要按照业务逻辑对执行顺序进行管理,那么可以使用责任链模式进行设计(注意,责任链模式建议使用递归而非循序进行控制,最好准备责任链的上下文管理器[完全可以参考Servlet中filter的设计思路])。关键接口示例如下:

/**
 * 订单模块为了事件处理,定义的责任链抽象类。
 * 事件策略逻辑过滤
 */
public abstract class OrderEventFilter 
  /**
   * 该方法将在订单事件触发时,参与逻辑处理链
   * @param orderInfo 当前发生事件的订单
   * @param event 事件类型,包括DELETE,CREATE,UPDATE ......
   * @param orderEventHolder 订单事件管理器,是否进行后续处理或处理过程的上下文,由该对象控制
   */
  public abstract void handler(OrderInfo orderInfo , Event event , OrderEventHolder orderEventHolder);

有的读者会问,这些原则和示例是否只适用于单应用系统,如果应用系统是微服务架构又该怎么办呢?微服务架构同样需要遵从功能模块设计的原则,实际上本文的内容已经足可以帮助读者扩展出微服务系统下的模块构建方式。不过微服务系统由于涉及进程间通信,所以需要增加在另一些关键技术方案上的突破,例如如何保证多进程间的数据一致性(传统的基于数据库的分布式事务一定是不行的)、再例如怎么控制进程间的消息订阅和发布等等。这些坑在本专题的后续内容中将逐步填上。

另外,本专题后续文章也会逐渐讨论与二次开发相关的实施方案,包括但不限于如何在功能模块内部完成开发(这种场景经常出现在以项目驱动的产品研发过程中),如何替换功能模块,如何将单一化应用系统改造为微服务系统等等。

以上是关于应用软件设计不是CRUD:如何进行应用系统功能模块的耦合性设计的主要内容,如果未能解决你的问题,请参考以下文章

应用软件设计不是CRUD:如何进行应用系统功能模块的耦合性设计

应用软件设计不是CRUD:如何进行应用系统功能模块的耦合性设计

应该如何设计执行 CRUD 操作的 bean 的单元测试?

系统管理模块_岗位管理_实现CRUD功能的具体步骤并设计Role实体

如何为 Laravel 模块架构应用 CRUD 生成器?

基于ABP落地领域驱动设计-01.全景图