Overview
状态机脑图:

machine: 状态机,调用者唯二感知的对象,是所有事件变化的入口
state:状态,包含状态code和进入、进出回调
transition:状态转移,表名从一个状态迁移到另一个状态,一般允许多个起始状态,但不允许多个终止状态,有进入进出的回调
event:事件,会触发一个状态转移,在触发状态转移时会传递一些事件上下文
示例,商户追偿状态机:

图中每个线条就是一个transition,每个节点是一个state。
需要注意的是,一个transition可以有一个或多个起始状态,但只能有一个终止状态。
transition有多个起始状态的场景:
1,复用简单的业务流程,比如客服审核拒绝、风控审核拒绝等流程,它们相似度很高:状态变化只是起始状态不同,使用一个transition,可以减少代码冗余
Event & Transition
在状态机中,事件是非常重要的部分,一方面负责将用户行为和状态转移关联在一起,另一方面充当触发器触发transition。
通过状态迁移表,可以发现event充当了触发器的角色:

观察下图,可以看到事件是如何将用户行为和状态转移关联在一起的:

一般情况下,业务验证和任务绑定会编排在事件中,这样做的目的是将业务变化聚集到事件这一层中,保证transition&state层的逻辑干净独立,也有利于代码的维护。
举个例子,索赔服务客服审核有两个事件分别是:客服审核通过、客服审核超时自动成功,这两个事件对应的是一个相同的transtion:CSAcceptTransition。
可以发现,这两个事件有各自不同的业务验证:审核通过无需验证时间,自动通过需要验证是否处理超时。
也有不同的业务行为:审核通过需要记录客服身份id和动作描述等,自动通过需要标记通过是系统行为。
因为我们将业务逻辑聚合在事件这一层,所以我们将业务验证和业务行为都聚合在了事件层,即客服人工审核通过和客服审核超时自动通过使用不同的事件。用这样的方式,我们可以很轻松地根据不同的用户行为来编写不同的事件,并保持transition这一层有效复用。
任务管理
每个事件都会有不同的任务,比如客服审核成功后,需要将进度推送索赔到风控审核,又比如索赔成功之后,需要推送触达消息给商户。
常见任务编排的方式有两种:
1,全量绑定
每个事件在定义时都会绑定全部的任务,比如:
class CompensationSuccessEvent(object):
TASKS = ['send_compensation_success_notice', 'create_voucher_order']
全量绑定的方式非常简单,易于阅读、维护,不过如果状态机中的事件的任务存在交集的话(比如客服审核失败、风控审核失败等事件都会发送相同的失败触达消息),会存在部分冗余代码。
2,基于routing key的编排方案
为了解决复杂状态机中任务代码重复的问题,可以使用一种基于routing key的编排方案,示例如下:
class CSRejectEvent(object):
ROUTING_KEY = "compensation.cs_approve.reject"
EVENT_TO_TASKS = {
"*.*.*":[],
"compensation.*.reject": ["send_compensation_fail_notice"]
}
不过这种方式增加了实现的复杂度,新人接手代码时,不能很快的理解,也容易出错。
用户动作
在整个状态机中,所有的状态迁移都是依赖外部事件的,而事件又是由用户触发了。这里就产生了几个问题:
1,业务验证应该在哪一层?
2,某些自动推进的状态迁移要怎么触发(比如客服审核通过后要推送风控审核)?
第一个问题,因为事件的粒度已经够细了,所以将业务验证放在事件这一层是没有问题的。
第二个问题,有两种方式,一个是在前一个事件完结后,添加一个后续事件的任务即可(mysql task),但是这种方式,会将业务逻辑耦合在一起,如果我们后续需要添加新的审核节点,需要改动现有事件代码,违背了开闭原则。
另一种解决方式是额外的实现一层EventWatcher(cron job,mq message),将需要自动触发的事件聚合在这一层(推送审核、时效类订单判责等),如果需要新增节点,一般不用改动现有事件的代码,只需要改动EventWatcher的设置即可。详细流程可参考下图:

其他事项
1,基于状态编排,还是基于事件编排
基于状态编排可以方便的看出某个状态下的事件走向,便于阅读,但是如果有节点变更,可能导致整个编排改动,代码量更大,除此之外,如果某个transition具备多个起始状态,会有很多重复代码:
CancelState:
InvalidTranstion: {
DoSomeTasks
ReturnNextState
}
基于事件编排,没有直接的聚合,不便于快速理解整个状态机,但是易于代码复用,并对变更友好:
InvaliTransition(from_states, to_state)
2,分支处理
如果某个状态出现分支,即存在多个终止状态,一定不能将分支揉在一个transition里,通过在transition中进行判断来动态决定终止条件。
一个可选的做法是,将分支判断拆到EventWatcher中进行。
3,event:transition = 1:N
在某些场景下可能出现,某个事件对应了多个transition的情况,比如:

遇到这种情况,可以在transition的on enter & on exit方法中注入后续状态迁移的逻辑,除此之外,还可以新增一个restarting状态,然后通过EventWatcher实现重启的相关操作。
4,初始状态&终止状态
在实践中发现,业务方使用状态机,一般都需要专门制定特殊的初始状态和终止状态,这两个状态一般只表示业务开始或完结,不会绑定业务含义。
这样做的主要原因是固定的头尾可以方便我们对状态机做各种变更,比如增减节点。
5,状态机初始化
在业务状态机中,经常需要将状态机和DTO做绑定,这给状态机初始化带来了一些特殊性:普通的状态机在初始化的时候只需要指定初始状态即可,而绑定DTO的状态机需要在初始化的时候创建数据库记录(可能会有多个表需要添加记录),有时甚至可能需要进行一些特殊的业务操作。
为状态机添加单独的init流程,可以解决这个问题。