- Ruby / Rails関連
週刊Railsウォッチ: Active SupportにObject#withが追加、カスタム名前空間のサポートほか(20230328前編)
こんにちは、hachi8833です。
🔗Rails: 先週の改修(Rails公式ニュースより)
- 公式更新情報: Ruby on Rails — This Week in Rails: Improve custom namespace autoloading, Object#with and more!
🔗 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/servicesはServices名前空間として解釈可能になった。しかし
app/servicesはオートロードパスでもあるので、これは曖昧さを導入してしまう。すなわちclassicオートローダーでは、Services::Users::SignupもUsers::Signupもそのファイルについて有効な定数になる。同じ引数で、app/models/user.rbはUserを定義することもModels::Userを定義することも許可している!
これが原因で、同じサブツリーで命名がばらついてしまったコードベースを見たことがある。
app/javascriptについて言うなら、appがオートロードパスであれば、app/javascriptは形式的にはRubyのJavascriptという名前空間ということになり、実に紛らわしい。この手法には欠点もあるが、上述の制約を回避するには必要だった。
Zeitwerk
zeitwerkモードではappをオートロードパスに追加することも一応可能だが、Zeitwerkではネステッドrootディレクトリをサポートしており、上述の曖昧さを意図的に排除しているので、app/servicesをActiveSupport::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ガイド
🔗 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されるべき(つまり、影響を受けるcommentsでblog_idとblog_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
参考: Rails EdgeAPI query_constraints -- ActiveRecord::Persistence::ClassMethods
🔗 delayed_jobが使うdisplay_nameをフェイルセーフにする
動機/背景
display_nameメソッドは、特定のジョブに関する情報(失敗メッセージも含まれる)をログ出力するためにdelayed_jobで使われる。
あるジョブクラスが移動または削除されるたびに、スケジュール済みのインスタンスが定数化(constantize)できなくなり、display_nameとログメソッドで例外が発生する。
特定の状況(ログ出力がrescueブロックで実行されるなど)では、delayed_jobのワーカー全体が終了してしまう可能性もある。このフェイルセーフな方法を使えば、失敗したジョブがワーカーによってgracefulに処理されて作業が続行され、すべてについて適切なログが出力されるようになる。
追加情報
問題の再現手順:
- delayed_jobをバックエンドにして、遠い未来のジョブをスケジュールする(例:
MyJob.set(wait_until: Date.tomorrow).perform_later)- ジョブクラスを
MyJobにリネームするか削除するdelayed jobs:rake jobs:work`でdelayed_jobを実行する- ジョブが
"NameError: uninitialized constant MyJob"で失敗する
同PRより
つっつきボイス:「ジョブクラスが変更されるとエラーが発生するのはよくある話ですね: エンキュー時点では存在していたクラスが、デプロイなどを挟んだ後でリネームされたり削除されたりとか」
「そういう場合にいきなりraiseするのではなくNameErrorをrescueする形に修正した、なるほど↓」
# 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
🔗 PostgreSQLのadd_indexでincludeとwhereを両方使えるようにする
動機/背景
#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_indexでinclude:オプションが使えるようになってたんですね」「ウォッチで見かけたような気もするけど#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 name、http verb、controller#actionも検索できるようにした。
Jason Kotchoff
同Changelogより
つっつきボイス:「これはdevelopment環境のRailsアプリをブラウザで/rails/info/routeにアクセスしたときに表示されるGUIルーティングテーブルの改修ですね」「言われてみればGUIだと検索してもフィルタされない列がいくつかあった気がします」「自分はコンソールでbin/rails routesをgrepすることが多いですが、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 endLá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 endGC.with(measure_total_time: true, auto_compact: false) do # 何かする endRailsコードベース内にある以下のような多くのテストもシンプルにできるだろう:
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)
- 20230315後編 Wasm Workers Server 1.0、mruby 3.2.0リリース、irbtoolsほか
- 20230314前編 Devise 4.9のHotwire/Turbo統合に対応する、英国政府のViewComponentほか
- 20230308後編 Ruby30周年記念イベント、37signalsのデプロイツールmrskほか
- 20230307前編 Action Mailerプレビューで全メールヘッダーを表示可能に、rubocop-graphqlほか
- 20230222後編 Ruby 3.2のData#initializeの設計、ruby-openai gemほか
- 20230221前編 Ruby30周年記念イベント、ActiveRecord APIクイズほか
- 20230215後編 Bundler 2.4リリース、RubyKaigi 2023参加募集開始ほか
- 20230214前編 AssumeSSLミドルウェア追加、Fly.ioとRails 7.1のDocker対応ほか
- 20230202後編 ShopifyのYJIT記事、RubyGemsのgem execコマンドほか
- 20230201後編 Ruby 3.2のベンチマーク記事、dry-cliで高度なCLIを作るほか
- 20230131前編 Evil Martiansが使っているgem、JavaScriptガイドが更新ほか
- 20230125前編 2022年のRails振り返り記事、RailsにDocker関連ファイルが追加ほか
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。



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