2018年2月6日

GraphQLの基礎:GraphQLリゾルバーにおける`info`引数の解明

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

GraphQL Basics

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

しかし、infoオブジェクトが多くの混乱と誤解の原因となるいくつかのエッジケースがあります。この記事の目的は、infoオブジェクトの内側を覗き込み、GraphQLの実行プロセスにおけるその役割に光を当てることです。

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

infoオブジェクトの構造

再確認:GraphQLリゾルバーのシグネチャ

簡単な再確認です。GraphQL.jsでGraphQL.jsGraphQLサーバーを構築する場合、2つの主要なタスクがあります。

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

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

  1. parent:前のリゾルバー呼び出しの結果(詳細情報)。
  2. args:リゾルバーのフィールドの引数。
  3. context:各リゾルバーが読み書きできるカスタムオブジェクト。
  4. infoそれがこの記事で議論することです。

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

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

infoにはクエリASTとより多くの実行情報が含まれています

infoオブジェクトの構造と役割に興味がある人は、暗闇の中に置き去りにされています。公式の仕様も、ドキュメントも、それについて全く言及していません。以前は、それに関するより良いドキュメントを要求するGitHub issueがありましたが、目立った行動もなくクローズされました。そのため、コードを掘り下げる以外に方法はありません。

非常に大まかに言えば、infoオブジェクトには、受信したGraphQLクエリのASTが含まれていると言えます。おかげで、リゾルバーはどのフィールドを返す必要があるかを知ることができます。

クエリASTがどのようなものかについて詳しく学ぶには、Christian Joudrey氏の素晴らしい記事GraphQLクエリのライフサイクル — 字句解析/構文解析と、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オブジェクトが渡されるフィールド(およびそのバックアップリゾルバー)に依存することを意味します。明らかな例は、fieldNamerootType、およびparentTypeです。次のGraphQL型のauthorフィールドを考えてみましょう。

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

さて、feedの場合、これらの値は当然異なります。fieldNamefeedであり、returnType[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を使用すると、生のクエリとミューテーションを送信するのではなく、専用のバインディング関数を呼び出すことで、利用可能なクエリとミューテーションを送信できるようになります。

たとえば、特定のUserを取得する次の生のクエリを考えてみましょう。

バインディング関数で同じことを実現すると、次のようになります。

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

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

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

Prismaを使用したアプリケーションスキーマからデータベーススキーマへのマッピング

infoオブジェクトが混乱を引き起こす可能性のあるもう1つの一般的なユースケースは、Prismaprisma-bindingに基づくGraphQLサーバーの実装です。

そのコンテキストでは、アイデアは2つのGraphQLレイヤーを持つことです。

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

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

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

Prismaがこのデータモデルに基づいて生成するデータベーススキーマは、これに似ています。

さて、これに似たアプリケーションスキーマを構築したいと仮定します。

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

このアプリケーションスキーマを実装するための最初の直感は、次のようになる可能性があります。

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

この実装は十分に合理的であるように思われます。feedリゾルバー内では、潜在的に受信するauthorIdに基づいてauthorFilterを構築しています。authorFilterは、postsクエリを実行し、Post要素を取得するために使用され、リストのcountへのアクセスを提供するpostsConnectionクエリも使用されます。

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

実際、この実装でGraphQLサーバーを起動すると、最初はすべてが順調に見えるでしょう。単純なクエリは適切に処理され、たとえば次のクエリは成功することに気付くでしょう。

Post要素のauthorを取得しようとすると、問題が発生します。

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

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

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

これは確かに動作を説明しています。ctx.db.query.postsの呼び出しは、Post要素の正しいセットを返しますが、idtitleの値のみを返します。authorに関するリレーショナルデータはありません。

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

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

このクエリは実装例2で失敗します:「タイプ’Post’のフィールド’posts’はサブセレクションを持つ必要があります。」

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

エラーメッセージ「タイプ’Post’のフィールド’posts’はサブセレクションを持つ必要があります。」は、上記の実装の8行目によって生成されます。

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

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

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

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

では、この問題の解決策は何でしょうか?この問題への1つのアプローチは、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ニュースレターに登録してください