ComputedModel
ComputedModelは依存解決アルゴリズムを備えた普遍的なバッチローダーです。
- 依存解決アルゴリズムの恩恵により、抽象化を損なわずに以下の3つを両立させることができます。
- ActiveRecord等から読み込んだデータを加工して提供する。
- ActiveRecord等からのデータの読み込みを一括で行うことでN+1問題を防ぐ。
- 必要なデータだけを読み込む。
- 複数のデータソースからの読み込みにも対応。
- データソースに依存しない普遍的な設計。HTTPで取得した情報とActiveRecordから取得した情報の両方を使う、といったこともできます。
解決したい問題
モデルが複雑化してくると、単にデータベースから取得した値を返すだけではなく、加工した値を返したくなることがあります。
class User < ApplicationRecord
has_one :preference
has_one :profile
def display_name
"#{preference.title} #{profile.name}"
end
end
ところがこれをそのまま使うと N+1 問題が発生することがあります。
# N+1 問題!
User.where(id: friend_ids).map(&:display_name)
N+1問題を解決するには、 #display_name
が何に依存していたかを調べ、それをpreloadしておく必要があります。
User.where(id: friend_ids).preload(:preference, :profile).map(&:display_name)
# ^^^^^^^^^^^^^^^^^^^^^ display_name の抽象化が漏れてしまう
これではせっかく #display_name
を抽象化した意味が半減してしまいます。
ComputedModelは依存解決アルゴリズムをバッチローダーに接続することでこの問題を解消します。
class User
define_primary_loader :raw_user do ... end
define_loader :preference do ... end
define_loader :profile do ... end
dependency :preference, :profile
computed def display_name
"#{preference.title} #{profile.name}"
end
end
インストール
Gemfileに以下の行を追加:
gem 'computed_model', '~> 0.3.0'
その後、以下を実行:
$ bundle
または直接インストール:
$ gem install computed_model
動かせるサンプルコード
require 'computed_model'
# この2つを外部から取得したデータとみなす (ActiveRecordやHTTPで取得したリソース)
RawUser = Struct.new(:id, :name, :title)
Preference = Struct.new(:user_id, :name_public)
class User
include ComputedModel::Model
attr_reader :id
def initialize(raw_user)
@id = raw_user.id
@raw_user = raw_user
end
def self.list(ids, with:)
bulk_load_and_compute(Array(with), ids: ids)
end
define_primary_loader :raw_user do |_subfields, ids:, **|
# ActiveRecordの場合:
# raw_users = RawUser.where(id: ids).to_a
raw_users = [
RawUser.new(1, "Tanaka Taro", "Mr. "),
RawUser.new(2, "Yamada Hanako", "Dr. "),
].filter { |u| ids.include?(u.id) }
raw_users.map { |u| User.new(u) }
end
define_loader :preference, key: -> { id } do |user_ids, _subfields, **|
# ActiveRecordの場合:
# Preference.where(user_id: user_ids).index_by(&:user_id)
{
1 => Preference.new(1, true),
2 => Preference.new(2, false),
}.filter { |k, _v| user_ids.include?(k) }
end
delegate_dependency :name, to: :raw_user
delegate_dependency :title, to: :raw_user
delegate_dependency :name_public, to: :preference
dependency :name, :name_public
computed def public_name
name_public ? name : "Anonymous"
end
dependency :public_name, :title
computed def public_name_with_title
"#{title}#{public_name}"
end
end
# あらかじめ要求したフィールドにだけアクセス可能
users = User.list([1, 2], with: [:public_name_with_title])
users.map(&:public_name_with_title) # => ["Mr. Tanaka Taro", "Dr. Anonymous"]
users.map(&:public_name) # => error (ForbiddenDependency)
users = User.list([1, 2], with: [:public_name_with_title, :public_name])
users.map(&:public_name_with_title) # => ["Mr. Tanaka Taro", "Dr. Anonymous"]
users.map(&:public_name) # => ["Tanaka Taro", "Anonymous"]
# 次のような場合は preference は読み込まれない。
users = User.list([1, 2], with: [:title])
users.map(&:title) # => ["Mr. ", "Dr. "]
次に読むもの
License
This library is distributed under MIT license.
Copyright (c) 2020 Masaki Hara
Copyright (c) 2020 Masayuki Izumi
Copyright (c) 2020 Wantedly, Inc.
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/wantedly/computed_model.