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

週刊Railsウォッチ: Active SupportにObject#withが追加、カスタム名前空間のサポートほか(20230328前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

🔗 Dockerのアプリケーションとgemをロックするようになった

Railsが現在生成するDockerfileは非rootユーザーとして実行されるので、OSの変更はできないものの、すべてのgemとアプリケーション自身が変更可能になっている。
このプルリクは、アプリケーションのgemをロックし、db、log、storage、tmpディレクトリのみアクセス可能にする形に変更する。
これは別案#47594よりもずっとセキュア。
同PRより


つっつきボイス:「Dockerfileで/usr/local/bundle/railsをrootユーザーownerとしてCOPYしてからUSER rails:railsを設定するようにしたことで、gemファイルとRailsのディレクトリをrailsユーザーが変更できないようにしたんですね: これらは通常は変更しないものなので変更不可にする方がいい👍」「なるほど」「Railsアプリのdb、log、storage、tmpディレクトリは、railsユーザーが変更できないとRailsアプリが動かなくなります」「そういえば少し前にSQLite3ファイルはstorageディレクトリに置くように変わりましたね(ウォッチ20221220)」

# railties/lib/rails/generators/rails/app/templates/Dockerfile.tt#L75
-# Run and own the application files as a non-root user for security
-RUN useradd rails --home /rails --shell /bin/bash
-USER rails:rails
-
# Copy built artifacts: gems, application
-COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
-COPY --from=build --chown=rails:rails /rails /rails
+COPY --from=build /usr/local/bundle /usr/local/bundle
+COPY --from=build /rails /rails
+
+# Run and own only the runtime files as a non-root user for security
+RUN useradd rails --home /rails --shell /bin/bash && \
+    chown -R rails:rails db log storage tmp
+USER rails:rails

🔗 Zeitwerkでのカスタム名前空間のサポート方法をドキュメントに追加

はじめに
このパッチは、カスタムroot名前空間のサポートを改善し、RailsにおけるZeitwerkの統合設計の方向性を固めるものである。
ユースケース
app/servicesディレクトリにあるすべてのサービスをServices名前空間の下に置きたい。
デフォルト設定ではapp/services/servicesというサブディレクトリを作成すればできるが、どうも違和感がある(好みもあるだろうが)。
それよりは、app/services/users/signup.rbを置けばServices::Users::Signupを定義できる方がよいだろう。

既存の回避方法
サポートはされていないものの、オートロードパスがObjectを表現するという制約を克服する方法を考えだした人もいる。
Classicローダー
classicのプロジェクトではappをオートロードのパスに追加していた。これによりapp/servicesServices名前空間として解釈可能になった。

しかしapp/servicesはオートロードパスでもあるので、これは曖昧さを導入してしまう。すなわちclassicオートローダーでは、Services::Users::SignupUsers::Signupもそのファイルについて有効な定数になる。同じ引数で、app/models/user.rbUserを定義することもModels::Userを定義することも許可している!
これが原因で、同じサブツリーで命名がばらついてしまったコードベースを見たことがある。
app/javascriptについて言うなら、appがオートロードパスであれば、app/javascriptは形式的にはRubyのJavascriptという名前空間ということになり、実に紛らわしい。

この手法には欠点もあるが、上述の制約を回避するには必要だった。

Zeitwerk
zeitwerkモードではappをオートロードパスに追加することも一応可能だが、Zeitwerkではネステッドrootディレクトリをサポートしており、上述の曖昧さを意図的に排除しているので、app/servicesActiveSupport::Dependencies.autoload_pathsから手動で削除する必要もあった。
app/javascriptはオートロードパスではなく理論上の名前空間を表すので、きれいに扱うためにはmainローダーがこのディレクトリを無視する必要があった。

もっとよい方法として、カスタムroot名前空間のサポートを利用する手もあるだろう。しかしapp/servicesにカスタムroot名前空間を設定しようとしてもRailsがこの設定を上書きしてしまう。このオプションを使う場合は、引き続きActiveSupport::Dependencies.autoload_pathsからapp/servicesを削除したうえでconfig.watchable_dirsで監視しなければならなかった。

どちらも今ひとつ。

ソリューション
ここでやりたいことを実現するには、カスタムroot名前空間を利用するのがRailsらしく、かつ推奨される方法。
その際、ユーザーが既に設定したものをアプリケーションの起動中に上書きしないようにすればよい。この方法をサポートされているものとしてドキュメントに書く。
要するに、「mainオートローダを設定してください」というドキュメントの指示通りにすることで問題なく動作する。

# config/initializers/autoloading.rb

# この名前空間は存在している必要がある。
#
# この例ではモジュールをその場で定義している。
# モジュールを別の場所で定義してここで普通に`require`で読み込んでもよい。
# いずれにしろ、`push_dir`はクラスオブジェクトかモジュールオブジェクトを期待する。
module Services; end

Rails.autoloaders.main.push_dir("#{Rails.root}/app/services", namespace: Services)

設計上の考慮
Railsには組み込みのオートローダーがあるので、オートローダー用の設定ポイントもある。しかし、ZeitwerkとRailsへの統合が進化するにつれて、RailsフレームワークがZeitwerkの振る舞いを反映すべきかどうかを決定する必要が生じた。
私の直感は、その路線を追いかけないようにするというものだった。Zeitwerkの現在の設定ポイントを見れば、Zeitwerkを優先しようもなかったことと、そうするメリットも疑わしかったことがわかるだろう。

その直感に導かれて、Rails 6で既に次のような文をドキュメント化していた。「あなたの知っている設定ポイントは機能します。それ以上のカスタマイズについてはオートローダーに手を付けることになります。これはpublicインターフェイスに属するものであり、さらにカスタマイズしても問題ありません。」
当時自分が悩んだのは、この場合config.autoload_pathsが名前空間をサポートすべきかどうか(後方互換性の面で困難)、あるいはconfig.namespacesのような設定を導入すべきかどうかだった。
いや、おそらく私の直感は時間によって検証されたのだろう。正しい方向性は、オートローダーを公開してカスタマイズを受け入れる方向に進むことだと考えている。その場合、いくつかのrootディレクトリが既に存在している可能性を受け入れてそれを尊重し、そしてカスタム名前空間を使う推奨のソリューションをRailsガイドのオートローダーガイドに書くことで、その見解の通りになるようにする。
同PRより


つっつきボイス:「Zeitwerkの作者fxnさんによるプルリクです」

「classicローダーだとオートロードのパスにappを追加すればapp/services/ディレクトリをServices名前空間にできたけど、たしかにZeitwerkだとこういうふうにautoloading.rbにmodule Services; endなんかを足して事前に空のモジュールだけ宣言しておかないと動かないんですよ↓」「オートローダー難しい」

# config/initializers/autoloading.rb
module Services; end

Rails.autoloaders.main.push_dir("#{Rails.root}/app/services", namespace: Services)

「プルリクではautoloading_and_reloading_constantsガイドにZeitwerkでやる方法を追加していて、コードの更新はごくわずかでした」「services/はデフォルトのRailsにはないディレクトリなので、ドキュメントを更新する形で対応するのが妥当でしょうね: 個人的にはmodule Services; endも書かずに済むとなお嬉しいけど、名前空間を設定する公式な方法が示されたのはいい👍」

参考: 定数の自動読み込みと再読み込み (Zeitwerk) - Railsガイド

fxn/zeitwerk - GitHub

🔗 MissingAttributeErrorでクラス名を表示するようになった

動機/背景
MissingAttributeErrorの現在のエラーメッセージは、どのクラスで属性が見当たらないかがわからない。特に属性が見当たらない可能性のあるクラスが複数ある場合に困る。

エラーメッセージにクラス名を表示することでデバッグしやすくなる。

user = User.first
user.pets.select(:id).first.user_id
#=> ActiveModel::MissingAttributeError: missing attribute 'user_id' for Pet

これにより、UnknownAttributeErrorのエラーメッセージとも一貫するようになる。

#=> ActiveModel::UnknownAttributeError: unknown attribute 'name' for Person

同PRより


つっつきボイス:「エラーメッセージでfor Petのようにクラス名も表示してくれるのはありがたい👍: 特にmissing系のエラーメッセージだとどこで起きたかを調べるためにデバッガを使うことも多かったので」

参考: Rails API ActiveModel::MissingAttributeError
参考: Rails API ActiveRecord::UnknownAttributeError

🔗 複合foreign_key制約のnullifyを修正

以下のようにセットアップされたモデルがあるとする。

class BlogPost < ApplicationRecord
  query_constraints :blog_id, :id

  has_many :comments, query_constraints: [:blog_id, :blog_post_id]
end

class Comment < ApplicationRecord
  query_constraints :blog_id, :blog_post_id

  belongs_to :blog_post, query_constraints: [:blog_id, :blog_post_id]
end

関連付けblog_post.comments = []comment.blog_post = nilの両方がnullify(無効化)可能なはずなので、結果では複合クエリ制約のあらゆる部分がnullifyされるべき(つまり、影響を受けるcommentsblog_idblog_post_idがnilになる)。

このソリューションは、query_constraintsで行う作業の中で最もシンプルなものである。
単一のforeign_keyに対して行っていたことを複合foreign_keyの各要素に対して一度だけ繰り返す。このユースケースでは「query_constraints外部キーのすべての部分をnullifyする」ことになる。


つっつきボイス:「複合外部キー周りの修正です」「nullified_owner_attributesがまだ単一の外部キーにしか対応していなかったのを、複合外部キーについても動作するように修正したようですね↓」「なるほど」「そういえば#46331から複合キーでquery_constraintsを使うようになってましたね(ウォッチ20221115)」

# activerecord/lib/active_record/associations/foreign_association.rb#L
    def nullified_owner_attributes
      Hash.new.tap do |attrs|
-       attrs[reflection.foreign_key] = nil
+       Array(reflection.foreign_key).each { |foreign_key| attrs[foreign_key] = nil }
        attrs[reflection.type] = nil if reflection.type.present?
      end
    end

参考: Allow specifying columns to use in ActiveRecord::Base object queries by nvasilevski · Pull Request #46331 · rails/rails

参考: Rails EdgeAPI query_constraints -- ActiveRecord::Persistence::ClassMethods

🔗 delayed_jobが使うdisplay_nameをフェイルセーフにする

動機/背景
display_nameメソッドは、特定のジョブに関する情報(失敗メッセージも含まれる)をログ出力するためにdelayed_jobで使われる。
あるジョブクラスが移動または削除されるたびに、スケジュール済みのインスタンスが定数化(constantize)できなくなり、display_nameとログメソッドで例外が発生する。
特定の状況(ログ出力がrescueブロックで実行されるなど)では、delayed_jobのワーカー全体が終了してしまう可能性もある。このフェイルセーフな方法を使えば、失敗したジョブがワーカーによってgracefulに処理されて作業が続行され、すべてについて適切なログが出力されるようになる。
追加情報
問題の再現手順:

  1. delayed_jobをバックエンドにして、遠い未来のジョブをスケジュールする(例: MyJob.set(wait_until: Date.tomorrow).perform_later
  2. ジョブクラスをMyJobにリネームするか削除する
  3. delayed jobs:rake jobs:work`でdelayed_jobを実行する
  4. ジョブが"NameError: uninitialized constant MyJob"で失敗する
    同PRより

つっつきボイス:「ジョブクラスが変更されるとエラーが発生するのはよくある話ですね: エンキュー時点では存在していたクラスが、デプロイなどを挟んだ後でリネームされたり削除されたりとか」

「そういう場合にいきなりraiseするのではなくNameErrorrescueする形に修正した、なるほど↓」

# activejob/lib/active_job/queue_adapters/delayed_job_adapter.rb#L50
        private
          def log_arguments?
            job_data["job_class"].constantize.log_arguments?
+         rescue NameError
+           false
          end

collectiveidea/delayed_job - GitHub

🔗 PostgreSQLのadd_indexincludewhereを両方使えるようにする

動機/背景
#44803で、PostgreSQLインデックス作成時のincludeオプションのサポートが追加された。
しかし、このオプションをwhereオプションと組み合わせると、INCLUDEとWHEREの順序が正しくならずにマイグレーションがエラーになることがある。

Error:
ActiveRecord::Migration::IndexTest#test_add_index_with_included_column_and_where_clause:
ActiveRecord::StatementInvalid: PG::SyntaxError: ERROR:  syntax error at or near "INCLUDE"
LINE 1: ...ings" ("last_name") WHERE first_name = 'john doe' INCLUDE ("...

CREATE INDEXでも指摘されているように、INCLUDEはWHEREよりも前になければならない。
このプルリクは、INCLUDEとWHEREオプションを同一のインデックスで使えるようにするために作成された。
詳細
このプルリクは、インデックスを追加する場合のINCLUDEとWHEREの順序を変更してクエリが有効になるようにする。
また、スキーマダンプでCREATE INDEX文に期待される順序も更新される。
同PRより


つっつきボイス:「修正はINCLUDEとWHEREの順序を正しくするというものですね」「ところで#44803でPostgreSQL用にadd_indexinclude:オプションが使えるようになってたんですね」「ウォッチで見かけたような気もするけど#44803は見当たりませんでした」

参考: PostgreSQL 14.5ドキュメント CREATE INDEX

「お、CREATE INDEXでINCLUDE句を使うとインデックスに非キー列も含められるのか↓: この列はインデックス化されるのでSELECTがめちゃ速くなりそう」「ぽすぐれにこんな機能があったとは」

INCLUDE
オプションのINCLUDE句は非キー列としてインデックスに含める列のリストを指定します。 非キー列をインデックススキャンの検索条件に使うことはできません。また、インデックスで何であれ一意性制約や排他制約を強制する目的に対しても無視されます。 しかしながら、インデックスオンリースキャンは、インデックスエントリから値を直接得ることができるので、インデックスのテーブルを見に行く必要なく、非キー列の内容を返すことができます。 このように非キー列の追加は、そうでないとできないインデックスオンリースキャンを利用可能にします
PostgreSQL 14.5ドキュメント CREATE INDEXより

参考: Rails API add_index--ActiveRecord::ConnectionAdapters::SchemaStatements`

🔗 rails/info/routeページのルーティング検索でテーブルのすべての内容を検索可能にした

rails/info/routesの検索フィールドを拡張して、route namehttp verbcontroller#actionも検索できるようにした。
Jason Kotchoff
同Changelogより


つっつきボイス:「これはdevelopment環境のRailsアプリをブラウザで/rails/info/routeにアクセスしたときに表示されるGUIルーティングテーブルの改修ですね」「言われてみればGUIだと検索してもフィルタされない列がいくつかあった気がします」「自分はコンソールでbin/rails routesgrepすることが多いですが、GUIでやりたい人には便利そう👍」

参考: §6.1 既存のルールを一覧表示する -- Rails のルーティング - Railsガイド

🔗 has_secure_passwordがsalt生成メソッドを生成するよう修正

has_secure_passwordが、パスワードダイジェスト計算用のsaltを返す#{属性名}_saltメソッドを生成するようになった。このsaltはパスワードが変更されるたびに変更されるので、generates_token_forで単一目的のパスワードリセットトークンの作成に利用できるようになる。

class User < ActiveRecord::Base
  has_secure_password
  generates_token_for :password_reset, expires_in: 15.minutes do
    password_salt&.last(10)
  end
end

Lázaro Nixon
同Changelogより


つっつきボイス:「has_secure_passwordを呼ぶときに属性のsalt用メソッドを今まではデフォルトで生成していなかったのか」「修正は普通にpublic_sendで生成していますね↓」

# activemodel/lib/active_model/secure_password.rb#173
+       # Returns the salt, a small chunk of random data added to the password before it's hashed.
+       define_method("#{attribute}_salt") do
+         attribute_digest = public_send("#{attribute}_digest")
+         attribute_digest.present? ? BCrypt::Password.new(attribute_digest).salt : nil
+       end

参考: Rails API has_secure_password -- ActiveModel::SecurePassword::ClassMethods
参考: Object#public_send (Ruby 3.2 リファレンスマニュアル)
参考: ソルト (暗号) - Wikipedia

🔗 Active SupportにObject#withが追加された

ユースケース
Rubyでは、特にテストで属性の値をいったん保存して新しい値を設定し、ensureで元の値を復元するというパターンが非常に多い。

例: 単体テストの場合

def test_something_when_enabled
  enabled_was, SomeLibrary.enabled = SomeLibrary.enabled, true
  # 何かテストする
ensure
  SomeLibrary.enabled = enabled_was
end

実際のAPIでも時々使われる:

def with_something_enabled
  enabled_was = @enabled
  @enabled = true
  yield
ensure
  @enabled = enabled_was
end

このパターンに本質的な問題があるわけではないが、たとえば単体テストで以下のような間違いが起こりやすい。

def test_something_when_enabled
  some_call_that_may_raise
  enabled_was, SomeLibrary.enabled = SomeLibrary.enabled, true
  # 何かテストする
ensure
  SomeLibrary.enabled = enabled_was
end

上のsome_call_that_may_raiseが実際にraiseすると、SomeLibrary.enabledがもとの値ではなくnilになってしまう。この種の間違いはよく見かけられる。

Object#with

このパターンを正しく使いやすく実装するためのメソッドがObjectにあれば便利だと思う。

注: アクセサがprivateな場合にこうしたメソッドが利用可能になってはいけないと思うので、public_sendを使っている。

withの使い方:

def test_something_when_enabled
  SomeLibrary.with(enabled: true) do
    # 何かテストする
  end
end
GC.with(measure_total_time: true, auto_compact: false) do
  # 何かする
end

Railsコードベース内にある以下のような多くのテストもシンプルにできるだろう:

  • Thread.report_on_exceptionの変更(2d2fdc9):
# activerecord/test/cases/connection_pool_test.rb#L583-L595
      def test_non_bang_disconnect_and_clear_reloadable_connections_throw_exception_if_threads_dont_return_their_conns
        Thread.report_on_exception, original_report_on_exception = false, Thread.report_on_exception
        @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout
        [:disconnect, :clear_reloadable_connections].each do |group_action_method|
          @pool.with_connection do |connection|
            assert_raises(ExclusiveConnectionTimeoutError) do
              new_thread { @pool.public_send(group_action_method) }.join
            end
          end
        end
      ensure
        Thread.report_on_exception = original_report_on_exception
      end
  • クラス属性の変更(2d2fdc):
# activerecord/test/cases/associations/belongs_to_associations_test.rb#L136-L150
  def test_optional_relation
    original_value = ActiveRecord::Base.belongs_to_required_by_default
    ActiveRecord::Base.belongs_to_required_by_default = true

    model = Class.new(ActiveRecord::Base) do
      self.table_name = "accounts"
      def self.name; "Temp"; end
      belongs_to :company, optional: true
    end

    account = model.new
    assert_predicate account, :valid?
  ensure
    ActiveRecord::Base.belongs_to_required_by_default = original_value
  end

同PRより


つっつきボイス:「値を一時保存しておいて、作業が終わったらensureで必ず元に戻すようにするのはよく使うけど、.with(enabled: true)のように書けばそのスコープ内で変更した値を変更終了後に復旧してくれるのか、なるほど」「Object#withを使うとensure書かずに済むのはよさそうですね」「Object#withが使えるのはpublicメソッドかつ書き換え可能なものに限られていますね」

「RubyのObjectクラスの拡張なのでActive Supportとしては割と大きな改修だと思いますが、やりたいことはわかる👍」

参考: Active Support コア拡張機能 - Railsガイド


前編は以上です。

バックナンバー(2023年度第1四半期)

週刊Railsウォッチ: Rubyに新しくRJITがマージされた、Shopifyのタスク管理gem maintenance_tasksほか(20230322)

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース

Ruby Weekly

Hacklines

Hacklines


CONTACT

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