淺談 GraphQL @defer @stream

C.T. Lin
9 min readJun 16, 2022

因為 GraphQL Working Group 近期的推動,GraphQL 的 @defer/@stream directive 提案又重新回到了開發者的視野。

目前對應的 RFC 仍為 Stage 1,但已經投入相當大量的規格制定工作與討論,應該蠻有機會持續推進。

GraphQL specification RFC 分成四個階段:
- Stage 0: Strawman
- Stage 1: Proposal
- Stage 2: Draft
- Stage 3: Accepted

起源

最早在 2016 時,當時人還在 Facebook 的 Lee Byron 就曾在 Conference 分享過 Facebook 內部正在實驗這兩個 directive:

React Europe 2016

雖然當時只是在描述概念跟使用方式,而忽略了許多實作上的細節,但已經給了大家很充分的想像。

例如,在欄位上標記 @defer 可以把回應拆成多個部分,被標記的部分會在後續回應中補上。例如以下這個 GraphQL query:

{
feed {
stories {
author { name }
message
comments @defer {
author { name }
message
}
}
}
}

會先拿回不包含 commentsstories

{
"feed": {
"stories" : [
{
"author": { "name": "Lee Byron" }
"message": "GraphQL est grand!"
}
]
}
}

然後後續會再收到包含 comments 的 patch:

{
"path": ["feed", "stories", 0, "comments"],
"data": [
{
"author": { "name": "Laney Kuenzel" },
"message": "J'aime GraphQL!"
}
}
}

這樣一來,第一包 data 就不會受限於整個 GraphQL 較慢的部分,而可以更快的呈現初始畫面給使用者。

@defer 的概念類似,也可以在 List 上標記 @stream 避免一次拿回整個 List 要花上太多的時間。例如,在上面範例中的 stories 欄位上標記 @stream

{
feed {
stories @stream {
author { name }
message
comments @defer {
author { name }
message
}
}
}
}

可以先拿回空的 array:

{
"feed": {
"stories" : []
}
}

再透過後續的 patch 拿到 array 中更多的 item:

{
"path": ["feed", "stories", 0],
"data": [
{
"author": { "name": "Lee Byron" }
"message": "GraphQL est grand!"
}
}
}

甚至是其中使用 @defer 處理的部分:

{
"path": ["feed", "stories", 0, "comments"],
"data": [
{
"author": { "name": "Laney Kuenzel" },
"message": "J'aime GraphQL!"
}
}
}

規格提案

在這許多年之間,雖然有不少 library、framework 有試圖去支援 @defer/@stream ,但因為這兩個 directive 一直沒出現在 GraphQL specification 裡面,大多只能持續維持實驗性質。

而中間因為 GraphQL 從 Facebook 轉移出來 GraphQL Foundation 等等因素,整個 GraphQL Working Group 延宕直到最近兩年才重新開始活躍。而 @defer 本身也在等待 Facebook 內部以及社群得出正面的實驗結果

2020 年底,GraphQL 發了「Improving Latency with @defer and @stream Directives」這篇文章,正式開始推動關於草案的回饋,以及推出實驗用的 GraphQL.jsexpress-graphql 版本,大幅的加速了整個進程。

可以看到目前的草案跟上方提到 2016 年原先的範例,有一些細節上的小差距,這是官方 RFC 上所使用當作範例的 @defer query:

query {
person(id: "cGVvcGxlOjE=") {
...HomeWorldFragment @defer(label: "homeWorldDefer")
name
films @stream(initialCount: 2, label: "filmsStream") {
title
}
}
}
fragment HomeWorldFragment on Person {
homeworld {
name
}
}

拿回的 Payload 1:

{
"data": {
"person": {
"name": "Luke Skywalker",
"films": [
{ "title": "A New Hope" },
{ "title": "The Empire Strikes Back" }
]
}
},
"hasNext": true
}

拿回的 Payload 2:

{
"label": "homeWorldDefer",
"path": ["person"],
"data": {
"homeworld": {
"name": "Tatooine"
}
},
"hasNext": true
}

很明顯的可以看出,其中多了 labelhasNext 可以幫助 client 去定位以及判斷是否已經拿完所有的 payload。

而在 @stream 的部分,一樣多了 labelhasNext 可以協助 client:

query {
person(id: "cGVvcGxlOjE=") {
...HomeWorldFragment @defer(label: "homeWorldDefer")
name
films @stream(initialCount: 2, label: "filmsStream") {
title
}
}
}
fragment HomeWorldFragment on Person {
homeworld {
name
}
}

拿回的 Payload 1:

{
"data": {
"person": {
"name": "Luke Skywalker",
"films": [
{ "title": "A New Hope" },
{ "title": "The Empire Strikes Back" }
]
}
},
"hasNext": true
}

拿回的 Payload 2:

{
"label": "filmsStream",
"path": ["person", "films", 2],
"data": {
"title": "Return of the Jedi"
},
"hasNext": true
}

拿回的 Payload 3:

{
"label": "filmsStream",
"path": ["person", "films", 3],
"data": {
"title": "Revenge of the Sith"
},
"hasNext": false
}

除此之外有 initialCount 也很方便,可以輕鬆決定第一個 payload 要包含多少個 array item,畢竟第一個畫面通常需要有一定筆數的 data。

Incremental Delivery over HTTP

雖然 GraphQL specification 沒有指定 transport protocols,但「Incremental Delivery over HTTP」這份有描述了接下來大部分 server 都會採用的 transfer-encoding: chunked。用這個方式可以保持 HTTP 的連線,每個 payload 準備好馬上傳輸,而且從古老的瀏覽器到現代的瀏覽器都有支援。

response body 則是使用 content-type: multipart/mixed,格式如下,批次中間使用 boundary 隔開:

---
Content-Type: application/json; charset=utf-8

{"data":{"hello":"Hello Rob"},"hasNext":true}
---
Content-Type: application/json; charset=utf-8

{"data":{"test":"Hello World"},"path":[],"hasNext":false}
-----

JavaScript 實作狀況

為了支援 @defer/@stream 語法以及 HTTP incremental delivery,目前的 library 跟 framework 都需要做一些小調整。

GraphQL.js 的部分,execute 的部分被改成能回傳 AsyncIterable,詳細可以看這隻 PR

而 server 部分,已經有實作相關功能的就是 express-graphql 以及 graphql-helix,大致上就是再把 AsyncIterable 處理成 multipart/mixed 的 HTTP 回應。

Apollo 的實作目前還在路上:

  • Apollo Client:有一個 open PR,不過在 GraphQL.js 穩定版本支援 @defer/@stream 之前,應該不會輕易合併釋出。
  • Apollo Server:因架構跟多種 HTTP server 耦合,打算在 v4 做大型的 breaking change 來支援 @defer/@stream,詳細可以看 roadmap

Facebook 自家的 Framework — Relay,貌似在更早之前就已經支援 @defer/@stream 了,最近也試圖相容最新的 proposal,不過並沒有提供任何 server 的方案。

因為 @defer/@stream 還在 specification draft 的階段,JavaScript 以外其他語言的支援狀況可以想見不會太好,不過後端語言的 GraphQL client 需要 @defer/@stream 的機會不多,未來社群搞定 GraphQL language 跟 GraphQL HTTP server 的部分就行了。

--

--

C.T. Lin

Architect @ Dcard. Author of Electron React Boilerplate and Bottender. JavaScript Developer.