GraphQL調査

TODO
- 2023年現在、GraphQLのパフォーマンス解析の状況に変化があったか調べたい
- 「GraphQLを使うと、ネストしたデータをする時にN+1が発生しやすい」って聞いたことあるけど、なんでなのかまだピンときてない
- DataLoaderって仕組みで解決してる、とかなんとか

参考記事

GraphQLとは何か
- Metaが開発しているWeb APIのための規格。
- 「クエリ言語」と「スキーマ言語」で構成されている。
- クエリ言語
- GraphQL APIのリクエストのための言語
- query(データ取得, Read)
- mutation(データ更新, Create-Update-Delete)
- subscription(サーバーサイドからのイベント通知)
- GraphQL APIのリクエストのための言語
- スキーマ言語
- GraphQL APIの仕様を記述するための言語
- リクエストされたクエリは、スキーマ言語で記述したスキーマに従って実行され、レスポンスを返す
- クエリ言語
- クエリが記述量が多い。クエリの構造がレスポンスの構造と似てる
- スキーマの型付けによって、型安全な運用ができる

シンプルな例
type Query {
currentUser: User!
}
type User {
id: ID!
name: String!
}
query GetCurrentUser {
currentUser {
id
name
}
}
{
"data": {
"currentUser": {
"id": "dXNlci80Mgo=",
"name": "foo",
}
}
}
ポイント
- スキーマに定義したフィールドのうち、クエリに指定したフィールドだけが返ってくる
- (スキーマとクエリが密接に関係してるので、クエリを書くためのいい感じのサポートツールがある)
- クエリの構造とレスポンスデータの構造が似てる
- フロントエンドの人にとって、Web APIに対する深い知識がなくても比較的楽に読み書きできる

GraphQLの利点
必要なデータを過不足なく取得できる
一般的なREST APIの場合、1つのAPIから取得できるリソースは1つだけ。例えば1つのページを作り上げるのに3つのリソースへのアクセスが必要な場合、3回のAPIコールが発生する事になる。(アンダーフェッチ)
GraphQLでは1つのクエリーで複数のリソースを取得する文法が規定されているため、クエリー内で宣言的に記述するだけで解決することが出来る。
また一般的なREST APIの場合、例えばUserテーブルのname属性だけが必要な場合でも、全てのフィールドのデータを取得してしまいがち。(オーバーフェッチ)
GraphQLではクエリの中でリソースのどのフィールドを取得するかを指定する文法が規定されているため、必要なデータだけを取得することができる。
クエリとレスポンスの構造が対応関係にある
クエリからレスポンスの構造を推測できるし、逆に求めるレスポンスに応じてクエリを書くことができる。
GaphQLのリクエストは一見するとRESTful APIと比べて冗長だけど、この情報量の多さは利点になることが多い。
例えばクライアントサイドのviewを作るときは、そのviewで必要な値をGraphQLのクエリの中にリストするだけで、過不足なくリソースをリクエストできる。
またコードリーディングの際に、Web APIの詳細を知らなくても、ある程度クライアントサイドのコードを読み進められる。
APIサーバーが提供する型をクライアントサイドで利用できる
ApolloなどのGraphQLクライアントを使うと、GraphQLのスキーマ情報等を元に、クライアントサイドで利用できる型やクライアントコードを生成出来るものがある。
これによってクライアントサイドへ外界からやってくる型の問題を解決(緩和)する事ができる。
スキーマとそれを利用するツールによる開発サポート
GraphQLはスキーマのあるWeb API規格。
このスキーマは、クエリやレスポンスの構造に加えて各々のフィールドの型を定義している。
だからスキーマ駆動開発ができ、またスキーマを利用したツールのサポートを受けられる。
例えばGraphQL Foundationが提供する公式のツールにGraphiQLがある。
これはGraphQLに対してクエリを発行して、レスポンスを閲覧するためのツール。
ただクエリを発行するだけでなく、クエリの補完やAPIリファレンスとの統合などの機能がある。
そしてその補完などの機能は、GraphiQLがスキーマの情報を利用することで実現いる。

GraphQLの欠点
パフォーマンスの分析が難しい
GraphQL APIのHTTPエンドポイントはひとつだけ。
New RelicなどのAPMでパフォーマンスの記録や分析などを行うとき、普通はエンドポイントごとに情報を収集する。
エンドポイントがひとつだけのGraphQLは全てのデータがまとめられてしまい、分析が難しい。
とはいえ、これはこれまでのパフォーマンス解析ツールがGraphQLを想定して作られていないことが根本的な原因なので、GraphQLが普及するにつれて改善されるはず。
(今読んでる記事が書かれたのが2018年なので、2023年現在どういう状況なのか気になる)
ライブラリへの依存
GraphQLクライアントをフルスクラッチで実装するのはそれほど難しくないが、GraphQLの処理系の実装コストはかなり大きい。
なので、多くの場合、既存のライブラリを使うことになる。
GraphQL APIの開発と運用コストは採用したライブラリに大きく依存することになるし、そのライブラリで未実装の機能は簡単には使えない。
この辺りは、処理がシンプルなRESTful APIと比べてデメリット。
画像や動画などの大容量バイナリの扱いが難しい
GraphQL自体はリクエストやレスポンスのシリアライズ方法は規定していないものの、多くのケースではJSON。
JSONはバイナリのシリアライズが苦手で、比較的エンコードサイズが軽量なBase64でもデータ量が1.3倍ほどになってしまう。
またサーバーサイドのJSONでシリアライザは基本的にオンメモリなので、メモリに乗り切らないほど大きなデータはそもそも処理できない。
シリアライザをMessagePackなどバイナリシリアライザにして大容量バイナリをシリアライズ時にファイルに退避するなどの工夫はできる。でもまさにその挙動がデフォルトであるHTTPのmultipart/form-dataと比較すると一手間かかってしまう。

Railsで使ってみる
Railsアプリを作る
$ ruby -v
ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [arm64-darwin21]
$ rails -v
Rails 6.1.7
$ rails new graphql-test --api
$ cd graphql-test
$ rails s
$ open 'http://localhost:3000'
適当なmodelとサンプルデータを作る
$ rails g model Book title:string
$ rails db:migrate
3.times do |i|
Book.create!(title: "本 #{i + 1}")
end
$ rails db:seed
GraphQLのセットアップ
+ gem 'graphql'
$ bundle install
$ rails g graphql:install
Running via Spring preloader in process 70415
create app/graphql/types
create app/graphql/types/.keep
create app/graphql/graphql_test_schema.rb
create app/graphql/types/base_object.rb
create app/graphql/types/base_argument.rb
create app/graphql/types/base_field.rb
create app/graphql/types/base_enum.rb
create app/graphql/types/base_input_object.rb
create app/graphql/types/base_interface.rb
create app/graphql/types/base_scalar.rb
create app/graphql/types/base_union.rb
create app/graphql/types/query_type.rb
add_root_type query
create app/graphql/mutations
create app/graphql/mutations/.keep
create app/graphql/mutations/base_mutation.rb
create app/graphql/types/mutation_type.rb
add_root_type mutation
create app/controllers/graphql_controller.rb
route post "/graphql", to: "graphql#execute"
Skipped graphiql, as this rails project is API only
You may wish to use GraphiQL.app for development: https://github.com/skevy/graphiql-app
create app/graphql/types/node_type.rb
insert app/graphql/types/query_type.rb
create app/graphql/types/base_connection.rb
create app/graphql/types/base_edge.rb
insert app/graphql/types/base_object.rb
insert app/graphql/types/base_object.rb
insert app/graphql/types/base_union.rb
insert app/graphql/types/base_union.rb
insert app/graphql/types/base_interface.rb
insert app/graphql/types/base_interface.rb
insert app/graphql/graphql_test_schema.rb
型定義ファイルを生成
$ rails g graphql:object Book
以下が生成される
# frozen_string_literal: true
module Types
class BookType < Types::BaseObject
field :id, ID, null: false
field :title, String
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
end
end
全ての本を取得するqueryを作成
$ mkdir app/graphql/queries
$ touch app/graphql/queries/base_query.rb
$ touch app/graphql/queries/books.rb
module Queries
class BaseQuery < GraphQL::Schema::Resolver
end
end
module Queries
class Books < Queries::BaseQuery
type [Types::BookType], null: false
def resolve
::Book.all.order(:id)
end
end
end
module Types
class QueryType < Types::BaseObject
...
+ field :books, resolver: Queries::Books
↑booksクエリが実行されると、Queries::Booksクラスのresolveメソッドが呼ばれるように設定
end
end
APIを叩いてデータを取得
$curl -X POST \
-H "Content-Type: application/json" \
--data '{ "query": "{ books { id title } }" }' \
http://localhost:3000/graphql
{"data":{"books":[{"id":"1","title":"本 1"},{"id":"2","title":"本 2"},{"id":"3","title":"本 3"}]}}
idを元に特定の本を取得するqueryを作成
$ touch app/graphql/queries/book.rb
module Queries
class Book < Queries::BaseQuery
argument :id, ID, required: true
type Types::BookType, null: false
def resolve(id:)
::Book.find(id)
end
end
end
module Types
class QueryType < Types::BaseObject
...
field :books, resolver: Queries::Books
+ field :book, resolver: Queries::Book
end
end
APIを叩いてデータを取得
$ curl -X POST -H "Content-Type: application/json" \
--data '{"query": "{ book(id: 2) { id title } }"}' \
http://localhost:3000/graphql
{"data":{"book":{"id":"2","title":"本 2"}}}

Mutation
引数の型定義ファイルを作る
$ touch app/graphql/types/book_input_type.rb
class Types::BookInputType < Types::BaseInputObject
argument :title, String, required: true
end
mutation(Create)を作る
$ app/graphql/mutations/create_book.rb
module Mutations
class CreateBook < Mutations::BaseMutation
argument :params, Types::BookInputType, required: true
field :book, Types::BookType, null: false
def resolve(params:)
book = Book.create!(params.to_h)
{ book: book }
rescue => e
GraphQL::ExecutionError.new(e.message)
end
end
end
module Types
class MutationType < Types::BaseObject
...
+ field :create_book, mutation: Mutations::CreateBook
end
end
APIを叩いてデータを生成
$ curl -X POST -H "Content-Type: application/json" \
--data '{"query": "mutation { createBook(input: {params: {title: \"New Book\"}}) { book { id title } } }"}' \
http://localhost:3000/graphql
{"data":{"createBook":{"book":{"id":"4","title":"New Book"}}}}

mutation(update)を作る
$ touch app/graphql/mutations/update_book.rb
module Mutations
class UpdateBook < Mutations::BaseMutation
argument :id, ID, required: true
argument :params, Types::BookInputType, required: true
field :book, Types::BookType, null: false
def resolve(id:, params:)
book = Book.find(id)
book.update!(params.to_h)
{ book: book }
rescue => e
GraphQL::ExecutionError.new(e.message)
end
end
end
module Types
class MutationType < Types::BaseObject
...
field :create_book, mutation: Mutations::CreateBook
+ field :update_book, mutation: Mutations::UpdateBook
end
end
APIを叩いてデータを更新
$ curl -X POST -H "Content-Type: application/json" \
--data '{"query": "mutation { updateBook(input: {id: 2, params: {title: \"updated book\"}}) { book { id title } } }"}' \
http://localhost:3000/graphql
{"data":{"updateBook":{"book":{"id":"2","title":"updated book"}}}}

mutation(delete)を作る
$ touch app/graphql/mutations/delete_book.rb
module Mutations
class DeleteBook < Mutations::BaseMutation
argument :id, ID, required: true
field :id, ID, null: false
def resolve(id:)
Book.find(id).delete
{ id: id }
rescue => e
GraphQL::ExecutionError.new(e.message)
end
end
end
module Types
class MutationType < Types::BaseObject
...
field :create_book, mutation: Mutations::CreateBook
field :update_book, mutation: Mutations::UpdateBook
+ field :delete_book, mutation: Mutations::DeleteBook
end
end
APIを叩いてデータを削除
$ curl -X POST -H "Content-Type: application/json" \
--data '{"query": "mutation { deleteBook(input: {id: 2}) { id } }"}' \
http://localhost:3000/graphql
{"data":{"deleteBook":{"id":"2"}}}

- HTTPのメソッドやステータスコードによる挙動の予測ができなくなる
- queryとmutationしかないということは、HTTPメソッドのGETかPOSTしかない状態に等しく、mutationの中でそれがリソースの追加・更新・削除のうちどれなのかを表現する方法は別に仕様レベルでは標準化されておらず、実装した本人以外から見たら挙動が予測しにくくなる
- Railsで使ったらMVCのレールのうちVCから割と外れる
- controllerとviewに入っていたはずのロジックが全てapp/graphql以下にくることになる。
- VとCが一緒になるだけだったら最悪いい(?)気もするけどqueryやmutationといったrootのフィールドにはいろんなリソースのロジックが一箇所に集まってくる上にroutes.rb相当の情報も来てしまう。
- N+1クエリの解決方法がいつもと違う感じになる