こんにちは、システム開発部のちょうです。今回はあるエラーから1つ小さいなライブラリを作った話について共有したいと思います。
先月の開発でまれに「A copy of xxx has been removed from the module tree but is still active!」というエラーに遭遇しまして、一回あったらRailsアプリを再起動しないといけない状態になります。回数が少ないですが、開発の邪魔になるので、少し調べました。
- ruby on rails - A copy of xxx has been removed from the module tree but is still active - Stack Overflow
- unloadableという不思議なメソッド - sugilogのブログ
ここまでわかったのは、
- 解決策1つめ、Fooを::Fooに変更
- 本番環境は出ない(自分の経験でも開発環境以外見たことはない)
- エラーメッセージによると、モジュールは削除されましたがまだ使われてる(正直ピンとこない)
ひとまず、解決策はわかったので、使ったら元々エラーになった箇所も確実に直りました。でもたまには別のところで同じエラーで怒られました。すべてのクラス名をフルネームで書き直すのは煩雑な作業になるから、やはり根本的な原因を探さないといけないと思いました。
エラーメッセージの一部をキーワードとしてプロジェクトとライブラリ内で検索してみたら、ActiveSupport::Dependenciesのソースコードでこのエラーメッセージが見つかりました。
module ActiveSupport module Dependencies def load_missing_constant(from_mod, const_name) # ... unless qualified_const_defined?(from_mod.name) && Inflector.constantize(from_mod.name).equal?(from_mod) raise ArgumentError, "A copy of #{from_mod} has been removed from the module tree but is still active!" end # ... end end end
breakpointを入れて開発環境で何回debugしてみましたが、残念ながら、一回も再現できませんでした…
一方、エラーメッセージでもっと調べた結果、このブログが見つかり、再現できる方法が分かりました。
# /autoloadable/money.rb # class Money # end # /autoloadable/customer.rb # class Customer # def money # Money.new # end # end customer = Customer.new ActiveSupport::Dependencies.clear customer.money
まとめてみると、その手順は
- インスタンス作成
- ActiveSupport::Dependenciesがクリアされ(開発環境ではコントロールやモデルファイルを弄るとかで発生する)
- インスタンスのメソッドで別のクラスを呼び出す
になります。
確かに、自分が開発しているRailsアプリでは、initializerにクラスのインスタンスを作るコードがありました。そして、
Autoloading and Reloading Constants — Ruby on Rails Guides
によると、initializerにインスタンスを初期化はおすすめしません。(正確にいえば、autoloadパスにあるクラスのインスタンスです。ライブラリのクラスは対象外です。)
つまり、根本的な解決策はinitializerにインスタンスを作成しないってことでしょうか。あるいは上のガイドで書かれたdynamic access point、つまりXXX.instanceを使えばいいのでしょうか。
正直開発環境のautoloadでinitializerにインスタンスを作れないのはあまり納得できないです。他の環境なら普通に使えますし、これによってXXX.instanceだらけなコードになるのもよろしくないと思います。すでに::Fooという解決策がいますので、他の解決策もあるはずです。
例えば、
customer = Customer.new ActiveSupport::Dependencies.clear customer = Customer.new customer.money
ActiveSupport::Dependenciesがクリアされる時、同時にインスタンスを作り直します。
この仕組みを実現するには、
- ActiveSupport::Dependenciesがクリアされるタイミング
- インスタンスを作成するロジックを持ってないといけない
が必要になります。
一番はとくに複雑ではないです。Railsでは、ActionDispatch::Reloaderという機能が使えます。ActiveSupport::Dependenciesがクリアされた後実行されます。
unless Rails.configuration.cache_classes ActionDispatch::Reloader.to_prepare do end end
もう一つの機能は自分で書こうと思います。名付けて「ObjectContainer」
class ObjectContainer attr_reader :block def initialize(object_mapping, &block) @object_mapping = object_mapping @block = block end def [](key) @object_mapping[key] end class << self def configure(&block) object_mapping = {} block.call(object_mapping) @instance = ObjectContainer.new(object_mapping, &block) end def instance @instance end def reload! container = instance return if container.nil? configure(&container.block) end end end class Money end class Customer def initialize puts 'create Customer' end def money Money.new end end ObjectContainer.configure do |mapping| mapping[:customer1] = Customer.new end puts ObjectContainer.instance[:customer1].money ObjectContainer.reload! puts ObjectContainer.instance[:customer1].money
出力
create Customer #<Money:0x007fd72205dd08> create Customer #<Money:0x007fd72205db78>
ObjectContainer.reload!はActionDispatch::Reloader.to_prepareに追加されれば、自動的にインスタンスを作り直します。この方法は::Fooよりすこしコードを書く必要がありますが、インスタンスは一元管理できるので、サービスなどに大きなメリットがあると思います。
おまけ
そもそもそのエラーはなぜ出たのか、なんで::Fooが解決できるのか、その答えはActiveSupport::Dependenciesのautoloadにあります。
これはActiveSupport::Dependenciesを基いてエラーを再現するコードです。
module AutoLoadTest def self.append_features(base) base.class_eval do # Emulate #exclude via an ivar return if defined?(@_const_missing) && @_const_missing @_const_missing = instance_method(:const_missing) remove_method(:const_missing) end super end def const_missing(const_name) puts "const_name = #{const_name}, self = #{self}" super end end Module.class_eval{ include AutoLoadTest } class Foo end class Bar def run Foo.new end end bar = Bar.new Object.send(:remove_const, :Foo) Object.send(:remove_const, :Bar) bar.run
ActiveSupport::Dependenciesのautoload機能はmoduleのconst_missingなどを利用して、動的にソースコードファイルをロードする仕組みです。
まず理解すべきはA::Bを解析するために、
- A
- A::B
の順番でやります。なので、Aがロードされない場合は、A::Bのロードは不正なリクエストと見なされます。
そして、クラスのインスタンスはクラスがremove_constされても消滅しません。ここは微妙なところですね。クラスがないのに、インスタンスが存在しています。
bar.runを実行する時、const_missingで入力したconst_nameはFoo、そしてモジュール(self)はBar、つまりBar::Fooを探しています。
autoloadが検索する時、
- Bar::Foo
- Foo
の順番でロードしてみます。でもBar::Fooをロードしようとすると、Barが削除されたので、うえのルールで不正なリクエストと見なされ、
A copy of Bar has been removed from the module tree but is still active!
というエラーが発生するはずです。
ここではBarという定数が削除されて、でもBarのインスタンスが残って、Barのインスタンスメソッドで別のクラスを呼び出そうとすると、Bar::XXXでロードし、Barがないままエラーになってしまいました。activeというのはインスタンスです。
ちなみに、::Foo.newで書くと、最初からFooでロードするので、Barのクラスがロードされなくてもエラーになりません。
いかがでしょうか。今回はエラーをきっかけにautoloadをすこし勉強しましたが、皆さんにも参考になれば幸いです。