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

Ruby on Rails アプリケーションにおけるモンキーパッチの当て方

技術部の牧本です。 今日はモンキーパッチの話をします。

モンキーパッチとは何か

そもそもモンキーパッチ (monkey patch) とは何でしょうか? 端的に言えば、言語の組み込みクラスやライブラリ、その他外部ライブラリの挙動を、動的に拡張する仕組みをモンキーパッチと呼びます。 *1

例えば、Ruby のモンキーパッチのすごく単純な例として以下のようなものがあります。

module NilClassExtension
  def empty?
    true
  end
end

NilClass.prepend(NilClassExtension)

インスタンスが空であるかどうかを判定するメソッドとしての #empty?StringArray など様々なクラスに存在しますが、 nil を唯一のインスタンスとする NilClass には本来は存在しません。 このモンキーパッチを導入することで、通常はメソッドがないことによる例外が発するはずの nil.empty? というメソッド呼び出しが、 true を返すようになります

さて、このようなパッチが読み込まれたシステムでは、予想外の挙動が起きます。 例えば、 method_missing を用いてフォールバックするようにしているライブラリがあるとすると、 nil は本来 #empty? メソッドを受け取れないはずが、パッチが存在するためにフォールバックせず、挙動が変わってしまいます。

ここで例に挙げた NilClass#empty? メソッドを追加するモンキーパッチは、あとで述べるように、モンキーパッチとしては行儀の悪い部類です。

しかし、このパッチは、実はかつて実際にクックパッドに存在していたものです。

クックパッドの中心となるアプリケーションのレポジトリは最初に作られてから10年弱が経過しており、歴史的経緯から様々なモンキーパッチが存在します。 われわれは、これらのモンキーパッチとうまくやる方法を考えていく必要があります。

そこから得られた知見をもとに、本稿では Ruby on Rails アプリケーションにおける、モンキーパッチの当て方、そして、モンキーパッチの外し方について紹介します。

モンキーパッチの当て方

さて、あなたが Ruby でアプリケーションを書いていて、ライブラリなどの標準の挙動を変えるためにモンキーパッチを導入したいと考えたとします。 その際、まずは最初の原則を当てはめましょう。

最初の原則 - モンキーパッチを使わない

まず、本当にモンキーパッチを当てる必要があるかを考えましょう。 それが何らかの不具合に対する対策だとしたら、ライブラリを最新バージョンにアップデートすることで対応できませんか。 発想がモンキーパッチになってはいけません。 何らかの組み込みクラスの挙動を変えたい場合、クラスそのものを拡張する以外の方法は取れないか考慮するべきです。

冒頭述べた NilClass#empty? について言うならば、 nil が入る可能性がある変数に対し、 var.empty? と呼んだときに true が返ってほしいのは理解できなくはないですが、Rails アプリケーションでは var.blank? というほぼ同等の代替手段があるので回避できるはずでした。 そういう観点ではこのモンキーパッチは入れるべきものではなかったと言えます。

それでもモンキーパッチを当てたいとき

とは言え、モンキーパッチを使わざるを得ない場面が起きるかも知れません。 よくある例として、外部のライブラリになんらかのバグがあるがそれが修正されたバージョンがリリースされていない場合、または、ライブラリのアップデートによる影響範囲が大きく別途検証が必要なためにすぐにアップデートできない場合などです。

以上のような理由でモンキーパッチを当てなければならない場合、最大限パッチをコントロールする必要があります。

パッチを隔離する

モンキーパッチを導入することを決めたとき、まず行なうべきはモンキーパッチ用のディレクトリを作成することです。 これによって、他のモジュールやクラスに影響を与えるコードを一箇所に集めることで一覧性を高め、読み込みのタイミングを統一します。

クックパッドでは #{Rails.root}/lib/monkey_patches というディレクトリがよく使われており、このディレクトリをイニシャライザで require しています。

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

アプリケーションから見えるインターフェースを変えない

モンキーパッチを当てる場合に気をつけることの一つに、パッチを当てたことによってアプリケーションコード (モンキーパッチを当てたライブラリを使う側のコード、 Rails の場合は app ディレクトリ以下にあるコードなど) を変更させるべきではないうものがあります。 つまり、たとえライブラリにパッチを当てたとしても、新しいインターフェースを増やしたり、既存のインターフェースを変更させないということです。

これによって、モンキーパッチを外すときの労力がかなり下がります。

例えば、最初に論じた NilClass#empty? を追加するというパッチは、この原則に則しておらず、実際にパッチを除去する際に大きな労力を要しました。

パッチが不要になったときに外せるようにする

さて、影響範囲を最小限にするという観点では、パッチが不要になるタイミングで適切にキャッチアップしてパッチを削除することができるようになるべきです。

最低限やるべきは、なぜそのパッチを導入することになったかをコメントとして残すことです。 さらに、どういう状態になればパッチを外して良いかまで明記されていると、将来の自分やチームメンバーがそのパッチを外せるかどうかで悩むことを防げます。

より良い方法は、パッチが不要になったら開発者が自動的に気づけるようにすることです。 例えば、ライブラリの特定のバージョンの挙動に依存してパッチを当てざるを得なくなった場合、以下のようにライブラリをアップデートしたら例外が発生するようにして、もしパッチを消し忘れてもテスト実行時などに気づけるようにします。 *2

# lib/monkey_patches/nanika_ext.rb
require 'nanika/version'
unless Nanika::VERSION == "2.2.0"
  raise "Consider removing this patch"
end

module NanikaMonkeyPatch
  # monkey patches go here...
end

Nanika.prepend(NanikaMonkeyPatch)

このように、大規模なアプリケーションにモンキーパッチを当てる際には細心の配慮をもって対応する必要があります。

モンキーパッチの外し方

さて、当てたモンキーパッチはいずれは外されなければなりません。 次に、どのようにモンキーパッチを外すのかについて論じていきます。

モンキーパッチを当てるときに注意を払えば、開発者はどのタイミングで外せるかを気づくことができるし、安全に外せるようになっているはずです。

モンキーパッチを外す作業は、外部ライブラリのアップデートや内部ライブラリの挙動を変更した場合の対応とほぼ同じです。 つまり、影響範囲を調べて、動作確認をして、問題なければリリースするという手順を踏みます。

先述の通り、ライブラリのインターフェースはパッチを当てた場合も外した場合も同じであることが期待されるので、基本的にアプリケーションのコードを変更する必要はないはずです。 しかしながら、実際はパッチによる副作用によって思わぬ場所で不具合が発生するかも知れないので、一度入れたパッチは細心の注意を払って外すべきです。

まとめ

本稿では、 Ruby on Rails アプリケーションにおけるモンキーパッチの当て方について記述しました。 先に述べたように、モンキーパッチを可能な限り使わず、使うときは最小限の影響範囲に留めて、なるべくふつうの Ruby、ふつうの Rails を使うことがアプリケーションの寿命を延ばす秘訣であると考えます。

*1:そもそもなぜ「モンキーパッチ」と呼ばれるようになったかについては、ある出典 によれば、 Zope の開発者内で使われ始めた用語であり、他のコードと衝突する可能性のあるパッチが ゲリラパッチ (guerilla patch) と呼ばれていたが、音が似ていることによって ゴリラパッチ (gorilla patch) に転じ、さらにそこから モンキーパッチ (monkey patch) になったとあります

*2:われわれのチームでは、これを「パッチに賞味期限を設定する」と呼んでいます

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/