Rails における gem へのモンキーパッチの方法をいくつか試した話

初めまして、ラクマでサーバサイドエンジニアをやっているtatsumiです。

普段は業務で Ruby on Railsを使っています。

業務で、サードパーティー製のgemのモンキーパッチを行ったのでいくつか方法を紹介しようと思います。

参考文献

この記事を作る上で、参考にした記事が以下の2つでこれらには書かれていない情報を記述しました。

techlife.cookpad.com

qiita.com

環境

Ruby 2.5.7

Ruby on Rails 5.2.4.4

 

モンキーパッチとは

モンキーパッチの概要について説明したいと思います。

今日では何かシステムを作る時に、よく使う機能はすでに作成されていると思います。

それらをインストールして、仕様に合わせて足りない部分を自分で実装していく方法が一般的だと思います。

仮に、自分で実装した部分に不具合が発生した場合、その部分を修正すれば良いですが、

サードパーティー製のライブラリの方で不具合が発生したり、一部挙動を変えたい場合に修正を行うとそれを使っている他の全プロジェクトに影響してしまいます。

オリジナルのソースコードを変更することなく自分のプロジェクト内だけで、サードパーティー製のライブラリのコードを拡張したり、変更したりする方法のことをモンキーパッチと読んでいます。

 

事前準備

Railsのlibフォルダ配下に仮のsample_gemを想定してファイルを配置します。 sample_gemの機能の概要を説明すると、 sample_gemのpropertyモジュールはextendした先のクラスで、property :fooと書くと、 fooという名前のゲッターとセッターが作成されてfooという名前のインスタンス変数が格納できるようになります。
今回はパッチで、SampleGem::SampleKlassにproperty :foo3を追加したいと思います。

lib/sample_gem/property.rb

module SampleGem
  module Property
    def property(name)
      module_eval <<-END_OF_DEF
        def #{name}
          @#{name}
        end

        def #{name}=(value)
          @#{name} = value
        end
      END_OF_DEF
    end
  end
end

lib/sample_gem/sample_klass.rb

module SampleGem
  class SampleKlass
    extend SampleGem::Property

    property :foo1
    property :foo2
  end
end
[1] pry(main)> s = SampleGem::SampleKlass.new
=> #<SampleGem::SampleKlass:0x00007f9bac81a3b8>
[2] pry(main)> s.foo1 = 'test1'
=> "test1"
[3] pry(main)> s.foo2 = 'test2'
=> "test2"
[4] pry(main)> s.foo1
=> "test1"
[5] pry(main)> s.foo2
=> "test2"

モンキーパッチの方法紹介

1. 以前、ラクマで行ったモンキーパッチの方法

以前、ラクマでもモンキーパッチを行ったことがあります。
その時はこの記事で紹介する方法とは異なるのですが、
参考までに紹介したいと思います。

config/initializers/gem_name/file_name.rb

module Name

  # ここで、処理の上書きを行う
  def override
  end
end

config/initializerにモジュールごと上書きをしていました。
ただし、この方法だと以下のようなデメリットがありました。

  1. モジュールの処理を丸々上書きしてしまうので、一部だけ変えたい時にも全部の処理を書く必要がある
  2. config/initializersにパッチファイルが散在し、第三者から見た時にどこでパッチしているかがわかりにくい

 

2. モジュールをincludeする方法

1.1で挙げた問題を解決するために、パッチファイルを1箇所にまとめるようにしました。
config/initializers/monkey_patches.rbを作成して、lib/monkey_patches配下の全てのパッチファイルをプロジェクト全体で読み込むようにしました。
これで、どこでパッチを行っているかがわかりやすくなりました。

config/initializers/monkey_patches.rb

Dir[Rails.root.join('lib', 'monkey_patches', '**', '*.rb')].sort.each do |file|
  require file
end

  lib/monkey_patches配下にsample_gem/sample_klass_extension.rbファイルを配置します。
今回は、property :foo3を追加したファイルを用意しました。

lib/monkey_patches/sample_gem/sample_klass_extension.rb

module SampleKlassExtension
  extend SampleGem::Property

  property :foo3
end
SampleGem::SampleKlass.include(SampleKlassExtension)

最後の行で定義したモジュールを元のモジュールにincludeしています。

他の記事を見ると、includeの箇所をprependしている記事があるのですが、
これはメソッドをパッチする場合で、prependを使って先にパッチされたメソッドを呼ぶようにしています。

今回は、クラスに新しいpropertyを追加するパッチなので、prependでもincludeでも動きました。
これで、foo3がpropertyに追加されました。

修正前

[1] pry(main)> s = SampleGem::SampleKlass.new
=> #<SampleGem::SampleKlass:0x00007ffc16088bc0>
[2] pry(main)> s.foo3 = 'test'
NoMethodError: undefined method `foo3=' for #<SampleGem::SampleKlass:0x00007ffc16088bc0>
Did you mean?  foo1=
               foo2=
               foo2
               foo1
from (pry):2:in `__pry__'

foo3 propertyが含まれていないことを確認

修正後

[1] pry(main)> s = SampleGem::SampleKlass.new
=> #<SampleGem::SampleKlass:0x00007fb96c823110>
[2] pry(main)> s.foo3 = 'test'
=> "test"
[3] pry(main)> s.foo3
=> "test"

foo3 propertyが含まれていることを確認  

3. itselfを使う方法

  config/initializers/monkey_patches.rbを用意するのは同じでincludeする代わりに、itselfを使います。
  lib/monkey_patches/sample_gem/sample_klass_extension.rb

# event_representerファイルに設定されているプロパティを展開
SampleGem::SampleKlass.itself

# new_propertyを追加
module SampleGem
  class SampleKlass
    property :foo3
  end
end

itselfはレシーバ自身を返すメソッドで、パッチファイルに付けるとそこで定義されているプロパティを全て展開してくれます。
よって、2. で紹介したincludeの方法と異なり追加したいプロパティを記述するだけです。

修正前

[1] pry(main)> s = SampleGem::SampleKlass.new
=> #<SampleGem::SampleKlass:0x00007f9bbc486138>
[2] pry(main)> s.foo3 = 'test'
NoMethodError: undefined method `foo3=' for #<SampleGem::SampleKlass:0x00007f9bbc486138>
Did you mean?  foo2=
               foo1=
               foo1
               foo2
from (pry):2:in `__pry__'

foo3 propertyが含まれていないことを確認

修正後

[1] pry(main)> s = SampleGem::SampleKlass.new
=> #<SampleGem::SampleKlass:0x00007fc65a25d5a0>
[2] pry(main)> s.foo3 = 'test'
=> "test"
[3] pry(main)> s.foo3
=> "test"

foo3 propertyが含まれていることを確認

まとめ

Railsでモンキーパッチする方法をいくつか紹介しました。

メソッドをパッチする場合は2. で紹介したincludeを使う方法を採用すれば良く、
クラスなどのオブジェクト自体の設定をパッチする場合は3. で紹介したitselfを使う方法を採用すれば良いと思います。