Action Controller - 控制 HTTP 流程

    HTTP通讯协定是一种Request-Response(请求-回应)的流程,客户端(通常是浏览器)向服务器送出一个HTTP request封包,然后服务器就回应一个response封包。在上一章中,我们介绍了Rails如何使用路由来分派requestController的其中一个Action。而每个Action的任务就是根据客户端传来的资料与Model互动,然后回应结果给客户端。这一章中我们将仔细介绍负责回应请求的Controller

    透过指令产生出来的 controller 都会继承自ApplicationController。因此定义在这里的方法可以被所有Controller取用,你可以在这边定义一些共享的方法。默认的application_controller.rb长的如下:

    其中的protectfrom_forgery方法启动了_CSRF安全性功能,所有非GETHTTP request都必须带有一个Token参数才能存取,Rails会自动在所有表单中帮你插入Token参数,默认的Layout中也有一行<%= csrfmeta_tag %>标籤可以让_JavaScript读取到这个Token

    但是当需要开放API给非浏览器客户端时,例如手机端或第三方应用的回呼(webhook),这时候我们会需要关闭这个功能,例如:

    1. class ApisController < ApplicationController
    2. skip_before_action :verify_authenticity_token # 整个 ApisController 关闭检查
    3. end

    CSRF 网络攻击

    产生Controller与Action

    我们在Part1示范过,要产生一个Controller档案,请输入

    1. rails g controller events

    如此便会产生app/controllers/events_controller.rb,依照RESTful设计的惯例,所有的Controller命名都是复数,而档案名称依照惯例都是{name}_controller.rb

    一个Action就是Controller里的一个Public方法:

    1. class EventsController < ApplicationController
    2. def show
    3. # ...
    4. end
    5. end

    除了继承自ApplicationController,我们也可以继承更底层的ActionController::Metal,请参考Rails3: 新的 Metal 机制

    Action方法中我们要处理request,基本上会做三件事情:1. 收集request的资讯,例如使用者传进来的参数2. 操作Model来做资料的处理3. 回传response结果,这个动作称作render

    Request资讯收集

    ControllerAction之中,Rails提供了一些方法可以让你得知此request各种资讯,包括:

    • actionname 目前的_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下述

    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.toxml 回传_XML格式
    • render :json => @event.tojson 回传_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例外错误。

    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应用程式中曾经示范过用法,respondto可以用来回应不同的资料格式。_Rails内建支援格式包括有:html, :text, :js, :css, :ics, :csv, :xml, :rss, :atom, :yaml, :json等。如果需要扩充,可以编辑config/initializers/mime_types.rb这个档案。

    如果你想要设定一个else的情况,你可以用:any

    1. respond_to do |format|
    2. format.html
    3. format.xml { render :xml => @event.to_xml }
    4. end

    另外,Rails也支援单行的简单写法:

    1. respond_to :html, :json, :js

    这样其实就是:

    1. respond_to do |format|
    2. format.html
    3. format.json
    4. format.js
    5. end

    Cookies

    Cookies 是浏览器的功能可以让我们将资料存在用户的浏览器上,并且之后每个 HTTP Request,浏览器都会将你所设的 Cookies 再送回来服务器,因此可以拿来追踪识别不同用户,以下是一些基本的用法范例:

    1. # Sets a simple session cookie.
    2. cookies[:user_name] = "david"
    3. # Sets a cookie that expires in 1 hour.
    4. cookies[:login] = { :value => "XJ-122", :expires => 1.hour.from_now }
    5. # Example for deleting:
    6. cookies.delete :user_name
    7. :value => 'a yummy cookie',
    8. :expires => 1.year.from_now,
    9. :domain => 'domain.com'
    10. }
    11. cookies.delete(:key, :domain => 'domain.com')

    因为资料是存放在使用者浏览器,所以存了什么内容用户是可以看到的,甚至也可以进行修改。所以如果需要保护不能让使用者乱改,Rails也提供了Signed方法帮你加密(会用config/secrets.yml这个档案里面设定的金钥来做对称式加密):

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

    另外,如果是尽可能永远留在使用者浏览器的资料,可以使用Permanent方法:

    两者也可以加在一起用:

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

    Sessions

    HTTP是一种无状态的通讯协定,为了能够让浏览器能够在跨request之间记住资讯,因此基于浏览器的 Cookies,Rails 再提供了所谓的 Session 可以更方便的操作,用来记住登入的状态、记住使用者购物车的内容等等。

    要操作Session,直接操作session这个Hash变量即可。例如:

    1. session[:cart_id] = @cart.id

    Session storage

    Rails默认采用Cookies session storage来储存Session资料,它是将Session资料透过config/secrets.ymlsecretkey_base加密编码后放到浏览器的_Cookie之中,最大的好处是对服务器的效能负担很低,缺点是大小最多存4Kb,另外虽然有加密不能让使用者去修改,但是毕竟资料还是存在用户的浏览器上,仍然存在被破解的风险(因此请保护好你的 config/secrets.yml 钥匙,如果外洩了就可以破解),因此不适合用在高度安全要求的网站应用。

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

    • :active_record_store 使用数据库来储存
    • :memcache_store 使用_快取系统来储存,适合高流量的网站

    一般来说使用默认的Cookies session storage即可,如果对安全性较高要求,可以使用数据库。如果希望兼顾效能,可以考虑使用Memcached

    采用:activerecord_store的话,必须安装_activerecord-session_store gem,然后产生sessions资料表:

    1. $ rails g active_record:session_migration
    2. $ rake db:migrate

    Flash讯息

    flash是一个Hash,其中的键你可以自定,常用:notice:warning:error等。例如我们在第一个Action中设定它:

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

    那么在下一个Action中,我们就可以在Template中读取到这个讯息,通常我们会放在Layout中:

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

    或是直接用notice这个Helper

    1. <p><%= notice %></p>

    使用过一次之后,Rails就会自动清除flash

    另外,有时候你等不及到下一个Action,就想让Template在同一个Action中读取到flash值,这时候你可以写成:

    1. flash.now[:notice] = "foobar"

    最后,Rails默认针对noticealert这两个类型可以直接塞进redirect_to当作参数,例如:

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

    你也可以自行扩充,例如新增一个warning

    可将Controller中重复的程式抽出来,有三种方法可以定义在进入Action之前、之中或之后执行特定方法,分别是beforeactionafter_actionaround_action,其中before_action最为常用。这三个方法可以接受_Code block、一个Symbol方法名称或是一个物件(Rails会呼叫此物件的filter方法)。

    before_action最常用于准备跨Action共享的资料,或是使用者权限验证等等:

    1. class EventsControler < ApplicationController
    2. before_action :find_event, :only => :show
    3. def show
    4. end
    5. protected
    6. def find_event
    7. @event = Event.find(params[:id])
    8. end
    9. end

    每一个都可以搭配:only:except参数。

    around_action

    1. # app/controllers/benchmark_filter.rb
    2. class BenchmarkFilter
    3. def self.filter(controller)
    4. timer = Time.now
    5. Rails.logger.debug "---#{controller.controller_name} #{controller.action_name}"
    6. yield # 这里让出来执行Action动作
    7. elapsed_time = Time.now - timer
    8. Rails.logger.debug "---#{controller.controller_name} #{controller.action_name} finished in %0.2f" % elapsed_time
    9. end
    10. end
    11. # app/controller/events_controller.rb
    12. around_action BenchmarkFilter
    13. end

    当有多个Filter时,Rails是由上往下依序执行的。如果需要加到第一个执行,可以使用prepend_before_action方法,同理也有和prepend_around_action

    如果需要取消从父类别继承过来的Filter,可以使用skip_before_action :filter_method_name方法,同理也有skip_after_actionskip_around_action

    rescue_from

    rescuefrom可以在_Controller中宣告救回特定的例外,改用你指定的方法处理,例如:

    1. class ApplicationController < ActionController::Base
    2. rescue_from ActiveRecord::RecordInvalid, :with => :show_error
    3. protected
    4. def show_error
    5. # render something
    6. end
    7. end

    那些没有被拦截到的错误例外,使用者会看到Rails默认的500错误画面。一般来说比较常会用到rescue_from的时机,可能会是使用某些第三方函式库,该函式库可能会丢出一些例外是你想要做额外的错误处理。例如在pundit这个检查权限的套件,如果发生权限不够的情况,会丢出Pundit::NotAuthorizedError的例外,这时候就可以捕捉这个例外,改成回到首页:

    1. rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
    2. protected
    3. def user_not_authorized
    4. flash[:alert] = I18n.t(:user_not_authorized)
    5. redirect_to(request.referrer || root_path)
    6. end

    顺道一提,关于如何设计好例外处理,可以参考笔者的一份投影片:

    HTTP Basic Authenticate

    Rails内建支援HTTP Basic Authenticate,可以很简单实作出认证功能:

    1. class PostsController < ApplicationController
    2. before_action :authenticate
    3. protected
    4. def authenticate
    5. authenticate_or_request_with_http_basic do |username, password|
    6. username == "foo" && password == "bar"
    7. end
    8. end
    9. end

    或是这样写:

    1. class PostsController < ApplicationController
    2. http_basic_authenticate_with :name => "foo", :password => "bar"
    3. end

    侦测客户端装置提供不同内容

    透过设定request.variant我们可以提供不同的Template内容,这可以拿来针对不同的客户端装置,提供不同的内容,例如利用request.user_agent来自动侦测电脑、手机和平板装置:

    1. class ApplicationController < ActionController::Base
    2. before_action :detect_browser
    3. private
    4. def detect_browser
    5. case request.user_agent
    6. when /iPad/i
    7. request.variant = :tablet
    8. when /iPhone/i
    9. request.variant = :phone
    10. when /Android/i && /mobile/i
    11. request.variant = :phone
    12. when /Android/i
    13. request.variant = :tablet
    14. when /Windows Phone/i
    15. request.variant = :phone
    16. else
    17. request.variant = :desktop
    18. end
    19. end
    1. def index
    2. # ...
    3. respond_to do |format|
    4. format.html
    5. format.html.phone
    6. format.html.tablet
    7. end

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

    更多线上资源