<body><script type="text/javascript"> function setAttributeOnload(object, attribute, val) { if(window.addEventListener) { window.addEventListener('load', function(){ object[attribute] = val; }, false); } else { window.attachEvent('onload', function(){ object[attribute] = val; }); } } </script> <div id="navbar-iframe-container"></div> <script type="text/javascript" src="https://apis.google.com/js/platform.js"></script> <script type="text/javascript"> gapi.load("gapi.iframes:gapi.iframes.style.bubble", function() { if (gapi.iframes && gapi.iframes.getContext) { gapi.iframes.getContext().openChild({ url: 'https://www.blogger.com/navbar.g?targetBlogID\x3d3443179681250049771\x26blogName\x3dNewbie+On+Rails\x26publishMode\x3dPUBLISH_MODE_BLOGSPOT\x26navbarType\x3dBLACK\x26layoutType\x3dCLASSIC\x26searchRoot\x3dhttps://l404.blogspot.com/search\x26blogLocale\x3dzh_CN\x26v\x3d2\x26homepageUrl\x3dhttp://l404.blogspot.com/\x26vt\x3d-4382633446460227058', where: document.getElementById("navbar-iframe-container"), id: "navbar-iframe" }); } }); </script>

Newbie On Rails

方便初学Rails的朋友们快速上手并驶入BDD敏捷之道。


### 温故知新 ###


在前面的六个章节中,我们循序渐进地完善了一个用户帐号系统,这样的系统一般都会作为一个独立的模块交付。在交付这个模块之前,还需要进一步地做些重构工作。在这篇文章中,笔者将会围绕测试重构展开。

源码下载:http://github.com/404/bdd_user_demo


### 主要内容 ###

    1. 测试环境本地化;
    2. 归类 steps;
    3. 用 Factory_girl 代替 fixtures;
    4. Steps Within Steps;
    5. Helpers


### 新建工作分支 ###

$ git checkout -b refactoring001


### 本地化测试环境 ###

$ gedit lib/tasks/cucumber.rake

修改第 5 行,

   t.cucumber_opts = "--format pretty --language zh-CN"

然后运行测试的时候就可以不用指定语言参数了,系统可以自动识别并读取我们用简体中文编写的故事。

(不过,目前的 Cucumber 中,如果你编写的故事场景名称不是英文,好像不能识别场景名称;所以,在测试单个文件(*.feature)的时候,还需要加上 -l 参数。)

可以运行下面的命令检测设置是否生效。

$ rake features




### 组织结构良好的测试脚本 ###

Cucumber 默认会加载 features/step_definitions/ 这个目录中所有的 *_steps.rb,如果读者朋友们稍微留心一点,就会很容易察觉到将一些公用方法放在 user_steps.rb 中算不上明智之举,因为那样的话感觉 user_steps.rb 有些杂乱。

明智的做法是将这公用方法单独放入一个 *_steps.rb 文件中。

$ gedit features/step_definitions/page_steps.rb

从 user_steps.rb 中剪切如下代码并作为 page_steps.rb 的填充,

When /^我来到(.+)$/ do |page_name|
  visit path_to(page_name)
end
 
When /^我在输入框<(.+)>中输入<(.*)>$/ do |field, value|
  fill_in(field, :with => value)
end
 
When /^我勾选<(.+)>$/ do |field|
  check(field)
end
 
When /^我按下<(.+)>按钮$/ do |button|
  click_button(button)
end
 
Then /我应该看到<(.+)>的提示信息/ do |msg|
  response.body.should =~ Regexp.new(msg)
end


开发人员应该尽量拆散一些杂乱的测试脚本文件,使得每一个测试脚本文件看起来干干净净,清晰明了。


### Factory_girl 初步之装载测试数据 ###

Factory_girl 是一个绝佳的fixtures替代品。fixtures 即Rails单元测试中内置的测试夹具,用来放一些测试数据;以前用单元测试构建测试数据的时候,都是在 test/fixtures/ 目录中新建YAML文件,并在这些YAML文件中按YAML的语法格式编写测试数据。而用上 Factory_girl 后,你可以直接用Ruby的语法编写测试数据;一是加快了测试速度,二来对程序员的大脑也友好些;而且 Factory_girl 能做的不仅仅是填充一些测试数据,还可以对这些数据进行灵活的变换以适应开发人员的需要。更多 Factory_girl 的信息请查阅该项目在 GitHub 上的主页:http://github.com/thoughtbot/factory_girl

可以用 gem 命令安装 factory_girl,

$ gem install thoughtbot-factory_girl --source http://gems.github.com

然后在你的环境配置文件中绑定这个gem包。由于factory_girl是拿来做测试用,所以将该gem包绑定在测试环境的配置文件中。

$ gedit config/environments/test.rb

添加如下代码,

  config.gem "thoughtbot-factory_girl", :lib => "factory_girl", :source => "http://gems.github.com"

按照文档上所说的,Factory_girl可以自动加载建立在 test/ 和 spec/ 目录中的测试数据。我们不妨在 spec/ 目录中新建一个 factories 目录,并在这个目录中放置所需的测试数据。

$ mkdir spec/factories

$ gedit spec/factories/user.rb


填充如下代码,

Factory.define :static_user, :class => User do |user|
  user.username              { '404' }
  user.email                 { 'xuliicom@gmail.com' }
  user.password              { 'password' }
  user.password_confirmation { 'password' }
end


如上,我们定义了一个 Factory,这个 Factory 的名字叫 :static_user, :static_user 代表的是一个instance;在 Factory.define 的第二个参数中,我们用迭代器的方式构造了一个User 模型类的实例,这个实例可以通过 :static_user 来标识。

接下来,我们要在测试代码中调用 :static_user 这个 instance 所包含的内容(即测试数据)。

$ gedit spec/models/user_spec.rb

找到如下这段代码,

  before(:each) do
    @valid_attributes = {
      :username               => '404',
      :email                  => 'xuliicom@gmail.com',
      :password               => 'password',
      :password_confirmation  => 'password'
    }
    @user = User.new(@valid_attributes)
  end


将 before(:each) do ... end 这段代码替换如下,

  before(:each) do
    @user = Factory.build(:static_user)
  end


上面两段代码的效果是一样的。使用第二种方式,我们将测试数据建立在了测试代码之外,并通过一行代码就将测试数据搬到测试脚本中来了,我们可以在任何测试文件中以这样的方式来“搬运”测试数据。显然,读者朋友们已经尝到了使用Factory_girl的第一个甜头,那就是测试数据 “一次定义,多处可用”。上面的代码中,Factory.build 创建了我们在用测试数据填充的 UserModel 实例对象。如果省去 build 方法直接用 Factory(:static_user) 这种形式还会多一个save操作,不过在此我们只需要在测试的时候内容中有这么一个数据就行了,所以才用 build 方法。

接着,找到如下这段代码,

  it "should have a unique username and password" do
    @first_user = User.create!(@valid_attributes)
    @second_user = User.new(@valid_attributes)
    @second_user.should_not be_valid
    @second_user.should have(1).errors_on(:username)
    @second_user.should have(1).errors_on(:email)
  end


修改为,

  it "should have a unique username and password" do
    @first_user = Factory.create(:static_user)
    @second_user = @user
    @second_user.should_not be_valid
    @second_user.should have(1).errors_on(:username)
    @second_user.should have(1).errors_on(:email)
  end


Factory.create() 和 Factory() 方法都会新建记录并执行保存操作,然后返回保存后的实例对象;另外,Factory()方法还可以传入hash参数修改已定义的属性值。在上面修改后的代码中,@first_user 指向的是一个已经保存过的User实例,@second_user试图使用同样的数据新建同样的记录;记得前面我们在 UserModel 类中加入过 username 和 email 必须唯一的验证,那么理论上@second_user的行为应该不会得逞,所以 @second_user.should_not be_valid 及后面两句断言应该能够顺利运行,不妨运行下测试看看。

$ ruby script/spec spec/models/user_spec.rb



测试通过!我们再来找找其他的测试代码可以用上 Factory 的地方。

$ gedit features/step_definitions/user_steps.rb

找到如下这段代码,

Given /^我已经使用<(.*)\/(.*)\/(.*)>注册过(且已经激活了帐号)?$/ do |username, email, password, confirm|
  @valid_attributes = {
    :username              => username,
    :email                 => email,
    :password              => password,
    :password_confirmation => password
  }
  @user = User.create!(@valid_attributes)
  if confirm
    @user.activation_token = nil
    @user.save(false)
  end
end


修改为,

Given /^我已经使用<(.*)\/(.*)\/(.*)>注册过(且已经激活了帐号)?$/ do |username, email, password, confirm|
  @user = Factory :static_user,
    :username              => username,
    :email                 => email,
    :password              => password,
    :password_confirmation => password 

  if confirm
    @user.activation_token = nil
    @user.save(false)
  end
end


在上面的那段代码中,我们用测试脚本中的参数结合Factory() 方法修改了 :static_user 这个实例的属性值。

运行测试,

$ rake features



测试通过!其实这段代码我们还可以进行重构,因为 if confirm .. end 中间还有两行代码,而且其中有一行代码还是赋值操作,不仅修改了数据,后一句代码还对修改的数据记录执行了更新操作;这就是说我们在测试脚本中用ActiveRecord的方式操作了数据。那么,既然是跑测试,为了测试脚本的干净利落,我们是不是应该尽量减少使用测试脚本直接地操作数据,转而用定义测试数据的方式实现呢?如果是,那么像这样一个“问题”您能想到好的解决方案吗?暂且留给读者朋友们思考,如果您有好的想法欢迎在下面留言!


### Steps Within Steps ###

正如我们在第6章所说的那样,Cucumber的运行以故事场景为单位,这些故事场景都是彼此独立的;上一个场景的执行结果不会在下一个(或其他)场景中有效。如果要在场景B中用到场景A中的情节步骤,就需要在场景B中重复定义场景A所用的情节(至少在测试代码里边包含针对这一复用情节的相关脚本)。我们在前面编写的故事用例中,大多数场景都包含相同的情节,比如下面这句:

当 我以<xuliicom@gmail.com/password>这个身份登录

这个场景子句还是一组复合语句,即 steps within steps 嵌套模式,可以打开 features/step_definitions/user_steps.rb 文件查阅这个子句的具体实现。如下代码,

When /^我以<(.+)\/(.+)>这个身份登录(并勾选<记住我>)?$/ do |username_or_email, password, remember|
  当 %{我来到用户登录页面}
  而且 %{我在输入框<用户名或邮箱>中输入<#{username_or_email}>}
  而且 %{我在输入框<密码>中输入<#{password}>}
  而且 %{我勾选<记住我>} if remember
  而且 %{我按下<登录>按钮}
end


上面这段代码折叠后仅仅一行而已,如果不折叠直接放到故事场景中去,又加上又是频繁使用的故事情节,想必会增加一些工作量。当功能越来越多的时候,如果不使用 steps within steps 这种模式,我们的手指就得多敲击几次键盘,故事的行数也会明显增加而且增加的都是相同步骤,那样也坏了DRY(Don't Repeat Youself)的规矩。所以,建议在编写一些出现频率较高的故事情节时,适当地折叠一下!

举个例子,用户在站点的很多操作(比如修改个人资料,发帖等等)都必须是在线状态,用一句话概况就是 “先登录,后操作”。“操作”有若干项,在操作之前,用户登录一次即可。这里的“操作”好比一些将要开发的新功能(比如发帖等)。那么在编写故事用例的时候,场景中免不了用户登录这一情节。结合前面我们编写故事场景的方式,发贴的某个场景应该像这样定义,

    场景: 登录用户发帖
      假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
      当 我以<xuliicom@gmail.com/password>这个身份登录
      那么 我应该看到<登录成功>的提示信息
      而且 我应该成功登录网站
      当 我来到发布新帖页面
      而且 我在...
      ...


这个场景中仅仅构造用户登录的就有4个子句,即4个步骤或者说4个情节。用一个词来形容,繁琐。当新增的功能越来越多,登录用户要操作这些功能的时候,我们要编写的故事场景个个都会肥胖无比。之所以把需求搬上测试上,就是要让需求更易懂且可行。所以,需要瘦身!“减肥”后的故事如下,

    场景: 登录用户发帖
      假如 我已经登录
      当 我来到发布新帖页面
      而且 我在...
      ...


这时候虚拟出一个已经登录的用户仅仅只有一行字而已。正所谓浓缩的才是精华,下面笔者就来揭示浓缩精华的秘密。

$ gedit features/step_definitions/user_steps.rb

添加如下代码,

Given /^我已经登录$/ do
end


$ gedit spec/factories/user.rb

添加如下代码,

Factory.sequence :username do |n|
  "test_user#{n}"
end

Factory.sequence :email do |n|
  "test_user#{n}@example.com"
end

Factory.define :user do |user|
  user.username              { Factory.next :username}
  user.email                 { Factory.next :email }
  user.password              { 'password' }
  user.password_confirmation { 'password' }
end


Factory.sequence 会生成序列,即给传入的参数构造唯一值。在上面的代码中,我们分别给 :username 和 :email 创建了序列,那么当用 Factory.next 访问的时候,每次 :username 和 :email 的值都是不同且唯一的,这更接近真实世界中的(注册)行为。

$ gedit features/step_definitions/user_steps.rb

修改 Given /^我已经登录$/ do end 如下,

Given /^我已经登录(并勾选<记住我>)?$/ do |remember|
  @user = Factory(:user)
  @user.activation_token = nil
  @user.save(false)
  当 %{我以<#{@user.email}/#{@user.password}>这个身份登录#{remember}}
  那么 %{我应该看到<登录成功>的提示信息}
  而且 %{我应该成功登录网站}
end


尽管这种写法看起来稍微丑陋,一半是数据操作,另一半是嵌套的情节调用;但却是简化了不少操作,尤其是用户注册和激活功能。不妨来实际应用一下,看看是否生效。

$ gedit features/user_logout.feature

修改后的故事用例如下,

功能: 用户安全退出
  1.提供一个“退出”链接,用户登录后点击该链接可以注销在线状态;
  2.用户登录并勾选记住我后,点击“退出”链接可以注销在线状态,下次访问的时候将不再自动登录。

  场景: 用户注销在线状态
    假如 我已经登录
    当 我退出网站
    那么 我应该看到<您已经安全退出>的提示信息
    而且 我应该尚未登录

  场景: 用户在持久在线状态下退出
    假如 我已经登录并勾选<记住我>
    当 我退出网站
    那么 我应该看到<您已经安全退出>的提示信息
    而且 我应该尚未登录
    当 我关闭网页下次再来访问的时候
    那么 我应该尚未登录


运行测试,

$ ruby script/cucumber -l zh-CN features/user_logout.feature




### 给测试脚本加上 Helper ###

user_steps.rb 文件中 “ Given /^我已经登录(并勾选<记住我>)?$/ {...} ” 这段脚本嵌套了其他的 steps 语句,其中有些 steps 还包含其他 steps 子句。很容易看出这些 steps 嵌套的层级较深,这样会聚合大量的正则匹配操作,这些正则匹配是计算时间成本的,多了有损测试速度。所以可以考虑将这些 steps 打回原形,直接用编码实现。如下代码,

Given /^我已经登录(并勾选<记住我>)?$/ do |remember|
  @user = Factory(:user)
  @user.activation_token = nil
  @user.save(false)
  visit login_path
  fill_in "用户名或邮箱", :with => @user.email
  fill_in "密码", :with => @user.password
  check "记住我" if remember
  click_button "登录"
  response.body.should =~ /登录成功/
  request.session[:user_id].should_not be_nil

end


保存。运行测试,

$ rake features



测试通过!在常见的WEB应用用,用户在执行某些操作前一般都要求登录。将这些操作对应到测试脚本中,所以登录行为很容易地被看成公共的steps。前面讲过,应该尽量将公共操作放到指定的测试脚本中;因此,上面那段代码还可以再灵活些。只不过,这次会介绍一种新的方式,即使用 Cucumber 的 Helper 模式。

前面我们在用 Factory_girl 组织测试数据的时候学习到了一个好处,那就是 “一次定义,多处使用” 。这个理念非常经典,同样也是 Rails 提倡的 DRY 的原则之一,Cucumber 的 Helper 也体现了这一经典妙用!

Cucumber 开放了一个接口,可以集成开发人员以Module方式组织的辅助方法(helper methods),如同在 Rails 中编写 helpers 一样。在 Rails 的 ApplicationHelper 模块中编写的辅助方法可以在任何页面模板中使用;在 Cucumber 中编写的 helper methods 则可以在Cucumber的任何测试脚本(*_steps.rb)中使用。编写辅助方法的详细教程可参阅:http://wiki.github.com/aslakhellesoy/cucumber/a-whole-new-world

下面,笔者来演示这样一个例子。

$ gedit features/step_definitions/user_steps.rb

修改 Given /^我已经登录(并勾选<记住我>)?$/ do end 这段代码如下,

Given /^我已经登录(并勾选<记住我>)?$/ do |remember|
  test_login!(remember)
end

def test_login!(remember = nil)
  user = Factory(:user)
  user.activation_token = nil
  user.save(false)
  visit login_path
  fill_in "用户名或邮箱", :with => user.email
  fill_in "密码", :with => user.password
  check "记住我" if remember
  click_button "登录"
  response.body.should =~ /登录成功/
  request.session[:user_id].should_not be_nil
end


保存 user_steps.rb。正常情况下,这时候要死运行测试应该会成功。

接下来得将 test_login! 这个方法注册到 helper methods 中去,让任意 *_steps.rb 文件中的脚本都可以调用。

先删除 user_steps.rb 文件中的 test_login! 方法,我们会在 Helper 中重新定义。

$ gedit features/support/user_helpers.rb

module UserHelpers

  def test_login!(remember = nil)
    user = Factory(:user)
    user.activation_token = nil
    user.save(false)
    visit login_path
    fill_in "用户名或邮箱", :with => user.email
    fill_in "密码", :with => user.password
    check "记住我" if remember
    click_button "登录"
    response.body.should =~ /登录成功/
    request.session[:user_id].should_not be_nil
  end

end

World { |world| world.extend(UserHelpers) }


这样Cucumber 运行时,World 将会包含 UserHelpers 模块中的方法,即 test_login! 成了可以在任意测试文件中(*_steps.rb)公用的辅助方法。

保存 features/support/user_helpers.rb。运行测试,

$ ruby script/cucumber -l zh-CN features/user_logout.feature




### 小结 ###

参与重构工作往往可以让开发人员在意识上踏上一个新的台阶,尤其是对于新手,更是眼前一亮,醍醐灌顶,乃至获得经验上的提升。往后的测试中,想必读者朋友们已经学到如何归类组织steps以及适当地折叠;还知道在什么情况下如何编写 Helper;当然,需要的话,别忘了捎上几名工厂妹(Factory_girl)。


### 提交工作成果到GIT仓库 ###

$ git status
$ git add .
$ git commit -m "First refactoring."
$ git checkout master
$ git merge refactoring001
$ git branch -d refactoring001
$ git tag v7


(注意,真正的开发中可不是到功能开发完毕了才commit,而是边开发边add和commit。为了方便演示编码过程,文章中没有一一列举。)

标签: , , ,


### 温故知新 ###


用户登出后,过了一段时间再次登录的时候,有时候会忘记密码,这时候系统就得有个找回密码的功能,可以让用户在不用登录的情况下重设密码。对于一个存在的帐号,有且只有一个用户可以修改密码,这个用户必须是此帐号的拥有者;那么,系统怎么知道这个用户就是该帐号的所有者呢?

答案是通过用户注册时填写的电子邮件来重建帐号和用户之间的关联。试想,如果一个用户曾经注册过,他必须填写了有效的电子邮件地址,而且还通过这个邮箱激活过帐号。那么,当注册用户忘记密码后,我们依然可以借助用户注册时填写的邮箱如法炮制,即发送一封带有重设密码链接的邮件,这个链接是唯一的,同样具有有效期,如果用户查收邮件并在有效的时间内访问了那个链接,那么将用户导向重设密码的页面,用户就可以更新密码了。

为了获得更好的阅读体验,读者朋友们可以在这里下载源码:http://github.com/404/bdd_user_demo/tree/master


### 新建工作分支 ###

$ git checkout -b reset_password


### 找回密码功能 ###

  1.用户点击“忘记密码?”链接,来到忘记密码页面;
  2.用户在忘记密码页面的输入框中输入注册时用的邮箱后,系统发送一封找回密码的邮件到该邮箱中;
  3.用户点击邮件中重设密码的链接来到重设密码页面;
  4.用户在重设密码页面输入新密码,确认无误提交后,系统将此新密码更新为用户的密码。



### 编写故事用例 ###

$ gedit features/reset_password.feature

经过前面几次的练习,读者朋友们想必已经具备使用Cucumber玩转Testing的实际开发经验了。笔者下面就不一一去写和测试每一个故事了(如果还不是很熟练,可以参考第3章的介绍的迭代开发方式),而是直接将可能出现的场景全部写了下来。

功能: 重设密码
  为了用户在忘记密码后能够找回密码
  作为一个注册用户
  我应该可以重设密码
 
  场景: 未注册用户请求重设密码
    假如 没有<somebody@somedomain.com>这个用户
    当 我使用邮箱<somebody@somedomain.com>来找回密码
    那么 我应该看到<没有找到somebody@somedomain.com这个用户>的提示信息

  场景: 注册用户请求重设密码
    假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
    当 我使用邮箱<xuliicom@gmail.com>来找回密码
    那么 我应该看到<邮件发送成功!>的提示信息
    而且 应该有封重设密码的邮件发送至<xuliicom@gmail.com>

  场景: 用户重设密码但确认密码输入错误
    假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
    当 我使用邮箱<xuliicom@gmail.com>来找回密码
    而且 我访问<xuliicom@gmail.com>邮件中重设密码的链接
    而且 我更新密码为<newpassword/wrong_password>
    那么 我应该看到<密码更新失败>的提示信息
    而且 我应该尚未登录

  场景: 用户成功重设密码
    假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
    当 我使用邮箱<xuliicom@gmail.com>来找回密码
    而且 我访问<xuliicom@gmail.com>邮件中重设密码的链接
    而且 我更新密码为<newpassword/newpassword>
    那么 我应该看到<密码更新成功>的提示信息
    而且 我应该成功登录网站
    当 我退出网站
    而且 我以<xuliicom@gmail.com/newpassword>这个身份登录
    那么 我应该成功登录网站


在上面一系列的故事用例中,我们反复定义了 “当 我使用邮箱<xuliicom@gmail.com>来找回密码” 这一情节步骤。这里解释一下缘由:我们编写的每个故事场景都是相互独立的,这意味着上一个场景返回的结果不会在下一个场景中有效;如果要在下一个故事场景中用到上面故事场景中的步骤,就需要在这个故事场景中重复定义上面场景所用的情节(至少在测试代码里边包含针对这一重复情节的相关脚本);这里复用的情节好比是函数中的局部变量,其作用域只在当前故事情节中有效而已。每一个故事场景的运行或多或少的覆盖了用于和用户产生交互的终端页面,包括处理业务逻辑的控制器,甚至和数据模型交互这些,相当于一次集成测试;我们编写的下一个故事场景会在上一个故事的基础上添加一些情节,通过运行这一系列连贯的故事场景,我们的开发工作在覆盖已有功能的基础上跟随这些迭代的集成测试同行。这也正是“测试-驱动-开发”的威力所在。感谢Cucumber的故事运行机制能够如此方便地帮助我们做到这点!

保存 reset_password.feature。运行测试,

$ ruby script/cucumber -l zh-CN features/reset_password.feature




### 编写故事用例运行所需的测试脚本 ###

$ gedit features/step_definitions/user_steps.rb

添加如下代码,

# reset password

When /^我使用邮箱<(.+)>来找回密码$/ do |email|
  当 %{我来到找回密码的页面}
  而且 %{我在输入框<email>中输入<#{email}>}
  而且 %{我按下<发送>按钮}
end

Then /^应该有封重设密码的邮件发送至<(.+)>$/ do |email|
  user = User.find_by_email(email)
  user.reset_password_token.should_not be_blank
  sent = ActionMailer::Base.deliveries.last
  sent.to.should eql([user.email])
  sent.subject.should =~ /重设/
  sent.body.should =~ /#{user.reset_password_token}/
end

When /^我访问<(.*)>邮件中重设密码的链接$/ do |email|
  user = User.find_by_email(email)
  visit edit_user_password_url(user.id, :token => user.reset_password_token)
end

When /^我更新密码为<(.+)\/(.+)>$/ do |new_password, new_password_confirmation|
  而且 %{我在输入框<user_password>中输入<#{new_password}>}
  而且 %{我在输入框<user_password_confirmation>中输入<#{new_password_confirmation}>}
  而且 %{我按下<更新密码>按钮}
end


保存user_steps.rb。运行测试,

$ ruby script/cucumber -l zh-CN features/reset_password.feature



测试结果告诉我们接下来应该该做些什么;首先,需要让测试程序能够导向找回密码的页面,这个页面及其相关的路由配置尚无;还有未定义reset_password_token等等。任务不少,我们先从比较简单的找回密码页面开始。


### 配置路由 ###

$ gedit features/support/paths.rb

修改 path_to 方法,续添一条找回密码页面的访问路径,

def path_to(page_name)
  case page_name
 
  when /首页/
    root_path
  when /用户注册页面/
    signup_path
  when /用户登录页面/
    login_path
  when /找回密码的页面/
    new_password_path

 
  # Add more page name => path mappings here
 
  else
    raise "Can't find mapping from \"#{page_name}\" to a path."
  end
end


保存paths.rb。继续在config/routes.rb文件中定义new_password_path,

$ gedit config/routes.rb

修改map.resources如下,

  map.resources :users, :has_one => :password
  map.resources :sessions,:passwords


注意,passwords资源是一组嵌套路由,隶属于users资源;如果读者朋友们对 Restful 不陌生,想必一定很好理解。


### 编写找回密码的业务流程 ###

$ ruby script/generate controller Passwords new create edit update

new 方法用来渲染找回密码页面;
create 方法用来发送重设密码的邮件;
edit 方法用来渲染修改密码页面;
update 方法更新用户密码。

修改找回密码页面,

$ gedit app/views/passwords/new.html.erb

<h1>找回密码</h1>

<p>请输入您注册时使用的电子邮箱,系统会发送一封关于重设密码的邮件到您的邮箱中。</p>

<% form_tag passwords_path do %>
  <p>
    <%= label_tag 'email', '电子邮箱' %>
    <%= text_field_tag 'email' %>
  </p>
  <p>
    <%= submit_tag '发送' %>
  </p>
<% end %>


保存app/views/passwords/new.html.erb。修改PasswordsController,在create方法中添加一些业务逻辑,

$ gedit app/controllers/passwords_controller.rb


  def create
    user = User.find_by_email(params[:email])
    if user.nil?
      flash.now[:notice] = "没有找到#{params[:email]}这个用户"
      render :action => :new
    else
      user.forgot_password!
      flash[:notice] = "邮件发送成功!请注意查收。"
      redirect_to login_path
    end
  end


保存app/controllers/passwords_controller.rb。运行测试,

$ ruby script/cucumber -l zh-CN features/reset_password.feature



没有找到User实例对象的forgot_password!方法,因为我们还没有在UserModel中定义该方法;该方法在用户忘记密码提交Email后会生成一份用于重设密码链接的标识码。标识码不仅是构成密码重设链接的一部分,而且还将存入数据库。当用户访问密码重设链接的时候,系统会校验数据库里边的标识码和链接中的标识码是否一致,如果一致则呈现给用户重设密码页面。

下面我们修改UserModel添加这个forgot_password!方法。

$ gedit app/models/user.rb

  # 生成忘记密码后的标识码
  # 且标识码在24小时后失效
  def forgot_password!
    self.reset_password_token = generate_token
    self.reset_password_token_expires_at = 24.hours.from_now
    save(false)
  end


我们知道在Ruby的类中,self调用的方法是类方法,self调用的属性是已定义过的实例变量。很明显,上述的forgot_password!方法中self调用的明显是实例变量。要在UserModel类中定义这两个实例变量非常简单,Rails的ActiveRecord会自动将模型类所对应的数据表的字段注册为该模型类的实例变量,基于AR带给我们开发者的一些便利,我们给users表新增这两个字段即可;还记得前面我们说过要将标识码的相关信息存入数据库的这一初衷吗?

下面我们给users新增两个字段,

$ ruby script/generate migration ForgotPassword

$ gedit db/migrate/*_forgot_password.rb


class ForgotPassword < ActiveRecord::Migration
  def self.up
    add_column :users, :reset_password_token, :string
    add_column :users, :reset_password_token_expires_at, :datetime
  end

  def self.down
    remove_column :users, :reset_password_token_expires_at
    remove_column :users, :reset_password_token
  end
end


$ rake db:migrate

$ rake db:test:prepare


运行测试看看,

$ ruby script/cucumber -l zh-CN features/reset_password.feature



发送关于密码重设的邮件这里出了点问题,事实上这时候邮件根本就还没有发送,因为我们没有编写关于发送密码重设邮件的代码,这可不是一个小的疏忽,得补上。

$ gedit app/models/user_mailer.rb

添加如下代码,

  def forgot_password(user, sent_at = Time.now)
    subject    '请重设您的密码'
    recipients user.email
    from       'Admin'
    sent_on    sent_at

    body       :username => user.username,
               :url      => edit_user_password_url(user.id, :token => user.reset_password_token)

  end


$ gedit app/views/user_mailer/forgot_password.text.html.erb

亲爱的 <%=@username%>:

    请点击下面的链接找回您的密码:
    <%=link_to @url, @url%>


修改 UserObserver,

$ gedit app/models/user_observer.rb

添加如下代码,

  def after_save(user)
    UserMailer.deliver_forgot_password(user) if user.forgot_password
  end


$ gedit app/models/user.rb


添加,

attr_reader :forgot_password

修改 forgot_password! 方法,

  def forgot_password!
    @forgot_password = true
    self.reset_password_token = generate_token
    self.reset_password_token_expires_at = 24.hours.from_now
    save(false)
  end


保存 app/models/user.rb。运行测试,

$ ruby script/cucumber -l zh-CN features/reset_password.feature



Could not find field: "user_password" (Webrat::NotFoundError)

抛出这一错误不奇怪呃,这个“user_password“表单字段是属于重设密码页面的。到此为止系统已经可以发送一封关于重设密码的邮件到用户的注册邮箱中;可是我们目前还没有编写重设密码的页面及其业务逻辑,我们来打理接下来的工作。

$ gedit app/views/passwords/edit.html.erb

<h1>重设密码</h1>

<%= error_messages_for :user %>

<% form_for(:user,
            :url => user_password_path(@user, :token => @user.reset_password_token),
            :html => { :method => :put }) do |form| %>
  <p>
    <%= form.label :password, '新密码' %>
    <%= form.password_field :password %>
  </p>
  <p>
    <%= form.label :password_confirmation, '确认新密码' %>
    <%= form.password_field :password_confirmation %>
  </p>
  <p>
  <%= form.submit '更新密码', :disable_with => '正在更新,请稍后...' %>
  </p>
<% end %>


保存模板文件。修改 PasswordsController,

$ gedit app/controllers/passwords_controller.rb

  def edit
    @user = User.find_by_id_and_reset_password_token(params[:user_id],
                                                     params[:token])
  end

  def update
    @user = User.find_by_id_and_reset_password_token(params[:user_id],
                                                     params[:token])
    if @user.update_password(params[:user][:password],
                             params[:user][:password_confirmation])
      @user.email_confirm! unless @user.activated?
      sign_user_in(@user)
      flash[:notice] = "密码更新成功!"
      redirect_to user_path(@user.id)
    else
      flash.now[:notice] = "密码更新失败!"
      render :action => :edit
    end
  end


添加如上两个action后,保存PasswordsController。接着修改 UserModel,

$ gedit app/models/user.rb


在protected之前添加如下代码,

  # 更新密码
  def update_password(new_password, new_password_confirmation)
    self.password              = new_password
    self.password_confirmation = new_password_confirmation
    if valid?
      self.reset_password_token            = nil
      self.reset_password_token_expires_at = nil
    end
    save
  end


保存app/models/user.rb。运行测试,

$ ruby script/cucumber -l zh-CN features/reset_password.feature



当我退出网站,而且我以<xuliicom@gmail.com/newpassword>这个身份登录,那么我应该成功登录网站。这原本是我们之前在最后一个故事场景里写好的预期,但根据上面测试结果可以明显地看出我们用新密码登录失败,这是怎么回事呢?

最容易想到的可能就是密码没有成功被更新;尽管测试结果告诉我们,已经看到<密码更新成功>的提示信息了,根据我们前面学习到的经验,users表记录中加密过的密码不一定真正地变更过……

也许可以经过两次手工测试证明物理数据未被更新这一假设,在此之前,我们还算根据失败的测试结果找找程序上的原因。既然我们假设users表中的密文没有更新,不妨从UserModel开始;当编辑器的滚动条在文件app/models/user.rb中上下滑行的一小会儿时间里,我们惊奇地发现问题大概是处在钩子方法那里。下面是一小段源码,

  # 钩子方法,保存之前生成 password_salt
  # 并使用 password_salt 和原始密码来加密生成新密码
  before_create :initialize_salt, :encrypt_password, :initialize_activation_token


现在你应该看出是什么问题了?问题出在这个时候就不应该用before_create,而应该是before_save;前者会在数据模型创建新记录之前调用参数中指定的方法;后者则在新建记录或者更新旧有记录时都会执行回调。在这个应用程序中,新用户注册需要生成密文,这会创建新记录;已经注册过的用户更新密码同样需要经过加密处理,这一操作会更新旧有记录;无论是新建还是更新记录,它们彼此都有一个共同操作,就是将数据保存到数据库这一过程,在这个关口上执行回调处理,也只有before_save能担此大任。

我们将上面代码中的before_create修改为before_save,然后修改initialize_activation_token方法如下,

  # 生成激活码
  def initialize_activation_token
    if new_record?
      self.activation_token = generate_token
    end
  end


new_record? 方法用来判断是不是新记录。或者改成下面这样也行,

  before_save :initialize_salt, :encrypt_password, :encrypt_password
  before_create :initialize_activation_token


保存app/models/user.rb;运行测试,

$ ruby script/cucumber -l zh-CN features/reset_password.feature



测试通过!hoho~ 最后笔者再重复强调一遍:开发人员最好亲临现场手工测试几次,确保程序真的万无一失。


### 小结 ###

在这一章里,我们学习到了这些知识:Cucumber的运行是以每个故事用例为单位进行的一次集成测试;每个故事用例之间彼此独立(在测试运行时独立),又彼此有联系(基于已有功能叠加);故事场景中的情节只在当前故事中有效,无法跨场景访问,如果需要在其他场景中运行,必须在其他场景中重复定义该情节。


### 下节预告 ###


通过这一系列教程:用户注册、邮件激活帐号、用户登录、登录并“记住我”、用户注销退出和找回密码,我们使用Cucumber+Rspec一步一步循序渐进地开发完成了一个基本的用户帐号系统。这个系统,说大不大,说小也不小了,基本上每个开放注册的站点都会有这么一个用户系统;所以,是时候将它作为一个独立的模块release出来了,不过在此之前,重构和测试是迭代开发中必不可少的一步。在接下来的一章里,笔者将会围绕测试重构展开,我们会配置Cucumber以适应测试环境,还有给测试代码添加辅助方法以及使用Factory_girl等细节,敬请关注!


### 提交工作成果到GIT仓库 ###

$ git status
$ git add .
$ git commit -m "People can be change their passwords."
$ git checkout master
$ git merge reset_password
$ git branch -d reset_password
$ git tag v6


(注意,真正的开发中可不是到功能开发完毕了才commit,而是边开发边add和commit。为了方便演示编码过程,文章中没有一一列举。)

标签: , , ,


### 温故知新 ###


为了保护用户的隐私,限制特定资料的访问,前面我们给系统增加了登录功能;紧接着,又为了方便用户在一段时间之内不必重复登录操作,我们实现了用户的持久登录状态,即“记住我”功能。如果浏览器未关闭,或者用户一直处于在线状态,而用户自己并没有使用这台设备,很显然,这对用户的帐号是非常危险的;基于此,系统应该提供一个给用户手工注销在线状态退出站点的功能。

为了获得更好的阅读体验,读者朋友们可以在这里下载源码:http://github.com/404/bdd_user_demo/tree/master


### 新建工作分支 ###

$ git checkout -b user_logout


### 用户注销退出功能 ###

  1.提供一个“退出”链接,用户登录后点击该链接可以注销在线状态;
  2.用户登录并勾选记住我后,点击“退出”链接可以注销在线状态,下次访问的时候将不再自动登录。

下面来编写对应于以上功能的故事场景。


### 故事用例之用户注销退出 ###

$ gedit features/user_logout.feature

功能: 用户安全退出
  为了保护我的帐号不被他人非法使用
  作为一名已经登录的在线用户
  我希望能够安全退出
 
  场景: 用户注销在线状态
    假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
    当 我以<xuliicom@gmail.com/password>这个身份登录
    那么 我应该成功登录网站
    当 我退出网站
    那么 我应该看到<您已经安全退出>的提示信息
    而且 我应该尚未登录

  场景: 用户在持久在线状态下退出
    假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
    当 我以<xuliicom@gmail.com/password>这个身份登录并勾选<记住我>
    那么 我应该成功登录网站
    当 我退出网站
    那么 我应该看到<您已经安全退出>的提示信息
    而且 我应该尚未登录
    当 我关闭网页下次再来访问的时候
    那么 我应该尚未登录


可以把一个feature文件当作一份书面需求的电子版,如果你觉得文件开头几句没什么用(为了...作为...我希望...),那直接在那里罗列出功能简要咯,故事场景就当作详尽的需求来写。例如:

功能: 用户安全退出
  1.提供一个“退出”链接,用户登录后点击该链接可以注销在线状态;
  2.用户登录并勾选记住我后,点击“退出”链接可以注销在线状态,下次访问的时候将不再自动登录。

 
  场景1
    ...

  场景2
    ...

若真那样,说不定可以节约不少会议时间,因为用于测试的故事文本(feature文件)完全可以替代现实中的功能需求书,而且更加灵活。再向前一步,就可以直接把客户说的需求整理到测试中去,完了发动测试引擎一直在那转动着,开发人员和测试引擎凑到一块儿玩结对编程……敏捷开发,我们还需要文档么?

回头干正事儿,保存 user_logout.feature。运行测试看看它能告诉我们应该做些什么,

$ ruby script/cucumber -l zh-CN features/user_logout.feature




### 编写测试脚本 ###

如上图所示,我们需要为“当 我退出网站”定义所需的运行脚本。

$ gedit features/step_definitions/user_steps.rb

# user logout

When /^我退出网站$/ do
  visit '/logout', :delete
end


保存user_steps.rb。运行测试,

$ ruby script/cucumber -l zh-CN features/user_logout.feature




### 配置登出路由(logout_path) ###

没有找到"/logout"这一访问路径,因为我们还没有在 routes.rb 配置文件中定义"/logout"。修改 routes.rb 文件,添加这条路由信息,

$ gedit config/routes.rb

为了sessions资源看起来有紧凑的结构,我们稍微变换了login_path的定义,与logout_path整合到了一起。

  map.with_options :controller => 'sessions' do |page|
    page.login '/login', :action => 'new'
    page.logout '/logout', :action => 'destroy'
  end



### 实现用户注销退出 ###

既然 "/logout" 路由指向了 SessionsController 类的 destroy 方法,那么我们还需要编写 destroy 方法的具体实现。

$ gedit app/controllers/sessions_controller.rb

添加 destroy 方法,

  def destroy
    forget(current_user)
    reset_session
    flash[:notice] = "您已经安全退出!"
    redirect_to login_path
  end


在 private 之后添加 forget 方法,

  def forget(user)
    user.forget_me! if user
    cookies.delete :remember_token
  end



### 删除服务端的remember_token ###

在forget方法中,程序调用了User实例对象的forget_me!方法;然后清除了当前客户端与服务端会话的cookies;仅仅清除客户端的cookies还不够,服务器上的也应该一并删除。

$ gedit app/models/user.rb

添加forget_me!方法,

  # 删除数据库里边的remember_token
  def forget_me!
    self.remember_token_expires_at = nil
    self.remember_token            = nil
    save(false)
  end


保存 user.rb。运行测试,

$ ruby script/cucumber -l zh-CN features/user_logout.feature



测试通过!


### 亲临现场 ###

为了方便登录用户能够以鼠标点击的方式退出站点,也为了方便开发人员自己手工测试,此时还需要给登录用户提供一个用于注销退出的链接。在之前设置的访问控制中,用户资料显示页面只能在用户登录以后才可见,我们可以将此退出链接加到这个页面中。

$ gedit app/views/users/show.html.erb

在该模板文件末尾加上如下一段代码,

<p>
  <%= link_to "安全退出", logout_path %>
</p>


保存 show.html.erb。打开 Web Server 手工测试看看,

$ ruby script/server

打开浏览器,登录到用户资料查看页面;



点击“安全退出”链接,我们看到系统将我们带到了用户登录页面;



再次刷新刚才那个用户资料显示页面,系统给我们呈现的是一张登录表单;事实说明我们已经成功登出了。




### 下节预告 ###

接下来的一章里,我们将会回到发送邮件的操作上,与之前发送激活邮件不同的是,下一次将会给忘记密码的用户发送一封用于找回密码的邮件。所以,我们下一章的主题就是找回密码。


### 提交工作成果到GIT仓库 ###

$ git status
$ git add .
$ git commit -m "A user can be logout."
$ git checkout master
$ git merge user_logout
$ git branch -d user_logout
$ git tag v5


(注意,真正的开发中可不是到功能开发完毕了才commit,而是边开发边add和commit。为了方便演示编码过程,文章中没有一一列举。)

标签: , , ,


### 温故知新 ###


    在上一篇文章中,我们参照文章内容完成了用户登录功能的开发工作。此时,注册用户可以顺利登录站点,查看用户资料等等;但这一状态也只限于当前的浏览器窗口,如果浏览器关闭了,用户重新打开浏览器下次访问的时候,还是需要来到登录页面进行重新登录。若不是做交易支付型站点,为了追求好一点的用户体验,我们可以给用户预留一个可选项;用户在登录的时候可以勾选“记住我”,一段时间内用户将不必重新登录。要实现用户的这种持久登录状态,我们应该怎么做呢?不妨来了解我们接下来的活儿。

    为了获得更好的阅读体验,读者朋友们可以在这里下载源码:http://github.com/404/bdd_user_demo/tree/master


### 新建工作分支 ###

    $ git checkout -b remember_me

    在有效时间内,要保持用户的在线状态。那么第一个问题会是,我们得知道用户是否已经登录呢?按照我们之前的预期,比如用户的资料应该是受保护的,只有当用户登录以后才可以查看;那就有必要在程序上做一些访问控制。


### 实现简单的访问控制 ###

    使用 Rails的 before_filter 钩子方法可以非常方便地实现我们的目的。

    $ gedit app/controllers/users_controller.rb

    在 UserController 类中的任何方法之前加上如下一段代码:

    before_filter :login_required, :only => [:show]

    这样在查看用户资料的时候会检查用户是否已经登录,如果未登录会提供一张登录用的表单,如果已经登录了就会呈现用户资料。

    我们继续秉承测试先行这一理念,从编写“用户登录且勾选记住我”的故事开始。


### 用户登录之“记住我” ###

    $ gedit features/user_login.feature

    在文件尾部续添如下文本:

      场景: 用户已激活帐号且使用有效身份登录并勾选记住我
        假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
        当 我以<xuliicom@gmail.com/password>这个身份登录并勾选<记住我>
        那么 我应该看到<登录成功>的提示信息
        而且 我应该成功登录网站
        当 我关闭网页下次再来访问的时候
        那么 我应该依然保持登录状态


    运行测试,

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   

    测试失败,根据提示信息来看,我们需要添加一些故事情节运行所需的测试脚本。


### 添加用户驱动故事运行的测试脚本 ###

    $ gedit features/step_definitions/user_steps.rb

    续添如下脚本:

    When /^我关闭网页下次再来访问的时候$/ do
      当 %{session已经被清除}
      而且 %{我来到用户登录页面}
    end

    When /^session已经被清除$/ do
      request.reset_session
      request.session[:user_id].should be_nil
    end

    Then /^我应该依然保持登录状态$/ do
      # 很遗憾,在测试代码中,cookies里边放符号索引会返回nil对象
      # 用字符串来索引没问题
      # 更多信息可以查阅 http://dev.rubyonrails.org/ticket/5924
      cookies['remember_token'].should_not be_blank
      request.session[:user_id].should_not be_nil
    end


    修改 “When /^我以<(.+)\/(.+)>这个身份登录$/ do ... end” 这段代码如下:

    When /^我以<(.+)\/(.+)>这个身份登录(并勾选<记住我>)?$/ do |username_or_email, password, remember|
      当 %{我来到用户登录页面}
      而且 %{我在输入框<用户名或邮箱>中输入<#{username_or_email}>}
      而且 %{我在输入框<密码>中输入<#{password}>}
      而且 %{我勾选<记住我>} if remember
      而且 %{我按下<登录>按钮}
    end


    为 “当 我勾选<记住我>” 添加对应的运行脚本,

    When /^我勾选<(.+)>$/ do |field|
      check(field)
    end


    保存 user_steps.rb。运行测试,

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   


### 观测试了解工作内容 ###

    测试结果返回 “Could not find field: "记住我" (Webrat::NotFoundError)”的相关信息,提示没有找到关于“记住我”的这个表单域,不用多想,这个“记住我”的多选框应该出现在用户登录页面。

    $ gedit app/views/sessions/new.html.erb

    修改后的登录页面代码如下,

    <% form_tag sessions_path do %>
      <p>
        <%= label_tag 'username_or_email', '用户名或邮箱' %><br />
        <%= text_field_tag 'username_or_email' %>
      </p>
      <p>
        <%= label_tag 'password', '密码' %><br />
        <%= password_field_tag 'password' %>
      </p>
      <p>
        <%= check_box_tag 'remember_me', 1, true %>
        <%= label_tag 'remember_me', '记住我' %>
      </p>
      <p>
        <%= submit_tag '登录' %>
      </p>
    <% end %>


    保存 app/views/sessions/new.html.erb。运行测试,

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   

    问题出在 user_steps.rb 文件的第86行,我们来看看这行代码的内容,

   

    测试代码里边写明了,如果用户关闭网页再次访问的时候,cookies[:remember_token]的值应该不为空,这样就可以实现记住我的功能。测试结果告诉我们该值为空,为了达到我们想要的效果,我们来做些实际的编码工作。

    之前在用户登录页面的模板文件中,我们已经添加了供用户可选“记住我”的选项,下面添加一些处理业务流程的代码。

    $ gedit app/controllers/sessions_controller.rb

    修改 create 方法,在 sign_user_in(@user)之前添加如下一句代码,

    remember(@user) if remember?

    这样用户在勾选“记住我”选项之后,系统会自动设置记住用户的相关细节,不过这些具体细节还需要我们通过编码来完成。

    继续在 SessionsController 中编写刚才那段代码中用到的两个方法,

      private
     
      def remember?
        params[:remember_me] && params[:remember_me] == "1"
      end
       
      def remember(user)
        user.remember_me!
        cookies[:remember_token] = {
          :value   => user.remember_token,
          :expires => user.remember_token_expires_at
        }
      end


    我们将这两个方法设为私有仅供程序内部使用,上面的 remember 方法已经涉及到数据存取;我们还需要修改 User 模型类以衔接上述的业务逻辑。

    首先增添上述代码用到的两个数据字段,即 remember_token 和 remember_token_expires_at;之所以添加这两个字段,是因为服务端需要记录客户端自动登录且唯一的cookie标识,并用该标识来验证客户端的请求是否有效,如果有效就可以打破HTTP协议无状态的限制建立持久的会话连接,如果客户端的cookie headers是伪造或失效的,那么很遗憾地非法请求将不得逞,并导向用户登录页面,提示该用户登录。下面我们来添加数据迁移文件,

    $ ruby script/generate migration RememberMe

    $ gedit db/migrate/*_remember_me.rb


    class RememberMe < ActiveRecord::Migration
      def self.up
        add_column :users, :remember_token, :string
        add_column :users, :remember_token_expires_at, :datetime
      end

      def self.down
        remove_column :users, :remember_token_expires_at
        remove_column :users, :remember_token
      end
    end


    保存 db/migrate/*_remember_me.rb。然后执行迁移,

    $ rake db:migrate

    $ rake db:test:prepare


    接着修改 UserModel,

    $ gedit app/models/user.rb

    添加如下代码,

      # remember_token 是否失效
      def remember?
        remember_token_expires_at && Time.now < remember_token_expires_at
      end

      # 记住多长时间
      def remember_me!
        remember_me_until 2.weeks.from_now
      end

      # 保存“记住我”的相关设置
      def remember_me_until(time)
        self.remember_token_expires_at = time
        self.remember_token            = encrypt(time)
        save(false)
      end


    保存 UserModel ,最后在 ApplicationController 类中添加一个读取并校验 cookie 的方法。用户访问的时候检查cookie,如果该cookie有效就可以直接登录了。

    $ gedit app/controllers/application.rb

    在 user_from_session 方法之后添加如下方法,

      def user_from_cookie
        if cookies[:remember_token]
          user = User.find_by_remember_token(cookies[:remember_token])
          user && user.remember? ? user : nil               
        end
      end


    然后修改 current_user 方法,可以接受用户使用 cookie 的方式登录。

      def current_user
        @_current_user ||= (user_from_session || user_from_cookie)
      end


    保存 ApplicationController,运行测试看看;

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   

    测试通过!:)


### 小结 ###

    这篇文章介绍的内容不是很多,功能也不算复杂。在用户已经能够登录站点的基础上,我们给登录用户加上了“记住我”的功能,这是一种持久登录状态。说到持久一词,就不得不提到HTTP协议是无状态的,无状态在这里意指在一般的B/S连接中,Server 无法识别特定的 Browser,因为一台 Web Server 响应的 Browsers 不计其数,Server 没办法知道当前所响应的Browser是谁,也不记得这Browser之前是否请求过。不过有了 cookie,就可以打破HTTP协议无状态的这一限制。cookie是一种在客户端存储数据并以此来跟踪和识别用户的机制。Server 响应 Browser 的请求时会发送一个带 set-cookie 的 http headers,Browser 会在本地记住这一cookies 数据;当 Browser 再次请求时,就会将这一 cookies 发送给 Server ,Server 在响应 Browser 请求的同时接受并读取此 cookies,以此来达到跟踪和识别用户的目的。如果服务端没有特别地设置cookies,客户端针对这一站点的cookies将随浏览器进程的关闭而失效。在之前我们使用session来做用户登录即是如此,因为Rails将会话数据(session data)存在客户端的cookies里边,cookies的有效期是随浏览器的关闭而失效的,所以当用户关闭浏览器后重新打开再次访问就需要登录;后来我们在程序里显示地配置了cookies的有效期为两周,当用户第一次登录后,cookies会在用户浏览器中保存两周,那么用户在这两周内就不需要重新登录了。


### 相关阅读 ###

    cookie机制的纯JavaScript实现:http://chinaonrails.com/topic/view/1449.html
    php系统和ror系统的用户登录授权问题:http://chinaonrails.com/topic/view/1711.html
    了解关于cookie和Rails交互的更多信息:http://chinaonrails.com/q/cookie


### 下节预告 ###

    接下来的一章里会向读者朋友们演示登录用户如何安全退出,敬请期待!


### 提交工作成果到GIT仓库 ###

    $ git status
    $ git add .
    $ git commit -m "A user can be login with remember me."
    $ git checkout master
    $ git merge remember_me
    $ git branch -d remember_me
    $ git tag v4


    (注意,真正的开发中可不是到功能开发完毕了才commit,而是边开发边add和commit。为了方便演示编码过程,文章中没有一一列举。)

标签: , , ,


### 温故知新 ###


    在前面的两篇文章中,笔者向读者朋友们分别演示了用户注册注册用户通过邮件激活帐号的开发过程。当用户注册成功并激活帐号后,系统应该可以让用户登录站点,这就是我们接下来的活儿。

    为了获得更好的阅读体验,读者朋友们可以在这里下载源码:http://github.com/404/bdd_user_demo/tree/master


### 新建工作分支 ###

    $ git checkout -b user_login


### 用户登录功能 ###

    1. 提供一张表单,方便用户输入帐号和密码,帐号可以是用户名或邮箱;
    2. 如果用户尚未注册,那么提示登录失败;
    3. 如果用户已注册但未激活,那么系统往该用户的邮箱发送一封激活邮件,并提示用户登录失败,返回登录页面;
    4. 如果用户输入的帐号或密码错误,那么提示用户登录失败,并返回登录页面;
    5. 如果用户输入的帐号和密码正确,那么提示用户登录成功,并将该用户ID记录在session中;
    6. 如果用户输入的帐号和密码正确,且勾选了“记住我”选项,那么提示用户登录成功,并将该用户记录在session和cookie中,然后转向用户登录之前或系统默认的页面。


### 用户登录之“用户尚未注册” ###


    我们简单地罗列了用户登录功能,随后我们将之转化成故事描述以供测试用,每个功能至少对应一个故事情节。我们先从“用户尚未注册”这个功能开始,下面我们编写用于这个功能的故事场景:

    $ gedit features/user_login.feature

    功能: 用户登录
      为了能够浏览网站只对在线会员可见的那些内容
      作为一名访客
      我希望能够登录

      场景: 用户登录功能
        假如 没有<somebody@somedomain.com>这个用户
        当 我以<somebody@somedomain.com/password>这个身份登录
        那么 我应该看到<用户名或密码错误>的提示信息
        而且 我应该尚未登录



### 编写用于故事运行的测试代码 ###

    $ gedit features/step_definitions/user_steps.rb

    添加如下代码:

    Given /^没有<(.*)>这个用户$/ do |email|
      User.find_by_email(email).should be_nil
    end

    When /^我以<(.+)\/(.+)>这个身份登录$/ do |username_or_email, password|
      当 %{我来到用户登录页面}
      而且 %{我在输入框<
用户名或邮箱>中输入<#{username_or_email}>}
      而且 %{我在输入框<
密码>中输入<#{password}>}
      而且 %{我按下<登录>按钮}
    end

    Then /^我应该尚未登录$/ do
      request.session[:user_id].should be_nil
    end


    运行测试,

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   

    “Can't find mapping from "登录页面" to a path. (RuntimeError)”,测试程序未能导向登录页面。还记得前面我们提到过的 paths.rb 文件吗?我们可以在该文件的 path_to 方法中指明登录页面的访问路径。


### 配置路由——login_path ###

    $ gedit features/support/paths.rb

    修改后的 path_to 方法如下,

    def path_to(page_name)
      case page_name
      
      when /首页/
        root_path
      when /用户注册页面/
        signup_path
      when /用户登录页面/
        login_path
      
      # Add more page name => path mappings here
      
      else
        raise "Can't find mapping from \"#{page_name}\" to a path."
      end
    end


    接下来还需要配置路由来定义login_path,不过在此之前我们需要创建一个用于映射login_path的控制器,通常情况下,这样的控制器的会是SessionController。

    $ ruby script/generate rspec_controller Sessions

    在这里,SessionController 的存在是我们定义 login_path 的基础,下面我们来定义登录页面的访问路径。

    $ gedit config/routes.rb

    ActionController::Routing::Routes.draw do |map|
      
      map.with_options :controller => 'users' do |page|
        page.signup '/signup', :action => 'new'
        page.activate '/activate/:token', :action => 'activate'
      end
      
      map.login '/login', :controller => 'sessions', :action => 'new'
      
      map.resources :users, :sessions

    end


    login_path定义完毕,如果你不知道接下来要做什么,此时您可以运行测试看看。


### 创建用于用户登录的表单页面 ###

    $ gedit app/views/sessions/new.html.erb

    填充如下代码,

    <% form_tag sessions_path do %>
      <p>
        <%= label_tag 'username_or_email', '用户名或邮箱' %><br />
        <%= text_field_tag 'username_or_email' %>
      </p>
      <p>
        <%= label_tag 'password', '密码' %><br />
        <%= password_field_tag 'password' %>
      </p>
      <p>
        <%= submit_tag '登录' %>
      </p>
    <% end %>



### 登录验证——用户输入的密码是否正确 ###

    $ gedit app/models/user.rb

    在 user.rb 中添加如下两个方法(写在protected之前):
      
    # 验证指定帐号的密码是否匹配
    def self.authenticate(username_or_email, password)
      account = username_or_email.to_s
      user = find(:first, :conditions => ['username = ? or email = ?', account, account])
      user && user.authenticated?(password) ? user : nil
    end

    # 密码是否有效
    def authenticated?(password)
      encrypted_password == encrypt(password)
    end


    数据模型和路由准备就位(这句话大概就是说,我们已经设计好用户该怎么来到注册页面,也告诉后端的数据模型该如何处理数据),下面来编写用于用户登录的业务流程。


### 处理用户登录的业务流程 ###

    $ gedit app/controllers/sessions_controller.rb

    填充如下代码,

    class SessionsController < ApplicationController

      def new
      end

      def create
        @user = User.authenticate(params[:username_or_email], params[:password])
        if @user.nil?
          flash.now[:notice] = '登录失败,用户名或密码错误!'
          render :action => :new, :status => :unauthorized
        else
          if @user.activated?
            sign_user_in(@user)
            flash[:notice] = '登录成功!'
            redirect_back_or user_path(@user.id)
          else
            UserMailer.deliver_confirm(@user)
            deny_access("登录失败!您的帐号尚未激活,系统已经向您的邮箱重新发送了一封激活邮件,请注意查收!")
          end
        end
      end

    end


    如果您又迷惑了,不妨就此打住运行测试看看,测试结果会告诉你该做什么。不过有时候你大脑里或许非常清楚下一步该做什么。

    下一步要做的就是增加访问控制,明确用户登录的意义;如果用户还没有登录,这时候是游客身份,是不可以查看用户资料的,如果登录成功,才可以查看。


### 添加访问控制 ###

    $ gedit app/controllers/application.rb

    在 ApplicationController 中添加如下代码,

    def login_required
      deny_access unless signed_in?
    end

    def signed_in?
      !current_user.nil?
    end

    def current_user
      @_current_user ||= user_from_session
    end

    def user_from_session
      if session[:user_id]
        user = User.find_by_id(session[:user_id])
        user && user.activated? ? user : nil                
      end
    end

    def sign_user_in(user)
      sign_in(user)
    end

    def sign_in(user)
      if user
        session[:user_id] = user.id
      end
    end

    def redirect_back_or(default)
      session[:return_to] ||= params[:return_to]
      if session[:return_to]
        redirect_to(session[:return_to])
      else
        redirect_to(default)
      end
      session[:return_to] = nil
    end

    def store_location
      session[:return_to] = request.request_uri if request.get?
    end

    def deny_access(flash_message = nil, opts = {})
      store_location
      flash.now[:failure] = flash_message if flash_message
      render :template => "/sessions/new", :status => :unauthorized
    end


    这下加了不少代码,得花点时间看看,看明白了就保存application.rb。接着运行测试,

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   

    测试通过,未注册用户登录失败,这正符合我们的要求;我们再来看看用户已注册但未激活帐号的情况下能否登录成功。


### 用户登录之“用户已注册但尚未激活帐号” ###

    正常情况下,用户已注册但尚未激活帐号的情况下,是不能登录成功的。原因很简单,没激活帐号的用户暂且算不上真正的注册用户。下面来试试看,

    $ gedit features/user_login.feature

    添加如下场景:

        场景: 用户已注册但尚未激活帐号
          假如 我已经使用<404/xuliicom@gmail.com/password>注册过
          当 我以<xuliicom@gmail.com/password>这个身份登录
          那么 我应该看到<帐号尚未激活>的提示信息
          而且 我应该尚未登录


    保存。再次运行测试,

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   

    测试失败,没有在输出中匹配到“帐号尚未激活”这段文本。在前面的编码中,我们一直使用 flash 传递提示信息,而且我们将呈现 flash 中信息的代码写在了 app/views/layouts/application.html.erb 这个模板中。打开这个文件,找到了这段代码:

    <% if flash[:notice] -%>
      <p style="color: green"><%= flash[:notice] %></p>
    <% end -%>


    还记得我们在ApplicationController类中定义的deny_access方法吗?我们在那里给 flash[:failure] 赋过值,而layout只针对 flash[:notice] 做了处理,看来我们需要修改这个layout模板的代码。

    $ gedit app/views/layouts/application.html.erb

    我们将

    <% if flash[:notice] -%>
      <p style="color: green"><%= flash[:notice] %></p>
    <% end -%>


    这段代码替换为

    <div id="flash">
      <% flash.each do |key, value| -%>
        <div id="flash_<%= key %>"><%=h value %></div>
      <% end %>
    </div>


    保存 application.html.erb。再次运行测试,

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   

    测试通过,两个故事场景都运行成功!在遭遇这样的小小挫折后,增进了我们往后测试的信心!TDD同样是增量开发的,当我们小幅度地尝试,小幅度的迭代,小范围地测试失败之后带来的总是小小成就的欢欣鼓舞。我们何不继续步步为营,稳打稳扎,循序渐进地编写并运行我们的故事尝尝更多的甜头呢?


### 用户登录之“更多情况..” ###

    接下来添加其他的几个故事场景,以测试我们编写的用户登录脚本是否达到指定功能,这下步子可能跨得大了点哦!

    $ gedit features/user_login.feature

    继续在文件尾添加如下文本:

      场景: 用户已激活帐号但密码输入错误
        假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
        当 我以<xuliicom@gmail.com/wrong_password>这个身份登录
        那么 我应该看到<用户名或密码错误>的提示信息
        而且 我应该尚未登录

      场景: 用户已激活帐号且邮箱和密码输入正确
        假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
        当 我以<xuliicom@gmail.com/password>这个身份登录
        那么 我应该看到<登录成功>的提示信息
        而且 我应该成功登录网站

      场景: 用户已激活帐号且用户名和密码输入正确
        假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
        当 我以<404/password>这个身份登录
        那么 我应该看到<登录成功>的提示信息
        而且 我应该成功登录网站


    保存。再次运行测试看看,

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   

    测试失败,反馈的结果告诉我们还需要分别针对“注册且激活”和“成功登录”定义相关的运行脚本。

    $ gedit features/step_definitions/user_steps.rb

    添加如下代码:

    Then /^我应该成功登录网站$/ do
      request.session[:user_id].should_not be_nil
    end


    然后修改 “ Given /^我已经使用<(.*)\/(.*)\/(.*)>注册过$/ do ... end “ 这段已经定义过的脚本,修改后的代码如下:

    Given /^我已经使用<(.*)\/(.*)\/(.*)>注册过(且已经激活了帐号)?$/ do |username, email, password, confirm|
      @valid_attributes = {
        :username              => username,
        :email                 => email,
        :password              => password,
        :password_confirmation => password
      }
      @user = User.create!(@valid_attributes)
      if confirm
        @user.activation_token = nil
        @user.save(false)
      end
    end


    保存user_steps.rb。继续运行测试,

    $ ruby script/cucumber -l zh-CN features/user_login.feature

   

    OK,测试成功,可以松口气了!至此,离用户登录功能的开发只剩下最后一步了,也是我们接下来要做的工作,那就是实现用户的持久登录状态。当用户输入帐号和密码并勾选“记住我”登录后,用户下次打开浏览器访问的时候依然保持用户的在线状态,那么我们要知道怎么判断用户是否已经登录呢?且听下回讲解!:)


### 小结 ###

    此次开发过程相当顺利,故事场景较之前稍微多一些,还好我们进行的是小幅度的迭代测试,我们的开发工作才得以循序渐进。所以学到的一个经验就是,当业务流程稍微复杂的时候,就要分而治之了。这和大事化小,小事化无的道理是一样的。cucumber的运行是以每一个故事场景为单位,可以让我们很方便地做到这一点。比如我们每次只编一个故事,然后通过运行测试来驱动我们编码后,可以让这个故事能够顺利地跑起来;完了之后又继续下一个故事……稳打稳扎,步步为营,这相当地保险,在TDD上玩迭代开发,那中间想必也多出几次成就感了。另外,我们还学到一点,如果不知道下一步怎么走,在测试准备就绪的情况,只需要轻轻地发动测试引擎一切又柳暗花明了。


### 提交工作成果到GIT仓库 ###

    $ git status
    $ git add .
    $ git commit -m "A user can be login."
    $ git checkout master
    $ git merge user_login
    $ git branch -d user_login
    $ git tag v3


    (注意,真正的开发中可不是到功能开发完毕了才commit,而是边开发边add和commit。为了方便演示编码过程,文章中没有一一列举。)

标签: , , ,


### 温故知新 ###


    前面我们已经完成了新用户注册功能的开发,为了方便我们后面的开发工作且不扰乱之前的工作成果,我们先将这份源代码归档并做个标记。

    为了获得更好的阅读体验,读者朋友们可以在这里下载源码:http://github.com/404/bdd_user_demo/tree/master


### 提交工作成果到GIT仓库 ###

    $ cd ~/code/user_demo
    $ git init
    $ git add .
    $ git commit -m
"A user can be able to sign up."
    $ git tag v1


    “git init” 会在 ~/code/user_demo 目录中初始化版本库;接着 “git add .” 将 user_demo 目录中的所有文件信息编入索引(index files);然后 “git commit” 命令将根据 index 中的信息将工作内容提交到项目的GIT仓库里边去,-m 选项加上了本次提交的一些说明;最后 “git tag” 给这次提交所生成的版本号标记了一个别名叫 v1。

    其实好习惯是在新建rails-app后就初始化版本库。由于篇幅的关系,笔者才将些许GIT的内容放到这篇文章中。这不,正好派上用场喽!

    在主干(master)上工作是危险的,因为控制的不够好会扰乱版本,这不是我们愿意看到的。为此,GIT允许我们在主干道的基础上建立新的分支(branch),在分支中进行开发工作,这样好控制风险。比如有时候分支中的工作搞得一塌糊涂,开发人员想重来的时候,直接丢掉删除这个分支再新建一个工作分支重新工作就是了,这对项目中的主干完全没有丝毫影响(不会扰乱你上次提交到master中的工作成果),等你在新分支中开发完毕后,再将这个分支中的工作成果归并到主干中就行。GIT的分支告诉我们,丢掉一个烂摊子比收拾一个烂摊子要轻松得多;潜意识里,我们几乎一致认为这对开发人员的大脑是友好的!:)

    下面我们在主干的基础上为后面邮件激活这个功能的开发新建一个分支。


### 新建工作分支 ###

    $ git checkout -b email_activation

    或者,

    $ git branch email_activation
    $ git checkout email_activation

    查看当前工作所在的分支,

    $ git branch

    会返回项目中的所有分支,前面加星*就是当前的工作分支。

    做开发要步步为营,不是吗?git可以很方便地帮我们做到这点。在归档源码后,接着我们新建了一个名为 email_activation 的分支,并将当前的工作状态从master主干切换到email_activation分支中。这里说明下,此时的 email_activation 相当于之前源码(v1)的一份副本,这份副本是我们进行后续开发的基础;后面我们将在用户已经能够注册的基础上进行用户激活帐号的开发工作,只不过在这个基础上所开发的一举一动都会被记录到email_activation分支中。当用户注册成功并能通过邮件激活帐号后,我们就可以将email_activation分支下的工作成果提交且归并到master主干中,从而把邮件激活的功能和用户注册的功能完美的衔接在一起,同时使得项目的版本干净整洁。

    如果我们在email_activation的分支中的开发工作不尽人意,怎么办呢?如果是一些小小的修改,那非常好办,直接改成你想要的就是了;可如果是大范围地修改后,结果却不是你想要的,有时会萌发重做的想法。下面就来告诉你一些开倒车的技巧:

    如果新增了文件,需要先用git add添加(这会被编入git的index,但不会提交到git仓库),否则回滚后会遗留下来(这句话好像就是说,等到你重新开发的时候发现要编码的文件已经存在了)。可以用 git status 命令查看都添加或者修改了哪些文件。

    如果你当前的工作目录(working tree)已经混乱不堪,但是还没有提交,可以使用:

    $ git reset --hard

    这会丢弃所有的改变,包括去除已经加到git index里边的内容;然后将 working tree 和 index 恢复到上次commit时的状态。

    如果想回滚到一个指定的版本,就需要指定版本号:

    $ git reset --hard v1

    v1 是我们在之前标记过的别名,即上次commit所生产的版本号别名,也可以替换成commit后的版本号,比如 af2d45c... ,版本号是一个唯一的哈希值,每次commit都会生成一个,省去了你找不到版本号的尴尬;基本上,使用git log 命令都能看到版本号。指定版本号的时候不需要写上所有字符,取前5个就可以,反正能说明版本号是唯一的就行了。比如你只有两次提交记录,指定版本号的时候取哈希值的前两个字符又何尝不可呢?

    还有,记得 --hard 选项要慎重使用,具体的您可以使用 “git reset -h” 命令查阅更多关于撤销修改的详细信息。

    如果只是想放弃对某一文件的修改,可以使用 checkout 命令。这个命令不单用于分支间的切换,还可以回滚一个指定的文件内容到上次所做的修改,例如:

    $ git checkout app/models/user.rb

    这会放弃对user.rb所做的修改,并将user.rb的内容从上一个已提交的版本中更新回来。当然还可以指定回滚到指定版本,例如:

    $ git checkout v1 app/models/user.rb

    这会将user.rb的内容从已提交的v1所对应的版本中更新回来。

    好了,到此您已经了解了一些实用的GIT知识;是时候步入正题进行我们的开发工作了,我们来了解下工作内容。


### 邮件激活功能 ###

    1. 用户成功注册成为网站用户;
    2. 系统发送一封包含激活链接的邮件到用户注册时填写的邮箱中;
    3. 用户点击邮箱中的激活链来接激活帐号;
    4. 用户帐号激活成功,并给出帐号激活成功的提示消息。

    根据上面的功能需求,我们在前面两个故事的基础上再添两笔。


### 故事用例之用户通过邮件激活帐号 ###

    $ gedit features/user_signup.feature

    修改后的文件内容如下,

    功能: 注册成为网站会员
      为了能够浏览网站只对在线会员可见的那些内容
      作为一名访客
      我希望注册成为网站会员

      场景: 用户填写无效数据并注册
        当 我来到用户注册页面
        而且 我在输入框<用户名>中输入<invalid username>
        而且 我在输入框<电子邮箱>中输入<invalid email>
        而且 我在输入框<密码>中输入<password>
        而且 我在输入框<确认密码>中输入<verify password>
        而且 我按下<注册>按钮
        那么 我应该看到<注册失败>的提示信息
       
      场景: 用户填写正确的数据并注册
        当 我来到用户注册页面
        而且 我在输入框<用户名>中输入<404>
        而且 我在输入框<电子邮箱>中输入<xuliicom@gmail.com>
        而且 我在输入框<密码>中输入<password>
        而且 我在输入框<确认密码>中输入<password>
        而且 我按下<注册>按钮
        那么 我应该看到<注册成功>的提示信息
        而且 应该有封激活帐号的邮件发送至<xuliicom@gmail.com>

      场景: 用户激活帐号
        假如 我已经使用<404/xuliicom@gmail.com/password>注册过
        当 我访问<xuliicom@gmail.com>邮件中激活帐号的链接
        那么 我应该看到<帐号激活成功>的提示信息


    我们只是在已有的故事上加了个别子句。为了能让故事跑起来,我们还需要针对故事场景中的情节编写相应的测试代码。


### 编写用于驱动故事运行的测试代码 ###


    $ gedit features/step_definitions/user_steps.rb

    添加如下代码,

    Then /^应该有封激活帐号的邮件发送至<(.+)>$/ do |email|
      user = User.find_by_email(email)
      user.activation_token.should_not be_blank
      sent = ActionMailer::Base.deliveries.last
      sent.to.should eql([user.email])
      sent.subject.should =~ /激活/
      sent.body.should =~ /#{user.activation_token}/
    end

    Given /^我已经使用<(.*)\/(.*)\/(.*)>注册过$/ do |username, email, password|
      @valid_attributes = {
        :username              => username,
        :email                 => email,
        :password              => password,
        :password_confirmation => password
      }
      @user = User.create!(@valid_attributes)
    end

    When /^我访问<(.*)>邮件中激活帐号的链接$/ do |email|
      user = User.find_by_email(email)
      visit activate_url(:token => user.activation_token)
    end


    故事用例基本上涵盖了我们开发的用意,测试代码准备就绪,还等什么,赶紧跑起来看看吖。

    运行测试,

    $ ruby script/cucumber -l zh-CN features/user_signup.feature

   

    测试未能通过,原本应该有封激活帐号的邮件发送至<xuliicom@gmail.com>,然而却没有,因为我们还没有编写用于发送激活邮件的代码。习惯了玩测试的话,测试结果无疑对指导你的编码工作非常有帮助!

    接下来,我们就来做这些工作。


### 添加激活码字段 ###


    怎么知道用户有没有激活帐号呢?答案是在 users 表中增加用于标识用户帐号是否激活的两个字段,一个用来存放激活码,另一个用来记录帐号激活时间。假设这两个字段分别是 activation_token 和 activated_at,如果 users.activation_token 字段有值,那么就说明用户还没有激活,如果 users.activation_token 为空且 users.activated_at 有值,那么就说明用户已经激活过了。

    下面来添加这组字段,

    $ ruby script/generate migration EmailConfirm

    $ gedit db/migrate/*_email_confirm.rb

    class EmailConfirm < ActiveRecord::Migration
      def self.up
        add_column :users, :activation_token, :string
        add_column :users, :activated_at, :datetime
      end

      def self.down
        remove_column :users, :activated_at
        remove_column :users, :activation_token
      end
    end


    $ rake db:migrate

    $ rake db:test:prepare

    表结构准备完毕后,再来生成用户注册时的激活码。


### 生成激活码——activation_token ###

    $ gedit app/models/user.rb

    before_create :initialize_salt, :encrypt_password, :initialize_activation_token

    # 生成并返回标识码
    def generate_token
      encrypt(Time.now.to_s.split(//).sort_by {rand}.join)
    end

    # 生成激活码
    def initialize_activation_token
      if new_record?
        self.activation_token = generate_token
      end
    end


    数据模型搞定后,再从路由下手,需要指定控制器该如何分配响应请求。


### 配置激活帐号的路由——activate_url ###

    $ gedit config/routes.rb

    修改后routes.rb文件内容如下,

    ActionController::Routing::Routes.draw do |map|

      map.with_options :controller => 'users' do |page|
        page.signup '/signup', :action => 'new'
        page.activate '/activate/:token', :action => 'activate'
      end

      map.resources :users

    end


    此时,如果你不清楚接下来要做什么;不妨运行测试,测试结果会告诉你答案。由于笔者知道会失败也知晓接下里该做什么,所以就略过此步;因为Model和Route都准备完毕,是时候动手编写业务流程了。

    如果你用Rails发过邮件,下面的步骤你一定很熟悉。


### 生成邮件 ###

    $ ruby script/generate mailer UserMailer confirm

    $ gedit app/models/user_mailer.rb

    class UserMailer < ActionMailer::Base
     
      def confirm(user, sent_at = Time.now)
        subject    '请激活您的帐号'
        recipients user.email
        from       'Admin'
        sent_on    sent_at
       
        body       :username => user.username,
                   :url  => activate_url(:token => user.activation_token)
      end

    end


    $ gedit app/views/user_mailer/confirm.erb

    亲爱的 <%=@username%>:

        您的帐号已经创建成功,请点击下面的链接激活您的帐号:
        <%=link_to @url, @url%>



### 发送邮件 ###

    用户注册成功之后,需要发送一封确认邮件到用户注册时填写的电子邮箱中。虽然可以在 User 模型中添加 after_create 的一个回调代码来执行,但这样就给 User 模型类增添了本不应该承担的责任;我们只需要 User 模型提供数据,而不是将发送邮件的任务丢给它。这时候 ActiveRecord 提供的 Observer 就可以派上用场了,使用Observer的好处是它可以将自身连接到模型类中并注册为回调,却无需修改任务模型类的代码,我们将其称之为观察器(是否联想到Ruby设计模式中的观察者模式,呵呵)。下面,我们针对 User 模型创建一个观察器:

    $ ruby script/generate observer User

    $ gedit app/models/user_observer.rb

    class UserObserver < ActiveRecord::Observer

      def after_create(user)
        UserMailer.deliver_confirm(user)
      end

    end


    然后在 config/environment.rb 注册这个 Observer。

    $ gedit config/environment.rb

    config.active_record.observers = :user_observer

    再次发动测试引擎,看看是否working,

    $ ruby script/cucumber -l zh-CN features/user_signup.feature

   

    由于在生成邮件那一章节里,激活链接我们用的是 link_url 这种形式,如果你知道 link_url 和 link_path 的区别,那么根据上面的测试结果,你应该了解出错的原因。如果不了解,笔者在这里补充下,link_url 会在链接中加上协议名、主机名和端口号这些;而 link_path 则不用,它会直接用根目录“/”代替之;也就是说, link_url 会在链接中加上网址;又或者说,link_url 采用绝对路径,而 link_path 采用相对路径。

    考虑到现实中的用户注册,系统会发送一封包含网址的邮件到注册用户的邮箱中,我们之前的邮件模板里不得不采用 link_url 这种形式。结合测试结果来看,也许此时您已经意识到,我们是不是忘了配置主机名呢?

    恭喜您!您确实猜对了。


### 配置邮件中激活链接的绝对路径 ###

    $ gedit app/models/user_mailer.rb

    default_url_options[:host] = HOST

    在 config/environments/test.rb 和 config/environments/development.rb 这两个配置文件中定义 HOST 常量,为了开发和测试需要,这里设置成localhost就可以了。

    HOST = 'localhost:3000'

    不过在 config/environments/production.rb 中,HOST 常量的值就必须是真实的主机名了。

    另一种方法无需修改app/models/user_mailer.rb和定义HOST常量,直接在各environment/各文件或environment.rb中配置就行了,如下代码

    $ gedit config/environment.rb

    config.action_mailer.default_url_options = { :host => 'localhost:3000' }

    这样做的好处是只需修改一处。

    好了,补上这个配置,再运行测试,看看有什么不同。

    $ ruby script/cucumber -l zh-CN features/user_signup.feature

   


### 激活帐号 ###

    看来我们的邮件能够成功发送了,不过好像访问邮件中的确认链接时出了点问题,根据调试信息“ActionController::UnknownAction”显示,应该是没有找到激活帐号的具体行为(action)。在前面的开发中,我们真的就还没有编写响应用户激活帐号的相关代码,想必此时我们都清楚该做哪些工作了。

    我们需要给 UserController 类添加一个 Action 来响应用户激活帐号的请求。

    $ gedit app/controllers/users_controller.rb

    之前我们在config/routes.rb文件中定义了activate_path,且该activate_path 的 :action 参数指向 activate 方法;于是乎,activate 就是我们需要在 UserController 类中添加的 action。activate方法的代码如下:

    def activate
      if @user = User.find_by_activation_token(params[:token])
        if !@user.activated?
          @user.email_confirm!
          flash.now[:notice] = '恭喜您,帐号激活成功!'
        end
      end
    end


    仔细观察 UserController#activate,我们还需要在 User 模型中编写 activated? 和 email_confirm! 这两个实例方法,前者用来确认用户的帐号是否已经激活过,后者则用来激活用户的帐号。

    $ gedit app/models/user.rb

    在 protected 之前添加如下两个方法:

    # 检查是否已经激活
    def activated?
      # 当 activation_token 为 nil 时表示用户帐号已经激活
      activation_token.nil?
    end
     
    # 激活帐号
    def email_confirm!
      update_attributes(:activation_token => nil, :activated_at => Time.now)
    end


    运行测试看看,

    $ ruby script/cucumber -l zh-CN features/user_signup.feature

   

    看来是没有找到模板文件,在此补上用户成功激活帐号的页面。

    $ gedit app/views/users/activate.html.erb

    保存即可。运行测试:

    $ ruby script/cucumber -l zh-CN features/user_signup.feature

    OK,测试通过!如图,

   


### 亲临现场 ###

    最后开发人员自己别忘了手工测试,以确保万无一失。

    先清除数据库中的记录,

    $ ruby script/console
    >> User.delete_all

    假设我们以404为用户名成功注册后,我们来看看数据库中404的activation_token字段是否有值。

    >> User.find_by_username('404', :select => "username, activation_token, activated_at")

   

    可以看到,activation_token 的值是一串加密后的字符,activated_at值为空,这说明程序已经给注册用户生成了激活码,而且此时用户还没有激活帐号。

    当我们注册成功后,打开邮箱却并没有看到激活帐号的邮件,这是怎么回事呢?

    因为测试程序跑到是test环境,而我们手工测试的时候,程序是运行在development环境下的,我们没有针对development环境配置邮件服务器。下面我们采用SMTP的发信方式,这里的SMTP SERVER用的是GMAIL,而且是SSL验证登录方式;三次握手,发信速度没sendmail那么快,呵呵!

    $ gedit config/environment.rb

    config.action_mailer.delivery_method = :smtp
    config.action_mailer.default_charset = 'utf-8'

    config.action_mailer.smtp_settings = {
      :address              => 'smtp.gmail.com',
      :port                 => 25,
      :domain               => 'YOUR_DOMAIN',
      :user_name            => 'YOUR_GMAL_USERNAME',
      :password             => 'YOUR_GMAIL_PASSWORD',
      :authentication       => 'login',
      :enable_starttls_auto => true
    }


    该配置中大写部分自行替换即可。

    清空users表,我们重新注册404这个用户。

    $ ruby script/console
    >> User.delete_all

    然后去邮箱看看,

   

    这回我们打开邮箱看到了激活帐号的邮件信息,不过邮件内容中的链接标签没有生效,我们期望发送到用户邮箱的是HTML格式的邮件。ActionMailer可以让我们发送多种格式的邮件,只需要按相应的内容类型修改邮件模板的文件名格式即可。基本上,邮件模板的文件名的格式像这样:name[.content.type].renderer;content.type 可选,缺省情况下为文本格式,你也可以手工指定为 text.plain,要发送HTML格式的邮件就需要指定为 text.html;文件后缀 renderer 一般情况下都是 erb(如果你用了HAML插件,模板后缀名应该是haml)。下面我们将之前文本格式的邮件模板修改为网页形式的:

    $ mv app/views/user_mailer/confirm.erb app/views/user_mailer/confirm.text.html.erb

    再次清空users表,重新注册404这个用户,然后前往邮箱看看我们收到的邮件是否是网页格式的。

   

    我们看到激活帐号的超链接生效了(没有将超链接标签明文显示),这说明系统发送出去的确实是HTML邮件。

    接下来我们点击邮件中的链接来到了激活帐号的页面,我们看到帐号激活成功的提示信息。

   

    如果激活成功,数据库中的activation_token字段应该是空值,且activated_at字段的值应该为一时间戳;在之前的程序中,我们确实是按此逻辑编码的。虽然测试成功,而且我们也非常顺利地亲历了一遍注册流程,那么是否就说明我们的应用程序没有程序上的漏洞了吗?我们真的激活帐号了吗?我们不妨看看数据库这只黑匣子,此时应该是让数据说话的时候了。

    $ ruby script/console
    >> User.find_by_username('404', :select => "username, activation_token, activated_at")

   

    哎呀!记录居然没被更新,看来我们被表面现象给忽悠了。我想你此时也和我一样迷惑,为什么数据记录没有被更新呢?这中间到底发生了什么?这让我不由自主地想象起来,也许Rails的ORM真的修改了User实例对象的activation_token和activated_at属性的值,只不过还没有成功地写入到数据库里边而已。果真如此吗?如何证明这一说法成立呢?我们来看看User模型类的 email_confirm! 方法,下面是email_confirm! 方法的源码:

    # 激活帐号
    def email_confirm!
      update_attributes(:activation_token => nil, :activated_at => Time.now)
    end


    我们知道,update_attributes 方法还有一个和自己长得差不多一样的方法,即 update_attributes!
;后者比前者仅仅多一个感叹号而已,两者都是更新当前模型对象所指向的数据记录,只不过前者更新失败会返回false,后者更新失败则会抛出异常信息并停止程序运行,我们不妨用 update_attributes! 替换 email_confirm!方法中的update_attributes,如果问题真的出现在这里,至少我们也可以看见抛出的错误信息,这些错误调试信息对开发人员来说是那么的重要。

    $ gedit app/models/user.rb

    修改 email_confirm! 方法如下:

    def email_confirm!
      update_attributes!(:activation_token => nil, :activated_at => Time.now)
    end


    保存,然后重新访问或刷新激活帐号的页面,我们看到系统捕获到了非常实用的情报,如图:

   

    看来问题还真的出在User模型类的email_confirm!方法这里,当程序尝试更新 activation_token 和 activated_at 这两个字段时,系统告诉我们密码不能为空并就此打住,程序抛出错误并停止执行,后面当然不会更新数据库里边的记录了。找到出错的原因后,我们马上就明白 update_attributes(或update_attributes!) 会更新当前对象所指向的记录的所有字段,并在更新之前执行数据校验,如果校验失败就会打断程序的运行。想到此,针对问题的解决方案也初现轮廓,只要程序更新指定的字段,并在更新这些指定字段的时候不去校验其他字段的数据有效性就行了。OK,我们有非常适合用于email_confirm!的替代写法,不妨修改email_confirm!方法如下:

    $ gedit app/models/user.rb

    # 激活帐号
    def email_confirm!
      self.activation_token = nil
      self.activated_at = Time.now
      save(false)
    end


    上述代码中的 save 方法会更新这些字段的值,第一个参数的值指明为false后将不会执行数据校验,看来这一切和我们的想法非常吻合,不妨保存user.rb再刷新几次浏览器看看。第一次刷新和我们初次访问激活链接看到的效果一样,都是提示帐号激活成功,后面几次就看不到激活成功的消息了,因为帐号只需要激活成功一次就足够了,效果确实很理想,我们去访问下数据库让它给我们做个见证。

    $ ruby script/console
    >> User.find_by_username('404', :select => "username, activation_token, activated_at")

   

    哈哈,数据记录已经更新了,这意味着程序已经可以按照我们之前的意愿运行了。用户提交注册资料后会收到一封关于激活帐号的邮件,然后点击其中的链接可以成功激活他的帐号。

    至此,我们在 email_activation 分支上的开发工作已经顺利完成,可以将工作成果归并到主干中去了。


### 提交工作成果到GIT仓库 ###

    $ git add .
    $ git commit -m
"People can activation their accounts by the confirm emails."
    $ git checkout master
    $ git merge email_activation
    $ git branch -d email_activation
    $ git tag v2


    (注意,真正的开发中可不是到功能开发完毕了才commit,而是边开发边add和commit。为了方便演示编码过程,文章中没有一一列举。)


### 小结 ###

    在这篇教程中,我们的开发工作遇到了不小的挫折,尤其是在人工测试那里,经过我们自己动手测试后,才知晓我们的程序漏洞百出。之所以这样,是由于笔者有意而为之,其实笔者的用意非常简单,就是想告诉开发者亲临现场做人工测试的重要性。也许确实让您受挫了,觉得好像是为了测试而测试似的;大可不必有如此想法,如果您是位Rails熟手,想必也不会犯那些低级错误,比如update_attributes和save(false)这种区别及其应用场合,也会知晓 test/development/production 这几种环境的区别;那也就避免了些不必要的麻烦。经验是慢慢积累的,过程可以帮我们汲取经验。等您自己应用熟练了,我相信您能体会到测试带来的好处。


### 下节预告 ###

    接下来我们依然是借助cucumber+rspec来驱动用户登录功能的开发,看测试跟session和cookie打交道。如果有兴趣,期待您能够下次光临!如果有好的建议和经验非常希望能够与您交流,您可以在下面发表留言或者和我email联系,我的邮箱是 xuliicom@gmail.com。

标签: , , ,