はじめに
この記事は fukuoka.ex Elixir/Phoenix Advent Calendar 2019の8日目です。
最近 Redis
を触る機会があって、 Sorted Set
型を使えば簡単にランキング機能を実装できることを知りました。
別の言語で実装したのですが、Elixir/Phoenixでやったらどうなるのかな〜と思ったのでやってみます!
環境は Phoenix v1.4.11
です!
準備
mix phx.new ranking --no-ecto
で適当にプロジェクト作ります。いつも通り mix deps.get
してください(もしくはプロジェクト作成時に Y 押してもいいです)。
redisをローカルで動かす必要があるので、dockerで用意しちゃいましょう。ちなみにポート番号が6380なのは私の環境でポート番号衝突しちゃっただけなので、特に問題なければ 6379:6379
でいいと思います。
version: '3'
services:
redis:
image: "redis:latest"
ports:
- "6380:6379"
volumes:
- "./data/redis:/data"
docker-compose up -d
して動かしましょう。 docker ps
を実行してredisが立ち上がっていればOKです。
キタ━(゚∀゚)━! $ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4edb0d97c8e4 redis:latest "docker-entrypoint.s…" 4 hours ago Up 4 hours 0.0.0.0:6380->6379/tcp ranking_redis_1
ちゃんとredis動いてそうですね。適当にコマンド打って遊んでみても面白いかもです。
キタ━(゚∀゚)━! $ docker exec -it 4edb0d97c8e4 bash
root@4edb0d97c8e4:/data# redis-cli
127.0.0.1:6379>
Redisをブラウザ上でお試しできるサイト
実はRedisの公式サイト上でコマンド打てるということをご存知でしょうか?
Redis使った開発を行う際に非常に便利なので是非活用してみてください
https://redis.io/commands/zadd
ランキングロジックを作る
hex.pm上でredisと検索して最も使われてそうな redix
というライブラリを使ってみます。
https://github.com/whatyouhide/redix
deps に追記して mix deps.get
してください。
defp deps do
[
...
{:redix, ">= 0.0.0"}
]
end
メインのランキングロジックを書いてみます。とりあえず set
と list
ができれば良さそうです。
Redixは redis-cli
に打ち込むのと同じ感じでできるみたいですね。 Sorted Set
型を使えばスコアの昇順にソートして取り出してくれるので(特にリアルタイムの)ランキング実装には非常に向いてます。
https://redis.io/commands/zrange
defmodule Ranking.Ranking do
@key "my_ranking_key"
def conn() do
{:ok, conn} = Redix.start_link(host: "localhost", port: 6380)
conn
end
def set(name, score) do
conn = conn()
Redix.command(conn, ["ZADD", @key, score, name])
end
# rankは今回使わなかったけど遊びで実装してみた
def rank(name) do
conn = conn()
{:ok, res} = Redix.command(conn, ["ZRANK", @key, name])
# 0オリジンで返ってくるため
res + 1
end
def list() do
conn = conn()
{:ok, res} = Redix.command(conn, ["ZRANGE", @key, 0, -1, "WITHSCORES"])
res
|> Enum.chunk_every(2)
|> Enum.with_index(1)
|> Enum.map(fn {[name, score], rank} -> %{name: name, score: score, rank: rank} end)
end
end
毎回 conn()
関数でコネクション生成してるのがだいぶ気持ち悪いんですが、うまい方法がわからなかったので強い人教えてもらえると嬉しいです・・・。
コネクション生成の追記
ドキュメントよく読んだらsupervisor使えばコネクション毎回生成しなくてもいいみたいです!
https://hexdocs.pm/redix/real-world-usage.html#single-named-redix-instance
なのでやってみる
children = [
{Redix, host: "localhost", port: 6380, name: :redix}
]
defmodule Ranking.Ranking do
@key "my_ranking_key"
@conn_name :redix
def set(name, score) do
Redix.command(@conn_name, ["ZADD", @key, score, name])
end
def rank(name) do
{:ok, res} = Redix.command(@conn_name, ["ZRANK", @key, name])
# 0オリジンで返ってくるため
res + 1
end
def list() do
{:ok, res} = Redix.command(@conn_name, ["ZRANGE", @key, 0, -1, "WITHSCORES"])
res
|> Enum.chunk_every(2)
|> Enum.with_index(1)
|> Enum.map(fn {[name, score], rank} -> %{name: name, score: score, rank: rank} end)
end
end
動作確認
iex -S mix
して動作確認してみましょう。
iex(2)> alias Ranking.Ranking
Ranking.Ranking
iex(3)> Ranking.set("koyo",3)
{:ok, 1}
iex(4)> Ranking.set("koyo2",2)
{:ok, 1}
iex(5)> Ranking.set("koyo3",1)
{:ok, 1}
iex(6)> Ranking.rank("koyo")
3
iex(7)> Ranking.list
[
%{name: "koyo3", rank: 1, score: "1"},
%{name: "koyo2", rank: 2, score: "2"},
%{name: "koyo", rank: 3, score: "3"}
]
適当な順で突っ込んでも list
で取り出すときにスコアの昇順で返ってきてますね!このロジックだけで色々応用ができそうです!
補足
def list() do
conn = conn()
{:ok, res} = Redix.command(conn, ["ZRANGE", @key, 0, -1, "WITHSCORES"])
res
|> Enum.chunk_every(2)
|> Enum.with_index(1)
|> Enum.map(fn {[name, score], rank} -> %{name: name, score: score, rank: rank} end)
end
Enum.chunk_every(2)
とか Enum.with_index(1)
って何?となると思うので補足を。
iex(8)> {:ok, conn} = Redix.start_link(host: "localhost", port: 6380)
{:ok, #PID<0.415.0>}
iex(9)> {:ok, res} = Redix.command(conn, ["ZRANGE", "my_ranking_key", 0, -1, "WITHSCORES"])
{:ok, ["koyo3", "1", "koyo2", "2", "koyo", "3"]}
iex(10)> res
["koyo3", "1", "koyo2", "2", "koyo", "3"]
このように Redix.command
の返り値がだいぶ微妙でこのままではテンプレートでループして描画するのに苦労しそうです。せめて [{名前, スコア}, {...}, ...]
の形式にしたい。
そこで Enum.chunk_every/2
の出番です!
iex(11)> res |> Enum.chunk_every(2)
[["koyo3", "1"], ["koyo2", "2"], ["koyo", "3"]]
・・・神!!! これだからElixir大好き!
欲を言えばランクもつけたいですよね・・・!
ランク順になってるのでインデックスを振ってあげればいいのですが、Elixirには手続き型でいうforがないのでどうすればいいのか・・・。そう! Enum.with_index/2
の出番です!
iex(14)> res |> Enum.chunk_every(2) |> Enum.with_index(1)
[{["koyo3", "1"], 1}, {["koyo2", "2"], 2}, {["koyo", "3"], 3}]
引数入れないと0から番号を振ってしまうので、1を入れてあげましょう。
さらに!!どの数値が何かを明示的にしたいので、マップ形式にします。ここまでくれば Enum.map
で全て解決です。
強力なパターンマッチがあるのでラクラク構造を変換できます。
iex(15)> res |> Enum.chunk_every(2) |> Enum.with_index(1) |> Enum.map(fn {[name, score], rank} -> %{name: name, score: score, rank: rank} end)
[
%{name: "koyo3", rank: 1, score: "1"},
%{name: "koyo2", rank: 2, score: "2"},
%{name: "koyo", rank: 3, score: "3"}
]
・・・というわけで、一見複雑なパイプ処理はデータ構造の変換を行なっているだけでした。とはいえここまでのデータ構造変換を たったの一行で、しかも可読性高く書ける のはElixirの強みだなぁと思います。
画面作る
さてロジックはできたので、あとは適当に画面作りましょう。以下のような感じで
<h1>Listing Users</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Score</th>
<th>Rank</th>
</tr>
</thead>
<tbody>
<%= for ranking <- @rankings do %>
<tr>
<td><%= link ranking.name, to: Routes.ranking_path(@conn, :index) %></td>
<td><%= ranking.score %></td>
<td><%= ranking.rank %></td>
</tr>
<% end %>
</tbody>
</table>
<span><%= link "Add New Ranking!!", to: Routes.ranking_path(@conn, :new) %></span>
<h1>New User</h1>
<%= render "form.html", Map.put(assigns, :action, Routes.ranking_path(@conn, :create)) %>
<span><%= link "Back", to: Routes.ranking_path(@conn, :index) %></span>
<%= form_for @conn, @action, fn f -> %>
<%= label f, :name %>
<%= text_input f, :name %>
<%= label f, :score %>
<%= text_input f, :score %>
<div>
<%= submit "Save" %>
</div>
<% end %>
ついでにトップページも変えます
<section class="phx-hero">
<h1><%= gettext "Welcome to %{name}!", name: "Phoenix" %></h1>
<p>A productive web framework that<br/>does not compromise speed or maintainability.</p>
</section>
<span><%= link "Back", to: Routes.ranking_path(@conn, :index) %></span>
コントローラも作りましょう(エラーハンドリングは適当)。 create
でフォームから入力されたリクエストボディの受け取り方がポイントです。調べてもテンプレートで使用した form_for
が Changeset
を使用する前提だったので困りました笑
今回はRDB使わない簡易的なランキングでしたが、PhoenixはEcto使う前提の話が多くて欲しい情報を探すのに苦労しました。
defmodule RankingWeb.RankingController do
use RankingWeb, :controller
alias Ranking.Ranking
def index(conn, _params) do
rankings = Ranking.list()
render(conn, "index.html", rankings: rankings)
end
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"name" => name, "score" => score}) do
case Ranking.set(name, score) do
{:ok, _} ->
conn
|> put_flash(:info, "Ranking created successfully.")
|> redirect(to: Routes.ranking_path(conn, :index))
end
end
end
忘れがちですがビューファイルも
defmodule RankingWeb.RankingView do
use RankingWeb, :view
end
ルーティングは以下のような感じで
scope "/", RankingWeb do
pipe_through :browser
get "/", PageController, :index
get "/ranking", RankingController, :index
get "/ranking/new", RankingController, :new
post "/ranking/new", RankingController, :create
end
完成!
$ iex -S mix phx.server
して localhost:4000 に接続すると・・・!?
うまく動いてますね!Redisの Sorted Set
型を使っているので、アプリケーション側でソートしなくても自動でrankが求まるのがいい感じです!
おわりに
PhoenixでRedisを使った簡単なランキングを実装してみました。なお実務で使う場合はRedis上のデータは消えることを前提に考えるべきなので、別途RDBに保存する必要があります。あとエラーハンドリングしっかりやったり、コネクションいちいち生成せずに使いまわしたりする必要があると思うのでご留意ください。
完成品は https://github.com/koyo-miyamura/ex_ranking に置いています。 docker-compose up -d
して iex -S mix phx.server
すると動くと思うので是非試してみてくださいね。
次回は tomoaki-kimura さんの記事です!