Railsの中身をチョロっとのぞいちゃえ!!!の段(find_by_XXX)

まえがき

こんにちは、machidaです。

この記事は KMC Advent Calendar 2019 - Adventar の18日目の記事です。

adventar.org

2があります

adventar.org

お絵かきもあります

adventar.org

前日の記事は id:PrimeNumber さんのオンラインBrainf*ckインタプリタ・デバッガを作った【Brainf*ck Advent Calendar 2019とその他のAdCの17日目】 - prime's diaryでした。

今年の夏ごろからRubyRuby on Railsのお勉強をしています。

Ruby on Rails(以下Rails)はRubyで作られたWebアプリケーションフレームワークです。

Railsは、少ないコード量で多くの機能を実現することができますが、その分前提となっている知識や概念はどちらかというと多めです。そのため、Railsの持っている文脈を理解するまでは、「そういうもの」と割り切って学習を進めなければならない場面によく遭遇します。

この記事の趣旨は、Railsの中身を少しだけのぞいてみることで、「そういうもの」としての理解からすこしだけでも踏み出そうというものです(俺が)。

 

なにをするの

Railsは非常に多くのライブラリやメソッドから構成されていますが、今回は、その中でも Active Record というライブラリに含まれる find_by_XXX メソッドについて調べてみます。

Active Recordは、Rubyのオブジェクトとデータベース上のデータを結びつけ、Rubyとデータベースの仲立ちをしてくれているライブラリです。

find_by_XXXは、Active Recordに含まれるメソッドです。

モデルをレシーバ、XXXを列名、引数をフィールドの値として呼び出すと、該当するレコードを取り出して、Active Recordオブジェクトとして返してく れます。

例えば、Memberテーブル(モデル)でkmc_idフィールドの値が"machida"のレコードを取り出したい場合は、以下のようにします。

Member.find_by_kmc_id "machida"

実際にコードを見て理解するまでは、自分で定義したわけでもないのに、カラムの名前を使ったメソッドが実行できてしまうこのメソッドは魔法のように感じられました。

Railsの中身をのぞいて、未定義のはずのfind_by_XXXメソッドが呼び出され、実行されるまでの流れを見てみましょう(実際にレコードを取得する部分については割愛します、ごめんなさい)。

準備

Railsの本体をローカルに持ってくる(じっくり読んでみたい場合)

GitHubにあるRailsのレポジトリをForkし、cloneしてローカルに落とします。

github.com

Railsプロジェクトの準備

newコマンドでRailsプロジェクトを作成します。

rails new PeekingInside

処理の流れを追うために必要なgemをGemfileに追加します。

#Gemfile
...
gem
gem 'pry-rails'
gem 'pry-byebug'

必要なgemを取得します。

bundle install

モデルの作成

id、first_name、last_name、created_at、updated_atのカラムを持つシンプルなUserモデルを作成します。

rails generate model User first_name:string last_name:string

いくつかレコードを追加します。

bin/rails console
User.create(first_name: "Mito", last_name: "Tsukino")
User.create(first_name: "Kaede", last_name: "Higuchi")
User.create(first_name: "Rin", last_name: "Shizuka")

以下のようなUserモデルが作成できました。

id first_name last_name created_at updated_at
1 Mito Tsukino "2019-12-17 13:24:09" "2019-12-17 13:24:09"
2 Kaede Higuchi "2019-12-17 13:24:16" "2019-12-17 13:24:16"
3 Rin Shizuka "2019-12-17 13:24:25" "2019-12-17 13:24:25"

実際に処理をのぞいてみる

pryによるステップ実行

Railsのコンソールコマンドでpryを起動します。

bin/rails console

find_by_first_nameメソッドを呼び出すtestメソッドを作ります。

[1] pry(main)> def test
[1] pry(main)*   User.find_by_first_name "Mito"
[1] pry(main)* end  
=> :test

まずは通常通り実行してみます。

[2] pry(main)> test
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."first_name" = ? LIMIT ?  [["first_name", "Mito"], ["LIMIT", 1]]
=> #<User:0x000055cfd9daf2d8 id: 1, first_name: "Mito", last_name: "Tsukino", created_at: Tue, 17 Dec 2019 13:24:09 UTC +00:00, updated_at: Tue, 17 Dec 2019 13:24:09 UTC +00:00>

SELECT文が発行され、first_nameが "Mito" になっているレコードが読み込まれました。

次は、先ほどのtestメソッドに1行加えたtest2メソッドを作ります。

[3] pry(main)> def test2
[3] pry(main)*   binding.pry
[3] pry(main)*   User.find_by_first_name "Mito"
[3] pry(main)* end  
=> :test2

コード中でbinding.pryを呼び出すと、ブレークポイントを設け、その場所からステップ実行することができます。

ステップ実行中には以下のコマンドが使えます。

コマンド 操作
step メソッドの内部に入る
next 現在のメソッド内で1行進める
finish 現在のメソッドを抜ける
exit! pryを抜ける

test2メソッドを実行すると、以下のようにbinding.pryを呼び出した場所で処理が止まります。

[4] pry(main)> test2

From: (pry) @ line 7 Object#test2:

    5: def test2
    6:   binding.pry
 => 7:   User.find_by_first_name "Mito"
    8: end

[1] pry(main)> 

  stepコマンドでメソッドの内部に入り、処理をみてみます。

[1] pry(main)> step

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activesupport-5.2.4/lib/active_support/dependencies.rb @ line 194 ActiveSupport::Dependencies::ModuleConstMissing#const_missing:

    193: def const_missing(const_name)
 => 194:   from_mod = anonymous? ? guess_for_anonymous(const_name) : self
    195:   Dependencies.load_missing_constant(from_mod, const_name)
    196: end

[1] pry(Object)> 

内部に入ることができました。

ここは、RailsのAutoloadという機能を実現している部分です。

Autoloadは、Railsには命名規則に則ったクラス(のファイル)を自動的に読み込む機能です。コントローラやモデルをrequireする必要がないのは、この仕組みのためです。

今回調べたいのはメソッド呼び出しの仕組みなので、この部分は飛ばします。

finishコマンドで現在のメソッドを抜け、次の処理に進みます。

method_missing

[1] pry(Object)> finish

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 16 ActiveRecord::DynamicMatchers#method_missing:

    15: def method_missing(name, *arguments, &block)
 => 16:   match = Method.match(self, name)
    17: 
    18:   if match && match.valid?
    19:     match.define
    20:     send(name, *arguments, &block)
    21:   else
    22:     super
    23:   end
    24: end

method_missingという奇妙な名前のメソッドにたどり着きました。

ここからが本題のメソッド呼び出しに関わる部分です。

Rubyでは、オブジェクトのメソッドが呼び出されると、まずそのオブジェクトのクラスのインスタンスメソッド中を探索します。そこでメソッドが見つからないと、次はそのクラスが継承している親クラス中を探索します。

これを繰り返し、最終的に最上位のBasicObjectクラスにたどり着いてもメソッドが見つからないとき、Rubyは、メソッドが存在しないことを伝えるために、元のオブジェクトのmethod_missingメソッドを呼び出します。

method_missingメソッドが呼び出されると、Rubyは通常のメソッド呼び出しの場合と同様に、呼び出されたmethod_missingメソッドの在りかを継承チェーンを登り探索しはじめます。

通常、最上位のBasicObjectに定義されているmethod_missingメソッドが呼び出され、メソッドが存在しないことを告げるエラーが出力されます。

下記は、存在しないメソッドを呼び出す例です。文字列変数strに対し、存在しないメソッドhogehogeを呼び出しています。

[1] pry(main)> str = "hello"
=> "hello"
[2] pry(main)> str.hogehoge
NoMethodError: undefined method `hogehoge' for "hello":String
from (pry):2:in `<main>'

実は、Rubyでは、通常のメソッドと同様に、method_missingメソッドもサブクラスで再定義(オーバーライド)することができます。

method_missingを再定義することによって、未定義のメソッドが呼び出された際に所定の処理を行うことが可能です。

[1] pry(Object)> finish

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 16 ActiveRecord::DynamicMatchers#method_missing:

    15: def method_missing(name, *arguments, &block)
 => 16:   match = Method.match(self, name)
    17: 
    18:   if match && match.valid?
    19:     match.define
    20:     send(name, *arguments, &block)
    21:   else
    22:     super
    23:   end
    24: end

Active Recordは、まさにこの部分でmethod_missingを再定義しています。

method_missingを再定義することで、単にエラーを出力して終了する前に、何らかの意義のあるメソッド呼び出しでないかを判定し、処理を行うことを可能にしているのです。

name, *arguments, &block

引き続きpryを使ってmethod_missingメソッドに与えられた引数name, arguments, blockの中身をのぞいてみましょう。

Rubyは見つからなかったメソッドの名前(シンボル)、与えられた引数、与えられたブロックを引数としてmethod_missingを呼び出します。

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 16 ActiveRecord::DynamicMatchers#method_missing:

    15: def method_missing(name, *arguments, &block)
 => 16:   match = Method.match(self, name)
    17: 
    18:   if match && match.valid?
    19:     match.define
    20:     send(name, *arguments, &block)
    21:   else
    22:     super
    23:   end
    24: end

[1] pry(User)> name
=> :find_by_first_name
[2] pry(User)> arguments
=> ["Mito"]
[3] pry(User)> block
=> nil

実際に、nameには呼び出した :find_by_first_name、argumentsには与えた引数 "Mito" が格納されていることが確認できます。今回はブロックは与えていないので、blockにはなにも入っていません。

Methodクラスのmatchメソッド

次は、match = Method.match(self, name) について調べましょう。

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 16 ActiveRecord::DynamicMatchers#method_missing:

    15: def method_missing(name, *arguments, &block)
 => 16:   match = Method.match(self, name)
    17: 
    18:   if match && match.valid?
    19:     match.define
    20:     send(name, *arguments, &block)
    21:   else
    22:     super
    23:   end
    24: end

[1] pry(User)> self
=> User (call 'User.connection' to establish a connection)
[2] pry(User)> name
=> :find_by_first_name

ActiveRecord::DynamicMatchers::Methodのクラスメソッドmatchが呼び出されています。

引数のselfにはUserモデル(クラス)自身、nameには呼び出したメソッド名 :find_by_first_nameが格納されています。

stepコマンドでmatchメソッドの内部に入ります。

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 33 ActiveRecord::DynamicMatchers::Method.match:

    32: def match(model, name)
 => 33:   klass = matchers.find { |k| k.pattern.match?(name) }
    34:   klass.new(model, name) if klass
    35: end

findは、ブロック引数に要素を入れてブロックを繰り返し、ブロックの返り値が真だったときにその要素を返すメソッドです。

matchersという変数には何が入っているのでしょうか。のぞいてみます。

[3] pry(ActiveRecord::DynamicMatchers::Method)> matchers
=> [ActiveRecord::DynamicMatchers::FindBy, ActiveRecord::DynamicMatchers::FindByBang]

matchersにはActiveRecord::DynamicMatchers::FindByActiveRecord::DynamicMatchers::FindByBangのクラスオブジェクトが格納されています。

FindByクラスとFindByBangクラスは、それぞれ以下のようなクラスです。

class FindBy < Method
  Method.matchers << self

  def self.prefix
    "find_by"
  end

  def finder
    "find_by"
  end
end

class FindByBang < Method
  Method.matchers << self

  def self.prefix
    "find_by"
  end

  def self.suffix
    "!"
  end

  def finder
    "find_by!"
  end
end

また、Methodクラスには以下のようなメソッドpatternが定義されています。

def pattern
  @pattern ||= /\A#{prefix}_([_a-zA-Z]\w*)#{suffix}\Z/
end

patternは、文字列変数prefixsuffixに挟まれた文字列にマッチするRegexpオブジェクトを返します。

FindByクラスとFindByBangクラスはどちらもMethodクラスを継承しているので、patternメソッドを使用することができます。

以上から、klass = matchers.find { |k| k.pattern.match?(name) }は、find_by系かfind_by!系の処理であるかをパターンマッチで判定し、マッチした場合は、対応するクラスオブジェクトを変数klassに格納するという処理を行っている、ということが分かりました。

余談ですが、変数名にclassでなくklassが用いられているのは、classRuby予約語で、変数名として使うことができないためです。

クラスオブジェクトの生成

次行のklass.new(model, name) if klassに進みます。

[1] pry(ActiveRecord::DynamicMatchers::Method)> next

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 34 ActiveRecord::DynamicMatchers::Method.match:

    32: def match(model, name)
    33:   klass = matchers.find { |k| k.pattern.match?(name) }
 => 34:   klass.new(model, name) if klass
    35: end

modelにはUserモデル(クラス)自身、nameには呼び出したメソッド名 :find_by_first_nameが入っているのでした。 stepコマンドで内部に入ります。

[1] pry(ActiveRecord::DynamicMatchers::Method)> step

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 53 ActiveRecord::DynamicMatchers::Method#initialize:

    52: def initialize(model, name)
 => 53:   @model           = model
    54:   @name            = name.to_s
    55:   @attribute_names = @name.match(self.class.pattern)[1].split("_and_")
    56:   @attribute_names.map! { |n| @model.attribute_aliases[n] || n }
    57: end

渡されたモデルやメソッド名の情報をもとに初期化を行い、オブジェクトを生成しています。

matchメソッドは渡された情報をもとに、先ほどのパターンマッチに合致したFindByクラスまたはFindByBangクラスのオブジェクトを生成し、返り値として返すメソッドだということが分かりました。

コンソールで確かめてみます。finishコマンドでmethod_missingメソッドの内部に戻りましょう。

[1] pry(#<ActiveRecord::DynamicMatchers::FindBy>)> finish

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 18 ActiveRecord::DynamicMatchers#method_missing:

    15: def method_missing(name, *arguments, &block)
    16:   match = Method.match(self, name)
    17: 
 => 18:   if match && match.valid?
    19:     match.define
    20:     send(name, *arguments, &block)
    21:   else
    22:     super
    23:   end
    24: end

[1] pry(User)> match
=> #<ActiveRecord::DynamicMatchers::FindBy:0x000055cfd8fa6970
 @attribute_names=["first_name"],
 @model=User (call 'User.connection' to establish a connection),
 @name="find_by_first_name">

実際に、Method.match(self, name)の返り値が格納されている変数matchには、FindByクラスオブジェクトが格納されていることが確認できました。

また、インスタンス変数には、モデルや探索しようとしている属性の名前、メソッドの名前などの情報が記録されています。

match && match.valid?

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 18 ActiveRecord::DynamicMatchers#method_missing:

    15: def method_missing(name, *arguments, &block)
    16:   match = Method.match(self, name)
    17: 
 => 18:   if match && match.valid?
    19:     match.define
    20:     send(name, *arguments, &block)
    21:   else
    22:     super
    23:   end
    24: end

マッチが完了すると、if文によってマッチが成功したかどうかの判定が行われます。

今回はマッチに成功していますが、マッチに失敗した場合は、superメソッドによって処理がスーパークラスmethod_missingメソッドに引き渡され、通常通りメソッドが存在しないことを告げるエラーが出力されます。

defineメソッドとsendによる実行

[2] pry(User)> next

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 19 ActiveRecord::DynamicMatchers#method_missing:

    15: def method_missing(name, *arguments, &block)
    16:   match = Method.match(self, name)
    17: 
    18:   if match && match.valid?
 => 19:     match.define
    20:     send(name, *arguments, &block)
    21:   else
    22:     super
    23:   end
    24: end

マッチに成功したことを確認すると、Methodクラスのdefineメソッドが呼び出されます。 stepで内部に入ります。

[2] pry(User)> step

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 64 ActiveRecord::DynamicMatchers::Method#define:

    63: def define
 => 64:   model.class_eval <<-CODE, __FILE__, __LINE__ + 1
    65:     def self.#{name}(#{signature})
    66:       #{body}
    67:     end
    68:   CODE
    69: end

class_evalメソッドで、クラスオブジェクトmatchが持っている情報をもとに、matchに対して新たなメソッドfind_by_first_nameを定義しています。

class_evalは、レシーバのクラスのコンテキストで与えられたコードを評価するメソッドです。

これで、最初は未定義だったfind_by_first_nameメソッドがれっきとしたメソッドとして定義されました。

From: /home/vagrant/.rbenv/versions/2.6.4/lib/ruby/gems/2.6.0/gems/activerecord-5.2.4/lib/active_record/dynamic_matchers.rb @ line 20 ActiveRecord::DynamicMatchers#method_missing:

    15: def method_missing(name, *arguments, &block)
    16:   match = Method.match(self, name)
    17: 
    18:   if match && match.valid?
    19:     match.define
 => 20:     send(name, *arguments, &block)
    21:   else
    22:     super
    23:   end
    24: end

最後にsendメソッドによって実際にfind_by_first_nameメソッドが呼び出され、実際にSQL文を発行し、レコードを取り出す処理が行われます(ここから先の流れは割愛します)。

あとがき

少しだけのぞいてみると言ったわりには、ずいぶん長くなってしまいました、ごめんなさい。

Railsの魅力が、Rubyの柔軟な言語仕様の賜物であることを改めて実感しました。

何はさておき、コード読んでみるといろいろな発見があって楽しいよ!ということだけでも伝わったら嬉しいです。

次の記事は id:Pasta-K さんのウェブページの表示を遅くなくしたい時の道標 - ぱすたけ日記です。