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

第 13 章 用户的微博

在开发这个演示应用的过程中,我们用到了四个资源:UsersSessionsAccountActivationsPasswordResets。但只有第一个资源通过 Active Record 模型对应了数据库中的表。本章,我们将再实现一个这样的资源——用户的微博(Microposts),即用户发布的短消息。第 2 章实现了微博的雏形,本章则会在 2.3 节的基础上,实现一个功能完整的 Microposts 资源。首先,我们要创建 Micropost 数据模型,通过 has_manybelongs_to 方法把微博和用户关联起来,然后再创建处理和显示微博所需的表单及局部视图(13.4 节还要实现图像上传功能)。第 14 章还要加入关注其他用户的功能,届时,我们这个小型 Twitter 克隆版才算完成。

13.1 Micropost 模型

实现 Microposts 资源的第一步是创建 Micropost 数据模型,在模型中设定微博的基本特征。与 2.3 节创建的模型类似,我们要实现的 Micropost 模型包含数据验证,以及与 User 模型之间的关联。除此之外,我们还会做充分的测试,指定默认的排序方式,以及自动删除已注销用户的微博。

如果使用 Git 做版本控制的话,和之前一样,建议你新建一个主题分支:

$ git checkout -b user-microposts

13.1.1 基本模型

Micropost 模型只需要两个属性:一个是 content,保存微博的内容;另一个是 user_id,把微博和用户关联起来。Micropost 模型的结构如图 13.1 所示。

micropost model 3rd edition
图 13.1Micropost 数据模型

注意,在这个模型中,content 属性的类型为 text,而不是 string,目的是存储任意长度的文本。虽然我们会限制微博内容的长度不超过 140 个字符(13.1.2 节),也就是说在 string 类型的 255 个字符长度的限制内,但使用 text 能更好地表达微博的特性,即把微博看成一段文本更符合常理。在 13.3.2 节,我们会把文本字段换成多行文本字段,用于提交微博。而且,如果以后想让微博的内容更长一些(例如包含多国文字),使用 text 类型处理起来更灵活。何况,在生产环境中使用 text 类型并没有什么性能差异,所以不会有什么额外消耗。

User 模型(代码清单 6.1)一样,我们要使用 generate model 命令生成 Micropost 模型:

代码清单 13.1:生成 Micropost 模型
$ rails generate model Micropost content:text user:references

上述命令生成的迁移用于创建 Micropost 模型,如代码清单 13.2 所示。这个模型与其他模型一样,继承自 ApplicationRecord 类,不过生成的模型中有一行用于指定一篇微博属于(belongs_to)一个用户,这是因为我们在 generate model 命令中指定了 user:references 参数。那一行代码的作用在 13.1.3 节说明。

代码清单 13.2:生成的 Micropost 模型
app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
end

代码清单 13.1 中的 generate 命令会生成一个迁移文件,用于在数据库中生成一个名为 microposts 的表,如代码清单 13.3 所示。可以代码清单 6.2 和生成 users 表的迁移对照一下。二者之间最大的区别是,前者用到了 references 类型。references 会自动添加 user_id 列(以及索引和外键引用)[1],把用户和微博关联起来。与 User 模型一样,Micropost 模型的迁移中也自动生成了 t.timestamps6.1.1 节说过,这行代码的作用是添加 created_atupdated_at 两列。(13.1.4 节将使用 created_at 列。)

代码清单 13.3:创建 Micropost 模型的迁移文件,还会创建索引
db/migrate/[timestamp]_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration[5.0]
  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user, foreign_key: true

      t.timestamps
    end
    add_index :microposts, [:user_id, :created_at]
  end
end

因为我们会按照发布时间的倒序检索某个用户发布的所有微博,所以在上述代码中为 user_idcreated_at 列创建了索引(参见旁注 6.2):

add_index :microposts, [:user_id, :created_at]

我们把 user_idcreated_at 放在一个数组中,告诉 Rails 我们要创建的是多键索引(multiple key index),因此 Active Record 会同时使用这两个键。

然后像之前一样,执行下面的命令更新数据库:

$ rails db:migrate
练习
  1. 在 Rails 控制台中使用 Micropost.new 实例化一个 Micropost 对象,将其赋值给 micropost 变量,然后把内容设为 "Lorem ipsum",把用户 ID 设为数据库中第一个用户的 ID。自动创建的 created_atupdated_at 两列的值分别是什么?

  2. 前一题中`micropost.user` 的值是什么?micropost.user.name 呢?

  3. micropost 存入数据库,那两个自动创建的列的值是什么?

13.1.2 Micropost 模型的数据验证

我们已经创建了基本的数据模型,下面要添加一些验证,实现符合需求的约束。Micropost 模型必须要有一个属性表示用户的 ID,这样才能知道某篇微博是由哪个用户发布的。实现这样的属性,最好的方法是使用 Active Record 关联。关联在 13.1.3 节实现,现在直接处理 Micropost 模型。

我们可以参照 User 模型的测试(代码清单 6.7),在 setup 方法中新建一个微博对象,并把它和固件中的一个有效用户关联起来,然后在测试中检查这个微博对象是否有效。因为每篇微博都有一个用户 ID,所以我们还要为 user_id 属性的存在性验证编写一个测试。综上所述,测试如代码清单 13.4 所示。

代码清单 13.4:测试微博是否有效 GREEN
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 方法中的注释所说,创建微博使用的方法不符合常见做法,我们会在 13.1.3 节修正。

User 模型的测试(代码清单 6.5)一样,代码清单 13.4 中的第一个测试只是健全性测试,而第二个测试检查有没有设定用户 ID。为了让测试通过,我们要添加存在性验证,如代码清单 13.5 所示。

代码清单 13.5:验证微博的 user_id 是否存在
app/models/micropost.rb
class Micropost < ActiveRecord::Base
  belongs_to :user
  validates :user_id, presence: true
end

顺便说一下,在 Rails 5 中,即便没有代码清单 13.5 中的那个验证,代码清单 13.4 中的测试也能通过,不过必须使用代码清单 13.4 中高亮那行不符合习惯的写法。换成代码清单 13.12 中符合习惯的写法之后,必须为用户 ID 添加存在性验证,所以我们在这里添加了验证。

现在测试应该(依然)能通过:

代码清单 13.6GREEN
$ rails test:models

接下来,我们要为 content 属性加上数据验证(参照 2.3.2 节的做法)。和 user_id 一样,content 属性必须存在,而且还要限制内容的长度不能超过 140 个字符,这才是真正的“微”博。

User 模型的数据验证一样(6.2 节),我们将使用测试驱动开发方式添加微博内容的验证。我们参照 User 模型的验证测试,编写一些简单的测试,如代码清单 13.7 所示。

代码清单 13.7:测试 Micropost 模型的验证 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 节一样,代码清单 13.7也用到了字符串连乘来测试微博的内容长度验证:

$ rails console
>> "a" * 10
=> "aaaaaaaaaa"
>> "a" * 141
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

在模型中添加的代码基本上和 User 模型中 name 属性的验证一样(代码清单 6.16),如代码清单 13.8 所示。

代码清单 13.8Micropost 模型的验证 GREEN
app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

现在,测试组件应该能通过:

代码清单 13.9GREEN
$ rails test
练习
  1. 在 Rails 控制台中实例化一个 Micropost 对象,不要设定用户 ID 和内容。这个对象有效吗?完整的错误消息是什么?

  2. 在 Rails 控制台中再实例化一个 Micropost 对象,不要设定用户 ID,但是把内容设为一个特别长的字符串。这个对象有效吗?完整的错误消息是什么?

13.1.3 User 模型和 Micropost 模型之间的关联

为 Web 应用构建数据模型时,最基本的要求是要能够在不同的模型之间建立关联。在这个应用中,每篇微博都属于某个用户,而每个用户一般都有多篇微博。用户和微博之间的关系在 2.3.3 节简单介绍过,如图 13.2图 13.3 所示。在实现这种关联的过程中,我们会为 Micropost 模型和 User 模型编写一些测试。

micropost belongs to user
图 13.2:微博和所属用户之间的“属于”(belongs_to)关系
user has many microposts
图 13.3:用户和微博之间的“拥有多个”(has_many)关系

使用本节实现的 belongs_to/has_many 关联之后,Rails 会自动创建一些方法,如表 13.1 所示。注意,从表中可知,相较于下面的方法

Micropost.create
Micropost.create!
Micropost.new

我们得到了下面几个方法:

user.microposts.create
user.microposts.create!
user.microposts.build

后者才是创建微博的正确方式,即通过相关联的用户对象创建。通过这种方式创建的微博,其 user_id 属性会自动设为正确的值。所以,我们可以把代码清单 13.4 中的下述代码

@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。

表 13.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 这样的代码能使用,我们要修改 User 模型和 Micropost 模型,添加一些代码,把这两个模型关联起来。代码清单 13.3 中的迁移已经自动添加了 belongs_to :user,如代码清单 13.10 所示。关联的另一端,has_many :microposts,要自己动手添加,如代码清单 13.11 所示。

代码清单 13.10:一篇微博属于(belongs_to)一个用户 GREEN
app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end
代码清单 13.11:一个用户拥有多篇(has_many)微博 GREEN
app/models/user.rb
class User < ApplicationRecord
  has_many :microposts
  .
  .
  .
end

定义好关联后,我们可以修改代码清单 13.4 中的 setup 方法,使用正确的方式创建一个微博对象,如代码清单 13.12 所示。

代码清单 13.12:使用正确的方式创建微博对象 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

当然,经过这次简单的重构后测试组件应该还能通过:

代码清单 13.13GREEN
$ rails test
练习
  1. 把数据库中的第一个用户赋值给 user 变量。运行 micropost = user.microposts.create(content: "Lorem ipsum") 的结果如何?

  2. 前一题应该会在数据库中创建一篇微博。使用 user.microposts.find(micropost.id) 确认这一点。如果传入的是 micropost 而不是 micropost.id,会怎样?

  3. user == micropost.user 的结果是什么?user.microposts.first == micropost 呢?

13.1.4 改进 Micropost 模型

本节,我们要改进一下用户和微博之间的关联:按照特定的顺序取回用户的微博,并且让微博依属于用户,如果用户注销了,就自动删除这个用户发布的所有微博。

默认作用域

默认情况下,user.microposts 不能确保微博的顺序,但是(按照博客和 Twitter 的习惯)我们希望微博按照创建的时间倒序排列,也就是最新发布的微博在前面。为此,我们要使用默认作用域(default scope)。

这样的功能很容易让测试意外通过(就算应用代码不对,测试也能通过),所以我们要使用测试驱动开发技术,确保实现的方式是正确的。首先,我们编写一个测试,检查数据库中的第一篇微博和微博固件中名为 most_recent 的微博相同,如代码清单 13.14 所示。

代码清单 13.14:测试微博的顺序 RED
test/models/micropost_test.rb
require 'test_helper'

class MicropostTest < ActiveSupport::TestCase
  .
  .
  .
  test "order should be most recent first" do
    assert_equal microposts(:most_recent), Micropost.first
  end
end

这段代码要使用微博固件,所以我们要定义固件,如代码清单 13.15 所示。

代码清单 13.15:微博固件
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 自动更新,一般无法手动设置,但在固件中可以这么做。实际上可能不用自己设置这些属性,因为在某些系统中固件会按照定义的顺序创建。在这个文件中,最后一个固件最后创建(因此是最新的一篇微博)。但是绝不要依赖这种行为,因为并不可靠,而且在不同的系统中有差异。

现在,测试组件应该无法通过:

代码清单 13.16RED
$ rails test test/models/micropost_test.rb

我们要使用 Rails 提供的 default_scope 方法让测试通过。这个方法的作用很多,这里我们要用它设定从数据库中检索数据的默认顺序。为了得到特定的顺序,我们要在 default_scope 方法中指定 order 参数,按 created_at 列的值排序,如下所示:

order(:created_at)

可是,这实现的是“升序”,从小到大排列,即最早发布的微博排在最前面。为了让微博降序排列,我们要向下走一层,使用纯 SQL 语句:

order('created_at DESC')

在 SQL 中,DESC 表示“降序”(descending),即新发布的微博在前面。[2]在以前的 Rails 版本中,必须使用纯 SQL 语句才能实现这个需求,但从 Rails 4.0 起,可以使用纯 Ruby 句法实现:

order(created_at: :desc)

把默认作用域加入 Micropost 模型,如代码清单 13.17 所示。

代码清单 13.17:使用 default_scope 排序微博 GREEN
app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

代码清单 13.17 中使用了“箭头”句法,这表示一种对象,叫 Proc(procedure)或 lambda,即匿名函数(anonymous function,没有名称的函数)。-> 接受一个代码块(4.3.2 节),返回一个 Proc,然后在这个 Proc 上调用 call 方法执行其中的代码。我们可以在控制台中看一下怎么使用 Proc:

>> -> { puts "foo" }
=> #<Proc:0x007fab938d0108@(irb):1 (lambda)>
>> -> { puts "foo" }.call
foo
=> nil

(Proc 是相对高级的 Ruby 知识,如果现在不理解也不用担心。)

按照代码清单 13.17 修改之后,测试应该可以通过了:

代码清单 13.18GREEN
$ rails test

依属关系:destroy

除了设定恰当的顺序外,我们还要对 Micropost 模型做一项改进。我们在 10.4 节讲过,管理员有删除用户的权限。那么,在删除用户的同时,有必要把该用户发布的微博也删除。

为此,我们可以把一个选项传给 has_many 关联方法,如代码清单 13.19 所示。

代码清单 13.19:确保用户的微博在删除用户的同时也被删除
app/models/user.rb
class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
  .
  .
  .
end

dependent: :destroy 的作用是在用户被删除的时候,把这个用户发布的微博也删除。这么一来,如果管理员删除了用户,数据库中就不会出现无主的微博了。

我们可以为 User 模型编写一个测试,证明代码清单 13.19 中的代码是正确的。我们要保存一个用户(因此得到了用户的 ID),再创建一个属于这个用户的微博,然后检查删除用户后微博的数量有没有减少一个,如代码清单 13.20 所示。(与代码清单 10.62 中“删除”链接的集成测试对比一下。)

代码清单 13.20:测试 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

如果代码清单 13.19 中的代码正确,测试组件应该依旧能通过:

代码清单 13.21GREEN
$ rails test
练习
  1. Micropost.first.created_atMicropost.last.created_at 的值各是什么?

  2. Micropost.firstMicropost.last 对应的 SQL 查询是什么?提示:查看控制台的输出。

  3. 把数据库中的第一个用户赋值给 user 变量。这个用户的第一篇微博的 ID 是多少?使用 destroy 方法删除数据库中的第一个用户,然后使用 Micropost.find 查找这个用户发布的第一篇微博,确认它也被删除了。

13.2 显示微博

尽管我们还没实现直接在网页中发布微博的功能(将在 13.3.2 节实现),不过还是有办法显示微博(并对显示的内容进行测试)。我们将按照 Twitter 的方式,不在 Microposts 控制器的 index 页面显示用户的微博,而在 Users 控制器的 show 页面显示,构思图如图 13.4 所示。我们将先使用一些简单的 ERb 代码,在用户的资料页面显示微博,然后在 10.3.2 节创建的种子数据中添加一些微博,这样才有内容可以显示。

user microposts mockup 3rd edition
图 13.4:显示有微博的资料页面构思图

13.2.1 渲染微博

我们计划在用户的资料页面(show.html.erb)显示用户的微博,还要显示用户发布了多少篇微博。你会发现,很多做法和 10.3 节列出所有用户时类似。

为了防止你在做练习时添加了微博,现在最好还原数据库,然后重新填充种子数据:

$ rails db:migrate:reset
$ rails db:seed

虽然 13.3 节才会用到 Microposts 控制器,但马上就需要使用视图,所以现在就要生成控制器:

$ rails generate controller Microposts

这一节的主要目的是渲染用户发布的所有微博。10.3.5 节用过这样的代码:

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

这段代码会自动使用局部视图 _user.html.erb 渲染 @users 变量中的每个用户。同样,我们要编写 _micropost.html.erb 局部视图,使用类似的方式渲染微博集合:

<ol class="microposts">
  <%= render @microposts %>
</ol>

注意,我们使用的是有序列表标签 ol(而不是无需列表 ul),因为微博是按照一定顺序显示的(按时间倒序)。相应的局部视图如代码清单 13.22 所示。

代码清单 13.22:显示单篇微博的局部视图
app/views/microposts/_micropost.html.erb
<li id="micropost-<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content"><%= micropost.content %></span>
  <span class="timestamp">
    Posted <%= time_ago_in_words(micropost.created_at) %> ago.
  </span>
</li>

这个局部视图使用了 time_ago_in_words 辅助方法,它的作用应该很明显,效果会在 13.2.2 节看到。代码清单 13.22 还为每篇微博指定了 CSS ID:

<li id="micropost-<%= micropost.id %>">

这是好习惯,说不定以后要处理单篇微博呢(例如使用 JavaScript)。

接下来要解决显示大量微博的问题。我们可以使用 10.3.3 节显示大量用户的方法来解决这个问题,即分页。和前面一样,我们要使用 will_paginate 方法:

<%= will_paginate @microposts %>

如果和用户列表页面的代码(代码清单 10.45)比较的话,你会发现之前使用的代码是:

<%= will_paginate %>

前面之所以可以直接调用,是因为在 Users 控制器中,will_paginate 假定有一个名为 @users 的实例变量(10.3.3 节说过,这个变量所属的类应该是 AvtiveRecord::Relation)。现在,还在 Users 控制器中,但是我们要分页显示微博,所以必须明确地把 @microposts 变量传给 will_paginate 方法。当然了,我们还要在 show 动作中定义 @microposts 变量,如代码清单 13.23 所示。

代码清单 13.23:在 Users 控制器的 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 拥有多少微博。(所有数据库都会对这种操作做性能优化。如果统计数量仍然是应用的性能瓶颈,可以使用计数器缓存(counter cache)进一步提速。)

综上所述,现在可以把微博添加到资料页面中了,如代码清单 13.24 所示。注意,if @user.microposts.any?(在代码清单 7.21 中见过类似的用法)的作用是,当用户没有发布微博时,不显示空列表。

代码清单 13.24:在用户资料页面中加入微博
app/views/users/show.html.erb
<% provide(:title, @user.name) %>
<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      <h1>
        <%= gravatar_for @user %>
        <%= @user.name %>
      </h1>
    </section>
  </aside>
  <div class="col-md-8">
    <% if @user.microposts.any? %>
      <h3>Microposts (<%= @user.microposts.count %>)</h3>
      <ol class="microposts">
        <%= render @microposts %>
      </ol>
      <%= will_paginate @microposts %>
    <% end %>
  </div>
</div>

现在,我们可以查看一下修改后的用户资料页面,如图 13.5 所示。可能会出乎你的意料,不过也是理所当然的,因为现在还没有微博。下面我们就来改变这种状况。

练习

  1. 7.3.3 节说过,在 Rails 控制台中可以通过 helper 对象调用辅助方法。在 helper 对象上调用 time_ago_in_words 方法,以文字的形式显示 3.weeks.ago6.months.ago

  2. helper.time_ago_in_words(1.year.ago) 得到的结果是什么?

  3. 一页微博属于哪个 Ruby 类?提示:参照代码清单 13.23,把 paginate 方法的参数设为 page: nil,然后调用 class 方法。

user profile no microposts 3rd edition
图 13.5:添加显示微博的代码后用户的资料页面,但没有微博

13.2.2 示例微博

13.2.1 节,为了显示用户的微博,我们创建或修改了几个模板,但是结果有点不给力。为了改变这种状况,我们要在 10.3.2 节用到的种子数据中加入一些微博。

为所有用户添加示例微博要花很长时间,所以我们决定只为前六个用户(即自定义了Gravatar的那五个用户,外加一个使用默认Gravatar的用户)添加。为此,要使用 take 方法:

User.order(:created_at).take(6)

调用 order 方法的作用是按照创建用户的顺序查找六个用户。

我们要分别为这六个用户创建 50 篇微博(数量要多于 30 个才能分页)。为了生成微博的内容,我们将使用 Faker gem 提供的 Lorem.sentence 方法。[3]添加示例微博后的种子数据如代码清单 13.25 所示。(代码清单 13.25 之所以采用那种顺序循环,是为了打乱动态流中的微博(14.3 节)。如果先迭代用户,动态流中会批量出现同一个用户发布的微博,视觉效果不好。)

代码清单 13.25:添加示例微博
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

然后,像之前一样重新把种子数据写入开发数据库:

$ rails db:migrate:reset
$ rails db:seed

完成后还要重启 Rails 开发服务器。

现在,我们能看到 13.2.1 节的劳动成果了——用户资料页面显示了微博。[4]初步结果如图 13.6 所示。

user profile microposts no styling 3rd edition
图 13.6:用户资料页面显示的微博,还没添加样式

图 13.6 中显示的微博还没有样式,那我们就加入一些,如代码清单 13.26 所示,[5]然后再看一下页面显示的效果。

代码清单 13.26:微博的样式(包含本章要使用的所有 CSS)
app/assets/stylesheets/custom.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;
  }
}

图 13.7 是第一个用户的资料页面,图 13.8 是另一个用户的资料页面,图 13.9 是第一个用户资料页面的第 2 页,页面下部还显示了分页链接。注意观察这三幅图,可以看到,微博后面显示了距离发布的时间(例如,“Posted 1 minute ago.”),这就是代码清单 13.22time_ago_in_words 方法实现的效果。过一会再刷新页面,这些文字会根据当前时间自动更新。

user profile with microposts 3rd edition
图 13.7:显示有微博的用户资料页面(/users/1)
other profile with microposts 3rd edition
图 13.8:另一个用户的资料页面(/users/5),也显示有微博
user profile microposts page 2 3rd edition
图 13.9:微博分页链接(/users/1?page=2)
练习
  1. 能不能想出 (1..10).to_a.take(6) 的结果是什么?在 Rails 控制台中确认你想的是否正确。

  2. 前一题有必要调用 to_a 方法吗?

  3. Faker 提供了众多有趣的随机数据,参阅 Faker 的文档,学习如何生成虚拟的大学名称虚拟的电话号码虚拟的 Hipster Ipsum 句子虚拟的 Chuck Norris 语录

13.2.3 测试资料页面中的微博

新激活的用户会重定向到资料页面,那时已经测试了资料页面是否能正确渲染(代码清单 11.33)。本节,我们要编写几个简短的集成测试,检查资料页面中的其他内容。首先,生成资料页面的集成测试文件:

$ 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
  .
  .
  .

为了测试微博分页,我们要使用代码清单 10.47 中用到的方法,通过嵌入式 Ruby 代码多生成一些微博固件:

<% 30.times do |n| %>
micropost_<%= n %>:
  content: <%= Faker::Lorem.sentence(5) %>
  created_at: <%= 42.days.ago %>
  user: michael
<% end %>

综上,修改后的微博固件如代码清单 13.27 所示。

代码清单 13.27:关联用户后的微博固件
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 头像、微博数量和分页显示的微博,如代码清单 13.28 所示。注意,为了使用代码清单 4.2 中的 full_title 辅助方法测试页面的标题,我们要把 ApplicationHelper 模块引入测试。[6]

代码清单 13.28:用户资料页面的测试 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.body12.3.3 节练习中见过。别被名字迷惑了,其实 response.body 的值是整个页面的 HTML 源码(不只是 body 元素中的内容)。如果我们只关心页面中某处显示的微博数量,使用下面的断言找到匹配的内容即可:

assert_match @user.microposts.count.to_s, response.body

assert_match 没有 assert_select 的针对性强,无需指定要查找哪个 HTML 标签。

代码清单 13.28 还在 assert_select 中使用了嵌套句法:

assert_select 'h1>img.gravatar'

这行代码的意思是,在顶级标题标签(h1)中查找类为 gravatarimg 标签。

因为应用能正常运行,所以测试组件应该也能通过:

代码清单 13.29GREEN
$ rails test
练习
  1. 代码清单 13.28 中包含 'h1' 的那两行对应的应用代码注释掉,确认测试会失败。

  2. 修改代码清单 13.28,测试分页导航只出现一次。提示:参阅表 5.2

13.3 微博相关的操作

微博的数据模型构建好了,也编写了相关的视图文件,接下来我们的开发重点是,通过网页发布微博。本节,我们将初步实现动态流,第 14 章再完善。最后,和 Users 资源一样,我们还要实现在网页中删除微博的功能。

上述功能的实现和之前的方式有点不同,需要特别注意:Microposts 资源相关的页面不通过 Microposts 控制器实现,而是通过资料页面和首页实现。因此 Microposts 控制器不需要 newedit 动作,只需要 createdestroy 动作。所以,Microposts 资源的路由如代码清单 13.30 所示。代码清单 13.30 中的代码对应的 REST 式路由如表 13.2 所示,这张表中的路由只是表 2.3 的一部分。不过,路由虽然简化了,但预示着实现的过程需要用到更高级的技术,而不会降低代码的复杂度。从第 2 章起我们就十分依赖脚手架,不过现在我们将舍弃脚手架的大部分功能。

代码清单 13.30Microposts 资源的路由
config/routes.rb
Rails.application.routes.draw do
  root   'static_pages#home'
  get    '/help',    to: 'static_pages#help'
  get    '/about',   to: 'static_pages#about'
  get    '/contact', to: 'static_pages#contact'
  get    '/signup',  to: 'users#new'
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
  resources :users
  resources :account_activations, only: [:edit]
  resources :password_resets,     only: [:new, :create, :edit, :update]
  resources :microposts,          only: [:create, :destroy]
end
表 13.2代码清单 13.30 设置 Microposts 资源后得到的 REST 式路由
HTTP 请求 URL 动作 具名路由

POST

/microposts

create

microposts_path

DELETE

/microposts/1

destroy

micropost_path(micropost)

13.3.1 访问限制

开发 Microposts 资源的第一步是在 Microposts 控制器中实现访问限制:若想访问 createdestroy 动作,用户必须先登录。

针对这个要求的测试与 Users 控制器中相应的测试类似(代码清单 10.20代码清单 10.61),我们要使用正确的请求类型访问这两个动作,然后确认微博的数量没有变化,而且会重定向到登录页面,如代码清单 13.31 所示。

代码清单 13.31Microposts 控制器的访问限制测试 RED
test/controllers/microposts_controller_test.rb
require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

  def setup
    @micropost = microposts(:orange)
  end

  test "should redirect create when not logged in" do
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: { 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 micropost_path(@micropost)
    end
    assert_redirected_to login_url
  end
end

在编写让这个测试通过的应用代码之前,先要做些重构。在 10.2.1 节,我们定义了一个前置过滤器 logged_in_user代码清单 10.15),要求访问相关的动作之前用户要先登录。那时,我们只需要在 Users 控制器中使用这个前置过滤器,但是现在也要在 Microposts 控制器中使用,所以要把它移到 Application 控制器中(所有控制器的基类),[7]代码清单 13.32 所示。

代码清单 13.32:把 logged_in_user 方法移到 Application 控制器中 RED
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

为了避免代码重复,同时还要把 Users 控制器中的 logged_in_user 方法删掉,如代码清单 13.33 所示。

代码清单 13.33:删除 logged_in_user 过滤器之后的 Users 控制器 RED
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  .
  .
  .
  private

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

    # 前置过滤器

    # 确保是正确的用户
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end

    # 确保是管理员
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end

现在,我们可以在 Microposts 控制器中使用 logged_in_user 方法了。在 Microposts 控制器中添加 createdestroy 动作,并使用前置过滤器限制访问,如代码清单 13.34 所示。

代码清单 13.34:限制访问 Microposts 控制器的动作 GREEN
app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]

  def create
  end

  def destroy
  end
end

现在,测试组件应该能通过了:

代码清单 13.35GREEN
$ rails test
练习
  1. 如果不删除 Users 控制器中的 logged_in_user 方法,有什么坏处?

13.3.2 创建微博

第 7 章,我们实现了用户注册功能,方法是使用 HTML 表单向 Users 控制器的 create 动作发送 POST 请求。创建微博的功能实现起来类似,主要的不同点是,表单不放在单独的页面 /microposts/new 中,而是在网站的首页(即根地址 /),构思图如图 13.10 所示。

上一次离开首页时,是图 5.8 那个样子,页面中部有个“Sign up now!”按钮。因为创建微博的表单只对登录后的用户有用,所以本节的目标之一是根据用户的登录状态在首页显示不同的内容,如代码清单 13.37 所示。

我们先来编写 Microposts 控制器的 create 动作,它与 Users 控制器的 create 动作类似(代码清单 7.28),二者之间主要的区别是,创建微博时,要使用用户和微博的关联关系构建微博对象,如代码清单 13.36 所示。注意 micropost_params 中的健壮参数,它限制只允许通过 Web 修改微博的 content 属性。

home page with micropost form mockup bootstrap
图 13.10:包含创建微博表单的首页构思图
代码清单 13.36Microposts 控制器的 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

我们使用代码清单 13.37 中的代码编写创建微博所需的表单,这个视图会根据用户的登录状态显示不同的 HTML。

代码清单 13.37:在首页(/)加入创建微博的表单
app/views/static_pages/home.html.erb
<% if logged_in? %>
  <div class="row">
    <aside class="col-md-4">
      <section class="user_info">
        <%= render 'shared/user_info' %>
      </section>
      <section class="micropost_form">
        <%= render 'shared/micropost_form' %>
      </section>
    </aside>
  </div>
<% else %>
  <div class="center jumbotron">
    <h1>Welcome to the Sample App</h1>

    <h2>
      This is the home page for the
      <a href="http://www.railstutorial.org/">Ruby on Rails Tutorial</a>
      sample application.
    </h2>

    <%= link_to "Sign up now!", signup_path, class: "btn btn-lg btn-primary" %>
  </div>

  <%= link_to image_tag("rails.png", alt: "Rails logo"),
              'http://rubyonrails.org/' %>
<% end %>

if-else 条件语句中各分支包含的代码太多,有点乱,在本节的练习中会使用局部视图整理。)

为了让代码清单 13.37 能正常渲染页面,我们要创建几个局部视图。首先是首页的侧边栏,如代码清单 13.38 所示。

代码清单 13.38:用户信息侧边栏局部视图
app/views/shared/_user_info.html.erb
<%= link_to gravatar_for(current_user, size: 50), current_user %>
<h1><%= current_user.name %></h1>
<span><%= link_to "view my profile", current_user %></span>
<span><%= pluralize(current_user.microposts.count, "micropost") %></span>

注意,与用户资料页面的侧边栏一样(代码清单 13.24),代码清单 13.38 中的用户信息也显示了用户发布的微博数量。不过显示的内容有细微的差别,在用户资料页面的侧边栏中,“Microposts” 是标注(label),所以“Microposts (1)”这样的用法是合理的。而在本例中,如果说“1 microposts”的话就不合语法了,所以我们要调用 pluralize 方法(7.3.3 节见过),显示成“1 micropost”“2 microposts”等。

下面我们来编写微博创建表单的局部视图,如代码清单 13.39 所示。这段代码和代码清单 7.15 中的注册表单类似。

代码清单 13.39:微博创建表单局部视图
app/views/shared/_micropost_form.html.erb
<%= form_for(@micropost) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="field">
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
<% end %>

我们还要做两件事,代码清单 13.39 中的表单才能使用。第一,(和之前一样)我们要通过关联定义 @micropost 变量:

@micropost = current_user.microposts.build

把这行代码写入控制器,如代码清单 13.40 所示。

代码清单 13.40:在 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 变量只能在用户登录后再定义。

我们要做的第二件事是,重写错误消息局部视图,让代码清单 13.39 中的这行能用:

<%= render 'shared/error_messages', object: f.object %>

你可能还记得,在代码清单 7.20 中,错误消息局部视图直接引用了 @user 变量,但现在我们提供的变量是 @micropost。为了在两个地方都能使用这个错误消息局部视图,我们可以把表单变量 f 传入局部视图,通过 f.object 获取相应的对象。因此,在 form_for(@user) do |f| 中,f.object@user;在 form_for(@micropost) do |f| 中,f.object@micropost

我们要通过一个散列把对象传入局部视图,值是这个对象,键是局部视图中所需的变量名,如代码清单 13.39 中的第二行所示。换句话说,object: f.object 会创建一个名为 object 的变量,供 error_messages 局部视图使用。通过这个对象,我们可以定制错误消息,如代码清单 13.41 所示。

代码清单 13.41:能使用其他对象的错误消息局部视图 RED
app/views/shared/_error_messages.html.erb
<% if object.errors.any? %>
  <div id="error_explanation">
    <div class="alert alert-danger">
      The form contains <%= pluralize(object.errors.count, "error") %>.
    </div>
    <ul>
    <% object.errors.full_messages.each do |msg| %>
      <li><%= msg %></li>
    <% end %>
    </ul>
  </div>
<% end %>

现在,你应该确认一下测试组件无法通过:

代码清单 13.42RED
$ rails test

这提醒我们要修改其他使用错误消息局部视图的视图,包括用户注册视图(代码清单 7.20)、重设密码视图(代码清单 12.14)和用户编辑视图(代码清单 10.2)。这三个视图修改后的版本分别如代码清单 13.43代码清单 13.45代码清单 13.44 所示。

代码清单 13.43:修改用户注册表单,改变渲染错误消息局部视图的方式
app/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages', 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 %>
  </div>
</div>
代码清单 13.44:修改编辑用户表单,改变渲染错误消息局部视图的方式
app/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= 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 %>

    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails">change</a>
    </div>
  </div>
</div>
代码清单 13.45:修改密码重设表单,改变渲染错误消息局部视图的方式
app/views/password_resets/edit.html.erb
<% provide(:title, 'Reset password') %>
<h1>Password reset</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= 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 %>
  </div>
</div>

现在,所有测试应该都能通过了:

$ rails test

此外,本节添加的所有 HTML 代码也都能正确渲染了。图 13.11 是创建微博的表单,图 13.12 显示提交表单后有一个错误。

练习
  1. 重构首页视图,把 if-else 语句的两个分支分别放到单独的局部视图中。

home with form 3rd edition
图 13.11:包含创建微博表单的首页
home form errors 3rd edition
图 13.12:显示一个错误消息的首页

13.3.3 动态流原型

现在创建微博的表单可以使用了,但是用户看不到实际效果,因为首页没有显示微博。如果你愿意的话,可以在图 13.11 所示的表单中发表一篇有效的微博,然后打开用户资料页面,验证一下这个表单是否可以正常使用。这样在页面之间来来回回有点麻烦,如果能在首页显示一个含有当前登入用户的微博列表(动态流)就好了,构思图如图 13.13 所示。(在第 14 章,我们会在这个微博列表中加入当前用户所关注用户发表的微博,就像 Twitter 那样。)

proto feed mockup 3rd edition
图 13.13:显示有动态流原型的首页构思图

因为每个用户都有一个动态流,因此我们可以在 User 模型中定义一个名为 feed 的方法,查找当前用户发表的所有微博。我们要在 Micropost 模型上调用 where 方法(11.3.3 节练习提到过)查找微博,如代码清单 13.46 所示。[8]

代码清单 13.46:初步实现微博动态流
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 实现动态流原型
  # 完整的实现参见第 14 章
  def feed
    Micropost.where("user_id = ?", id)
  end

    private
    .
    .
    .
end

Micropost.where("user_id = ?", id) 中的问号确保 id 的值在传入底层的 SQL 查询语句之前做了适当的转义,从而避免SQL 注入(SQL injection)这种严重的安全隐患。这里用到的 id 属性是个整数(即 self.id,用户的唯一 ID),没什么危险,不过在 SQL 语句中引入变量之前做转义是个好习惯。

细心的读者可能已经注意到了,代码清单 13.46 中的代码和下面的代码是等效的:

def feed
  microposts
end

我们之所以使用代码清单 13.46 中的版本,是因为它能更好的服务于第 14 章实现的完整动态流。

要在这个演示应用中添加动态流,我们可以在 home 动作中定义一个 @feed_items 实例变量,分页获取当前用户的微博,如代码清单 13.47 所示。然后在首页(参见代码清单 13.49)中加入一个动态流局部视图(参见代码清单 13.48)。注意,现在用户登录后要执行两行代码,所以代码清单 13.47代码清单 13.40 中的

@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 语句。

代码清单 13.47:在 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
代码清单 13.48:动态流局部视图
app/views/shared/_feed.html.erb
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

动态流局部视图使用如下的代码,把单篇微博交给代码清单 13.22 中定义的局部视图渲染:

<%= render @feed_items %>

Rails 知道要渲染 micropost 局部视图,因为 @feed_items 中的元素都是 Micropost 类的实例。所以,Rails 会在对应资源的视图文件夹中寻找正确的局部视图,即:

app/views/microposts/_micropost.html.erb

和之前一样,我们可以把动态流局部视图加入首页,如代码清单 13.49 所示。加入后的效果就是在首页显示动态流,这实现了我们的需求,如图 13.14 所示。

代码清单 13.49:在首页加入动态流
app/views/static_pages/home.html.erb
<% if logged_in? %>
  <div class="row">
    <aside class="col-md-4">
      <section class="user_info">
        <%= render 'shared/user_info' %>
      </section>
      <section class="micropost_form">
        <%= render 'shared/micropost_form' %>
      </section>
    </aside>
    <div class="col-md-8">
      <h3>Micropost Feed</h3>
      <%= render 'shared/feed' %>
    </div>
  </div>
<% else %>
  .
  .
  .
<% end %>

现在,发布新微博的功能可以按预期使用了,如图 13.15 所示。不过还有个小小的不足:如果发布微博失败,首页还是需要一个名为 @feed_items 的实例变量,所以提交失败时网站无法正常运行。最简单的解决方法是,如果提交失败就把 @feed_items 设为空数组,如代码清单 13.50 所示。(但是这么做分页链接就失效了,你可以点击分页链接,看一下是什么原因。)

代码清单 13.50:在 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
home with proto feed 3rd edition
图 13.14:显示有动态流原型的首页
micropost created 3rd edition
图 13.15:发布新微博后的首页
练习
  1. 使用刚添加的 UI 发布第一篇真实的微博。打开服务器日志,查看 INSERT 命令的内容是什么。

  2. 打开 Rails 控制台,把数据库中的第一个用户赋值给 user 变量。确认 Micropost.where("user_id = ?", user.id)user.micropostsuser.feed 的结果一样。提示:或许直接使用 == 运算符比较最简单。

13.3.4 删除微博

我们要为 Microposts 资源实现的最后一个功能是删除。与删除用户类似(10.4.2 节),删除微博也要通过“删除”链接实现,构思图如图 13.16 所示。只有管理员才能删除用户,而只有发布人自己才能删除微博。

首先,我们要在单篇微博局部视图(代码清单 13.22)中加入删除链接,如代码清单 13.51 所示。

代码清单 13.51:在单篇微博局部视图中添加删除链接
app/views/microposts/_micropost.html.erb
<li id="<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content"><%= micropost.content %></span>
  <span class="timestamp">
    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 %>
  </span>
</li>

然后,参照 Users 控制器的 destroy 动作(代码清单 10.59),编写 Microposts 控制器的 destroy 动作。在 Users 控制器中,我们在 admin_user 前置过滤器中定义了 @user 变量,用于查找用户;但现在要通过关联查找微博,这样,如果某个用户试图删除其他用户的微博,会自动失败。我们把查找微博的操作放在 correct_user 前置过滤器中,确保当前用户确实拥有指定 ID 的微博,如代码清单 13.52 所示。

代码清单 13.52Microposts 控制器的 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 [9] 和实现友好转向时使用的 request.original_url 关系紧密,表示前一个 URL(这里是首页)。[10]因为首页和资料页面都有微博,所以这么做很方便,我们使用 request.referrer 把用户重定向到发起删除请求的页面,如果 request.referrernil(例如在某些测试中),就转向 root_url。(可以和代码清单 9.24 中设置选项默认值的用法对比一下。)

添加上述代码后,删除最新发布的第二篇微博后显示的页面如图 13.17 所示。

home post delete 3rd edition
图 13.17:删除最新发布的第二篇微博后显示的首页
练习
  1. 发布一篇微博,然后把它删除。打开服务器日志,DELETE 命令的内容是什么?

  2. redirect_to request.referrer || root_url 换成 redirect_back(fallback_location: root_url),在浏览器中确认可以这么改。(redirect_back 是 Rails 5 新增的方法。)

13.3.5 微博的测试

至此,Micropost 模型和相关的界面完成了。我们还要编写简短的 Microposts 控制器测试,检查权限限制,以及一个集成测试,检查整个操作流程。

首先,在微博固件中添加一些由不同用户发布的微博,如代码清单 13.53 所示。(现在只需要使用一个微博固件,不过还是要多添加几个,以备后用。)

代码清单 13.53:添加几个由不同用户发布的微博
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

然后,编写一个简短的测试,确保用户不能删除其他用户的微博,并且要重定向到正确的地址,如代码清单 13.54 所示。

代码清单 13.54:测试用户不能删除其他用户的微博 GREEN
test/controllers/microposts_controller_test.rb
require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

  def setup
    @micropost = microposts(:orange)
  end

  test "should redirect create when not logged in" do
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: { 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 micropost_path(@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 micropost_path(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

这个测试的代码如代码清单 13.55 所示。看看你能否把代码和前面说的步骤对应起来。

代码清单 13.55:微博界面的集成测试 GREEN
test/integration/microposts_interface_test.rb
require 'test_helper'

class MicropostsInterfaceTest < 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, params: { 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, params: { 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

因为我们已经把可以正常运行的应用开发好了,所以测试组件应该可以通过:

代码清单 13.56GREEN
$ rails test
练习
  1. 代码清单 13.55 中以注释表明了的四种情况(从“# 无效提交”开始),把各种情况对应的应用代码注释掉,确认测试会失败,然后再把注释去掉,让测试通过。

  2. 为侧边栏中的微博数量编写测试(还要检查使用了正确的单复数形式)。可以参照代码清单 13.57

代码清单 13.57:侧边栏中微博数量的测试模板
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

13.4 微博中的图像

我们已经实现了微博相关的所有操作,本节要让微博除了能输入文字之外还能插入图像。我们首先会开发一个基础版本,只能在开发环境中使用,然后再做一系列功能增强,允许在生产环境上传图像。

添加图像上传功能明显要完成两件事:编写用于上传图像的表单,准备好所需的图像。上传图像按钮和微博中显示的图像构思如图 13.18 所示。[11]

micropost image mockup
图 13.18:图像上传界面的构思图(上传了一张图像)

13.4.1 基本的图像上传功能

我们要使用 CarrierWave 处理图像上传,并把图像与 Micropost 模型关联起来。为此,我们要在 Gemfile 文件中添加 carrierwavemini_magick 两个 gem,如代码清单 13.58 所示。为了一次安装完所有 gem,代码清单 13.58 还包含在生产环境中上传图像所需的 fog gem(13.4.4 节)。

代码清单 13.58:在 Gemfile 文件中添加 CarrierWave
source 'https://rubygems.org'

gem 'rails',                   '5.1.6'
gem 'bcrypt',                  '3.1.12'
gem 'faker',                   '1.7.3'
gem 'carrierwave',             '1.2.2'
gem 'mini_magick',             '4.7.0'
gem 'will_paginate',           '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'
.
.
.
group :production do
  gem 'pg',  '0.20.0'
  gem 'fog', '1.42'
end

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

$ bundle install

CarrierWave 自带了一个 Rails 生成器,用于生成图像上传程序。我们要创建一个名为 picture 的上传程序:[12]

$ rails generate uploader Picture

CarrierWave 上传的图像应该对应于 Active Record 模型中的一个属性,这个属性只需存储图像的文件名字符串即可。添加这个属性后的 Micropost 模型如图 13.19 所示。

micropost model picture
图 13.19:添加 picture 属性后的 Micropost 数据模型

为了把 picture 属性添加到 Micropost 模型中,我们要生成一个迁移,然后在开发数据库中执行迁移:

$ rails generate migration add_picture_to_microposts picture:string
$ rails db:migrate

告诉 CarrierWave 把图像和模型关联起来的方式是使用 mount_uploader 方法。这个方法的第一个参数是属性的符号形式,第二个参数是上传程序的类名:

mount_uploader :picture, PictureUploader

PictureUploader 类在 picture_uploader.rb 文件中定义,13.4.2 节会修改,现在使用生成的默认内容即可。)把这个上传程序添加到 Micropost 模型,如代码清单 13.59 所示。

代码清单 13.59:在 Micropost 模型中添加图像上传程序
app/models/micropost.rb
class Micropost < ApplicationRecord
  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 服务器,测试组件才能通过。(如果你按照 3.6.2 节所讲,使用了 Guard,可能还要重启 Guard。或许,甚至还要退出终端,然后重新启动 Guard。)

图 13.18 所示,为了在首页添加图像上传功能,我们要在发布微博的表单中添加一个 file_field 标签,如代码清单 13.60 所示。[13]

代码清单 13.60:在发布微博的表单中添加图像上传按钮
app/views/shared/_micropost_form.html.erb
<%= form_for(@micropost) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="field">
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
  <span class="picture">
    <%= f.file_field :picture %>
  </span>
<% end %>

最后,我们要把 picture 添加到可通过 Web 修改的属性列表中。为此,要修改 micropost_params 方法,如代码清单 13.61 所示。

代码清单 13.61:把 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 辅助方法渲染图像,如代码清单 13.62 所示。注意,我们使用 picture? 布尔值方法,如果没有图像就不显示 img 标签。这个方法由 CarrierWave 自动创建,方法名根据保存图像文件名的属性而定。自己动手上传图像后显示的页面如图 13.20 所示。针对图像上传功能的测试留作练习

代码清单 13.62:在微博中显示图像
app/views/microposts/_micropost.html.erb
<li id="micropost-<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content">
    <%= micropost.content %>
    <%= image_tag micropost.picture.url if micropost.picture? %>
  </span>
  <span class="timestamp">
    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 %>
  </span>
</li>
microposts with image 4th ed
图 13.20:发布包含图像的微博后显示的页面
练习
  1. 发表一篇包含图像的微博。图像是不是太大了?(如果是,先别急,我们会在 13.4.3 节解决这个问题。)

  2. 代码清单 13.63 为模板,为 13.4 节的图像上传程序编写测试。测试之前,要在固件文件夹中放一个图像(例如,可以执行 cp app/assets/images/rails.png test/fixtures/ 命令)。代码清单 13.63 中添加的几个断言用于检查首页有没有文件上传字段,以及成功提交表单后有没有正确设定 picture 属性的值。注意,在测试中上传固件中的文件使用的是专门的 fixture_file_upload 方法。[14]提示:为了检查 picture 属性的值,可以使用 11.3.3 节提到的 assigns 方法,在提交成功后获取 create 动作中的 @micropost 变量。

代码清单 13.63:测试图像上传功能的模板
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, params: { 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, params: { 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

13.4.2 验证图像

13.4.1 节添加的上传程序是个好的开始,但有一定不足:没对上传的文件做任何限制,如果用户上传的文件很大,或者类型不对,会导致问题。这一节我们要修正这个不足,添加验证,限制图像的大小和类型。我们既会在服务器端添加验证,也会在客户端(即浏览器)添加验证。

对图像类型的限制在 CarrierWave 的上传程序中设置。我们要限制能使用的图像扩展名(PNG、GIF 和 JPEG 的两个变种),如代码清单 13.64 所示。(在生成的上传程序中有一段注释说明了该怎么做。)

代码清单 13.64:限制可上传图像的类型
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_whitelist
    %w(jpg jpeg gif png)
  end
end

图像大小的限制在 Micropost 模型中设定。和前面用过的模型验证不同,Rails 没有为文件大小提供现成的验证方法,所以我们要自己定义。我们把这个方法命名为 picture_size,如代码清单 13.65 所示。注意,调用自定义的验证时使用的是 validate 方法,而不是 validates

代码清单 13.65:添加图像大小验证
app/models/micropost.rb
class Micropost < ApplicationRecord
  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(使用旁注 9.1 介绍的句法),就向 errors 集合(6.2.2 节简介过)添加一个自定义的错误消息。

除了这两个验证之外,我们还要在客户端检查上传的图像。首先,我们在 file_field 方法中使用 accept 参数限制图像的格式:

<%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %>

有效的格式使用 MIME 类型指定,这些类型对应于代码清单 13.64 中限制使用的类型。

然后,我们要编写一些 JavaScript 代码(更确切地说是 jQuery 代码),如果用户试图上传太大的图像就弹出一个提示框(免得浪费时间上传,还能减轻服务器的压力):

$('#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,参见代码清单 13.60),当这个元素的内容变化时,执行这段代码,如果文件太大,就调用 alert 方法。[15]

把这两个检查措施添加到微博表单中,如代码清单 13.66 所示。[16]

代码清单 13.66:使用 jQuery 检查文件的大小
app/views/shared/_micropost_form.html.erb
<%= form_for(@micropost) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="field">
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
  <span class="picture">
    <%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %>
  </span>
<% end %>

<script type="text/javascript">
  $('#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.');
    }
  });
</script>

上传一个过大的文件试试你就知道,代码清单 13.66 中的代码并不能清除文件输入字段,用户可以关闭弹出框,继续上传文件。如果这是一本关于 jQuery 的书,我们或许会增强一下,修正这个瑕疵,但是要知道,像代码清单 13.66 这样的代码并不能阻止用户上传大文件。即使我们在 JavaScript 代码中清除了文件输入字段,用户还可以使用 Web 审查工具修改 JavaScript,或者直接发送 POST 请求(例如,使用 curl)。为了阻止用户上传大文件,必须在服务器端添加如代码清单 13.65 所示的验证。

练习
  1. 如果尝试上传超过 5MB 的图像,会发生什么?

  2. 如果尝试上传无效的图像类型,会发生什么?

13.4.3 调整图像尺寸

13.4.2 节对图像大小的限制是个好的开始,不过用户还是可以上传尺寸很大的图像,撑破网站的布局,有时会把网站搞得一团糟,如图 13.21 所示。因此,如果允许用户从本地磁盘中上传尺寸很大的图像,最好在显示图像之前调整图像的尺寸。[17]

large uploaded image 4th ed
图 13.21:上传了一张超级大的图像

我们要使用 ImageMagick 调整图像的尺寸,所以在开发环境中要安装这个程序。(如 13.4.4 节所示,Heroku 在生产环境已经预装了 ImageMagick。)在云端 IDE 中可以使用下面的命令安装:[18]

$ sudo yum install -y ImageMagick

然后,我们要在 CarrierWave 中引入 MiniMagick 为 ImageMagick 提供的接口,还要调用一个调整尺寸的方法。MiniMagick 的文档中列出了多个调整尺寸的方法,我们要使用的是 resize_to_limit: [400, 400],如果图像很大,把它调整为宽和高都不超过 400 像素,而小于这个尺寸的图像则不做调整。(CarrierWave 文档中列出的方法会把小图片放大,这不是我们需要的效果。)添加代码清单 13.67 中的代码后,就能完美调整大尺寸图像了,如图 13.22 所示。

resized image 4th ed
图 13.22:调整尺寸后的图像
代码清单 13.67:配置图像上传程序,调整图像的尺寸
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_whitelist
    %w(jpg jpeg gif png)
  end
end
练习
  1. 上传一张大图,确认应用能正确地调整尺寸。如果图像不是方形的,还能调整尺寸吗?

  2. 如果你完成了代码清单 13.63 中的图像上传测试,现在测试组件可能会报告一个让人摸不着头脑的错误消息。为了修正这个问题,要使用代码清单 13.68 中的初始化文件配置 CarrierWave,指定在测试中不调整图像的尺寸。

代码清单 13.68:指定在测试中不调整图像尺寸的初始化文件
config/initializers/skip_image_resizing.rb
if Rails.env.test?
  CarrierWave.configure do |config|
    config.enable_processing = false
  end
end

13.4.4 在生产环境中上传图像

13.4.3 节实现的图像上传程序在开发环境中用起来不错,但图像都存储在本地文件系统中(如代码清单 13.67storage :file 那行所示),在生产环境这么做可不好。[19]所以,我们要使用云存储服务存储图像,把图像与应用所在的文件系统分开。[20]

我们要使用 fog gem 配置应用,在生产环境使用云存储,如代码清单 13.69 所示。

代码清单 13.69:配置生产环境使用的图像上传程序
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_whitelist
    %w(jpg jpeg gif png)
  end
end

代码清单 13.69 中,我们使用旁注 7.1 中介绍的 production? 布尔值方法根据所在的环境选择存储方式:

if Rails.env.production?
  storage :fog
else
  storage :file
end

云存储服务很多,我们要使用其中一个最受欢迎并且支持比较好的——Amazon 的 Simple Storage Service(简称 S3)。[21]基本步骤如下:

  1. 如果没有 Amazon Web Services 账户,注册一个;如果在 1.2.1 节已经注册了 Cloud9 IDE,那就已经有 AWS 账户了,请跳过这一步;

  2. 通过 AWS Identity and Access Management(简称 IAM) 创建一个用户,记下访问公钥和密钥;

  3. 使用 AWS Console 创建一个 S3 bucket(名称自己定),然后赋予上一步创建的用户读写权限。

关于这些步骤的详细说明,参见 S3 的文档。(如果需要可以在 Google 中搜索,或者在 Stack Overflow 中提问。)

创建并配置好 S3 账户后,创建 CarrierWave 配置文件,写入代码清单 13.70 中的内容。

注意:如果做了这些设置之后连不上 S3,可能是区域位置的问题。有些用户可能要在 fog 的凭据中添加 :region => ENV['S3_REGION'],然后在命令行中执行 heroku config:set S3_REGION=<bucket_region>,其中 bucket_region 是你所在的区域,例如 'eu-central-1'。如果想找到你所在的区域,请查看 Amazon AWS 的文档

代码清单 13.70:配置 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

与生产环境的电子邮件配置一样(代码清单 11.41),代码清单 13.70 也使用 Heroku 中的 ENV 变量,没直接在代码中写入敏感信息。在 11.4 节,电子邮件所需的变量由 SendGrid 扩展自动定义,但现在我们要自己定义,方法是使用 heroku config:set 命令,如下所示:

$ heroku config:set S3_ACCESS_KEY=<access key>
$ heroku config:set S3_SECRET_KEY=<secret key>
$ heroku config:set S3_BUCKET=<bucket name>

配置好之后,我们可以提交并部署了。我建议你像代码清单 13.71 那样更新 .gitignore 文件,忽略保存上传图像的目录。

代码清单 13.71:在 .gitignore 文件中添加保存上传图像的目录
.
.
.
# 忽略上传的测试图像
/public/uploads

我们先提交主题分支中的变动,然后再合并到主分支:

$ rails 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 rails db:migrate
$ heroku run rails db:seed

Heroku 已经安装了 ImageMagick,所以在生产环境中调整图像尺寸和上传功能都能正常使用,如图 13.23 所示。

练习
  1. 在生产环境中上传一张大图,确认能调整图像的尺寸。如果图像不是方形的,还能调整图像的尺寸吗?

image upload production 4th ed
图 13.23:在生产环境中上传的图像

13.5 小结

实现 Microposts 资源后,我们的演示应用基本上完成了。现在还剩下社交功能没有实现,即让用户之间可以相互关注。在第 14 章,我们将学习如何实现用户之间的这种关系,届时还将实现一个真正的动态流。

如果你跳过了 13.4.4 节,在继续之前,先提交改动,然后再合并:

$ rails 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 rails db:migrate
$ heroku run rails db:seed

值得注意的是,这一章安装了需要的最后几个 gem。为了便于参考,下面列出完整的 Gemfile 文件,如代码清单 13.72 所示。

代码清单 13.72:演示应用的 Gemfile 文件完整版本
source 'https://rubygems.org'

gem 'rails',                   '5.1.6'
gem 'bcrypt',                  '3.1.12'
gem 'faker',                   '1.7.3'
gem 'carrierwave',             '1.2.2'
gem 'mini_magick',             '4.7.0'
gem 'will_paginate',           '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'
gem 'bootstrap-sass',          '3.3.7'
gem 'puma',                    '3.9.1'
gem 'sass-rails',              '5.0.6'
gem 'uglifier',                '3.2.0'
gem 'coffee-rails',            '4.2.2'
gem 'jquery-rails',            '4.3.1'
gem 'turbolinks',              '5.0.1'
gem 'jbuilder',                '2.7.0'

group :development, :test do
  gem 'sqlite3', '1.3.13'
  gem 'byebug',  '9.0.6', platform: :mri
end

group :development do
  gem 'web-console',           '3.5.1'
  gem 'listen',                '3.1.5'
  gem 'spring',                '2.0.2'
  gem 'spring-watcher-listen', '2.0.1'
end

group :test do
  gem 'rails-controller-testing', '1.0.2'
  gem 'minitest',                 '5.10.3'
  gem 'minitest-reporters',       '1.1.14'
  gem 'guard',                    '2.14.1'
  gem 'guard-minitest',           '2.4.6'
end

group :production do
  gem 'pg',  '0.20.0'
  gem 'fog', '1.42'
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

13.5.1 本章所学

  • 和用户一样,微博也是一种资源,而且有对应的 Active Record 模型;

  • Rails 支持多键索引;

  • 我们可以分别在 UserMicropost 模型中使用 has_manybelongs_to 方法实现一个用户拥有多篇微博的模型;

  • has_many/belongs_to 会创建很多方法,能通过关联创建对象;

  • user.microposts.build(…​) 创建一个微博对象,并自动把微博与用户关联起来;

  • Rails 支持使用 default_scope 指定默认排序方式;

  • 作用域方法的参数是匿名函数;

  • 加入 dependent: :destroy 选项后,删除对象时也会把关联的对象删除;

  • 分页和数量统计都可以通过关联调用,这样写出的代码很简洁;

  • 在固件中可以创建关联;

  • 可以向 Rails 局部视图中传入变量;

  • 查询 Active Record 模型时可以使用 where 方法;

  • 通过关联创建和销毁对象有安全保障;

  • 可以使用 CarrierWave 上传图像及调整图像的尺寸。

  1. 外键引用是数据库层约束,指明 microposts 表中的用户 ID 指代 users 表中的 id 列。这个细节对本教程来说不重要,而且不是所有数据库都支持外键约束。(我们在生产环节中使用的 PostgreSQL 支持,但是开发环境使用的 SQLite 数据库适配器不支持。)14.1.2 节将进一步学习外键。
  2. SQL 不区分大小写,但是习惯把 SQL 关键字(如 DESC)写成全大写。
  3. Faker::Lorem.sentence 生成 lorem ipsum 文本。第 6 章说过,lorem ipsum 的背景故事很有趣
  4. Faker 生成的 lorem ipsum 文本是随机的,所以你看到的示例微博可能和我的不一样。
  5. 为了方便,代码清单 13.26 实际上包含了本章用到的所有 CSS。
  6. 如果也想使用 full_title 重构其他测试,例如代码清单 3.30,应该在 test_helper.rb 文件中引入 ApplicationHelper 模块。
  7. 注意,与 Java 或 C++ 等语言不同,Ruby 中的私有方法可以在衍生的类中调用。感谢读者 Vishal Antony 让我注意到这个区别。
  8. where 及其相关方法的详细说明参见 Rails 指南中的“Active Record Query Interface”一文。
  9. 对应 HTTP 规范中的 HTTP_REFERER。注意,“REFERER”不是错误拼写,规范中就是这么写的。Rails 更正了这个错误,写成“referrer”。
  10. 我没有立即想到如何在 Rails 应用中获取这个 URL,在 Google 中搜索“rails request previous url”之后找到了 Stack Overflow 中的这个问答
  11. 沙滩图像的来源:https://www.flickr.com/photos/grungepunk/14026922186。
  12. 一开始我把这个属性命名为 image,但这个名字太泛泛了,容易误解。
  13. 使用 form_tag 时,为了处理文件上传,要加上 html: { multipart: true } 选项,但是使用 form_for 时,Rails 自动添加多部分表单数据编码类型。感谢读者 Alan Cruz 让我注意到这个细节。
  14. 如果使用 Windows 系统,要加上 :binary 参数:fixture_file_upload(file, type, :binary)
  15. 如果想知道怎么实现这样的效果,可以在 Google 中搜索“javascript maximum file size”,你会在 Stack Overflow 中找到答案的。
  16. jQuery 高级用户可能会把大小检查放在单独的 JavaScript 函数中,但这不是 JavaScript 教程,像代码清单 13.66 那样做就行了。
  17. 使用 CSS 能限制图像显示的尺寸,但无法改变图像本身的尺寸。一般来说,尺寸大的图像加载时间要久一些。(你可能访问过一些网站,图像看着虽小,但是加载时间很长。原因就是如此。)
  18. 如果你没使用云端 IDE,或其他类似的 Linux 系统,可以在 Google 中搜索“imagemagick <your platform>”。在 macOS 中,如果安装了 Homebrew,可以执行 brew install imagemagick 命令安装。
  19. 坏处有很多,其中一个是:Heroku 中存储的文件是临时的,重新部署后会把以前上传的图像删除。
  20. 这一节有一定难度,可以跳过,对后面的内容没有影响。
  21. S3 是收费服务,不过测试这个演示应用,每月的花费不会超过一美分。