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

Rails 6のB面に隠れている地味にうれしい機能たち(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

  • 2019/05/16: 初版公開
  • 2021/07/19: 更新

Rails 6のB面に隠れている地味にうれしい機能たち(翻訳)

はじめに

次回のメジャーアップグレードの中から、運用実績のある成熟したアプリケーションでも使いたくなるような、あまり知られていない機能を発掘したいと思います。昔の音楽に例えれば、「ヒットチャート上位」に顔を出すような売れ線の機能ではなく、LPレコードのB面やレアコレクションに隠れている名曲のような、新しいリリースの「地味だけど絶妙に役立つ」機能に目を向けてみたいと思います。

Rails 6で最も喧伝されているAction MailboxAction Textのような機能につい目を奪われがちですが、アップグレードするだけで利用できるWYSIWYGテキストエディタが、ある程度の期間運用されている現実のRailsアプリで今すぐに重宝するとは考えにくいでしょう。

一方、マルチデータベースのサポートパラレルテストのような、それほど前面に出ていない機能がただちに生産性向上に役立つようなこともあります。Rails 6には、そうした一見の価値のある機能が目白押しです。

私は数年前にAction Cableの改良に携わって以来、Railsフレームワークの開発を追いかけており、無数のプルリクに目を通しています。Rails 6 RC版リリースの数か月前には、既にある中規模クラスのRails 4アプリをRails 6向けに書き直す権限を与えられました。

私はAnyCableを手がけていることもあって、なんとかCableのたぐいは私の専門分野と言えます。

私は熱狂的な音楽ファンでもあるので、Railsという大規模フレームワークのリリースを目撃するのは、さながら(音楽の)レコード発売日に居合わせるような心持ちです。ヘビロテされる大ヒット曲もあれば、B面に埋もれたままになったり、ファンが発掘してレア物としてありがたがる曲もあります。

本記事では、このたびリリースされるRailsの舞台裏で息を潜めているgemを拾い上げてみたいと思います。新しいgemもあれば、数年の時を耐え忍んでRails 6にマージされたgemもありますし、プルリク一発のコードもあれば、Rails 6.xまでおあずけのgemもあります。

🔗 Action Cableのテスト

Rails 5のメジャーな機能であるAction Cableは、WebSocketsをすぐに利用でき、JavaScriptライブラリも同梱されていました。Action CableはRails wayの「設定より規約」に沿っていて構文も親しみやすいのですが、テスト駆動アプローチのサポートがありませんでした。つまり、チャンネルのテストを書くための公式な方法が提供されていなかったのです。

Rails 6では、Action CableのJavaScript部分がついにCoffeeScriptとおさらばし、#34177でES6に書き直されました。

ある日、私は#23211を再オープンして修正する機会がありました。このプルリクはRails 5に取り込まれることになっていたのですが、最終的な曲目からは漏れてしまいました。その代り、シングル盤レコード(つまりaction-cable-testing gemのことです)をリリースし、3年越しでついに#33659でRails 6にマージされたのです。

というわけで、新しいRails 6プロジェクトで(--skip-action-cableを付けずに)rails newを実行すれば、app/channelsフォルダに加えてtest/channelsフォルダも作成されるようになりました。

サンプルではRSpecが使われています。Action Cableの統合はaction-cable-testing gemで実装されていますが、RSpec 4でマージされる予定です(#2113)(訳注: 現在はマージ済みです)。

さて、Action Cableではどこをテストすればよいのでしょうか?現実のアプリで使われている事例を見てみましょう。

Action Cableの接続周りであれば、次のように認証に関連するロジックをテストしたいでしょう。

# spec/channels/application_cable/connection_spec.rb
require "rails_helper"

# `type: :channel`でAction Cableテスティングヘルパーを追加する
# 現時点ではaction-testing-cable gemを使うが、RSpec 4に同梱されるはず
RSpec.describe ApplicationCable::Connection, type: :channel do
  let(:user) { create(:user) }

  it "cookieでの接続に成功する" do
    # "virtual"リクエストcookieをセット
    cookies.signed[:user] = user.id

    # `connect`メソッドはサーバーへのwebsocketクライアント接続を表す
    connect "/websocket"

    # idが正しく設定されたことをチェックできるようになった
    expect(connection.current_user).to eq user
  end

  it "cookieなしの接続は拒否する" do
    # cookieが渡されない場合は接続を拒否することをテストする
    expect { connect "/websocket" }.to have_rejected_connection
  end

  it "存在しないユーザーからの接続は拒否する" do
    cookies.signed[:user] = -1

    expect { connect "/websocket" }.to have_rejected_connection
  end
end

その他のAction Cableプリミティブである「チャネル」のテストもさらに興味深いものになりました。チャネルはWebSocketのコントローラとみなすことができます。

以下のテストで使っているPresenceChannelクラスは、実際のアプリでもさまざまなページに渡るユーザーのアクティビティを正確にトラッキングするのに使われています。#subscribe向けに以下のテストシナリオがあります。

  • ユーザーがそのチャンネルに接続するときは、プレゼンストラッキングシステムに登録されていなければならない
  • ユーザーがそのチャンネルに接続するときは、対応するストリームでサブスクライブされなければならない(通知を受け取るため)
  • ユーザーがそのチャンネルに接続するときは、「ユーザーが参加しました」という通知をストリームに送信しなければならない

真新しいAction Cableテストユーティリティを用いて、次のテストを書きます。

require "rails_helper"

RSpec.describe PresenceChannel, type: :channel do
  # `let_it_be`ヘルパーは`test-prof` gemが提供
  let_it_be(:projectschool) { create(:project) }
  let_it_be(:user) { create(:user, project: project) }

  before do
    # `stub_connection`は、渡されたidで
    # Connectionインスタンスを初期化する 
    stub_connection current_user: user
  end

  describe "#subscribe" do
    subject do
      # `subscribe`ヘルパーは、記載されているチャンネルへ
      # のサブスクライブアクションを実行する
      subscribe
      # `subscription`はサブスクライブされたチャネルのインスタンス
      subscription
    end

    it "プレゼンスストリームにサブスクライブする" do
      expect(subject).to be_confirmed
      expect(subject).to have_stream_for(project)
    end

    it "現在のユーザーをオンラインリストに登録する" do
      subject
      # Presence::OnlineUsersはこのアプリ特有のバックエンド実装
      # (プレゼンスのデータを保存する)
      expect(Presence::OnlineUsers.for(project)).to match_array([user])
    end

    it "オンライン通知を送信する" do
      expect { subject }
        .to have_broadcasted_to(project)
        .with(
          type: "presence",
          event: "user-presence-changed",
          user_id: user.id,
          status: "online"
        )
    end
  end

  # `unsubscribe`アクションもほぼ同一のシナリオ
  describe "#subscribe" do
    before do
      # unsbscribeを呼ぶ前に最初にsubscribeweしなければならない
      subscribe
    end

    it "現在のユーザーをオンラインリストから削除する" do
      expect(Presence::OnlineUsers.for(project)).to match_array([user])

      unsubscribe
      expect(Presence::OnlineUsers.for(project)).to eq([])
    end

    it "オフライン通知を送信する" do
      expect { unsubscribe }
        .to have_broadcasted_to(project)
        .with(
          type: "presence",
          event: "user-presence-changed",
          user_id: user.id,
          status: "offline"
        )
    end
  end
end

皆さんのアプリケーションに足りなかったAction Cableテストを存分に追加してみてください!

🔗 Active Storageの将来を垣間見る

Active Storageは、Rails 5.2で新たに加わった新しいフレームワークの一員です。

私がActive Storageを使い始めたのはRails 6 beta 1からです。Active Storageには、すぐ使えるダイレクトアップロードのようなおいしい機能もいくつかありますが、まだ荒削りな部分がたくさん残っています。

グッドニュース: Active Storageは急速に進化を遂げており、改良方法が絶え間なく提案されています。

バッドニュース: 私たちが最も待ち望んでいるプルリクは、惜しくもRails 6の最初の安定版リリースには間に合いませんでした。しかし、それまでは提案されている変更を以下のような別実装で使ってみることもできます。

  • 添付ファイルのサイズやcontent typeのバリデーション(#35390: その後close)。現時点ではactive_storage_validations gemで実装されています。
  • 添付ファイルごとに複数の異なるサービスを使い分ける(#34935: その後マージ済み)。このプルリクがマージされれば、添付ファイルの種類ごとに異なるサービスを使い分けられるようになります(モデルごとに異なるS3バケットを使うなど)。
class User < ActiveRecord::Base
  has_one_attached :avatar, service: :s3
  has_one_attached :contract, service: :super_encrypted_s3
end
  • 添付ファイルを、現在のようにリダイレクトを経由せず、プロキシ経由で送信する(#34477: その後マージ済み)。これにより、CDNを簡単にセットアップして、最終的にユーザーがアップロードするアセットをより高速に送信できるようになります。別の#34581プルリクでは、同じ目的のpublic_service_urが提案されています。

  • 画像のvariantに名前を付ける機能(#35290: その後close)。現在は、添付ファイルのvariant(訳注: サイズ違いの画像)を作成するときにuser.avatar.variant(resize_to_limit: "50x50")のように正確なオプションを指定しなければなりません。named variants機能によってuser.avatar.variant(:thumb)のように書けるようになります。

named variants機能はRailsへのマージ待ち状態ですが、私たちが独自に実装したものもありますので、どうぞご覧ください。

🔗 Active Recordのinsert_all

#35077でActive RecordのバルクINSERTがサポートされます。

この機能がこれまで提案されていなかったことも驚きですが、私たちは既にいくつかのgem(activerecord-importがその道では最も有名です)を使っていました。

多数のレコードを一括でINSERTする方が、レコードを1つずつ保存するよりも明らかに効率が高くなります。

  • 必要なSQLクエリが1つで済む
  • モデルオブジェクトをインスタンス化する必要がない(メモリ使用量のコストはかかる)

ただし大きなトレードオフが1つあります。#insert_allメソッドではコールバックやバリデーションが一切呼び出されません。ご利用は計画的に!

この機能のおまけとして、すぐ使えるUPSERTステートメントがサポートされます。UPSERTPostgreSQLなどほとんどのリレーショナルデータベースでサポートされています。UPSERTINSERTUPDATEとして使った場合を考えてみましょう。INSERTしようとしているレコードがuniqueness制約に引っかかると、例外を出さずに既存レコードのUPDATE操作にフォールバックします。

PostgreSQLのINSERTのドキュメントに書かれていないxmax ='0'という裏技を使うと、どのレコードが実際に(UPDATEではなく)INSERTされたかをトラッキングできます。詳しくはStack Overflowをご覧ください。

今私が手がけているプロジェクトのコードを少しお目にかけましょう。このプロジェクトには「ユーザーの一括招待」機能があり、その背後にはInvitation(user_id, event_id, rsvp:bool, disposable:bool)モデルがあります。あるユーザーがイベントに他のユーザーを大勢招待すると、未招待のユーザーごとにinvitationのレコードを1件作成します。ユーザーが招待済みの場合は、invitationプロパティを更新したいと考えています(ここでUPSERTが活躍します)。

Invitation.pg_batch_insert(
  columns, # INSERTするカラムのリスト
  values, # 値のリスト(配列の配列)
  on_conflict:
    # (user_id, event_id)ペアでuniqueness制約をかけている
    "(user_id, event_id) DO UPDATE "\
    "SET disposable = (events.disposable AND EXCLUDED.disposable), "\
    "rsvp = (events.rsvp OR EXCLUDED.rsvp)",
  returning: "user_id, (xmax = '0') as inserted"
)

このコードでは少し前に書いたコードをmixinしてあり、とても役に立っています。

以上でおしまいです。後はRails 6がinsert_allメソッドやupsert_allメソッドを提供してくれます。

# 上と同じ機能
Invitation.upsert_all(
  # 注意: Railsではハッシュの配列が入力として期待されている
  values.map { |v| columns.zip(v).to_h },
  unique_by: %i[event_id user_id],
  update_sql: "disposable = (events.disposable AND EXCLUDED.disposable), "\
              "rsvp = (events.rsvp OR EXCLUDED.rsvp)",
  returning: "user_id, (xmax = '0') as inserted"
)

なお、上の例のupdate_sqlオプションやreturningオプションのプルリクは現時点ではマージされていない(#35636: その後close)ので、今後の更新情報をフォローしてください。

🔗 「dirty」ストアアクセサ

Action Cableテストのプルリクはお披露目されるまでに3年も待ち続けていましたが、この記録の上をいくのは間違いなく#19333の「ストアアクセサのdirtyトラッキングメソッド群」でしょう。何しろ提案が出されたのは2015年です。

このプルリクがめでたくRails 6にマージされたことで、Store属性を「素の」Active Record属性と同じように変更できるようになりました。

class Account < ApplicationRecord
  store_accessor :settings, :color
end

acc = Account.new
acc.color_changed? #=> false

acc.color = "red-n-white"
acc.color_changed? #=> true

この機能ができるまでの歴史を簡単に振り返ってみましょう。

Railsでは、いわゆる"dirty"属性(saveされていない変更)をトラッキングする方法が提供されています。dirtyは2008年のRails 2.1で導入されました。Rails 3.2では、単一カラムにシリアライズ保存されたデータ(多くはJSON)への読み書きメソッドを作成する方法として、いわゆるストアアクセサが追加されました

しかし、ストアアクセサが真の力を発揮したのは、PostgreSQLでJSONBデータ型がサポートされた後のことです。JSONBは、構造化されていないデータを効率よくコンパクトに、かつインデックス化可能な方法で保存する方法を提供します。

従来、ストアアクセサの変更トラッキングは次のような感じで行われていました(この例は実際に使われているコードベースから引用しました)。

class RangeQuestion < ActiveRecord::Base
  after_commit :recalculate_answers_scores, on: :update, if: :answer_was_changed?
  # RangeQuestionでは`min`値と`max`値に収まる正しい回答を期待する
  store_accessor :options, :min, :max
  # なおストアアクセサは通常の属性と同じ方法でバリデーションできる
  validates :min, :max, presence: true, numericality: { only_integer: true }
end

従来のanswer_was_changed?メソッドでは、options属性の変更全体をトラッキングしなければならず、以下のように扱いが面倒でした。

def answer_was_changed?
  # 詳しくはActiveRecord::AttributeMethods::Dirtyを参照
  return false if saved_change_to_attribute?("options")

  prev_options = saved_change_to_attribute("options").first

  prev_options.dig("min") != min || prev_options.dig("max") != max
end

これと同じようなことをコードのあちこちで行わなければならなかったので、私はあるときストアアクセサで*_changed?をextendすることを思いつきました。Rails 6からは以下のように書くだけで済みます。

def answer_was_changed?
  saved_change_to_min? || saved_change_to_max?
end

随分良くなったと思いませんか?こんなシンプルな機能のマージに時間がかかった理由は、主に当時コアコントリビュータだったSean Griffinが、ストアアクセサをあまり知られていない Attributes APIを用いる機能をフル装備した属性に昇格させることを望んでいたことです。残念なことにこの構想は実現せず、当面その見通しもなさそうです。なおSeanは最近Railsコアチームからリタイアしました。

🔗 Active Recordのその他の素敵な小物たち

  • optimizer hintsのサポート(#35615)。これは、指定のクエリの最大実行時間に上限を設定するMySQLの機能です。
User.optimizer_hints("MAX_EXECUTION_TIME(5000)").all
#=> SELECT /*+ MAX_EXECUTION_TIME(5000) */ `users`.* FROM `users`

ちなみに、Active Recordは実行タイムアウトエラーを認識してStatementTimeout例外をraiseするようになりました(#31129)。これで例外を好きなだけキャッチできます。

  • クエリにannotateでコメントを追加できるようになった。
Post.for_user(user).annotate("fetching posts for user ##{user.id}").to_sql
#=> SELECT "posts".* FROM "posts" WHERE ... /* fetching posts for user #123 */
  • enumのネガティブスコープが自動で生成されるようになった(35381)。
class User < ApplicationRecord
  enum role: {
    member: "member",
    manager: "manager",
    admin: "admin"
  }
end

User.not_member == User.where.not(role: :member) #=> true
  • 以下のさまざまなショートカットが追加された:
  • seedの冒頭でModel.delete_allを実行する必要がなくなった。(railsコマンドの)db:truncate_allですべてのテーブルをDROPせずに内容をクリアできます。

# データベース内の全テーブルをtruncateする
# メモ: `be`は`bundle exec`のエイリアスを設定したものです(よろしければ皆さんもどうぞ)
$ be rails db:truncate_all

# truncateとdb:seedを以下のコマンド一発で実行できます
$ be rails db:seed:replant

この機能は、ステージングやレビュー用アプリで、データベースをDROPせずにseedを再実行したい場合に特に便利です(さすがにproductionのDBでseedを再実行するべきではありませんよね?)。

🔗 環境ごとのcredential

Rails 5.2から導入されたcredentialによって、評判のよろしくない.envファイルで重要なデータを管理せずに済むよう、新たなRails wayに則って重要なデータを扱えるようになっています。credentialを用いることで、サードパーティサービスの暗号化済みキーをバージョン管理システムに直接登録できるようになりました。

ただし現在のRailsでは、すべての環境で同一の暗号化済みファイルを使うようになっていたため、development環境とproduction環境で異なるキーを使おうとすると少々トリッキーになります。Rails 6ではこの点が環境ごとのcredential(#33521)によって最終的に解決されました。

私の作ったanyway_configでもcredentialをサポートしました。このgemを使うことで、アプリの設定データをさまざまなデータソース(credentialやYAMLファイルや環境変数)に直接アクセスせずに透過的に利用できます。

🔗 テーブル形式でないルーティング表示

アプリケーションのルーティングで頭を抱えたことのある方は、私の友人であるBenoitの新作rails routes --expandedをぜひお試しください(#32130)。

これであの邪魔っけなテーブル形式とおさらばです!

$ rails routes -g direct_uploads --expanded

Prefix            | rails_direct_uploads
Verb              | POST
URI               | /rails/active_storage/direct_uploads(.:format)
Controller#Action | active_storage/direct_uploads#create

🔗 Active Jobの小ネタ

Active Jobにもさまざまな改良が行われていて私の目を惹きつけました。

  • timezoneメタデータをジョブに追加することで、キューに入ったのと同じタイムゾーンでジョブを実行できるようになった(#32085)。ところで、ジョブ実行中は現在のロケールも保持されることをご存知ですか?

  • ジョブがキューに入った時刻を示すenqueued_atフィールドが新たにタイムスタンプとして追加された。これにより(私たちの作ったYabeda gemなどを用いて)パフォーマンス上きわめて重要な特性(ジョブがキューに入ってから実行されるまでの待ち時間)を測定できます。

🔗 Actionable Errorsによるエラー画面の操作性向上

最後は、私のもうひとりの友人であるGenadi Samokovarovが作った新機能で締めくくりたいと思います(#34788)。Genadiはweb-consoleの作者であり、Rails開発者をActionable Errors APIで幸せにしようとしています。

ブラウザ上でボタンをクリックするだけで、実行し忘れていたマイグレーションを実行できます

ブラウザ上でボタンをクリックするだけで、実行し忘れていたマイグレーションを実行できます

この機能は通常のRails例外ページにボタンを追加し、ブラウザのエラーページでマイグレーションを実行してActiveRecord::PendingMigrationErrorエラーを解決できるようにします。

カスタム例外にアクションを追加して機能を拡張するのも自由自在です!


ご覧のとおり、Rails 6には素敵な機能が山ほど盛り込まれています。こうした機能はさほどアナウンスされていませんが、名もないプルリクたちによって、皆さんが待ちに待った機能が実装されたり、あるいは既に愛用している有名な機能が強化されたりすることで、productionのRailsアプリが見違えるほど変わることもあります。

私見では、今回のRailsアップグレードは、特に長年に渡って成熟したプロジェクトから関心を寄せられると思います。正直に申し上げると、Rails 5が登場したときは、大量のRails 4コードベースをアップグレードするに足る理由が見当たりませんでした。Rails 6への移行は最終的に正しいものになると感じています。

お知らせ

スタートアップ企業をワープ速度まで加速すべく飛来した外宇宙のエンジニアたちに告ぐ: Evil Martiansのフォームまで連絡を乞う。

関連記事

Rails 5.2新機能を先行チェック!Active Storage/ダイレクトアップロード/Early Hintsほか(翻訳)


CONTACT

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