どうも、靖宗です。
Mix and OTP編もこれで終了?
チュートリアルが終わっただけでまだなーんにもやってないんですが、基礎的な所は一通り目を通せた感じです。
まだMeta-programming編が残ってるので入門が終わりって訳じゃ無いんですが、とりあえず進めて行きます。
- Our first distributed code
- async/await
- Distributed tasks
- Routing layer
- Test filters and tags
- Application environment and configuration
- 総括
この章ではKVServer
ではなく:kv
をまた修正していくようです。
現状は一台のマシンでプロセスを管理してますが、実際の運用を考えると複数台のノードに振り分けるルーティング(負荷分散など)が必要になってきそうです。
ここではBucket名の最初の文字でルーティングを行う想定で、a~m
までをfoo@computer-name
、n~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つが考えられるそうです。
- Erlangの
:rpc
モジュールを使う。 - GenServerモジュールの
call
を使う。GenServer.call({name, node}, arg)
で呼び出せる? - Taskを使う。
:rpc
とGenServer
の方法はシングルプロセスで動作するっぽいんですが、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/2
やapply/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~m
のhello
とn~z
のworld
のBucketを作成し、プロセスが分散するかチェックします。
あとは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.exs
のapplication
あたりをいじるんじゃないだろうかと予想できますが、その通りでした。
apps/kv/mix.exs
のapplication/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 environment
はconfig.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を色々勉強していきたいと思います。