Incremental Data Delivery with GraphQL defer and stream

  • このディレクティブの登場背景
  • ディレクティブの Spec
  • graphql-js を用いた利用サンプル

@defer / @stream とは何か

@defer@stream は共にデータの取得方法を制御するためのディレクティブだ。名前が示すとおり、クエリ全体から特定の箇所の読み込みを遅延させたり、ストリーミングさせることができる。2020 年末現在、GraphQL spec としては Stage 2(草案段階)であり、参照実装である graphql-js にも実装が存在している。

解決しようとしている課題

ディレクティブの詳細に入る前に、GraphQL を使ったアプリケーションが直面しやすい性能上の問題について触れておく。

Colocation = 画面のパーツと必要なデータ(フラグメント)をペアで管理する手法
  • 商品名や商品画像、定価といった基本的な属性の他に、商品の特別価格を表示する必要がある。特別価格の決定要因には展開中のキャンペーンやユーザーが保持しているクーポンなどがあり、計算コストが高い。
query ProductDetailQuery {
product(id: 100) {
id
name
imageURL
specialPrice # 計算が大変
}
}
画面上では、特別価格(specialPrice)も商品詳細で表示したい
特別価格の計算が終わるまでローディングが表示されてしまう
優先度に合わせて、逐次描画したい
  • この EC サイトのトップページは、現在おこなっているキャンペーン、商品カテゴリのリスト、ユーザーにおすすめの商品のリストを表示するものとする
初期描画にすべてのフラグメントが必要なわけではない
query ProductDetailQuery {
product(id: 100) {
id
name
imageUrl
}
}
# パフォーマンス観点でクエリを分けました!
query ProductDetailLazyQuery {
product(id: 100) {
id
specialPrice
}
}

Spec

ここからはディレクティブの仕様に話を移そう。一点補足だが、以下に書く内容は GraphQL Spec で提案されているものである。というのも、 @defer / @stream は Facebook Relay が独自に実装していたものがあり、Spec に提出された案も基本的に Relay の実装を元にしているのだが、現在は細かい点で差異があるためだ。

@defer

@defer は特定のフラグメントについて、結果取得を遅延させる。 @skip@include とは違い、フィールドには付与できない。

fragment ProductLazyFragment on Product {
specialPrice
}
# fragment spread の例
query ProductDetailQuery {
product(id: 100) {
id
name
...ProductDetailLazyQuery @defer
}
}
# inline fragment の例
query ProductDetailQuery {
product(id: 100) {
id
name
... on Product @defer {
specialPrice
}
}
}

@stream

@stream はリスト型のフィールドについて、データの取得を段階的に行えるようにするためのディレクティブである。以下のように利用する。

query ProductsQuery {
products(first: 50) {
nodes @stream(initialCount: 10) {
id
name
}
}
}

1 Request / Multiple Response

GraphQL サーバーは、必ずしも @defer@stream を実装する必要はない。実装していない場合、 @defer@stream のディレクティブは完全に無視されて、これまで通りすべてのデータの完成を待ってからクライアントに届ければいいだけだ。

query ProductDetailQuery {
product(id: 100) {
id
name
... on Product @defer {
specialPrice
}
}
}
// 1st payload
{ "data": { "product": { "id": 100, "name": "とても良い商品" } }, "hasNext": true }
// 2nd payload
{ "path": ["product"], "data": { "specialPrice": 2000 }, "hasNext": false }
type AsyncExecutionResult = {
data: any; // データ本体
hasNext: boolean; // 最後かどうか
path?: (string | number)[]; // 2つめ以降のペイロードの場合、パッチを当てるべき場所情報
label?: string; // リクエスト時に label パラメータを付与した場合に、その値が詰められる
};

graphql-js

GraphQL Spec の参照実装である graphql-js には v15.4.0 で @defer@stream が実装されている。ただし、2020 年 12 月現在では npm graphql@v^15.4.0 とするだけでは利用できず、 experimental-stream-defer をバージョンに付与してインストールする必要がある。

npm i graphql@v15.4.0-experimental-stream-defer.1
declare function graphql(
...args
): Promise<AsyncIterableIterator<any> | ExecutionResult<any, any>>;
type Product {
id: ID!
name: String!
price: Int!
specialPrice: Int ## 計算に時間がかかる
}
type Query {
products(first: Int!): [Product]
}
Schema Execution Example
動画はこちら

Transport (Server-Side)

Spec が規定するのは、あくまでリクエストとレスポンスの形式までであり、それらが実際にどのようなネットワークプロトコルの上でやりとりされるかは GraphQL の仕様の範疇ではない。

  • HTTP 1.1 の multipart/mixed Content Type
  • Web Socket
  • Server Sent Event
  • HTTP/3 の server side push
multipart/mixed で payload を送信する Express のサンプルコード

Transport (Client-Side)

クライアント側も multipart/mixed なレスポンスについて、ReadableStream を開いて、ペイロードを一つずつ yield するような generatorを準備しておく。

ブラウザ側で Payload を結合するサンプル

@defer / @stream の使い所

この 2 つのディレクティブは、何か新しい機能を実装するためのものというよりは、性能向上のためのものだ。

ライブラリの状況

主要な GraphQL クライアントライブラリの @defer / @stream への対応状況は下記のとおり。

  • Apollo Client: v3.5 のマイルストンで対応を表明している
  • Relay: Undocumented ながら実装はされている。ただし、現状の Spec と細部が多少ことなる( hasNextinitialCount など)

サマリ

  • @defer / @stream は GraphQL アプリケーションのパフォーマンス改善を行うためのもの
  • 「データ取得がウォーターフォールになってしまう箇所」に適用することで効果が見込める
  • Relay の実装をベースに GraphQL Spec で策定が進んでいてる。2020 年 12 月現在 Stage 2
  • graphql-js には experimental チャネルから利用可能だが、ライブラリの対応状況はまだまだ
  • express-graphal では Content-Type: multipart/mixed, Transfer-Encoding: chunked を使って HTTP 1.1 トランスポートを実装している

hasNext: true

特にクライアント側の処理について、もう少し踏み込んだ内容を書いていきたいのだけど、正直力付きてきているので、今回のエントリはここまでにする。

参考リンク

--

--

Front-end web developer. TypeScript, Angular and Vim, weapon of choice.

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Yosuke Kurami

Yosuke Kurami

Front-end web developer. TypeScript, Angular and Vim, weapon of choice.