在GraphQL的环境中,关于批处理
这篇文章是关于什么的?
我将根据我在使用Rails和GraphQL中获得的经验,对于gem graphql-batch和批处理机制进行描述。
如果你想要尝试运行实际的代码,请参考我在此仓库中提交的代码:https://github.com/tanaka51/sample-rails-graphql-batch。
我没有写过。
-
- GraphQL について
- N+1 について
圣诞倒数日历
本文是作为CBcloud Advent Calendar(https://qiita.com/advent-calendar/2020/cbcloud)的第18篇文章发布的。
使用批处理方式解决GraphQL的N+1问题
用GraphQL直接实现并确认N+1。
从这里开始,我们来考虑以下所示的类似博客系统的东西。

有一种系统,拥有用户和文章的功能。用户可以撰写文章,并且能够保存自己喜欢的文章信息。
现在,让我们考虑在 GraphQL 中执行以下查询的情况。
{
users {
name
favorites {
article {
title
}
}
}
}
这个查询是获取所有用户喜欢的文章标题的汇总。
使用graphql-ruby库来编写与此查询对应的实现,则如下所示。
module Types
class QueryType < Types::BaseObject
field :users, [UserType], null: false
def users
User.all
end
end
class UserType < Types::BaseObject
field :name, String, null: false
field :articles, [Types::ArticleType], null: false
field :favorites, [Types::FavoriteType], null: false
end
class FavoriteType < Types::BaseObject
field :user, Types::UserType, null: false
field :article, Types::ArticleType, null: false
end
class ArticleType < Types::BaseObject
field :title, String, null: false
field :author, Types::UserType, null: false
end
end
详细的内容就省略了,但在 graphql-ruby 中,我们可以定义需要的类型,并通过声明式的方式来组织它们,从而定义查询。
然后,如果按照这个方式执行,结果将会是N+1。
Started POST "/graphql" for 127.0.0.1 at 2020-12-14 21:58:27 +0900
Processing by GraphqlController#execute as HTML
Parameters: {"query"=>"{\n users {\n favorites {\n article {\n title\n }\n }\n }\n}", "variables"=>nil, "graphql"=>{"query"=>"{\n users {\n favorites {\n article {\n title\n }\n }\n }\n}", "variables"=>nil}}
Can't verify CSRF token authenticity.
(0.1ms) SELECT sqlite_version(*)
↳ app/controllers/graphql_controller.rb:15:in `execute'
User Load (0.5ms) SELECT "users".* FROM "users"
↳ app/controllers/graphql_controller.rb:15:in `execute'
Favorite Load (0.8ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = ? [["user_id", 1]]
↳ app/controllers/graphql_controller.rb:15:in `execute'
Article Load (0.5ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]]
↳ app/controllers/graphql_controller.rb:15:in `execute'
Article Load (0.2ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]]
↳ app/controllers/graphql_controller.rb:15:in `execute'
Favorite Load (0.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = ? [["user_id", 2]]
↳ app/controllers/graphql_controller.rb:15:in `execute'
Article Load (0.2ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
↳ app/controllers/graphql_controller.rb:15:in `execute'
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
↳ app/controllers/graphql_controller.rb:15:in `execute'
Completed 200 OK in 28ms (Views: 0.3ms | ActiveRecord: 2.8ms | Allocations: 9994)
避免常见的 N+1 写作方式
为了比较,我将在这里写一段常规的Rails代码。
def index
users = User.all.includes(favorites: :article).map do |user|
{
name: user.name,
favorites: user.favorites.map do |favorite|
article: {
title: favorite.article.title
}
end
}
end
render :json, { data: users }
end
也许会变成这种形式。这是为了避免典型的 N+1 而写的代码。考虑对资源 User 下面要附加什么,并将其记录在代码中,以避免 N+1。
该想法在GraphQL中无法很好地实现。原因是因为用户可以随意重新组合查询。举个例子,用户编写的文章列表也必须在同一个端点上提供。
{
users {
name
articles {
title
}
}
}
换句话说,我们无法事先定义”附加在底部的事物”是什么。虽然可以考虑列举查询的所有组合并对它们每一个进行定义,但这并不现实。
作为解决这个问题的一种方法,graphql-batch 是引入了一个叫做 Batch 的机制的 gem。在使用这个 gem 的同时,我想介绍一下什么是 Batch 机制。
使用graphql-batch来避免N+1的问题。
首先,让我们简单地使用 graphql-batch 来写下代码,看看它会变成什么样子。
首先准备一个加载器。
# app/loaders/user_favorites_loader.rb
class UserFavoritesLoader < GraphQL::Batch::Loader
def perform(user_ids)
favorites = Favorite.where(user_id: user_ids)
favorites.group_by(&:user_id).each do |user_id, favorites|
fulfill(user_id, favorites)
end
end
end
# app/loaders/article_loader.rb
class ArticleLoader < GraphQL::Batch::Loader
def perform(ids)
Article.where(id: ids).each{|a| fulfill(a.id, a)}
end
end
在类型定义的部分,我们要使用 Loader。
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
field :articles, [Types::ArticleType], null: true
field :favorites, [Types::FavoriteType], null: true
def favorites
UserFavoritesLoader.for.load(object.id)
end
end
end
module Types
class FavoriteType < Types::BaseObject
field :id, ID, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
field :user, Types::UserType, null: true
field :article, Types::ArticleType, null: true
def article
ArticleLoader.for.load(object.article_id)
end
end
end
当你运行它,服务器的日志将如下所示,你就会知道 N+1 的问题已经解决了。
Started POST "/graphql" for 127.0.0.1 at 2020-12-18 13:51:59 +0900
Processing by GraphqlController#execute as HTML
Parameters: {"query"=>"{\n users {\n favorites {\n article {\n title\n }\n }\n }\n}", "variables"=>nil, "graphql"=>{"query"=>"{\n users {\n favorites {\n article {\n title\n }\n }\n }\n}", "variables"=>nil}}
Can't verify CSRF token authenticity.
User Load (0.1ms) SELECT "users".* FROM "users"
↳ app/controllers/graphql_controller.rb:15:in `execute'
Favorite Load (0.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" IN (?, ?) [[nil, 1], [nil, 2]]
↳ app/loaders/favorites_loader.rb:4:in `group_by'
Article Load (0.3ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" IN (?, ?, ?, ?) [[nil, 4], [nil, 5], [nil, 1], [nil, 2]]
↳ app/loaders/article_loader.rb:3:in `perform'
Completed 200 OK in 10ms (Views: 0.4ms | ActiveRecord: 0.7ms | Allocations: 3675)
以上是使用Loader的最基本的方式。
GraphQL-Batch的使用方法。
我将更深入地解释一下grapql-batch。
首先让我们来看一下使用Loader的最简代码。
class ArticleLoader < GraphQL::Batch::Loader
def perform(ids)
Article.where(id: ids).each{|a| fulfill(a.id, a)}
end
end
module Types
class FavoriteType < Types::BaseObject
field :article, Types::ArticleType, null: true
def article
ArticleLoader.for.load(object.article_id)
end
end
end
基本的地使用 Loader 的方法如下:
1. 使用 for 方法进行初始化
2. 使用 load 方法传入关键值
下面是要求的中文翻译:
并且 Loader 的实现步骤如下:
1. 创建一个继承自 GraphQL::Batch::Loader 的类
2. 实现 perform 方法
这是以下内容的同义短语:然后。
接下来,将解释如何实现 perform 方法。
在生成最终响应时会调用 perform。当实际调用时,传递的值将作为一个数组传递给 load。在这个时机上,会一次性获取资源,并封装到 fulfill 方法中。
履行需要按照键和值的顺序传递值。其中键指定与传递给加载的值相同的值。通过这样做,加载可以将值作为返回值提取出来。
因为用文字解释可能不太容易理解,所以我也会附上图片来说明。

希望你能够通过这个来正确理解,总结一下要点就是:
load で値を集める
集めた値は perform で処理される
perform 内でリソースをまとめて取得し、 fulfill で詰めていく
fulfill で詰めた値を load が取り出して戻り値となる
下面是一个汉语的表述:
就是这样。
我认为上述要点即为批处理的概念。
不立即执行眼前的处理,而是先将其暂存在另一个地方,最后一起处理。
实施示例
以下是从 README 中提取的内容:
关于 graphql-batch,我们可以从它的初始化方法 .for 可以看出,它实际上是一个通用设计的组件。
field :product, Types::Product, null: true do
argument :id, ID, required: true
end
def product(id:)
RecordLoader.for(Product).load(id)
end
另外,在示例中可以找到 Loader 的例子,所以基本上你只需要使用这个就可以了。
https://github.com/Shopify/graphql-batch/tree/master/examples
看起来也许对 Loader 的实现方式一开始会有些难以理解,但我希望这篇文章能对您在阅读过程中提供一些帮助。
在资源计数中,解决N+1问题的方法
我想写一个作为未来实现的示例,即资源计数的 Loader。
例如,用它来处理这样的查询。
{
users {
favorites_count
}
}
如果以简单的方式来实现它,就会变成这样。
module Types
class UserType < Types::BaseObject
field :favorites_count, Integer, null: false
def favorites_count
object.favorites.count
end
end
end
Started POST "/graphql" for 127.0.0.1 at 2020-12-18 17:39:42 +0900
Processing by GraphqlController#execute as HTML
Parameters: {"query"=>"{\n users {\n favoritesCount\n }\n}", "variables"=>nil, "graphql"=>{"query"=>"{\n users {\n favoritesCount\n }\n}", "variables"=>nil}}
Can't verify CSRF token authenticity.
User Load (0.6ms) SELECT "users".* FROM "users"
↳ app/controllers/graphql_controller.rb:15:in `execute'
(0.5ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."user_id" = ? [["user_id", 1]]
↳ app/graphql/types/user_type.rb:16:in `favorites_count'
(0.2ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."user_id" = ? [["user_id", 2]]
↳ app/graphql/types/user_type.rb:16:in `favorites_count'
Completed 200 OK in 34ms (Views: 0.3ms | ActiveRecord: 2.0ms | Allocations: 10884)
以这种方式,将会发出与用户数量一样多的 COUNT 查询语句。
即使在使用 Rails 时考虑使用 counter_cache 的情况下,使用 Loader 可以使代码更加简洁。
class UserFavoritesCountLoader < GraphQL::Batch::Loader
def perform(user_ids)
Favorite.where(user_id: user_ids).group(:user_id).count.each do |user_id, count|
fulfill(user_id, count)
end
end
end
module Types
class UserType < Types::BaseObject
field :favorites_count, Integer, null: true
def favorites_count
UserFavoritesCountLoader.for.load(object.id)
end
end
end
上述所述的UserFavoritesLoader几乎不变,其内容是以收集到的user_id进行group by + count,将目标user_id作为键并保存计数。
Started POST "/graphql" for 127.0.0.1 at 2020-12-18 17:43:16 +0900
Processing by GraphqlController#execute as HTML
Parameters: {"query"=>"{\n users {\n favoritesCount\n }\n}", "variables"=>nil, "graphql"=>{"query"=>"{\n users {\n favoritesCount\n }\n}", "variables"=>nil}}
Can't verify CSRF token authenticity.
(0.1ms) SELECT sqlite_version(*)
↳ app/controllers/graphql_controller.rb:15:in `execute'
User Load (0.1ms) SELECT "users".* FROM "users"
↳ app/controllers/graphql_controller.rb:15:in `execute'
(0.2ms) SELECT COUNT(*) AS count_all, "favorites"."user_id" AS favorites_user_id FROM "favorites" WHERE "favorites"."user_id" IN (?, ?) GROUP BY "favorites"."user_id" [[nil, 1], [nil, 2]]
↳ app/loaders/user_favorites_count_loader.rb:3:in `perform'
Completed 200 OK in 25ms (Views: 0.3ms | ActiveRecord: 1.8ms | Allocations: 9009)
这样就能避免了N+1。
总结
我使用graphql-batch gem实现了一个简单的Loader,并解释了如何使用。虽然在本文中我们创建了一个有限的Loader,但如果我们创建一个通用的Loader,我们只需要使用Loader来获取资源,然后就不需要担心N+1的问题了,这样就可以更加专注于核心的问题了。