7
2

More than 3 years have passed since last update.

GraphQL アンチパターン - 孫煩悩 -

Posted at

この記事は GraphQL Advent Calendar 2020 7 日目の記事です。
前回の記事は @indigolain さんの クエリ結果を軸としたGraphQLのエラーハンドリング でした。

概要

孫の面倒を見すぎると分岐が大変になるので、Loader を活用して宣言的に書くとよさそう、という話です。
例としては Ruby (graphql-ruby) で示しますが、考え方は他の言語、ライブラリでも同じになるはずではないかと考えています。

詳細

Tweet にいいねする機能があるとします。

Tweet 1-* Like *-1 User

このとき、タイムラインで Tweet を並べつつ、各々の Tweet を自分がいいねしたかどうかを表示するとします。

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 は必要かもしれませんし、必要でないかもしれません。

liked が必要かどうかは、例えば graphql-ruby では lookahead という方法で、どの field が要求されているかをチェックすることが可能です。

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 resolver の実装が難しくなってきたら、孫の面倒を見すぎていないか考えてみましょう。

とはいえ、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}]}}
7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2