はじめに
過不足なく必要なデータのみを取得することが可能な GraphQL
。必要に応じて「親レコードのみ取得できればよい」「子レコードも一緒にまとめて取得したい」といった使い分け可能な点が魅力ですが、それゆえにうっかりN + 1問題を引き起こさないように対策が必要となってきます。
今回は GraphQL::Dataloader
を用いてアソシエーション部分のN + 1問題の対策を行なってみました。
用意したアソシエーション
projects
テーブルから見てcategories
を親レコード、tasks
を子レコードとして持ち、teams
とは中間テーブルteam_projects
を挟んで「多対多」の構成とします。
class Project < ApplicationRecord
belongs_to :category
has_many :tasks
has_many :team_projects
has_many :teams, through: :team_projects
end
class Category < ApplicationRecord
has_many :projects
end
class Task < ApplicationRecord
belongs_to :project
end
class TeamProject < ApplicationRecord
belongs_to :team
belongs_to :project
end
class Team < ApplicationRecord
has_many :team_projects
has_many :projects, through: :team_projects
end
ER図で表すと以下のようになります。
Dataloaderで親子モデル
belongs_to
projects
を起点に親レコードのcategory
を一緒に取得するパターンを考えます。
query Projects {
projects {
id
name
category {
id
name
}
}
}
module Types
class ProjectType < Types::BaseObject
field :id, GraphQL::Types::ID, null: false
field :name, String, null: false
field :category, Types::CategoryType, null: false # belongs_to
end
end
まずはGraphQL::Dataloader
を使わずprojects
(全5レコード)を取得します。
下記のように1レコードごとに親のcategories
を取得するクエリが発行されてしまっています。
Project Load (0.4ms) SELECT `projects`.* FROM `projects`
Category Load (0.3ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 1 LIMIT 1
Category Load (0.3ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 2 LIMIT 1
Category Load (0.2ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 3 LIMIT 1
Category Load (0.1ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 4 LIMIT 1
Category Load (0.2ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 5 LIMIT 1
それでは、親のcategories
を取得する際に GraphQL::Dataloader
を使うようにコードを修正します。ドキュメントに掲載されている例をほぼそのまま使用しています。
class Sources::ActiveRecordSingleObject < GraphQL::Dataloader::Source
def initialize(model_class)
@model_class = model_class
end
def fetch(ids)
records = @model_class.where(id: ids)
# return a list with `nil` for any ID that wasn't found
ids.map { |id| records.find { |r| r.id == id.to_i } }
end
end
module Types
class ProjectType < Types::BaseObject
field :id, GraphQL::Types::ID, null: false
field :name, String, null: false
field :category, Types::CategoryType, null: false # belongs_to
+ def category
+ dataloader.with(Sources::ActiveRecordSingleObject, ::Category).load(object.category_id)
+ end
end
end
上記対応後の発行SQLがこちらとなります。親 categories
を取得するSQLがIN検索で1つにまとめられて、N+1を回避することができました。
Project Load (0.2ms) SELECT `projects`.* FROM `projects`
Category Load (0.3ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`id` IN (1, 2, 3, 4, 5)
has_many
projects
を起点に子レコード tasks
を取得します。
query Projects {
projects {
id
name
tasks {
id
name
}
}
}
GraphQL::Dataloader
を使わないと、やはりN+1問題が発生しています。
Project Load (0.3ms) SELECT `projects`.* FROM `projects`
Task Load (0.3ms) SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 1
Task Load (0.3ms) SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 2
Task Load (0.2ms) SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 3
Task Load (0.2ms) SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 4
Task Load (0.2ms) SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 5
こちらも子レコード取得時に GraphQL::Dataloader
を利用するように修正します。コードはこちらの記事を参考にさせていただきました。
class Sources::ActiveRecordMultipleObject < GraphQL::Dataloader::Source
def initialize(model_class, foreign_key_column_name)
@model_class = model_class
@foreign_key_column_name = foreign_key_column_name
end
def fetch(ids)
records = @model_class.where({ @foreign_key_column_name => ids })
.group_by { |record| record[@foreign_key_column_name] }
ids.map { |id| records[id] || [] }
end
end
module Types
class ProjectType < Types::BaseObject
field :id, GraphQL::Types::ID, null: false
field :name, String, null: false
field :tasks, [Types::TaskType], null: true # has_many
+ def tasks
+ dataloader.with(Sources::ActiveRecordMultipleObject, ::Task, :project_id).load(object.id)
+ end
end
end
この場合も、GraphQL::Dataloader
を使用することによって下記のようにN+1問題を回避できるようになりました。
Project Load (0.3ms) SELECT `projects`.* FROM `projects`
Task Load (0.2ms) SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` IN (1, 2, 3, 4, 5)
Dataloaderで「多対多(N:N)」モデル
さて、「多対多」関係にある teams
はどのように取得するべきでしょうか。
query Projects {
projects {
id
name
teams {
id
name
}
}
}
そのまま取得しにかかるとやはりN+1問題が発生します。
Project Load (0.3ms) SELECT `projects`.* FROM `projects`
Team Load (0.2ms) SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 1
Team Load (0.2ms) SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 2
Team Load (0.2ms) SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 3
Team Load (0.2ms) SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 4
Team Load (0.2ms) SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 5
team_projects
経由で取得する(has_many
、belongs_to
にあたる部分で GraphQL::Dataloader
を使う)ことでN+1問題は回避できますが、外部キー以外の情報を持っていないteam_projects
をわざわざ取得したくはありません。
query Projects {
projects {
id
name
teamProjects {
id
team {
id
name
}
}
}
}
そこで、has_many
用に実装したコードを発展させて、中間テーブル取得時に関連先を同時にキャッシュし、最終的には関連先のレコードを返すクラスを新たに実装します。
class Sources::ActiveRecordMultipleThroughObject < GraphQL::Dataloader::Source
def initialize(model_class, foreign_key_column_name, through_class)
@model_class = model_class
@foreign_key_column_name = foreign_key_column_name
@through_class = through_class
end
def fetch(ids)
association_name = @model_class.name.underscore
# 中間テーブル取得時に関連先もキャッシュ
records = @through_class.where({ @foreign_key_column_name => ids }).preload(association_name)
.group_by { |record| record[@foreign_key_column_name] }
# キャッシュした関連先レコードを返す
ids.map { |id|
(records[id] || []).map {|record|
record.public_send association_name
}
}
end
end
module Types
class ProjectType < Types::BaseObject
field :id, GraphQL::Types::ID, null: false
field :name, String, null: false
field :teams, [Types::TeamType], null: true # has_many througth
+ def teams
+ dataloader.with(Sources::ActiveRecordMultipleThroughObject, ::Team, :project_id, ::TeamProject).load(object.id)
+ end
end
end
上記対応後、teams
を取得するSQLが1つにまとめらて、N+1問題を回避することができました。
Project Load (0.2ms) SELECT `projects`.* FROM `projects`
TeamProject Load (0.3ms) SELECT `team_projects`.* FROM `team_projects` WHERE `team_projects`.`project_id` IN (1, 2, 3, 4, 5)
↳ app/graphql/sources/active_record_multiple_object.rb:9:in `group_by'
Team Load (0.3ms) SELECT `teams`.* FROM `teams` WHERE `teams`.`id` IN (1, 2, 3, 5)
↳ app/graphql/sources/active_record_single_object.rb:9:in `block in fetch'
まとめ
過不足なく必要なデータのみを取得できるのがGraphQL
の良いところですが、N+1問題が発生してしまうと結局のところパフォーマンスの低下につながってしまいます。GraphQL
の強みを存分に活かすたには、GraphQL::Dataloader
等を活用したN+1問題の対策が必要不可欠と言えるでしょう。