graphql loves rails

Posted by nysalor on December 22, 2024 · 23 mins read

この記事はリサーチ・アンド・イノベーション 開発者ブログ のアドベントカレンダーに掲載した記事を一部手直ししたものです。

graphql

皆さんgraphqlしてますか? 今を去ること3年弱(*git blame調べ)私の所属するサーバサイド開発チームはgraphqlを今後の技術として選択しました。 当時gprc or graphqlで議論したのですが、Ruby(Rails)でgrpcはどうも厳しそうだったこと、アプリ開発チームがgraphqlの方を好んだことなどからgraphqlを選んでいます。 Rest APIからgraphqlにシフトした理由としては、以下のような目論見がありました。

  • APIの開発終了を待たずに同時進行でアプリの開発を進めたい(mockがしやすい)
  • schemaという形で自動的に仕様書が作成される
  • 引数やレスポンスを追加してもアプリの実装を大きく変更しないで済む

一方でいくらかのconsがあることも理解しており、一度に全てを移行せずまずは新規開発する機能についてgraphqlで開発しつつ、既存のRest APIについては徐々に切り替えて行く方針に決まりました。

graphql-ruby

https://graphql-ruby.org/

現在、railsでgraphqlを使うならこれ一択だと思います。 開発も活発ですし、一通りの機能が揃っていて、使っている人も多いため情報もすぐ見つかります。 かゆいところに手が届かない場合はプラグインも書けます。 以下、簡単にgraphqlの実装方法と、陥りがちな問題などについて書いてみようかと思います。 なおgraphqlの基本的な思想などについては割愛します。

基本的な実装

まずいつもの通り、Gemfileに以下を追加してbundle installします。

gem 'graphql'
group :development do
  gem 'graphiql-rails'
end

graphiqlはブラウザ上でgraphqlクエリを実行できるUIです。 必須ではありませんが、開発環境で http://localhost:3000/graphiql にアクセスして試すことができます。

次に前準備を行います。

rails g graphql:install

これでコントローラ app/controllers/graphql_controller.rbapp/graphql/ 以下のファイルが生成されます。

query_type(resolver)にfieldとqueryを書く

最初にすることはfieldとqueryの追加です。 app/graphql/types/query_type.rb に以下を追加します。

## app/graphql/types/query_type.rb

module Types
  class QueryType < Types::BaseObject
  field :item, Types::ItemType, null: false, description: 'fetch item' do
    argument :item_id, ID, required: true
  end
  def item(item_id:)
   Item.find item_id
  end
end

Item というモデルがあると仮定して、それを1つ持ってくるクエリです。 fieldに指定したシンボルと同じ名前のメソッドを作り、引数は argument で設定したのと同じ名前にします。 次にレスポンスの型を app/graphql/types/item_type.rb として作成します。

## app/graphql/types/item_type.rb

module Types
  class ItemType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :price, Integer, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime
    field :updated_at, GraphQL::Types::ISO8601DateTime
  end
end

たったこれだけでgraphqlで以下のクエリが実行できるようになります。

query getItem($itemId: ID!) {
  item(itemId: $itemId) {
    id
    name
    price
  }
}
variables {
    "itemId": 1234
}

注意する点として、フィールド名(=関数名)や引数はrailsで設定した snake_casecamelCase に変換したものになるということです。 この場合 item_id -> itemId item -> item (lowerCamelCaseなのでそのまま) になっています。 変換ルールが分からない場合はrails consoleで 'item_id'.camelize(:lower) とやれば得ることができます。

ちなみにレスポンスは以下のようになります。

{
  "data": {
    "item": {
      "id": "1234",
      "name": "コンデンススープクリームパンプキン",
      "price": "1500"
  }
}

nullable or not nullable

良い点として、 argumentfieldnull: false を付けておくことで、graphql上でもnot nullableとして扱われるため、クライアント(アプリ)の実装時にnullableかどうかはっきり分かるようになります。 ruby側でも簡単なテスト(後述)で判定できるようになるためかなり便利です。 rubyらしくなくなる という違和感はややありますが、あくまでgraphqlの実装中だけに限定されるのでモデルのロジックに入れたりするよりは気が楽だと思います。

attributeに手を加えたい場合

draperactive_decoratorのようにDBの値そのままでなく、手を加えた値を返したいことがあります。 その時は単にtypeクラスのメソッドでオーバライドしてやればOKです。

## app/graphql/types/item_type.rb

module Types
  class ItemType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :price, String, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime
    field :updated_at, GraphQL::Types::ISO8601DateTime
  end

  # Item#priceがオーバライドされる
  # Itemモデルのインスタンスはobjectに格納されている
  def price
    "#{object.price}JPY"
  end
end

attributeを追加したい場合はfieldも追加します。

## app/graphql/types/item_type.rb

module Types
  class ItemType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :price, Integer, null: false
    field :price_with_currency, String, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime
    field :updated_at, GraphQL::Types::ISO8601DateTime
  end

  # graphql上ではpriceWithCurrencyになる
  def price_with_currency
    "#{object.price}JPY"
  end
end

アソシエーション

関連モデルを一度に取得することもできます。 ただし単純に実装するとN+1問題が発生しやすくなるので注意です。(後述) Category というモデルがあると仮定します。 Category モデルは Item モデルから belongs_to で紐付いています。

## app/graphql/types/category_type.rb

module Types
  class CategoryType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime
    field :updated_at, GraphQL::Types::ISO8601DateTime
  end
end
## app/graphql/types/item_type.rb

module Types
  class ItemType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :price, Integer, null: false
    field :category, Types::CategoryType
    field :created_at, GraphQL::Types::ISO8601DateTime
    field :updated_at, GraphQL::Types::ISO8601DateTime
  end
end

fieldにカスタムスカラー( Types::CategoryType )を追加するだけです。 これでitemクエリでcategoryも取得できるようになります。

query getItem($itemId: ID!) {
  item(itemId: $itemId) {
    id
    name
    price
    category {
        id
        name
    }
  }
}
variables {
    "itemId": 1234
}

レスポンスは以下のようになります。

{
  "data": {
    "item": {
      "id": "1234",
      "name": "コンデンススープクリームパンプキン",
      "price": "1500",
      "category": {
        "id": "567",
        "name": "インスタント食品"
      }
  }
}

ページネーション

複数の Item を取得するクエリは以下のように実装します。 fieldのtypeが [] に入って配列であることを示しています。

## app/graphql/types/query_type.rb

module Types
  class QueryType < Types::BaseObject
  field :items, [Types::ItemType], null: false, description: 'fetch items' do
    argument :category_id, ID, required: true
  end
  def items(category_id:)
   Items.where(category_id:)
  end
end

当然ですが、これだと全てのitemが取得されてしまいます。 kaminari みたいにページネーションさせたいですよね。 組み込みの connection_type というのがあるのでそれを使います。

## app/graphql/types/query_type.rb

module Types
  class QueryType < Types::BaseObject
  field :items, Types::ItemType.connection_type, null: false, description: 'fetch items' do
    argument :category_id, ID, required: true
  end
  def items(category_id:)
   Items.where(category_id:)
  end
end

これだけでページネーションを実現できますが、方式は Relay Cursor Connections というgraphql特有の形式になるので注意です。 クエリは次のような形式になります。

query getItems($categoryId: ID!) {
  items(categoryId: $categoryId, first: 10) {
    edges {
      cursor
      node {
        id
        name
        price
        category {
          id
          name
         }
      }
    }
    pageInfo {
      endCursor
      hasNextPage
      startCursor
      hasPreviousPage
    }
}
variables {
    "categoryId": "567"
}

レスポンスは以下のようになります。

{
  "data": {
    "items": {
      "edges": [
        {
          "cursor": "AA",
          "node": {
            "id": "1234",
            "name": "コンデンススープクリームパンプキン",
            "price": "1500",
            "category": {
              "id": "567",
              "name": "インスタント食品"
            }
          }
        },
        {
          "cursor": "AB",
          "node": {
            "id": "1238",
            "name": "コーンクリームスープ",
            "price": "1100",
            "category": {
              "id": "567",
              "name": "インスタント食品"
            }
          }
        },
        ...
        {
          "cursor": "BB",
          "node": {
            "id": "1421",
            "name": "野菜入り味噌汁",
            "price": "600",
            "category": {
              "id": "567",
              "name": "インスタント食品"
            }
          }
        }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "hasPreviousPage": false,
        "startCursor": "AA",
        "endCursor": "BB"
      }
    }
  }
}

cursor の値は不定です。(たぶん)

Dataloader

上記の items() クエリは Item モデルと関連する Category モデルを一度に持って来れますが、このままの実装だとN+1問題が発生します。 具体的には取得する Item の数だけ categories テーブルへのSELECTが行われてしまいます。 Rest APIの場合、N+1問題は includespreload を使って解決するのがセオリーでした。 graphqlの場合でも、 query_type.rb で以下のようにすることでN+1は防げます。

## app/graphql/types/query_type.rb

module Types
  class QueryType < Types::BaseObject
  field :items, Types::ItemType.connection_type, null: false, description: 'fetch items' do
    argument :category_id, ID, required: true
  end
  def items(category_id:)
   Items.includes(:category).where(category_id:)
  end
end

ただしこの方法にも問題はあります。 まず、graphqlのメリットとしてモデルのどの要素が呼び出されるかクエリで指定されている=不要なアソシエーションを解決しなくて良いというのがありますが、includes(preload)はそのメリットを潰してしまいます。 この例だとcategoryを要求してない場合でもテーブルをjoinしてしまい、パフォーマンスが悪化します。 (ただしgraphql-rubyでは指定された要素=カラムだけをSELECTするような実装にはなっていない)

この問題を解決するために利用されるテクニックをバッチローディングと言います。 簡単に言うと、親子関係になっているtypeをクエリが発行される際に遅延ローディングにしておき、全てのtypeを取得し終わる時にまとめてローディングする仕組みです。 graphql-rubyで使えるいくつかの実装がありますが、最近のgraphql-rubyにはdataloaderがバンドルされているのでそれを使います。 なおdataloaderはFiberを使っているため、ruby3以降ではノンブロッキングI/Oになります。なるはず。きっとなってる。

dataloaderを使うには以下のようにします。

## app/graphql/sources/category_by_id.rb

module Sources
  class CategoryById < GraphQL::Dataloader::Source
    def initialize
      @model_class = ::Category
    end

    def fetch(ids)
      records = @model_class.where(id: ids)
      ids.map { |id| records.find { |r| r.id == id } }
    end
  end
end
## app/graphql/types/item_type.rb

module Types
  class ItemType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :price, Integer, null: false
    field :category, Types::CategoryType
    field :created_at, GraphQL::Types::ISO8601DateTime
    field :updated_at, GraphQL::Types::ISO8601DateTime
  end

  def category
    dataloader.with(::Sources::CategoryById).load(object.category_id)
  end
end

これだけで自動的に遅延ローディングが行われて、 Category.where(id: ids) が最後に一度だけ実行され category が読み出されます。 コードを読めば分かる通り、結果はidsと同じ並びの配列に入るシンプルな設計になっています。 自分で実装するとハッシュを使ってしまいそうですが、速度やメモリ効率を考えると配列の方が良いのでしょうね。

queryとmutation

graphqlで対象のデータを作成したり、変更を加える場合には query の代わりに mutation を定義します。

## app/graphql/types/mutation_type.rb

module Types
  class MutationType < Types::BaseObject
  field :create_item, Types::ItemType, null: false, description: 'creeate item' do
    argument :name, ID, required: true
    argument :price, Integer, required: true
    argument :category_id, Integer, required: true
  end
  def create_item(name:, price:, category_id:)
    item = Item.new(name:, price:, category_id:)
    item.save!
    item
  end
end

リクエストは以下のようになります。

mutation newItem($name: String, $price: Integer, $categoryId: Integer) {
  item(name: $name, price: $price, categoryId: $categoryId) {
    id
    name
    price
    category {
        id
        name
    }
  }
}
variables {
    "name": "インスタントカレー",
    "price": 980,
    "categoryId": 567
}

実のところ、graphql-rubyではqueryとmutationにあまり違いはなく、queryで実装しても問題なく動きます。 機能を明確にするためにはもちろん分けた方が良いと思います。

その他のTips

queryからtypeに値を渡したい

ヘッダ情報を参照したい場合など、queryからtypeに値を渡す場合は context を使います。

## app/graphql/types/query_type.rb

module Types
  class QueryType < Types::BaseObject
  field :item, Types::ItemType, null: false, description: 'fetch item' do
    argument :item_id, ID, required: true
  end
  def item(item_id:)
    context[:message] = 'some message'
   Item.find item_id
  end
end
## app/graphql/types/item_type.rb

module Types
  class ItemType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :price, Integer, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime
    field :updated_at, GraphQL::Types::ISO8601DateTime
    fiels :message, String
  end

  def message
    context[:message]
  end
end

type内でargumentsを参照したい

contextを経由しても良いですが、次のような方法でも参照できます。

## app/graphql/types/item_type.rb

module Types
  class ItemType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :price, Integer, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime
    field :updated_at, GraphQL::Types::ISO8601DateTime
    fiels :message, String
  end

  def message
    context.query.provided_variables[:item_id]
  end
end

いざとなったらできますが、あまりやらない方が良さそうですね。

ファイルアップロード

ところで、httpでgraphqlをどう実現しているかと言うと、決められたendpoint(デフォルトで /graphql)にクエリをPOSTするようになっています。 クエリの文法はDSLで決まっており、引数(variables)はJSONですが、仕組みとしては単なるhttp POSTです。 難しいのが画像などのバイナリファイルをアップロードする場合です。 すぐ思いつくのは、例えばBASE64などでエンコードしてvariablesに含めることでしょう。 実装は簡単ですが、ファイル名や拡張子を渡せないのと、JSONのパースはオンメモリで行われるため、大きなファイルだと落ちてしまう恐れがあります。

GraphQL multipart request specification

やはりRESTと同様にmultipartでファイルを送りたいところですが、graphqlだと一工夫必要になります。 それが GraphQL multipart request specification です。 この仕組みはmultipartでリクエストを3パート(以上)に分け、

  • graphql(mutationとvariables)
  • 引数とパートのマッピング
  • エンコードされたバイナリ

とします。 ポイントは2つ目のマッピングで、 { "0": "variables.images" } のようなJSONでパートと引数のマッピングをします。 具体的な実装は以下のようになります。 Itemモデルには has_many_attachedimages 属性が追加されているものとします。

## app/graphql/types/mutation_type.rb

module Types
  class MutationType < Types::BaseObject
  field :create_item, Types::ItemType, null: false, description: 'creeate item' do
    argument :name, ID, required: true
    argument :price, Integer, required: true
    argument :category_id, Integer, required: true
    argument :images, [ApolloUploadServer::Upload], required: true
  end
  def create_item(name:, price:, category_id:, images:)
    item = Item.new(name:, price:, category_id:)
    images.each do |image|
      item.images.attach key: 'path', io: image.to_io
    end
    item.save!
    item
  end
end

リクエストはgraphiqlでは再現できず、chrome拡張の Altair GraphQL Client 等を使う必要があります。 多くの場合、クライアントの実装ではApolloのライブラリを使うことになるでしょう。 Altairではこのようなリクエストになります。 (Add filesのところに images.0 を入力して select files でファイルを選択)

[f:id:r-n-i:20241222000327j:plain]
altair

rails側の実装は簡単ですが、クライアント側との意識合わせが必要となるでしょう。 もちろん、ややこしいのでアップロードだけはRESTで、という選択肢もあると思います。

テスト

最後にgraphqlのテストの書き方です。 ビジネスロジックはユニットテストでカバーして、受け入れテスト(request spec)はリクエストとレスポンスを検証するだけにしています。 variablesのところを変数にすれば null: false について正しいかどうかのテストもできると思います。

## spec/requests/graphql/query/item_spec.rb

Rspec.describe 'item query', type: :request do
  subject { post graphql_path, params: { query: } }
  let(:item) { create(:item) }
  let(:query) do
    <<~QUERY
      query getItem($itemId: ID!) {
        item(itemId: $itemId) {
          id
          name
          price
        }
      }
      variables {
          "itemId": 1234
      }
    QUERY
  end

  before { subject }

  it { response.parsed_body['id'].to eq(item.id) }

schemaファイルを出力する

クライアント開発チームと共有する場合など、現在のschemaをファイルに出力したい場合があります。 ビルトインのrakeタスクがあるので簡単に追加できます。

## Rakefile
require 'graphql/rake_task'

GraphQL::RakeTask.new(schema_name: 'CodeRailsSchema', directory: './graphql')
bundle exec rake graphql:schema:dump

github actionsに定義して自動的に更新させておくと便利です。

##.github/workflows/rspec.yml

jobs:
  rspec:
    steps:
      - name: Update Graphql Schema
        env:
          RAILS_ENV: test
        run: bundle exec rake graphql:schema:dump

      - name: Commit Graphql Schema Changes
        run: |
          if ! git diff --exit-code --quiet -- graphql/;
          then
            git config --local user.name "$(git --no-pager log --format=format:'%an' -n 1)"
            git config --local user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
            git add graphql/
            git commit -m 'update graphql schema'
            git checkout .
            git fetch origin
            git rebase origin/$
            git push
          fi
        continue-on-error: true

introspection query

graphql-rubyはデフォルトでintrospection queryが有効になっており、以下のクエリでschemaを得ることができます。

{
	__schema {
		types {
			name
		}
	}
}

クライアント開発には非常に便利ですが、一方で悪意のある相手には定義済みのqueryや属性を全て知られてしまうリスクがあります。 ユーザのクライアント開発をオープンにするサービス以外では塞いでおいた方が無難でしょう。 初期設定の rails g graphql:install の時に作成されたファイルに一行書くだけで塞げます。(伏線回収)

## app/graphql/sample_server_schema.rb

class SampleServerSchema < GraphQL::Schema
  disable_introspection_entry_points unless Rails.env.local?
end

この例ではdevelopment, test環境以外でintrospection queryを無効にしています。

課題

graphqlの実装にはだいぶ慣れてきましたが、課題がいくつかあります。

N+1問題

上でも触れましたが、実装によってはN+1問題が発生しやすくなります。 特にアソシエーションが複雑にネストしている場合など、dataloaderで解決するのも難しいことがあります。

デバッグのしづらさ

httpのエンドポイントが /graphql に全てまとまってしまうため、例外が発生した場合にログから該当のリクエストを探すのが大変です。 パラメータをunescapeしてDSLをパースして・・・とやらなければなりません。 またパフォーマンスを計測する場合にも、queryごとにレスポンスを分析するのが手間になります。 newrelic, datadogといったツールはgraphqlに対応していてqueryごとに分けてくれるのですが、RESTと混在している場合は別々のページを参照しなければならないなどの問題がありました。

おわりに

graphqlの採用により、サーバサイド開発では一手間増えたかな?という体験が多いのですが、一方でアプリ(クライアント)開発チームとのコミュニケーションは非常にスムーズになりました。 属性の追加などについても気軽にできるようになり、特にアプリ側ではバージョンの古いアプリの動作に影響を与える心配なく取得する情報を変更できるという大きなメリットがあります。 一方で、先にschemaを決めておいてサーバとアプリの開発を同時進行する、という目論見がありましたが、実装を始めてみると事前に決めたschemaでは不備があるといったケースがしばしばあるため、これは必ずしもうまく行っていません。 しかし、CIで自動的にschemaが生成され、githubでいつでも参照できるというのは大きなメリットだと思います。 今後のチャレンジとしては、Reactを使ったクライアントも書いてみたいと思っています。