1.はじめに
産業機械をPLCなどを使って制御する際に、 ラダーやST言語などを使って、シーケンスプログラムを書きます。
Elixir言語でシーケンスプログラム=ステートマシンを実現したい場合に、何か便利な仕組みがないか探していたところ、erlangのgen_statemという仕組みを見つけました。
まずはgen_statemで、どんなことができるかを試行してみました。
2.まずはお手本~ATMでお金を引き出す装置の例
ATMでお金を引き出す装置を、gen_statem
で試行しているブログがありました。
まずはこれで、どのような動作をするか確認していきます。
(1)考え方
このサンプルの、ATMの動きは下記のとおりです。
ステートは3つ
- idle
- pin
- cash
動作の流れ
- カードを入れる
- 暗証番号PINを入れる(PINを間違ったら最初に戻ります)
- 引き出したい金額を入れる
- 最初に戻る
--- 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.特定の処理にタイムアウトを加える
途中の動作の中で、一定時間内に終わらなかったら最初のステートに戻るようにします。
- 動作の流れ
- カードを入れる(10秒以内に入らないと最初に戻ります)
- 暗証番号PINを入れる(PINを間違ったら最初に戻ります)
- 引き出したい金額を入れる
- 最初に戻る
さらに、最初の状態(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.参考資料
- 考え方
リファレンスマニュアル
実践記事