GraphQL の @defer, @stream ディレクティブを試してみる
GraphQL Working Groupで進められている標準化で先日Stage 2に上がったProposalである @defer
, @stream
ディレクティブの概要をさらって動作を試してみる記事です。
参考記事
以下の記事に詳細な情報が書いてあり、この記事のほとんどはそれを参照したものです。
@defer
, @stream
ディレクティブの概要
GraphQLは画面に表示する情報を一度のリクエストで取得できる便利な技術ですが、その反面サーバー側で要求されたデータすべてが揃うまでレスポンスを返せないという欠点もあります。それを補うための仕様が @defer
, @stream
ディレクティブです。GraphQLのレスポンスを複数に分け、これらのディレクティブが付いたフィールドをあとから返すことで遅いデータにクエリ全体が引きずられることがないようにできます。
以下のような、 person
のクエリでデータの取得に時間がかかる profile
フィールドがあるとして、そこに @defer
フィールドを適用する例を挙げます。
query {
person(id: "1") {
name
iconUrl
...ProfileFragment @defer(label: "profileDefer")
}
fragment ProfileFragment on Person {
profile {
address
phoneNumber
}
}
このクエリに対するサーバーからの応答は以下のように2度に分割されます。
{
"data": {
"person": {
"name": "Luke Skywalker",
"iconUrl": "https://example.com/persons/luke/icon.png"
}
},
"hasNext": true
}
{
"label": "profileDefer",
"path": ["person"],
"data": {
"profile": {
"address": "...",
"phoneNumber": "..."
}
},
"hasNext": false
}
最初に @defer
ディレクティブの付いていないフィールドのレスポンスが送信され、 hasNext: true
となり後続のレスポンスが続くことが示されます。続くレスポンスでは label
, path
といったフィールドで対応する @defer
ディレクティブとデータが追加される場所が示され、すべての @defer
ディレクティブに対応するレスポンスが終わった時点で hasNext: false
となります。
@stream
ディレクティブも似たような動作になります。最初に挙げた記事に説明があるのでそれを参照してください。
graphql-js, express-graphqlのexperimentalな実装
この @defer
, @stream
ディレクティブのexperimentalな実装がgraphql-js, express-graphqlで既に行われており npm
でも公開されています。
{
"dependencies": {
"graphql": "15.4.0-experimental-stream-defer.1",
"express-graphql": "0.12.0-experimental-stream-defer.1"
}
}
対応するPRもあり、feedback welcomeとのことです。
筆者は graphql-js, express-graphql のどちらの実装にも詳しくないので実装について言えることは無いですが、express-graphqlのコードを見るとasync iterableの for await
が使われていて確かに今回の用途っぽいなと思いました。(感想)
またこの実装からもわかるように、レスポンスには Content-Type: multipart/mixed;
が使われているようです。分割されたGraphQLレスポンスがmultipartで送られてくるのはなるほどと思いました。(感想)
graphql-helix というOSSは中身を見ていないですが既に @defer
, @stream
ディレクティブをサポートしているようです。
Server push and client pull. GraphQL Helix supports real-time requests with both subscriptions and
@defer
and@stream
directives.
graphql-helixを使った @defer
, @stream
ディレクティブを動かしたサンプル実装のリポジトリも公開されています。
動かしてみる
先程あげたサンプル実装のリポジトリでは、サーバーサイドの実装と @defer
, @stream
に対応したGraphiQLの実装がされています。このリポジトリをForkしてReactアプリに雑に組み込んでみました。
シンプルに fetch
APIでGraphQLのクエリを送信します。
fetch("http://localhost:4000/graphql", {
method: "POST",
mode: "cors",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `query DeferTestQuery {
deferTest {
name
... on GraphQLDeferTest @defer {
deferThisField
}
}
}`,
}),
})
すると次のようなHTTPレスポンスが返ってきます。
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Type: multipart/mixed; boundary="-"
Date: Sat, 12 Dec 2020 14:33:34 GMT
Transfer-Encoding: chunked
X-Powered-By: Express
---
Content-Type: application/json; charset=utf-8
{"data":{"deferTest":{"name":"Peter Parker"}},"hasNext":true}
---
Content-Type: application/json; charset=utf-8
{"data":{"deferThisField":"Took a long time ,he?"},"path":["deferTest"],"hasNext":false}
-----
仕様とexpress-graphqlの実装から見た通りのレスポンスです。あとはこれを処理するだけですが、 fetch(...).then(res => res.text())
などとすると最後のレスポンスまで待ってしまうことになりせっかくmultipartにした意味がないので多少がんばる必要があります。ReactのカスタムHookとして実装した例が以下になります。
import parser from "http-string-parser";
function useDeferQuery() {
const [response, setResponse] = React.useState<any>();
React.useEffect(() => {
fetch("http://localhost:4000/graphql", {
method: "POST",
mode: "cors",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `query DeferTestQuery {
deferTest {
name
... on GraphQLDeferTest @defer {
deferThisField
}
}
}`,
}),
}).then((res) => {
const reader = res.body?.getReader();
// https://developer.mozilla.org/ja/docs/Web/API/Streams_API/Using_readable_streams
return new ReadableStream({
start(controller) {
return pump();
function pump(): any {
return reader?.read().then(({ done, value }) => {
// データを消費する必要がなくなったら、ストリームを閉じます
if (done) {
controller.close();
return;
}
// uint8arrayを文字列に変換する
const str = new TextDecoder("utf-8").decode(value);
// 文字列形式のHTTPレスポンスをパースする
const resBody = parser.parseResponse(str).body;
// bodyが2行の文字列で、1行目にJSON、2行目に区切りの---が付いているので1行目を取り出す
const body = resBody.split("\n")[0];
const queryRes = JSON.parse(body);
// deferディレクティブを使ったレスポンスの最初はpathがなく、後続のものはpathが付いている
if (queryRes.path) {
setResponse((prev: any) => ({ ...prev, ...queryRes.data }));
} else {
setResponse({ ...queryRes.data.deferTest });
}
return pump();
});
}
},
});
});
}, []);
return response;
}
ReaderSteram
あたりはMDNからのほぼコピペであんまりわかってないですが、 res.body.getReader()
とすることで ReadableStream
が得られ、それを read()
することで都度multipartに分割されたレスポンスを適宜扱うことができるようです。
まとめ
GraphQLの仕様として標準化が進んでいる @defer
, @stream
ディレクティブの概要とサンプル実装を紹介しました。実際に一般的なWebアプリケーション開発者が使うようになるのはApollo Client/Serverなどの利用者の多いライブラリが対応してからにはなると思います。実用できる段階になっても誰もが使うようなものでもないですがGraphQLの欠点をうまく解決していて面白い仕様だと思いました。(感想)
まだexperimentalでfeedback welcomeとのことなので触ってみて意見をフィードバックしてみてはいかがでしょうか。この記事も勢いで書いたので間違いなどあればご指摘いただけるとありがたいです。
Discussion