状态机引擎在vivo营销自动化中的深度实践 | 引擎篇02

本文是《vivo营销自动化技术解密》的第3篇文章,分析了营销自动化业务背景和状态机引入原因、状态机的三种基本实现方式,同时介绍了几种业界流行的开源状态机框架实现和特点,以及在项目开发过程中的实践和总结。

一、业务背景

营销自动化平台支持多种不同类型运营活动策略(比如:短信推送策略、微信图文推送策略、App Push推送策略),每种活动类型都有各自不同的执行流程和活动状态。比如短信活动的活动执行流程如下:

(图1-1:短信活动状态转移)

整个短信活动经历了 未开始 → 数据准备中 → 数据已就绪 → 活动推送中→ 活动结束 多个状态变更流程。不仅如此, 我们发现在活动业务逻辑处理过程中,都有以下类似的特点:

  • 每增加一种新的活动业务类型,就要新增相应的活动状态以及处理状态变更的逻辑;

  • 当一个类型的活动业务流程有修改时,可能需要对原先的状态转移过程进行变更;

  • 当每个业务都各自编写自己的状态转移的业务代码时,核心业务逻辑和控制逻辑耦合性会非常强,扩展性差,成本也很高。

针对系统状态的流转管理,计算机领域有一套标准的理论方案模型——有限状态机。

二、理解状态机

2.1 状态机定义

有限状态机(Finite-State Machine , 缩写:FSM),简称状态机。是表示有限个状态以及这些状态之间的转移和触发动作的模型。

  • 状态是描述系统对象在某个时刻所处的状况。

  • 转移指示状态变更,一般是通过外部事件为条件触发状态的转移。

  • 动作是对给定状态下要进行的操作。

简而言之,状态机是由事件、状态、动作三大部分组成。三者的关系是:事件触发状态的转移,状态的转移触发后续动作的执行。其中动作不是必须的,也可以只进行状态转移,不进行任何操作。

(图2-1:状态机组成)

所以将上述【图1-1:短信活动状态转移 】使用状态机模型来描述就是:

(图2-2:短信活动状态机)

状态机本质上是对系统的一种数学建模,将问题解决方案系统化表达出来。下面我们来看下在实际开发中有哪些实现状态机的方式 。

2.2 状态机的实现方式

2.2.1 基于条件判断的实现

这是最直接的一种实现方式,所谓条件判断就是通过使用 if-else 或 switch-case 分支判断进行硬编码实现。对于前面短信活动,基于条件判断方式的代码实例如下:

/**
  * 短信活动状态枚举
  */
public enum ActivityState {
    NOT_START(0), //活动未开始
    DATA_PREPARING(1), //数据准备中
    DATA_PREPARED(2), //数据已就绪
    DATA_PUSHING(3), //活动推送中
    FINISHED(4); //活动结束
}
 
/**
  * 短信活动状态机
  */
public class ActivityStateMachine {
    //活动状态
    private ActivityState currentState;
    public ActivityStateMachine() {
        this.currentState = ActivityState.NOT_START;
    }
    /**
     * 活动时间开始
     */
    public void begin() {
        if (currentState.equals(ActivityState.NOT_START)) {
            this.currentState = ActivityState.DATA_PREPARING;
            //发送通知给运营人员
            notice();
        }
        // do nothing or throw exception ...
    }
 
    /**
     * 数据计算完成
     */
    public void finishCalData() {
        if (currentState.equals(ActivityState.DATA_PREPARING)) {
            this.currentState = ActivityState.DATA_PREPARED;
            //发送通知给运营人员
            notice();
        }
        // do nothing or throw exception ...
    }
 
     /**
     * 活动推送开始
     */
    public void beginPushData() {
        //省略
    }
     /**
     * 数据推送完成
     */
    public void finishPushData() {
        //省略
    }
}

通过条件分支判断来控制状态的转移和动作的触发,上述的 if 判断条件也可以换成 switch 语句,以当前状态为分支来控制该状态下可以执行的操作。

适用场景

适用于业务状态个数少或者状态间跳转逻辑比较简单的场景。

缺陷

当触发事件和业务状态之间对应关系不是简单的一对一时,就需要嵌套多个条件分支判断,分支逻辑会变得异常复杂;当状态流程有变更时,也需要改动分支逻辑,不符合开闭原则,代码可读性和扩展性非常差。

2.2.2 基于状态模式的实现

了解设计模式的童鞋,很容易就可以把状态机和状态模式这两个概念联系起来,状态模式其实可以作为状态机的一种实现方式。主要实现思路是通过状态模式将不同状态的行为进行分离,根据状态变量的变化,来调用不同状态下对应的不同方法。代码示例如下:

/**
   * 活动状态接口
   */
interface IActivityState {
    ActivityState getName();
    //触发事件
    void begin();
    void finishCalData();
    void beginPushData();
    void finishPushData();
}
 
 /**
   * 具体状态类—活动未开始状态
   */
public class ActivityNotStartState implements IActivityState {
    private ActivityStateMachine stateMachine;
    public ActivityNotStartState(ActivityStateMachine stateMachine) {
        this.stateMachine = stateMachine;
    }
 
    @Override
    public ActivityState getName() {
        return ActivityState.NOT_START;
    }
 
    @Override
    public void begin() {
        stateMachine.setCurrentState(new ActivityDataPreparingState(stateMachine));
        //发送通知
        notice();
    }
 
    @Override
    public void finishCalData() {
        // do nothing or throw exception ...
    }
    @Override
    public void beginPushData() {
        // do nothing or throw exception ...
    }
    @Override
    public void finishPushData() {
        // do nothing or throw exception ...
    }
}
 
 /**
   * 具体状态类—数据准备中状态
   */
public class ActivityDataPreparingState implements IActivityState {
    private ActivityStateMachine stateMachine;
    public ActivityNotStartState(ActivityStateMachine stateMachine) {
        this.stateMachine = stateMachine;
    }
 
    @Override
    public ActivityState getName() {
        return ActivityState.DATA_PREPARING;
    }
    @Override
    public void begin() {
        // do nothing or throw exception ...
    }
    public void finishCalData() {
        stateMachine.setCurrentState(new ActivityDataPreparedState(stateMachine));
        //TODO:发送通知
    }
   @Override
    public void beginPushData() {
        // do nothing or throw exception ...
    }
    @Override
    public void finishPushData() {
        // do nothing or throw exception ...
    }
}
    ...(篇幅原因,省略其他具体活动类)
 
 
 /**
   * 状态机
   */
public class ActivityStateMachine {
    private IActivityState currentState;
    public ActivityStateMachine(IActivityState currentState) {
        this.currentState = new ActivityNotStartState(this);
    }
    public void setCurrentState(IActivityState currentState) {
        this.currentState = currentState;
    }
    public void begin() {
        currentState.begin();
    }
    public void finishCalData() {
        currentState.finishCalData();
    }
    public void beginPushData() {
        currentState.beginPushData();
    }
    public void finishPushData() {
        currentState.finishCalData();
    }
}

状态模式定义了状态-行为的对应关系, 并将各自状态的行为封装在对应的状态类中。我们只需要扩展或者修改具体状态类就可以实现对应流程状态的需求。

适用场景

适用于业务状态不多且状态转移简单的场景,相比于前面的if/switch条件分支法,当业务状态流程新增或修改时,影响粒度更小,范围可控,扩展性更强。

缺陷

同样难以应对业务流程状态转移复杂的场景,此场景下使用状态模式会引入非常多的状态类和方法,当状态逻辑有变更时,代码也会变得难以维护。

可以看到,虽然以上两种方式都可以实现状态机的触发、转移、动作流程,但是复用性都很低。如果想要构建一个可以满足绝大部分业务场景的抽象状态机组件,是无法满足的。

2.2.3 基于DSL的实现

2.2.3.1 DSL 介绍

DSL 全称是 Domain-Specific Languages,指的是针对某一特定领域,具有受限表达性的一种计算机程序设计语言。不同于通用的编程语言,DSL只用在某些特定的领域,聚焦于解决该领域系统的某块问题。DSL通常分为 内部 DSL ( Internal DSLs ),外部 DSL ( external DSLs ) 。

  • 内部DSL :基于系统的宿主语言,由宿主语言进行编写和处理的 DSL,比如:基于 Java 的 内部 DSL 、基于 C++ 的内部 DSL 、基于 Javascript 的 内部 DSL 。

  • 外部DSL :不同于系统宿主语言,由自定义语言或者其他编程语言编写并处理的 DSL,有独立的解析器。比如:正则表达式、XML、SQL、HTML 等。

(有关DSL的更多内容可以了解:Martin Fowler《Domain Specific Languages》)。

2.2.3.2 DSL 的选型和状态机实现

使用DSL作为开发工具,可以用更加清晰和更具表达性的形式来描述系统的行为。DSL 也是目前实现状态机比较推荐的方式,可以根据自身的需要选用内部 DSL 或者外部DSL 来实现。

  • 内部 DSL :业务系统如果只希望通过代码直接进行状态机的配置,那么可以选择使用内部 DSL,特点是简单直接,不需要依赖额外的解析器和组件。

Java 内部 DSL 一般是利用 Builder Pattern 和 Fluent Interface 方式(Builder 模式和流式接口),实现示例:

StateMachineBuilder builder = new StateMachineBuilder();
                     builder.sourceState(States.STATE1)
                            .targetState(States.STATE2)
                            .event(Events.EVENT1)
                            .action(action1());
  • 外部 DSL :可以利用外部存储和通用脚本语言的解析能力,实现运行时动态配置、支持可视化配置和跨语言应用场景。

外部 DSL 本质上就是将状态转移过程用其他外部语言进行描述,比如使用 XML 的方式:

<state id= "STATE1">
  <transition event="EVENT1"  target="STATE2">
    <action method="action1()"/>
  </transition>
</state>
 
<state id= "STATE2">
</state>

外部 DSL 一般放在配置文件或者数据库等外部存储中,经过对应的文本解析器,就可以将外部 DSL 的配置解析成类似内部 DSL 的模型,进行流程处理;同时由于外部存储的独立性和持久性,可以很方便地支持运行时动态变更和可视化配置。

Java开源的状态机框架基本上都是基于DSL的实现方式。

三、开源状态机框架

我们分别使用三种开源状态机框架来完成短信活动状态流转过程。

3.1 Spring Statemachine

enum ActivityState {
    NOT_START(0),
    DATA_PREPARING(1),
    DATA_PREPARED(2),
    DATA_PUSHING(3),
    FINISHED(4);
 
    private int state;
    private ActivityState(int state) {
        this.state = state;
    }
}
 
enum ActEvent {
    ACT_BEGIN, FINISH_DATA_CAL,FINISH_DATA_PREPARE,FINISH_DATA_PUSHING
}
 
@Configuration
@EnableStateMachine
public class StatemachineConfigurer extends EnumStateMachineConfigurerAdapter<ActivityState, ActEvent> {
    @Override
    public void configure(StateMachineStateConfigurer<ActivityState, ActEvent> states)
            throws Exception {
                states
                .withStates()
                .initial(ActivityState.NOT_START)
                .states(EnumSet.allOf(ActivityState.class));
    }
    @Override
    public void configure(StateMachineTransitionConfigurer<ActivityState, ActEvent> transitions)
            throws Exception {
                transitions
                .withExternal()
                .source(ActivityState.NOT_START).target(ActivityState.DATA_PREPARING)
                .event(ActEvent.ACT_BEGIN).action(notice())
                .and()
                .withExternal()
                .source(ActivityState.DATA_PREPARING).target(ActivityState.DATA_PREPARED)
                .event(ActEvent.FINISH_DATA_CAL).action(notice())
                .and()
                .withExternal()
                .source(ActivityState.DATA_PREPARED).target(ActivityState.DATA_PUSHING)
                .event(ActEvent.FINISH_DATA_PREPARE).action(notice())
                .and()
                .withExternal()
                .source(ActivityState.DATA_PUSHING).target(ActivityState.FINISHED)
                .event(ActEvent.FINISH_DATA_PUSHING).action(notice())
                .and() ;
    }
    @Override
    public void configure(StateMachineConfigurationConfigurer<ActivityState, ActEvent> config)
            throws Exception {
        config.withConfiguration()
                .machineId("ActivityStateMachine");
    }
    public Action<ActivityState, ActEvent> notice() {
        return context -> System.out.println("【变更前状态】:"+context.getSource().getId()+";【变更后状态】:"+context.getTarget().getId());
    }
 
   //测试类
   class DemoApplicationTests {
    @Autowired
    private StateMachine<ActivityState, ActEvent> stateMachine;
 
    @Test
    void contextLoads() {
        stateMachine.start();
        stateMachine.sendEvent(ActEvent.ACT_BEGIN);
        stateMachine.sendEvent(ActEvent.FINISH_DATA_CAL);
        stateMachine.sendEvent(ActEvent.FINISH_DATA_PREPARE);
        stateMachine.sendEvent(ActEvent.FINISH_DATA_PUSHING);
        stateMachine.stop();
    }
}

通过重写配置模板类的三个configure方法,通过流式Api形式完成状态初始化、状态转移的流程以及状态机的声明,实现Java内部DSL的状态机。外部使用状态机通过sendEvent事件触发,推动状态机的自动流转。

优势

  • Spring Statemachine 是 Spring 官方的产品,具有强大生态社区。

  • 功能十分完备,除了支持基本的状态机配置外,还具备可嵌套的子状态机、基于zk的分布式状态机和外部存储持久化等丰富的功能特性。

缺陷

  • Spring Statemachine 在每个 statemachine 实例内部保存了当前状态机上下文相关的属性,也就是说是有状态的(这一点从触发状态机流转只需事件作为参数也可以看出来),所以使用单例模式的状态机实例不是线程安全的。要保证线程安全性只能每次通过工厂模式创建一个新的状态机实例,这种方式在高并发场景下,会影响系统整体性能。

  • 代码层次结构稍显复杂,二次开发改造成本大,一般场景下也并不需要使用如此多的功能,使用时观感上显得比较沉重。

3.2 Squirrel Foundation

public class SmsStatemachineSample {
    // 1. 状态定义
     enum ActivityState {
        NOT_START(0),
        DATA_PREPARING(1),
        DATA_PREPARED(2),
        DATA_PUSHING(3),
        FINISHED(4);
 
        private int state;
        private ActivityState(int state) {
            this.state = state;
        }
    }
 
    // 2. 事件定义
    enum ActEvent {
        ACT_BEGIN, FINISH_DATA_CAL,FINISH_DATA_PREPARE,FINISH_DATA_PUSHING
    }
 
    // 3. 状态机上下文
    class StatemachineContext {
    }
 
    @StateMachineParameters(stateType=ActivityState.class, eventType=ActEvent.class, contextType=StatemachineContext.class)
    static class SmsStatemachine extends AbstractUntypedStateMachine {
        protected void notice(ActivityState from, ActivityState to, ActEvent event, StatemachineContext context) {
            System.out.println("【变更前状态】:"+from+";【变更后状态】:"+to);
        }
    }
 
    public static void main(String[] args) {
        // 4. 构建状态转移
        UntypedStateMachineBuilder builder = StateMachineBuilderFactory.create(SmsStatemachine.class);
        builder.externalTransition().from(ActivityState.NOT_START).to(ActivityState.DATA_PREPARING).on(ActEvent.ACT_BEGIN).callMethod("notice");
        builder.externalTransition().from(ActivityState.DATA_PREPARING).to(ActivityState.DATA_PREPARED).on(ActEvent.FINISH_DATA_CAL).callMethod("notice");
        builder.externalTransition().from(ActivityState.DATA_PREPARED).to(ActivityState.DATA_PUSHING).on(ActEvent.FINISH_DATA_PREPARE).callMethod("notice");
        builder.externalTransition().from(ActivityState.DATA_PUSHING).to(ActivityState.FINISHED).on(ActEvent.FINISH_DATA_PUSHING).callMethod("notice");
 
        // 5. 触发状态机流转
        UntypedStateMachine fsm = builder.newStateMachine(ActivityState.NOT_START);
        fsm.fire(ActEvent.ACT_BEGIN,  null);
        fsm.fire(ActEvent.FINISH_DATA_CAL, null);
        fsm.fire(ActEvent.FINISH_DATA_PREPARE, null);
        fsm.fire(ActEvent.FINISH_DATA_PUSHING, null);
     }
}

squirrel-foundation 是一款轻量级的状态机库,设计目标是为企业使用提供轻量级、高度灵活、可扩展、易于使用、类型安全和可编程的状态机实现。

优势

  • 和目标理念一致,与 Spring Statemachine 相比,不依赖于spring框架,设计实现方面更加轻量,虽然也是有状态的设计,但是创建状态机实例开销较小,功能上也更加简洁,相对比较适合二次开发。

  • 对应的文档和测试用例也比较丰富,开发者上手容易。

缺陷

  • 过于强调“约定优于配置”的理念,不少默认性的处理,比如状态转移后动作是通过方法名来调用,不利于操作管理。

  • 社区活跃度不高。

3.3 Cola Statemachine

/**
 * 状态机工厂类
 */
public class StatusMachineEngine {
    private StatusMachineEngine() {
    }
    private static final Map<OrderTypeEnum, String> STATUS_MACHINE_MAP = new HashMap();
 
    static {
        //短信推送状态
        STATUS_MACHINE_MAP.put(ChannelTypeEnum.SMS, "smsStateMachine");
        //PUSH推送状态
        STATUS_MACHINE_MAP.put(ChannelTypeEnum.PUSH, "pushStateMachine");
        //......
    }
 
    public static String getMachineEngine(ChannelTypeEnum channelTypeEnum) {
        return STATUS_MACHINE_MAP.get(channelTypeEnum);
    }
 
   /**
     * 触发状态转移
     * @param channelTypeEnum
     * @param status 当前状态
     * @param eventType 触发事件
     * @param context 上下文参数
     */
    public static void fire(ChannelTypeEnum channelTypeEnum, String status, EventType eventType, Context context) {
        StateMachine orderStateMachine = StateMachineFactory.get(STATUS_MACHINE_MAP.get(channelTypeEnum));
        //推动状态机进行流转,具体介绍本期先省略
        orderStateMachine.fireEvent(status, eventType, context);
    }
 
/**
 * 短信推送活动状态机初始化
 */
@Component
public class SmsStateMachine implements ApplicationListener<ContextRefreshedEvent> {
 
    @Autowired
    private  StatusAction smsStatusAction;
    @Autowired
    private  StatusCondition smsStatusCondition;
 
    //基于DSL构建状态配置,触发事件转移和后续的动作
    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        StateMachineBuilder<String, EventType, Context> builder = StateMachineBuilderFactory.create();
        builder.externalTransition()
                .from(INIT)
                .to(NOT_START)
                .on(EventType.TIME_BEGIN)
                .when(smsStatusAction.checkNotifyCondition())
                .perform(smsStatusAction.doNotifyAction());
        builder.externalTransition()
                .from(NOT_START)
                .to(DATA_PREPARING)
                .on(EventType.CAL_DATA)
                .when(smsStatusCondition.doNotifyAction())
                .perform(smsStatusAction.doNotifyAction());
        builder.externalTransition()
                .from(DATA_PREPARING)
                .to(DATA_PREPARED)
                .on(EventType.PREPARED_DATA)
                .when(smsStatusCondition.doNotifyAction())
                .perform(smsStatusAction.doNotifyAction());
        ...(省略其他状态)
        builder.build(StatusMachineEngine.getMachineEngine(ChannelTypeEnum.SMS));
    }
 
   //调用端
   public class Client {
     public static void main(String[] args){
          //构建活动上下文
          Context context = new Context(...);
         // 触发状态流转
          StatusMachineEngine.fire(ChannelTypeEnum.SMS, INIT, EventType.SUBMIT, context);
      }
   }
}

Cola Statemachine 是阿里COLA开源框架里面的一款状态机框架,和前面两者最大的不同就是:无状态的设计——触发状态机流转时需要把当前状态作为入参,状态机实例中不需要保留当前状态上下文消息,只有一个状态机实例,也就直接保证了线程安全性和高性能。

优势

  • 轻量级无状态,安全,性能高。

  • 设计简洁,方便扩展。

  • 社区活跃度较高。

缺陷

  • 不支持嵌套、并行等高级功能。

3.4 小结

三种开源状态机框架对比如下:

希望直接利用开源状态机能力的系统,可以根据自身业务的需求和流程复杂度,进行合适的选型。

四、营销自动化业务案例实践

4.1 设计选型

vivo营销自动化的业务特点是:

  • 运营活动类型多,业务流量大,流程相对简单,性能要求高。

  • 流程变更频繁,经常需要新增业务状态,需要支持快速新增配置和变更。

  • 在状态触发后会有多种不同的业务操作,比如状态变更后的消息提醒,状态完结后的业务处理等,需要支持异步操作和方便扩展。

针对以上业务特点,在实际项目开发中,我们是基于开源状态的实现方案——基于内部DSL的方式进行开发。同时汲取了以上开源框架的特点,选用了无状态高性能、功能简洁、支持动作异步执行的轻量设计。

  • 无状态高性能:保证高性能,采用无状态的状态机设计,只需要一个状态机实例就可以进行运转。

  • 功能简洁:最小设计原则,只保留核心的设计,比如事件触发,状态的基本流转,后续的操作和上下文参数处理。

  • 动作异步执行:针对异步业务流程,采用线程池或者消息队列的方式进行异步解耦。

4.2 核心流程

  • 沿用开源状态机的内部DSL流式接口设计,在应用启动时扫描状态机定义;

  • 创建异步处理线程池支持业务的后置动作;

  • 解析状态机的DSL配置,初始化状态机实例;

  • 构建执行上下文,存放各个状态机的实例和其他执行过程信息;

  • 状态机触发时,根据触发条件和当前状态,自动匹配转移过程,推动状态机流转;

  • 执行后置同步/异步处理操作。

(图4-1:核心流程设计)

4.3 实践思考

1)状态机配置可视化,结合外部DSL的方式(比如JSON的方式,存储到数据库中),支持更快速的配置。

2)目前只支持状态的简单流转,在流转过程加入流转接口扩展点,应对未来可能出现的复杂场景。

五、总结

状态机是由事件、状态、动作三大部分组成。三者的关系是:事件触发状态的转移,状态的转移触发后续动作的执行。利用状态机进行系统状态管理,可以提升业务扩展性和内聚性。状态机可以使用条件分支判断、状态模式和基于DSL来实现,其中更具表达性的DSL也是很多开源状态机的实现方式。可以基于开源状态机的特点和自身项目需求进行合适的选型,也可以基于前面的方案自定义状态机组件。

本篇是《营销自动化技术解密》系列专题文章的第三篇,系列文章回顾:

《营销自动化技术解密|开篇》

《设计模式如何提升 vivo 营销自动化业务拓展性|引擎篇01》

后面我们将继续带来系列专题文章的其他内容,每一篇文章都会对里面的技术实践进行详尽解析,敬请期待。

作者:vivo互联网服务器团队-Chen Wangrong