こんにちは。フリーランスで、主にRailsを使った開発の仕事をしている ymstshinichiro と申します。
直近約3年GraphQLの現場で仕事をしつつ、今年4月からはOpenAPIの現場でも並行で稼働しているのですが、両方を使ってみて個人的にGraphQLのここが良いなと思ったポイントをまとめてみました。
おことわり:
- 「これからGraphQLを使っていくべきか迷っている」という方へ向けた記事になったので、既にGraphQLをガンガン使いこなしている人には当たり前のことしか書いてないかもです
- あくまで僕の 好み・主観・経験 に基づく記事なので、その辺考慮の上で読んでいただけると助かります
- 記事中のサンプルコードはRailsで書かれています
GraphQLを使うと自然にCQSに近づく
先日、YOUTRUSTさんが主催する実際のproductionコードを見ながら会話する勉強会 に参加した際に、一般的なRailsの構造 (REST) を取りつつ、CQSを徹底した実装を行なっているよ というお話を聞かせていただきました。
いくつか要点があるのですが、今回話したい部分だけを抜粋すると下記のような感じで実装されているそうです。
- Clientがデータを取得するとき、コントローラではQueryというサフィックスのクラスが呼び出される
- データを絞り込むロジックはこのQueryクラスの中に閉じ込める
- モデルのscopeは極力使わない
- データを更新する際にはエンドポイントでUseCaseという概念のクラスを定義する
- UseCaseからはCommandというサフィックスのクラスを呼び出す。具体的な更新ロジックはこのCommandクラスの中で記述する
- 認可はUseCase、データ整合はCommandの中でそれぞれバリデーションする
- モデルにはバリデーションをできるだけ書かない
コードにするとこんな感じになります。(以下は https://github.com/team-youtrust/sample-webapp から抜粋)
# Queryの使用箇所。発表では 参照系 と呼ばれていた
class Api::FriendRequest::ReceivingController < Api::ApplicationController
before_action :authenticate_user!
def index
@receiving_friend_request_ids = ReceivingFriendRequestsQuery
.run(operation_user: current_user)
.map(&:encrypted_id)
end
end
# 補足: indexではidのみを返し、clientが描画に必要なデータはこのidを使って別のAPIを叩きにいく実装とおっしゃっていた記憶
# --------------
# UseCaseの使用箇所。発表では 更新系 と呼ばれていた
class Api::FriendRequest::AcceptController < Api::ApplicationController
before_action :authenticate_user!
def update
friend_request = FriendRequest.find_by_encrypted_id!(params[:id])
use_case = FriendRequest::AcceptUseCase.run(operation_user: current_user, friend_request: friend_request)
if use_case.success?
@friend_request = use_case.friend_request
render :update, status: :ok
else
head :bad_request
end
end
end
# 補足: UseCaseの中では対象レコードの行ロックを取ってCommandクラスを実行する
話を聞きながら思ったんですが、これ GraphQLの QueryType と MutationType の各Resolverの感じに似てるな〜、と。
僕は以前から「RESTよりGraphQLの方がなんとなくスッキリしてて好き」ぐらいに思ってたんですが、その理由は
GraphQLを使うと、単一のエンドポイント (/graphql
) -> Query || Mutation -> それぞれの Resolver と辿ることで、CQSに近いUseCaseレベルでの分離が自然にできあがる
ところから来ているのか〜と、この時3年越しでようやく腹落ちできました。
比較のために先ほどのコードをGraphQLっぽくするとこんな感じになりそうです。
# 認証はGraphqlControllerでやる && current_user はcontextから取れるように実装してある前提
# 参照系
class Resolvers::FriendRequestRecieving < BaseResolver
type [EncryptedId], null: false # カスタムスカラ型を定義する
def resolve
FriendRequest.where(user: current_user).map(&:encrypted_id)
end
end
# resolve内はQueryクラス相当と見做してモデルを直接呼んでいるが、
# 先のサンプル同様Queryクラスを作ってもいい。(そうするとテストがちょっと楽かも)
# 逆にResolverに全てを閉じ込めたテストにすると引数と返り値の型も一箇所にテストがまとまる良さはあるかも
# 先ほどの実装と比較になるようここではidを返す実装を踏襲しているが、
# 普通のGraphQLっぽくするならTypes::FriendRequesTypeを用意して,
# clientがidフィールドだけをリクエストするという実装にしても良い。
# また、その場合はid配列を引数に取れるようにこのqueryを実装して、
# id指定 + 詳細フィールドでもリクエストできるようにすると実装が一つで良いというメリットがある。
# が、逆に言うとclientが叩き分ける前提になってしまうので、サーバー主体で
# 何を返すかコントロールしたいケースでは用途ごとにちゃんと分割した方が良さそう。
# --------------
# 更新系 (こっちは中身ほぼ一緒)
class Mutations::FriendRequestAccept < BaseMutation
field :result, Boolean, null: false
argument :id, ID, required: true
def resolve(id:)
friend_request = FriendRequest.find_by_encrypted_id!(id)
use_case = FriendRequest::AcceptUseCase.run(operation_user: current_user, friend_request: friend_request)
raise GraphQL::ExecutionError, '更新処理に失敗しました' unless use_case.success?
{ result: true }
end
end
通常のRailsのコントローラと比較すると以下が優位な点かなと。
- このAPIが「何を引数にして 中で何をして 最後に何を返すのか」がResolverを見れば一目で全部わかる
- フィールドとResolverが1対1なのでコントローラの実装が見通しやすい
今回はやってないですがResolverやInputTypeの中で引数のバリデーションを書いたりもできるので、どこに何があるか (何をやるべきか) のわかりやすさ の解像度が一段上がる感じがするなと思っています。
逆にBadな点として、普通のRailsならあるリソースへのコントローラ内でアクションに共通した before_action
が書かれるような実装の場合、GraphQLではResolverに個別でそれを実装しなければならないというのがありそうです。
あと重要なのは、GraphQLを導入するとこのようにパッと見スッキリした実装になっていきますが、 それだけでCQSが実現されるわけではない ということです。
本当の意味でCQSを実現するには、YOUTRUSTさんで実践されているように できるだけモデルにロジックを書かず、更新とバリデーションは常にCommandから通す というアプローチを徹底する必要があったり、DBもprimary/readを分けてCommand/Queryに向かせるとか、ちゃんとロック取ってアトミックな実装にするなど、他にもやることがたくさんあるのでご注意ください。
(余談ですが、サービスの途中からこれをやっていくのは相当大変だったのではないかと思います。どういう戦術でアーキテクチャ移行を進めたのか?みたいな開発プロセスの話も今後のOPEN CODEで聞いてみたいですね。)
補足:
勉強会の中で紹介されていた資料は以下の通りです。
YOUTRUST 寺井さん、お忙しい中ご協力ありがとうございました!
ルーティングで考えることが少ない
(※ 上記と変わって、ある現場での話です)
人間を表すAccountモデルと、所属を表すOrganizationモデル、両者を紐づけるAccountOrganizationという中間テーブルを用意して、人間がどの所属に関連しているかを更新するという機能の実装を進めていました。
この時、URLのパスを PUT account/:account_id/organization/
みたいな感じで実装したのですが、GraphQLに慣れていると「このパスだけ見た時に何やってるかいまひとつ分かりづらいな」という気持ちになったのを覚えています。
また、実際に更新されるのは中間テーブルの AccountOrganization なので、本当はそれに即したエンドポイントでなくていいのか?、もしそのように変える場合は更新される中間テーブルのレコードはcreate/deleteのどちらかだからputではなくなる?といった、
- 本来フォーカスすべき設計実装の話ではないところで迷いが発生する
- Clientがエンティティのことをかなり具体で知っている前提で諸々実装していく感じになる
というのが若干微妙だよなーと思ってしまったんですね。(パスの名前をもっと工夫したらいいとか、ライブラリの使い方次第ではこういう悩みは無くなるとか、そもそもお前がナーバスに考えすぎなだけ みたいなツッコミは全然ありそう)
ちなみにGraphQLだったらメソッドもPOSTしかないし、Mutationの命名もやりたいことをフィールド名にするだけなので、Clientが持たなければならない知識がBEの実装から離れやすくなるなと。
今回の例で言えば、
resolver -> Mutations::Accounts::ChangeLinkedOrganization
フィールド -> accountsChangeLinkedOrganization
みたいに実装できそうです。
実装からスキーマを生成できるので管理が楽
RESTなシステムで「スキーマが定義されてないんだけど頻繁に使ってるAPIがある」みたいなのは割とよくある話かなと思います。
スタートアップの最初期ではとにかく何かを作っていくことが最優先なのでこういった状況が起こることは仕方ないとして、サービスの拡大につれてボディブローのような足枷になっていくのは想像に難くありません。
対して、GraphQLではスキーマが存在しなかったり一致しないフィールドにはリクエストが通らなくなるのでこういったことはまず起こらなそうなのと、実装からスキーマを生成できる ことが開発の大きな助けになってくれます。
例えば実際のタスクを進める際に、
- まずFE/BEエンジニア感で「こんな感じのスキーマにしよう」という事前設計をする
- その後このスキーマが成立する、下記のような空の実装を作ってスキーマをgenerateし、開発中のベースブランチにコミットする
class Resolvers::AdminUser::Products < Resolvers::BaseResolver
field :products, [Types::ProductType], null: false
argument :keyword, String, null: true
def resolve(keyword: nil)
# 実装がこのような状態でもスキーマは出力可能
[]
end
end
この時点からベースブランチのスキーマを起点にし、FE/BEはそれぞれ開発を進めることが可能になります。
またコードジェネレータという観点で見ると、OpenAPIは歴史が長くその分Clientもサーバーもライブラリが数多存在し、今回提示したようなエコシステムを構築することも可能なのではないかと思います。
一方で、ツールに振り回されてない?と感じることも多いです。
特にFE側はツールの変遷が激しく、長く生きてるプロダクトだと結局どのライブラリを今は使っているの?とか、古いジェネレータで作ったコードが現状の実装と乖離しててつらいみたいな状況もままあるかなと。
GraphQLは今のところApolloに乗っかっておけばOKという現場がほとんどなので、新しくJoinした時もスムーズだしメンテもしやすいなという印象があります。
注:アプリはRelayの方が優勢っぽいとか、別の観点がありそうです。
エラーハンドリングのお作法が決まってる
この記事最高なので、言いたいことは全て書いてあった...!
https://techblog.zozo.com/entry/graphql_error_handling
蛇足で個人的に思うところを書くと。
RESTなAPIで「このエラーの時にステータス何にする?」とか、「どういうメッセージ返す?」みたいなことって意外と議論が紛糾したり、そもそも議論すらされず野放図になっていて障害が起こった時にどこから追ったらいいかわからんみたいなことは割とあるかなと思います。
GraphQLの場合は、最もコスト軽くやるなら基底のGraphqlControllerで全部Rescueし、ログはSentryに送ってFEには「なんかエラー出たよ」ぐらいのメッセージをGraphQL::ExecutionErrorで返すという適当さでも一旦どうにかなるという手段を取ることができます。
(リリースの段階で最低限の仕組みを少ないコストで用意できる)
まとめ
色々書きましたが、 REST (+Rails way)で構築したAPIサーバーが大きくなっていくと最終的にはGraphQLで使われているプラクティスっぽいものに寄っていくぽい、というのが最近の僕の所感です。
であれば、これから立ち上げるプロダクトには、最初からGraphQL使っておくと後々かかるコストを削減できそうというのがこの記事で伝えたかったことでした。(特にtoC向けのプロダクトの場合)
もしかすると「これまでいわゆる普通のRailsでずっと開発してきてGraphQLを触ったことがない」という方が新規でサービスを立ち上げる際には、スピード感重視で今までのやり方を踏襲されるかもしれません。
が、GraphQLの学習コストはそこまで大きくないと個人的には思いますし、ここまで書いたように継続開発で得られるメリットはとても大きいと思います。
これから新しくRailsでAPIサーバーを立てるのであれば、ぜひ選択肢の一つとしてGraphQLの導入を検討されてはいかがでしょうか。
参考資料