この記事は約9分で読めます。
この記事は1年以上前に書かれたものです。
内容が古い可能性がありますのでご注意ください。
こんにちは、サーバーワークス 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::Baseenduser = 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 yetreturn super unless ids.length == 1return super if block_given? || primary_key.nil? || scope_attributes?id = ids.firstreturn super if StatementCache.unsupported_value?(id)key = primary_keystatement = 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
メソッドをたどっていくと、
ActiveRecord::AttributeMethods::PrimaryKey#primary_key
メソッドが呼ばれ、- 同モジュールの
get_primary_key
メソッドが呼ばれ、 - 最終的には
ActiveRecord::ConnectionAdapters::SQLite3Adapter#primary_keys
が呼ばれ、 ("PRAGMA table_info(#{quote_table_name(table_name)})", "SCHEMA"
部分でテーブル情報を取得し、プライマリキーを取得
していることがわかります。すなわち、上述の primary_key
メソッドによりDBにアクセスし、プライマリキーとなる "id" という文字列を得ることが出来ていました。
モデルの属性の取得
さて、 #find
のコードブロックに戻ります。(再掲)
def find(*ids) # :nodoc:# We don't have cache keys for this stuff yetreturn super unless ids.length == 1return super if block_given? || primary_key.nil? || scope_attributes?id = ids.firstreturn super if StatementCache.unsupported_value?(id)key = primary_keystatement = 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.execute
は ActiveRecord::StatementCache#execute
です。
def execute(params, connection, &block)bind_values = bind_map.bind paramssql = query_builder.sql_for bind_values, connectionklass.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
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 doreturn false if @attribute_methods_generatedsuperclass.define_attribute_methods unless base_class?super(attribute_names)@attribute_methods_generated = trueendend
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) }endend
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"endendend
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))endend
rails/activesupport/lib/active_support/code_generator.rb at v7.0.5 · rails/rails · GitHub
module_eval
メソッドは、クラスメソッドを動的に定義できるメソッドです。 @sources
に先ほどの文字列で定義されたメソッドが格納されており、この内容がここで定義されることがわかります。
すなわち、この部分でモデルのオブジェクトに必要なメソッドが定義されていくこととなります。
おわりに
今回、Active Recordの内部実装からモデルのオブジェクトの生成過程の一端を覗いてみました。このようなライブラリはRubyのメタプログラミングの力が存分に発揮されているので、便利だなと思いつつ、慣れていないと読み解くのに結構骨が折れるなと思ったのですが、非常に勉強になりました。
コードリーディングの殴り書きのような形になりましたが、Active Recordの内部実装が気になった時の参考になれば幸いです。
紅林輝(くればやしあきら)(サービス開発部) 記事一覧
サービス開発部所属。2015年にサーバーワークスにJOIN。クラウドインテグレーション部を経て、現在はCloud Automatorの開発に従事。ドラクエ部。推しナンバーはⅤ、推しモンスターはクックルー。