6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

GraphQLAdvent Calendar 2020

Day 20

GraphQL Ruby の resolver を並行に動かす

Last updated at Posted at 2020-12-20

この記事は、GraphQL Advent Calendar 2020 20 日目の記事です。
昨日は、@marin_a__ さんの Apollo Client × codegen でのLocalStateの使い方 でした。


次のような GraphQL schema があるとします。

type Query {
  articles: [Article!]
  users: [User!]
}

type Article {
  title: String!
}

type User {
  avatar: String!
}

Article、User、そして Avatar が別々のマイクロサービスになっているとします。

image.png

また、個々のサービスが提供している API が次の通りだとします。

  • Article サービスは articles を返す (ArticleServiceClient.get_articles)
  • User サービスは users (avatar はなく、id のみ) を返す (UserServiceClient.get_users)
  • Avatar サービスは user id を受け取って avatar を返す (AvatarServiceClient.get_avatar(id))

このときに、次のような query を解決することを考えてみましょう。

query {
  articles {
    title
  }
  users {
    avatar
  }
}

素直に書くと次のようになりそうです。

class ArticleType < GraphQL::Schema::Object
  field :title, String, null: false
end

class UserType < GraphQL::Schema::Object
  field :avatar, String, null: false

  def avatar
    AvatarServiceClient.get_avatar(object.id)
  end
end

class QueryType < GraphQL::Schema::Object
  field :users, [UserType], null: true
  field :articles, [ArticleType], null: true

  def users
    UserServiceClient.get_users
  end

  def articles
    ArticleServiceClient.get_articles
  end
end

class TestSchema < GraphQL::Schema
  query QueryType
end

このとき、users を返すのに 2 秒、articles を返すのに 3 秒、avatar を返すのに 1 秒かかるとし、users が 3 人いるとすると、合計で 2 + 3 + 1 * 3 で 8 秒かかります。

image.png

しかし、articles と users は schema 上独立しているため、並列にリクエストを投げることができそうに思えます。
GraphQL Ruby では、遅延評価するための仕組みがあり、これを concurrent-ruby と組み合わせることで並行処理 1 をすることができます。

具体的には、以下のようにサービスへのリクエストをする部分を Concurrent::Promises.future で包み、lazy_resolve に設定をすることで実現できます。

class UserType < GraphQL::Schema::Object
  def avatar
    Concurrent::Promises.future do
      AvatarServiceClient.get_avatar(object.id)
    end
  end
end

class QueryType < GraphQL::Schema::Object
  def users
    Concurrent::Promises.future do
      UserServiceClient.get_users
    end
  end

  def articles
    Concurrent::Promises.future do
      ArticleServiceClient.get_articles
    end
  end
end

class TestSchema < GraphQL::Schema
  lazy_resolve Concurrent::Promises::Future, :value!
end

ただし、GraphQL Ruby の lazy_resolve は兄弟関係にある field すべてを待ち合わせるようです。
この例では users が 1 秒 articles よりも先に返せますが、articles を待ち合わせてから avatar が評価されます。
つまり、users のあとすぐに avatar が動けば 3 秒で済むところ、実際には 4 秒かかることになります。

コード

以下のコードを保存して、$ ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 FUTURE=1 ruby a.rb のようにすると動作を確認することができます。 FUTURE=1 の有無によって 8 秒強の実行時間が 4 秒強になることが確認できます。

require "bundler/inline"

gemfile do
  source "https://rubygems.org"

  gem "graphql"
  gem "concurrent-ruby"
end

query = <<~Q
  query {
    articles {
      title
    }
    users {
      avatar
    }
  }
Q

require 'logger'

$logger = Logger.new(STDOUT)

module ArticleServiceClient
  Article = Struct.new(:id, :title, keyword_init: true)

  ALL_ARTICLES = Array.new(3) do |i|
    Article.new(id: i, title: "title#{i}")
  end

  def self.get_articles
    $logger.debug("#{name}##{__method__} start")
    sleep ENV['ARTICLE_SERVICE_SLEEP'].to_i
    $logger.debug("#{name}##{__method__} end")

    ALL_ARTICLES
  end
end

module UserServiceClient
  User = Struct.new(:id, keyword_init: true)

  ALL_USERS = Array.new(3) do |i|
    User.new(id: i)
  end

  def self.get_users
    $logger.debug("#{name}##{__method__} start")
    sleep ENV['USER_SERVICE_SLEEP'].to_i
    $logger.debug("#{name}##{__method__} end")

    ALL_USERS
  end
end

module AvatarServiceClient
  def self.get_avatar(id)
    $logger.debug("#{name}##{__method__} start")
    sleep ENV['AVATAR_SERVICE_SLEEP'].to_i
    $logger.debug("#{name}##{__method__} end")

    "#{id}.jpg"
  end
end

class ArticleType < GraphQL::Schema::Object
  field :title, String, null: false

  def title
    $logger.debug("#{self.class}##{__method__}")
    object.title
  end
end

class UserType < GraphQL::Schema::Object
  field :avatar, String, null: false

  def avatar
    if ENV['FUTURE']
      Concurrent::Promises.future do
        AvatarServiceClient.get_avatar(object.id)
      end
    else
      AvatarServiceClient.get_avatar(object.id)
    end
  end
end

class QueryType < GraphQL::Schema::Object
  field :articles, [ArticleType], null: true
  field :users, [UserType], null: true

  def articles
    if ENV['FUTURE']
      Concurrent::Promises.future do
        ArticleServiceClient.get_articles
      end
    else
      ArticleServiceClient.get_articles
    end
  end

  def users
    if ENV['FUTURE']
      Concurrent::Promises.future do
        UserServiceClient.get_users
      end
    else
      UserServiceClient.get_users
    end
  end
end

class TestSchema < GraphQL::Schema
  query QueryType
  lazy_resolve Concurrent::Promises::Future, :value!
end

pp TestSchema.execute(query).to_h

__END__
$ time ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 ruby a.rb 
D, [2020-12-20T12:11:14.873364 #23151] DEBUG -- : ArticleServiceClient#get_articles start
D, [2020-12-20T12:11:17.876517 #23151] DEBUG -- : ArticleServiceClient#get_articles end
D, [2020-12-20T12:11:17.876831 #23151] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:17.876937 #23151] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:17.877046 #23151] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:17.877143 #23151] DEBUG -- : UserServiceClient#get_users start
D, [2020-12-20T12:11:19.879268 #23151] DEBUG -- : UserServiceClient#get_users end
D, [2020-12-20T12:11:19.879546 #23151] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:20.880675 #23151] DEBUG -- : AvatarServiceClient#get_avatar end
D, [2020-12-20T12:11:20.880875 #23151] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:21.882006 #23151] DEBUG -- : AvatarServiceClient#get_avatar end
D, [2020-12-20T12:11:21.882193 #23151] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:22.883306 #23151] DEBUG -- : AvatarServiceClient#get_avatar end
{"data"=>
  {"articles"=>[{"title"=>"title0"}, {"title"=>"title1"}, {"title"=>"title2"}],
   "users"=>[{"avatar"=>"0.jpg"}, {"avatar"=>"1.jpg"}, {"avatar"=>"2.jpg"}]}}
ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 ruby a.rb  0.26s user 0.02s system 3% cpu 8.286 total

$ time ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 FUTURE=1 ruby a.rb
D, [2020-12-20T12:11:37.120206 #23200] DEBUG -- : ArticleServiceClient#get_articles start
D, [2020-12-20T12:11:37.120294 #23200] DEBUG -- : UserServiceClient#get_users start
D, [2020-12-20T12:11:39.120599 #23200] DEBUG -- : UserServiceClient#get_users end
D, [2020-12-20T12:11:40.120562 #23200] DEBUG -- : ArticleServiceClient#get_articles end
D, [2020-12-20T12:11:40.120985 #23200] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:40.121188 #23200] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:40.121261 #23200] DEBUG -- : ArticleType#title
D, [2020-12-20T12:11:40.121934 #23200] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:40.121994 #23200] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:40.122088 #23200] DEBUG -- : AvatarServiceClient#get_avatar start
D, [2020-12-20T12:11:41.122276 #23200] DEBUG -- : AvatarServiceClient#get_avatar end
D, [2020-12-20T12:11:41.122413 #23200] DEBUG -- : AvatarServiceClient#get_avatar end
D, [2020-12-20T12:11:41.122499 #23200] DEBUG -- : AvatarServiceClient#get_avatar end
{"data"=>
  {"articles"=>[{"title"=>"title0"}, {"title"=>"title1"}, {"title"=>"title2"}],
   "users"=>[{"avatar"=>"0.jpg"}, {"avatar"=>"1.jpg"}, {"avatar"=>"2.jpg"}]}}
ARTICLE_SERVICE_SLEEP=3 USER_SERVICE_SLEEP=2 AVATAR_SERVICE_SLEEP=1 FUTURE=1   0.22s user 0.07s system 6% cpu 4.285 total
  1. Ruby の Thread を利用するため、GVL の関係で Ruby レベルでは並列ではなく並行になります。しかし、IO 待ちのケースでは GVL は開放されるため、今回のようなサービス間通信であれば並列に行われます。https://docs.ruby-lang.org/ja/latest/doc/spec=2fthread.html

6
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
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?