CLOVER🍀

That was when it all began.

Spring Statemachineのチョイス/ジャンクションを試してみる

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

最近Spring Statemachineに関する勉強をちょっとずつしていましたが、そろそろこれでひと区切りにしようかなと思います。

今回は、チョイスとジャンクションを扱ってみます。

チョイス/ジャンクション

チョイスとジャンクションは、「疑似ステート」の一種です。

用語集には以下のように紹介されています。

  • Choice State
    • たとえばイベントのヘッダーやステート変数といったものに基づき、遷移の選択を行える疑似ステート
  • Junction State
    • チョイスによく似た疑似ステートで、チョイスではひとつの入力遷移のみ許可するが、ジャンクションは複数の入力遷移を許可する

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

ジャンクションは複数の入力遷移を〜とありますが、ソースコードを見ていると本当だろうか…?という気はしますが。

クラッシュコースにも説明があります。

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

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

こちらには、ガードを使いif/elseif/else構造を実現するものだ、と書かれています。また、定義とガードの評価結果、いずれかの
遷移が選択されるようにしておかないとステートマシンがデッドロックすることが注意事項として書かれています。

ステートマシン対して、チョイスとジャンクションを定義する説明はこちらに記載があります。

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

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

ここでも、やはりガードとともに使ってif/elseif/else構造を定義できることが解説されています。

そしてジャンクションの説明を見ると、チョイスとジャンクションはコードレベルではほぼ同じ機能であることが書かれています。

チョイスを例にして、ドキュメントからコード例を読み取ってみましょう。

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

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
            .withChoice()
                .source(States.S1)
                .first(States.S2, s2Guard())
                .then(States.S3, s3Guard())
                .last(States.S4);
    }

StateConfigurer#choiceおよびChoiceTransitionConfigurer#sourceで、同じステートを指定する必要があります。

firstthenlastifelse ifelse構造にそれぞれ対応します。firstおよびthenにはガードを組み合わせ、最初にtrueを返したステートに
遷移します。thenは複数回定義できます。firstthenのいずれもtrueになるケースがなかった場合は、lastで指定したステートに
遷移します。

後続の遷移を決める際には、チョイスの結果がどの遷移になってもいいようにステートの遷移を定義する必要があります。遷移の途切れた
箇所があってそこに遷移してしまった場合は、ステートマシンが終わらなくなってしまいます。

といっても、今回は使う時にはそこまで網羅的には定義しませんが。ソースコードがちょっと長くなってしまうので。

では、試していってみましょう。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.4 2022-07-19
OpenJDK Runtime Environment (build 17.0.4+8-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.4+8-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.4, 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-126-generic", arch: "amd64", family: "unix"

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

Spring Bootプロジェクトを作成します。

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

Spring Bootが2.6.7なのは、ドキュメントに記載のバージョンと合わせているからです。

Spring Statemachine / Getting started / Using Maven

プロジェクト内に移動。

$ cd statemachine-choice-junction

自動生成されたソースコードは、今回は削除しておきます。

$ rm src/main/java/org/littlewings/spring/statemachine/StatemachineChoiceJunctionApplication.java src/test/java/org/littlewings/spring/statemachine/StatemachineChoiceJunctionApplicationTests.java

Maven依存関係など。

        <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>

このうち、spring-boot-starterspring-statemachine-starterに変更します。

 <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>

続いて、ソースコードを作成していきます。

ステートを定義したenum

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

package org.littlewings.spring.statemachine;

public enum States {
    INITIAL_STATE,
    STATE1,
    STATE1_1,
    STATE1_2,
    STATE1_3,
    STATE1_4,
    STATE2,
    STATE2_1,
    STATE2_2,
    STATE3,
    STATE3_1,
    STATE3_2,
    STATE3_3,
    STATE3_4,
    END_STATE
}

枝番のようにしているもの(_が入っているもの)は、チョイスやジャンクションで使います。

イベントを定義したenum

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

package org.littlewings.spring.statemachine;

public enum Events {
    EVENT1,
    EVENT2,
    EVENT3,
    EVENT4
}

ステートマシンの定義。

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

package org.littlewings.spring.statemachine;

import java.time.LocalDateTime;
import java.util.EnumSet;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.action.Action;
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.guard.Guard;

@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");
    }

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
                .withStates()
                .initial(States.INITIAL_STATE)
                .choice(States.STATE1)
                .choice(States.STATE2)
                .junction(States.STATE3)
                .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).event(Events.EVENT1).target(States.STATE1)
                .action(loggingAction())
                .and()
                // choice
                .withChoice()
                .source(States.STATE1)
                .first(States.STATE1_1, falseGuard(), loggingAction())
                .then(States.STATE1_2, falseGuard(), loggingAction())
                .then(States.STATE1_3, trueGuard(), loggingAction())
                .last(States.STATE1_4, loggingAction())
                .and()
                // normal
                .withExternal()
                .source(States.STATE1_3).event(Events.EVENT2).target(States.STATE2)
                .action(loggingAction())
                .and()
                // choice
                .withChoice()
                .source(States.STATE2)
                .first(States.STATE2_1, falseGuard(), loggingAction())
                .last(States.STATE2_2, loggingAction())
                .and()
                // normal
                .withExternal()
                .source(States.STATE2_2).event(Events.EVENT3).target(States.STATE3)
                .action(loggingAction())
                .and()
                // junction
                .withJunction()
                .source(States.STATE3)
                .first(States.STATE3_1, falseGuard(), loggingAction())
                .then(States.STATE3_2, falseGuard(), loggingAction())
                .then(States.STATE3_3, trueGuard(), loggingAction())
                .last(States.STATE3_4, loggingAction())
                .and()
                // normal
                .withExternal()
                .source(States.STATE3_3).event(Events.EVENT4).target(States.END_STATE)
                .action(loggingAction());
    }

    @Bean
    public Guard<States, Events> trueGuard() {
        return context -> {
            System.out.printf(
                    "[%s] true guard, stage = %s, source = %s, state = %s, pseudo state = %s, trigger type = %s, event = %s%n",
                    LocalDateTime.now(),
                    context.getStage(),
                    context.getSource().getId(),
                    context.getTarget().getId(),
                    context.getTarget().getPseudoState().getKind(),
                    context.getTransition().getTrigger() != null ? context.getTransition().getTrigger().getClass().getSimpleName() : "[no trigger]",
                    context.getMessage() != null ? context.getMessage().getPayload() : "[none]"
            );

            return true;
        };
    }

    @Bean
    public Guard<States, Events> falseGuard() {
        return context -> {
            System.out.printf(
                    "[%s] false guard, stage = %s, source = %s, state = %s, pseudo state = %s, trigger type = %s, event = %s%n",
                    LocalDateTime.now(),
                    context.getStage(),
                    context.getSource().getId(),
                    context.getTarget().getId(),
                    context.getTarget().getPseudoState().getKind(),
                    context.getTransition().getTrigger() != null ? context.getTransition().getTrigger().getClass().getSimpleName() : "[no trigger]",
                    context.getMessage() != null ? context.getMessage().getPayload() : "[none]"
            );

            return false;
        };
    }

    @Bean
    public Action<States, Events> loggingAction() {
        return stateContext ->
                System.out.printf(
                        "[%s] state action, stage = %s, source = %s, state = %s, pseudo state = %s, trigger type = %s, event = %s%n",
                        LocalDateTime.now(),
                        stateContext.getStage(),
                        stateContext.getSource().getId(),
                        stateContext.getTarget().getId(),
                        stateContext.getTarget().getPseudoState().getKind(),
                        stateContext.getTransition().getTrigger() != null ? stateContext.getTransition().getTrigger().getClass().getSimpleName() : "[no trigger]",
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }
}

チョイスとジャンクションの部分、そしてガードの部分は実行結果と合わせて見ていくことにします。

ステートマシンを使うクラス。

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 {
        System.out.println("send event EVENT1");
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT1).build()))
                .blockFirst();

        System.out.println("send event EVENT2");
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT2).build()))
                .blockFirst();

        System.out.println("send event EVENT3");
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT3).build()))
                .blockFirst();

        System.out.println("send event EVENT4");
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT4).build()))
                .blockFirst();

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

イベントを同期的に送りますが、その前にこれからイベントを送ることをログ出力しています。

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

こんなログが出力されました。

send event EVENT1
[2022-09-30T23:59:50.438003981] state action, stage = TRANSITION, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1
[2022-09-30T23:59:50.439263641] false guard, stage = STATE_CHANGED, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1
[2022-09-30T23:59:50.439656433] false guard, stage = STATE_CHANGED, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1
[2022-09-30T23:59:50.439885672] true guard, stage = STATE_CHANGED, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1
[2022-09-30T23:59:50.440655575] state action, stage = STATE_CHANGED, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1
send event EVENT2
[2022-09-30T23:59:50.448411104] state action, stage = TRANSITION, source = STATE1_3, state = STATE2, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT2
[2022-09-30T23:59:50.448991593] false guard, stage = STATE_CHANGED, source = STATE1_3, state = STATE2, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT2
[2022-09-30T23:59:50.449371375] state action, stage = STATE_CHANGED, source = STATE1_3, state = STATE2, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT2
send event EVENT3
[2022-09-30T23:59:50.451593945] state action, stage = TRANSITION, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3
[2022-09-30T23:59:50.452477335] false guard, stage = STATE_CHANGED, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3
[2022-09-30T23:59:50.452786880] false guard, stage = STATE_CHANGED, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3
[2022-09-30T23:59:50.453079612] true guard, stage = STATE_CHANGED, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3
[2022-09-30T23:59:50.453582227] state action, stage = STATE_CHANGED, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3
send event EVENT4
[2022-09-30T23:59:50.455591432] state action, stage = TRANSITION, source = STATE3_3, state = END_STATE, pseudo state = END, trigger type = EventTrigger, event = EVENT4
StateMachine complete? true

チョイス、ジャンクションともに動いているのですが、このログをソースコードと合わせて説明していきます。

今回作成したステートマシンの定義を確認する

今回作成したステートマシンの定義と、ログを見ながらどのような定義、動作になるかを確認していきます。

まず、チョイスにしてもジャンクションにしても条件分岐になるので、ガードを定義します。

    @Bean
    public Guard<States, Events> trueGuard() {
        return context -> {
            System.out.printf(
                    "[%s] true guard, stage = %s, source = %s, state = %s, pseudo state = %s, trigger type = %s, event = %s%n",
                    LocalDateTime.now(),
                    context.getStage(),
                    context.getSource().getId(),
                    context.getTarget().getId(),
                    context.getTarget().getPseudoState().getKind(),
                    context.getTransition().getTrigger() != null ? context.getTransition().getTrigger().getClass().getSimpleName() : "[no trigger]",
                    context.getMessage() != null ? context.getMessage().getPayload() : "[none]"
            );

            return true;
        };
    }

    @Bean
    public Guard<States, Events> falseGuard() {
        return context -> {
            System.out.printf(
                    "[%s] false guard, stage = %s, source = %s, state = %s, pseudo state = %s, trigger type = %s, event = %s%n",
                    LocalDateTime.now(),
                    context.getStage(),
                    context.getSource().getId(),
                    context.getTarget().getId(),
                    context.getTarget().getPseudoState().getKind(),
                    context.getTransition().getTrigger() != null ? context.getTransition().getTrigger().getClass().getSimpleName() : "[no trigger]",
                    context.getMessage() != null ? context.getMessage().getPayload() : "[none]"
            );

            return false;
        };
    }

今回は、シンプルにtrueまたはfalseを決め打ちで返すガードにしています。

ステートの変化を見るために、ログ出力を行うアクションも定義。

    @Bean
    public Action<States, Events> loggingAction() {
        return stateContext ->
                System.out.printf(
                        "[%s] state action, stage = %s, source = %s, state = %s, pseudo state = %s, trigger type = %s, event = %s%n",
                        LocalDateTime.now(),
                        stateContext.getStage(),
                        stateContext.getSource().getId(),
                        stateContext.getTarget().getId(),
                        stateContext.getTarget().getPseudoState().getKind(),
                        stateContext.getTransition().getTrigger() != null ? stateContext.getTransition().getTrigger().getClass().getSimpleName() : "[no trigger]",
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

まず、StateConfigurer#choiceおよびStateConfigurer#junctionで、遷移を条件分岐するステートを指定します。

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

次に、StateMachineTransitionConfigurerを使用した遷移の定義を行います。

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE).event(Events.EVENT1).target(States.STATE1)
                .action(loggingAction())
                .and()
                // choice
                .withChoice()
                .source(States.STATE1)
                .first(States.STATE1_1, falseGuard(), loggingAction())
                .then(States.STATE1_2, falseGuard(), loggingAction())
                .then(States.STATE1_3, trueGuard(), loggingAction())
                .last(States.STATE1_4, loggingAction())
                .and()
                // normal
                .withExternal()
                .source(States.STATE1_3).event(Events.EVENT2).target(States.STATE2)
                .action(loggingAction())
                .and()
                // choice
                .withChoice()
                .source(States.STATE2)
                .first(States.STATE2_1, falseGuard(), loggingAction())
                .last(States.STATE2_2, loggingAction())
                .and()
                // normal
                .withExternal()
                .source(States.STATE2_2).event(Events.EVENT3).target(States.STATE3)
                .action(loggingAction())
                .and()
                // junction
                .withJunction()
                .source(States.STATE3)
                .first(States.STATE3_1, falseGuard(), loggingAction())
                .then(States.STATE3_2, falseGuard(), loggingAction())
                .then(States.STATE3_3, trueGuard(), loggingAction())
                .last(States.STATE3_4, loggingAction())
                .and()
                // normal
                .withExternal()
                .source(States.STATE3_3).event(Events.EVENT4).target(States.END_STATE)
                .action(loggingAction());
    }

この部分は、ふつうの遷移の定義ですね。EVENT1をトリガーにして、INITIAL_STATEからSTATE1に遷移します。

                .withExternal()
                .source(States.INITIAL_STATE).event(Events.EVENT1).target(States.STATE1)
                .action(loggingAction())
                .and()

イベントを送り込んだら、すぐに遷移が行われます。

        System.out.println("send event EVENT1");

そして、STATE1に遷移したらこちらをソースにしてfirstthenthenlastの条件分岐を定義しています。
ifelse ifelse ifelseが並んでいる状態ですね。

                // choice
                .withChoice()
                .source(States.STATE1)
                .first(States.STATE1_1, falseGuard(), loggingAction())
                .then(States.STATE1_2, falseGuard(), loggingAction())
                .then(States.STATE1_3, trueGuard(), loggingAction())
                .last(States.STATE1_4, loggingAction())
                .and()

定義の開始がStateMachineTransitionConfigurer#withChoiceになっているところがポイントです。

今回はfirstから見て、falsefalsetrueとなっているので、この分岐はSTATE1_3に遷移します。

ログを見ると、イベントを送り込んだ後にすぐさまチョイスの遷移に移っています。
※通常の遷移の部分とチョイスの部分がわかりやすいように、改行を入れています

send event EVENT1
[2022-09-30T23:59:50.438003981] state action, stage = TRANSITION, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1


[2022-09-30T23:59:50.439263641] false guard, stage = STATE_CHANGED, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1
[2022-09-30T23:59:50.439656433] false guard, stage = STATE_CHANGED, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1
[2022-09-30T23:59:50.439885672] true guard, stage = STATE_CHANGED, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1
[2022-09-30T23:59:50.440655575] state action, stage = STATE_CHANGED, source = INITIAL_STATE, state = STATE1, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT1

この部分が連続で動いている感じですね。

                .withExternal()
                .source(States.INITIAL_STATE).event(Events.EVENT1).target(States.STATE1)
                .action(loggingAction())
                .and()
                // choice
                .withChoice()
                .source(States.STATE1)
                .first(States.STATE1_1, falseGuard(), loggingAction())
                .then(States.STATE1_2, falseGuard(), loggingAction())
                .then(States.STATE1_3, trueGuard(), loggingAction())
                .last(States.STATE1_4, loggingAction())
                .and()

ガードの評価は、trueを返したところで停止するようです。

完全にif文的な動きですね。

再掲しますが、StateMachineTransitionConfigurer#withChoiceに続く`ChoiceTransitionConfigurer#sourceに指定したステートは、
StateMachineStateConfigurer#choiceでも指定しておく必要があることに注意です。

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

これはチョイスでもジャンクションでも同じです。以降はあらためてこの対称性を説明しません。

次はふつうの遷移の定義。EVENT2をトリガーにして、STATE1_3からSTATE2に遷移します。

                // normal
                .withExternal()
                .source(States.STATE1_3).event(Events.EVENT2).target(States.STATE2)
                .action(loggingAction())
                .and()

ひとつ前の遷移の定義が、STATE1_3になっていることを前提にしています。

なお、今回はステートSTATE1_3からの遷移しか定義していませんが、その前のチョイスに定義されているfirstthenlastの評価結果、
STATE1_3以外のステートに遷移した場合は対応する遷移の定義がないためステートマシンが終了しなくなります。

本来は分岐先のステートのパターンに対して、網羅的に遷移を定義する必要があることに注意しましょう。

続いてのチョイスの定義。今回はfirstlastのみにしました。ifelseだけが定義されている状態です。

                // choice
                .withChoice()
                .source(States.STATE2)
                .first(States.STATE2_1, falseGuard(), loggingAction())
                .last(States.STATE2_2, loggingAction())
                .and()

firstfalseを返すガードを指定しているので、lastが有効になりSTATE2_2に遷移することになります。

そして、このような動作に。

send event EVENT2
[2022-09-30T23:59:50.448411104] state action, stage = TRANSITION, source = STATE1_3, state = STATE2, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT2


[2022-09-30T23:59:50.448991593] false guard, stage = STATE_CHANGED, source = STATE1_3, state = STATE2, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT2
[2022-09-30T23:59:50.449371375] state action, stage = STATE_CHANGED, source = STATE1_3, state = STATE2, pseudo state = CHOICE, trigger type = EventTrigger, event = EVENT2

最初のチョイスの結果、STATE1_3に遷移していることがここで確認できます。そしてガードが1回評価され、ステートが遷移しています。

次の遷移の定義。EVENT3をトリガーにして、STATE2_2からSTATE3に遷移します。

                // normal
                .withExternal()
                .source(States.STATE2_2).event(Events.EVENT3).target(States.STATE3)
                .action(loggingAction())
                .and()

その次はジャンクションにします。StateMachineTransitionConfigurer#withJunctionからJunctionTransitionConfigurer#sourceを指定して、
firstthenlastを定義。

ここでは、thenを2回使ってifelse ifelse ifelseの構造にしました。最初のチョイスと同じですね。

                // junction
                .withJunction()
                .source(States.STATE3)
                .first(States.STATE3_1, falseGuard(), loggingAction())
                .then(States.STATE3_2, falseGuard(), loggingAction())
                .then(States.STATE3_3, trueGuard(), loggingAction())
                .last(States.STATE3_4, loggingAction())
                .and()

ガードの結果がtrueを返す、STATE3_3が次の遷移先ステートです。

結果。

send event EVENT3
[2022-09-30T23:59:50.451593945] state action, stage = TRANSITION, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3


[2022-09-30T23:59:50.452477335] false guard, stage = STATE_CHANGED, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3
[2022-09-30T23:59:50.452786880] false guard, stage = STATE_CHANGED, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3
[2022-09-30T23:59:50.453079612] true guard, stage = STATE_CHANGED, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3
[2022-09-30T23:59:50.453582227] state action, stage = STATE_CHANGED, source = STATE2_2, state = STATE3, pseudo state = JUNCTION, trigger type = EventTrigger, event = EVENT3

チョイスと同じように、ガードがtrueになるまで評価されていることがわかります。

最後は、通常の遷移で終了です。

                // normal
                .withExternal()
                .source(States.STATE3_3).event(Events.EVENT4).target(States.END_STATE)
                .action(loggingAction());

ログはこちら。

send event EVENT4
[2022-09-30T23:59:50.455591432] state action, stage = TRANSITION, source = STATE3_3, state = END_STATE, pseudo state = END, trigger type = EventTrigger, event = EVENT4

こう見ていると、動きがわかりますね。

チョイスとジャンクションの違いは?

ところで、チョイスとジャンクションはどう違うのか、使い分けのようなところがわからなかったので、少し見てみました。
ドキュメントに記載されているサンプルも少ないので。

テストコードでは差がわかりませんでした。

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/ChoiceStateTests.java

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/JunctionStateTests.java

チョイスとジャンクションの実装であるChoicePseudoStateJunctionPseudoStateを見ていると、ほぼ差がありません。

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/main/java/org/springframework/statemachine/state/ChoicePseudoState.java

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/main/java/org/springframework/statemachine/state/JunctionPseudoState.java

呼び出し方も同じです。

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/main/java/org/springframework/statemachine/support/AbstractStateMachine.java

こう見ていると、チョイスとジャンクションには違いがなさそうですね。ドキュメントにはチョイスとジャンクションはコードレベルでは
ほぼ同じ機能であると書かれていましたが、確かにそのとおりのようです。

まとめ

Spring Statemachineのチョイスとジャンクションを試してみました。

遷移にifelse ifelseを持ち込め、その条件はガードで定義できるので便利ですね。

最初はドキュメントを読んでいても、ソースコードを書いてみてもピンとこなかったのですが、動かしてみてやっと感覚がつながったので
やっぱり動かして確認するのは大事だなぁと思いました…。