2017年12月6日

GraphQLのリモートスキーマはどのように機能しますか?

GraphQLスキーマステッチングを理解する(パートI)

How do GraphQL remote schemas work?

この記事では、既存の任意の GraphQL APIを使用し、それを自身のサーバーを通じて公開する方法を理解したいと考えています。このセットアップでは、サーバーは受け取ったGraphQLクエリとミューテーションを、基盤となるGraphQL APIに転送するだけです。これらの操作の転送を担当するコンポーネントは、リモート(実行可能)スキーマと呼ばれます。

リモートスキーマは、GraphQLコミュニティにおける真新しいトピックであるスキーマステッチングと呼ばれるツールとテクニックのセットの基礎となります。以下の記事では、スキーマステッチングのさまざまなアプローチについてさらに詳しく議論します。

要約: GraphQLスキーマ

前回の記事では、GraphQLスキーマの基本的なメカニズムと内部動作についてすでに説明しました。簡単に復習しましょう!

始める前に、GraphQLスキーマという用語はいくつかの意味を持つ可能性があるため、その意味を明確にすることが重要です。この記事の文脈では、主にNode.jsで書かれたGraphQLサーバーの基礎として使用される、GraphQL.js参照実装によって提供されるGraphQLSchemaクラスのインスタンスを指す言葉として使用します。

スキーマは主に2つのコンポーネントで構成されています

  • スキーマ定義: この部分は通常、GraphQLのスキーマ定義言語(SDL)で記述され、APIの機能を抽象的に記述します。そのため、まだ実際の実装はありません。要するに、スキーマ定義はサーバーが受け入れる操作(クエリ、ミューテーション、サブスクリプション)の種類を指定します。スキーマ定義が有効であるためには、Query型を含んでいる必要があり、オプションでMutation型やSubscription型を含めることができます。(コード内でスキーマ定義を参照する場合、対応する変数は通常typeDefsと呼ばれます。)
  • リゾルバ: ここでスキーマ定義が実体化し、実際の動作を獲得します。リゾルバは、スキーマ定義によって指定されたAPIを実装します。(詳細については、前回の記事を参照してください。)

スキーマがスキーマ定義とリゾルバ関数の両方を持つ場合、それを実行可能スキーマとも呼びます。GraphQLSchemaのインスタンスが必ずしも実行可能であるとは限りません。スキーマ定義のみを含み、リゾルバがアタッチされていない場合もあります。

以下に、graphql-toolsmakeExecutableSchema関数を使用した簡単な例を示します。

typeDefsには、必須のQuery型とシンプルなUser型を含むスキーマ定義が含まれています。resolversは、Query型で定義されたuserフィールドの実装を含むオブジェクトです。

makeExecutableSchemaは、スキーマ定義内のSDL型からのフィールドを、resolversオブジェクトで定義された対応する関数にマッピングします。これによりGraphQLSchemaのインスタンスが返され、例えばGraphQL.jsのgraphql関数を使用して実際のGraphQLクエリを実行できるようになります。

graphql関数はGraphQLSchemaのインスタンスに対してクエリを実行できるため、GraphQL(実行)エンジンとも呼ばれます。

GraphQL実行エンジンは、実行可能スキーマとクエリ(またはミューテーション)が与えられた場合に、有効なレスポンスを生成するプログラム(または関数)です。したがって、その主な役割は、実行可能スキーマ内のリゾルバ関数の呼び出しを調整し、GraphQL仕様に従って、レスポンスデータを適切にパッケージ化することです。

この知識をもって、既存のGraphQL APIに基づいてGraphQLSchemaの実行可能インスタンスを作成する方法について掘り下げていきましょう。

GraphQL APIのイントロスペクション

GraphQL APIの便利な特性の1つは、イントロスペクションを可能にすることです。これは、いわゆるイントロスペクションクエリを送信することで、任意のGraphQL APIのスキーマ定義を抽出できることを意味します。

上記の例を考慮すると、次のクエリを使用して、スキーマからすべての型とそのフィールドを抽出できます。

これにより、以下のJSONデータが返されます。

ご覧の通り、このJSONオブジェクトの情報は、上記のSDLベースのスキーマ定義と同等です(実際には、フィールドの引数を要求していないため100%同等ではありませんが、上記のイントロスペクションクエリを拡張してこれらを含めることもできます)。

リモートスキーマの作成

既存のGraphQL APIのスキーマをイントロスペクトする機能があれば、既存のスキーマ定義と同一の新しいGraphQLSchemaインスタンスを簡単に作成できます。これこそが、graphql-toolsmakeRemoteExecutableSchemaの考え方です。

makeRemoteExecutableSchemaは2つの引数を受け取ります。

  • スキーマ定義(上記で見たイントロスペクションクエリを使用して取得できます)。スキーマ定義は、ランタイムにイントロスペクションクエリを送信する(大きなパフォーマンスオーバーヘッドにつながる)のではなく、開発時にダウンロードし、.graphqlファイルとしてサーバーにアップロードすることがベストプラクティスとされています。
  • プロキシされるGraphQL APIに接続されたLink。本質的に、このLinkは既存のGraphQL APIにクエリやミューテーションを転送できるコンポーネントであり、その(HTTP)エンドポイントを知っている必要があります。

makeRemoteExecutableSchema実装はここからは非常に簡単です。スキーマ定義は新しいスキーマの基盤として使用されます。しかし、リゾルバはどうでしょうか、どこから来るのでしょうか?

もちろん、スキーマ定義をダウンロードするのと同じ方法でリゾルバをダウンロードすることはできません。リゾルバのイントロスペクションクエリは存在しません。しかし、言及したLinkコンポーネントを使用して、受信したクエリやミューテーションを基盤となるGraphQL APIに単に転送する新しいリゾルバを作成することは可能です。

うんざりする話はこれくらいにして、コードを見てみましょう!以下に、Userという型のGraphcool CRUD APIに基づき、リモートスキーマを作成し、それを専用サーバー(graphql-yogaを使用)を通じて公開する例を示します。

このコードの動作例はこちらで見つけることができます。

参考までに、User型に対するCRUD APIはこれといくらか似ています(完全なバージョンはこちらで見つけることができます)。

リモートスキーマの内部構造

上記の例のdatabaseServiceSchemaDefinitiondatabaseServiceExecutableSchemaが内部でどのように見えるか調べてみましょう。

GraphQLスキーマの調査

まず注目すべきは、両方ともGraphQLSchemaのインスタンスであるということです。ただし、databaseServiceSchemaDefinitionにはスキーマ定義のみが含まれているのに対し、databaseServiceExecutableSchemaは実際に実行可能なスキーマであり、その型のフィールドにリゾルバ関数がアタッチされています。

Chromeデバッガを使用すると、databaseServiceSchemaDefinitionが次のようなJavaScriptオブジェクトであることがわかります。

GraphQLSchemaの実行不可能なインスタンスGraphQLSchemaの実行不可能なインスタンス

青い四角は、そのプロパティを持つQuery型を示しています。予想通り、allUsersというフィールド(その他にも)があります。しかし、このスキーマインスタンスにはQueryのフィールドにリゾルバがアタッチされていないため、実行可能ではありません。

では、databaseServiceExecutableSchemaも見てみましょう。

実行可能スキーマ = スキーマ定義 + リゾルバ実行可能スキーマ = スキーマ定義 + リゾルバ

このスクリーンショットは先ほど見たものと非常によく似ていますが、allUsersフィールドにresolve関数がアタッチされています。(これはQuery型にある他のフィールド(Usernodeuser_allUsersMeta)についても同様ですが、スクリーンショットには表示されていません。)

さらに一歩踏み込んで、resolve関数の実装を見てみましょう(このコードはmakeRemoteExecutableSchemaによって動的に生成されたものです)。

私たちにとって興味深いのは12行目から16行目です。fetcherという関数が、queryvariablescontextの3つの引数で呼び出されています。fetcherは、以前に提供したLinkに基づいて生成されたもので、基本的に特定の(Linkを作成するために使用された)エンドポイントにGraphQL操作を送信できる関数であり、ここでまさにそれを実行しています。13行目でクエリの値として渡される実際のGraphQLドキュメントが、リゾルバに渡されるinfo引数(10行目を参照)から来ていることに注目してください。infoにはクエリのAST表現が含まれています。

非ルートリゾルバはネットワーク呼び出しを行わない

上記でallUsersルートフィールドのリゾルバ関数を探索したのと同じように、User型のフィールドのリゾルバがどのように見えるかも調べることができます。そのため、databaseServiceExecutableSchema_typeMapsプロパティに移動し、そこにフィールドを持つUser型を見つける必要があります。

User型には2つのフィールドがあります: idとname(両方にリゾルバ関数がアタッチされています)User型には2つのフィールドがあります: idとname(両方にリゾルバ関数がアタッチされています)

両方のフィールド(idname)にはresolve関数がアタッチされており、ここにmakeRemoteExecutableSchemaによって生成されたその実装があります(両方のフィールドで同一であることに注意してください)。

興味深いことに、今回は生成されたリゾルバはfetcher関数を使用していません。実際には、ネットワークへの呼び出しはまったく行いません。返される結果は、関数に渡されるparent引数(10行目)から単純に取得されます。

リモートスキーマにおけるリゾルバデータのトレース

リモート実行可能スキーマのリゾルバに関するトレースデータもこの発見を裏付けています。以下のスクリーンショットでは、以前のスキーマ定義をArticle型とComment型(それぞれexistingUserにも接続されています)で拡張し、より深くネストされたクエリを送信できるようにしました。

GraphQL Playgroundsは、リゾルバのトレースデータをすぐに表示する機能をサポートしています(右下)GraphQL Playgroundsは、リゾルバのトレースデータをすぐに表示する機能をサポートしています(右下)

トレースデータから、ルートリゾルバ(allUsersフィールド用)だけが顕著な時間(167ミリ秒)を要していることが非常によく分かります。非ルートフィールドのデータを返す役割を担う残りのすべてのリゾルバは、実行にわずか数マイクロ秒しかかかっていません。これは、ルートリゾルバがfetcherを使用して受信したクエリを転送する一方、すべての非ルートリゾルバは受信したparent引数に基づいてデータを単純に返すという、以前に行った観察で説明できます。

リゾルバの戦略

スキーマ定義のリゾルバ関数を実装する際には、いくつかの方法があります。

標準パターン: 型レベルのリゾルビング

次のスキーマ定義を考えてみましょう。

Query型に基づいて、次のクエリをAPIに送信することが可能です。

対応するリゾルバは通常どのように実装されるでしょうか?この標準的なアプローチは以下のようになります(このコードでfetchで始まる関数はデータベースからリソースをロードしていると仮定します)。

このアプローチでは、型レベルで解決しています。これは、特定のクエリ(例: 特定のArticle)に対する実際のオブジェクトが、Article型のいずれのリゾルバも呼び出される前にフェッチされることを意味します。

上記のクエリに対するリゾルバの呼び出しを考えてみましょう。

  1. Query.userリゾルバが呼び出され、データベースから特定のUserオブジェクトをロードします。クエリで要求されていないにもかかわらず、idnameを含むUserオブジェクトのすべてのスカラフィールドをロードすることに注意してください。articlesについてはまだ何もロードしていませんが、これは次のステップで起こることです。
  2. 次に、User.articlesリゾルバが呼び出されます。入力引数parentが前のリゾルバからの戻り値であることに注目してください。つまり、それは完全なUserオブジェクトであり、リゾルバがUseridにアクセスしてそのArticleオブジェクトをロードすることを可能にします。

この例を理解するのが難しい場合は、GraphQLスキーマに関する前回の記事を必ず読んでください。

リモート実行可能スキーマはマルチレベルリゾルバアプローチを使用する

では、リモートスキーマの例とそのリゾルバについてもう一度考えてみましょう。リモート実行可能スキーマを使用してクエリを実行する場合、データソースはルートリゾルバ(上記のスクリーンショットでfetcherを見つけた場所)で一度だけヒットすることがわかりました。他のすべてのリゾルバは、入力されたparent引数(最初のリゾルバ呼び出しの結果のサブパート)に基づいて正規の結果を返します。

しかし、それはどのように機能するのでしょうか?ルートリゾルバが単一のリゾルバですべて必要なデータをフェッチしているように見えますが、これは非常に非効率ではないでしょうか?確かに、常にすべてのオブジェクトフィールドを含めてすべてのリレーショナルデータをロードするならば、それは非常に非効率でしょう。では、受信したクエリで指定されたデータのみをロードするにはどうすればよいでしょうか?

これが、リモート実行可能スキーマのルートリゾルバが、クエリ情報を含む利用可能なinfo引数を利用する理由です。実際のリゾルバの選択セットを見ることで、リゾルバはオブジェクトのすべてのフィールドをロードする必要はなく、必要なフィールドのみをロードします。この「トリック」により、単一のリゾルバですべてのデータをロードすることが依然として効率的になります。

まとめ

この記事では、graphql-toolsmakeRemoteExecutableSchemaを使用して、既存のGraphQL APIのプロキシを作成する方法を学びました。このプロキシはリモート実行可能スキーマと呼ばれ、自身のサーバー上で動作します。これは、受け取ったクエリをすべて基盤となるGraphQL APIに転送するだけです。

また、このリモート実行可能スキーマがマルチレベルリゾルバを使用して実装されていることも確認しました。ここでは、ネストされたデータが型レベルで複数回ではなく、最初のリゾルバによって1回だけフェッチされます。

リモートスキーマについてはまだ多くの発見があります: スキーマステッチングとどのように関連するのか?GraphQLサブスクリプションとはどのように連携するのか?私のcontextオブジェクトはどうなるのか?次に何を学びたいかコメントで教えてください!👋

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

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

© . All rights reserved.