[翻訳] ElixirにおけるOTPの紹介 という記事が上がってた (いつも翻訳ありがとうございます) のでそれに触発されて勢いで書いてみる。
ちなみに今後発売される Web+DB PRESS Vol.88, 89 の連載では2回続けて Elixir の記事を書いているよ。
もとい
- Elixir 状態を扱うにはどうするのが定番か
- 軽量プロセスを使ったクライアント/サーバーのありがちな実装
- それを GenServer を使うとどう書けるのか
- GenServer 使うとどんないいことがあるのか
あたりを解説してみる。
Elixir のプロセスで状態を扱う
Elixir で状態を扱いたい場合は軽量プロセスの仕組みを使って、プロセスの中に状態を閉じ込めて、そのプロセスとのメッセージパッシングで状態への操作を行うのが定石である。
その例として、例えば key + value を保存できるプロセスを作ってみる。
- KVS という名前で実装を作る
- KVS には
store/2
とlookup/1
という API を持たせて、それぞれ key, value の保存と取得を行う -
store/2
とlookup/1
は実際はプロセスとのメッセージパッシングのラッパ - プロセスは、プロセス辞書 (プロセスに固有の dict) にとりあえず値を保存する
という方針で、まずは作ってみる。
KVS の API としてはこんな感じになる。
KVS.start
KVS.store(:weather, :raining)
IO.inspect KVS.lookup(:weather) # :raining が返る
以下、その実装。(ちょっと記憶があやふやだけどプログラミングErlangに載ってた例を Elixir で書き換えたものだったきがする)
defmodule KVS do
def start do
pid = spawn_link(fn -> loop() end)
Process.register pid, :kvs
end
def store(key, value) do
rpc {:store, key, value}
end
def lookup(key) do
rpc {:lookup, key}
end
def rpc(q) do
send :kvs, {self(), q}
receive do
{:kvs, reply} -> reply
end
end
def loop do
receive do
{from, {:store, key, value}} ->
Process.put(key, {:ok, value})
send from, {:kvs, true}
loop()
{from, {:lookup, key}} ->
send from, {:kvs, Process.get(key)}
loop()
end
end
end
start/0
が呼ばれると spawn_link/1 でプロセスが立ち上がり、イベントループの loop/0
に入る。loop/0
は再帰になっていて、クライアントからのメッセージを receive/0
し続ける。
reveive/0
ではクライアントからのメッセージの内容に合わせて、値を保存するのか、取得するのかを判断する。
こんな具合である。特に難しいところはないと思う。
なお、この KVS サーバーはメッセージを受け取って結果をクライアントにまたメッセージで返信している。間違ったクライアントにメッセージを送ってしまわないように
send :kvs, {self(), q}
と、クライアントは自分の pid (self()
) をつけてメッセージを送り、サーバー側は
receive do
{from, {:store, key, value}} ->
Process.put(key, {:ok, value})
send from, {:kvs, true}
と、その受け取りもと (from
) に対して返信を行っている。
OTP ビヘイビア ─ GenServer
この、プロセスに状態をもたせてメッセージパッシングでやりとりする、みたいなのは Erlang や Elixir でしょっちゅう実装する。つまり、パターンである。
Erlang/Elixir においてはその手の頻出パターンを「ビヘイビア」と呼んで形式化している。形式化? まあようするに、そのパターンを実装するためのフレームワークとかインタフェースを用意してくれてるってこと。
今回の KVS のようなクライアント/サーバー実装は GenServer がそのビヘイビアモジュールである。
先の実装を GenServer を使って書き換える。
defmodule KVS.Server do
use GenServer
def store(key, value) do
GenServer.cast(:kvs, {:store, key, value})
end
def lookup(key) do
GenServer.call(:kvs, {:lookup, key})
end
# GenServer が実装を要求してくるコールバック
def start_link do
GenServer.start_link(__MODULE__, HashDict.new, name: :kvs)
end
def handle_cast({:store, key, value}, state) do
{:noreply, Dict.put(state, key, value)}
end
def handle_call({:lookup, key}, _from, state) do
{:reply, state[key], state}
end
end
以下のように使う。
KVS.Server.start_link
KVS.Server.store(:weather, :fine)
IO.inspect KVS.Server.lookup(:weather)
receive/0
しつづける実装とか、クライアントからの pid を受け取ってそいつに send
するとかその辺の記述が一切なくなっている。GenServer は他の言語でいうところの抽象クラスみたいなもの。receive とか send とかその辺は GenServer 側でやるから、自分とこの処理は handle_cast/3
や handle_call/3
という形でコールバックで実装しろや、というものである。
それらのコールバックでは戻り値のデータ構造にも決まりがある。{:reply, state[key], state}
とかね。このタプルの2番目の要素がクライアントへのメッセージで、3番目の要素がこのプロセスが扱ってる状態。
なお、先の実装では状態の扱いに プロセス辞書を使っていたが、ここでは HashDict を引き回すことでそれを達成している。
ビヘイビアに乗っかったおかげで ─ Supervisor
GenServer を使うと、こんな感じでサーバーの実装を端折れる・・・というのもあるんだけど重要なのはそこというよりは次で、ビヘイビアに従っておけば、そのビヘイビアを前提にしてる他の部品の恩恵に与れる、ということである。
代表的なのが Supervisor。Erlang や Elixir では、プロセスがエラーになったらどうすんべ、という問題に対して「プロセス自身ががんばってそれを維持しようとするんでなく、プロセスは構わずクラッシュして死んでしまえ。その代わり他のプロセスでそいつを監視して、再起動するとかエラー戦略練ってやるから」という方針で対処するのが良いアプローチとされる。Supervisor は、その監視する側の機能を実装してくれてるモジュールである。
先の KVS.Server を Supervisor で監視させてみよう。
Mix でプロジェクトを作る時に --sup
オプションを付けると Supervisor を使うアプリケーションとしてプロジェクトを初期化できる。
$ mix new --sup --module=KVS kvs
KVS.Server はそのまま lib/kvs/server.ex に置く。
そして、lib/kvs.ex ・・・ これは --sup
オプションを付けると自動で生成されてるんだけど、これの children に KVS.Server を指定する。
defmodule KVS do
use Application
def start(_type, _args) do
import Supervisor.Spec, warn: false
children = [
# ここ追加
worker(KVS.Server, [])
]
opts = [strategy: :one_for_one, name: KVS.Supervisor]
Supervisor.start_link(children, opts)
end
end
これだけで OK。KVS.Server は一切弄らずに Supervisor から監視されるようになった。ビヘイビアに乗っかったおかげである。
以下動作確認。
$ iex -S mix
iex(1)> KVS.Server.store(:weather, :file)
:ok
iex(2)> KVS.Server.lookup(:weather)
:file
先ほどの kvs.ex をみると
opts = [strategy: :one_for_one, name: KVS.Supervisor]
Supervisor.start_link(children, opts)
こんな感じで、監視した結果エラーになったらどうする? というのに対して戦略 (strategy) を指定している。:one_for_one
は確か死んだら再起動しろ、ってことだったはず。他にも死んだら監視してるプロセス全部まとめて再起動とか、パラメータ変えて再起動とかいろんな戦略を指定することができる。
(そこまでは試すの面倒なのでやってないけど) なので、このサーバーをクラッシュさせても何事もなかったように生き返ります。
まとめ
- 状態はプロセスで扱う
- その辺のパターンはビヘイビアとして形式化されててフレームワーク化されてる
- フレームワークに乗っかると Supervisor そのほかいろんな恩恵に与ることができる
ちなみに Phoenix で生成したコードを見てると、Phoenix もまたこの辺のビヘイビアに従って Supervisor や Cowboy と繋がってることがわかって面白いと思う。