環境
$ elixir -v
Erlang/OTP 20 [erts-9.0] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]
Elixir 1.5.1
問題
簡単な ON/OFF スイッチの状態遷移を実装します。
初期状態が OFF で、 push イベントが発生したら、 ON になる。今回はここまで。
初期化
defmodule StatemTest do
def start_link do
{:ok, fsm} = :gen_statem.start_link(__MODULE__, [], [])
fsm
end
end
iex(1)> StatemTest.start_link
18:56:31.041 [error] ** State machine #PID<0.123.0> terminating
** When server state = :undefined
** Reason for termination = :error:{:"function not exported", {StatemTest, :init, 1}}
** Callback mode = :undefined
** Stacktrace =
** [{:gen_statem, :init_it, 6, [file: 'gen_statem.erl', line: 676]},
{:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 247]}]
** (EXIT from #PID<0.121.0>) evaluator process exited with reason: an exception was raised:
** (UndefinedFunctionError) function StatemTest.init/1 is undefined or private
(statem_test) StatemTest.init([])
(stdlib) gen_statem.erl:676: :gen_statem.init_it/6
(stdlib) proc_lib.erl:247: :proc_lib.init_p_do_apply/3
Interactive Elixir (1.5.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>
ほぉ、init/1
が必要、と。
defmodule StatemTest do
def start_link do
{:ok, fsm} = :gen_statem.start_link(__MODULE__, [], [])
fsm
end
+
+ def init(args) do
+ IO.puts "called init with #{args}"
+ end
end
iex(1)> StatemTest.start_link
called init with
** (EXIT from #PID<0.121.0>) evaluator process exited with reason: {:bad_return_from_init, :ok}
Interactive Elixir (1.5.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>
18:59:03.615 [error] ** State machine #PID<0.123.0> terminating
** When server state = :undefined
** Reason for termination = :error:{:bad_return_from_init, :ok}
** Callback mode = :undefined
** Stacktrace =
** [{:gen_statem, :init_result, 6, [file: 'gen_statem.erl', line: 721]},
{:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 247]}]
お。なんか、init でいい感じの return をしてあげないといけないっぽいですね。
init/1
のマニュアル を見ると、
Module:init(Args) -> Result(StateType)
Types
Args = term()
Result(StateType) = init_result(StateType)
と書いてあって、
init_result(StateType) =
{ok, State :: StateType, Data :: data()} |
{ok,
State :: StateType,
Data :: data(),
Actions :: [action()] | action()} |
ignore |
{stop, Reason :: term()}
なので、Action を指定したり、ignore したり、stop と理由を返したりいろいろ
できるっぽいですね。今回はシンプルに :ok
で初期状態(:off
)を返すだけにしましょう。
defmodule StatemTest do
def start_link do
{:ok, fsm} = :gen_statem.start_link(__MODULE__, [], [])
fsm
end
def init(args) do
IO.puts "called init with #{args}"
+ {:ok, :off, []}
end
end
iex(1)> StatemTest.start_link
called init with
19:09:02.334 [error] ** State machine #PID<0.123.0> terminating
** Last event = {:internal, :init_state}
** When server state = {:off, []}
** Reason for termination = :error:{:"function not exported", {StatemTest, :callback_mode, 0}}
** Callback mode = :undefined
** Stacktrace =
** [{:gen_statem, :call_callback_mode, 1, [file: 'gen_statem.erl', line: 1177]},
{:gen_statem, :enter, 7, [file: 'gen_statem.erl', line: 659]},
{:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 247]}]
** (EXIT from #PID<0.121.0>) evaluator process exited with reason: an exception was raised:
** (UndefinedFunctionError) function StatemTest.callback_mode/0 is undefined or private
(statem_test) StatemTest.callback_mode()
(stdlib) gen_statem.erl:1177: :gen_statem.call_callback_mode/1
(stdlib) gen_statem.erl:659: :gen_statem.enter/7
(stdlib) proc_lib.erl:247: :proc_lib.init_p_do_apply/3
Interactive Elixir (1.5.1) - press Ctrl+C to exit (type h() ENTER for help)
ほほーぅ。 callback_mode/0
という関数が必要だよ、と。
マニュアルを見ると、
Module:callback_mode() -> CallbackMode
Types
CallbackMode = callback_mode() | [ callback_mode() | state_enter() ]
と書いてあり、さらに
callback_mode() = state_functions | handle_event_function
と書いてありますね。
- state_functions というのが状態ごとに関数を作る方式
- handle_event_function というのがイベントを処理する関数を
1つだけ作り、その中で状態とイベントに応じた処理を書く方式
という2つの書式がサポートされているようです。
今回は state_functions
で行ってみましょう。
defmodule StatemTest do
def start_link do
{:ok, fsm} = :gen_statem.start_link(__MODULE__, [], [])
fsm
end
def init(args) do
IO.puts "called init with #{args}"
{:ok, :off, []}
end
+
+ def callback_mode do
+ :state_functions
+ end
end
iex(1)> StatemTest.start_link
called init with
# PID<0.123.0>
iex(2)>
ふぉー、なんか動いたー!
状態の確認
今、どのステータスなのかを知りたいですね。
どうやら、 :sys.get_status/1
とか :sys.get_state/1
というので
調べられるらしい。
iex(2)> :sys.get_status(pid)
{:status, #PID<0.140.0>, {:module, :gen_statem},
[["$ancestors": [#PID<0.121.0>, #PID<0.59.0>],
"$initial_call": {StatemTest, :init, 1}], :running, #PID<0.121.0>, [],
[header: 'Status for state machine <0.140.0>',
data: [{'Status', :running}, {'Parent', #PID<0.121.0>},
{'Logged Events', []}, {'Postponed', []}], data: [{'State', {:off, []}}]]]}
iex(3)> :sys.get_state(pid)
{:off, []}
ふむふむ。ちゃんと確認できましたね。
イベントの発生と状態遷移
次は何かイベントを起こして状態遷移させてみたいですね。
ボタンを押すイメージで push
というイベントにしてみましょうか。
イベントを発生させるのは
-
cast
: 非同期 -
call
: 同期
という方法があるらしい。この辺は GenServer
などでお馴染みですね。
ひとまず同期でやってみましょうかね。
call(ServerRef :: server_ref(), Request :: term()) ->
Reply :: term()
call(ServerRef :: server_ref(),
Request :: term(),
Timeout ::
timeout() |
{clean_timeout, T :: timeout()} |
{dirty_timeout, T :: timeout()}) ->
Reply :: term()
という 2 つの API があるらしい。後者はタイムアウトというのを
扱ってくれるようですね。まずはシンプルに前者で。
iex(2)> :gen_statem.call(pid, :push)
11:03:31.503 [error] ** State machine #PID<0.123.0> terminating
** Last event = {{:call, {#PID<0.121.0>, #Reference<0.3670981518.2609905667.261347>}}, :push}
** When server state = {:off, []}
** Reason for termination = :error:{:"function not exported", {StatemTest, :off, 3}}
** Callback mode = :state_functions
** Stacktrace =
** [{:gen_statem, :call_state_function, 5, [file: 'gen_statem.erl', line: 1240]},
{:gen_statem, :loop_event, 6, [file: 'gen_statem.erl', line: 1012]},
{:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 247]}]
** (EXIT from #PID<0.121.0>) evaluator process exited with reason: an exception was raised:
** (UndefinedFunctionError) function StatemTest.off/3 is undefined or private
(statem_test) StatemTest.off({:call, {#PID<0.121.0>, #Reference<0.3670981518.2609905667.261347>}}, :push, [])
(stdlib) gen_statem.erl:1240: :gen_statem.call_state_function/5
(stdlib) gen_statem.erl:1012: :gen_statem.loop_event/6
(stdlib) proc_lib.erl:247: :proc_lib.init_p_do_apply/3
ほほーぅ。自動的に
- 直近のイベント
- どのステータスのときに起きたか
- なぜエラーしたのか
が表示されるのですごくわかりやすいですね。
現在 off
というステータスに居て、 off
で push
ってイベントが起きたけど、
それを処理するための off/3
という関数がないぞ、と。
マニュアルにも
Makes a synchronous call to the gen_statem ServerRef by sending a request and waiting until its reply arrives. The gen_statem calls the state callback with event_type() {call,From} and event content Request.
A Reply is generated when a state callback returns with {reply,From,Reply} as one action(), and that Reply becomes the return value of this function.
と書いてあります。
型はこんな感じ。
Module:StateName(enter, OldState, Data) -> StateEnterResult(StateName)
Module:StateName(EventType, EventContent, Data) -> StateFunctionResult
サンプルがだいたい後者で書かれているようなのでそれに従いましょうかね。
で、戻り値の StateFunctionResult
は何かというと
event_handler_result(StateType) =
{next_state, NextState :: StateType, NewData :: data()} |
{next_state,
NextState :: StateType,
NewData :: data(),
Actions :: [action()] | action()} |
state_callback_result(action())
StateType is state_name() if callback mode is state_functions, or state() if callback mode is handle_event_function.
next_state
The gen_statem does a state transition to NextState (which can be the same as the current state), sets NewData, and executes all Actions.
ということだそうです。今回はシンプルに
{next_state, NextState :: StateType, NewData :: data()}
で書いてみましょう。
defmodule StatemTest do
def start_link do
{:ok, fsm} = :gen_statem.start_link(__MODULE__, [], [])
fsm
end
def init(args) do
IO.puts "called init with #{args}"
{:ok, :off, []}
end
def callback_mode do
:state_functions
end
+
+ def off({:call, from}, :push, args) do
+ {:next_state, :on, []}
+ end
end
iex(1)> pid = StatemTest.start_link
called init with
# PID<0.171.0>
iex(2)> :gen_statem.call(pid, :push)
おやぁ?このまま固まって動かなくなってしまいました。C-c で止めて、もう一度
マニュアルを読んでみましょう。
call/3
のところにこんな説明がありました。
A Reply is generated when a state callback returns with {reply,From,Reply} as one action(), and that Reply becomes the return value of this function.
ふむふむ。 Action を返してあげないと、 call/3
の戻り値が生成されないのですね。
ということは
{next_state, NextState :: StateType, NewData :: data()}
ではなくて、
{next_state,
NextState :: StateType,
NewData :: data(),
Actions :: [action()] | action()}
でやらなければいけない、と。
defmodule StatemTest do
def start_link do
{:ok, fsm} = :gen_statem.start_link(__MODULE__, [], [])
fsm
end
def init(args) do
IO.puts "called init with #{args}"
{:ok, :off, []}
end
def callback_mode do
:state_functions
end
def off({:call, from}, :push, args) do
- {:next_state, :on, []}
+ {:next_state, :on, [], {:reply, from, :on}}
end
end
iex(1)> pid = StatemTest.start_link
called init with
# PID<0.123.0>
iex(2)> :gen_statem.call(pid, :push)
:on
iex(3)> :sys.get_state(pid)
{:on, []}
おー、うまく遷移できたみたいですね。
今回はひとまずここまで。
まとめ
-
:gen_statem.start_link
するには-
init/1
を定義しておかないといけない -
callback_mode/0
を定義しておかないといけない
-
- 状態を調べるには
:sys.get_state/1
や:sys.get_status/1
- イベントの発火は
call
かcast
-
:state_functions
モードのときには、状態名と同じ関数を定義し、:next_state
で次の状態へ遷移。 -
call
に戻り値を返すには、state function で{:reply, from, 返答内容}
というアクションを返してあげないといけない
感想
- タイムアウトを扱ってたり、callback_mode なんてのを選べたり、同期・非同期が選べたり、この他にも今回述べてないけど、エラーが起きて terminate するときの振る舞いをカスタマイズできたり、とにかくいろんなところがカスタマイズできるので、フツーの状態遷移ライブラリに比べると理解するのに時間がかかる
- 型が非常にしっかり定義されてたり適切な名前がついてたりするし、それがマニュアルにちゃんと書かれているので非常にわかりやすい
- テキトーに作って動かしてみると、わかりやすいエラーが出て、次に何をしたらいいのかすぐわかる
気になること(次回以降?)
- 非同期 (cast) だとどうなる?
- タイムアウト
- 意図しないイベントを受け取った時の処理