こんにちは、Plex Job開発チームの種井です。
Plex Job開発チームではバックエンド開発にRuby on Railsを使用しています。
開発を進める中で、Active Record で1対多の関連先レコードを差分更新したい場面があります。私個人の話ですが、そのような要件があった際に自前で差分のみを抽出し、関連先の属性に指定するような実装を行っていました。
ある時、関連先の属性に直接指定することで、Active Record が差分更新してくれることを同僚に教えてもらいました。とても便利に思った反面、Active Record の内部でどのように差分更新されているかについて気になったので、調べてみました。
今回は差分更新時の挙動とActive Record内部での実装のされ方について調べてみた内容を紹介したいと思います。
例として挙げているソースコードの動作環境は以下となります。
関連先は差分更新される
まずは、差分更新の挙動についてみていきたいと思います。 Railsガイドの例を借りて、以下のようなテーブルを想定します。
対応するモデルは以下のようになります。
class Author < ApplicationRecord has_many :books end class Book < ApplicationRecord end
authorsテーブルとbooksテーブルにそれぞれレコードを作成します。
author = Author.new(name: 'Martin Fowler') author.books = [Book.new(title: 'Refactoring Ruby Edition'), Book.new(title: 'Refactoring')] author.save! author.books.pluck(:id) # => [1, 2]
この著者(Author)の書籍(Book)の関連付けを変更してみます。
uml_book = Book.create!(title: 'UML Distilled') poeaa_book = Book.create!(title: 'Patterns of Enterprise Application Architecture') refactoring_book = author.books.first refactoring_book.title = 'Refactoring 2nd Edition' author.books = [refactoring_book, uml_book, poeaa_book] author.save! author.books.pluck(:id) # => [1, 3, 4] author.books.pluck(:title) # => ["Refactoring 2nd Edition", "UML Distilled", "Patterns of Enterprise Application Architecture"]
Book(id: 2)は削除され、Book(id: 3, 4)は追加、Book(id: 1)はタイトルが変更されています。
このように、関連先として指定されたオブジェクトに応じて、Active Recordがよしなに差分更新(SQLのinsert, update, delete文の発行)を行ってくれることがわかります。
便利な点
- 関連先の更新後の状態を指定するだけ
- 実装者間で差分更新のロジックがムラが出ない
が便利なところだと思っています。
関連先の更新後の状態を指定するだけ
=
の挙動として直感的であり、何より差分更新ロジックをフレームワーク側が担ってくれるため記述量も少なくなります。
例えば、以下はこの機能を知る前の私が書いた差分更新の実装です。とても冗長ですね、、
author = Author.new(name: 'Martin Fowler') author.books = [Book.new(title: 'Refactoring Ruby Edition'), Book.new(title: 'Refactoring')] author.save! author.books.pluck(:id) # => [1, 2]
uml_book = Book.create!(title: 'UML Distilled') poeaa_book = Book.create!(title: 'Patterns of Enterprise Application Architecture') refactoring_book = author.books.first refactoring_book.title = 'Refactoring 2nd Edition' ids = author.books.pluck(:id) delete_ids = ids - [refactoring_book.id, uml_book.id, poeaa_book.id].map(&:to_i) new_ids = [refactoring_book.id, uml_book.id, poeaa_book.id].map(&:to_i) - ids if new_ids.present? author.books << Book.where(id: new_ids) author.save! end author.books.where(id: delete_ids).find_each(&:destroy!) author.books.reload author.books.pluck(:id) # => [1, 3, 4] author.books.pluck(:title) # => ["Refactoring 2nd Edition", "UML Distilled", "Patterns of Enterprise Application Architecture"]
実装者間で差分更新のロジックがムラが出ない
以下は実際にプロジェクトにあった別の差分更新ロジックです。一度関連レコードを全て削除した上で新しいレコードとして作成するシンプルな実装方法です。
author = Author.new(name: 'Martin Fowler') author.books = [Book.new(title: 'Refactoring Ruby Edition'), Book.new(title: 'Refactoring')] author.save! author.books.pluck(:id) # => [1, 2]
uml_book = Book.create!(title: 'UML Distilled') poeaa_book = Book.create!(title: 'Patterns of Enterprise Application Architecture') refactoring_book = author.books.first refactoring_book.title = 'Refactoring 2nd Edition' author.books.map(&:destroy) author.books = [rename_book, book1, book2] author.books.pluck(:id) # => [3, 4, 5] author.books.pluck(:title) # => ["Refactoring 2nd Edition", "UML Distilled", "Patterns of Enterprise Application Architecture"]
上記の例のように、開発者が個々人で差分更新のロジックを実装することで、レコードの作成のされ方に差が出てしまったり、コードの統一感がなくなってしまいます。 その点、Active Recordの機能を素直に使うことで仕様やコード品質を均一化することができます。
仕組み
ここまで、has_many 関連を持つ属性にオブジェクトを含む配列を渡すことで、Active Record が対応するテーブルに対して差分更新を行ってくれることを紹介してきました。 続いて、Active Record が自動的に行う差分更新の仕組みについて、内部のコードを追いながら見ていきたいと思います。
イメージしやすいように、登場するクラスやメソッドを簡単にまとめてみました。
has_manyクラスマクロの呼び出し
前述した例を改めて。
class Author < ApplicationRecord has_many :books end
has_many
クラスマクロの定義から見てみます。
def has_many(name, scope = nil, **options, &extension) reflection = Builder::HasMany.build(self, name, scope, options, &extension) Reflection.add_reflection self, name, reflection end
Builder::HasMany.build(self, name, scope, options, &extension)
というBuilderを使用して、HasManyAssociation
オブジェクト作成しています。
Builderは.build
メソッド呼び出し時にaccessorとしてwriter
,reader
メソッドを動的に定義します。
def self.define_readers(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name} association(:#{name}).reader end CODE end
def self.define_writers(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name}=(value) association(:#{name}).writer(value) end CODE end
これにより、以下の例のように関連先の参照、書き込み操作を可能にしています。
author = Author.new # reader author.books # writer author.books = [Book.new(title: "タイトル")]
上記から今回の場合、HasManyAssociation
クラスに定義されているであろう、writer
メソッドを見ると差分更新のロジックが分かりそうです。
writerメソッドの動作
HasManyAssociation
クラスを確認すると、writer
メソッドは定義されていませんでしたが、親クラスのCollectionAssociation
クラスにwriter
メソッドが定義されていました。
def writer(records) replace(records) end
writer
はさらに対象となる配列を受取り、replace
メソッドを呼び出しています。
replaceメソッドの動作
# Replace this collection with +other_array+. This will perform a diff # and delete/add only records that have changed. def replace(other_array) other_array.each { |val| raise_on_type_mismatch!(val) } original_target = skip_strict_loading { load_target }.dup if owner.new_record? replace_records(other_array, original_target) else replace_common_records_in_memory(other_array, original_target) if other_array != original_target transaction { replace_records(other_array, original_target) } else other_array end end end
親レコードが新規レコードかどうかを確認して処理が分岐しています。今回はすでに親レコードが存在する場合の処理ついて見ていきたいと思います。
まずはreplace_common_records_in_memory
メソッドです。
def replace_common_records_in_memory(new_target, original_target) common_records = intersection(new_target, original_target) common_records.each do |record| skip_callbacks = true replace_on_target(record, skip_callbacks, replace: true) end end
intersection
メソッドで既存の関連レコードの配列と変更後の配列の共通部分のみを取り出し、replace_on_target
メソッドを呼び出しています。
def intersection(a, b) a & b end
def replace_on_target(record, skip_callbacks, replace:, inversing: false) if replace && (!record.new_record? || @replaced_or_added_targets.include?(record)) index = @target.index(record) end ... if index target[index] = record elsif @_was_loaded || !loaded? @association_ids = nil target << record end end
replace_on_target
メソッドでは、メモリ上で既存の関連レコードを変更後のオブジェクトで上書きしています。
replace
メソッドに戻ります。既存の関連レコードの配列と変更後の配列に差分がある場合はtransaction
ブロック内でreplace_records
メソッドを呼び出しています。
def replace_records(new_target, original_target) delete(difference(target, new_target)) unless concat(difference(new_target, target)) @target = original_target raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \ "new records could not be saved." end target end
def difference(a, b) a - b end
target
はHasManyAssociation(CollectionAssociation)
内で共有されているインスタンス変数です。
replace_records
メソッドが呼び出される前にreplace_common_records_in_memory
メソッドで新旧配列の共通要素に対して属性が更新されている可能性があります。 既存の関連レコードと変更後の配列の差分をとり
- 変更後の配列にないものは削除
- 変更前の配列にないものは追加
- 変更前後の配列に存在するものは上書き
した上で、対応するテーブルに書き込み、差分更新を実現しています。 以上から、Active Recordがどのように関連先のレコードを差分更新するかが分かりました。
まとめ
今回、has_manyな関連先の更新時に Active Record が差分更新を行う挙動について見てきました。 この機能を知る前に自前で実装していた処理が、Active Record 内にすでに組み込まれており、同様の要件であれば Active Record の機能を素直に使用する方が、コードをシンプルに保てることが分かりました。 事前にドキュメントをしっかり読んだり、直感的に使用していれば、そもそも自前で差分更新のロジックを書く必要はなかったかもしれません。 ただ、Active Record がどのように差分更新を行うのかを深掘りできたことで、今後自信を持って活用できるようになる良い機会になりました。