gem の内部を書き換える2つの方法 + おまけ(後から明示的に dependent: :destroy を止める方法 Rails3 限定)
皆さん、こんにちは。MUGENUP の osada です。 いきなりですが、
gem の内部を書き換えたい!
と思ったことはありませんか?
globalize
というgem
が、内部で
has_many :translations, dependent: :destroy
を実行するのですが、このdpendent: :destroy
を止めたい、という要望から発生した、
gem
の内部の挙動をオーバライドする方法のご紹介です。
想定する読者は、ruby
を使い始めたばかりで、オープンクラス
やメタプログラミング
を使ったことがない人です。
概要
dpendent: :destroy
を止める方法として、下記の方法を紹介します。
オープンクラス
を使ったパッチを当てるmodule_eval
を使ってパッチを当てる- has_many を再定義する
- (
Rails 3.2
) destroy 用のメソッドをオーバーライドする - (
Rails 3.2
) destroy 用の callback を停止する
gem のクローンは最終手段
gem の挙動の一部を書き換えたいからといって、 gem を app などにコピーして使うのは、悪手だと考えています。 理由は、 バージョンアップに追従できない からです。 例えば、重要なセキュリティ修正 があった場合、それを取り込むのは大変難しくなるでしょう。
それよりも、gem をクローンし、パッチを宛てたものを 新しい gem として読み込む方が適切です。 この場合、クローン元であるオリジナルの gem をマージしてくることで、 バージョンアップに追従することができます。
しかしこれも、マージなどのメンテナンスコストを考えると最終手段だと考えます。
(もし、大きな修正が必要である場合、gem を継承して拡張するほうが適切でしょう)
オープンクラスを使ったモンキーパッチを当てる
軽度な修正であれば、モンキーパッチを当てる方が簡単です。
ruby の オープンクラス
は、拡張として使うことができます。
オープンクラスについては、 シンボルでも文字列でもアクセス可能なHashを使おう!ActiveSupport {Hash編} - MUGENUP技術ブログ でも触れました。
同名でクラス定義を行うと、クラスの再定義(上書き)ではなく、クラスへの追加拡張になる仕組み。
です。 つまりそれは、gem の中で定義したものであっても、app 側で再定義できる、ということです。
今回書き換えたい globalize
の has_many
は、vendor/bundle/gems/globalize-3.1.0/lib/globalize/active_record/act_macro.rb
の、def setup_translates!(options)
に定義されています。
module Globalize module ActiveRecord module ActMacro ...... def setup_translates!(options) ...... has_many :translations, :class_name => translation_class.name, :foreign_key => options[:foreign_key], :dependent => :destroy, :extend => HasManyExtensions, :autosave => false ...... end end ...... end end
まずこのファイルを、config/initializer/extensions/globzlize/active_record/act_macro.rb
にコピーし、setup_translates!
以外を削除、has_many
から dependent: :destroy
を
削除します。
module Globalize module ActiveRecord module ActMacro protected def setup_translates!(options) ...... has_many :translations, :class_name => translation_class.name, :foreign_key => options[:foreign_key], :extend => HasManyExtensions, :autosave => false ...... end end end end
以上、終了です。簡単ですね!
別の場所でクラスを開きなおして、メソッドを再定義するだけで良い のですから、
わざわざ gem
をコピーしたり、クローンしたりする必要はありません。
メソッドチェインを使用する。
上記のメソッドを再定義するタイプのパッチは、非常に簡単ですが、
alias_method_chain
などのメソッドに紐づいた実装
を切ってしまう、という欠点があります。
どこかで誰かが、さらにパッチを当てているかもしれません。
できれば、メソッドチェインタイプのパッチを宛てる方が良いでしょう
module Globalize module ActiveRecord module ActMacro def setup_translates_with_stop_dependent_destroy!(options) setup_translates_without_stop_dependent_destroy!(options) has_many :translations, :class_name => translation_class.name, :foreign_key => options[:foreign_key], :extend => HasManyExtensions, :autosave => false end alias_method_chain :setup_translates!, :stop_dependent_destroy end end end
実際、このglobalize
は globalize-versioning
という拡張gemを用意しており、
このタイプの拡張でなければ、同時に使うとエラーになってしまいます。
module_eval を使ってパッチを当てる
さて、alias_method_chain
であれば、オープンクラスである必要はなさそうな気がします。
それがもう一つの方法、module_eval
を使う方法です。
config/initializers/globalize_patch.rb
に先ほどのパッチをコピーします。
先ほどとは異なり、名前空間が異なるので、HasManyExtensions
は、
::Globalize::ActiveRecord::HasManyExtensions
のように宣言する必要があります。
Globalize::ActiveRecord::ActMacro.module_eval do def setup_translates_with_stop_dependent_destroy!(options) setup_translates_without_stop_dependent_destroy!(options) has_many :translations, :class_name => translation_class.name, :foreign_key => options[:foreign_key], :extend => ::Globalize::ActiveRecord::HasManyExtensions, :autosave => false end alias_method_chain :setup_translates!, :stop_dependent_destroy end
以上、終了です。簡単ですね!
といっても、これらはコードを読む側
からすると、
どこで何が再定義されているかわからない 、
知らないところで何かが勝手に変わっている 、という意味で魔術的手法です。
用法・用量を守ってお使いください
オープンクラスと module_eval(class_eval) の違い
さて、この2つはどう違うのでしょうか?
みんな大好き stackoverflow
に、回答がありましたので、引用します。
ruby - class_eval and open classes - Stack Overflow
reopen class will not inform you (but class_eval will raise error) if the existing class does not exist or not loaded.
class_eval
は拡張元のクラスがないとき エラーになる。オープンクラスは何も教えてくれない。
オープンクラスというのは、クラスを定義する際の特徴的な動き、というだけのことであり、 そもそも拡張を意図としている訳ではない、ということですね。
特徴的なオープンクラス
他の言語では、module_eval のような拡張の仕方がメインなのではないでしょうか。
例えば、python であれば、後からクラスにメソッドを追加するときは、定義したメソッドを追加する、というコードになります。
まず、オープンクラスではないので、class A
を再定義すれば、メソッドは消えてしまいます。
class A(object): def hello(self): print "hello", self.__class__.__name__ A().hello() # => hello A class A(object): def bye(self): print "bye", self.__class__.__name__ A().hello() # Traceback (most recent call last): # File "tmp.py", line 11, in <module> # A().hello() # AttributeError: 'A' object has no attribute 'hello'
よって、メソッドを定義し、クラスに追加することで、拡張します。
class A(object): def hello(self): print "hello", self.__class__.__name__ def bye(self): print "bye", self.__class__.__name__ A.bye = bye a = A() a.bye() a.hello() # => bye A # => hello A
メソッドへの割り込みは例えば以下のようになるかもしれません (他にも拡張の方法があると思います)。
class A(object): def hello(self): print "hello", self.__class__.__name__ def hello_with_bye(self): self.hello_without_bye() print "bye", self.__class__.__name__ A.hello_without_bye = A.hello A.hello = hello_with_bye A().hello() # => hello A # => bye A
coffeescript(javascript) の場合も同じです。
class A hello: -> console.log "hello" new A().hello() # hello class A bye: -> console.log "bye" new A().hello() # TypeError: Object #<A> has no method 'hello' # at Object.<anonymous> (/Users/osada/tmp.coffee:11:9) # at Object.<anonymous> (/Users/osada/tmp.coffee:1:1) # at Module._compile (module.js:456:26)
class A hello: -> console.log "hello" A.prototype.bye = -> console.log "bye" a = new A() a.hello() a.bye() # hello # bye
class A hello: -> console.log "hello" A.prototype.hello_without_bye = A.prototype.hello A.prototype.hello = -> this.hello_without_bye() console.log "bye" new A().hello() # hello # bye
……ちょっと例が悪かったでしょうか。
python
と javascript
は大変親和性が高いので、
同じようなコードになってしまいました。
いずれにせよ、
- クラス定義
- クラスに対して、メソッドを追加
という手順を踏むことになります。
オープンクラス
のように
- クラス定義
- 同名のクラス定義を使用して、定義の続きを行う
という動作は、他の言語と比べると、かなり特徴的であると思います。
予想外の箇所でオーバライドしてしまうこともあるため、 理解して使うことがオススメです。
コラム [オープンクラスでテストが落ちた]
単体テストは通るのに、全体テストが通らない、という原因がオープンクラスだったことがあります。 モジュールのテストのために、適当なクラスを宣言して、include して使っていた所、
class Foo include SomeModule end
別の箇所でも
Foo
を定義しており、全体テストのときのみ、再定義が発動して テストが落ちていました。 他の言語であれば、同名のクラスは再定義されるので、no method error
で落ちるところが、 オープンクラスでは、メソッド自体は定義されないので落ちないが、 他で定義されたコードにより想定外の干渉が起きるということです。 オープンクラスならではだと思います。
has_many を再定義する
さて、パッチは魔術だ、というお話を致しました。
実際、該当のソースを読んだだけでは、dependent: :destroy
が無効化されていることには気づけないでしょう。
できるなら、明示的に オーバライドしていることを宣言したいところです。
この globalize
は translates
メソッドを呼ぶときに、内部で has_many
を定義しています。
translates :description, versioning: :paper_trail
# translates メソッドを呼ぶと、gem 内のメソッドで下記が実行される has_many :translations, :class_name => translation_class.name, :foreign_key => options[:foreign_key], :dependent => :destroy, :extend => HasManyExtensions, :autosave => false
よって、translates
の後に has_many
を再定義してしまえば、上書きされます。
例えば、Project
というクラスに使用した場合、
translation_class.name
は Project::Translation
になります。
class Project translates :description, versioning: :paper_trail has_many :translations, :class_name => Project::Translation, :foreign_key => nil, :extend => ::Globalize::ActiveRecord::HasManyExtensions, :autosave => false
これを毎回書くのは大変なのですが、読み手には優しくなります。 もう少し楽に書きながら、明示する方法はないでしょうか?
destroy 用のメソッドをオーバーライドする (Rails3.2)
dependent: :destroy
は、before_destroy
で発動します。
このメソッドをオーバーライドしてしまえば、
事実上、destroy
を止めることができます。
メソッドはhas_many
で定義されており、名前は "has_many_dependent_for_#{name}"
。
つまり今回で言うと、"has_many_dependent_for_translation"
です。
def configure_dependency if options[:dependent] ............ model.before_destroy dependency_method_name end end def dependency_method_name "has_many_dependent_for_#{name}" end
よって、translates
の後に、true
を返すメソッドとして再定義すればOKです。
translates :description, versioning: :paper_trail has_many_dependent_for_translations = -> { true }
このように再定義できることを知っておくと、コールバックに割り込みができて便利です。
しかし単にコールバックを止めることはできないのでしょうか?
実は、skip_callback
として下記のコードを書いても、停止されなかったのです。
translates :description, versioning: :paper_trail skip_callback :destroy, :before, :has_many_dependent_for_translations
ここには一つ、バッドノウハウを知っている必要があります。
destroy 用の callback を停止する(Rails3.2)
結論から書くと、dependen: :destroy
のコールバック自体を止めるには下記のように書きます。
translates :description, versioning: :paper_trail skip_callback :destroy, :before, "(has_many_dependent_for_translations)"
括弧をつけたメソッド名の文字列 です。
callback
には、メソッド名のシンボル、文字列、または Proc を設定できます。
ここに登録されるときの名前が、文字列の場合には 括弧が付く ようです。
def _compile_filter(filter) method_name = "_callback_#{@kind}_#{next_id}" case filter when Array filter.map {|f| _compile_filter(f)} when Symbol filter when String "(#{filter})”
こちらは destroy コールバック自体を止めているので、一番適切な止め方だと考えています。
括弧が付く
というのは、かなりのハマりどころでした。
しかし残念なことに、Rails4.0 でこの実装はなくなってしまいました。 汎用的にコールバックを停止することは難しいようです。
Rails 4 では、メソッド名に由来する削除ができない
Rails 4
では、コールバックの登録が lambda
になっており、
メソッド名を使用した、オーバーライドや、コールバックの停止、が出来なくなっています。
def self.add_before_destroy_callbacks(model, reflection) ....... model.before_destroy lambda { |o| o.association(name).handle_dependency } end
before_destroy
での登録が lambda
なので、
skip_callback
を使おうとしても対象が宣言できないためです。
よって、対応策として、下記の2つを考えました。
- reset_callbacks(:destroy)
- callbacks を直接操作する
reset_callbacks
は全てのコールバックを消してしまうため、
dependent: :destroy
以外の destroy callback
を使っている gem
には、使用できません。
また、他の destroy
よりも前に宣言しておく必要があります。
translates :description, versioning: :paper_trail reset_callbacks(:destroy)
一方、callbacks
を直接操作するのは、かなりの黒魔術です。
translates :description, versioning: :paper_trail skip_callback :destroy, :before, _destroy_callbacks.instance_variable_get(:@chain).last.filter
こちらは、直近に登録された filter
を取り出すことで、skip_callback
を使っています。
こちらも、複数のcallback
が登録されていた場合、dependent: :destroy
が last
とは
限らないため、慎重な使い方が要求されますし、使うべきではないでしょう。
よって、Rails 4
の場合、明示して打ち消すのは難しいようです
まとめ
gem の中を書き換えるには、下記の4つの方法が考えられます。
- app に コピーして修正して使う
- クローンして修正した gem を使う
- オープンクラスでパッチを当てる
- module_eval(class_eval) でパッチを当てる
dependent: :destroy
を止めるには、Rails 3
系であれば、下記2つを使い、明示的に止めることができます。
- destroy 用のメソッドをオーバーライドする
- destroy 用の callback を停止する
しかし、Rails 4
では、パッチを当てることが有効でしょう。
ということで、gem の内部を書き換える方法の共有でした。
検索用
今回、幾つかググっても情報が出てこなかったので、検索用に英語用のワードも記載しておきます。 この記事が誰かの助けになることができれば幸いです。
Cancel (remove) the
dependent destroy
defined in gem