ユニファ開発者ブログ

ユニファ株式会社システム開発部メンバーによるブログです。

エラー「A copy of xxx has been removed from the module tree but is still active!」と「ObjectContainer」

こんにちは、システム開発部のちょうです。今回はあるエラーから1つ小さいなライブラリを作った話について共有したいと思います。

先月の開発でまれに「A copy of xxx has been removed from the module tree but is still active!」というエラーに遭遇しまして、一回あったらRailsアプリを再起動しないといけない状態になります。回数が少ないですが、開発の邪魔になるので、少し調べました。

ここまでわかったのは、

  1. 解決策1つめ、Fooを::Fooに変更
  2. 本番環境は出ない(自分の経験でも開発環境以外見たことはない)
  3. エラーメッセージによると、モジュールは削除されましたがまだ使われてる(正直ピンとこない)

ひとまず、解決策はわかったので、使ったら元々エラーになった箇所も確実に直りました。でもたまには別のところで同じエラーで怒られました。すべてのクラス名をフルネームで書き直すのは煩雑な作業になるから、やはり根本的な原因を探さないといけないと思いました。

エラーメッセージの一部をキーワードとしてプロジェクトとライブラリ内で検索してみたら、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してみましたが、残念ながら、一回も再現できませんでした…

一方、エラーメッセージでもっと調べた結果、このブログが見つかり、再現できる方法が分かりました。

Rails autoloading

# /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

まとめてみると、その手順は

  1. インスタンス作成
  2. ActiveSupport::Dependenciesがクリアされ(開発環境ではコントロールやモデルファイルを弄るとかで発生する)
  3. インスタンスのメソッドで別のクラスを呼び出す

になります。

確かに、自分が開発している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がクリアされる時、同時にインスタンスを作り直します。

この仕組みを実現するには、

  1. ActiveSupport::Dependenciesがクリアされるタイミング
  2. インスタンスを作成するロジックを持ってないといけない

が必要になります。

一番はとくに複雑ではないです。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を解析するために、

  1. A
  2. A::B

の順番でやります。なので、Aがロードされない場合は、A::Bのロードは不正なリクエストと見なされます。

そして、クラスのインスタンスはクラスがremove_constされても消滅しません。ここは微妙なところですね。クラスがないのに、インスタンスが存在しています。

bar.runを実行する時、const_missingで入力したconst_nameはFoo、そしてモジュール(self)はBar、つまりBar::Fooを探しています。

autoloadが検索する時、

  1. Bar::Foo
  2. 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をすこし勉強しましたが、皆さんにも参考になれば幸いです。