今回は、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つに分けて進めていきました。
- 型の定義
- Query の定義(データの取得)
- 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 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
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スキーマ周りの開発中は主にこちらを利用しました。
Insomnia のほうは主にREST APIを対象としたクライアントアプリですが、リクエストヘッダの設定やレスポンスヘッダの確認が容易なため、GraphQL APIに対するRate Limitや認証の実装および動作確認に利用しました。
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実装の参考になれば幸いです。 👋