見出し画像

とあるプロジェクトにGraphQLを導入してみた話

こんにちは。日産のルークスルークです。
バックエンド&Webチームで、PoC (Proof of Concept) プロジェクトのバックエンド(Go言語)を担当しています。

この技術noteでは、GraphQLを使ったことのない私のチームが、PoCプロジェクトで初めてGraphQLを採用してみた経験、その中でエンジニア個人として面白かったこと、良かったこと、大変だったことなどを話していきたいと思います。

GraphQLとは

API向けに作られたクエリ言語

Webのサーバとクライアント間のやり取りとして、RESTがよく用いられると思いますが、GraphQLはそのRESTの代わりとなるものです。

1つのエンドポイントに対してquery(データ取得リクエスト)やmutation(データ変更リクエスト)を投げて、知りたい情報の取得や、データの変更ができる仕組みです。

自分の所有している車の情報を取得するquery
上記のqueryを投げた結果

クライアントは欲しい情報を好きに選べる

実行できるquery/mutationはRESTのエンドポイントのように決まっていますが(例えば上記のような”me”のログインユーザの情報取得など)、結果で返ってくるデータはRESTと違って自由に指定して選べます。

サーバは要求されたデータのみ処理して返却する

RESTだと1つのエンドポイントでやりとりするデータは基本的には固定されています。
様々なクライアントがある場合や仕様変更の頻度が高いアジャイル開発では柔軟性や高効率が求められ、RESTだと応じにくいところがあると思います。
GraphQLを使うと、こういった問題点を解決できます。

サーバは、あるqueryなどに対してリクエストされたデータのみ処理し、クライアントはリクエストしたデータのみ受け取ります。毎回全てのデータを処理しないといけないといったことがなくなるので、あらゆるデータの定義さえしてしまえば、クライアントが自由にそのデータから好きな組み合わせ(クエリ)でリクエストできるようになります。結果として、複数エンドポイントへのリクエスト、また結合や整形といった処理コストが大幅に減ります。

ここで感じた最初の懸念

このGraphQLの話を同僚(クライント側の人間)から聞いて、今回のPoCで使ってみたら?と勧められたときは、確かにクライアント側的にはいいことずくめに聞こえますが、クライアントが色々楽になる分、サーバ側でやらないといけないことが増えるだけじゃないの?と懸念していました。
また、クライアントが好きにクエリを実行して好きなだけデータをリクエストできるとサーバへの負荷は大丈夫なのか?という心配もありました。

結論から言いますと、そこまで心配することはありませんでした。
サーバでは各データ単位で処理の実装を行い、クライアントではその用意されたデータを自由な組み合わせで取得するので、開発中にクライアントチームがサーバチームにAPI改修の依頼をしたり、開発の速度がそれに依存したりすることがあまり発生せず、お互いの開発がスムーズが進むので、サーバとクライアント間のチーム開発にいい影響が生まれました。
また、サーバへの負荷を守るための仕組みもちゃんと考えられています。
一方で、GraphQLを習得する学習時間もそれなりに必要でした。

GraphQLの特徴

スキーマと実装コードが完全一致する

GraphQLでは内部の実装コードだけではなく、スキーマも用いられます。
このスキーマに定義されているquery/mutationとそれらから返るデータ(または指定されるデータ)は全てtypeとして定義されています。

上記のqueryを可能にするスキーマ定義

この定義を先にスキーマで書いてから、それに沿ってコードを書く方法をschema firstと言い、逆にコードで先に実装してから、結果をスキーマに反映させる方法をcode firstと言います。

今回は前者のschema firstを採用しました。
理由はサーバ側やクライアント側でまずデータの定義を両者で決めてから実装に移るという開発フローにしたかったからです。

自然と嘘をつかないドキュメンテーションになる

ドキュメンテーションは、注意深く行っていても記載漏れや更新漏れで、嘘をついたドキュメントとなり、開発者の間では永遠の課題だと思います。

これらを防ぐ手段として自動化がありますが、たとえコードからドキュメントを生成しても、データが必ずその通り返却される保証はありません。(ドキュメントとAPIレスポンスを比較するテストまで行えば防ぐことが可能かもしれません。)

一方、GraphQLのスキーマは、後付のようなドキュメントではなく、中のライブラリが依存するコードのようなものです。
そして、それをサーバのライブラリもクライアントのライブラリも必要とします。
変数名や型が異なるところではコンパイルができないのと同じく、スキーマとコードが合っていないとエラーになります。
結果としてスキーマに定義されている内容と実装コードが完全一致することになります。

そのおかげで、スキーマに書いてあるデータしか返さず、コード生成との相性も良く、ボイラープレートコードを書く量も減らすことができます。

ここからは主にサーバ上でGraphQLを用いた話になります。

Controller関数で各エンドポイントを処理するのではなく、resolver関数でtype毎に処理をする

エンドポイントは1つしかないので、エンドポイント単位で関数を用意して処理を行うのではなく、スキーマに定義されるtype単位でresolverといった関数を用意(生成)し、その中で関連のデータを取得したりして処理を行います。

例えばUser typeのresolverでデータベースからユーザ情報を取得して返却し、Car typeのresolverで外部サービスから車両情報を取得して返却するといった単位の処理で構成されます。

こんな構成になると、例えばCar typeのフィールドとしてowner (User type)があり、またそのUser typeのフィールドとして所有してる車のリストのcars (Car typeの配列)があると、お互い(Car ⇔ User)を参照するqueryが可能となります。

type定義
重複して同じデータを取得するquery

RESTですと、このデータの取得を複数エンドポイントなどに分けないと無限ループになってしまいますが、GraphQLではリクエストしたデータのみが解決されるため無限ループになりません。
しかし、重複してquery(例えばcars.owner.cars.owner.carsなど)を書けば書くほど同じデータを取得し、無駄に同じ処理をサーバにさせてしまいます。
全く同じCarデータを解決するresolverが重複して何回も呼び出されてしまいます。
(尚、queryの複雑さを評価して、複雑過ぎるものを拒否するといった制御もライブラリで用意されていることがあります。)

重複する処理を解決する “dataloader”

この重複処理を解決するために用意されているのがdataloaderと呼ばれているcacheの仕組みです。

例えば同一リクエスト内の1回目のCarデータの解決時ではまだcacheにデータがないので普通にデータベースなどからデータを取得してcacheをします。
2回目の同じCarデータの解決時ではcacheにデータがあるのでそれを使い回すのみとなります。

また、複数のresolver実行からの複数リクエストをまとめて1回のみ裏のサービスを叩くbatchingもしてくれます。

1つのエンドポイント、且つstatus codeを使用しない状況でアクセスログはどうするのか?

status codeもGraphQLでは使用していなく、基本どの場合も200になります。

そのため、query/mutationの名前、指定されたパラメータのデータ、リクエストされたtypeといったGraphQLのライブラリから得られる情報をログのデータとして出力する必要があります。そしてログ調査時には、エンドポイント名ではなく、query/mutationの名前などで検索することとなります。

エラーが起きたときも、エラー情報(エラーtypeなど)をリクエストのアクセスログに入れるとstatus codeの代用となるので、status codeが使用されなくても特に問題になりません。
むしろ、鮮明にエラーtypeを出力することでstatus codeよりもより意味のあるログデータになります。

status codeを使用しないと、エラーハンドリングはどうするのか?

GraphQLは、status codeが使用されていないので、エラー判定もRESTと異なります。

RESTですと、status code毎にエラーの種類を区別することができたとしても、具体的に何が悪かったかはサーバが返却する汎用的なjsonなどを、クライアント側で解釈する必要性があります。

GraphQLの場合は決まったやり方がなく、様々な解決方法が考えられていますが、今回我々が選択した方法を紹介します。

正常時のデータのみならず、エラーのデータもスキーマでtypeとして定義しておけば、スキーマから簡単にあるquery/mutationの結果、どのエラーtypeがあり得るか把握でき(typeのunionを使います)、さらにエラー内のデータも具体的に把握できるため、クライアントは気にするエラーのtypeのみ取得し(query内のtype assertionで識別します)、type毎にtype内のデータを使いながら必要な処理を行うだけとなります。

エラーを示すtypeも返せるmutation定義
type assertionで気にするtypeを取得するmutation

GraphQLスキーマをRESTっぽく定義しちゃう問題

GraphQLとRESTの間には様々な違いがあり、そこが面白いと感じつつも、GraphQLを完全に理解するまでにはそれなりの時間が必要だと感じました。慣れるまでは、GraphQLのスキーマ定義を行う際に、無意識にRESTに寄せた定義にしてしまい、GraphQLの良さがなくなってしまうような事もありました。
必ずこれが正しいといった決まりはないですが、例えば:

REST式: 関連するデータを別queryで取得する
GraphQL式: type内から関連するデータまで辿る
: ユーザの所有している車を取得するとき
 REST式: cars(userId: ID!)とする
 GraphQL式: users.carsやme.carsとする

type Aとtype Bが関連しているとき
REST式: 循環参照しないように、A→Bは参照できても、B→Aは参照できないように定義し、BからAを取得したいときは別queryを用意する
GraphQL式: A→BとB→Aと両方から参照できるように定義する
: userがcarを所有している場合
 REST式: user→car: user.car, car→user: query user(id: carOwnerId)
 GraphQL式: user→car: user.car, car→user: car.user

配列で複数種類のデータを返却するとき
REST式
: クライアントのロジックで配列の中身を見て、ある属性をキーにmapする処理などを行う
GraphQL式: 種類毎にtypeを用意して、それらのtypeのunion、またはinterfaceを配列で返して、query内のtype assertionで識別する
: 車が処理中の複数リモートコマンドを取得するとき
 REST式: どの種類のコマンドかをアプリ側のロジックで判別する
 GraphQL式: query内で … on DoorLockCommand とtype assertionで判定する

返却されるtypeがunion、またはinterfaceによって複数あり得るが、その一部のみ取得したい場合
REST式
: queryの引数指定で取得したいtypeを指定する
GraphQL式: query内のtype assertionで取得したいtypeのみ指定する
: 車が処理中の複数リモートコマンドを一部のみ取得するとき
 REST式:
query commands(carId: carOwnerId, commandTypes: [DOOR_LOCK])
 GraphQL式: query内で … on DoorLockCommand とtype assertionで取得する

ライブラリが未だ過渡期

GraphQLの歴史を考えると当然かもしれませんが、GraphQLのライブラリは言語によってまだRESTほどの完成度に達していないように感じました。

GraphQLが生まれたJavaScriptですといいことを聞きますが、今回経験できたGo(とJava)ではまだ過渡期だというふうに感じました。

例えば、今回採用したライブラリでは、mutation時に渡されてくるデータのnull(クライアントがnullを指定した = そのデータをnullに設定したい)とundefined(クライアントがデータを指定しなかった = そのデータを変更したくない)の区別がつかず、resolver内のコードでライブラリ内のmutation解釈データを覗いて文字列比較による区別を行う必要があり、せっかくGraphQLでコードと紐づく綺麗なスキーマを定義できるのに、そのスキーマと紐づかないところで文字列をハードコードする必要があったのが残念なところでした。(機会を見てコミュニティに貢献できたらいいなと思います。)

まとめ

GraphQLは、全てのプロジェクトに向いているとは言えませんが、高頻度に仕様が変わるデータを扱い且つ整合性を担保したいプロジェクトに向いており、さらにサーバとクライアント間のコミュニケーションコストを重視したいプロジェクトには、GraphQLを採用することで開発がスムーズに進むと思います。

クライアントチームからは、名前の通り、graphのようにedgeを経由してデータが繋がるので、後々このデータも必要だとなった際、サーバチームに改修を依頼することなくqueryさえ書き換えれば取得できることや、データ結合が1つのqueryでできる点が満足ポイントだと聞いています。

日産中目黒オフィスでの働き方

今回のPoCでは、今までに我々が採用していない言語(Go言語)と、初めて扱うGraphQLを用いたことで、新たな技術チャレンジと技術知識の習得を行うことができました。
この経験により、今後他の開発プロジェクトでも、そのプロジェクトにマッチすれば、GraphQLを積極的に採用していきたいと思います。

日産中目黒オフィスは、この様な新たなチャレンジを歓迎していて、モビリティの未来を創るチャレンジの中で、組織も個人も常に前に向かって成長していける環境です。

こんなチャレンジをしたい!一緒に働いてみたい!と思うメンバーを募集中なので、興味があったらこちらまで!