10 Jul 2014

2 Agent

Mixの翻訳をしました。

今週は思い思いの場所で引き続き翻訳活動をしてみました。先週にも増してうまく翻訳できた自信ありませんが、GitHubのリポジトリも用意してあるので、誤訳等ご指摘いただけたら幸いです。


この章ではKV.Bucketというモジュールを作ります。このモジュールはキーバリューの読込や変更に対して異なるプロセスで反応します。

もしGetting Startedのガイドを読み飛ばしていたり、前に読んでから時間が経ってしまった場合、Processesの章を読み直すことをお勧めします。この章ではProcessesの知識が前提です。

2.1 ステートの問題点

Elixirはイミュータブルな言語で、通常では共有させることができません。もしバケットを複数の場所で作成、保存、操作などを行う場合、Elixirではふたつの方法が用意されています。

ここではまずETSというものがどういった違いについて触れる必要があるまでプロセスについて紹介します。プロセスの代わりにETSを用いるケースは滅多にありませんが、ElixirとOTPで抽象的な操作を行うことができます。

  • エージェント - ステート周りのシンプルなラッパー
  • GenServer - プロセスをカプセル化したり、同期通信、非同期通信やコードの再読込をサポートするジェネリックなサーバー
  • GenEvent - イベントの共有化や複数のハンドラーを管理するためのジェネリックなイベント
  • タスク - プロセスを発行したり、後から簡単に呼び出すことができる非同期処理の単位

これらの抽象的な操作はすべて網羅していきます。これらがプロセスでVMの機能にあるsendreceivespawnlinkを包括していることを覚えていてください。

2.2 エージェント

エージェントはステートにおけるシンプルなラッパーです。もし状態を保つためにプロセスを使うのであれば、エージェントがうまく解決してくれます。それでは早速プロジェクトのディレクトリでiexを起動させてください。

$ iex -S mix

まずはエージェントの簡単な例で説明します。

iex> {:ok, agent} = Agent.start_link fn -> [] end
{:ok, #PID<0.57.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.stop/1を呼んでプロセスを完全に終了させます。

KV.Bucketはエージェントを使って用意するのですが、その前にいくつか最初のテストを用意します。test/kv/bucket_test.exsというファイル(.exsは拡張子)を用意してください。

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を作り、そのアサーションは単純にget/2put/3が操作した結果を用意します。これらを止める方法は、テストそのもののプロセスはテストの終了に伴って自動で行われるためにわざわざ用意する必要がありません。

もうひとつ注意すべき部分はExUnit.Caseasync: trueという設定をしなければならない点です。これはこのテストが他のテストケースも含めて一斉に実行されるという:asyncオプションです。これはCPUのコアの数だけテストケース全体を高速化させることができるので非常に便利です。ただし:asyncオプションを有効にしている間はグローバルな値を参照させたり、変更させることはできません。例えば、テストにファイルシステムを用いたり、プロセスを登録したり、データベースの操作などを行う場合はテストを行っている間は非同期させないようにしてください。

非同期が必要か不要かどうかに拘らず、先ほど用意したばかりのテストは当然何も関数的な記述がなされていないために必ず失敗します。

テストの失敗を修正するために、lib/kv/bucket.exというファイルを用意します。次のような例を写す前に自分でKV.Bucketを用意しても構いません。

defmodule KV.Bucket do
  @doc """
  新しいバケットを開始する。
  """
  def start_link do
    Agent.start_link(fn -> HashDict.new end)
  end

  @doc """
  バケットから値を取得する。
  """
  def get(bucket, key) do
    Agent.get(bucket, &HashDict.get(&1, key))
  end

  @doc """
  バケットから渡された値を表示する。
  """
  def put(bucket, key, value) do
    Agent.update(bucket, &HashDict.put(&1, key, value))
  end
end

KV.Bucketモジュールが定義できればテストは成功します。この例ではMapの代わりにHashDictを使って状態を保存しています。現在のバージョンのElixirではマップに巨大なキーを持たせることが非効率だからです。

2.3 ExUnitのコールバック

KV.Bucketの追加機能をもう少し掘り下げて、ExUnitのコールバックについて触れていきます。KV.Bucketのテストを毎回セットアップと終了後の処理に関する振る舞いもテストに含めなければならないと思われるかもしれませんが、ExUnitには反復的なタスクを省略させるためのコールバックが備わっています。

テストケースをコールバックを用いて書き直してみましょう。

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

  setup do
    {:ok, bucket} = KV.Bucket.start_link
    {:ok, bucket: bucket}
  end

  test "stores values by key", %{bucket: bucket} do
    assert KV.Bucket.get(bucket, "milk") == nil

    KV.Bucket.put(bucket, "milk", 3)
    assert KV.Bucket.get(bucket, "milk") == 3
  end
end

まず最初にセットアップに用いるsetup/1というコールバックを定義しました。setup/1はすべてのテストが始まる前に実行され、テストに毎回必要な処理を定義しておくことができます。

バケットをテストするにはpidを扱う必要があり、テストコンテクストと呼ばれる方法を使います。これは{:ok, bucket: bucket}がコールバックによって返されると、ExUnitはふたつめの要素のタプル(あるいは辞書)をテストコンテクストにまとめます。テストコンテクストはテストの定義やブロックの値を参照するための手段です。

test "stores values by key", %{bucket: bucket} do
  # バケットはセットアップのブロックから引き継がれます。
end

ExUnitのケースについてはExUnit.Caseモジュールの ドキュメントExUnit.Callbacks のドキュメントを参照してください。

2.4 その他のエージェント操作

バケットの値を取得と更新を行うAgent.get_and_update/2という関数と、キーからバケットの消去を行うKV.Bucket.delete/2という関数をそれぞれ実装してみましょう。

@doc """
キーとバケットの消去。

キーが存在すれば、そのキーの値を返す
"""
def delete(bucket, key) do
  Agent.get_and_update(bucket, &HashDict.pop(&1, key))
end

それではこの関数を満たすテストケースを書いてみましょう。もちろん、エージェントに関する詳しいドキュメントを読んでみてもよいでしょう。

2.5 クライアントとサーバーのエージェント

次の章へ移る前に、クライアントとサーバーの二分法について触れておきましょう。先ほどのdelete/2を拡張します。

def delete(bucket, key) do
  Agent.get_and_update(bucket, fn dict->
    HashDict.pop(dict, key)
  end)
end

関数に含まれているものすべてがエージェントのプロセスで実行されます。今回はエージェントのプロセスがクライアントのリクエストを処理します。エージェントのプロセスをサーバーとして、サーバーの外側で発せられるものすべてをクライアントとして説明します。

この区別は重要です。もし、一度の処理に必要な手続きが多いときはサーバーとクライアントのどちらで処理をさせれば負荷を軽減させるか考えなければなりません。例えば、

def delete(bucket, key) do
  :timer.sleep(1000) # クライアントを待機させる
  Agent.get_and_update(bucket, fn dict ->
    :timer.sleep(1000) # サーバーを待機させる
    HashDict.pop(dict, key)
  end)
end

サーバー側に膨大な処理をさせている場合、すべてのアクションが完了するまでサーバーはずっと待機状態にさせなければならず、その結果クライアントに対してタイムアウトが発生させてしまうかもしれません。

次の章ではGenServersについて、クライアントとサーバーのはっきりとした違いについて説明していきます。