概要
ActiveModel::AttributeMethodsはクラスの各attributeにprefix, suffix, affixをつけたメソッドをまとめて定義出来る便利なモジュールです。
使い方はActiveModel::AttributeMethodsをインクルードしたクラスで
- attribute_xxxxメソッドをprefix, suffixを引数にして呼び出し
- define_attribute_methodsをattribute_メソッドを定義したいattributeの名前を引数にして呼び出し
- 対応するattributeメソッドを定義
- prefixの場合 : #{prefix}_attributeメソッド
- suffixの場合はattribute_#{suffix}メソッド
- affixの場合は#{prefix}attribute#{suffix}メソッド
することでprefix, suffix, affixをつけたメソッドが動的に定義されます。
class Person
include ActiveModel::AttributeMethods
# attribute_xxxxメソッドをprefix, suffixを引数にして呼び出し
attribute_method_prefix 'clear_'
attribute_method_suffix '_contrived?'
attribute_method_affix prefix: 'reset_', suffix: '_to_default!'
# define_attribute_methodsをattribute_メソッドを定義したいattributeの名前を引数にして呼び出し
define_attribute_methods :name
attr_accessor :name
def attributes
{ 'name' => @name }
end
private
# 対応するattributeメソッドを定義
## prefixの場合 : #{prefix}_attributeメソッド
def clear_attribute(attr)
send("#{attr}=", nil)
end
## suffixの場合はattribute_#{suffix}メソッド
def attribute_contrived?(attr)
true
end
## affixの場合は#{prefix}_attribute_#{suffix}メソッド
def reset_attribute_to_default!(attr)
send("#{attr}=", 'Default Name')
end
end
# > person = Person.new
# > person.clear_name
# => nil
# > person.name_contrived?
# => true
# > person.reset_name_to_default!
# => "Default Name"
仕組み
具体例として上記の
attribute_method_prefix 'clear_'
の実装を追ってみます。
ソースコードを見ると
# Declares a method available for all attributes with the given prefix.
# Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method.
#
# #{prefix}#{attr}(*args, &block)
#
# to
#
# #{prefix}attribute(#{attr}, *args, &block)
# ~略
def attribute_method_prefix(*prefixes)
とありmethod_missingとrespond_to?を使ってメソッドを
clear_name(*args, &block)
から
clear_attribute(:name, *args, &block)
に書き換えているとあります。
実際に実装を追ってみます。
まずクラス生成時にAttributeMethods.attribute_method_prefixが呼ばれます
この例では1つしか引数を渡していませんがまとめて複数渡すことが出来るので可変長引数になっています。
def attribute_method_prefix(*prefixes)
self.attribute_method_matchers += prefixes.map! { |prefix| AttributeMethodMatcher.new prefix: prefix }
undefine_attribute_methods
end
1行目で各prefixをattribute_method_matchersにセットします。AttributeMethodMatcherの詳細は省きますが、結果として以下のようAttributeMethodMatcherオブジェクトがself.attribute_method_matchersにセットされます。
ここでmethod_missing_targetにmethod_missingで捕捉するメソッドを登録しています。
def attribute_method_prefix(*prefixes)
self.attribute_method_matchers += prefixes.map! { |prefix| AttributeMethodMatcher.new prefix: prefix }
#=> [ #<ActiveModel::AttributeMethods::ClassMethods::AttributeMethodMatcher:0x007f81323bd1d8
#@method_missing_target="clear_attribute",
#@method_name="clear_%s",
#@prefix="clear_",
#@regex=/^(?:clear_)(.*)(?:)$/,
#@suffix=""]
undefine_attribute_methods
end
その後、define_attribute_methods :name内でdefine_proxy_callというメソッドが呼ばれて、clear_nameが定義されます。ここがポイントなのですが、このメソッド定義はPersonクラス内に直接されるのではなく、動的に生成されたModuleに定義されてそのModuleがPersonクラスの1つ上に差し込まれます
def define_proxy_call(include_private, mod, name, send, *extra) #:nodoc:
defn = if name =~ NAME_COMPILABLE_REGEXP
"def #{name}(*args)"
else
"define_method(:'#{name}') do |*args|"
end
extra = (extra.map!(&:inspect) << "*args").join(", ".freeze)
target = if send =~ CALL_COMPILABLE_REGEXP
"#{"self." unless include_private}#{send}(#{extra})"
else
"send(:'#{send}', #{extra})"
end
mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
#{defn}
#{target}
end
RUBY
# def clear_name(*args)
# clear_attribute("name", *args)
# end
# がModuleに定義される
end
> Person.ancestors
=> [Person,
#<Module:0x007fcdfc463328>, #差し込まれる
ActiveModel::AttributeMethods,
...
BasicObject]
> Person.ancestors[1].instance_methods(false)
=> [:name, :clear_name] #clear_nameがmoduleに定義されている
これで準備は整いました 。実際にclear_nameが呼び出される時の流れを追ってみます。
まずPersonオブジェクトがclear_nameを呼び出し、階層を上にたどってModuleに定義されているclear_nameを呼び出します。
次にclear_name内でclear_attributeが呼ばれ継承階層を上に辿っていきますが、メソッドが見つからずMethodMissingとなります。
MethodMissingとなったので呼び出し元のModuleに戻りmethod_missingメソッドを探します。結果MethodAttributeクラスにmethod_missingがありそのmethod_missing内で登録しておいたmethod_missing_target(clear_attribute)と一致するのでnameを引数にしてPersonObjectのclear_attributeに転送します。
実際MethodAttributesのmethod_missingは以下のようになっておりmatched_attribute_method内でメソッドを探索しマッチするものがあればattribute_missingを呼びします。最後にattribute_missing内でtargetのメソッドを呼び出します。
def method_missing(method, *args, &block)
if respond_to_without_attributes?(method, true)
super
else
match = matched_attribute_method(method.to_s)
match ? attribute_missing(match, *args, &block) : super
end
end
def attribute_missing(match, *args, &block)
__send__(match.target, match.attr_name, *args, &block)
end
※ 上記の様な仕組みになっていると思います。
何故思いますかというといくらやってもこのmethod_missingの中に入ってこないからです。
普通に無いメソッドをcallすれば入ってくるのですが、なぜ入ってこないかわかりませんでした。
method_missingを使っているとコメントに書いてあるのでMethodAttributes#method_missingに入ってくるはずだと思ってこのように書きましたがそもそもそれが間違っているのかもしれません。
def method_missing(method, *args, &block)
puts "CALL METHOD MISSING!!"
if respond_to_without_attributes?(method, true)
> Person.new.nothing_method
CALL METHOD MISSING!!
> Person.new.clear_name
Person.new.clear_name
=> nil
もしご存知の方いればぜひご教示ください!!!!(その他間違いもありましたら奇譚なくご指摘くださいm(_)m)
追記:
上記の説明は以下のところが間違えていました。
次にclear_name内でclear_attributeが呼ばれ継承階層を上に辿っていきますが、メソッドが見つからずMethodMissingとなります。
これは実際にはModuel起点でメソッドの探索は行われず、PersonObjectから探索が行われます。
module A
def hoge
baz
end
def baz
puts "call form A"
end
end
class B
include A
def baz
puts "call form B"
end
end
> B.new.hoge
call form B
=> nil
なのでMethodMissingは呼び出されず、正しい図はこうなります
ということはやっぱりMethodAttributeのmethod_missingはcallされてないということになります。
じゃあこのコメントの説明はどういう意味なんだろうか。。。
# Declares a method available for all attributes with the given prefix.
# Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method.
#
# #{prefix}#{attr}(*args, &block)
#
# to
#
# #{prefix}attribute(#{attr}, *args, &block)
# ~略
追記の追記
このコメントが追加されたのは2009年でそれ以降変わってないようです。
かつこの時の実装は明らかにsendしていますね
def method_missing(method_id, *args, &block)
method_name = method_id.to_s
if match = match_attribute_method?(method_name)
guard_private_attribute_method!(method_name, args)
return __send__("#{match.prefix}attribute#{match.suffix}", match.base, *args, &block)
end
super
end
ますます怪しい
https://github.com/rails/rails/commit/f8d3c72c39ad209abca7f3613f91fb3a03805261