更多内容请访问 rubyonrails.org:

Action Controller 概述

在本指南中,您将学习控制器的运作方式以及它们如何在应用程序的请求周期中发挥作用。

阅读本指南后,您将了解如何

  • 跟踪请求通过控制器的流程。
  • 限制传递到控制器的参数。
  • 将数据存储在会话或 cookie 中,以及原因。
  • 使用操作回调在请求处理期间执行代码。
  • 使用 Action Controller 的内置 HTTP 身份验证。
  • 将数据直接流式传输到用户的浏览器。
  • 过滤敏感参数,使其不会出现在应用程序日志中。
  • 处理请求处理期间可能引发的异常。
  • 使用内置的健康检查端点用于负载均衡器和正常运行时间监视器。

1 控制器的作用是什么?

Action Controller 是 MVC 中的 C。在路由器确定要使用哪个控制器来处理请求后,控制器负责理解请求并生成相应的输出。幸运的是,Action Controller 为您完成了大部分基础工作,并使用智能约定使这个过程尽可能简单直观。

对于大多数传统的 RESTful 应用程序,控制器将接收请求(这对开发者来说是不可见的),从模型中获取或保存数据,并使用视图创建 HTML 输出。如果您的控制器需要以略有不同的方式执行操作,这并非问题,这只是控制器工作最常见的模式。

因此,可以将控制器视为模型和视图之间的中间人。它将模型数据提供给视图,以便视图可以将这些数据显示给用户,并且它将用户数据保存或更新到模型。

有关路由过程的更多详细信息,请参阅 从外部到内部的 Rails 路由

2 控制器命名约定

Rails 中的控制器命名约定倾向于将控制器名称最后一个词进行复数化,尽管这不是严格要求的(例如 ApplicationController)。例如,ClientsControllerClientController 更可取,SiteAdminsControllerSiteAdminControllerSitesAdminsController 更可取,以此类推。

遵循此约定将允许您使用默认的路由生成器(例如 resources 等),而无需为每个 :path:controller 指定限定符,并将保持命名路由助手在整个应用程序中的使用一致。有关更多详细信息,请参阅 布局和渲染指南

控制器命名约定与模型的命名约定不同,模型的命名约定要求使用单数形式。

3 方法和操作

控制器是一个继承自 ApplicationController 的 Ruby 类,并且像其他任何类一样拥有方法。当您的应用程序接收到请求时,路由将确定要运行哪个控制器和操作,然后 Rails 将创建该控制器的实例并运行与操作同名的那个方法。

class ClientsController < ApplicationController
  def new
  end
end

例如,如果用户访问应用程序中的 /clients/new 来添加新客户,Rails 将创建 ClientsController 的实例并调用其 new 方法。请注意,上面的示例中的空方法可以正常工作,因为 Rails 默认情况下会渲染 new.html.erb 视图,除非操作另有说明。通过创建一个新的 Clientnew 方法可以使 @client 实例变量在视图中可用。

def new
  @client = Client.new
end

布局和渲染指南 更详细地解释了这一点。

ApplicationController 继承自 ActionController::Base,它定义了许多有用的方法。本指南将涵盖其中的一部分,但如果您想知道其中包含的内容,您可以在 API 文档 或源代码本身中查看所有这些方法。

只有公共方法可以作为操作调用。最佳实践是降低不打算作为操作的方法的可访问性(使用 privateprotected),例如辅助方法或过滤器。

Action Controller 保留了一些方法名。不小心将它们重新定义为操作,甚至重新定义为辅助方法,都可能导致 SystemStackError。如果您将控制器限制为仅使用 RESTful 资源路由 操作,则无需担心此问题。

如果您必须使用保留方法作为操作名,一种解决方法是使用自定义路由将保留方法名映射到您的非保留操作方法。

4 参数

您可能希望访问控制器操作中用户或其他参数发送的数据。Web 应用程序中可能存在两种类型的参数。第一种是作为 URL 部分发送的参数,称为查询字符串参数。查询字符串是 URL 中 “?” 后面的所有内容。第二种参数通常称为 POST 数据。这些信息通常来自用户填写过的 HTML 表单。它被称为 POST 数据,因为它只能作为 HTTP POST 请求的一部分发送。Rails 不会区分查询字符串参数和 POST 参数,两者在控制器中的 params 哈希中均可用。

class ClientsController < ApplicationController
  # This action uses query string parameters because it gets run
  # by an HTTP GET request, but this does not make any difference
  # to how the parameters are accessed. The URL for
  # this action would look like this to list activated
  # clients: /clients?status=activated
  def index
    if params[:status] == "activated"
      @clients = Client.activated
    else
      @clients = Client.inactivated
    end
  end

  # This action uses POST parameters. They are most likely coming
  # from an HTML form that the user has submitted. The URL for
  # this RESTful request will be "/clients", and the data will be
  # sent as part of the request body.
  def create
    @client = Client.new(params[:client])
    if @client.save
      redirect_to @client
    else
      # This line overrides the default rendering behavior, which
      # would have been to render the "create" view.
      render "new"
    end
  end
end

params 哈希不是普通的 Ruby 哈希;相反,它是一个 ActionController::Parameters 对象。虽然它像哈希一样工作,但它并没有继承自哈希。

4.1 哈希和数组参数

params 哈希不仅限于一维键值对。它可以包含嵌套的数组和哈希。要发送值的数组,请在键名后面附加一对空方括号 "[]"。

GET /clients?ids[]=1&ids[]=2&ids[]=3

在本例中,实际的 URL 将被编码为 "/clients?ids%5b%5d=1&ids%5b%5d=2&ids%5b%5d=3",因为 "[" 和 "]" 字符在 URL 中不允许。大多数情况下,您不必担心这个问题,因为浏览器会为您进行编码,Rails 会自动进行解码,但如果您发现自己必须手动将这些请求发送到服务器,则应牢记这一点。

现在 params[:ids] 的值将为 ["1", "2", "3"]。请注意,参数值始终为字符串;Rails 不会尝试猜测或强制转换类型。

params 中的值,例如 [nil][nil, nil, ...],默认情况下会被安全原因替换为 []。有关详细信息,请参阅 安全指南

要发送哈希,您需要将键名包含在方括号中

<form accept-charset="UTF-8" action="/clients" method="post">
  <input type="text" name="client[name]" value="Acme" />
  <input type="text" name="client[phone]" value="12345" />
  <input type="text" name="client[address][postcode]" value="12345" />
  <input type="text" name="client[address][city]" value="Carrot City" />
</form>

当提交此表单时,params[:client] 的值将为 { "name" => "Acme", "phone" => "12345", "address" => { "postcode" => "12345", "city" => "Carrot City" } }。请注意 params[:client][:address] 中的嵌套哈希。

params 对象的行为像哈希,但允许您将符号和字符串互换地用作键。

4.2 JSON 参数

如果您的应用程序公开 API,您很可能将以 JSON 格式接收参数。如果请求的 "Content-Type" 标头设置为 "application/json",Rails 将自动将您的参数加载到 params 哈希中,您可以像往常一样访问它。

例如,如果您正在发送此 JSON 内容

{ "company": { "name": "acme", "address": "123 Carrot Street" } }

您的控制器将接收 params[:company],值为 { "name" => "acme", "address" => "123 Carrot Street" }

此外,如果您在初始化程序中启用了 config.wrap_parameters 或者在控制器中调用了 wrap_parameters,那么您可以安全地省略 JSON 参数中的根元素。在这种情况下,参数将被克隆并使用基于您的控制器名称选择的键进行包装。因此,上述 JSON 请求可以写成

{ "name": "acme", "address": "123 Carrot Street" }

并且,假设您将数据发送到 CompaniesController,它将被包装在 :company 键中,如下所示

{ name: "acme", address: "123 Carrot Street", company: { name: "acme", address: "123 Carrot Street" } }

您可以通过查阅 API 文档 来自定义键的名称或要包装的特定参数。

解析 XML 参数的支持已提取到名为 actionpack-xml_parser 的 gem 中。

4.3 路由参数

params 哈希始终包含 :controller:action 键,但您应该使用 controller_nameaction_name 方法来访问这些值。路由定义的任何其他参数,例如 :id,也将可用。例如,考虑一个客户列表,其中列表可以显示活动客户或非活动客户。我们可以添加一个路由,该路由在 "漂亮" URL 中捕获 :status 参数

get "/clients/:status", to: "clients#index", foo: "bar"

在这种情况下,当用户打开 URL /clients/active 时,params[:status] 将被设置为 "active"。当使用此路由时,params[:foo] 也将被设置为 "bar",就好像它是在查询字符串中传递的一样。您的控制器还将接收 params[:action],值为 "index",以及 params[:controller],值为 "clients"。

4.4 复合键参数

复合键参数在一个参数中包含多个值。出于这个原因,我们需要能够提取每个值并将其传递给 Active Record。我们可以利用 extract_value 方法来处理这种情况。

给定以下控制器

class BooksController < ApplicationController
  def show
    # Extract the composite ID value from URL parameters.
    id = params.extract_value(:id)
    # Find the book using the composite ID.
    @book = Book.find(id)
    # use the default rendering behaviour to render the show view.
  end
end

以及以下路由

get "/books/:id", to: "books#show"

当用户打开 URL /books/4_2 时,控制器将提取复合键值 ["4", "2"] 并将其传递给 Book.find 以在视图中呈现正确的记录。extract_value 方法可用于从任何分隔符参数中提取数组。

4.5 default_url_options

您可以通过在控制器中定义名为 default_url_options 的方法来设置 URL 生成的全局默认参数。此方法必须返回一个包含所需默认值的哈希,其键必须是符号

class ApplicationController < ActionController::Base
  def default_url_options
    { locale: I18n.locale }
  end
end

这些选项将用作生成 URL 的起点,因此有可能它们会被传递给 url_for 调用的选项覆盖。

如果您在 ApplicationController 中定义 default_url_options,如上面的示例所示,这些默认值将用于所有 URL 生成。该方法也可以在特定控制器中定义,在这种情况下,它只影响在那里生成的 URL。

在给定的请求中,该方法实际上并没有为每个生成的 URL 调用。出于性能原因,返回的哈希会被缓存,并且每个请求最多只调用一次。

4.6 强参数

使用强参数,Action Controller 参数在被允许之前禁止在 Active Model 批量赋值中使用。这意味着您必须有意识地决定允许哪些属性进行批量更新。这是一个更好的安全实践,有助于防止意外地允许用户更新敏感的模型属性。

此外,参数可以被标记为必需,并将通过预定义的 raise/rescue 流程,如果未传递所有必需参数,则将返回 400 Bad Request。

class PeopleController < ActionController::Base
  # This will raise an ActiveModel::ForbiddenAttributesError exception
  # because it's using mass assignment without an explicit permit
  # step.
  def create
    Person.create(params[:person])
  end

  # This will pass with flying colors as long as there's a person key
  # in the parameters, otherwise it'll raise an
  # ActionController::ParameterMissing exception, which will get
  # caught by ActionController::Base and turned into a 400 Bad
  # Request error.
  def update
    person = current_account.people.find(params[:id])
    person.update!(person_params)
    redirect_to person
  end

  private
    # Using a private method to encapsulate the permissible parameters
    # is just a good pattern since you'll be able to reuse the same
    # permit list between create and update. Also, you can specialize
    # this method with per-user checking of permissible attributes.
    def person_params
      params.expect(person: [:name, :age])
    end
end

4.6.1 允许的标量值

调用 permit,例如

params.permit(:id)

允许包含指定的键(:id),如果它出现在 params 中,并且它有一个允许的标量值相关联。否则,该键将被过滤掉,因此数组、哈希或任何其他对象不能被注入。

允许的标量类型为 StringSymbolNilClassNumericTrueClassFalseClassDateTimeDateTimeStringIOIOActionDispatch::Http::UploadedFileRack::Test::UploadedFile

要声明 params 中的值必须是允许的标量值的数组,请将键映射到一个空数组

params.permit(id: [])

有时不可能或不方便声明哈希参数的有效键或其内部结构。只需映射到一个空哈希

params.permit(preferences: {})

但要小心,因为这会为任意输入打开大门。在这种情况下,permit 确保返回的结构中的值是允许的标量,并过滤掉其他任何东西。

expect 提供了一种简洁而安全的方法来要求和允许参数。

id = params.expect(:id)

expect 确保返回的类型不会受到参数篡改的威胁。上面的 expect 将始终返回一个标量值,而不是数组或哈希。当期望来自表单的参数时,使用 expect 来确保根键存在并且属性是允许的。

user_params = params.expect(user: [:username, :password])
user_params.has_key?(:username) # => true

当用户键不是具有预期键的嵌套哈希时,expect 将引发错误并返回 400 Bad Request 响应。

要要求和允许整个参数哈希,可以使用以下方式使用 expect

params.expect(log_entry: {})

这将 :log_entry 参数哈希及其任何子哈希标记为允许,并且不会检查允许的标量,任何东西都将被接受。在使用 permit! 或使用空哈希调用 expect 时要格外小心,因为它将允许使用外部用户控制的参数对当前和将来的所有模型属性进行批量赋值。

4.6.2 嵌套参数

您也可以对嵌套参数使用 expect(或 permit),例如

# Given the example expected params:
params = ActionController::Parameters.new(
  name: "Martin",
  emails: ["[email protected]"],
  friends: [
    { name: "André", family: { name: "RubyGems" }, hobbies: ["keyboards", "card games"] },
    { name: "Kewe", family: { name: "Baroness" }, hobbies: ["video games"] },
  ]
)
# the following expect will ensure the params are permitted
name, emails, friends = params.expect(
  :name,                 # permitted scalar
  emails: [],            # array of permitted scalars
  friends: [[            # array of permitted Parameter hashes
    :name,               # permitted scalar
    family: [:name],     # family: { name: "permitted scalar" }
    hobbies: []          # array of permitted scalars
  ]]
)

此声明允许 nameemailsfriends 属性并返回它们各自的值。预计 emails 将是允许的标量值的数组,并且 friends 将是资源的数组(请注意新的双数组语法,以明确要求一个数组),具有特定属性:它们应该具有 name 属性(允许任何允许的标量值)、hobbies 属性作为允许的标量值的数组,以及 family 属性,该属性仅限于具有 name 键和任何允许的标量值的哈希。

4.6.3 更多示例

模型类方法 accepts_nested_attributes_for 允许您更新和销毁关联的记录。这基于 id_destroy 参数

# permit :id and :_destroy
params.expect(author: [ :name, books_attributes: [[ :title, :id, :_destroy ]] ])

具有整数键的哈希将被不同地对待,您可以像它们是直接子项一样声明属性。当您将 accepts_nested_attributes_forhas_many 关联结合使用时,您将获得此类参数

# To permit the following data:
# {"book" => {"title" => "Some Book",
#             "chapters_attributes" => { "1" => {"title" => "First Chapter"},
#                                        "2" => {"title" => "Second Chapter"}}}}

params.expect(book: [ :title, chapters_attributes: [[ :title ]] ])

想象一下,您有参数代表一个产品名称,以及与该产品关联的任意数据的哈希,并且您想允许产品名称属性以及整个数据哈希

def product_params
  params.expect(product: [ :name, data: {} ])
end

4.6.4 强参数范围之外

强参数 API 是针对最常见的用例设计的。它并不意味着可以解决所有参数过滤问题。但是,您可以轻松地将 API 与您自己的代码混合,以适应您的情况。

5 会话

您的应用程序为每个用户都有一个会话,您可以在其中存储少量的将在请求之间持久化的数据。会话只在控制器和视图中可用,可以使用几种不同的存储机制

所有会话存储都使用 cookie 来存储每个会话的唯一 ID(您必须使用 cookie,Rails 不允许您在 URL 中传递会话 ID,因为这样不太安全)。

对于大多数存储,此 ID 用于在服务器上查找会话数据,例如在数据库表中。有一个例外,那就是默认且推荐的会话存储 - CookieStore - 它将所有会话数据存储在 cookie 本身中(如果您需要,ID 仍然可用)。这样做的优点是非常轻量级,而且在使用会话的新应用程序中不需要任何设置。cookie 数据经过加密签名,以防止篡改。它也被加密,所以任何有权访问它的人都不能读取其内容。(如果 cookie 数据被编辑,Rails 不会接受它)。

CookieStore 可以存储大约 4 kB 的数据 - 比其他存储少得多 - 但这通常已经足够了。无论您的应用程序使用哪种会话存储,都建议不要在会话中存储大量数据。您应该特别避免在会话中存储复杂的对象(例如模型实例),因为服务器可能无法在请求之间重新组装它们,这会导致错误。

如果您的用户会话没有存储关键数据或不需要长时间保留(例如,如果您只使用 flash 进行消息传递),您可以考虑使用 ActionDispatch::Session::CacheStore。这将使用您为应用程序配置的缓存实现存储会话。这样做的好处是,您可以使用现有的缓存基础设施来存储会话,而无需任何额外的设置或管理。当然,缺点是会话是短暂的,随时可能消失。

安全指南 中了解更多关于会话存储的信息。

如果您需要不同的会话存储机制,您可以在初始化器中更改它

Rails.application.config.session_store :cache_store

有关更多信息,请参阅配置指南中的 config.session_store

Rails 在签名会话数据时会设置一个会话密钥(cookie 的名称)。这些也可以在初始化器中更改

# Be sure to restart your server when you modify this file.
Rails.application.config.session_store :cookie_store, key: "_your_app_session"

您还可以传递一个 :domain 密钥并指定 cookie 的域名

# Be sure to restart your server when you modify this file.
Rails.application.config.session_store :cookie_store, key: "_your_app_session", domain: ".example.com"

Rails 在 config/credentials.yml.enc 中设置(对于 CookieStore)用于签名会话数据的密钥。可以使用 bin/rails credentials:edit 更改此密钥。

# aws:
#   access_key_id: 123
#   secret_access_key: 345

# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.
secret_key_base: 492f...

在使用 CookieStore 时更改 secret_key_base 会使所有现有会话失效。

5.1 访问会话

在您的控制器中,您可以通过 session 实例方法访问会话。

会话是延迟加载的。如果您在操作的代码中没有访问会话,它们就不会被加载。因此,您永远不需要禁用会话,只需不访问它们即可。

会话值使用类似于哈希的键值对存储

class ApplicationController < ActionController::Base
  private
    # Finds the User with the ID stored in the session with the key
    # :current_user_id This is a common way to handle user login in
    # a Rails application; logging in sets the session value and
    # logging out removes it.
    def current_user
      @_current_user ||= session[:current_user_id] &&
        User.find_by(id: session[:current_user_id])
    end
end

要在会话中存储内容,只需像哈希一样将其分配给键

class LoginsController < ApplicationController
  # "Create" a login, aka "log the user in"
  def create
    if user = User.authenticate_by(email: params[:email], password: params[:password])
      # Save the user ID in the session so it can be used in
      # subsequent requests
      session[:current_user_id] = user.id
      redirect_to root_url
    end
  end
end

要从会话中删除内容,请删除键值对

class LoginsController < ApplicationController
  # "Delete" a login, aka "log the user out"
  def destroy
    # Remove the user id from the session
    session.delete(:current_user_id)
    # Clear the memoized current user
    @_current_user = nil
    redirect_to root_url, status: :see_other
  end
end

要重置整个会话,请使用 reset_session

5.2 Flash

Flash 是会话的一个特殊部分,它在每次请求时都会被清除。这意味着存储在那里的值只会在下一次请求中可用,这对传递错误消息等很有用。

Flash 通过 flash 方法访问。与会话一样,flash 也表示为哈希。

让我们以注销为例。控制器可以发送一条消息,该消息将在下次请求时显示给用户

class LoginsController < ApplicationController
  def destroy
    session.delete(:current_user_id)
    flash[:notice] = "You have successfully logged out."
    redirect_to root_url, status: :see_other
  end
end

注意,也可以将闪存消息作为重定向的一部分分配。您可以分配 :notice:alert 或通用 :flash

redirect_to root_url, notice: "You have successfully logged out."
redirect_to root_url, alert: "You're stuck here!"
redirect_to root_url, flash: { referral_code: 1234 }

destroy 操作重定向到应用程序的 root_url,消息将在那里显示。注意,完全由下一个操作决定它将对前一个操作放入 flash 中的内容做什么(如果有)。通常的做法是在应用程序的布局中显示 flash 中的任何错误警报或通知

<html>
  <!-- <head/> -->
  <body>
    <% flash.each do |name, msg| -%>
      <%= content_tag :div, msg, class: name %>
    <% end -%>

    <!-- more content -->
  </body>
</html>

这样,如果一个操作设置了通知或警报消息,布局将自动显示它。

您可以传递会话可以存储的任何内容;您不仅限于通知和警报

<% if flash[:just_signed_up] %>
  <p class="welcome">Welcome to our site!</p>
<% end %>

如果您希望 flash 值被传递到另一个请求,请使用 flash.keep

class MainController < ApplicationController
  # Let's say this action corresponds to root_url, but you want
  # all requests here to be redirected to UsersController#index.
  # If an action sets the flash and redirects here, the values
  # would normally be lost when another redirect happens, but you
  # can use 'keep' to make it persist for another request.
  def index
    # Will persist all flash values.
    flash.keep

    # You can also use a key to keep only some kind of value.
    # flash.keep(:notice)
    redirect_to users_url
  end
end

5.2.1 flash.now

默认情况下,将值添加到 flash 会使它们在下一次请求中可用,但有时您可能希望在同一请求中访问这些值。例如,如果 create 操作无法保存资源,并且您直接渲染 new 模板,这不会导致新的请求,但您可能仍然希望使用 flash 显示消息。为此,您可以像使用普通的 flash 一样使用 flash.now

class ClientsController < ApplicationController
  def create
    @client = Client.new(client_params)
    if @client.save
      # ...
    else
      flash.now[:error] = "Could not save client"
      render action: "new"
    end
  end
end

6 Cookies

您的应用程序可以在客户端上存储少量数据 - 称为 cookie - 这些数据将在请求和会话之间持久保存。Rails 通过 cookies 方法提供对 cookie 的轻松访问,该方法类似于 session - 工作方式类似于哈希

class CommentsController < ApplicationController
  def new
    # Auto-fill the commenter's name if it has been stored in a cookie
    @comment = Comment.new(author: cookies[:commenter_name])
  end

  def create
    @comment = Comment.new(comment_params)
    if @comment.save
      flash[:notice] = "Thanks for your comment!"
      if params[:remember_name]
        # Remember the commenter's name.
        cookies[:commenter_name] = @comment.author
      else
        # Delete cookie for the commenter's name cookie, if any.
        cookies.delete(:commenter_name)
      end
      redirect_to @comment.article
    else
      render action: "new"
    end
  end
end

注意,虽然对于会话值,您可以将键设置为 nil,但要删除 cookie 值,您应该使用 cookies.delete(:key)

Rails 还提供了一个签名的 cookie 罐和一个加密的 cookie 罐,用于存储敏感数据。签名的 cookie 罐在 cookie 值上附加一个加密签名,以保护其完整性。加密的 cookie 罐除了签名之外还会对值进行加密,以便最终用户无法读取它们。有关更多详细信息,请参阅 API 文档

这些特殊的 cookie 罐使用序列化器将分配的值序列化为字符串,并在读取时反序列化为 Ruby 对象。您可以通过 config.action_dispatch.cookies_serializer 指定要使用的序列化器。

新应用程序的默认序列化器是 :json。请注意,JSON 对 Ruby 对象的往返支持有限。例如,DateTimeSymbol 对象(包括 Hash 键)将被序列化和反序列化为 String

class CookiesController < ApplicationController
  def set_cookie
    cookies.encrypted[:expiration_date] = Date.tomorrow # => Thu, 20 Mar 2014
    redirect_to action: "read_cookie"
  end

  def read_cookie
    cookies.encrypted[:expiration_date] # => "2014-03-20"
  end
end

如果您需要存储这些对象或更复杂的对象,您可能需要在后续请求中手动转换它们的值。

如果您使用 cookie 会话存储,则以上内容也适用于 sessionflash 哈希。

7 渲染

ActionController 使渲染 HTML、XML 或 JSON 数据变得轻而易举。如果您使用脚手架生成了控制器,它看起来应该像这样

class UsersController < ApplicationController
  def index
    @users = User.all
    respond_to do |format|
      format.html # index.html.erb
      format.xml  { render xml: @users }
      format.json { render json: @users }
    end
  end
end

您可能会注意到上面的代码中我们使用的是 render xml: @users,而不是 render xml: @users.to_xml。如果对象不是字符串,那么 Rails 会自动为我们调用 to_xml

您可以在 布局和渲染指南 中了解更多关于渲染的信息。

8 操作回调

操作回调是在控制器操作“之前”、“之后”或“前后”运行的方法。

操作回调是继承的,因此如果您在 ApplicationController 上设置一个回调,它将在您的应用程序中的每个控制器上运行。

“before” 操作回调通过 before_action 注册。它们可能会停止请求周期。一个常见的“before” 操作回调是要求用户登录才能运行操作。您可以这样定义方法

class ApplicationController < ActionController::Base
  before_action :require_login

  private
    def require_login
      unless logged_in?
        flash[:error] = "You must be logged in to access this section"
        redirect_to new_login_url # halts request cycle
      end
    end
end

该方法只是在 flash 中存储一条错误消息,并在用户未登录时重定向到登录表单。如果“before” 操作回调渲染或重定向,则控制器操作将不会运行。如果安排在该回调之后运行其他操作回调,它们也会被取消。

在这个例子中,操作回调被添加到 ApplicationController,因此应用程序中的所有控制器都继承了它。这将使应用程序中的所有内容都需要用户登录才能使用它。由于显而易见的原因(用户首先无法登录!),并非所有控制器或操作都应该需要这样做。您可以使用 skip_before_action 阻止此操作回调在特定操作之前运行

class LoginsController < ApplicationController
  skip_before_action :require_login, only: [:new, :create]
end

现在,LoginsControllernewcreate 操作将像以前一样工作,而不需要用户登录。:only 选项用于仅针对这些操作跳过此操作回调,还有一个 :except 选项,它的作用相反。这些选项也可以在添加操作回调时使用,这样您就可以在第一时间添加仅针对所选操作运行的回调。

使用不同的选项多次调用同一个操作回调将不起作用,因为最后一个操作回调定义将覆盖之前的定义。

8.1 后操作和前后操作回调

除了“before” 操作回调之外,您还可以控制器操作执行后运行操作回调,或者在操作之前和之后都运行。

“after” 操作回调通过 after_action 注册。它们类似于“before” 操作回调,但由于控制器操作已经运行,因此它们可以访问即将发送到客户端的响应数据。显然,“after” 操作回调无法阻止操作运行。请注意,“after” 操作回调仅在控制器操作成功后执行,但在请求周期中发生异常时不会执行。

“around” 操作回调通过 around_action 注册。它们负责通过 yield 运行它们关联的操作,类似于 Rack 中间件的工作方式。

例如,在更改具有审批工作流的网站中,管理员可以通过在事务中应用更改来轻松预览它们

class ChangesController < ApplicationController
  around_action :wrap_in_transaction, only: :show

  private
    def wrap_in_transaction
      ActiveRecord::Base.transaction do
        begin
          yield
        ensure
          raise ActiveRecord::Rollback
        end
      end
    end
end

注意,“around” 操作回调也包装了渲染。特别是,在上面的例子中,如果视图本身从数据库中读取(例如通过范围),它将在事务中进行,从而呈现要预览的数据。

您可以选择不 yield 并自己构建响应,在这种情况下,控制器操作将不会运行。

8.2 使用操作回调的其他方式

虽然使用动作回调的最常见方法是创建私有方法并使用before_actionafter_actionaround_action来添加它们,但还有两种方法可以做到同样的事情。

第一种方法是直接使用*_action方法中的块。该块将控制器作为参数接收。上面提到的require_login动作回调可以改写为使用块

class ApplicationController < ActionController::Base
  before_action do |controller|
    unless controller.send(:logged_in?)
      flash[:error] = "You must be logged in to access this section"
      redirect_to new_login_url
    end
  end
end

请注意,在这种情况下,动作回调使用send,因为logged_in?方法是私有的,并且动作回调不在控制器的范围内执行。这不是实现此特定动作回调的推荐方法,但在更简单的情况下,它可能有用。

特别是对于around_action,该块也会在action中 yield。

around_action { |_controller, action| time(&action) }

第二种方法是使用类(实际上,任何响应正确方法的对象都可以)来处理回调动作。这在更复杂的情况下很有用,并且无法使用另外两种方法以可读且可重用的方式实现。例如,您可以再次改写登录动作回调以使用类

class ApplicationController < ActionController::Base
  before_action LoginActionCallback
end

class LoginActionCallback
  def self.before(controller)
    unless controller.send(:logged_in?)
      controller.flash[:error] = "You must be logged in to access this section"
      controller.redirect_to controller.new_login_url
    end
  end
end

同样,这也不是此动作回调的理想示例,因为它不在控制器的范围内执行,而是将控制器作为参数传递。该类必须实现一个与动作回调同名的方法,因此对于before_action动作回调,该类必须实现一个before方法,依此类推。around方法必须yield以执行动作。

9 请求伪造保护

跨站点请求伪造是一种攻击,其中一个网站欺骗用户在另一个网站上发出请求,可能在该网站上添加、修改或删除数据,而用户对此并不知情或未经其许可。

避免此问题的首要步骤是确保所有“破坏性”操作(创建、更新和销毁)只能通过非 GET 请求访问。如果您遵循 RESTful 约定,那么您已经在执行此操作。但是,恶意网站仍然可以非常轻松地向您的网站发送非 GET 请求,而这就是请求伪造保护的用武之地。顾名思义,它可以防止伪造的请求。

实现此方法的方式是在每个请求中添加一个不可猜测的令牌,该令牌仅您的服务器知道。这样,如果请求在没有正确令牌的情况下进入,它将被拒绝访问。

如果您像这样生成表单

<%= form_with model: @user do |form| %>
  <%= form.text_field :username %>
  <%= form.text_field :password %>
<% end %>

您将看到令牌是如何作为隐藏字段添加的

<form accept-charset="UTF-8" action="/users/1" method="post">
<input type="hidden"
       value="67250ab105eb5ad10851c00a5621854a23af5489"
       name="authenticity_token"/>
<!-- fields -->
</form>

Rails 将此令牌添加到使用表单助手生成的每个表单中,因此大多数情况下您不必担心。如果您正在手动编写表单或需要出于其他原因添加令牌,则可以通过form_authenticity_token方法获取它。

form_authenticity_token会生成一个有效的身份验证令牌。这在 Rails 未自动添加令牌的地方很有用,例如在自定义 Ajax 调用中。

安全指南中对此进行了更多介绍,以及在开发 Web 应用程序时您应该注意的许多其他安全问题。

10 请求和响应对象

在每个控制器中,有两个访问器方法指向与当前正在执行的请求周期相关的请求和响应对象。request方法包含一个ActionDispatch::Request的实例,而response方法返回一个响应对象,它代表将要发送回客户端的内容。

10.1 request对象

请求对象包含有关来自客户端的请求的大量有用信息。要获取可用方法的完整列表,请参阅Rails API 文档Rack 文档。您可以访问此对象上的属性包括

request的属性 目的
host 用于此请求的主机名。
domain(n=2) 主机名的前n个段,从最右边开始(顶级域名)。
format 客户端请求的 content type。
method 用于请求的 HTTP 方法。
get?post?patch?put?delete?head? 如果 HTTP 方法是 GET/POST/PATCH/PUT/DELETE/HEAD,则返回 true。
headers 返回一个包含与请求相关的标头的哈希。
port 用于请求的端口号(整数)。
protocol 返回一个字符串,包含使用的协议加上“://”,例如“http://”。
query_string URL 的查询字符串部分,即“?”之后的所有内容。
remote_ip 客户端的 IP 地址。
url 用于请求的完整 URL。

10.1.1 path_parametersquery_parametersrequest_parameters

Rails 在params哈希中收集所有与请求一起发送的参数,无论它们是作为查询字符串的一部分发送还是作为 post 主体发送。请求对象具有三个访问器,可以根据参数的来源访问这些参数。query_parameters哈希包含作为查询字符串的一部分发送的参数,而request_parameters哈希包含作为 post 主体发送的参数。path_parameters哈希包含由路由识别为是通往此特定控制器和操作的路径一部分的参数。

10.2 response对象

通常不会直接使用响应对象,而是在执行动作和渲染发送回用户的数据期间构建,但在某些情况下 - 例如在动作后回调中 - 直接访问响应可能很有用。其中一些访问器方法还具有设置器,允许您更改其值。要获取可用方法的完整列表,请参阅Rails API 文档Rack 文档

response的属性 目的
body 这是发送回客户端的数据字符串。这通常是 HTML。
status 响应的 HTTP 状态码,例如 200 表示请求成功,404 表示未找到文件。
location 客户端被重定向到的 URL(如果有)。
content_type 响应的 content type。
charset 用于响应的字符集。默认值为“utf-8”。
headers 用于响应的标头。

10.2.1 设置自定义标头

如果您想为响应设置自定义标头,那么response.headers是设置的地方。标头属性是一个哈希,它将标头名称映射到它们的值,并且 Rails 会自动设置其中一些。如果您想添加或更改标头,只需将其分配给response.headers,如下所示

response.headers["Content-Type"] = "application/pdf"

在上面的情况下,直接使用content_type设置器更有意义。

11 HTTP 身份验证

Rails 带有三种内置的 HTTP 身份验证机制

  • 基本身份验证
  • 摘要身份验证
  • 令牌身份验证

11.1 HTTP 基本身份验证

HTTP 基本身份验证是一种身份验证方案,大多数浏览器和其他 HTTP 客户端都支持它。例如,考虑一个管理部分,该部分只有在浏览器中输入用户名和密码才能访问。使用内置的身份验证只需要您使用一个方法,即http_basic_authenticate_with

class AdminsController < ApplicationController
  http_basic_authenticate_with name: "humbaba", password: "5baa61e4"
end

有了这个,您就可以创建继承自AdminsController的命名空间控制器。因此,动作回调将对这些控制器的所有动作执行,使用 HTTP 基本身份验证对其进行保护。

11.2 HTTP 摘要身份验证

HTTP 摘要身份验证优于基本身份验证,因为它不需要客户端通过网络发送未加密的密码(尽管 HTTP 基本身份验证在 HTTPS 上是安全的)。在 Rails 中使用摘要身份验证只需要使用一个方法,即authenticate_or_request_with_http_digest

class AdminsController < ApplicationController
  USERS = { "lifo" => "world" }

  before_action :authenticate

  private
    def authenticate
      authenticate_or_request_with_http_digest do |username|
        USERS[username]
      end
    end
end

如上面的示例所示,authenticate_or_request_with_http_digest块只接受一个参数 - 用户名。该块返回密码。从authenticate_or_request_with_http_digest返回falsenil会导致身份验证失败。

11.3 HTTP 令牌身份验证

HTTP 令牌身份验证是一种方案,用于在 HTTP Authorization 标头中使用承载令牌。有许多令牌格式可用,描述它们超出了本文档的范围。

例如,假设您想使用预先发行的身份验证令牌来执行身份验证和访问。在 Rails 中实现令牌身份验证只需要使用一个方法,即authenticate_or_request_with_http_token

class PostsController < ApplicationController
  TOKEN = "secret"

  before_action :authenticate

  private
    def authenticate
      authenticate_or_request_with_http_token do |token, options|
        ActiveSupport::SecurityUtils.secure_compare(token, TOKEN)
      end
    end
end

如上面的示例所示,authenticate_or_request_with_http_token块接受两个参数 - 令牌和一个Hash,其中包含从 HTTP Authorization 标头解析的选项。如果身份验证成功,该块应返回true。如果返回falsenil,则会导致身份验证失败。

12 流和文件下载

有时您可能希望向用户发送文件,而不是渲染 HTML 页面。Rails 中的所有控制器都有send_datasend_file方法,它们都将数据流传输到客户端。send_file是一个方便的方法,可以让您提供磁盘上文件的名称,它会为您流传输该文件的内容。

要将数据流传输到客户端,请使用send_data

require "prawn"
class ClientsController < ApplicationController
  # Generates a PDF document with information on the client and
  # returns it. The user will get the PDF as a file download.
  def download_pdf
    client = Client.find(params[:id])
    send_data generate_pdf(client),
              filename: "#{client.name}.pdf",
              type: "application/pdf"
  end

  private
    def generate_pdf(client)
      Prawn::Document.new do
        text client.name, align: :center
        text "Address: #{client.address}"
        text "Email: #{client.email}"
      end.render
    end
end

上面的示例中的download_pdf操作将调用一个私有方法,该方法实际上会生成 PDF 文档并将其作为字符串返回。然后,此字符串将作为文件下载流传输到客户端,并且会向用户建议一个文件名。有时,当将文件流传输到用户时,您可能不希望他们下载该文件。以图像为例,图像可以嵌入 HTML 页面中。要告诉浏览器文件并非要下载,您可以将:disposition选项设置为“inline”。此选项的相反值和默认值为“attachment”。

12.1 发送文件

如果您想发送一个磁盘上已存在的文件,请使用send_file方法。

class ClientsController < ApplicationController
  # Stream a file that has already been generated and stored on disk.
  def download_pdf
    client = Client.find(params[:id])
    send_file("#{Rails.root}/files/clients/#{client.id}.pdf",
              filename: "#{client.name}.pdf",
              type: "application/pdf")
  end
end

这将一次读取和流传输文件 4 kB,避免将整个文件一次性加载到内存中。您可以使用:stream选项关闭流传输,或使用:buffer_size选项调整块大小。

如果未指定:type,则将从:filename中指定的扩展名推断。如果未为扩展名注册内容类型,则将使用application/octet-stream

使用来自客户端(参数、cookie等)的数据在磁盘上定位文件时要小心,因为这是一种安全风险,可能会允许他人访问他们无权访问的文件。

如果您可以在 Web 服务器上的公共文件夹中保留静态文件,则不建议您通过 Rails 流式传输静态文件。让用户使用 Apache 或其他 Web 服务器直接下载文件效率更高,从而避免请求不必要地经过整个 Rails 堆栈。

12.2 RESTful 下载

虽然send_data工作正常,但如果您正在创建 RESTful 应用程序,通常不需要为文件下载创建单独的操作。在 REST 术语中,上面的示例中的 PDF 文件可以被视为客户端资源的另一种表示。Rails 提供了一种巧妙的方法来执行“RESTful”下载。以下是如何重写示例,使 PDF 下载成为show操作的一部分,而无需任何流式传输

class ClientsController < ApplicationController
  # The user can request to receive this resource as HTML or PDF.
  def show
    @client = Client.find(params[:id])

    respond_to do |format|
      format.html
      format.pdf { render pdf: generate_pdf(@client) }
    end
  end
end

您可以对format调用任何方法,该方法是 Rails 注册为 MIME 类型的扩展名。Rails 已经注册了常见的 MIME 类型,如"text/html""application/pdf"

Mime::Type.lookup_by_extension(:pdf)
# => "application/pdf"

如果您需要其他 MIME 类型,请在config/initializers/mime_types.rb文件中调用Mime::Type.register。例如,以下是您如何注册富文本格式 (RTF)

Mime::Type.register("application/rtf", :rtf)

配置文件不会在每次请求时重新加载,因此您必须重启服务器才能使其更改生效。

现在,用户可以通过在 URL 中添加“.pdf”来请求获取客户的 PDF 版本。

GET /clients/1.pdf

12.3 任意数据的实时流式传输

Rails 允许您流式传输的不仅仅是文件。实际上,您可以在响应对象中流式传输任何您想要的内容。ActionController::Live模块允许您与浏览器建立持久连接。使用此模块,您将能够在特定时间点将任意数据发送到浏览器。

12.3.1 整合实时流式传输

在您的控制器类中包含ActionController::Live将为控制器内的所有操作提供流式传输数据的功能。您可以像这样混合模块

class MyController < ActionController::Base
  include ActionController::Live

  def stream
    response.headers["Content-Type"] = "text/event-stream"
    100.times {
      response.stream.write "hello world\n"
      sleep 1
    }
  ensure
    response.stream.close
  end
end

上面的代码将与浏览器保持持久连接,并发送 100 条"hello world\n"消息,每条消息间隔一秒。

上面的示例中需要注意几件事。我们需要确保关闭响应流。忘记关闭流将使套接字永远保持打开状态。我们还必须在写入响应流之前将内容类型设置为text/event-stream。这是因为在响应提交后(当response.committed?返回真值时)无法写入标头,这发生在您writecommit响应流时。

12.3.2 示例用法

假设您正在制作卡拉 OK 机,用户想要获取某首特定歌曲的歌词。每个Song都有特定数量的行,每行需要num_beats时间才能唱完。

如果我们想以卡拉 OK 方式返回歌词(只在歌手唱完前一行时发送该行),那么我们可以使用ActionController::Live,如下所示

class LyricsController < ActionController::Base
  include ActionController::Live

  def show
    response.headers["Content-Type"] = "text/event-stream"
    song = Song.find(params[:id])

    song.each do |line|
      response.stream.write line.lyrics
      sleep line.num_beats
    end
  ensure
    response.stream.close
  end
end

上面的代码只在歌手唱完前一行之后才发送下一行。

12.3.3 流式传输注意事项

流式传输任意数据是一个非常强大的工具。如前面的示例所示,您可以选择何时以及通过响应流发送什么。但是,您还应注意以下事项

  • 每个响应流都会创建一个新线程,并从原始线程复制线程本地变量。线程本地变量过多会对性能产生负面影响。同样,大量线程也会阻碍性能。
  • 未能关闭响应流将使相应的套接字永远保持打开状态。确保在使用响应流时始终调用close
  • WEBrick 服务器会缓冲所有响应,因此包含ActionController::Live将不起作用。您必须使用不会自动缓冲响应的 Web 服务器。

13 日志过滤

Rails 在log文件夹中为每个环境保留一个日志文件。这些在调试应用程序中实际发生的事情时非常有用,但在实时应用程序中,您可能不希望将所有信息都存储在日志文件中。

13.1 参数过滤

您可以通过将敏感请求参数附加到应用程序配置中的config.filter_parameters来将敏感请求参数从日志文件过滤掉。这些参数将在日志中标记为 [FILTERED]。

config.filter_parameters << :password

提供的参数将通过部分匹配正则表达式过滤掉。Rails 在相应的初始化程序(initializers/filter_parameter_logging.rb)中添加了一系列默认过滤器,包括:passw:secret:token,以处理典型的应用程序参数,如passwordpassword_confirmationmy_token

13.2 重定向过滤

有时,您可能希望从日志文件中过滤掉应用程序重定向到的某些敏感位置。您可以使用config.filter_redirect配置选项来实现。

config.filter_redirect << "s3.amazonaws.com"

您可以将其设置为字符串、正则表达式或两者的数组。

config.filter_redirect.concat ["s3.amazonaws.com", /private_path/]

匹配的 URL 将被替换为“'[FILTERED]'”。但是,如果您只想过滤参数,而不是整个 URL,请参阅参数过滤

14 救援

您的应用程序很可能包含错误或引发需要处理的异常。例如,如果用户点击了指向不再存在于数据库中的资源的链接,Active Record 将引发ActiveRecord::RecordNotFound异常。

Rails 默认异常处理为所有异常显示“500 服务器错误”消息。如果请求是在本地进行的,将显示一个很好的回溯和一些附加信息,以便您可以找出问题所在并进行处理。如果请求是远程的,Rails 将只向用户显示一个简单的“500 服务器错误”消息,或者如果出现路由错误或记录无法找到,则显示“404 未找到”消息。有时您可能希望自定义这些错误的捕获方式以及如何向用户显示它们。Rails 应用程序中有多个级别的异常处理可用

14.1 默认的 500 和 404 模板

默认情况下,在生产环境中,应用程序将渲染 404 或 500 错误消息。在开发环境中,所有未处理的异常都只是被引发。这些消息包含在公共文件夹中的静态 HTML 文件中,分别在404.html500.html中。您可以自定义这些文件以添加一些额外的信息和样式,但请记住,它们是静态 HTML;即您不能对它们使用 ERB、SCSS、CoffeeScript 或布局。

14.2 rescue_from

如果您想在捕获错误时执行一些更复杂的操作,可以使用rescue_from,它处理整个控制器及其子类中特定类型(或多种类型)的异常。

当发生被rescue_from指令捕获的异常时,异常对象将被传递给处理程序。处理程序可以是方法或传递给:with选项的Proc对象。您也可以直接使用块,而不是显式的Proc对象。

以下是如何使用rescue_from来拦截所有ActiveRecord::RecordNotFound错误并对它们执行一些操作。

class ApplicationController < ActionController::Base
  rescue_from ActiveRecord::RecordNotFound, with: :record_not_found

  private
    def record_not_found
      render plain: "404 Not Found", status: 404
    end
end

当然,这个例子远非复杂,而且根本没有改进默认的异常处理,但一旦您可以捕获所有这些异常,您就可以随心所欲地处理它们。例如,您可以创建自定义异常类,这些类将在用户无权访问应用程序的某个部分时抛出。

class ApplicationController < ActionController::Base
  rescue_from User::NotAuthorized, with: :user_not_authorized

  private
    def user_not_authorized
      flash[:error] = "You don't have access to this section."
      redirect_back(fallback_location: root_path)
    end
end

class ClientsController < ApplicationController
  # Check that the user has the right authorization to access clients.
  before_action :check_authorization

  # Note how the actions don't have to worry about all the auth stuff.
  def edit
    @client = Client.find(params[:id])
  end

  private
    # If the user is not authorized, just throw the exception.
    def check_authorization
      raise User::NotAuthorized unless current_user.admin?
    end
end

使用rescue_fromExceptionStandardError一起使用会导致严重的不良影响,因为它会阻止 Rails 正确处理异常。因此,不建议这样做,除非有充分的理由。

在生产环境中运行时,所有ActiveRecord::RecordNotFound错误都将渲染 404 错误页面。除非您需要自定义行为,否则您无需处理此问题。

某些异常只能从ApplicationController类中进行救援,因为它们是在控制器初始化之前和操作执行之前引发的。

15 强制 HTTPS 协议

如果您希望确保与控制器的通信只能通过 HTTPS 进行,那么您应该通过在环境配置中启用ActionDispatch::SSL中间件来实现。这可以通过config.force_ssl来实现。

16 内置健康检查端点

Rails 还附带一个内置的健康检查端点,可以通过/up路径访问。如果应用程序已启动且没有任何异常,此端点将返回 200 状态代码,否则将返回 500 状态代码。

在生产环境中,许多应用程序需要将其状态上报到上游,无论是上报给在出现问题时会向工程师发送提醒的正常运行时间监控,还是上报给用于确定 Pod 健康状况的负载均衡器或 Kubernetes 控制器。此健康检查旨在成为适用于多种情况的一劳永逸之选。

虽然任何新生成的 Rails 应用程序都将在/up处进行健康检查,但您可以在“config/routes.rb”中将路径配置为您喜欢的任何内容。

Rails.application.routes.draw do
  get "healthz" => "rails/health#show", as: :rails_health_check
end

现在可以通过/healthz路径访问健康检查。

此端点不反映应用程序所有依赖项的状态,例如数据库或 Redis 集群。如果您有特定于应用程序的需求,请将“rails/health#show”替换为您自己的控制器操作。

仔细考虑您要检查的内容,因为它会导致由于第三方服务出现故障而导致应用程序重新启动的情况。理想情况下,您应该设计应用程序以优雅地处理这些中断。



返回顶部