🍇

GraphQL の @defer, @stream ディレクティブを試してみる

2020/12/12に公開

GraphQL Working Groupで進められている標準化で先日Stage 2に上がったProposalである @defer, @stream ディレクティブの概要をさらって動作を試してみる記事です。

参考記事

以下の記事に詳細な情報が書いてあり、この記事のほとんどはそれを参照したものです。

https://foundation.graphql.org/news/2020/12/08/improving-latency-with-defer-and-stream-directives/

@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 でも公開されています。

package.json
{
  "dependencies": {
    "graphql": "15.4.0-experimental-stream-defer.1",
    "express-graphql": "0.12.0-experimental-stream-defer.1"
  }
}

https://www.npmjs.com/package/graphql/v/15.4.0-experimental-stream-defer.1
https://www.npmjs.com/package/express-graphql/v/0.12.0-experimental-stream-defer.1

対応するPRもあり、feedback welcomeとのことです。

https://github.com/graphql/graphql-js/pull/2839
https://github.com/graphql/express-graphql/pull/726

筆者は graphql-js, express-graphql のどちらの実装にも詳しくないので実装について言えることは無いですが、express-graphqlのコードを見るとasync iterableの for await が使われていて確かに今回の用途っぽいなと思いました。(感想)

https://github.com/graphql/express-graphql/blob/213d365143dd6ebf6e747393f9e8eee383f8611e/src/index.ts#L458-L491

またこの実装からもわかるように、レスポンスには Content-Type: multipart/mixed; が使われているようです。分割されたGraphQLレスポンスがmultipartで送られてくるのはなるほどと思いました。(感想)
graphql-helix というOSSは中身を見ていないですが既に @defer, @stream ディレクティブをサポートしているようです。

https://github.com/contrawork/graphql-helix

Server push and client pull. GraphQL Helix supports real-time requests with both subscriptions and @defer and @stream directives.

graphql-helixを使った @defer, @stream ディレクティブを動かしたサンプル実装のリポジトリも公開されています。

https://github.com/n1ru4l/graphql-bleeding-edge-playground

動かしてみる

先程あげたサンプル実装のリポジトリでは、サーバーサイドの実装と @defer, @stream に対応したGraphiQLの実装がされています。このリポジトリをForkしてReactアプリに雑に組み込んでみました。

https://github.com/adwd/graphql-bleeding-edge-playground/pull/1

シンプルに 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レスポンスが返ってきます。

header
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
body
---
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