Rubyのシングルトンインスタンスを再読み込みする裏技(翻訳)
既にご存知かと思いますが、RubyではいわゆるシングルトンパターンをSingleton
モジュールで実装しています。技術的には、このSingleton
モジュールをinclude
するクラスは、アプリケーションが起動してから終了するまでのライフサイクルにおいて、生成されるインスタンスが常に1個だけになります。
Singleton
モジュールの最も一般的な用途としては、ある種のコンフィグ用オブジェクトやログ出力、ある種のグローバルなサードパーティクライアントなどがあります。
RubyのSingleton
モジュールは、new
メソッドやallocate
メソッドをクラスレベルで隠蔽することで、Singleton
モジュールをinclude
したクラスで新しいインスタンスを作成できないようにするとともに、そのクラスのextend_object
メソッドも未定義に戻します。
また、インスタンスをclone
メソッドやdup
メソッドで複製しようとしても例外が発生するようになります。
Singleton
モジュールをinclude
したクラスでは、最初にinstance
メソッドが呼び出されたときにシングルトンインスタンスが作成され、アプリケーションが起動してから終了するまでのライフサイクルを通じて内部的に維持されるようになります。
しかし、特定のシングルトンのインスタンスについてのみ、どうしても再インスタンス化しなければならなくなったときは、どうしたらよいでしょうか?
数日前の私は、複数の顧客が利用しているシングルトンパターンを用いたライブラリを、Railsアプリケーションの新しい内部実装に移行するという作業に携わっていました。
移行前の実装では、ターゲットURLを生成するロジックを実装しており、そのロジックでは、環境ごとの設定を保存したYAMLファイルが使われていました。特定の環境でコードを実行するなら、これで十分でしたが、移行後も既存のproduction環境の設定が100%壊れないようにしておきたかったのです。
当初の構想はいたってシンプルでした。
すべての顧客向けにproduction環境で生成されるべきターゲットURLを生成し、「Rails環境をスタブ化して、test環境であえてproduction
を返す」テストケースを作成し、内部実装の変更後に顧客の環境で生成されるURLが正しいかどうかをチェックするというものでした。
理論上はこれで動作するはずで、実際、テストを個別に実行していたときにはうまくいきました。しかしテストスイート全体を実行すると、シングルトンクラスのインスタンスを利用している最初のテストによって、テストスイート全体のステートが設定されてしまっていることに気付きました。つまり、別のどれかの顧客の環境のインスタンスを使う他のテストより前に呼び出されないとテストが成功せず、さらに悪いことに、顧客の環境でのテスト以後に実行されるすべてのテストケースで、(test環境ではなく)production環境のようなステートのインスタンスが返されます。
このときの私は、どうにかしてシングルトンクラスを再インスタンス化しなければならなくなったのです。さもなければ、移行用の1個のテストを実行するためだけに別のテストスイートを実行しなければならなくなります。
手始めにRubyシングルトンのソースコードを調べてみたところ、ドキュメント化されていない__init__
メソッドを見つけたのです。これがまさに、そのときの私にぜひとも必要だったのでした。
このメソッドは、インスタンスを削除して(nil
に設定)、スレッド安全用に新しいミューテックスを作成する形で、シングルトンクラスのステートをリセットします。これで、次回シングルトンクラスのinstance
メソッドが呼び出されると、新しいインスタンスが作成されます。
このおかげで、テストの前にRails envをスタブで塞いでシングルトンインスタンスをリセットし、後はテストの実行後にenvスタブを削除してシングルトンインスタンスを再度リセットすることを忘れないように、テストのステージを設定するだけで済むようになったのです。
RSpec.describe "Clients migration" do
before { setup_env('production') }
after { setup_env('test') }
it 'generates the correct URL for client instance' do
# ...
end
def setup_env(env)
Rails.env = env
Singleton.__init__(MyClient)
Singleton.__init__(AnotherClient)
# ...
end
end
この方法のおかげで、内部実装を安全に移行するとともに、以前と完全に同じ振る舞いになることをテストできるようになりました。
🔗 シングルトンクラスをclone
する
Singletonモジュールをinclude
したクラスには、__init__
を呼び出すクラスレベルのclone
メソッドも追加されます。しかしこれは、既存のステートをリセットするのではなく、新しいステートを持つ無名の新しいシングルトンクラスを返すので、通常のclone
メソッドと振る舞いが若干異なる点にご注意ください。
class Timer
include Singleton
attr_reader :timestamp
def initialize
@timestamp = Time.now
end
end
Timer.instance.timestamp #=> 2025-07-04 09:30:36.98988 +0200
Timer.clone.instance.timestamp #=> 2025-07-04 09:30:57.770478 +0200
Timer.instance.timestamp #=> 2025-07-04 09:30:36.98988 +0200
Singleton.__init__(Timer).instance.timestamp #=> 2025-07-04 09:31:42.419874 +0200
Timer.instance.timestamp #=> 2025-07-04 09:31:42.419874 +0200
私の経験では、本記事で紹介しているようなSingleton
パターンの使い所はあまりありませんが、何らかの理由でシングルトンインスタンスをリセットしなければならないようなユースケースに遭遇した場合は、Singleton::__init__
が役に立つことがあるかもしれません。
関連記事
Rails: new_framework_defaultsの設定が反映されるタイミングを無邪気に信じてはいけない(翻訳)
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: module
Singleton
(Ruby 3.4 リファレンスマニュアル)__init__
はAPIドキュメントがないため、今後変わる可能性があります__init__
をあくまでテスト用に使っていますが、production環境での利用は避けるべきと思われます__init__
は、マルチスレッドでの利用に注意が必要と思われます