CLOVER🍀

That was when it all began.

Spring Statemachineを試してみる

これは、なにをしたくて書いたもの?

少し、ステートマシンについて調べる機会がありまして。

Spring Statemachine

Spring Statemachineのプロジェクトページは、こちら。

Spring Statemachine

Spring Statemachineの現在のバージョンは3.2.0で、ドキュメントはこちら。

Spring Statemachine - Reference Documentation

ドキュメントによると、Spring Statemachineは次の機能を提供するものらしいです。

  • シンプルなユースケース向けのフラット(1レベル)ステートマシン
  • 複雑な状態構成を容易にする、階層型ステートマシン構造
  • さらに複雑な状態構成のための、ステートマシンリージョンの提供
  • トリガー、トランジション(遷移)、ガード、アクション
  • タイプセーフな構成アダプター
  • ステートマシンイベントリスナー
  • Beanをステートマシンに関連付けるためのSpring IoCインテグレーション

Spring Statemachine / Introduction

Spring Statemachineを使う前に、用語集とクラッシュコースを読むことを勧められているので、こちらを読んでおくとよいでしょう。

Spring Statemachine / Appendices / Appendix B: State Machine Concepts / Glossary

Spring Statemachine / Appendices / Appendix B: State Machine Concepts / A State Machine Crash Course

Spring Statemachine 3.2.0は、Spring Framework 5.3.19を前提にしているようです。

Spring Statemachine 3.2.0 is built and tested with JDK 8 (all artifacts have JDK 7 compatibility) and Spring Framework 5.3.19.

Spring Statemachine / Getting started / System Requirement

その後を見ていると、Spring Bootとしては2.6.7に依存しているようなので、こちらに合わせて軽く試してみましょう。

Spring Statemachine / Getting started / Using Maven

まずはこちらを書いて動かしてみることにします。

Spring Statemachine / Getting started / Developing Your First Spring Statemachine Application

環境

今回の環境は、こちらです。

$ java --version
openjdk 17.0.3 2022-04-19
OpenJDK Runtime Environment (build 17.0.3+7-Ubuntu-0ubuntu0.20.04.1)
OpenJDK 64-Bit Server VM (build 17.0.3+7-Ubuntu-0ubuntu0.20.04.1, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.3, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-121-generic", arch: "amd64", family: "unix"

Spring Bootプロジェクトを作成する

まずは、Spring Bootプロジェクトを作成します。Spring StatemachineはSpring Initializrでは選択できないようなので、今回は依存関係なしで
作成します。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=2.6.7 \
  -d javaVersion=17 \
  -d name=statemachine-getting-started \
  -d groupId=org.littlewings \
  -d artifactId=statemachine-getting-started \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.statemachine \
  -d baseDir=statemachine-getting-started | tar zxvf -

プロジェクト内に移動。

$ cd statemachine-getting-started

自動生成されたソースコードは削除。

$ rm src/main/java/org/littlewings/spring/statemachine/StatemachineGettingStartedApplication.java src/test/java/org/littlewings/spring/statemachine/StatemachineGettingStartedApplicationTests.java

pom.xmlを確認してみます。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-parent</artifactId>
                <version>2.6.7</version>
                <relativePath/> <!-- lookup parent from repository -->
        </parent>
        <groupId>org.littlewings</groupId>
        <artifactId>statemachine-getting-started</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>statemachine-getting-started</name>
        <description>Demo project for Spring Boot</description>
        <properties>
                <java.version>17</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter</artifactId>
                </dependency>

                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-test</artifactId>
                        <scope>test</scope>
                </dependency>
        </dependencies>

        <build>
                <plugins>
                        <plugin>
                                <groupId>org.springframework.boot</groupId>
                                <artifactId>spring-boot-maven-plugin</artifactId>
                        </plugin>
                </plugins>
        </build>

</project>

ドキュメントに習って、spring-statemachine-starterを依存関係として追加します(spring-boot-starterはspring-statemachine-starterの
推移的依存関係に含まれるので、削除しました)。

Spring Statemachine / Getting started / Using Maven

 <dependencies>
        <dependency>
            <groupId>org.springframework.statemachine</groupId>
            <artifactId>spring-statemachine-starter</artifactId>
            <version>3.2.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

https://search.maven.org/artifact/org.springframework.statemachine/spring-statemachine-bom/3.2.0/pom

では、ソースコードを書いていきましょう。

ソースコードを作成する

今回作成するソースコードは、基本的にはこちらに習っていきたいと思います。

Spring Statemachine / Getting started / Developing Your First Spring Statemachine Application

ですが、完全に同じものを作成するのもなんなので、少し手を加えつつという感じで。

ステートは、Getting Startedの例にひとつ加えて、4つ用意することにしました。

src/main/java/org/littlewings/spring/statemachine/States.java

package org.littlewings.spring.statemachine;

public enum States {
    INITIAL_STATE,
    STATE1,
    STATE2,
    END_STATE
}

終了状態を追加しました。

これに合わせて、イベントもひとつ追加。

src/main/java/org/littlewings/spring/statemachine/Events.java

package org.littlewings.spring.statemachine;

public enum Events {
    EVENT1,
    EVENT2,
    EVENT3
}

ステートとイベントは、Enumとして定義します。

次に、ステートマシンの設定をしていきます。

src/main/java/org/littlewings/spring/statemachine/StateMachineConfig.java

package org.littlewings.spring.statemachine;

import java.util.EnumSet;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.StateMachine;
import org.springframework.statemachine.config.EnableStateMachine;
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;
import org.springframework.statemachine.listener.StateMachineListener;
import org.springframework.statemachine.listener.StateMachineListenerAdapter;
import org.springframework.statemachine.state.State;
import org.springframework.statemachine.transition.Transition;

@Configuration
@EnableStateMachine
public class StateMachineConfig extends EnumStateMachineConfigurerAdapter<States, Events> {
    @Override
    public void configure(StateMachineConfigurationConfigurer<States, Events> config)
            throws Exception {
        config
                .withConfiguration()
                .autoStartup(true)
                .machineId("my-statemachine")
                .listener(listener());
    }

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
                .withStates()
                .initial(States.INITIAL_STATE)
                .end(States.END_STATE)
                .states(EnumSet.allOf(States.class));
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE).target(States.STATE1).event(Events.EVENT1)
                .and()
                .withExternal()
                .source(States.STATE1).target(States.STATE2).event(Events.EVENT2)
                .and()
                .withExternal()
                .source(States.STATE2).target(States.END_STATE).event(Events.EVENT3);
    }

    @Bean
    public StateMachineListener<States, Events> listener() {
        return new StateMachineListenerAdapter<>() {
            @Override
            public void stateEntered(State<States, Events> state) {
                System.out.printf("State enter %s%n", state.getId());
            }

            @Override
            public void stateChanged(State<States, Events> from, State<States, Events> to) {
                if (from != null) {
                    System.out.printf("State change from %s to %s%n", from.getId(), to.getId());
                } else {
                    System.out.printf("State change to %s%n", to.getId());
                }
            }

            @Override
            public void stateExited(State<States, Events> state) {
                System.out.printf("State exit %s%n", state.getId());
            }

            @Override
            public void transitionStarted(Transition<States, Events> transition) {
                if (transition.getSource() != null && transition.getTarget() != null && transition.getTrigger() != null) {
                    System.out.printf(
                            "Transition start %s -> %s, %s%n",
                            transition.getSource().getId(),
                            transition.getTarget().getId(),
                            transition.getTrigger().getEvent()
                    );
                } else {
                    System.out.printf("Transition start%n");
                }
            }

            @Override
            public void transitionEnded(Transition<States, Events> transition) {
                if (transition.getSource() != null && transition.getTarget() != null && transition.getTrigger() != null) {
                    System.out.printf(
                            "Transition end %s -> %s, %s%n",
                            transition.getSource().getId(),
                            transition.getTarget().getId(),
                            transition.getTrigger().getEvent()
                    );
                } else {
                    System.out.printf("Transition end%n");
                }
            }

            @Override
            public void stateMachineStarted(StateMachine<States, Events> stateMachine) {
                System.out.printf("StateMachine start %s%n", stateMachine.getId());
            }

            @Override
            public void stateMachineStopped(StateMachine<States, Events> stateMachine) {
                System.out.printf("StateMachine stop %s%n", stateMachine.getId());
            }
        };
    }
}

ここは、説明を少し書いていきましょう。Getting Started内にはほとんど説明が書いていないので、必要に応じてドキュメントの他の箇所も
参照していきます。

まず、クラスの定義。@EnableStateMachineアノテーションで、StateMachineを作成することを示します。

@Configuration
@EnableStateMachine
public class StateMachineConfig extends EnumStateMachineConfigurerAdapter<States, Events> {

@EnableStateMachineアノテーションは、@Configurationと合わせて使います。

Annotation which imports @Configurations related to building state machines.

EnableStateMachine (Spring State Machine 3.2.0 API)

Spring Statemachine / Using Spring Statemachine / Statemachine Configuration / Using enable Annotations

最初、mainクラスに付与した@SpringBootApplicationアノテーションと一緒につけておけばいいのかなと思って失敗しました…。

また、EnumStateMachineConfigurerAdapterクラスを継承して、この時にステートとイベントを指定します。

EnumStateMachineConfigurerAdapter (Spring State Machine 3.2.0 API)

EnumStateMachineConfigurerAdapterクラスは、Enumを使ってStateMachineを構築するクラスです。

Enumを使わない場合は、EnumStateMachineConfigurerAdapterクラスの親クラスであるStateMachineConfigurerAdapterクラスを継承して
実装するみたいですね。

StateMachineConfigurerAdapter (Spring State Machine 3.2.0 API)

StateMachineConfigurerAdapterクラスを継承して、ステートとイベントを文字列として構成する例もドキュメントに記載があります。
※Enumに比べて、型安全性が失われるという注意書き付きですが

Spring Statemachine / Using Spring Statemachine / Statemachine Configuration / Configuring States

続いて、StateMachineの設定に移っていきます。

StateMachineの共通設定から。

    @Override
    public void configure(StateMachineConfigurationConfigurer<States, Events> config)
            throws Exception {
        config
                .withConfiguration()
                .autoStartup(true)
                .machineId("my-statemachine")
                .listener(listener());
    }

autoStartupはStateMachineが自動的に開始するかどうかを指定します。デフォルトはfalseで、今回は自動開始します。
machineIdは、StateMachineにIDを設定します。設定しない場合はnullです。

Spring Statemachine / Using Spring Statemachine / Statemachine Configuration / Configuring Common Settings

Spring Statemachine / Using Spring Statemachine / State Machine ID

最後にStateMachineListenerもつけていますが、StateMachineListener自体は後に記載します。

ステートの設定。ここでは、疑似ステートと使用するステートのEnumを指定しています。

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
                .withStates()
                .initial(States.INITIAL_STATE)
                .end(States.END_STATE)
                .states(EnumSet.allOf(States.class));
    }

疑似ステートは、Initial StateやTerminate State(End State)のことです。今回は、Getting StartedのドキュメントにTerminate Stateを
追加しています。

Spring Statemachine / Using Spring Statemachine / Statemachine Configuration / Configuring Pseudo States

どんな種類のステートがあるのかは、Enum(EnumSet#allOf)で指定しているのでした。

Spring Statemachine / Using Spring Statemachine / Statemachine Configuration / Configuring States

遷移(Transition)の設定。

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE).target(States.STATE1).event(Events.EVENT1)
                .and()
                .withExternal()
                .source(States.STATE1).target(States.STATE2).event(Events.EVENT2)
                .and()
                .withExternal()
                .source(States.STATE2).target(States.END_STATE).event(Events.EVENT3);
    }

状態遷移のfrom → toと、関連付けられるイベントを定義する感じですね。

遷移の指定には、external、internal、localの3種類があるようです。その説明は、クラッシュコースに書かれています。

Spring Statemachine / Appendices / Appendix B: State Machine Concepts / A State Machine Crash Course / Transitions

internalは内部遷移と呼ばれ、状態遷移を引き起こさずにアクションを実行する場合に使用します。つまり、withInternalを使用すると
targetの指定ができなくなります。

externalとlocalはたいていの場合では機能的に同じであり、親ステートとサブステートの間で遷移が発生する場合に異なる挙動になるようです。

最後は、リスナーです。ここでは、StateMachineListenerのインスタンスを作成してStateMachineに関連付けています。

    @Bean
    public StateMachineListener<States, Events> listener() {
        return new StateMachineListenerAdapter<>() {
            @Override
            public void stateEntered(State<States, Events> state) {
                System.out.printf("State enter %s%n", state.getId());
            }

            @Override
            public void stateChanged(State<States, Events> from, State<States, Events> to) {
                if (from != null) {
                    System.out.printf("State change from %s to %s%n", from.getId(), to.getId());
                } else {
                    System.out.printf("State change to %s%n", to.getId());
                }
            }

            @Override
            public void stateExited(State<States, Events> state) {
                System.out.printf("State exit %s%n", state.getId());
            }

            @Override
            public void transitionStarted(Transition<States, Events> transition) {
                if (transition.getSource() != null && transition.getTarget() != null && transition.getTrigger() != null) {
                    System.out.printf(
                            "Transition start %s -> %s, %s%n",
                            transition.getSource().getId(),
                            transition.getTarget().getId(),
                            transition.getTrigger().getEvent()
                    );
                } else {
                    System.out.printf("Transition start%n");
                }
            }

            @Override
            public void transitionEnded(Transition<States, Events> transition) {
                if (transition.getSource() != null && transition.getTarget() != null && transition.getTrigger() != null) {
                    System.out.printf(
                            "Transition end %s -> %s, %s%n",
                            transition.getSource().getId(),
                            transition.getTarget().getId(),
                            transition.getTrigger().getEvent()
                    );
                } else {
                    System.out.printf("Transition end%n");
                }
            }

            @Override
            public void stateMachineStarted(StateMachine<States, Events> stateMachine) {
                System.out.printf("StateMachine start %s%n", stateMachine.getId());
            }

            @Override
            public void stateMachineStopped(StateMachine<States, Events> stateMachine) {
                System.out.printf("StateMachine stop %s%n", stateMachine.getId());
            }
        };
    }

リスナーに関するドキュメントはこちら。

Spring Statemachine / Using Spring Statemachine / Listening to State Machine Events

Spring Statemachineでのイベントを扱う方法は、ApplicationContextに対するリスナーとStateMachineEventListenerを使う方法の2つが
あります。

今回はStateMachineEventListenerを使い、StateMachineの開始、停止、遷移やイベントに対するログ出力を行っています。

そして、StateMachineを使うソースコード。

src/main/java/org/littlewings/spring/statemachine/Runner.java

package org.littlewings.spring.statemachine;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Component
public class Runner implements ApplicationRunner {
    StateMachine<States, Events> stateMachine;

    public Runner(StateMachine<States, Events> stateMachine) {
        this.stateMachine = stateMachine;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT1).build()))
                //.subscribe()
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT2).build()))
                //.subscribe()
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT3).build()))
                //.subscribe()
                .blockFirst();

        System.out.printf("StateMachine complete? %b%n", stateMachine.isComplete());
    }
}

StateMachine自体はSpringのBeanとしてインジェクションして

    StateMachine<States, Events> stateMachine;

    public Runner(StateMachine<States, Events> stateMachine) {
        this.stateMachine = stateMachine;
    }

イベントを送り込むことで、ステートを進めていきます。

    @Override
    public void run(ApplicationArguments args) throws Exception {
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT1).build()))
                //.subscribe()
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT2).build()))
                //.subscribe()
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT3).build()))
                //.subscribe()
                .blockFirst();

        System.out.printf("StateMachine complete? %b%n", stateMachine.isComplete());
    }

EVENT1、EVENT2、EVENT3を順次送り込んでいます。

現在はReactorを使ってイベントを送り込むのが良いようなので(Getting Started通りに書いたらDeprecatedでした)、今回はイベントを即時に
連続で送り込む都合上、一応blockFirstで同期呼び出しにしています。
※subscribeでもいいのですが

最後は、StateMachineが完了したかを確認しています。

3.xからはReactorを使うようになったみたいですね。

Spring Statemachine / Using Spring Statemachine / Triggering Transitions / Using EventTrigger

Spring Statemachine / Appendices / Appendix D: Reactor Migration Guide

最後に、mainメソッドを持ったクラスを作成して完了です。

src/main/java/org/littlewings/spring/statemachine/App.java

package org.littlewings.spring.statemachine;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}

動かしてみる

では、作成したアプリケーションを動かしてみましょう。

$ mvn spring-boot:run

この時、作成したStateMachineLintenerが出力するログは、こんな感じになります。
※改行は見やすいように入れました

Transition start
State enter INITIAL_STATE
State change to INITIAL_STATE
StateMachine start my-statemachine
Transition end


Transition start INITIAL_STATE -> STATE1, EVENT1
State exit INITIAL_STATE
State enter STATE1
State change from INITIAL_STATE to STATE1
Transition end INITIAL_STATE -> STATE1, EVENT1


Transition start STATE1 -> STATE2, EVENT2
State exit STATE1
State enter STATE2
State change from STATE1 to STATE2
Transition end STATE1 -> STATE2, EVENT2


Transition start STATE2 -> END_STATE, EVENT3
State exit STATE2
State enter END_STATE
State change from STATE2 to END_STATE


StateMachine stop my-statemachine

また、StateMachineは完了したことになっています。

StateMachine complete? true

この部分は、ステートの設定からendを外すと

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
                .withStates()
                .initial(States.INITIAL_STATE)
                //.end(States.END_STATE)
                .states(EnumSet.allOf(States.class));
    }

結果が変わります。

StateMachine complete? false

とりあえず、こんなところでしょうか。

まとめ

Spring Statemachineを試してみました。

ステートマシン自体に慣れていないのですが、Getting Startedや用語などを見ていてなんとなく雰囲気がわかってきました。

もう少し、追っておきたいかなと思うところがあったりするので、気が向いたら…。