← 上一章:CRUD 分解動作 - 簡易票選系統實作(上) 下一章:Layout, Render 與 View Helper →

CRUD 分解動作 - 簡易票選系統實作(下)

在上一章,我們完成了候選人的 CRUD(新增、讀取、更新以及刪除),但是程式碼寫得有點不好看,而且不少地方是重複的,接下來這個章節將跟大家介紹怎麼樣完成投票功能以及整理重複的程式碼。

實作:票選系統

第 11 步 -「投票給某位候選人」功能 Part 1

跟在做候選人資料的 CRUD 一樣,先從 Route 下手,把路徑寫出來。因為我希望可以做出「對 /candidates/1/vote 使用 POST 方法」來進行投票,所以這裡我使用 Route 的 member 方法,在 resources :candidates 裡加了一組路徑設定,補充原本 CRUD 的 8 個路徑:

  Rails.application.routes.draw do
    resources :candidates do
      member do
        post :vote
      end
    end
  end

若不熟悉 Route 的 membercollection 的使用方法,請參閱第 11 章的說明。這時候的路徑會變這樣:

$ bin/rails routes
        Prefix Verb   URI Pattern                    Controller#Action
vote_candidate POST   /candidates/:id/vote(.:format) candidates#vote
    candidates GET    /candidates(.:format)          candidates#index
               POST   /candidates(.:format)          candidates#create
 new_candidate GET    /candidates/new(.:format)      candidates#new
edit_candidate GET    /candidates/:id/edit(.:format) candidates#edit
     candidate GET    /candidates/:id(.:format)      candidates#show
               PATCH  /candidates/:id(.:format)      candidates#update
               PUT    /candidates/:id(.:format)      candidates#update
               DELETE /candidates/:id(.:format)      candidates#destroy

這樣就多了一組 /candidates/:id/vote 的路徑。接下來,把原來在候選人列表的投票連結:

  <td><%= link_to "投給這位", "#" %></td>

改成這樣:

  <td><%= link_to "投給這位", vote_candidate_path(candidate), method: "post", data: { confirm: "確定投給這個人?" } %></td>

當使用者點擊了「投給這位」的連結後,Rails 將使用 POST 方法來發送資料,並將觸發 CandidatesController 上的 vote 方法,也就是說,接下來我們需要手動新增一個 vote 方法來處理這件事:

  class CandidatesController < ApplicationController
    # .. [略] ..

    def vote
      @candidate = Candidate.find_by(id: params[:id])
      @candidate.increment(:votes)
      @candidate.save
      redirect_to candidates_path, notice: "完成投票!"
    end

    # .. [略] ..
  end

vote 這個方法做的事情,就是先把那筆資料抓出來,然後使用 increment 方法來增加那筆資料的 votes 欄位的值(也可使用 updateupdate_attributes 方式更新),投票完成轉往候選人列表頁。測試一下功能:

按下「投給這位」連結,跳出確認視窗:

image

按下「確定」按鈕後即可進行投票:

image

票數的確增加了!

第 12 步 -「投票給某位候選人」功能 Part 2

雖然看起來已經完成投票功能,目前我們是直接把票數欄位跟每位候選人的資料綁在一起,但正式一點的活動可能沒辦法就只這麼做,因為大家到時候應該會想知道是哪些人投票給這位候選人,不然也太容易被黑箱作業了。所以,我們待會就來新增一個名為 Vote 的 Model,專門用來紀錄投票時間以及 IP 位置。接下來,使用 Rails 的產生器來建立這個 Model:

$ bin/rails generate model vote_log candidate:references ip_address:string
Running via Spring preloader in process 25387
  invoke  active_record
  create    db/migrate/20170503024259_create_vote_logs.rb
  create    app/models/vote_log.rb
  invoke    test_unit
  create      test/models/vote_log_test.rb
  create      test/fixtures/vote_logs.yml

這邊希望可以記錄幾件事情:

  1. 投票時間。
  2. 投票給誰。
  3. 投票時候的 IP 位置。

除了上面列的這些外,要記錄更多資訊也是沒問題的,例如如果有會員系統的話,也可以把投票者的資訊記錄下來,但我們這邊就先記這三項資料就好。

關於第 1 點的投票時間,我們可以不需要特別開欄位,因為 Rails 的 Migration 預設都會加一個時間戳記(就是常在 migration 檔案裡看到的 t.timestamps),在資料表裡記錄著該筆資料的建立時間(created_at)以及更新時間(updated_at),所以這點可以不用擔心。

第 2 點,在產生器的語法中,雖然也可改用 candidate_id:integer 的方式明確指出該欄位的名稱(candidate_id)以及屬性(integer),但使用 candidate:references 會更方便一些,它一樣會幫你做出一個 candidate_idinteger 型態欄位,而且待會你就可以看到這樣的寫法還會幫忙處理一些 Model 之間的關連性。

至於第 3 點,就很簡單的給它一個字串型態就行了。別忘了執行 migration:

$ bin/rails db:migrate
== 20170503024259 CreateVoteLogs: migrating ===================================
-- create_table(:vote_logs)
   -> 0.0032s
== 20170503024259 CreateVoteLogs: migrated (0.0033s) ==========================

接下來,要設定一下 CandidateVoteLog 這兩個 Model 之間的關連性,在系統規劃中,我期望每位候選人會有很多(has_many)的投票紀錄,而每張選票紀錄因為都屬於(belongs_to)某位候選人,所以可以順利的指出候選人是誰。請打開 Candidate Model 加上以下的修改:

  class Candidate < ApplicationRecord
    has_many :vote_logs
  end

如果剛剛前面在建立 VoteLog Model 的時候使用了 candidate:references 的參數,現在看起來會長得像這樣:

  class VoteLog < ApplicationRecord
    belongs_to :candidate
  end

如果沒有那行 belongs_to,自己手動加上去就行了。更多關於 Model 關連性的說明,請參閱第 18 章「Model 關連性」。

接下來,改寫原本在 CandidatesControllervote 方法:

  class CandidatesController < ApplicationController
    # .. [略] ..

    def vote
      @candidate = Candidate.find_by(id: params[:id])
      @candidate.vote_logs.create(ip_address: request.remote_ip) if @candidate
      redirect_to candidates_path, notice: "完成投票!"
    end

    # .. [略] ..
  end

跟原本的 vote 方法的差別,在於這次是把投票的內容(什麼時候投、投給誰以及投票時候的 IP 位置)透過 VoteLog Model 寫到 vote_logs 資料表裡。

這時候進 Rails Console 呼叫 VoteLog.all 方法看一下,應該可以看到 vote_logs 表格是有資料的:

$ bin/rails console
Running via Spring preloader in process 28753
Loading development environment (Rails 5.1.0)
2.4.1 :001 > VoteLog.all
  VoteLog Load (2.3ms)  SELECT  "vote_logs".* FROM "vote_logs" LIMIT ?  [["LIMIT", 11]]
 => #<ActiveRecord::Relation [#<VoteLog id: 1, candidate_id: 1, ip_address: "127.0.0.1", created_at: "2017-05-03 07:50:15", updated_at: "2017-05-03 07:50:15">]>

確認資料有正常寫入後,最後,現在候選人列表所顯示的票數還是原本 Candidatevotes 欄位資料,所以再把這個:

  <td><%= candidate.votes %></td>

修正成這樣:

  <td><%= candidate.vote_logs.count %></td>

這樣票數應該就可以正常出現了。

第 13 步 -「投票給某位候選人」功能 Part 3

雖然投票功能看起來已經完成,但完成投票的當下其實都沒有什麼「提示」告知使用者已否已經完成。其實我們在 createupdatedestroy 以及 vote 方法裡都有放了一個 notice,像是這樣:

    redirect_to candidates_path, notice: "新增候選人成功!"

這一行的效果等同於下面這兩行:

    flash[:notice] = "新增候選人成功!"
    redirect_to candidates_path

這個 flash[:notice] 稱之 Flash 訊息,因為這兩行太常這樣寫,所以後來比較新的 Rails 版本就可以直接在後面加上一個 notice 參數就可以達到一樣的效果。你可以把 Flash 訊息看成一種特別的 Session,這個訊息一旦被印在畫面上就會消失。因為想要讓這個訊息出現在所有的頁面,可以直接把這部份的訊息寫在 Layout 裡(檔案: app/views/layouts/application.html.erb):

<!DOCTYPE html>
<html>
  ..[略]..

  <body>
    <%= notice %>
    <%= yield %>
  </body>
</html>

這樣一來,只要再投一次票就可以發現,左上角會出現「完成投票!」字樣,但這個字樣在重新整理之後就會消失了。

image

關於 Layout 的使用方法,可參閱第 15 章 - Layout, Render 與 View Helper

第 14 步 -「投票給某位候選人」功能 Part 4

不管是候選人資料或是投票功能,目前看起來都很不錯,但其實有個隱藏的問題,只要切換到 Log 的視窗就可以發現這段:

Started GET "/candidates" for 127.0.0.1 at 2017-05-03 16:29:42 +0800
Processing by CandidatesController#index as HTML
  Rendering candidates/index.html.erb within layouts/application
  Candidate Load (0.3ms)  SELECT "candidates".* FROM "candidates"
   (0.2ms)  SELECT COUNT(*) FROM "vote_logs" WHERE "vote_logs"."candidate_id" = ?  [["candidate_id", 1]]
   (0.2ms)  SELECT COUNT(*) FROM "vote_logs" WHERE "vote_logs"."candidate_id" = ?  [["candidate_id", 2]]
   (0.2ms)  SELECT COUNT(*) FROM "vote_logs" WH + 1 ERE "vote_logs"."candidate_id" = ?  [["candidate_id", 3]]
  Rendered candidates/index.html.erb within layouts/application (47.6ms)
Completed 200 OK in 172ms (Views: 164.0ms | ActiveRecord: 1.0ms)

也就是說,以我們的例子來說,雖然只有 3 筆資料,但卻對資料庫做了 4 次的查詢,這是一種所謂的「N + 1 問題」,這非常浪費系統效能。要解決這個問題,可以使用 Rails 提供的 Counter Cache 做法。

所謂的 Counter Cache,以這個例子來說,就是「把投票的結果更新到 Candidate Model 裡,投票的紀錄則是留在 VoteLog Model 裡」,這樣同時可兼顧效能也可記錄過程。做法滿簡單的,首先在 Candidate Model 開一個 integer 型態的欄位,名稱叫做 vote_logs_count,我們使用 Migration 來做這件事:

$ bin/rails generate migration add_counter_to_candidate vote_logs_count:integer
Running via Spring preloader in process 31648
  invoke  active_record
  create    db/migrate/20170503093543_add_count_to_candidate.rb

這時候 Migration 檔案的內容應該長得像這樣:

  class AddCounterToCandidate < ActiveRecord::Migration[5.1]
    def change
      add_column :candidates, :vote_logs_count, :integer
    end
  end

別忘了執行 bin/rails db:migrate。然後,在 VoteLog Model 的 belongs_to 後面加上 counter_cache: true 參數:

  class VoteLog < ApplicationRecord
    belongs_to :candidate, counter_cache: true
  end

這樣一來,每當在新增一筆投票記錄的同時,就會在這個當下把該位候選人的得票的總票數,更新到 Candidate Model 的 vote_logs_count 欄位了,相當便利。最後,候選人列表頁也要再做一點調整,把原來顯示票數的地方:

  <td><%= candidate.vote_logs.count %></td>

改成這樣:

  <td><%= candidate.vote_logs.size %></td>

是的,就是把 count 改成 size 就行了,如果這邊還是使用 count 方法,它一樣會去做原本的 N + 1 查詢。

再回來看一下 Log,應該會發現原來的 N + 1 查詢變成只做 1 次查詢了:

Started GET "/candidates" for 127.0.0.1 at 2017-05-03 17:46:30 +0800
Processing by CandidatesController#index as HTML
  Rendering candidates/index.html.erb within layouts/application
  Candidate Load (0.2ms)  SELECT "candidates".* FROM "candidates"
  Rendered candidates/index.html.erb within layouts/application (8.5ms)
Completed 200 OK in 52ms (Views: 49.5ms | ActiveRecord: 0.2ms)

第 15 步 - 整理重複的程式碼

到這裡大部份的功能已經完成了,但回頭看看 Controller,有好多重複的程式碼,像是這行:

  @candidate = Candidate.find_by(id: params[:id])

就出現了好幾次。在 Controller 裡有一個叫做 before_action 的方法,顧名思義,就是可以在每個 Action 執行之前先執行某個方法,例如:

  class CandidatesController < ApplicationController
    # .. [略] ..

    private
    def candidate_params
      params.require(:candidate).permit(:name, :age, :party, :politics )
    end

    def find_candidate
      @candidate = Candidate.find_by(id: params[:id])
    end
  end

我在 private 區塊寫了一個叫做 find_candidate 的方法,就是專門在做剛剛這個重複的事,然後把這個方法掛在 before_action 上,並且把重複的程式碼刪除,完整的程式碼會變這樣:

  class CandidatesController < ApplicationController
    before_action :find_candidate, only: [:edit, :update, :destroy, :vote]

    def index
      @candidates = Candidate.all
    end

    def new
      @candidate = Candidate.new
    end

    def edit
    end

    def update
      if @candidate.update_attributes(candidate_params)
        # 成功
        redirect_to candidates_path, notice: "資料更新成功!"
      else
        # 失敗
        render :edit
      end
    end

    def create
      @candidate = Candidate.new(candidate_params)

      if @candidate.save
        # 成功
        redirect_to candidates_path, notice: "成功新增候選人!"
      else
        # 失敗
        render :new
      end
    end

    def destroy
      @candidate.destroy if @candidate
      redirect_to candidates_path, notice: "候選人資料已刪除!"
    end

    def vote
      @candidate.increment(:votes)
      @candidate.save
      redirect_to candidates_path, notice: "完成投票!"
    end

    private
    def candidate_params
      params.require(:candidate).permit(:name, :age, :party, :politics )
    end

    def find_candidate
      @candidate = Candidate.find_by(id: params[:id])
    end
  end

其中,因為不是每個 Action 都需要先做 find_candidate,所以在 before_action 後面加上了 only,只有這幾個方法需要先處理。

接下來這兩個步驟是非必須的,但可以讓我們做出來的東西更漂亮一點,程式碼也會再精簡一些。

第 16 步 - 使用 bootstrap 來美化頁面

現在的畫面其實有點醜醜的,如果你沒有設計師幫你設計頁面或調整 CSS,這樣的東西也不好意思拿出去見人。還好,網路上的厲害的善心人士很多,像是 Twitter Bootstrap(以下簡稱 bootstrap)就是一例,透過 bootstrap,只要簡單幾行就可以讓原來看起來有點陽春的畫面一下子就變得高大上。

要使用 bootstrap 有好幾種用法,根據 bootstrap 的官方網站說明,可以使用 CDN 的方式,也可直接下載 JavaScript/CSS 檔案,但在 Rails 專案裡,我個人偏好使用另外把 bootstrap 打包好的 gem。

這個 gem 的名字是 bootstrap-sass,原始程式碼及安裝說明請參閱Github 上的說明手冊

另外,為了讓全站每一頁都使用 bootstrap 的功能,請編輯 app/views/layouts/application.html.erb 檔案,把 <%= yield %> 外面用一個 <div> 包起來,並且設定這個 div 的 classcontainer

  <!DOCTYPE html>
  <html>
    <head>
      <title>MyCandidates</title>
      <%= csrf_meta_tags %>

      <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
      <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
    </head>

    <body>
      <div class="container">
      <%= yield %>
      </div>
    </body>
  </html>

關於 Layout 更詳細的說明請參考第 15 章

注意:安裝完 bootstrap-sass gem 之後,可能會需要重新啟動 rails server

重新整理一下,原來的畫面有一些變化了:

image

讓我們用 bootstrap 幫醜醜的 <table> 化個妝。請打開 app/views/candidates/index.html.erb,幫原來的 <table> 加上一個 class

  <h1>候選人列表</h1>

  <%= link_to "新增候選人", new_candidate_path %>

  <table class="table">
    <thead>
      <tr>
        <td>投票</td>
        <td>候選人姓名</td>
  ...[略]...

重新整理一下,表格應該會立刻變一個樣子:

image

那個投票的連結不太明顯,讓我們用 bootstrap 把它化妝成一顆按鈕的樣子,就在原來的 link_to 後面加上 class 語法,像這樣:

  <td><%= link_to "投給這位", vote_candidate_path(candidate), method: "post", data: { confirm: "確認要投給這位候選人嗎?!" }, class:"btn btn-danger btn-xs" %></td>

後面那個 btn btn-danger btn-xs 的意思是指「一顆紅色(危險)但又是小號(xs)的按鈕」,存檔後重新整理,原來的連結看起來就像一顆按鈕了。

image

第 17 步 - 使用 gem 來簡化表單

雖然使用 form_for 跟一些 form helper 來製作表單不是很困難的事,但有個叫做 simple_form 的 gem 可以再簡化這些語法,原始碼及使用方法請見SimpleForm 網站說明。不過因為我們前面剛好有安裝 bootstrap,如果想要讓 simple_form 跟它更整合的話,在安裝的時候請記得使用這行指令(其實網站上就有提到):

$ bin/rails generate simple_form:install --bootstrap

注意:安裝完 gem 之後,可能會需要重新啟動 rails server

simple_form 可以用來取代原來的 form_for,原來那個 Partial Render 的 _form.html.erb

  <%= form_for(candidate) do |f| %>
    <%= f.label :name, "姓名" %>
    <%= f.text_field :name %> <br />

    <%= f.label :age, "年齡" %>
    <%= f.text_field :age %> <br />

    <%= f.label :party, "政黨" %>
    <%= f.text_field :party %> <br />

    <%= f.label :politics, "政見" %>
    <%= f.text_area :politics %> <br />

    <%= f.submit %>
  <% end %>

simple_form 改寫之後可以變成這樣:

  <%= simple_form_for(candidate) do |f| %>
    <%= f.input :name, label: "姓名" %>
    <%= f.input :age, label: "年齡" %>
    <%= f.input :party, label: "政黨" %>
    <%= f.input :politics, label: "政見" %>
    <%= f.submit %>
  <% end %>

把原本的 form_for 改成 simple_form_for,原本的 f.text_field 或是 f.text_area,都可統一改成 f.input,simple_form 會根據資料表的欄位型態,自動轉換成單行或多行輸入欄位。另外,也因為整合了 bootstrap,現在的畫面會變成這樣:

image

不僅程式碼變精簡了,畫面也變好看了。

以上實作完整程式碼可在 https://github.com/kaochenlong/my_candidates 取得。

← 上一章:CRUD 分解動作 - 簡易票選系統實作(上) 下一章:Layout, Render 與 View Helper →

Comments