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

週刊Railsウォッチ: Rails 8でSprocketsがPropshaftに置き換わる、devcontainerサポートほか(20240228)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 rails newで.devcontainer用ファイルをセットアップするようになった

新規アプリ作成時に.devcontainerフォルダとそのコンテンツを生成するようになった。

この.devcontainerフォルダには、リモートコンテナ内でアプリを起動して開発するのに必要なものがすべて含まれる。

コンテナのセットアップには以下が含まれる。

  • redisコンテナ(KredisやAction Cableなど向け)
  • データベース(SQLite、PostgreSQL、MySQL、MariaDBのいずれか)
  • ヘッドレスChromeコンテナ(システムテスト用)
  • Active Storage(ローカルディスク利用設定済み、プレビュー機能も動く)

アプリのセットアップでこれらのオプションのいずれかをスキップすると、コンテナのコンフィグに含まれなくなる。

--skip-devcontainerオプションを指定することでファイルをスキップできる。

Andrew Novoselac & Rafael Mendonça França


システムテストのアプリケーションサーバー設定用のSystemTestCase#served_byを導入

デフォルトではlocalhostになる。このメソッドを使うことでhostportを手動設定可能になる。

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  served_by host: "testserver", port: 45678
end

Andrew Novoselac & Rafael Mendonça França
同Changelogより


つっつきボイス:「このプルリクを見るまでdevcontainerを知りませんでした」「devcontainerはVSCodeでDockerコンテナ内での開発を行うときのセットアップを行うものとして少し前に登場していましたね: VSCode以外にもJetBrains IDEでも使えますし、同じマイクロソフトがやっていることもあってGitHub Codespacesでも公式にサポートされていますね」「なるほど」「devcontainerはフロントエンド系でよく使われている印象があるかな: VSCodeでdevcontainerの開発用コンテナを立ち上げるとリモートから接続できたりと、VSCodeとのインテグレーションが進んでいるようです」

参考: Devcontainer ってなに? 実際につかってみる
参考: 開発コンテナーの概要 - GitHub Docs
参考: Dev Containers | IntelliJ IDEA ドキュメント

rails newのオプションに応じて.devcontainerフォルダの下にcompose.yamldevcontainer.jsonを生成するんですね」「システムテスト用のヘッドレスChromeコンテナとかRedisとか、必要そうなものが入っている感じ: 既存プロジェクトでdevcontainerをセットアップするときのひな形にも使えそう👍」「自分でゼロから作らなくても公式のセットアップが使えるのはいいですね」

# .devcontainer/devcontainer.json
// For format details, see https://aka.ms/devcontainer.json.
{
    "name": "Rails project development",
    "dockerComposeFile": "compose.yaml",
    "service": "rails",
    "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",

    // Features to add to the dev container. More info: https://containers.dev/features.
    "features": {
        "ghcr.io/devcontainers/features/github-cli:1": {
            "version": "latest"
        }
    },

    "containerEnv": {
        "PGHOST": "postgres",
        "PGUSER": "postgres",
        "PGPASSWORD": "postgres",
        "MYSQL_HOST": "mariadb",
        "REDIS_URL": "redis://redis/0",
        "MEMCACHE_SERVERS": "memcached:11211"
    },

    // Use 'forwardPorts' to make a list of ports inside the container available locally.
    // This can be used to network with other containers or the host.
    // "forwardPorts": [3000, 5432],

    // Use 'postCreateCommand' to run commands after the container is created.
    "postCreateCommand": ".devcontainer/boot.sh",

    // Configure tool-specific properties.
    "customizations": {
        "vscode": {
            // Add the IDs of extensions you want installed when the container is created.
            "extensions": [
                "Shopify.ruby-lsp"
            ]
        }
    },

    // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
    // "remoteUser": "root"
}

「ところでcompose.yamlを見ていたらUnixのsleepコマンドにinfinityという指定があるのを見つけた↓」「こんな指定があるんですね」

# https://github.com/rails/rails/blob/main/railties/test/fixtures/.devcontainer/compose.yaml
services:
  rails-app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile

    volumes:
      - ../..:/workspaces:cached

    # Overrides default command so things don't shut down after the process ends.
    command: sleep infinity

    networks:
    - default

    # Uncomment the next line to use a non-root user for all processes.
    # user: vscode

    # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
    # (Adding the "ports" property to this file will not forward from a Codespace.)
    ports:
    - 45678:45678
    depends_on:
    - selenium
    - redis

  selenium:
    image: seleniarm/standalone-chromium
    restart: unless-stopped
    networks:
    - default

  redis:
    image: redis:7.2
    restart: unless-stopped
    networks:
    - default
    volumes:
    - redis-data:/data




volumes:
  redis-data:

参考: sleep infinity で無限に待つ - tmtms のメモ

「あとこのプルリクはChangelogエントリがもう1つあって、#SystemTestCase#served_byが追加されたそうです↓」「なるほど、システムテストでホスト名やポートを指定するときに使えるんですね: たしかに環境によってはホスト名やポートを外から注入したいことはよくある」

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  served_by host: "testserver", port: 45678
end

🔗 ActiveRecord::Base.with_connectionを追加

コネクションを短い時間だけリースするショートカットとしてActiveRecord::Base.with_connectionを追加する。

リースされたコネクションがyieldされると、ブロックの実行中はActiveRecord::Base.connectionで同じコネクションがyieldされる。

これは、リクエストやジョブの継続中コネクションをリースし続けることなく、複数のデータベース操作を実行したい場合に有用。

Jean Boussier
同Changelogより


つっつきボイス:「Jean Boussierさん(byrootとcasperisfineも同一人物)が概念実証用の#50793からこのwith_connectionを切り出してマージしたそうです」「ActiveRecord::Base.connectionにブロックを与えられるようになっていますね↓」「with_connectionの中では同じコネクションが使われるようになったっぽい」

# activerecord/lib/active_record/connection_handling.rb#L261
+   def with_connection(&block) # :nodoc:
+     connection_pool.with_connection(&block)
    end
# activerecord/lib/active_record/transactions.rb#L211
      def transaction(**options, &block)
-       connection.transaction(**options, &block)
+       with_connection do |connection|
+         connection.transaction(**options, &block)
+       end
      end

「最初のテストコードはwith_connectionブロックの中ではactive_connection?がtrueでブロックを抜けるとfalseになる、次のテストコードはwith_connectionブロック内で複数接続してもコネクションが変わらないようになっていますね↓」「このプルリクだけだと意図やユースケースがよくわからないな〜」「#50793をチラ見した限りでは、スレッドやfiberに絡んだActiveRecord::Base.connection周りの最適化の一環のようですね」

# activerecord/test/cases/connection_handling_test.rb
# frozen_string_literal: true

require "cases/helper"

module ActiveRecord
  class ConnectionHandlingTest < ActiveRecord::TestCase
    unless in_memory_db?
      test "#with_connection lease the connection for the duration of the block" do
        ActiveRecord::Base.connection_pool.release_connection
        assert_not_predicate ActiveRecord::Base.connection_pool, :active_connection?

        ActiveRecord::Base.with_connection do |connection|
          assert_predicate ActiveRecord::Base.connection_pool, :active_connection?
          assert_same connection, ActiveRecord::Base.connection
        end

        assert_not_predicate ActiveRecord::Base.connection_pool, :active_connection?
      end

      test "#with_connection use the already leased connection if available" do
        leased_connection = ActiveRecord::Base.connection
        assert_predicate ActiveRecord::Base.connection_pool, :active_connection?

        ActiveRecord::Base.with_connection do |connection|
          assert_same leased_connection, connection
          assert_same ActiveRecord::Base.connection, connection
        end

        assert_predicate ActiveRecord::Base.connection_pool, :active_connection?
        assert_same ActiveRecord::Base.connection, leased_connection
      end

後で概念実証の#50793を読んでみました。

コンテキスト

Active Recordは、パフォーマンスとシンプルさの理由、また歴史的にスレッドサポートがなかったこともあって、fiberローカル変数のスレッド内のコネクションをチェックアウトして保持するActiveRecord::Base.connectionに非常に強く依存している。

具体的には、すべてのリクエストやジョブは、データベース操作を実行する必要が生じるとコネクションを遅延チェックアウトし、リクエストやジョブが完了するまでそのコネクションを保持する。完了した時点でExecutorフックがコネクションを自動的にプールにチェックイン(返却)する。

これによってコネクション管理の問題がほぼ解消されるため、少ないスレッド数で十分メリットを得られ、多数のIOを実行しない圧倒的多数のRailsアプリケーションでは、完全に適切なソリューションである。

ただし、ほとんどの時間をデータベース以外のIO(サードパーティAPIなど)に費やし、はるかに高レベルのコンカレンシーでメリットが生じるアプリケーションの場合、スレッドやfiberとほぼ同数のデータベースコネクションが必要になるため、この戦略が問題となる。ほとんどのコネクションはアイドル状態だが、コネクションがプールからチェックアウトされて別のスレッドやfiberによって掴まれたままになるため利用できなくなる。

目標

大多数のRailsユーザーにとっては現在のモデルが引き続き望ましいと信じている。ActiveRecord::Base.connectionの振る舞いを変更すると、最終的に大量のコードが破損することになるので、デフォルトのまま変えないようにすべきだと思う。

ただし、ActiveRecord::Base.connectionがリクエストサイクルの終わりまでコネクションを保持し続けず、利用後すぐにコネクションをチェックインして再利用可能にする別のモードをサポートすることは可能だと思う。

実装

まず、ActiveRecord.cache_connection_checkoutを無効にしなければ始まらない。

次に、Rails自身やサードパーティのライブラリ、プライベートRailsアプリケーションのコードの多くはModel.connection.なんちゃら()に依存しているため、コネクションをキャッシュせずに動かす必要がある。

これを解決するには、キャッシュが無効な場合はModel.connectionからプロキシオブジェクトを返すようにする。これにより、受信したメソッドが新たにチェックアウトされたコネクションに委譲され、再度チェックインされる。詳しくはconnection_pool.rbLazyConnectionProxyを参照。

下位互換性を維持しながらこの機能を許可するには、ここがカギとなる。コードでは以後のModel.connection呼び出しで同じコネクションインスタンスが返されることを前提にしている可能性が大きいので、完璧ではないものの、ほとんどの場合不要である。

また、プロキシはコネクションを保持している間スレッドやfiberでキャッシュされるため、以後のModel.connection呼び出しでは、再度チェックインされるまで同じコネクションを返す。これにより、トランザクションをオープンするといったステートフル性の問題のほとんどが解決される(APIがブロッキングされる形になっている限り)。

現状

このプルリクは概念実証であり、マージの準備ができているわけではない。主な目的は、いくつかの作業を加えて実行可能か単に不可能かを調査すること。

Active Recordテストスイートのほとんどは、コネクションの切断と再接続を行う3つのテストを除いてSqlite3ではパスする。一般に、この機能からすべてのテストをオプトアウトする必要があったが、その理由を正確に調査する必要がある。

また、キャッシュ依存が強すぎるいくつかのテストスイートではこの機能を無効にする必要があり、これを回避するには少しリファクタリングが必要だが、根本的な非互換性はなさそうに見える。

懸念事項

現在、複数のチェックアウト/チェックインサイクルを引き起こすActive Recordメソッドが多数存在しており、それを最適化するために戦略的に配置されたwith_connection do呼び出しの恩恵を受けられる。しかし主な問題はパフォーマンスであり、根本的な非互換性ではない。

もう1つの主な懸念事項はクエリキャッシュである。現状では、チェックアウトキャッシュが無効になっていると、クエリキャッシュはあまり役に立たない(コネクションを再度チェックインするときにキャッシュをクリアする必要があるためキャッシュはほぼ常に空になる)。再び利用可能にするには、大幅なリファクタリングが必要。可能そうに見えるが、決して簡単ではない。

結論

これに関しては少し迷っている。うまくいくとは思うが、すでに概念実証だけでほぼ丸一日を費やしており、すべての問題を解決するにはさらに多くの作業が必要である。

Active Recordのコード自体で必要な実際の変更は比較的小さいが、このオプションで適切に動作するために実際に更新する必要があるのはすべてのテストスイートとサードパーティのコードであり、蓋を開けたらえらいことになりはしないかと心配している。
#50793より

🔗 バイナリカラムの暗号化をサポート

暗号化と復号でバイナリデータをType::Binary::Dataで受け渡しするようにする。

従来は、バイナリカラムをActiveRecord::Encryption::MessageSerializerで暗号化すると、MySQLやSQLiteでは動作するがPostgreSQLでは動作しなかった。

Donal McBreen
同Changelogより

動機/背景

ActiveRecord::Encryptionはバイナリカラムの暗号化を禁止していないが、適切なサポートも存在しない。

暗号化と復号を経たデータは文字列に変換される。これは、暗号化層がバイナリデータに対して透過的ではないことを意味する。バイナリデータはType::Binary::Dataで渡されるべき。

その結果、データがSQLクエリ内で適切にエスケープされなかったり、復号後に正しくデシリアライズされなくなったりする。

ただし、MessageSerializerではエンコードの必要な文字を使わないので、MySQLやSQLiteではたまたま正常に動作するが、カスタムシリアライザを使おうとすると壊れる。

PostgreSQLの場合は、デシリアライズでType::Binary::DataではなくStringBytea型に渡されるため、データのエスケープ解除を試みるときにnullバイトが含まれていると、データが台無しになったりエラーが発生したりする。

詳細

このコミットでは、データの暗号化と復号の後で再度シリアライズすることで問題を修正する。テキストデータの場合は何も行わないが、バイナリデータの場合はType::Binary::Dataに逆変換される。
同PRより


つっつきボイス:「ActiveRecord::Encryptionでバイナリカラムを暗号化しようとしたらPostgreSQLだとできなかったのね」「バイナリは文字列に変換せずにバイナリのまま扱うようにしたんですね」「byteaはPostgreSQLのbyte array型のことか」

参考: PostgreSQL 15ドキュメント 8.4. バイナリ列データ型

🔗 puma-devの改修2件

Dockerを使っていない場合、複数のRailsアプリケーションをローカルで開発するときの黄金のパスはpuma-devになる。そこで、bin/setupを実行したときにpuma-devのセットアップ方法を表示することにしよう。
同PRより


つっつきボイス:「これは次の#51087と合わせてDHHがpuma-devサポートを追加したんですね: 改修自体はbin/setupのテンプレートにpuma-dev設定方法のメッセージを追加するシンプルなもの」

# railties/lib/rails/generators/rails/app/templates/bin/setup.tt#L41
  puts "\n== Restarting application server =="
  system! "bin/rails restart"

+ # puts "\n== Configuring puma-dev =="
+ # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}"
+ # system "curl -Is https://#{APP_NAME}.test/up | head -n 1"
end

development環境でデフォルトで許可されるホストに".test"を追加し、puma-devがスムーズにセットアップされるようにする。

DHH
同Changelogより

# actionpack/lib/action_dispatch/middleware/host_authorization.rb#L22
  class HostAuthorization
-   ALLOWED_HOSTS_IN_DEVELOPMENT = [".localhost", IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0")]
+   ALLOWED_HOSTS_IN_DEVELOPMENT = [".localhost", ".test", IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0")]

「こちらもdevelopment環境で.testで終わるホスト名を使えるようにするシンプルな修正ですね」「.testって予約済みドメインにあるのか↓」

参考: 予約済みドメイン (.example, .localhost, .test) について | blog.jxck.io

「puma-devは以前取り上げたこともあったけど(ウォッチ20221026)どういうものだったっけ?」「Dockerを使わずにサーバーを複数立ち上げたいときにpuma-devを使うと自動的にホスト名やポートを割り振ったりするので便利だよという感じでDHHが推しているみたいですね」「このあたりは好みもありますけど、自分はローカル環境をあれこれ変えるよりはDockerでやる方がいいかな」

puma/puma-dev - GitHub

🔗Rails

🔗 Rails 8の計画(Ruby Weeklyより)


つっつきボイス:「もう知られているものもありますが、Rails 8に向けてのプランを記事にまとめたものです」「Rails 8のマイルストーンを元にした記事なのかな」

参考: 8.0.0 Milestone

「Solid QueueがActive Job用のジョブエンジンとして使われるのは既に見てきましたね」「Action Cableのmessage broadcastがRedisを使うものからDBのpub/sub機能を使うものに実装が変更されるらしい」「小ネタだけどPWA(progressive Web App)への対応もありますね」

参考: Solid Queue should be the default Active Job backend for Rails 8 · Issue #50442 · rails/rails
参考: Add DB-backed Action Cable adapter as new default in Rails 8 · Issue #50480 · rails/rails

Rails: Solid Queueで重要なUPDATE SKIP LOCKEDを理解する(翻訳)

参考: プログレッシブウェブアプリ - Wikipedia

「お、Railsのアセットパイプラインのデフォルトとして長らく使われてきたSprocketsをいよいよ正式にPropshaftに置き換えるらしい」

参考: Make Propshaft the default asset pipeline in Rails 8 · Issue #50444 · rails/rails
参考: § 3 Sprocketsの利用法 -- アセットパイプライン - Railsガイド

Propshaft gem README(翻訳)

「Rails 8にlanguage server(LSP)が追加されるのは期待できそう👍」「ベンチマークツールも標準装備する予定なんですね」

参考: Add a default LSP for better editor/IDE support in Rails 8 · Issue #50453 · rails/rails
参考: Add a built-in benchmark tool · Issue #50451 · rails/rails

「RuboCop設定やGitHubとの統合、HTTP/2サポート強化のためにRack 3が必須になる話やKamalなどもこれまで見てきましたね(ウォッチ20240117)」「全体として、Solid Queue導入やPropshaftへの置き換えはそれなりの変更だけど、Rails 8のアーキテクチャは大きくは変わってはいない感じかな」

参考: Require Rack 3 · Issue #50565 · rails/rails
参考: Setup Kamal by default for new applications · Issue #50441 · rails/rails

Kamal README: 37signalsの多機能コンテナデプロイツール(翻訳)


個人的にはBasic認証のジェネレータが入るのがちょっと嬉しいです😂: システムテストも生成してくれるといいですね。

参考: Add basic authentication generator · Issue #50446 · rails/rails
参考: § 11.1 BASIC認証 -- Rails をはじめよう - Railsガイド

🔗 dynamoid: AWS DynamoDB向けのORM gem(Ruby Weeklyより)

Dynamoid/dynamoid - GitHub


つっつきボイス:「AWSのフルマネージドNoSQL DBとしておなじみのDynamoDB用ORM: この手のORマッパーはいろいろありますよね」

参考: オブジェクト関係マッピング - Wikipedia
参考: 【公式】Amazon DynamoDBとは(マネージド NoSQL データベース)| AWS

「READMEがかなり詳しく書かれていますね」「read_capacityをモデル側で設定したり、capacity_mode: :on_demandのような指定もできるのね↓」「これはどういうものでしょうか?」「DynamoDBのテーブル作成・設定変更時にread / writeで割り当てるキャパシティを指定するもので、provisionedモードならread / writeキャパシティの数値、ondemandモードならモードの指定だけすれば良いです: もちろん割り当てを大きくするとそれだけコストも上がります」

# 同リポジトリより
class User
  include Dynamoid::Document

  table name: :awesome_users, key: :user_id, read_capacity: 5, write_capacity: 5
end
table capacity_mode: :on_demand

参考: 読み取り/書き込みキャパシティモード - Amazon DynamoDB

「こういうORMが欲しくなるのはわかるけど、DynamoDB backedなモデルに対してActive Recordと同じように柔軟なアクセスを気軽にやってしまうと、RDBMSとは違うDynamoDB独自のインデックス仕様をうまく使えずに課金額がものすごいことになったりする可能性があるので要注意です」「ご利用は計画的にということですね」

🔗 その他Rails

つっつきボイス:「前にも見た気がしますが、Rails公式がポストしたZeitwerkのノウハウです」「そうそう、これはZeitwerkでディレクトリを増やすときにありがちなパターンで、空のServicesモジュールを宣言しておかないとエラーになるヤツですね(ウォッチ20230328)」

🔗Ruby

🔗 runruby.dev: bundlerも実行できるブラウザRuby環境(Ruby Weeklyより)


runruby.devより


つっつきボイス:「Ruby Playgroundや先週のemirb(ウォッチ20240227)に続いてこれもWebAssemblyベースでRubyを実行できるサイトだけど、bundle installを実行できるのが面白いですね👍」

参考: Ruby Playground
参考: emirb - irb on ruby-wasm-emscripten + xterm-pty

🔗言語/ツール/OS/CPU

🔗 Gitの知られざる機能(Ruby Weeklyより)


つっつきボイス:「FOSDEMっていうイベント初めて見た」「サイトを見ると2024年度はベルギーのブリュッセルで開催されていますね」「スケジュールを見るとメイントラックが2つの構成だけど、本当のメインであるdevroomがめちゃくちゃ多い↓」「全部チェックするのは物理的に無理でしょうね」「Rubyのdevroomもあった」


FOSDEM 2024より

参考: FOSDEM 2024 - Home
参考: FOSDEM 2024 - Schedule

「動画は45分なので飛ばし見するか」「Gitは今19歳なのね」「git blame -wでホワイトスペースの差分を非表示にできるのか」「git maintenance startって知らなかった」「他にもいろいろ便利技がありそうですね」

参考: Git - git-maintenance Documentation
参考: Git - git-blame Documentation


つっつき後に調べると、発表者が動画の内容をブログで公開してくれていました↓🎉

参考: Git Tips 1: Oldies but Goodies
参考: Git Tips 2: New Stuff in Git
参考: Git Tips 3: Really Large Repositories


今回は以上です。

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

週刊Railsウォッチ: Turbo Nativeアプリ、書籍『Everyday Rails Testing with RSpec』新版執筆開始ほか(20240227後編)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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