Elixir で Redis をいい感じに扱う

## キャッシュを使いたい

Elixir w/ Phoenix で開発をしていてキャッシュを使いたい場面が出てきた。
当初は[こちらの記事](https://qiita.com/mserizawa/items/41feec92de6fccbba01f)を参考に [ConCache](https://github.com/sasa1977/con_cache) を使おうと思ったけど、

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

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

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

(長ったらしい前置きなんていらねーよ！って方は[最後](#完成形)まで飛んでください)

## Redix を設定していく

どうやら Elixir で Redis を扱うには [Redix](https://github.com/whatyouhide/redix) がいいらしいので、公式に則り設定していく。

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

```
$ mix phx.new redis_sample --no-ecto
```

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

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

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

```elixir: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](https://hexdocs.pm/redix/Redix.html#start_link/1) を参考にしてください。

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

```
$ iex -S mix phx.server
```

Step4: テストしてみる

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

よし、動いた。

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

```elixir: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 で簡単に扱えるようになります。

```elixir: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` を使ってデコードするとマップのキーが文字列になっています。
このままでも扱えなくは無いのですが、マップのキーが文字列とアトムが混合すると面倒なので、キーをアトムに変換します。

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

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

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

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

```elixir: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"}
```

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

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

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

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

## 完成形

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

```elixir: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、
という使い分けが良いかと思いました。