此文翻譯自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”中的文章。