Gemにモンキーパッチを当てるサンプル

Railsで開発していると使っているGemの挙動を一部変更したくなることが稀にあると思います。
gemに限らずですが、外部ライブラリの挙動を変えるために書く仕組みはモンキーパッチと呼ばれます。

モンキーパッチについては下記のクックパッドさんの記事がわかりやすかったのでご覧ください。
この記事でもこのやり方を参考にしています。
https://techlife.cookpad.com/entry/a-guide-to-monkey-patchers

モンキーパッチはある程度やり方が定型化されていますが、たまにしか実装しないのでやり方を忘れてしまいがちだと思います。
そこで備忘録としてよくありそうなモンキーパッチの当て方をこの記事に残しておこうと思います。

# 対象のgem

モンキーパッチを当てるsample_gemは下記の通り。

## 仕様
* incrementというクラス変数を持っている
    * 渡した数値を+1して返却する
    * 数値以外を受け取ると`SampleGem::NoIntegerError`
* initializeではnumを渡してインスタンス変数に設定する
    * 数値以外を受け取ると`SampleGem::NoIntegerError`
* incrementというインスタンス変数を持っている
    * 実行するとインスタンス変数のnumを+1する
* 定数: SAMPLE_CONSTが定義されている

```ruby:lib/sample_gem/sample_klass.rb
module SampleGem
  class SampleKlass
    SAMPLE_CONST='これはサンプルです'

    attr_reader :num

    class << self
      def increment(num)
        raise SampleGem::NoIntegerError unless num == num.to_i

        num + 1
      end

      def sample_const
        SAMPLE_CONST
      end
    end

    def initialize(num)
      raise SampleGem::NoIntegerError unless num == num.to_i

      @num = num.to_i
    end

    def increment
      @num += 1
    end
  end
end
```

詳細は下記のリポジトリ参照
https://github.com/ham0215/sample_gem

# モンキーパッチを当てる
## 事前準備
適当なrailsアプリを作ってそこにsample_gemを追加します。
rubygemsには公開していないのでリポジトリを直接参照しています。

```ruby:Gemfile
gem 'sample_gem', git: 'https://github.com/ham0215/sample_gem.git', branch: 'master'
```

冒頭で紹介したクックパッドさんの記事を参考にパッチファイルを作成

```ruby:config/initializers/monkey_patches.rb
Dir[Rails.root.join('lib/monkey_patches/**/*.rb')].sort.each do |file|
  require file
end
```

```ruby:lib/monkey_patches/sample_gem_ext.rb
require 'sample_gem/version'
unless SampleGem::VERSION == '0.1.0'
  raise 'Consider removing this patch'
end

### ここに実装していきます ###
```

## インスタンスメソッドにパッチをあてる
まずはインスタンスメソッドにパッチをあててみます。
『`#increment`を使わなくしたいが、ソースが膨大すぎて使用箇所を全て削除できたか不安。もし呼ばれたらdeprecated warningをログ出力するようにしてしばらく様子をみてみよう。』
というユースケースを考えてみます。

### 実装例

1. パッチを実装するmoduleを定義(`SampleKlassMonkeyPatch`)
2. 上書きしたいメソッド(`increment`)を定義
3. deprecated warningのログ出力する処理を書く。元々の処理はそのままで良いので`super`を呼び出す
4. 対象のclass(`SampleGem::SampleKlass`)にprependする

prependすることで既存クラスのインスタンスメソッドより先にパッチのメソッドが呼ばれるようになります。

```ruby:lib/monkey_patches/sample_gem_ext.rb
module SampleKlassMonkeyPatch
  def increment
    Rails.logger.warn 'DEPRECATION WARNING: 使わないで！'
    super
  end
end
SampleGem::SampleKlass.prepend(SampleKlassMonkeyPatch)
```

コンソールで動作確認

```ruby
> sample = SampleGem::SampleKlass.new 10
> sample.increment
DEPRECATION WARNING: 使わないで！
=> 11
> sample.increment
DEPRECATION WARNING: 使わないで！
=> 12
```

## クラスメソッドにパッチをあてる
次にクラスメソッドにパッチをあててみます。
『`Class.increment(num)`は数値以外が渡されると`SampleGem::NoIntegerError`をraiseする仕様になっているが、前バージョンまでは数値以外を渡しても問答無用で`.to_i`して０として扱われていた。
使っている箇所を全て見直して数値以外を渡さないようにするのがよいが使用箇所が多くて大変だ。
取り急ぎ、数値以外を渡しても例外が発生しないようにパッチをあてよう。』
というユースケースを考えてみます。

### 実装例

1. パッチを実装するmoduleを定義(`SampleKlassMonkeyPatch`)
2. 上書きしたいメソッド(`increment`)を定義
3. 引数を`to_i`で数値に直して既存のメソッドを呼び出す
4. 対象のclass(`SampleGem::SampleKlass`)のsingleton_classにprependする

4の手順で`singleton_class`にprependするところがインスタンスメソッドとは違う点です。

```ruby:lib/monkey_patches/sample_gem_ext.rb
module SampleKlassMonkeyPatch
  def increment(num)
    super(num.to_i)
  end
end
SampleGem::SampleKlass.singleton_class.prepend(SampleKlassMonkeyPatch)
```

コンソールで動作確認

```ruby
> SampleGem::SampleKlass.increment(2)
=> 3
> SampleGem::SampleKlass.increment('a')
=> 1
```

## 定数を上書きする
これまで紹介した2つに比べてあまりやることはないと思いますが定数を上書きしたい場合を考えてみます。

### 実装例

1. `=`で代入する

これをパッチをあてると言っていいのか微妙ですがこれだけです。

```ruby:lib/monkey_patches/sample_gem_ext.rb
SampleGem::SampleKlass::SAMPLE_CONST = 'hogehoge'.freeze
```

コンソールで動作確認

```ruby
> SampleGem::SampleKlass::SAMPLE_CONST
=> "hogehoge"
> SampleGem::SampleKlass.sample_const
=> "hogehoge"
```

# おまけ
## パッチがあたっていることの確認方法
実際に動作確認するのも良いですが、`#ancestors`を使うことでモジュールの順序を確認することができます。

```ruby
# インスタンスメソッドの確認
# 自分のクラスより前にパッチが挿入されていたらOK
> SampleGem::SampleKlass.ancestors
=> [SampleKlassMonkeyPatch, SampleGem::SampleKlass, ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency, ActiveSupport::ToJsonWithActiveSupportEncoder, Object, PP::ObjectMixin, JSON::Ext::Generator::GeneratorMethods::Object, ActiveSupport::Tryable, ActiveSupport::Dependencies::Loadable, Kernel, BasicObject]

# クラスメソッドの確認
# 自分のクラスより前にパッチが挿入されていたらOK
> SampleGem::SampleKlass.singleton_class.ancestors
=> [SampleKlassMonkeyPatch, #<Class:SampleGem::SampleKlass>, #<Class:Object>, #<Class:BasicObject>, Class, Module, Module::Concerning, ActiveSupport::Dependencies::ModuleConstMissing, ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency, ActiveSupport::ToJsonWithActiveSupportEncoder, Object, PP::ObjectMixin, JSON::Ext::Generator::GeneratorMethods::Object, ActiveSupport::Tryable, ActiveSupport::Dependencies::Loadable, Kernel, BasicObject]
```