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

週刊Railsウォッチ(20210607前編)ActiveRecord::Relationのone?とmany?が高速化、RubyKaigi Takeout 2021登壇者募集開始ほか

こんにちは、hachi8833です。RubyKaigi Takeout 2021の登壇者募集が始まりましたね。

週刊Railsウォッチについて

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

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

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

Rails公式ニュースが久しぶりに更新されたので、その中からこれまで取り上げていなかったものを中心に拾いました。

🔗 ActiveRecord::Relationone?many?を高速化

# activerecord/lib/active_record/relation.rb#L296
    def one?
      return super if block_given?
-     limit_value ? records.one? : size == 1
+     return records.one? if limit_value || loaded?
+     limited_count == 1
    end

    def many?
      return super if block_given?
-     limit_value ? records.many? : size > 1
+     return records.many? if limit_value || loaded?
+     limited_count > 1
    end

つっつきボイス:「SELECT COUNT(*)をやめてLIMIT 2を使って高速化したようですね: 巨大テーブルのCOUNTは遅いですけど、SELECT 1したうえでLIMIT 2にすると速いですよ」「お〜、ベンチマークも1300倍以上になったとありますね」「100M行ならこのぐらい差が出ますよ」「速度が欲しい人たちはこれまで同様の独自メソッドを書いていたかもしれませんね」

LIMIT 2で速くなるかどうかは状況にもよるかも: たとえばMySQLでSELECT COUNT(*) FROM tableのようにテーブル内の行をすべて取得するようなクエリを投げると、MySQLのinformation_schemaに既にある値を使うのでLIMIT 2よりも速くなるかもしれませんね(訂正↓)」「それは速そう!」「LIMIT 2より速くできる方法は他にもありそうですが、Active Recordでscoped chainされることを考えればこのPRの方法がよさそう👍」

訂正(2021/06/08)

MySQLのエンジンがInnoDBの場合はこのようにならないとのご指摘をいただきました(ツイート)。ありがとうございます!

参考: InnoDBのINFORMATION_SCHEMA TABLES Tableと実データのレコード数比率を出してみた - カイワレの大冒険 Third

LIMIT 2に加えてSELECT 1も速くするのによく使われますね」「1ですか?」「1というスタティックな値だけを返すという意味ですね: 定数であればよいので1でなくても構いません」「あ〜なるほど」「行データが不要で件数だけ欲しいようなときに使うことがあります」

参考: sql server 2008 - What does "select 1 from" do? - Stack Overflow


ActiveRecord::Relationone?メソッドやmany?メソッドは、背後でSELECT COUNT(*) FROM postsのようなクエリを生成してから、結果が1に等しい(または1より大きい)かどうかを比較している。巨大テーブルや複雑な条件ではCOUNTが非常に遅くなることがあるが、この場合は全レコード件数は完全に不要。それならLIMITを追加してSELECT COUNT(count_column) FROM (SELECT 1 AS count_column FROM posts LIMIT 2)のようにするだけでずっと高速になる。実際any?などのメソッドではLIMITを使っている。
なおこれはすべてのバージョンのRailsで発生している。
100M行のテーブルでいくつかベンチマークを取ってみると、スピードが1300倍以上増加した。
同PRより大意

🔗 ネストしたsecretsにメソッド名でアクセスできるようになった


つっつきボイス:「ネストしたsecrets自体は前からサポートされていた覚えがありますけど、今回はそれにメソッド名でもアクセスできるようにしたようですね」「なるほど」

参考: Rails 5.2 credentials cheat cheat -- ネストした値をフェッチする例が記載されています

  • Rails.application.credentialsのキーにネステッドアクセスできるようになった

従来はcredentials.yml.encのトップレベルにあるキーにしかメソッド呼び出しでアクセスできなかったが、任意のキーに対してできるようになった。
たとえば以下のsecretsがあるとする。

aws:
   access_key_id: 123
   secret_access_key: 345

この改修で、Rails.application.credentials.aws.access_key_idRails.application.credentials.aws[:access_key_id]と同じものを返すようになった。
Alex Ghiculescu
Changelogより大意

🔗 ActionController::Live#send_streamが追加


つっつきボイス:「send_streamも見覚えある: 最近はsend_dataよりもなるべくsend_streamを使おうみたいな話があったような気がしますね」

「既に生成されてファイルになったものを送信するのであればsend_streamでもsend_dataでも変わらないんですが、以下のようにeachで処理を回すものを送信するのであればsend_streamの方が送信開始を早められます」「あ、send_dataだと処理が完了するまで送信できないのか」「そういうことですね: どちらも最終的に同じことをやれるのであれば基本的にsend_streamを使う方がいいかなと思いました」「なるほど」

生成済みのストリームをより送信しやすくするActionController::Live#send_streamを追加。

   send_stream(filename: "subscribers.csv") do |stream|
     stream.write "email_address,updated_at\n"

     @subscribers.find_each do |subscriber|
       stream.write "#{subscriber.email_address},#{subscriber.updated_at}\n"
     end
   end

同PRより大意

Rails 7でActiveStorage::Streamingサポートが追加(翻訳)

🔗 ActiveStorage::Streamingが切り出された


つっつきボイス:「DHHによる改修です」「streaming.rbがActive Storageから切り出された↓」「リファクタリングっぽいですね」「send_blob_streamというメソッドもできた」

# streaming.rb
# frozen_string_literal: true

module ActiveStorage::Streaming
  DEFAULT_BLOB_STREAMING_DISPOSITION = "inline"

  include ActionController::Live

  private
    # Stream the blob from storage directly to the response. The disposition can be controlled by setting +disposition+.
    # The content type and filename is set directly from the +blob+.
    def send_blob_stream(blob, disposition: nil) #:doc:
      send_stream(
          filename: blob.filename.sanitized,
          disposition: blob.forced_disposition_for_serving || disposition || DEFAULT_BLOB_STREAMING_DISPOSITION,
          type: blob.content_type_for_serving) do |stream|
        blob.download do |chunk|
          stream.write chunk
        end
      end
    end
end

Active Storageからストリーミングするコントローラを独自に作りたいのであれば、ストリーミングを適切に行えるメソッドがあると便利。そういうものがActiveStorage::BaseControllerに切り出されていないまま既に存在していた。
同PRより大意

🔗 fresh_whenstale?Cache-Controlヘッダーを上書きできるよう修正

stale(形容詞)新鮮でない、気の抜けた


つっつきボイス:「Cache-Controlヘッダーにまた修正が入った(ウォッチ20201012ウォッチ20210412)」

「コントローラのアクションでCache-Controlヘッダーに積極的に情報を渡す手段が追加されたということみたいですね↓: Cache-Controlを直接書き変えずにfresh_whenメソッドやstaleメソッドのオプション経由で変えられるようにした」「なるほど」

    # When overwriting Cache-Control header:
    #
    #   def show
    #     @article = Article.find(params[:id])
    #     fresh_when(@article, public: true, cache_control: { no_cache: true })
    #   end
    #
    # This will set in the response Cache-Control = public, no-cache.
  • fresh_whenstale?cache_control: {}オプションを追加。
    これらのメソッドでresponse.cache_controlを設定するショートカットとして使える。
    Jacopo Beschi
    同Changelogより大意

「クライアントキャッシュの制御はコントローラがやるものと考えれば、そのためのヘルパーメソッドがあるのは理にかなっていそうな気もする」「あ、Cache-Controlヘッダーが制御するのは一瞬Railsサーバーのページキャッシュのような気がしたけど、クライアントキャッシュの方だったか」

「コントローラでCache-Controlヘッダーを制御できるようになるのはいいことだと思う一方で、コントローラではサーバーのページキャッシュとクライアントキャッシュのどちらについても気にする場面があるので、どちらを制御しているかに気をつけないといけないかも」「それもそうですね」

参考: Cache-Control - HTTP | MDN
参考: RailsにおけるCacheの概念と使い方 - Qiita

🔗Rails

🔗 PostgreSQLのRow-Level SecurityをRailsで使う(Ruby Weeklyより)


つっつきボイス:「PostgreSQLに行レベルのセキュリティ機能があるとは」「略してRLS」「ENABLE ROW LEVEL SECURITY;で有効にできるのか」「どういうふうに使うのかな?」「なるほど、こんな感じでCREATE POLICYでポリシーを記述するんですね↓」

-- 同記事より
CREATE POLICY transactions_app_user
  ON transactions
  TO app_user
  USING (customer_id = NULLIF(current_setting('rls.customer_id', TRUE), '')::bigint);

参考: PostgreSQL: Documentation: 13: 5.8. Row Security Policies

「記事ではこれをRailsで使ってる↓」「やるな〜」

-- 同記事より
class CreateTransactions < ActiveRecord::Migration[6.1]
  def change
    create_table :transactions, id: :uuid do |t|
      t.bigint :customer_id
      t.text :description
      t.bigint :amount_cents
      t.timestamptz :created_at
    end

    # Grant application user permissions on the table (this migration should run as the admin user)
    reversible do |dir|
      dir.up do
        execute 'GRANT SELECT, INSERT, UPDATE, DELETE ON transactions TO app_user'
      end
      dir.down do
        execute 'REVOKE SELECT, INSERT, UPDATE, DELETE ON transactions FROM app_user'
      end
    end

    # Define RLS policy
    reversible do |dir|
      dir.up do
        execute 'ALTER TABLE transactions ENABLE ROW LEVEL SECURITY'
        execute "CREATE POLICY transactions_app_user ON transactions TO app_user USING (customer_id = NULLIF(current_setting('rls.customer_id', TRUE), '')::bigint)"
      end
      dir.down do
        execute 'DROP POLICY transactions_app_user ON transactions'
        execute 'ALTER TABLE transactions DISABLE ROW LEVEL SECURITY'
      end
    end
  end
end

「モデルのコードを見ると、コネクションの部分で'SET rls.customer_id = %s'のようにRLSを設定してますね↓: PostgreSQLコネクションのセッション変数的な部分にこれを設定することで、クエリが適切かどうかをポリシーでチェックできるようになる感じかな」「複雑になるけどその分安心できそう」

# 同記事より
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  SET_CUSTOMER_ID_SQL = 'SET rls.customer_id = %s'.freeze
  RESET_CUSTOMER_ID_SQL = 'RESET rls.customer_id'.freeze
  def self.with_customer_id(customer_id, &block)
    begin
      connection.execute format(SET_CUSTOMER_ID_SQL, connection.quote(customer_id))
      block.call
    ensure
      connection.execute RESET_CUSTOMER_ID_SQL
    end
  end
end

「RLSをセキュリティ上の防衛という観点から見た場合、PostgreSQLのRLSはあくまで生SQLの中で設定しているので、仮にセッションを乗っ取られて生SQLを実行されたら回避できないでしょうね」「それもそうか」

「自分なら、たとえば今度Rails 7に入る予定のモデル暗号化機能(ウォッチ20210412)みたいな暗号化用gemを使って、暗号化の鍵はアプリケーション側だけで持ち、PostgreSQLには暗号化済みのデータを保存する方が、生SQLされたときの対策としては有効かなと思いました」「あ〜なるほど」「もちろんPostgreSQLのRLSも、有効性の範囲を理解したうえでポリシー設定に使う分にはよいものだろうと思います👍」

「面白い機能ですね」「ぽすぐれならこういう機能があっても不思議じゃない」「記事の最後には、RLSを使う場合はパフォーマンスにも注意しようと書かれてますね」

🔗 Active Recordのnewにはブロックも渡せる(Ruby Weeklyより)


つっつきボイス:「Active Recordのnewcreateのような作成系メソッドは以前からこういうふうにブロックも渡せますね↓」「あ、そうでしたか」

# 同記事より
u = User.new do |user|
  user.first_name = "Jordan"
  user.last_name = "Knight"
end

「Rubyだと『こういう書き方、やってみたら動くかな?』と思いついてドキュメントも読まずにやってみると本当に動くことがちょくちょくありますけど、そういうノリでnewにブロック渡ししてみたらできたという感じでしょうね」「たしかにRubyだとそれよくありますよね」「Ruby名物の『やったらできた』」


追いかけボイス:「同記事の後半では、Rubyのtapメソッドを使った場合にブロック渡しの挙動が異なることについても触れられていますね↓」

# 同記事より
# tapにブロックを渡す場合
new_user = User.create(first_name: "Jordan", last_name: "Knight").tap do |u|
  u.first_name = "Jonathan"
end

new_user.first_name        #=> "Jonathan"
new_user.reload.first_name #=> "Jordan"
# createに直接ブロックを渡す場合
new_user = User.create(first_name: "Jordan", last_name: "Knight") do |u|
  u.first_name = "Jonathan"
end

new_user.first_name        #=> "Jonathan"
new_user.reload.first_name #=> "Jonathan"

参考: Object#tap (Ruby 3.0.0 リファレンスマニュアル)

🔗 Hotwire記事2本


つっつきボイス:「1本目は今翻訳中のEvil Martiansの記事で、2本目は少し前のですがwillnetさんのHotwire記事です」「もしかすると自分が本当に欲しかったのはHotwireだったのかもしれない、という気持ちもある一方で、Turboがね...Turboを使うかどうかまだ悩み中なんですよ」「そこですよね」「Evil Martiansの記事は、いい意味で古き良き時代のWeb開発に戻れるよと言いつつ、既存Railsアプリに導入するとそこそこ直しが必要だったようです」「HotwireとRails本体の相性についてはあまり心配していませんが、むしろRails以外のものとHotwireとのインテグレーションが気になっています」「あ、そうか!」「いずれにしろHotwireについてはもう少し調べておかないといけないでしょうね」「たしかに」


速報: Basecampがリリースした「Hotwire」の概要

🔗 その他Rails


つっつきボイス:「小ネタですが、過去のRails gemがインストールされていれば、こんなふうにバージョン番号の前後にアンダースコア_を付けるとバージョン指定できるそうです↓」「そうそう、Railsにはこんなオプションが隠れていますね」「たまにとても欲しくなりそうな機能」

# 同記事より
# Create Rails 6.1 project
$ rails _6.1.3_ new rails6_1

# Create Rails 6.0 project
$ rails _6.0.3_ new rails6_0

# Create Rails 5.2 project
$ rails _5.2.3_ new rails5.2

前編は以上です。

バックナンバー(2021年度第2四半期)

週刊Railsウォッチ(20210601後編)Python使いから見たRuby、MySQLのインデックス解説、GitHubが採用したOpenTelemetryほか

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

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

Rails公式ニュース

Ruby Weekly

Hacklines

Hacklines


CONTACT

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