LoginSignup
18
4

More than 1 year has passed since last update.

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

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

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

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

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

論よりRUN

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

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

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

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

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

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秒ごとに期限切れのデータを消去する設定でインスタンスを生成します。

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

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

:sys.get_state(:test_cache)

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

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

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

MyCache.get(:test_cache, :a)
MyCache.get_all(:test_cache)

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

MyCache.entries(:test_cache)

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

MyCache.stop(:test_cache)

:tada:

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

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

whitfin/cachex

melpon/memoize

cabol/nebulex

elixir-toniq/mentat

(順不同)

ご参考までに

18
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
4