エンジニアHubPowered by エン転職

若手Webエンジニアのための情報メディア

最速で知る! ElixirプログラミングとErlang/OTPの始め方【第二言語としてのElixir】

第二言語としてのElixir、今回はErlangのVM上のプロセスをElixirで扱う方法を説明し、Elixirでどのようにアプリケーションを構築するのかを解説します。

はじめまして! 大原常徳(おおはら・つねのり)といいます。 今回から2回に分けて「第二言語としてのElixir」というテーマで、プログラミング言語Elixirの入門記事をお届けします。

第二言語としてElixir Vol. 1

Elixirは、José Valim氏によって開発されているプログラミング言語です。 最大の特徴は、ErlangのVM上で動作し、Erlangのモジュールを利用できることでしょう。 ちょうど、ScalaがJava VM上で動作し、Javaの関数を利用できるという関係に似ていますね。

elixir-lang.org

Elixir

ErlangのVM上で動作することから、Elixirには次のような特徴が備わっています。

  • 耐障害性
  • 高可用性
  • 分散アプリケーションの構築のしやすさ

Erlangでは「プロセス間のメッセージパッシング」というErlang独自の概念をうまく使うことで、びっくりするくらいあっさりとこれらの特徴を実現しています。

そこでこの記事では、まずErlangのVM上のプロセスをElixirで扱う方法を説明してから、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の後にqRetrunを入力します。

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. プロセスから別のプロセスに対してメッセージを送信する
  2. プロセスからのメッセージを別のプロセスが受信する
  3. プロセスが別のプロセスを生成する
  4. プロセスが別のプロセスをリンクする
  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が終了(再起動)してはたまらないので、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は、基本的なクライアント/サーバー処理におけるサーバーの振る舞い、つまり

  1. リクエストを投げ、
  2. サーバーの状態を更新し、
  3. レスポンスが返却される、

という処理を抽象化したパターンです。

このパターンにしたがって振る舞うサーバーを実装したかったら、 サーバーを実装するモジュールの先頭で、 次のように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)>

初期値を010100として、3つのサーバープロセスを起動しています。 それぞれinitで定義したとおりにメッセージが出力されていることがわかります。

サーバープロセスの初期化

これで、GenServerを使って定義したカウンターサーバーのプロセスが起動し、 起動時にはinit関数で定義した動作をすることがわかりました。 続いて、このカウンターサーバーに、カウントアップとカウントダウンの機能を実装していきましょう。

しかし、その前に、一般にサーバーの機能をクライアントから呼び出すときの2つの方法の違いについて説明します。

同期呼び出しと非同期呼び出し

汎用サーバーに対する操作には、同期呼び出しと、非同期呼び出しの2種類があります。

同期呼び出しと非同期呼び出しの違いは何でしょうか? 同期呼び出しでは、サーバーがクライアントに実行結果を返します。 一方、非同期呼び出しでは、サーバーがクライアントに実行結果を返しません。

つまり、同期呼び出しでは、サーバーにおける処理が完了して実行結果を返せるようになった時点で、呼び出したクライアントにその実行結果が返されます。 一方、非同期呼び出しでは、サーバー側で処理が完了していなくてもクライアントに何らかの結果が返されます。

サーバーの処理が終わる前に結果が返る非同期呼び出しは、何が嬉しいのでしょうか?

例えば、ものすごく時間がかかる処理を考えてみてください。 同期呼び出しでは、実行時間が長い処理が完了するまで、結果が返るのを待っている必要があります (つまり呼び出し側でサーバー側の処理が終わるまで待たないといけません)

非同期呼び出しでは、どれだけ処理に時間がかかろうと、即座に処理が返ってくるので、 サーバーの処理が終わるのを待っている時間がないのです。 これが非同期呼び出しの大きなメリットです。

GenServerには、サーバーの処理を同期呼び出しとして実装するための仕組みも、 非同期呼び出しとして実装するための仕組みも用意されています。 これらの仕組みを使って、カウンターサーバーの機能を実装してみましょう。

カウンターサーバーに同期呼び出しの機能を実装する

Counterモジュールに、同期呼び出しでカウントアップとカウントダウンの機能を実装しましょう。

GenServerを使って同期呼び出しされる処理を実装するには、

handle_call(<リクエスト識別子>, <リクエスト元のプロセスID>, <更新前のサーバーの状態>)

という関数を定義します。 この関数は、戻り値として{:reply, <クライアントへの返却値>, <更新後のサーバーの状態>}を返すようにします。

handle_callとして定義した処理を呼び出すときは、 GenServer.call(<汎用サーバープロセス>, <リクエスト識別子>)とします。 すると、Counterモジュールに定義したhandle_callが実行され、<クライアントへの返却値>が返されるというわけです。

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)>

カウントアップができました!

state更新によるカウントアップ
同期呼び出しのカウントダウン

続いて、カウントダウンをリクエスト識別子: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>がないことに注目してください。

castによる非同期呼び出し

非同期版のカウントアップとカウントダウンは、以下のように実装できます。

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を使うことで、inithandle_callhandle_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というライブラリ管理の仕組みがあります。

hex.pm

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.getmix 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関数に、利用するライブラリを記述します。 ライブラリの記述方法は、以下のどちらの方法で取得するかによって異なります。

  1. hex.pm経由でライブラリを取得
  2. 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.exsdepsを定義したら、mix deps.getコマンドでライブラリを取得します。 実行後、depsディレクトリが作成され、ここにライブラリ(今の例ではholiday_jpが配置されます。

mix.exsdeps

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で並行かつ安全なアプリケーションを開発する方法を紹介します。

お楽しみに!

執筆者プロフィール

大原常徳(おおはら・つねのり、GitHubTwitter

大原常徳
サーバーサイドエンジニア。好きな言語はLispとErlang、もちろんElixirも。 2011年から株式会社ドリコムにて広告システムと基盤システムの開発に携わる。仏像制作が趣味。
一般社団法人Japan Elixir Association理事。tokyo.ex、Elixir Conf Japan幹事。

関連記事

いま学ぶべき第二のプログラミング言語はコレだ! 未来のために挑戦したい9つの言語とその理由

挑戦! Elixirによる並行・分散アプリケーションの作り方【第二言語としてのElixir】

編集協力:鹿野桂一郎(しかの・けいいちろう、Twitter 技術書出版ラムダノート