- 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 end
Alex 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
::normalize
to::normalize_value_for
by 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のHash
APIの変更に合わせるのがよいと思う。追加情報
このパッチを書いた理由は、#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:prepare
beforebin/rails test
commands 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ウォッチタグ)