在线版的内容可能落后于电子书,如果想及时获得更新,请购买电子书

第 7 章 注册

User 模型可以使用了,接下来要实现大多数网站都离不开的功能——注册。在 7.2 节我们将创建一个 HTML 表单,提交用户注册时填写的信息,然后在 7.4 节使用提交的数据创建新用户,把属性的值存入数据库。注册功能实现后,还将创建一个用户资料页面,显示用户的个人信息——这是实现 REST 架构(2.2.2 节)用户资源的第一步。在实现这些功能的过程中,我们会在 5.3.4 节的基础上编写简练生动的集成测试。

本章要依赖第 6 章User 模型编写的验证,尽量保证新用户的电子邮件地址有效。第 11 章会在用户注册过程中添加账户激活功能,确保电子邮件地址确实可用。

本书内容力求通俗易懂,但是要兼顾专业性,毕竟 Web 开发是个复杂的话题。本章难度明显增加了,建议你多花点时间,认真理解。(有些读者反馈说,读两遍能加深理解。)

7.1 显示用户的信息

profile mockup profile name bootstrap
图 7.1:本节实现的用户资料页面构思图

本节要实现的用户资料页面是完整页面的一小部分,只显示用户的名字和头像,构思图如图 7.1 所示。[1]最终完成的用户资料页面会显示用户的头像、基本信息和微博列表,构思图如图 7.2 所示。[2](在图 7.2 中,我们第一次用到了“lorem ipsum”占位文字,这些文字背后的故事很有意思,有空的话你可以了解一下。)这个资料页面将和整个演示应用一起在第 14 章完成。

如果你一直坚持使用版本控制,现在像之前一样,新建一个主题分支:

$ git checkout -b sign-up
profile mockup bootstrap
图 7.2:最终实现的用户资料页面构思图

7.1.1 调试信息和 Rails 环境

本节要实现的用户资料页面是我们这个应用中第一个真正意义上的动态页面。虽然视图的代码不会动态改变,不过每个用户资料页面显示的内容却是从数据库中读取的。添加动态页面之前,最好做些准备工作,现在我们能做的就是在网站布局中加入一些调试信息,如代码清单 7.1 所示。这段代码使用 Rails 内置的 debug 方法和 params 变量(7.1.2 节将详细介绍),在各个页面显示一些对开发有帮助的信息。

代码清单 7.1:在网站布局中添加一些调试信息
app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  .
  .
  .
  <body>
    <%= render 'layouts/header' %>
    <div class="container">
      <%= yield %>
      <%= render 'layouts/footer' %>
      <%= debug(params) if Rails.env.development? %>
    </div>
  </body>
</html>

因为我们不想在线上网站中向用户显示调试信息,所以上述代码使用 if Rails.env.development? 限制只在开发环境中显示调试信息。开发环境是 Rails 默认支持的三个环境之一(旁注 7.1)。[3]Rails.env.development? 的返回值只在开发环境中为 true,所以下面这行嵌入式 Ruby 代码

<%= debug(params) if Rails.env.development? %>

不会在生产环境和测试环境中执行。(在测试环境中显示调试信息虽然没有坏处,但也没什么好处,所以最好只在开发环境中显示。)

为了让调试信息看起来漂亮一些,我们在第 5 章创建的自定义样式表中加入一些样式规则,如代码清单 7.2 所示。

代码清单 7.2:添加美化调试信息的样式,用到一个 Sass 混入
app/assets/stylesheets/custom.scss
@import "bootstrap-sprockets";
@import "bootstrap";

/* mixins, variables, etc. */

$gray-medium-light: #eaeaea;

@mixin box_sizing {
  -moz-box-sizing:    border-box;
  -webkit-box-sizing: border-box;
  box-sizing:         border-box;
}
.
.
.
/* miscellaneous */

.debug_dump {
  clear: both;
  float: left;
  width: 100%;
  margin-top: 45px;
  @include box_sizing;
}

这段代码用到了 Sass 的混入(mixin)功能,创建的这个混入名为 box_sizing。混入的作用是打包一系列样式规则,供多次使用。预处理器会把

.debug_dump {
  .
  .
  .
  @include box_sizing;
}

转换成

.debug_dump {
  .
  .
  .
  -moz-box-sizing:    border-box;
  -webkit-box-sizing: border-box;
  box-sizing:         border-box;
}

7.2.1 节会再次用到这个混入。美化后的调试信息如图 7.3 所示。[4]

图 7.3 中的调试信息显示了当前页面的一些有用信息:

---
controller: static_pages
action: home

这是 params 变量的 YAML [5]形式(与散列类似),显示当前页面的控制器名和动作名。7.1.2 节会介绍其他调试信息的意思。

练习
  1. 在浏览器中访问 /about,通过调试信息确定 params 散列中的控制器和动作。

  2. 在 Rails 控制台中获取数据库中的第一个用户,把它赋值给 user 变量。puts user.attributes.to_yaml 的输出是什么?y user.attributes 的输出呢?

home page with debug 3rd edition
图 7.3:显示有调试信息的演示应用首页

7.1.2 Users 资源

为了实现用户资料页面,数据库中要有用户记录,这引出了“先有鸡还是先有蛋”的问题:网站还没有注册页面,怎么可能有用户呢?其实这个问题在 6.3.4 节已经解决了,那时我们自己动手在 Rails 控制台中创建了一个用户,所以数据库中应该有一条用户记录:

$ rails console
>> User.count
=> 1
>> User.first
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com",
created_at: "2016-05-23 20:36:46", updated_at: "2016-05-23 20:36:46",
password_digest: "$2a$10$xxucoRlMp06RLJSfWpZ8hO8Dt9AZXlGRi3usP3njQg3...">

(如果你的数据库中现在没有用户记录,回到 6.3.4 节,在继续阅读之前先完成那里的操作。)从控制台的输出可以看出,这个用户的 ID 是 1,我们现在的目标就是创建一个页面,显示这个用户的信息。我们会遵从 Rails 使用的 REST 架构(旁注 2.2),把数据视为资源,可以创建、显示、更新和删除。这四个操作分别对应 HTTP 标准中的 POSTGETPATCHDELETE 请求方法(旁注 3.2)。

按照 REST 架构的规则,资源一般由资源名加唯一标识符表示。我们把用户看做一个资源,若想查看 ID 为 1 的用户,要向 /users/1 发送 GET 请求。这里没必要指明用哪个动作,Rails 的 REST 功能解析时,会自动把这个 GET 请求交给 show 动作处理。

2.2.1 节介绍过,ID 为 1 的用户对应的 URL 是 /users/1,不过现在访问这个 URL 的话,会显示错误信息,如图 7.4 中的服务器日志所示。

profile routing error 4th edition
图 7.4:访问 /users/1 时服务器日志中显示的错误

我们只需在路由文件 config/routes.rb 中添加如下的一行代码就可以正常访问 /users/1 了:

resources :users

修改后的路由文件如代码清单 7.3 所示。

代码清单 7.3:在路由文件中添加 Users 资源的规则
config/routes.rb
Rails.application.routes.draw do
  root 'static_pages#home'
  get  '/help',    to: 'static_pages#help'
  get  '/about',   to: 'static_pages#about'
  get  '/contact', to: 'static_pages#contact'
  get  '/signup',  to: 'users#new'
  resources :users
end

我们的目的只是为了显示用户资料页面,可是 resources :users 不仅让 /users/1 可以访问,而且还为演示应用中的 Users 资源提供了符合 REST 架构的所有动作,[6]以及用来获取相应 URL 的具名路由(5.3.3 节)。最终得到的 URL、动作和具名路由的对应关系如表 7.1 所示(与表 2.2 对比一下)。接下来的三章会介绍表 7.1 中的所有动作,并不断完善,把用户打造成完全符合 REST 架构的资源。

表 7.1代码清单 7.3 中添加的 Users 资源规则实现的 REST 式路由
HTTP 请求 URL 动作 具名路由 作用

GET

/users

index

users_path

显示所有用户的页面

GET

/users/1

show

user_path(user)

显示单个用户的页面

GET

/users/new

new

new_user_path

创建(注册)新用户的页面

POST

/users

create

users_path

创建新用户

GET

/users/1/edit

edit

edit_user_path(user)

编辑 ID 为 1 的用户页面

PATCH

/users/1

update

user_path(user)

更新用户信息

DELETE

/users/1

destroy

user_path(user)

删除用户

添加代码清单 7.3 中的代码之后,路由就生效了,但是页面还不存在(图 7.5)。下面我们在页面中添加一些简单的内容,7.1.4 节再添加更多内容。

user show unknown action 3rd edition
图 7.5:/users/1 的路由生效了,但页面不存在

用户资料页面的视图保存在标准的位置,即 app/views/users/show.html.erb。这个视图和自动生成的 new.html.erb代码清单 5.38)不同,现在不存在,要手动创建,[7]然后写入代码清单 7.4 中的代码。

代码清单 7.4:用户资料页面的临时视图
app/views/users/show.html.erb
<%= @user.name %>, <%= @user.email %>

在这段代码中,我们假设存在一个 @user 变量,使用 ERb 代码显示这个用户的名字和电子邮件地址。这和最终实现的视图有点不一样,届时不会公开显示用户的电子邮件地址。

我们要在 Users 控制器的 show 动作中定义 @user 变量,这样用户资料页面才能正常渲染。你可能猜到了,我们要在 User 模型上调用 find 方法(6.1.4 节),从数据库中检索用户,如代码清单 7.5 所示。

代码清单 7.5:含有 show 动作的 Users 控制器
app/controllers/users_controller.rb
class UsersController < ApplicationController

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

  def new
  end
end

在这段代码中,我们使用 params 获取用户的 ID。当我们向 Users 控制器发送请求时,params[:id] 会返回用户的 ID,即 1,所以这就和 6.1.4 节中直接调用 User.find(1) 的效果一样。(严格来说,params[:id] 返回的是字符串 "1",不过 find 方法会自动将其转换成整数。)

定义视图和动作之后,/users/1 就可以正常访问了,如图 7.6 所示。(如果添加 bcrypt 之后没重启过 Rails 服务器,现在或许要重启。这也体现了“技术是复杂的”。)留意一下调试信息,它证实了 params[:id] 的值与前面分析的一样:

---
action: show
controller: users
id: '1'

所以,代码清单 7.5 中的 User.find(params[:id]) 才会取回 ID 为 1 的用户。

user show 3rd edition
图 7.6:添加 show 动作后的用户资料页面
练习
  1. 使用嵌入式 Ruby,在代码清单 7.4 中添加 created_atupdated_at 两个属性。

  2. 使用嵌入式 Ruby,在用户资料页面添加 Time.now。刷新浏览器后会发生什么?

7.1.3 调试器

7.1.2 节中我们看到,调试信息能帮助我们理解应用的运作方式。不过,使用 byebug gem(代码清单 3.2)可以更直接地获取调试信息。我们把 debugger 方法加到应用中,看一下这个 gem 的作用,如代码清单 7.6 所示。

代码清单 7.6:在 Users 控制器中添加 debugger 方法
app/controllers/users_controller.rb
class UsersController < ApplicationController

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

  def new
  end
end

现在访问 /users/1 时,Rails 服务器的输出中会显示 byebug 提示符:

(byebug)

我们可以把它当成 Rails 控制台,在其中执行命令,查看应用的状态:

(byebug) @user.name
"Example User"
(byebug) @user.email
"example@railstutorial.org"
(byebug) params[:id]
"1"

若想退出 byebug,继续执行应用,可以按 Ctrl-D 键。把 show 动作中的 debugger 方法删除,如代码清单 7.7 所示。

代码清单 7.7:删除 debugger 方法后的 Users 控制器
app/controllers/users_controller.rb
class UsersController < ApplicationController

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

  def new
  end
end

只要你觉得 Rails 应用中哪部分有问题,就可以在可能导致问题的代码附近加上 debugger 方法。byebug gem 很强大,可以查看系统的状态,排查应用错误,还能交互式调试应用。

练习
  1. 代码清单 7.6 那样在 show 动作中添加 debugger 方法,然后访问 /users/1。使用 puts 方法显示 params 散列的 YAML 形式。提示:参考 7.1.1 节节的练习。与网站模板中的 debug 方法相比,这样做显示的调试信息有什么不同?

  2. debugger 方法添加到 new 动作中,然后访问 /users/new。@user 的值什么?

7.1.4 Gravatar 头像和侧边栏

前面创建了一个略显简陋的用户资料页面,这一节要再添加一些内容:用户头像和侧边栏。首先,我们要在用户资料页面添加一个全球通用识别头像,或者叫 Gravatar[8]这是一项免费服务,让用户上传图像,将其关联到自己的电子邮件地址上。使用 Gravatar 可以简化在网站中添加用户头像的过程,开发者不必分心去处理图像上传、剪裁和存储,只要使用用户的电子邮件地址构成头像的 URL 地址,用户的头像便能显示出来。(13.4 节将说明如何处理图像上传。)

我们的计划是定义一个名为 gravatar_for 的辅助方法,返回指定用户的 Gravatar 头像,如代码清单 7.8 所示。

代码清单 7.8:显示用户名字和 Gravatar 头像的用户资料页面视图
app/views/users/show.html.erb
<% provide(:title, @user.name) %>
<h1>
  <%= gravatar_for @user %>
  <%= @user.name %>
</h1>

默认情况下,所有辅助方法文件中定义的方法都自动在任意视图中可用,不过为了便于管理,我们会把 gravatar_for 方法放在 Users 控制器对应的辅助方法文件中。根据 Gravatar 的文档,头像的 URL 地址中要使用用户电子邮件地址的 MD5 哈希值。在 Ruby 中,MD5 哈希算法由 Digest 库中的 hexdigest 方法实现:

>> email = "MHARTL@example.COM"
>> Digest::MD5::hexdigest(email.downcase)
=> "1fda4469bcbec3badf5418269ffc5968"

电子邮件地址不区分大小写,但是 MD5 哈希算法区分,所以我们要先调用 downcase 方法把电子邮件地址转换成小写形式,然后再传给 hexdigest 方法。(在代码清单 6.32 中的回调里我们已经把电子邮件地址转换成小写形式了,但这里最好也转换,以防电子邮件地址来自其他地方。)我们定义的 gravatar_for 辅助方法如代码清单 7.9 所示。

代码清单 7.9:定义 gravatar_for 辅助方法
app/helpers/users_helper.rb
module UsersHelper

  # 返回指定用户的 Gravatar
  def gravatar_for(user)
    gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
    gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}"
    image_tag(gravatar_url, alt: user.name, class: "gravatar")
  end
end

gravatar_for 方法的返回值是一个 img 标签,用于显示 Gravatar 头像。img 标签的 CSS 类为 gravataralt 属性的值是用户的名字(对视觉障碍人士使用的屏幕阅读器特别有用)。

用户资料页面如图 7.7 所示,页面中显示的头像是 Gravatar 的默认图像,因为 user@example.com 不是真的电子邮件地址(访问这个网站你便会发现,example.com 这个域名是专门用来举例的)。

我们调用 update_attributes 方法(6.1.5 节)更新一下数据库中的用户记录,然后就可以显示用户真正的头像了:

$ rails console
>> user = User.first
>> user.update_attributes(name: "Example User",
?>                        email: "example@railstutorial.org",
?>                        password: "foobar",
?>                        password_confirmation: "foobar")
=> true

我们把用户的电子邮件地址改成 example@railstutorial.org。我已经把这个地址的头像设为了本书网站的徽标,修改后的结果如图 7.8 所示。

我们还要添加一个侧边栏,这才能完成图 7.1 中的构思图。我们将使用 aside 标签定义侧边栏。aside 中的内容一般是对主体内容的补充(例如侧边栏),不过也可以自成一体。我们要把 aside 标签的类设为 row col-md-4,这两个类都是 Bootstrap 提供的。在用户资料页面中添加侧边栏所需的代码如代码清单 7.10 所示。

代码清单 7.10:在 show 视图中添加侧边栏
app/views/users/show.html.erb
<% provide(:title, @user.name) %>
<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      <h1>
        <%= gravatar_for @user %>
        <%= @user.name %>
      </h1>
    </section>
  </aside>
</div>
profile with gravatar 3rd edition
图 7.7:显示 Gravatar 默认头像的用户资料页面
profile custom gravatar 3rd edition
图 7.8:显示真实头像的用户资料页面

添加 HTML 结构和 CSS 类之后,我们再用 SCSS 为资料页面(包括侧边栏和 Gravatar 头像)定义一些样式,如代码清单 7.11 所示。[9](注意:因为 Asset Pipeline 使用 Sass 预处理器,所以样式中才可以使用嵌套。)实现的效果如图 7.9 所示。

代码清单 7.11:用户资料页面的样式,包括侧边栏的样式
app/assets/stylesheets/custom.scss
.
.
.
/* sidebar */

aside {
  section.user_info {
    margin-top: 20px;
  }
  section {
    padding: 10px 0;
    margin-top: 20px;
    &:first-child {
      border: 0;
      padding-top: 0;
    }
    span {
      display: block;
      margin-bottom: 3px;
      line-height: 1;
    }
    h1 {
      font-size: 1.4em;
      text-align: left;
      letter-spacing: -1px;
      margin-bottom: 3px;
      margin-top: 0px;
    }
  }
}

.gravatar {
  float: left;
  margin-right: 10px;
}

.gravatar_edit {
  margin-top: 15px;
}
user show sidebar css 3rd edition
图 7.9:添加侧边栏和 CSS 后的用户资料页面
练习
  1. 如果你还没为自己的电子邮件地址关联 Gravatar,现在关联一个。你的头像的 MD5 是多少?

  2. 确认代码清单 7.12 中定义的 gravatar_for 辅助方法能接受可选的 size 参数,在视图中可以像这样调用:gravatar_for user, size: 50。(10.3.1 节会使用这个改进的辅助方法。)

  3. 前一题中的 options 散列仍然经常使用,但是从 Ruby 2.0 开始,可以换成关键字参数(keyword argument)。确认代码清单 7.13 中的代码可以代替代码清单 7.12。二者有什么区别?

代码清单 7.12:为 gravatar_for 辅助方法添加一个可选的散列参数
app/helpers/users_helper.rb
module UsersHelper

  # 返回指定用户的 Gravatar
  def gravatar_for(user, options = { size: 80 })
    gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
    size = options[:size]
    gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
    image_tag(gravatar_url, alt: user.name, class: "gravatar")
  end
end
代码清单 7.13:在 gravatar_for 辅助方法中使用关键字参数
app/helpers/users_helper.rb
module UsersHelper

  # 返回指定用户的 Gravatar
  def gravatar_for(user, size: 80)
    gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
    gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
    image_tag(gravatar_url, alt: user.name, class: "gravatar")
  end
end

7.2 注册表单

用户资料页面已经可以访问了,但内容还不完整。下面我们要为网站创建一个注册表单。如图 5.11图 7.10 再次展示)所示,注册页面还没有什么内容,无法注册新用户。本节将实现如图 7.11 所示的注册表单,添加注册功能。

new signup page 3rd edition
图 7.10:注册页面(/signup)现在的样子
signup mockup bootstrap
图 7.11:用户注册页面的构思图

7.2.1 使用 form_for

注册页面的核心是一个表单,用于提交注册相关的信息(名字、电子邮件地址、密码和密码确认)。在 Rails 中,创建表单可以使用 form_for 辅助方法,传入 Active Record 对象后,使用该对象的属性构建一个表单。

回顾一下:注册页面的地址是 /signup,由 Users 控制器的 new 动作处理(代码清单 5.43)。首先,我们要创建传给 form_for 的用户对象,然后赋值给 @user 变量,如代码清单 7.14 所示。

代码清单 7.14:在 new 动作中添加 @user 变量
app/controllers/users_controller.rb
class UsersController < ApplicationController

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

  def new
    @user = User.new
  end
end

表单的代码参见代码清单 7.157.2.2 节会详细分析这个表单,现在我们先添加一些 SCSS,如代码清单 7.16 所示。(注意,这里重用了代码清单 7.2 中的 box_sizing 混入。)添加样式后的注册页面如图 7.12 所示。

代码清单 7.15:用户注册表单
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| %>
      <%= f.label :name %>
      <%= f.text_field :name %>

      <%= f.label :email %>
      <%= f.email_field :email %>

      <%= f.label :password %>
      <%= f.password_field :password %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation %>

      <%= f.submit "Create my account", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>
代码清单 7.16:注册表单的样式
app/assets/stylesheets/custom.scss
.
.
.
/* forms */

input, textarea, select, .uneditable-input {
  border: 1px solid #bbb;
  width: 100%;
  margin-bottom: 15px;
  @include box_sizing;
}

input {
  height: auto !important;
}
signup form 3rd edition
图 7.12:用户注册表单
练习
  1. 代码清单 7.15 中的 :name 换成 :nome,会看到什么错误消息?

  2. f 换成 foobar,确认块变量的名称对结果没有影响。想想为什么使用 foobar 不好。

7.2.2 注册表单的 HTML

为了更好地理解代码清单 7.15 中定义的表单,我们将其分成几段来看。先看外层结构——开头的 form_for 方法和结尾的 end

<%= form_for(@user) do |f| %>
  .
  .
  .
<% end %>

这段代码中有关键字 do,说明 form_for 方法可以接受一个块,而且有一个块变量 f(代表“form”)。

我们一般无需了解 Rails 辅助方法的内部实现,但是对 form_for 来说,我们要知道 f 对象的作用是什么:在这个对象上调用表单字段(例如,文本字段、单选按钮或密码字段)对应的方法,生成的字段元素可以用来设定 @user 对象的属性。也就是说:

<%= f.label :name %>
<%= f.text_field :name %>

生成的 HTML 是一个有标注(label)的文本字段,用于设定 User 模型的 name 属性。

在浏览器中按住 Ctrl 键再点击鼠标,然后选择“审查元素”,查看页面的源码,如代码清单 7.17 所示。下面花点儿时间介绍一下表单的结构。

代码清单 7.17图 7.12 中表单的源码
<form accept-charset="UTF-8" action="/users" class="new_user"
      id="new_user" method="post">
  <input name="utf8" type="hidden" value="&#x2713;" />
  <input name="authenticity_token" type="hidden"
         value="NNb6+J/j46LcrgYUC60wQ2titMuJQ5lLqyAbnbAUkdo=" />
  <label for="user_name">Name</label>
  <input id="user_name" name="user[name]" type="text" />

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

  <label for="user_password">Password</label>
  <input id="user_password" name="user[password]"
         type="password" />

  <label for="user_password_confirmation">Confirmation</label>
  <input id="user_password_confirmation"
         name="user[password_confirmation]" type="password" />

  <input class="btn btn-primary" name="commit" type="submit"
         value="Create my account" />
</form>

先看表单里的结构。比较一下代码清单 7.15代码清单 7.17,我们发现,下面的 ERb 代码

<%= f.label :name %>
<%= f.text_field :name %>

生成的 HTML 是

<label for="user_name">Name</label>
<input id="user_name" name="user[name]" type="text" />

下面的 ERb 代码

<%= f.label :email %>
<%= f.email_field :email %>

生成的 HTML 是

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

而下面的 ERb 代码

<%= f.label :password %>
<%= f.password_field :password %>

生成的 HTML 是

<label for="user_password">Password</label>
<input id="user_password" name="user[password]" type="password" />

图 7.13 所示,文本字段和电子邮件地址字段(type="text"type="email")会直接显示填写的内容,而密码字段(type="password")基于安全考虑会遮盖输入的内容。(把电子邮件地址字段的类型设为 type="email" 有个好处,有些系统会以不同的方式处理这种文本字段,例如某些移动设备会显示专门用于输入电子邮件地址的键盘。)

filled in form bootstrap 3rd edition
图 7.13:在表单的文本字段和密码字段中填写内容

7.4 节会说明,之所以能创建用户,全靠 input 元素的 name 属性:

<input id="user_name" name="user[name]" - - - />
.
.
.
<input id="user_password" name="user[password]" - - - />

7.3 节会介绍,Rails 会以这些 name 属性的值为键,用户输入的内容为值,构成一个名为 params 的散列,用来创建用户。

另外一个重要的标签是 form 自身。Rails 使用 @user 对象创建这个 form 元素,因为每个 Ruby 对象都知道它所属的类(4.4.1 节),所以 Rails 知道 @user 所属的类是 User,而且,@user 是一个新用户,因此 Rails 知道要使用 post 方法——这正是创建新对象所需的 HTTP 请求(参见旁注 3.2):

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

这里的 classid 属性并不重要,重要的是 action="/users"method="post"。设定这两个属性后,Rails 会向 /users 发送 POST 请求。接下来的两节会介绍这个请求的效果。

你可能还会注意到,form 标签中有下面这段代码:

<input name="utf8" type="hidden" value="&#x2713;" />
<input name="authenticity_token" type="hidden"
       value="NNb6+J/j46LcrgYUC60wQ2titMuJQ5lLqyAbnbAUkdo=" />

这段代码不会在浏览器中显示,只在 Rails 内部有用,所以你并不需要知道它的作用。简单来说,这段代码首先使用 Unicode 字符 &#x2713;(对号 ✓)强制浏览器使用正确的字符编码提交数据;后面是一个真伪令牌(authenticity token),Rails 用它抵御跨站请求伪造(Cross-Site Request Forgery,简称 CSRF)攻击。[10]

7.3 注册失败

虽然前一节大概介绍了图 7.12 中表单的 HTML 结构(参见代码清单 7.17),但并没涉及什么细节,其实注册失败时才能更好地理解这个表单的作用。本节,我们会在注册表单中填写一些无效的数据,然后提交表单,此时页面不会转向其他页面,而是重新渲染注册页面,并且列出错误消息,如图 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.18 中的代码。这段代码再次用到了 render 方法,上一次是在局部视图中(5.1.3 节),不过如你所见,在控制器的动作中也可以使用 render 方法。同时,我们在这段代码中介绍了 if-else 分支结构的用法:根据 @user.save 的返回值,分别处理用户存储成功和失败两种情况(6.1.3 节说过,存储成功时返回值为 true,失败时返回值为 false)。

代码清单 7.18:能处理注册失败的 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.18 中代码的作用,结果如图 7.15 所示,底部完整的调试信息如图 7.16 所示。

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

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

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

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

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

其中,name 属性的值是 user[email],对应于 user 散列中的 email 键。

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

@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.18 中也有,说明这不是最终的实现方式。因为初始化整个 params 散列十分危险,会把用户提交的所有数据传给 User.new 方法。假设除了这几个属性,User 模型中还有一个 admin 属性,用于标识网站的管理员。(我们将在 10.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 方法只会在 Users 控制器内部使用,不需要开放给外部用户,所以我们可以使用 Ruby 中的 private 关键字[11]把这个方法的作用域设为“私有”,如代码清单 7.19 所示。(我们会在 9.1 节进一步说明 private。)

代码清单 7.19:在 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 方法多了一层缩进,目的是为了从视觉上易于辨认哪些是私有方法。(经验证明,这么做很明智。如果一个类中有很多方法,容易不小心把方法定义为私有的,在相应的对象上无法调用时你会觉得莫名其妙。)

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

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

练习
  1. 访问 /signup?admin=1,确认调试信息中显示的 params 中有 admin 属性。

7.3.3 注册失败错误消息

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

$ 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.18 中,保存失败时也会生成一组和 @user 对象相关的错误消息。如果想在浏览器中显示这些错误消息,我们要在 new 视图中渲染一个错误消息局部视图,并把表单中每个输入框的 CSS 类设为 form-control(在 Bootstrap 中有特殊意义),如代码清单 7.20 所示。注意,这个错误消息局部视图只是临时的,最终版在 13.3.2 节实现。

代码清单 7.20:在注册表单中显示错误消息
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 的一个约定:如果局部视图要在多个控制器中使用(10.1.1 节),则把它存放在专门的 shared/ 目录中。所以我们要使用 mkdir表 1.1)新建 app/views/shared 目录:

$ mkdir app/views/shared

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

代码清单 7.21:显示表单错误消息的局部视图
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 数组上,13.2 节会好好利用这三个方法。)

还有一个比较新的方法是 pluralize,在控制台中可以通过 helper 对象调用:

>> helper.pluralize(1, "error")
=> "1 error"
>> helper.pluralize(5, "error")
=> "5 errors"

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

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

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

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

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

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

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

signup error messages 3rd edition
图 7.18:注册失败后显示的错误消息
练习
  1. 把密码的最小长度改为 5,确认错误消息会自动更新。

  2. 注册表单提交之前(图 7.12)的 URL 和提交之后(图 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 节测试注册成功时也使用这个文件。)

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

$ rails console
>> User.count
=> 1

现在 User.count 的返回值是 1,因为我们在 6.3.4 节创建了一个用户。不过,如果你在阅读的过程中添加或删除了用户,看到的数量可能有所不同。与 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, params: { user: { name:  "",
                                     email: "user@invalid",
                                     password:              "foo",
                                     password_confirmation: "bar" } }
end

这里用到了 create 动作中传给 User.newparams[:user] 散列(代码清单 7.29)。(在 Rails 5 之前的版本中,params 隐式传入,而且只会传入 user 散列。Rails 5.0 废弃了这种做法,因此现在建议使用完整的 params 散列。)

我们把 post 方法放在 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 的习惯用法。

注意,从技术层面来讲,getpost 之间没有关系,向 users_path 发送 POST 请求之前没必要先向 signup_path 发送 GET 请求。不过我喜欢这么做,因为这样能明确表述概念,而且可以再次确认渲染注册表单时没有错误。

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

代码清单 7.23:注册失败的测试 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,  params: { user: { name:  "",
                                  email: "user@invalid",
                                  password:              "foo",
                                  password_confirmation: "bar" } }
    end
    assert_template 'users/new'
  end
end

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

代码清单 7.24GREEN
$ rails test
练习
  1. 编写测试检查代码清单 7.20 中实现的错误消息。测试具体怎么写由你自己决定,可以参照代码清单 7.25

  2. 注册表单提交之前的 URL 是 /signup,提交之后的 URL 是 /users,二者不一样的原因是我们为前者定制了具名路由(代码清单 5.43),而后者使用的是默认的 REST 式路由(代码清单 7.3)。添加代码清单 7.26代码清单 7.27 中的代码,去掉二者之间的差异。然后在表单中提交,确认提交前后的 URL 都是 /signup。测试还能通过吗?为什么?

  3. 更新代码清单 7.25 中的 post 那一行,使用前一题中的新 URL。确认测试仍能通过。

  4. 代码清单 7.27 改回原样(代码清单 7.20),确认测试仍能通过。这是个问题,因为改回去之后提交 URL 不对。在代码清单 7.25 中添加一个 assert_select 断言,捕获这个问题。现在测试是失败的,把注册表单再改成代码清单 7.27 那样,让测试通过。提示:提交表单之前测试有没有 'form[action="/signup"]'

代码清单 7.25:错误消息测试的模板
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,  params: { user: { name:  "",
                                  email: "user@invalid",
                                  password:              "foo",
                                  password_confirmation: "bar" } }
    end
    assert_template 'users/new'
    assert_select 'div#<CSS id for error explanation>'
    assert_select 'div.<CSS class for field with error>'
  end
  .
  .
  .
end
代码清单 7.26:添加响应 POST 请求的 signup 路由
config/routes.rb
Rails.application.routes.draw do
  root 'static_pages#home'
  get  '/help',    to: 'static_pages#help'
  get  '/about',   to: 'static_pages#about'
  get  '/contact', to: 'static_pages#contact'
  get  '/signup',  to: 'users#new'
  post '/signup',  to: 'users#create'
  resources :users
end
代码清单 7.27:把表单提交给 /signup
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, url: signup_path) 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>

7.4 注册成功

处理完提交无效数据的情况,本节我们要完成注册表单的功能,如果提交的数据有效,把用户存入数据库。我们先尝试保存用户,如果保存成功,用户的数据会自动存入数据库,然后在浏览器中重定向,转向新注册用户的资料页面,页面中还会显示一个欢迎消息,构思图如图 7.19 所示。如果保存用户失败了,就交由上一节实现的功能处理。

signup success mockup bootstrap
图 7.19:注册成功后显示的页面构思图

7.4.1 完整的注册表单

为了完成注册表单的功能,我们要把代码清单 7.19 中的注释换成适当的代码。现在,提交有效数据时也不能正确处理,页面会停在那里,如图 7.20 中提交按钮的颜色所示,因为 Rails 动作的默认行为是渲染对应的视图,而 create 动作不对应视图(图 7.21)。

valid submission error 4th ed
图 7.20:提交有效的注册信息后页面不动了
no create template error
图 7.21:服务器日中显示没有找到 create 模板

create 动作是可以有视图,但是通常是在成功创建资源后重定向到其他页面。这里,我们按照习惯,重定向到新注册用户的资料页面,不过转到根地址也行。为此,在应用代码中要使用 redirect_to 方法,如代码清单 7.28 所示。

代码清单 7.28create 动作的代码,处理保存和重定向操作
app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to @user
    else
      render 'new'
    end
  end

  private

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

注意,我们写的是:

redirect_to @user

不过,也可以写成:

redirect_to user_url(@user)

Rails 看到 redirect_to @user 后,知道我们是想重定向到 user_url(@user)

练习
  1. 在 Rails 控制台中确认,提交有效信息后的确创建了用户。

  2. 修改代码清单 7.28,确认 redirect_to user_url(@user) 的作用与 redirect_to @user 相同。

7.4.2 闪现消息

有了代码清单 7.28 中的代码后,注册表单已经可以使用了。不过在提交有效数据注册之前,我们要添加 Web 应用中经常使用的一个增强功能:访问随后的页面时显示一个消息(这里,我们要显示一个欢迎新用户的消息),如果再访问其他页面,或者刷新页面,这个消息则消失。

在 Rails 中,短暂显示一个消息使用闪现消息(flash)实现。按照 Rails 的约定,操作成功时使用 :success 键表示,如代码清单 7.29 所示。

代码清单 7.29:用户注册成功后显示一个闪现消息
app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  private

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

把一个消息赋值给 flash 之后,我们就可以在重定向后的第一个页面中将其显示出来了。我们要遍历 flash,在网站布局中显示所有相关的消息。你可能还记得 4.3.3 节在控制台中遍历散列那个例子,当时我故意把变量命名为 flash

代码清单 7.30:在控制台中迭代 flash 散列
$ rails console
>> flash = { success: "It worked!", danger: "It failed." }
=> {:success=>"It worked!", danger: "It failed."}
>> flash.each do |key, value|
?>   puts "#{key}"
?>   puts "#{value}"
>> end
success
It worked!
danger
It failed.

按照上述方式,我们可以使用如下的代码在网站的全部页面中显示闪现消息的内容:

<% flash.each do |message_type, message| %>
  <div class="alert alert-<%= message_type %>"><%= message %></div>
<% end %>

(这段代码很乱,混用了 HTML 和 ERb,不易阅读。后面的练习中有一题会要求你把它改得好看一些。)

其中,下述 ERb 代码为各种类型的消息指定一个 CSS 类。

alert-<%= message_type %>

因此,:success 消息的类是 alert-success。(:success 是个符号,ERb 会自动把它转换成字符串 "success",然后再插入模板。)

为不同类型的消息指定不同的 CSS 类,可以为不同类型的消息指定不同的样式。例如,8.1.4 节会使用 flash[:danger] 显示登录失败消息。[12](其实,在代码清单 7.21 中为错误消息区域指定样式时,已经用过 alert-danger。)Bootstrap 提供的 CSS 支持四种闪现消息样式,分别为 successinfowarningdanger,在开发这个演示应用的过程中,我们会找机会全部使用一遍(11.2 节使用 info11.3 节使用 warning8.1.4 节使用 danger)。

消息也会在模板中显示,如下的代码:

flash[:success] = "Welcome to the Sample App!"

得到的完整 HTML 是:

<div class="alert alert-success">Welcome to the Sample App!</div>

把前面的 ERb 代码放入网站的布局中,得到的布局如代码清单 7.31 所示。

代码清单 7.31:在网站的布局中添加 flash 变量的内容
app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  .
  .
  .
  <body>
    <%= render 'layouts/header' %>
    <div class="container">
      <% flash.each do |message_type, message| %>
        <div class="alert alert-<%= message_type %>"><%= message %></div>
      <% end %>
      <%= yield %>
      <%= render 'layouts/footer' %>
      <%= debug(params) if Rails.env.development? %>
    </div>
    .
    .
    .
  </body>
</html>
练习
  1. 在控制台中确认可以直接在字符串中插值原始的符号。例如,"#{:success}" 的返回值是什么?

  2. 前一题与代码清单 7.30 中迭代闪现消息的代码有什么关系?

7.4.3 首次注册

现在我们可以注册一个用户,看看到目前为止所实现的功能。虽然前面提交表单后页面不动了(图 7.20),但是 Users 控制器中的 user.save 却执行了,因此可能会创建用户。为了删除那个用户,我们执行下述命令,还原数据库:

$ rails db:migrate:reset

在某些系统中可能要重启 Web 服务器,这样改动才能生效。

接下来,我们要创建第一个用户。用户的名字使用“Rails Tutorial”,电子邮件地址使用“example@railstutorial.org”,如图 7.22 所示。注册成功后,页面中显示了一个友好的欢迎消息,如图 7.23 所示。消息的样式由 5.1.2 节集成的 Bootstrap 框架提供的 .success 类实现。刷新用户资料页面后,闪现消息会消失,如图 7.24 所示。

练习
  1. 打开 Rails 控制台,通过电子邮件地址查找用户,确保真地创建了新用户。看到的结果应该与代码清单 7.32 类似。

  2. 使用你的电子邮件地址创建一个用户。确认正确显示了你的 Gravatar 头像。

代码清单 7.32:在数据库中查找我们刚刚创建的用户
$ rails console
>> User.find_by(email: "example@railstutorial.org")
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.
org", created_at: "2016-05-31 17:17:33", updated_at: "2016-05-31 17:17:33",
password_digest: "$2a$10$8MaeHdnOhZvMk3GmFdmpPOeG6a7u7/k2Z9TMjOanC9G...">
first signup
图 7.22:填写信息,注册首个用户
signup flash 3rd edition
图 7.23:注册成功后显示有闪现消息的页面
signup flash reloaded 3rd edition
图 7.24:刷新页面后资料页面中的闪现消息不见了

7.4.4 注册成功的测试

在继续之前,我们要编写测试,确认提交有效数据后应用的表现正常,并捕获可能出现的回归。与 7.3.4 节中注册失败的测试一样,我们主要检查数据库中的内容。这一次,我们要提交有效的数据,确认创建了一个用户。类似代码清单 7.23 中使用的

assert_no_difference 'User.count' do
  post users_path, ...
end

这里我们要使用对应的 assert_difference 方法:

assert_difference 'User.count', 1 do
  post users_path, ...
end

assert_no_difference 一样,assert_difference 的第一个参数是字符串 'User.count',目的是比较块中的代码执行前后 User.count 的变化。第二个参数可选,指定变化的数量(这里是 1)。

assert_difference 加入代码清单 7.23 对应的文件后,得到的测试如代码清单 7.33 所示。注意,向 users_path 发送 POST 请求之后,我们使用 follow_redirect! 方法跟踪重定向,渲染 users/show 模板。(最好为闪现消息编写一个测试,这个留作练习。)

代码清单 7.33:注册成功的测试 GREEN
test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "valid signup information" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!
    assert_template 'users/show'
  end
end

注意,这个测试还确认了成功注册后会渲染 show 视图。如果想让测试通过,Users 资源的路由(代码清单 7.3)、Users 控制器中的 show 动作(代码清单 7.5)和 show.html.erb 视图(代码清单 7.8)都得能正常使用才行。所以,

assert_template 'users/show'

这一行代码就能测试用户资料页面几乎所有的相关功能。这种对应用中重要功能的端到端覆盖展示了集成测试的重大作用。

练习
  1. 编写测试检查 7.4.2 节实现的闪现消息。测试具体怎么写由你自己决定,可以参照代码清单 7.34,把 FILL_IN 换成适当的代码。(即便不测试闪现消息的内容,只测试有正确的键也很脆弱,所以我倾向于只测试闪现消息不为空。)

  2. 前面说过,代码清单 7.31 中闪现消息的 HTML 有点乱。换用代码清单 7.35 中较为整洁的代码,运行测试组件,确认使用 content_tag 辅助方法也行。

  3. 代码清单 7.28 中重定向那行注释掉,确认测试会失败。

  4. 假如我们把代码清单 7.28 中的 @user.save 改成 false,对测试中的 assert_difference 块有什么影响?

代码清单 7.34:闪现消息测试的模板
test/integration/users_signup_test.rb
require 'test_helper'
  .
  .
  .
  test "valid signup information" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!
    assert_template 'users/show'
    assert_not flash.FILL_IN
  end
end
代码清单 7.35:使用 content_tag 编写网站布局中的闪现消息
app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
      .
      .
      .
      <% flash.each do |message_type, message| %>
        <%= content_tag(:div, message, class: "alert alert-#{message_type}") %>
      <% end %>
      .
      .
      .
</html>

7.5 专业部署方案

现在注册页面可以使用了,该把应用部署到生产环境了。虽然我们从第 3 章就开始部署了,但现在应用才真正有点用,所以借此机会我们要把部署过程变得更专业一些。具体而言,我们要在生产环境的应用中添加一个重要功能,保障注册过程的安全性,还要把默认的 Web 服务器换成一个更适合在真实环境中使用的服务器。

为了部署,现在你应该把改动合并到 master 分支中:

$ git add -A
$ git commit -m "Finish user signup"
$ git checkout master
$ git merge sign-up

7.5.1 在生产环境中使用 SSL

在本章开发的注册表单中提交数据注册用户时,用户的名字、电子邮件地址和密码会在网络中传输,因此可能在途中被恶意用户拦截。这是应用的重大潜在安全隐患,解决的方法是使用安全套接层(Secure Sockets Layer,简称 SSL),[13]在数据离开浏览器之前加密相关信息。我们可以只在注册页面启用 SSL,不过整站启用也容易实现。整站都启用 SSL 后,第 8 章实现的用户登录功能也能从中受益,而且还能防范 9.1 节讨论的会话劫持(session hijacking)。

虽然 Heroku 默认使用 SSL,但是并不强制浏览器使用,因此仍能通过常规的 HTTP 协议访问应用,导致与应用的交互处在不安全的环境中。(如果不信,可以把地址栏中的 https 改成 http 试试。)幸好,强制浏览器使用 SSL 很简单,只要在生产环境的配置文件 production.rb 中去掉一行代码的注释即可。如代码清单 7.36 所示,只需把 config.force_ssl 设为 true

代码清单 7.36:配置应用,在生产环境中使用 SSL
config/environments/production.rb
Rails.application.configure do
  .
  .
  .
  # Force all access to the app over SSL, use Strict-Transport-Security,
  # and use secure cookies.
  config.force_ssl = true
  .
  .
  .
end

然后,我们要在远程服务器中设置 SSL。这个过程包括为自己的域名购买和配置 SSL 证书,有很多工作要做。不过幸运的是,我们并不需要处理这些事,因为在 Heroku 中运行的应用(例如我们的演示应用),可以直接使用 Heroku 的 SSL 证书。所以,7.5.2 节部署应用后,会自动启用 SSL。(如果你想在自己的域名上使用 SSL,例如 www.example.com,请参照 Heroku 文档对 SSL 的说明。)

7.5.2 生产环境中的 Web 服务器

启用 SSL 后,我们要配置应用,让它使用一个适合在生产环境中使用的 Web 服务器。默认情况下,Heroku 使用纯 Ruby 实现的 WEBrick,这个服务器易于搭建,但不能很好地处理巨大流量。因此,WEBrick 不适合在生产环境中使用,我们要换用能处理大量请求的 Puma

我们按照 Heroku 文档中的说明,换用 Puma。第一步,要在 Gemfile 文件中添加 puma gem,不过从 Rails 5 起默认已经包含了(代码清单 3.2),因此我们可以直接跳到第 2 步,把 config/puma.rb 文件中的默认内容替换成代码清单 7.37 中的配置。这段代码直接摘自 Heroku 的文档[14]你没必要理解它的意思。

代码清单 7.37:生产环境所用 Web 服务器的配置文件
config/puma.rb
workers Integer(ENV['WEB_CONCURRENCY'] || 2)
threads_count = Integer(ENV['RAILS_MAX_THREADS'] || 5)
threads threads_count, threads_count

preload_app!

rackup      DefaultRackup
port        ENV['PORT']     || 3000
environment ENV['RACK_ENV'] || 'development'

on_worker_boot do
  # 专门针对 Rails 4.1+ 的职程设置
  # 参见:https://devcenter.heroku.com/articles/
  # deploying-rails-applications-with-the-puma-web-server#on-worker-boot
  ActiveRecord::Base.establish_connection
end

最后,我们要新建一个 Procfile 文件,告诉 Heroku 在生产环境运行一个 Puma 进程。这个文件的内容如代码清单 7.38 所示。Procfile 文件和 Gemfile 文件一样,应该放在应用的根目录中。

代码清单 7.38:创建 Puma 需要的 Procfile 文件
./Procfile
web: bundle exec puma -C config/puma.rb

7.5.3 部署到生产环境

生产环境的 Web 服务器配置好之后,我们可以提交并部署了:[15]

$ rails test
$ git add -A
$ git commit -m "Use SSL and the Puma webserver in production"
$ git push
$ git push heroku
$ heroku run rails db:migrate

现在,注册页面可以在生产环境中使用了,注册成功后显示的页面如图 7.25。注意图中的地址栏,使用的是 https://,而且还有一个锁状图标,这表明 SSL 启用了。

signup in production 4th edition
图 7.25:在生产环境中注册

Ruby 版本号

部署到 Heroku 时,可能会看到类似下面的提醒消息:

###### WARNING:
       You have not declared a Ruby version in your Gemfile.
       To set your Ruby version add this line to your Gemfile:
       ruby '2.1.5'

经验表明,对本书面向的读者来说,明确指定 Ruby 的版本号要做很多额外工作,得不偿失,[16]所以现在你应该忽略这个提醒。为了让演示应用和系统中的 Ruby 版本保持最新,会遇到很多问题,而且不同的版本之间没有太大的差异。不过要记住,如果想在 Heroku 中运行重要的应用,建议在 Gemfile 文件中明确指定 Ruby 版本号,尽量减少开发环境和生产环境之间的差异。

练习
  1. 在你的浏览器中确认有 SSL 锁状图标和 https

  2. 在线上网站中使用你的电子邮件地址注册一个用户。确认有没有正确显示你的 Gravatar 头像。

7.6 小结

实现注册功能对这个演示应用来说是个重要的里程碑。虽然现在还没实现真正有用的功能,不过却为后续功能的开发奠定了坚实的基础。第 8 章第 9 章将实现用户登录、退出功能(以及可选的“记住我”功能),完成整个身份验证功能。第 10 章将实现更新用户个人信息的功能,还会实现管理员删除用户的功能,这样才算完全实现了表 7.1 中列出的 Users 资源相关的 REST 动作。

7.6.1 本章所学

  • Rails 通过 debug 方法显示一些有用的调试信息;

  • Sass 混入定义一组 CSS 规则,可以多次使用;

  • Rails 默认提供了三个标准环境:开发环境、测试环境和生产环境;

  • 可以通过一组标准的 REST URL 与 Users 资源交互;

  • Gravatar 提供了一种简便的方法显示用户的头像;

  • form_for 辅助方法用于创建与 Active Record 对象交互的表单;

  • 注册失败后渲染注册页面,而且会显示由 Active Record 自动生成的错误消息;

  • Rails 提供了 flash 作为显示临时消息的标准方式;

  • 注册成功后会在数据库中创建一个用户记录,而且会重定向到用户资料页面,并显示一个欢迎消息;

  • 可以使用集成测试检查表单提交的行为,并能捕获回归;

  • 可以配置应用在生产环境中使用 SSL 加密通信,还可以使用 Puma 提升性能。

  1. Mockingbird 不支持插入自定义的图片,图 7.1 中的头像是我用 GIMP 加上的。
  2. 图中的河马原图在此 http://www.flickr.com/photos/43803060@N00/24308857/,发布于 2014 年 6 月 16 日。Copyright © 2002 by Shaun Wallin。未经改动,基于“知识共享 署名 2.0 通用”许可证使用。
  3. 也可以自己添加环境,详情参见 RailsCast 中的视频
  4. Rails 调试信息的具体内容在不同的版本中稍有不同。例如,从 Rails 5 开始,调试信息显示的是允许的(permitted)信息(7.3.2 节说明)。请尝试自行解决这样的复杂问题。
  5. Rails 的调试信息是 YAML(YAML Ain’t Markup Language 的递归缩写)格式,这种格式对机器和人类都很友好。
  6. 意思是,路由可以正常使用,不过对应的页面现在还不能访问。例如,/users/1/edit 交由 Users 控制器中的 edit 动作处理,但现在还没编写 edit 动作,如果访问这个地址会看到一个错误页面。
  7. 例如,使用 touch app/views/users/show.html.erb 命令。
  8. 在印度教中,Avatar 是神的化身,可以是一个人,也可以是一种动物。由此引申到其他领域,特别是在虚拟世界中,Avatar 代表一个人。(在 Twitter 和其他社交媒体中,现在流行称之为 avi,这是 avatar 的一个变种。)
  9. 代码清单 7.11 中有个 .gravatar_edit 类,第 10 章将用到。
  10. 如果你想了解真伪令牌的细节,可以查看 Stack Overflow 中的这个问题
  11. 其实 private 是方法,不是关键字,参见《The Ruby Programming Language》一书。——译者注
  12. 其实我们要使用的是 flash.now,现在暂且不管二者之间的细微差别。
  13. 严格来说,SSL 现在叫 TLS(Transport Layer Security,传输层安全),不过我认识的人都继续用“SSL”。
  14. 为了确保代码行短于 80 列,代码清单 7.37 稍微修改了一下排版。
  15. 本章没有修改数据模型,所以在 Heroku 中不执行迁移也行。因为有些读者反馈遇到了问题,所以安全起见,我在最后添加了一步,执行 heroku run rails db:migrate 命令。
  16. 例如,我花了好几个小时在本地电脑中安装 Ruby 2.1.4,一直不成功,然后发现 Ruby 2.1.5 前一天发布了。我再尝试安装 2.1.5,仍然失败。