解讀Rails – 屬性方法

Martin91發表於2014-11-05

此文翻譯自Reading Rails – Attribute Methods,限於本人水平,翻譯不當之處,敬請指教!

在我們上一篇的探討中,我們已經看到了Rails在跟蹤屬性變更中使用到的屬性方法(attribute methods)。有三種型別的屬性方法:字首式(prefix)、字尾式(suffix)以及固定詞綴式( affix)。為了表述簡潔,我們將只關注類似attribute_method_suffix這樣的字尾式屬性方法,並且特別關注它是如何幫助我們實現類似name這樣的模型屬性以及對應生成的類似name_changed?這樣的方法的。

如果需要跟著我的步驟走,請使用qwandry開啟每一個相關的程式碼庫,或者直接從github檢視原始碼即可。

宣告(Declarations)

屬性方法是Rails中眾多使用了超程式設計技術的案例之一。在超程式設計中,我們編寫可以編寫程式碼的程式碼。舉例來說,attribute_method_suffix字尾式方法是一個為每個屬性都定義了一個helper方法的方法。在之前的討論中,ActiveModel使用這種方式為您的每一個屬性都定義了一個_changed?方法(提示: 命令列中鍵入qw activemodel檢視程式碼):

module Dirty
  extend ActiveSupport::Concern
  include ActiveModel::AttributeMethods

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

讓我們開啟ActiveModel庫中的attribute_methods.rb檔案,並且看一下到底發生了什麼事情。

def attribute_method_suffix(*suffixes)
  self.attribute_method_matchers += suffixes.map! do |suffix|
    AttributeMethodMatcher.new suffix: suffix
  end
  #...
end

當你呼叫attribute_method_suffix方法的時候,每一個字尾都通過map!方法轉換為一個AttributeMethodMatcher物件。這些物件會被儲存在attribute_method_matchers中。如果你重新看一下這個module的頂部,你會發現attribute_method_matchers是在每一個包含此module的類中使用class_attribute定義的方法:

module AttributeMethods
  extend ActiveSupport::Concern

  included do
    class_attribute :attribute_aliases,
                    :attribute_method_matchers,
                    instance_writer: false
    #...

class_attribute方法幫助你在類上定義屬性。你可以這樣在你自己的程式碼中這樣使用:

class Person
  class_attribute :database
  #...
end

class Employee < Person
end

Person.database = Sql.new(:host=>`localhost`)
Employee.database #=> <Sql:host=`localhost`>

Ruby中並沒有class_attribute的內建實現,它是在ActiveSupport(提示:命令列中鍵入qw activesupport檢視程式碼)中定義的方法。如果你對此比較好奇,可以簡單看下attribute.rb

現在我們來看一下AttributeMethodMatcher

class AttributeMethodMatcher #:nodoc:
  attr_reader :prefix, :suffix, :method_missing_target

  def initialize(options = {})
    #...
    @prefix, @suffix = options.fetch(:prefix, ``), options.fetch(:suffix, ``)
    @regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/
    @method_missing_target = "#{@prefix}attribute#{@suffix}"
    @method_name = "#{prefix}%s#{suffix}"
  end

程式碼中的prefix以及suffix是通過Hash#fetch方法提取出來的。這會返回一個對應鍵的值,或者是一個預設值。如果呼叫方法的時候沒有提供預設值,Hash#fetch方法將會丟擲一個異常,提示指定的鍵不存在。對於options的處理來說是一種不錯的模式,特別是對於boolean型資料來說:

options = {:name => "Mortimer", :imaginary => false}
# Don`t do this:
options[:imaginary] || true     #=> true
# Do this:
options.fetch(:imaginary, true) #=> false

對於我們的attribute_method_suffix其中的`_changed`示例來說,AttributeMethodMatcher將會有如下的例項變數:

@prefix                #=> ""
@suffix                #=> "_changed?"
@regex                 #=> /^(?:)(.*)(?:_changed?)$/
@method_missing_target #=> "attribute_changed?"
@method_name           #=> "%s_changed?"

你一定想知道%s_changed中的%s是用來幹什麼的吧?這是一個格式化字串(format string)。你可以使用sprintf方法對它插入值,或者使用縮寫(shortcut)%

sprintf("%s_changed?", "name") #=> "named_changed?"
"%s_changed?" % "age"          #=> "age_changed?"

第二個比較有趣的地方就是正規表示式建立的方式。請留意建立@regex變數時Regexp.escape的用法。如果字尾沒有被escape,則正規表示式中帶有特殊含義的符號將會被錯誤解釋(misinterpreted):

# Don`t do this!
regex = /^(?:#{@prefix})(.*)(?:#{@suffix})$/ #=> /^(?:)(.*)(?:_changed?)$/
regex.match("name_changed?")                 #=> nil
regex.match("name_change")                   #=> #<MatchData "name_change" 1:"name">

# Do this:
@regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/
regex.match("name_changed?")                 #=> #<MatchData "name_changed?" 1:"name">
regex.match("name_change")                   #=> nil

請仔細記住regex以及method_name,它們可以用來匹配和生成屬性方法,我們在後面還會繼續用到它們。

我們現在已經搞明白了屬性方法是如何宣告的,但是實際中,Rails又是如何使用它們的呢?

通過Method Missing呼叫(Invocation With Method Missing)

當我們呼叫了一個未定義的方法時,Rails將會在丟擲異常之前呼叫物件的method_missing方法。讓我們看看Rails是如何利用這個技巧呼叫屬性方法的:

def method_missing(method, *args, &block)
  if respond_to_without_attributes?(method, true)
    super
  else
    match = match_attribute_method?(method.to_s)
    match ? attribute_missing(match, *args, &block) : super
  end
end

傳給method_missing方法的第一個引數是一個用symbol型別表示的方法名,比如,我們的:name_changed?*args是(未定義的)方法被呼叫時傳入的所有引數,&block是一個可選的程式碼塊。Rails首先通過呼叫respond_to_without_attributes方法檢查是否有別的方法可以對應這次呼叫。如果別的方法可以處理這次呼叫,則通過super方法轉移控制權。如果找不到別的方法可以處理當前的呼叫,ActiveModel則會通過match_attribute_method?方法檢查當前呼叫的方法是否是一個屬性方法。如果是,它則會接著呼叫attribute_missing方法。

match_attribute_method方法利用了之前宣告過的AttributeMethodMatcher物件:

def match_attribute_method?(method_name)
  match = self.class.send(:attribute_method_matcher, method_name)
  match if match && attribute_method?(match.attr_name)
end

在這個方法裡邊發生了兩件事。第一,Rails查詢到了一個匹配器(matcher),並且檢查這是否真的是一個屬性。說實話,我自己也是比較迷惑,為什麼match_attribute_method?方法呼叫的是self.class.send(:attribute_method_matcher, method_name),而不是self.attribute_method_matcher(method_name),但是我們還是可以假設它們的效果是一樣的。

如果我們再接著看attribute_method_matcher,就會發現它的最核心的程式碼僅僅只是掃描匹配了AttributeMethodMatcher例項,它所做的事就是對比物件本身的正規表示式與當前的方法名:

def attribute_method_matcher(method_name)
  #...
  attribute_method_matchers.detect { |method| method.match(method_name) }
  #...
end

如果Rails找到了匹配當前呼叫的方法的屬性,那麼接下來所有引數都會被傳遞給attribute_missing方法:

def attribute_missing(match, *args, &block)
  __send__(match.target, match.attr_name, *args, &block)
end

這個方法將匹配到的屬性名以及傳入的任意引數或者程式碼塊代理給了match.target。回頭看下我們的例項變數,match.target將會是attribute_changed?,而且match.attr_name則是”name”。__send__方法將會呼叫attribute_changed?方法,或者是你定義的任意一個特殊的屬性方法。

超程式設計(Metaprogramming)

有很多的方式可以對一個方法的呼叫進行分發(dispatch),如果這個方法經常被呼叫,那麼實現一個name_changed?方法將會更為有效。Rails通過define_attribute_methods方法做到了對這類屬性方法的自動定義:

def define_attribute_methods(*attr_names)
  attr_names.flatten.each { |attr_name| define_attribute_method(attr_name) }
end

def define_attribute_method(attr_name)
  attribute_method_matchers.each do |matcher|
    method_name = matcher.method_name(attr_name)

    define_proxy_call true,
                      generated_attribute_methods,
                      method_name,
                      matcher.method_missing_target,
                      attr_name.to_s
  end
end

matcher.method_name使用了我們前面見到過的格式化字串,並且插入了attr_name。在我們的例子中,"%s_changed?"變成了"name_changed?"。現在我們我們準備好了瞭解在define_proxy_call中的超程式設計。下面是這個方法被刪掉了一些特殊場景下的程式碼的版本,你可以在閱讀完這篇文章後自己去了解更多的程式碼。

def define_proxy_call(include_private, mod, name, send, *extra)
  defn = "def #{name}(*args)"
  extra = (extra.map!(&:inspect) << "*args").join(", ")
  target = "#{send}(#{extra})"

  mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    #{defn}
      #{target}
    end
  RUBY
end

這裡為我們定義了一個新的方法。name就是正要被定義的方法名,而send則是處理器(handler),另外的extra是屬性名。mod引數是一個Rails用generated_attribute_methods方法生成的特殊的模組(module),它被嵌入(mixin)到我們的類中。現在讓我們多看一下module_eval方法。這裡有三件有趣的事情發生了。

第一件事就是HEREDOC被用作一個引數傳給了一個方法。這是有點難懂的,但是對某些場景卻是非常有用的。舉個例子,想象我們在一個伺服器響應(response)中有一個方法要用來嵌入Javascript程式碼:

include_js(<<-JS, :minify => true)
  $(`#logo`).show();
  App.refresh();
JS

這將會把字串"$(`#logo`).show(); App.refresh();"作為呼叫include_js時傳入的第一個引數,而:minify => true作為第二個引數。在Ruby中需要生成程式碼時,這是一個非常有用的技巧。值得高興的是,諸如TextMate這類編輯器都能夠識別這個模式,並且正確地高亮顯示字串。即使你並不需要生成程式碼,HEREDOC對於多行的字串也是比較有用的。

現在我們就知道了<<-RUBY做了些什麼事,但是__FILE__以及__LINE__ + 1呢?__FILE__返回了當前檔案的(相對)路徑,而__LINE__返回了當前程式碼的行號。module_eval接收這些引數,並通過這些引數決定新的程式碼定義在檔案中“看起來”的位置。在對於棧跟蹤(stack traces)來說是特別有用的。

最後,讓我們看一些module_eval中實際執行的程式碼。我們可以把值替換成我們的name_changed?

mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
  def name_changed?(*args)
    attribute_changed?("name", *args)
  end
RUBY

現在name_changed?就是一個真實的方法了,比起依賴於method_missing方法的實現,這種方法的開銷要小得多。

總結(Recap)

我們發現了呼叫attribute_method_suffix方法會儲存一個配置好的物件,這個物件用於Rails中兩種超程式設計方法中的一種。不考慮是否使用了method_missing,或者通過module_eval定義了新的方法,方法的呼叫最後總會被傳遞到諸如attribute_changed?(attr)這樣的方法上。

走過這次比較寬泛的旅途,我們也收穫了一些有用的技巧:

  • 你必須使用Hash#fetch從options中讀取引數,特別是對於boolean型別引數來說。
  • 諸如"%s_changed"這樣的格式化字串,可以被用於簡單的模板。
  • 可以使用Regexp.escapeescape正規表示式。
  • 當你試圖呼叫一個未定義的方法時,Ruby會呼叫method_missing方法。
  • HEREDOCs可以用在方法引數中,也可以用來定義多行的字串。
  • __FILE__以及__LINE__指向當前的檔案以及行號。
  • 你可以使用module_eval動態生成程式碼。

堅持瀏覽Rails的原始碼吧,你總會發現你原本不知道的寶藏!

喜歡這篇文章?

閱讀另外8篇《解讀Rails》中的文章。

相關文章