尝试使用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并进行比较。