この記事はリサーチ・アンド・イノベーション 開発者ブログ のアドベントカレンダーに掲載した記事を一部手直ししたものです。
皆さんgraphqlしてますか? 今を去ること3年弱(*git blame調べ)私の所属するサーバサイド開発チームはgraphqlを今後の技術として選択しました。 当時gprc or graphqlで議論したのですが、Ruby(Rails)でgrpcはどうも厳しそうだったこと、アプリ開発チームがgraphqlの方を好んだことなどからgraphqlを選んでいます。 Rest APIからgraphqlにシフトした理由としては、以下のような目論見がありました。
一方でいくらかのconsがあることも理解しており、一度に全てを移行せずまずは新規開発する機能についてgraphqlで開発しつつ、既存のRest APIについては徐々に切り替えて行く方針に決まりました。
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.rb と app/graphql/ 以下のファイルが生成されます。
最初にすることは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_case を camelCase に変換したものになるということです。
この場合 item_id -> itemId item -> item (lowerCamelCaseなのでそのまま) になっています。
変換ルールが分からない場合はrails consoleで 'item_id'.camelize(:lower) とやれば得ることができます。
ちなみにレスポンスは以下のようになります。
{
"data": {
"item": {
"id": "1234",
"name": "コンデンススープクリームパンプキン",
"price": "1500"
}
}
良い点として、 argument や field に null: false を付けておくことで、graphql上でもnot nullableとして扱われるため、クライアント(アプリ)の実装時にnullableかどうかはっきり分かるようになります。
ruby側でも簡単なテスト(後述)で判定できるようになるためかなり便利です。
rubyらしくなくなる という違和感はややありますが、あくまでgraphqlの実装中だけに限定されるのでモデルのロジックに入れたりするよりは気が楽だと思います。
draperやactive_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 の値は不定です。(たぶん)
上記の items() クエリは Item モデルと関連する Category モデルを一度に持って来れますが、このままの実装だとN+1問題が発生します。
具体的には取得する Item の数だけ categories テーブルへのSELECTが行われてしまいます。
Rest APIの場合、N+1問題は includes や preload を使って解決するのがセオリーでした。
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と同じ並びの配列に入るシンプルな設計になっています。
自分で実装するとハッシュを使ってしまいそうですが、速度やメモリ効率を考えると配列の方が良いのでしょうね。
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で実装しても問題なく動きます。 機能を明確にするためにはもちろん分けた方が良いと思います。
ヘッダ情報を参照したい場合など、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
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のパースはオンメモリで行われるため、大きなファイルだと落ちてしまう恐れがあります。
やはりRESTと同様にmultipartでファイルを送りたいところですが、graphqlだと一工夫必要になります。
それが GraphQL multipart request specification です。
この仕組みはmultipartでリクエストを3パート(以上)に分け、
とします。
ポイントは2つ目のマッピングで、 { "0": "variables.images" } のようなJSONでパートと引数のマッピングをします。
具体的な実装は以下のようになります。
Itemモデルには has_many_attached で images 属性が追加されているものとします。
## 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 でファイルを選択)
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をファイルに出力したい場合があります。 ビルトインの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
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問題が発生しやすくなります。 特にアソシエーションが複雑にネストしている場合など、dataloaderで解決するのも難しいことがあります。
httpのエンドポイントが /graphql に全てまとまってしまうため、例外が発生した場合にログから該当のリクエストを探すのが大変です。
パラメータをunescapeしてDSLをパースして・・・とやらなければなりません。
またパフォーマンスを計測する場合にも、queryごとにレスポンスを分析するのが手間になります。
newrelic, datadogといったツールはgraphqlに対応していてqueryごとに分けてくれるのですが、RESTと混在している場合は別々のページを参照しなければならないなどの問題がありました。
graphqlの採用により、サーバサイド開発では一手間増えたかな?という体験が多いのですが、一方でアプリ(クライアント)開発チームとのコミュニケーションは非常にスムーズになりました。 属性の追加などについても気軽にできるようになり、特にアプリ側ではバージョンの古いアプリの動作に影響を与える心配なく取得する情報を変更できるという大きなメリットがあります。 一方で、先にschemaを決めておいてサーバとアプリの開発を同時進行する、という目論見がありましたが、実装を始めてみると事前に決めたschemaでは不備があるといったケースがしばしばあるため、これは必ずしもうまく行っていません。 しかし、CIで自動的にschemaが生成され、githubでいつでも参照できるというのは大きなメリットだと思います。 今後のチャレンジとしては、Reactを使ったクライアントも書いてみたいと思っています。