利用 Redis 打造選舉即時票數排行

台灣又即將進入選舉的時節,看著政治人物不做正事忙著在跑選舉行程,大家是否也跟我一樣有很強烈的亡國感呢?不過沒有關係,不要讓負面情緒影響我們的心態,今天就讓我們用 Redis 搭配 ActionCable 來做一個即時計票顯示吧!

vote_leaderboard.gif

Why Redis?

Redis 是一個 in-memory 的 key-value database,時常被使用在需要快取的場合,以減輕後端資料庫的壓力。在選舉計票的時候,統計數據更新的頻率會相當頻繁,因此 Redis 便成為一個非常好的資料庫選項。

Prerequisite

當你在讀這篇文章時,我預期你已經懂得簡單的 Rails 開發,以及如何在本地端安裝、啟動 Redis,因此不會在文章內提供相關的教學。本內容不會用到如 MySQL 或是 PostgreSQL 等關聯式資料庫。

加入需要的 Gems

在你的 Gemfile 裡面加入下面三個 Gem,然後執行 bundle install

# 這是一個 Ruby 版本的 Redis Client Library
gem 'redis'

# 我們會利用 jquery 來操縱 DOM
gem 'jquery-rails'

# 套用 bootstrap style,可忽略。若要使用這個 Gem 可參考 https://github.com/twbs/bootstrap-rubygem 的相關說明。
gem 'bootstrap', '~> 4.3.1'

初始化 Redis

config/initializers 新增一個 redis.rb 檔案

require 'redis'

# 理論上在 Test 和 Production 會有區別,但因為我們只在本機端操作,所以直接初始化一個 Redis Client 即可
REDIS = Redis.new

若你在 rails console 裡面輸入REDIS.ping 會回傳一個 “PONG” 的字串,就代表應用程式和 Redis 的連線成功了。

在 Redis 中有個東西叫做有序集(Sorted Set),可以在一個 Key 中儲存多組元素,並且給予權重積分排序。我們會利用 Sorted Set 來完成這個功能。

將票數記錄到 Redis

在這裏必須優先說明的是,因為並不曉得選舉期間有沒有單位會提供計票的 API(在台灣估計是沒有),因此我們用模擬了 API 的 Request,而 Request 的架構如下:

{
  sector: string,
  vote_count[]: array of string,
  name[]: array of string
}

我們可以產生一個新的 VotesController,來當作我們計票 API 的接口。

class VotesController < ApplicationController
  skip_before_action :verify_authenticity_token

  # zincrby 需要三個 Arguments,第一個是 Key 的名稱,我們設為 "POLL:投票區名",第二個則是元素的權重積分,也就是票數。而第三個則是元素本身,我們將其作為候選人的名字。

  # 若在這個 Key 中,候選人存在,zincrby 會將票數加在他目前的票上;反之會直接建立一個新的候選人元素。

  def create
    params[:name].each_with_index do |name, idx|
      REDIS.zincrby("POLL:#{params[:sector]}", params[:vote_count][idx].to_i, name)
    end
  end
end

這裡有個必須注意的地方。由於我們會另外使用一個 Ruby 的 Script 來送出計票的 Request,所以必須要跳過 authenticity_token 的驗證。這個作法在 Production 環境會有安全上的疑慮,必須先聲明。

別忘了,還要在 routes.rb 裡面追加設定,才可以使用這個接口。

# config/route.rb

Rails.application.routes.draw do
  post 'votes', to: 'votes#create'
end

Request 的 Script 在這裡,當你設定好上述的 route 之後,執行這隻 API,應該就可以順利地在將票數記錄在 Redis 裡面。(上面的 URL Port 是 3000,你的 Port 有可能不同)

顯示票數

在我們讓票數會自己跳動之前,先想辦法讓票數顯示吧!這裡創造一個新的 Controller,並給它對應的 Route,我建議就把它設成 root 吧。

class VoteLeaderboardController < ApplicationController

  # 這裡預設有兩個選區,當然可以更多。
  SECTORS = ["t1", "t2"]

  def index
    # 一次顯示多個選區
    @sectors = SECTORS.each_with_object([]) do |sector_name, arr|
      arr << Sector.new(sector_name)
    end
  end
end
# config/routes.rb

Rails.application.routes.draw do
  post 'votes', to: 'votes#create'
  root to: 'vote_leaderboard#index'
end

這裡的 Sector 是一個 class,我將它定義在 app/models 的下面,但它其實並非我們平常使用的 Model,只是一個非常單純的 class。

# app/models/sector.rb

class Sector
  attr_reader :name

  def initialize(name)
    @name = name 
  end

  def get_votes
    # #zrange 需要四個 Arguments,第一個是 Key 的名稱(選區),第二個是取回元素開始的位置,第三個則是取回元素結束的位置,將它想成 Array 就比較好理解了。-1 則代表是要取回所有的元素。

    # 由於我們不僅僅想知道它的排列順序,還想實際知道每個候選人的得票數字,所以我們加入了 with_scores: true 的 option。

    # 最後,由於他排列的順序會是從最小到最大,但我們希望可以從最高票的人開始排列,因此在最後的結果 叫了 #reverse 這個函數。

    # 實際上會得到 [["Roy", 1050], ["Sabrina", 800]] 這樣子型態的資料。

    @votes = REDIS.zrange("POLL:#{@name}", 0, -1, with_scores: true).reverse
  end
end

這個 Sector 可以用來存取 Sector (選區)的名稱,然後利用 #get_votes 這個函式來得知選區內所有候選人的姓名和得票數。

然後,我們創造可以創造 View 來讓得票數顯示:

# app/views/vote_leaderboard/index.html.erb
<h1 class="display-3" style="text-align: center"> Vote Leaderboard </h1>
<div class="container">
  <div class="row">
    <% @sectors.each do |sector| %>
      <div class="col-6" style="text-align:center">
        <h2> <%= sector.name %> Sector</h2>
        <%= render "sector_vote_count", sector: sector %>
      </div>
    <% end %>
  </div>
</div>
<table class="table" id="<%= sector.name %>">
  <thead class="thead-dark">
    <tr>
      <th> # </th>
      <th> Candidate Name </th>
      <th> Vote Count </th>
    </tr>
  </thead>

  <% sector.get_votes.each.with_index(1) do |votes, idx| %>

    <tr>
      <td> <%= idx %> </td>
      <td> <%= votes[0] %> </td>
      <td> <%= votes[1].to_i %> </td>
    </tr>
  <% end %>
</table>

現在打開 Rails server,如果你方才有跑上面的計票 Script,現在應該可以在首頁看到票數的統計。

該讓票數動起來了

現在讓我們來處理 ActionCable 的部份。首先,我們可以用 Rails 的指令來產生 Channel,輸入 rails g channel vote_leaderboard 的話,你應該會得到一個 app/channels/vote_leaderboard_channel.rb 以及 app/assets/javascripts/channels/vote_leaderboard.coffee

channel 是為了讓你建立頻道的訂閱,而這個 coffee script 則是操控收到票數統計之後的畫面行為。你如果現在打開這兩個檔案,應該會發現裡頭已經有一些內容了,但實際上我們需要的並沒有那麼地多:

# app/channels/vote_leaderboard_channel.rb

class VoteLeaderboardChannel < ApplicationCable::Channel
  def subscribed
    stream_from "vote_leaderboard_channel"
  end
end
// app/assets/javascripts/channels/vote_leaderboard.coffee

App.vote_leaderboard = App.cable.subscriptions.create "VoteLeaderboardChannel",
  received: (data) ->
    sector_table = $("##{data["sector_name"]}")

    sector_table.html(data["template"])

簡單來說,就是 APP 會訂閱 voteleaderboard 這個 Channel,當 Channel 收到資料的時候,會用 `voteleaderboard.coffee` 操作 DOM 元件。在這裡可以看到,我們用 jQuery 去抓取了選區的票數統計表格,然後用新收到的票數表格去替換原本的表格。

我們可以創立一個 Job 去接計票的 Request,然後再計完票之後立刻執行 ActionCable 的動作。

app/jobs/vote_counting_job.rb

class VoteCountingJob < ApplicationJob
  queue_as :default

  # perform 的時候會直接 publish vote_leaderboard_channel,然後將新表格的 HTML 及選區的名稱傳到前端去。

  def perform(sector)
    ActionCable.server.broadcast("vote_leaderboard_channel", template: render_html(sector), sector_name: sector)
  end

  private

  def render_html(sector)
    # 這裡利用 ApplicationController.render 這個函式來讀取 Partial,這樣我們就可以把得到的表格變成單純的 HTML 去給 JavaScript 來處理了

    ApplicationController.render(
      partial: 'vote_leaderboard/sector_vote_count',
      locals: { sector: Sector.new(sector) }
    )
  end
end

這時候我們必須決定什麼時候要來 Perform 這個 Job,我認為合理的時機是在將票數計入 Redis 之後,因此我們回到 votes_controller

class VotesController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    params[:name].each_with_index do |name, idx|
      REDIS.zincrby("POLL:#{params[:sector]}", params[:vote_count][idx].to_i, name)
    end

    VoteCountingJob.perform_later(params[:sector])
  end
end

此時打開你的頁面,執行虛擬計票的 Script,ta-da! 你應該可以看到票數迅速地在改變,不需要重新整理也可以得到最新的選票情報了!(當然,如果中選會願意提供真正的 API 那就太好了。)