ElixirからETSを使う

- この記事はElikir Advent Calendar 2015の7日目の記事です。

# ETSとは
- Erlang Term Storage の略で、etsモジュールを経由してin memoryなテーブルにtupleを保存できる仕組みです。
- 子プロセス間でデータを共有できますが、ネットワーク越しの共有はできません。
- ドキュメントはこちら。 http://www.erlang.org/doc/man/ets.html

# REPLから使ってみる
- `iex`でREPLを起動しデータを登録したり取り出してみたりします。

### 新しいテーブルを作成

```ex
iex(1)> table = :ets.new(:temp_table, [:set, :protected])
8211
```

- `:ets.new/2`で新しいテーブルを作成してETSのPIDが返ります。1個目の引数はテーブル名のatom、2個目はテーブルの定義をatomのlistで設定します。
- `:set`はテーブルの定義を指定していて、同一のキーは登録不可になります。他だと`:bag`はキーの重複ありなど種類に応じて使い分けられます。
- `:protected`はアクセスの範囲を指定しています。この場合だと書き込みはテーブルを作成したプロセスのみ、読み取りはオープンという具合です。他には`public`、`private`があります。

### 読み書き

```ex
iex(2)> :ets.insert(table, {:foo, "hoge"})
true
iex(3)> :ets.lookup(table, :foo)
[{:foo, "hoge"}]
iex(4)> :ets.lookup(table, :bar)
[]
iex(5)> :ets.insert(table, {:foo, "hogehoge"})
[{:foo, "hogehoge"}]
iex(6)> :ets.insert_new(table, {:foo, "hogehogehoge"})
false
iex(7)> :ets.insert_new(table, {:bar, "fuga"})
{:bar, "fuga"}
```

- `insert`で"foo"を登録し、`lookup`で"foo"を探して存在するので取得できました。一方、存在しない"bar"の`lookup`は空のlistが返ってきています。
- ２回目の`insert`ではすでに同じキー名が存在する場合は上書きを、`insert_new`の場合はすでにキーが存在している場合はfalseが帰り、登録はできていません。新規登録であれば成功します。
- テーブルの登録時に形式を`ordered_set`にすると`last/1`や`first/1`などが使えるようになります。

# 何かつくってみる
- 以前、[ElixirでSlackのBotを作る](http://qiita.com/rei-m/items/3340e0116bec5c5273d5)というのを書いて遊んでいたのですが、これに手をいれてみます。
- lgtmが流れたらlgtm.inからランダムで画像を拾って流すというbotに、呼ばれた回数をETSに保存する処理を追加します。

### コード
- まずはBotのSupervisorを起動するところでETSのテーブルを作成してPIDを取得します。取得したIDはBotのstateで持ち続けるようにします。

```ex:lib/magic_bot/supervisor.ex
# ETSを作成。アクセスは各Botの子プロセスから読み書きされることを想定しているのでPublic。
table = :ets.new(:work_bot, [:set, :public])

# BotのWorkerの引数のlistに作成したETSのプロセスを追加
children = [
  supervisor(BotAction.Supervisor, [[name: @action_sup_name]]),
  worker(MagicBot.Bot, [api_key, [name: @bot_name, sup_action: @action_sup_name, ets: table]])
]

supervise(children, strategy: :one_for_one)
```

- Botの子プロセスにETSのPIDを渡す

```ex:lib/lib/bot_action/supervisor.ex

  def start_action(state, command, trigger, message, slack) do
    Task.Supervisor.start_child(state[:sup_action], fn ->
      # stateからetsを取り出して引数に追加
      case command do
        :respond -> BotAction.Action.respond(trigger, message, state[:ets], slack)
        :hear -> BotAction.Action.hear(trigger, message, state[:ets], slack)
      end
    end)
  end
```

- Botの行動を修正

```ex:lib/bot_action/action.ex
  def hear("lgtm", message, ets, slack) do

    # lgtm が呼ばれたらETSに呼ばれた回数をカウントアップして保存
    :ets.insert(ets, {:lgtm_called_count, get_lgtm_called_count(ets) + 1})

    HTTPoison.start
    case URI.encode("http://www.lgtm.in/g") |> HTTPoison.get do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> body
        |> Floki.find("#imageUrl")
        |> Floki.attribute("value")
        |> hd
        |> send_message(message, slack)
      {_, _} -> nil
    end
  end

  # lgtmcountを話しかけたらETSからlgtmが呼ばれた回数を取得してslackに流すactionを追加
  def respond("lgtmcount", message, ets, slack) do
    send_message("いまのとこ #{get_lgtm_called_count(ets)} 回のlgtm", message, slack)
  end

  # ETSからlgtmの呼ばれた回数を取得する関数を追加
  defp get_lgtm_called_count(ets) do
    case :ets.lookup(ets, :lgtm_called_count) do
      [] -> 0
      x -> x[:lgtm_called_count]
    end
  end
```

### 動きを確認してみる
- Herokuにデプロイして確認します。ローカルでも`mix run --no-halt`で確認できます。

<img width="748" alt="スクリーンショット 2015-12-05 16.28.10.png" src="https://qiita-image-store.s3.amazonaws.com/0/25841/880acb64-0cef-2156-a14d-0dfe6da0a524.png">

- 最初は0回と言われていて、lgtmを読んだあとは1回と表示されていますね。意図したとおりにカウントアップしているようです。
- 最初に書いたとおりETSはin memoryなストレージなのでbotが再起動されたら、ストレージもリセットされます。永続的にしたいのであればそもそもDBに保存するようにするか、プロセスが死んだときにETSから取り出して保存みたいなコードを書くことになるかと思います。
- コードはこちら https://github.com/rei-m/magic_bot まだまだElixir触り始めたばかりですが盛り上がっていくといいですね！