Action Controller: 控制HTTP流程
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 :text => "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_urlredirect_to :back回到上一页。
注意,一个 Action 中只能有一个
render或一个redirect_to。不然你会得到一个DoubleRenderError例外错误。
串流 Sending data
如果需要回传二进制 Binary 资料,有两个方法可以使用:
send_data(data, options={}) 回传二进制字串,接受以下参数:
- 其中
data参数是二进制的字串: :filename使用者储存下来的档案名称:type默认是 application/octet-stream:dispositioninline 或 attachment:status默认是 200
send_file(file_location, options={}) 回传一个档案,接受以下参数:
- 其中
file_location是档案路径和档名: :filename使用者储存下来的档案名称:type默认是 application/octet-stream:dispositioninline 或 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 :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.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 。
