【OSSリーディング】Active Recordの内部実装を覗いてみた

記事タイトルとURLをコピーする

こんにちは、サーバーワークス OSS探求部のくればやしです。(「部」は部活の部)

普段業務ではAWSの運用自動化サービスのCloud Automatorの開発に携わっており、Webの開発ではRuby on Railsを用いています。 Railsのコンポーネントの中でもActive Recordは特に便利ですよね。

Active Recordが具体的にどうやってモデルのオブジェクトを生成したりしているかふと気になったのでソースコードを覗いてみました。

全体を読むのは大変なので、今回は findメソッド の処理を追っていくことで、得られるオブジェクトがどのように生成されるのかを探っていきたいと思います。具体的には以下の疑問を調べていきます。

① 生成されるオブジェクトに必要なメソッド(モデルの属性。今回の場合は .name 等)をどこでどのように取得しているか
② 取得したメソッドをどこでどのように定義していて、オブジェクトを生成しているか

Active RecordのVersionはv7.0.5で確認しています。

環境準備

今回はSQLiteを用いました。事前に testdb というDBを作成し、 id をプライマリキー、 name という文字列の属性を持つ users テーブルを作成し、レコードを1つ以上作成しておきます。

> create table users(id intege primary key, name string);
> insert into users values(1, 'akira');

Rubyのコードはこんな感じのイメージです。

require "active_record"
require "pry"
  
ActiveRecord::Base.establish_connection(
  adapter:   "sqlite3",
  database:  "testdb",
)
  
class User < ActiveRecord::Base
end
  
user = User.find(1) # ここでどのように `name` メソッドや `name=` メソッドを持つ user オブジェクトを作成しているのか
puts user.name

ソースコードリーディング

まず ① 生成されるオブジェクトに必要なメソッド(今回の場合は .name 等)をどこでどのように確認しているか を念頭にコードを探っていきます。

最初に #find そのものが定義されている箇所を確認していきます。RubyにはMethod#source_locationという便利なメソッドがあるため、これを使っていきます。

確認すると ActiveRecord::Core#find メソッドが呼ばれているとわかります。

def find(*ids) # :nodoc:
  # We don't have cache keys for this stuff yet
  return super unless ids.length == 1
  return super if block_given? || primary_key.nil? || scope_attributes?
  
  id = ids.first
  
  return super if StatementCache.unsupported_value?(id)
  
  key = primary_key
  
  statement = cached_find_by_statement(key) { |params|
    where(key => params.bind).limit(1)
  }
  
  statement.execute([id], connection).first ||
    raise(RecordNotFound.new("Couldn't find #{name} with '#{key}'=#{id}", name, key, id))
end

https://github.com/rails/rails/blob/v7.0.5/activerecord/lib/active_record/core.rb#L268

上記コードブロックの16行目でSQLを実行していると思えますが、その前に10行目の key = primary_key がポイントとなりそうなので見ていきます。

プライマリキーの取得

primary_key メソッドをたどっていくと、

していることがわかります。すなわち、上述の primary_key メソッドによりDBにアクセスし、プライマリキーとなる "id" という文字列を得ることが出来ていました。

モデルの属性の取得

さて、 #find のコードブロックに戻ります。(再掲)

def find(*ids) # :nodoc:
  # We don't have cache keys for this stuff yet
  return super unless ids.length == 1
  return super if block_given? || primary_key.nil? || scope_attributes?
  
  id = ids.first
  
  return super if StatementCache.unsupported_value?(id)
  
  key = primary_key
  
  statement = cached_find_by_statement(key) { |params|
    where(key => params.bind).limit(1)
  }
  
  statement.execute([id], connection).first ||
    raise(RecordNotFound.new("Couldn't find #{name} with '#{key}'=#{id}", name, key, id))
end

https://github.com/rails/rails/blob/v7.0.5/activerecord/lib/active_record/core.rb#L268

16行目の statement.execute([id], connection).first 部分で、先ほど得たプライマリキーを元にレコードを取得し、モデルのオブジェクトを生成していると窺えるため、この部分を見ていきます。

statement.executeActiveRecord::StatementCache#execute です。

def execute(params, connection, &block)
  bind_values = bind_map.bind params

  sql = query_builder.sql_for bind_values, connection

  klass.find_by_sql(sql, bind_values, preparable: true, &block)
rescue ::RangeError
  []
end

rails/activerecord/lib/active_record/statement_cache.rb at v7.0.5 · rails/rails · GitHub

上記コードブロック中の sql 変数を展開すると、 "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"id\" = ? LIMIT ?" です。

つまり、得たプライマリキーを用いてモデルをDBから取得するためのSQLを準備したことが確認できます。 次の klass.find_by_sql では、 ActiveRecord::Querying#find_by_sql が呼ばれます。

def find_by_sql(sql, binds = [], preparable: nil, &block)
  _load_from_sql(_query_by_sql(sql, binds, preparable: preparable), &block)
end

rails/activerecord/lib/active_record/querying.rb at v7.0.5 · rails/rails · GitHub

この中の _query_by_sql メソッドをたどっていくと、 ActiveRecord::ConnectionAdapters::DatabaseStatements#select に行き、

def select(sql, name = nil, binds = [], prepare: false, async: false)
  # 略
  exec_query(sql, name, binds, prepare: prepare)
end

rails/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb at v7.0.5 · rails/rails · GitHub

exec_query を通して、今回はSQLiteのためそのアダプタとなる、 ActiveRecord::ConnectionAdapters::SQLite3::DatabaseStatements#exec_query に処理が到達します。

そこからはネイティブ拡張部分に処理が移るようですが、このメソッドの返り値が ActiveRecord::Result のオブジェクトとなっており、中身はプライマリキーで指定したIDのレコードとなっており、カラムの情報も入っています。これで①の疑問の答えとなる、この箇所でモデルの属性と値を取得できているということを確認できました。

このオブジェクトが先ほどの _query_by_sql メソッドの返り値となります。

取得した属性の情報を元にオブジェクトを生成

次に②の疑問を解消するため、さらにコードを読んでいきます。元の ActiveRecord::Querying#find_by_sql に戻ります。(再掲)

def find_by_sql(sql, binds = [], preparable: nil, &block)
  _load_from_sql(_query_by_sql(sql, binds, preparable: preparable), &block)
end

rails/activerecord/lib/active_record/querying.rb at v7.0.5 · rails/rails · GitHub

_query_by_sql メソッドで ActiveRecord::Result を取得したことを確認したので、この情報を元に _load_from_sql メソッドでオブジェクトを生成すると予想できます。

_load_from_sql メソッドを見ていきます。処理が進んでいくと ActiveRecord::Persistence#instantiate_instance_of が呼ばれます。

def instantiate_instance_of(klass, attributes, column_types = {}, &block)
  attributes = klass.attributes_builder.build_from_database(attributes, column_types)
  klass.allocate.init_with_attributes(attributes, &block)
end

rails/activerecord/lib/active_record/persistence.rb at v7.0.5 · rails/rails · GitHub

allocate メソッドは自身のインスタンスを生成するメソッドです。ここの init_with_attributes メソッドをたどっていくと、 ActiveRecord::AttributeMethods#define_attribute_methods メソッドに処理が行きます。

def define_attribute_methods # :nodoc:
  return false if @attribute_methods_generated
  # Use a mutex; we don't want two threads simultaneously trying to define
  # attribute methods.
  generated_attribute_methods.synchronize do
    return false if @attribute_methods_generated
    superclass.define_attribute_methods unless base_class?
    super(attribute_names)
    @attribute_methods_generated = true
  end
end

rails/activerecord/lib/active_record/attribute_methods.rb at v7.0.5 · rails/rails · GitHub

上記8行目の super(attribute_names) でここから Active Model の同メソッドに処理が移ります。

def define_attribute_methods(*attr_names)
  ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |owner|
    attr_names.flatten.each { |attr_name| define_attribute_method(attr_name, _owner: owner) }
  end
end

rails/activemodel/lib/active_model/attribute_methods.rb at v7.0.5 · rails/rails · GitHub

ブロック内の define_attribute_method メソッドを見ておきます。このメソッドをたどっていくと、 ActiveRecord::AttributeMethods::Read#define_method_attribute に行きます。

def define_method_attribute(name, owner:)
  ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method(
    owner, name
  ) do |temp_method_name, attr_name_expr|
    owner.define_cached_method(name, as: temp_method_name, namespace: :active_record) do |batch|
      batch <<
        "def #{temp_method_name}" <<
        "  _read_attribute(#{attr_name_expr}) { |n| missing_attribute(n, caller) }" <<
        "end"
    end
  end
end

rails/activerecord/lib/active_record/attribute_methods/read.rb at v7.0.5 · rails/rails · GitHub

この7行目~9行目では後の #name メソッド(属性の読み込み用のメソッド)となる内容が文字列で定義されていることが分かります。

元に戻って、 ActiveSupport::CodeGenerator.batch メソッドをたどっていくと、 ActiveSupport::CodeGenerator::MethodSet#apply メソッドに行きます。

def apply(owner, path, line)
  unless @sources.empty?
    @cache.module_eval("# frozen_string_literal: true\n" + @sources.join(";"), path, line)
  end
  @methods.each do |name, as|
    owner.define_method(name, @cache.instance_method(as))
  end
end

rails/activesupport/lib/active_support/code_generator.rb at v7.0.5 · rails/rails · GitHub

module_eval メソッドは、クラスメソッドを動的に定義できるメソッドです。 @sources に先ほどの文字列で定義されたメソッドが格納されており、この内容がここで定義されることがわかります。

すなわち、この部分でモデルのオブジェクトに必要なメソッドが定義されていくこととなります。

おわりに

今回、Active Recordの内部実装からモデルのオブジェクトの生成過程の一端を覗いてみました。このようなライブラリはRubyのメタプログラミングの力が存分に発揮されているので、便利だなと思いつつ、慣れていないと読み解くのに結構骨が折れるなと思ったのですが、非常に勉強になりました。

コードリーディングの殴り書きのような形になりましたが、Active Recordの内部実装が気になった時の参考になれば幸いです。

くればやし (記事一覧)