この記事はGLOBIS Advent Calendar 2020の19日目の記事です。
新規サービスの開発にバックエンドはRuby on Rails + GraphQL、クライアントサイドはReactを使っています。バックエンド側ではGraphQL Rubyをライブラリとして使用しています。実際にGraphQL Rubyを開発に盛り込んでしてきたことを書いていきます。
なぜ GraphQL + React を採用したのか
我々が現在開発中の新規サービスではサーバーサイドに GraphQL、フロントエンドに React.js を採用しました。
グロービスでサービス側はフロントエンドに React.js を主に採用しており知見・リソース共にサービス立ち上げの開発速度を担保するために十分でした。サーバーサイドについては Ruby on Rails を利用しており、APIについては Swagger を利用したAPI や、Grape gem を利用した REST Like API を実装してきましたが、各所で議論されている REST API 故の問題や、 立ち上げ時から使われていた Ruby on Rails 側ビューにあるユースケースとの分断、メンテされない Swaggerfile などが問題になっていました。
GraphQLについてはグロービス学び放題で一部、Globis Unlimited では積極利用しており、フロントエンド側に知見が溜まっていたこと、Apollo clientと合わせたときのTSとの親和性の高さ、開発体験がフロントエンドから好評であるという認識がありました。変化しやすいフロントエンド手動でデータを組み立てたてられるのは開発を加速する要因になりえます。
Swagger を使ったメリット・デメリットと比較した場合に GraphQL を使った方がより効率的なAPIの設計になるのではと見込んでの採用になりました。
disclaimer
今回の技術選定をおこなったのは 2019年8月であることを記しておきます。2020年12月現在技術選定を行う場合はこのような構成を採用するかは深慮する必要があるでしょう。
リポジトリ構成について
これまでの社内のサービスでは、フロントエンドとサーバーサイドのリポジトリが分割されていたのですが、フロントエンドエンジニアの皆さんにかなりの無理を言ってモノレポ構成にしていただきました。
これには二つの理由があります。一つ目は、現在のようにフロントとサーバーが分業体制になった場合連絡コストのほぼすべてはサーバーとフロントのインターフェースの連絡コストとして支払うことになりますが、このコストを一つのリポジトリに押し込む事によって最小にする目的としています。二つ目の理由として、フロントとサーバーのデプロイタイミングがずれる事によって引き起こされてしまうと予想できるフロント側でのエラーや、デプロイタイミングの「息を合わせるため」の連絡コストを最小化するためです。ただし、二つ目の理由については既存のサーバー、サービスに対するSPA化の場合などについては、サーバー側が安定しているためフロント側リポジトリが別れていても十分に開発速度が維持できると思われます。モノレポは新規プロダクト故の問題解決ですので、この記事を参考にされる場合は対象サービスが新規サービスなのか、既存サービスなのか(不安定なインターフェースなのか・安定したインターフェースなのか)をよくご検討ください。
実際のリポジトリ構成
実際のリポジトリ構成については以下のようになっています。
.
├── CHANGELOG.md
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── Procfile
├── README.md
├── app/
├── app.json
│ ├── controllers/
│ ├── forms/
│ ├── graphql/
│ │ ├── mutations/
│ │ ├── resolvers/
│ │ └── types/
│ ├── jobs/
│ ├── mailers/
│ ├── models/
│ ├── policies/
│ ├── uploaders/
│ └── views/
├── bin/
├── buildspec.yaml
├── codegen.yml
├── config/
│ ├── environments/
│ ├── initializers/
│ ├── locales/
│ └── settings/
├── config.ru
├── db/
│ ├── migrate/
│ └── seeds/
├── docker-compose.yml
├── lib/
│ ├── assets/
│ └── tasks/
├── node_modules/
├── package.json
├── packages/ ---------------------------- (1)
│ ├── controlpanel/
│ │ ├── index.js
│ │ ├── node_modules/
│ │ ├── package.json
│ │ └── webpack.config.ts
│ └── client/
│ ├── App.tsx
│ ├── api/
│ ├── assets/
│ ├── babel.config.js
│ ├── components/
│ ├── constants/
│ ├── containers/
│ ├── graphql/
│ ├── index.html
│ ├── index.less
│ ├── index.tsx
│ ├── node_modules/
│ ├── package.json
│ ├── routes/
│ ├── test-helpers/
│ ├── types/
│ ├── utils/
│ └── webpack.config.ts
├── prettier.config.js
├── public/
│ ├── packs/
│ ├── static/
│ │ └── images
│ └── uploads/
├── regconfig.json
├── renovate.json
├── schema.graphql
├── schema.json
├── ship.config.js
├── spec/
├── tsconfig.json
├── vendor/
└── yarn.lock
(1) とした所がフロントのコードになります。当プロジェクトでは sprockets の利用を停止し、フロントのアセットについてはすべてフロントエンド側のスタックで管理しています。これを実現するために yarn workspace を利用しており、 controlpanel ディレクトリが Rails で作っている管理画面側のアセット、 client 側がサービス側のアセットとなっています。
GraphQL Ruby のディレクトリ構成について
GraphQL のディレクトリ構成は以下のようになっています。
app/graphql
├── schema.rb
├── mutations/
├── resolvers/
└── types/
Getting Started からの変更点
Getting Started の Build a Schema の項 (https://graphql-ruby.org/getting_started#build-a-schema) にサンプルコードがあります。このコードを参照する限り query_type.rb に直接 root type を定義するようになっていますが、早晩この設計は破綻するのが目に見えます。ブログがありコメントができる、というような簡易なものであればこの設計でも問題ないかもしれませんが、現実的に我々がメンテナンスしているシステムはもっと複雑なものです。
QueryType
QueryTypeの定義については以下のようにシンプルな定義になっています。
カスタムResolverは公式で推奨されているわけではありませんが可読性が高くなること、QueyTypeの肥大化が防げることという点においてメリットが大きいと考え実装しています。
module Types
class QueryType < Types::BaseObject
field :user, resolver: Resolvers::UserResolver
field :hoge, resolver: Resolvers::HogeResolver
field :huga, resolver: Resolvers::HugaResolver
end
end
resolvers
resolvers ディレクトリ
module Resolvers
class UserResolver < Resolvers::BaseResolver
description 'Find an User by ID. Require ID'
argument :id, ID, required: true
type Types::UserType, null: false
def resolve(id:)
_class_name, id = GraphQL::Schema::UniqueWithinType.decode(id)
User.find!(id)
end
end
end
types
types にはユーザー定義型を配置しており、以下のような定義になっています。N+1 に向き合うための方法や、認可に関する設定などは後述していますので、そちらもご参照ください。
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :comments, Types::Comments.connection_type, null: false do
argument :comment_id, ID, required: false, loads: Types::CommentType, as: :comment
end
def comments(comment_id:)
# comments の実装
end
end
end
スキーマファーストの開発
GraphQL Ruby自体はコードファーストの考え方を採用していますが、我々の実際の開発においてはスキーマをもとにしてフロントエンド、バックエンドが開発を行うスキーマファーストの考え方を採用しています。
新規開発ですので基本的に機能追加はバックエンドフロントエンドどちらも作業を行う必要がある場合がほとんどです。開発の最初にschema.graphqlをフロントエンドとバックエンドで協力して編集し目指すべきスキーマを決定しています。
schema.graphqlはテキストファルでありGitHubでの閲覧もしやすいので共通認識がしやすいです。
この方法の前はチャットで〇〇Typeに〇〇fieldを足しましょうという形での打ち合わせでしたが、実際にコードにしながら書くことようにしたことで認識や全体感の共有がしやすくなりました。
開発においてはschema.graphqlが一番抽象化している部分であり、フロントエンド、バックエンドともにこの抽象に向かって実装を行っていく方式をとっています。
スキーマ設計の基本方針
GraphQLのスキーマはクライアントとバックエンドどちらからも依存される抽象的なものであることを書きました。スキーマの設計においても抽象的な方面に寄せていくことが重要です。
例えばUserTypeという型定義があったとしてバックエンドでは
# Table name: users
#
# id :bigint(8) not null, primary key
# name :string(255) not null
# role :integer(4) default("member"), not null
# Table name: user_passwords
#
# id :bigint(8) not null, primary key
# email :string(255) not null
# encrypted_password :string(255) default(""), not null
等様々なテーブル(モデル)を複合していますが
GraphQLにするときはクライアントにとって直感的になるようにUserTypeに一つにまとめてしまうべきでしょう
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :email, String, null: true
field :name, String, null: false
field :role, Types::UserRoleType, null: false
こうすることでクライアントはいちいちDBの構成を意識することなく直感的なクエリ発行ができるようになります。
N+1問題への具体的な対策方法
GraphQL RubyにおけるN+1問題への対処としてはgraphql-batchが有名ですが私たちはbatch-loaderというGemを使っています。
理由としてはgraphql-batchはPromiseというあまりメンテナンスされていないGemに依存している点があることと、batch-loaderは依存が少なく実際のコードベースも少なく理解がしやすいGemだったことが挙げられます。
原理としては遅延評価の考え方をもとにしたもので、Proc#source_locationをキーとして呼び出されたことをキャッシュしながら値の読み込みとキャッシュを行ってくれます。
実際の動作についてはGem作者がわかりやすい解説がオススメです。
https://speakerdeck.com/exaspark/batching-a-powerful-way-to-solve-n-plus-1-queries
具体的にN+1が発生しやすい部分の実例と対処法ですが、
例えば以下のようなTypeがあった場合
module Types
class FavoriteType < Types::BaseObject
field :id, ID, null: false
field :user, Types::UserType, null: false
field :post, Types::PostType, null: false
内部は関係テーブルだろうということはすぐに分かると思いますが、こういった別のテーブルに紐づくTypeを返すfieldを持つTypeはコレクションとしてどこかから呼ばれると簡単にN+1が発生します。
TypeのコードからはこのTypeがどう呼ばれるかわからないということを念頭におくことが重要です。
GraphQL Rubyの型定義ではfieldとして呼び出す側がコレクションとして呼び出す時にN+1問題に気づきやすいですが、実際にN+1を解決する実装は呼び出されるType内に配置するのが良いでしょう。型が複数のテーブルをつないでいる情報はその型のみが知っている状態のほうが知識が集約されたコードになります。
Typeが外部からコレクションとして呼び出される時にDBへのアクセスがどうなるかを考えるのが良いでしょう。
BatchLoaderを使った場合の解決策のコードは以下のようになります。
def user
BatchLoader.for(object.user_id).batch do |user_ids, loader|
User.where(id: user_ids).each { |user| loader.call(user.id, user) }
end
end
記法にやや癖がありますがブロック内でのpreload等自由に行えるので融通が効きます。
難点としてはソースコードの場所に依存するので使用者側が利用しやすいように改変するのはまた別の工夫が必要になってくるという所でしょう。現在はTypes以下には上記のようなコードが数多く存在しています。
認可に対する考え方
GraphQL::Pro の利用
graphql-ruby には有償の pro ライセンスがあり、こちらの機能のひとつとして Pundit gem とのインテグレーションがサポートされています。このインテグレーションを上手く利用するためにちょっとしたコツがあり、
ObjectId のシリアライズ
GraphQL::Pro では pundit インテグレーションとして ActiveRecord インスタンスへの Policy ファイル適用が可能です。該当ドキュメントのAuthtorizing Loaded Objectsという項目に "Mutations can automatically load and authorize objects by ID using the loads: option" という表記があり、loads オプションに型定義を渡すことにより、型定義に指定した Pundit の Policy ファイルが自動的に適用されます。
このインテグレーションを利用するためには オブジェクトの ID から ActiveRecord インスタンスを直接引き当てる必要がありますが、デフォルトの設定だと ActiveRecord の ID がそのままクライアント側に渡り、この ID がサーバー側へクエリする場合に使用されてしまいます。ID のみで ActiveRecord インスタンスを一意に引き当てる必要があるため、ID にこれらすべての情報を持たせる必要があります。
すべてのモデルに対して一意な ID を発行できるようなシステムであれば不要ですが、今回永続層として利用しているのは ActiveRecord 越しの MySQL であるため現実的ではありません。
graphql-ruby リポジトリのobject_identification.mdというドキュメントに サンプルコードを見つけることができ、これを参考に ActiveRecord の型名と ID を組み合わせた値をシリアライス・デシリアライズすることによりシステムグローバルの一意な ID としています。
またloads: optionはinput typeのfieldでしか動作しないという問題があったのですが、このObjectIdからpunditへ渡す方式を通常のfieldのargumentでも使いたいと考えていたところ、issueをみつけたのでパッチを送りました。これによってInputTypeをわざわざ定義しなくてもloadsオプションが使えるようになりました。
module Types
class UserType < Types::BaseObject
field :finished, Boolean, null: false do
argument :courseid, ID, required: false, loads: Types::CourseType
end
エラー通知
まず認可以外においてQueryTypeのfieldを叩く分にはエラーは発生しないようにします。認可エラーも最小限に抑え、基本的にはスコープを狭める方式で対処していきます。
理由としては、スキーマをみて自由に組み立てられるのがGraphQLの良い所なのですが、〇〇Typeの〇〇fieldを叩くとエラーが発生することがあるという状態にしてしまうとクエリを組み立てる側がそれを覚えておく必要があります。せっかく自動生成されるドキュメントも役に立たなくなりフロントエンドが快適に開発するという目的が果たせなくなってしまいます。
なので基本的にエラーを返却するのはMutaionが多いです。
基本的にGraphQL::Execution::Errorsを用いてrescue_fromからエラーを通知する形をとっています。こちらの記事を参考にさせていただきエラー返却の仕方もこの記事で追加された機能を使わせてもらっています。
class MyProductSchema < GraphQL::Schema
use GraphQL::Execution::Errors
rescue_from ActiveRecord::RecordInvalid do |err, _obj, _args, _ctx, _field|
raise GraphQL::ExecutionError.new(
err,
{
extensions:
{
code: 'INVALID_INPUT',
exception: { **err.record&.errors&.details, object: err.record.class&.name },
},
},
)
end
公式のエラーハンドリングの記事においてもGraphQL::Execution::Errors
がデフォルトになるという記載がありますね。
試してみたいこと
実際に実装は行っていないのですが、試してみたいこととして以下の記事のようにMutationの返り値をQueyTypeにしてクライアントが戻り値を自由に組み立てられるようするというものがあります。
https://medium.com/@danielrearden/a-better-refetch-flow-for-apollo-client-7ff06817b052
Payloadに対してrefetch fieldを追加してQueyTypeをマッピングします。
type CreateIssuePayload {
clientMutationId: String
refetch: Query!
}
これはGraphQL-Rubyにおいては以下のように記述すれば再現できます
module Mutations
class CreateIssue < BaseMutation
argument :title, String, required: true
argument :description, String, required: true
field :refetch, Types::QueryType, null: false
def resolve(title:, description:)
# Mutationの実装
{refetch: Types::QueryType }
end
end
end
end
クライアント側はMutationの返り値を自由に設計できるようになるので関数を叩く回数を減らせる可能性があります。
まとめ
GraphQL-Rubyは規模の大きなGemであり実装も複雑です。メンテナーの方も勢力的に活動されていて日々新機能が追加されています。自分たちも利用しきれていない機能がまだ多くあると思われます。目的はあくまで「プロダクトを開発しやすくする」なのでそれに沿った実装をしていきたいですね。