跟踪一下define_attribute_method注释中的例子

[1] pry(main)> binding.trace_tree(htmp: 'rails/attribute_methods') do
[1] pry(main)*   class Person
[1] pry(main)*     include ActiveModel::AttributeMethods
[1] pry(main)*
[1] pry(main)*     attr_accessor :name
[1] pry(main)*     attribute_method_suffix '_short?'
[1] pry(main)*
[1] pry(main)*     # Call to define_attribute_method must appear after the
[1] pry(main)*     # attribute_method_prefix, attribute_method_suffix or
[1] pry(main)*     # attribute_method_affix declarations.
[1] pry(main)*     define_attribute_method :name
[1] pry(main)*
[1] pry(main)*     private
[1] pry(main)*
[1] pry(main)*     def attribute_short?(attr)
[1] pry(main)*       send(attr).length < 5
[1] pry(main)*     end
[1] pry(main)*   end
[1] pry(main)* end
=> :attribute_short?


完整调用栈如下

20170715_210556_908_attribute_methods.html

首先,include了ActiveModel::AttributeMethods的类都会带有attribute_aliases和attribute_method_matchers类变量

included do
  class_attribute :attribute_aliases, :attribute_method_matchers, instance_writer: false
  self.attribute_aliases = {}
  self.attribute_method_matchers = [ClassMethods::AttributeMethodMatcher.new]
end


检查这两个类变量,会发现在刚才整套Person类定义下来,attribute_method_matchers除了included时的ClassMethods::AttributeMethodMatcher.new,还多了一个attribute_short?的ActiveModel::AttributeMethods::ClassMethods::AttributeMethodMatcher

[3] pry(main)> person.attribute_method_matchers
=> [#<activemodel::attributemethods::classmethods::attributemethodmatcher:0x007f7b1a45d520 @method_missing_target="attribute" ,="" @method_name="%s" ,="" @prefix="" ,="" @regex="/^(?:)(.*)(?:)$/," @suffix="">,
 #<activemodel::attributemethods::classmethods::attributemethodmatcher:0x007f7b1a24fc88 @method_missing_target="attribute_short?" ,="" @method_name="%s_short?" ,="" @prefix="" ,="" @regex="/^(?:)(.*)(?:_short\?)$/," @suffix="_short?">]
[4] pry(main)> person.attribute_aliases
=> {}</activemodel::attributemethods::classmethods::attributemethodmatcher:0x007f7b1a24fc88></activemodel::attributemethods::classmethods::attributemethodmatcher:0x007f7b1a45d520>


此外,因为ActiveModel::AttributeMethods是有concern的,所以Person会获得以下类方法

[9] pry(main)> ActiveModel::AttributeMethods::ClassMethods.instance_methods
=> [:alias_attribute,
 :define_attribute_methods,
 :generated_attribute_methods,
 :undefine_attribute_methods,
 :attribute_method_prefix,
 :attribute_method_suffix,
 :attribute_method_affix,
 :attribute_alias?,
 :attribute_alias,
 :define_attribute_method]


接着来看Person用到的attribute_method_suffix和define_attribute_method。首先是attribute_method_suffix,它所做的就是两件事:新建一个AttributeMethodMatcher,然后undefine_attribute_methods

def attribute_method_suffix(*suffixes)
  self.attribute_method_matchers += suffixes.map! { |suffix| AttributeMethodMatcher.new suffix: suffix }
  undefine_attribute_methods
end


AttributeMethodMatcher的作用,从其内部的一些命名来看,似乎是用于method_missing时,检查是否能调用有所call的带有prefix/suffix的方法

class AttributeMethodMatcher
  attr_reader :prefix, :suffix, :method_missing_target

  AttributeMethodMatch = Struct.new(:target, :attr_name, :method_name)

  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

  def match(method_name)
    if @regex =~ method_name
      AttributeMethodMatch.new(method_missing_target, $1, method_name)
    end
  end

  def method_name(attr_name)
    @method_name % attr_name
  end

  def plain?
    prefix.empty? && suffix.empty?
  end
end


而undefine_attribute_methods则是……比较复杂,目前只看出它使用到一个匿名module,这个匿名module会被include到当前类中,估计用于定义方法并归纳它们,但instance_methods.each { |m| undef_method(m) }和attribute_method_matchers_cache又是为什么呢?暂时先不管

def undefine_attribute_methods
  generated_attribute_methods.module_eval do
    instance_methods.each { |m| undef_method(m) }
  end
  attribute_method_matchers_cache.clear
end

def generated_attribute_methods
  @generated_attribute_methods ||= Module.new {
    extend Mutex_m
  }.tap { |mod| include mod }
end

private

  # The methods +method_missing+ and +respond_to?+ of this module are
  # invoked often in a typical rails, both of which invoke the method
  # +matched_attribute_method+. The latter method iterates through an
  # array doing regular expression matches, which results in a lot of
  # object creations. Most of the time it returns a +nil+ match. As the
  # match result is always the same given a +method_name+, this cache is
  # used to alleviate the GC, which ultimately also speeds up the app
  # significantly (in our case our test suite finishes 10% faster with
  # this cache).
  def attribute_method_matchers_cache
    @attribute_method_matchers_cache ||= Concurrent::Map.new(initial_capacity: 4)
  end


于是,便轮到示例所说的只能(还是必须?)在attribute_method_prefix、attribute_method_suffix、attribute_method_affix之后使用的define_attribute_method,其相关代码如下

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

    unless instance_method_already_implemented?(method_name)
      generate_method = "define_method_#{matcher.method_missing_target}"

      if respond_to?(generate_method, true)
        send(generate_method, attr_name.to_s)
      else
        define_proxy_call true, generated_attribute_methods, method_name, matcher.method_missing_target, attr_name.to_s
      end
    end
  end
  attribute_method_matchers_cache.clear
end

private
  def define_proxy_call(include_private, mod, name, send, *extra)
    defn = if NAME_COMPILABLE_REGEXP.match?(name)
      "def #{name}(*args)"
    else
      "define_method(:'#{name}') do |*args|"
    end

    extra = (extra.map!(&:inspect) << "*args").join(", ".freeze)

    target = if CALL_COMPILABLE_REGEXP.match?(send)
      "#{"self." unless include_private}#{send}(#{extra})"
    else
      "send(:'#{send}', #{extra})"
    end

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


简单来说,就是遍历已定义的prefix/suffix/affix(attribute_method_matchers),拼接出最终想要的方法名,即prefix + attr_name + suffix,然后define到匿名module(generated_attribute_methods)中,对于本例,就是def一个name_short?方法。

而unless instance_method_already_implemented?的作用,估计是为了避免调用define_attribute_method时给了重复的属性名,导致重复定义prefix + attr_name + suffix方法

而prefix + attr_name + suffix的方法体,即target部分,可以推导出,是send matcher.method_missing_target, attr_name.to_s,其中method_missing_target是AttributeMethodMatcher 约定的"#{@prefix}attribute#{@suffix}",attr_name.to_s则是属性名,对于本例,大概就是send attribute_short?(:name)。因此,如示例所述,如果你指定了suffix方法_short?,则你需要定义一个attribute_short?(attr)方法,以便在匿名module调用时,能查找到子类(当前类)的这个方法。所以,name_short?的调用栈非常简单

[29] pry(main)> person.name = 'ken'
=> "ken"
[30] pry(main)> binding.trace_tree(htmp: 'rails/call_attribute_method'){person.name_short?}
=> true


如下

20170715_232024_575_call_attribute_method.html

至此,ActiveModel::AttributeMethods的机制基本已明了。若想看复杂一点的用例,源码开头也是有给的。或者,也可看看dirty.rb

class Person
  include ActiveModel::AttributeMethods

  attribute_method_affix  prefix: 'reset_', suffix: '_to_default!'
  attribute_method_suffix '_contrived?'
  attribute_method_prefix 'clear_'
  define_attribute_methods :name

  attr_accessor :name

  def attributes
    { 'name' => @name }
  end

  private

  def attribute_contrived?(attr)
    true
  end

  def clear_attribute(attr)
    send("#{attr}=", nil)
  end

  def reset_attribute_to_default!(attr)
    send("#{attr}=", 'Default Name')
  end
end


至于undefine_attribute_methods,它规定了你所有attribute_method_xxx必须定在所有define_attribute_method(s)之前,否则,后来的一个attribute_method_xxx中的undefine_attribute_methods就会抹掉之前在匿名module中动态定义的方法。可以认为它是为了规范代码结构,令你的pre/suf方法定义凑在一起,而非分散到文件的头部尾部。当然,也可以认为它是一个坑

[35] pry(main)> class Person
[35] pry(main)*   attribute_method_suffix '_long?'
[35] pry(main)* end
=> #
[36] pry(main)> person.name_short?
NoMethodError: undefined method `name_short?' for #<person:0x007f7b1a15e9f0 @name="ken">
from /home/z/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/activemodel-5.1.2/lib/active_model/attribute_methods.rb:432:in `method_missing'</person:0x007f7b1a15e9f0>


此外,也因为这种undef机制,以及匿名module被归为当前类的实例变量,而每次def/undef都从当前类的取出该实例变量来作用域该匿名module,所以,如果想为不同的属性添加不同的pre/suf,则需要将不同的“attribute_method_xxx和define_attribute_method(s)”写在不同的module中,例如dirty.rb就单独开来一个module。这同样也达到了归类pre/suf的目的

而attribute_method_matchers_cache,暂不深究……