Tech Racho エンジニアの「?」を「!」に。
  • 開発

Ruby: 私たち、もしかしてat_exit?を使いすぎ?(翻訳)

概要

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

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内でのネスト

どちらかというとスタックらしい振る舞いです。この振る舞いが変更されて動かなくなるバグもいくつかあったりしました。

今度は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実行時の挙動はどうなるのか?」皆さんは既に答えをご存知かもしれませんね。

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

capybaraat_exitの直接利用を避けるにはどうしたらよいでしょうか。おそらく、この種のコードは背後のテストスイートに癒着させて、capybara-minitestcapybara-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関連書籍もどうぞ。

関連記事

Ruby: 「マジック」と呼ぶのをやめよう(翻訳)

異色のRubyメソッド: `Module.class_exec`(翻訳)

Ruby: メタプログラミングに役立つフック系メソッド(翻訳)


CONTACT

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