1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

日本CTO協会24卒Advent Calendar 2024

Day 18

【Ruby on Rails】find_eachの内部実装を追う

Posted at

はじめに

こちらはアドベントカレンダー18日目の記事です。

本記事では、find_eachの内部実装を調査しながら、その処理内容や設計上の意図について考察します。

実装を追う

本記事ではRailsドキュメントに記載がある以下のfind_eachの使用例において、実行されるコードを追っていこうと思います。

Person.find_each do |person|
  person.do_awesome_stuff
end

find_eachメソッド

block_given?メソッドはカレントコンテキストにブロックが渡されていればtrueが返されるのでfind_in_batchesが実行されます。find_in_batchesメソッドへブロックを渡しており、ブロック内部ではfind_each実行時に渡したブロックをeachメソッドに渡しています。つまり、recordsはPersonモデルのオブジェクトであることがわかります。

def find_each(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil, order: :asc, &block)
  if block_given?
    find_in_batches(start: start, finish: finish, batch_size: batch_size, error_on_ignore: error_on_ignore, order: order) do |records|
      records.each(&block)
    end
  else
    ...
  end
end

考察

  • find_in_batchesはバッチ処理なので、recordsはバッチ単位ごとのPersonモデルのオブジェクトの配列であるはずです。
  • find_in_batchesの内部ではrecordsを渡すyieldを実行しており、第一引数としてPersonモデルのオブジェクトがどこかで渡されるはず。
  • startとfinishでは処理の位置を指定しているがプライマリキーを基準にしていそうです。

find_in_batchesメソッド

引数をそのままリレーして、in_batchesを実行しています。ブロックの内部ではfind_eachで考察した通り、yieldが実行されており、第一引数としてブロック引数のArrayオブジェクトを返しています。

def find_in_batches(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil, order: :asc)
  ...

  in_batches(of: batch_size, start: start, finish: finish, load: true, error_on_ignore: error_on_ignore, order: order) do |batch|
    yield batch.to_a
  end
end

in_batchesメソッド

find_eachの核となるメソッドです。まとまりごとに一つずつ見ていきましょう。

def in_batches(of: 1000, start: nil, finish: nil, load: false, error_on_ignore: nil, order: :asc)
  relation = self

  ...

  batch_limit = of
  if limit_value
    remaining   = limit_value
    batch_limit = remaining if remaining < batch_limit
  end

  relation = relation.reorder(batch_order(order)).limit(batch_limit)
  relation = apply_limits(relation, start, finish, order)
  relation.skip_query_cache! # Retaining the results in the query cache would undermine the point of batching
  batch_relation = relation

  loop do
    if load
      records = batch_relation.records
      ids = records.map(&:id)
      yielded_relation = where(primary_key => ids)
      yielded_relation.load_records(records)
    else
      ...
    end

    break if ids.empty?

    primary_key_offset = ids.last
    raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset

    yield yielded_relation

    break if ids.length < batch_limit

    ...

    batch_relation = relation.where(
      predicate_builder[primary_key, primary_key_offset, order == :desc ? :lt : :gt]
    )
  end
end

relation

relationにはfind_eachを呼び出したレシーバ(今回の例ではPerson)がselfとして代入されます。

  relation = self

batch_limitとremaining

batch_limitにはfind_eachからリレーされてきた1000が代入されます。limit_valueについてはlimitの実装を読むとわかりますが、limitメソッドの引数が代入されています。limitが指定されている場合、batch_limitはその上限に調整されます。

今回のケースではlimitをチェーンしたActiveRecordのオブジェクトではないので条件文は実行されません。

  batch_limit = of
  if limit_value
    remaining   = limit_value
    batch_limit = remaining if remaining < batch_limit
  end

batch_relation

先ほどrelationを定義しましたが、このままではbatch単位のオブジェクトになっていないのでrelationをbatch単位にしています。apply_limitsの実装の実装を読むと、limitをかけているだけではなく、プライマリキーについてsortも走っていることがわかります。sortを走らせているのはwhere条件でプライマリキーの範囲をstartとfinishで指定し、limitの処理で適切な数のレコードを取得するために必要で、これは確かにapply_limitsの責務です。

クエリキャッシュを無効にしているのはキャッシュをしてしまうと、batch単位ごとに処理することでメモリ効率化を図るというbatch処理の意義が損なわれてしまうためです。

  relation = relation.reorder(batch_order(order)).limit(batch_limit)
  relation = apply_limits(relation, start, finish, order)
  relation.skip_query_cache! # Retaining the results in the query cache would undermine the point of batching
  batch_relation = relation

loop内部

ここでbatch処理全体を実行しています。注目するべきポイントは3つです。

  • 1つ目はyieldです。yielded_relation(今回のケースではPerson)がin_batchesへ渡しているブロック引数に代入されます。
  • 2つ目はbreakの条件です。ループごとにbatch単位のidsを取得しているのですが、idsが空(処理するべきbatch対象がない)場合とidsがbatchサイズより小さい場合です。
  • 3つ目はprimary_key_offsetです。これでbatch処理のoffsetを指定しています。
  loop do
    if load
      records = batch_relation.records
      ids = records.map(&:id)
      yielded_relation = where(primary_key => ids)
      yielded_relation.load_records(records)
    else
      ...
    end

    break if ids.empty?

    primary_key_offset = ids.last
    raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset

    yield yielded_relation

    break if ids.length < batch_limit

    ...

    batch_relation = relation.where(
      predicate_builder[primary_key, primary_key_offset, order == :desc ? :lt : :gt]
    )
  end

最後に

メモリ節約によく利用されるfind_eachについて見てきました。内部実装を読むことで、Rubyの知らないメソッドに出会えたり、Railsにおけるインターフェースの思想に触れられて設計力が向上するのでおすすめです。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?