これは、なにをしたくて書いたもの?
少し、ステートマシンについて調べる機会がありまして。
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)
最初、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 / 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を
追加しています。
どんな種類のステートがあるのかは、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種類があるようです。その説明は、クラッシュコースに書かれています。
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や用語などを見ていてなんとなく雰囲気がわかってきました。
もう少し、追っておきたいかなと思うところがあったりするので、気が向いたら…。