技術メモ

プログラミングとか電子工作とか

Elixir入門(Mix and OTP編 第10章 Distributed tasks and configuration)

f:id:ysmn_deus:20190122112104p:plain

どうも、靖宗です。
Mix and OTP編もこれで終了?
チュートリアルが終わっただけでまだなーんにもやってないんですが、基礎的な所は一通り目を通せた感じです。
まだMeta-programming編が残ってるので入門が終わりって訳じゃ無いんですが、とりあえず進めて行きます。

この章ではKVServerではなく:kvをまた修正していくようです。
現状は一台のマシンでプロセスを管理してますが、実際の運用を考えると複数台のノードに振り分けるルーティング(負荷分散など)が必要になってきそうです。 ここではBucket名の最初の文字でルーティングを行う想定で、a~mまでをfoo@computer-namen~zまでをbar@computer-nameのものとするとき、

[
  {?a..?m, :"foo@computer-name"},
  {?n..?z, :"bar@computer-name"}
]

というフォーマットで管理される想定で実装します。
今回みたいな小規模なアプリケーションであればここまでする必要は無さそうですが、より大規模なものだとこういった負荷分散をシンプルなルーティングマップで管理出来るのは魅力的です。(スケールしやすい)

Our first distributed code

Elixirの標準機能でノード同士(例えば同じネットワークで2台のパソコンがiexを開いている状態、このiexで開かれている仮想マシンのプロセス同士)で情報のやりとりができるそうです。プロセス同士のメッセージングみたいなものかな?
同じネットワーク上でお互いが識別できる状態であれば、ユーザー名@パソコン名の様な名称でおたがいに色々できるみたいです。
まずはiexで試してみます。iexをiex --sname fooで起動します。(Windowsならiex.bat --sname foo

PS > iex.bat --sname foo
Interactive Elixir (1.8.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(foo@PC名)1>

いつものiex(1)>じゃなくなりました。
このiex上でモジュールを定義してみます。

iex> defmodule Hello do
...>   def world, do: IO.puts "hello world"
...> end

次に同じPCで良いのでまたiexに名称を付けて開きます。今度はiex --sname barで開きます。
当然ですが、こちらのプロセスではHelloモジュールは定義してないのでHello.world/0できません。

iex(bar@PC名)1> Hello.world
** (UndefinedFunctionError) function Hello.world/0 is undefined (module Hello is not available)
    Hello.world()

ここでNode.spawn_linkを使えば、別のノード上でプロセスが実行できます。

iex(bar@PC名)1> Node.spawn_link :"foo@PC名", fn -> Hello.world end
hello world
#PID<10368.117.0>

プロセスIDも実行結果も返ってきてますが、このプロセスはfoo@PC名で実行されています。
プロセスは別ノードで実行されてるのに出力が実行ノードで返ってきてるのは不思議に思うかもしれませんが、おそらくNode.spawn_linkを実行したノードがそのプロセスのgroup leaderとして見なされ、標準出力がプリントされたのでしょう。group leaderのお話は以前の十二章で若干取り上げてました。ようやく使いどころというか使われどころが出てきたのでしっくりきました。

ここでもう少しNode.spawn_linkで遊んでみます。別ノード間でPing-Pongのメッセージングをやってみます。

iex(bar@PC名)14> pid = Node.spawn_link :"foo@DESKTOP-YSMN519", fn ->
...(bar@PC名)14>   receive do
...(bar@PC名)14>     {:ping, client} -> send client, :pong
...(bar@PC名)14>   end
...(bar@PC名)14> end
#PID<10368.20069.0>
iex(bar@PC名)15> flush() # 一応なんも届いてないのを確認
:ok
iex(bar@PC名)16> send pid, {:ping, self()}
{:ping, #PID<0.105.0>}
iex(bar@PC名)17> flush()
:pong
:ok

問題無さそうです。

とりあえずNode.spawn_link/2を試しましたが、今までの流れから行くとspawn_linkでプロセス生成すな!supervisorで管理しろ!という話になりそうです。実際なります。
Elixirでこの実装は以下の3つが考えられるそうです。

  1. Erlang:rpcモジュールを使う。
  2. GenServerモジュールのcallを使う。GenServer.call({name, node}, arg)で呼び出せる?
  3. Taskを使う。

:rpcGenServerの方法はシングルプロセスで動作するっぽいんですが、TaskであればSupervisorが良い感じに非同期的に実行してくれるようです。(もちろん各プロセスは基本的にシングルプロセス)
なのでサンプルではルーティングはTaskを使って実装するそうです。

async/await

ジャバスクリプトっぽいのがきました。
とりあえずasyncして非同期実行してawaitで同期実行というイメージでしょうか?サンプルを見ます。

task = Task.async(fn -> compute_something_expensive end)
res  = compute_something_else()
res + Task.await(task)

Task.asyncでプロセスを発行してそのまま非同期的に実行、Task.awaitでその結果を取り出せるみたいです。
前回、serveの処理をTask.Supervisorに投げましたが、このTask.SupervisorでもTask.Supervisor.start_child/2を使う代わりにTask.Supervisor.async/2を使って実行すればTask.await/2で値を取り出せるようです。
前回に限って言えば取り出したい値などはTCPのメッセージング以外には必要無かったので困りませんでしたが、値を取り出したい場合はasyncを使うのも良いのかもしれません。

Distributed tasks

実際にiex上でTask.Supervisor.async/2を試してみます。supervisorを通して実行する以外はほぼ上のTaskと同じ操作です。
lib/kv/supervisor.exのchildrenに{Task.Supervisor, name: KV.RouterTasks}を追加します。

    children = [
      {Task.Supervisor, name: KV.RouterTasks},
...

これでTaskのsupervisorがKV.RouterTasksという名前で実行されます。
iex -S mixでiexを二つ実行します。

$ iex --sname foo -S mix
$ iex --sname bar -S mix

barのノードからfooのノードへプロセスを発行します。

iex> task = Task.Supervisor.async {KV.RouterTasks, :"foo@computer-name"}, fn ->
...>   {:ok, node()}
...> end
%Task{owner: #PID<0.122.0>, pid: #PID<12467.88.0>, ref: #Reference<0.0.0.400>}
iex> Task.await(task)
{:ok, :"foo@computer-name"}

いけました。
今回は無名関数をTaskとしてKV.RouterTasksに投げましたが、タスクをルーティングして分配するときはモジュール名などを使用して処理を実行することが望ましいとされています。

iex> task = Task.Supervisor.async {KV.RouterTasks, :"foo@computer-name"}, Kernel, :node, []
%Task{owner: #PID<0.122.0>, pid: #PID<12467.89.0>, ref: #Reference<0.0.0.404>}
iex> Task.await(task)
:"foo@computer-name"

('ω')。o(Kernel, :node, []????????????)
この辺の挙動はMeta-programming編を進めてみないと厳密には言及できないと思いますが、どうやらKernel, :node, []というのはKenel.node([])と同義のようです。
おそらくよりElixirのコア部分で解釈しやすい形式としてKernel, :node, []という形で関数を渡しているのだと思います。
バージョンの差異などを鑑みると、こういった分散処理はなかなか鬼門な気がします・・・

Routing layer

それでは、以上を踏まえた上でlib/kv/router.exを作成していきます。

defmodule KV.Router do
  @doc """
  Dispatch the given `mod`, `fun`, `args` request
  to the appropriate node based on the `bucket`.
  """
  def route(bucket, mod, fun, args) do # mod:モジュール名、fun:関数名、args;引数
    # Get the first byte of the binary
    first = :binary.first(bucket)

    # Try to find an entry in the table() or raise
    # entryには{?a..?m, :"foo@computer-name"}という該当するテーブルが入る
    entry =
      Enum.find(table(), fn {enum, _node} ->
        first in enum
      end) || no_entry_error(bucket)

    # If the entry node is the current node
    # テーブル名では使えないのでテーブルの2個目の要素(0から数えると1)をelem/2で取り出して比較
    if elem(entry, 1) == node() do
      # 自分のノードが担当だったら実行
      apply(mod, fun, args)
    else
      {KV.RouterTasks, elem(entry, 1)}
      |> Task.Supervisor.async(KV.Router, :route, [bucket, mod, fun, args])
      |> Task.await()
    end
  end

  defp no_entry_error(bucket) do
    raise "could not find entry for #{inspect bucket} in table #{inspect table()}"
  end

  @doc """
  The routing table.
  """
  def table do
    # Replace computer-name with your local machine name
    [{?a..?m, :"foo@PC名"}, {?n..?z, :"bar@PC名"}]
  end
end

PC名の箇所は要編集です。この辺のハードコードがエレガントじゃないなぁ・・・

elem/2apply/3あたりは初見です。
elem/2は第一引数のリストなり何なりの第二引数のインデックス要素を出力する関数のようです。
apply/3は純粋にモジュールの関数を実行する関数で下記のようになります。

iex> apply(Enum, :reverse, [[1, 2, 3]])
[3, 2, 1]

つまりapply(mod, fun, args)というのはmod.fun(args)を実行するのと同義です。
おそらく別ノードで実行するにあたってroute/4関数がmod, fun, argsを受けているからこの関数を使って実行しているのでしょう。

だいたい良さそうなのでテストコードもtest/kv/router_test.exsに書いていきます。

defmodule KV.RouterTest do
  use ExUnit.Case, async: true

  test "route requests across nodes" do
    assert KV.Router.route("hello", Kernel, :node, []) ==
           :"foo@PC名"
    assert KV.Router.route("world", Kernel, :node, []) ==
           :"bar@PC名"
  end

  test "raises on unknown entries" do
    assert_raise RuntimeError, ~r/could not find entry/, fn ->
      KV.Router.route(<<0>>, Kernel, :node, [])
    end
  end
end

このテストもPC名は要編集です。
一応a~mhellon~zworldBucketを作成し、プロセスが分散するかチェックします。
あとはa~zのきちんとしたアルファベットでないBucket名が来たらはじかれるかのテストです。

このテストを動かすには一個ノードを動かしておく必要があります。この辺テストコードでなんとかならんのかなぁ。
apps/kvディレクトリでiexを実行しておきます。

$ iex --sname bar -S mix

ディレクトリで、下記のコマンドでテストを実行します。

$ elixir --sname foo -S mix test

問題無く通りました!

Test filters and tags

もう大体よさそうなんですが、テストがややこしくなってきました。
複数ノードを使うテストなんかは、自分がノードとして明示的に実行されているか否か(elixir --sname foo -S mix testで実行されているか否か)でテストの内容を変更できれば開発に役立ちそうです。
そこで、テストにタグを付け、ExUnitでフィルタリングしてスキップするか否かを設定します。まずはtest/kv/router_test.exsのテストにタグを付けます。

@tag :distributed
test "route requests across nodes" do

このタグは前回:capture_logというものを付けました。おそらくExUnitに対して色々設定できるタグがあるんだと思います。
このタグのフィルタリング設定を書くためにtest/test_helper.exsを編集します。

exclude =
  if Node.alive?, do: [], else: [distributed: true]

ExUnit.start(exclude: exclude)

Node.alive?、はローカルのノードが生きてるか否かを返す関数で、elixir --sname foo -S mix testとして実行されているか否かを判別しています。
きちんと名称を付けてノードとしてテストが実行されていればExUnit.start[distributed: true]が渡され、:distributedのタグがついてるテストがスキップされるという流れです。
試しにmix testしてみます。

PS > mix test
Excluding tags: [distributed: true]

..........

Finished in 0.06 seconds
1 doctest, 10 tests, 0 failures, 1 excluded

Randomized with seed 170000

タグ付けしたテストがスキップされてます。
今度はelixir --sname foo -S mix testとしてテストを実行します。

PS > elixir --sname foo -S mix test
...........

Finished in 0.09 seconds
1 doctest, 10 tests, 0 failures

Randomized with seed 275000

ちゃんとスキップされずに通りました。
ちなみに、distributedというタグ付けされたテストだけ実行したい場合はelixir --sname foo -S mix test --only distributedというかんじにするそうです。

PS > elixir --sname foo -S mix test --only distributed
Excluding tags: [:test]
Including tags: [:distributed]

.

Finished in 0.06 seconds
1 doctest, 10 tests, 0 failures, 10 excluded

Randomized with seed 852000

ちなみに、distributedというタグはcapture_logのようにExUnitの中で特別な意味のあるタグではなく、ユーザーが勝手に付けたタグ名です。なので、自分の好きなようにタグ付けしてテストをカスタマイズすることができます。
ただし、capture_logのようにExUnitで特別な意味を持つタグ名は避ける必要があるので、タグ付けの際にはExUnitのドキュメントを参照することをオススメします。

Application environment and configuration

そういえば、KV.Routerのルーティングテーブルはハードコードされていました。これはエレガントではないので別ファイルに切り出したいところです。
そこでapplication environmentという機能の登場だそうです。

applicationの環境ということで多分mix.exsapplicationあたりをいじるんじゃないだろうかと予想できますが、その通りでした。
apps/kv/mix.exsapplication/0を編集していきます。

def application do
  [
    extra_applications: [:logger],
    env: [routing_table: []], # これが増えた
    mod: {KV, []}
  ]
end

envというオプションが増えてます。
次に、ルーティングテーブルが必要なところで呼び出すようにします。lib/kv/router.exを修正します。

@doc """
The routing table.
"""
def table do
  Application.fetch_env!(:kv, :routing_table)
end

Application.fetch_env!/2という関数でenvに追加した値を取ってこれるようです。第一引数にアプリケーション名、第二引数に環境変数名といったところでしょうか。
ちなみに、予想通りApplication.fetch_env/2も存在し、返値は{:ok, value}という形になるようです。 今のところrouting_tableには値を入れていないのでテストを実行するとエラーがでます。

ちなみに、このapplication environmentconfig.exsファイルにも記載できます。
apps/kv/config/config.exsにルーティングテーブルを記載してみます。

config :kv, :routing_table, [{?a..?m, :"foo@PC名"}, {?n..?z, :"bar@PC名"}]

これでテストを実行すると、問題無く通ります。
つまり、config.exsにアプリケーションの環境変数を記載すると、apps/kv/mix.exsに書かれた環境変数は上書きされます。
なのでルーティングテーブルや各種設定はconfig.exsに記載して読み込ませるのや良いでしょう。

Elixirのv1.2以降はumbrellaプロジェクト下にあるアプリケーションは環境変数を共有するそうです。
umbrellaプロジェクトにあるconfigディレクトリのconfig.exsにインポートする設定が記載されているので納得です。

import_config "../apps/*/config/config.exs"

また、明示的にconfig.exsをロードしたい場合はmix run --config hogehogeとするそうです。

このconfigを用いれば:kv_serverのポート番号を変更することも可能ですし、Mix.env()によって運用環境なのかそれ以外なのかでロードするconfigを使い分けることも可能です。

総括

以上でMix and OTP編は終了です。
mixの使い方やAgent, GenServer, SupervusirといったOTPを利用しましたが、不慣れでとっつきにくさを感じている反面、強力な実装(プロセス管理が明瞭で、動作が保証されている感)で色々実装してみたい欲が湧きました。
とりあえず次回以降はMeta-programming編を進めて行きたいですが、その次はとうとうPhoenix Frameworkを色々勉強していきたいと思います。