概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Are we abusing at_exit? | Arkency Blog
- 原文公開日: 2013/06/22
- 著者: Robert Pankowecki
- サイト: Arkency Blog
Ruby: 私たち、もしかしてat_exit?を使いすぎ?(翻訳)
Rubyに興味津々の方なら、Kernel#at_exit
というメソッドがあることぐらいとっくにご存知でしょう。もしかすると、このメソッドの存在を知らないまま毎日使っているかもしれませんね。このメソッドはさまざまなgemで多くの問題を解決するのに用いられています。むしろ多すぎるぐらいかもしれません。
初級
at_exit
の基本を少々おさらいしておきましょう。ご存じの方はこのセクションをとっとと飛ばしていただいて構いません。
訳注:
at_exit
はIRBやpryだと書いた瞬間に動いてしまうため、ファイルから実行する必要があります。
puts "start"
at_exit do
puts "inside at_exit"
end
puts "end"
この短いスクリプトからは次が出力されます。
start
end
inside at_exit
もうそのまんまですね。ドキュメントを読めばわかることですが、きっと読んでませんよね。それでは先に進みましょう。
中級
at_exit
とコードの終了
Rubyスクリプトの終了方法はいくつもあります。他のプログラマーは終了ステータスコードを重視しますが、at_exit
ブロックで終了ステータスコードを改変できてしまいます。
puts "start"
at_exit do
puts "inside at_exit"
exit 7
end
puts "end"
exit 0
実際に動かしてみましょう。
> ruby exiting.rb; echo $?
start
end
inside at_exit
7
しかし終了コードは、例外によって暗黙に変更されてしまう可能性があります。
at_exit do
raise "surprise, exception happened inside at_exit"
end
出力はこうです。
> ruby exiting.rb; echo $?
exiting.rb:2:in `block in <main>': surprise, exception happened inside at_exit (RuntimeError)
1
しかしこれを回避する方法があります。終了コードを事前に設定しておけば、変更されなくなります。
at_exit do
raise "surprise, exception happened inside at_exit"
end
exit 0
ご自分でも確かめてみてください。
> ruby exiting.rb; echo $?
exiting.rb:2:in `block in <main>': surprise, exception happened inside at_exit (RuntimeError)
0
しかしこれだけではありません。
at_exit
ハンドラの順序
ドキュメントには「ハンドラが複数登録されると、登録と逆順に実行される」とあります。
さて問題です。以下のコードの実行結果を予測できますか?
puts "開始"
at_exit do
puts "1番目のat_exitの冒頭"
at_exit { puts "1番目のat_exit内でのネスト" }
at_exit { puts "1番目のat_exit内でのもうひとつのネスト" }
puts "1番目のat_exitの末尾"
end
at_exit do
puts "2番目のat_exitの冒頭"
at_exit { puts "2番目のat_exit内でのネスト" }
at_exit { puts "2番目のat_exit内でのもうひとつのネスト"}
puts "2番目のat_exitの末尾"
end
puts "終了"
私の実行結果は次のようになりました。
開始
終了
2番目のat_exitの冒頭
2番目のat_exitの末尾
2番目のat_exit内でのもうひとつのネスト
2番目のat_exit内でのネスト
1番目のat_exitの冒頭
1番目のat_exitの末尾
1番目のat_exit内でのもうひとつのネスト
1番目のat_exit内でのネスト
どちらかというとスタックらしい振る舞いです。この振る舞いが変更されて動かなくなるバグもいくつかあったりしました。
- #5197 at_exit order has changed in 1.9.3dev32413
- #25 MiniTest::Unit.after_tests is executed before tests under ruby 1.9.3.preview
今度はminitest
を見てみましょう。
minitest
at_exit
を用いた例で最もよく知られているのはminitest
です。
メモ: 私の例では、rubygemsからインストールしたminitest-5.0.5
を使っています。
以下はシンプルなminitestファイルです。
# test.rb
gem "minitest"
require "minitest/autorun"
class TestStruct < Minitest::Test
def test_struct
assert_equal "chillout", Struct.new(:name).new("chillout").name
end
end
上はruby test.rb
で実行できます。どうということはありません。しかしここで疑問が持ち上がります。「minitest
をrequireした後でテストが定義されると、minitest実行時の挙動はどうなるのか?」皆さんは既に答えをご存知かもしれませんね。
- minitestは
at_exit
フックをテスト実行のトリガに用いている - かつ、実行されるテストをコレクションするために
inherited
フックを用いている
rspecもat_exit
を使ってるのがわかります。
minitestでのat_exit
の使い方は少々込み入っています。
# プロセス終了時にminitestを実行するように登録
def self.autorun
at_exit {
next if $! and not $!.kind_of? SystemExit
exit_code = nil
at_exit {
@@after_run.reverse_each(&:call)
exit exit_code || false
}
exit_code = Minitest.run ARGV
} unless @@installed_at_exit
@@installed_at_exit = true
end
# シンプルなフックを用いて、すべて実行完了した後に
# コードのブロックを実行する
# 例:
# Minitest.after_run { p $debugging_info }
def self.after_run &block
@@after_run << block
end
しかし、一体全体どんな理由でここでat_exit
を使っているのでしょうか?これは何かのハックでしょうか?皆さんはどう思うかわかりませんが、私はハックがほんのり匂うように感じます。at_exit
を使わずにやるとどうなるか見てみましょう。
gem "minitest"
require "minitest"
class TestStruct < Minitest::Test
def test_struct
assert_equal "chillout", Struct.new(:name).new("chillout").name
end
end
# 何も実行したくない場合はオーバーライドが必要
# (minitest/autrunがどっちみちpride_pluginを読み込むので)
# https://github.com/seattlerb/minitest/blob/f771b23367dc698586f1e794eae83bcb905fa0d8/lib/minitest/pride_plugin.rb#L1
def Minitest.autorun
end
Minitest.run
上のコードは動きます。
> ruby test.rb
Run options: --seed 63193
# Running:
.
Finished in 0.000675s, 1481.4332 runs/s, 1481.4332 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skip
ここから、上述の問題は実は問題ではないのではと想像がつきます。ファイルの末尾に1行書くことで、at_exit
の利用を避けつつspecの実行をトリガできるのですから。しかしこの方法では、複数のファイルについてテストを実行するときに複雑になります。この問題は、短いヘルパーを書くことで解決できます。
gem "minitest"
require "minitest"
require "./test1"
require "./test2"
def Minitest.autorun
end
Minitest.run
しかし今度はテストファイルからMinitest.run
を取り除ける必要があります(複数回実行されないようにするため)。これでは、私たちが慣れ親しんできた従来のruby single_file_test.rb
構文で1つのファイルから複数のテストを実行することは不可能になってしまいます。
ruby helper.rb -- test.rb test2.rb
のように、スクリプトで必要なファイルを引数に応じて動的にrequireする方法もあるといえばあります。検討を進めるうちに、私たちの路線はテストを実行する独自バイナリをビルドする方向に近づきつつあります。
minitestバイナリ
minitest
に現在足りないのはこれだと思っています。テストを実行するバイナリがあれば、テストの場所を指定できるでしょう。唯一の違いは、テストを走らせるためにはruby file_test.rb
ではなくminitest file_test.rb
を実行しなければならないことです。リリースされるバイナリは私たちのプログラムの開始地点と終了地点を兼ねるので、テスト実行のためにat_exit
をトリガする必要がなくなるでしょう。つまるところ、「ファイルAで何かを実行するプログラム」はプログラム名 a.rb
と入力する方が、「RubyはファイルAを実行し、その終了時に、本来達成したいこととはまったく別の何かを行う」と考えるよりもずっと筋が通っているのではないでしょうか。皆さんも同意してくださればと願っています。
Railsアプリはrails
コマンドやunicorn
コマンドやrackup
コマンド(その他皆さんお使いの何らかのWebサーバーでも何でも構いませんが)で起動します。ruby config/environment.rb
でRailsを起動することも、at_exit
フックでWebサーバーを実行することも普通はありません。このアナロジーで示したように、minitest file_test.rb
で実行できる方が私には自然に思えます。
Capybara
しかしat_exit
フックで面白いことをやってるのはminitest
だけではありません。もうひとつの有名な例はcapybara
です。Capybaraはテスト終了時にFirefoxなどのブラウザを終了するのにat_exit
フックを用いています。以下でおわかりのように、なかなか込み入ったロジックになっています。
def browser
unless @browser
@browser = Selenium::WebDriver.for(options[:browser], options.reject { |key,val| SPECIAL_OPTIONS.include?(key) })
main = Process.pid
at_exit do
# テスト実行の終了ステータスを保存する
# (でないとat_exit処理の呼び出し後に消えるので...)
@exit_status = $!.status if $!.is_a?(SystemExit)
quit if Process.pid == main
exit @exit_status if @exit_status # 保存したステータスで強制的にexit
end
end
@browser
end
capybara
でat_exit
の直接利用を避けるにはどうしたらよいでしょうか。おそらく、この種のコードは背後のテストスイートに癒着させて、capybara-minitest
やcapybara-rspec
といった別gemからフックを指定するのがよさそうです。今ならいくつかの主要なフレームワークで可能です。
minitest
ならMinitest.after_run
を使えます。現時点ではat_exit
が使われていますが、minitest
バイナリの末尾で単にこれを手動で実行するような内部実装が今後変更されるかどうかを心配する必要はありません。さらに、Minitest.after_run
は意図が明確になります。rspec
ではafter(:suite)
が使えます。cucumber
では残念ながらat_exit
の直接利用が推奨されています
もちろんat_exit
の方が普遍性が高くなりますし、capybaraはテスト環境以外の場所で使われる可能性もあります。私なら、ブラウザを閉じるぐらいはプログラマーにやってもらっておしまいにするでしょう。
Sinatra
Sinatraは自分自身(アプリ)を実行するのにat_exit
フックを使っています。
結論
私としては、Webブラウザやテストフレームワークのように使用頻度が高く実行時間も長いプロセスは独自のバイナリとカスタムフックを提供して、プログラム終了時にコードを実行できるようにするのが一番ではないかと思っています。これならat_exit
のことなんか全部忘れていつまでも幸せに暮らせるでしょう。私たちのchillout
gemでは、Webサーバーが停止する直前の最後のリクエスト中に集めた統計情報を確定させるのにat_exit
を用いることを検討し、幸いにも私たちのバックエンドに配置できました。このやり方を今後も続けたいかどうかは何とも言えませんが。
追伸
いろいろ申し上げましたが、at_exit
を避けるべき理由をまだ示していませんでしたね。この機能を用いるプロジェクトはどんなものであれ、いずれはこの挙動に関連したバグを踏み、そして回避方法を模索することになるかもしれません。
謝意
私たちが日頃使っている素晴らしいソフトウェアを作ってくださったSeattle Ruby Brigade(特にRyan Davis)およびJonas Nicklasに深い感謝を捧げたいと思います。at_exit
で少々お騒がせしたことを皆様がどうか気になさりませんように😢。
本記事を気に入ってくださった方は、ぜひ私どものRails関連書籍もどうぞ。