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