19
7

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.

fukuoka.ex Elixir/PhoenixAdvent Calendar 2019

Day 8

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

Last updated at Posted at 2019-12-07

はじめに

この記事は 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 でいいと思います。

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です。

キタ━(゚∀゚)━! $ 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 してください。

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

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

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

なのでやってみる

application.ex
    children = [
      {Redix, host: "localhost", port: 6380, name: :redix}
    ]
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 して動作確認してみましょう。

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

画面作る

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

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

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

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_forChangeset を使用する前提だったので困りました笑
今回はRDB使わない簡易的なランキングでしたが、PhoenixはEcto使う前提の話が多くて欲しい情報を探すのに苦労しました。

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

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

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

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

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

image.png

image.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 さんの記事です!

19
7
1

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?