Mongoidでrelationを取ってくる際、非常にトリッキーな挙動をしていてハマった。黒魔術の一端を垣間見てしまったので、ここでその記録を残しておく。
なおこの問題は普通にMongoidを使っていればまず遭遇しない問題で、99%の人には無関係だと思われる。
環境
- Ruby 2.3.1
- Mongoid 5.1.4
問題となる挙動
1:Nの関係を持つ二つのモデルを考える。ここではArticle
とComment
とする。
class Article
has_many :comments
...
end
class Comment
belongs_to :article
...
end
ArticleからCommentを取得することを考える。この時にcomments
メソッドを使うが、この返り値が問題となる。
IRBなどで実行すると
article = Article.find( ...ID... )
article.comments
# => [ #<Comment _id: .... >, #<Comment _id: ....>, .... ]
というようにまるでArrayかのように一見振る舞う。しかし実態は異なっており、これはinspect
メソッドがオーバーライドされているだけである。
def inspect
entries.inspect
end
じゃあ、実際に何のクラスが返ってくるか調べてみる。しかし、ここで変な挙動に出くわす。
comments = article.comments
comments.class #=> Mongoid::Relations::Targets::Enumerable
comments.is_a?(Array) #=> true
comments.is_a?(Mongoid::Relations::Targets::Enumerable) #=> false
あれ???なんだこれは?
ひょっとして Mongoid::Relations::Targets::Enumerable がArrayから継承しているのかと思ったが違う様子。
Mongoid::Relations::Targets::Enumerable.ancestors.include?(Array) # => false
よくよくソースコードを読んでみたらis_a?
を明示的に委譲していた。
https://github.com/mongodb/mongoid/blob/v5.1.4/lib/mongoid/relations/targets/enumerable.rb#L19
delegate :is_a?, :kind_of?, to: []
そ、そういうことか。。。
commentsは何のインスタンスなのか?
じゃあcomments
は「Mongoid::Relations::Targets::Enumerable
のインスタンスなのだろう」と思いたくなるのだが、実は違う
別のクラス Mongoid::Relations::Referenced::Many
のインスタンスなのである。
一体何が起きているかを順番に追ってみる。
まず、Mongoid::Relations::Referenced::Many
は Mongoid::Relations::Many
を継承しており、それはさらに Mongoid::Relations::Proxy
を継承している。
このProxyが黒魔術的なことをしている。
まずProxyはほとんどのpublic_methodをundefしている。例外的に(send
,object_id
,respond_to
)などの一部のメソッドは残されている。
# We undefine most methods to get them sent through to the target.
instance_methods.each do |method|
undef_method(method) unless
method =~ /(^__|^send|^object_id|^respond_to|^tap|^public_send|extend_proxy|extend_proxies)/
end
じゃあProxyインスタンスに対するメソッドはどのように呼ばれるかというと、保持しているtarget
というインスタンス変数にmethod_missing
経由で送られる。
def method_missing(name, *args, &block)
if target.respond_to?(name)
target.send(name, *args, &block)
else
klass.send(:with_scope, criteria) do
criteria.public_send(name, *args, &block)
end
end
end
なので、#class
メソッドは最初のif文がtrueになり、@target
というインスタンス変数に対して呼ばれている。
この@targetが Mongoid::Relations::Targets::Enumerable
のインスタンスなので、別のclassが返っていたというわけである。
どういう場合に問題がおきるか?
大概の場合はこのような奇妙な動作も意識せずに使えるのだが、オブジェクトに対してモンキーパッチをしようとすると問題が起きる。
例えば、Objectに対してMessagePackのシリアライズのメソッドを定義したいとしよう。
Object.class_eval do
def to_msgpack
MessagePack.pack( [self.id, self.class] )
end
end
そして、 article.comments.to_msgpack
を呼ぶとどうなるかが問題である。
これはファイルがrequireされた順番に依存してしまう。
もしMongoidがrequireされてから、"monkey_patch.rb"をrequrieするのであれば問題無い。
しかし、逆の順番で"monkey_patch.rb"をrequireしてからMongoidをするとまずいことが起きる。
まず、Objectに対してto_msgpack
メソッドが定義される。
しかし、Mongoidの"proxy.rb"が呼ばれた時に、to_msgpack
がundefされてしまう。
この状態で、to_msgpackが呼ばれると、#method_missing
が呼ばれてしまい、処理がMongoid::Relations::Targets::Enumerable
のインスタンスに委譲されてしまう。
本当は Mongoid::Relations::Referenced::Many
のインスタンスに対してメソッドを呼びたかったのに別のインスタンスに対してメソッドが呼ばれてしまうことになる。
解決法としては、"monkey_patch.rb"をrequireするタイミングをMongoidを呼んだ後にすることである。
そうすればこの問題は発生しないはずである。