7.3 注册失败
虽然上一节大概介绍了图 7.12 中表单的 HTML 结构(参见代码清单 7.15),但并没涉及什么细节,其实注册失败时才能更好地理解这个表单的作用。本节,我们会在注册表单中填写一些无效的数据,提交表单后,页面不会转向其他页面,而是返回“注册”页面,显示一些错误消息,如图 7.14 中的构思图所示。
图 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 控制台中无法获取。)
图 7.15:注册失败图 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
元素,而且只允许传入 name
、email
、password
和 password_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 节解决。
图 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
,否则返回 false
。any?
方法就是取反 empty?
的返回值,如果对象中有内容就返回 true
,没内容则返回 false
。(顺便说一下,count
、empty?
和 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_errors
的 div
元素中。我们可以利用这些 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
之后,会自动解决这个问题。)
图 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.new
的 params[: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