5
0

高機能enumとしてのSealed Classでワークフローシステムを作ってみる

Last updated at Posted at 2023-12-03

この記事は

WEMEX株式会社 Advent Calendar 2023の4日目に出るはずの記事です。
Java21からSealed Class/Sealed Interfaceのswitch文/式でパターンマッチングが正式利用できるようになりました。
パターンマッチングの応用として、ワークフローシステムをイメージしながらどのようにSealed Class/Sealed Interfaceが使えそうか、試してみました。

パターンマッチの有用性

Javaでパターンマッチングができるようになったのはわりと最近(Java16以降)ですが、ScalaやKotlinではそれ以前から利用されています。
Scalaの例ですが、こちらのページで書かれている通り、パターンマッチングには以下の長所があります。

ノードの種類と構造によって分岐する
ネストしたノードを分解する
ネストしたノードを分解した結果で変数を束縛する

上のページの例をJavaで書くと、以下のようになります。

Exp
public sealed interface Exp permits Add, Sub, Mul, Div, Lit{}

record Add(Exp a, Exp b) implements Exp {}
record Sub(Exp a, Exp b) implements Exp {}
record Mul(Exp a, Exp b) implements Exp {}
record Div(Exp a, Exp b) implements Exp {}
record Lit(int a) implements Exp {}
Main
public class Main {
	public static void main(String[] args) {
		var example =
				new Add(
						new Lit(1),
						new Div(
								new Mul(
										new Lit(2),
										new Lit(3)
								),
								new Lit(2)
						)
				);
		System.out.println("example = " + eval(example));
	}

	public static int eval(Exp exp) {
		return switch(exp) {
			case Add(Exp a, Exp b) -> eval(a) + eval(b);
			case Sub(Exp a, Exp b) -> eval(a) - eval(b);
			case Mul(Exp a, Exp b) -> eval(a) * eval(b);
			case Div(Exp a, Exp b) -> eval(a) / eval(b);
			case Lit(int a) -> a;
		};
	}

}

要するに四則演算を行っているだけなのですが、注目すべきはevalメソッド内のswitch式において、

  • インスタンスのクラス(変数のクラスでなく)に応じて、返却する値が変化している(Addクラスのインスタンスであれば加算、Subクラスのインスタンスであれば減算など)
  • Litクラスのように、パラメータの数が他クラスと異なっていても問題なくコンパイルできる

といったことができるようになったことです。
特に後者の性質が、こちらのページで高機能なenumとしての使い方として挙げられています。
これらはいわゆる代数的データ型の表現(のはず)であり、データ構造をより柔軟に定義することができるのが嬉しいポイントです。

enumの使い所は?

高機能版に行く前に、通常のenumはどういう場面で利用されるか振り返ると、排他的な定数を表現するために利用されることが多いかと思います。
特に、インスタンスの状態をenumで実装することはよくあるユースケースです。
例えばenumを使って状態を表現すると、

public enum Week {
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY,
    SUNDAY
}

こんな感じかと思います。
このとき、SATURDAYやSUNDAYは週末だから区別できるようにしたいというニーズがあったとしても、パラメータを追加して区別することはできません。
パラメータをそれぞれの列挙子において個別に持つことができないということです。

Sealed Class/Sealed Interfaceを利用したワークフロー

今回新たに正式利用可能となったSealed ClassやSealed Interfaceは、上記の問題点が解消されています。
つまりSealed Class/Sealed Interfaceを利用すると、上記の例で見たようにパラメータを追加で持つことができます。
このため、インスタンスの状態を、個別パラメータを持った状態で表現することができるようになりました。
これがどのように役立つか、エンタープライズシステム開発で実際ありそうな例を用いて確認してみます。

サンプルで作ってみる

サンプルとして取引先の審査システムのワークフローを作ってみます。

状態遷移図

要件としては以下のようになります。

  1. 取引先が画面から登録される。登録途中のデータは編集中として一時保存される
  2. 編集履歴は照会できるようにする
  3. 編集が完了したら、登録を行う。一時保存せず、画面から直接登録を行うこともできる。
  4. 登録後、誤りに気づいた場合引き戻しとして編集中に戻すことができる
  5. 審査者は登録内容をチェックし、取引却下とするか取引承認とするか差し戻しとして編集中に戻すかを決定する
  6. 最終的には、取引承認か取引却下のどちらかに収束する

各状態間に遷移できる処理の名称を線上に記載します。

できたもの

思ったより時間がかかったので メインどころに集中するため状態遷移図のうち以下を実装しました。
リポジトリはこちら

  1. 取引先データを編集(下書き)
  2. 取引先データを編集(代表者を更新)
  3. 登録

Sealed Classとして取引先情報(Party)を実装し、状態遷移を行うアクションのきっかけとなる業務イベントをWorkflowEventとして実装しました。
以下のmodel部分です。

.
├── DemoApplication.java
├── config
│   └── UUIDTypeHandler.java
├── controller
│   └── PartyController.java
├── entity
│   ├── EditHistory.java
│   └── PartyEntity.java
├── mapper
│   ├── EditHistoryMapper.java
│   ├── PartyMapper.java
│   └── RegisterHistoryMapper.java
├── model
│   ├── party
│   │   ├── Declined.java
│   │   ├── ManualChecked.java
│   │   ├── ManualChecking.java
│   │   ├── Party.java
│   │   ├── PartyApproved.java
│   │   ├── PartyCreated.java
│   │   ├── PartyEditing.java
│   │   └── PartyRegistered.java
│   └── workflow
│       └── event
│           ├── DeclineEvent.java
│           ├── ManualApprovedEvent.java
│           ├── ManualCheckFinishEvent.java
│           ├── ManualCheckStartEvent.java
│           ├── RegisterEvent.java
│           ├── TempSaveEvent.java
│           └── WorkflowEvent.java
└── repository
    ├── EventRepository.java
    └── PartyRepository.java

実際にSealed Classとして実装したのはPartyクラスです。コードは以下のような感じです。

Party
package com.wemex.java21.demo.model.party;

import com.wemex.java21.demo.model.workflow.event.RegisterEvent;
import com.wemex.java21.demo.model.workflow.event.TempSaveEvent;

import java.util.UUID;

public sealed abstract class Party permits PartyApproved,
        PartyRegistered,
        PartyCreated,
        PartyEditing,
        ManualChecking,
        ManualChecked,
        Declined{

    final UUID id;
    // 会社名
    final String name;
    // 代表者名
    final String representativeName;
    // 住所
    final String address;

    Party(UUID id, String partyName, String representativeName, String address) {
        this.id = id;
        this.name = partyName;
        this.representativeName = representativeName;
        this.address = address;
    }

    abstract public String getState();

    /**
     * tempSaveアクションはPartyCreatedもしくはEditingからのみ実行可能
     * caseが網羅されていればstateが増えた時にコンパイルエラーになる
     */
    // tempSaveアクション
    public Party tempSave(TempSaveEvent event) {
        return switch (this) {
            // PartyCreatedは作られたばかりなのでeditCountは1
            case PartyCreated partyCreated ->
                    new PartyEditing(partyCreated.id, event.partyName(), event.representativeName(), event.address(), 1);
            // PartyEditingは編集されているのでeditCountをインクリメント
            case PartyEditing partyEditing -> {
                int editCount = partyEditing.getEditCount() + 1;
                yield new PartyEditing(partyEditing.id, event.partyName(), event.representativeName(), event.address(), editCount);
            }
            // 以降は無視(アクションによって状態は変わらない)
            case PartyRegistered partyRegistered -> partyRegistered;
            case PartyApproved partyApproved -> partyApproved;
            case ManualChecking manualChecking -> manualChecking;
            case ManualChecked manualChecked -> manualChecked;
            case Declined declined -> declined;
        };
    }

    /**
     * instanceOfを使った従前の書き方(jav14以降)
     * パターンマッチングでなくとも機能としては同じ
     * stateが増えた時にはコンパイルエラーにならない
     */
    // tempSaveアクション
    public Party tempSaveOldStyle(TempSaveEvent event) {
        if(this instanceof PartyCreated partyCreated) {
            return new PartyEditing(partyCreated.id, event.partyName(), event.representativeName(), event.address(), 1);
        } else if (this instanceof PartyEditing partyEditing) {
            int editCount = partyEditing.getEditCount() + 1;
            return new PartyEditing(partyEditing.id, event.partyName(), event.representativeName(), event.address(), editCount);
        } else if(this instanceof PartyRegistered partyRegistered){
            return partyRegistered;
        } else if(this instanceof PartyApproved partyApproved) {
            return partyApproved;
        } else if(this instanceof ManualChecking manualChecking) {
            return manualChecking;
        } else if(this instanceof ManualChecked manualChecked) {
            return manualChecked;
        } else if(this instanceof Declined declined) {
            return declined;
        } else {
            throw new IllegalStateException("Unexpected value: " + this);
        }
    }

    /**
     * registerアクションはParty未作成もしくはEditingからのみ実行可能
     * caseが網羅されていればstateが増えた時にコンパイルエラーになる
     */
    // registerアクション
    public Party register(RegisterEvent event) {
        return switch(this) {
            case PartyCreated partyCreated ->
                // Register後にはremarks(登録時備考)を持つことができる
                new PartyRegistered(partyCreated.id, event.partyName(), event.representativeName(), event.address(), event.remarks());
            case PartyEditing partyEditing ->
                new PartyRegistered(partyEditing.id, event.partyName(), event.representativeName(), event.address(), event.remarks());
            case PartyRegistered partyRegistered -> partyRegistered;
            case PartyApproved partyApproved -> partyApproved;
            case ManualChecking manualChecking -> manualChecking;
            case ManualChecked manualChecked -> manualChecked;
            case Declined declined -> declined;
        };
    }

    public UUID getId() {
        return this.id;
    }
}


これまでであればクラス内にstateオブジェクトを持って状態を判別したり、Stateパターンのように各状態で実行できるアクションをstateオブジェクトに移譲するということを行なっていたかと思います。
Stateパターンの場合、自分がどの状態へ遷移できるのかを遷移元クラスが知っている必要があったり、ダブルディスパッチになってコードが読みづらいと言う点がありました。
また、stateオブジェクトをenumで実装すると状態ごとに異なるパラメータを持つことができないと言う点もあります。

Sealed Classで実装してみると、

  • 各状態で実行できるアクションと遷移元・遷移先の管理はSealed Classが行う
  • 共通部分は抽象クラス側に変数として持ち、
  • 各状態ごとに個別に持つ変数はサブクラス側に持つ

ということができます。

一方で、WorkflowEventはSealed Interfaceとして実装し、各業務イベントはrecordとしました。

WorkflowEvent
package com.wemex.java21.demo.model.workflow.event;

public sealed interface WorkflowEvent
        permits RegisterEvent,
        ManualCheckStartEvent,
        ManualCheckFinishEvent,
        ManualApprovedEvent,
        TempSaveEvent,
        DeclineEvent {
}

これらのイベントは、状態遷移のきっかけとなる業務イベントなので、イベントに含まれる情報は異なるのが普通かと思います。
今回の例で言えば、取引先の申請前の一時保存中には取引先名や代表者名などの追加や変更を行うので、一時保存を表すTempSaveEventには取引先名などのインスタンス変数を持たせています。

TempSaveEvent
package com.wemex.java21.demo.model.workflow.event;

import java.time.LocalDateTime;
import java.util.UUID;

public record TempSaveEvent(
        UUID id,
        UUID partyId,
        String partyName,
        String representativeName,
        String address,
        LocalDateTime savedTime,
        String userId
) implements WorkflowEvent {
}

一方で取引先審査後の却下を表すイベントでは、却下理由などが画面から入力されるなどがあるかと思います。
これをイベントで持つと、以下のようになります。

DeclineEvent
package com.wemex.java21.demo.model.workflow.event;

import java.time.LocalDateTime;
import java.util.UUID;

public record DeclineEvent(
        UUID id,
        UUID partyId,
        LocalDateTime declinedTime,
        String userId,
        String reason
) implements WorkflowEvent {}

これらのイベントを編集するということは考えにくく、基本はリクエストとして入力されたイベントをDBに保存したり、照会のリクエストに応じてそのままレスポンスとして返すということがユースケースとして多いと思われるため、イミュータブルに利用できるrecordが向いていそうです。

考察

モデリングについて

Sealed ClassとSealed Interfaceをどう使い分けるか?が今回の検証のメインでした。
今回のようなケースでは、①取引先としての情報(取引先名、代表者名など)②状態が進むにつれて増えていく情報(編集履歴、申請日時、登録時備考など)③各状態においてのみ利用される情報があるかと思います。
②状態が進むにつれて増えていく情報をどう保持するかが悩ましいのですが、③各状態においてのみ利用される情報は、状態に紐づくパラメータとして表現するのが良さそうです。

  1. インスタンスの状態そのものをクラス(Sealed Class)として表現
  2. インスタンス内部に状態変数(Sealed Interface)を持つ

という2つのやり方が考えられます。
今回はTypeScriptのDiscriminated Union typeのように型で状態を表現してみたかったので前者で実装しましたが、どちらもメリットデメリットあると思います。後者のほうが多くのケースで使いやすいかもしれません。

1.の場合

  • メリット
    • インスタンスそのものが状態を表現しておりstate変数だけ外に取り出されてもちまわられることがない
  • デメリット
    • Sealed Classが神クラスになりそう
    • pertmis先のクラスでSealed ClassのメソッドをOverrideできる

2.の場合

  • メリット
    • 直感的にわかりやすく、RDBの実装も素直(ほとんどの場合stateをカラムとして状態を保つクラスのEntityに保存すると思われる)
  • デメリット
    • state変数を安直に外部に公開しないよう注意が必要

審査が進むにつれて増えていく審査アクティビティのようなイベントは、②状態が進むにつれて増えていく情報になると思います。
今回は状態に紐付けず、イベントとしてテーブルに保存して必要に応じて呼び出すだけとしましたが、取引先クラス/各状態クラスに審査アクティビティとしてインスタンス変数に保持するやり方もありそうです。

パターンマッチングでなるべくdefaultを利用しないようにする

今回の状態遷移のように辺が少ないグラフの場合、遷移が許可されていない辺の数が多くなります。
このようなパターンをdefaultにマッチさせてしますと新しく状態を追加した場合に対応を忘れそうなので、defaultは使用しないと決めてしまった方が良さそうです。
幸い、GitHub Copilotなどの技術を使えば、定義されていないパターンを補完してくれるので、そこまで苦にはならないです。どちらかというと、defaultであることを検知して、禁止とするようなLinterを使うようにしたいところです。

つまりどころ

Gradleが利用できない

kotlinがjdk21対応できていないため、現行最新のGradle8.4でのビルドができないとのことでエラーが出ました。
とりあえずビルドシステムをMavenにすることで続行できました。

recordは継承できない

本題とは別になるが、各Partyクラスは差分ばかりでなく共通のプロパティを持つため、それを重複して管理はしたくない。
recordを継承したベースクラスが作れるかと思ったが、不可(recordのコンパイル時finalが自動的に付与されるため

まとめ

Sealed Class/Sealed Interfaceの応用例としてサンプルでワークフローシステムを作ってみました。
取引先の審査システムをワークフロー化するにあたり、状態遷移と遷移イベントをそれぞれSealed ClassとSealed Interfaceで実装しました。
パターンマッチングの機能としてはサッとかけました。どちらかというとモデリングに悩む時間の方が長かったです。
作り出すと色々凝りたくなってきたので、別パターンでの実装も考えてみたいと思います。

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0