软件架构设计之系统模块的拆分
- 基本概念
- 功能模块
- 循环依赖问题
- 模块拆分原则
- 高内聚性
- 低耦合性
- 模块拆分方式
- 模块拆分示例
- 业务需求
- 业务分析
- 项目原始代码
- 需求重构
- 项目代码重构
- 总结
基本概念
- 功能模块拆分: 全面了解业务需求后,以寻找大量内聚性调用确定模块边界为目的,以寻求应用软件中易变性和不易变性的边界为目的的应用系统的设计过程
- 要了解什么是功能模块,才能讨论功能模块的拆分原则和设计方案
功能模块
- 功能模块从业务的角度来说就是功能描述的名词
- 功能模块从技术意义上讲,至少应该具备以下特点:
- 单一业务性:
- 功能模块一定只是处理单一业务
- 功能模块可能本身不能完成业务的闭环,但是对于业务闭环的某个业务点的处理,一定只是由一个功能模块来完成的
- 闭合开放性:
- 闭合性:
- 功能模块的内部实现细节应该对外关闭,任何调用者不能进行修改
- 外部调用者要么使用要么不使用,要么直接使用要么整体替换
- 开放性:
- 业务功能模块本身的扩展是开放的
- 开发人员在不改变模块现有功能的前提下,可以对模块功能进行新增
- 业务抽象性:
- 抽象性是对业务需求的提取,为了确定业务的边界,这样有利于功能模块的扩展
- 抽象性保证了功能模块的闭合性
- 接口规范性:
- 功能模块提供给外部调用者的接口调用方式和事件订阅方式是有边界的,稳定的
- 如果功能模块提供给外部调用着的接口随时都在发生变化,说明模块的拆分存在问题
- 模块的接口规范并且边界可控依赖于功能模块的单一业务性
- 接口规范性保证了功能模块的开放性和闭合性
- 单向依赖性:
- 功能模块和外部模块的依赖一定是单向的. 也就是说,如果一个模块直接或者间接依赖于另一个模块,那么被依赖的模块就一定不会察觉到依赖模块的存在
- 由于单向依赖性,功能模块可以在项目系统中有清晰的层次定位
- 如果模块无法在项目系统中明确定位,说明模块的拆分存在问题
循环依赖问题
- 除了功能模块自身需具备的特点外,循环依赖问题也和功能模块的划分存在联系
- 循环依赖: 两个或者多个功能模块在接口层面出现相互依赖的情况,包括直接的相互依赖和间接的相互依赖.这种情况就叫做循环依赖
- 循环依赖在项目开发上可以减小功能逻辑的实现难度,提高单位时间内代码的编写效率.因为可以不用关注设计模式的应用,只需要按照业务流程开发功能
- 如果将循环依赖和模块设计联系在一起,那么循环依赖会对功能模块的设计产生较大的负面影响
- 功能模块间存在循环依赖时,就无法保证功能模块间的单向依赖性,各个功能模块无法稳定在项目系统中某个固定的层级
- 功能模块内部存在循环依赖时,这个功能模块就无法继续向下进行更细粒度的拆分
- 功能模块内部是否存在循环依赖是进行模块边界辨识的重要依据:
- 功能模块间一旦存在循环依赖,会直接导致功能模块不满足单向依赖性,无法稳定地固定在项目系统中的某一个层级
- 边界以外的功能和功能模块只存在标准的接口调用和事件订阅.边界以内的功能因为存在循环依赖,所以不能再继续向下进行更细粒度的拆分
模块拆分原则
高内聚性
高内聚性: 模块内所有接口,接口层级调用的紧密程度
- 这些紧密集合在一起的工作逻辑对外是透明的,并且只为一个目标存在,就是从功能模块所处的不同层次出发,共同完成业务模块所负责的单一业务任务
- 高内聚性用于描述模块内各个接口层级的调用关系
示例:
- MVC分层设计:
高内聚性在提高开发效率的前提下,可能会导致循环依赖的情况出现
低耦合性
- 低耦合性:
- 低耦合性用于描述模块和模块之间的关联紧密程度
- 模块依赖的外部模块越多,那么需要关注的其余模块的事件就越多,就会造成模块的扩展难度大,替换成本高
- 从系统设计角度来说,降低功能模块间的耦合性比提高功能模块内的聚合性更为重要
- 功能模块内的聚合性是否紧密只是涉及到这个模块本身设计的好坏
- 功能模块间的低耦合性能够保证功能模块内不好的设计影响的范围限制在功能模块的内部,不会传递到其余的模块
- 为了达到低耦合性的要求,一定要避免以下几种功能模块间的关联方式:
- 直接越过其余模块的标准接口,对其余模块的数据进行读写:
- 这种修改方式直接取决于其余模块的业务实现细节
- 如果其余模块的业务逻辑内部发生修改或者实现方式被直接替换,那么本模块内相关的业务逻辑也需要进行修改
- 这种处理方式违背了面向对象中的依赖接口而非依赖实现的基本原则
- 两个或者多个模块都对同样的业务进行操作:
- 在两个或者多个模块中进行数据绑定时经常会出现这样的情况
- 比如在物流模块中运单负责人和用户模块中的人员进行绑定时的业务场景中,需要确认好到底由哪一个业务模块来控制人员的绑定关系
- 在UE层面,从方便用户操作的角度来看,应该在用户模块中提供一个直接绑定运单负责人的操作界面
- 在功能设计层面,应该由上层模块完成绑定数据的工作,也就是将功能归纳到上层模块中进行管理
- 在已经满足了拆分要求的各个功能模块中,具有这种绑定关系的功能模块一定具有单向依赖的特点
- 将两个业务模块的绑定关系,特别是多对多关系归纳到上层模块中:
- 可以使下层模块减少关注规模,保证下层模块的稳定性
- 可以增加上层模块的扩展性
- 如果无法确定模块间的某种绑定关系应该归纳到哪个模块中,最可能的原因就是:
- 这两个或者多个功能模块拆分失败,需要重新进行功能模块设计
- 这些关联数据的功能模块内具有内聚性的绑定信息
- 为了达到低耦合性的要求,应该尽可能减少或者限制出现以下场景的模块间的依赖方式:
- 将两个或者多个设计存在缺陷的功能模块中存在循环依赖的部分提取出来下沉为一个公共功能模块
- 这里的公共模块是一种解决多个功能模块中依赖冲突的方法,但是作为下沉的功能模块,存在以下问题:
- 这个模块中会涉及其余功能模块关注或者操作的业务逻辑,不能解决根本问题
- 根本原因在于这个方式是将功能模块之间的循环依赖问题转移到了这个公共模块的内部,使得循环依赖以内聚性的方式继续存在,并不是解决这种循环依赖问题
- 上层功能模块依赖这个公共模块后,会将上层模块中无需关注的接口,模型,事件暴露出来,增加了上层功能模块的开发难度
- 增加了一个无法归入任何特定功能模块的公共功能模块,会增加业务系统本身的维护难度,在后续的二次开发环节,会造成对于是否需要引入,修改这个公共模块的混乱
- 优秀的低耦合性设计应该使得系统达到以下的效果:
- 有利于二次开发实施:
- 好的低耦合性的功能模块可以在二次开发阶段由二次开发团队按照新的需求思路和技术思路进行完全重构
- 这样的二次开发是有明确边界的,这个边界应该和被二次开发替换模块的功能边界一致
- 二次开发团队在进行功能模块重写时,无需关注这个功能模块以外的功能模块的工作原理,在于低耦合性的设计中,其余功能模块不会因为这个功能模块的重写发生改变
- 功能模块层级明确:
- 只要按照模块拆分原则进行系统设计,就能按照功能架构图完成各个功能模块的构建,系统中的各个模块可以有完美的顺序依赖结构
- 需要注意,往往错误的功能模块的拆分方式,会导致项目系统无法达到功能架构图的设计
- 具有单向依赖特点和层次特点的功能模块是稳定的:
- 代码修改和替换的边界是可控的:
- 当需要对项目进行删除,移动时,这个功能模块本身不会报错,这个模块原始存在的项目系统也不会报错
- 模块内的错误是可控的:
- 模块内出现的问题只会限制在各个功能模块的内部
模块拆分方式
- 功能模块的拆分原则就是提高功能模块内的内聚性,降低功能模块间的耦合性.更重要的是降低功能模块间的耦合性,功能模块内的高内聚性是降低功能模块内的耦合性的必然产物
- 基于不同的业务场景,使用规范的设计模式来降低依赖:
- 功能模块间的耦合性可以通过多种设计模式,主要是行为型模式来降低耦合性
- 需要注意的是,使用设计模式最大的原则是: 同一类型的问题要使用相同的设计模式进行设计
- 各个模块只存在最少方法调用,最小对象传参这样的依赖方式.最小限度来说就是必须解决功能模块间的循环依赖问题
- 示例:
- 岗位模块和用户模块由于设计的问题耦合在一起,两者存在循环依赖
- 在没有解决循环依赖的问题之前,两个模块是分不出业务层级的.为了将两个模块进行最低限度的耦合,分出两个模块的层级,需要将两个模块的依赖关系变为单向依赖
- 由于用户模块由其余模块调用的频次要高于岗位模块由其余模块调用的频次,并且用户模块更需要进行抽象,所以一般认为用户模块位于岗位模块的下层
- 也就是说,岗位模块应该依赖于用户模块,可以使用用户模块的接口,模型. 但是用户模块不应该依赖于岗位模块,甚至不应该知晓用户模块上层任何模块的存在
- 技术实现:
- 在用户模块提供事件通知,将用户模块工作逻辑中需要由上层模块协作完成的事件触发点公布出去,然后由上层模块按照相关需求进行实现
- 这个过程可以使用监听器模式或者观察者模式等,也可以使用Spring框架提供的事件订阅机制
- 依赖倒转的本质: 将本模块和其余模块逻辑相关的所有实现,由下层模块转移到上层模块中
- 这样,上层业务模块的逻辑情况对下层模块透明,上层模块可以根据自身情况,对这些事件进行订阅实现
- 规划出功能模块标准的调用边界:
- 所有上层功能模块对这个功能模块的调用,只能通过边界进入这个功能模块
- 为了适应各种调用场景,支持功能模块的单向依赖,并且统一功能模块边界的数据描述
- 功能模块边界的数据描述应该包括:
- 标准的调用接口:
- 标准的调用接口只有处于功能模块的上级模块才能直接使用
- 也就是说,如果外部功能模块直接调用这个功能模块提供的接口,那么外部功能模块一定是处于这个功能模块的上级功能模块
- 标准的模型定义:
- 标准的模型定义用于规范外部功能模块向这个功能模块传递信息的统一要求,同时也规范向外部功能模块返回的处理结果
- 标准的模型一般包括枚举信息,包括VO模型和DTO模型
- 标准的事件定义:
- 标准的事件定义用来保证这个模块能够向上层模块通知这个模块中的数据变化和逻辑处理要求
- 功能模块必须进行事件定义
- 模块实现应该与模块边界分离:
- 功能模块有明确的功能边界
- 相当于功能模块之间建立了一堵墙将模块外部和模块内部进行了隔离,并且在墙上开了一道门
- 门外不知道门内的具体逻辑实现,门内可以有若干种具体实现方式
- 门内的具体实现也要和这堵墙进行分离,这样可以在构建的时候选择需要哪种实现
- 尽可能使用最低耦合性的数据耦合和参数耦合:
- 降低功能模块间的耦合,不是全面地去除模块间的耦合
- 脱耦的关键目标在于明确功能模块的边界,将模块中要处理的内容分割清楚,以便各个功能模块都能各自独立完整处理负责的内容
- 要想达到这个目标,直接进行接口的调用或者处理逻辑的调用不能完全达到目标
- 可以使用关键数据进行模块关联,也就是数据耦合.并利用局部参数传递,也就是参数耦合.可以有效减少两个模块的耦合程度,达到脱耦的目标
- 功能模块和另一个功能模块耦合时,只是在这个功能模块中记录另一个模块的关键数据而不是全部数据,尽可能少记录另一个功能模块在这个功能模块中的冗余数据
- 这样可以保证在另一个功能模块的具体工作逻辑被替换掉时,这个模块也可以根据这些关联的数据精确驱动另一个功能模块的处理过程
- 数据的传递和驱动要求的传递,通过局部参数或者对象的属性完成,这样可以保证两个功能模块中的逻辑处理不会发生影响
- 文档支持:
- 研发团队应该使用文档对产品级别的软件研发过程进行功能模块层面的描述. 这样不仅便于研发团队内部进行模块开发级别的交流,还便于二次开发团队了解功能模块的具体作用,使用方式,注意事项等
- 功能模块级别的文档至少应该描述以下几个方面:
- 功能模块在整个项目系统中的位置
- 功能模块的下层直接依赖了哪些其余功能模块以及原因
- 功能模块提供的暴露的调用接口和事件订阅方式
- 功能模块在项目系统级别的引入方式
- 文档模板:
模块拆分示例
业务需求
- 创建一个新的订货信息时,需要验证订货者是否还有未完成的退货单:
- 如果有未完成的退货单就不允许进行订货单的创建
- 另外退货单创建时必须有关联的订货单并且订货单的状态必须是已完成
- 退货单创建过程中必须将对应的订货单置为失效退货状态
业务分析
- 从需求层面上分析,这两个模块的功能应该是耦合在一起的
- 但是业务需求间的耦合不是技术层面的耦合
- 研发设计的目的就是将业务需求解耦,转变为方便维护的一个个独立功能模块
项目原始代码
- 订货模块:
// 退货服务
@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);
...
}
- 以上的业务代码就是直接按照需求人员对需求的描述,直接翻译成代码复制到应用系统中.遵从的编码规范,使用了统一的命名规范,格式规范,有统一的边界校验控制,使用统一的工具和编写技巧尽可能减少代码规模
- 直接将需求翻译成代码存在的问题:
- 订货模块和退货模块出现了强依赖,两个模块循环依赖在一起
- 这两个模块要是后续进行更加细粒度的拆分,会耗费很大的工作量
需求重构
- 订货模块和退货模块属于两个独立工作的模块,必须通过系统设计的方式,降低这两个模块的耦合性,至少需要将这两个模块的依赖方式变成单向依赖,将两个模块的耦合度降低到只有数据耦合和参数耦合
- 分析两个模块的依赖方向:
- 订货模块适合放置到下层.因为按照业务需求订货模块除了作为退货模块的依赖外,还会作为其余模块的依赖
- 模块拆分方案:
- 订货模块中不应该引入任何退货模块的接口
- 订货模块都不应该知道有一个退货模块
- 订货模块中要去掉退货相关的逻辑
- 反转订货模块中依赖的退货接口
项目代码重构
- 设计模式中的多种行为模式可以实现模块拆分的方案
- 使用监听器模式或者观察者模式
- 设计订货模块规范的事件接口
- 由上层退货模块根据自身的业务需求实现这些事件
- 定义订货模块的标准事件:
/**
* 订货事件
* 这个事件定义在订货模块中
*/
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))
}
...
}
- 首先采用监听器模式解决了两个模块的循环依赖问题
- 然后退货单无论是直接调用订货模块的接口,还是实现订货模块的事件订阅都会遵循订货模块向外面暴露的标准边界,这个标准边界就相当于门.完全避免了和订货模块中任何具体实现逻辑产生关系
- 最后订货模块和退货模块的耦合仅仅局限于调用方法时传递的参数,也就是事件中传递的订货单对象信息.并且退货单模块仅关联订单模块中的订单业务编号,通过关联的业务定单编号,就可以在退货单中精确定位到相关的订货单信息
- 这样一来,订货模块只需要将自身发生的变动的情况或者需要获取数据的事件发布出去,无需知道哪些模块会订阅这些事件
- 订货模块中只有在数据层面,参数层面会和上层模块有耦合的情况
- 订货模块不需要知道哪些上层模块会订阅事件,不需要知道上层模块的作用
- 退货模块:
/**
* 退货模块
* 实现了订货模块中的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), "订货者还有未完成的退货单,不允许新建订货单!");
}
}
- 对于如何进行事件的发布或者如何进行实现者行为的控制,完全取决于对需求的抽象能力,以及将抽象需求转换为程序设计思路的能力
- 比如当事件发生时,系统会有多个实现,但是只能按照条件选择一个合适的进行调用,可以使用策略模式进行设计
/**
* 订货单事件的策略定义
*/
public interface OrderCreateEventStrategy {
/**
* 这个方法在订货单创建事件发生后,首先触发.系统根据方法的返回情况,确定是否使用这个策略匹配本次订单创建后的处理逻辑
*
* @param orderInfo 创建的订货单信息
* @return boolean 如果返回true,表示当前策略将会被执行.否则就不会执行当前策略的实现逻辑
*/
public boolean isHandler(OrderInfo orderInfo);
/**
* 只有当前策略实现的方法isHandler()返回true,才会执行这个onCreated()方法
*
* @param orderInfo 创建的订货单信息
*/
public void onCreated(OrderInfo orderInfo);
}
- 对于需要将事件的实现行为串联起来执行,并且需要按照业务逻辑对执行顺序进行管理,可以使用责任链模式进行程序设计
- 责任链模式建议使用递归而不是循环来进行控制,需要准备好责任链的上下文管理器
/**
* 订货单事件的责任链抽象类
* 用于订货单的事件处理,根据事件策略进行逻辑过滤处理
*/
public abstract class OrderEventFilter {
/**
* 在订货单事件触发时,参与逻辑处理链
*
* @param orderInfo 当前触发事件的订货单
* @param event 事件.包括DELETE,CREATE,UPDATE等操作
* @param orderEventHolder 订单事件管理器.对后续处理和处理过程的上下文进行控制
*/
public abstract void handler(OrderInfo orderInfo, Event event, OrderEventHolder orderEventHolder);
}
总结
- 本篇文章的程序设计示例都是在单应用系统实现的.对于微服务系统来说,同样是遵循功能模块的设计原则
- 微服务系统中设计进程间的通信,所以要考虑到以下几个方面:
- 多进程间的数据一致性
- 进程间的消息订阅和发布等