Rails: モンキーパッチが元コードの更新で乖離するのを防ぐ6つの方法(翻訳)
モンキーパッチ(monkey-patching)を手短に説明すると、直接管理されていない外部のソースコードを、プロジェクトの目的に沿って改変することです。長年動いている「レガシー」プロジェクトのフレームワークを一新する場合や、依存関係をまだアップグレードできない事情がある場合、あるいは多数のコードブロックを移動する必要が生じた場合にモンキーパッチが必要になることがよくあります。
モンキーパッチは手っ取り早く作業を進めるための短期的なソリューションです。モンキーパッチの効果はただちに現れます。外部コードの変更許可を取り付ける必要もなければ、依存関係のコードをforkさせて余分な作業を背負い込む必要もありません。
しかしモンキーパッチを使うと、コードが非常にもろくなるという代償がついて回ります。外部の依存関係が将来更新されたときに、現行のモンキーパッチが問題なく動作すると期待するのはまったく無理な話です。個人開発ではなくチームで開発しているのであれば、モンキーパッチという「短期借り入れ」を行ったことをチームに周知しておくことが非常に重要です。
🔗 1: バージョンチェックで依存関係の変更から保護する
依存関係にモンキーパッチを当てたことをチームに伝える手段のひとつは、テストを書く形でドキュメント化することです。
ドキュメントをテストの形にする理由は以下のとおりです。
- プロジェクト内のテストコードは、外部のドキュメントよりもモンキーパッチで改変されるコードに近い場所にあるので見つけやすい。
-
単なるコードコメントと異なり、テストは実行可能なので、誰かさんのようにお知らせを見落とすリスクを大幅に軽減できる。
最近私が関わったとあるプロジェクトでは、既に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の元フェローのツイート↓を読んで、この問題に新たな光が当たりました。
That is one of the coolest #ruby tricks, I've seen. You need to do some monkey patch of a library method. At any point in the future, the library might change the method implementation and your patch needs to be re-verified with newer library version. This detects such situation. pic.twitter.com/LVpZ8Fev6y
— Robert Pankowecki (@pankowecki) August 3, 2023
これは自分が見た中で最もクールな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
という依存関係に由来していたのです。それでも既存のプロジェクトにおける依存関係についてはうまく動き、単なるバージョンチェックよりも優秀でした。
他に改良できそうな点はあるでしょうか?
🔗 4: 実装の抽象構文木(AST)をチェックする
ソースコードのハッシュを算出する方法はイケていると思いますが、「フォーマットのみの」変更には力不足です。ソースコードはテキストで表現されるものであり、スペースや改行といったいわゆるホワイトスペースを加えても実装としては変わらず、振る舞いは同じになります。しかしハッシュはホワイトスペースが変わると変わってしまい、偽陰性となります。
この点を改良できないものでしょうか?はい、可能です。抽象構文木(AST)に少しばかり助けてもらえばよいのです。理論上は、AST表現を用いることでモンキーパッチされたコードのフォーマットの違いに悩まされずに済むようになるはずです。
Rubyの場合、ソースコードのASTを得る方法はいくつも考えられます。人気が高いのはparser
gemやsyntax_tree
gemです。Ruby標準ライブラリにはRipper
もありますし、ネイティブのRubyVM::AbstractSyntaxTree
も利用できます1。
悲観的な方なら、それぞれの方法における限界に真っ先に気づくことでしょう。
RubyVM::AbstractSyntaxTree
とRipper
の出力には引き続きフォーマットが含まれてしまうので、目的を達成できません。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はpry
やmutant
やrubocop
と一緒にプロジェクトに入っていたので、依存をこれ以上増やさずに平和にやれました。
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でこういうもろもろの作業を手軽に行える日が来ますように。
Not yet but certainly possible! I can take a quick look next week. I think this wouldn’t be a big lift for us.
— Kevin Newton (@kddnewton) August 26, 2023
Markus Schirp
Rubyに統合されたYARP(注: 現在はPrismに改名)にMethod#source_ast
を追加する案は出されているだろうか?これがあればツールを大幅に改善できるだろう。もちろん場合によってはnil
になってしまうことは承知しているが、メソッドとソースコードを関連付ける現在の方法は「最悪」なので。Kevin Newton
まだだけど、これは確かに可能!来週ちょっと見てみる時間を取れそう。この追加は重たい作業にはならないと思う。
どうかいい方向に進みますように 🤞
関連記事
-
Ruby 3.3からは
Prism
(旧YARP)も利用できます。 ↩
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。