零、写在前面
“状态”
算是 人们对事物一个很基本的抽象理解了,在现实世界里,“状态”
无时无刻不体现在我们的生活和工作之中;现实中客观存在的事物,我们总可以给它定义出几个状态来。 而在软件领域,也很早就形成了基于状态的行为模型范式,即 有限状态机(Finite-State Machine
)。 本文将 结合状态机的实现框架Spring State Machine
(aka. SSM
, 下面的内容将直接使用此简称),介绍下状态机的基本原理,以及在实践中遇到的一些坑。
一、什么是状态机?
1.1 事物的态
本质上,任何现实中的事物都有其状态,所谓的状态是指在某一时间点上,事物存在的形式;而不同的事物之间,通过状态进行相互影响,相互改变。
我们回到现实世界中,来看看两个例子:
- 水的存在有三种状态:固态:零下摄氏度为凝结为冰、霜、液态: 0-100 摄氏度之间、气态:100度以上形成水蒸气。而水的存在状态,会直接影响人类的态: 人类可以用固态的冰来消暑,而用液态的来饮用、洗漱、灌溉;气态的水可以用来用作蒸汽发动机提供动力。
- 手机的状态: 关机、待机、通话中、网络连接、飞行模式、无信号、屏幕破损等,这些状态会直接影响到我们的行为,如关机、无信号、飞行模式等,都无法打电话,联系其他人;网络连接不上,我们就没办法通过手机上网等等。
所以,这个世界存在的形式,在某种意义上来说,可以说就是各种个体事物之间的状态相互作用、相互影响的产物。
实际上,作为程序员,我们编程的本质,就是对事物的态的处理。没感觉?那想想我们写的所有的if()语句吧,所有的if()语句都是对事物状态的处理。
1.2 有限状态机-对事物的态的抽象 现实世界中,我们经常会考虑到事物的生命周期。在整个生命周期中,会呈现出各种状态,而状态和状态之间会发生流转。 以手机为例,最简单的状态有: 关机、待机、有信号、无信号等;而关机和待机两个状态,可以通过开机这个动作完成流转。 对事物生命周期抽象的过程,在软件工程上,被称为状态机。 状态机 主要至少需要抽象两件事: ● 某个具体事物存在的几种状态; ● 状态之间的转换关系
什么是有限状态机? 有限状态机(英语:finite-state machine,缩写:FSM)又称有限状态自动机(英语:finite-state automation,缩写:FSA),简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型。
UML建模规范中,专门定义了状态机的行为模型,接下来将介绍几个核心的概念:
- State:状态 ● Pseudo state , 伪状态,是状态机中的和事物本身没有关系的状态,用以描述状态机完整工作的状态,比如状态机的开始,结束等 ● Simple State, 真实状态,表示的是真实的事物状态 ● Composite State, 组合状态,将多个简单状态组合成一个新的状态 ● Submachine State, 子状态机状态,状态机可以嵌套子状态机,而子状态机可以的整体可以Submachine State 表示。
- Event : 事件,状态流转时锁触发的事件
- Transition: 状态流转,表达的是从一个状态到另外一个状态的转换,包含 source state,target state,event 信息 ● ExternalTransition 两个不同状态之间的流转 ● Internal Transition 一个状态下的内部流转,不会影响到当前状态改变
- Region 作用域,将某个状态机或者 组合状态
- StateMachine,状态机:以上所有概念的集合。
接下来将通过一个实际的例子,重点介绍几个关键概念。先看一个简化版的电商订单状态机:
1.2.1 State-状态
上述例子的图中,所有的节点都称之为状态;而状态会包含如下几种组成部分:
❏ state 当前状态的描述
❏ entry (进入节点前应当处理的行为)
❏ exit (从当前状态退出时应当采取的行为)
其中,entry
、exit
是状态定义的两种行为,而这两种方式就是留给业务拓展的地方。比如待收货状态下,可以在entry
行为上,增加通知用户逻辑,而不影响整体状态流转
在Spring StateMachine中,会将 entry
和 exit
抽象成统一的action
, 在状态定义的时候进行初始化。 如下是一个最精简的State定义:状态值的类型,可以是任意结构,比如用字符串表示 “INITIAL”,“SAVED”,“DELETED”, 也可以用复杂的结构体,只要保证其唯一性即可。
public interface State<S, E> {
/**
* Initiate an exit sequence for the state.
*
* @param context the state context
*/
void exit(StateContext<S, E> context);
/**
* Initiate an entry sequence for the state.
*
* @param context the state context
*/
void entry(StateContext<S, E> context);
/**
* 状态id
*/
S getId();
/**
* entry actions
*/
Collection<? extends Action<S, E>> getEntryActions();
/**
* exit actions
*/
Collection<? extends Action<S, E>> getExitActions();
}
而 enty
和 exit
的执行,则是靠 事件(Event) 触发:
1.2.2 Event-事件
状态内或者不同的状态之间,通过事件的方式进行触发转换。如下图所示, 从State A
到 State B
发生转换,通过Event 触发。
对于状态机而言,事件对象可以使用任意的结构进行表达,也可以用基于消息的形式来体现,如下的结构在SSM里可以表示事件:
代码语言:javascript复制public interface Message<T> {
T getPayload();
MessageHeaders getHeaders();
}
一般情况下,T 为事件的本体,可以是String, 也可以是复杂的结构;而在Header里,可以携带一些上下文信息,作为状态机引擎执行过程中的上下文参数使用。比如,可以在header 携带当前状态机所表示的订单实例的订单号、创建人等属性。
客户端可以对状态机实例发出事件请求,而状态机实例会根据当前实例所处的状态进行判断,是否可以接受并处理消息。一般接受消息,会有相应的状态机状态的转换(Transition)动作。
1.2.3 Transition - 状态转换
状态机的状态之间的流转行为,如下图所示,状态和状态之间的连线,在定义上,被称为transition。
一个transition 包含五个元素:
● source State (原状态)
: 状态变迁的起始状态
● Trigger(触发器)
: 触发器是指如何触发transition的形式,可以基于事件,也可以基于定时器。大部分场景下,是基于事件的触发器。根据事件的key 去寻找 transition, 唯一匹配到特定的transition;
●target State (目标状态)
:事件发生后,应当到达的状态
● guard (门卫)
:当事件请求触发时,可以定义校验规则,当满足此规则的时候,则正常执行状态变迁,否则提前终止
●actions(动作)
:当状态机判断transition 是合法时,会执行 actions。actions是框架层面开放出来的拓展点,请注意,actions 如果执行抛出了异常,则transition 状态变迁将会终止。
SSM中对Transition 的定义和上述的要素基本一致:
代码语言:javascript复制public interface Transition<S, E> {
/**
* 执行 transition操作
*
*/
boolean transit(StateContext<S, E> context);
/**
* 执行transition 相关的action , 一般会在 transit()内触发调用
*/
void executeTransitionActions(StateContext<S, E> context);
/**
* 来源状态
*/
State<S,E> getSource();
/**
* 目标状态
*/
State<S,E> getTarget();
/**
* 守卫
*/
Guard<S, E> getGuard();
/**
* 业务自定义的action
*/
Collection<Action<S, E>> getActions();
/**
* 触发器,可以是事件触发器,也可以是定时触发器,事件触发器居多
*/
Trigger<S, E> getTrigger();
}
二、基于状态机的编程模式的价值
如果不使用状态机的编程模式,基于上述的电商系统的例子,我们也可以通过简单的if-else的语句来完成,类似的伪代码为:
代码语言:javascript复制 Order order = ...;
if(order.status.equals("待支付")){
}else if(order.status.equals("待发货")){
}else if(order.status.equals("待收货")){
}else if(order.status.equals("待评价")){
}else if(order.status.equals("已完成")){
}
这种 if-else 的方式本质上也是状态机,只不过把状态的变迁处理,hard code 到了代码里。这种研发模式会有如下几个问题:
- 问题分析过程和代码实现的衔接出现断层,两者没有约束力; 一般在需求阶段,研发人员与PD、业务方、都会通过这种状态机分析方式进行梳理业务逻辑,大家统一语言,形成了逻辑上的约束力,而这种约束力没有模型化体现在代码上的限制,而是研发人员基于模型,自己手动coding完成;随着时间的推移,业务上的调整,研发人员基于沟通理解再转化成代码,渐渐地,会导致逻辑不一致的情况;
- 代码的拓展性差,业务逻辑的调整,都需要代码重新修改,造成不小的成本 而针对状态机的抽象,使用要统一的UML模型,在建模期间,再到代码结构上能保持高度统一,然后再借助配置化工具,可以做到基于配置就可以灵活的组织代码,保证代码的拓展性。
除了使用状态机的建模分析问题,如果能够将状态机的整体模型应用到代码中,将极大地提高系统的拓展能力和灵活度。将状态机模型统一,将代码逻辑的实现变成拓展点来实现。为了达到系统的灵活性,一般会配备一个开发者后台,进行基于状态机流转和业务能力的动态组装。
三、状态机的使用过程
在了解状态机的使用过程之前,先来梳理下面三个概念:
3.1 状态机模型、实例和实体对象的关系
- 状态机模型: 是指定义了某个实体的状态集合,以及状态之间的流转逻辑;
- 状态机实例 : 基于状态机模型定义,进行实例化;实例化的状态机,会有当前流转的状态、实例id等信息;
- 实体对象: 是指现实中的实体,如订单,一个订单可能包含订单编号、商品名称、订单金额、当前状态、下单时间等一系列信息;而一个订单实体会对应一个状态机的实例,状态机的实例会和订单的状态保持一致。
3.2 定义状态机模型
一个 SSM 状态机模型包含了三个元素:
- 状态集合(States): 即 共有多少状态,哪个是起始状态和终止状态
- 状态转换(Transitions) : 根据1.2.3定义的Transition结构维护的 转换集合
- 配置项(Configuration Data): 这部分主要是包含了在“SSM”框架下的一些运行时配置信息,比如状态机的Listeners,Monitors, 安全校验策略等
其中,1、2 两个元素的的定义比较简单,在形式上可以根据实际需要,使用不同的持久化方式。比如,你可以使用UML建模工具,以标准UML语言来进行可视化定义;你也可以使用关系型数据库来存储。后面会详细介绍他们的差异点和应用场景;
如下是SSM的状态机模型的抽象定义:
代码语言:javascript复制/**
* Base abstract SPI class for state machine configuration.
*
* @author Janne Valkealahti
*
* @param <S> the type of state
* @param <E> the type of event
*/
public abstract class StateMachineModel<S, E> {
/**
* 和Spring StateMahcine 执行策略先关的配置
*
* @return the configuration config data
*/
public abstract ConfigurationData<S, E> getConfigurationData();
/**
* 通用的状态定义
*
* @return the states config data
*/
public abstract StatesData<S, E> getStatesData();
/**
* 通用的Transition 状态转换定义
*
* @return the transitions config data
*/
public abstract TransitionsData<S, E> getTransitionsData();
}
3.2.1 状态机模型定义的方式
状态机模型的定义,分为如下三种类型:
● 在代码中硬编码,使用StateMachineBuilder
直接构建。这种模式比较简单直接,因为通过代码进行定义的,灵活度很受限。如下是样例:
public enum States {
SI, S1, S2
}
public enum Events {
E1, E2
}
@Configuration
@EnableStateMachine
public class StateMachineConfig
extends EnumStateMachineConfigurerAdapter<States, Events> {
@Override
public void configure(StateMachineConfigurationConfigurer<States, Events> config)
throws Exception {
config
.withConfiguration()
.autoStartup(true)
.listener(listener());
}
@Override
public void configure(StateMachineStateConfigurer<States, Events> states)
throws Exception {
states
.withStates()
.initial(States.SI)
.states(EnumSet.allOf(States.class));
}
@Override
public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
throws Exception {
transitions
.withExternal()
.source(States.SI).target(States.S1).event(Events.E1)
.and()
.withExternal()
.source(States.S1).target(States.S2).event(Events.E2);
}
}
一般使用状态机,在于系统内的模型会有多个,并且灵活多变,采用这种模式会导致每次业务逻辑发生调整,都要重新修改代码。这种模式从生产实践上看不太可取,和直接编写业务逻辑区别不是很大,无非是换了一种编码风格。
● 借助UML建模工具(官方推荐的工具是Eclipse Papyrus) 定义uml 文件,然后加载。因为要借助UML建模工具,本身有较高的使用门槛,一般是有建模经验的开发人员,需要对UML 状态机模型有很深刻的理解。使用时,需要安装 Eclipse Papyrus 软件,其交互页面大致如下,有全面的UML规范:
通过上述工具定义好模型,需要将产生的文件加载到应用中,代码结构如下:
代码语言:javascript复制@Configuration
@EnableStateMachine
public static class Config2 extends StateMachineConfigurerAdapter<String, String> {
@Override
public void configure(StateMachineModelConfigurer<String, String> model) throws Exception {
model
.withModel()
.factory(modelFactory());
}
@Bean
public StateMachineModelFactory<String, String> modelFactory() {
UmlStateMachineModelFactory factory = new UmlStateMachineModelFactory(
"classpath:org/springframework/statemachine/uml/docs/simple-machine.uml");
factory.setStateMachineComponentResolver(stateMachineComponentResolver());
return factory;
}
@Bean
public StateMachineComponentResolver<String, String> stateMachineComponentResolver() {
DefaultStateMachineComponentResolver<String, String> resolver = new DefaultStateMachineComponentResolver<>();
// action 和 guard 手动进行instance 注册
resolver.registerAction("myAction", myAction());
resolver.registerGuard("myGuard", myGuard());
return resolver;
}
public Action<String, String> myAction() {
return new Action<String, String>() {
@Override
public void execute(StateContext<String, String> context) {
}
};
}
public Guard<String, String> myGuard() {
return new Guard<String, String>() {
@Override
public boolean evaluate(StateContext<String, String> context) {
return false;
}
};
}
}
一般是会用这种方式的,主要是开发人员自身有状态机开发经验,并且能够将产品需求理解梳理成状态机,基于UML建模,和产品、测试等项目参与成员有个共同语言,一定程度上降低了沟通和开发的成本。但是由于基于配置文件的加载机制,在业务调整时,可能需要通过发布变更的方式处理,不能做到无缝升级,可能还伴随着历史数据的兼容处理等。
● 抽出底层的状态机模型,用数据库存储。这种模式下,SSM 自身会将几个核心的模型 state
、transition
、action
、guard
抽出E-R 模型结构,然后借助通用的存储进行持久化,目前官方支持的几个存储: JPA(传统关系型数据库)
, Mongodb
, Redis
。
在这种模式下,一般业务系统自身需要基于业务场景,自定义状态机的配置页面。这种成本相对比较高些,但是拓展性比较好,可以做到基于业务规则进行动态 runtime 级别的调整,即时生效。 较之前两种,都不能做到即时生效;另外这种基于数据库存储,可以将配置项做成版本化,不同的历史数据,可以用不同的状态机逻辑,这样可以有很好的逻辑隔离和系统兼容。
将上述三种方案汇总起来,如下图所示:
3.3 生成状态机实例
根据上述定义状态机模型的三种方式,从模型可以加载成状态机实例。整个过程如下图所示:
状态机实例的几个关键信息:
● machineId
: 一般是具备实例特性表征的id,比如订单编号、用户的唯一id
● uuid
: 为在SSM中表示唯一性,会在内部生成一个uuid表示,未在上图显示出来
● currentStatus
: 当前所处的状态
● ModelData
: 基于状态机模型,加载出来的内存对象,包括 states
、transition
、guard
、action
其中,
machineId
和currentStatus
当前尚未指定值,需要根据真实的实例进行还原。
3.4 根据真实数据还原状态机状态
一个状态机实例,实际上是对现实中某一个实例的在状态上的表达,所以machineId 和 currentStatus 需要根据真实数据进行映射还原,如下是一个例子:
3.5 向状态机发送事件
最后一步,则是向状态机发送事件,状态机实例则会根据是事件的消息体,做如下几个相关逻辑:
- 根据消息体,转换成事件值
event
; - 基于
event
查询是哪个transition
,transition
如上所述,会关联sourceStatus
和targetStatus
; - 判断
sourceStatus
和状态机实例的currentStatus
是否相等,如果不相等,则抛出异常,状态机处理失败; - 执行
transition
上定义的guard
,guard
执行成功,则执行transition
上定义的actions
. - 真正执行状态跃迁,
currentStatus
值从sourceStatus
变迁到targetStatus
状态机实例 处理事件的过程,是整个状态机引擎里非常核心的逻辑,将上述的流程串起来,模型上如下:
SSM中开放出了令人发指的非常多的拓展点,令人眼花缭乱;掌握这些拓展点,其核心是要区分出来,哪些是数据阻塞性拓展点和非阻塞性拓展点:
阻塞性拓展点
:即如果抛出异常,则状态机执行失败,状态跃迁失败,所执行的相关事务全部回滚非阻塞性拓展点
:即使执行期间抛出了异常,也不会阻塞状态的跃迁(即状态机的主流程),仅仅是对执行时期的拓展抽象
简单的来说:①、② 就属于阻塞性拓展点;③、④ 属于非阻塞性拓展点。
四、SSM 拓展点全序图
最后,结合上述描述的阻塞性拓展点和非阻塞性拓展点,整体上来看,SSM开放出的拓展点如下图所示,其中标红的属于阻塞性拓展点,紫色的则为非阻塞性拓展点。读者可以结合自身的要求,选择合适的时机进行业务拓展。
五、写在后面
本文从相对宏观的角度,阐述了状态机的基本理念,以及SSM的一些基础设计,尚未涉及到全部细节,如父子状态机、Region、状态类型(内部状态、外部状态)、分布式状态机、状态机工厂等,以及 SSM本身存在的设计缺陷,以及如何在实践中对状态机进行改造,将另开一篇文章介绍~ 待续~
如果在实践中也遇到问题,欢迎一起讨论。