12.5 练习

最后更新于:2022-04-01 22:30:45

# 12.5 练习 电子书中有练习的答案,如果想阅读参考答案,请[购买电子书](http://railstutorial-china.org/#purchase)。 避免练习和正文冲突的方法参见[第 3 章练习](chapter3.html#mostly-static-pages-exercises)中的说明。 1. 编写测试,检查首页和资料页面显示的数量统计。提示:写入[代码清单 11.27](chapter11.html#listing-user-profile-test) 的测试文件中。(想一下为什么没单独测试首页显示的数量统计。) 2. 编写测试,检查首页正确显示了动态流的第一页。模板参见[代码清单 12.49](#listing-home-feed-test)。注意,我们使用 `CGI.escapeHTML` 转义了 HTML,想一下为什么要这么做。(把转义的代码去掉,仔细查看不匹配的微博内容源码。) ##### 代码清单 12.49:测试动态流的 HTML GREEN test/integration/following_test.rb ``` require 'test_helper' class FollowingTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) log_in_as(@user) end . . . test "feed on Home page" do get root_path @user.feed.paginate(page: 1).each do |micropost| assert_match CGI.escapeHTML(FILL_IN), FILL_IN end end end ```
';

12.4 小结

最后更新于:2022-04-01 22:30:43

# 12.4 小结 实现了动态流后,本书的演示应用就开发完了。这个应用演示了 Rails 的全部重要功能,包括模型、视图、控制器、模板、局部视图、过滤器、数据验证、回调、`has_many`/`belongs_to` 关联、`has_many :through` 关联、安全、测试和部署。 除此之外,Rails 还有很多功能值得我们学习。下面提供了一些后续学习资源,可在以后的学习中优先使用。 ## 12.4.1 后续的学习资源 商店和网上都有很多 Rails 资源,而且多得让你挑花眼。可喜的是,读完这本书后,你已经可以学习几乎所有其他的知识了。下面是建议你后续学习的资源: * [本书配套视频](http://screencasts.railstutorial.org/):我为本书录制了内容充足的配套视频,除了覆盖本书的内容之外,还介绍了很多小技巧。当然视频还能弥补印刷书的不足,让你观看我是如何开发的。你可以在[本书的网站](http://railstutorial.org/)中购买这些视频。 * [RailsCasts](http://railscasts.com/):我建议你浏览一下 [RailsCasts 的视频归档](http://railscasts.com/episodes/archive),观看你感兴趣的视频。 * [Tealeaf Academy](http://www.gotealeaf.com/railstutorial):近些年出现了很多面授开发课程,我建议你参加一个当地的培训。其中 [Tealeaf Academy](http://www.gotealeaf.com/railstutorial) 是线上课程,可在任何地方学习。Tealeaf 的课程组织良好,而且能得到老师的指导。 * [Thinkful](http://www.thinkful.com/a/railstutorial):没 Tealeaf 那么高级的课程(和本书的难度差不多)。 * [RailsApps](https://tutorials.railsapps.org/hartl):很有启发性的 Rails 示例应用。 * [Code School](http://mbsy.co/6VQ8l):很多交互式编程课程。 * Ruby 和 Rails 相关的书:若想进一步学习 Ruby,我推荐阅读 Peter Cooper 写的《[Beginning Ruby](http://www.amazon.com/gp/product/1430223634)》,David A. Black 写的《[The Well-Grounded Rubyist](http://www.amazon.com/gp/product/1933988657)》,Russ Olsen 写的《[Eloquent Ruby](http://www.amazon.com/Eloquent-Ruby-Addison-Wesley-Professional-Series/dp/0321584104/)》和 Hal Fulton 写的《[The Ruby Way](http://www.amazon.com/gp/product/0672328844)》。若想进一步学习 Rails,我推荐阅读 Sam Ruby、Dave Thomas 和 David Heinemeier Hansson 合著的《[Agile Web Development with Rails](https://pragprog.com/book/rails4/agile-web-development-with-rails-4)》,Obie Fernandez 写的《[The Rails 4 Way](http://www.amazon.com/Rails-Edition-Addison-Wesley-Professional-Series/dp/0321944275)》以及 Ryan Bigg 和 Yehuda Katz 合著的《[Rails 4 in Action](http://www.amazon.com/Rails-4-Action-Ryan-Bigg/dp/1617291099)》。 ## 12.4.2 读完本章学到了什么 * 使用 `has_many :through` 可以实现数据模型之间的复杂关系; * `has_many` 方法有很多可选的参数,可用来指定对象的类名和外键名; * 使用 `has_many` 和 `has_many :through`,并且指定合适的类名和外键名,可以实现“主动关系”和“被动关系”; * Rails 支持嵌套路由; * `where` 方法可以创建灵活且强大的数据库查询; * Rails 支持使用低层 SQL 语句查询数据库; * 把本书实现的所有功能放在一起,最终实现了一个能关注用户并且显示动态流的应用。
';

12.3 动态流

最后更新于:2022-04-01 22:30:41

# 12.3 动态流 接下来我们要实现演示应用最难的功能:微博动态流。基本上本节的内容算是全书最高深的。完整的动态流以 [11.3.3 节](chapter11.html#a-proto-feed)的动态流原型为基础实现,动态流中除了当前用户自己的微博之外,还包含他关注的用户发布的微博。我们会采用循序渐进的方式实现动态了。在实现的过程中,会用到一些相当高级的 Rails、Ruby 和 SQL 技术。 因为我们要做的事情很多,在此之前最好先清楚我们要实现的是什么样的功能。[图 12.5](#fig-page-flow-home-page-feed-mockup) 显示了最终要实现的动态流,[图 12.21](#fig-home-page-feed-mockup) 是同一幅图。 ## 12.3.1 目的和策略 我们对动态流的构思很简单。[图 12.22](#fig-user-feed) 中显示了一个示例的 `microposts` 表和要显示的动态。动态流就是要把当前用户关注的用户发布的微博(也包括当前用户自己的微博)从 `microposts` 表中取出来,如图中的箭头所示。 ![page flow home page feed mockup bootstrap](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_573330818264f.png)图 12.21:某个用户登录后看到的首页,显示有动态流![user feed](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_573330819a3fb.png)图 12.22:ID 为 1 的用户关注了 ID 为 2,7,8,10 的用户后得到的动态流 虽然我们还不知道怎么实现动态流,但测试的方法很明确,所以我们先写测试。测试的关键是要覆盖三种情况:动态流中既要包含关注的用户发布的微博,还要有用户自己的微博,但是不能包含未关注用户的微博。根据[代码清单 9.43](chapter9.html#listing-users-fixtures-extra-users) 和[代码清单 11.51](chapter11.html#listing-add-micropost-different-owner) 中的固件,也就是说,Michael 要能看到 Lana 和自己的微博,但不能看到 Archer 的微博。把这个需求转换成测试,如[代码清单 12.41](#listing-full-feed-test) 所示。(用到了[代码清单 11.44](chapter11.html#listing-proto-status-feed) 中定义的 `feed` 方法。) ##### 代码清单 12.41:测试动态流 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase . . . test "feed should have the right posts" do michael = users(:michael) archer = users(:archer) lana = users(:lana) # 关注的用户发布的微博 lana.microposts.each do |post_following| assert michael.feed.include?(post_following) end # 自己的微博 michael.microposts.each do |post_self| assert michael.feed.include?(post_self) end # 未关注用户的微博 archer.microposts.each do |post_unfollowed| assert_not michael.feed.include?(post_unfollowed) end end end ``` 当然,现在的动态流只是个原型,测试无法通过: ##### 代码清单 12.42:**RED** ``` $ bundle exec rake test ``` ## 12.3.2 初步实现动态流 有了检查动态流的测试后([代码清单 12.41](#listing-full-feed-test)),我们可以开始实现动态流了。因为要实现的功能有点复杂,因此我们会一点一点实现。首先,我们要知道该使用怎样的查询语句。我们要从 `microposts` 表中取出关注的用户发布的微博(也要取出用户自己的微博)。为此,我们可以使用类似下面的查询语句: ``` SELECT * FROM microposts WHERE user_id IN () OR user_id = ``` 编写这个查询语句时,我们假设 SQL 支持使用 `IN` 关键字检测集合中是否包含指定的元素。(还好,SQL 支持。) [11.3.3 节](chapter11.html#a-proto-feed)实现动态流原型时,我们使用 Active Record 中的 `where` 方法完成上面这种查询([代码清单 11.44](chapter11.html#listing-proto-status-feed))。那时所需的查询很简单,只是通过当前用户的 ID 取出他发布的微博: ``` Micropost.where("user_id = ?", id) ``` 而现在,我们遇到的情况复杂得多,要使用类似下面的代码实现: ``` Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id) ``` 从上面的查询条件可以看出,我们需要生成一个数组,其元素是关注的用户的 ID。生成这个数组的方法之一是,使用 Ruby 中的 `map` 方法,这个方法可以在任意“可枚举”(enumerable)的对象上调用,[[9](#fn-9)]例如由一组元素组成的集合(数组或哈希)。我们在 [4.3.2 节](chapter4.html#blocks)举例介绍过这个方法,现在再举个例子,把整数数组中的元素都转换成字符串: ``` $ rails console >> [1, 2, 3, 4].map { |i| i.to_s } => ["1", "2", "3", "4"] ``` 像上面这种在每个元素上调用同一个方法的情况很常见,所以 Ruby 为此定义了一种简写形式([4.3.2 节](chapter4.html#blocks)简介过)——在 `&` 符号后面跟上被调用方法的符号形式: ``` >> [1, 2, 3, 4].map(&:to_s) => ["1", "2", "3", "4"] ``` 然后再调用 `join` 方法([4.3.1 节](chapter4.html#arrays-and-ranges)),就可以把数组中的元素合并起来组成字符串,各元素之间用逗号加一个空格分开: ``` >> [1, 2, 3, 4].map(&:to_s).join(', ') => "1, 2, 3, 4" ``` 参照上面介绍的方法,我们可以在 `user.following` 中的每个元素上调用 `id` 方法,得到一个由关注的用户 ID 组成的数组。例如,对数据库中的第一个用户而言,可以使用下面的方法得到这个数组: ``` >> User.first.following.map(&:id) => [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51] ``` 其实,因为这种用法太普遍了,所以 Active Record 默认已经提供了: ``` >> User.first.following_ids => [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51] ``` 上述代码中的 `following_ids` 方法是 Active Record 根据 `has_many :following` 关联([代码清单 12.8](#listing-has-many-following-through-active-relationships))合成的。因此,我们只需在关联名后面加上 `_ids` 就可以获取 `user.following` 集合中所有用户的 ID。用户 ID 组成的字符串如下: ``` >> User.first.following_ids.join(', ') => "4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51" ``` 不过,插入 SQL 语句时,无须手动生成字符串,`?` 插值操作会为你代劳(同时也避免了一些数据库之间的兼容问题)。所以,实际上只需要使用 `following_ids` 而已。 所以,之前猜测的写法确实可用: ``` Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id) ``` `feed` 方法的定义如[代码清单 12.43](#listing-initial-working-feed) 所示。 ##### 代码清单 12.43:初步实现的动态流 GREEN app/models/user.rb ``` class User < ActiveRecord::Base . . . # 如果密码重设超时失效了,返回 true def password_reset_expired? reset_sent_at < 2.hours.ago end # 返回用户的动态流 def feed Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id) end # 关注另一个用户 def follow(other_user) active_relationships.create(followed_id: other_user.id) end . . . end ``` 现在测试组件应该可以通过了: ##### 代码清单 12.44:**GREEN** ``` $ bundle exec rake test ``` 在某些应用中,这样的初步实现已经能满足大部分需求了,但这不是我们最终要使用的实现方式。在阅读下一节之前,你可以想一下为什么。(提示:如果用户关注了 5000 个人呢?) ## 12.3.3 子查询 如前一节末尾所说,对 [12.3.2 节](#a-first-feed-implementation)的实现方式来说,如果用户关注了 5000 个人,动态流中的微博数量会变多,性能就会下降。本节,我们会重新实现动态流,在关注的用户数量很多时,性能也很好。 [12.3.2 节](#a-first-feed-implementation)中所用代码的问题是 `following_ids` 这行代码,它会把所有关注的用户 ID 取出,加载到内存,还会创建一个元素数量和关注的用户数量相同的数组。既然[代码清单 12.43](#listing-initial-working-feed) 的目的只是为了检查集合中是否包含了指定的元素,那么就一定有一种更高效的方式。其实 SQL 真得提供了针对这种问题的优化措施:使用“子查询”(subselect),在数据库层查找关注的用户 ID。 针对动态流的重构,先从[代码清单 12.45](#listing-feed-second-cut) 中的小改动开始。 ##### 代码清单 12.45:在获取动态流的 `where` 方法中使用键值对 GREEN app/models/user.rb ``` class User < ActiveRecord::Base . . . # 返回用户的动态流 def feed Micropost.where("user_id IN (:following_ids) OR user_id = :user_id", following_ids: following_ids, user_id: user) end . . . end ``` 为了给下一步重构做准备,我们把 ``` where("user_id IN (?) OR user_id = ?", following_ids, id) ``` 换成了等效的 ``` where("user_id IN (:following_ids) OR user_id = :user_id", following_ids: following_ids, user_id: id) ``` 使用问号做插值虽然可以,但如果要在多处插入同一个值,后一种写法更方便。 上面这段话表明,我们要在 SQL 查询语句中两次用到 `user_id`。具体而言,我们要把下面这行 Ruby 代码 ``` following_ids ``` 换成包含 SQL 语句的代码 ``` following_ids = "SELECT followed_id FROM relationships WHERE follower_id = :user_id" ``` 上面这行代码使用了 SQL 子查询语句。那么针对 ID 为 1 的用户,整个查询语句是这样的: ``` SELECT * FROM microposts WHERE user_id IN (SELECT followed_id FROM relationships WHERE follower_id = 1) OR user_id = 1 ``` 使用子查询后,所有的集合包含关系都交由数据库处理,这样效率更高。 有了这些基础,我们就可以着手实现更高效的动态流了,如[代码清单 12.46](#listing-feed-final) 所示。注意,因为现在使用的是纯 SQL 语句,所以使用插值方式把 `following_ids` 加入语句中,而没使用转义的方式。 ##### 代码清单 12.46:动态流的最终实现 GREEN app/models/user.rb ``` class User < ActiveRecord::Base . . . # 返回用户的动态流 def feed following_ids = "SELECT followed_id FROM relationships WHERE follower_id = :user_id" Micropost.where("user_id IN (#{following_ids}) OR user_id = :user_id", user_id: id) end . . . end ``` 这段代码结合了 Rails、Ruby 和 SQL 的优势,达到了目的,而且做的很好: ##### 代码清单 12.47:**GREEN** ``` $ bundle exec rake test ``` 当然,子查询也不是万能的。对于更大型的网站而言,可能要使用“后台作业”(background job)异步生成动态流。性能优化这个话题已经超出了本书范畴。 现在,动态流完全实现了。[11.3.3 节](chapter11.html#a-proto-feed)已经在首页加入了动态流,下面再次列出来([代码清单 12.48](#listing-real-feed-instance-variable)),以便参考。[第 11 章](chapter11.html#user-microposts)实现的只是动态流原型([图 11.14](chapter11.html#fig-home-with-proto-feed)),添加[代码清单 12.46](#listing-feed-final) 中的代码后,首页显示的动态流完整了,如[图 12.23](#fig-home-page-with-feed) 所示。 ##### 代码清单 12.48:`home` 动作中分页显示的动态流 app/controllers/static_pages_controller.rb ``` class StaticPagesController < ApplicationController def home if logged_in? @micropost = current_user.microposts.build @feed_items = current_user.feed.paginate(page: params[:page]) end end . . . end ``` 现在可以把改动合并到 `master` 分支了: ``` $ bundle exec rake test $ git add -A $ git commit -m "Add user following" $ git checkout master $ git merge following-users ``` 然后再推送到远程仓库,并部署到生产环境: ``` $ git push $ git push heroku $ heroku pg:reset DATABASE $ heroku run rake db:migrate $ heroku run rake db:seed ``` 在生产环境的线上网站中也会显示动态流,如[图 12.24](#fig-live-status-feed) 所示。 ![home page with feed 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333081af264.png)图 12.23:首页,显示有动态流![live status feed](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333081d27fa.png)图 12.24:线上网站中显示的动态流
';

12.2 关注用户的网页界面

最后更新于:2022-04-01 22:30:38

# 12.2 关注用户的网页界面 [12.1 节](#the-relationship-model)用到了很多数据模型技术,可能要花些时间才能完全理解。其实,理解这些关联最好的方式是在网页界面中使用。 在本章的导言中,我们介绍了关注用户的操作流程。本节,我们要实现这些构思的页面,以及关注和取消关注功能。我们还会创建两个页面,分别列出我关注的用户和关注我的用户。在 [12.3 节](#the-status-feed),我们会实现用户的动态流,届时,这个演示应用才算完成。 ## 12.2.1 示例数据 和之前的几章一样,我们要使用 Rake 任务把“关系”相关的种子数据加载到数据库中。有了示例数据,我们就可以先实现网页界面,本节末尾再实现后端功能。 “关系”相关的种子数据如[代码清单 12.14](#listing-sample-relationships) 所示。我们让第一个用户关注第 3-51 个用户,并让第 4-41 个用户关注第一个用户。这样的数据足够用来开发应用的界面了。 ##### 代码清单 12.14:在种子数据中添加“关系”相关的数据 db/seeds.rb ``` # Users User.create!(name: "Example User", email: "example@railstutorial.org", password: "foobar", password_confirmation: "foobar", admin: true, activated: true, activated_at: Time.zone.now) 99.times do |n| name = Faker::Name.name email = "example-#{n+1}@railstutorial.org" password = "password" User.create!(name: name, email: email, password: password, password_confirmation: password, activated: true, activated_at: Time.zone.now) end # Microposts users = User.order(:created_at).take(6) 50.times do content = Faker::Lorem.sentence(5) users.each { |user| user.microposts.create!(content: content) } end # Following relationships users = User.all user = users.first following = users[2..50] followers = users[3..40] following.each { |followed| user.follow(followed) } followers.each { |follower| follower.follow(user) } ``` 然后像之前一样,执行下面的命令,运行[代码清单 12.14](#listing-sample-relationships) 中的代码: ``` $ bundle exec rake db:migrate:reset $ bundle exec rake db:seed ``` ## 12.2.2 数量统计和关注表单 现在示例用户已经关注了其他用户,也被其他用户关注了,我们要更新一下用户资料页面和首页,把这些变动显示出来。首先,我们要创建一个局部视图,在资料页面和首页显示我关注的人和关注我的人的数量。然后再添加关注和取消关注表单,并且在专门的页面中列出我关注的用户和关注我的用户。 [12.1.1 节](#a-problem-with-the-data-model-and-a-solution)说过,我们参照了 Twitter 的叫法,在我关注的用户数量后使用“following”作标记(label),例如“50 following”。[图 12.1](#fig-page-flow-profile-mockup) 中的构思图就使用了这种表述方式,现在把这部分单独摘出来,如[图 12.10](#fig-stats-partial-mockup) 所示。 ![stats partial mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307e70b4c.png)图 12.10:数量统计局部视图的构思图 [图 12.10](#fig-stats-partial-mockup) 中显示的数量统计包含当前用户关注的人数和关注当前用户的人数,而且分别链接到专门的用户列表页面。在[第 5 章](chapter5.html#filling-in-the-layout),我们使用 `#` 占位符代替真实的网址,因为那时我们还没怎么接触路由。现在,虽然 [12.2.3 节](#following-and-followers-pages)才会创建所需的页面,不过可以先设置路由,如[代码清单 12.15](#listing-following-followers-actions-routes) 所示。这段代码在 `resources` 块中使用了 `:member` 方法。我们以前没用过这个方法,你可以猜测一下这个方法的作用是什么。 ##### 代码清单 12.15:在用户控制器中添加 `following` 和 `followers` 两个动作 config/routes.rb ``` Rails.application.routes.draw do root 'static_pages#home' get 'help' => 'static_pages#help' get 'about' => 'static_pages#about' get 'contact' => 'static_pages#contact' get 'signup' => 'users#new' get 'login' => 'sessions#new' post 'login' => 'sessions#create' delete 'logout' => 'sessions#destroy' resources :users do member do get :following, :followers end end resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] resources :microposts, only: [:create, :destroy] end ``` 你可能猜到了,设定上述路由后,得到的 URL 地址类似 /users/1/following 和 /users/1/followers 这种形式。不错,[代码清单 12.15](#listing-following-followers-actions-routes) 的作用确实如此。因为这两个页面都是用来显示数据的,所以我们使用了 `get` 方法,指定这两个地址响应的是 GET 请求。而且,使用 `member` 方法后,这两个动作对应的 URL 地址中都会包含用户的 ID。除此之外,我们还可以使用 `collection` 方法,但 URL 中就没有用户 ID 了。所以,如下的代码 ``` resources :users do collection do get :tigers end end ``` 得到的 URL 是 /users/tigers(或许可以用来显示应用中所有的老虎)。[[7](#fn-7)] [代码清单 12.15](#listing-following-followers-actions-routes) 生成的路由如[表 12.2](#table-following-routes) 所示。留意一下我关注的用户页面和关注我的用户页面的具名路由是什么,稍后会用到。 表 12.2:[代码清单 12.15](#listing-following-followers-actions-routes) 中设置的规则生成的 REST 路由 | HTTP 请求 | URL | 动作 | 具名路由 | | --- | --- | --- | --- | | GET | /users/1/following | `following` | `following_user_path(1)` | | GET | /users/1/followers | `followers` | `followers_user_path(1)` | 设好了路由后,我们来编写数量统计局部视图。我们要在一个 `div` 元素中显示几个链接,如[代码清单 12.16](#listing-stats-partial) 所示。 ##### 代码清单 12.16:显示数量统计的局部视图 app/views/shared/_stats.html.erb ``` <% @user ||= current_user %> ``` 因为用户资料页面和首页都要使用这个局部视图,所以在[代码清单 12.16](#listing-stats-partial) 的第一行,我们要获取正确的用户对象: ``` <% @user ||= current_user %> ``` 我们在[旁注 8.1](chapter8.html#aside-or-equals)中介绍过这种用法,如果 `@user` 不是 `nil`(在用户资料页面),这行代码没什么效果;如果是 `nil`(在首页),就会把当前用户赋值给 `@user`。还有一处要注意,我关注的人数和关注我的人数是通过关联获取的,分别使用 `@user.following.count` 和 `@user.followers.count`。 我们可以和[代码清单 11.23](chapter11.html#listing-user-show-microposts) 中获取微博数量的代码对比一下,微博的数量通过 `@user.microposts.count` 获取。为了提高效率,Rails 会直接在数据库层统计数量。 最后还有一个细节需要注意,某些元素指定了 CSS ID,例如: ``` ... ``` 这些 ID 是为 [12.2.5 节](#a-working-follow-button-with-ajax)中的 Ajax 准备的,因为 Ajax 要通过独一无二的 ID 获取页面中的元素。 编写好局部视图,把它放入首页就很简单了,如[代码清单 12.17](#listing-home-page-stats) 所示。 ##### 代码清单 12.17:在首页显示数量统计 app/views/static_pages/home.html.erb ``` <% if logged_in? %>

Micropost Feed

<%= render 'shared/feed' %>
<% else %> . . . <% end %> ``` 我们要添加一些 SCSS 代码,美化数量统计,如[代码清单 12.18](#listing-stats-css) 所示(包含本章用到的所有样式)。添加样式后,首页如[图 12.11](#fig-home-page-follow-stats) 所示。 ##### 代码清单 12.18:首页侧边栏的 SCSS 样式 app/assets/stylesheets/custom.css.scss ``` . . . /* sidebar */ . . . .gravatar { float: left; margin-right: 10px; } .gravatar_edit { margin-top: 15px; } .stats { overflow: auto; margin-top: 0; padding: 0; a { float: left; padding: 0 10px; border-left: 1px solid $gray-lighter; color: gray; &:first-child { padding-left: 0; border: 0; } &:hover { text-decoration: none; color: blue; } } strong { display: block; } } .user_avatars { overflow: auto; margin-top: 10px; .gravatar { margin: 1px 1px; } a { padding: 0; } } .users.follow { padding: 0; } /* forms */ . . . ``` ![home page follow stats 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307e8e1fa.png)图 12.11:显示有数量统计的首页 稍后再把数量统计局部视图添加到用户资料页面中,现在先来编写关注和取消关注按钮的局部视图,如[代码清单 12.19](#listing-follow-form-partial) 所示。 ##### 代码清单 12.19:显示关注或取消关注表单的局部视图 app/views/users/_follow_form.html.erb ``` <% unless current_user?(@user) %>
<% if current_user.following?(@user) %> <%= render 'unfollow' %> <% else %> <%= render 'follow' %> <% end %>
<% end %> ``` 这段代码其实也没做什么,只是把具体的工作分配给 `follow` 和 `unfollow` 局部视图了。我们要再次设置路由,加入“关系”资源,如[代码清单 12.20](#listing-relationships-resource) 所示,和微博资源的设置类似([代码清单 11.29](chapter11.html#listing-microposts-resource))。 ##### 代码清单 12.20:添加“关系”资源的路由设置 config/routes.rb ``` Rails.application.routes.draw do root 'static_pages#home' get 'help' => 'static_pages#help' get 'about' => 'static_pages#about' get 'contact' => 'static_pages#contact' get 'signup' => 'users#new' get 'login' => 'sessions#new' post 'login' => 'sessions#create' delete 'logout' => 'sessions#destroy' resources :users do member do get :following, :followers end end resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] resources :microposts, only: [:create, :destroy] resources :relationships, only: [:create, :destroy] end ``` `follow` 和 `unfollow` 局部视图的代码分别如[代码清单 12.21](#listing-follow-form) 和[代码清单 12.22](#listing-unfollow-form) 所示。 ##### 代码清单 12.21:关注用户的表单 app/views/users/_follow.html.erb ``` <%= form_for(current_user.active_relationships.build) do |f| %>
<%= hidden_field_tag :followed_id, @user.id %>
<%= f.submit "Follow", class: "btn btn-primary" %> <% end %> ``` ##### 代码清单 12.22:取消关注用户的表单 app/views/users/_unfollow.html.erb ``` <%= form_for(current_user.active_relationships.find_by(followed_id: @user.id), html: { method: :delete }) do |f| %> <%= f.submit "Unfollow", class: "btn" %> <% end %> ``` 这两个表单都使用 `form_for` 处理“关系”模型对象,二者之间主要的不同点是,[代码清单 12.21](#listing-follow-form) 用来构建一个新“关系”,而[代码清单 12.22](#listing-unfollow-form) 查找现有的“关系”。很显然,第一个表单会向 `RelationshipsController` 发送 `POST` 请求,创建“关系”(`create` 动作);而第二个表单发送的是 `DELETE` 请求,销毁“关系”(`destroy` 动作)。(这两个动作在 [12.2.4 节](#a-working-follow-button-the-standard-way)编写。)你可能还注意到了,关注用户的表单中除了按钮之外什么内容也没有,但是仍然要把 `followed_id` 发送给控制器。在[代码清单 12.21](#listing-follow-form) 中,我们使用 `hidden_field_tag` 方法把 `followed_id` 添加到表单中,生成的 HTML 如下: ``` ``` [10.2.4 节](chapter10.html#resetting-the-password)说过,隐藏的 `input` 标签会把所需的信息包含在表单中,但在浏览器中不会显示出来。 现在我们可以在资料页面中加入关注表单和数量统计了,如[代码清单 12.23](#listing-user-follow-form-profile-stats) 所示,只需渲染相应的局部视图即可。显示有关注按钮和取消关注按钮的用户资料页面分别如[图 12.12](#fig-profile-follow-button) 和[图 12.13](#fig-profile-unfollow-button) 所示。 ##### 代码清单 12.23:在用户资料页面加入关注表单和数量统计 app/views/users/show.html.erb ``` <% provide(:title, @user.name) %>
<%= render 'follow_form' if logged_in? %> <% if @user.microposts.any? %>

Microposts (<%= @user.microposts.count %>)

    <%= render @microposts %>
<%= will_paginate @microposts %> <% end %>
``` ![profile follow button 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307ea654d.png)图 12.12:某个用户的资料页面([/users/2](http://localhost:3000/users/2)),显示有关注按钮![profile unfollow button 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307eca27c.png)图 12.13:某个用户的资料页面([/users/5](http://localhost:3000/users/5)),显示有取消关注按钮 稍后我们会让这些按钮起作用,而且要使用两种方式实现,一种是常规方式([12.2.4 节](#a-working-follow-button-the-standard-way)),另一种使用 Ajax([12.2.5 节](#a-working-follow-button-with-ajax))。不过在此之前,我们要创建剩下的页面——我关注的用户列表页面和关注我的用户列表页面。 ## 12.2.3 我关注的用户列表页面和关注我的用户列表页面 我关注的用户列表页面和关注我的用户列表页面是资料页面和用户列表页面混合体,在侧边栏显示用户的信息(包括数量统计),再列出一系列用户。除此之外,还会在侧边栏中显示一个用户头像列表。构思图如[图 12.14](#fig-following-mockup)(我关注的用户)和[图 12.15](#fig-followers-mockup)(关注我的用户)所示。 ![following mockup bootstrap](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307f26047.png)图 12.14:我关注的用户列表页面构思图![followers mockup bootstrap](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307f42150.png)图 12.15:关注我的用户列表页面构思图 首先,我们要让这两个页面的地址可访问。按照 Twitter 的方式,访问这两个页面都需要先登录。我们要先编写测试,参照以前的访问限制测试,写出的测试如[代码清单 12.24](#listing-following-followers-authorization-test) 所示。 ##### 代码清单 12.24:我关注的用户列表页面和关注我的用户列表页面的访问限制 test/controllers/users_controller_test.rb ``` require 'test_helper' class UsersControllerTest < ActionController::TestCase def setup @user = users(:michael) @other_user = users(:archer) end . . . test "should redirect following when not logged in" do get :following, id: @user assert_redirected_to login_url end test "should redirect followers when not logged in" do get :followers, id: @user assert_redirected_to login_url end end ``` 在实现这两个页面的过程中,唯一很难想到的是,我们要在用户控制器中添加相应的两个动作。按照[代码清单 12.15](#listing-following-followers-actions-routes) 中的路由设置,这两个动作应该命名为 `following` 和 `followers`。在这两个动作中,需要设置页面的标题、查找用户,获取 `@user.followed_users` 或 `@user.followers`(要分页显示),然后再渲染页面,如[代码清单 12.25](#listing-following-followers-actions) 所示。 ##### 代码清单 12.25:`following` 和 `followers` 动作 RED app/controllers/users_controller.rb ``` class UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy, :following, :followers] . . . def following @title = "Following" @user = User.find(params[:id]) @users = @user.following.paginate(page: params[:page]) render 'show_follow' end def followers @title = "Followers" @user = User.find(params[:id]) @users = @user.followers.paginate(page: params[:page]) render 'show_follow' end private . . . end ``` 读过本书前面的内容我们发现,按照 Rails 的约定,动作最后都会隐式渲染对应的视图,例如 `show` 动作最后会渲染 `show.html.erb`。而[代码清单 12.25](#listing-following-followers-actions) 中的两个动作都显式调用了 `render` 方法,渲染一个名为 `show_follow` 的视图。下面我们就来编写这个视图。这两个动作之所以使用同一个视图,是因为两种情况用到的 ERb 代码差不多,如[代码清单 12.26](#listing-show-follow-view) 所示。 ##### 代码清单 12.26:渲染我关注的用户列表页面和关注我的用户列表页面的 `show_follow` 视图 app/views/users/show_follow.html.erb ``` <% provide(:title, @title) %>

<%= @title %>

<% if @users.any? %> <%= will_paginate %> <% end %>
``` [代码清单 12.25](#listing-following-followers-actions) 中的动作会按需渲染[代码清单 12.26](#listing-show-follow-view) 中的视图,分别显式我关注的用户列表和关注我的用户列表,如[图 12.16](#fig-user-following) 和[图 12.17](#fig-user-followers) 所示。注意,上述代码都没有到“当前用户”,所以这两个链接对其他用户也可用,如[图 12.18](#fig-different-user-followers) 所示。 ![user following 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307f60bd8.png)图 12.16:显示某个用户关注的人![user followers 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307fa22c6.png)图 12.17:显示关注某个用户的人![diferent user followers 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307fbf28f.png)图 12.18:显示关注另一个用户的人 现在,这两个页面可以使用了,下面要编写一些简短的集成测试,确认表现正确。这些测试只是健全检查,无需面面俱到。正如 [5.3.4 节](chapter5.html#layout-link-tests)所说的,全面的测试,例如检查 HTML 结构,并不牢靠,而且可能适得其反。对这两个页面来说,我们计划确认显示的数量正确,而且页面中有指向正确的 URL 的链接。 首先,和之前一样,生成一个集成测试文件: ``` $ rails generate integration_test following invoke test_unit create test/integration/following_test.rb ``` 然后,准备测试数据。我们要在“关系”固件中创建一些关注关系。[11.2.3 节](chapter11.html#profile-micropost-tests)使用下面的代码把微博和用户关联起来: ``` orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> user: michael ``` 注意,我们没有用 `user_id: 1`,而是 `user: michael`。 按照这样的方式编写“关系”固件,如[代码清单 12.27](#listing-relationships-fixtures) 所示。 ##### 代码清单 12.27:“关系”固件 test/fixtures/relationships.yml ``` one: follower: michael followed: lana two: follower: michael followed: malory three: follower: lana followed: michael four: follower: archer followed: michael ``` 在这些固件中,Michael 关注了 Lana 和 Malory,Lana 和 Archer 关注了 Michael。为了测试数量,我们可以使用检查资料页面中微博数量的 `assert_match` 方法([代码清单 11.27](chapter11.html#listing-user-profile-test))。然后再检查页面中有没有正确的链接,如[代码清单 12.28](#listing-following-tests) 所示。 ##### 代码清单 12.28:测试我关注的用户列表页面和关注我的用户列表页面 GREEN test/integration/following_test.rb ``` require 'test_helper' class FollowingTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) log_in_as(@user) end test "following page" do get following_user_path(@user) assert_not @user.following.empty? assert_match @user.following.count.to_s, response.body @user.following.each do |user| assert_select "a[href=?]", user_path(user) end end test "followers page" do get followers_user_path(@user) assert_not @user.followers.empty? assert_match @user.followers.count.to_s, response.body @user.followers.each do |user| assert_select "a[href=?]", user_path(user) end end end ``` 注意,在这段测试中有下面这个断言: ``` assert_not @user.following.empty? ``` 如果不加入这个断言,下面这段代码就没有实际意义: ``` @user.following.each do |user| assert_select "a[href=?]", user_path(user) end ``` (对关注我的用户列表页面的测试也是一样。) 测试组件应该可以通过: ##### 代码清单 12.29:**GREEN** ``` $ bundle exec rake test ``` ## 12.2.4 关注按钮的常规实现方式 视图创建好了,下面我们要让关注和取消关注按钮起作用。因为关注和取消关注涉及到创建和销毁“关系”,所以我们需要一个控制器。像之前一样,我们使用下面的命令生成这个控制器: ``` $ rails generate controller Relationships ``` 在[代码清单 12.31](#listing-relationships-controller) 中会看到,限制访问这个控制器中的动作没有太大的意义,但我们还是要加入安全机制。我们要在测试中确认,访问这个控制器中的动作之前要先登录(没登录就重定向到登录页面),而且数据库中的“关系”数量没有变化,如[代码清单 12.30](#listing-relationships-access-control) 所示。 ##### 代码清单 12.30:`RelationshipsController` 基本的访问限制测试 RED test/controllers/relationships_controller_test.rb ``` require 'test_helper' class RelationshipsControllerTest < ActionController::TestCase test "create should require logged-in user" do assert_no_difference 'Relationship.count' do post :create end assert_redirected_to login_url end test "destroy should require logged-in user" do assert_no_difference 'Relationship.count' do delete :destroy, id: relationships(:one) end assert_redirected_to login_url end end ``` 在 `RelationshipsController` 中添加 `logged_in_user` 事前过滤器后,这个测试就能通过,如[代码清单 12.31](#listing-relationships-controller) 所示。 ##### 代码清单 12.31:`RelationshipsController` 的访问限制 GREEN app/controllers/relationships_controller.rb ``` class RelationshipsController < ApplicationController before_action :logged_in_user def create end def destroy end end ``` 为了让关注和取消关注按钮起作用,我们需要找到表单中 `followed_id` 字段(参见[代码清单 12.21](#listing-follow-form) 和[代码清单 12.22](#listing-unfollow-form))对应的用户,然后再调用[代码清单 12.10](#listing-follow-unfollow-following) 中定义的 `follow` 或 `unfollow` 方法。各动作完整的实现如[代码清单 12.32](#listing-relationships-controller-following) 所示。 ##### 代码清单 12.32:`RelationshipsController` 的代码 app/controllers/relationships_controller.rb ``` class RelationshipsController < ApplicationController before_action :logged_in_user def create user = User.find(params[:followed_id]) current_user.follow(user) redirect_to user end def destroy user = Relationship.find(params[:id]).followed current_user.unfollow(user) redirect_to user end end ``` 从这段代码中可以看出为什么前面说“限制访问没有太大意义”:如果未登录的用户直接访问某个动作(例如使用 `curl` 等命令行工具),`current_user` 的值是 `nil`,执行到这两个动作的第二行代码时会抛出异常,即得到一个错误,但对应用和数据来说都没危害。不过完全依赖这样的表现也不好,所以我们添加了一层安全防护措施。 现在,关注和取消关注功能都能正常使用了,任何用户都可以关注或取消关注其他用户。你可以在浏览器中点击相应的按钮验证一下。(我们会在 [12.2.6 节](#following-tests)编写集成测试检查这些操作。)关注第二个用户前后显示的资料页面如[图 12.19](#fig-unfollowed-user) 和[图 12.20](#fig-followed-user) 所示。 ![unfollowed user](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307fe46f4.png)图 12.19:关注前的资料页面![followed user](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_573330800ef35.png)图 12.20:关注后的资料页面 ## 12.2.5 关注按钮的 Ajax 实现方式 虽然关注用户的功能已经完全实现了,但在实现动态流之前,还有可以增强的地方。你可能已经注意到了,在 [12.2.4 节](#a-working-follow-button-the-standard-way)中,`RelationshipsController` 中的 `create` 和 `destroy` 动作最后都返回了一开始访问的用户资料页面。也就是说,用户 A 先访问用户 B 的资料页面,点击关注按钮关注用户 B,然后页面立即又转回到用户 B 的资料页面。因此,对这样的流程我们有一个疑问:为什么要多一次页面转向呢? Ajax [[8](#fn-8)]可以解决这种问题。Ajax 向服务器发送异步请求,在不刷新页面的情况下更新页面的内容。因为经常要在表单中处理 Ajax 请求,所以 Rails 提供了简单的实现方式。其实,关注和取消关注表单局部视图不用做大的改动,只要把 `form_for` 改成 `form_for…​, remote: true`,Rails 就会自动使用 Ajax 处理表单。这两个局部视图更新后的版本如[代码清单 12.33](#listing-follow-form-ajax) 和[代码清单 12.34](#listing-unfollow-form-ajax) 所示。 ##### 代码清单 12.33:使用 Ajax 处理关注用户的表单 app/views/users/_follow.html.erb ``` <%= form_for(current_user.active_relationships.build, remote: true) do |f| %>
<%= hidden_field_tag :followed_id, @user.id %>
<%= f.submit "Follow", class: "btn btn-primary" %> <% end %> ``` ##### 代码清单 12.34:使用 Ajax 处理取消关注用户的表单 app/views/users/_unfollow.html.erb ``` <%= form_for(current_user.active_relationships.find_by(followed_id: @user.id), html: { method: :delete }, remote: true) do |f| %> <%= f.submit "Unfollow", class: "btn" %> <% end %> ``` 上述 ERb 代码生成的 HTML 没什么好说的,如果你好奇的话,可以看一下(细节可能不同): ```
. . .
``` 可以看出,`form` 标签中设定了 `data-remote="true"`,这个属性告诉 Rails,这个表单可以使用 JavaScript 处理。Rails 遵从了“[非侵入式 JavaScript](http://railscasts.com/episodes/205-unobtrusive-javascript)”原则(unobtrusive JavaScript),没有直接在视图中写入 JavaScript 代码(Rails 之前的版本直接写入了 JavaScript 代码),而是使用了一个简单的 HTML 属性。 修改表单后,我们要让 `RelationshipsController` 响应 Ajax 请求。为此,我们要使用 `respond_to` 方法,根据请求的类型生成合适的响应。例如: ``` respond_to do |format| format.html { redirect_to user } format.js end ``` 这种写法可能会让人困惑,其实只有一行代码会执行。(`respond_to` 块中的代码更像是 `if-else` 语句,而不是代码序列。)为了让 `RelationshipsController` 响应 Ajax 请求,我们要在 `create` 和 `destroy` 动作([代码清单 12.32](#listing-relationships-controller-following))中添加类似上面的 `respond_to` 块,如[代码清单 12.35](#listing-relationships-controller-ajax) 所示。注意,我们把本地变量 `user` 改成了实例变量 `@user`,因为在[代码清单 12.32](#listing-relationships-controller-following) 中无需使用实例变量,而使用 Ajax 处理的表单([代码清单 12.33](#listing-follow-form-ajax) 和[代码清单 12.34](#listing-unfollow-form-ajax))则需要使用。 ##### 代码清单 12.35:在 `RelationshipsController` 中响应 Ajax 请求 app/controllers/relationships_controller.rb ``` class RelationshipsController < ApplicationController before_action :logged_in_user def create @user = User.find(params[:followed_id]) current_user.follow(@user) respond_to do |format| format.html { redirect_to @user } format.js end end def destroy @user = Relationship.find(params[:id]).followed current_user.unfollow(@user) respond_to do |format| format.html { redirect_to @user } format.js end end end ``` [代码清单 12.35](#listing-relationships-controller-ajax) 中的代码会优雅降级(不过要配置一个选项,如[代码清单 12.36](#listing-degrade-gracefully) 所示),如果浏览器不支持 JavaScript,也能正常运行。 ##### 代码清单 12.36:添加优雅降级所需的配置 config/application.rb ``` require File.expand_path('../boot', __FILE__) . . . module SampleApp class Application < Rails::Application . . . # 在处理 Ajax 的表单中添加真伪令牌 config.action_view.embed_authenticity_token_in_remote_forms = true end end ``` 当然,如果支持 JavaScript,也能正确的响应。如果是 Ajax 请求,Rails 会自动调用包含 JavaScript 的嵌入式 Ruby 文件(`.js.erb`),文件名和动作一样,例如 `create.js.erb` 或 `destroy.js.erb`。你可能猜到了,在这种的文件中既可以使用 JavaScript 也可以使用嵌入式 Ruby 处理当前页面。所以,为了更新关注后和取消关注后的页面,我们要创建这种文件。 在 JS-ERb 文件中,Rails 自动提供了 [jQuery](http://jquery.com/) 库的辅助函数,可以通过“[文档对象模型](http://www.w3.org/DOM/)”(Document Object Model,简称 DOM)处理页面中的内容。jQuery 库中有很多处理 DOM 的方法,但现在我们只会用到其中两个。首先,我们要知道通过 ID 获取 DOM 元素的美元符号,例如,要获取 `follow_form` 元素,可以使用如下的代码: ``` $("#follow_form") ``` (参见[代码清单 12.19](#listing-follow-form-partial),这个元素是包含表单的 `div`,而不是表单本身。)上面的句法和 CSS 一样,`#` 符号表示 CSS 中的 ID。由此你可能猜到了,jQuery 和 CSS 一样,使用点号 `.` 表示 CSS 中的类。 我们要使用的第二个方法是 `html`,使用指定的内容修改元素中的 HTML。例如,如果要把整个表单换成字符串 `"foobar"`,可以这么写: ``` $("#follow_form").html("foobar") ``` 和常规的 JavaScript 文件不同,JS-ERb 文件还可以使用嵌入式 Ruby 代码。在 `create.js.erb` 文件中,(成功关注后)我们会把关注用户表单换成取消关注用户表单,并更新关注数量,如[代码清单 12.37](#listing-create-js-erb) 所示。这段代码中用到了 `escape_javascript` 方法,在 JavaScript 中写入 HTML 代码必须使用这个方法对 HTML 进行转义。 ##### 代码清单 12.37:创建“关系”的 JS-ERb 代码 app/views/relationships/create.js.erb ``` $("#follow_form").html("<%= escape_javascript(render('users/unfollow')) %>") $("#followers").html('<%= @user.followers.count %>') ``` `destroy.js.erb` 文件的内容类似,如[代码清单 12.38](#listing-destroy-js-erb) 所示。 ##### 代码清单 12.38:销毁“关系”的 JS-ERb 代码 app/views/relationships/destroy.js.erb ``` $("#follow_form").html("<%= escape_javascript(render('users/follow')) %>") $("#followers").html('<%= @user.followers.count %>') ``` 加入上述代码后,你应该访问用户资料页面,看一下关注或取消关注用户后页面是不是真的没有刷新。 ## 12.2.6 关注功能的测试 关注按钮可以使用了,现在我们要编写一些简单的测试,避免回归。关注用户时,我们要向相应的地址发送 `POST` 请求,确认关注的人数增加了一个: ``` assert_difference '@user.following.count', 1 do post relationships_path, followed_id: @other.id end ``` 这是测试普通请求的方式,测试 Ajax 请求的方式基本类似,把 `post` 换成 `xhr :post` 即可: ``` assert_difference '@user.following.count', 1 do xhr :post, relationships_path, followed_id: @other.id end ``` 我们使用 `xhr` 方法(表示 XmlHttpRequest)发起 Ajax 请求,目的是执行 `respond_to` 块中对应于 JavaScript 的代码([代码清单 12.35](#listing-relationships-controller-ajax))。 取消关注的测试类似,只需把 `post` 换成 `delete`。在下面的代码中,我们检查关注的人数减少了一个,而且指定了“关系”的 ID: 普通请求: ``` assert_difference '@user.following.count', -1 do delete relationship_path(relationship), relationship: relationship.id end ``` Ajax 请求: ``` assert_difference '@user.following.count', -1 do xhr :delete, relationship_path(relationship), relationship: relationship.id end ``` 综上所述,测试如[代码清单 12.39](#listing-follow-button-tests) 所示。 ##### 代码清单 12.39:测试关注和取消关注按钮 GREEN test/integration/following_test.rb ``` require 'test_helper' class FollowingTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) @other = users(:archer) log_in_as(@user) end . . . test "should follow a user the standard way" do assert_difference '@user.following.count', 1 do post relationships_path, followed_id: @other.id end end test "should follow a user with Ajax" do assert_difference '@user.following.count', 1 do xhr :post, relationships_path, followed_id: @other.id end end test "should unfollow a user the standard way" do @user.follow(@other) relationship = @user.active_relationships.find_by(followed_id: @other.id) assert_difference '@user.following.count', -1 do delete relationship_path(relationship) end end test "should unfollow a user with Ajax" do @user.follow(@other) relationship = @user.active_relationships.find_by(followed_id: @other.id) assert_difference '@user.following.count', -1 do xhr :delete, relationship_path(relationship) end end end ``` 测试组件应该能通过: ##### 代码清单 12.40:**GREEN** ``` $ bundle exec rake test ```
';

12.1 “关系”模型

最后更新于:2022-04-01 22:30:36

# 12.1 “关系”模型 为了实现用户关注功能,首先要创建一个看上去并不是那么直观的数据模型。一开始我们可能会认为 `has_many` 关联能满足我们的要求:一个用户关注多个用户,而且也被多个用户关注。但实际上这种实现方式有问题,下面我们会学习如何使用 `has_many :through` 解决。 和之前一样,如果使用 Git,现在应该新建一个主题分支: ``` $ git checkout master $ git checkout -b following-users ``` ## 12.1.1 数据模型带来的问题以及解决方法 在构建关注用户所需的数据模型之前,我们先来分析一个典型的案例。假如一个用户关注了另外一个用户,比如 Calvin 关注了 Hobbes,也就是 Hobbes 被 Calvin 关注了,那么 Calvin 就是“关注人”(follower),Hobbes 则是“被关注人”(followed)。按照 Rails 默认的复数命名习惯, 我们称关注了某个用户的所有用户为这个用户的“followers”,因此,`hobbes.followers` 是一个数组,包含所有关注了 Hobbes 的用户。不过,如果顺序颠倒,这种表述就说不通了:默认情况下,所有被关注的用户应该叫“followeds”,但是这样说并不符合英语语法。所以,参照 Twitter 的叫法,我们把被关注的用户叫做“following”(例如,“50 following, 75 followers”)。因此,Calvin 关注的人可以通过 `calvin.following` 数组获取。 经过上述讨论,我们可以按照[图 12.6](#fig-naive-user-has-many-following) 中的方式构建被关注用户的模型——一个 `following` 表和 `has_many` 关联。由于 `user.following` 应该是一个用户对象组成的数组,所以 `following` 表中的每一行都应该是一个用户,通过 `followed_id` 列标识。然后再通过 `follower_id` 列建立关联。[[2](#fn-2)]除此之外,由于每一行都是一个用户,所以还要在表中加入用户的其他属性,例如名字、电子邮件地址和密码等。 ![naive user has many following](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307a1d245.png)图 12.6:用户关注的人(天真方式) [图 12.6](#fig-naive-user-has-many-following) 中的数据模型有个问题——存在非常多的冗余,每一行不仅包括了被关注用户的 ID,还包括了他们的其他信息,而这些信息在 `users` 表中都有。 更糟糕的是,为了保存关注我的人,还需要另一个同样冗余的 `followers` 表。这么做会导致数据模型极难维护:用户修改名字时,不仅要修改 `users` 表中的数据,还要修改 `following` 和 `followers` 表中包含这个用户的每一个记录。 造成这个问题的原因是缺少了一层抽象。找到合适的抽象有一种方法:思考在应用中如何实现关注用户的操作。[7.1.2 节](chapter7.html#a-users-resource)介绍过,REST 架构涉及到资源的创建和销毁两个操作。 由此引出了两个问题:用户关注另一个用户时,创建了什么?用户取消关注另一个用户时,销毁了什么?按照这样的方式思考,我们会发现,在关注用户的过程中,创建和销毁的是两个用户之间的“关系”。因此,一个用户有多个“关系”,从而通过这个“关系”得到很多我关注的人(`following`)和关注我的人(`followers`)。 在实现应用的数据模型时还有一个细节要注意:Facebook 实现的关系是对称的,A 关注 B 时,B 也就关注了 A;而我们要实现的关系和 Twitter 类似,是不对称的,Calvin 可以关注 Hobbes,但 Hobbes 并不需要关注 Calvin。为了区分这两种情况,我们要使用专业的术语:如果 Calvin 关注了 Hobbes,但 Hobbes 没有关注 Calvin,那么 Calvin 和 Hobbes 之间建立的是“主动关系”(Active Relationship),而 Hobbes 和 Calvin 之间是“被动关系”(Positive Relationship)。[[3](#fn-3)] 现在我们集中精力实现“主动关系”,即获取我关注的用户。[12.1.5 节](#followers)会实现“被动关系”。从[图 12.6](#fig-naive-user-has-many-following) 中可以看出实现的方式:既然我关注的每一个用户都由 `followed_id` 独一无二的标识出来了,我们就可以把 `following` 表转化成 `active_relationships` 表,删掉用户的属性,然后使用 `followed_id` 从 `users` 表中获取我关注的用户的信息。这个数据模型如[图 12.7](#fig-user-has-many-following) 所示。 ![user has many following 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307a32226.png)图 12.7:通过“主动关系”获取我关注的用户 因为“主动关系”和“被动关系”最终会存储在同一个表中,所以我们把这个表命名为“relationships”。这个表对应的模型是 `Relationship`,如[图 12.8](#fig-relationship-model) 所示。从 [12.1.4 节](#followed-users)开始,我们会介绍如何使用这个模型同时实现“主动关系”和“被动关系”。 ![relationship model](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307a4e1bf.png)图 12.8:Relationship 数据模型 为此,我们要生成所需的模型: ``` $ rails generate model Relationship follower_id:integer followed_id:integer ``` 因为我们会通过 `follower_id` 和 `followed_id` 查找关系,所以还要为这两个列建立索引,提高查询的效率,如[代码清单 12.1](#listing-relationships-migration) 所示。 ##### 代码清单 12.1:在 `relationships` 表中添加索引 db/migrate/[timestamp]_create_relationships.rb ``` class CreateRelationships < ActiveRecord::Migration def change create_table :relationships do |t| t.integer :follower_id t.integer :followed_id t.timestamps null: false end add_index :relationships, :follower_id add_index :relationships, :followed_id add_index :relationships, [:follower_id, :followed_id], unique: true end end ``` 在[代码清单 12.1](#listing-relationships-migration) 中,我们还设置了一个“多键索引”,确保 (`follower_id, followed_id`) 组合是唯一的,避免多次关注同一个用户。(可以和[代码清单 6.28](chapter6.html#listing-email-uniqueness-index) 中保持电子邮件地址唯一的索引比较一下。)从 [12.1.4 节](#followed-users)起会看到,用户界面不会允许这样的事发生,但添加索引后,如果用户试图创建重复的关系(例如使用 `curl` 这样的命令行工具),应用会抛出异常。 为了创建 `relationships` 表,和之前一样,我们要执行迁移: ``` $ bundle exec rake db:migrate ``` ## 12.1.2 用户和“关系”模型之间的关联 在获取我关注的人和关注我的人之前,我们要先建立用户和“关系”模型之间的关联。一个用户有多个“关系”(`has_many`), 因为一个“关系”涉及到两个用户,所以“关系”同时属于(`belongs_to`)该用户和被关注的用户。 和 [11.1.3 节](chapter11.html#user-micropost-associations)创建时微博一样,我们要通过关联创建“关系”,如下面的代码所示: ``` user.active_relationships.build(followed_id: ...) ``` 此时,你可能想在应用中加入类似于 [11.1.3 节](chapter11.html#user-micropost-associations)使用的代码。我们要添加的代码确实很像,但有两处不同。 首先,把用户和微博关联起来时我们写成: ``` class User < ActiveRecord::Base has_many :microposts . . . end ``` 之所以可以这么写,是因为 Rails 会寻找 `:microposts` 符号对应的模型,即 `Micropost`。[[4](#fn-4)]可是现在模型名为 `Relationship`,而我们想写成: ``` has_many :active_relationships ``` 所以要告诉 Rails 模型的类名。 其次,前面在微博模型中是这么写的: ``` class Micropost < ActiveRecord::Base belongs_to :user . . . end ``` 之所以可以这么写,是因为 `microposts` 表中有识别用户的 `user_id` 列([11.1.1 节](chapter11.html#the-basic-model))。这种连接两个表的列,我们称之为“外键”(foreign key)。当指向用户模型的外键为 `user_id` 时,Rails 会自动获知关联,因为默认情况下,Rails 会寻找名为 `<class>_id` 的外键,其中 `<class>` 是模型类名的小写形式。[[5](#fn-5)]现在,尽管我们处理的还是用户,但识别用户使用的外键是 `follower_id`,所以要告诉 Rails 这一变化。 综上所述,用户和“关系”模型之间的关联如[代码清单 12.2](#listing-user-relationships-association) 和[代码清单 12.3](#listing-relationship-belongs-to) 所示。 ##### 代码清单 12.2:实现“主动关系”中的 `has_many` 关联 app/models/user.rb ``` class User < ActiveRecord::Base has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy . . . end ``` (因为删除用户时也要删除涉及这个用户的“关系”,所以我们在关联中加入了 `dependent: :destroy`。) ##### 代码清单 12.3:在“关系”模型中添加 `belongs_to` 关联 app/models/relationship.rb ``` class Relationship < ActiveRecord::Base belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" end ``` 尽管 [12.1.5 节](#followers)才会用到 `followed` 关联,但同时添加易于理解。 建立上述关联后,会得到一系列类似于[表 11.1](chapter11.html#table-association-methods) 中的方法,如[表 12.1](#table-association-methods-relationships) 所示。 表 12.1:用户和“主动关系”关联后得到的方法简介 | 方法 | 作用 | | --- | --- | | `active_relationship.follower` | 获取关注我的用户 | | `active_relationship.followed` | 获取我关注的用户 | | `user.active_relationships.create(followed_id: other_user.id)` | 创建 `user` 发起的“主动关系” | | `user.active_relationships.create!(followed_id: other_user.id)` | 创建 `user` 发起的“主动关系”(失败时抛出异常) | | `user.active_relationships.build(followed_id: other_user.id)` | 构建 `user` 发起的“主动关系”对象 | ## 12.1.3 数据验证 在继续之前,我们要在“关系”模型中添加一些验证。测试([代码清单 12.4](#listing-relationship-validation-tests))和应用代码([代码清单 12.5](#listing-relationship-validations))都非常直观。和生成的用户固件一样([代码清单 6.29](chapter6.html#listing-default-fixtures)),生成的“关系”固件也违背了迁移中的唯一性约束([代码清单 12.1](#listing-relationships-migration))。这个问题的解决方法也和之前一样([代码清单 6.30](chapter6.html#listing-empty-fixtures))——删除自动生成的固件,如[代码清单 12.6](#listing-empty-relationship-fixture) 所示。 ##### 代码清单 12.4:测试“关系”模型中的验证 test/models/relationship_test.rb ``` require 'test_helper' class RelationshipTest < ActiveSupport::TestCase def setup @relationship = Relationship.new(follower_id: 1, followed_id: 2) end test "should be valid" do assert @relationship.valid? end test "should require a follower_id" do @relationship.follower_id = nil assert_not @relationship.valid? end test "should require a followed_id" do @relationship.followed_id = nil assert_not @relationship.valid? end end ``` ##### 代码清单 12.5:在“关系”模型中添加验证 app/models/relationship.rb ``` class Relationship < ActiveRecord::Base belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" validates :follower_id, presence: true validates :followed_id, presence: true end ``` ##### 代码清单 12.6:删除“关系”固件 test/fixtures/relationships.yml ``` # empty ``` 现在,测试应该可以通过: ##### 代码清单 12.7:**GREEN** ``` $ bundle exec rake test ``` ## 12.1.4 我关注的用户 现在到“关系”的核心部分了——获取我关注的用户(`following`)和关注我的用户(`followers`)。这里我们要首次用到 `has_many :through` 关联:用户通过“关系”模型关注了多个用户,如[图 12.7](#fig-user-has-many-following) 所示。默认情况下,在 `has_many :through` 关联中,Rails 会寻找关联名单数形式对应的外键。例如: ``` has_many :followeds, through: :active_relationships ``` Rails 发现关联名是“followeds”,把它变成单数形式“followed”,因此会在 `relationships` 表中获取一个由 `followed_id` 组成的集合。不过,[12.1.1 节](#a-problem-with-the-data-model-and-a-solution)说过,写成 `user.followeds` 有点说不通,所以我们会使用 `user.following`。Rails 允许定制默认生成的关联方法:使用 `source` 参数指定 `following` 数组由 `followed_id` 组成,如[代码清单 12.8](#listing-has-many-following-through-active-relationships) 所示。 ##### 代码清单 12.8:在用户模型中添加 `following` 关联 app/models/user.rb ``` class User < ActiveRecord::Base has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy has_many :following, through: :active_relationships, source: :followed . . . end ``` 定义这个关联后,我们可以充分利用 Active Record 和数组的功能。例如,可以使用 `include?` 方法([4.3.1 节](chapter4.html#arrays-and-ranges))检查我关注的用户中有没有某个用户,或者通过关联查找一个用户: ``` user.following.include?(other_user) user.following.find(other_user) ``` 很多情况下我们都可以把 `following` 当成数组来用,Rails 会使用特定的方式处理 `following`,所以这么做很高效。例如: ``` following.include?(other_user) ``` 看起来好像是要把我关注的所有用户都从数据库中读取出来,然后再调用 `include?`。其实不然,为了提高效率,Rails 会直接在数据库层执行相关的操作。(和 [11.2.1 节](chapter11.html#rendering-microposts)使用 `user.microposts.count` 获取数量一样,都直接在数据库中操作。) 为了处理关注用户的操作,我们要定义两个辅助方法:`follow` 和 `unfollow`。这样我们就可以写 `user.follow(other_user)`。我们还要定义 `following?` 布尔值方法,检查一个用户是否关注了另一个用户。[[6](#fn-6)] 现在是编写测试的好时机,因为我们还要等很久才会开发关注用户的网页界面,如果一直没人监管,很难向前推进。我们可以为用户模型编写一个简短的测试,先调用 `following?` 方法确认某个用户没有关注另一个用户,然后调用 `follow` 方法关注这个用户,再使用 `following?` 方法确认关注成功了,最后调用 `unfollow` 方法取消关注,并确认操作成功,如[代码清单 12.9](#listing-utility-method-tests) 所示。 ##### 代码清单 12.9:测试关注用户相关的几个辅助方法 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase . . . test "should follow and unfollow a user" do michael = users(:michael) archer = users(:archer) assert_not michael.following?(archer) michael.follow(archer) assert michael.following?(archer) michael.unfollow(archer) assert_not michael.following?(archer) end end ``` 参照[表 12.1](#table-association-methods-relationships),我们要使用 `following` 关联定义 `follow`、`unfollow` 和 `following?` 方法,如[代码清单 12.10](#listing-follow-unfollow-following) 所示。(注意,只要可能,我们就省略 `self`。) ##### 代码清单 12.10:定义关注用户相关的几个辅助方法 GREEN app/models/user.rb ``` class User < ActiveRecord::Base . . . def feed . . . end # 关注另一个用户 def follow(other_user) active_relationships.create(followed_id: other_user.id) end # 取消关注另一个用户 def unfollow(other_user) active_relationships.find_by(followed_id: other_user.id).destroy end # 如果当前用户关注了指定的用户,返回 true def following?(other_user) following.include?(other_user) end private . . . end ``` 现在,测试能通过了: ##### 代码清单 12.11:**GREEN** ``` $ bundle exec rake test ``` ## 12.1.5 关注我的人 “关系”的最后一部分是定义与 `user.following` 对应的 `user.followers` 方法。从[图 12.7](#fig-user-has-many-following) 中得知,获取关注我的人所需的数据都已经存在于 `relationships` 表中(我们要参照[代码清单 12.2](#listing-user-relationships-association) 中实现 `active_relationships` 表的方式)。其实我们要使用的方法和实现我关注的人一样,只要对调 `follower_id` 和 `followed_id` 的位置,并把 `active_relationships` 换成 `passive_relationships` 即可,如[图 12.9](#fig-user-has-many-followers) 所示。 ![user has many followers 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307a6029b.png)图 12.9:通过“被动关系”获取关注我的用户 参照[代码清单 12.8](#listing-has-many-following-through-active-relationships),我们可以使用[代码清单 12.12](#listing-has-many-following-through-passive-relationships) 中的代码实现[图 12.9](#fig-user-has-many-followers) 中的模型。 ##### 代码清单 12.12:使用“被动关系”实现 `user.followers` app/models/user.rb ``` class User < ActiveRecord::Base has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy has_many :passive_relationships, class_name: "Relationship", foreign_key: "followed_id", dependent: :destroy has_many :following, through: :active_relationships, source: :followed has_many :followers, through: :passive_relationships, source: :follower . . . end ``` 值得注意的是,其实我们可以省略 `followers` 关联中的 `source` 参数,直接写成: ``` has_many :followers, through: :passive_relationships ``` 因为 Rails 会把“followers”转换成单数“follower”,然后查找名为 `follower_id` 的外键。[代码清单 12.12](#listing-has-many-following-through-passive-relationships) 之所以保留了 `source` 参数,是为了和 `has_many :following` 关联的结构保持一致。 我们可以使用 `followers.include?` 测试这个数据模型,如[代码清单 12.13](#listing-followers-test) 所示。(这段测试本可以使用与 `following?` 方法对应的 `followed_by?` 方法,但应用中用不到,所以没这么做。) ##### 代码清单 12.13:测试 `followers` 关联 GREEN test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase . . . test "should follow and unfollow a user" do michael = users(:michael) archer = users(:archer) assert_not michael.following?(archer) michael.follow(archer) assert michael.following?(archer) assert archer.followers.include?(michael) michael.unfollow(archer) assert_not michael.following?(archer) end end ``` 我们只在[代码清单 12.9](#listing-utility-method-tests) 的基础上增加了一行代码,但若想让这个测试通过,很多事情都要正确处理才行,所以足以测试[代码清单 12.12](#listing-has-many-following-through-passive-relationships) 中的关联。 现在,整个测试组件都能通过: ``` $ bundle exec rake test ```
';

第 12 章 关注用户

最后更新于:2022-04-01 22:30:34

# 第 12 章 关注用户 这一章,我们要为演示应用添加社交功能,允许用户关注(及取消关注)其他人,并在主页显示被关注用户发布的微博。我们会在 [12.1 节](#the-relationship-model)学习如何建立用户之间的关系,然后在 [12.2 节](#a-web-interface-for-following-users)编写相应的网页界面(还会介绍 Ajax)。最后,在 [12.3 节](#the-status-feed)实现功能完善的动态流。 这是本书最后一章,有些内容具有挑战性,比如说,为了实现动态流,我们会使用一些 Ruby 和 SQL 技巧。 通过这些示例,你会了解到 Rails 是如何处理更加复杂的数据模型的,这些知识也会在你日后开发其他应用时发挥作用。 为了帮助你平稳地从学习过渡到独立开发,[12.4 节](#following-users-conclusion)介绍了一些进阶学习资源。 因为本章的内容比较有挑战性,所以在开始编写代码之前,我们先来讨论一下界面。 和之前的章节一样,在开发之前,我们要使用构思图。[[1](#fn-1)]完整的页面流程是这样的:一个用户 (John Calvin) 从他的资料页面([图 12.1](#fig-page-flow-profile-mockup))浏览到用户列表页面([图 12.2](#fig-page-flow-user-index-mockup)),关注了另一个用户;然后他又打开另一个用户 Thomas Hobbes 的资料页面([图 12.3](#fig-page-flow-other-profile-follow-button)),点击“Follow”(关注)按钮 关注了他,这时“Follow”按钮会变为“Unfollow”(取消关注),而且关注 Hobbes 的人数增加了一个([图 12.4](#fig-page-flow-other-profile-unfollow-button-mockup));接着,Calvin 回到主页,看到他关注的人数也增加了一个,而且在动态流中能看到 Hobbes 发布的微博([图 12.5](#fig-page-flow-home-page-feed-mockup))。本章接下来的内容就是要实现这样的页面流程。 ![page flow profile mockup 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333077ca284.png)图 12.1:一个用户的资料页面![page flow user index mockup bootstrap](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333077e6c49.png)图 12.2:找一个想关注的用户![page flow other profile follow button mockup 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_573330780a4eb.png)图 12.3:想关注的那个用户的资料页面,有一个“Follow”(关注)按钮![page flow other profile unfollow button mockup 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307822e31.png)图 12.4:资料页面中显示了“Unfollow”(取消关注)按钮,而且关注他的人数增加了一个![page flow home page feed mockup 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333078458ae.png)图 12.5:首页,显示了动态流,而且关注的人数增加了一个
';

11.6 练习

最后更新于:2022-04-01 22:30:31

# 11.6 练习 电子书中有练习的答案,如果想阅读参考答案,请[购买电子书](http://railstutorial-china.org/#purchase)。 避免练习和正文冲突的方法参见[第 3 章练习](chapter3.html#mostly-static-pages-exercises)中的说明。 1. 重构首页视图,把 `if-else` 语句的两个分支分别放到单独的局部视图中。 2. 为侧边栏中的微博数量编写测试(还要检查使用了正确的单复数形式)。可以参照[代码清单 11.67](#listing-sidebar-micropost-count)。 3. 以[代码清单 11.68](#listing-image-upload-test) 为模板,为 [11.4 节](#micropost-images)的图片上传程序编写测试。测试之前,要在固件文件夹中放一个图片(例如,可以执行 `cp app/assets/http://railstutorial-china.org/book/images/rails.png test/fixtures/` 命令)。(如果使用 Git,建议你更新 `.gitignore` 文件,如[代码清单 11.69](#listing-gitignore-uploads) 所示。)为了避免出现难以理解的错误,还要配置 CarrierWave,在测试中不调整图片的尺寸。方法是创建一个初始化脚本,写入[代码清单 11.70](#listing-skip-resize-initializer) 中的内容。[代码清单 11.68](#listing-image-upload-test) 中添加的几个断言,检查首页有没有文件上传字段,以及成功提交表单后有没有正确设定 `picture` 属性的值。注意,在测试中上传固件中的文件使用的是专门的 `fixture_file_upload` 方法。[[17](#fn-17)]提示:为了检查 `picture` 属性的值,可以使用 [10.1.4 节](chapter10.html#activation-test-and-refactoring)介绍的 `assigns` 方法,在提交成功后获取 `create` 动作中的 `@micropost` 变量。 ##### 代码清单 11.67:侧边栏中微博数量的测试模板 test/integration/microposts_interface_test.rb ``` require 'test_helper' class MicropostInterfaceTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "micropost sidebar count" do log_in_as(@user) get root_path assert_match "#{FILL_IN} microposts", response.body # 这个用户没有发布微博 other_user = users(:malory) log_in_as(other_user) get root_path assert_match "0 microposts", response.body other_user.microposts.create!(content: "A micropost") get root_path assert_match FILL_IN, response.body end end ``` ##### 代码清单 11.68:图片上传测试的模板 test/integration/microposts_interface_test.rb ``` require 'test_helper' class MicropostInterfaceTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end test "micropost interface" do log_in_as(@user) get root_path assert_select 'div.pagination' assert_select 'input[type=FILL_IN]' # 无效提交 post microposts_path, micropost: { content: "" } assert_select 'div#error_explanation' # 有效提交 content = "This micropost really ties the room together" picture = fixture_file_upload('test/fixtures/rails.png', 'image/png') assert_difference 'Micropost.count', 1 do post microposts_path, micropost: { content: content, picture: FILL_IN } end assert FILL_IN.picture? follow_redirect! assert_match content, response.body # 删除一篇微博 assert_select 'a', 'delete' first_micropost = @user.microposts.paginate(page: 1).first assert_difference 'Micropost.count', -1 do delete micropost_path(first_micropost) end # 访问另一个用户的资料页面 get user_path(users(:archer)) assert_select 'a', { text: 'delete', count: 0 } end . . . end ``` ##### 代码清单 11.69:在 `.gitignore` 中添加存储上传图片的文件夹 ``` # See https://help.github.com/articles/ignoring-files for more about ignoring # files. # # If you find yourself ignoring temporary files generated by your text editor # or operating system, you probably want to add a global ignore instead: # git config --global core.excludesfile '~/.gitignore_global' # Ignore bundler config. /.bundle # Ignore the default SQLite database. /db/*.sqlite3 /db/*.sqlite3-journal # Ignore all logfiles and tempfiles. /log/*.log /tmp # Ignore Spring files. /spring/*.pid # Ignore uploaded test images. /public/uploads ``` ##### 代码清单 11.70:一个初始化脚本,在测试中不调整图片的尺寸 config/initializers/skip_image_resizing.rb ``` if Rails.env.test? CarrierWave.configure do |config| config.enable_processing = false end end ```
';

11.5 小结

最后更新于:2022-04-01 22:30:29

# 11.5 小结 实现微博资源后,我们的演示应用基本上完成了。现在还剩下社交功能没有实现,即让用户之间可以相互关注。在[第 12 章](chapter12.html#following-users),我们会学习如何实现用户之间的这种关系,还要实现一个真正的动态流。 如果你跳过了 [11.4.4 节](#image-upload-in-production),在继续之前,先提交改动,然后再合并到 `master` 分支: ``` $ bundle exec rake test $ git add -A $ git commit -m "Add user microposts" $ git checkout master $ git merge user-microposts $ git push ``` 然后部署到生产环境: ``` $ git push heroku $ heroku pg:reset DATABASE $ heroku run rake db:migrate $ heroku run rake db:seed ``` 值得注意的是,这一章安装了需要的最后几个 gem。为了便于参考,完整的 `Gemfile` 如[代码清单 11.66](#listing-final-gemfile) 所示。 ##### 代码清单 11.66:演示应用的 `Gemfile` 完整版本 ``` source 'https://rubygems.org' gem 'rails', '4.2.2' gem 'bcrypt', '3.1.7' gem 'faker', '1.4.2' gem 'carrierwave', '0.10.0' gem 'mini_magick', '3.8.0' gem 'fog', '1.23.0' gem 'will_paginate', '3.0.7' gem 'bootstrap-will_paginate', '0.0.10' gem 'bootstrap-sass', '3.2.0.0' gem 'sass-rails', '5.0.2' gem 'uglifier', '2.5.3' gem 'coffee-rails', '4.1.0' gem 'jquery-rails', '4.0.3' gem 'turbolinks', '2.3.0' gem 'jbuilder', '2.2.3' gem 'sdoc', '0.4.0', group: :doc group :development, :test do gem 'sqlite3', '1.3.9' gem 'byebug', '3.4.0' gem 'web-console', '2.0.0.beta3' gem 'spring', '1.1.3' end group :test do gem 'minitest-reporters', '1.0.5' gem 'mini_backtrace', '0.1.3' gem 'guard-minitest', '2.3.1' end group :production do gem 'pg', '0.17.1' gem 'rails_12factor', '0.0.2' gem 'unicorn', '4.8.3' end ``` ## 11.5.1 读完本章学到了什么 * 和用户一样,微博也是一种“资源”,而且有对应的 Active Record 模型; * Rails 支持多键索引; * 我们可以分别在用户和微博模型中使用 `has_many` 和 `belongs_to` 方法实现一个用户拥有多篇微博的模型; * `has_many`/`belongs_to` 会创建很多方法,通过关联创建对象; * `user.microposts.build(…​)` 创建一个微博对象,并自动把这个微博和用户关联起来; * Rails 支持使用 `default_scope` 指定默认排序方式; * 作用域方法的参数是匿名函数; * 加入 `dependent: :destroy` 参数后,删除对象时也会把关联的对象删除; * 分页和数量统计都可以通过关联调用,这样写出的代码很简洁; * 在固件中可以创建关联; * 可以向 Rails 局部视图中传入变量; * 查询 Active Record 模型时可以使用 `where` 方法; * 通过关联创建和销毁对象有安全保障; * 可以使用 CarrierWave 上传图片及调整图片的尺寸。
';

11.4 微博中的图片

最后更新于:2022-04-01 22:30:27

# 11.4 微博中的图片 我们已经实现了微博相关的所有操作,本节要让微博除了能输入文字之外还能插入图片。我们首先会开发一个基础版本,只能在生产环境中使用,然后再做一系列功能增强,允许在生产环境上传图片。 添加图片上传功能明显要完成两件事:编写用于上传图片的表单,准备好所需的图片。上传图片按钮和微博中显示的图片构思如[图 11.18](#fig-micropost-image-mockup) 所示。[[9](#fn-9)] ![micropost image mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333075d7360.png)图 11.18:图片上传界面的构思图(包含一张上传后的图片) ## 11.4.1 基本的图片上传功能 我们要使用 [CarrierWave](https://github.com/carrierwaveuploader/carrierwave) 处理图片上传,并把图片和微博模型关联起来。为此,我们要在 `Gemfile` 中添加 `carrierwave` gem,如[代码清单 11.55](#listing-gemfile-carrierwave) 所示。为了一次安装完所有 gem,[代码清单 11.55](#listing-gemfile-carrierwave) 中还添加了用于调整图片尺寸的 `mini_magick`([11.4.3 节](#image-resizing))和用于在生产环境中上传图片的 `fog`([11.4.4 节](#image-upload-in-production))。 ##### 代码清单 11.55:在 `Gemfile` 中添加 CarrierWave ``` source 'https://rubygems.org' gem 'rails', '4.2.2' gem 'bcrypt', '3.1.7' gem 'faker', '1.4.2' gem 'carrierwave', '0.10.0' gem 'mini_magick', '3.8.0' gem 'fog', '1.36.0' gem 'will_paginate', '3.0.7' gem 'bootstrap-will_paginate', '0.0.10' . . . ``` 然后像之前一样,执行下面的命令安装: ``` $ bundle install ``` CarrierWave 自带了一个 Rails 生成器,用于生成图片上传程序。我们要创建一个名为 `picture` 的上传程序: ``` $ rails generate uploader Picture ``` CarrierWave 上传的图片应该对应于 Active Record 模型中的一个属性,这个属性只需存储图片的文件名字符串即可。添加这个属性后的微博模型如[图 11.19](#fig-micropost-model-picture) 所示。[[10](#fn-10)] ![micropost model picture](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333075f31c3.png)图 11.19:添加 `picture` 属性后的微博数据模型 为了把 `picture` 属性添加到微博模型中,我们要生成一个迁移,然后在开发服务器中执行迁移: ``` $ rails generate migration add_picture_to_microposts picture:string $ bundle exec rake db:migrate ``` 告诉 CarrierWave 把图片和模型关联起来的方式是使用 `mount_uploader` 方法。这个方法的第一个参数是属性的符号形式,第二个参数是上传程序的类名: ``` mount_uploader :picture, PictureUploader ``` (`PictureUploader` 类在 `picture_uploader.rb` 文件中,[11.4.2 节](#image-validation)会编辑,现在使用生成的默认内容即可。)把这个上传程序添加到微博模型,如[代码清单 11.56](#listing-micropost-model-picture) 所示。 ##### 代码清单 11.56:在微博模型中添加图片上传程序 app/models/micropost.rb ``` class Micropost < ActiveRecord::Base belongs_to :user default_scope -> { order(created_at: :desc) } mount_uploader :picture, PictureUploader validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end ``` 在某些系统中可能要重启 Rails 服务器,测试组件才能通过。 如[图 11.18](#fig-micropost-image-mockup) 所示,为了在首页添加图片上传功能,我们要在发布微博的表单中添加一个 `file_field` 标签,如[代码清单 11.57](#listing-micropost-create-image-upload) 所示。 ##### 代码清单 11.57:在发布微博的表单中添加图片上传按钮 app/views/shared/_micropost_form.html.erb ``` <%= form_for(@micropost, html: { multipart: true }) do |f| %> <%= render 'shared/error_messages', object: f.object %>
<%= f.text_area :content, placeholder: "Compose new micropost..." %>
<%= f.submit "Post", class: "btn btn-primary" %> <%= f.file_field :picture %> <% end %> ``` 注意,`form_for` 中指定了 `html: { multipart: true }` 参数。为了支持文件上传功能,必须指定这个参数。 最后,我们要把 `picture` 添加到可通过 Web 修改的属性列表中。为此,要修改 `micropost_params` 方法,如[代码清单 11.58](#listing-micropost-params-picture) 所示。 ##### 代码清单 11.58:把 `picture` 添加到允许修改的属性列表中 app/controllers/microposts_controller.rb ``` class MicropostsController < ApplicationController before_action :logged_in_user, only: [:create, :destroy] before_action :correct_user, only: :destroy . . . private def micropost_params params.require(:micropost).permit(:content, :picture) end def correct_user @micropost = current_user.microposts.find_by(id: params[:id]) redirect_to root_url if @micropost.nil? end end ``` 图片上传后,在微博局部视图中可以使用 `image_tag` 辅助方法渲染,如[代码清单 11.59](#listing-micropost-partial-image-display) 所示。注意,我们使用了 `picture?` 布尔值方法,如果没有图片就不显示 `img` 标签。这个方法由 CarrierWave 自动创建,方法名根据保存图片文件名的属性而定。自己动手上传图片后显示的页面如[图 11.20](#fig-micropost-with-image) 所示。针对图片上传功能的测试留作练习([11.6 节](#user-microposts-exercises))。 ##### 代码清单 11.59:在微博中显示图片 app/views/microposts/_micropost.html.erb ```
  • <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %> <%= link_to micropost.user.name, micropost.user %> <%= micropost.content %> <%= image_tag micropost.picture.url if micropost.picture? %> Posted <%= time_ago_in_words(micropost.created_at) %> ago. <% if current_user?(micropost.user) %> <%= link_to "delete", micropost, method: :delete, data: { confirm: "You sure?" } %> <% end %>
  • ``` ![microposts with image](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333076173f8.png)图 11.20:发布包含图片的微博后显示的页面 ## 11.4.2 验证图片 前一节添加的上传程序是个好的开始,但有一定不足:没对上传的文件做任何限制,如果用户上传的文件很大,或者类型不对,会导致问题。这一节我们要修正这个不足,添加验证,限制图片的大小和类型。我们既会在服务器端添加验证,也会在客户端(即浏览器)添加验证。 对图片类型的限制在 CarrierWave 的上传程序中设置。我们要限制能使用的图片扩展名(PNG,GIF 和 JPEG 的两个变种),如[代码清单 11.60](#listing-validate-picture-format) 所示。(在生成的上传程序中有一段注释说明了该怎么做。) ##### 代码清单 11.60:限制可上传图片的类型 app/uploaders/picture_uploader.rb ``` class PictureUploader < CarrierWave::Uploader::Base storage :file # Override the directory where uploaded files will be stored. # This is a sensible default for uploaders that are meant to be mounted: def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end # 添加一个白名单,指定允许上传的图片类型 def extension_white_list %w(jpg jpeg gif png) end end ``` 图片大小的限制在微博模型中设定。和前面用过的模型验证不同,Rails 没有为文件大小提供现成的验证方法。所以我们要自己定义一个验证方法,我们把这个方法命名为 `picture_size`,如[代码清单 11.61](#listing-micropost-model-image-validation) 所示。注意,调用自定义的验证时使用的是 `validate` 方法,而不是 `validates`。 ##### 代码清单 11.61:添加图片大小验证 app/models/micropost.rb ``` class Micropost < ActiveRecord::Base belongs_to :user default_scope -> { order(created_at: :desc) } mount_uploader :picture, PictureUploader validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } validate :picture_size private # 验证上传的图片大小 def picture_size if picture.size > 5.megabytes errors.add(:picture, "should be less than 5MB") end end end ``` 这个验证会调用指定符号(`:picture_size`)对应的方法。在 `picture_size` 方法中,如果图片大于 5MB(使用[旁注 8.2](chapter8.html#aside-time-helpers) 中介绍的句法),就向 `errors` 集合([6.2.2 节](chapter6.html#validating-presence)简介过)添加一个自定义的错误消息。 除了这两个验证之外,我们还要在客户端检查上传的图片。首先,我们在 `file_field` 方法中使用 `accept` 参数限制图片的格式: ``` <%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %> ``` 有效的格式使用 [MIME 类型](https://en.wikipedia.org/wiki/Internet_media_type)指定,这些类型对应于[代码清单 11.60](#listing-validate-picture-format) 中限制的类型。 然后,我们要编写一些 JavaScript(更确切地说是 [jQuery](http://jquery.com/) 代码),如果用户试图上传太大的图片就弹出一个提示框(节省了上传的时间,也减少了服务器的负载): ``` $('#micropost_picture').bind('change', function() { var size_in_megabytes = this.files[0].size/1024/1024; if (size_in_megabytes > 5) { alert('Maximum file size is 5MB. Please choose a smaller file.'); } }); ``` 本书虽然没有介绍 jQuery,不过你或许能理解这段代码:监视页面中 CSS ID 为 `micropost_picture` 的元素(如 `#` 符号所示,这是微博表单的 ID,参见[代码清单 11.57](#listing-micropost-create-image-upload)),当这个元素的内容变化时,会执行这段代码,如果文件太大,就调用 `alert` 方法。[[11](#fn-11)] 把这两个检查措施添加到微博表单中,如[代码清单 11.62](#listing-format-jquery-file-test) 所示。 ##### 代码清单 11.62:使用 jQuery 检查文件的大小 app/views/shared/_micropost_form.html.erb ``` <%= form_for(@micropost, html: { multipart: true }) do |f| %> <%= render 'shared/error_messages', object: f.object %>
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
    <%= f.submit "Post", class: "btn btn-primary" %> <%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %> <% end %> ``` 有一点很重要,你要知道,像[代码清单 11.62](#listing-format-jquery-file-test) 这样的代码并不能阻止用户上传大文件。我们添加的代码虽然能阻止用户通过 Web 界面上传,但用户可以使用 Web 审查工具修改 JavaScript,或者直接发送 `POST` 请求(例如,使用 `curl`)。为了阻止用户上传大文件,必须在服务器端添加验证,如[代码清单 11.61](#listing-micropost-model-image-validation) 所示。 ## 11.4.3 调整图片的尺寸 前一节对图片大小的限制是个好的开始,不过用户还是可以上传尺寸很大的图片,撑破网站的布局,有时会把网站搞得一团糟,如[图 11.21](#fig-large-uploaded-image) 所示。因此,如果允许用户从本地硬盘中上传尺寸很大的图片,最好在显示图片之前调整图片的尺寸。[[12](#fn-12)] ![large uploaded image](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333076362f4.png)图 11.21:上传了一张超级大的图片 我们要使用 [ImageMagick](http://www.imagemagick.org/) 调整图片的尺寸,所以要在开发环境中安装这个程序。(如 [11.4.4 节](#image-upload-in-production)所示,Heroku 已经预先安装好了。)在云端 IDE 中可以使用下面的命令安装:[[13](#fn-13)] ``` $ sudo apt-get update $ sudo apt-get install imagemagick --fix-missing ``` 然后,我们要在 CarrierWave 中引入 [MiniMagick](https://github.com/minimagick/minimagick) 为 ImageMagick 提供的接口,还要调用一个调整尺寸的方法。[MiniMagick 的文档](http://www.rdoc.info/github/jnicklas/carrierwave/CarrierWave/MiniMagick)中列出了多个调整尺寸的方法,我们要使用的是 `resize_to_limit: [400, 400]`,如果图片很大,就把它调整为宽和高都不超过 400 像素,而小于这个尺寸的图片则不调整。([CarrierWave 文档](https://github.com/carrierwaveuploader/carrierwave#using-minimagick)中列出的方法会把小图片放大,这不是我们需要的效果。)添加[代码清单 11.63](#listing-image-uploader-resizing) 中的代码后,就能完美调整大尺寸图片了,如[图 11.22](#fig-resized-image) 所示。 ##### 代码清单 11.63:配置图片上传程序,调整图片的尺寸 app/uploaders/picture_uploader.rb ``` class PictureUploader < CarrierWave::Uploader::Base include CarrierWave::MiniMagick process resize_to_limit: [400, 400] storage :file # Override the directory where uploaded files will be stored. # This is a sensible default for uploaders that are meant to be mounted: def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end # 添加一个白名单,指定允许上传的图片类型 def extension_white_list %w(jpg jpeg gif png) end end ``` ![resized image](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307663470.png)图 11.22:调整尺寸后的图片 ## 11.4.4 在生产环境中上传图片 前面使用的图片上传程序在开发环境中用起来不错,但图片都存储在本地文件系统中(如[代码清单 11.63](#listing-image-uploader-resizing) 中 `storage :file` 那行所示),在生产环境这么做可不好。[[14](#fn-14)]所以,我们要使用云存储服务存储图片,和应用所在的文件系统分开。[[15](#fn-15)] 我们要使用 `fog` gem 配置应用,在生产环境使用云存储,如[代码清单 11.64](#listing-image-uploader-production) 所示。 ##### 代码清单 11.64:配置生产环境使用的图片上传程序 app/uploaders/picture_uploader.rb ``` class PictureUploader < CarrierWave::Uploader::Base include CarrierWave::MiniMagick process resize_to_limit: [400, 400] if Rails.env.production? storage :fog else storage :file end # Override the directory where uploaded files will be stored. # This is a sensible default for uploaders that are meant to be mounted: def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end # 添加一个白名单,指定允许上传的图片类型 def extension_white_list %w(jpg jpeg gif png) end end ``` 在[代码清单 11.64](#listing-image-uploader-production) 中,使用[旁注 7.1](chapter7.html#aside-rails-environments) 中介绍的 `production?` 布尔值方法根据所在的环境选择存储方式: ``` if Rails.env.production? storage :fog else storage :file end ``` 云存储服务有很多,我们要使用其中一个最受欢迎并且支持比较好的——Amazon 的 [Simple Storage Service](http://aws.amazon.com/s3/)(简称 S3)。[[16](#fn-16)]基本步骤如下: 1. 注册一个 [Amazon Web Services](http://aws.amazon.com/) 账户; 2. 通过 [AWS Identity and Access Management](http://aws.amazon.com/iam/)(简称 IAM) 创建一个用户,记下访问公钥和密钥; 3. 使用 [AWS Console](https://console.aws.amazon.com/s3) 创建一个 S3 bucket(名字自己定),然后赋予上一步创建的用户读写权限。 关于这些步骤的详细说明,参见 [S3 的文档](http://aws.amazon.com/documentation/s3/)。(如果需要还可以搜索。) 创建并配置好 S3 账户后,创建 CarrierWave 配置文件,写入[代码清单 11.65](#listing-carrier-wave-configuration) 中的内容。注意:如果做了这些设置之后连不上 S3,可能是区域位置的问题。有些用户要在 fog 的配置中添加 `:region => ENV['S3_REGION']`,然后在命令行中执行 `heroku config:set S3_REGION=<bucket_region>`,其中 `bucket_region` 是你所在的区域,例如 `'eu-central-1'`。如果想找到你所在的区域,请查看 [Amazon AWS 的文档](http://docs.aws.amazon.com/general/latest/gr/rande.html)。 ##### 代码清单 11.65:配置 CarrierWave 使用 S3 config/initializers/carrier_wave.rb ``` if Rails.env.production? CarrierWave.configure do |config| config.fog_credentials = { # Amazon S3 的配置 :provider => 'AWS', :aws_access_key_id => ENV['S3_ACCESS_KEY'], :aws_secret_access_key => ENV['S3_SECRET_KEY'] } config.fog_directory = ENV['S3_BUCKET'] end end ``` 和生产环境的电子邮件配置一样([代码清单 10.56](chapter10.html#listing-sendgrid-config)),[代码清单 11.65](#listing-carrier-wave-configuration) 也使用 Heroku 中的 `ENV` 变量,没直接在代码中写入敏感信息。在 [10.3 节](chapter10.html#email-in-production),电子邮件所需的变量由 SendGrid 扩展自动定义,但现在我们要自己定义,方法是使用 `heroku config:set` 命令,如下所示: ``` $ heroku config:set S3_ACCESS_KEY= $ heroku config:set S3_SECRET_KEY= $ heroku config:set S3_BUCKET= ``` 配置好之后,我们可以提交并部署了。我们先提交主题分支中的变动,然后再合并到 `master` 分支: ``` $ bundle exec rake test $ git add -A $ git commit -m "Add user microposts" $ git checkout master $ git merge user-microposts $ git push ``` 然后部署,重设数据库,再重新把示例数据载入数据库: ``` $ git push heroku $ heroku pg:reset DATABASE $ heroku run rake db:migrate $ heroku run rake db:seed ``` Heroku 已经安装了 ImageMagick,所在生产环境中调整图片尺寸和上传功能都能正常使用,如[图 11.23](#fig-image-upload-production) 所示。 ![image upload production](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307688656.png)图 11.23:在生产环境中上传图片
    ';

    11.3 微博相关的操作

    最后更新于:2022-04-01 22:30:25

    # 11.3 微博相关的操作 微博的数据模型构建好了,也编写了相关的视图文件,接下来我们的开发重点是,通过网页发布微博。本节,我们会初步实现动态流,[第 12 章](chapter12.html#following-users)再完善。最后,和用户资源一样,我们还要实现在网页中删除微博的功能。 上述功能的实现和之前的方式有点不同,需要特别注意:微博资源相关的页面不通过微博控制器实现,而是通过资料页面和首页实现。因此微博控制器不需要 `new` 和 `edit` 动作,只需要 `create` 和 `destroy` 动作。所以,微博资源的路由如[代码清单 11.29](#listing-microposts-resource) 所示。 [代码清单 11.29](#listing-microposts-resource) 中的代码对应的 REST 路由如[表 11.2](#table-restful-microposts) 所示,这张表中的路由只是[表 2.3](chapter2.html#table-demo-restful-microposts) 的一部分。不过,路由虽然简化了,但预示着实现的过程需要用到更高级的技术,而不会降低代码的复杂度。从[第 2 章](chapter2.html#a-toy-app)起我们就十分依赖脚手架,不过现在我们将舍弃脚手架的大部分功能。 ##### 代码清单 11.29:微博资源的路由设置 config/routes.rb ``` Rails.application.routes.draw do root 'static_pages#home' get 'help' => 'static_pages#help' get 'about' => 'static_pages#about' get 'contact' => 'static_pages#contact' get 'signup' => 'users#new' get 'login' => 'sessions#new' post 'login' => 'sessions#create' delete 'logout' => 'sessions#destroy' resources :users resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] resources :microposts, only: [:create, :destroy] end ``` 表 11.2:[代码清单 11.29](#listing-microposts-resource) 设置的微博资源路由 | HTTP 请求 | URL | 动作 | 作用 | | --- | --- | --- | --- | | `POST` | /microposts | `create` | 创建新微博 | | `DELETE` | /microposts/1 | `destroy` | 删除 ID 为 1 的微博 | ## 11.3.1 访问限制 开发微博资源的第一步,我们要在微博控制器中实现访问限制:若想访问 `create` 和 `destroy` 动作,用户要先登录。 针对这个要求的测试和用户控制器中相应的测试类似([代码清单 9.17](chapter9.html#listing-edit-update-redirect-tests) 和[代码清单 9.56](chapter9.html#listing-action-tests-admin)),我们要使用正确的请求类型访问这两个动作,然后确认微博的数量没有变化,而且会重定向到登录页面,如[代码清单 11.30](#listing-create-destroy-micropost-tests) 所示。 ##### 代码清单 11.30:微博控制器的访问限制测试 RED test/controllers/microposts_controller_test.rb ``` require 'test_helper' class MicropostsControllerTest < ActionController::TestCase def setup @micropost = microposts(:orange) end test "should redirect create when not logged in" do assert_no_difference 'Micropost.count' do post :create, micropost: { content: "Lorem ipsum" } end assert_redirected_to login_url end test "should redirect destroy when not logged in" do assert_no_difference 'Micropost.count' do delete :destroy, id: @micropost end assert_redirected_to login_url end end ``` 在编写让这个测试通过的应用代码之前,先要做些重构。在 [9.2.1 节](chapter9.html#requiring-logged-in-users),我们定义了一个事前过滤器 `logged_in_user`([代码清单 9.12](chapter9.html#listing-authorize-before-filter)),要求访问相关的动作之前用户要先登录。那时,我们只需要在用户控制器中使用这个事前过滤器,但是现在也要在微博控制器中使用,所以把它移到 `ApplicationController` 中(所有控制器的基类),如[代码清单 11.31](#listing-sessions-helper-authenticate) 所示。 ##### 代码清单 11.31:把 `logged_in_user` 方法移到 `ApplicationController` 中 app/controllers/application_controller.rb ``` class ApplicationController < ActionController::Base protect_from_forgery with: :exception include SessionsHelper private # 确保用户已登录 def logged_in_user unless logged_in? store_location flash[:danger] = "Please log in." redirect_to login_url end end end ``` 为了避免代码重复,同时还要把用户控制器中的 `logged_in_user` 方法删掉。 现在,我们可以在微博控制器中使用 `logged_in_user` 方法了。我们在微博控制器中添加 `create` 和 `destroy` 动作,并使用事前过滤器限制访问,如[代码清单 11.32](#listing-microposts-controller-access-control) 所示。 ##### 代码清单 11.32:限制访问微博控制器的动作 GREEN app/controllers/microposts_controller.rb ``` class MicropostsController < ApplicationController before_action :logged_in_user, only: [:create, :destroy] def create end def destroy end end ``` 现在,测试组件应该能通过了: ##### 代码清单 11.33:**GREEN** ``` $ bundle exec rake test ``` ## 11.3.2 创建微博 在[第 7 章](chapter7.html#sign-up),我们实现了用户注册功能,方法是使用 HTML 表单向用户控制器的 `create` 动作发送 `POST` 请求。创建微博的功能实现起来类似,主要的不同点是,表单不放在单独的页面 /microposts/new 中,而是在网站的首页(即根地址 /),构思图如[图 11.10](#fig-home-page-with-micropost-form-mockup) 所示。 ![home page with micropost form mockup bootstrap](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333072d4a5f.png)图 11.10:包含创建微博表单的首页构思图 上一次离开首页时,是[图 5.6](chapter5.html#fig-sample-app-logo) 那个样子,页面中部有个“Sign up now!”按钮。因为创建微博的表单只对登录后的用户有用,所以本节的目标之一是根据用户的登录状态显示不同的首页内容,如[代码清单 11.35](#listing-microposts-home-page) 所示。 我们先来编写微博控制器的 `create` 动作,和用户控制器的 `create` 动作类似([代码清单 7.23](chapter7.html#listing-user-create-action)),二者之间主要的区别是,创建微博时,要使用用户和微博的关联关系构建微博对象,如[代码清单 11.34](#listing-microposts-create-action) 所示。注意 `micropost_params` 中的健壮参数,只允许通过 Web 修改微博的 `content` 属性。 ##### 代码清单 11.34:微博控制器的 `create` 动作 app/controllers/microposts_controller.rb ``` class MicropostsController < ApplicationController before_action :logged_in_user, only: [:create, :destroy] def create @micropost = current_user.microposts.build(micropost_params) if @micropost.save flash[:success] = "Micropost created!" redirect_to root_url else render 'static_pages/home' end end def destroy end private def micropost_params params.require(:micropost).permit(:content) end end ``` 我们使用[代码清单 11.35](#listing-microposts-home-page) 中的代码编写创建微博所需的表单,这个视图会根据用户的登录状态显示不同的 HTML。 ##### 代码清单 11.35:在首页加入创建微博的表单 app/views/static_pages/home.html.erb ``` <% if logged_in? %>
    <% else %>

    Welcome to the Sample App

    This is the home page for the Ruby on Rails Tutorial sample application.

    <%= link_to "Sign up now!", signup_path, class: "btn btn-lg btn-primary" %>
    <%= link_to image_tag("rails.png", alt: "Rails logo"), 'http://rubyonrails.org/' %> <% end %> ``` (`if-else` 条件语句中各分支包含的代码太多,有点乱,在[练习](#user-microposts-exercises)中会使用局部视图整理。) 为了让[代码清单 11.35](#listing-microposts-home-page) 能正常渲染页面,我们要创建几个局部视图。首先是首页的侧边栏,如[代码清单 11.36](#listing-user-info) 所示。 ##### 代码清单 11.36:用户信息侧边栏局部视图 app/views/shared/_user_info.html.erb ``` <%= link_to gravatar_for(current_user, size: 50), current_user %>

    <%= current_user.name %>

    <%= link_to "view my profile", current_user %> <%= pluralize(current_user.microposts.count, "micropost") %> ``` 注意,和用户资料页面的侧边栏一样([代码清单 11.23](#listing-user-show-microposts)),[代码清单 11.36](#listing-user-info) 中的用户信息也显示了用户发布的微博数量。不过显示上有细微的差别,在用户资料页面的侧边栏中,“Microposts” 是“标注”(label),所以“Microposts (1)”这样的用法是合理的。而在本例中,如果说“1 microposts”的话就不合语法了,所以我们调用了 `pluralize` 方法([7.3.3 节](chapter7.html#signup-error-messages)见过),显示成“1 micropost”,“2 microposts”等。 下面我们来编写微博创建表单的局部视图,如[代码清单 11.37](#listing-micropost-form) 所示。这段代码和[代码清单 7.13](chapter7.html#listing-signup-form) 中的注册表单类似。 ##### 代码清单 11.37:微博创建表单局部视图 app/views/shared/_micropost_form.html.erb ``` <%= form_for(@micropost) do |f| %> <%= render 'shared/error_messages', object: f.object %>
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
    <%= f.submit "Post", class: "btn btn-primary" %> <% end %> ``` 我们还要做两件事,[代码清单 11.37](#listing-micropost-form) 中的表单才能使用。第一,(和之前一样)我们要通过关联定义 `@micropost` 变量: ``` @micropost = current_user.microposts.build ``` 把这行代码写入控制器,如[代码清单 11.38](#listing-micropost-instance-variable) 所示。 ##### 代码清单 11.38:在 `home` 动作中定义 `@micropost` 实例变量 app/controllers/static_pages_controller.rb ``` class StaticPagesController < ApplicationController def home @micropost = current_user.microposts.build if logged_in? end def help end def about end def contact end end ``` 因为只有用户登录后 `current_user` 才存在,所以 `@micropost` 变量只能在用户登录后再定义。 我们要做的第二件事是,重写错误消息局部视图,让[代码清单 11.37](#listing-micropost-form) 中的这行能用: ``` <%= render 'shared/error_messages', object: f.object %> ``` 你可能还记得,在[代码清单 7.18](chapter7.html#listing-f-error-messages) 中,错误消息局部视图直接引用了 `@user` 变量,但现在我们提供的变量是 `@micropost`。为了在两个地方都能使用这个错误消息局部视图,我们可以把表单变量 `f` 传入局部视图,通过 `f.object` 获取相应的对象。因此,在 `form_for(@user) do |f|` 中,`f.object` 是 `@user`;在 `form_for(@micropost) do |f|` 中,`f.object` 是 `@micropost`。 我们要通过一个哈希把对象传入局部视图,值是这个对象,键是局部视图中所需的变量名,如[代码清单 11.37](#listing-micropost-form) 中的第二行所示。换句话说,`object: f.object` 会创建一个名为 `object` 的变量,供 `error_messages` 局部视图使用。通过这个对象,我们可以定制错误消息,如[代码清单 11.39](#listing-updated-error-messages-partial) 所示。 ##### 代码清单 11.39:能使用其他对象的错误消息局部视图 RED app/views/shared/_error_messages.html.erb ``` <% if object.errors.any? %>
    The form contains <%= pluralize(object.errors.count, "error") %>.
      <% object.errors.full_messages.each do |msg| %>
    • <%= msg %>
    • <% end %>
    <% end %> ``` 现在,你应该确认一下测试组件无法通过: ##### 代码清单 11.40:**RED** ``` $ bundle exec rake test ``` 这提醒我们要修改其他使用错误消息局部视图的视图,包括用户注册视图([代码清单 7.18](chapter7.html#listing-f-error-messages)),重设密码视图([代码清单 10.50](chapter10.html#listing-password-reset-form))和编辑用户视图([代码清单 9.2](chapter9.html#listing-user-edit-view))。这三个视图修改后的版本分别如[代码清单 11.41](#listing-signup-errors-updated),[代码清单 11.43](#listing-password-reset-updated) 和[代码清单 11.42](#listing-edit-errors-updated) 所示。 ##### 代码清单 11.41:修改用户注册表单中渲染错误消息局部视图的方式 app/views/users/new.html.erb ``` <% provide(:title, 'Sign up') %>

    Sign up

    <%= form_for(@user) do |f| %> <%= render 'shared/error_messages', object: f.object %> <%= f.label :name %> <%= f.text_field :name, class: 'form-control' %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation, "Confirmation" %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit "Create my account", class: "btn btn-primary" %> <% end %>
    ``` ##### 代码清单 11.42:修改编辑用户表单中渲染错误消息局部视图的方式 app/views/users/edit.html.erb ``` <% provide(:title, "Edit user") %>

    Update your profile

    <%= form_for(@user) do |f| %> <%= render 'shared/error_messages', object: f.object %> <%= f.label :name %> <%= f.text_field :name, class: 'form-control' %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation, "Confirmation" %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit "Save changes", class: "btn btn-primary" %> <% end %>
    <%= gravatar_for @user %> change
    ``` ##### 代码清单 11.43:修改密码重设表单中渲染错误消息局部视图的方式 app/views/password_resets/edit.html.erb ``` <% provide(:title, 'Reset password') %>

    Password reset

    <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %> <%= render 'shared/error_messages', object: f.object %> <%= hidden_field_tag :email, @user.email %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation, "Confirmation" %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit "Update password", class: "btn btn-primary" %> <% end %>
    ``` 现在,所有测试应该都能通过了: ``` $ bundle exec rake test ``` 而且,本节添加的所有 HTML 代码也都能正确渲染了。[图 11.11](#fig-home-with-form) 是创建微博的表单,[图 11.12](#fig-home-form-errors) 显示提交表单后有一个错误。 ![home with form 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307305313.png)图 11.11:包含创建微博表单的首页![home form errors 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307320ad9.png)图 11.12:表单中显示一个错误消息的首页 ## 11.3.3 动态流原型 现在创建微博的表单可以使用了,但是用户看不到实际效果,因为首页没有显示微博。如果你愿意的话,可以在[图 11.11](#fig-home-with-form) 所示的表单中发表一篇有效的微博,然后打开用户资料页面,验证一下这个表单是否可以正常使用。这样在页面之间来来回回有点麻烦,如果能在首页显示一个含有当前登入用户的微博列表(动态流)就好了,构思图如[图 11.13](#fig-proto-feed-mockup) 所示。(在[第 12 章](chapter12.html#following-users),我们会在这个微博列表中加入当前登入用户所关注用户发表的微博。) ![proto feed mockup 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307336beb.png)图 11.13:显示有动态流的首页构思图 因为每个用户都有一个动态流,因此我们可以在用户模型中定义一个名为 `feed` 的方法,查找当前用户发表的所有微博。我们要在微博模型上调用 `where` 方法([10.5 节](chapter10.html#account-activation-and-password-reset-exercises)提到过)查找微博,如[代码清单 11.44](#listing-proto-status-feed) 所示。[[6](#fn-6)] ##### 代码清单 11.44:微博动态流的初步实现 app/models/user.rb ``` class User < ActiveRecord::Base . . . # 实现动态流原型 # 完整的实现参见第 12 章 def feed Micropost.where("user_id = ?", id) end private . . . end ``` `Micropost.where("user_id = ?", id)` 中的问号确保 `id` 的值在传入底层的 SQL 查询语句之前做了适当的转义,避免“[SQL 注入](http://en.wikipedia.org/wiki/SQL_injection)”(SQL injection)这种严重的安全隐患。这里用到的 `id` 属性是个整数,没什么危险,不过在 SQL 语句中引入变量之前做转义是个好习惯。 细心的读者可能已经注意到了,[代码清单 11.44](#listing-proto-status-feed) 中的代码和下面的代码是等效的: ``` def feed microposts end ``` 我们之所以使用[代码清单 11.44](#listing-proto-status-feed) 中的版本,是因为它能更好的服务于[第 12 章](chapter12.html#following-users)实现的完整动态流。 要在演示应用中添加动态流,我们可以在 `home` 动作中定义一个 `@feed_items` 实例变量,分页获取当前用户的微博,如[代码清单 11.45](#listing-feed-instance-variable) 所示。然后在首页(参见[代码清单 11.47](#listing-home-with-feed))中加入一个动态流局部视图(参见[代码清单 11.46](#listing-feed-partial))。注意,现在用户登录后要执行两行代码,所以[代码清单 11.45](#listing-feed-instance-variable) 把[代码清单 11.38](#listing-micropost-instance-variable) 中的 ``` @micropost = current_user.microposts.build if logged_in? ``` 改成了 ``` if logged_in? @micropost = current_user.microposts.build @feed_items = current_user.feed.paginate(page: params[:page]) end ``` 也就是把条件放在行尾的代码改成了使用 `if-end` 语句。 ##### 代码清单 11.45:在 `home` 动作中定义一个实例变量,获取动态流 app/controllers/static_pages_controller.rb ``` class StaticPagesController < ApplicationController def home if logged_in? @micropost = current_user.microposts.build @feed_items = current_user.feed.paginate(page: params[:page]) end end def help end def about end def contact end end ``` ##### 代码清单 11.46:动态流局部视图 app/views/shared/_feed.html.erb ``` <% if @feed_items.any? %>
      <%= render @feed_items %>
    <%= will_paginate @feed_items %> <% end %> ``` 动态流局部视图使用如下的代码,把单篇微博交给[代码清单 11.21](#listing-micropost-partial) 中的局部视图渲染: ``` <%= render @feed_items %> ``` Rails 知道要渲染 `micropost` 局部视图,因为 `@feed_items` 中的元素都是 `Micropost` 类的实例。所以,Rails 会在对应资源的视图文件夹中寻找正确的局部视图: ``` app/views/microposts/_micropost.html.erb ``` 和之前一样,我们可以把动态流局部视图加入首页,如[代码清单 11.47](#listing-home-with-feed) 所示。加入后的效果就是在首页显示动态流,实现了我们的需求,如[图 11.14](#fig-home-with-proto-feed) 所示。 ##### 代码清单 11.47:在首页加入动态流 app/views/static_pages/home.html.erb ``` <% if logged_in? %>

    Micropost Feed

    <%= render 'shared/feed' %>
    <% else %> . . . <% end %> ``` 现在,发布新微博的功能可以按照设想的方式使用了,如[图 11.15](#fig-micropost-created) 所示。不过还有个小小的不足:如果发布微博失败,首页还会需要一个名为 `@feed_items` 的实例变量,所以提交失败时网站无法正常运行。最简单的解决方法是,如果提交失败就把 `@feed_items` 设为空数组,如[代码清单 11.48](#listing-microposts-create-action-with-feed) 所示。(但是这么做分页链接就失效了,你可以点击分页链接,看一下是什么原因。) ![home with proto feed 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_573330735711f.png)图 11.14:显示有动态流原型的首页![micropost created 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_573330739b11e.png)图 11.15:发布新微博后的首页 ##### 代码清单 11.48:在 `create` 动作中定义 `@feed_items` 实例变量,值为空数组 app/controllers/microposts_controller.rb ``` class MicropostsController < ApplicationController before_action :logged_in_user, only: [:create, :destroy] def create @micropost = current_user.microposts.build(micropost_params) if @micropost.save flash[:success] = "Micropost created!" redirect_to root_url else @feed_items = [] render 'static_pages/home' end end def destroy end private def micropost_params params.require(:micropost).permit(:content) end end ``` ## 11.3.4 删除微博 我们要为微博资源实现的最后一个功能是删除。和删除用户类似([9.4.2 节](chapter9.html#the-destroy-action)),删除微博也要通过删除链接实现,构思图如[图 11.16](#fig-micropost-delete-links-mockup) 所示。用户只有管理员才能删除,而微博只有发布人才能删除。 首先,我们要在微博局部视图([代码清单 11.21](#listing-micropost-partial))中加入删除链接,如[代码清单 11.49](#listing-micropost-partial-with-delete) 所示。 ##### 代码清单 11.49:在微博局部视图中添加删除链接 app/views/microposts/_micropost.html.erb ```
  • <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %> <%= link_to micropost.user.name, micropost.user %> <%= micropost.content %> Posted <%= time_ago_in_words(micropost.created_at) %> ago. <% if current_user?(micropost.user) %> <%= link_to "delete", micropost, method: :delete, data: { confirm: "You sure?" } %> <% end %>
  • ``` ![micropost delete links mockup 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333073b9e53.png)图 11.16:显示有删除链接的动态流原型构思图 然后,参照 `UsersController` 的 `destroy` 动作([代码清单 9.54](chapter9.html#listing-admin-destroy-before-filter)),编写 `MicropostsController` 的 `destroy` 动作。在 `UsersController` 中,我们在 `admin_user` 事前过滤器中定义 `@user` 变量,查找用户,但现在要通过关联查找微博,这么做,如果某个用户试图删除其他用户的微博,会自动失败。我们把查找微博的操作放在 `correct_user` 事前过滤器中,确保当前用户确实拥有指定 ID 的微博,如[代码清单 11.50](#listing-microposts-destroy-action) 所示。 ##### 代码清单 11.50:`MicropostsController` 的 `destroy` 动作 app/controllers/microposts_controller.rb ``` class MicropostsController < ApplicationController before_action :logged_in_user, only: [:create, :destroy] before_action :correct_user, only: :destroy . . . def destroy @micropost.destroy flash[:success] = "Micropost deleted" redirect_to request.referrer || root_url end private def micropost_params params.require(:micropost).permit(:content) end def correct_user @micropost = current_user.microposts.find_by(id: params[:id]) redirect_to root_url if @micropost.nil? end end ``` 注意,在 `destroy` 动作中重定向的地址是: ``` request.referrer || root_url ``` `request.referrer` [[7](#fn-7)] 和实现友好转向时使用的 `request.url` 关系紧密,表示前一个 URL(这里是首页)。[[8](#fn-8)]因为首页和资料页面都有微博,所以这么做很方便,我们使用 `request.referrer` 把用户重定向到发起删除请求的页面,如果 `request.referrer` 为 `nil`(例如在某些测试中),就转向 `root_url`。(可以和[代码清单 8.50](chapter8.html#listing-test-helper-log-in) 中设置参数默认值的用法对比一下。) 添加上述代码后,删除最新发布的第二篇微博后显示的页面如[图 11.17](#fig-home-post-delete) 所示。 ![home post delete 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333073d7635.png)图 11.17:删除最新发布的第二篇微博后显示的首页 ## 11.3.5 微博的测试 至此,微博模型和相关的界面完成了。我们还要编写简短的微博控制器测试,检查权限限制,以及一个集成测试,检查整个操作流程。 首先,在微博固件中添加一些由不同用户发布的微博,如[代码清单 11.51](#listing-add-micropost-different-owner) 所示。(现在只需要使用一个微博固件,但还是要多添加几个,以备后用。) ##### 代码清单 11.51:添加几个由不同用户发布的微博 test/fixtures/microposts.yml ``` . . . ants: content: "Oh, is that what you want? Because that's how you get ants!" created_at: <%= 2.years.ago %> user: archer zone: content: "Danger zone!" created_at: <%= 3.days.ago %> user: archer tone: content: "I'm sorry. Your words made sense, but your sarcastic tone did not." created_at: <%= 10.minutes.ago %> user: lana van: content: "Dude, this van's, like, rolling probable cause." created_at: <%= 4.hours.ago %> user: lana ``` 然后,编写一个简短的测试,确保某个用户不能删除其他用户的微博,并且要重定向到正确的地址,如[代码清单 11.52](#listing-micropost-user-mismatch-test) 所示。 ##### 代码清单 11.52:测试用户不能删除其他用户的微博 GREEN test/controllers/microposts_controller_test.rb ``` require 'test_helper' class MicropostsControllerTest < ActionController::TestCase def setup @micropost = microposts(:orange) end test "should redirect create when not logged in" do assert_no_difference 'Micropost.count' do post :create, micropost: { content: "Lorem ipsum" } end assert_redirected_to login_url end test "should redirect destroy when not logged in" do assert_no_difference 'Micropost.count' do delete :destroy, id: @micropost end assert_redirected_to login_url end test "should redirect destroy for wrong micropost" do log_in_as(users(:michael)) micropost = microposts(:ants) assert_no_difference 'Micropost.count' do delete :destroy, id: micropost end assert_redirected_to root_url end end ``` 最后,编写一个集成测试:登录,检查有没有分页链接,然后分别提交有效和无效的微博,再删除一篇微博,最后访问另一个用户的资料页面,确保没有删除链接。和之前一样,使用下面的命令生成测试文件: ``` $ rails generate integration_test microposts_interface invoke test_unit create test/integration/microposts_interface_test.rb ``` 这个测试的代码如[代码清单 11.53](#listing-microposts-interface-test) 所示。看看你能否把代码和前面说的步骤对应起来。(在这个测试中,`post` 请求后调用了 `follow_redirect!`,而没有直接使用 `post_via_redirect`,这是要兼顾[代码清单 11.68](#listing-image-upload-test) 中的图片上传测试。) ##### 代码清单 11.53:微博资源界面的集成测试 GREEN test/integration/microposts_interface_test.rb ``` require 'test_helper' class MicropostInterfaceTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end test "micropost interface" do log_in_as(@user) get root_path assert_select 'div.pagination' # 无效提交 assert_no_difference 'Micropost.count' do post microposts_path, micropost: { content: "" } end assert_select 'div#error_explanation' # 有效提交 content = "This micropost really ties the room together" assert_difference 'Micropost.count', 1 do post microposts_path, micropost: { content: content } end assert_redirected_to root_url follow_redirect! assert_match content, response.body # 删除一篇微博 assert_select 'a', text: 'delete' first_micropost = @user.microposts.paginate(page: 1).first assert_difference 'Micropost.count', -1 do delete micropost_path(first_micropost) end # 访问另一个用户的资料页面 get user_path(users(:archer)) assert_select 'a', text: 'delete', count: 0 end end ``` 因为我们已经把可以正常运行的应用开发好了,所以测试组件应该可以通过: ##### 代码清单 11.54:**GREEN** ``` $ bundle exec rake test ```
    ';

    11.2 显示微博

    最后更新于:2022-04-01 22:30:22

    # 11.2 显示微博 尽管我们还没实现直接在网页中发布微博的功能(将在 [11.3.2 节](#creating-microposts)实现),不过还是有办法显示微博,并对显示的内容进行测试。我们将按照 Twitter 的方式,不在微博资源的 `index` 页面显示用户的微博,而在用户资源的 `show` 页面显示,构思图如[图 11.4](#fig-user-microposts-mockup) 所示。我们会先使用一些简单的 ERb 代码,在用户的资料页面显示微博,然后在 [9.3.2 节](chapter9.html#sample-users)的种子数据中添加一些微博,这样才有内容可以显示。 ![user microposts mockup 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306f2af9d.png)图 11.4:显示有微博的资料页面构思图 ## 11.2.1 渲染微博 我们计划在用户的资料页面(`show.html.erb`)显示用户的微博,还要显示用户发布了多少篇微博。你会发现,很多做法和 [9.3 节](chapter9.html#showing-all-users)列出所有用户时类似。 虽然 [11.3 节](#manipulating-microposts)才会用到微博控制器,但马上就需要使用视图,所以现在就要生成控制器: ``` $ rails generate controller Microposts ``` 这一节的主要目的是渲染用户发布的所有微博。[9.3.5 节](chapter9.html#partial-refactoring)用过这样的代码: ```
      <%= render @users %>
    ``` 这段代码会自动使用局部视图 `_user.html.erb` 渲染 `@users` 变量中的每个用户。同样地,我们要编写 `_micropost.html.erb` 局部视图,使用类似的方式渲染微博集合: ```
      <%= render @microposts %>
    ``` 注意,我们使用的是有序列表标签 `ol`(而不是无需列表 `ul`),因为微博是按照一定顺序显示的(按时间倒序)。相应的局部视图如[代码清单 11.21](#listing-micropost-partial) 所示。 ##### 代码清单 11.21:渲染单篇微博的局部视图 app/views/microposts/_micropost.html.erb ```
  • <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %> <%= link_to micropost.user.name, micropost.user %> <%= micropost.content %> Posted <%= time_ago_in_words(micropost.created_at) %> ago.
  • ``` 这个局部视图使用了 `time_ago_in_words` 辅助方法,这个方法的作用应该很明显,效果会在 [11.2.2 节](#sample-microposts)看到。[代码清单 11.21](#listing-micropost-partial) 还为每篇微博指定了 CSS ID: ```
  • ``` 这是好习惯,说不定以后要处理(例如使用 JavaScript)单篇微博呢。 接下来要解决显示大量微博的问题。我们可以使用 [9.3.3 节](chapter9.html#pagination)显示大量用户的方法来解决这个问题,即使用分页。和前面一样,我们要使用 `will_paginate` 方法: ``` <%= will_paginate @microposts %> ``` 如果和用户列表页面的代码([代码清单 9.41](chapter9.html#listing-will-paginate-index-view))比较的话,会发现之前使用的代码是: ``` <%= will_paginate %> ``` 前面之所以可以直接调用,是因为在用户控制器中,`will_paginate` 假定有一个名为 `@users` 的实例变量([9.3.3 节](chapter9.html#pagination)说过,这个变量所属的类应该是 `AvtiveRecord::Relation`)。现在,因为还在用户控制器中,但是我们要分页显示微博,所以必须明确地把 `@microposts` 变量传给 `will_paginate` 方法。当然了,我们还要在 `show` 动作中定义 `@microposts` 变量,如[代码清单 11.22](#listing-user-show-microposts-instance) 所示。 ##### 代码清单 11.22:在用户控制器的 `show` 动作中定义 `@microposts` 变量 app/controllers/users_controller.rb ``` class UsersController < ApplicationController . . . def show @user = User.find(params[:id]) @microposts = @user.microposts.paginate(page: params[:page]) end . . . end ``` 注意看 `paginate` 方法是多么智能,甚至可以在关联上使用,从 `microposts` 表中取出每一页要显示的微博。 最后,还要显示用户发布的微博数量。我们可以使用 `count` 方法实现: ``` user.microposts.count ``` 和 `paginate` 方法一样,`count` 方法也可以在关联上使用。`count` 的计数过程不是把所有微博都从数据库中读取出来,然后再在所得的数组上调用 `length` 方法,如果这样做的话,微博数量一旦很多,效率就会降低。其实,`count` 方法直接在数据库层计算,让数据库统计指定的 `user_id` 拥有多少微博。(所有数据库都会对这种操作做性能优化。如果统计数量仍然是应用的性能瓶颈,可以使用“[计数缓存](http://railscasts.com/episodes/23-counter-cache-column)”进一步提速。) 综上所述,现在可以把微博添加到资料页面了,如[代码清单 11.23](#listing-user-show-microposts) 所示。注意,`if @user.microposts.any?`(在[代码清单 7.19](chapter7.html#listing-errors-partial) 中见过类似的用法)的作用是,如果用户没有发布微博,不显示一个空列表。 ##### 代码清单 11.23:在用户资料页面中加入微博 app/views/users/show.html.erb ``` <% provide(:title, @user.name) %>
    <% if @user.microposts.any? %>

    Microposts (<%= @user.microposts.count %>)

      <%= render @microposts %>
    <%= will_paginate @microposts %> <% end %>
    ``` 现在,我们可以查看一下修改后的用户资料页面,如[图 11.5](#fig-user-profile-no-microposts)。可能会出乎你的意料,不过也是理所当然的,因为现在还没有微博。下面我们就来改变这种状况。 ![user profile no microposts 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306f440e6.png)图 11.5:添加显示微博的代码后用户的资料页面,但没有微博 ## 11.2.2 示例微博 在 [11.2.1 节](#rendering-microposts),为了显示用户的微博,创建或修改了几个模板,但是结果有点不给力。为了改变这种状况,我们要在 [9.3.2 节](chapter9.html#sample-users)用到的种子数据中加入一些微博。 为所有用户添加示例微博要花很长时间,所以我们决定只为前六个用户添加。为此,要使用 `take` 方法: ``` User.order(:created_at).take(6) ``` 调用 `order` 方法的作用是按照创建用户的顺序查找六个用户。 我们要分别为这六个用户创建 50 篇微博(数量要多于 30 个才能分页)。为了生成微博的内容,我们要使用 Faker 提供的 [`Lorem.sentence`](http://rubydoc.info/gems/faker/1.3.0/Faker/Lorem) 方法。[[2](#fn-2)]添加示例微博后的种子数据如[代码清单 11.24](#listing-sample-microposts) 所示。 ##### 代码清单 11.24:添加示例微博 db/seeds.rb ``` . . . users = User.order(:created_at).take(6) 50.times do content = Faker::Lorem.sentence(5) users.each { |user| user.microposts.create!(content: content) } end ``` 然后,像之前一样重新把种子数据写入开发数据库: ``` $ bundle exec rake db:migrate:reset $ bundle exec rake db:seed ``` 完成后还要重启 Rails 开发服务器。 现在,我们能看到 [11.2.1 节](#rendering-microposts)的劳动成果了——用户资料页面显示了微博。[[3](#fn-3)]初步结果如[图 11.6](#fig-user-profile-microposts-no-styling) 所示。 ![user profile microposts no styling 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306f59e20.png)图 11.6:用户资料页面显示的微博,还没添加样式 [图 11.6](#fig-user-profile-microposts-no-styling) 中显示的微博还没有样式,那我们就加入一些样式,如[代码清单 11.25](#listing-micropost-css) 所示,[[4](#fn-4)]然后再看一下页面显示的效果。 ##### 代码清单 11.25:微博的样式(包含本章要使用的所有 CSS) app/assets/stylesheets/custom.css.scss ``` . . . /* microposts */ .microposts { list-style: none; padding: 0; li { padding: 10px 0; border-top: 1px solid #e8e8e8; } .user { margin-top: 5em; padding-top: 0; } .content { display: block; margin-left: 60px; img { display: block; padding: 5px 0; } } .timestamp { color: $gray-light; display: block; margin-left: 60px; } .gravatar { float: left; margin-right: 10px; margin-top: 5px; } } aside { textarea { height: 100px; margin-bottom: 5px; } } span.picture { margin-top: 10px; input { border: 0; } } ``` [图 11.7](#fig-user-profile-with-microposts) 是第一个用户的资料页面,[图 11.8](#fig-other-profile-with-microposts) 是另一个用户的资料页面,[图 11.9](#fig-user-profile-microposts) 是第一个用户资料页面的第 2 页,页面底部还显示了分页链接。注意观察这三幅图,可以看到,微博后面显示了距离发布的时间(例如,“Posted 1 minute ago.”),这就是[代码清单 11.21](#listing-micropost-partial) 中 `time_ago_in_words` 方法实现的效果。过一会再刷新页面,这些文字会根据当前时间自动更新。 ![user profile with microposts 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306f9ac5c.png)图 11.7:显示有微博的用户资料页面([/users/1](http://localhost:3000/users/1))![other profile with microposts 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306fb5879.png)图 11.8:另一个用户的资料页面([/users/5](http://localhost:3000/users/5)),也显示有微博![user profile microposts page 2 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306fd199a.png)图 11.9:微博分页链接([/users/1?page=2](http://localhost:3000/users/1?page=2)) ## 11.2.3 资料页面中微博的测试 新激活的用户会重定向到资料页面,那时已经测试了资料页面是否能正确渲染([代码清单 10.31](chapter10.html#listing-signup-with-account-activation-test))。本节,我们要编写几个简短的集成测试,检查资料页面中的其他内容。首先,生成资料页面的集成测试文件: ``` $ rails generate integration_test users_profile invoke test_unit create test/integration/users_profile_test.rb ``` 为了测试资料页面中显示有微博,我们要把微博固件和用户关联起来。Rails 提供了一种便利的方法,可以在固件中建立关联,例如: ``` orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> user: michael ``` 把 `user` 的值设为 `michael` 后,Rails 会把这篇微博和指定的用户固件关联起来: ``` michael: name: Michael Example email: michael@example.com . . . ``` 为了测试微博分页,我们要使用[代码清单 9.43](chapter9.html#listing-users-fixtures-extra-users) 中用到的方法,通过嵌入式 Ruby 代码多生成一些微博固件: ``` <% 30.times do |n| %> micropost_<%= n %>: content: <%= Faker::Lorem.sentence(5) %> created_at: <%= 42.days.ago %> user: michael <% end %> ``` 综上,修改后的微博固件如[代码清单 11.26](#listing-updated-micropost-fixtures) 所示。 ##### 代码清单 11.26:添加关联用户后的微博固件 test/fixtures/microposts.yml ``` orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> user: michael tau_manifesto: content: "Check out the @tauday site by @mhartl: http://tauday.com" created_at: <%= 3.years.ago %> user: michael cat_video: content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk" created_at: <%= 2.hours.ago %> user: michael most_recent: content: "Writing a short test" created_at: <%= Time.zone.now %> user: michael <% 30.times do |n| %> micropost_<%= n %>: content: <%= Faker::Lorem.sentence(5) %> created_at: <%= 42.days.ago %> user: michael <% end %> ``` 测试数据准备好了,测试本身也很简单:访问资料页面,检查页面的标题、用户的名字、Gravatar 头像、微博数量和分页显示的微博,如[代码清单 11.27](#listing-user-profile-test) 所示。注意,为了使用[代码清单 4.2](chapter4.html#listing-title-helper) 中的 `full_title` 辅助方法测试页面的标题,我们要把 `ApplicationHelper` 模块引入测试。[[5](#fn-5)] ##### 代码清单 11.27:用户资料页面的测试 GREEN test/integration/users_profile_test.rb ``` require 'test_helper' class UsersProfileTest < ActionDispatch::IntegrationTest include ApplicationHelper def setup @user = users(:michael) end test "profile display" do get user_path(@user) assert_template 'users/show' assert_select 'title', full_title(@user.name) assert_select 'h1', text: @user.name assert_select 'h1>img.gravatar' assert_match @user.microposts.count.to_s, response.body assert_select 'div.pagination' @user.microposts.paginate(page: 1).each do |micropost| assert_match micropost.content, response.body end end end ``` 检查微博数量时用到了 `response.body`,[第 10 章的练习](chapter10.html#account-activation-and-password-reset-exercises)中见过。别被名字迷惑了,其实 `response.body` 的值是整个页面的 HTML 源码(不只是 `body` 元素中的内容)。如果我们只关心页面中某处显示的微博数量,使用下面的断言找到匹配的内容即可: ``` assert_match @user.microposts.count.to_s, response.body ``` `assert_match` 没有 `assert_select` 的针对性强,无需指定要查找哪个 HTML 标签。 [代码清单 11.27](#listing-user-profile-test) 还在 `assert_select` 中使用了嵌套式句法: ``` assert_select 'h1>img.gravatar' ``` 这行代码的意思是,在 `h1` 标签中查找类为 `gravatar` 的 `img` 标签。 因为应用能正常运行,所以测试组件应该也能通过: ##### 代码清单 11.28:**GREEN** ``` $ bundle exec rake test ```
  • ';

    11.1 微博模型

    最后更新于:2022-04-01 22:30:20

    # 11.1 微博模型 实现微博资源的第一步是创建微博数据模型,在模型中设定微博的基本特征。和 [2.3 节](chapter2.html#the-microposts-resource)创建的模型类似,我们要实现的微博模型要包含数据验证,以及和用户模型之间的关联。除此之外,我们还会做充分的测试,指定默认的排序方式,以及自动删除已注销用户的微博。 如果使用 Git 做版本控制的话,和之前一样,建议你新建一个主题分支: ``` $ git checkout master $ git checkout -b user-microposts ``` ## 11.1.1 基本模型 微博模型只需要两个属性:一个是 `content`,用来保存微博的内容;另一个是 `user_id`,把微博和用户关联起来。微博模型的结构如[图 11.1](#fig-micropost-model) 所示。 ![micropost model 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306e48377.png)图 11.1:微博数据模型 注意,在这个模型中,`content` 属性的类型为 `text`,而不是 `string`,目的是存储任意长度的文本。虽然我们会限制微博内容的长度不超过 140 个字符([11.1.2 节](#micropost-validations)),也就是说在 `string` 类型的 255 个字符长度的限制内,但使用 `text` 能更好地表达微博的特性,即把微博看成一段文本更符合常理。在 [11.3.2 节](#creating-microposts),会把文本字段换成多行文本字段,用于提交微博。而且,如果以后想让微博的内容更长一些(例如包含多国文字),使用 `text` 类型处理起来更灵活。何况,在生产环境中使用 `text` 类型并[没有什么性能差异](http://www.postgresql.org/docs/9.1/static/datatype-character.html),所以不会有什么额外消耗。 和用户模型一样([代码清单 6.1](chapter6.html#listing-generate-user-model)),我们要使用 `generate model` 命令生成微博模型: ``` $ rails generate model Micropost content:text user:references ``` 这个命令会生成一个迁移文件,用于在数据库中生成一个名为 `microposts` 的表,如[代码清单 11.1](#listing-micropost-migration) 所示。可以和生成 `users` 表的迁移对照一下,参见[代码清单 6.2](chapter6.html#listing-users-migration)。二者之间最大的区别是,前者使用了 `references` 类型。`references` 会自动添加 `user_id` 列(以及索引),把用户和微博关联起来。和用户模型一样,微博模型的迁移中也自动生成了 `t.timestamps`。[6.1.1 节](chapter6.html#database-migrations)说过,这行代码的作用是添加 `created_at` 和 `updated_at` 两列。([11.1.4 节](#micropost-refinements)和 [11.2.1 节](#rendering-microposts)会使用 `created_at` 列。) ##### 代码清单 11.1:微博模型的迁移文件,还创建了索引 db/migrate/[timestamp]_create_microposts.rb ``` class CreateMicroposts < ActiveRecord::Migration def change create_table :microposts do |t| t.text :content t.references :user, index: true, foreign_key: true t.timestamps null: false end add_index :microposts, [:user_id, :created_at] end end ``` 因为我们会按照发布时间的倒序查询某个用户发布的所有微博,所以在上述代码中为 `user_id` 和 `created_at` 列创建了索引(参见[旁注 6.2](chapter6.html#aside-database-indices)): ``` add_index :microposts, [:user_id, :created_at] ``` 我们把 `user_id` 和 `created_at` 放在一个数组中,告诉 Rails 我们要创建的是“多键索引”(multiple key index),因此 Active Record 会同时使用这两个键。 然后像之前一样,执行下面的命令更新数据库: ``` $ bundle exec rake db:migrate ``` ## 11.1.2 微博模型的数据验证 我们已经创建了基本的数据模型,下面要添加一些验证,实现符合需求的约束。微博模型必须要有一个属性表示用户的 ID,这样才能知道某篇微博是由哪个用户发布的。实现这样的属性,最好的方法是使用 Active Record 关联。[11.1.3 节](#user-micropost-associations)会实现关联,现在我们直接处理微博模型。 我们可以参照用户模型的测试([代码清单 6.7](chapter6.html#listing-name-presence-test)),在 `setup` 方法中新建一个微博对象,并把它和固件中的一个有效用户关联起来,然后在测试中检查这个微博对象是否有效。因为每篇微博都要和用户关联起来,所以我们还要为 `user_id` 属性的存在性验证编写一个测试。综上所述,测试如[代码清单 11.2](#listing-micropost-validity-test) 所示。 ##### 代码清单 11.2:测试微博是否有效 RED test/models/micropost_test.rb ``` require 'test_helper' class MicropostTest < ActiveSupport::TestCase def setup @user = users(:michael) # 这行代码不符合常见做法 @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id) end test "should be valid" do assert @micropost.valid? end test "user id should be present" do @micropost.user_id = nil assert_not @micropost.valid? end end ``` 如 `setup` 方法中的注释所说,创建微博使用的方法不符合常见做法,我们会在 [11.1.3 节](#user-micropost-associations)修正。 微博是否有效的测试能通过,但用户 ID 存在性验证的测试无法通过,因为微博模型目前还没有任何验证规则: ##### 代码清单 11.3:**RED** ``` $ bundle exec rake test:models ``` 为了让测试通过,我们要添加用户 ID 存在性验证,如[代码清单 11.4](#listing-micropost-user-id-validation) 所示。(注意,这段代码中 `belongs_to` 那行由[代码清单 11.1](#listing-micropost-migration) 中的迁移自动生成。[11.1.3 节](#user-micropost-associations)会深入介绍这行代码的作用。) ##### 代码清单 11.4:微博模型 `user_id` 属性的验证 GREEN app/models/micropost.rb ``` class Micropost < ActiveRecord::Base belongs_to :user validates :user_id, presence: true end ``` 现在,整个测试组件应该都能通过: ##### 代码清单 11.5:**GREEN** ``` $ bundle exec rake test ``` 接下来,我们要为 `content` 属性加上数据验证(参照 [2.3.2 节](chapter2.html#putting-the-micro-in-microposts)的做法)。和 `user_id` 一样,`content` 属性必须存在,而且还要限制内容的长度不能超过 140 个字符,这才是真正的“微”博。首先,我们要参照 [6.2 节](chapter6.html#user-validations)用户模型的验证测试,编写一些简单的测试,如[代码清单 11.6](#listing-micropost-validations-tests) 所示。 ##### 代码清单 11.6:测试微博模型的验证 RED test/models/micropost_test.rb ``` require 'test_helper' class MicropostTest < ActiveSupport::TestCase def setup @user = users(:michael) @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id) end test "should be valid" do assert @micropost.valid? end test "user id should be present" do @micropost.user_id = nil assert_not @micropost.valid? end test "content should be present" do @micropost.content = " " assert_not @micropost.valid? end test "content should be at most 140 characters" do @micropost.content = "a" * 141 assert_not @micropost.valid? end end ``` 和 [6.2 节](chapter6.html#user-validations)一样,[代码清单 11.6](#listing-micropost-validations-tests)也用到了字符串连乘来测试微博内容长度的验证: ``` $ rails console >> "a" * 10 => "aaaaaaaaaa" >> "a" * 141 => "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ``` 在模型中添加的代码基本上和用户模型 `name` 属性的验证一样([代码清单 6.16](chapter6.html#listing-length-validation)),如[代码清单 11.7](#listing-micropost-validations) 所示。 ##### 代码清单 11.7:微博模型的验证 GREEN app/models/micropost.rb ``` class Micropost < ActiveRecord::Base belongs_to :user validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end ``` 现在,测试组件应该能通过了: ##### 代码清单 11.8:**GREEN** ``` $ bundle exec rake test ``` ## 11.1.3 用户和微博之间的关联 为 Web 应用构建数据模型时,最基本的要求是要能够在不同的模型之间建立关联。在这个应用中,每篇微博都属于某个用户,而每个用户一般都有多篇微博。用户和微博之间的关系在 [2.3.3 节](chapter2.html#a-user-has-many-microposts)简单介绍过,如[图 11.2](#fig-micropost-belongs-to-user) 和[图 11.3](#fig-user-has-many-microposts) 所示。在实现这种关联的过程中,我们会为微博模型和用户模型编写一些测试。 ![micropost belongs to user](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306e65763.png)图 11.2:微博和所属用户之间的 `belongs_to`(属于)关系![user has many microposts](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306e7b37d.png)图 11.3:用户和微博之间的 `has_many`(拥有多个)关系 使用本节实现的 `belongs_to`/`has_many` 关联之后,Rails 会自动创建一些方法,如[表 11.1](#table-association-methods) 所示。注意,从表中可知,相较于下面的方法 ``` Micropost.create Micropost.create! Micropost.new ``` 我们得到了 ``` user.microposts.create user.microposts.create! user.microposts.build ``` 后者才是创建微博的正确方式,即通过相关联的用户对象创建。通过这种方式创建的微博,其 `user_id` 属性会自动设为正确的值。所以,我们可以把[代码清单 11.2](#listing-micropost-validity-test) 中的下述代码 ``` @user = users(:michael) # 这行代码不符合常见做法 @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id) ``` 改为 ``` @user = users(:michael) @micropost = @user.microposts.build(content: "Lorem ipsum") ``` (和 `new` 方法一样,`build` 方法返回一个存储在内存中的对象,不会修改数据库。)只要关联定义的正确,`@micropost` 变量的 `user_id` 属性就会自动设为所关联用户的 ID。 表 11.1:用户和微博之间建立关联后得到的方法简介 | 方法 | 作用 | | --- | --- | | `micropost.user` | 返回和微博关联的用户对象 | | `user.microposts` | 返回用户发布的所有微博 | | `user.microposts.create(arg)` | 创建一篇 `user` 发布的微博 | | `user.microposts.create!(arg)` | 创建一篇 `user` 发布的微博(失败时抛出异常) | | `user.microposts.build(arg)` | 返回一个 `user` 发布的新微博对象 | | `user.microposts.find_by(id: 1)` | 查找 `user` 发布的一篇微博,而且微博的 ID 为 1 | 为了让 `@user.microposts.build` 这样的代码能使用,我们要修改用户模型和微博模型,添加一些代码,把这两个模型关联起来。[代码清单 11.1](#listing-micropost-migration) 中的迁移已经自动添加了 `belongs_to :user`,如[代码清单 11.9](#listing-micropost-belongs-to-user) 所示。关联的另一头,`has_many :microposts`,我们要自己动手添加,如[代码清单 11.10](#listing-user-has-many-microposts) 所示。 ##### 代码清单 11.9:一篇微博属于(`belongs_to`)一个用户 GREEN app/models/micropost.rb ``` class Micropost < ActiveRecord::Base belongs_to :user validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end ``` ##### 代码清单 11.10:一个用户有多篇(`has_many`)微博 GREEN app/models/user.rb ``` class User < ActiveRecord::Base has_many :microposts . . . end ``` 定义好关联后,我们可以修改[代码清单 11.2](#listing-micropost-validity-test) 中的 `setup` 方法了,使用正确的方式创建一个微博对象,如[代码清单 11.11](#listing-micropost-validity-test-idiomatic) 所示。 ##### 代码清单 11.11:使用正确的方式创建微博对象 GREEN test/models/micropost_test.rb ``` require 'test_helper' class MicropostTest < ActiveSupport::TestCase def setup @user = users(:michael) @micropost = @user.microposts.build(content: "Lorem ipsum") end test "should be valid" do assert @micropost.valid? end test "user id should be present" do @micropost.user_id = nil assert_not @micropost.valid? end . . . end ``` 当然,经过这次简单的重构后测试组件应该还能通过: ##### 代码清单 11.12:**GREEN** ``` $ bundle exec rake test ``` ## 11.1.4 改进微博模型 本节,我们要改进一下用户和微博之间的关联:按照特定的顺序取回用户的微博,并且让微博依属于用户,如果用户注销了,就自动删除这个用户发布的所有微博。 ### 默认作用域 默认情况下,`user.microposts` 不能确保微博的顺序,但是按照博客和 Twitter 的习惯,我们希望微博按照创建时间倒序排列,也就是最新发布的微博在前面。[[1](#fn-1)]为此,我们要使用“默认作用域”(default scope)。 这样的功能很容易让测试意外通过(就算应用代码不对,测试也能通过),所以我们要使用测试驱动开发技术,确保实现的方式是正确的。首先,我们编写一个测试,检查数据库中的第一篇微博和微博固件中名为 `most_recent` 的微博相同,如[代码清单 11.13](#listing-micropost-order-test) 所示。 ##### 代码清单 11.13:测试微博的排序 RED test/models/micropost_test.rb ``` require 'test_helper' class MicropostTest < ActiveSupport::TestCase . . . test "order should be most recent first" do assert_equal Micropost.first, microposts(:most_recent) end end ``` 这段代码要使用微博固件,所以我们要定义固件,如[代码清单 11.14](#listing-micropost-fixtures) 所示。 ##### 代码清单 11.14:微博固件 test/fixtures/microposts.yml ``` orange: content: "I just ate an orange!" created_at: <%= 10.minutes.ago %> tau_manifesto: content: "Check out the @tauday site by @mhartl: http://tauday.com" created_at: <%= 3.years.ago %> cat_video: content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk" created_at: <%= 2.hours.ago %> most_recent: content: "Writing a short test" created_at: <%= Time.zone.now %> ``` 注意,我们使用嵌入式 Ruby 明确设置了 `created_at` 属性的值。因为这个属性由 Rails 自动更新,一般无法手动设置,但在固件中可以这么做。实际上可能不用自己设置这些属性,因为在某些系统中固件会按照定义的顺序创建。在这个文件中,最后一个固件最后创建(因此是最新的一篇微博)。但是绝不要依赖这种行为,因为并不可靠,而且在不同的系统中有差异。 现在,测试应该无法通过: ##### 代码清单 11.15:**RED** ``` $ bundle exec rake test TEST=test/models/micropost_test.rb \ > TESTOPTS="--name test_order_should_be_most_recent_first" ``` 我们要使用 Rails 提供的 `default_scope` 方法让测试通过。这个方法的作用很多,这里我们要用它设定从数据库中读取数据的默认顺序。为了得到特定的顺序,我们要在 `default_scope` 方法中指定 `order` 参数,按 `created_at` 列的值排序,如下所示: ``` order(:created_at) ``` 可是,这实现的是“升序”,从小到大排列,即最早发布的微博排在最前面。为了让微博降序排列,我们要向下走一层,使用纯 SQL 语句: ``` order('created_at DESC') ``` 在 SQL 中,`DESC` 表示“降序”,即新发布的微博在前面。在以前的 Rails 版本中,必须使用纯 SQL 语句才能实现这个需求,但从 Rails 4.0 起,可以使用纯 Ruby 句法实现: ``` order(created_at: :desc) ``` 把默认作用域加入微博模型,如[代码清单 11.16](#listing-micropost-ordering) 所示。 ##### 代码清单 11.16:使用 `default_scope` 排序微博 GREEN app/models/micropost.rb ``` class Micropost < ActiveRecord::Base belongs_to :user default_scope -> { order(created_at: :desc) } validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end ``` [代码清单 11.16](#listing-micropost-ordering) 中使用了“箭头”句法,表示一种对象,叫 Proc(procedure)或 lambda,即“匿名函数”(没有名字的函数)。`->` 接受一个代码块([4.3.2 节](chapter4.html#blocks)),返回一个 Proc。然后在这个 Proc 上调用 `call` 方法执行其中的代码。我们可以在控制台中看一下怎么使用 Proc: ``` >> -> { puts "foo" } => # >> -> { puts "foo" }.call foo => nil ``` (Proc 是高级 Ruby 知识,如果现在不理解也不用担心。) 按照[代码清单 11.16](#listing-micropost-ordering) 修改后,测试应该可以通过了: ##### 代码清单 11.17:**GREEN** ``` $ bundle exec rake test ``` ### 依属关系:destroy 除了设定恰当的顺序外,我们还要对微博模型做一项改进。我们在 [9.4 节](chapter9.html#deleting-users)介绍过,管理员有删除用户的权限。那么,在删除用户的同时,有必要把该用户发布的微博也删除。 为此,我们可以把一个参数传给 `has_many` 关联方法,如[代码清单 11.18](#listing-micropost-dependency) 所示。 ##### 代码清单 11.18:确保用户的微博在删除用户的同时也被删除 app/models/user.rb ``` class User < ActiveRecord::Base has_many :microposts, dependent: :destroy . . . end ``` `dependent: :destroy` 的作用是在用户被删除的时候,把这个用户发布的微博也删除。这么一来,如果管理员删除了用户,数据库中就不会出现无主的微博了。 我们可以为用户模型编写一个测试,证明[代码清单 11.18](#listing-micropost-dependency) 中的代码是正确的。我们要保存一个用户(因此得到了用户的 ID),再创建一个属于这个用户的微博,然后检查删除用户后微博的数量有没有减少一个,如[代码清单 11.19](#listing-dependent-destroy-test) 所示。(和[代码清单 9.57](chapter9.html#listing-delete-link-integration-test) 中“删除”链接的集成测试对比一下。) ##### 代码清单 11.19:测试 `dependent: :destroy` GREEN test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confirmation: "foobar") end . . . test "associated microposts should be destroyed" do @user.save @user.microposts.create!(content: "Lorem ipsum") assert_difference 'Micropost.count', -1 do @user.destroy end end end ``` 如果[代码清单 11.18](#listing-micropost-dependency) 正确,测试组件就应该能通过: ##### 代码清单 11.20:**GREEN** ``` $ bundle exec rake test ```
    ';

    第 11 章 用户的微博

    最后更新于:2022-04-01 22:30:18

    # 第 11 章 用户的微博 在开发这个演示应用的过程中,我们用到了四个资源:用户,会话,账户激活和密码重设。但只有第一个资源通过 Active Record 模型对应了数据库中的表。本章,我们要再实现一个这样的资源——用户的微博,即用户发布的短消息。[第 2 章](chapter2.html#a-toy-app)实现了微博的雏形,本章则会在 [2.3 节](chapter2.html#the-microposts-resource)的基础上,实现一个功能完整的微博资源。首先,我们要创建微博数据模型,通过 `has_many` 和 `belongs_to` 方法把微博和用户关联起来,然后再创建处理和显示微博所需的表单及局部视图([11.4 节](#micropost-images)还要实现上传图片功能)。在[第 12 章](chapter12.html#following-users),还要加入关注其他用户的功能,届时,我们这个山寨版 Twitter 才算完成。
    ';

    10.6 证明超时失效的比较算式

    最后更新于:2022-04-01 22:30:15

    # 10.6 证明超时失效的比较算式 本节我们要证明 [10.2.4 节](#resetting-the-password)比较密码重设超时失效的算式是正确的。我们先来定义两个时间间隔:表示发送密码重设邮件后经过的时间,表示限制的失效时长(例如两个小时)。如果邮件发出后经过的时间比限制的失效时长长,说明此次密码重设请求已经失效,即: 如果用 表示现在的时间,表示发送邮件的时间,表示失效的时间(例如两个小时以前),那么: 把这两个等式代入第一个算式: 在这个不等式的两边乘于 -1 后得到: 把 = 2.hours.ago 代入这个不等式后就能得到[代码清单 10.53](#listing-user-model-password-reset-expired) 中的 `password_reset_expired?` 方法: ``` def password_reset_expired? reset_sent_at < 2.hours.ago end ``` [10.2.4 节](#resetting-the-password)说过,如果把 `<` 理解成“超过”而不是“小于号”的话,就能得到一个符合人类逻辑的句子:“密码重设邮件已经发出超过两小时”。
    ';

    10.5 练习

    最后更新于:2022-04-01 22:30:13

    # 10.5 练习 电子书中有练习的答案,如果想阅读参考答案,请[购买电子书](http://railstutorial-china.org/#purchase)。 避免练习和正文冲突的方法参见[3.6 节](chapter3.html#mostly-static-pages-exercises)中的说明。 1. 填写[代码清单 10.57](#listing-password-reset-expire-test) 中缺少的代码,为[代码清单 10.52](#listing-password-reset-update-action) 中的密码重设超时失效分支编写集成测试。([代码清单 10.57](#listing-password-reset-expire-test) 用到了 `response.body`,用来获取返回页面中的 HTML。)检查是否过期有很多方法,[代码清单 10.57](#listing-password-reset-expire-test) 使用的方法是,检查响应主体中是否包含单词“expired”(不区分大小写)。 2. 现在,用户列表页面会显示所有用户,而且各用户还可以通过 /users/:id 查看。不过,更合理的做法是只显示已激活的用户。填写[代码清单 10.58](#listing-show-only-active-users-exercise) 中缺少的代码,实现这一需求。[[9](#fn-9)](这段代码中使用了 Active Record 提供的 `where` 方法,[11.3.3 节](chapter11.html#a-proto-feed)会详细介绍。)附加题:为 /users 和 /users/:id 编写集成测试。 3. 在[代码清单 10.42](#listing-user-model-password-reset) 中,`activate` 和 `create_reset_digest` 方法中都调用了两次 `update_attribute` 方法,每一次调用都要单独执行一个数据库事务(transaction)。填写[代码清单 10.59](#listing-update-columns) 中缺少的代码,把两个 `update_attribute` 调用换成一个 `update_columns` 调用,这样修改后每个方法只会和数据库交互一次。然后再运行测试组件,确保仍能通过。 ##### 代码清单 10.57:测试密码重设超时失效了 GREEN test/integration/password_resets_test.rb ``` require 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear @user = users(:michael) end . . . test "expired token" do get new_password_reset_path post password_resets_path, password_reset: { email: @user.email } @user = assigns(:user) @user.update_attribute(:reset_sent_at, 3.hours.ago) patch password_reset_path(@user.reset_token), email: @user.email, user: { password: "foobar", password_confirmation: "foobar" } assert_response :redirect follow_redirect! assert_match /FILL_IN/i, response.body end end ``` ##### 代码清单 10.58:只显示已激活用户的代码模板 app/controllers/users_controller.rb ``` class UsersController < ApplicationController . . . def index @users = User.where(activated: FILL_IN).paginate(page: params[:page]) end def show @user = User.find(params[:id]) redirect_to root_url and return unless FILL_IN end . . . end ``` ##### 代码清单 10.59:使用 `update_columns` 的代码模板 app/models/user.rb ``` class User < ActiveRecord::Base attr_accessor :remember_token, :activation_token, :reset_token before_save :downcase_email before_create :create_activation_digest . . . # 激活账户 def activate update_columns(activated: FILL_IN, activated_at: FILL_IN) end # 发送激活邮件 def send_activation_email UserMailer.account_activation(self).deliver_now end # 设置密码重设相关的属性 def create_reset_digest self.reset_token = User.new_token update_columns(reset_digest: FILL_IN, reset_sent_at: FILL_IN) end # 发送密码重设邮件 def send_password_reset_email UserMailer.password_reset(self).deliver_now end . . . end ```
    ';

    10.4 小结

    最后更新于:2022-04-01 22:30:11

    # 10.4 小结 实现账户激活和密码重设功能后,我们的演示应用已经完整实现了“注册-登录-退出”机制,而且是专业级的。本书剩下的章节以此为基础,实现类似 Twitter 的微博([第 11 章](chapter11.html#user-microposts))和所关注用户发布的微博列表([第 12 章](chapter12.html#following-users))。在实现的过程中,我们会学到一些 Rails 提供的强大功能,例如使用 `has_many` 和 `has_many :through` 实现的高级数据模型。 ## 10.4.1 读完本章学到了什么 * 和会话一样,账户激活虽然没有对应的 Active Record 对象,但也可以看做一个“资源”; * Rails 可以生成 Action Mailer 动作和视图,用于发送邮件; * Action Mailer 支持纯文本邮件和 HTML 邮件; * 与普通的动作和视图一样,在邮件程序的视图中也可以使用邮件程序动作中的实例变量; * 与会话和账户激活一样,密码重设虽然没有对应的 Active Record 对象,但也可以看做一个“资源”; * 账户激活和密码重设都使用生成的令牌创建唯一的 URL,分别用于激活账户和重设密码; * 邮件程序的测试和集成测试对确认邮件程序的表现都有用; * 在生产环境可以使用 SendGrid 发送电子邮件。
    ';

    10.3 在生产环境中发送邮件

    最后更新于:2022-04-01 22:30:09

    # 10.3 在生产环境中发送邮件 我们已经成功实现了账户激活和密码重设功能,本节要配置应用,让它在生产环境中能真正地发送邮件。我们首先搭建一个免费的邮件服务,然后配置应用,最后再部署。 我们要在生产环境中使用 SendGrid 服务发送邮件。这个服务是 Heroku 的扩展,只有通过认证的账户才能使用。(要在 Heroku 的账户中填写信用卡信息,不过认证不收费。)对我们的应用来说,入门套餐(免费,写作本书时限制每天最多只能发送 400 封邮件)就够了。我们可以使用下面的命令添加这个扩展: ``` $ heroku addons:create sendgrid:starter ``` 为了让应用使用 SendGrid 发送邮件,我们要配置生产环境的 [SMTP](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol) 设置,而且还要定义一个 `host` 变量,设置生产环境中网站的地址,如[代码清单 10.56](#listing-sendgrid-config) 所示。 ##### 代码清单 10.56:配置应用在生产环境中使用 SendGrid config/environments/production.rb ``` Rails.application.configure do . . . config.action_mailer.raise_delivery_errors = true config.action_mailer.delivery_method = :smtp host = '.herokuapp.com' config.action_mailer.default_url_options = { host: host } ActionMailer::Base.smtp_settings = { :address => 'smtp.sendgrid.net', :port => '587', :authentication => :plain, :user_name => ENV['SENDGRID_USERNAME'], :password => ENV['SENDGRID_PASSWORD'], :domain => 'heroku.com', :enable_starttls_auto => true } . . . end ``` [代码清单 10.56](#listing-sendgrid-config) 中设置了 SendGrid 账户的用户名(`user_name`)和密码(`password`),但是注意,这两个值是从 `ENV` 环境变量中获取的,而没有直接写入代码。这是生产环境应用的最佳实践,为了安全,绝不能在源码中写入敏感信息,例如原始密码。这两个值由 SendGrid 扩展自动设置,[11.4.4 节](chapter11.html#image-upload-in-production)会介绍如何自己定义。如果好奇,可以使用下面的命令查看这两个环境变量的值: ``` $ heroku config:get SENDGRID_USERNAME $ heroku config:get SENDGRID_PASSWORD ``` 现在,应该把主题分支合并到主分支中: ``` $ bundle exec rake test $ git add -A $ git commit -m "Add password resets & email configuration" $ git checkout master $ git merge account-activation-password-reset ``` 然后,推送到远程仓库,再部署到 Heroku: ``` $ bundle exec rake test $ git push $ git push heroku master $ heroku run rake db:migrate ``` 配置好 SendGrid 服务后,在生产环境的演示应用中使用你的电子邮件注册试试。你应该会收到一封激活邮件,如[图 10.20](#fig-activation-email-production) 所示。如果忘记密码(或者假装忘了),可以使用 [10.2 节](#password-reset)实现的功能重设密码,收到的重设邮件如[图 10.21](#fig-reset-email-production) 所示。 ![activation email production](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306c5d6a5.png)图 10.20:生产环境中的应用发送的账户激活邮件![reset email production](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306c840e0.png)图 10.21:生产环境中的应用发送的密码重设邮件
    ';

    10.2 密码重设

    最后更新于:2022-04-01 22:30:06

    # 10.2 密码重设 完成账户激活功能后(从而确认了用户的电子邮件地址可用),我们要处理一种常见的问题:用户忘记密码。我们会看到,密码重设的很多步骤和账户激活类似,所以这里会用到 [10.1 节](#account-activation)学到的知识。不过,开头不一样,和账户激活功能不同的是,密码重设要修改一个视图,还要创建两个表单(处理电子邮件地址提交和设定新密码)。 编写代码之前,我们先构思要实现的重设密码步骤。首先,我们要在演示应用的登录表单中添加“Forgot Password”(忘记密码)链接,如[图 10.7](#fig-login-forgot-password-mockup) 所示。 ![login forgot password mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306a5efc8.png)图 10.7:“Forgot Password”链接的构思图 点击“Forgot Password”链接后打开一个页面,这个页面中有一个表单,要求输入电子邮件地址,提交后向这个地址发送一封包含密码重设链接的邮件,如[图 10.8](#fig-forgot-password-form-mockup) 所示。 ![forgot password form mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306a74aac.png)图 10.8:“Forgot Password”表单的构思图 点击密码重设链接会打开一个表单,用户在这个表单中重设密码(还要填写密码确认),如[图 10.9](#fig-reset-password-form-mockup) 所示。 ![reset password form mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306a84c37.png)图 10.9:重设密码表单的构思图 和账户激活一样,我们要把“密码重设”看做一个资源,每个重设密码操作都有一个重设令牌和对应的摘要。主要的步骤如下: 1. 用户请求重设密码时,使用提交的电子邮件地址查找用户; 2. 如果数据库中有这个电子邮件地址,生成一个重设令牌和对应的摘要; 3. 把重设摘要保存在数据库中,然后给用户发送一封邮件,其中有一包含重设令牌和用户电子邮件地址的链接; 4. 用户点击这个链接后,使用电子邮件地址查找用户,然后对比令牌和摘要; 5. 如果匹配,显示重设密码的表单。 ## 10.2.1 资源 和账户激活一样([10.1.1 节](#account-activations-resource)),第一步要为资源生成控制器: ``` $ rails generate controller PasswordResets new edit --no-test-framework ``` 注意,我们指定了不生成测试的参数,因为我们不需要控制器测试(和 [10.1.4 节](#activation-test-and-refactoring)一样,要使用集成测试),所以最好不生成。 我们需要两个表单,一个请求重设密码([图 10.8](#fig-forgot-password-form-mockup)),一个修改用户模型中的密码([图 10.9](#fig-reset-password-form-mockup)),所以需要为 `new`、`create`、`edit` 和 `update` 四个动作制定路由——通过[代码清单 10.37](#listing-password-resets-resource) 中高亮显示的那行 `resources` 规则实现。 ##### 代码清单 10.37:添加“密码重设”资源的路由 config/routes.rb ``` Rails.application.routes.draw do root 'static_pages#home' get 'help' => 'static_pages#help' get 'about' => 'static_pages#about' get 'contact' => 'static_pages#contact' get 'signup' => 'users#new' get 'login' => 'sessions#new' post 'login' => 'sessions#create' delete 'logout' => 'sessions#destroy' resources :users resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] end ``` 添加这个规则后,得到了[表 10.2](#table-restful-password-resets) 中的 REST 路由。 表 10.2:定义“密码重设”资源后得到的 REST 路由 | HTTP 请求 | URL | 动作 | 具名路由 | | --- | --- | --- | --- | | `GET` | /password_resets/new | `new` | `new_password_reset_path` | | `POST` | /password_resets | `create` | `password_resets_path` | | `GET` | /password_resets/<token>/edit | `edit` | `edit_password_reset_path(token)` | | `PATCH` | /password_resets/<token> | `update` | `password_reset_path(token)` | 通过表中第一个路由可以得到指向“Forgot Password”表单的链接: ``` new_password_reset_path ``` 把这个链接添加到登录表单,如[代码清单 10.38](#listing-log-in-password-reset) 所示。添加后的效果如[图 10.10](#fig-forgot-password-link) 所示。 ##### 代码清单 10.38:添加打开忘记密码表单的链接 app/views/sessions/new.html.erb ``` <% provide(:title, "Log in") %>

    Log in

    <%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= link_to "(forgot password)", new_password_reset_path %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> Remember me on this computer <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %>

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

    ``` ![forgot password link](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306aa41d7.png)图 10.10:添加“Forgot Password”链接后的登录页面 密码重设所需的数据模型和账户激活的类似([图 10.1](#fig-user-model-account-activation))。参照“记住我”功能([8.4 节](chapter8.html#remember-me))和账户激活功能([10.1 节](#account-activation)),密码重设需要一个虚拟的重设令牌属性,在重设密码的邮件中使用,以及一个重设摘要属性,用来取回用户。 如果存储未哈希的令牌,能访问数据库的攻击者就能发送一封重设密码邮件给用户,然后使用令牌和邮件地址访问对应的密码重设链接,从而获得账户控制权。因此,必须存储令牌的摘要。为了进一步保障安全,我们还计划过几个小时后让重设链接失效,所以要记录重设邮件发送的时间。据此,我们要添加两个属性:`reset_digest` 和 `reset_sent_at`,如[图 10.11](#fig-user-model-password-reset) 所示。 ![user model password reset](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306abc0c6.png)图 10.11:添加密码重设相关属性后的用户模型 执行下面的命令,创建添加这两个属性的迁移: ``` $ rails generate migration add_reset_to_users reset_digest:string \ > reset_sent_at:datetime ``` 然后像之前一样执行迁移: ``` $ bundle exec rake db:migrate ``` ## 10.2.2 控制器和表单 我们要参照前面为没有模型的资源编写表单的方法,即创建新会话的登录表单([代码清单 8.2](chapter8.html#listing-login-form)),编写请求重设密码的表单。为了便于参考,我们再把这个表单列出来,如[代码清单 10.39](#listing-login-form-redux) 所示。 ##### 代码清单 10.39:登录表单的代码 app/views/sessions/new.html.erb ``` <% provide(:title, "Log in") %>

    Log in

    <%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> Remember me on this computer <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %>

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

    ``` 请求重设密码的表单和[代码清单 10.39](#listing-login-form-redux) 有很多共通之处,最大的区别是,`form_for` 中的资源和地址不一样,而且也没有密码字段。请求重设密码的表单如[代码清单 10.40](#listing-new-password-reset) 所示,渲染的结果如[图 10.12](#fig-forgot-password-form) 所示。 ##### 代码清单 10.40:请求重设密码页面的视图 app/views/password_resets/new.html.erb ``` <% provide(:title, "Forgot password") %>

    Forgot password

    <%= form_for(:password_reset, url: password_resets_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.submit "Submit", class: "btn btn-primary" %> <% end %>
    ``` ![forgot password form](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306ad1c8f.png)图 10.12:“Forgot Password”表单 提交[图 10.12](#fig-forgot-password-form) 中的表单后,我们要通过电子邮件地址查找用户,更新这个用户的 `reset_token`、`reset_digest` 和 `reset_sent_at` 属性,然后重定向到根地址,并显示一个闪现消息。和登录一样([代码清单 8.9](chapter8.html#listing-correct-login-failure)),如果提交的数据无效,我们要重新渲染这个页面,并且显示一个 `flash.now` 消息。据此,写出的 `create` 动作如[代码清单 10.41](#listing-create-password-reset) 所示。 ##### 代码清单 10.41:`PasswordResetsController` 的 `create` 动作 app/controllers/password_resets_controller.rb ``` class PasswordResetsController < ApplicationController def new end def create @user = User.find_by(email: params[:password_reset][:email].downcase) if @user @user.create_reset_digest @user.send_password_reset_email flash[:info] = "Email sent with password reset instructions" redirect_to root_url else flash.now[:danger] = "Email address not found" render 'new' end end def edit end end ``` 然后要在用户模型中定义 `create_reset_digest` 方法,如[代码清单 10.42](#listing-user-model-password-reset) 所示。 ##### 代码清单 10.42:在用户模型中添加重设密码所需的方法 app/models/user.rb ``` class User < ActiveRecord::Base attr_accessor :remember_token, :activation_token, :reset_token before_save :downcase_email before_create :create_activation_digest . . . # 激活账户 def activate update_attribute(:activated, true) update_attribute(:activated_at, Time.zone.now) end # 发送激活邮件 def send_activation_email UserMailer.account_activation(self).deliver_now end # 设置密码重设相关的属性 def create_reset_digest self.reset_token = User.new_token update_attribute(:reset_digest, User.digest(reset_token)) update_attribute(:reset_sent_at, Time.zone.now) end # 发送密码重设邮件 def send_password_reset_email UserMailer.password_reset(self).deliver_now end private # 把电子邮件地址转换成小写 def downcase_email self.email = email.downcase end # 创建并赋值激活令牌和摘要 def create_activation_digest self.activation_token = User.new_token self.activation_digest = User.digest(activation_token) end end ``` 如[图 10.13](#fig-invalid-email-password-reset) 所示,提交无效电子邮件地址时,应用的表现正常。为了让提交有效地址时应用也能正常运行,我们要定义发送密码重设邮件的方法,这一步会在 [10.2.3 节](#password-reset-mailer-method)完成。 ![invalid email password reset](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b0bd8a.png)图 10.13:提交无效电子邮件地址后显示的“Forgot Password”表单 ## 10.2.3 邮件程序 [代码清单 10.42](#listing-user-model-password-reset) 中发送密码重设邮件的代码是: ``` UserMailer.password_reset(self).deliver_now ``` 让这个邮件程序运作起来所需的代码几乎和 [10.1.2 节](#account-activation-mailer-method)的账户激活邮件程序一样。我们首先在 `UserMailer` 中定义 `password_reset` 方法([代码清单 10.43](#listing-mail-password-reset)),然后再编写邮件的纯文本视图([代码清单 10.44](#listing-password-reset-text))和 HTML 视图([代码清单 10.45](#listing-password-reset-html))。 ##### 代码清单 10.43:发送密码重设链接 app/mailers/user_mailer.rb ``` class UserMailer < ApplicationMailer default from: "noreply@example.com" def account_activation(user) @user = user mail to: user.email, subject: "Account activation" end def password_reset(user) @user = user mail to: user.email, subject: "Password reset" end end ``` ##### 代码清单 10.44:密码重设邮件的纯文本视图 app/views/user_mailer/password_reset.text.erb ``` To reset your password click the link below: <%= edit_password_reset_url(@user.reset_token, email: @user.email) %> This link will expire in two hours. If you did not request your password to be reset, please ignore this email and your password will stay as it is. ``` ##### 代码清单 10.45:密码重设邮件的 HTML 视图 app/views/user_mailer/password_reset.html.erb ```

    Password reset

    To reset your password click the link below:

    <%= link_to "Reset password", edit_password_reset_url(@user.reset_token, email: @user.email) %>

    This link will expire in two hours.

    If you did not request your password to be reset, please ignore this email and your password will stay as it is.

    ``` 和账户激活邮件一样([10.1.2 节](#account-activation-mailer-method)),我们可以使用 Rails 提供的邮件预览程序预览密码重设邮件。参照[代码清单 10.16](#listing-account-activation-preview),密码重设的邮件预览程序如[代码清单 10.46](#listing-password-reset-preview) 所示。 ##### 代码清单 10.46:预览密码重设邮件所需的方法 test/mailers/previews/user_mailer_preview.rb ``` # Preview all emails at http://localhost:3000/rails/mailers/user_mailer class UserMailerPreview < ActionMailer::Preview # Preview this email at # http://localhost:3000/rails/mailers/user_mailer/account_activation def account_activation user = User.first user.activation_token = User.new_token UserMailer.account_activation(user) end # Preview this email at # http://localhost:3000/rails/mailers/user_mailer/password_reset def password_reset user = User.first user.reset_token = User.new_token UserMailer.password_reset(user) end end ``` 然后就可以预览密码重设邮件了,HTML 格式和纯文本格式分别如[图 10.14](#fig-password-reset-html-preview) 和[图 10.15](#fig-password-reset-text-preview) 所示。 ![password reset html preview](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b4195c.png)图 10.14:预览 HTML 格式的密码重设邮件![password reset text preview](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b7b091.png)图 10.15:预览纯文本格式的密码重设邮件 参照账户激活邮件程序的测试([代码清单 10.18](#listing-real-account-activation-test)),密码重设邮件程序的测试如[代码清单 10.47](#listing-password-reset-mailer-test) 所示。注意,我们要创建密码重设令牌,以便在视图中使用。这一点和激活令牌不一样,激活令牌使用 `before_create` 回调创建([代码清单 10.3](#listing-user-model-activation-code)),但是密码重设令牌只会在用户成功提交“Forgot Password”表单后创建。在集成测试中很容易创建密码重设令牌(参见[代码清单 10.54](#listing-password-reset-integration-test)),但在邮件程序的测试中必须手动创建。 ##### 代码清单 10.47:添加密码重设邮件程序的测试 GREEN test/mailers/user_mailer_test.rb ``` require 'test_helper' class UserMailerTest < ActionMailer::TestCase test "account_activation" do user = users(:michael) user.activation_token = User.new_token mail = UserMailer.account_activation(user) assert_equal "Account activation", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.name, mail.body.encoded assert_match user.activation_token, mail.body.encoded assert_match CGI::escape(user.email), mail.body.encoded end test "password_reset" do user = users(:michael) user.reset_token = User.new_token mail = UserMailer.password_reset(user) assert_equal "Password reset", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.reset_token, mail.body.encoded assert_match CGI::escape(user.email), mail.body.encoded end end ``` 现在,测试组件应该能通过: ##### 代码清单 10.48:**GREEN** ``` $ bundle exec rake test ``` 有了[代码清单 10.43](#listing-mail-password-reset)、[代码清单 10.44](#listing-password-reset-text) 和[代码清单 10.45](#listing-password-reset-html) 之后,提交有效电子邮件地址后显示的页面如[图 10.16](#fig-valid-email-password-reset) 所示。服务器日志中记录的邮件类似于[代码清单 10.49](#listing-password-reset-email)。 ![valid email password reset](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306b96cce.png)图 10.16:提交有效电子邮件地址后显示的页面 ##### 代码清单 10.49:服务器日志中记录的一封密码重设邮件 ``` Sent mail to michael@michaelhartl.com (66.8ms) Date: Thu, 04 Sep 2014 01:04:59 +0000 From: noreply@example.com To: michael@michaelhartl.com Message-ID: <5407babbee139_8722b257d04576a@mhartl-rails-tutorial-953753.mail> Subject: Password reset Mime-Version: 1.0 Content-Type: multipart/alternative; boundary="--==_mimepart_5407babbe3505_8722b257d045617"; charset=UTF-8 Content-Transfer-Encoding: 7bit ----==_mimepart_5407babbe3505_8722b257d045617 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit To reset your password click the link below: http://rails-tutorial-c9-mhartl.c9.io/password_resets/3BdBrXeQZSWqFIDRN8cxHA/ edit?email=michael%40michaelhartl.com This link will expire in two hours. If you did not request your password to be reset, please ignore this email and your password will stay as it is. ----==_mimepart_5407babbe3505_8722b257d045617 Content-Type: text/html; charset=UTF-8 Content-Transfer-Encoding: 7bit

    Password reset

    To reset your password click the link below:

    Reset password

    This link will expire in two hours.

    If you did not request your password to be reset, please ignore this email and your password will stay as it is.

    ----==_mimepart_5407babbe3505_8722b257d045617-- ``` ## 10.2.4 重设密码 为了让下面这种形式的链接生效,我们要编写一个表单,重设密码。 ``` http://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=foo%40bar.com ``` 这个表单的目的和编辑用户资料的表单([代码清单 9.2](chapter9.html#listing-user-edit-view))类似,不过现在只需更新密码和密码确认字段。而且处理起来有点复杂,因为我们希望通过电子邮件地址查找用户,也就是说,在 `edit` 动作和 `update` 动作中都需要使用邮件地址。在 `edit` 动作中可以轻易的获取邮件地址,因为链接中有。可是提交表单后,邮件地址就没有了。为了解决这个问题,我们可以使用一个“隐藏字段”,把这个字段的值设为邮件地址(不会显示),和表单中的其他数据一起提交给 `update` 动作,如[代码清单 10.50](#listing-password-reset-form) 所示。 ##### 代码清单 10.50:重设密码的表单 app/views/password_resets/edit.html.erb ``` <% provide(:title, 'Reset password') %>

    Reset password

    <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %> <%= render 'shared/error_messages' %> <%= hidden_field_tag :email, @user.email %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation, "Confirmation" %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit "Update password", class: "btn btn-primary" %> <% end %>
    ``` 注意,在[代码清单 10.50](#listing-password-reset-form) 中,使用的表单字段辅助方法是 ``` hidden_field_tag :email, @user.email ``` 而不是 ``` f.hidden_field :email, @user.email ``` 因为在重设密码的链接中,邮件地址在 `params[:email]` 中,如果使用后者,就会把邮件地址放入 `params[:user][:email]` 中。 为了正确渲染这个表单,我们要在 `PasswordResetsController` 的 `edit` 控制器中定义 `@user` 变量。和账户激活一样([代码清单 10.29](#listing-account-activation-edit-action)),我们要找到 `params[:email]` 中电子邮件地址对应的用户,确认这个用户已经激活,然后使用[代码清单 10.24](#listing-generalized-authenticated-p) 中的 `authenticated?` 方法认证 `params[:id]` 中的令牌。因为在 `edit` 和 `update` 动作中都要使用 `@user`,所以我们要把查找用户和认证令牌的代码写入一个事前过滤器中,如[代码清单 10.51](#listing-password-reset-edit-action) 所示。 ##### 代码清单 10.51:重设密码的 `edit` 动作 app/controllers/password_resets_controller.rb ``` class PasswordResetsController < ApplicationController before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] . . . def edit end private def get_user @user = User.find_by(email: params[:email]) end # 确保是有效用户 def valid_user unless (@user && @user.activated? && @user.authenticated?(:reset, params[:id])) redirect_to root_url end end end ``` [代码清单 10.51](#listing-password-reset-edit-action) 中的 `authenticated?(:reset, params[:id])`,[代码清单 10.26](#listing-generalized-current-user) 中的 `authenticated?(:remember, cookies[:remember_token])`,以及[代码清单 10.29](#listing-account-activation-edit-action) 中的 `authenticated?(:activation, params[:id])`,就是[表 10.1](#table-password-token-digest) 中 `authenticated?` 方法的三个用例。 现在,点击[代码清单 10.49](#listing-password-reset-email) 中的链接后,会显示密码重设表单,如[图 10.17](#fig-password-reset-form) 所示。 ![password reset form](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306bb4777.png)图 10.17:密码重设表单 `edit` 动作对应的 `update` 动作要考虑四种情况:密码重设超时失效,重设成功,密码无效导致的重设失败,密码和密码确认为空值时导致的密码重设失败(此时看起来像是成功了)。前三种情况对应[代码清单 10.52](#listing-password-reset-update-action) 中外层 `if` 语句的三个分支。因为这个表单会修改 Active Record 模型(用户模型),所以我们可以使用共用的局部视图渲染错误消息。密码为空值的情况比较特殊,因为用户模型的验证允许出现这种情况(参见[代码清单 9.10](chapter9.html#listing-allow-blank-password)),所以要特别处理,直接在 `@user` 对象的错误消息中添加一个错误:[[8](#fn-8)] ``` @user.errors.add(:password, "can't be empty") ``` ##### 代码清单 10.52:重设密码的 `update` 动作 app/controllers/password_resets_controller.rb ``` class PasswordResetsController < ApplicationController before_action :get_user, only: [:edit, :update] before_action :valid_user, only: [:edit, :update] before_action :check_expiration, only: [:edit, :update] def new end def create @user = User.find_by(email: params[:password_reset][:email].downcase) if @user @user.create_reset_digest @user.send_password_reset_email flash[:info] = "Email sent with password reset instructions" redirect_to root_url else flash.now[:danger] = "Email address not found" render 'new' end end def edit end def update if params[:user][:password].empty? @user.errors.add(:password, "can't be empty") render 'edit' elsif @user.update_attributes(user_params) log_in @user flash[:success] = "Password has been reset." redirect_to @user else render 'edit' end end private def user_params params.require(:user).permit(:password, :password_confirmation) end # 事前过滤器 def get_user @user = User.find_by(email: params[:email]) end # 确保是有效用户 def valid_user unless (@user && @user.activated? && @user.authenticated?(:reset, params[:id])) redirect_to root_url end end # 检查重设令牌是否过期 def check_expiration if @user.password_reset_expired? flash[:danger] = "Password reset has expired." redirect_to new_password_reset_url end end end ``` 我们把密码重设是否超时失效交给用户模型判断: ``` @user.password_reset_expired? ``` 所以,我们要定义 `password_reset_expired?` 方法。如 [10.2.3 节](#password-reset-mailer-method)的邮件模板所示,如果邮件发出后两个小时内没重设密码,就认为此次请求超时失效了。这个设想可以通过下面的 Ruby 代码实现: ``` reset_sent_at < 2.hours.ago ``` 如果你把 `<` 当成小于号,读成“密码重设邮件发出少于两小时”就错了,和想表达的意思正好相反。 这里,最好把 `<` 理解成“超过”,读成“密码重设邮件已经发出超过两小时”,这才是我们想表达的意思。`password_reset_expired?` 方法的定义如[代码清单 10.53](#listing-user-model-password-reset-expired) 所示。(对这个比较算式的证明参见 [10.6 节](#proof-of-expiration-comparison)。) ##### 代码清单 10.53:在用户模型中定义 `password_reset_expired?` 方法 app/models/user.rb ``` class User < ActiveRecord::Base . . . # 如果密码重设超时失效了,返回 true def password_reset_expired? reset_sent_at < 2.hours.ago end private . . . end ``` 现在,[代码清单 10.52](#listing-password-reset-update-action) 中的 `update` 动作可以使用了。密码重设失败和成功后显示的页面分别如[图 10.18](#fig-password-reset-failure) 和[图 10.19](#fig-password-reset-success) 所示。(稍等一会,[10.5 节](#account-activation-and-password-reset-exercises)中有一题,为第三个分支编写测试。) ![password reset failure](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306bd376d.png)图 10.18:密码重设失败![password reset success](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306bebaae.png)图 10.19:密码重设成功 ## 10.2.5 测试 本节,我们要编写一个集成测试,覆盖[代码清单 10.52](#listing-password-reset-update-action) 中的两个分支:重设失败和重设成功。(前面说过,第三个分支的测试留作练习。)首先,为重设密码生成一个测试文件: ``` $ rails generate integration_test password_resets invoke test_unit create test/integration/password_resets_test.rb ``` 这个测试的步骤大致和[代码清单 10.31](#listing-signup-with-account-activation-test) 中的账户激活测试差不多,不过开头有点不同。首先访问“Forgot Password”表单,分别提交有效和无效的电子邮件地址,电子邮件地址有效时要创建密码重设令牌,并且发送重设邮件。然后,访问邮件中的链接,分别提交无效和有效的密码,验证各自的表现是否正确。最终写出的测试如[代码清单 10.54](#listing-password-reset-integration-test) 所示。这是一个不错的练习,可以锻炼阅读代码的能力。 ##### 代码清单 10.54:密码重设的集成测试 test/integration/password_resets_test.rb ``` require 'test_helper' class PasswordResetsTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear @user = users(:michael) end test "password resets" do get new_password_reset_path assert_template 'password_resets/new' # 电子邮件地址无效 post password_resets_path, password_reset: { email: "" } assert_not flash.empty? assert_template 'password_resets/new' # 电子邮件地址有效 post password_resets_path, password_reset: { email: @user.email } assert_not_equal @user.reset_digest, @user.reload.reset_digest assert_equal 1, ActionMailer::Base.deliveries.size assert_not flash.empty? assert_redirected_to root_url # 密码重设表单 user = assigns(:user) # 电子邮件地址错误 get edit_password_reset_path(user.reset_token, email: "") assert_redirected_to root_url # 用户未激活 user.toggle!(:activated) get edit_password_reset_path(user.reset_token, email: user.email) assert_redirected_to root_url user.toggle!(:activated) # 电子邮件地址正确,令牌不对 get edit_password_reset_path('wrong token', email: user.email) assert_redirected_to root_url # 电子邮件地址正确,令牌也对 get edit_password_reset_path(user.reset_token, email: user.email) assert_template 'password_resets/edit' assert_select "input[name=email][type=hidden][value=?]", user.email # 密码和密码确认不匹配 patch password_reset_path(user.reset_token), email: user.email, user: { password: "foobaz", password_confirmation: "barquux" } assert_select 'div#error_explanation' # 密码为空值 patch password_reset_path(user.reset_token), email: user.email, user: { password: "", password_confirmation: "" } assert_select 'div#error_explanation' # 密码和密码确认有效 patch password_reset_path(user.reset_token), email: user.email, user: { password: "foobaz", password_confirmation: "foobaz" } assert is_logged_in? assert_not flash.empty? assert_redirected_to user end end ``` [代码清单 10.54](#listing-password-reset-integration-test) 中的大多数用法前面都见过,但是针对 `input` 标签的测试有点陌生: ``` assert_select "input[name=email][type=hidden][value=?]", user.email ``` 这行代码的意思是,页面中有 `name` 属性、类型(隐藏)和电子邮件地址都正确的 `input` 标签: ``` ``` 现在,测试组件应该能通过: ##### 代码清单 10.55:**GREEN** ``` $ bundle exec rake test ```
    ';

    10.1 账户激活

    最后更新于:2022-04-01 22:30:04

    # 10.1 账户激活 目前,用户注册后立即就能完全控制自己的账户([第 7 章](chapter7.html#sign-up))。本节,我们要添加一步,激活用户的账户,从而确认用户拥有注册时使用的电子邮件地址。为此,我们要为用户创建激活令牌和摘要,然后给用户发送一封电子邮件,提供包含令牌的链接。用户点击这个链接后,激活这个账户。 我们要采取的实现步骤与注册用户([8.2 节](chapter8.html#logging-in))和记住用户([8.4 节](chapter8.html#remember-me))差不多,如下所示: 1. 用户一开始处于“未激活”状态; 2. 用户注册后,生成一个激活令牌和对应的激活摘要; 3. 把激活摘要存储在数据库中,然后给用户发送一封电子邮件,提供一个包含激活令牌和用户电子邮件地址的链接;[[2](#fn-2)] 4. 用户点击这个链接后,使用电子邮件地址查找用户,并且对比令牌和摘要; 5. 如果令牌和摘要匹配,就把状态由“未激活”改为“已激活”。 因为与密码和记忆令牌类似,实现账户激活(以及密码重设)功能时可以继续使用前面的很多方法,包括 `User.digest`、`User.new_token` 和修改过的 `user.authenticated?`。这几个功能(包括 [10.2 节](#password-reset)要实现的密码重设)之间的对比,如[表 10.1](#table-password-token-digest) 所示。我们会在 [10.1.3 节](#activating-the-account)定义可用于表中所有情况的通用版 `authenticated?` 方法。 表 10.1:登录,记住状态,账户激活和密码重设之间的对比 | 查找方式 | 字符串 | 摘要 | 认证 | | --- | --- | --- | --- | | `email` | `password` | `password_digest` | `authenticate(password)` | | `id` | `remember_token` | `remember_digest` | `authenticated?(:remember, token)` | | `email` | `activation_token` | `activation_digest` | `authenticated?(:activation, token)` | | `email` | `reset_token` | `reset_digest` | `authenticated?(:reset, token)` | 和之前一样,我们要在主题分支中开发新功能。读到 [10.3 节](#email-in-production)会发现,账户激活和密码重设需要共用一些电子邮件设置,合并到 `master` 分支之前,要把这些设置应用到这两个功能上,所以在一个分支中开发这两个功能比较方便: ``` $ git checkout master $ git checkout -b account-activation-password-resets ``` ## 10.1.1 资源 和会话一样([8.1 节](chapter8.html#sessions)),我们要把“账户激活”看做一个资源,不过这个资源不对应模型,相关的数据(激活令牌和激活状态)存储在用户模型中。然而,我们要通过标准的 REST URL 处理账户激活操作。激活链接会改变用户的激活状态,所以我们计划在 `edit` 动作中处理。[[3](#fn-3)]所需的控制器使用下面的命令生成:[[4](#fn-4)] ``` $ rails generate controller AccountActivations --no-test-framework ``` 我们需要使用下面的方法生成一个 URL,放在激活邮件中: ``` edit_account_activation_url(activation_token, ...) ``` 因此,我们需要为 `edit` 动作设定一个具名路由——通过[代码清单 10.1](#listing-account-activations-route) 中高亮显示的那行 `resources` 实现。 ##### 代码清单 10.1:添加账户激活所需的资源路由 config/routes.rb ``` Rails.application.routes.draw do root 'static_pages#home' get 'help' => 'static_pages#help' get 'about' => 'static_pages#about' get 'contact' => 'static_pages#contact' get 'signup' => 'users#new' get 'login' => 'sessions#new' post 'login' => 'sessions#create' delete 'logout' => 'sessions#destroy' resources :users resources :account_activations, only: [:edit] end ``` 接下来,我们需要一个唯一的激活令牌,用来激活用户。密码、记忆令牌和密码重设([10.2 节](#password-reset))需要考虑很多安全隐患,因为如果攻击者获取了这些信息就能完全控制账户。账户激活则不需要这么麻烦,但如果不哈希激活令牌,账户也有一定危险。[[5](#fn-5)]所以,参照记住登录状态的做法([8.4 节](chapter8.html#remember-me)),我们会公开令牌,而在数据库中存储哈希摘要。这么做,我们可以使用下面的方式获取激活令牌: ``` user.activation_token ``` 使用下面的代码认证用户: ``` user.authenticated?(:activation, token) ``` (不过得先修改[代码清单 8.33](chapter8.html#listing-authenticated-p) 中定义的 `authenticated?` 方法。)我们还要定义一个布尔值属性 `activated`,使用自动生成的布尔值方法检查用户的激活状态(类似 [9.4.1 节](chapter9.html#administrative-users)使用的方法): ``` if user.activated? ... ``` 最后,我们还要记录激活的日期和时间,虽然本书用不到,但说不定以后需要使用。完整的数据模型如[图 10.1](#fig-user-model-account-activation) 所示。 ![user model account activation](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333066c61a2.png)图 10.1:添加账户激活相关属性后的用户模型 下面的命令生成一个迁移,添加这些属性。我们在命令行中指定了要添加的三个属性: ``` $ rails generate migration add_activation_to_users \ > activation_digest:string activated:boolean activated_at:datetime ``` 和 `admin` 属性一样([代码清单 9.50](chapter9.html#listing-admin-migration)),我们要把 `activated` 属性的默认值设为 `false`,如[代码清单 10.2](#listing-add-activation-to-users-migration) 所示。 ##### 代码清单 10.2:添加账户激活所需属性的迁移 db/migrate/[timestamp]_add_activation_to_users.rb ``` class AddActivationToUsers < ActiveRecord::Migration def change add_column :users, :activation_digest, :string add_column :users, :activated, :boolean, default: false add_column :users, :activated_at, :datetime end end ``` 然后像之前一样,执行迁移: ``` $ bundle exec rake db:migrate ``` 因为每个新注册的用户都得激活,所以我们应该在创建用户对象之前为用户分配激活令牌和摘要。类似的操作在 [6.2.5 节](chapter6.html#uniqueness-validation)见过,那时我们要在用户存入数据库之前把电子邮件地址转换成小写形式。我们使用的是 `before_save` 回调和 `downcase` 方法([代码清单 6.31](chapter6.html#listing-email-downcase))。`before_save` 回调在保存对象之前,包括创建对象和更新对象,自动调用。不过现在我们只想在创建用户之前调用回调,创建激活摘要。为此,我们要使用 `before_create` 回调,按照下面的方式定义: ``` before_create :create_activation_digest ``` 这种写法叫“方法引用”,Rails 会寻找一个名为 `create_activation_digest` 的方法,在创建用户之前调用。(在[代码清单 6.31](chapter6.html#listing-email-downcase) 中,我们直接把一个块传给 `before_save`。不过方法引用是推荐的做法。)`create_activation_digest` 方法只会在用户模型内使用,没必要公开。如 [7.3.2 节](chapter7.html#strong-parameters)所示,在 Ruby 中可以使用 `private` 实现这个需求: ``` private def create_activation_digest # 创建令牌和摘要 end ``` 在一个类中,`private` 之后的方法都会自动“隐藏”。我们可以在控制器会话中验证这一点: ``` $ rails console >> User.first.create_activation_digest NoMethodError: private method `create_activation_digest' called for # ``` 这个 `before_create` 回调的作用是为用户分配令牌和对应的摘要,实现的方法如下所示: ``` self.activation_token = User.new_token self.activation_digest = User.digest(activation_token) ``` 这里用到了实现“记住我”功能时用来生成令牌和摘要的方法。我们可以把这两行代码和[代码清单 8.32](chapter8.html#listing-user-model-remember) 中的 `remember` 方法比较一下: ``` # 为了持久会话,在数据库中记住用户 def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end ``` 二者之间的主要区别是,`remember` 方法中使用的是 `update_attribute`。因为,创建记忆令牌和摘要时,用户已经存在于数据库中了,而 `before_create` 回调在创建用户之前执行。有了这个回调,使用 `User.new` 新建用户后(例如用户注册后,参见[代码清单 7.17](chapter7.html#listing-create-action-strong-parameters)),会自动赋值 `activation_token` 和 `activation_digest` 属性,而且因为 `activation_digest` 对应数据库中的一个列([图 10.1](#fig-user-model-account-activation)),所以保存用户时会自动把属性的值存入数据库。 综上所述,用户模型如[代码清单 10.3](#listing-user-model-activation-code) 所示。因为激活令牌是虚拟属性,所以我们又添加了一个 `attr_accessor`。注意,我们还把电子邮件地址转换成小写的回调改成了方法引用形式。 ##### 代码清单 10.3:在用户模型中添加账户激活相关的代码 GREEN app/models/user.rb ``` class User < ActiveRecord::Base attr_accessor :remember_token, :activation_token before_save :downcase_email before_create :create_activation_digest validates :name, presence: true, length: { maximum: 50 } . . . private # 把电子邮件地址转换成小写 def downcase_email self.email = email.downcase end # 创建并赋值激活令牌和摘要 def create_activation_digest self.activation_token = User.new_token self.activation_digest = User.digest(activation_token) end end ``` 在继续之前,我们还要修改种子数据,把示例用户和测试用户设为已激活,如[代码清单 10.4](#listing-seed-users-activated) 和[代码清单 10.5](#listing-fixture-users-activated) 所示。(`Time.zone.now` 是 Rails 提供的辅助方法,基于服务器使用的时区,返回当前时间戳。) ##### 代码清单 10.4:激活种子数据中的用户 db/seeds.rb ``` User.create!(name: "Example User", email: "example@railstutorial.org", password: "foobar", password_confirmation: "foobar", admin: true, activated: true, activated_at: Time.zone.now) 99.times do |n| name = Faker::Name.name email = "example-#{n+1}@railstutorial.org" password = "password" User.create!(name: name, email: email, password: password, password_confirmation: password, activated: true, activated_at: Time.zone.now) end ``` ##### 代码清单 10.5:激活固件中的用户 test/fixtures/users.yml ``` michael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %> admin: true activated: true activated_at: <%= Time.zone.now %> archer: name: Sterling Archer email: duchess@example.gov password_digest: <%= User.digest('password') %> activated: true activated_at: <%= Time.zone.now %> lana: name: Lana Kane email: hands@example.gov password_digest: <%= User.digest('password') %> activated: true activated_at: <%= Time.zone.now %> malory: name: Malory Archer email: boss@example.gov password_digest: <%= User.digest('password') %> activated: true activated_at: <%= Time.zone.now %> <% 30.times do |n| %> user_<%= n %>: name: <%= "User #{n}" %> email: <%= "user-#{n}@example.com" %> password_digest: <%= User.digest('password') %> activated: true activated_at: <%= Time.zone.now %> <% end %> ``` 为了应用[代码清单 10.4](#listing-seed-users-activated) 中的改动,我们要还原数据库,然后像之前一样写入数据: ``` $ bundle exec rake db:migrate:reset $ bundle exec rake db:seed ``` ## 10.1.2 邮件程序 写好模型后,我们要编写发送账户激活邮件的代码了。我们要使用 Action Mailer 库创建一个邮件程序,在用户控制器的 `create` 动作中发送一封包含激活链接的邮件。邮件程序的结构和控制器动作差不多,邮件模板使用视图定义。这一节的任务是创建邮件程序,以及编写视图,写入激活账户所需的激活令牌和电子邮件地址。 与模型和控制器一样,我们可以使用 `rails generate` 生成邮件程序: ``` $ rails generate mailer UserMailer account_activation password_reset ``` 我们使用这个命令生成了所需的 `account_activation` 方法,以及 [10.2 节](#password-reset)要使用的 `password_reset` 方法。 生成邮件程序时,Rails 还为每个邮件程序生成了两个视图模板,一个用于纯文本邮件,一个用于 HTML 邮件。账户激活邮件程序的两个视图如[代码清单 10.6](#listing-generated-account-activation-view-text) 和[代码清单 10.7](#listing-generated-account-activation-view-html) 所示。 ##### 代码清单 10.6:生成的账户激活邮件视图,纯文本格式 app/views/user_mailer/account_activation.text.erb ``` UserMailer#account_activation <%= @greeting %>, find me in app/views/user_mailer/account_activation.text.erb ``` ##### 代码清单 10.7:生成的账户激活邮件视图,HTML 格式 app/views/user_mailer/account_activation.html.erb ```

    UserMailer#account_activation

    <%= @greeting %>, find me in app/views/user_mailer/account_activation.html.erb

    ``` 我们看一下生成的邮件程序,了解它是如何工作的,如[代码清单 10.8](#listing-generated-application-mailer) 和[代码清单 10.9](#listing-generated-user-mailer)所示。代码[代码清单 10.8](#listing-generated-application-mailer) 设置了一个默认的发件人地址(`from`),整个应用中的全部邮件程序都会使用这个地址。(这个代码清单还设置了各种邮件格式使用的布局。本书不会讨论邮件的布局,生成的 HTML 和纯文本格式邮件布局在 `app/views/layouts` 文件夹中。)[代码清单 10.9](#listing-generated-user-mailer) 中的每个方法中都设置了收件人地址。在生成的代码中还有一个实例变量 `@greeting`,这个变量可在邮件程序的视图中使用,就像控制器中的实例变量可以在普通的视图中使用一样。 ##### 代码清单 10.8:生成的 `ApplicationMailer` app/mailers/application_mailer.rb ``` class ApplicationMailer < ActionMailer::Base default from: "from@example.com" layout 'mailer' end ``` ##### 代码清单 10.9:生成的 `UserMailer` app/mailers/user_mailer.rb ``` class UserMailer < ActionMailer::Base # Subject can be set in your I18n file at config/locales/en.yml # with the following lookup: # # en.user_mailer.account_activation.subject # def account_activation @greeting = "Hi" mail to: "to@example.org" end # Subject can be set in your I18n file at config/locales/en.yml # with the following lookup: # # en.user_mailer.password_reset.subject # def password_reset @greeting = "Hi" mail to: "to@example.org" end end ``` 为了发送激活邮件,我们首先要修改生成的模板,如[代码清单 10.10](#listing-application-mailer) 所示。然后要创建一个实例变量,其值是用户对象,以便在视图中使用,然后把邮件发给 `user.email`。如[代码清单 10.11](#listing-mail-account-activation) 所示,`mail` 方法还可以接受 `subject` 参数,指定邮件的主题。 ##### 代码清单 10.10:在 `ApplicationMailer` 中设定默认的发件人地址 ``` class ApplicationMailer < ActionMailer::Base default from: "noreply@example.com" layout 'mailer' end ``` ##### 代码清单 10.11:发送账户激活链接 app/mailers/user_mailer.rb ``` class UserMailer < ApplicationMailer def account_activation(user) @user = user mail to: user.email, subject: "Account activation" end def password_reset @greeting = "Hi" mail to: "to@example.org" end end ``` 和普通的视图一样,在邮件程序的视图中也可以使用嵌入式 Ruby。在邮件中我们要添加一个针对用户的欢迎消息,以及一个激活链接。我们计划使用电子邮件地址查找用户,然后使用激活令牌认证用户,所以链接中要包含电子邮件地址和令牌。因为我们把“账户激活”视作一个资源,所以可以把令牌作为参数传给[代码清单 10.1](#listing-account-activations-route) 中定义的具名路由: ``` edit_account_activation_url(@user.activation_token, ...) ``` 我们知道,`edit_user_url(user)` 生成的地址是下面这种形式: ``` http://www.example.com/users/1/edit ``` 那么,账户激活的链接应该是这种形式: ``` http://www.example.com/account_activations/q5lt38hQDc_959PVoo6b7A/edit ``` 其中,`q5lt38hQDc_959PVoo6b7A` 是使用 `new_token` 方法([代码清单 8.31](chapter8.html#listing-token-method))生成的 base64 字符串,可安全地在 URL 中使用。这个值的作用和 /users/1/edit 中的用户 ID 一样,在 `AccountActivationsController` 的 `edit` 动作中可以通过 `params[:id]` 获取。 为了包含电子邮件地址,我们要使用“查询参数”(query parameter)。查询参数放在 URL 中的问号后面,使用键值对形式指定:[[6](#fn-6)] ``` account_activations/q5lt38hQDc_959PVoo6b7A/edit?email=foo%40example.com ``` 注意,电子邮件地址中的“@”被替换成了 `%40`,也就是被转义了,这样,URL 才是有效的。在 Rails 中设定查询参数的方法是,把一个哈希传给具名路由: ``` edit_account_activation_url(@user.activation_token, email: @user.email) ``` 使用这种方式设定查询参数,Rails 会自动转义所有特殊字符。而且,在控制器中会自动反转义电子邮件地址,通过 `params[:email]` 可以获取电子邮件地址。 定义好实例变量 `@user` 之后([代码清单 10.11](#listing-mail-account-activation)),我们可以使用 `edit` 动作的具名路由和嵌入式 Ruby 创建所需的链接了,如[代码清单 10.12](#listing-account-activation-view-text) 和[代码清单 10.13](#listing-account-activation-view-html) 所示。注意,在[代码清单 10.13](#listing-account-activation-view-html) 中,我们使用 `link_to` 方法创建有效的链接。 ##### 代码清单 10.12:账户激活邮件的纯文本视图 app/views/user_mailer/account_activation.text.erb ``` Hi <%= @user.name %>, Welcome to the Sample App! Click on the link below to activate your account: <%= edit_account_activation_url(@user.activation_token, email: @user.email) %> ``` ##### 代码清单 10.13:账户激活邮件的 HTML 视图 app/views/user_mailer/account_activation.html.erb ```

    Sample App

    Hi <%= @user.name %>,

    Welcome to the Sample App! Click on the link below to activate your account:

    <%= link_to "Activate", edit_account_activation_url(@user.activation_token, email: @user.email) %> ``` 若想查看这两个邮件视图的效果,我们可以使用邮件预览功能。Rails 提供了一些特殊的 URL,用来预览邮件。首先,我们要在应用的开发环境中添加一些设置,如[代码清单 10.14](#listing-development-email-settings) 所示。 ##### 代码清单 10.14:开发环境中的邮件设置 config/environments/development.rb ``` Rails.application.configure do . . . config.action_mailer.raise_delivery_errors = true config.action_mailer.delivery_method = :test host = 'example.com' config.action_mailer.default_url_options = { host: host } . . . end ``` [代码清单 10.14](#listing-development-email-settings) 中设置的主机地址是 `'example.com'`,你应该使用你的开发环境的主机地址。例如,在我的系统中,可以使用下面的地址(包括云端 IDE 和本地服务器): ``` host = 'rails-tutorial-c9-mhartl.c9.io' # 云端 IDE host = 'localhost:3000' # 本地主机 ``` 然后重启开发服务器,让[代码清单 10.14](#listing-development-email-settings) 中的设置生效。接下来,我们要修改邮件程序的预览文件。生成邮件程序时已经自动生成了这个文件,如[代码清单 10.15](#listing-generated-user-mailer-previews) 所示。 ##### 代码清单 10.15:生成的邮件预览程序 test/mailers/previews/user_mailer_preview.rb ``` # Preview all emails at http://localhost:3000/rails/mailers/user_mailer class UserMailerPreview < ActionMailer::Preview # Preview this email at # http://localhost:3000/rails/mailers/user_mailer/account_activation def account_activation UserMailer.account_activation end # Preview this email at # http://localhost:3000/rails/mailers/user_mailer/password_reset def password_reset UserMailer.password_reset end end ``` 因为[代码清单 10.11](#listing-mail-account-activation) 中定义的 `account_activation` 方法需要一个有效的用户作为参数,所以[代码清单 10.15](#listing-generated-user-mailer-previews) 中的代码现在还不能使用。为了解决这个问题,我们要定义 `user` 变量,把开发数据库中的第一个用户赋值给它,然后作为参数传给 `UserMailer.account_activation`,如[代码清单 10.16](#listing-account-activation-preview) 所示。注意,在这段代码中,我们还给 `user.activation_token` 赋了值,因为[代码清单 10.12](#listing-account-activation-view-text) 和[代码清单 10.13](#listing-account-activation-view-html) 中的模板要使用账户激活令牌。(`activation_token` 是虚拟属性,所以数据库中的用户并没有激活令牌。) ##### 代码清单 10.16:预览账户激活邮件所需的方法 test/mailers/previews/user_mailer_preview.rb ``` # Preview all emails at http://localhost:3000/rails/mailers/user_mailer class UserMailerPreview < ActionMailer::Preview # Preview this email at # http://localhost:3000/rails/mailers/user_mailer/account_activation def account_activation user = User.first user.activation_token = User.new_token UserMailer.account_activation(user) end # Preview this email at # http://localhost:3000/rails/mailers/user_mailer/password_reset def password_reset UserMailer.password_reset end end ``` 这样修改之后,我们就可以访问注释中提示的 URL 预览账户激活邮件了。(如果使用云端 IDE,要把 `localhost:3000` 换成相应的 URL。)HTML 和纯文本邮件分别如[图 10.2](#fig-account-activation-html-preview) 和[图 10.3](#fig-account-activation-text-preview) 所示。 ![account activation html preview](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333066da535.png)图 10.2:预览 HTML 格式的账户激活邮件![account activation text preview](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333066f37bf.png)图 10.3:预览纯文本格式的账户激活邮件 最后,我们要编写一些测试,再次确认邮件的内容。这并不难,因为 Rails 生成了一些有用的测试示例,如[代码清单 10.17](#listing-generated-user-mailer-test) 所示。 ##### 代码清单 10.17:Rails 生成的 `UserMailer` 测试 test/mailers/user_mailer_test.rb ``` require 'test_helper' class UserMailerTest < ActionMailer::TestCase test "account_activation" do mail = UserMailer.account_activation assert_equal "Account activation", mail.subject assert_equal ["to@example.org"], mail.to assert_equal ["from@example.com"], mail.from assert_match "Hi", mail.body.encoded end test "password_reset" do mail = UserMailer.password_reset assert_equal "Password reset", mail.subject assert_equal ["to@example.org"], mail.to assert_equal ["from@example.com"], mail.from assert_match "Hi", mail.body.encoded end end ``` [代码清单 10.17](#listing-generated-user-mailer-test) 中使用了强大的 `assert_match` 方法。这个方法既可以匹配字符串,也可以匹配正则表达式: ``` assert_match 'foo', 'foobar' # true assert_match 'baz', 'foobar' # false assert_match /\w+/, 'foobar' # true assert_match /\w+/, '$#!*+@' # false ``` [代码清单 10.18](#listing-real-account-activation-test) 使用 `assert_match` 检查邮件正文中是否有用户的名字、激活令牌和转义后的电子邮件地址。注意,转义用户电子邮件地址使用的方法是 `CGI::escape(user.email)`。[[7](#fn-7)](其实还有第三种方法,`ERB::Util` 中的 [`url_encode` 方法](http://apidock.com/ruby/ERB/Util/url_encode)有同样的效果。) ##### 代码清单 10.18:测试现在这个邮件程序 RED test/mailers/user_mailer_test.rb ``` require 'test_helper' class UserMailerTest < ActionMailer::TestCase test "account_activation" do user = users(:michael) user.activation_token = User.new_token mail = UserMailer.account_activation(user) assert_equal "Account activation", mail.subject assert_equal [user.email], mail.to assert_equal ["noreply@example.com"], mail.from assert_match user.name, mail.body.encoded assert_match user.activation_token, mail.body.encoded assert_match CGI::escape(user.email), mail.body.encoded end end ``` 注意,我们在[代码清单 10.18](#listing-real-account-activation-test) 中为用户固件指定了激活令牌,因为固件中没有虚拟属性。 为了让这个测试通过,我们要修改测试环境的配置,设定正确的主机地址,如[代码清单 10.19](#listing-test-domain-host) 所示。 ##### 代码清单 10.19:设定测试环境的主机地址 config/environments/test.rb ``` Rails.application.configure do . . . config.action_mailer.delivery_method = :test config.action_mailer.default_url_options = { host: 'example.com' } . . . end ``` 现在,邮件程序的测试应该可以通过了: ##### 代码清单 10.20:**GREEN** ``` $ bundle exec rake test:mailers ``` 若要在我们的应用中使用这个邮件程序,只需在处理用户注册的 `create` 动作中添加几行代码,如[代码清单 10.21](#listing-user-signup-with-account-activation) 所示。注意,[代码清单 10.21](#listing-user-signup-with-account-activation) 修改了注册后的重定向地址。之前,我们把用户重定向到资料页面([7.4 节](chapter7.html#successful-signups)),可是现在需要先激活,再转向这个页面就不合理了,所以把重定向地址改成了根地址。 ##### 代码清单 10.21:在注册过程中添加账户激活 RED app/controllers/users_controller.rb ``` class UsersController < ApplicationController . . . def create @user = User.new(user_params) if @user.save UserMailer.account_activation(@user).deliver_now flash[:info] = "Please check your email to activate your account." redirect_to root_url else render 'new' end end . . . end ``` 因为现在重定向到根地址而不是资料页面,而且不会像之前那样自动登入用户,所以测试组件无法通过,不过应用能按照我们设计的方式运行。我们暂时把导致失败的测试注释掉,如[代码清单 10.22](#listing-comment-out-failing-tests) 所示。我们会在 [10.1.4 节](#activation-test-and-refactoring)去掉注释,并且为账户激活编写能通过的测试。 ##### 代码清单 10.22:临时注释掉失败的测试 GREEN test/integration/users_signup_test.rb ``` require 'test_helper' class UsersSignupTest < ActionDispatch::IntegrationTest test "invalid signup information" do get signup_path assert_no_difference 'User.count' do post users_path, user: { name: "", email: "user@invalid", password: "foo", password_confirmation: "bar" } end assert_template 'users/new' assert_select 'div#error_explanation' assert_select 'div.field_with_errors' end test "valid signup information" do get signup_path assert_difference 'User.count', 1 do post_via_redirect users_path, user: { name: "Example User", email: "user@example.com", password: "password", password_confirmation: "password" } end # assert_template 'users/show' # assert is_logged_in? end end ``` 如果现在注册,重定向后显示的页面如[图 10.4](#fig-redirected-not-activated) 所示,而且会生成一封邮件,如[代码清单 10.23](#listing-account-activation-email) 所示。注意,在开发环境中并不会真发送邮件,不过能在服务器的日志中看到(可能要往上滚动才能看到)。[10.3 节](#email-in-production)会介绍如何在生产环境中发送邮件。 ##### 代码清单 10.23:在服务器日志中看到的账户激活邮件 ``` Sent mail to michael@michaelhartl.com (931.6ms) Date: Wed, 03 Sep 2014 19:47:18 +0000 From: noreply@example.com To: michael@michaelhartl.com Message-ID: <540770474e16_61d3fd1914f4cd0300a0@mhartl-rails-tutorial-953753.mail> Subject: Account activation Mime-Version: 1.0 Content-Type: multipart/alternative; boundary="--==_mimepart_5407704656b50_61d3fd1914f4cd02996a"; charset=UTF-8 Content-Transfer-Encoding: 7bit ----==_mimepart_5407704656b50_61d3fd1914f4cd02996a Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit Hi Michael Hartl, Welcome to the Sample App! Click on the link below to activate your account: http://rails-tutorial-c9-mhartl.c9.io/account_activations/ fFb_F94mgQtmlSvRFGsITw/edit?email=michael%40michaelhartl.com ----==_mimepart_5407704656b50_61d3fd1914f4cd02996a Content-Type: text/html; charset=UTF-8 Content-Transfer-Encoding: 7bit

    Sample App

    Hi Michael Hartl,

    Welcome to the Sample App! Click on the link below to activate your account:

    Activate ----==_mimepart_5407704656b50_61d3fd1914f4cd02996a-- ``` ![redirected not activated](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306729172.png)图 10.4:注册后显示的首页,有一个提醒激活的消息 ## 10.1.3 激活账户 现在可以正确生成电子邮件了([代码清单 10.23](#listing-account-activation-email)),接下来我们要编写 `AccountActivationsController` 中的 `edit` 动作,激活用户。[10.1.2 节](#account-activation-mailer-method)说过,激活令牌和电子邮件地址可以分别通过 `params[:id]` 和 `params[:email]` 获取。参照密码([代码清单 8.5](chapter8.html#listing-find-authenticate-user))和记忆令牌([代码清单 8.36](chapter8.html#listing-persistent-current-user))的实现方式,我们计划使用下面的代码查找和认证用户: ``` user = User.find_by(email: params[:email]) if user && user.authenticated?(:activation, params[:id]) ``` (稍后会看到,上述代码还缺一个判断条件。看看你能否猜到缺了什么。) 上述代码使用 `authenticated?` 方法检查账户激活的摘要和指定的令牌是否匹配,但是现在不起作用,因为 `authenticated?` 方法是专门用来认证记忆令牌的([代码清单 8.33](chapter8.html#listing-authenticated-p)): ``` # 如果指定的令牌和摘要匹配,返回 true def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end ``` 其中,`remember_digest` 是用户模型的属性,在模型内,我们可以将其改写成: ``` self.remember_digest ``` 我们希望以某种方式把这个值变成“变量”,这样才能调用 `self.activation_token`,而不是把合适的参数传给 `authenticated?` 方法。 我们要使用的解决方法涉及到“元编程”(metaprogramming),意思是用程序编写程序。(元编程是 Ruby 最强大的功能,Rails 中很多“神奇”的功能都是通过元编程实现的。)这里的关键是强大的 `send` 方法。这个方法的作用是在指定的对象上调用指定的方法。例如,在下面的控制台会话中,我们在一个 Ruby 原生对象上调用 `send` 方法,获取数组的长度: ``` $ rails console >> a = [1, 2, 3] >> a.length => 3 >> a.send(:length) => 3 >> a.send('length') => 3 ``` 可以看出,把 `:length` 符号或者 `'length'` 字符串传给 `send` 方法的作用和在对象上直接调用 `length` 方法的作用一样。再看一个例子,获取数据库中第一个用户的 `activation_digest` 属性: ``` >> user = User.first >> user.activation_digest => "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae" >> user.send(:activation_digest) => "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae" >> user.send('activation_digest') => "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae" >> attribute = :activation >> user.send("#{attribute}_digest") => "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae" ``` 注意最后一种调用方式,我们定义了一个 `attribute` 变量,其值为符号 `:activation`,然后使用字符串插值构建传给 `send` 方法的参数。`attribute` 变量的值使用字符串 `'activation'` 也行,不过符号更便利。不管使用什么,插值后,`"#{attribute}_digest"` 的结果都是 `"activation_digest"`。([7.4.2 节](chapter7.html#the-flash)介绍过,插值时会把符号转换成字符串。) 基于上述对 `send` 方法的介绍,我们可以把 `authenticated?` 方法改写成: ``` def authenticated?(remember_token) digest = self.send('remember_digest') return false if digest.nil? BCrypt::Password.new(digest).is_password?(remember_token) end ``` 以此为模板,我们可以为这个方法增加一个参数,代表摘要的名字,然后再使用字符串插值,扩大这个方法的用途: ``` def authenticated?(attribute, token) digest = self.send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end ``` (我们把第二个参数的名字改成了 `token`,以此强调这个方法的用途更广。)因为这个方法在用户模型内,所以可以省略 `self`,得到更符合习惯写法的版本: ``` def authenticated?(attribute, token) digest = send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end ``` 现在我们可以像下面这样调用 `authenticated?` 方法实现以前的效果: ``` user.authenticated?(:remember, remember_token) ``` 把修改后的 `authenticated?` 方法写入用户模型,如[代码清单 10.24](#listing-generalized-authenticated-p) 所示。 ##### 代码清单 10.24:用途更广的 `authenticated?` 方法 RED app/models/user.rb ``` class User < ActiveRecord::Base . . . # 如果指定的令牌和摘要匹配,返回 true def authenticated?(attribute, token) digest = send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end . . . end ``` 如[代码清单 10.24](#listing-generalized-authenticated-p) 的标题所示,测试组件无法通过: ##### 代码清单 10.25:**RED** ``` $ bundle exec rake test ``` 失败的原因是,`current_user` 方法([代码清单 8.36](chapter8.html#listing-persistent-current-user))和摘要为 `nil` 的测试([代码清单 8.43](chapter8.html#listing-test-authenticated-invalid-token))使用的都是旧版 `authenticated?`,期望传入的是一个参数而不是两个。因此,我们只需修改这两个地方,换用修改后的 `authenticated?` 方法就能解决这个问题,如[代码清单 10.26](#listing-generalized-current-user) 和[代码清单 10.27](#listing-test-authenticated-invalid-token-updated) 所示。 ##### 代码清单 10.26:在 `current_user` 中使用修改后的 `authenticated?` 方法 GREEN app/helpers/sessions_helper.rb ``` module SessionsHelper . . . # 返回当前登录的用户(如果有的话) def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(:remember, cookies[:remember_token]) log_in user @current_user = user end end end . . . end ``` ##### 代码清单 10.27:在 `UserTest` 中使用修改后的 `authenticated?` 方法 GREEN test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confirmation: "foobar") end . . . test "authenticated? should return false for a user with nil digest" do assert_not @user.authenticated?(:remember, '') end end ``` 修改后,测试应该可以通过了: ##### 代码清单 10.28:**GREEN** ``` $ bundle exec rake test ``` 没有坚实的测试组件做后盾,像这样的重构很容易出错,所以我们才要在 [8.4.2 节](chapter8.html#login-with-remembering)和 [8.4.6 节](chapter8.html#remember-tests)排除万难编写测试。 有了[代码清单 10.24](#listing-generalized-authenticated-p) 中定义的 `authenticated?` 方法,现在我们可以编写 `edit` 动作,认证 `params` 哈希中电子邮件地址对应的用户了。我们要使用的判断条件如下所示: ``` if user && !user.activated? && user.authenticated?(:activation, params[:id]) ``` 注意,这里加入了 `!user.activated?`,就是前面提到的那个缺失的条件,作用是避免激活已经激活的用户。这个条件很重要,因为激活后我们要登入用户,但是不能让获得激活链接的攻击者以这个用户的身份登录。 如果通过了上述判断条件,我们要激活这个用户,并且更新 `activated_at` 中的时间戳: ``` user.update_attribute(:activated, true) user.update_attribute(:activated_at, Time.zone.now) ``` 据此,写出的 `edit` 动作如[代码清单 10.29](#listing-account-activation-edit-action) 所示。注意,在[代码清单 10.29](#listing-account-activation-edit-action) 中我们还处理了激活令牌无效的情况。这种情况很少发生,但处理起来也很容易,直接重定向到根地址即可。 ##### 代码清单 10.29:在 `edit` 动作中激活账户 app/controllers/account_activations_controller.rb ``` class AccountActivationsController < ApplicationController def edit user = User.find_by(email: params[:email]) if user && !user.activated? && user.authenticated?(:activation, params[:id]) user.update_attribute(:activated, true) user.update_attribute(:activated_at, Time.zone.now) log_in user flash[:success] = "Account activated!" redirect_to user else flash[:danger] = "Invalid activation link" redirect_to root_url end end end ``` 然后,复制粘贴[代码清单 10.23](#listing-account-activation-email) 中的地址,应该就可以激活对应的用户了。例如,在我的系统中,我访问的地址是: ``` http://rails-tutorial-c9-mhartl.c9.io/account_activations/ fFb_F94mgQtmlSvRFGsITw/edit?email=michael%40michaelhartl.com ``` 然后会看到如[图 10.5](#fig-activated-user) 所示的页面。 ![activated user](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306746907.png)图 10.5:成功激活后显示的资料页面 当然,现在激活用户后没有什么实际效果,因为我们还没修改用户登录的方式。为了让账户激活有实际意义,只能允许已经激活的用户登录,即 `user.activated?` 返回 `true` 时才能像之前那样登录,否则重定向到根地址,并且显示一个提醒消息([图 10.6](#fig-not-activated-warning)),如[代码清单 10.30](#listing-preventing-unactivated-logins) 所示。 ##### 代码清单 10.30:禁止未激活的用户登录 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]) if user.activated? log_in user params[:session][:remember_me] == '1' ? remember(user) : forget(user) redirect_back_or user else message = "Account not activated. " message += "Check your email for the activation link." flash[:warning] = message redirect_to root_url end else flash.now[:danger] = 'Invalid email/password combination' render 'new' end end def destroy log_out if logged_in? redirect_to root_url end end ``` ![not activated warning](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_573330675d32c.png)图 10.6:未激活用户试图登录后看到的提醒消息 至此,激活用户的功能基本完成了,不过还有个地方可以改进。(可以改进的是,不显示未激活的用户。这个改进留作[练习](#account-activation-and-password-reset-exercises)。)[10.1.4 节](#activation-test-and-refactoring)会编写一些测试,再做一些重构,完成整个功能。 ## 10.1.4 测试和重构 本节,我们要为账户激活功能添加一些集成测试。我们已经为提交有效信息的注册过程编写了测试,所以我们要把这个测试添加到 [7.4.4 节](chapter7.html#a-test-for-valid-submission)编写的测试中([代码清单 7.26](chapter7.html#listing-a-test-for-valid-submission))。在测试中,我们要添加好多步,不过意图都很明确,看看你是否能理解[代码清单 10.31](#listing-signup-with-account-activation-test) 中的测试。 ##### 代码清单 10.31:在用户注册的测试文件中添加账户激活的测试 GREEN test/integration/users_signup_test.rb ``` require 'test_helper' class UsersSignupTest < ActionDispatch::IntegrationTest def setup ActionMailer::Base.deliveries.clear end test "invalid signup information" do get signup_path assert_no_difference 'User.count' do post users_path, user: { name: "", email: "user@invalid", password: "foo", password_confirmation: "bar" } end assert_template 'users/new' assert_select 'div#error_explanation' assert_select 'div.field_with_errors' end test "valid signup information with account activation" do get signup_path assert_difference 'User.count', 1 do post users_path, user: { name: "Example User", email: "user@example.com", password: "password", password_confirmation: "password" } end assert_equal 1, ActionMailer::Base.deliveries.size user = assigns(:user) assert_not user.activated? # 尝试在激活之前登录 log_in_as(user) assert_not is_logged_in? # 激活令牌无效 get edit_account_activation_path("invalid token") assert_not is_logged_in? # 令牌有效,电子邮件地址不对 get edit_account_activation_path(user.activation_token, email: 'wrong') assert_not is_logged_in? # 激活令牌有效 get edit_account_activation_path(user.activation_token, email: user.email) assert user.reload.activated? follow_redirect! assert_template 'users/show' assert is_logged_in? end end ``` 代码很多,不过有一行完全没见过: ``` assert_equal 1, ActionMailer::Base.deliveries.size ``` 这行代码确认只发送了一封邮件。`deliveries` 是一个数组,会统计所有发出的邮件,所以我们要在 `setup` 方法中把它清空,以防其他测试发送了邮件([10.2.5 节](#password-reset-test)就会这么做)。[代码清单 10.31](#listing-signup-with-account-activation-test) 还第一次在本书正文中使用了 `assigns` 方法。[8.6 节](chapter8.html#log-in-log-out-exercises)说过,`assigns` 的作用是获取相应动作中的实例变量。例如,用户控制器的 `create` 动作中定义了一个 `@user` 变量,那么我们可以在测试中使用 `assigns(:user)` 获取这个变量的值。最后,注意,[代码清单 10.31](#listing-signup-with-account-activation-test) 把[代码清单 10.22](#listing-comment-out-failing-tests) 中的注释去掉了。 现在,测试组件应该可以通过: ##### 代码清单 10.32:**GREEN** ``` $ bundle exec rake test ``` 有了[代码清单 10.31](#listing-signup-with-account-activation-test) 中的测试做后盾,接下来我们可以稍微重构一下了:把处理用户的代码从控制器中移出,放入模型。我们会定义一个 `activate` 方法,用来更新用户激活相关的属性;还要定义一个 `send_activation_email` 方法,发送激活邮件。这两个方法的定义如[代码清单 10.33](#listing-user-activation-methods) 所示,重构后的应用代码如[代码清单 10.34](#listing-user-signup-refactored) 和[代码清单 10.35](#listing-account-activation-refactored) 所示。 ##### 代码清单 10.33:在用户模型中添加账户激活相关的方法 app/models/user.rb ``` class User < ActiveRecord::Base . . . # 激活账户 def activate update_attribute(:activated, true) update_attribute(:activated_at, Time.zone.now) end # 发送激活邮件 def send_activation_email UserMailer.account_activation(self).deliver_now end private . . . end ``` ##### 代码清单 10.34:通过用户模型对象发送邮件 app/controllers/users_controller.rb ``` class UsersController < ApplicationController . . . def create @user = User.new(user_params) if @user.save @user.send_activation_email flash[:info] = "Please check your email to activate your account." redirect_to root_url else render 'new' end end . . . end ``` ##### 代码清单 10.35:通过用户模型对象激活账户 app/controllers/account_activations_controller.rb ``` class AccountActivationsController < ApplicationController def edit user = User.find_by(email: params[:email]) if user && !user.activated? && user.authenticated?(:activation, params[:id]) user.activate log_in user flash[:success] = "Account activated!" redirect_to user else flash[:danger] = "Invalid activation link" redirect_to root_url end end end ``` 注意,在[代码清单 10.33](#listing-user-activation-methods) 中没有使用 `user`。如果还像之前那样写就会出错,因为用户模型中没有这个变量: ``` -user.update_attribute(:activated, true) -user.update_attribute(:activated_at, Time.zone.now) +update_attribute(:activated, true) +update_attribute(:activated_at, Time.zone.now) ``` (也可以把 `user` 换成 `self`,但 [6.2.5 节](chapter6.html#uniqueness-validation)说过,在模型内可以不加 `self`。)调用 `UserMailer` 时,还把 `@user` 改成了 `self`: ``` -UserMailer.account_activation(@user).deliver_now +UserMailer.account_activation(self).deliver_now ``` 就算是简单的重构,也可能忽略这些细节,不过好的测试组件能捕获这些问题。现在,测试组件应该仍能通过: ##### 代码清单 10.36:**GREEN** ``` $ bundle exec rake test ``` 账户激活功能完成了,我们取得了一定进展,可以提交了: ``` $ git add -A $ git commit -m "Add account activations" ```
    ';

    第 10 章 账户激活和密码重设

    最后更新于:2022-04-01 22:30:02

    # 第 10 章 账户激活和密码重设 [第 9 章](chapter9.html#updating-showing-and-deleting-users)完成了一个基本的用户资源(实现了[表 7.1](chapter7.html#table-restful-users) 中的所有 REST 标准动作),以及灵活的认证和权限系统。本章,我们要对这个系统做最后的调整,增加两个联系紧密的功能:账户激活(验证新注册用户的电子邮件地址)和密码重设(帮助忘记密码的用户)。实现这两个功能都要创建新资源,借此机会我们还能再介绍一下控制器、路由和数据库迁移。这两个功能还涉及到一个重要且具挑战的话题——在 Rails 应用中发送电子邮件。而且这两个功能之间需要相互配合,因为重设密码时要向用户的电子邮件地址发送一封包含重设链接的邮件,电子邮件地址是否有效则要在激活账户时验证。[[1](#fn-1)]
    ';