在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.png

有一种系统,拥有用户和文章的功能。用户可以撰写文章,并且能够保存自己喜欢的文章信息。

现在,让我们考虑在 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 方法中。

履行需要按照键和值的顺序传递值。其中键指定与传递给加载的值相同的值。通过这样做,加载可以将值作为返回值提取出来。

因为用文字解释可能不太容易理解,所以我也会附上图片来说明。

IMG_0208.PNG

希望你能够通过这个来正确理解,总结一下要点就是:

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的问题了,这样就可以更加专注于核心的问题了。

广告
将在 10 秒后关闭
bannerAds