技術メモ

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

Elixir入門(Mix and OTP編 第2章 Agent)

f:id:ysmn_deus:20190122112104p:plain

どうも、靖宗です。
地味に第n章の箇所を漢数字からアラビア数字に変更しました。いちいち変換するのめんどくさい・・・

お次はAgent。たぶんMix and OTP編の全ての章に該当すると思うんですが、第1章を踏まえた上での内容になっとります。
あとProcessesも読んどいた方がいいよってあるので第十一章も必読かもしれません。

この章ではKV.Bucketというモジュールを作成していくようです。KV自体がモジュールじゃないの?

The trouble with state

Elixirは、というか関数型言語全般?は変数などが不変の言語なので出したり入れたりする入れ物の場所を作ってあげないと行けないそうで。
一見不便に見えるんですが、おそらくこの辺をしっかり設計してると堅牢なシステムが作れるんでしょうね。
それはさておき、入れ物を実現するにはElixirでは2種類の方法が考えられます。

  • Processes
  • ETS (Erlang Term Storage) まだやってない

ETSはあとの章で言及があるとして、プロセスは別プロセスとして起動してメッセージでやりとりするアレ(State?)ですね。
ただいちいちプロセスで云々やることは少ないそうで、ElixirやOTPでは下記の3つが採用されるそうです。

  • Agent シンプルな状態管理ラッパー?
  • GenServer 状態をカプセル化するプロセス?同期非同期使えてコードを再ロードできる。しらんけど。
  • Task 非同期の何か。名前的に並列処理のときとかに使いそう。

たぶん全部プロセスを拡張したようなやつ。
とりあえず読み進めましょう。

Agents

まずはAgenst(エージェント?)。プロセスのStateを再現したいならたぶんこれ。iex -S mixでiexを起動してサンプルを打ってみます。

iex(1)> {:ok, agent} = Agent.start_link fn -> [] end
{:ok, #PID<0.129.0>}
iex(2)> Agent.update(agent, fn list -> ["eggs" | list] end)
:ok
iex(3)> Agent.get(agent, fn list -> list end)
["eggs"]
iex(4)> Agent.stop(agent)
:ok

だいたいプロセスのところでやったStateと同じ様な感じなんですが、元から用意されてるんですね。
いちいちsendとかする代わりにAgentモジュールでやりとりする感じです。
Agent.updateなどは引数に関数を与えてますが、パイプラインを形成してるイメージでしょうか?(引数->結果をどんどん紡いでいく)

Agentを利用してKV.Bucketを実装していきますが、まずはKV.BucketTestを書いていきます。
場所はtest/kv/bucket_test.exsに保存します。(モジュール名/ファイル名が一般的?)

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

  test "stores values by key" do
    {:ok, bucket} = KV.Bucket.start_link([]) # KV.Bucketをstart_link/1で呼び出す(プロセス生成?)
    assert KV.Bucket.get(bucket, "milk") == nil # 何も登録してないのでキーで呼び出しても何も返ってこない

    KV.Bucket.put(bucket, "milk", 3) # "milk"というキーに3という値を登録
    assert KV.Bucket.get(bucket, "milk") == 3 # 登録したキーで3が呼び出せてるかテスト
  end
end

use ExUnit.Caseasyncというオプションがついてますが、どうやらテストを並列で実行するオプションだそうです。
基本付けてた方が早く終わりそうなもんですが、ファイルを触ったりデータベースを触ったりするテストの場合は競合したりするのでこのオプションは使わない方が良さげです。

ここまででmix testしてみると、失敗しますが一応テストファイルは認識しているようです。
早速本体のKV.Bucketを書いていきます。テスト同様lib/kv/bucket.exに書いていきます。

defmodule KV.Bucket do
  use Agent # Agentモジュールを利用する

  @doc """
  Starts a new bucket.
  """
  def start_link(_opts) do
    Agent.start_link(fn -> %{} end) # 初期値に空のマップを登録してエージェントを立ち上げる
  end

  @doc """
  Gets a value from the `bucket` by `key`.
  """
  def get(bucket, key) do
    Agent.get(bucket, &Map.get(&1, key)) # Agent.getを利用する。
      # イメージとしては投げる関数はパイプラインに乗る? Agentの保持してるマップ |> &Map.get(&1, key)
  end

  @doc """
  Puts the `value` for the given `key` in the `bucket`.
  """
  def put(bucket, key, value) do
    Agent.update(bucket, &Map.put(&1, key, value)) # Agent.putを利用する。
      # get同様イメージとしてはパイプライン Agentの保持してるマップ |> &Map.put(&1, key, value) ←コレの返値が保持する状態になる
  end
end

&などは関数キャプチャ。第八章を参照。

たぶん思ってた機能は実装できたのでテストを回してみる。

PS > mix test
Compiling 1 file (.ex)
...

Finished in 0.03 seconds
1 doctest, 2 tests, 0 failures

Randomized with seed 614000

ウレシイ!

Test setup with ExUnit callbacks

KV.Bucketの機能を追加していく前に、テストをもうチョイ綺麗に書く準備をします。
今のままだとテスト毎に{:ok, bucket} = KV.Bucket.start_link([])してうにゃうにゃしないと行けないのでBucketのエージェントを立ち上げるところは共通化したいです。
そこでExUnitの機能callbackという機能を用いて共通のヘッダみたいなのを作って行きます。

defmodule KV.BucketTest do
  use ExUnit.Case, async: true
  
  # ここからsetup/1マクロのcallback
  setup do
    {:ok, bucket} = KV.Bucket.start_link([])
    %{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

callbackで値を返しておけばtestの時にパターンマッチで拾ってこれるようです。
今はまねするだけでよさそうですが、詳しくはドキュメントを読むのが良いと思います。

hexdocs.pm

Other agent actions

さて、元のKV.Bucketに機能を追加していきます。
CRUDで言うところのCRUまでは出来てると思うので、あとは削除です。
Agentには削除にあたる機能が直接備わっているのではなく、Agent.get_and_update/2(キーを元に取得して更新する)を利用して削除を実装するのがよさそうです。

@doc """
Deletes `key` from `bucket`.

Returns the current value of `key`, if `key` exists.
"""
def delete(bucket, key) do
  Agent.get_and_update(bucket, &Map.pop(&1, key)) # Map.pop(マップ, キー)でマップからキーを削除した物を返す?
end

削除が実装できました。
削除の動作もテストに書いておきます。以下はテストファイルに追記しました。

  test "delete value by key", %{bucket: bucket} do
    KV.Bucket.put(bucket, "milk", 3)
    assert KV.Bucket.delete(bucket, "milk")  == 3 # 削除したときに値が返ってくるので一応
    assert KV.Bucket.get(bucket, "milk") == nil # 削除したキーが空であることを確認
  end

テスト実行!

PS > mix test
Compiling 1 file (.ex)
....

Finished in 0.03 seconds
1 doctest, 3 tests, 0 failures

Randomized with seed 929000

KVモジュールのハローワールド、KV.Bucketの2種類のテスト合計3つのテストが問題無く通ってます。

Client/Server in agents

次の章に行く前に、エージェントに於けるクライアント/サーバーの関係性を説明してくれてます。
例として先ほどのKVBucket.delete/2を改良していきます。

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

機能としては全く一緒。
エージェントとの関係性を強調するためにちょっと分かりやすく変更しただけだと思います。

ここではエージェントは別プロセスで実行されており、メッセージのやりとりで状態管理の機能が実装されているはずです。
ですので、ここでメッセージを送る(最初によこせ!とか更新しろ!とか言う)プロセスをクライアント、そのメッセージを受けるプロセスをサーバーとしましょう、という決め毎でしょうか。
相対関係をはっきりさせるために定義してるだけだと思いますので、そこまで深く考えなくてもいい気はします。(イメージ的にもたしかにクライアントとサーバーの関係性だし)

先ほどのコードをより強調して書く。

def delete(bucket, key) do
  Process.sleep(1000) # クライアントで1000ミリ秒待つ
  Agent.get_and_update(bucket, fn dict ->
    Process.sleep(1000) # サーバーで1000ミリ秒待つ
    Map.pop(dict, key)
  end)
end

同期、非同期などでハマらないようにするためにも、このクライアント/サーバーの関係性は意識したいところです。