跟踪model中属性(值)的变更

此文翻译自Reading Rails – Change Tracking,限于本人水平,翻译不当之处,敬请指教!

我们今天来看看Rails是如何追踪model里边属性的变更的。

1
2
3
4
5
6
7
person = Person.find(8)
person.name = "Mortimer"
person.name_changed?    #=> true
person.name_was         #=> "Horton"
person.changes          #=> {"name"=>["Horton","Mortimer"]}
person.save!
person.changes          #=> {}

name_changed?方法是从哪来的呢?变更又是如何被创建的?让我们顺着这个场景,看看这一切背后的秘密。

如果需要跟着我的步骤走,请使用qwandry打开每一个相关的代码库,或者直接从github查看源码即可。

ActiveModel

当你想探寻ActiveRecord里边的功能时,你应该首先了解ActiveModel。ActiveModel(提示: 命令行中键入qw activemodel查看代码)定义了没有与数据库捆绑的逻辑。我们将从dirty.rb文件开始。在这个模块最开始的地方,代码调用了attribute_method_suffix

1
2
3
4
5
6
7
module Dirty
  extend ActiveSupport::Concern
  include ActiveModel::AttributeMethods

  included do
    attribute_method_suffix '_changed?', '_change', '_will_change!', '_was'
    #...

attribute_method_suffix定义了定制的属性读写器。这主要用来告诉Rails将一些带有类似_changed?后缀的调用分发到特定的处理器方法上。为了看看它们是如何实现的,请向下滚动代码,并且找到def attribute_changed?

1
2
3
def attribute_changed?(attr)
  changed_attributes.include?(attr)
end

我们将会在另外的一篇文章中再着重介绍如何连接这些方法的细节,当你调用一个类似name_changed?的方法时,Rails将会把"name"作为参数attr传给上述方法。往回看一点点,你会发现changed_attributes只是一个包含了从属性名到旧的属性值的映射的Hash而已:

1
2
3
4
5
6
7
8
9
# Returns a hash of the attributes with unsaved changes indicating their original
# values like <tt>attr => original value</tt>.
#
#   person.name # => "bob"
#   person.name = 'robert'
#   person.changed_attributes # => {"name" => "bob"}
def changed_attributes
  @changed_attributes ||= {}
end

在Ruby中,如果你之前都没有见过||=操作,那么你可能需要了解这其实是一个用于初始化变量值的技巧。当它第一次被访问的时候,变量的值是nil,所以它返回了一个空的Hash并且用其初始化@changed_attributes。当它再一次被访问的时候,@changed_attributes已经被赋值过了。那么现在我们可以回答我们的第一个问题了,name_changed?方法被转发到attribute_changed?方法,而后者会在changed_attributes中查找特定的值。

在我们的例子中,我们看到changes返回一个类似{"name"=>["Horton","Mortimer"]}这样既包含旧的属性值,又包含新的属性值的Hash。让我们这又是如何做到的:

1
2
3
def changes
  ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }]
end

这段代码看起来有点难以理解,但是我们可以一步一步分析。首先我们从ActiveSupport::HashWithIndifferentAccess开始,这是在ActiveSupport中所定义的Hash的子类,通过字符串类型或者符号类型的键去访问它将得到一样的结果:

1
2
3
hash = ActiveSupport::HashWithIndifferentAccess.new
hash[:name] = "Mortimer"
hash["name"] #=> "Mortimer"

接下来就有点奇怪了,Rails调用了Hash[]方法。这是一个鲜为人知的从包含键/值对的数组中初始化一个哈希表的方法。

1
2
3
4
Hash[
  [:name, "Mortimer"],
  [:species, "Crow"]
] #=> {[:name, "Mortimer"]=>[:species, "Crow"]}

可以查看Hash Tricks找到更多类似的方法。changes中剩余部分的代码就比较清晰了。属性名被映射到类似[attr, attribute_change(attr)]的数组。其中第一个元素,也就是attr编程了一个键,而对应的值则是attribute_change(attr)返回的结果。

1
2
3
def attribute_change(attr)
  [changed_attributes[attr], __send__(attr)] if attribute_changed?(attr)
end

这是另一个被分发的属性方法,但是在这个例子里,它返回了一个包含了两个元素的数组,第一个元素是从changed_attributes哈希表中读到的attr所对应的旧的值,第二个则是所对应的新的值。Rails通过使用__send__方法调用了名为attr的方法,进而得到新的属性值。然后这对值会被返回,并且用作changes哈希表中attr所对应的值。

ActiveRecord

现在让我们来找出Rails是如何记录更改的。ActiveRecord实现了读写ActiveModel所跟踪的属性的代码。跟ActiveModel一样,ActiveRecord也有一个dirty.rb文件,我们将要对这个文件进行挖掘。通过在定义了changed_attributes的文件中(提示:命令行中键入qw activerecord)找到的相关代码,我们可以看到这个文件包装了ActiveRecord的write_attribute与逻辑以实现对变更的跟踪。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Wrap write_attribute to remember original attribute value.
def write_attribute(attr, value)
  attr = attr.to_s

  # The attribute already has an unsaved change.
  if attribute_changed?(attr)
    old = @changed_attributes[attr]
    @changed_attributes.delete(attr) unless _field_changed?(attr, old, value)
  else
    old = clone_attribute_value(:read_attribute, attr)
    @changed_attributes[attr] = old if _field_changed?(attr, old, value)
  end

  # Carry on.
  super(attr, value)
end

让我们暂时偏离一下主题,并且看一下方法的包装。这是在Rails的代码里边非常常见的模式。当你调用super的时候,Ruby查找当前对象的所有祖先,包括相关的模块。由于一个类可以引进多个模块,所以你可以多层地包装方法。这里是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module Shouting
  def say(message)
    message.upcase
  end
end

class Speaker
  include Shouting

  def say(message)
    puts super(message)
  end
end

Speaker.new.say("Hi!") #=> "HI!"

请注意ShoutingSpeaker所包含的模块,而不是后者所扩展的类。Rails使用这种技巧去包装方法,以此确保在不同的文件里有独立的关注点(Concern)。这也意味着为了了解整个系统,你可能需要从多个文件里边找到相关的代码。假如你看到了一个对super的调用,这是一个可以告诉你在别的地方还有更多代码需要了解的好线索。假如你想学习更多的这方面的知识,James Coglan有一个非常详细的文章讲解了Ruby的方法分发

回到write_attribute方法。根据属性(值)是否已经改变,会有两个可能的场景。第一个分支检查你是否正在将一个属性(值)还原到原来的值,如果是这样,它将会从记录了已改变属性的哈希表中删除属性。第二个分支仅仅在新的值与旧的值不同的时候记录下更改。一旦更改被记录下来,实际的用于更新属性的逻辑通过调用super方法完成。

总结

Rails为你的model提供了变更的跟踪。这个功能是在ActiveModel中实现的,但是真正的监测更改的逻辑则是在ActiveRecord中实现的。

通过了解这个功能,我们也发掘到了一些有趣的小贴士:

  • ActiveModel定义了attribute_method_suffix方法用于分发类似name_changed?的方法。
  • ||=操作符是一个可以用来初始化变量的方便的方法。
  • HashWithIndifferentAccess中,字符串类型以及符号类型的键是一样的。
  • Hash可以通过Hash[key_value_pairs]方法初始化。
  • 你可以使用模块拦截方法并为方法加上另一层的功能。

假如你有关于你想阅读的关于Rails中其他部分的建议,请让我知道。

喜欢这篇文章?

阅读更多“解读Rails”中的文章。“解读Rails”中的文章。

Comments