最速で知る! ElixirプログラミングとErlang/OTPの始め方【第二言語としてのElixir】
Elixir入門の手引、第1弾となる今回はErlangのVM上のプロセスをElixirで扱う方法を説明し、Elixirでどのようにアプリケーションを構築するのかを解説します。
はじめまして! 大原常徳(おおはら・つねのり)といいます。 今回から2回に分けて「第二言語としてのElixir」というテーマで、プログラミング言語Elixirの入門記事をお届けします。
Elixirは、José Valim氏によって開発されているプログラミング言語です。 最大の特徴は、ErlangのVM上で動作し、Erlangのモジュールを利用できることでしょう。 ちょうど、ScalaがJava VM上で動作し、Javaの関数を利用できるという関係に似ていますね。
ErlangのVM上で動作することから、Elixirには次のような特徴が備わっています。
- 耐障害性
- 高可用性
- 分散アプリケーションの構築のしやすさ
Erlangでは「プロセス間のメッセージパッシング」というErlang独自の概念をうまく使うことで、びっくりするくらいあっさりとこれらの特徴を実現しています。
そこでこの記事では、まずErlangのVM上のプロセスをElixirで扱う方法を説明してから、Elixirでどのようにアプリケーションを構築するのかを解説します。 はじめのうちは慣れない概念に戸惑うかもしれませんが、がんばって読み進めてみてください。
それでは、環境構築から始めましょう!
- Elixirの動作環境を構築する
- プロセスを使ってアプリケーションを構築する
- GenServerから始めるOTP
- スーパバイザービヘイビアによる監視
- Elixirのエコシステムと開発の流れ
- まとめ
- 執筆者プロフィール
Elixirの動作環境を構築する
さまざまなプラットフォームでのインストール方法
プラットフォームごとに、Elixirのインストール手順を見ていきましょう。
ElixirはErlangのVM上で動作するので、 当然ですが実行にはErlangのVMが必要です。 一般的な環境であれば、Elixirのインストールと同時に、ErlangのVMもインストールされます。
macOSにElixirをインストールする
Macユーザーは、Homebrewを使って簡単にElixirをインストールできます。
# brewのパッケージを更新 $ brew update # Elixirのインストール $ brew install elixir
HomebrewでElixirをインストールすると、Erlangも関連パッケージとして同時にインストールされます。
WindowsにElixirをインストールする
Windowsユーザーは、Elixirの公式サイトのダウンロードページからWindows用のインストーラーをダウンロードし、実行してください。
サイトの説明にあるように「Click next, next, …, finish」と進めればインストールできます!
Unix/Linux環境にElixirをインストールする
Unix/Linuxユーザーは、各ディストリビューション毎のパッケージインストール方法で、Elixirパッケージをインストールしてください。
Dockerで環境を構築する
Dockerで動かす場合は、Elixirの公式イメージを利用するだけです。
このDocker環境を起動するには次のようにします。
$ docker run -it elixir
上記を実行すると、後述するIExというElixirの対話環境が、コンテナ内で起動した状態になります。
なお、次のように実行することで、IExではなくシェル(Bash)が起動された状態にすることも可能です。
# コンテナ内でbashを起動(このあとで`iex`と打てばIExが利用できる) $ docker run -it elixir bash
対話環境のIExを起動してみよう
Elixirをインストールすると、対話環境(REPL)としてiex
というコマンドが利用可能になります。 手元のインストール環境で、次のように起動してみましょう。
# iexを起動
$ iex
起動したら、プログラミング教育の伝統に則り、お約束の処理を実行してみましょう。
$ iex Erlang/OTP 19 [erts-8.3.1] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:10] [hipe] [kernel-poll:false] Interactive Elixir (1.4.2) - press Ctrl+C to exit (type h() ENTER for help) iex(1)> IO.puts "hello, world!" hello, world! :ok iex(2)>
Erlang/OTP 19
から始まる3行はiex
の起動メッセージで、 iex(1)>
がiex
のコマンドプロンプト、つまり入力待ち状態を表す表記です。 以後この起動メッセージは省略します。
上の例で入力されているIO.puts
は引数を標準出力に出力する関数で、"hello, world!"
は文字列です。
IExを終了するには、Ctl-C
を2回入力するか、Ctl-G
の後にq
とRetrun
を入力します。
iex(2)> BREAK: (a)bort (c)ontinue (p)roc info (i)nfo (l)oaded (v)ersion (k)ill (D)b-tables (d)istribution ^C $
プロセスを使ってアプリケーションを構築する
アクターモデルという言葉を聞いたことがあるでしょうか?
アクターモデルとは並行計算モデルの1つで、すべての「もの」を「アクター」という構成要素で表現します。 ちょうど、オブジェクト指向プログラミングにおいて、あらゆる「もの」は「オブジェクト」であると考えることに似ています。
アクターがただの「もの」と違うのは、次のような特徴があることです。
- 他のアクターにメッセージを送信(メッセージパッシング)できる(メッセージの送信先をメールアドレスと呼びます)
- アクターは、受け取ったメッセージをキューに溜められる(このキューをメールボックスと呼びます)
- アクターは、新たなアクターを生成できる
- アクターは、並行に処理が実行される
Elixirが並行処理や分散処理に強いといわれるのは、このアクターモデルを採用しているおかげです。 個々のアクターでは小さな仕事を担当し、それらアクター間での非同期なやり取りとして全体のプログラムを作れるので、出来上がったプログラムが自然と並行・分散システムになるというわけです。
そこで、ここからの説明では、まずElixirにおけるアクターモデルの使い方を説明していきます。 具体的には、アクター間でのメッセージのやり取りについて学びます。 しばらくの間は何の役に立つのか分からない話が続くと思いますが、ぜひ実際に手を動かしながら読み進めてください。
そうして「アクター間でのメッセージのやり取り」という世界観を掴んでしまえば、Elixirの力で簡単に並行・分散プログラミングを楽しめるようになります!
Elixirのアクターモデルとプロセス
Elixirのアクターモデルは、ベースとなるErlangのVM上で動作しています。Erlangではアクターのことを「プロセス」と呼ぶので、以降の説明でもプロセスという用語を使います(紛らわしいことに、ErlangのプロセスはOSのプロセスとはまったく別物なので注意してください)。
Elixirのプロセス、つまりErlangのプロセスですが、メモリはデフォルトで309ワード、 起動にかかる時間は数マイクロ秒と、OSのプロセスに比べて非常に軽量です。
プロセスとプロセスの間では、何ができるでしょうか? プロセス間のやりとりとして実行可能な処理は、主に以下の5つです。
- プロセスから別のプロセスに対してメッセージを送信する
- プロセスからのメッセージを別のプロセスが受信する
- プロセスが別のプロセスを生成する
- プロセスが別のプロセスをリンクする
- プロセスが別のプロセスをモニタする
順番に見ていきましょう。
1. プロセスから別のプロセスに対してメッセージを送信する
Elixirのプロセスはそれぞれユニークな識別IDを持っており、このIDをプロセスID(PID)と言います。
プロセスIDを使って send <process-id>, <message>
とすることで、 あるプロセスから別のプロセスに対してメッセージを送信できます。
また、自身のプロセスIDは、self()
とすることで取得できます。
IExを立ち上げて試してみましょう。
# 自分自身のプロセスID iex(1)> self() #PID<0.83.0>
詳しくは後述しますが、iex
自身も1つのプロセスなので、プロセスIDを持っています。 上記の例では、いま実行しているiex
のプロセスIDが「0.83.0」であるということが読み取れます。
次に、この自分自身のプロセスに何かメッセージを送ってみましょう。 例として"my-message"という文字列を送ってみます。
# 自分自身のメールボックスに"my-message"を送信 iex(2)> send self(), "my-message" "my-message" iex(3)>
プロセスに送ったメッセージはどうなるのでしょうか? 次は受信側を見てみましょう。
2. プロセスからのメッセージを別のプロセスが受信する
送信したメッセージを受信してみます。
Elixirにはメッセージを受信する方法がいくつかありますが、 ここではメールボックス内のすべてのメッセージを表示、解放するflush()
を使って確認してみましょう。
# 受信したメッセージ("my-message")を表示・解放 iex(4)> flush() "my-message" :ok # 上でメッセージを解放したので何も起こらない iex(5)> flush() :ok # メッセージを2つ送信 iex(6)> send self(), "my-message1" "my-message1" iex(7)> send self(), "my-message2" "my-message2" # 2つ分のメッセージが表示・解放 iex(8)> flush() "my-message1" "my-message2" :ok iex(9)>
3. プロセスが別のプロセスを生成する
プロセスは、spawn <モジュール>, <関数>, <引数の配列>
として生成できます。 モジュールというのは、いくつかの関数をまとめて名前を付けたもののことです。 あるモジュールで定義された、ある関数に、引数を渡すことで、新しいプロセスを生成するわけです。
例として、モジュールSampleFunc
と関数hello
を定義してみましょう。 次のコードをsample_func.ex
というファイルに保存してください。
defmodule SampleFunc do def hello(person) do IO.puts "Hello, #{person}. My pid is #{inspect self()}." receive do message -> IO.puts "Message is #{message}." end end end
Elixirでは、defmodule
でモジュールを、def
で関数を定義します。
上記の例では、引数として渡された人物の名前(person
)と、自身のプロセスIDを、挨拶の文字列に埋め込んで表示するだけの関数を定義しています。 このように、文字列中に#{xxx}
を含めることで文字列を、#{inspect xxx}
を含めることで文字列以外の値を埋め込めます。
receive
というのは、メッセージを待ち受けて、ブロック内の処理を行う仕組みです。 この例では、受け取ったメッセージの内容を標準出力へと書き出すのにreceive
を利用しています。
それでは、このSampleFunc
モジュールを使って、プロセスの生成とメッセージの送信をしてみましょう。
iex
は、引数にファイル名を指定すると、そのファイルをコンパイルしてロードした状態で起動します。 次のようにiex sample_func.ex
とすることで、sample_func.ex
で定義されているSampleFunc
モジュールをロードした状態でiex
が起動します。
$ iex sample_func.ex # (1) プロセスを生成 iex(1)> pid = spawn(SampleFunc, :hello, ["田中太郎"]) Hello, 田中太郎. My pid is #PID<0.86.0>. #PID<0.86.0> # (2) メッセージを送信し、送信先のプロセスで処理を実行 iex(2)> send pid, "田中太郎さん、よろしくおねがいします" "Message is 田中太郎さん、よろしくおねがいします." "田中太郎さん、よろしくおねがいします" # (3) メッセージを送信しても、送信先プロセスが終了しているので反応なし iex(3)> send pid, "田中太郎さん、よろしくおねがいします" "田中太郎さん、よろしくおねがいします" iex(4)>
(1)では、spawn
で新しく生成したプロセスのプロセスIDが返却されるので、それをpid
として保持しています。 この新たに生成されたプロセスでは、SampleFunc.hello
関数が、"田中太郎"
という引数で実行されます。 この関数は、名前とプロセスIDを表示した後、receive
でメッセージを待ち受けます。
(2)では、(1)で生成したプロセスに、send
を使ってメッセージを送信しています。 (1)で生成したプロセスは、メッセージを受け取ると、 receive
ブロック内の処理(メッセージ内容の標準出力への書き出し)を行い、その後に終了します。
(3)では、プロセスに再度メッセージを送信しています。 しかし、(2)ですでにreceive
の待ち受けが終了しているので、何も起こりません。 このように、SampleFunc.hello
の処理が完了すると、プロセスは終了します(つまり消えてしまいます)。
このプロセスをずっと維持したい場合はどうすればよいでしょうか? 処理の最後で自分自身(hello
)を呼び出せば、SampleFunc.hello
がループされるので、プロセスが維持できそうです。
処理をループするバージョンの関数を、以下のようにhello2
として定義してみましょう(なお、#
から行末まではElixirのコメントです)。
defmodule SampleFunc do def hello2(person) do IO.puts "Hello, #{person}. My pid is #{inspect self()}." receive do message -> IO.puts "Message is #{message}." hello2(person) # メッセージを受信し、処理が完了したら自分自身を呼び出す end end end
前回と同じように実行してみます。
# プロセスを生成 iex(1)> pid = spawn(SampleFunc, :hello2, ["山田二郎"]) Hello, 山田二郎. My pid is #PID<0.86.0>. #PID<0.86.0> # メッセージを送信し、送信先プロセスで処理を実行 iex(2)> send pid, "二郎さん、ループしてますか?" "Message is 二郎さん、ループしてますか?." "二郎さん、ループしてますか?" Hello, 山田二郎. My pid is #PID<0.86.0>. # メッセージを送信し、送信先プロセスで処理を実行 iex(3)> send pid, "二郎さん、ループしてますか?" "Message is 二郎さん、ループしてますか?." "二郎さん、ループしてますか?" Hello, 山田二郎. My pid is #PID<0.86.0>. # メッセージを送信し、送信先プロセスで処理を実行 iex(4)> send pid, "二郎さん、ループしてますか?" "Message is 二郎さん、ループしてますか?." "二郎さん、ループしてますか?" Hello, 山田二郎. My pid is #PID<0.86.0>. iex(5)>
うまくいきました!
4. プロセスが別のプロセスをリンクする
プロセスの生成やメッセージの送受信というのは、なんとなくイメージが掴めそうですが、 プロセスを「リンク」することで一体何ができるのでしょうか?
プロセスを別のプロセスとリンクすると、プロセス同士が互いを監視するようになります。 これにより、一方のプロセスが死んだときに、もう一方のプロセスに対して終了メッセージ(終了シグナル)を送信できます。
あるプロセスから、spawn
ではなくspawn_link
で別のプロセスを生成することにより、生成元のプロセスとリンクしたプロセスを生成できます。 iex
上でspawn_link
を呼び出してプロセスを生成してみましょう。
iex(1)> pid = spawn_link(SampleFunc, :hello2, ["田中花子"]) Hello, 田中花子. My pid is #PID<0.86.0>. #PID<0.86.0> iex(2)> send pid, "こんにちは" "Message is こんにちは." Hello, 田中花子. My pid is #PID<0.86.0>. "こんにちは"
このプロセスを終了してみましょう。 プロセス終了の命令Process.exit(<プロセスID>, <終了理由>)
を実行することで、 引数のプロセスに終了シグナルが送信され、それを受け取ったプロセスが終了します。
iex(3)> Process.exit(pid, "終了しなさい") ** (EXIT from #PID<0.84.0>) "終了しなさい" Interactive Elixir (1.4.2) - press Ctrl+C to exit (type h() ENTER for help) iex(1)>
iex
上でspawn_link
により生成したプロセスを終了すると、iex
が再起動してしまいました、これはどういうことでしょう?
実は、iex
もプロセスとして実装されているので、spawn_link
で生成されたプロセスはiex
のプロセスとリンクされます。 この状態でプロセスが終了すると、リンクされているiex
のプロセスに対して終了シグナルが送信されるのでiex
が終了するのです。
終了シグナルを受信しても終了しないプロセスのことをシステムプロセスと言います。
リンクしたプロセスが終了するたびにiex
が終了(再起動)してはたまらないので、iex
をシステムプロセスにしましょう。 Process.flag(:trap_exit, true)
とすることで、自身(のプロセス)をシステムプロセスにできます。
iex(1)> pid = spawn_link(SampleFunc, :hello2, ["田中花子"]) Hello, 田中花子. My pid is #PID<0.86.0>. #PID<0.86.0> iex(2)> send pid, "こんにちは" "Message is こんにちは." Hello, 田中花子. My pid is #PID<0.86.0>. "こんにちは" iex(3)> Process.flag(:trap_exit, true) # システムプロセスにする、Process.flagの返り値は変更前のフラグ値 false iex(4)> Process.exit(pid, "終了しなさい") true iex(5)> flush() {:EXIT, #PID<0.86.0>, "終了しなさい"} # プロセスの終了メッセージ :ok iex(6)>
終了メッセージ{:EXIT, #PID<0.86.0>, "終了しなさい"}
が受信されることを確認できました。うまくいったようです。
5. プロセスが別のプロセスをモニタする
「リンク」がプロセス間の相互監視なのに対し、非対称の一方通行でプロセスを監視するのが「モニタ」です。 リンクとモニタには、相互に監視しあうのか、片方だけを監視するのかという違いがあります。
モニタされたプロセスを生成することで、そのプロセスが死んだときに生成元のプロセスに対してメッセージを送るようにできます。 逆に、生成元のプロセスが死んでも監視対象のプロセスには何も起こりません。
リンクではなくモニタしたプロセスを生成するにはspawn_monitor
を使います。
iex(1)> {pid, ref} = spawn_monitor(SampleFunc, :hello2, ["山田太郎"]) # (1) Hello, 山田太郎. My pid is #PID<0.86.0>. {#PID<0.86.0>, #Reference<0.0.4.318>} iex(2)> send pid, "こんばんは" "Message is こんばんは." Hello, 山田太郎. My pid is #PID<0.86.0>. "こんばんは" iex(3)> Process.exit(pid, "終了してください") # (2) true iex(4)> flush() # (3) {:DOWN, #Reference<0.0.4.318>, :process, #PID<0.86.0>, "終了してください"} :ok iex(5)>
(1)でspawn_monitor
が返すのは、spawn_link
のときとは違って、単なるプロセスIDではなく{<プロセスID>, <リファレンス>}
という組です。 この「リファレンス」というのは、グローバルに一意な参照値です。
(3)では、モニタされたプロセスに対し終了シグナルを送信して、プロセスを終了させています。 リンクのときと違って、生成元のプロセスであるiex
は、終了メッセージを受け取っても終了しません。
(4)からわかるように、モニタしているプロセスが終了すると、 終了シグナルではなくダウンメッセージ({:DOWN, ...}
)が送信されるので、 監視元のプロセスは終了しないのです。
GenServerから始めるOTP
前節では、プロセスの生成方法や、プロセス同士の監視方法について説明しました。 Elixirでは、これらプロセスの操作を組み合わせてプログラムを構築します。 なんだか、とても複雑で大変そうですね。
しかし、ご安心を。
ElixirのベースにあるErlangでは、プロセスの操作として抽象化された処理やパターンが、 OTP(Open Telecom Platform)というフレームワークとして提供されています。 Erlangは、このOTPとかなり密接に結び付いているので、両方を併記してErlang/OTPと総称する場合もあります。
ErlangのOTPは、Elixirからもシームレスに利用できるので、ElixirプログラマもOTPを使って定型的な処理の多くを簡潔に表現できます。 ここからは、特によく使われるOTPのパターン(ビヘイビアと呼ばれます)のひとつ、GenServerを実際に利用してみましょう。
GenServerによるクライアント/サーバー処理の抽象化
GenServerは、基本的なクライアント/サーバー処理におけるサーバーの振る舞い、つまり
- リクエストを投げ、
- サーバーの状態を更新し、
- レスポンスが返却される、
という処理を抽象化したパターンです。
このパターンにしたがって振る舞うサーバーを実装したかったら、 サーバーを実装するモジュールの先頭で、 次のようにuse GenServer
とします。
defmodule Server do use GenServer # 振る舞いの実装 end
これだけで、汎用サーバープロセスの振る舞いを実現する関数が、モジュールServer
にインポートされて利用できるようになります。
そうしてGenServerを利用して構成したサーバーは、次のようにしてプロセスとして起動できます。
GenServer.start_link(<振る舞いを実装したモジュール名>, <初期値>)
上記によって{:ok, <プロセスID>}
という組が返るので、このプロセスIDを使ってサーバーとしての処理を行えばよいのです。
iex(1)> {:ok, pid} = GenServer.start_link(Server, "init-data") {:ok, #PID<0.86.0>} iex(2)>
上の例では、振る舞いのモジュールをServer
、初期値を"init-data"
としてサーバープロセスを起動しています。
カウンターサーバーを実装してみよう
GenServerの使い方を説明するために、実際に次のような仕様のシンプルなカウンターサーバーをGenServerで実装してみましょう。
- サーバー起動時に初期値(カウンタの初期値)を設定する
- サーバー(プロセス)は状態(カウンタ値)を持つ
- サーバー(プロセス)に対して、カウントアップ、カウントダウンを実行できる
- カウントアップ、ダウンを実行するとサーバー(プロセス)内の状態が更新される
カウンターサーバーの初期化
GenServerを使って実装したサーバーをGenServer.start_link(モジュール名, <初期値>)
で起動すると、まずinit(<初期値>)
という関数が実行されることになっています。 そのため、何かしらの初期化処理が必要なサーバーでは、モジュールの定義でinit
を実装しておきます。
init
関数の実装に要求されるのは、次の2点です。
- 初期値を引数にとること
-
{:ok, <設定したい初期値>}
を返すこと
初期値をそのままサーバープロセスに設定するだけであれば、init
関数の定義は省略可能です。 これから作るカウンターサーバーでは、初期化時に特に処理を行わないので、init
関数を省略してもかまいません。
しかし、ここではGenServerを使ったサーバーでinit
が呼ばれることを見るために、独自のinit
関数を実装してみましょう。 例として、サーバープロセスの起動時にinit
関数でメッセージを出力するようにしてみます。
defmodule Counter do use GenServer def init(state) do IO.puts "--- init(#{inspect state}) called ---" {:ok, state} end end
カウンターサーバーの起動
それではサーバープロセスをいくつか立ち上げてみましょう
iex(1)> GenServer.start_link(Counter, 0) --- init(0) called --- {:ok, #PID<0.86.0>} iex(2)> GenServer.start_link(Counter, 10) --- init(10) called --- {:ok, #PID<0.88.0>} iex(3)> GenServer.start_link(Counter, 100) --- init(100) called --- {:ok, #PID<0.90.0>} iex(4)>
初期値を0
、10
、100
として、3つのサーバープロセスを起動しています。 それぞれinit
で定義したとおりにメッセージが出力されていることがわかります。
これで、GenServerを使って定義したカウンターサーバーのプロセスが起動し、 起動時にはinit
関数で定義した動作をすることがわかりました。 続いて、このカウンターサーバーに、カウントアップとカウントダウンの機能を実装していきましょう。
しかし、その前に、一般にサーバーの機能をクライアントから呼び出すときの2つの方法の違いについて説明します。
同期呼び出しと非同期呼び出し
汎用サーバーに対する操作には、同期呼び出しと、非同期呼び出しの2種類があります。
同期呼び出しと非同期呼び出しの違いは何でしょうか? 同期呼び出しでは、サーバーがクライアントに実行結果を返します。 一方、非同期呼び出しでは、サーバーがクライアントに実行結果を返しません。
つまり、同期呼び出しでは、サーバーにおける処理が完了して実行結果を返せるようになった時点で、呼び出したクライアントにその実行結果が返されます。 一方、非同期呼び出しでは、サーバー側で処理が完了していなくてもクライアントに何らかの結果が返されます。
サーバーの処理が終わる前に結果が返る非同期呼び出しは、何が嬉しいのでしょうか?
例えば、ものすごく時間がかかる処理を考えてみてください。 同期呼び出しでは、実行時間が長い処理が完了するまで、結果が返るのを待っている必要があります (つまり呼び出し側でサーバー側の処理が終わるまで待たないといけません)。
非同期呼び出しでは、どれだけ処理に時間がかかろうと、即座に処理が返ってくるので、 サーバーの処理が終わるのを待っている時間がないのです。 これが非同期呼び出しの大きなメリットです。
GenServerには、サーバーの処理を同期呼び出しとして実装するための仕組みも、 非同期呼び出しとして実装するための仕組みも用意されています。 これらの仕組みを使って、カウンターサーバーの機能を実装してみましょう。
カウンターサーバーに同期呼び出しの機能を実装する
Counterモジュールに、同期呼び出しでカウントアップとカウントダウンの機能を実装しましょう。
GenServerを使って同期呼び出しされる処理を実装するには、
handle_call(<リクエスト識別子>, <リクエスト元のプロセスID>, <更新前のサーバーの状態>)
という関数を定義します。 この関数は、戻り値として{:reply, <クライアントへの返却値>, <更新後のサーバーの状態>}
を返すようにします。
handle_call
として定義した処理を呼び出すときは、 GenServer.call(<汎用サーバープロセス>, <リクエスト識別子>)
とします。 すると、Counterモジュールに定義したhandle_call
が実行され、<クライアントへの返却値>
が返されるというわけです。
同期呼び出しのカウントアップ
まずはカウントアップの処理を実装しましょう。 リクエスト識別子は:up
とします (Elixirでは、先頭が:
の要素をアトムと呼びます。アトムは文字列とは区別される値で、他の言語ではシンボルなどと呼ばれるものに相当します)。
defmodule Counter do use GenServer def init(state) do # 略 end def handle_call(:up, from, state) do IO.puts "--- handle_call(:up, #{inspect from}, #{inspect state}) called ---" state = state + 1 {:reply, "result: count up to #{inspect state}", state} end end
サーバープロセスを起動してプロセスIDを取得し、GenServer.call
で何回か呼び出してみましょう。
iex(1)> {:ok, pid} = GenServer.start_link(Counter, 0) --- init(0) called --- {:ok, #PID<0.86.0>} iex(2)> GenServer.call(pid, :up) --- handle_call(:up, {#PID<0.84.0>, #Reference<0.0.4.72>}, 0) called --- "result: count up to 1" iex(3)> GenServer.call(pid, :up) --- handle_call(:up, {#PID<0.84.0>, #Reference<0.0.1.388>}, 1) called --- "result: count up to 2" iex(4)> GenServer.call(pid, :up) --- handle_call(:up, {#PID<0.84.0>, #Reference<0.0.1.408>}, 2) called --- "result: count up to 3" iex(5)>
カウントアップができました!
同期呼び出しのカウントダウン
続いて、カウントダウンをリクエスト識別子:down
として実装しましょう。
defmodule Counter do use GenServer def init(state) do # 略 end def handle_call(:up, from, state) do # 略 end def handle_call(:down, from, state) do IO.puts "--- handle_call(:down, #{inspect from}, #{inspect state}) called ---" state = state - 1 {:reply, "result: count down to #{inspect state}", state} end
おや?と思った方がいるかもしれません。 上記のコードでは、同じモジュールの中に、handle_call
という同じ名前の関数を2回定義しています。
これは、引数に対するパターンマッチを利用した関数の定義です。 Elixirでは、このように引数のパターンを変えて同じ名前の関数を定義することで、 第一引数が:up
のときはhandle_call(:up, from, state)
が、 :down
のときはhandle_call(:down, from, state)
が実行されるのです。 1つの関数定義の中で第一引数の値で条件分岐をする必要がないので、 シンプルに処理を記述でき、非常に見通しがよくなります。
それでは先ほどと同様にGenServer.call
を実行してみましょう。 今回は、第一引数の値として、:up
だけでなく:down
も指定してみます。
iex(1)> {:ok, pid} = GenServer.start_link(Counter, 0) --- init(0) called --- {:ok, #PID<0.86.0>} iex(2)> GenServer.call(pid, :up) --- handle_call(:up, {#PID<0.84.0>, #Reference<0.0.3.416>}, 0) called --- "result: count up to 1" iex(3)> GenServer.call(pid, :up) --- handle_call(:up, {#PID<0.84.0>, #Reference<0.0.3.436>}, 1) called --- "result: count up to 2" iex(4)> GenServer.call(pid, :down) --- handle_call(:down, {#PID<0.84.0>, #Reference<0.0.3.456>}, 2) called --- "result: count down to 1" iex(5)> GenServer.call(pid, :down) --- handle_call(:down, {#PID<0.84.0>, #Reference<0.0.3.476>}, 1) called --- "result: count down to 0" iex(6)> GenServer.call(pid, :down) --- handle_call(:down, {#PID<0.84.0>, #Reference<0.0.3.496>}, 0) called --- "result: count down to -1" iex(7)>
カウントダウンもOKそうです。
カウンターサーバーに非同期呼び出しの機能を実装する
GenServerを使って非同期呼び出しされる処理を実装するには、
handle_cast(<リクエスト識別子>, <更新前のサーバーの状態>)
という関数を定義します。 この関数は、戻り値として{:noreply, <更新後のサーバーの状態>}
を返すようにします。
同期呼び出しと異なり、結果を返す必要がないので、 引数には<クライアントの返却値>
と<リクエスト元のプロセスID>
がないことに注目してください。
非同期版のカウントアップとカウントダウンは、以下のように実装できます。
defmodule Counter do use GenServer def init(state) do # 略 end def handle_call(:up, from, state) do # 略 end def handle_call(:down, from, state) do # 略 end def handle_cast(:up, state) do IO.puts "--- handle_cast(:up, #{inspect state}) called ---" state = state + 1 IO.puts "--- state -> #{state} ---" {:noreply, state} end def handle_cast(:down, state) do IO.puts "--- handle_cast(:down, #{inspect state}) called ---" state = state - 1 IO.puts "--- state -> #{state} ---" {:noreply, state} end end
handle_cast
として定義した処理を呼び出すときは、 GenServer.cast(<汎用サーバープロセス>, <リクエスト識別子>)
とします。 すると、Counterモジュールに定義したhandle_cast
が実行され、その終了を待たずにクライアントに処理が返されます。
iex(1)> {:ok, pid} = GenServer.start_link(Counter, 0) --- init(0) called --- {:ok, #PID<0.86.0>} iex(2)> GenServer.cast(pid, :up) --- handle_cast(:up, 0) called --- --- state -> 1 --- :ok iex(3)> GenServer.cast(pid, :up) --- handle_cast(:up, 1) called --- --- state -> 2 --- :ok iex(4)> GenServer.cast(pid, :up) --- handle_cast(:up, 2) called --- --- state -> 3 --- :ok iex(5)> GenServer.cast(pid, :down) --- handle_cast(:down, 3) called --- --- state -> 2 --- :ok iex(6)> GenServer.cast(pid, :down) --- handle_cast(:down, 2) called --- --- state -> 1 --- :ok iex(7)>
値は返しませんが、内部のstateがカウントアップ・ダウンされているのがわかりますね。
このように、GenServerを使うことで、init
、handle_call
、handle_cast
を実装するだけで、 汎用のクライアント/サーバー処理を構成できます。 しかも、同期処理や非同期処理の具体的な実現方法、汎用サーバー自身の処理についての詳細を意識することなく提供できるのです。
スーパバイザービヘイビアによる監視
Elixirのアプリケーションは、数百から数千・数万のプロセスで構成され、 それぞれのプロセスが処理の小さな一部分のみを処理しています。 これらのプロセスのうち1つがクラッシュしても、他の処理への影響が最小限になるようにするには、どうすればよいでしょうか?
このようなプログラムをElixirで設計するときに頻出するパターンが、スーパバイザーと呼ばれるビヘイビアです。 プロセスのリンクとモニタを使って設計されたスーパバイザーによりプロセスの監視と再起動を行い、 たとえ一部のプロセスがクラッシュしたとしても全体が停止することなく動作するようにアプリケーションを実行できます。
クライアント/サーバーという構成のアプリケーションでは、サーバーの冗長性や可用性がサービスにとって重要なことも少なくありません。
スーパバイザービヘイビアが提供するような機能が必要な場合、他の多くの言語では外部ツール(supervisordなど)を利用して実現することになりますが、 Elixir/Erlangにはこれが言語の機能として標準化されています。 したがって、サーバープロセスにスーパバイザービヘイビアを組み込むだけで、ある程度の冗長性や可用性を難なく実現できるというわけです。
スーパバイザービヘイビアの使い方
スーパバイザービヘイビアを利用したプログラムの骨格は以下のようになります。
import Supervisor.Spec # (1) # (2) children = [ worker(WorkerModule, [<引数>, <起動オプション>]), # (3) ... # 複数のworkerを監視できます ] Supervisor.start_link(children, strategy: <起動戦略>) # (4)
1行めのimport Supervisor.Spec
により、スーパバイザービヘイビアで利用できるヘルパー関数が有効になります。
監視対象のプロセス(ワーカープロセス)は、worker
関数で設定します。 ここで設定した監視対象のプロセスが、スーパバイザーにより、WorkerModule.start_link(<引数>, <起動オプション>)
のようにリンクされて起動することになります。
なお、コード内のコメントにあるように、ワーカープロセスは複数をいっぺんに設定できます。 さらに、ワーカーとして指定するプロセスは、別のスーパバイザープロセスであってもかまいません。 つまり、スーパバイザーを入れ子のようにして監視することもできるのです!
最後の行では、この起動設定を引数に指定して、Supervisor.start_link
によりスーパバイザーを起動しています。 これでWorkerModuleを監視対象にしたスーパバイザープロセスが生成されるのですが、 もう一つの引数であるstrategy: <起動戦略>
とはいったい何でしょうか?
再起動設定
Supervisor.start_link
の2つめの引数であるstrategy:
では、 監視対象のワーカープロセスがクラッシュしたときの再起動方法を指定できます。 スーパバイザーはワーカーのクラッシュ時に適切にプロセスを再起動してくれるわけですが、 この「適切さ」をかなり柔軟に設定できるということです。
再起動の戦略として設定できるのは次の表のような値です。
再起動設定 | 戦略 |
---|---|
:one_for_one | クラッシュした監視対象のプロセスのみ再起動 |
:one_for_all | 監視対象のプロセスをすべて再起動 |
:rest_for_one | クラッシュしたプロセスと、それ以降に開始されたプロセスを再起動 |
:simple_one_for_one | 監視対象のプロセスを任意のタイミングで追加できる |
アプリケーションの種類や、利用される状況に応じて、これらの再起動戦略から適切なものを選ぶことになります。
Counterをスーパバイザーで監視する
それでは実際にスーパバイザービヘイビアを使ってみましょう。 先ほどGenServerを使って作成したCounterモジュールをワーカープロセスとして、スーパバイザーで監視してみることにします。
スーパバイザーがワーカープロセスを起動するときにはstart_link
を利用するので、まずはCounterモジュールにstart_link
を追加します。
defmodule Counter do use GenServer def start_link(state, opts) do IO.puts "--- Counter.start_link(#{inspect state}, #{inspect opts}) called ---" GenServer.start_link(__MODULE__, state, opts) # __MODULE__ で自身のモジュール名(Counter)を参照できます end def init(state) do # 略 end def handle_call(...) do # 略 end def handle_cast(...) do # 略 end end
__MODULE__
は、自身のモジュール名を表します(正確に言うと、自身のモジュール名に展開されるマクロです。マクロについては次回の記事で説明します)。 したがって、GenServer.start_link(__MODULE__, state, opts)
は、 GenServer.start_link(Counter, state, opts)
と同じ意味になります。
start_link
の定義を追加したCounterをスーパバイザーに組み込んでみましょう。 先ほど説明した骨格に従って、iex
上で次のように入力、実行してみてください。
# (a) Counterを読み込んでiexを起動 $ iex counter.ex # (b) スーパバイザー関連のヘルパ関数(worker)を利用できるように iex(1)> import Supervisor.Spec Supervisor.Spec # (c) [name: :counter_process] でCounterプロセスの名前を設定 iex(2)> children = [worker(Counter, [5, [name: :counter_process]])] [{Counter, {Counter, :start_link, [5, [name: :counter_process]]}, :permanent, 5000, :worker, [Counter]}] # (d) Counterを監視するスーパバイザーの起動 iex(3)> Supervisor.start_link(children, strategy: :one_for_one) --- Counter.start_link(5, [name: :counter_process]) called --- --- Counter.init(5) called --- {:ok, #PID<0.88.0>}
(c)では、Counterの起動時に渡すオプションを[name: :counter_process]
としています。 このオプションによって、監視対象であるCounterプロセスに、:counter_process
という名前を付与しています。
GenServerを使ってcast/callを呼び出すときには、プロセスIDが必要でしたが、 このように名前を付与することで、その名前をプロセスIDの代わりに使えるようになります。 つまりGenServer.call(:counter_process, ...)
と呼び出すことができるのです。
# (e) (c)で設定した名前でcall/cast呼び出し iex(4)> GenServer.call(:counter_process, :up) --- handle_call(:up, {#PID<0.84.0>, #Reference<0.0.4.579>}, 5) called --- "result: count up to 6" iex(5)> GenServer.cast(:counter_process, :down) --- handle_cast(:down, 6) called --- --- state -> 5 --- :ok
プロセスIDの代わりに:counter_process
で呼び出せていますね。
ちなみに、このプロセス名からプロセスIDを取得するには:erlang.whereis
を使います。
# (f) :counter_processという名前のプロセスIDを取得 iex(6)> pid = :erlang.whereis(:counter_process) #PID<0.89.0>
監視対象のCounterプロセスを終了させてみましょう。 どうなるでしょうか?
# (g) 監視対象のプロセスを終了させる iex(7)> Process.exit(pid, "再起動してくれるかな?") --- Counter.start_link(5, [name: :counter_process]) called --- true --- Counter.init(5) called --- # (h) スーパバイザーがCounterプロセスを再起動するのでcall/castが可能、ただし状態(state)は初期値にリセットされる iex(8)> GenServer.call(:counter_process, :down) --- handle_call(:down, {#PID<0.84.0>, #Reference<0.0.4.637>}, 5) called --- "result: count down to 4" iex(9)> GenServer.cast(:counter_process, :up) --- handle_cast(:up, 4) called --- --- state -> 5 --- :ok # (i) もう一度Counterプロセスを終了させ、再起動後のプロセスIDが変わっている iex(10)> pid = :erlang.whereis(:counter_process) #PID<0.94.0> iex(11)> Process.exit(pid, "もう一度Counterを終了させる") true --- Counter.start_link(5, [name: :counter_process]) called --- --- Counter.init(5) called --- iex(12)> pid = :erlang.whereis(:counter_process) #PID<0.100.0> iex(13)>
--- Counter.init(5) called ---
のログが出ていますね。 これは、監視対象のプロセスが終了(クラッシュ)したときに、スーパバイザーによって再起動されたことを意味します。 うまくいきました!
Elixirのエコシステムと開発の流れ
Elixirによるアプリケーション開発を続けていると、 次のような作業を定常的に行うことになります。
- 利用するライブラリの管理
- 雛形アプリの作成
- アプリのコンパイル・起動・停止
Elixirでは、これらのタスクを実行するためのツールとして、mixと呼ばれるコマンドが用意されています。 mix
コマンドはElixirのインストール時に利用可能になるので、すでに皆さんの手元でも実行できるはずです。
mixで何ができるかをmix help
で見てみましょう。
$ mix help mix # Runs the default task (current: "mix run") mix app.start # Starts all registered apps mix app.tree # Prints the application tree mix archive # Lists installed archives mix archive.build # Archives this project into a .ez file mix archive.install # Installs an archive locally mix archive.uninstall # Uninstalls archives mix clean # Deletes generated application files mix cmd # Executes the given command mix compile # Compiles source files mix deps # Lists dependencies and their status mix deps.clean # Deletes the given dependencies' files mix deps.compile # Compiles dependencies mix deps.get # Gets all out of date dependencies mix deps.tree # Prints the dependency tree mix deps.unlock # Unlocks the given dependencies mix deps.update # Updates the given dependencies mix do # Executes the tasks separated by comma mix escript # Lists installed escripts mix escript.build # Builds an escript for the project mix escript.install # Installs an escript locally mix escript.uninstall # Uninstalls escripts mix help # Prints help information for tasks mix loadconfig # Loads and persists the given configuration mix local # Lists local tasks mix local.hex # Installs Hex locally mix local.public_keys # Manages public keys mix local.rebar # Installs Rebar locally mix new # Creates a new Elixir project mix profile.fprof # Profiles the given file or expression with fprof mix run # Runs the given file or expression mix test # Runs a project's tests mix xref # Performs cross reference checks iex -S mix # Starts IEx and runs the default task $
さまざまなタスクがあるようですね。 そのうち特によく利用するいくつかのタスクについて説明します。
mixを使ったライブラリ管理
Elixirのライブラリを新しくインストールして使いたい場合、どうすればよいでしょうか? PerlにCPANが、RubyにRubygemsが、node.jsにはnpmがあるように、ElixirにはHexというライブラリ管理の仕組みがあります。
Elixirでhex.pmにホスティングされたライブラリを利用するには、Hexが必要です。 Hexは、ElixirとErlang向けのパッケージ管理ツールであり、mixを使ってインストールできます。
以下のようにmix local.hex
を実行すると、Hexをインストールするかどうか[Yn]
で聞かれるので、 Y
としてインストールしてください。
$ mix local.hex Are you sure you want to install archive "https://repo.hex.pm/installs/1.4.0/hex-0.16.0.ez"? [Yn] Y * creating /path/to/your/home/.mix/archives/hex-0.16.0 $
これでhex
コマンドが利用できるようになりますが、Elixirでは実際のライブラリ取得やコンパイルといった操作で、Hexを直接は使いません。
代わりに、後述するmix deps.get
やmix deps.compile
といったmixのタスクを使います。 Hexは、これらのmixのライブラリ操作タスクで、内部的に利用されています。
mixを使ったアプリケーション開発
mixを使ったアプリケーションの作成から実行までの流れを実際に試してみましょう。
1. プロジェクトの作成
mix new <app-name>
でElixirプロジェクト、つまりアプリケーションの雛形を作成できます。
ためしに、SampleApp
という名前でプロジェクトの雛形を作成してみましょう (Elixirでは、慣習として、ディレクトリやファイルの名前にはスネークケース、モジュール名にはキャメルケースを使います)。
$ mix new sample_app * creating README.md * creating .gitignore * creating mix.exs * creating config * creating config/config.exs * creating lib * creating lib/sample_app.ex * creating test * creating test/test_helper.exs * creating test/sample_app_test.exs Your Mix project was created successfully. You can use "mix" to compile it, test it, and more: cd sample_app mix test Run "mix help" for more commands. $
作成される雛形は、以下のようなディレクトリ構成になります。
ディレクトリ/ファイル | 説明 |
---|---|
mix.exs | プロジェクト情報や関連モジュールを定義 |
config/ | 設定ファイルを配置 |
lib/ | プロジェクトの本体、この中にアプリの処理を記述するコードを配置 |
test/ | テスト関連のコードを配置 |
2. mix.exsの修正
利用したいライブラリは、mix.exs
内のdeps
関数内に記述します。 雛形作成直後のmix.exs
は以下となります。
defmodule SampleApp.Mixfile do use Mix.Project # プロジェクト関連の設定 def project do # ~略~ end # アプリケーションの設定 def application do # ~略~ end # 関連モジュールの設定 defp deps do [] # 利用ライブラリをここに定義 end end
このdeps
関数に、利用するライブラリを記述します。 ライブラリの記述方法は、以下のどちらの方法で取得するかによって異なります。
- hex.pm経由でライブラリを取得
- git(GitHub)リポジトリ経由でライブラリを取得
たとえば、日本の祝日を判定するholiday_jp
ライブラリを組み込む場合、 hex.pm経由で利用する場合には次のようにしてライブラリ名とバージョンを指定します。
{:holiday_jp, "~> 0.2.1"}
git(GitHub)のリポジトリ経由で利用する場合には、次のように、リポジトリのURLに加えてブランチ/タグも指定してください。
{:json, git: "https://github.com/ne-sachirou/holiday_jp-elixir", tag: "0.1.1"}
3. ライブラリの取得とコンパイル
mix.exs
のdeps
を定義したら、mix deps.get
コマンドでライブラリを取得します。 実行後、deps
ディレクトリが作成され、ここにライブラリ(今の例ではholiday_jp
)が配置されます。
mix.exs
のdeps
を
defp deps do [ {:holiday_jp, "~> 0.2.1"} ] end
としてmix deps.get
を実行した結果が以下となります。
$ mix deps.get Running dependency resolution... * Getting holiday_jp (Hex package) Checking package (https://repo.hex.pm/tarballs/holiday_jp-0.2.1.tar) Fetched package $ ls deps/ holiday_jp $
deps
以下のモジュールをコンパイルするにはmix deps.compile
を実行します。 コンパイルされたファイル(beam
という拡張子で実行バイナリが作成されます)は、_build
ディレクトリ以下に配置されます。
$ mix deps.compile ==> holiday_jp Compiling 4 files (.ex) Generated holiday_jp app $ ls _build/dev/lib/ holiday_jp sample_app $
プロジェクト本体のコードをコンパイルするには、mix compile
を実行します。 なお、後述するアプリケーションの実行時に自動でソースコードがコンパイルされるので、コンパイルコマンドは省略可能です。
$ mix compile Compiling 1 file (.ex) Generated sample_app app $
4. アプリケーションの実行
Elixirの対話環境であるiex
を起動する際に、 オプションとして-S mix
を追加すると、 ライブラリなどの依存関係を読み込んだ上でアプリケーションを対話環境内で立ち上げることが可能です。
holiday_jp
ライブラリを使ったアプリケーションを、依存関係を読み込んだ上でiex
を使って起動し、祝日を判定してみましょう。
$ iex -S mix iex(1)> HolidayJp.on ~D[2017-02-11] [%HolidayJp.Holiday{date: ~D[2017-02-11], name: "建国記念の日", name_en: "National Foundation Day", week: "土", week_en: "Saturday"}] iex(2)> HolidayJp.on ~D[2017-02-13] [] iex(3)>
2017年2月11日は建国記念の日、2017年2月13日は祝日ではない、ということがわかりました!
まとめ
この記事では、Elixirのプロセスをベースにしたプログラミングの考え方と、 GenServerを使ったサーバーの構成、スーパバイザーの組み込み方、 そしてElixirプロジェクトの開発の流れについて駆け足で紹介しました。
Javaのようなオブジェクト指向言語ではオブジェクトを中心にプログラムを設計するのに対し、 Elixirでは、この記事で解説したように、プロセスを中心にプログラムを設計します。 さらに、OTP(とGenServerやSupervisorなどのビヘイビア)を使うことで さまざまな処理を安全かつシンプルに実装し、並行動作させることができます。
プロセスの並行処理については、この記事では同期呼び出しと非同期呼び出しの違いを簡単に紹介しただけでした。 次回の記事では、もう少し詳しく、Elixirで並行かつ安全なアプリケーションを開発する方法を紹介します。
お楽しみに!
執筆者プロフィール
大原常徳(おおはら・つねのり、GitHub・Twitter)
編集協力:鹿野桂一郎(しかの・けいいちろう、Twitter) {$annotation_17}技術書出版ラムダノート