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を持ち込め、その条件はガードで定義できるので便利ですね。

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

Java 17で文字を数える

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

前に、Java 11でこんなエントリーを書きました。

Java 11で文字を数える - CLOVER🍀

この時の主なテーマはBreakIteratorだったのですが、BreakIteratorではうまく扱えない絵文字があることがわかりまして。

その後、Java 13以降であればUnicode拡張書記素クラスタ境界(正規表現)を使うと扱えることがわかり、今回こちらを試してみることに
しました。

BreakIteratorでうまく扱えない絵文字

たとえば、以下のようなものです。

絵文字 数値文字参照
👆🏼 &#x1f446;&#x1f3fc;
🇦🇿 &#x1f1e6;&#x1f1ff;
🏴󠁧󠁢󠁥󠁮󠁧󠁿 &#x1f3f4;&#xe0067;&#xe0062;&#xe0065;&#xe006e;&#xe0067;&#xe007f;
👨‍👩‍👧‍👦 &#x1f468;&#x200d;&#x1f469;&#x200d;&#x1f467;&#x200d;&#x1f466;

複数の絵文字・文字で、ひとつの絵文字を形成するような文字です。

JavaUnicodeのバージョン

JavaがサポートしているUnicodeのバージョンですが、CharacterJavadocに書いてありました。

Character (Java SE 17 & JDK 17)

Java 17の時点でUnicode 13.0をサポートしています。

現在のUnicodeは15.0です。

Unicode® 15.0.0

この中に、Emoji 15.0が含まれていることが書かれています。

20 emoji characters, including hair pick, maracas, jellyfish, khanda, and pink heart. For complete statistics regarding all emoji as of Unicode 15.0, see Emoji Counts. For more information about emoji additions in version 15.0, including new emoji ZWJ sequences and emoji modifier sequences, see Emoji Recently Added, v15.0.

Emoji Recently Added, v15.0

Unicodeの中に、絵文字がバージョン入りで含まれているようです。

Unicode 13.0を見てみましょう。

Unicode 13.0.0

こちらでは、Emoji 13.0での追加絵文字が書かれています。

55 emoji characters. For complete statistics regarding all emoji as of Unicode 13.0, see Emoji Counts. For more information about emoji additions in version 13.0, including new emoji ZWJ sequences and emoji modifier sequences, see Emoji Recently Added, v13.0.

Emoji Recently Added, v13.0

絵文字

現時点での絵文字に関する情報、そして一覧はこちら。

Unicode Emoji v15.1

Emoji List, v15.1

Emoji 13.0の場合。

Unicode Emoji v13.0

Emoji List, v13.0

ですが、ちゃんとしたリストを見ようと思うと、こちらを参照した方が良さそうです。

Index of /Public/emoji

たとえば、Emoji 13.0の場合。

https://unicode.org/Public/emoji/13.0/emoji-sequences.txt

https://unicode.org/Public/emoji/13.0/emoji-zwj-sequences.txt

絵文字と構造

絵文字とその構造は、以下のドキュメントに記載されています。

UTS #51: Unicode Emoji

絵文字は、シーケンスというもので分類されるようです。

Unicode Emoji / Introduction / Definitions / Emoji Sequences

シーケンスには以下があります。

  • emoji core sequence
    • emoji character
    • emoji keycap sequence
    • emoji modifier sequence
    • emoji flag sequence
  • emoji zwj sequence
  • emoji tag sequence

先ほど、emoji-sequences.txtemoji-zwj-sequences.txtの2つのファイルを紹介しましたが、どのファイルにどのシーケンスが
含まれているかは、以下に記載があります。

Unicode Emoji / Introduction / Definitions / Emoji Sets

また以下は最新のEmojiバージョンで追加された絵文字ですが、どのシーケンスに分類されるかが記載されています。

Emoji Sequences, v15.1

複数の絵文字を組み合わせる話は、以下あたりに記載があります。

ZWJというのは、ゼロ幅接合子(Zero Width Jointer)のことで、コードポイントではU+200Dを指します。

ゼロ幅接合子 - Wikipedia

こちらを使って、複数の絵文字を接合して表示する仕組みになっています。

今回の絵文字では、 👨‍👩‍👧‍👦 (&#x1f468;&#x200d;&#x1f469;&#x200d;&#x1f467;&#x200d;&#x1f466;)で使われています。

大人2人と子ども2人の絵文字を使って、家族の絵文字を形成しています。

Unicode拡張書記素クラスタ境界

前置きが長くなりましたが、BreakIteratorでうまく扱えない文字で紹介した以下のような絵文字を分割するには
正規表現Unicode拡張書記素クラスタ境界(\b{g})」を使います。

絵文字 数値文字参照
👆🏼 &#x1f446;&#x1f3fc;
🇦🇿 &#x1f1e6;&#x1f1ff;
🏴󠁧󠁢󠁥󠁮󠁧󠁿 &#x1f3f4;&#xe0067;&#xe0062;&#xe0065;&#xe006e;&#xe0067;&#xe007f;
👨‍👩‍👧‍👦 &#x1f468;&#x200d;&#x1f469;&#x200d;&#x1f467;&#x200d;&#x1f466;

なお、Java 13以降を使う必要があります。

PatternクラスのJavadocに書かれていますね。

Unicode拡張書記素クラスタ」は、書記素クラスタ・マッチャ\Xと対応する境界マッチャ\b{g}によってサポートされています。

Pattern (Java SE 17 & JDK 17)

なお、Unicode拡張書記素クラスタ自体はJava 9で導入されていました。

[JDK-7071819] To support Extended Grapheme Clusters in Regex - Java Bug System

Java 9のPatternクラスの時点で、すでに記載があります。

Pattern (Java SE 9 & JDK 9 )

Java 8の時点ではありませんでした。

Pattern (Java Platform SE 8)

Unicode拡張書記素クラスタについては、以下に記載があります。

ひとつまたはそれ以上のUnicode文字が、人が1文字として捉えるものを構成することがあります。文字というものに対して、コンピューター上での
曖昧さを避けるためのもので、これを「書記素クラスタ」と呼びます。

One or more Unicode characters may make up what the user thinks of as a character. To avoid ambiguity with the computer use of the term character, this is called a grapheme cluster.

Unicode regular expressions / Extended Unicode Support: Level 2 / Extended Grapheme Clusters and Character Classes with Strings

上記ドキュメントは、正規表現エンジンをUnicodeに適合させるためのガイドラインです。

いきなり「拡張(Extended)」とあるので、そうではないものがあるのでは?と思ったのですが、以前の書記素クラスタは「Legacy」と
呼ばれ、下位互換性のために残されているようです。

The Unicode Standard provides default algorithms for determining grapheme cluster boundaries, with two variants: legacy grapheme clusters and extended grapheme clusters. The most appropriate variant depends on the language and operation involved. However, the extended grapheme cluster boundaries are recommended for general processing, while the legacy grapheme cluster boundaries are maintained primarily for backwards compatibility with earlier versions of this specification.

Unicode Text Segmentation / Grapheme Cluster Boundaries

上記のドキュメントは、Unicodeテキストのセグメンテーションを決めるためのガイドラインです。その中に書記素クラスタについて
触れられている箇所があります。

サポートするUnicode拡張書記素クラスタのアップグレード

今回挙げた絵文字を扱うためにはJava 13以降を使う必要がある、と書いたのは以下のリリースノートに関連します。

JDK 13 Release Notes

Java 13でUnicode 12.1をサポートしましたが、

Bug ID: JDK-8221431 Update Unicode Data Files to 12.1.0

この時にUnicode拡張書記素クラスタのアップグレードを行っています。

[JDK-8222978] Upgrade the extended grapheme cluster support to the latest Unicode level. - Java Bug System

一連の修正コミット。

8221431: Support for Unicode 12.1 · openjdk/jdk17u@93fabcd · GitHub

この対応がポイントです。

その関連issueとして、Unicode拡張書記素クラスタ境界で複数の絵文字から成る「フラグ(旗)」の絵文字が、うまく扱えなかったという
事象が報告されていました。

[JDK-8209777] \b{g} in regexes fails to break between flag emoji - Java Bug System

今回は、Java 17でUnicode拡張書記素クラスタ境界を使って文字を分割してみたいと思います。

なお、動作確認時点でのPatternGraphemeUnicode拡張書記素クラスタに関するクラス)のソースコードは、こちら。

https://github.com/openjdk/jdk17u/blob/jdk-17.0.4+8/src/java.base/share/classes/java/util/regex/Pattern.java

https://github.com/openjdk/jdk17u/blob/jdk-17.0.4+8/src/java.base/share/classes/java/util/regex/Grapheme.java

環境

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

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

準備

Maven依存関係やプラグインの設定。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.23.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

確認は、前エントリーと同様にテストコードで行うことにします。

テストコードの雛形。

src/test/java/CountCharTest.java

import java.text.BreakIterator;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class CountCharTest {

    // ここに、テストを書く
}

お題

今回は、BMPの範囲の文字、サロゲートペア、異体字セレクタを使った文字、絵文字、複数の絵文字からなる絵文字をそれぞれ数えてみます。

対象は以下にしました。前回のエントリーでの確認内容も入れています。

    @Test
    public void print() {
        String bmpString = "羽";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        System.out.printf("BMP example: %s%n", bmpString);
        System.out.printf("SurrogatePair example: %s%n", surrogatePairString);
        System.out.printf("VariationSelector example: %s%n", variationSelectorString);
        System.out.printf("Emoji example: %s%n", emojiString);
        System.out.printf("Combined Emoji2 example: %s%n", combinedEmojiString1);
        System.out.printf("Combined Emoji2 example: %s%n", combinedEmojiString2);
        System.out.printf("Combined Emoji3 example: %s%n", combinedEmojiString3);
        System.out.printf("Combined Emoji4 example: %s%n", combinedEmojiString4);
    }

標準出力で確認するとこのようになりました。
※この箇所だけ、家族の絵文字がうまく貼れませんでした…

BMP example: 羽
SurrogatePair example: 𩸽
VariationSelector example: 飴󠄁
Emoji example: 🍀
Combined Emoji2 example: 👆🏼
Combined Emoji2 example: 🇦🇿
Combined Emoji3 example: 🏴󠁧󠁢󠁥
Combined Emoji4 example: 👨<200d>👩<200d>👧<200d>👦

Unicodeコードポイントで見ると、このようになります。

    @Test
    public void unicodeCodePoints() {
        String bmpString = "羽";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        assertThat(bmpString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x7fbd");
        assertThat(surrogatePairString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x29e3d");
        assertThat(variationSelectorString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x98f4", "0xe0101");
        assertThat(emojiString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f340");
        assertThat(combinedEmojiString1.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f446", "0x1f3fc");
        assertThat(combinedEmojiString2.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f1e6", "0x1f1ff");
        assertThat(combinedEmojiString3.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f3f4", "0xe0067", "0xe0062", "0xe0065", "0xe006e", "0xe0067", "0xe007f");
        assertThat(combinedEmojiString4.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList()))
                .containsExactly("0x1f468", "0x200d", "0x1f469", "0x200d", "0x1f467", "0x200d", "0x1f466");
    }

Unicode拡張書記素クラスタ境界を使う

では、Unicode拡張書記素クラスタ境界(\b{g})を使ってみます。

    @Test
    public void extendedGraphemeClusterRegex() {
        String bmpString = "羽";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        assertThat(bmpString.split("\\b{g}").length).isEqualTo(1);
        assertThat(surrogatePairString.split("\\b{g}").length).isEqualTo(1);
        assertThat(variationSelectorString.split("\\b{g}").length).isEqualTo(1);
        assertThat(emojiString.split("\\b{g}").length).isEqualTo(1);
        assertThat(combinedEmojiString1.split("\\b{g}").length).isEqualTo(1);
        assertThat(combinedEmojiString2.split("\\b{g}").length).isEqualTo(1);
        assertThat(combinedEmojiString3.split("\\b{g}").length).isEqualTo(1);
        assertThat(combinedEmojiString4.split("\\b{g}").length).isEqualTo(1);
    }

すべて1文字になりました。

BreakIterator

BreakIteratorでも確認してみましょう。

    @Test
    public void breakIterator() {
        String bmpString = "羽";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        Function<String, Integer> counter = w -> {
            BreakIterator iterator = BreakIterator.getCharacterInstance();
            iterator.setText(w);

            int count = 0;
            while (iterator.next() != BreakIterator.DONE) {
                count++;
            }

            return count;
        };

        assertThat(counter.apply(bmpString)).isEqualTo(1);
        assertThat(counter.apply(surrogatePairString)).isEqualTo(1);
        assertThat(counter.apply(variationSelectorString)).isEqualTo(1);
        assertThat(counter.apply(emojiString)).isEqualTo(1);
        assertThat(counter.apply(combinedEmojiString1)).isEqualTo(2);
        assertThat(counter.apply(combinedEmojiString2)).isEqualTo(2);
        assertThat(counter.apply(combinedEmojiString3)).isEqualTo(7);
        assertThat(counter.apply(combinedEmojiString4)).isEqualTo(7);
    }

こちらは、複数の絵文字から構成される絵文字が軒並みうまく扱えていません。

というわけで、Java 13以降であればUnicode拡張書記素クラスタ境界(\b{g})を使った方が良さそうです。

Java 11だとどうなるのか

ところで、Java 11だとUnicode拡張書記素クラスタ境界(\b{g})がどういう結果になるのか試してみることにしました。

素直にテストコードを書くと失敗したところで止まってしまうので、こんなコードを用意。

こちらは、文字数を表示します。

    @Test
    public void printExtendedGraphemeClusterRegexLength() {
        String bmpString = "羽";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        System.out.printf("%s: splitted length = %d%n", bmpString, bmpString.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", surrogatePairString, surrogatePairString.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", variationSelectorString, variationSelectorString.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", emojiString, emojiString.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", combinedEmojiString1, combinedEmojiString1.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", combinedEmojiString2, combinedEmojiString2.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", combinedEmojiString3, combinedEmojiString3.split("\\b{g}").length);
        System.out.printf("%s: splitted length = %d%n", combinedEmojiString4, combinedEmojiString4.split("\\b{g}").length);
    }

こちらは、ここまでに登場した全文字をつなげて、どのように分割されるのかを表示します。

    @Test
    public void splitCharsUsingExtendedGraphemeClusterRegex() {
        String string = "羽\uD867\uDE3D\uDB40\uDD01🍀\uD83D\uDC46\uD83C\uDFFC\uD83C\uDDE6\uD83C\uDDFF\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";

        for (String c : string.split("\\b{g}")) {
            int start = string.indexOf(c);
            int end = start + c.length();
            System.out.printf("extended grapheme cluster regex: char(%d, %d) = %s%n", start, end, c);
        }
    }

それぞれ、結果はこうなりました。

羽: splitted length = 1
𩸽: splitted length = 1
飴󠄁: splitted length = 1
🍀: splitted length = 1
👆🏼: splitted length = 1
🇦🇿: splitted length = 1
🏴󠁧󠁢󠁥: splitted length = 1
👨‍👩‍👧‍👦: splitted length = 1


extended grapheme cluster regex: char(0, 1) = 羽
extended grapheme cluster regex: char(1, 3) = 𩸽
extended grapheme cluster regex: char(3, 6) = 飴󠄁
extended grapheme cluster regex: char(6, 8) = 🍀
extended grapheme cluster regex: char(8, 12) = 👆🏼
extended grapheme cluster regex: char(12, 16) = 🇦🇿
extended grapheme cluster regex: char(16, 30) = 🏴󠁧󠁢󠁥
extended grapheme cluster regex: char(30, 41) = 👨‍👩‍👧‍👦

ここで、Java 11に切り替え、

$ java --version
openjdk 11.0.16 2022-07-19
OpenJDK Runtime Environment (build 11.0.16+8-post-Ubuntu-0ubuntu120.04)
OpenJDK 64-Bit Server VM (build 11.0.16+8-post-Ubuntu-0ubuntu120.04, mixed mode, sharing)

pom.xmlでのコンパイルオプションも変更して再度実行してみます。

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

すると、確かにおかしな結果になりました。

羽: splitted length = 1
𩸽: splitted length = 1
飴󠄁: splitted length = 1
🍀: splitted length = 1
👆🏼: splitted length = 2
🇦🇿: splitted length = 1
🏴󠁧󠁢󠁥: splitted length = 7
👨‍👩‍👧‍👦: splitted length = 4


extended grapheme cluster regex: char(0, 1) = 羽
extended grapheme cluster regex: char(1, 3) = 𩸽
extended grapheme cluster regex: char(3, 6) = 飴󠄁
extended grapheme cluster regex: char(6, 8) = 🍀
extended grapheme cluster regex: char(8, 10) = 👆
extended grapheme cluster regex: char(10, 12) = 🏼
extended grapheme cluster regex: char(12, 16) = 🇦🇿
extended grapheme cluster regex: char(16, 18) = 🏴
extended grapheme cluster regex: char(18, 20) = 󠁧
extended grapheme cluster regex: char(20, 22) = 󠁢
extended grapheme cluster regex: char(22, 24) = 󠁥
extended grapheme cluster regex: char(24, 26) = 󠁮
extended grapheme cluster regex: char(18, 20) = 󠁧
extended grapheme cluster regex: char(28, 30) = 󠁿
extended grapheme cluster regex: char(30, 33) = 👨‍
extended grapheme cluster regex: char(33, 36) = 👩‍
extended grapheme cluster regex: char(36, 39) = 👧‍
extended grapheme cluster regex: char(39, 41) = 👦

確かに、以前のバージョンではうまく機能しないようですね。

その他

ここから先は、Javaのバージョンで結果が変わらない方法で見ていきます。内容的には、以前のエントリーで書いたものと同じですが。

String#length

今回のお題だと、String#lengthでは文字数をうまく数えられないものばかりになります。

    @Test
    public void stringLength() {
        String bmpString = "羽";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        assertThat(bmpString.length()).isEqualTo(1);
        assertThat(surrogatePairString.length()).isEqualTo(2);
        assertThat(variationSelectorString.length()).isEqualTo(3);
        assertThat(emojiString.length()).isEqualTo(2);
        assertThat(combinedEmojiString1.length()).isEqualTo(4);
        assertThat(combinedEmojiString2.length()).isEqualTo(4);
        assertThat(combinedEmojiString3.length()).isEqualTo(14);
        assertThat(combinedEmojiString4.length()).isEqualTo(11);
    }
String#codePointCount

String#codePointCountは、そのままUnicodeコードポイントの数になります。

    @Test
    public void codePointCount() {
        String bmpString = "羽";
        String surrogatePairString = "\uD867\uDE3D";  // U+29E3D
        String variationSelectorString = "飴\uDB40\uDD01";  // U+98F4 U+E0101
        String emojiString = "🍀";  // U+1F340
        String combinedEmojiString1 = "\uD83D\uDC46\uD83C\uDFFC";  // U+1F446 U+1F3FC
        String combinedEmojiString2 = "\uD83C\uDDE6\uD83C\uDDFF";  // U+1F1E6 U+1F1FF
        String combinedEmojiString3 = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F";  // U+1F3F4 U+E0067 U+E0062 U+E0065 U+E006E U+E0067 U+E007F
        String combinedEmojiString4 = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";  // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466

        assertThat(bmpString.codePointCount(0, bmpString.length())).isEqualTo(1);
        assertThat(surrogatePairString.codePointCount(0, surrogatePairString.length())).isEqualTo(1);
        assertThat(variationSelectorString.codePointCount(0, variationSelectorString.length())).isEqualTo(2);
        assertThat(emojiString.codePointCount(0, emojiString.length())).isEqualTo(1);
        assertThat(combinedEmojiString1.codePointCount(0, combinedEmojiString1.length())).isEqualTo(2);
        assertThat(combinedEmojiString2.codePointCount(0, combinedEmojiString2.length())).isEqualTo(2);
        assertThat(combinedEmojiString3.codePointCount(0, combinedEmojiString3.length())).isEqualTo(7);
        assertThat(combinedEmojiString4.codePointCount(0, combinedEmojiString4.length())).isEqualTo(7);
    }
BreakIteratorで文字を分割

BreakIteratorを使って文字を数えるコードはすでに載せましたが、どんな感じの分割のされ方をしていたか確認してみましょう。

Unicode拡張書記素クラスタ境界(\b{g})を使って、今回扱ったすべての文字をつなげて、分割するコードをBreakIteratorを使って
書いてみます。

    @Test
    public void splitCharsUsingBreakIterator() {
        String string = "羽\uD867\uDE3D\uDB40\uDD01🍀\uD83D\uDC46\uD83C\uDFFC\uD83C\uDDE6\uD83C\uDDFF\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66";

        BreakIterator iterator = BreakIterator.getCharacterInstance();
        iterator.setText(string);

        for (int start = iterator.first(), end = iterator.next();
             end != BreakIterator.DONE; start = end, end = iterator.next()) {
            String c = string.substring(start, end);
            System.out.printf("break iterator: char(%d, %d) = %s%n", start, end, c);
        }
    }

結果。

break iterator: char(0, 1) = 羽
break iterator: char(1, 3) = 𩸽
break iterator: char(3, 6) = 飴󠄁
break iterator: char(6, 8) = 🍀
break iterator: char(8, 10) = 👆
break iterator: char(10, 12) = 🏼
break iterator: char(12, 14) = 🇦
break iterator: char(14, 16) = 🇿
break iterator: char(16, 18) = 🏴
break iterator: char(18, 20) = 󠁧
break iterator: char(20, 22) = 󠁢
break iterator: char(22, 24) = 󠁥
break iterator: char(24, 26) = 󠁮
break iterator: char(26, 28) = 󠁧
break iterator: char(28, 30) = 󠁿
break iterator: char(30, 32) = 👨
break iterator: char(32, 33) = ‍
break iterator: char(33, 35) = 👩
break iterator: char(35, 36) = ‍
break iterator: char(36, 38) = 👧
break iterator: char(38, 39) = ‍
break iterator: char(39, 41) = 👦

やっぱり、各絵文字がバラバラに切り出されてしまいました。

代替手段

Java 13以前の環境で、Unicode拡張書記素クラスタ境界(\b{g})と同じように文字を数えたい(分割したい)場合は、ICU4JのBreakIterator
使えばよいみたいです。

ICU4J | ICU Documentation

BreakIterator (ICU4J 73)

まとめ

これまで先送りにしていた、Unicode拡張書記素クラスタ境界(\b{g})を使った文字の数え方(分割の仕方)を試してみました。

使い方そのものよりも、絵文字に関する情報を調べる方が大変でしたね…。