7.3 注册失败

虽然上一节大概介绍了图 7.12 中表单的 HTML 结构(参见代码清单 7.15),但并没涉及什么细节,其实注册失败时才能更好地理解这个表单的作用。本节,我们会在注册表单中填写一些无效的数据,提交表单后,页面不会转向其他页面,而是返回“注册”页面,显示一些错误消息,如图 7.14 中的构思图所示。

signup failure mockup bootstrap图 7.14:注册失败时显示的页面构思图

7.3.1 可正常使用的表单

回顾一下 7.1.2 节的内容,在 routes.rb 文件中设置 resources :users 之后(代码清单 7.3),Rails 应用就可以响应表 7.1中符合 REST 架构的 URL 了。其中,发送到 /users 地址上的 POST 请求由 create 动作处理。在 create 动作中,我们可以调用 User.new 方法,使用提交的数据创建一个新用户对象,尝试存入数据库,失败后再重新渲染“注册”页面,让访客重新填写注册信息。我们先来看一下生成的 form 元素:

<form action="/users" class="new_user" id="new_user" method="post">

7.2.2 节说过,这个表单会向 /users 地址发送 POST 请求。

为了让这个表单可用,首先我们要添加代码清单 7.16 中的代码。这段代码再次用到了 render 方法,上一次是在局部视图中(5.1.3 节),不过如你所见,在控制器的动作中也可以使用 render 方法。同时,我们在这段代码中介绍了 if-else 分支结构的用法:根据 @user.save 的返回值,分别处理用户存储成功和失败两种情况(6.1.3 节介绍过,存储成功时返回值为 true,失败时返回值为 false)。

代码清单 7.16:能处理注册失败的 create 动作

app/controllers/users_controller.rb

class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
 @user = User.new(params[:user])    # 不是最终的实现方式    if @user.save
      # 处理注册成功的情况
    else
      render 'new'
    end
  end
end

留意上述代码中的注释——这不是最终的实现方式,但现在完全够用。最终版会在 7.3.2 节实现。

我们要实际操作一下,提交一些无效的注册数据,这样才能更好地理解代码清单 7.16 中代码的作用,结果如图 7.15 所示,底部完整的调试信息如图 7.16 所示。(图 7.15 中还显示了 Web 控制台,这是个 Rails 控制台,只不过显示在浏览器中,用来协助调试。我们可以在其中查看用户模型,不过这里我们想审查 params,可是在 Web 控制台中无法获取。)

signup failure 3rd edition图 7.15:注册失败signup failure debug 3rd edition图 7.16:注册失败时显示的调试信息

下面我们来分析一下调试信息中请求参数哈希的 user 部分(图 7.16),以便深入理解 Rails 处理表单的过程:

"user" => { "name" => "Foo Bar",
            "email" => "foo@invalid",
            "password" => "[FILTERED]",
            "password_confirmation" => "[FILTERED]"
          }

这个哈希是 params 的一部分,会传给用户控制器。7.1.2 节说过,params 哈希中包含每次请求的信息,例如向 /users/1 发送请求时,params[:id] 的值是用户的 ID,即 1。提交表单发送 POST 请求时,params 是一个嵌套哈希。嵌套哈希在 4.3.3 节中使用控制台介绍 params 时用过。上面的调试信息说明,提交表单后,Rails 会构建一个名为 user 的哈希,哈希中的键是 input 标签的 name 属性值(代码清单 7.13),键对应的值是用户在字段中填写的内容。例如:

<input id="user_email" name="user[email]" type="email" />

name 属性的值是 user[email],表示 user 哈希中的 email 元素。

虽然调试信息中的键是字符串形式,不过却以符号形式传给用户控制器。params[:user] 这个嵌套哈希实际上就是 User.new 方法创建用户所需的参数。我们在 4.4.5 节介绍过 User.new 的用法,代码清单 7.16 也用到了。也就是说,如下代码:

@user = User.new(params[:user])

基本上等同于

@user = User.new(name: "Foo Bar", email: "foo@invalid",
                 password: "foo", password_confirmation: "bar")

在旧版 Rails 中,使用

@user = User.new(params[:user])

就行了,但默认情况下这种用法并不安全,需要谨慎处理,避免恶意用户篡改应用的数据库。在 Rails 4.0 之后的版本中,这行代码会抛出异常(如图 7.15图 7.16 所示),增强了安全。

7.3.2 健壮参数

我们在 4.4.5 节提到过“批量赋值”——使用一个哈希初始化 Ruby 变量,如下所示:

@user = User.new(params[:user])    # 不是最终的实现方法

上述代码中的注释代码清单 7.16 中也有,说明这不是最终的实现方式。因为初始化整个 params 哈希十分危险,会把用户提交的所有数据传给 User.new 方法。假设除了前述的属性,用户模型中还有一个 admin 属性,用来标识网站的管理员。(我们会在 9.4.1 节加入这个属性。)如果想把这个属性设为 true,要在 params[:user] 中包含 admin='1'。这个操作可以使用 curl 等命令行 HTTP 客户端轻易实现。如果把整个 params 哈希传给 User.new,那么网站中的任何用户都可以在请求中包含 admin='1' 来获取管理员权限。

旧版 Rails 使用模型中的 attr_accessible 方法解决这个问题,在一些早期的 Rails 应用中可能还会看到这种用法。但是,从 Rails 4.0 起,推荐在控制器层使用一种叫做“健壮参数”(strong parameter)的技术。这个技术可以指定需要哪些请求参数,以及允许传入哪些请求参数。而且,如果按照上面的方式传入整个 params 哈希,应用会抛出异常。所以,现在默认情况下,Rails 应用已经堵住了批量赋值漏洞。

本例,我们需要 params 哈希包含 :user 元素,而且只允许传入 nameemailpasswordpassword_confirmation 属性。我们可以使用下面的代码实现:

params.require(:user).permit(:name, :email, :password, :password_confirmation)

这行代码会返回一个 params 哈希,只包含允许使用的属性。而且,如果没有指定 :user 元素还会抛出异常。

为了使用方便,可以定义一个名为 user_params 的方法,换掉 params[:user],返回初始化所需的哈希:

@user = User.new(user_params)

user_params 方法只会在用户控制器内部使用,不需要开放给外部用户,所以我们可以使用 Ruby 中的 private 关键字[9]把这个方法的作用域设为“私有”,如代码清单 7.17 所示。(我们会在 8.4 节详细介绍 private。)

代码清单 7.17:在 create 动作中使用健壮参数

app/controller/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def create
 @user = User.new(user_params)    if @user.save
      # 处理注册成功的情况
    else
      render 'new'
    end
  end

  private

    def user_params
 params.require(:user).permit(:name, :email, :password, :password_confirmation)    end
end

顺便说一下,private 后面的 user_params 方法多了一层缩进,目的是为了从视觉上容易辨认哪些是私有方法。(经验证明,这么做很明智。如果一个类中有很多方法,容易不小心把方法定义为“私有”,在相应的对象上无法调用时会觉得非常奇怪。)

现在,注册表单可以使用了,至少提交后不会显示错误了。但是,如图 7.17,提交无效数据后,(除了只在开发环境中显示的调试信息之外)表单没有显示任何反馈信息,容易让人误解。而且也没真正创建一个新用户。第一个问题在 7.3.3 节解决,第二个问题在 7.4 节解决。

invalid submission no feedback图 7.17:提交无效信息后显示的注册表单

7.3.3 注册失败错误消息

处理注册失败的最后一步,要加入有用的错误消息,说明注册失败的原因。默认情况下,Rails 基于用户模型的验证,提供了这种消息。假设我们使用无效的电子邮件地址和长度较短的密码创建用户:

$ rails console
>> user = User.new(name: "Foo Bar", email: "foo@invalid",
?>                 password: "dude", password_confirmation: "dude")
>> user.save
=> false
>> user.errors.full_messages
=> ["Email is invalid", "Password is too short (minimum is 6 characters)"]

如上所示,errors.full_message 对象是一个由错误消息组成的数组(6.2.2 节简介过)。

和上面的控制台会话类似,在代码清单 7.16 中,保存失败时也会生成一组和 @user 对象相关的错误消息。如果想在浏览器中显示这些错误消息,我们要在 new 视图中渲染一个错误消息局部视图,并把表单中每个输入框的 CSS 类设为 form-control(在 Bootstrap 中有特殊意义),如代码清单 7.18 所示。注意,这个错误消息局部视图只是临时的,最终版会在 11.3.2 节实现。

代码清单 7.18:在注册表单中显示错误消息

app/views/users/new.html.erb

<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>

<div class="row">
 <div class="col-md-6 col-md-offset-3">
  <%= form_for(@user) do |f| %>
<%= render 'shared/error_messages' %> 
  <%= f.label :name %>
<%= f.text_field :name, class: 'form-control' %> 
  <%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %> 
  <%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %> 
  <%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %> 
  <%= f.submit "Create my account", class: "btn btn-primary" %>
  <% end %>
 </div>
</div>

注意,在上面的代码中,渲染的局部视图名为 shared/error_messages,这里用到了 Rails 的一个约定:如果局部视图要在多个控制器中使用(9.1.1 节),则把它存放在专门的 shared/ 文件夹中。所以我们要使用 mkdir表 1.1)新建 app/views/shared 文件夹:

$ mkdir app/views/shared

然后像之前一样,在文本编辑器中新建局部视图 _error_messages.html.erb 文件。这个局部视图的内容如代码清单 7.19 所示。

代码清单 7.19:显示表单错误消息的局部视图

app/views/shared/_error_messages.html.erb

<% if @user.errors.any? %>
 <div id="error_explanation">
 <div class="alert alert-danger">
 The form contains <%= pluralize(@user.errors.count, "error") %>.
 </div>
 <ul>
  <% @user.errors.full_messages.each do |msg| %>
 <li><%= msg %></li>
  <% end %>
 </ul>
 </div>
<% end %>

这个局部视图的代码使用了几个之前没用过的 Rails/Ruby 结构,还有 Rails 错误对象上的两个新方法。第一个新方法是 count,它的返回值是错误的数量:

>> user.errors.count
=> 2

第二个新方法是 any?,它和 empty? 的作用相反:

>> user.errors.empty?
=> false
>> user.errors.any?
=> true

第一次使用 empty? 方法是在 4.2.3 节,用在字符串上;从上面的代码可以看出,empty? 也可用在 Rails 错误对象上,如果错误对象为空返回 true,否则返回 falseany? 方法就是取反 empty? 的返回值,如果对象中有内容就返回 true,没内容则返回 false。(顺便说一下,countempty?any? 都可以用在 Ruby 数组上,11.2 节会好好利用这三个方法。)

还有一个比较新的方法是 pluralize,在控制台中默认不可用,不过我们可以引入 ActionView::Helpers::TextHelper 模块,加载这个方法:[10]

>> include ActionView::Helpers::TextHelper
>> pluralize(1, "error")
=> "1 error"
>> pluralize(5, "error")
=> "5 errors"

如上所示,pluralize 方法的第一个参数是整数,返回值是这个数字和第二个参数组合在一起后,正确的单复数形式。pluralize 方法由功能强大的“转置器”(inflector)实现,转置器知道怎么处理大多数单词的单复数变换,甚至很多不规则的变换方式:

>> pluralize(2, "woman")
=> "2 women"
>> pluralize(3, "erratum")
=> "3 errata"

所以,使用 pluralize 方法后,如下的代码:

<%= pluralize(@user.errors.count, "error") %>

返回值是 "0 errors""1 error""2 errors" 等,单复数形式取决于错误的数量。这样可以避免出现类似 "1 errors" 这种低级的错误(这是网络中常见的错误之一)。

注意,代码清单 7.19 还添加了一个 CSS ID,error_explanation,可用来样式化错误消息。(5.1.2 节介绍过,CSS 中以 # 开头的规则是用来给 ID 添加样式的。)出错时,Rails 还会自动把有错误的字段包含在一个 CSS 类为 field_with_errorsdiv 元素中。我们可以利用这些 ID 和类为错误消息添加样式,所需的 SCSS 如代码清单 7.20 所示。在这段代码中,使用 Sass 的 @extend 函数引入了 Bootstrap 中的 has-error 类。

代码清单 7.20:错误消息的样式

app/assets/stylesheets/custom.css.scss

.
.
.
/* forms */
.
.
.
#error_explanation {
  color: red;
  ul {
    color: red;
    margin: 0 0 30px 0;
  }
}

.field_with_errors {
  @extend .has-error;
  .form-control {
    color: $state-danger-text;
  }
}

添加代码清单 7.18代码清单 7.19 中的代码,以及代码清单 7.20 中的 SCSS 之后,提交无效的注册信息后,会显示一些有用的错误消息,如图 7.18 所示。因为错误消息是由模型验证生成的,所以如果以后修改了验证规则,例如电子邮件地址的格式,或者密码的最短长度,错误消息会自动变化。(注意,因为我们添加了存在性验证,而且 has_secure_password 方法会验证是否有密码(密码是否为 nil),所以,如果用户没有输入密码,目前会出现重复的错误消息。我们可以直接处理错误消息,去掉重复的消息,不过,9.1.4 节添加 allow_nil: true 之后,会自动解决这个问题。)

signup error messages 3rd edition图 7.18:注册失败后显示的错误消息

7.3.4 注册失败的测试

在没有完全支持测试的强大 Web 框架出现以前,开发者不得不自己动手测试表单。例如,为了测试注册页面,我们要在浏览器中访问这个页面,然后分别提交无效和有效的注册信息,检查各种情况下应用的表现是否正常。而且,每次修改应用后都要重复这个痛苦又容易出错的过程。

幸好,使用 Rails 可以编写测试,自动测试表单。这一节,我们要编写测试,确认在表单中提交无效的数据时表现正确。7.4.4 节会编写提交有效数据时的测试。

首先,我们要为用户注册功能生成一个集成测试文件,这个文件名为 users_signup(沿用使用复数命名资源名的约定):

$ rails generate integration_test users_signup
      invoke  test_unit
      create    test/integration/users_signup_test.rb

7.4.4 节测试注册成功时也使用这个文件。)

测试的主要目的是,确认点击注册按钮提交无效数据后,不会创建新用户。(对错误消息的测试留作7.7 节。)方法是检测用户的数量。测试会使用每个 Active Record 类(包括 User 类)都能使用的 count 方法:

$ rails console
>> User.count
=> 0

现在 User.count 的返回值是 0,因为我们在 7.2 节开头还原了数据库。和 5.3.4 节一样,我们要使用 assert_select 测试相应页面中的 HTML 元素。注意,只能测试以后基本不会修改的元素。

首先,我们使用 get 方法访问注册页面:

get signup_path

为了测试表单提交后的状态,我们要向 users_path 发起 POST 请求(表 7.1)。这个操作可以使用 post 方法完成:

assert_no_difference 'User.count' do
  post users_path, user: { name:  "",
                           email: "user@invalid",
                           password:              "foo",
                           password_confirmation: "bar" }
end

这里用到了 create 动作中传给 User.newparams[:user] 哈希(代码清单 7.24)。我们把 post 方法放在 assert_no_difference 方法的块中,并把 assert_no_difference 方法的参数设为字符串 'User.count'。执行这段代码时,会比较块中的代码执行前后 User.count 的值。这段代码相当于先记录用户数量,然后在 post 请求中发送数据,再确认用户的数量没变,如下所示:

before_count = User.count
post users_path, ...
after_count  = User.count
assert_equal before_count, after_count

虽然这两种方式的作用相同,但使用 assert_no_difference 更简洁,而且更符合 Ruby 的习惯用法。

把上述代码放在一起,写出的测试如代码清单 7.21 所示。在测试中,我们还调用了 assert_template 方法,检查提交失败后是否会重新渲染 new 动作。检查错误消息的测试留作练习,参见 7.7 节

代码清单 7.21:注册失败的测试 GREEN

test/integration/users_signup_test.rb

require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest

  test "invalid signup information" do
    get signup_path
    assert_no_difference 'User.count' do
      post users_path, user: { name:  "",
                               email: "user@invalid",
                               password:              "foo",
                               password_confirmation: "bar" }
    end
    assert_template 'users/new'
  end
end

因为在编写集成测试之前已经写好了应用代码,所以测试组件应该能通过:

代码清单 7.22:GREEN
$ bundle exec rake test