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

週刊Railsウォッチ: RailsにHealthControllerが追加、Active RecordのNormalizationほか(20230207前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 ActiveSupport::CurrentAttributesで使ってはいけない属性名をraiseするようになった

setresetなどの属性は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は以前議論になったことがありましたね↓」

Railsの`CurrentAttributes`は有害である(翻訳)

🔗 Active Recordにregroupメソッドとregroup!メソッドが追加された

動機/背景
現在は、(selectwherereorderのように)それ以前に設定したgroupステートメントを1つのメソッドでオーバーライドする方法がなかった。

詳細
regroupメソッドとregroup!メソッドを追加する。後者はgroupメソッドのようにgroup_valuesを配列にappendするのではなく、オーバーライドする。

追加情報
Post.group(:title).regroup(:author)は以下のようにも書ける。

Post.group(:title).unscope(:group).group(:author)

同PRより


つっつきボイス:「従来のgroupに加えてregroupregroup!も追加された」「特定のgroupunscopeして再度groupしているのと同じ挙動なんだろうなと思ったら、プルリクメッセージにもそう書かれていた」「reorderでも似たような感じでやっていますよね」「regroupの使いみちはすぐ思いつかないけど、そういうユースケースがあるんでしょうね」

参考: Rails API group -- ActiveRecord::QueryMethods
参考: Rails API reorder -- ActiveRecord::QueryMethods

🔗 assert_emailsで送信済みメールのオブジェクトが返されるようになった

これによって、メールをより深く分析しやすくなる。

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にリネームされた

::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を高速化した

動機/背景
...引数を用いるメソッド委譲は、呼び出されるたびにArrayHashのアロケーションが余分に発生するため遅くなることが知られている。
bugs.ruby-lang.org#19165を参照。

現在のdelegateの実装では、オリジナルメソッドのarityにかかわらず、この遅い形式ですべての委譲メソッドを定義する。しかし委譲の対象がClassの場合は、定義の時点でオリジナルのメソッドのarityを調べて、適切な最小のarityで委譲を定義できる。

詳細

クラスの委譲方法には2つのイディオムがある。1つはto: ClassNameSuchAsActiveRecord::Baseで、もう1つはto: :classself.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は作り忘れがち」

その後ドキュメントも追加されていました↓

🔗 bin/rails testの実行前にtest:prepareを実行するようになった

最初に背景をいくつか。現在、Railsでテストを実行する方法は2通りある。

  • bin/rake test

これはtesting.rakeのrakeタスクのひとつを実行し、それがRails::TestUnit::Runner.rake_runを呼び出してrails testsystem呼び出しを行う(ここ)。

しかしこれは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 testbin/rake testを呼び出さないので、rake test:prepareはまったく実行されない。

問題
Rails テスティングガイドには、bin/rails testを実行すべきとかなりはっきり書かれている。

しかし上述したように、これはtest:prepareに依存するものがある場合にうまくいかない。代わりに別のコマンドを実行してtest環境を適切にセットアップしなければならない。development環境では(開発中にCSSがコンパイルされることもあるので)運良くうまくいく場合もあるだろうが、CIやproduction環境ではそうはいかない。
これは、rails/tailwindcss-rails#230rails/tailwindcss-rails#134などの手順で混乱することになり、1個のコマンドだけで準備を整えてすべてのテストを実行することがほんの少しやりにくくなる。
同PRより


つっつきボイス:「bin/のrake testrails 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を実行する。
    これはsystemrakeコマンドを呼び出すようにすることで行われる。基本的には、bin/rake testsystemrailsコマンドを呼び出すのと逆の形になる。

上の動作は、コマンドに引数を渡さずに呼び出した場合の動作である。
特定のテストについてファイルパスを渡す場合は以下のようになる。

  • 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/railsbin/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後編)

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

Ruby 公式ニュース

Rails公式ニュース

Ruby Weekly

Publickey

publickey_banner_captured


CONTACT

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