Ruby on Rails 教程

Ruby on Rails Tutorial 原书第 2 版(涵盖 Rails 4)

第 8 章 登录和退出

  1. 8.1 session 和登录失败
  2. 8.1.1 Sessions 控制器
  3. 8.1.2 测试登录功能
  4. 8.1.3 登录表单
  5. 8.1.4 分析表单提交
  6. 8.1.5 显示 Flash 消息
  7. 8.2 登录成功
  8. 8.2.1 “记住我”
  9. 8.2.2 定义 sign_in 方法
  10. 8.2.3 获取当前用户
  11. 8.2.4 改变导航链接
  12. 8.2.5 注册后直接登录
  13. 8.2.6 退出
  14. 8.3 Cucumber 简介(选读)
  15. 8.3.1 安装和设置
  16. 8.3.2 功能和步骤定义
  17. 8.3.3 小技巧:自定义 RSpec 匹配器
  18. 8.4 小结
  19. 8.5 练习

第 7 章已经实现了注册新用户的功能,本章我们要为已注册的用户提供登录和退出功能。实现登录功能之后,就可以根据登录状态和当前用户的身份定制网站的内容了。例如,本章我们会更新网站的头部,显示“登录”或“退出”链接,以及到个人资料页面的链接;在第 10 章中,会根据当前登录用户的 id 创建关联到这个用户的微博;在第 11 章,我们会实现当前登录用户关注其他用户的功能,实现之后,在首页就可以显示被关注用户发表的微博了。

实现登录功能之后,还可以实现一种安全机制,即根据用户的身份限制可以访问的页面,例如,在第 9 章中会介绍如何实现只有登入的用户才能访问编辑用户资料的页面。登录系统还可以赋予管理员级别的用户特别的权限,例如删除用户(也会在第 9 章中实现)等。

实现验证系统的核心功能之后,我们会简要的介绍一下 Cucumber 这个流行的行为驱动开发(Behavior-driven Development, BDD)系统,使用 Cucumber 重新实现之前的一些 RSpec 集成测试,看一下这两种方式有何不同。

和之前的章节一样,我们会在一个新的从分支中工作,本章结束后再将其合并到主分支中:

$ git checkout -b sign-in-out

8.1 session 和登录失败

session 是两台电脑(例如运行有网页浏览器的客户端电脑和运行 Rails 的服务器)之间的半永久性连接,我们就是利用它来实现“登录”这一功能的。网络中常见的 session 处理方式有好几种:可以在用户关闭浏览器后清除 session;也可以提供一个“记住我”单选框让用户选择永远保存,直到用户退出后 session 才会失效。1 在示例程序中我们选择使用第二种处理方式,即用户登录后,会永久的记住登录状态,直到用户点击“退出”链接之后才清除 session。(在 8.2.1 节中会介绍“永久”到底有多久。)

很显然,我们可以把 session 视作一个符合 REST 架构的资源,在登录页面中准备一个新的 session,登录后创建这个 session,退出则会销毁 session。不过 session 和 Users 资源有所不同,Users 资源使用数据库(通过 User 模型)持久的存储数据,而 Sessions 资源是利用 cookie 来存储数据的。cookie 是存储在浏览器中的简单文本。实现登录功能基本上就是在实现基于 cookie 的验证机制。在本节及接下来的一节中,我们会构建 Sessions 控制器,创建登录表单,还会实现控制器中相关的动作。在 8.2 节中会加入处理 cookie 所需的代码。

8.1.1 Sessions 控制器

登录和退出功能其实是由 Sessions 控制器中相应的动作处理的,登录表单在 new 动作中处理(本节的内容),登录的过程就是向 create 动作发送 POST 请求(8.1 节8.2 节),退出则是向 destroy 动作发送 DELETE 请求(8.2.6 节)。(HTTP 请求和 REST 动作之间的对应关系可以查看表格 7.1。)首先,我们要生成 Sessions 控制器,以及验证系统所需的集成测试:

$ rails generate controller Sessions --no-test-framework
$ rails generate integration_test authentication_pages

参照 7.2 节中的“注册”页面,我们要创建一个登录表单,用来生成新的 session。注册表单的构思图如图 8.1 所示。

“登录”页面的地址由 signin_path(稍后定义)获取,和之前一样,我们要先编写相应的测试,如代码 8.1 所示。(可以和代码 7.6 中对“注册”页面的测试比较一下。)

signin mockup bootstrap

图 8.1:注册表单的构思图

代码 8.1:new 动作和对应视图的测试

spec/requests/authentication_pages_spec.rb

require 'spec_helper'

describe "Authentication" do

  subject { page }

  describe "signin page" do
    before { visit signin_path }

    it { should have_content('Sign in') }
    it { should have_title('Sign in') }
  end
end

现在测试是失败的:

$ bundle exec rspec spec/

要让代码 8.1 中的测试通过,首先,我们要为 Sessions 资源设置路由,还要修改“登录”页面具名路由的名称,将其映射到 Sessions 控制器的 new 动作上。和 Users 资源一样,我们可以使用 resources 方法设置标准的 REST 动作:

resources :sessions, only: [:new, :create, :destroy]

因为我们没必要显示或编辑 session,所以我们对动作的种类做了限制,为 resources 方法指定了 :only 选项,只创建 newcreatedestroy 动作。最终的结果,包括登录和退出具名路由的设置,如代码 8.2 所示。

代码 8.2:设置 session 相关的路由

config/routes.rb

SampleApp::Application.routes.draw do
  resources :users
  resources :sessions, only: [:new, :create, :destroy]
  root to: 'static_pages#home'
  match '/signup',  to: 'users#new',            via: 'get'
  match '/signin',  to: 'sessions#new',         via: 'get'
  match '/signout', to: 'sessions#destroy',     via: 'delete'
  .
  .
  .
end

注意,设置退出路由那行使用了 via: 'delete',这个参数指明 destroy 动作要使用 DELETE 请求。

代码 8.2 中的路由设置会生成类似表格 7.1 所示的URI 地址和动作的对应关系,如表格 8.1 所示。注意,我们修改了登录和退出具名路由,而创建 session 的路由还是使用默认值。

为了让代码 8.1 中的测试通过,我们还要在 Sessions 控制器中加入 new 动作,相应的代码如代码 8.3 所示(同时也定义了 createdestroy 动作)。

代码 8.3:没什么内容的 Sessions 控制器

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  def new
  end

  def create
  end

  def destroy
  end
end

表格 8.1:代码 8.2 中的设置生成的符合 REST 架构的路由关系

HTTP 请求 URL 地址 具名路由 动作 目的
GET /signin signin_path new 创建新 session 的页面(登录)
POST /sessions sessions_path create 创建 session
DELETE /signout signout_path destroy 删除 session(退出)

接下来还要创建“登录”页面的视图,因为“登录”页面的目的是创建新 session,所以创建的视图位于 app/views/sessions/new.html.erb。在视图中我们要显示网页的标题和一个一级标头,如代码 8.4 所示。

代码 8.4:“登录”页面的视图

app/views/sessions/new.html.erb

<% provide(:title, "Sign in") %>
<h1>Sign in</h1>

现在代码 8.1 中的测试应该可以通过了,接下来我们要编写登录表单。

$ bundle exec rspec spec/

8.1.2 测试登录功能

对比图 8.1 和图 7.11 之后,我们发现登录表单和注册表单外观上差不多,只是少了两个字段,只有 Email 地址和密码字段。和注册表单一样,我们可以使用 Capybara 填写表单,再点击按钮进行测试。

在测试的过程中,我们不得不向程序中加入相应的功能,这也正是 TDD 带来的好处之一。我们先来测试填写不合法数据的登录过程,构思图如图 8.2 所示。

从图 8.2 我们可以看出,如果提交的数据不正确,我们会重新渲染“注册”页面,还会显示一个错误提示消息。这个错误提示是 Flash 消息,我们可以通过下面的测试验证:

it { should have_selector('div.alert.alert-error') }

这行代码使用了代码 7.32(在第 7 章的练习中)中用过的 have_selector 方法。have_selector 会检查页面中是否出现了指定的元素(例如,一个 HTML 标签,不过在 Capybara 2.0 中只会检查可见的元素) 本例我们要查找的元素是:

div.alert.alert-error

上面这行代码会寻找一个 div 标签。前面介绍过,这里的点号代表 CSS 中的 class(参见 5.1.2 节),你也许猜到了,这里我们要查找的是同时具有 alertalert-error class 的 div 元素,如下:

<div class="alert alert-error">Invalid...</div>
signin failure mockup bootstrap

图 8.2:注册失败页面的构思图

代码 8.5 是针对标题和 Flash 消息的测试。我们可以看出,这些代码缺少了一个很重要的部分,会在 8.1.5 节中说明。

代码 8.5:登录失败时的测试

spec/requests/authentication_pages_spec.rb

require 'spec_helper'

describe "Authentication" do
  .
  .
  .
  describe "signin" do
    before { visit signin_path }

    describe "with invalid information" do
      before { click_button "Sign in" }

      it { should have_title('Sign in') }
      it { should have_selector('div.alert.alert-error') }
    end
  end
end

测试了登录失败的情况,下面我们要测试登录成功的情况了。我们要测试登录成功后是否转向了用户资料页面(从页面的标题判断,标题中应该包含用户的名字),还要测试网站的导航中是否有以下三个变化:

  1. 出现了指向用户资料页面的链接
  2. 出现了“退出”链接
  3. “登录”链接消失了

(对“设置(Settings)”链接的测试会在 9.1 节中实现,对“所有用户(Users)”链接的测试会在 9.3 节中实现。)如上变化的构思图如图 8.3 所示。2注意,“退出”和“个人资料”链接位于“账户(Account)”下拉菜单中。在 8.2.4 节中会介绍如何通过 Bootstrap 实现这种下拉菜单。

对登录成功时的测试如代码 8.6 所示。

代码 8.6:登录成功时的测试

spec/requests/authentication_pages_spec.rb

require 'spec_helper'

describe "Authentication" do
  .
  .
  .
  describe "signin" do
    before { visit signin_path }
    .
    .
    .
    describe "with valid information" do
      let(:user) { FactoryGirl.create(:user) }
      before do
        fill_in "Email",    with: user.email.upcase
        fill_in "Password", with: user.password
        click_button "Sign in"
      end

      it { should have_title(user.name) }
      it { should have_link('Profile',     href: user_path(user)) }
      it { should have_link('Sign out',    href: signout_path) }
      it { should_not have_link('Sign in', href: signin_path) }
    end
  end
end
signin success mockup bootstrap

图 8.3:登录成功后显示的用户资料页面构思图

在代码 8.6 中用到了 Capybara 提供的 have_link 方法,它的第一参数是链接文本,第二个参数是可选的 :href,指定链接的地址,因此如下的代码

it { should have_link('Profile', href: user_path(user)) }

确保了页面中有一个 a 元素,链接到指定的 URL 地址。这里我们要检测的是一个指向用户资料页面的链接。注意,我们调用 upcase 方法把用户的 Email 地址转换成了大写形式,确保在数据库中查询用户时不用担心大小写问题。

8.1.3 登录表单

写完测试之后,我们就可以创建登录表单了。在代码 7.17 中,注册表单使用了 form_for 帮助函数,并指定其参数为 @user 变量:

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

注册表单和登录表单的区别在于,程序中没有 Session 模型,因此也就没有类似 @user 的变量。也就是说,在构建登录表单时,我们要给 form_for 提供更多的信息。一般来说,如下的代码

form_for(@user)

Rails 会自动向 /users 地址发送 POST 请求。对于登录表单,我们则要明确的指定资源的名称以及相应的 URL 地址:

form_for(:session, url: sessions_path)

(创建表单还有另一种方法,不用 form_for,而用 form_tagform_tag 也是 Rails 程序常用的方法,不过换用 form_tag 之后就和注册表单有很多不同之处了,我现在是想使用相似的代码构建登录表单。使用 form_tag 构建登录表单会留作练习(参见 8.5 节)。)

使用上述这种 form_for 形式,参照代码 7.17 中的注册表单,很容易的就能编写一个符合图 8.1 的登录表单,如代码 8.7 所示。

代码 8.7:注册表单的代码

app/views/sessions/new.html.erb

<% provide(:title, "Sign in") %>
<h1>Sign in</h1>

<div class="row">
  <div class="span6 offset3">
    <%= form_for(:session, url: sessions_path) do |f| %>

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

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

      <%= f.submit "Sign in", class: "btn btn-large btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

注意,为了访客的便利,我们还加入了到“注册”页面的链接。代码 8.7 中的登录表单效果如图 8.4 所示。

signin form bootstrap

图 8.4:登录表单(/signup

用的多了你就不会老是查看 Rails 生成的 HTML(你会完全信任所用的帮助函数可以正确的完成任务),不过现在还是来看一下登录表单的 HTML 吧(如代码 8.8 所示)。

代码 8.8:代码 8.7 中登录表单生成的 HTML

<form accept-charset="UTF-8" action="/sessions" method="post">
  <div>
    <label for="session_email">Email</label>
    <input id="session_email" name="session[email]" type="text" />
  </div>
  <div>
    <label for="session_password">Password</label>
    <input id="session_password" name="session[password]"
           type="password" />
  </div>
  <input class="btn btn-large btn-primary" name="commit" type="submit"
       value="Sign in" />
</form>

你可以对比一下代码 8.8 和代码 7.20。你可能已经猜到了,提交登录表单后会生成一个 params Hash,其中 params[:session][:email]params[:session][:password] 分别对应了 Email 和密码字段。

8.1.4 分析表单提交

和创建用户类似,创建 session 时先要处理提交不合法数据的情况。我们已经编写了对提交不合法数据的测试(参见代码 8.5),也添加了有几处难理解但还算简单的代码让测试通过了。下面我们就来分析一下表单提交的过程,然后为登录失败添加失败提示信息(如图 8.2)。最后,以此为基础,验证提交的 Email 和密码,处理登录成功的情况(参见 8.2 节)。

首先,我们来编写 Sessions 控制器的 create 动作,如代码 8.9 所示,现在只是直接渲染登录页面。在浏览器中访问 /sessions/new,然后提交空表单,显示的页面如图 8.5 所示。

代码 8.9:Sessions 控制器中 create 动作的初始版本

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  .
  .
  .
  def create
    render 'new'
  end
  .
  .
  .
end
initial failed signin rails3 bootstrap

图 8.5:代码 8.9 中的 create 动作显示的登录失败后的页面

仔细看一下图 8.5 中显示的调试信息,你会发现,如在 8.1.3 节末尾说过的,表单提交后会生成 params Hash,Email 和密码都在 :session 键中:

---
session:
  email: ''
  password: ''
commit: Sign in
action: create
controller: sessions

和注册表单类似,这些参数是一个嵌套的 Hash,在代码 4.6 中见过。params 包含了如下的嵌套 Hash:

{ session: { password: "", email: "" } }

也就是说

params[:session]

本身就是一个 Hash:

{ password: "", email: "" }

所以,

params[:session][:email]

就是提交的 Email 地址,而

params[:session][:password]

就是提交的密码。

也就是说,在 create 动作中,params 包含了使用 Email 和密码验证用户身份所需的全部数据。幸运的是,我们已经定义了身份验证过程中所需的两个方法,即由 Active Record 提供的 User.find_by_email(参见 6.1.4 节),以及由 has_secure_password 提供的 authenticate 方法(参见 6.3.3 节)。我们之前介绍过,如果提交的数据不合法,authenticate 方法会返回 false。基于以上的分析,我们计划按照如下的方式实现用户登录功能:

def create
  user = User.find_by(email: params[:session][:email].downcase)
  if user && user.authenticate(params[:session][:password])
    # Sign the user in and redirect to the user's show page.
  else
    # Create an error message and re-render the signin form.
  end
end

create 动作的第一行,使用提交的 Email 地址从数据库中取出相应的用户。(我们在 6.2.5 节讲过,Email 地址都是以小写字母形式保存的,所以这里调用了 downcase 方法,确保提交合法 Email 地址后能查询到相应的记录。)第二行看起来很怪,但在 Ruby 中经常使用:

user && user.authenticate(params[:session][:password])

表格 8.2:user && user.authenticate(...) 可能出现的结果

用户 密码 a && b
不存在 任意值 nil && [anything] == false
存在 错误的密码 true && false == false
存在 正确的密码 true && true == true

我们使用 &&(逻辑与)检测获取的用户是否合法。因为除了 nilfalse 之外的所有对象都被视作 true,上面这个语句可能出现的结果如表格 8.2所示。我们可以从表格 8.2 中看出,当且仅当数据库中存在提交的 Email 并提交了对应的密码时,这个语句才会返回 true

8.1.5 显示 Flash 消息

7.3.3 节中,我们使用 User 模型的数据验证信息来显示注册失败时的提示信息。这些错误提示信息是关联在某个 Active Record 对象上的,不过这种方式不可以用在 session 上,因为 session 不是 Active Record 模型。我们要采取的方法是,在登录失败时,把错误提示信息赋值给 Flash 消息。代码 8.10 显示的是我们首次尝试实现这种方法所用的代码,其中有个小小的错误。

failed signin flash bootstrap

图 8.6:登录失败后显示的 Flash 消息

代码 8.10:尝试处理登录失败(有个小小的错误)

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # Sign the user in and redirect to the user's show page.
    else
      flash[:error] = 'Invalid email/password combination' # Not quite right!
      render 'new'
    end
  end

  def destroy
  end
end

布局中已经加入了显示 Flash 消息的局部视图(代码 7.27),所以无需其他修改,上述 Flash 错误提示消息就会显示出来,而且因为使用了 Bootstrap,这个错误消息的样式也很美观(如图 8.6)。

不过,就像代码 8.10 中的注释所说,这些代码还有问题。显示的页面看起来很正常啊,那么,问题出现在哪儿呢?问题的关键在于,Flash 消息在一个请求的生命周期内是持续存在的,而重新渲染页面(使用 render 方法)和代码 7.28 中的转向不同,它不算新的请求,你会发现这个 Flash 消息存在的时间比设想的要长很多。例如,我们提交了不合法的登录信息,Flash 消息生成了,然后在登录页面中显示出来(如图 8.6),这时如果我们点击链接转到其他页面(例如“首页”),这只算是表单提交后的第一次请求,所以页面中还是会显示 Flash 消息(如图 8.7)。

flash persistence bootstrap

图 8.7:仍然显示有 Flash 消息的页面

Flash 消息没有按预期消失算是程序的一个 bug,在修正之前,我们最好编写一个测试来捕获这个错误。现在,登录失败时的测试是可以通过的:

$ bundle exec rspec spec/requests/authentication_pages_spec.rb \
> -e "signin with invalid information"

不过程序中有错误,测试应该是失败的,所以我们要编写一个能够捕获这种错误的测试。幸好,捕获这种错误正是集成测试的拿手好戏,所用的代码如下:

describe "after visiting another page" do
  before { click_link "Home" }
  it { should_not have_selector('div.alert.alert-error') }
end

提交不合法的登录信息之后,这个测试用例会点击网站中的“首页”链接,期望显示的页面中没有 Flash 错误消息。添加上述测试用例的测试文件如代码 8.11 所示。

代码 8.11:登录失败时的合理测试

spec/requests/authentication_pages_spec.rb

require 'spec_helper'

describe "Authentication" do
  .
  .
  .
  describe "signin" do

    before { visit signin_path }

    describe "with invalid information" do
      before { click_button "Sign in" }

      it { should have_title('Sign in') }
      it { should have_selector('div.alert.alert-error') }

      describe "after visiting another page" do
        before { click_link "Home" }
        it { should_not have_selector('div.alert.alert-error') }
      end
    end
    .
    .
    .
  end
end

新添加的测试和预期一致,是失败的:

$ bundle exec rspec spec/requests/authentication_pages_spec.rb \
> -e "signin with invalid information"

要让这个测试通过,我们要用 flash.now 替换 flashflash.now 就是专门用来在重新渲染的页面中显示 Flash 消息的,在发送新的请求之后,Flash 消息便会消失。正确的 create 动作代码如代码 8.12 所示。

代码 8.12:处理登录失败所需的正确代码

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # Sign the user in and redirect to the user's show page.
    else
      flash.now[:error] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
  end
end

现在登录失败时的所有测试应该都可以通过了:

$ bundle exec rspec spec/requests/authentication pages spec.rb \
> -e "with invalid information"

8.2 登录成功

上一节处理了登录失败的情况,这一节我们要处理登录成功的情况了。实现用户登录的过程是本书目前为止最考验 Ruby 编程能力的部分,你要坚持读完本节,做好心理准备,付出大量的脑力劳动。幸好,第一步还算是简单的,完成 Sessions 控制器的 create 动作没什么难的,不过还是需要一点小技巧。

我们需要把代码 8.12 中处理登录成功分支中的注释换成具体的代码,使用 sign_in 方法实现登录操作,然后转向用户的资料页面,如代码 8.13 所示。这就是我们使用的技巧,使用还没定义的方法 sign_in。本节后面的内容会定义这个方法。

代码 8.13:完整的 create 动作代码(还不能正常使用)

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  .
  .
  .
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      sign_in user
      redirect_to user
    else
      flash.now[:error] = 'Invalid email/password combination'
      render 'new'
    end
  end
  .
  .
  .
end

8.2.1 “记住我”

现在我们要开始实现登录功能了,第一步是实现“记住我”这个功能,即用户登录的状态会被“永远”记住,直到用户点击“退出”链接为止。实现登录功能用到的函数已经超越了传统的 MVC 架构,其中一些函数要同时在控制器和视图中使用。在4.2.5 节中介绍过,Ruby 支持模块(module)功能,打包一系列函数,在不同的地方引入。我们会利用模块来打包用户身份验证相关的函数。我们当然可以创建一个新的模块,不过 Sessions 控制器已经提供了一个名为 SessionsHelper 的模块,而且这个模块中的帮助方法会自动引入 Rails 程序的视图中。所以,我们就直接使用这个现成的模块,然后在 Application 控制器中引入,如代码 8.14 所示。

代码 8.14:在 Application 控制器中引入 Sessions 控制器的帮助方法模块

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

默认情况下帮助函数只可以在视图中使用,不能在控制器中使用,而我们需要同时在控制器和视图中使用帮助函数,所以我们就手动引入帮助函数所在的模块。

因为 HTTP 是无状态的协议,所以如果应用程序需要实现登录功能的话,就要找到一种方法记住用户的状态。维持用户登录状态的方法之一,是使用常规的 Rails session(通过 session 函数),把用户的 id 保存在“记忆权标(remember token)”中:

session[:remember_token] = user.id

session 对象把用户 id 保存在浏览器的 cookie 中,这样在网站的所有页面就都可以使用了。浏览器关闭后,cookie 也随之失效。在网站中的任何页面,只需调用 User.find(session[:remember_token]) 就可以取回用户对象了。Rails 在处理 session 时,会确保安全性。倘若用户企图伪造用户 id,Rails 可以通过每个 session 的 session id 检测到。

根据示例程序的设计目标,我们计划要实现的是持久保存的 session,即使浏览器关闭了,登录状态依旧存在,所以,登入的用户要有一个持久保存的标识符才行。为此,我们要为每个用户生成一个唯一而安全的记忆权标,长期存储,不会随着浏览器的关闭而消失。

记忆权标要附属到特定的用户对象上,而且要保存起来以待后用,所以我们就可以把它设为 User 模型的属性(如图 8.8)。我们先来编写 User 模型的测试,如代码 8.15 所示。

user model remember token 31

图 8.8:User 模型,添加了 remember_token 属性

代码 8.15:记忆权标的第一个测试

spec/models/user_spec.rb

require 'spec_helper'

describe User do
  .
  .
  .
  it { should respond_to(:password_confirmation) }
  it { should respond_to(:remember_token) }
  it { should respond_to(:authenticate) }
  .
  .
  .
end

要让这个测试通过,我们要生成记忆权标属性,执行如下命令:

$ rails generate migration add_remember_token_to_users

然后按照代码 8.16 修改生成的迁移文件。注意,因为我们要使用记忆权标取回用户,所以我们为 remember_token 列加了索引(参见 旁注 6.2)。

代码 8.16:users 表添加 remember_token 列的迁移

db/migrate/[timestamp]_add_remember_token_to_users.rb

class AddRememberTokenToUsers < ActiveRecord::Migration
  def change
    add_column :users, :remember_token, :string
    add_index :users, :remember_token
  end
end

然后,还要更新“开发数据库”和“测试数据库”:

$ bundle exec rake db:migrate
$ bundle exec rake test:prepare

现在,User 模型的测试应该可以通过了:

$ bundle exec rspec spec/models/user_spec.rb

接下来我们要考虑记忆权标要保存什么数据,这有很多种选择,其实任何足够长的随机字符串都是可以的,只要能保证唯一性。Ruby 标准库中 SecureRandom 模块提供的 urlsafe_base64 方法可以满足我们的要求。3SecureRandom.urlsafe_base64 创建的字符串长度为 16,由 A-Z、a-z、0-9、下划线(_)和连字符(-)组成(每一位字符都有 64 种可能的情况,所以叫做“base64”).所以两个记忆权标相等的概率就是 1/6416 = 2-96 ≈ 10-29,完全可以忽略。

我们计划在浏览器中存储这个 base64 权标,在数据库中存储加密后的版本。如果要自动登入用户,就可以从 cookie 中取出记忆权标,加密后查询数据库。数据库之所以只保存加密后的权标是因为,即便整个数据库都泄露了,攻击者也无法使用记忆权标登入网站。为了让记忆权标更安全,我们计划每次会话都生成不一样的权标,这样即使会话被劫持了(攻击者偷取 cookie 伪装成某个用户登录),用户下次登录时前一个会话就会失效。(会话劫持可以通过 Firesheep 扩展检测,使用这个扩展可以看到很多著名的网站在连接到公共 WIFI 时,记忆权标都是可见的。为了避免这个问题可以全站使用 SSL,在 7.4.4 节有介绍。)

真实的应用程序都会自动登入刚注册的用户(这样做的一个副作用就是创建了一个新的记忆权标),但是我们不想这么做,我们要用一种更好的方式,确保从一开始用户就有可用的记忆权标。为此,我们要使用回调函数生成权标。在 6.2.5 节中确保 Email 唯一性时用过回调函数,调用的是 before_save。现在我们要调用 before_create 在创建用户时设置记忆权标。4

要测试这个过程,我们可以先保存测试所需的用户对象,然后检查 remember_token 是否为非空值。这样做,如果以后需要改变记忆权标的生成方式,也无需修改测试。测试代码如代码 8.17 所示。

代码 8.17:测试合法的(非空)记忆权标值

spec/models/user_spec.rb

require 'spec_helper'

describe User do

  before do
    @user = User.new(name: "Example User", email: "[email protected]",
                     password: "foobar", password_confirmation: "foobar")
  end

  subject { @user }
  .
  .
  .
  describe "remember token" do
    before { @user.save }
    its(:remember_token) { should_not be_blank }
  end
end

代码 8.17 中用到了 its 方法,它和 it 很像,不过测试对象是参数中指定的属性而不是整个测试的对象。也就是说,如下的代码:

its(:remember_token) { should_not be_blank }

等同于

it { expect(@user.remember_token).not_to be_blank }

User 模型的代码会涉及到一些新的知识。其一,我们添加了一个回调函数,在用户存入数据库之前生成记忆权标:

before_create :create_remember_token

上面这行代码叫做“方法引用”。当 Rails 执行到这行代码时,会寻找一个名为 create_remember_token 的方法,在创建用户之前执行。(在代码 6.20 中,我们直接给 before_save 指定了块参数,但更推荐使用这种“方法引用”方式。)而且,create_remember_token 只会在 User 模型内部使用,所以没必要把它开放给用户之外的对象。如 7.3.2 节中所讲,在 Ruby 中,我们可以使用 private 关键字5限制方法的可见性:

private

  def create_remember_token
    # Create the token.
  end

在类中,private 之后定义的方法都会被设为私有方法,所以,如果执行下面的操作

$ rails console
>> User.first.create_remember_token

就会抛出 NoMethodError 异常。

create_remember_token 方法中,要给用户的属性赋值,需要在 remember_token 前加上 self 关键字:

  def User.new_remember_token
    SecureRandom.urlsafe_base64
  end

  def User.hash(token)
    Digest::SHA1.hexdigest(token.to_s)
  end

  private

    def create_remember_token
      self.remember_token = User.hash(User.new_remember_token)
    end

Active Record 是把模型的属性和数据库表中的列对应的,如果不指定 self 的话,我们就只是创建了一个名为 remember_token 的局部变量而已,这可不是我们期望得到的结果。加上 self 之后,赋值操作就会把值赋值给用户的 remember_token 属性,保存用户时,随着其他的属性一起存入数据库。(现在你知道了为什么代码 6.20 中的 before_save 为什么要用 self.name 而不直接用 email。)

注意,我们使用 SHA1 加密了记忆权标。这种哈希算法比 6.3.1 节中用来加密用户密码的 Bcrypt 速度快得多。之所以看中速度,是因为对登入的用户来说,每个页面都会加密记忆权标(参加8.2.2 节)。SHA1 的安全性不如 Bcrypt,但能满足现在的需求,加密后的记忆权标是 16 位随机字符,基本无法破解。(上述代码之所以调用 to_s,是为了处理输入为 nil 的情况,在浏览器中不会遇到,测试时偶尔会出现。)

hashnew_remember_token 方法定义在 User 类中,因为二者无需用户实例就可使用6,而且还是公开方法(位于 private 上面)。在 8.2.3 节中会将这两个方法剥离出 User 模型。

把上述的分析结合起来,最终得到的 User 模型文件如代码 8.18 所示。

代码 8.18:生成记忆权标的 before_create 回调函数

app/models/user.rb

class User < ActiveRecord::Base
  before_save { self.email = email.downcase }
  before_create :create_remember_token
  .
  .
  .
  def User.new_remember_token
    SecureRandom.urlsafe_base64
  end

  def User.hash(token)
    Digest::SHA1.hexdigest(token.to_s)
  end

  private

    def create_remember_token
      self.remember_token = User.hash(User.new_remember_token)
    end
end

顺便说一下,我们为 create_remember_token 方法增加了一层缩进,这样可以更好的突出这些方法是在 private 之后定义的。(经验表明,这样做是明智地。)7

因为加密后的 SecureRandom.urlsafe_base64 方法创建的字符串不可能为空值,所以对 User 模型的测试现在应该可以通过了:

$ bundle exec rspec spec/models/user_spec.rb

8.2.2 定义 sign_in 方法

本小节我们要开始实现登录功能了,首先来定义 sign_in 方法。上一小节已经说明了,我们计划实现的身份验证方式是,在用户的浏览器中存储记忆权标,在网站的页面与页面之间通过这个记忆权标获取数据库中的用户记录(会在 8.2.3 节实现)。实现这一设想所需的代码如代码 8.19 所示,这段代码使用了 current_user 方法,会在 8.2.3 节中定义。

代码 8.19:完整但还不能正常使用的 sign_in 方法

app/helpers/sessions_helper.rb

module SessionsHelper

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.hash(remember_token))
    self.current_user = user
  end
end

上述代码遵照了设定的步骤:首先,创建新权标;随后,把未加密的权标存入浏览器的 cookie;然后,把加密后的权标存入数据库;最后,把制定的用户设为当前登入的用户。我们会在 8.2.3 节中看到,现在并不一定要把 user 指定为当前用户,因为 create 动作完成后回立即转向(代码 8.13)。不过指定也好,防止无需转向的登录操作。

注意,保存记忆权标使用的是 update_attribute 方法,这样可以跳过数据验证更新单个属性。我们必须用这个方法,因为我们无法提供用户的密码及密码确认。代码 8.19 中用到的 cookies 方法是由 Rails 提供的,我们可以把它看成 Hash,其中每个元素又都是一个 Hash,包含两个元素,value 指定 cookie 的文本,expires 指定 cookie 的失效日期。例如,我们可以使用下述代码实现登录功能,把 cookie 的值设为用户的记忆权标,失效日期设为 20 年之后:

cookies[:remember_token] = { value:   remember_token,
                             expires: 20.years.from_now.utc }

(这里使用了 Rails 提供的时间帮助方法,详情参见旁注 8.1。)

旁注 8.1:cookie 在 20.years.from_now 之后失效

4.4.2 节中介绍过,你可以向任何的 Ruby 类,甚至是内置的类中添加自定义的方法,我们就向 String 类添加了 palindrome? 方法(而且还发现了 "deified" 是回文)。我们还介绍过,Rails 为 Object 类添加了 blank? 方法(所以,"".blank?" ".blank?nil.blank? 的返回值都是 true)。代码 8.19 中处理 cookie 的代码又是一例,使用了 Rails 提供的时间帮助方法,这些方法是添加到 Fixnum 类(数字的基类)中的。

$ rails console
>> 1.year.from_now
=> Sun, 13 Mar 2011 03:38:55 UTC +00:00
>> 10.weeks.ago
=> Sat, 02 Jan 2010 03:39:14 UTC +00:00

Rails 还添加了其他的帮助函数,如:

>> 1.kilobyte
=> 1024
>> 5.megabytes
=> 5242880

这几个帮助函数可用于限制上传文件的大小,例如,图片最大不超过 5.megabytes

这种为内置类添加方法的特性很灵便,可以扩展 Ruby 的功能,不过使用时要小心一些。其实 Rails 的很多优雅之处正式基于 Ruby 语言的这一特性。

因为开发者经常要把 cookie 的失效日期设为 20 年后,所以 Rails 特别提供了 permanent 方法,前面处理 cookie 的代码可以改写成:

cookies.permanent[:remember_token] = remember_token

Rails 的 permanent 方法会自动把 cookie 的失效日期设为 20 年后。

设定了 cookie 之后,在网页中我们就可以使用下面的代码取回用户:

User.find_by(remember_token: remember_token)

其实浏览器中保存的 cookie 并不是 Hash,赋值给 cookies 只是把值以文本的形式保存在浏览器中。这正体现了 Rails 的智能,我们无需关心具体的处理细节,专注地实现应用程序的功能。

8.2.3 获取当前用户

上一小节已经介绍了如何在 cookie 中存储记忆权标以待后用,这一小节我们要看一下如何取回用户。我们先回顾一下 sign_in 方法:

module SessionsHelper

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.hash(remember_token))
    self.current_user = user
  end
end

现在还无法使用的代码是:

self.current_user = user

我们在代码 8.19 后面说过,这行代码并不会真的在这个程序中使用,因为登录后会直接转向。如果 sign_in 方法只考虑转向一种情况也是很危险的。

current_user 方法可以在控制器和视图中使用,所以你既可以这样用:

<%= current_user.name %>

也可以这样用:

redirect_to current_user

这行代码中的 self 也是必须的,原因在分析代码 8.18 时已经说过,如果没有 self,Ruby 只是定义了一个名为 current_user 的局部变量。

在开始编写 current_user 方法的代码之前,请仔细看这行代码:

self.current_user = user

这是一个赋值操作,我们必须先定义相应的方法才能这么用。Ruby 为这种赋值操作提供了一种特别的定义方式,如代码 8.20 所示。

代码 8.20:实现 current_user 方法对应的赋值操作

app/helpers/sessions_helper.rb

module SessionsHelper

  def sign_in(user)
    .
    .
    .
  end

  def current_user=(user)
    @current_user = user
  end
end

这段代码看起来很奇怪,因为大多数的编程语言并不允许在方法名中使用等号。其实这段代码定义的 current_user= 方法是用来处理 current_user 赋值操作的。也就是说,如下的代码

self.current_user = ...

会自动转换成下面这种形式

current_user=(...)

就是直接调用 current_user= 方法,接受的参数是赋值语句右侧的值,本例中是要登录的用户对象。current_user= 方法定义体内只有一行代码,即设定实例变量 @current_user 的值,以备后用。

在常见的 Ruby 代码中,我们还会定义 current_user 方法,用来读取 @current_user 的值,如代码 8.21 所示。

代码 8.21:尝试定义 current_user 方法,不过我们不会使用这种方式

module SessionsHelper

  def sign_in(user)
    .
    .
    .
  end

  def current_user=(user)
    @current_user = user
  end

  def current_user
    @current_user # Useless! Don't use this line.
  end
end

上面的做法其实就是实现了 attr_accessor 方法的功能(4.4.5 节介绍过)。8如果按照代码 8.21 来定义 current_user 方法,会出现一个问题:程序不会记住用户的登录状态。一旦用户转到其他的页面,session 就失效了,会自动退出。若要避免这个问题,我们要使用代码 8.19 中生成的记忆权标查找用户,如代码 8.22 所示。注意,因为数据库中保存的记忆权标是加密的,所以在用来查找用户之前要加密从 cookie 中读取的权标。我们可以使用代码 8.18 中定义的 User.hash 方法加密。

代码 8.22:通过记忆权标查找当前用户

app/helpers/sessions_helper.rb

module SessionsHelper
  .
  .
  .
  def current_user=(user)
    @current_user = user
  end

  def current_user
    remember_token = User.hash(cookies[:remember_token])
    @current_user ||= User.find_by(remember_token: remember_token)
  end
end

代码 8.22 中使用了一个常见但不是很容易理解的 ||=(“or equals”)操作符(旁注 8.2中有详细介绍)。使用这个操作符之后,当且仅当 @current_user 未定义时才会把通过记忆权标获取的用户赋值给实例变量 @current_user9也就是说,如下的代码

@current_user ||= User.find_by(remember_token: remember_token)

只在第一次调用 current_user 方法时调用 find_by 方法,如果后续再调用的话就直接返回 @current_user 的值,而不必再查询数据库。10这种方式的优点只有当在一个请求中多次调用 current_user 方法时才能显现。不管怎样,只要用户访问了相应的页面,find_by 方法都至少会执行一次。

旁注 8.2:||= 操作符简介

||= 操作符非常能够体现 Ruby 的特性,如果你打算长期进行 Ruby 编程的话就要好好学习它的用法。初学时会觉得 ||= 很神秘,不过通过和其他操作符类比之后,你会发现也不是很难理解。

我们先来看一下改变已经定义的变量时经常使用的结构。在很多程序中都会把变量自增一,如下所示

x = x + 1

大多数语言都为这种操作提供了简化的操作符,在 Ruby 中,可以按照下面的方式重写(C、C++、Perl、Python、Java 等也如此):

x += 1

其他操作符也有类似的简化形式:

$ rails console
>> x = 1
=> 1
>> x += 1
=> 2
>> x *= 3
=> 6
>> x -= 7
=> -1

上面的举例可以概括为,x = x O yx O=y 是等效的,其中 O 表示操作符。

在 Ruby 中还经常会遇到这种情况,如果变量的值为 nil 则赋予其他的值,否则就不改变这个变量的值。4.2.3 节 中介绍过 || 或操作符,所以这种情况可以用如下的代码表示:

>> @user
=> nil
>> @user = @user || "the user"
=> "the user"
>> @user = @user || "another user"
=> "the user"

因为 nil 表示的布尔值是 false,所以第一个赋值操作等同于 nil || "the user",这个语句的计算结果是 "the user";类似的,第二个赋值操作等同于 "the user" || "another user",这个语句的计算结果还是 "the user",因为 "the user" 表示的布尔值是 true,这个或操作在执行了第一个表达式之后就终止了。(或操作的执行顺序是从左至右,只要出现真值就会终止语句的执行,这种方式称作“短路计算(short-circuit evaluation)”。)

和上面的控制台会话对比之后,我们可以发现 @user = @user || value 符合 x = x O y 的形式,只需把 O 换成 ||,所以就得到了下面这种简写形式:

>> @user ||= "the user"
=> "the user"

不难理解吧!11

8.2.4 改变导航链接

本小节我们要完成的是实现登录、退出功能的最后一步,根据登录状态改变布局中的导航链接。如图 8.3 所示,我们要在登录和退出后显示不同的导航,要添加指向列出所有用户页面的链接、到用户设置页面的链接(第 9 章加入),还有到当前登录用户资料页面的链接。加入这些链接之后,代码 8.6 中的测试就可以通过了,这是本章目前为止测试首次变绿通过。

在网站的布局中改变导航链接需要用到 ERb 的 if-else 分支结构:

<% if signed_in? %>
# Links for signed-in users
<% else %>
# Links for non-signed-in-users
<% end %>

若要上述代码起作用,先要用 signed_in? 方法。我们现在就来定义。

如果 session 中存有当前用户的话,就可以说用户已经登录了。我们要判断 current_user 的值是不是 nil,这里需要用到取反操作符,用感叹号 ! 表示,一般读作“bang”。只要 current_user 的值不是 nil,就说明用户登录了,如代码 8.23 所示。

代码 8.23:定义 signed_in? 帮助方法

app/helpers/sessions_helper.rb

module SessionsHelper

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.hash(remember_token))
    self.current_user = user
  end

  def signed_in?
    !current_user.nil?
  end
  .
  .
  .
end

定义了 signed_in? 方法后就可以着手修改布局中的导航了。我们要添加四个新链接,其中两个链接的地址先不填(第 9 章再填):

<%= link_to "Users", '#' %>
<%= link_to "Settings", '#' %>

退出链接的地址使用代码 8.2 中定义的 signout_path

<%= link_to "Sign out", signout_path, method: "delete" %>

(注意,我们还为退出链接指定了类型为 Hash 的参数,指明点击链接后发送的是 HTTP DELETE 请求。12)最后,我们还要添加一个到资料页面的链接:

<%= link_to "Profile", current_user %>

这个链接我们本可以写成

<%= link_to "Profile", user_path(current_user) %>

不过我们可以直接把链接地址设为 current_user,Rails 会自动将其转换成 user_path(current_user)

在添加导航链接的过程中,我们还要使用 Bootstrap 实现下拉菜单的效果,具体的实现方式可以参阅 Bootstrap 的文档。添加导航链接所需的代码如代码 8.24 所示。注意其中和 Bootstrap 下拉菜单有关的 CSS id 和 class。

代码 8.24:根据登录状态改变导航链接

app/views/layouts/_header.html.erb

<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="navbar-inner">
    <div class="container">
      <%= link_to "sample app", root_path, id: "logo" %>
      <nav>
        <ul class="nav pull-right">
          <li><%= link_to "Home", root_path %></li>
          <li><%= link_to "Help", help_path %></li>
          <% if signed_in? %>
            <li><%= link_to "Users", '#' %></li>
            <li id="fat-menu" class="dropdown">
              <a href="#" class="dropdown-toggle" data-toggle="dropdown">
                Account <b class="caret"></b>
              </a>
              <ul class="dropdown-menu">
                <li><%= link_to "Profile", current_user %></li>
                <li><%= link_to "Settings", '#' %></li>
                <li class="divider"></li>
                <li>
                  <%= link_to "Sign out", signout_path, method: "delete" %>
                </li>
              </ul>
            </li>
          <% else %>
            <li><%= link_to "Sign in", signin_path %></li>
          <% end %>
        </ul>
      </nav>
    </div>
  </div>
</header>

实现下拉菜单还要用到 Bootstrap 中的 JavaScript 代码,我们可以编辑应用程序的 JavaScript 文件,通过 asset pipeline 引入所需的文件,如代码 8.25 所示。

profile with signout link bootstrap

图 8.9:登录后显示了新链接和下拉菜单

代码 8.25:把 Bootstrap 的 JavaScript 代码加入 application.js

app/assets/javascripts/application.js

//= require jquery
//= require jquery_ujs
//= require bootstrap
//= require turbolinks
//= require_tree .

引入文件的功能是由 Sprockets 实现的,而文件本身是由 5.1.2 节中添加的 bootstrap-sass gem 提供的。

添加了代码 8.24 之后,所有的测试应该都可以通过了:

$ bundle exec rspec spec/

现在登入的用户就可以看到代码 8.24 新添加的链接和下拉菜单了,如图 8.9 所示。

现在你可以验证一下是否可以登录,然后关闭浏览器,再打开看一下是否还是登入的状态。如果需要,你还可以直接查看浏览器的 cookies,如图 8.10 所示。

cookie in browser

图 8.10:查看浏览器中的记忆权标 cookie

8.2.5 注册后直接登录

虽然现在基本完成了用户身份验证功能,但是新注册的用户可能还是会困惑,为什么注册后没有登录呢。在实现退出功能之前,我们还要实现注册后直接登录的功能。我们要先编写测试,在身份验证的测试中加入一行代码,如代码 8.26 所示。这段代码要用到第 7 章一个练习中的“after saving the user” describe 块(参见代码 7.32),如果之前你没有做这个练习的话,现在请添加相应的测试代码。

代码 8.26:测试刚注册的用户是否会自动登录

spec/requests/user_pages_spec.rb

require 'spec_helper'

describe "User pages" do
    .
    .
    .
    describe "with valid information" do
      .
      .
      .
      describe "after saving the user" do
        before { click_button submit }
        let(:user) { User.find_by(email: [email protected]') }

        it { should have_link('Sign out') }
        it { should have_title(user.name) }
        it { should have_selector('div.alert.alert-success', text: 'Welcome') }
      end
    end
  end
end

我们检测页面中有没有退出链接,来验证用户注册后是否登录了。

有了 8.2 节中定义的 sign_in 方法,要让这个测试通过就很简单了:在用户保存到数据库中之后加上 sign_in @user 就可以了,如代码 8.27 所示。

代码 8.27:用户注册后直接登录

app/controllers/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      sign_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end
  .
  .
  .
end

8.2.6 退出

8.1 节中介绍过,我们要实现的身份验证机制会记住用户的登录状态,直到用户点击退出链接为止。本小节,我们就要实现退出功能。

目前为止,Sessions 控制器的动作完全遵从了 REST 架构,new 动作用于登录页面,create 动作实现登录的过程。我们还要添加一个 destroy 动作,删除 session,实现退出功能。针对退出功能的测试,我们可以检测点击退出链接后,页面中是否有登录链接,如代码 8.28 所示。

代码 8.28:测试用户退出

spec/requests/authentication_pages_spec.rb

require 'spec_helper'

describe "Authentication" do
  .
  .
  .
  describe "signin" do
    .
    .
    .
    describe "with valid information" do
      .
      .
      .
      describe "followed by signout" do
        before { click_link "Sign out" }
        it { should have_link('Sign in') }
      end
    end
  end
end

登录功能是由 sign_in 方法实现的,对应的,我们会使用 sign_out 方法实现退出功能,如代码 8.29 所示。

代码 8.29:销毁 session,实现退出功能

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  .
  .
  .
  def destroy
    sign_out
    redirect_to root_path
  end
end

和其他身份验证相关的方法一样,我们会在 Sessions 控制器的帮助方法模块中定义 sign_out 方法。具体的定义如代码 8.30 所示:先修改数据库中保存的记忆权标,以防 cookie 被窃取用来验证用户;然后在 cookies 上调用 delete 方法从 session 中删除记忆权标;最后一行代码是可选的,把当前用户设为 nil。(其实这里没必要把当前用户设为 nil,因为在 destroy 动作中我们加入了转向操作。这里我们之所以这么做是为了兼容不转向的退出操作。)

代码 8.30:Sessions 帮助方法模块中定义的 sign_out 方法

app/helpers/sessions_helper.rb

module SessionsHelper

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.hash(remember_token))
    self.current_user = user
  end
  .
  .
  .
  def sign_out
    current_user.update_attribute(:remember_token,
                                  User.hash(User.new_remember_token))
    self.current_user = nil
    cookies.delete(:remember_token)
  end
end

现在,注册、登录和退出三个功能都实现了,测试也应该可以通过了:

$ bundle exec rspec spec/

有一点需要注意,我们的测试覆盖了身份验证机制的大多数功能,但不是全部。例如,我们没有测试“记住我”到底记住了多久,也没测试是否设置了记忆权标。我们当然可以加入这些测试,不过经验告诉我们,直接测试 cookie 的值不可靠,而且要依赖具体的实现细节,而实现的方法在不同的 Rails 版本中可能会有所不同,即便应用程序可以使用,测试却会失败。所以我们只关注抽象的功能(验证用户是否可以登录,是否可以保持登录状态,以及是否可以退出),编写的测试没必要针对实现的细节。

8.3 Cucumber 简介(选读)

前面两节基本完成了示例程序的身份验证系统,这一节我们将介绍如何使用 Cucumber 编写登录测试。Cucumber 是一个流行的行为驱动开发(Behavior-driven Development, BDD)工具,在 Ruby 社区中占据着一定的地位。本节的内容是选读的,你可以直接跳过,不会影响后续内容。

Cucumber 使用纯文本的故事(story)描述应用程序的行为,很多 Rails 开发者发现使用 Cucumber 处理客户案例时十分方便,因为非技术人员也能读懂这些行为描述,Cucumber 测试可以用于和客户沟通,甚至经常是由客户来编写的。当然,使用不是纯 Ruby 代码组成的测试框架有它的局限性,而且我还发现纯文本的故事很啰嗦。不管怎样,Cucumber 在 Ruby 测试工具中还是有其存在意义的,我特别欣赏它对抽象行为的关注,而不是死盯底层的具体实现。

因为本书着重介绍的是 RSpec 和 Capybara,所以本节对 Cucumber 的介绍很浅显,也不完整,很多内容都没做详细说明,我只是想让你体验一下如何使用 Cucumber,如果你感觉不错,可以阅读专门介绍 Cucumber 的书籍深入学习。(一般我会推荐你阅读 David Chelimsky 的《The RSpec Book》,Ryan Bigg 和 Yehuda Katz 的《Rails 3 in Action》,以及 Matt Wynne 和 Aslak Hellesøy 的《The Cucumber Book》。)

8.3.1 安装和设置

若要安装 Cucumber,需要在 Gemfile:test 组中加入 cucumber-railsdatabase_cleaner 这两个 gem,如代码 8.31 所示。

代码 8.31Gemfile 中加入 cucumber-rails

.
.
.
group :test do
  .
  .
  .
  gem 'cucumber-rails', '1.4.0', :require => false
  gem 'database_cleaner', github: 'bmabey/database_cleaner'
end
.
.
.

然后和之前一样运行一下命令安装:

$ bundle install

如果要在程序中使用 Cucumber,我们先要生成一些所需的文件和文件夹:

$ rails generate cucumber:install

这个命令会在根目录中创建 features 文件夹,Cucumber 相关的文件都会存在这个文件夹中。

8.3.2 功能和步骤定义

Cucumber 中的“功能(feature)”就是希望应用程序实现的行为,使用一种名为 Gherkin 的纯文本语言编写。使用 Gherkin 编写的测试和写的很好的 RSpec 测试用例差不多,不过因为 Gherkin 是纯文本,所以特别适合那些不是很懂 Ruby 代码而可以理解英语的人使用。

下面我们要编写一些 Cucumber 功能,实现代码 8.5 和代码 8.6 中针对登录功能的部分测试用例。首先,我们在 features 文件夹中新建名为 signing_in.feature 的文件。

Cucumber 的功能由一个简短的描述文本开始,如下所示:

Feature: Signing in

然后再添加一定数量相对独立的场景(scenario)。例如,要测试登录失败的情况,我们可以按照如下的方式编写场景:

  Scenario: Unsuccessful signin
    Given a user visits the signin page
    When he submits invalid signin information
    Then he should see an error message

类似的,测试登录成功时,我们可以加入如下的场景:

  Scenario: Successful signin
    Given a user visits the signin page
    And the user has an account
    When the user submits valid signin information
    Then he should see his profile page
    And he should see a signout link

把上述的文本放在一起,就组成了代码 8.32 所示的 Cucumber 功能文件。

代码 8.31:测试用户登录功能

features/signing_in.feature

Feature: Signing in

  Scenario: Unsuccessful signin
    Given a user visits the signin page
    When he submits invalid signin information
    Then he should see an error message

  Scenario: Successful signin
    Given a user visits the signin page
    And the user has an account
    When the user submits valid signin information
    Then he should see his profile page
    And he should see a signout link

然后使用 cucumber 命令运行这个功能:

$ bundle exec cucumber features/

上述命令和执行 RSpec 测试的命令类似:

$ bundle exec rspec spec/

提示一下,Cucumber 和 RSpec 一样,可以通过 Rake 命令执行:

$ bundle exec rake cucumber

(鉴于某些原因,我经常使用的命令是 rake cucumber:ok。)

我们只是写了一些纯文本,所以毫不意外,Cucumber 场景现在不会通过。若要让测试通过,我们要新建一个步骤定义文件,把场景中的纯文本和 Ruby 代码对应起来。步骤定义文件存放在 features/step_definition 文件夹中,我们要将其命名为 authentication_steps.rb

FeatureScenario 开头的行基本上只被视作文档,其他的行则都要和 Ruby 代码对应。例如,功能文件中下面这行

Given a user visits the signin page

对应到步骤定义中的

Given //ˆa user visits the signin page$/ do
  visit signin_path
end

在功能文件中,Given 只是普通的字符串,而在步骤定义中 Given 则是一个方法,可以接受一个正则表达式作为参数,后面还可以跟着一个块。Given 方法的正则表达式参数是用来匹配功能文件中某个特定行的,块中的代码则是实现描述的行为所需的 Ruby 代码。本例中的“a user visits the signin page”是由下面这行代码实现的:

visit signin_path

你可能觉得这行代码很眼熟,不错,这就是前面用过的 Capybara 提供的方法,Cucumber 的步骤定义文件会自动引入 Capybara。接下来的两行代码实现也同样眼熟。如下的场景步骤:

When he submits invalid signin information
Then he should see an error message

对应到步骤定义文件中的

When //^he submits invalid signin information$/ do
  click_button "Sign in"
end

Then //^he should see an error message$/ do
  expect(page).to have_selector('div.alert.alert-error')
end

上面这段代码的第一步还是用了 Capybara,第二步则结合了 Capybara 的 page 和 RSpec。很明显,之前我们使用 RSpec 和 Capybara 编写的测试,在 Cucumber 中也是有用武之地的。

场景中接下来的步骤也可以做类似的处理。最终的步骤定义文件如代码 8.33 所示。你可以一次只添加一个步骤,然后执行下面的代码,直到测试都通过为止:

$ bundle exec cucumber features/

代码 8.32:使登录功能通过的步骤定义

features/step_definitions/authentication_steps.rb

Given //^a user visits the signin page$/ do
  visit signin_path
end

When //^he submits invalid signin information$/ do
  click_button "Sign in"
end

Then //^he should see an error message$/ do
  expect(page).to have_selector('div.alert.alert-error')
end

Given //^the user has an account$/ do
  @user = User.create(name: "Example User", email: "[email protected]",
                      password: "foobar", password_confirmation: "foobar")
end

When //^the user submits valid signin information$/ do
  fill_in "Email",    with: @user.email
  fill_in "Password", with: @user.password
  click_button "Sign in"
end

Then //^he should see his profile page$/ do
  expect(page).to have_title(@user.name)
end

Then //^he should see a signout link$/ do
  expect(page).to have_link('Sign out', href: signout_path)
end

添加了代码 8.33,Cucumber 测试应该就可以通过了:

$ bundle exec cucumber features/

8.3.3 小技巧:自定义 RSpec 匹配器

编写了一些简单的 Cucumber 场景之后,我们来和相应的 RSpec 测试用例对比一下。先看一下代码 8.32 中的 Cucumber 功能和代码 8.33 中的步骤定义,然后再看一下如下的 RSpec 集成测试:

describe "Authentication" do

  subject { page }

  describe "signin" do
    before { visit signin_path }

    describe "with invalid information" do
      before { click_button "Sign in" }

      it { should have_title('Sign in') }
      it { should have_selector('div.alert.alert-error') }
    end

    describe "with valid information" do
      let(:user) { FactoryGirl.create(:user) }
      before do
        fill_in "Email",    with: user.email.upcase
        fill_in "Password", with: user.password
        click_button "Sign in"
      end

      it { should have_title(user.name) }
      it { should have_link('Profile',     href: user_path(user)) }
      it { should have_link('Sign out',    href: signout_path) }
      it { should_not have_link('Sign in', href: signin_path) }
    end
  end
end

由此你大概就可以看出 Cucumber 和集成测试各自的优缺点了。Cucumber 功能可读性很好,但是却和测试代码分隔开了,同时削弱了功能和测试代码的作用。我觉得 Cucumber 测试读起来很顺口,但是写起来怪怪的;而集成测试读起来不太顺口,但是很容易编写。

Cucumber 把功能描述和步骤定义分开,可以很好的实现抽象层面的行为。例如,下面这个描述

Then he should see an error message

表达的意思是,期望看到一个错误提示信息。如下的步骤定义则检测了能否实现这个期望:

Then //^he should see an error message$/ do
  expect(page).to have_selector('div.alert.alert-error')
end

Cucumber 这种分离方式特别便捷的地方在于,只有步骤定义是依赖具体实现的,所以假如我们修改了错误提示信息所用的 CSS class,功能描述文件是不需要修改的。

那么,如果你只是想检测页面中是否显示有错误提示信息,就不想在多个地方重复的编写下面的测试:

should have_selector('div.alert.alert-error')

如果你真的这么做了,就把测试和具体的实现绑死了,一旦改变了实现方式,就要到处修改测试。在 RSpec 中,可以自定义匹配器来解决这个问题,我们可以直接这么写:

should have_error_message('Invalid')

我们可以在 5.3.4 节 中定义 full_title 测试帮助方法的文件中定义这个匹配器,代码如下:

RSpec::Matchers.define :have_error_message do |message|
  match do |page|
    expect(page).to have_selector('div.alert.alert-error', text: message)
  end
end

我们还可以为一些常用的操作定义帮助方法,例如:

def valid_signin(user)
  fill_in "Email",    with: user.email
  fill_in "Password", with: user.password
  click_button "Sign in"
end

最终的文件如代码 8.34 所示(把 5.6 节中的代码 5.38 和代码 5.39 合并了)。我觉得这种方法比 Cucumber 的步骤定义还要灵活,特别是当匹配器和帮助方法可以接受一个参数时,例如 valid_signin(user)。我们也可以用步骤定义中的正则表达式匹配来实现这种功能,不过太过繁杂。

代码 8.33:添加一个帮助函数和一个 RSpec 自定义匹配器

spec/support/utilities.rb

include ApplicationHelper

def valid_signin(user)
  fill_in "Email",    with: user.email
  fill_in "Password", with: user.password
  click_button "Sign in"
end

RSpec::Matchers.define :have_error_message do |message|
  match do |page|
    expect(page).to have_selector('div.alert.alert-error', text: message)
  end
end

添加了代码 8.34 之后,我们就可以直接写

it { should have_error_message('Invalid') }

describe "with valid information" do
  let(:user) { FactoryGirl.create(:user) }
  before { valid_signin(user) }
  .
  .
  .

还有很多测试用例把测试和具体的实现绑缚在一起了,我们会在 8.5 节的练习中彻底的搜查现有的测试组件,使用自定义匹配器和帮助方法解耦测试和具体实现。

8.4 小结

本章我们介绍了很多基础知识,也为稍显简陋的应用程序实现了注册和登录功能。实现了用户身份验证功能后,我们就可以根据登录状态和用户的身份限制对特定页面的访问权限。在实现限制访问的过程中,我们会为用户添加编辑个人信息的功能,还会为管理员添加删除用户的功能。这些是第 9 章的主要内容。

在继续阅读之前,先把本章的改动合并到主分支吧:

$ git add .
$ git commit -m "Finish sign in"
$ git checkout master
$ git merge sign-in-out

然后再推送到 GitHub 和 Heroku “生产环境”服务器:

$ git push
$ git push heroku
$ heroku run rake db:migrate

8.5 练习

  1. 重构登录表单,把 form_for 换成 form_tag,确保测试还是可以通过的。提示:可以参照 Railscasts 第 270 集《Authentication in Rails 3.1》,特别留意一下 params Hash 结构的变化。

  2. 参照 8.3.3 节中的示例,遍览用户和身份验证相关的集成测试,在 spec/support/utilities.rb 中定义帮助函数,解耦测试和具体实现。附加题:把这些帮助方法放到不同的文件和模块中,然后再引入相应的模块。

  1. 另外一个常见的 session 处理方式是,在一定时间之后失效。这种方式特别适合包含敏感信息的网站,例如银行和交易账户。

  2. 图片来自 http://www.flickr.com/photos/hermanusbackpackers/3343254977/

  3. 我选择这么生成记忆权标是因为看了 Railscasts 第 274 集《Remember Me & Reset Password》。

  4. Active Record 支持的其他回调函数在 Rails 指南中有介绍。

  5. 译者注:其实 private 是方法而不是关键字,请参阅《Ruby 编程语言》P233

  6. 其实这两种方式是完全等效的,attr_accessor 会自动创建取值和设定方法。

  7. 译者注:如果按照 bbatsov 的《Ruby 编程风格指南》来编写 Ruby 代码的话,就没必要多加一层缩进。

  8. 如果方法不需要在实例上调用,就要定义为类方法。

  9. 这也是一种备忘(memoization),详情参见旁注 6.3

  10. 一般来说,这句话的意思是把初始值为 nil 的变量附上了新值,不过 ||= 也会把初始值为 false 的变量附上新值。

  11. 译者注:这里对 ||= 的分析和 Peter Cooper 的分析有点差异,我推荐你看以下 Ruby Inside 中的《What Ruby’s ||= (Double Pipe / Or Equals) Really Does》一文。

  12. 浏览器其实并不能发送 DELETE 请求,Rails 是通过 JavaScript 模仿的。