Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: モンキーパッチが元コードの更新で乖離するのを防ぐ6つの方法(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails: モンキーパッチが元コードの更新で乖離するのを防ぐ6つの方法(翻訳)

モンキーパッチ(monkey-patching)を手短に説明すると、直接管理されていない外部のソースコードを、プロジェクトの目的に沿って改変することです。長年動いている「レガシー」プロジェクトのフレームワークを一新する場合や、依存関係をまだアップグレードできない事情がある場合、あるいは多数のコードブロックを移動する必要が生じた場合にモンキーパッチが必要になることがよくあります。

モンキーパッチは手っ取り早く作業を進めるための短期的なソリューションです。モンキーパッチの効果はただちに現れます。外部コードの変更許可を取り付ける必要もなければ、依存関係のコードをforkさせて余分な作業を背負い込む必要もありません。

しかしモンキーパッチを使うと、コードが非常にもろくなるという代償がついて回ります。外部の依存関係が将来更新されたときに、現行のモンキーパッチが問題なく動作すると期待するのはまったく無理な話です。個人開発ではなくチームで開発しているのであれば、モンキーパッチという「短期借り入れ」を行ったことをチームに周知しておくことが非常に重要です。

🔗 1: バージョンチェックで依存関係の変更から保護する

依存関係にモンキーパッチを当てたことをチームに伝える手段のひとつは、テストを書く形でドキュメント化することです。

ドキュメントをテストの形にする理由は以下のとおりです。

  1. プロジェクト内のテストコードは、外部のドキュメントよりもモンキーパッチで改変されるコードに近い場所にあるので見つけやすい。

  2. 単なるコードコメントと異なり、テストは実行可能なので、誰かさんのようにお知らせを見落とすリスクを大幅に軽減できる。

最近私が関わったとあるプロジェクトでは、既にUserモデルの内部でAcitveRecord::Persistence#reloadにモンキーパッチが当たっていたのに、何も周知されていませんでした。910,000行を超えるコードのテストカバレッジがたった10%であったことを考えると、自分がこのモンキーパッチを掘り当てたのは実に強運だったと思います。

モンキーパッチを当てたことをコードのコメントに書いただけでは、私ならまず気づけないでしょう。私がそのプロジェクトに参加したのはごく最近の話ですが、プロジェクトに関わっていたコードの作者たちはとっくにいなくなっていました。

私がドキュメントに追加したテストは以下のような感じです。

# spec/models/user_spec.rb

require "rails_helper"

RSpec.describe "User" do
  specify "#reloadメソッドがフレームワークの実装に基づいてオーバーライドされていること" do
    expect(Rails.version).to eq("5.1.7"), failure_message
  end

  def failure_message
    <<~WARN
    Railsがアップグレードされた可能性あり。
    User#reloadメソッドの本文が
    rails/activerecord/lib/active_record/persistence.rb#reloadの
    最新のRails実装と対応しているかどうかチェックすること。
    準備ができたら、この条件でバージョンアップすること。
    WARN
  end
end

これで、Railsのバージョンが変更されるたびにチェックが失敗するようになります。失敗したときには、パッチを延命させるために何をすべきかについてわかりやすいメッセージが表示されます。

CI(継続的インテグレーション)に依存していて、テスト文化を目指している組織であれば、このようなパッチ適用の失敗をこれで十分防げるでしょう。

しかし開発者にとってもっと便利な形にできないものでしょうか?

🔗 2: バージョンチェックを改善する

バージョンを厳密にチェックする方法の欠点は、厳密過ぎることです。依存関係によっては、利用可能なバージョンを範囲指定するのがベストなこともあります。セキュリティ問題を軽減するバージョン変更のチェックが失敗しないようにするには、たとえば以下のようにします。

--- spec/app/models/user_spec.rb
+++ spec/app/models/user_spec.rb
@@ -4,7 +4,7 @@ require 'rails_helper'

-    expect(Rails.version).to eq('7.0.7'), failure_message
+    expect(Rails.version).to eq('7.0.7.2'), failure_message
+    # Mitigate CVE-2023-38037
+    # https://discuss.rubyonrails.org/t/cve-2023-38037-possible-file-disclosure-of-locally-encrypted-files/83544

依存関係のバージョニングスキームが意味のあるものになっていることが確実であれば、バージョン制約のバリデーションを緩める形でチェックを変更できます。

以下はRubyGems APIを利用する例です。

--- spec/app/models/user_spec.rb
+++ spec/app/models/user_spec.rb
@@ -4,7 +4,7 @@ require 'rails_helper'

-   expect(Rails.version).to eq('7.0.7.2'), failure_message
+   expect(Gem::Requirement.create('~> 7.0.0').satisfied_by?(Gem::Version.create(Rails.version))).to eq(true), failure_message

しかし、セキュリティパッチのリリースが、自分たちの当てたモンキーパッチを変更しないことを皆さんは確信できますか?

🔗 3: パッチが当たったソースが変更されていないかどうかをチェックする

パッチが当たっているメソッドのソースコードを覗き込んで、元のメソッドから変更されていないかどうかをチェックできたら理想的ですよね。

arkencyの元フェローのツイート↓を読んで、この問題に新たな光が当たりました。

これは自分が見た中で最もクールなRubyトリックの1つ。ライブラリメソッドに何らかのモンキーパッチを当てなければならなくなった場合、今後ライブラリ側でメソッドの実装が変更される可能性があるので、新バージョンのライブラリでモンキーパッチが動くかどうかを再検証する必要がある。スクショのコードはそうした状況を検出するためのもの。

この方法を用いた完全なテストは以下のようになります。

# spec/models/user_spec.rb

require "rails_helper"

RSpec.describe "User" do
  specify "#reloadメソッドがフレームワークの実装に基づいてオーバーライドされていること" do
    expect(checksum_of_actual_reload_implementation).to eq(
      checksum_of_expected_reload_implementation,
    ),
    failure_message

    private

    def checksum_of_actual_reload_implementation
      Digest::SHA256.hexdigest(
        ActiveRecord::Persistence.instance_method(:reload).source,
      )
    end

    def checksum_of_expected_reload_implementation
      "3bf4f24fb7f24f75492b979f7643c78d6ddf8b9f5fbd182f82f3dd3d4c9f1600"
    end

    def failure_message
      #...
    end
  end
end

少しがっかりした点は、以下でツイート↓したようにMethod#sourceが標準のRubyにはないことでした。Method#sourceは、実は私が参加したプロジェクトでたまたまpry経由で間接的に使われていたmethod_sourceという依存関係に由来していたのです。それでも既存のプロジェクトにおける依存関係についてはうまく動き、単なるバージョンチェックよりも優秀でした。

banister/method_source - GitHub

他に改良できそうな点はあるでしょうか?

🔗 4: 実装の抽象構文木(AST)をチェックする

ソースコードのハッシュを算出する方法はイケていると思いますが、「フォーマットのみの」変更には力不足です。ソースコードはテキストで表現されるものであり、スペースや改行といったいわゆるホワイトスペースを加えても実装としては変わらず、振る舞いは同じになります。しかしハッシュはホワイトスペースが変わると変わってしまい、偽陰性となります。

この点を改良できないものでしょうか?はい、可能です。抽象構文木(AST)に少しばかり助けてもらえばよいのです。理論上は、AST表現を用いることでモンキーパッチされたコードのフォーマットの違いに悩まされずに済むようになるはずです。

Rubyの場合、ソースコードのASTを得る方法はいくつも考えられます。人気が高いのはparser gemやsyntax_tree gemです。Ruby標準ライブラリにはRipperもありますし、ネイティブのRubyVM::AbstractSyntaxTreeも利用できます1

悲観的な方なら、それぞれの方法における限界に真っ先に気づくことでしょう。

  • RubyVM::AbstractSyntaxTreeRipperの出力には引き続きフォーマットが含まれてしまうので、目的を達成できません。
  • parser gemとsyntax_tree gemは外部依存になるので、普遍的に適用できるとは限りません。おそらくですが、既にプロジェクト内でこれらのgemに間接的に依存していることでしょう。

私も最初はこうした点の多くに気づきませんでした。私がオススメしたくない実装を以下に紹介します。

🔗 (非推奨)フォーマットに影響されないチェックサムを探し求める

RubyコアにあるRubyVM::AbstractSyntaxTreeは、RubyコードをパースしてASTを得るメソッドがいくつかあります。残念ながら、この出力には行や列(=行の左から何文字目かを表す)の情報も含まれているため、ソースコードのフォーマットに影響されずにチェックサムを得るには適していません。つまりあらゆる点で、ソースコードをそのままダイジェスト化するのと大して変わりません。

# spec/models/user_spec.rb

require "rails_helper"

RSpec.describe "User" do
  specify ""#reloadメソッドがフレームワークの実装に基づいてオーバーライドされていること"" do
    expect(checksum_of_actual_reload_implementation).to eq(
      checksum_of_expected_reload_implementation,
    ),
    failure_message
  end

  private

  def checksum_of_actual_reload_implementation
    Digest::SHA256.hexdigest(
      RubyVM::AbstractSyntaxTree.parse(
        ActiveRecord::Persistence.instance_method(:reload).source,
      ).pretty_print_inspect,
    )
  end

  def checksum_of_expected_reload_implementation
    "ed2f4fdf62aece74173a44a65d8919ecf3e0fca7a5d38e2cefb9e51c408a4ab4"
  end
end

🔗 5: チェックサムを使わずに実装が実際に変更されたかどうかをチェックする

Ruby標準ライブラリにはRipperというRubyスクリプトパーサーもあります。Ripperは、コードをパースしてS式のツリーを得ます。残念ながら、こちらの出力にも行や列の情報が含まれますが、後処理をいくつか追加すればこの問題を取り除けそうです。個人的にはS式のチェックサムを比較する方法が好みです。テストフレームワークでは、比較した構文木の差分を表示する機会があります。嬉しいボーナスですね!

# spec/models/user_spec.rb

require "rails_helper"

RSpec.describe "User" do
  specify "#reload method is overridden based on framework implementation" do
    expect(actual_find_record_implementation).to eq(
      expected_find_record_implementation,
    ),
    failure_message
  end

  private

  def actual_reload_implementation
    Ripper.sexp(ActiveRecord::Persistence.instance_method(:reload).source)
  end

  def expected_reload_implementation
    [
      :program,
      [
        [
          :def,
          [:@ident, "reload", [1, 8]],
          [
            :paren,
            [
              :params,
              nil,
              [
                [
                  [:@ident, "options", [1, 15]],
                  [:var_ref, [:@kw, "nil", [1, 25]]],
                ],
              ],
              nil,
              nil,
              nil,
              nil,
              nil,
            ],
          ],
          [
            :bodystmt,
            [
              [
                :call,
                [
                  :call,
                  [
                    :call,
                    [:var_ref, [:@kw, "self", [2, 6]]],
                    [:@period, ".", [2, 10]],
                    [:@ident, "class", [2, 11]],
                  ],
                  [:@period, ".", [2, 16]],
                  [:@ident, "connection", [2, 17]],
                ],
                [:@period, ".", [2, 27]],
                [:@ident, "clear_query_cache", [2, 28]],
              ],
              [
                :assign,
                [:var_field, [:@ident, "fresh_object", [4, 6]]],
                [
                  :if,
                  [
                    :method_add_arg,
                    [:fcall, [:@ident, "apply_scoping?", [4, 24]]],
                    [
                      :arg_paren,
                      [
                        :args_add_block,
                        [[:var_ref, [:@ident, "options", [4, 39]]]],
                        false,
                      ],
                    ],
                  ],
                  [
                    [
                      :method_add_arg,
                      [:fcall, [:@ident, "_find_record", [5, 8]]],
                      [
                        :arg_paren,
                        [
                          :args_add_block,
                          [[:var_ref, [:@ident, "options", [5, 21]]]],
                          false,
                        ],
                      ],
                    ],
                  ],
                  [
                    :else,
                    [
                      [
                        :method_add_block,
                        [
                          :call,
                          [
                            :call,
                            [:var_ref, [:@kw, "self", [7, 8]]],
                            [:@period, ".", [7, 12]],
                            [:@ident, "class", [7, 13]],
                          ],
                          [:@period, ".", [7, 18]],
                          [:@ident, "unscoped", [7, 19]],
                        ],
                        [
                          :brace_block,
                          nil,
                          [
                            [
                              :method_add_arg,
                              [:fcall, [:@ident, "_find_record", [7, 30]]],
                              [
                                :arg_paren,
                                [
                                  :args_add_block,
                                  [[:var_ref, [:@ident, "options", [7, 43]]]],
                                  false,
                                ],
                              ],
                            ],
                          ],
                        ],
                      ],
                    ],
                  ],
                ],
              ],
              [
                :assign,
                [:var_field, [:@ivar, "@association_cache", [10, 6]]],
                [
                  :method_add_arg,
                  [
                    :call,
                    [:var_ref, [:@ident, "fresh_object", [10, 27]]],
                    [:@period, ".", [10, 39]],
                    [:@ident, "instance_variable_get", [10, 40]],
                  ],
                  [
                    :arg_paren,
                    [
                      :args_add_block,
                      [
                        [
                          :symbol_literal,
                          [:symbol, [:@ivar, "@association_cache", [10, 63]]],
                        ],
                      ],
                      false,
                    ],
                  ],
                ],
              ],
              [
                :assign,
                [:var_field, [:@ivar, "@attributes", [11, 6]]],
                [
                  :method_add_arg,
                  [
                    :call,
                    [:var_ref, [:@ident, "fresh_object", [11, 20]]],
                    [:@period, ".", [11, 32]],
                    [:@ident, "instance_variable_get", [11, 33]],
                  ],
                  [
                    :arg_paren,
                    [
                      :args_add_block,
                      [
                        [
                          :symbol_literal,
                          [:symbol, [:@ivar, "@attributes", [11, 56]]],
                        ],
                      ],
                      false,
                    ],
                  ],
                ],
              ],
              [
                :assign,
                [:var_field, [:@ivar, "@new_record", [12, 6]]],
                [:var_ref, [:@kw, "false", [12, 20]]],
              ],
              [
                :assign,
                [:var_field, [:@ivar, "@previously_new_record", [13, 6]]],
                [:var_ref, [:@kw, "false", [13, 31]]],
              ],
              [:var_ref, [:@kw, "self", [14, 6]]],
            ],
            nil,
            nil,
            nil,
          ],
        ],
      ],
    ]
  end

  def failure_message
    # ...
  end
end

🔗 6: 最終成果物

最後に、私は「実用的な」実装にこだわっています。そのためにparser gemとmethod_source gemに依存することになります。幸いこれらのgemはprymutantrubocopと一緒にプロジェクトに入っていたので、依存をこれ以上増やさずに平和にやれました。

whitequark/parser - GitHub

require "parser/current"

RSpec.describe "User" do
  include AST::Sexp

  specify "#reload method is overridden based on framework implementation" do
    expect(actual_find_record_implementation).to eq(
      expected_find_record_implementation,
    ),
    failure_message
  end

  private

  def actual_reload_implementation
    Parser::CurrentRuby.parse(
      ActiveRecord::Persistence.instance_method(:reload).source,
    )
  end

  def expected_reload_implementation
    s(
      :def,
      :reload,
      s(:args, s(:optarg, :options, s(:nil))),
      s(
        :begin,
        s(
          :send,
          s(:send, s(:send, s(:self), :class), :connection),
          :clear_query_cache,
        ),
        s(
          :lvasgn,
          :fresh_object,
          s(
            :if,
            s(:send, nil, :apply_scoping?, s(:lvar, :options)),
            s(:send, nil, :_find_record, s(:lvar, :options)),
            s(
              :block,
              s(:send, s(:send, s(:self), :class), :unscoped),
              s(:args),
              s(:send, nil, :_find_record, s(:lvar, :options)),
            ),
          ),
        ),
        s(
          :ivasgn,
          :@association_cache,
          s(
            :send,
            s(:lvar, :fresh_object),
            :instance_variable_get,
            s(:sym, :@association_cache),
          ),
        ),
        s(
          :ivasgn,
          :@attributes,
          s(
            :send,
            s(:lvar, :fresh_object),
            :instance_variable_get,
            s(:sym, :@attributes),
          ),
        ),
        s(:ivasgn, :@new_record, s(:false)),
        s(:ivasgn, :@previously_new_record, s(:false)),
        s(:self),
      ),
    )
  end

  def failure_message
    # ...
  end
end

ご覧の通り、出力に行や列への参照は含まれていません。

移植性を高めるために、これらのgemに依存しない形にできればと願っています。今後のRubyでこういうもろもろの作業を手軽に行える日が来ますように。

Markus Schirp
Rubyに統合されたYARP(注: 現在はPrismに改名)にMethod#source_astを追加する案は出されているだろうか?これがあればツールを大幅に改善できるだろう。もちろん場合によってはnilになってしまうことは承知しているが、メソッドとソースコードを関連付ける現在の方法は「最悪」なので。

Kevin Newton
まだだけど、これは確かに可能!来週ちょっと見てみる時間を取れそう。この追加は重たい作業にはならないと思う。

どうかいい方向に進みますように 🤞

関連記事

Rubyパーサーを一新するprism(旧YARP)プロジェクトの全容と将来(翻訳)


  1. Ruby 3.3からはPrism(旧YARP)も利用できます。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。