8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Elixir で Redis をいい感じに扱う

Last updated at Posted at 2020-08-26

キャッシュを使いたい

Elixir w/ Phoenix で開発をしていてキャッシュを使いたい場面が出てきた。
当初はこちらの記事を参考に ConCache を使おうと思ったけど、

  • 大量のデータをキャッシュするとメモリがオーバーフローするのでは?
  • 複数台のアプリケーションサーバで共有できない?

という疑問(どちらもそういった場面になる予定は無い)が出てきたので Redis を使うことにした。

その過程で色々と苦労した点(殆ど Redis 関係ないけど)があったので、
同じ壁にぶちあった人の参考になれば、と。

(長ったらしい前置きなんていらねーよ!って方は最後まで飛んでください)

Redix を設定していく

どうやら Elixir で Redis を扱うには Redix がいいらしいので、公式に則り設定していく。

Step0: アプリケーションを作成する

$ mix phx.new redis_sample --no-ecto

Step1: mix.exs に下記を追加して mix deps.get を実行する

mix.exs
  defp deps do
    [
      ...,
      {:redix, ">= 0.0.0"},
    ]
  end

Step2: Supervisor の監視下でコネクションを作成する

lib/redis_sample/application.ex
  def start(_type, _args) do
    children = [
      ...,
      redix_child_spec(:sample, 1),
    ]
    ...
  end

  defp redix_child_spec(name, database) do
    Supervisor.child_spec(
      {
        Redix,
        [
          host: "localhost",
          port: 6379,
          database: database,
          name: name,
        ]
      },
      id: {Redix, name}
    )
  end

諸々のオプションについては HexDocs を参考にしてください。

Step3: アプリケーションを起動する

$ iex -S mix phx.server

Step4: テストしてみる

iex
iex(1)> Redix.command(:sample, ["SET", "foo", "bar"])
{:ok, "OK"}
iex(2)> Redix.command(:sample, ["GET", "foo"])
{:ok, "bar"} 

よし、動いた。

壁1: コレクション?なにそれおいしいの?

iex
iex(3)> map = %{foo: "bar"}
%{foo: "bar"}
iex(4)> Redix.command(:sample, ["SET", "map", map])
** (exit) an exception was raised:
    ** (Protocol.UndefinedError) protocol String.Chars not implemented for %{foo: "bar"} of type Map.

あー、なるほど。バイナリデータにしないと保存できない、と。

ConCache だと Elixir のプロセスにデータをキャッシュしておくだけなので、
何も考えずに保存することができた。

defimpl String.Chars, for: Map で実装すれば良さそうなんですが、
後々面倒(マップはいいけど構造体が面倒)なので JSON形式で保存するようにします。

Jason を使うことで構造体も @derive Jason.Encoder で簡単に扱えるようになります。

iex
iex(5)> {_, data} = Jason.encode(map)
{:ok, "{\"foo\":\"bar\"}"}
iex(6)> Redix.command(:sample, ["SET", "map", data])
{:ok, "OK"}
iex(7)> {_, ret} = Redix.command(:sample, ["GET", "map"])
{:ok, "{\"foo\":\"bar\"}"}
iex(8)> Jason.decode(ret)
{:ok, %{"foo" => "bar"}}

無事に保存できました。

壁2: マップのキーが文字列になっている

よく見ると Jason.decode を使ってデコードするとマップのキーが文字列になっています。
このままでも扱えなくは無いのですが、マップのキーが文字列とアトムが混合すると面倒なので、キーをアトムに変換します。

iex
iex(9)> Enum.map(before, fn({k, v}) -> {String.to_atom(k), v} end) |> Map.new
%{foo: "bar"}

無事にキーをアトムに戻せました。

壁3: 日付や時間でもひと手間かかる

コレクションがダメだったということで、他のデータ型も試してみます。

iex
iex(10)> {_, date} = Date.new(2020, 08, 01)
{:ok, ~D[2020-08-01]}
iex(11)> {_, data} = Jason.encode(date)
{:ok, "\"2020-08-01\""}
iex(12)> Redix.command(:sample, ["SET", "date", data])
{:ok, "OK"}
iex(12)> {_, ret} = Redix.command(:sample, ["GET", "date"])
{:ok, "\"2020-08-01\""}
iex(13)> Jason.decode(ret)
{:ok, "2020-08-01"}

保存はできたけど復元した値は文字列ですね…これも変換します。

iex
iex(14)> {_, before} = Jason.decode(ret)
{:ok, "2020-08-01"}
iex(15)> Date.from_iso8601(before)
{:ok, ~D[2020-08-01]}

無事に日付型に戻せました。

壁2と壁3は大した話じゃないのですが、次への前フリなのでご容赦ください。

完成形

と、保存する際にデータをエンコードしたり、取り出した際にひと手間くわえたりと何かと処理を行わないといけないので、まるっと担ってくれるモジュールを作成します。

lib/redis_sample/cache.ex
defmodule RedisSample.Cache do

  def get(namespace, key) do
    case Redix.command(namespace, ["GET", key]) do
      {:ok, nil} -> nil
      {:ok, data } ->
        {_status, ret} = Jason.decode(data)
        transform(ret)
    end
  end

  def put(namespace, key, value) do
    {_status, ret} = Jason.encode(value)
    case Redix.command(namespace, ["SET", key, ret]) do
      {:ok, "OK"} -> :ok 
      {:error, reason} -> :error
    end
  end

  # マップを再帰的に変換する
  defp transform(map) when is_map(map) do
    Enum.map(map, fn({k, v}) ->
      cond do
        is_binary(k) -> {String.to_atom(k), transform(v)}
        true -> {k, transform(v)}
      end
    end)
    |> Map.new
  end

  # リストの内容を変換する
  defp transform(list) when is_list(list) do
    Enum.map(list, fn(v) ->
      transform(v)
    end)
  end

  # 文字列は内容に応じて変換する
  defp transform(string) when is_binary(string) do
    cond do
      Regex.match?(~r/^[\d]{4}-[\d]{2}-[\d]{2}T[\d]{2}:[\d]{2}:[\d]{2}/, string) ->
        {_, datetime, _offset} = DateTime.from_iso8601(string)
        datetime
      Regex.match?(~r/^[\d]{4}-[\d]{2}-[\d]{2}/, string) ->
        {_, date} = Date.from_iso8601(string)
        date
      Regex.match?(~r/^[\d]{2}:[\d]{2}:[\d]{2}/, string) ->
        {_, time} = Time.from_iso8601(string)
        time
      true ->
        string
    end
  end

  # 他のデータ型はそのまま
  defp transform(other), do: other

end

これで一通りのデータには対応できるようになりました。

まとめ

アプリケーションの規模にもよりますが小規模なら ConCache、中規模以上なら Redix、
という使い分けが良いかと思いました。

8
3
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
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?