本系列文章专注于讨论在业务系统设计时,如何降低业务系统中各个模块的耦合度,以提供更好的业务扩展性。本系列文章还会具体演示设计模式,特别是行为模式如何被用于实际的应用系统设计过程中。(注意:本系列文章会假设读者已经知晓常用的设计模式,并已经有真实的业务系统开发经历)
现在我们遇到这样的一个业务场景,A公司开发了一款物流系统产品(记为产品X)。这款产品中有如下功能需求:
当进行到货单内部验证成功并生成到货单后,随后会生成一张入库单。到货单业务和入库单业务相对独立,而且不止到货单可以生成入库单,调拨单也可以生成入库单,只是入库单的入库类型不一样而已。业务调用关系如下图所示:

上图所示的业务调用关系基于需求是合理的,简单而且很好理解。调用关系中每个业务模块要处理的功能边界是清晰的,如果单从这个业务需求层面去直接理解代码逻辑,那么编写出来的代码也不存在循环依赖的问题。将需求直接翻译成代码(伪代码示例),那么到货单的业务代码可以是这样:
@Service
public class ArrivalServiceImpl implements ArrivalService {
@Autowired
private ArrivalRepository arrivalRepository;
@Autowired
private WarehousingService warehousingService;
@Override
@Transactional
public void create() {
// 首先进行到货单自身的验证和保存
ArrivalDto arrival = new ArrivalDto();
// 当然要进行相关属性的赋值,并进行验证
// ......
// validate(arrival);
// ......
this.arrivalRepository.save(arrival);
// 接着创建入库单业务所需的对象
WarehousingDto warehousingDto = new WarehousingDto();
// 当然这里要根据arrival对象的属性,进行入库单对象属性的赋值
// ......
// warehousingDto.setField(arrival.getXXX);
// ......
// 最后调用入库单业务
this.warehousingService.create(warehousingDto);
}
}
但是在上一小节本文已经说过,这是A公司开发的一个产品,而产品一旦销售给客户,客户大概率是会根据自己的实际业务对产品功能提出定制化要求的。A公司的产品X就遇到了这样的情况。现在客户对到货单和入库单两个模块的需求做出了定制化要求:
1、到货单生成后,不止要生成入库单,还需要生成一张检验单(检验单业务是这个客户独有的一种业务)。
2、产品X所提供的标准入库单业务,和客户的入库业务匹配度较低(30%),客户要求入库业务需要进行定制开发
3、产品X所提供的标准到货单业务,虽然匹配度较高(90%)但有些细节仍然需要定制,如:达到了仓储上限不允许入库。
4、A公司在长期的销售过程成中,A公司的市场人员发现基本上入库单业务,都会被客户提出自定义要求;而到货单业务、调拨业务的变化要求基本比较少。

我们来分析一下以上的需求,就会发现客户对于产品X的定制需求可以归纳为以下几种模式:
收到客户对产品X的定制要求后,A公司通常可以有以下处理方式:
A、不做这单生意:
这显然是一个明智的做法,如果A公司的产品不能适应客户需求的变化,那么A公司不如趁早解散,良心老板还可以给各位打工人发放N+1,甚至2N的补偿,何乐而不为呢?
B、说服客户改变自己的业务流程:
这个难度甚至高于处理方式A的难度,特别是放在内卷严重的应用研发领域。如果该软件只是客户业务的边缘领域,那么这种改变几乎不可能;如果该软件涉及客户的核心业务领域,那么这样的调整将使客户付出极大的成本(例如时间成本、人力成本、物资成本等)
C、对产品X的源代码进行修改:
按照客户的要求,对产品X进行修改也是一个可选择的处理方式。不过会遇到新的问题:这个自定义需求只会出现在这个客户的业务场景下,那么新的客户如果对产品A提出了新的定制需求怎么办?特别是新的客户如果对产品A中的到货单、入库单业务提出了新的需求,那么又该怎么办?

D、由专门的二开小组进行定制化开发:
二次开发小组只针对该客户提出的特定模块的需求变化,对特定模块进行功能调整。调整的代码只存放于该客户管理的代码仓库、编译环境、运行环境。甚至在合同规定的实施过程完成后,可以将定制代码(注意是定制代码而不是产品代码)交付给客户,由客户的技术团队进行后续的研发、维护工作。
可能有的读者会说,处理方式C和处理方式D不就是一种处理方式吗?只不过对于研发团队的叫法不一样而已。但实际上处理方式C和处理方式D,在技术层面上有本质的区别:
处理方式C进行功能修改时,必须对产品级别的源代码进行修改;处理方式D进行功能修改时,不会修改产品级别的源代码,在按需引入产品级模块后,只需要对产品定义的接口进行扩展即可完成自定义功能研发。
如果使用在“代码仓库中建立多条代码分支”的方式来对处理方式C中形成的“客户定制系统”源代码进行单独维护(例如在GIT仓库中,专门创建一个新的分支)。那么两种方式在分支的管理上也会有明显的区别:处理方式C的每条分支中都包括所有的完整代码,且每个分支的都存在若干的不同(因为每个客户的定制需求不一样),这明显增加了运维团队运维难度和研发团队的开发难度,甚至在几个月后除产品的原始分支外,其余分支均不可维护(由于人员离职、技术债务等原因)。
这还涉及到一个标准产品A升级的问题,由于不同分支上,产品A的代码都针对不同的客户进行了不同程度的源代码定制修改。那么当标准产品A进行升级时,大概率会产生代码冲突。那么负责维护某个客户分支代码的技术团队,就一定会对升级有所顾忌。
而处理方式D中,如果要使用“建立代码分支进行客户定制产品源代码”的管理方式(实际上由于产品级别的源代码不会通过正常途径泄露,所以这种处理方式下的二次开发代码完全可以存储在客户自己的源代码仓库中),在这个分支上也只会有特定客户的定制代码和配置信息,标准产品层面的功能模块都是以依赖配置的方式存在。这保证了在标准产品功能进行升级的同时,客户系统只需要根据自己的实际情况决定是否升级标准产品的特性,而无需每次都进行产品级别源代码的合并。
在商务支持和售前支持层面,处理方式C和处理方式D也存在着明显的差异。例如研发团队是否只能被动地将产品级源代码提供给客户,是否能根据商务合同主动选择是否向客户提供产品级源代码。

读者在使用Spring框架时,这种区别就体现得非常明显:研发人员不需要知晓Spring的工作原理也不必获得Spring框架的任何源代码,只需要引入需要的Spring组件依赖就可以进行系统研发;Spring框架中各个组件的研发团队也不需要了解各种业务系统的需求,就可以向社区提供底层功能。
什么叫耦合性?耦合性是一种软件设计度量,一般是指在程序中业务模块和业务模块之间的依赖程度。在微服务化流行的当下,这种耦合性度量也同样成立,把业务模块换成微服务即可。本系列文章后续的内容,将详细介绍和分析模块的概念,并详细介绍进行低耦合系统设计的基本原则,我们还会和读者一起从一个全新的角度剖析设计模式在模块解耦过程中的作用。
在本篇文章中,读者可以这样理低耦合状态的业务模块:业务边界的内部围成了一个黑盒,且这个业务边界是清晰可控的。在黑盒外的“人”并不知道黑盒的工作原理和工作情况,但黑盒提供了各种手段让黑盒外的“人”可以对黑盒的工作状态进行监控、替换,可以对黑盒的工作过程进行干预、设定。 这样的描述保证了黑盒完全不会知道边界以外的世界,自然也就不会受到边界外变化的影响。
在上一节提到的解决方式C和解决方式D,落地到具体的代码实现上也是不一样的。诸如解决方式C这样的思路,我们可以按照以下方式进行产品级源代码的调整
// 对产品中的到货单业务进行修改
// 调整到货单的验证逻辑,并在到货单中增加对检验单的调用
@Service
public class ArrivalServiceImpl implements ArrivalService {
@Autowired
private ArrivalRepository arrivalRepository;
@Autowired
private WarehousingService warehousingService;
// 检验单服务
@Autowired
private InspectionService inspectionService;
@Override
@Transactional
public void create() {
// 首先进行到货单自身的验证和保存
ArrivalDto arrival = new ArrivalDto();
// 这里的验证逻辑需要进行修改
// ......
// validateForSonething(arrival);
// ......
this.arrivalRepository.save(arrival);
// 接着生成创建入库单业务所需的对象
WarehousingDto warehousingDto = new WarehousingDto();
// 当然这要根据arrival对象的属性,进行入库单对象属性的赋值
// ......
// warehousingDto.setField(arrival.getXXX);
// ......
// 然后调用入库单业务
this.warehousingService.create(warehousingDto);
// 最后调用当前这个客户特有的场景,生成检验单的业务过程
this.inspectionService.create(inspectionDto);
}
}
这种处理方式并没有实质的降低耦合性(实际上就是上一节解决方式C在代码层面的落地形式),每当到货单业务或者其直接/间接关联的业务发生变化时,都可能对到货单业务的实现逻辑进行修改。这引起的研发、运维和商务层面的问题在上文已经进行了描述,这里不再进行赘述。
那么读者能想到一些什么样的设计方式,来降低到货单模块、检验单模块、入库单模块等业务模块的耦合度呢?一种很容易想到的方式就是通过某种中间件提供的订阅/发布机制,在各个模块间进行信息通知。例如利用Redis的订阅/发布机制;又或者使用消息队列进行消息推送;如下图所示:

根据使用的中间件不同,代码实现的方式也不同。但目标都只有一个,就是在尽可能降低各个模块耦合程度的基础上,将事件信息通知给其他模块,保证各个业务模块的扩展性。但是这样设计的问题也很明显:
一致性问题:由于这种方式通过了第三方中间件,无论是同步还是异步方式都会产生事务性问题。例如到货单处理成功并向中间件发出了事件通知,但是在随后的入库单处理过程中,却出现了问题。这时为了保证数据的一致性,就需要一种方式通知到货单业务,以便到货单业务能够进行业务回滚。也就是说无论系统复杂与否,设计人员都要设计额外的数据一致性机制。这并不是说这样的设计有什么错误,而是说需要根据系统的复杂度、重要程度、使用背景、商务情况等因素进行综合考虑,再来决定是否有必要这么做。
适用场景有限:订阅/发布方式或者消息推送方式,只能向外界传递当前业务模块发生了变化,但是无法让外界(无论是模块还是服务)干预当前业务模块的逻辑过程——例如上文需求中提到的,需要为到货模块额外增加“如果仓储已满则不允许创建到货单”这样的验证逻辑。究其原因是因为这些事件或者消息被通知给外界的前提是:当前业务模块的逻辑已经成功完成。
运维问题:引入一个和业务过程有关的中间件会增加业务系统的结构复杂度,还会增加运维复杂度,当然后者会增加多少就和中间件本身的使用场景、设计方式和工作特性有关了。如果读者参与的是大型系统的设计研发,并且系统中确有多个场景适用中间件的某种使用场景(如MQ中间件的使用场景无非适用于解耦、异步、消峰、边界外传输的场景)那么可以使用中间件来顺便解决耦合性问题。但如果只是单独为了降低系统中多个模块的耦合度,而选择引入一款中间件,那么这种设计方式则是不可取的。
这种解耦的设计方式往往出现在存在循环依赖的两个或者多个模块中,如下图所示:

创建一个下沉的模块后(称为模块X),原来的A、B、C模块将调用X模块提供的功能,而将循环依赖问题限制在X模块的逻辑内部。注意这样做并不能解决循环依赖问题,但是可以限制循环依赖的问题在一个可控范围。当然这样做同样也存在问题:
下沉的业务模块使得业务边界不再清晰:因为模块X既包括了一部分A模块负责的业务,也包括了一部分B模块负责的业务。也就是说这种下沉的业务模块越多,系统中的业务边界将越模糊,系统的技术债务和业务债务将出现堆积。
下沉的业务模块将增大系统的维护难度:这个问题也很好理解,当前应用系统在没有增加业务功能的情况下,却增加了一个没有明确业务边界的下层业务模块。以前进行到货功能的维护只需要维护到货功能模块本身,现在由于两个模块中都有一些到货功能的实现逻辑所以需要维护两个功能模块。
下沉得到的新的业务模块,粒度无法再细化:这个详细的描述参考《应用软件设计不是CRUD:如何进行应用系统功能模块的耦合性设计》一文中的描述。
很显然上一节提到的处理方式D,才是这种场景下研发团队更佳选择,但这就需要产品A本身具有很好的扩展性。需要产品A可以在尽量保持应用系统各模块低耦合状态的前提下,在二次开发团队不需要了解整个产品底层工作原理的前提下,进行自定义功能/模块的二次开发工作。
那么我们怎么让设计出来的业务系统,具备处理方式D所需要的扩展特性呢?要对业务进行扩展性设计,就需要对业务进行抽象理解。因为只有对业务抽象化,才能找到这些自定义需求的共同特点。对于业务的不同抽象理解,为设计者从不同角度理解业务变化的共性,剥离出业务细节提供帮助。以下对于业务的抽象理解,没有最正确的只有更适合的:
经过业务抽象以后,是不是有了一些似曾相识的感觉?是的,这就是我们大家都熟悉的监听器模式、观察者模式和责任链模式等设计模式的应用场景。在下篇文章中,本系列文档将进行详细说明。(接下文)