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__は、マルチスレッドでの利用に注意が必要と思われます