审批操作是ERP或OA系统中必不可少的功能之一。这里介绍两种我设计的用于审批操作的方案,并借此就“状态模式”与“策略模式”提出一点自己的理解。
别问我为什么不使用工作流引擎等工具来实现审批功能。做第一版方案时,我孤陋寡闻得并不知道有这个东西。后来引入工作流框架会导致学习曲线骤然上扬,不太划算。
背景
背景无需过多介绍,不外乎有一些数据/任务/请求,需要由领导们点一下头或者按钮。
思路
由于孤陋寡闻,在得到需求之后,我第一反应不是“工作流”,而是“状态机”。它从“提交”状态开始,流经“已初审”“已终审”或者“初审驳回”“终审驳回”等状态,进入终态。
这个状态机如下图所示:
当然,状态机中状态的名称、状态间的流转,是与业务需求紧密相关的。例如,有些业务会要求在“已终审”状态下执行“驳回”操作后进入“终审驳回”状态,而有些则要求返回“已初审”状态。不过万变不离其宗,种种流程最终都能归纳到“状态机”中来。
在这个思路下,我用了两种不同的设计模式来实现需求——状态模式和策略模式,它们都很好的完成了任务。需要多说一句的是,这是两个不同系统下独立的两次实现,而不是一个系统中的“原始版”和“改进版”。因而,两个方案之间并没有非常显著的优劣对比,本文的重点也不是二者的“优劣”对比。
方案一:状态模式
- 状态模式
首先来回顾一下我们常说的状态模式。简便起见,这里只提供类图。
其中的核心是“状态”接口。这个接口中有N个方法,对应的是状态机中的N个状态。每个方法负责从当前状态迁移到另一个状态上——一般是别的状态,也可以仍然是当前状态。
每个具体的状态都继承自这个接口,并在实现类中封装自己所需要的数据、重写自己的状态迁移操作。 - 我的方案
在我的设计方案中,类图则是这个样子的:
与“教科书”上的类图相似的,是“状态”接口(Examiner),以及各个实际状态所对应的子类。
与之不同的是,虽然我的状态机中有五个状态,但是由于每个状态最多都只有两个状态迁移操作(通过,或者驳回),因此,状态接口中我只定义了两个方法。
还有一点不同在于,我在Examiner接口下,加了一个默认的实现类(ExaminerAsDefault)。这个类实际上什么都不做,每个方法都直接抛出UnSupportedOperationException。这个类的作用是简化子类,使得每个子类只需要重写自己关心的方法,而不需要重写无关方法。当然,Java 8为接口引入的默认方法,可以实现同样的功能,这是后话。此外,由于业务需求中每次只做一步状态迁移,因此Examiner接口不需要再返回自己。还有一点不同的是,这个方案中,状态迁移操作与状态数据被拆开了——迁移操作由Examiner定义,状态数据则用Dto来封装。 - 扩展
当出现新的状态、或者新的迁移操作怎么办呢?
出现新的状态时,创建一个新的“状态”子类,并实现对应的“状态迁移”方法就行了。出现新的迁移操作时则更简单,只需要做第二步就可以了。
方案二:策略模式
- 策略模式
众所周知的策略模式一般都有这样的类图: - 我的方案
在我的设计方案中,类图则是这样的:
可以说这是一个“标准”的策略模式类图。接口定义从一个状态到另一个状态的迁移动作,不同的子类用不同的“策略”去实现它——例如从“已提交”到“已初审”,或者从“已初审”到“初审驳回”,等等。
状态相关的数据,仍然由单独的Dto来保存和传递。 - 扩展
策略模式下,如何增加新的状态、新的迁移操作呢?
由于策略模式仅仅定义了“状态迁移”动作,因此,无论是增加新的状态、还是增加新的迁移操作,都只需要增加对应的子类即可。
对比
我并不喜欢比较不同设计模式之间的区别。但这里仍可以多说几句。
用状态模式实现状态机,大概是一个最直观、最容易想到的设计。但是,标准的状态模式将状态数据也封装到状态类中。这使得这个类无法用单例实现。另外,由于状态接口中,对应每一个状态都有一个方法,这可能会使得部分子类非常的大。
用策略模式实现状态机,与状态机思想是有冲突的。状态机是以“状态”为本,状态迁移操作为辅;而策略模式却专注于状态迁移操作,“状态”的概念淡化得几乎消失了。此外,与状态模式中的“超级类”相反,策略模式可能导致“类爆炸”。
两种模式之间的分界线,也许只是概念上的“以状态为本”或“以操作为本”。就实践上来说,像我的方案中那样,将状态模式中的数据与操作拆分开,那么整个方案与策略模式其实相去无几。
这是我不喜欢比较不同设计模式之间区别的原因。由于设计模式的变化、组合非常多,很多时候不同设计模式之间的界限仅仅存在于概念上、思想上,而不在实践中。费尽心思去分析“如何区分23种设计模式”,只在学习阶段有一点意义。我们更应该关注设计模式适用的业务场景、业务问题,以及如何实现它们。
毕竟,科学可以满足于“认识世界”,技术必须要以“改造世界”为目标。