跟蹤model中屬性(值)的變更

Martin91發表於2014-11-05

此文翻譯自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!"

請注意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中其他部分的建議,請讓我知道。

喜歡這篇文章?

閱讀另外8篇“解讀Rails”中的文章。

相關文章