読者です 読者をやめる 読者になる 読者になる

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 側で再定義できる、ということです。

今回書き換えたい globalizehas_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

実際、このglobalizeglobalize-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

……ちょっと例が悪かったでしょうか。 pythonjavascript は大変親和性が高いので、 同じようなコードになってしまいました。

いずれにせよ、

  1. クラス定義
  2. クラスに対して、メソッドを追加

という手順を踏むことになります。

オープンクラス のように

  1. クラス定義
  2. 同名のクラス定義を使用して、定義の続きを行う

という動作は、他の言語と比べると、かなり特徴的であると思います。

予想外の箇所でオーバライドしてしまうこともあるため、 理解して使うことがオススメです。

コラム [オープンクラスでテストが落ちた]

単体テストは通るのに、全体テストが通らない、という原因がオープンクラスだったことがあります。 モジュールのテストのために、適当なクラスを宣言して、include して使っていた所、

class Foo
 include SomeModule
end

別の箇所でも Foo を定義しており、全体テストのときのみ、再定義が発動して テストが落ちていました。 他の言語であれば、同名のクラスは再定義されるので、no method errorで落ちるところが、 オープンクラスでは、メソッド自体は定義されないので落ちないが、 他で定義されたコードにより想定外の干渉が起きるということです。 オープンクラスならではだと思います。

has_many を再定義する

さて、パッチは魔術だ、というお話を致しました。 実際、該当のソースを読んだだけでは、dependent: :destroy が無効化されていることには気づけないでしょう。

できるなら、明示的に オーバライドしていることを宣言したいところです。

この globalizetranslates メソッドを呼ぶときに、内部で 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.nameProject::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: :destroylast とは 限らないため、慎重な使い方が要求されますし、使うべきではないでしょう。

よって、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