9.3 列出所有用户

本节,我们要添加倒数第二个用户控制器动作,indexindex 动作不是显示某一个用户,而是显示所有用户。在这个过程中,我们要学习如何在数据库中生成示例用户数据,以及如何分页显示用户列表,让首页显示任意数量的用户。用户列表、分页链接和“Users”(所有用户)导航链接的构思图如图 9.8 所示。[6]9.4 节会添加管理功能,用来删除用户。

user index mockup bootstrap图 9.8:用户列表页面的构思图

9.3.1 用户列表

创建用户列表之前,我们先要实现一个安全机制。单个用户的资料页面对网站的所有访问者开放,但要限制用户列表页面,只让已登录的用户查看,减少未注册用户能看到的信息量。[7]

为了限制访问 index 动作,我们先编写一个简短的测试,确认应用会正确重定向 index 动作,如代码清单 9.31 所示。

代码清单 9.31:测试 index 动作的重定向 RED

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 index when not logged in" do get :index assert_redirected_to login_url end  .
  .
  .
end

然后我们要定义 index 动作,并把它加入被 logged_in_user 事前过滤器保护的动作列表中,如代码清单 9.32 所示。

代码清单 9.32:访问 index 动作要先登录 GREEN

app/controllers/users_controller.rb

class UsersController < ApplicationController
 before_action :logged_in_user, only: [:index, :edit, :update]  before_action :correct_user,   only: [:edit, :update]

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

若要显示用户列表,我们要定义一个变量,存储网站中的所有用户,然后在 index 动作的视图中遍历,显示各个用户。你可能还记得玩具应用中相应的动作(2.5 节),我们可以使用 User.all 从数据库中读取所有用户,然后把这些用户赋值给实例变量 @users,以便在视图中使用,如代码清单 9.33 所示。(你可能会觉得一次列出所有用户不太好,你是对的,我们会在 9.3.3 节改进。)

代码清单 9.33:用户控制器的 index 动作

app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  .
  .
  def index
 @users = User.all  end
  .
  .
  .
end

为了显示用户列表页面,我们要创建一个视图(要自己动手创建视图文件),遍历所有用户,把每个用户包含在一个 li 标签中。我们要使用 each 方法遍历所有用户,显示用户的 Gravatar 头像和名字,然后把所有用户包含在一个无序列表 ul 标签中,如代码清单 9.34 所示。

代码清单 9.34:index 视图

app/views/users/index.html.erb

<% provide(:title, 'All users') %>
<h1>All users</h1>

<ul class="users">
  <% @users.each do |user| %>
 <li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
 </li>
  <% end %>
</ul>

代码清单 9.34 中,我们用到了 7.7 节练习中代码清单 7.31 的成果,向 Gravatar 辅助方法传入第二个参数,指定头像的大小。如果你之前没有做这个练习,在继续阅读之前请参照代码清单 7.31,更新用户控制器的辅助方法文件。

然后再添加一些 CSS 样式(确切地说是 SCSS),如代码清单 9.35

代码清单 9.35:用户列表页面的 CSS

app/assets/stylesheets/custom.css.scss

.
.
.
/* Users index */

.users {
  list-style: none;
  margin: 0;
  li {
    overflow: auto;
    padding: 10px 0;
    border-bottom: 1px solid $gray-lighter;
  }
}

最后,我们还要把头部导航中用户列表页面的链接地址换成 users_path,这是表 7.1 中还没用到的最后一个具名路由,如代码清单 9.36 所示。

代码清单 9.36:添加用户列表页面的链接地址

app/views/layouts/_header.html.erb

<header class="navbar navbar-fixed-top navbar-inverse">
 <div class="container">
  <%= link_to "sample app", root_path, id: "logo" %>
 <nav>
 <ul class="nav navbar-nav navbar-right">
 <li><%= link_to "Home", root_path %></li>
 <li><%= link_to "Help", help_path %></li>
  <% if logged_in? %>
 <li><%= link_to "Users", users_path %></li>
 <li class="dropdown">
 <a href="#" class="dropdown-toggle" data-toggle="dropdown">
 Account <b class="caret"></b>
 </a>
 <ul class="dropdown-menu">
 <li><%= link_to "Profile", current_user %></li>
 <li><%= link_to "Settings", edit_user_path(current_user) %></li>
 <li class="divider"></li>
 <li>
  <%= link_to "Log out", logout_path, method: "delete" %>
 </li>
 </ul>
 </li>
  <% else %>
 <li><%= link_to "Log in", login_path %></li>
  <% end %>
 </ul>
 </nav>
 </div>
</header>

至此,用户列表页面完成了,所有的测试也都可以通过了:

代码清单 9.37:GREEN
$ bundle exec rake test

不过,如图 9.9 所示,页面中只显示了一个用户,有点孤单。下面,我们来改变这种悲惨状况。

user index only one 3rd edition图 9.9:用户列表页面,只显示了一个用户

9.3.2 示例用户

本节,我们要为应用添加更多的用户。为了让用户列表看上去像个“列表”,我们可以在浏览器中访问注册页面,一个一个地注册用户,不过还有更好的方法,让 Ruby(和 Rake)为我们创建用户。

首先,我们要在 Gemfile 中加入 faker gem,如代码清单 9.38 所示。这个 gem 会使用半真实的名字和电子邮件地址创建示例用户。(通常,可能只需在开发环境中安装 faker gem,但是对这个演示应用来说,生产环境也要使用 faker,参见 9.5 节。)

代码清单 9.38:在 Gemfile 中加入 faker
source 'https://rubygems.org'

gem 'rails',                '4.2.2'
gem 'bcrypt',               '3.1.7'
gem 'faker',                '1.4.2' .
.
.

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

$ bundle install

接下来,我们要添加一个 Rake 任务,向数据库中添加示例用户。Rails 使用一个标准文件 db/seeds.rb 完成这种操作,如代码清单 9.39 所示。(这段代码涉及一些高级知识,现在不必太关注细节。)

代码清单 9.39:向数据库中添加示例用户的 Rake 任务

db/seeds.rb

User.create!(name:  "Example User",
             email: "[email protected]",
             password:              "foobar",
             password_confirmation: "foobar")

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)
end

代码清单 9.39 中,首先使用现有用户的名字和电子邮件地址创建一个示例用户,然后又创建了 99 个示例用户。其中,create! 方法和 create 方法的作用类似,只不过遇到无效数据时会抛出异常,而不是返回 false。这么做出现错误时不会静默,有利于调试。

然后,我们可以执行下述命令,还原数据库,再使用 db:seed 调用这个 Rake 任务:[8]

$ bundle exec rake db:migrate:reset
$ bundle exec rake db:seed

向数据库中添加数据的操作可能很慢,在某些系统中可能要花上几分钟。此外,有些读者反馈说,Rails 服务器运行的过程中无法执行 reset 命令,因此,可能要先停止服务器,然后再执行上述命令。

执行完 db:seed Rake 任务后,我们的应用中就有 100 个用户了,如图 9.10 所示。(可能要重启服务器才能看到效果。)我牺牲了一点个人时间,为前几个用户上传了头像,这样就不会都显示默认的 Gravatar 头像了。

user index all 3rd edition图 9.10:用户列表页面,显示了 100 个示例用户

9.3.3 分页

现在,最初的那个用户不再孤单了,但是又出现了新问题:用户太多,全在一个页面中显示。现在的用户数量是 100 个,算是少的了,在真实的网站中,这个数量可能是以千计的。为了避免在一页中显示过多的用户,我们可以分页,一页只显示 30 个用户。

在 Rails 中有很多实现分页的方法,我们要使用其中一个最简单也最完善的,叫 will_paginate。为此,我们要使用 will_paginatebootstrap-will_paginate 这两个 gem。其中,bootstrap-will_paginate 的作用是设置 will_paginate 使用 Bootstrap 提供的分页样式。修改后的 Gemfile代码清单 9.40 所示。

代码清单 9.40:在 Gemfile 中加入 will_paginate
source 'https://rubygems.org'

gem 'rails',                   '4.2.2'
gem 'bcrypt',                  '3.1.7'
gem 'faker',                   '1.4.2'
gem 'will_paginate',           '3.0.7' gem 'bootstrap-will_paginate', '0.0.10' .
.
.

然后执行下面的命令安装:

$ bundle install

安装后还要重启 Web 服务器,确保成功加载这两个新 gem。

为了实现分页,我们要在 index 视图中加入一些代码,告诉 Rails 分页显示用户,而且要把 index 动作中的 User.all 换成知道如何分页的方法。我们先在视图中加入特殊的 will_paginate 方法,如代码清单 9.41 所示。稍后我们会看到为什么要在用户列表的前后都加入这个方法。

代码清单 9.41:在 index 视图中加入分页

app/views/users/index.html.erb

<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %> 
<ul class="users">
  <% @users.each do |user| %>
 <li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
 </li>
  <% end %>
</ul>

<%= will_paginate %>

will_paginate 方法有点小神奇,在用户控制器的视图中,它会自动寻找名为 @users 的对象,然后显示一个分页导航链接。代码清单 9.41 中的视图现在还不能正确显示分页,因为 @users 的值是通过 User.all 方法获取的(代码清单 9.33),而 will_paginate 需要调用 paginate 方法才能分页:

$ rails console
>> User.paginate(page: 1)
 User Load (1.5ms)  SELECT "users".* FROM "users" LIMIT 30 OFFSET 0
 (1.7ms)  SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1,...

注意,paginate 方法可以接受一个哈希参数,:page 键的值指定显示第几页。User.paginate 方法根据 :page 的值,一次取回一组用户(默认为 30 个)。所以,第一页显示的是第 1-30 个用户,第二页显示的是第 31-60 个,以此类推。如果 :page 的值为 nilpaginate 会显示第一页。

我们可以把 index 动作中的 all 方法换成 paginate,如代码清单 9.42 所示,这样就能分页显示用户了。paginate 方法所需的 :page 参数由 params[:page] 指定,params 中的这个键由 will_pagenate 自动生成。

代码清单 9.42:在 index 动作中分页取回用户

app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  .
  .
  def index
 @users = User.paginate(page: params[:page])  end
  .
  .
  .
end

现在,用户列表页面应该可以显示分页了,如图 9.11 所示。(在某些系统中,可能需要重启 Rails 服务器。)因为我们在用户列表前后都加入了 will_paginate 方法,所以这两个地方都会显示分页链接。

user index pagination 3rd edition图 9.11:分页显示的用户列表页面

如果点击链接“2”,或者“Next”,就会显示第二页,如图 9.12 所示。

user index page two 3rd edition图 9.12:用户列表的第二页

9.3.4 用户列表页面的测试

现在用户列表页面可以正常使用了,接下来要为这个页面编写一些简单的测试,其中一个测试前一节实现的分页。测试的步骤是,先登录,然后访问用户列表页面,确认第一页显示了一些用户,而且还显示了分页链接。为此,测试数据库中要有能足够数量的用户,足以分页才行,即超过 30 个。

我们在代码清单 9.20 中创建了第二个用户固件,但手动创建 30 多个用户,工作量有点大。不过,由固件中的 password_digest 属性得知,固件文件支持嵌入式 Ruby,所以我们可以使用代码清单 9.43 中的代码,再创建 30 个用户。(代码清单 9.43 还多创建了几个用户,以备后用。)

代码清单 9.43:在固件中再创建 30 个用户

test/fixtures/users.yml

michael:
  name: Michael Example
  email: [email protected]
  password_digest: <%= User.digest('password') %>

archer:
  name: Sterling Archer
  email: [email protected]
  password_digest: <%= User.digest('password') %>

lana:
  name: Lana Kane
  email: [email protected]
  password_digest: <%= User.digest('password') %>

malory:
  name: Malory Archer
  email: [email protected]
  password_digest: <%= User.digest('password') %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
<% end %>

然后,我们可以编写用户列表页面的测试了。首先,生成所需的测试文件:

$ rails generate integration_test users_index
      invoke  test_unit
      create    test/integration/users_index_test.rb

在测试中,我们要检查是否有一个类为 pagination 的标签,以及第一页中是否显示了用户,如代码清单 9.44 所示。

代码清单 9.44:用户列表及分页的测试 GREEN

test/integration/users_index_test.rb

require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "index including pagination" do
    log_in_as(@user)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination'
    User.paginate(page: 1).each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
    end
  end
end

测试组件应该可以通过:

代码清单 9.45:GREEN
$ bundle exec rake test

9.3.5 使用局部视图重构

用户列表页面现在已经可以显示分页了,但是有个地方可以改进,我不得不介绍一下。Rails 提供了一些很巧妙的方法,可以精简视图的结构。本节我们要利用这些方法重构一下用户列表页面。因为我们已经做了很好的测试,所以可以放心重构,不必担心会破坏网站的功能。

重构的第一步,把代码清单 9.41 中的 li 换成 render 方法调用,如代码清单 9.46 所示。

代码清单 9.46:重构用户列表视图的第一步

app/views/users/index.html.erb

<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
  <%= render user %>
  <% end %>
</ul>

<%= will_paginate %>

在上述代码中,render 的参数不再是指定局部视图的字符串,而是代表 User 类的变量 user。[9]此时,Rails 会自定寻找一个名为 _user.html.erb 的局部视图。我们要手动创建这个视图,然后写入代码清单 9.47 中的内容。

代码清单 9.47:显示单个用户的局部视图

app/views/users/_user.html.erb

<li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>

这个改进不错,不过我们还可以做得更好。我们可以直接把 @users 变量传给 render 方法,如代码清单 9.48 所示。

代码清单 9.48:完全重构后的用户列表视图 GREEN

app/views/users/index.html.erb

<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
<%= render @users %> </ul>

<%= will_paginate %>

Rails 会把 @users 当作一个 User 对象列表,传给 render 方法后,Rails 会自动遍历这个列表,然后使用局部视图 _user.html.erb 渲染每个对象。重构后,我们得到了如代码清单 9.48 这样简洁的代码。

每次重构修改应用代码后,都要运行测试组件确认仍能通过:

代码清单 9.49:GREEN
$ bundle exec rake test