Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

GraphQLを導入しようとしている話

こんにちは。Studyplusでサーバーサイドを担当している金澤です。 弊社ではいまapiの一部にGraphQLを導入するべく取り組んでいます。

GraphQLってなんだという話や導入手順などはweb上にすでに沢山あると思います。 なのでそのへんはあっさりめで、検証にあたってどのような実装をしているかという話をします。

で、GraphQLってなんだ

公式ページから言葉を借りれば、

A query language for your API

です。

apiに対する問い合わせをクライアントで組み立てて柔軟にできます。

上記ページのデモがとても分かりやすいのでピンと来ない方は是非ご覧ください。

GraphQLでできる3つのこと

query

  • データの問い合わせ
  • 今回はこの話だけします

mutation

  • データの変更

subscription

  • いわゆるpub/sub

なぜGraphQLなのか

動機としては

  • パフォーマンスの改善のため、各種apiを一つにまとめようという話が以前からあった
    • GraphQLによってパフォーマンスがよくなるという話ではなく、apiが細分化しすぎていてインデックスも複雑(あるいは効いてない)というケースを整理しましょうという流れ
  • 画面をトライアンドエラーするとき、いちいち微調整にコミュニケーションコストがかかるのは勿体無い
    • ある程度好きにできるapiを用意するので、開発フェーズによってはそれを柔軟に使って完結してほしい
    • クライアントとサーバを同じ人が作るような体制の場合はこういう凝った仕組み必要ないかもしれない
  • ナウい
    • たまには新しいものを取り入れないと澱む

といったところです。

導入方法

まっさらなrailsプロジェクトにGraphQLを導入する手順です。

railsプロジェクトを作る

$ rails new graphql_test

graphql-rubyのインストール

https://github.com/rmosolgo/graphql-ruby

gem 'graphql'
$ bundle install
$ rails generate graphql:install
  • 上記コマンドでGemfileにgem 'graphiql-rails', group: :development というのが追加されるので、使う場合はもう一度bundle install

    • graphiqlは開発用のインタラクティブな画面です
    • 多分graphicとgraphqlの言葉遊びなのかな?
  • app/graphql以下にいろいろ関連ファイルができます

    • application.rbconfig.eager_load_paths << "#{config.root}/app/graphql" を足すと便利

graphiql

rails s して http://localhost:3000/graphiql から見れます

Hello, World!

インストールが終わったら、graphiql画面に{ testField }と入力して実行しましょう。 いつものが返ってきます。

Queryで頻出しそうなパターンをどう実装したかの話

ここからが本題です。 データの問い合わせapiを作るにあたって、よく登場するパターンをどう解決したかという例を三つほどご紹介します。

ユーザー権限をチェックしたいパターン

正しくインストールされていると、app/controllers/graphql_controller.rb というコントローラができてます。 下記のような感じです。

class GraphqlController < ApplicationController
  def execute
    variables = ensure_hash(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]

    context = {
      # Query context goes here, for example:
      # current_user: current_user,
    }
    result = GraphqlTestSchema.execute(
      query, variables: variables, context: context, operation_name: operation_name
    )
    render json: result
  rescue => e
    raise e unless Rails.env.development?
    handle_error_in_development e
  end
  # 以下略

コメントにあるように、コンテキストを意識したい場合はcontext に追加し、後から取り出せます。 権限チェックのためにログインユーザーが欲しい、というような場合は例えば下記のように実現できます(実際のコードではないです)。

GraphiQL::Rails.config.headers["Authorization"] = -> (context) { "Bearer hogehoge" }
    # 前略
    auth_header = request.headers["Authorization"]
    session = find_session_by_header(auth_header)
    context = {
        session: session
    }
    # 以下略

こうすると、query_typeやobject_type内でresolveする際にctxからセッションを取得できるようになります。

field :test, types.String do
    resolve ->(obj, args, ctx) {
        ctx[:session] # コントローラでセットしたオブジェクト
    }
}

ページングするパターン

データの問い合わせにページを指定したり、検索文字列を指定したりなどよくあると思います。

以下のようにクエリにはargumentを設定することができます。

Types::QueryType = GraphQL::ObjectType.define do
  name "Query"

  field :page, !types[types.Int] do
    description "ページングテスト"
    argument :per_page, types.Int, default_value: 5, prepare: ->(limit, _ctx) { [limit, 5].min }
    argument :page, types.Int, default_value: 1
    resolve ->(_obj, args, ctx) {
      page = args[:page]
      limit = args[:per_page]

      arr = %w(1 2 3 4 5 6 7 8 9 10)
      offset = [(page - 1), 0].max * limit
      last = offset + limit
      arr[offset...last]
    }
  end
end

上記query_type.rbを上記の内容にし、下記クエリをqraphiql画面から入力することで、ページングが確認できます。

{ page(per_page: 3, page: 2) }
{
  "data": {
    "page": [
      4,
      5,
      6
    ]
  }
}

lazyに呼ぶパターン

GraphQLはapi呼び出し側でクエリを自由に書けるので、呼び出される側が無駄な計算をしないように気をつける必要がありました。

例えば、下記のようなTypeがあったとします。

Types::Test::LazyType = GraphQL::ObjectType.define do
  name 'Lazy'

  field :karui, !types.String
  field :omoi, !types.String
  field :sinu, !types.String
end

このtypeを使った返事を以下のように実装した場合、

Types::QueryType = GraphQL::ObjectType.define do
  name "Query"

  field :lazy, !Types::Test::LazyType do
    resolve ->(_obj, _args, _ctx) {
      karui = "軽い!"
      omoi = -> () {
        sleep(3)
        "重い..."
      }.call # ここが重い
      sinu = ->() {
        raise "死ぬ"
      }.call # ここで死ぬ

     OpenStruct.new(karui: karui, omoi: omoi, sinu: sinu)
    }
  end
end
  • クエリで必要なものだけ取得できる意味がない
  • というかこの場合は必ず死ぬ
  • しかも順番に実行されていくので重い作業を待った末に死ぬ

という本末転倒なことになります。

そこで下記のように、必要な分だけ計算するようにしました。

Types::QueryType = GraphQL::ObjectType.define do
  name "Query"

  field :lazy, !Types::Test::LazyType do
    resolve ->(_obj, _args, _ctx) {
      res = OpenStruct.new
      def res.karui
        "軽い!"
      end

      def res.omoi # ここでは重くない
        sleep(3)
        "重い..."
      end

      def res.sinu # ここでは死なない
        raise "死ぬ"
      end

      res
    }
  end
end

こうすることで、

  • { lazy { karui } } 実行時は軽い
  • { lazy { karui omoi } } 実行時は重いものを取得したいのでしょうがない
  • { lazy { sinu karui omoi } } とすることで一瞬で死ぬため計算資源を無駄にしない
    • ただし、{ lazy { karui omoi sinu} } とすると順番に実行されてやはりomoi分が無駄になる

当たり前の話ですが、個別に実行できるようにし、必要なものを必要なだけ取るようにできると無駄がありませんでした。

終わりに

まだ手探り状態なので、実運用に乗せたら何が起きるかなどはわかっていませんし、テスト運用して採用を取りやめる可能性もゼロではないと思っています。

起きそうな問題としてパッと思いつくのは、

  • cartesian product問題やN+1問題に代表されるようなデータアクセス上の典型的諸問題が、レイヤーを一段またいで脳みそが二つになることによってより厄介になったりしないだろうか
    • たくさん取得してクライアントで選別しよう、みたいなコードを書こうと思えば書けてしまう
    • もちろんapiの作りっぷりで制限することは可能で、そういう意味では従来の作りとあまり変わっていないかもしれない
  • endpointが一つになるので、NewRelicなどでパフォーマンス計測する際よくわからないことにならないか?
    • クエリ別に集計する必要がありそうだが対応しているのか?
      • { hoge fuga piyo}fuga piyo hoge は同じものとして集計されてほしいのか違うのかなど場合によって違いそう
    • 関係ないけどNewRelicの無料プランがだいぶ制限されるようなのでどなたか良い代替プロダクトを教えてください

といったところでしょうか。

とはいえ、うまく使えば開発効率が上がりそうな手応えも感じています。 色々な導入例や運用例から、ベストプラクティスやアンチパターンが出来上がっていくと思いますし、追随していきたいと思います。