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

Rails 7.2: ActiveRecord::Core#inspectの修正とattributes_for_inspectの便利な使い方(翻訳)

概要

CC BY-NC-SA 4.0 Deedに基づいて翻訳・公開いたします。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

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

参考: ウォッチ20231122: ActiveRecord::Core#inspectの出力をカスタマイズ可能になった

Rails 7.2: ActiveRecord::Core#inspectの修正とattributes_for_inspectの便利な使い方(翻訳)

Active Recordモデルのinspectメソッドが返すのは、「モデルのクラス」と「すべての属性と値のリスト」です。Rails 7.2では、inspectの出力にどの属性を含めてよいかを指定可能になりました。

本記事では、この機能を実装するきっかけとなったパフォーマンス問題について解説するとともに、この機能を活用して開発者のエクスペリエンスを快適にする方法についても説明します。

🔗 to_sinspectについて

Rubyのオブジェクトには、to_sメソッドもinspectメソッドも定義されます。to_sはオブジェクトの文字列表現を返しますが、inspectはデバッグに利用可能なオブジェクトの情報を返します。

Active Recordモデルではto_sinspectの動作がどう違うかを見てみましょう。

Book.first.to_s #=> "#<Book:0x000000017765e6b8>"

Book.first.inspect #=> "#<Book id: 1, title: "The Rings of Saturn", created_at: \"2024-03-19 10:16:20.229686000 -0400\", updated_at: \"2024-03-19 10:16:20.229686000 -0400\">"

見ての通り、toはモデルのクラス名だけを含むモデル名の短い文字列表現を返しますが、inspectはモデルの属性とその値をすべて含む文字列を返します。

しかしこれは、inspect呼び出しがto_s呼び出しよりもずっと低速になってしまう可能性があるということです。inspectを呼び出すときは、モデルの全属性をイテレーションして値を取得しなければならず、しかも、値の1つ1つをActiveRecord::ParameterFilterで定義されているフィルタリストと照合して、いわゆる個人情報(Personal Identifiable Information: PII)が出力で露出しないように秘匿化する必要もあります。

つまり、inspectの呼び出しコストは、モデルにある属性の個数とパラメータフィルタの個数に応じて増加します。一方、to_sの呼び出しコストは一定です。

inspectは一般にデバッグ目的で利用されますが、呼び出されるモデルによってはパフォーマンスが低下する可能性があるため、production環境でinspectがどんなタイミングで呼び出されるかについて注意を払うべきです。しかし、今から説明するように、うっかりinspectを呼び出して問題を引き起こすのは驚くほど簡単なのです。

🔗 inspectは"フットガン"

Shopifyの開発者たちは、アプリケーションのパフォーマンスボトルネックを特定するために定期的にコードのプロファイリングを実施しています。昨年、あるチームが、Active Recordのinspectメソッドの実行が特定のリクエストで8秒近くかかってしまっていることを発見しました。8秒はさすがに長すぎますね。

Profile showing >7s spent in ActiveRecord::Core#inspect

上の図はプロファイルから切り出したもので、Active Recordのinspectメソッドの実行時間が7秒を超えていることを示しています。図の表示は左が最も値が大きいので、このリクエストではinspectがトータル7秒以上を消費していることを意味します(消費時間は連続しているとは限りません)。このプロファイルについて詳しくはspeedscopeを参照してください。

さらに調べを進めると、このコードは配列に対してto_sを呼び出していたのですが、その配列には数百個ものActive Recordモデルが含まれていることが判明しました。to_sは、配列やハッシュなどのenumerable(列挙可能)なオブジェクトでは、単なるinspectのエイリアスとして振る舞います(つまり、enumerableな要素をイテレーションして、要素ごとにinspectを呼び出します)。
to_sはさまざまな場面で呼び出される可能性があり、文字列の式展開でもto_sが暗黙で呼び出されます。つまり、1個もしくは数百個のモデルに対してinspectが呼び出される可能性があったとしても、すぐに見分けがつくとは限らないということです。

開発者は、この種の問題を「フットガン(footgun)」と呼ぶことがあります1。たしかにinspectはデバッグで重宝しますが、使い方を間違えればproduction環境でパフォーマンス低下を簡単に引き起こしてしまう方法でもあるのです。Active Recordモデルがぎっしりつまった巨大配列に対してto_sを呼び出すのはたいていの場合誤りの可能性が高いのですが、間違いはいつか起きるものであり、間違えたときにこれほどパフォーマンスを損なう問題を引き起こすべきではありません。

🔗 フットガンを「武装解除する」

アプリケーションのコードをリファクタリングして大量のinspect呼び出しを回避すれば、この種のパフォーマンス低下の修正は一応可能です。しかし、このような悲惨な状況を回避するためには、Railsがproduction環境でもう少しうまく頑張って欲しいものです。

どんな解決方法が可能かを検討するときは、inspectのユースケースがproduction環境に存在していることを思い出すのが重要です。したがって、私たちの解決方法は、単にproduction環境のinspectメソッド呼び出しを再定義したり削除したりするだけでは終わりません。私たちの理想の解決方法は、inspectのパワーを失わずに、意図しない誤用に対する安全性を提供することです。

いくつかの方法を検討した結果、以下の2つのソリューションに落ち着きました。

  1. inspectでどの属性を出力してよいかを開発者が設定可能にする
  2. production環境では、inspectidのみを出力するようモデルを設定する

このソリューションについてもう少し詳しく見ていきましょう(#49765もチェックしてみてください)。

まず、ActiveRecord::Baseattributes_for_inspectという新しいクラス属性を作成しました。モデルのインスタンスでinspectが呼び出されると、attributes_for_inspectのリストに含まれている属性だけが出力に含まれるようになります。

Book.first.inspect #=> "#<Book id: 1, title: \"The Rings of Saturn\", created_at: \"2024-03-19 10:16:20.229686000 -0400\", updated_at: \"2024-03-19 10:16:20.229686000 -0400\">"

Book.attributes_for_inspect = [:id, :title]

Book.first.inspect #=> "#<Book id: 1, title: \"The Rings of Saturn\">"

次に、production環境ではすべてのモデルでattributes_for_inspectの値を[:id]に設定しました。これにより、production環境のRailsコンソールで以下の結果が表示されるようになります。

Book.first.inspect #=> "#<Book id: 1>"

これで、潜在的なパフォーマンス低下の問題は大幅に軽減されました。production環境では、モデルでinspectを呼び出しても、:id属性だけが表示されます。この場合、属性に対するパラメータフィルタも引き続きチェックされるので、to_sを呼び出すよりは遅くなりますが、重要なのは、モデルの属性数が増えてもinspectの呼び出しコストが増加しなくなったことです。

inspectのフルパワーを取り戻す「脱出ハッチ」も2種類用意されています。
1つは、attributes_for_inspect:allを渡すことで、これによって再びすべての属性が出力されるようになります。この振る舞いはdevelopmentモードとtestモードのデフォルトなので、従来通りinspectをデバッグに利用可能になります。

Book.attributes_for_inspect = :all

Book.first.inspect #=> "#<Book id: 1, title: \"The Rings of Saturn\", created_at: \"2024-03-19 10:16:20.229686000 -0400\", updated_at: \"2024-03-19 10:16:20.229686000 -0400\">"

もう1つは、新しいfull_inspectを呼び出す方法です。これは特定の場面でのみすべての属性を出力する必要が生じた場合に利用できます。

Book.attributes_for_inspect = [:id]
Book.first.inspect #=> "#<Book id: 1>
Book.first.full_inspect #=> "#<Book id: 1, title: "The Rings of Saturn", created_at: \"2024-03-19 10:16:20.229686000 -0400\", updated_at: \"2024-03-19 10:16:20.229686000 -0400\">"

🔗 ボーナス: attributes_for_inspectで快適に開発しよう

attributes_for_inspectを導入した動機は、もともとproduction環境でinspectが使われた場合にパフォーマンスが低下する可能性のある問題を軽減することでした。しかしattributes_for_inspectはアプリケーションの開発エクスペリエンスを快適にするのにも利用できるのです。

大規模なRailsアプリケーションでは、Active Recordモデルに何十個もの属性があり、場合によっては属性の値が非常に長くなることも珍しくありません。

inspectは手動で呼び出して使うとは限りません。たとえば以下のようにRailsコンソールでオブジェクトが返されるときにもinspectが自動で呼び出されますが、inspectの表示量が増えると邪魔になることがあります。

myapp(dev)> Book.first
=>
  #<Book:0x000000017f9a9d88 id: 1,
  title: "The Rings of Saturn",
  author: "W.G. Sebald",
  published: 1998,
  pages: 306,
  language: "English",
  type: "fiction",
  format: "hardcover",
  translated: true,
  original_language: "German",
  original_name: "Die Ringe des Saturn",
  translator: "Michael Hulse",
  isbn-13: 9780811213783,
  isbn-10: "0811213781",
  publisher: "New Directions",
  created_at: "2024-03-19 10:16:20.229686000 -0400",
  updated_at: "2024-03-19 10:16:20.229686000 -0400">

inspectの出力量が増えた場合やターミナルウィンドウが狭い場合は、ある時点で上のように複数行に分割されます。ターミナルが出力結果でうずまってしまうと上下にスクロールしなければならなくなったり、デバッグセッションをいったん終了してからでないとコマンドもろくに入力できなくなったりすることがあります。inspectの出力結果が多すぎると、テストが失敗したときの結果表示が崩れたりすることもあります。

おそらく皆さんも私と同様に、出力が多すぎて結果の解析で苦労したり、デバッグ中に画面に表示された大量の文字を理解するのに手間取ったりした経験がおありかと思います。私の場合、モデルの全属性について値を把握しなければならなくなることは、まずありません。むしろ、モデル属性の1つか2つについて値がわかれば、次の作業を決定するのに十分です。

そこで、attributes_for_inspectを使えば、デバッグ時に最も有用な属性だけを出力できます。これは、特にモデルの配列を操作するときに便利で、数百行の出力をわずか数行に削減できます。

Rails 7.2のデフォルトでは、既存の振る舞いを維持するために、developmentモードとtestモードですべてのモデルに対してattributes_for_inspect:allに設定されます。
しかし私としては、Railsコンソールを快適にするためにattributes_for_inspect をぜひ皆さんにもお試しいただきたいと思います。full_inspectを呼び出せばいつでもモデルの全属性を表示できることもお忘れなく。

🔗 まとめ

よく言われているように、Railsは切れ味のよい刃物も提供していますが、こうした強力な機能が思わぬ問題を引き起こすこともあります。
本記事では、開発者がうっかり大量のActive Recordモデルに対してinspectメソッドを呼び出してパフォーマンス低下につながる可能性があることを見てきました。

Rails 7.2からは、inspectで出力してよい属性をコンフィグで指定可能になりました。これは、inspectが誤って呼び出されたときにパフォーマンスが低下する潜在的な問題を防ぐのに役立ちます。

さらに、この機能を活用すれば開発エクスペリエンスを快適にできることも見てきました。Railsアプリケーションを7.2にアップグレードする機会があったら、ぜひattributes_for_inspectをお試しください。

関連記事

Rails: アサーションが動いていないテストを効果的に発見する方法(翻訳)


  1. footgunは「自分の足を撃ち抜くために作られたかのようなピストル」のことで、強力だが痛い目に遭う可能性がある機能というニュアンスで英語圏の技術記事でよく使われます。参考: Urban Dictionary: footgun 

CONTACT

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