本稿はElixir公式サイトの許諾を得て「Agent」の解説にもとづき、加筆補正を加えて、ElixirのAgent
とExUnit
によるそのテストについてご説明します。
練習用のコードは、モジュールKV
に子モジュールを加えるかたちで進めます。
$ mix new kv --module KV
「MixとOTP」シリーズをとおしてつくられるアプリケーションは
josevalim/kv_umbrella
です。
プロセスを扱いますので、必要に応じて「Elixir入門 11: プロセス」をご参照ください。
状態の操作
ElixirとOTPでは、つぎのような抽象化された機能が使えます。
-
Agent
: 状態を包む単純なラッパー。 -
GenServer
: 状態をカプセル化する「汎用サーバー」(プロセス)。同期と非同期の呼び出しやコードの再読み込みなどに対応します。 -
Task
: 非同期の処理単位。プロセスを生成したり、あとから結果を得ることもあります。
これらの機能はプロセス上に実装されています。そして、VMに備わるsend
やreceive
、spawn
、link
などの基本的な操作を用いているのです。
Agent
Agent
は、状態を包む単純なラッパーです。プロセスから得たいのが状態だけでしたら、もっとも適しています。iex
のセッションをプロジェクト内で始めましょう。
$ iex -S mix
まず、新たなAgent
を開始して、空のリスト[]
の状態で初期化します。つぎに、Agent
を更新して、新たな項目はリストのヘッドに加えます。そのうえで、更新されたリストを値として得ています。最後にAgent
は停止して、プロセスを終了させます。
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.138.0>}
iex> Agent.update(agent, fn list -> ["eggs" | list] end)
:ok
iex> Agent.get(agent, fn list -> list end)
["eggs"]
iex> Agent.stop(agent)
:ok
-
Agent.start_link/2
: 現在のプロセスにリンクしたAgent
を引数の関数でつくります。 -
Agent.update/3
: 第1引数のAgent
の状態を、第2引数の関数で更新します。関数が引数に受け取るのは、Agent
の現在の状態です。 -
Agent.get/3
: 第1引数のAgent
から値を、第2引数の関数で受け取ります。 -
Agent.stop/3
:Agent
を同期的に停止します。
Agent
をモジュールに実装しましょう。新たにつくるモジュールはKV.Bucket
です。けれど、実装より先にテストを書くことにします。プロジェクトの中にテストファイルtest/kv/bucket_test.exs
をつくり、記述するのは以下のコードです(テストファイルの拡張子は.exs
とします)。
テスト(test/3
)はまず、モジュールKV.Bucket
のstart_link/1
に空のリストを渡して呼び出します。つぎに、get/2
とput/3
を順に呼び、それぞれassert/1
で結果が確かめられます。
ExUnit.Case
にオプションasync: true
を渡していることにご注意ください。このオプションにより、テストは非同期で行われます。つまり、マルチコアのマシン上で、他の非同期(async: true
)のテストケースと並行して実行されるのです。テストスイートを高速化するのにとても効果があります。ただし、非同期の設定は、グローバルな値に依存したり、変更したりしない場合にのみ用いてください。たとえば、テストのときファイルシステムに書き込んだり、データベースを参照する場合には、同期処理(:async
は使わない)にしないとテスト間の競合状態が招かれるからです。
defmodule KV.BucketTest do
use ExUnit.Case, async: true
test "stores values by key" do
{:ok, bucket} = KV.Bucket.start_link([])
assert KV.Bucket.get(bucket, "milk") == nil
KV.Bucket.put(bucket, "milk", 3)
assert KV.Bucket.get(bucket, "milk") == 3
end
end
このテストは今はもちろん失敗します。まだ、モジュールKV.Bucket
そのものを書いていないからです(module KV.Bucket is not available)。
$ mix test test/kv/bucket_test.exs
1) test stores values by key (KV.BucketTest)
test/kv/bucket_test.exs:4
** (UndefinedFunctionError) function KV.Bucket.start_link/1 is undefined (module KV.Bucket is not
available)
code: {:ok, bucket} = KV.Bucket.start_link([])
stacktrace:
KV.Bucket.start_link([])
test/kv/bucket_test.exs:5: (test)
Finished in 0.02 seconds
1 test, 1 failure
Randomized with seed 980520
KV.Bucket
モジュールは、新たなファイルlib/kv/bucket.ex
に定めます。コードは以下のとおりです。まず、use/2
で、コンテキストにモジュールAgent
を使います。
つぎに、start_link/1
関数がAgent
を開始します。start_link/1
にはオプションとしてリストを受け取るのが通例です。今のところ、オプションは定めておくだけで使いません。関数本体からはAgent.start_link/2
を呼び出して、引数には無名関数が渡され、Agent
の初期状態を返します。
Agent
にはマップでキーと値を納めます。get/2
とput/3
はAgent
のAPIでマップの値を操作し、引数の関数にはキャプチャ演算子&
が用いられています(「Elixir入門 08: モジュールと関数」の「関数のキャプチャ」参照)。
defmodule KV.Bucket do
use Agent
@doc """
新たな`Bbucket`をつくる。
"""
def start_link(_opts) do
Agent.start_link(fn -> %{} end)
end
@doc """
`bucket`から`key`で値を得る。
"""
def get(bucket, key) do
Agent.get(bucket, &Map.get(&1, key))
end
@doc """
`bucket`の`key`に`value`を与える。
"""
def put(bucket, key, value) do
Agent.update(bucket, &Map.put(&1, key, value))
end
end
KV.Bucket
モジュールが定められ、使われる関数も実装しましたので、テストは成功します。
$ mix test test/kv/bucket_test.exs
Compiling 1 file (.ex)
.
Finished in 0.02 seconds
1 test, 0 failures
Randomized with seed 313891
ExUnitのコールバックによるテストの設定
Agent
を使ったモジュールのテストでは、予めAgent
を起ち上げておかなければなりません。テストごとに起動せずに済むように、ExUnit
にはコールバックが備わっています。コールバックを定めるのはsetup/1
マクロです。setup/1
のコールバックは各テストの前に、テストと同じプロセスで実行されます。
テストに渡しているのは、コールバックから返されるマップのPIDです。このときテストのコンテキストが用いられます。コールバックから%{bucket: bucket}
が返されると、ExUnit
はテストコンテキストにマップを取り込みます。テストコンテキストもマップですので、パターンマッチングを使って、テストの中の%{bucket: bucket}
が参照できるのです。
defmodule KV.BucketTest do
use ExUnit.Case, async: true
setup do # setupを追加
{:ok, bucket} = KV.Bucket.start_link([])
%{bucket: bucket}
end
# test "stores values by key" do
test "stores values by key", %{bucket: bucket} do
# {:ok, bucket} = KV.Bucket.start_link([])
assert KV.Bucket.get(bucket, "milk") == nil
KV.Bucket.put(bucket, "milk", 3)
assert KV.Bucket.get(bucket, "milk") == 3
end
end
さらに詳しくは「ExUnit.Case
」をお読みください。また、コールバックについては「ExUnit.Callbacks
」に解説されています。
Agentのその他のアクション
Agent.get/3
で値を得て、Agent.update/3
により状態を更新するほかに、ふたつの処理をひとつの関数の呼び出しで行うこともできます。それがAgent.get_and_update/3
です。現在の値を返すとともに、状態は更新されます。この関数によりAgent
からキーを除くのが、以下に加えたKV.Bucket.delete/2
関数です。
@doc """
`bucket`から`key`を除きます。`key`が存在していたら、その値を返します。
"""
def delete(bucket, key) do
Agent.get_and_update(bucket, &Map.pop(&1, key))
end
練習として、新たな関数KV.Bucket.delete/2
のテストを加えてみましょう。Agent
について詳しくは、モジュール「Agent
」の解説をお読みください。
Agentのクライアント/サーバー
Agent
におけるクライアントとサーバーの区別について触れておきます。前掲KV.Bucket.delete/2
を例として使いますので、手が加えやすいように関数はキャプチャせず、Map.pop/3
を呼び出します。
def delete(bucket, key) do
Agent.get_and_update(bucket, fn dict ->
Map.pop(dict, key)
end)
end
Agent
に渡した関数の中で行う処理は、すべてそのAgent
のプロセスで扱われます。Agent
のプロセスはメッセージを受け渡すので、このプロセスはサーバーと呼ばれます。引数の関数の外で扱われるものがクライアントです。ふたつの区別は重要です。負荷の高い処理があるとき、実行をクライアントにするかサーバーにするか考えなければなりません。
時間のかかる処理がサーバーで行われると、そのサーバーへの他のリクエストは処理が終わるのを待つことになります。すると、クライアントによってはタイムアウトが生じるかもしれないのです。
def delete(bucket, key) do
Process.sleep(1000) # クライアントを待たせる
Agent.get_and_update(bucket, fn dict ->
Process.sleep(1000) # サーバーを待たせる
Map.pop(dict, key)
end)
end
Top comments (0)