此文翻译自Reading Rails - Change Tracking,限于本人水平,翻译不当之处,敬请指教!
我们今天来看看Rails是如何追踪model里边属性的变更的。
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
:
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?
:
def attribute_changed?(attr)
changed_attributes.include?(attr)
end
我们将会在另外的一篇文章中再着重介绍如何连接这些方法的细节,当你调用一个类似name_changed?
的方法时,Rails将会把"name"
作为参数attr
传给上述方法。往回看一点点,你会发现changed_attributes
只是一个包含了从属性名到旧的属性值的映射的Hash
而已:
# 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
。让我们这又是如何做到的:
def changes
ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }]
end
这段代码看起来有点难以理解,但是我们可以一步一步分析。首先我们从ActiveSupport::HashWithIndifferentAccess
开始,这是在ActiveSupport中所定义的Hash
的子类,通过字符串类型或者符号类型的键去访问它将得到一样的结果:
hash = ActiveSupport::HashWithIndifferentAccess.new
hash[:name] = "Mortimer"
hash["name"] #=> "Mortimer"
接下来就有点奇怪了,Rails调用了Hash[]
方法。这是一个鲜为人知的从包含键/值对的数组中初始化一个哈希表的方法。
Hash[
[:name, "Mortimer"],
[:species, "Crow"]
] #=> {[:name, "Mortimer"]=>[:species, "Crow"]}
可以查看Hash Tricks
找到更多类似的方法。changes
中剩余部分的代码就比较清晰了。属性名被映射到类似[attr, attribute_change(attr)]
的数组。其中第一个元素,也就是attr
编程了一个键,而对应的值则是attribute_change(attr)
返回的结果。
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
与逻辑以实现对变更的跟踪。
# 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查找当前对象的所有祖先,包括相关的模块。由于一个类可以引进多个模块,所以你可以多层地包装方法。这里是一个简单的例子:
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!"
请注意Shouting
是Speaker
所包含的模块,而不是后者所扩展的类。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中其他部分的建议,请让我知道。
喜欢这篇文章?
阅读另外8篇“解读Rails”中的文章。