尝试使用GraphQL Ruby的Dataloader

当使用GraphQL Ruby时,我认为可以使用批量加载来解决SQL的N+1问题。一些著名的gem包括GraphQL::Batch、BatchLoader和 Dataloader等。个人而言,我使用过Dataloader,但据我了解,它似乎从2018年开始就没有更新了。去年,GraphQL Ruby本身就引入了Dataloader,所以这次我想尝试使用它。

GraphQL::Dataloader 是一个用于解决数据加载问题的工具.

文档在这里。

 

GraphQL::Dataloader是一种用于高效访问数据库的工具,它使用Ruby的Fiber,并且似乎还支持Ruby 3的非阻塞Fiber。受其影响,以下两个方面值得一提。

    • https://github.com/bessey/graphql-fiber-test/tree/no-gem-changes

 

    https://github.com/shopify/graphql-batch

Dataloader与GraphQL-Batch有所不同,GraphQL-Batch和其他加载器使用Promise,而Dataloader则使用Fiber,这是它的特点。据Robert Mosolgo(Dataloader的贡献者)说,选择使用Fiber是因为它是Ruby本身就具备的功能,可以实现并发I/O,而不需要使用其他复杂的方法,这是使用Promise容易变得复杂的地方。

 

提前提出的条件

    • graphql v1.13.2

 

    rails v7.0.0

准备之前

我們將根據樣本數據進行驗證。
我們將簡單地創建三個表User、Article和Like。

class User < ApplicationRecord
  has_many :articles, foreign_key: 'author_id'
end

class Article < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :likes
end

class Like < ApplicationRecord
  belongs_to :article, validate: true
  belongs_to :user, validate: true
end

场地的情况如下。

module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: false
    field :articles, Types::ArticleType.connection_type, null: false
  end
end

module Types
  class ArticleType < Types::BaseObject
    field :id, ID, null: false
    field :title, String, null: false
    field :body, String, null: false
    field :author, Types::UserType, null: false
    field :liked, Types::LikeConnection, null: false, method: :likes
  end
end

module Types
  class LikeType < Types::BaseObject
    field :id, ID, null: false
    field :user, Types::UserType, null: false
    field :article, Types::ArticleType, null: false
    field :liked_at, Int, null: false, method: :created_at
  end
end

这次想要的查询大致如下:获取当前用户投稿的文章列表以及喜欢该文章的用户列表。其中部分页面需要使用连接进行分页处理。

query {
  currentUser {
    name
    articles {
      edges {
        node {
          title
          body
          liked {
            count
            edges {
              node {
                user {
                  name
                }
              }
            }
          }
        }
      }
    }
  }
}
预先生成虚拟数据
生成要获取的数据。
查询的结果如下:
{
“data”: {
“currentUser”: {
“name”: “test_user1”,
“articles”: {
“edges”: [
{
“node”: {
“title”: “第一篇文章”,
“body”: “第一篇文章的内容”,
“liked”: {
“count”: 4,
“edges”: [
{
“node”: {
“user”: {
“name”: “test_user2”
}
}
},
{
“node”: {
“user”: {
“name”: “test_user3”
}
}
},
{
“node”: {
“user”: {
“name”: “test_user4”
}
}
},
{
“node”: {
“user”: {
“name”: “test_user5”
}
}
}
]
}
}
},
{
“node”: {
“title”: “第二篇文章”,
“body”: “第二篇文章的内容”,
“liked”: {
“count”: 1,
“edges”: [
{
“node”: {
“user”: {
“name”: “test_user2”
}
}
}
]
}
}
},
{
“node”: {
“title”: “第三篇文章”,
“body”: “第三篇文章的内容”,
“liked”: {
“count”: 2,
“edges”: [
{
“node”: {
“user”: {
“name”: “test_user3”
}
}
},
{
“node”: {
“user”: {
“name”: “test_user4”
}
}
}
]
}
}
},
{
“node”: {
“title”: “第四篇文章”,
“body”: “第四篇文章的内容”,
“liked”: {
“count”: 0,
“edges”: []
}
}
},
{
“node”: {
“title”: “第五篇文章”,
“body”: “第五篇文章的内容”,
“liked”: {
“count”: 1,
“edges”: [
{
“node”: {
“user”: {
“name”: “test_user5”
}
}
}
]
}
}
}
]
}
}
}
}

首先试着发出查询

如果按照原样提交查询,会直接导致N+1问题的出现,因此我们需要解决这个问题。

  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]

  Article Load (0.2ms)  SELECT "articles".* FROM "articles" WHERE "articles"."author_id" = ? LIMIT ?  [["author_id", 1], ["LIMIT", 20]]
  Like Load (0.1ms)  SELECT "likes".* FROM "likes" WHERE "likes"."article_id" = ? LIMIT ?  [["article_id", 1], ["LIMIT", 20]]
  Like Load (0.1ms)  SELECT "likes".* FROM "likes" WHERE "likes"."article_id" = ? LIMIT ?  [["article_id", 2], ["LIMIT", 20]]
  Like Load (0.2ms)  SELECT "likes".* FROM "likes" WHERE "likes"."article_id" = ? LIMIT ?  [["article_id", 3], ["LIMIT", 20]]
  Like Load (0.2ms)  SELECT "likes".* FROM "likes" WHERE "likes"."article_id" = ? LIMIT ?  [["article_id", 4], ["LIMIT", 20]]
  Like Load (0.5ms)  SELECT "likes".* FROM "likes" WHERE "likes"."article_id" = ? LIMIT ?  [["article_id", 5], ["LIMIT", 20]]

  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 5], ["LIMIT", 1]]
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  User Load (0.7ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 5], ["LIMIT", 1]]

加载程序的引入。

如果在新项目中使用`rails generate graphql:install`命令,则已经启用了该功能,只需要在模式(schema)中添加以下行即可。

class MySchema < GraphQL::Schema
  # ...
+  use GraphQL::Dataloader
end

购买单曲唱片

首先,我们将从like.user部分的取单记录的belongs_to关系开始准备。

在这个实施过程中,

    • Source

 

    resolverのメソッド

这是两个选项。

在进行解释之前,请先贴上代码。这几乎是直接从文档中复制的示例。

 module Sources
  class UserById < GraphQL::Dataloader::Source
    def initialize
      @model_class = ::User
    end

    def fetch(ids)
      records = @model_class.where(id: ids)
      ids.map { |id| records.find { |r| r.id == id.to_i } }
    end
  end
end
module Types
  class LikeType < Types::BaseObject

    field :user, Types::UserType, null: false

+    def user
+      dataloader.with(::Sources::UserById).load(object.user_id)
+    end
  end
end

通过这个操作,可以通过一个查询来获得用户。

  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ?)  [["id", 2], ["id", 3], ["id", 4], ["id", 5]]

解释

首先是关于资源的问题。

 

该Source类用于描述批量加载的操作,继承自GraphQL::Dataloader::Source,并且实现了fetch方法。
通过GraphQL::Dataloader生成实例,并调用fetch方法,该方法的参数是用于获取数据的键(本例中为ids)。本次操作将通过该键获取User的数据。
返回值应该按传入参数的键顺序返回相应的对象。

在resolver端我们可以进行如下获取。

def user
  dataloader.with(::Sources::UserById).load(object.user_id)
end

根据这个例子,似乎通过load将数据放入Fiber的队列中进行延迟加载。在这种情况下,object.user_id作为参数传递给Source的fetch方法。

获取多条记录

这里也先放上代码。
基本上和单曲记录没有太大区别。

module Sources
  class LikesByUserId < GraphQL::Dataloader::Source
    def initialize
      @model_class = ::Like
    end

    def fetch(keys)
      records = @model_class.where(article_id: keys)
                            .group_by { |record| record.article_id }
      keys.map { |key| records[key] || [] }
    end
  end
end
module Types
  class ArticleType < Types::BaseObject

    field :liked, Types::LikeConnection, null: false

+    def liked
+      dataloader.with(::Sources::LikesByUserId).load(object.id)
+    end
  end
end

這裡唯一有些變化的是fetch方法。
由於這裡可能會返回多條記錄給一個鍵,因此我們進行了group_by操作。
返回值同樣按照鍵的順序返回一個數組。
僅憑這樣,多個記錄也可以進行延遲加載。

结果如下,变得相当高效的查询。

  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Article Load (0.1ms)  SELECT "articles".* FROM "articles" WHERE "articles"."author_id" = ? LIMIT ?  [["author_id", 1], ["LIMIT", 20]]
  Like Load (0.1ms)  SELECT "likes".* FROM "likes" WHERE "likes"."article_id" IN (?, ?, ?, ?, ?)  [["article_id", 1], ["article_id", 2], ["article_id", 3], ["article_id", 4], ["article_id", 5]]
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ?)  [["id", 2], ["id", 3], ["id", 4], ["id", 5]]

仍然不明白的事情

与GraphQL::Batch等相比,GraphQL Dataloader的引入时间还不久。因此,这次我们只尝试了相当简单的内容,对于实际运营中出现的复杂情况是否能够经受住考验尚不清楚。

另外,當我查看文件時,我有一種感覺,即即使處理方式相似,但類別數量似乎仍在增加。在源文件增加時,是否能有效地實現和管理尚不清楚。

最终

虽然我还没有使用过诸如GraphQL::Batch之类的其他Gem,但由于GraphQL Ruby已经包含在其中,所以引入非常容易。如果您希望使用方便,我认为它非常好。我也希望尝试触摸其他的Loader并进行比较。

bannerAds