mybook

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(複数エンドポイント)と比較:

RESTGraphQL
エンドポイント複数(/properties, /users, /reviews)単一(/graphql)
データ取得サーバーが決めるクライアントが決める
Over-fetching発生しやすい発生しない
Under-fetching複数回リクエスト必要1回で解決
学習コスト

graphql-ruby のセットアップ

# Gemfile
gem 'graphql'
gem 'graphql-batch'  # N+1解決のためのDataLoader
bundle 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
end

GraphQL クエリの書き方

クライアントは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
end

AWS 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を学ぶ。