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

週刊Railsウォッチ: MinitestとRSpecの比較、商用版NGINXの重要機能がオープンソース化ほか(20220829前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

なお、もう次のが出ていました。

🔗 drop_enumにもenum定義を渡せるようになった

#45735の続き。
このコミット以前は、drop_enumif_exists: trueオプションを受け取れなかったのでマイグレーションをrevertできなかった。
このコミットはその点を修正しつつテストカバレッジも追加する。
また、enum値が指定されていない場合はdrop_enumをrevertするとActiveRecord::IrreversibleMigrationエラーが発生するようにしている。
同PRより


つっつきボイス:「先週追加されたdrop_enumウォッチ20220822)の続きが出ていました」「if_exists: trueを渡しておけばdrop_enumをリバース可能にする、これは欲しいでしょうね」

# activerecord/test/cases/migration/command_recorder_test.rb#435
      def test_invert_create_enum
        drop = @recorder.inverse_of :create_enum, [:color, ["blue", "green"]]
        assert_equal [:drop_enum, [:color, ["blue", "green"]], nil], drop
      end

      def test_invert_drop_enum
        create = @recorder.inverse_of :drop_enum, [:color, ["blue", "green"]]
        assert_equal [:create_enum, [:color, ["blue", "green"]], nil], create
      end

      def test_invert_drop_enum_without_values
        assert_raises(ActiveRecord::IrreversibleMigration) do
          @recorder.inverse_of :drop_enum, [:color]
        end

        assert_raises(ActiveRecord::IrreversibleMigration) do
          @recorder.inverse_of :drop_enum, [:color, if_exists: true]
        end
      end

🔗 Flashミドルウェアが利用できない場合のEtagWithFlashの挙動を修正

修正: #45781
flashがdelegateされているのでリクエストではrespond_to?(:flash)を呼び出す必要がある。
参考: rails/flash.rb at 46c7420ebfd94314cef1606baf044230007bacfe · rails/rails
同PRより


つっつきボイス:「このFlashはいわゆるFlashメッセージのことなんですね」「そうそう」

参考: §5.2 Flash -- Action Controller の概要 - Railsガイド
参考: Rails API ActionDispatch::Flash

「Flashミドルウェアがない場合にEtagWithFlashの挙動がおかしかったのが修正されたらしい↓」「なるほど」「#44195を見ると、RailsをAPIモードにしたときにEtagWithFlashで例外が発生していたようですね」「あ〜」「実はRailsのAPIモードでは、普段使っている機能がデフォルトでいくつか無効にされるんですよ: セッション関連のミドルウェアが使えなくて困ったことがあったのを思い出した」

# actionpack/lib/action_controller/metal/etag_with_flash.rb#L12
  module EtagWithFlash
    extend ActiveSupport::Concern
    include ActionController::ConditionalGet

    included do
-     etag { flash unless flash.empty? }
+     etag { flash if request.respond_to?(:flash) && !flash.empty? }
    end
  end

参考 Fix exception on EtagWithFlash when api_only by mihaic195 · Pull Request #44195 · rails/rails
参考: § 4.4 セッションミドルウェアを利用する -- Rails による API 専用アプリケーション - Railsガイド

🔗 assert_enqueued_email_withを改善

概要
assert_enqueued_email_withは、パラメータと引数を両方渡されてキューに入れられたメーラーのテストをサポートしていない。このアサーションは、パラメータ化メーラーにマッチするパラメータ(パラメータ引数がハッシュの場合)か、メーラーにマッチする引数(パラメータ引数がハッシュでない場合)のどちらかを前提としている。
さしあたって修正すべきissue
現在は、キューに入ったメール・メッセージはassert_enqueued_email_with: UserMailer.with(user: @user).deliver_invoice(invoice)とマッチできない。
このプルリクは、paramsという名前付き引数をassert_enqueued_email_withに追加することで、パラメータ化メーラーにマッチするパラメータを明示的に提供できるようにする。

assert_enqueued_email_with UserMailer, :deliver_invoice, params: { user: @user }, args: [invoice]

また、このプルリクは、パラメータ化メーラーをアサーションに直接渡すことでパラメータ化メーラーをテスト可能にする機能も導入する。これはテストされる実際のコードに最も近いアサーションを美しく作成できる方法。

assert_enqueued_email_with UserMailer.with(user: @user), :deliver_invoice, args: [invoice]

その他
このプルリクは既存のコードを破壊しないが、今後どこかの時点で、ハッシュをargs引数として渡すとparamsを明示的に使用するよう通知する非推奨化警告を導入してもよいだろう。
同PRより


つっつきボイス:「Action Mailerのアサーションの修正だそうです」「名前付き引数paramsを追加して、パラメータと引数を両方アサーションに渡せるようにしたらしい」「自分はレンダリングしたメールでbodyの内容をチェックすることが多かったけど、このアサーションを使うとパラメータを渡す時点でチェックできるということですね」

参考: Rails API assert_enqueued_email_with -- ActionMailer::TestHelper

🔗 メーラーのプレビューパスを複数指定可能になる

config.action_mailer.preview_pathオプションは非推奨化され、config.action_mailer. preview_pathsが推奨される。このコンフィグにパスを追加すると、メーラーのプレビューを探索するパスとして使われるようになる。
fatkodima
同Changelogより


つっつきボイス:「preview_pathが複数形のpreview_pathsに変わってマルチプルプレビューをサポートするそうです」「preview_pathってどの機能かなと思ったら、メールをHTMLでプレビューする機能のことなんですね: 普段letter_opener gemでやっているのでAction Mailerのこの機能は使ってなかった」

# actionmailer/lib/action_mailer/preview.rb#L144
      private
        def load_previews
-         if preview_path
+         preview_paths.each do |preview_path|
            Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require_dependency file }
          end
        end

-       def preview_path
-         Base.preview_path
+       def preview_paths
+         Base.preview_paths
        end

参考: Action Mailerのpreview機能を使って、Railsアプリケーションから送るメールを一覧するページを作った - pockestrap

ryanb/letter_opener - GitHub

参考: 【Rails】開発中に送ったメールを確認する(Gem: letter_opener) - おぴよの気まぐれ日記

🔗 ルーティングで発生するリダイレクトのログ出力を修正

;* PR: Log redirects from router similarly to controller redirects by djfpaagman · Pull Request #43755 · rails/rails

メモ: これについてdiscuss.rubyonrails.orgのフォーラムに(ガイドラインに沿って)投稿したが特に返信がなかったので、このプルリクをオープンした。必要なら自由にクローズしてもらって構わない。
概要
ルーティングで直接発生するリダイレクトにログ出力を追加する変更を提案したい。

現在は、ルーティングでリダイレクトが発生したときに、実際にリダイレクトが発生したかどうかログを見てもはっきりしない。コントローラで発生するリダイレクトのログ出力はルーティングの場合と異なるので、リダイレクトがどこで発生したのかを突き止めようとして時間を吸われてしまったことがある。
たとえば以下のルーティングがあるとする。

get :moo, to: "rails/welcome#index"
get :redirect, to: "regular#redirect"
root to: redirect("moo")

直接リダイレクトするルーティングにリクエストを送信すると、ログには最初のGETとリダイレクト先ページのGETしか出力されない。

Started GET "/" for 127.0.0.1 at 2021-10-21 13:09:51 +0200
Started GET "/moo" for 127.0.0.1 at 2021-10-21 13:09:51 +0200
Processing by Rails::WelcomeController#index as /
...

しかしコントローラでのリダイレクトではずっと詳しい情報が生成される。

Started GET "/redirect" for 127.0.0.1 at 2021-10-21 13:11:33 +0200
Processing by RegularController#redirect as /
Redirected to http://localhost:3000/moo
Completed 302 Found in 0ms (ActiveRecord: 0.0ms | Allocations: 414)

Started GET "/moo" for 127.0.0.1 at 2021-10-21 13:11:33 +0200
Processing by Rails::WelcomeController#index as /
...

そこで、ActionDispatch::Routing::Redirectにいくつかのinstrumentationを追加して、ルーティングで発生するリダイレクトでもこれと同様のログを出力するようにした。

この変更によって、ルーティングでのリダイレクトで以下のようなログが出力されるようになる。

Started GET "/" for 127.0.0.1 at 2021-10-21 13:14:49 +0200
Redirected to http://localhost:3000/moo
Completed 301 Moved Permanently in 0ms

Started GET "/moo" for 127.0.0.1 at 2021-10-21 13:14:49 +0200
Processing by Rails::WelcomeController#index as /
...

皆さんの考えを聞かせてもらえると嬉しい。意見や改良点を歓迎する。
同PRより


つっつきボイス:「お〜、routes.rbにこういうふうにリダイレクトを書けるのか↓」「やったことない書き方」

# 同PRより
get :moo, to: "rails/welcome#index"
get :redirect, to: "regular#redirect"
root to: redirect("moo")

「routes.rbでのリダイレクトとコントローラでのリダイレクトで、ログ出力の情報が違っていたらしい」「コントローラのリダイレクトだと、以下のように最初のGETでhttp://localhost:3000/mooにリダイレクトされたことがログでわかるけど、ルーティングでリダイレクトするとそういう情報がログに出てなかったんですね↓」

# 同PRより: コントローラでのリダイレクト
Started GET "/redirect" for 127.0.0.1 at 2021-10-21 13:11:33 +0200
Processing by RegularController#redirect as /
Redirected to http://localhost:3000/moo
Completed 302 Found in 0ms (ActiveRecord: 0.0ms | Allocations: 414)

Started GET "/moo" for 127.0.0.1 at 2021-10-21 13:11:33 +0200
Processing by Rails::WelcomeController#index as /
...

「修正後のログを見ると、ルーティングでのリダイレクトが301 Moved Permanentlyになっている: これが今までログに出てなかったということらしい↓」

# 同PRより: 修正後のルーティングリダイレクト
Started GET "/" for 127.0.0.1 at 2021-10-21 13:14:49 +0200
Redirected to http://localhost:3000/moo
Completed 301 Moved Permanently in 0ms

Started GET "/moo" for 127.0.0.1 at 2021-10-21 13:14:49 +0200
Processing by Rails::WelcomeController#index as /
...

参考: 301 Moved Permanently - HTTP | MDN

「Railsが301 Moved Permanentlyを実際に返していたのにログに出力されていなかったのは、個人的には従来の振る舞いはバグと言ってもよい気がします」「そうですね」「ログがStarted行で始まっているのにCompleted行で終わっていないと、ログが壊れているかと思うし、ログのパーサーを書くときなんかに困りそう」

🔗 touchupdate_columnupdate_columnsがreadonlyレコードでエラーを出すよう修正

touchメソッド、update_columnメソッド、update_columnsメソッドは、レコードがreadonlyの場合にエラーを出していなかった。
修正: #44839
同PRより


つっつきボイス:「このプルリクは少し前のもので、以下の記事で知りました↓」「たしかにreadonlyのレコードでtouchとかを実行したらraiseしないとおかしいですね」

参考: ActiveRecord methods touch and update_columns no longer work for readonly models | Saeloun Blog

「readonlyレコードならupdate_columnupdate_columnsでエラーが出なくても更新されることはないだろうけど、エラーなしでtouchできちゃってたとしたら良くない」「#44839を見ると、touchでreadonlyレコードのタイムスタンプが更新されていたらしいですね」「怖い」「touchを暗黙で使っているgemなどがあるかもしれないので気をつけておくといいかも」

参考: touch -- ActiveRecord::Persistence

🔗Rails

🔗 属性がネストしている場合のバルクINSERT(Ruby Weeklyより)


つっつきボイス:「これは面倒になりがちなヤツ」「nested attributesのバルクINSERTは基本的にやりたくないですね」「こういう場合はbelongs_to先のレコードに親のidを入れないといけないんですが、親のidはINSERTするまで決まらないという問題がある」「ちゃんと書かないとすぐ激重になりますよね」

「この記事とは別の解決方法になりますけど、UUIDが利用可能ならそれを使ってidを事前に決め打ちするという力技な方法も一応考えられます」「あ〜、たしかにUUIDが使えるならDB問い合わせ前にidを確定できますね」「あまり褒められた方法ではありませんし、使わずに済むならそうしたいですけどね」「最後の手段ぐらいに思っておく方がよさそう」「でもシステムの外部でUUIDが発行されるなら原理的にはありかも」

参考: UUID - Wikipedia

🔗 validates_email_format_of: RFC 2822/5322に沿ったメールバリデーション(Ruby Weeklyより)

validates-email-format-of/validates_email_format_of - GitHub


つっつきボイス:「メールのバリデータだそうです」「validates_email_format_of、使ったことがあるかも」「リポジトリを見るとコミットも盛んで歴史もありそうですね」

check_mxでMXレコードをチェックできるのはいいかも: 書式チェックだけなら正規表現などでもできますけど、いたずらやスパムを排除するにはMXレコードでメールアドレスが実在するかどうかもチェックする必要があるので」「MXレコードチェックするとDNSにクエリをかける分遅くなるけどしょうがないですね」

参考: DNS MXレコードとは? | Cloudflare

後で調べると、RFC 2822は既に廃止されていて、RFC 5322は修正版だそうです。

参考: RFC 5322 - Internet Message Format 日本語訳

🔗 NGINXの商用版機能の一部がオープンソース化


つっつきボイス:「エンタープライズ向けNGINX Plusの機能の一部がオープンソースとして使えるようになる、いい話🎉」「記事に"無料のNGINX Open Source版によるSaaSを提供する"とあるけど、SaaSそのものは有料なのかな?」「SaaSは今後もずっと無料とありますね↓」

We are also going to release a new SaaS offering that natively integrates with NGINX Open Source and will help you make it useful and valuable in seconds. There will be no registration, no gate, no paywall. This SaaS will be free to use, forever.
The Future of NGINX: Getting Back to Our Open Source Roots - NGINXより

参考: NGINX Plus|高性能アプリケーション配信システム

「今回の発表の中では、DNSサービスディスカバリ機能がオープンソース版NGINXに入る話↓が個人的にイチオシ👍」「おぉ」「オープンソース版NGINXはずっと前から使っているんですが、この機能がなくてつらかったんですよ」「早く実現されるといいですね」

DNSサービスディスカバリなど有償版の重要な機能をオープンソースに移植し無料化する
同記事より

「この機能がないとどうつらくなるんでしょうか?」「たとえばNGINXをロードバランサーとして使っていて、その配下にRailsサーバーが5台あるとします: NGINXのコンフィグにRailsサーバーをホスト名で書くんですが、オープンソース版のNGINXはRailsサーバーを最初に参照したときのDNS情報をキャッシュしてしまうんですよ」「あ〜」

「AWSのALBはIPアドレスを5個ぐらい返すんですが、キャッシュがあると最初の1個しか使われなくなってしまう」「う〜む」「さらに厄介なことに、ALBはAWS側の都合でときどきIPアドレスが変更されるのに、キャッシュがあると変更に追従できなくなってエラーになったりする」「なるほど」「その点エンタープライズ向けのNGINXは、ちゃんとIPアドレス変更の追従やラウンドロビンなどを行ってくれます」

参考: Application Load Balancer とは? - Elastic Load Balancing

🔗 RailsでMinitestとRSpecのどちらを使うか(Ruby Weeklyより)


つっつきボイス:「HoneyBadgerによるMinitestとRSpecの比較記事です」「自分はMinitestは割と好きなんですが、モデルのテストはRSpecの方がシンプルに書けることも多いですね」「そうそう、Minitestでモデルのテストはつらい」「Request SpecのようなものはMinitestで問題なく書けると思いますけどね」

「RSpecはつい複雑に書いてしまうという問題はありますけど、Minitestはモデルのテストコードが冗長になりやすいけどRSpecだと書きやすくなることを考えると、原則としてRSpecを選ぶのはそれはそれでありだと思います」「アプリが大きく育たないことが事前にわかっていればMinitestで十分でしょうね」

「記事の最後の方で、以下のスライドを引用しつつ"全般にMinitestの方が高速だけど、そうとは限らないぞ"と書かれていますね」「そういうのってテストフレームワークよりもDBの速度やfactoryあたりの方が影響しそうですけど」「たしかに」

その他Rails

つっつきボイス:「ブロックパラメータにデフォルト値付き引数を使う書き方がRuby 3.0以降だと動かないらしい」「x: 0, y: 0のような書き方ですね」「ブロック引数でこの書き方をうまく扱えたためしがなかったような気がします」

# 同記事より
def sample
  data = {
    a: nil,
    b: { x: 1, y: 2 },
    c: { x: 1 },
    d: { y: 2 },
  }
  data.map do |k, x: 0, y: 0|
    [k, x, y]
  end
end

「記事ではブロック引数をhで受けて内部で展開することでシグネチャを変えずに修正しているんですね↓」「ブロック引数だといつもの**が使えないのか〜」「この書き方を多用している人は大変そう」「Railsのスコープあたりで使いそうな書き方だけど、x: 1よりもx=1みたいな書き方にする方が多い気はしますね」

# 同記事より
 def sample
   data = {
     a: nil,
     b: { x: 1, y: 2 },
     c: { x: 1 },
     d: { y: 2 },
   }
-  data.map do |k, x: 0, y: 0|
+  data.map do |k, h|
+    x, y = { x: 0, y: 0 }.merge(h || {}).values_at(:x, :y)
     [k, x, y]
   end
 end

前編は以上です。

バックナンバー(2022年度第3四半期)

週刊Railsウォッチ: byebugからruby/debugへの移行ガイド、YJIT解説記事ほか(20220823後編)

今週の主なニュースソース

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

Rails公式ニュース

Ruby Weekly


CONTACT

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