(この記事は、fukuoka.ex Elixir/Phoenix Advent Calendar Advent Calendar 2018 の13日目です)
昨日は @tuchiro さんのBehaviourとMix.Configで切り替え可能なStubを実装するでした。
今日は今年取り組んだ Elixir 修行の集大成を書こうと思います。Elixir 修行することになった原因は Elixir を使うようになった経緯 〜電力システム制御の現場から〜 に書きましたので合わせて御覧ください。
題材として電灯のon/offのプログラムを披露します。2階建ての家にはよくある階段の電灯とそれを操作するスイッチです。階段の上と下とのどちらでもONにできてどちらでもOFFにできる… ってよくありますよね。広い部屋の電灯を部屋の両端のスイッチどちらでも操作できるってのもありますね。これ不思議な感じですが電気回路でやるとまあ簡単な回路でできるんです。そういうのをシミュレートするような Elixir プログラムを作ってみましょう。中に色々と小技を入れてみたので、どうぞお楽しみください。
仕様を考える
プログラムの外部仕様はこんなのです。
- 階段の上と下とにスイッチがある
- パチパチすると左か右を向いて安定する
- スイッチ自体には on とか off とかの概念はない
- 電灯がある
- スイッチを1回パチンと切り替えると点灯・消灯が切り替わる
- 階段の上のスイッチでも下のスイッチでも、どちらのスイッチでも操作できる
私の今年の集大成としてこういう技を入れてみてます。
- プロセスで状態を表現する(GenStateMachineを使いました)
- スイッチ2つそれぞれがプロセス
- 電灯を制御するメインもプロセス
- 大事なプロセスはスーパバイザの下にぶら下げる
- 今回はメインをぶら下げました
あと、今まで使ったことのない Logger と Registry ライブラリを使ってみました。
入出力について
スイッチも電灯も物理的なデバイスでやりたいのはやまやまなんです。デジタルの入出力しかないので、ラズパイとかでも簡単にできるはず。しかしながら準備が間に合わず。
- スイッチ操作:コンソールから特定の関数を呼び出す
- 電灯:コンソールに状態を表示する
というところに留めました。
スイッチとボタン
とずっとここまで スイッチ と書いてきました。がここに来て、途中まで書いてたプログラムでは Button と記載していることに気づきました。しまった!
スミマセンがここからは ボタン と言わせてもらいます。
ちなみにスイッチは切替器なので安定な場所が2つ(ないしもっと)あって、人間の操作でどこかに落ち着くという感じでしょう。今回はこっちなんです。ボタンは安定してる場所があって、人間の操作で不安定な位置に行くけど、操作をやめるともとに戻る… 感じですね。いやいやまずった。
ログ出力とデバッグ出力について
ログやらは今回全部 Logger ライブラリの関数を呼んでます。Elixir の Logger 関数は error, warn, info, debug と4種類のレベルのメッセージを出せます。これを以下のように分類して出力するようにしました。
- error: 本当にバグってるとき
- warn: 電灯の点灯/消灯に関するメッセージ
- info: ボタン操作に関するメッセージ
- debug: プロセスの起動時に出すメッセージ
なお、Logger は外部ライブラリで拡張することができて、たとえば syslog に出力ができたりします。今回はそこまでやらずにコンソールにログを出力します。
syslog で脱線 (MACOSX と Elixir のライブラリ)
ここで電灯についてのメッセージをなぜ warning 扱いにするかというと、Mac OSX の syslog に吐かせると、info レベル以下を console に出力してくれないからです。ですので、MAC のコンソールでも必ず見ることのできるのは warn と error の2つだけになります。error は本当のエラーに割り当てたいので warn に割り当ててます。
なお MAC の syslog 関係はそれなりに改変されてて、info 以下はコンソールアプリにも出てきませんし、どのファイルにも残せません。いろいろ試したんですが、私の技量では解決できませんでした。普通の UNIX なら syslog で難なく全部のレベルが出せるでしょう。
Logger の分類が syslog の分類より少ないのも不思議です。なんで全部用意しないんでしょうね。大した手間でもないと思うのですが。拡張するのも難しくなさそうに思いますが、時間がないので今回はこのままでやります。
プログラムを作る
では実際に作ったプログラムを披露します。
準備をする
例によって、プログラムを作る前に作業ディレクトリの準備をします。
mix コマンドの実行
最初に作業したいディレクトリに行って mix new --sup stair
を実行します。ここで stair は私が勝手に決めてますので、もちろんお好きなのをどうぞ。以降は stair であるものとして書き進めます。
$ mix new --sup stair
* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating config
* creating config/config.exs
* creating lib
* creating lib/stair.ex
* creating lib/stair/application.ex
* creating test
* creating test/test_helper.exs
* creating test/stair_test.exs
Your Mix project was created successfully.
You can use "mix" to compile it, test it, and more:
cd stair
mix test
Run "mix help" for more commands.
とたくさん作ってくれました。
mix.exs を確認する
以下を確認してください。特に mod: {Stair.Application, []} が大事です。これは最初にどのモジュールが起動されるかが記述されてます。この例では Stair.Application.start/2 が起動されます。
def application do
[
extra_applications: [:logger],
mod: {Stair.Application, []}
]
end
mix.exs を編集する
今回は外部ライブラリ(って言うのかな、要は標準じゃないライブラリ)の GenStateMachine を使います。
先程のディレクトリの下に stair ディレクトリができてますので、その下の mix.exs を編集します。1行だけ {:gen_state_machine, "~> 2.0.4"}
を依存関係リストに入れてください。編集前と編集後の diff を示します。
$ diff -c mix.exs{.org,}
*** mix.exs.org 2018-12-13 08:11:52.000000000 +0900
--- mix.exs 2018-12-13 08:15:54.000000000 +0900
***************
*** 22,27 ****
--- 22,28 ----
# Run "mix help deps" to learn about dependencies.
defp deps do
[
+ {:gen_state_machine, "~> 2.0.4"}
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
]
これをやったら mix deps.get
を実行します。余談ですが、私 mix deps get
とやっててハマったことがあります。
$ mix deps.get
Resolving Hex dependencies...
Dependency resolution completed:
New:
gen_state_machine 2.0.4
* Getting gen_state_machine (Hex package)
うまく持ってこれるようになったようです。準備完了したので順に書いていきましょう。プログラムを lib/stair の下に創っていきます。
アプリケーション application.ex
このモジュールの start が最初に起動されるプログラムです。
defmodule Stair.Application do
use Application # これは雛形にもある。必ず use すること
require Logger # ログ出力用
def start(_type, _args) do
{:ok, _} = Registry.start_link(:unique, Stair) # プロセスの管理用に Registry を使う
{:ok, pid} = Stair.Supervisor.start_link(:sweet) # スーパバイザの立ち上げ
{:ok, _} = Stair.Button.start_link(:upstair) # 階上のボタンのプロセスを起動
{:ok, _} = Stair.Button.start_link(:downstair) # 階下のボタンのプロセスを起動
{:ok, pid} # この関数の戻り値として Supervisor.start_link の戻り値を使うこと
end
end
注意としては、まずは Registry を起動してください。これは次に立ち上げる Supervisor の中(の中の Main)で使うからです。あと、Supervisor を立ち上げた後にアレコレするので(この場合はボタンプロセスの立上げ)Supervisor の戻り値を pid に一旦バインドして、最後にこの関数自体の戻り値とするようにしてあります。
ログをコンソールに出すのに Logger ライブラリを使います。これはいずれ、ファイルとか syslog にログを吐かせることを意図しています。デバッグ用に IO.puts や IO.inspect を使う人は不要です。
なお本質的ではありませんが、メインのプロセスの名前を :sweet と、階上/階下のボタンのプロセス名を :upstair / :downstair としてあります。このあたりはお好みです。
スーパバイザ supervisor.ex
スーパバイザの振る舞いを記述します。コールバック関数というか監視対象に、処理の本丸の Stair.Main を指示しています。
defmodule Stair.Supervisor do
use Supervisor
def start_link(house_name) do
{:ok, _sup} = Supervisor.start_link(__MODULE__, house_name)
end
def init(house_name) do
children = [worker(Stair.Main, [house_name])]
supervise(children, strategy: :one_for_one)
end
end
ボタンのプロセス button.ex
階上と階下のボタンは同じ仕様です。application.ex には同じモジュールから2つのプロセスを生成するように記述してあります。
起動時に左に倒れていて、パチパチするたびに右に倒れて、また左に倒れます。パチるたびにメインの flop 関数を叩きに行きます。物理的なデバイスがないので、ここでは flip/1 関数を呼ぶことで、実際の操作に代えています。
defmodule Stair.Button do
use GenStateMachine # 状態を扱うライブラリ
require Logger # ログ出力用
def start_link(button_name) do # ボタンプロセス起動用です
Logger.debug("Button ==#{button_name}== starts")
GenStateMachine.start_link(__MODULE__, button_name, [name: button_name])
end
def flip(button_name) do # ボタンをパチパチするときの動作です
GenStateMachine.cast(button_name, :flip)
end
def get_status(button_name) do # ボタンの状態を見ます。
GenStateMachine.call(button_name, :get_status)
end
# ここまでがキレイに見せるためのラッパ関数、ここからがコールバック関数
def init(button_name) do
{:ok, :left, button_name} # 初期状態としてはボタンが左側に倒れてます
end
def handle_event(:cast, :flip, :left, button_name) do
Logger.info("button ==#{button_name}== turns to RIGHT")
Stair.Main.flop() # パチとなったらメインに伝えます
{:next_state, :right, button_name} # 左に倒れてたのをパチると右に倒れます
end
def handle_event(:cast, :flip, :right, button_name) do
Logger.info("button ==#{button_name}== turns to LEFT")
Stair.Main.flop() # パチとなったらメインに伝えます
{:next_state, :left, button_name} # 右に倒れてたのをパチると左に倒れます
end
def handle_event(:cast, ope, state, button_name) do # ここには来ないはず
Logger.error("#{button_name} illegal button operation: #{ope}")
{:next_state, state, button_name}
end
def handle_event({:call, from}, :get_status, state, button_name) do # 状態を見るために使います
{:next_state, state, button_name, [{:reply, from, {state, button_name}}]}
end
end
get_status/1 や handle_event で :call を受け取ってるのは、実行中のデバッグ用で、今回は使いません。
ランプを点灯消灯するメインモジュール main.ex
ここまで↑はまずまずシンプルでした。これだけちょっと複雑になってます。
defmodule Stair.Main do
use GenStateMachine # 状態を扱うライブラリ
require Logger # ログ出力用
def start_link(house_name) do # メインの起動
Logger.debug("#{__MODULE__} started named #{house_name}")
GenStateMachine.start_link(__MODULE__, house_name, [name: house_name])
end
def change_state(house_name, event) do # デバッグ用:状態を手動で変更する
GenStateMachine.cast(house_name, event)
end
def get_status(house_name) do # デバッグ用:状態を見るために使います
GenStateMachine.call(house_name, :get_status)
end
def flop() do # ボタンをパチると呼ばれる関数(詳細は後述)
Registry.dispatch(Stair, __MODULE__, fn entries ->
for {pid, _ignore} <- entries,
do: GenServer.cast(pid, :change_lamp_state) end)
end
# ここまでがキレイに見せるためのラッパ関数、ここからがコールバック関数
def init(house_name) do
Registry.register(Stair, __MODULE__, []) # プロセスを登録。上の flop/0 関数で使用する
Logger.warn("Lamp is DARK")
{:ok, :off, house_name} # 電灯は初期状態では消灯している
end
def handle_event(:cast, :change_lamp_state, :off, house_name) do
Logger.warn("Lamp becomes BRIGHT") # デバイスの状態を変更する
{:next_state, :on, house_name} # :off 状態から変化があると :on に
end
def handle_event(:cast, :change_lamp_state, :on, house_name) do
Logger.warn("Lamp becomes DARK") # デバイスの状態を変更する
{:next_state, :off, house_name} # :on 状態から変化があると :off に
end
def handle_event(:cast, event, state, house_name) do # ここには来ないはず
Logger.error("No such transition #{event} at #{state} in #{house_name}")
{:next_state, state, house_name}
end
def handle_event({:call, from}, :get_status, state, house_name) do
{:next_state, state, house_name, [{:reply, from, {state, house_name}}]}
end
end
Logger.warn("Lamp becomes BRIGHT")
や Logger.warn("Lamp becomes DARK")
とある部分、今回は実際にはライトを点灯するデバイスがないので、代わりにこのデバッグプリントをしています。もしそういう物理デバイスがあるのなら、それを操作する関数をここで呼べば良いです。
なお change_lamp_state/2 はデバッグ時に手動で状態を変えるための関数で、今回は使いません。あと、ここでも get_status/1 や handle_event で :call を受け取ってるのは、実行中のデバッグ用で、今回は使いません。
プロセスを管理するのに Registry を使う
ここで、ちょっとだけ工夫した点があります。(良い工夫なのかちょっとわかりませんが。)
ボタンがパチられたとき、メインの関数を叩いて電灯の off/on の制御を要請します。ここなんですが、ボタン側はメインのプロセスとは独立して起動されてるプロセスです。メイン側の関数を叩くときに main.ex でどのような引数で start_link/2 をしたのかはわかりません。
具体的に言うと、handle_event/4 関数の最後の引数(ここでは house_name)がわからないので、呼べないのです。しかし、Button モジュールでは大域変数も持ってませんし、main を呼び出すためにずっと関数の引数に house_name を持ち回るのも冗長な感じがします。そこで用いたのが標準ライブラリにある Registry です。
def flop() do
Registry.dispatch(Stair, __MODULE__, fn entries ->
for {pid, _ignore} <- entries,
do: GenServer.cast(pid, :change_lamp_state) end)
end
def init(house_name) do
Registry.register(Stair, __MODULE__, [])
Logger.warn("Lamp is DARK")
{:ok, :off, house_name}
end
全体の起動時に application.ex で {:ok, _} = Registry.start_link(:unique, Stair)
を実行しています。これで Stair というレジストリが生成されています。
次に Main のプロセスの起動時にコールバックの init/1 が呼ばれます。ここで Registry.register(Stair, __MODULE__, [])
を実行して、レジストリに自分自身を登録します。登録名はこのモジュール名すなわち Stair.Main です。
こうしておくと(名前さえ覚えておけば)後で Registry.dispatch/3 関数によって登録されてるプロセスの関数を呼び出すことができます。この例では Main.flop/0 です。このように記述すると、登録されてるプロセス全てに対して無名関数を適用できます。
実行してみる
では親ディレクトリに戻って実行してみましょう。
$ iex -S mix # 起動します
Erlang/OTP 21 [erts-10.1.2] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
14:54:53.756 [debug] Elixir.Stair.Main started named sweet
14:54:53.759 [warn] Lamp is DARK
14:54:53.760 [debug] Button ==upstair== starts
14:54:53.760 [debug] Button ==downstair== starts
Interactive Elixir (1.7.4) - press Ctrl+C to exit (type h() ENTER for help)
実行すると、デバッグプリントでメインと階上/階下のボタンのプロセスが起動したのが分かります。電灯は消灯状態です。パチパチをしてみましょう。
iex(1)> Stair.Button.flip(:downstair) # 階下でパチると点灯
14:55:41.668 [info] button ==downstair== turns to RIGHT
14:55:41.668 [warn] Lamp becomes BRIGHT
:ok
iex(2)> Stair.Button.flip(:downstair) # 階下でもう一回パチると消灯
:ok
14:55:45.851 [info] button ==downstair== turns to LEFT
14:55:45.851 [warn] Lamp becomes DARK
iex(3)> Stair.Button.flip(:upstair) # 階上でパチっても点灯できる
:ok
14:55:51.127 [info] button ==upstair== turns to RIGHT
iex(4)>
14:55:51.127 [warn] Lamp becomes BRIGHT
iex(4)> Stair.Button.flip(:upstair) # 階上でも2回パチると消灯
14:55:53.058 [info] button ==upstair== turns to LEFT
:ok
14:55:53.058 [warn] Lamp becomes DARK
ここまでは同じ場所のボタンをパチってます。これが場所が違っても動くのかの確認をします。
iex(5)> Stair.Button.flip(:downstair) # 階下で点灯して…
:ok
14:55:56.499 [info] button ==downstair== turns to RIGHT
14:55:56.499 [warn] Lamp becomes BRIGHT
iex(6)> Stair.Button.flip(:upstair) # 階上で消灯もできます
:ok
14:55:58.387 [info] button ==upstair== turns to RIGHT
14:55:58.387 [warn] Lamp becomes DARK
iex(7)>
うまく動いてるようです。
考察
お見せしてるのは完成したやつで、途中の紆余曲折が見えないです。ここでは自分の思考履歴を思い出しながら、ポイントをまとめてみます。
有限状態機械について
今回は動作が簡単だったので、状態遷移と言っても、ボタンも本体もそれぞれ2状態しか持ちませんでした。本体側ではボタンの状態を持たないように作ったこともあり、本当に簡単なアルゴリズムに落ち着いています。
これがもう少し複雑になってきたら、処理をどのモジュールに持たせるか、あるいは物理デバイスとは別のモジュールに持たせるかとか、設計で頭をひねることになるかと思います。
分散環境
今回はボタンも電灯も一つのアプリケーション下にありました。これ、複数のアプリケーションや複数のプロセッサやさらに複数のホストで独立にプロセスが動くと素敵です。ちょっと実験はしてるのですが、次に書いてるプロセスの名前空間問題に引っかかって、キレイなプログラムに落とせてないです。来年の早いうちに扱えるようになりたいです。
プロセスの空間
今回何に一番ハマって苦しんだかというとプロセスへのアクセスです。別々に起動してしまったプロセス間でどうやりとりをするか。言うなればプロセスIDを持ってフラットな空間に散らばってしまってるので、なにか取っ掛かりを持ってないとなりません。{:ok, _pid} = ...
なんてやった瞬間にプロセスにアクセスする鍵を捨ててしまってます。とはいえ、このプロセスID情報を持ってても、じゃあどれで持って管理するんだという問題が出ますし、異なるアプリケーションや異なるホストでプロセスを立ち上げたら、それにはどうやってアクセスするのかという問題が残ります。
これ、プロセスツリー、すなわちプロセスの呼出し/呼出され関係によってアプリケーション内にユニークな名前を振るのはできないことはなさそうです。しかしながら、Elixir(というか Erlang)のプロセスの構造に合わせると、そう望むものができるようには思えません。例えばロバスト性を重視して Supervisor を使うなら、そこはできるだけ簡単にして、フラットな構造でプロセスを作りたくなります。プロセス作って、それの子プロセス作って、さらに… とやってそれに準じる名前空間を持ってもあまりハッピーにならないでしょう。
Registry ライブラリを知る前には、複数の要素(ここでなら house_name と button_name)からアトムを構成する関数を作ったりしました。例えば :sweet と :downstair から :sweet_downstair というアトムを作って、ボタンのプロセスを生成するとかです。しかしこれはうまくない。アトムについている文字列に構造をもたせるので、くっつけたりバラしたり、プログラムが汚くなり読解性が悪くなります。
結局は Registry ライブラリを見つけてそれで実装しました。ただし、万事うまく解決したわけではありません。今回は Stair.Main のプロセスにアクセスするのに Stair.Main.flop/0 という関数を使ってます。これ、Stair.Main に閉じてるふうに書いていますが、じつは全然閉じてなくて、Registry を使えばどのプロセスからでも Stair.Main のプロセスを刺激できます。今回は Register 対象の名前にモジュール名を使って、かつ Stair.Main にしか書かなかったのでなんとなく閉じてる風にはできてます。
これは Elixir のアプローチがどうこう以前の根本的な問題です。UNIX のプロセスにしたって、独立した複数のプロセスに寄るプロセス間通信するときには何らかの共通な情報をプロセス同士で交換しないとなりません。Elixir のプロセスでも同じことです。関数型言語のきれいな環境に状態やら、それを表現するためのプロセスやら、入れてしまってるので、そこはグローバルフラットな空間でどう互いを扱うか問題が存在しているというところです。
まだ Registry ライブラリに触れて間もないので、もう少し習熟すると良いことがあるかもしれません。続きはまた今度。
おわりに
簡単な制御装置を Elixir を使って書いてみました。すべてのデバイスをモノごとに独立した Elixir プロセスとしてます。一番重要そうなのは一応スーパバイザ監視下においてます。プロセスから別のプロセスを叩くときに Registry を使ってみました。
まあ、Elixir 漬けになって半年。まずまずなんじゃないでしょうか。楽しんでいただけましたでしょうか。ただ、どうにもまだまだ修行が足りてない感が満載なので、「こう書いたらいいんじゃね?」ってなコメント、募集中です。
明日のfukuoka.ex Elixir/Phoenix Advent Calendar 2018 14日目の記事は, @kotar0 さんのLiveViewというウェブアプリを作る第三の選択肢です。こちらもお楽しみに!
参考
ことしの七転八起というか七転八倒というか、こちらの下にまとめてあります。
はじめてのElixir(0)
今回使った主なマニュアル類はこちら。
GenStateMachine
Elixir v1.7.4 GenServer
Elixir v1.7.4 Supervisor
Elixir v1.7.4 Logger
Elixir v1.7.4 Registry
Registryについては公式ドキュメントに加えてこちらが参考になるかと。
Elixir標準ライブラリRegistryを使ったPub/Sub by @niku さん
Registry について by @mururu さん