目录
背景
最近做了一个单据提交审核相关的功能,其中有个这样的场景:单据在失败状态可以驳回,也可以经过两层审批变为成功,但是经过了一层审批的单据不允许再驳回,已经驳回的单据也不允许再次审批通过。这里简化业务场景后,状态变化如下图:
在实际的开发中,上面的场景其实是很常见的。在笔者的业务场景中,这些状态分散在好几个域,分到每个域需要处理的状态和动作就比较少,所以分别if else处理下就能搞定。
但是,扩展一下,如果这些状态集中在一个域,或者单据状态更多,行为更多的时候,继续使用if else会发生什么呢?
可以想象,肯定是一大堆的if else嵌套,一处改动可能影响很多状态,完全不符合开闭原则,这会导致后期极难维护,然后变成一堆臃肿的烂代码。
对这种有状态的对象编程,传统的解决方案是:将这些所有可能发生的情况全都考虑到,然后使用 if-else 或 switch-case 语句来做状态判断,再进行不同情况的处理。但是显然这种做法对复杂的状态判断存在天然弊端,条件判断语句会过于臃肿,可读性差,且不具备扩展性,维护难度也大。且增加新的状态时要添加新的 if-else 语句,这违背了“开闭原则”,不利于程序的扩展。
以上问题如果采用“状态模式”就能很好地得到解决。状态模式的解决思想是:
当控制一个对象状态转换的条件表达式过于复杂时,把相关“判断逻辑”提取出来,用各个不同的类进行表示,系统处于哪种情况,直接使用相应的状态类对象进行处理,这样能把原来复杂的逻辑判断简单化,消除了 if-else、switch-case 等冗余语句,代码更有层次性,并且具备良好的扩展力。
什么是状态机?
在了解状态模式前,我们先简单了解下状态机:
有限状态机(英语:finite-state machine,缩写:FSM)又称有限状态自动机,简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。
状态机可归纳为4个要素,即现态、条件、动作、次态。“现态”和“条件”是因,“动作”和“次态”是果:
- 现态:是指当前所处的状态。
- 条件:又称为“事件”。当一个条件被满足,将会触发一个动作,或者执行一次状态的迁移。
- 动作:条件满足后执行的动作。动作执行完毕后,可以迁移到新的状态,也可以仍旧保持原状态。动作不是必需的,当条件满足后,也可以不执行任何动作,直接迁移到新状态。
- 次态:条件满足后要迁往的新状态。“次态”是相对于“现态”而言的,“次态”一旦被激活,就转变成新的“现态”了。
状态模式
状态模式是一种对象行为型模式,主要有以下优点。
- 结构清晰,状态模式将与特定状态相关的行为局部化到一个状态中,并且将不同状态的行为分割开来,满足“单一职责原则”。
- 将状态转换显示化,减少对象间的相互依赖。将不同的状态引入独立的对象中会使得状态转换变得更加明确,且减少对象间的相互依赖。
- 状态类职责明确,有利于程序的扩展。通过定义新的子类很容易地增加新的状态和转换。
基本状态模式类图
在状态模式(State Pattern)中,类的行为是基于它的状态改变的。这种类型的设计模式属于行为型模式。
上面是基本状态机模式的类图,一个抽象的状态类有多个具体的状态实现,由一个上下文类(Context)持有一个具体的状态,在上下文中转调具体状态的handle()方法,客户端一般只和Context交互。
这样,当状态增加的时候,就只需要增加实现类,符合对扩展开放,对修改封闭的原则。
使用状态模式重构业务代码
这里就直接上代码了。
抽象的state:这里给每个行为都定义了默认的实现,这样在具体的实现类中,就只需要关注当前状态支持的行为。
@Slf4j
public abstract class AbstractState {
/**
* 审核通过
* @return
*/
public String pass() {
log.info("非法操作,当前状态不允许该操作");
return "非法操作,当前状态不允许该操作";
}
/**
* 自动校验
* @return
*/
public String autoCheck() {
log.info("非法操作,当前状态不允许该操作");
return "非法操作,当前状态不允许该操作";
}
/**
* 复核通过
* @return
*/
public String rePass() {
log.info("非法操作,当前状态不允许该操作");
return "非法操作,当前状态不允许该操作";
}
/**
* 驳回
* @return
*/
public String reject() {
log.info("非法操作,当前状态不允许该操作");
return "非法操作,当前状态不允许该操作";
}
/**
* 提交
* @return
*/
public String submit() {
log.info("非法操作,当前状态不允许该操作");
return "非法操作,当前状态不允许该操作";
}
}
具体状态类:此处仅列举新建和提交状态
@Slf4j
public class NewState extends AbstractState{
@Override
public String submit() {
//do some thing
log.info("submit success!");
return "ok";
}
}
@Slf4j
public class SubmitState extends AbstractState{
@Override
public String reject() {
//do some thing...
log.info("reject success!");
return "ok";
}
@Override
public String autoCheck() {
//do some thing...
log.info("autoCheck success!");
return "ok";
}
}
......
上下文:
上下文持有所有的状态实现,客户端只需要与上下文交互,再由上下文转调具体实现类的方法。
public class BillStateContext {
private AbstractState state;
public BillStateContext(AbstractState state) {
this.state = state;
}
public String pass() {
return this.state.pass();
}
public String rePass(){
return this.state.rePass();
}
public String reject() {
return this.state.reject();
}
public String autoCheck(){
return this.state.autoCheck();
}
public String submit(){
return this.state.submit();
}
}
调用客户端:
public class Main {
@Test
public void newStateTest(){
BillStateContext context = new BillStateContext(new NewState());
Assert.assertEquals("ok", context.submit());
Assert.assertEquals("非法操作,当前状态不允许该操作", context.reject());
}
}
基本状态模式的实现是很简单的,但是,基本的状态模式其实是存在一些问题的:
- 实现类膨胀,有多少个状态就会有多少个实现类
- 每次客户端调用,都要new一个state的实现对象
优化实现
下面来个优化版本的,核心思路是使用枚举来代替多个实现类:
这里使用状态接口替换原来的抽象类:
在jdk8中,接口已经允许方法有默认的实现了,只需要添加关键字default即可,而且默认实现的方法不强制要求实现,但实现类依然可以选择Override默认的实现。
public interface BillStateInterface {
/**
* 审核通过
* @return
*/
default String pass() {
return "非法操作,当前状态不允许该操作";
}
/**
* 自动校验
* @return
*/
default String autoCheck() {
return "非法操作,当前状态不允许该操作";
}
/**
* 复核通过
* @return
*/
default String rePass() {
return "非法操作,当前状态不允许该操作";
}
/**
* 驳回
* @return
*/
default String reject() {
return "非法操作,当前状态不允许该操作";
}
/**
* 提交
* @return
*/
default String submit() {
return "非法操作,当前状态不允许该操作";
}
}
状态的枚举实现:
public enum BillStates implements BillStateInterface{
NEW{
@Override
public String submit() {
return "ok";
}
},
SUBMIT{
@Override
public String reject() {
return "ok";
},
@Override
public String autoCheck() {
return null;
}
},
WAIT{
@Override
public String rePass() {
return "ok";
}
}
......
}
客户端调用入口:
public class Main {
@Test
public void newStateTest(){
BillStateContext context = new BillStateContext(BillStates.NEW);
Assert.assertEquals("ok", context.submit());
Assert.assertEquals("非法操作,当前状态不允许该操作", context.reject());
}
}
优化后的实现,将所有的状态聚合到枚举中,更方便修改,也不会导致实现类膨胀了,不过需要注意不要将所有业务代码集合在一个类中,导致实现类过大。
题外话:在《重构 改善既有代码的设计》中,专门有讲Large Class(过大类)的问题:
如果想利用单一class做太多事情,其内往往就会出现太多instance变量。一旦如此,Duplicated Code也就接踵而至了。 你可以运用Extract Class将数个变量一起提炼至新class内。提炼时应该选择class内彼此相关的变量,将它们放在一起。例如"depositAmount"和 "depositCurrency"可能应该隶属同一个class。通常如果class内的数个变量有着相同的前缀或字尾,这就意味有机会把它们提炼到某个组件内。如果这个组件适合作为一个subclass,你会发现Extract Subclass往往比较简单。 有时候class并非在所有时刻都使用所有instance变量。果真如此,你或许可以多次使用Extract Class或Extract Subclass。 和「太多instance变量」一样,class内如果有太多代码,也是「代码重复、混乱、死亡」的绝佳滋生地点。最简单的解决方案(还记得吗,我们喜欢简单的解决方案)是把赘余的东西消弭于class内部。如果有五个「百行函数」,它们之中很多代码都相同,那么或许你可以把它们变成五个「十行函数」和十个提炼出来的「双行函 数」。
适用场景
通常在以下情况下可以考虑使用状态模式。
- 当一个对象的行为取决于它的状态,并且它必须在运行时根据状态改变它的行为时,就可以考虑使用状态模式。
- 一个操作中含有庞大的分支结构,并且这些分支决定于对象的状态时。
结合享元模式扩展
在有些情况下,可能有多个环境对象需要共享一组状态,这时需要引入享元模式,将这些具体状态对象放在集合中供程序共享,其结构图如图:
与策略模式对比
策略模式也是消除if else的比较好的方案之一,而且状态模式和策略模式的 UML 类图架构几乎完全一样,但两者的应用场景是不一样的。策略模式的多种算法行为择其一都能满足,彼此之间是独立的,用户可自行更换策略算法,而状态模式的各个状态间存在相互关系,彼此之间在一定条件下存在自动切换状态的效果,并且用户无法指定状态,只能设置初始状态。
一个通用的状态机模式
网上看到有同学设计了一个很全面的状态机,包含了状态转义的前后置操作,事件通知,状态监听,状态同步等等,设计的很全面,但也比较“重”,以后如果有业务需要可以参考做取舍,类图如下:
总结
当然,设计模式也不能生搬硬套,切忌过度设计,把简单的问题复杂化。设计模式实际上是在我们遇到复杂的业务场景时,给我们提供了一套可借鉴的方法论,我们要结合实际业务,灵活运用。
做业务技术,说起来简单,但是要做好,很难。如何从纷乱如麻的业务场景中抽象出合适的模型?如何灵活应对快速更迭的需求?如何训练结构化思维,一眼看穿需求的本质?这些都需要积累和不断的学习。
一边是底层技术,一边是复杂业务,方向不同,但殊途同归,需要的都是一样的方法论和设计理念。做业务技术,两手都要抓,在业务实践中理解,验证,并不断升级自己的所学到的方法论,才能有所成长。
长期学习,持续成长,与大家共勉!
参考资料
- 《重构 改善既有代码的设计》
- 《设计模式:可复用面向对象软件的基础》
- https://juejin.im/post/6844904023670128647