やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

【OTP入門】ElixirとOTPを使ってスタックサーバーを実装するまで

そもそもOTPとは

一言で言えば、Erlangで用意された便利なライブラリなどの集合体で便利ツールをまとめたものという認識をしている
OTPとはopen telecom platoformの略で当初は堅牢性が重要な電話交換機を開発するために使用されていた
今になってはElixirでもOTPを使用して気軽に様々なアプリを作成することが可能

OTPには behaviour(ビヘイビア: 振る舞い、動作)というルール(規約)、役割のようなものが数多く用意されている
ドラクエでいう職業のようなもので、それぞれのビヘイビアによって実行する内容や役割が異なる

今回、扱うのは「GenServer」と言われるビヘイビアでサーバーを実装するためのものだ
このGenServerを使用することでstateを保持するサーバーを簡単に実装することが可能
関数型言語でどのように値を保持しているかという内容を補足的に解説しておくと、以前から扱っているアキュムレーターがまさにそれである
内部的には再帰関数によってアキュムレーターによる値の保存を行なっている
しかし、それを毎度毎度、記述するのはどうなのよということでGenServerの出番というわけだ
アキュムレーターについて解説した記事もあるので宜しければ、ご覧ください

www.okb-shelf.work

また合わせて「Supervisor」と言われるプロセスの死活管理のためのビヘイビアについても軽く扱う
戦士(GenServer)と僧侶(Supervisor)の二人組のパーティを作っていくと思ってもらえれば良い
戦士(GenServer)が死亡した際に、僧侶(Supervisor)が面倒をみてくれる
Supervisorは優秀で彼が使う蘇生呪文は「ザオリク」であり、100%プロセスを復活させる(例え話ね)
そのためOTPを使ったアプリケーションは高い保守性と堅牢性とトラッキング(ログ、行動管理など)を実現することが出来る

GenServerについて

先ほども説明した通り、用意されたビヘイビアの1つで値を保持するサーバーを実装するために今回は用いる
GenServerにはクライアントのリクエストを捌くための2つの関数が用意されている

  • handle_call -> 同期関数で戻り値を指定可能
  • handle_cast -> 非同期関数で戻り値を指定不可

自身では戻り値があるやつとないやつ程度のレベルで理解している
このhandle_callとhandle_castがクライアントからのリクエストを受け付ける関数で第1引数のアトムでパターンマッチをして対象の関数を呼び分ける
第2引数はpid(Process Identifier(プロセス認識子))が渡り、第3引数にはサーバーで保持しているstateが渡る
今回は簡略化のためmoduleでwrapしていないが、実際にはそうではない。詳しくはまた後ほど記述するだろう

handle_call

# 戻り値は{:reply, クラインとに返したい値, サーバーのstateとして更新する値}を記述する
# stateを1に
def handle_call(:one, _form, _state), do: {:reply, 1, 1}

# stateを2に
def handle_call(:two, _form, _state), do: {:reply, 2, 2}

# stateを3に
def handle_call(:three, _form, _state), do: {:reply, 3, 3}

handle_cast

# 戻り値は{:noreply, サーバーのstateとして更新する値}を記述する
# stateを0にしてリセット
def handle_cast(:reset, _state), do: {:noreply, 0)

こんな感じでcallもcastも同名の関数でパターンマッチを使用して処理を分岐させてるんだなーという理解をしてもらえれば100点だ
他にも

  • init()
  • handle_info()
  • terminate()
  • code_change()
  • form_status()

という関数も用意されているがそれぞれについて詳しくは触れない

いまさらながらスタックについて

有名なデータ構造の1つでいわゆる「後入れ先出し」という動きをとる
スタックにはpush(追加)とpop(取得&除去)という2つの動き方がある
リストを使って説明をすると以下のようになる
なお、先頭要素は1で末尾要素は5なので1.皿の上に2.皿が。2.皿の上に3.皿が....というイメージをしてみてほしい

# 最初のデータ
init_stack_values = [1,2,3,4,5]

# 6をpush
stack_values = [1,2,3,4,5,6]

# 99をpush
stack_values = [1,2,3,4,5,6,99]

# pop(末尾の値を取得して取り除く)
popped_value = 99
stack_values = [1,2,3,4,5,6]

# pop
popped_value = 6
stack_values = [1,2,3,4,5]

今回はこのpushとpopに加えて、現在のstackの状態を知らせるinfoという関数も実装する

プロジェクトの作成

一通り説明したところでさっそくスタックサーバーを作成する
まずはプロジェクトを新規作成する。その際に「--sup」というオプションを付与しておく
これは後にsupervisorを実装する際に必要となるので指定しておく

$ mix new --sup stack

コマンドの実行後、stackというディレクトリが生成され、中には様々なファイルが生成される
./ilb/stack にserver.exを新規作成して以下の記述をする

.lib/stack/server.ex

defmodule Stack.Server do
  # GenServerを使うということを宣言(必須)
  use GenServer

  # GenServerの立ち上げ用の関数
  def start_link(init_lst) do
    # arguments: 1 -> 対象のモジュール, 2: -> stateの初期値, name: -> プロセスに名前付け
    # __MODULE__ -> Stack.Serverが参照される
    GenServer.start_link(__MODULE__, init_lst, name: __MODULE__)
  end

  # 以下、handole_castとhandle_callでpopとpushとinfoを実装
  def handle_call(:pop, _form, state) do
    [head | tail] = Enum.reverse(state)
    {:reply, head, Enum.reverse(tail)}
  end

  # 値を渡したい時は第1引数をタプルにする
  # 第1引数: {:push(パターンマッチ用), 渡された値}
  def handle_cast({:push, value}, state) do
    {:noreply, state ++ [value]}
  end

  def handle_call(:info, _form, state) do
    {:reply, state, state}
  end
end

さっそく動作を確認してみる
GenServerのプロセスを立ち上げて、スタックの動きを確認する

$ iex -S mix

iexのプロンプトが立ち上がる

iex> Stack.Server.start_link([1,2,3])
{:ok, #PID<0.148.0>}

iex> GenServer.call(Stack.Server, :pop)
3

iex> GenServer.cast(Stack.Server, {:push, 99})
:ok

iex> GenServer.call(Stack.Server, :info)
[1, 2, 99]

どうやら一連の動きは問題なく稼働しているようだ
しかしながら、例外を無理矢理、発生されると当然ながらエラーになる(stackを空にしてpop)

iex> GenServer.call(Stack.Server, :pop)
99

iex> GenServer.call(Stack.Server, :pop)
2

iex> GenServer.call(Stack.Server, :pop)
1

iex> GenServer.call(Stack.Server, :info)
[]

iex> GenServer.call(Stack.Server, :pop)
22:11:06.670 [error] GenServer Stack.Server terminating
** (MatchError) no match of right hand side value: []
:

一度、プロセスがcrashするともうGenServerの関数を呼び出しても反応がなくなる

iex> GenServer.call(Stack.Server, :info)
** (exit) exited in: GenServer.call(Stack.Server, :info, 5000)
:

再度、GenServer(プロセス)を立ち上げてやれば問題はない

iex> Stack.Server.start_link([11,22,33])
{:ok, #PID<0.163.0>}

iex> GenServer.call(Stack.Server, :info)
[11, 22, 33]

しかしながら面倒だ
Elixir、Erlangでは死んだプロセスは破棄して新たにプロセスを立ち上げてやれば良いという方針で
こんな時にSupervisorが役に立つ。死んだプロセスを破棄して新たなプロセスを立ち上げてくれる

supervisorを設定する

今回はシンプルなsupervisorを設定する
iexプロンプトの立ち上げ時にGenServer(プロセス)の立ち上げとプロセスがcrashした際に新規にプロセスを立ち上げるようにしてもらう
--supをプロジェクト生成時に渡しているため、以下のファイルが生成されているはずだ

./lib/stack/application.ex

defmodule Stack.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications
  @moduledoc false

  use Application

  def start(_type, _args) do
    # List all child processes to be supervised
    children = [
      # Starts a worker by calling: Stack.Worker.start_link(arg)
      # {Stack.Worker, arg}
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: Stack.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

初期の状態は上記のようになっているので、これを以下のように変更する

defmodule Stack.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications
  @moduledoc false

  use Application

  def start(_type, _args) do
    # モジュールのimport
    import Supervisor.Spec, warn: false
    # List all child processes to be supervised
    children = [
      # 起動したいGenServer(モジュール)を指定し、第2引数にstateの初期値をリストで渡す
      worker(Stack.Server, [[1,2,3]])
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: Stack.Supervisor]
    Supervisor.start_link(children, opts)
  end

プロンプトを一度、終了して再度立ち上げる

$ iex -S mix

すでにGenServer(プロセス)が立ち上がっているのが分かる

iex> GenServer.call(Stack.Server, :info)
[1, 2, 3]

そしてわざとcrashさせてみる。先ほどは再度、GenServer(プロセス)を立ち上げたが今回はどうか

iex> GenServer.call(Stack.Server, :pop)
3
iex> GenServer.call(Stack.Server, :pop)
2
iex> GenServer.call(Stack.Server, :pop)
1
iex> GenServer.call(Stack.Server, :pop)
22:31:05.161 [error] GenServer Stack.Server terminating
** (MatchError) no match of right hand side value: []
:

iex> GenServer.call(Stack.Server, :info)
[1, 2, 3]

supervisorによってGenServer(プロセス)が新たに作成されているようだ
初期値は先程、applicationの初期値として指定したものが与えられている
crash前の値を保持できないの?と当然思うが、別のプロセスを用意してそちらへ保管させることで可能である
そのやり方についてはプログラミングElixirにも記述があるが別の記事にまとようと思う

例外処理と関数の整理

最後にGenServerの利便性を上げるためにちょっとした修正をする
毎回、GenServer.call(Stack.Server, :info)とやるのは面倒だし、よりシンプルにスタックを利用したいため、関数を以下の様に整備した
またスタックが空になった時に、errorになってしまう問題を解決するために再帰関数でお馴染みのパターンマッチを用意する

defmodule Stack.Server do
  use GenServer
  def start_link(init_lst) do
    GenServer.start_link(__MODULE__, init_lst, name: __MODULE__)
  end

  # pop関数を実装 -> GenServer.callを呼び出す
  def pop(), do: GenServer.call(__MODULE__, :pop)

  # push関数を実装 -> 受け取った値をGenServer.castに渡して呼び出す
  def push(val), do: GenServer.cast(__MODULE__, {:push, val})

  # info関数を実装
  def info(), do: GenServer.call(__MODULE__, :info)

  # stackが空になった場合に例外処理が発動する様に関数を追加
  def handle_call(:pop, _form, []), do: {:reply, [], []}
  def handle_call(:pop, _form, state) do
    [head | tail] = Enum.reverse(state)
    {:reply, head, Enum.reverse(tail)}
  end

  def handle_cast({:push, value}, state) do
    {:noreply, state ++ [value]}
  end

  def handle_call(:info, _form, state) do
    {:reply, state, state}
  end
end

こうしてやることでよりシンプルで利便性よくGenServerを利用することが可能だ

iex> Stack.Server.pop()
3
iex> Stack.Server.pop()
2
iex> Stack.Server.push(99)
:ok
iex> Stack.Server.info()
[1, 99]
iex> Stack.Server.push(999)
:ok
iex> Stack.Server.info()
[1, 99, 999]
iex> Stack.Server.pop()
[]

呼び出しがシンプルになり、例外のerrorが無事に発生しなくなった
いいね

感想というか語らせて

以前からOTPというものの存在を理解はしていたが、どちらかというとElixirのsyntaxや仕様ばかりに着目していた
今回OTPを自身でまとめてみたのには理由がある
知人の方からElixirでリモートワークを募集しているページを見せて頂いた
求人の要項には

  • OTPへの理解がある方
  • phoenixへの理解がある方
  • 新しいものへの挑戦心

という項目が多かった。3つ目はばっちりだとしても上記の2つはよく分かっていない
特にOTPはそうで、どこで使うだとか、全くイメージできていないレベル感で「こりゃあかんな」と思った
今まで趣味レベルでのElixirへのアプローチをとってきたが少し、仕事を意識してしまったというのが今回の記事が生まれた経緯となる
というか絶対に落ちないサーバーってめちゃくちゃかっこいいやん(joe曰く99.9999%落ちない)
なんでスルーしてたんだろうと反省

ようやく入門できたかなと自己満足して終了

参考文献

プログラミングElixir
What is OTP?
Elixir初心者がOTPって結局なんなのか調べてみた
[翻訳] ElixirにおけるOTPの紹介
スタック