- Ruby / Rails関連
週刊Railsウォッチ: RailsにHealthControllerが追加、Active RecordのNormalizationほか(20230207前編)
こんにちは、hachi8833です。
🔗Rails: 先週の改修(Rails公式ニュースより)
- 公式更新情報: Ruby on Rails — An endpoint for uptime monitors, an improved help command, etc
- 公式更新情報: Ruby on Rails — Active Record regroup, CurrentAttributes name restrictions and more!
🔗 ActiveSupport::CurrentAttributesで使ってはいけない属性名をraiseするようになった
setやresetなどの属性はCurrentAttributesのpublic APIと衝突するので使ってはいけない。
Alex Ghiculescu
同Changelogより
つっつきボイス:「このリストにある名前はCurrentAttributesで定義済みの属性名と衝突するので使えないようにしたんですって↓」「:set、:reset、:resets、:instance、:before_reset、:after_reset、:reset_all、:clear_allが使っちゃいけない名前なのか」
# activesupport/lib/active_support/current_attributes.rb#L100
def attribute(*names)
+ invalid_attribute_names = names.map(&:to_sym) & [:set, :reset, :resets, :instance, :before_reset, :after_reset, :reset_all, :clear_all]
+ if invalid_attribute_names.any?
+ raise ArgumentError, "Restricted attribute names: #{invalid_attribute_names.join(", ")}"
+ end
参考: Rails API ActiveSupport::CurrentAttributes
「ところでCurrentAttributesは以前議論になったことがありましたね↓」
🔗 Active Recordにregroupメソッドとregroup!メソッドが追加された
動機/背景
現在は、(select、where、reorderのように)それ以前に設定したgroupステートメントを1つのメソッドでオーバーライドする方法がなかった。詳細
regroupメソッドとregroup!メソッドを追加する。後者はgroupメソッドのようにgroup_valuesを配列にappendするのではなく、オーバーライドする。追加情報
Post.group(:title).regroup(:author)は以下のようにも書ける。Post.group(:title).unscope(:group).group(:author)同PRより
つっつきボイス:「従来のgroupに加えてregroupとregroup!も追加された」「特定のgroupをunscopeして再度groupしているのと同じ挙動なんだろうなと思ったら、プルリクメッセージにもそう書かれていた」「reorderでも似たような感じでやっていますよね」「regroupの使いみちはすぐ思いつかないけど、そういうユースケースがあるんでしょうね」
参考: Rails API group -- ActiveRecord::QueryMethods
参考: Rails API reorder -- ActiveRecord::QueryMethods
🔗 assert_emailsで送信済みメールのオブジェクトが返されるようになった
- PR:
assert_emails: return the emails that were sent by ghiculescu · Pull Request #47025 · rails/rails
これによって、メールをより深く分析しやすくなる。
def test_emails_more_thoroughly email = assert_emails 1 do ContactMailer.welcome.deliver_now end assert_email "Hi there", email.subject emails = assert_emails 2 do ContactMailer.welcome.deliver_now ContactMailer.welcome.deliver_later end assert_email "Hi there", emails.first.subject endAlex Ghiculescu
同Changelogより
つっつきボイス:「assert_emailsアサーションが今までtrueを返すかraiseするだけだったのが、成功の場合にメールのオブジェクトを返すようになったんですね: こういうふうに受け取ったオブジェクトをさらにチェックできる↓のはいい👍」
# 同PRより
emails = assert_emails 2 do
post invite_user_path # ...
end
emails.each do |email|
assert_equal "You were invited!", email.subject
end
🔗 ::normalizeが::normalize_value_forにリネームされた
- PR: Rename
::normalizeto::normalize_value_forby jonathanhefner · Pull Request #47034 · rails/rails
::normalizeメソッドは属性名と値を受け取って値を型キャストし、属性に対して宣言された正規化を適用する。型キャストはほとんどの属性で機能するので、指定した値の型が属性の型と大きく異なる可能性がある。このため、::normalizeメソッドが::normalizesメソッドと混同される可能性がある。class Post < ActiveRecord::Base normalize :title, with: -> { _1.titlecase } vend上のコード例では、ユーザーは
::normalizesを呼ぶつもりで::normalizeを呼んでしまっている。この:withキーワード引数はHash値として扱われ、続いてStringに型キャストされるので、正規化が適用されない理由にユーザーが気づけない。こうした混同を防ぐために、このコミットでは
::normalizeを::normalize_value_forにリネームする。
同PRより
つっつきボイス:「normalizesというs付きのメソッドが既にあって紛らわしかったのが修正されたんですね」「なるほど」
「そもそもActive Recordに正規化機能があるって知らなかった: APIドキュメントにあるように" CRUISE-CONTROL@EXAMPLE.COM\n"のような大文字/スペース改行含みの文字列を"cruise-control@example.com"のように正規化するのは便利そう↓」「どんな正規化かと思ったらそういう意味の正規化なんですね」
# activerecord/lib/active_record/normalization.rb#L94
- # User.normalize(:email, " CRUISE-CONTROL@EXAMPLE.COM\n")
+ # User.normalize_value_for(:email, " CRUISE-CONTROL@EXAMPLE.COM\n")
# # => "cruise-control@example.com"
- def normalize(name, value)
+ def normalize_value_for(name, value)
type_for_attribute(name).cast(value)
end
end
「ところで、ActiveRecord::NormalizationはEdge APIにしか入っていませんね」「Railsのmainブランチには入っているけど、7_0_stableブランチにはまだActiveRecord::Normalization自体がない」「じゃまだ使えないのか〜」「Normalizationは昨年12月に#43945で導入されたけど、プルリク自体はその一年前に投げられたものなんですね」
参考: Rails Edge API ActiveRecord::Normalization
「APIドキュメントを見ると、この正規化は属性に代入/更新が行われたタイミングで実行されるとあるので、before_validationよりも早いタイミングですね」「なるほど」「こういう正規化をbefore_validationフックで書くことはよくあるので、この機能が入ったら今後はこっちを使いたい👍」「いいこと知りました」
🔗 rails -hをRailsディレクトリの外で呼んだ場合にも共通コマンドを表示するようになった
動機
Railsアプリケーションでないディレクトリでrails -hコマンドやrailsコマンドを実行すると、rails newコマンドを実行するときの全オプションだけが表示される。これは、ユーザーが(rails new以外の)共通コマンドのヘルプを見たい場合に戸惑う可能性がある。この振る舞いではなく、
rails -hをRailsアプリケーションディレクトリの中で実行した場合でも、Railsアプリケーションディレクトリの外で実行した場合でも、共通のコマンドは常に表示すべき。
詳細
この変更により、ヘルプは以下のように動作するようになる。Railsアプリケーションディレクトリの外:
db:migrateなどの拡張コマンドのヘルプは表示されなくなる(拡張コマンドのヘルプ表示にはRailsアプリケーションが必要で、拡張コマンドのヘルプ表示量は多い)。
rails: gemコマンドのヘルプを表示するrails -h: gemコマンドのヘルプを表示するrails new -h:rails newコマンドのヘルプを表示するRailsアプリケーションディレクトリの中: 従来の動作とほとんど同じ。唯一の違いは、
bin/railsだけを呼んだ場合は共通コマンドのヘルプだけが表示され、拡張コマンドのヘルプが表示されなくなること。
bin/rails: 共通コマンドのヘルプを表示するbin/rails -h: 共通コマンドのヘルプと拡張コマンドのヘルプを表示する追加情報
以前41425を送信したが、このプルリクはstaleしてしまったのでアクセスできなくなった。
修正対象: #40823
同PRより
つっつきボイス:「rails -hコマンドを改良したそうです」
「おさらいすると、gem install railsでgemをインストールしておけば、Railsディレクトリ以外のディレクトリでもrailsコマンドを実行できる」「rails newなんかはまさにそれですね」「で、今まではRailsディレクトリ以外のディレクトリでrails -hを実行するとrails new関連のヘルプしか表示されなかったけど、それ以外のコマンドのヘルプも見られるようにしたということのようですね」「ややこしい...」「自分はこのあたりに特にこだわりはないかな」
🔗 HashWithIndifferentAccess#transform_keysがRubyのHash#transform_keysと同様にHash引数を受け取れるようになった
動機/背景
Active SupportのオリジナルのHash#transform_keysは引数を取らない形で使われていて、このバージョンがRuby 2.5に移植されていた。
ruby/ruby@1405111722その後Ruby 3.0で、Rubyのこのメソッドが拡張されて
hash引数を受け取るようになった。
ruby/ruby@b25e27277dそこで、Railsの
HashライクなクラスであるHashWithIndifferentAccessも、RubyのHashAPIの変更に合わせるのがよいと思う。追加情報
このパッチを書いた理由は、#46821 (comment)で@skipkayhilから指摘をもらったため(@skipkayhilに感謝!)
同PRより
つっつきボイス:「@amatsudaさんによるプルリクです」「HWIAはHashWithIndifferentAccessの略」「あ、それのことでしたか」「Ruby 3でtransform_keysに追加されたhash引数を使う形でRailsのHashWithIndifferentAccessを書き直したらしい」「RailsとRubyがお互いに影響を与えあっているのを実感できますね」
# activesupport/lib/active_support/hash_with_indifferent_access.rb#L340
- def transform_keys(*args, &block)
- return to_enum(:transform_keys) unless block_given?
- dup.tap { |hash| hash.transform_keys!(*args, &block) }
+ NOT_GIVEN = Object.new # :nodoc:
+
+ def transform_keys(hash = NOT_GIVEN, &block)
+ return to_enum(:transform_keys) if NOT_GIVEN.equal?(hash) && !block_given?
+ dup.tap { |h| h.transform_keys!(hash, &block) }
end
参考: Hash#transform_keys (Ruby 3.2 リファレンスマニュアル)
参考: Hash#transform_keys (Ruby 2.7.0 リファレンスマニュアル)
🔗 クラスに委譲する場合のdelegateを高速化した
動機/背景
...引数を用いるメソッド委譲は、呼び出されるたびにArrayやHashのアロケーションが余分に発生するため遅くなることが知られている。
bugs.ruby-lang.org#19165を参照。現在の
delegateの実装では、オリジナルメソッドのarityにかかわらず、この遅い形式ですべての委譲メソッドを定義する。しかし委譲の対象がClassの場合は、定義の時点でオリジナルのメソッドのarityを調べて、適切な最小のarityで委譲を定義できる。詳細
クラスの委譲方法には2つのイディオムがある。1つは
to: ClassNameSuchAsActiveRecord::Baseで、もう1つはto: :class(self.classを意味する)。このパッチでは両方を扱う。謝辞および今後の計画
このアイデアは、別のプルリク#46805 (comment)で@matthewdと@jonathanhefnerからいただいたレビューコメントに基づいている。
このプルリクがマージされたら、delegateをさらに拡張して新たなオプション(おそらくarityとかclass)を追加するつもり。
同PRより
つっつきボイス:「こちらも@amatsudaさんのプルリクです」「arityって引数の個数のことでしたよね」
参考: アリティ - Wikipedia
# activesupport/lib/active_support/core_ext/module/delegation.rb#200
...
# Attribute writer methods only accept one argument. Makes sure []=
# methods still accept two arguments.
- definition = /[^\]]=\z/.match?(method) ? "arg" : "..."
+ definition = \
+ if /[^\]]=\z/.match?(method)
+ "arg"
+ else
+ method_object =
+ begin
+ if to.is_a?(Module)
+ to.method(method)
+ elsif receiver == "self.class"
+ method(method)
+ end
+ rescue NameError
+ # Do nothing. Fall back to `"..."`
+ end
+
+ if method_object
+ parameters = method_object.parameters
+
+ if (parameters.map(&:first) & [:opt, :rest, :keyreq, :key, :keyrest]).any?
+ "..."
+ else
+ defn = parameters.filter_map { |type, arg| arg if type == :req }
+ defn << "&block" if parameters.last&.first == :block
+ defn.join(", ")
+ end
+ else
+ "..."
+ end
+ end
...
「delegateの定義を上のように改修してから、個別のdelegate呼び出しをincluded経由で使う形に書き換えているのか↓」
# actioncable/lib/action_cable/channel/broadcasting.rb#L8
module Broadcasting
extend ActiveSupport::Concern
- delegate :broadcasting_for, :broadcast_to, to: :class
+ included do
+ delegate :broadcasting_for, :broadcast_to, to: :class
+ end
参考: Module#included (Ruby 3.2 リファレンスマニュアル)
「delegateはRailsのあちこちで使われているはずだから、高速化がよく効きそう👍」「3倍以上速くなってるのすごい」
# 同PRより
Warming up --------------------------------------
old 811.534k i/100ms
new 1.807M i/100ms
Calculating -------------------------------------
old 9.809M (± 3.4%) i/s - 49.504M in 5.053355s
new 34.360M (± 0.8%) i/s - 173.465M in 5.048692s
Comparison:
new: 34360408.4 i/s
old: 9809157.4 i/s - 3.50x (± 0.00) slower
🔗 RailsにデフォルトでHealthControllerが追加されるようになる
- 新規に生成されるアプリケーションで
Rails::HealthController#showを追加して/upにマッピングするロードバランサやアップタイム監視では、アプリが生きているかどうかを調べるための基本的なエンドポイントを必要とする。
これは、多くの場面で有効な出発点となる。
DHH
同Changelogより
つっつきボイス:「HealthControllerはBPS社内Slackでも話題になってましたね」「今後はAPIを作るときにHealthControllerを追加するプルリクをわざわざ投げなくてもよくなるんですか?」「そういうことですね」「HealthControllerは作り忘れがち」
その後ドキュメントも追加されていました↓
- PR: [#46936] Add documentation for Rails::HealthController by zzak · Pull Request #46972 · rails/rails
🔗 bin/rails testの実行前にtest:prepareを実行するようになった
- PR: Run
test:preparebeforebin/rails testcommands by ghiculescu · Pull Request #46664 · rails/rails
最初に背景をいくつか。現在、Railsでテストを実行する方法は2通りある。
bin/rake testこれはtesting.rakeのrakeタスクのひとつを実行し、それが
Rails::TestUnit::Runner.rake_runを呼び出してrails testにsystem呼び出しを行う(ここ)。しかしこれはrakeタスクなので、他のタスクで拡張することも可能。
testing.rakeのほとんどのタスクはtest:prepareで拡張される。これはプレースホルダタスクであり、他のものからフックされる。
たとえば、cssbundling-railsはこのタスクを拡張して、テスト実行時にCSSファイルをビルドする。
これは必要である。さもないと、stylesheet_link_tag "tailwind"などがCI環境でクラッシュする(アセットが存在しないため)。
bin/rails testこれは
Rails::Command::TestCommandを実行する。実際にはrunメソッドでRails::TestUnit::Runnerを起動する。上述のように、
bin/rake testを実行すると、間接的にbin/rails testも呼び出すことになる。しかし
bin/rails testはbin/rake testを呼び出さないので、rake test:prepareはまったく実行されない。問題
Rails テスティングガイドには、bin/rails testを実行すべきとかなりはっきり書かれている。しかし上述したように、これは
test:prepareに依存するものがある場合にうまくいかない。代わりに別のコマンドを実行してtest環境を適切にセットアップしなければならない。development環境では(開発中にCSSがコンパイルされることもあるので)運良くうまくいく場合もあるだろうが、CIやproduction環境ではそうはいかない。
これは、rails/tailwindcss-rails#230やrails/tailwindcss-rails#134などの手順で混乱することになり、1個のコマンドだけで準備を整えてすべてのテストを実行することがほんの少しやりにくくなる。
同PRより
つっつきボイス:「bin/のrake testとrails testで挙動が違ってたということかな」「今はbin/railsの方が正式なんですよね」「bin/rails testを実行したときにrake test:prepareを呼び出すようになったらしい」「プルリクメッセージが長いけど、後半にある表がわかりやすい↓」
解決方法
@dhhがここで言及しているように、test:prepareを実行する場合を増やせばよいはずである。このプルリクは以下を実装している。
bin/rake testを実行するとtest:prepareを実行する。
従来は実行されず、bin/rake test:allなどのサブコマンドでのみ実行されていた。bin/rails testを実行するとtest:prepareを実行する。
これはsystemがrakeコマンドを呼び出すようにすることで行われる。基本的には、bin/rake testでsystemがrailsコマンドを呼び出すのと逆の形になる。上の動作は、コマンドに引数を渡さずに呼び出した場合の動作である。
特定のテストについてファイルパスを渡す場合は以下のようになる。
bin/rake test test/models/foo_test.rbは、test:prepareを実行し、すべてのテストを実行する。
これはプルリク前の動作である。rakeタスクはこのような引数渡しをサポートしていないので、常に全テストスイートが実行される。bin/rails test test/models/foo_test.rbは、test:prepareを実行し、foo_test.rbのみを実行する。
これはプルリク前の振る舞いと同じである。まとめるとこうなる。
プルリク前の動作:プルリク後
すなわち、"
bin/rails testを実行するだけでよい"がCIでも通用するようになる👍
同PRより
「テストファイルのパスを渡した場合の動作と渡さない動作が、bin/railsとbin/rakeで違ってたのか」「一般には、ファイルパスを渡さない場合はテストを全件実行する方が自然でしょうね」
🔗 Node.jsを使うRailsアプリに.node-versionファイルを追加してDockerfileで参照するようになった
動機/背景
このプルリクが作成されたのは、ローカルでテストされたアプリケーションと異なるNodeバージョンをデプロイするとうまく動かない(またはまったく動かない)可能性があるため。詳細
このプルリクは、ローカルでインストール済みであるバージョンのNodeやYarnをインストールDockerfileを生成するよう変更する。また、以下も行う。
- libvipsのインストールをNodeから切り離す
- Yarnを実行するとnode_modulesのインストールを追加する
追加情報
このプルリクは、fly.ioが生成したDockerfileから、Railsにとって一般的な関心のある機能を追加する多くのプルリクのひとつである。全体像について詳しくは以下を参照。
Preparations for Rails 7.1 - General - Fly.io今後のプルリクを改善できるよう、変更を段階的に取り入れて早い段階でフィードバックを得るようにしている。
この変更は、esbuildを利用する新規アプリケーションで生成してデプロイする形でテストされた。
同PRより
つっつきボイス:「この間Railsに公式に追加されたDockerサポート(ウォッチ20230125)に改修が入りました」「修正前はDockerfileのNode.jsバージョンが19で固定されていたけど、Dockerfileから.node-versionファイルのNodeバージョンを参照するようになったんですね👍」
# railties/lib/rails/generators/rails/app/templates/Dockerfile.tt#L3
FROM ruby:$RUBY_VERSION
<% if using_node? -%>
-# Install JavaScript dependencies and libvips for Active Storage
-ARG NODE_MAJOR_VERSION=19
-RUN curl -sL https://deb.nodesource.com/setup_$NODE_MAJOR_VERSION.x | bash -
-RUN apt-get update -qq && \
- apt-get install -y build-essential libvips nodejs && \
- apt-get clean && \
- rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man && \
- npm install -g yarn
+# Install JavaScript dependencies
+ARG NODE_VERSION=<%= dockerfile_node_version %>
+ARG YARN_VERSION=<%= dockerfile_yarn_version %>
+ENV VOLTA_HOME="/root/.volta" \
+ PATH="$VOLTA_HOME/bin:/usr/local/bin:$PATH"
+RUN curl https://get.volta.sh | bash && \
+ volta install node@$NODE_VERSION yarn@$YARN_VERSION
前編は以上です。
バックナンバー(2023年度第1四半期)
週刊Railsウォッチ: ShopifyのYJIT記事、RubyGemsのgem execコマンドほか(20230202後編)
- 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ウォッチタグ)