Ruby on Rails 實戰聖經

使用 Rails 5.0+ 及 Ruby 2.3+

電子書製作中,歡迎留下 E-mail,有消息將會通知您。若您有任何意見、鼓勵或勘誤,也歡迎來信給我。願意贊助支持的話,這是我的微信 QR Code,謝謝。

Action Controller - 控制 HTTP 流程

Controlling complexity is the essence of computer programming. — Brian Kernighan

HTTP通訊協定是一種Request-Response(請求-回應)的流程,客戶端(通常是瀏覽器)向伺服器送出一個HTTP request封包,然後伺服器就回應一個response封包。在上一章中,我們介紹了Rails如何使用路由來分派requestController的其中一個Action。而每個Action的任務就是根據客戶端傳來的資料與Model互動,然後回應結果給客戶端。這一章中我們將仔細介紹負責回應請求的Controller

ApplicationController

透過rails g controller指令產生出來的 controller 都會繼承自ApplicationController。因此定義在這裡的方法可以被所有Controller取用,你可以在這邊定義一些共用的方法。預設的application_controller.rb長的如下:

class ApplicationController < ActionController::Base
  protect_from_forgery
end

其中的protect_from_forgery方法啟動了CSRF安全性功能,所有非GETHTTP request都必須帶有一個Token參數才能存取,Rails會自動在所有表單中幫你插入Token參數,預設的Layout中也有一行<%= csrf_meta_tag %>標籤可以讓JavaScript讀取到這個Token

但是當需要開放API給非瀏覽器客戶端時,例如手機端或第三方應用的回呼(webhook),這時候我們會需要關閉這個功能,例如:

class ApisController < ApplicationController
  skip_before_action :verify_authenticity_token # 整個 ApisController 關閉檢查
end

CSRF 網路攻擊 http://en.wikipedia.org/wiki/Cross-site_request_forgery

注意,請將方法放在protectedprivate之下,如果是public方法,就會變成一個公開的Action可以給瀏覽器呼叫到。

產生ControllerAction

我們在Part1示範過,要產生一個Controller檔案,請輸入

rails g controller events

如此便會產生app/controllers/events_controller.rb,依照RESTful設計的慣例,所有的Controller命名都是複數,而檔案名稱依照慣例都是{name}_controller.rb

一個Action就是Controller裡的一個Public方法:

class EventsController < ApplicationController
  def show
    # ...
  end
end

除了繼承自ApplicationController,我們也可以繼承更底層的ActionController::Metal,請參考Rails3: 新的 Metal 機制

Action方法中我們要處理request,基本上會做三件事情: 1. 收集request的資訊,例如使用者傳進來的參數 2. 操作Model來做資料的處理 3. 回傳response結果,這個動作稱作render

Request資訊收集

ControllerAction之中,Rails提供了一些方法可以讓你得知此request各種資訊,包括:

  • action_name 目前的Action名稱
  • cookies Cookie 下述
  • headers HTTP標頭
  • params 包含用戶所有傳進來的參數Hash,這是最常使用的資訊
  • request 各種關於此request的詳細資訊,較常用的例如:
    • xml_http_request? 或 xhr?,這個方法可以知道是不是 Ajax 請求
    • host_with_port
    • remote_ip
    • headers
  • response 代表要回傳的內容,會由Rails設定好。通常你會用到的時機是你想加特別的Response Header
  • session Session下述

正確的說,params這個HashActiveSupport::HashWithIndifferentAccess物件,而不是普通的Hash而已。Ruby內建的Hash,用Symbolhash[:foo]和用字串的hash["foo"]是不一樣的,這在混用的時候常常搞錯而取不到值,算是常見的臭蟲來源。Rails在這裡使用的ActiveSupport::HashWithIndifferentAccess物件,無論鍵是Symbol或字串,都指涉相同的值,減少麻煩。

Render結果

在根據request資訊做好資料處理之後,我們接下來就要回傳結果給用戶。事實上,就算你什麼都不處理,Action方法裡面空空如也,甚至不定義ActionRails預設也還是會執行render方法。這個render方法會回傳預設的Template,依照Rails慣例就是app/views/{controller_name}/{action_name}。如果找不到樣板檔案的話,會出現Template is missing的錯誤。

當然,有時候我們會需要自定render,也許是指定不同的Template,也許是不需要Template。這時候有以下參數可以使用:

直接回傳結果

  • render :text => "Hello" 直接回傳字串內容,不使用任何樣板。
  • render :xml => @event.to_xml 回傳XML格式
  • render :json => @event.to_json 回傳JSON格式(再加上:callback就會是JSONP)

指定Template

  • :template 指定Template,例如render :template => "index"或可以省略成render "index",如果是不同ControllerTemplate再加上Controller名稱,例如render "events/index"
  • :action 指定同一個Controller中另一個ActionTemplate(注意到只是使用它的Template,而不會執行該Action內的程式)

其他參數

  • :status 設定HTTP status,預設是200,也就是正常。其他常用代碼包括401權限不足、404找不到頁面、500伺服器錯誤等。
  • :layout 可以指定這個ActionLayout,設成false即關掉Layout

補充一提,在特定情況你想把render的結果存成一個字串,例如拿到局部樣板Partials成為一個字串,這時候可以改使用render_to_string :partial => "foobar"

Redirect

如果Action不要render任何結果,而是要使用者轉向到別頁,可以使用redirect_to

  • redirect_to events_url
  • redirect_to :back 回到上一頁。

注意,一個Action中只能有一個render或一個redirect_to。不然你會得到一個DoubleRenderError例外錯誤。

串流 Sending data

如果需要回傳二進位Binary資料,有兩個方法可以使用:

send_data(data, options={}) 回傳二進位字串,接受以下參數:

  • 其中data參數是二進位的字串:
  • :filename 使用者儲存下來的檔案名稱
  • :type 預設是application/octet-stream
  • :disposition inlineattachment
  • :status 預設是200

send_file(file_location, options={}) 回傳一個檔案,接受以下參數:

  • 其中file_location是檔案路徑和檔名:
  • :filename 使用者儲存下來的檔案名稱
  • :type 預設是application/octet-stream
  • :disposition inlineattachment
  • :status 預設是200

不過實務上我們很少在上線環境上直接用Rails來推送靜態檔案,因為大檔的傳輸時間會浪費寶貴的Rails運算資源。我們會改用X-Sendfile Header將傳檔的任務委派給網頁伺服器(例如ApacheNginx)處理,來降低Rails伺服器的負擔。或是搭配第三方雲儲存服務例如AWS S3將傳檔的任務外包出去。

respond_to

我們在第六章RESTful應用程式中曾經示範過用法,respond_to可以用來回應不同的資料格式。Rails內建支援格式包括有:html, :text, :js, :css, :ics, :csv, :xml, :rss, :atom, :yaml, :json等。如果需要擴充,可以編輯config/initializers/mime_types.rb這個檔案。

如果你想要設定一個else的情況,你可以用:any

respond_to do |format|
  format.html
  format.xml { render :xml => @event.to_xml }
  format.any { render :text => "WTF" }
end

另外,Rails也支援單行的簡單寫法:

respond_to :html, :json, :js

這樣其實就是:

respond_to do |format|
  format.html
  format.json
  format.js
end

Cookies

Cookies 是瀏覽器的功能可以讓我們將資料存在用戶的瀏覽器上,並且之後每個 HTTP Request,瀏覽器都會將你所設的 Cookies 再送回來伺服器,因此可以拿來追蹤識別不同用戶,以下是一些基本的用法範例:

# Sets a simple session cookie.
cookies[:user_name] = "david"

# Sets a cookie that expires in 1 hour.
cookies[:login] = { :value => "XJ-122", :expires => 1.hour.from_now }

# Example for deleting:
cookies.delete :user_name

cookies[:key] = {
   :value => 'a yummy cookie',
   :expires => 1.year.from_now,
   :domain => 'domain.com'
}

cookies.delete(:key, :domain => 'domain.com')

因為資料是存放在使用者瀏覽器,所以存了什麼內容用戶是可以看到的,甚至也可以進行修改。所以如果需要保護不能讓使用者亂改,Rails也提供了Signed方法幫你加密(會用config/secrets.yml這個檔案裡面設定的金鑰來做對稱式加密):

cookies.signed[:user_preference] = @current_user.preferences

另外,如果是盡可能永遠留在使用者瀏覽器的資料,可以使用Permanent方法:

cookies.permanent[:remember_me] = [current_user.id, current_user.salt]

兩者也可以加在一起用:

cookies.permanent.signed[:remember_me] = [current_user.id, current_user.salt]

Sessions

HTTP是一種無狀態的通訊協定,為了能夠讓瀏覽器能夠在跨request之間記住資訊,因此基於瀏覽器的 Cookies,Rails 再提供了所謂的 Session 可以更方便的操作,用來記住登入的狀態、記住使用者購物車的內容等等。

要操作Session,直接操作session這個Hash變數即可。例如:

session[:cart_id] = @cart.id

Session 原理可以參考Session_ID,基本上也是利用瀏覽器的cookie來追蹤requests請求。

Session storage

Rails預設採用Cookies session storage來儲存Session資料,它是將Session資料透過config/secrets.ymlsecret_key_base加密編碼後放到瀏覽器的Cookie之中,最大的好處是對伺服器的效能負擔很低,缺點是大小最多存4Kb,另外雖然有加密不能讓使用者去修改,但是畢竟資料還是存在用戶的瀏覽器上,仍然存在被破解的風險(因此請保護好你的 config/secrets.yml 鑰匙,如果外洩了就可以破解),因此不適合用在高度安全要求的網站應用。

除了Cookies session storageRails也支援其他方式,你可以修改config/initializers/session_store.rb

  • :active_record_store 使用資料庫來儲存
  • :mem_cache_store 使用Memcached快取系統來儲存,適合高流量的網站

一般來說使用預設的Cookies session storage即可,如果對安全性較高要求,可以使用資料庫。如果希望兼顧效能,可以考慮使用Memcached

採用:active_record_store的話,必須安裝activerecord-session_store gem,然後產生sessions資料表:

$ rails g active_record:session_migration
$ rake db:migrate

Flash訊息

我們在Part1示範過用Flash來傳遞訊息。它的用處在於redirect時,能夠從這一個request傳遞文字訊息到下一個request,例如從create Action傳遞「成功建立」的訊息到show Action

flash是一個Hash,其中的鍵你可以自定,常用:notice:warning:error等。例如我們在第一個Action中設定它:

def create
  @event = Event.create(params[:event])
  flash[:notice] = "成功建立"
  redirect_to event_url(@event)
end

那麼在下一個Action中,我們就可以在Template中讀取到這個訊息,通常我們會放在Layout中:

<p><%= flash[:notice] %></p>

或是直接用notice這個Helper

<p><%= notice %></p>

使用過一次之後,Rails就會自動清除flash

另外,有時候你等不及到下一個Action,就想讓Template在同一個Action中讀取到flash值,這時候你可以寫成:

flash.now[:notice] = "foobar"

最後,Rails預設針對noticealert這兩個類型可以直接塞進redirect_to當作參數,例如:

redirect_to event_url(@event), :notice => "成功建立"

你也可以自行擴充,例如新增一個warning

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  add_flash_types :warning
  #...
end

# in your controller
redirect_to user_path(@user), warning: "Incomplete profile"

# in your view
<%= warning %>

Filters

可將Controller中重複的程式抽出來,有三種方法可以定義在進入Action之前、之中或之後執行特定方法,分別是before_actionafter_actionaround_action,其中before_action最為常用。這三個方法可以接受Code block、一個Symbol方法名稱或是一個物件(Rails會呼叫此物件的filter方法)。

before_action

before_action最常用於準備跨Action共用的資料,或是使用者權限驗證等等:

class EventsControler < ApplicationController
  before_action :find_event, :only => :show

  def show
  end

  protected

  def find_event
    @event = Event.find(params[:id])
  end

end

每一個都可以搭配:only:except參數。

around_action

# app/controllers/benchmark_filter.rb
class BenchmarkFilter
    def self.filter(controller)
     timer = Time.now
     Rails.logger.debug "---#{controller.controller_name} #{controller.action_name}"
     yield # 這裡讓出來執行Action動作
     elapsed_time = Time.now - timer
     Rails.logger.debug "---#{controller.controller_name} #{controller.action_name} finished in %0.2f" % elapsed_time
    end
end

# app/controller/events_controller.rb
class EventsControler < ApplicationController
    around_action BenchmarkFilter
end

Filter的順序

當有多個Filter時,Rails是由上往下依序執行的。如果需要加到第一個執行,可以使用prepend_before_action方法,同理也有prepend_after_actionprepend_around_action

如果需要取消從父類別繼承過來的Filter,可以使用skip_before_action :filter_method_name方法,同理也有skip_after_actionskip_around_action

rescue_from

rescue_from可以在Controller中宣告救回特定的例外,改用你指定的方法處理,例如:

class ApplicationController < ActionController::Base

    rescue_from ActiveRecord::RecordInvalid, :with => :show_error

    protected

    def show_error
        # render something
    end

end

那些沒有被攔截到的錯誤例外,使用者會看到Rails預設的500錯誤畫面。一般來說比較常會用到rescue_from的時機,可能會是使用某些第三方函式庫,該函式庫可能會丟出一些例外是你想要做額外的錯誤處理。例如在pundit這個檢查權限的套件,如果發生權限不夠的情況,會丟出Pundit::NotAuthorizedError的例外,這時候就可以捕捉這個例外,改成回到首頁:

rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

protected

def user_not_authorized
 flash[:alert] = I18n.t(:user_not_authorized)
 redirect_to(request.referrer || root_path)
end

順道一提,關於如何設計好例外處理,可以參考筆者的一份投影片:Exception Handling: Designing Robust Software in Ruby

HTTP Basic Authenticate

Rails內建支援HTTP Basic Authenticate,可以很簡單實作出認證功能:

class PostsController < ApplicationController
    before_action :authenticate

    protected

    def authenticate
     authenticate_or_request_with_http_basic do |username, password|
       username == "foo" && password == "bar"
     end
    end
end

或是這樣寫:

class PostsController < ApplicationController
    http_basic_authenticate_with :name => "foo", :password => "bar"
end

偵測客戶端裝置提供不同內容

透過設定request.variant我們可以提供不同的Template內容,這可以拿來針對不同的客戶端裝置,提供不同的內容,例如利用request.user_agent來自動偵測電腦、手機和平板裝置:

class ApplicationController < ActionController::Base

before_action :detect_browser

private

def detect_browser
  case request.user_agent
    when /iPad/i
      request.variant = :tablet
    when /iPhone/i
      request.variant = :phone
    when /Android/i && /mobile/i
      request.variant = :phone
    when /Android/i
      request.variant = :tablet
    when /Windows Phone/i
      request.variant = :phone
    else
      request.variant = :desktop
   end
end

接著在需要支援的action中,加上

def index
  # ...
  respond_to do |format|
    format.html
    format.html.phone
    format.html.tablet
  end
end

Template的命名則是index.html+phone.erbindex.html+tablet.erb

更多線上資源

》回到頁首