Elixir でステートマシンを処理

「Akka でステートマシンを処理」 と同じ処理を gen_statem の Elixir 用ラッパー(以下)を使って実装します。

ソースは http://github.com/fits/try_samples/tree/master/blog/20171121/

ステートマシンの実装(sample1)

まず、以下のステートマシンを実装します。

  • 初期状態は Idle 状態
  • Idle 状態で On イベントが発生すると Active 状態へ遷移
  • Active 状態で Off イベントが発生すると Idle 状態へ遷移
現在の状態 Off On
Idle Active
Active Idle

準備

mix でプロジェクトを作成します。

プロジェクト作成
> mix new sample1

・・・
> cd sample1

mix.exs の deps へ gen_state_machine を追加します。 今回は escript で実行するので、そのための設定も追加しておきます。

mix.exs
defmodule Sample1.Mixfile do
  use Mix.Project

  def project do
    [
      ・・・
      deps: deps(),
      # escript の設定
      escript: [ main_module: Sample1 ]
    ]
  end

  ・・・

  defp deps do
    [
      # GenStateMachine
      {:gen_state_machine, "~> 2.0"}
    ]
  end
end

実装

handle_event(event_type, event_content, state, data) 関数でイベントをハンドリングし、{:next_state, <遷移先の状態>, <新しいデータ>} を返せば新しい状態へ遷移します。

状態遷移しない場合は {:keep_state, <データ>}:keep_state_and_data でも可)を返します。

ここで、cast 関数(返事を待たない一方通行の呼び出し。Akka の tell と同じ)を使った場合のイベントタイプは :cast となります。

lib/sample_state_machine.ex
defmodule SampleStateMachine do
  use GenStateMachine

  # 初期状態
  def init(_args) do
    {:ok, :idle, 0}
  end

  # on イベントの処理(idle から active へ遷移)
  def handle_event(:cast, :on, :idle, data) do
    IO.puts "*** :on, idle -> active"
    {:next_state, :active, data + 1}
  end

  # off イベントの処理(active から idle へ遷移)
  def handle_event(:cast, :off, :active, data) do
    IO.puts "*** :off, active -> idle"
    {:next_state, :idle, data}
  end

  # 上記以外
  def handle_event(event_type, event_content, state, data) do
    IO.puts "*** Unhandled: type=#{event_type}, content=#{event_content}, state=#{state}, data=#{data}"
    {:keep_state, data}
    # 以下でも可
    # {:keep_state_and_data, []}
  end
end

このステートマシンを動作確認するための処理を実装します。 escript で実行できるように main 関数内に定義しています。

lib/sample1.ex
defmodule Sample1 do
  def main(_args) do
    {:ok, pid} = GenStateMachine.start_link(SampleStateMachine, nil)

    GenStateMachine.cast(pid, :on)
    GenStateMachine.cast(pid, :off)

    GenStateMachine.cast(pid, :off)

    GenStateMachine.stop(pid)
  end
end

ビルドと実行

deps.get で gen_state_machine を取得します。

依存パッケージの取得
> mix deps.get

Running dependency resolution...
Dependency resolution completed:
  gen_state_machine 2.0.1
* Getting gen_state_machine (Hex package)
  Checking package (https://repo.hex.pm/tarballs/gen_state_machine-2.0.1.tar)
  Using locally cached package

escript.build で escript 実行用にビルドします。

ビルド
> mix escript.build

==> gen_state_machine
Compiling 3 files (.ex)
Generated gen_state_machine app
==> sample1
Compiling 2 files (.ex)
Generated sample1 app
Generated escript sample1 with MIX_ENV=dev

escript コマンドで実行します。

実行結果
> escript sample1

*** :on, idle -> active
*** :off, active -> idle
*** Unhandled: type=cast, content=off, state=idle, data=1

タイムアウト付きステートマシンの実装(sample2)

次に、タイムアウト時の遷移を追加してみます。

現在の状態 Off On Timeout (2秒)
Idle Active
Active Idle Idle

実装

{:next_state, <遷移先の状態>, <新しいデータ>, <タイムアウト(ミリ秒)>} を返すとタイムアウトを設定できます。

イベントタイプ :timeoutタイムアウトをハンドリングできます。

注意点として、このタイムアウトは状態自体のタイムアウトではなくイベントの受信に対するタイムアウトです。

lib/timeout_state_machine.ex
defmodule TimeoutStateMachine do
  use GenStateMachine

  def init(_args) do
    {:ok, :idle, 0}
  end

  def handle_event(:cast, :on, :idle, data) do
    IO.puts "*** :on, idle -> active"
    # 2秒タイムアウト
    {:next_state, :active, data + 1, 2000}
  end

  def handle_event(:cast, :off, :active, data) do
    IO.puts "*** :off, active -> idle"
    {:next_state, :idle, data}
  end

  # タイムアウト時の処理
  def handle_event(:timeout, event_content, :active, data) do
    IO.puts "*** :timeout content=#{event_content}, active -> idle"
    {:next_state, :idle, data}
  end

  def handle_event(event_type, event_content, state, data) do
    IO.puts "*** Unhandled: type=#{event_type}, content=#{event_content}, state=#{state}, data=#{data}"
    {:keep_state, data}
  end
end

動作確認の処理を実装します。

lib/sample2.ex
defmodule Sample2 do
  def main(_args) do
    {:ok, pid} = GenStateMachine.start_link(TimeoutStateMachine, nil)

    GenStateMachine.cast(pid, :on)
    GenStateMachine.cast(pid, :off)

    GenStateMachine.cast(pid, :off)

    GenStateMachine.cast(pid, :on)

    :timer.sleep(2500)

    GenStateMachine.cast(pid, :on)

    :timer.sleep(1500)

    GenStateMachine.cast(pid, :invalid_message)

    :timer.sleep(1500)

    GenStateMachine.cast(pid, :invalid_message)

    :timer.sleep(2500)

    GenStateMachine.stop(pid)
  end
end

ビルドと実行

依存パッケージの取得とビルド
> mix deps.get
・・・

> mix escript.build
・・・

実行結果は以下の通り、invalid_message のハンドリングでタイムアウトは機能しなくなっています。

実行結果
> escript sample2

*** :on, idle -> active
*** :off, active -> idle
*** Unhandled: type=cast, content=off, state=idle, data=1
*** :on, idle -> active
*** :timeout content=2000, active -> idle
*** :on, idle -> active
*** Unhandled: type=cast, content=invalid_message, state=active, data=3
*** Unhandled: type=cast, content=invalid_message, state=active, data=3

状態タイムアウト付きステートマシンの実装(sample3)

最後に、状態のタイムアウトを実現します。

実装

:next_state を返す際に {:state_timeout, <タイムアウト(ミリ秒)>, <イベント>} を設定したリストを含める事で状態のタイムアウトを実現できます。

状態のタイムアウトはイベントタイプ :state_timeout でハンドリングします。

lib/state_timeout_state_machine.ex
defmodule StateTimeoutStateMachine do
  use GenStateMachine

  def init(_args) do
    {:ok, :idle, 0}
  end

  def handle_event(:cast, :on, :idle, data) do
    IO.puts "*** :on, idle -> active"
    # 状態タイムアウトの設定
    actions = [{:state_timeout, 2000, :off}]
    {:next_state, :active, data + 1, actions}
  end

  def handle_event(:cast, :off, :active, data) do
    IO.puts "*** :off, active -> idle"
    {:next_state, :idle, data}
  end

  # 状態タイムアウトの処理
  def handle_event(:state_timeout, :off, :active, data) do
    IO.puts "*** :state_timeout, active -> idle"
    {:next_state, :idle, data}
  end

  def handle_event(event_type, event_content, state, data) do
    IO.puts "*** Unhandled: type=#{event_type}, content=#{event_content}, state=#{state}, data=#{data}"
    {:keep_state, data}
  end
end

動作確認の処理を実装します。

lib/sample3.ex
defmodule Sample3 do
  def main(_args) do
    {:ok, pid} = GenStateMachine.start_link(StateTimeoutStateMachine, nil)

    GenStateMachine.cast(pid, :on)
    GenStateMachine.cast(pid, :off)

    GenStateMachine.cast(pid, :off)

    GenStateMachine.cast(pid, :on)

    :timer.sleep(2500)

    GenStateMachine.cast(pid, :on)

    :timer.sleep(1500)

    GenStateMachine.cast(pid, :invalid_message)

    :timer.sleep(1500)

    GenStateMachine.cast(pid, :invalid_message)

    :timer.sleep(2500)

    GenStateMachine.stop(pid)
  end
end

ビルドと実行

依存パッケージの取得とビルド
> mix deps.get
・・・

> mix escript.build
・・・

実行結果は以下の通り、invalid_message のハンドリングとは無関係に状態のタイムアウトが機能しています。

実行結果
> escript sample3

*** :on, idle -> active
*** :off, active -> idle
*** Unhandled: type=cast, content=off, state=idle, data=1
*** :on, idle -> active
*** :state_timeout, active -> idle
*** :on, idle -> active
*** Unhandled: type=cast, content=invalid_message, state=active, data=3
*** :state_timeout, active -> idle
*** Unhandled: type=cast, content=invalid_message, state=idle, data=3