hotch-potch, Note to self

いろいろ作業記録

Elixirでステートマシンをつくる ~ gen_statem

1.はじめに

産業機械をPLCなどを使って制御する際に、 ラダーやST言語などを使って、シーケンスプログラムを書きます。

Elixir言語でシーケンスプログラム=ステートマシンを実現したい場合に、何か便利な仕組みがないか探していたところ、erlanggen_statemという仕組みを見つけました。

まずはgen_statemで、どんなことができるかを試行してみました。

2.まずはお手本~ATMでお金を引き出す装置の例

ATMでお金を引き出す装置を、gen_statemで試行しているブログがありました。 まずはこれで、どのような動作をするか確認していきます。

(1)考え方

このサンプルの、ATMの動きは下記のとおりです。

  • ステートは3つ

    • idle
    • pin
    • cash
  • 動作の流れ

    1. カードを入れる
    2. 暗証番号PINを入れる(PINを間違ったら最初に戻ります)
    3. 引き出したい金額を入れる
    4. 最初に戻る
---
title: ATMのステート
---
stateDiagram-v2
   [*] --> idle: start()
   idle --> [*]: stop()
   idle --> pin: insert_card()
   pin --> cash: insert_pin(string)/correct
   pin --> idle: insert_pin(string)/incorrect
   cash --> idle: insert_amount(int)

(2)ソースコードの準備

#プロジェクトを生成
$ mix new statem

#空のソースファイルを生成
$ cd statem
$ touch ./lib/atm_statem.ex

作成したファイルatm_statem.exに、 先ほどのブログのImplementationの部分を書き写します。

(3)サンプルの試行

$ iex -S mix

gen_statemの開始と終了だけ試します。

iex(1)> AtmStatem.start()
{:ok, PID<0.136.0>}

iex(2)> AtmStatem.stop()
"atm_statem terminated with reason - normal"
:ok

正常な手順

先ほど示した動作の流れに従って操作します。

# 開始
iex(3)> AtmStatem.start
{:ok, PID<0.167.0>}

# メッセージを確認
iex(4)> AtmStatem.message
"Please insert card"

# カードを挿入→状態pinに移る
iex(5)> AtmStatem.insert_card  
:pin
iex(6)> AtmStatem.message    
"Please enter PIN"

# PINを入力→状態cashに移る
iex(7)> AtmStatem.insert_pin("1111")
:cash
iex(8)> AtmStatem.message           
"Insert cash amount"

#引き出したい金額を入力→状態idleに移る
iex(10)> AtmStatem.insert_amount(100)
:idle

異常な手順

いきなり金額を入れる

iex(4)> AtmStatem.insert_amount(100)
"Please insert card"

カードを入れて、暗証番号を間違える→状態idleに移る

iex(7)> AtmStatem.insert_card       
:pin

iex(8)> AtmStatem.insert_pin("0")   
:idle

カードを入れて、いきなり金額を入れる

iex(9)> AtmStatem.insert_card    
:pin

iex(10)> AtmStatem.insert_amount(1)  
"Please enter PIN"

3.読み解きと理解

:gen_statemの特徴として以下があります。

(1)現在のステートを保持する仕組み

保持しているステートを確認すると、下記の結果が得られます。

:sys.get_status 関数を使います。

・・・(省略)・・・
[
  header: ~c"Status for state machine atm_statem",
  data: [
    {~c"Status", :running},
    {~c"Parent", #PID<0.190.0>},
    {~c"Modules", [AtmStatemCast]},
    {~c"Time-outs", {0, []}},
    {~c"Logged Events", []},
    {~c"Postponed", []}
  ],
  data: [{~c"State", {:idle, "Please insert card"}}]
]

最後のdata:項に、ステートのアトムと文字列のデータを保持しています。(タイムアウト・Time-outsの項目も見えます)

gen_statemでは、基本的な機能あらかじめ提供してくれています。

(2)コールバック関数名が、現在のステートと同じ名前になる

@impl :gen_statem def callback_mode, do: :state_functions

このように記述したとき、コールバック関数の名前が、handle_***ではなく、現在のステート名そのものになります。

そのため、ステートごとに呼び出されるコールバック関数を、別々に表記することができます。

API側の実装の抜粋

APIは、行動に紐づく書き方をしているだけで、ステートには言及しません。

  # 行動・カードを入れる
  def insert_card, do: :gen_statem.call(@name, :insert_card)

  # 行動・PINを入れる
  def insert_pin(pin) when is_binary(pin) do
    ・・・・
  end

  # 行動・金額を入れる
  def insert_amount(_amount), do: :gen_statem.call(@name, :insert_amount)

コールバック関数の実装の抜粋

コールバック関数は、現在のステートに合わせて、APIから受け取った行動(:insert_cardなど)に対応する関数が実行されます。

  # ステート・:idleのときにコールバック
  def idle({:call, from}, :insert_card, _data),
      #:idleで:insert_cardのとき
      #次のステートに移行
      do: {:next_state, :pin, @pin_message, [{:reply, from, :pin}]}

  # ステート・:pinのときにコールバック
  def pin({:call, from}, :insert_correct_pin, _data),
      #:pinで:insert_correct_pinのとき
      #次のステートに移行
      do: {:next_state, :cash, @cash_message, [{:reply, from, :cash}]}

  # ステート・:pinのときにコールバック
  def pin({:call, from}, :insert_wrong_pin, _data),
      #:pinで:insert_wrong_pinのとき
      #最初のステートに戻る
      do: {:next_state, :idle, @idle_message, [{:reply, from, :idle}]}

  # ステート・:cashのときにコールバック
  def cash({:call, from}, :insert_amount, _data),
      #:cashで:insert_amountのとき
      #最初のステートに戻る
      do: {:next_state, :idle, @idle_message, [{:reply, from, :idle}]}

また、上記で関数がマッチしないときは、 一律handle_eventを呼び出します。

例:

  # idleでマッチしないとき
  def idle(event_type, event_content, data), do: handle_event(event_type, event_content, data)

  # 一律呼び出される関数
  defp handle_event({:call, from}, _, data),
        #現在のステート、データを保持
       do: {:keep_state, data, [{:reply, from, data}]}

また、callback_modeの返り値を変えることで、通常のElixirのコールバック関数に倣った書き方も可能です。(今回は省略)

4.先ほどのコードを、非同期castに書き換え

お手本の実装では、call(同期)で処理が書かれています。

このあと、タイムアウトをそのまま実装すると、各コマンドを実行した際に、タイムアウトするまで反応がなくなってしまいます。

タイムアウトの処理を追加する前に、cast(非同期)に書き換えてみます。

参考にした資料はこちらです。

併せて、gen_statemのドキュメントを眺めていて、表記が簡単になりそうな部分も手直ししています。

また、毎回メッセージを確認するコマンドの入力は大変なので、都度メッセージを表示するようにしています。

(1)ソースコード

defmodule AtmStatemCast do
  @moduledoc """
  非同期版
  """
  @behaviour :gen_statem

  @name :atm_statem

  @idle_message "Please insert card"
  @pin_message "Please enter PIN"
  @cash_message "Insert cash amount"
  @final_message "Return the card"

  # -----------------------------------
  # 公開API
  # -----------------------------------

  @doc """
  開始
  """
  def start, do: :gen_statem.start({:local, @name}, __MODULE__, [], [])

  @doc """
  終了
  """
  def stop, do: :gen_statem.stop(@name)

  @doc """
  現在のメッセージを表示
  """
  def message, do: :gen_statem.call(@name, :message)

  @doc """
  現在のステータスの取得
  """
  def get_status do
    {_, _, _, [_, _, _, _, data]} = :sys.get_status(@name)
    data
  end

  @doc """
  手順1・カードを入れる
  """
  def insert_card do
    IO.puts(" card  : in")
    :gen_statem.cast(@name, :insert_card)
  end

  @doc """
  手順2・PINを入れる
  """
  def insert_pin(pin) when is_binary(pin) do
    IO.puts(" pin   : #{inspect(pin)}")

    case correct_pin?(pin) do
      # 正しい
      true -> :gen_statem.cast(@name, :insert_correct_pin)
      # 誤り
      false -> :gen_statem.cast(@name, :insert_wrong_pin)
    end
  end

  @doc """
  手順3・金額を入れる
  """
  def insert_amount(amount) when is_integer(amount) do
    IO.puts(" amount: #{inspect(amount)}")
    :gen_statem.cast(@name, :insert_amount)
  end

  # -----------------------------------
  # callback関数 / gen_statem
  # -----------------------------------

  @doc """
  初期化
  """
  @impl :gen_statem
  def init([]) do
    IO.puts(" - init state: idle, #{@idle_message}")
    # 初期ステートとメッセージを設定
    {:ok, :idle, @idle_message}
  end

  @doc """
  終了
  """
  @impl :gen_statem
  def terminate(reason, _state, _data) do
    IO.puts(" *** terminated with reason - #{inspect(reason)}")
  end

  @doc """
  コールバックの関数名を、現在のステート名と同じにする
  """
  @impl :gen_statem
  def callback_mode, do: :state_functions

  # -----------------------------------
  # callbacks関数 / 各ステートごとに準備
  # -----------------------------------

  @doc """
  ステート・idle
  """
  def idle(:cast, :insert_card, _) do
    # ステート・idleの状態で、insert_cardの:gen_statem.castを受けた
    IO.puts(" - next state: idle > pin, #{@pin_message} ")

    # 次のステートに移行
    {:next_state, :pin, @pin_message}
  end

  def idle(event_type, event_content, data),
    # いずれにもマッチしないときは、共通のhandle_eventを呼び出す
    do: handle_event(event_type, event_content, data)

  @doc """
  ステート・pin
  """
  def pin(:cast, :insert_correct_pin, _) do
    IO.puts(" - next state: pin > cash, #{@cash_message}")
    # 次のステートに移行
    {:next_state, :cash, @cash_message}
  end

  def pin(event_type, event_content, data),
    do: handle_event(event_type, event_content, data)

  @doc """
  ステート・cash
  """
  def cash(:cast, :insert_amount, _) do
    IO.puts(" - next state: cash > idle, #{@final_message}")
    # ステート・idleに戻る
    state_to_idle()
  end

  def cash(event_type, event_content, data),
    do: handle_event(event_type, event_content, data)

  # -----------------------------------
  # internal functions
  # -----------------------------------

  defp handle_event(:cast, _, data) do
    # いずれにもマッチしないときは、現在のステートを表示
    IO.puts(" - keep state: #{inspect(data)}")
    :keep_state_and_data
  end

  defp state_to_idle() do
    # idleに戻るときの共通処理
    IO.puts(" - next state: idle, #{@idle_message} ")
    {:next_state, :idle, @idle_message}
  end

  defp correct_pin?(pin) do
    # PINの接頭辞が0の時は、(誤ったPIN入力とみなし)falseを返す
    !String.starts_with?(pin, "0")
  end
end

(2)試行

先ほどのcall(同期)版と、(メッセージの出方は変わりますが)同じ挙動です。

#開始
iex(21)> AtmStatemCast.start

 - init state: idle, Please insert card
{:ok, PID<0.189.0>}

#カードを入れる
iex(22)> AtmStatemCast.insert_card

 card  : in
 - next state: idle > pin, Please enter PIN 
:ok


#PINを入れる
iex(23)> AtmStatemCast.insert_pin("1111")

 pin   : "1111"
 - next state: pin > cash, Insert cash amount
:ok

#金額を入れる
iex(24)> AtmStatemCast.insert_amount(100)
 amount: 100
 - next state: cash > idle, Return the card
:ok
 - next state: idle, Please insert card 
#最初のステートに戻る。(カードが入るのを待っている状態)

5.特定の処理にタイムアウトを加える

途中の動作の中で、一定時間内に終わらなかったら最初のステートに戻るようにします。

  • 動作の流れ
    1. カードを入れる(10秒以内に入らないと最初に戻ります)
    2. 暗証番号PINを入れる(PINを間違ったら最初に戻ります)
    3. 引き出したい金額を入れる
    4. 最初に戻る

さらに、最初の状態(idle)が10秒継続以上継続すると、gen_statemを自動的に終了するようにしました。

---
title: ATMのステート・タイムアウト付き
---
stateDiagram-v2
   [*] --> idle: start()
   idle --> [*]: (timeout)/stop()
   idle --> pin: 1. insert_card()
   pin --> cash: 2. insert_pin(string)/correct
   pin --> idle: (timeout)
   pin --> idle: 2. insert_pin(string)/incorrect
   cash --> idle: 3. insert_amount(int)

(1)ソースコード

defmodule AtmStatemCastTo do
  @moduledoc """
  非同期版・途中にタイムアウト付き
  """
  @behaviour :gen_statem

  @name :atm_statem

  @idle_message "Please insert card"
  @pin_message "Please enter PIN"
  @cash_message "Insert cash amount"
  @final_message "Return the card"

  # -----------------------------------
  # 公開API
  # -----------------------------------

  @doc """
  開始
  """
  def start, do: :gen_statem.start({:local, @name}, __MODULE__, [], [])

  @doc """
  終了
  """
  def stop, do: :gen_statem.stop(@name)

  @doc """
  現在のメッセージを表示
  """
  def message, do: :gen_statem.call(@name, :message)

  @doc """
  現在のステータスの取得
  """
  def get_status do
    {_, _, _, [_, _, _, _, data]} = :sys.get_status(@name)
    data
  end

  @doc """
  手順1・カードを入れる
  """
  def insert_card do
    IO.puts(" card  : in")
    :gen_statem.cast(@name, :insert_card)
  end

  @doc """
  手順2・PINを入れる
  """
  def insert_pin(pin) when is_binary(pin) do
    IO.puts(" pin   : #{inspect(pin)}")

    case correct_pin?(pin) do
      # 正しい
      true -> :gen_statem.cast(@name, :insert_correct_pin)
      # 誤り
      false -> :gen_statem.cast(@name, :insert_wrong_pin)
    end
  end

  @doc """
  手順3・金額を入れる
  """
  def insert_amount(amount) when is_integer(amount) do
    IO.puts(" amount: #{inspect(amount)}")
    :gen_statem.cast(@name, :insert_amount)
  end

  # -----------------------------------
  # callback関数 / gen_statem
  # -----------------------------------

  @doc """
  初期化
  """
  @impl :gen_statem
  def init([]) do
    IO.puts(" - init state: idle, #{@idle_message} within 10 seconds")
    actions = [{:state_timeout, 10000, nil}]
    # 初期ステートとメッセージを設定
    {:ok, :idle, @idle_message, actions}
  end

  @doc """
  終了
  """
  @impl :gen_statem
  def terminate(reason, _state, _data) do
    IO.puts(" *** terminated with reason - #{inspect(reason)}")
  end

  @doc """
  コールバックの関数名を、現在のステート名と同じにする
  """
  @impl :gen_statem
  def callback_mode, do: :state_functions

  # -----------------------------------
  # callbacks関数 / 各ステートごとに準備
  # -----------------------------------

  @doc """
  ステート・idle
  """
  def idle(:cast, :insert_card, _) do
    # ステート・idleの状態で、insert_cardの:gen_statem.castを受けた
    IO.puts(" - next state: idle > pin, #{@pin_message} within 10 seconds")
    # タイムアウトを設定
    actions = [{:state_timeout, 10000, nil}]
    # 次のステートに移行
    {:next_state, :pin, @pin_message, actions}
  end

  def idle(:state_timeout, _, _) do
    # タイムアウトを受けたときは、終了
    IO.puts(" *** timeout: exit.")
    :stop
  end

  def idle(event_type, event_content, data),
    # いずれにもマッチしないときは、共通のhandle_eventを呼び出す
    do: handle_event(event_type, event_content, data)

  @doc """
  ステート・pin
  """
  def pin(:cast, :insert_correct_pin, _) do
    IO.puts(" - next state: pin > cash, #{@cash_message}")
    # 次のステートに移行
    {:next_state, :cash, @cash_message}
  end

  def pin(event_type, event_content, data),
    do: handle_event(event_type, event_content, data)

  @doc """
  ステート・cash
  """
  def cash(:cast, :insert_amount, _) do
    IO.puts(" - next state: cash > idle, #{@final_message}")
    # ステート・idleに戻る
    state_to_idle()
  end

  def cash(event_type, event_content, data),
    do: handle_event(event_type, event_content, data)

  # -----------------------------------
  # internal functions
  # -----------------------------------

  defp handle_event(:cast, _, data) do
    # いずれにもマッチしないときは、現在のステートを表示
    IO.puts(" - keep state: #{inspect(data)}")
    :keep_state_and_data
  end

  defp handle_event(:state_timeout, _from, data) do
    # タイムアウトを受けたときは、強制的にステート・idleに戻る
    IO.puts(" *** timeout: #{inspect(data)}")
    # ステート・idleに戻る
    state_to_idle()
  end

  defp state_to_idle() do
    # idleに戻るときの共通処理
    IO.puts(" - next state: idle, #{@idle_message} within 10 seconds")
    # タイムアウトを設定
    actions = [{:state_timeout, 10000, nil}]
    {:next_state, :idle, @idle_message, actions}
  end

  defp correct_pin?(pin) do
    # PINの接頭辞が0の時は、(誤ったPIN入力とみなし)falseを返す
    !String.starts_with?(pin, "0")
  end
end

(2)試行

正常な手順を、一通り実行してみます。

#開始
iex(13)> AtmStatemCastTo.start             

 - init state: idle, Please insert card within 10 seconds
{:ok, PID<0.173.0>}

#ステータスを見る
iex(19)> AtmStatemCastTo.get_status

[
  header: ~c"Status for state machine atm_statem",
  data: [
    {~c"Status", :running},
    {~c"Parent", PID<0.175.0>},
    {~c"Modules", [AtmStatemCastTo]},
    {~c"Time-outs", {1, [state_timeout: nil]}},
    {~c"Logged Events", []},
    {~c"Postponed", []}
  ],
  data: [{~c"State", {:idle, "Please insert card"}}]
]

#カードを入れる
iex(14)> AtmStatemCastTo.insert_card       

 card  : in
 - next state: idle > pin, Please enter PIN within 10 seconds
:ok

#PINを入れる
iex(15)> AtmStatemCastTo.insert_pin("1111")
 pin   : "1111"
 - next state: pin > cash, Insert cash amount
:ok

#金額を入れる
iex(16)> AtmStatemCastTo.insert_amount(100)

 amount: 100
 - next state: cash > idle, Return the card
:ok
 - next state: idle, Please insert card within 10 seconds
 *** timeout: exit.
 *** terminated with reason - :normal
#10秒放置すると勝手に終了します

次に、カードが入らない状態が10秒継続したときに、gen_statemが終了するのを確認します。

#開始
iex(17)> AtmStatemCastTo.start             

 - init state: idle, Please insert card within 10 seconds
{:ok, PID<0.174.0>}
 *** timeout: exit.
 *** terminated with reason - :normal
#10秒放置すると勝手に終了します

A.参考資料