はじめに
rails6でGraphQLを用いた開発を行ったので、導入方法や使い方をまとめてみました。
今回はQuery/アソシエーション/N+1問題を取り上げて、ご紹介します。
開発環境
ruby2.7.1
rails6.0.3
GraphQL
1. GraphQLって何?
GraphQLとはAPIリクエストのためクエリ言語で下記の特徴があります。
- エンドポイントは
/graphql
1つのみ - Query: データの取得(Get)
- Mutation: データの作成、更新、削除(Create, Update, Delete)
RESTとの大きな違い一つ目のエンドポイントが一つということです。
RESTの場合、/sign_up
, /users
, /users/1
など複数のエンドポイントが存在しますが、
GraphQLの場合は、エンドポイントは/graphql
のみです。
RESTでは複数のリソースで必要な場合、複数のAPIリクエストが必要ですが、
GraphQLはエンドポイントが一つなので、必要なデータを一回で取得できコードがシンプルになります。
2. 今回使用するテーブル
親のUserテーブルと子のPostテーブルの二つで実装します。
Userが複数のPostを投稿できる1対多の関係とします。
$ rails g model User name:string email:string
$ rails g model Post title:string description:string user:references
$ rails db:migrate
Userテーブル
カラム | 型 |
---|---|
name | string |
string |
class User < ApplicationRecord
has_many :posts, dependent: :destroy
end
Postテーブル
カラム | 型 |
---|---|
title | string |
description | string |
class Post < ApplicationRecord
belongs_to :user
end
3. railsにGraphQLを導入する
gemをインストールします。
gem 'graphql' #追加
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'graphiql-rails' #開発環境に追加
end
require "sprockets/railtie" #コメントアウトを外す
$ bundle install
$ rails generate graphql:install #GraphQLに関するファイルが作成されます
routes.rb
に下記を追加
エンドポイントが開発環境では/graphiql
, 本番環境では/graphql
となります。
Rails.application.routes.draw do
if Rails.env.development?
# add the url of your end-point to graphql_path.
mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql"
end
post '/graphql', to: 'graphql#execute' #ここはrails generate graphql:installで自動生成される
end
詳細は下記にまとめましたので参考にしてください。
Rails6のAPIモードでGraphQLを使う方法(エラー対策も含む)
4. User一覧を取得するQuery
GraphQLではType(modelごとに定義する)を持っており、そのTypeに従い、Queryを実行してデータを取得します。
UserのObjectTypeを作成
下記のコマンドでUserのTypeを作成します。
!
をつけるとnull:false
が追加されます。
$ rails g graphql:object User id:ID! name:String! email:String!
下記のファイルが生成されます。
module Types
class UserType < Types::BaseObject
field :id, ID, null: false # `!`をつけると`null:false`が追加されます。
field :name, String, null: false
field :email, String, null: false
end
end
UserのQueryを作成
先ほど作成したuser_type
を元にqueryを作成します。
module Types
class QueryType < Types::BaseObject
field :users, [Types::UserType], null: false # userを配列で定義する
def users
User.all # user一覧を取得
end
end
end
Userのデータをコンソールで作成します。
$ rails c
$ > User.create(name: "user1", email: "user-1@test.com")
$ > User.create(name: "user2", email: "user-2@test.com")
Queryを実行する
準備は整いましたので、サーバーを立ち上げてGraphiqlで確認します。(http://localhost:3000/graphiql)
$ rails s
下記のqueryを実行します。
query{
users{
id
name
email
}
}
するとjson形式のレスポンスが返ってきます。
usersのTypeを配列にしているので配列となっています。
{
"data": {
"users": [
{
"id": "1",
"name": "user1",
"email": "user-1@test.com"
},
{
"id": "2",
"name": "user2",
"email": "user-2@test.com"
}
]
}
}
http://localhost:3000/graphiql に接続して、実行した結果です。
必要なデータだけリクエストし受け取る場合
すべてカラムのデータが必要でない場合はqueryを変更します。
例えばuserのidだけ取得することもできます。
query{
users{
id
}
}
レスポンス
{
"data": {
"users": [
{
"id": "1",
},
{
"id": "2",
}
]
}
}
5. アソシエーション
次にUserに紐づくPostを取得してみます。
Userを同じようにObjectTypeとQueryを作成します。
PostのObjectTypeを生成
$ rails g graphql:object Post id:ID! title:String! description:String!
下記のファイルが生成されます。
Userのデータを取得するためにfield :user, Types::UserType, null: false
を追加します。
module Types
class PostType < Types::BaseObject
field :id, ID, null: false
field :title, String, null: false
field :description, String, null: false
field :user, Types::UserType, null: false # この一文を追加。belongs_to :userのようなもの
end
end
UserTypeにはfield :posts, [Types::PostType], null: false
を追加します。
こちらは複数データが紐づくので配列で定義します。
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :email, String, null: false
field :posts, [Types::PostType], null: false # この一文を追加。has_many :postsのようなもの
end
end
$ rails c
$ > Post.create(title: "title", description: "description", user_id: 1)
PostのQueryを生成する
module Types
class QueryType < Types::BaseObject
field :users, [Types::UserType], null: false
def users
User.all
end
# 下記を追加
field :posts, [Types::PostType], null: false
def posts
Post.all
end
end
end
Queryを実行する
実行するqueryです。
usersにpostをネストさせてリクエストします。
query{
users{
id
name
email
post{
id
title
description
}
}
}
実行するとネストしたPostのデータが返ってきます。
RESTの場合でいうuser.posts
のデータを取得できます。
post.user
のデータが必要な場合は、下記のようなqueryで取得できます。
query{
posts{
id
title
description
user{
id
}
}
}
6. N+1を検知するBulletを導入する
GraphQLのクエリは木構造になっているので、アソシエーションがあるとN+1問題が発生しやすいです。
そこで、N+1を検知するgemであるBulletを導入することをおすすめします。
Bulletをインストール
group :development do
gem 'bullet'
end
$ bundle install
config/environments/development.rbに下記の設定を追加
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
Bullet.rails_logger = true
end
N+1を確認してみる
Bulletの導入が完了したら、N+1が発生していないか確認してみましょう
query{
users{
id
name
email
posts{
id
title
description
}
}
}
先ほどと同様のqueryを実行すると下記のようなログが出ます。
Processing by GraphqlController#execute as */*
Parameters: {"query"=>"query{\n users{\n id\n name\n email\n posts{\n id\n title\n description\n }\n }\n}", "variables"=>nil, "graphql"=>{"query"=>"query{\n users{\n id\n name\n email\n posts{\n id\n title\n description\n }\n }\n}", "variables"=>nil}}
User Load (0.2ms) SELECT "users".* FROM "users"
↳ app/controllers/graphql_controller.rb:15:in `execute'
Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ? [["user_id", 1]]
↳ app/controllers/graphql_controller.rb:15:in `execute'
Post Load (0.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ? [["user_id", 2]]
↳ app/controllers/graphql_controller.rb:15:in `execute'
Completed 200 OK in 36ms (Views: 0.3ms | ActiveRecord: 1.7ms | Allocations: 18427)
POST /graphql
USE eager loading detected
User => [:posts]
Add to your query: .includes([:posts])
Call stack
Add to your query: .includes([:posts])
と言われているのでN+1が発生しています。
SQLも三回発行されています。
User Load (0.2ms) SELECT "users".* FROM "users"
Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ? [["user_id", 1]]
Post Load (0.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ? [["user_id", 2]]
N+1を解消する方法
N+1を解消するには通常通り、includes
すれば大丈夫です。
dataloaderというものでも解消できますが、今回はincludes
を使用します。
User一覧を取得している部分を下記のように変更します。
def users
# User.all # 変更前
User.includes(:posts).all # 変更後
end
では、N+1が解消されたかログで確認してみましょう。
警告がなくなっています。
Processing by GraphqlController#execute as */*
Parameters: {"query"=>"query{\n users{\n id\n name\n email\n posts{\n id\n title\n description\n }\n }\n}", "variables"=>nil, "graphql"=>{"query"=>"query{\n users{\n id\n name\n email\n posts{\n id\n title\n description\n }\n }\n}", "variables"=>nil}}
User Load (0.7ms) SELECT "users".* FROM "users"
↳ app/controllers/graphql_controller.rb:15:in `execute'
Post Load (1.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (?, ?) [["user_id", 1], ["user_id", 2]]
↳ app/controllers/graphql_controller.rb:15:in `execute'
Completed 200 OK in 57ms (Views: 0.2ms | ActiveRecord: 1.9ms | Allocations: 15965)
SQL文も2つに減ったので無事にN+1が解消されました。
User Load (0.7ms) SELECT "users".* FROM "users"
Post Load (1.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (?, ?) [["user_id", 1], ["user_id", 2]]
7. resolverの切り出し
通常のQuery
普通にコードを書いていくと、モデルに関係なく、fieldsとメソッドがquery_type
に追加されるのでquery_type
がどんどん肥大化します。
module Types
class QueryType < Types::BaseObject
field :users, [Types::UserType], null: false
def users
User.includes(:posts).all
end
field :posts, [Types::PostType], null: false
def posts
Post.all
end
end
end
Resolverを使ったQuery
GitHubのissueでは、Resolverを使用することで、query_type.rbの肥大化を回避するベストプラクティスが紹介されています。
https://github.com/rmosolgo/graphql-ruby/issues/1825#issuecomment-441306410
query_type
にはfieldのみを定義します。
module Types
class QueryType < BaseObject
field :users, resolver: Resolvers::QueryTypes::UsersResolver
field :posts, resolver: Resolvers::QueryTypes::PostsResolver
end
end
そして、メソッドの部分はObjectTypeごとにResolverに切り出します。(新たにresolversディレクトリを作成しました。)
GraphQL::Schema::Resolver
の記載を忘れるとエラーが出るので忘れないように注意してください。
module Resolvers::QueryTypes
class UsersResolver < GraphQL::Schema::Resolver
type [Types::UserType], null: false
def resolve
User.includes(:posts).all
end
end
end
module Resolvers::QueryTypes
class PostsResolver < GraphQL::Schema::Resolver
type [Types::PostType], null: false
def resolve
Post.all
end
end
end
終わりに
GraphQLついて、自分の復習もかねてまとめていたら思いの外、長文となってしまいました。
rspecやmutationについても書きたかったですが、次回にしたいと思います。