DEV Community

gumi TECH for gumi TECH Blog

Posted on • Edited on

MixとOTP 02: Agent

本稿はElixir公式サイトの許諾を得て「Agent」の解説にもとづき、加筆補正を加えて、ElixirのAgentExUnitによるそのテストについてご説明します。

練習用のコードは、モジュールKVに子モジュールを加えるかたちで進めます。

$ mix new kv --module KV
Enter fullscreen mode Exit fullscreen mode

「MixとOTP」シリーズをとおしてつくられるアプリケーションはjosevalim/kv_umbrellaです。

プロセスを扱いますので、必要に応じて「Elixir入門 11: プロセス」をご参照ください。

状態の操作

ElixirとOTPでは、つぎのような抽象化された機能が使えます。

  • Agent: 状態を包む単純なラッパー。
  • GenServer: 状態をカプセル化する「汎用サーバー」(プロセス)。同期と非同期の呼び出しやコードの再読み込みなどに対応します。
  • Task: 非同期の処理単位。プロセスを生成したり、あとから結果を得ることもあります。

これらの機能はプロセス上に実装されています。そして、VMに備わるsendreceivespawnlinkなどの基本的な操作を用いているのです。

Agent

Agentは、状態を包む単純なラッパーです。プロセスから得たいのが状態だけでしたら、もっとも適しています。iexのセッションをプロジェクト内で始めましょう。

$ iex -S mix
Enter fullscreen mode Exit fullscreen mode

まず、新たな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
Enter fullscreen mode Exit fullscreen mode
  • 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.Bucketstart_link/1に空のリストを渡して呼び出します。つぎに、get/2put/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
Enter fullscreen mode Exit fullscreen mode

このテストは今はもちろん失敗します。まだ、モジュール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
Enter fullscreen mode Exit fullscreen mode

KV.Bucketモジュールは、新たなファイルlib/kv/bucket.exに定めます。コードは以下のとおりです。まず、use/2で、コンテキストにモジュールAgentを使います。

つぎに、start_link/1関数がAgentを開始します。start_link/1にはオプションとしてリストを受け取るのが通例です。今のところ、オプションは定めておくだけで使いません。関数本体からはAgent.start_link/2を呼び出して、引数には無名関数が渡され、Agentの初期状態を返します。

Agentにはマップでキーと値を納めます。get/2put/3Agentの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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

さらに詳しくは「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
Enter fullscreen mode Exit fullscreen mode

練習として、新たな関数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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

MixとOTPもくじ

Top comments (0)