Help us understand the problem. What is going on with this article?

Elixirでgen_statemを使ってみる

More than 1 year has passed since last update.

環境

$ 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 というステータスに居て、 offpush ってイベントが起きたけど、
それを処理するための 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
  • イベントの発火は callcast
  • :state_functions モードのときには、状態名と同じ関数を定義し、 :next_state で次の状態へ遷移。
  • call に戻り値を返すには、state function で {:reply, from, 返答内容} というアクションを返してあげないといけない

感想

  • タイムアウトを扱ってたり、callback_mode なんてのを選べたり、同期・非同期が選べたり、この他にも今回述べてないけど、エラーが起きて terminate するときの振る舞いをカスタマイズできたり、とにかくいろんなところがカスタマイズできるので、フツーの状態遷移ライブラリに比べると理解するのに時間がかかる
  • 型が非常にしっかり定義されてたり適切な名前がついてたりするし、それがマニュアルにちゃんと書かれているので非常にわかりやすい
  • テキトーに作って動かしてみると、わかりやすいエラーが出て、次に何をしたらいいのかすぐわかる

気になること(次回以降?)

  • 非同期 (cast) だとどうなる?
  • タイムアウト
  • 意図しないイベントを受け取った時の処理
Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away