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

週刊Railsウォッチ: Railsの必須Rubyバージョンが3.1.0以上に変更ほか(20240123前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 url_helpersモジュールが別のconcernでincludeされるとエラーになる問題を修正

#46530の続き。

動的に生成されるurl_helpersモジュールはActiveSupport::Concernである。したがって、それが別のActiveSupport::Concernincludeされると、そのincludedブロックは後者のconcern自体が別の場所でincludeされるまで延期される。この場合、def self.included(base)includedブロックがまだ_routesメソッドを定義していないのでbase._routes呼び出しがraiseされる。

このコミットは、最初にbase_routesに応答するかどうかをチェックすることでエラーを防ぐ。
同PRより


つっつきボイス:「concernの使われ方によってdefer(先延ばし)されたurl_helpersモジュールがエラーになっていたのはバグ」「base.respond_to?(:_routes)チェックを追加して修正したんですね↓」

# actionpack/lib/action_dispatch/routing/route_set.rb#L605
          def self.included(base)
            super
-           if !base._routes.equal?(@_proxy._routes)
+           if base.respond_to?(:_routes) && !base._routes.equal?(@_proxy._routes)
              @dup_for_reinclude ||= self.dup
              base.include @dup_for_reinclude
            end
          end

🔗 assert_queries_countassert_no_queriesassert_queries_matchassert_no_queries_matchアサーションを公開

#50281の続き。

最初に導入(公開)されたヘルパーには、以下のような小さい制約がいくつかあった。

  1. 期待するクエリ数を正確に指定しなければならない
  2. アプリケーション関連のクエリしか考慮されず、スキーマやトランザクション関連のクエリは常にスキップされる
  3. マッチャーは1つしか指定できない

これらの制約は通常のユーザーにとってはほとんど問題にならないが、より低レイヤのテストで困ることがある(ある種のgem内部のテストなど)。

たとえば、ある操作を実行したときにインデックスや外部キーが作成されるかどうかをテストできなかった。
修正後は以下のようにテスト可能になる。

assert_queries_match(/create index/i, include_schema: true) do
  do_something
end

同PRより


つっつきボイス:「先週Arelで改修されたassert_queriesassert_no_queriesの続きです(ウォッチ20240117)」「アプリケーションの純粋なクエリ件数しか取れなかったりしたのをスキーマのクエリなども取れるようにするなどして改善したのか」「assert_queries_matchassert_no_queries_matchも追加されたんですね」「assert_queriesとかだとクエリの何をアサーションするのかわかりにくかったけど、assert_queries_countとかの方がネーミングとしてもたしかにいい👍」

先週のChangelogも更新されました↓。

以下のアサーションをpublicにした:

  • assert_queries_count
  • assert_no_queries
  • assert_queries_match
  • assert_no_queries_match

期待される件数のクエリが作成されるというアサーションのため、Rails 内部で assert_queries_countassert_no_queriesが使われている。特定のSQLクエリが作成されたというアサーションには、assert_queries_matchassert_no_queries_matchが使われている。これらのアサーションがアプリケーションでも利用可能になった。

class ArticleTest < ActiveSupport::TestCase
  test "queries are made" do
    assert_queries_count(1) { Article.first }
  end

  test "creates a foreign key" do
    assert_queries_match(/ADD FOREIGN KEY/i, include_schema: true) do
      @connection.add_foreign_key(:comments, :posts)
    end
  end
end

Petrik de Heus, fatkodima

同Changelogより

🔗 バグレポートのgem版テンプレートを廃止した

コントリビュータが、あるバージョンをテストするために1行変更する場合、必ずしもレポートの種類ごとにファイルが1つずつ必要になるわけではない。

gemバージョンを維持することについての議論で自分が唯一理解できるのは、ユーザーがRailsの特定のバージョンのバグを報告するときは、たいていアップグレード中であるということだ。Railsのmainブランチでアプリケーションをテストするユーザーはほとんどいないだろう。

さらに、gemバージョンのテンプレートがあればCIでmainとstableの両方についてRailsをテストすることになるので、それなりのメリットは一応ある。

しかし私見では、レポートファイルを分けることのコストと、テンプレートをさらに増やすことで生じる混乱の方が、そのメリットを上回ると思う。

(追加のアダプタなどの)レポートのテンプレートを増やしたいというのが自分の動機だが、gemバージョンのテンプレートも維持するとなると、そのリストが肥大化して管理不能になる。

/cc @yahonda @skipkayhil(これについて議論したことがあったと思うので)
同PRより


つっつきボイス:「Railsにバグを報告するときのテンプレートはこれまでgem版(stable)とmain版の2種類だったんですが↓、gem版のテンプレートを廃止したそうです」「stableの方はRailsがアップデートされるたびにテンプレート内のgemのバージョンを更新しないといけなくなるので、テンプレートのメンテの手間を減らすためにテンプレートをmain版に一本化したということのようですね」「この改修を前提に次の#49986の改修が行われていました」


1.1 Creating a Bug Report -- Rails Guides 7.0より

🔗 Action View用のバグレポートテンプレートをサンプルテスト付きで追加

動機/背景

コントリビュータ向けにAction Viewのバグレポートテンプレートを導入し、ActionView::TestCaseインスタンスの失敗に関するissueを再現できるようにするため。

詳細

inline:キーワードでERBをレンダリングする機能に加えて、サンプルテストにHelpersモジュールを含めておくことでビューヘルパーを再現スクリプトに組み込む方法を示すようにする。
同PRより


つっつきボイス:「上の#50317の続きです」「Action Viewの特定機能を単体で動かして再現するバグレポート用のテンプレートが新たに追加されたんですね: Action Viewを単体で動かすようなコードは普段書かないので、こういうサンプルコード付きのテンプレート↓があると問題を再現しやすくて助かる👍」

# guides/bug_report_templates/action_view.rb
# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "rails"
  # If you want to test against edge Rails replace the previous line with this:
  # gem "rails", github: "rails/rails", branch: "main"
end

require "minitest/autorun"
require "action_view"

class BugTest < ActionView::TestCase
  helper do
    def upcase(value)
      value.upcase
    end
  end

  def test_stuff
    render inline: <<~ERB, locals: { key: "value" }
      <p><%= upcase(key) %></p>
    ERB

    element = rendered.html.at("p")

    assert_equal element.text, "VALUE"
  end
end

🔗 Active Storageでwebpとavifをインライン配信できるようになった

# activestorage/lib/active_storage/engine.rb#L67
    config.active_storage.content_types_allowed_inline = %w(
+     image/webp
+     image/avif
      image/png
      image/gif
      image/jpeg
      image/tiff
      image/bmp
      image/vnd.adobe.photoshop
      image/vnd.microsoft.icon
      application/pdf
    )

つっつきボイス:「Active Storageで配信できるフォーマットが追加されたというシンプルな改修ですね: このactive_storage.content_types_allowed_inlineはブラウザでファイルのURLに直接アクセスしたときに(1)ブラウザでそのまま開く(インライン配信)か、(2)ダウンロードダイアログを開くかをContent-Dispositionヘッダで制御しているのですが、その処理対象のContent-Typeを指定する設定のようですね」「なるほど」「今回はDHHによるコミットが多いんですが、この改修もこの後のDHHによる#50505と関連しているようです」

参考: Content-Disposition - HTTP | MDN
参考: WebP - Wikipedia
参考: AV1 Image File Format(AVIF) -- AV1 - Wikipedia


webpやavifについては以下のシリーズ記事もどうぞ↓。

保存版: Web画像フォーマットを「正しく」扱う(1)ピクセルとDPRを完全理解する(翻訳)

🔗 非推奨化された古いRails UJSのコードを削除

公式パッケージは引き続き@rails/ujsにあり、アセットパイプラインの最終コンパイル済みターゲットにも残される。しかしそれ以外のものはすべて消えるべき。

同PRより


つっつきボイス:「Rails UJSでもう消せるものはさすがに消してもいいだろうという判断が下ったんですね」「time to dieか〜」「@rails/ujsは引き続きメンテされるので大丈夫だと思います👍」

🔗 PWA用のマニフェストファイルとサービスワーカーファイルをデフォルトで追加する

app/views/pwaから配信され、ERBで動的にレンダリング可能なマニフェストとサービスワーカー用のデフォルトPWAファイルを追加する。生成されるルーティングファイル内のデフォルトルーティングを用いて、これらのファイルを明示的にrootにマウントする。

DHH
同Changelogより


つっつきボイス:「PWA(Progressive Web App)といえば、Webアプリをスマホで表示したときにホーム画面にアプリアイコンを追加するのに使いますよね」「ストアに登録しなくてもホーム画面に追加できるヤツですね」「マーケティング方面でPWAがもてはやされてましたね」「PWA関連のファイルをサポートするのは別に構わないんですが、デフォルトで有効にするとPWAの存在に気づかないまま導入されてしまいそうな気がする」「あ〜言われてみればRailsのデフォルトアイコンとかがそのままPWAで使われたりしそう」「デフォルトではPWAはオフにしておいて、オプションで追加できる形でサポートする方がいいのかもしれないと思いました」

参考: Progressive web app - Wikipedia -- PWA

🔗 GitHub CI向けの設定ファイルをデフォルトで追加するようになった

dependabot、brakeman、rubocop、およびテスト実行用のGitHub CIファイルを追加する。--skip-ciを指定することでスキップ可能。

DHH
同Changelogより

rubocopとbrakemanがRailsのデフォルトになったので、新しいアプリケーションにデフォルトのGitHub CIワークフローファイルを追加するのが合理的だ。これにより、特に初心者がスキャンやlintやテストの自動化を始めやすくなる。これは、私たちが単体テストを行うようになって以来やってきたことを自然な形で現代でも継続するものである。

そういうわけで、よいデフォルト設定を用いて.github/workflows/ci.yml.github/dependabot.ymlを追加する。

これは--skip-ciでスキップ可能。

  • [x] アプリケーションがMySQLまたはPostgreSQL用の場合はデータベースサービスをセットアップする

同PRより


つっつきボイス:「先週取り上げたRubocop設定やbrakeman設定(ウォッチ20240117)に加えて、GitHub CIワークフローファイルもデフォルトで追加されるのは地味にありがたい」「CIのyamlをどこかからコピーしたりしなくて済みますね」「データベースに応じて設定ファイルをif文で変えるようになっている↓: 個人的にはそこまでしなくてもプロジェクトで普通にカスタマイズすればいい気はするけど、Railsが初めての人向けのサポートとしてよさそう👍」

# railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt
    <%- if options[:database] == "sqlite3" -%>
    # services:
    #  redis:
    #    image: redis
    #    ports:
    #      - 6379:6379
    #    options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
    <%- else -%>
    services:
      <%- if options[:database] == "mysql" || options[:database] == "trilogy" -%>
      mysql:
        image: mysql
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: true
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
      <%- elsif options[:database] == "postgresql" -%>
      postgres:
        image: postgres
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
      <%- end -%>

🔗 アプリでの利用を許可するブラウザの最小バージョンをallow_browserで指定できるようになった

すべてのアクションへのアクセスを許可する(またはonly:except:で何らかの制約をかける)ブラウザのバージョンを指定する。

指定したバージョンを下回る場合、versions:に渡されたハッシュまたは名前付きセットで一致するブラウザだけがブロックされる。

つまり、それ以外のすべてのブラウザと、User-Agentヘッダーを報告していないエージェントはアクセスを許可されることになる。

ブロックされたブラウザには、デフォルトでpublic/426.htmlのファイルが配信され、HTTPステータス"426 Upgrade Required"となる。

名前のあるブラウザバージョンに加えて、セットになっている:modernを渡すことで、WebP画像、Webプッシュ、バッジ、importmap、CSSネスティング、CSS :hasをネイティブにサポートするブラウザのみにサポートを制限することも可能。これにはSafari 17.2以降、Chrome 119以降、Firefox 121以降、Opera 104以降が含まれる。

利用する機能をサポートするブラウザのバージョンはhttps://caniuse.comで確認可能。

ActiveSupport::Notificationsを使うことで、ブロックされているブラウザのイベントをbrowser_block.action_controllerというイベント名でサブスクライブできる。

例:

class ApplicationController < ActionController::Base
  # Allow only browsers natively supporting webp images, web push, badges, import maps, CSS nesting + :has
  allow_browser versions: :modern
end

class ApplicationController < ActionController::Base
  # All versions of Chrome and Opera will be allowed, but no versions of "internet explorer" (ie). Safari needs to be 16.4+ and Firefox 121+.
  allow_browser versions: { safari: 16.4, firefox: 121, ie: false }
end

class MessagesController < ApplicationController
  # In addition to the browsers blocked by ApplicationController, also block Opera below 104 and Chrome below 119 for the show action.
  allow_browser versions: { opera: 104, chrome: 119 }, only: :show
end

同PRより


つっつきボイス:「ユーザーがモダンでないブラウザをアプリで使うとブラウザのアップデートをユーザーに促す機能が入ったそうです」「WebPやimportmapあたりをサポートしないブラウザはモダンではないということになるのね」「HTTP 426 Upgrade Requiredというレスポンスを返せるとは知らなかった」「こういう機能はあっていいと思います👍」

参考: 426 Upgrade Required - HTTP | MDN

🔗 Railsの必須Rubyバージョンが3.1.0以上に変更

従来のRailsでは、新しいメジャーなRubyバージョンのうち古いRubyとの互換性を削除していただけだったが、このポリシーの変更を提案する。今のままでは、EOLになって久しいRubyとの互換性を残すか、さもなければ複数の古いRubyバージョンをまとめて削除するためにRailのメジャーバージョンアップをもっと頻繁に行うことになる。

私見では、現状のインセンティブがうまく調整されていないと思える。新しいマイナーバージョンがEOL(3年経過)になるたびにサポートを打ち切る方がずっといいのではないか。

Rubyはアップストリームへの依存なので、セマンティックバージョニング違反にすらならない。

Rails 7.2は数か月前には計画されていなかったので、今年3月にEOLとなるRuby 3.0は既に削除可能。

FYI: @matthewd(これに関する意見があったと思うので)
同PRより


つっつきボイス:「言われてみれば、これまではRailsのメジャーバージョンがアップデートするタイミングでRubyの必須バージョンをアップデートしていましたね」「従来のアップデートタイミングはシンプルな分わかりやすいと言えばわかりやすいけど、必要ならそれ以外のタイミングでもRubyの必須バージョンをもっと頻繁にアップデートするのはありなんじゃないかな」「ところでRails本体の必須Rubyバージョンを上げるのは大丈夫だと思いますけど、Railsで使うサードパーティgemが組み合わせによってはRubyの必須バージョン変更にすぐ対応しきれないことは割りとあったりするんですよね」「たしかに」

🔗 レート制限APIを追加

RedisとKredis limiter typeを利用したレート制限APIを追加する。

class SessionsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create
end

class SignupsController < ApplicationController
  rate_limit to: 1000, within: 10.seconds,
    by: -> { request.domain }, with: -> { redirect_to busy_controller_url, alert: "Too many signups!" }, only: :new
end

Note: レート制限は、アプリケーションにアクセス可能なRedisと、バンドルでKredis 1.7.0以降に依存する。
同PRより


つっつきボイス:「rack-attack gem↓でやっているようなレート制限をredisの機能を使ってRailsでサポートするようになったんですね: こういう機能はそろそろRailsフレームワーク本体でやってもよさそう👍」

rack/rack-attack - GitHub


前編は以上です。

バックナンバー(2024年度第1四半期)

週刊Railsウォッチ: Ruby 3.3でYJITを有効にすべき理由、Turbo 8の注意点8つほか(20240119後編)

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

Rails公式ニュース


CONTACT

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