- 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 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)
- 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ウォッチタグ)