GraphQL APIをRailsアプリに実装した時のメモ

今回は、qnyp GraphQL APIを設計・実装する過程で役立ったものや参考になった情報を実際のコードを交えて紹介しようと思います(qnypはアニメの感想を記録するサービスです)。API設計の詳細や具体的な実装手順までは踏み込みません。

API実装はRailsアプリ内で行っていますので、紹介するライブラリなどは主にRuby向けのものとなります。

GraphQLの概要をつかむ

The Anatomy of a GraphQL Query

このエントリは、GraphQLを使う際に知っておく必要のあるOperationやVariables、Fragments、Directiveといった概念を手っ取り早く俯瞰する際に役立ちました。

GraphQL APIの実装を進めていくと、最終的には graphql.org にある Introduction to GraphQL を隅々まで読むことになるとは思いますが、まず大枠を把握しておきたいタイプの方には上記のエントリがおすすめです。

スキーマの設計

GraphQLスキーマの設計過程は、おおまかに以下の3つに分けて進めていきました。

  1. 型の定義
  2. Query の定義(データの取得)
  3. Mutation の定義(データの操作)

このうち、1と2においては以下のような既存のGraphQL APIの設計とドキュメントが最も参考になりました。

Relay とは?

GitHub の GraphQL API を調べていくと、突然、ID型やIssueConnection型(コネクション)のような Relay 由来の概念に出くわして戸惑います。これらは仕様なのか紳士協定なのかベストプラクティスなのかの位置付けが難しいのですが、実用的かつスケーラブルなAPIを作ろうとするのであればできるだけ設計に取り込んでおいたほうがよいと思います。

とくにコネクションは、現時点で、複数の値を返すフィールドにおいて「APIクライアント側でのページングの制御」や「結果の絞り込み・ソート」を行うための標準的な方法になりつつある印象です(graphql.org でも Pagination にて言及されています)。コネクションの理解には Explaining GraphQL Connections というエントリがビジュアル豊富かつ丁寧でおすすめです。

そもそも「Relay とはなんぞや」というところが気になる場合は、Apollo というGraphQLクライアントライブラリとの比較を行っている Relay vs Apollo - Comparing GraphQL clients for React apps というエントリが、Relay の立ち位置を相対的に捉えられるのでわかりやすいと思います。

※ Relay には新たに再設計された Relay Modern という実装(仕様)がありますが、上記で触れている範疇においてはその違いを想定しなくても困ることはないと思います。

後方互換性を保ちやすい Mutation の設計

Mutation の設計においては、考慮しておくべき原則を紹介した Designing GraphQL Mutations – Apollo GraphQL が参考になります。GraphQL APIは今のところ「API全体のバージョニングは行わず、後方互換性を保ちつつ1つの実装を継続的に進化させていく」というスタイルが主流のようですが、その際に後方互換性を保ちやすくするための土台として、このエントリで挙げられている原則はどれも有用でした。

Mutation のエラーメッセージ

GraphQL APIに限ったことでもないのですが、Mutation の実行において発生しうるエラーは様々な粒度になります。

例えば「操作の権限が不足している場合」はエラーメッセージを1つ返せば充分ですが、「ユーザーの入力値が不正な場合」には「どのフィールドの値がどのように不正か」という情報をAPIクライアントがプログラマブルに処理できるような構造でエラーを返したほうが、クライアント側のUXを向上させやすくなるのですが、このあたりについては Validation and User Errors in GraphQL Mutations で簡単に紹介されています。

GraphQL APIの実装

Railsアプリ内でGraphQL APIを実装するための土台となるライブラリとして GraphQL Ruby を利用しました。このライブラリはGitHubのGraphQL API実装にも利用されており、開発者の方も(おそらく)数ヶ月前にGitHubberになられたようです。メンテナンスが精力的に続けられており、Issueで議論やPull Requestの内容もGraphQL APIの設計・実装の参考になります。

認証・認可

GraphQLの仕様には認証や認可の仕組みは含まれていないため、 doorkeeper を利用してオーソドックスにOAuth 2のアクセストークンによる認証・認可を実装しました。

APIエンドポイントに対する権限のチェックは、基本的にはRailsのコントローラーレベルでdoorkeeperの機能を利用して行うことになります。

class GraphQLController < ApiController
  before_action :doorkeeper_authorize!

  def execute
    # ...
    result = QnypSchema.execute(query, variables: variables, context: context)
    render(json: result)
  end
end

ただし、単純なエンドポイントへのアクセスの可否だけではなく、アクセストークンの持つスコープをもとにクエリの実行を制限しようとするような場合は、アクション内のGraphQLのクエリを実行する部分に処理が移ったあとで権限のチェックを行う必要があります。これには、更新系のMutationを含むクエリは特定のスコープを持ったOAuth 2トークンでのみ行えるようにしたい、といったケースが該当します。

そのような用途向けに GraphQL Ruby は Analyzer API としてクエリ実行前のフックポイントを用意しているので、これを利用してクエリ内容に応じた権限のチェックを行うようにしています。

# Query analyzer for checking scope
class ScopeChecker
  SCOPES_FOR_QUERY = %w[public].freeze
  SCOPES_FOR_MUTATION = %w[public write].freeze

  # Public: Returns initial value for analysis state.
  #
  # query - A GraphQL::Query object.
  #
  # Returns Hash.
  def initial_value(query)
    required_scopes = query.mutation? ? SCOPES_FOR_MUTATION : SCOPES_FOR_QUERY
    accepted = true

    doorkeeper_token = query.context[:doorkeeper_token]
    raise ::GraphQL::AnalysisError, 'Access token not found.' unless doorkeeper_token

    if required_scopes.present?
      accepted = doorkeeper_token.scopes.has_scopes?(required_scopes)
    end

    {
      accepted: accepted,
      required_scopes: required_scopes,
    }
  end

  def call(memo, _visit_type, _irep_node)
    memo
  end

  def final_value(memo)
    return if memo[:accepted]
    raise ::Qnyp::GraphQL::Errors::ScopeRequired, memo[:required_scopes]
  end
end

class GraphQLController < ApiController
  before_action :doorkeeper_authorize!

  def execute
    # ...
    context = {
      doorkeeper_token: doorkeeper_token,
    }
    result = QnypSchema.execute(query, variables: variables, context: context)
    # ...
  end
end

QnypSchema = GraphQL::Schema.define do
  query_analyzer ScopeChecker.new
  # ...
end

API Explorerの実装

API利用者向けのUIとして qnyp GraphQL Explorer を提供するために graphiql-rails を利用しています。これは GraphiQL というJavaScriptベースの実装をRails Engineとしてマウントできるようにラップしたものです。

graphiql-rails を使うと、それをマウントしたRails側でHTTPリクエストヘッダをセットしたうえでGraphQL APIのエンドポイントにリクエストを送信できるため、「Railsアプリが保持しているトークンを使ってGraphQL ExplorerがAPIリクエストを行う」ような構成を簡単に実現することができます。

GraphQL Explorer のデモ
GraphQL Explorer

パフォーマンスの監視

GraphQL APIエンドポイントのパフォーマンスを監視する方法は色々とありますが、GraphQL Ruby を利用している場合は計測用のフックポイントがAPIとして提供されていますので、これを利用して必要な情報をログに出力したりNew Relicなどの外部のAPMに送信するクラスを実装するのが手っ取り早いです(年間$900の GraphQL::Pro では主要なAPMに対応したモニタリング機能があらかじめ用意されています)。

qnypはAPMとして Skylight を利用しているので、Skylight に情報を送信する以下のようなクラスを作成して利用しています。

require 'skylight'

module Qnyp
  module GraphQL
    module Skylight
      # GraphQL field instrumenter for Skylight
      # https://rmosolgo.github.io/graphql-ruby/schema/instrumentation
      class FieldTimerInstrumentation
        # Public: Instrument field resolving and report it to Skylight.
        # If a field was flagged to be timed, wrap its resolve proc with a timer.
        #
        # Set `timed` metadata in Type definition:
        #
        #   field :name, !types.String do
        #     timed true
        #   end
        #
        # type  - Field type object.
        # field - Field object.
        #
        # Returns Field type object.
        def instrument(type, field)
          if field.metadata[:timed]
            old_resolve_proc = field.resolve_proc
            new_resolve_proc = lambda do |obj, args, ctx|
              ::Skylight.instrument(title: "GraphQL field: #{type.name}.#{field.name}") do
                old_resolve_proc.call(obj, args, ctx)
              end
            end

            # Return a copy of `field`, with a new resolve proc
            field.redefine do
              resolve(new_resolve_proc)
            end
          else
            field
          end
        end
      end
    end
  end
end

このクラスを以下のようにスキーマ定義に組み込み、

## app/graphql/qnyp_schema.rb

# Custom metadata for field
GraphQL::Field.accepts_definitions(
  # Whether the field have to be instrumented (boolean)
  timed: GraphQL::Define.assign_metadata_key(:timed)
)

QnypSchema = GraphQL::Schema.define do
  # Set instrumenter for field
  instrument(:field, Qnyp::GraphQL::Skylight::FieldTimerInstrumentation.new)
  # ...
end

計測したいフィールドの定義内でtimedフラグをオンにして利用します。

Types::QueryType = GraphQL::ObjectType.define do
  connection :searchTitles, Types::TitleConnectionType, max_page_size: 100 do
    # ...
    resolve ->(obj, args, ctx) do
      # ...
    end
    timed true
  end

  # ...
end
Skylight のスクリーンショット
Skylight で GraphQL API エンドポイントを計測した様子

GraphQLに特化したAPMとしてApollo Opticsのようなサービスもあるので、今ならこうしたものを利用してもよいかもしれません(開発中に試用した際はまだRailsとのインテグレーションがこなれていない印象だったので利用を見送りました)。

パフォーマンスの改善

GraphQLクエリの内容によっては、1回の実行において同一のデータベーステーブルからのデータ取得クエリが複数回発行されてしまい、パフォーマンスがかなり低下します。これを回避するため Shopify/graphql-batch を導入しています。

graphql-batch を利用すると、一連のデータの取得処理(GraphQLレスポンスに含まれる各フィールド値の解決処理)を溜めておき、必要となったタイミングで1回の処理として実行するようなコードを簡単に書くことができるようになります。

## Before:

Types::LogType = GraphQL::ObjectType.define do
  name 'Log'

  field :episode do
    type !Types::EpisodeType
    resolve ->(obj, _args, _ctx) do
      # 複数の Log を取得する GraphQL Query において Episode.find は Log の件数だけ実行される
      Episode.find(obj.episode_id)
    end
  end
end

## After:

module Loaders
  # Batch record loader
  class RecordLoader < GraphQL::Batch::Loader
    def initialize(model)
      @model = model
    end

    def perform(ids)
      @model.where(id: ids).each { |record| fulfill(record.id, record) }
      ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
    end
  end
end

Types::LogType = GraphQL::ObjectType.define do
  name 'Log'

  field :episode do
    type !Types::EpisodeType
    resolve ->(obj, _args, _ctx) do
      # 複数の Log を取得する GraphQL Query において Episode.where が1回だけ実行される
      Loaders::RecordLoader.for(Episode).load(obj.episode_id)
    end
  end
end

GraphQL APIの動作確認

GraphQL APIの実装と動作確認を進めていくにあたって、以下の2つのクライアントアプリを利用しました。

graphiql-app は前述のJavaScriptによるGraphQLクライアントであるGraphiQLをElectronアプリとしてラップしたものです。下記のスクリーンショットのように「GraphQLスキーマに含まれるドキュメント」を確認しながらクエリを記述していけるため、GraphQLスキーマ周りの開発中は主にこちらを利用しました。

graphiql-app のスクリーンショット
graphiql-app

Insomnia のほうは主にREST APIを対象としたクライアントアプリですが、リクエストヘッダの設定やレスポンスヘッダの確認が容易なため、GraphQL APIに対するRate Limitや認証の実装および動作確認に利用しました。

Insomnia のスクリーンショット
Insomnia

GraphQL APIのテスト

APIエンドポイントに対してはGraphQL特有のテストの仕組み等はとくに使っておらず、主要なQueryおよびMutationをリクエストするテストをRSpecのRequest specとして実装しています。

また、GraphQLスキーマ定義に関しては Tracking Schema Changes with GraphQL-Ruby というエントリを参考に、スキーマのダンプファイルをバージョン管理するようにして、スキーマ定義の変更をトラッキングできるようにしています。

なお、GraphQLスキーマ定義のダンプファイルを管理するようにした場合、スキーマ定義を変更した後にダンプファイルの再生成を忘れてしまう可能性があるため、以下のようなテストで「Rubyコードで定義しているスキーマ」と「スキーマのダンプファイル」が整合することをチェックしています(上記エントリで紹介されているテストケースを元にしています)。

# spec/graphql/qnyp_schema_spec.rb
require 'rails_helper'

RSpec.describe 'QnypSchema' do
  let(:dumped_schema_path) { Rails.root.join('app', 'graphql', 'qnyp_schema.graphql') }
  let(:current_defn) { QnypSchema.to_definition }
  let(:printout_defn) { File.read(dumped_schema_path) }

  it 'equals dumped schema' do
    expect(current_defn).to eq(printout_defn)
  end
end

終わりに

アウトラインなどを作らず勢いで書き上げてきたためか、チュートリアルでもノウハウ集でもない散漫な内容になってしまいましたが、力付きたのでこのあたりでひとまず終わります。少しでもGraphQL API実装の参考になれば幸いです。 👋

アニメファンのためのオンラインコミュニティ

qnyp(キュニップ)に参加すると、感想を読み書きしたり、感想をもとに自分の視聴記録を確認することができるようになって、アニメを見るのがより楽しくなります。

qnypを見てみる

タグ

  1. 雑談 (7)
  2. イベント (4)
  3. 技術 (20)
  4. ビジネス (3)
  5. 統計情報 (2)
  6. サービス (15)
  7. 新機能 (11)

年別アーカイブ

  1. 2017 (7)
  2. 2015 (2)
  3. 2014 (7)
  4. 2013 (9)
  5. 2012 (16)