GraphQL 反型模式 – 孫憂悩 –
这篇文章是 GraphQL Advent Calendar 2020的第7天的文章。
上一篇文章是由 @indigolain 写的关于以查询结果为核心的GraphQL错误处理的文章。
简述
鑑於太過關心孫子會變得很複雜,因此使用Loader并以宣告式的方式來寫應該會很好。我們以Ruby(graphql-ruby)作為示例,但這種思維方法應該也適用於其他語言和庫。
详细
假设推文上有一个点赞功能。
Tweet 1- 用户点赞
在这种情况下,我们假设在时间线上按顺序展示推文,并显示每个推文是否被自己点赞。
type Query {
timelineTweets: [TimelineTweet!]!
}
type TimelineTweet {
id: ID!
liked: Boolean!
}
如果我们按照通常的实现 REST API 的方式考虑,最初需要做的就是获取所有必要的内容。举个例子,我们可以使用 JOIN 来一次性获取所有内容。
为了不熟悉Ruby(ActiveRecord)的人们,下面也会写出对应的SQL。
class TimelineTweet < GraphQL::Schema::Object
field :id, ID, null: false
field :liked, Boolean, null: false
def liked
# sqlite だと bool がないので変換
object.liked == 1
end
end
class QueryType < GraphQL::Schema::Object
field :timeline_tweets, '[TimelineTweet]', null: true
def timeline_tweets
# SELECT tweets.*, (likes.id IS NOT NULL) AS liked FROM "tweets" LEFT OUTER JOIN "likes" ON "likes"."tweet_id" = "tweets"."id" WHERE ("likes"."user_id" = ? OR "likes"."user_id" IS NULL) [["user_id", 1]]
Tweet.left_outer_joins(:likes).select("tweets.*, (likes.id IS NOT NULL) AS liked").merge(Like.where(user_id: [nil, context[:current_user].id]))
end
end
class Schema < GraphQL::Schema
query QueryType
end
然而,在GraphQL中,根据客户端的查询需求可能会有所变化。根据查询的内容,”liked”字段可能是必需的,也可能不是必需的。
在graphql-ruby中,可以使用”lookahead”方法来检查需要的字段,以确定是否需要”liked”。
class QueryType < GraphQL::Schema::Object
field :timeline_tweets, '[TimelineTweet]', null: true, extras: [:lookahead]
def timeline_tweets(lookahead:)
# SELECT tweets.*, (likes.id IS NOT NULL) AS liked FROM "tweets" LEFT OUTER JOIN "likes" ON "likes"."tweet_id" = "tweets"."id" WHERE ("likes"."user_id" = ? OR "likes"."user_id" IS NULL) [["user_id", 1]]
if lookahead.selects?(:liked)
Tweet.left_outer_joins(:likes).select("tweets.*, (likes.id IS NOT NULL) AS liked").merge(Like.where(user_id: [nil, context[:current_user].id]))
else
Tweet.all
end
end
end
这种情况下,父母过于照顾子孙会导致参数变化丰富并且复杂地交织在一起的 REST API,这真是让人头疼。
在GraphQL中,应考虑将其拆分为一个loader来处理这种情况。在这里,我们使用graphql-batch,但也可以搜索其他语言的类似库,比如loader/data loader/batch loader等应该有一些库。
class LikesLoader < GraphQL::Batch::Loader
def initialize(user_id)
@user_id = user_id
end
def perform(tweet_ids)
Like.where(tweet_id: tweet_ids, user_id: @user_id).each {|l| fulfill(l.tweet_id, true) }
tweet_ids.each {|id| fulfill(id, false) unless fulfilled?(id) }
end
end
class TimelineTweet < GraphQL::Schema::Object
field :id, ID, null: false
field :liked, Boolean, null: false
def liked
LikesLoader.for(context[:current_user].id).load(object.id)
end
end
如果这样做,分支将从timeline_tweets消失。
class QueryType < GraphQL::Schema::Object
field :timeline_tweets, '[TimelineTweet]', null: true
def timeline_tweets
Tweet.all
end
end
总结
如果GraphQL解析器的实现变得困难,我们可以思考一下是否过度照顾了孙子的事情。
然而,在GraphQL中,对于父级应该在多大程度上照顾孙级的责任线是困难的。
不能断言一定不应该照顾孙级的责任。
例如,在这个例子中,可以说Tweet.all隐式地加载了TimelineTweet的id。
我可能還沒有清楚地表達出來,但我認為作為祖父憂鬱的一個指標,可能存在著「JOIN 查詢」。
整体代码
如果您保存并键入 $ ruby foo.rb,即可直接执行。
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'graphql'
gem 'activerecord', require: 'active_record'
gem 'sqlite3'
gem 'graphql-batch'
end
require 'logger'
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Schema.define do
create_table :tweets, force: true do |t|
end
create_table :likes, force: true do |t|
t.references :tweet
t.references :user
end
create_table :users, force: true do |t|
end
end
class Tweet < ActiveRecord::Base
has_many :likes
end
class Like < ActiveRecord::Base
belongs_to :tweet
belongs_to :user
end
class User < ActiveRecord::Base
has_many :likes
end
user = User.create!
tweet_1 = Tweet.create!
tweet_2 = Tweet.create!
tweet_3 = Tweet.create!
Like.create!(user: user, tweet: tweet_1)
Like.create!(user: user, tweet: tweet_3)
class LikedLoader < GraphQL::Batch::Loader
def initialize(user_id)
@user_id = user_id
end
def perform(tweet_ids)
Like.where(tweet_id: tweet_ids, user_id: @user_id).each {|l| fulfill(l.tweet_id, true) }
tweet_ids.each {|id| fulfill(id, false) unless fulfilled?(id) }
end
end
class TimelineTweet < GraphQL::Schema::Object
field :id, ID, null: false
field :liked, Boolean, null: false
def liked
LikedLoader.for(context[:current_user].id).load(object.id)
end
end
class QueryType < GraphQL::Schema::Object
field :timeline_tweets, '[TimelineTweet]', null: true
def timeline_tweets
Tweet.all
end
end
class Schema < GraphQL::Schema
query QueryType
use GraphQL::Batch
end
result = Schema.execute(<<~GQL, context: {current_user: user})
query {
timelineTweets {
id
liked
}
}
GQL
pp result.as_json
# {"data"=>
# {"timelineTweets"=>
# [{"id"=>"1", "liked"=>true},
# {"id"=>"2", "liked"=>false},
# {"id"=>"3", "liked"=>true}]}}