C#: Statelessライブラリを使う

statemachine

先日、図のような状態遷移を扱う設計だったところに、if文やswitch文を多用して状態遷移を実現しているコードに出会いました。

今回議論の対象にする状態遷移

こういうのは、古からの知恵としてステートマシンを使って実現すると、スッキリして保守や拡張に強いコードになることが知られています。(参考:Stateパターン

自前で実現しても大したことはない内容なので、自作のライブラリに自前実装のステートマシンを持っていて、それを利用している人も多いのではないでしょうか。

.NETな環境で開発する場合、Statelessというライブラリが非常に軽く、取り回しも良く、個人的には好きなのですが、これを同僚に紹介したところ、日本語情報の少なさに難色を示されました。

そこで今日は、Statelessの日本語記事を自分で書くことで日本語情報の少なさをカバーしようという目論見です。

ちなみに、日本語情報などなくとも、StatelessのGitHubページにあるExampleフォルダ配下に優良なサンプルが用意されていますので、コードを読むのに抵抗が無い方はそちらを参照するのが良いと思います。

冒頭の図をStatlessを使って実現してみます。

適当なコンソールプロジェクトを用意し、プロジェクトの依存関係にStatelessを追加しておきます。コマンドからやる場合はこんな具合です。

dotnet new console -lang C#
dotnet add package Stateless

今回は、投稿を扱うプログラムなので状態遷移に則って動いていくクラスとして、Postクラスを用意します。

using Stateless;

namespace StateMachineExample.Posts;
public class Post
{
    // 投稿ステータス
    private enum PostStatus
    {
        Draft, // 下書き
        Publishing, // 公開中
        Published,  // 公開済み
        PublishError // 公開エラー
    };

    // 投稿に関するイベント
    private enum PostEventTrigger
    {
        SaveAsDraft, // 下書き保存
        ToPublish, // 公開中
        PublishSucceed, // 公開成功
        PublishFailed  // 公開失敗
    };

    // 今回のキモ。状態遷移マシン
    private readonly StateMachine<PostStatus, PostEventTrigger> _machine;
    // 投稿の成功状態を判定するダミーメソッド
    private static bool PublishResult() => true;
    // 状態遷移時に行いたい処理がある場合、対応するためのメソッドを用意する
    private static void SaveAsDraftEvent() => Console.WriteLine("再び下書きとして保存されました。");
    private static void PublishSucceedEvent() => Console.WriteLine("投稿公開に成功しました");
    private static void PublishFailedEvent() => Console.WriteLine("投稿公開に失敗しました");
    private void PublishEvent()
    {
        Console.WriteLine("投稿公開処理が開始されました");

        if (PublishResult())
        {
            // 投稿成功イベントを発火する
            _machine.Fire(PostEventTrigger.PublishSucceed);
        }
        else
        {
            // 投稿失敗イベントを発火する
            _machine.Fire(PostEventTrigger.PublishFailed);
        }
    }

    // コンストラクタ
    public Post()
    {
        // 初期状態は下書きにしておく
        _machine = new StateMachine<PostStatus, PostEventTrigger>(PostStatus.Draft);

        // 下書き状態に関する設定を行う
        // - 下書き状態のときに、「下書き保存」で再び下書き状態に遷移することを許可する
        // - 「公開」で公開中状態に遷移する
        // - 下書き状態から、もう一度下書き保存された時だけ、SaveAsDraftEventを実行する
        _ = _machine.Configure(PostStatus.Draft)
                    .PermitReentry(PostEventTrigger.SaveAsDraft)
                    .Permit(PostEventTrigger.ToPublish, PostStatus.Publishing)
                    .OnEntryFrom(PostEventTrigger.SaveAsDraft, SaveAsDraftEvent);

        // 公開中状態に関する設定を行う
        // - 公開成功で、公開済み状態に遷移する
        // - 公開失敗で、公開エラー状態に遷移する
        // - 公開中に遷移したら、PublishEventを実行する
        _ = _machine.Configure(PostStatus.Publishing)
                    .Permit(PostEventTrigger.PublishSucceed, PostStatus.Published)
                    .Permit(PostEventTrigger.PublishFailed, PostStatus.PublishError)
                    .OnEntry(PublishEvent);

        // 公開済み状態に関する設定を行う
        // - 公開済みに遷移したら、PublishSucceedEventを実行する
        _ = _machine.Configure(PostStatus.Published)
                    .OnEntry(PublishSucceedEvent);

        // 公開エラー状態に関する設定を行う
        // - 公開エラーに遷移したら、PublishFailedEventを実行する
        _ = _machine.Configure(PostStatus.PublishError)
                    .OnEntry(PublishFailedEvent);
    }


    // 外から投稿を操作するためのメソッド群
    public void SaveAsDraft() =>
        _machine.Fire(PostEventTrigger.SaveAsDraft); // 下書き保存イベントを発火する

    public void Publish() =>
        _machine.Fire(PostEventTrigger.ToPublish); // 公開イベントを発火する
}

やるべき事はほぼコード中にコメントで書いてある通りなのですが

  • Configureでどの状態に対する状態設定をするか指示する
  • Permit系のメソッドを使って、どのイベントでどの状態に遷移するかを定義する
  • OnEntryやOnExit等、On系のメソッドを使って状態に入った時や状態から出たとき等、処理実行したいタイミングに合わせて実行するメソッドを設定する
  • Fireメソッドでイベントを発火して、状態を遷移させる

という操作になります。例えば、用意したPostクラスはこのようにしてメインプログラムから呼び出して使うことができます。

using StateMachineExample.Posts;

// 初期状態の投稿を作成
var post = new Post();

// 投稿を公開する
post.Publish();

これを実行すると、コンソールにメッセージが出力されます。

投稿公開処理が開始されました
投稿公開に成功しました

ここまでで記述した内容は、コードをたどると次のような動きになっています。
思ったよりも複雑な処理が、スッキリと書けている事に気づくのではないでしょうか。

  • コンストラクタによって初期状態(Draft)として投稿が作成されるが、初めての作成で、以前の状態は無いのでSaveAsDraftEvent処理は行われない
  • post.Publish()でToPublishイベントが発火する。Draft状態のときにToPublishイベントが発生したら、Publishing状態に遷移する。
  • Publishing状態に遷移したら、PublishEvent処理が開始され、「投稿公開処理が開始されました」メッセージがコンソールに表示される
  • PublishEvent処理の中で、(今回は常に処理成功にしているので)PublishSucceedイベントを発火する。
  • Publishing状態のときにPublishSucceedイベントが発生したら、Published状態に遷移する
  • Published状態に遷移したら、PublishSucceedEvent処理が開始され「投稿公開に成功しました」メッセージがコンソールに表示される

Statelessでは、Permit系メソッドで許可されていない状態遷移はエラーになりますので、Program.csを次のように書き換えると、「投稿公開に成功しました」メッセージが表示された後に例外が発生します。

using StateMachineExample.Posts;

// 初期状態の投稿を作成
var post = new Post();

// 投稿を公開する
post.Publish();

// 一回公開すると、下書き保存はできない
post.SaveAsDraft();
投稿公開処理が開始されました
投稿公開に成功しました
Unhandled exception. System.InvalidOperationException: No valid leaving transitions are permitted from state 'Published' for trigger 'SaveAsDraft'. Consider ignoring the trigger.
   at Stateless.StateMachine`2.DefaultUnhandledTriggerAction(TState state, TTrigger trigger, ICollection`1 unmetGuardConditions)
   at Stateless.StateMachine`2.UnhandledTriggerAction.Sync.Execute(TState state, TTrigger trigger, ICollection`1 unmetGuards)
... 以下略

エラーメッセージに書いてある通りで、Publishedに対してSaveAdDraftのトリガーによる状態遷移が許可されていないことが分かります。

ifやswitchを多用して管理していると、予期せぬ状態遷移が発生してしまったりして不具合の原因になることが多いのですがステートマシンを上手に使って管理すれば、宣言的に状態を管理できるようになるので開発がかなり楽になると思います。