Open11

GraphQL調査

ピン留めされたアイテム
DANDAN

TODO

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

GraphQLとは何か

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

シンプルな例

スキーマ
type Query {
  currentUser: User!
}

type User {
  id: ID!
  name: String!
}
↑のスキーマに対するクエリの例
query GetCurrentUser {
  currentUser {
    id
    name
  }
}
レスポンス
{
  "data": {
    "currentUser": {
      "id": "dXNlci80Mgo=",
      "name": "foo",
    }
  }
}

ポイント

  • スキーマに定義したフィールドのうち、クエリに指定したフィールドだけが返ってくる
    • (スキーマとクエリが密接に関係してるので、クエリを書くためのいい感じのサポートツールがある)
  • クエリの構造とレスポンスデータの構造が似てる
    • フロントエンドの人にとって、Web APIに対する深い知識がなくても比較的楽に読み書きできる
DANDAN

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がスキーマの情報を利用することで実現いる。

DANDAN

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と比較すると一手間かかってしまう。

DANDAN

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
db/seeds.rb
3.times do |i|
  Book.create!(title: "本 #{i + 1}")
end
$ rails db:seed

GraphQLのセットアップ

Gemfile
+ gem 'graphql'
shell
$ bundle install
shell
$ 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

以下が生成される

app/graphql/types/book_type.rb
# 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
app/graphql/queries/base_query.rb
module Queries
  class BaseQuery < GraphQL::Schema::Resolver
  end
end
app/graphql/queries/books.rb
module Queries
  class Books < Queries::BaseQuery

    type [Types::BookType], null: false

    def resolve
      ::Book.all.order(:id)
    end
  end
end
app/graphql/types/query_type.rb
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
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
app/graphql/types/query_type.rb
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"}}}
DANDAN

Mutation

引数の型定義ファイルを作る

$ touch app/graphql/types/book_input_type.rb
app/graphql/book_input.rb
class Types::BookInputType < Types::BaseInputObject
  argument :title, String, required: true
end

mutation(Create)を作る

$ app/graphql/mutations/create_book.rb
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
app/graphql/types/mutation_type.rb
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"}}}}
DANDAN

mutation(update)を作る

$ touch app/graphql/mutations/update_book.rb
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
app/graphql/types/mutation_type.rb
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"}}}}
DANDAN

mutation(delete)を作る

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

https://k0kubun.hatenablog.com/entry/graphql