GraphQLで考える
GraphQLは、プロダクト開発者とクライアントアプリケーションのニーズに焦点を当てることで、クライアントがデータを取得するための新しい方法を提供します。開発者がビューに必要な正確なデータを指定する方法を提供し、クライアントが単一のネットワークリクエストでそのデータを取得できるようにします。RESTなどの従来のアプローチと比較して、GraphQLはアプリケーションがより効率的にデータを取得するのに役立ち(リソース指向のRESTアプローチと比較して)、カスタムエンドポイントで発生する可能性のあるサーバーロジックの重複を回避できます。さらに、GraphQLは開発者がプロダクトコードとサーバーロジックを分離するのに役立ちます。たとえば、プロダクトは関連するすべてのサーバーエンドポイントを変更することなく、より多くのまたはより少ない情報を取得できます。データを取得するのに最適な方法です。
この記事では、GraphQLクライアントフレームワークを構築することが何を意味するのか、そしてこれがより伝統的なRESTシステム用のクライアントと比較してどのように異なるのかを探ります。その過程で、Relayの背後にある設計上の決定を見て、それが単なるGraphQLクライアントではなく、宣言的なデータフェッチのためのフレームワークでもあることを確認します。まずは最初から始めて、データをフェッチしましょう!
データのフェッチ
ストーリーのリストと、それぞれの詳細をフェッチする単純なアプリケーションがあると想像してください。リソース指向のRESTでは、次のようになります。
// Fetch the list of story IDs but not their details:
rest.get('/stories').then(stories =>
// This resolves to a list of items with linked resources:
// `[ { href: "http://.../story/1" }, ... ]`
Promise.all(stories.map(story =>
rest.get(story.href) // Follow the links
))
).then(stories => {
// This resolves to a list of story items:
// `[ { id: "...", text: "..." } ]`
console.log(stories);
});
このアプローチでは、サーバーへのn+1回のリクエストが必要であることに注意してください。1回はリストをフェッチするため、n回は各アイテムをフェッチするためです。GraphQLを使用すると、サーバーへの単一のネットワークリクエストで同じデータをフェッチできます(その後、維持する必要があるカスタムエンドポイントを作成することなく)。
graphql.get(`query { stories { id, text } }`).then(
stories => {
// A list of story items:
// `[ { id: "...", text: "..." } ]`
console.log(stories);
}
);
今のところ、GraphQLを一般的なRESTアプローチのより効率的なバージョンとして使用しているにすぎません。GraphQLバージョンにおける2つの重要な利点に注目してください。
- すべてのデータが1回のラウンドトリップでフェッチされます。
- クライアントとサーバーは分離されています。クライアントは、サーバーエンドポイントが正しいデータを返すことに依存するのではなく、必要なデータを指定します。
単純なアプリケーションの場合、すでに大幅な改善です。
クライアントキャッシュ
サーバーから情報を繰り返し再フェッチすると、非常に遅くなる可能性があります。たとえば、ストーリーのリストからリストアイテムに移動し、再びストーリーのリストに戻ると、リスト全体を再フェッチする必要があります。標準的な解決策であるキャッシュでこれを解決します。
リソース指向のRESTシステムでは、URIに基づいてレスポンスキャッシュを維持できます。
var _cache = new Map();
rest.get = uri => {
if (!_cache.has(uri)) {
_cache.set(uri, fetch(uri));
}
return _cache.get(uri);
};
レスポンスキャッシュはGraphQLにも適用できます。基本的なアプローチは、RESTバージョンと同様に機能します。クエリのテキスト自体をキャッシュキーとして使用できます。
var _cache = new Map();
graphql.get = queryText => {
if (!_cache.has(queryText)) {
_cache.set(queryText, fetchGraphQL(queryText));
}
return _cache.get(queryText);
};
これで、以前にキャッシュされたデータに対するリクエストには、ネットワークリクエストを行わずに即座に応答できます。これは、アプリケーションの知覚パフォーマンスを向上させるための実用的なアプローチです。ただし、このキャッシュ方法は、データの整合性に問題を引き起こす可能性があります。
キャッシュの一貫性
GraphQLでは、複数のクエリの結果が重複することが非常に一般的です。ただし、前のセクションのレスポンスキャッシュは、この重複を考慮していません。別々のクエリに基づいてキャッシュします。たとえば、ストーリーをフェッチするためにクエリを発行した場合
query { stories { id, text, likeCount } }
その後、likeCount
がインクリメントされたストーリーの1つを後で再フェッチした場合
query { story(id: "123") { id, text, likeCount } }
ストーリーへのアクセス方法に応じて、異なるlikeCount
が表示されるようになりました。最初のクエリを使用するビューには古いカウントが表示され、2番目のクエリを使用するビューには更新されたカウントが表示されます。
グラフのキャッシュ
GraphQLをキャッシュする解決策は、階層的なレスポンスをレコードのフラットなコレクションに正規化することです。Relayは、このキャッシュをIDからレコードへのマップとして実装します。各レコードは、フィールド名からフィールド値へのマップです。レコードは他のレコードにリンクすることもでき(循環グラフを記述できるようにします)、これらのリンクはトップレベルマップを参照する特別な値型として保存されます。このアプローチでは、サーバーレコードはフェッチ方法に関係なく1回保存されます。
ストーリーのテキストとその作者の名前をフェッチするクエリの例を次に示します。
query {
story(id: "1") {
text,
author {
name
}
}
}
そして、これが可能なレスポンスです。
{
"query": {
"story": {
"text": "Relay is open-source!",
"author": {
"name": "Jan"
}
}
}
}
レスポンスは階層的ですが、すべてのレコードをフラット化することでキャッシュします。Relayがこのクエリのレスポンスをキャッシュする方法の例を次に示します。
Map {
// `story(id: "1")`
1: Map {
text: 'Relay is open-source!',
author: Link(2),
},
// `story.author`
2: Map {
name: 'Jan',
},
};
これは単純な例にすぎません。実際には、キャッシュは多対一の関連付けとページネーション(他にも)を処理する必要があります。
キャッシュの使用
では、このキャッシュをどのように使用するのでしょうか?2つの操作を見てみましょう。レスポンスを受信したときにキャッシュに書き込む操作と、クエリをローカルで満たすことができるかどうかを判断するためにキャッシュから読み取る操作です(上記の_cache.has(key)
と同等ですが、グラフの場合)。
キャッシュへの書き込み
キャッシュへの書き込みには、階層的なGraphQLレスポンスをたどり、正規化されたキャッシュレコードを作成または更新することが含まれます。最初はレスポンスだけでレスポンスを処理するのに十分であるように思えるかもしれませんが、実際にはこれは非常に単純なクエリにのみ当てはまります。user(id: "456") { photo(size: 32) { uri } }
を考えてみましょう。photo
をどのように保存すればよいでしょうか?キャッシュでフィールド名としてphoto
を使用することはできません。別のクエリが同じフィールドをフェッチする可能性があるものの、引数の値が異なるためです(例:photo(size: 64) {...}
)。同様の問題がページネーションで発生します。stories(first: 10, offset: 10)
で11番目から20番目のストーリーをフェッチする場合、これらの新しい結果は既存のリストに追加される必要があります。
したがって、GraphQLの正規化されたレスポンスキャッシュでは、ペイロードとクエリを並行して処理する必要があります。たとえば、上記のphoto
フィールドは、フィールドとその引数の値を一意に識別するために、photo_size(32)
などの生成されたフィールド名でキャッシュされる場合があります。
キャッシュからの読み取り
キャッシュから読み取るために、クエリをたどり、各フィールドを解決できます。しかし、ちょっと待ってください。それは、GraphQLサーバーがクエリを処理するときに行うこととまったく同じように聞こえます。そうです!キャッシュからの読み取りは、a)すべての結果が固定データ構造から得られるため、ユーザー定義のフィールド関数が不要であり、b)結果は常に同期である(データがキャッシュされているか、そうでないか)という特別な実行者のケースです。
Relayは、キャッシュやレスポンスペイロードなどの他のデータとともにクエリをたどる操作である、いくつかのクエリトラバーサルのバリエーションを実装しています。たとえば、クエリがフェッチされると、Relayは「差分」トラバーサルを実行して、どのフィールドが欠落しているかを判断します(Reactが仮想DOMツリーを差分処理するのと同様です)。これにより、多くの一般的なケースでフェッチされるデータ量を削減でき、クエリが完全にキャッシュされている場合はRelayがネットワークリクエストを完全に回避することもできます。
キャッシュの更新
この正規化されたキャッシュ構造では、重複する結果を重複なしでキャッシュできることに注意してください。各レコードは、フェッチ方法に関係なく1回保存されます。以前の矛盾したデータの例に戻り、このキャッシュがそのシナリオでどのように役立つかを見てみましょう。
最初のクエリはストーリーのリスト用でした。
query { stories { id, text, likeCount } }
正規化されたレスポンスキャッシュを使用すると、リスト内の各ストーリーに対してレコードが作成されます。stories
フィールドには、これらの各レコードへのリンクが保存されます。
2番目のクエリは、それらのストーリーの1つの情報を再フェッチしました。
query { story(id: "123") { id, text, likeCount } }
このレスポンスが正規化されると、Relayはid
に基づいて、この結果が既存のデータと重複していることを検出できます。Relayは、新しいレコードを作成するのではなく、既存の123
レコードを更新します。したがって、新しいlikeCount
は、このストーリーを参照する可能性のある他のクエリだけでなく、両方のクエリで使用できます。
データ/ビューの一貫性
正規化されたキャッシュは、キャッシュが整合性を保つことを保証します。しかし、私たちのビューはどうでしょうか?理想的には、Reactビューは常にキャッシュの現在の情報を反映します。
対応する作者の名前と写真とともに、ストーリーのテキストとコメントをレンダリングすることを考えてみましょう。これがGraphQLクエリです。
query {
story(id: "1") {
text,
author { name, photo },
comments {
text,
author { name, photo }
}
}
}
最初にこのストーリーをフェッチした後、キャッシュは次のようになる可能性があります。ストーリーとコメントの両方が、同じレコードをauthor
として参照していることに注意してください。
// Note: This is pseudo-code for `Map` initialization to make the structure
// more obvious.
Map {
// `story(id: "1")`
1: Map {
text: 'got GraphQL?',
author: Link(2),
comments: [Link(3)],
},
// `story.author`
2: Map {
name: 'Yuzhi',
photo: 'http://.../photo1.jpg',
},
// `story.comments[0]`
3: Map {
text: 'Here\'s how to get one!',
author: Link(2),
},
}
このストーリーの著者は、ごく普通のことですが、コメントもしています。ここで、別のビューが著者に関する新しい情報をフェッチし、彼女のプロフィール写真が新しいURIに変更されたと想像してください。これが、キャッシュされたデータの中で変更される唯一の部分です。
Map {
...
2: Map {
...
photo: 'http://.../photo2.jpg',
},
}
photo
フィールドの値が変更されました。したがって、レコード2
も変更されました。それだけです。キャッシュ内の他の部分には影響はありません。しかし、明らかに私たちのビューは更新を反映する必要があります。UI内の著者(ストーリーの著者とコメントの著者として)の両方のインスタンスに新しい写真を表示する必要があります。
標準的な対応は「不変データ構造を使用する」ことですが、もしそうした場合どうなるかを見てみましょう。
ImmutableMap {
1: ImmutableMap // same as before
2: ImmutableMap {
... // other fields unchanged
photo: 'http://.../photo2.jpg',
},
3: ImmutableMap // same as before
}
2
を新しい不変レコードで置き換えると、キャッシュオブジェクトの新しい不変インスタンスも取得されます。ただし、レコード1
と3
は変更されません。データは正規化されているため、story
レコードだけを見てもstory
の内容が変更されたかどうかはわかりません。
ビューの一貫性の実現
フラット化されたキャッシュでビューを最新の状態に保つためのさまざまな解決策があります。Relayが採用しているアプローチは、各UIビューから参照するIDのセットへのマッピングを維持することです。この場合、ストーリービューは、ストーリー(1
)、著者(2
)、およびコメント(3
およびその他)の更新をサブスクライブします。データをキャッシュに書き込むとき、RelayはどのIDが影響を受けるかを追跡し、これらのIDをサブスクライブしているビューのみに通知します。影響を受けたビューは再レンダリングされ、影響を受けないビューはパフォーマンス向上のために再レンダリングをオプトアウトします(Relayは安全で効果的なデフォルトのshouldComponentUpdate
を提供します)。この戦略がなければ、ごくわずかな変更でもすべてのビューが再レンダリングされます。
この解決策は書き込みにも機能することに注意してください。キャッシュへの更新は影響を受けるビューに通知し、書き込みはキャッシュを更新するもう1つの手段に過ぎません。
ミューテーション
ここまで、データのクエリとビューの最新状態の維持のプロセスを見てきましたが、書き込みについては見ていません。GraphQLでは、書き込みはミューテーションと呼ばれます。これらは副作用のあるクエリと考えることができます。以下は、特定のストーリーを現在のユーザーが「いいね」したとマークする可能性のあるミューテーションを呼び出す例です。
// Give a human-readable name and define the types of the inputs,
// in this case the id of the story to mark as liked.
mutation StoryLike($storyID: String) {
// Call the mutation field and trigger its side effects
storyLike(storyID: $storyID) {
// Define fields to re-fetch after the mutation completes
likeCount
}
}
ミューテーションの結果として変更された可能性のあるデータをクエリしていることに注目してください。明らかな疑問は、サーバーは変更された内容を教えてくれないのか?ということです。答えは、複雑だということです。GraphQLはあらゆるデータストレージレイヤー(または複数のソースの集約)を抽象化し、あらゆるプログラミング言語で機能します。さらに、GraphQLの目標は、製品開発者がビューを構築するのに役立つ形式でデータを提供することです。
GraphQLスキーマが、データがディスクに保存される形式とわずかに、あるいは大幅に異なることはよくあることです。簡単に言うと、基盤となるデータストレージ(ディスク)でのデータの変更と、製品で表示されるスキーマ(GraphQL)でのデータの変更の間には、常に1対1の対応があるわけではありません。この完璧な例はプライバシーです。age
のようなユーザー向けのフィールドを返すには、アクティブなユーザーがそのage
を表示することを許可されているかどうかを判断するために、データストレージレイヤー内の多数のレコードにアクセスする必要がある場合があります(私たちは友達ですか?私の年齢は共有されていますか?私はあなたをブロックしましたか?など)。
これらの現実世界の制約を考慮すると、GraphQLのアプローチは、クライアントがミューテーション後に変更される可能性のあるものをクエリすることです。しかし、そのクエリに具体的に何を記述すればよいでしょうか?Relayの開発中に、いくつかのアイデアを検討しました。Relayが現在のアプローチを使用する理由を理解するために、簡単に見ていきましょう。
オプション1:アプリがこれまでにクエリしたすべてを再フェッチします。実際に変更されるデータはごく一部であっても、サーバーがすべてのクエリを実行し、結果をダウンロードし、再び処理するのを待つ必要があります。これは非常に非効率的です。
オプション2:アクティブにレンダリングされているビューに必要なクエリのみを再フェッチします。これはオプション1よりも少し改善されています。ただし、現在表示されていないキャッシュされたデータは更新されません。このデータが何らかの方法で古いとマークされるか、キャッシュから削除されない限り、後続のクエリでは古い情報を読み取ります。
オプション3:ミューテーション後に変更される可能性のある固定のフィールドリストを再フェッチします。このリストをファットクエリと呼びます。典型的なアプリケーションはファットクエリのサブセットのみをレンダリングするため、これも非効率的であることがわかりましたが、このアプローチではそれらのフィールドをすべてフェッチする必要がありました。
オプション4(Relay):変更される可能性のあるもの(ファットクエリ)とキャッシュ内のデータの共通部分を再フェッチします。Relayはデータのキャッシュに加えて、各アイテムをフェッチするために使用されたクエリも記憶します。これらは追跡されたクエリと呼ばれます。追跡されたクエリとファットクエリを交差させることで、Relayはアプリケーションが更新に必要な情報のセットだけをクエリでき、それ以上のものをクエリする必要はありません。
データフェッチAPI
ここまで、データフェッチの低レベルな側面を見てきて、さまざまななじみのある概念がGraphQLにどのように変換されるかを見てきました。次に、少し立ち返って、製品開発者がデータフェッチに関して直面することが多い、より高レベルな懸念事項をいくつか見ていきましょう。
- ビュー階層のすべてのデータをフェッチする。
- 非同期状態遷移を管理し、同時リクエストを調整する。
- エラーを管理する。
- 失敗したリクエストを再試行する。
- クエリ/ミューテーション応答を受信した後、ローカルキャッシュを更新する。
- 競合状態を回避するためにミューテーションをキューに入れる。
- ミューテーションに対するサーバーの応答を待っている間、UIを楽観的に更新する。
私たちは、命令型APIを使用したデータフェッチの一般的なアプローチでは、開発者がこの本質的でない複雑さの多くに対処せざるを得ないことがわかりました。たとえば、楽観的なUI更新について考えてみましょう。これは、サーバー応答を待っている間にユーザーにフィードバックを提供する方法です。何を行うかのロジックは非常に明確です。ユーザーが「いいね」をクリックすると、ストーリーを「いいね」とマークし、リクエストをサーバーに送信します。しかし、実装ははるかに複雑になることがよくあります。命令型アプローチでは、これらの手順をすべて実装する必要があります。UIにアクセスしてボタンを切り替え、ネットワークリクエストを開始し、必要に応じて再試行し、失敗した場合はエラーを表示(およびボタンを元に戻す)します。データフェッチも同様です。必要なデータを指定すると、データがどのように、いつフェッチされるかが決まることがよくあります。次に、これらの懸念事項をRelayで解決するためのアプローチを探ります。
このページは役に立ちましたか?
以下の簡単な質問に答えて、このサイトをさらに良くするのを手伝ってください。 いくつかの簡単な質問に答える.