PhoenixでRedisを使った簡単ランキングの実装

# はじめに
この記事は [fukuoka.ex Elixir／Phoenix Advent Calendar 2019](https://qiita.com/advent-calendar/2019/fukuokaex)の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` でいいと思います。

```yaml:ranking/docker-compose.yml
version: '3'
services:
  redis:
    image: "redis:latest"
    ports:
      - "6380:6379"
    volumes:
      - "./data/redis:/data"
```

`docker-compose up -d` して動かしましょう。 `docker ps` を実行してredisが立ち上がっていればOKです。

```bash
ｷﾀ━(ﾟ∀ﾟ)━! $ 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動いてそうですね。適当にコマンド打って遊んでみても面白いかもです。

```bash
ｷﾀ━(ﾟ∀ﾟ)━! $ 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` してください。

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

メインのランキングロジックを書いてみます。とりあえず `set` と` list` ができれば良さそうです。
Redixは `redis-cli` に打ち込むのと同じ感じでできるみたいですね。 `Sorted Set` 型を使えばスコアの昇順にソートして取り出してくれるので（特にリアルタイムの）ランキング実装には非常に向いてます。
https://redis.io/commands/zrange

```elixir:lib/ranking/ranking.ex
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

なのでやってみる

```elixir:application.ex
    children = [
      {Redix, host: "localhost", port: 6380, name: :redix}
    ]
```

```elixir:application.ex
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` して動作確認してみましょう。

```elixir
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` で取り出すときにスコアの昇順で返ってきてますね！このロジックだけで色々応用ができそうです！

## 補足
```elixir
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)` って何？となると思うので補足を。

```elixir
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` の出番です！

```elixir
iex(11)> res |> Enum.chunk_every(2)
[["koyo3", "1"], ["koyo2", "2"], ["koyo", "3"]] 
```

**・・・神！！！** これだからElixir大好き！
欲を言えばランクもつけたいですよね・・・！
ランク順になってるのでインデックスを振ってあげればいいのですが、Elixirには手続き型でいうforがないのでどうすればいいのか・・・。そう！ `Enum.with_index/2` の出番です！

```elixir
iex(14)> res |> Enum.chunk_every(2) |> Enum.with_index(1)                                   
[{["koyo3", "1"], 1}, {["koyo2", "2"], 2}, {["koyo", "3"], 3}]
```

引数入れないと0から番号を振ってしまうので、1を入れてあげましょう。
さらに！！どの数値が何かを明示的にしたいので、マップ形式にします。ここまでくれば `Enum.map` で全て解決です。
強力なパターンマッチがあるのでラクラク構造を変換できます。

```elixir
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の強みだなぁと思います。

# 画面作る
さてロジックはできたので、あとは適当に画面作りましょう。以下のような感じで

```erb:lib/ranking_web/templates/ranking/index.html.eex

<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>
```

```erb:lib/ranking_web/templates/ranking/new.html.eex
<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>
```

```erb:lib/ranking_web/templates/ranking/form.html.eex
<%= 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 %>
```

ついでにトップページも変えます

```erb:lib/ranking_web/templates/page/index.html.eex
<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使う前提の話が多くて欲しい情報を探すのに苦労しました。

```elixir:lib/ranking_web/controllers/ranking_controller.ex
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
```

忘れがちですがビューファイルも

```elixir:lib/ranking_web/views/ranking_view.ex
defmodule RankingWeb.RankingView do
  use RankingWeb, :view
end
```

ルーティングは以下のような感じで

```elixir:lib/ranking_web/router.ex
  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 に接続すると・・・！？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/0a6e1a62-e1cb-2afd-b45a-e885ffa83fb6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/68f34394-4449-14c8-59ef-3752bd90d426.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/8bbab032-290e-82ef-d296-5335fbfd6a92.png)

うまく動いてますね！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 さんの記事です！