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

週刊Railsウォッチ: 2022年のRails振り返り記事、RailsにDocker関連ファイルが追加ほか(20230125前編)

こんにちは、hachi8833です。週刊Railsウォッチがほぼ1か月ぶりのご無沙汰となりました 🙇

週刊Railsウォッチについて

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

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

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

だいぶ間が空いてしまいましたので、昨年末の改修から追いかけていきます。

🔗 all_queries: trueを指定したデフォルトスコープだけがreload時にクエリに適用されるよう修正

修正対象: #46731
現在のreloadの振る舞いは、いずれかにall_queries: trueを指定するとすべてのデフォルトスコープが適用されるようになっている。reloadするときは、all_queries: trueを指定したデフォルトスコープだけが適用されるのが正しい振る舞い。
同PRより


概要
現在のdefault_scopesの振る舞いに一貫していない部分がある。
Active Recordモデルには多くのスコープを設定でき、レコードをリロードするときはどのスコープも適用されない。スコープのどれかひとつにall_queries: trueが指定されていると、すべてのスコープが適用される。
参考: rails/persistence.rb at main · rails/rails
参考: rails/persistence.rb at main · rails/rails

再現手順

1 - あるActive Recordモデルにdefault_scope(all_queries: false { where("1=1") }を追加する
2 - レコードを作成し、クエリをかけ、リロードする: このスコープはリロードクエリ内で適用されない
3 - 今度はall_queries: trueを指定してdefault_scope(all_queries: true { where("2=2") }という別のデフォルトスコープを追加する
4 - 手順2を繰り返してクエリを調べる: 両方のスコープが適用される

# 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", github: "rails/rails", branch: "main"
  gem "sqlite3", "< 1.5"
end

require "active_record"
require "minitest/autorun"
require "logger"


ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :posts, force: true do |t|
    t.boolean :deleted
  end
end

class Post < ActiveRecord::Base
  default_scope { where(deleted: false) }
end

class BugTest < Minitest::Test
  def test_default_scope_side_effect
    post = Post.create!
    last_query = nil

    ActiveSupport::Notifications.subscribe("sql.active_record") do |_, _, _, _, data|
      last_query = data[:sql]
    end

    expected_query = 'SELECT "posts".* FROM "posts" WHERE "posts"."id" = ? LIMIT ?'

    # デフォルトスコープは適用「されない」
    post.reload
    assert_equal expected_query, last_query

    # 何もしないデフォルトスコープ: `all_queries: false`
    Post.default_scopes << ActiveRecord::Scoping::DefaultScope.new(lambda { where("1 = 1") }, false)

    # クエリは変更されず、デフォルトスコープはまったく適用されない
    post.reload
    assert_equal expected_query, last_query

    # 何もしないデフォルトスコープ: `all_queries: true`
    Post.default_scopes << ActiveRecord::Scoping::DefaultScope.new(lambda { where("2 = 2") }, true)

    expected_modified_query = 'SELECT "posts".* FROM "posts" WHERE (2 = 2) AND "posts"."id" = ? LIMIT ?'
    # 3つのスコープがすべて適用される
    post.reload
    assert_equal expected_modified_query, last_query
  end
end

期待される振る舞い
Active Recordモデルにdefalut_scopeを追加したとき、レコードをリロードするときに他へのdefault_scopeの振る舞いに影響すべきではない。
クエリの振る舞いを一貫させるとしたら、以下のどちらかを期待したい。

  • モデルをリロードしたときに、all_queriesを指定したデフォルトスコープだけがクエリに適用される
  • モデルをリロードするときはdefault_scopeを適用しない(こういうスコープがreload操作で適用される理由が自分にはさっぱりわからない)

実際の振る舞い
モデルにはall_queries: falseのdefault_scopesを複数定義可能で、レコードをリロードするときはどれも適用されない。
そういうモデルにall_queries: trueを指定したdefault_scopeが1個あると、すべてのdefault_scopeが適用されてしまう。
#46731より


つっつきボイス:「あるデフォルトスコープでall_queries: trueを指定したときに他のデフォルトスコープにもall_queries: trueが効いてしまっていたのを修正したということらしい」「ちなみにAPIドキュメントを見るとall_queriesのデフォルトはnilなので普段は無効なんですね」

参考: Rails API default_scope -- ActiveRecord::Scoping::Default::ClassMethods

🔗 productionでRAILS_MASTER_KEYを渡さなくてもassets:precompileを実行可能にした

production環境でイメージビルド手順中にアセットをコンパイルするときに本物のRAILS_MASTER_KEYを渡さなければならないのは不便。そこで、ENV["SECRET_KEY_BASE_DUMMY"] = 1を指定すれば、development環境やtest環境でやっているのと同様にダミーのsecret_key_baseを渡せるようにする。この場合、本物のcredentialやメッセージダイジェスト検証にはアクセスできなくなるが、実際のところは無用なので、ビルドを完了できるようになる。
同PRより


つっつきボイス:「DHH自らのプルリクです」「なるほど、たしかにマスターキーを指定しなくてもアセットをプリコンパイルしたいですよね」「ということは今まではマスターキーがないとプリコンパイルできなかったんですか?」「プリコンパイルそのものでは不要でも、rails assets:precompileコマンドのどこかの処理でマスターキーを要求していた箇所があってそこで止まってたんでしょうね」「地味に不便ですね」「そもそもマスターキーは不必要な場所には絶対配布したくないものなので、ビルドするだけのサーバーとかには置きたくない」「たしかに」

# railties/lib/rails/application.rb#473
    def secret_key_base
-     if Rails.env.development? || Rails.env.test?
+     if Rails.env.development? || Rails.env.test? || ENV["SECRET_KEY_BASE_DUMMY"]
        secrets.secret_key_base ||= generate_development_secret
      else
        validate_secret_key_base(
          ENV["SECRET_KEY_BASE"] || credentials.secret_key_base || secrets.secret_key_base
        )
      end
    end

参考: § 4.1 アセットをプリコンパイルする -- アセットパイプライン - Railsガイド

🔗 Action Textで変更のないノードの置き換えを回避して高速化

置き換えのロジックが何らかの条件付けに基づいている場合のパフォーマンスを改善し、スキップしたい場合は無変更のノードをそのまま返す。以下のような感じ:

content.fragment.replace("p") do |node|
  if node.text =~ /replace me/
   "<p>replaced</p>"
  else
    node
  end
end

(ベンチマークコードは省略)
結果:

Warming up --------------------------------------
Current implementation
                         2.000  i/100ms
  New implementation     5.000  i/100ms
Calculating -------------------------------------
  Current implementation
                         32.484  (±30.8%) i/s -    134.000  in   5.036419s
  New implementation     74.878  (±38.7%) i/s -    250.000  in   5.052168s

Comparison:
  New implementation:       74.9 i/s
  Current implementation:       32.5 i/s - 2.31x  (± 0.00) slower

つっつきボイス:「Action Textで不要なノードの置き換えを回避する形での最適化👍」「if文が追加された部分がそれなんですね↓」「ベンチマークが倍速くなってる」

# actiontext/lib/action_text/fragment.rb#37
    def replace(selector)
      update do |source|
        source.css(selector).each do |node|
-         node.replace(yield(node).to_s)
+         replacement_node = yield(node)
+         node.replace(replacement_node.to_s) if node != replacement_node
        end
      end
    end

参考: Action Text の概要 - Railsガイド

🔗 Active SupportのMessageEncryptorsとMessageVerifiersにtransitional属性を追加

このコミットはActiveSupport::MessageEncryptorsActiveSupport::MessageVerifierstransitional属性を追加する。transitional = trueを設定すると、MessageEncryptorsやMessageVerifiersのビルド時にローテーションの最初の2件をスワップする。
たとえば、以下のコンフィグではMessageVerifiersがserializer: Marshal, url_safe: trueでメッセージを生成し、生成されたメッセージを3つのオプションのいずれかを用いて検証できるようになる。

verifiers = ActiveSupport::MessageVerifiers.new { ... }
verifiers.rotate(serializer: JSON, url_safe: true)
verifiers.rotate(serializer: Marshal, url_safe: true)
verifiers.rotate(serializer: Marshal, url_safe: false)
verifiers.transitional = true

これはアプリケーションをローリングデプロイする際に、まだ更新されていないサーバーが更新済みサーバーからのメッセージを検証可能にする必要がある場合に便利。特に、Rails.application.message_verifiersのデフォルトのローテーションに適用できる。
同PRより


つっつきボイス:「transitional属性の追加は、鍵の方式を変更するときなんかに鍵のローテーションをやりやすくする改修のようですね👍」「ローリングデプロイのときに便利とありますね」「ローリングデプロイでは移行中に古い方式のサーバーと新しい方式のサーバーが共存するので、そういう状況向けでしょうね: そのうち具体的な使用例記事が出てくるかも」

参考: デプロイ / リリース 手法 まとめ - galife -- ローリングデプロイ

🔗 Rails.env.local?でdevelopmentとtestの環境変数をまとめて扱えるようになった

test環境やdevelopment環境で実行するときにRails.envをチェックすることが多いので、1個にまとめる。
従来:

if Rails.env.development? || Rails.env.test?

改修後

if Rails.env.local?

同PRより


つっつきボイス:「これもDHHによるプルリクです」「if Rails.env.development? || Rails.env.test?はよく使われる書き方ですけど、それをif Rails.env.local?でまとめて書けるようになったのか」「地味にありがたい改修」「localはdevelopment環境とtest環境の両方を表すんですね」「こういう改修をおもむろに入れてくるのがDHHらしい」

🔗 ActiveRecord::Relation#none?#any?#one?Enumerableと同様のパターン引数を渡せるようになった

Enumerable版の#none?#any?#one?述語メソッドはそれぞれブロックの代わりにオプションのパターン引数を受け取る。パターンが指定された場合、これらのメソッドはEnumerable内にある要素とパターンがマッチするかどうかをRubyのcase文の===演算子でチェックする。
以下の2つは同等。

products.any?(MediaBlock)
products.any? { |product| MediaBlock === product }

このスタイルをポリモーフィック関連付けでも使えると便利だろう。たとえばOrder#plan?メソッドの場合は以下のようになる。

class Order
  has_many :line_items
  has_many :products, through: :line_items

  # orderにplanが含まれていたらtrueを返す
  def plan?
    products.any?(Plan)
  end
end

class LineItem
  belongs_to :order
  has_one :product, polymorphic: true
end

しかしActiveRecord::Relation版のこれらのメソッドはオプションのパターン引数を受け取らない。背後で読み込まれているrecords配列はブロックが与えられたときにこれを行っているので、それらに委譲する形でこれらのメソッドを更新する。
同PRより


つっつきボイス:「なるほど、RubyのEnumerableと同じようにActiveRecord::Relationでもブロックを渡す方法に加えて#none?#any?#one?メソッドも呼べるようになった」「コード例やテストコードを見た感じではSTIを想定している感じですね」

参考: § 5 シングルテーブル継承 (STI) -- Active Record の関連付け - Railsガイド

参考: module Enumerable (Ruby 3.2 リファレンスマニュアル)

🔗 production環境のRailsコンソールでIRBのオートコンプリートがデフォルトで無効になった

IRB_USE_AUTOCOMPLETE=trueを設定すればこのデフォルトの振る舞いをオーバーライドできる。
Stan Lo
同Changelogより


つっつきボイス:「これだけ公式更新情報ではなく以下の記事で知りました↓」

Ruby 3.2のIRBに導入された新機能(翻訳)

「productionのコンソールでIRBのオートコンプリートをデフォルトでオフにするようになった: おそらくリッチなCLI描画が動かないような環境や、事前に検証済みのコードをコピペする場合なんかにオートコンプリートが干渉して想定外の挙動が起こるのを防ぐためなんじゃないかな」「最初にIRB 3.2でそのためのIRB_USE_AUTOCOMPLETEを追加して、それからこの改修を行ったという流れのようです: ちなみにどちらもst0012さんがやっています」

「現場だとncurses(CLIのインターフェイス構築用ライブラリ)あたりがうまく動かないWebコンソールを使わざるをえないこともあって、オートコンプリートを止めないとEnterキーを叩くのも怖いなんてこともありますよね」「そうそう」

参考: ncurses - Wikipedia

🔗Rails

🔗 2022年のRails振り返り記事


つっつきボイス:「1件目はRails公式の2022年振り返り記事で、2件目は@tenderloveさんによる振り返り記事です」「ほとんどは週刊Railsウォッチで扱ってきたものだと思いますが、こうやってまとまるのはよい👍」「@tenderloveさんの記事の末尾には、言語とフレームワークとIDEが今後もっと密に連携するようになることを期待したいとも書かれていますね」

「以下の記事は許可をいただけたので翻訳中です↓」「お、Rails 7.1が近いのかな?」「いえ、Rails 7.1はブランチもタグもできてない状態なのでまだ先だと思います」「ありゃ残念」「記事は7.1に入るであろう2022年の新機能のうち著者が気に入っているものを中心に取り上げています」「なるほど、ぼくの好きな機能まとめという感じ」「たしかに全部は入り切らないでしょうね」「週刊Railsウォッチの先週の改修をまとめたような感じです」

🔗 RailsにDockerfileとdocked gemが追加された(Rails公式ニュースより)

rails/docked - GitHub


つっつきボイス:「RailsにDockerfileが追加された下のプルリクがBPS社内Slackでも話題になっていましたが、その他にdockedというgemもRailsリポジトリに入ってきました↑」「dockedは、Railsにおけるdocker公式対応なテンプレ部分をgemに切り出したものに近い感じかな」「公式に入ったんですか」「for beginnersと書かれているので主にそういう用途なんでしょうね」「このDockerfileがそのまま実際のプロジェクトで使えるかどうかはともかく、DockerがRailsに公式に取り入れられたことでDockerの設定で議論しなくても済むようになることは期待できそう👍」

🔗 RailsのSSRF解説(RubyFlowより)


つっつきボイス:「CSRF(Cross-Site Request forgery)じゃなくてSSRF(Server-Side Request Forgery)の解説記事なんですね」「SlackにURLを貼るとOGP情報をサーバー側で取ってきて展開しますけど、ああいうふうにユーザーが入力したURLをサーバーサイドがアクセスしに行くところを攻撃するのもSSRFの一種」「そういえばはてなブックマークなんかもそういう動作ですね」「AWS EC2インスタンスはインスタンスの情報を固定URLで取れるんですが、そこを攻撃して鍵などの設定情報を盗み出したりする例でよくSSRFが引き合いに出されます」「なるほど」「SSRFもだいぶ知られてきましたが、これに限らず、ユーザーが入力するURLにサーバー側でアクセスするときは十分注意が必要」「クローラー的なことをさせると踏みがちですね」

参考: SSRF(Server Side Request Forgery)徹底入門 | 徳丸浩の日記
参考: [待望のアプデ]EC2インスタンスメタデータサービスv2がリリースされてSSRF脆弱性等への攻撃に対するセキュリティが強化されました! | DevelopersIO

RailsのCSRF保護を詳しく調べてみた(翻訳)

🔗 Railsアプリで見落としやすい"eager loadingできてない箇所"(Ruby Weeklyより)


つっつきボイス:「N+1クエリ問題を見落としやすい場合があるという記事だそうです」

# 同記事より
class User < ApplicationRecord
  has_many :products, strict_loading: true
end

「上のモデルではstrict_loading: trueでeager loadingしていて、以下のサンプルだと1つ目は正しくraiseされているけど、2つ目のようにスコープを追加するとすり抜けてN+1クエリが発生してしまうヤツですね」「踏んだらつらいヤツ」

# 同記事より
User.last(2).map(&:products).map(&:to_a)

# raises => ActiveRecord::StrictLoadingViolationError

User.last(2).map(&:products).map(&:by_rating).map(&:to_a)

# productsは読み込まれるがN+1クエリが発生する

Rails: Bulletで検出されないN+1クエリを解消する

🔗 Railsで"期間"を扱うときは要注意(Ruby Weeklyより)


つっつきボイス:「期間の扱いは定番ネタ」「日付や時刻がらみはホント難しい」「みんなUnix時間で生きていくことにすればいいのに」「うるう秒を廃止するという話も最近出てましたね」

参考: UNIX時間 - Wikipedia
参考: うるう秒、2035年までに廃止へ | ギズモード・ジャパン

🔗 その他Rails


つっつきボイス:「これはruby-jp Slackで知ったもので、discuss.rubyonrails.orgのsecurityタグで直近のRailsセキュリティに関する議論を一覧できます」「なるほど、この間のRailsセキュリティ修正↓で扱われていたものもここで見えますね👍」

Railsセキュリティ修正7.0.4.1、6.1.7.1、6.0.6.1がリリースされました


前編は以上です。

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

週刊Railsウォッチ: Ruby 3.2の正規表現高速化、Googleのosv-scannerほか(20221221後編)

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

Rails公式ニュース

Ruby Weekly

RubyFlow

160928_1638_XvIP4h


CONTACT

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