6.1 用户模型

接下来的三章要实现网站的“注册”页面(构思图如图 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 master
$ git checkout -b modeling-users

6.1.1 数据库迁移

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

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

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

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

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

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

$ rails generate controller Users new

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

代码清单 6.1:生成用户模型
$ rails generate model User name:string email:string
      invoke  active_record
      create    db/migrate/20140724010738_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.4代码清单 5.28 中的动作名对比一下,看看有什么不同。

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

代码清单 6.2:用户模型的迁移文件(创建 users 表)

db/migrate/[timestamp]_create_users.rb

class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.string :name
      t.string :email

      t.timestamps null: false
    end
  end
end

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

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

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

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

$ bundle exec rake db:migrate

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

sqlite database browser 3rd edition图 6.5:在 SQLite 数据库浏览器中查看刚创建的 users

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

$ bundle exec rake db:rollback

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

如果你执行了上面的回滚操作,在继续阅读之前请再迁移回来:

$ bundle exec rake db:migrate

6.1.2 模型文件

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

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

代码清单 6.3:刚创建的用户模型

app/models/user.rb

class User < ActiveRecord::Base
end

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

6.1.3 创建用户对象

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

$ 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.13 中的代码才能使用 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: "[email protected]")
=> #<User id: nil, name: "Michael Hartl", email: "[email protected]", created_at: nil, updated_at: nil>

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

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

>> user.valid?
true

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

>> user.save
 (0.2ms)  begin transaction
 User Exists (0.2ms)  SELECT  1 AS one FROM "users"  WHERE LOWER("users".
 "email") = LOWER('[email protected]') LIMIT 1
 SQL (0.5ms)  INSERT INTO "users" ("created_at", "email", "name", "updated_at)
 VALUES (?, ?, ?, ?)  [["created_at", "2014-09-11 14:32:14.199519"],
 ["email", "[email protected]"], ["name", "Michael Hartl"], ["updated_at",
 "2014-09-11 14:32:14.199519"]]
 (0.9ms)  commit transaction
=> true

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

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

>> user
=> #<User id: 1, name: "Michael Hartl", email: "[email protected]",
created_at: "2014-07-24 00:57:46", updated_at: "2014-07-24 00:57:46">

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

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

>> user.name
=> "Michael Hartl"
>> user.email
=> "[email protected]"
>> user.updated_at
=> Thu, 24 Jul 2014 00:57:46 UTC +00:00

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

>> User.create(name: "A Nother", email: "[email protected]")
#<User id: 2, name: "A Nother", email: "[email protected]", created_at:
"2014-07-24 01:05:24", updated_at: "2014-07-24 01:05:24">
>> foo = User.create(name: "Foo", email: "[email protected]")
#<User id: 3, name: "Foo", email: "[email protected]", created_at: "2014-07-24
01:05:42", updated_at: "2014-07-24 01:05:42">

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

create 的逆操作是 destroy

>> foo.destroy
=> #<User id: 3, name: "Foo", email: "[email protected]", created_at: "2014-07-24
01:05:42", updated_at: "2014-07-24 01:05:42">

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

>> foo
=> #<User id: 3, name: "Foo", email: "[email protected]", created_at: "2014-07-24
01:05:42", updated_at: "2014-07-24 01:05:42">

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

6.1.4 查找用户对象

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

>> User.find(1)
=> #<User id: 1, name: "Michael Hartl", email: "[email protected]",
created_at: "2014-07-24 00:57:46", updated_at: "2014-07-24 00:57:46">

我们把用户的 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 无法在数据库中找到这个用户,抛出了一个异常,这说明在查找过程中出现了问题。因为 ID 不存在,所以 find 方法抛出 ActiveRecord::RecordNotFound 异常。[8]

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

>> User.find_by(email: "[email protected]")
=> #<User id: 1, name: "Michael Hartl", email: "[email protected]",
created_at: "2014-07-24 00:57:46", updated_at: "2014-07-24 00:57:46">

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

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

>> User.first
=> #<User id: 1, name: "Michael Hartl", email: "[email protected]",
created_at: "2014-07-24 00:57:46", updated_at: "2014-07-24 00:57:46">

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

>> User.all
=> #<ActiveRecord::Relation [#<User id: 1, name: "Michael Hartl",
email: "[email protected]", created_at: "2014-07-24 00:57:46",
updated_at: "2014-07-24 00:57:46">, #<User id: 2, name: "A Nother",
email: "[email protected]", created_at: "2014-07-24 01:05:24",
updated_at: "2014-07-24 01:05:24">]>

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

6.1.5 更新用户对象

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

>> user           # 只是为了查看 user 对象的属性是什么
=> #<User id: 1, name: "Michael Hartl", email: "[email protected]",
created_at: "2014-07-24 00:57:46", updated_at: "2014-07-24 00:57:46">
>> user.email = "[email protected]"
=> "[email protected]"
>> user.save
=> true

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

>> user.email
=> "[email protected]"
>> user.email = "[email protected]"
=> "[email protected]"
>> user.reload.email
=> "[email protected]"

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

>> user.created_at
=> "2014-07-24 00:57:46"
>> user.updated_at
=> "2014-07-24 01:37:32"

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

>> user.update_attributes(name: "The Dude", email: "[email protected]")
=> true
>> user.name
=> "The Dude"
>> user.email
=> "[email protected]"

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

>> user.update_attribute(:name, "The Dude")
=> true
>> user.name
=> "The Dude"