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}]}}
广告
将在 10 秒后关闭
bannerAds