Spring Statemachine でステートマシンを処理

Spring Statemachine を使って単純な有限ステートマシン(FSM)を実装してみました。

ソースは http://github.com/fits/try_samples/tree/master/blog/20171002/

はじめに

Spring Boot 2.0.0.M4 を使用して Kotlin で実装するため、以下のような Gradle ビルド定義を使いました。

build.gradle
buildscript {
    ext {
        kotlinVersion = '1.1.51'
        springBootVersion = '2.0.0.M4'
    }
    repositories {
        mavenCentral()
        maven { url "https://repo.spring.io/snapshot" }
        maven { url "https://repo.spring.io/milestone" }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
        classpath("org.jetbrains.kotlin:kotlin-allopen:${kotlinVersion}")
    }
}

apply plugin: 'kotlin'
apply plugin: 'kotlin-spring'
apply plugin: 'org.springframework.boot'

compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

repositories {
    mavenCentral()
    maven { url "https://repo.spring.io/snapshot" }
    maven { url "https://repo.spring.io/milestone" }
}

dependencies {
    // JDK 9 でも実行できるようにバージョンを設定
    compile("org.springframework.boot:spring-boot-starter:${springBootVersion}")
    // Spring Statemachine
    compile('org.springframework.statemachine:spring-statemachine-core:2.0.0.BUILD-SNAPSHOT')

    compile("org.jetbrains.kotlin:kotlin-stdlib-jre8:${kotlinVersion}")
    compile("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}")
}

上記は Spring Initializr で生成したものをベースに多少の変更 ※ を加えています。

 ※ 不要な設定を削除し、JDK 9 でも実行できるように
    compile(・・・) で spring-boot-starter のバージョンを設定

      compile('org.springframework.boot:spring-boot-starter') のままでは
      JDK 9 で以下のようなエラーが発生したため(バージョン指定が欠ける)

        Could not find org.springframework.boot:spring-boot-starter:.

a. StateMachineBuilder 使用

Spring Statemachine では、有限ステートマシンを定義するために以下のような方法が用意されているようなので、まずは StateMachineBuilder を使ってみます。

実装するステートマシンは以下の通りです。

  • 初期状態は Idle 状態
  • Idle 状態で On イベントが発生すると Active 状態へ遷移
  • Active 状態で Off イベントが発生すると Idle 状態へ遷移
現在の状態 Off On
Idle Active
Active Idle

Spring Statemachine におけるステートマシンは StateMachine<状態の型, イベントの型> として扱います。

今回は状態の型を States、イベントの型を Events とし、enum で定義しています。

StateMachineBuilder を使用する場合、builder() で取得した StateMachineBuilder.Builder<状態の型, イベントの型> に対してステートマシンの状態(初期状態など)や状態遷移等の設定を行います。

状態の設定は configureStates()StateMachineStateConfigurer を取得し、更に withStates() で取得した StateConfigurer で設定します。

initial で初期の状態を指定し、states で全ての状態を指定します。

状態遷移の設定は configureTransitions() で取得した StateMachineTransitionConfigurer に対して行います。

別の状態へ遷移する場合は withExternal() で取得した ExternalTransitionConfigurer で設定します。

source(状態) で遷移前の状態、target(状態) で遷移後の状態を指定し event(イベント) で遷移のきっかけとなるイベントを指定します。

その際に、何らかの処理を行う場合は action(処理) で指定できます。

複数の状態遷移を繋げて書きたい場合は and() を使います。

状態遷移等の状況確認には StateMachineListener が使えます。

src/main/kotlin/sample/Application.kt
package sample

import org.springframework.boot.CommandLineRunner
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.context.annotation.Bean
import org.springframework.messaging.Message
import org.springframework.statemachine.StateMachine
import org.springframework.statemachine.config.StateMachineBuilder
import org.springframework.statemachine.listener.StateMachineListenerAdapter
import org.springframework.statemachine.state.State

// 状態
enum class States { Idle, Active }
// イベント
enum class Events { On, Off }

@SpringBootApplication
class Application : CommandLineRunner {
    override fun run(vararg args: String?) {
        val machine = stateMachine()
        machine.addStateListener(SampleListener())

        // ステートマシンの開始
        machine.start()

        // Idle -> Active (stateChanged)
        machine.sendEvent(Events.On)

        // Active -> Idle (stateChanged)
        machine.sendEvent(Events.Off)

        // Idle 状態で Off しても何も起こらない (eventNotAccepted)
        machine.sendEvent(Events.Off)
    }

    // 有限ステートマシンの定義
    @Bean
    fun stateMachine(): StateMachine<States, Events> {
        val builder = StateMachineBuilder.builder<States, Events>()
        // 状態の設定
        builder.configureStates().withStates()
                .initial(States.Idle).states(States.values().toSet())

        // 遷移の設定
        builder.configureTransitions()
                // On イベントで Idle から Active 状態へ遷移
                .withExternal().source(States.Idle).target(States.Active).event(Events.On)
                .and()
                // Off イベントで Active から Idle 状態へ遷移
                .withExternal().source(States.Active).target(States.Idle).event(Events.Off)

        return builder.build()
    }
}

class SampleListener : StateMachineListenerAdapter<States, Events>() {
    // 状態遷移の発生時
    override fun stateChanged(from: State<States, Events>?, to: State<States, Events>?) {
        println("*** stateChanged: ${from?.id} -> ${to?.id}")
    }
    // 受付不可なイベント発生時
    override fun eventNotAccepted(event: Message<Events>?) {
        println("*** eventNotAccepted: ${event?.payload}")
    }
}

fun main(args: Array<String>) {
    SpringApplication.run(Application::class.java, *args)
}

実行結果は以下の通りです。

実行結果
> gradle -q bootRun

・・・
*** stateChanged: null -> Idle
・・・
*** stateChanged: Idle -> Active
*** stateChanged: Active -> Idle
*** eventNotAccepted: Off
・・・
・・・ o.s.s.support.LifecycleObjectSupport     : destroy called

処理が終わるとプロセスは終了しました。

b. @StateMachineFactory アノテーション使用

次に、@StateMachineFactory アノテーションを使ってステートマシンを定義します。

状態やイベントの型に enum を使っている場合は、EnumStateMachineConfigurerAdapter<状態の型, イベントの型> を extends したクラスへ @StateMachineFactory を付与します。

この場合、@Autowired 対象の変数の型を StateMachineFactory<状態の型, イベントの型> とします。

@StateMachine アノテーションの場合も基本的に同じで、その場合は @Autowired 対象の型を StateMachine<状態の型, イベントの型> とします。

ステートマシンの定義は、該当する configure(xxxConfigurer) をオーバーライドして StateMachineBuilder と同じ様に設定するだけです。

ここでは、StateMachineBuilder のサンプルへ以下の機能を追加してみました。

  • start() メソッドを呼び出さなくても開始するように autoStartup(true) を設定
  • Active 状態のまま 2秒経過すると Idle 状態へ戻る遷移を追加

状態遷移は以下のようになります。

現在の状態 Off On Timeout (2秒)
Idle Active
Active Idle Idle

ここでは、withInternal()timerOnce(ミリ秒)action(処理) を組み合わせて、Active 状態が 2秒続いた(タイムアウトした)際に Off イベントを送信して Idle 状態へ遷移するようにしてみましたが、timerOnce(ミリ秒)withExternal() でも使えます。

src/main/kotlin/sample/SampleStateMachineConfig.kt
package sample

import org.springframework.statemachine.StateContext
import org.springframework.statemachine.config.EnableStateMachineFactory
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter
import org.springframework.statemachine.config.builders.StateMachineConfigurationConfigurer
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer

enum class States { Idle, Active }
enum class Events { On, Off }

// 有限ステートマシンの定義
@EnableStateMachineFactory
class SampleStateMachineConfig : EnumStateMachineConfigurerAdapter<States, Events>() {
    override fun configure(config: StateMachineConfigurationConfigurer<States, Events>?) {
        config!!.withConfiguration()
                // 自動的に開始(start メソッドを呼び出す必要がなくなる)
                .autoStartup(true)
    }

    override fun configure(states: StateMachineStateConfigurer<States, Events>?) {
        states!!.withStates()
                .initial(States.Idle).states(States.values().toSet())
    }

    override fun configure(transitions: StateMachineTransitionConfigurer<States, Events>?) {
        transitions!!
                .withExternal().source(States.Idle).target(States.Active).event(Events.On)
                .and()
                .withExternal().source(States.Active).target(States.Idle).event(Events.Off)
                .and()
                .withInternal().source(States.Active).timerOnce(2000).action(this::timeout)
                // 以下でも可
                //.withExternal().source(States.Active).target(States.Idle).timerOnce(2000)
    }

    private fun timeout(ctx: StateContext<States, Events>) {
        println("*** timeout: ${ctx.source.id}")
        // Off イベント送信(Idle 状態へ戻す)
        ctx.stateMachine.sendEvent(Events.Off)
    }
}

上記を @Autowired して使います。

autoStartup を有効化したので StateMachine の start() を呼び出す必要はありません。

src/main/kotlin/sample/Application.kt
package sample

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.messaging.Message
import org.springframework.statemachine.config.StateMachineFactory
import org.springframework.statemachine.listener.StateMachineListenerAdapter
import org.springframework.statemachine.state.State

@SpringBootApplication
class Application : CommandLineRunner {
    @Autowired
    lateinit var stateMachineFactory: StateMachineFactory<States, Events>

    override fun run(vararg args: String?) {

        val machine = stateMachineFactory.stateMachine
        machine.addStateListener(SampleListener())

        machine.sendEvent(Events.On)
        machine.sendEvent(Events.Off)

        machine.sendEvent(Events.Off)

        // Active 状態にして放置
        machine.sendEvent(Events.On)

        // timerOnce を使うとプロセスが終了しなくなるため sleep は不要だった
        // Thread.sleep(2500)
    }
}

class SampleListener : StateMachineListenerAdapter<States, Events>() {
    override fun stateChanged(from: State<States, Events>?, to: State<States, Events>?) {
        println("*** stateChanged: ${from?.id} -> ${to?.id}")
    }

    override fun eventNotAccepted(event: Message<Events>?) {
        println("*** eventNotAccepted: ${event?.payload}")
    }
}

fun main(args: Array<String>) {
    SpringApplication.run(Application::class.java, *args)
}

実行結果は以下の通りです。

実行結果
> gradle -q bootRun

・・・
*** stateChanged: Idle -> Active
*** stateChanged: Active -> Idle
*** eventNotAccepted: Off
*** stateChanged: Idle -> Active
・・・
*** timeout: Active
*** stateChanged: Active -> Idle

timerOnce 等を使うとプロセスが終了しなくなるようなので、Ctrl + c 等でプロセスを停止します。