2018年2月6日

GraphQLの基本:GraphQLリゾルバーの`info`引数を解明する

GraphQLサーバーの構造と実装(パートIII)

GraphQL Basics

これまでにGraphQLサーバーを書いたことがあるなら、リゾルバーに渡される`info`オブジェクトを目にしたことがあるでしょう。幸いなことに、ほとんどの場合、それが実際に何をするのか、クエリ解決中の役割が何であるかを理解する必要はありません。

しかし、`info`オブジェクトが多くの混乱や誤解の原因となるエッジケースがいくつか存在します。この記事の目的は、`info`オブジェクトの内部を詳しく調べ、GraphQLの実行プロセスにおけるその役割を明らかにすることです。

この記事は、GraphQLのクエリとミューテーションがどのように解決されるかの基本に既に精通していることを前提としています。この点で少し不安がある場合は、このシリーズの以前の記事をぜひ確認してください。パートI:GraphQLスキーマ(必須)パートII:ネットワーク層(任意)

infoオブジェクトの構造

復習:GraphQLリゾルバーのシグネチャ

簡単に復習すると、GraphQL.jsでGraphQLサーバーを構築する際には、主に2つのタスクがあります。

  • あなたのGraphQLスキーマを定義する(SDLまたはプレーンなJSオブジェクトのいずれかで)
  • スキーマ内の各フィールドについて、そのフィールドの値を返す方法を知っているリゾルバー関数を実装する

リゾルバー関数は(この順序で)4つの引数を取ります

  1. parent: 以前のリゾルバー呼び出しの結果(詳細はこちら)。
  2. args: リゾルバーのフィールドの引数。
  3. context: 各リゾルバーが読み書きできるカスタムオブジェクト。
  4. info: これについてはこの記事で説明します。

ここでは、シンプルなGraphQLクエリの実行プロセスと、それに属するリゾルバーの呼び出しの概要を示します。第2リゾルバーレベルの解決は自明であるため、これらのリゾルバーを実際に実装する必要はありません。それらの戻り値はGraphQL.jsによって自動的に推論されます。

Blog image

GraphQLリゾルバーチェーンにおけるparentおよびargs引数の概要

infoにはクエリのASTとその他の実行情報が含まれる

`info`オブジェクトの構造と役割について興味がある人は、情報が不足しています。公式の仕様ドキュメントも、それについて一切言及していません。以前は、より良いドキュメントを求めるGitHubのissueがありましたが、特に進展なくクローズされました。そのため、コードを掘り下げるしかありません。

非常に高レベルで見ると、`info`オブジェクトには、入力されたGraphQLクエリのASTが含まれていると言えます。これにより、リゾルバーはどのフィールドを返す必要があるかを知ることができます。

クエリASTがどのようなものかについて詳しく知るには、Christian Joudreyによる素晴らしい記事Life of a GraphQL Query — Lexing/Parsing、そしてEric Baerによる素晴らしい講演GraphQL Under the Hoodをぜひご覧ください。

infoの構造を理解するために、そのFlow型定義を見てみましょう。

以下に、これらの各キーの概要と簡単な説明を示します

  • fieldName: 前述のとおり、GraphQLスキーマの各フィールドはリゾルバーによってサポートされる必要があります。fieldNameには、現在のリゾルバーに属するフィールドの名前が含まれます。
  • fieldNodes: 各オブジェクトが残りの選択セット内のフィールドを表す配列。
  • returnType: 対応するフィールドのGraphQL型。
  • parentType: このフィールドが属するGraphQL型。
  • path: 現在のフィールド(つまりリゾルバー)に到達するまでに辿られたフィールドを追跡します。
  • schema: 実行可能なスキーマを表すGraphQLSchemaインスタンス。
  • fragments: クエリドキュメントの一部であったフラグメントのマップ。
  • rootValue: 実行に渡されたrootValue引数。
  • operation: クエリ全体のAST。
  • variableValues: クエリと一緒に提供された変数のマップ。variableValues引数に対応します。

これらがまだ抽象的に思えても心配しないでください。すぐにすべての例を見ることができます。

フィールド固有 vs グローバル

上記のキーに関して、興味深い観察が1つあります。infoオブジェクト上のキーは、フィールド固有であるか、グローバルであるかのいずれかです。

フィールド固有とは、そのキーの値が、infoオブジェクトが渡されるフィールド(およびその基盤となるリゾルバー)に依存することを意味します。明らかな例としては、fieldNamereturnTypeparentTypeが挙げられます。以下のGraphQL型のauthorフィールドを考えてみましょう。

そのフィールドのfieldNameは単にauthorであり、returnTypeUser!parentTypeQueryです。

さて、feedの場合、これらの値は当然異なります。fieldNamefeedreturnType[Post!]!、そしてparentTypeQueryです。

したがって、これら3つのキーの値はフィールド固有です。さらにフィールド固有のキーは、fieldNodespathです。実質的に、上記のFlow定義の最初の5つのキーはフィールド固有です。

一方、グローバルとは、これらのキーの値が、どのリゾルバーを扱っているかに関わらず変化しないことを意味します。schemafragmentsrootValueoperation、そしてvariableValuesは、すべてのリゾルバーで常に同じ値を持ちます。

簡単な例

では、infoオブジェクトの内容の例を見ていきましょう。この例のために、ここで使用するスキーマ定義を示します。

このスキーマのリゾルバーが以下のように実装されていると仮定します。

Post.titleリゾルバーは実際には必須ではありませんが、リゾルバーが呼び出されたときにinfoオブジェクトがどのように見えるかを確認するために、ここに含まれています。

次に、以下のクエリを考えます。

簡潔にするため、Query.authorフィールドのリゾルバーのみを議論し、Post.titleのリゾルバー(上記のクエリが実行される際にやはり呼び出されます)については議論しません。

この例を試してみたい場合は、上記のスキーマの動作バージョンを含むリポジトリを用意しましたので、試すことができます!

次に、infoオブジェクト内の各キーを見て、Query.authorリゾルバーが呼び出されたときにそれらがどのように見えるかを確認しましょう(infoオブジェクトの完全なログ出力はこちらで見ることができます)。

fieldName

fieldNameは単にauthorです。

fieldNodes

fieldNodesがフィールド固有であることを思い出してください。それは実質的にクエリASTの抜粋を含んでいます。この抜粋はクエリのルートからではなく、現在のフィールド(つまりauthor)から始まります。(ルートから始まるクエリAST全体はoperationに格納されます。下記を参照してください)。

returnType & parentType

以前に見たように、returnTypeparentTypeはかなり自明です。

path

pathは、現在のフィールドに到達するまでに辿られたフィールドを追跡します。Query.authorの場合、単に"path": { "key": "author" }のようになります。

比較のために、Post.titleリゾルバーでは、pathは以下のようになります。

残りの5つのフィールドは「グローバル」カテゴリに属し、したがってPost.titleリゾルバーでも同じになります。

schema

schemaは実行可能なスキーマへの参照です。

fragments

fragmentsにはフラグメント定義が含まれますが、クエリドキュメントにはそのようなものがないため、単なる空のマップ{}です。

rootValue

前述のとおり、rootValueキーの値は、そもそもgraphql実行関数に渡されるrootValue引数に対応します。この例の場合、それは単にnullです。

operation

operationには、入力されたクエリの完全なクエリASTが含まれています。他の情報に加えて、これには上で見たfieldNodesと同じ値が含まれていることを思い出してください。

variableValues

このキーは、クエリに渡された変数を表します。この例には変数が含まれていないため、この値も単なる空のマップ{}です。

クエリが変数で記述された場合

variableValuesキーは単に以下の値を持つことになります。

GraphQLバインディングを使用する際のinfoの役割

記事の冒頭で述べたように、ほとんどのシナリオではinfoオブジェクトについてまったく心配する必要はありません。それは単にリゾルバーのシグネチャの一部であるだけで、実際には何も使っていないからです。では、それがいつ重要になるのでしょうか?

バインディング関数へのinfoの受け渡し

以前にGraphQLバインディングを扱ったことがあるなら、生成されたバインディング関数の一部としてinfoオブジェクトを見たことがあるでしょう。以下のスキーマを考えてみましょう。

graphql-bindingを使用すると、生(raw)のクエリやミューテーションを送信する代わりに、専用のバインディング関数を呼び出すことで、利用可能なクエリやミューテーションを送信できるようになります。

例えば、特定のUserを取得する以下の生(raw)のクエリを考えてみましょう。

バインディング関数で同じことを行うと、以下のようになります。

バインディングインスタンスのuser関数の呼び出しと、対応する引数を渡すことで、上記の生(raw)のGraphQLクエリとまったく同じ情報を伝達します。

graphql-bindingのバインディング関数は3つの引数を取ります。

  1. args: フィールドの引数を含みます(例:上記のcreateUserミューテーションのusername)。
  2. context: リゾルバーチェーンを介して渡されるcontextオブジェクト。
  3. info: infoオブジェクト。`info`の型であるGraphQLResolveInfoのインスタンスではなく、選択セットを単に定義する文字列を渡すこともできることに注意してください。

Prismaを用いたアプリケーションスキーマとデータベーススキーマのマッピング

`info`オブジェクトが混乱を招く可能性のあるもう一つの一般的なユースケースは、Prismaおよびprisma-bindingに基づいてGraphQLサーバーを実装する場合です。

その文脈では、2つのGraphQLレイヤーを持つという考え方です。

  • The データベースレイヤーはPrismaによって自動生成され、汎用的で強力なCRUD APIを提供します。
  • The* アプリケーションレイヤー*は、クライアントアプリケーションに公開され、アプリケーションのニーズに合わせて調整されたGraphQL APIを定義します。

バックエンド開発者として、あなたはアプリケーションレイヤーのアプリケーションスキーマを定義し、そのリゾルバーを実装する責任があります。prisma-bindingのおかげで、リゾルバーの実装は、大きなオーバーヘッドなしで、受信クエリを下位のデータベースAPIに委任するプロセスに過ぎません。

簡単な例を考えてみましょう。Prismaデータベースサービスに以下のデータモデルから開始するとします。

Prismaがこのデータモデルに基づいて生成するデータベーススキーマは、次のようになります。

さて、次のようなアプリケーションスキーマを構築したいと仮定します。

feedクエリは、Post要素のリストを返すだけでなく、リストのcount(件数)も返すことができます。オプションでauthorIdを受け取り、特定のUserによって書かれたPost要素のみを返すようにフィードをフィルタリングすることに注意してください。

このアプリケーションスキーマを実装する際の最初の直感は、次のようになるかもしれません。

実装1:この実装は正しく見えますが、微妙な欠陥があります

この実装は十分に妥当に見えます。feedリゾルバー内で、入力される可能性のあるauthorIdに基づいてauthorFilterを構築しています。authorFilterはその後、postsクエリを実行してPost要素を取得し、さらにリストのcountへのアクセスを提供するpostsConnectionクエリを実行するために使用されます。

実際のPost要素は、postsConnectionクエリのみを使用して取得することも可能です。物事を単純にするために、ここではpostsクエリを使用し、他のアプローチは注意深い読者の練習問題として残しておきます。

実際、この実装でGraphQLサーバーを起動すると、最初はうまくいくように見えるでしょう。シンプルなクエリは適切に処理され、例えば以下のクエリは成功します。

Post要素のauthorを取得しようとすると、問題に遭遇します。

なるほど!どういうわけか、実装がauthorを返さず、そのため「nullを非null可能なPost.authorに返すことはできません。」というエラーが発生します。これは、アプリケーションスキーマPost.authorフィールドが必須としてマークされているためです。

実装の関連部分をもう一度見てみましょう。

ここでPost要素を取得します。しかし、選択セットをpostsバインディング関数に渡していません。Prismaバインディング関数に2番目の引数が渡されない場合、デフォルトの動作はその型のすべてのスカラーフィールドをクエリすることです。

これは確かにその振る舞いを説明します。ctx.db.query.postsの呼び出しは、正しいPost要素のセットを返しますが、authorに関するリレーショナルデータはなく、そのidtitleの値のみです。

では、どうすればこれを修正できるでしょうか?明らかに必要なのは、postsバインディング関数にどのフィールドを返す必要があるかを伝える方法です。しかし、その情報はfeedリゾルバーのコンテキストのどこに存在するのでしょうか?推測できますか?

正解:infoオブジェクトの中です!Prismaバインディング関数の2番目の引数は、文字列またはinfoオブジェクトのいずれかであるため、feedリゾルバーに渡されるinfoオブジェクトをそのままpostsバインディング関数に渡しましょう。

このクエリは実装2で失敗します:「タイプ'Post'のフィールド'posts'はサブ選択を持たなければなりません。」

しかし、この実装では、どのリクエストも適切に処理されません。例として、以下のクエリを考えてみましょう。

エラーメッセージ「タイプ'Post'のフィールド'posts'はサブ選択を持たなければなりません。」は、上記実装の8行目で生成されます。

では、ここで何が起こっているのでしょうか?これが失敗する理由は、infoオブジェクト内のフィールド固有のキーがpostsクエリと一致しないためです。

feedリゾルバー内でinfoオブジェクトを出力すると、状況がより明確になります。fieldNodes内のフィールド固有の情報のみを考慮してみましょう。

このJSONオブジェクトは、文字列の選択セットとしても表現できます。

これですべてが分かりました!上記の選択セットをPrismaデータベーススキーマのpostsクエリに送信しているのですが、もちろんPrismaはfeedフィールドとcountフィールドを認識していません。確かに生成されるエラーメッセージはあまり役に立ちませんが、少なくとも今では何が起こっているのかを理解できます。

では、この問題の解決策は何でしょうか?この問題に取り組む一つの方法は、fieldNodesの選択セットの正しい部分を手動でパースして、それをpostsバインディング関数に渡すこと(例えば文字列として)でしょう。

しかし、この問題にははるかにエレガントな解決策があり、それはアプリケーションスキーマからFeed型専用のリゾルバーを実装することです。適切な実装は次のようになります。

実装3:この実装は上記の問題を修正します

この実装は、上で議論されたすべての問題を解決します。いくつか注意すべき点があります。

  • 8行目で、文字列の選択セット({ id })を2番目の引数として渡しています。これは効率のためであり、そうでなければすべてのスカラー値がフェッチされてしまいますが(この例では大きな違いはありません)、ここではIDのみが必要です。
  • Query.feedリゾルバーからpostsを返すのではなく、IDの配列(文字列として表現)であるpostIdsを返しています。
  • Feed.postsリゾルバーでは、リゾルバーによって返されたpostIdsにアクセスできるようになりました。今回は、入力されたinfoオブジェクトを利用して、それをそのままpostsバインディング関数に渡すことができます。

この例を試してみたい場合は、上記の例の動作バージョンを含むこのリポジトリを確認してください。この記事で言及されている異なる実装を自由に試して、ご自身で動作を確認してください!

まとめ

この記事では、GraphQL.jsに基づいてGraphQL APIを実装する際に使用されるinfoオブジェクトについて深く掘り下げました。

infoオブジェクトは公式には文書化されていません。詳細を知るにはコードを深く掘り下げる必要があります。このチュートリアルでは、その内部構造を概説し、GraphQLリゾルバー関数におけるその役割を理解することから始めました。次に、infoに関するより深い理解が必要となるいくつかのエッジケースと潜在的な落とし穴について説明しました。

この記事で示されたすべてのコードは、対応するGitHubリポジトリで見つけることができます。ご自身で`info`オブジェクトの動作を試して観察してみてください。

次の投稿をお見逃しなく!

Prismaニュースレターに登録する

© . All rights reserved.