企业🤖AI Agent构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
# 7.3 注册失败 虽然上一节大概介绍了[图 7.12](#fig-signup-form) 中表单的 HTML 结构(参见[代码清单 7.15](#listing-signup-form-html)),但并没涉及什么细节,其实注册失败时才能更好地理解这个表单的作用。本节,我们会在注册表单中填写一些无效的数据,提交表单后,页面不会转向其他页面,而是返回“注册”页面,显示一些错误消息,如[图 7.14](#fig-signup-failure-mockup) 中的构思图所示。 ![signup failure mockup bootstrap](https://box.kancloud.cn/2016-05-11_5732bd14c7b0a.png)图 7.14:注册失败时显示的页面构思图 ## 7.3.1 可正常使用的表单 回顾一下 [7.1.2 节](#a-users-resource)的内容,在 `routes.rb` 文件中设置 `resources :users` 之后([代码清单 7.3](#listing-users-resource)),Rails 应用就可以响应[表 7.1](#table-restful-users)中符合 REST 架构的 URL 了。其中,发送到 /users 地址上的 `POST` 请求由 `create` 动作处理。在 `create` 动作中,我们可以调用 `User.new` 方法,使用提交的数据创建一个新用户对象,尝试存入数据库,失败后再重新渲染“注册”页面,让访客重新填写注册信息。我们先来看一下生成的 `form` 元素: ``` <form action="/users" class="new_user" id="new_user" method="post"> ``` [7.2.2 节](#signup-form-html)说过,这个表单会向 /users 地址发送 `POST` 请求。 为了让这个表单可用,首先我们要添加[代码清单 7.16](#listing-first-create-action) 中的代码。这段代码再次用到了 `render` 方法,上一次是在局部视图中([5.1.3 节](chapter5.html#partials)),不过如你所见,在控制器的动作中也可以使用 `render` 方法。同时,我们在这段代码中介绍了 `if-else` 分支结构的用法:根据 `@user.save` 的返回值,分别处理用户存储成功和失败两种情况([6.1.3 节](chapter6.html#creating-user-objects)介绍过,存储成功时返回值为 `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 节](#strong-parameters)实现。 我们要实际操作一下,提交一些无效的注册数据,这样才能更好地理解[代码清单 7.16](#listing-first-create-action) 中代码的作用,结果如[图 7.15](#fig-signup-failure) 所示,底部完整的调试信息如[图 7.16](#fig-signup-failure-rails-debug) 所示。([图 7.15](#fig-signup-failure) 中还显示了 Web 控制台,这是个 Rails 控制台,只不过显示在浏览器中,用来协助调试。我们可以在其中查看用户模型,不过这里我们想审查 `params`,可是在 Web 控制台中无法获取。) ![signup failure 3rd edition](https://box.kancloud.cn/2016-05-11_5732bd14dc4c7.png)图 7.15:注册失败![signup failure debug 3rd edition](https://box.kancloud.cn/2016-05-11_5732bd15054b5.png)图 7.16:注册失败时显示的调试信息 下面我们来分析一下调试信息中请求参数哈希的 `user` 部分([图 7.16](#fig-signup-failure-rails-debug)),以便深入理解 Rails 处理表单的过程: ``` "user" => { "name" => "Foo Bar", "email" => "foo@invalid", "password" => "[FILTERED]", "password_confirmation" => "[FILTERED]" } ``` 这个哈希是 `params` 的一部分,会传给用户控制器。[7.1.2 节](#a-users-resource)说过,`params` 哈希中包含每次请求的信息,例如向 /users/1 发送请求时,`params[:id]` 的值是用户的 ID,即 1。提交表单发送 `POST` 请求时,`params` 是一个嵌套哈希。嵌套哈希在 [4.3.3 节](chapter4.html#hashes-and-symbols)中使用控制台介绍 `params` 时用过。上面的调试信息说明,提交表单后,Rails 会构建一个名为 `user` 的哈希,哈希中的键是 `input` 标签的 `name` 属性值([代码清单 7.13](#listing-signup-form)),键对应的值是用户在字段中填写的内容。例如: ``` <input id="user_email" name="user[email]" type="email" /> ``` `name` 属性的值是 `user[email]`,表示 `user` 哈希中的 `email` 元素。 虽然调试信息中的键是字符串形式,不过却以符号形式传给用户控制器。`params[:user]` 这个嵌套哈希实际上就是 `User.new` 方法创建用户所需的参数。我们在 [4.4.5 节](chapter4.html#a-user-class)介绍过 `User.new` 的用法,[代码清单 7.16](#listing-first-create-action) 也用到了。也就是说,如下代码: ``` @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](#fig-signup-failure) 和[图 7.16](#fig-signup-failure-rails-debug) 所示),增强了安全。 ## 7.3.2 健壮参数 我们在 [4.4.5 节](chapter4.html#a-user-class)提到过“批量赋值”——使用一个哈希初始化 Ruby 变量,如下所示: ``` @user = User.new(params[:user]) # 不是最终的实现方法 ``` 上述代码中的注释[代码清单 7.16](#listing-first-create-action) 中也有,说明这不是最终的实现方式。因为初始化整个 `params` 哈希十分危险,会把用户提交的所有数据传给 `User.new` 方法。假设除了前述的属性,用户模型中还有一个 `admin` 属性,用来标识网站的管理员。(我们会在 [9.4.1 节](chapter9.html#administrative-users)加入这个属性。)如果想把这个属性设为 `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](#fn-9)]把这个方法的作用域设为“私有”,如[代码清单 7.17](#listing-create-action-strong-parameters) 所示。(我们会在 [8.4 节](chapter8.html#remember-me)详细介绍 `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](#fig-invalid-submission-no-feedback),提交无效数据后,(除了只在开发环境中显示的调试信息之外)表单没有显示任何反馈信息,容易让人误解。而且也没真正创建一个新用户。第一个问题在 [7.3.3 节](#signup-error-messages)解决,第二个问题在 [7.4 节](#successful-signups)解决。 ![invalid submission no feedback](https://box.kancloud.cn/2016-05-11_5732bd15257db.png)图 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 节](chapter6.html#validating-presence)简介过)。 和上面的控制台会话类似,在[代码清单 7.16](#listing-first-create-action) 中,保存失败时也会生成一组和 `@user` 对象相关的错误消息。如果想在浏览器中显示这些错误消息,我们要在 `new` 视图中渲染一个错误消息局部视图,并把表单中每个输入框的 CSS 类设为 `form-control`(在 Bootstrap 中有特殊意义),如[代码清单 7.18](#listing-f-error-messages) 所示。注意,这个错误消息局部视图只是临时的,最终版会在 [11.3.2 节](chapter11.html#creating-microposts)实现。 ##### 代码清单 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 节](chapter9.html#edit-form)),则把它存放在专门的 `shared/` 文件夹中。所以我们要使用 `mkdir`([表 1.1](chapter1.html#table-unix-commands))新建 `app/views/shared` 文件夹: ``` $ mkdir app/views/shared ``` 然后像之前一样,在文本编辑器中新建局部视图 `_error_messages.html.erb` 文件。这个局部视图的内容如[代码清单 7.19](#listing-errors-partial) 所示。 ##### 代码清单 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 节](chapter4.html#objects-and-message-passing),用在字符串上;从上面的代码可以看出,`empty?` 也可用在 Rails 错误对象上,如果错误对象为空返回 `true`,否则返回 `false`。`any?` 方法就是取反 `empty?` 的返回值,如果对象中有内容就返回 `true`,没内容则返回 `false`。(顺便说一下,`count`、`empty?` 和 `any?` 都可以用在 Ruby 数组上,[11.2 节](chapter11.html#showing-microposts)会好好利用这三个方法。) 还有一个比较新的方法是 `pluralize`,在控制台中默认不可用,不过我们可以引入 `ActionView::Helpers::TextHelper` 模块,加载这个方法:[[10](#fn-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](#listing-errors-partial) 还添加了一个 CSS ID,`error_explanation`,可用来样式化错误消息。([5.1.2 节](chapter5.html#bootstrap-and-custom-css)介绍过,CSS 中以 `#` 开头的规则是用来给 ID 添加样式的。)出错时,Rails 还会自动把有错误的字段包含在一个 CSS 类为 `field_with_errors` 的 `div` 元素中。我们可以利用这些 ID 和类为错误消息添加样式,所需的 SCSS 如[代码清单 7.20](#listing-error-messages-css) 所示。在这段代码中,使用 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](#listing-f-error-messages) 和[代码清单 7.19](#listing-errors-partial) 中的代码,以及[代码清单 7.20](#listing-error-messages-css) 中的 SCSS 之后,提交无效的注册信息后,会显示一些有用的错误消息,如[图 7.18](#fig-signup-error-messages) 所示。因为错误消息是由模型验证生成的,所以如果以后修改了验证规则,例如电子邮件地址的格式,或者密码的最短长度,错误消息会自动变化。(注意,因为我们添加了存在性验证,而且 `has_secure_password` 方法会验证是否有密码(密码是否为 `nil`),所以,如果用户没有输入密码,目前会出现重复的错误消息。我们可以直接处理错误消息,去掉重复的消息,不过,[9.1.4 节](chapter9.html#successful-edits)添加 `allow_nil: true` 之后,会自动解决这个问题。) ![signup error messages 3rd edition](https://box.kancloud.cn/2016-05-11_5732bd1540109.png)图 7.18:注册失败后显示的错误消息 ## 7.3.4 注册失败的测试 在没有完全支持测试的强大 Web 框架出现以前,开发者不得不自己动手测试表单。例如,为了测试注册页面,我们要在浏览器中访问这个页面,然后分别提交无效和有效的注册信息,检查各种情况下应用的表现是否正常。而且,每次修改应用后都要重复这个痛苦又容易出错的过程。 幸好,使用 Rails 可以编写测试,自动测试表单。这一节,我们要编写测试,确认在表单中提交无效的数据时表现正确。[7.4.4 节](#a-test-for-valid-submission)会编写提交有效数据时的测试。 首先,我们要为用户注册功能生成一个集成测试文件,这个文件名为 `users_signup`(沿用使用复数命名资源名的约定): ``` $ rails generate integration_test users_signup invoke test_unit create test/integration/users_signup_test.rb ``` ([7.4.4 节](#a-test-for-valid-submission)测试注册成功时也使用这个文件。) 测试的主要目的是,确认点击注册按钮提交无效数据后,不会创建新用户。(对错误消息的测试留作[7.7 节](#sign-up-exercises)。)方法是检测用户的数量。测试会使用每个 Active Record 类(包括 `User` 类)都能使用的 `count` 方法: ``` $ rails console >> User.count => 0 ``` 现在 `User.count` 的返回值是 `0`,因为我们在 [7.2 节](#signup-form)开头还原了数据库。和 [5.3.4 节](chapter5.html#layout-link-tests)一样,我们要使用 `assert_select` 测试相应页面中的 HTML 元素。注意,只能测试以后基本不会修改的元素。 首先,我们使用 `get` 方法访问注册页面: ``` get signup_path ``` 为了测试表单提交后的状态,我们要向 `users_path` 发起 `POST` 请求([表 7.1](#table-restful-users))。这个操作可以使用 `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](#listing-signup-flash))。我们把 `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](#listing-a-test-for-invalid-submission) 所示。在测试中,我们还调用了 `assert_template` 方法,检查提交失败后是否会重新渲染 `new` 动作。检查错误消息的测试留作练习,参见 [7.7 节](#sign-up-exercises)。 ##### 代码清单 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 ```