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

第 6 章 用户建模

第 5 章末尾创建了一个临时的用户注册页面(5.4 节)。本书接下来的六章会逐步在这个页面中添加功能。本章我们将迈出关键的一步,创建网站中用户的数据模型(data model),并实现存储数据的方式。第 7 章会实现用户注册功能,并创建用户资料页面。用户能注册后,我们将实现登录和退出功能(第 8 章第 9 章)。第 10 章10.2.1 节)会介绍如何保护页面,禁止无权限的用户访问。最后,在第 11 章第 12 章实现账户激活(从而确认电子邮件地址有效)和密码重设功能。第 6 章第 12 章的内容结合在一起,为 Rails 应用开发一个功能完整的登录和身份验证系统。你或许知道已经有很多开发好的 Rails 身份验证方案,旁注 6.1会告诉你为什么至少在初学阶段,最好自己动手实现。

6.1 User 模型

接下来的三章要实现网站的“注册”页面(构思图如图 6.1 所示),在此之前我们要先解决存储问题,因为现在还没地方存储用户信息。所以,实现用户注册功能的第一步是,创建一个数据结构,用于存取用户的信息。

signup mockup bootstrap
图 6.1:用户注册页面的构思图

在 Rails 中,数据模型的默认数据结构叫模型(model,MVC 中的 M,参见 1.3.3 节)。Rails 为解决数据持久化提供的默认解决方案是,使用数据库存储需要长期使用的数据。与数据库交互默认使用的是 Active Record。[1]Active Record 提供了一系列方法,无需使用关系数据库所用的结构化查询语言(Structured Query Language,简称 SQL),[2]就能创建、保存和查询数据对象。Rails 还支持迁移(migration)功能,允许我们使用纯 Ruby 代码定义数据结构,而不用学习 SQL 数据定义语言(Data Definition Language,简称 DDL)。最终的结果是,Active Record 把你和数据库完全隔开了。本书开发的应用在本地使用 SQLite,部署后使用 PostgreSQL(由 Heroku 提供,参见 1.5 节)。这就引出了一个更深层的话题——在不同的环境中,即便使用不同类型的数据库,我们也无需关心 Rails 是如何存储数据的。

和之前一样,如果使用 Git 做版本控制,现在应该新建一个主题分支,用于建模用户:

$ git checkout -b modeling-users

6.1.1 数据库迁移

回顾一下 4.4.5 节的内容,我们在自己创建的 User 类中为用户对象定义了 nameemail 两个属性。那是个很有用的例子,但没有实现持久化存储最关键的要求:在 Rails 控制台中创建的用户对象,退出控制台后就会消失。本节的目的是为用户创建一个模型,让用户数据不会这么轻易消失。

4.4.5 节中定义的 User 类一样,我们先为 User 模型创建两个属性,分别为 nameemail。我们会把 email 属性用作唯一的用户名。[3]6.3 节会添加一个属性,用于存储密码。)在代码清单 4.17 中,我们使用 Ruby 的 attr_accessor 方法创建了这两个属性:

class User
  attr_accessor :name, :email
  .
  .
  .
end

不过,在 Rails 中不用这样定义属性。前面提到过,Rails 默认使用关系数据库存储数据,数据库中的表由数据行(row)组成,每一行都有相应的列(column),对应于数据属性。例如,为了存储用户的名字和电子邮件地址,我们要创建 users 表,表中有两个列,nameemail,这样每一行就表示一个用户,如图 6.2 所示,对应的数据模型如图 6.3 所示。(图 6.3 只是梗概,完整的数据模型如图 6.4 所示。)把列命名为 nameemail 后,Active Record 会自动把它们识别为用户对象的属性。

users table
图 6.2users 表中的示例数据
user model sketch
图 6.3User 数据模型梗概

你可能还记得,在代码清单 5.38 中,我们使用下面的命令生成了 Users 控制器和 new 动作:

$ rails generate controller Users new

创建模型有个类似的命令——generate model。我们可以使用这个命令生成 User 模型,以及 nameemail 属性,如代码清单 6.1 所示。

代码清单 6.1:生成 User 模型
$ rails generate model User name:string email:string
      invoke  active_record
      create    db/migrate/20160523010738_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml

(注意,控制器名是复数,模型名是单数:控制器是 Users,而模型是 User。)我们指定了可选的参数 name:stringemail:string,告诉 Rails 我们需要的两个属性是什么,以及各自的类型(两个都是字符串)。你可以把这两个参数与代码清单 3.6代码清单 5.38 中的动作名对比一下,看看有什么不同。

执行上述 generate 命令之后,会生成一个迁移文件。迁移是一种递进修改数据库结构的方式,可以根据需求修改数据模型。执行上述 generate 命令后会自动为 User 模型创建迁移,这个迁移的作用是创建一个 users 表,以及 nameemail 两个列,如代码清单 6.2 所示。(我们会在 6.2.5 节介绍如何手动创建迁移文件。)

代码清单 6.2User 模型的迁移文件(创建 users 表)
db/migrate/[timestamp]_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.0]
  def change
    create_table :users do |t|
      t.string :name
      t.string :email

      t.timestamps
    end
  end
end

注意,迁移文件名前面有个时间戳(timestamp),指明创建的时间。早期,迁移文件名的前缀是递增的数字,在团队协作中,如果多个程序员生成了序号相同的迁移文件就可能会发生冲突。除非两个迁移文件在同一秒钟生成这种小概率事件发生了,否则使用时间戳基本可以避免冲突。

迁移文件中有一个名为 change 的方法,定义要对数据库做什么操作。在代码清单 6.2 中,change 方法使用 Rails 提供的 create_table 方法在数据库中新建一个表,用于存储用户。create_table 方法可以接受一个块,有一个块变量 t(“table”)。在块中,create_table 方法通过 t 对象在数据库中创建 nameemail 两个列,二者均为 string 类型。[4]表名是复数形式(users),不过模型名是单数形式(User),这是 Rails 在用词上的一个约定:模型表示单个用户,而数据库表中存储了很多用户。块中最后一行 t.timestamps 是个特殊的方法,它会自动创建 created_atupdated_at 两个列,分别记录创建用户的时间戳和更新用户的时间戳。(6.1.3 节有使用这两个列的例子。)这个迁移文件表示的完整数据模型如图 6.4 所示。(注意,图 6.3 中没有列出自动添加的两个时间戳列。)

user model initial 3rd edition
图 6.4代码清单 6.2 生成的 User 数据模型

我们可以使用如下的 db:migrate 命令执行这个迁移(这叫“向上迁移”):

$ rails db:migrate

(你可能还记得,我们在 2.2 节用过这个命令。)第一次运行 db:migrate 命令时会创建 db/development.sqlite3 文件,这是 SQLite [5]数据库文件。若想查看数据库结构,可以使用 SQLite 数据库浏览器打开 db/development.sqlite3 文件,如图 6.5 所示。(如果使用云端 IDE,要先把数据库文件下载到本地磁盘中,如图 6.6 所示。)与图 6.4 中的模型对比之后,你可能会发现有一个列在迁移中没有出现——id 列。2.2 节提到过,这个列是自动生成的,Rails 用这个列作为行的唯一标识符。

sqlite database browser 3rd edition
图 6.5:在 SQLite 数据库浏览器中查看刚创建的 users
sqlite download
图 6.6:从云端 IDE 中下载文件
练习
  1. Rails 使用 db/ 目录中的 schema.rb 文件记录数据库的结构[称作模式(schema),因此才用这个文件名]。打开你应用中的 db/schema.rb 文件,与代码清单 6.2 中的迁移代码比较一下。

  2. 大多数迁移,包括本书中的所有迁移,都是可逆的,也就是说可以使用一个简单的命令“向下迁移”,撤销之前的操作。这个命令是 db:rollback

    $ rails db:rollback
    

    执行上述命令后,查看 db/schema.rb 文件,确认成功回滚了。

    (还有一个撤销迁移的方法,参见旁注 3.1。)这个命令会调用 drop_table 方法,把 users 表从数据库中删除。之所以可以这么做,是因为 change 方法知道 create_table 的逆操作是 drop_table,所以回滚时会直接调用 drop_table 方法。对于一些无法自动逆转的操作,例如删除列,就不能依赖 change 方法了,我们要分别定义 updown 方法。关于迁移的更多信息请阅读 Rails 指南

  3. 执行 rails db:migrate 命令,重新执行迁移。确认 db/schema.rb 文件的内容确实还原了。

6.1.2 模型文件

我们看到,执行代码清单 6.1 中的命令后会生成一个迁移文件(代码清单 6.2),也看到了执行迁移后得到的结果(图 6.5):修改 development.sqlite3 文件,新建 users 表,并创建 idnameemailcreated_atupdated_at 等列。代码清单 6.1 还生成了一个模型文件,本节剩下的内容专门讲解这个文件。

我们先看 User 模型的代码,在 app/models/ 目录中的 user.rb 文件里。这个文件的内容非常简单,如代码清单 6.3 所示。

代码清单 6.3:刚创建的 User 模型
app/models/user.rb
class User < ApplicationRecord
end

4.4.2 节介绍过,class User < ApplicationRecord 的意思是 User 类继承自 ApplicationRecord 类(而它继承自 ActiveRecord::Base 类,参见图 2.18),所以 User 模型自动获得了 ActiveRecord::Base 的所有功能。当然了,只知道这种继承关系没什么用,我们并不知道 ActiveRecord::Base 做了什么。下面看几个实例。

练习
  1. 根据 4.4.4 节所讲的知识,在 Rails 控制台中确认 User.new 属于 User 类,而它继承自 ApplicationRecord 类。

  2. 确认 ApplicationRecord 继承自 ActiveRecord::Base

6.1.3 创建用户对象

第 4 章一样,探索数据模型使用的工具是 Rails 控制台。因为我们(还)不想修改数据库中的数据,所以要在沙盒(sandbox)模式中启动控制台:

$ rails console --sandbox
Loading development environment in sandbox
Any modifications you make will be rolled back on exit
>>

如提示消息所说,“Any modifications you make will be rolled back on exit”,在沙盒模式下使用控制台,退出当前会话后,对数据库做的所有改动都会回归到原来的状态(即撤销)。

4.4.5 节的控制台会话中,我们要引入代码清单 4.17 中的代码才能使用 User.new 创建用户对象。对模型来说,情况有所不同。你可能还记得 4.4.4 节说过,Rails 控制台会自动加载 Rails 环境,这其中就包括模型。也就是说,现在无需加载任何代码就可以直接创建用户对象:

>> User.new
=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>

上述代码显示了用户对象在控制台中的默认表述。

如果不为 User.new 指定参数,对象的所有属性值都是 nil。在 4.4.5 节,我们自己编写的 User 类可以接受一个散列参数,指定用于初始化对象的属性。这种方式是受 Active Record 启发的,在 Active Record 中也可以使用相同的方式指定初始值:

>> user = User.new(name: "Michael Hartl", email: "mhartl@example.com")
=> #<User id: nil, name: "Michael Hartl", email: "mhartl@example.com",
created_at: nil, updated_at: nil>

我们看到 nameemail 属性的值都已经按预期设定了。

数据的有效性(validity)对理解 Active Record 模型对象很重要,我们会在 6.2 节深入探讨。不过注意,现在这个 user 对象是有效的,我们可以在这个对象上调用 valid? 方法确认:

>> user.valid?
true

目前为止,我们都没有修改数据库:User.new 只在内存中创建一个对象,user.valid? 只是检查对象是否有效。如果想把用户对象保存到数据库中,要在 user 变量上调用 save 方法:

>> user.save
   (0.1ms)  SAVEPOINT active_record_1
  SQL (0.8ms)  INSERT INTO "users" ("name", "email", "created_at",
  "updated_at") VALUES (?, ?, ?, ?)  [["name", "Michael Hartl"],
  ["email", "mhartl@example.com"], ["created_at", 2016-05-23 19:05:58 UTC],
  ["updated_at", 2016-05-23 19:05:58 UTC]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

如果保存成功,save 方法返回 true,否则返回 false。(现在所有保存操作都会成功,因为还没有数据验证;6.2 节会看到一些失败的例子。)Rails 还会在控制台中显示 user.save 对应的 SQL 语句(INSERT INTO "users"…),以供参考。本书几乎不会使用原始的 SQL,[6]所以此后我会省略 SQL。不过,从 Active Record 各种操作生成的 SQL 中可以学到很多知识。

你可能注意到了,刚创建时用户对象的 idcreated_atupdated_at 属性值都是 nil,下面看一下保存之后有没有变化:

>> user.save
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com",
created_at: "2016-05-23 19:05:58", updated_at: "2016-05-23 19:05:58">

我们看到,id 的值变成了 1,那两个自动创建的时间戳属性也变成了当前时间和日期。[7]现在这两个时间戳是一样的,6.1.5 节会看到二者不同的情况。

4.4.5 节定义的 User 类一样,User 模型的实例也可以使用点号获取属性:

>> user.name
=> "Michael Hartl"
>> user.email
=> "mhartl@example.com"
>> user.updated_at
=> Mon, 23 May 2016 19:05:58 UTC +00:00

第 7 章会介绍,虽然一般习惯把创建和保存分成如上所示的两步完成,不过 Active Record 也允许我们使用 User.create 方法把这两步合成一步:

>> User.create(name: "A Nother", email: "another@example.org")
#<User id: 2, name: "A Nother", email: "another@example.org", created_at:
"2016-05-23 19:18:46", updated_at: "2016-05-23 19:18:46">
>> foo = User.create(name: "Foo", email: "foo@bar.com")
#<User id: 3, name: "Foo", email: "foo@bar.com", created_at: "2016-05-23
19:19:06", updated_at: "2016-05-23 19:19:06">

注意,User.create 的返回值不是 truefalse,而是创建的用户对象,可以直接赋值给变量(例如上面第二个命令中的 foo 变量).

create 的逆操作是 destroy

>> foo.destroy
   (0.1ms)  SAVEPOINT active_record_1
  SQL (0.2ms)  DELETE FROM "users" WHERE "users"."id" = ?  [["id", 3]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> #<User id: 3, name: "Foo", email: "foo@bar.com", created_at: "2016-05-23
19:19:06", updated_at: "2016-05-23 19:19:06">

奇怪的是,destroycreate 一样,返回值是对象。我不觉得什么地方会用到 destroy 的返回值。更奇怪的是,销毁的对象还在内存中:

>> foo
=> #<User id: 3, name: "Foo", email: "foo@bar.com", created_at: "2016-05-23
19:19:06", updated_at: "2016-05-23 19:19:06">

那么我们怎么知道对象是否真被销毁了呢?对于已经保存而没有销毁的对象,怎样从数据库中读取呢?要回答这些问题,我们要先学习如何使用 Active Record 查找用户对象。

练习
  1. 确认 user.nameuser.email 属于 String 类。

  2. created_atupdated_at 属性的值属于哪个类?

6.1.4 查找用户对象

Active Record 提供了好几种查找对象的方法。下面我们使用这些方法查找前面创建的第一个用户,同时也验证一下第三个用户(foo)是否被销毁了。先看一下还存在的用户:

>> User.find(1)
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com",
created_at: "2016-05-23 19:05:58", updated_at: "2016-05-23 19:05:58">

我们把用户的 ID 传给 User.find 方法,Active Record 会返回 ID 为 1 的用户对象。

下面来看一下 ID 为 3 的用户是否还在数据库中:

>> User.find(3)
ActiveRecord::RecordNotFound: Couldn't find User with ID=3

因为我们在 6.1.3 节销毁了第三个用户,所以 Active Record 无法在数据库中找到这个用户,从而抛出一个异常(exception),这说明在查找过程中出现了问题。因为 ID 不存在,所以 find 方法抛出 ActiveRecord::RecordNotFound 异常。[8]

除了这种查找方式之外,Active Record 还支持通过属性查找用户:

>> User.find_by(email: "mhartl@example.com")
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com",
created_at: "2016-05-23 19:05:58", updated_at: "2016-05-23 19:05:58">

我们将使用电子邮件地址做用户名,在学习如何让用户登录网站时会用到这种 find 方法(第 7 章)。你可能会担心如果用户数量过多,使用 find_by 的效率不高。事实的确如此,我们会在 6.2.5 节说明这个问题,以及如何使用数据库索引解决。

最后,再介绍几个常用的查找方法。首先是 first 方法:

>> User.first
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com",
created_at: "2016-05-23 19:05:58", updated_at: "2016-05-23 19:05:58">

很明显,first 会返回数据库中的第一个用户。还有 all 方法:

>> User.all
=> #<ActiveRecord::Relation [#<User id: 1, name: "Michael Hartl",
email: "mhartl@example.com", created_at: "2016-05-23 19:05:58",
updated_at: "2016-05-23 19:05:58">, #<User id: 2, name: "A Nother",
email: "another@example.org", created_at: "2016-05-23 19:18:46",
updated_at: "2016-05-23 19:18:46">]>

从控制台的输出可以看出,User.all 方法返回一个 ActiveRecord::Relation 实例,其实这是一个数组(4.3.1 节), 包含数据库中的所有用户。

练习
  1. 通过用户的名字(name)查找用户。确认也可以使用 find_by_name 方法。(在旧的 Rails 应用中经常能见到这种旧的 find_by 方法。)

  2. User.all 得到的结果虽然行为类似于数组,但它不是数组,确认它其实属于 User::ActiveRecord_Relation 类。

  3. 确认可以使用 length 方法(4.2.3 节)获取 User.all 的长度。在 Ruby 中,我们根据对象的行为而不是所属的类确定能对对象执行什么操作,这叫鸭子类型(duck typing),意思是:“如果看起来像鸭子,叫起来也像鸭子,那么它可能就是鸭子”。

6.1.5 更新用户对象

创建对象后,一般都会进行更新操作。更新有两种基本方式,其一,可以分别为各个属性赋值,在 4.4.5 节就是这么做的:

>> user           # 只是为了查看 user 对象的属性是什么
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com",
created_at: "2016-05-23 19:05:58", updated_at: "2016-05-23 19:05:58">
>> user.email = "mhartl@example.net"
=> "mhartl@example.net"
>> user.save
=> true

注意,如果想把改动写入数据库,必须执行最后一个方法。我们可以执行 reload 命令来看一下没保存的话是什么情况。reload 方法会使用数据库中的数据重新加载对象:

>> user.email
=> "mhartl@example.net"
>> user.email = "foo@bar.com"
=> "foo@bar.com"
>> user.reload.email
=> "mhartl@example.net"

现在我们已经更新了用户数据,如 6.1.3 节所说,现在自动创建的那两个时间戳属性不一样了:

>> user.created_at
=> "2016-05-23 19:05:58"
>> user.updated_at
=> "2016-05-23 19:08:23"

更新数据的第二种常用方式是使用 update_attributes 方法:[9]

>> user.update_attributes(name: "The Dude", email: "dude@abides.org")
=> true
>> user.name
=> "The Dude"
>> user.email
=> "dude@abides.org"

update_attributes 方法接受一个指定对象属性的散列作为参数,如果操作成功,会执行更新和保存两个操作(保存成功时返回 true)。注意,如果任何一个数据验证失败了,例如存储记录时需要密码(6.3 节实现),update_attributes 操作就会失败。如果只需要更新单个属性,可以使用 update_attribute 方法,跳过验证:

>> user.update_attribute(:name, "El Duderino")
=> true
>> user.name
=> "El Duderino"
练习
  1. 通过赋值更新用户的名字,然后调用 save 方法。

  2. 调用 update_attributes 方法,更新用户的电子邮件地址。

  3. 通过赋值更新 created_at 列的值,然后调用 save 方法,以此确认特殊的列也可以直接更新。把这一列的值设为 1.year.ago,这是 Rails 扩展的功能,作用是创建距当前时间一年前的时间戳。

6.2 验证用户数据

6.1 节创建的 User 模型现在已经有了可以使用的 nameemail 属性,不过功能还很简单:任何字符串(包括空字符串)都可以使用。名字和电子邮件地址的格式显然要复杂一些。例如,name 不应该是空的,email 应该符合特定的格式。而且,我们将把电子邮件地址当成用户名用来登录,那么在数据库中就不能重复出现。

总之,nameemail 不是什么字符串都可以使用的,我们要对它们可以使用的值做个限制。Active Record 通过数据验证(validation)实现这种限制(2.3.2 节简单提到过)。本节介绍几种常用的数据验证:存在性、长度、格式和唯一性。6.3.2 节还会介绍另一种常用的数据验证——二次确认。7.3 节会看到,如果提交不合要求的数据,数据验证会显示一些很有用的错误消息。

6.2.1 有效性测试

旁注 3.3说过,TDD 并不适用所有情况,但是模型验证是使用 TDD 的绝佳时机。如果不先编写失败测试,再想办法让它通过,我们很难确定验证是否实现了我们希望实现的功能。

我们采用的方法是,先得到一个有效的模型对象,然后把属性改为无效值,以此确认这个对象是无效的。以防万一,我们先编写一个测试,确认模型对象一开始是有效的。这样,如果验证测试失败了,我们才知道的确事出有因(而不是因为一开始对象是无效的)。

代码清单 6.1 中的命令生成了一个用于测试 User 模型的测试文件,现在这个文件中还没什么内容,如代码清单 6.4 所示。

代码清单 6.4:还没什么内容的 User 模型测试文件
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase
  # test "the truth" do
  #   assert true
  # end
end

为了测试有效的对象,我们要在特殊的 setup 方法中创建一个有效的用户对象 @user第 3 章的练习中提到过,setup 方法会在每个测试方法运行前执行。因为 @user 是实例变量,所以自动可在所有测试方法中使用,而且我们可以使用 valid? 方法检查它是否有效。测试如代码清单 6.5 所示。

代码清单 6.5:测试用户对象一开始是有效的 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")
  end

  test "should be valid" do
    assert @user.valid?
  end
end

代码清单 6.5 使用简单的 assert 方法,如果 @user.valid? 返回 true,测试就能通过;返回 false,测试则会失败。

因为 User 模型现在还没有任何验证,所有这个测试可以通过:

代码清单 6.6GREEN
$ rails test:models

这里,我们使用 rails test:models 命令,只运行模型测试(与 5.3.4 节rails test:integration 对比一下)。

练习
  1. 在控制台中确认新建的用户现在是有效的。

  2. 确认 6.1.3 节创建的用户也是有效的。

6.2.2 存在性验证

存在性验证算是最基本的验证了,只是检查指定的属性是否存在。本节我们会确保用户存入数据库之前,nameemail 字段都有值。7.3.3 节会介绍如何把这个限制应用到创建用户的注册表单中。

我们要先在代码清单 6.5 的基础上再编写一个测试,检查 name 属性是否存在。如代码清单 6.7 所示,我们只需把 @user 变量的 name 属性设为空字符串(包含几个空格的字符串),然后使用 assert_not 方法确认得到的用户对象是无效的。

代码清单 6.7:测试 name 属性的验证 RED
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com")
  end

  test "should be valid" do
    assert @user.valid?
  end

  test "name should be present" do
    @user.name = "     "
    assert_not @user.valid?
  end
end

现在,模型测试应该失败:

代码清单 6.8RED
$ rails test:models

我们在第 2 章的练习中见过,name 属性的存在性验证使用 validates 方法,而且其参数为 presence: true,如代码清单 6.9 所示。presence: true 是只有一个元素的可选散列参数;4.3.4 节说过,如果方法的最后一个参数是散列,可以省略花括号。(5.1.1 节说过,Rails 经常使用散列做参数。)

代码清单 6.9:为 name 属性添加存在性验证 GREEN
app/models/user.rb
class User < ApplicationRecord
  validates :name, presence: true
end

代码清单 6.9 中的代码看起来可能有点儿神奇,其实 validates 就是个方法。加入括号后,可以写成:

class User < ApplicationRecord
  validates(:name, presence: true)
end

打开控制台,看一下在 User 模型中加入验证后有什么效果:[10]

$ rails console --sandbox
>> user = User.new(name: "", email: "mhartl@example.com")
>> user.valid?
=> false

这里我们使用 valid? 方法检查 user 变量的有效性,如果有一个或多个验证失败,返回值为 false;如果所有验证都能通过,返回 true。现在只有一个验证,所以我们知道是哪一个失败,不过看一下失败时生成的 errors 对象还是很有用的:

>> user.errors.full_messages
=> ["Name can't be blank"]

(错误消息暗示,Rails 使用 4.4.3 节介绍的 blank? 方法验证存在性。)

因为用户无效,如果尝试把它保存到数据库中,操作会失败:

>> user.save
=> false

加入验证后,代码清单 6.7 中的测试应该可以通过了:

代码清单 6.10GREEN
$ rails test:models

按照代码清单 6.7 的方式,再编写一个检查 email 属性存在性的测试就简单了,如代码清单 6.11 所示。让这个测试通过的应用代码如代码清单 6.12 所示。

代码清单 6.11:测试 email 属性的验证 RED
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com")
  end

  test "should be valid" do
    assert @user.valid?
  end

  test "name should be present" do
    @user.name = ""
    assert_not @user.valid?
  end

  test "email should be present" do
    @user.email = "     "
    assert_not @user.valid?
  end
end
代码清单 6.12:为 email 属性添加存在性验证 GREEN
app/models/user.rb
class User < ApplicationRecord
  validates :name,  presence: true
  validates :email, presence: true
end

现在,存在性验证都添加了,测试组件应该可以通过了:

代码清单 6.13GREEN
$ rails test
练习
  1. 新建一个用户,赋值给变量 u,确认一开始这个用户对象是无效的。看一下完整的错误消息是什么。

  2. 确认 u.errors.messages 是一个散列。怎么获取电子邮件地址相关的错误呢?

6.2.3 长度验证

我们已经对 User 模型可接受的数据做了一些限制,现在必须为用户提供一个名字,不过我们应该做进一步限制,因为用户的名字会在演示应用中显示,所以最好限制它的长度。有了前一节的基础,这一步就简单了。

没有科学的方法确定最大长度应该是多少,我们就使用 50 作为长度的上限吧。因此,我们要验证 51 个字符超长了。此外,用户的电子邮件地址可能会超过字符串的最大长度限制,这个最大值在很多数据库中都是 255——这种情况虽然很少发生,但也有发生的可能。因为下一节的格式验证无法实现这种限制,所以我们要在这一节实现。测试如代码清单 6.14 所示。

代码清单 6.14:测试 name 属性的长度验证 RED
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com")
  end
  .
  .
  .
  test "name should not be too long" do
    @user.name = "a" * 51
    assert_not @user.valid?
  end

  test "email should not be too long" do
    @user.email = "a" * 244 + "@example.com"
    assert_not @user.valid?
  end
end

为了方便,我们使用字符串连乘生成了一个有 51 个字符的字符串。在控制台中可以看到连乘是什么:

>> "a" * 51
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
>> ("a" * 51).length
=> 51

在电子邮件地址长度的测试中,我们创建了一个比要求多一个字符的地址:

>> "a" * 244 + "@example.com"
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaa@example.com"
>> ("a" * 244 + "@example.com").length
=> 256

现在,代码清单 6.14 中的测试应该失败:

代码清单 6.15RED
$ rails test

为了让测试通过,我们要使用验证参数限制长度,即 length,以及限制上线的 maximum 参数,如代码清单 6.16 所示。

代码清单 6.16:为 name 属性添加长度验证 GREEN
app/models/user.rb
class User < ApplicationRecord
  validates :name,  presence: true, length: { maximum: 50 }
  validates :email, presence: true, length: { maximum: 255 }
end

现在测试应该可以通过了:

代码清单 6.17GREEN
$ rails test

测试组件再次通过,接下来我们要实现一个更有挑战的验证——电子邮件地址的格式。

练习
  1. 使用非常长的名字和电子邮件地址创建一个用户,确认它是无效的。

  2. 长度验证生成的错误消息是什么?

6.2.4 格式验证

name 属性的验证只需做一些简单的限制就好——任何非空、长度小于 51 个字符的字符串都可以。可是 email 属性需要更复杂的限制,必须是有效的电子邮件地址才行。目前我们只拒绝空电子邮件地址,本节将限制电子邮件地址符合常用的形式,类似 user@example.com 这种。

这里我们用到的测试和验证不是十全十美的,只是刚好可以覆盖大多数有效的电子邮件地址,并拒绝大多数无效的电子邮件地址。我们会先测试一组有效的电子邮件地址和一组无效的电子邮件地址。我们将使用 %w[] 创建这两组地址,其中每个地址都是字符串形式,如下面的控制台会话所示:

>> %w[foo bar baz]
=> ["foo", "bar", "baz"]
>> addresses = %w[USER@foo.COM THE_US-ER@foo.bar.org first.last@foo.jp]
=> ["USER@foo.COM", "THE_US-ER@foo.bar.org", "first.last@foo.jp"]
>> addresses.each do |address|
?>   puts address
>> end
USER@foo.COM
THE_US-ER@foo.bar.org
first.last@foo.jp

在上面的控制台会话中,我们使用 each 方法(4.3.2 节)遍历 addresses 数组中的元素。掌握这种用法之后,我们就可以编写一些基本的电子邮件地址格式验证测试了。

电子邮件地址格式验证有点棘手,而且容易出错,所以我们会先编写检查有效电子邮件地址的测试,这些测试应该能通过,以此捕获验证可能出现的错误。也就是说,添加验证后,不仅要拒绝无效的电子邮件地址,例如 user@example,com,还得接受有效的电子邮件地址,例如 user@example.com。(显然目前会接受所有电子邮件地址,因为只要不为空值都能通过验证。)检查有效电子邮件地址的测试如代码清单 6.18 所示。

代码清单 6.18:测试有效的电子邮件地址格式 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")
  end
  .
  .
  .
  test "email validation should accept valid addresses" do
    valid_addresses = %w[user@example.com USER@foo.COM A_US-ER@foo.bar.org
                         first.last@foo.jp alice+bob@baz.cn]
    valid_addresses.each do |valid_address|
      @user.email = valid_address
      assert @user.valid?, "#{valid_address.inspect} should be valid"
    end
  end
end

注意,我们为 assert 方法指定了可选的第二个参数,用于定制错误消息,识别是哪个地址导致测试失败的:

assert @user.valid?, "#{valid_address.inspect} should be valid"

这行代码在字符串插值中使用了 4.3.3 节介绍的 inspect 方法。像这种使用 each 方法的测试,最好能知道是哪个地址导致失败的,因为不管哪个地址导致测试失败,都无法看到行号,很难查出问题的根源。

接下来,我们要测试几个无效的电子邮件,确认它们无法通过验证,例如 user@example,com(点号变成了逗号)和 user_at_foo.org(没有“@”符号)。与代码清单 6.18 一样,代码清单 6.19 也指定了错误消息参数,识别是哪个地址导致测试失败的。

代码清单 6.19:测试电子邮件地址格式验证 RED
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com")
  end
  .
  .
  .
  test "email validation should reject invalid addresses" do
    invalid_addresses = %w[user@example,com user_at_foo.org user.name@example.
                           foo@bar_baz.com foo@bar+baz.com]
    invalid_addresses.each do |invalid_address|
      @user.email = invalid_address
      assert_not @user.valid?, "#{invalid_address.inspect} should be invalid"
    end
  end
end

现在,测试应该失败:

代码清单 6.20RED
$ rails test

电子邮件地址格式验证使用 format 参数,用法如下:

validates :email, format: { with: /<regular expression>/ }

它使用指定的正则表达式(regular expression,简称 regex)验证属性。正则表达式很强大,使用模式匹配字符串,但往往晦涩难懂。我们要编写一个正则表达式,匹配有效的电子邮件地址,但不匹配无效的地址。

在官方标准中其实有一个正则表达式,可以匹配全部有效的电子邮件地址,但没必要使用这么复杂的正则表达式。[11]本书使用一个更务实的正则表达式,能很好地满足实际需求,如下所示:

VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i

为了便于理解,我把 VALID_EMAIL_REGEX 拆分成几块来讲,如表 6.1 所示。[12]

表 6.1:拆解匹配有效电子邮件地址的正则表达式
表达式 含义

/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i

完整的正则表达式

/

正则表达式开始

\A

匹配字符串的开头

[\w+\-.]+

一个或多个字母、加号、连字符或点号

@

匹配 @ 符号

[a-z\d\-.]+

一个或多个字母、数字、连字符或点号

\.

匹配点号

[a-z]+

一个或多个字母

\z

匹配字符串末尾

/

结束正则表达式

i

不区分大小写

表 6.1 中虽然能学到很多,但若想真正理解正则表达式,我觉得交互式正则表达式匹配工具,例如 Rubular图 6.7[13],是必不可少的。Rubular 的界面很友好,便于编写所需的正则表达式,而且还有一个便捷的语法速查表。我建议你使用 Rubular 来理解表 6.1中的正则表达式——读得次数再多也不比不上在 Rubular 中实操几次。(注意:如果你在 Rubular 中输入表 6.1 中的正则表达式,要把 \A\z 去掉,这样便可以一次匹配字符串中的多个电子邮件地址。此外还要注意,正则表达式夹在一对斜线内,在 Rubular 中无需再输入斜线。)

rubular
图 6.7:强大的 Rubular 正则表达式编辑器

email 属性的格式验证中使用这个正则表达式后得到的代码如代码清单 6.21 所示。

代码清单 6.21:使用正则表达式验证电子邮件地址的格式 GREEN
app/models/user.rb
class User < ApplicationRecord
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX }
end

其中,VALID_EMAIL_REGEX 是一个常量(constant)。在 Ruby 中常量的首字母为大写。下面这段代码:

  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX }

确保只有匹配正则表达式的电子邮件地址才是有效的。这个正则表达式有一个缺陷:能匹配 foo@bar..com 这种有连续点号的地址。修正这个瑕疵需要一个更复杂的正则表达式,留作练习由你完成。

现在测试应该可以通过了:

代码清单 6.22GREEN
$ rails test:models

那么,现在就只剩一个限制要实现了:确保电子邮件地址的唯一性。

练习
  1. 代码清单 6.18 中的有效地址和代码清单 6.19 中的无效地址复制粘贴到 Rubular 的测试字符串文本框中,确认代码清单 6.21 中的正则表达式能匹配全部有效地址,而且不能匹配任何无效地址。

  2. 前面说过,代码清单 6.21 中的电子邮件地址正则表达式能匹配有连续点号的无效地址,例如 foo@bar..com。把这个地址添加到代码清单 6.19 中的无效地址列表中,让测试失败,然后使用代码清单 6.23 中较复杂的正则表达式让测试通过。

  3. foo@bar..com 添加到 Rubular 中的测试字符串文本框中,确认代码清单 6.23 中的正则表达式能匹配全部有效地址,而且不能匹配任何无效地址。

代码清单 6.23:不允许电子邮件地址中有多个点号的正则表达式 GREEN
app/models/user.rb
class User < ApplicationRecord
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  validates :email, presence:   true, length: { maximum: 255 },
                    format:     { with: VALID_EMAIL_REGEX }
end

6.2.5 唯一性验证

确保电子邮件地址的唯一性(这样才能作为用户名),要使用 validates 方法的 :unique 选项。提前说明,实现的过程中有一个很大的陷阱,所以别轻易跳过本节,要认真阅读。

我们要先编写一些简短的测试。之前的模型测试,只是使用 User.new 在内存中创建一个 Ruby 对象,但是测试唯一性时要把数据存入数据库。[14]对重复电子邮件地址的测试如代码清单 6.24 所示。

代码清单 6.24:拒绝重复电子邮件地址的测试 RED
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com")
  end
  .
  .
  .
  test "email addresses should be unique" do
    duplicate_user = @user.dup
    @user.save
    assert_not duplicate_user.valid?
  end
end

我们使用 @user.dup 方法创建一个和 @user 的电子邮件地址一样的用户对象,然后保存 @user,因为数据库中的 @user 已经占用了这个电子邮件地址,所以 duplicate_user 对象无效。

email 属性的验证中加入 uniqueness: true 可以让代码清单 6.24 中的测试通过,如代码清单 6.25 所示。

代码清单 6.25:电子邮件地址唯一性验证 GREEN
app/models/user.rb
class User < ApplicationRecord
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: true
end

这还不行,一般来说电子邮件地址不区分大小写,也就说 foo@bar.comFOO@BAR.COMFoO@BAr.coM 是同一个地址,所以验证时也要考虑这种情况。[15]因此,还要测试不区分大小写,如代码清单 6.26 所示。

代码清单 6.26:测试电子邮件地址的唯一性验证不区分大小写 RED
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com")
  end
  .
  .
  .
  test "email addresses should be unique" do
    duplicate_user = @user.dup
    duplicate_user.email = @user.email.upcase
    @user.save
    assert_not duplicate_user.valid?
  end
end

上面的代码,在字符串上调用 upcase 方法(4.3.2 节简介过)。这个测试和前面对重复电子邮件的测试作用一样,只是把地址转换成全部大写字母的形式。如果你觉得太抽象,那就在控制台中实操一下吧:

$ rails console --sandbox
>> user = User.create(name: "Example User", email: "user@example.com")
>> user.email.upcase
=> "USER@EXAMPLE.COM"
>> duplicate_user = user.dup
>> duplicate_user.email = user.email.upcase
>> duplicate_user.valid?
=> true

当然,现在 duplicate_user.valid? 的返回值是 true,因为唯一性验证还区分大小写。我们希望得到的结果是 false。幸好 :uniqueness 可以指定 :case_sensitive 选项,正好可以解决这个问题,如代码清单 6.27 所示。

代码清单 6.27:电子邮件地址唯一性验证,不区分大小写 GREEN
app/models/user.rb
class User < ApplicationRecord
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
end

注意,我们直接把 true 换成了 case_sensitive: false,Rails 会自动指定 :uniqueness 的值为 true

至此,我们的应用虽然还有不足,但基本可以保证电子邮件地址的唯一性了,测试组件应该可以通过了:

代码清单 6.28GREEN
$ rails test

现在还有一个小问题——Active Record 中的唯一性验证无法保证数据库层也能实现唯一性。我来解释一下:

  1. Alice 使用 alice@wonderland.com 在演示应用中注册;

  2. Alice 不小心按了两次提交按钮,连续发送了两次请求;

  3. 然后就会发生这种事情:请求 1 在内存中新建了一个用户对象,能通过验证;请求 2 也一样。请求 1 创建的用户存入了数据库,请求 2 创建的用户也存入了数据库。

  4. 结果是,尽管有唯一性验证,数据库中还是有两条用户记录的电子邮件地址是一样的。

相信我,上面这种难以置信的情况可能发生,只要有一定的访问量,在任何 Rails 网站中都可能发生(这是我从教训中学到的经验)。幸好解决的方法很简单,只需在数据库层也加上唯一性限制。我们要做的是在数据库中为 email 列建立索引(旁注 6.2),然后为索引加上唯一性约束。

email 列建立索引要改变数据模型,在 Rails 中可以通过迁移实现。在 6.1.1 节我们看到,生成 User 模型时会自动创建一个迁移文件(代码清单 6.2)。现在我们是要改变已经存在的模型结构,那么使用 migration 命令直接创建迁移文件就可以了:

$ rails generate migration add_index_to_users_email

User 模型的迁移不同,实现电子邮件地址唯一性的操作没有事先定义好的模板可用,所以我们要自己动手编写,如代码清单 6.29 所示。[16]

代码清单 6.29:添加电子邮件唯一性约束的迁移
db/migrate/[timestamp]_add_index_to_users_email.rb
class AddIndexToUsersEmail < ActiveRecord::Migration[5.0]
  def change
    add_index :users, :email, unique: true
  end
end

上述代码调用了 Rails 中的 add_index 方法,为 users 表中的 email 列建立索引。索引本身并不能保证唯一性,所以还要指定 unique: true

最后,执行数据库迁移:

$ rails db:migrate

(如果迁移失败的话,退出所有打开的沙盒模式控制台会话试试。这些会话可能会锁定数据库,拒绝迁移操作。)

现在测试组件应该无法通过,因为固件(fixture)中的数据违背了唯一性约束。固件的作用是为测试数据库提供示例数据。执行代码清单 6.1 中的命令时会自动生成用户固件,如代码清单 6.30 所示,电子邮件地址有重复。(电子邮件地址也无效,但固件中的数据不会应用验证规则。)

代码清单 6.30:默认生成的用户固件 RED
test/fixtures/users.yml
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/
# FixtureSet.html

one:
  name: MyString
  email: MyString

two:
  name: MyString
  email: MyString

我们到第 8 章才会用到固件,现在暂且把其中的数据删除,只留下一个空文件,如代码清单 6.31 所示。

代码清单 6.31:没有内容的固件文件 GREEN
test/fixtures/users.yml
# empty

为了保证电子邮件地址的唯一性,还要做些修改。有些数据库适配器的索引区分大小写,会把“Foo@ExAMPle.CoM”和“foo@example.com”视作不同的字符串,但我们的应用会把它们看做同一个地址。为了避免不兼容,我们要统一使用小写形式的地址,存入数据库前,把“Foo@ExAMPle.CoM”转换成“foo@example.com”。为此,我们要使用回调(callback),在 Active Record 对象生命周期的特定时刻调用。[17]这里,我们要使用的回调是 before_save,在用户对象存入数据库之前把电子邮件地址转换成全小写字母形式,如代码清单 6.32 所示。(这只是初步实现方式,11.1 节会再次讨论这个话题,届时会使用常用的“方法引用”定义回调。)

代码清单 6.32:把 email 属性的值转换为小写形式,确保电子邮件地址的唯一性 GREEN
app/models/user.rb
class User < ApplicationRecord
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
end

代码清单 6.32 中,before_save 后有一个块,块中的代码调用字符串的 downcase 方法,把用户的电子邮件地址转换成小写形式。(针对电子邮件地址转换成小写形式的测试留作练习。)

代码清单 6.32 中,我们可以把赋值语句写成:

self.email = self.email.downcase

其中 self 表示当前用户。但是在 User 模型中,右侧的 self 关键字是可选的,我们在 palindrome? 方法中调用 reverse 方法时说过(4.4.2 节):

self.email = email.downcase

注意,左侧的 self 不能省略,所以写成

email = email.downcase

是不对的。(9.1 节会进一步讨论这个话题。)

现在,前面 Alice 遇到的问题解决了,数据库会存储请求 1 创建的用户,不会存储请求 2 创建的用户,因为后者违反了唯一性约束。(在 Rails 的日志中会显示一个错误,不过并无大碍。)为 email 列建立索引同时也解决了 6.1.4 节提到的问题:如旁注 6.2 所说,为 email 列添加索引之后,使用电子邮件地址查找用户时不会进行全表扫描,从而解决了潜在的效率问题。

练习
  1. 代码清单 6.32 中把电子邮件地址转换成小写形式的代码编写一个测试,如代码清单 6.33 所示。这段测试使用 reload 方法从数据库中重新加载数据,使用 assert_equal 方法测试是否相等。为了验证代码清单 6.33 是正确的,先把 before_save 那行注释掉,看测试是否失败,然后去掉注释,看测试能否通过。

  2. 通过测试组件确认在 before_save 回调中可以使用“炸弹”方法 email.downcase! 直接修改 email 属性,如代码清单 6.34 所示。

代码清单 6.33代码清单 6.32 中把电子邮件地址转换成小写形式的测试
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com")
  end
  .
  .
  .
  test "email addresses should be unique" do
    duplicate_user = @user.dup
    duplicate_user.email = @user.email.upcase
    @user.save
    assert_not duplicate_user.valid?
  end

  test "email addresses should be saved as lower-case" do
    mixed_case_email = "Foo@ExAMPle.CoM"
    @user.email = mixed_case_email
    @user.save
    assert_equal mixed_case_email.downcase, @user.reload.email
  end
end
代码清单 6.34before_save 回调的另一种实现方式 GREEN
app/models/user.rb
class User < ApplicationRecord
  before_save { email.downcase! }
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
end

6.3 添加安全密码

我们已经为 nameemail 字段添加了验证规则,现在要加入用户所需的最后一个常规属性:安全密码。每个用户都要设置一个密码(还要二次确认),数据库中则存储经过哈希(hash)加密后的密码。(你可能会困惑。这里所说的“哈希”不是 4.3.3 节介绍的 Ruby 数据结构,而是经过不可逆哈希算法计算得到的结果。){![哈希和散列对应的英语单词都是 hash,但是译成中文后,就没有这个问题了:算法是“哈希”,数据结构是“散列”。——译者注]}我们还要加入基于密码的身份验证机制,第 8 章会利用这个机制实现用户登录功能。

验证身份的方法是,获取用户提交的密码,哈希加密,再与数据库中存储的密码哈希值对比。如果二者一致,用户提交的就是正确的密码,用户的身份也就通过验证了。我们要对比的是密码哈希值,而不是原始密码,所以不用在数据库中存储用户的密码。因此,就算被“脱库”了,用户的密码仍然安全。

6.3.1 计算密码哈希值

我们使用的安全密码机制基本上用一个 Rails 方法即可实现,这个方法是 has_secure_password。我们要在 User 模型中调用这个方法,如下所示:

class User < ApplicationRecord
  .
  .
  .
  has_secure_password
end

在模型中调用这个方法后,会自动添加如下功能:

  • 在数据库中的 password_digest 列存储安全的密码哈希值;

  • 获得一对虚拟属性,[18]passwordpassword_confirmation,而且创建用户对象时会执行存在性验证和匹配验证;

  • 获得 authenticate 方法,如果密码正确,返回对应的用户对象,否则返回 false

has_secure_password 发挥功效的唯一要求是,对应的模型中有个名为 password_digest 的属性。(digest(摘要)是哈希加密算法中的术语。“密码哈希值”和“密码摘要”是一个意思。)[19]User 模型来说,我们要实现如图 6.8 所示的数据模型。

user model password digest 3rd edition
图 6.8User 数据模型,多了一个 password_digest 属性

为了实现图 6.8 中的数据模型,首先要创建一个适当的迁移文件,添加 password_digest 列。迁移的名字随意,不过最好以 to_users 结尾,因为这样 Rails 会自动生成一个向 users 表添加列的迁移。我们把这个迁移命名为 add_password_digest_to_users,生成迁移的命令如下:

$ rails generate migration add_password_digest_to_users password_digest:string

在这个命令中,我们还加入了参数 password_digest:string,指定想添加的列名和类型。(与代码清单 6.1 中的命令对比一下,那个命令生成创建 users 表的迁移,指定了 name:stringemail:string 两个参数。)加入 password_digest:string 后,我们为 Rails 提供了足够的信息,它会为我们生成一个完整的迁移,如代码清单 6.35 所示。

代码清单 6.35:向 users 表添加 password_digest 列的迁移
db/migrate/[timestamp]_add_password_digest_to_users.rb
class AddPasswordDigestToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :password_digest, :string
  end
end

这个迁移使用 add_column 方法把 password_digest 列添加到 users 表中。执行下述命令在数据库中运行迁移:

$ rails db:migrate

has_secure_password 方法使用先进的 bcrypt 哈希算法计算密码摘要。使用 bcrypt 计算密码哈希值,就算攻击者设法获得了数据库副本也无法登录网站。为了在演示应用中使用 bcrypt,我们要把 bcrypt gem 添加到 Gemfile 文件中,如代码清单 6.36 所示。

代码清单 6.36:把 bcrypt gem 添加到 Gemfile 文件中
source 'https://rubygems.org'

gem 'rails',                   '5.1.6'
gem 'bcrypt',                  '3.1.12'
.
.
.

然后像往常一样,执行 bundle install 命令:

$ bundle install

6.3.2 用户有安全的密码

现在我们已经在 User 模型中添加了 password_digest 属性,也安装了 bcrypt,下面可以在 User 模型中添加 has_secure_password 方法了,如代码清单 6.37 所示。

代码清单 6.37:在 User 模型中添加 has_secure_password 方法 RED
app/models/user.rb
class User < ApplicationRecord
  before_save { self.email = email.downcase }
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
end

代码清单 6.37 中的“RED”所示,测试现在失败,我们可以在命令行中执行下述命令确认:

代码清单 6.38RED
$ rails test

我们在 6.3.1 节说过,has_secure_password 会在 passwordpassword_confirmation 两个虚拟属性上执行验证,但是现在代码清单 6.26 创建 @user 变量时没有设定这两个属性:

def setup
  @user = User.new(name: "Example User", email: "user@example.com")
end

所以,为了让测试组件通过,我们要添加这两个属性,如代码清单 6.39 所示。

代码清单 6.39:添加密码和密码确认 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
  .
  .
  .
end

注意,setup 方法的第一行末尾有个逗号,这是 Ruby 的散列句法所需的(4.3.3 节)。如果没有那个逗号,会出现句法错误。如果遇到这个问题,你要设法自行解决。

现在测试应该可以通过了:

代码清单 6.40GREEN
$ rails test

6.3.4 节会看到在 User 模型中添加 has_secure_password 方法的作用。在此之前,为了密码的安全,先添加一个小限制。

练习
  1. 确认名字和电子邮件地址有效的用户还不算有效。

  2. 如果用户没有密码,错误消息是什么?

6.3.3 密码的最短长度

一般来说,最好为密码做些限制,让别人更难猜测。在 Rails 中增强密码强度有很多方法,简单起见,我们只限制最短长度,而且要求密码不能为空。最短长度为 6 是个不错的选择,针对这个验证的测试如代码清单 6.41 所示。

代码清单 6.41:测试密码的最短长度 RED
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 "password should be present (nonblank)" do
    @user.password = @user.password_confirmation = " " * 6
    assert_not @user.valid?
  end

  test "password should have a minimum length" do
    @user.password = @user.password_confirmation = "a" * 5
    assert_not @user.valid?
  end
end

注意这段代码中使用的双重赋值:

@user.password = @user.password_confirmation = "a" * 5

这行代码同时为 passwordpassword_confirmation 赋值,值是长度为 5 的字符串,使用字符串连乘创建。

参照 name 属性的 maximum 验证(代码清单 6.16),你或许能猜到限制最短长度所需的代码:

validates :password, length: { minimum: 6 }

在上述代码的基础上,还要加上存在性验证,得出的 User 模型如代码清单 6.42 所示。(has_secure_password 方法本身会验证存在性,但是可惜,只会验证有没有密码,因此用户可以创建 “      ”(6 个空格)这样的无效密码。)

代码清单 6.42:实现安全密码的全部代码 GREEN
app/models/user.rb
class User < ApplicationRecord
  before_save { self.email = email.downcase }
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }
end

现在,测试应该可以通过了:

代码清单 6.43GREEN
$ rails test:models
练习
  1. 确认名字和电子邮件地址有效,但是密码太短的用户是无效的。

  2. 相应的的错误消息是什么?

6.3.4 创建并验证用户的身份

至此,基本的 User 模型已经完成了。接下来,我们要在数据库中创建一个用户,为 7.1 节开发的用户资料页面做准备。同时也看一下在 User 模型中添加 has_secure_password 方法后的效果,还要用一下重要的 authenticate 方法。

因为现在还不能在网页中注册(第 7 章实现),我们要在 Rails 控制台中手动创建新用户。为了方便,我们会使用 6.1.3 节介绍的 create 方法。注意,不要在沙盒模式中启用控制台,否则结果不会存入数据库。我们要使用 rails console 启动普通的控制台,然后使用有效的名字和电子邮件地址,以及密码和密码确认,创建一个用户:

$ rails console
>> User.create(name: "Michael Hartl", email: "mhartl@example.com",
?>             password: "foobar", password_confirmation: "foobar")
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com",
created_at: "2016-05-23 20:36:46", updated_at: "2016-05-23 20:36:46",
password_digest: "$2a$10$xxucoRlMp06RLJSfWpZ8hO8Dt9AZXlGRi3usP3njQg3...">

为了确认结果,我们使用 SQLite 数据库浏览器查看开发数据库(db/development.sqlite3)中的 users 表,如图 6.9 所示。[20](如果使用云端 IDE,要按照图 6.6 中的方法下载数据库文件。)留意图 6.8 中数据模型的各个属性。

sqlite user row with password 4th edition
图 6.9:SQLite 数据库 db/development.sqlite3 中的一个用户记录

回到控制台,查看 password_digest 属性的值,由此可以看出代码清单 6.42has_secure_password 方法的作用:

>> user = User.find_by(email: "mhartl@example.com")
>> user.password_digest
=> "$2a$10$xxucoRlMp06RLJSfWpZ8hO8Dt9AZXlGRi3usP3njQg3yOcVFzb6oK"

这是创建用户对象时指定的密码("foobar")的哈希值。这个值由 bcrypt 计算得出,很难反推出原始密码。[21]

6.3.1 节说过,has_secure_password 方法会自动在对应的模型对象中添加 authenticate 方法。这个方法会计算给定密码的哈希值,然后与数据库中 password_digest 列的值比较,以此判断用户提供的密码是否正确。我们可以在刚创建的用户上试几个错误密码:

>> user.authenticate("not_the_right_password")
false
>> user.authenticate("foobaz")
false

我们提供的密码都是错误的,所以 user.authenticate 返回 false。如果提供正确的密码,authenticate 方法会返回数据库中对应的用户:

>> user.authenticate("foobar")
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com",
created_at: "2016-05-23 20:36:46", updated_at: "2016-05-23 20:36:46",
password_digest: "$2a$10$xxucoRlMp06RLJSfWpZ8hO8Dt9AZXlGRi3usP3njQg3...">

第 8 章会使用 authenticate 方法把注册的用户登入网站。其实,authenticate 方法返回的用户对象并不重要,关键是这个值是“真值”。4.2.3 节 说过,!! 会把对象转换成相应的布尔值。我们可以使用这种方式确认 user.authenticate 方法很好地完成了任务:

>> !!user.authenticate("foobar")
=> true
练习
  1. 退出控制台,然后重启,查找本节创建的用户。

  2. 尝试修改这个用户的名字,然后调用 save 方法。为什么不起作用?

  3. 把这个用户的名字改成你自己的名字。提示:所需的技术参见 6.1.5 节

6.4 小结

本章从零开始建立了一个可以正常使用的 User 模型,创建了 nameemailpassword 属性,还为这些属性制定了重要的取值约束规则。而且,已经可以使用密码对用户进行身份验证了。整个 User 模型只用了十行代码。

在接下来的第 7 章,我们将创建一个注册表单,用于新建用户;还将创建一个页面,显示用户的信息。第 8 章会使用 6.3 节实现的身份验证机制让用户登录网站。

如果使用 Git,而且一直都没提交,现在是提交的好时机:

$ rails test
$ git add -A
$ git commit -m "Make a basic User model (including secure passwords)"

然后合并到主分支,再推送到远程仓库中:

$ git checkout master
$ git merge modeling-users
$ git push

为了让 User 模型在生产环境中能正常使用,我们要在 Heroku 中执行迁移。这个操作可以通过 heroku run 命令完成:

$ rails test
$ git push heroku
$ heroku run rails db:migrate

我们可以在生产环境的控制台中执行下述代码确认一下:

$ heroku run rails console --sandbox
>> User.create(name: "Michael Hartl", email: "michael@example.com",
?>             password: "foobar", password_confirmation: "foobar")
=> #<User id: 1, name: "Michael Hartl", email: "michael@example.com",
created_at: "2016-05-23 20:54:41", updated_at: "2016-05-23 20:54:41",
password_digest: "$2a$10$74xFguZRoTZBXTUqs1FjpOf3OoLhrvgxC2wlohtTEcH...">

6.4.1 本章所学

  • 使用迁移可以修改应用的数据模型;

  • Active Record 提供了很多创建和处理数据模型的方法;

  • 使用 Active Record 验证可以在模型的数据上添加约束条件;

  • 常见的验证有存在性、长度和格式;

  • 正则表达式晦涩难懂,但功能强大;

  • 数据库索引可以提升查询效率,而且能在数据库层实现唯一性约束;

  • 可以使用内置的 has_secure_password 方法在模型中添加一个安全的密码。

  1. Active Record 这个名称来自“Active Record 模式”,出自 Martin Fowler 写的《企业应用架构模式》一书。
  2. SQL 的官方读音是“ess-cue-ell”,不过也经常读作“sequel”。
  3. 把电子邮件地址作为用户名,以后如果需要和用户联系就方便了(参见第 11 章第 12 章)。
  4. 别管 t 对象是怎么实现的,这是抽象层(abstraction layer)的东西,我们无需知道。你只要相信 t 能完成指定的工作就行了。
  5. SQLite 读作“ess-cue-ell-ite”,不过倒是经常使用错误的读音“sequel-ite”。
  6. 14.3.3 节是唯一的例外。
  7. 时间戳使用协调世界时(Coordinated Universal Time,简称 UTC)记录,UTC 的作用类似于格林尼治标准时间(Greenwich Mean Time,简称 GMT)。以下内容摘自美国国家标准技术研究所时间和频率司的常见问题解答页面。问:为什么协调世界时的缩写是 UTC 而不是 CUT?答:协调世界时是在 1970 年由国际电信联盟(International Telecommunication Union,简称 ITU)的专家顾问团设计的,ITU 觉得应该使用一个通用的缩写以避免混淆,因为各方无法达成共识,最终 ITU 没有采用英文缩写 CUT 或法文缩写 TUC ,而是折中选择了 UTC。
  8. 异常和异常处理是 Ruby 语言相对高级的功能,本书基本不会用到。不过异常是 Ruby 语言很重要的一部分,建议你阅读 14.4.1 节推荐的书籍学习。
  9. update_attributes 方法是 update 方法的别名,但我喜欢使用前者,因为它和单数形式 update_attribute 对应。
  10. 如果结果不难想象,我会省略在控制台中执行命令得到的结果,例如 User.new 的返回值。
  11. 你知道吗,根据标准,"Michael Hartl"@example.com 虽有引号和空格,但也是有效的电子邮件地址?很不可思议吧。
  12. 注意,表 6.1 中的“字母”指的是“小写字母”,正则表达式末尾的 i 指定匹配时不区分大小写。
  13. 如果你和我一样觉得 Rubular 很有用,建议你向 Rubular 的作者 Michael Lovitt 适当捐献一些钱,感谢他的辛勤劳动。
  14. 如前所述,这里要使用测试专用的数据库 db/test.sqlite3
  15. 严格来说,只有域名部分不区分大小写,foo@bar.comFoo@bar.com 其实是不同的地址。但在实际使用中,千万别依赖这个规则。about.com 中的文章说道,“区分大小写的电子邮件地址会带来很多麻烦,不易互换使用,也不利传播,所以要求输入正确的大小写是很愚蠢的。几乎没有电子邮件服务提供商或 ISP 强制要求使用区分大小写的电子邮件地址,也不会提示收件人的大小写错了(例如,要全部大写)。”感谢读者 Riley Moses 指正这个问题。
  16. 当然,我们可以直接编辑创建 users 表的迁移文件(代码清单 6.2),不过,这样要先回滚再迁移。这不是 Rails 的风格,正确的做法是每次修改数据结构都使用迁移。
  17. Rails 支持的回调参见 Rails API
  18. “虚拟”的意思是模型对象中有属性,但数据库中没有对应的列。
  19. “密码哈希摘要”经常称作“加密密码”,这种叫法是错误的。Rails 源码和本书前两版都犯了这个错误。“加密密码”之所以不对,是因为从设计角度看,加密是可逆的——能加密,也能解密。而计算密码哈希摘要的目的是实现不可逆,由摘要很难推出原始密码。感谢读者 Andy Philips 指出这个问题,并建议我使用正确的术语。
  20. 如果出现问题,可以使用这个方法还原数据库:1)退出控制台;2)在命令行中执行 $ rm -f development.sqlite3 命令,删除数据库(第 7 章会介绍一种更优雅的做法);3)执行 rails db:migrate 命令,重新运行迁移;4)重启控制台。
  21. bcrypt 算法计算哈希值时会加盐,这样做能避免两种重要的攻击方式:字典攻击彩虹表攻击