CLOVER🍀

That was when it all began.

Spring Statemachineのタイマートリガーを使ってみる

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

Spring Statemachineのトリガーには、イベントとタイマーの2種類があります。

これまでSpring Statemachineを使ってきた時にはイベントトリガーしか扱ってこなかったので、タイマートリガーを試してみたいなと思います。

トリガー

そもそもトリガーとは?というところですが、クラッシュコースに載っています。

A trigger begins a transition. Triggers can be driven by either events or timers.

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

遷移を開始させるもので、イベントとトリガーの2種類があります。

イベントトリガーは、こんな感じでStateMachine#sendEventで送り込むことで起動します。

   stateMachine
        .sendEvent(Mono.just(MessageBuilder
            .withPayload("E1").build()))
        .subscribe();

イベントをステートマシンに送信するトリガーですね。

EventTrigger is the most useful trigger, because it lets you directly interact with a state machine by sending events to it.

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

イベントトリガーの定義は、以下のようにTransitionConfigurer#eventで設定します。

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
            .withExternal()
                .source(States.SI).target(States.S1).event(Events.E1)
                .and()
            .withExternal()
                .source(States.S1).target(States.S2).event(Events.E2);
    }

triggerとは出てこないので、あんまり「トリガー」という印象になりにくい感じがしますね。

タイマートリガーは、ユーザーの操作なしで自動的にトリガーが起動する必要がある場合に便利なものだそうです。

TimerTrigger is useful when something needs to be triggered automatically without any user interaction.

Spring Statemachine / Using Spring Statemachine / Triggering Transitions / Using TimerTrigger

タイマートリガーは2種類で、継続的に起動するものと1度だけ起動するものがあります。

それぞれ、TransitionConfigurer#timerおよびTransitionConfigurer#timerOnceで設定します。

       transitions
            .withExternal()
                .source("S1").target("S2").event("E1")
                .and()
            .withExternal()
                .source("S1").target("S3").event("E2")
                .and()
            .withInternal()
                .source("S2")
                .action(timerAction())
                .timer(1000)
                .and()
            .withInternal()
                .source("S3")
                .action(timerAction())
                .timerOnce(1000);

では、実際に使っていってみましょう。

環境

今回の環境はこちら。

$ 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-125-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-timer-triger \
  -d groupId=org.littlewings \
  -d artifactId=statemachine-timer-triger \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.statemachine \
  -d baseDir=statemachine-timer-triger | tar zxvf -

Spring Bootが2.6.7なのは、ドキュメントに習っています。

Spring Statemachine / Getting started / Using Maven

プロジェクト内に移動。

$ cd statemachine-timer-triger

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

$ rm src/main/java/org/littlewings/spring/statemachine/StatemachineTimerTrigerApplication.java src/test/java/org/littlewings/spring/statemachine/StatemachineTimerTrigerApplicationTests.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-starterをspring-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,
    STATE2,
    END_STATE
}

イベントを定義したenum。

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

package org.littlewings.spring.statemachine;

public enum Events {
    EVENT1,
    EVENT2,
    EVENT3
}

ステートマシンの定義。

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

package org.littlewings.spring.statemachine;

import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;

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.trigger.TimerTrigger;

@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)
                .state(States.STATE1)
                .state(States.STATE2)
                .end(States.END_STATE);
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE).target(States.STATE1)
                .timer(TimeUnit.SECONDS.toMillis(2L))
                .action(loggingAction())
                .and()
                .withExternal()
                .source(States.STATE1).target(States.STATE2)
                .timerOnce(TimeUnit.SECONDS.toMillis(4L))
                .action(loggingAction())
                .and()
                .withExternal()
                .source(States.STATE2).target(States.END_STATE)
                .timer(TimeUnit.SECONDS.toMillis(6L))
                .action(loggingAction());
    }

    @Bean
    public Action<States, Events> loggingAction() {
        return stateContext ->
                System.out.printf(
                        "[%s] state action, stage = %s, state = %s, is timer trigger = %b, event = %s%n",
                        LocalDateTime.now(),
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getTransition().getTrigger() instanceof TimerTrigger,
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }
}

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

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

package org.littlewings.spring.statemachine;

import java.util.concurrent.TimeUnit;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;

@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 {
        TimeUnit.SECONDS.sleep(15L);

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

アプリケーションを実行する前に、ステートマシンの設定を見返してみます。タイマートリガーの部分ですね。

TransitionConfigurer#timer、TransitionConfigurer#timerOnce、TransitionConfigurer#timerの順で設定しています。

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE).target(States.STATE1)
                .timer(TimeUnit.SECONDS.toMillis(2L))
                .action(loggingAction())
                .and()
                .withExternal()
                .source(States.STATE1).target(States.STATE2)
                .timerOnce(TimeUnit.SECONDS.toMillis(4L))
                .action(loggingAction())
                .and()
                .withExternal()
                .source(States.STATE2).target(States.END_STATE)
                .timer(TimeUnit.SECONDS.toMillis(6L))
                .action(loggingAction());
    }

2秒後 → 4秒後 → 6秒後で起動するように仕掛けています。

では、起動してみましょう。

$ mvn spring-boot:run

結果。

2022-09-14 23:59:23.477  INFO 19904 --- [           main] org.littlewings.spring.statemachine.App  : Started App in 1.183 seconds (JVM running for 1.615)
[2022-09-14T23:59:25.342022008] state action, stage = TRANSITION, state = STATE1, is timer trigger = true, event = [none]
[2022-09-14T23:59:29.348587936] state action, stage = TRANSITION, state = STATE2, is timer trigger = true, event = [none]
[2022-09-14T23:59:35.335503356] state action, stage = TRANSITION, state = END_STATE, is timer trigger = true, event = [none]
StateMachine complete? true

イベントは発生せず、トリガーはTimerTriggerのインスタンスで行われていることが確認できました。

ステートマシンもしっかり終了しています。

timeとtimerOnceの違い

ところで、timerとtimerOnceの違いが確認できていません。

ソースコードで確認することにします。

遷移のうちexternalの定義を見てみます。

   @Override
    public ExternalTransitionConfigurer<S, E> timer(long period) {
        setPeriod(period);
        return this;
    }

    @Override
    public ExternalTransitionConfigurer<S, E> timerOnce(long period) {
        setPeriod(period);
        setCount(1);
        return this;
    }

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/main/java/org/springframework/statemachine/config/configurers/DefaultExternalTransitionConfigurer.java#L74-L85

両者の差は、countの指定有無になっています。

そして、TimerTriggerに以下のように反映されます。

   private void schedule() {
        long initialDelay = count > 0 ? period : 0;
        Flux<Long> interval = Flux.interval(Duration.ofMillis(initialDelay), Duration.ofMillis(period))
            .doOnNext(c -> {
                notifyTriggered();
            });
        if (count > 0) {
            interval = interval.take(count);
        }
        disposable = interval.subscribe();
    }

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/main/java/org/springframework/statemachine/trigger/TimerTrigger.java#L118-L128

というわけで、こんな感じのコードを書いてみました。

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

package org.littlewings.spring.statemachine;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;

@Component
public class TimerTriggerEmulator implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) throws Exception {
        long period = 3000L;

        // timerOnce
        System.out.printf("[%s] timer trigger once start.%n", LocalDateTime.now());

        Flux<Long> timerOnceTriger = Flux.interval(Duration.ofMillis(period), Duration.ofMillis(period))
                .doOnNext(c -> System.out.printf("[%s] timer trigger once execute.%n", LocalDateTime.now()));

        timerOnceTriger = timerOnceTriger.take(1);

        timerOnceTriger.subscribe();

        // timer
        System.out.printf("[%s] timer trigger start.%n", LocalDateTime.now());

        Flux<Long> timerTriger = Flux.interval(Duration.ofMillis(0), Duration.ofMillis(period))
                .doOnNext(c -> System.out.printf("[%s] timer trigger execute.%n", LocalDateTime.now()));

        timerTriger.subscribe();

        TimeUnit.SECONDS.sleep(15L);
    }
}

3秒おきに実行、timeOnce、timeの順で似せて定義。

実行すると、以下のようにtimerOnceは1回、timerは継続で実行されます。

[2022-09-15T00:22:33.947264112] timer trigger once start.
[2022-09-15T00:22:34.000809450] timer trigger start.
[2022-09-15T00:22:34.001413086] timer trigger execute.
[2022-09-15T00:22:37.000953420] timer trigger once execute.
[2022-09-15T00:22:37.001310650] timer trigger execute.
[2022-09-15T00:22:40.001455757] timer trigger execute.
[2022-09-15T00:22:43.001376982] timer trigger execute.
[2022-09-15T00:22:46.001450416] timer trigger execute.
[2022-09-15T00:22:49.001499904] timer trigger execute.

なのですが、timerの方はすぐに起動してしまっているので、この処理以外になにか細工があるんでしょうね…。
まあ、そこまでは追わないことにします。

が、timerはどう使うと効果的なんでしょうか…?1回のタイマートリガーの起動で、ステートが遷移しない可能性がある場合、とかに
使うんでしょうか。エラー発生時の考慮とかもですかね。

まとめ

Spring Statemachineのタイマートリガーを試してみました。

遷移を起こすトリガーの種類を確認しようと思って見てみたのですが、あっさり使えましたね。

timerとtimerOnceの使い分けがちょっと気になりますが、他の要素を勉強していくとわかるかもしれません。