Elixir GenServerとETSを使って自分でキャッシュ

[elixir]: https://elixir-lang.org/
[erlang]: https://www.erlang.org/
[phoenix]: https://www.phoenixframework.org/
[nerves]: https://hexdocs.pm/nerves
[livebook]: https://livebook.dev/
[iex]: https://elixirschool.com/ja/lessons/basics/basics/#%E5%AF%BE%E8%A9%B1%E3%83%A2%E3%83%BC%E3%83%89
[GenServer]: https://hexdocs.pm/elixir/GenServer.html
[ETS]: https://elixir-lang.org/getting-started/mix-otp/ets.html
[whitfin/cachex]: https://github.com/whitfin/cachex
[melpon/memoize]: https://github.com/melpon/memoize
[cabol/nebulex]: https://github.com/cabol/nebulex
[elixir-toniq/mentat]: https://github.com/elixir-toniq/mentat
[Erlangの公式ドキュメント]: https://www.erlang.org/doc/man/ets.html
[Elixir School]: https://elixirschool.com/ja/lessons/storage/ets
[:ets.fun2ms/1]: https://www.erlang.org/doc/man/ets.html#fun2ms-1
[Match Spec]: https://www.erlang.org/doc/apps/erts/match_spec.html
[ex2ms]: https://hex.pm/packages/ex2ms
[Ex2ms.fun/1]: https://hexdocs.pm/ex2ms/Ex2ms.html#fun/1
[:sys.get_state/1]: https://www.erlang.org/doc/man/sys.html#get_state-1
[Map]: https://hexdocs.pm/elixir/Map.html

[Elixir]で気軽にキャッシュします。

キャッシュ機構を実装した[Elixir]パッケージは複数ありますが、機能が盛りだくさんで使いこなせてない場合もあると思います。

[Elixir]には[GenServer]や[ETS]といった強力な機能が備わっているので、他の言語に比べてお手軽にキャッシュ機構が書けます。

第三者パッケージへの依存関係がなくなることがメリットの一つです。また、動作やオプション等を自分の好きなように変えられる自由もあります。ビルド成果物も小さくなるかもしれません。

正直いうと慣れるまではそんなに簡単ではありませんが、[GenServer]や[ETS]に対する理解を深めるには自分で実装するの一番と信じています。また、将来使える知見が得られますので、そう言った意味でもやりがいがあります。

## 論よりRUN

[Erlang]独自のクエリ言語と言える[Match Spec]を使いたいので、それを[Elixir]で使いやすくするために[ex2ms]パッケージをインストールします。これで[Ex2ms.fun/1]マクロが使えます。

[Erlang]の[:ets.fun2ms/1]は[Elixir]で使うとたまに謎のコンパイル関連エラーが出て怖いので[Elixir]用の[ex2ms]パッケージを使うと無難そうです。

```elixir
Mix.install [{:ex2ms, "~> 1.0"}]
```

[GenServer]で簡単なキャッシュ機構を実装しました。データは[ETS]テーブルに保存します。

[Map]風のインターフェイスとTTL（有効期限）と定期的に期限切れのデータを破棄する機能のみです。

```elixir
defmodule MyCache do
  use GenServer
  require Ex2ms

  # キャッシュストアのインスタンスを生成する
  def start_link(opts) do
    server_name = Keyword.fetch!(opts, :name)
    GenServer.start_link(__MODULE__, opts, name: server_name)
  end

  # キャッシュストアを削除する
  def stop(cache_name) do
    GenServer.stop(cache_name)
  end

  # キャッシュストアから有効な値を取得
  def get(cache_name, key, default \\ nil) do
    GenServer.call(cache_name, {:get, key, default})
  end

  # キャッシュストアから全ての有効な値を取得
  def get_all(cache_name) do
    GenServer.call(cache_name, :get_all)
  end

  # キャッシュストアに値を挿入
  def put(cache_name, key, value) do
    GenServer.cast(cache_name, {:put, key, value})
  end

  # キャッシュストアから全ての値を削除
  def delete_all(cache_name) do
    GenServer.cast(cache_name, :delete_all)
  end

  # キャッシュストア内部のリストを取得
  def entries(cache_name) do
    :ets.select(
      cache_name,
      Ex2ms.fun do
        {k, v, ttl} -> {k, v, ttl}
      end
    )
  end

  @impl true
  def init(args) do
    cache_name = args[:name]
    cache_ttl = args[:ttl] || :infinity
    cleanup_interval = args[:cleanup_interval] || :timer.minutes(60)

    # ETSテーブルを生成
    ^cache_name = :ets.new(cache_name, [:set, :named_table, :public])

    # キャッシュの名前とTTLを覚えておく
    state = %{
      cache_name: cache_name,
      cache_ttl: cache_ttl,
      cleanup_interval: cleanup_interval
    }

    # すぐ呼び出し元にプロセスIDを返し、handle_continueで非同期に他の処理をする
    {:ok, state, {:continue, :after_init}}
  end

  @impl true
  def handle_continue(:after_init, state) do
    Process.send_after(self(), :delete_expired, state.cleanup_interval)

    {:noreply, state}
  end

  @impl true
  def handle_call({:get, key, default}, _from, state) do
    time_now_ms = System.monotonic_time(:millisecond)

    reply =
      case :ets.lookup(state.cache_name, key) do
        # 値が見つからなかった場合、デフォルトの値を返す
        [] ->
          default

        # 値が見つかったが期限切れの場合、デフォルトの値を返す
        [{^key, _value, inserted_at_ms}]
        when is_integer(state.cache_ttl) and inserted_at_ms + state.cache_ttl <= time_now_ms ->
          default

        # 見つかった値を返す
        [{^key, value, _inserted_at_ms}] ->
          value
      end

    {:reply, reply, state}
  end

  @impl true
  def handle_call(:get_all, _from, state) do
    reply = do_get_all(state)

    {:reply, reply, state}
  end

  @impl true
  def handle_cast({:put, key, value}, state) do
    inserted_at_ms = System.monotonic_time(:millisecond)

    # キーと値のペアを時刻（マイクロ秒）と共に挿入
    true = :ets.insert(state.cache_name, {key, value, inserted_at_ms})

    {:noreply, state}
  end

  @impl true
  def handle_cast(:delete_all, state) do
    :ets.delete_all_objects(state.cache_name)

    {:noreply, state}
  end

  @impl true
  def handle_info(:delete_expired, state) do
    # 次回のキャッシュのクリアのタイマーをセット
    Process.send_after(self(), :delete_expired, state.cleanup_interval)

    # 今キャッシュのクリアを実施
    do_delete_expired(state)

    {:noreply, state}
  end

  @impl true
  def terminate(reason, state) do
    # GenServerが停止したら、紐づいているETSインスタンスも削除する方針
    :ets.delete(state.cache_name)
    reason
  end

  defp do_get_all(%{cache_name: cache_name, cache_ttl: cache_ttl}) do
    time_now_ms = System.monotonic_time(:millisecond)

    :ets.select(
      cache_name,
      Ex2ms.fun do
        {key, value, inserted_at_ms}
        when is_integer(^cache_ttl) and inserted_at_ms + ^cache_ttl >= ^time_now_ms ->
          {key, value}
      end
    )
  end

  defp do_delete_expired(%{cache_name: cache_name, cache_ttl: cache_ttl}) do
    time_now_ms = System.monotonic_time(:millisecond)

    :ets.select_delete(
      cache_name,
      Ex2ms.fun do
        {_key, _value, inserted_at_ms}
        when is_integer(^cache_ttl) and inserted_at_ms + ^cache_ttl < ^time_now_ms ->
          true
      end
    )
  end
end
```

着想は[elixir-toniq/mentat]から得ました。ありがとうございます。

使ってみます。試しにデータの保存期間を5秒間、30秒ごとに期限切れのデータを消去する設定でインスタンスを生成します。

```elixir
MyCache.start_link(name: :test_cache, ttl: 5_000, cleanup_interval: 30_000)
```

[GenServer]の状態を確認するのに便利なのは[:sys.get_state/1]です。
ただしこれは開発用途専用とのことですので、本番で使用しないよう注意が必要です。

```elixir
:sys.get_state(:test_cache)
```

キーと値のペアを挿入します。

```elixir
MyCache.put(:test_cache, :a, 1)
MyCache.put(:test_cache, :b, 2)
MyCache.put(:test_cache, :c, 3)
```

5秒間だけ値が取得できるはずです。
5秒後に値が取得できなくなります。

```elixir
MyCache.get(:test_cache, :a)
```

```elixir
MyCache.get_all(:test_cache)
```

実は内部の[ETS]テーブルにはまだデータは残っています。
30秒ごとに期限切れのデータは消去されます。

```elixir
MyCache.entries(:test_cache)
```

不要なインスタンスは削除できます。

```elixir
MyCache.stop(:test_cache)
```

:tada:

## キャッシュ関連[Elixir]パッケージ

自分でも実装できるとはいえ、当然ながら第三者パッケージを即取り入れてビジネスに専念する方が効率的な場面もあります。

### [whitfin/cachex]

### [melpon/memoize]

### [cabol/nebulex]

### [elixir-toniq/mentat]

（順不同）

## ご参考までに

https://qiita.com/piacerex/items/e0b6e46b1325bb931122

https://qiita.com/torifukukaiou/items/1edb3e961acf002478fd

https://qiita.com/piacerex/items/e5590fa287d3c89eeebf