おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

OmniAuthを利用しているプロジェクトのSystemテストでログインを可能な限り省略する

大抵のwebサービスのSystemテストは、ログインしてから何かをすることになると思います。ログイン機能のテストはちゃんとある前提で、ログインのために「トップページ→ログインページ→ログイン処理→ログイン画面」までの遷移を毎回やるのは時間がかかる。

deviseはそれを解決するためのテストヘルパーを用意していて、「次のリクエストをログイン状態として扱う」ができるようになっています*1

deviseではなくOmniAuthを単体で使っている場合はOmniAuth.config.test_mode = trueとしておいて、次のようにモックデータを用意することで「トップページ→"/auth/twitter"→"/auth/twitter/callback"→ログイン後のページ」のような遷移をしている人が多いはず。

OmniAuth.config.mock_auth[:twitter] = OmniAuth::AuthHash.new({
  :provider => 'twitter',
  :uid => '123545'
  # etc.
})

ref: Integration Testing · omniauth/omniauth Wiki

しかし、素朴な実装であれば直接"/auth/twitter/callback"にアクセスしてもログインすることができます。つまり

def login
  visit root_path
  click_on "Twitterでログイン" # ここで/auth/twitter ->/auth/twitter/callback -> ログイン後のページへの遷移が起きる
  expect(page).to have_content 'ログインしました'
end  

というコードを

def login
  visit "/auth/twitter/callback" # ログイン後のページへのリダイレクトが起きる
  expect(page).to have_content 'ログインしました'
end

に変更するということ。これで「トップページ→"/auth/twitter"」へのアクセスが省略できるのでちょっぴりsystemテストが高速化されます。ちょっぴり、とはいえ大量のテストがある場合は馬鹿にならないと思うので該当する方は試してみると良いかと思います*2

*1:systemテストで扱うには工夫が必要なこともあるけど今回はそこには触れません

*2:もちろんログイン機能のテストは別でちゃんと定義しておきましょう

rails g authenticationで生成されるコードを眺めて気になったこと

Rails8からrails g authenticationで簡単な認証用のコードが生成できるようになりました。これまで認証といえばdevise gemが定番でしたが、rails g authentication(以下認証ジェネレータと呼びます)もこれから一定使われるようになるのではないかと想像しています。

そんな認証ジェネレータを利用して、生成されたコードを読んでみると気になるポイントがあります。認証用の情報がsession[:user_id]ではなくcookies.signed[:session_id]のような形で保持する形式になっているところです。 rails/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt at main · rails/rails

ほとんどのRailsアプリケーションではsession[:user_id]のような形式で保存しておいた情報をもってログイン済みのユーザを判別しているはずです。なぜsessionを使わないのでしょうか?

認証ジェネレータで生成されるコードでsessionを使っていない理由

理由としては、Action Cableで認証情報を利用したいからだというのが考えられます。Action Cableでは基本的にsessionが使えません。cookiesは読み取り専用ですが使うことができます。

「基本的に使えません」と書いたのは、比較的簡単に使えるときもあるからです。Action Cable の概要 - Railsガイド にあるように、Action Cableではアプリケーション内にマウントして実行する形式と、スタンドアロンと呼ばれる形式でAction Cableだけを動かすプロセスとして実行する形式の二種類があります。前者の形式かつnot APIモード場合、RailsのデフォルトのmiddlewareにActionDispatch::Session::CookieStoreなど*1 が入っています。これによりwebsocket接続時のsessionにrequest.session[:user_id]のようにしてアクセスすることが可能です。

しかし後者の形式もしくはAPIモードのときはsession用のmiddlewareを通らないため、request.session[:user_id]としてもnilが返ってくるようになります。

すでにsessionを使って認証機能を実装しているRailsアプリケーションにAction Cableを追加したい場合はどうしたらよいでしょうか?Railsガイドにはクッキーセッションを使っている場合の例が書かれています。

以下 Action Cable の概要 - Railsガイド から引用

認証にセッションを含む場合、セッションにcookieストアを使い、セッションcookieの_sessionとユーザーIDのキーとなるuser_idを使う方法が利用可能になります。

verified_user = User.find_by(id: cookies.encrypted["_session"]["user_id"])

クッキーセッション以外を使っている場合も、Rack Middlewareを追加してやれば恐らくsessionを読み込むこと自体はできると思います(未確認)

なぜAction Cableでsessionが使えないか

なぜAction Cableでsessionが使えないかは僕が探した限りではどこにもドキュメントはなかったのですが、websocketだとsessionは扱いづらいというのが理由ではないかと推測しています。

例えばクッキーセッションだとHTTPレスポンスヘッダのSet-Cookieで値を更新しなければなりませんが、websocket上ではそれはできません。Redisなどにセッション情報を格納していれば可能ですが、Railsとして統一したインタフェースを作ることは難しいと思います。

まとめ

他にもSessionというモデルを使ってDBで認証情報を管理しているところなど、認証ジェネレータには面白いポイントがいくつかあるのですがとりあえずあまりドキュメント化されていないであろうsessionの話をまとめてみました。他にも気になるところがあったらなにか書くかもしれません。

*1:config.session_storeの設定によって変わります

RackアプリケーションをLambdaでホスティングしてみた

最近、Lamby というRackアプリケーションをAWS Lambda上で動かすためのGemを使って、RackアプリケーションをAWS Lambda上で動かしてみました。簡単に使えて良い感じだったのでメモしておきます。

前提

もともとm3 tech blogの記事 を見てLambdaでもRailsを動かせることは知っていました。

ここ数年ほど個人ブログ用のリダイレクタを DigitalOceanの$4/月のインスタンス上で動かしていたのですが、普段は全然アクセス数がないはずなので、これをLambdaに載せ替えたらもっと安くなるのでは?と思い、試しに乗り換えてみることにしたのでした。

設定の仕方

Lambyでは、以下のようなコマンドを使って新しいRailsアプリ*1の雛形を生成できます。

docker run \
  --rm \
  --interactive \
  --volume "${PWD}:/var/task" \
  ghcr.io/rails-lambda/lamby-cookiecutter \
  "gh:rails-lambda/lamby-cookiecutter"

単にrails newしただけではなく、lambdaにデプロイするためのCI設定(GitHub ActionsやCircleCIの設定ファイル)、やbin/deploy スクリプトなどが含まれています。生成されたプロジェクトをベースに少し設定をいじり、bin/deploy を実行すると、簡単にLambda上にRailsアプリをデプロイできます。デプロイしたアプリケーションはLambda 関数 URL経由でアクセスできます。

Lambda関数URL自体はhttps://<url-id>.lambda-url.<region>.on.awsのような形式のURLなのですが、独自ドメインで運用したい場合のドキュメントはあるので、そのとおりにCloudfrontとDNSを設定したところ比較的すんなり設定できました。

注意点

Lambyは一応「Rackアプリケーション対応」となっていますが、実際にはRailsを前提に設計されている印象です。僕のリダイレクタはSinatraアプリだったのですが、これをLambyで使おうとした場合の設定はドキュメントにはなかったので、Lambyのコードを読んで理解する必要がありました。

まとめ

多少荒削りな部分はあるものの、bin/deploy でサクッとLambdaにデプロイできる体験は良かったです。1ヶ月ほど運用してみた料金は、LambdaとCloudfrontの料金は無料の範囲で収まっているようで0ドル、ECRの料金が0.04ドルほどかかっている、という状況です。やすい。アクセス数の少ないサービスであれば、安価に運用できて良さそうだなと感じました。

*1:執筆時点ではRailsの最新は8.0でしたが7.2のRailsアプリケーションが生成されました

prependとaliasは混ぜるな危険

これはRubyKaigi 2025のLT用に出したプロポーザルの内容をブログエントリにしたものです。プロポーザルは落選したのでここに書くことで供養しておこうと思います。

導入

既存のメソッドの定義を再利用しつつ新しい振る舞いを追加する方法としてはModule#prependが一般的な手法ですが、Module#prependがなかったころ(Ruby < 2.0)はaliasを利用していました。深く考えずに昔の名残でaliasを利用する人がまだいるかもしれません。しかし、aliasModule#prependが組み合わされると思わぬ不具合に遭遇することがあります。

不具合の例

次のように、同じメソッドをModule#prependで上書きしてからaliasで上書きするとSystemStackErrorになります。

class Hello
  def say
    'hello'
  end
end

Hello.prepend(Module.new do
  def say # (m)
    super
  end
end)

Hello.class_eval do
  alias orig_say say

  def say # (a)
    "#{orig_say} world"
  end
end

Hello.new.say #=> SystemStackError

しかし、aliasModule#prependの順番ではエラーになりません。なぜでしょうか?

class Hello
  def say # (o)
    'hello'
  end
end

Hello.class_eval do
  alias orig_say say

  def say # (a)
    "#{orig_say} world"
  end
end

Hello.prepend(Module.new do
  def say # (m)
    super
  end
end)

Hello.new.say #=> "hello world"

原因

これはaliasが「alias実行時点でのメソッド実装をそのまま利用して別名のメソッドを作成する」という仕様なため起きます*1。前者のコード例だとorig_sayはその時点でのHello#sayの継承ツリーで最初に見つかる(m)を指すことになります。するとHello.new.say(m) -> (a) -> (m) -> (a) -> (m) => ... => SystemStackErrorとなります。しかし後者のコード例だとorig_say(o)を指すため、Hello.new.say(m) -> (a) -> (o) => "hello world"となり正常終了します。

今どきaliasを使ってメソッドに振る舞いを追加しているgemはそれほど多くありませんが、稀にあります。例えば以前activerecord-multi-tenantattr-encrypted が共にActiveRecord::Base#reloadをオーバライドしており、かつattr-encryptedがalias形式を利用していたためGemfileの記載順がactiverecord-multi-tenant, attr-encryptedの順のときにSystemStackErrorになるケースが有りました( 今はこの問題を解消するPRがmergeされているため最新版を使えば問題ありません)。

まとめ

aliasModule#prependの組み合わせにより起きる不具合について紹介しました。

この不具合は基本的に複数のライブラリの組み合わせによって発生する、というのとライブラリの読み込み順番によっては起きない、というやっかいな性質を持っており、めったに踏まないとは思いますが踏んだときの調査は面倒です。2025年にメソッドの振る舞いを変更する方法としてaliasを使う理由は基本的にない*2と思うので、もしaliasを使っている箇所を見つけたらModule#prependに書き換えていきましょう。

*1:コードを見たい方はこの辺りを眺めると良いと思います ruby/vm_method.c at ce849d565bf6aae8e0179fffb04eb1f665f17347 · ruby/ruby

*2:例外としてはRuby2.7以下をサポートしているライブラリがKernelなどの組み込みモジュールのメソッドを上書きしているケースですが、もうRuby3.0がEOLの時代なのでそういったライブラリも数少ないはず…

ginza.rb 第89を開催してHanamiについて学んだ

Ginza.rb 第89回 - Hanamiについて学ぶぞ - connpass 第89回は、8年ぶり2回目*1Hanamiについて学ぶ回でした。

Hanami 2.2

y-yagiさんが用意してくれた資料を見ながらワイワイしました。

前回のHanami回は1.0のときに実施したのですが、今回は2.2。1.0のときと比べるとだいぶ様相が変わったな、というのが第一印象。Hanamiのここ8年の細かい動向はウォッチしてなかったのですが、1系のときに試行錯誤があったのではないかと想像しました。

2系でも根本思想自体は変わっておらず、Railsがデフォルトで持っていないたくさんのレイヤを提供しているためアプリケーションが大きくなったときに新しいレイヤを導入する必要がないというのは良い点だと思います。

しかしそれぞれのレイヤを破綻なく協調させるのにはそれなりの腕が必要で、その腕があるのであればRailsでもいい感じに作れるので強い思いがないと採用するのが難しい印象。また、簡単なアプリケーションを作るときにお試しで使ってみる、というのも簡単なアプリケーションではHanamiのよさを感じるのが難しい、というところでやっぱり採用には強い思いが必要。

Hanami 1系のときはdnsimpleがHanamiを使っていた(Hanamiの作者であるLuca Guidiがdnsimpleで働いていた)ので、現場で使いつつ培った知見をフレームワーク側に反映させることができていたのだけれど、2系は採用事例をまだ見かけないので現場の知見をどうやって反映させていけるかというところが気になりポイントでした*2

Hanamiを触りつつドキュメントを読むと、普段のRailsアプリケーション開発だと見かけない概念であるリポジトリ層やDIコンテナやモナドなどを見かけて楽しいのですが、実際これをお仕事で使っていけるかというと、これからのHanamiのブランディングにかかっていると思います。dry-rbやROMと統合してリブランディングを予定しているらしいのでそれに期待しておきます。

次回

次回は5月30日(金)にwasmをお題としてワイワイする予定です。興味のある人はどうぞご参加ください。

*1: 前回のHanami回はこちら Ginza.rb 第46回 おHanamiをしましょ!

*2: どうやら今作っている新しい公式ページはHanamiを使っているらしい けど、恐らくほとんど静的ページなはず

Railsで使うビューテンプレートエンジンのベンチマーク2025

Railsでよく使われるテンプレートエンジンとしてerb(erubi)、haml、 slimがあります。パフォーマンスの観点だけをとりあげたとき、約5年前に パーフェクトRuby on Rails【増補改訂版】 を書いたときには、速い実装を選べば速度差は特にないという認識でした。それを裏付けるベンチマークはこちら↓。

当時のベンチマーク結果

2025年でも結果は変わらないかな?と思い新しくベンチマークを取ってみた結果が次のとおりです。M1 max MBPでベンチマークを取っています。

前提として、hamlのv6以降、hamlitがhamlになったのでhamlitは入れていません。あと個人的に注目しているPhlexを追加しています。

ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24]
Warming up --------------------------------------
       erubi v1.13.1    50.029k i/100ms
         slim v5.2.1    39.765k i/100ms
         haml v6.3.0    47.385k i/100ms
        phlex v2.1.1    12.770k i/100ms
Calculating -------------------------------------
       erubi v1.13.1    482.580k (± 3.0%) i/s    (2.07 μs/i) -      2.451M in   5.084563s
         slim v5.2.1    388.980k (± 4.1%) i/s    (2.57 μs/i) -      1.948M in   5.018968s
         haml v6.3.0    459.842k (± 2.6%) i/s    (2.17 μs/i) -      2.322M in   5.052679s
        phlex v2.1.1    126.474k (± 1.2%) i/s    (7.91 μs/i) -    638.500k in   5.049251s

Comparison:
       erubi v1.13.1:   482580.4 i/s
         haml v6.3.0:   459841.9 i/s - same-ish: difference falls within error
         slim v5.2.1:   388980.0 i/s - 1.24x  slower
        phlex v2.1.1:   126474.0 i/s - 3.82x  slower

erubiとhamlがslimよりも少し速い、という結果になりました。phlexは速度で勝負しているわけではないと思うのでこんなものかなあ。

今後手元で任意のバージョンでベンチマークが欲しくなったタイミングですぐにベンチマークを実行できるようにリポジトリを作っておきました。もしよければみなさんも手元で使ってみてください。

willnet/template-engine-benchmarks

ginza.rb 第88回を開催してKamalとOmakubについて学んだ

Ginza.rb 第88回 - KamalとOmakubについて学ぶぞ - connpass 第88回は、Rails8.0から標準採用されたデプロイツールKamalとDHH製ubuntuセットアップツールOmakubの2本立てでした。

Omakub

Omakubはy-yagiさん進行で The Omakub Manual をみつつワイワイしました。お仕事でRailsアプリケーションを作っている人の9割以上はmacを使っている(要出典)ので、ubuntu用セットアップツールであるOmakubの出番はないと思っている人が大半だと思いますがOmakubが採用しているツールたちは例えばbetter lsのezaだったり今どきのfuzzy finderのfzfだったりで、macでも使えるわけです。このあたりのツールを採用するきっかけとしてOmakubをウォッチするのは悪くないなと思いました。

Kamal

僕がざっくりesaにまとめた内容を見つつワイワイしました。お仕事でそれなりの規模のアプリケーションを運用するのであればやっぱりクラウドのほうが良いんじゃん?という意見がありつつ、一昔前はherokuが担っていた「One Person FrameworkとしてRailsアプリケーションを開発したあとの最初のデプロイ先」として格安のVPSを使えるようになった*1のは間口が広がって素晴らしいですねとなりました。

初学者観点だとPaaSに比べてセキュリティを確保したりホストを運用するのがちょっと難しいかもしれないというのが個人的な気になりポイントで、ホストで公開するポートを絞ったりメモリやファイルシステム容量を監視したりなどのkamalデプロイ前後のベストプラクティスがまとまってくるともっと利用者が増えてくるんじゃないかなと思いました。

会社で使う観点だと例えば37signals社がkamalを採用して年間150万ドルの予算を削減しているらしいけれど、37signals社と同様にクラウドから離れたほうがメリットがある状態(プロダクトや会社の環境)とは具体的にどういう状態なんだろうというのが気になりポイントです。おそらくインフラに強いエンジニアとアプリケーション開発に強いエンジニアが在籍しつつ、webリクエスト数がそれなりに安定していてかつクラウド独自機能が不要なふつうのWebサービスを運営しているというのが大まかな傾向だろうとは思うのですが、どこが損益分岐点なのかな。

合わせて読みたい: Rails: Kamalデプロイツールはゲームチェンジャーになるか?(翻訳)|TechRacho by BPS株式会社

次回

次回は4月4日(金)開催予定です。春なのでHanamiについて学ぼうと思っています。興味のある人は予定を空けておいてください(\( ⁰⊖⁰)/)

*1:前から使えなかったわけではなく、初学者の人でも使いやすくなった