キャッシュを使いたい
Elixir w/ Phoenix で開発をしていてキャッシュを使いたい場面が出てきた。
当初はこちらの記事を参考に ConCache を使おうと思ったけど、
- 大量のデータをキャッシュするとメモリがオーバーフローするのでは?
- 複数台のアプリケーションサーバで共有できない?
という疑問(どちらもそういった場面になる予定は無い)が出てきたので Redis を使うことにした。
その過程で色々と苦労した点(殆ど Redis 関係ないけど)があったので、
同じ壁にぶちあった人の参考になれば、と。
(長ったらしい前置きなんていらねーよ!って方は最後まで飛んでください)
Redix を設定していく
どうやら Elixir で Redis を扱うには Redix がいいらしいので、公式に則り設定していく。
Step0: アプリケーションを作成する
$ mix phx.new redis_sample --no-ecto
Step1: mix.exs
に下記を追加して mix deps.get
を実行する
defp deps do
[
...,
{:redix, ">= 0.0.0"},
]
end
Step2: Supervisor の監視下でコネクションを作成する
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(1)> Redix.command(:sample, ["SET", "foo", "bar"])
{:ok, "OK"}
iex(2)> Redix.command(:sample, ["GET", "foo"])
{:ok, "bar"}
よし、動いた。
壁1: コレクション?なにそれおいしいの?
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(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(9)> Enum.map(before, fn({k, v}) -> {String.to_atom(k), v} end) |> Map.new
%{foo: "bar"}
無事にキーをアトムに戻せました。
壁3: 日付や時間でもひと手間かかる
コレクションがダメだったということで、他のデータ型も試してみます。
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(14)> {_, before} = Jason.decode(ret)
{:ok, "2020-08-01"}
iex(15)> Date.from_iso8601(before)
{:ok, ~D[2020-08-01]}
無事に日付型に戻せました。
壁2と壁3は大した話じゃないのですが、次への前フリなのでご容赦ください。
完成形
と、保存する際にデータをエンコードしたり、取り出した際にひと手間くわえたりと何かと処理を行わないといけないので、まるっと担ってくれるモジュールを作成します。
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、
という使い分けが良いかと思いました。