🏝️

mongodb/mongoid を読む - Associations 周辺の内部キャッシュ仕様

2021/07/15に公開

MongoDB Ruby の 公式ODM mongodb/mongoid をよく利用しています。

Associations has_many, has_one 等で定義したデータに一度アクセスすると、mongoid内部にキャッシュしてくれるが、最新のデータを取得したいケースのやり方・キャッシュ仕様を内部実装をソースを読んで調べたときのメモ。

読んだコミットID: 3c31c0f0adaa028c6c58a4fe09b81b332d5fcae9

TL;DR

  • Model#association の第一引数に true 指定でインスタンス変数のキャッシュを利用せずに最新データを取得できる。
Model#association(true)

Associations(has_one, has_many等)の定義箇所

まず、Associations の has_one, has_many, belongs_to, has_and_belongs_to_many マクロの定義は、Mongoid::Document 内で以下モジュールを順にincludeで定義されている。

Mongoid::Composable
Mongoid::Association
Mongoid::Association::Macros

具体的な定義箇所

lib/mongoid/association/macro.rb

        def belongs_to(name, options = {}, &block)
          define_association!(__method__, name, options, &block)
        end
	
        def has_many(name, options = {}, &block)
          define_association!(__method__, name, options, &block)
        end
	
        def has_and_belongs_to_many(name, options = {}, &block)
          define_association!(__method__, name, options, &block)
        end
	
        def has_one(name, options = {}, &block)
          define_association!(__method__, name, options, &block)
        end
	
        private

        def define_association!(macro_name, name, options = {}, &block)
          Association::MACRO_MAPPING[macro_name].new(self, name, options, &block).tap do |assoc|
            assoc.setup!
            self.relations = self.relations.merge(name => assoc)
          end
        end

define_association! で各Associationクラスを初期化して setup! にてgetter/setter 等の関数が定義されている。

例えば、has_many の setup! 関数の定義は以下のようになっている。

lib/mongoid/association/referenced/has_many.rb

        def setup!
          setup_instance_methods!
          self
        end

        def setup_instance_methods!
          define_getter!
          define_ids_getter!
          define_setter!
          define_ids_setter!
          define_existence_check!
          define_autosaver!
          polymorph!
          define_dependency!
          @owner_class.validates_associated(name) if validate?
          self
        end

(ざっくり)
各Association(has_one, has_many, belongs_to, has_and_belongs_to_many)に実装されている setup! の内容は若干異なるが、内部でコールしている define_getter!, define_setter! は各Association共通でコールされている。

getterの定義

getterは以下内容で定義されている。

lib/mongoid/association/accessors.rb

      # Defines the getter for the association. Nothing too special here: just
      # return the instance variable for the association if it exists or build
      # the thing.
      #
      # @example Set up the getter for the association.
      #   Person.define_getter!(association)
      #
      # @param [ Association ] association The association metadata for the association.
      #
      # @return [ Class ] The class being set up.
      #
      # @since 2.0.0.rc.1
      def self.define_getter!(association)
        name = association.name
        association.inverse_class.tap do |klass|
          klass.re_define_method(name) do |reload = false|
            value = get_relation(name, association, nil, reload)
            if value.nil? && association.autobuilding? && !without_autobuild?
              value = send("build_#{name}")
            end
            value
          end
        end
      end

getter内容であるklass.re_define_method(name)で渡しているブロックの引数に reload = false の定義があり、このブロック引数 reload を利用することで、MongoDBへのデータリロードの有無を指定できる。
このreload引数については get_relation 関数コメントの引数部分にもデータリロードの有無の振る舞いをする旨がコメントされている。

lib/mongoid/association/accessors.rb

      # Get the association. Extracted out from the getter method to avoid
      # infinite recursion when overriding the getter.
      #
      # @api private
      #
      # @example Get the association.
      #   document.get_relation(:name, association)
      #
      # @param [ Symbol ] name The name of the association.
      # @param [ Association ] association The association metadata.
      # @param [ Object ] object The object used to build the association.
      # @param [ true, false ] reload If the association is to be reloaded.
      #
      # @return [ Proxy ] The association.
      #
      # @since 3.0.16
      def get_relation(name, association, object, reload = false)
        if !reload && (value = ivar(name)) != false
          value
        else
          _building do
            _loading do
              if object && needs_no_database_query?(object, association)
                __build__(name, object, association)
              else
                selected_fields = _mongoid_filter_selected_fields(association.key)
                __build__(name, attributes[association.key], association, selected_fields)
              end
            end
          end
        end
      end

(ざっくり)
get_relation では、reload指定なしでインスタンス変数ivar(name)にキャッシュしていたら、キャッシュ済みの値を返す。

キャッシュ使わなかった場合に実行される __build__ 関数を追ってみると、create_relation関数内で Association#build にて実際にクエリを発行し、set_relation にてキャッシュとして発行したクエリをインスタンス変数に保存していた。

lib/mongoid/association/accessors.rb

      def __build__(name, object, association, selected_fields = nil)
        relation = create_relation(object, association, selected_fields)
        set_relation(name, relation)
      end
      
      def create_relation(object, association, selected_fields = nil)
        type = @attributes[association.inverse_type]
        target = association.build(self, object, type, selected_fields)
        target ? association.create_relation(self, target) : nil
      end

      def set_relation(name, relation)
        instance_variable_set("@_#{name}", relation)
      end

各Association(has_many, has_one, belongs_to, has_and_belongs_to_many) の#buildメソッドの内容は以下。
MongoDBへの問い合わせクエリが発行されていることがわかる。

has_many

lib/mongoid/association/referenced/has_many/buildable.rb

          def build(base, object, type = nil, selected_fields = nil)
            return (object || []) unless query?(object)
            return [] if object.is_a?(Array)
            query_criteria(object, base)
          end

has_one

lib/mongoid/association/referenced/has_one/buildable.rb

          def build(base, object, type = nil, selected_fields = nil)
            if query?(object)
              if !base.new_record?
                execute_query(object, base)
              end
            else
              clear_associated(object)
              object
            end
          end

belongs_to

lib/mongoid/association/referenced/belongs_to/buildable.rb

          def build(base, object, type = nil, selected_fields = nil)
            return object unless query?(object)
            execute_query(object, type)
          end

has_and_belongs_to_many

lib/mongoid/association/referenced/has_and_belongs_to_many/buildable.rb

          def build(base, object, type = nil, selected_fields = nil)
            if query?(object)
              query_criteria(object)
            else
              object.try(:dup)
            end
          end

おわり

mongodb/mongoid の associations の getterを中心に内部キャッシュ仕様について調べました。association の第一引数にtrueするだけでMongoDBへのデータリロードを行うことができることがわかりました。他に良い方法があれば、コメントなりで教えてもらえると嬉しいです。

Discussion