GraphQL 入門 — 必要なデータだけ取得する
Over-fetchingの問題
6ヶ月が経ち、LivlyのAPIは順調に使われていた。しかし新しい問題が浮上してきた。
「ナツミさん、トップ画面に物件サムネイルと名前だけ表示したいんですが、レスポンスに無駄なフィールドが多すぎます。description とか area_unit とかいらないです」——林さん(iOS)
「逆に、物件詳細画面では口コミと近隣施設情報も一緒に欲しいんですが、別のAPIを3回叩かないといけない」——田中さん(Android)
RESTの問題が顕在化していた。
- Over-fetching:必要以上のデータが返ってくる
- Under-fetching:必要なデータを得るために複数のAPIを叩く必要がある
GraphQLはこの問題を解決する。
GraphQLとはなにか
GraphQLはFacebookが開発したAPIのクエリ言語とランタイム。クライアントが必要なフィールドだけを指定してリクエストできる。
Loading diagram...
REST(複数エンドポイント)と比較:
| REST | GraphQL | |
|---|---|---|
| エンドポイント | 複数(/properties, /users, /reviews) | 単一(/graphql) |
| データ取得 | サーバーが決める | クライアントが決める |
| Over-fetching | 発生しやすい | 発生しない |
| Under-fetching | 複数回リクエスト必要 | 1回で解決 |
| 学習コスト | 低 | 中 |
graphql-ruby のセットアップ
# Gemfile
gem 'graphql'
gem 'graphql-batch' # N+1解決のためのDataLoaderbundle install
rails generate graphql:install生成されるファイル構成:
app/graphql/
├── livly_schema.rb # スキーマ定義
├── types/
│ ├── base_object.rb # 型の基底クラス
│ ├── base_argument.rb
│ ├── base_field.rb
│ ├── query_type.rb # クエリ(読み取り)のルート
│ └── mutation_type.rb # ミューテーション(書き込み)のルート
├── mutations/
│ └── base_mutation.rb
└── resolvers/ # データ取得ロジック
スキーマの定義
# app/graphql/types/property_type.rb
module Types
class PropertyType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :description, String, null: true
field :price, Integer, null: false
field :area, Float, null: true
# カスタムフィールド
field :location, String, null: true
field :thumbnail_url, String, null: true
field :published_at, GraphQL::Types::ISO8601DateTime, null: true
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
# アソシエーション
field :user, Types::UserType, null: false
field :photos, [Types::PhotoType], null: false
field :reviews, [Types::ReviewType], null: false
field :reviews_count, Integer, null: false
def location
"#{object.prefecture}#{object.city}"
end
def thumbnail_url
object.photos.first&.url
end
def reviews_count
object.reviews.size
end
end
end# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
# 物件一覧
field :properties, resolver: Resolvers::PropertiesResolver
# 物件詳細
field :property, Types::PropertyType, null: true do
argument :id, ID, required: true
end
def property(id:)
Property.find_by(id: id)
end
end
endリゾルバーの実装
# app/graphql/resolvers/properties_resolver.rb
module Resolvers
class PropertiesResolver < BaseResolver
type [Types::PropertyType], null: false
argument :page, Integer, required: false, default_value: 1
argument :per_page, Integer, required: false, default_value: 20
argument :prefecture, String, required: false
argument :min_price, Integer, required: false
argument :max_price, Integer, required: false
argument :sort, String, required: false, default_value: 'newest'
def resolve(page:, per_page:, prefecture: nil, min_price: nil, max_price: nil, sort:)
properties = Property.where(published: true)
properties = properties.where(prefecture: prefecture) if prefecture
properties = properties.where('price >= ?', min_price) if min_price
properties = properties.where('price <= ?', max_price) if max_price
case sort
when 'price_asc' then properties = properties.order(price: :asc)
when 'price_desc' then properties = properties.order(price: :desc)
else properties = properties.order(created_at: :desc)
end
properties.page(page).per(per_page)
end
end
endGraphQL クエリの書き方
クライアントはGraphQLクエリで必要なフィールドだけを指定する。
# トップ画面:サムネイルと名前だけ
query GetPropertiesList {
properties(page: 1, perPage: 10) {
id
name
price
thumbnailUrl # location, description などは含めない
publishedAt
}
}# 詳細画面:全情報 + 口コミ + オーナー情報を1リクエストで
query GetPropertyDetail($id: ID!) {
property(id: $id) {
id
name
description
price
area
location
photos {
id
url
}
user {
name
avatarUrl
}
reviews {
id
rating
comment
createdAt
}
reviewsCount
}
}ミューテーション(書き込み操作)
# app/graphql/mutations/create_property.rb
module Mutations
class CreateProperty < BaseMutation
description '物件を作成する'
argument :name, String, required: true
argument :description, String, required: false
argument :price, Integer, required: true
argument :area, Float, required: false
argument :prefecture, String, required: true
argument :city, String, required: true
field :property, Types::PropertyType, null: true
field :errors, [String], null: false
def resolve(name:, description: nil, price:, area: nil, prefecture:, city:)
property = context[:current_user].properties.build(
name: name,
description: description,
price: price,
area: area,
prefecture: prefecture,
city: city
)
if property.save
{ property: property, errors: [] }
else
{ property: nil, errors: property.errors.full_messages }
end
end
def authorized?(...)
context[:current_user].present? || raise(GraphQL::ExecutionError, '認証が必要です')
end
end
end# クライアント側でのミューテーション
mutation CreateProperty($input: CreatePropertyInput!) {
createProperty(input: $input) {
property {
id
name
}
errors
}
}N+1問題の解決 — DataLoader
GraphQLはRESTよりN+1問題が起きやすい。graphql-batch で解決する。
# app/graphql/loaders/record_loader.rb
class Loaders::RecordLoader < GraphQL::Batch::Loader
def initialize(model, column: :id)
super()
@model = model
@column = column
end
def perform(ids)
@model.where(@column => ids).each do |record|
fulfill(record.public_send(@column), record)
end
ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
end
end# app/graphql/loaders/association_loader.rb
class Loaders::AssociationLoader < GraphQL::Batch::Loader
def initialize(model, association_name)
super()
@model = model
@association_name = association_name
end
def perform(records)
preloader = ActiveRecord::Associations::Preloader.new(
records: records,
associations: [@association_name]
)
preloader.call
records.each { |record| fulfill(record, record.public_send(@association_name)) }
end
end# PropertyTypeでの使用
module Types
class PropertyType < Types::BaseObject
field :user, Types::UserType, null: false
field :photos, [Types::PhotoType], null: false
def user
# N+1を起こさずバッチロード
Loaders::AssociationLoader.for(Property, :user).load(object)
end
def photos
Loaders::AssociationLoader.for(Property, :photos).load(object)
end
end
end認証とコンテキスト
# app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
def execute
variables = prepare_variables(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
current_user: current_user # コンテキストでユーザーを渡す
}
result = LivlySchema.execute(query,
variables: variables,
context: context,
operation_name: operation_name
)
render json: result
rescue StandardError => e
raise e unless Rails.env.development?
render json: { errors: [{ message: e.message }] }, status: 500
end
private
def current_user
token = request.headers['Authorization']&.split(' ')&.last
return nil unless token
JsonWebToken.decode(token)
User.find(payload[:user_id])
rescue
nil
end
endAWS AppSyncとの比較
AWS AppSyncはマネージドGraphQLサービス。Rails + graphql-rubyとの使い分け:
Loading diagram...
INFO
AppSyncの強み:リアルタイムサブスクリプション(WebSocket)が簡単に実装できる。チャット機能や通知のような用途に向く。Railsとの連携はLambdaリゾルバー経由で可能。
GraphQL Playgroundで動作確認
開発環境ではGraphiQL(インタラクティブなIDEコンソール)が使える。
# Gemfile(開発のみ)
group :development do
gem 'graphiql-rails'
end
# config/routes.rb(開発のみ)
if Rails.env.development?
mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: '/graphql'
endまとめ
- GraphQLはクライアントが必要なフィールドを指定できるため、Over-fetchingとUnder-fetchingを解決する
graphql-rubyでタイプ、リゾルバー、ミューテーションを定義する- DataLoader(
graphql-batch)でN+1問題を解決する - 認証はコンテキスト経由でリゾルバーに渡す
- リアルタイム機能が必要な場合はAWS AppSyncも選択肢
次章では、マイクロサービス間通信に最適なgRPCとProtocol Buffersを学ぶ。