Active Record: 进阶功能

ActiveRecord - 进阶功能

Most of you are familiar with the virtues of a programmer. There are three, of course: laziness, impatience, and hubris. - Larry Wall

本章介绍其他 ActiveRecord 的常用进阶功能。

单一表格继承STI(Single-table inheritance)

如何将物件导向中的继承概念,对应到关联式数据库的设计,是个大哉问。 Rails 内建了其中最简单的一个解法,只用一个资料表储存继承体系中的物件,搭配一个type字段用来指名这笔资料的类别名称。

要开启 STI 功能,依照惯例只要有一个字段叫做type,型态字串即可。假设以下的contacts资料表有字段叫做type,那么这三个 Models 实际上就会共享contacts一个资料表,当然,还有这两个子类别也都继承到父类别的validates_presence_of :name:

class Contact < ApplicationRecord
    validates_presence_of :name
end

class Company < Contact
end

class Person < Contact
end

让我们进入 rails console 实验看看, Rails 会根据你使用的类别,自动去设定type字段:

contact = Person.create( :name => "ihower")
contact.type # "Person"
contact.id # 1
contact = Company.create( :name => "ALPHA Camp" )
contact.id # 2
contact.type # "Company"

很遗憾,也因为这个惯例的关系,你不能将type这么名字挪做它用。

STI 最大的问题在于字段的浪费,如果继承体系中交集的字段不多,那么使用 STI 就会非常的浪费空间。如果有较多的不共享的字段,笔者会建议不要使用这个功能,让个别的类别有自己的资料表。要关闭 STI ,请父类别加上self.abstract_class = true

class Contact < ApplicationRecord
    self.abstract_class = true
    validates_presence_of :name
end

class Company < Contact
end

class Person < Contact
end

这样 Company 和 Person 就需要有自己的 Migrations 建立 companies 和 people 资料表了。

除了 STI 之外,在 Patterns of Enterprise Application Architecture 一书中也有介绍其他数据库处理OO继承的设计模式,包括Class Table Inheritance、Concrete Table Inheritance和Inheritance Mappers

交易Transactions

Transaction(交易)保证所有资料的操作都只有在成功的情况下才会写入到数据库,最着名的例子也就是银行的帐户交易,只有在帐户提领金额及存入帐户这两个动作都成功的情况下才会将这笔操作写入数据库,否则在其中一个动作因为某些原因失败的话就会放弃所有已做的操作将资料回复到交易前的状态。在 Rails 中使用交易的方式像这样:

ActiveRecord::Base.transaction do
  david.withdrawal(100)
  mary.deposit(100)
end

你可以在一个交易中包含不同 Active Record 的类别或物件,这是因为交易是以数据库连线为范围,而不是个别 Model :

User.transaction do
  User.create!(:name => 'ihower')
  Feed.create!
end

注意到这里我们要使用create!而不是create,这是因为前者验证失败才会丢出例外,好让整个交易失败。同理,在交易里做更新应该使用update!而不是update。

单一 Model 的save及destroy方法已经帮你使用 transaction 包起来了,当资料验证失败或其中的回呼发生例外时, Rails 就会触发 rollback 。所以下述的交易区块是多馀的不需要写:

User.transaction do # 这是多馀的
  User.create!(:name => 'ihower')
end

另外,由于资料的更新要在交易完成后才能被读取到,所以如果你在after_save回呼里让外部服务存取(例如呼叫全文搜寻引擎做索引),很可能因为交易尚未完成,会读取不到更新。这时候必须改用after_commit这个回呼,才能确保读取到交易完成后的资料。

Dirty objects

Dirty Objects 功能可以追踪 Model 的属性是否有改变:

person = Person.find_by_name('Uncle Bob')
person.changed?       # => false 没有改变任何值

## 让我们来改一些值
person.name = 'Bob'
person.changed?       # => true 有改变
person.name_changed?  # => true 这个属性有改变
person.name_was       # => 'Uncle Bob' 改变之前的值
person.name_change    # => ['Uncle Bob', 'Bob']
person.name = 'Bill'
person.name_change    # => ['Uncle Bob', 'Bill']

## 储存进数据库
person.save
person.changed?       # => false
person.name_changed?  # => false

## 看看哪些属性改变了
person.name = 'Bob'
person.changed        # => ['name']
person.changes        # => { 'name' => ['Bill', 'Bob'] }

注意到 Model 资料一旦储存进数据库,追踪记录就重算消失了。

什么时候会用到这个功能呢?通常是在储存进数据库前的回呼、验证或 Observer 中,你想根据修改了什么来做些动作,这时候 Dirty Objects 功能就派上用场了。

序列化Serialize

序列化(Serialize)通常指的是将一个物件转换成一个可被数据库储存及传输的纯文字形态,反之将这笔资料从数据库读出后转回物件的动作我们就称之为反序列(Deserialize), Rails 提供了serialize让你指定需要序列化资料的字段,任何物件在存入数据库时就会自动序列化成 YAML 格式,而当从数据库取出时就会自动帮你反序列成原先的物件。这个字段通常用 text 型态,有比较大的空间可以储存资料,然后将一个 Hash 物件序列化之后存进去。

常用的情境例如杂七杂八的使用者 settings :

class User < ApplicationRecord
  serialize :settings
end

> user = User.create(:settings => { "sex" => "male", "url" => "foo" })
> User.find(user.id).settings # => { "sex" => "male", "url" => "foo" }

或是一些不需要数据库索引和正规化的一整包资料,例如 KML 轨迹资料等等。

虽然序列化很方便可以让你储存任意的物件,但是缺点是序列化资料就失去了透过数据库查询索引的功效,你无法在 SQL 的 where 条件中指定序列化后的资料。

Store

Store 又在包裹了上一节的序列化功能,是个简单又实用的功能,让你可以将某个字段指定储存为 Hash 值。举例来说,上一节的 settings 也可以改用 store 来设定:

class User < ApplicationRecord
  store :settings, :accessors => [:sex, :url]
end

特别的是其中accessors用来设定可以直接存取的属性,这样就可以像平常一样那样操作 sex 和 url 这两个属性,让我们进 console 实验看看:

> user = User.new(:sex => "male", :url => "http://example.com")
> user.sex
 => "male"
> user.url
 => "http://example.com"
> user.settings
 => {:sex => "male", :url => "http://example.com"}

因为store就像使用 hash 一样,你也可以直接操作它,加入新的资料:

> user.settings[:food] = "pizza"
> user.settings
 => {:sex => "male", :url => "http://example.com", :food => "pizza"}

更多线上资源