はじめに

私が携わっているプロダクトでは、フロントエンド用のAPIとしてGraphQLを採用しました。
実際に使ってみてかなり良い感じなのですが、最初はいざ実装しようにもよく分からずに苦労しました。
そこで、GraphQL の実装方法についてサーバーサイドに焦点を当てて書いていきたいと思います。
ちなみに基礎編とありますが、基礎的な内容なので基礎編です。応用編があるかは分かりません。(もしかしたら書くかも)

GraphQLはいいぞ。

GraphQLとは何か

公式の定義によると「自分のAPIに対して使えるクエリ言語の一種」です。
他にも

などを読むと、大体どんなものか掴めると思います。

個人的に実装していて思うのは、GraphQLはRESTの制約をガチガチに固めた上でAPIとエンドポイントとの固い結合を解いたものだな〜ということです。
GraphQLではデータの取得や更新を基本的にPOSTで行います (仕様ではGETも定められています)。
このPOSTのリクエストパラメーターにクエリを生やすことで、APIに対して行いたい処理を指定します。

この時クライアント側は、クエリを好きな組み合わせで、欲しいデータや更新したいデータのみを指定して、APIを叩くことができます。このため、例えば複数のモデルを別々に更新したい場合と同時に更新したい場合とでエンドポイントを分ける必要がなく、クライアント側がどのカラムを更新するかも含めて選択することができます。

このように、クライアントからは扱いたいデータを自由に選択でき、バックエンドは実装の負荷を下げられるのがGraphQLの良いところです。

GraphQLの実装

GraphQL自体はあくまで仕様で、実装は様々なものがあります。以下に一例を挙げます。
(awesome-graphqlにGraphQLの実装の一覧が載っています。)

これらは大まかに2種類に分けることができます。Graphql RubyやGraphQL.jsは既存のサーバーサイドフレームワークと組み合わせて使うのですが、GraphcoolやAppSyncはそれそのもので完結していて比較的簡単にAPIサーバーを実装することができます。Graphcoolは最近オープンソースになったのでイケイケです。

今回はRailsと組み合わせて使うことができるGraphQL Rubyで話を進めていきます。

GraphQLを書いてみる

GraphQLがどういったものか、というのは実際に書いてみるのが一番理解しやすい(説明しやすい)ので、ここからはサンプルアプリを作りながら解説していきます。
サンプルアプリはこちらです -> https://github.com/kielze/graphql-ruby-demo/
plain-railsブランチにGraphQLを実装していない状態を残しておきました。これを使って一緒にGraphQL APIを実装してみましょう!(事前にbin/setupを実行しておいてください。)

Install

まずはgraphqlgemをインストールします。

gem 'graphql'
$ bundle install

Hello World!

Generatorがあるので、それを使ってgraphql-rubyの雛形を作りましょう。

$ rails g graphql:install

すると、このようなディレクトリツリーが出力されます。中身は後で説明します。

app/graphql
├── graphql_ruby_demo_schema.rb
├── mutations
└── types
    ├── mutation_type.rb
    └── query_type.rb

また、graphiqlというブラウザ上でGraphQL APIを動かして動作を確認できるツールがあります。それのRails版をdevelopment groupにインストールしましょう。

  gem 'graphiql-rails'
$ bundle install

ルーティングにgraphiqlのパスを追加します。

if Rails.env.development?
  mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: '/graphql'
end

はい、ここまでで一度rails sして http://localhost:3000/graphiql にアクセスしてみてください。
graphiqlの画面が表示されていれば設定は完了です。試しにクエリを投げてみましょう。

{
  testField
}

Hello World!が返ってくれば設定は完了です!

QueryとMutationとSubscription

GraphQLで問い合わせをするための最も基本的な概念としてQueryとMutationとSubscriptionがあります。

Query

データを取得するための仕組みです。

Mutation

データを更新するための仕組みです。

Subscription

Websocketを扱うための仕組みです。ここでは触れません。

Query

ここからは実際にQueryを定義してデータを取得できるようにしていきます。まずはseed_fuで入れたデータ(db/fixtures/sample_data.rb)を取得できるようにしましょう。

UserType

まずはUserモデルからです。graphql-rubyにはgeneratorがあるのでそれを使います。

$ rails g graphql:object User id:ID! name:String! email:String!

すると、以下のようなファイルがapp/graphql/types/user_type.rbに生成されます。(見やすさのために少し整形しています)

Types::UserType = GraphQL::ObjectType.define do
  name 'User'

  field :id, !types.ID
  field :name, !types.String
  field :email, !types.String
end

簡単に説明します。ここではTypes::UserTypeGraphQL::ObjectTypeで定義しています。ObjectTypeは基本となるオブジェクト型で、これからもよく出てきます。
nameはschemaの定義に用います。詳しくは後で説明します。
fieldはどのデータにアクセスするかを定義しています。graphql-rubyでは対応するモデルのattributeと同じ名前でfieldを定義すると、それだけでデータを取得することができます。

これでUserTypeを定義することはできたのですが、データを取得するためにはこれをQueryと紐付けないといけません。(逆に言うと定義した型は再利用が可能です。)
Queryと紐付けるためには最初にgenerateしたTypes::QueryType内にfieldを書いていきます。

Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'

  field :user, !Types::UserType do
    resolve ->(_obj, _args, ctx) {
      ctx[:current_user]
    }
  end
end

ここで、resolveというものが出てきました。これはGraphQL内でロジックを書くための仕組みです。graphql-rubyでは単純にfieldのみを書くと、そのattribute(またはメソッド)が生えていない場合にエラーで落ちます。また、attributeが生えている場合でもデータを加工して返したい場合もあると思います。そのような時にこのresolveを使うことで解決することができます。

resolveに渡すlambdaには obj, args, ctx という3つの引数があります。

  • obj は自信のオブジェクトで、例えばUserTypeであればuserの情報が入っています
    • QueryTypeでは使いません
  • args はfieldに渡す引数です
    • ここでは触れません、詳しくはこちら
  • ctx はログインユーザーの情報など重要な情報を渡すために使います

ということでctxがとても重要です。ログイン機能が付いているアプリではcurrent_userを渡してあげるのが常套手段です。このctxはgraphql-rubyがgenerateしたGraphqlControllerで指定します。見てみるとコメントにそれらしいことが書いてあるのが分かると思います。今回はログイン機能がないので、User.lastcurrent_userとすることとします。

class GraphqlController < ApplicationController
  def execute
    variables = ensure_hash(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {
      current_user: User.last,  # ここでcurrent_userを指定する
    }
    result = GraphqlRubyDemoSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  end

...

はい、ここまで来ればGraphQLで値を取得することができます。graphiqlから以下のクエリを投げてみてください。seedで登録したデータが返ってくるはずです。

{
  user {
    id
    name
    email
  }
}

graphql-rubyではこんな感じのDSLでAPIを定義していきます。最初は違和感があると思いますが、慣れるとフォーマットがある程度決まっているので簡単です。(最近Classで定義できるPRがマージされてバージョン1.8でリリースされる予定なので、DSLが気持ち悪いと感じた方はもう少しお待ち下さい。)

AddressType

次に、Userと1対1で紐付いているAddressにアクセスできるようにします。まずはAddressTypeを作りましょう。

$ rails g graphql:object Address id:ID! postal_code:Int! address:String!

もしActiveRecordを使ってAddressを取得する場合、直接Addressを取りに行くのではなくUserから辿ると思います。それはGraphQLでも同じです。なので、Userにfieldを生やしてあげます。

Types::UserType = GraphQL::ObjectType.define do
  name 'User'

  field :id, !types.ID
  field :name, !types.String
  field :email, !types.String
  field :address, !Types::AddressType  # 追加する
end

これでAddressを取得できるようになりました。graphiqlから以下のクエリを投げてみてください。

{
  user {
    id
    name
    email
    address {
      postal_code
      address
    }
  }
}

住所と郵便番号が返ってきたでしょうか。これでQueryの基本的な部分の説明はだいたい終わりました。最後にConnectionについて軽く触れておきます。

Connection

モデルにはUserがつぶやきを投稿できるような場合を想定して、PostがUserに対して1対多で生えています。これを取得できるようにしたいです。なにはともあれPostTypeをgenerateしましょう。

$ rails g graphql:object Post id:ID! subject:String!

UserTypeに対してPostTypeが複数あるような場合、fieldではなくconnectionを使って関連を表します。

Types::UserType = GraphQL::ObjectType.define do
  name 'User'

  field :id, !types.ID
  field :name, !types.String
  field :email, !types.String
  field :address, !Types::AddressType
  connection :posts, !Types::PostType.connection_type  # 追加する
end

これでPostsをまとめて取れるようになりました。graphiqlから以下のクエリを投げてみてください。

{
  user {
    id
    name
    email
    posts {
      edges {
        node {
          id
          subject
        }
      }
    }
  }
}

Postの一覧が取得できたと思います。並び順を変えたい場合は、Resolverを使ってorder_byすれば良い感じになります。詳しくはこちらをご覧ください。

Connectionを使うとページネーションの仕組みも一緒に付いてくるので非常に便利です。Connectionのクライアント側からの詳しい使い方については下記を参照してください。
https://facebook.github.io/relay/graphql/connections.htm

いくつか補足

ここまで駆け足で説明してきたので、いくつか説明が抜けている部分があります。それらをここで簡単に補足します。

GraphQLには型があります。

  • Int: A signed 32‐bit integer
  • Float: A signed double-precision floating-point value
  • String: A UTF‐8 character sequence
  • Boolean: true or false
  • ID: ユニークな文字列で、CacheやConnectionなどRelay由来の仕組みに使われます

また、独自にScalarTypeを定義することもできます。

Relayとは

RelayはFacebookが開発しているGraphQLを使ったFluxの実装です。ReactとGraphQLの性能を最大限引き出すことができるようで、GC等のかなり強力な機能が付いています。FluxなのでReduxと組み合わせて使うことはできません。Redux&ReactとGraphQLを組み合わせたい場合はApolloを使うといいです。

Relayは独自にGraphQLを拡張しているのですが、いくつかの機能がGraphQLにも取り入れられています。特にConnectionType(ページネーションの仕組み)はGraphQL本体の仕様になったと言っても過言ではありません。

型に付いてる!マーク

field :name, !types.String!マークはnullable falseを表します。!マークが付いていなければ、そのfieldはnullableです。基本的にnullable falseで定義していくのが良いと思います。

Mutation

ここからはMutationを定義してデータを更新できるようにします。これから説明するのはRelay由来の方のMutationです。こちらの方がすっきり書けて見通しがよく、またコードの再利用ができるようになっています。元のMutationはこちらが理解できれば簡単に書けるので説明は割愛します。

Mutations::UpdateAddressMutation

まずはAddressを変更できるようにしてみましょう。Mutationのファイルをgenerateします。

$ rails g graphql:mutation UpdateAddressMutation

すると、コメントにinput_fieldreturn_fieldresolverを定義するように書いてあります。これらを定義してあげればMutationが作れそうです。

input_fieldはそのままで、Mutationに使うインプットです。今回は変更したい住所と郵便番号を設定します。

return_fieldは返り値です。アップデートした後の各fieldの値を返すために、Queryで使うために定義したTypes::AddressTypeを使います。

そして、resolverでアップデートのロジックを書くことで実装は完了します。

Mutations::UpdateAddressMutation = GraphQL::Relay::Mutation.define do
  name 'UpdateAddress'

  input_field :postal_code, !types.Int
  input_field :address, !types.String

  return_field :address, !Types::AddressType

  resolve ->(_obj, inputs, ctx) {
    begin
      address = ctx[:current_user].address
      address.postal_code = inputs.postal_code
      address.address = inputs.address
      address.save
    rescue => e
      return GraphQL::ExecutionError.new(e.message)
    end

    { address: address }
  }
end

{ address: address }return_fieldで返す値で、Hashで定義します。また、エラーハンドリングで投げる例外はGraphQL::ExecutionErrorを使うのが良いです。

では、実際にアップデートしてみましょう。graphiqlから以下のクエリを投げてみてください。返ってきた値が更新後のものになっていれば正常に動作しています。

mutation {
  updateAddress(input: {
      postal_code: 1638001
      address: "東京都新宿区西新宿2丁目8−1"
  }) {
    address {
      id
      postal_code
      address
    }
  }
}

Mutations::CreatePostMutation

先程定義したQueryのPostTypeを投稿できるようにしてみましょう。Mutationのファイルをgenerateします。

$ rails g graphql:mutation CreatePostMutation

先程と同じように中身を書いていきます。今度はCreateなので、Postをbuildして値を入れ、保存します。

Mutations::CreatePostMutation = GraphQL::Relay::Mutation.define do
  name 'CreatePost'

  input_field :subject, !types.String

  return_field :post, !Types::PostType

  resolve ->(_obj, inputs, ctx) {
    begin
      post = ctx[:current_user].posts.build
      post.subject = inputs.subject
      post.save
    rescue => e
      return GraphQL::ExecutionError.new(e.message)
    end

    { post: post }
  }
end

Mutationが定義できたらTypes::MutationTypeにfieldを追加します。

Types::MutationType = GraphQL::ObjectType.define do
  name 'Mutation'

  field :updateAddress, field: Mutations::UpdateAddressMutation.field
  field :createPost, field: Mutations::CreatePostMutation.field  # 追加する
end

できたらgraphiqlから投稿してみましょう!

mutation {
  createPost(input: {subject: "良い感じ!"}) {
    post {
      subject
    }
  }
}

current_userのpostsのqueryを投げてみて、postが追加されていることを確認してみてください。

Mutations::UpdatePostMutation

今度は投稿したpostを更新してみましょう!UpdatePostMutationをgenerateします。

$ rails g graphql:mutation UpdatePostMutation

今度は更新したいpostを指定する必要があるため、input_fieldidを追加します。それ以外はやることは先程とほとんど同じです。

Mutations::UpdatePostMutation = GraphQL::Relay::Mutation.define do
  name 'UpdatePost'

  input_field :id, !types.ID
  input_field :subject, !types.String

  return_field :post, !Types::PostType

  resolve ->(_obj, inputs, ctx) {
    begin
      post = ctx[:current_user].posts.find(inputs.id)
      post.subject = inputs.subject
      post.save
    rescue => e
      return GraphQL::ExecutionError.new(e.message)
    end

    { post: post }
  }
end

あとはTypes::MutationTypeにfieldを追加したら、更新してみましょう!

mutation {
  updatePost(input: {id: "{先程投稿したpostのid}", subject: "めっちゃ良い感じ!!!"}) {
    post {
      subject
    }
  }
}

InputType

上記のMutationはinput_fieldの項目が少なかったので良かったのですが、それが多くなってくると全部をMutationに書いていくのはしんどくなってきます。後ほど説明するdescriptionを追加していくとコードの量が一気に増えます。また、同じモデルに対するCreateやUpdateではinput_fieldが同じになることも多く、使いまわせたら便利です。それをするための仕組みがInputTypeです。試しにUpdateAddressMutationのInputTypeを定義してみましょう。app/graphql/types/address_input_type.rbに以下のように実装します。

Types::AddressInputType = GraphQL::InputObjectType.define do
  name 'AddressInput'

  argument :postal_code, !types.Int
  argument :address, !types.String
end

そうしたら、UpdateAddressMutationを以下のように変更します。

Mutations::UpdateAddressMutation = GraphQL::Relay::Mutation.define do
  name 'UpdateAddress'

  input_field :addressInput, !Types::AddressInputType  # input_fieldにTypes::AddressInputTypeを指定する

  return_field :address, !Types::AddressType

  resolve ->(_obj, inputs, ctx) {
    address_input = inputs.addressInput  # input_fieldのネストが一段深くなるので、addressInputを取り出す
    begin
      address = ctx[:current_user].address
      address.postal_code = address_input.postal_code
      address.address = address_input.address
      address.save
    rescue => e
      return GraphQL::ExecutionError.new(e.message)
    end

    { address: address }
  }
end

これで、下記のようにUpdateAddressMutationのクエリを投げることができます。(以前とちょっと違う)

mutation {
  updateAddress(input: {addressInput: {postal_code: 1638001, address: "東京都新宿区西新宿2丁目8−1"}}) {
    address {
      id
      postal_code
      address
    }
  }
}

GraphQLのテスト

Request spec

GraphQLの挙動を確認するテストはrequest specを書いていくのが良さそうです。graphiqlから投げているqueryをテスト内で同じように使うことで、実際の挙動をテストすることができます。

説明はともかく実装を見てみましょう。spec/requests/graphql/query/user_spec.rbに以下のようなテストを書きます。

require 'rails_helper'

RSpec.describe 'user query', type: :request do
  subject { post graphql_path, params: { query: query } }

  let!(:user) { FactoryBot.create(:user) }

  let(:query) do
    <<~QUERY
      {
        user {
          id
          email
          name
        }
      }
    QUERY
  end

  it 'response body is User data' do
    subject
    json = JSON.parse(response.body, symbolize_names: true)
    expect(json[:data][:user][:id]).to eq user.id
    expect(json[:data][:user][:email]).to eq user.email
    expect(json[:data][:user][:name]).to eq user.name
  end
end

これで意図したとおりのQueryが投げられているかを確認できます。ここまで必要ないという意見もあると思いますが、個人的には必要なテストだと思いますし、これが書いてあると後から見た時に挙動が一発で分かるので便利です。Mutationのテストも同じように書くことができます。試してみてください。

graphql-rubyのschemaが正しいか確認するテスト

graphql-rubyでは、実装からschema.graphqlを機械的に出力することができます。このschema.graphqlはそのままドキュメントになるので、きちんと追従していれば実装とドキュメントがかけ離れるということがありません。追従するに当たってはCIを使って自動更新することも出来ると思うのですが、ここでは簡単に対処するために最新のschemaになっていなかったらテストで落ちるようにするやり方を説明します。

schema.graphql

その前にschema.graphqlです。先程も言ったとおりこれはドキュメントになるのですが、そのためにはdescriptionを書く必要があります。今までの説明ではすっ飛ばしてきましたが、QueryやMutationを定義する時にdescriptionを追加します。試しにQueryTypeUserTypeに書いてみましょう。

Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'

  field :user, !Types::UserType do
    description 'You can access current_user'  # description を追加
    resolve ->(_obj, _args, ctx) {
      ctx[:current_user]
    }
  end
end
Types::UserType = GraphQL::ObjectType.define do
  name 'User'

  field :id, !types.ID, 'ユニークな ID'
  field :name, !types.String, '名前'
  field :email, !types.String, 'e-mail アドレス'
  field :address, !Types::AddressType, '住所'
  connection :posts, !Types::PostType.connection_type, '投稿一覧'
end

descriptionの書き方はブロックを使うかどうかで変わりますが、やりたいことは同じです。この状態でschema.graphqlをgenerateしてみましょう。まずはgraphql-rubyのrake taskを呼べるようにRakefileに追記します。

Rakefile
require_relative 'config/application'
require 'graphql/rake_task'  # 追記

Rails.application.load_tasks

GraphQL::RakeTask.new(schema_name: 'GraphqlRubyDemoSchema')  # 追記
...

これでschema.graphqlをdumpできるようになったので、rake taskを実行しましょう。

$ rake graphql:schema:dump

schema.graphqlschema.jsonがdumpされたと思います。schema.jsonの方はschemaの全ての情報がjsonで網羅されています(graphiqlで使われてるんだっけな...よくわかってません)。

schema.graphqlのtype Querytype Userを見てみると、先程書いたdescriptionが出力されていることがわかります。この型の定義と説明が合わさったschemaを実装から機械的に生成できるのは、json schemaやswaggerに対して便利な点だと感じています。

schema.graphqlのテスト

機械的に出力されたschemaも最新でなければ意味がありません。そこで、実装から出力される最新のschemaと既に出力済みのschemaで差分がないかどうかをチェックするテストを書き、最新でなければエラーになるようにします。エラーで落ちたら改めてschemaをdumpすればOKです。以下のようなテストを追加します(私はずぼらなのでspec/requests/graphql/graphql_ruby_demo_schema_spec.rbに追加しました)。

require 'rails_helper'

RSpec.describe 'GraphqlRubyDemoSchema' do
  let(:current_definition) { GraphqlRubyDemoSchema.to_definition }
  let(:printout_definition) { File.read(Rails.root.join('schema.graphql')) }

  it 'equals dumped schema, `rake graphql:schema:dump` please!' do
    expect(current_definition).to eq(printout_definition)
  end
end

試しにどこかのタイプを変更してテストを実行してみてください。エラーで落ちたらdumpして再度テストを実行してみてください。テストが通ると思います。

これにより、schemaが古い状態であることに必ず気づくことができ、ドキュメントを常に実装と一致させることができます。また、意図した変更であるかどうかも同時に確認することができます。これ実際に使っていて非常に便利なので、是非使ってみてください!

最後に

GraphQLは公式のドキュメントがかなりしっかりしているので、それを読めば必要なことは全部書いてあります。書いてあるのですが割と量が多いので、最初は概要を掴むのに苦労しました。
なので、ざっくり全体像が分かるような記事があると良いなーと思い、これを書きました。最初の入り口として参考になれば幸いです!

graphql-rubyの機能は他にもたくさんありますし、書き方も様々です。まだまだベストプラクティスが定まっていない新しい技術なので、みんなで触ってより良くしていきましょう!

参考資料