Action Controller - 控制 HTTP 流程
Controlling complexity is the essence of computer programming. — Brian Kernighan
HTTP通訊協定是一種Request-Response(請求-回應)的流程,客戶端(通常是瀏覽器)向伺服器送出一個HTTP request封包,然後伺服器就回應一個response封包。在上一章中,我們介紹了Rails如何使用路由來分派request到Controller的其中一個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安全性功能,所有非GET的HTTP 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
注意,請將方法放在protected或private之下,如果是public方法,就會變成一個公開的Action可以給瀏覽器呼叫到。
產生Controller與Action
我們在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資訊收集
在Controller的Action之中,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這個Hash是
ActiveSupport::HashWithIndifferentAccess
物件,而不是普通的Hash而已。Ruby內建的Hash,用Symbol的hash[:foo]
和用字串的hash["foo"]
是不一樣的,這在混用的時候常常搞錯而取不到值,算是常見的臭蟲來源。Rails在這裡使用的ActiveSupport::HashWithIndifferentAccess
物件,無論鍵是Symbol或字串,都指涉相同的值,減少麻煩。
Render結果
在根據request資訊做好資料處理之後,我們接下來就要回傳結果給用戶。事實上,就算你什麼都不處理,Action方法裡面空空如也,甚至不定義Action,Rails預設也還是會執行render方法。這個render方法會回傳預設的Template,依照Rails慣例就是app/views/{controller_name}/{action_name}。如果找不到樣板檔案的話,會出現Template is missing的錯誤。
當然,有時候我們會需要自定render,也許是指定不同的Template,也許是不需要Template。這時候有以下參數可以使用:
直接回傳結果
render :plain => "Hello"
直接回傳字串內容,不使用任何樣板。render :xml => @event.to_xml
回傳XML格式render :json => @event.to_json
回傳JSON格式(再加上:callback
就會是JSONP)
指定Template
:template
指定Template,例如render :template => "index"
或可以省略成render "index"
,如果是不同Controller的Template再加上Controller名稱,例如render "events/index"
。:action
指定同一個Controller中另一個Action的Template(注意到只是使用它的Template,而不會執行該Action內的程式)
其他參數
:status
設定HTTP status,預設是200,也就是正常。其他常用代碼包括401權限不足、404找不到頁面、500伺服器錯誤等。:layout
可以指定這個Action的Layout,設成false即關掉Layout
補充一提,在特定情況你想把render
的結果存成一個字串,例如拿到局部樣板Partials成為一個字串,這時候可以改使用render_to_string :partial => "foobar"
Redirect
如果Action不要render任何結果,而是要使用者轉向到別頁,可以使用redirect_to
redirect_to events_url
redirect_back(fallback_location: root_path)
回到上一頁。若不知道上一頁,則回到首頁。
注意,一個Action中只能有一個
render
或一個redirect_to
。不然你會得到一個DoubleRenderError
例外錯誤。
串流 Sending data
如果需要回傳二進位Binary資料,有兩個方法可以使用:
send_data(data, options={})
回傳二進位字串,接受以下參數:
- 其中
data
參數是二進位的字串: :filename
使用者儲存下來的檔案名稱:type
預設是application/octet-stream:disposition
inline或attachment:status
預設是200
send_file(file_location, options={})
回傳一個檔案,接受以下參數:
- 其中
file_location
是檔案路徑和檔名: :filename
使用者儲存下來的檔案名稱:type
預設是application/octet-stream:disposition
inline或attachment:status
預設是200
不過實務上我們很少在上線環境上直接用Rails來推送靜態檔案,因為大檔的傳輸時間會浪費寶貴的Rails運算資源。我們會改用X-Sendfile Header將傳檔的任務委派給網頁伺服器(例如Apache或Nginx)處理,來降低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 :plain => "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.yml的secret_key_base
加密編碼後放到瀏覽器的Cookie之中,最大的好處是對伺服器的效能負擔很低,缺點是大小最多存4Kb,另外雖然有加密不能讓使用者去修改,但是畢竟資料還是存在用戶的瀏覽器上,仍然存在被破解的風險(因此請保護好你的 config/secrets.yml
鑰匙,如果外洩了就可以破解),因此不適合用在高度安全要求的網站應用。
除了Cookies session storage,Rails也支援其他方式,你可以修改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預設針對notice
和alert
這兩個類型可以直接塞進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_action
、after_action
和around_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_action
和prepend_around_action
。
如果需要取消從父類別繼承過來的Filter,可以使用skip_before_action :filter_method_name
方法,同理也有skip_after_action
和skip_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.erb和index.html+tablet.erb。