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

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

`Thread.current[:hoge]`はスレッドローカル変数を参照していると思いきや実際はファイバーローカル変数だった

このエントリはSmartHR Advent Calendar 2021の23日目の記事です。

SmartHRでは毎週「Rubyist@SmartHR(仮)」という名の定例ミーティング*1が行われています。このミーティングはバックエンドエンジニアが集まり、チームをまたいだ情報共有や相談をすることを目的としています。その中では僕がTipsなどを共有する「willnetさんのありがたいお言葉」というコーナーが常設されています。

このエントリでは、そのコーナーで共有した内容をひとつ紹介します*2

Thread#[]で取得できる値はファイバーローカル変数なのだった

アプリケーションのコードではあまり見かけませんが、ライブラリ中でスレッドセーフを意識している設定を読むと

Thread.current[:locale] = :ja

のようになっているのをよく見かけます。それで僕はThread#[]はスレッドローカル変数を返すのだな、と長年思いこんでいたのですがそれは大きな勘違いでした。Thread#[]=で設定した値はファイバーを切り替えると参照できなくなってしまいます。

検証用コード

require 'fiber'

f = Fiber.new do
  Thread.current[:locale] = :ja
  puts 'in another fiber'
  puts "locale: #{Thread.current[:locale]}"
end

f.resume

puts 'in main fiber'
puts "locale: #{Thread.current[:locale]}"

実行結果

in another fiber
locale: ja
in main fiber
locale: 

ではファイバーを切り替えても同一スレッドであれば同じ値を返すようにしたいときにはどうすればよいでしょうか?

スレッドローカル変数を扱いたいときは別のメソッドを使う

Thread#thread_variable_getThread#thread_variable_setを使います。

検証用コード

require 'fiber'

f = Fiber.new do
  Thread.current.thread_variable_set(:locale, :ja)
  puts 'in another fiber'
  puts "locale: #{Thread.current.thread_variable_get(:locale)}"
end

f.resume

puts 'in main fiber'
puts "locale: #{Thread.current.thread_variable_get(:locale)}"

実行結果

in another fiber
locale: ja
in main fiber
locale: ja

よく読むとるりまにもちゃんと書いてありますね…。

Thread#[]Thread#[]= を用いたスレッド固有の変数は Fiber を切り替えると異なる変数を返す事に注意してください。

それがどうしたんですか?

Rails6.1までは、恐らくスレッドローカル変数のつもりでファイバーローカル変数(Thread#[], Thread#[]=)が使われている箇所がありました。例えばCurrentAttributesの設定値はファイバーローカル変数を使っています。

rails/current_attributes.rb at f0506126cb98616444b359162361d2aaaa329f46 · rails/rails

これは、たとえばpumaのようなスレッドベースのアプリケーションサーバのどこかで複数のファイバーを使っていると問題になります。ファイバーAで設定したファイバーローカル変数は(当たり前ですが)ファイバーBでは見えません。これは期待している挙動ではないでしょう。

いまどきのよくあるRailsアプリケーションでは明示的に複数ファイバーを使うことはあまりなさそうに思えます。が、例えば内部でファイバーを使っているgemに依存性があるかもしれません。また、明示的にファイバーを使わなくてもEnumeratorで外部イテレータを使っているところではファイバーが使われています(参考: class Enumerator (Ruby 3.0.0 リファレンスマニュアル) )。外部イテレータもいまどきのよくあるRailsアプリケーションで明示的に使われることは少なそうですが、これも依存しているgemで採用されている可能性はあります。

下記のコードは外部イテレータでファイバーローカル変数とスレッドローカル変数をアサインしたあとに参照したサンプルコードです。アサインしたはずのファイバーローカル変数が参照できていない事がわかります。

o = Object.new
def o.each
  yield Thread.current[:fiber_local] = 'hi'
  yield Thread.current.thread_variable_set(:thread_local, 'hihi')
end

e = o.to_enum
e.next #=> 'hi'
e.next #=> 'hihi'
Thread.current[:fiber_local] #=> nil
Thread.current.thread_variable_get(:thread_local) #=> 'hihi'

まれに問題になりそうなのはわかったけど…それで?

素直に考えるとRailsの内部でファイバーローカル変数を使っている箇所をスレッドローカル変数に変更すれば良いように思えます。が、みんながスレッドベースのアプリケーションサーバを使っているとは限らないのでした。falconなどのファイバーベースのアプリケーションサーバを使いたい人たちにとってはファイバーローカル変数でないと困ります。

そこで、Rails7.0からは、設定としてスレッドローカル変数を使うかファイバーローカル変数を使うか選択できるようになりました。

Introduce ActiveSupport::IsolatedExecutionState for internal use · rails/rails@540d2f4

デフォルトでは:threadが設定されているので、pumaなどのスレッドベースのサーバを使っている人はRails7.0に上げれば問題は起きなくなりそうです。falconを使いたい人は下記のように明示的にfiberを使うようにする必要があります。

Rails.application.config.active_support.isolation_level = :fiber

そんなすぐRails7.0にあげられないぞ、という人はそれっぽい問題が起きたときにこのことを思い出せるようにしておいてください。そして早く7.0にできるように頑張りましょう。

まとめ

  • Rubyでのスレッドローカル変数とファイバーローカル変数の違い
  • 関連したRailsでの問題と解決の事例

について書きました。

Rails本体に関してはRails7.0以降は問題が起きなさそうですが、他のgemでスレッドローカル変数を意図してThread.current#[]=を使っていそうなところがちらほらあり、ちゃんと考えると何らかの対応(ex: Railsのように設定でスレッドローカルとファイバーローカルを切り替える)をしないといけないんだろうなーという気がしています(が、全てのgemで対応するのはなかなか難しそうですね…)。

*1:バックエンドミーティングやバックエンド定例とも呼ばれています

*2:ちなみに去年のアドベントカレンダーも同じように同じようにありがたいお言葉コーナーからの切り出し記事でした https://blog.willnet.in/entry/2020/12/11/100000

Rack::RuntimeがRailsのデフォルトRackミドルウェアから削除された

Rack::RuntimeというRackミドルウェアがあります。これはリクエストを処理するのにかかった時間を"X-Runtime"というレスポンスヘッダに含める、というものです。コードはこれ↓

rack/runtime.rb at master · rack/rack

これはRailsのデフォルトのRackミドルウェアであり、特に何もしない限り有効になっています。

このX-Runtimeが、タイミング攻撃で使われている事例があったとのことで、デフォルトから削除になりました。

Remove Rack::Runtime and deprecate referencing it · rails/rails@7bfcf4b

これにより、明示的にRack::Runtimeをミドルウェアで使う宣言をしない限りはRack::Runtimeは使えなくなります(Rails7.0から)。

実際にはX-Runtimeの値を見なくても、レスポンスが帰ってくる時間を計測すれば(ネットワークを経由することによる多少の誤差はありつつも)タイミング攻撃はできてしまうのでRack::Runtimeの削除の効果は限定的です。しかし普段の開発で使ってないならRails7.0を待たずに削除しておくのがいいんじゃないかな、と個人的には思っています。↓のコードでデフォルトRackミドルウェアから消せるので、消したい方はこれで消してみてください。

# config/application.rb

config.middleware.delete Rack::Runtime

追記

削除のコミットがrevertされたので結局Rails7.0でも使えます。revertされたのは上の方で書いた

レスポンスが帰ってくる時間を計測すれば(ネットワークを経由することによる多少の誤差はありつつも)タイミング攻撃はできてしまうのでRack::Runtimeの削除の効果は限定的です

が理由のようです

Add back Rack::Runtime to the default middleware stack. · rails/rails@4ace047

Gimeiのバージョン1.0.0をリリースした

から、そろそろ1.0.0にしてもいいんじゃないかな、という気持ちになり1.0.0のリリースに踏み切りました。

ランダムで名前を返す、というコアな機能は最初のリリースからほぼ変わってないのですが

  • 住所も返せる(ex: Gimei.address)
  • テスト内でユニークな名前が返ることが保証できる記法がある(ex: Gimei.unique.name)
  • 名前と住所のデータを遅延読み込みするようになった

などなど、8年で地味に着実に改善が進んでいます。どうぞご利用ください。

willnet/gimei: random Japanese name and address generator

iCARE Dev MeetupでHotwireについて話した

僕がお手伝いしているiCAREさん主催のミートアップ、iCARE Dev Meetupで、最近発表されたBasecamp社製jsフレームワークであるHotwireについて話しました。

【iCARE Dev Meetup #18】技術顧問が語る、Ruby on Rails実践開発 - connpass

動画も公開されているので気になる方は探してみてください*1

所感

弊社サービスであるsavanna.ioはHotwireを使って作っています。Hotwireはつい最近発表されましたが、その前身となるフレームワークであるturbolinksStimulusの組み合わせで数年間開発していました。

なので「サーバサイドはHTMLを返し、jsは最小限」というスタイルが少人数で開発するチームにとてもマッチすることは身を持って体験しています。Vue.jsやReactを採用していたら機能を開発するスピードはもっと遅かったに違いありません。

もちろん適材適所なのでHotwireが適さない分野もありますが、Hotwireが適しているのに無理して他のjsライブラリを使っているプロジェクトはたくさんあるんじゃないかなあ…と推測しています。この発表でちょっとでもそんなプロジェクトが減りみんながハッピーになると嬉しいです(\( ⁰⊖⁰)/)

*1:恥ずかしいのでリンクは貼らない

TurbolinksからTurboへの移行

弊社サービスである savanna.io はずっとTurbolinksStimulusで開発してきたのですが、この度 Hotwireがリリースされた*1のでTurbolinks部分をTurboに置き換えてみました。その際のやったことやハマったことのメモを残しておきます。メモ書きなので雑なのはご容赦ください。

前提

webpackerを使ってます。assets pipeline派や素のwebpackを利用している人は適宜読み替えてください

TurbolinksのアンインストールとTurboのインストール

  • turbo-railsをGemfileに追加してbundle install
  • ./bin/rails turbo:installをする

で問題なくいけるのであればそれで。turbo:installが失敗したらturbo-rails/turbo_with_webpacker.rb と同等のことを手動でやればOKです。savanna.io ではTypeScriptを利用しているためうまくいきませんでした(turbo:install はapp/javascript/application.jsがあることを前提としている)。

turbolinksをturboに置換する

document.addEventListener('turbolinks:load', ...のようにしている箇所をdocument.addEventListener('turbo:load', ...に置換します。

turbolinks-cache-controlなどは、Turboのドキュメントには書いてありませんがTurboのコードをみたらturbolinksの箇所をturboに書き換えるだけで問題なさそうだったのでそのようにしたら動作しました。多分turbolinksでできることはturboのドキュメントに書いてなくてもできるんじゃないかな…(未確認)。

OAuthログインの対応

savanna.ioはOAuthによるログインに対応しています。omniauthのCSRF脆弱性に対応できるように、しばらく前から次のようにPOSTを利用するようにしていました。

= link_to 'twitterにログイン', '/auth/twitter', method: :post

このコードで生成したリンクにアクセスするとうまく遷移できません。これは、Turboからはformも非同期対象になったのが原因です。twitterのログイン用URLをfetchしようとしてpreflightリクエストでエラーになっていました。

Turboはdata-turbo=false属性をつけることで特定のリンクやformをTurbo非対応にすることができます。

しかし、上記のlink_toにmethodオプションをつけたものについてはうまくdata-turbo=false属性をつけることができません。methodオプションがついているリンクをクリックするとrails-ujsが自動的にformを作りsubmitする、という実装になっています。つまり自動で生成されるformに対してdata-turbo=falseを付ける必要があるのですが、これを行うためのAPIがありません。

なのでlink_toをbutton_toに変更して対応しました。

= button_to('/auth/twitter') { 'Twitterでログイン'  }

バリデーションエラー時の対応

TurboはTurbolinksでは未対応だったformのsubmitに対応しています。data-remote=trueなformでなくとも自動でfetchを使い非同期でPOSTします。

バリデーションエラー時に422などのステータスコードを返すと、URLはそのままの状態で画面の差し替えが行われます。つまり普通に(scaffoldで生成したようなアクションの形で)バリデーションエラーのメッセージを含む画面を描画できるようになりました。

Turbo方式とTurbolinks方式を混在させる

savanna.io では ajax_error_rendererというgemを使いSJRでエラーメッセージを差し込む、ということをやっているのでそれを普通のエラーメッセージ用のHTMLを返すように変更する必要があります。

すべてのアクションを一気に変更するのは大変なので、徐々に切り替えられるように、Turbo方式とTurbolinks方式を混在させられるような仕組みを作りました。

Turbolinksはrails-ujsによるajaxでPOSTをする想定です。そのときredirectをしたらTurbolinks.visit()を実行するようにサーバからレスポンスを返します。これをturbolinks-railsというgemが実現していました。次のコードは、turbolinks-railsと同等のことをTurboで実現するようにしています。このように書くと、data-remote=trueなformからPOSTしたときはturbolinksの挙動で、それ以外はTurboの挙動になります。

# ApplicationController
  def redirect_to(...)
    super.tap do
      visit_location_with_turbo(location) if request.xhr? && !request.get? && !request.format.turbo_stream?
    end
  end

  def visit_location_with_turbo(location)
    visit_options = { action: 'replace' }

    script = []
    script << 'Turbo.clearCache()'
    script << "Turbo.visit(#{location.to_json}, #{visit_options.to_json})"

    self.status = 200
    self.response_body = script.join("\n")
    response.content_type = 'text/javascript'
    response.headers['X-Xhr-Redirect'] = location
  end

request.format.turbo_stream?がtrueのときはTurboからのリクエスト経由のリダイレクトなのでなにもしない(通常のredirect_to)ようにしています。

// application.ts
import { Turbo } from '@hotwired/turbo-rails'
window.Turbo = Turbo

Turboはwebpack環境ではglobalではないので明示的にglobal変数として扱えるようにする必要があります。

Turboではバリデーションエラー時にturbo:loadが発火しない

これであとは頑張ってバリデーションエラー時の処理を置き換えていくだけ…と思いきやそんなことはありません><バリデーションエラー時にturbo:loadが発火しないという現象に遭遇しました。https://github.com/hotwired/turbo/issues/85 を見た限りこれは仕様とのことです…。

savanna.ioではturbo:loadがすべてのページ表示のタイミングで発火するのを期待しているコードがあったので、検討した結果、上記の「Turbo方式とTurbolinks方式を混在させる」で書いたやり方は不採用にしました*2

結局、turbo-streamを利用するとajax_error_rendererと使用感がほぼ同じになるのでturbo-streamでエラーメッセージを差し込むようにしました。次のようにしてrender_errors_by_turbo_streamを呼び出すと<div id="erorr"></div>にエラーメッセージが差し込まれます。

  def render_errors_by_turbo_stream(model:)
    render turbo_stream: turbo_stream.update(:error, partial: 'errors', locals: { model: model }),
           status: :unprocessable_entity
  end
- unless model.errors.empty?
  #error_explanation.alert.alert-danger.error-messages
    %ul
      - model.errors.full_messages.each do |msg|
        %li= msg

[コラム]バリデーションエラー時のリロードの処理

バリデーションエラー時にブラウザをリロードしたときの挙動はTurbo使用時とそうでないときとで違いがあります。Turbo使用時はformのページがリロードされます。Turbo未使用時は「フォームの内容を再送しますか?」といったダイアログが表示されます。

これはHistroy APIを利用している以上仕方がない*3ですが、Turbolinksのときと比べるとだいぶ改善された感じがあります。Turbolinks利用時かつdata-remote=trueなしなフォームのときは、newアクションのformにいるはずがリロードするとindexアクションにアクセスしてしまう、という事が起きていました。

参考: Turbolinks5でPOSTするときはajax経由のほうが良いのかも - おもしろwebサービス開発日記チラシの裏

data-disable-withに対応する

turbo-streamを使うと動的にエラーメッセージを差し込むことができてめでたしめでたし、かと思いきや、バリデーションエラー時にsubmitボタンがdisableのまま、という状況に遭遇しました。

form_withなどでajaxを利用してPOSTしたとき、ajax終了時に自動でdisable状態を解除するようにrails-ujsで定義されています。turboはrails-ujsを利用していないので、独自で対応する必要があります。次のように、フォームsubmit後のバリデーションエラーで、rails-ujsのdisable状態を解除するためのメソッドを呼び出すようにしました。

document.addEventListener('turbo:submit-end', (event: Event) => {
  if (!event.detail.success) {
    Rails.enableElement(event.detail.formSubmission.formElement)
  }
})

assets precompileの対象から外す

turbo-railsは今のところassets precompileできるなら、turboをその対象として自動で挿入するという仕様になっています。savanna.io は基本webpackerなのですがassetsを使っているgem(administrate)があるのでassets pipelineはオフにしていません。そしてturbo-railsが提供しているjsはES6の書き方なので、本番デプロイ時にuglifierによる圧縮処理で失敗しました。そもそもassetsとして提供されているjsは使わないので、次のようにして対処しました。

# config/application.rb

config.after_initialize do
  config.assets.precompile -= ['turbo']
end

その他turbolinksやrails-ujsに依存している処理を書き換える

あとは細かい変更なので概要だけ書いておきます。

  • turbolinks化でgoogle-analyticsを使うために google-analytics-turbolinkというgemを使っていたのをやめた
  • 「保存されていない変更があります。移動してよろしいですか?」を実装するためにrails-ujsのイベントをhookしていたのをturboのイベントにフックするようにした

まとめ

  • ここまで見てきたように、TurbolinksからTurboの移行に関してはそれなりにハマりどころがあります
  • ただ明らかにTurbolinks時代から改善が進んでいて体験が良くなっているので、気になっている人は一度試してみるのをおすすめします

*1:厳密には現時点で7.0.0-beta.4です

*2:便利に使う人もいるかと思うので書き残しています

*3:History APIはGETしか対応していない

Railsアプリケーションのテストが失敗したときにどうしたらいいのか

本エントリはiCARE Advent Calendar 2020の25日目です。

僕はiCARE社内で技術顧問としていろんなことをやっていますが、そのうちの一つとしてRailsアプリケーションのテスト改善があります。具体的には「たまに失敗するテスト」で難しいものがあったときに調査して解決をしています。この「たまに失敗するテスト」はiCAREに限らず、ほとんどの会社が苦しめられているのではないでしょうか。僕のお手伝いしている他の会社でも同様なので、複数社の社内ドキュメントツールに「こういうふうに調査するといいですよ」という文章を書いています。しかしこれらはどれも社内wikiどまりで、現時点で公開されている文章が存在していません。

そこで今回この場を借り「失敗したテストがあったときにどうしたらいいのか」の決定版を書いて、今後は「これ読んでおいてください」で済ませたいなと思っています。

前提

RailsアプリケーションのテストをRSpec、Capybara、Selenium、headless chromeを利用して書いている

まず、悩むのをやめよう

テストが失敗したとき、その原因になり得るものはたくさんあります。

  • テストコードが悪い
  • アプリケーションコードが悪い
  • テストの環境が悪い

テストコードやアプリケーションコードそのものが原因であれば比較的簡単に修正することができるはずです。しかし、それ以外に原因があったときに悩んでしまう人が多い気がします。しかし悩んでも解決することはまずありません。大事なのは必要な情報を収集することです。悩む前にありったけの情報をかき集めましょう。

  • binding.prybinding.irbbyebugなどのブレークポイントを活用し、該当するテストコードやアプリケーションコードが想定通り動いているかを確認する

  • E2Eのテストであれば、スクリーンショットをとりテスト失敗時の画面の状況を見る

  • テスト用のログ(例: log/test.log)を確認してサーバが想定通りリクエストを受け取り、レスポンスを返しているかを確認する
  • CIでたまに失敗するテストであれば、ローカルで再現するか試してみる
    • 例えば次のように書いて rspec --fail-fast ファイル名:行番号 とすると1000回テストを実行して最初に失敗したところで止まります。これが全部通るのであれば、順番依存などで失敗するテストの可能性が高いです。
1000.times do
  it do
    # たまに失敗するテスト
  end
end

テストがどのように動いているかを学ぼう

ログやスクリーンショットを見てもよくわからん、となるとき、そもそもテストがどう動いているかについてよく知らないのであたりがつけられない可能性があります。テストはあくまで本番を模して確認するためのもので、本番とは違ったテクニックで実現されています。それらのテクニックを把握してると、失敗の原因を推測できる可能性が高まります(もちろん本番環境がどのように動いているのか知るのも大事です)。

以下抑えておいてほしいテストの仕組みについて書いています。

テストの設定を知ろう

テストは基本的に次の3つのファイルで設定されているはずです。特に「開発環境だとちゃんと動いているように見えるんだけどテストだとなぜかうまくいかない」ようなケースではtest.rbとdevelopment.rbをみて、どのような差分があるかチェックしてみるとよさそうです。

  • config/environments/test.rb
  • spec/rails_helper.rb
  • spec/spec_helper.rb

例えばActive Jobは各環境で別々のアダプタを使っている事が多いです。本番時にはsidekiqを利用するけれど、開発時にはasyncアダプタ(スレッドを利用した非同期実行)を利用し、テスト時にはtestアダプタ(テスト用のキューにジョブの情報を入れるだけでジョブ自体は実行しない)を選択するような設定が一般的です。

このように、テストを実行するために本番や開発時とは違う形にしている箇所はいくつかあるはず。思わぬ箇所の設定が影響していることがあるので、設定のそれぞれの行がなにを表しているのか説明できるようになっているのが望ましいです(大変だけど)。

各種設定の内容はRailsガイドにまとまっています。読んだけどよくわからない、という場合はお近くの技術顧問に質問ください。

テスト中に行った変更は必ずもとに戻そう

複数のテストを実行するときは、それぞれ独立した環境でテストを実行したいところです。例えばあるテスト中にレコードをinsertしたら、テスト終了時に何らかの形でDBをロールバックしてから次のテストを実行しないとテストが不安定になってしまいます。

RailsはデフォルトでDBのトランザクションを利用してロールバックする仕組みを持っています。最近のRailsプロジェクトだとすべてその方式(トランザクションを利用してロールバックする)で統一できますが、昔のRailsプロジェクトだとfeature spec用にdatabase_cleanerdatabase_rewinderを利用してDBを毎回空にして対応しているケースもあります(詳細はコラム参照のこと)。

つまり、DBに関しては特に意識しなくても自動で元の状態に戻るようなっているはずです。またrspec-mocksによるモックの設定などもテスト終了時に自動でもとに戻してくれています。べんりですね。

しかし、ツール側で対応していないものは手動で元に戻さないといけません。例えば、csvファイルを出力するメソッドをテストする場合、作ったcsvファイルをテスト終了時に削除しないとファイルがずっと残ってしまいます。そのcsvファイルの有無で挙動が変わるようなアプリケーションコードがあったら、別のテストが失敗してしまうかもしれませんね。

このように、テスト中の変更をもとに戻さないと順番依存で失敗するテストが出来上がるので、意識して変更をもとに戻すようにしましょう。順番依存で失敗するテストは再現がなかなか難しいです。機械的に順番依存失敗テストを再現させる方法としてはrspec の --bisectオプションがあります(ローカルで大量のテストを--bisectするのは骨が折れるので、実際に適用できるケースは少なそうですが)。

参考: Rails tips: ランダムにコケるRSpecテストの修正に便利なbisect(翻訳)

[コラム] feature specと system specの違い

feature specでは、テストコード用のスレッドとテストサーバのスレッドが異なります。結果としてDBのコネクションが別々になり、テストコード用のスレッドでトランザクションを利用するとテストサーバ側のスレッドはそれを参照できません。つまりトランザクションによるロールバックの仕組みを利用できないことになります。なのでfeature specではそれぞれの変更を都度COMMITし、テスト終了時にTRUNCATE文やDELETE文でレコードを削除するのが通例でした。それを自動で行うためにdatabase_cleanerやdatabase_rewinderのgemが使われていました。Rails 5.1から利用できるsystem specではこの仕組をハックして各スレッド間で同一コネクションを使えるようにして、E2Eのテストでもトランザクションを利用できるようにしています。そのため、system specではdatabase_cleanerやdatabase_rewinderは必須ではなくなっています。

E2Eテストの仕組みを知ろう

E2Eのテストは実際にブラウザを動かしてテストするため他のテストよりも複雑で、デバッグも大変なことが多いです。まず、昨今の一般的なRailsのE2Eテストではheadless chromeを利用することが多いと思いますが、その際には次の3つのプロセスが起動します(アプリケーションサーバにpumaを使用していると仮定)。

  • capybara(selenium driver)とpuma
    • ※ capybara(selenium driver)とpumaは同一プロセスだが別スレッドで動いている
  • chrome driver
  • chrome

capybara がchrome driverのAPIを叩き、chrome driver が chrome のAPIを叩き、chromeはpumaにアクセスするという流れ。

それぞれが独立したプロセスで動いているので、テストを実行している最中に前のテスト関連のリクエストがpumaに届く、というケースがあります。ajaxで非同期にリクエストを投げる画面で起こりやすいです。これが原因でテストが時々落ちることがあります。

たとえばdeviseを利用しているプロジェクトでrequest specを実行するときに、deviseが提供するsign_inというヘルパメソッドを利用してログイン済み状態のリクエストを作ることがあります。このsign_inメソッドは、「次のリクエストをログイン済み状態として扱う」という挙動をします。これと先ほど説明した「テスト中に前のテストのリクエストが届く」を組み合わせるとたまに失敗するテストの一丁上がりです。前のテストのリクエストでsign_inメソッドによるログイン済み状態が解除され、request specが投げるリクエストが未ログイン状態になりテストが失敗します。

[コラム] chrome driverとchromeのバージョンを合わせる

chrome driverはバージョンごとにサポートしているchromeバージョンが違うので、ここが一致していないとE2Eのテストが全部失敗したりします。webdrivers gemを利用するとテスト実行時に自動でインストール済みのchromeに合ったバージョンのchrome driverをダウンロードしてくれるので積極的に使いましょう。

ただしwebdriversは並列テスト時に同時にchrome driverをダウンロードしようとして挙動がおかしくなることがあります。そのようなケースでは並列テストを実行する前に明示的にWebdrivers::Chromedriver.updateを実行してchrome driverをダウンロードすると回避できます。

capybaraの要素が現れる(or 消える)のを自動で待ってくれるのを知ろう

E2Eテストで問題になるのはajaxを利用している箇所とアニメーションを利用している箇所が多いのではないかと思います。

何かをクリックしたときにJavaScriptによって新しい要素が表示され、その新しい要素をクリックしたい場合は通常と変わらずclick_on '新しい要素'のように書きます。このときAPIを実行するなどでJavaScript中の処理が遅くclick_on実行時に新しい要素が表示されていないような場合、capybaraは一定時間待って再度要素を探す、ということを繰り返します。最大でCapybara.default_max_wait_time秒待ちます。デフォルトでは2秒ですが、5秒などにしているプロジェクトも多いのではないでしょうか。

これだけみるとJavaScript(ajax)を利用した画面でもなにも考えずにテストできるように思えますが、そんなことはありません。明示的に、何かが起きるのを待つためのコードを挿入しないとテストが失敗するケースがあります。

例えばモーダルダイアログがリンクの上に表示されているようなケースで「モーダルを閉じるボタンをクリックする→リンクをクリックする」という処理を書いたとします。このときはモーダルを閉じるときにすでにリンク自体は存在するので、先程書いた「要素が現れるのを待つ」は実行されません。なのでモーダルを閉じるのに時間がかかると、次のリンククリックに失敗します(上にモーダルが残っているのでクリックできない)。そのようなケースでは、リンクをクリックする前にモーダルが閉じられていることを明示的に確認し、もしモーダルが閉じられていなかったら待つようにするとうまくいくでしょう。例えば page.has_no_css?('.modal') のように書くとそれができます。

関連: capybaraの公式ドキュメント

capybara(selenium)によるクリックの仕組みを知ろう

capybaraのクリックの挙動も知っておく必要があります。capybaraは要素をクリックするときに

  1. 要素の座標を取得する
  2. 取得した座標をクリックする

という手順をとります。このとき1と2の間にタイムラグがあるため、そのときに

  • cssアニメーションの途中である
  • 画像ローディングの途中である

などの理由により要素が移動するとクリックに失敗します。

cssアニメーションをオフにする機能はcapybaraが用意してくれています(Capybara.disable_animation = true)。これはcssアニメーションをオフにするstyleタグをRackミドルウェアを利用してすべてのページに挿入することで実現しています。ただし、あくまでこのようなscriptやstylesheetがテスト中のHTML中に差し込まれるだけなので、ここでオフにしている以外の方法でアニメーションを実現していたら別の方法が必要です。

画像のローディングに関しては特に公式のサポートはないので、↓のようなヘルパメソッドを定義して必要なところで呼び出してあげるとよいでしょう。

  def wait_for_image_loading
    Timeout.timeout(Capybara.default_max_wait_time) do
      sleep 0.5 until evaluate_script(<<~JS)
        Array.prototype.every.call(
          document.querySelectorAll('img'),
          (e) => e.complete
        )
      JS
    end
  end

まとめ

ここまでテスト固有のTipsについて書いてきました。

しかしRailsアプリケーションのデバッグに必要な知識はこれにとどまりません。Rubyそのものに対する知識も必要ですし、Railsを始めとしたgemのソースコードを読み解かなければ解決できない問題に遭遇することもあるでしょう。他にもプロセス、スレッド、ソケットやJavaScriptなど、Railsアプリケーションが関わっている技術の知識も欲しいところです。

範囲が膨大で大変ですが、近道はないので少しずつ勉強してきましょう。僕もがんばります。

APIに利用制限をかけるとしたらどういうやりかたがあるのか

この記事はSmartHR Advent Calendar 2020 11日目の記事です。

僕のお手伝いしているSmartHRでは、毎週バックエンドエンジニアが集まり、技術的なトピックについて共有、相談しあうミーティングを開催しています。そのミーティングでは僕がTipsなどを共有するコーナーが常設されています*1

このエントリでは、そのコーナーで共有した内容をひとつ紹介します。

APIに制限をかける方法について

APIを外部に提供するとき、一定の制限をかけてユーザがAPIを乱用するのを防ぐことはよくあることではないでしょうか。素直に考えると「1時間に5000回までAPIを実行できる」のようなやり方を思いつきますね。GitHubのAPIもそのやり方ですし、SmartHRのAPIも同様です。

じゃあそれでいいのでは。となるかもしれませんが少し待ってください。いろんなクライアントがAPIを大量に叩くことを想像してみてください。12:00、13:00、14:00 のような時間ごとにAPI回数をリセットすると仮定すると、例えば12:00~12:01の間に大量にアクセスが来てそれぞれのクライアントがリミットに達し、残りの59分間はほとんどリクエストが来ない、みたいなことになりそうです。そうすると1分間の爆発的なアクセスのためだけにサーバを増強しなければなりません。

もうちょっとアクセスが均等になるような制限が欲しいと思いませんか?そこでLeaky Bucketアルゴリズムの登場です。

Leaky Bucketアルゴリズムとは

名前の通りAPIの利用を「穴の空いているバケツに水を入れる」と考えます。

次のようなアルゴリズムです。

  • APIを利用するとバケツに水が入ります
  • これ以上水を入れるとバケツから水があふれる!というときはAPIを利用することはできません
  • バケツには穴が空いており、時間ごとに水が減っていきます

このような制限をかけることでクライアントに「ちょっとずつAPIを使う」ことを強いることができます。

ではこれをどうやって実装すると良いでしょうか。素朴に実装しようとすると、クライアントごとにバケツの水量を記録しておき、API利用時にそれを増加させ、一定時間ごとに減少させるような形になるでしょう。しかしそれだと「一定時間ごとに減少させる」のコストが高くなってしまいます。

例えば「1分毎に各クライアントの水を100減らす」という実装をしたとします。このときクライアントの数が多くなると、1分ではすべての処理が完了しないかもしれません。つまりこの実装はクライアントが多い場合は現実的ではありません。

同等のアルゴリズムで「一定時間ごとに減少させる」を不要にしたのがGCRA(Generic Cell Rate Algorithm)です

GCRA

GCRAは計算不要で自動的に増える値である「現在時間」を使って、時間を計算することでAPIの利用可否を判断します。

GCRAは水ではなくセルという名前をつかいますが、アルゴリズム的には同じです。他にもいくつか覚えないといけない用語があります。

用語名 内容
理論到着時間(TAT) Tごとにセルが到着すると仮定したときの到着時間 。APIが実行されるたびにTの分加算される。現在時刻が加算前のTATより大きい場合もしくは初回は現在時刻+TがTATになる
T セルが放出される間隔
τ バケットの時間的な最大容量(バケットの最大容量 * T)

この用語でアルゴリズムを説明すると、「TAT-τが現在時刻以下であればAPI利用可能」となります。

なるほどわからん

具体的な数値をいれないとよくわからないですよね。仮の数値を入れて考えてみましょう。

前提

  • バケットの最大容量2セル
  • 1分毎に1のセルが放出される
    • つまりτは2分となる
  • APIをそれぞれ12:00:00, 12:00:01, 12:00:02に実行した

12:00:00のAPI実行時

  • TATは12:01(現在時刻+T)
  • TAT - τが12:01 - 2分 = 11:59となり、現在時刻(12:00:00)より前なのでAPI実行可能

12:00:01のAPI実行時

  • TATは12:02
  • TAT - τが12:02 - 2分 = 12:00となり、現在時刻(12:00:01)より前なのでAPI実行可能

12:00:02のAPI実行時

  • TATは12:03
  • TAT - τが 12:03 - 2分 = 12:01となり、現在時刻(12:00:02)より後なのでAPI実行不可。実行するには12:01まで待つ必要がある

まだわからない

「なんかいい感じに均等にAPI利用制限できるアルゴリズムがあり、その名前はleakey bucket(もしくはGCRA)である」と覚えておいてください

使ってみたいけどイチから実装するのめんどくさいのでgemないですか

Sidekiq Enterpriseのv2.2から、GCRAのアルゴリズムで制限をかける機能が使えるようになっています。もともとSidekiq Enterpriseの機能として利用制限をかけるものはあったのですが、それにGCRA(leaky bucket)用のアルゴリズムが追加された形です。この機能はsidekiqのワーカ以外でも使うことができます。

次のようなコード(The Leaky Bucket rate limiter | Mike Perhamから引用)をRack Middlewareとして利用すると簡単にAPIサーバにleaky bucketな制限をかけることができます。

class LeakyLimiter
  def initialize(app)
    @app = app
  end

  def call(env)
    remote_ip = env["REMOTE_ADDR"].tr(':', '_')
    begin
      limiter = Sidekiq::Limiter.leaky("ip-#{remote_ip}", 10, 10, wait_timeout: 0)
      limiter.within_limit do
        @app.call(env)
      end
    rescue Sidekiq::Limiter::OverLimit => ex
      [429, {
        "Content-Type" => "text/plain",
        "X-Rate-Limited-For" => ex.limiter.next_drip.to_s,
      }, ["Rate limited"]]
    end
  end
end

もしこれから外部にAPIを公開していくぞ!となったときにこの話を思い出してみてください。

まとめ

だいたいこのような感じで毎週なにかしら共有をしています。自分も参加してみたい!という方はこちらのリンクからどうぞ。

*1:毎週ネタをひねり出すのに苦労しています><