🧐

Rails8お試し(Authentication GeneratorとSolidQueue/Cache/Cable)

2024/11/10に公開
1

Ruby on Rails — Rails 8.0: No PaaS RequiredにてRails8がついにリリース!という記事が公開されていた。
https://rubyonrails.org/2024/11/7/rails-8-no-paas-required

今回は外部依存のミドルウェア(Redisとか)が無くともRDB(デフォルトはSQLite。その他MySQLやPostgresも使える。)で何とかする仕組みがSolidX(SolidQueue/SolidCache/SolidCable)の形で実装されたり、デプロイツールのKamal2とThrusterが追加されたりとThe One Person Frameworkへの強化がメインになっていそう。他にも新たなアセットパイプラインとしてPropshaftが導入されたり、認証システム構築のための基本コンポーネントが組み込まれたりといった辺りも目玉のアップデートとなっている。

この辺りの新機能や改修を一通り試してみたい。せっかくRedis等のミドルウェアが不要になっているのでDockerも使わずホスト環境でそのまま触ってみる。

Rails8のセットアップ

とりあえずRails8をインストールしてスタートページを表示させる。

 mkdir rails8-sandbox
 cd rails8-sandbox
 brew upgrade ruby-build
 rbenv install 3.3.6
 rbenv local 3.3.6
 rbenv rehash
 gem install rails -v 8.0.0
 rails _8.0.0_ new .
  bin/dev
  open http://127.0.0.1:3000

認証機能をBasic Authentication Generatorで作成する

Rails 8で基本的な認証ジェネレータが導入される(翻訳)|TechRacho by BPS株式会社
Rails 8 introduces a basic authentication generator - BigBinary Blog
Add basic authentication generator · Issue #50446 · rails/rails

今回から追加されたRails提供の基本の認証機能ジェネレータを実装してみる。実行し生成されるファイルを確認してみるとこんな感じ。

 $ bin/rails generate authentication
 $ git status
 On branch main
 Changes not staged for commit:
   (use "git add <file>..." to update what will be committed)
   (use "git restore <file>..." to discard changes in working directory)
 	modified:   Gemfile
 	modified:   Gemfile.lock
 	modified:   app/controllers/application_controller.rb
 	modified:   config/routes.rb
 
 Untracked files:
   (use "git add <file>..." to include in what will be committed)
 	app/channels/
 	app/controllers/concerns/authentication.rb
 	app/controllers/passwords_controller.rb
 	app/controllers/sessions_controller.rb
 	app/mailers/passwords_mailer.rb
 	app/models/current.rb
 	app/models/session.rb
 	app/models/user.rb
 	app/views/passwords/
 	app/views/passwords_mailer/
 	app/views/sessions/
 	db/migrate/
 	test/fixtures/users.yml
 	test/mailers/previews/
 	test/models/user_test.rb

TableとしてはUserとSessionのテーブルを作成するマイグレーションファイルが作成されている。emailとpasswordの普通の認証処理でhas_secure_passwordを利用してセッショントークンを作成するみたい。ActiveSupport::CurrentAttributesにsessionを保持し、そのsessionにuserを委譲する設計。こんな感じ。

app/models/current.rb
 class Current < ActiveSupport::CurrentAttributes
   attribute :session
   delegate :user, to: :session, allow_nil: true
 end

app/controllers/concerns/authentication.rbに具体的な認証処理が書かれている。

app/controllers/concerns/authentication.rb
 module Authentication
   extend ActiveSupport::Concern
 
   included do
     before_action :require_authentication
     helper_method :authenticated?
   end
 
   class_methods do
     def allow_unauthenticated_access(**options)
       skip_before_action :require_authentication, **options
     end
   end
 
   private
     def authenticated?
       resume_session
     end
 
     def require_authentication
       resume_session || request_authentication
     end
 
 
     def resume_session
       Current.session ||= find_session_by_cookie
     end
 
     def find_session_by_cookie
       Session.find_by(id: cookies.signed[:session_id])
     end
 
 
     def request_authentication
       session[:return_to_after_authenticating] = request.url
       redirect_to new_session_path
     end
 
     def after_authentication_url
       session.delete(:return_to_after_authenticating) || root_url
     end
 
 
     def start_new_session_for(user)
       user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
         Current.session = session
         cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
       end
     end
 
     def terminate_session
       Current.session.destroy
       cookies.delete(:session_id)
     end
 end

なんでメソッドごとに2行改行と1行改行があるのか謎だけど処理としてはシンプル。cookieからsession idを取り出して有効なら先ほどのCurrent.sessionに保持させて使い回す感じ。もしsession idがない場合はサインインページへリダイレクトされるだけ。あとはpasswords_controller.rbsessions_controller.rb、それに対応するviewやroutes.rbも勝手に作成されている。従来のようにDevise入れてゴニョゴニョやるよりデフォルトが提供してる分スッキリしてる印象で良い。

余談だけどpasswords_controller.rbUser.find_by_password_reset_token!という呼び出しがあり、このメソッドはどこで定義されてるのか調べたらhas_secure_passwordのクラスでdefine_methodされていた。#{attribute}_reset_tokenという感じで動的生成されており、つまりデフォルトはpasswordだけどhoge_reset_tokenという名前にもできるということっぽい。ヘぇ〜。

dbのmigrateをして実際に認証ページ( http://127.0.0.1:3000/session/new )へアクセスしてみるといい感じに表示された。

 $ bin/rails db:migrate

で、じゃあ新規ユーザー作成するか〜と思ってリンク探したらなんとない!新規作成はサポートしてないらしい...。ここまでやってくれるなら新規作成までやってよくない?と思ったが仕方ないのでbin/rails consoleUse.create(email_address:, password:)してHomeのcontrollerとviewを作りrootの設定をした。そんであとはさっきのログインページからログインすればhomeにリダイレクトされることを確認した(ちなみにcannot load such file -- bcryptのエラーが出たんだけどserverを再起動するだけで直るので焦るな。)

デフォルトだとAuthenticationのconcern内でrequire_authenticationが実行されており、ApplicationControllerでそれがincludeされているのでこのApplicationControllerを継承したページは全部ログイン必須になってた。やはり認証機能の最低限だけは適当に提供しとくけど、あとはご自由にどうぞという割り切りで作られてるな。

SolidCache/SolidQueue/SolidCableを試す

以下SolidCache/SolidQueue/SolidCableを雑に試していく。

SolidQueue

rails/solid_queue: Database-backed Active Job backend
https://github.com/rails/solid_queue
SolidCache自体はRailsのデフォルトで入っているので追加のgemなどはいらない。設定ファイルがconfig/cache.ymlにあるのでそこにキャッシュのname_spaceやmax_sizeを設定していく模様。詳しいOptionはここにリストされている。基本はActiveJobのバックエンドとしてシームレスに利用できるようになっているから利用者としてはあまり意識しなくて良い(はず)。今までセッション管理のためだけにElastiCacheを使ってたりした部分がRDSに統合できるようなって嬉しい〜というのがおすすめポイントらしい。何かと依存するミドルウェアが多いと環境構築とか面倒だからありがたい改善ではある。

とはいえ設定がデフォルトでされているのは本番環境用の話であり、それ以外の開発環境などでは別途自分で設定が必要だった。まずconfig/development.rbに下記を追加。

config/development.rb
 config.active_job.queue_adapter = :solid_queue
 config.solid_queue.connects_to = { database: { writing: :queue } }

そんでconfig/database.ymlのdevelopmentを下記に変更。SolidQueue用に別DBを作成する必要があるのか...。パフォーマンス考えたらそりゃそう。

config/database.yml
 development:
   primary:
     <<: *default
     database: storage/development.sqlite3
   queue:
     <<: *default
     database: storage/development_queue.sqlite3
     migrations_paths: db/queue_migrate

そしてbin/rails db:drop && bin/rails db:prepareを実行する。これでstorage/development.sqlite3development_queue.sqlite3が作成される。あとはbin/jobを実行するとSolidQueueが起動する。

ひとまず適当に↓のようなJobを作成してRailsコンソールを立ち上げてSampleJob.perform_laterを実行。

app/jobs/sample_job.rb
 class SampleJob < ApplicationJob
   queue_as :default
 
   def perform(*args)
     Rails.logger.info "SampleJob is running"
     sleep 5
     Rails.logger.info "SampleJob is done"
   end
 end

するとlogs/development.logにログが出力されるはず。実行できてた。

QueueのDBの方がどうなってるのかも気になるのでActiveJobのダッシュボードを提供してくれるmission-control-jobsを入れてroute.rbにmount MissionControl::Jobs::Engine, at: "/jobs"を設定。

すると/jobsというパスが生えるのでアクセスすると↓こんな感じになって実行結果がモニターできたりする。

sidekiq/webみたいなUIの自動更新がないのでやや不便。issueは出来てたのでPRチャンス。

データ用のDBとQueue用のDBは分けることを推奨されているが一つのDBで全て実行してもいいらしい。でもデータ用とは別にDBが必要なら、普通クラウドのRDB費用はクラウド全体の費用に占める結構な割合になりがちだし、それじゃあElastiCacheみたいなものを使うのと費用面ではあんま変わんないよな〜と思ったりした。

SolidCache

rails/solid_cache: A database-backed ActiveSupport::Cache::Store
https://github.com/rails/solid_cache

SolidCacheはRailsのキャッシュ機構のバックエンドを今までのmemcachedやRedisからRDBを使うようにできる機能。SolidQueueと同様にconfig/cache.ymlという設定ファイルがあったり、config/database.ymlにキャッシュ用のデータベースを作成する設定ができたりと同じインターフェースになっている。

今回は一番わかりやすそうなフラグメントキャッシュ(viewの部分キャッシュ)で試す。とりあえず開発環境だと有効になってないので下記のコマンドで有効にする。

 rails dev:cache

次にdevelopment.rbでcache storeをSolidCacheに変更。

config/environments/development.rb
 # config.cache_store = :memory_store <- コメントアウト
 config.cache_store = :solid_cache_store

ほんでdatabase.ymlにqueueの時と同じくcacheの設定を記述する。

config/database.yml
 development:
   ...
   cache:
     <<: *default
     database: storage/development_cache.sqlite3
     migrations_paths: db/cache_migrate

キャッシュの更新についても確認しやすいようにexpireする時間を短くしておく。cache.ymlに下記を追加。失効時間を30秒に設定。

config/cache.yml
 development:
   database: cache
   store_options:
     expiry_batch_size: 1
     max_age: <%= 30.seconds.to_i %>

フラグメントキャッシュが動いてるのを確認するために下記をview/home/index.htmlに追加

view/home/index.html
 <% cache("home_index_time") do %>
   <p>
     <%= Time.now %>
   </p>
 <% end %>

これで何度かアクセスするとTime.nowの時間がキャッシュされた時間から更新されなくなるはず。サーバーログには↓こんな感じでsolid_cache_entriesテーブルからデータが取得されていることがわかった。

 SolidCache::Entry Load (0.8ms)  SELECT "solid_cache_entries"."key", "solid_cache_entries"."value" FROM "solid_cache_entries" WHERE "solid_cache_entries"."key_hash" IN (-398009230309464708) /*action='index',application='Rails8Sandbox',controller='home'*/
   ↳ app/views/home/index.html.erb:3
 Read fragment views/home/index:61461968e53d2da16b5bba2b509e5932/home_index_time (1.8ms)

次にどうやってキャッシュが削除されるのかを確認する。これは少し独特なので注意が必要。というのも次回書き込み時にキャッシュに失効期限のものがあれば削除されるというアルゴリズムになっている。なので例えばcache.ymlmax_ageというキャッシュの有効期限を設定していても、次に書き込みがある新規のキャッシュ追加更新処理がなければそのキャッシュは失効していたとしても更新されない。

先ほどのviewの場合、例えば下のように新規のキャッシュ処理を追加した時に初めて表示されるTime.nowの時間が更新される仕組み。

 <% cache("home_index_time") do %>
   <p>
     <%= Time.now %>
   </p>
 <% end %>
 
 <% cache("home_index_time2") do %>
   <p>削除用のキャッシュテスト</p>
 <% end %>

これがrenderingされるときに初めて下記のログが表示された。

 SolidCache::Entry Upsert (0.4ms)  INSERT INTO "solid_cache_entries" ("key","value","key_hash","byte_size","created_at") VALUES (x'646576656c6f706d656e743a76696577732f686f6d652f696e6465783a30666163373539643361373034356238643734363231313065616430346164612f686f6d655f696e6465785f74696d65', x'001102000000000000f0bf0a0000000408492200063a06454620203c703e0a20202020323032342d31312d31302031363a35393a3337202b303930300a20203c2f703e0a', 2662525179818617242, 285, STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) ON CONFLICT ("key_hash") DO UPDATE SET "key"=excluded."key","value"=excluded."value","byte_size"=excluded."byte_size" RETURNING "id" /*action='index',application='Rails8Sandbox',controller='home'*/
    ↳ app/views/home/index.html.erb:3
  Write fragment views/home/index:0fac759d3a7045b8d7462110ead04ada/home_index_time (1.2ms)

次の書き込み時とか関係なく失効時間になったら自動で更新されて欲しい場合はどうすんだ...。

ちなみにデフォルトだと書き込み処理の際に失効対象のキャッシュを探し、もしあればそのスレッド中で削除処理が行われる。だから例えば削除対象のキャッシュが多い場合はそのプロセスだけ重くなってしまうはず。というわけで削除処理自体をバックグラウンドジョブに逃がすオプションがある。expiry_method: :jobcache.ymlで設定しておけば有効になる。その他詳しいアルゴリズムについてはREADME/cache-expiryに書いてるので確認しておくとよさそう。

SolidCable

rails/solid_cable: A database backed ActionCable adapter
https://github.com/rails/solid_cable

SolidCacheはActionCableのバックエンドをRDBにするための機能。他のSolidX同様にRailsの依存からRedisを剥がすモチベーションで使われる模様。

まずはconfig/database.ymlconfig/cable.ymlに設定を追加する。

config/database.yml
 development:
   ...
   cable:
     <<: *default
     database: storage/development_cable.sqlite3
     migrations_paths: db/cable_migrate
config/cable.yml
 development:
   adapter: solid_cable
   connects_to:
     database:
       writing: cable
   polling_interval: 0.1.seconds
   message_retention: 1.day

そしてbin/rails db:drop && bin/rails db:prepareでdbの作成。

デモとしては下記のgeneratorで雛形を作成し簡単なチャットUIを作ってみるのが早い。Rails 7でリアルタイム通信を実現! Action Cableの基本をチュートリアルとともに理解しようの記事なんかを参考に作ればok。

 $ bin/rails generate channel Chat

あとはメッセージを送ってログを確認すると↓のようにSolidCacheが使われているのがわかるはず。

 broadcast
 ChatChannel#speak({"message"=>"hi!"})
 [ActionCable] Broadcasting to chat_channel: {:message=>"hi!", :sent_at=>"19:12"}
   SolidCable::Message Insert (0.3ms)  INSERT INTO "solid_cable_messages" ("created_at","channel","payload","channel_hash") VALUES ('2024-11-10 10:12:07.253561', x'636861745f6368616e6e656c', x'7b226d657373616765223a22686921222c2273656e745f6174223a2231393a3132227d', 5736154075277867913) ON CONFLICT  DO NOTHING RETURNING "id" /*application='Rails8Sandbox'*/
   ↳ app/channels/chat_channel.rb:12:in `speak'
 [ActiveJob] [SolidCable::TrimJob] [86e0320c-08fa-455e-81a8-158779ecf61e] Performing SolidCable::TrimJob (Job ID: 86e0320c-08fa-455e-81a8-158779ecf61e) from SolidQueue(default)

まとめ

今回はRails8に追加されたBasic Authentication GeneratorとSolidCache/SolidQueue/SolidCableを触ってみた。他にもAssetPipelineの軽量代替実装のPropshaftや本番デプロイ楽々マンになるためのKamal2+Thrusterなんかも触りたかったけど、力尽きてしまったのでまたいつか。

今回の作業リポジトリ↓
https://github.com/YuheiNakasaka/rails8-sandbox

Discussion